@mmerterden/multi-agent-pipeline 17.3.0 → 17.5.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 +203 -0
- package/README.md +23 -5
- package/README.tr.md +23 -5
- package/docs/adr/0013-lsp-code-intelligence.md +102 -0
- package/docs/adr/README.md +1 -0
- package/docs/token-budget-history.md +1 -1
- package/install/templates/copilot-instructions.md +9 -3
- package/package.json +1 -1
- package/pipeline/agents/code-reviewer.md +35 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +5 -3
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +17 -6
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +3 -3
- package/pipeline/lib/multi-repo-pipeline.sh +26 -0
- package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
- package/pipeline/multi-agent-refs/analysis/render.md +2 -1
- package/pipeline/multi-agent-refs/channels/pr.md +26 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +22 -0
- package/pipeline/multi-agent-refs/features/base-branch-evidence.md +222 -0
- package/pipeline/multi-agent-refs/features/code-graph.md +40 -0
- package/pipeline/multi-agent-refs/features/code-intelligence.md +80 -0
- package/pipeline/multi-agent-refs/features/design-conformance.md +14 -0
- package/pipeline/multi-agent-refs/features/review-file-set.md +132 -0
- package/pipeline/multi-agent-refs/phases/modes.md +23 -3
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +96 -71
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +31 -23
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +1 -1
- package/pipeline/multi-agent-refs/phases.md +7 -2
- package/pipeline/multi-agent-refs/picker-contract.md +37 -5
- package/pipeline/multi-agent-refs/tracker-contract.md +25 -14
- package/pipeline/schemas/agent-state.schema.json +88 -4
- package/pipeline/schemas/prefs.schema.json +22 -0
- package/pipeline/schemas/review-file-exclusions.json +137 -0
- package/pipeline/schemas/reviewer-output.schema.json +27 -1
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/autopilot-runner.mjs +292 -45
- package/pipeline/scripts/base-branch-candidates.mjs +599 -0
- package/pipeline/scripts/diff-risk-score.mjs +1 -36
- package/pipeline/scripts/gc-abandoned.sh +5 -3
- package/pipeline/scripts/gen-mode-dispatch.mjs +39 -16
- package/pipeline/scripts/git-path.mjs +63 -0
- package/pipeline/scripts/glob-match.mjs +62 -0
- package/pipeline/scripts/graph-mermaid.mjs +251 -0
- package/pipeline/scripts/phase-tracker.sh +39 -2
- package/pipeline/scripts/phase0-exit-gate.mjs +128 -0
- package/pipeline/scripts/review-file-filter.mjs +180 -0
- package/pipeline/scripts/skill-conformance.mjs +1 -31
- package/pipeline/scripts/validate-analysis-doc.mjs +53 -0
- package/pipeline/scripts/validate-reviewer.mjs +90 -1
- package/pipeline/scripts/verify-citations.mjs +428 -0
- package/pipeline/skills/.skill-manifest.json +2 -2
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +1 -1
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
*
|
|
18
18
|
* v16.0.0 removed the four dev-* modes. Depth is no longer a command name: the
|
|
19
19
|
* Phase 0 Step 7.5 picker asks Full or Short and sets `state.onlyDevelop`. That
|
|
20
|
-
* answer arrives long after the tracker boots at Step -1, so
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* answer arrives long after the tracker boots at Step -1, so v17.5.0 splits
|
|
21
|
+
* registration for the two modes that ask it (`full`, `local`): Phase 0 at Step -1,
|
|
22
|
+
* the rest once depth has named them. Everything else still registers its whole
|
|
23
|
+
* set up front, and a generated phase set is per-COMMAND, never per-depth.
|
|
23
24
|
*
|
|
24
25
|
* Companion smoke `smoke-mode-dispatch-drift.sh` regenerates the section for
|
|
25
26
|
* each mode file, diffs against the on-disk content, and fails on drift.
|
|
@@ -60,10 +61,10 @@ const PHASE_NAMES_NO_TEST = PHASE_NAMES.filter((p) => p !== "5:Test");
|
|
|
60
61
|
*/
|
|
61
62
|
const MODES = {
|
|
62
63
|
autopilot: { phases: PHASE_NAMES_NO_TEST, local: false, autopilot: true },
|
|
63
|
-
full: { phases: PHASE_NAMES, local: false, autopilot: false },
|
|
64
|
-
local: { phases: PHASE_NAMES_NO_TEST, local: true, autopilot: false },
|
|
64
|
+
full: { phases: PHASE_NAMES, local: false, autopilot: false, depth: true },
|
|
65
|
+
local: { phases: PHASE_NAMES_NO_TEST, local: true, autopilot: false, depth: true },
|
|
65
66
|
"local-autopilot": { phases: PHASE_NAMES_NO_TEST, local: true, autopilot: true },
|
|
66
|
-
"full-local": { phases: PHASE_NAMES_NO_TEST, local: true, autopilot: false },
|
|
67
|
+
"full-local": { phases: PHASE_NAMES_NO_TEST, local: true, autopilot: false, depth: true },
|
|
67
68
|
// Analysis produces a document, not code: no Dev, no Test, and Phase 6
|
|
68
69
|
// publishes instead of committing. Same 8-phase contract, four of them
|
|
69
70
|
// reinterpreted.
|
|
@@ -81,8 +82,6 @@ if (!spec) {
|
|
|
81
82
|
process.exit(2);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
const phaseLoop = spec.phases.map((p) => `"${p}"`).join(" ");
|
|
85
|
-
|
|
86
85
|
const ALL_PHASE_IDS = PHASE_NAMES.map((p) => p.split(":")[0]);
|
|
87
86
|
const activeIds = spec.phases.map((p) => p.split(":")[0]);
|
|
88
87
|
const skippedIds = ALL_PHASE_IDS.filter((id) => !activeIds.includes(id));
|
|
@@ -104,7 +103,7 @@ const skipNote =
|
|
|
104
103
|
? `${modeLabel} mode does NOT TaskCreate phases ${skippedIds.join("/")} - those are not part of the ${modeLabel} phase set (\`${spec.phases.join(" ")}\`). Only register tiles for the active set.`
|
|
105
104
|
: `${modeLabel} mode TaskCreates all 8 phases (no phase is skipped).`;
|
|
106
105
|
|
|
107
|
-
const orderingNote = `**All TaskCreate calls fire in strict phase-number order BEFORE any TaskUpdate is applied.** For ${modeLabel} that means: ${phaseSequence}. The native widget renders by creation order, not by phase number - out-of-order calls produce visually scrambled tile stacks. Full ordering contract in \`$HOME/.claude/multi-agent-refs/tracker-contract.md\` section "TaskCreate ordering (strict)".`;
|
|
106
|
+
const orderingNote = `**All TaskCreate calls in a batch fire in strict phase-number order BEFORE any TaskUpdate is applied.** For ${modeLabel} that means: ${spec.depth ? `Phase 0 at Step -1, then the rest in ascending order at Step 7.5 (${phaseSequence} minus whatever the depth answer drops)` : phaseSequence}. The native widget renders by creation order, not by phase number - out-of-order calls produce visually scrambled tile stacks. Full ordering contract in \`$HOME/.claude/multi-agent-refs/tracker-contract.md\` section "TaskCreate ordering (strict)".`;
|
|
108
107
|
|
|
109
108
|
const localCaveat = spec.local
|
|
110
109
|
? `\n> **Local mode:** no worktree is created, work happens on the current branch. Phase 0 Init still calls \`init\` - the \`--local\` flag is stored in tracker-state.json, and \`:resume\` restores the correct CWD.\n`
|
|
@@ -114,6 +113,32 @@ const banner = spec.autopilot
|
|
|
114
113
|
? `\n> **Autopilot mode:** user confirmations are skipped. The tracker is still mandatory - autopilot agent calls cannot skip it; skipping breaks \`smoke-tracker-contract.sh\`.\n`
|
|
115
114
|
: "";
|
|
116
115
|
|
|
116
|
+
// Registration shape. A mode that asks the depth question does not know its phase
|
|
117
|
+
// set at Step -1 (the answer needs taskType, which needs the fetched issue and the
|
|
118
|
+
// branch), so it registers Phase 0 there and the rest at Step 7.5. Drawing eight
|
|
119
|
+
// tiles beside the question that decides whether two of them run is the failure this
|
|
120
|
+
// split exists to remove.
|
|
121
|
+
const fullSet = spec.phases.filter((p) => p !== "0:Init");
|
|
122
|
+
const shortSet = fullSet.filter((p) => Number(p.split(":")[0]) >= 3);
|
|
123
|
+
const loopFor = (list) =>
|
|
124
|
+
`for p in ${list.map((x) => `"${x}"`).join(" ")}; do\n bash $HOME/.claude/scripts/phase-tracker.sh add "\${p%%:*}" "\${p#*:}"\ndone`;
|
|
125
|
+
|
|
126
|
+
const initLoop = spec.depth
|
|
127
|
+
? 'bash $HOME/.claude/scripts/phase-tracker.sh add 0 "Init"\nbash $HOME/.claude/scripts/phase-tracker.sh tiles'
|
|
128
|
+
: `${loopFor(spec.phases)}`;
|
|
129
|
+
|
|
130
|
+
const deferredBlock = spec.depth
|
|
131
|
+
? `
|
|
132
|
+
# Phase 0 Step 7.5, immediately after the depth answer - the first moment this
|
|
133
|
+
# mode knows its phase set. Full:
|
|
134
|
+
${loopFor(fullSet)}
|
|
135
|
+
# Short (Analysis and Planning are not run, so they get no tile at all):
|
|
136
|
+
${loopFor(shortSet)}
|
|
137
|
+
# Then the widget, narrowed to the phases that do not have a tile yet:
|
|
138
|
+
bash $HOME/.claude/scripts/phase-tracker.sh tiles --new
|
|
139
|
+
`
|
|
140
|
+
: "";
|
|
141
|
+
|
|
117
142
|
const out = `## Required: Phase Tracker Contract
|
|
118
143
|
|
|
119
144
|
**The phase tracker is mandatory** - the agent cannot skip it. Full spec: [\`$HOME/.claude/multi-agent-refs/tracker-contract.md\`]($HOME/.claude/multi-agent-refs/tracker-contract.md).
|
|
@@ -126,11 +151,9 @@ Two channels run in parallel at every phase boundary:
|
|
|
126
151
|
\`\`\`bash
|
|
127
152
|
# Phase 0, very first shell call (every CLI):
|
|
128
153
|
bash $HOME/.claude/scripts/phase-tracker.sh init "$TASK_ID"
|
|
129
|
-
|
|
130
|
-
bash $HOME/.claude/scripts/phase-tracker.sh add "\${p%%:*}" "\${p#*:}"
|
|
131
|
-
done
|
|
154
|
+
${initLoop}
|
|
132
155
|
bash $HOME/.claude/scripts/phase-tracker.sh update 0 in_progress
|
|
133
|
-
|
|
156
|
+
${deferredBlock}
|
|
134
157
|
# Every phase boundary (every CLI):
|
|
135
158
|
bash $HOME/.claude/scripts/phase-tracker.sh update <N> in_progress|completed|failed|skipped
|
|
136
159
|
|
|
@@ -142,11 +165,11 @@ bash $HOME/.claude/scripts/phase-tracker.sh tokens <N> <in> <out> [cached]
|
|
|
142
165
|
|
|
143
166
|
In Claude Code the agent MUST also drive the native TaskList widget so the user sees a sticky phase tile stack - this is the only progress signal Claude Code surfaces. Skipping these calls is the #1 source of "I don't see any phases" complaints.
|
|
144
167
|
|
|
145
|
-
**TaskCreate ordering (strict)**: All TaskCreate calls fire in strict phase-number order BEFORE any TaskUpdate is
|
|
168
|
+
**TaskCreate ordering (strict)**: All TaskCreate calls in a registration batch fire in strict phase-number order BEFORE any TaskUpdate in that batch, and a later batch only ever appends phases numbered above everything already registered.${spec.depth ? " This mode registers in two batches (Step -1, then Step 7.5), so `tiles --new` narrows the second one and the Phase 0 tile is never created twice." : ""} The native widget renders by creation order, not by phase number - out-of-order calls produce visually scrambled tile stacks (e.g. \`1 ✓ · 2 ✓ · 4 ✓ · 0 ▶ · 3 ☐\`) even when the underlying state is correct. Pre-marking phases as completed/skipped before Phase 0 starts is FORBIDDEN - register the tile in order, then flip status via TaskUpdate when the phase actually short-circuits. Full contract in \`$HOME/.claude/multi-agent-refs/tracker-contract.md\` section "TaskCreate ordering (strict)".
|
|
146
169
|
|
|
147
170
|
\`\`\`text
|
|
148
|
-
#
|
|
149
|
-
for each phase in ${spec.phases.join(", ")}:
|
|
171
|
+
# Register one tile per phase, capture the taskId, persist it:
|
|
172
|
+
for each phase in ${spec.depth ? `0:Init at Step -1, then ${fullSet.join(", ")} (Full) or ${shortSet.join(", ")} (Short) at Step 7.5` : spec.phases.join(", ")}:
|
|
150
173
|
TaskCreate({ subject: "Phase <N>: <Name>", activeForm: "<doing-form>" })
|
|
151
174
|
-> returns taskId
|
|
152
175
|
bash $HOME/.claude/scripts/phase-tracker.sh meta <N> tasklist_id "<taskId>"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file git-path.mjs - undo git's C-style path quoting, in one place.
|
|
5
|
+
*
|
|
6
|
+
* With `core.quotePath` on (the default), git quotes any path containing a
|
|
7
|
+
* non-ASCII byte or an unusual character: a Turkish filename comes out of
|
|
8
|
+
* `diff --name-only` as `"G\303\266r\303\274n\303\274m.swift"`, quotes and all.
|
|
9
|
+
* A consumer that treats that string as a path sees a name that matches no
|
|
10
|
+
* glob, resolves to no file, and simply DISAPPEARS from whatever it was
|
|
11
|
+
* feeding - which is how every non-ASCII-named file was once silently dropped
|
|
12
|
+
* from risk scoring.
|
|
13
|
+
*
|
|
14
|
+
* Shared rather than copied because the failure is silent on every axis that
|
|
15
|
+
* uses it: a dropped file is not an error anywhere, it is an absence.
|
|
16
|
+
*
|
|
17
|
+
* @module pipeline/scripts/git-path
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reverse git's C-style quoting: octal byte escapes (`\NNN`, one per raw byte,
|
|
22
|
+
* so a multi-byte UTF-8 character is several consecutive triplets) plus the
|
|
23
|
+
* standard `\\ \" \t \n \r` escapes.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} s the INNER text, without the surrounding quotes
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function unquoteGitPath(s) {
|
|
29
|
+
const bytes = [];
|
|
30
|
+
for (let i = 0; i < s.length; i++) {
|
|
31
|
+
if (s[i] === "\\") {
|
|
32
|
+
const octal = s.slice(i + 1, i + 4);
|
|
33
|
+
if (/^[0-7]{3}$/.test(octal)) {
|
|
34
|
+
bytes.push(parseInt(octal, 8));
|
|
35
|
+
i += 3;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const simple = { "\\": 92, '"': 34, t: 9, n: 10, r: 13 };
|
|
39
|
+
const next = s[i + 1];
|
|
40
|
+
if (next in simple) {
|
|
41
|
+
bytes.push(simple[next]);
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
bytes.push(s.charCodeAt(i));
|
|
47
|
+
}
|
|
48
|
+
return Buffer.from(bytes).toString("utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Unquote a path only if git actually quoted it. An unquoted path is returned
|
|
53
|
+
* untouched, so this is safe to run over every line of any git path listing.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} s
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function unquotePathIfNeeded(s) {
|
|
59
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
60
|
+
return unquoteGitPath(s.slice(1, -1));
|
|
61
|
+
}
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file glob-match.mjs - the one glob matcher, so two denominators cannot drift.
|
|
5
|
+
*
|
|
6
|
+
* `skill-conformance.mjs` matches a changed file against a rule's declared
|
|
7
|
+
* scope; `review-file-filter.mjs` matches the same file against the exclusion
|
|
8
|
+
* list. Those two answers decide what is reviewed and what it was measured
|
|
9
|
+
* against, so a second implementation is not a duplication of code but a
|
|
10
|
+
* duplication of MEANING: the day they disagree, a file is excluded from the
|
|
11
|
+
* review and still counted in the conformance denominator, and nothing says so.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately minimal - the `**\/x`, `*.ext`, `dir/**` shapes the pattern
|
|
14
|
+
* lists actually use. No brace expansion, no extglob, no character classes: a
|
|
15
|
+
* pattern that needs them belongs in a list nobody can read either.
|
|
16
|
+
*
|
|
17
|
+
* @module pipeline/scripts/glob-match
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compile a glob to an anchored RegExp.
|
|
22
|
+
*
|
|
23
|
+
* `**` followed by `/` matches zero or more path segments, so `**\/x` matches
|
|
24
|
+
* a bare `x` at the root as well as `a/b/x`. A bare `*` never crosses `/`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} glob
|
|
27
|
+
* @returns {RegExp}
|
|
28
|
+
*/
|
|
29
|
+
export function globToRegExp(glob) {
|
|
30
|
+
let re = "";
|
|
31
|
+
for (let i = 0; i < glob.length; i++) {
|
|
32
|
+
const c = glob[i];
|
|
33
|
+
if (c === "*") {
|
|
34
|
+
if (glob[i + 1] === "*") {
|
|
35
|
+
if (glob[i + 2] === "/") {
|
|
36
|
+
re += "(?:.*/)?";
|
|
37
|
+
i += 2;
|
|
38
|
+
} else {
|
|
39
|
+
re += ".*";
|
|
40
|
+
i += 1;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
re += "[^/]*";
|
|
44
|
+
}
|
|
45
|
+
} else if (c === "?") re += "[^/]";
|
|
46
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
47
|
+
}
|
|
48
|
+
return new RegExp(`^${re}$`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `file` matches any glob, or when the list is empty - an unscoped
|
|
53
|
+
* rule applies everywhere.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} file
|
|
56
|
+
* @param {string[]} globs
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function matchesAnyGlob(file, globs) {
|
|
60
|
+
if (!globs || globs.length === 0) return true;
|
|
61
|
+
return globs.some((g) => globToRegExp(g).test(file));
|
|
62
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file graph-mermaid.mjs - draw the blast radius that was already measured.
|
|
5
|
+
*
|
|
6
|
+
* The PR body's Impact Analysis asks, in part 3, which symbols and files a
|
|
7
|
+
* change reaches. `graph-affected.mjs` answers exactly that question
|
|
8
|
+
* deterministically, from a graph pinned to a commit - and until now the answer
|
|
9
|
+
* was re-typed as prose by a model while the measurement sat unused on disk.
|
|
10
|
+
*
|
|
11
|
+
* This emits the same answer as a mermaid `flowchart`. Mermaid because GitHub
|
|
12
|
+
* renders it natively in pull requests, issues and markdown files, so the
|
|
13
|
+
* diagram costs no renderer, no plugin and no dependency: the output is text.
|
|
14
|
+
* Jira is deliberately NOT a target - its wiki renderer turns a mermaid fence
|
|
15
|
+
* into a literal `{code:mermaid}` block, so a diagram there is worse than the
|
|
16
|
+
* prose it replaced.
|
|
17
|
+
*
|
|
18
|
+
* Traversal is not reimplemented here. `findByName` and `affected` come from
|
|
19
|
+
* graph-affected.mjs, so the diagram and the text report can never disagree
|
|
20
|
+
* about what is affected - they are the same call.
|
|
21
|
+
*
|
|
22
|
+
* Inputs:
|
|
23
|
+
* "<symbol>" Symbol name; comma-separate for several seeds. Required.
|
|
24
|
+
* --graph <path> Graph file. Default: the same default graph-query uses
|
|
25
|
+
* --depth N Reverse traversal depth. Default: 2
|
|
26
|
+
* --kind K[,K] Restrict to these edge kinds
|
|
27
|
+
* --max-nodes N Node ceiling. Default: 25
|
|
28
|
+
* --direction LR|TD Flowchart direction. Default: LR
|
|
29
|
+
* --json Emit {mermaid, nodes, edges, truncated, baseCommit}
|
|
30
|
+
*
|
|
31
|
+
* Exit codes (same family as graph-affected.mjs, on purpose):
|
|
32
|
+
* 0 - drawn, possibly with nothing affected
|
|
33
|
+
* 1 - graph missing or unreadable; the reason goes to stderr and the
|
|
34
|
+
* caller records it as a gap rather than inventing a diagram
|
|
35
|
+
* 64 - usage error
|
|
36
|
+
*
|
|
37
|
+
* @module pipeline/scripts/graph-mermaid
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { resolve } from "node:path";
|
|
41
|
+
import { parseFlags } from "./graph-build.mjs";
|
|
42
|
+
import { loadGraph, defaultGraphPath } from "./graph-query.mjs";
|
|
43
|
+
import { findByName, affected } from "./graph-affected.mjs";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MAX_NODES = 25;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A mermaid-safe identifier for a graph node id.
|
|
49
|
+
*
|
|
50
|
+
* Graph ids carry `:`, `/`, `#` and `.`, all of which mermaid reads as syntax.
|
|
51
|
+
* Rather than escaping them, ids are positional (`n0`, `n1`) and the real name
|
|
52
|
+
* travels in the quoted label, where mermaid only needs `"` handled.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} i
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function nodeKey(i) {
|
|
58
|
+
return `n${i}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The label a reader sees. Paths are shown basename-first because a column of
|
|
63
|
+
* identical directory prefixes is the fastest way to make a diagram unreadable,
|
|
64
|
+
* and the full path is already in the text report beside it.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} node
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function label(node) {
|
|
70
|
+
// A symbol is known by its name; a file by its basename. Using the path for
|
|
71
|
+
// both printed the DEFINING FILE as the label of every symbol, which drew a
|
|
72
|
+
// diagram where the thing that changed appeared to be something else.
|
|
73
|
+
const base =
|
|
74
|
+
node.kind === "file" || node.kind === "module"
|
|
75
|
+
? (node.path || node.name || node.id).split("/").pop()
|
|
76
|
+
: node.name || node.id;
|
|
77
|
+
const where = node.kind !== "file" && node.path ? ` - ${node.path.split("/").pop()}` : "";
|
|
78
|
+
return `${base}${where}`.replace(/"/g, "'");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the diagram model: which nodes survive the ceiling, which edges connect
|
|
83
|
+
* them, and how many were left out.
|
|
84
|
+
*
|
|
85
|
+
* Over the ceiling the highest-degree nodes are kept, because they are the ones
|
|
86
|
+
* a reviewer most needs to see, and the count of the rest is REPORTED. Silently
|
|
87
|
+
* dropping them would draw a small blast radius for a large change, which is
|
|
88
|
+
* the one failure a diagram must not have.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} graph
|
|
91
|
+
* @param {{id: string, dist: number}[]} hits
|
|
92
|
+
* @param {string[]} seedIds
|
|
93
|
+
* @param {number} maxNodes
|
|
94
|
+
* @returns {{nodes: object[], edges: object[], truncated: number}}
|
|
95
|
+
*/
|
|
96
|
+
export function buildModel(graph, hits, seedIds, maxNodes) {
|
|
97
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
98
|
+
const wanted = new Map();
|
|
99
|
+
|
|
100
|
+
for (const id of seedIds) {
|
|
101
|
+
const n = byId.get(id);
|
|
102
|
+
if (n) wanted.set(id, { node: n, dist: 0 });
|
|
103
|
+
}
|
|
104
|
+
for (const h of hits) {
|
|
105
|
+
if (wanted.has(h.id)) continue;
|
|
106
|
+
const n = byId.get(h.id);
|
|
107
|
+
if (n) wanted.set(h.id, { node: n, dist: h.dist });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Seeds are never dropped - a diagram without the thing that changed is not a
|
|
111
|
+
// smaller diagram, it is a different question. Order the rest by distance
|
|
112
|
+
// first (near blast radius before far), then by degree, then by id so the
|
|
113
|
+
// output is byte-stable across runs.
|
|
114
|
+
const seeds = [...wanted.values()].filter((e) => e.dist === 0);
|
|
115
|
+
const rest = [...wanted.values()]
|
|
116
|
+
.filter((e) => e.dist !== 0)
|
|
117
|
+
.sort(
|
|
118
|
+
(a, b) =>
|
|
119
|
+
a.dist - b.dist ||
|
|
120
|
+
(b.node.degree || 0) - (a.node.degree || 0) ||
|
|
121
|
+
a.node.id.localeCompare(b.node.id),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const room = Math.max(0, maxNodes - seeds.length);
|
|
125
|
+
const kept = [...seeds, ...rest.slice(0, room)];
|
|
126
|
+
const truncated = rest.length - Math.min(rest.length, room);
|
|
127
|
+
|
|
128
|
+
const keptIds = new Set(kept.map((e) => e.node.id));
|
|
129
|
+
const edges = graph.edges
|
|
130
|
+
.filter((e) => keptIds.has(e.from) && keptIds.has(e.to))
|
|
131
|
+
.sort(
|
|
132
|
+
(a, b) =>
|
|
133
|
+
a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { nodes: kept.map((e) => ({ ...e.node, dist: e.dist })), edges, truncated };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render the model as mermaid text.
|
|
141
|
+
*
|
|
142
|
+
* @param {{nodes: object[], edges: object[], truncated: number}} model
|
|
143
|
+
* @param {{direction?: string, baseCommit?: string}} [opts]
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function render(model, opts = {}) {
|
|
147
|
+
const dir = opts.direction || "LR";
|
|
148
|
+
const idx = new Map(model.nodes.map((n, i) => [n.id, nodeKey(i)]));
|
|
149
|
+
const L = [`flowchart ${dir}`];
|
|
150
|
+
|
|
151
|
+
for (const n of model.nodes) {
|
|
152
|
+
const key = idx.get(n.id);
|
|
153
|
+
// Seeds are the thing that changed; everything else is downstream of it.
|
|
154
|
+
L.push(n.dist === 0 ? ` ${key}["${label(n)}"]` : ` ${key}("${label(n)}")`);
|
|
155
|
+
}
|
|
156
|
+
for (const e of model.edges) {
|
|
157
|
+
L.push(` ${idx.get(e.from)} -->|${e.kind}| ${idx.get(e.to)}`);
|
|
158
|
+
}
|
|
159
|
+
if (model.truncated > 0) {
|
|
160
|
+
L.push(` more["+${model.truncated} more, not drawn"]`);
|
|
161
|
+
}
|
|
162
|
+
return L.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main() {
|
|
166
|
+
// Same argument contract as graph-affected.mjs, deliberately: positional parts
|
|
167
|
+
// are one name. Several seeds are comma-separated inside that name, so a PR
|
|
168
|
+
// touching three symbols draws one diagram rather than three.
|
|
169
|
+
const { flags, positional } = parseFlags(process.argv.slice(2));
|
|
170
|
+
const name = positional.join(" ").trim();
|
|
171
|
+
if (!name) {
|
|
172
|
+
console.error('graph-mermaid: a symbol name is required, e.g. graph-mermaid "Flight"');
|
|
173
|
+
process.exit(64);
|
|
174
|
+
}
|
|
175
|
+
const symbols = name
|
|
176
|
+
.split(",")
|
|
177
|
+
.map((s) => s.trim())
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
|
|
180
|
+
const graphPath = flags.graph && flags.graph !== true ? resolve(flags.graph) : defaultGraphPath();
|
|
181
|
+
let graph;
|
|
182
|
+
try {
|
|
183
|
+
graph = loadGraph(graphPath);
|
|
184
|
+
} catch {
|
|
185
|
+
// Not an error to shout about: a repo with no graph yet simply has no
|
|
186
|
+
// diagram, and the caller records the reason instead of drawing nothing and
|
|
187
|
+
// calling it empty.
|
|
188
|
+
console.error(`graph-mermaid: no code graph at ${graphPath} - run /multi-agent:graph first`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const depth = Number(flags.depth && flags.depth !== true ? flags.depth : 2);
|
|
193
|
+
// A Set, not an array: `affected` calls `kinds.has(...)`, and an array there
|
|
194
|
+
// silently matches nothing rather than failing.
|
|
195
|
+
const kinds = flags.kind && flags.kind !== true ? new Set(String(flags.kind).split(",")) : null;
|
|
196
|
+
const maxNodes = Number(
|
|
197
|
+
flags["max-nodes"] && flags["max-nodes"] !== true ? flags["max-nodes"] : DEFAULT_MAX_NODES,
|
|
198
|
+
);
|
|
199
|
+
const direction = flags.direction === "TD" ? "TD" : "LR";
|
|
200
|
+
|
|
201
|
+
const seedNodes = symbols.flatMap((s) => findByName(graph, s));
|
|
202
|
+
if (seedNodes.length === 0) {
|
|
203
|
+
console.error(`graph-mermaid: no node named ${symbols.join(", ")} in ${graphPath}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
// `affected` takes node OBJECTS, not ids - it reads `n.id` off each seed.
|
|
207
|
+
// Passing ids made every traversal start from `undefined` and return nothing,
|
|
208
|
+
// which looks exactly like "nothing depends on this".
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const seeds = seedNodes.filter((n) => !seen.has(n.id) && seen.add(n.id));
|
|
211
|
+
const hits = affected({ graph, seeds, depth, kinds });
|
|
212
|
+
const model = buildModel(
|
|
213
|
+
graph,
|
|
214
|
+
hits,
|
|
215
|
+
seeds.map((n) => n.id),
|
|
216
|
+
maxNodes,
|
|
217
|
+
);
|
|
218
|
+
const mermaid = render(model, { direction });
|
|
219
|
+
|
|
220
|
+
if (flags.json) {
|
|
221
|
+
console.log(
|
|
222
|
+
JSON.stringify(
|
|
223
|
+
{
|
|
224
|
+
mermaid,
|
|
225
|
+
nodes: model.nodes.map((n) => n.id),
|
|
226
|
+
edges: model.edges.map((e) => ({ from: e.from, to: e.to, kind: e.kind })),
|
|
227
|
+
truncated: model.truncated,
|
|
228
|
+
baseCommit: graph.baseCommit || null,
|
|
229
|
+
},
|
|
230
|
+
null,
|
|
231
|
+
2,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log("```mermaid");
|
|
238
|
+
console.log(mermaid);
|
|
239
|
+
console.log("```");
|
|
240
|
+
// The pin, printed beside the diagram rather than inside it: a graph built at
|
|
241
|
+
// an older commit draws an older blast radius, and a reader who cannot see
|
|
242
|
+
// which commit it came from has no way to notice.
|
|
243
|
+
if (graph.baseCommit) {
|
|
244
|
+
console.log("");
|
|
245
|
+
console.log(`_Graph at \`${String(graph.baseCommit).slice(0, 12)}\`._`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
250
|
+
main();
|
|
251
|
+
}
|
|
@@ -681,8 +681,17 @@ EOF
|
|
|
681
681
|
# a subject is a task title and travels through surfaces that are not a terminal.
|
|
682
682
|
subjects() {
|
|
683
683
|
need_jq
|
|
684
|
-
local state want="${1:-}"
|
|
684
|
+
local state want="${1:-}" new_only=""
|
|
685
|
+
# `--new` narrows to phases that have no tile yet, which is how a deferred
|
|
686
|
+
# registration batch (Phase 0 at Step -1, the rest once depth is known) avoids
|
|
687
|
+
# asking the host to create a tile it already has. A phase whose tasklist_id was
|
|
688
|
+
# never recorded has, as far as anything can tell, no tile.
|
|
689
|
+
if [ "$want" = "--new" ]; then new_only=1; want=""; fi
|
|
685
690
|
state=$(load_state)
|
|
691
|
+
local new_ids=""
|
|
692
|
+
if [ -n "$new_only" ]; then
|
|
693
|
+
new_ids=" $(echo "$state" | jq -r '[.phases[]? | select(((.meta.tasklist_id // "") | tostring) == "") | .id] | join(" ")') "
|
|
694
|
+
fi
|
|
686
695
|
local prices_json='{"prices":{}}'
|
|
687
696
|
if [ -f "$COST_TABLE" ]; then
|
|
688
697
|
prices_json=$(cat "$COST_TABLE" 2>/dev/null) || prices_json='{"prices":{}}'
|
|
@@ -704,6 +713,9 @@ subjects() {
|
|
|
704
713
|
while IFS=$'\x1f' read -r pid pname pstatus p_start p_end pmodel ptok pusd; do
|
|
705
714
|
[ -n "$pid" ] || continue
|
|
706
715
|
[ -z "$want" ] || [ "$want" = "$pid" ] || continue
|
|
716
|
+
if [ -n "$new_only" ]; then
|
|
717
|
+
case "$new_ids" in *" $pid "*) ;; *) continue ;; esac
|
|
718
|
+
fi
|
|
707
719
|
local line="Phase $pid $pname"
|
|
708
720
|
[ -n "$pmodel" ] && line="$line - $pmodel"
|
|
709
721
|
local s_ep e_ep el=""
|
|
@@ -755,10 +767,33 @@ subjects() {
|
|
|
755
767
|
# already does for a different reason.
|
|
756
768
|
tiles_script() {
|
|
757
769
|
need_jq
|
|
770
|
+
local new_only="${1:-}"
|
|
758
771
|
local state; state=$(load_state)
|
|
759
772
|
local has_subs
|
|
760
773
|
has_subs=$(echo "$state" | jq '[.phases[]?.subs[]?] | length')
|
|
761
774
|
|
|
775
|
+
# A list carrying sub-steps has to be rebuilt whole, so the rebuild wins over a
|
|
776
|
+
# narrowed batch: appending to it would land the new rows after Phase 7.
|
|
777
|
+
[ "${has_subs:-0}" -gt 0 ] && new_only=""
|
|
778
|
+
|
|
779
|
+
if [ -n "$new_only" ]; then
|
|
780
|
+
local pending_new
|
|
781
|
+
pending_new=$(subjects --new)
|
|
782
|
+
if [ -z "$pending_new" ]; then
|
|
783
|
+
printf 'Every registered phase already has a tile - nothing to create.\n'
|
|
784
|
+
return 0
|
|
785
|
+
fi
|
|
786
|
+
printf 'REQUIRED - create one native tile per phase below, in this exact order.
|
|
787
|
+
'
|
|
788
|
+
printf 'Tiles already created keep their place; every phase here is numbered
|
|
789
|
+
'
|
|
790
|
+
printf 'above them, so the widget stays in phase order.
|
|
791
|
+
|
|
792
|
+
'
|
|
793
|
+
printf '%s\n' "$pending_new" | sed 's/^/ TaskCreate(subject: "/; s/$/")/'
|
|
794
|
+
return 0
|
|
795
|
+
fi
|
|
796
|
+
|
|
762
797
|
if [ "${has_subs:-0}" -gt 0 ]; then
|
|
763
798
|
printf 'The tile list has sub-steps now, and the widget orders by CREATION.
|
|
764
799
|
'
|
|
@@ -1346,6 +1381,8 @@ EOF
|
|
|
1346
1381
|
|
|
1347
1382
|
tiles)
|
|
1348
1383
|
need_jq
|
|
1384
|
+
tiles_new=""
|
|
1385
|
+
[ "${1:-}" = "--new" ] && tiles_new=1
|
|
1349
1386
|
tiles_state=$(load_state)
|
|
1350
1387
|
tiles_count=$(echo "$tiles_state" | jq '[.phases[]?] | length')
|
|
1351
1388
|
[ "${tiles_count:-0}" -gt 0 ] || {
|
|
@@ -1368,7 +1405,7 @@ EOF
|
|
|
1368
1405
|
# tools it has. And the fallback is not enough on its own: a user looking
|
|
1369
1406
|
# at a missing widget needs the one command that brings it back, which is
|
|
1370
1407
|
# why the opt-in is printed next to it.
|
|
1371
|
-
tiles_script
|
|
1408
|
+
tiles_script "$tiles_new"
|
|
1372
1409
|
echo
|
|
1373
1410
|
echo "At every phase boundary re-run \`phase-tracker.sh subjects <id>\` and"
|
|
1374
1411
|
echo "pass that line as the subject of the TaskUpdate. The subject is the only"
|