feat: implement auto-scaffolding for project workflow files in bare repos
This commit is contained in:
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user