@ngockhoale/ukit 3.4.1 → 3.4.3

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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
@@ -93,9 +93,78 @@ export function telemetryDirFor(projectRoot) {
93
93
  return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
94
94
  }
95
95
 
96
+ // --- session-file lock (C90-19) ----------------------------------------------
97
+ // Synchronous variant of the `<file>.lock` convention async-lock.mjs owns for
98
+ // the async twins (mkdir is atomic, so exactly one process wins; `owner` stamps
99
+ // pid+token so stale reclaim is validated). The telemetry writers are all
100
+ // short-lived, fully-sync hook processes — the async lock would need an event
101
+ // loop these callers deliberately avoid — so the sync copy stays minimal:
102
+ // bounded spin, stale reclaim when the recorded pid is provably gone, and a
103
+ // fail-closed return (a missed budget drops the row — telemetry is advisory).
104
+ const TEL_LOCK_STALE_MS = 10_000;
105
+ const TEL_LOCK_BUDGET_MS = 300;
106
+
107
+ function isLockOwnerAlive(lockPath) {
108
+ try {
109
+ const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
110
+ if (!Number.isInteger(owner?.pid) || owner.pid <= 0) return false;
111
+ process.kill(owner.pid, 0);
112
+ return true;
113
+ } catch (error) {
114
+ // EPERM: the process exists but belongs to another user — still alive.
115
+ return error?.code === 'EPERM';
116
+ }
117
+ }
118
+
119
+ function withTelemetryFileLock(filePath, fn) {
120
+ const lockPath = `${filePath}.lock`;
121
+ const deadline = Date.now() + TEL_LOCK_BUDGET_MS;
122
+ let held = false;
123
+ while (!held && Date.now() < deadline) {
124
+ try {
125
+ fs.mkdirSync(lockPath);
126
+ held = true;
127
+ try {
128
+ fs.writeFileSync(
129
+ path.join(lockPath, 'owner'),
130
+ `${JSON.stringify({ pid: process.pid, token: `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, ts: Date.now() })}\n`,
131
+ );
132
+ } catch {
133
+ // Owner stamp is advisory (stale reclaim degrades to the mtime
134
+ // threshold); failing to write it must not drop the held lock.
135
+ }
136
+ } catch (error) {
137
+ if (!error || error.code !== 'EEXIST') return undefined;
138
+ try {
139
+ const stat = fs.statSync(lockPath);
140
+ if (Date.now() - stat.mtimeMs > TEL_LOCK_STALE_MS && !isLockOwnerAlive(lockPath)) {
141
+ fs.rmSync(lockPath, { recursive: true, force: true });
142
+ continue;
143
+ }
144
+ } catch {
145
+ // stat raced away — fall through to the bounded spin
146
+ }
147
+ const spinUntil = Date.now() + 5;
148
+ while (Date.now() < spinUntil) { /* bounded spin — sync callers only */ }
149
+ }
150
+ }
151
+ if (!held) return undefined;
152
+ try {
153
+ return fn();
154
+ } finally {
155
+ try {
156
+ fs.rmSync(lockPath, { recursive: true, force: true });
157
+ } catch {
158
+ // Leftover lock self-heals via the stale threshold above.
159
+ }
160
+ }
161
+ }
162
+
96
163
  // Bounded work on THIS session's file only: stat, at most one read of a file
97
164
  // already capped at maxBytes, one rewrite of the kept half. Directory growth is
98
165
  // bounded separately by the sampled sweepTelemetryDir below (BUG-C21-10).
166
+ // C90-19: the rewrite is tmp+rename — the pre-fix in-place writeFileSync on the
167
+ // live file could expose a torn file or lose an interleaved append.
99
168
  function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
100
169
  let size = 0;
101
170
  try {
@@ -116,7 +185,17 @@ function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
116
185
  keep.unshift(lines[i]);
117
186
  kept += lineBytes;
118
187
  }
119
- fs.writeFileSync(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
188
+ const tmpPath = `${filePath}.rot-${process.pid}-${Math.random().toString(16).slice(2)}`;
189
+ try {
190
+ fs.writeFileSync(tmpPath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
191
+ fs.renameSync(tmpPath, filePath);
192
+ } catch (renameError) {
193
+ try { fs.rmSync(tmpPath, { force: true }); } catch { /* best effort */ }
194
+ if (renameError?.code !== 'EXDEV') throw renameError;
195
+ // EXDEV: tmp and destination on different mounts — same copy-over
196
+ // fallback token-utils writeJson carries.
197
+ fs.writeFileSync(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
198
+ }
120
199
  } catch {
121
200
  // Rotation failed; drop this row rather than grow past the cap.
122
201
  }
@@ -230,12 +309,18 @@ export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
230
309
  fs.mkdirSync(dir, { recursive: true });
231
310
  const filePath = path.join(dir, `${safeName(sessionId)}.jsonl`);
232
311
  const line = `${JSON.stringify(row)}\n`;
233
- rotateIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), capBytes(options.maxBytes));
234
- fs.appendFileSync(filePath, line, 'utf8');
235
- // Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
236
- // never alter the row this call just appended.
237
- try { maybeSweepTelemetryDir(dir); } catch (error) { noteSweepFailure(error); }
238
- return true;
312
+ // C90-19: rotation and the append run under the session-file lock — an
313
+ // unlocked append landing between the rotator's readFileSync and its
314
+ // rename was the lost-row window. Lock budget expiry drops the row
315
+ // (telemetry is advisory, never a blocker).
316
+ return withTelemetryFileLock(filePath, () => {
317
+ rotateIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), capBytes(options.maxBytes));
318
+ fs.appendFileSync(filePath, line, 'utf8');
319
+ // Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
320
+ // never alter the row this call just appended.
321
+ try { maybeSweepTelemetryDir(dir); } catch (error) { noteSweepFailure(error); }
322
+ return true;
323
+ }) === true;
239
324
  } catch {
240
325
  // Advisory: an unwritable or corrupt telemetry target must never alter a
241
326
  // hook's verdict, output, or exit status.
@@ -61,6 +61,23 @@ async function checkFileEvidence(projectRoot, entry) {
61
61
  const abs = resolveLocator(projectRoot, entry.locator);
62
62
  if (abs == null) return { state: UNKNOWN, detail: 'locator-unverifiable' };
63
63
 
64
+ // Symlink defense (SPEC §14): a locator that resolves inside projectRoot
65
+ // but lands on a symlink pointing outside must never be followed — the
66
+ // realpath containment check makes escapes unverifiable → 'unknown'.
67
+ try {
68
+ const lst = await fsp.lstat(abs);
69
+ if (lst.isSymbolicLink()) {
70
+ const root = path.resolve(projectRoot);
71
+ const real = await fsp.realpath(abs).catch(() => null);
72
+ if (real == null || (real !== root && !real.startsWith(root + path.sep))) {
73
+ return { state: UNKNOWN, detail: 'locator-unverifiable' };
74
+ }
75
+ }
76
+ } catch {
77
+ // lstat failure → fall through to the normal stat path, which maps
78
+ // ENOENT to 'file-missing' and other errors to 'file-unverified'.
79
+ }
80
+
64
81
  const fp = entry.fingerprint;
65
82
  if (typeof fp === 'string' && fp.startsWith('sha1:')) {
66
83
  const actual = await sha1Fingerprint(abs);
@@ -249,7 +249,9 @@ function sanitizeValue(value, ctx, depth) {
249
249
  let kept = 0;
250
250
  for (const [k, v] of Object.entries(value)) {
251
251
  if (kept >= MAX_OBJECT_KEYS) break;
252
- if (DENIED_PAYLOAD_FIELDS.has(k)) continue;
252
+ // TASK-C91-015 (W3-OB1): the denylist is case-insensitive — 'Prompt'
253
+ // and 'AUTHORIZATION' leak the same data as their lowercase forms.
254
+ if (DENIED_PAYLOAD_FIELDS.has(k.toLowerCase())) continue;
253
255
  const key = sanitizeKey(k, ctx);
254
256
  if (key === null) continue;
255
257
  const sv = sanitizeValue(v, ctx, depth + 1);
@@ -473,6 +475,10 @@ const MAX_POLICY_VERSION_CHARS = 128;
473
475
  const SAMPLING_BUCKETS = 10000;
474
476
  const SAMPLING_SEED = 0;
475
477
  let samplingConfigInvalidSeen = false;
478
+ // TASK-C91-015 (W3-OB2): the CONFIG_INVALID report latch is process-level —
479
+ // emitOne re-creates its per-call `state`, so a `state.configInvalidReported`
480
+ // flag re-reported the aggregate on every emit in the same process.
481
+ let samplingConfigInvalidReported = false;
476
482
 
477
483
  function resolveSampling(config = null) {
478
484
  const node = isPlainObject(config) ? config.observability : undefined;
@@ -659,24 +665,46 @@ async function withTimeout(promise, ms) {
659
665
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
660
666
  }
661
667
 
668
+ // Marks a readState result synthesized from a FAILED read (corrupt file,
669
+ // transient EACCES/IO_TIMEOUT, non-object payload) — in-memory only, never
670
+ // persisted (parity with segments/internal.js STATE_READ_FAILED).
671
+ const STATE_READ_FAILED = Symbol('ukit.observability.stateReadFailed');
672
+
662
673
  async function readState(root, timeoutMs) {
663
674
  try {
664
675
  const raw = await withTimeout(fs.promises.readFile(path.join(root, STATE_FILE), 'utf8'), timeoutMs);
665
676
  const parsed = JSON.parse(raw);
666
- return { ...STATE_DEFAULTS, ...(parsed && typeof parsed === 'object' ? parsed : {}) };
667
- } catch {
668
- return { ...STATE_DEFAULTS };
677
+ // TASK-C91-015 (W3-OB3): a torn/corrupt state file must fail CLOSED —
678
+ // defaulting to `disabled:false` would keep the total-size gate open
679
+ // through corruption. Only ENOENT means "no state yet" (open); every
680
+ // other failure and every non-object payload is disabled:true until a
681
+ // clean write re-enables the store. The synthesized state is MARKED so
682
+ // it is never persisted back as durable disabled:true.
683
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
684
+ return { ...STATE_DEFAULTS, disabled: true, [STATE_READ_FAILED]: true };
685
+ }
686
+ return { ...STATE_DEFAULTS, ...parsed };
687
+ } catch (err) {
688
+ if (err && err.code === 'ENOENT') return { ...STATE_DEFAULTS };
689
+ return { ...STATE_DEFAULTS, disabled: true, [STATE_READ_FAILED]: true };
669
690
  }
670
691
  }
671
692
 
672
693
  async function writeState(root, state, timeoutMs) {
694
+ // TASK-C91-015 (W3-OB3): atomic publish — tmp sibling + rename, same
695
+ // convention as segments/internal.js writeState, so a crash mid-write
696
+ // leaves the previous _state.json (or none), never a torn file. A state
697
+ // synthesized from a failed read is never published — persisting it
698
+ // would turn a transient I/O error into permanent disabled:true.
699
+ if (state && state[STATE_READ_FAILED]) return false;
700
+ const statePath = path.join(root, STATE_FILE);
701
+ const tmpPath = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
673
702
  try {
674
- await withTimeout(
675
- fs.promises.writeFile(path.join(root, STATE_FILE), JSON.stringify(state, null, 2)),
676
- timeoutMs,
677
- );
703
+ await withTimeout(fs.promises.writeFile(tmpPath, JSON.stringify(state, null, 2)), timeoutMs);
704
+ await withTimeout(fs.promises.rename(tmpPath, statePath), timeoutMs);
678
705
  return true;
679
706
  } catch {
707
+ try { await fs.promises.unlink(tmpPath); } catch { /* best-effort */ }
680
708
  return false;
681
709
  }
682
710
  }
@@ -876,8 +904,8 @@ async function emitOne({ projectRoot, config, record, deps, deadlineMs }) {
876
904
  }
877
905
  return fail('sampled-out');
878
906
  }
879
- if (samplingConfigInvalidSeen && !state.configInvalidReported) {
880
- state.configInvalidReported = true;
907
+ if (samplingConfigInvalidSeen && !samplingConfigInvalidReported) {
908
+ samplingConfigInvalidReported = true;
881
909
  const aggregate = fillEnvelope({
882
910
  semantic_name: 'telemetry.dropped',
883
911
  payload: { reason_code: 'CONFIG_INVALID', dropped_count: 1 },
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
2
3
  import fs from 'node:fs/promises';
3
4
  import fsSync from 'node:fs';
4
5
  import path from 'node:path';
@@ -331,6 +332,7 @@ function buildRecoveryFileName({
331
332
  summary = '',
332
333
  profile = 'generic',
333
334
  exitCode = null,
335
+ contentHash = '',
334
336
  } = {}) {
335
337
  const slug = sanitizeFileComponent(String(command).split(/\s+/).slice(0, 3).join('-'), {
336
338
  fallback: sanitizeFileComponent(profile, { fallback: 'output', maxLength: 16 }),
@@ -341,6 +343,10 @@ function buildRecoveryFileName({
341
343
  summary: String(summary ?? '').trim(),
342
344
  profile: String(profile ?? 'generic').trim(),
343
345
  exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
346
+ // C90-05: bind the name to the bytes it stores. The older inputs let two
347
+ // compressions of the same command compressing to the same summary (the
348
+ // normal retry loop) collide at the tee dir and overwrite each other.
349
+ contentHash: String(contentHash ?? ''),
344
350
  }).split(':').at(-1)?.replace(/[^a-z0-9_-]/gi, '-').slice(0, 16) || 'recovery';
345
351
  return `${slug}-${fingerprint}.log`;
346
352
  }
@@ -527,6 +533,11 @@ async function persistRawOutput(projectRoot, {
527
533
  summary,
528
534
  profile,
529
535
  exitCode,
536
+ contentHash: crypto.createHash('sha256')
537
+ .update(String(stdout ?? ''))
538
+ .update('\0')
539
+ .update(String(stderr ?? ''))
540
+ .digest('hex'),
530
541
  });
531
542
  const absolutePath = path.join(teeCacheDir, fileName);
532
543
  const rawOutputText = buildRawOutputText({
@@ -153,7 +153,7 @@ async function main() {
153
153
  ? hookPayload.session_id.trim()
154
154
  : undefined,
155
155
  };
156
- const state = await loadFreshRouteState(projectRoot);
156
+ const state = await loadFreshRouteState(projectRoot, sessionConfig.sessionId);
157
157
  const compressEnabled = shouldCompress(config);
158
158
  const header = '=== PROJECT CONTEXT (post-compaction) ===';
159
159
  const footer = '=== END ===';
@@ -375,13 +375,34 @@ async function loadRuntimeConfig(projectRoot) {
375
375
  return deepMerge(defaultRuntimeConfig(), raw && typeof raw === 'object' ? raw : {});
376
376
  }
377
377
 
378
- async function loadFreshRouteState(projectRoot) {
378
+ // W3-RI1: route state is session-scoped — its top-level `sessionId` is the
379
+ // owner stamp both writers (skill-router.sh and route-task.mjs) persist.
380
+ // Comparing it before reinjecting matters: session A's compaction must never
381
+ // adopt session B's lane/plans as its own. Fail conservative (planner note):
382
+ // a state whose stored sessionId is absent or different is not reinjected for
383
+ // a sessioned caller; a sessionless payload (legacy/unknown harness) compares
384
+ // against nothing and is left to the same equality — ownerless↔ownerless only.
385
+ async function loadFreshRouteState(projectRoot, sessionId) {
379
386
  const statePath = path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json');
380
387
  const state = await readJson(statePath, {});
381
388
  if (typeof state?.ts === 'number' && (Date.now() - state.ts) > STATE_TTL_MS) {
382
389
  return {};
383
390
  }
384
- return state && typeof state === 'object' ? state : {};
391
+ if (!state || typeof state !== 'object') return {};
392
+ const ownerId = typeof state.sessionId === 'string' && state.sessionId.trim()
393
+ ? state.sessionId.trim()
394
+ : null;
395
+ if (ownerId !== (sessionId ?? null)) {
396
+ try {
397
+ process.stderr.write(
398
+ `[UKit] reinject-context: skipped shared route state owned by a different session (owner=${ownerId ?? 'none'}).\n`,
399
+ );
400
+ } catch {
401
+ // diagnostics are best-effort
402
+ }
403
+ return {};
404
+ }
405
+ return state;
385
406
  }
386
407
 
387
408
  function unique(values) {
@@ -352,6 +352,37 @@ async function drainRunJournal(target) {
352
352
  }
353
353
  return records;
354
354
  }
355
+ // W3-RR1: the journal's drain→rm window is one locked section under the SAME
356
+ // journal-local lock appendRunJournal serializes on — a checkpoint appended
357
+ // between the drain and the rm was silently deleted. `update` runs while the
358
+ // record lock is already held; `applied: true` means it landed the target
359
+ // write, so the journal can be consumed. When the journal lock itself is
360
+ // contended the update still runs (the record lock serializes this section),
361
+ // but the journal is kept: an in-flight append has not been observed and the
362
+ // next holder re-merges idempotently. Lock order is record→journal only;
363
+ // appenders never hold the journal while waiting on the record lock, so this
364
+ // cannot deadlock.
365
+ async function runJournalUpdate(target, update) {
366
+ const journalPath = runJournalPathFor(target);
367
+ const lockedOutcome = await withAsyncLock(
368
+ journalPath,
369
+ { deadlineMs: RUN_JOURNAL_LOCK_BUDGET_MS },
370
+ async () => {
371
+ const result = await update();
372
+ if (result?.applied === true) {
373
+ try {
374
+ await fs.rm(journalPath, { force: true });
375
+ } catch {
376
+ // a leftover journal re-applies idempotently on the next lock
377
+ }
378
+ }
379
+ return result;
380
+ },
381
+ );
382
+ if (lockedOutcome?.ok === true) return lockedOutcome.value;
383
+ return update();
384
+ }
385
+
355
386
 
356
387
  function recordTimestamp(record) {
357
388
  return Number.isFinite(record?.updatedAt) ? record.updatedAt : 0;
@@ -397,17 +428,17 @@ export async function writeResumableRun(projectRoot, record, {
397
428
  // Reconcile first: journaled snapshots from earlier busy writes are
398
429
  // candidates alongside the incoming record; the newest updatedAt wins and
399
430
  // lands in ONE atomic write. The journal is removed only after the write
400
- // lands, so a crash mid-section leaves the records re-appliable.
401
- const journaled = await drainRunJournal(target);
402
- const candidates = [...journaled, stamped];
403
- const winner = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
404
- await writeJsonAtomic(target, winner);
405
- try {
406
- await fs.rm(runJournalPathFor(target), { force: true });
407
- } catch {
408
- // a leftover journal re-applies idempotently on the next lock
409
- }
410
- return winner;
431
+ // lands, so a crash mid-section leaves the records re-appliable — and the
432
+ // drain+rm runs under the journal lock (W3-RR1) so an append racing the
433
+ // rm can never lose a checkpoint.
434
+ const result = await runJournalUpdate(target, async () => {
435
+ const journaled = await drainRunJournal(target);
436
+ const candidates = [...journaled, stamped];
437
+ const winner = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
438
+ await writeJsonAtomic(target, winner);
439
+ return { applied: true, value: winner };
440
+ });
441
+ return result.value;
411
442
  });
412
443
 
413
444
  if (outcome?.ok === true) {
@@ -606,27 +637,26 @@ export async function invalidateResumableRun(projectRoot, taskId, codes, {
606
637
 
607
638
  const outcome = await withAsyncLock(target, { signal, deadlineMs }, async () => {
608
639
  // Reconcile the journal first so invalidation applies to the newest known
609
- // state, not a superseded on-disk record.
610
- const journaled = await drainRunJournal(target);
611
- let parsed;
612
- try {
613
- parsed = JSON.parse(await fs.readFile(target, 'utf8'));
614
- } catch (error) {
615
- if (error?.code !== 'ENOENT') return { ok: false, reason: 'invalid' };
616
- parsed = null;
617
- }
618
- const candidates = [...journaled, ...(parsed ? [parsed] : [])];
619
- if (candidates.length === 0) return { ok: false, reason: 'absent' };
620
- const base = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
621
- const { record, invalidated } = applyInvalidationCodes(base, codeList);
622
- record.updatedAt = Date.now();
623
- await writeJsonAtomic(target, record);
624
- try {
625
- await fs.rm(runJournalPathFor(target), { force: true });
626
- } catch {
627
- // leftover journal re-applies idempotently on the next lock
628
- }
629
- return { ok: true, invalidated };
640
+ // state, not a superseded on-disk record. The drain+rm runs under the
641
+ // journal lock (W3-RR1) — same convention as the write path.
642
+ const result = await runJournalUpdate(target, async () => {
643
+ const journaled = await drainRunJournal(target);
644
+ let parsed;
645
+ try {
646
+ parsed = JSON.parse(await fs.readFile(target, 'utf8'));
647
+ } catch (error) {
648
+ if (error?.code !== 'ENOENT') return { applied: false, value: { ok: false, reason: 'invalid' } };
649
+ parsed = null;
650
+ }
651
+ const candidates = [...journaled, ...(parsed ? [parsed] : [])];
652
+ if (candidates.length === 0) return { applied: false, value: { ok: false, reason: 'absent' } };
653
+ const base = candidates.reduce((a, b) => (recordTimestamp(b) >= recordTimestamp(a) ? b : a));
654
+ const { record, invalidated } = applyInvalidationCodes(base, codeList);
655
+ record.updatedAt = Date.now();
656
+ await writeJsonAtomic(target, record);
657
+ return { applied: true, value: { ok: true, invalidated } };
658
+ });
659
+ return result.value;
630
660
  });
631
661
 
632
662
  if (outcome?.ok === true) return outcome.value;
@@ -283,10 +283,18 @@ export function compactContextBlock(
283
283
  forceFirstCount: (header ? 1 : 0) + normalizedAnchorLines.length,
284
284
  },
285
285
  ).join('\n').trim();
286
- validationMode = 'forced-anchors';
286
+ // 'forced-anchors' means every anchor is present — verify it. When the block
287
+ // budget cannot hold them all, the mode must report the shortfall instead of
288
+ // claiming an anchor-complete block that silently dropped the tail anchors
289
+ // (C92-H-03: the dropped tail used to be memory-bridge + ask-before-drop).
290
+ validationMode = findMissingAnchors(text, normalizedAnchorLines).length === 0
291
+ ? 'forced-anchors'
292
+ : 'anchor-shortfall';
287
293
  }
288
294
  }
289
295
 
296
+ const missingAnchors = findMissingAnchors(text, normalizedAnchorLines);
297
+
290
298
  return {
291
299
  text,
292
300
  tokensBefore: estimateTokenCount(beforeText),
@@ -294,6 +302,7 @@ export function compactContextBlock(
294
302
  savedTokens: Math.max(0, estimateTokenCount(beforeText) - estimateTokenCount(text)),
295
303
  validationMode,
296
304
  anchorCount: normalizedAnchorLines.length,
305
+ missingAnchors,
297
306
  };
298
307
  }
299
308
 
@@ -361,11 +370,13 @@ function buildCompactedContextLines(
361
370
  forceFirstCount = 0,
362
371
  } = {},
363
372
  ) {
373
+ const hardTokenCap = Math.max(maxTokens, Math.ceil(maxTokens * 2));
364
374
  const sourceLines = Array.isArray(lines) ? lines : [];
365
375
  const selected = [];
366
376
  const seen = new Set();
367
377
  let usedTokens = 0;
368
378
  let nonEmptyCount = 0;
379
+ let forcedRemaining = forceFirstCount;
369
380
 
370
381
  for (const line of sourceLines) {
371
382
  const compressed = compressLine(line);
@@ -379,20 +390,37 @@ function buildCompactedContextLines(
379
390
  }
380
391
 
381
392
  const tokens = estimateTokenCount(compressed);
382
- const forceLine = nonEmptyCount < forceFirstCount;
383
- const wouldOverflow = selected.length > 0 && (usedTokens + tokens) > maxTokens;
393
+ // Forced lines (header + anchors) are the first `forceFirstCount` distinct
394
+ // non-empty source lines: they bypass the line cap and the soft token budget —
395
+ // dropping a forced anchor would silently break the anchor contract this
396
+ // function exists to enforce. The quota is consumed on the candidate itself,
397
+ // whether or not it fits, so a dropped forced anchor never slides its slot
398
+ // onto a following content line.
399
+ const forceLine = forcedRemaining > 0;
400
+ if (forceLine) {
401
+ forcedRemaining -= 1;
402
+ }
384
403
  const wouldExceedLines = nonEmptyCount >= maxLines;
385
404
 
386
- if (!forceLine && (wouldOverflow || wouldExceedLines)) {
405
+ if ((!forceLine && wouldExceedLines) || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
387
406
  break;
388
407
  }
389
- if (forceLine && wouldExceedLines) {
390
- break;
408
+
409
+ const selectedLine = forceLine && (usedTokens + tokens) > hardTokenCap
410
+ ? truncateToTokenBudget(compressed, hardTokenCap - usedTokens)
411
+ : compressed;
412
+ const selectedTokens = estimateTokenCount(selectedLine);
413
+
414
+ // A forced line whose remaining slice of hardTokenCap is empty used to break
415
+ // the loop, dropping every anchor after it. Keep going so later anchors still
416
+ // get their chance — the caller reports whichever anchors never landed.
417
+ if (!selectedLine) {
418
+ continue;
391
419
  }
392
420
 
393
- selected.push(compressed);
421
+ selected.push(selectedLine);
394
422
  seen.add(dedupeKey);
395
- usedTokens += tokens;
423
+ usedTokens += selectedTokens;
396
424
  nonEmptyCount += 1;
397
425
  }
398
426
 
@@ -411,6 +439,13 @@ function findMissingAnchors(text, anchorLines) {
411
439
  });
412
440
  }
413
441
 
442
+ function truncateToTokenBudget(line, maxTokens) {
443
+ if (maxTokens <= 0) return '';
444
+ const maxChars = Math.max(1, (maxTokens * 4) - 4);
445
+ const value = String(line ?? '');
446
+ return value.length <= maxChars ? value : `${value.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
447
+ }
448
+
414
449
  function normalizePromptCacheEntry(entry) {
415
450
  if (!entry || typeof entry !== 'object') {
416
451
  return null;
@@ -595,10 +630,18 @@ export async function recordCompaction(projectRoot, entry, { maxEntries = DEFAUL
595
630
  }
596
631
 
597
632
  const runtimePaths = buildRuntimePaths(projectRoot);
598
- const history = normalizeCompactHistoryDocument(await readJson(runtimePaths.compactHistoryPath, null), { maxEntries });
599
- const nextDocument = normalizeCompactHistoryDocument({
600
- entries: [normalizedEntry, ...history.entries],
601
- }, { maxEntries });
602
- await writeJson(runtimePaths.compactHistoryPath, nextDocument);
603
- return normalizedEntry;
633
+ // C90-06: lock the read-modify-write like the src twin
634
+ // (src/core/compact/index.js recordCompaction → appendCompactHistory) —
635
+ // concurrent hook processes each prepended to a stale snapshot and
636
+ // last-write-wins silently dropped one entry. The same lock protocol the
637
+ // prompt-cache RMW above uses; fail-closed (a skipped write is journaled,
638
+ // matching the history file's advisory-cache posture).
639
+ return withFileLock(runtimePaths.compactHistoryPath, async () => {
640
+ const history = normalizeCompactHistoryDocument(await readJson(runtimePaths.compactHistoryPath, null), { maxEntries });
641
+ const nextDocument = normalizeCompactHistoryDocument({
642
+ entries: [normalizedEntry, ...history.entries],
643
+ }, { maxEntries });
644
+ await writeJson(runtimePaths.compactHistoryPath, nextDocument);
645
+ return normalizedEntry;
646
+ });
604
647
  }