@mmerterden/multi-agent-pipeline 16.27.0 → 16.29.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 (65) hide show
  1. package/CHANGELOG.md +144 -1
  2. package/README.md +4 -4
  3. package/README.tr.md +3 -3
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/install/claude.mjs +17 -0
  7. package/package.json +4 -4
  8. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  9. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
  10. package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
  11. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  12. package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
  13. package/pipeline/commands/multi-agent/jira/SKILL.md +3 -0
  14. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  15. package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
  16. package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
  17. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
  18. package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
  19. package/pipeline/lib/_jira-auth.sh +99 -0
  20. package/pipeline/lib/analysis-jira-write.sh +203 -0
  21. package/pipeline/lib/issue-fetcher.sh +138 -9
  22. package/pipeline/lib/multi-repo-pipeline.sh +8 -0
  23. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  24. package/pipeline/multi-agent-refs/analysis/intake.md +11 -2
  25. package/pipeline/multi-agent-refs/analysis/locked.md +1 -0
  26. package/pipeline/multi-agent-refs/analysis/redesign.md +112 -0
  27. package/pipeline/multi-agent-refs/analysis/render.md +6 -1
  28. package/pipeline/multi-agent-refs/analysis/resolve.md +1 -0
  29. package/pipeline/multi-agent-refs/analysis/review.md +15 -0
  30. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  31. package/pipeline/multi-agent-refs/analysis-template-corporate.md +3 -3
  32. package/pipeline/multi-agent-refs/analysis-template.md +36 -0
  33. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -3
  34. package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
  35. package/pipeline/multi-agent-refs/features/doctor.md +197 -0
  36. package/pipeline/multi-agent-refs/features/jira-context.md +101 -0
  37. package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
  38. package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -7
  39. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +1 -1
  40. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +1 -1
  41. package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
  42. package/pipeline/multi-agent-refs/picker-contract.md +35 -0
  43. package/pipeline/multi-agent-refs/readiness-review.md +1 -1
  44. package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
  45. package/pipeline/preferences-template.json +1 -1
  46. package/pipeline/schemas/agent-state.schema.json +24 -0
  47. package/pipeline/schemas/analysis-spec.schema.json +336 -95
  48. package/pipeline/schemas/prefs.schema.json +86 -2
  49. package/pipeline/scripts/analysis-story-tree.mjs +441 -0
  50. package/pipeline/scripts/anonymize-findings.mjs +24 -0
  51. package/pipeline/scripts/build-references.mjs +10 -6
  52. package/pipeline/scripts/council-view.mjs +144 -0
  53. package/pipeline/scripts/doctor.mjs +758 -0
  54. package/pipeline/scripts/phase-tracker.sh +100 -3
  55. package/pipeline/scripts/scan-agent-config.sh +48 -10
  56. package/pipeline/scripts/skill-siblings.mjs +42 -9
  57. package/pipeline/scripts/validate-analysis-doc.mjs +371 -7
  58. package/pipeline/scripts/validate-analysis.mjs +7 -5
  59. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
  60. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
  61. package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +1 -0
  62. package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +1 -0
  63. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
  64. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
  65. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +18 -0
@@ -555,8 +555,8 @@
555
555
  },
556
556
  "fableEnabled": {
557
557
  "type": "boolean",
558
- "default": true,
559
- "description": "Whether the fable rung is available at all on Claude Code. false makes every persona that declares preferredModel: fable dispatch on opus from the first call, with no dispatch attempt on fable and no error to recover from - a cost control, not a fallback. Claude Code only: Copilot CLI does not offer Fable 5, and on Codex CLI the fable rung means gpt-5.6 @ xhigh, which this switch deliberately does not touch. Turning it off also collapses the Phase 4 Claude Code reviewer panel from three to two (Reviewer 1 lands on opus, which Reviewer 2 already holds), and consensus.reviewerCount records 2."
558
+ "default": false,
559
+ "description": "Whether the fable rung is available at all on Claude Code. Ships OFF: the top rung is the most expensive thing a run can reach, and a cost control that is on by default is not a control. false makes every persona that declares preferredModel: fable dispatch on opus from the first call, with no dispatch attempt on fable and no error to recover from - a cost control, not a fallback. Claude Code only: Copilot CLI does not offer Fable 5, and on Codex CLI the fable rung means gpt-5.6 @ xhigh, which this switch deliberately does not touch. Turning it off also collapses the Phase 4 Claude Code reviewer panel from three to two (Reviewer 1 lands on opus, which Reviewer 2 already holds), and consensus.reviewerCount records 2."
560
560
  },
