workflow: project-driven agent workflow (docs-first, TODO/ROADMAP tracking, verify-before-done)
- 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:
@@ -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 `<gitroot>/TODO.md` and `<gitroot>/ROADMAP.md`
|
||||||
|
when present, capped (e.g. 6_000 chars each), rendered as:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- TODO.md (project task list) ---
|
||||||
|
<first N lines, preserving section headers>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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;
|
**`/fork`** — clone the session at the last turn boundary into a new saved session;
|
||||||
trying a different approach no longer costs the original.
|
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
|
### 0.1.0-beta.1
|
||||||
|
|
||||||
**Core loop** — `streamText` with tool approvals suspended and resumed through the SDK's
|
**Core loop** — `streamText` with tool approvals suspended and resumed through the SDK's
|
||||||
|
|||||||
@@ -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] 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] 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
|
- [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)
|
||||||
|
|||||||
@@ -47,6 +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) |
|
||||||
| `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) |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -339,6 +339,7 @@ const session = new Session({
|
|||||||
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
|
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
|
||||||
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
|
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd } : {}),
|
||||||
...(cfg.maxSpendPerTurn !== undefined ? { maxSpendPerTurn: cfg.maxSpendPerTurn } : {}),
|
...(cfg.maxSpendPerTurn !== undefined ? { maxSpendPerTurn: cfg.maxSpendPerTurn } : {}),
|
||||||
|
...(cfg.workflow !== undefined ? { workflow: cfg.workflow } : {}),
|
||||||
extraTools: {
|
extraTools: {
|
||||||
...(mcp?.tools ?? {}),
|
...(mcp?.tools ?? {}),
|
||||||
...externalTools.tools,
|
...externalTools.tools,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type CommandAction =
|
|||||||
| { type: 'changes' }
|
| { type: 'changes' }
|
||||||
| { type: 'search'; query: string }
|
| { type: 'search'; query: string }
|
||||||
| { type: 'fork' }
|
| { type: 'fork' }
|
||||||
|
| { type: 'workflow' }
|
||||||
/** A custom command from a markdown file, expanded against its arguments. */
|
/** A custom command from a markdown file, expanded against its arguments. */
|
||||||
| { type: 'custom'; command: CustomCommand; args: string[] }
|
| { type: 'custom'; command: CustomCommand; args: string[] }
|
||||||
| { type: 'unknown'; name: 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: 'changes', summary: 'show what the last turn changed on disk' },
|
||||||
{ name: 'search', arg: '<query>', summary: 'search saved sessions for a phrase' },
|
{ 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: '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: 'clear', summary: 'clear the transcript and history' },
|
||||||
{ name: 'exit', aliases: ['quit'], summary: 'quit' },
|
{ 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>' };
|
return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search <query>' };
|
||||||
case 'fork':
|
case 'fork':
|
||||||
return { type: 'fork' };
|
return { type: 'fork' };
|
||||||
|
case 'workflow':
|
||||||
|
return { type: 'workflow' };
|
||||||
default: {
|
default: {
|
||||||
const cmd = custom.find((c) => c.name === name);
|
const cmd = custom.find((c) => c.name === name);
|
||||||
return cmd ? { type: 'custom', command: cmd, args: arg ? arg.split(/\s+/) : [] } : { type: 'unknown', name };
|
return cmd ? { type: 'custom', command: cmd, args: arg ? arg.split(/\s+/) : [] } : { type: 'unknown', name };
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export type Config = {
|
|||||||
maxSpendUsd?: number;
|
maxSpendUsd?: number;
|
||||||
/** USD ceiling per individual turn: abort a step if the turn's delta exceeds this. */
|
/** USD ceiling per individual turn: abort a step if the turn's delta exceeds this. */
|
||||||
maxSpendPerTurn?: number;
|
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. */
|
/** Model id for subagents; omit to share the parent's. */
|
||||||
subagentModel?: string;
|
subagentModel?: string;
|
||||||
/** Default agent variant name. */
|
/** Default agent variant name. */
|
||||||
@@ -116,6 +123,14 @@ export async function loadConfig(): Promise<Config> {
|
|||||||
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}),
|
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}),
|
||||||
...(typeof file.maxSpendUsd === 'number' && file.maxSpendUsd > 0 ? { maxSpendUsd: file.maxSpendUsd } : {}),
|
...(typeof file.maxSpendUsd === 'number' && file.maxSpendUsd > 0 ? { maxSpendUsd: file.maxSpendUsd } : {}),
|
||||||
...(typeof file.maxSpendPerTurn === 'number' && file.maxSpendPerTurn > 0 ? { maxSpendPerTurn: file.maxSpendPerTurn } : {}),
|
...(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.subagentModel ? { subagentModel: file.subagentModel } : {}),
|
||||||
...(file.agent ? { agent: file.agent } : {}),
|
...(file.agent ? { agent: file.agent } : {}),
|
||||||
...(file.thinking ? { thinking: file.thinking } : {}),
|
...(file.thinking ? { thinking: file.thinking } : {}),
|
||||||
|
|||||||
+22
-8
@@ -3,6 +3,10 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
const NAMES = ['AGENTS.md', 'CLAUDE.md', '.shiro.md'];
|
const NAMES = ['AGENTS.md', 'CLAUDE.md', '.shiro.md'];
|
||||||
/** Cap per file so one huge doc cannot crowd out the conversation. */
|
/** Cap per file so one huge doc cannot crowd out the conversation. */
|
||||||
const MAX_CHARS = 12_000;
|
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 }[];
|
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();
|
const text = (await file.text()).trim();
|
||||||
if (text) found.push({ path, text: text.slice(0, MAX_CHARS) });
|
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;
|
return found;
|
||||||
}
|
}
|
||||||
@@ -42,15 +58,13 @@ export function formatInstructions(instructions: Instructions, cwd = process.cwd
|
|||||||
if (instructions.length === 0) return '';
|
if (instructions.length === 0) return '';
|
||||||
const blocks = instructions.map(({ path, text }) => {
|
const blocks = instructions.map(({ path, text }) => {
|
||||||
const label = path.startsWith(cwd) ? path.slice(cwd.length + 1) || path : path;
|
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 [
|
return ['', ...blocks].join('\n');
|
||||||
'',
|
|
||||||
'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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INSTRUCTION_NAMES = NAMES;
|
export const INSTRUCTION_NAMES = NAMES;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type PromptParts = {
|
|||||||
mcpServers?: readonly string[];
|
mcpServers?: readonly string[];
|
||||||
/** Ignore-aware workspace file list injected at boot (gitignore-respected, capped). */
|
/** Ignore-aware workspace file list injected at boot (gitignore-respected, capped). */
|
||||||
workspaceFiles?: readonly string[];
|
workspaceFiles?: readonly string[];
|
||||||
|
/** Project-driven workflow policy block. Rendered when the project tracks its own progress. */
|
||||||
|
workflowPolicy?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolDoc = { name: string; line: string };
|
type ToolDoc = { name: string; line: string };
|
||||||
@@ -169,6 +171,7 @@ export function systemPrompt(parts: PromptParts): string {
|
|||||||
plugins = '',
|
plugins = '',
|
||||||
availableTools,
|
availableTools,
|
||||||
canAsk = false,
|
canAsk = false,
|
||||||
|
workflowPolicy = '',
|
||||||
} = parts;
|
} = parts;
|
||||||
|
|
||||||
const toolNames = availableTools ?? TOOL_DOCS.map((d) => d.name);
|
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
|
How to work
|
||||||
${workflow}
|
${workflow}
|
||||||
|
${workflowPolicy ? `\nProject workflow (this repo tracks its own progress)
|
||||||
|
${workflowPolicy}` : ''}
|
||||||
|
|
||||||
When something fails
|
When something fails
|
||||||
${recovery}
|
${recovery}
|
||||||
|
|||||||
+172
-1
@@ -23,6 +23,8 @@ import { createSkillTool, renderSkills, type Skill } from './skills';
|
|||||||
import { suggestSkillsFromTranscript, writeAutoSkill } from './skill-learner';
|
import { suggestSkillsFromTranscript, writeAutoSkill } from './skill-learner';
|
||||||
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
|
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
|
||||||
import { onBeforeWrite, SnapshotStack, type FileState } from './snapshot';
|
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 = {
|
export type ApprovalRequest = {
|
||||||
approvalId: string;
|
approvalId: string;
|
||||||
@@ -110,6 +112,13 @@ export type SessionOptions = {
|
|||||||
onNotice?: (text: string) => void;
|
onNotice?: (text: string) => void;
|
||||||
/** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */
|
/** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */
|
||||||
workspaceFiles?: readonly string[];
|
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). */
|
/** Disable background auto-learn (tests). */
|
||||||
disableAutoLearn?: boolean;
|
disableAutoLearn?: boolean;
|
||||||
};
|
};
|
||||||
@@ -223,7 +232,7 @@ export class Session {
|
|||||||
private learnTurns = 0;
|
private learnTurns = 0;
|
||||||
private lastLearnLen = 0;
|
private lastLearnLen = 0;
|
||||||
/** Versions of the volatile prompt parts; a change busts the system-prompt cache. */
|
/** 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 promptCache: { key: string; text: string } | undefined;
|
||||||
private readonly cacheStats = { hits: 0, misses: 0 };
|
private readonly cacheStats = { hits: 0, misses: 0 };
|
||||||
/** Current ignore-aware file list; refreshed at turn boundaries after writes. */
|
/** Current ignore-aware file list; refreshed at turn boundaries after writes. */
|
||||||
@@ -233,6 +242,17 @@ export class Session {
|
|||||||
private turnStartUsd: number | undefined;
|
private turnStartUsd: number | undefined;
|
||||||
/** One notice per turn when the per-turn cap trips, so a capped turn is not silent. */
|
/** One notice per turn when the per-turn cap trips, so a capped turn is not silent. */
|
||||||
private turnCappedNotice: string | undefined;
|
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) {
|
constructor(private readonly opts: SessionOptions) {
|
||||||
this.messages = opts.messages ?? [];
|
this.messages = opts.messages ?? [];
|
||||||
@@ -396,6 +416,119 @@ export class Session {
|
|||||||
return { ...this.cacheStats };
|
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 —
|
* 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
|
* the messages strictly before the most recent user prompt. The current
|
||||||
@@ -439,6 +572,34 @@ export class Session {
|
|||||||
this.opts.onChange?.(this.messages);
|
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. */
|
/** A subagent's finished run, folded into the session's spend and the /cost split. */
|
||||||
recordSubagentUsage(usage: { inputTokens: number; outputTokens: number }): void {
|
recordSubagentUsage(usage: { inputTokens: number; outputTokens: number }): void {
|
||||||
this.subagentInputTokens += usage.inputTokens;
|
this.subagentInputTokens += usage.inputTokens;
|
||||||
@@ -592,6 +753,7 @@ export class Session {
|
|||||||
`pl:${this.versions.plugins}`,
|
`pl:${this.versions.plugins}`,
|
||||||
`tl:${this.versions.tools}`,
|
`tl:${this.versions.tools}`,
|
||||||
`ws:${this.versions.workspace}`,
|
`ws:${this.versions.workspace}`,
|
||||||
|
`wf:${this.versions.workflow ?? 0}`,
|
||||||
].join('|');
|
].join('|');
|
||||||
if (this.promptCache && this.promptCache.key === versionKey) {
|
if (this.promptCache && this.promptCache.key === versionKey) {
|
||||||
this.cacheStats.hits += 1;
|
this.cacheStats.hits += 1;
|
||||||
@@ -613,6 +775,7 @@ export class Session {
|
|||||||
canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'),
|
canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'),
|
||||||
...(this.mcpServerNamesForPrompt() ? { mcpServers: this.mcpServerNamesForPrompt() } : {}),
|
...(this.mcpServerNamesForPrompt() ? { mcpServers: this.mcpServerNamesForPrompt() } : {}),
|
||||||
...(this.workspaceFiles && this.workspaceFiles.length > 0 ? { workspaceFiles: this.workspaceFiles } : {}),
|
...(this.workspaceFiles && this.workspaceFiles.length > 0 ? { workspaceFiles: this.workspaceFiles } : {}),
|
||||||
|
...(this.workflowPolicy() ? { workflowPolicy: this.workflowPolicy() } : {}),
|
||||||
});
|
});
|
||||||
this.promptCache = { key: versionKey, text };
|
this.promptCache = { key: versionKey, text };
|
||||||
return text;
|
return text;
|
||||||
@@ -725,6 +888,7 @@ export class Session {
|
|||||||
this.turnBeforeFiles = new Map<string, FileState>();
|
this.turnBeforeFiles = new Map<string, FileState>();
|
||||||
this.turnStartUsd = this.spend().usd;
|
this.turnStartUsd = this.spend().usd;
|
||||||
this.turnCappedNotice = undefined;
|
this.turnCappedNotice = undefined;
|
||||||
|
this.todoWrittenThisTurn = false;
|
||||||
onBeforeWrite(async (abs: string) => {
|
onBeforeWrite(async (abs: string) => {
|
||||||
if (this.turnBeforeFiles.has(abs)) return;
|
if (this.turnBeforeFiles.has(abs)) return;
|
||||||
const exists = await Bun.file(abs).exists();
|
const exists = await Bun.file(abs).exists();
|
||||||
@@ -909,6 +1073,7 @@ export class Session {
|
|||||||
yield { type: 'tool-start', id: part.id, name: part.toolName };
|
yield { type: 'tool-start', id: part.id, name: part.toolName };
|
||||||
break;
|
break;
|
||||||
case 'tool-call':
|
case 'tool-call':
|
||||||
|
if (part.toolName === 'todo_write') this.todoWrittenThisTurn = true;
|
||||||
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
|
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
|
||||||
break;
|
break;
|
||||||
case 'tool-result':
|
case 'tool-result':
|
||||||
@@ -977,6 +1142,12 @@ export class Session {
|
|||||||
// await; touching them would throw NoOutputGeneratedError.
|
// await; touching them would throw NoOutputGeneratedError.
|
||||||
if (sawError) return;
|
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 (compactions.length > 0) yield compactions.shift()!;
|
||||||
while (outputs.length > 0) yield outputs.shift()!;
|
while (outputs.length > 0) yield outputs.shift()!;
|
||||||
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
|
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
|
||||||
|
|||||||
+6
-1
@@ -33,7 +33,7 @@ import {
|
|||||||
type SubagentView,
|
type SubagentView,
|
||||||
} from './Panels';
|
} from './Panels';
|
||||||
import { CommandMenu, InstallConfirm, Picker } from './Pickers';
|
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 { PromptInput } from './PromptInput';
|
||||||
import { accent, glyph } from './theme';
|
import { accent, glyph } from './theme';
|
||||||
import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript';
|
import { nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript';
|
||||||
@@ -739,6 +739,11 @@ export function App({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'workflow': {
|
||||||
|
push({ kind: 'user', text: chosen.trim() });
|
||||||
|
setPanel(workflowPanel(session));
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'provider':
|
case 'provider':
|
||||||
push({ kind: 'user', text: chosen.trim() });
|
push({ kind: 'user', text: chosen.trim() });
|
||||||
setOnboarding(true);
|
setOnboarding(true);
|
||||||
|
|||||||
@@ -102,3 +102,18 @@ export function changesPanel(session: Session): Panel {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
return { title: 'changes', body };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
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');
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user