feat: implement auto-scaffolding for project workflow files in bare repos
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-11 18:52:50 +07:00
parent 44bb4bebf1
commit 7d77408a41
9 changed files with 441 additions and 9 deletions
+3
View File
@@ -31,6 +31,8 @@ export type Config = {
enabled?: boolean;
/** Directory the project keeps its developer docs in, for the docs-driven rule. Default 'docs'. */
docsDir?: string;
/** Auto-write TODO.md/ROADMAP.md/docs/AGENTS.md on first turn when the repo has none. Default true. */
autoScaffold?: boolean;
};
/** Model id for subagents; omit to share the parent's. */
subagentModel?: string;
@@ -132,6 +134,7 @@ export async function loadConfig(): Promise<Config> {
workflow: {
...(typeof file.workflow.enabled === 'boolean' ? { enabled: file.workflow.enabled } : {}),
...(file.workflow.docsDir ? { docsDir: file.workflow.docsDir } : {}),
...(typeof file.workflow.autoScaffold === 'boolean' ? { autoScaffold: file.workflow.autoScaffold } : {}),
},
}
: {}),
+118 -1
View File
@@ -1,5 +1,9 @@
import { join } from 'node:path';
import { join, dirname } from 'node:path';
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
import { streamText } from 'ai';
import type { LanguageModel } from 'ai';
/** True when the repo holds the tracking files the workflow expects. */
/**
* Scaffolds the project-workflow files when a repo has none.
@@ -80,4 +84,117 @@ export function scaffoldWorkflowFiles(cwd = process.cwd()): string[] {
}
return written;
}
/**
* The set of workflow files the agent treats as "the project tracks itself".
* Auto-scaffolding only runs when none of these exist — a repo that already
* tracks progress keeps what it has.
*/
const TRACKING_NAMES = ['TODO.md', 'ROADMAP.md', 'AGENTS.md'] as const;
/** True when any tracking/instruction file exists at the git root. */
export function projectTracksAt(root: string, docsDir = 'docs'): boolean {
for (const name of TRACKING_NAMES) {
if (existsSync(join(root, name))) return true;
}
return existsSync(join(root, docsDir));
}
/**
* Writes the workflow files a project-driven session expects when the repo
* has none — before the first turn, so the system prompt's workflow policy and
* the very first nudge see them. Uses the model to write project-specific
* content (TODO.md, ROADMAP.md, docs/, AGENTS.md). Never overwrites.
*
* Returns the relative paths written. On model failure it degrades to the
* empty-template scaffold so the *session* never fails — a missing model
* should not break a turn.
*/
export async function scaffoldMissingAuto(
root: string,
model: LanguageModel,
opts: { docsDir?: string; maxRetries?: number } = {},
): Promise<string[]> {
const docsDir = opts.docsDir ?? 'docs';
const todo = join(root, 'TODO.md');
const roadmap = join(root, 'ROADMAP.md');
const docs = join(root, docsDir);
const agents = join(root, 'AGENTS.md');
const hasTodo = existsSync(todo);
const hasRoadmap = existsSync(roadmap);
const hasDocs = existsSync(docs);
const hasAgents = existsSync(agents);
// A repo that already tracks anything is left alone.
if (hasTodo || hasRoadmap || hasDocs || hasAgents) return [];
const missing = {
todo: !hasTodo,
roadmap: !hasRoadmap,
docs: !hasDocs,
agents: !hasAgents,
};
// One model call for the whole set keeps it cheap; the prompt asks for real
// content derived from the repo (the /init prompt does the same for AGENTS.md).
// Uses a stream so it works with any model (mocks included) — a text-only
// generateText would need doGenerate, which not every model implements.
let content = '';
try {
const missingList = [
missing.todo ? 'TODO.md — a task list: Now / Next / Maintenance sections, one item per line' : '',
missing.roadmap ? 'ROADMAP.md — where the project is heading: Next section, what is built and what is deliberately declined' : '',
missing.docs ? 'docs/ with a README.md — developer documentation' : '',
missing.agents ? 'AGENTS.md — orientation for a coding agent joining cold: what the project is, install/build/test/typecheck commands, layout, conventions, surprising things' : '',
].filter(Boolean).join('; ');
const { textStream: ts } = await streamText({
model,
system:
'You are bootstrapping project workflow files for a codebase that has none. ' +
'Write only the files, with real content derived from the repo. Keep each file concise and honest: ' +
'TODO.md (Now/Next/Maintenance), ROADMAP.md (Next), docs/README.md, AGENTS.md. ' +
'Do not invent commands you cannot verify. Output must be plain text with no markdown fences — ' +
'the exact file contents for each file, separated by a line that reads exactly: ===FILE <path>===',
prompt: `Look at this repo and write the missing workflow files. Missing: ${missingList}.`,
maxRetries: opts.maxRetries ?? 3,
});
for await (const chunk of ts) content += chunk;
} catch {
// fall through to the empty-template scaffold below
}
if (content) {
const written: string[] = [];
// Split on the ===FILE <path>=== marker; each block is one file.
const blocks = content.split(/^===FILE\s+(.+?)\s*===$/m);
for (let i = 1; i + 1 < blocks.length; i += 2) {
const rel = blocks[i]!.trim();
const body = blocks[i + 1]!.trim();
if (!(rel.startsWith('TODO.md') || rel.startsWith('ROADMAP.md') || rel.startsWith('docs/') || rel === 'AGENTS.md')) continue;
const abs = join(root, rel);
if (existsSync(abs)) continue;
try {
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, body.endsWith('\n') ? body : body + '\n');
written.push(rel);
} catch {
// a read-only workspace keeps what it has
}
}
// If the model produced at least one real file, that's the win; fill any
// still-missing standard files with the empty templates.
if (written.length > 0) {
const templated = scaffoldWorkflowFiles(root);
const union = [...written];
for (const t of templated) {
if (!union.includes(t)) union.push(t);
}
return union;
}
}
// Model failed or wrote nothing: plain empty templates, never fail the turn.
return scaffoldWorkflowFiles(root);
}
+29
View File
@@ -121,6 +121,8 @@ export type SessionOptions = {
enabled?: boolean;
/** Where the project keeps developer docs. Default 'docs'. */
docsDir?: string;
/** Auto-write TODO.md/ROADMAP.md/docs/AGENTS.md on the first turn when the repo has none. Default true. */
autoScaffold?: boolean;
};
/** Disable background auto-learn (tests). */
disableAutoLearn?: boolean;
@@ -251,6 +253,8 @@ export class Session {
private turnWrote = false;
/** How many times this session has nudged about the task list; capped at 3. */
private workflowNudgeCount = 0;
/** Auto-scaffold runs once per session on the first turn; this prevents a repeat. */
private autoScaffoldDone = false;
/** Line count of TODO.md at last check, for /workflow. */
private workflowTodoLines = 0;
private workflowRoadmapLines = 0;
@@ -922,6 +926,31 @@ export class Session {
}
async *send(userText: string): AsyncGenerator<AgentEvent> {
// On the first turn of a fresh session in an existing repo with no tracking
// files, bootstrap the workflow files (TODO.md / ROADMAP.md / docs/ / AGENTS.md)
// so the policy and nudges engage immediately. Runs once, before the model's
// real turn, and never fails the turn (degraded inside scaffoldMissingAuto).
if (!this.autoScaffoldDone && this.opts.workflow?.enabled !== false && this.opts.workflow?.autoScaffold !== false) {
this.autoScaffoldDone = true;
try {
const root = this.gitRoot();
if (root) {
const { scaffoldMissingAuto } = await import('./scaffold');
const written = await scaffoldMissingAuto(root, this.model, {
docsDir: this.opts.workflow?.docsDir,
});
if (written.length > 0) {
// The policy now finds the tracker files; drop the stale prompt cache
// and re-render so this turn's system prompt already carries them.
this.promptCache = undefined;
this.versions.workflow = (this.versions.workflow ?? 0) + 1;
yield { type: 'notice', text: `scaffolded project workflow files: ${written.join(', ')}` };
}
}
} catch {
// scaffolding must never break a turn
}
}
// The ceiling is checked before the model is: a turn started past the limit
// would spend money the caller said not to. An unpriced model cannot be
// measured, so it is never refused here — the ceiling simply cannot see it.