@ngockhoale/ukit 3.0.1 → 3.0.3

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.
@@ -196,6 +196,21 @@ Status: planning_done — ready for executor
196
196
  ```
197
197
  Wave structure is NOT stored here — inferred from task `Dependencies` fields at runtime.
198
198
 
199
+ **RUN.md** — write the run's `ExitPredicate:` line before iteration 1 (the orchestrator
200
+ keeps it verbatim on every cursor rewrite):
201
+
202
+ ```
203
+ ExitPredicate: all(index:all-done, git:clean)
204
+ ```
205
+
206
+ Grammar: `all(<term>[,<term>...])` or a single term; terms are `index:all-done`,
207
+ `git:clean`, `file-exists:<relpath>` (repo-relative, no `..` segments). Default to
208
+ `all(index:all-done, git:clean)`; add `file-exists:` terms only for artifacts the plan
209
+ itself must produce. The stop gate evaluates this mechanically when the cursor claims
210
+ `Phase: done` — a `done` claim with failing terms is bounced back with the per-term
211
+ state. **Never relax the predicate mid-run**: if the run cannot satisfy it, the plan is
212
+ wrong — fix the plan, not the gate.
213
+
199
214
  ## Self-Audit — run before reporting, every time
200
215
 
201
216
  The downstream pipeline is unattended: an incomplete or wrong plan is not caught by a human
@@ -59,7 +59,7 @@ resumable. If compaction does happen, the run resumes from the cursor automatica
59
59
 
60
60
  ### Run cursor — write after every step
61
61
 
62
- Maintain `docs/AI_HANDOFF/RUN.md`. Rewrite it (whole file, 6 lines) immediately after every
62
+ Maintain `docs/AI_HANDOFF/RUN.md`. Rewrite it (whole file, 7 lines) immediately after every
63
63
  numbered step completes:
64
64
 
65
65
  ```
@@ -69,9 +69,15 @@ Base: <BASE branch>
69
69
  Phase: <P0|P1|P2|P2.5|P3|I1|I2|I3|I4|R1|R2|R3|R4|R4.5|R5|F|done|blocked>
70
70
  Cursor: wave <N> batch <M> — <what just finished>
71
71
  Next: <the exact next step to run>
72
+ ExitPredicate: all(<term>[,<term>...]) # written once at P2; never relaxed
72
73
  QuietScans: <n>/<required> # only while sweeping for stragglers near the end
73
74
  ```
74
75
 
76
+ `ExitPredicate` grammar: `all(<term>[,<term>...])` or a single term; terms are
77
+ `index:all-done`, `git:clean`, `file-exists:<relpath>`. The stop gate evaluates it
78
+ mechanically when the cursor claims `Phase: done` — a `done` claim with failing terms
79
+ is bounced back with the per-term state. Keep the line verbatim on every rewrite.
80
+
75
81
  This file is the resume contract. It costs one small write per step and is what turns an
76
82
  interrupted run into a continuable one.
77
83
 
@@ -172,9 +178,12 @@ The run must keep itself alive without the human watching.
172
178
 
173
179
  - **Stop gate (Claude Code, automatic):** while `RUN.md` `Phase:` is not `done`/`blocked`,
174
180
  `completion-gate.sh` refuses the stop with the cursor's `Next:` step. Recaps and
175
- premature stops cannot park the run. A stalled-cursor breaker
181
+ premature stops cannot park the run. When RUN.md carries `ExitPredicate:`, a
182
+ `Phase: done` whose predicate fails is refused too — `done` means the predicate
183
+ evaluates true. A stalled-cursor breaker
176
184
  (`handoff.fullstack.stopGateMaxStalledBlocks`, default 12) releases the session if the
177
- cursor has genuinely stopped advancing — the escape hatch, not the norm.
185
+ cursor has genuinely stopped advancing — the escape hatch, not the norm — and its
186
+ release advisory reports the predicate state, so a plateau is visible, not silent.
178
187
  - **Scheduled wakeup (when the harness offers one):** arm it at run start — Claude Code:
179
188
  `CronCreate` a ~`handoff.fullstack.idleWatchdogMin`-minute (default 5) session job with
180
189
  prompt `Resume HANDOFF FULLSTACK from docs/AI_HANDOFF/RUN.md — continue the Next: step`;
@@ -269,7 +278,13 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
269
278
  ```
