@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.
- package/CHANGELOG.md +75 -2
- package/README.md +4 -4
- package/README.tr.md +3 -3
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/install/claude.mjs +17 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
- package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
- package/pipeline/lib/_jira-auth.sh +99 -0
- package/pipeline/lib/analysis-jira-write.sh +203 -0
- package/pipeline/lib/issue-fetcher.sh +4 -4
- package/pipeline/multi-agent-refs/analysis/render.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
- package/pipeline/multi-agent-refs/features/doctor.md +197 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -7
- package/pipeline/multi-agent-refs/picker-contract.md +35 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
- package/pipeline/preferences-template.json +1 -1
- package/pipeline/schemas/agent-state.schema.json +5 -0
- package/pipeline/schemas/analysis-spec.schema.json +336 -95
- package/pipeline/schemas/prefs.schema.json +60 -2
- package/pipeline/scripts/analysis-story-tree.mjs +441 -0
- package/pipeline/scripts/doctor.mjs +758 -0
- package/pipeline/scripts/phase-tracker.sh +97 -17
- package/pipeline/scripts/scan-agent-config.sh +48 -10
- package/pipeline/scripts/skill-siblings.mjs +1 -1
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
- package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
- 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":
|
|
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();
|