@mmerterden/multi-agent-pipeline 15.0.0 → 15.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +9 -9
  3. package/README.tr.md +9 -9
  4. package/SECURITY.md +43 -0
  5. package/docs/adr/0007-multi-tool-adapter-framework.md +1 -1
  6. package/docs/architecture.md +9 -9
  7. package/docs/ecosystem.md +10 -10
  8. package/index.js +4 -1
  9. package/install/_common.mjs +44 -2
  10. package/install/_platform-filter.mjs +6 -131
  11. package/install/_plugin-skills.mjs +17 -28
  12. package/install/claude.mjs +111 -6
  13. package/install/codex.mjs +0 -3
  14. package/install/copilot.mjs +36 -1
  15. package/install/index.mjs +3 -1
  16. package/package.json +2 -2
  17. package/pipeline/commands/multi-agent/refactor/SKILL.md +36 -1
  18. package/pipeline/commands/multi-agent/scan/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/stack/SKILL.md +18 -8
  20. package/pipeline/commands/multi-agent/update/SKILL.md +31 -4
  21. package/pipeline/lib/parse-complaints.sh +12 -2
  22. package/pipeline/multi-agent-refs/complaint-analysis-template.md +1 -1
  23. package/pipeline/multi-agent-refs/phases/operations.md +7 -1
  24. package/pipeline/multi-agent-refs/phases/phase-7-report.md +6 -0
  25. package/pipeline/multi-agent-refs/tracker-contract.md +2 -1
  26. package/pipeline/preferences-template.json +6 -0
  27. package/pipeline/schemas/prefs.schema.json +27 -0
  28. package/pipeline/scripts/README.md +4 -3
  29. package/pipeline/scripts/check-derived-drift.mjs +5 -2
  30. package/pipeline/scripts/match-skills.mjs +4 -0
  31. package/pipeline/scripts/migrate-prefs.mjs +5 -1
  32. package/pipeline/scripts/phase-tracker.sh +19 -0
  33. package/pipeline/scripts/uninstall.mjs +3 -1
  34. package/pipeline/scripts/usage-report.mjs +457 -0
  35. package/pipeline/scripts/validate-complaint-doc.mjs +32 -11
  36. package/pipeline/skills/.skill-manifest.json +4 -4
  37. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +153 -90
  38. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +18 -8
  39. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +1 -1
