@mmerterden/multi-agent-pipeline 20.27.2 → 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 (99) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -6
  3. package/README.tr.md +6 -5
  4. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
  5. package/docs/architecture.md +4 -4
  6. package/docs/ecosystem.md +5 -5
  7. package/docs/facts.json +14 -12
  8. package/docs/features.md +2 -0
  9. package/install/_dev-only-files.mjs +1 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/claude.mjs +112 -0
  12. package/install/index.mjs +0 -2
  13. package/install/templates/claude-hooks.json +30 -1
  14. package/manifest.json +101 -67
  15. package/package.json +4 -1
  16. package/pipeline/agents/code-reviewer.md +1 -0
  17. package/pipeline/agents/test-gen-analyst.md +98 -0
  18. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
  22. package/pipeline/contract/types/index.d.ts +1 -1
  23. package/pipeline/lib/hook-registration.mjs +252 -0
  24. package/pipeline/lib/prefs-value.mjs +44 -0
  25. package/pipeline/lib/run-completion.mjs +148 -0
  26. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  27. package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
  28. package/pipeline/multi-agent-refs/features/doctor.md +24 -0
  29. package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
  30. package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
  31. package/pipeline/multi-agent-refs/help/en.md +1 -0
  32. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  33. package/pipeline/multi-agent-refs/phases/operations.md +19 -2
  34. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  35. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  36. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -8
  37. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  38. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  39. package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
  40. package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
  41. package/pipeline/preferences-template.json +10 -0
  42. package/pipeline/rules/figma-pipeline.md +2 -0
  43. package/pipeline/schemas/agent-state.schema.json +20 -5
  44. package/pipeline/schemas/phases.json +3 -2
  45. package/pipeline/schemas/phases.schema.json +5 -1
  46. package/pipeline/schemas/prefs.schema.json +51 -0
  47. package/pipeline/schemas/runs-index.schema.json +1 -1
  48. package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
  49. package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
  50. package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
  51. package/pipeline/schemas/test-gen-results.schema.json +44 -0
  52. package/pipeline/scripts/_active-run.mjs +158 -0
  53. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  54. package/pipeline/scripts/_guard_rules.py +605 -0
  55. package/pipeline/scripts/_handle-patterns.mjs +166 -0
  56. package/pipeline/scripts/_http-post.mjs +141 -0
  57. package/pipeline/scripts/_stack-routing.mjs +4 -0
  58. package/pipeline/scripts/_test-gaming.mjs +227 -0
  59. package/pipeline/scripts/_unified-diff.mjs +104 -0
  60. package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
  61. package/pipeline/scripts/agent-guard.py +118 -7
  62. package/pipeline/scripts/agent-guard.sh +50 -3
  63. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  64. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  65. package/pipeline/scripts/confirm-command.mjs +70 -0
  66. package/pipeline/scripts/doctor.mjs +105 -0
  67. package/pipeline/scripts/evidence-gate.mjs +82 -7
  68. package/pipeline/scripts/gate-ledger.mjs +163 -7
  69. package/pipeline/scripts/handle-diff-audit.mjs +188 -0
  70. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  71. package/pipeline/scripts/pre-commit-check.sh +12 -0
  72. package/pipeline/scripts/routine-registry.mjs +5 -1
  73. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  74. package/pipeline/scripts/run-scope-guard.sh +23 -0
  75. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  76. package/pipeline/scripts/run-stop-guard.sh +25 -0
  77. package/pipeline/scripts/run-ui-tests.sh +27 -3
  78. package/pipeline/scripts/test-classify.mjs +296 -0
  79. package/pipeline/scripts/test-gen-gate.mjs +409 -0
  80. package/pipeline/scripts/test-gen-state.mjs +313 -0
  81. package/pipeline/scripts/test-integrity-gate.mjs +76 -11
  82. package/pipeline/scripts/uninstall.mjs +2 -0
  83. package/pipeline/scripts/usage-register-beacon.mjs +239 -0
  84. package/pipeline/scripts/usage-register.mjs +128 -33
  85. package/pipeline/scripts/usage-report.mjs +43 -33
  86. package/pipeline/scripts/validate-state.mjs +26 -0
  87. package/pipeline/scripts/write-state.mjs +114 -0
  88. package/pipeline/skills/.skill-manifest.json +23 -3
  89. package/pipeline/skills/.skills-index.json +57 -2
  90. package/pipeline/skills/shared/README.md +10 -5
  91. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  92. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
  93. package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
  94. package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
  95. package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
  96. package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
  97. package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
  98. package/pipeline/skills/skills-index.md +7 -2
  99. package/install/_telemetry.mjs +0 -61
