@ngockhoale/ukit 2.7.6 → 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 (47) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +1 -1
  3. package/scripts/install/sync-installed-mirror.mjs +250 -0
  4. package/scripts/perf/audit-perf.mjs +287 -36
  5. package/scripts/perf/diff-perf-findings.mjs +136 -0
  6. package/scripts/perf/perf-findings.json +260 -206
  7. package/scripts/perf/perf-measure.md +271 -0
  8. package/src/context/detectProjectContext.js +5 -0
  9. package/src/core/codeintel/invalidation.js +4 -0
  10. package/src/core/fileOps.js +40 -117
  11. package/src/core/hookChainDoctor.js +65 -2
  12. package/src/core/memory/store.js +22 -1
  13. package/src/core/taskBudgetValidator.js +9 -6
  14. package/src/core/unattendedDoctor.js +8 -1
  15. package/src/render/buildVariables.js +10 -0
  16. package/templates/.claude/agents/bug-debugger.md +1 -1
  17. package/templates/.claude/agents/feature-implementer.md +2 -2
  18. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  19. package/templates/.claude/commands/ukit/handoff-fullstack.md +1 -1
  20. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  21. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  22. package/templates/.claude/hooks/auto-prune-bash.sh +19 -0
  23. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  24. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  25. package/templates/.claude/hooks/reinject-context.sh +22 -0
  26. package/templates/.claude/hooks/reset-compact-pressure.sh +29 -0
  27. package/templates/.claude/hooks/session-episode.sh +20 -0
  28. package/templates/.claude/hooks/skill-router.sh +15 -8
  29. package/templates/.claude/hooks/verification-guard.sh +3 -0
  30. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  31. package/templates/.claude/ukit/index/task-budget-validator.mjs +6 -2
  32. package/templates/.claude/ukit/runtime/async-lock.mjs +144 -10
  33. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  34. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  35. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +156 -24
  36. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +57 -0
  37. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +84 -12
  38. package/templates/.claude/ukit/runtime/hook-telemetry.sh +50 -0
  39. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  40. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  41. package/templates/.codex/settings.json +1 -5
  42. package/templates/.omp/agents/bug-debugger.md +1 -1
  43. package/templates/.omp/agents/feature-implementer.md +2 -2
  44. package/templates/.omp/hooks/pre/ukit-bridge.js +157 -26
  45. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  46. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  47. package/templates/ukit/storage/config.json +2 -2
@@ -16,6 +16,7 @@
16
16
  // Readers (hook-chain-runner.mjs) keep accepting the `@path` argv form unchanged.
17
17
 
18
18
  import fs from 'node:fs';
19
+ import fsp from 'node:fs/promises';
19
20
  import path from 'node:path';
20
21
  import crypto from 'node:crypto';
21
22
 
@@ -90,6 +91,62 @@ export function createPayloadReference(text, {
90
91
  }
91
92
  }
92
93
 
