@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.
- package/CHANGELOG.md +13 -0
- package/README.md +14 -1
- package/docs/facts.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +53 -42
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/contract/types/index.d.ts +1 -1
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/phases/operations.md +17 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +37 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/doctor.mjs +54 -0
- package/pipeline/scripts/evidence-gate.mjs +8 -4
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/uninstall.mjs +1 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- 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
|
|
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"`:
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
node $HOME/.claude/scripts/
|
|
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
|
|
|
@@ -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": [
|
|
280
|
-
|
|
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.
|
|
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",
|