@mmerterden/multi-agent-pipeline 16.15.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 (37) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +5 -4
  3. package/README.tr.md +5 -4
  4. package/docs/adr/0011-dormant-ci.md +87 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/architecture.md +2 -2
  7. package/docs/ecosystem.md +5 -5
  8. package/install/_common.mjs +0 -1
  9. package/install/_dev-only-files.mjs +1 -0
  10. package/install/claude.mjs +14 -4
  11. package/install/index.mjs +8 -1
  12. package/package.json +6 -5
  13. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  14. package/pipeline/commands/multi-agent/steer/SKILL.md +109 -0
  15. package/pipeline/commands/multi-agent/sync/SKILL.md +5 -7
  16. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  17. package/pipeline/multi-agent-refs/phases/operations.md +23 -0
  18. package/pipeline/multi-agent-refs/phases.md +38 -0
  19. package/pipeline/preferences-template.json +2 -10
  20. package/pipeline/schemas/agent-state.schema.json +35 -0
  21. package/pipeline/schemas/analysis-spec.schema.json +8 -2
  22. package/pipeline/schemas/conventions-output.schema.json +2 -13
  23. package/pipeline/scripts/build-references.mjs +22 -6
  24. package/pipeline/scripts/code-graph-rules/android.json +4 -21
  25. package/pipeline/scripts/code-graph-rules/go.json +4 -19
  26. package/pipeline/scripts/code-graph-rules/node.json +4 -21
  27. package/pipeline/scripts/feedback-send.mjs +3 -1
  28. package/pipeline/scripts/gate-linux.sh +62 -0
  29. package/pipeline/scripts/localize-commands.mjs +1 -2
  30. package/pipeline/scripts/pre-push-check.sh +6 -4
  31. package/pipeline/scripts/usage-report.mjs +16 -14
  32. package/pipeline/scripts/validate-analysis-doc.mjs +46 -27
  33. package/pipeline/scripts/validate-analysis.mjs +7 -1
  34. package/pipeline/scripts/validate-complaint-doc.mjs +24 -8
  35. package/pipeline/scripts/write-state.mjs +71 -12
  36. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +111 -0
  37. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
@@ -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.
@@ -31,7 +31,7 @@ Run all steps automatically:
31
31
 
32
32
  ```
33
33
  Step 1: DETECT Compare timestamps, find stale targets
34
- Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 54 sub-command skills)
34
+ Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 55 sub-command skills)
35
35
  Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 51 specs as refs + 8 agent TOML)
36
36
  Step 3: REPO Claude Code -> pipeline repo (genericized, personal data scrub)
37
37
  Step 3d: DEV-TOOLKIT Companion MCP server -> detect movement, ship gates, commit + publish
@@ -98,7 +98,7 @@ If nothing is stale -> report "All targets up to date" and stop.
98
98
  ## Codex Sync (Step 2b)
99
99
 
100
100
  This step does **not** hand-copy files. The Codex tree is a *transform* of the Claude
101
- tree, not a mirror: the 54 sub-command specs become reference files (Codex silently
101
+ tree, not a mirror: the 55 sub-command specs become reference files (Codex silently
102
102
  truncates its skills block - see `cross-cli-contract.md` 2.6), every reference to a
103
103
  CLI-owned tree is retargeted (`agents/<persona>.md` becomes `.toml`, the dispatcher
104
104
  becomes the router skill), the 8 personas are regenerated as TOML with a model +
@@ -224,7 +224,7 @@ When invoked with the `release` argument:
224
224
  |-------------|-------------|
225
225
  | `~/.claude/commands/multi-agent/{cmd}.md` | `~/.copilot/skills/multi-agent-{cmd}/SKILL.md` |
226
226
 
227
- **54 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
227
+ **55 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
228
228
 
229
229
  ```
230
230
  analysis, analysis-resolve, autopilot, build-optimize, channels,
@@ -233,7 +233,7 @@ dev-local-autopilot, diff-explain, feedback, forget, garbage-collect,
233
233
  graph, help, ios-coding-standard, issue, jira, kill, language, local,
234
234
  local-autopilot, log, manual-test, prune-logs, prune-prompts, purge,
235
235
  refactor, resume, resume-local, review, review-analysis, review-issue,
236
- review-jira, routines, save, scan, search, setup, stack, status,
236
+ review-jira, routines, save, scan, search, setup, stack, status, steer,
237
237
  store-ready, sync, test, test-accessibility, test-dark-mode,
238
238
  test-dynamic-type, test-screenshots, testflight-validation, uninstall, update
239
239
  ```