@mmerterden/multi-agent-pipeline 20.28.0 → 20.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +14 -1
  3. package/docs/facts.json +1 -1
  4. package/install/claude.mjs +112 -0
  5. package/install/templates/claude-hooks.json +30 -1
  6. package/manifest.json +53 -42
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  9. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  10. package/pipeline/contract/types/index.d.ts +1 -1
  11. package/pipeline/lib/hook-registration.mjs +252 -0
  12. package/pipeline/lib/prefs-value.mjs +44 -0
  13. package/pipeline/lib/run-completion.mjs +148 -0
  14. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  15. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  16. package/pipeline/multi-agent-refs/phases/operations.md +17 -0
  17. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  18. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  19. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
  21. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  22. package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
  23. package/pipeline/preferences-template.json +7 -0
  24. package/pipeline/rules/figma-pipeline.md +2 -0
  25. package/pipeline/schemas/agent-state.schema.json +20 -5
  26. package/pipeline/schemas/phases.json +3 -2
  27. package/pipeline/schemas/phases.schema.json +5 -1
  28. package/pipeline/schemas/prefs.schema.json +37 -0
  29. package/pipeline/schemas/runs-index.schema.json +1 -1
  30. package/pipeline/scripts/_active-run.mjs +158 -0
  31. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  32. package/pipeline/scripts/_guard_rules.py +605 -0
  33. package/pipeline/scripts/agent-guard.py +118 -7
  34. package/pipeline/scripts/agent-guard.sh +50 -3
  35. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  36. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  37. package/pipeline/scripts/confirm-command.mjs +70 -0
  38. package/pipeline/scripts/doctor.mjs +54 -0
  39. package/pipeline/scripts/evidence-gate.mjs +8 -4
  40. package/pipeline/scripts/gate-ledger.mjs +163 -7
  41. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  42. package/pipeline/scripts/pre-commit-check.sh +12 -0
  43. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  44. package/pipeline/scripts/run-scope-guard.sh +23 -0
  45. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  46. package/pipeline/scripts/run-stop-guard.sh +25 -0
  47. package/pipeline/scripts/uninstall.mjs +1 -0
  48. package/pipeline/scripts/validate-state.mjs +26 -0
  49. package/pipeline/scripts/write-state.mjs +114 -0
  50. package/pipeline/skills/.skill-manifest.json +1 -1
@@ -25,7 +25,7 @@ Progress emission per `$HOME/.claude/multi-agent-refs/progress-contract.md` -
25
25
 
26
26
  #### Input contract
27
27
 
