@ngockhoale/ukit 2.7.7 → 2.7.9

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 (35) hide show
  1. package/CHANGELOG.md +54 -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/diffPlan.js +60 -1
  6. package/src/core/fileOps.js +46 -119
  7. package/src/render/buildVariables.js +10 -0
  8. package/templates/.claude/agents/bug-debugger.md +1 -1
  9. package/templates/.claude/agents/feature-implementer.md +2 -2
  10. package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
  11. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  12. package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -1
  13. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  14. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  15. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  16. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  17. package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
  18. package/templates/.claude/hooks/skill-router.sh +15 -8
  19. package/templates/.claude/hooks/verification-guard.sh +3 -0
  20. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  21. package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
  22. package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
  23. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  24. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  25. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
  26. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +131 -0
  27. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  28. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  29. package/templates/.codex/settings.json +1 -5
  30. package/templates/.omp/agents/bug-debugger.md +1 -1
  31. package/templates/.omp/agents/feature-implementer.md +2 -2
  32. package/templates/.omp/hooks/pre/ukit-bridge.js +216 -51
  33. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  34. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  35. package/templates/ukit/storage/config.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+
6
+ ## 2.7.9 - 2026-09-22
7
+
8
+ - **User permission mode is never overwritten**: `ukit install`/`ukit update`
9
+ used to rewrite `.claude/settings.json` wholesale, so the template's
10
+ `permissions.defaultMode: "bypassPermissions"` silently clobbered a
11
+ user-chosen mode (e.g. `plan`) on every install. `defaultMode` is now
12
+ user-owned in `merge_env_overwrite_with_backup`: excluded from the drift
13
+ comparison and merged back into the written content. The template default
14
+ still applies on fresh installs or when the user never set a mode.
15
+ (`src/core/diffPlan.js`, +4 tests in `tests/core/diffPlan.test.js`)
16
+
17
+ ## 2.7.8 - 2026-09-22
18
+
19
+ Stall/hang fixes — cycle C43 (TASK-001..008): the indefinite-stall producers
20
+ root-caused in `docs/AI_REPORT/2026-09-21-NORMAL_CHAT_STALL.md` are fixed
21
+ across Claude Code, omp, and Codex runtimes.
22
+
23
+ - **CX-8**: route-state writes are atomic + locked — torn `skill-router-state.json`
24
+ writes can no longer wedge the router.
25
+ - **OMP-3/F-2**: omp receipt dedupe — a tool result is recorded once; duplicate
26
+ receipts no longer double-count execution records.
27
+ - **RC-4/OMP-1/F-3/F-4**: breaker-freeze family — budgeted reclaim, pstart
28
+ stamps, fail-open streaks; continuation sidecar merge gate stamps
29
+ `lastContinuationAt` strictly newer than its base and materializes counter
30
+ state when the main ledger file is absent.
31
+ - **F-5/F-5b**: `withFileLock` policy + reclaim — stale locks reclaimed via
32
+ pstart mismatch; recycled-pid regression tests added.
33
+ - **F-15**: verifier-classification — verification outcomes classified so a
34
+ non-verification failure no longer masquerades as a test failure.
35
+ - **F-RC-2**: `hook-chain-runner` exits promptly after verdict — bounded stdout
36
+ flush then `process.exit`; an abandoned in-proc step promise can no longer
37
+ pin the event loop for the full registered timeout.
38
+ - **F-10/OMP-2**: omp Stop parity — omp sessions get the same handoff-cursor
39
+ Stop-gate lane as Claude Code.
40
+ - **OMP-6/F-8/CX-10**: `createPayloadReference` staging runs under a deadline
41
+ with fail-open to inline payload (never throws, host loop not frozen); omp
42
+ SessionStart carries `session_id` so `reset-compact-pressure` no longer
43
+ wipes every session's pressure records; codex `requiredBeforeCompletion`
44
+ renders only commands that exist in `package.json`.
45
+ - Housekeeping: stale "Kilo" tool labels scrubbed from handoff docs, agent
46
+ report contracts, and config help text (functional support was already
47
+ removed; the regression tests and gateway comments documenting that removal
48
+ are intentionally kept).
49
+
5
50
  ## 2.7.7 - 2026-09-21
