Compare commits

...

10 Commits

Author SHA1 Message Date
Timmy ddc4228b10 feat(904): MCP progress notifications + SSE for long-running tool calls
Follow-up to bug 903. The attach fix made run_tests retries safe, but
agents still observed the underlying MCP transport timeout as a
tool-call error and had to handle it via retry. Implement the proper
fix: MCP `notifications/progress` events keep the client's transport
timer alive so the call never errors from the agent's perspective.

What changed:

server/src/http/mcp/progress.rs (new)
  - `ProgressEmitter` (progressToken + mpsc sender) installed in a
    `tokio::task_local!` scope by the SSE response path.
  - `emit_progress(progress, total, message)` builds a JSON-RPC
    `notifications/progress` message and sends it via the channel.
    No-op when no emitter is in scope (plain JSON path / tests / API
    runtimes), so tool handlers can call it unconditionally.

server/src/http/mcp/mod.rs
  - mcp_post_handler now detects `Accept: text/event-stream` AND a
    `params._meta.progressToken` on tools/call. When both are present,
    routes through `sse_tools_call` instead of the plain JSON path.
  - sse_tools_call: spawns the dispatch task with the emitter installed,
    builds an SSE stream that interleaves incoming progress events with
    the final JSON-RPC response, with a 15s keep-alive interval as a
    backstop for tools that don't emit their own progress.
  - Plain JSON behaviour is unchanged for non-SSE clients and for
    everything other than tools/call.

server/src/http/mcp/shell_tools/script.rs
  - tool_run_tests poll loop emits `notifications/progress` every 25s
    of elapsed time (well below the typical ~60s MCP transport
    timeout). Attached callers (the bug 903 fix path) also emit so
    their MCP socket stays alive while waiting for the in-flight job.
  - Output filtering: on a passing run the response now returns a
    one-line summary ("All N tests passed.") instead of the full
    `cargo test` stdout, which was pure noise that burned agent
    tokens. Failure output is unchanged (truncated tail with the
    `failures:` section and final test_result line). CRDT entry
    stores the same filtered value so attached callers see it too.

Tests (3 new):
  - emit_progress_no_op_without_emitter — calling outside scope is safe
  - emit_progress_sends_notification_when_emitter_installed — full path
  - emit_progress_omits_optional_fields — total/message optional

Not changed: coder system_prompts still tell agents to retry on
transport-timeout errors. That advice is now belt-and-braces — if
claude-code's HTTP MCP client honours progress notifications, no agent
will ever observe the error; if not, retry is still safe post-903. We
can drop the retry advice once we've observed the SSE path working in
the field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:05:04 +01:00
Timmy a97a10fba2 docs(903): coder system_prompts — clarify run_tests retry contract
Pre-d64f1e94 the "call run_tests again — it attaches" guidance was a
lie (every call killed the prior job and spawned a fresh one). With
the attach fix in place, the contract is now real and safe to depend
on. Tighten the wording so agents see exactly what to do:

OLD: "Do not use ScheduleWakeup to wait for run_tests; if run_tests
      appears to time out, call run_tests again — it attaches to the
      in-flight test job and blocks until completion."

NEW: "If run_tests errors with a transport timeout, call it again —
      it's idempotent and attaches to the same in-flight test job,
      so retries are safe and eventually return a pass/fail result."

Improvements:
- "errors with a transport timeout" matches what the agent literally
  observes (a tool-call error), not the vague "appears to time out".
- Explicit on idempotency so agents understand why retry is safe and
  don't worry about double-running the suite.
- Drops the ScheduleWakeup clause — already enforced via the
  `disallowed_tools` setting on coder-1/2/3/opus, so the prompt
  reminder was redundant.

Applied uniformly across coder-1, coder-2, coder-3, coder-opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:54:34 +01:00
Timmy d64f1e94ff fix(903): run_tests attaches to in-flight job instead of kill+respawn
Bug 903: every `run_tests` MCP call killed the prior `cargo test` child
for the same worktree and spawned a fresh one. Combined with the
~60s MCP client-side timeout and the 896 agent prompt that told agents
to "call run_tests again — it attaches to the in-flight test job",
this produced a respawn loop: agent calls, MCP times out at 60s, agent
retries, run_tests kills the running build and starts a new one. The
test suite never reaches the finish line.

Server log evidence: "Started test job for <worktree> (pid N)" with a
new PID every ~60-90s for the same worktree.

Fix: when `run_tests` is called and a job is already in flight for that
worktree, ATTACH to it instead of killing+respawning. The original job's
poll loop already writes the final status to the CRDT `test_jobs`
collection; attached callers just poll that CRDT entry (the same
pattern `get_test_result` uses) and return the result when the
in-flight job transitions out of "running". The 896 prompt's claim is
now actually true.

Worktrees remain isolated from each other and may run `cargo test`
concurrently — there is no cross-worktree serialisation. The single
invariant is "at most one test job per worktree at a time".

New test: `tool_run_tests_concurrent_calls_attach_to_single_job`
spawns two concurrent calls on the same worktree against a 2s
`sleep`-based script and asserts total elapsed stays close to 2s
(attach) rather than 4s (respawn).

Note: the cross-worktree linker-OOM symptom Timmy reported in the
field was downstream of the respawn loop. Killed-but-not-fully-reaped
cargo invocations stack memory pressure beyond the nominal N
worktrees. With the attach fix, each worktree runs exactly one
in-flight build at a time and old builds finish cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:22:35 +01:00
dave 22bf203853 huskies: merge 894 2026-05-12 13:02:53 +00:00
Timmy f06492f540 feat: add Blocked → Backlog legal transition (Demote)
Pipeline gap: the state machine refused `move_story(... target='backlog')`
from a Blocked story, leaving stuck items with no way to be parked while
waiting on dependent fixes — operators had to either Unblock (which
re-enters the active flow) or Archive (which loses the item).

Extend the existing Demote rule so `Blocked + Demote → Backlog` is a
legal transition, alongside the existing `Coding/Qa/Merge + Demote`.
Also update `map_stage_move_to_event` in agents/lifecycle.rs so the
chat/MCP `move_story` API recognises Blocked → backlog and routes it
through `PipelineEvent::Demote`.

Tests:
  - `blocked_demote_returns_to_backlog` — happy path.
  - `cannot_demote_from_done` / `cannot_demote_from_upcoming` — sanity
    checks that the broadened rule does NOT permit Demote from
    terminal or pre-triage stages.

Pattern follows 892 (MergeFailure → Done) and 893 (MergeFailure →
Coding) — pure transition.rs extension plus matching event mapping in
lifecycle.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:13:18 +01:00
Timmy e955250474 fix(902): coder system_prompts steer to get_story_todos for story content
Bug 902: the Step 0 "resume from worktree state" instruction told coders
to call git_status / git_log / git_diff to discover prior session work,
which they then extended into hunting for the story `.md` file on disk
via find / ls — pointless post-865, since story content lives only in
the CRDT.

Update Step 0 in coder-1, coder-2, coder-3, and coder-opus to add an
explicit instruction: "To read story content, ACs, or description, call
the `get_story_todos` MCP tool — do NOT search for a story `.md` file
on disk; story content is CRDT-only."

Single substring replacement covers all four agents (identical Step 0
across them).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:13:08 +01:00
Timmy 98d496b1ad fix(901): unblock_story works on CRDT-only stories post-865
Bug 901: `unblock_story` (and the chat `unblock` command) routed through
`parse_front_matter` and errored with "Missing front matter" on any
post-865 story (story content is now CRDT-only with no YAML on disk).

In `chat/commands/unblock.rs::unblock_by_story_id`:
  - Drop the early `parse_front_matter` gate.
  - Read story name and blocked state from the CRDT register API instead
    of parsed YAML (`crdt_state::read_item`, `pipeline_state::read_typed`).
  - Keep the legacy fallback cleanup, but gate it on the content actually
    starting with a `---` YAML block, so CRDT-only stories don't hit a
    parse error there either.
  - Remove the now-unused `parse_front_matter` import.

Surfaced a second sub-bug: even when the state-machine transition
fired (`Blocked + Unblock → Coding`), the CRDT `blocked` register was
never explicitly cleared. Pre-865 the YAML-strip content_transform
cleared it as a side effect; post-865 there is no YAML to strip.

  - Add `crdt_state::set_blocked(story_id, bool)` parallel to
    `set_retry_count`. Wired through `crdt_state::write` and the
    crate-level re-export.
  - `agents::lifecycle::transition_to_unblocked` now calls
    `set_blocked(story_id, false)` alongside `set_retry_count(0)` so
    the legacy register stays in sync with the typed stage.

Test: `unblock_command_works_on_crdt_only_story_no_yaml` seeds a CRDT
entry with no YAML on disk, runs unblock, asserts success + cleared
blocked + retry_count=0. All 10 existing unblock tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:13:01 +01:00
Timmy cd12cb5e2c fix: Bash(:*) is invalid; use unconstrained Bash instead
Claude Code rejects "Bash(:*)" with "Prefix cannot be empty before :*" —
the rule is silently skipped, which since 5b48f0d0 left no Bash entry
in the allowlist at all. Every coder agent's Bash call has been
auto-denying since that commit landed (~840 of 1.4k denials in the sled
log).

The canonical form for "allow all bash commands" is the tool name alone:
"Bash" (no parens). Apply it in three places that 5b48f0d0 touched:
  - .claude/settings.json (project root, inherited by new worktrees)
  - server/src/io/fs/scaffold/templates.rs (huskies init template)
  - server/src/io/fs/scaffold/tests.rs (assertion now checks "Bash")

The gateway settings.json at ~/Desktop/huskies/.claude/settings.json and
the four live worktrees (810, 888, 890, 894) were also corrected — not
in this commit since they live outside the repo.

Surfaced via /doctor; reported with rule "Invalid permission rule
Bash(:*) was skipped: Prefix cannot be empty before :*".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:46:34 +01:00
dave 9be438e6d3 huskies: merge 865 2026-05-08 14:29:06 +00:00
dave fac4442969 fix(896): disallow ScheduleWakeup for coder agents; add run_tests retry guidance
- Add `disallowed_tools` field to `AgentConfig` and render it as
  `--disallowedTools` CLI flag in `render_agent_args`
- Set `disallowed_tools = ["ScheduleWakeup"]` on all four coder agents
  (coder-1, coder-2, coder-3, coder-opus); QA and mergemaster unaffected
- Append instruction to all coder `system_prompt`s: do not use
  ScheduleWakeup to wait for run_tests; if run_tests appears to time out,
  call run_tests again — it attaches to the in-flight job and blocks
- Add tests: `render_agent_args_disallowed_tools` and
  `coder_agents_disallow_schedule_wakeup`