270
279
  Then **verify the write** (read back the `Status:` line).
271
280
 
272
- 8. **Report:** task IDs, dependency graph, recovery/superseded tasks, any
281
+ 8. **Write `ExitPredicate:` into `docs/AI_HANDOFF/RUN.md`** — the mechanical definition of
282
+ done for this run: `all(<term>[,<term>...])` over `index:all-done`, `git:clean`,
283
+ `file-exists:<relpath>` (default `all(index:all-done, git:clean)`). Written once,
284
+ before iteration 1, and **never relaxed** — if the run cannot satisfy it, the plan is
285
+ wrong, not the gate. Then **verify the write** (read back the `ExitPredicate:` line).
286
+
287
+ 9. **Report:** task IDs, dependency graph, recovery/superseded tasks, any
273
288
  `needs_breakdown` tasks + reason.
274
289
 
275
290
  ### P2.5 — Independent plan review (strong model, separate agent)
@@ -455,7 +470,12 @@ Worktrees are **always deleted immediately** — no exceptions.
455
470
  A wave boundary is the only safe place to shed context, because everything of value is
456
471
  already on disk (task files hold the full logs, git holds the code). Do all four, in order:
457
472
 
458
- 1. **Checkpoint the code** — one commit per wave, so every wave is independently revertible:
473
+ 1. **Checkpoint the code — commit-if-advanced.** One commit per wave, so every wave is
474
+ independently revertible — but only when the wave's diff advances the run's
475
+ `ExitPredicate` (a task moved toward `done`, a required file appeared, the worktree
476
+ got cleaner). A wave whose diff does not advance the predicate is **discarded**: its
477
+ copied-back changes are reverted and nothing is committed. A plateau is not a stop —
478
+ the run continues to the next wave or fix round; the predicate is never relaxed.
459
479
  ```bash
460
480
  git add -A && git commit -m "handoff: wave <N> — TASK-00x, TASK-00y"
461
481
  ```
@@ -54,9 +54,11 @@ If that diff is empty → handoff-implement was not completed. Report which task
54
54
 
55
55
  **Batch the review set — mandatory.** Read `handoff.maxParallelAgents` from `.ukit/storage/config.json`. If more `pending_review` tasks exist than that, split into consecutive batches of at most that many; finish one batch's verdicts (2a–2d, appended to each task file) before starting the next.
56
56
 
57
- **Claude Code — MANDATORY, do this before anything else:** for each batch, call the Agent tool once per `pending_review` task **in parallel** (at most `handoff.maxParallelAgents`), each with `subagent_type: "code-reviewer"` (omp: the `task` tool with `agent: "code-reviewer"`, likewise invoked once per task in the batch, not once per wave). Reviewer agents only read the diff and append a verdict to their own task file — no worktree, no shared write target — so running them in parallel carries none of Phase 3's file-conflict risk. Do NOT review the diff yourself in the current session — this step is contracted to the strong tier (opus/unic-smart) and MUST differ from the executor's model, which only the spawned agent's frontmatter model guarantees. Pass each agent: the task file path, the executor's report, and the diff.
57
+ **Spawn the review panel — MANDATORY, do this before anything else:** read `modelRoles['review-panel']` from `.ukit/storage/config.json` → `modelRoles` (default `['smart','code','lite']`; omp tier aliases `@smol`/`@default`/`@slow`). For each `pending_review` task in the batch, call the Agent tool **once per panel entry** (in parallel, bounded by `handoff.maxParallelAgents`), each with `subagent_type: "code-reviewer"` (omp: the `task` tool with `agent: "code-reviewer"`, likewise invoked once per panel entry per task) — each member bound to its panel tier and setting `PANEL_MEMBER: <tier>` in its verdict. Reviewer agents only read the diff and append a verdict to their own task file — no worktree, no shared write target — so running them in parallel carries none of Phase 3's file-conflict risk. Do NOT review the diff yourself in the current session — this step is contracted to the panel tiers and MUST differ from the executor's model, which only the spawned agent's frontmatter model guarantees. Pass each agent: the task file path, the executor's report, and the diff.
58
58
 
