@mmerterden/multi-agent-pipeline 20.12.0 → 20.13.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 (56) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/README.tr.md +1 -1
  4. package/docs/facts.json +1 -1
  5. package/install/_common.mjs +1 -1
  6. package/install/_plugin-skills.mjs +13 -9
  7. package/install/catalog-history.json +1 -1
  8. package/install/codex.mjs +12 -12
  9. package/manifest.json +59 -53
  10. package/package.json +1 -1
  11. package/pipeline/commands/multi-agent/SKILL.md +1 -0
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +7 -3
  13. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +57 -7
  14. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
  15. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -0
  16. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +1 -0
  17. package/pipeline/commands/multi-agent/doctor/SKILL.md +6 -0
  18. package/pipeline/commands/multi-agent/feedback/SKILL.md +2 -2
  19. package/pipeline/commands/multi-agent/forget/SKILL.md +1 -0
  20. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -0
  21. package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
  22. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
  23. package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +1 -0
  24. package/pipeline/commands/multi-agent/review/SKILL.md +5 -0
  25. package/pipeline/commands/multi-agent/review-analysis/SKILL.md +7 -2
  26. package/pipeline/lib/analysis-jira-write.sh +81 -27
  27. package/pipeline/lib/analysis-quality.mjs +261 -0
  28. package/pipeline/lib/analysis-sections.mjs +105 -0
  29. package/pipeline/lib/jira-epic-link.sh +54 -0
  30. package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
  31. package/pipeline/multi-agent-refs/analysis/redesign.md +3 -1
  32. package/pipeline/multi-agent-refs/analysis/render.md +20 -21
  33. package/pipeline/multi-agent-refs/analysis/resolve.md +1 -1
  34. package/pipeline/multi-agent-refs/analysis/review.md +16 -3
  35. package/pipeline/multi-agent-refs/analysis/synthesis.md +10 -2
  36. package/pipeline/multi-agent-refs/analysis-template-corporate.md +44 -9
  37. package/pipeline/multi-agent-refs/analysis-template.md +21 -14
  38. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -0
  39. package/pipeline/multi-agent-refs/features/analysis-jira.md +69 -19
  40. package/pipeline/schemas/analysis-spec.schema.json +22 -0
  41. package/pipeline/schemas/prefs.schema.json +22 -1
  42. package/pipeline/scripts/analysis-story-body.mjs +210 -0
  43. package/pipeline/scripts/analysis-story-tree.mjs +73 -12
  44. package/pipeline/scripts/analysis-tickets-writeback.mjs +101 -0
  45. package/pipeline/scripts/build-references.mjs +14 -1
  46. package/pipeline/scripts/confluence-readback.mjs +148 -0
  47. package/pipeline/scripts/jira-wiki-escape.mjs +40 -0
  48. package/pipeline/scripts/validate-analysis-doc.mjs +120 -70
  49. package/pipeline/skills/.skill-manifest.json +8 -8
  50. package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +6 -1
  51. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +56 -6
  52. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +6 -0
  53. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +6 -0
  54. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -0
  55. package/pipeline/skills/shared/core/multi-agent-review-analysis/SKILL.md +5 -0
  56. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -0
@@ -57,6 +57,28 @@
57
57
  "default": "global",
58
58
  "description": "Analysis profile chosen at Phase 0 Step 1b (Locked 31). 'global' renders analysis-template.md (23-section development handoff); 'corporate' renders analysis-template-corporate.md (IG/UC/FG requirements document). Both read the same evidence; only the projection differs."
59
59
  },
60
+ "edgeCandidates": {
61
+ "type": "array",
62
+ "description": "Failure paths proposed from the evidence (refusals, error codes, timeouts, empty or expired states), shown at the Pass B preview. Only accepted ones enter the document; a candidate needs a source.",
63
+ "items": {
64
+ "type": "object",
65
+ "additionalProperties": false,
66
+ "required": ["target", "candidate", "source"],
67
+ "properties": {
68
+ "target": {
69
+ "type": "string",
70
+ "description": "Use case or rule group the candidate belongs to, e.g. UC-001 or BR-login."
71
+ },
72
+ "candidate": { "type": "string", "minLength": 1 },
73
+ "source": {
74
+ "type": "string",
75
+ "minLength": 1,
76
+ "description": "Where the evidence states it: a spec path, a page, a file:line."
77
+ },
78
+ "accepted": { "type": "boolean" }
79
+ }
80
+ }
81
+ },
60
82
  "options": {
61
83
  "type": "object",
62
84
  "additionalProperties": false,
@@ -948,7 +948,7 @@
948
948
  },
