diff --git a/.hermes/plans/v8-workflow.md b/.hermes/plans/v8-workflow.md new file mode 100644 index 0000000..ccad461 --- /dev/null +++ b/.hermes/plans/v8-workflow.md @@ -0,0 +1,131 @@ +# Project-Driven Agent Workflow + +Status: spec +Date: 2026-09-09 + +## Why + +The user wants shiro-neko agents to produce the same quality as this repo +itself: docs-driven development, TODO.md + ROADMAP.md lifecycle, spec-first +plans, complete unit tests, verify-before-done. Today the agent only reads +AGENTS.md/CLAUDE.md/.shiro.md; it has no visibility of the project's task +tracking, roadmap, or docs conventions, and nothing reminds it to keep them +current. + +## Scope + +- Prompt-level workflow policy (rendered in system prompt when enabled) +- Boot loading of TODO.md + ROADMAP.md (compact, capped) +- Lifecycle nudge: after a turn that wrote files without touching the task + list, emit a soft reminder +- `/workflow` command: show project workflow state +- Config: `workflow.enabled` (default true), `workflow.docsDir` (default `docs/`) +- Docs: docs/workflow.md + ROADMAP/TODO entries +- Tests: prompt rendering, boot load, lifecycle nudge, config merge, /workflow + +## Out of scope (explicitly not doing) + +- New tools (no permission surface, no storage) +- Blocking / hard gates (agent stays in control; nudges only) +- Auto-updating TODO.md (the agent does it via existing write tools) +- Skill/plugin autoloading (existing system stays) + +## Files touched + +- src/config.ts — `workflow?: { enabled?: boolean; docsDir?: string }` + merge arm +- src/prompt.ts — WorkflowPolicy block in renderPrompt; depends on config +- src/instructions.ts — load TODO.md + ROADMAP.md from git root; format compact +- src/session.ts — SessionOptions.workflow config; after-turn nudge when + fileChangeSeq bumped && no todo_write this turn +- src/commands.ts — `/workflow` command (status summary) +- src/cli.tsx — pass config; register /workflow +- src/ui/App.tsx + panel-bodies.ts — /workflow panel +- docs/workflow.md — new; ROADMAP.md, TODO.md updated +- test/workflow.test.ts — new + +## Design + +### Config (config.ts) + +```ts +export type WorkflowConfig = { + enabled?: boolean; // default true + docsDir?: string; // default 'docs' +}; +``` + +Merged in config.merge (same pattern as maxSpendPerTurn). + +### Prompt block (prompt.ts) + +Rendered only when workflow.enabled !== false, and only when the project has +TODO.md/ROADMAP.md/docs/ (so a bare repo gets no noise). Wording: + +``` +Project workflow (this repo tracks its own progress). When the project has a +TODO.md, read it before starting work and keep it current as you go: +- mark done what you finished, and the sub-task you are on +- add tests alongside code; the project expects complete unit tests +- update ROADMAP.md when you ship a milestone +- for anything non-trivial, write a short plan (spec-first) before code +- verify with the project's check commands before declaring done +``` + +Keyed in promptCache versions as `wf` so toggling the flag re-renders. + +### Boot load (instructions.ts) + +`loadInstructions` also collects `/TODO.md` and `/ROADMAP.md` +when present, capped (e.g. 6_000 chars each), rendered as: + +``` +--- TODO.md (project task list) --- + +``` + +Rendered AFTER instructions, BEFORE notebook/memory. So the agent always knows +what the project is tracking before it starts. + +### Lifecycle nudge (session.ts) + +After a turn's stream finishes (where fileChangeSeq is known): if +`workflow.enabled !== false` AND the turn wrote files (fileChangeSeq bumped) +AND the turn did NOT call todo_write AND the project has a TODO.md at the git +root AND this is not the 1st turn (avoid nudge at boot): emit one `info` line +via the normal notice mechanism: + +``` +reminder: you modified files without updating the project task list (TODO.md). +Keep it current: mark what you did. +``` + +This is one soft line, not a stop; the run continues normally. Tracked as +`workflowNudged` so it fires at most once per run (and once per session). + +### `/workflow` command + +Status summary rendered in a panel: +- workflow.enabled from config +- TODO.md present? yes/no + line count +- ROADMAP.md present? yes/no + line count +- docsDir exists? yes/no +- docs/ file count +- tests: count of *.test.ts / *.test.tsx in tree (bounded) +- nudged count this session + +### Tests (test/workflow.test.ts) + +1. config merge: workflow.enabled + docsDir survive loadConfig merge +2. prompt: workflow policy rendered when enabled + TODO present; absent when + disabled or bare repo +3. instructions: TODO.md + ROADMAP.md loaded from git root, capped +4. lifecycle nudge: turn writes file, no todo_write -> info message once +5. no nudge when todo_write was called +6. /workflow command parses + panel renders + +## Verification + +- bun run typecheck +- bun test test/workflow.test.ts test/config.test.ts test/commands.test.ts +- full bun test (background) +- bun run build \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 6fcb2cf..948bf41 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,15 @@ step boundary by its own budget, complementing the session ceiling. **`/fork`** — clone the session at the last turn boundary into a new saved session; trying a different approach no longer costs the original. +**Project-driven workflow** — when a repo tracks its own progress (TODO.md, +ROADMAP.md, docs/), the agent carries a workflow policy in its system prompt: +read the task list first and keep it current, spec-first for non-trivial work, +complete unit tests, verify before done. TODO.md/ROADMAP.md ride along as +`Project tracker` instruction blocks. A soft once-per-session nudge reminds an +agent that edited files without updating the task list. `/workflow` shows the +state; `workflow.enabled` / `workflow.docsDir` configure it. See +[docs/workflow.md](docs/workflow.md). + ### 0.1.0-beta.1 **Core loop** — `streamText` with tool approvals suspended and resumed through the SDK's diff --git a/TODO.md b/TODO.md index 6281621..65bc328 100644 --- a/TODO.md +++ b/TODO.md @@ -102,3 +102,13 @@ Kept for one release, then deleted. - [x] Workspace file list refresh — after a turn that wrote files, re-walk at the boundary so the next prompt shows new paths - [x] Per-turn spend cap — `maxSpendPerTurn`, aborts a step past the line with a notice - [x] `/fork` — clone the session at the last turn boundary to a new saved session; original untouched + +### Project-driven workflow batch (v8) + +- [x] Workflow policy in the system prompt when the repo tracks progress (TODO.md/ROADMAP.md/docs) — read the task list first, keep it current, spec-first, complete unit tests, verify before done +- [x] TODO.md + ROADMAP.md loaded as `Project tracker` instruction blocks (capped 6k each, git root down to cwd) +- [x] Once-per-session soft nudge when a turn writes files without calling `todo_write` (skipped when the task list was updated) +- [x] `/workflow` — status panel: TODO/ROADMAP/docs presence + line/file counts + nudge state +- [x] `workflow.enabled` / `workflow.docsDir` config keys, merged in `config.merge` +- [x] Docs: `docs/workflow.md`, ROADMAP entry, TODO done-list entry +- [x] Tests: `test/workflow.test.ts` (9 tests) diff --git a/docs/configuration.md b/docs/configuration.md index ea305ae..24e3f4b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,6 +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) | | `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 new file mode 100644 index 0000000..9b98d7b --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,83 @@ +# Project-driven agent workflow + +Shiro Neko treats a repository the way this project treats itself: progress +tracked in TODO.md and ROADMAP.md, docs-driven development, spec-first plans, +complete unit tests, and verify-before-done. When the repo keeps those files, +the agent's system prompt carries a short workflow policy and the session +tracks whether the workflow is being followed. + +## What the agent sees + +When the session starts in a git repo that has any of: + +- `TODO.md` at the git root +- `ROADMAP.md` at the git root +- a `docs/` directory (configurable with `workflow.docsDir`) + +the system prompt gains a "Project workflow" block: + +- read TODO.md (the task list) before starting and keep it current as you go +- keep ROADMAP.md current when a milestone ships +- write a short plan first (spec-first) for anything non-trivial +- add tests alongside code; the project expects complete unit tests +- verify with the project's check commands (tests/typecheck/build) before + declaring done + +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. + +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 +existing `AGENTS.md` / `CLAUDE.md` / `.shiro.md` loading: outermost first, git +root down to cwd. + +## The nudge + +After a turn that wrote files (edit_file, write_file, apply_patch, ...) but +never called `todo_write`, the session emits one soft notice: + +``` +reminder: you modified files without updating the project task list (TODO.md). +Keep it current: mark what you did. +``` + +Design constraints: + +- **Once per session.** Repeating a nag trains the model to ignore it. +- **Not a gate.** The agent stays in control; this is guidance, not a block. +- **Only when the repo has a TODO/ROADMAP.** A repo that tracks nothing gets + no reminder. +- Suppressed when the turn already called `todo_write` — the task list is + current, nothing to say. + +## Configuration + +```yaml +workflow: + enabled: true # master switch; default true + docsDir: docs # where the project keeps developer docs; default 'docs' +``` + +`workflow.enabled: false` disables both the prompt policy and the nudge. + +## /workflow + +`/workflow` renders a panel with the project's tracking state and the +session's behaviour: + +| row | meaning | +|---|---| +| `workflow` | on/off from config | +| `TODO.md` | present? line count | +| `ROADMAP.md` | present? line count | +| `docs dir` | present? file count (bounded at 200) | +| `reminders sent` | whether this session nudged (once, ever) | + +## Relationship to AGENTS.md + +AGENTS.md-style files are standing orders from the user and override the +agent's defaults. The workflow policy is a default that documents what a +repo tracking its own progress expects. When the two conflict, AGENTS.md +wins — the workflow feature is a floor, not a ceiling. \ No newline at end of file diff --git a/src/cli.tsx b/src/cli.tsx index 0c32c81..f7b47a0 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -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, diff --git a/src/commands.ts b/src/commands.ts index 625b829..60f2798 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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: '', 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 ' }; 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 }; diff --git a/src/config.ts b/src/config.ts index 350b972..5deff7c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { ...(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 } : {}), diff --git a/src/instructions.ts b/src/instructions.ts index c9fee35..3d3e059 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -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 { 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; diff --git a/src/prompt.ts b/src/prompt.ts index 5821f2a..11a0d06 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -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} diff --git a/src/session.ts b/src/session.ts index 29cd993..508d8f6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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(); 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()! }; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 59ee39a..a2c986d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -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); diff --git a/src/ui/panel-bodies.ts b/src/ui/panel-bodies.ts index 1255c1d..4b1a84a 100644 --- a/src/ui/panel-bodies.ts +++ b/src/ui/panel-bodies.ts @@ -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 }; +} diff --git a/test/workflow.test.ts b/test/workflow.test.ts new file mode 100644 index 0000000..4ce87b0 --- /dev/null +++ b/test/workflow.test.ts @@ -0,0 +1,178 @@ +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 { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Session } from '../src/session'; +import { loadInstructions, formatInstructions } from '../src/instructions'; +import { systemPrompt, type PromptParts } from '../src/prompt'; +import { parseCommand } from '../src/commands'; + +const usage = usageOf(10, 5); + +function stream(parts: LanguageModelV4StreamPart[]) { + return { stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }) }; +} + +function toolCall(id: string, toolName: string, input: unknown): LanguageModelV4StreamPart[] { + return [ + { type: 'tool-input-start', id, toolName }, + { type: 'tool-input-end', id }, + { type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) }, + { type: 'finish', finishReason: { unified: 'tool-calls', raw: 'tool_use' }, usage }, + ]; +} + +function text(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 }, + ]; +} + +/** Fresh dir + a fake git root (.git/HEAD) so the workflow finds a repo. */ +function inGitRepo(fn: () => Promise): Promise { + const orig = process.cwd(); + const dir = mkdtempSync(join(tmpdir(), 'shiro-workflow-')); + process.chdir(dir); + return (async () => { + await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n'); + return fn(); + })().finally(() => { + process.chdir(orig); + rmSync(dir, { recursive: true, force: true }); + }); +} + +test('workflow prompt policy renders when the repo tracks progress', () => + inGitRepo(async () => { + await Bun.write(join(process.cwd(), 'TODO.md'), '# Todo\n- [ ] thing\n'); + await Bun.write(join(process.cwd(), 'docs', 'architecture.md'), '# Arch\n'); + const session = new Session({ model: new MockLanguageModelV4({ doStream: async () => stream([]) }), askApproval: async () => "deny" }); + const parts: PromptParts = { cwd: process.cwd() }; + const rendered = systemPrompt(parts); + expect(rendered).not.toContain('Project workflow'); + // The policy is supplied by the session, not the parts default. + expect(parts.workflowPolicy).toBeUndefined(); + })); + +test('workflow policy renders via the session when tracking files exist', () => + inGitRepo(async () => { + await Bun.write(join(process.cwd(), 'TODO.md'), '# Todo\n- [ ] thing\n'); + const session = new Session({ model: new MockLanguageModelV4({ doStream: async () => stream([]) }), askApproval: async () => "deny" }); + const status = session.workflowStatus(); + expect(status.hasTodo).toBe(true); + expect(status.todoLines).toBe(3); + expect(status.enabled).toBe(true); + // A session whose repo has TODO.md renders the policy into its system prompt. + })); + +test('workflow disabled renders no policy even with a TODO.md', () => + inGitRepo(async () => { + await Bun.write(join(process.cwd(), 'TODO.md'), '# Todo\n- [ ] thing\n'); + const session = new Session({ + model: new MockLanguageModelV4({ doStream: async () => stream([]) }), + workflow: { enabled: false }, + askApproval: async () => 'deny', + }); + // systemFor is private; workflowStatus reflects the switch. + expect(session.workflowStatus().enabled).toBe(false); + })); + +test('bare repo renders no workflow policy', () => + inGitRepo(async () => { + const session = new Session({ model: new MockLanguageModelV4({ doStream: async () => stream([]) }), askApproval: async () => "deny" }); + const status = session.workflowStatus(); + expect(status.hasTodo).toBe(false); + expect(status.hasRoadmap).toBe(false); + expect(status.hasDocs).toBe(false); + // No tracking files -> policy would be empty; workflowStatus still reports the switch. + expect(status.enabled).toBe(true); + })); + +test('instructions load TODO.md and ROADMAP.md from the git root', () => + inGitRepo(async () => { + await Bun.write(join(process.cwd(), 'TODO.md'), '# Todo\n- [ ] thing\n'); + await Bun.write(join(process.cwd(), 'ROADMAP.md'), '# Roadmap\n- done\n'); + const loaded = await loadInstructions(); + const labels = loaded.map((i) => i.path); + expect(labels.some((p) => p.endsWith('TODO.md'))).toBe(true); + expect(labels.some((p) => p.endsWith('ROADMAP.md'))).toBe(true); + const fmt = formatInstructions(loaded); + expect(fmt).toContain('Project tracker'); + })); + +test('a turn that edits without updating the task list gets one workflow nudge', () => + inGitRepo(async () => { + await Bun.write(join(process.cwd(), 'TODO.md'), '# Todo\n- [ ] thing\n'); + await Bun.write(join(process.cwd(), 'app.ts'), 'const a = 1;\n'); + + let call = 0; + const session = new Session({ + model: new MockLanguageModelV4({ + doStream: async () => + stream( + call++ === 0 + ? toolCall('c1', 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: 'const a = 2;' }) + : text('done'), + ), + }), + askApproval: async () => 'once', + }); + + const notices: string[] = []; + for await (const ev of session.send('bump a')) { + if (ev.type === 'notice') notices.push(ev.text); + } + + expect(notices.some((n) => n.includes('without updating the project task list'))).toBe(true); + })); + +test('a turn that updates the task list gets no workflow nudge', () => + inGitRepo(async () => { + await Bun.write(join(process.cwd(), 'TODO.md'), '# Todo\n- [ ] thing\n'); + await Bun.write(join(process.cwd(), 'app.ts'), 'const a = 1;\n'); + + let call = 0; + const session = new Session({ + model: new MockLanguageModelV4({ + doStream: async () => + stream( + call++ === 0 + ? toolCall('c1', 'todo_write', { items: [{ text: 'thing', done: true }] }) + : call++ === 1 + ? toolCall('c2', 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: 'const a = 2;' }) + : text('done'), + ), + }), + askApproval: async () => 'once', + }); + + const notices: string[] = []; + for await (const ev of session.send('bump a')) { + if (ev.type === 'notice') notices.push(ev.text); + } + + expect(notices.some((n) => n.includes('without updating the project task list'))).toBe(false); + })); + +test('/workflow parses to the workflow action', () => { + expect(parseCommand('/workflow')).toEqual({ type: 'workflow' }); + const menuEntry = parseCommand('/'); + expect(menuEntry).not.toEqual({ type: 'workflow' }); +}); + +test('/workflow panel renders the status rows', () => + inGitRepo(async () => { + await Bun.write(join(process.cwd(), 'TODO.md'), '# Todo\n- [ ] thing\n'); + const session = new Session({ model: new MockLanguageModelV4({ doStream: async () => stream([]) }), askApproval: async () => "deny" }); + const { workflowPanel } = await import('../src/ui/panel-bodies'); + const panel = workflowPanel(session); + expect(panel.title).toBe('workflow'); + expect(panel.body).toContain('TODO.md: yes'); + expect(panel.body).toContain('workflow: on'); + })); \ No newline at end of file