@mmerterden/multi-agent-pipeline 16.27.0 → 16.28.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 (36) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/package.json +4 -4
  3. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  4. package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
  5. package/pipeline/commands/multi-agent/jira/SKILL.md +3 -0
  6. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  7. package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
  8. package/pipeline/lib/issue-fetcher.sh +134 -5
  9. package/pipeline/lib/multi-repo-pipeline.sh +8 -0
  10. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  11. package/pipeline/multi-agent-refs/analysis/intake.md +11 -2
  12. package/pipeline/multi-agent-refs/analysis/locked.md +1 -0
  13. package/pipeline/multi-agent-refs/analysis/redesign.md +112 -0
  14. package/pipeline/multi-agent-refs/analysis/render.md +5 -0
  15. package/pipeline/multi-agent-refs/analysis/resolve.md +1 -0
  16. package/pipeline/multi-agent-refs/analysis/review.md +15 -0
  17. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  18. package/pipeline/multi-agent-refs/analysis-template-corporate.md +3 -3
  19. package/pipeline/multi-agent-refs/analysis-template.md +36 -0
  20. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -0
  21. package/pipeline/multi-agent-refs/features/jira-context.md +101 -0
  22. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +1 -1
  23. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +1 -1
  24. package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
  25. package/pipeline/multi-agent-refs/readiness-review.md +1 -1
  26. package/pipeline/schemas/agent-state.schema.json +19 -0
  27. package/pipeline/schemas/prefs.schema.json +26 -0
  28. package/pipeline/scripts/anonymize-findings.mjs +24 -0
  29. package/pipeline/scripts/build-references.mjs +10 -6
  30. package/pipeline/scripts/council-view.mjs +144 -0
  31. package/pipeline/scripts/phase-tracker.sh +18 -1
  32. package/pipeline/scripts/skill-siblings.mjs +41 -8
  33. package/pipeline/scripts/validate-analysis-doc.mjs +371 -7
  34. package/pipeline/scripts/validate-analysis.mjs +7 -5
  35. package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +1 -0
  36. package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +1 -0
@@ -44,11 +44,20 @@ import { homedir } from "node:os";
44
44
  import { fileURLToPath } from "node:url";
45
45
 
46
46
  const HERE = dirname(fileURLToPath(import.meta.url));
47
- const ROOT = resolve(HERE, "..", "..");
48
47
  const HOME = homedir();
49
48
 
50
- const CMD_DIR = join(ROOT, "pipeline", "commands", "multi-agent");
51
- const CORE_DIR = join(ROOT, "pipeline", "skills", "shared", "core");
49
+ // Two layouts, and the script ships into the second one. From the repo checkout
50
+ // HERE is <repo>/pipeline/scripts, so the authored trees sit one level up. From
51
+ // an install HERE is ~/.claude/scripts and there is no `pipeline/` above it:
52
+ // resolving blind reported "no commands found" and made every caller believe
53
+ // the tree had changed. Try the repo first, fall back to the install.
54
+ const REPO_ROOT = resolve(HERE, "..", "..");
55
+ const REPO_CMD_DIR = join(REPO_ROOT, "pipeline", "commands", "multi-agent");
56
+ const IN_REPO = existsSync(REPO_CMD_DIR);
57
+ const ROOT = IN_REPO ? REPO_ROOT : HOME;
58
+
59
+ const CMD_DIR = IN_REPO ? REPO_CMD_DIR : join(HOME, ".claude", "commands", "multi-agent");
60
+ const CORE_DIR = join(REPO_ROOT, "pipeline", "skills", "shared", "core");
52
61
  const COPILOT_DIR = join(HOME, ".copilot", "skills");
53
62
  const CODEX_DIR = join(HOME, ".codex", "multi-agent-refs", "commands");
54
63
  // The dispatcher is the one skill Codex installs as a SKILL: the 51 sub-commands
@@ -107,11 +116,23 @@ function siblingsOf(name) {
107
116
  );
108
117
  const installedName = name === "multi-agent" ? "multi-agent" : `multi-agent-${name}`;
109
118
 