@@ -61,6 +61,7 @@ import {
61
61
  } from "../lib/unattended-settings-location.mjs";
62
62
  import { prefsHosts, profileGaps } from "../lib/unattended-profile-check.mjs";
63
63
  import { xcodeSkillsStatus } from "../lib/xcode-plugin.mjs";
64
+ import { hookReport } from "../lib/hook-registration.mjs";
64
65
 
65
66
  const HERE = dirname(fileURLToPath(import.meta.url));
66
67
  const HOME = homedir();
@@ -94,6 +95,7 @@ const CHECK_IDS = [
94
95
  "prefs-valid",
95
96
  "identity",
96
97
  "hook-coverage",
98
+ "hook-reachability",
97
99
  "credential-mapping",
98
100
  "credential-liveness",
99
101
  "store-reference",
@@ -104,6 +106,7 @@ const CHECK_IDS = [
104
106
  "disk-space",
105
107
  "worktree-residue",
106
108
  "usage-report",
109
+ "usage-register",
107
110
  "free-models",
108
111
  "xcode-skills",
109
112
  ];
@@ -514,6 +517,57 @@ function checkHookCoverage() {
514
517
  ok("hook-coverage");
515
518
  }
516
519
 
520
+ // hook-coverage asks whether each template command appears in settings.json
521
+ // at all. This asks what Claude Code will actually run: per event, a matcher
522
+ // that covers the template's tool names, the script present under
523
+ // ~/.claude/scripts, no disableAllHooks; and which preferences do nothing
524
+ // because their hook is missing (lib/hook-registration.mjs).
525
+ function checkHookReachability() {
526
+ const tpl = join(CLAUDE, "templates", "claude-hooks.json");
527
+ if (!existsSync(tpl)) {
528
+ skip("hook-reachability", "no hooks template installed to compare against");
529
+ return;
530
+ }
531
+ const r = hookReport({ templatePath: tpl, home: HOME });
532
+ if (r.error) {
533
+ skip("hook-reachability", r.error);
534
+ return;
535
+ }
536
+ if (r.ok) {
537
+ ok("hook-reachability");
538
+ return;
539
+ }
540
+ const parts = [];
541
+ if (r.disabled) parts.push(r.disabled);
542
+ else if (r.missing.length) {
543
+ const names = [
544
+ ...new Set(
545
+ r.missing.map(
546
+ (m) => `${m.script || m.command} on ${m.event}${m.matcher ? ` ${m.matcher}` : ""}`,
547
+ ),
548
+ ),
549
+ ];
550
+ parts.push(
551
+ `${r.missing.length} template hook(s) Claude Code will not run: ${names.slice(0, 4).join(", ")}${names.length > 4 ? ` and ${names.length - 4} more` : ""}`,
552
+ );
553
+ }
554
+ if (r.inert.length) {
555
+ parts.push(
556
+ `inert preference(s): ${r.inert.map((i) => `${i.pref} (needs ${i.script})`).join(", ")}`,
557
+ );
558
+ }
559
+ report(
560
+ "hook-reachability",
561
+ "WARN",
562
+ parts.join("; "),
563
+ `merge ${tpl} into ${join(CLAUDE, "settings.json")} (or run ${hostCommand("setup", HERE)})`,
564
+ [
565
+ ...r.missing.map((m) => `${m.event} ${m.matcher || "-"} ${m.command}: ${m.why}`),
566
+ ...r.inert.map((i) => `prefs.global.${i.pref} -> ${i.script}`),
567
+ ],
568
+ );
569
+ }
570
+
517
571
  function credentialInventory() {
518
572
  const inv = join(CLAUDE, "lib", "credential-inventory.sh");
519
573
  if (!existsSync(inv)) return null;
@@ -936,6 +990,55 @@ function checkUsageReport() {
936
990
  );
937
991
  }
938
992
 
993
+ // usage-register.mjs runs quietly from setup, update and Phase 0, so the record
994
+ // of its last attempt, and of the failure report it may have sent, is where a
995
+ // machine that never registers shows why.
996
+ const REGISTER_FINE = new Set(["registered", "registered-feedback-only", "enabled"]);
997
+ const REGISTER_QUIET = new Set([
998
+ "token in environment",
999
+ "token in prefs",
1000
+ "token already onboarded",
1001
+ ]);
1002
+
1003
+ function beaconSummary(beacon) {
1004
+ const last = beacon?.last;
1005
+ if (!last || typeof last.outcome !== "string") return "no failure report was sent";
1006
+ const when = last.t ? ` at ${last.t}` : "";
1007
+ return last.outcome === "sent"
1008
+ ? `the failure report (${last.reason}) reached the site${when}`
1009
+ : `the failure report (${last.reason}) could not be delivered${when}`;
1010
+ }
1011
+
1012
+ function checkUsageRegister() {
1013
+ const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
1014
+ if (prefs?.global?.usageLog?.optOut === true) {
1015
+ skip("usage-register", "usage reporting is opted out");
1016
+ return;
1017
+ }
1018
+ const logs = join(CLAUDE, "logs", "multi-agent");
1019
+ const last = readJson(join(logs, "usage-last-register.json"));
1020
+ if (!last || typeof last.status !== "string") {
1021
+ skip("usage-register", "no registration has been attempted on this machine yet");
1022
+ return;
1023
+ }
1024
+ if (
1025
+ REGISTER_FINE.has(last.status) ||
1026
+ (last.status === "skipped" && REGISTER_QUIET.has(last.detail))
1027
+ ) {
1028
+ ok("usage-register");
1029
+ return;
1030
+ }
1031
+ const beacon = beaconSummary(readJson(join(logs, "usage-register-beacon.json")));
1032
+ const when = last.t ? ` at ${last.t}` : "";
1033
+ const detail = last.detail ? ` (${last.detail})` : "";
1034
+ report(
1035
+ "usage-register",
1036
+ "WARN",
1037
+ `the last usage registration${when} was ${last.status}${detail}; ${beacon}`,
1038
+ `run node ${join(HERE, "usage-register.mjs")} --refresh`,
1039
+ );
1040
+ }
1041
+
939
1042
  // route-on --free promises that simple tasks leave Claude. With no usable
940
1043
  // provider every one of them silently escalates back, which costs nothing but
941
1044
  // saves nothing either - the user believes a control is working that is not.
@@ -1353,6 +1456,7 @@ function main() {
1353
1456
  checkPrefsValid();
1354
1457
  checkIdentity();
1355
1458
  checkHookCoverage();
1459
+ checkHookReachability();
1356
1460
  checkCredentials(credentialInventory());
1357
1461
  checkStoreReference();
1358
1462
  checkEmbeddedCredentials();
@@ -1362,6 +1466,7 @@ function main() {
1362
1466
  checkDiskSpace();
1363
1467
  checkWorktreeResidue();
1364
1468
  checkUsageReport();
1469
+ checkUsageRegister();
1365
1470
  checkFreeModels();
1366
1471
  checkXcodeSkills();
1367
1472
  if (PROFILE === "server") checkServerProfile(resolved);
@@ -12,11 +12,23 @@
12
12
  // node pipeline/scripts/evidence-gate.mjs --claim test --status passed --evidence test.log \
13
13
  // --success-pattern 'All tests passed' --failure-pattern 'FAILED'
14
14
  // node pipeline/scripts/evidence-gate.mjs --claim manual --status passed --evidence manual-test.json
15
+ // node pipeline/scripts/evidence-gate.mjs --claim artefact --status passed --evidence gaps.json \
16
+ // --stage-start 2026-10-23T09:00:00Z
15
17
  //
16
18
  // Flags:
17
- // --claim build | test | review | manual
19
+ // --claim build | test | review | manual | artefact
18
20
  // build/test/review select default success/failure markers;
19
- // manual is JSON-based (Phase 5 manual-test.json, see below)
21
+ // manual is JSON-based (Phase 5 manual-test.json, see below);
22
+ // artefact is a stage's own output file, judged only by
23
+ // --stage-start (required with it)
24
+ // --stage-start <t> ISO time or epoch (ms, or seconds) the stage started.
25
+ // The evidence must be this stage's own file, written
26
+ // during it and whole: an mtime earlier than the start
27
+ // less 2s of clock slack is another stage's (or an old
28
+ // run's) file. A .json file is whole when it parses; any
29
+ // other file under 400 bytes, or ending in a letter or
30
+ // digit with no trailing newline, is partial, cut off
31
+ // mid-write. Applies to every claim when given.
20
32
  // --status the claimed status (only "passed" is gated; others pass through)
21
33
  // --evidence <path> the log / artifact that must substantiate a "passed" claim
22
34
  // --require-screenshot manual claim only: every "pass" criterion must name a
@@ -63,7 +75,7 @@ import { spawnSync } from "node:child_process";
63
75
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
64
76
  import { fileURLToPath } from "node:url";
65
77
 
66
- import { gateContext } from "./gate-ledger.mjs";
78
+ import { ATTENDED_RECORDED, gateContext } from "./gate-ledger.mjs";
67
79
 
68
80
  const LEDGER = fileURLToPath(new URL("./gate-ledger.mjs", import.meta.url));
69
81
 
@@ -93,7 +105,54 @@ const DEFAULT_MARKERS = {
93
105
  };
94
106
 
95
107
  const JSON_CLAIMS = new Set(["manual"]);
96
- const CLAIMS = [...Object.keys(DEFAULT_MARKERS), ...JSON_CLAIMS];
108
+ const ARTEFACT_CLAIM = "artefact";
109
+ const CLAIMS = [...Object.keys(DEFAULT_MARKERS), ...JSON_CLAIMS, ARTEFACT_CLAIM];
110
+
111
+ const STAGE_SLACK_MS = 2000;
112
+ const PARTIAL_UNDER_BYTES = 400;
113
+
114
+ /** --stage-start as epoch ms: ISO, epoch ms, or epoch seconds. */
115
+ function stageStartMs(value) {
116
+ if (value === undefined) return null;
117
+ const s = String(value).trim();
118
+ if (/^\d+(\.\d+)?$/.test(s)) {
119
+ const n = Number(s);
120
+ return n < 1e12 ? n * 1000 : n;
121
+ }
122
+ const t = Date.parse(s);
123
+ if (value === true || Number.isNaN(t)) usage(`--stage-start is not a time: ${value}`);
124
+ return t;
125
+ }
126
+
127
+ /**
128
+ * Is this the stage's own, whole file? Returns the reason it is not, or null.
129
+ *
130
+ * @param {string} path
131
+ * @param {Buffer} raw
132
+ * @param {number} startMs
133
+ */
134
+ function stageArtefactProblem(path, raw, startMs) {
135
+ const mtime = statSync(path).mtimeMs;
136
+ if (mtime < startMs - STAGE_SLACK_MS) {
137
+ return `written ${new Date(mtime).toISOString()}, before the stage started (${new Date(startMs).toISOString()}); not this stage's output`;
138
+ }
139
+ if (/\.json$/i.test(path)) {
140
+ try {
141
+ JSON.parse(raw.toString("utf8"));
142
+ return null;
143
+ } catch (err) {
144
+ return `partial: not valid JSON (${err.message})`;
145
+ }
146
+ }
147
+ if (raw.length < PARTIAL_UNDER_BYTES) {
148
+ return `partial: ${raw.length} bytes, under ${PARTIAL_UNDER_BYTES}`;
149
+ }
150
+ const last = raw.toString("utf8").slice(-1);
151
+ if (/[A-Za-z0-9]/.test(last)) {
152
+ return "partial: ends mid-token, with no trailing newline";
153
+ }
154
+ return null;
155
+ }
97
156
  const MANUAL_VERDICTS = new Set(["pass", "fail", "not-tested"]);
98
157
 
99
158
  const MAX_PATTERN_LEN = 200;
@@ -144,15 +203,19 @@ function parseArgs(argv) {
144
203
  // the bytes are gone. The verdict is short today; the verdict is also the only
145
204
  // thing a caller reads, so losing half of it is the one failure that matters.
146
205
  let verdictExtra = {};
147
- // Only while the quality gates are active (lib/unattended.mjs, gatesActive):
148
- // one synchronous append, so the verdict and its ledger entry
206
+ // While the quality gates are active (lib/unattended.mjs, gatesActive), and
207
+ // for the build and test claims of an attended run: one synchronous append, so the verdict and its ledger entry
149
208
  // cannot come apart. A ledger problem is reported and never changes the verdict.
150
209
  let ledgerClaim = null;
151
210
  let ledgerState = null;
152
211
  function ledger(verdict, reason, extra) {
153
212
  if (!ledgerClaim) return;
154
213
  const ctx = gateContext({ statePath: ledgerState });
155
- if (!ctx.active) return;
214
+ // Attended, only the build and test claims are recorded (marked attended):
215
+ // the attended commit check reads them. Without a run state there is
216
+ // nothing to record into.
217
+ if (!ctx.active && !(ctx.statePath && ATTENDED_RECORDED.has(`evidence-gate/${ledgerClaim}`)))
218
+ return;
156
219
  const detail = { reason, ...(extra.summary ? { summary: extra.summary } : {}) };
157
220
  if (verdictExtra.stack) detail.stack = verdictExtra.stack;
158
221
  const argv = [LEDGER, "append", "--gate", `evidence-gate/${ledgerClaim}`, "--verdict", verdict];
@@ -291,6 +354,10 @@ if (typeof status !== "string") {
291
354
  }
292
355
  ledgerClaim = claim;
293
356
  ledgerState = typeof args.state === "string" ? args.state : null;
357
+ const stageStart = stageStartMs(args["stage-start"]);
358
+ if (claim === ARTEFACT_CLAIM && stageStart === null) {
359
+ usage("--claim artefact needs --stage-start");
360
+ }
294
361
 
295
362
  // Only a "passed" claim needs proof. failed / pending / anything else is honest
296
363
  // about not succeeding, so it passes through untouched.
@@ -336,6 +403,14 @@ if (body.trim().length === 0) {
336
403
  fail(`${claim} claims passed but evidence file is blank: ${evidence} (default-FAIL)`);
337
404
  }
338
405
 
406
+ if (stageStart !== null) {
407
+ const problem = stageArtefactProblem(evidence, readFileSync(evidence), stageStart);
408
+ if (problem) fail(`${claim} evidence ${evidence}: ${problem} (default-FAIL)`);
409
+ if (claim === ARTEFACT_CLAIM) {
410
+ ok(`${claim} ${evidence} is this stage's own output and whole`);
411
+ }
412
+ }
413
+
339
414
  if (JSON_CLAIMS.has(claim)) {
340
415
  gateManual(claim, evidence, body, args["require-screenshot"] === true);
341
416
  }
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * Entries are written while the quality gates are active (lib/unattended.mjs,
17
17
  * gatesActive): MULTI_AGENT_UNATTENDED=1, or a run in autopilot mode. A run
18
- * that is neither writes nothing and state.gates[] stays absent. The commit
18
+ * that is neither writes only the gates in ATTENDED_RECORDED, each entry marked
19
+ * `attended: true`, and checkCommit never counts those. The commit
19
20
  * hook's REQUIREMENT is narrower: it applies only with the variable set, since
20
21
  * it is security enforcement and a terminal autopilot run has a person at it.
21
22
  *
@@ -32,6 +33,9 @@
32
33
  * gate-ledger.mjs append --gate <id> --verdict pass|fail|not-applicable
33
34
  * [--detail '<json>'] [--state <path>] [--repo <dir>]
34
35
  * gate-ledger.mjs check [--state <path>] [--repo <dir>] [--sha <sha>] [--json]
36
+ * gate-ledger.mjs evidence [--state <path>] [--repo <dir>] [--sha <sha>] [--json]
37
+ * the attended commit check: build/test evidence for HEAD, per
38
+ * prefs.global.attendedCommitGate.mode (exit 0 fine, 1 warn, 4 block)
35
39
  * gate-ledger.mjs park --outcome awaiting-answer|verification-failed
36
40
  * [--waiting-for <step>] [--gate <id>] [--reason <text>] [--state <path>]
37
41
  *
@@ -49,12 +53,21 @@
49
53
  */
50
54
 
51
55
  import { spawnSync } from "node:child_process";
52
- import { existsSync, realpathSync, renameSync, writeFileSync } from "node:fs";
56
+ import {
57
+ existsSync,
58
+ readFileSync,
59
+ realpathSync,
60
+ renameSync,
61
+ statSync,
62
+ writeFileSync,
63
+ } from "node:fs";
53
64
  import { basename, dirname } from "node:path";
54
65
 
55
66
  import { decisionEntry, withDecision } from "../lib/decisions.mjs";
56
67
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
57
68
  import { readRegularFile, readRegularJson } from "../lib/regular-file.mjs";
69
+ import { globalPref } from "../lib/prefs-value.mjs";
70
+ import { modeEntry } from "../lib/run-completion.mjs";
58
71
  import { attendedVerdict, gatesActive } from "../lib/unattended.mjs";
59
72
  import { listRuns, resolveRunFile } from "./_run-paths.mjs";
60
73
  import { acquireLock, releaseLock, stillOurs } from "./write-state.mjs";
@@ -80,6 +93,41 @@ export const MANDATORY_AT_COMMIT = [
80
93
  "plan-coverage",
81
94
  ];
82
95
 
96
+ /**
97
+ * Gates whose verdict is recorded in an attended run too, marked
98
+ * `attended: true`. `plan-coverage` is what lib/run-completion.mjs reads
99
+ * before a run may write `status: complete`. One locked append per verdict;
100
+ * every other gate writes nothing while the gates are off.
101
+ */
102
+ export const ATTENDED_RECORDED = new Set([
103
+ "plan-coverage",
104
+ "evidence-gate/build",
105
+ "evidence-gate/test",
106
+ ]);
107
+
108
+ /** What an attended commit is checked for (pre-commit-check.sh, `evidence`). */
109
+ export const ATTENDED_COMMIT_EVIDENCE = ["evidence-gate/build", "evidence-gate/test"];
110
+
111
+ /** prefs.global.attendedCommitGate.mode values; `warn` is the default. */
112
+ export const ATTENDED_COMMIT_MODES = ["off", "warn", "block"];
113
+
114
+ /**
115
+ * The build/test evidence an attended commit on `sha` lacks: every gate in
116
+ * ATTENDED_COMMIT_EVIDENCE whose latest entry for that sha, attended or not,
117
+ * is missing or not passing.
118
+ *
119
+ * @param {object} state
120
+ * @param {string} sha
121
+ * @returns {string[]}
122
+ */
123
+ export function attendedEvidenceGaps(state, sha) {
124
+ const latest = latestByGate(state?.gates, sha);
125
+ return ATTENDED_COMMIT_EVIDENCE.filter((g) => {
126
+ const e = latest.get(g);
127
+ return !e || !PASSING.has(e.verdict);
128
+ });
129
+ }
130
+
83
131
  const QUESTION_ID = /^[a-z][a-z0-9-]*$/;
84
132
  const OPTION_ID = /^[a-z0-9][a-z0-9+_-]*$/;
85
133
  const QUESTION_KEYS = ["id", "stepId", "text", "kind", "options"];
@@ -201,6 +249,59 @@ export function resolveStatePath({ statePath = null, repo = null, env = process.
201
249
  }
202
250
  }
203
251
 
252
+ export const TREE_MATCH_MAX_AGE_MS = 6 * 60 * 60 * 1000;
253
+
254
+ /**
255
+ * The in-progress run an attended commit in `repo` belongs to, or null.
256
+ *
257
+ * Only a run still in progress counts: a finished local-mode run keeps the
258
+ * project root as its worktree forever and would otherwise judge every later
259
+ * commit there. Of several, the one bound to the hook's session (`sessionId`
260
+ * = MULTI_AGENT_SESSION_ID, or `hostSessionId` = the payload's session_id)
261
+ * wins, then the most recently written. A run matched only by its worktree
262
+ * counts while its state was written in the last six hours.
263
+ *
264
+ * @param {{repo: string, session?: string, env?: Record<string,string|undefined>}} opts
265
+ * @returns {string|null}
266
+ */
267
+ export function liveRunFor({ repo, session = "", env = process.env }) {
268
+ const prevRoot = process.env.LOGS_ROOT;
269
+ if (env.LOGS_ROOT !== undefined) process.env.LOGS_ROOT = env.LOGS_ROOT;
270
+ try {
271
+ const top = toplevel(repo);
272
+ const want = top ? real(top) : null;
273
+ const launched = env.MULTI_AGENT_SESSION_ID || "";
274
+ const hits = [];
275
+ for (const run of listRuns()) {
276
+ const p = resolveRunFile(run.taskId, "agent-state.json", run.project);
277
+ if (!p) continue;
278
+ const doc = readJson(p);
279
+ if (!doc || typeof doc !== "object" || doc.status !== "in_progress") continue;
280
+ const bound =
281
+ (launched && doc.sessionId === launched) || (session && doc.hostSessionId === session);
282
+ const onTree =
283
+ want && typeof doc.worktreePath === "string" && real(doc.worktreePath) === want;
284
+ if (!bound && !onTree) continue;
285
+ let t = 0;
286
+ try {
287
+ t = statSync(p).mtimeMs;
288
+ } catch {
289
+ t = 0;
290
+ }
291
+ // A run that crashed keeps status in_progress forever. Matched only by
292
+ // its worktree, it judges commits there while its state is still being
293
+ // written; once it has gone quiet the tree is the user's again.
294
+ if (!bound && Date.now() - t > TREE_MATCH_MAX_AGE_MS) continue;
295
+ hits.push({ p, bound: bound ? 1 : 0, t });
296
+ }
297
+ hits.sort((a, b) => b.bound - a.bound || b.t - a.t);
298
+ return hits.length ? hits[0].p : null;
299
+ } finally {
300
+ if (prevRoot === undefined) delete process.env.LOGS_ROOT;
301
+ else process.env.LOGS_ROOT = prevRoot;
302
+ }
303
+ }
304
+
204
305
  /**
205
306
  * Do the quality gates run here, and against which state?
206
307
  *
@@ -274,9 +375,17 @@ export async function record(
274
375
  throw new LedgerError("usage", `verdict must be one of ${VERDICTS.join(", ")}, got ${verdict}`);
275
376
  }
276
377
  const ctx = gateContext({ statePath, repo, env });
277
- if (!ctx.active) return { written: false, reason: attendedVerdict(gate).reason };
378
+ const attended = !ctx.active;
379
+ if (attended && !ATTENDED_RECORDED.has(gate)) {
380
+ return { written: false, reason: attendedVerdict(gate).reason };
381
+ }
278
382
  const path = ctx.statePath;
279
- if (!path) return { written: false, reason: "no run state found for this repository" };
383
+ if (!path) {
384
+ // Attended with no run to write into is the ordinary case outside the
385
+ // pipeline, not a ledger problem worth a line on stderr.
386
+ if (attended) return { written: false, reason: attendedVerdict(gate).reason };
387
+ return { written: false, reason: "no run state found for this repository" };
388
+ }
280
389
  let shaRepo = repo;
281
390
  if (!shaRepo) {
282
391
  const wt = ctx.state?.worktreePath;
@@ -288,6 +397,7 @@ export async function record(
288
397
  at: new Date().toISOString(),
289
398
  sha: headSha(shaRepo),
290
399
  detail: detail && typeof detail === "object" ? detail : { value: detail },
400
+ ...(attended ? { attended: true } : {}),
291
401
  };
292
402
  await updateState(path, (s) => ({
293
403
  ...s,
@@ -403,7 +513,12 @@ export function latestByGate(gates, sha) {
403
513
  * @returns {{ok: boolean, missing: string[], failing: object[], sha: string}}
404
514
  */
405
515
  export function checkCommit(state, sha, { mandatory = MANDATORY_AT_COMMIT } = {}) {
406
- const latest = latestByGate(state?.gates, sha);
516
+ // An entry written while the gates were off says what an attended run
517
+ // observed; it never stands in for a verdict the unattended contract requires.
518
+ const gated = (Array.isArray(state?.gates) ? state.gates : []).filter(
519
+ (e) => !(e && e.attended === true),
520
+ );
521
+ const latest = latestByGate(gated, sha);
407
522
  const missing = [];
408
523
  const failing = [];
409
524
  for (const g of mandatory) {
@@ -499,6 +614,47 @@ async function main(argv) {
499
614
  );
500
615
  return 0;
501
616
  }
617
+ if (mode === "evidence") {
618
+ // The attended commit check: exit 0 nothing to say (no run, mode off, a
619
+ // mode that commits nothing, evidence present), 1 warn, 4 block. Never 2
620
+ // or 3 for a missing run: outside the pipeline a commit is none of this
621
+ // hook's business.
622
+ const pref = globalPref("attendedCommitGate.mode", "warn");
623
+ const gateMode = ATTENDED_COMMIT_MODES.includes(pref) ? pref : "warn";
624
+ if (gateMode === "off") return 0;
625
+ let session = "";
626
+ if (opts["hook-input"] === "-") {
627
+ try {
628
+ session = String(JSON.parse(readFileSync(0, "utf8"))?.session_id || "");
629
+ } catch {
630
+ session = "";
631
+ }
632
+ }
633
+ const path = opts.state || liveRunFor({ repo, session });
634
+ if (!path) return 0;
635
+ const state = readJson(path);
636
+ if (!state || typeof state !== "object") return 0;
637
+ if (state.status !== "in_progress") return 0;
638
+ if (modeEntry(state)?.commits === false) return 0;
639
+ const sha = opts.sha || headSha(repo);
640
+ if (!sha) return 0;
641
+ const missing = attendedEvidenceGaps(state, sha);
642
+ if (opts.json)
643
+ writeFileSync(1, `${JSON.stringify({ statePath: path, sha, gateMode, missing })}\n`);
644
+ if (!missing.length) return 0;
645
+ const verb = gateMode === "block" ? "BLOCKED" : "warning";
646
+ writeFileSync(
647
+ 2,
648
+ `${[
649
+ `pre-commit-check: ${verb}: the run's gate ledger (${path}) has no passing ${missing.join(" / ")} for HEAD ${sha.slice(0, 12)}.`,
650
+ " Run the build and the tests through evidence-gate.mjs (Phase 2 Gate 1) so the commit rests on recorded evidence.",
651
+ gateMode === "block"
652
+ ? " prefs.global.attendedCommitGate.mode is block; set it to warn to commit anyway."
653
+ : " The commit goes ahead; prefs.global.attendedCommitGate.mode block refuses it instead.",
654
+ ].join("\n")}\n`,
655
+ );
656
+ return gateMode === "block" ? 4 : 1;
657
+ }
502
658
  if (mode === "park") {
503
659
  const path = resolveStatePath({ statePath: opts.state, repo });
504
660
  let pendingQuestion;
@@ -523,7 +679,7 @@ async function main(argv) {
523
679
  });
524
680
  return 0;
525
681
  }
526
- throw new LedgerError("usage", "mode must be append, check or park");
682
+ throw new LedgerError("usage", "mode must be append, check, evidence or park");
527
683
  }
528
684
 
529
685
  if (invokedDirectly(import.meta.url)) {
@@ -533,7 +689,7 @@ if (invokedDirectly(import.meta.url)) {
533
689
  process.stderr.write(`gate-ledger: ${err.message}\n`);
534
690
  if (err.code === "usage") {
535
691
  process.stderr.write(
536
- "usage: gate-ledger.mjs append|check|park [--gate g] [--verdict v] [--detail json] [--state p] [--repo d] [--sha s] [--json]\n",
692
+ "usage: gate-ledger.mjs append|check|evidence|park [--gate g] [--verdict v] [--detail json] [--state p] [--repo d] [--sha s] [--json]\n",
537
693
  );
538
694
  process.exit(2);
539
695
  }