6
51
 
7
52
  Audit-report remediation + hook observability — cycle C42 (TASK-001..011): all 12
@@ -64,6 +109,15 @@ findings from the three 2026-09-20 reports closed; the reports moved to
64
109
  fixture whose outcome depended on whether the clock ticked mid-array, so it
65
110
  passed on an unloaded box and failed under load; the fixture is now
66
111
  deterministic and the case fails against the old code.
112
+ - `withFileLock` (both `templates/.claude/ukit/runtime/token-utils.mjs` and its
113
+ `src/core/fileOps.js` twin) is now fail-closed: a lock wait that exceeds
114
+ `maxWaitMs` skips the mutation instead of running it unlocked, and the drop is
115
+ journaled to `<file>.lock-drops.jsonl` (bounded 128-record JSONL). Both twins
116
+ delegate to `async-lock.mjs`, so owner stamping (pid + token + pstart),
117
+ recycled-pid detection, and claim+quarantine stale reclaim exist exactly once;
118
+ a stale lock whose recorded pid was recycled is now reclaimed instead of
119
+ wedging every mutation for that file. `writeJson`/`writeFileAtomic` fall back
120
+ to copy+unlink when `rename` fails with EXDEV (cross-mount tmp dirs).
67
121
 
68
122
  ## 2.7.6 - 2026-09-20