119
+ // The two authored surfaces exist only in the checkout. From an install they
120
+ // are not missing, they are not applicable, and counting them as absent would
121
+ // report every command as 3/5 forever - a number that reads as damage and is
122
+ // really just the layout. In an install CMD_DIR is also the claude tree, so
123
+ // listing "authored" there would double-count one file as two surfaces.
124
+ const authoredSurfaces = IN_REPO
125
+ ? [
126
+ { tree: "command (authored)", path: authored, exists: existsSync(authored), inRepo: true },
127
+ { tree: "shared core skill", path: shared, exists: existsSync(shared), inRepo: true },
128
+ ]
129
+ : [];
130
+
110
131
  return {
111
132
  name,
133
+ layout: IN_REPO ? "repo" : "install",
112
134
  surfaces: [
113
- { tree: "command (authored)", path: authored, exists: existsSync(authored), inRepo: true },
114
- { tree: "shared core skill", path: shared, exists: existsSync(shared), inRepo: true },
135
+ ...authoredSurfaces,
115
136
  {
116
137
  tree: "claude (installed)",
117
138
  path: join(CLAUDE_DIR, name === "multi-agent" ? "SKILL.md" : join(name, "SKILL.md")),
@@ -145,11 +166,23 @@ if (AUDIT) {
145
166
  });
146
167
  const broken = rows.filter((r) => r.missingRepo.length > 0);
147
168
  if (JSON_OUT) {
148
- process.stdout.write(JSON.stringify({ commands: names.length, broken, rows }, null, 2) + "\n");
169
+ process.stdout.write(
170
+ JSON.stringify(
171
+ { layout: IN_REPO ? "repo" : "install", commands: names.length, broken, rows },
172
+ null,
173
+ 2,
174
+ ) + "\n",
175
+ );
149
176
  } else {
150
- process.stdout.write(`${names.length} command(s) checked\n`);
177
+ process.stdout.write(`${names.length} command(s) checked (${IN_REPO ? "repo" : "install"})\n`);
151
178
  if (broken.length === 0) {
152
- process.stdout.write(" every command has its authored sibling in the repo\n");
179
+ // From an install there is no authored side to check, and claiming one
180
+ // was checked is the failure this script exists to prevent one level up.
181
+ process.stdout.write(
182
+ IN_REPO
183
+ ? " every command has its authored sibling in the repo\n"
184
+ : " authored siblings not checked - no repo checkout resolved from here\n",
185
+ );
153
186
  }
154
187
  for (const r of broken) {
155
188
  process.stdout.write(` MISSING ${r.name}: ${r.missingRepo.join(", ")}\n`);
@@ -100,11 +100,27 @@ const BANNED_PUNCT = [
100
100
  { ch: "’", name: "curly-single-close" },
101
101
  ];
102
102
 
103
+ // A Section 20 status cell is bilingual (`Açık / Open`, `Karar verildi / Decided`),
104
+ // so openness is decided per token rather than by searching the row. A substring
105
+ // search read "hata açıklaması netleşti" as open, because "açıklama" contains
106
+ // "Açık" - and "açıklama" is one of the most common words in a Turkish spec, so
107
+ // that blocked a correctly closed document. English has the same trap in
108
+ // "reopened" and "opened".
109
+ const OPEN_STATUS = ["açık", "acik", "open", "girdi bekleniyor", "pending input"];
110
+ function isOpenStatus(cell) {
111
+ return String(cell)
112
+ .split("/")
113
+ .some((part) => {
114
+ const t = part.trim().toLowerCase();
115
+ return OPEN_STATUS.some((v) => t === v || t.startsWith(`${v} `) || t.startsWith(`${v}(`));
116
+ });
117
+ }
118
+
103
119
  function readInput() {
104
- const args = process.argv.slice(2).filter((a) => a !== "--strict");
120
+ const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
105
121
  const arg = args[0];
106
122
  if (!arg) {
107
- console.error("usage: validate-analysis-doc.mjs <path|-> [--strict]");
123
+ console.error("usage: validate-analysis-doc.mjs <path|-> [--strict] [--report]");
108
124
  process.exit(64);
109
125
  }
110
126
  if (arg === "-") return readFileSync(0, "utf-8");
@@ -169,11 +185,56 @@ function tableDataRows(bodyLines) {
169
185
  return rows;
170
186
  }
171
187
 
188
+ // The report registry. `--report` prints one line per check, and the reason a
189
+ // check did not run is part of the answer: the traceability matrix is corporate
190
+ // only, so on a global document it never executes - and until this existed the
191
+ // output for "ran, found nothing" and "never ran" was the same string. A summary
192
+ // that cannot tell those apart is the shape of defect this file exists to catch.
193
+ //
194
+ // Attribution is positional: mark() closes the check that was running and takes
195
+ // every error and warning pushed since the previous mark. That keeps the checks
196
+ // themselves untouched, and the totals reconcile by construction - a forgotten
197
+ // mark misnames a finding, it can never lose one.
198
+ //
199
+ // A count therefore OUTRANKS a skip label. Two blocks legitimately push a finding
200
+ // and then mark themselves skipped - a malformed `status` value is caught by the
201
+ // closure gate that then reports "draft document", and the redesign drift warning
202
+ // is raised by the branch that reports "not a redesign". Printing the skip label
203
+ // there dropped a real finding out of the report while stderr still carried it,
204
+ // which is the one thing this registry exists to prevent.
205
+ function makeReporter(errors, warns) {
206
+ const rows = [];
207
+ let e = 0;
208
+ let w = 0;
209
+ return {
210
+ rows,
211
+ mark(id, skipped) {
212
+ const de = errors.length - e;
213
+ const dw = warns.length - w;
214
+ e = errors.length;
215
+ w = warns.length;
216
+ const counts = [de ? `${de} error(s)` : null, dw ? `${dw} warning(s)` : null]
217
+ .filter(Boolean)
218
+ .join(", ");
219
+ const verdict = counts
220
+ ? skipped
221
+ ? `${counts} (otherwise skipped: ${skipped})`
222
+ : counts
223
+ : skipped
224
+ ? `skipped: ${skipped}`
225
+ : "ok";
226
+ rows.push({ id, verdict });
227
+ },
228
+ };
229
+ }
230
+
172
231
  function main() {
173
232
  const text = readInput();
174
233
  const strict = process.argv.includes("--strict");
234
+ const report = process.argv.includes("--report");
175
235
  const errors = [];
176
236
  const warns = [];
237
+ const { rows: checkRows, mark } = makeReporter(errors, warns);
177
238
 
178
239
  // 1. Front-matter
179
240
  const parsed = parseFrontMatter(text);
@@ -198,6 +259,7 @@ function main() {
198
259
  errors.push(`front-matter profile must be global|corporate, got: ${profile}`);
199
260
  }
200
261
  const noPlatform = (parsed?.fm?.platform || "") === "none";
262
+ mark("front-matter");
201
263
 
202
264
  // 2. Never-omitted sections (by heading keyword)
203
265
  // Numbered section headings only. Layer headings (`# Bölüm B - Teknik Analiz`)
@@ -218,8 +280,15 @@ function main() {
218
280
  errors.push(`missing required section (${rule}): ${sec.key}`);
219
281
  }
220
282
  }
283
+ mark("required-sections");
284
+
285
+ // Section 20, resolved once and read twice: the marker check needs its owner
286
+ // column, the closure gate needs its status column.
287
+ let risksRows;
288
+ let risksHeader = null;
289
+ let risksStatusIdx = -1;
221
290
 
222
- // 2a. Corporate profile: every EKLENECEK owes an open question, by id.
291
+ // 2a. Every admitted gap owes an open question, by id - in BOTH profiles.
223
292
  //
224
293
  // A placeholder with nobody assigned to resolve it is how "to be filled in"
225
294
  // reaches production. This used to pair a gap with Section 20 by matching the
@@ -228,7 +297,15 @@ function main() {
228
297
  // cases it still could not separate. Stable ids remove the ambiguity the same
229
298
  // way IG / UC / FG already do: the body writes EKLENECEK (AS-07) and Section 20
230
299
  // defines AS-07, and each side is checked against the other.
231
- if (profile === "corporate") {
300
+ //
301
+ // It ran only in the corporate profile until now, which made the global one the
302
+ // profile where an inline marker could point at nothing. Two documents already
303
+ // told it to: Locked 36 says an unstamped gap fails the dispatch gate with no
304
+ // profile clause, and analysis/render.md routes an external-dependency gap to
305
+ // `AS-NN` in Section 20 for every run. The check was the only side that
306
+ // disagreed. Nothing changes for a document that carries no markers: every loop
307
+ // below is empty, so this is free for the documents that never needed it.
308
+ {
232
309
  const lines = text.split("\n");
233
310
  const risksStart = lines.findIndex((l) => /^#{2,3}\s+\d+/.test(l) && /(Riskler|Risks)/.test(l));
234
311
  let risksBlock = "";
@@ -271,6 +348,103 @@ function main() {
271
348
  }
272
349
  }
273
350
 
351
+ // The owner is what separates a plan from a defect, and until now that
352
+ // sentence lived only in analysis/review.md - a model's judgement call. The
353
+ // column is located from the header rather than by index, because the two
354
+ // profiles order Section 20 differently and an index would silently read the
355
+ // wrong cell in one of them.
356
+ const rows = risksBlock.split("\n").filter((l) => /^\s*\|/.test(l));
357
+ const header = rows.find((l) => /(Sahibi|Owner|Kime sorulacak)/i.test(l));
358
+ // Hoisted for the closure gate below, which has to read the SAME rows: the
359
+ // first pipe row in the document carrying an id is often a body table cell
360
+ // (the corporate template writes `| **Request** | EKLENECEK (AS-NN) |`), and
361
+ // judging openness from that row let a final document with live placeholders
362
+ // through. Section 20 is the only place that answers the question.
363
+ risksRows = rows;
364
+ risksHeader = header || rows.find((l) => /(Durum|Status)/i.test(l)) || null;
365
+ if (risksHeader) {
366
+ risksStatusIdx = risksHeader
367
+ .split("|")
368
+ .map((c) => c.trim())
369
+ .findIndex((c) => /(Durum|Status)/i.test(c));
370
+ }
371
+ if (header) {
372
+ const cols = header.split("|").map((c) => c.trim());
373
+ const oi = cols.findIndex((c) => /(Sahibi|Owner|Kime sorulacak)/i.test(c));
374
+ for (const row of rows) {
375
+ if (row === header || /^\s*\|[\s|:-]*\|?\s*$/.test(row)) continue;
376
+ if (!/\bAS-\d{2,3}\b/.test(row)) continue;
377
+ const cells = row.split("|").map((c) => c.trim());
378
+ const owner = cells[oi] || "";
379
+ if (!owner || /^(-|\?|TBD|EKLENECEK|N\/A)$/i.test(owner)) {
380
+ const id = (row.match(/\bAS-\d{2,3}\b/) || ["a row"])[0];
381
+ errors.push(`${id} names no owner in Risks and Open Questions (Locked 36)`);
382
+ }
383
+ }
384
+ } else if (rows.some((l) => /\bAS-\d{2,3}\b/.test(l))) {
385
+ // Only when there is something to own. A Section 20 with no stamped row
386
+ // has no owner to check, and warning there would fire on every document
387
+ // that never needed the column - noise that Phase 4 runs under --strict.
388
+ warns.push(
389
+ "Risks and Open Questions carries AS-NN rows but no owner column; ownership cannot be checked",
390
+ );
391
+ }
392
+ mark("uncertainty-markers");
393
+ }
394
+
395
+ // 2a-2. The closure gate. `status: final` is a publication claim, so it is the
396
+ // one place an open marker is fatal; `draft` reports the count and moves on.
397
+ // The key is optional and absent means draft - REQUIRED_FM deliberately does
398
+ // not list it, because a rule that fails every document written before it is
399
+ // not a rule, it is a migration nobody asked for.
400
+ {
401
+ const status = (parsed?.fm?.status || "draft").toLowerCase();
402
+ if (status !== "draft" && status !== "final") {
403
+ errors.push(`front-matter status must be draft|final, got: ${parsed.fm.status}`);
404
+ } else if (status === "final") {
405
+ const lines = text.split("\n");
406
+ const openMarkers = new Set();
407
+ for (const id of new Set(text.match(/\bAS-\d{2,3}\b/g) || [])) {
408
+ const row = risksRows.find((l) => l !== risksHeader && l.includes(id));
409
+ if (!row) {
410
+ // No Section 20 row at all. The pairing check above already errors on
411
+ // this; under `final` it is also unresolvable, so it stays open.
412
+ openMarkers.add(id);
413
+ continue;
414
+ }
415
+ if (risksStatusIdx >= 0) {
416
+ const cell = row.split("|").map((c) => c.trim())[risksStatusIdx] || "";
417
+ if (isOpenStatus(cell)) openMarkers.add(id);
418
+ } else if (lines.some((l) => l.includes(id) && /\b(EKLENECEK|TBD)\b/.test(l))) {
419
+ // The corporate Section 20 carries no status column, so the body is the
420
+ // signal: an EKLENECEK still stamped with this id is the gap itself,
421
+ // whatever the table says.
422
+ openMarkers.add(id);
423
+ }
424
+ }
425
+ for (const id of [...openMarkers].sort()) {
426
+ errors.push(`status: final but ${id} is still open (Locked 36 closure gate)`);
427
+ }
428
+ const stillOpen =
429
+ risksStatusIdx >= 0
430
+ ? risksRows.filter(
431
+ (l) =>
432
+ l !== risksHeader &&
433
+ isOpenStatus(l.split("|").map((c) => c.trim())[risksStatusIdx] || ""),
434
+ ).length
435
+ : 0;
436
+ if (stillOpen > 0 && openMarkers.size === 0) {
437
+ errors.push(
438
+ `status: final but Risks and Open Questions still has ${stillOpen} unresolved row(s)`,
439
+ );
440
+ }
441
+ }
442
+ mark("closure-gate", status === "final" ? undefined : "draft document");
443
+ }
444
+
445
+ if (profile === "corporate") {
446
+ const lines = text.split("\n");
447
+
274
448
  // 2b. JSON does not fit in a table cell (analysis-template.md Section 9.2 / 9.3).
275
449
  //
276
450
  // One document rendered `Request` as inline JSON with a field note glued to
@@ -289,6 +463,8 @@ function main() {
289
463
  }
290
464
  }
291
465
 
466
+ mark("payload-in-table-cell");
467
+
292
468
  // The traceability matrix is the most expensive thing in the document to get
293
469
  // wrong, because every downstream reader trusts it instead of re-deriving
294
470
  // the IG -> UC -> FG chain themselves. A matrix that merely exists is not
@@ -326,7 +502,13 @@ function main() {
326
502
  }
327
503
  }
328
504
  }
505
+ mark("traceability-matrix");
506
+ } else {
507
+ mark("traceability-matrix", "lite mode");
329
508
  }
509
+ } else {
510
+ mark("payload-in-table-cell", "global profile");
511
+ mark("traceability-matrix", "global profile");
330
512
  }
331
513
 
332
514
  // 2c. Diagram labels keep their diacritics.
@@ -363,6 +545,9 @@ function main() {
363
545
  }
364
546
  }
365
547
  }
548
+ mark("diagram-diacritics");
549
+ } else {
550
+ mark("diagram-diacritics", "document language is not tr");
366
551
  }