59
- The spawned reviewer agent performs 2a–2d below per task:
59
+ Degenerate panel: if `modelRoles['review-panel']` is empty or has one entry, spawn a single reviewer and require `PANEL_MEMBER: solo` in its verdict.
60
+
61
+ Each spawned reviewer agent performs 2a–2d below per task:
60
62
 
61
63
  ### 2a — Model isolation check (always first)
62
64
 
@@ -98,6 +100,7 @@ matches the task file's wording.
98
100
  VERDICT: approved | approved_minor | changes_requested | critical_block
99
101
  REVIEWER_MODEL: <your exact model ID>
100
102
  EXECUTOR_MODEL: <from Executor Report>
103
+ PANEL_MEMBER: <panel tier | solo | ->
101
104
  VERIFICATION_RERUN: PASS | FAIL
102
105
  FINDINGS:
103
106
  critical: <file:line — what fails> | none
@@ -116,7 +119,29 @@ BLOCKING: <one line per critical/important finding, or "none">
116
119
  ```
117
120
  Do not paste the diff, the findings prose, or verification logs back into the orchestrator.
118
121
 
119
- ### 2e — Orchestrator updates INDEX.md
122
+ ### 2e — Aggregate the panel + lead bucketing
123
+
124
+ Once every panel member for a task has appended its `## Reviewer Verdict`, run:
125
+
126
+ ```bash
127
+ node .claude/ukit/index/review-panel-aggregate.mjs docs/AI_HANDOFF/tasks/TASK-xxx.md
128
+ ```
129
+
130
+ The script emits an Agreement Map (finding → set of panel members reporting it) and a
131
+ consensus verdict (`approved` when ≥2 members approve and no critical;
132
+ `changes_requested` when ≥1 critical or ≥2 request changes; else `approved_minor`).
133
+ `consensus≥2 identical findings = high signal` — a finding reported by two or more
134
+ members is high-signal. The consensus verdict, not any single member's vote, drives
135
+ `NEXT_STATUS_FOR_INDEX`.
136
+
137
+ Then the **lead member** (first entry in `modelRoles['review-panel']` = the judgment
138
+ tier) applies the lead-judgment buckets to every finding — **Act on** / **Consider** /
139
+ **Noted** / **Dismissed** — and appends a `## Panel Consensus` block to the task file
140
+ carrying the Agreement Map and the bucketed findings. `Act on` findings feed Step 3's
141
+ auto-fix loop; `Consider`/`Noted`/`Dismissed` are advisory. A high-signal finding must
142
+ not be bucketed below `Consider` without a stated reason.
143
+
144
+ ### 2f — Orchestrator updates INDEX.md
120
145
 
121
146
  Set `status = NEXT_STATUS_FOR_INDEX`. Orchestrator writes INDEX, not the reviewer.
122
147
 
@@ -144,7 +169,7 @@ For round in 1..2:
144
169
  ```bash
145
170
  git add -A && git commit -m "handoff: fix round <N> — TASK-00x"
146
171
  ```
147
- 4. Re-review **only the tasks touched this round**, in parallel, per 2a–2e. The reviewer must
172
+ 4. Re-review **only the tasks touched this round**, in parallel, per 2a–2f — panel spawn, aggregation, and lead bucketing included. The reviewers must
148
173
  still differ from the executor model and still re-runs verification itself.
149
174
  5. Update `INDEX.md` and `RUN.md`. Exit when everything is approved.
150
175
 
@@ -190,5 +215,5 @@ Then, as the last line:
190
215
  This is the only place this command may ask for a compaction. All state lives in git,
191
216
  `INDEX.md` and `RUN.md`, so a compacted or fresh session resumes with nothing lost.
192
217
 