28
- Phase 4 consumes the latest Phase 3 triage output object conforming to `$HOME/.claude/schemas/triage-output.schema.json` (`accepted`, `deferred`, `rejected` buckets) plus the working-tree mutations from Phase 2. The commit body cites only `accepted` findings that were resolved; `deferred` items must be linked in the PR description as follow-up work, never silently dropped. If the triage output reports any unresolved blocking accepted finding, Phase 4 refuses to commit - the pipeline returns to Phase 2 for rework.
28
+ Phase 4 consumes the latest Phase 3 triage output (`$HOME/.claude/schemas/triage-output.schema.json` (`accepted`, `deferred`, `rejected` buckets) and the Phase 2 working tree. The commit body cites only `accepted` findings that were resolved; `deferred` items must be linked in the PR description as follow-up work, never silently dropped. An unresolved blocking accepted finding sends the run back to Phase 2.
29
29
 
30
30
  "Build passes" below is evidence-gated: Phase 4 trusts the `buildStatus.ok` Phase 2 / Phase 3 recorded through `$HOME/.claude/scripts/evidence-gate.mjs` (a pass with no build/test log behind it is unverified and blocks the commit). The secret scanner (`pre-commit-check.sh`) runs on the staged diff as the final pre-commit gate.
31
31
 
@@ -44,7 +44,7 @@ Exit 0 passes. Exit 2 with `.verdict` `not-applicable` in `$PC` is `COVERAGE_NA`
44
44
 
45
45
  A step is accounted for when `completed`, `skipped` + `skipReason`, or `failed` + `failureReason`; a bare `pending` / `in_progress` is the gap. Every Section 14 `Add new` row must name a file in the tree; an empty `docPath[]` reports that half `skipped`, never `passed`.
46
46
 
47
- `COVERAGE_GAP` (a gap, a zero-step plan, unreadable input): show `$PC` verbatim, then return to Phase 2 or have the user mark each step deliberately (`plan-todos.sh` writes the reason); never proceed without that decision. `COVERAGE_NA`: log `plan-coverage: n/a` and continue. Gated runs: `$HOME/.claude/multi-agent-refs/features/unattended-gates.md`.
47
+ `COVERAGE_GAP` (a gap, a zero-step plan, unreadable input): show `$PC` verbatim, then return to Phase 2 or have the user mark each step deliberately (`plan-todos.sh` writes the reason); never proceed without that decision, and re-run the gate once the steps are marked. `COVERAGE_NA`: log `plan-coverage: n/a` and continue. Gated runs: `$HOME/.claude/multi-agent-refs/features/unattended-gates.md`.
48
48
 
49
49
  #### Step 0 - Multi-Repo Integration Build
50
50
 
@@ -84,7 +84,7 @@ Branch **deterministically**, no implicit fallback. Read `agent-state.json` and
84
84
 
85
85
  **Instruction-driven path:**
86
86
 
87
- 1. Ask: "Want to commit?" (skip in autopilot)
87
+ 1. Ask: "Want to commit?" (skip in autopilot; operations.md "Attended waits")
88
88
  2. Read commit instruction file (e.g. `.instructions/figma/figma-iteration-commit/SKILL.md`)
89
89
  3. Follow its flow (build validation, test, submodule commits, push, PR, issue management)
90
90
  4. Log: "Phase 4: instruction-driven commit - file={instructionFiles.commit}"
@@ -111,8 +111,9 @@ Branch **deterministically**, no implicit fallback. Read `agent-state.json` and
111
111
  - Commit message uses same convention but scope reflects submodule: `{type}({submodule-scope}): {description} [{jiraId}]`
112
112
  6. Commit with convention: `{type}({scope}): {description} [{jiraId}]` or `[#{shortId}]`. When `node $HOME/.claude/scripts/repo-profile.mjs resolve commit.format --repo "$PROJECT_ROOT"` returns `use: true`, its `ticketPosition` places the ticket (`prefix` first, `suffix` last; `none` keeps this default unless confirmed or manual), `ticketStyle` its form and `scopeRequired` the scope. A profile hook with `amends-commit` or `writes-files` rewrites the commit: re-read the SHA after committing ([`features/repo-profile.md`]($HOME/.claude/multi-agent-refs/features/repo-profile.md)). **Local-only repos** (`projects[i].provider == "local"`) have no ticket, and that wins over any profile placement: the subject keeps the conventional prefix with no ticket, and the taskId (`LOCAL-...`) goes in the body footer: `Local-Task: {taskId}`.
113
113
  6a. **Valven (required):** `features/valven.md`.
114
+ 6b. **Record the commit**: operations.md "Completion evidence".
114
115
  7. Push to remote - **skip per-repo when `projects[i].provider == "local"`** (no `git push`, no upstream config). Log `Phase 4: local-only commit {sha} (no push)` for that repo.
115
- 8. Ask: "Want to open a Pull Request?"
116
+ 8. Ask: "Want to open a Pull Request?" (attended wait, as in step 1)
116
117
  - **Skip the prompt entirely when `state.offlineOnly == true`** (all repos local - no PR target exists). Proceed to Phase 5 with `pr.status = "skipped-offline"` for each project.
117
118
  - In mixed mode (some local, some remote), only prompt for the remote-backed repos; local ones auto-skip.
118
119
  - No -> Phase 5
@@ -126,7 +127,7 @@ Branch **deterministically**, no implicit fallback. Read `agent-state.json` and
126
127
  --task-id "$TASK_ID" --project "$PROJECT" --branch "$BRANCH")