367
552
 
368
553
  // 2b. Opt-in coverage sections must be present when the front-matter says so.
@@ -392,6 +577,8 @@ function main() {
392
577
  }
393
578
  }
394
579
 
580
+ mark("optin-coverage-sections");
581
+
395
582
  // 2c. The Test Plan is Phase 3's RED input (phase-3-dev.md step 5b): the tests
396
583
  // written first come from it. Full mode without it hands dev an empty matrix.
397
584
  // It is Section 15 in the global profile and Section 19 in the corporate one;
@@ -427,6 +614,10 @@ function main() {
427
614
  }
428
615
  }
429
616
 
617
+ if (mode !== "full") mark("test-plan", "lite mode");
618
+ else if (noPlatform) mark("test-plan", "no target platform");
619
+ else mark("test-plan");
620
+
430
621
  // 2d. Section 3 carries the diagrams. Omission is legitimate under the
431
622
  // template's conditions, so absence warns; a rendered section with no
432
623
  // mermaid block is empty scaffolding and fails.
@@ -444,6 +635,8 @@ function main() {
444
635
  );
445
636
  }
446
637
 
638
+ mark("flow-chart");
639
+
447
640
  // 2e. Locked 16: every Files-to-Add row is tagged. The decision says untagged
448
641
  // rows fail the dispatch gate, so this is where that sentence becomes true.
