From 7d77408a4108c7ba63ceae66195fc01c275ad551 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 11 Sep 2026 18:51:25 +0700 Subject: [PATCH] feat: implement auto-scaffolding for project workflow files in bare repos --- .hermes/plans/auto-scaffold.md | 51 +++++++++++++ docs/configuration.md | 2 +- docs/workflow.md | 32 +++++++- src/config.ts | 3 + src/scaffold.ts | 119 +++++++++++++++++++++++++++++- src/session.ts | 29 ++++++++ test/new-features.test.ts | 8 +- test/scaffold.test.ts | 131 +++++++++++++++++++++++++++++++++ test/workflow.test.ts | 75 ++++++++++++++++++- 9 files changed, 441 insertions(+), 9 deletions(-) create mode 100644 .hermes/plans/auto-scaffold.md create mode 100644 test/scaffold.test.ts diff --git a/.hermes/plans/auto-scaffold.md b/.hermes/plans/auto-scaffold.md new file mode 100644 index 0000000..6173e69 --- /dev/null +++ b/.hermes/plans/auto-scaffold.md @@ -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. \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 24e3f4b..cd40020 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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) | diff --git a/docs/workflow.md b/docs/workflow.md index 9b98d7b..9d09514 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -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 diff --git a/src/config.ts b/src/config.ts index 3ddb768..07555cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { 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 } : {}), }, } : {}), diff --git a/src/scaffold.ts b/src/scaffold.ts index b65a907..793b081 100644 --- a/src/scaffold.ts +++ b/src/scaffold.ts @@ -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 { + 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 ===', + 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 === 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); } \ No newline at end of file diff --git a/src/session.ts b/src/session.ts index 8b31b1a..d8d158e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { + // 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. diff --git a/test/new-features.test.ts b/test/new-features.test.ts index bab21c2..1036fa3 100644 --- a/test/new-features.test.ts +++ b/test/new-features.test.ts @@ -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); diff --git a/test/scaffold.test.ts b/test/scaffold.test.ts new file mode 100644 index 0000000..ff91dbb --- /dev/null +++ b/test/scaffold.test.ts @@ -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(); + } +}); \ No newline at end of file diff --git a/test/workflow.test.ts b/test/workflow.test.ts index 44bb603..19d2068 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -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 }); + } }); \ No newline at end of file