94
+ // createPayloadReferenceAsync(text, {maxBytes, deadlineMs, dir}) -> Promise<reference>
95
+ //
96
+ // TASK-008 (SPEC §S8, OMP-6): the sync variant's mkdirSync/writeFileSync/renameSync
97
+ // run deadline-free on the CALLER's event loop — on the omp host a stalled mount
98
+ // freezes the whole app, and the deadlineMs check at t≈0 is decorative because a
99
+ // stalled sync call never returns to re-check it. This variant does the same
100
+ // atomic staging through async fs raced against a real deadline; on expiry or
101
+ // any failure it degrades to inline transport exactly like the sync path.
102
+ // The losing fs promise is left to settle in the background — it is unref'd by
103
+ // the race and its result discarded; worst case it completes a tmp write that
104
+ // the sampled sweep later reclaims.
105
+ export async function createPayloadReferenceAsync(text, {
106
+ maxBytes = PAYLOAD_INLINE_MAX_BYTES,
107
+ deadlineMs = 250,
108
+ dir,
109
+ } = {}) {
110
+ const payloadText = String(text ?? '');
111
+ const bytes = Buffer.byteLength(payloadText, 'utf8');
112
+ if (bytes <= maxBytes) return inlineReference(payloadText, bytes);
113
+ if (!(deadlineMs > 0) || !dir) return inlineReference(payloadText, bytes);
114
+
115
+ const stage = (async () => {
116
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
117
+ const name = `${Date.now().toString(36)}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.json`;
118
+ const finalPath = path.join(dir, name);
119
+ const tmpPath = path.join(dir, `.${name}.tmp`);
120
+ await fsp.writeFile(tmpPath, payloadText, { encoding: 'utf8', mode: 0o600 });
121
+ await fsp.rename(tmpPath, finalPath);
122
+ return {
123
+ mode: 'file',
124
+ arg: `@${finalPath}`,
125
+ text: payloadText,
126
+ path: finalPath,
127
+ bytes,
128
+ cleanup() {
129
+ try { fs.rmSync(finalPath, { force: true }); } catch { /* best effort */ }
130
+ },
131
+ };
132
+ })();
133
+ // Fail-open contract (TASK-008 fix): the race must resolve to inline on ANY
134
+ // staging outcome that is not a completed file — deadline expiry AND fs
135
+ // rejection (ENOTDIR/EACCES/EROFS on a read-only or wedged mount). Racing the
136
+ // raw stage promise let a fast rejection propagate through runScriptChain and
137
+ // skip the whole hook chain; .catch(() => null) consumes it so the winner is
138
+ // always a reference or null. The same catch also swallows a rejection that
139
+ // lands after the deadline already won, so no unhandled rejection escapes.
140
+ let timer;
141
+ const deadline = new Promise((resolve) => {
142
+ timer = setTimeout(() => resolve(null), deadlineMs);
143
+ timer.unref?.();
144
+ });
145
+ const winner = await Promise.race([stage.catch(() => null), deadline]);
146
+ clearTimeout(timer);
147
+ return winner ?? inlineReference(payloadText, bytes);
148
+ }
149
+
93
150
  // probePayloadIntegrity(reference) -> null | 'missing' | 'partial'
94
151
  //
95
152
  // O(1): one stat of the file this bridge staged. Called by the bridge after the
@@ -69,7 +69,13 @@ function normalizeElapsedMs(value) {
69
69
  // Allowlist builder — the ONLY place row fields are chosen. `elapsedMs` may be
70
70
  // null when the caller genuinely could not measure it (e.g. the hook exited
71
71
  // before its stdin envelope was staged); unknown stays unknown, never zero.
72
+ //
73
+ // O3 (SPEC §FR-009): `stageMs` splits that elapsed window into the part the hook
74
+ // spent waiting on stdin staging and the part it spent executing. It is OPTIONAL
75
+ // — omitted entirely when unmeasurable — so a v1 reader's key set is unchanged
76
+ // for a row that has no stage data, and `TELEMETRY_VERSION` stays 1.
72
77
  export function buildTimingRow(entry = {}) {
78
+ const stageMs = normalizeElapsedMs(entry.stageMs);
73
79
  return {
74
80
  v: TELEMETRY_VERSION,
75
81
  ts: Number.isFinite(entry.ts) ? entry.ts : Date.now(),
@@ -79,6 +85,7 @@ export function buildTimingRow(entry = {}) {
79
85
  hook: shortString(entry.hook, 128),
80
86
  elapsedMs: normalizeElapsedMs(entry.elapsedMs),
81
87
  outcome: shortString(entry.outcome, 32) || 'ok',
88
+ ...(stageMs === null ? {} : { stageMs }),
82
89
  };
83
90
  }
84
91
 
@@ -242,13 +249,14 @@ export function recordHookTiming({
242
249
  tool,
243
250
  hook,
244
251
  elapsedMs,
252
+ stageMs,
245
253
  outcome,
246
254
  toolUseId,
247
255
  ts,
248
256
  projectRoot,
249
257
  maxBytes,
250
258
  } = {}) {
251
- const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome });
259
+ const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome, stageMs });
252
260
  const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
253
261
  return appendTelemetryRow(root, sessionId, row, { maxBytes });
254
262
  }