449
642
  const allLines = text.split("\n");
@@ -459,6 +652,8 @@ function main() {
459
652
  }
460
653
  }
461
654
 
655
+ mark("files-to-add-tags", filesBody ? undefined : "no Files-to-Add section");
656
+
462
657
  // 2f. Locked 24: every Pass B concept-table cell carries its footnote.
463
658
  const conceptBody = sectionBody(allLines, ["Kavram tablosu", "Concept table"], 3);
464
659
  if (conceptBody) {
@@ -475,6 +670,8 @@ function main() {
475
670
  }
476
671
  }
477
672
 
673
+ mark("concept-table-evidence", conceptBody ? undefined : "no concept table");
674
+
478
675
  // 2g. Locked 31 main clause: a rule with no test scenario fails the gate.
479
676
  // The pre-existing occurrence-count heuristic below stays as the looser net.
480
677
  if (mode === "full") {
@@ -496,6 +693,145 @@ function main() {
496
693
  }
497
694
  }
498
695
 
696
+ mark("rule-to-test", mode === "full" ? undefined : "lite mode");
697
+
698
+ // 2h. Redesign mode (Locked 37). Eight checks, live only when the front-matter
699
+ // opts in. The contract they enforce is analysis/redesign.md, which loads only
700
+ // on a redesign run; what is here is the enforcement, not the explanation.
701
+ const redesign = String(parsed?.fm?.redesign || "false").toLowerCase() === "true";
702
+ const cbBody = sectionBody(
703
+ allLines,
704
+ ["Mevcut Davranış", "Mevcut Davranis", "Current Behaviour", "Current Behavior"],
705
+ 3,
706
+ );
707
+ const epBody = sectionBody(
708
+ allLines,
709
+ ["Endpoint Eşlemesi", "Endpoint Eslemesi", "Endpoint Mapping"],
710
+ 3,
711
+ );
712
+ const dlBody = sectionBody(allLines, ["Fark Listesi", "Difference List"], 3);
713
+ if (redesign) {
714
+ // 1. The sections themselves. Without this every row check below passes over
715
+ // nothing, which is the specific defect that shows a gate green on an empty
716
+ // document - the same shape the corporate matrix check guards against.
717
+ for (const [body, name] of [
718
+ [cbBody, "4.5 Current Behaviour"],
719
+ [epBody, "4.6 Endpoint Mapping"],
720
+ [dlBody, "9.5 Difference List"],
721
+ ]) {
722
+ if (!body) errors.push(`redesign: true but Section ${name} is missing (Locked 37)`);
723
+ }
724
+
725
+ const STATUSES = ["Moved", "Partial", "Missing", "New", "Out of scope"];
726
+ const CB_ID = /\bCB-[a-z0-9]+(?:-[a-z0-9]+)*-\d+/gi;
727
+ const cbRows = cbBody ? tableDataRows(cbBody) : [];
728
+ const dlRows = dlBody ? tableDataRows(dlBody) : [];
729
+
730
+ for (const row of cbRows) {
731
+ const cells = row.split("|").map((c) => c.trim());
732
+ const id = (row.match(CB_ID) || ["a row"])[0];
733
+ const evidence = cells[3] || "";
734
+ const certainty = (cells[4] || "").toLowerCase();
735
+ // 2. An unmarked guess. Prose can only ask an author to mark it; this finds
736
+ // the row that was not marked.
737
+ if (!/:\d+\b/.test(evidence) && !certainty.includes("uncertain")) {
738
+ errors.push(
739
+ `current-behaviour row ${id} cites no file:line and is not marked uncertain (Locked 37)`,
740
+ );
741
+ }
742
+ // 3. A rule that lives only in a shared module legitimately reads this way,
743
+ // so it warns rather than blocks - and Phase 4 runs --strict, so it blocks
744
+ // there, which is where a human is already looking.
745
+ if (/cross-cutting/i.test(evidence) && certainty.includes("confirmed")) {
746
+ warns.push(
747
+ `current-behaviour row ${id} is confirmed on cross-cutting evidence alone; name the file:line or mark it uncertain`,
748
+ );
749
+ }
750
+ }
751
+
752
+ for (const row of dlRows) {
753
+ const cells = row.split("|").map((c) => c.trim());
754
+ const id = (row.match(CB_ID) || ["a row"])[0];
755
+ const statusCell = cells[2] || "";
756
+ const status = STATUSES.find((v) => new RegExp(`\\b${v}\\b`, "i").test(statusCell));
757
+ // 4. A controlled vocabulary decaying into free text.
758
+ if (!status) {
759
+ errors.push(
760
+ `difference-list row ${id} has status "${statusCell}"; expected one of ${STATUSES.join(" / ")} (Locked 37)`,
761
+ );
762
+ continue;
763
+ }
764
+ // 5. The reason the mode exists: a behaviour v2 drops with no decision.
765
+ if ((status === "Missing" || status === "Partial") && !/\bAS-\d{2,3}\b/.test(row)) {
766
+ errors.push(
767
+ `difference-list row ${id} is ${status} with no AS-NN open question (Locked 37)`,
768
+ );
769
+ }
770
+ }
771
+
772
+ // 6. Both directions. A CB- id in the code and on nobody's difference list is
773
+ // the thing a redesign loses and production finds; an id on the list that no
774
+ // behaviour table defines is a row about nothing.
775
+ if (cbBody && dlBody) {
776
+ const defined = new Set(
777
+ cbRows.flatMap((r) => (r.match(CB_ID) || []).map((x) => x.toUpperCase())),
778
+ );
779
+ const listed = new Set(
780
+ dlRows.flatMap((r) => (r.match(CB_ID) || []).map((x) => x.toUpperCase())),
781
+ );
782
+ for (const id of defined) {
783
+ if (!listed.has(id))
784
+ errors.push(`${id} is in current behaviour but not in the difference list`);
785
+ }
786
+ for (const id of listed) {
787
+ if (!defined.has(id))
788
+ errors.push(`${id} is in the difference list but no behaviour defines it`);
789
+ }
790
+ }
791
+
792
+ // 7. A renderer that adds a column would silently disable the row checks
793
+ // above, so the shape is asserted before the rows are trusted.
794
+ if (epBody) {
795
+ const header = epBody.find((l) => /^\s*\|/.test(l) && /\bv1\b/i.test(l));
796
+ // GFM makes the trailing pipe optional, so a raw segment count reported a
797
+ // perfectly valid row as short. Strip the outer pipes on both sides first.
798
+ const cellCount = (line) =>
799
+ line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").length;
800
+ if (!header) {
801
+ errors.push("Section 4.6 has no v1/v2 header row (Locked 37)");
802
+ } else {
803
+ const cols = cellCount(header);
804
+ for (const row of tableDataRows(epBody)) {
805
+ if (cellCount(row) !== cols) {
806
+ errors.push(`endpoint-mapping row has ${cellCount(row)} cells, header has ${cols}`);
807
+ }
808
+ }
809
+ }
810
+ }
811
+ mark("redesign-artifacts");
812
+ } else {
813
+ // 8. Drift: the sections are rendered but the front-matter does not claim the
814
+ // mode, so none of the seven checks above ran over them.
815
+ //
816
+ // The heading match alone is not enough to say that. sectionBody tests the
817
+ // keyword as a raw substring of the heading line - deliberately, since Locked
818
+ // 2 re-flows section numbers and the corporate profile numbers them
819
+ // differently - so an unrelated `### 4.9 Notes on the old Current Behaviour
820
+ // audit process` matched, and Phase 4 runs --strict, which turns this warning
821
+ // into a blocked document. The artefact itself is the honest signal: a
822
+ // rendered current-behaviour table always carries `CB-<slug>-NN` ids, and a
823
+ // paragraph that merely uses the words never does.
824
+ const looksRendered = /\bCB-[a-z0-9]+(?:-[a-z0-9]+)*-\d+/i.test(
825
+ [...(cbBody || []), ...(dlBody || [])].join("\n"),
826
+ );
827
+ if (looksRendered) {
828
+ warns.push(
829
+ "redesign sections are present but front-matter does not say redesign: true, so none of their checks ran",
830
+ );
831
+ }
832
+ mark("redesign-artifacts", "not a redesign");
833
+ }
834
+
499
835
  // 3. Humanizer punctuation. Scope mirrors the humanizer pass exactly