2026-05-08 15:28:48 +01:00
59 changed files with 1132 additions and 827 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"permissions": {
"allow": [
"Bash(:*)",
"Bash",
"Read",
"Edit",
"Write",
+12 -8
View File
@@ -5,8 +5,9 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 80
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
disallowed_tools = ["ScheduleWakeup"]
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
[[agent]]
name = "coder-2"
@@ -15,8 +16,9 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 80
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
disallowed_tools = ["ScheduleWakeup"]
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
[[agent]]
name = "coder-3"
@@ -25,8 +27,9 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 80
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
disallowed_tools = ["ScheduleWakeup"]
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
[[agent]]
name = "qa-2"
@@ -126,8 +129,9 @@ role = "Senior full-stack engineer for complex tasks. Implements features across
model = "opus"
max_turns = 80
max_budget_usd = 20.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. You handle complex tasks requiring deep architectural understanding. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
disallowed_tools = ["ScheduleWakeup"]
prompt ="You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. To read story content, ACs, or description, call the `get_story_todos` MCP tool — do NOT search for a story `.md` file on disk; story content is CRDT-only. You handle complex tasks requiring deep architectural understanding. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing. If run_tests errors with a transport timeout, call it again — it's idempotent and attaches to the same in-flight test job, so retries are safe and eventually return a pass/fail result."
[[agent]]
name = "qa"
+13 -8
View File
@@ -10,7 +10,7 @@ use std::num::NonZeroU32;
use std::path::Path;
use std::process::Command;
use crate::io::story_metadata::clear_front_matter_field_in_content;
use crate::db::yaml_legacy::clear_front_matter_field_in_content;
use crate::pipeline_state::{
ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, apply_transition,
stage_label,
@@ -34,7 +34,7 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
// Numeric-only ID: check content store front matter for explicit type.
if after_num.is_empty()
&& let Some(content) = crate::db::read_content(item_id)
&& let Ok(meta) = crate::io::story_metadata::parse_front_matter(&content)
&& let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&content)
&& let Some(t) = meta.item_type.as_deref()
{
return match t {
@@ -214,7 +214,7 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
let mut result = clear_front_matter_field_in_content(content, "review_hold");
if !notes_owned.is_empty() {
result =
crate::io::story_metadata::write_rejection_notes_to_content(&result, &notes_owned);
crate::db::yaml_legacy::write_rejection_notes_to_content(&result, &notes_owned);
}
result
});
@@ -257,7 +257,7 @@ pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String>
pub fn transition_to_merge_failure(story_id: &str, reason: &str) -> Result<(), String> {
let reason_owned = reason.to_string();
let transform: Box<dyn Fn(&str) -> String> = Box::new(move |content: &str| {
crate::io::story_metadata::write_merge_failure_in_content(content, &reason_owned)
crate::db::yaml_legacy::write_merge_failure_in_content(content, &reason_owned)
});
apply_transition(
story_id,
@@ -286,7 +286,11 @@ pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> {
.map(|_| ())
.map_err(|e| e.to_string())?;
// Reset the retry counter in the CRDT so the story gets a fresh budget.
// Reset CRDT registers so the legacy `blocked`/`retry_count` fields match
// the new typed stage. Pre-865, YAML stripping kept these in sync as a
// side-effect of the content_transform above; post-865 the content has no
// YAML, so we must clear the registers explicitly.
crate::crdt_state::set_blocked(story_id, false);
crate::crdt_state::set_retry_count(story_id, 0);
Ok(())
}
@@ -312,9 +316,10 @@ fn map_stage_move_to_event(
feature_branch: branch(),
commits_ahead: nz1(),
}),
(Stage::Coding, "backlog") | (Stage::Qa, "backlog") | (Stage::Merge { .. }, "backlog") => {
Ok(PipelineEvent::Demote)
}
(Stage::Coding, "backlog")
| (Stage::Qa, "backlog")
| (Stage::Merge { .. }, "backlog")
| (Stage::Blocked { .. }, "backlog") => Ok(PipelineEvent::Demote),
(Stage::Qa, "current") => Ok(PipelineEvent::GatesFailed {
reason: "manual move".to_string(),
}),
@@ -24,7 +24,7 @@ impl AgentPool {
/// logged so the user can see the promotion was triggered by an archived dep, not
/// a clean completion.
pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) {
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
let items = scan_stage_items(project_root, "1_backlog");
for story_id in &items {
+1 -1
View File
@@ -79,7 +79,7 @@ impl AgentPool {
// crash/restart doesn't re-trigger an infinite loop.
if let Some(contents) = crate::db::read_content(story_id) {
let updated =
crate::io::story_metadata::write_mergemaster_attempted_in_content(
crate::db::yaml_legacy::write_mergemaster_attempted_in_content(
&contents,
);
crate::db::write_content(story_id, &updated);
@@ -159,10 +159,7 @@ impl AgentPool {
let default_qa = crate::config::ProjectConfig::load(project_root)
.unwrap_or_default()
.default_qa_mode();
let story_path = project_root
.join(".huskies/work/2_current")
.join(format!("{story_id}.md"));
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa)
crate::io::story_metadata::resolve_qa_mode(story_id, default_qa)
}
};
@@ -221,9 +218,7 @@ impl AgentPool {
let story_path = project_root
.join(".huskies/work/3_qa")
.join(format!("{story_id}.md"));
if let Err(e) =
crate::io::story_metadata::write_review_hold(&story_path)
{
if let Err(e) = crate::db::yaml_legacy::write_review_hold(&story_path) {
eprintln!(
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
);
@@ -278,14 +273,11 @@ impl AgentPool {
if item_type == "spike" {
true
} else {
let story_path = project_root
.join(".huskies/work/3_qa")
.join(format!("{story_id}.md"));
let default_qa = crate::config::ProjectConfig::load(project_root)
.unwrap_or_default()
.default_qa_mode();
matches!(
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa),
crate::io::story_metadata::resolve_qa_mode(story_id, default_qa),
crate::io::story_metadata::QaMode::Human
)
}
@@ -295,7 +287,7 @@ impl AgentPool {
let story_path = project_root
.join(".huskies/work/3_qa")
.join(format!("{story_id}.md"));
if let Err(e) = crate::io::story_metadata::write_review_hold(&story_path) {
if let Err(e) = crate::db::yaml_legacy::write_review_hold(&story_path) {
eprintln!(
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
);
@@ -24,14 +24,14 @@ pub(super) fn read_story_front_matter_agent(
{
return Some(agent.clone());
}
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
let contents = read_story_contents(project_root, story_id)?;
parse_front_matter(&contents).ok()?.agent
}
/// Return `true` if the story file in the given stage has `review_hold: true` in its front matter.
pub(super) fn has_review_hold(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
@@ -52,7 +52,7 @@ pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id:
return true;
}
// Legacy fallback: check front-matter field for backward compatibility.
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
@@ -122,7 +122,7 @@ pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id:
/// Return `true` if the story file has a `merge_failure` field in its front matter.
pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
@@ -142,7 +142,7 @@ pub(super) fn has_content_conflict_failure(
_stage_dir: &str,
story_id: &str,
) -> bool {
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
@@ -163,7 +163,7 @@ pub(super) fn has_mergemaster_attempted(
_stage_dir: &str,
story_id: &str,
) -> bool {
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
@@ -80,7 +80,7 @@ pub(super) fn resolve_qa_mode_from_store(
/// Write review_hold to the content store.
pub(super) fn write_review_hold_to_store(story_id: &str) {
if let Some(contents) = crate::db::read_content(story_id) {
let updated = crate::io::story_metadata::write_review_hold_in_content(&contents);
let updated = crate::db::yaml_legacy::write_review_hold_in_content(&contents);
crate::db::write_content(story_id, &updated);
// Also persist to SQLite via shadow write.
let stage = crate::pipeline_state::read_typed(story_id)
+1 -1
View File
@@ -230,7 +230,7 @@ pub(super) async fn run_agent_spawn(
// content and prepend it to the system prompt so the agent treats it as
// authoritative context.
if let Some(story_content) = crate::db::read_content(&sid)
&& let Ok(meta) = crate::io::story_metadata::parse_front_matter(&story_content)
&& let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&story_content)
&& let Some(ref epic_id) = meta.epic
&& let Some(epic_content) = crate::db::read_content(epic_id)
{
+1 -1
View File
@@ -70,7 +70,7 @@ pub(super) fn read_front_matter_agent(story_id: &str, agent_name: Option<&str>)
return Some(agent.clone());
}
crate::db::read_content(story_id).and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents)
crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()?
.agent
})
+1 -1
View File
@@ -7,7 +7,7 @@
//! Passing no dependency numbers clears the field entirely.
use super::CommandContext;
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
/// Handle the `depends` command.
///
+1 -1
View File
@@ -4,7 +4,7 @@
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
use super::CommandContext;
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
/// Handle the `freeze` command.
+1 -1
View File
@@ -57,7 +57,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
};
let found_name = content
.and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok())
.and_then(|c| crate::db::yaml_legacy::parse_front_matter(&c).ok())
.and_then(|m| m.name);
let display_name = found_name.as_deref().unwrap_or(&story_id);
+1 -1
View File
@@ -109,7 +109,7 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<Stri
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
let (_, _, _, content) = crate::chat::lookup::find_story_by_number(root, num_str)?;
let content = content?;
crate::io::story_metadata::parse_front_matter(&content)
crate::db::yaml_legacy::parse_front_matter(&content)
.ok()
.and_then(|m| m.name)
}
+1 -1
View File
@@ -86,7 +86,7 @@ pub(crate) fn build_status_from_items(
.filter(|i| matches!(i.stage, Stage::Merge { .. }))
.filter_map(|i| {
let content = crate::db::read_content(&i.story_id.0)?;
let meta = crate::io::story_metadata::parse_front_matter(&content).ok()?;
let meta = crate::db::yaml_legacy::parse_front_matter(&content).ok()?;
let mf = meta.merge_failure?;
Some((i.story_id.0.clone(), mf))
})
+1 -1
View File
@@ -69,7 +69,7 @@ fn build_triage_dump(
None => return format!("Story {num_str}: content not found in content store."),
};
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
let meta = crate::db::yaml_legacy::parse_front_matter(&contents).ok();
let name = meta
.as_ref()
.and_then(|m| m.name.as_deref())
+127 -60
View File
@@ -5,7 +5,7 @@
//! and returns a confirmation.
use super::CommandContext;
use crate::io::story_metadata::{clear_front_matter_field_in_content, parse_front_matter};
use crate::db::yaml_legacy::clear_front_matter_field_in_content;
use std::path::Path;
/// Handle the `unblock` command.
@@ -50,77 +50,76 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
/// `blocked: true` / `merge_failure` front-matter, then routes through
/// [`crate::agents::lifecycle::transition_to_unblocked`].
fn unblock_by_story_id(story_id: &str) -> String {
// Read content for the story name and legacy field checks.
let contents = match crate::db::read_content(story_id) {
Some(c) => c,
None => return format!("Failed to read story content for **{story_id}**"),
};
// Post-865, story content is CRDT-only (no YAML front matter on disk or in
// the content store). Read the story name and blocked state from CRDT
// registers; do NOT call `parse_front_matter` on the content.
let crdt_item = crate::crdt_state::read_item(story_id);
let story_name = crdt_item
.as_ref()
.and_then(|i| i.name.clone())
.unwrap_or_else(|| story_id.to_string());
let meta = match parse_front_matter(&contents) {
Ok(m) => m,
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
};
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
// Check if the story is blocked via the typed stage or legacy front-matter.
let typed_blocked = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
// Canonical "is this story blocked?" comes from the typed pipeline state.
let typed_item = crate::pipeline_state::read_typed(story_id).ok().flatten();
let typed_blocked = typed_item
.as_ref()
.is_some_and(|item| item.stage.is_blocked());
let has_blocked = meta.blocked == Some(true);
let has_merge_failure = meta.merge_failure.is_some();
let typed_merge_failure = matches!(
typed_item.as_ref().map(|i| &i.stage),
Some(crate::pipeline_state::Stage::MergeFailure { .. })
);
// CRDT register fallback for items not yet projected into typed state.
let crdt_blocked = crdt_item.as_ref().and_then(|i| i.blocked).unwrap_or(false);
if !typed_blocked && !has_blocked && !has_merge_failure {
if !typed_blocked && !crdt_blocked {
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
}
// Route through the state machine.
match crate::agents::lifecycle::transition_to_unblocked(story_id) {
Ok(()) => {}
Err(e) => {
// If the typed transition fails (e.g. legacy Archived item),
// fall back to clearing front-matter fields directly.
crate::slog_warn!(
"[unblock] State-machine transition failed for '{story_id}': {e}. \
Falling back to front-matter cleanup."
);
let mut updated = contents;
if has_blocked {
// Route through the state machine. This clears blocked/merge_failure/
// retry_count via `fields_to_clear_transform` and resets retry_count in
// the CRDT.
if let Err(e) = crate::agents::lifecycle::transition_to_unblocked(story_id) {
// If the typed transition fails (e.g. a legacy Archived item with no
// valid `Unblock` transition out of its current stage), fall back to
// a direct CRDT/content cleanup. The legacy front-matter cleanup is
// gated on content actually still containing YAML, so post-865
// CRDT-only stories don't hit a parse error.
crate::slog_warn!(
"[unblock] State-machine transition failed for '{story_id}': {e}. \
Falling back to direct CRDT cleanup."
);
if let Some(content) = crate::db::read_content(story_id) {
// Only run legacy front-matter cleanup if the stored content still
// begins with a `---` YAML block. Post-865 content has been
// stripped and would no-op here anyway.
if content.trim_start().starts_with("---") {
let mut updated = content;
updated = clear_front_matter_field_in_content(&updated, "blocked");
}
if has_merge_failure {
updated = clear_front_matter_field_in_content(&updated, "merge_failure");
updated = clear_front_matter_field_in_content(&updated, "retry_count");
crate::db::write_content(story_id, &updated);
let stage = typed_item
.as_ref()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(
story_id,
&stage,
&updated,
crate::db::ItemMeta::from_yaml(&updated),
);
}
updated = clear_front_matter_field_in_content(&updated, "retry_count");
crate::db::write_content(story_id, &updated);
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(
story_id,
&stage,
&updated,
crate::db::ItemMeta::from_yaml(&updated),
);
crate::crdt_state::set_retry_count(story_id, 0);
}
crate::crdt_state::set_retry_count(story_id, 0);
}
let mut cleared = Vec::new();
if typed_blocked || has_blocked {
cleared.push("blocked");
}
if has_merge_failure {
cleared.push("merge_failure");
}
format!(
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
cleared.join(", ")
)
let cleared = if typed_merge_failure {
"merge_failure"
} else {
"blocked"
};
format!("Unblocked **{story_name}** ({story_id}). Cleared: {cleared}. Retry count reset to 0.")
}
// ---------------------------------------------------------------------------
@@ -278,6 +277,74 @@ mod tests {
);
}
#[test]
fn unblock_command_works_on_crdt_only_story_no_yaml() {
// Bug 901 regression: post-865, story content is CRDT-only with no
// YAML front matter on disk or in the content store. unblock_story
// used to fail here with "Missing front matter" because the legacy
// code parsed YAML before consulting the CRDT.
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::TempDir::new().unwrap();
// CRDT-only content: pure markdown body, no `---` block. Matches the
// post-865 on-disk shape.
let body = "# Stuck Story\n\nNo YAML front matter — this is the post-865 shape.\n";
let story_id = "9904_story_crdt_only";
// Canonical post-865 blocked story: stage is `2_blocked` (typed
// Stage::Blocked), so transition_to_unblocked can fire the proper
// Blocked → Coding state-machine transition.
let stage = "2_blocked";
// Seed content store with the YAML-less body so find_story_by_number's
// content-store path returns it.
crate::db::write_item_with_content(
story_id,
stage,
body,
crate::db::ItemMeta::from_yaml(body),
);
// Seed CRDT registers: blocked=true, retry_count=5, with a name so the
// response can echo it back instead of falling through to the raw id.
crate::crdt_state::write_item(
story_id,
stage,
Some("Stuck Story"),
None,
Some(5),
Some(true),
None,
None,
None,
None,
);
let output = unblock_cmd_with_root(tmp.path(), "9904").unwrap();
assert!(
!output.contains("Missing front matter")
&& !output.contains("Failed to parse front matter"),
"must not error on YAML parse for CRDT-only stories: {output}"
);
assert!(
output.contains("Unblocked") && output.contains("Stuck Story"),
"should confirm unblock with story name: {output}"
);
let item = crate::crdt_state::read_item(story_id)
.expect("story should still be in CRDT after unblock");
assert_eq!(
item.retry_count,
Some(0),
"retry_count must be reset to 0 in CRDT after unblock"
);
assert!(
!item.blocked.unwrap_or(false),
"blocked flag must be cleared in CRDT after unblock: {:?}",
item.blocked
);
}
#[test]
fn unblock_command_finds_story_in_any_stage() {
let tmp = tempfile::TempDir::new().unwrap();
+2 -2
View File
@@ -148,7 +148,7 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
if file_num == num_str
&& let Some(c) = crate::db::read_content(&id)
{
return crate::io::story_metadata::parse_front_matter(&c)
return crate::db::yaml_legacy::parse_front_matter(&c)
.ok()
.and_then(|m| m.name);
}
@@ -182,7 +182,7 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
.unwrap_or("");
if file_num == num_str {
return std::fs::read_to_string(&path).ok().and_then(|c| {
crate::io::story_metadata::parse_front_matter(&c)
crate::db::yaml_legacy::parse_front_matter(&c)
.ok()
.and_then(|m| m.name)
});
+1 -1
View File
@@ -11,7 +11,7 @@
use crate::agents::{AgentPool, AgentStatus};
use crate::chat::util::strip_bot_mention;
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
/// A parsed assign command from a Matrix message body.
+1 -1
View File
@@ -71,7 +71,7 @@ pub async fn handle_delete(
let story_name = content
.and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents)
crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()
.and_then(|m| m.name)
})
+1 -1
View File
@@ -90,7 +90,7 @@ pub async fn handle_start(
let story_name = content
.and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents)
crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()
.and_then(|m| m.name)
})
+9
View File
@@ -239,6 +239,8 @@ pub struct AgentConfig {
#[serde(default)]
pub allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub disallowed_tools: Option<Vec<String>>,
#[serde(default)]
pub max_turns: Option<u32>,
#[serde(default)]
pub max_budget_usd: Option<f64>,
@@ -321,6 +323,7 @@ impl Default for ProjectConfig {
prompt: default_agent_prompt(),
model: None,
allowed_tools: None,
disallowed_tools: None,
max_turns: None,
max_budget_usd: None,
system_prompt: None,
@@ -573,6 +576,12 @@ impl ProjectConfig {
args.push("--allowedTools".to_string());
args.push(tools.join(","));
}
if let Some(ref tools) = agent.disallowed_tools
&& !tools.is_empty()
{
args.push("--disallowedTools".to_string());
args.push(tools.join(","));
}
if let Some(turns) = agent.max_turns {
args.push("--max-turns".to_string());
args.push(turns.to_string());
+50
View File
@@ -627,3 +627,53 @@ fn project_toml_has_three_sonnet_coders() {
sonnet_coders.len()
);
}
#[test]
fn render_agent_args_disallowed_tools() {
let toml_str = r#"
[[agent]]
name = "coder"
model = "sonnet"
disallowed_tools = ["ScheduleWakeup", "SomeTool"]
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
let (_, args, _) = config
.render_agent_args("/tmp/wt", "42_foo", None, None)
.unwrap();
assert!(
args.contains(&"--disallowedTools".to_string()),
"Expected --disallowedTools flag in args"
);
assert!(
args.contains(&"ScheduleWakeup,SomeTool".to_string()),
"Expected disallowed tools joined as comma-separated string"
);
}
#[test]
fn coder_agents_disallow_schedule_wakeup() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let project_root = manifest_dir.parent().unwrap();
let config = ProjectConfig::load(project_root).unwrap();
let coder_agents: Vec<_> = config
.agent
.iter()
.filter(|a| a.stage.as_deref() == Some("coder"))
.collect();
assert!(
!coder_agents.is_empty(),
"Expected at least one coder-stage agent"
);
for agent in coder_agents {
let disallowed = agent.disallowed_tools.as_deref().unwrap_or(&[]);
assert!(
disallowed.iter().any(|t| t == "ScheduleWakeup"),
"Coder agent '{}' must have ScheduleWakeup in disallowed_tools",
agent.name
);
}
}
+2 -1
View File
@@ -53,7 +53,8 @@ pub use types::{
};
pub use write::{
bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id,
set_agent, set_depends_on, set_mergemaster_attempted, set_qa_mode, set_retry_count, write_item,
set_agent, set_blocked, set_depends_on, set_mergemaster_attempted, set_qa_mode,
set_retry_count, write_item,
};
#[cfg(test)]
+19
View File
@@ -285,6 +285,25 @@ pub fn set_retry_count(story_id: &str, count: i64) {
}
}
/// Set the `blocked` register on a story to the given value.
///
/// Pure metadata operation — the item's stage is not changed.
/// Use this alongside a state-machine transition out of `Blocked` /
/// `MergeFailure` to keep the legacy `blocked` register in sync with the
/// typed stage post-865 (where YAML side-effects no longer clear the
/// register on their own).
pub fn set_blocked(story_id: &str, blocked: bool) {
let Some(state_mutex) = get_crdt() else {
return;
};
let Ok(mut state) = state_mutex.lock() else {
return;
};
if let Some(&idx) = state.index.get(story_id) {
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].blocked.set(blocked));
}
}
/// Increment `retry_count` by 1 and return the new value.
///
/// Pure metadata operation — the item's stage is not changed.
+2 -2
View File
@@ -10,7 +10,7 @@ mod migrations;
mod tests;
pub use item::{
bump_retry_count, set_agent, set_depends_on, set_mergemaster_attempted, set_qa_mode,
set_retry_count, write_item,
bump_retry_count, set_agent, set_blocked, set_depends_on, set_mergemaster_attempted,
set_qa_mode, set_retry_count, write_item,
};
pub use migrations::{migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id};
+33 -1
View File
@@ -19,6 +19,9 @@ pub mod content_store;
pub mod ops;
/// Background shadow-write task — persists pipeline items to SQLite asynchronously.
pub mod shadow_write;
/// Legacy YAML helpers — used by callers reading the small set of fields not
/// yet mirrored into the CRDT.
pub(crate) mod yaml_legacy;
pub use content_store::{all_content_ids, delete_content, read_content, write_content};
pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content};
@@ -30,7 +33,7 @@ pub use content_store::ensure_content_store;
#[cfg(test)]
mod tests {
use super::*;
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs;
/// Helper: write a minimal story .md file with front matter.
@@ -379,6 +382,35 @@ mod tests {
);
}
/// Regression (story 894): startup with no YAML migration call leaves
/// existing clean content readable and unmodified. Verifies that removing
/// `db::yaml_migration::run()` from the startup path does not break reads.
#[test]
fn startup_reads_clean_content_unchanged() {
crate::crdt_state::init_for_test();
ensure_content_store();
let story_id = "9894_story_clean_content";
// Plain body — no YAML block, representing post-migration state.
let body = "# Story Heading\n\nSome content.\n";
write_item_with_content(
story_id,
"2_current",
body,
ItemMeta {
name: Some("Clean".into()),
..ItemMeta::default()
},
);
let read_back = read_content(story_id).expect("content present");
assert_eq!(read_back, body, "plain content must be readable as-is");
assert!(
!read_back.trim_start().starts_with("---"),
"no YAML header should appear"
);
}
/// Bug 780: stage transitions must reset retry_count to 0 in the CRDT.
/// Carryover from prior-stage retries was tripping the auto-assigner's
/// deterministic-merge skip logic.
+1 -1
View File
@@ -7,7 +7,7 @@ use super::content_store::{
all_content_ids, delete_content, ensure_content_store, read_content, write_content,
};
use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg};
use crate::io::story_metadata::parse_front_matter;
use super::yaml_legacy::parse_front_matter;
/// Typed metadata for a pipeline item write.
///
+254
View File
@@ -0,0 +1,254 @@
//! Legacy YAML front-matter helpers — kept ONLY for the one-shot migration
//! and for the small set of fields not yet mirrored into the CRDT
//! (`item_type`, `epic`, `review_hold`, `merge_failure` reason text, etc.).
//!
//! After the migration runs, every body in the content store is YAML-free, so
//! every helper here returns `Ok(None)` / a no-op on the next read. Callers
//! should treat this module as a deprecated escape hatch — new code should
//! read typed CRDT registers instead.
use crate::io::story_metadata::QaMode;
use serde::Deserialize;
use std::fs;
use std::path::Path;
/// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors
/// the original `io::story_metadata::FrontMatter`.
#[derive(Debug, Default, Deserialize)]
pub(crate) struct FrontMatter {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<String>,
pub retry_count: Option<u32>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
pub frozen: Option<bool>,
pub resume_to_stage: Option<String>,
pub run_tests_passed: Option<bool>,
#[serde(rename = "type")]
pub item_type: Option<String>,
pub mergemaster_attempted: Option<bool>,
pub epic: Option<String>,
}
/// Parsed metadata view returned by [`parse_front_matter`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct StoryMetadata {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<QaMode>,
pub retry_count: Option<u32>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
pub frozen: Option<bool>,
pub resume_to_stage: Option<String>,
pub run_tests_passed: Option<bool>,
pub item_type: Option<String>,
pub mergemaster_attempted: Option<bool>,
pub epic: Option<String>,
}
/// Errors that can occur when parsing legacy YAML front matter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StoryMetaError {
MissingFrontMatter,
InvalidFrontMatter(String),
}
impl std::fmt::Display for StoryMetaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingFrontMatter => write!(f, "Missing front matter"),
Self::InvalidFrontMatter(m) => write!(f, "Invalid front matter: {m}"),
}
}
}
/// Parse the YAML front-matter block from a markdown body.
///
/// Post-migration this returns `Err(StoryMetaError::MissingFrontMatter)` for
/// every body since the front matter has been stripped. Callers that need
/// fields not stored in the CRDT (`item_type`, `epic`, …) should treat the
/// missing-front-matter case as "default value".
pub(crate) fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
let mut lines = contents.lines();
let first = lines.next().unwrap_or_default().trim();
if first != "---" {
return Err(StoryMetaError::MissingFrontMatter);
}
let mut front_lines = Vec::new();
for line in &mut lines {
if line.trim() == "---" {
let raw = front_lines.join("\n");
let front: FrontMatter = serde_yaml::from_str(&raw)
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
return Ok(StoryMetadata {
qa: front.qa.as_deref().and_then(QaMode::from_str),
name: front.name,
coverage_baseline: front.coverage_baseline,
merge_failure: front.merge_failure,
agent: front.agent,
review_hold: front.review_hold,
retry_count: front.retry_count,
blocked: front.blocked,
depends_on: front.depends_on,
frozen: front.frozen,
resume_to_stage: front.resume_to_stage,
run_tests_passed: front.run_tests_passed,
item_type: front.item_type,
mergemaster_attempted: front.mergemaster_attempted,
epic: front.epic,
});
}
front_lines.push(line);
}
Err(StoryMetaError::InvalidFrontMatter(
"Missing closing front matter delimiter".to_string(),
))
}
/// Insert or update a `key: value` line in the YAML front matter of a
/// markdown string. Returns the input unchanged if no `---` block is found.
pub(crate) fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
}
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
Some(i) => i + 1,
None => return contents.to_string(),
};
let key_prefix = format!("{key}:");
let existing_idx = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1);
let new_line = format!("{key}: {value}");
if let Some(idx) = existing_idx {
lines[idx] = new_line;
} else {
lines.insert(close_idx, new_line);
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
/// Remove a `key: value` line from the YAML front matter of a markdown string.
pub(crate) fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
}
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
Some(i) => i + 1,
None => return contents.to_string(),
};
let key_prefix = format!("{key}:");
if let Some(idx) = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1)
{
lines.remove(idx);
} else {
return contents.to_string();
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
/// Append rejection notes to a markdown body.
pub(crate) fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String {
format!("{contents}\n\n## QA Rejection Notes\n\n{notes}\n")
}
/// Write or update `merge_failure` in story content.
pub(crate) fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
let escaped = reason
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', "");
set_front_matter_field(contents, "merge_failure", &format!("\"{escaped}\""))
}
/// Write `review_hold: true` to story content.
pub(crate) fn write_review_hold_in_content(contents: &str) -> String {
set_front_matter_field(contents, "review_hold", "true")
}
/// Write `mergemaster_attempted: true` to story content.
pub(crate) fn write_mergemaster_attempted_in_content(contents: &str) -> String {
set_front_matter_field(contents, "mergemaster_attempted", "true")
}
/// Remove a key from the YAML front matter of a story file on disk.
///
/// Legacy filesystem-backed wrapper around
/// [`clear_front_matter_field_in_content`] for the small number of callers
/// that still read story files directly.
pub(crate) fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let updated = clear_front_matter_field_in_content(&contents, key);
if updated != contents {
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
}
Ok(())
}
/// Write `review_hold: true` to the YAML front matter of a story file on disk.
///
/// Legacy filesystem-backed wrapper around [`write_review_hold_in_content`].
pub(crate) fn write_review_hold(path: &Path) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let updated = write_review_hold_in_content(&contents);
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_front_matter_round_trips_basic_fields() {
let input = "---\nname: Test\nagent: coder-1\n---\n# Body\n";
let meta = parse_front_matter(input).expect("parse");
assert_eq!(meta.name.as_deref(), Some("Test"));
assert_eq!(meta.agent.as_deref(), Some("coder-1"));
}
#[test]
fn parse_front_matter_returns_missing_when_no_yaml() {
let err = parse_front_matter("# Plain markdown\n").unwrap_err();
assert_eq!(err, StoryMetaError::MissingFrontMatter);
}
#[test]
fn set_front_matter_field_inserts_when_absent() {
let out = set_front_matter_field("---\nname: X\n---\n# B\n", "agent", "coder-1");
assert!(out.contains("agent: coder-1"));
}
#[test]
fn clear_front_matter_field_removes_key() {
let out = clear_front_matter_field_in_content(
"---\nname: X\nblocked: true\n---\n# B\n",
"blocked",
);
assert!(!out.contains("blocked"));
}
}
+128 -1
View File
@@ -14,6 +14,9 @@ pub mod dispatch;
pub mod git_tools;
/// MCP tools for merge status and merge-to-master operations.
pub mod merge_tools;
/// Task-local progress emitter used to deliver `notifications/progress`
/// during long-running tool calls so MCP clients' socket timers reset.
pub mod progress;
/// MCP tools for QA request, approve, and reject workflows.
pub mod qa_tools;
/// MCP tools for running shell commands and test suites.
@@ -36,10 +39,12 @@ use crate::http::gateway::jsonrpc::JsonRpcResponse;
use poem::handler;
use poem::http::StatusCode;
use poem::web::Data;
use poem::{Body, Request, Response};
use poem::web::sse::{Event, SSE};
use poem::{Body, IntoResponse, Request, Response};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
#[derive(Deserialize)]
struct JsonRpcRequest {
@@ -102,6 +107,24 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
return json_response(JsonRpcResponse::error(None, -32600, "Missing id".into()));
}
// Progress-aware path: only `tools/call` is long-running, and only when
// the client supplies a `progressToken` AND accepts `text/event-stream`
// do we take the SSE branch. Everything else returns plain JSON as before.
if rpc.method == "tools/call" {
let accepts_sse = req
.header("accept")
.map(|h| h.contains("text/event-stream"))
.unwrap_or(false);
let progress_token = rpc
.params
.get("_meta")
.and_then(|m| m.get("progressToken"))
.cloned();
if let (true, Some(token)) = (accepts_sse, progress_token) {
return sse_tools_call(rpc.id, rpc.params, token, Arc::clone(&ctx)).await;
}
}
let resp = match rpc.method.as_str() {
"initialize" => handle_initialize(rpc.id),
"tools/list" => {
@@ -114,6 +137,110 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
json_response(resp)
}
/// SSE variant of `tools/call`. Installs a progress emitter in a
/// `tokio::task_local!` scope before dispatching the tool, then streams
/// `notifications/progress` events as they arrive followed by the final
/// JSON-RPC response. Keep-alive comments every 15s keep idle connections
/// from being closed by intermediate proxies even before the tool emits its
/// first progress event.
async fn sse_tools_call(
id: Option<Value>,
params: Value,
progress_token: Value,
ctx: Arc<AppContext>,
) -> Response {
use tokio::sync::mpsc::unbounded_channel;
let tool_name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let (tx, mut rx) = unbounded_channel::<Value>();
let emitter = progress::ProgressEmitter {
token: progress_token,
tx,
};
// Spawn dispatch with the emitter installed in the task-local. The
// task runs to completion regardless of whether the SSE consumer is
// still listening (so the test job, etc. still finishes and writes
// its final state to the CRDT even on client disconnect).
let dispatch_ctx = Arc::clone(&ctx);
let dispatch_handle = tokio::spawn(async move {
progress::EMITTER
.scope(emitter, async move {
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
})
.await
});
let id_for_final = id;
let stream = async_stream::stream! {
let mut dispatch_handle = Some(dispatch_handle);
loop {
tokio::select! {
biased;
Some(notification) = rx.recv() => {
let data = serde_json::to_string(&notification).unwrap_or_default();
yield Event::message(data);
}
join_result = async {
match dispatch_handle.as_mut() {
Some(h) => h.await,
None => std::future::pending().await,
}
}, if dispatch_handle.is_some() => {
// Mark the handle taken so the select arm's guard
// disables on the next iteration (we break before
// looping but the explicit take is clearer).
let _ = dispatch_handle.take();
// Drain any progress events that landed while we were
// racing the select arm — they would otherwise sit in
// the receiver and never be flushed.
while let Ok(notification) = rx.try_recv() {
let data = serde_json::to_string(&notification).unwrap_or_default();
yield Event::message(data);
}
let final_resp = match join_result {
Ok(Ok(content)) => JsonRpcResponse::success(
id_for_final.clone(),
json!({ "content": [{ "type": "text", "text": content }] }),
),
Ok(Err(msg)) => {
crate::slog_warn!("[mcp/sse] Tool call failed: {msg}");
JsonRpcResponse::success(
id_for_final.clone(),
json!({
"content": [{ "type": "text", "text": msg }],
"isError": true
}),
)
}
Err(join_err) => {
crate::slog_warn!("[mcp/sse] Tool dispatch task panicked: {join_err}");
JsonRpcResponse::error(
id_for_final.clone(),
-32603,
format!("internal error: {join_err}"),
)
}
};
let data = serde_json::to_string(&final_resp).unwrap_or_default();
yield Event::message(data);
break;
}
}
}
};
SSE::new(stream)
.keep_alive(Duration::from_secs(15))
.into_response()
}
fn json_response(resp: JsonRpcResponse) -> Response {
let body = serde_json::to_vec(&resp).unwrap_or_default();
Response::builder()
+123
View File
@@ -0,0 +1,123 @@
//! Task-local plumbing for MCP `notifications/progress` events.
//!
//! Long-running tool handlers (notably `tool_run_tests`) emit progress events
//! during execution so the MCP client's transport timer resets and the call
//! never surfaces as a tool-call error to the agent. The HTTP MCP handler
//! installs an emitter via `tokio::task_local!` before dispatching the tool
//! and converts emitted events into SSE messages on the response stream.
//!
//! Tool handlers call [`emit_progress`] unconditionally — when no emitter is
//! installed (plain JSON response path, or test code), the call is a no-op.
//! That keeps handler code free of `if let Some(...) =` ceremony for the
//! progress-aware branch.
//!
//! Wire format follows MCP 2025-03-26: the emitter wraps the data in a
//! standard JSON-RPC notification with method `notifications/progress` and a
//! `progressToken` echoed back to the client.
use serde_json::{Value, json};
use tokio::sync::mpsc::UnboundedSender;
/// Per-request channel for emitting progress notifications back to an MCP
/// client. Installed in a `tokio::task_local!` scope by the SSE response
/// path and consumed by the SSE stream producer.
#[derive(Clone)]
pub struct ProgressEmitter {
/// The client-supplied opaque token from `params._meta.progressToken`.
/// Echoed back unchanged in every progress notification so the client
/// can correlate progress with the originating request.
pub token: Value,
/// Channel into the SSE response stream. Each value sent is a
/// fully-formed `notifications/progress` JSON-RPC message ready to be
/// serialised as an SSE `data:` field.
pub tx: UnboundedSender<Value>,
}
tokio::task_local! {
/// Set by the SSE response path before dispatching a tool call. Unset
/// in the plain JSON path and in tests, where [`emit_progress`] no-ops.
pub static EMITTER: ProgressEmitter;
}
/// Emit a progress notification to the current request's SSE stream, if one
/// is attached. No-op when no emitter is in scope (plain JSON path).
///
/// `progress` is a monotonically increasing value (typically seconds elapsed
/// for long-running tools); `total` is optional and omitted when unknown;
/// `message` is a short human-readable description of the current state.
pub fn emit_progress(progress: f64, total: Option<f64>, message: Option<&str>) {
let _ = EMITTER.try_with(|e| {
let mut params = json!({
"progressToken": e.token,
"progress": progress,
});
if let Some(t) = total {
params["total"] = json!(t);
}
if let Some(m) = message {
params["message"] = json!(m);
}
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": params,
});
// Send is fire-and-forget. If the receiver dropped (client
// disconnected mid-stream), we don't care — the tool dispatch
// task keeps running and writes its final state to the CRDT.
let _ = e.tx.send(notification);
});
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc::unbounded_channel;
#[tokio::test]
async fn emit_progress_no_op_without_emitter() {
// Calling outside a task_local scope must not panic.
emit_progress(1.0, None, Some("hello"));
}
#[tokio::test]
async fn emit_progress_sends_notification_when_emitter_installed() {
let (tx, mut rx) = unbounded_channel();
let emitter = ProgressEmitter {
token: json!("test-token"),
tx,
};
EMITTER
.scope(emitter, async {
emit_progress(5.0, Some(10.0), Some("halfway"));
})
.await;
let notif = rx.recv().await.expect("notification must be delivered");
assert_eq!(notif["method"], "notifications/progress");
assert_eq!(notif["params"]["progressToken"], "test-token");
assert_eq!(notif["params"]["progress"], 5.0);
assert_eq!(notif["params"]["total"], 10.0);
assert_eq!(notif["params"]["message"], "halfway");
}
#[tokio::test]
async fn emit_progress_omits_optional_fields() {
let (tx, mut rx) = unbounded_channel();
let emitter = ProgressEmitter {
token: json!(42),
tx,
};
EMITTER
.scope(emitter, async {
emit_progress(1.0, None, None);
})
.await;
let notif = rx.recv().await.unwrap();
assert_eq!(notif["params"]["progressToken"], 42);
assert_eq!(notif["params"]["progress"], 1.0);
assert!(notif["params"].get("total").is_none());
assert!(notif["params"].get("message").is_none());
}
}
+2 -2
View File
@@ -59,7 +59,7 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
.join(".huskies/work/3_qa")
.join(format!("{story_id}.md"));
if qa_path.exists() {
let _ = crate::io::story_metadata::clear_front_matter_field(&qa_path, "review_hold");
let _ = crate::db::yaml_legacy::clear_front_matter_field(&qa_path, "review_hold");
}
if is_spike(story_id) {
@@ -142,7 +142,7 @@ pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<Str
.join(format!("{story_id}.md"));
let agent_name = if story_path.exists() {
let contents = std::fs::read_to_string(&story_path).unwrap_or_default();
crate::io::story_metadata::parse_front_matter(&contents)
crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()
.and_then(|meta| meta.agent)
} else {
+158 -11
View File
@@ -13,6 +13,10 @@ use super::exec::validate_working_dir;
const TEST_TIMEOUT_SECS: u64 = 1200;
const MAX_OUTPUT_LINES: usize = 100;
/// How often `tool_run_tests` emits a `notifications/progress` event while a
/// test job is in flight. Chosen well below typical MCP HTTP transport
/// timeouts (~60s) so the client's socket timer resets before it can fire.
const PROGRESS_INTERVAL_SECS: u64 = 25;
// ── In-flight process registry ───────────────────────────────────────────────
//
@@ -70,15 +74,31 @@ pub(crate) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
let sid = story_key(&working_dir);
// Kill any existing in-flight job for this worktree before starting a new one.
{
let mut jobs = active_jobs().lock().map_err(|e| e.to_string())?;
if let Some(mut old_job) = jobs.remove(&working_dir) {
let _ = old_job.child.kill();
let _ = old_job.child.wait();
}
// If a test job is already in flight for this worktree, ATTACH to it
// rather than kill+respawn. This makes the agent system_prompt advice
// ("if run_tests appears to time out, call run_tests again — it
// attaches to the in-flight test job") actually true, and eliminates
// the respawn-loop bug where MCP client-side timeouts (~60s) cause
// agents to retry, killing the still-running cargo build each time
// and never making progress. The original job's poll loop below
// updates the CRDT on completion; attached callers just poll the CRDT.
let already_running = {
let jobs = active_jobs().lock().map_err(|e| e.to_string())?;
jobs.contains_key(&working_dir)
};
if already_running {
crate::slog!(
"[run_tests] Attaching to in-flight test job for {}",
working_dir.display()
);
return attach_to_in_flight_test_job(&sid).await;
}
// Worktrees are isolated and may run cargo tests concurrently — no
// cross-worktree serialisation. The only invariant enforced here is
// "at most one test job per worktree at a time", which the
// already_running check above gives us.
// Spawn the test process with piped stdout/stderr so we can capture output.
// Pipes are drained in background threads to prevent deadlock when the
// child fills the 64KB OS pipe buffer.
@@ -126,9 +146,24 @@ pub(crate) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
// Block server-side, checking every second until done or timeout.
let start = std::time::Instant::now();
let mut last_progress_emit_secs: u64 = 0;
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Emit periodic progress notifications so any SSE consumer's MCP
// transport timer resets and the tool call doesn't surface a
// transport-timeout error to the agent. No-op when there is no
// emitter (plain JSON path).
let elapsed_secs = start.elapsed().as_secs();
if elapsed_secs.saturating_sub(last_progress_emit_secs) >= PROGRESS_INTERVAL_SECS {
crate::http::mcp::progress::emit_progress(
elapsed_secs as f64,
None,
Some(&format!("running tests: {elapsed_secs}s elapsed")),
);
last_progress_emit_secs = elapsed_secs;
}
let mut jobs = active_jobs().lock().map_err(|e| e.to_string())?;
let job = match jobs.get_mut(&working_dir) {
Some(j) => j,
@@ -149,9 +184,18 @@ pub(crate) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
.unwrap_or_default();
let combined = format!("{stdout}{stderr}");
let (tests_passed, tests_failed) = parse_test_counts(&combined);
let truncated = truncate_output(&combined, MAX_OUTPUT_LINES);
let passed = status.success();
let exit_code = status.code().unwrap_or(-1);
// On success the full cargo output is pure noise that burns
// agent tokens — replace with a one-line summary. On failure
// we keep the tail (which usually contains the `failures:`
// section + final test_result line) so agents have enough
// context to fix the failing tests.
let response_output = if passed {
format!("All {tests_passed} tests passed.")
} else {
truncate_output(&combined, MAX_OUTPUT_LINES)
};
let crdt_status = if passed { "pass" } else { "fail" };
crate::slog!(
"[run_tests] Test job for {} finished (pid {}, passed={})",
@@ -160,13 +204,14 @@ pub(crate) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
passed
);
// Persist result in CRDT for post-restart visibility.
// Persist result in CRDT for post-restart visibility and so
// attached callers see the same filtered output.
crate::crdt_state::write_test_job(
&sid,
crdt_status,
started_at_unix,
Some(unix_now()),
Some(&truncated),
Some(&response_output),
);
// Capture positive test evidence in the DB so the pipeline
@@ -182,7 +227,7 @@ pub(crate) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
"timed_out": false,
"tests_passed": tests_passed,
"tests_failed": tests_failed,
"output": truncated,
"output": response_output,
}))
.map_err(|e| format!("Serialization error: {e}"));
}
@@ -233,6 +278,54 @@ pub(crate) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
}
}
/// Poll the CRDT `test_jobs` collection for `sid` until the entry transitions
/// out of "running" or we hit [`TEST_TIMEOUT_SECS`].
///
/// Used by `run_tests` to attach to a job that another caller already spawned
/// for the same worktree, so concurrent callers all observe the same single
/// `cargo test` run rather than racing to kill+respawn.
async fn attach_to_in_flight_test_job(sid: &str) -> Result<String, String> {
let start = std::time::Instant::now();
let mut last_progress_emit_secs: u64 = 0;
loop {
match crate::crdt_state::read_test_job(sid) {
None => {
// The job entry disappeared from the CRDT — most likely the
// spawning task lost the race between insert/read. Treat as a
// transient error so the agent can retry.
return Err("In-flight test job vanished from CRDT before completing".to_string());
}
Some(view) if view.status == "pass" || view.status == "fail" => {
return format_crdt_result(&view);
}
Some(_) => {
// Still "running" — wait and re-poll.
}
}
// Keep the SSE consumer's MCP socket alive while we wait. No-op when
// no emitter is installed (plain JSON path).
let elapsed_secs = start.elapsed().as_secs();
if elapsed_secs.saturating_sub(last_progress_emit_secs) >= PROGRESS_INTERVAL_SECS {
crate::http::mcp::progress::emit_progress(
elapsed_secs as f64,
None,
Some(&format!(
"attached to in-flight test job: {elapsed_secs}s elapsed"
)),
);
last_progress_emit_secs = elapsed_secs;
}
if elapsed_secs > TEST_TIMEOUT_SECS {
return Err(format!(
"Attached test job for '{sid}' did not complete within {TEST_TIMEOUT_SECS}s"
));
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
// ── get_test_result ──────────────────────────────────────────────────────────
/// How long `get_test_result` blocks server-side before returning "running".
@@ -536,6 +629,60 @@ mod tests {
assert_eq!(parsed["exit_code"], 1);
}
#[tokio::test]
async fn tool_run_tests_concurrent_calls_attach_to_single_job() {
// Bug 903 regression: a second `run_tests` call for the same worktree
// while the first is still in flight must ATTACH to that job (i.e.
// observe its result) rather than kill+respawn. Pre-fix, every call
// killed the prior `cargo test` child and spawned a fresh one,
// creating a respawn loop driven by the agent's MCP-timeout retries.
let tmp = tempfile::tempdir().unwrap();
let script_dir = tmp.path().join("script");
std::fs::create_dir_all(&script_dir).unwrap();
let script_path = script_dir.join("test");
// Slow enough that the second call definitely overlaps the first.
std::fs::write(
&script_path,
"#!/usr/bin/env bash\nsleep 2\necho 'test result: ok. 0 passed'\nexit 0\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = test_ctx(tmp.path());
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let start = std::time::Instant::now();
let ctx_a = ctx.clone();
let ctx_b = ctx.clone();
let t1 = tokio::spawn(async move { tool_run_tests(&json!({}), &ctx_a).await });
// Give T1 time to spawn its child + insert into active_jobs before T2
// arrives, so T2 deterministically takes the attach path.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let t2 = tokio::spawn(async move { tool_run_tests(&json!({}), &ctx_b).await });
let r1 = t1.await.unwrap().unwrap();
let r2 = t2.await.unwrap().unwrap();
let elapsed = start.elapsed().as_secs_f64();
let p1: serde_json::Value = serde_json::from_str(&r1).unwrap();
let p2: serde_json::Value = serde_json::from_str(&r2).unwrap();
assert_eq!(p1["passed"], true, "first call must succeed: {p1}");
assert_eq!(p2["passed"], true, "second call must succeed: {p2}");
// If the second call had killed + respawned, total elapsed would be
// ~4s (two 2s runs serially). With attach, both calls observe the
// SAME single 2s run, so elapsed stays close to 2s.
assert!(
elapsed < 3.5,
"concurrent calls must share one job (~2s), not respawn (~4s); elapsed={elapsed:.2}s"
);
}
#[tokio::test]
async fn tool_run_tests_worktree_path_must_be_inside_worktrees() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -176,7 +176,7 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
// --- Front matter ---
let mut front_matter = serde_json::Map::new();
if let Ok(meta) = crate::io::story_metadata::parse_front_matter(&contents) {
if let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&contents) {
if let Some(name) = &meta.name {
front_matter.insert("name".to_string(), json!(name));
}
+2 -1
View File
@@ -5,6 +5,7 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
@@ -13,7 +14,7 @@ use crate::http::workflow::{
update_story_in_file, validate_story_dirs,
};
use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
};
use crate::service::story::parse_test_cases;
use crate::slog_warn;
+2 -1
View File
@@ -5,6 +5,7 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
@@ -13,7 +14,7 @@ use crate::http::workflow::{
update_story_in_file, validate_story_dirs,
};
use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
};
use crate::service::story::parse_test_cases;
use crate::slog_warn;
+1 -1
View File
@@ -4,9 +4,9 @@
//! and refactors. They are not pipeline-driven but provide authoritative context
//! injected into agent prompts for all member work items.
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::create_epic_file;
use crate::io::story_metadata::parse_front_matter;
use serde_json::{Value, json};
/// Create a new epic and store it in the CRDT items list.
+2 -1
View File
@@ -5,6 +5,7 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
@@ -13,7 +14,7 @@ use crate::http::workflow::{
update_story_in_file, validate_story_dirs,
};
use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
};
use crate::service::story::parse_test_cases;
use crate::slog_warn;
+2 -1
View File
@@ -5,6 +5,7 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
@@ -13,7 +14,7 @@ use crate::http::workflow::{
update_story_in_file, validate_story_dirs,
};
use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
};
use crate::service::story::parse_test_cases;
use crate::slog_warn;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Bug-item creation and listing operations.
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content};
+1 -1
View File
@@ -1,6 +1,6 @@
//! Refactor-item creation and listing operations.
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content};
+1 -1
View File
@@ -3,7 +3,7 @@
use super::bug::{create_bug_file, extract_bug_name_from_content, list_bug_files};
use super::refactor::{create_refactor_file, list_refactor_files};
use super::spike::create_spike_file;
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs;
fn setup_git_repo(root: &std::path::Path) {
+1 -1
View File
@@ -1,8 +1,8 @@
//! Pipeline state — types and loading functions for the story pipeline.
use crate::agents::AgentStatus;
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::io::story_metadata::parse_front_matter;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
+2 -2
View File
@@ -80,7 +80,7 @@ pub fn create_story_file(
#[cfg(test)]
mod tests {
use super::*;
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs;
#[allow(dead_code)]
@@ -262,7 +262,7 @@ mod tests {
create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false)
.unwrap();
let content = crate::db::read_content(&story_id).expect("content must exist");
let meta = crate::io::story_metadata::parse_front_matter(&content)
let meta = crate::db::yaml_legacy::parse_front_matter(&content)
.expect("front matter should be valid");
assert_eq!(
meta.item_type.as_deref(),
+2 -2
View File
@@ -10,7 +10,7 @@ use super::super::{
slugify_name, story_stage, write_story_content,
};
use crate::io::story_metadata::set_front_matter_field;
use crate::db::yaml_legacy::set_front_matter_field;
fn json_value_to_yaml_scalar(value: &Value) -> String {
match value {
@@ -114,7 +114,7 @@ pub fn update_story_in_file(
#[cfg(test)]
mod tests {
use super::*;
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs;
#[allow(dead_code)]
+1 -1
View File
@@ -1,5 +1,5 @@
//! Test result persistence — writes structured test results into story markdown files.
use crate::io::story_metadata::set_front_matter_field;
use crate::db::yaml_legacy::set_front_matter_field;
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
use std::path::Path;
+1 -1
View File
@@ -70,7 +70,7 @@ setup wizard instructions and guide the user through it conversationally.\n";
pub(super) const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
"permissions": {
"allow": [
"Bash(:*)",
"Bash",
"Read",
"Edit",
"Write",
+9 -7
View File
@@ -614,13 +614,15 @@ fn scaffold_story_kit_claude_settings_uses_canonical_bash_syntax() {
);
}
// The wildcard `Bash(:*)` must be present — covers all bash commands.
// (Previously this asserted a curated per-command list; replaced with a
// single wildcard since coders kept hitting auto-deny on patterns the
// list missed, and the per-command gate offers no real safety in this
// trusted single-user deployment.)
// The unconstrained `Bash` rule must be present — covers all bash commands.
// (Previously this asserted a curated per-command list; replaced with the
// tool-only form since coders kept hitting auto-deny on patterns the list
// missed, and the per-command gate offers no real safety in this trusted
// single-user deployment. The earlier `Bash(:*)` form was tried and
// rejected by Claude Code — empty prefix before `:*` is invalid and
// silently skipped.)
assert!(
settings.contains(r#""Bash(:*)""#),
"settings.json missing wildcard Bash allowlist: {settings}"
settings.contains(r#""Bash""#),
"settings.json missing unconstrained Bash allowlist: {settings}"
);
}
+55 -153
View File
@@ -1,18 +1,15 @@
//! Dependency resolution: check whether story dependencies are satisfied.
//! Dependency resolution helpers — filesystem-backed lookups that don't
//! require any in-memory CRDT state.
//!
//! The CRDT-backed equivalents (`check_unmet_deps_crdt`,
//! `check_archived_deps_crdt`) live in `crate::crdt_state::read`; callers
//! that already have a CRDT entry should prefer those. This module exists
//! for the story-creation path, where dependency IDs are known in memory
//! before any CRDT entry has been written.
use std::fs;
use std::path::Path;
use super::parser::parse_front_matter;
/// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`.
///
/// **Dependency semantics:** Both `5_done` and `6_archived` satisfy a `depends_on` entry.
/// Stories auto-sweep from `5_done` to `6_archived` after 4 hours, so by the time a dep
/// reaches `6_archived`, the dependent story has already been promoted. When a dep is
/// already in `6_archived` at the moment of promotion (e.g., it was manually archived or
/// abandoned before the dependent story was created), the dependency is still considered
/// satisfied — but a warning is logged so the user can see that the dep was archived, not
/// cleanly completed. Use `check_archived_deps` to detect this case.
fn dep_is_done(project_root: &Path, dep_number: u32) -> bool {
let prefix = format!("{dep_number}_");
let exact = dep_number.to_string();
@@ -35,8 +32,7 @@ fn dep_is_done(project_root: &Path, dep_number: u32) -> bool {
false
}
/// Return `true` if a story with the given numeric ID exists specifically in `6_archived`
/// (i.e., it satisfies a `depends_on` but via the archive rather than via a clean done).
/// Return `true` if a story with the given numeric ID exists specifically in `6_archived`.
fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
let prefix = format!("{dep_number}_");
let exact = dep_number.to_string();
@@ -60,11 +56,37 @@ fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
false
}
/// Return the list of dependency story numbers from `story_id`'s front matter
/// that have **not** yet reached `5_done` or `6_archived`.
/// Given an explicit list of dep numbers, return those that have NOT reached
/// `5_done` or `6_archived`.
///
/// Returns an empty `Vec` when there are no unmet dependencies (including when
/// the story has no `depends_on` field at all).
/// Used by callers that have the dep list in memory (e.g. story update at
/// promotion time) and want a filesystem fact rather than an in-memory CRDT
/// state which may be stale during transitions.
pub fn check_unmet_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
deps.iter()
.copied()
.filter(|&dep| !dep_is_done(project_root, dep))
.collect()
}
/// Given an explicit list of dep numbers, return those already in `6_archived`.
///
/// Used at story-creation time when the dep list is known in memory (before
/// the story file has been written), so the caller does not need to parse
/// the story.
pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
deps.iter()
.copied()
.filter(|&dep| dep_is_archived(project_root, dep))
.collect()
}
/// Filesystem-backed unmet-dep check for a story file in `<stage_dir>/`.
///
/// Reads the story's `depends_on` list from its YAML front matter and returns
/// the numeric deps still pending (not yet in `5_done` or `6_archived`). This
/// is the legacy API used by the auto-assigner when the CRDT layer is not yet
/// initialised; CRDT-aware callers should prefer `check_unmet_deps_crdt`.
pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
let path = project_root
.join(".huskies")
@@ -75,23 +97,20 @@ pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) ->
Ok(c) => c,
Err(_) => return Vec::new(),
};
let deps = match parse_front_matter(&contents)
let deps = match crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()
.and_then(|m| m.depends_on)
{
Some(d) => d,
None => return Vec::new(),
};
deps.into_iter()
.filter(|&dep| !dep_is_done(project_root, dep))
.collect()
check_unmet_deps_from_list(project_root, &deps)
}
/// Return the list of dependency story numbers from `story_id`'s front matter
/// that are in `6_archived` (satisfied via archive rather than via normal done).
/// Filesystem-backed archived-dep check for a story file in `<stage_dir>/`.
///
/// Used to emit a warning when backlog promotion fires because a dep was archived
/// rather than cleanly completed. Returns an empty `Vec` when no deps are archived.
/// Reads the story's `depends_on` list from its YAML front matter and returns
/// the numeric deps that satisfied via `6_archived` rather than `5_done`.
pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
let path = project_root
.join(".huskies")
@@ -102,141 +121,40 @@ pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str)
Ok(c) => c,
Err(_) => return Vec::new(),
};
let deps = match parse_front_matter(&contents)
let deps = match crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()
.and_then(|m| m.depends_on)
{
Some(d) => d,
None => return Vec::new(),
};
deps.into_iter()
.filter(|&dep| dep_is_archived(project_root, dep))
.collect()
}
/// Given an explicit list of dep numbers, return those already in `6_archived`.
///
/// Used at story-creation time when the dep list is known in memory (before the
/// story file has been written), so the caller does not need to parse the story.
pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
deps.iter()
.copied()
.filter(|&dep| dep_is_archived(project_root, dep))
.collect()
check_archived_deps_from_list(project_root, &deps)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_unmet_deps_returns_empty_when_no_deps() {
let tmp = tempfile::tempdir().unwrap();
let stage = tmp.path().join(".huskies/work/2_current");
std::fs::create_dir_all(&stage).unwrap();
std::fs::write(stage.join("10_story_foo.md"), "---\nname: Foo\n---\n").unwrap();
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
assert!(unmet.is_empty());
}
#[test]
fn check_unmet_deps_returns_unmet_numbers() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".huskies/work/2_current");
let done = tmp.path().join(".huskies/work/5_done");
std::fs::create_dir_all(&current).unwrap();
std::fs::create_dir_all(&done).unwrap();
// Dep 477 is done, dep 478 is not.
std::fs::write(done.join("477_story_dep.md"), "---\nname: Dep\n---\n").unwrap();
std::fs::write(
current.join("10_story_foo.md"),
"---\nname: Foo\ndepends_on: [477, 478]\n---\n",
)
.unwrap();
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
assert_eq!(unmet, vec![478]);
}
#[test]
fn check_unmet_deps_returns_empty_when_all_deps_done() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".huskies/work/2_current");
let done = tmp.path().join(".huskies/work/5_done");
std::fs::create_dir_all(&current).unwrap();
std::fs::create_dir_all(&done).unwrap();
std::fs::write(done.join("477_story_a.md"), "---\nname: A\n---\n").unwrap();
std::fs::write(done.join("478_story_b.md"), "---\nname: B\n---\n").unwrap();
std::fs::write(
current.join("10_story_foo.md"),
"---\nname: Foo\ndepends_on: [477, 478]\n---\n",
)
.unwrap();
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
assert!(unmet.is_empty());
}
#[test]
fn dep_is_done_finds_story_in_archived() {
let tmp = tempfile::tempdir().unwrap();
let archived = tmp.path().join(".huskies/work/6_archived");
std::fs::create_dir_all(&archived).unwrap();
std::fs::write(archived.join("100_story_old.md"), "---\nname: Old\n---\n").unwrap();
std::fs::write(archived.join("100_story_old.md"), "# old\n").unwrap();
assert!(dep_is_done(tmp.path(), 100));
assert!(!dep_is_done(tmp.path(), 101));
}
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
/// check_archived_deps returns the dep IDs that are in 6_archived.
#[test]
fn check_archived_deps_returns_archived_dep_numbers() {
fn check_unmet_deps_from_list_returns_unmet_numbers() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".huskies/work/2_current");
let archived = tmp.path().join(".huskies/work/6_archived");
std::fs::create_dir_all(&current).unwrap();
std::fs::create_dir_all(&archived).unwrap();
// Dep 100 is in 6_archived; dep 101 is not anywhere.
std::fs::write(archived.join("100_spike_old.md"), "---\nname: Old\n---\n").unwrap();
std::fs::write(
current.join("5_story_dependent.md"),
"---\nname: Dep\ndepends_on: [100, 101]\n---\n",
)
.unwrap();
let archived_deps = check_archived_deps(tmp.path(), "2_current", "5_story_dependent");
assert_eq!(archived_deps, vec![100]);
}
/// check_archived_deps returns empty when no deps are in 6_archived.
#[test]
fn check_archived_deps_returns_empty_when_dep_in_done() {
let tmp = tempfile::tempdir().unwrap();
let backlog = tmp.path().join(".huskies/work/1_backlog");
let done = tmp.path().join(".huskies/work/5_done");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::create_dir_all(&done).unwrap();
// Dep 200 is in 5_done (not archived).
std::fs::write(done.join("200_story_done.md"), "---\nname: Done\n---\n").unwrap();
std::fs::write(
backlog.join("5_story_waiting.md"),
"---\nname: Waiting\ndepends_on: [200]\n---\n",
)
.unwrap();
let archived_deps = check_archived_deps(tmp.path(), "1_backlog", "5_story_waiting");
assert!(archived_deps.is_empty());
std::fs::write(done.join("477_story_dep.md"), "# dep\n").unwrap();
let unmet = check_unmet_deps_from_list(tmp.path(), &[477, 478]);
assert_eq!(unmet, vec![478]);
}
/// check_archived_deps returns empty when story has no depends_on.
#[test]
fn check_archived_deps_returns_empty_when_no_deps() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".huskies/work/2_current");
std::fs::create_dir_all(&current).unwrap();
std::fs::write(current.join("3_story_free.md"), "---\nname: Free\n---\n").unwrap();
let archived_deps = check_archived_deps(tmp.path(), "2_current", "3_story_free");
assert!(archived_deps.is_empty());
}
/// check_archived_deps_from_list returns archived dep IDs from an in-memory list.
#[test]
fn check_archived_deps_from_list_returns_archived_ids() {
let tmp = tempfile::tempdir().unwrap();
@@ -244,25 +162,12 @@ mod tests {
let archived = tmp.path().join(".huskies/work/6_archived");
std::fs::create_dir_all(&done).unwrap();
std::fs::create_dir_all(&archived).unwrap();
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap();
// Only 20 is archived; 10 is in done, 30 is nowhere.
std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap();
std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap();
let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]);
assert_eq!(result, vec![20]);
}
/// check_archived_deps_from_list returns empty when no deps are archived.
#[test]
fn check_archived_deps_from_list_empty_when_no_archived_deps() {
let tmp = tempfile::tempdir().unwrap();
let done = tmp.path().join(".huskies/work/5_done");
std::fs::create_dir_all(&done).unwrap();
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
let result = check_archived_deps_from_list(tmp.path(), &[10]);
assert!(result.is_empty());
}
/// dep_is_archived returns true only for stories in 6_archived, not 5_done.
#[test]
fn dep_is_archived_distinguishes_done_from_archived() {
let tmp = tempfile::tempdir().unwrap();
@@ -270,13 +175,10 @@ mod tests {
let archived = tmp.path().join(".huskies/work/6_archived");
std::fs::create_dir_all(&done).unwrap();
std::fs::create_dir_all(&archived).unwrap();
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap();
// 10 is in 5_done only — not archived.
std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap();
std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap();
assert!(!dep_is_archived(tmp.path(), 10));
// 20 is in 6_archived — archived.
assert!(dep_is_archived(tmp.path(), 20));
// 99 doesn't exist anywhere.
assert!(!dep_is_archived(tmp.path(), 99));
}
}
-212
View File
@@ -1,212 +0,0 @@
//! Front-matter field manipulation: insert, update, remove, and write helpers.
use std::fs;
use std::path::Path;
/// Insert or update a key: value pair in the YAML front matter of a markdown string.
///
/// If no front matter (opening `---`) is found, returns the content unchanged.
pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
}
// Find closing --- (search from index 1)
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
Some(i) => i + 1,
None => return contents.to_string(),
};
let key_prefix = format!("{key}:");
let existing_idx = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1);
let new_line = format!("{key}: {value}");
if let Some(idx) = existing_idx {
lines[idx] = new_line;
} else {
lines.insert(close_idx, new_line);
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
/// Remove a key: value line from the YAML front matter of a markdown string.
///
/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged.
pub(super) fn remove_front_matter_field(contents: &str, key: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
}
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
Some(i) => i + 1,
None => return contents.to_string(),
};
let key_prefix = format!("{key}:");
if let Some(idx) = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1)
{
lines.remove(idx);
} else {
return contents.to_string();
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
/// Remove a key from the YAML front matter of a story file on disk.
///
/// If front matter is present and contains the key, the line is removed.
/// If no front matter or key is not found, the file is left unchanged.
pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let updated = remove_front_matter_field(&contents, key);
if updated != contents {
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
}
Ok(())
}
/// Write `review_hold: true` to the YAML front matter of a story file.
///
/// Used to mark spikes that have passed QA and are waiting for human review.
pub fn write_review_hold(path: &Path) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let updated = set_front_matter_field(&contents, "review_hold", "true");
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
Ok(())
}
/// Remove a key from the YAML front matter of a markdown string (pure function).
///
/// Returns the updated content. If no front matter or key is not found,
/// returns the original content unchanged.
pub fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String {
remove_front_matter_field(contents, key)
}
/// Append rejection notes to a markdown string (pure function).
///
/// Returns the updated content with a `## QA Rejection Notes` section appended.
pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String {
let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n");
format!("{contents}{section}")
}
/// Write or update `merge_failure` in story content (pure function).
pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
let escaped = reason
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', "");
let yaml_value = format!("\"{escaped}\"");
set_front_matter_field(contents, "merge_failure", &yaml_value)
}
/// Write `review_hold: true` to story content (pure function).
pub fn write_review_hold_in_content(contents: &str) -> String {
set_front_matter_field(contents, "review_hold", "true")
}
/// Write `mergemaster_attempted: true` to story content (pure function).
///
/// Used by the auto-assigner to record that a mergemaster session has been
/// spawned for a content-conflict failure, preventing repeated auto-spawns.
pub fn write_mergemaster_attempted_in_content(contents: &str) -> String {
set_front_matter_field(contents, "mergemaster_attempted", "true")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_front_matter_field_inserts_new_key() {
let input = "---\nname: My Story\n---\n# Body\n";
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
assert!(output.contains("coverage_baseline: 55.0%"));
assert!(output.contains("name: My Story"));
assert!(output.ends_with('\n'));
}
#[test]
fn set_front_matter_field_updates_existing_key() {
let input = "---\nname: My Story\ncoverage_baseline: 40.0%\n---\n# Body\n";
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
assert!(output.contains("coverage_baseline: 55.0%"));
assert!(!output.contains("40.0%"));
}
#[test]
fn set_front_matter_field_no_op_without_front_matter() {
let input = "# No front matter\n";
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
assert_eq!(output, input);
}
#[test]
fn remove_front_matter_field_removes_key() {
let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n";
let output = remove_front_matter_field(input, "merge_failure");
assert!(!output.contains("merge_failure"));
assert!(output.contains("name: My Story"));
assert!(output.ends_with('\n'));
}
#[test]
fn remove_front_matter_field_no_op_when_absent() {
let input = "---\nname: My Story\n---\n# Body\n";
let output = remove_front_matter_field(input, "merge_failure");
assert_eq!(output, input);
}
#[test]
fn remove_front_matter_field_no_op_without_front_matter() {
let input = "# No front matter\n";
let output = remove_front_matter_field(input, "merge_failure");
assert_eq!(output, input);
}
#[test]
fn clear_front_matter_field_updates_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(
&path,
"---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n",
)
.unwrap();
clear_front_matter_field(&path, "merge_failure").unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(!contents.contains("merge_failure"));
assert!(contents.contains("name: Test"));
}
#[test]
fn write_review_hold_sets_field() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("spike.md");
std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap();
write_review_hold(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("review_hold: true"));
assert!(contents.contains("name: My Spike"));
}
}
+7 -14
View File
@@ -1,24 +1,17 @@
//! Story metadata — parses and modifies YAML front matter in story markdown files.
//! Story metadata helpers — CRDT-backed lookups plus pure-content parsers.
//!
//! Submodules:
//! - `types` — core data types (`QaMode`, `StoryMetadata`, `StoryMetaError`) — types used internally by the other submodules
//! - `parser` — YAML front-matter parsing and QA-mode resolution
//! - `fields` — front-matter field insertion, update, and removal helpers
//! - `deps` — dependency satisfaction checks (`check_unmet_deps`, etc.)
//! Story 865 stripped YAML front matter from the content store; this module
//! no longer parses or writes YAML. What remains:
//! - `types` — `QaMode` enum.
//! - `parser` — `parse_unchecked_todos`, `resolve_qa_mode`, `is_story_frozen_in_store`.
//! - `deps` — dependency satisfaction checks (CRDT-backed).
mod deps;
mod fields;
mod parser;
mod types;
pub use deps::{check_archived_deps, check_archived_deps_from_list, check_unmet_deps};
pub use fields::{
clear_front_matter_field, clear_front_matter_field_in_content, set_front_matter_field,
write_merge_failure_in_content, write_mergemaster_attempted_in_content,
write_rejection_notes_to_content, write_review_hold, write_review_hold_in_content,
};
pub use parser::{
is_story_frozen_in_store, parse_front_matter, parse_unchecked_todos, resolve_qa_mode,
resolve_qa_mode_from_content,
is_story_frozen_in_store, parse_unchecked_todos, resolve_qa_mode, resolve_qa_mode_from_content,
};
pub use types::QaMode;
+31 -232
View File
@@ -1,90 +1,9 @@
//! Parsing logic for story YAML front matter and todo checkboxes.
use serde::Deserialize;
use std::fs;
use std::path::Path;
use super::types::{QaMode, StoryMetaError, StoryMetadata};
#[derive(Debug, Deserialize)]
pub(super) struct FrontMatter {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
/// Configurable QA mode field: "human", "server", or "agent".
pub qa: Option<String>,
/// Number of times this story has been retried at its current pipeline stage.
pub retry_count: Option<u32>,
/// When `true`, auto-assign will skip this story (retry limit exceeded).
pub blocked: Option<bool>,
/// Story numbers this story depends on.
pub depends_on: Option<Vec<u32>>,
/// When `true`, the story is frozen.
pub frozen: Option<bool>,
/// Stage directory to restore on unfreeze (e.g. `"2_current"`).
pub resume_to_stage: Option<String>,
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
/// Used by the bug-645 salvage path to distinguish a genuine test-passing
/// session from one that merely compiled.
pub run_tests_passed: Option<bool>,
/// Item type: "story", "bug", "spike", or "refactor".
#[serde(rename = "type")]
pub item_type: Option<String>,
/// Set to `true` when the auto-assigner has already spawned a mergemaster
/// session for a content-conflict failure.
pub mergemaster_attempted: Option<bool>,
/// Epic this item belongs to (numeric ID as string, e.g. "880").
pub epic: Option<String>,
}
/// Parse the YAML front matter block from a story markdown string.
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
let mut lines = contents.lines();
let first = lines.next().unwrap_or_default().trim();
if first != "---" {
return Err(StoryMetaError::MissingFrontMatter);
}
let mut front_lines = Vec::new();
for line in &mut lines {
let trimmed = line.trim();
if trimmed == "---" {
let raw = front_lines.join("\n");
let front: FrontMatter = serde_yaml::from_str(&raw)
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
return Ok(build_metadata(front));
}
front_lines.push(line);
}
Err(StoryMetaError::InvalidFrontMatter(
"Missing closing front matter delimiter".to_string(),
))
}
fn build_metadata(front: FrontMatter) -> StoryMetadata {
let qa = front.qa.as_deref().and_then(QaMode::from_str);
StoryMetadata {
name: front.name,
coverage_baseline: front.coverage_baseline,
merge_failure: front.merge_failure,
agent: front.agent,
review_hold: front.review_hold,
qa,
retry_count: front.retry_count,
blocked: front.blocked,
depends_on: front.depends_on,
frozen: front.frozen,
resume_to_stage: front.resume_to_stage,
run_tests_passed: front.run_tests_passed,
item_type: front.item_type,
mergemaster_attempted: front.mergemaster_attempted,
epic: front.epic,
}
}
//! Pure-content helpers and CRDT-backed metadata lookups.
//!
//! Story 865 stripped YAML front matter from stored content and the codebase
//! at large; the only remaining functions here read the CRDT or operate on
//! the markdown body directly.
use super::types::QaMode;
/// Parse unchecked todo items (`- [ ] ...`) from a markdown string.
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
@@ -97,46 +16,32 @@ pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
.collect()
}
/// Resolve the effective QA mode for a story file.
/// Resolve the effective QA mode for a story by ID via the CRDT.
///
/// Reads the `qa` front matter field. If absent, falls back to `default`.
/// Spikes are **not** handled here — the caller is responsible for overriding
/// to `Human` for spikes.
pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return default,
};
match parse_front_matter(&contents) {
Ok(meta) => meta.qa.unwrap_or(default),
Err(_) => default,
}
/// Returns `default` when the story has no entry or its `qa_mode` register is
/// unset. Spikes are **not** handled here — callers override to `Human` for
/// spikes themselves.
pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode {
crate::crdt_state::read_item(story_id)
.and_then(|view| view.qa_mode)
.as_deref()
.and_then(QaMode::from_str)
.unwrap_or(default)
}
/// Resolve the effective QA mode for a story by story ID.
///
/// Checks the typed `qa_mode` CRDT register first. If the register holds a
/// recognised value (`"server"`, `"agent"`, or `"human"`), returns it.
/// Otherwise falls back to parsing the `qa` YAML front-matter field from
/// `contents`. If neither source provides a value, returns `default`.
pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaMode) -> QaMode {
// CRDT register takes precedence over YAML front matter.
if let Some(view) = crate::crdt_state::read_item(story_id)
&& let Some(ref s) = view.qa_mode
&& let Some(mode) = QaMode::from_str(s)
{
return mode;
}
// Fall back to YAML front matter for backward compatibility.
match parse_front_matter(contents) {
Ok(meta) => meta.qa.unwrap_or(default),
Err(_) => default,
}
/// Resolve the effective QA mode by parsing legacy YAML front matter from a
/// markdown body. Used during one-time fallbacks when the CRDT register isn't
/// set; new code should always read `qa_mode` from the CRDT.
pub fn resolve_qa_mode_from_content(_story_id: &str, content: &str, default: QaMode) -> QaMode {
crate::db::yaml_legacy::parse_front_matter(content)
.ok()
.and_then(|m| m.qa)
.unwrap_or(default)
}
/// Return `true` if the story is in the `Frozen` pipeline stage.
///
/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance
/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance
/// code to suppress stage transitions for frozen stories.
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
crate::pipeline_state::read_typed(story_id)
@@ -150,48 +55,6 @@ pub fn is_story_frozen_in_store(story_id: &str) -> bool {
mod tests {
use super::*;
#[test]
fn parses_front_matter_metadata() {
let input = r#"---
name: Establish the TDD Workflow and Gates
workflow: tdd
---
# Story 26
"#;
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(
meta.name.as_deref(),
Some("Establish the TDD Workflow and Gates")
);
assert_eq!(meta.coverage_baseline, None);
}
#[test]
fn parses_coverage_baseline_from_front_matter() {
let input = "---\nname: Test Story\ncoverage_baseline: 78.5%\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.coverage_baseline.as_deref(), Some("78.5%"));
}
#[test]
fn rejects_missing_front_matter() {
let input = "# Story 26\n";
assert_eq!(
parse_front_matter(input),
Err(StoryMetaError::MissingFrontMatter)
);
}
#[test]
fn rejects_unclosed_front_matter() {
let input = "---\nname: Test\n";
assert!(matches!(
parse_front_matter(input),
Err(StoryMetaError::InvalidFrontMatter(_))
));
}
#[test]
fn parse_unchecked_todos_mixed() {
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
@@ -220,75 +83,11 @@ workflow: tdd
}
#[test]
fn parses_review_hold_from_front_matter() {
let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.review_hold, Some(true));
}
#[test]
fn review_hold_defaults_to_none() {
let input = "---\nname: Story\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.review_hold, None);
}
#[test]
fn parses_qa_mode_from_front_matter() {
let input = "---\nname: Story\nqa: server\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Server));
let input = "---\nname: Story\nqa: agent\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Agent));
let input = "---\nname: Story\nqa: human\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Human));
}
#[test]
fn qa_mode_defaults_to_none() {
let input = "---\nname: Story\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, None);
}
#[test]
fn resolve_qa_mode_uses_file_value() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap();
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human);
}
#[test]
fn resolve_qa_mode_falls_back_to_default() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server);
assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent);
}
#[test]
fn resolve_qa_mode_missing_file_uses_default() {
let path = std::path::Path::new("/nonexistent/story.md");
assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server);
}
#[test]
fn parses_depends_on_from_front_matter() {
let input = "---\nname: Story\ndepends_on: [477, 478]\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.depends_on, Some(vec![477, 478]));
}
#[test]
fn depends_on_defaults_to_none() {
let input = "---\nname: Story\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.depends_on, None);
fn resolve_qa_mode_falls_back_to_default_when_crdt_empty() {
crate::crdt_state::init_for_test();
assert_eq!(
resolve_qa_mode("9999_no_such_story", QaMode::Server),
QaMode::Server
);
}
}
+1 -56
View File
@@ -1,4 +1,4 @@
//! Core data types for story front-matter metadata.
//! Core data types for story metadata.
/// QA mode for a story: determines how the pipeline handles post-coder review.
///
@@ -39,58 +39,3 @@ impl std::fmt::Display for QaMode {
f.write_str(self.as_str())
}
}
/// Parsed YAML front-matter fields from a story markdown file.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StoryMetadata {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<QaMode>,
/// Number of times this story has been retried at its current pipeline stage.
pub retry_count: Option<u32>,
/// When `true`, auto-assign will skip this story (retry limit exceeded).
pub blocked: Option<bool>,
/// Story numbers this story depends on. Auto-assign will skip this story
/// until all dependencies have reached `5_done` or `6_archived`.
pub depends_on: Option<Vec<u32>>,
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
/// does not advance it, and no mergemaster is spawned.
pub frozen: Option<bool>,
/// Pipeline stage to restore when unfreezing (e.g. `"2_current"`).
/// Written by `transition_to_frozen`; cleared by `transition_to_unfrozen`.
pub resume_to_stage: Option<String>,
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
/// Used by the bug-645 salvage path to require real test evidence, not just
/// compilation success.
pub run_tests_passed: Option<bool>,
/// Item type: "story", "bug", "spike", or "refactor".
///
/// Present on items created with numeric-only IDs (no slug suffix).
/// Used by the pipeline to determine routing (e.g. spikes skip QA).
pub item_type: Option<String>,
/// Set to `true` when the auto-assigner has already spawned a mergemaster
/// session for a content-conflict failure. Prevents repeated spawns.
pub mergemaster_attempted: Option<bool>,
/// Epic this item belongs to. The value is the epic's numeric ID (e.g. "880").
/// Set on story/bug/spike/refactor items to declare membership in an epic.
pub epic: Option<String>,
}
/// Errors that can occur when parsing story front-matter metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoryMetaError {
MissingFrontMatter,
InvalidFrontMatter(String),
}
impl std::fmt::Display for StoryMetaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"),
StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"),
}
}
}
+2 -2
View File
@@ -107,7 +107,7 @@ pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyErro
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
let resume_dir = item.stage.dir_name().to_string();
let transform = move |content: &str| -> String {
crate::io::story_metadata::set_front_matter_field(content, "resume_to_stage", &resume_dir)
crate::db::yaml_legacy::set_front_matter_field(content, "resume_to_stage", &resume_dir)
};
apply_transition(story_id, PipelineEvent::Freeze, Some(&transform))
}
@@ -118,7 +118,7 @@ pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyErro
/// the `resume_to_stage` field from the front matter.
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
let transform = |content: &str| -> String {
crate::io::story_metadata::clear_front_matter_field_in_content(content, "resume_to_stage")
crate::db::yaml_legacy::clear_front_matter_field_in_content(content, "resume_to_stage")
};
apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform))
}
+1 -1
View File
@@ -135,7 +135,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
let resume_to = crate::db::read_content(&view.story_id)
.and_then(|content| {
crate::io::story_metadata::parse_front_matter(&content)
crate::db::yaml_legacy::parse_front_matter(&content)
.ok()
.and_then(|m| m.resume_to_stage)
.and_then(|dir| Stage::from_dir(&dir))
+36
View File
@@ -197,6 +197,42 @@ fn unblock_returns_to_coding() {
assert!(matches!(result, Stage::Coding));
}
#[test]
fn blocked_demote_returns_to_backlog() {
// Stuck-story parking lane: `Blocked + Demote → Backlog` lets operators
// move a blocked story back to the backlog without losing it to
// Archived. Complements `Blocked + Unblock → Coding` which re-enters
// active work.
let s = Stage::Blocked {
reason: "waiting on dep".into(),
};
let result = transition(s, PipelineEvent::Demote).unwrap();
assert!(matches!(result, Stage::Backlog));
}
#[test]
fn cannot_demote_from_done() {
// Sanity: Demote remains illegal from terminal/archived stages — the
// new `Blocked + Demote → Backlog` rule must NOT broaden it further.
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("x"),
};
assert!(matches!(
transition(s, PipelineEvent::Demote),
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_demote_from_upcoming() {
let s = Stage::Upcoming;
assert!(matches!(
transition(s, PipelineEvent::Demote),
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn legacy_unblock_archived_blocked_returns_to_backlog() {
let s = Stage::Archived {
+7 -1
View File
@@ -217,7 +217,13 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
}),
// ── Demote: send an active item back to backlog ────────────────
(Coding, Demote) | (Qa, Demote) | (Merge { .. }, Demote) => Ok(Backlog),
// `Blocked + Demote → Backlog` lets operators park a stuck story in
// the backlog while waiting on dependent fixes, without losing it to
// Archived. Unlike `Unblock` (Blocked → Coding), this does not
// re-enter the active flow.
(Coding, Demote) | (Qa, Demote) | (Merge { .. }, Demote) | (Blocked { .. }, Demote) => {
Ok(Backlog)
}
// ── Close: direct completion from any active stage ─────────────
(Backlog, Close) | (Coding, Close) | (Qa, Close) | (Merge { .. }, Close) => Ok(Done {
+2 -2
View File
@@ -185,7 +185,7 @@ pub fn get_work_item_content(
for (stage_dir, stage_name) in &stages {
if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok();
return Ok(WorkItemContent {
content,
stage: stage_name.to_string(),
@@ -215,7 +215,7 @@ pub fn get_work_item_content(
})
.unwrap_or("unknown")
.to_string();
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok();
return Ok(WorkItemContent {
content,
stage,
+1 -1
View File
@@ -4,7 +4,7 @@
//! side effects: reading from the CRDT content store, loading configuration,
//! and spawning the background listener task.
use crate::io::story_metadata::parse_front_matter;
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
mod listener;