@ngockhoale/ukit 2.7.7 → 2.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/package.json +1 -1
  3. package/src/context/detectProjectContext.js +5 -0
  4. package/src/core/codeintel/invalidation.js +4 -0
  5. package/src/core/fileOps.js +40 -117
  6. package/src/render/buildVariables.js +10 -0
  7. package/templates/.claude/agents/bug-debugger.md +1 -1
  8. package/templates/.claude/agents/feature-implementer.md +2 -2
  9. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  10. package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -1
  11. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  12. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  14. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  15. package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
  16. package/templates/.claude/hooks/skill-router.sh +15 -8
  17. package/templates/.claude/hooks/verification-guard.sh +3 -0
  18. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  19. package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
  20. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  21. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  22. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
  23. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
  24. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  25. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  26. package/templates/.codex/settings.json +1 -5
  27. package/templates/.omp/agents/bug-debugger.md +1 -1
  28. package/templates/.omp/agents/feature-implementer.md +2 -2
  29. package/templates/.omp/hooks/pre/ukit-bridge.js +157 -26
  30. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  31. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  32. package/templates/ukit/storage/config.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.7.8 - 2026-09-22
6
+
7
+ Stall/hang fixes — cycle C43 (TASK-001..008): the indefinite-stall producers
8
+ root-caused in `docs/AI_REPORT/2026-09-21-NORMAL_CHAT_STALL.md` are fixed
9
+ across Claude Code, omp, and Codex runtimes.
10
+
11
+ - **CX-8**: route-state writes are atomic + locked — torn `skill-router-state.json`
12
+ writes can no longer wedge the router.
13
+ - **OMP-3/F-2**: omp receipt dedupe — a tool result is recorded once; duplicate
14
+ receipts no longer double-count execution records.
15
+ - **RC-4/OMP-1/F-3/F-4**: breaker-freeze family — budgeted reclaim, pstart
16
+ stamps, fail-open streaks; continuation sidecar merge gate stamps
17
+ `lastContinuationAt` strictly newer than its base and materializes counter
18
+ state when the main ledger file is absent.
19
+ - **F-5/F-5b**: `withFileLock` policy + reclaim — stale locks reclaimed via
20
+ pstart mismatch; recycled-pid regression tests added.
21
+ - **F-15**: verifier-classification — verification outcomes classified so a
22
+ non-verification failure no longer masquerades as a test failure.
23
+ - **F-RC-2**: `hook-chain-runner` exits promptly after verdict — bounded stdout
24
+ flush then `process.exit`; an abandoned in-proc step promise can no longer
25
+ pin the event loop for the full registered timeout.
26
+ - **F-10/OMP-2**: omp Stop parity — omp sessions get the same handoff-cursor
27
+ Stop-gate lane as Claude Code.
28
+ - **OMP-6/F-8/CX-10**: `createPayloadReference` staging runs under a deadline
29
+ with fail-open to inline payload (never throws, host loop not frozen); omp
30
+ SessionStart carries `session_id` so `reset-compact-pressure` no longer
31
+ wipes every session's pressure records; codex `requiredBeforeCompletion`
32
+ renders only commands that exist in `package.json`.
33
+ - Housekeeping: stale "Kilo" tool labels scrubbed from handoff docs, agent
34
+ report contracts, and config help text (functional support was already
35
+ removed; the regression tests and gateway comments documenting that removal
36
+ are intentionally kept).
37
+
5
38
  ## 2.7.7 - 2026-09-21
6
39
 
7
40
  Audit-report remediation + hook observability — cycle C42 (TASK-001..011): all 12
@@ -64,6 +97,15 @@ findings from the three 2026-09-20 reports closed; the reports moved to
64
97
  fixture whose outcome depended on whether the clock ticked mid-array, so it
65
98
  passed on an unloaded box and failed under load; the fixture is now
66
99
  deterministic and the case fails against the old code.