@@ -256,6 +264,13 @@ export function recordHookTiming({
256
264
  // --- `--finish` CLI: one short-lived node process per direct hook exit ------
257
265
  // Invoked by ukit_hook_telemetry_finish (hook-telemetry.sh) from the shared
258
266
  // cleanup path in hook-input.sh, while the staged payload file still exists.
267
+ //
268
+ // O1 (TASK-008): the same CLI serves hooks that deliberately never stage stdin
269
+ // (gate-first fast paths). Those have no payload mtime to measure from, so the
270
+ // arm helper hands over an explicit start plus the envelope's identifying
271
+ // fields: UKIT_TEL_START_MS (epoch ms), UKIT_TEL_SESSION_ID, UKIT_TEL_EVENT.
272
+ // Every override is optional and falsy-safe, so the 18 staging hooks that set
273
+ // none of them keep byte-for-byte today's behaviour.
259
274
 
260
275
  const FINISH_PARSE_LIMIT = 2 * 1024 * 1024; // all hooks except record-execution stage <= 2 MiB
261
276
  const FINISH_HEAD_BYTES = 256 * 1024; // bounded head scan for oversized payloads
@@ -318,32 +333,89 @@ function readEnvelope(inputFile) {
318
333
  }
319
334
  }
320
335
 
336
+ // Explicit arm start. Non-finite, zero, or negative values are ignored: a
337
+ // malformed marker must degrade to the staged-payload mtime (or to the honest
338
+ // null), never to a bogus "0ms" measurement.
339
+ function explicitStartMs(raw) {
340
+ const parsed = Number.parseInt(String(raw ?? ''), 10);
341
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
342
+ return parsed;
343
+ }
344
+
345
+ // Start reference, most explicit first: the arm helper's epoch-ms reading, the
346
+ // arm marker's mtime (the shell could not express sub-second precision on this
347
+ // host), then the staged payload's mtime. None present stays null — an armed
348
+ // hook that lost its marker must not be reported as a zero-millisecond hook.
349
+ //
350
+ // O3 (SPEC §FR-009) needs the two ends SEPARATELY, not just their difference:
351
+ // when the payload's own mtime is the start, `elapsedMs` already begins where
352
+ // staging ended, so `source` is what tells the stage split that no window is
353
+ // left to measure.
354
+ function resolveStartRef() {
355
+ const explicit = explicitStartMs(process.env.UKIT_TEL_START_MS);
356
+ if (explicit !== null) return { startMs: explicit, source: 'explicit' };
357
+ const marker = process.env.UKIT_TEL_START_FILE || '';
358
+ if (marker) {
359
+ try {
360
+ return { startMs: fs.statSync(marker).mtimeMs, source: 'marker' };
361
+ } catch {
362
+ // Marker already reaped — fall through to the staged payload.
363
+ }
364
+ }
365
+ const inputFile = process.env.UKIT_INPUT_FILE || '';
366
+ if (!inputFile) return { startMs: null, source: null };
367
+ try {
368
+ return { startMs: fs.statSync(inputFile).mtimeMs, source: 'payload' };
369
+ } catch {
370
+ return { startMs: null, source: null }; // never staged (refuse path) — unknown, not zero
371
+ }
372
+ }
373
+
374
+ // O3 (SPEC §FR-009): the stage window = the staged payload's mtime minus the arm
375
+ // start, and only the two ARM sources qualify. A hook that deliberately skips
376
+ // stdin staging (gate-first fast paths, SPEC §14) has no payload at all, and an
377
+ // unarmed hook's start IS the payload mtime — reporting a stage from either
378
+ // would invent a window that never existed, so unmeasurable stays ABSENT.
379
+ // Clamped to the row's own elapsed window: the split attributes time inside that
380
+ // whole, it never exceeds it.
381
+ function resolveStageMs(startRef, elapsedMs) {
382
+ if (startRef.source !== 'explicit' && startRef.source !== 'marker') return null;
383
+ const inputFile = process.env.UKIT_INPUT_FILE || '';
384
+ if (!inputFile) return null;
385
+ let stagedAt;
386
+ try {
387
+ stagedAt = fs.statSync(inputFile).mtimeMs;
388
+ } catch {
389
+ return null;
390
+ }
391
+ const raw = stagedAt - startRef.startMs;
392
+ if (!Number.isFinite(raw) || raw < 0) return null;
393
+ return Math.min(raw, normalizeElapsedMs(elapsedMs) ?? raw);
394
+ }
395
+
321
396
  function finishMain() {
322
397
  try {
323
398
  // process.uptime() covers this spawn itself so the row measures the hook,
324
399
  // not the telemetry process startup.
325
400
  const spawnOverheadMs = process.uptime() * 1000;
326
401
  const inputFile = process.env.UKIT_INPUT_FILE || '';
327
- let startMs = null;
328
- if (inputFile) {
329
- try {
330
- startMs = fs.statSync(inputFile).mtimeMs;
331
- } catch {
332
- startMs = null; // envelope never staged (refuse path) — unknown, not zero
333
- }
334
- }
402
+ const startRef = resolveStartRef();
403
+ const elapsedMs = startRef.startMs === null
404
+ ? null
405
+ : Date.now() - startRef.startMs - spawnOverheadMs;
335
406
  const envelope = inputFile ? readEnvelope(inputFile) : {};
336
407
  const rc = Number.parseInt(process.env.UKIT_TEL_RC ?? '', 10);
337
408
  // The wrapper resolves PROJECT_ROOT before arming; fall back to the hook
338
409
  // env's CLAUDE_PROJECT_DIR, then cwd (same resolution as the wrappers).
339
410
  recordHookTiming({
340
411
  projectRoot: process.env.PROJECT_ROOT || process.env.CLAUDE_PROJECT_DIR || undefined,
341
- sessionId: envelope.session_id,
342
- event: envelope.hook_event_name,
412
+ sessionId: process.env.UKIT_TEL_SESSION_ID || envelope.session_id,
413
+ event: process.env.UKIT_TEL_EVENT || envelope.hook_event_name,
343
414
  tool: envelope.tool_name,
344
415
  toolUseId: envelope.tool_use_id,
345
416
  hook: process.env.UKIT_TEL_HOOK,
346
- elapsedMs: startMs === null ? null : Date.now() - startMs - spawnOverheadMs,
417
+ elapsedMs,
418
+ stageMs: resolveStageMs(startRef, elapsedMs),
347
419
  // Direct rows reuse the chain taxonomy (TASK-018): a non-zero exit is a
348
420
  // verdict ('exit-code'), not a guess. Timeout/overflow kills bypass the
349
421
  // EXIT trap, so those kinds stay chain-runner-reported.
@@ -11,12 +11,54 @@
11
11
  # payload. Finish spawns at most one bounded node process, discards all output,
12
12
  # and never changes the hook's exit status or verdict.
13
13
  #
14
+ # O1 (TASK-008): hooks that never stage stdin (the gate-first fast paths wired
15
+ # in TASK-009) have no payload mtime to measure from. They call
16
+ # ukit_hook_telemetry_arm instead, which creates the same kind of start marker
17
+ # without adding a node spawn to the hot path — and trap
18
+ # ukit_hook_telemetry_finish on EXIT themselves.
19
+ #
14
20
  # Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
15
21
  # source this file with `|| true` and the armed flag stays unset.
16
22
 
17
23
  UKIT_TEL_ARMED=1
18
24
  UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
19
25
  UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ UKIT_TEL_START_FILE=""
27
+ UKIT_TEL_START_MS=""
28
+
29
+ # Epoch milliseconds from a file's mtime, or empty when this host's stat cannot
30
+ # express sub-second precision. GNU coreutils is probed first: it rejects the
31
+ # BSD format string outright, while BSD stat would read a GNU format string as
32
+ # a list of directives and print something plausible-but-wrong.
33
+ ukit_tel_file_ms() {
34
+ local raw seconds frac
35
+ raw="$(/usr/bin/stat -c '%.3Y' "$1" 2>/dev/null)" || raw=""
36
+ case "$raw" in
37
+ ''|*[!0-9.]*) raw="$(/usr/bin/stat -f '%Fm' "$1" 2>/dev/null)" || raw="" ;;
38
+ esac
39
+ case "$raw" in
40
+ *.*) seconds="${raw%%.*}"; frac="${raw#*.}" ;;
41
+ *) seconds="$raw"; frac="" ;;
42
+ esac
43
+ # Unknown stays unknown: an unparseable mtime yields empty, never a bogus 0.
44
+ case "$seconds" in
45
+ ''|*[!0-9]*) return 0 ;;
46
+ esac
47
+ frac="${frac}000"
48
+ printf '%s' "$(( seconds * 1000 + 10#${frac:0:3} ))"
49
+ }
50
+
51
+ # No-staging arm path. A marker file whose mtime is the start reference costs
52
+ # one mktemp; a `node -e` clock read would cost ~30ms to measure milliseconds.
53
+ # Always returns 0 — an unarmed hook must behave exactly like an unmeasured one.
54
+ ukit_hook_telemetry_arm() {
55
+ UKIT_TEL_START_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-tel-start.XXXXXX" 2>/dev/null)" || {
56
+ UKIT_TEL_START_FILE=""
57
+ return 0
58
+ }
59
+ UKIT_TEL_START_MS="$(ukit_tel_file_ms "$UKIT_TEL_START_FILE")"
60
+ return 0
61
+ }
20
62
 
21
63
  ukit_hook_telemetry_finish() {
22
64
  # The hook's exit status arrives via UKIT_TEL_RC (ukit_cleanup_hook_input
@@ -44,6 +86,8 @@ ukit_hook_telemetry_finish() {
44
86
 
45
87
  UKIT_TEL_RC="$rc" \
46
88
  UKIT_TEL_HOOK="$UKIT_TEL_HOOK" \
89
+ UKIT_TEL_START_MS="${UKIT_TEL_START_MS:-}" \
90
+ UKIT_TEL_START_FILE="${UKIT_TEL_START_FILE:-}" \
47
91
  UKIT_INPUT_FILE="${UKIT_INPUT_FILE:-}" \
48
92
  PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-}}" \
49
93
  node "$UKIT_TEL_RUNTIME_DIR/hook-telemetry.mjs" --finish </dev/null >/dev/null 2>&1 &
@@ -56,5 +100,11 @@ ukit_hook_telemetry_finish() {
56
100
  if wait "$child" 2>/dev/null; then :; fi
57
101
  kill "$watchdog" 2>/dev/null || true
58
102
  if wait "$watchdog" 2>/dev/null; then :; fi
103
+ # The marker existed only to be stat'd by the child above; dropping it here
104
+ # keeps a long session from accumulating one temp file per hooked event.
105
+ if [ -n "${UKIT_TEL_START_FILE:-}" ]; then
106
+ rm -f "$UKIT_TEL_START_FILE" 2>/dev/null || true
107
+ UKIT_TEL_START_FILE=""
108
+ fi
59
109
  return 0
60
110
  }
@@ -168,7 +168,7 @@ export const DEDUPE_WINDOW_MS = 2000;
168
168
  // Fixed budget slices inside the default 3000ms hook deadline: the ledger child
169
169
  // gets the larger slice, each watchdog lock acquisition gets a short one.
170
170
  const LEDGER_BUDGET_MS = 1500;
171
- const LOCK_BUDGET_MS = 600;
171
+ export const LOCK_BUDGET_MS = 600;
172
172
 
173
173
  const WATCHDOG_PROVENANCE_PREFIX = '[ukit-stop-coordinator] task-watchdog also requested a block: ';
174
174
  const WATCHDOG_FAILURE_PREFIX = '[ukit-stop-coordinator] task-watchdog evaluator failed (advisory lane, reported verbatim): ';
@@ -453,28 +453,43 @@ function parseRunCursor(text) {
453
453
  /**
454
454
  * Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
455
455
  * { signature, count }. Same signature as last time → count+1; a moved cursor
456
- * resets the streak. Returns the committed streak count; a lock-timeout or any
457
- * I/O failure returns null (fail-open — never decides the stop).
456
+ * resets the streak. Returns the committed streak count.
457
+ *
458
+ * RC-4: a lock-busy acquisition used to return null, and the release branch
459
+ * required `streak !== null` — so a leaked state lock froze the
460
+ * stopGateMaxStalledBlocks breaker forever and the handoff-cursor lane became an
461
+ * unbounded bounce loop. The breaker must ALWAYS advance: on lock-busy (or any
462
+ * lock error) the same read-modify-write runs best-effort unlocked. A lost tick
463
+ * under a racing writer only delays the cap by one stop; a frozen breaker loops
464
+ * forever. Only a genuinely unwritable state file still returns null.
458
465
  */
459
466
  async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
460
467
  const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
468
+ const bump = async () => {
469
+ let current = {};
470
+ try {
471
+ current = JSON.parse(await fs.readFile(statePath, 'utf8')) || {};
472
+ } catch {}
473
+ const prev = current.handoff || {};
474
+ const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
475
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
476
+ await fs.writeFile(
477
+ statePath,
478
+ `${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
479
+ 'utf8',
480
+ );
481
+ return count;
482
+ };
461
483
  try {
462
- const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
463
- let current = {};
464
- try {
465
- current = JSON.parse(await fs.readFile(statePath, 'utf8')) || {};
466
- } catch {}
467
- const prev = current.handoff || {};
468
- const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
469
- await fs.mkdir(path.dirname(statePath), { recursive: true });
470
- await fs.writeFile(
471
- statePath,
472
- `${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
473
- 'utf8',
474
- );
475
- return count;
476
- });
477
- return outcome.ok ? outcome.value : null;
484
+ const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, bump);
485
+ if (outcome.ok) return outcome.value;
486
+ } catch {
487
+ // fall through to the best-effort tick below
488
+ }
489
+ try {
490
+ // Lock-busy: count the stop as a stall tick anyway so the liveness breaker
491
+ // keeps advancing behind a wedged or leaked lock.
492
+ return await bump();
478
493
  } catch {
479
494
  return null;
480
495
  }