561
561
  "onDispatchError": {
562
562
  "type": "boolean",
@@ -1358,6 +1358,32 @@
1358
1358
  "description": "v12.8+ - Phase 4 Step 1.77 reviewer-scope gate. Decides the reviewer count from the deterministic diff-risk report via `pipeline/scripts/review-scope.mjs`: a diff under 20 lines of churn with max_score < 3.0 and no security_path / migration / public_api / no_test_change / test_lines_removed signal runs ONE reviewer instead of the full CLI-aware set (2 on Claude Code, 3 on Copilot CLI). No LLM. Fails safe in one direction only - any error, empty report or validator rejection resolves to the full set, because skipping a reviewer trades coverage for cost. Set false to force the full set on every diff.",
1359
1359
  "$comment": "Shipped inert for a release: the script existed, was unit- and smoke-tested, and no phase doc referenced it, so every diff paid for the full reviewer set. Wired in Step 1.77; smoke-gate-wiring.sh keeps it reachable."
1360
1360
  },
1361
+ "jiraContext": {
1362
+ "type": "object",
1363
+ "additionalProperties": false,
1364
+ "description": "v16.28+ - when a Jira issue has a parent, the intake fetcher also reads the parent's other sub-tasks in ONE extra search request and exposes them as descriptor.relatedIssues[]. A development sub-task filed with an empty description whose analysis sibling carries the real requirement is the case this exists for. Costs nothing on an issue with no parent, because an issue with no parent has no siblings. The extra round trip lands before the maturity verdict is shown, so turn it off when intake latency matters more than the context.",
1365
+ "properties": {
1366
+ "enabled": {
1367
+ "type": "boolean",
1368
+ "default": true,
1369
+ "description": "Master switch. When false the fetcher never issues the search and relatedIssues[] is always empty."
1370
+ },
1371
+ "maxItems": {
1372
+ "type": "integer",
1373
+ "default": 6,
1374
+ "minimum": 0,
1375
+ "maximum": 20,
1376
+ "description": "Hard cap on relatedIssues[]. Siblings that carry a description are kept first, so the cap drops the empty ones. 0 disables the lookup exactly like enabled: false."
1377
+ },
1378
+ "maxCharsPerItem": {
1379
+ "type": "integer",
1380
+ "default": 1200,
1381
+ "minimum": 0,
1382
+ "maximum": 4000,
1383
+ "description": "Per-sibling description truncation, marked with truncated: true. Worst case payload is maxItems x this, next to the 4000-char cap on the issue's own description."
1384
+ }
1385
+ }
1386
+ },
1361
1387
  "priorArtEnrichment": {
1362
1388
  "type": "object",
1363
1389
  "additionalProperties": false,
@@ -1842,6 +1868,64 @@
1842
1868
  "description": "Recording cap. A flow needing longer is a debugging session, not a review artefact."
1843
1869
  }
1844
1870
  }
