@mmerterden/multi-agent-pipeline 20.28.0 → 20.30.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 +19 -0
- package/README.md +19 -6
- package/README.tr.md +5 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -7
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +122 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +189 -77
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/contract/CHANGELOG.md +62 -0
- package/pipeline/contract/README.md +52 -6
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/manifest.json +367 -22
- package/pipeline/contract/types/index.d.ts +531 -4
- 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 +5 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -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/office/NOTICE.md +42 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +5319 -0
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +429 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +33 -3
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +56 -0
- package/pipeline/schemas/prs.schema.json +73 -0
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +60 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +73 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1027 -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-control.mjs +214 -9
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/contract-server.mjs +1178 -48
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -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/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -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 +42 -6
- package/pipeline/scripts/run-results.mjs +373 -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/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/toolkit-tools.mjs +599 -0
- package/pipeline/scripts/uninstall.mjs +4 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/work-items.mjs +304 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +19 -7
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/skills-index.md +4 -1
|
@@ -50,7 +50,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
|
|
|
50
50
|
import { homedir } from "node:os";
|
|
51
51
|
import { dirname, join } from "node:path";
|
|
52
52
|
import { fileURLToPath } from "node:url";
|
|
53
|
-
import {
|
|
53
|
+
import { modelRateLookup, priceUsage as priceRecord } from "./_cost.mjs";
|
|
54
54
|
import { readJsonl } from "./_jsonl.mjs";
|
|
55
55
|
import { runMain } from "../lib/fatal.mjs";
|
|
56
56
|
import { messages, outputLanguage } from "../lib/user-facing.mjs";
|
|
@@ -167,58 +167,7 @@ const asNumber = (v, fallback) => {
|
|
|
167
167
|
|
|
168
168
|
const TABLE = JSON.parse(readFileSync(join(HERE, "cost-table.json"), "utf-8"));
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
* Wire ID -> rate. Transcripts carry the wire value ("claude-opus-5-5"); the
|
|
172
|
-
* price table is keyed by rung, so each rung's `modelId` and `copilotModelId`
|
|
173
|
-
* map to its rate, `legacy` adds previous-generation IDs at their own rates,
|
|
174
|
-
* and `models` overrides the rung rate for a dispatched ID whose model is not
|
|
175
|
-
* the rung's (Copilot CLI's older models bill at their own rate). Matching on
|
|
176
|
-
* the ID rather than the rung name keeps a generation move a data edit, which
|
|
177
|
-
* is the contract cost-table.json states for itself.
|
|
178
|
-
*/
|
|
179
|
-
const RATE_BY_MODEL_ID = new Map([
|
|
180
|
-
...Object.entries(TABLE.legacy || {}),
|
|
181
|
-
...Object.values(TABLE.prices || {}).flatMap((p) =>
|
|
182
|
-
[p.modelId, p.copilotModelId].filter(Boolean).map((id) => [id, p]),
|
|
183
|
-
),
|
|
184
|
-
...Object.entries(TABLE.models || {}),
|
|
185
|
-
]);
|
|
186
|
-
const IDS_LONGEST_FIRST = [...RATE_BY_MODEL_ID.keys()].sort((a, b) => b.length - a.length);
|
|
187
|
-
|
|
188
|
-
function rateFor(modelId) {
|
|
189
|
-
if (!modelId) return null;
|
|
190
|
-
const exact = RATE_BY_MODEL_ID.get(modelId);
|
|
191
|
-
if (exact) return exact;
|
|
192
|
-
// A dated snapshot id (a known id, a hyphen, a release date) prices as that
|
|
193
|
-
// id; the longest known id wins, so a current id is never read as a shorter
|
|
194
|
-
// legacy one. Written as a shape rather than as a literal, because
|
|
195
|
-
// smoke-model-fallback.sh reads every model id in this tree and requires it
|
|
196
|
-
// to exist in cost-table.json. An id nobody knows prices as nothing and is
|
|
197
|
-
// COUNTED as unpriced rather than folded in at zero.
|
|
198
|
-
const id = IDS_LONGEST_FIRST.find(
|
|
199
|
-
(known) => modelId.startsWith(`${known}-`) || modelId.startsWith(`${known}[`),
|
|
200
|
-
);
|
|
201
|
-
return id ? RATE_BY_MODEL_ID.get(id) : null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Cache WRITES are not a discount, they are a surcharge: Anthropic prices a
|
|
206
|
-
* 1h cache write above fresh input. cost-table.json carries cacheReadPerMtok
|
|
207
|
-
* only, so the write rate is derived here at the published 1.25x of input and
|
|
208
|
-
* labelled as derived - an unpriced component silently dropped would make
|
|
209
|
-
* every long-context session look cheap, which is the direction that hurts.
|
|
210
|
-
*/
|
|
211
|
-
const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
212
|
-
|
|
213
|
-
function priceRecord(rate, u) {
|
|
214
|
-
const fresh = Number(u.input_tokens || 0);
|
|
215
|
-
const out = Number(u.output_tokens || 0);
|
|
216
|
-
const cacheRead = Number(u.cache_read_input_tokens || 0);
|
|
217
|
-
const cacheWrite = Number(u.cache_creation_input_tokens || 0);
|
|
218
|
-
const base = costUsd(rate, fresh, out, cacheRead);
|
|
219
|
-
if (base === null) return null;
|
|
220
|
-
return base + (cacheWrite / 1_000_000) * (rate.inPerMtok || 0) * CACHE_WRITE_MULTIPLIER;
|
|
221
|
-
}
|
|
170
|
+
const rateFor = modelRateLookup(TABLE);
|
|
222
171
|
|
|
223
172
|
// --- the series --------------------------------------------------------------
|
|
224
173
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-10-14 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `copilotModelId`, where present, is the name Copilot CLI dispatches for the rung: one of the dotted IDs `copilot help config` lists (Copilot rejects any other as not available), the newest of the rung's family there. A rung without one (fable) is not offered on Copilot CLI, where its personas take the opus rung. smoke-model-fallback.sh checks the IDs against `copilot help config` when the CLI is installed. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site; `free-chain` (the `free` rung) only from bulk-read and delegate, where the multi-agent-toolkit's verified free-model chain answers and the caller's default takes over when it escalates; any other provider from no call site. model-dispatch.sh enforces this. Unknown models render USD as ' - ' and emit a footnote - never block PR-body generation. cacheReadPerMtok is the discounted rate for prompt-cache hits, published per model and well below inPerMtok; the renderer prices a phase's tokens_cached at this rate when the tracker records it, so resume/cache reuse is visible in the ledger.",
|
|
3
3
|
"schemaVersion": "1.1.0",
|
|
4
|
+
"verifiedAt": "2026-10-14",
|
|
4
5
|
"_legacyReadme": "`legacy` prices previous-generation wire IDs a transcript may still name, keyed by the exact ID (a dated snapshot of one prices as it). Dispatch never reads it; cost-analyze consults it after the current `modelId` values and the `models` map. Official Anthropic prices per million tokens.",
|
|
5
6
|
"legacy": {
|
|
6
7
|
"claude-fable-5": { "inPerMtok": 10.0, "outPerMtok": 50.0, "cacheReadPerMtok": 1.0 },
|
|
@@ -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",
|
|
@@ -515,6 +517,57 @@ function checkHookCoverage() {
|
|
|
515
517
|
ok("hook-coverage");
|
|
516
518
|
}
|
|
517
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
|
+
|
|
518
571
|
function credentialInventory() {
|
|
519
572
|
const inv = join(CLAUDE, "lib", "credential-inventory.sh");
|
|
520
573
|
if (!existsSync(inv)) return null;
|
|
@@ -1403,6 +1456,7 @@ function main() {
|
|
|
1403
1456
|
checkPrefsValid();
|
|
1404
1457
|
checkIdentity();
|
|
1405
1458
|
checkHookCoverage();
|
|
1459
|
+
checkHookReachability();
|
|
1406
1460
|
checkCredentials(credentialInventory());
|
|
1407
1461
|
checkStoreReference();
|
|
1408
1462
|
checkEmbeddedCredentials();
|
|
@@ -75,7 +75,7 @@ import { spawnSync } from "node:child_process";
|
|
|
75
75
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
76
76
|
import { fileURLToPath } from "node:url";
|
|
77
77
|
|
|
78
|
-
import { gateContext } from "./gate-ledger.mjs";
|
|
78
|
+
import { ATTENDED_RECORDED, gateContext } from "./gate-ledger.mjs";
|
|
79
79
|
|
|
80
80
|
const LEDGER = fileURLToPath(new URL("./gate-ledger.mjs", import.meta.url));
|
|
81
81
|
|
|
@@ -203,15 +203,19 @@ function parseArgs(argv) {
|
|
|
203
203
|
// the bytes are gone. The verdict is short today; the verdict is also the only
|
|
204
204
|
// thing a caller reads, so losing half of it is the one failure that matters.
|
|
205
205
|
let verdictExtra = {};
|
|
206
|
-
//
|
|
207
|
-
// 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
|
|
208
208
|
// cannot come apart. A ledger problem is reported and never changes the verdict.
|
|
209
209
|
let ledgerClaim = null;
|
|
210
210
|
let ledgerState = null;
|
|
211
211
|
function ledger(verdict, reason, extra) {
|
|
212
212
|
if (!ledgerClaim) return;
|
|
213
213
|
const ctx = gateContext({ statePath: ledgerState });
|
|
214
|
-
|
|
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;
|
|
215
219
|
const detail = { reason, ...(extra.summary ? { summary: extra.summary } : {}) };
|
|
216
220
|
if (verdictExtra.stack) detail.stack = verdictExtra.stack;
|
|
217
221
|
const argv = [LEDGER, "append", "--gate", `evidence-gate/${ledgerClaim}`, "--verdict", verdict];
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* office-build.mjs - assemble the office panel page from pipeline/office-src.
|
|
4
|
+
*
|
|
5
|
+
* The server's CSP allows one nonce'd inline script, one nonce'd inline style
|
|
6
|
+
* and data: images, so the page has to be a single file. The sources stay
|
|
7
|
+
* readable as separate files (shell.html, style.css, world.js, app.js,
|
|
8
|
+
* strings.json) and this script inlines them into
|
|
9
|
+
* pipeline/office/index.html: the list of sprite names in pipeline/office/assets
|
|
10
|
+
* (served by contract-server.mjs at /office/assets/), the strings as a JSON
|
|
11
|
+
* literal, the two code files in order inside one IIFE.
|
|
12
|
+
*
|
|
13
|
+
* node pipeline/scripts/office-build.mjs write the page
|
|
14
|
+
* node pipeline/scripts/office-build.mjs --check exit 1 when the page on
|
|
15
|
+
* disk is not what the
|
|
16
|
+
* sources build to
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { basename, join } from "node:path";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
const ROOT = join(import.meta.dirname, "..");
|
|
24
|
+
const SRC = join(ROOT, "office-src");
|
|
25
|
+
const OUT = join(ROOT, "office", "index.html");
|
|
26
|
+
const ASSETS = join(ROOT, "office", "assets");
|
|
27
|
+
const PNG_SIGNATURE = "89504e470d0a1a0a";
|
|
28
|
+
|
|
29
|
+
// The sprite names the page may load from /office/assets/: every PNG beside
|
|
30
|
+
// the page, checked to be one.
|
|
31
|
+
function atlas() {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const name of readdirSync(ASSETS).sort()) {
|
|
34
|
+
if (!name.endsWith(".png")) continue;
|
|
35
|
+
const buf = readFileSync(join(ASSETS, name));
|
|
36
|
+
if (buf.subarray(0, 8).toString("hex") !== PNG_SIGNATURE) {
|
|
37
|
+
throw new Error(`${name} is not a PNG file`);
|
|
38
|
+
}
|
|
39
|
+
out.push(basename(name, ".png"));
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function build() {
|
|
45
|
+
const read = (f) => readFileSync(join(SRC, f), "utf8");
|
|
46
|
+
const strings = JSON.parse(read("strings.json"));
|
|
47
|
+
const sprites = atlas();
|
|
48
|
+
const script = [
|
|
49
|
+
'"use strict";',
|
|
50
|
+
"(() => {",
|
|
51
|
+
`const ATLAS = ${JSON.stringify(sprites)};`,
|
|
52
|
+
`const STRINGS = ${JSON.stringify(strings)};`,
|
|
53
|
+
'const spriteUrl = (name) => "/office/assets/" + name + ".png";',
|
|
54
|
+
read("world.js").trim(),
|
|
55
|
+
read("app.js").trim(),
|
|
56
|
+
"})();",
|
|
57
|
+
].join("\n");
|
|
58
|
+
const style = read("style.css").trim();
|
|
59
|
+
// Text that would close the inline element early, or open an HTML comment
|
|
60
|
+
// inside it, turns source into markup.
|
|
61
|
+
if (/<\/script|<!--/i.test(script)) throw new Error("the script contains </script or <!--");
|
|
62
|
+
if (/<\/style/i.test(style)) throw new Error("style.css contains </style");
|
|
63
|
+
const shell = read("shell.html");
|
|
64
|
+
for (const marker of ["/*STYLE*/", "/*SCRIPT*/"]) {
|
|
65
|
+
if (shell.split(marker).length !== 2) throw new Error(`shell.html needs exactly one ${marker}`);
|
|
66
|
+
}
|
|
67
|
+
return shell.replace("/*STYLE*/", () => style).replace("/*SCRIPT*/", () => script);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
71
|
+
if (isMain) {
|
|
72
|
+
const html = build();
|
|
73
|
+
if (process.argv.includes("--check")) {
|
|
74
|
+
let current = "";
|
|
75
|
+
try {
|
|
76
|
+
current = readFileSync(OUT, "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
// A missing page is stale by definition.
|
|
79
|
+
}
|
|
80
|
+
if (current !== html) {
|
|
81
|
+
console.error("office/index.html is stale: run node pipeline/scripts/office-build.mjs");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
console.log("office/index.html matches its sources");
|
|
85
|
+
} else {
|
|
86
|
+
writeFileSync(OUT, html);
|
|
87
|
+
console.log(`wrote ${OUT} (${Buffer.byteLength(html)} bytes)`);
|
|
88
|
+
}
|
|
89
|
+
}
|