@@ -0,0 +1,457 @@
1
+ /**
2
+ * usage-report.mjs - usage ping (opt-in) for the private mission-control dashboard.
3
+ *
4
+ * Emits one compact activity event keyed by run id (`taskId`). Called at each
5
+ * phase boundary (status=running, via phase-tracker.sh) and once at the end
6
+ * (terminal status, from Phase 7 report or the halt path). The server upserts
7
+ * by run id, so per-phase calls update ONE record - it always reflects the last
8
+ * phase a run reached, including runs that halt before Phase 7. Fire-and-forget,
9
+ * never throws, never delays the run.
10
+ *
11
+ * Payload is activity metadata only - who, command, mode, input type, repo,
12
+ * phase, outcome, halt cause, review iterations, duration, token spend, cost,
13
+ * per-phase breakdown, language, stack, plugins, integrations, credential
14
+ * health, invoked skills, error tags, version. No prompts, code, diffs, token
15
+ * values, or absolute paths.
16
+ *
17
+ * Enable + configure via prefs.global.usageLog:
18
+ * { "enabled": true,
19
+ * "endpoint": "https://mmerterden.vercel.app/api/usage/ingest",
20
+ * "token": "<shared ingest token>" } // token may instead come from
21
+ * // env MULTI_AGENT_USAGE_TOKEN
22
+ * Nothing is sent when enabled is not true or no token resolves.
23
+ *
24
+ * Usage:
25
+ * node usage-report.mjs --state /path/to/agent-state.json
26
+ * node usage-report.mjs --task-id PROJ-123 # resolves the state path
27
+ *
28
+ * @module pipeline/scripts/usage-report
29
+ */
30
+
31
+ import { readFileSync, existsSync, readdirSync, appendFileSync } from "fs";
32
+ import { homedir, platform } from "os";
33
+ import { dirname, join } from "path";
34
+ import { fileURLToPath } from "url";
35
+ import { execFileSync } from "child_process";
36
+ import { costUsd } from "./_cost.mjs";
37
+
38
+ const __dirname = dirname(fileURLToPath(import.meta.url));
39
+ const ENDPOINT_DEFAULT = "https://mmerterden.vercel.app/api/usage/ingest";
40
+ const TIMEOUT_MS = 2500;
41
+
42
+ function parseArgs(argv) {
43
+ const out = { state: null, taskId: null, phase: null, status: null, dryRun: false };
44
+ for (let i = 0; i < argv.length; i += 1) {
45
+ const a = argv[i];
46
+ if (a === "--state") out.state = argv[++i];
47
+ else if (a === "--task-id") out.taskId = argv[++i];
48
+ else if (a === "--phase") out.phase = argv[++i];
49
+ else if (a === "--status") out.status = argv[++i];
50
+ else if (a === "--dry-run") out.dryRun = true;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function readJson(path) {
56
+ try {
57
+ return JSON.parse(readFileSync(path, "utf-8"));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function resolveGlobalPrefs() {
64
+ const path =
65
+ process.env.MULTI_AGENT_PREFS ||
66
+ join(homedir(), ".claude", "multi-agent-preferences.json");
67
+ return readJson(path)?.global ?? {};
68
+ }
69
+
70
+ function resolvePrefs() {
71
+ return resolveGlobalPrefs().usageLog ?? {};
72
+ }
73
+
74
+ /**
75
+ * Resolve the ingest token without ever storing it in a synced file.
76
+ * Order: env, then prefs.usageLog.token, then the Keychain entry mapped by
77
+ * prefs.global.keychainMapping.usage_ingest (via credential-store.sh). This
78
+ * lets update flip usageLog.enabled on while the secret stays in the Keychain.
79
+ */
80
+ function resolveToken() {
81
+ if (process.env.MULTI_AGENT_USAGE_TOKEN) return process.env.MULTI_AGENT_USAGE_TOKEN;
82
+ const g = resolveGlobalPrefs();
83
+ const inline = g.usageLog?.token;
84
+ if (typeof inline === "string" && inline.trim()) return inline.trim();
85
+ const name = g.keychainMapping?.usage_ingest;
86
+ if (typeof name === "string" && name.trim()) {
87
+ const store = join(homedir(), ".claude", "lib", "credential-store.sh");
88
+ if (existsSync(store)) {
89
+ try {
90
+ const out = execFileSync("bash", [store, "get", name.trim()], {
91
+ encoding: "utf-8",
92
+ timeout: 4000,
93
+ stdio: ["ignore", "pipe", "ignore"],
94
+ }).trim();
95
+ if (out) return out;
96
+ } catch {
97
+ /* keychain unavailable - stay silent */
98
+ }
99
+ }
100
+ }
101
+ return "";
102
+ }
103
+
104
+ function enabledPlugins(state) {
105
+ const root = state.projectRoot || process.cwd();
106
+ const settings = readJson(join(root, ".claude", "settings.json"));
107
+ const raw = settings?.enabledPlugins ?? settings?.plugins ?? [];
108
+ const list = Array.isArray(raw) ? raw : Object.keys(raw || {});
109
+ return list
110
+ .map((p) => (typeof p === "string" ? p.split("@")[0] : null))
111
+ .filter(Boolean)
112
+ .slice(0, 20);
113
+ }
114
+
115
+ function integrationTags(state) {
116
+ const apps = new Set();
117
+ if (state.jiraId) apps.add("jira");
118
+ if (state.remoteType) apps.add(String(state.remoteType).toLowerCase());
119
+ if (state.githubIssue) apps.add("github");
120
+ if (state.confluenceSpace) apps.add("confluence");
121
+ if (state.figmaAccess?.tier) apps.add("figma");
122
+ for (const call of state.telemetry?.mcpCalls ?? []) {
123
+ const tool = String(call?.tool || "");
124
+ const m = tool.match(/^mcp__([a-z0-9_]+?)__/i) || tool.match(/^mcp__([a-z0-9_]+)/i);
125
+ if (m) apps.add(m[1].replace(/_/g, "-").toLowerCase());
126
+ }
127
+ return Array.from(apps).slice(0, 20);
128
+ }
129
+
130
+ function credHealth() {
131
+ const g = resolveGlobalPrefs();
132
+ const mapping = g.keychainMapping ?? {};
133
+ const on = Object.entries(mapping)
134
+ .filter(([, v]) => typeof v === "string" && v.trim())
135
+ .map(([k]) => k)
136
+ .slice(0, 40);
137
+
138
+ const svc = {};
139
+ for (const [name, st] of Object.entries(g.serviceStatus ?? {})) {
140
+ const err = String(st?.error || "").toLowerCase();
141
+ const expired = /401|403|expired|unauthor|revoked|invalid.token/.test(err);
142
+ svc[name] = {
143
+ s: st?.ok ? "ok" : expired ? "expired" : "fail",
144
+ at: st?.checkedAt || null,
145
+ };
146
+ }
147
+ return { on, svc };
148
+ }
149
+
150
+ function invokedCalls(state) {
151
+ const names = new Set();
152
+ for (const c of state.telemetry?.skillCalls ?? []) {
153
+ if (c?.skill) names.add(String(c.skill));
154
+ }
155
+ return Array.from(names).slice(0, 30);
156
+ }
157
+
158
+ function detectOs() {
159
+ const p = platform();
160
+ if (p === "darwin") return "macos";
161
+ if (p === "win32") return "windows";
162
+ if (p === "linux") return "linux";
163
+ return p;
164
+ }
165
+
166
+ function detectCli() {
167
+ const e = process.env;
168
+ if (e.MULTI_AGENT_CLI) return String(e.MULTI_AGENT_CLI).toLowerCase();
169
+ if (e.CLAUDECODE || e.CLAUDE_CODE_ENTRYPOINT || e.ANTHROPIC_CLI) return "claude";
170
+ if (e.CODEX_HOME || e.CODEX_SANDBOX || e.CODEX_CLI) return "codex";
171
+ if (e.COPILOT_CLI || e.GITHUB_COPILOT_CLI || e.GH_COPILOT || e.COPILOT_AGENT_ID) {
172
+ return "copilot";
173
+ }
174
+ return null;
175
+ }
176
+
177
+ function deriveContext(state) {
178
+ return {
179
+ lang: resolveGlobalPrefs().outputLanguage ?? null,
180
+ os: detectOs(),
181
+ cli: detectCli(),
182
+ stack: Array.isArray(state.stacks)
183
+ ? state.stacks.slice(0, 10)
184
+ : state.stack
185
+ ? [String(state.stack)]
186
+ : [],
187
+ plug: enabledPlugins(state),
188
+ apps: integrationTags(state),
189
+ creds: credHealth(),
190
+ calls: invokedCalls(state),
191
+ };
192
+ }
193
+
194
+ function resolveStatePath({ state, taskId }) {
195
+ if (state) return state;
196
+ if (process.env.MULTI_AGENT_STATE_FILE && existsSync(process.env.MULTI_AGENT_STATE_FILE)) {
197
+ return process.env.MULTI_AGENT_STATE_FILE;
198
+ }
199
+ if (!taskId) return null;
200
+
201
+ const logRoot = join(homedir(), ".claude", "logs", "multi-agent");
202
+ const direct = [
203
+ join(logRoot, taskId, "agent-state.json"),
204
+ join(process.cwd(), ".worktrees", taskId, "agent-state.json"),
205
+ join(process.cwd(), "agent-state.json"),
206
+ ];
207
+ const hit = direct.find((p) => existsSync(p));
208
+ if (hit) return hit;
209
+
210
+ try {
211
+ for (const project of readdirSync(logRoot)) {
212
+ const p = join(logRoot, project, taskId, "agent-state.json");
213
+ if (existsSync(p)) return p;
214
+ }
215
+ } catch {
216
+ /* logs dir missing */
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function packageVersion() {
222
+ const candidates = [
223
+ join(__dirname, "..", "..", "package.json"),
224
+ join(homedir(), ".claude", "multi-agent-pipeline", "package.json"),
225
+ ];
226
+ for (const p of candidates) {
227
+ const v = readJson(p)?.version;
228
+ if (v) return v;
229
+ }
230
+ return null;
231
+ }
232
+
233
+ function inputType(state) {
234
+ if (state.jiraId) return "jira";
235
+ if (state.githubIssue) return "github-issue";
236
+ return "free-text";
237
+ }
238
+
239
+ function commandOf(state) {
240
+ if (state.mode) return state.mode;
241
+ if (state.onlyDevelop) return "dev";
242
+ if (state.localMode) return "local";
243
+ return "full";
244
+ }
245
+
246
+ function resolveTracker(state) {
247
+ const id = state.taskId || state.shortId;
248
+ if (id == null) return null;
249
+ const root = join(homedir(), ".claude", "logs", "multi-agent");
250
+ const candidates = [
251
+ join(root, String(id), "tracker-state.json"),
252
+ state.artifactsPath ? join(state.artifactsPath, "tracker-state.json") : null,
253
+ ].filter(Boolean);
254
+ return candidates.find((p) => existsSync(p)) ?? null;
255
+ }
256
+
257
+ function phaseDurationSec(p) {
258
+ if (Number.isFinite(Number(p?.duration_ms))) {
259
+ return Math.round(Number(p.duration_ms) / 1000);
260
+ }
261
+ const s = p?.startedAt || p?.started_at || p?.start;
262
+ const f = p?.finishedAt || p?.finished_at || p?.completed_at || p?.end;
263
+ const sm = s ? Date.parse(s) : NaN;
264
+ const fm = f ? Date.parse(f) : NaN;
265
+ if (Number.isNaN(sm) || Number.isNaN(fm) || fm < sm) return null;
266
+ return Math.round((fm - sm) / 1000);
267
+ }
268
+
269
+ function trackerSummary(state) {
270
+ const path = resolveTracker(state);
271
+ if (!path) return { tk: null, cost: null, phs: null, failed: [], models: [] };
272
+ const tracker = readJson(path);
273
+ if (!tracker?.phases) return { tk: null, cost: null, phs: null, failed: [], models: [] };
274
+
275
+ let tin = 0;
276
+ let tout = 0;
277
+ let tcache = 0;
278
+ const phs = [];
279
+ const failed = [];
280
+ const models = new Set();
281
+ for (const [id, p] of Object.entries(tracker.phases)) {
282
+ if (p?.status === "failed") failed.push(id);
283
+ if (p?.model) models.add(String(p.model));
284
+ const pin = Number(p?.tokens_in || 0);
285
+ const pout = Number(p?.tokens_out || 0);
286
+ const pcache = Number(p?.tokens_cached || 0);
287
+ tin += pin;
288
+ tout += pout;
289
+ tcache += pcache;
290
+ phs.push({
291
+ p: Number.isNaN(Number(id)) ? id : Number(id),
292
+ st: p?.status || null,
293
+ du: phaseDurationSec(p),
294
+ tk: pin + pout + pcache || null,
295
+ });
296
+ }
297
+ phs.sort((a, b) => Number(a.p) - Number(b.p));
298
+
299
+ const total = tin + tout + tcache;
300
+ const table = readJson(join(__dirname, "cost-table.json"));
301
+ const rate = table?.prices?.opus;
302
+ const cost =
303
+ total > 0 && rate ? Number(costUsd(rate, tin, tout, tcache).toFixed(4)) : null;
304
+ return {
305
+ tk: total || null,
306
+ cost,
307
+ phs: phs.length ? phs : null,
308
+ failed,
309
+ models: Array.from(models).slice(0, 10),
310
+ };
311
+ }
312
+
313
+ function deriveErrors(state, failedPhases) {
314
+ const errs = new Set();
315
+ if (state.haltReason) errs.add(String(state.haltReason));
316
+ for (const id of failedPhases ?? []) errs.add(`phase-${id}-failed`);
317
+ const s = String(state.status || "").toLowerCase();
318
+ if (s === "failed") errs.add("run-failed");
319
+ return Array.from(errs).slice(0, 20);
320
+ }
321
+
322
+ function durationSec(state) {
323
+ const s = state.startedAt ? Date.parse(state.startedAt) : NaN;
324
+ const f = state.finishedAt ? Date.parse(state.finishedAt) : NaN;
325
+ if (Number.isNaN(s) || Number.isNaN(f) || f < s) return null;
326
+ return Math.round((f - s) / 1000);
327
+ }
328
+
329
+ function runId(state) {
330
+ const base = state.taskId ?? state.shortId;
331
+ return base == null ? null : String(base);
332
+ }
333
+
334
+ function eventTimestamp(state) {
335
+ return state.finishedAt || state.updatedAt || new Date().toISOString();
336
+ }
337
+
338
+ const TERMINAL_STATUSES = ["complete", "completed", "failed", "paused", "halted", "error"];
339
+
340
+ function isTerminalStatus(state) {
341
+ if (state.haltReason) return true;
342
+ return TERMINAL_STATUSES.includes(String(state.status || "").toLowerCase());
343
+ }
344
+
345
+ function appendErrorLedger(event) {
346
+ if (!event.errs || event.errs.length === 0) return;
347
+ const path = join(homedir(), ".claude", "logs", "multi-agent", "errors-ledger.jsonl");
348
+ const row = {
349
+ t: event.t,
350
+ id: event.id,
351
+ u: event.u,
352
+ c: event.c,
353
+ rp: event.rp,
354
+ ph: event.ph,
355
+ v: event.v,
356
+ errs: event.errs,
357
+ };
358
+ try {
359
+ appendFileSync(path, JSON.stringify(row) + "\n");
360
+ } catch {
361
+ /* logs dir missing or unwritable - never break the run */
362
+ }
363
+ }
364
+
365
+ function buildEvent(state) {
366
+ const spend = trackerSummary(state);
367
+ const ctx = deriveContext(state);
368
+ return {
369
+ id: runId(state),
370
+ t: eventTimestamp(state),
371
+ u: state.identity?.username || null,
372
+ c: commandOf(state),
373
+ m: state.mode || null,
374
+ ap: Boolean(state.autopilot),
375
+ lo: Boolean(state.localMode),
376
+ dev: Boolean(state.onlyDevelop),
377
+ in: inputType(state),
378
+ rp: state.project || null,
379
+ ph: typeof state.currentPhase === "number" ? state.currentPhase : null,
380
+ st: state.status || null,
381
+ hr: state.haltReason || null,
382
+ ri: Array.isArray(state.reviewIterations)
383
+ ? state.reviewIterations.length
384
+ : 0,
385
+ pr: Boolean(state.pr && (state.pr.url || state.pr.number)),
386
+ du: durationSec(state),
387
+ tk: spend.tk,
388
+ cost: spend.cost,
389
+ phs: spend.phs,
390
+ os: ctx.os,
391
+ cli: ctx.cli,
392
+ models: spend.models,
393
+ lang: ctx.lang,
394
+ stack: ctx.stack,
395
+ plug: ctx.plug,
396
+ apps: ctx.apps,
397
+ creds: ctx.creds,
398
+ calls: ctx.calls,
399
+ errs: deriveErrors(state, spend.failed),
400
+ v: packageVersion(),
401
+ };
402
+ }
403
+
404
+ async function post(endpoint, token, event) {
405
+ const ctrl = new AbortController();
406
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
407
+ try {
408
+ await fetch(endpoint, {
409
+ method: "POST",
410
+ headers: {
411
+ "Content-Type": "application/json",
412
+ "X-Usage-Token": token,
413
+ },
414
+ body: JSON.stringify(event),
415
+ signal: ctrl.signal,
416
+ });
417
+ } catch {
418
+ /* never break the run on a telemetry failure */
419
+ } finally {
420
+ clearTimeout(timer);
421
+ }
422
+ }
423
+
424
+ async function main() {
425
+ const args = parseArgs(process.argv.slice(2));
426
+ const prefs = resolvePrefs();
427
+ const token = resolveToken();
428
+ const endpoint = prefs.endpoint || ENDPOINT_DEFAULT;
429
+
430
+ if (!args.dryRun && (prefs.enabled !== true || !token)) return;
431
+
432
+ const statePath = resolveStatePath(args);
433
+ let state = statePath ? readJson(statePath) : null;
434
+
435
+ if (!state) {
436
+ if (!args.taskId) return;
437
+ state = { taskId: args.taskId };
438
+ }
439
+
440
+ if (args.phase != null) {
441
+ const n = Number(args.phase);
442
+ state.currentPhase = Number.isNaN(n) ? args.phase : n;
443
+ }
444
+ if (args.status) state.status = args.status;
445
+
446
+ const event = buildEvent(state);
447
+
448
+ if (args.dryRun) {
449
+ process.stdout.write(JSON.stringify(event, null, 2) + "\n");
450
+ return;
451
+ }
452
+
453
+ if (isTerminalStatus(state)) appendErrorLedger(event);
454
+ await post(endpoint, token, event);
455
+ }
456
+
457
+ main().catch(() => process.exit(0));
@@ -14,9 +14,13 @@
14
14
  // complaint_count, graylog_degraded); language in {tr, en}.
15
15
  // - Required sections present by bilingual title keyword: Summary, Triage,
16
16
  // Complaint Details, Open Questions, Methodology, References.
17
- // - Every triage-table row (a table row carrying a C-NN id) has a valid
18
- // verdict token: client:<layer> | bff:<layer> | core | insufficient-evidence.
17
+ // - Every triage-table row (a row in the Triage section whose FIRST cell is
18
+ // a C-NN id) has a valid verdict token in a cell of its own:
19
+ // client:<layer> | bff:<layer> | core | insufficient-evidence.
19
20
  // Verdict tokens stay English in both languages (payload vocabulary).
21
+ // Scoping matters: the Section 1 coverage table cites C-NN ids in its
22
+ // "Verdicts attributed" column and must not be read as triage rows, and a
23
+ // summary cell mentioning "core" must not classify the row as core.
20
24
  // - Every core verdict has a matching entry in the routing section
21
25
  // (Yönlendirme / Routing), and that section exists when any core row does.
22
26
  // - Every client/bff verdict has a fix-plan marker (Fix plan / Geliştirme
@@ -24,7 +28,9 @@
24
28
  // - Humanizer punctuation policy: no em-dash / en-dash / ellipsis /
25
29
  // section-sign / curly quotes anywhere.
26
30
  // - Redaction leak scan: an email address or a 13-19 digit card-like run is
27
- // an ERROR; a bare 11-digit run is a WARN (could be a numeric trx id).
31
+ // an ERROR; a bare 11-digit run is a WARN (could be a numeric trx id); a
32
+ // PNR-shaped token (6-char uppercase letter+digit mix, or 6 uppercase
33
+ // letters right after a "PNR" keyword) is a WARN.
28
34
  //
29
35
  // Usage:
30
36
  // node validate-complaint-doc.mjs complaints/complaints-20260810.md
@@ -49,8 +55,10 @@ const REQUIRED_SECTIONS = [
49
55
  const ROUTING_KEYWORDS = ["Routing", "Yönlendirme", "Yonlendirme"];
50
56
  const FIX_PLAN_RE = /(Fix plan|Fix Plan|Geliştirme planı|Geliştirme Planı|Gelistirme plani|Gelistirme Plani)/;
51
57
 
52
- const VERDICT_RE =
53
- /\b(client:(ios|android|web)|bff:(mobile-bff|web-bff)|core|insufficient-evidence)\b/;
58
+ // Cell-exact on purpose: a substring match would classify a row as `core`
59
+ // because its summary says "core-data" or "core team" before the verdict column.
60
+ const VERDICT_CELL_RE =
61
+ /^(client:(ios|android|web)|bff:(mobile-bff|web-bff)|core|insufficient-evidence)$/;
54
62
 
55
63
  const BANNED_PUNCT = [
56
64
  { ch: "—", name: "em-dash" },
@@ -66,6 +74,12 @@ const BANNED_PUNCT = [
66
74
  const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
67
75
  const CARD_RE = /\b\d(?:[ -]?\d){12,18}\b/;
68
76
  const NATIONAL_ID_RE = /\b\d{11}\b/;
77
+ // Same shape parse-complaints.sh redacts (6-char uppercase alnum with at least
78
+ // one letter AND one digit), so a leak the parser should have caught fails
79
+ // loudly here too. All-letter PNRs are only flaggable with context: a bare
80
+ // 6-letter word is far too common, so those warn only right after "PNR".
81
+ const PNR_MIXED_RE = /\b(?=[A-Z0-9]{6}\b)(?=[A-Z0-9]*\d)(?=\d*[A-Z])[A-Z0-9]{6}\b/;
82
+ const PNR_CONTEXT_RE = /\bPNR\b[^A-Za-z0-9\n]{0,3}[A-Z]{6}\b/;
69
83
 
70
84
  function readInput() {
71
85
  const args = process.argv.slice(2).filter((a) => a !== "--strict");
@@ -144,21 +158,25 @@ function main() {
144
158
  }
145
159
  }
146
160
 
147
- // 3. Triage rows: every table row carrying a complaint id needs a verdict.
148
- const rows = text.split("\n").filter((l) => /^\s*\|.*\bC-\d{2,}\b/.test(l));
161
+ // 3. Triage rows: scoped to the Triage section (the Section 1 coverage table
162
+ // also cites C-NN ids, in its "Verdicts attributed" column), and a triage row
163
+ // carries the id in its FIRST cell. Every such row needs a verdict cell.
164
+ const triageScope = sectionBody(text, ["Triage"]) ?? text;
165
+ const rows = triageScope.split("\n").filter((l) => /^\s*\|\s*C-\d{2,}\s*\|/.test(l));
149
166
  if (rows.length === 0) {
150
- errors.push("no triage rows found (expected table rows carrying C-NN complaint ids)");
167
+ errors.push("no triage rows found (expected Triage-section rows with a C-NN id in the first cell)");
151
168
  }
152
169
  const coreIds = [];
153
170
  const handoffIds = [];
154
171
  for (const row of rows) {
155
172
  const id = (row.match(/\bC-\d{2,}\b/) || [])[0];
156
- const verdict = row.match(VERDICT_RE);
173
+ const cells = row.split("|").map((c) => c.trim());
174
+ const verdict = cells.find((c) => VERDICT_CELL_RE.test(c));
157
175
  if (!verdict) {
158
176
  errors.push(`triage row ${id} has no valid verdict token (client:<layer> | bff:<layer> | core | insufficient-evidence)`);
159
- } else if (verdict[0] === "core" && !coreIds.includes(id)) {
177
+ } else if (verdict === "core" && !coreIds.includes(id)) {
160
178
  coreIds.push(id);
161
- } else if (/^(client|bff):/.test(verdict[0]) && !handoffIds.includes(id)) {
179
+ } else if (/^(client|bff):/.test(verdict) && !handoffIds.includes(id)) {
162
180
  handoffIds.push(id);
163
181
  }
164
182
  }
@@ -207,6 +225,9 @@ function main() {
207
225
  if (NATIONAL_ID_RE.test(bodyLines[i]) && !CARD_RE.test(bodyLines[i])) {
208
226
  warns.push(`possible redaction leak: bare 11-digit run at line ${i + 1} (national-id-like; ignore if it is a numeric trx id)`);
209
227
  }
228
+ if (PNR_MIXED_RE.test(bodyLines[i]) || PNR_CONTEXT_RE.test(bodyLines[i])) {
229
+ warns.push(`possible redaction leak: PNR-shaped token at line ${i + 1} (parse-complaints.sh should have redacted it)`);
230
+ }
210
231
  }
211
232
 
212
233
  // Report
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-08-13T11:39:06Z",
3
+ "generatedAt": "2026-08-14T19:54:00Z",
4
4
  "skillCount": 205,
5
5
  "entries": [
6
6
  {
@@ -125,7 +125,7 @@
125
125
  },
126
126
  {
127
127
  "path": "shared/core/multi-agent-refactor/SKILL.md",
128
- "sha256": "0aee8c68a617ff38e4535f37e90c1b2f94bb3e69e11d59f9b7f6fbfa28aadaf3"
128
+ "sha256": "1924dd999d8d99fbfc68dac132c26e9c9d47c9c6e0330db4821f724eb7f2408c"
129
129
  },
130
130
  {
131
131
  "path": "shared/core/multi-agent-resume-local/SKILL.md",
@@ -169,7 +169,7 @@
169
169
  },
170
170
  {
171
171
  "path": "shared/core/multi-agent-stack/SKILL.md",
172
- "sha256": "45ea71a5b1cc6f37713dd7b780b4f5f6e4c4a5fdb5bf90083dc8be83cce7663b"
172
+ "sha256": "f49fb2fcf0a5a1f3f044a4393ec625208d48ef4b685ea0370d74c4ac18f1afe7"
173
173
  },
174
174
  {
175
175
  "path": "shared/core/multi-agent-status/SKILL.md",
@@ -213,7 +213,7 @@
213
213
  },
214
214
  {
215
215
  "path": "shared/core/multi-agent-update/SKILL.md",
216
- "sha256": "154afad4824b29423e378554c689c61b8c5cc4cd8f6ca07377e32471547fdbc2"
216
+ "sha256": "66d960a9c360ba0353b6496fde641c1a99834274bc0ac68932ed107b15411ccb"
217
217
  },
218
218
  {
219
219
  "path": "shared/core/multi-agent/SKILL.md",