@mmerterden/multi-agent-pipeline 16.2.2 → 16.4.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.
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ // plan-coverage-gate.mjs - the plan's own work must be accounted for before commit.
3
+ //
4
+ // Phase 4 answers "is what changed correct" and Step 1.45 answers "did every
5
+ // planned TEST land". Nothing answered "did every planned TASK land". The
6
+ // criteria manifest's denominator is rule IDs, not plan steps, and the only
7
+ // place a step's status surfaced was render-work-summary.sh at Phase 7 - a
8
+ // report, printed after the commit. So a plan with seven steps could ship five
9
+ // and read as done.
10
+ //
11
+ // Pattern source: github/spec-kit templates/commands/converge.md. Two of its
12
+ // properties are copied deliberately:
13
+ // - it assesses the CURRENT STATE, not a diff. A file the plan promised is
14
+ // either on disk or it is not; git history does not enter into it.
15
+ // - a clean run writes and says nothing. No empty "Convergence" section, no
16
+ // noise to scroll past.
17
+ // What is not copied: spec-kit appends remediation tasks to tasks.md. This gate
18
+ // only reports and fails; appending work to a plan that the user already
19
+ // approved is a Phase 2 decision, not a gate's.
20
+ //
21
+ // A step is accounted for when it is `completed`, or `skipped` with a
22
+ // skipReason, or `failed` with a failureReason. A bare `pending` /
23
+ // `in_progress` is the gap this exists to catch: not "we decided not to do it"
24
+ // but "nobody noticed it was still open".
25
+ //
26
+ // Usage:
27
+ // plan-coverage-gate.mjs --state <agent-state.json> [--analysis <doc.md>] [--root <dir>] [--json]
28
+ // plan-coverage-gate.mjs --todos <plan-todos.json> [--analysis <doc.md>] [--root <dir>] [--json]
29
+ //
30
+ // Exit codes: 0 clean, 1 coverage gap, 2 usage / parse error.
31
+
32
+ import { readFileSync, existsSync } from "node:fs";
33
+ import { join, isAbsolute } from "node:path";
34
+
35
+ export function auditTodos(todos) {
36
+ const gaps = [];
37
+ const accounted = [];
38
+ for (const todo of Array.isArray(todos) ? todos : []) {
39
+ const id = todo?.id ?? "(no id)";
40
+ const task = todo?.task ?? todo?.title ?? "(no task text)";
41
+ const status = todo?.status;
42
+ if (status === "completed") {
43
+ accounted.push({ id, status });
44
+ } else if (status === "skipped") {
45
+ if (String(todo?.skipReason ?? "").trim()) accounted.push({ id, status });
46
+ else gaps.push({ id, task, status, why: "skipped with no skipReason" });
47
+ } else if (status === "failed") {
48
+ if (String(todo?.failureReason ?? "").trim()) accounted.push({ id, status });
49
+ else gaps.push({ id, task, status, why: "failed with no failureReason" });
50
+ } else {
51
+ gaps.push({ id, task, status: status ?? "(absent)", why: "never reached a terminal status" });
52
+ }
53
+ }
54
+ return { gaps, accounted };
55
+ }
56
+
57
+ // Section 14 rows tagged `Add new` name files the analysis promised would
58
+ // exist. The tag vocabulary is Locked 16 and identical in both languages, so
59
+ // the heading is matched on the number rather than on either title.
60
+ export function filesToAdd(markdown) {
61
+ const lines = String(markdown).split("\n");
62
+ const start = lines.findIndex((l) => /^#{1,3}\s*14\.\s/.test(l));
63
+ if (start === -1) return null;
64
+ const rows = [];
65
+ for (let i = start + 1; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ if (/^#{1,3}\s*(1[5-9]|2[0-9])\.\s/.test(line)) break;
68
+ if (!line.trim().startsWith("|")) continue;
69
+ const cells = line
70
+ .split("|")
71
+ .slice(1, -1)
72
+ .map((c) => c.trim());
73
+ if (cells.length < 2) continue;
74
+ const path = cells[0].replace(/`/g, "").trim();
75
+ const tag = cells[1].replace(/`/g, "").trim();
76
+ if (!path || /^-+$/.test(path)) continue;
77
+ if (path.includes("<") || tag.includes("<")) continue;
78
+ // No header-row filter: the tag column of the header reads "Etiket / Tag",
79
+ // which the `Add new` test below already rejects. The filter that used to be
80
+ // here matched on the PATH column instead and would have skipped a real row
81
+ // whose path begins with a `File/` directory.
82
+ if (!/^add new$/i.test(tag)) continue;
83
+ rows.push(path);
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ function readJson(path) {
89
+ return JSON.parse(readFileSync(path, "utf8"));
90
+ }
91
+
92
+ const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
93
+ if (isMain) {
94
+ const args = process.argv.slice(2);
95
+ const flag = (name) => {
96
+ const i = args.indexOf(name);
97
+ return i === -1 ? undefined : args[i + 1];
98
+ };
99
+ // Repeatable: `state.analysis.docPath[]` is an array, one document per
100
+ // platform. Reading only the first would check one platform's promised files
101
+ // and report the verdict as if it covered the run.
102
+ const flagAll = (name) => {
103
+ const out = [];
104
+ for (let i = 0; i < args.length; i++) {
105
+ if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
106
+ }
107
+ return out;
108
+ };
109
+
110
+ if (args.includes("--help") || args.length === 0) {
111
+ process.stdout.write(
112
+ [
113
+ "usage: plan-coverage-gate.mjs --state <agent-state.json> [--analysis <doc.md>] [--root <dir>] [--json]",
114
+ " plan-coverage-gate.mjs --todos <plan-todos.json> [--analysis <doc.md>] [--root <dir>] [--json]",
115
+ "",
116
+ "Fails when a plan step never reached a terminal status, when a skip or",
117
+ "failure carries no reason, or when an analysis Section 14 `Add new` file",
118
+ "is missing from the tree. Run before Phase 6 commit.",
119
+ "",
120
+ ].join("\n"),
121
+ );
122
+ process.exit(args.includes("--help") ? 0 : 2);
123
+ }
124
+
125
+ const statePath = flag("--state");
126
+ const todosPath = flag("--todos");
127
+ const analysisPaths = flagAll("--analysis");
128
+ const root = flag("--root") ?? process.cwd();
129
+ const asJson = args.includes("--json");
130
+
131
+ if (!statePath && !todosPath) {
132
+ process.stderr.write("plan-coverage-gate: pass --state or --todos\n");
133
+ process.exit(2);
134
+ }
135
+
136
+ let todos;
137
+ try {
138
+ if (todosPath) {
139
+ const parsed = readJson(todosPath);
140
+ todos = parsed?.todos ?? parsed;
141
+ } else {
142
+ const state = readJson(statePath);
143
+ todos = state?.plan?.todos ?? state?.plan?.todoList?.todos ?? null;
144
+ }
145
+ } catch (e) {
146
+ process.stderr.write(`plan-coverage-gate: cannot read the plan: ${e.message}\n`);
147
+ process.exit(2);
148
+ }
149
+
150
+ // No plan at all is not a pass, and neither is an empty one: a Phase 2 that
151
+ // produced no steps, or a state whose todos got cleared, would otherwise read
152
+ // as "0/0 accounted for" and let the commit through. A Short run has no Phase
153
+ // 2, and the caller is expected to skip this gate for those modes rather than
154
+ // let it report clean.
155
+ if (!Array.isArray(todos) || todos.length === 0) {
156
+ const msg = Array.isArray(todos)
157
+ ? "the plan has zero steps - that is an unusable plan, not a clean one; skip this gate explicitly for modes with no Phase 2"
158
+ : "no plan todos found - if this mode has no Phase 2, skip this gate explicitly";
159
+ if (asJson)
160
+ process.stdout.write(`${JSON.stringify({ verdict: "unusable", reason: msg }, null, 2)}\n`);
161
+ else process.stderr.write(`plan-coverage-gate: ${msg}\n`);
162
+ process.exit(2);
163
+ }
164
+
165
+ const { gaps, accounted } = auditTodos(todos);
166
+
167
+ let missingFiles = [];
168
+ let filesVerdict;
169
+ if (analysisPaths.length) {
170
+ const allRows = [];
171
+ const docsWithoutSection = [];
172
+ for (const docPath of analysisPaths) {
173
+ let rows = null;
174
+ try {
175
+ rows = filesToAdd(readFileSync(docPath, "utf8"));
176
+ } catch (e) {
177
+ process.stderr.write(`plan-coverage-gate: cannot read the analysis doc: ${e.message}\n`);
178
+ process.exit(2);
179
+ }
180
+ if (rows === null) docsWithoutSection.push(docPath);
181
+ else allRows.push(...rows);
182
+ }
183
+ const unique = [...new Set(allRows)];
184
+ missingFiles = unique.filter((p) => !existsSync(isAbsolute(p) ? p : join(root, p)));
185
+ const docNote = docsWithoutSection.length
186
+ ? `; ${docsWithoutSection.length} doc(s) carried no Section 14`
187
+ : "";
188
+ filesVerdict = unique.length
189
+ ? `${unique.length - missingFiles.length}/${unique.length} promised files present across ${analysisPaths.length} doc(s)${docNote}`
190
+ : `no Section 14 rows in ${analysisPaths.length} doc(s)${docNote}`;
191
+ } else {
192
+ filesVerdict = "skipped (no --analysis given)";
193
+ }
194
+
195
+ const failed = gaps.length > 0 || missingFiles.length > 0;
196
+ const result = {
197
+ verdict: failed ? "gap" : "clean",
198
+ todos: { total: todos.length, accounted: accounted.length, gaps },
199
+ filesToAdd: { verdict: filesVerdict, missing: missingFiles },
200
+ };
201
+
202
+ if (asJson) {
203
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
204
+ } else if (failed) {
205
+ process.stderr.write(
206
+ `plan coverage: ${gaps.length} unaccounted step(s), ${missingFiles.length} missing file(s)\n`,
207
+ );
208
+ for (const g of gaps) process.stderr.write(` ${g.id} [${g.status}] ${g.task} - ${g.why}\n`);
209
+ for (const f of missingFiles)
210
+ process.stderr.write(` missing file promised by analysis Section 14: ${f}\n`);
211
+ process.stderr.write(` files-to-add: ${filesVerdict}\n`);
212
+ } else {
213
+ // spec-kit's rule: a clean pass says one line and writes nothing.
214
+ process.stdout.write(
215
+ `plan coverage: ${accounted.length}/${todos.length} steps accounted for; files-to-add: ${filesVerdict}\n`,
216
+ );
217
+ }
218
+ process.exit(failed ? 1 : 0);
219
+ }
@@ -15,6 +15,12 @@
15
15
  * firstPassClean - iteration 1 had 0 accepted BLOCKING
16
16
  * accepted/deferred/rejected (final) - triage outcome
17
17
  * reviewFindingsRaw -> acceptedRatio - signal-to-noise of the reviewers
18
+ * reviewSignal.perReviewer - the same ratio per reviewer model, so
19
+ * "which model is worth dispatching" stops
20
+ * being a guess. Needs the anonymization
21
+ * label map to attribute accepted findings;
22
+ * without it the raw counts still land and
23
+ * attribution reports "unavailable".
18
24
  * consensusVerdict - unanimous-* / split / unverified
19
25
  * buildPassed - per-repo build outcome
20
26
  *
@@ -79,9 +85,42 @@ if (iterations.length) {
79
85
  let rawFindings = 0;
80
86
  let acceptedAll = 0;
81
87
  let classifiedAll = 0;
88
+ // Per-reviewer tallies. `unknown` is its own bucket: a reviewer entry written
89
+ // before the shape was typed must not be folded into a named model, or the
90
+ // ratio reads as coverage that was never measured.
91
+ const rawByModel = new Map();
92
+ // Separate denominator: only iterations that carried a label map can contribute
93
+ // an accepted count, so mixing them with map-less iterations understates every
94
+ // reviewer's ratio. A resumed run that upgraded mid-flight is exactly that case.
95
+ const rawAttributableByModel = new Map();
96
+ const acceptedByModel = new Map();
97
+ let mappedIterations = 0;
98
+ let acceptedUnattributed = 0;
82
99
  for (const it of iterations) {
100
+ const labelToModel = it?.anonymizationMap?.labelToModel || it?.triage?.labelToModel || null;
83
101
  for (const r of Array.isArray(it.reviewers) ? it.reviewers : []) {
84
- rawFindings += Array.isArray(r?.findings) ? r.findings.length : 0;
102
+ const n = Array.isArray(r?.findings) ? r.findings.length : 0;
103
+ rawFindings += n;
104
+ const model = typeof r?.model === "string" && r.model ? r.model : "unknown";
105
+ rawByModel.set(model, (rawByModel.get(model) || 0) + n);
106
+ if (labelToModel)
107
+ rawAttributableByModel.set(model, (rawAttributableByModel.get(model) || 0) + n);
108
+ }
109
+ if (labelToModel) mappedIterations += 1;
110
+ {
111
+ const acceptedList = Array.isArray(it?.triage?.accepted) ? it.triage.accepted : [];
112
+ for (const f of acceptedList) {
113
+ const model = labelToModel ? labelToModel[f?.foundBy] : undefined;
114
+ // Every accepted finding lands in exactly one bucket, so
115
+ // acceptedAll === sum(perReviewer.accepted) + acceptedUnattributed holds.
116
+ // Two things arrive here: a deterministic-gate finding, which carries no
117
+ // foundBy by design, and any finding from an iteration that had no map.
118
+ if (!model) {
119
+ acceptedUnattributed += 1;
120
+ continue;
121
+ }
122
+ acceptedByModel.set(model, (acceptedByModel.get(model) || 0) + 1);
123
+ }
85
124
  }
86
125
  const t = it.triage || {};
87
126
  acceptedAll += Array.isArray(t.accepted) ? t.accepted.length : 0;
@@ -125,6 +164,32 @@ const metrics = {
125
164
  classified: classifiedAll,
126
165
  acceptedAll,
127
166
  acceptedRatio: rawFindings > 0 ? Math.round((acceptedAll / rawFindings) * 100) / 100 : null,
167
+ perReviewer: [...rawByModel.entries()]
168
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
169
+ .map(([model, raw]) => {
170
+ const rawAttributable = rawAttributableByModel.get(model) || 0;
171
+ const accepted = acceptedByModel.get(model) || 0;
172
+ return {
173
+ model,
174
+ rawFindings: raw,
175
+ rawAttributable,
176
+ accepted: mappedIterations > 0 ? accepted : null,
177
+ // Denominator is the attributable raw count, never the total: a run
178
+ // whose second iteration lost its map would otherwise read as if the
179
+ // reviewer produced noise it was never credited for.
180
+ acceptedRatio:
181
+ mappedIterations > 0 && rawAttributable > 0
182
+ ? Math.round((accepted / rawAttributable) * 100) / 100
183
+ : null,
184
+ };
185
+ }),
186
+ // "map" means the mapping existed, not that it resolved something: zero
187
+ // accepted findings under a present map is a real 0, and reporting it as
188
+ // "unavailable" is the absent-vs-zero conflation this metric set exists to
189
+ // avoid. A number, not a "1/2" string, because metrics.jsonl is aggregated.
190
+ perReviewerAttribution: mappedIterations > 0 ? "map" : "unavailable",
191
+ mappedIterations,
192
+ acceptedUnattributed,
128
193
  },
129
194
  consensusVerdict: lastTriage.consensus ? lastTriage.consensus.verdict : null,
130
195
  buildPassed,
@@ -95,10 +95,15 @@ function parseFrontMatter(text) {
95
95
  }
96
96
 
97
97
  function sectionBody(lines, keywords, level = 2) {
98
- const head = new RegExp(`^#{${level}}\\s+\\d+(\\.\\d+)*\\.?\\s`);
98
+ // Opened by a heading at exactly `level`; closed by the next heading at that
99
+ // level OR ANY SHALLOWER one. A sub-section that is the last of its parent has
100
+ // no sibling after it, so terminating only on equal depth runs the body into
101
+ // the following section and audits its rows as if they belonged here.
102
+ const open = new RegExp(`^#{${level}}\\s+\\d+(\\.\\d+)*\\.?\\s`);
103
+ const close = new RegExp(`^#{1,${level}}\\s`);
99
104
  let start = -1;
100
105
  for (let i = 0; i < lines.length; i++) {
101
- if (head.test(lines[i]) && keywords.some((k) => lines[i].includes(k))) {
106
+ if (open.test(lines[i]) && keywords.some((k) => lines[i].includes(k))) {
102
107
  start = i;
103
108
  break;
104
109
  }
@@ -106,7 +111,7 @@ function sectionBody(lines, keywords, level = 2) {
106
111
  if (start < 0) return null;
107
112
  let end = lines.length;
108
113
  for (let i = start + 1; i < lines.length; i++) {
109
- if (head.test(lines[i])) {
114
+ if (close.test(lines[i])) {
110
115
  end = i;
111
116
  break;
112
117
  }
@@ -120,7 +120,7 @@ PR: {prUrl}
120
120
  # Scenario 2
121
121
  ```
122
122
 
123
- First line: PR URL (one-click jump). POST `/rest/api/2/issue/{id}/comment` with heredoc + `jq --rawfile` + `curl --data-binary @file`. Real newlines, no HTML entities.
123
+ First line: PR URL (one-click jump). Run the converted body through `node "$HOME/.claude/scripts/jira-wiki-escape.mjs"` before POST - Jira turns `:)` `(x)` `(!)` `(/)` into emoticon images, including inside `{{monospace}}`. POST `/rest/api/2/issue/{id}/comment` with heredoc + `jq --rawfile` + `curl --data-binary @file` from the escaped file. Real newlines, no HTML entities.
124
124
 
125
125
  ### Confluence page
126
126
 
@@ -40,7 +40,7 @@ A `C` section fires on: Design Reference → Figma URL; Screenshots → pasted i
40
40
  - **Figma** (`FIGMA_URL` set) - 3-tier chain (MCP → REST `figma` PAT → user screenshot); standalone command, MCP allowed here.
41
41
  - **Swagger** (`SWAGGER_URL` set or contract pasted) - fetch the spec, extract ONLY the referenced endpoints (paths/operationIds/tags named in `FREE_TEXT`, else the pointed-at group): method + path + one-line summary + key request/response fields. Never crawl the whole spec. Fetch failure → link-only or omit.
42
42
  - **Screenshots** - staged for the step 8 attachment opt-in, referenced inline with `!file|thumbnail!`.
43
- 7. **Draft + clarifying questions** - compose summary (`{minedPrefix} {title}`, 255 trunc) + description from the type's **standard template** in Jira wiki markup (`h3.`), auto-sizing conditional sections, humanizer pass, in `outputLanguage`; body to a file for `jq -n --rawfile` (UTF-8 verbatim, `--data-binary @file`, no re-encode). Ask ONLY genuinely unknown fields: component, epic (when usage rate is high), priority (when norm ambiguous), labels, assignee, sprint vs backlog, required customs, and any always-present section still empty (Bug Steps/Environment, or a Task/Story with no derivable Scope/AC). Never invent content the user did not supply.
43
+ 7. **Draft + clarifying questions** - compose summary (`{minedPrefix} {title}`, 255 trunc) + description from the type's **standard template** in Jira wiki markup (`h3.`), auto-sizing conditional sections, humanizer pass, in `outputLanguage`; body to a file for `jq -n --rawfile` (UTF-8 verbatim, `--data-binary @file`, no re-encode), passed through `node "$HOME/.claude/scripts/jira-wiki-escape.mjs"` first - the description field expands `:)` `(x)` `(!)` `(/)` into emoticon images. Ask ONLY genuinely unknown fields: component, epic (when usage rate is high), priority (when norm ambiguous), labels, assignee, sprint vs backlog, required customs, and any always-present section still empty (Bug Steps/Environment, or a Task/Story with no derivable Scope/AC). Never invent content the user did not supply.
44
44
  8. **Full preview + approval gate (never skipped)** - render the complete issue (all fields + full description + attachment plan, uploads default off). Ask: `Approve` / `Edit` (apply → re-render → re-ask, loop) / `Cancel` (nothing created). No flag or mode bypasses this gate.
45
45
  9. **Create** - `POST /rest/api/2/issue` with `jq -n --rawfile` payload + `curl --data-binary @file`. 400 field errors → show verbatim, re-ask those fields, re-preview.
46
46
  10. **Post-create** (each failure-isolated: warn + continue): sprint chosen → `POST /rest/agile/1.0/sprint/{id}/issue` `{"issues":["KEY"]}`; Figma → `POST /rest/api/2/issue/{KEY}/remotelink`; Swagger → `remotelink` with title "API contract"; screenshots opt-in → `POST /rest/api/2/issue/{KEY}/attachments` with `X-Atlassian-Token: no-check`.