@mmerterden/multi-agent-pipeline 16.14.0 → 16.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +5 -4
  3. package/README.tr.md +5 -4
  4. package/docs/adr/0010-own-code-graph.md +6 -0
  5. package/docs/adr/0011-dormant-ci.md +87 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/architecture.md +2 -2
  8. package/docs/ecosystem.md +5 -5
  9. package/install/_common.mjs +0 -1
  10. package/install/_dev-only-files.mjs +1 -0
  11. package/install/claude.mjs +14 -4
  12. package/install/index.mjs +8 -1
  13. package/package.json +6 -5
  14. package/pipeline/commands/multi-agent/help/SKILL.md +44 -39
  15. package/pipeline/commands/multi-agent/steer/SKILL.md +109 -0
  16. package/pipeline/commands/multi-agent/sync/SKILL.md +5 -7
  17. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  18. package/pipeline/multi-agent-refs/phases/operations.md +23 -0
  19. package/pipeline/multi-agent-refs/phases/phase-0-init.md +4 -0
  20. package/pipeline/multi-agent-refs/phases/phase-7-report.md +1 -1
  21. package/pipeline/multi-agent-refs/phases.md +38 -0
  22. package/pipeline/preferences-template.json +2 -10
  23. package/pipeline/rules/outside-the-pipeline.md +1 -1
  24. package/pipeline/schemas/agent-state.schema.json +35 -0
  25. package/pipeline/schemas/analysis-spec.schema.json +8 -2
  26. package/pipeline/schemas/conventions-output.schema.json +2 -13
  27. package/pipeline/scripts/build-references.mjs +22 -6
  28. package/pipeline/scripts/code-graph-rules/android.json +4 -21
  29. package/pipeline/scripts/code-graph-rules/go.json +4 -19
  30. package/pipeline/scripts/code-graph-rules/node.json +4 -21
  31. package/pipeline/scripts/feedback-send.mjs +3 -1
  32. package/pipeline/scripts/gate-linux.sh +62 -0
  33. package/pipeline/scripts/localize-commands.mjs +1 -2
  34. package/pipeline/scripts/pre-push-check.sh +6 -4
  35. package/pipeline/scripts/usage-report.mjs +16 -14
  36. package/pipeline/scripts/validate-analysis-doc.mjs +46 -27
  37. package/pipeline/scripts/validate-analysis.mjs +7 -1
  38. package/pipeline/scripts/validate-complaint-doc.mjs +24 -8
  39. package/pipeline/scripts/write-state.mjs +71 -12
  40. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +111 -0
  41. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+ # gate-linux.sh - run the ci-lite job on a real Ubuntu image, locally.
