@mmerterden/multi-agent-pipeline 20.22.0 → 20.23.1

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 (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/adr/0010-own-code-graph.md +3 -2
  3. package/docs/facts.json +3 -3
  4. package/docs/features.md +2 -2
  5. package/install/_usage-lifecycle.mjs +44 -0
  6. package/install/catalog-history.json +1 -1
  7. package/install/index.mjs +2 -0
  8. package/manifest.json +54 -51
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/create-jira/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +3 -3
  12. package/pipeline/commands/multi-agent/update/SKILL.md +3 -3
  13. package/pipeline/lib/analysis-jira-write.sh +25 -5
  14. package/pipeline/lib/json-file-lock.mjs +24 -2
  15. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  16. package/pipeline/multi-agent-refs/cross-cli-contract.md +1 -0
  17. package/pipeline/multi-agent-refs/features/analysis-jira.md +13 -2
  18. package/pipeline/multi-agent-refs/features/code-graph.md +40 -3
  19. package/pipeline/multi-agent-refs/features/usage-reporting.md +40 -23
  20. package/pipeline/multi-agent-refs/generate-issue.md +11 -1
  21. package/pipeline/multi-agent-refs/knowledge.md +18 -0
  22. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  23. package/pipeline/schemas/code-graph.schema.json +3 -3
  24. package/pipeline/schemas/prefs.schema.json +29 -6
  25. package/pipeline/scripts/_code-graph.mjs +121 -12
  26. package/pipeline/scripts/_diff-impact.mjs +67 -32
  27. package/pipeline/scripts/_supertypes.mjs +146 -0
  28. package/pipeline/scripts/analysis-story-body.mjs +24 -8
  29. package/pipeline/scripts/analysis-story-tree.mjs +16 -2
  30. package/pipeline/scripts/capture-flush.sh +4 -1
  31. package/pipeline/scripts/code-graph-rules/android.json +1 -0
  32. package/pipeline/scripts/code-graph-rules/ios.json +6 -1
  33. package/pipeline/scripts/code-graph-rules/node.json +1 -0
  34. package/pipeline/scripts/code-graph-rules/python.json +1 -0
  35. package/pipeline/scripts/git-path.mjs +21 -0
  36. package/pipeline/scripts/graph-affected.mjs +2 -1
  37. package/pipeline/scripts/graph-confirm.mjs +140 -0
  38. package/pipeline/scripts/learnings-ledger.mjs +3 -2
  39. package/pipeline/scripts/product-context.mjs +30 -19
  40. package/pipeline/scripts/symbol-existence-gate.mjs +21 -9
  41. package/pipeline/scripts/test-gap-scan.mjs +4 -1
  42. package/pipeline/scripts/test-strength.mjs +7 -8
  43. package/pipeline/scripts/usage-identity.mjs +1 -1
  44. package/pipeline/scripts/usage-register.mjs +181 -15
  45. package/pipeline/scripts/validate-code-graph.mjs +3 -3
  46. package/pipeline/scripts/valven-gate.mjs +9 -1
  47. package/pipeline/scripts/valven-port.mjs +16 -5
  48. package/pipeline/scripts/valven-split.mjs +6 -4
  49. package/pipeline/skills/.skill-manifest.json +3 -3
  50. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +3 -3
  51. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +3 -3
@@ -23,14 +23,13 @@
23
23
  import { execFileSync } from "node:child_process";
24
24
  import { existsSync, statSync } from "node:fs";
25
25
  import { join } from "node:path";
26
- import { unquotePathIfNeeded } from "./git-path.mjs";
26
+ import { diffHeaderPath } from "./git-path.mjs";
27
27
 
28
28
  const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
29
29
 
30
30
  /** The path of a `---`/`+++` line: git-quoted or not, one-letter prefix removed. */
31
31
  function sidePath(raw, prefix) {
32
- const text = raw.startsWith('"') ? raw : raw.replace(/\t.*$/, "");
33
- const path = unquotePathIfNeeded(text);
32
+ const path = diffHeaderPath(raw);
34
33
  if (path === "/dev/null") return null;
35
34
  return path.startsWith(prefix) ? path.slice(prefix.length) : path;
36
35
  }
@@ -39,7 +38,7 @@ function addLine(set, line) {
39
38
  set.add(Math.max(1, line));
40
39
  }
41
40
 
42
- function toRanges(lines) {
41
+ function toRanges(lines, gaps = new Set()) {
43
42
  const sorted = [...lines].sort((a, b) => a - b);
44
43
  const out = [];
45
44
  for (const l of sorted) {
@@ -47,21 +46,28 @@ function toRanges(lines) {
47
46
  if (last && l === last.end + 1) last.end = l;
48
47
  else if (!last || l > last.end) out.push({ start: l, end: l });
49
48
  }
50
- return out;
49
+ // A gap is a point between two lines where lines went in (or, on the new
50
+ // side, came out): `{start: before, end: before + 1, gap: true}`.
51
+ for (const before of gaps)
52
+ if (!sorted.includes(before) && !sorted.includes(before + 1))
53
+ out.push({ start: before, end: before + 1, gap: true });
54
+ return out.sort((a, b) => a.start - b.start);
51
55
  }
52
56
 
53
57
  /**
54
58
  * Every file of a unified diff with the lines it touched on each side.
55
59
  *
56
- * New side: an added line counts as itself, and a deletion as the line now
57
- * standing where the removed lines were. Old side: a removed line counts as
58
- * itself, and an insertion as the old line standing where it went in. Either way a hunk that
59
- * only adds or only removes still lands inside the declaration it changed.
60
+ * New side: an added line counts as itself, and a pure deletion as the gap
61
+ * between the two new lines it fell between. Old side: a removed line counts as
62
+ * itself, and a pure insertion as the gap between the two old lines it went in
63
+ * between. A gap belongs to the innermost declaration spanning both of its
64
+ * lines, so a hunk that only adds or only removes lands inside the declaration
65
+ * it changed, whatever the context size.
60
66
  * Hunks are counted from their headers, so concatenated `diff -u` output with
61
67
  * no `diff --git` lines parses too, CRLF included, at any context size.
62
68
  *
63
69
  * @param {string} diffText - unified diff
64
- * @returns {Array<{oldPath: string|null, newPath: string|null, oldRanges: Array<{start: number, end: number}>, newRanges: Array<{start: number, end: number}>}>}
70
+ * @returns {Array<{oldPath: string|null, newPath: string|null, oldRanges: Array<{start: number, end: number, gap?: boolean}>, newRanges: Array<{start: number, end: number, gap?: boolean}>}>}
65
71
  */
66
72
  export function parseDiffFiles(diffText) {
67
73
  const files = [];
@@ -71,23 +77,36 @@ export function parseDiffFiles(diffText) {
71
77
  let newLine = 0;
72
78
  let oldLeft = 0;
73
79
  let newLeft = 0;
80
+ // One run of -/+ lines. Its removed lines stand for it on the old side and
81
+ // its added lines on the new side; a run with nothing on one side is a gap
82
+ // there, between the lines either side of it.
83
+ let run = null;
84
+ const endRun = () => {
85
+ if (run && !run.removed) cur.oldGaps.add(run.oldBefore);
86
+ if (run && !run.added) cur.newGaps.add(run.newBefore);
87
+ run = null;
88
+ };
74
89
  for (const line of String(diffText).split("\n")) {
75
90
  const raw = line.endsWith("\r") ? line.slice(0, -1) : line;
76
91
  if (oldLeft > 0 || newLeft > 0) {
77
- if (raw.startsWith("+")) {
78
- addLine(cur.newLines, newLine);
79
- addLine(cur.oldLines, oldLine);
80
- newLine++;
81
- newLeft--;
82
- continue;
83
- }
84
- if (raw.startsWith("-")) {
85
- addLine(cur.oldLines, oldLine);
86
- addLine(cur.newLines, newLine);
87
- oldLine++;
88
- oldLeft--;
92
+ if (raw.startsWith("+") || raw.startsWith("-")) {
93
+ run = run || { oldBefore: oldLine - 1, newBefore: newLine - 1, removed: 0, added: 0 };
94
+ if (raw.startsWith("+")) {
95
+ addLine(cur.newLines, newLine);
96
+ run.added++;
97
+ newLine++;
98
+ newLeft--;
99
+ } else {
100
+ addLine(cur.oldLines, oldLine);
101
+ run.removed++;
102
+ oldLine++;
103
+ oldLeft--;
104
+ }
105
+ if (oldLeft <= 0 && newLeft <= 0) endRun();
89
106
  continue;
90
107
  }
108
+ if (raw.startsWith("\\")) continue;
109
+ endRun();
91
110
  if (raw.startsWith(" ") || raw === "") {
92
111
  oldLine++;
93
112
  newLine++;
@@ -95,7 +114,6 @@ export function parseDiffFiles(diffText) {
95
114
  newLeft--;
96
115
  continue;
97
116
  }
98
- if (raw.startsWith("\\")) continue;
99
117
  oldLeft = 0;
100
118
  newLeft = 0;
101
119
  }
@@ -113,6 +131,8 @@ export function parseDiffFiles(diffText) {
113
131
  newPath: sidePath(raw.slice(4), "b/"),
114
132
  oldLines: new Set(),
115
133
  newLines: new Set(),
134
+ oldGaps: new Set(),
135
+ newGaps: new Set(),
116
136
  };
117
137
  files.push(cur);
118
138
  pendingOld = undefined;
@@ -122,12 +142,14 @@ export function parseDiffFiles(diffText) {
122
142
  if (hunk && cur) {
123
143
  const oldCount = hunk[2] === undefined ? 1 : Number(hunk[2]);
124
144
  const newCount = hunk[4] === undefined ? 1 : Number(hunk[4]);
125
- oldLine = Number(hunk[1]);
126
- newLine = Number(hunk[3]);
145
+ // An empty side names the line BEFORE the change; any other side names
146
+ // the change's first line.
147
+ oldLine = Number(hunk[1]) + (oldCount === 0 ? 1 : 0);
148
+ newLine = Number(hunk[3]) + (newCount === 0 ? 1 : 0);
127
149
  oldLeft = oldCount;
128
150
  newLeft = newCount;
129
- if (newCount === 0) addLine(cur.newLines, newLine);
130
- if (oldCount === 0) addLine(cur.oldLines, oldLine);
151
+ if (oldCount === 0) cur.oldGaps.add(oldLine - 1);
152
+ if (newCount === 0) cur.newGaps.add(newLine - 1);
131
153
  }
132
154
  }
133
155
  return files
@@ -135,8 +157,8 @@ export function parseDiffFiles(diffText) {
135
157
  .map((f) => ({
136
158
  oldPath: f.oldPath,
137
159
  newPath: f.newPath,
138
- oldRanges: toRanges(f.oldLines),
139
- newRanges: toRanges(f.newLines),
160
+ oldRanges: toRanges(f.oldLines, f.oldGaps),
161
+ newRanges: toRanges(f.newLines, f.newGaps),
140
162
  }));
141
163
  }
142
164
 
@@ -203,8 +225,13 @@ export function changedSymbols(graph, ranges) {
203
225
  continue;
204
226
  }
205
227
  for (const r of list) {
228
+ // A gap belongs to a declaration only when the lines on both sides do.
206
229
  const containing = spanned
207
- .filter((s) => (s.startLine ?? s.line) <= r.end && s.endLine >= r.start)
230
+ .filter((s) =>
231
+ r.gap
232
+ ? (s.startLine ?? s.line) <= r.start && s.endLine >= r.end
233
+ : (s.startLine ?? s.line) <= r.end && s.endLine >= r.start,
234
+ )
208
235
  .sort((a, b) => b.line - a.line);
209
236
  if (!containing.length) {
210
237
  seeds.set(fileNode.id, fileNode);
@@ -268,16 +295,24 @@ export function graphFreshness(graph, { root = graph.root } = {}) {
268
295
  }
269
296
 
270
297
  /**
271
- * Which side of a diff the graph's line numbers describe. A graph that matches
298
+ * Which side of a diff the graph's line numbers describe. For a commit range
299
+ * (`range`, `<base>..<head>`) the graph's build commit decides: built at the
300
+ * head, the new side; at the base, the old side. Otherwise a graph that matches
272
301
  * the tree at `root` (same HEAD, no file edited since the build) describes the
273
302
  * new side; any other graph was built before the change - on the base checkout,
274
303
  * or before the edit - and its spans are old-side line numbers.
275
304
  *
276
305
  * @param {object} graph
277
- * @param {{root?: string}} [opts]
306
+ * @param {{root?: string, range?: string}} [opts]
278
307
  * @returns {"new"|"old"}
279
308
  */
280
309
  export function graphSide(graph, opts = {}) {
310
+ const parts = typeof opts.range === "string" ? opts.range.split("..") : [];
311
+ if (parts.length === 2 && opts.root && graph.baseCommit && parts.every((p) => REF_RE.test(p))) {
312
+ const sha = (ref) => gitOut(opts.root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
313
+ if (sha(parts[1]) === graph.baseCommit) return "new";
314
+ if (sha(parts[0]) === graph.baseCommit) return "old";
315
+ }
281
316
  return graphFreshness(graph, opts).stale ? "old" : "new";
282
317
  }
283
318
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @file _supertypes.mjs - the types a declaration header names as its parents.
3
+ *
4
+ * Reads the header text that follows a type's name in a stripped body (comments
5
+ * and strings already blanked) and returns the simple names of its superclass,
6
+ * protocols or interfaces. Three syntaxes cover the stacks the graph indexes:
7
+ *
8
+ * colon Swift and Kotlin: `Name<T>(ctor): A, B<X>() where ... {`
9
+ * parens Python: `Name(A, B, metaclass=M):`, `Name[T](A):`
10
+ * extends TypeScript / JavaScript: `Name<T> extends A implements B, C {`
11
+ *
12
+ * Only names that start with a letter or underscore are kept, qualified names
13
+ * reduce to their last segment, and generic arguments, constructor calls and
14
+ * Kotlin delegation (`by impl`) are dropped. A name the graph does not define
15
+ * (String, Codable) simply produces no edge.
16
+ *
17
+ * Zero runtime dependencies (ADR-0004).
18
+ *
19
+ * @module pipeline/scripts/_supertypes
20
+ */
21
+
22
+ const HEADER_MAX = 4000;
23
+ const CALL_RE = /^\s*[A-Za-z_$][\w$.]*\s*(?:<[^()]*>)?\s*\(/;
24
+ const OPEN = { "(": ")", "<": ">", "[": "]" };
25
+ const CLOSE = new Set([")", ">", "]"]);
26
+
27
+ /** `->` and `=>` are arrows, not the end of a generic argument list. */
28
+ const isArrow = (text, i) => text[i] === ">" && (text[i - 1] === "-" || text[i - 1] === "=");
29
+
30
+ /**
31
+ * The header from `start` to the `{` that opens the body (or `;`, or Python's
32
+ * closing `:`) at bracket depth 0, capped so a declaration with no body cannot
33
+ * read on into the file.
34
+ */
35
+ function headerText(body, start, syntax) {
36
+ let depth = 0;
37
+ const end = Math.min(body.length, start + HEADER_MAX);
38
+ for (let i = start; i < end; i++) {
39
+ const c = body[i];
40
+ if (c in OPEN) depth++;
41
+ else if (CLOSE.has(c) && depth > 0 && !isArrow(body, i)) depth--;
42
+ else if (depth === 0 && (c === "{" || c === ";")) return body.slice(start, i);
43
+ else if (depth === 0 && syntax === "parens" && c === ":") return body.slice(start, i);
44
+ }
45
+ return body.slice(start, end);
46
+ }
47
+
48
+ /** Split on commas at bracket depth 0. */
49
+ function splitTop(text) {
50
+ const parts = [];
51
+ let depth = 0;
52
+ let cur = "";
53
+ for (let i = 0; i < text.length; i++) {
54
+ const c = text[i];
55
+ if (c in OPEN) depth++;
56
+ else if (CLOSE.has(c) && depth > 0 && !isArrow(text, i)) depth--;
57
+ if (c === "," && depth === 0) {
58
+ parts.push(cur);
59
+ cur = "";
60
+ } else cur += c;
61
+ }
62
+ parts.push(cur);
63
+ return parts;
64
+ }
65
+
66
+ /** Skip one balanced bracket group starting at `i` when `text[i]` is `open`. */
67
+ function skipGroup(text, i, open) {
68
+ if (text[i] !== open) return i;
69
+ const close = OPEN[open];
70
+ let depth = 0;
71
+ for (let j = i; j < text.length; j++) {
72
+ if (text[j] === open) depth++;
73
+ else if (text[j] === close && !isArrow(text, j) && --depth === 0) return j + 1;
74
+ }
75
+ return text.length;
76
+ }
77
+
78
+ function simpleName(raw) {
79
+ const item = raw.replace(/^\s*(?:@[\w.]+(?:\([^)]*\))?\s*)+/, "").replace(/`/g, "");
80
+ const m = /^\s*(?:out\s+|in\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)/.exec(item);
81
+ if (!m || item.includes("=")) return null;
82
+ return m[1].split(".").pop();
83
+ }
84
+
85
+ function names(list) {
86
+ const out = [];
87
+ for (const item of splitTop(list)) {
88
+ const n = simpleName(item);
89
+ if (n && !out.includes(n)) out.push(n);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * @param {string} body - stripped source
96
+ * @param {number} start - offset just past the declared name
97
+ * @param {"colon"|"parens"|"extends"} syntax
98
+ * @returns {string[]} simple supertype names, in header order
99
+ */
100
+ export function supertypesAt(body, start, syntax) {
101
+ const header = headerText(body, start, syntax);
102
+ if (syntax === "parens") {
103
+ let k = 0;
104
+ while (k < header.length && /\s/.test(header[k])) k++;
105
+ // PEP 695 type parameters come before the bases: `class Box[T](Base):`.
106
+ const rest = header.slice(skipGroup(header, k, "["));
107
+ const m = /^\s*\(/.exec(rest);
108
+ if (!m) return [];
109
+ const end = skipGroup(rest, m[0].length - 1, "(");
110
+ return names(rest.slice(m[0].length, end - 1));
111
+ }
112
+ if (syntax === "extends") {
113
+ let k = 0;
114
+ while (k < header.length && /\s/.test(header[k])) k++;
115
+ const rest = header.slice(skipGroup(header, k, "<"));
116
+ const out = [];
117
+ const ext = /\bextends\s+([\s\S]*?)(?=\bimplements\b|$)/.exec(rest);
118
+ const imp = /\bimplements\s+([\s\S]*)$/.exec(rest);
119
+ // A base built by a call (`extends mixin(Base)`) is the call's result,
120
+ // which names no type the graph can point at.
121
+ for (const part of [ext?.[1], imp?.[1]])
122
+ if (part)
123
+ for (const n of names(
124
+ splitTop(part)
125
+ .filter((p) => !CALL_RE.test(p))
126
+ .join(","),
127
+ ))
128
+ if (!out.includes(n)) out.push(n);
129
+ return out;
130
+ }
131
+ let i = 0;
132
+ while (i < header.length && /\s/.test(header[i])) i++;
133
+ i = skipGroup(header, i, "<");
134
+ let j = i;
135
+ while (j < header.length && /\s/.test(header[j])) j++;
136
+ const ctor =
137
+ /^(?:@[A-Za-z_][\w.]*(?:\([^)]*\))?\s+)*(?:(?:private|protected|internal|public)\s+)?constructor\s*/.exec(
138
+ header.slice(j),
139
+ );
140
+ if (ctor) j += ctor[0].length;
141
+ j = skipGroup(header, j, "(");
142
+ while (j < header.length && /\s/.test(header[j])) j++;
143
+ if (header[j] !== ":") return [];
144
+ const list = header.slice(j + 1).split(/\bwhere\b/)[0];
145
+ return names(list.replace(/\bby\s+[^,]*/g, ""));
146
+ }
@@ -16,6 +16,11 @@ import { escapeJiraWiki, wikiLink } from "./jira-wiki-escape.mjs";
16
16
  import { redesignSections } from "../lib/analysis-sections.mjs";
17
17
 
18
18
  const MAX_CRITERIA = 8;
19
+
20
+ // Panel border colours of a story section, the ones testers read stories in:
21
+ // the user story, the acceptance criteria, and every other section.
22
+ const PANEL = { story: "#d4d39e", criteria: "#d4a19e", info: "#9eb6d4" };
23
+ const panel = (colour, lines) => [`{panel:borderColor=${colour}}`, ...lines, "{panel}", ""];
19
24
  const MAX_DESIGN_LINKS = 5;
20
25
 
21
26
  // A criterion that describes a refusal, an error or an alternative path. One is
@@ -152,19 +157,30 @@ function linksOf(story, lines, ctx) {
152
157
 
153
158
  function render({ userStory, acceptance, currentVsTarget, links }, sourceIds) {
154
159
  const out = [];
155
- if (userStory) out.push("h3. User story", escapeJiraWiki(userStory), "");
160
+ if (userStory) out.push(...panel(PANEL.story, ["h3. User story", escapeJiraWiki(userStory)]));
156
161
  if (acceptance.length) {
157
- out.push("h3. Acceptance criteria", ...acceptance.map((a) => `# ${escapeJiraWiki(a)}`), "");
162
+ out.push(
163
+ ...panel(PANEL.criteria, [
164
+ "h3. Acceptance criteria",
165
+ ...acceptance.map((a) => `# ${escapeJiraWiki(a)}`),
166
+ ]),
167
+ );
158
168
  }
159
169
  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("");
170
+ out.push(
171
+ ...panel(PANEL.info, [
172
+ "h3. Current vs target",
173
+ "||Id||Status||Where||",
174
+ ...currentVsTarget.map(
175
+ (r) => `|${escapeJiraWiki(r.id)}|${escapeJiraWiki(r.status)}|${escapeJiraWiki(r.where)}|`,
176
+ ),
177
+ ]),
178
+ );
165
179
  }
166
180
  if (links.length) {
167
- out.push("h3. Links", ...links.map((l) => `* ${wikiLink(l.label, l.url)}`), "");
181
+ out.push(
182
+ ...panel(PANEL.info, ["h3. Links", ...links.map((l) => `* ${wikiLink(l.label, l.url)}`)]),
183
+ );
168
184
  }
169
185
  out.push(
170
186
  `Sources: ${escapeJiraWiki(sourceIds.length ? sourceIds.join(", ") : "derived from the section heading")}`,
@@ -153,7 +153,7 @@ export function identityLabel(prefix, docId, sourceIds) {
153
153
  * issueTree.projectKey where the tree is created
154
154
  * issueTree.epicIssueType the type name for an epic on this site
155
155
  * issueTree.storyIssueType the type name for a story
156
- * issueTree.subtaskIssueType null = discover from createmeta
156
+ * issueTree.subtaskIssueType a name, a {role: name} map, or null = discover
157
157
  * issueTree.subtaskRoles how a story is split; ships empty on purpose
158
158
  * issueTree.channelComponents channel -> this site's component name
159
159
  * issueTree.channelTeams channel -> this site's team name
@@ -195,6 +195,17 @@ function readPrefs() {
195
195
  }
196
196
  }
197
197
 
198
+ /**
199
+ * The sub-task type a role is created as, from issueTree.subtaskIssueType: a
200
+ * name for every role, or a {role: name} map with "*" for any other role. Null
201
+ * means the writer discovers it from the site.
202
+ */
203
+ export function subtaskTypeFor(setting, role) {
204
+ if (typeof setting === "string" && setting) return setting;
205
+ if (setting && typeof setting === "object") return setting[role] ?? setting["*"] ?? null;
206
+ return null;
207
+ }
208
+
198
209
  /** Every open placeholder that makes a document unfit to become work. */
199
210
  export function openItems(text) {
200
211
  const found = [];
@@ -557,7 +568,10 @@ async function main() {
557
568
  for (const line of s.description.split("\n")) process.stdout.write(` ${line}\n`);
558
569
  }
559
570
  for (const t of s.subtasks) {
560
- process.stdout.write(` subtask ${t.role} [${t.label}]\n`);
571
+ const type = subtaskTypeFor(result.prefsUsed.subtaskIssueType, t.role);
572
+ process.stdout.write(
573
+ ` subtask ${t.role} type ${type ?? "discovered from createmeta"} [${t.label}]\n`,
574
+ );
561
575
  }
562
576
  for (const c of s.clones || []) {
563
577
  process.stdout.write(
@@ -66,7 +66,10 @@ LOGS="${LOGS_ROOT:-$HOME/.claude/logs/multi-agent}"
66
66
  SCRIPTS="$HOME/.claude/scripts"
67
67
  [ -d "$SCRIPTS" ] || SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
68
68
 
69
- say() { [ "$QUIET" -eq 1 ] && return 0; printf '%s\n' "$1"; }
69
+ say() {
70
+ [ "$QUIET" -eq 1 ] && return 0
71
+ if [ "$JSON" -eq 1 ]; then printf '%s\n' "$1" >&2; else printf '%s\n' "$1"; fi
72
+ }
70
73
 
71
74
  # Resolve the run to flush.
72
75
  #
@@ -51,6 +51,7 @@
51
51
  }
52
52
  ],
53
53
  "referenceKinds": ["class", "interface", "object", "enum", "record", "typealias"],
54
+ "supertypes": { "syntax": "colon", "kinds": ["class", "interface", "object", "enum"] },
54
55
  "referenceKindsNote": "fun is deliberately absent, for the reason Swift's rules record for func: a bare lowercase name matched across files is almost never a call to that exact declaration, and on a large app the generic ones (invoke, build, create, get) take the top god-node slots purely because each happened to be declared once. Composables are the tempting exception - they are capitalised and behave like types - but they are declared with fun, so including them means including every other fun with them.",
55
56
  "ignoredIdentifiers": [
56
57
  "this",
@@ -47,10 +47,15 @@
47
47
  "importPatterns": [
48
48
  {
49
49
  "id": "import",
50
- "regex": "^\\s*import\\s+(?:class|struct|enum|protocol|func|var|let|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)|^\\s*import\\s+([A-Za-z_][A-Za-z0-9_]*)"
50
+ "regex": "^\\s*(?:@[A-Za-z_]\\w*(?:\\([^)\\n]*\\))?\\s+)*import\\s+(?:class|struct|enum|protocol|func|var|let|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)|^\\s*(?:@[A-Za-z_]\\w*(?:\\([^)\\n]*\\))?\\s+)*import\\s+([A-Za-z_][A-Za-z0-9_]*)"
51
51
  }
52
52
  ],
53
53
  "referenceKinds": ["class", "struct", "enum", "protocol", "actor", "typealias"],
54
+ "supertypes": {
55
+ "syntax": "colon",
56
+ "kinds": ["class", "struct", "enum", "protocol", "actor"],
57
+ "extensionPattern": "\\bextension\\s+([A-Z][A-Za-z0-9_.]*)"
58
+ },
54
59
  "referenceKindsNote": "func is deliberately absent. A bare lowercase method name matched across files is almost never a call to that exact declaration: on a large Swift app `with`, `size`, `name`, `image` and `model` took the top god-node slots purely because each happened to be declared in exactly one file. Functions still reach the graph through their `defines` edge, so they stay findable by name.",
55
60
  "ignoredIdentifiers": [
56
61
  "self",
@@ -46,6 +46,7 @@
46
46
  }
47
47
  ],
48
48
  "referenceKinds": ["class", "interface", "type", "enum", "const"],
49
+ "supertypes": { "syntax": "extends", "kinds": ["class", "interface"] },
49
50
  "referenceKindsNote": "function is deliberately absent, for the reason Swift's rules record for func: a bare name matched across files is almost never a call to that exact declaration, and the generic ones (run, main, get, parse, handler) would take the top god-node slots purely because each happened to be declared once. Functions still reach the graph through their defines edge, so they stay findable by name. const is present but narrowed to an uppercase-initial declaration at column 0, which is what a component, a singleton or an exported table looks like; a lowercase const is a local and is not a declaration anyone references across files. Measured on this repo (126 sources): including function put `ok` at degree 52, `f` at 38, `tokens` at 15 and `arg` at 12 - local helper names that happen to be declared exactly once, so the ambiguity rule does not catch them either. Excluding it leaves a thin symbol layer, and that is the honest shape of this stack: a JavaScript module's exported unit is usually a function, so the useful graph here is the import graph between files. `graph-affected \"<file>.mjs\"` answers who imports a module, which is the question this stack actually gets asked.",
50
51
  "ignoredIdentifiers": [
51
52
  "this",
@@ -30,6 +30,7 @@
30
30
  }
31
31
  ],
32
32
  "referenceKinds": ["class"],
33
+ "supertypes": { "syntax": "parens", "kinds": ["class"] },
33
34
  "referenceKindsNote": "def is deliberately absent, for the reason Swift's rules record for func and the node rules record with measurements: a bare lowercase name matched across files is almost never a call to that exact declaration, and Python's convention of short module-level helpers (main, run, load, parse, setup) makes it worse than most. Functions still reach the graph through their defines edge, so they stay findable by name.",
34
35
  "ignoredIdentifiers": [
35
36
  "self",
@@ -61,3 +61,24 @@ export function unquotePathIfNeeded(s) {
61
61
  }
62
62
  return s;
63
63
  }
64
+
65
+ /**
66
+ * The path a `---` or `+++` diff header names (the text after that marker): a
67
+ * quoted path ends at its closing quote, which git may follow with a tab, and
68
+ * an unquoted one ends at a tab.
69
+ *
70
+ * @param {string} text
71
+ * @returns {string}
72
+ */
73
+ export function diffHeaderPath(text) {
74
+ if (text.startsWith('"')) {
75
+ for (let i = 1; i < text.length; i++) {
76
+ if (text[i] === "\\") {
77
+ i++;
78
+ continue;
79
+ }
80
+ if (text[i] === '"') return unquoteGitPath(text.slice(1, i));
81
+ }
82
+ }
83
+ return text.replace(/\t.*$/, "");
84
+ }
@@ -171,7 +171,8 @@ function runDiff(flags, graph, depth, kinds) {
171
171
  text = diffFor(root, flags.diff);
172
172
  }
173
173
  const freshness = graphFreshness(graph, { root });
174
- const side = graphSide(graph, { root });
174
+ const range = flags["diff-file"] && flags["diff-file"] !== true ? undefined : flags.diff;
175
+ const side = graphSide(graph, { root, range });
175
176
  const mapped = changedSymbols(graph, parseHunkRanges(text, side));
176
177
  const hits = affected({ graph, seeds: mapped.seeds, depth, kinds });
177
178
  const result = {
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file graph-confirm.mjs - a name-based blast radius, checked by a language server.
5
+ *
6
+ * The code graph joins files to declarations by name, so a dependent it lists
7
+ * may only share a name with the changed type, and a dependent behind an
8
+ * ambiguous name is missing. The toolkit's `code_references` answers the same
9
+ * question semantically when the project has an index. This merges the two
10
+ * without trusting either blindly:
11
+ *
12
+ * confirmed a direct dependent the server also found
13
+ * name-only a direct dependent a ready semantic index did not find
14
+ * unchecked a deeper ring the server did not name (it depends on an
15
+ * intermediate declaration), or no answer could check it
16
+ *
17
+ * A deeper ring the server names directly is confirmed too.
18
+ *
19
+ * `lspOnly` lists files the server found that the graph did not. Only a result
20
+ * with `semantic: true` and `indexReady: true` confirms or demotes anything; a
21
+ * fallback answer or an index still building leaves every file unchecked,
22
+ * because "not found" from an unready index is not evidence.
23
+ *
24
+ * Inputs:
25
+ * --affected <path> graph-affected --json output
26
+ * --references <path> JSON array of code_references results, one per changed type
27
+ * --root <path> repository root the server's absolute paths are under
28
+ *
29
+ * Exit codes: 0 answered, 64 usage error.
30
+ *
31
+ * Zero runtime dependencies (ADR-0004).
32
+ *
33
+ * @module pipeline/scripts/graph-confirm
34
+ */
35
+
36
+ import { readFileSync, realpathSync } from "node:fs";
37
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
38
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
39
+
40
+ /**
41
+ * @param {{affected: Array<{path: string|null, dist: number}>}} affectedDoc
42
+ * @param {Array<{semantic?: boolean, indexReady?: boolean, results?: Array<{file: string}>}>} references
43
+ * @param {{root: string}} opts
44
+ */
45
+ export function confirmImpact(affectedDoc, references, { root }) {
46
+ const usable = (references || []).filter(
47
+ (r) => r && r.semantic === true && r.indexReady === true,
48
+ );
49
+ const checked = usable.length > 0 && usable.length === (references || []).length;
50
+ const real = (p) => {
51
+ const abs = resolve(p);
52
+ try {
53
+ return realpathSync(abs);
54
+ } catch {
55
+ const parent = dirname(abs);
56
+ return parent === abs ? abs : join(real(parent), basename(abs));
57
+ }
58
+ };
59
+ const repo = real(root);
60
+ // A server answers with paths relative to its own workspace root (the
61
+ // nearest package), absolute ones through whatever spelling of a symlinked
62
+ // directory it was given, and files outside the repository (SDKs).
63
+ const rel = (file, serverRoot) => {
64
+ const abs = isAbsolute(file) ? file : resolve(serverRoot || root, file);
65
+ const r = relative(repo, real(abs));
66
+ return r && !r.startsWith("..") && !isAbsolute(r) ? r : null;
67
+ };
68
+ const found = new Set();
69
+ for (const r of usable)
70
+ for (const loc of r.results || []) {
71
+ const f = loc?.file ? rel(loc.file, r.root) : null;
72
+ if (f) found.add(f);
73
+ }
74
+
75
+ const seedPaths = new Set((affectedDoc.changedSymbols || []).map((c) => c.path).filter(Boolean));
76
+ for (const id of affectedDoc.seeds || []) {
77
+ const s = String(id);
78
+ if (s.startsWith("sym:")) seedPaths.add(s.slice(4, s.lastIndexOf("#")));
79
+ else if (s.startsWith("file:")) seedPaths.add(s.slice(5));
80
+ }
81
+ const graphPaths = new Set();
82
+ const affected = (affectedDoc.affected || []).map((row) => {
83
+ if (row.path) graphPaths.add(row.path);
84
+ let confirmation = "unchecked";
85
+ if (checked && row.path && found.has(row.path)) confirmation = "confirmed";
86
+ else if (checked && row.dist === 1 && row.path) confirmation = "name-only";
87
+ return { ...row, confirmation };
88
+ });
89
+
90
+ const lspOnly = checked
91
+ ? [...found].filter((f) => !graphPaths.has(f) && !seedPaths.has(f)).sort()
92
+ : [];
93
+ const byPath = new Map();
94
+ for (const a of affected) if (a.path && !byPath.has(a.path)) byPath.set(a.path, a.confirmation);
95
+ const count = (c) => [...byPath.values()].filter((v) => v === c).length;
96
+ return {
97
+ affected,
98
+ lspOnly,
99
+ summary: {
100
+ confirmed: count("confirmed"),
101
+ nameOnly: count("name-only"),
102
+ unchecked: count("unchecked"),
103
+ lspOnly: lspOnly.length,
104
+ checked,
105
+ },
106
+ };
107
+ }
108
+
109
+ function flag(argv, name) {
110
+ const i = argv.indexOf(name);
111
+ const v = i >= 0 ? argv[i + 1] : undefined;
112
+ return v && !v.startsWith("--") ? v : null;
113
+ }
114
+
115
+ function main(argv) {
116
+ const affectedPath = flag(argv, "--affected");
117
+ const referencesPath = flag(argv, "--references");
118
+ const root = flag(argv, "--root");
119
+ if (!affectedPath || !referencesPath || !root) {
120
+ process.stderr.write(
121
+ "usage: graph-confirm.mjs --affected <json> --references <json> --root <path>\n",
122
+ );
123
+ process.exitCode = 64;
124
+ return;
125
+ }
126
+ let affectedDoc;
127
+ let references;
128
+ try {
129
+ affectedDoc = JSON.parse(readFileSync(affectedPath, "utf8"));
130
+ references = JSON.parse(readFileSync(referencesPath, "utf8"));
131
+ } catch (e) {
132
+ process.stderr.write(`graph-confirm: ${e.message}\n`);
133
+ process.exitCode = 64;
134
+ return;
135
+ }
136
+ if (!Array.isArray(references)) references = [references];
137
+ process.stdout.write(`${JSON.stringify(confirmImpact(affectedDoc, references, { root }))}\n`);
138
+ }
139
+
140
+ if (invokedDirectly(import.meta.url)) main(process.argv.slice(2));