@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.
- package/CHANGELOG.md +41 -0
- package/README.md +20 -6
- package/README.tr.md +6 -5
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
- package/docs/architecture.md +4 -4
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +2 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/index.mjs +0 -2
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +101 -67
- package/package.json +4 -1
- package/pipeline/agents/code-reviewer.md +1 -0
- package/pipeline/agents/test-gen-analyst.md +98 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
- 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/cross-cli-contract.md +11 -5
- package/pipeline/multi-agent-refs/features/doctor.md +24 -0
- package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/operations.md +19 -2
- 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 +7 -8
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
- package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
- package/pipeline/preferences-template.json +10 -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 +51 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
- package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
- package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
- package/pipeline/schemas/test-gen-results.schema.json +44 -0
- 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/_handle-patterns.mjs +166 -0
- package/pipeline/scripts/_http-post.mjs +141 -0
- package/pipeline/scripts/_stack-routing.mjs +4 -0
- package/pipeline/scripts/_test-gaming.mjs +227 -0
- package/pipeline/scripts/_unified-diff.mjs +104 -0
- package/pipeline/scripts/_worktree-snapshot.mjs +113 -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 +105 -0
- package/pipeline/scripts/evidence-gate.mjs +82 -7
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/handle-diff-audit.mjs +188 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +5 -1
- 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/run-ui-tests.sh +27 -3
- package/pipeline/scripts/test-classify.mjs +296 -0
- package/pipeline/scripts/test-gen-gate.mjs +409 -0
- package/pipeline/scripts/test-gen-state.mjs +313 -0
- package/pipeline/scripts/test-integrity-gate.mjs +76 -11
- package/pipeline/scripts/uninstall.mjs +2 -0
- package/pipeline/scripts/usage-register-beacon.mjs +239 -0
- package/pipeline/scripts/usage-register.mjs +128 -33
- package/pipeline/scripts/usage-report.mjs +43 -33
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +23 -3
- package/pipeline/skills/.skills-index.json +57 -2
- package/pipeline/skills/shared/README.md +10 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
- package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
- package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
- package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
- package/pipeline/skills/skills-index.md +7 -2
- 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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|