3
+ #
4
+ # The pre-push gate (pre-push-check.sh) is the primary one, but it runs on the
5
+ # maintainer's macOS workstation against an installed node_modules that has
6
+ # accumulated over months. Two things it cannot answer: does this tree work on
7
+ # Linux, and does `npm ci` from the lockfile into an empty tree still resolve.
8
+ # ci-lite.yml answers both, and has not executed since 2026-07-25.
9
+ #
10
+ # `act` runs that workflow against the same image GitHub would use, so the
11
+ # answer comes from the workflow definition rather than from a second script
12
+ # that would drift away from it.
13
+ #
14
+ # NOT part of `npm run gate` on purpose: act and Docker are not installed
15
+ # everywhere, and a required gate that cannot run is the failure ADR-0011
16
+ # exists to avoid. Run it before a release, or after touching install/,
17
+ # lib/credential-store.sh, or anything path-shaped.
18
+ #
19
+ # Needs network inside the container: the scorecard's dependency metrics ask
20
+ # the registry (advisories and signatures) and report an unreachable registry
21
+ # as a failure rather than a skip, on the grounds that a supply-chain check
22
+ # which goes green without reaching the registry is worse than no check.
23
+ #
24
+ # Exit codes: 0 = the job passed, 1 = the job failed, 2 = tooling missing
25
+
26
+ set -uo pipefail
27
+
28
+ REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
29
+ cd "$REPO_ROOT" || { echo "FAIL: can't cd to repo root" >&2; exit 2; }
30
+
31
+ if ! command -v act >/dev/null 2>&1; then
32
+ cat >&2 <<'MSG'
33
+ ✗ act is not installed - this gate needs it to run the Ubuntu job locally.
34
+
35
+ brew install act # macOS
36
+ # and a running Docker daemon (Docker Desktop, colima, orbstack)
37
+
38
+ Why this is not bundled: it pulls a multi-gigabyte runner image. The rest of
39
+ the gate chain (npm run gate) has no such dependency and stays the default.
40
+ MSG
41
+ exit 2
42
+ fi
43
+
44
+ if ! docker info >/dev/null 2>&1; then
45
+ echo "✗ act is installed but no Docker daemon is reachable. Start Docker and retry." >&2
46
+ exit 2
47
+ fi
48
+
49
+ echo "→ Running ci-lite on ubuntu-22.04 via act (this pulls an image on first run)"
50
+ echo ""
51
+
52
+ if act workflow_dispatch \
53
+ -W .github/workflows/ci-lite.yml \
54
+ -P ubuntu-latest=catthehacker/ubuntu:act-22.04; then
55
+ echo ""
56
+ echo "✓ ci-lite passed on Linux"
57
+ exit 0
58
+ fi
59
+
60
+ echo "" >&2
61
+ echo "✗ ci-lite failed on Linux. A pass on macOS does not cover this." >&2
62
+ exit 1
@@ -116,8 +116,7 @@ export function localizeCommands(dir, mode) {
116
116
  // `.split("/").pop()` never split a backslash path, so on Windows this was
117
117
  // always false - `restore` exited 0 doing nothing and /multi-agent:sync then
118
118
  // mirrored localized descriptions into the repo, the one thing it must prevent.
119
- const invokedDirectly =
120
- process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
119
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
121
120
  if (invokedDirectly) {
122
121
  const args = process.argv.slice(2);
123
122
  const mode = args[0];
@@ -3,10 +3,12 @@
3
3
  #
4
4
  # Purpose:
5
5
  # The repo HAS workflows (test.yml, ci-lite.yml), but they have not executed
6
- # since 2026-07-25: the jobs complete in ~2 seconds with zero steps recorded,
7
- # which is a runner/quota block rather than a test failure. Until that is
8
- # resolved, this hook is not a second opinion - it is the only thing standing
9
- # between a broken commit and npm.
6
+ # since 2026-07-02: every attempt after that day - the last on 2026-07-25 -
7
+ # finished in about four seconds with zero steps recorded, which is a
8
+ # runner/quota block rather than a test failure. Both are now dormant by
9
+ # declaration too (ADR-0011: the push triggers are commented out). So this
10
+ # hook is not a second opinion - it is the only thing standing between a
11
+ # broken commit and npm.
10
12
  #
11
13
  # It therefore runs the CANONICAL chain, `npm test`, rather than a hand-picked
12
14
  # subset. The subset it used to run was missing eight of the eleven steps
@@ -69,8 +69,7 @@ function readJson(path) {
69
69
 
70
70
  function resolveGlobalPrefs() {
71
71
  const path =
72
- process.env.MULTI_AGENT_PREFS ||
73
- join(homedir(), ".claude", "multi-agent-preferences.json");
72
+ process.env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json");
74
73
  return readJson(path)?.global ?? {};
75
74
  }
76
75
 
@@ -130,9 +129,7 @@ function enabledPlugins(state) {
130
129
  ]) {
131
130
  for (const name of readEnabledPluginNames(s)) names.add(name.split("@")[0]);
132
131
  }
133
- return Array.from(names)
134
- .filter(Boolean)
135
- .slice(0, 20);
132
+ return Array.from(names).filter(Boolean).slice(0, 20);
136
133
  }
137
134
 
138
135
  function integrationTags(state) {
@@ -146,8 +143,7 @@ function integrationTags(state) {
146
143
  const tool = String(call?.tool || "");
147
144
  // Server segment may contain hyphens (mcp__multi-agent-toolkit__ios_tap), so the
148
145
  // class must accept them or "multi-agent-toolkit" collapses to "dev".
149
- const m =
150
- tool.match(/^mcp__([a-z0-9_-]+?)__/i) || tool.match(/^mcp__([a-z0-9_-]+)/i);
146
+ const m = tool.match(/^mcp__([a-z0-9_-]+?)__/i) || tool.match(/^mcp__([a-z0-9_-]+)/i);
151
147
  if (m) apps.add(m[1].replace(/_/g, "-").toLowerCase());
152
148
  }
153
149
  return Array.from(apps).slice(0, 20);
@@ -345,7 +341,8 @@ function trackerSummary(state) {
345
341
  const phs = [];
346
342
  const failed = [];
347
343
  const models = new Set();
348
- let startedAt = typeof tracker?.started_at === "string" && tracker.started_at ? tracker.started_at : null;
344
+ let startedAt =
345
+ typeof tracker?.started_at === "string" && tracker.started_at ? tracker.started_at : null;
349
346
  let endedAt = null;
350
347
  for (const p of list) {
351
348
  const id = p?.id != null && String(p.id) !== "" ? String(p.id) : "?";
@@ -510,9 +507,7 @@ function buildEvent(state) {
510
507
  ph: typeof state.currentPhase === "number" ? state.currentPhase : null,
511
508
  st: state.status || null,
512
509
  hr: state.haltReason || null,
513
- ri: Array.isArray(state.reviewIterations)
514
- ? state.reviewIterations.length
515
- : 0,
510
+ ri: Array.isArray(state.reviewIterations) ? state.reviewIterations.length : 0,
516
511
  pr: Boolean(state.pr && (state.pr.url || state.pr.number)),
517
512
  du: durationSec(state, spend),
518
513
  tk: spend.tk,
@@ -612,10 +607,17 @@ async function main() {
612
607
  }
613
608
 
614
609
  // Windows-safe entry-point check: compare file URLs, never string-split a path.
615
- const invokedDirectly =
616
- process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
610
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
617
611
  if (invokedDirectly) {
618
612
  main().catch(() => process.exit(0));
619
613
  }
620
614
 
621
- export { endpointAllowed, integrationTags, enabledPlugins, readEnabledPluginNames, trackerSummary, buildEvent, resolveUser };
615
+ export {
616
+ endpointAllowed,
617
+ integrationTags,
618
+ enabledPlugins,
619
+ readEnabledPluginNames,
620
+ trackerSummary,
621
+ buildEvent,
622
+ resolveUser,
623
+ };
@@ -46,9 +46,7 @@ import { readFileSync } from "node:fs";
46
46
  // "mobile" and "web" are its platform values. "none" is the narrower case where the
47
47
  // evidence describes no interface at all. All three are real values, not missing ones.
48
48
  // "web" is the pre-"web" spelling, still accepted so older documents validate.
49
- const KNOWN_PLATFORMS = new Set([
50
- "ios", "android", "web", "backend", "mobile", "frontend", "none",
51
- ]);
49
+ const KNOWN_PLATFORMS = new Set(["ios", "android", "web", "backend", "mobile", "frontend", "none"]);
52
50
  const REQUIRED_FM = ["feature", "platform", "language", "mode", "template_version"];
53
51
 
54
52
  // Never-omitted sections (Locked 2), matched by bilingual title keyword so the
@@ -70,13 +68,19 @@ const REQUIRED_SECTIONS = [
70
68
  const CORPORATE_SECTIONS = [
71
69
  { key: "purpose and scope", any: ["Amaç ve Kapsam", "Amac ve Kapsam", "Purpose and Scope"] },
72
70
  { key: "business analysis", any: ["İş Analizi", "Is Analizi", "Business Analysis"] },
73
- { key: "business requirements", any: ["İş Gereksinimleri", "Is Gereksinimleri", "Business Requirements"] },
71
+ {
72
+ key: "business requirements",
73
+ any: ["İş Gereksinimleri", "Is Gereksinimleri", "Business Requirements"],
74
+ },
74
75
  { key: "ai requirements", any: ["Yapay Zeka", "AI Requirements"] },
75
76
  { key: "use cases", any: ["Kullanım Senaryoları", "Kullanim Senaryolari", "Use Cases"] },
76
77
  { key: "hardware and infrastructure", any: ["Donanım", "Donanim", "Hardware"] },
77
78
  { key: "quality requirements", any: ["Kalite Gereksinimleri", "Quality Requirements"] },
78
79
  { key: "regulatory requirements", any: ["Regülasyonel", "Regulasyonel", "Regulatory"] },
79
- { key: "content requirements", any: ["İçerik Gereksinimleri", "Icerik Gereksinimleri", "Content Requirements"] },
80
+ {
81
+ key: "content requirements",
82
+ any: ["İçerik Gereksinimleri", "Icerik Gereksinimleri", "Content Requirements"],
83
+ },
80
84
  { key: "risks", any: ["Riskler", "Risks"] },
81
85
  { key: "references", any: ["Referanslar", "References"] },
82
86
  ];
@@ -226,9 +230,7 @@ function main() {
226
230
  // defines AS-07, and each side is checked against the other.
227
231
  if (profile === "corporate") {
228
232
  const lines = text.split("\n");
229
- const risksStart = lines.findIndex((l) =>
230
- /^#{2,3}\s+\d+/.test(l) && /(Riskler|Risks)/.test(l),
231
- );
233
+ const risksStart = lines.findIndex((l) => /^#{2,3}\s+\d+/.test(l) && /(Riskler|Risks)/.test(l));
232
234
  let risksBlock = "";
233
235
  if (risksStart !== -1) {
234
236
  for (let i = risksStart + 1; i < lines.length; i++) {
@@ -237,9 +239,7 @@ function main() {
237
239
  }
238
240
  }
239
241
  const AS_ID = /\bAS-(\d{2,3})\b/g;
240
- const definedIds = new Set(
241
- [...risksBlock.matchAll(AS_ID)].map((m) => `AS-${m[1]}`),
242
- );
242
+ const definedIds = new Set([...risksBlock.matchAll(AS_ID)].map((m) => `AS-${m[1]}`));
243
243
  const referencedIds = new Set();
244
244
  for (let i = 0; i < lines.length; i++) {
245
245
  if (risksStart !== -1 && i > risksStart) continue;
@@ -248,9 +248,7 @@ function main() {
248
248
  // A gap that names no id cannot be traced to an owner, which is the whole
249
249
  // point of admitting it.
250
250
  if (!/\bAS-\d{2,3}\b/.test(lines[i])) {
251
- errors.push(
252
- `line ${i + 1}: EKLENECEK carries no AS-NN reference (Locked 33)`,
253
- );
251
+ errors.push(`line ${i + 1}: EKLENECEK carries no AS-NN reference (Locked 33)`);
254
252
  }
255
253
  }
256
254
  for (const id of referencedIds) {
@@ -264,9 +262,7 @@ function main() {
264
262
  if (!referencedIds.has(id)) {
265
263
  // An open question with no body reference is allowed only when the row
266
264
  // says so; otherwise it is a question about nothing the document raises.
267
- const row = risksBlock
268
- .split("\n")
269
- .find((l) => l.includes(id)) || "";
265
+ const row = risksBlock.split("\n").find((l) => l.includes(id)) || "";
270
266
  if (!/(no body reference|govde referansi yok)/i.test(row)) {
271
267
  errors.push(
272
268
  `${id} is defined in Risks and Open Questions but never referenced in the body (Locked 33)`,
@@ -318,7 +314,9 @@ function main() {
318
314
 
319
315
  for (const id of defined) {
320
316
  if (!inMatrix.has(id)) {
321
- errors.push(`${id} is defined in the document but missing from the traceability matrix`);
317
+ errors.push(
318
+ `${id} is defined in the document but missing from the traceability matrix`,
319
+ );
322
320
  }
323
321
  }
324
322
  for (const id of inMatrix) {
@@ -342,12 +340,23 @@ function main() {
342
340
  let inMermaid = false;
343
341
  for (let i = 0; i < mmLines.length; i++) {
344
342
  const t = mmLines[i].trim();
345
- if (/^```mermaid\s*$/.test(t)) { inMermaid = true; continue; }
346
- if (inMermaid && t === "```") { inMermaid = false; continue; }
343
+ if (/^```mermaid\s*$/.test(t)) {
344
+ inMermaid = true;
345
+ continue;
346
+ }
347
+ if (inMermaid && t === "```") {
348
+ inMermaid = false;
349
+ continue;
350
+ }
347
351
  if (!inMermaid) continue;
348
352
  const labels = [...mmLines[i].matchAll(/[[({|]([^\]})|]{3,})[\]})|]/g)].map((m) => m[1]);
349
353
  for (const lbl of labels) {
350
- if (/^[\x20-\x7E]+$/.test(lbl) && /\b(Hayir|Basarili|Basarisiz|Odeme|Iptal|Gecerli|Gecersiz|Secim|Dogrulama|Uyari|Baslangic|Bitis|Onay|Aciklama)\b/.test(lbl)) {
354
+ if (
355
+ /^[\x20-\x7E]+$/.test(lbl) &&
356
+ /\b(Hayir|Basarili|Basarisiz|Odeme|Iptal|Gecerli|Gecersiz|Secim|Dogrulama|Uyari|Baslangic|Bitis|Onay|Aciklama)\b/.test(
357
+ lbl,
358
+ )
359
+ ) {
351
360
  errors.push(
352
361
  `WARN line ${i + 1}: diagram label "${lbl}" looks like Turkish flattened to ASCII (Locked 7)`,
353
362
  );
@@ -356,7 +365,6 @@ function main() {
356
365
  }
357
366
  }
358
367
 
359
-
360
368
  // 2b. Opt-in coverage sections must be present when the front-matter says so.
361
369
  const uiTests = String(parsed?.fm?.ui_tests || "false").toLowerCase() === "true";
362
370
  const a11yDepth = String(parsed?.fm?.a11y_depth || "basic").toLowerCase();
@@ -402,7 +410,9 @@ function main() {
402
410
  if (parsed?.fm?.platform === "backend") {
403
411
  warns.push(`${msg} - allowed for backend only when no contract testing is planned`);
404
412
  } else {
405
- errors.push(`missing Section 15 Test Plan; Full mode dev reads it as the RED input (Locked 31)`);
413
+ errors.push(
414
+ `missing Section 15 Test Plan; Full mode dev reads it as the RED input (Locked 31)`,
415
+ );
406
416
  }
407
417
  } else {
408
418
  const hasUnit =
@@ -469,7 +479,11 @@ function main() {
469
479
  // The pre-existing occurrence-count heuristic below stays as the looser net.
470
480
  if (mode === "full") {
471
481
  const idRx = /BR-[a-z0-9]+(?:-[a-z0-9]+)*-\d+/gi;
472
- const storyBody = sectionBody(allLines, ["Kullanıcı Hikayeleri", "Kullanici Hikayeleri", "User Stories"]);
482
+ const storyBody = sectionBody(allLines, [
483
+ "Kullanıcı Hikayeleri",
484
+ "Kullanici Hikayeleri",
485
+ "User Stories",
486
+ ]);
473
487
  const testBody = sectionBody(allLines, ["Test Planı", "Test Plani", "Test Plan"]);
474
488
  if (storyBody && testBody) {
475
489
  const defined = new Set((storyBody.join("\n").match(idRx) || []).map((x) => x.toUpperCase()));
@@ -556,13 +570,18 @@ function main() {
556
570
  inVariant = true;
557
571
  continue;
558
572
  }
559
- if (inVariant && /^#{1,3}\s/.test(line)) { inVariant = false; continue; }
573
+ if (inVariant && /^#{1,3}\s/.test(line)) {
574
+ inVariant = false;
575
+ continue;
576
+ }
560
577
  if (!inVariant || !/^\|/.test(line)) continue;
561
578
  const cells = line.split("|").map((c) => c.trim());
562
- if (cells.length < 7) continue; // not the 6-column body
579
+ if (cells.length < 7) continue; // not the 6-column body
563
580
  if (/^-+$/.test(cells[1]) || /Bile\u015fen|Component/i.test(cells[1])) continue;
564
581
  if (!cells[3] || !cells[4]) {
565
- errors.push(`variant row "${cells[1]}" is missing the all-values or used-here column (Section 6.X / Locked 29)`);
582
+ errors.push(
583
+ `variant row "${cells[1]}" is missing the all-values or used-here column (Section 6.X / Locked 29)`,
584
+ );
566
585
  }
567
586
  }
568
587
 
@@ -21,7 +21,13 @@ import { readFileSync } from "node:fs";
21
21
  // output, a golden-task fixture or a resumed run written before the rename still
22
22
  // validates; new output uses "web". Mirrors analysis-output.schema.json.
23
23
  const ALLOWED_STACKS = new Set([
24
- "ios", "android", "backend", "web", "mobile", "frontend", "unknown",
24
+ "ios",
25
+ "android",
26
+ "backend",
27
+ "web",
28
+ "mobile",
29
+ "frontend",
30
+ "unknown",
25
31
  ]);
26
32
  const ALLOWED_SEVERITIES = new Set(["low", "medium", "high"]);
27
33
 
@@ -46,14 +46,18 @@ const REQUIRED_FM = ["run_name", "generated_at", "language", "complaint_count",
46
46
  const REQUIRED_SECTIONS = [
47
47
  { key: "summary", any: ["Summary", "Özet", "Ozet"] },
48
48
  { key: "triage table", any: ["Triage"] },
49
- { key: "complaint details", any: ["Complaint Details", "Şikayet Detayları", "Sikayet Detaylari", "Detay"] },
49
+ {
50
+ key: "complaint details",
51
+ any: ["Complaint Details", "Şikayet Detayları", "Sikayet Detaylari", "Detay"],
52
+ },
50
53
  { key: "open questions", any: ["Open Questions", "Açık Sorular", "Acik Sorular"] },
51
54
  { key: "methodology", any: ["Methodology", "Metodoloji"] },
52
55
  { key: "references", any: ["References", "Referanslar"] },
53
56
  ];
54
57
 
55
58
  const ROUTING_KEYWORDS = ["Routing", "Yönlendirme", "Yonlendirme"];
56
- const FIX_PLAN_RE = /(Fix plan|Fix Plan|Geliştirme planı|Geliştirme Planı|Gelistirme plani|Gelistirme Plani)/;
59
+ const FIX_PLAN_RE =
60
+ /(Fix plan|Fix Plan|Geliştirme planı|Geliştirme Planı|Gelistirme plani|Gelistirme Plani)/;
57
61
 
58
62
  // Cell-exact on purpose: a substring match would classify a row as `core`
59
63
  // because its summary says "core-data" or "core team" before the verdict column.
@@ -164,7 +168,9 @@ function main() {
164
168
  const triageScope = sectionBody(text, ["Triage"]) ?? text;
165
169
  const rows = triageScope.split("\n").filter((l) => /^\s*\|\s*C-\d{2,}\s*\|/.test(l));
166
170
  if (rows.length === 0) {
167
- errors.push("no triage rows found (expected Triage-section rows with a C-NN id in the first cell)");
171
+ errors.push(
172
+ "no triage rows found (expected Triage-section rows with a C-NN id in the first cell)",
173
+ );
168
174
  }
169
175
  const coreIds = [];
170
176
  const handoffIds = [];
@@ -173,7 +179,9 @@ function main() {
173
179
  const cells = row.split("|").map((c) => c.trim());
174
180
  const verdict = cells.find((c) => VERDICT_CELL_RE.test(c));
175
181
  if (!verdict) {
176
- errors.push(`triage row ${id} has no valid verdict token (client:<layer> | bff:<layer> | core | insufficient-evidence)`);
182
+ errors.push(
183
+ `triage row ${id} has no valid verdict token (client:<layer> | bff:<layer> | core | insufficient-evidence)`,
184
+ );
177
185
  } else if (verdict === "core" && !coreIds.includes(id)) {
178
186
  coreIds.push(id);
179
187
  } else if (/^(client|bff):/.test(verdict) && !handoffIds.includes(id)) {
@@ -185,7 +193,9 @@ function main() {
185
193
  if (coreIds.length > 0) {
186
194
  const routing = sectionBody(text, ROUTING_KEYWORDS);
187
195
  if (!routing) {
188
- errors.push(`core verdict(s) ${coreIds.join(", ")} but no routing section (Yönlendirme / Routing)`);
196
+ errors.push(
197
+ `core verdict(s) ${coreIds.join(", ")} but no routing section (Yönlendirme / Routing)`,
198
+ );
189
199
  } else {
190
200
  for (const id of coreIds) {
191
201
  if (!routing.includes(id)) {
@@ -200,7 +210,9 @@ function main() {
200
210
  for (const id of handoffIds) {
201
211
  const detail = sectionBody(text, [id]);
202
212
  if (!detail || !FIX_PLAN_RE.test(detail)) {
203
- errors.push(`client/bff verdict ${id} has no fix-plan block (Fix plan / Geliştirme planı) in its detail section`);
213
+ errors.push(
214
+ `client/bff verdict ${id} has no fix-plan block (Fix plan / Geliştirme planı) in its detail section`,
215
+ );
204
216
  }
205
217
  }
206
218
 
@@ -223,10 +235,14 @@ function main() {
223
235
  errors.push(`redaction leak: card-like digit run at line ${i + 1}`);
224
236
  }
225
237
  if (NATIONAL_ID_RE.test(bodyLines[i]) && !CARD_RE.test(bodyLines[i])) {
226
- warns.push(`possible redaction leak: bare 11-digit run at line ${i + 1} (national-id-like; ignore if it is a numeric trx id)`);
238
+ warns.push(
239
+ `possible redaction leak: bare 11-digit run at line ${i + 1} (national-id-like; ignore if it is a numeric trx id)`,
240
+ );
227
241
  }
228
242
  if (PNR_MIXED_RE.test(bodyLines[i]) || PNR_CONTEXT_RE.test(bodyLines[i])) {
229
- warns.push(`possible redaction leak: PNR-shaped token at line ${i + 1} (parse-complaints.sh should have redacted it)`);
243
+ warns.push(
244
+ `possible redaction leak: PNR-shaped token at line ${i + 1} (parse-complaints.sh should have redacted it)`,
245
+ );
230
246
  }
231
247
  }
232
248
 
@@ -25,8 +25,20 @@
25
25
  *
26
26
  * Tunables (env):
27
27
  * WRITE_STATE_LOCK_TIMEOUT_MS acquire window before exit 2 (default 15000)
28
- * WRITE_STATE_LOCK_STALE_MS age past which a lock is reclaimed (default 30000)
29
- * A dead holder (PID no longer alive) is reclaimed immediately regardless of age.
28
+ * WRITE_STATE_LOCK_STALE_MS age past which a lock with no readable PID is
29
+ * reclaimed (default 30000)
30
+ * WRITE_STATE_LOCK_ABANDON_MS ceiling past which even a lock that probes as
31
+ * alive is reclaimed, covering PID reuse
32
+ * (default: 10x STALE_MS)
33
+ * A dead holder (PID no longer alive) is reclaimed immediately regardless of
34
+ * age. A live holder keeps its lock: liveness outranks age. See
35
+ * lockIdentityIfStale() for why that order is load-bearing.
36
+ *
37
+ * Every successful write bumps `rev`, a counter that only ever increases. A
38
+ * reader that kept the `rev` it read can tell whether the record moved under
39
+ * it before writing back. The lock makes a single write atomic; it does not
40
+ * cover the gap between a phase reading state and writing it back, and
41
+ * `/multi-agent:steer` puts a deliberate second writer in that gap.
30
42
  */
31
43
 
32
44
  import {
@@ -136,11 +148,29 @@ async function acquireLock(
136
148
  }
137
149
 
138
150
  /**
139
- * A lock is stale when its owning PID is no longer alive, or when the lock
140
- * file is older than `staleMs` (which also covers PID reuse).
151
+ * A lock is stale when its owning PID is no longer alive. Age alone is not
152
+ * staleness.
153
+ *
154
+ * The order here is the whole point. Age used to be checked first and returned
155
+ * "stale" on its own, so a holder that was demonstrably ALIVE lost its lock the
156
+ * moment the file passed 30 seconds - and the next writer then deleted a live
157
+ * lock and overwrote the update behind it. Liveness is direct evidence;
158
+ * a timestamp is a guess about it, and a guess must not overrule the evidence.
159
+ *
160
+ * Age survives for the one case liveness cannot answer: PID reuse. A dead
161
+ * holder whose PID has been recycled by an unrelated process probes as alive
162
+ * forever, so a lock that is alive but older than `abandonMs` is reclaimed
163
+ * anyway. That ceiling is deliberately far above any real write (default ten
164
+ * times `staleMs`), because it is a last resort rather than a routine path.
165
+ *
166
+ * No periodic heartbeat: this writer holds the lock across one synchronous
167
+ * write and rename, so there is no window in which a timer could refresh the
168
+ * file - and refreshing a timestamp would be weaker evidence than the liveness
169
+ * probe already gives.
170
+ *
141
171
  * @param {string} lockPath
142
172
  * @param {number} staleMs
143
- * @returns {boolean}
173
+ * @returns {{ino: number, mtimeMs: number} | null}
144
174
  */
145
175
  function lockIdentityIfStale(lockPath, staleMs) {
146
176
  let pid;
@@ -156,13 +186,17 @@ function lockIdentityIfStale(lockPath, staleMs) {
156
186
  return null;
157
187
  }
158
188
  const identity = { ino, mtimeMs };
159
- if (Date.now() - mtimeMs > staleMs) return identity;
160
- // An unreadable PID is NOT proof of staleness. Treating it as such is what
161
- // let a writer delete a live lock and lose another writer's update. With the
162
- // link-based acquire above a lock is never observable without its PID, so
163
- // this can only be genuine corruption - which the staleMs check reclaims
164
- // anyway, without racing a writer that is merely mid-flight.
165
- if (!Number.isInteger(pid) || pid <= 0) return null;
189
+ const abandonMs = Number(process.env.WRITE_STATE_LOCK_ABANDON_MS) || staleMs * 10;
190
+ const age = Date.now() - mtimeMs;
191
+
192
+ // PID-reuse ceiling. Only reached by a lock that still probes as alive after
193
+ // a length of time no legitimate write comes close to.
194
+ if (age > abandonMs) return identity;
195
+
196
+ // A lock with no readable PID cannot be probed, so age is the only evidence
197
+ // left for it. That is corruption rather than a live writer: the link-based
198
+ // acquire makes a lock observable only after its PID is inside.
199
+ if (!Number.isInteger(pid) || pid <= 0) return age > staleMs ? identity : null;
166
200
  try {
167
201
  process.kill(pid, 0); // probe liveness without signalling
168
202
  return null; // holder alive
@@ -255,6 +289,31 @@ async function main() {
255
289
  next = deepMerge(current, payload);
256
290
  }
257
291
 
292
+ // A state document is an object. Anything else cannot carry `rev`, and
293
+ // assigning to a primitive throws a raw TypeError from inside the writer,
294
+ // which is a worse answer than saying so. `--replace` is where this
295
+ // reaches: the merge path can only ever produce an object.
296
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
297
+ throw new Error(
298
+ `state must be a JSON object, got ${Array.isArray(next) ? "array" : typeof next}`,
299
+ );
300
+ }
301
+
302
+ // Monotonic revision, taken as the HIGHER of what the incoming document
303
+ // carries and what is on disk. A reader that writes back what it read
304
+ // carries a `rev` from before the record moved, and following it down
305
+ // would tell the next reader that nothing changed.
306
+ let priorRev = Number.isInteger(next.rev) ? next.rev : 0;
307
+ if (existsSync(path)) {
308
+ try {
309
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
310
+ if (Number.isInteger(parsed?.rev)) priorRev = Math.max(priorRev, parsed.rev);
311
+ } catch {
312
+ /* unreadable on disk - the merge path above already threw for that */
313
+ }
314
+ }
315
+ next.rev = priorRev + 1;
316
+
258
317
  const tmp = `${dirname(path)}/.${basename(path)}.${process.pid}.tmp`;
259
318
  writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n");
260
319
  renameSync(tmp, path); // atomic on POSIX
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: multi-agent-steer
3
+ language: en
4
+ description: "Queue an instruction for a task that is already running. It is applied at the next phase boundary, not mid-phase. Use when a run is going the wrong way and killing it would throw away good work."
5
+ user-invocable: true
6
+ argument-hint: "#id \"<instruction>\" - e.g. #3 \"the field is called web, not frontend\". With no instruction, you are asked for it."
7
+ ---
8
+
9
+ # multi-agent steer - correct a run without stopping it
10
+
11
+ **Input**: $ARGUMENTS
12
+
13
+ Leave one instruction for a running task. The next phase reads it before it
14
+ starts work, applies it, and marks it consumed.
15
+
16
+ This exists because the alternative was losing the run. `kill` stops a task and
17
+ deletes its worktree; `resume` picks a stopped one back up. Neither helps while
18
+ a phase is in flight, so a correction that arrived mid-run - "that field is
19
+ called `web`, not `frontend`", "keep the analysis, drop the rest" - had nowhere
20
+ to go, and the run carried on in the wrong direction until it finished.
21
+
22
+ **Not a second prompt.** One instruction is queued at a time. Steering a task
23
+ that already has an unconsumed instruction replaces it, after showing you what
24
+ is being replaced.
25
+
26
+ ## Steps
27
+
28
+ 1. **Find the task** - parse `#N` or `{JIRA-KEY}-XXXXX` from the argument,
29
+ the same way `kill` and `resume` do. Locate its `agent-state.json`:
30
+
31
+ ```bash
32
+ find {repo}/.worktrees/ -name "agent-state.json" -maxdepth 2
33
+ find $HOME/.claude/logs/multi-agent -maxdepth 4 -name agent-state.json -path '*/artifacts/*'
34
+ ```
35
+
36
+ Not found → `ERR: no task #N. '/multi-agent:status' lists what is running.`
37
+
38
+ 2. **Check it can still be steered** - read `status` and `currentPhase`:
39
+
40
+ | State | What to do |
41
+ |---|---|
42
+ | `in_progress` | Queue it. This is the case the command is for. |
43
+ | `paused` / `failed` | Say the task is not running, and that `/multi-agent:resume #N` will re-enter with the instruction applied at that phase's entry. Queue it. |
44
+ | `complete` | Refuse. Nothing will read it. Point at `/multi-agent` for a follow-up run. |
45
+
46
+ `currentPhase` is 7 and status is `in_progress` → warn that Phase 7 is the
47
+ last one, so an instruction queued now may never be consumed.
48
+
49
+ 3. **Read the instruction** - from the argument, or ask for it when the
50
+ argument carries only an id. Verbatim, up to 4000 characters. Do not
51
+ summarize or rewrite it: the phase that consumes it needs the user's own
52
+ words, and a paraphrase is where the meaning goes.
53
+
54
+ 4. **Show what will be queued, and ask**:
55
+
56
+ ```
57
+ Steer #3 ({JIRA-KEY}-12345, Phase 3 Dev, in_progress)
58
+
59
+ "the field is called web, not frontend"
60
+
61
+ Applied at the entry to Phase 4. The current phase finishes as it is.
62
+ ```
63
+
64
+ Already carrying an unconsumed `pendingSteer` → print the old text above the
65
+ new one and ask whether to replace it.
66
+
67
+ 5. **Write it** - through the state writer, never by editing the file, because
68
+ the running task is writing to it too. `$STATE_FILE` is the path from step 1
69
+ and `$INSTRUCTION` the text from step 3:
70
+
71
+ ```bash
72
+ printf '{"pendingSteer":{"text":%s,"at":"%s","appliedAt":null,"appliedPhase":null}}' \
73
+ "$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$INSTRUCTION")" \
74
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
75
+ | node $HOME/.claude/scripts/write-state.mjs "$STATE_FILE"
76
+ ```
77
+
78
+ The instruction is JSON-encoded by `node -e`, not by hand: it is arbitrary
79
+ user text, and a quote or newline in it would otherwise produce invalid JSON
80
+ or, worse, a payload that merges into fields nobody meant to touch.
81
+
82
+ Exit 2 (lock timeout) → the task is mid-write. Retry once, then report it
83
+ rather than forcing the write.
84
+
85
+ 6. **Confirm**: `🧭 Steer queued for #N - applies at the entry to Phase {N+1}`
86
+
87
+ ## What the phase does with it
88
+
89
+ Phase entry, before any work: read a `pendingSteer` that has no `appliedAt`.
90
+ When present, apply it to that phase's context, set `appliedAt` and
91
+ `appliedPhase` - which is what stops it being read a second time - and write a
92
+ `Steer applied` line into `agent-log.md`. The record itself stays, so the run
93
+ keeps the trace of what was asked and when. The contract lives in
94
+ `$HOME/.claude/multi-agent-refs/phases.md` under "Phase entry - pending
95
+ steer" (the file has a second, unrelated "Phase entry" line inside the tracker
96
+ block).
97
+
98
+ Applied at entry rather than the moment it arrives, on purpose: a phase that
99
+ changes target halfway through throws away the work it already did, which is
100
+ the outcome this command exists to avoid.
101
+
102
+ An instruction that contradicts the plan is not silently obeyed. The phase says
103
+ what it is changing, and a contradiction that would invalidate an approved plan
104
+ halts for the user instead of quietly rewriting it.
105
+
106
+ **One limit worth knowing.** The reader is the phase contract, so a task started
107
+ by an install that predates it will not consume the field: the instruction is
108
+ written and nothing picks it up. There is no version stamp on a state file to
109
+ detect this from, so the honest advice is that steer applies to runs started
110
+ after the install carrying it. A run already in flight from an older install is
111
+ still a `kill` or a wait.