@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.
- package/CHANGELOG.md +36 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/docs/facts.json +1 -1
- package/install/_common.mjs +1 -1
- package/install/_plugin-skills.mjs +13 -9
- package/install/catalog-history.json +1 -1
- package/install/codex.mjs +12 -12
- package/manifest.json +59 -53
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +7 -3
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +57 -7
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -0
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/doctor/SKILL.md +6 -0
- package/pipeline/commands/multi-agent/feedback/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/forget/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -0
- package/pipeline/commands/multi-agent/review-analysis/SKILL.md +7 -2
- package/pipeline/lib/analysis-jira-write.sh +81 -27
- package/pipeline/lib/analysis-quality.mjs +261 -0
- package/pipeline/lib/analysis-sections.mjs +105 -0
- package/pipeline/lib/jira-epic-link.sh +54 -0
- package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
- package/pipeline/multi-agent-refs/analysis/redesign.md +3 -1
- package/pipeline/multi-agent-refs/analysis/render.md +20 -21
- package/pipeline/multi-agent-refs/analysis/resolve.md +1 -1
- package/pipeline/multi-agent-refs/analysis/review.md +16 -3
- package/pipeline/multi-agent-refs/analysis/synthesis.md +10 -2
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +44 -9
- package/pipeline/multi-agent-refs/analysis-template.md +21 -14
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -0
- package/pipeline/multi-agent-refs/features/analysis-jira.md +69 -19
- package/pipeline/schemas/analysis-spec.schema.json +22 -0
- package/pipeline/schemas/prefs.schema.json +22 -1
- package/pipeline/scripts/analysis-story-body.mjs +210 -0
- package/pipeline/scripts/analysis-story-tree.mjs +73 -12
- package/pipeline/scripts/analysis-tickets-writeback.mjs +101 -0
- package/pipeline/scripts/build-references.mjs +14 -1
- package/pipeline/scripts/confluence-readback.mjs +148 -0
- package/pipeline/scripts/jira-wiki-escape.mjs +40 -0
- package/pipeline/scripts/validate-analysis-doc.mjs +120 -70
- package/pipeline/skills/.skill-manifest.json +8 -8
- package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +6 -1
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +56 -6
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +6 -0
- package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +6 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -0
- package/pipeline/skills/shared/core/multi-agent-review-analysis/SKILL.md +5 -0
- 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
|
|
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
|
|
260
|
-
// still real work. Deriving from those keeps such a run useful
|
|
261
|
-
// `unverifiable`
|
|
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(
|
|
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)
|
|
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);
|