@mmerterden/multi-agent-pipeline 16.28.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 (38) hide show
  1. package/CHANGELOG.md +75 -2
  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 +1 -1
  8. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
  9. package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
  10. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
  12. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
  13. package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
  14. package/pipeline/lib/_jira-auth.sh +99 -0
  15. package/pipeline/lib/analysis-jira-write.sh +203 -0
  16. package/pipeline/lib/issue-fetcher.sh +4 -4
  17. package/pipeline/multi-agent-refs/analysis/render.md +1 -1
  18. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  19. package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
  20. package/pipeline/multi-agent-refs/features/doctor.md +197 -0
  21. package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
  22. package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -7
  23. package/pipeline/multi-agent-refs/picker-contract.md +35 -0
  24. package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
  25. package/pipeline/preferences-template.json +1 -1
  26. package/pipeline/schemas/agent-state.schema.json +5 -0
  27. package/pipeline/schemas/analysis-spec.schema.json +336 -95
  28. package/pipeline/schemas/prefs.schema.json +60 -2
  29. package/pipeline/scripts/analysis-story-tree.mjs +441 -0
  30. package/pipeline/scripts/doctor.mjs +758 -0
  31. package/pipeline/scripts/phase-tracker.sh +97 -17
  32. package/pipeline/scripts/scan-agent-config.sh +48 -10
  33. package/pipeline/scripts/skill-siblings.mjs +1 -1
  34. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
  35. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
  36. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
  37. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
  38. 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",
@@ -1868,6 +1868,64 @@
1868
1868
  "description": "Recording cap. A flow needing longer is a debugging session, not a review artefact."
1869
1869
  }
1870
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
+ }
1871
1929
  }
1872
1930
  }
1873
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();