@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.
- package/CHANGELOG.md +13 -0
- package/manifests/documentation.yaml +11 -0
- package/manifests/engineConformance.yaml +32 -0
- package/manifests/hostCapabilities.yaml +1 -1
- package/manifests/platform.full.yaml +26 -0
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +38 -2
- package/src/core/executionContracts.js +40 -0
- package/src/core/handoffDocValidator.js +152 -0
- package/src/core/reviewPanelAggregate.js +120 -0
- package/src/index/taskRouting.js +50 -1
- package/template_project/.claude/agents/code-reviewer.md +25 -0
- package/template_project/.claude/agents/handoff-planner.md +15 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +25 -5
- package/template_project/.claude/commands/ukit/handoff-review.md +31 -6
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +193 -0
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +174 -0
- package/template_project/.claude/ukit/index/route-task.mjs +91 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +4 -1
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +187 -8
- package/template_project/.omp/agents/code-reviewer.md +27 -0
- package/template_project/.omp/agents/handoff-planner.md +15 -0
- package/template_project/ukit/storage/config.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## 3.0.3 - 2026-09-23
|
|
7
|
+
|
|
8
|
+
- Same content as 3.0.2 (npm staged-publish never finalized; re-published as 3.0.3).
|
|
9
|
+
|
|
10
|
+
## 3.0.2 - 2026-09-23
|
|
11
|
+
|
|
12
|
+
- **v3 reshape leftovers + docs verify-and-close (cycles C49–C51)**:
|
|
13
|
+
- Decision table v2: `MODEL_TIER_BY_CONTRACT` + `resolveModelTier` in `src/core/executionContracts.js` — contract→tier resolution is now data-driven.
|
|
14
|
+
- `limits=` route-text segment behind `routing.rigor.stage` (`src/index/taskRouting.js` + `route-task.mjs` mirror; stage-off output byte-identical, 10-case suite).
|
|
15
|
+
- Mechanical validators: `handoffDocValidator.js` + `reviewPanelAggregate.js` (+ `.mjs` CLIs) — handoff doc checks and review-panel aggregation now scriptable.
|
|
16
|
+
- Exit-predicate loop in stop-coordinator; diversity-review contract; M05.1' program-gate verdict + pstack doc banners.
|
|
17
|
+
- Docs: DOC-201..206 verified shipped (165/165 acceptance tests); stale STATUS.md queue pointer + 11 roadmap rows corrected.
|
|
18
|
+
|
|
6
19
|
## 3.0.1 - 2026-09-23
|
|
7
20
|
|
|
8
21
|
- **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:
|
|
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,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
|
+
}
|
package/src/index/taskRouting.js
CHANGED
|
@@ -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:
|
|
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
|