100
+ - `withFileLock` (both `templates/.claude/ukit/runtime/token-utils.mjs` and its
101
+ `src/core/fileOps.js` twin) is now fail-closed: a lock wait that exceeds
102
+ `maxWaitMs` skips the mutation instead of running it unlocked, and the drop is
103
+ journaled to `<file>.lock-drops.jsonl` (bounded 128-record JSONL). Both twins
104
+ delegate to `async-lock.mjs`, so owner stamping (pid + token + pstart),
105
+ recycled-pid detection, and claim+quarantine stale reclaim exist exactly once;
106
+ a stale lock whose recorded pid was recycled is now reclaimed instead of
107
+ wedging every mutation for that file. `writeJson`/`writeFileAtomic` fall back
108
+ to copy+unlink when `rename` fails with EXDEV (cross-mount tmp dirs).
67
109
 
68
110
  ## 2.7.6 - 2026-09-20
69
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.7",
3
+ "version": "2.7.8",
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",
@@ -24,5 +24,10 @@ export async function detectProjectContext(projectRoot) {
24
24
  os: process.platform,
25
25
  nodeVersion: process.version,
26
26
  },
27
+ // CX-10: raw package.json scripts so renderers can emit only commands that
28
+ // actually exist (verify-context.mjs:186-191 does the same existence check).
29
+ scripts: packageJson?.scripts && typeof packageJson.scripts === 'object'
30
+ ? packageJson.scripts
31
+ : {},
27
32
  };
28
33
  }