127
128
  ```
128
129
 
129
- The script salvages before removing, keeps the branch, and does **not** check it out - the user's HEAD and uncommitted work stay put. On success it stamps `worktreeRemovedAt`, `artifactsPath` and `worktreePath: null` into the **salvaged** `agent-state.json` itself. Do NOT write those via `write-state.mjs "$STATE_FILE"`: in single-repo mode that path went away with the worktree, so the write fails, the fields land nowhere, and Phase 5 reads a dead path and silently skips the triage ingest. After a removal re-point `STATE_FILE` at `$(jq -r .artifactsPath <<< "$FIN")/agent-state.json`. Exit 3 is a safe skip (uncommitted changes, unpushed or detached HEAD, `--local`, cwd inside the tree, preference off): report and continue. Never `--force`. Contract: [`features/worktree-finalize.md`]($HOME/.claude/multi-agent-refs/features/worktree-finalize.md).
130
+ The script salvages before removing, keeps the branch, and does **not** check it out - the user's HEAD and uncommitted work stay put. On success it stamps `worktreeRemovedAt`, `artifactsPath` and `worktreePath: null` into the **salvaged** `agent-state.json` itself. Do NOT write those via `write-state.mjs "$STATE_FILE"`: that path left with the worktree, and Phase 5 would skip the triage ingest. After a removal re-point `STATE_FILE` at `$(jq -r .artifactsPath <<< "$FIN")/agent-state.json`. Exit 3 is a safe skip (uncommitted changes, unpushed or detached HEAD, `--local`, cwd inside the tree, preference off): report and continue. Never `--force`. Contract: [`features/worktree-finalize.md`]($HOME/.claude/multi-agent-refs/features/worktree-finalize.md).
130
131
 
131
132
  10. **Issue body update** (GitHub Issue only): if the issue body has a `### Pull Requests` section and/or a `### Progress` table, fill in the PR URL(s) (one row per submodule - e.g. `- **common:** {url}`, `- **uicomponents:** {url}`) and flip the Implementation / Testing / Code Connect flags from Pending to Done using whatever marker the template uses (match in place, do NOT introduce new markers). Apply it with `gh issue edit {issueNo} --body-file <file>`, preserving every other section unchanged.
132
133
  11. **NEVER close or resolve the issue** - neither GitHub Issue nor Jira. Issues require team review (4 approvals) before closing. Only post a comment with commit/PR URLs.
@@ -271,7 +272,7 @@ For each repo in `state.projects[]` (sequentially, in dependency order from Phas
271
272
  `repo-scope` is the repo's short name (e.g. `common`, `uicomponents`). The subject and `[tracker-id]` are identical across all repos.
272
273
  3. `git add` (specific files only - same rules as single-repo Step 5)
273
274
  4. `git -C "$WT_PATH" commit -m "$MESSAGE"` (heredoc body) - using identity already pinned in Phase 0 Step 8.
274
- 5. Capture `state.projects[i].commit = "<sha>"`.
275
+ 5. Capture `state.projects[i].commit = {"sha": "<sha>"}`.
275
276
 
276
277
  ##### Push-must-succeed policy (gated by `prefs.global.settings.pushMustSucceed`, default `true`)
277
278
 
@@ -215,12 +215,13 @@ $HOME/.claude/scripts/log-metric.sh "$TASK_ID" 5 task.completed \
215
215
 
216
216
  ```bash
217
217
  STATE_FILE="${STATE_FILE:-$(node $HOME/.claude/scripts/usage-report.mjs --task-id "$TASK_ID" --print-state)}"
218
- printf '{"status":"complete","currentPhase":5,"finishedAt":"%s"}' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
219
- | node $HOME/.claude/scripts/write-state.mjs "$STATE_FILE" >/dev/null 2>&1 || true
220
- node $HOME/.claude/scripts/usage-report.mjs --task-id "$TASK_ID" --event completed >/dev/null 2>&1 || true
218
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
219
+ WS_ERR=$(printf '{"status":"complete","currentPhase":5,"finishedAt":"%s"}' "$TS" \
220
+ | node $HOME/.claude/scripts/write-state.mjs "$STATE_FILE" 2>&1 >/dev/null); WS_RC=$?
221
+ [ "$WS_RC" = 0 ] && node $HOME/.claude/scripts/usage-report.mjs --task-id "$TASK_ID" --event completed >/dev/null 2>&1
221
222
  ```
222
223
 
223
- Parked on the channels choice instead: emit `--event parked` and leave `status` alone.
224
+ `WS_RC` 6: `$WS_ERR` names what `complete` still lacks; record it (operations.md "Completion evidence") and write again, or halt. Parked on the channels choice instead: emit `--event parked` and leave `status` alone.
224
225
 
225
226
  **Aggregate before reporting**: run `aggregate-metrics.mjs --since=$(date -u -v-30d +%Y-%m-%d)` and embed output in agent-log.md. Use `--json` for Jira; missing files are skipped.
226
227
 
@@ -109,6 +109,13 @@
109
109
  },