500
836
  // (analysis/render.md): front-matter, fenced code blocks, table rows and
501
837
  // URLs are exempt there, so flagging them here would raise an ERROR that no
@@ -517,6 +853,8 @@ function main() {
517
853
  }
518
854
  }
519
855
 
856
+ mark("punctuation");
857
+
520
858
  // 4. Placeholder whole-section bodies (Locked 2).
521
859
  // Not a defect in the corporate profile: there N/A and EKLENECEK are how a
522
860
  // backbone section reports "considered, nothing to state" (Locked 33), and the
@@ -527,6 +865,8 @@ function main() {
527
865
  );
528
866
  }
529
867
 
868
+ mark("placeholder-bodies", profile === "global" ? undefined : "corporate profile");
869
+
530
870
  // 5. Traceability (Full mode only, Locked 31)
531
871
  if (mode === "full") {
532
872
  const idRe = /BR-[a-z0-9]+(?:-[a-z0-9]+)*-\d+/gi;
@@ -548,6 +888,8 @@ function main() {
548
888
  }
549
889
  }
550
890
 
891
+ mark("br-reference-count", mode === "full" ? undefined : "lite mode");
892
+
551
893
  // 6. Manual test scenarios (Section 15.7): every MT row carries a BR id.
552
894
  // A manual scenario nobody can trace back to a rule is a scenario nobody can