@@ -547,7 +562,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
547
562
  * invocation is a same-session duplicate inside the window. Fail-open — an
548
563
  * unreadable/unlockable dedupe state must never disable the Stop coordinator.
549
564
  */
550
- async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
565
+ export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
551
566
  const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
552
567
  try {
553
568
  const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
@@ -3,6 +3,8 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
+ import { journalDroppedLockMutation, withAsyncLock } from './async-lock.mjs';
7
+
6
8
  export const DEFAULT_PROMPT_CACHE_MAX_ENTRIES = 20;
7
9
  export const DEFAULT_COMPACT_HISTORY_MAX_ENTRIES = 50;
8
10
  const DEFAULT_MAX_COMPACT_ANCHORS = 3;
@@ -60,7 +62,17 @@ export async function writeJson(filePath, value) {
60
62
  const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
61
63
  try {
62
64
  await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
63
- await fs.rename(tempPath, filePath);
65
+ try {
66
+ await fs.rename(tempPath, filePath);
67
+ } catch (renameError) {
68
+ // EXDEV: the tmp file and the destination sit on different mounts (union
69
+ // mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
70
+ // The payload is already fully written — copy it over and unlink the tmp.
71
+ // Less atomic than rename, but the update must not be silently lost.
72
+ if (renameError?.code !== 'EXDEV') throw renameError;
73
+ await fs.copyFile(tempPath, filePath);
74
+ await fs.rm(tempPath, { force: true });
75
+ }
64
76
  } catch (error) {
65
77
  try {
66
78
  await fs.rm(tempPath, { force: true });
@@ -74,140 +86,39 @@ export async function writeJson(filePath, value) {
74
86
  const LOCK_STALE_MS = 10_000;
75
87
  const LOCK_MAX_WAIT_MS = 5_000;
76
88
 
77
- function lockBackoffDelayMs() {
78
- return 3 + Math.floor(Math.random() * 9);
79
- }
80
-
81
- function sleep(ms) {
82
- return new Promise((resolve) => setTimeout(resolve, ms));
83
- }
84
-
85
- function isPidAlive(pid) {
86
- try {
87
- process.kill(pid, 0);
88
- return true;
89
- } catch (error) {
90
- // EPERM: the process exists but belongs to another user — still alive.
91
- return error?.code === 'EPERM';
92
- }
93
- }
94
-
95
- async function readLockOwner(lockPath) {
96
- try {
97
- const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
98
- const pid = Number(raw?.pid);
99
- return Number.isInteger(pid) && pid > 0
100
- ? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
101
- : null;
102
- } catch {
103
- return null;
104
- }
105
- }
106
-
107
- // In-process holder registry: same-pid holders are parallel async flows whose liveness a
108
- // pid probe cannot prove, so the module tracks them itself.
109
- const inProcessLockHolders = new Map();
110
-
111
89
  /**
112
90
  * Serialize read-modify-write mutations of a shared state file — across processes
113
91
  * (hook invocations run as separate node processes) and across concurrent async
114
92
  * flows in one process (parallel subagents). The lock is a directory created next
115
93
  * to the target file: `mkdir` is atomic, so exactly one caller can create it.
116
- * Ownership is recorded in an `owner` file inside the lock dir: stale reclaim first
117
- * proves the recorded holder is gone (dead pid, or no in-process holder for our own
118
- * pid — no owner file means a pre-token holder and keeps the legacy mtime-only
119
- * reclaim), so a slow-but-alive holder on a crawling disk is waited out, not stolen.
120
- * Release only removes a dir this acquisition still owns, so a reclaimed-then-
121
- * re-acquired lock is never deleted out from under its successor.
122
- * Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
123
- * the callback runs anyway (the pre-lock behaviour) — these state files are
124
- * advisory caches, and losing an update beats freezing a hook mid-flight.
125
- * Protocol-compatible with src/core/fileOps.js withFileLock (same `<file>.lock`
126
- * path and owner-file format), so CLI processes and hook processes serialize
127
- * against each other.
94
+ *
95
+ * The entire lock protocol lives in async-lock.mjs (TASK-004 fix round 1): owner
96
+ * stamping (pid + token + pstart), recycled-pid detection via recordedProcessGone,
97
+ * claim+quarantine stale reclaim, and verified release exist exactly once there —
98
+ * this function and the src/core/fileOps.js twin both delegate to it so the two
99
+ * protocol copies can never drift apart again.
100
+ *
101
+ * FAIL-CLOSED (TASK-004, unified with async-lock/ledger policy): if the lock cannot
102
+ * be acquired within maxWaitMs the callback is SKIPPED — never run unlocked — and
103
+ * the drop is journaled to `<file>.lock-drops.jsonl`. These state files are
104
+ * advisory caches: losing an update was already the accepted outcome of the old
105
+ * fail-open race; now it is explicit and journaled instead of a silent torn write.
128
106
  * @param {string} filePath - state file the mutation targets (lock lives beside it)
129
107
  * @param {() => Promise<*>} fn - critical section; its result is returned
130
- * @returns {Promise<*>} whatever fn resolves with
108
+ * @returns {Promise<*|undefined>} whatever fn resolves with, or undefined when the
109
+ * lock wait expired and the mutation was skipped (journaled)
131
110
  */
132
111
  export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
133
- const lockPath = `${filePath}.lock`;
134
- const startedAt = Date.now();
135
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
136
- let locked = false;
137
- let ownerStamped = false;
138
-
139
- while (!locked) {
140
- try {
141
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
142
- await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
143
- locked = true;
144
- inProcessLockHolders.set(lockPath, ownerToken);
145
- try {
146
- await fs.writeFile(
147
- path.join(lockPath, 'owner'),
148
- `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
149
- 'utf8',
150
- );
151
- ownerStamped = true;
152
- } catch {
153
- ownerStamped = false; // unverifiable release skips removal; stale reclaim cleans up
154
- }
155
- break;
156
- } catch (error) {
157
- if (error?.code !== 'EEXIST') throw error;
158
- }
159
-
160
- // Someone holds the lock. Reclaim it only when the holder is provably gone.
161
- try {
162
- const stat = await fs.stat(lockPath);
163
- if (Date.now() - stat.mtimeMs > staleMs) {
164
- const owner = await readLockOwner(lockPath);
165
- const liveInProcess = inProcessLockHolders.has(lockPath);
166
- // Stealing a live holder reintroduces the exact interleaved-write race this
167
- // lock exists to prevent, and the stolen holder's release then deleted the
168
- // successor's lock. Only a dead pid (or a leaked same-pid dir with no live
169
- // registered flow) may be reclaimed.
170
- const reclaimable = !owner || owner.pid === process.pid
171
- ? !liveInProcess
172
- : !isPidAlive(owner.pid);
173
- if (reclaimable) {
174
- await fs.rm(lockPath, { recursive: true, force: true });
175
- continue; // the slot is free now — retry immediately
176
- }
177
- }
178
- } catch (statError) {
179
- // BUG-C22-16: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
180
- // mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
181
- // EEXIST) must not busy-spin — a bare `continue` skipped both the
182
- // maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
183
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
184
- if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
185
- await sleep(lockBackoffDelayMs());
186
- continue;
187
- }
188
-
189
- if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
190
- await sleep(lockBackoffDelayMs());
191
- }
192
-
193
- try {
194
- return await fn();
195
- } finally {
196
- if (locked) {
197
- try {
198
- // Remove the lock only if THIS acquisition still owns it: after a stale reclaim
199
- // another holder may already own the dir, and deleting it would unlock their
200
- // critical section for a third waiter.
201
- const current = ownerStamped ? await readLockOwner(lockPath) : null;
202
- if (current && current.token === ownerToken) {
203
- await fs.rm(lockPath, { recursive: true, force: true });
204
- }
205
- if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
206
- } catch {
207
- // best-effort release; a stale lock is reclaimed by the next waiter
208
- }
209
- }
210
- }
112
+ const outcome = await withAsyncLock(filePath, { deadlineMs: maxWaitMs, staleMs }, fn);
113
+ if (outcome?.ok === true) return outcome.value;
114
+ // Fail closed: the mutation is dropped, never run unlocked. The drop is
115
+ // journaled so the lost update is auditable; callers treat undefined as
116
+ // "update skipped" (they already tolerated losing it silently).
117
+ await journalDroppedLockMutation(filePath, {
118
+ reason: 'lock-wait-expired',
119
+ waitedMs: outcome?.waitedMs ?? maxWaitMs,
120
+ });
121
+ return undefined;
211
122
  }
212
123
 
213
124
  export function buildCompactMachineKey(prefix, payload = {}) {
@@ -358,10 +358,6 @@
358
358
  "non-trivial": "full test + lint + typecheck"
359
359
  },
360
360
  "verification": {
361
- "requiredBeforeCompletion": [
362
- "{{runtime.packageManager}} test",
363
- "{{runtime.packageManager}} lint",
364
- "{{runtime.packageManager}} typecheck"
365
- ]
361
+ "requiredBeforeCompletion": {{verification.requiredBeforeCompletion}}
366
362
  }
367
363
  }
@@ -49,7 +49,7 @@ Systematic debugging — understand before fixing.
49
49
 
50
50
  ```
51
51
  STATUS: DONE | BLOCKED | PARTIAL
52
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
52
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
53
53
  EXECUTOR_MODEL: [exact model name you are running as. "unknown" if you cannot tell.]
54
54
  EXECUTOR_SUBAGENT: [subagent name within your host, if any, else "-"]
55
55
  SUMMARY: [1-2 sentences — root cause and fix]
@@ -71,9 +71,9 @@ and even then, report it, don't ask about it.
71
71
 
72
72
  ```
73
73
  STATUS: DONE | BLOCKED | PARTIAL
74
- EXECUTOR_TOOL: [claude-code | kilo-code | codex | other]
74
+ EXECUTOR_TOOL: [claude-code | codex | omp | other]
75
75
  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.]
76
- EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Kilo:code", "Claude:feature-implementer". Otherwise "-".]
76
+ EXECUTOR_SUBAGENT: [name of the subagent you are, if your host has multiple — e.g. "Claude:feature-implementer", "omp:task". Otherwise "-".]
77
77
  SUMMARY: [1-2 sentences of what was implemented]
78
78
  TEST_PLAN_FOLLOWED: [task §4 / inline / N/A — reason]
79
79
  FILES_CHANGED: