@ngockhoale/ukit 3.0.1 → 3.0.2

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 CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to UKit are documented here.
4
4
 
5
5
 
6
+ ## 3.0.2 - 2026-09-23
7
+
8
+ - **v3 reshape leftovers + docs verify-and-close (cycles C49–C51)**:
9
+ - Decision table v2: `MODEL_TIER_BY_CONTRACT` + `resolveModelTier` in `src/core/executionContracts.js` — contract→tier resolution is now data-driven.
10
+ - `limits=` route-text segment behind `routing.rigor.stage` (`src/index/taskRouting.js` + `route-task.mjs` mirror; stage-off output byte-identical, 10-case suite).
11
+ - Mechanical validators: `handoffDocValidator.js` + `reviewPanelAggregate.js` (+ `.mjs` CLIs) — handoff doc checks and review-panel aggregation now scriptable.
12
+ - Exit-predicate loop in stop-coordinator; diversity-review contract; M05.1' program-gate verdict + pstack doc banners.
13
+ - Docs: DOC-201..206 verified shipped (165/165 acceptance tests); stale STATUS.md queue pointer + 11 roadmap rows corrected.
14
+
6
15
  ## 3.0.1 - 2026-09-23
7
16
 
8
17
  - **Template root split (breaking-scale layout change, kept on 3.0.x per owner decision)**: `templates/` → `template_project/` (project-level install surface) and `templates/user/` → `template_user/` (user-level `~/.ukit/` seeds). `buildPathConfig` gains `userTemplatesRoot`; `platform.user.yaml` sourceTemplates resolve relative to `template_user/`. Dev mirrors re-synced (C47 drift fixed). Pre-existing test debt fixed: `packageVersion` major pin, runtime config version, `routeCatalog` principle-skill map, `packageArtifact` negation handling, `artifactReferenceIntegrity` allowlist.
@@ -146,6 +146,17 @@ entries:
146
146
  load_policy: on-demand
147
147
  validation: [manual]
148
148
  archive_policy: never
149
+ - id: docs-engine-conformance
150
+ path: docs/ENGINE_CONFORMANCE.md
151
+ class: canonical
152
+ audience: [agent, maintainer]
153
+ owner: adapter
154
+ source_of_truth: manifests/engineConformance.yaml
155
+ merge_strategy: none
156
+ load_policy: on-demand
157
+ validation: [manual, test:tests/consistency/engineConformance.test.js]
158
+ archive_policy: never
159
+ notes: 'hand-authored mirror of manifests/engineConformance.yaml (M05.2 rescope); edit both together'
149
160
  - id: docs-gateway
150
161
  path: docs/GATEWAY.md
151
162
  class: canonical
@@ -0,0 +1,32 @@
1
+ # Engine conformance contract (M05.2 rescope — V3_RESHAPE §5).
2
+ # Same logical route on all 3 engines; same completion semantics where the host
3
+ # supports blocking; documented degradation elsewhere.
4
+ # Every class x host cell carries a support value:
5
+ # enforced — the host can hard-block / a gate reads persisted state (evidence required)
6
+ # degraded — the mechanism exists only as advisory text on this host (note required)
7
+ # advisory — stdout/prompt text the model is asked to respect; no enforcement expected
8
+ # Human mirror: docs/ENGINE_CONFORMANCE.md
9
+ # Consistency enforced by: tests/consistency/engineConformance.test.js
10
+ version: 1
11
+ routeParity:
12
+ mechanism: shared-mirror # all engines route through the same route-task.mjs
13
+ evidence: tests/consistency/engineConformance.test.js
14
+ classes:
15
+ host-enforced:
16
+ description: hook can hard-block (PreToolUse deny, Stop gate, hook deadlines)
17
+ hosts:
18
+ claude-code: { support: enforced, evidence: tests/hooks/blockDangerous.test.js }
19
+ codex: { support: degraded, note: 'no PreToolUse hook surface; deny rules are advisory text in AGENTS.md/settings' }
20
+ omp: { support: enforced, evidence: tests/hooks/ompHookBridge.test.js }
21
+ state-checked:
22
+ description: persisted state a gate reads (completionEvidence, ledger counters, riskEscalation)
23
+ hosts:
24
+ claude-code: { support: enforced, evidence: tests/hooks/executionLedgerRiskGate.test.js }
25
+ codex: { support: degraded, note: 'no Stop hook; completionEvidence is advisory in .codex/README.md' }
26
+ omp: { support: enforced, evidence: tests/hooks/ompHookBridge.test.js }
27
+ advisory:
28
+ description: stdout/prompt text the model is asked to respect (routes, suppress, budgets)
29
+ hosts:
30
+ claude-code: { support: advisory }
31
+ codex: { support: advisory }
32
+ omp: { support: advisory }
@@ -32,7 +32,7 @@ hosts:
32
32
  subagents: { state: tested, evidence: tests/consistency/ompAgentParity.test.js }
33
33
  hooks: { state: tested, evidence: tests/hooks/ompHookBridge.test.js }
34
34
  session-start-injection: { state: tested, evidence: tests/hooks/ompHookBridge.test.js }
35
- stop-gate: { state: unknown }
35
+ stop-gate: { state: tested, evidence: tests/hooks/ompHookBridge.test.js }
36
36
  slash-commands: { state: observed, note: 'delegation parentheticals checked by tests/consistency/ompCommandParity.test.js' }
37
37
  owner-file-injection: { state: unknown }
38
38
  settings-env: { state: observed, note: 'template_project/.omp/config.yml modelRoles reviewed by ompAgentParity' }
@@ -1815,6 +1815,32 @@ items:
1815
1815
  packs:
1816
1816
  - core
1817
1817
 
1818
+ # Mechanic #10 (TASK-004): both scripts ship in template_project/ and must be
1819
+ # manifest-listed or autoDiscoverTemplates:false skips them and the documented
1820
+ # `node .claude/ukit/index/handoff-doc-validator.mjs` gate 404s on installs —
1821
+ # the same bug class as BUG-C21-14 above.
1822
+ - id: ukit-index-handoff-doc-validator-script
1823
+ type: config
1824
+ sourceTemplate: .claude/ukit/index/handoff-doc-validator.mjs
1825
+ targetPath: .claude/ukit/index/handoff-doc-validator.mjs
1826
+ requires: []
1827
+ mergeStrategy: overwrite_with_backup
1828
+ variables: []
1829
+ enabledByDefault: true
1830
+ packs:
1831
+ - core
1832
+
1833
+ - id: ukit-index-review-panel-aggregate-script
1834
+ type: config
1835
+ sourceTemplate: .claude/ukit/index/review-panel-aggregate.mjs
1836
+ targetPath: .claude/ukit/index/review-panel-aggregate.mjs
1837
+ requires: []
1838
+ mergeStrategy: overwrite_with_backup
1839
+ variables: []
1840
+ enabledByDefault: true
1841
+ packs:
1842
+ - core
1843
+
1818
1844
  - id: gitignore-root
1819
1845
  type: config
1820
1846
  sourceTemplate: .gitignore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
+ import { parse } from 'yaml';
4
5
  import { pathExists, readJsonIfExists } from '../../core/fileOps.js';
5
6
  import { buildPathConfig } from '../../core/paths.js';
6
7
  import { buildRuntimePaths } from '../../core/runtimePaths.js';
@@ -32,8 +33,8 @@ import { loadPlaybooks } from '../../core/userPlaybooks.js';
32
33
  import { userMemoryStats } from '../../core/memory/userMemory.js';
33
34
 
34
35
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
35
- const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway', '--docs', '--permissions', '--json']);
36
- const SUPPORTED_FLAGS_LIST = '--help, -h, --skills, --gateway, --docs, --permissions, --json';
36
+ const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway', '--docs', '--permissions', '--conformance', '--json']);
37
+ const SUPPORTED_FLAGS_LIST = '--help, -h, --skills, --gateway, --docs, --permissions, --conformance, --json';
37
38
 