553
895
  // tell is still correct when the rule changes.
@@ -559,6 +901,8 @@ function main() {
559
901
  }
560
902
  }
561
903
 
904
+ mark("manual-scenario-ids");
905
+
562
906
  // 7. Variant coverage (Section 6.X): a row that lists an axis must say what
563
907
  // the whole axis is AND which values this screen uses. Half a row cannot be
564
908
  // audited - "used subset" means nothing without the set it is a subset of.
@@ -585,21 +929,41 @@ function main() {
585
929
  }
586
930
  }
587
931
 
932
+ mark("variant-coverage");
933
+
588
934
  // Report
935
+ if (report) {
936
+ const width = Math.max(...checkRows.map((r) => r.id.length));
937
+ console.log(
938
+ `validate-analysis-doc: ${checkRows.length} check(s), mode=${mode}, profile=${profile}`,
939
+ );
940
+ // The provenance the reader needs to compare two reports. A timestamp cannot
941
+ // separate two runs on the same day; the digest says the evidence moved and
942
+ // base_commit says the repo did. Both are already in the front-matter, on
943
+ // the side only a machine reads - this is the human-readable copy.
944
+ const digest = parsed?.fm?.evidence_digest;
945
+ const commit = parsed?.fm?.base_commit;
946
+ if (digest || commit) {
947
+ console.log(` evidence_digest ${digest || "absent"} base_commit ${commit || "absent"}`);
948
+ }
949
+ for (const r of checkRows) console.log(` ${r.id.padEnd(width)} ${r.verdict}`);
950
+ console.log("");
951
+ }
589
952
  for (const w of warns) console.error(`WARN: ${w}`);
