@mmerterden/multi-agent-pipeline 20.25.0 → 20.26.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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +6 -7
  3. package/README.tr.md +6 -7
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +4 -5
  7. package/install/catalog-history.json +1 -1
  8. package/manifest.json +36 -37
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/channels/SKILL.md +4 -2
  11. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  12. package/pipeline/lib/_jira-auth.sh +3 -1
  13. package/pipeline/lib/jira-family-write.sh +339 -0
  14. package/pipeline/lib/jira-publish.sh +28 -1
  15. package/pipeline/multi-agent-refs/channels/jira.md +2 -0
  16. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  17. package/pipeline/multi-agent-refs/features/jira-family.md +175 -0
  18. package/pipeline/multi-agent-refs/help/en.md +0 -1
  19. package/pipeline/multi-agent-refs/help/tr.md +0 -1
  20. package/pipeline/multi-agent-refs/phases/phase-0-init.md +3 -1
  21. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +0 -2
  22. package/pipeline/schemas/agent-state.schema.json +36 -5
  23. package/pipeline/schemas/phases.json +0 -4
  24. package/pipeline/schemas/prefs.schema.json +23 -23
  25. package/pipeline/scripts/jira-family.mjs +947 -0
  26. package/pipeline/scripts/jira-wiki-escape.mjs +3 -3
  27. package/pipeline/skills/.skill-manifest.json +5 -9
  28. package/pipeline/skills/.skills-index.json +1 -12
  29. package/pipeline/skills/shared/README.md +3 -4
  30. package/pipeline/skills/shared/core/multi-agent/SKILL.md +5 -0
  31. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +7 -0
  32. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  33. package/pipeline/skills/skills-index.md +1 -2
  34. package/pipeline/commands/multi-agent/deps/SKILL.md +0 -61
  35. package/pipeline/multi-agent-refs/features/dep-bump.md +0 -176
  36. package/pipeline/scripts/dep-bump.mjs +0 -1556
  37. package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +0 -60
