workflow: project-driven agent workflow (docs-first, TODO/ROADMAP tracking, verify-before-done)
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s

- system prompt gains a Project workflow block when the repo tracks its own
  progress (TODO.md/ROADMAP.md/docs): read the task list first and keep it
  current, spec-first for non-trivial work, complete unit tests, verify before
  declaring done
- TODO.md + ROADMAP.md loaded as 'Project tracker' instruction blocks, capped
  tighter than AGENTS.md so the agent sees the shape without drowning in it
- one soft nudge per session when a turn writes files without touching the
  task list; suppressed when todo_write was called; never a gate
- /workflow command + panel showing TODO/ROADMAP/docs presence, line/file
  counts, nudge state
- config: workflow.enabled (default true), workflow.docsDir (default docs)
- prompt-cache version key extended with wf: so toggling the workflow busts
  the cached system prompt
- docs: docs/workflow.md, configuration table row, ROADMAP + TODO entries
- tests: test/workflow.test.ts (9 tests)

830 tests pass, typecheck clean, build green, live headless demo verified
This commit is contained in:
asepharyana
2026-09-09 19:00:22 +07:00
parent e709737df1
commit a3739eb628
14 changed files with 652 additions and 10 deletions
+1
View File
@@ -339,6 +339,7 @@ const session = new Session({
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
...(cfg.maxSpendPerTurn !== undefined ? { maxSpendPerTurn: cfg.maxSpendPerTurn } : {}),
...(cfg.workflow !== undefined ? { workflow: cfg.workflow } : {}),
extraTools: {
...(mcp?.tools ?? {}),
...externalTools.tools,
+4
View File
@@ -31,6 +31,7 @@ export type CommandAction =
| { type: 'changes' }
| { type: 'search'; query: string }
| { type: 'fork' }
| { type: 'workflow' }
/** A custom command from a markdown file, expanded against its arguments. */
| { type: 'custom'; command: CustomCommand; args: string[] }
| { type: 'unknown'; name: string };
@@ -71,6 +72,7 @@ export const COMMANDS: CommandSpec[] = [
{ name: 'changes', summary: 'show what the last turn changed on disk' },
{ name: 'search', arg: '<query>', summary: 'search saved sessions for a phrase' },
{ name: 'fork', summary: 'fork the session at the last turn boundary (keeps the original)' },
{ name: 'workflow', summary: 'show project workflow state: TODO/ROADMAP tracking, docs, nudges' },
{ name: 'clear', summary: 'clear the transcript and history' },
{ name: 'exit', aliases: ['quit'], summary: 'quit' },
];
@@ -245,6 +247,8 @@ export function parseCommand(raw: string, custom: readonly CustomCommand[] = [])
return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search <query>' };
case 'fork':
return { type: 'fork' };
case 'workflow':
return { type: 'workflow' };
default: {
const cmd = custom.find((c) => c.name === name);
return cmd ? { type: 'custom', command: cmd, args: arg ? arg.split(/\s+/) : [] } : { type: 'unknown', name };
+15
View File
@@ -25,6 +25,13 @@ export type Config = {
maxSpendUsd?: number;
/** USD ceiling per individual turn: abort a step if the turn's delta exceeds this. */
maxSpendPerTurn?: number;
/** Project-driven workflow: TODO.md/ROADMAP.md tracking, docs-driven dev, verify-before-done. */
workflow?: {
/** Master switch. Default true (nudges and prompt policy). */
enabled?: boolean;
/** Directory the project keeps its developer docs in, for the docs-driven rule. Default 'docs'. */
docsDir?: string;
};
/** Model id for subagents; omit to share the parent's. */
subagentModel?: string;
/** Default agent variant name. */
@@ -116,6 +123,14 @@ export async function loadConfig(): Promise<Config> {
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}),
...(typeof file.maxSpendUsd === 'number' && file.maxSpendUsd > 0 ? { maxSpendUsd: file.maxSpendUsd } : {}),
...(typeof file.maxSpendPerTurn === 'number' && file.maxSpendPerTurn > 0 ? { maxSpendPerTurn: file.maxSpendPerTurn } : {}),
...(file.workflow !== undefined
? {
workflow: {
...(typeof file.workflow.enabled === 'boolean' ? { enabled: file.workflow.enabled } : {}),
...(file.workflow.docsDir ? { docsDir: file.workflow.docsDir } : {}),
},
}
: {}),
...(file.subagentModel ? { subagentModel: file.subagentModel } : {}),
...(file.agent ? { agent: file.agent } : {}),
...(file.thinking ? { thinking: file.thinking } : {}),
+22 -8
View File
@@ -3,6 +3,10 @@ import { dirname, join, resolve } from 'node:path';
const NAMES = ['AGENTS.md', 'CLAUDE.md', '.shiro.md'];
/** Cap per file so one huge doc cannot crowd out the conversation. */
const MAX_CHARS = 12_000;
/** Documents that track the project's own progress, loaded like instructions but capped tighter. */
const TRACKER_NAMES = ['TODO.md', 'ROADMAP.md'];
/** Trackers get less room: the model needs the shape, not every item. */
const TRACKER_MAX_CHARS = 6_000;
export type Instructions = { path: string; text: string }[];
@@ -34,6 +38,18 @@ export async function loadInstructions(cwd = process.cwd()): Promise<Instruction
const text = (await file.text()).trim();
if (text) found.push({ path, text: text.slice(0, MAX_CHARS) });
}
// Project trackers ride along from the same dirs, capped tighter. They are
// project-relative progress files (git root down to cwd), so the model sees
// the current TODO/ROADMAP before it picks up work.
for (const name of TRACKER_NAMES) {
const path = join(d, name);
if (seen.has(path)) continue;
const file = Bun.file(path);
if (!(await file.exists())) continue;
seen.add(path);
const text = (await file.text()).trim();
if (text) found.push({ path, text: text.slice(0, TRACKER_MAX_CHARS) });
}
}
return found;
}
@@ -42,15 +58,13 @@ export function formatInstructions(instructions: Instructions, cwd = process.cwd
if (instructions.length === 0) return '';
const blocks = instructions.map(({ path, text }) => {
const label = path.startsWith(cwd) ? path.slice(cwd.length + 1) || path : path;
return `--- ${label} ---\n${text}`;
const isTracker = TRACKER_NAMES.some((n) => path.endsWith(n));
const title = isTracker
? `Project tracker (${label}) — current progress. Read before starting work and keep it current as you go.`
: `Project instructions (${label}) — standing orders from the user; they override your defaults but never your safety rules.`;
return `--- ${title} ---\n${text}`;
});
return [
'',
'Project instructions (from the files below). Treat these as standing orders from the user;',
'they override your defaults but never your safety rules.',
'',
...blocks,
].join('\n');
return ['', ...blocks].join('\n');
}
export const INSTRUCTION_NAMES = NAMES;
+5
View File
@@ -22,6 +22,8 @@ export type PromptParts = {
mcpServers?: readonly string[];
/** Ignore-aware workspace file list injected at boot (gitignore-respected, capped). */
workspaceFiles?: readonly string[];
/** Project-driven workflow policy block. Rendered when the project tracks its own progress. */
workflowPolicy?: string;
};
type ToolDoc = { name: string; line: string };
@@ -169,6 +171,7 @@ export function systemPrompt(parts: PromptParts): string {
plugins = '',
availableTools,
canAsk = false,
workflowPolicy = '',
} = parts;
const toolNames = availableTools ?? TOOL_DOCS.map((d) => d.name);
@@ -225,6 +228,8 @@ ${renderTools(toolNames)}${mcpServers.length > 0 ? `\n\nMCP servers (${mcpServer
How to work
${workflow}
${workflowPolicy ? `\nProject workflow (this repo tracks its own progress)
${workflowPolicy}` : ''}
When something fails
${recovery}
+172 -1
View File
@@ -23,6 +23,8 @@ import { createSkillTool, renderSkills, type Skill } from './skills';
import { suggestSkillsFromTranscript, writeAutoSkill } from './skill-learner';
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
import { onBeforeWrite, SnapshotStack, type FileState } from './snapshot';
import { dirname, join, resolve } from 'node:path';
import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
export type ApprovalRequest = {
approvalId: string;
@@ -110,6 +112,13 @@ export type SessionOptions = {
onNotice?: (text: string) => void;
/** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */
workspaceFiles?: readonly string[];
/** Project-driven workflow: TODO/ROADMAP tracking + verify-before-done nudges. */
workflow?: {
/** Master switch. Default true. */
enabled?: boolean;
/** Where the project keeps developer docs. Default 'docs'. */
docsDir?: string;
};
/** Disable background auto-learn (tests). */
disableAutoLearn?: boolean;
};
@@ -223,7 +232,7 @@ export class Session {
private learnTurns = 0;
private lastLearnLen = 0;
/** Versions of the volatile prompt parts; a change busts the system-prompt cache. */
private readonly versions = { notebook: 0, memory: 0, skills: 0, plugins: 0, tools: 0, workspace: 0 };
private readonly versions = { notebook: 0, memory: 0, skills: 0, plugins: 0, tools: 0, workspace: 0, workflow: 0 };
private promptCache: { key: string; text: string } | undefined;
private readonly cacheStats = { hits: 0, misses: 0 };
/** Current ignore-aware file list; refreshed at turn boundaries after writes. */
@@ -233,6 +242,17 @@ export class Session {
private turnStartUsd: number | undefined;
/** One notice per turn when the per-turn cap trips, so a capped turn is not silent. */
private turnCappedNotice: string | undefined;
/** Did the current turn call todo_write? Gates the workflow nudge. */
private todoWrittenThisTurn = false;
/** Fired at most once per session: an agent that edits without updating the task list. */
private workflowNudged = false;
/** Line count of TODO.md at last check, for /workflow. */
private workflowTodoLines = 0;
private workflowRoadmapLines = 0;
private workflowDocsFiles = 0;
private workflowHasTodo = false;
private workflowHasRoadmap = false;
private workflowHasDocs = false;
constructor(private readonly opts: SessionOptions) {
this.messages = opts.messages ?? [];
@@ -396,6 +416,119 @@ export class Session {
return { ...this.cacheStats };
}
/**
* The git root of the workspace, walking up like instructions.ts does.
* Returns undefined outside a repo (bare dirs get no project workflow).
* Sync: called on the hot path (systemFor), must not block, so it uses
* Node's existsSync over Bun.file(...).exists().
*/
private gitRoot(): string | undefined {
try {
let dir = resolve(this.opts.cwd ?? process.cwd());
while (true) {
if (existsSync(join(dir, '.git', 'HEAD'))) return dir;
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
} catch {
return undefined;
}
}
/**
* Rendered only when the project tracks its own progress (TODO.md/ROADMAP.md
* or a docs dir), so a bare repo gets no noise. The policy is guidance, not
* a gate: the agent stays in control, but it knows this project expects
* task tracking, docs-driven dev, tests, and verification.
*/
private workflowPolicy(): string {
if (this.opts.workflow?.enabled === false) return '';
const root = this.gitRoot();
if (!root) return '';
if (!this.workflowChecked) {
try {
this.workflowChecked = true;
const todoPath = join(root, 'TODO.md');
const roadmapPath = join(root, 'ROADMAP.md');
this.workflowHasTodo = existsSync(todoPath);
this.workflowHasRoadmap = existsSync(roadmapPath);
if (this.workflowHasTodo) {
try {
this.workflowTodoLines = readFileSync(todoPath, 'utf8').split('\n').length;
} catch {
this.workflowTodoLines = 0;
}
}
if (this.workflowHasRoadmap) {
try {
this.workflowRoadmapLines = readFileSync(roadmapPath, 'utf8').split('\n').length;
} catch {
this.workflowRoadmapLines = 0;
}
}
const docsDir = join(root, this.opts.workflow?.docsDir ?? 'docs');
try {
if (existsSync(docsDir) && statSync(docsDir).isDirectory()) {
this.workflowHasDocs = true;
let count = 0;
try {
const walkDir = (d: string): void => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (count > 200) return;
const p = join(d, e.name);
if (e.isDirectory()) walkDir(p);
else count += 1;
}
};
walkDir(docsDir);
} catch {}
this.workflowDocsFiles = count;
}
} catch {}
} catch {}
}
if (!this.workflowHasTodo && !this.workflowHasRoadmap && !this.workflowHasDocs) return '';
const lines = [
'This repo tracks its own progress. When you start real work here:',
'- read TODO.md (task list) before starting and keep it current as you go: mark what you did',
'- keep ROADMAP.md current when you ship a milestone',
'- for anything non-trivial, write a short plan first (spec-first), then code',
'- add tests alongside code; this project expects complete unit tests, not just happy paths',
'- verify with the project\'s check commands (tests/typecheck/build) before declaring done',
];
return lines.join('\n');
}
private workflowChecked = false;
private checkWorkflowState(_root: string): void {
// kept for backwards compat — logic now in workflowPolicy()
}
/**
* One soft line after a turn that wrote files without touching the task
* list. Not a stop — it keeps the agent moving while reminding it the
* project expects the plan kept current. Fires at most once per session.
*/
private workflowNudge(): string | undefined {
if (this.opts.workflow?.enabled === false) return undefined;
if (this.workflowNudged) return undefined;
const root = this.gitRoot();
if (!root) return undefined;
if (!this.workflowChecked) this.workflowPolicy();
if (!this.workflowHasTodo && !this.workflowHasRoadmap) return undefined;
if (this.todoWrittenThisTurn) return undefined;
// fileChangeSeq bump lives in onBeforeWrite (async), but lastWalkSeq is
// refreshed after the turn; compare at turn end: if no onBeforeWrite
// fired, this turn changed nothing — no nudge.
if (this.fileChangeSeq <= this.lastWalkSeq && this.fileChangeSeq === 0) return undefined;
// Only when onBeforeWrite actually fired (a write succeeded) and no todo_write
const hasWrites = this.turnBeforeFiles?.size > 0;
if (!hasWrites) return undefined;
this.workflowNudged = true;
return 'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.';
}
/**
* A deep, independent copy of the session's messages up to a turn boundary —
* the messages strictly before the most recent user prompt. The current
@@ -439,6 +572,34 @@ export class Session {
this.opts.onChange?.(this.messages);
}
/**
* Project workflow state for /workflow: what the repo tracks, whether the
* policy is rendered, and how much of the workflow the session exercised.
*/
workflowStatus(): {
enabled: boolean;
hasTodo: boolean;
todoLines: number;
hasRoadmap: boolean;
roadmapLines: number;
hasDocs: boolean;
docsFiles: number;
nudged: boolean;
} {
const root = this.gitRoot();
if (!this.workflowChecked && root) this.workflowPolicy();
return {
enabled: this.opts.workflow?.enabled !== false,
hasTodo: this.workflowHasTodo,
todoLines: this.workflowTodoLines,
hasRoadmap: this.workflowHasRoadmap,
roadmapLines: this.workflowRoadmapLines,
hasDocs: this.workflowHasDocs,
docsFiles: this.workflowDocsFiles,
nudged: this.workflowNudged,
};
}
/** A subagent's finished run, folded into the session's spend and the /cost split. */
recordSubagentUsage(usage: { inputTokens: number; outputTokens: number }): void {
this.subagentInputTokens += usage.inputTokens;
@@ -592,6 +753,7 @@ export class Session {
`pl:${this.versions.plugins}`,
`tl:${this.versions.tools}`,
`ws:${this.versions.workspace}`,
`wf:${this.versions.workflow ?? 0}`,
].join('|');
if (this.promptCache && this.promptCache.key === versionKey) {
this.cacheStats.hits += 1;
@@ -613,6 +775,7 @@ export class Session {
canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'),
...(this.mcpServerNamesForPrompt() ? { mcpServers: this.mcpServerNamesForPrompt() } : {}),
...(this.workspaceFiles && this.workspaceFiles.length > 0 ? { workspaceFiles: this.workspaceFiles } : {}),
...(this.workflowPolicy() ? { workflowPolicy: this.workflowPolicy() } : {}),
});
this.promptCache = { key: versionKey, text };
return text;
@@ -725,6 +888,7 @@ export class Session {
this.turnBeforeFiles = new Map<string, FileState>();
this.turnStartUsd = this.spend().usd;
this.turnCappedNotice = undefined;
this.todoWrittenThisTurn = false;
onBeforeWrite(async (abs: string) => {
if (this.turnBeforeFiles.has(abs)) return;
const exists = await Bun.file(abs).exists();
@@ -909,6 +1073,7 @@ export class Session {
yield { type: 'tool-start', id: part.id, name: part.toolName };
break;
case 'tool-call':
if (part.toolName === 'todo_write') this.todoWrittenThisTurn = true;
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
break;
case 'tool-result':
@@ -977,6 +1142,12 @@ export class Session {
// await; touching them would throw NoOutputGeneratedError.
if (sawError) return;
// Project workflow: a turn that changed files without touching the task
// list gets one soft reminder. Keep it below the fold — a nag that
// repeats would train the model to ignore it.
const nudge = this.workflowNudge();
if (nudge) yield { type: 'notice', text: nudge };
while (compactions.length > 0) yield compactions.shift()!;
while (outputs.length > 0) yield outputs.shift()!;
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
+6 -1
View File
@@ -33,7 +33,7 @@ import {
type SubagentView,
} from './Panels';
import { CommandMenu, InstallConfirm, Picker } from './Pickers';
import { contextPanel, costPanel, todosPanel, toolsPanel, changesPanel } from './panel-bodies';
import { contextPanel, costPanel, todosPanel, toolsPanel, changesPanel, workflowPanel } from './panel-bodies';
import { PromptInput } from './PromptInput';
import { accent, glyph } from './theme';
import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript';
@@ -739,6 +739,11 @@ export function App({
}
return;
}
case 'workflow': {
push({ kind: 'user', text: chosen.trim() });
setPanel(workflowPanel(session));
return;
}
case 'provider':
push({ kind: 'user', text: chosen.trim() });
setOnboarding(true);
+15
View File
@@ -102,3 +102,18 @@ export function changesPanel(session: Session): Panel {
.join('\n');
return { title: 'changes', body };
}
/** Project workflow state: what the repo tracks and how the session behaved. */
export function workflowPanel(session: Session): Panel {
const w = session.workflowStatus();
const yes = (b: boolean): string => (b ? 'yes' : 'no');
const rows = [
['workflow', w.enabled ? 'on' : 'off'],
['TODO.md', `${yes(w.hasTodo)}${w.hasTodo ? ` (${w.todoLines} lines)` : ''}`],
['ROADMAP.md', `${yes(w.hasRoadmap)}${w.hasRoadmap ? ` (${w.roadmapLines} lines)` : ''}`],
['docs dir', `${yes(w.hasDocs)}${w.hasDocs ? ` (${w.docsFiles} files)` : ''}`],
['reminders sent', w.nudged ? '1 (this session)' : 'none'],
];
const body = rows.map(([k, v]) => `${k}: ${v}`).join('\n');
return { title: 'workflow', body };
}