193
- > Orchestrator (this session) handles Step 1, 2e, and 3 directly — those are not delegated.
194
- > **For the human operator, on a tool with no agent support (Codex) — not an instruction to the model:** manually switch to the strong model, run 2a–2d per task (one session at a time).
218
+ > Orchestrator (this session) handles Step 1, 2e, 2f, and 3 directly — those are not delegated.
219
+ > **For the human operator, on a tool with no agent support (Codex) — not an instruction to the model:** manually switch to the strong model, run 2a–2d per task (one session at a time, panel members sequentially), then run `review-panel-aggregate.mjs` and apply the lead buckets yourself.
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // handoff-doc-validator.mjs — TASK-004 / Cycle C50 (shipped CLI twin)
3
+ //
4
+ // Self-contained twin of src/core/handoffDocValidator.js for user installs (no `src/`).
5
+ // The logic below is a literal port — keep the two files behavior-identical when editing.
6
+ //
7
+ // REAL GATE — unlike task-budget-validator.mjs (advisory, always exit 0), this
8
+ // script exits 1 on any violation. Do not "fix" the exit code.
9
+ //
10
+ // Usage:
11
+ // node .claude/ukit/index/handoff-doc-validator.mjs <file.md> [file.md...]
12
+ //
13
+ // Kind detection per file: basename PLAN.md → 'plan'; TASK-*.md → 'task';
14
+ // otherwise content sniffing (`## §1` → plan, `## Goal` → task, else
15
+ // executor-report). Task files carrying `## Executor Report` are also checked
16
+ // against the report header contract.
17
+ //
18
+ // Output:
19
+ // `PASS <file>` when clean; one `FAIL <file>: <rule>` line per violation.
20
+ // Exit 0 when every file passes, 1 otherwise, 2 on usage/read errors.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import process from 'node:process';
25
+ import { pathToFileURL } from 'node:url';
26
+
27
+ const PLAN_SECTIONS = ['§1', '§2', '§3', '§4', '§5', '§6'];
28
+
29
+ const TASK_SECTIONS = [
30
+ 'Goal',
31
+ 'Target Files',
32
+ 'Test Cases',
33
+ 'Test Files',
34
+ 'Verification Commands',
35
+ 'Acceptance Criteria',
36
+ 'Dependencies',
37
+ 'Interfaces',
38
+ ];
39
+
40
+ const REPORT_FIELDS = ['EXECUTOR_TOOL', 'EXECUTOR_MODEL', 'EXECUTOR_SUBAGENT', 'RED_OUTPUT'];
41
+
42
+ // Section helpers. Any line starting with `## ` opens a section; the body runs
43
+ // until the next `## ` line or EOF. Returns [{ name, body, pos }] in file order.
44
+ function splitSections(markdown) {
45
+ const sections = [];
46
+ const lines = String(markdown).split('\n');
47
+ let current = null;
48
+ lines.forEach((line, idx) => {
49
+ const m = line.match(/^##\s+(.+?)\s*$/);
50
+ if (m) {
51
+ current = { name: m[1], body: '', pos: idx };
52
+ sections.push(current);
53
+ } else if (current) {
54
+ current.body += line + '\n';
55
+ }
56
+ });
57
+ return sections;
58
+ }
59
+
60
+ // Positions of `## §<n>` headings, keyed by the literal token ('§1'..'§6').
61
+ // A heading matches when its name starts with the token (`## §1 Intent`).
62
+ function planSectionPositions(sections) {
63
+ const positions = new Map();
64
+ for (const s of sections) {
65
+ for (const token of PLAN_SECTIONS) {
66
+ if (!positions.has(token) && s.name.startsWith(token)) positions.set(token, s.pos);
67
+ }
68
+ }
69
+ return positions;
70
+ }
71
+
72
+ function hasSection(sections, name) {
73
+ return sections.some((s) => s.name === name || s.name.startsWith(`${name} `) || s.name.startsWith(`${name}(`));
74
+ }
75
+
76
+ function getSection(sections, name) {
77
+ return sections.find((s) => s.name === name || s.name.startsWith(`${name} `) || s.name.startsWith(`${name}(`));
78
+ }
79
+
80
+ function validatePlan(markdown) {
81
+ const failures = [];
82
+ const sections = splitSections(markdown);
83
+ const positions = planSectionPositions(sections);
84
+
85
+ for (const token of PLAN_SECTIONS) {
86
+ if (!positions.has(token)) failures.push(`missing required section heading "## ${token}"`);
87
+ }
88
+
89
+ // Order check over the sections that exist: each must appear after the
90
+ // highest position seen so far.
91
+ let maxPos = -1;
92
+ for (const token of PLAN_SECTIONS) {
93
+ const pos = positions.get(token);
94
+ if (pos === undefined) continue;
95
+ if (pos < maxPos) {
96
+ failures.push(`section order violation: "## ${token}" appears after a later section`);
97
+ } else {
98
+ maxPos = pos;
99
+ }
100
+ }
101
+
102
+ const report = getSection(sections, 'Planner Report');
103
+ if (!report) {
104
+ failures.push('missing required section "## Planner Report"');
105
+ } else if (!/^PLANNER_MODEL\s*:/m.test(report.body)) {
106
+ failures.push('missing required field "PLANNER_MODEL:" inside "## Planner Report"');
107
+ }
108
+
109
+ return failures;
110
+ }
111
+
112
+ function validateExecutorReportFields(markdown, prefix) {
113
+ const failures = [];
114
+ for (const field of REPORT_FIELDS) {
115
+ if (!new RegExp(`^${field}\\s*:`, 'm').test(markdown)) {
116
+ failures.push(`${prefix}missing required field "${field}:"`);
117
+ }
118
+ }
119
+ return failures;
120
+ }
121
+
122
+ function validateTask(markdown) {
123
+ const failures = [];
124
+ const sections = splitSections(markdown);
125
+
126
+ for (const name of TASK_SECTIONS) {
127
+ if (!hasSection(sections, name)) failures.push(`missing required section "## ${name}"`);
128
+ }
129
+ if (!/^\s*-?\s*Status\s*:/m.test(markdown)) {
130
+ failures.push('missing required field "Status:"');
131
+ }
132
+
133
+ // A task file that already carries an Executor Report must satisfy the
134
+ // report header contract too (FR-007).
135
+ const report = getSection(sections, 'Executor Report');
136
+ if (report) {
137
+ failures.push(...validateExecutorReportFields(report.body, 'executor-report: '));
138
+ }
139
+
140
+ return failures;
141
+ }
142
+
143
+ export function validateHandoffDoc(markdown, kind) {
144
+ const text = String(markdown ?? '');
145
+ let failures;
146
+ if (kind === 'plan') failures = validatePlan(text);
147
+ else if (kind === 'task') failures = validateTask(text);
148
+ else if (kind === 'executor-report') failures = validateExecutorReportFields(text, '');
149
+ else failures = [`unknown kind "${kind}" (expected plan|task|executor-report)`];
150
+ return { ok: failures.length === 0, failures };
151
+ }
152
+
153
+ // CLI-only helpers below (not part of the canonical module).
154
+
155
+ function detectKind(filePath, markdown) {
156
+ const base = path.basename(filePath);
157
+ if (/^PLAN\.md$/i.test(base)) return 'plan';
158
+ if (/^TASK-.*\.md$/i.test(base)) return 'task';
159
+ if (/^##\s+§1/m.test(markdown)) return 'plan';
160
+ if (/^##\s+Goal\b/m.test(markdown)) return 'task';
161
+ return 'executor-report';
162
+ }
163
+
164
+ function main() {
165
+ const files = process.argv.slice(2);
166
+ if (files.length === 0) {
167
+ console.error('usage: handoff-doc-validator.mjs <file.md> [file.md...]');
168
+ process.exit(2);
169
+ }
170
+ let failed = false;
171
+ for (const file of files) {
172
+ let markdown;
173
+ try {
174
+ markdown = fs.readFileSync(file, 'utf8');
175
+ } catch (err) {
176
+ console.log(`FAIL ${file}: unreadable (${err.message})`);
177
+ failed = true;
178
+ continue;
179
+ }
180
+ const { ok, failures } = validateHandoffDoc(markdown, detectKind(file, markdown));
181
+ if (ok) {
182
+ console.log(`PASS ${file}`);
183
+ } else {
184
+ failed = true;
185
+ for (const f of failures) console.log(`FAIL ${file}: ${f}`);
186
+ }
187
+ }
188
+ process.exit(failed ? 1 : 0);
189
+ }
190
+
191
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
192
+ main();
193
+ }
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ // review-panel-aggregate.mjs — TASK-004 / Cycle C50 (shipped CLI twin)
3
+ //
4
+ // Self-contained twin of src/core/reviewPanelAggregate.js for user installs (no `src/`).
5
+ // The logic below is a literal port — keep the two files behavior-identical when editing.
6
+ //
7
+ // ADVISORY — always exits 0. Aggregation output feeds the lead reviewer's
8
+ // AGREEMENT_MAP field and the orchestrator's INDEX status update; it must never
9
+ // break a pipeline.
10
+ //
11
+ // Usage:
12
+ // node .claude/ukit/index/review-panel-aggregate.mjs <TASK-xxx.md> [TASK-xxx.md...]
13
+ //
14
+ // Reads every `## Reviewer Verdict` block from the given task files, then prints:
15
+ // AGREEMENT_MAP: finding → member, member (one line per finding)
16
+ // HIGH_SIGNAL: finding → member, member (≥2 members or any critical)
17
+ // UNPARSED: <file>#verdict[<i>] (blocks with no VERDICT line)
18
+ // VERDICT: approved | approved_minor | changes_requested
19
+
20
+ import fs from 'node:fs';
21
+ import process from 'node:process';
22
+ import { pathToFileURL } from 'node:url';
23
+
24
+ const SEVERITIES = ['critical', 'important', 'minor'];
25
+
26
+ // Parse one `## Reviewer Verdict` block (format: code-reviewer.md §Output).
27
+ // Returns null when no recognizable VERDICT line exists — the block is then
28
+ // counted as absent and named in `unparsed`.
29
+ export function parseVerdictBlock(text) {
30
+ const src = String(text ?? '');
31
+ const verdictMatch = src.match(/^VERDICT\s*:\s*(APPROVED-WITH-MINOR|APPROVED|CHANGES-REQUESTED|CRITICAL)\s*$/im);
32
+ if (!verdictMatch) return null;
33
+
34
+ const memberMatch = src.match(/^PANEL_MEMBER\s*:\s*(\S+)\s*$/im);
35
+ const member = memberMatch && memberMatch[1] !== '-' ? memberMatch[1] : null;
36
+
37
+ const findings = { critical: [], important: [], minor: [] };
38
+ const findingsMatch = src.match(/^FINDINGS:\s*$/im);
39
+ if (findingsMatch) {
40
+ const rest = src.slice(findingsMatch.index + findingsMatch[0].length);
41
+ let severity = null;
42
+ for (const line of rest.split('\n')) {
43
+ if (/^\S/.test(line)) break; // next top-level field ends FINDINGS
44
+ const sev = line.match(/^\s{2}(critical|important|minor)\s*:/);
45
+ if (sev) {
46
+ severity = sev[1];
47
+ continue;
48
+ }
49
+ const item = line.match(/^\s{4}-\s+(.+?)\s*$/);
50
+ if (item && severity) {
51
+ const text2 = item[1];
52
+ if (text2 !== 'none' && text2 !== '-') findings[severity].push(text2);
53
+ }
54
+ }
55
+ }
56
+
57
+ return { member, verdict: verdictMatch[1].toUpperCase(), findings };
58
+ }
59
+
60
+ // Normalize a finding bullet for identity comparison: two members report the
61
+ // "identical finding" when the normalized text matches.
62
+ function normalizeFinding(text) {
63
+ return String(text).replace(/\s+/g, ' ').trim().toLowerCase();
64
+ }
65
+
66
+ export function aggregatePanelVerdicts(verdicts) {
67
+ const list = Array.isArray(verdicts) ? verdicts : [];
68
+ const parsed = [];
69
+ const unparsed = [];
70
+
71
+ list.forEach((block, i) => {
72
+ const p = parseVerdictBlock(block);
73
+ if (p) parsed.push({ ...p, label: p.member || `verdict[${i}]` });
74
+ else unparsed.push(`verdict[${i}]`);
75
+ });
76
+
77
+ // Agreement Map: finding → set of panel members reporting it.
78
+ const map = new Map(); // normalized → { finding, members:Set, critical:boolean }
79
+ for (const p of parsed) {
80
+ for (const sev of SEVERITIES) {
81
+ for (const f of p.findings[sev]) {
82
+ const key = normalizeFinding(f);
83
+ let entry = map.get(key);
84
+ if (!entry) {
85
+ entry = { finding: f, members: new Set(), critical: sev === 'critical' };
86
+ map.set(key, entry);
87
+ }
88
+ entry.members.add(p.label);
89
+ if (sev === 'critical') entry.critical = true;
90
+ }
91
+ }
92
+ }
93
+
94
+ const agreementMap = [...map.values()].map((e) => ({
95
+ finding: e.finding,
96
+ members: [...e.members],
97
+ }));
98
+ const highSignal = [...map.values()]
99
+ .filter((e) => e.members.size >= 2 || e.critical)
100
+ .map((e) => ({ finding: e.finding, members: [...e.members] }));
101
+
102
+ const approvals = parsed.filter((p) => p.verdict === 'APPROVED').length;
103
+ const changeRequests = parsed.filter((p) => p.verdict === 'CHANGES-REQUESTED').length;
104
+ const hasCritical =
105
+ parsed.some((p) => p.verdict === 'CRITICAL') || parsed.some((p) => p.findings.critical.length > 0);
106
+
107
+ let verdict;
108
+ if (hasCritical || changeRequests >= 2) verdict = 'changes_requested';
109
+ else if (approvals >= 2) verdict = 'approved';
110
+ else verdict = 'approved_minor';
111
+
112
+ return { agreementMap, verdict, highSignal, unparsed };
113
+ }
114
+
115
+ // CLI-only helpers below (not part of the canonical module).
116
+
117
+ // Extract every `## Reviewer Verdict` block from a task file. A block runs from
118
+ // its heading to the next `## ` heading or EOF.
119
+ function extractVerdictBlocks(markdown) {
120
+ const blocks = [];
121
+ const re = /^##\s+Reviewer Verdict\s*$/gm;
122
+ let m;
123
+ while ((m = re.exec(markdown)) !== null) {
124
+ const start = m.index;
125
+ const rest = markdown.slice(start + m[0].length);
126
+ const next = rest.search(/^##\s+/m);
127
+ blocks.push(next === -1 ? rest : rest.slice(0, next));
128
+ }
129
+ return blocks;
130
+ }
131
+
132
+ function main() {
133
+ const files = process.argv.slice(2);
134
+ if (files.length === 0) {
135
+ console.error('usage: review-panel-aggregate.mjs <TASK-xxx.md> [TASK-xxx.md...]');
136
+ process.exit(0); // advisory — never break a pipeline
137
+ }
138
+
139
+ const blocks = [];
140
+ const labels = [];
141
+ for (const file of files) {
142
+ let markdown;
143
+ try {
144
+ markdown = fs.readFileSync(file, 'utf8');
145
+ } catch (err) {
146
+ console.log(`UNPARSED: ${file} (unreadable: ${err.message})`);
147
+ continue;
148
+ }
149
+ for (const block of extractVerdictBlocks(markdown)) {
150
+ labels.push(file);
151
+ blocks.push(block);
152
+ }
153
+ }
154
+
155
+ const { agreementMap, verdict, highSignal, unparsed } = aggregatePanelVerdicts(blocks);
156
+
157
+ console.log('AGREEMENT_MAP:');
158
+ for (const e of agreementMap) console.log(` ${e.finding} → ${e.members.join(', ')}`);
159
+ if (agreementMap.length === 0) console.log(' (none)');
160
+ console.log('HIGH_SIGNAL:');
161
+ for (const e of highSignal) console.log(` ${e.finding} → ${e.members.join(', ')}`);
162
+ if (highSignal.length === 0) console.log(' (none)');
163
+ if (unparsed.length === 0) console.log('UNPARSED: (none)');
164
+ for (const u of unparsed) {
165
+ const idx = Number(u.slice('verdict['.length, -1));
166
+ console.log(`UNPARSED: ${labels[idx] ?? '?'}#${u}`);
167
+ }
168
+ console.log(`VERDICT: ${verdict}`);
169
+ process.exit(0);
170
+ }
171
+
172
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
173
+ main();
174
+ }