1871
+ },
1872
+ "issueTree": {
1873
+ "type": "object",
1874
+ "additionalProperties": false,
1875
+ "description": "v16.29+ - how /multi-agent:analysis-jira turns an analysis document into a Jira story tree. Every site-specific NAME lives in a free-form map value here, never as a schema key: a key is a published literal and this file ships to everyone, so a site's component, team and issue-type names must be values the site fills in. subtaskRoles ships EMPTY on purpose - an empty list is an instruction to go and look at how this board actually splits work, while a ready-made list would be the guess we most want to avoid. subtaskIssueType null means discover it: createmeta returns whichever type carries subtask:true, under whatever name the site gave it. Same rule as features/jira-context.md - the type travels as it comes from Jira and is never a matching criterion in code.",
1876
+ "properties": {
1877
+ "enabled": {
1878
+ "type": "boolean",
1879
+ "default": true
1880
+ },
1881
+ "projectKey": {
1882
+ "type": ["string", "null"],
1883
+ "default": null,
1884
+ "description": "Where the tree is created. Null means ask at preview time."
1885
+ },
1886
+ "epicIssueType": {
1887
+ "type": "string",
1888
+ "default": "Epic"
1889
+ },
1890
+ "storyIssueType": {
1891
+ "type": "string",
1892
+ "default": "Story"
1893
+ },
1894
+ "subtaskIssueType": {
1895
+ "type": ["string", "null"],
1896
+ "default": null,
1897
+ "description": "Null means discover from createmeta rather than assume a name."
1898
+ },
1899
+ "subtaskRoles": {
1900
+ "type": "array",
1901
+ "items": {
1902
+ "type": "string"
1903
+ },
1904
+ "default": [],
1905
+ "description": "The roles each story is split into. Ships empty: mine the board, do not guess."
1906
+ },
1907
+ "channelComponents": {
1908
+ "type": "object",
1909
+ "additionalProperties": {
1910
+ "type": "string"
1911
+ },
1912
+ "default": {},
1913
+ "description": "channel -> this site's component name. Free-form: the key is the channel, the value is the site's own vocabulary."
1914
+ },
1915
+ "channelTeams": {
1916
+ "type": "object",
1917
+ "additionalProperties": {
1918
+ "type": "string"
1919
+ },
1920
+ "default": {},
1921
+ "description": "channel -> this site's team name. Free-form, same reason."
1922
+ },
1923
+ "labelPrefix": {
1924
+ "type": "string",
1925
+ "default": "ma-analysis",
1926
+ "description": "Prefix of the identity label a created issue carries. The label is how a second run, on a second machine or by a second analyst, finds the tree it already made instead of opening a duplicate."
1927
+ }
1928
+ }
1845
1929
  }
1846
1930
  }