590
953
  for (const e of errors) console.error(`ERROR: ${e}`);
591
954
  if (errors.length > 0) {
592
955
  console.error(
593
956
  `validate-analysis-doc: FAIL (${errors.length} error(s), ${warns.length} warning(s))`,
594
957
  );
595
- process.exit(1);
958
+ process.exitCode = 1;
959
+ return;
596
960
  }
597
961
  if (strict && warns.length > 0) {
598
962
  console.error(`validate-analysis-doc: FAIL under --strict (${warns.length} warning(s))`);
599
- process.exit(1);
963
+ process.exitCode = 1;
964
+ return;
600
965
  }
601
966
  console.log(`validate-analysis-doc: OK (mode=${mode}, ${warns.length} warning(s))`);
602
- process.exit(0);
603
967
  }
604
968
 
605
969
  main();
@@ -55,7 +55,8 @@ async function main() {
55
55
  parsed = JSON.parse(raw);
56
56
  } catch (err) {
57
57
  emit({ ok: false, code: 1, errors: [`invalid JSON: ${err.message}`] });
58
- process.exit(1);
58
+ process.exitCode = 1;
59
+ return;
59
60
  }
60
61
 
61
62
  const errors = [];
@@ -63,7 +64,8 @@ async function main() {
63
64
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
64
65
  errors.push("root must be an object");
65
66
  emit({ ok: false, code: 1, errors });
66
- process.exit(1);
67
+ process.exitCode = 1;
68
+ return;
67
69
  }