110
110
  "testGen": {
111
111
  "revisionCap": 20
112
+ },
113
+ "attendedCommitGate": {
114
+ "mode": "warn"
115
+ },
116
+ "readOnlyRepos": [],
117
+ "stopGuard": {
118
+ "enabled": true
112
119
  }
113
120
  },
114
121
  "projects": {},
@@ -98,6 +98,8 @@ Re-fetching Figma in dev phases:
98
98
 
99
99
  The repo carries `pipeline/scripts/smoke-no-mcp-in-dev-phases.sh`, which reads a state file's `state.telemetry.mcpCalls[]`: a Figma tool entry with `phase >= 2` fails it, and a Figma entry with no phase is rejected as unjudgeable. It is a maintainer-side regression check: it runs locally as part of `npm test` and the pre-push hook, not in CI (the CI workflow is dormant) and not after every pipeline run.
100
100
 
101
+ During a run on Claude Code the rule is enforced by a hook: `run-scope-guard.sh` is registered on PreToolUse `mcp__.*[Ff]igma.*` (`install/templates/claude-hooks.json`). For the run bound to the session it denies a Figma call above `thresholds.mcpAllowedThroughPhase` of `schemas/phases.json` (Phase 1, where Step 1.4 captures the evidence record) unless the run is in `analysis` mode, and it appends every allowed call to `telemetry.mcpCalls[]` itself. Copilot CLI and Codex have no equivalent hook; there the record is still the phase's own write.
102
+
101
103
  Memory: [[mcp-only-in-analysis]]
102
104
 
103
105
  ### Phase Order (platform-agnostic)
@@ -38,6 +38,10 @@
38
38
  "type": ["string", "null"],
39
39
  "description": "The Claude Code session id this run was launched under, copied by Phase 0 from MULTI_AGENT_SESSION_ID when the launcher set it. Continuous mode sets it to the id it passed as --session-id, and matches this field to find ITS run's state rather than another run in the same repo. Absent or null on an attended run: nothing sets the variable there."
40
40
  },
41
+ "hostSessionId": {
42
+ "type": ["string", "null"],
43
+ "description": "The host session that made this run's last phase transition: write-state.mjs copies CLAUDE_CODE_SESSION_ID from its environment when a write creates the state or sets currentPhase, and never otherwise, so a second session editing the state (steer) does not take the run over. run-stop-guard.mjs and run-scope-guard.mjs match it against a hook payload's session_id to find the run a hook call belongs to. Absent on a host that sets no such variable."
44
+ },
41
45
  "title": {
42
46
  "type": ["string", "null"],
43
47
  "description": "The issue title Phase 0 fetched (GitHub title, Jira summary). runs-index.mjs carries it as the run's title; absent when the input was free text or nothing was fetched."
@@ -275,9 +279,16 @@
275
279
  }
276
280
  },
277
281
  "waitingFor": {
278
- "type": "string",
279
- "enum": ["maturity", "user-channels-choice", "question"],
280
- "description": "The step a paused run must RE-ENTER, as opposed to the phase it should continue past. resume reads this first and falls back to currentPhase + 1 when it is absent. maturity: Phase 0's maturity step, with the item re-fetched (refs/features/maturity-followup.md). user-channels-choice: Phase 5's channels menu, with the stored channelsInput. question: the step named by pendingQuestion.stepId, answered by `resume <id> --answer <optionId>`; open-questions-gate.mjs writes it as the last step of Phase 1 with stepId phase-1/open-questions."
282
+ "type": ["string", "null"],
283
+ "enum": [
284
+ "maturity",
285
+ "user-channels-choice",
286
+ "local-test",
287
+ "attended-reply",
288
+ "question",
289
+ null
290
+ ],
291
+ "description": "The step a paused run must RE-ENTER, as opposed to the phase it should continue past. resume reads this first and falls back to currentPhase + 1 when it is absent or null (null is how a wait is cleared through write-state). maturity: Phase 0's maturity step, with the item re-fetched (refs/features/maturity-followup.md). user-channels-choice: Phase 5's channels menu, with the stored channelsInput. local-test: Phase 3's user-test gate. attended-reply: a typed reply asked for in plain text inside currentPhase; resume re-enters currentPhase and asks again. question: the step named by pendingQuestion.stepId, answered by `resume <id> --answer <optionId>`, and never written without that pendingQuestion; open-questions-gate.mjs writes it as the last step of Phase 1 with stepId phase-1/open-questions. write-state clears every value but question when a write advances currentPhase."
281
292
  },