69
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.7",
3
+ "version": "2.7.9",
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;
@@ -34,6 +34,54 @@ async function checkLinkStatus(targetPath, linkTarget) {
34
34
  // template, so a template-side change to e.g. CLAUDE_CODE_AUTO_COMPACT_WINDOW still triggers
35
35
  // a real reinstall. Without this carve-out, the managed gateway keys written by the post-apply
36
36
  // step would cause a spurious "update" on every subsequent install.
37
+ //
38
+ // `permissions.defaultMode` is user-owned: the user picks their permission mode
39
+ // (bypassPermissions / plan / acceptEdits / default) and UKit must never change it.
40
+ // It is excluded from the drift comparison AND re-injected into the rendered content
41
+ // before write (mergeSettingsJson), so an install/update can never flip the user's mode.
42
+ // When the user has NOT set it, the template's shipped default applies on create/update.
43
+ const USER_OWNED_PERMISSION_KEYS = new Set(['defaultMode']);
44
+
45
+ function parseSettingsJson(content) {
46
+ if (typeof content !== 'string') return null;
47
+ try {
48
+ const parsed = JSON.parse(content);
49
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ // Strip user-owned permission keys from a parsed permissions object for comparison.
56
+ // Returns a new object; never mutates the input.
57
+ function stripUserOwnedPermissionKeys(permissions) {
58
+ if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
59
+ return permissions;
60
+ }
61
+ const stripped = { ...permissions };
62
+ for (const key of USER_OWNED_PERMISSION_KEYS) delete stripped[key];
63
+ return stripped;
64
+ }
65
+
66
+ // Merge rendered settings.json with the user's existing file: the rendered template
67
+ // wins every key EXCEPT user-owned permission keys (currently permissions.defaultMode),
68
+ // which keep the value the user set. Returns the merged JSON as a string, or the
69
+ // rendered content unchanged when either side is unparseable.
70
+ export function mergeSettingsJson(existingContent, renderedContent) {
71
+ const existing = parseSettingsJson(existingContent);
72
+ const rendered = parseSettingsJson(renderedContent);
73
+ if (!existing || !rendered) return renderedContent;
74
+ const merged = { ...rendered };
75
+ if (existing.permissions && typeof existing.permissions === 'object' && !Array.isArray(existing.permissions)) {
76
+ const mergedPermissions = { ...(rendered.permissions && typeof rendered.permissions === 'object' ? rendered.permissions : {}) };
77
+ for (const key of USER_OWNED_PERMISSION_KEYS) {
78
+ if (key in existing.permissions) mergedPermissions[key] = existing.permissions[key];
79
+ }
80
+ merged.permissions = mergedPermissions;
81
+ }
82
+ return `${JSON.stringify(merged, null, 2)}\n`;
83
+ }
84
+
37
85
  function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
38
86
  if (typeof existingContent !== 'string' || typeof renderedContent !== 'string') {
39
87
  return false;
@@ -56,7 +104,13 @@ function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
56
104
  for (const key of Object.keys(parsedRendered)) {
57
105
  if (key === 'env') continue;
58
106
  if (!(key in parsedExisting)) return false;
59
- if (JSON.stringify(parsedExisting[key]) !== JSON.stringify(parsedRendered[key])) {
107
+ const renderedValue = key === 'permissions'
108
+ ? stripUserOwnedPermissionKeys(parsedRendered[key])
109
+ : parsedRendered[key];
110
+ const existingValue = key === 'permissions'
111
+ ? stripUserOwnedPermissionKeys(parsedExisting[key])
112
+ : parsedExisting[key];
113
+ if (JSON.stringify(existingValue) !== JSON.stringify(renderedValue)) {
60
114
  return false;
61
115
  }
62
116
  }
@@ -128,7 +182,12 @@ function resolveFileAction(entry, existingContent) {
128
182
  // Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
129
183
  // and may carry user overrides). All other top-level keys — and the rest of the env
130
184
  // block — must still match the template, so genuine template updates still reinstall.
185
+ // User-owned permission keys (permissions.defaultMode) are excluded from the drift
186
+ // check AND merged back into the content that gets written, so install/update can
187
+ // never flip the user's chosen permission mode.
188
+ const merged = mergeSettingsJson(existingContent, entry.renderedContent);
131
189
  action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
190
+ return { ...entry, renderedContent: merged, exists, action, existingContent };
132
191
  } else if (existingContent === entry.renderedContent) {
133
192
  action = 'unchanged';
134
193
  } else if (entry.mergeStrategy === 'skip') {
@@ -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, withTransientFsRetry } 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);
@@ -99,12 +103,26 @@ export async function writeFileAtomic(filePath, content) {
99
103
  const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
100
104
 
101
105
  try {
106
+ // C44: temp write, rename, and the EXDEV copy+rm fallback each retry
107
+ // transient kernel errors (EAGAIN/EBUSY/EMFILE/ENFILE/ESTALE) — the same
108
+ // transient-open EAGAIN observed on external APFS volumes under metadata
109
+ // churn. Non-transient codes (EXDEV, EISDIR) throw immediately, unchanged.
102
110
  if (Buffer.isBuffer(content)) {
103
- await fs.writeFile(tempPath, content);
111
+ await withTransientFsRetry(() => fs.writeFile(tempPath, content));
104
112
  } else {
105
- await fs.writeFile(tempPath, content, 'utf8');
113
+ await withTransientFsRetry(() => fs.writeFile(tempPath, content, 'utf8'));
114
+ }
115
+ try {
116
+ await withTransientFsRetry(() => fs.rename(tempPath, filePath));
117
+ } catch (renameError) {
118
+ // EXDEV: the tmp file and the destination sit on different mounts (union
119
+ // mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
120
+ // The payload is already fully written — copy it over and unlink the tmp.
121
+ // Less atomic than rename, but the update must not be silently lost.
122
+ if (renameError?.code !== 'EXDEV') throw renameError;
123
+ await withTransientFsRetry(() => fs.copyFile(tempPath, filePath));
124
+ await withTransientFsRetry(() => fs.rm(tempPath, { force: true }));
106
125
  }
107
- await fs.rename(tempPath, filePath);
108
126
  } catch (error) {
109
127
  try {
110
128
  await fs.rm(tempPath, { force: true });
@@ -161,130 +179,39 @@ export async function writeJson(filePath, data) {
161
179
  const LOCK_STALE_MS = 10_000;
162
180
  const LOCK_MAX_WAIT_MS = 5_000;
163
181
 
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
182
  /**
198
183
  * Serialize read-modify-write mutations of a shared state file — across processes
199
184
  * (hook invocations run as separate node processes) and across concurrent async
200
185
  * flows in one process (parallel subagents). The lock is a directory created next
201
186
  * 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.
187
+ *
188
+ * The entire lock protocol lives in templates/.claude/ukit/runtime/async-lock.mjs
189
+ * (TASK-004 fix round 1): owner stamping (pid + token + pstart), recycled-pid
190
+ * detection via recordedProcessGone, claim+quarantine stale reclaim, and verified
191
+ * release exist exactly once there — this function and the token-utils.mjs twin
192
+ * both delegate to it so the two protocol copies can never drift apart again.
193
+ *
194
+ * FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
195
+ * be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
196
+ * the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
197
+ * advisory caches: losing an update was already the accepted outcome of the old
198
+ * fail-open race; now it is explicit and journaled instead of a silent torn write.
212
199
  * @param {string} filePath - state file the mutation targets (lock lives beside it)
213
200
  * @param {() => Promise<*>} fn - critical section; its result is returned
214
- * @returns {Promise<*>} whatever fn resolves with
201
+ * @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
202
+ * lock wait expired and the mutation was skipped (journaled)
215
203
  */
216
204
  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
- }
205
+ const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
206
+ if (outcome?.ok === true) return outcome.value;
207
+ // Fail closed: the mutation is dropped, never run unlocked. The drop is
208
+ // journaled so the lost update is auditable; callers treat undefined as
209
+ // "update skipped" (they already tolerated losing it silently).
210
+ await journalDroppedLockMutation(filePath, {
211
+ reason: 'lock-wait-expired',
212
+ waitedMs: outcome?.waitedMs ?? maxWaitMs,
213
+ });
214
+ return undefined;
288
215
  }
289
216
 
290
217
  /**
@@ -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:
@@ -42,6 +42,11 @@ Write `docs/AI_HANDOFF/archive/cycle-NNN.md` (if there is anything worth archivi
42
42
  ```
43
43
  If `archive/` has > 3 files → delete oldest, append 1-line summary to `HISTORY.md`.
44
44
 
45
+ After writing the archive file, **verify the write** — read it back and confirm
46
+ the `ABORTED` heading is present; on mismatch or read failure retry once, on
47
+ second failure stop and report (same rule as handoff-fullstack's "State-file
48
+ write verification").
49
+
45
50
  ## Step 4 — Reset state files
46
51
 
47
52
  ```
@@ -54,6 +59,12 @@ RUN.md → set `Phase: done` (or delete) — a live cursor that is neither `do
54
59
  tasks/TASK-*.md → delete all (keep _TEMPLATE.md)
55
60
  ```
56
61
 
62
+ Every file rewritten above is a state file — **verify the write** on each:
63
+ read it back and confirm the marker you just wrote (empty INDEX header,
64
+ `_(no active cycle)_`, `Phase: done`). On mismatch or read failure retry the
65
+ write once; on second failure STOP and report — never leave unverified state
66
+ files behind, or the next cycle resumes from stale bytes.
67
+
57
68
  ## Step 5 — Report
58
69
 
59
70
  ```
@@ -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
 
@@ -75,6 +75,23 @@ QuietScans: <n>/<required> # only while sweeping for stragglers near the end
75
75
  This file is the resume contract. It costs one small write per step and is what turns an
76
76
  interrupted run into a continuable one.
77
77
 
78
+ ### State-file write verification — "verify the write"
79
+
80
+ Every Write/Edit to a handoff state file — `INDEX.md`, `ACTIVE.md`, `RUN.md`,
81
+ `SPEC.md`, `PLAN.md`, `tasks/TASK-*.md` — can be silently lost to a transient
82
+ filesystem error (the Write tool's own `open()` has been observed to fail with
83
+ EAGAIN while the write is reported as done). After each such write:
84
+
85
+ 1. **Read it back** and confirm the marker you just wrote is present — the
86
+ `Status:` row, `Phase:`/`Cursor:` line, or section heading the write was
87
+ supposed to change.
88
+ 2. **On mismatch or read failure, retry the write once.**
89
+ 3. **On second failure, STOP the pipeline** and report the file plus the error —
90
+ never continue on unverified state. A stale `INDEX.md`/`RUN.md` is how a run
91
+ ships silent corruption.
92
+
93
+ Wherever a step below says **verify the write**, it means this rule.
94
+
78
95
  ### Resume — when the run is re-invoked mid-flight
79
96
 
80
97
  If `docs/AI_HANDOFF/RUN.md` exists with `Phase:` not `done` or `blocked`, this is a
@@ -240,6 +257,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
240
257
  6. **Update `docs/AI_HANDOFF/INDEX.md`** — one row per task, `status=ready`. Every
241
258
  unfinished item from the Phase 0 sweep is either a row here or superseded by a
242
259
  `-R<n>` recovery row.
260
+ Then **verify the write** (read back the new rows).
243
261
 
244
262
  7. **Update `docs/AI_HANDOFF/ACTIVE.md`:**
245
263
  ```
@@ -249,6 +267,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
249
267
  Tasks: <N> total
250
268
  Status: planning_done — ready for executor
251
269
  ```
270
+ Then **verify the write** (read back the `Status:` line).
252
271
 
253
272
  8. **Report:** task IDs, dependency graph, recovery/superseded tasks, any
254
273
  `needs_breakdown` tasks + reason.
@@ -444,6 +463,7 @@ already on disk (task files hold the full logs, git holds the code). Do all four
444
463
 
445
464
  2. **Update the run cursor** — rewrite `docs/AI_HANDOFF/RUN.md` with `Phase: I3`,
446
465
  `Cursor: wave <N> done`, `Next: wave <N+1>` (or `I4` if that was the last wave).
466
+ Then **verify the write** (read back the `Phase:`/`Cursor:` lines).
447
467
 
448
468
  3. **Collapse the wave in working memory.** From this point on, refer to the finished wave
449
469
  only by its one-line-per-task summary (`TASK-xxx PASS <files>`). Do not re-read the task
@@ -469,6 +489,7 @@ logs used to cost. That difference is what makes a multi-wave cycle finish in on
469
489
  - PASS tasks → `pending_review`
470
490
  - FAIL tasks → `blocked`
471
491
  - EXECUTOR_MODEL missing → `needs_executor_report`
492
+ Then **verify the write** (read back the updated status column).
472
493
 
473
494
  2. Verify cleanup:
474
495
  ```bash
@@ -647,6 +668,8 @@ conventions so a future session needs no transcript to understand the cycle:
647
668
  summary in `HISTORY.md` and remove it.
648
669
  3. Reset `ACTIVE.md` to its empty template; clear `INDEX.md` rows to a fresh header;
649
670
  remove `tasks/TASK-*.md`; clear `PLAN.md`/`SPEC.md` (templates stay).
671
+ Then **verify the write** on each reset file (read back the empty header /
672
+ template marker).
650
673
  4. Commit the docs + archive changes:
651
674
  `git add -A && git commit -m "handoff: finalize + archive cycle <ID>"`
652
675
 
@@ -654,6 +677,8 @@ conventions so a future session needs no transcript to understand the cycle:
654
677
 
655
678
  Set `Phase: done` in `docs/AI_HANDOFF/RUN.md`, disarm any watchdog job armed at the
656
679
  start, and emit the Final Report below.
680
+ Then **verify the write** (read back `Phase: done`) — a lost cursor write
681
+ leaves the next session resuming a finished run.
657
682
 
658
683
  ---
659
684
 
@@ -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
  }