38
39
  export function printDoctorHelp() {
39
40
  console.log('Usage: ukit doctor [options]');
@@ -47,6 +48,7 @@ export function printDoctorHelp() {
47
48
  console.log(' --docs Run doc-contract checks (manifests/documentation.yaml projects only)');
48
49
  console.log(' --permissions Print the full per-host permission report table');
49
50
  console.log(' --json Emit the permission report as JSON ({permissions:{...}})');
51
+ console.log(' --conformance Print the engine conformance matrix (enforcement class per host)');
50
52
  }
51
53
 
52
54
  function printPermissionSection(permissionReport, { verbose }) {
@@ -91,6 +93,40 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
91
93
  return;
92
94
  }
93
95
 
96
+ // TASK-002 / FR-003 — `--conformance` is a standalone diagnostic surface: it
97
+ // prints the enforcement-class matrix from manifests/engineConformance.yaml
98
+ // and returns. Missing/malformed manifest degrades to an `unavailable` line;
99
+ // it never throws and never affects the exit code (advisory report only).
100
+ if (argv.includes('--conformance')) {
101
+ const conformancePath = path.join(packageRoot, 'manifests', 'engineConformance.yaml');
102
+ let conformance = null;
103
+ let unavailableReason = null;
104
+ try {
105
+ conformance = parse(await fs.readFile(conformancePath, 'utf8'));
106
+ } catch (error) {
107
+ unavailableReason = error?.code === 'ENOENT'
108
+ ? 'manifests/engineConformance.yaml not found'
109
+ : (error?.message ?? String(error));
110
+ }
111
+ const classes = conformance?.classes;
112
+ if (!unavailableReason && (conformance?.version !== 1 || !classes || typeof classes !== 'object')) {
113
+ unavailableReason = 'unexpected manifest shape (expected version: 1 with classes)';
114
+ }
115
+ if (unavailableReason) {
116
+ console.log(`[UKit] conformance: unavailable (${unavailableReason})`);
117
+ return;
118
+ }
119
+ console.log('[UKit] Engine conformance matrix:');
120
+ for (const [className, classEntry] of Object.entries(classes)) {
121
+ const hosts = classEntry?.hosts ?? {};
122
+ for (const [host, cell] of Object.entries(hosts)) {
123
+ const detail = cell?.evidence ?? cell?.note;
124
+ console.log(`[UKit] ${host} · ${className}: ${cell?.support ?? 'unknown'}${detail ? ` (${detail})` : ''}`);
125
+ }
126
+ }
127
+ return;
128
+ }
129
+
94
130
  const pathConfig = buildPathConfig({ packageRoot, projectRoot });
95
131
  const runtimePaths = buildRuntimePaths(projectRoot);
96
132
  const manifest = await loadManifest(pathConfig.manifestPath);
@@ -90,6 +90,46 @@ export const MODEL_TIER_BY_CONTRACT = {
90
90
  'review-release': 'smart',
91
91
  };
92
92
 
93
+ // Decision table v2 (V3_RESHAPE §5): MODEL_TIER_BY_CONTRACT stays the base row;
94
+ // resolveModelTier adds the (riskFloor, hostCapabilities) columns. Deterministic —
95
+ // no registry, no leases, no outbound capability calls.
96
+ export const ROUTE_EFFORTS = new Set(['low', 'medium', 'high']);
97
+
98
+ // Tier ladder for the one-band high-risk escalation; effort rides the resolved tier.
99
+ const MODEL_TIER_ORDER = ['lite', 'code', 'smart'];
100
+ const MODEL_TIER_EFFORT = {
101
+ lite: 'low',
102
+ code: 'medium',
103
+ smart: 'high',
104
+ };
105
+
106
+ /**
107
+ * Resolves {tier, effort, advisoryOnly} for a route. A 'high-risk' floor escalates
108
+ * the tier one band (lite→code→smart, capped at smart) and forces effort 'high'.
109
+ * Unknown mode → {tier:null, effort:null, advisoryOnly:true}. Missing/unknown
110
+ * riskFloor → 'none'. Missing hostCapabilities → advisoryOnly:true (the route text
111
+ * is all the host gets when it cannot bind model and effort).
112
+ */
113
+ export function resolveModelTier({
114
+ executionMode = null,
115
+ riskFloor = null,
116
+ hostCapabilities = null,
117
+ } = {}) {
118
+ const baseTier = MODEL_TIER_BY_CONTRACT[executionMode] ?? null;
119
+ if (!baseTier) {
120
+ return { tier: null, effort: null, advisoryOnly: true };
121
+ }
122
+ const highRisk = riskFloor?.floor === 'high-risk';
123
+ const tier = highRisk
124
+ ? MODEL_TIER_ORDER[Math.min(MODEL_TIER_ORDER.indexOf(baseTier) + 1, MODEL_TIER_ORDER.length - 1)]
125
+ : baseTier;
126
+ return {
127
+ tier,
128
+ effort: highRisk ? 'high' : MODEL_TIER_EFFORT[tier],
129
+ advisoryOnly: !(hostCapabilities?.canBindModel === true && hostCapabilities?.canBindEffort === true),
130
+ };
131
+ }
132
+
93
133
  export const CONTRACT_RISK_PROFILES = {
94
134
  'tiny-fix': { riskLevel: 'minimal', contextPolicy: 'confirm-target-only' },
95
135
  'local-fix': { riskLevel: 'local', contextPolicy: 'bounded-local' },
@@ -0,0 +1,152 @@
1
+ // handoffDocValidator.js — TASK-004 / Cycle C50 (mechanic #10, FR-007)
2
+ //
3
+ // Mechanical structure gate for handoff docs. Unlike taskBudgetValidator (advisory,
4
+ // exit 0), this validator is a REAL gate: the CLI twin exits 1 on any violation.
5
+ // Keep that difference explicit — do not "fix" the exit code.
6
+ //
7
+ // Exports:
8
+ // validateHandoffDoc(markdown, kind) → { ok: boolean, failures: string[] }
9
+ // kind ∈ 'plan' | 'task' | 'executor-report'
10
+ //
11
+ // Rules (SPEC §5 FR-007):
12
+ // plan — `## §1`..`## §6` headings present and in ascending order
13
+ // (extra trailing sections allowed), `## Planner Report`
14
+ // section containing `PLANNER_MODEL:`.
15
+ // task — required `## ` sections: Goal, Target Files, Test Cases,
16
+ // Test Files, Verification Commands, Acceptance Criteria,
17
+ // Dependencies, Interfaces; plus a `- Status:` field.
18
+ // When the doc also carries `## Executor Report`, the report
19
+ // header fields are validated too.
20
+ // executor-report — required fields: EXECUTOR_TOOL, EXECUTOR_MODEL,
21
+ // EXECUTOR_SUBAGENT, RED_OUTPUT.
22
+ //
23
+ // Parsing is deliberately markdown-light and dependency-free so the logic can be
24
+ // duplicated byte-for-byte into the shipped CLI twin
25
+ // (template_project/.claude/ukit/index/handoff-doc-validator.mjs) without
26
+ // dragging `src/` into user installs — same pattern as taskBudgetValidator (C12).
27
+
28
+ const PLAN_SECTIONS = ['§1', '§2', '§3', '§4', '§5', '§6'];
29
+
30
+ const TASK_SECTIONS = [
31
+ 'Goal',
32
+ 'Target Files',
33
+ 'Test Cases',
34
+ 'Test Files',
35
+ 'Verification Commands',
36
+ 'Acceptance Criteria',
37
+ 'Dependencies',
38
+ 'Interfaces',
39
+ ];
40
+
41
+ const REPORT_FIELDS = ['EXECUTOR_TOOL', 'EXECUTOR_MODEL', 'EXECUTOR_SUBAGENT', 'RED_OUTPUT'];
42
+
43
+ // Section helpers. Any line starting with `## ` opens a section; the body runs
44
+ // until the next `## ` line or EOF. Returns [{ name, body, pos }] in file order.
45
+ function splitSections(markdown) {
46
+ const sections = [];
47
+ const lines = String(markdown).split('\n');
48
+ let current = null;
49
+ lines.forEach((line, idx) => {
50
+ const m = line.match(/^##\s+(.+?)\s*$/);
51
+ if (m) {
52
+ current = { name: m[1], body: '', pos: idx };
53
+ sections.push(current);
54
+ } else if (current) {
55
+ current.body += line + '\n';
56
+ }
57
+ });
58
+ return sections;
59
+ }
60
+
61
+ // Positions of `## §<n>` headings, keyed by the literal token ('§1'..'§6').
62
+ // A heading matches when its name starts with the token (`## §1 Intent`).
63
+ function planSectionPositions(sections) {
64
+ const positions = new Map();
65
+ for (const s of sections) {
66
+ for (const token of PLAN_SECTIONS) {
67
+ if (!positions.has(token) && s.name.startsWith(token)) positions.set(token, s.pos);
68
+ }
69
+ }
70
+ return positions;
71
+ }
72
+
73
+ function hasSection(sections, name) {
74
+ return sections.some((s) => s.name === name || s.name.startsWith(`${name} `) || s.name.startsWith(`${name}(`));
75
+ }
76
+
77
+ function getSection(sections, name) {
78
+ return sections.find((s) => s.name === name || s.name.startsWith(`${name} `) || s.name.startsWith(`${name}(`));
79
+ }
80
+
81
+ function validatePlan(markdown) {
82
+ const failures = [];
83
+ const sections = splitSections(markdown);
84
+ const positions = planSectionPositions(sections);
85
+
86
+ for (const token of PLAN_SECTIONS) {
87
+ if (!positions.has(token)) failures.push(`missing required section heading "## ${token}"`);
88
+ }
89
+
90
+ // Order check over the sections that exist: each must appear after the
91
+ // highest position seen so far.
92
+ let maxPos = -1;
93
+ for (const token of PLAN_SECTIONS) {
94
+ const pos = positions.get(token);
95
+ if (pos === undefined) continue;
96
+ if (pos < maxPos) {
97
+ failures.push(`section order violation: "## ${token}" appears after a later section`);
98
+ } else {
99
+ maxPos = pos;
100
+ }
101
+ }
102
+
103
+ const report = getSection(sections, 'Planner Report');
104
+ if (!report) {
105
+ failures.push('missing required section "## Planner Report"');
106
+ } else if (!/^PLANNER_MODEL\s*:/m.test(report.body)) {
107
+ failures.push('missing required field "PLANNER_MODEL:" inside "## Planner Report"');
108
+ }
109
+
110
+ return failures;
111
+ }
112
+
113
+ function validateExecutorReportFields(markdown, prefix) {
114
+ const failures = [];
115
+ for (const field of REPORT_FIELDS) {
116
+ if (!new RegExp(`^${field}\\s*:`, 'm').test(markdown)) {
117
+ failures.push(`${prefix}missing required field "${field}:"`);
118
+ }
119
+ }
120
+ return failures;
121
+ }
122
+
123
+ function validateTask(markdown) {
124
+ const failures = [];
125
+ const sections = splitSections(markdown);
126
+
127
+ for (const name of TASK_SECTIONS) {
128
+ if (!hasSection(sections, name)) failures.push(`missing required section "## ${name}"`);
129
+ }
130
+ if (!/^\s*-?\s*Status\s*:/m.test(markdown)) {
131
+ failures.push('missing required field "Status:"');
132
+ }
133
+
134
+ // A task file that already carries an Executor Report must satisfy the
135
+ // report header contract too (FR-007).
136
+ const report = getSection(sections, 'Executor Report');
137
+ if (report) {
138
+ failures.push(...validateExecutorReportFields(report.body, 'executor-report: '));
139
+ }
140
+
141
+ return failures;
142
+ }
143
+
144
+ export function validateHandoffDoc(markdown, kind) {
145
+ const text = String(markdown ?? '');
146
+ let failures;
147
+ if (kind === 'plan') failures = validatePlan(text);
148
+ else if (kind === 'task') failures = validateTask(text);
149
+ else if (kind === 'executor-report') failures = validateExecutorReportFields(text, '');
150
+ else failures = [`unknown kind "${kind}" (expected plan|task|executor-report)`];
151
+ return { ok: failures.length === 0, failures };
152
+ }
@@ -0,0 +1,120 @@
1
+ // reviewPanelAggregate.js — TASK-004 / Cycle C50 (mechanic #10, FR-008)
2
+ //
3
+ // Deterministic aggregation of `## Reviewer Verdict` blocks written by the
4
+ // `modelRoles['review-panel']` diversity-review panel (mechanic #6). The
5
+ // orchestrator stays context-lean: this script does the consensus math.
6
+ //
7
+ // Exports:
8
+ // parseVerdictBlock(text) → { member, verdict, findings } | null
9
+ // aggregatePanelVerdicts(verdicts: string[]) →
10
+ // { agreementMap: [{ finding, members[] }],
11
+ // verdict: 'approved' | 'approved_minor' | 'changes_requested',
12
+ // highSignal: [{ finding, members[] }],
13
+ // unparsed: string[] }
14
+ //
15
+ // Consensus rules (SPEC §5 FR-008):
16
+ // changes_requested — ≥1 CRITICAL verdict, ≥1 critical-severity finding,
17
+ // or ≥2 CHANGES-REQUESTED verdicts.
18
+ // approved — ≥2 APPROVED verdicts and no critical.
19
+ // approved_minor — everything else (incl. degenerate/empty panel).
20
+ //
21
+ // High signal: a finding reported by ≥2 panel members, or any critical-severity
22
+ // finding (a lone critical must never be buried below the lead's judgment).
23
+ //
24
+ // Advisory class: the CLI twin always exits 0.
25
+ //
26
+ // Parsing is deliberately markdown-light and dependency-free so the logic can be
27
+ // duplicated byte-for-byte into the shipped CLI twin
28
+ // (template_project/.claude/ukit/index/review-panel-aggregate.mjs) — same
29
+ // pattern as taskBudgetValidator (C12).
30
+
31
+ const SEVERITIES = ['critical', 'important', 'minor'];
32
+
33
+ // Parse one `## Reviewer Verdict` block (format: code-reviewer.md §Output).
34
+ // Returns null when no recognizable VERDICT line exists — the block is then
35
+ // counted as absent and named in `unparsed`.
36
+ export function parseVerdictBlock(text) {
37
+ const src = String(text ?? '');
38
+ const verdictMatch = src.match(/^VERDICT\s*:\s*(APPROVED-WITH-MINOR|APPROVED|CHANGES-REQUESTED|CRITICAL)\s*$/im);
39
+ if (!verdictMatch) return null;
40
+
41
+ const memberMatch = src.match(/^PANEL_MEMBER\s*:\s*(\S+)\s*$/im);
42
+ const member = memberMatch && memberMatch[1] !== '-' ? memberMatch[1] : null;
43
+
44
+ const findings = { critical: [], important: [], minor: [] };
45
+ const findingsMatch = src.match(/^FINDINGS:\s*$/im);
46
+ if (findingsMatch) {
47
+ const rest = src.slice(findingsMatch.index + findingsMatch[0].length);
48
+ let severity = null;
49
+ for (const line of rest.split('\n')) {
50
+ if (/^\S/.test(line)) break; // next top-level field ends FINDINGS
51
+ const sev = line.match(/^\s{2}(critical|important|minor)\s*:/);
52
+ if (sev) {
53
+ severity = sev[1];
54
+ continue;
55
+ }
56
+ const item = line.match(/^\s{4}-\s+(.+?)\s*$/);
57
+ if (item && severity) {
58
+ const text2 = item[1];
59
+ if (text2 !== 'none' && text2 !== '-') findings[severity].push(text2);
60
+ }
61
+ }
62
+ }
63
+
64
+ return { member, verdict: verdictMatch[1].toUpperCase(), findings };
65
+ }
66
+
67
+ // Normalize a finding bullet for identity comparison: two members report the
68
+ // "identical finding" when the normalized text matches.
69
+ function normalizeFinding(text) {
70
+ return String(text).replace(/\s+/g, ' ').trim().toLowerCase();
71
+ }
72
+
73
+ export function aggregatePanelVerdicts(verdicts) {
74
+ const list = Array.isArray(verdicts) ? verdicts : [];
75
+ const parsed = [];
76
+ const unparsed = [];
77
+
78
+ list.forEach((block, i) => {
79
+ const p = parseVerdictBlock(block);
80
+ if (p) parsed.push({ ...p, label: p.member || `verdict[${i}]` });
81
+ else unparsed.push(`verdict[${i}]`);
82
+ });
83
+
84
+ // Agreement Map: finding → set of panel members reporting it.
85
+ const map = new Map(); // normalized → { finding, members:Set, critical:boolean }
86
+ for (const p of parsed) {
87
+ for (const sev of SEVERITIES) {
88
+ for (const f of p.findings[sev]) {
89
+ const key = normalizeFinding(f);
90
+ let entry = map.get(key);
91
+ if (!entry) {
92
+ entry = { finding: f, members: new Set(), critical: sev === 'critical' };
93
+ map.set(key, entry);
94
+ }
95
+ entry.members.add(p.label);
96
+ if (sev === 'critical') entry.critical = true;
97
+ }
98
+ }
99
+ }
100
+
101
+ const agreementMap = [...map.values()].map((e) => ({
102
+ finding: e.finding,
103
+ members: [...e.members],
104
+ }));
105
+ const highSignal = [...map.values()]
106
+ .filter((e) => e.members.size >= 2 || e.critical)
107
+ .map((e) => ({ finding: e.finding, members: [...e.members] }));
108
+
109
+ const approvals = parsed.filter((p) => p.verdict === 'APPROVED').length;
110
+ const changeRequests = parsed.filter((p) => p.verdict === 'CHANGES-REQUESTED').length;
111
+ const hasCritical =
112
+ parsed.some((p) => p.verdict === 'CRITICAL') || parsed.some((p) => p.findings.critical.length > 0);
113
+
114
+ let verdict;
115
+ if (hasCritical || changeRequests >= 2) verdict = 'changes_requested';
116
+ else if (approvals >= 2) verdict = 'approved';
117
+ else verdict = 'approved_minor';
118
+
119
+ return { agreementMap, verdict, highSignal, unparsed };
120
+ }
@@ -11,6 +11,8 @@ import {
11
11
  EXECUTION_CONTRACTS,
12
12
  EXECUTION_MODE_ORDER,
13
13
  MODEL_TIER_BY_CONTRACT,
14
+ ROUTE_EFFORTS,
15
+ resolveModelTier,
14
16
  } from '../core/executionContracts.js';
15
17
  import { resolveModelRoles, readMergedRuntimeConfig } from '../core/runtimeConfig.js';
16
18
  import { resolvePlaybook } from '../core/userPlaybooks.js';
@@ -275,6 +277,37 @@ function formatRiskFloorSegment(riskFloor = null) {
275
277
  return printed.length > 0 ? `risk=${riskFloor.floor}(${printed.join(',')})` : null;
276
278
  }
277
279
 
280
+ // FR-001 (M01.2' limits fragment): compile the contract's numeric budget keys
281
+ // into an advisory map. Only finite numbers survive — a non-numeric or missing
282
+ // key is simply absent. Zero is a real budget ("no read passes"), never
283
+ // filtered. Same key set as the ceremonyBudget.limits builder below.
284
+ export function deriveCeremonyLimits(executionContract = null) {
285
+ if (executionContract === null || typeof executionContract !== 'object') {
286
+ return {};
287
+ }
288
+ return Object.fromEntries(
289
+ ['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
290
+ .filter((key) => Number.isFinite(executionContract[key]))
291
+ .map((key) => [key, executionContract[key]]),
292
+ );
293
+ }
294
+
295
+ // Route-line segment (FR-002): fixed reads,ctx,reassess order; only present
296
+ // keys print. Empty/absent map → null so the segment never appears.
297
+ export function formatLimitsSegment(limits = null) {
298
+ if (limits === null || typeof limits !== 'object') {
299
+ return null;
300
+ }
301
+ const parts = [
302
+ ['reads', 'maxReadPasses'],
303
+ ['ctx', 'maxContextPulls'],
304
+ ['reassess', 'maxReadPassesBeforeReassess'],
305
+ ]
306
+ .filter(([, key]) => Number.isFinite(limits[key]))
307
+ .map(([label, key]) => `${label}:${limits[key]}`);
308
+ return parts.length > 0 ? `limits=${parts.join(',')}` : null;
309
+ }
310
+
278
311
  // FR-004 (M01.3'): Fast Path eligibility predicate. Returns null when no riskFloor
279
312
  // was supplied — eligibility must never be derived without the floor check, so a
280
313
  // missing floor means "not computed", not "none". Eligible iff the lane is
@@ -383,6 +416,10 @@ function buildResolvedRouteFields({
383
416
  completionState = null,
384
417
  riskFloor = null,
385
418
  } = {}) {
419
+ // Decision table v2 (FR-001/FR-002): tier + effort resolve together from the
420
+ // contract lane and the additive riskFloor. The router cannot observe host
421
+ // binding capabilities, so the emitted pair is advisory text by definition.
422
+ const tierDecision = resolveModelTier({ executionMode, riskFloor });
386
423
  const signalText = buildRouteSignalText(routingContext.promptText, routingContext.commandText);
387
424
  const deliveryOnly = isDeliveryOnlyRequest({
388
425
  signalText,
@@ -407,7 +444,8 @@ function buildResolvedRouteFields({
407
444
  riskFloor: riskFloor?.floor ?? null,
408
445
  phase: null,
409
446
  contractVersion: ROUTE_CONTRACT_VERSION,
410
- modelTier: executionContract?.modelTier ?? MODEL_TIER_BY_CONTRACT[executionMode] ?? null,
447
+ modelTier: tierDecision.tier,
448
+ effort: tierDecision.effort,
411
449
  },
412
450
  evidence: {
413
451
  observations: [],
@@ -490,6 +528,10 @@ export function validateResolvedRoute(route = null) {
490
528
  if (route.execution.modelTier !== null && !ROUTE_MODEL_TIERS.has(route.execution.modelTier)) {
491
529
  errors.push(`execution.modelTier must be null or one of: ${[...ROUTE_MODEL_TIERS].join(', ')}.`);
492
530
  }
531
+ if (route.execution.effort !== null && route.execution.effort !== undefined
532
+ && !ROUTE_EFFORTS.has(route.execution.effort)) {
533
+ errors.push(`execution.effort must be null or one of: ${[...ROUTE_EFFORTS].join(', ')}.`);
534
+ }
493
535
  }
494
536
  if (!isObject(route.evidence)) {
495
537
  errors.push('evidence must be an object.');
@@ -849,6 +891,12 @@ export function buildRouteSummary({
849
891
  })
850
892
  : null;
851
893
  const riskSegment = formatRiskFloorSegment(riskFloor);
894
+ // FR-003 (M01.2' limits fragment): the rigor advisory — emitted whenever
895
+ // rigor.stage is on, independent of fastPath/escalation. Stage off → no
896
+ // segment, byte-identical route.
897
+ const limitsSegment = resolveRouteStage(runtimeConfig, 'rigor') !== 'off'
898
+ ? formatLimitsSegment(deriveCeremonyLimits(executionContract))
899
+ : null;
852
900
  // FR-004 (M01.3'): fastPath is emitted whenever its own stage is on — the field
853
901
  // is always set then (eligible or not) so telemetry/harness can read it; the
854
902
  // route-line segment prints only for eligible routes.
@@ -920,6 +968,7 @@ export function buildRouteSummary({
920
968
  formatCompactSegment('styles', styleFiles),
921
969
  editGuardHint ? `editGuard=${editGuardHint}` : null,
922
970
  riskSegment,
971
+ limitsSegment,
923
972
  fastPathSegment,
924
973
  delegationRecommendation?.hint ? `delegate=${delegationRecommendation.hint}` : null,
925
974
  policyMode ? `policy=${policyMode}` : null,
@@ -57,6 +57,8 @@ If any input is missing, return `CHANGES-REQUESTED` with reason "incomplete hand
57
57
  VERDICT: APPROVED | APPROVED-WITH-MINOR | CHANGES-REQUESTED | CRITICAL
58
58
  REVIEWER_MODEL: [model name actually used]
59
59
  EXECUTOR_MODEL: [from executor report]
60
+ PANEL_MEMBER: [panel tier — `smart`/`code`/`lite`, or `solo` when the panel degenerates; `-` outside panel runs]
61
+ AGREEMENT_MAP: [lead member only — finding → panel members reporting it, from review-panel-aggregate.mjs output; `-` otherwise]
60
62
  VERIFICATION_RERUN:
61
63
  command: [exact command]
62
64
  result: [N pass / M fail]
@@ -121,6 +123,29 @@ Same model is the most common silent failure. Do not skip this check.
121
123
  - Keep the verdict block <= 30 lines. Findings are bullet points, not essays.
122
124
  - The same-model refusal above is non-negotiable: bypassing it defeats the entire Quality Gate.
123
125
 
126
+ ## Panel review (diversity review)
127
+
128
+ When the orchestrator spawns you as one member of the `modelRoles['review-panel']`
129
+ panel (`.ukit/storage/config.json` → `modelRoles`, default `['smart','code','lite']`),
130
+ everything above applies unchanged — same Review order, same severity ladder, same
131
+ verdict block — with these additions:
132
+
133
+ - Set `PANEL_MEMBER: <tier>` in the verdict to your panel tier (e.g. `smart`,
134
+ `code`, `lite`). Every member runs the same rubric independently; do not read or
135
+ wait for other members' verdicts.
136
+ - Leave `AGREEMENT_MAP` as `-` unless you are the **lead member** (the first entry in
137
+ `modelRoles['review-panel']` = the judgment tier). After the orchestrator runs
138
+ `node .claude/ukit/index/review-panel-aggregate.mjs <TASK-xxx.md...>` and hands you
139
+ the output, the lead fills `AGREEMENT_MAP` with the emitted finding → members map
140
+ and applies the lead-judgment buckets to every finding: **Act on** / **Consider** /
141
+ **Noted** / **Dismissed**.
142
+ - `consensus≥2 identical findings = high signal`: a finding reported by two or more
143
+ panel members is high-signal and must not be bucketed below **Consider** without a
144
+ stated reason.
145
+ - Degenerate panel: if `modelRoles['review-panel']` is empty or has one entry, the
146
+ panel collapses to a single reviewer — run the normal solo review, set
147
+ `PANEL_MEMBER: solo`, and note the fallback in `NOTES`.
148
+
124
149
  ## Spec/Plan Review (REVIEW_TARGET_TYPE=spec|plan)
125
150
 
126
151
  ### Inputs you expect
@@ -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