@@ -0,0 +1,947 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * jira-family.mjs - the issue family of a run that started from a Jira link,
4
+ * and the writes the run owes it.
5
+ *
6
+ * A run can start from a development sub-task or from its parent story. Either
7
+ * way the work touches three issues: the story (whose description should be in
8
+ * the story format `/multi-agent:analysis-jira` writes), the development task
9
+ * (which gets the Phase 5 comment) and the test sub-task (which gets the test
10
+ * scenarios). This file is the deterministic half: it resolves the family from
11
+ * the issue JSON, checks and reformats the story description, builds the test
12
+ * comment and plans the writes. `lib/jira-family-write.sh` fetches and writes.
13
+ *
14
+ * A reformat never invents text. The existing lines are kept verbatim and only
15
+ * wrapped into panels and ordered: user story, acceptance criteria, the rest.
16
+ * A heading is added only to a block that had none, and `preserved` is false
17
+ * (and the write is dropped) if any original line would be lost. The author's
18
+ * lines are already Jira markup and pass through unescaped; only the text this
19
+ * file adds (macros, an added heading) is escaped, so the writer sends the body
20
+ * with `jira-publish.sh --raw`.
21
+ *
22
+ * Nothing here asks. An attended run shows every planned write in one grouped
23
+ * confirmation and drops the ones the person deselects (`--skip`); autopilot
24
+ * keeps them all and lists each choice it made in `decisions[]`. The one
25
+ * question left is which development sub-task, when a story has several.
26
+ *
27
+ * Every comment carries a `{anchor:...}` marker keyed on the run id. An anchor
28
+ * renders as nothing, and the writer skips a comment whose marker is already on
29
+ * the issue, so a re-run posts nothing twice.
30
+ *
31
+ * The settings this file consumes, by their full path:
32
+ * issueTree.familyWrites false keeps the single dev comment on the input issue
33
+ * issueTree.storyIssueTypes issue types that are stories (reformatted); empty = storyIssueType
34
+ * issueTree.storyIssueType the site's story type name, "Story" when unset
35
+ * issueTree.testSubtaskTypes sub-task type names that mean "test"; empty = discover
36
+ * issueTree.devSubtaskTypes sub-task type names that mean "development"; empty = match by name
37
+ * issueTree.storyPanels panel colours of the story format, or false for none
38
+ * global.outputLanguage tr or en: headings, questions and the confirmation line
39
+ * global.hosts.jira the host of the dev task link, when --host is not given
40
+ *
41
+ * Usage:
42
+ * jira-family.mjs resolve --input F [--story F] [--site-types F] [--prefs F] [--lang tr|en]
43
+ * jira-family.mjs plan --family F --dev-body F --run-id ID [--mode interactive|autopilot]
44
+ * [--pr-url URL] [--build TEXT] [--host HOST] [--skip id,id]
45
+ * [--dev-task KEY] [--test-task KEY] [--prefs F] [--lang tr|en]
46
+ * jira-family.mjs check --description F [--prefs F] exit 0 conforms, 1 does not
47
+ *
48
+ * Exit: 0 ok, 1 check: does not conform, 2 usage, 3 unreadable input.
49
+ *
50
+ * @module pipeline/scripts/jira-family
51
+ */
52
+
53
+ import { readFileSync } from "node:fs";
54
+ import { homedir } from "node:os";
55
+ import { join } from "node:path";
56
+
57
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
58
+ import { panelColours } from "./analysis-story-body.mjs";
59
+ import { escapeJiraEmoticons, wikiLink } from "./jira-wiki-escape.mjs";
60
+
61
+ const FAMILY_SETTINGS = [
62
+ "issueTree.familyWrites",
63
+ "issueTree.storyIssueTypes",
64
+ "issueTree.storyIssueType",
65
+ "issueTree.testSubtaskTypes",
66
+ "issueTree.devSubtaskTypes",
67
+ "issueTree.storyPanels",
68
+ ];
69
+
70
+ const TEST_TYPE = /\btest\b/i;
71
+ const NOT_TEST_TYPE = /\b(unit|bug|defect|automation)\b/i;
72
+ const looksLikeTest = (name) => TEST_TYPE.test(name) && !NOT_TEST_TYPE.test(name);
73
+ const DEV_TYPE = /\bdev(elop(ment)?)?\b|geliştirme/i;
74
+ const CRITERIA_WORDS = /acceptance|criteria|kabul|kriter|\bAC\b/i;
75
+ const STORY_WORDS = /user story|hikaye|\bstory\b|açıklama|description|amaç|purpose|özet|summary/i;
76
+ const KIND_ORDER = { story: 0, criteria: 1, other: 2 };
77
+ const IMPACT_EXCERPT_MAX = 8;
78
+ const SUMMARY_EXCERPT_MAX = 5;
79
+
80
+ const TEXT = {
81
+ tr: {
82
+ storyHeading: "Kullanıcı Hikayesi",
83
+ otherHeading: "Diğer",
84
+ summary: "Geliştirme Özeti",
85
+ scenarios: "Test Senaryoları",
86
+ impact: "Etki Analizi",
87
+ refs: "Bağlantılar",
88
+ devTask: "Geliştirme görevi",
89
+ writes: "yazma",
90
+ labels: {
91
+ story: "story açıklaması",
92
+ dev: "dev yorumu",
93
+ test: "test yorumu",
94
+ "story-tests": "story'ye test yorumu",
95
+ },
96
+ devQ: "Story'nin birden fazla geliştirme alt görevi var. Geliştirme yorumu hangisine gitsin?",
97
+ testQ: "Story'nin birden fazla test alt görevi var. Test yorumu hangisine gitsin?",
98
+ },
99
+ en: {
100
+ storyHeading: "User story",
101
+ otherHeading: "Details",
102
+ summary: "Development Summary",
103
+ scenarios: "Test Scenarios",
104
+ impact: "Impact Analysis",
105
+ refs: "References",
106
+ devTask: "Development task",
107
+ writes: "writes",
108
+ labels: {
109
+ story: "story description",
110
+ dev: "dev comment",
111
+ test: "test comment",
112
+ "story-tests": "test comment on the story",
113
+ },
114
+ devQ: "The story has more than one development sub-task. Which one gets the development comment?",
115
+ testQ: "The story has more than one test sub-task. Which one gets the test comment?",
116
+ },
117
+ };
118
+
119
+ const SUMMARY_HEADINGS = ["Geliştirme Özeti", "Development Summary"];
120
+ const SCENARIO_HEADINGS = ["Test Senaryoları", "Test Scenarios"];
121
+ const IMPACT_HEADINGS = ["Etki Analizi", "Impact Analysis"];
122
+
123
+ /** tr or en; anything else reads as en. */
124
+ export function language(value) {
125
+ return String(value || "").toLowerCase() === "tr" ? "tr" : "en";
126
+ }
127
+
128
+ function prefsPath() {
129
+ return (
130
+ process.env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json")
131
+ );
132
+ }
133
+
134
+ /** The family settings plus the output language, from a prefs file. Missing file: defaults. */
135
+ export function readFamilyPrefs(path = prefsPath()) {
136
+ let g;
137
+ try {
138
+ g = JSON.parse(readFileSync(path, "utf8"))?.global || {};
139
+ } catch {
140
+ g = {};
141
+ }
142
+ const out = {};
143
+ for (const p of FAMILY_SETTINGS) {
144
+ const leaf = p.split(".")[1];
145
+ const v = g.issueTree?.[leaf];
146
+ if (v !== undefined) out[leaf] = v;
147
+ }
148
+ return {
149
+ familyWrites: out.familyWrites !== false,
150
+ storyIssueTypes:
151
+ Array.isArray(out.storyIssueTypes) && out.storyIssueTypes.length
152
+ ? out.storyIssueTypes
153
+ : [
154
+ typeof out.storyIssueType === "string" && out.storyIssueType
155
+ ? out.storyIssueType
156
+ : "Story",
157
+ ],
158
+ testSubtaskTypes: Array.isArray(out.testSubtaskTypes) ? out.testSubtaskTypes : [],
159
+ devSubtaskTypes: Array.isArray(out.devSubtaskTypes) ? out.devSubtaskTypes : [],
160
+ storyPanels: out.storyPanels,
161
+ lang: language(g.outputLanguage),
162
+ host: typeof g.hosts?.jira === "string" ? g.hosts.jira : null,
163
+ };
164
+ }
165
+
166
+ const lower = (list) => list.map((s) => String(s).toLowerCase());
167
+
168
+ /**
169
+ * The type names that mean "test": the configured list, else the site's own
170
+ * sub-task types (createmeta) whose name carries the word test.
171
+ */
172
+ export function testTypeNames(siteSubtaskTypes, configured) {
173
+ if (Array.isArray(configured) && configured.length) return configured;
174
+ return (siteSubtaskTypes || []).filter(looksLikeTest);
175
+ }
176
+
177
+ /** "test", "dev" or "other" for one sub-task type name. */
178
+ export function classifySubtask(typeName, { testTypes = [], devTypes = [] } = {}) {
179
+ const name = String(typeName || "");
180
+ const isTest = testTypes.length
181
+ ? lower(testTypes).includes(name.toLowerCase())
182
+ : looksLikeTest(name);
183
+ if (isTest) return "test";
184
+ const isDev = devTypes.length
185
+ ? lower(devTypes).includes(name.toLowerCase())
186
+ : DEV_TYPE.test(name);
187
+ return isDev ? "dev" : "other";
188
+ }
189
+
190
+ // --- the story description -------------------------------------------------
191
+
192
+ const WIKI_HEADING = /^\s*h[1-6]\.\s+(.*\S)\s*$/;
193
+ const BOLD_HEADING = /^\s*\*([^*]{1,60})\*\s*:?\s*$/;
194
+ const LABEL_HEADING = /^\s*([\p{L}][\p{L} ]{2,40}):\s*$/u;
195
+ const WHOLE_PANEL = /^\s*\{panel(?::([^}]*))?\}\s*$/;
196
+ const LITERAL_TOKEN = /\{(code|noformat)(?::[^}]*)?\}/g;
197
+ const PAIRED_TOKEN = /\{(quote|color|panel)(:[^}]*)?\}/g;
198
+
199
+ const knownSection = (text) => CRITERIA_WORDS.test(text) || STORY_WORDS.test(text);
200
+
201
+ /**
202
+ * The heading text of a line, or null: an `hN.` heading, or a bold-only or
203
+ * `Label:` line that names a known section (user story, acceptance criteria).
204
+ */
205
+ function headingText(line) {
206
+ const w = line.match(WIKI_HEADING);
207
+ if (w) return w[1].trim();
208
+ const b = line.match(BOLD_HEADING) || line.match(LABEL_HEADING);
209
+ if (b && knownSection(b[1])) return b[1].trim();
210
+ return null;
211
+ }
212
+
213
+ function kindOf(heading) {
214
+ if (CRITERIA_WORDS.test(heading)) return "criteria";
215
+ if (STORY_WORDS.test(heading)) return "story";
216
+ return "other";
217
+ }
218
+
219
+ function borderColour(params) {
220
+ const m = String(params || "").match(/borderColor=(#[0-9a-fA-F]{6})/);
221
+ return m ? m[1].toLowerCase() : null;
222
+ }
223
+
224
+ function panelTitle(params) {
225
+ const m = String(params || "").match(/(?:^|\|)\s*title=([^|]*)/);
226
+ return m && m[1].trim() ? m[1].trim() : null;
227
+ }
228
+
229
+ /**
230
+ * The description as lines, with what the rest of the file needs to know
231
+ * about each: whether it is literal text (`{code}` / `{noformat}` body, any
232
+ * parameters), how deep it sits inside `{quote}` / `{color}` / inline `{panel}`
233
+ * pairs, and whether it is a `{panel}` macro alone on its line outside every
234
+ * such pair. Only those whole-line panels are containers this file may
235
+ * replace; everything else is the author's text.
236
+ *
237
+ * `ok` is false when the line endings mix, a pair is left open, or a
238
+ * whole-line panel opens inside another one: then nothing can be proven about
239
+ * the text and it must not be rewritten.
240
+ */
241
+ export function scanDescription(description) {
242
+ const text = String(description ?? "");
243
+ const crlf = text.includes("\r\n");
244
+ const strayCr = (crlf ? text.replace(/\r\n/g, "") : text).includes("\r");
245
+ const lines = text.split(crlf ? "\r\n" : "\n");
246
+ const rows = [];
247
+ let literal = null;
248
+ let depth = 0;
249
+ const open = { quote: 0, color: 0, panel: 0 };
250
+ let wholePanel = false;
251
+ let ok = !strayCr;
252
+ for (const line of lines) {
253
+ const row = { line, literal: literal !== null, depth, panel: null };
254
+ if (literal === null && depth === 0) {
255
+ const pm = line.match(WHOLE_PANEL);
256
+ if (pm) {
257
+ const closes = wholePanel && pm[1] === undefined;
258
+ if (!closes && wholePanel) ok = false;
259
+ row.panel = closes ? { close: true } : { open: true, params: pm[1] ?? "" };
260
+ wholePanel = !closes;
261
+ rows.push(row);
262
+ continue;
263
+ }
264
+ }
265
+ let i = 0;
266
+ while (i < line.length) {
267
+ if (literal !== null) {
268
+ const close = line.indexOf(`{${literal}}`, i);
269
+ if (close < 0) break;
270
+ i = close + literal.length + 2;
271
+ literal = null;
272
+ continue;
273
+ }
274
+ LITERAL_TOKEN.lastIndex = i;
275
+ PAIRED_TOKEN.lastIndex = i;
276
+ const lit = LITERAL_TOKEN.exec(line);
277
+ const pair = PAIRED_TOKEN.exec(line);
278
+ if (!lit && !pair) break;
279
+ if (lit && (!pair || lit.index < pair.index)) {
280
+ literal = lit[1];
281
+ i = lit.index + lit[0].length;
282
+ continue;
283
+ }
284
+ const [, name, params] = pair;
285
+ if (name === "quote") {
286
+ open.quote = open.quote ? 0 : 1;
287
+ } else if (params || !open[name]) {
288
+ open[name]++;
289
+ } else {
290
+ open[name]--;
291
+ }
292
+ depth = open.quote + open.color + open.panel;
293
+ i = pair.index + pair[0].length;
294
+ }
295
+ row.endLiteral = literal !== null;
296
+ row.endDepth = depth;
297
+ rows.push(row);
298
+ }
299
+ if (literal !== null || depth !== 0 || wholePanel) ok = false;
300
+ return { rows, eol: crlf ? "\r\n" : "\n", ok };
301
+ }
302
+
303
+ /** Whether a scanned row can start a section: a heading outside every literal block and pair. */
304
+ function headingOf(row) {
305
+ return !row.literal && row.depth === 0 && !row.panel ? headingText(row.line) : null;
306
+ }
307
+
308
+ /**
309
+ * Whether a description is in the story format.
310
+ *
311
+ * With panels: every block sits in a whole-line panel whose border is one of
312
+ * the story colours, each panel opens with a heading, and the panels run user
313
+ * story, criteria, then the rest. A trailing `Sources:` line is allowed, as the
314
+ * analysis writer puts it there. Without panels (storyPanels false): no panel
315
+ * macros, and the text opens with a heading.
316
+ *
317
+ * @returns {{status: "conforms"|"nonconforming"|"empty", reasons: string[]}}
318
+ */
319
+ export function checkStoryFormat(description, panelsSetting) {
320
+ const text = String(description ?? "");
321
+ if (!text.trim()) return { status: "empty", reasons: [] };
322
+ const colours = panelColours(panelsSetting);
323
+ const scan = scanDescription(text);
324
+ const reasons = [];
325
+ if (!scan.ok) reasons.push("unbalanced macros or mixed line endings");
326
+ const content = scan.rows.filter((r) => r.line.trim());
327
+ if (!colours) {
328
+ if (content.some((r) => r.panel)) reasons.push("panel macros present but panels are off");
329
+ if (!content.length || !headingOf(content[0])) reasons.push("content before the first heading");
330
+ const unique = [...new Set(reasons)];
331
+ return { status: unique.length ? "nonconforming" : "conforms", reasons: unique };
332
+ }
333
+ const kindByColour = {};
334
+ for (const k of ["other", "criteria", "story"]) kindByColour[colours[k].toLowerCase()] = k;
335
+ let inPanel = false;
336
+ let awaitingHeading = false;
337
+ let panels = 0;
338
+ let lastKind = -1;
339
+ for (const row of content) {
340
+ if (row.panel?.open) {
341
+ inPanel = true;
342
+ awaitingHeading = true;
343
+ panels++;
344
+ const kind = kindByColour[borderColour(row.panel.params)];
345
+ if (!kind) {
346
+ reasons.push("a panel colour that is not a story section colour");
347
+ } else {
348
+ if (KIND_ORDER[kind] < lastKind) reasons.push("sections out of order");
349
+ lastKind = Math.max(lastKind, KIND_ORDER[kind]);
350
+ }
351
+ continue;
352
+ }
353
+ if (row.panel?.close) {
354
+ if (awaitingHeading) reasons.push("an empty panel");
355
+ inPanel = false;
356
+ continue;
357
+ }
358
+ if (!inPanel) {
359
+ if (!/^Sources:/.test(row.line.trim())) reasons.push("text outside the panels");
360
+ } else if (awaitingHeading) {
361
+ if (!headingOf(row)) reasons.push("a panel without a heading");
362
+ awaitingHeading = false;
363
+ }
364
+ }
365
+ if (!panels) reasons.push("no panels");
366
+ const unique = [...new Set(reasons)];
367
+ return { status: unique.length ? "nonconforming" : "conforms", reasons: unique };
368
+ }
369
+
370
+ /**
371
+ * The description cut into sections at its own headings and at its
372
+ * whole-line panels. A heading inside a literal block or a pair never cuts.
373
+ * A panel's `title=` becomes the heading of its section; a section with no
374
+ * heading of its own gets one added (`added: true`).
375
+ */
376
+ function sectionsOf(scan) {
377
+ const sections = [];
378
+ let current = null;
379
+ const start = (heading) => {
380
+ current = { heading, headingLine: null, lines: [] };
381
+ sections.push(current);
382
+ };
383
+ for (const row of scan.rows) {
384
+ if (row.panel?.open) {
385
+ start(panelTitle(row.panel.params));
386
+ continue;
387
+ }
388
+ if (row.panel?.close) {
389
+ current = null;
390
+ continue;
391
+ }
392
+ const h = headingOf(row);
393
+ if (h !== null) {
394
+ start(h);
395
+ current.headingLine = row.line;
396
+ continue;
397
+ }
398
+ if (!current) {
399
+ if (!row.line.trim()) continue;
400
+ start(null);
401
+ }
402
+ current.lines.push(row.line);
403
+ }
404
+ return sections
405
+ .map((s) => {
406
+ const lines = [...s.lines];
407
+ while (lines.length && !lines[0].trim()) lines.shift();
408
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
409
+ return { ...s, lines };
410
+ })
411
+ .filter((s) => s.heading !== null || s.lines.length);
412
+ }
413
+
414
+ /** The non-blank lines of a description that are the author's text: everything but whole-line panels. */
415
+ function authorLines(description) {
416
+ return scanDescription(description)
417
+ .rows.filter((r) => !r.panel && r.line.trim())
418
+ .map((r) => r.line);
419
+ }
420
+
421
+ /**
422
+ * Whether every line of the author's text in `before` is in `after`, exactly
423
+ * and as many times as it was. Only whole-line panel macros may go.
424
+ */
425
+ export function preservesContent(before, after) {
426
+ const have = new Map();
427
+ for (const l of String(after ?? "").split(/\r?\n/)) have.set(l, (have.get(l) || 0) + 1);
428
+ for (const l of authorLines(before)) {
429
+ const n = have.get(l) || 0;
430
+ if (!n) return false;
431
+ have.set(l, n - 1);
432
+ }
433
+ return true;
434
+ }
435
+
436
+ /**
437
+ * The description in the story format, built from its own lines.
438
+ *
439
+ * Each line of the author's text is kept as it is, headings included at their
440
+ * own level; sections move as whole blocks into story, criteria, rest order.
441
+ * `preserved` is false, and the caller must not write, when the text cannot be
442
+ * proven to survive: unbalanced macros, mixed line endings, a lost line.
443
+ *
444
+ * @returns {{body: string, sections: {heading: string, kind: string, added: boolean}[], preserved: boolean}}
445
+ */
446
+ export function reformatStory(description, { panels, lang = "en" } = {}) {
447
+ const T = TEXT[language(lang)];
448
+ const colours = panelColours(panels);
449
+ const scan = scanDescription(description);
450
+ const sections = sectionsOf(scan).map((s, i) => {
451
+ const added = s.heading === null;
452
+ const heading = added ? (i === 0 ? T.storyHeading : T.otherHeading) : s.heading;
453
+ const kind = added ? (i === 0 ? "story" : "other") : kindOf(heading);
454
+ const headingLine = s.headingLine ?? `h3. ${escapeJiraEmoticons(heading)}`;
455
+ return { ...s, heading, kind, added, headingLine };
456
+ });
457
+ const ordered = [...sections].sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]);
458
+ const out = [];
459
+ for (const s of ordered) {
460
+ const block = [s.headingLine, ...s.lines];
461
+ if (colours) out.push(`{panel:borderColor=${colours[s.kind]}}`, ...block, "{panel}", "");
462
+ else out.push(...block, "");
463
+ }
464
+ while (out.length && !out[out.length - 1]) out.pop();
465
+ const body = out.join(scan.eol);
466
+ return {
467
+ body,
468
+ sections: sections.map((s) => ({ heading: s.heading, kind: s.kind, added: s.added })),
469
+ preserved: scan.ok && preservesContent(description, body),
470
+ };
471
+ }
472
+
473
+ // --- the family ------------------------------------------------------------
474
+
475
+ function subtaskRow(s) {
476
+ return {
477
+ key: s.key,
478
+ summary: s.fields?.summary ?? "",
479
+ type: s.fields?.issuetype?.name ?? "",
480
+ status: s.fields?.status?.name ?? "",
481
+ };
482
+ }
483
+
484
+ /**
485
+ * The family of the input issue.
486
+ *
487
+ * @param {object} input the input issue JSON (fields: issuetype, parent, subtasks, description)
488
+ * @param {object|null} story the parent's issue JSON when the input is a sub-task
489
+ * @param {{siteSubtaskTypes?: string[], prefs?: object}} [opts]
490
+ */
491
+ export function resolveFamily(input, story, opts = {}) {
492
+ const prefs = opts.prefs || readFamilyPrefs("/nonexistent");
493
+ const f = input?.fields || {};
494
+ const isSub = f.issuetype?.subtask === true;
495
+ const storyIssue = isSub
496
+ ? story || { key: f.parent?.key, fields: f.parent?.fields || {} }
497
+ : input;
498
+ const sf = storyIssue?.fields || {};
499
+ const testTypes = testTypeNames(opts.siteSubtaskTypes, prefs.testSubtaskTypes);
500
+ const types = { testTypes, devTypes: prefs.devSubtaskTypes };
501
+ const subtasks = (sf.subtasks || []).map(subtaskRow);
502
+ for (const s of subtasks) s.role = classifySubtask(s.type, types);
503
+ const tests = subtasks.filter((s) => s.role === "test");
504
+ const devs = subtasks.filter((s) => s.role === "dev");
505
+
506
+ let devTaskKey = null;
507
+ if (isSub) devTaskKey = input.key;
508
+ else if (devs.length === 1) devTaskKey = devs[0].key;
509
+
510
+ const known = typeof sf.description === "string" || sf.description === null;
511
+ const description = typeof sf.description === "string" ? sf.description : "";
512
+ const storyType = sf.issuetype?.name ?? "";
513
+ const storyTypes = lower(prefs.storyIssueTypes || ["Story"]);
514
+ let format;
515
+ if (storyType && !storyTypes.includes(storyType.toLowerCase())) {
516
+ format = { status: "not-a-story", reasons: [`${storyType} is not a story issue type`] };
517
+ } else if (known) {
518
+ format = checkStoryFormat(description, prefs.storyPanels);
519
+ } else {
520
+ format = { status: "unknown", reasons: ["the story was not fetched"] };
521
+ }
522
+ const reformat =
523
+ format.status === "nonconforming"
524
+ ? reformatStory(description, { panels: prefs.storyPanels, lang: prefs.lang })
525
+ : null;
526
+
527
+ return {
528
+ inputKey: input?.key ?? null,
529
+ inputRole: isSub ? "subtask" : "story",
530
+ story: { key: storyIssue?.key ?? null, summary: sf.summary ?? "", description },
531
+ subtasks,
532
+ testTypes,
533
+ format: {
534
+ ...format,
535
+ reformatted: reformat?.body ?? null,
536
+ preserved: reformat?.preserved ?? null,
537
+ },
538
+ stateEntry: {
539
+ inputKey: input?.key ?? null,
540
+ inputRole: isSub ? "subtask" : "story",
541
+ storyKey: storyIssue?.key ?? null,
542
+ devTaskKey,
543
+ devCandidates: devs.map((d) => d.key),
544
+ testTaskKey: tests.length === 1 ? tests[0].key : null,
545
+ testCandidates: tests.map((t) => t.key),
546
+ storyFormat: { status: format.status, reasons: format.reasons },
547
+ },
548
+ };
549
+ }
550
+
551
+ // --- the comments ----------------------------------------------------------
552
+
553
+ const slug = (s) =>
554
+ String(s || "run")
555
+ .toLowerCase()
556
+ .replace(/[^a-z0-9]+/g, "-")
557
+ .replace(/^-+|-+$/g, "") || "run";
558
+
559
+ /** The hidden marker a comment of `role` carries for `runId`. */
560
+ export function marker(runId, role) {
561
+ return `{anchor:ma-family-${slug(role)}-${slug(runId)}}`;
562
+ }
563
+
564
+ // Wiki markup uses `#` for list items, markdown for headings, so the dialect
565
+ // decides which lines are headings: a body with any `hN.` heading is wiki.
566
+ function headingPatterns(lines) {
567
+ const wiki = lines.some((l) => /^\s*h[1-6]\.\s/.test(l));
568
+ return wiki
569
+ ? { h2: /^\s*h2\.\s+(.*\S)\s*$/, stop: /^\s*h[12]\.\s/ }
570
+ : { h2: /^\s*##\s+(.*\S)\s*$/, stop: /^\s*#{1,2}\s/ };
571
+ }
572
+
573
+ /** The lines under the first `##` / `h2.` heading named one of `names`, up to the next heading. */
574
+ export function sectionOf(body, names) {
575
+ const want = names.map((n) => n.toLowerCase());
576
+ const lines = String(body ?? "").split("\n");
577
+ const re = headingPatterns(lines);
578
+ const start = lines.findIndex((l) => {
579
+ const m = l.match(re.h2);
580
+ return Boolean(m && want.includes(m[1].toLowerCase()));
581
+ });
582
+ if (start < 0) return null;
583
+ const out = [];
584
+ for (const l of lines.slice(start + 1)) {
585
+ if (re.stop.test(l)) break;
586
+ out.push(l);
587
+ }
588
+ while (out.length && !out[0].trim()) out.shift();
589
+ while (out.length && !out[out.length - 1].trim()) out.pop();
590
+ return out;
591
+ }
592
+
593
+ /** Whether the scenarios section holds at least one step a tester can follow. */
594
+ export function hasScenarios(devBody) {
595
+ const s = sectionOf(devBody, SCENARIO_HEADINGS);
596
+ return Boolean(s && s.some((l) => /^\s*(#+|\d+\.)\s+\S/.test(l)));
597
+ }
598
+
599
+ /** The first paragraph of the dev comment's summary: what changed, in its own words. */
600
+ function summaryExcerpt(devBody) {
601
+ const s = sectionOf(devBody, SUMMARY_HEADINGS) || [];
602
+ const end = s.findIndex((l) => !l.trim());
603
+ return s.slice(0, end < 0 ? SUMMARY_EXCERPT_MAX : Math.min(end, SUMMARY_EXCERPT_MAX));
604
+ }
605
+
606
+ /** Part 3 of the impact section (where to test), else its first lines. */
607
+ function impactExcerpt(devBody) {
608
+ const s = sectionOf(devBody, IMPACT_HEADINGS);
609
+ if (!s) return [];
610
+ const part = (n) => (l) => new RegExp(`^\\s*\\*{0,2}${n}\\s*-`).test(l);
611
+ const from = s.findIndex(part(3));
612
+ if (from >= 0) {
613
+ const to = s.findIndex((l, i) => i > from && part(4)(l));
614
+ return s.slice(from, to < 0 ? undefined : to).filter((l, i, a) => l.trim() || i < a.length - 1);
615
+ }
616
+ return s.slice(0, IMPACT_EXCERPT_MAX);
617
+ }
618
+
619
+ /**
620
+ * The test sub-task comment, in the dev comment's format: the same four
621
+ * sections in the same order. The summary's first paragraph (what changed),
622
+ * the test scenarios verbatim, part 3 of the impact analysis (where to look),
623
+ * and links to the development task, the PR and the build to test. Null
624
+ * without scenarios.
625
+ */
626
+ export function buildTestComment({ devBody, lang, prUrl, build, devKey, host, runId, role }) {
627
+ if (!hasScenarios(devBody)) return null;
628
+ const T = TEXT[language(lang)];
629
+ const out = [];
630
+ const summary = summaryExcerpt(devBody);
631
+ if (summary.length) out.push(`h2. ${T.summary}`, ...summary, "");
632
+ out.push(`h2. ${T.scenarios}`, ...sectionOf(devBody, SCENARIO_HEADINGS), "");
633
+ const impact = impactExcerpt(devBody);
634
+ if (impact.length) out.push(`h2. ${T.impact}`, ...impact, "");
635
+ const refs = [];
636
+ if (devKey) {
637
+ const url = host
638
+ ? `https://${String(host)
639
+ .replace(/^https?:\/\//, "")
640
+ .replace(/\/$/, "")}/browse/${devKey}`
641
+ : null;
642
+ refs.push(`* ${T.devTask}: ${url ? wikiLink(devKey, url) : devKey}`);
643
+ }
644
+ if (prUrl) refs.push(`* PR: ${wikiLink(prUrl, prUrl)}`);
645
+ if (build) refs.push(`* Build: ${String(build).replace(/\s+/g, " ").trim()}`);
646
+ if (refs.length) out.push(`h2. ${T.refs}`, ...refs, "");
647
+ out.push(marker(runId, role || "test"));
648
+ return out.join("\n");
649
+ }
650
+
651
+ // --- the plan --------------------------------------------------------------
652
+
653
+ /**
654
+ * The writes the run owes the family, the questions an interactive run still
655
+ * has to ask before them, and the decisions an autopilot run took instead.
656
+ *
657
+ * @param {object} family resolveFamily output
658
+ * @param {{devBody: string, lang?: string, mode?: string, runId?: string, prUrl?: string,
659
+ * build?: string, host?: string, skip?: string[],
660
+ * devTask?: string, familyWrites?: boolean}} o
661
+ */
662
+ export function planWrites(family, o) {
663
+ const lang = language(o.lang);
664
+ const T = TEXT[lang];
665
+ const autopilot = o.mode === "autopilot";
666
+ const st = family.stateEntry;
667
+ const writes = [];
668
+ const skipped = [];
669
+ const questions = [];
670
+ const decisions = [];
671
+ if (!o.runId) throw new Error("planWrites needs a run id: the comment markers are keyed on it");
672
+ const runId = o.runId;
673
+ const devBody = String(o.devBody ?? "").replace(/\s+$/, "");
674
+ const dev = (issue) => ({
675
+ id: "dev",
676
+ kind: "comment",
677
+ issue,
678
+ label: T.labels.dev,
679
+ marker: marker(runId, "dev"),
680
+ body: `${devBody}\n\n${marker(runId, "dev")}`,
681
+ });
682
+
683
+ if (o.familyWrites === false) {
684
+ writes.push(dev(st.inputKey));
685
+ return finish();
686
+ }
687
+
688
+ if (st.storyFormat.status === "not-a-story") {
689
+ skipped.push({ id: "story", reason: `not applicable: ${st.storyFormat.reasons.join("; ")}` });
690
+ if (autopilot) {
691
+ decisions.push({
692
+ writeId: null,
693
+ what: `Left the description of ${st.storyKey} as it is`,
694
+ why: `${st.storyFormat.reasons.join("; ")}; only a story is reformatted into the story format`,
695
+ costIfWrong:
696
+ "If this issue should follow the story format, it has to be reformatted by hand",
697
+ phase: 5,
698
+ });
699
+ }
700
+ }
701
+ if (st.storyFormat.status === "nonconforming") {
702
+ if (family.format.preserved !== true) {
703
+ skipped.push({
704
+ id: "story",
705
+ reason: "the reformat would not keep every line; left as it is",
706
+ });
707
+ } else {
708
+ writes.push({
709
+ id: "story",
710
+ kind: "description",
711
+ issue: st.storyKey,
712
+ label: T.labels.story,
713
+ body: family.format.reformatted,
714
+ original: family.story.description,
715
+ raw: true,
716
+ });
717
+ if (autopilot) {
718
+ decisions.push({
719
+ writeId: "story",
720
+ what: `Reformatted the description of ${st.storyKey} into the story format`,
721
+ why: `It did not follow the story format (${st.storyFormat.reasons.join("; ")}); the lines were kept verbatim and only wrapped into panels`,
722
+ costIfWrong:
723
+ "The story reads differently than its author left it; the previous description is backed up under jira-backups and can be put back",
724
+ phase: 5,
725
+ });
726
+ }
727
+ }
728
+ }
729
+
730
+ let devTarget = st.devTaskKey;
731
+ if (!devTarget && o.devTask && st.devCandidates.includes(o.devTask)) devTarget = o.devTask;
732
+ if (!devTarget && st.devCandidates.length > 1) {
733
+ if (autopilot) {
734
+ devTarget = st.inputKey;
735
+ decisions.push({
736
+ writeId: "dev",
737
+ what: `Posted the development comment on ${st.inputKey} instead of one of its development sub-tasks`,
738
+ why: `The story has ${st.devCandidates.length} development sub-tasks (${st.devCandidates.join(", ")}) and none is the run's own`,
739
+ costIfWrong:
740
+ "The developer looks for the comment on the sub-task and finds it on the story",
741
+ phase: 5,
742
+ });
743
+ } else {
744
+ questions.push({ id: "dev-task", question: T.devQ, options: st.devCandidates });
745
+ }
746
+ }
747
+ if (!devTarget && !questions.some((q) => q.id === "dev-task")) devTarget = st.inputKey;
748
+ if (devTarget) writes.push(dev(devTarget));
749
+
750
+ if (!hasScenarios(devBody)) {
751
+ skipped.push({ id: "test", reason: "no test scenarios" });
752
+ return finish();
753
+ }
754
+ const testArgs = {
755
+ devBody,
756
+ lang,
757
+ prUrl: o.prUrl,
758
+ build: o.build,
759
+ devKey: devTarget && devTarget !== st.storyKey ? devTarget : null,
760
+ host: o.host,
761
+ runId,
762
+ };
763
+ let testTarget = st.testTaskKey;
764
+ const testCandidates = st.testCandidates || [];
765
+ if (!testTarget && o.testTask && testCandidates.includes(o.testTask)) testTarget = o.testTask;
766
+ if (!testTarget && testCandidates.length > 1) {
767
+ if (!autopilot) {
768
+ questions.push({ id: "test-task", question: T.testQ, options: testCandidates });
769
+ return finish();
770
+ }
771
+ const exact = (family.subtasks || []).find(
772
+ (x) => testCandidates.includes(x.key) && String(x.type).toLowerCase() === "test",
773
+ );
774
+ testTarget = exact ? exact.key : testCandidates[0];
775
+ if (!exact) {
776
+ decisions.push({
777
+ writeId: "test",
778
+ what: `Posted the test comment on ${testTarget}, the first of ${testCandidates.length} test sub-tasks`,
779
+ why: `The story has ${testCandidates.length} test sub-tasks (${testCandidates.join(", ")}) and none is of the type Test`,
780
+ costIfWrong: "The tester looks on another test sub-task and does not find the scenarios",
781
+ phase: 5,
782
+ });
783
+ }
784
+ }
785
+ if (testTarget) {
786
+ writes.push({
787
+ id: "test",
788
+ kind: "comment",
789
+ issue: testTarget,
790
+ label: T.labels.test,
791
+ marker: marker(runId, "test"),
792
+ body: buildTestComment({ ...testArgs, role: "test" }),
793
+ });
794
+ return finish();
795
+ }
796
+ writes.push({
797
+ id: "story-tests",
798
+ kind: "comment",
799
+ issue: st.storyKey,
800
+ label: T.labels["story-tests"],
801
+ marker: marker(runId, "story-tests"),
802
+ body: buildTestComment({ ...testArgs, role: "story-tests" }),
803
+ });
804
+ if (autopilot) {
805
+ decisions.push({
806
+ writeId: "story-tests",
807
+ what: `Posted the test scenarios on ${st.storyKey}, which has no test sub-task`,
808
+ why: "Autopilot does not drop scenarios a tester needs, and the story is the issue a tester can find",
809
+ costIfWrong:
810
+ "The scenarios sit on the story until a test sub-task is opened and they are moved there",
811
+ phase: 5,
812
+ });
813
+ }
814
+ return finish();
815
+
816
+ function finish() {
817
+ const drop = new Set(o.skip || []);
818
+ for (const w of writes.filter((x) => drop.has(x.id))) {
819
+ skipped.push({ id: w.id, reason: "deselected" });
820
+ }
821
+ const kept = writes.filter((w) => !drop.has(w.id));
822
+ const keptDecisions = decisions.filter((d) => d.writeId === null || !drop.has(d.writeId));
823
+ const labels = kept.map((w) => w.label);
824
+ return {
825
+ version: 1,
826
+ lang,
827
+ mode: autopilot ? "autopilot" : "interactive",
828
+ runId,
829
+ storyPanels: o.storyPanels ?? null,
830
+ ready: questions.length === 0,
831
+ confirm: `${kept.length} ${T.writes}: ${labels.join(", ")}`,
832
+ writes: kept,
833
+ skipped,
834
+ questions,
835
+ decisions: keptDecisions,
836
+ };
837
+ }
838
+ }
839
+
840
+ // --- CLI -------------------------------------------------------------------
841
+
842
+ class UsageError extends Error {}
843
+
844
+ function parseArgs(argv) {
845
+ const opts = {};
846
+ for (let i = 0; i < argv.length; i++) {
847
+ const a = argv[i];
848
+ if (!a.startsWith("--")) throw new UsageError(`unexpected argument ${a}`);
849
+ const v = argv[i + 1];
850
+ if (v === undefined || v.startsWith("--")) throw new UsageError(`${a} needs a value`);
851
+ opts[a.slice(2)] = v;
852
+ i++;
853
+ }
854
+ return opts;
855
+ }
856
+
857
+ function readJson(path) {
858
+ return JSON.parse(readFileSync(path, "utf8"));
859
+ }
860
+
861
+ function readLines(path) {
862
+ try {
863
+ return readFileSync(path, "utf8")
864
+ .split("\n")
865
+ .map((l) => l.trim())
866
+ .filter(Boolean);
867
+ } catch {
868
+ return [];
869
+ }
870
+ }
871
+
872
+ const ENUMS = {
873
+ mode: ["interactive", "autopilot"],
874
+ lang: ["tr", "en"],
875
+ };
876
+
877
+ const HELP = `usage:
878
+ jira-family.mjs resolve --input F [--story F] [--site-types F] [--prefs F] [--lang tr|en]
879
+ jira-family.mjs plan --family F --dev-body F --run-id ID [--mode interactive|autopilot]
880
+ [--pr-url URL] [--build TEXT] [--host HOST] [--skip id,id]
881
+ [--dev-task KEY] [--test-task KEY] [--prefs F] [--lang tr|en]
882
+ jira-family.mjs check --description F [--prefs F]
883
+ Contract: multi-agent-refs/features/jira-family.md`;
884
+
885
+ function main(argv) {
886
+ const cmd = argv[0];
887
+ if (!cmd || cmd === "help" || argv.includes("--help") || argv.includes("-h")) {
888
+ process.stdout.write(`${HELP}\n`);
889
+ return cmd ? 0 : 2;
890
+ }
891
+ const o = parseArgs(argv.slice(1));
892
+ for (const [k, allowed] of Object.entries(ENUMS)) {
893
+ if (o[k] !== undefined && !allowed.includes(o[k])) {
894
+ throw new UsageError(`--${k} must be one of ${allowed.join(", ")}`);
895
+ }
896
+ }
897
+ const prefs = readFamilyPrefs(o.prefs || prefsPath());
898
+ if (o.lang) prefs.lang = o.lang;
899
+ if (cmd === "check") {
900
+ if (!o.description) throw new UsageError("check needs --description FILE");
901
+ const r = checkStoryFormat(readFileSync(o.description, "utf8"), prefs.storyPanels);
902
+ process.stdout.write(`${JSON.stringify(r)}\n`);
903
+ return r.status === "conforms" ? 0 : 1;
904
+ }
905
+ if (cmd === "resolve") {
906
+ if (!o.input) throw new UsageError("resolve needs --input FILE");
907
+ const input = readJson(o.input);
908
+ const story = o.story ? readJson(o.story) : null;
909
+ const fam = resolveFamily(input, story, {
910
+ siteSubtaskTypes: o["site-types"] ? readLines(o["site-types"]) : [],
911
+ prefs,
912
+ });
913
+ process.stdout.write(`${JSON.stringify({ ...fam, lang: prefs.lang }, null, 2)}\n`);
914
+ return 0;
915
+ }
916
+ if (cmd === "plan") {
917
+ if (!o.family || !o["dev-body"] || !o["run-id"]) {
918
+ throw new UsageError("plan needs --family FILE --dev-body FILE --run-id ID");
919
+ }
920
+ const plan = planWrites(readJson(o.family), {
921
+ devBody: readFileSync(o["dev-body"], "utf8"),
922
+ lang: prefs.lang,
923
+ mode: o.mode,
924
+ runId: o["run-id"],
925
+ prUrl: o["pr-url"],
926
+ build: o.build,
927
+ host: o.host || prefs.host,
928
+ skip: o.skip ? o.skip.split(",").map((x) => x.trim()) : [],
929
+ devTask: o["dev-task"],
930
+ testTask: o["test-task"],
931
+ storyPanels: prefs.storyPanels,
932
+ familyWrites: prefs.familyWrites,
933
+ });
934
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
935
+ return 0;
936
+ }
937
+ throw new UsageError("usage: jira-family.mjs resolve|plan|check ...");
938
+ }
939
+
940
+ if (invokedDirectly(import.meta.url)) {
941
+ try {
942
+ process.exitCode = main(process.argv.slice(2));
943
+ } catch (err) {
944
+ process.stderr.write(`jira-family: ${err.message}\n`);
945
+ process.exitCode = err instanceof UsageError ? 2 : 3;
946
+ }
947
+ }