@@ -105,6 +105,10 @@ export async function notifyEdit(projectRoot, relPaths) {
105
105
  for (let attempt = 0; attempt < MERGE_MAX_ATTEMPTS; attempt += 1) {
106
106
  try {
107
107
  const paths = await withFileLock(dirtyPath, mergeOnce);
108
+ // TASK-004: withFileLock is fail-closed — a contended lock skips the merge
109
+ // (journaled to dirty.json.lock-drops.jsonl) and resolves undefined. Return
110
+ // the current state like the exhausted-retry path; never retry unlocked.
111
+ if (paths === undefined) break;
108
112
  return { dirty: paths };
109
113
  } catch (error) {
110
114
  lastError = error;
@@ -1,7 +1,11 @@
1
- import crypto from 'node:crypto';
2
1
  import fs from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
 
4
+ // The shipped runtime carries the single lock implementation; src and the
5
+ // installed package always ship templates/ together (package.json files list),
6
+ // so the protocol twin delegates instead of keeping a driftable copy.
7
+ import { journalDroppedLockMutation, withAsyncLock } from '../../templates/.claude/ukit/runtime/async-lock.mjs';
8
+
5
9
  export async function pathExists(targetPath) {
6
10
  try {
7
11
  await fs.access(targetPath);
@@ -104,7 +108,17 @@ export async function writeFileAtomic(filePath, content) {
104
108
  } else {
105
109
  await fs.writeFile(tempPath, content, 'utf8');
106
110
  }
107
- await fs.rename(tempPath, filePath);
111
+ try {
112
+ await fs.rename(tempPath, filePath);
113
+ } catch (renameError) {
114
+ // EXDEV: the tmp file and the destination sit on different mounts (union
115
+ // mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
116
+ // The payload is already fully written — copy it over and unlink the tmp.
117
+ // Less atomic than rename, but the update must not be silently lost.
118
+ if (renameError?.code !== 'EXDEV') throw renameError;
119
+ await fs.copyFile(tempPath, filePath);
120
+ await fs.rm(tempPath, { force: true });
121
+ }
108
122
  } catch (error) {
109
123
  try {
110
124
  await fs.rm(tempPath, { force: true });
@@ -161,130 +175,39 @@ export async function writeJson(filePath, data) {
161
175
  const LOCK_STALE_MS = 10_000;
162
176
  const LOCK_MAX_WAIT_MS = 5_000;
163
177
 
164
- function lockBackoffDelayMs() {
165
- return 3 + Math.floor(Math.random() * 9);
166
- }
167
-
168
- function sleep(ms) {
169
- return new Promise((resolve) => setTimeout(resolve, ms));
170
- }
171
-
172
- function isPidAlive(pid) {
173
- try {
174
- process.kill(pid, 0);
175
- return true;
176
- } catch (error) {
177
- // EPERM: the process exists but belongs to another user — still alive.
178
- return error?.code === 'EPERM';
179
- }
180
- }
181
-
182
- async function readLockOwner(lockPath) {
183
- try {
184
- const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
185
- const pid = Number(raw?.pid);
186
- return Number.isInteger(pid) && pid > 0
187
- ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
188
- : null;
189
- } catch {
190
- return null;
191
- }
192
- }
193
-
194
- // Same-pid holders are parallel async flows whose liveness a pid probe cannot prove.
195
- const inProcessLockHolders = new Map();
196
-
197
178
  /**
198
179
  * Serialize read-modify-write mutations of a shared state file — across processes
199
180
  * (hook invocations run as separate node processes) and across concurrent async
200
181
  * flows in one process (parallel subagents). The lock is a directory created next
201
182
  * to the target file: `mkdir` is atomic, so exactly one caller can create it.
202
- * Ownership is recorded in an `owner` file inside the lock dir: stale reclaim first
203
- * proves the recorded holder is gone (dead pid, or no in-process holder for our own
204
- * pid — no owner file keeps legacy mtime-only recovery). Release only removes a dir
205
- * this acquisition still owns, so a reclaimed-then-reacquired lock is never deleted
206
- * out from under its successor.
207
- * Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
208
- * the callback runs anyway (the pre-lock behaviour) — these state files are
209
- * advisory caches, and losing an update beats freezing a hook mid-flight.
210
- * Protocol-compatible with the runtime token-utils.mjs lock (same `<file>.lock`
211
- * path and owner-file format), so CLI and hook processes serialize against each other.
183
+ *
184
+ * The entire lock protocol lives in templates/.claude/ukit/runtime/async-lock.mjs
185
+ * (TASK-004 fix round 1): owner stamping (pid + token + pstart), recycled-pid
186
+ * detection via recordedProcessGone, claim+quarantine stale reclaim, and verified
187
+ * release exist exactly once there — this function and the token-utils.mjs twin
188
+ * both delegate to it so the two protocol copies can never drift apart again.
189
+ *
190
+ * FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
191
+ * be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
192
+ * the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
193
+ * advisory caches: losing an update was already the accepted outcome of the old
194
+ * fail-open race; now it is explicit and journaled instead of a silent torn write.
212
195
  * @param {string} filePath - state file the mutation targets (lock lives beside it)
213
196
  * @param {() => Promise<*>} fn - critical section; its result is returned
214
- * @returns {Promise<*>} whatever fn resolves with
197
+ * @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
198
+ * lock wait expired and the mutation was skipped (journaled)
215
199
  */
216
200
  export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
217
- const lockPath = `${filePath}.lock`;
218
- const startedAt = Date.now();
219
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
220
- let locked = false;
221
- let ownerStamped = false;
222
-
223
- while (!locked) {
224
- try {
225
- await ensureDir(path.dirname(lockPath));
226
- await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
227
- locked = true;
228
- inProcessLockHolders.set(lockPath, ownerToken);
229
- try {
230
- await fs.writeFile(
231
- path.join(lockPath, 'owner'),
232
- `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
233
- 'utf8',
234
- );
235
- ownerStamped = true;
236
- } catch {
237
- ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
238
- }
239
- break;
240
- } catch (error) {
241
- if (error?.code !== 'EEXIST') throw error;
242
- }
243
-
244
- // Someone holds the lock. Reclaim it only when the holder is provably gone.
245
- try {
246
- const stat = await fs.stat(lockPath);
247
- if (Date.now() - stat.mtimeMs > staleMs) {
248
- const owner = await readLockOwner(lockPath);
249
- const liveInProcess = inProcessLockHolders.has(lockPath);
250
- const reclaimable = !owner || owner.pid === process.pid
251
- ? !liveInProcess
252
- : !isPidAlive(owner.pid);
253
- if (reclaimable) {
254
- await fs.rm(lockPath, { recursive: true, force: true });
255
- continue; // the slot is free now — retry immediately
256
- }
257
- }
258
- } catch (statError) {
259
- // BUG-C22-17: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
260
- // mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
261
- // EEXIST) must not busy-spin — a bare `continue` skipped both the
262
- // maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
263
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
264
- if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
265
- await sleep(lockBackoffDelayMs());
266
- continue;
267
- }
268
-
269
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
270
- await sleep(lockBackoffDelayMs());
271
- }
272
-
273
- try {
274
- return await fn();
275
- } finally {
276
- if (locked) {
277
- try {
278
- const current = ownerStamped ? await readLockOwner(lockPath) : null;
279
- if (current && current.token === ownerToken) {
280
- await fs.rm(lockPath, { recursive: true, force: true });
281
- }
282
- if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
283
- } catch {
284
- // best-effort release; a stale lock is reclaimed by the next waiter
285
- }
286
- }
287
- }
201
+ const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
202
+ if (outcome?.ok === true) return outcome.value;
203
+ // Fail closed: the mutation is dropped, never run unlocked. The drop is
204
+ // journaled so the lost update is auditable; callers treat undefined as
205
+ // "update skipped" (they already tolerated losing it silently).
206
+ await journalDroppedLockMutation(filePath, {
207
+ reason: 'lock-wait-expired',
208
+ waitedMs: outcome?.waitedMs ?? maxWaitMs,
209
+ });
210
+ return undefined;
288
211
  }
289
212
 
290
213
  /**
@@ -39,6 +39,16 @@ export function buildTemplateVariables({ projectContext, stackContext, packageVe
39
39
  os: projectContext.runtime.os,
40
40
  nodeVersion: projectContext.runtime.nodeVersion,
41
41
  },
42
+ verification: {
43
+ // CX-10: render only commands whose scripts exist in the target
44
+ // package.json — a hardcoded lint/typecheck list produced a permanently
45
+ // failing verification contract on projects without those scripts.
46
+ requiredBeforeCompletion: JSON.stringify(
47
+ ['test', 'lint', 'typecheck']
48
+ .filter((name) => projectContext?.scripts?.[name])
49
+ .map((name) => `${projectContext.runtime.packageManager} ${name}`),
50
+ ),
51
+ },
42
52
  stack: {
43
53
  // original flags
44
54
  frontend: stackContext.flags.frontend,
@@ -50,7 +50,7 @@ Systematic debugging — understand before fixing.
50
50
 
51
51
  ```
52
52
  STATUS: DONE | BLOCKED | PARTIAL
53
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
53
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
54
54
  EXECUTOR_MODEL: [exact model name you are running as. "unknown" if you cannot tell.]
55
55
  EXECUTOR_SUBAGENT: [subagent name within your host, if any, else "-"]
56
56
  SUMMARY: [1-2 sentences — root cause and fix]
@@ -72,9 +72,9 @@ and even then, report it, don't ask about it.
72
72
 
73
73
  ```
74
74
  STATUS: DONE | BLOCKED | PARTIAL
75
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
75
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
76
76
  EXECUTOR_MODEL: [exact model name you are running as — e.g. unic-code, claude-sonnet-4-5, gpt-5-mini. If you truly cannot tell, write "unknown" — reviewer treats unknown as suspicious and asks the human to confirm.]
77
- EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Kilo:code", "Claude:feature-implementer". Otherwise "-".]
77
+ EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Claude:feature-implementer", "omp:task". Otherwise "-".]
78
78
  SUMMARY: [1-2 sentences of what was implemented]
79
79
  TEST_PLAN_FOLLOWED: [task §4 / inline / N/A — reason]
80
80
  FILES_CHANGED:
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-create — Phase 1 + 2: Plan
2
2
 
3
3
  **Role: PLANNER**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
  **Model split:**
6
6
  - Read/understand → lite model (haiku · unic-lite · cheapest available)
7
7
  - Write plan + tasks → strong model (Opus · unic-smart · strongest available)
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-fullstack — Full Pipeline: Plan → Implement → Review
2
2
 
3
3
  **Role: ORCHESTRATOR**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
 
6
6
  ## Model Split
7
7
 
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-implement — Phase 3: Execute
2
2
 
3
3
  **Role: EXECUTOR (orchestrated)**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
  **Model: code model** (Sonnet · unic-code · cheap-smart)
6
6
 
7
7
  > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
@@ -1,7 +1,7 @@
1
1
  # /ukit:handoff-review — Phase 4: Review
2
2
 
3
3
  **Role: REVIEWER**
4
- **Tool: any** (Claude Code / Codex / omp / Kilo — your choice)
4
+ **Tool: any** (Claude Code / Codex / omp — your choice)
5
5
  **Model: strong model, MUST differ from executor** (Opus · unic-smart · strongest available)
6
6
 
7
7
  > **omp model tiers:** the tiers above map to `.omp/config.yml`'s `modelRoles`, referenced from agent frontmatter as `@smol` / `@default` / `@slow`.
@@ -238,7 +238,10 @@ async function readRunCursor() {
238
238
  if (synced.reset || synced.probed) {
239
239
  // Persist a clean probe too: hook processes are ephemeral, so otherwise every
240
240
  // mutation would reopen and parse the same 4MB transcript tail.
241
- state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
241
+ // TASK-004: withFileLock is fail-closed — a contended pressure lock skips the
242
+ // write and resolves undefined (journaled). Keep the synced in-memory state;
243
+ // the gate still decides on correct data, the persist just lands next run.
244
+ state = (await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig)) ?? synced.state;
242
245
  }
243
246
  } catch {
244
247
  state = await mod.buildCompactPressureState(rawState, sessionConfig);
@@ -187,16 +187,24 @@ if (toolName === 'Write' || toolName === 'Edit') {
187
187
  const newVisible = stripHtmlComments(newContent);
188
188
  const currentVisible = stripHtmlComments(currentContent);
189
189
 
190
+ // A documented, human-approved override closes the tier contract for this
191
+ // cycle — e.g. a gateway with no smart-tier model where the human approved
192
+ // planning on the available tier. Same semantics as the push-time check.
193
+ const planHasHumanOverride = (text) =>
194
+ /## Model-Tier Guard Override/.test(text) && /OVERRIDE_APPROVED_BY:\s*human/i.test(text);
195
+
190
196
  if (isPlan && /## Planner Report/.test(newVisible)) {
191
197
  const plannerModel = extractField(newVisible, 'PLANNER_MODEL');
192
- if (!plannerModel || /^unknown$/i.test(plannerModel)) {
193
- block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
194
- }
195
- if (tierOf(plannerModel) === 'vision') {
196
- block(`PLANNER_MODEL "${plannerModel}" ${VISION_LANE_MESSAGE}`);
197
- }
198
- if (tierOf(plannerModel) !== 'smart') {
199
- block(`PLANNER_MODEL "${plannerModel}" is not strong/opus tier. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart).`);
198
+ if (!planHasHumanOverride(newVisible)) {
199
+ if (!plannerModel || /^unknown$/i.test(plannerModel)) {
200
+ block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
201
+ }
202
+ if (tierOf(plannerModel) === 'vision') {
203
+ block(`PLANNER_MODEL "${plannerModel}" ${VISION_LANE_MESSAGE}`);
204
+ }
205
+ if (tierOf(plannerModel) !== 'smart') {
206
+ block(`PLANNER_MODEL "${plannerModel}" is not strong/opus tier. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart).`);
207
+ }
200
208
  }
201
209
  }
202
210
 
@@ -209,8 +217,9 @@ if (toolName === 'Write' || toolName === 'Edit') {
209
217
  const isFreshTaskFile = !fileExists;
210
218
  if (isFreshTaskFile) {
211
219
  const planContent = (await pathExists(planPath)) ? await readTextSafe(planPath) : '';
212
- const plannerModel = extractField(stripHtmlComments(planContent), 'PLANNER_MODEL');
213
- if (!plannerModel || tierOf(plannerModel) !== 'smart') {
220
+ const planVisible = stripHtmlComments(planContent);
221
+ const plannerModel = extractField(planVisible, 'PLANNER_MODEL');
222
+ if (!planHasHumanOverride(planVisible) && (!plannerModel || tierOf(plannerModel) !== 'smart')) {
214
223
  block(`Cannot create ${taskId}.md — PLAN.md has no valid smart-tier PLANNER_MODEL yet. Run planning via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) first.`);
215
224
  }
216
225
 
@@ -285,13 +294,15 @@ if (toolName === 'Write' || toolName === 'Edit') {
285
294
  if (hasReviewerVerdict && !currentReviewerReal) {
286
295
  const executorModel = extractField(currentVisible, 'EXECUTOR_MODEL') || extractField(newVisible, 'EXECUTOR_MODEL');
287
296
  const reviewerModel = extractField(newVisible, 'REVIEWER_MODEL');
297
+ const planContentForReview = (await pathExists(planPath)) ? await readTextSafe(planPath) : '';
298
+ const reviewOverride = planHasHumanOverride(stripHtmlComments(planContentForReview));
288
299
  if (isPlaceholderValue(reviewerModel) || /^unknown$/i.test(reviewerModel)) {
289
300
  if (!isFreshTaskFile) {
290
301
  block(`${taskId}: REVIEWER_MODEL missing/placeholder. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart) and self-report its model.`);
291
302
  }
292
303
  // Else: skeleton Reviewer Verdict on a freshly created task file — Phase 4
293
304
  // appends the real verdict and it is validated there.
294
- } else {
305
+ } else if (!reviewOverride) {
295
306
  if (tierOf(reviewerModel) === 'vision') {
296
307
  block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
297
308
  }
@@ -151,6 +151,13 @@ process.stdin.on("end", () => {
151
151
  }
152
152
  })();
153
153
  const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
154
+ // TASK-008 fix round: the pressure writer keys records by payload.session_id,
155
+ // and hosts that omit session_id on prompt/tool events (omp) now send the
156
+ // transcript path as the session key. When session_start DOES carry a real id
157
+ // but the writers did not, the record lives under the transcript key — delete
158
+ // both so the targeted reset cannot silently miss. Transcript paths are
159
+ // unique per session, so this can never touch a sibling record.
160
+ const transcriptKey = typeof payload.transcript_path === "string" ? payload.transcript_path.trim() : "";
154
161
  const pressurePath = process.argv[1];
155
162
 
156
163
  withLock(`${pressurePath}.lock`, async () => {
@@ -179,6 +186,9 @@ process.stdin.on("end", () => {
179
186
 
180
187
  const sessions = { ...raw.sessions };
181
188
  delete sessions[sessionId];
189
+ if (transcriptKey && transcriptKey !== sessionId) {
190
+ delete sessions[transcriptKey];
191
+ }
182
192
  const newest = Object.entries(sessions)
183
193
  .sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
184
194
  const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -2523,6 +2523,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2523
2523
  if (!entry || typeof entry !== 'object') {
2524
2524
  return;
2525
2525
  }
2526
+ // The sandbox starts without .ukit/storage/cache. Create it before the
2527
+ // sidecar append and lock acquisition; otherwise both operations fail with
2528
+ // ENOENT and withStateLock falls through to an unlocked read-modify-write.
2529
+ await ensureDir(filePath);
2526
2530
 
2527
2531
  // BUG-C23-15: the entry is STAGED to an append-only sidecar before the lock is
2528
2532
  // attempted (small appendFile is atomic — no read-modify-write, so no lost
@@ -2605,14 +2609,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2605
2609
  if (!error || error.code !== 'EEXIST') return;
2606
2610
  try {
2607
2611
  const stat = fsSync.statSync(lockPath);
2608
- if (Date.now() - stat.mtimeMs > 10000) {
2609
- try {
2610
- const owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
2611
- if (!owner || !Number.isInteger(owner.pid) || !isPidAlive(owner.pid)) {
2612
- fsSync.rmSync(lockPath, { recursive: true, force: true });
2613
- continue;
2614
- }
2615
- } catch {
2612
+ let owner = null;
2613
+ try {
2614
+ owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
2615
+ } catch {}
2616
+ // The watchdog may call process.exit while this process is in the
2617
+ // async merge. Its finally block cannot run after that synchronous
2618
+ // exit, so the exit fold must reclaim its own abandoned lock before
2619
+ // folding the staged sidecar. A live foreign owner remains protected.
2620
+ const ownInterruptedLock = owner?.pid === process.pid;
2621
+ if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
2622
+ if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
2616
2623
  fsSync.rmSync(lockPath, { recursive: true, force: true });
2617
2624
  continue;
2618
2625
  }
@@ -215,6 +215,9 @@ function isVerificationCommand(command) {
215
215
  ].some((pattern) => pattern.test(command));
216
216
  }
217
217
 
218
+ // F-15: execution-ledger.mjs mirrors this list in its own
219
+ // isExplicitBroadVerificationRequest — the completion gate honors the same
220
+ // explicit-broad signal when classifying a scope='broad' receipt. Keep both in sync.
218
221
  function isExplicitBroadVerificationRequest(promptText) {
219
222
  const text = String(promptText || '').toLowerCase();
220
223
  return [