282
293
  "workspaceSource": {
283
294
  "type": "string",
@@ -497,7 +508,7 @@
497
508
  "status": {
498
509
  "type": "string",
499
510
  "enum": ["in_progress", "awaiting_input", "paused", "complete", "failed", "abandoned"],
500
- "description": "Overall task status. `abandoned` is written through the locked state writer by the autopilot runner, for a run its session launched that ended with neither a PR nor a parked question, and by a kill; `abandonedBy` names the writer. `awaiting_input` is deliberately distinct from both `in_progress` and `paused`: the run is not broken and not resumable by a retry - it is finished with what it can do alone and is holding for a human answer, which is what Phase 5 does by design after the PR is open. Without a word for it, a run whose work had landed read exactly like one that crashed mid-development, and a state file in the wild had already invented `awaiting-user-test-main-checkout` to say it. `paused` stays what it was: stopped deliberately, resumable, nothing expected from anyone."
511
+ "description": "Overall task status. `complete` is earned, not claimed: write-state.mjs refuses a write that moves the run into it unless currentPhase is the mode's last phase and, for a mode that commits, Phase 4 left evidence (commit.sha, pr, publish, projects[].commit, or phases.4 skipped with notes) and gates[] holds a passing plan-coverage verdict (lib/run-completion.mjs); the autopilot runner files a complete state without that evidence as stopped-incomplete. `abandoned` is written through the locked state writer by the autopilot runner, for a run its session launched that ended with neither a PR nor a parked question, and by a kill; `abandonedBy` names the writer. `awaiting_input` is deliberately distinct from both `in_progress` and `paused`: the run is not broken and not resumable by a retry - it is finished with what it can do alone and is holding for a human answer, which is what Phase 5 does by design after the PR is open. Without a word for it, a run whose work had landed read exactly like one that crashed mid-development, and a state file in the wild had already invented `awaiting-user-test-main-checkout` to say it. `paused` stays what it was: stopped deliberately, resumable, nothing expected from anyone."
501
512
  },
502
513
  "abandonedBy": {
503
514
  "type": "string",
@@ -2059,7 +2070,7 @@
2059
2070
  },
2060
2071
  "gates": {
2061
2072
  "type": "array",
2062
- "description": "Gate ledger, written while the quality gates are active (lib/unattended.mjs gatesActive: MULTI_AGENT_UNATTENDED=1, or state.autopilot true): one entry per verdict, appended by gate-ledger.mjs under the write-state lock. With MULTI_AGENT_UNATTENDED=1 in its environment, pre-commit-check.sh blocks a commit unless the latest entry of every mandatory gate for HEAD is pass or not-applicable. Absent in a run with neither.",
2073
+ "description": "Gate ledger, written while the quality gates are active (lib/unattended.mjs gatesActive: MULTI_AGENT_UNATTENDED=1, or state.autopilot true): one entry per verdict, appended by gate-ledger.mjs under the write-state lock. With MULTI_AGENT_UNATTENDED=1 in its environment, pre-commit-check.sh blocks a commit unless the latest entry of every mandatory gate for HEAD is pass or not-applicable. A run with neither records only the gates in gate-ledger.mjs ATTENDED_RECORDED, each entry marked attended: true, which the unattended commit check never counts.",
2063
2074
  "items": {
2064
2075
  "type": "object",
2065
2076
  "additionalProperties": false,
@@ -2082,6 +2093,10 @@
2082
2093
  "type": ["string", "null"],
2083
2094
  "description": "Repository HEAD the gate ran against."
2084
2095
  },
2096
+ "attended": {
2097
+ "type": "boolean",
2098
+ "description": "True on an entry written while the quality gates were off (an attended run). lib/run-completion.mjs and the attended commit check read it; gate-ledger.mjs checkCommit ignores it."
2099
+ },
2085
2100
  "detail": {
2086
2101
  "type": "object",
2087
2102
  "additionalProperties": true
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/phases.json",
4
- "description": "The phase contract, in one place: the phase ids and names, which phases each mode runs, the per-phase token ceilings and the phase numbers other code compares against. gen-mode-dispatch.mjs, render-work-summary.sh, the autopilot runner, runs-index.mjs, usage-report.mjs and gen-facts.mjs read it; smoke-phase-contract.sh holds every copy that cannot read it (the phase docs and prose counts, agent-state.schema.json, token-budget.json, phase-tracker.sh, phase-banner.sh, gc-abandoned.sh) to it. Adding or merging a phase means editing here. Ceilings are measured against the current phase documents, never summed from earlier ones: smoke-token-budget.sh fails a ceiling more than 25% above the measurement.",
4
+ "description": "The phase contract, in one place: the phase ids and names, which phases each mode runs, the per-phase token ceilings and the phase numbers other code compares against. gen-mode-dispatch.mjs, render-work-summary.sh, the autopilot runner, runs-index.mjs, usage-report.mjs and gen-facts.mjs read it; smoke-phase-contract.sh holds every copy that cannot read it (the phase docs and prose counts, agent-state.schema.json, token-budget.json, phase-tracker.sh, phase-banner.sh, gc-abandoned.sh) to it. Adding or merging a phase means editing here. A mode with `commits: false` delivers no commit in its Phase 4 (analysis publishes a document), so lib/run-completion.mjs asks it for no commit evidence before `status: complete`; any other mode commits. Ceilings are measured against the current phase documents, never summed from earlier ones: smoke-token-budget.sh fails a ceiling more than 25% above the measurement.",
5
5
  "contractVersion": "2.0.0",
6
6
  "phaseSchema": 2,
7
7
  "phaseSchemaNote": "The phase vocabulary generation. metrics.jsonl is append-only and v19.0.0 renumbered the phases, so every line written from v19.0.0 on carries this number and a line without the field is generation 1. Aggregators pick their name table from it; without it phase 3 means Dev in old rows and Review in new ones and no reader can tell them apart.",
@@ -72,7 +72,8 @@
72
72
  },
73
73
  "analysis": {
74
74
  "phases": [0, 1, 3, 4, 5],
75
- "autopilot": false
75
+ "autopilot": false,
76
+ "commits": false
76
77
  }
77
78
  },
78
79
  "thresholds": {
@@ -50,7 +50,11 @@
50
50
  "items": { "type": "integer", "minimum": 0 },
51
51
  "uniqueItems": true
52
52
  },
53
- "autopilot": { "type": "boolean" }
53
+ "autopilot": { "type": "boolean" },
54
+ "commits": {
55
+ "type": "boolean",
56
+ "description": "false when the mode delivers no commit in its Phase 4; lib/run-completion.mjs then asks for no commit evidence before status complete. Absent means the mode commits."
57
+ }
54
58
  }