949
949
  "titleFormat": {
950
950
  "type": "string",
951
- "description": "Page-title pattern, e.g. \"{prefix}{module}_{flow}_{suffix}\". Placeholders are resolved at emit time."
951
+ "description": "Page-title pattern, e.g. \"{status}{prefix}{module}_{flow}_{suffix}\". Placeholders are resolved at emit time; {status} is the draft marker while the document is not final, and empty once it is. Without {status} the marker is prepended."
952
952
  },
953
953
  "titlePrefix": {
954
954
  "type": "string",
@@ -2107,6 +2107,27 @@
2107
2107
  "type": "string",
2108
2108
  "default": "ma-analysis",
2109
2109
  "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."
2110
+ },
2111
+ "epicLinkMode": {
2112
+ "type": "string",
2113
+ "enum": ["auto", "parent", "epicLinkField", "none"],
2114
+ "default": "auto",
2115
+ "description": "How a story joins an existing epic. auto: parent on Cloud, the epic-link field (found by its schema type) on Server and Data Center, nothing when neither exists."
2116
+ },
2117
+ "epicLinkFieldId": {
2118
+ "type": ["string", "null"],
2119
+ "default": null,
2120
+ "description": "The epic-link custom field id, for epicLinkField mode or to skip discovery. Null means discover."
2121
+ },
2122
+ "cloneByChannel": {
2123
+ "type": "boolean",
2124
+ "default": false,
2125
+ "description": "Create one linked story per channel in channelComponents, each carrying that channel's component."
2126
+ },
2127
+ "cloneLinkType": {
2128
+ "type": "string",
2129
+ "default": "Relates",
2130
+ "description": "The link type between a story and its channel clones, by this site's name."
2110
2131
  }
2111
2132
  }
2112
2133
  },
