framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
218 lines • 8.3 kB
JavaScript
import { join } from 'node:path';
import { THE_FRAMEWORK_DIR } from './framework-dir.js';
import { nodeStoreFs } from './store/index.js';
/**
* The `.the-framework/LOGS.md` project log (#378): a human-readable markdown
* record of every loop, prompt, and build run in a project. Pure core over the
* same {@link StoreFs} seam as the run store; the run-lifecycle wiring and UI
* land in later issues (#379/#380).
*/
/** The directory, under the project root, that holds the log. Defined in its own node-free
* module (#874) so the browser-reachable preset registry can share it; re-exported here
* because this is where every existing import site expects to find it. */
export { THE_FRAMEWORK_DIR };
/** The markdown log file name. */
export const LOGS_FILE = 'LOGS.md';
/**
* The `.the-framework/.gitignore` that keeps run state transient (#313): the dir
* holds both the committed DB (LOGS.md) and the transient run logs, so this
* ignores everything except LOGS.md and itself.
*
* An allow-list, so anything else meant to be committed needs its own negation —
* see `CONVERSATIONS_GITIGNORE` (#908), which install appends to this.
*/
export const LOGS_GITIGNORE = '# The Framework: the committed project DB; session state is transient.\n*\n!.gitignore\n!LOGS.md\n';
const KINDS = ['loop', 'prompt', 'build'];
const STATUSES = ['done', 'stopped', 'failed', 'running'];
/** Heading field separator: a middle dot (U+00B7) with a space on each side. */
const SEP = ' · ';
/**
* Escape a free-text field so it stays on its own line (#897). A title is the run's prompt and a
* prompt bullet is agent text, but the file is committed history parsed line by line: an unescaped
* newline spills the rest of the prompt into the file, where a `## ` line forges an entry and a
* `- status: ` line rewrites one. Reversed by {@link decodeField}.
*/
function encodeField(value) {
return value.replace(/\\/g, '\\\\').replace(/\r\n|\r|\n/g, '\\n');
}
/** Reverse {@link encodeField}. Entries written before #897 are unescaped, so a literal `\n` in
* one of them decodes to a newline; harmless next to reading the rest of the prompt as entries. */
function decodeField(value) {
return value.replace(/\\(\\|n)/g, (_, char) => (char === 'n' ? '\n' : '\\'));
}
/** The one-time first line of the file, written on the first append. */
export const LOGS_HEADER = '# The Framework logs\n';
/** The log path under `cwd`. */
export function logsPath(cwd) {
return join(cwd, THE_FRAMEWORK_DIR, LOGS_FILE);
}
/** The `.gitignore` path under `cwd`'s `.the-framework/`. */
export function gitignorePath(cwd) {
return join(cwd, THE_FRAMEWORK_DIR, '.gitignore');
}
/**
* The single-line `- key: ` fields, in render order, each owning both directions of its own
* encoding. render and parse both walk this one list, so a field's name, prefix, and escaping
* can never drift between the two sides — the file's one real hazard when they were hand-mirrored.
* The heading and the `- prompts:` nested list are structurally different and stay bespoke.
*/
const LOG_FIELDS = [
{
prefix: '- status: ',
render: entry => entry.status,
parse: (body, draft) => {
draft.status = body.trim();
},
},
{
prefix: '- run: ',
render: entry => (entry.id ? encodeField(entry.id) : undefined),
parse: (body, draft) => {
draft.id = decodeField(body.trim());
},
},
{
prefix: '- session: ',
render: entry => entry.sessionId === undefined
? undefined
: entry.sessionLink
? `[${entry.sessionId}](${entry.sessionLink})`
: entry.sessionId,
parse: (body, draft) => {
const value = body.trim();
const linked = /^\[(.+)\]\((.+)\)$/.exec(value);
if (linked) {
draft.sessionId = linked[1];
draft.sessionLink = linked[2];
}
else {
draft.sessionId = value;
}
},
},
{
prefix: '- name: ',
render: entry => (entry.sessionName ? encodeField(entry.sessionName) : undefined),
parse: (body, draft) => {
draft.sessionName = decodeField(body.trim());
},
},
{
prefix: '- branch: ',
render: entry => (entry.branch ? encodeField(entry.branch) : undefined),
parse: (body, draft) => {
draft.branch = decodeField(body.trim());
},
},
];
/** Markdown for one entry, starting at `## ` (no file header, no blank lines around it). */
export function renderLogEntry(entry) {
const lines = [`## ${entry.at}${SEP}${entry.kind}${SEP}${encodeField(entry.title)}`, ''];
for (const field of LOG_FIELDS) {
const body = field.render(entry);
if (body !== undefined)
lines.push(`${field.prefix}${body}`);
}
if (entry.prompts && entry.prompts.length > 0) {
lines.push('- prompts:');
for (const prompt of entry.prompts)
lines.push(` - ${encodeField(prompt)}`);
}
return lines.join('\n');
}
/**
* Parse every entry out of the markdown, in file order (append order, so
* oldest-first). Forgiving: a malformed or torn entry is skipped, never thrown.
*/
export function parseLogs(md) {
const entries = [];
let block;
const flush = () => {
const entry = block && parseEntry(block);
if (entry)
entries.push(entry);
block = undefined;
};
for (const line of md.split('\n')) {
if (line.startsWith('## ')) {
flush();
block = [line];
}
else if (block) {
block.push(line);
}
// Anything before the first `## ` (the file header) is ignored.
}
flush();
return entries;
}
/** Parse one `## `-headed block; `undefined` when a required field is missing/invalid. */
function parseEntry(lines) {
const heading = lines[0]?.slice('## '.length) ?? '';
const parts = heading.split(SEP);
const at = parts[0];
const kind = parts[1];
// Re-join the rest so a title containing the separator survives.
const title = decodeField(parts.slice(2).join(SEP));
if (!at || !kind || !title || !KINDS.includes(kind))
return undefined;
const draft = {};
let prompts;
let inPrompts = false;
for (const line of lines.slice(1)) {
if (inPrompts && line.startsWith(' - ')) {
prompts.push(decodeField(line.slice(' - '.length)));
continue;
}
inPrompts = false;
if (line.trim() === '- prompts:') {
prompts = [];
inPrompts = true;
continue;
}
const field = LOG_FIELDS.find(f => line.startsWith(f.prefix));
if (field)
field.parse(line.slice(field.prefix.length), draft);
}
if (!draft.status || !STATUSES.includes(draft.status))
return undefined;
const entry = {
at,
kind: kind,
title,
status: draft.status,
};
if (draft.id)
entry.id = draft.id;
if (draft.sessionId)
entry.sessionId = draft.sessionId;
if (draft.sessionLink)
entry.sessionLink = draft.sessionLink;
if (draft.sessionName)
entry.sessionName = draft.sessionName;
if (draft.branch)
entry.branch = draft.branch;
if (prompts && prompts.length > 0)
entry.prompts = prompts;
return entry;
}
/**
* Append one entry to `.the-framework/LOGS.md`, creating the dir and the
* one-time file header when absent. A raw write (may reject); the caller
* decides best-effort.
*/
export async function appendLog(cwd, entry, fs = nodeStoreFs()) {
await fs.mkdir(join(cwd, THE_FRAMEWORK_DIR));
const path = logsPath(cwd);
if (!(await fs.exists(path)))
await fs.write(path, LOGS_HEADER);
await fs.append(path, '\n' + renderLogEntry(entry) + '\n');
}
/** Read the project log, newest-first (append order reversed). Missing file yields `[]`. */
export async function readLogs(cwd, fs = nodeStoreFs()) {
const path = logsPath(cwd);
if (!(await fs.exists(path)))
return [];
return parseLogs(await fs.read(path)).reverse();
}
//# sourceMappingURL=logs.js.map