55
59
  }
56
60
  },
@@ -1872,6 +1872,43 @@
1872
1872
  "enabled": false
1873
1873
  }
1874
1874
  },
1875
+ "stopGuard": {
1876
+ "type": "object",
1877
+ "additionalProperties": false,
1878
+ "description": "run-stop-guard.sh, the Claude Code Stop hook that holds the session driving a pipeline run from ending its turn mid-phase with no halt or wait recorded. Read first on every Stop; false lets every stop through. Removing the hook from settings.json works too, and install does not add it back.",
1879
+ "properties": {
1880
+ "enabled": {
1881
+ "type": "boolean",
1882
+ "default": true,
1883
+ "description": "true = the hook judges a stop as documented. false = it never blocks."
1884
+ }
1885
+ },
1886
+ "default": {
1887
+ "enabled": true
1888
+ }
1889
+ },
1890
+ "readOnlyRepos": {
1891
+ "type": "array",
1892
+ "items": { "type": "string", "minLength": 1 },
1893
+ "default": [],
1894
+ "description": "Absolute paths (a leading ~ is expanded) of checkouts kept as read-only references: agent-guard.sh refuses an Edit, Write or NotebookEdit whose target is inside one, and a git commit, push, merge, cherry-pick, revert, am, rebase or tag that runs in one (after any cd or git -C on the line). Attended and unattended alike. Empty means no repository is protected this way."
1895
+ },
1896
+ "attendedCommitGate": {
1897
+ "type": "object",
1898
+ "additionalProperties": false,
1899
+ "description": "What pre-commit-check.sh does with an attended `git commit` inside a pipeline run whose gate ledger holds no passing build and test verdict (evidence-gate/build, evidence-gate/test) for HEAD. evidence-gate.mjs records both in an attended run, marked attended. Read by gate-ledger.mjs `evidence`; a commit outside a run, or in a mode that commits nothing, is never checked. Unattended commits are held to the full ledger regardless of this setting.",
1900
+ "properties": {
1901
+ "mode": {
1902
+ "type": "string",
1903
+ "enum": ["off", "warn", "block"],
1904
+ "default": "warn",
1905
+ "description": "warn = say so on stderr and let the commit through. block = refuse the commit (exit 2) until the evidence is recorded. off = no check."
1906
+ }
1907
+ },
1908
+ "default": {
1909
+ "mode": "warn"
1910
+ }
1911
+ },
1875
1912
  "mutationProbe": {
1876
1913
  "type": "object",
1877
1914
  "additionalProperties": false,
@@ -87,7 +87,7 @@
87
87
  "waitingFor": {
88
88
  "type": ["string", "null"],
89
89
  "x-sensitivity": "none",
90
- "description": "The step a paused run re-enters (agent-state waitingFor): maturity, user-channels-choice or question. Added in 1.1.0."
90
+ "description": "The step a paused run re-enters (agent-state waitingFor): maturity, user-channels-choice, local-test, attended-reply or question; null or absent when the run is not waiting. Added in 1.1.0."
91
91
  },
92
92
  "pendingQuestion": {
93
93
  "x-sensitivity": "none",
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @file _active-run.mjs - which pipeline run a hook call belongs to, and where
3
+ * that run stands against its mode's phase list.
4
+ *
5
+ * A hook receives the host's session id and working directory, never a task id.
6
+ * A run is bound to a session by one of three facts, strongest first:
7
+ *
8
+ * 1. `sessionId` equals MULTI_AGENT_SESSION_ID in the hook's environment: the
9
+ * id a launcher started the run with (continuous mode, a client).
10
+ * 2. `hostSessionId` equals the payload's `session_id`: write-state.mjs stamps
11
+ * the host session that made the run's last phase transition
12
+ * (CLAUDE_CODE_SESSION_ID), so the session driving a run is the one bound
13
+ * to it, and a resume in a new session rebinds it at its first transition.
14
+ * 3. A state with no `hostSessionId` whose `worktreePath` is the git toplevel
15
+ * of the payload's cwd: a run written before the stamp existed. A state
16
+ * that carries a different `hostSessionId` is another session's run and
17
+ * never matches this way.
18
+ *
19
+ * Nothing here decides anything; callers (run-stop-guard, run-scope-guard)
20
+ * apply their own rule to the run this returns. Zero runtime dependencies.
21
+ *
22
+ * @module pipeline/scripts/_active-run
23
+ */
24
+
25
+ import { spawnSync } from "node:child_process";
26
+ import { appendFileSync, mkdirSync, realpathSync, statSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+
29
+ import { readRegularJson } from "../lib/regular-file.mjs";
30
+ import { listRuns, logsRoot, resolveRunFile } from "./_run-paths.mjs";
31
+
32
+ export {
33
+ finalPhaseOf,
34
+ modeOf,
35
+ modePhases,
36
+ nextPhaseOf,
37
+ phaseContract,
38
+ phaseName,
39
+ } from "../lib/run-completion.mjs";
40
+
41
+ function real(p) {
42
+ try {
43
+ return realpathSync(p);
44
+ } catch {
45
+ return p;
46
+ }
47
+ }
48
+
49
+ /** The git toplevel of `dir`, real-pathed, or null outside a repository. */
50
+ export function gitToplevel(dir) {
51
+ if (!dir) return null;
52
+ const r = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
53
+ encoding: "utf8",
54
+ timeout: 5000,
55
+ });
56
+ return r.status === 0 ? real(r.stdout.trim()) : null;
57
+ }
58
+
59
+ function mtime(p) {
60
+ try {
61
+ return statSync(p).mtimeMs;
62
+ } catch {
63
+ return 0;
64
+ }
65
+ }
66
+
67
+ const LIVE_STATUSES = new Set(["in_progress", "awaiting_input"]);
68
+
69
+ /**
70
+ * The run bound to this hook call, or null.
71
+ *
72
+ * @param {{sessionId?: string|null, cwd?: string|null, env?: Record<string,string|undefined>}} opts
73
+ * @returns {{statePath: string, state: object, matchedBy: "session"|"host-session"|"worktree"} | null}
74
+ * with `unreadable` (count of state files that did not parse) on the returned
75
+ * object or on the `findActiveRun.lastUnreadable` property when nothing matched
76
+ */
77
+ export function findActiveRun({ sessionId = null, cwd = null, env = process.env } = {}) {
78
+ const launched = env.MULTI_AGENT_SESSION_ID || "";
79
+ let top;
80
+ const topOf = () => {
81
+ if (top === undefined) top = cwd ? gitToplevel(cwd) : null;
82
+ return top;
83
+ };
84
+ const hits = [];
85
+ let unreadable = 0;
86
+ const prevRoot = process.env.LOGS_ROOT;
87
+ if (env.LOGS_ROOT !== undefined) process.env.LOGS_ROOT = env.LOGS_ROOT;
88
+ try {
89
+ for (const run of listRuns()) {
90
+ const p = resolveRunFile(run.taskId, "agent-state.json", run.project);
91
+ if (!p) continue;
92
+ const doc = readRegularJson(p, null);
93
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
94
+ unreadable += 1;
95
+ continue;
96
+ }
97
+ let matchedBy = null;
98
+ if (launched && doc.sessionId === launched) matchedBy = "session";
99
+ else if (sessionId && doc.hostSessionId === sessionId) matchedBy = "host-session";
100
+ else if (
101
+ !doc.hostSessionId &&
102
+ typeof doc.worktreePath === "string" &&
103
+ doc.worktreePath &&
104
+ topOf() &&
105
+ real(doc.worktreePath) === topOf()
106
+ ) {
107
+ matchedBy = "worktree";
108
+ }
109
+ if (matchedBy) hits.push({ statePath: p, state: doc, matchedBy, t: mtime(p) });
110
+ }
111
+ } finally {
112
+ if (prevRoot === undefined) delete process.env.LOGS_ROOT;
113
+ else process.env.LOGS_ROOT = prevRoot;
114
+ }
115
+ findActiveRun.lastUnreadable = unreadable;
116
+ if (!hits.length) return null;
117
+ const rank = { session: 0, "host-session": 1, worktree: 2 };
118
+ hits.sort((a, b) => rank[a.matchedBy] - rank[b.matchedBy] || b.t - a.t);
119
+ const lead = hits.filter((h) => h.matchedBy === hits[0].matchedBy);
120
+ // A session can hold a finished run next to a live one. The run started
121
+ // last is the one the session is on: a live run started after the finished
122
+ // one wins however recently the finished file was touched, and a live run
123
+ // started before it was abandoned without a halt and does not hold the
124
+ // session. startedAt orders them; the state's mtime stands in without it.
125
+ const started = (h) => Date.parse(h.state?.startedAt) || h.t;
126
+ const isLive = (h) => LIVE_STATUSES.has(h.state?.status);
127
+ const liveRun = lead.find(isLive);
128
+ const finished = lead.filter((h) => !isLive(h));
129
+ const lastFinished = finished.sort((a, b) => started(b) - started(a))[0];
130
+ let pick = lead[0];
131
+ if (liveRun)
132
+ pick = lastFinished && started(lastFinished) > started(liveRun) ? lastFinished : liveRun;
133
+ const { t: _t, ...best } = pick;
134
+ return { ...best, unreadable };
135
+ }
136
+ findActiveRun.lastUnreadable = 0;
137
+
138
+ /**
139
+ * Append one line to the hook failure log under the log root. A hook that
140
+ * fails open says why somewhere a person can read later; this never throws.
141
+ *
142
+ * @param {string} hook
143
+ * @param {string} reason
144
+ * @param {Record<string,string|undefined>} [env]
145
+ */
146
+ export function logHookFailure(hook, reason, env = process.env) {
147
+ try {
148
+ const root = env.LOGS_ROOT || logsRoot();
149
+ mkdirSync(root, { recursive: true });
150
+ const line = `${new Date().toISOString()} ${hook}: ${String(reason).replace(/\s+/g, " ")}\n`;
151
+ appendFileSync(join(root, "hook-failures.log"), line);
152
+ } catch {
153
+ // Logging is best-effort; the hook's verdict never depends on it.
154
+ }
155
+ }
156
+
157
+ /** The run directory that holds a state file. */
158
+ export const runDirOf = (statePath) => dirname(statePath);
@@ -27,6 +27,7 @@ export const OUTCOME = Object.freeze({
27
27
  MERGED: "merged",
28
28
  SKIPPED: "skipped",
29
29
  COMPLETED_NO_PR: "completed-no-pr",
30
+ STOPPED_INCOMPLETE: "stopped-incomplete",
30
31
  NEEDS_INPUT: "needs-input",
31
32
  AWAITING_ANSWER: "awaiting-answer",
32
33
  VERIFICATION_FAILED: "verification-failed",