@@ -0,0 +1,210 @@
1
+ /**
2
+ * analysis-story-body.mjs - the description of a story planned from an
3
+ * analysis document.
4
+ *
5
+ * Everything in the body comes from the document: the user story, the
6
+ * acceptance criteria of the story's own requirement ids, the redesign rows that
7
+ * touch its section, and the links to the analysis page, the design and the API
8
+ * spec. A missing piece stays missing and is reported as a warning; nothing is
9
+ * invented. Document text is escaped before it is placed in wiki markup, so it
10
+ * cannot become a macro, a mention, an image or a link.
11
+ *
12
+ * @module pipeline/scripts/analysis-story-body
13
+ */
14
+
15
+ import { escapeJiraWiki, wikiLink } from "./jira-wiki-escape.mjs";
16
+ import { redesignSections } from "../lib/analysis-sections.mjs";
17
+
18
+ const MAX_CRITERIA = 8;
19
+ const MAX_DESIGN_LINKS = 5;
20
+
21
+ // A criterion that describes a refusal, an error or an alternative path. One is
22
+ // expected per story, so the tester has something that must NOT happen.
23
+ const NEGATIVE =
24
+ /(?<![\p{L}])(not|never|cannot|error|errors|fails?|failed|invalid|reject\w*|denied|timeout|hata\w*|geçersiz|reddedil\w*|başarısız|olmaz|yapılamaz|bulunamaz\w*)(?![\p{L}])/iu;
25
+
26
+ const FIGMA_URL = /https:\/\/(?:www\.)?figma\.com\/[^\s)|\]]+/g;
27
+ const API_URL = /https?:\/\/[^\s)|\]]*(?:swagger|openapi|api-docs)[^\s)|\]]*/gi;
28
+
29
+ function cells(row) {
30
+ return row
31
+ .trim()
32
+ .replace(/^\||\|$/g, "")
33
+ .split("|")
34
+ .map((c) => c.trim());
35
+ }
36
+
37
+ const unbold = (s) => s.replace(/\*\*/g, "").trim();
38
+
39
+ function headingIndex(lines, pred) {
40
+ return lines.findIndex((l) => /^#{2,4}\s/.test(l) && pred(l));
41
+ }
42
+
43
+ /** Lines under the first heading `pred` accepts, up to the next heading at its level or shallower. */
44
+ function bodyUnder(lines, pred) {
45
+ const start = headingIndex(lines, pred);
46
+ if (start < 0) return [];
47
+ const depth = lines[start].match(/^#+/)[0].length;
48
+ const out = [];
49
+ for (const l of lines.slice(start + 1)) {
50
+ const m = l.match(/^(#+)\s/);
51
+ if (m && m[1].length <= depth) break;
52
+ out.push(l);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ const sectionPred = (number) => (l) =>
58
+ new RegExp(`^#{2,4}\\s+${number.replace(/\./g, "\\.")}\\b`).test(l);
59
+
60
+ /** Value cell of a two-column `| **Field** | value |` row whose field is one of `names`. */
61
+ function fieldValue(lines, names) {
62
+ for (const l of lines) {
63
+ if (!l.trim().startsWith("|")) continue;
64
+ const c = cells(l);
65
+ if (c.length >= 2 && names.some((n) => unbold(c[0]).toLowerCase() === n.toLowerCase())) {
66
+ return c[1] || null;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function rowFor(lines, id) {
73
+ return lines.find((l) => {
74
+ if (!l.trim().startsWith("|")) return false;
75
+ return (cells(l)[0] || "").toUpperCase() === id.toUpperCase();
76
+ });
77
+ }
78
+
79
+ const isEmpty = (v) => !v || /^\p{Pd}$/u.test(v.trim());
80
+
81
+ function userStoryOf(story, lines, corporate) {
82
+ if (corporate) {
83
+ const uc = story.group || "";
84
+ const ucLines = uc ? bodyUnder(lines, (l) => l.includes(uc)) : [];
85
+ const desc = fieldValue(ucLines, ["Kısa Açıklama", "Short Description"]);
86
+ if (isEmpty(desc)) return null;
87
+ const actor = fieldValue(ucLines, ["Aktör", "Actor"]);
88
+ return isEmpty(actor) ? desc : `${actor}: ${desc}`;
89
+ }
90
+ const isStory = (l) => /^\s*(As an?\s|Bir\s.+\solarak\b)/i.test(l);
91
+ const own = story.sectionNumber ? bodyUnder(lines, sectionPred(story.sectionNumber)) : [];
92
+ const line = own.find(isStory);
93
+ if (line) return line.trim();
94
+ // Rules often sit in their own sub-section, apart from the story they serve.
95
+ // The user-story section answers only when it holds exactly one story; with
96
+ // several, which one this story belongs to is not guessed.
97
+ const all = bodyUnder(lines, sectionPred("4")).filter(isStory);
98
+ return all.length === 1 ? all[0].trim() : null;
99
+ }
100
+
101
+ function acceptanceOf(story, lines, corporate) {
102
+ const out = [];
103
+ for (const id of story.sourceIds || []) {
104
+ const row = rowFor(lines, id);
105
+ if (!row) continue;
106
+ const c = cells(row);
107
+ const value = corporate ? c[1] : !isEmpty(c[3]) ? c[3] : c[1];
108
+ if (!isEmpty(value)) out.push(value);
109
+ }
110
+ if (corporate && story.group) {
111
+ const ucLines = bodyUnder(lines, (l) => l.includes(story.group));
112
+ for (const names of [
113
+ ["Alternatif Akış", "Alternative Flow"],
114
+ ["İstisna Akışı", "Exception Flow"],
115
+ ]) {
116
+ const v = fieldValue(ucLines, names);
117
+ if (!isEmpty(v)) out.push(v);
118
+ }
119
+ }
120
+ return [...new Set(out)].slice(0, MAX_CRITERIA);
121
+ }
122
+
123
+ function currentVsTargetOf(story, lines, profile, redesign) {
124
+ if (!redesign || !story.sectionNumber) return [];
125
+ const rd = redesignSections(lines, profile);
126
+ if (!rd.dl.body) return [];
127
+ const rows = rd.dl.body.filter((l) => /^\s*\|\s*CB-/i.test(l));
128
+ return rows
129
+ .map(cells)
130
+ .filter((c) => (c[2] || "").startsWith(story.sectionNumber))
131
+ .map((c) => ({ id: c[0], status: c[1], where: c[2] }));
132
+ }
133
+
134
+ function linksOf(story, lines, ctx) {
135
+ const links = [];
136
+ if (ctx.pageUrl) links.push({ kind: "analysis", label: "Analysis", url: ctx.pageUrl });
137
+ const scope = [
138
+ ...(story.sectionNumber ? bodyUnder(lines, sectionPred(story.sectionNumber)) : []),
139
+ ...(story.group ? bodyUnder(lines, (l) => l.includes(story.group)) : []),
140
+ ].join("\n");
141
+ const design = [...new Set(scope.match(FIGMA_URL) || [])].slice(0, MAX_DESIGN_LINKS);
142
+ design.forEach((url, i) =>
143
+ links.push({ kind: "design", label: design.length > 1 ? `Design ${i + 1}` : "Design", url }),
144
+ );
145
+ const refs = bodyUnder(lines, (l) => /Referans|References/i.test(l)).join("\n");
146
+ const api = [...new Set(refs.match(API_URL) || [])];
147
+ api.forEach((url, i) =>
148
+ links.push({ kind: "api", label: api.length > 1 ? `API spec ${i + 1}` : "API spec", url }),
149
+ );
150
+ return links;
151
+ }
152
+
153
+ function render({ userStory, acceptance, currentVsTarget, links }, sourceIds) {
154
+ const out = [];
155
+ if (userStory) out.push("h3. User story", escapeJiraWiki(userStory), "");
156
+ if (acceptance.length) {
157
+ out.push("h3. Acceptance criteria", ...acceptance.map((a) => `# ${escapeJiraWiki(a)}`), "");
158
+ }
159
+ if (currentVsTarget.length) {
160
+ out.push("h3. Current vs target", "||Id||Status||Where||");
161
+ for (const r of currentVsTarget) {
162
+ out.push(`|${escapeJiraWiki(r.id)}|${escapeJiraWiki(r.status)}|${escapeJiraWiki(r.where)}|`);
163
+ }
164
+ out.push("");
165
+ }
166
+ if (links.length) {
167
+ out.push("h3. Links", ...links.map((l) => `* ${wikiLink(l.label, l.url)}`), "");
168
+ }
169
+ out.push(
170
+ `Sources: ${escapeJiraWiki(sourceIds.length ? sourceIds.join(", ") : "derived from the section heading")}`,
171
+ );
172
+ return out.join("\n");
173
+ }
174
+
175
+ /**
176
+ * @param {{title: string, group?: string, sectionNumber?: string, sourceIds: string[]}} story
177
+ * @param {{text: string, profile?: string, pageUrl?: string|null, redesign?: boolean}} ctx
178
+ */
179
+ export function buildStoryBody(story, ctx) {
180
+ const lines = ctx.text.split("\n");
181
+ const profile = (ctx.profile || "global").toLowerCase();
182
+ const corporate = profile === "corporate";
183
+ const userStory = userStoryOf(story, lines, corporate);
184
+ const acceptance = acceptanceOf(story, lines, corporate);
185
+ const currentVsTarget = currentVsTargetOf(story, lines, profile, ctx.redesign === true);
186
+ const links = linksOf(story, lines, ctx);
187
+
188
+ const warnings = [];
189
+ if (!userStory) warnings.push("no user story found in the document");
190
+ if (acceptance.length < 2)
191
+ warnings.push(`fewer than 2 acceptance criteria (${acceptance.length})`);
192
+ if (!acceptance.some((a) => NEGATIVE.test(a))) warnings.push("no negative acceptance criterion");
193
+
194
+ const body = { userStory, acceptance, currentVsTarget, links };
195
+ return { ...body, warnings, wiki: render(body, story.sourceIds || []) };
196
+ }
197
+
198
+ /**
199
+ * The short description of a role sub-task: its role, its story and its sources.
200
+ *
201
+ * @param {string} role
202
+ * @param {{title: string, sourceIds: string[]}} story
203
+ */
204
+ export function buildSubtaskBody(role, story) {
205
+ return [
206
+ `Role: ${escapeJiraWiki(role)}`,
207
+ `Story: ${escapeJiraWiki(story.title)}`,
208
+ `Sources: ${escapeJiraWiki((story.sourceIds || []).join(", ") || "derived from the section heading")}`,
209
+ ].join("\n");
210
+ }
@@ -49,12 +49,13 @@
49
49
  * field in the JSON, and the exit code is what it was.
50
50
  *
51
51
  * Usage:
52
- * analysis-story-tree.mjs <analysis.md> [--json] [--prefs FILE]
52
+ * analysis-story-tree.mjs <analysis.md> [--json] [--brief] [--prefs FILE] [--page-url URL]
53
53
  *
54
54
  * Exit: 0 planned, 2 nothing to plan, 3 usage, 4 the document is not issue-ready.
55
55
  */
56
56
 
57
57
  import { readFileSync, existsSync } from "node:fs";
58
+ import { buildStoryBody, buildSubtaskBody } from "./analysis-story-body.mjs";
58
59
  import { createHash } from "node:crypto";
59
60
  import { homedir } from "node:os";
60
61
  import { dirname, join } from "node:path";
@@ -72,12 +73,16 @@ import {
72
73
 
73
74
  const argv = process.argv.slice(2);
74
75
  const JSON_OUT = argv.includes("--json");
76
+ const BRIEF = argv.includes("--brief");
75
77
  const prefsFlag = argv.indexOf("--prefs");
76
78
  const PREFS_PATH =
77
79
  prefsFlag !== -1
78
80
  ? argv[prefsFlag + 1]
79
81
  : join(homedir(), ".claude", "multi-agent-preferences.json");
80
- const file = argv.find((a) => !a.startsWith("--") && a !== PREFS_PATH);
82
+ const pageUrlFlag = argv.indexOf("--page-url");
83
+ const PAGE_URL = pageUrlFlag !== -1 ? argv[pageUrlFlag + 1] : null;
84
+ const valueIdx = new Set([prefsFlag, pageUrlFlag].filter((i) => i !== -1).map((i) => i + 1));
85
+ const file = argv.find((a, i) => !a.startsWith("--") && !valueIdx.has(i));
81
86
 
82
87
  function die(msg, code) {
83
88
  process.stderr.write(`${msg}\n`);
@@ -153,6 +158,10 @@ export function identityLabel(prefix, docId, sourceIds) {
153
158
  * issueTree.channelComponents channel -> this site's component name
154
159
  * issueTree.channelTeams channel -> this site's team name
155
160
  * issueTree.labelPrefix prefix of the identity label
161
+ * issueTree.epicLinkMode how a story joins an existing epic
162
+ * issueTree.epicLinkFieldId the epic-link field, when not discovered
163
+ * issueTree.cloneByChannel one linked story per channel in channelComponents
164
+ * issueTree.cloneLinkType the link type between a story and its clones
156
165
  */
157
166
  const ISSUE_TREE_SETTINGS = [
158
167
  "issueTree.enabled",
@@ -164,6 +173,10 @@ const ISSUE_TREE_SETTINGS = [
164
173
  "issueTree.channelComponents",
165
174
  "issueTree.channelTeams",
166
175
  "issueTree.labelPrefix",
176
+ "issueTree.epicLinkMode",
177
+ "issueTree.epicLinkFieldId",
178
+ "issueTree.cloneByChannel",
179
+ "issueTree.cloneLinkType",
167
180
  ];
168
181
 
169
182
  function readPrefs() {
@@ -234,7 +247,7 @@ export function coverageOf(defined, nodes, corporate = false) {
234
247
  };
235
248
  }
236
249
 
237
- export function plan(text, prefs) {
250
+ export function plan(text, prefs, { pageUrl = null } = {}) {
238
251
  const fm = frontMatter(text);
239
252
  const profile = (fm.profile || "global").toLowerCase();
240
253
  const corporate = profile === "corporate";
@@ -256,12 +269,9 @@ export function plan(text, prefs) {
256
269
  // atoms. A story with no section is still a story - the atoms are the claim,
257
270
  // the heading is only the label.
258
271
  //
259
- // A lite document may carry no ids at all, and its user-story sub-sections are
260
- // still real work. Deriving from those keeps such a run useful AND makes
261
- // `unverifiable` a state that actually occurs: without this fallback the
262
- // no-atom document produced no stories, exited "nothing to plan", and the
263
- // unverifiable branch below was unreachable - a case the code claimed to
264
- // handle and never could.
272
+ // A document may carry no ids at all, and its user-story sub-sections are
273
+ // still real work. Deriving from those keeps such a run useful and gives it
274
+ // an honest `unverifiable` coverage verdict instead of "nothing to plan".
265
275
  //
266
276
  // Corporate atoms are attributed ROW-WISE, never section-wise. The template
267
277
  // states the mapping in a column - the functional-requirement table carries a
@@ -326,6 +336,7 @@ export function plan(text, prefs) {
326
336
  kind: "story",
327
337
  title: best && best.n > 0 ? `${best.s.number} ${best.s.title}` : g,
328
338
  group: g,
339
+ sectionNumber: best && best.n > 0 ? best.s.number : null,
329
340
  sourceIds: ids,
330
341
  label: identityLabel(prefix, docId, [g, ...ids]),
331
342
  subtasks: (prefs.subtaskRoles || []).map((role) => ({
@@ -346,6 +357,7 @@ export function plan(text, prefs) {
346
357
  kind: "story",
347
358
  title: `${sec.number} ${sec.title}`,
348
359
  group: sec.number,
360
+ sectionNumber: sec.number,
349
361
  sourceIds: [],
350
362
  derivedFrom: "section",
351
363
  label: identityLabel(prefix, docId, [sec.number]),
@@ -360,6 +372,34 @@ export function plan(text, prefs) {
360
372
  }
361
373
  }
362
374
 
375
+ const bodyCtx = {
376
+ text,
377
+ profile,
378
+ pageUrl: pageUrl || fm.confluence_url || null,
379
+ redesign: String(fm.redesign || "").toLowerCase() === "true",
380
+ };
381
+ for (const story of stories) {
382
+ const b = buildStoryBody(story, bodyCtx);
383
+ story.description = b.wiki;
384
+ story.body = {
385
+ userStory: b.userStory,
386
+ acceptance: b.acceptance,
387
+ currentVsTarget: b.currentVsTarget,
388
+ links: b.links,
389
+ };
390
+ story.bodyWarnings = b.warnings;
391
+ for (const t of story.subtasks) t.description = buildSubtaskBody(t.role, story);
392
+ const channels = prefs.cloneByChannel ? Object.entries(prefs.channelComponents || {}) : [];
393
+ story.clones = channels.map(([channel, component]) => ({
394
+ kind: "clone",
395
+ channel,
396
+ title: `${story.title} [${channel}]`,
397
+ components: [component],
398
+ label: identityLabel(prefix, docId, [story.group, ...story.sourceIds, `ch:${channel}`]),
399
+ description: story.description,
400
+ }));
401
+ }
402
+
363
403
  const coverage = coverageOf(defined, stories, corporate);
364
404
 
365
405
  return {
@@ -379,9 +419,16 @@ export function plan(text, prefs) {
379
419
  labelPrefix: prefix,
380
420
  channelComponents: prefs.channelComponents ?? {},
381
421
  channelTeams: prefs.channelTeams ?? {},
422
+ epicLinkMode: prefs.epicLinkMode ?? "auto",
423
+ epicLinkFieldId: prefs.epicLinkFieldId ?? null,
424
+ cloneByChannel: prefs.cloneByChannel === true,
425
+ cloneLinkType: prefs.cloneLinkType ?? "Relates",
382
426
  },
383
427
  nodes: stories,
384
- writeCount: stories.reduce((n, s) => n + 1 + s.subtasks.length, 0),
428
+ writeCount: stories.reduce(
429
+ (n, s) => n + 1 + s.subtasks.length + 2 * (s.clones?.length || 0),
430
+ 0,
431
+ ),
385
432
  };
386
433
  }
387
434
 
@@ -407,7 +454,11 @@ function consistencyOf(text, nodes) {
407
454
  }
408
455
 
409
456
  async function main() {
410
- if (!file) return die("usage: analysis-story-tree.mjs <analysis.md> [--json] [--prefs FILE]", 3);
457
+ if (!file)
458
+ return die(
459
+ "usage: analysis-story-tree.mjs <analysis.md> [--json] [--brief] [--prefs FILE] [--page-url URL]",
460
+ 3,
461
+ );
411
462
  if (!existsSync(file)) return die(`analysis-story-tree: no such file: ${file}`, 3);
412
463
  const text = readFileSync(file, "utf8");
413
464
 
@@ -438,7 +489,7 @@ async function main() {
438
489
  return;
439
490
  }
440
491
 
441
- const result = plan(text, prefs);
492
+ const result = plan(text, prefs, { pageUrl: PAGE_URL });
442
493
  if (result.nodes.length === 0) {
443
494
  process.stderr.write("analysis-story-tree: no stories could be derived from this document\n");
444
495
  process.exitCode = 2;
@@ -500,9 +551,19 @@ async function main() {
500
551
  ` sources: ${s.sourceIds.length ? s.sourceIds.join(", ") : "(none - derived from the section heading)"}\n`,
501
552
  );
502
553
  process.stdout.write(` label: ${s.label}\n`);
554
+ for (const w of s.bodyWarnings || []) process.stdout.write(` body warning: ${w}\n`);
555
+ if (!BRIEF && s.description) {
556
+ process.stdout.write(" description:\n");
557
+ for (const line of s.description.split("\n")) process.stdout.write(` ${line}\n`);
558
+ }
503
559
  for (const t of s.subtasks) {
504
560
  process.stdout.write(` subtask ${t.role} [${t.label}]\n`);
505
561
  }
562
+ for (const c of s.clones || []) {
563
+ process.stdout.write(
564
+ ` clone ${c.channel} component ${c.components.join(", ")} [${c.label}]\n`,
565
+ );
566
+ }
506
567
  }
507
568
  // Every field's provenance, so a wrong setting is visible here rather than in
508
569
  // Jira after the writes have happened.
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * analysis-tickets-writeback.mjs - list the tickets a story tree produced in
4
+ * the analysis document it came from.
5
+ *
6
+ * Reads the plan (titles, sources, channels) and the writer's ledger (the key
7
+ * each label was created as, or found as), and writes a `## Tickets` table
8
+ * between `<!-- tickets:start -->` and `<!-- tickets:end -->`. A second run
9
+ * replaces the block, so the table never doubles. The block sits after the last
10
+ * numbered section and carries no number, so canonical numbering is untouched.
11
+ * A node with no key in the ledger is left out: the table lists what exists.
12
+ *
13
+ * Local only. Publishing the updated document is a separate, approved step.
14
+ *
15
+ * Usage:
16
+ * analysis-tickets-writeback.mjs --doc FILE --ledger FILE --plan FILE
17
+ */
18
+
19
+ import { readFileSync, writeFileSync } from "node:fs";
20
+ import { pathToFileURL } from "node:url";
21
+
22
+ const START = "<!-- tickets:start -->";
23
+ const END = "<!-- tickets:end -->";
24
+
25
+ /** Latest key per label, from `created` and `exists` events. */
26
+ export function keysFromLedger(text) {
27
+ const keys = new Map();
28
+ for (const line of text.split("\n")) {
29
+ if (!line.trim()) continue;
30
+ let e;
31
+ try {
32
+ e = JSON.parse(line);
33
+ } catch {
34
+ continue;
35
+ }
36
+ if ((e.event === "created" || e.event === "exists") && e.label && e.key) {
37
+ keys.set(e.label, e.key);
38
+ }
39
+ }
40
+ return keys;
41
+ }
42
+
43
+ const cell = (v) => String(v).replace(/\|/g, "\\|").replace(/\n/g, " ");
44
+
45
+ /** The marked block for this plan and these keys. */
46
+ export function ticketsBlock(plan, keys) {
47
+ const rows = [];
48
+ for (const n of plan.nodes || []) {
49
+ const sources = (n.sourceIds || []).join(", ") || "-";
50
+ if (keys.has(n.label)) rows.push([n.title, keys.get(n.label), sources, "-"]);
51
+ for (const c of n.clones || []) {
52
+ if (keys.has(c.label)) rows.push([c.title, keys.get(c.label), sources, c.channel]);
53
+ }
54
+ }
55
+ const heading =
56
+ plan.coverage?.verdict === "unverifiable" ? "## Tickets (coverage unverified)" : "## Tickets";
57
+ return [
58
+ START,
59
+ heading,
60
+ "",
61
+ "| Story | Key | Sources | Channel |",
62
+ "|---|---|---|---|",
63
+ ...rows.map((r) => `| ${r.map(cell).join(" | ")} |`),
64
+ END,
65
+ ].join("\n");
66
+ }
67
+
68
+ /** The document with its tickets block replaced, or appended when absent. */
69
+ export function withTickets(doc, block) {
70
+ const s = doc.indexOf(START);
71
+ const e = doc.indexOf(END);
72
+ if (s >= 0 && e > s) return doc.slice(0, s) + block + doc.slice(e + END.length);
73
+ return `${doc.replace(/\s*$/, "")}\n\n${block}\n`;
74
+ }
75
+
76
+ function arg(argv, flag) {
77
+ const i = argv.indexOf(flag);
78
+ return i >= 0 ? argv[i + 1] : undefined;
79
+ }
80
+
81
+ function main() {
82
+ const argv = process.argv.slice(2);
83
+ const doc = arg(argv, "--doc");
84
+ const ledger = arg(argv, "--ledger");
85
+ const planFile = arg(argv, "--plan");
86
+ if (!doc || !ledger || !planFile) {
87
+ process.stderr.write(
88
+ "usage: analysis-tickets-writeback.mjs --doc FILE --ledger FILE --plan FILE\n",
89
+ );
90
+ process.exitCode = 64;
91
+ return;
92
+ }
93
+ const plan = JSON.parse(readFileSync(planFile, "utf8"));
94
+ const keys = keysFromLedger(readFileSync(ledger, "utf8"));
95
+ const before = readFileSync(doc, "utf8");
96
+ const after = withTickets(before, ticketsBlock(plan, keys));
97
+ if (after !== before) writeFileSync(doc, after);
98
+ process.stdout.write(`tickets: ${keys.size} key(s) in ${doc}\n`);
99
+ }
100
+
101
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
@@ -13,6 +13,8 @@
13
13
  //
14
14
  // build (default) state JSON -> markdown table on stdout
15
15
  // --check <doc.md> state JSON + emitted doc -> coverage gate, exit 1 on drift
16
+ // --missing-panel the sources the run could not read, as the quoted panel
17
+ // at the top of the document; nothing when all were read
16
18
  //
17
19
  // Usage:
18
20
  // node build-references.mjs state.json [--lang tr|en]
@@ -76,6 +78,7 @@ function rowsFrom(spec, lang) {
76
78
  type,
77
79
  cells: [cell(type), cell(source), cell(url), cell(ref), cell(role), access, cell(notes)],
78
80
  key: keyOf(url, source),
81
+ missing: String(access).startsWith(FAILED[lang]),
79
82
  });
80
83
  };
81
84
 
@@ -338,7 +341,7 @@ function main() {
338
341
  const statePath = argv[0];
339
342
  if (!statePath || statePath.startsWith("--")) {
340
343
  process.stderr.write(
341
- "usage: build-references.mjs <state.json|-> [--lang tr|en] [--check <doc.md>]\n",
344
+ "usage: build-references.mjs <state.json|-> [--lang tr|en] [--check <doc.md>] [--missing-panel]\n",
342
345
  );
343
346
  process.exitCode = 2;
344
347
  return;
@@ -362,6 +365,16 @@ function main() {
362
365
  return;
363
366
  }
364
367
 
368
+ if (argv.includes("--missing-panel")) {
369
+ const missing = rowsFrom(spec, lang).filter((r) => r.missing);
370
+ if (missing.length) {
371
+ const title = lang === "tr" ? "> **Eksik girdiler:**" : "> **Missing inputs:**";
372
+ const items = missing.map((r) => `> - ${r.cells[0]} ${r.cells[1]}: ${r.cells[5]}`);
373
+ process.stdout.write(`${[title, ...items].join("\n")}\n`);
374
+ }
375
+ return;
376
+ }
377
+
365
378
  const docPath = argOf(argv, "--check");
366
379
  if (docPath) {
367
380
  const problems = check(spec, docPath, lang);