@mmerterden/multi-agent-pipeline 17.5.1 → 18.0.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 +276 -0
- package/README.md +59 -1
- package/README.tr.md +57 -0
- package/docs/adr/0011-dormant-ci.md +25 -1
- package/docs/features.md +24 -0
- package/docs/server-readiness.md +188 -0
- package/docs/token-budget-history.md +1 -1
- package/index.js +16 -1
- package/install/_common.mjs +42 -17
- package/install/_dev-only-files.mjs +8 -0
- package/install/_unattended-profile.mjs +113 -0
- package/install/index.mjs +48 -0
- package/install/templates/claude-hooks.json +13 -1
- package/manifest.json +1049 -0
- package/package.json +5 -2
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/feedback/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/graph/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/issue/SKILL.md +13 -1
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +16 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +14 -16
- package/pipeline/commands/multi-agent/status/SKILL.md +52 -21
- package/pipeline/commands/multi-agent/update/SKILL.md +13 -56
- package/pipeline/lib/_jira-auth.sh +8 -0
- package/pipeline/lib/analysis-jira-write.sh +32 -0
- package/pipeline/lib/ask-choice.sh +13 -2
- package/pipeline/lib/autopilot-state.sh +8 -0
- package/pipeline/lib/fatal.mjs +129 -0
- package/pipeline/lib/figma-mcp-refresh.sh +18 -0
- package/pipeline/lib/figma-screenshot.sh +18 -0
- package/pipeline/lib/invoked-directly.mjs +43 -0
- package/pipeline/lib/jira-publish.sh +42 -0
- package/pipeline/lib/md2confluence-v3.py +47 -0
- package/pipeline/lib/outbound-gate.mjs +175 -0
- package/pipeline/lib/plan-todos.sh +27 -6
- package/pipeline/lib/post-pr-review.sh +77 -8
- package/pipeline/lib/repo-hygiene.sh +8 -3
- package/pipeline/lib/require-jq.sh +40 -0
- package/pipeline/lib/run-paths.sh +335 -0
- package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +70 -0
- package/pipeline/multi-agent-refs/features/code-graph.md +20 -0
- package/pipeline/multi-agent-refs/features/cost-analysis.md +93 -0
- package/pipeline/multi-agent-refs/features/doctor.md +68 -0
- package/pipeline/multi-agent-refs/features/maturity-followup.md +166 -0
- package/pipeline/multi-agent-refs/features/package-manager.md +80 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +79 -0
- package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
- package/pipeline/multi-agent-refs/features/verify.md +83 -0
- package/pipeline/multi-agent-refs/phases/operations.md +13 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -3
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +8 -2
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/unattended-contract.md +129 -0
- package/pipeline/preferences-template.json +1 -1
- package/pipeline/schemas/agent-state.schema.json +122 -11
- package/pipeline/schemas/prefs.schema.json +35 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/_run-paths.mjs +372 -0
- package/pipeline/scripts/aggregate-metrics.mjs +64 -64
- package/pipeline/scripts/autopilot-arming.mjs +2 -1
- package/pipeline/scripts/autopilot-intake.mjs +2 -1
- package/pipeline/scripts/autopilot-runner.mjs +206 -2
- package/pipeline/scripts/build-references.mjs +2 -1
- package/pipeline/scripts/build-stack-plugins.mjs +10 -2
- package/pipeline/scripts/capture-evidence.sh +7 -2
- package/pipeline/scripts/classify-plan-safety.mjs +2 -1
- package/pipeline/scripts/cost-analyze.mjs +600 -0
- package/pipeline/scripts/cost-budget-check.mjs +4 -12
- package/pipeline/scripts/council-view.mjs +2 -1
- package/pipeline/scripts/crush-json.mjs +2 -1
- package/pipeline/scripts/diff-explain.mjs +6 -9
- package/pipeline/scripts/diff-risk-score.mjs +2 -1
- package/pipeline/scripts/doctor.mjs +203 -4
- package/pipeline/scripts/evidence-gate.mjs +9 -3
- package/pipeline/scripts/feedback-send.mjs +13 -3
- package/pipeline/scripts/gc-abandoned.sh +29 -13
- package/pipeline/scripts/gc-worktrees.sh +11 -4
- package/pipeline/scripts/github-ssh-setup.sh +64 -7
- package/pipeline/scripts/graph-mermaid.mjs +4 -2
- package/pipeline/scripts/graph-report.mjs +155 -1
- package/pipeline/scripts/keychain-save.sh +101 -30
- package/pipeline/scripts/learn-from-transcripts.mjs +2 -1
- package/pipeline/scripts/learning-curve.mjs +34 -29
- package/pipeline/scripts/make-manifest.mjs +199 -0
- package/pipeline/scripts/maturity-followup.mjs +294 -0
- package/pipeline/scripts/migrate-prefs.mjs +2 -1
- package/pipeline/scripts/migrate-state.mjs +94 -4
- package/pipeline/scripts/package-manager.mjs +310 -0
- package/pipeline/scripts/phase-banner.sh +6 -2
- package/pipeline/scripts/phase-tracker.sh +41 -3
- package/pipeline/scripts/plan-coverage-gate.mjs +6 -2
- package/pipeline/scripts/pre-commit-check.sh +7 -0
- package/pipeline/scripts/pre-push-check.sh +7 -0
- package/pipeline/scripts/purge.sh +23 -6
- package/pipeline/scripts/render-agent-log-cost.sh +9 -2
- package/pipeline/scripts/render-cost-summary.sh +9 -2
- package/pipeline/scripts/render-work-summary.sh +11 -4
- package/pipeline/scripts/review-file-filter.mjs +4 -2
- package/pipeline/scripts/review-scope.mjs +2 -1
- package/pipeline/scripts/routine-registry.mjs +2 -1
- package/pipeline/scripts/run-aggregator.mjs +13 -14
- package/pipeline/scripts/run-metrics.mjs +3 -1
- package/pipeline/scripts/runs-index.mjs +343 -0
- package/pipeline/scripts/scorecard-snapshot.mjs +178 -0
- package/pipeline/scripts/search-logs.sh +18 -0
- package/pipeline/scripts/test-gap-scan.mjs +2 -1
- package/pipeline/scripts/test-integrity-gate.mjs +2 -1
- package/pipeline/scripts/update-issue-progress.sh +56 -7
- package/pipeline/scripts/usage-register.mjs +271 -0
- package/pipeline/scripts/usage-report.mjs +14 -3
- package/pipeline/scripts/validate-analysis-doc.mjs +2 -1
- package/pipeline/scripts/validate-code-graph.mjs +6 -3
- package/pipeline/scripts/validate-complaint-doc.mjs +2 -1
- package/pipeline/scripts/validate-diff-risk.mjs +6 -3
- package/pipeline/scripts/validate-test-gap.mjs +6 -3
- package/pipeline/scripts/validate-triage.mjs +3 -1
- package/pipeline/scripts/verify-citations.mjs +4 -2
- package/pipeline/scripts/verify.mjs +327 -0
- package/pipeline/scripts/worktree-finalize.sh +13 -4
- package/pipeline/scripts/write-state.mjs +154 -15
- package/pipeline/skills/.skill-manifest.json +6 -6
- package/pipeline/skills/.skills-index.json +56 -1
- package/pipeline/skills/shared/README.md +8 -3
- package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +14 -0
- package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +14 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
- package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +33 -9
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +6 -0
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/package_app.sh +4 -1
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +4 -1
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +2 -1
- package/pipeline/skills/skills-index.md +6 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify.mjs - "is this install the thing that was published".
|
|
4
|
+
*
|
|
5
|
+
* Two questions, and they fail in different directions.
|
|
6
|
+
*
|
|
7
|
+
* 1. PACKAGE. Every file the package shipped, against the SHA-256 recorded in
|
|
8
|
+
* manifest.json at publish time. A file that differs was edited after
|
|
9
|
+
* install or arrived damaged; a file that is missing was never written.
|
|
10
|
+
* 2. INSTALL. ~/.claude holds a COPY of the package's pipeline tree, made by
|
|
11
|
+
* install.js. From the moment it is written the two drift independently,
|
|
12
|
+
* and both directions are real: an edit in ~/.claude is a change with no
|
|
13
|
+
* source, and a file the installer skipped is a script the docs describe
|
|
14
|
+
* and nobody has.
|
|
15
|
+
*
|
|
16
|
+
* Only the Claude tree is compared byte for byte. The Copilot and Codex trees
|
|
17
|
+
* are path-rewritten by the installer on purpose, so a byte difference there is
|
|
18
|
+
* the design rather than the bug, and they are reported as counts.
|
|
19
|
+
*
|
|
20
|
+
* What a green result means, stated plainly because the opposite is easy to
|
|
21
|
+
* imply: it means the bytes match what the publisher recorded. It is not proof
|
|
22
|
+
* of who published them. The manifest, the signature and this verifier all
|
|
23
|
+
* travel inside the same tarball, so anyone able to rewrite one can rewrite the
|
|
24
|
+
* others - provenance is npm's integrity field, not ours. This catches damage,
|
|
25
|
+
* partial installs and post-install edits, which are the failures that actually
|
|
26
|
+
* happen.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* verify.mjs both questions, human output
|
|
30
|
+
* verify.mjs --package package integrity only
|
|
31
|
+
* verify.mjs --install install drift only
|
|
32
|
+
* verify.mjs --json
|
|
33
|
+
*
|
|
34
|
+
* Exit codes:
|
|
35
|
+
* 0 everything matches
|
|
36
|
+
* 1 a difference was found
|
|
37
|
+
* 2 nothing to verify (no manifest - a dev checkout, or a pre-manifest version)
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
41
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
42
|
+
import { homedir } from "node:os";
|
|
43
|
+
import { dirname, join, relative } from "node:path";
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
45
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
46
|
+
|
|
47
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
48
|
+
const MANIFEST = join(ROOT, "manifest.json");
|
|
49
|
+
|
|
50
|
+
const argv = process.argv.slice(2);
|
|
51
|
+
const has = (f) => argv.includes(f);
|
|
52
|
+
const json = has("--json");
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The ed25519 public key a signature is checked against. It comes from the
|
|
56
|
+
* environment and nothing is pinned in this file yet - so on a stock install a
|
|
57
|
+
* signed manifest reports "signed, no public key to check it against" rather
|
|
58
|
+
* than claiming valid. That is the honest state: a signature nobody can check
|
|
59
|
+
* is not a signature that passed, and hard-coding a key here before there is a
|
|
60
|
+
* release key to hard-code would be the opposite.
|
|
61
|
+
*/
|
|
62
|
+
const PUBLIC_KEY = process.env.MULTI_AGENT_SIGNING_PUBKEY || "";
|
|
63
|
+
|
|
64
|
+
const sha256 = (p) => createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Walk a tree, following symlinks but never twice.
|
|
68
|
+
*
|
|
69
|
+
* Following them is required rather than optional: `install --link` puts a
|
|
70
|
+
* symlink at ~/.claude/scripts, and a walker that skipped symlinks would report
|
|
71
|
+
* a linked install as entirely missing. The cost is that a link pointing at an
|
|
72
|
+
* ancestor turns the walk into a loop, so every directory is recorded by its
|
|
73
|
+
* RESOLVED path and visited once.
|
|
74
|
+
*/
|
|
75
|
+
function walk(dir, base = dir, out = [], seen = new Set()) {
|
|
76
|
+
let real;
|
|
77
|
+
try {
|
|
78
|
+
real = realpathSync(dir);
|
|
79
|
+
} catch {
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
if (seen.has(real)) return out;
|
|
83
|
+
seen.add(real);
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = readdirSync(dir);
|
|
87
|
+
} catch {
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
for (const name of entries) {
|
|
91
|
+
if (name === ".DS_Store" || name === "__pycache__") continue;
|
|
92
|
+
const abs = join(dir, name);
|
|
93
|
+
let st;
|
|
94
|
+
try {
|
|
95
|
+
st = statSync(abs);
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (st.isDirectory()) walk(abs, base, out, seen);
|
|
100
|
+
else out.push(relative(base, abs));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- 1. package integrity ----------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function verifyPackage() {
|
|
108
|
+
const manifest = JSON.parse(readFileSync(MANIFEST, "utf-8"));
|
|
109
|
+
const changed = [];
|
|
110
|
+
const missing = [];
|
|
111
|
+
for (const [rel, want] of Object.entries(manifest.files)) {
|
|
112
|
+
const abs = join(ROOT, rel);
|
|
113
|
+
if (!existsSync(abs)) {
|
|
114
|
+
missing.push(rel);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (sha256(abs) !== want) changed.push(rel);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let signature = "unsigned";
|
|
121
|
+
const sigPath = join(ROOT, "manifest.sig");
|
|
122
|
+
if (existsSync(sigPath)) {
|
|
123
|
+
if (!PUBLIC_KEY) signature = "signed, no public key to check it against";
|
|
124
|
+
else {
|
|
125
|
+
try {
|
|
126
|
+
const ok = cryptoVerify(
|
|
127
|
+
null,
|
|
128
|
+
Buffer.from(readFileSync(MANIFEST)),
|
|
129
|
+
createPublicKey(PUBLIC_KEY),
|
|
130
|
+
Buffer.from(readFileSync(sigPath, "utf-8").trim(), "base64"),
|
|
131
|
+
);
|
|
132
|
+
signature = ok ? "valid" : "INVALID";
|
|
133
|
+
} catch (err) {
|
|
134
|
+
signature = `unverifiable: ${err?.message ?? err}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
version: manifest.version,
|
|
141
|
+
commit: manifest.commit,
|
|
142
|
+
fileCount: manifest.fileCount,
|
|
143
|
+
changed,
|
|
144
|
+
missing,
|
|
145
|
+
signature,
|
|
146
|
+
ok: changed.length === 0 && missing.length === 0 && signature !== "INVALID",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- 2. install drift --------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The trees install.js writes into ~/.claude, and how each one may be compared.
|
|
154
|
+
* Kept as data rather than asked of the installer, because the installer's job
|
|
155
|
+
* is to WRITE them and a verifier that asks the writer what it wrote proves
|
|
156
|
+
* nothing.
|
|
157
|
+
*
|
|
158
|
+
* `bytes` means the copy is verbatim and a difference is drift. `presence` is
|
|
159
|
+
* for commands/, where install.js rewrites each SKILL.md's `description` into
|
|
160
|
+
* the user's outputLanguage - every file differs there BY DESIGN, so comparing
|
|
161
|
+
* bytes would report 57 findings on a healthy machine and teach everyone to
|
|
162
|
+
* ignore the whole report.
|
|
163
|
+
*/
|
|
164
|
+
const CLAUDE_TREES = [
|
|
165
|
+
["scripts", "scripts", "bytes"],
|
|
166
|
+
["lib", "lib", "bytes"],
|
|
167
|
+
["multi-agent-refs", "multi-agent-refs", "bytes"],
|
|
168
|
+
["agents", "agents", "bytes"],
|
|
169
|
+
["commands/multi-agent", "commands/multi-agent", "presence"],
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Files the package carries but the installer deliberately does not write:
|
|
174
|
+
* smokes, linters, fixtures and the rest of the maintainer surface. Without
|
|
175
|
+
* this filter every one of them reads as "the installer skipped a file", which
|
|
176
|
+
* is 251 false findings on a correct install.
|
|
177
|
+
*/
|
|
178
|
+
async function devOnly() {
|
|
179
|
+
try {
|
|
180
|
+
const mod = await import(new URL("../../install/_dev-only-files.mjs", import.meta.url).href);
|
|
181
|
+
return mod.isDevOnlyScript;
|
|
182
|
+
} catch {
|
|
183
|
+
// No filter available: say so by filtering nothing, and let the counts be
|
|
184
|
+
// wrong loudly rather than silently.
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function verifyInstall() {
|
|
190
|
+
const home = homedir();
|
|
191
|
+
const claude = join(home, ".claude");
|
|
192
|
+
if (!existsSync(claude)) {
|
|
193
|
+
return { present: false, reason: "no ~/.claude on this machine", ok: true, trees: [] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const isDevOnly = await devOnly();
|
|
197
|
+
const trees = [];
|
|
198
|
+
let differing = 0;
|
|
199
|
+
let absent = 0;
|
|
200
|
+
for (const [srcRel, dstRel, mode] of CLAUDE_TREES) {
|
|
201
|
+
const src = join(ROOT, "pipeline", srcRel);
|
|
202
|
+
const dst = join(claude, dstRel);
|
|
203
|
+
if (!existsSync(src)) continue;
|
|
204
|
+
const srcFiles = walk(src).filter(
|
|
205
|
+
(rel) => !(srcRel === "scripts" && isDevOnly && isDevOnly(rel)),
|
|
206
|
+
);
|
|
207
|
+
const changed = [];
|
|
208
|
+
const missing = [];
|
|
209
|
+
for (const rel of srcFiles) {
|
|
210
|
+
const b = join(dst, rel);
|
|
211
|
+
if (!existsSync(b)) {
|
|
212
|
+
missing.push(rel);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (mode === "bytes" && sha256(join(src, rel)) !== sha256(b)) changed.push(rel);
|
|
216
|
+
}
|
|
217
|
+
differing += changed.length;
|
|
218
|
+
absent += missing.length;
|
|
219
|
+
trees.push({ tree: dstRel, mode, files: srcFiles.length, changed, missing });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const others = [".copilot", ".codex"].filter((h) => existsSync(join(home, h)));
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
present: true,
|
|
226
|
+
trees,
|
|
227
|
+
differing,
|
|
228
|
+
absent,
|
|
229
|
+
otherHosts: others,
|
|
230
|
+
ok: differing === 0 && absent === 0,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- output ------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
async function main() {
|
|
237
|
+
const wantPackage = has("--package") || !has("--install");
|
|
238
|
+
const wantInstall = has("--install") || !has("--package");
|
|
239
|
+
|
|
240
|
+
if (wantPackage && !existsSync(MANIFEST)) {
|
|
241
|
+
const msg =
|
|
242
|
+
"no manifest.json - this is a source checkout or a version published before manifests existed";
|
|
243
|
+
if (json)
|
|
244
|
+
process.stdout.write(`${JSON.stringify({ kind: "UNVERIFIABLE", reason: msg }, null, 2)}\n`);
|
|
245
|
+
else process.stdout.write(`UNVERIFIABLE: ${msg}\n`);
|
|
246
|
+
process.exitCode = 2;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const pkg = wantPackage ? verifyPackage() : null;
|
|
251
|
+
const inst = wantInstall ? await verifyInstall() : null;
|
|
252
|
+
|
|
253
|
+
if (json) {
|
|
254
|
+
process.stdout.write(`${JSON.stringify({ package: pkg, install: inst }, null, 2)}\n`);
|
|
255
|
+
} else {
|
|
256
|
+
if (pkg) {
|
|
257
|
+
process.stdout.write(
|
|
258
|
+
`paket: ${pkg.version} (${pkg.commit?.slice(0, 12) ?? "commit bilinmiyor"}) - ${pkg.fileCount} dosya, imza: ${pkg.signature}\n`,
|
|
259
|
+
);
|
|
260
|
+
if (!pkg.changed.length && !pkg.missing.length)
|
|
261
|
+
process.stdout.write(" butun dosyalar yayinlandigi gibi\n");
|
|
262
|
+
const SHOWN = 20;
|
|
263
|
+
for (const f of pkg.changed.slice(0, SHOWN)) process.stdout.write(` ! degismis: ${f}\n`);
|
|
264
|
+
for (const f of pkg.missing.slice(0, SHOWN)) process.stdout.write(` ! eksik: ${f}\n`);
|
|
265
|
+
// Counted per list against what was actually PRINTED. Subtracting a flat
|
|
266
|
+
// 40 from the combined total spent a budget the lists had not used: with
|
|
267
|
+
// 25 changed and 0 missing it printed 20, hid 5, and said nothing - a
|
|
268
|
+
// tampered install under-reported by up to twenty files with no sign.
|
|
269
|
+
const hidden =
|
|
270
|
+
Math.max(0, pkg.changed.length - SHOWN) + Math.max(0, pkg.missing.length - SHOWN);
|
|
271
|
+
if (hidden > 0) process.stdout.write(` ... ve ${hidden} dosya daha\n`);
|
|
272
|
+
}
|
|
273
|
+
if (inst) {
|
|
274
|
+
if (!inst.present) process.stdout.write(`kurulum: ${inst.reason}\n`);
|
|
275
|
+
else {
|
|
276
|
+
const total = inst.trees.reduce((a, t) => a + t.files, 0);
|
|
277
|
+
process.stdout.write(
|
|
278
|
+
`kurulum: ~/.claude - ${inst.trees.length} agac, ${total} dosya karsilastirildi\n`,
|
|
279
|
+
);
|
|
280
|
+
for (const t of inst.trees) {
|
|
281
|
+
if (!t.changed.length && !t.missing.length) continue;
|
|
282
|
+
const how =
|
|
283
|
+
t.mode === "presence" ? " (yalnizca varlik: aciklamalar kurulumda cevriliyor)" : "";
|
|
284
|
+
process.stdout.write(
|
|
285
|
+
` ${t.tree}: ${t.changed.length} farkli, ${t.missing.length} eksik${how}\n`,
|
|
286
|
+
);
|
|
287
|
+
for (const f of [...t.changed.slice(0, 5)]) process.stdout.write(` ! ${f}\n`);
|
|
288
|
+
for (const f of [...t.missing.slice(0, 5)])
|
|
289
|
+
process.stdout.write(` ? ${f} (hic yazilmamis)\n`);
|
|
290
|
+
}
|
|
291
|
+
if (inst.ok) process.stdout.write(" kaynakla birebir ayni\n");
|
|
292
|
+
if (inst.otherHosts.length)
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
` ${inst.otherHosts.join(", ")} kurulu - yollari kurulumda yeniden yazildigi icin bayt karsilastirmasi yapilmadi\n`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const bad = (pkg && !pkg.ok) || (inst && !inst.ok);
|
|
301
|
+
process.exitCode = bad ? 1 : 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Importable AND runnable: index.js calls main() for `multi-agent-pipeline
|
|
305
|
+
// verify`, and the direct-run guard keeps a bare `node verify.mjs` behaving
|
|
306
|
+
// the same way every other script here does.
|
|
307
|
+
export { main };
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* realpath, not a string compare against argv[1]. Two ordinary situations break
|
|
311
|
+
* the naive form: macOS hands out /var/folders/... paths that resolve to
|
|
312
|
+
* /private/var/folders/..., and `install --link` puts a SYMLINK at
|
|
313
|
+
* ~/.claude/scripts/verify.mjs - in both cases import.meta.url is the resolved
|
|
314
|
+
* path, argv[1] is not, and the script silently does nothing at all.
|
|
315
|
+
*/
|
|
316
|
+
function invokedDirectly() {
|
|
317
|
+
if (!process.argv[1]) return false;
|
|
318
|
+
try {
|
|
319
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (invokedDirectly()) {
|
|
326
|
+
runMain("verify", main);
|
|
327
|
+
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
# 0 removed (or dry-run that would remove)
|
|
29
29
|
# 3 SKIPPED by a safety precondition - not an error, reason is reported
|
|
30
30
|
# 1 usage / hard error
|
|
31
|
-
set -
|
|
31
|
+
set -euo pipefail
|
|
32
32
|
|
|
33
33
|
WORKTREE=""; PROJECT_ROOT=""; TASK_ID=""; PROJECT=""; BRANCH=""
|
|
34
34
|
LOGS_ROOT="${HOME}/.claude/logs/multi-agent"
|
|
@@ -97,6 +97,10 @@ skip() { REASON="$1"; emit; exit 3; }
|
|
|
97
97
|
# preference at all, because turning it off appears to work and does nothing.
|
|
98
98
|
PREFS="${PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
|
|
99
99
|
if [ -f "$PREFS" ] && command -v node >/dev/null 2>&1; then
|
|
100
|
+
# `|| echo true` on the far side already covers a node that throws: an
|
|
101
|
+
# unreadable preference file reads as "not set", which is the default. Noted
|
|
102
|
+
# here because it is the one assignment in this file that was ALREADY safe
|
|
103
|
+
# under `set -e`, and the next reader should not have to re-derive that.
|
|
100
104
|
enabled="$(node -e '
|
|
101
105
|
const fs=require("fs");
|
|
102
106
|
try {
|
|
@@ -173,6 +177,9 @@ fi
|
|
|
173
177
|
# alternatives are end-anchored - unanchored, `.build.log.old` was forgiven here
|
|
174
178
|
# but not deletable below, which made every removal refuse forever.
|
|
175
179
|
ARTIFACTS_RE='^(agent-state\.json|phase-tracker\.json|triage-output\.json|\.review-diff\.txt|\.build\.log|\.test\.log)$|^\.pipeline/'
|
|
180
|
+
# Forgiven at the end of the pipeline (see below): a worktree whose git dir is
|
|
181
|
+
# already gone reports nothing, and "nothing dirty" is the correct reading of
|
|
182
|
+
# that - aborting would strand the removal this function exists to perform.
|
|
176
183
|
dirty="$(git -C "$rp_wt" status --porcelain 2>/dev/null | awk '
|
|
177
184
|
{
|
|
178
185
|
st = substr($0, 1, 2); path = substr($0, 4);
|
|
@@ -183,7 +190,7 @@ dirty="$(git -C "$rp_wt" status --porcelain 2>/dev/null | awk '
|
|
|
183
190
|
continue # our own untracked artefact
|
|
184
191
|
fi
|
|
185
192
|
printf '%s\n' "$path"
|
|
186
|
-
done)"
|
|
193
|
+
done || true)"
|
|
187
194
|
if [ -n "$dirty" ]; then
|
|
188
195
|
first="$(printf '%s\n' "$dirty" | head -1)"
|
|
189
196
|
skip "worktree has uncommitted changes (e.g. $first) - refusing to remove"
|
|
@@ -219,7 +226,9 @@ copy_one() {
|
|
|
219
226
|
SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
|
|
220
227
|
return 0
|
|
221
228
|
fi
|
|
222
|
-
|
|
229
|
+
# Forgiven: this function already tolerates a copy that does not happen, and
|
|
230
|
+
# aborting here would skip the worktree removal that follows.
|
|
231
|
+
mkdir -p "$DEST/$(dirname "$1")" 2>/dev/null || true
|
|
223
232
|
[ -d "$DEST/$1" ] && rm -rf "${DEST:?}/$1"
|
|
224
233
|
cp -R "$src" "$DEST/$1" 2>/dev/null && SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
|
|
225
234
|
}
|
|
@@ -240,7 +249,7 @@ copy_from_logs() {
|
|
|
240
249
|
SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
|
|
241
250
|
return 0
|
|
242
251
|
fi
|
|
243
|
-
mkdir -p "$DEST/$(dirname "$1")" 2>/dev/null
|
|
252
|
+
mkdir -p "$DEST/$(dirname "$1")" 2>/dev/null || true
|
|
244
253
|
cp -R "$src" "$DEST/$1" 2>/dev/null && SALVAGED="${SALVAGED:+$SALVAGED,}\"$1\""
|
|
245
254
|
}
|
|
246
255
|
|
|
@@ -16,12 +16,22 @@
|
|
|
16
16
|
*
|
|
17
17
|
* # Replace: full overwrite from stdin JSON
|
|
18
18
|
* cat new-state.json | node write-state.mjs --replace /path/to/agent-state.json
|
|
19
|
+
* cat patch.json | node write-state.mjs --if-rev=7 /path/to/agent-state.json
|
|
20
|
+
*
|
|
21
|
+
* `--if-rev=<n>` is compare-and-swap: write only if the record on disk is
|
|
22
|
+
* still at revision n. The lock closes the window between two WRITERS; it does
|
|
23
|
+
* nothing about the one between a read and a write, which is where an editor
|
|
24
|
+
* lives - `/multi-agent:steer` reads the state, a human thinks, and the run may
|
|
25
|
+
* move three phases before the edit lands. Without this the edit merges over
|
|
26
|
+
* the top and reinstates decisions the run already replaced.
|
|
19
27
|
*
|
|
20
28
|
* Exit codes:
|
|
21
29
|
* 0 - write succeeded
|
|
22
30
|
* 1 - invalid JSON on stdin
|
|
23
31
|
* 2 - lock timeout (another writer held the lock past the acquire window)
|
|
32
|
+
* 4 - the lock was taken from us mid-write; nothing was written, retry
|
|
24
33
|
* 3 - I/O error (disk full, permission denied, parent dir missing)
|
|
34
|
+
* 5 - --if-rev did not match; the record moved, nothing was written
|
|
25
35
|
*
|
|
26
36
|
* Tunables (env):
|
|
27
37
|
* WRITE_STATE_LOCK_TIMEOUT_MS acquire window before exit 2 (default 15000)
|
|
@@ -52,9 +62,11 @@ import {
|
|
|
52
62
|
} from "fs";
|
|
53
63
|
import { dirname, basename } from "path";
|
|
54
64
|
import { Buffer } from "node:buffer";
|
|
65
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
66
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
55
67
|
|
|
56
68
|
/**
|
|
57
|
-
* @typedef {{ replace: boolean, path: string }} ParsedArgs
|
|
69
|
+
* @typedef {{ replace: boolean, path: string, ifRev: number|null }} ParsedArgs
|
|
58
70
|
*/
|
|
59
71
|
|
|
60
72
|
/** @returns {ParsedArgs} */
|
|
@@ -62,18 +74,26 @@ function parseArgs() {
|
|
|
62
74
|
const args = process.argv.slice(2);
|
|
63
75
|
let replace = false;
|
|
64
76
|
let path = null;
|
|
77
|
+
let ifRev = null;
|
|
65
78
|
for (const a of args) {
|
|
66
79
|
if (a === "--replace") replace = true;
|
|
67
|
-
else if (a.startsWith("--")) {
|
|
80
|
+
else if (a.startsWith("--if-rev=")) {
|
|
81
|
+
const v = Number(a.slice("--if-rev=".length));
|
|
82
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
83
|
+
console.error(`write-state: --if-rev takes a non-negative integer, got ${a.slice(9)}`);
|
|
84
|
+
process.exit(3);
|
|
85
|
+
}
|
|
86
|
+
ifRev = v;
|
|
87
|
+
} else if (a.startsWith("--")) {
|
|
68
88
|
console.error(`write-state: unknown flag ${a}`);
|
|
69
89
|
process.exit(3);
|
|
70
90
|
} else path = a;
|
|
71
91
|
}
|
|
72
92
|
if (!path) {
|
|
73
|
-
console.error("usage: write-state.mjs [--replace] <path>");
|
|
93
|
+
console.error("usage: write-state.mjs [--replace] [--if-rev=<n>] <path>");
|
|
74
94
|
process.exit(3);
|
|
75
95
|
}
|
|
76
|
-
return { replace, path };
|
|
96
|
+
return { replace, path, ifRev };
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
/**
|
|
@@ -103,8 +123,13 @@ async function acquireLock(
|
|
|
103
123
|
try {
|
|
104
124
|
writeFileSync(stagePath, String(process.pid));
|
|
105
125
|
linkSync(stagePath, lockPath); // fails EEXIST if another writer holds it
|
|
126
|
+
// The inode of the lock WE created. Everything after this point checks
|
|
127
|
+
// against it: a lock at the same path with a different inode is somebody
|
|
128
|
+
// else's, and both releasing it and writing under it are the same bug the
|
|
129
|
+
// reclaim path below already refuses to commit.
|
|
130
|
+
const ino = statSync(lockPath).ino;
|
|
106
131
|
unlinkSync(stagePath);
|
|
107
|
-
return;
|
|
132
|
+
return ino;
|
|
108
133
|
} catch (err) {
|
|
109
134
|
try {
|
|
110
135
|
unlinkSync(stagePath);
|
|
@@ -208,16 +233,46 @@ function lockIdentityIfStale(lockPath, staleMs) {
|
|
|
208
233
|
// Path whose lock this process currently holds (null when none). Lets the
|
|
209
234
|
// top-level error handler release our lock on unexpected failures.
|
|
210
235
|
let heldLockFor = null;
|
|
236
|
+
// The inode of that lock. The path alone does not identify it: a lock deleted
|
|
237
|
+
// and re-created at the same path is a different file and a different owner.
|
|
238
|
+
let heldLockIno = null;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* True when the lock at `path` is still the one this process created.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} path
|
|
244
|
+
* @param {number | null} ino
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
function stillOurs(path, ino) {
|
|
248
|
+
if (ino === null) return true; // nothing was ever locked; nothing to lose
|
|
249
|
+
try {
|
|
250
|
+
return statSync(`${path}.lock`).ino === ino;
|
|
251
|
+
} catch {
|
|
252
|
+
return false; // our lock is gone, which means somebody removed it
|
|
253
|
+
}
|
|
254
|
+
}
|
|
211
255
|
|
|
212
256
|
/** @param {string} path */
|
|
213
|
-
function releaseLock(path) {
|
|
257
|
+
function releaseLock(path, ino = heldLockIno) {
|
|
214
258
|
const lockPath = `${path}.lock`;
|
|
215
259
|
try {
|
|
260
|
+
// By identity, never by path - the same rule acquireLock states and this
|
|
261
|
+
// function used to break. If our lock was reclaimed as stale while we were
|
|
262
|
+
// starved (the abandon ceiling, or the unreadable-PID window), the lock now
|
|
263
|
+
// at this path belongs to a LIVE writer, and unlinking it puts two writers
|
|
264
|
+
// inside at once and loses one update. That is the third point in this
|
|
265
|
+
// cycle where "delete by path" produces the same data loss; the other two
|
|
266
|
+
// are documented in acquireLock.
|
|
267
|
+
if (ino !== null && statSync(lockPath).ino !== ino) return;
|
|
216
268
|
unlinkSync(lockPath);
|
|
217
269
|
} catch {
|
|
218
270
|
/* already gone - fine */
|
|
219
271
|
}
|
|
220
|
-
if (heldLockFor === path)
|
|
272
|
+
if (heldLockFor === path) {
|
|
273
|
+
heldLockFor = null;
|
|
274
|
+
heldLockIno = null;
|
|
275
|
+
}
|
|
221
276
|
}
|
|
222
277
|
|
|
223
278
|
/**
|
|
@@ -225,6 +280,22 @@ function releaseLock(path) {
|
|
|
225
280
|
* @param {object} patch
|
|
226
281
|
* @returns {object}
|
|
227
282
|
*/
|
|
283
|
+
/**
|
|
284
|
+
* The version a NEWLY created state document is stamped with, read from the
|
|
285
|
+
* schema so the writer and the schema cannot disagree. The literal is a
|
|
286
|
+
* fallback for an unreadable schema, which validate-schemas.mjs would already
|
|
287
|
+
* have failed on.
|
|
288
|
+
*/
|
|
289
|
+
const STATE_SCHEMA_VERSION = (() => {
|
|
290
|
+
try {
|
|
291
|
+
const schemaPath = new URL("../schemas/agent-state.schema.json", import.meta.url);
|
|
292
|
+
const sv = JSON.parse(readFileSync(schemaPath, "utf-8"))?.properties?.schemaVersion;
|
|
293
|
+
return sv?.const ?? sv?.enum?.at(-1) ?? "2.1.0";
|
|
294
|
+
} catch {
|
|
295
|
+
return "2.1.0";
|
|
296
|
+
}
|
|
297
|
+
})();
|
|
298
|
+
|
|
228
299
|
function deepMerge(base, patch) {
|
|
229
300
|
if (Array.isArray(patch)) return patch; // arrays replace
|
|
230
301
|
if (typeof patch !== "object" || patch === null) return patch;
|
|
@@ -236,7 +307,7 @@ function deepMerge(base, patch) {
|
|
|
236
307
|
}
|
|
237
308
|
|
|
238
309
|
async function main() {
|
|
239
|
-
const { replace, path } = parseArgs();
|
|
310
|
+
const { replace, path, ifRev } = parseArgs();
|
|
240
311
|
|
|
241
312
|
// Read stdin
|
|
242
313
|
const chunks = [];
|
|
@@ -257,7 +328,7 @@ async function main() {
|
|
|
257
328
|
}
|
|
258
329
|
|
|
259
330
|
try {
|
|
260
|
-
await acquireLock(path);
|
|
331
|
+
heldLockIno = await acquireLock(path);
|
|
261
332
|
heldLockFor = path;
|
|
262
333
|
} catch (err) {
|
|
263
334
|
if (err.code === "LOCK_TIMEOUT") {
|
|
@@ -272,6 +343,8 @@ async function main() {
|
|
|
272
343
|
// here must release OUR lock explicitly before exiting - otherwise a corrupt
|
|
273
344
|
// state file (or any I/O error) leaks agent-state.json.lock and blocks the
|
|
274
345
|
// next writer until the stale-lock window elapses.
|
|
346
|
+
const fileExistedBefore = existsSync(path);
|
|
347
|
+
|
|
275
348
|
try {
|
|
276
349
|
let next;
|
|
277
350
|
if (replace) {
|
|
@@ -304,16 +377,67 @@ async function main() {
|
|
|
304
377
|
// carries a `rev` from before the record moved, and following it down
|
|
305
378
|
// would tell the next reader that nothing changed.
|
|
306
379
|
let priorRev = Number.isInteger(next.rev) ? next.rev : 0;
|
|
380
|
+
let diskRev = null;
|
|
307
381
|
if (existsSync(path)) {
|
|
308
382
|
try {
|
|
309
383
|
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
310
|
-
if (Number.isInteger(parsed?.rev))
|
|
384
|
+
if (Number.isInteger(parsed?.rev)) {
|
|
385
|
+
diskRev = parsed.rev;
|
|
386
|
+
priorRev = Math.max(priorRev, parsed.rev);
|
|
387
|
+
}
|
|
311
388
|
} catch {
|
|
312
389
|
/* unreadable on disk - the merge path above already threw for that */
|
|
313
390
|
}
|
|
314
391
|
}
|
|
392
|
+
|
|
393
|
+
// Compare-and-swap. `rev` already told a reader whether the record moved;
|
|
394
|
+
// --if-rev is what lets it ACT on that instead of merging over the top.
|
|
395
|
+
//
|
|
396
|
+
// The lock closes the window between two writers. It does not close the
|
|
397
|
+
// one between a READ and a write, and `/multi-agent:steer` puts a second
|
|
398
|
+
// writer in exactly that window on purpose: it reads the state, a human
|
|
399
|
+
// thinks, and by the time the edit lands the run may have moved three
|
|
400
|
+
// phases. A merge then reinstates decisions the run already replaced.
|
|
401
|
+
//
|
|
402
|
+
// Refusing costs a re-read. Merging costs a phase that silently reverts.
|
|
403
|
+
if (ifRev !== null) {
|
|
404
|
+
const seen = diskRev ?? 0;
|
|
405
|
+
if (seen !== ifRev) {
|
|
406
|
+
console.error(
|
|
407
|
+
`write-state: rev is ${seen}, expected ${ifRev} - the record moved since you read it; re-read and retry`,
|
|
408
|
+
);
|
|
409
|
+
releaseLock(path);
|
|
410
|
+
process.exit(5);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
315
414
|
next.rev = priorRev + 1;
|
|
316
415
|
|
|
416
|
+
// schemaVersion is stamped only when this call CREATES the document. The
|
|
417
|
+
// migration runner keys on it, and a document that never carries one can
|
|
418
|
+
// never be migrated - measured on a real install, 43 of 43 state files had
|
|
419
|
+
// no schemaVersion, so pipeline/schemas/migrations/ was unreachable code.
|
|
420
|
+
// A merge into an existing unstamped file is deliberately left alone:
|
|
421
|
+
// stamping there would claim a conformance nothing verified.
|
|
422
|
+
if (!fileExistedBefore && typeof next.schemaVersion !== "string") {
|
|
423
|
+
next.schemaVersion = STATE_SCHEMA_VERSION;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Last check before the write lands: is the lock still ours? Everything
|
|
427
|
+
// above read the file under a lock we believed we held, and if that belief
|
|
428
|
+
// stopped being true the rename would overwrite a concurrent writer's
|
|
429
|
+
// update with a merge that never saw it. Reporting success while losing an
|
|
430
|
+
// update is the one outcome this script exists to prevent, so it refuses
|
|
431
|
+
// instead - a caller that sees exit 4 can read and retry, which a caller
|
|
432
|
+
// that saw exit 0 never will.
|
|
433
|
+
if (!stillOurs(path, heldLockIno)) {
|
|
434
|
+
console.error(
|
|
435
|
+
`write-state: the lock was taken from us mid-write (${path}.lock). ` +
|
|
436
|
+
`Nothing was written. Re-read the state and retry.`,
|
|
437
|
+
);
|
|
438
|
+
process.exit(4);
|
|
439
|
+
}
|
|
440
|
+
|
|
317
441
|
const tmp = `${dirname(path)}/.${basename(path)}.${process.pid}.tmp`;
|
|
318
442
|
writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n");
|
|
319
443
|
renameSync(tmp, path); // atomic on POSIX
|
|
@@ -327,8 +451,23 @@ async function main() {
|
|
|
327
451
|
process.exit(0);
|
|
328
452
|
}
|
|
329
453
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
454
|
+
// Only run when invoked as a program. The lock helpers are the part with the
|
|
455
|
+
// subtle behaviour and they are unreachable from a test otherwise: the window
|
|
456
|
+
// they guard is microseconds wide, so a test that drives this file as a CLI
|
|
457
|
+
// measures the scheduler rather than the code.
|
|
458
|
+
const isDirectRun = invokedDirectly(import.meta.url);
|
|
459
|
+
if (isDirectRun) {
|
|
460
|
+
// Exit 3 is this file's documented "died without writing" code, and the
|
|
461
|
+
// cleanup is the reason the handler exists at all: a writer that dies holding
|
|
462
|
+
// the advisory lock makes the NEXT writer wait out the acquire window and
|
|
463
|
+
// then decide, on nothing but age, whether to take a lock that may still be
|
|
464
|
+
// live. Releasing on the abnormal path keeps that decision from arising.
|
|
465
|
+
runMain("write-state", main, {
|
|
466
|
+
code: 3,
|
|
467
|
+
cleanup: () => {
|
|
468
|
+
if (heldLockFor) releaseLock(heldLockFor);
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export { acquireLock, releaseLock, stillOurs, lockIdentityIfStale };
|