1847
1931
  },
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file analysis-story-tree.mjs - an analysis document, read as a work breakdown.
4
+ *
5
+ * WHAT THIS IS
6
+ *
7
+ * It plans a Jira tree from a rendered analysis document and prints the plan.
8
+ * It never writes to Jira; `analysis-jira-write.sh` does that, from this output.
9
+ * The split is the point: planning is deterministic and testable with no network,
10
+ * and the thing that can create issues is small enough to read in one sitting.
11
+ *
12
+ * THE COVERAGE CHECK IS TWO-WAY, AND THE SECOND DIRECTION IS THE USEFUL ONE
13
+ *
14
+ * Forward: every atom in the document (`BR-<slug>-NN` globally, `FG-NN` in the
15
+ * corporate profile) appears in some story, or is listed as uncovered. That
16
+ * catches a dropped requirement.
17
+ *
18
+ * Backward: every id a story cites is actually defined in the document. That
19
+ * catches an INVENTED story - a node with no requirement behind it - which no
20
+ * forward check can see, and which is the failure mode of building a tree from a
21
+ * model's reading rather than from the document's own ids.
22
+ *
23
+ * AN UNVERIFIABLE RUN MUST NOT LOOK VERIFIED
24
+ *
25
+ * A lite global document may carry no `BR` ids at all. Then coverage cannot run,
26
+ * and the verdict is `unverifiable` - not `ok`. It gets its own line in the
27
+ * preview, its own approval option, and its own heading in any write-back. A run
28
+ * that could not be checked is allowed; one that looks checked when it was not
29
+ * is the defect.
30
+ *
31
+ * IDENTITY IS A LABEL, NOT A TITLE
32
+ *
33
+ * Each planned node carries a label derived from a fingerprint of the document
34
+ * id plus the node's source ids. A second run finds its own tree back through
35
+ * JQL on that label. Titles were the obvious key and are the wrong one: they get
36
+ * edited, and matching on them breaks exactly when someone has improved the
37
+ * wording. The label lives server-side, so it also works from a second machine,
38
+ * after `~/.claude` is deleted, and for a SECOND ANALYST - who is precisely the
39
+ * person positioned to open a duplicate tree.
40
+ *
41
+ * Usage:
42
+ * analysis-story-tree.mjs <analysis.md> [--json] [--prefs FILE]
43
+ *
44
+ * Exit: 0 planned, 2 nothing to plan, 3 usage, 4 the document is not issue-ready.
45
+ */
46
+
47
+ import { readFileSync, existsSync } from "node:fs";
48
+ import { createHash } from "node:crypto";
49
+ import { homedir } from "node:os";
50
+ import { join } from "node:path";
51
+
52
+ const argv = process.argv.slice(2);
53
+ const JSON_OUT = argv.includes("--json");
54
+ const prefsFlag = argv.indexOf("--prefs");
55
+ const PREFS_PATH =
56
+ prefsFlag !== -1
57
+ ? argv[prefsFlag + 1]
58
+ : join(homedir(), ".claude", "multi-agent-preferences.json");
59
+ const file = argv.find((a) => !a.startsWith("--") && a !== PREFS_PATH);
60
+
61
+ function die(msg, code) {
62
+ process.stderr.write(`${msg}\n`);
63
+ process.exitCode = code;
64
+ return null;
65
+ }
66
+
67
+ const ATOM_GLOBAL = /\bBR-[a-z0-9]+(?:-[a-z0-9]+)*-\d+\b/gi;
68
+ const ATOM_CORP = /\bFG-\d{2,3}\b/g;
69
+ const GROUP_CORP = /\bUC-\d{2,3}\b/g;
70
+
71
+ function frontMatter(text) {
72
+ const lines = text.split("\n");
73
+ if (lines[0]?.trim() !== "---") return {};
74
+ const end = lines.indexOf("---", 1);
75
+ if (end < 0) return {};
76
+ const fm = {};
77
+ for (let i = 1; i < end; i++) {
78
+ const m = lines[i].match(/^([a-z_]+):\s*(.*)$/);
79
+ if (m) fm[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
80
+ }
81
+ return fm;
82
+ }
83
+
84
+ /** Headings with their bodies, so a story can be scoped to a section. */
85
+ function sections(text) {
86
+ const out = [];
87
+ let cur = null;
88
+ for (const line of text.split("\n")) {
89
+ const h = line.match(/^(#{2,3})\s+(\d+(?:\.\d+)*)\.?\s+(.*)$/);
90
+ if (h) {
91
+ if (cur) out.push(cur);
92
+ cur = { number: h[2], title: h[3].trim(), body: [] };
93
+ continue;
94
+ }
95
+ if (cur) cur.body.push(line);
96
+ }
97
+ if (cur) out.push(cur);
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * The identity label. Derived from the document id, the node's own group and
103
+ * its source ids, so it is stable across re-runs and distinct per node - and it
104
+ * is NOT derived from the title, which is the thing people edit.
105
+ *
106
+ * The group is in the hash because the source ids alone are not an identity:
107
+ * two groups can legitimately cite the same atoms, and a shared label collapses
108
+ * both nodes onto one issue the next time the tree is searched by label.
109
+ */
110
+ export function identityLabel(prefix, docId, sourceIds) {
111
+ const h = createHash("sha256")
112
+ .update(`${docId}${[...sourceIds].sort().join(",")}`)
113
+ .digest("hex")
114
+ .slice(0, 10);
115
+ return `${prefix}-${h}`;
116
+ }
117
+
118
+ /**
119
+ * The settings this file consumes, by their full `parent.leaf` path.
120
+ *
121
+ * Written out rather than destructured because a bare leaf (`enabled`,
122
+ * `projectKey`) is also an ordinary word: a reader cannot tell which settings a
123
+ * file actually reads, and neither can `smoke-prefs-consumed.sh`. Naming the
124
+ * path is what makes "declared" and "consumed" the same list.
125
+ *
126
+ * issueTree.enabled refuse to run when the site turned this off
127
+ * issueTree.projectKey where the tree is created
128
+ * issueTree.epicIssueType the type name for an epic on this site
129
+ * issueTree.storyIssueType the type name for a story
130
+ * issueTree.subtaskIssueType null = discover from createmeta
131
+ * issueTree.subtaskRoles how a story is split; ships empty on purpose
132
+ * issueTree.channelComponents channel -> this site's component name
133
+ * issueTree.channelTeams channel -> this site's team name
134
+ * issueTree.labelPrefix prefix of the identity label
135
+ */
136
+ const ISSUE_TREE_SETTINGS = [
137
+ "issueTree.enabled",
138
+ "issueTree.projectKey",
139
+ "issueTree.epicIssueType",
140
+ "issueTree.storyIssueType",
141
+ "issueTree.subtaskIssueType",
142
+ "issueTree.subtaskRoles",
143
+ "issueTree.channelComponents",
144
+ "issueTree.channelTeams",
145
+ "issueTree.labelPrefix",
146
+ ];
147
+
148
+ function readPrefs() {
149
+ try {
150
+ const j = JSON.parse(readFileSync(PREFS_PATH, "utf8"));
151
+ const g = j?.global || {};
152
+ const out = {};
153
+ for (const path of ISSUE_TREE_SETTINGS) {
154
+ const leaf = path.split(".")[1];
155
+ const v = g.issueTree?.[leaf];
156
+ if (v !== undefined) out[leaf] = v;
157
+ }
158
+ return out;
159
+ } catch {
160
+ return {};
161
+ }
162
+ }
163
+
164
+ /** Every open placeholder that makes a document unfit to become work. */
165
+ export function openItems(text) {
166
+ const found = [];
167
+ for (const [i, line] of text.split("\n").entries()) {
168
+ if (/\bEKLENECEK\b/.test(line)) found.push(`line ${i + 1}: EKLENECEK`);
169
+ else if (/\bTBD\b/.test(line)) found.push(`line ${i + 1}: TBD`);
170
+ else if (/^\s*\|/.test(line) && /(Açık \/ Open|Acik \/ Open|Girdi bekleniyor)/i.test(line))
171
+ found.push(`line ${i + 1}: an open Section 20 row`);
172
+ }
173
+ return found;
174
+ }
175
+
176
+ /**
177
+ * Coverage, in both directions, over a set of defined ids and a set of nodes.
178
+ *
179
+ * Extracted so the backward direction can be tested against ITS OWN code rather
180
+ * than against a re-implementation in a test. The planner cannot produce an
181
+ * invented id - it derives every `sourceIds` from `defined` - so a test that
182
+ * builds one by hand and re-checks it with hand-written logic proves nothing
183
+ * about this function. Calling this with a fabricated node does.
184
+ *
185
+ * The backward direction guards the boundary where a plan arrives from
186
+ * somewhere else: hand-edited, resumed from an older format, or written by
187
+ * something that read the document rather than its ids.
188
+ */
189
+ export function coverageOf(defined, nodes, corporate = false) {
190
+ const covered = new Set(nodes.flatMap((n) => n.sourceIds || []));
191
+ const uncovered = [...defined].filter((id) => !covered.has(id)).sort();
192
+ const invented = [...covered].filter((id) => !defined.has(id)).sort();
193
+ let verdict;
194
+ if (defined.size === 0) {
195
+ // Nothing to check against. Saying "ok" here would be the lie this tool is
196
+ // built to avoid: no atoms means the check did not run, not that it passed.
197
+ verdict = "unverifiable";
198
+ } else if (uncovered.length || invented.length) {
199
+ verdict = "incomplete";
200
+ } else {
201
+ verdict = "ok";
202
+ }
203
+ return {
204
+ verdict,
205
+ atomsDefined: defined.size,
206
+ atomsCovered: covered.size,
207
+ uncovered,
208
+ invented,
209
+ reason:
210
+ verdict === "unverifiable"
211
+ ? `no ${corporate ? "FG-NN" : "BR-<slug>-NN"} ids in the document, so coverage could not be checked`
212
+ : null,
213
+ };
214
+ }
215
+
216
+ export function plan(text, prefs) {
217
+ const fm = frontMatter(text);
218
+ const profile = (fm.profile || "global").toLowerCase();
219
+ const corporate = profile === "corporate";
220
+ const secs = sections(text);
221
+
222
+ const atomRe = corporate ? ATOM_CORP : ATOM_GLOBAL;
223
+ const defined = new Set((text.match(atomRe) || []).map((x) => x.toUpperCase()));
224
+
225
+ // The document's own identity. `evidence_digest` is what changes when the
226
+ // evidence changes, which is exactly when a re-plan should produce new nodes.
227
+ const docId = fm.evidence_digest || fm.feature || "unidentified";
228
+ const prefix = prefs.labelPrefix || "ma-analysis";
229
+
230
+ const groups = corporate
231
+ ? [...new Set(text.match(GROUP_CORP) || [])].sort()
232
+ : [...new Set([...defined].map((id) => id.replace(/-\d+$/, "")))].sort();
233
+
234
+ // One story per group, scoped to the section that defines the most of its
235
+ // atoms. A story with no section is still a story - the atoms are the claim,
236
+ // the heading is only the label.
237
+ //
238
+ // A lite document may carry no ids at all, and its user-story sub-sections are
239
+ // still real work. Deriving from those keeps such a run useful AND makes
240
+ // `unverifiable` a state that actually occurs: without this fallback the
241
+ // no-atom document produced no stories, exited "nothing to plan", and the
242
+ // unverifiable branch below was unreachable - a case the code claimed to
243
+ // handle and never could.
244
+ //
245
+ // Corporate atoms are attributed ROW-WISE, never section-wise. The template
246
+ // states the mapping in a column - the functional-requirement table carries a
247
+ // source-UC cell, and the traceability matrix repeats the chain - so both of
248
+ // those sections necessarily name EVERY use case. Reading attribution from
249
+ // section containment therefore handed every group every atom: three stories
250
+ // with identical sources, identical titles, and, since the label hashes the
251
+ // sources, one identical label, which collapsed the whole tree onto a single
252
+ // issue on the second run while coverage still reported `ok`.
253
+ //
254
+ // No column name is hardcoded. An atom and a group sharing one table row IS
255
+ // the mapping, in whatever language the site writes its headers.
256
+ const rowAtoms = new Map();
257
+ if (corporate) {
258
+ for (const line of text.split("\n")) {
259
+ const gs = line.match(GROUP_CORP);
260
+ if (!gs) continue;
261
+ const as = line.match(ATOM_CORP);
262
+ if (!as) continue;
263
+ for (const g of new Set(gs)) {
264
+ if (!rowAtoms.has(g)) rowAtoms.set(g, new Set());
265
+ for (const a of as) rowAtoms.get(g).add(a.toUpperCase());
266
+ }
267
+ }
268
+ }
269
+
270
+ const stories = [];
271
+ for (const g of groups) {
272
+ let ids;
273
+ if (!corporate) {
274
+ ids = [...defined].filter((id) => id.startsWith(`${g}-`)).sort();
275
+ } else if (rowAtoms.has(g)) {
276
+ ids = [...rowAtoms.get(g)].sort();
277
+ } else {
278
+ // No row states this group's atoms. A document that lists them inside the
279
+ // use-case section instead is still readable; section containment is the
280
+ // fallback, not the rule.
281
+ ids = [
282
+ ...new Set(
283
+ secs
284
+ .filter((s) => s.body.join("\n").includes(g) || s.title.includes(g))
285
+ .flatMap((s) => s.body.join("\n").match(ATOM_CORP) || []),
286
+ ),
287
+ ].sort();
288
+ }
289
+ if (ids.length === 0) continue;
290
+ // The template titles a use-case section with the group's own id, so a title
291
+ // match is the accurate answer where one exists. The hit count is the
292
+ // fallback, and on a consolidated table it names the same section for every
293
+ // group - a cosmetic limit, since the title is a label and the group is the
294
+ // identity.
295
+ const titled = secs.find((s) => s.title.includes(g));
296
+ const best = titled
297
+ ? { s: titled, n: 1 }
298
+ : secs
299
+ .map((s) => ({
300
+ s,
301
+ n: ids.filter((id) => s.body.join("\n").toUpperCase().includes(id)).length,
302
+ }))
303
+ .sort((a, b) => b.n - a.n)[0];
304
+ stories.push({
305
+ kind: "story",
306
+ title: best && best.n > 0 ? `${best.s.number} ${best.s.title}` : g,
307
+ group: g,
308
+ sourceIds: ids,
309
+ label: identityLabel(prefix, docId, [g, ...ids]),
310
+ subtasks: (prefs.subtaskRoles || []).map((role) => ({
311
+ kind: "subtask",
312
+ role,
313
+ title: role,
314
+ sourceIds: ids,
315
+ label: identityLabel(prefix, docId, [g, ...ids, role]),
316
+ })),
317
+ });
318
+ }
319
+
320
+ if (stories.length === 0 && defined.size === 0) {
321
+ const storySections = secs.filter((sec) => /^4\.\d+$/.test(sec.number));
322
+ for (const sec of storySections) {
323
+ if (!sec.body.join("").trim()) continue;
324
+ stories.push({
325
+ kind: "story",
326
+ title: `${sec.number} ${sec.title}`,
327
+ group: sec.number,
328
+ sourceIds: [],
329
+ derivedFrom: "section",
330
+ label: identityLabel(prefix, docId, [sec.number]),
331
+ subtasks: (prefs.subtaskRoles || []).map((role) => ({
332
+ kind: "subtask",
333
+ role,
334
+ title: role,
335
+ sourceIds: [],
336
+ label: identityLabel(prefix, docId, [sec.number, role]),
337
+ })),
338
+ });
339
+ }
340
+ }
341
+
342
+ const coverage = coverageOf(defined, stories, corporate);
343
+
344
+ return {
345
+ document: {
346
+ id: docId,
347
+ profile,
348
+ mode: fm.mode || "full",
349
+ feature: fm.feature || null,
350
+ },
351
+ coverage,
352
+ prefsUsed: {
353
+ projectKey: prefs.projectKey ?? null,
354
+ epicIssueType: prefs.epicIssueType ?? "Epic",
355
+ storyIssueType: prefs.storyIssueType ?? "Story",
356
+ subtaskIssueType: prefs.subtaskIssueType ?? null,
357
+ subtaskRoles: prefs.subtaskRoles ?? [],
358
+ labelPrefix: prefix,
359
+ channelComponents: prefs.channelComponents ?? {},
360
+ channelTeams: prefs.channelTeams ?? {},
361
+ },
362
+ nodes: stories,
363
+ writeCount: stories.reduce((n, s) => n + 1 + s.subtasks.length, 0),
364
+ };
365
+ }
366
+
367
+ function main() {
368
+ if (!file) return die("usage: analysis-story-tree.mjs <analysis.md> [--json] [--prefs FILE]", 3);
369
+ if (!existsSync(file)) return die(`analysis-story-tree: no such file: ${file}`, 3);
370
+ const text = readFileSync(file, "utf8");
371
+
372
+ // The marker gate, before anything else and before any network call. A
373
+ // document with an open placeholder is not a plan, and turning it into a tree
374
+ // publishes the gap as work somebody is now assigned.
375
+ const open = openItems(text);
376
+ if (open.length) {
377
+ process.stderr.write(
378
+ `analysis-story-tree: the document is not issue-ready (${open.length} open item(s))\n`,
379
+ );
380
+ for (const m of open.slice(0, 5)) process.stderr.write(` ${m}\n`);
381
+ process.stderr.write(" run /multi-agent:analysis-resolve to close them first\n");
382
+ process.exitCode = 4;
383
+ return;
384
+ }
385
+
386
+ const prefs = readPrefs();
387
+ // A declared setting that changes nothing is worse than no setting: it reads
388
+ // as a control and is not one.
389
+ if (prefs.enabled === false) {
390
+ process.stderr.write(
391
+ "analysis-story-tree: prefs.global.issueTree.enabled is false on this machine\n",
392
+ );
393
+ process.exitCode = 2;
394
+ return;
395
+ }
396
+
397
+ const result = plan(text, prefs);
398
+ if (result.nodes.length === 0) {
399
+ process.stderr.write("analysis-story-tree: no stories could be derived from this document\n");
400
+ process.exitCode = 2;
401
+ return;
402
+ }
403
+
404
+ if (JSON_OUT) {
405
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
406
+ return;
407
+ }
408
+
409
+ const c = result.coverage;
410
+ process.stdout.write(
411
+ `analysis: ${result.document.feature || result.document.id} (${result.document.profile}, ${result.document.mode})\n`,
412
+ );
413
+ // The verdict gets its own line whatever it is. A reader scanning a preview
414
+ // sees "unverifiable" in the same place they would have seen "ok".
415
+ process.stdout.write(
416
+ `coverage: ${c.verdict}${c.reason ? ` - ${c.reason}` : ` (${c.atomsCovered}/${c.atomsDefined} atoms)`}\n`,
417
+ );
418
+ if (c.uncovered.length) process.stdout.write(` not in any story: ${c.uncovered.join(", ")}\n`);
419
+ if (c.invented.length) process.stdout.write(` cited but undefined: ${c.invented.join(", ")}\n`);
420
+ process.stdout.write(`\n${result.writeCount} issue(s) would be created:\n`);
421
+ for (const s of result.nodes) {
422
+ process.stdout.write(` ${result.prefsUsed.storyIssueType} ${s.title}\n`);
423
+ process.stdout.write(
424
+ ` sources: ${s.sourceIds.length ? s.sourceIds.join(", ") : "(none - derived from the section heading)"}\n`,
425
+ );
426
+ process.stdout.write(` label: ${s.label}\n`);
427
+ for (const t of s.subtasks) {
428
+ process.stdout.write(` subtask ${t.role} [${t.label}]\n`);
429
+ }
430
+ }
431
+ // Every field's provenance, so a wrong setting is visible here rather than in
432
+ // Jira after the writes have happened.
433
+ process.stdout.write("\nfrom prefs.global.issueTree:\n");
434
+ for (const [k, v] of Object.entries(result.prefsUsed)) {
435
+ const shown = typeof v === "object" ? JSON.stringify(v) : String(v);
436
+ process.stdout.write(` ${k.padEnd(20)} ${shown === "null" ? "(not set)" : shown}\n`);
437
+ }
438
+ }
439
+
440
+ const isDirectRun = process.argv[1] && process.argv[1].endsWith("analysis-story-tree.mjs");
441
+ if (isDirectRun) main();
@@ -122,6 +122,30 @@ export function anonymize(input, { seed } = {}) {
122
122
  }
123
123
  });
124
124
 
125
+ // How many DIFFERENT reviewers reported this finding, counted from the
126
+ // fingerprints the validator already stamped. It costs nothing: every
127
+ // reviewer's findings are in this one payload and nowhere else are they
128
+ // together. `count` names no reviewer, so it survives the identity strip
129
+ // without reopening what the strip closed.
130
+ //
131
+ // It is context for triage, never a rule. ADR-0001 rejected majority voting
132
+ // precisely because the same hallucination shows up in two of three
133
+ // same-family reviewers, so a high count is not evidence of truth. The
134
+ // reading that pays is the other end: count 1 is the finding only one
135
+ // reviewer saw, which is where a judge should look hardest.
136
+ const seen = new Map();
137
+ for (const f of tagged) {
138
+ const k = typeof f?.fingerprint === "string" && f.fingerprint ? f.fingerprint : null;
139
+ if (!k) continue;
140
+ if (!seen.has(k)) seen.set(k, new Set());
141
+ seen.get(k).add(f.foundBy);
142
+ }
143
+ const of = reviewers.length;
144
+ for (const f of tagged) {
145
+ const k = typeof f?.fingerprint === "string" && f.fingerprint ? f.fingerprint : null;
146
+ f.corroboration = { count: k ? seen.get(k).size : 1, of };
147
+ }
148
+
125
149
  tagged.sort((a, b) => {
126
150
  const ka = stableKey(a);
127
151
  const kb = stableKey(b);
@@ -338,7 +338,8 @@ function main() {
338
338
  process.stderr.write(
339
339
  "usage: build-references.mjs <state.json|-> [--lang tr|en] [--check <doc.md>]\n",
340
340
  );
341
- process.exit(2);
341
+ process.exitCode = 2;
342
+ return;
342
343
  }
343
344
 
344
345
  let spec;
@@ -346,13 +347,15 @@ function main() {
346
347
  spec = readState(statePath);
347
348
  } catch (err) {
348
349
  process.stderr.write(`ERROR: cannot read state: ${err.message}\n`);
349
- process.exit(2);
350
+ process.exitCode = 2;
351
+ return;
350
352
  }
351
353
 
352
354
  const lang = argOf(argv, "--lang") ?? spec.language ?? "tr";
353
355
  if (!HEADERS[lang]) {
354
356
  process.stderr.write(`ERROR: unknown language "${lang}"; expected tr or en\n`);
355
- process.exit(2);
357
+ process.exitCode = 2;
358
+ return;
356
359
  }
357
360
 
358
361
  const docPath = argOf(argv, "--check");
@@ -361,10 +364,11 @@ function main() {
361
364
  if (problems.length) {
362
365
  for (const p of problems) process.stderr.write(`${p}\n`);
363
366
  process.stderr.write(`\n${problems.length} references coverage failure(s)\n`);
364
- process.exit(1);
367
+ process.exitCode = 1;
368
+ return;
365
369
  }
366
370
  process.stdout.write("references coverage ok\n");
367
- process.exit(0);
371
+ return;
368
372
  }
369
373
 
370
374
  const rows = rowsFrom(spec, lang);
@@ -376,7 +380,7 @@ function main() {
376
380
  ? "Bu koşuda getirilen kaynak yok.\n"
377
381
  : "No sources were fetched for this run.\n",
378
382
  );
379
- process.exit(0);
383
+ return;
380
384
  }
381
385
  process.stdout.write(`${renderTable(rows, lang)}\n`);
382
386
  }