@ngockhoale/ukit 3.4.50 → 3.4.52
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 +18 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +36 -4
- package/src/core/reviewPanelAggregate.js +71 -3
- package/template_project/.claude/agents/code-reviewer.md +41 -6
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -11
- package/template_project/.claude/commands/ukit/handoff-review.md +34 -8
- package/template_project/.claude/hooks/handoff-resume.sh +17 -3
- package/template_project/.claude/hooks/skill-router.sh +89 -11
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +71 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +36 -4
- package/template_project/.claude/ukit/index/route-task.mjs +24 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +162 -9
- package/template_project/.claude/ukit/runtime/omp-task-dispatch-guard.mjs +262 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +8 -0
- package/template_project/.omp/agents/code-reviewer.md +41 -6
- package/template_project/.omp/hooks/pre/ukit-bridge.js +38 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// omp-task-dispatch-guard.mjs — block-only preflight for OMP `task` dispatches.
|
|
2
|
+
//
|
|
3
|
+
// Field evidence: an ordinary OMP agent dispatched with a `sourceSession` (or `model`)
|
|
4
|
+
// field is rejected by OMP's embedded validator (18.12.5) even when the value is empty
|
|
5
|
+
// (`''` / `[]` / `null`), and batch-root `sourceSession`/`model` are rejected outright.
|
|
6
|
+
// The model's natural retry after that rejection re-sends the exact same invalid
|
|
7
|
+
// payload, so one bad field can loop a dispatch indefinitely.
|
|
8
|
+
//
|
|
9
|
+
// What this guard does (host PreToolUse return supports BLOCK only — it cannot rewrite
|
|
10
|
+
// the native input, so correction is always the caller's job):
|
|
11
|
+
//
|
|
12
|
+
// - `preflightOmpTaskDispatch()` is the only function a bridge needs to call, before
|
|
13
|
+
// the native validator sees the payload. Valid dispatches are returned as-is with
|
|
14
|
+
// `action: 'allow'` and NEVER touch disk. An invalid dispatch is never rewritten:
|
|
15
|
+
// the guard returns `action: 'block'` carrying `violations`, the exact
|
|
16
|
+
// `removeFields` paths and a `guidance` string, so the model can correct the
|
|
17
|
+
// payload itself; a corrected dispatch is a valid, different shape and is allowed
|
|
18
|
+
// without consuming any budget.
|
|
19
|
+
// - `validateOmpTaskArgs()` checks exactly the confirmed ordinary restriction: batch
|
|
20
|
+
// root `sourceSession`/`model` are invalid, and a `tasks[]` item whose agent is not
|
|
21
|
+
// `seance` must not carry `sourceSession` or `model` at all — presence alone is the
|
|
22
|
+
// violation, any value included. Seance items are NOT validated here (missing
|
|
23
|
+
// `sourceSession`, custom tools, etc. remain the native validator's job); the guard
|
|
24
|
+
// never sanitizes a seance task and never switches an agent to seance. This is
|
|
25
|
+
// deliberately narrower than the full native validator, not a re-implementation.
|
|
26
|
+
// - Repeat blocks are counted per (session, taskInstanceId, dispatch shape). The
|
|
27
|
+
// `taskInstanceId` is REQUIRED for a count: the bridge reads it from
|
|
28
|
+
// `readRouteState(projectRoot, { session_id })` (per-session sidecar, owner
|
|
29
|
+
// verified) and supplies it here. A missing session or task instance id means the
|
|
30
|
+
// budget cannot be scoped, so the dispatch is blocked WITHOUT recording or borrowing
|
|
31
|
+
// any count. Full session ids are hashed (no truncation), so distinct sessions that
|
|
32
|
+
// share a long prefix never collide.
|
|
33
|
+
// - Once attempts reach `OMP_DISPATCH_MAX_RETRIES` identical invalid blocks for one
|
|
34
|
+
// task instance (two, by default), the guidance says STOP: the dispatch shape is
|
|
35
|
+
// provably stuck, and retrying unchanged only burns tokens. The count persists under
|
|
36
|
+
// `.ukit/storage/cache/omp-task-dispatch-guard.json` behind the shared async lock
|
|
37
|
+
// (`withAsyncLock`), so a compact or session reload does NOT reset it. Only hashed
|
|
38
|
+
// keys and counters are stored — never task prose, agent names, or raw session or
|
|
39
|
+
// task-instance ids.
|
|
40
|
+
// - The fingerprint (`ompTaskDispatchFingerprint`) intentionally EXCLUDES task prose,
|
|
41
|
+
// name, read and status — a reworded prompt is the same stuck dispatch and must not
|
|
42
|
+
// reset the count — while preserving agent, the presence AND value of the offending
|
|
43
|
+
// fields, and tools/isolation/isolated, so a genuinely different branch (different
|
|
44
|
+
// agent, tools, isolation, or field value) keeps its own independent count and a new
|
|
45
|
+
// genuine task is never blocked by another task's failures.
|
|
46
|
+
//
|
|
47
|
+
// Failure posture: the guard is fail-CLOSED for invalid input it already understands —
|
|
48
|
+
// a lock timeout or internal I/O error still BLOCKS the known-invalid dispatch
|
|
49
|
+
// (reason: 'lock-unavailable' / 'internal-error') rather than letting a looping
|
|
50
|
+
// payload through; the native validator would reject it anyway. Valid dispatches hit
|
|
51
|
+
// no disk, so they are unaffected by any of this.
|
|
52
|
+
|
|
53
|
+
import fs from 'node:fs';
|
|
54
|
+
import path from 'node:path';
|
|
55
|
+
import crypto from 'node:crypto';
|
|
56
|
+
import { withAsyncLock } from './async-lock.mjs';
|
|
57
|
+
|
|
58
|
+
export const OMP_DISPATCH_MAX_RETRIES = 2;
|
|
59
|
+
const STATE_FILE_NAME = 'omp-task-dispatch-guard.json';
|
|
60
|
+
const MAX_ENTRIES = 200;
|
|
61
|
+
const LOCK_BUDGET_MS = 1_000;
|
|
62
|
+
const ENTRY_TTL_MS = 24 * 60 * 60_000;
|
|
63
|
+
const SEANCE_AGENT = 'seance';
|
|
64
|
+
// Fields the OMP validator rejects when present on an ordinary agent or the batch
|
|
65
|
+
// root. Presence alone (even '' / [] / null) is the violation.
|
|
66
|
+
const ORDINARY_FORBIDDEN_FIELDS = ['sourceSession', 'model'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check the confirmed OMP 18.12.5 ordinary restriction on task dispatch arguments.
|
|
70
|
+
* Pure: never mutates `args`, never talks to disk, and does NOT validate seance tasks
|
|
71
|
+
* (the native validator owns all seance rules).
|
|
72
|
+
* @param {object} args - single task object, or a batch `{ tasks: [...] }`
|
|
73
|
+
* @returns {{ valid: boolean, violations: Array<{code: string, path: string, field: string}> }}
|
|
74
|
+
*/
|
|
75
|
+
export function validateOmpTaskArgs(args) {
|
|
76
|
+
const violations = [];
|
|
77
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
78
|
+
return { valid: true, violations };
|
|
79
|
+
}
|
|
80
|
+
const isBatch = Array.isArray(args.tasks);
|
|
81
|
+
// Batch-root fields are only a violation in batch shape; a single task object's own
|
|
82
|
+
// fields are validated as that task below.
|
|
83
|
+
if (isBatch) {
|
|
84
|
+
for (const field of ORDINARY_FORBIDDEN_FIELDS) {
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(args, field)) {
|
|
86
|
+
violations.push({ code: 'batch-root-field-present', path: field, field });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const tasks = isBatch ? args.tasks : [args];
|
|
91
|
+
tasks.forEach((task, index) => {
|
|
92
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) return;
|
|
93
|
+
// The host trims the agent name before dispatch, so the seance comparison must
|
|
94
|
+
// trim too — a padded ' seance ' agent is a seance task, not an ordinary one.
|
|
95
|
+
if (taskAgent(task) === SEANCE_AGENT) return; // native validator owns seance rules
|
|
96
|
+
const label = isBatch ? `tasks[${index}]` : 'task';
|
|
97
|
+
for (const field of ORDINARY_FORBIDDEN_FIELDS) {
|
|
98
|
+
if (Object.prototype.hasOwnProperty.call(task, field)) {
|
|
99
|
+
violations.push({ code: 'ordinary-field-present', path: `${label}.${field}`, field });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return { valid: violations.length === 0, violations };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Stable shape fingerprint for repeat counting: includes agent, the presence AND value
|
|
108
|
+
* of the offending fields, tools and isolation; deliberately excludes task prose,
|
|
109
|
+
* name, read and status so a reworded prompt cannot reset the count.
|
|
110
|
+
* @param {object} args
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function ompTaskDispatchFingerprint(args) {
|
|
114
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return 'empty';
|
|
115
|
+
const shape = {};
|
|
116
|
+
for (const field of ORDINARY_FORBIDDEN_FIELDS) {
|
|
117
|
+
if (Object.prototype.hasOwnProperty.call(args, field)) shape[field] = normalizeValue(args[field]);
|
|
118
|
+
}
|
|
119
|
+
const tasks = Array.isArray(args.tasks) ? args.tasks : [args];
|
|
120
|
+
shape.tasks = tasks.map((task) => {
|
|
121
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) return { agent: String(task) };
|
|
122
|
+
const item = { agent: taskAgent(task) };
|
|
123
|
+
for (const field of ORDINARY_FORBIDDEN_FIELDS) {
|
|
124
|
+
if (Object.prototype.hasOwnProperty.call(task, field)) item[field] = normalizeValue(task[field]);
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(task.tools)) item.tools = task.tools.map((t) => normalizeValue(t));
|
|
127
|
+
if (task.isolation !== undefined) item.isolation = normalizeValue(task.isolation);
|
|
128
|
+
if (task.isolated !== undefined) item.isolated = normalizeValue(task.isolated);
|
|
129
|
+
return item;
|
|
130
|
+
});
|
|
131
|
+
return JSON.stringify(shape);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The host trims the agent name before dispatch; mirror that here so a padded agent
|
|
135
|
+
// is classified and fingerprinted as its trimmed value.
|
|
136
|
+
function taskAgent(task) {
|
|
137
|
+
return String(task?.agent ?? '').trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Key-order-stable, content-exact serialization: arrays serialize their items (so
|
|
141
|
+
// same-length different-value arrays never collide), objects sort their keys (so
|
|
142
|
+
// insertion order never changes the fingerprint), and only whitespace-only strings
|
|
143
|
+
// collapse to 'empty'. The fingerprint must isolate the ACTUAL offending field value.
|
|
144
|
+
function normalizeValue(value) {
|
|
145
|
+
if (value === null) return ['null'];
|
|
146
|
+
if (Array.isArray(value)) return ['array', value.map(normalizeValue)];
|
|
147
|
+
if (typeof value === 'object') {
|
|
148
|
+
return ['object', Object.keys(value).sort().map((key) => [key, normalizeValue(value[key])])];
|
|
149
|
+
}
|
|
150
|
+
if (typeof value === 'string') return ['string', value.trim() === '' ? '' : value];
|
|
151
|
+
return [typeof value, String(value)];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Full, untruncated session id — slicing (e.g. to 80 chars) would let two distinct
|
|
155
|
+
// long session ids that share a prefix collide into one budget.
|
|
156
|
+
function countKey(sessionId, taskInstanceId, fingerprint) {
|
|
157
|
+
return crypto.createHash('sha1').update(`${sessionId}\u0000${taskInstanceId}\u0000${fingerprint}`).digest('hex').slice(0, 16);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stateFilePath(projectRoot) {
|
|
161
|
+
return path.join(projectRoot, '.ukit', 'storage', 'cache', STATE_FILE_NAME);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readState(file) {
|
|
165
|
+
try {
|
|
166
|
+
const s = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
167
|
+
return s && typeof s === 'object' && s.entries && typeof s.entries === 'object' ? s : { entries: {} };
|
|
168
|
+
} catch {
|
|
169
|
+
return { entries: {} };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function guidanceFor(violations, exhausted, attempts) {
|
|
174
|
+
const lines = violations.map((v) => {
|
|
175
|
+
if (v.code === 'batch-root-field-present') {
|
|
176
|
+
return `Remove field \`${v.field}\` from the batch root: the OMP task validator rejects it there. Keep the tasks[] items unchanged.`;
|
|
177
|
+
}
|
|
178
|
+
return `Remove field \`${v.field}\` from ${v.path}: an ordinary agent (agent !== 'seance') must not carry it — any value, even '' or null, is rejected. Keep the agent, tools and isolation unchanged; do not switch the agent.`;
|
|
179
|
+
});
|
|
180
|
+
if (exhausted) {
|
|
181
|
+
lines.push(`This exact invalid dispatch was already blocked ${attempts} times for this task. STOP retrying it: apply the removal above ONCE, or record this reviewer as NOT_RUN with the actual reason. Continue independent authorized work; do not invent configuration changes or bypass a denied tool.`);
|
|
182
|
+
}
|
|
183
|
+
return lines.join(' ');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Block-only preflight for one OMP task dispatch. See the module header for the full
|
|
188
|
+
* contract. Never rewrites `args`; never validates seance tasks; valid calls never
|
|
189
|
+
* touch disk.
|
|
190
|
+
* @param {{
|
|
191
|
+
* projectRoot: string,
|
|
192
|
+
* sessionId?: string,
|
|
193
|
+
* taskInstanceId?: string,
|
|
194
|
+
* args: object,
|
|
195
|
+
* now?: number,
|
|
196
|
+
* }} input
|
|
197
|
+
* @returns {Promise<{
|
|
198
|
+
* action: 'allow' | 'block',
|
|
199
|
+
* args: object,
|
|
200
|
+
* violations?: Array<object>,
|
|
201
|
+
* removeFields?: string[],
|
|
202
|
+
* guidance?: string,
|
|
203
|
+
* attempts?: number,
|
|
204
|
+
* exhausted?: boolean,
|
|
205
|
+
* reason?: 'unscoped' | 'lock-unavailable' | 'internal-error',
|
|
206
|
+
* }>}
|
|
207
|
+
*/
|
|
208
|
+
export async function preflightOmpTaskDispatch({ projectRoot, sessionId, taskInstanceId, args, now = Date.now() }) {
|
|
209
|
+
const verdict = validateOmpTaskArgs(args);
|
|
210
|
+
if (verdict.valid) {
|
|
211
|
+
return { action: 'allow', args };
|
|
212
|
+
}
|
|
213
|
+
const removeFields = verdict.violations.map((v) => v.path);
|
|
214
|
+
const blockBase = { action: 'block', args, violations: verdict.violations, removeFields };
|
|
215
|
+
try {
|
|
216
|
+
// A count requires a scoppable identity. Without it the dispatch is blocked
|
|
217
|
+
// WITHOUT recording or borrowing any count entry.
|
|
218
|
+
const scoped = projectRoot && sessionId && taskInstanceId
|
|
219
|
+
&& fs.existsSync(path.join(projectRoot, '.ukit', 'storage'));
|
|
220
|
+
if (!scoped) {
|
|
221
|
+
return { ...blockBase, guidance: guidanceFor(verdict.violations, false, 0), reason: 'unscoped' };
|
|
222
|
+
}
|
|
223
|
+
const key = countKey(sessionId, taskInstanceId, ompTaskDispatchFingerprint(args));
|
|
224
|
+
const file = stateFilePath(projectRoot);
|
|
225
|
+
const outcome = await withAsyncLock(file, { deadlineMs: LOCK_BUDGET_MS }, async () => {
|
|
226
|
+
const state = readState(file);
|
|
227
|
+
for (const [k, v] of Object.entries(state.entries)) {
|
|
228
|
+
if (!v || now - (v.last || 0) > ENTRY_TTL_MS) delete state.entries[k];
|
|
229
|
+
}
|
|
230
|
+
const keys = Object.keys(state.entries);
|
|
231
|
+
if (!state.entries[key] && keys.length >= MAX_ENTRIES) {
|
|
232
|
+
for (const k of keys.sort((a, b) => (state.entries[a].last || 0) - (state.entries[b].last || 0)).slice(0, keys.length - MAX_ENTRIES + 1)) {
|
|
233
|
+
delete state.entries[k];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const entry = state.entries[key] || (state.entries[key] = { count: 0, first: now });
|
|
237
|
+
entry.count += 1;
|
|
238
|
+
entry.last = now;
|
|
239
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
240
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
241
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`);
|
|
242
|
+
fs.renameSync(tmp, file);
|
|
243
|
+
return entry.count;
|
|
244
|
+
});
|
|
245
|
+
if (!outcome.ok) {
|
|
246
|
+
// Fail closed: the payload is known-invalid, so block it even without a count.
|
|
247
|
+
return { ...blockBase, guidance: guidanceFor(verdict.violations, false, 0), reason: 'lock-unavailable' };
|
|
248
|
+
}
|
|
249
|
+
const attempts = outcome.value;
|
|
250
|
+
// STOP guidance starts once the count REACHES the bound (after two identical
|
|
251
|
+
// blocks, by default) — not on the block after them.
|
|
252
|
+
const exhausted = attempts >= OMP_DISPATCH_MAX_RETRIES;
|
|
253
|
+
return { ...blockBase, guidance: guidanceFor(verdict.violations, exhausted, attempts), attempts, exhausted };
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
...blockBase,
|
|
257
|
+
guidance: guidanceFor(verdict.violations, false, 0),
|
|
258
|
+
reason: 'internal-error',
|
|
259
|
+
note: `omp dispatch guard count unavailable: ${String(error?.message || error).slice(0, 120)}`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -519,6 +519,14 @@ export async function readResumableRun(projectRoot, {
|
|
|
519
519
|
};
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
+
// The filename is a lossy sanitization of taskId (path chars normalized, length
|
|
523
|
+
// truncated to 80), so distinct task ids can share one record file. A record
|
|
524
|
+
// whose stored taskId is not the exact requested id belongs to another task and
|
|
525
|
+
// must never be borrowed — its evidence and failures are not this task's.
|
|
526
|
+
if (taskId !== undefined && parsed.taskId !== taskId) {
|
|
527
|
+
return { status: 'absent', record: null, warnings: [] };
|
|
528
|
+
}
|
|
529
|
+
|
|
522
530
|
// A new explicit task boundary resets the record — the persisted state belongs
|
|
523
531
|
// to a different logical task.
|
|
524
532
|
if (typeof taskBoundary === 'string' && taskBoundary.length > 0
|
|
@@ -15,6 +15,38 @@ You are the independent reviewer for UKit's handoff Quality Gate. Your model is
|
|
|
15
15
|
- `code` (default, if not specified) — reviewing a handoff task diff. Follow **Code Review** below, unchanged.
|
|
16
16
|
- `spec` | `plan` — reviewing a document (e.g. `docs/plans/*.md`), no diff/task file/executor report involved. Skip straight to **Spec/Plan Review** at the end of this file instead.
|
|
17
17
|
|
|
18
|
+
## REVIEW_KIND (code reviews only)
|
|
19
|
+
|
|
20
|
+
Set one kind per review — from the task's own classification when the spawning
|
|
21
|
+
command names one, else from what the diff shows:
|
|
22
|
+
|
|
23
|
+
- `product` (default when the diff changes source or tests) — the full Code
|
|
24
|
+
Review rubric below applies unchanged, including Test Plan adherence and
|
|
25
|
+
RED_OUTPUT evidence.
|
|
26
|
+
- `integration-evidence` — the diff verifies or mirrors behavior already
|
|
27
|
+
accepted elsewhere (template mirrors, parity copies, runtime/docs evidence).
|
|
28
|
+
The new-tests + RED_OUTPUT TDD requirements are inapplicable when the task
|
|
29
|
+
introduces no new behavior under test; review the evidence chain instead and
|
|
30
|
+
never fabricate tests or edits to satisfy them.
|
|
31
|
+
- `status-only` — the task only records status/evidence; an empty diff is
|
|
32
|
+
legitimate. Review the executor report and verification output on disk.
|
|
33
|
+
|
|
34
|
+
The review kind scopes only the implementation-TDD evidence (new tests +
|
|
35
|
+
RED_OUTPUT). It never waives the required executable checks — the task's
|
|
36
|
+
Verification Commands still re-run in your session (fresh PASS or the severity
|
|
37
|
+
ladder applies; `NOT_RUN` on a required command blocks in every kind) — and it
|
|
38
|
+
never lowers the task's original acceptance criteria.
|
|
39
|
+
|
|
40
|
+
**Verdict ≠ acceptance.** The review verdict and the task's original acceptance
|
|
41
|
+
are separate judgments. An `integration-evidence` review may legitimately be
|
|
42
|
+
`APPROVED` as a review (the evidence chain is sound and complete) while the
|
|
43
|
+
verdict's NOTES/BLOCKING explicitly report that product acceptance remains
|
|
44
|
+
unmet or pending external observation — that report flows through
|
|
45
|
+
`NEXT_STATUS_FOR_INDEX`/the task discussion and must not flip the product task
|
|
46
|
+
to `done` or close a pending parent item. A `status-only` recording is
|
|
47
|
+
bookkeeping, not an independent review pass: it never counts as evidence that
|
|
48
|
+
independent review approved the underlying work.
|
|
49
|
+
|
|
18
50
|
## Code Review (REVIEW_TARGET_TYPE=code)
|
|
19
51
|
|
|
20
52
|
### Inputs you expect
|
|
@@ -28,7 +60,7 @@ If any input is missing, return `CHANGES-REQUESTED` with reason "incomplete hand
|
|
|
28
60
|
### Review order
|
|
29
61
|
|
|
30
62
|
0. **Verification package completeness** — Check whether the project has a lint or typecheck script (`package.json` scripts, or the stack's equivalent). If it does and the task's Verification Commands don't run it, that is `CHANGES-REQUESTED`: "verification commands missing lint/typecheck — re-run planner or add the command and re-verify" — do this before anything else below.
|
|
31
|
-
1. **Test Plan adherence** — Were all tests in §4 actually implemented, including the ≥2 edge cases required by `handoff.plan.minTestsEdgeCase`? Check the Executor Report's `RED_OUTPUT` field: it must contain actual failing-test output (assertion failure, stack trace, non-zero exit), not a bare claim like "confirmed" or "yes". Missing or vague `RED_OUTPUT` → `CHANGES-REQUESTED`: "no evidence tests were RED before implementation — re-run TDD cycle and paste real output".
|
|
63
|
+
1. **Test Plan adherence (REVIEW_KIND=product)** — Were all tests in §4 actually implemented, including the ≥2 edge cases required by `handoff.plan.minTestsEdgeCase`? Check the Executor Report's `RED_OUTPUT` field: it must contain actual failing-test output (assertion failure, stack trace, non-zero exit), not a bare claim like "confirmed" or "yes". Missing or vague `RED_OUTPUT` → `CHANGES-REQUESTED`: "no evidence tests were RED before implementation — re-run TDD cycle and paste real output". For `integration-evidence`/`status-only` reviews that TDD evidence is inapplicable — grade the task's own evidence chain instead of demanding new tests or RED_OUTPUT. Either way, run the required checks yourself: `<task Verification Commands>`. Fresh PASS required, no trusting executor's output blindly.
|
|
32
64
|
2. **Correctness** — Does the diff implement the requested behavior? Any obvious wrong assumptions, stale refs, missing cases?
|
|
33
65
|
3. **Regression risk** — What existing behavior could this break? Are shared paths/tests/contracts still aligned? Run the wider suite only when the diff touched shared code; otherwise the task's own targeted commands are the gate and the wave-boundary full `yarn test` (see docs/AI_HANDOFF/RULES.md "Test selection") is the regression net.
|
|
34
66
|
4. **Safety / security / data loss** — Destructive actions, auth/permission, path handling, unsafe shell/DB/file ops.
|
|
@@ -75,13 +107,14 @@ model, so reviewer ≠ executor still stands.
|
|
|
75
107
|
## Reviewer Verdict
|
|
76
108
|
|
|
77
109
|
VERDICT: APPROVED | APPROVED-WITH-MINOR | CHANGES-REQUESTED | CRITICAL
|
|
110
|
+
REVIEW_KIND: product | integration-evidence | status-only
|
|
78
111
|
REVIEWER_MODEL: [model name actually used]
|
|
79
112
|
EXECUTOR_MODEL: [from executor report]
|
|
80
113
|
PANEL_MEMBER: [panel tier — `@slow`/`@default`/`@smol`, or `solo` when the panel degenerates; `-` outside panel runs]
|
|
81
114
|
AGREEMENT_MAP: [lead member only — finding → panel members reporting it, from review-panel-aggregate.mjs output; `-` otherwise]
|
|
82
|
-
VERIFICATION_RERUN:
|
|
83
|
-
command: [exact command]
|
|
84
|
-
result: [N pass / M fail]
|
|
115
|
+
VERIFICATION_RERUN: PASS | FAIL | NOT_RUN | N/A
|
|
116
|
+
command: [exact command, or `-` when N/A]
|
|
117
|
+
result: [N pass / M fail — or NOT_RUN — <reason>, or N/A — <reason>]
|
|
85
118
|
TEST_PLAN_COVERAGE: [all-followed | partial — list gaps | missing — list]
|
|
86
119
|
FINDINGS:
|
|
87
120
|
critical:
|
|
@@ -102,7 +135,7 @@ After writing the verdict, update `docs/AI_HANDOFF/INDEX.md` row for this task:
|
|
|
102
135
|
TASK: TASK-xxx
|
|
103
136
|
VERDICT: approved | approved_minor | changes_requested | critical_block
|
|
104
137
|
REVIEWER_MODEL: <exact model ID>
|
|
105
|
-
VERIFICATION_RERUN: PASS | FAIL
|
|
138
|
+
VERIFICATION_RERUN: PASS | FAIL | NOT_RUN | N/A (+ one-line reason for NOT_RUN/N/A)
|
|
106
139
|
BLOCKING: <one line per critical/important finding, or "none">
|
|
107
140
|
```
|
|
108
141
|
|
|
@@ -138,7 +171,9 @@ Same model is the most common silent failure. Do not skip this check.
|
|
|
138
171
|
### Rules
|
|
139
172
|
|
|
140
173
|
- **Always re-run** the task's Verification Commands. If they fail, VERDICT = CRITICAL regardless of executor claims.
|
|
141
|
-
-
|
|
174
|
+
- **VERIFICATION_RERUN is evidence, not prose.** `NOT_RUN` = the required verification never executed in your session (blocked, timed out, environment unavailable); `FAIL` = the re-run executed and did not pass. Either one must never accompany APPROVED or APPROVED-WITH-MINOR — with `NOT_RUN` the verdict is at most CHANGES-REQUESTED, and with `FAIL` it is CRITICAL per the ladder above. State the blocking status once, with the reason — a contradicting second line (e.g. header `PASS` next to `result: NOT_RUN`) is read fail-closed: the blocking status wins. `N/A` is only legitimate when this review has no executable required checks (e.g. a read-only spec/plan review, with `command: -` or no command line); when the task package defines Verification Commands, the re-run is required, so an `N/A` claim there counts as `NOT_RUN`. An `N/A` result that sits beside a declared executable `command:` (e.g. `command: yarn test`) contradicts its own evidence and counts as `NOT_RUN` — the aggregator fails it closed too. Never fabricate a PASS — a fabricated rerun is a fake test → CRITICAL.
|
|
175
|
+
- If executor said `TEST_PLAN_FOLLOWED: N/A` without a real justification, downgrade to at minimum CHANGES-REQUESTED (REVIEW_KIND=product; a check-free `integration-evidence`/`status-only` review may declare N/A legitimately when no Test Plan applies — the required Verification Commands still re-run).
|
|
176
|
+
- Verdict spelling is `-`/`_`-insensitive at the aggregator: `CHANGES_REQUESTED`, `CRITICAL_BLOCK`, `APPROVED_WITH_MINOR` parse to the same canonical verdicts as the hyphen forms, so write either spelling — a blocking verdict is never dropped as unparsed.
|
|
142
177
|
- Never approve when test file has no real `expect`/`assert` - that is a fake test -> CRITICAL.
|
|
143
178
|
- Keep the verdict block <= 30 lines. Findings are bullet points, not essays.
|
|
144
179
|
- The same-model refusal above is non-negotiable: bypassing it defeats the entire Quality Gate.
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
// drift apart (a configured 60-120s chain used to be killed here at 14-20s, which
|
|
30
30
|
// for Edit|Write is a fail-closed transport failure = every edit blocked).
|
|
31
31
|
import { resolveChainExecTimeoutMs } from '../../../.claude/ukit/runtime/hook-chain-budget.mjs';
|
|
32
|
+
import { preflightOmpTaskDispatch } from '../../../.claude/ukit/runtime/omp-task-dispatch-guard.mjs';
|
|
32
33
|
|
|
33
34
|
// OMP-3 (TASK-002): pi.exec forwards only {cwd, signal, timeout} to its spawn —
|
|
34
35
|
// an environment option is dropped (verified against the bundled omp exec), and the host's
|
|
@@ -276,6 +277,27 @@ function runtimeMetadata(event = {}, context = {}) {
|
|
|
276
277
|
};
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
// OMP 18.12.5 before_agent_start has no origin field. Recover only the nearest
|
|
281
|
+
// native prompt-bearing branch entry, never a prefix claimed by prompt text.
|
|
282
|
+
export function promptOriginFromBranch(prompt, context = {}) {
|
|
283
|
+
try {
|
|
284
|
+
const branch = context?.sessionManager?.getBranch?.();
|
|
285
|
+
if (!Array.isArray(branch)) return 'user';
|
|
286
|
+
for (let i = branch.length - 1; i >= Math.max(0, branch.length - 64); i -= 1) {
|
|
287
|
+
const entry = branch[i];
|
|
288
|
+
if (entry?.type === 'message' && entry.message?.role === 'user') return 'user';
|
|
289
|
+
const custom = entry?.type === 'custom_message' ? entry
|
|
290
|
+
: entry?.type === 'message' && entry.message?.role === 'custom' ? entry.message : null;
|
|
291
|
+
if (!custom || custom.customType !== 'session-stop-continuation') continue;
|
|
292
|
+
return custom.attribution === 'agent' && typeof custom.content === 'string'
|
|
293
|
+
&& custom.content.trim() === String(prompt ?? '').trim() ? 'system' : 'user';
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// Older hosts or a closed session may not expose branch metadata.
|
|
297
|
+
}
|
|
298
|
+
return 'user';
|
|
299
|
+
}
|
|
300
|
+
|
|
279
301
|
function buildHookPayload(hookEventName, fields = {}) {
|
|
280
302
|
const payload = { hook_event_name: hookEventName, ukit_harness: 'omp' };
|
|
281
303
|
const mappings = [
|
|
@@ -290,6 +312,7 @@ function buildHookPayload(hookEventName, fields = {}) {
|
|
|
290
312
|
['prompt', 'prompt'],
|
|
291
313
|
['transcriptPath', 'transcript_path'],
|
|
292
314
|
['source', 'source'],
|
|
315
|
+
['promptOrigin', 'ukit_prompt_origin'],
|
|
293
316
|
['subagentSession', 'ukit_subagent_session'],
|
|
294
317
|
];
|
|
295
318
|
for (const [field, payloadField] of mappings) {
|
|
@@ -892,6 +915,20 @@ export async function runToolCall(pi, event, { projectRoot, context: extensionCo
|
|
|
892
915
|
: [];
|
|
893
916
|
|
|
894
917
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
918
|
+
// Only the native OMP task tool uses this schema. Block, never rewrite: ordinary
|
|
919
|
+
// agents must omit seance-only fields, including empty/null placeholders. Some
|
|
920
|
+
// host versions may reject arguments before tool_call; that remains native-owned.
|
|
921
|
+
if (String(rawToolName ?? '').trim().toLowerCase() === 'task') {
|
|
922
|
+
const state = await readRouteState(projectRoot, { session_id: metadata.sessionId });
|
|
923
|
+
const dispatch = await preflightOmpTaskDispatch({
|
|
924
|
+
projectRoot, sessionId: metadata.sessionId, taskInstanceId: state?.taskInstanceId,
|
|
925
|
+
args: event.input,
|
|
926
|
+
});
|
|
927
|
+
if (dispatch.action === 'block') {
|
|
928
|
+
return { block: true, toolName: 'Task', invoked: [], context: [],
|
|
929
|
+
reason: `UKit OMP task preflight: ${dispatch.guidance}` };
|
|
930
|
+
}
|
|
931
|
+
}
|
|
895
932
|
const payload = buildHookPayload('PreToolUse', {
|
|
896
933
|
toolName,
|
|
897
934
|
toolInput: normalizeToolInput(toolName, event.input),
|
|
@@ -981,6 +1018,7 @@ export async function runBeforeAgentStart(pi, event, { projectRoot, context: ext
|
|
|
981
1018
|
const metadata = runtimeMetadata(event, extensionContext);
|
|
982
1019
|
const payload = buildHookPayload('UserPromptSubmit', {
|
|
983
1020
|
prompt: event.prompt,
|
|
1021
|
+
promptOrigin: promptOriginFromBranch(event.prompt, extensionContext),
|
|
984
1022
|
...metadata,
|
|
985
1023
|
});
|
|
986
1024
|
const result = await runScriptChain(pi, HOOK_EVENT_MAP.before_agent_start, payload, { projectRoot });
|