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
+51
View File
@@ -0,0 +1,51 @@
# Auto-scaffold project workflow files on first use in an existing repo
## Problem
`/init` already scaffolds TODO.md / ROADMAP.md / docs/ **manually** (plus writes AGENTS.md
via the model). But when the user runs shiro against an *existing* project that has no
tracking files, nothing is generated automatically — the workflow policy block stays out of
the system prompt, no nudges ever fire, and the model has no task list context.
The user's ask: when the agent starts working in an existing repo that lacks
TODO.md / ROADMAP.md / docs/, generate them (also auto-write AGENTS.md), so the workflow
hooks in before the first turn.
## Scope
- Auto-generate TODO.md, ROADMAP.md, docs/, and AGENTS.md in an existing repo that has none
of them, before the first user turn.
- Model-driven content (the `/init` prompt pattern) rather than empty templates, so the
files reflect the actual project.
- Opt-out: `workflow.autoScaffold: false` disables; `workflow.enabled: false` stays the master
switch. Also bail if the repo already tracks anything (TODO/ROADMAP/docs or AGENTS.md) — a
repo that self-tracks does not need re-scaffolding.
- Generated files are surfaced as a `notice` event so the CLI/UI can show them.
- No overwriting: never touch an existing file.
## Files touched
- `src/scaffold.ts` — add `scaffoldMissingAuto(cwd, model)` that (a) checks git root,
(b) if no TODO/ROADMAP/docs/AGENTS.md exist, generates them via one model call
(reuse `INIT_PROMPT`-style tone, but cover all four files), writing directly.
- `src/session.ts` — in `send()` (first turn only, `workflow.autoScaffold !== false` and
`workflow.enabled !== false`), call `scaffoldMissingAuto` once; if anything was written,
set a flag so `systemFor()`'s `workflowPolicy()` sees the files right away, bump
`versions.workflow`, and yield a `notice`.
- `src/config.ts` — parse `workflow.autoScaffold` from config.
- `src/session.ts` `SessionOptions.workflow` — add `autoScaffold?: boolean` (default true).
- `test/scaffold.test.ts` (new or extend) — auto-scaffold on a repo with nothing; bail when
TODO.md exists; bail when AGENTS.md exists; opt-out flag.
- `docs/workflow.md` — document auto-scaffold + the flag.
## Verification
- `bun run typecheck` clean.
- `bun test` full suite green.
- New tests cover: auto-scaffold writes 4 files in a bare repo; existing TODO.md bails;
existing AGENTS.md bails; `autoScaffold:false` skips; notice event emitted.
- `bun run build` compiles.
## Risks / decisions
- One model call for all four files keeps it cheap and atomic-ish; content is project-specific.
- Runs once per session (flag), at first `send()` *before* the model's real turn, so the
system prompt and the turn see it. If the model call fails, degrade to the existing
`scaffoldWorkflowFiles` empty-template fallback — never fail the turn.
- Cwd-relative writes: resolve against the **git root**, not `process.cwd()` (matches
`workflowPolicy`). Nested-cwd runs still write at the repo root.
+1 -1
View File
@@ -47,7 +47,7 @@ Written by `/provider`, editable by hand. Every field is optional.
| `maxRetries` | retries per model call for transient failures. Default 3 |
| `maxSpendUsd` | session spend ceiling: warn at 80%, refuse the next turn at 100%. Headless exits non-zero naming the ceiling. Only enforced on priced models |
| `maxSpendPerTurn` | per-turn spend ceiling: a single turn past this line is stopped at a step boundary, even when the session ceiling is far away. Only enforced on priced models |
| `workflow` | project-driven workflow: `{ "enabled": true, "docsDir": "docs" }`. When the repo has TODO.md/ROADMAP.md/docs, the agent's prompt carries a workflow policy and the session nudges once when files change without the task list being updated. See [docs/workflow.md](workflow.md) |
| `workflow` | project-driven workflow: `{ "enabled": true, "docsDir": "docs", "autoScaffold": true }`. When the repo has TODO.md/ROADMAP.md/docs, the agent's prompt carries a workflow policy and the session nudges once when files change without the task list being updated. When the repo has none, the first turn auto-scaffolds them (model-generated, never overwriting). See [docs/workflow.md](workflow.md) |
| `subagentModel` | model id for `explore` subagents, which search rather than reason. Omit to share the parent's model. `/cost` reports subagent spend separately |
| `plugins` | which builtin plugins to enable. Omit for `["guard", "secrets", "protect", "time", "no-force-push", "no-net-pipe", "no-root", "no-env-write"]` |
| `toolSets` | optional tool sets beyond `core`: `edit-plus`, `nav`, `extra`, `git`, and `net`. Omit for the defaults; `net` is opt-in. See [tools](tools.md) |
+30 -2
View File
@@ -27,6 +27,31 @@ Bare repos (no TODO, ROADMAP, or docs) get no such block — the policy only
renders when the project itself tracks progress, so a throwaway directory does
not collect noise.
## Auto-scaffolding
When you run shiro against an *existing* repo that has none of the tracking
files (no TODO.md, no ROADMAP.md, no `docs/`, no AGENTS.md), the first turn
bootstraps them automatically: the agent investigates the repo and writes
project-specific TODO.md, ROADMAP.md, `docs/README.md`, and AGENTS.md before
answering. A notice reports what was written:
```
scaffolded project workflow files: TODO.md, ROADMAP.md, docs/, AGENTS.md
```
- **Never overwrites.** Any existing tracker (TODO.md, ROADMAP.md, `docs/`, or
AGENTS.md) at the git root means the repo already tracks itself — nothing is
created or touched.
- **Model-driven content.** The files use real project content (commands,
layout, conventions verified against the code) like `/init` does for
AGENTS.md. If the model call fails, it degrades to the empty-template
scaffold so the turn is never interrupted.
- **Write at the git root**, not the cwd — matches where the policy looks.
- Runs **once per session**, before the first real turn, so the policy and the
first nudge already see the files.
- The manual `/init` command still exists for when you want to write AGENTS.md
(and scaffold the trackers) on demand.
TODO.md and ROADMAP.md are also loaded into the conversation like instruction
files (`Project tracker (...)`), capped tighter than AGENTS.md so the agent
sees the shape of the work without filling its context. This mirrors the
@@ -56,11 +81,14 @@ Design constraints:
```yaml
workflow:
enabled: true # master switch; default true
docsDir: docs # where the project keeps developer docs; default 'docs'
enabled: true # master switch; default true
docsDir: docs # where the project keeps developer docs; default 'docs'
autoScaffold: true # write TODO/ROADMAP/docs/AGENTS.md in a bare repo on first turn; default true
```
`workflow.enabled: false` disables both the prompt policy and the nudge.
`workflow.autoScaffold: false` disables only the auto-bootstrap (the policy and
nudge still engage when the repo already tracks progress).
## /workflow
+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.
+5 -3
View File
@@ -74,15 +74,17 @@ test('/changes is undefined for a turn that wrote nothing', () =>
expect(session.lastTurnSummary()).toBeUndefined();
}));
test('system prompt is memoized until a volatile part changes', () => {
test('system prompt is memoized until a volatile part changes', async () => {
const session = new Session({
model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')) }),
askApproval: noop,
});
// private API is exercised through the public turn loop; assert the cache counts.
for (let i = 0; i < 3; i++) void session.estimatedTokens();
// force a miss then a few hits via send
void drain(session);
// force a miss then a few hits via send. Awaited: the turn loop must run to
// completion before the stats are read (the auto-scaffold adds an async step
// at the top of send, so a fire-and-forget drain races the first prompt build).
await drain(session);
const stats = session.promptCacheStats();
expect(stats.misses).toBeGreaterThanOrEqual(1);
expect(stats.hits).toBeGreaterThanOrEqual(0);
+131
View File
@@ -0,0 +1,131 @@
import { expect, test } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
import { scaffoldMissingAuto, projectTracksAt } from '../src/scaffold';
import { usageOf } from './helpers';
const usage = usageOf(5, 3);
function textParts(body: string): LanguageModelV4StreamPart[] {
return [
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: body },
{ type: 'text-end', id: '0' },
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
];
}
function stream(body: string) {
const parts = textParts(body);
return { stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }) };
}
function makeRepo(): { dir: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), 'shiro-scaffold-'));
const cleanup = () => rmSync(dir, { recursive: true, force: true });
return { dir, cleanup };
}
const ALL_FOUR = `===FILE TODO.md===
# TODO
## Now
- first
## Next
- second
===FILE ROADMAP.md===
# Roadmap
## Next
- plan
===FILE docs/README.md===
# Docs
Developer docs.
===FILE AGENTS.md===
# AGENTS
This project is a test.
`;
test('projectTracksAt is false for an empty repo and true when a tracker exists', async () => {
const { dir, cleanup } = makeRepo();
try {
expect(projectTracksAt(dir)).toBe(false);
await Bun.write(join(dir, 'TODO.md'), '# Todo\n');
expect(projectTracksAt(dir)).toBe(true);
} finally {
cleanup();
}
});
test('scaffoldMissingAuto writes all four files when the repo has none', async () => {
const { dir, cleanup } = makeRepo();
try {
const model = new MockLanguageModelV4({ doStream: async () => stream(ALL_FOUR) });
const written = await scaffoldMissingAuto(dir, model);
// Expect 4 files written.
expect(written.length).toBe(4);
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
expect(existsSync(join(dir, 'docs', 'README.md'))).toBe(true);
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true);
// Content came from the model, not the empty template.
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toContain('- first');
} finally {
cleanup();
}
});
test('scaffoldMissingAuto bails when a repo already tracks progress', async () => {
const { dir, cleanup } = makeRepo();
try {
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] existing\n');
const model = new MockLanguageModelV4({ doStream: async () => stream('') });
const written = await scaffoldMissingAuto(dir, model);
expect(written).toEqual([]);
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(false);
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toBe('# Todo\n- [ ] existing\n');
} finally {
cleanup();
}
});
test('scaffoldMissingAuto degrades to empty templates when the model fails', async () => {
const { dir, cleanup } = makeRepo();
try {
const model = new MockLanguageModelV4({ doStream: async () => { throw new Error('model down'); } });
const written = await scaffoldMissingAuto(dir, model);
// The fallback writes the empty-tracker templates so the session never dies.
expect(written.length).toBeGreaterThanOrEqual(3);
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
} finally {
cleanup();
}
});
test('scaffoldMissingAuto bails entirely when any tracking file exists', async () => {
const { dir, cleanup } = makeRepo();
try {
await Bun.write(join(dir, 'TODO.md'), 'precious');
const model = new MockLanguageModelV4({
doStream: async () =>
stream('===FILE TODO.md===\n# TODO\noverwrite me\n===FILE ROADMAP.md===\n# Roadmap\n'),
});
const written = await scaffoldMissingAuto(dir, model);
// A repo with any tracker is left alone — nothing new is written.
expect(written).toEqual([]);
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toBe('precious');
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(false);
} finally {
cleanup();
}
});
+73 -2
View File
@@ -2,7 +2,7 @@ import { usageOf } from './helpers';
import { expect, test } from 'bun:test';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Session } from '../src/session';
@@ -85,7 +85,14 @@ test('workflow disabled renders no policy even with a TODO.md', () =>
test('bare repo renders no workflow policy', () =>
inGitRepo(async () => {
const session = new Session({ model: new MockLanguageModelV4({ doStream: async () => stream([]) }), askApproval: async () => "deny" });
const session = new Session({
model: new MockLanguageModelV4({ doStream: async () => stream([]) }),
askApproval: async () => "deny",
// Without this, the first send() would auto-scaffold TODO.md/etc into
// the empty repo — that is the point of the feature, but this test is
// specifically about the policy wire-up, so keep the repo bare.
workflow: { autoScaffold: false },
});
const status = session.workflowStatus();
expect(status.hasTodo).toBe(false);
expect(status.hasRoadmap).toBe(false);
@@ -277,4 +284,68 @@ test('todo_write in a turn suppresses that turn\'s nudge', async () => {
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('first turn auto-scaffolds TODO.md/ROADMAP.md/docs/AGENTS.md in a bare repo', async () => {
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-autoscaffold'));
const orig = process.cwd();
process.chdir(dir);
try {
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
// The model returns the four workflow files on the scaffold call, then a
// plain text answer for the actual turn.
let call = 0;
const session = new Session({
model: new MockLanguageModelV4({
doStream: async () => {
call++;
if (call === 1) {
// Scaffold call: emit text-delta chunks carrying all four file blocks.
const body = [
'===FILE TODO.md===',
'# TODO\n\n## Now\n- first',
'===FILE ROADMAP.md===',
'# Roadmap\n\n## Next\n- plan',
'===FILE docs/README.md===',
'# Docs\n\nDeveloper docs.',
'===FILE AGENTS.md===',
'# AGENTS\n\nA test project.',
].join('\n');
return {
stream: simulateReadableStream({
chunks: [
{ type: 'text-start', id: 's' },
{ type: 'text-delta', id: 's', delta: body },
{ type: 'text-end', id: 's' },
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
],
chunkDelayInMs: null,
initialDelayInMs: null,
}),
};
}
// The actual turn: no tool calls, just text.
return stream(text('ok'));
},
}),
askApproval: async () => 'once',
});
const notices: string[] = [];
for await (const ev of session.send('hello')) {
if (ev.type === 'notice') notices.push(ev.text);
}
// The scaffold notice fired before the real turn.
expect(notices.some((n) => n.startsWith('scaffolded project workflow files'))).toBe(true);
// All four files now exist at the git root.
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
expect(existsSync(join(dir, 'docs', 'README.md'))).toBe(true);
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true);
} finally {
process.chdir(orig);
rmSync(dir, { recursive: true, force: true });
}
});