@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
@@ -294,7 +294,8 @@ export async function readRouteState(projectRoot, payload = {}) {
294
294
  }
295
295
 
296
296
  export async function readExecutionLedger(projectRoot, payload = {}) {
297
- return readJson(ledgerPath(projectRoot, payload), null);
297
+ const target = ledgerPath(projectRoot, payload);
298
+ return mergeContinuationSidecar(await readJson(target, null), target);
298
299
  }
299
300
 
300
301
  function promptKeyFromText(promptText) {
@@ -313,7 +314,7 @@ function hasUnfinishedCompletion(state = {}, ledger = {}) {
313
314
  if (!IMPLEMENT_MODES.has(mode)) return false;
314
315
  const required = requiredEvidence(state);
315
316
  if (required.length === 0) return false;
316
- return required.some((item) => !evidenceSatisfied(item, ledger, state?.routeSummary || {}));
317
+ return required.some((item) => !evidenceSatisfied(item, ledger, state));
317
318
  }
318
319
 
319
320
  function resumeSessionHash(sessionId) {
@@ -400,9 +401,38 @@ function explicitError(payload = {}) {
400
401
  payload.tool_result?.is_error,
401
402
  payload.tool_response?.isError,
402
403
  payload.tool_response?.is_error,
404
+ // omp tool details carry their own error flag (eval cells, bash timeouts).
405
+ payload.details?.isError,
406
+ payload.tool_output?.details?.isError,
407
+ payload.tool_result?.details?.isError,
403
408
  ].some((value) => value === true);
404
409
  }
405
410
 
411
+ // omp tool results do not all spell the exit code the same way: bash details
412
+ // carry `exitCode` (non-zero only), other producers serialize `exit_code`, and
413
+ // eval results report per-cell codes under `details.cells[].exitCode`. A missed
414
+ // shape used to journal a failed verification as exitCode null → success, so
415
+ // evidence never satisfied and the Stop gate bounced to the cap.
416
+ function detailsExitCode(details) {
417
+ if (!details || typeof details !== 'object') return null;
418
+ for (const value of [details.exitCode, details.exit_code]) {
419
+ // Number(null) === 0 — a JSON `"exitCode": null` must fall through to the
420
+ // next probe (exit_code, then cells[]), never journal as a successful exit.
421
+ if (value === undefined || value === null) continue;
422
+ const number = Number(value);
423
+ if (Number.isFinite(number)) return number;
424
+ }
425
+ const cells = Array.isArray(details.cells) ? details.cells : [];
426
+ let lastCode = null;
427
+ for (const cell of cells) {
428
+ const number = Number(cell?.exitCode ?? cell?.exit_code);
429
+ if (!Number.isFinite(number)) continue;
430
+ if (number !== 0) return number;
431
+ lastCode = number;
432
+ }
433
+ return lastCode;
434
+ }
435
+
406
436
  function extractExitCode(payload = {}) {
407
437
  const candidates = [
408
438
  payload.tool_output?.exitCode,
@@ -415,8 +445,20 @@ function extractExitCode(payload = {}) {
415
445
  payload.tool_response?.exit_code,
416
446
  payload.exitCode,
417
447
  payload.exit_code,
448
+ // omp details shapes: the bridge mirrors event.details under BOTH
449
+ // tool_output.details and tool_result.details, and eval results nest the
450
+ // real code under details.cells[].
451
+ detailsExitCode(payload.tool_output?.details),
452
+ detailsExitCode(payload.tool_result?.details),
453
+ detailsExitCode(payload.details),
454
+ detailsExitCode(payload.tool_response?.details),
455
+ detailsExitCode(payload.tool_output),
456
+ detailsExitCode(payload.tool_result),
418
457
  ];
419
458
  for (const value of candidates) {
459
+ // Number(null) === 0 — a JSON `"exitCode": null` (or a details probe that
460
+ // found nothing) must read as "no code", never as a successful exit.
461
+ if (value === undefined || value === null) continue;
420
462
  const number = Number(value);
421
463
  if (Number.isFinite(number)) return number;
422
464
  }
@@ -801,6 +843,96 @@ function journalQuarantinePathFor(target) {
801
843
  return `${journalPathFor(target)}.quarantine`;
802
844
  }
803
845
 
846
+ // F-4: the continuation counter is STATE, not a journal row. When the journal is
847
+ // full (or its lock unavailable) a continuation/notified event used to be rejected
848
+ // outright, so MAX_CONTINUATIONS fired several bounces late — or never while the
849
+ // ledger lock stayed contended. Rejected counter events land in this small sidecar
850
+ // instead; readExecutionLedger merges it into the view every evaluator sees, and
851
+ // the next committed ledger write folds it in and removes it.
852
+ function continuationSidecarPathFor(target) {
853
+ return `${target}.continuations.json`;
854
+ }
855
+
856
+ // Overlay sidecar counter state onto the ledger when the sidecar is newer than the
857
+ // ledger's last continuation. A sidecar at or behind the ledger is already folded
858
+ // (or written for a superseded request) and is ignored — never double-counted.
859
+ // When the main file is absent the sidecar IS the counter state: returning null
860
+ // hid every tick from evaluators and the next committed write deleted the sidecar
861
+ // unread, so the cap fired late. A sidecar without counter state still yields null
862
+ // — the no-ledger contract is preserved.
863
+ async function mergeContinuationSidecar(ledger, target) {
864
+ let sidecar = null;
865
+ try {
866
+ sidecar = JSON.parse(await fs.readFile(continuationSidecarPathFor(target), 'utf8'));
867
+ } catch {
868
+ return ledger;
869
+ }
870
+ if (!sidecar || typeof sidecar !== 'object') return ledger;
871
+ const sidecarAt = Number(sidecar.lastContinuationAt) || 0;
872
+ if (!ledger) {
873
+ const hasState = sidecarAt > 0
874
+ || Number(sidecar.continuationCount) > 0
875
+ || sidecar.notified === true;
876
+ if (!hasState) return null;
877
+ return {
878
+ requestKey: sidecar.requestKey ?? null,
879
+ promptKey: sidecar.promptKey ?? null,
880
+ continuationRequestKey: sidecar.requestKey ?? null,
881
+ continuationCount: Number(sidecar.continuationCount) || 0,
882
+ noProgressCount: Number(sidecar.noProgressCount) || 0,
883
+ lastProgressDigest: sidecar.lastProgressDigest ?? null,
884
+ notified: sidecar.notified === true,
885
+ lastContinuationAt: sidecarAt,
886
+ updatedAt: Number(sidecar.updatedAt) || sidecarAt,
887
+ };
888
+ }
889
+ const ledgerAt = Number(ledger.lastContinuationAt) || 0;
890
+ if (!(sidecarAt > ledgerAt)) return ledger;
891
+ return {
892
+ ...ledger,
893
+ continuationCount: Number(sidecar.continuationCount) || 0,
894
+ noProgressCount: Number(sidecar.noProgressCount) || 0,
895
+ continuationRequestKey: sidecar.requestKey ?? ledger.continuationRequestKey ?? null,
896
+ lastProgressDigest: sidecar.lastProgressDigest ?? ledger.lastProgressDigest ?? null,
897
+ notified: ledger.notified === true || sidecar.notified === true,
898
+ lastContinuationAt: sidecarAt,
899
+ updatedAt: Math.max(Number(ledger.updatedAt) || 0, sidecarAt),
900
+ };
901
+ }
902
+
903
+ // Best-effort counter tick for a continuation/notified event the journal rejected.
904
+ // Reads the merged ledger view (main file + existing sidecar) so consecutive ticks
905
+ // accumulate even while the main lock stays busy. Returns { ok } — a sidecar write
906
+ // failure means the tick is genuinely lost and the caller reports the rejection.
907
+ async function bumpContinuationSidecar(target, event) {
908
+ try {
909
+ const base = (await mergeContinuationSidecar(await readJson(target, null), target)) || {};
910
+ const applied = event.type === 'notified'
911
+ ? { ...base, notified: true, updatedAt: Date.now() }
912
+ : applyContinuationToLedger(base, event);
913
+ // The merge gate requires sidecarAt > ledgerAt. A notified tick inherits the
914
+ // base stamp unchanged, so it must be bumped past the base it was computed
915
+ // from — otherwise the tick is invisible to every evaluator and the cap's
916
+ // final notice loops forever while contention persists. The +1 floor also
917
+ // covers a continuation landing in the same millisecond as the base stamp.
918
+ const baseAt = Number(base.lastContinuationAt) || 0;
919
+ const stampedAt = Math.max(Number(applied.lastContinuationAt) || 0, baseAt + 1);
920
+ await writeJsonAtomic(continuationSidecarPathFor(target), {
921
+ requestKey: event?.requestKey ?? applied.requestKey ?? applied.continuationRequestKey ?? null,
922
+ promptKey: event?.promptKey ?? applied.promptKey ?? null,
923
+ continuationCount: Number(applied.continuationCount) || 0,
924
+ noProgressCount: Number(applied.noProgressCount) || 0,
925
+ lastProgressDigest: applied.lastProgressDigest ?? null,
926
+ notified: applied.notified === true,
927
+ lastContinuationAt: stampedAt,
928
+ updatedAt: Date.now(),
929
+ });
930
+ return { ok: true };
931
+ } catch {
932
+ return { ok: false };
933
+ }
934
+ }
935
+
804
936
  function newEventId() {
805
937
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
806
938
  }
@@ -1214,6 +1346,11 @@ export async function recordLedgerEvent(event, {
1214
1346
  // Consume the journal only after the ledger write landed: a crash before this line
1215
1347
  // leaves the journal intact and the next drain re-applies idempotently by eventId.
1216
1348
  if (drained.commit) await drained.commit();
1349
+ // The continuation sidecar was already merged into `current` (and therefore into
1350
+ // the ledger just written): its ticks are now durable state, so the sidecar is
1351
+ // retired. A tick landing between the read and this rm is lost — same accepted
1352
+ // window as the journal drain — but the breaker still advances.
1353
+ try { await fs.rm(continuationSidecarPathFor(target), { force: true }); } catch {}
1217
1354
  return { committed: true, eventId, value, drained: drained.applied, quarantined: drained.quarantined };
1218
1355
  });
1219
1356
  if (outcome.ok) {
@@ -1228,9 +1365,16 @@ export async function recordLedgerEvent(event, {
1228
1365
  // NEVER mutated unlocked — journal the event once; the next acquired lock reconciles it.
1229
1366
  const record = buildJournalRecord({ event, eventId, payload, routeState: routeState || null });
1230
1367
  const journalResult = await appendJournalRecord(target, record, { signal, deadlineMs });
1231
- return journalResult.ok
1232
- ? { journaled: true, eventId }
1233
- : { rejected: true, eventId, reason: journalResult.reason };
1368
+ if (journalResult.ok) return { journaled: true, eventId };
1369
+ // F-4: continuation/notified are counter state, not journal rows — a journal-full
1370
+ // (or journal-unavailable) rejection must still advance the counter or
1371
+ // MAX_CONTINUATIONS fires late/never. Land the tick in the sidecar the evaluator
1372
+ // merges; only a sidecar write failure is a real rejection.
1373
+ if (event.type === 'continuation' || event.type === 'notified') {
1374
+ const sidecar = await bumpContinuationSidecar(target, event);
1375
+ if (sidecar.ok) return { counted: true, eventId, reason: journalResult.reason };
1376
+ }
1377
+ return { rejected: true, eventId, reason: journalResult.reason };
1234
1378
  }
1235
1379
 
1236
1380
  // TASK-027: the receipt is classified (kind, file, command, targeted/broad scope) from the
@@ -1291,6 +1435,44 @@ export async function recordExecutionReceipt({
1291
1435
  );
1292
1436
  }
1293
1437
 
1438
+ // F-15: mirror of isExplicitBroadVerificationRequest in
1439
+ // templates/.claude/hooks/verification-guard.sh — the guard uses this prompt signal to
1440
+ // let a broad suite run under a targeted policy, so the completion gate must honor the
1441
+ // same signal when it classifies the resulting broad receipt. Keep both lists in sync.
1442
+ function isExplicitBroadVerificationRequest(promptText) {
1443
+ const text = String(promptText || '').toLowerCase();
1444
+ return [
1445
+ /run (the )?full test suite/,
1446
+ /run all tests/,
1447
+ /full verification/,
1448
+ /verify everything/,
1449
+ /run the whole suite/,
1450
+ /run broad verification/,
1451
+ /full suite/,
1452
+ /chạy (?:toàn bộ|full) test/,
1453
+ /chạy hết test/,
1454
+ /chạy full test suite/,
1455
+ /verify toàn bộ/,
1456
+ /kiểm tra toàn bộ/,
1457
+ // Plan-driven execution prompts often authorize the plan's final broad
1458
+ // verification without spelling it as "full test suite".
1459
+ /(?:run|execute|implement|do)\b.*\bfull\b.*\b(?:prd\s+)?plan\b/,
1460
+ /\bfull\b.*\b(?:prd\s+)?plan\b.*\b(?:verify|verification|test|tests)\b/,
1461
+ /(?:chạy|lam|làm|thực hiện|triển khai)\b.*\bfull\b.*\b(?:prd|plan|ke hoach|kế hoạch)\b/,
1462
+ /\bfull\b.*\b(?:prd|plan|ke hoach|kế hoạch)\b.*\b(?:verify|verification|test|tests|kiểm tra)\b/,
1463
+ ].some((pattern) => pattern.test(text));
1464
+ }
1465
+
1466
+ // The route cache strips prompt text (route-task.mjs *ForCache deletes it), so the live
1467
+ // state is the only place the explicit-broad signal survives for the evaluator.
1468
+ function explicitBroadVerificationRequested(state = {}) {
1469
+ const routingContext = state?.routingContext || {};
1470
+ return [
1471
+ routingContext.lastExplicitUserPromptText,
1472
+ routingContext.promptText,
1473
+ ].some((promptText) => isExplicitBroadVerificationRequest(promptText));
1474
+ }
1475
+
1294
1476
  function requiredEvidence(state = {}) {
1295
1477
  const routeSummary = state?.routeSummary || {};
1296
1478
  const contractEvidence = routeSummary.executionContract?.completionEvidence;
@@ -1300,14 +1482,19 @@ function requiredEvidence(state = {}) {
1300
1482
  return [...new Set(routeSummary.completionState?.missingEvidence || [])];
1301
1483
  }
1302
1484
 
1303
- function evidenceSatisfied(evidence, ledger = {}, routeSummary = {}) {
1485
+ function evidenceSatisfied(evidence, ledger = {}, state = {}) {
1486
+ const routeSummary = state?.routeSummary || {};
1304
1487
  if (evidence === 'write-evidence') return ledger.writeSucceeded === true;
1305
1488
  if (evidence === 'verification-evidence') {
1306
1489
  // WS-C: when the route names concrete verification commands, only a receipt that ran
1307
1490
  // one of them counts — an unrelated `yarn test` no longer satisfies the gate.
1491
+ // F-15: unless the user explicitly requested/approved a broad suite — the same
1492
+ // prompt signal verification-guard.sh honors — in which case a passing
1493
+ // scope='broad' receipt (verificationSucceeded) satisfies the gate too.
1308
1494
  const routedCommands = routedVerificationCommands(routeSummary);
1309
1495
  if (routedCommands.length > 0) {
1310
- return ledger.targetedVerificationSucceeded === true;
1496
+ return ledger.targetedVerificationSucceeded === true
1497
+ || (explicitBroadVerificationRequested(state) && ledger.verificationSucceeded === true);
1311
1498
  }
1312
1499
  return ledger.verificationSucceeded === true;
1313
1500
  }
@@ -1433,7 +1620,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
1433
1620
  || ledger.requestKey === state.requestKey
1434
1621
  || (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
1435
1622
  const effectiveLedger = sameRequest ? ledger : {};
1436
- const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, routeSummary));
1623
+ const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state));
1437
1624
  if (missingEvidence.length === 0) {
1438
1625
  // Silent success: the route is present and every required evidence is satisfied. Marked
1439
1626
  // `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
@@ -1609,12 +1796,19 @@ async function readStdin() {
1609
1796
 
1610
1797
  // Malformed hook input crashes the evaluate BEFORE any route/session can be read, so the only
1611
1798
  // stable identity is the project root. Count consecutive crashes there; past MAX_GATE_CRASHES,
1612
- // release LOUD naming the crash and the remedy instead of blocking a corrupt hook forever. Any
1613
- // counter I/O failure fails SAFE to block (still loud, never silent). A successful evaluate
1614
- // resets the count.
1799
+ // release LOUD naming the crash and the remedy instead of blocking a corrupt hook forever. A
1800
+ // successful evaluate resets the count.
1801
+ //
1802
+ // F-3: the counter exists to break the infinite bounce — so losing the count must
1803
+ // never BECOME the infinite bounce. When the counter write fails, fail OPEN (loud
1804
+ // release) instead of emitting an unconditional block that can never reach the cap.
1615
1805
  async function handleEvaluateCrash(projectRoot, error) {
1616
1806
  const detail = `malformed hook input crashed the completion gate (${error?.message || error})`;
1617
1807
  const blockReason = () => `${detail}. This is the UKit completion gate, not the task — re-send the task in a new message so the hook payload is regenerated.`;
1808
+ const loudRelease = (message) => {
1809
+ process.stderr.write(`[ukit-completion] ${message}\n`);
1810
+ process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
1811
+ };
1618
1812
  let count = 0;
1619
1813
  try {
1620
1814
  const current = await readJson(crashCounterPath(projectRoot), null);
@@ -1625,15 +1819,21 @@ async function handleEvaluateCrash(projectRoot, error) {
1625
1819
  const next = count + 1;
1626
1820
  try {
1627
1821
  await writeJsonAtomic(crashCounterPath(projectRoot), { count: next, updatedAt: Date.now() });
1628
- } catch {
1629
- // Counter unwritable: we cannot bound the crash loop, but silence is never an option.
1630
- process.stdout.write(`${JSON.stringify({ decision: 'block', reason: blockReason() })}\n`);
1822
+ } catch (writeError) {
1823
+ // Counter unwritable: the crash loop can no longer be bounded, so blocking again
1824
+ // would bounce this stop forever. Release loudly — never silently.
1825
+ loudRelease(
1826
+ `UKit completion gate: ${detail}. The crash counter could not be persisted `
1827
+ + `(${writeError?.message || writeError}), so the gate cannot bound repeated crashes and is `
1828
+ + 'releasing loudly instead of blocking forever. Run: ukit install to refresh the runtime, '
1829
+ + 'then re-send the task in a new message.',
1830
+ );
1631
1831
  return;
1632
1832
  }
1633
1833
  if (next >= MAX_GATE_CRASHES) {
1634
- const message = `UKit completion gate: ${detail}. This recurred ${next} times and cannot be bounded, so the stop is releasing loudly instead of blocking again. The hook payload is corrupt — run: ukit install to refresh the runtime, then re-send the task in a new message.`;
1635
- process.stderr.write(`[ukit-completion] ${message}\n`);
1636
- process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
1834
+ loudRelease(
1835
+ `UKit completion gate: ${detail}. This recurred ${next} times and cannot be bounded, so the stop is releasing loudly instead of blocking again. The hook payload is corrupt — run: ukit install to refresh the runtime, then re-send the task in a new message.`,
1836
+ );
1637
1837
  return;
1638
1838
  }
1639
1839
  process.stdout.write(`${JSON.stringify({ decision: 'block', reason: blockReason() })}\n`);
@@ -437,6 +437,36 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
437
437
  return { results, elapsedMs, budgetMs: totalBudgetMs, budgetExhausted, skippedFailClosed };
438
438
  }
439
439
 
440
+ // TASK-006 (SPEC §S6, F-RC-2): a timed-out in-proc step abandons its
441
+ // Promise.race loser — when that loser holds ref'd libuv handles (timers,
442
+ // watchers, sockets) the event loop stays pinned and the host waits the full
443
+ // registered 30–73s timeout for a process that already produced its verdict.
444
+ // Every emit path ends here: flush stdout/stderr, then exit explicitly. The
445
+ // flush is bounded — a reader that never drains must not turn the fix into a
446
+ // new stall.
447
+ const FLUSH_EXIT_MS = Number(process.env.UKIT_HOOK_FLUSH_EXIT_MS || 2000);
448
+
449
+ function flushStream(stream) {
450
+ return new Promise((resolve) => {
451
+ try {
452
+ // end() flushes every queued chunk before invoking the callback —
453
+ // including when the stream was already ended/destroyed (the callback
454
+ // still fires, with an error we deliberately ignore).
455
+ stream.end(() => resolve());
456
+ } catch {
457
+ resolve();
458
+ }
459
+ });
460
+ }
461
+
462
+ async function flushAndExit(code) {
463
+ await Promise.race([
464
+ Promise.all([flushStream(process.stdout), flushStream(process.stderr)]),
465
+ new Promise((resolve) => setTimeout(resolve, FLUSH_EXIT_MS).unref()),
466
+ ]);
467
+ process.exit(code);
468
+ }
469
+
440
470
  try {
441
471
  let argv = process.argv.slice(2);
442
472
  // TASK-234: `--emit-verdict` adapts the runner for Claude Code settings.json
@@ -493,16 +523,16 @@ try {
493
523
  });
494
524
  if (emitVerdict) {
495
525
  process.stdout.write(deny);
496
- process.exitCode = 0;
497
526
  } else {
498
527
  process.stdout.write(JSON.stringify({
499
528
  results: [],
500
529
  wrapperError: 'stdin staging truncated — fail-closed chain refused',
501
530
  stdinTruncated: true,
502
531
  }));
503
- process.exitCode = 2;
504
532
  }
505
- process.exit(emitVerdict ? 0 : 2);
533
+ // TASK-006: flush before exit — a bare process.exit can truncate a
534
+ // verdict still queued on the pipe.
535
+ await flushAndExit(emitVerdict ? 0 : 2);
506
536
  }
507
537
  }
508
538
  const chain = await run(payloadText, scriptPaths, {
@@ -595,10 +625,14 @@ try {
595
625
  }
596
626
  }
597
627
  }
628
+ // TASK-006 (F-RC-2): the verdict is on the wire — exit now. Waiting for a
629
+ // natural exit lets an abandoned in-proc step's ref'd handles pin the loop
630
+ // until the host's registered timeout kills us.
631
+ await flushAndExit(process.exitCode ?? 0);
598
632
  } catch (error) {
599
633
  process.stdout.write(JSON.stringify({
600
634
  results: [],
601
635
  wrapperError: error?.message || String(error),
602
636
  }));
603
- process.exitCode = 1;
637
+ await flushAndExit(1);
604
638
  }
@@ -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
@@ -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 () => {