68
70
 
69
71
  // stack
@@ -124,11 +126,11 @@ async function main() {
124
126
 
125
127
  if (errors.length > 0) {
126
128
  emit({ ok: false, code: 1, errors });
127
- process.exit(1);
129
+ process.exitCode = 1;
130
+ return;
128
131
  }
129
132
 
130
133
  emit({ ok: true, code: 0, errors: [] });
131
- process.exit(0);
132
134
  }
133
135
 
134
136
  function emit(result) {
@@ -137,5 +139,5 @@ function emit(result) {
137
139
 
138
140
  main().catch((err) => {
139
141
  emit({ ok: false, code: 1, errors: [`unhandled: ${err.message}`] });
140
- process.exit(1);
142
+ process.exitCode = 1;
141
143
  });
@@ -4,6 +4,7 @@ language: en
4
4
  description: "List unassigned GitHub issues, pick one, auto-assign, and launch the multi-agent pipeline. Use when a GitHub issue should be picked up and started without knowing its number in advance."
5
5
  user-invocable: true
6
6
  argument-hint: "[autopilot] - optional: run the pipeline without confirmations"
7
+ not-for: jira
7
8
  ---
8
9
 
9
10
  # multi-agent issue - GitHub Issue Picker
@@ -4,6 +4,7 @@ language: en
4
4
  description: "Assess whether a GitHub issue is ready for multi-agent development: fetch it, grade scope / acceptance criteria / repro / design / API / stack readiness, then (after confirm) post the gaps as an issue comment. Read-only on code. Use when deciding whether a GitHub issue is specified well enough to hand to development."
5
5
  user-invocable: true
6
6
  argument-hint: "[#N | repo#N | GitHub issue URL] - optional; with no argument, pick from open issues"
7
+ not-for: review-jira
7
8
  ---
8
9
 
9
10
  # multi-agent review-issue - GitHub issue readiness review