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 | | `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 | | `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 | | `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 | | `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"]` | | `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) | | `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 renders when the project itself tracks progress, so a throwaway directory does
not collect noise. 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 TODO.md and ROADMAP.md are also loaded into the conversation like instruction
files (`Project tracker (...)`), capped tighter than AGENTS.md so the agent files (`Project tracker (...)`), capped tighter than AGENTS.md so the agent
sees the shape of the work without filling its context. This mirrors the sees the shape of the work without filling its context. This mirrors the
@@ -56,11 +81,14 @@ Design constraints:
```yaml ```yaml
workflow: workflow:
enabled: true # master switch; default true enabled: true # master switch; default true
docsDir: docs # where the project keeps developer docs; default 'docs' 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.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 ## /workflow
+3
View File
@@ -31,6 +31,8 @@ export type Config = {
enabled?: boolean; enabled?: boolean;
/** Directory the project keeps its developer docs in, for the docs-driven rule. Default 'docs'. */ /** Directory the project keeps its developer docs in, for the docs-driven rule. Default 'docs'. */
docsDir?: string; 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. */ /** Model id for subagents; omit to share the parent's. */
subagentModel?: string; subagentModel?: string;
@@ -132,6 +134,7 @@ export async function loadConfig(): Promise<Config> {
workflow: { workflow: {
...(typeof file.workflow.enabled === 'boolean' ? { enabled: file.workflow.enabled } : {}), ...(typeof file.workflow.enabled === 'boolean' ? { enabled: file.workflow.enabled } : {}),
...(file.workflow.docsDir ? { docsDir: file.workflow.docsDir } : {}), ...(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 { 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. * Scaffolds the project-workflow files when a repo has none.
@@ -80,4 +84,117 @@ export function scaffoldWorkflowFiles(cwd = process.cwd()): string[] {
} }
return written; 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; enabled?: boolean;
/** Where the project keeps developer docs. Default 'docs'. */ /** Where the project keeps developer docs. Default 'docs'. */
docsDir?: string; 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). */ /** Disable background auto-learn (tests). */
disableAutoLearn?: boolean; disableAutoLearn?: boolean;
@@ -251,6 +253,8 @@ export class Session {
private turnWrote = false; private turnWrote = false;
/** How many times this session has nudged about the task list; capped at 3. */ /** How many times this session has nudged about the task list; capped at 3. */
private workflowNudgeCount = 0; 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. */ /** Line count of TODO.md at last check, for /workflow. */
private workflowTodoLines = 0; private workflowTodoLines = 0;
private workflowRoadmapLines = 0; private workflowRoadmapLines = 0;
@@ -922,6 +926,31 @@ export class Session {
} }
async *send(userText: string): AsyncGenerator<AgentEvent> { 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 // 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 // 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. // 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(); 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({ const session = new Session({
model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')) }), model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')) }),
askApproval: noop, askApproval: noop,
}); });
// private API is exercised through the public turn loop; assert the cache counts. // private API is exercised through the public turn loop; assert the cache counts.
for (let i = 0; i < 3; i++) void session.estimatedTokens(); for (let i = 0; i < 3; i++) void session.estimatedTokens();
// force a miss then a few hits via send // force a miss then a few hits via send. Awaited: the turn loop must run to
void drain(session); // 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(); const stats = session.promptCacheStats();
expect(stats.misses).toBeGreaterThanOrEqual(1); expect(stats.misses).toBeGreaterThanOrEqual(1);
expect(stats.hits).toBeGreaterThanOrEqual(0); 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 { expect, test } from 'bun:test';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider'; 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 { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { Session } from '../src/session'; 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', () => test('bare repo renders no workflow policy', () =>
inGitRepo(async () => { 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(); const status = session.workflowStatus();
expect(status.hasTodo).toBe(false); expect(status.hasTodo).toBe(false);
expect(status.hasRoadmap).toBe(false); expect(status.hasRoadmap).toBe(false);
@@ -277,4 +284,68 @@ test('todo_write in a turn suppresses that turn\'s nudge', async () => {
} finally { } finally {
rmSync(dir, { recursive: true, force: true }); 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 });
}
}); });