@ngockhoale/ukit 3.4.1 → 3.4.2

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 +19 -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
@@ -5,8 +5,12 @@
5
5
  *
6
6
  * Enumerate → classify → gate → remove only classified-safe entries. Dry-run
7
7
  * is the default and mutates nothing; `--apply` runs `git worktree remove`
8
- * exclusively on entries classified `safe`. Never a blanket sweep — every
9
- * skipped entry keeps a named verdict.
8
+ * exclusively on entries classified `safe` — stale entries (`prunable` /
9
+ * missing dir) go through `git worktree prune` instead, since `remove` has
10
+ * no tree to detach (older git exits 128 there). The worktree containing this
11
+ * process's cwd/--root is excluded by resolved-path identity, never by
12
+ * porcelain record position. Never a blanket sweep — every skipped entry
13
+ * keeps a named verdict.
10
14
  *
11
15
  * Classification (in priority order, first match wins):
12
16
  * safe — prunable/stale worktree (missing dir or unborn branch) that is
@@ -15,15 +19,20 @@
15
19
  * skip-locked — porcelain `locked` flag (a human locked it)
16
20
  * skip-detached-head — detached HEAD (nothing merges into anything)
17
21
  * skip-dirty — `git -C <wt> status --porcelain` non-empty or
18
- * unreadable (unknown state is never removable)
22
+ * unreadable (unknown state is never removable);
23
+ * `reason=` on the verdict line names which case it was
19
24
  * skip-active — resumable-run records present (.ukit/storage/runs/)
20
25
  * OR docs/AI_HANDOFF/RUN.md phase not done/blocked,
21
26
  * OR the branch has commits not merged into default
22
27
  *
23
28
  * CLI: node worktree-sweep.mjs [--apply] [--root <dir>]
24
29
  * exit 0 on a successful report/apply (even when nothing is removable);
25
- * non-zero only on invocation error (bad flag, not-a-repo, git spawn
26
- * failure). A failed `worktree remove` is reported per-entry, not fatal.
30
+ * non-zero only on invocation or repo-level errors (bad flag, not-a-repo,
31
+ * a git spawn failure/timeout on a root call such as `worktree list`).
32
+ * A failed or timed-out per-worktree call is NOT an invocation error —
33
+ * it degrades that entry to skip-dirty with a `reason=` on its verdict
34
+ * line and the sweep continues (C92-D-02). A failed `worktree remove`
35
+ * is reported per-entry, not fatal.
27
36
  */
28
37
 
29
38
  import fs from 'node:fs';
@@ -42,13 +51,17 @@ function fail(msg) {
42
51
  process.exit(1);
43
52
  }
44
53
 
45
- function git(root, args) {
54
+ // `degrade` marks a per-worktree call whose spawn error or timeout must not
55
+ // abort the sweep: the caller gets the errored result back and degrades that
56
+ // entry (C92-D-02 — one worktree on a wedged volume is not an invocation
57
+ // error). Calls without it are repo-level and stay fatal on spawn errors.
58
+ function git(root, args, { degrade = false } = {}) {
46
59
  const res = spawnSync('git', ['-C', root, ...args], {
47
60
  encoding: 'utf8',
48
61
  timeout: GIT_CALL_TIMEOUT_MS,
49
62
  killSignal: 'SIGKILL',
50
63
  });
51
- if (res.error) {
64
+ if (res.error && !degrade) {
52
65
  fail(`git ${args[0]} failed to run: ${res.error.message ?? res.error}`);
53
66
  }
54
67
  return res;
@@ -93,10 +106,29 @@ for (const line of list.stdout.split('\n')) {
93
106
  }
94
107
  if (cur) worktrees.push(cur);
95
108
 
96
- // The main worktree is always the first record — it is the checkout running
97
- // this sweep and is never a removal candidate.
98
- const main = worktrees.shift();
99
- const candidates = worktrees;
109
+ // Removal candidates are excluded by resolved-path identity, never by list
110
+ // position (W3-06): the old positional `worktrees.shift()` only protected the
111
+ // FIRST porcelain record. Invoked from inside a linked worktree (or with a
112
+ // --root pointing at one), the caller's own worktree stayed in the removal
113
+ // set and `git worktree remove` happily deleted the process's cwd.
114
+ // The main worktree is record 0 — kept for the default-branch fallback but
115
+ // also excluded below by path identity.
116
+ const main = worktrees[0] ?? null;
117
+ const realPath = (p) => {
118
+ try { return fs.realpathSync(p); } catch { return path.resolve(p); }
119
+ };
120
+ const mainPath = main ? realPath(main.path) : null;
121
+ // A worktree is "self" when its root contains this process's cwd or the
122
+ // resolved --root (usually the same path). Containment, not equality — the
123
+ // sweep may be invoked from any subdirectory of the worktree.
124
+ const selfRoots = [root, process.cwd()].map((p) => realPath(p) + path.sep);
125
+ const isSelfWorktree = (wtPath) => {
126
+ const wt = realPath(wtPath) + path.sep;
127
+ return selfRoots.some((self) => self.startsWith(wt));
128
+ };
129
+ const candidates = worktrees.filter(
130
+ (wt) => realPath(wt.path) !== mainPath && !isSelfWorktree(wt.path),
131
+ );
100
132
 
101
133
  // Default branch: prefer remote HEAD, then local main/master, then the main
102
134
  // worktree's own checked-out branch.
@@ -120,11 +152,19 @@ if (defaultBranch) {
120
152
  }
121
153
 
122
154
  // --- classify ---------------------------------------------------------------
123
- function isDirty(wtPath) {
124
- const res = git(wtPath, ['status', '--porcelain']);
125
- // Unreadable status → treat as dirty: unknown state is never removable.
126
- if (res.status !== 0) return true;
127
- return res.stdout.trim().length > 0;
155
+ // Readability check, not just dirtiness: returns the reason to print whenever
156
+ // the status is non-clean — actual uncommitted work, a non-zero exit, or a
157
+ // spawn error/timeout on that worktree (degraded, never fatal: C92-D-02).
158
+ // Unknown state is never removable.
159
+ function dirtyState(wtPath) {
160
+ const res = git(wtPath, ['status', '--porcelain'], { degrade: true });
161
+ if (res.error) return { dirty: true, reason: `status check failed: ${res.error.message ?? res.error}` };
162
+ if (res.status !== 0) {
163
+ const detail = (res.stderr || '').split('\n')[0].trim();
164
+ return { dirty: true, reason: `status check failed (exit ${res.status})${detail ? `: ${detail}` : ''}` };
165
+ }
166
+ if (res.stdout.trim().length > 0) return { dirty: true, reason: 'uncommitted changes' };
167
+ return { dirty: false };
128
168
  }
129
169
 
130
170
  function hasActiveRun(wtPath) {
@@ -148,27 +188,29 @@ function hasActiveRun(wtPath) {
148
188
  }
149
189
 
150
190
  function classify(wt) {
151
- if (wt.locked) return 'skip-locked';
191
+ const verdict = (v, reason) => ({ verdict: v, reason });
192
+ if (wt.locked) return verdict('skip-locked');
152
193
  if (wt.prunable || !fs.existsSync(wt.path)) {
153
194
  // Stale: the checkout directory is already gone — clean by definition.
154
- return 'safe';
195
+ return verdict('safe');
155
196
  }
156
- if (wt.detached || !wt.branch) return 'skip-detached-head';
157
- if (isDirty(wt.path)) return 'skip-dirty';
158
- if (hasActiveRun(wt.path)) return 'skip-active';
197
+ if (wt.detached || !wt.branch) return verdict('skip-detached-head');
198
+ const status = dirtyState(wt.path);
199
+ if (status.dirty) return verdict('skip-dirty', status.reason);
200
+ if (hasActiveRun(wt.path)) return verdict('skip-active');
159
201
  const branchExists = git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${wt.branch}`]).status === 0;
160
- if (!branchExists) return 'safe'; // stale: branch gone/unborn, and clean
202
+ if (!branchExists) return verdict('safe'); // stale: branch gone/unborn, and clean
161
203
  if (mergedSet.has(wt.branch)) {
162
204
  // `git branch --merged` lists tip-merged branches; zero commits ahead is
163
205
  // implied — a merged tip IS zero-ahead. Double-check cheaply.
164
206
  const ahead = git(root, ['rev-list', '--count', `${defaultBranch}..${wt.branch}`]);
165
- if (ahead.status === 0 && Number(ahead.stdout.trim()) === 0) return 'safe';
207
+ if (ahead.status === 0 && Number(ahead.stdout.trim()) === 0) return verdict('safe');
166
208
  }
167
- return 'skip-active'; // branch carries unmerged work — a run may resume it
209
+ return verdict('skip-active'); // branch carries unmerged work — a run may resume it
168
210
  }
169
211
 
170
212
  // --- report + apply ---------------------------------------------------------
171
- const results = candidates.map((wt) => ({ ...wt, verdict: classify(wt) }));
213
+ const results = candidates.map((wt) => ({ ...wt, ...classify(wt) }));
172
214
 
173
215
  const mode = apply ? 'apply' : 'dry-run';
174
216
  console.log(`worktree-sweep (${mode}) — root: ${root}`);
@@ -178,13 +220,29 @@ if (results.length === 0) {
178
220
  }
179
221
  for (const r of results) {
180
222
  let line = `${r.path} branch=${r.branch ?? '(none)'} verdict=${r.verdict}`;
223
+ if (r.reason) line += ` reason=${r.reason}`;
181
224
  if (apply && r.verdict === 'safe') {
182
- const rm = spawnSync('git', ['-C', root, 'worktree', 'remove', r.path], {
183
- encoding: 'utf8',
184
- timeout: GIT_CALL_TIMEOUT_MS,
185
- killSignal: 'SIGKILL',
186
- });
187
- if (rm.status === 0) line += ' removed';
225
+ // W3-01: a stale entry (porcelain `prunable` or missing dir) has no tree
226
+ // left for `git worktree remove`, which fails outright on older git
227
+ // (exit 128, "fatal: '<path>' does not exist"). Drop the admin record via
228
+ // `git worktree prune` instead; `--expire=now` bypasses the default
229
+ // gc.worktreePruneExpire window. Prune skips locked worktrees — but a
230
+ // locked entry never reaches 'safe' anyway.
231
+ const stale = r.prunable || !fs.existsSync(r.path);
232
+ const rm = stale
233
+ ? spawnSync('git', ['-C', root, 'worktree', 'prune', '--expire=now'], {
234
+ encoding: 'utf8',
235
+ timeout: GIT_CALL_TIMEOUT_MS,
236
+ killSignal: 'SIGKILL',
237
+ })
238
+ : spawnSync('git', ['-C', root, 'worktree', 'remove', r.path], {
239
+ encoding: 'utf8',
240
+ timeout: GIT_CALL_TIMEOUT_MS,
241
+ killSignal: 'SIGKILL',
242
+ });
243
+ const stillRegistered = stale
244
+ && git(root, ['worktree', 'list', '--porcelain']).stdout.includes(`worktree ${r.path}\n`);
245
+ if (rm.status === 0 && !stillRegistered) line += ' removed';
188
246
  else line += ` remove-failed: ${(rm.stderr || rm.error?.message || '').trim() || 'unknown error'}`;
189
247
  }
190
248
  console.log(line);
@@ -497,13 +497,18 @@ export async function buildCompactThresholds(config = {}) {
497
497
  const hardCapTokens = negotiated
498
498
  ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
499
499
  : shippedHardCap;
500
- // An explicit operator tokenThreshold is honored as-is; otherwise the advisory phase is
501
- // derived from the cap it precedes, so a soft phase can never sit above the cap (150k of
502
- // advisory over a negotiated 100k cap would simply never fire).
500
+ // An explicit operator tokenThreshold is honoured but still clamped under the
501
+ // negotiated cap: an advisory soft/hard pair sitting at or above hardCapTokens can
502
+ // never fire, because context-hardcap-gate.sh already refuses tools at the cap
503
+ // (C92-H-01 — on a default install tokenThreshold is the shipped 150k default, not a
504
+ // real operator choice, so this is correcting a number, not overriding intent).
503
505
  const softThreshold = explicitSoftThreshold > 0
504
- ? Math.max(1, explicitSoftThreshold)
506
+ ? Math.max(1, Math.min(explicitSoftThreshold, hardCapTokens - 1))
505
507
  : Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
506
- const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
508
+ const hardThreshold = Math.min(
509
+ hardCapTokens,
510
+ Math.max(softThreshold + 1, Math.round(softThreshold * 1.6)),
511
+ );
507
512
  const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
508
513
 
509
514
  return {
@@ -773,6 +778,9 @@ export function resolveThresholdCompactBudget({
773
778
  // record's flat fields stay projected at the document's top level so readers that predate
774
779
  // this shape (raw JSON readers) still see a coherent state.
775
780
  const PRESSURE_SESSIONS_MAX = 8;
781
+ // C90-16: sessionless writers (omp tool events without session_id/transcript)
782
+ // accumulate here — never on the newest sibling's record.
783
+ const SESSIONLESS_PRESSURE_BUCKET = '_sessionless';
776
784
 
777
785
  function normalizeSessionId(value) {
778
786
  const id = typeof value === 'string' ? value.trim() : '';
@@ -793,12 +801,20 @@ function readPressureDocument(raw) {
793
801
  return raw && typeof raw === 'object' ? { default: raw } : {};
794
802
  }
795
803
 
796
- function pickPressureSession(sessions, sessionId) {
804
+ function pickPressureSession(sessions, sessionId, { forWrite = false } = {}) {
797
805
  if (sessionId) {
798
806
  // A session id that has no record yet starts clean — it must not inherit another
799
807
  // session's totals, and the other way round nothing here touches that session.
800
808
  return { id: sessionId, record: sessions[sessionId] ?? null };
801
809
  }
810
+ if (forWrite) {
811
+ // C90-16: a writer without a session id must never accumulate onto the
812
+ // newest sibling's record — cross-session pressure corruption survives a
813
+ // SessionStart reset and wedges that session's hard-cap gate. Sessionless
814
+ // mutations accumulate on a dedicated bucket instead, even when the
815
+ // document holds no records yet.
816
+ return { id: SESSIONLESS_PRESSURE_BUCKET, record: sessions[SESSIONLESS_PRESSURE_BUCKET] ?? null };
817
+ }
802
818
  const entries = Object.entries(sessions);
803
819
  if (!entries.length) {
804
820
  return { id: 'default', record: null };
@@ -835,11 +851,22 @@ export async function buildCompactPressureState(rawState = null, config = {}) {
835
851
  const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
836
852
  const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
837
853
  if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
838
- thresholds.softThreshold = rawSoftThreshold;
839
- thresholds.hardThreshold = rawHardThreshold > rawSoftThreshold
840
- ? rawHardThreshold
841
- : Math.max(rawSoftThreshold + 1, Math.round(rawSoftThreshold * 1.6));
842
- thresholds.baselineTokens = Math.max(120, Math.min(18_000, Math.round(thresholds.softThreshold * 0.18)));
854
+ // A persisted pair is only honoured while it still sits inside the negotiated cap.
855
+ // The record outlives capacity negotiation: an install that ran before the record
856
+ // existed, or before the model was verified, carries thresholds tuned for the
857
+ // shipped 500k ceiling — restoring them re-pins the advisory ABOVE the live
858
+ // hardCapTokens forever (C92-H-02). Falling through keeps the freshly derived
859
+ // pair, which is also what the rewritten record persists, so the file self-heals.
860
+ if (rawSoftThreshold < thresholds.hardCapTokens) {
861
+ thresholds.softThreshold = rawSoftThreshold;
862
+ thresholds.hardThreshold = Math.min(
863
+ thresholds.hardCapTokens,
864
+ rawHardThreshold > rawSoftThreshold
865
+ ? rawHardThreshold
866
+ : Math.max(rawSoftThreshold + 1, Math.round(rawSoftThreshold * 1.6)),
867
+ );
868
+ thresholds.baselineTokens = Math.max(120, Math.min(18_000, Math.round(thresholds.softThreshold * 0.18)));
869
+ }
843
870
  }
844
871
  const recentPrompts = (Array.isArray(rawState?.recentPrompts) ? rawState.recentPrompts : [])
845
872
  .map((entry) => normalizePromptEntry(entry))
@@ -1185,13 +1212,17 @@ export async function buildThresholdCompactPlan({
1185
1212
  const compacted = compactContextBlock(rawLines, {
1186
1213
  maxTokens: budget.maxTokens,
1187
1214
  maxLines: budget.maxLines,
1215
+ // Guardrail anchors are listed FIRST: under hard pressure the block budget may
1216
+ // not fit every anchor, and the lines that protect the user (ask-before-drop)
1217
+ // and the session (memory bridge) are the ones that must never be the tail
1218
+ // casualty (C92-H-03).
1188
1219
  anchorLines: [
1189
1220
  thresholdLine,
1221
+ askBeforeDropLine,
1222
+ memoryBridgeLine,
1190
1223
  routeLine,
1191
1224
  previousLine,
1192
1225
  outputLine,
1193
- memoryBridgeLine,
1194
- askBeforeDropLine,
1195
1226
  ].filter(Boolean),
1196
1227
  maxAnchors: 6,
1197
1228
  });
@@ -1208,6 +1239,7 @@ export async function buildThresholdCompactPlan({
1208
1239
  tokensBefore: compacted.tokensBefore,
1209
1240
  tokensAfter: compacted.tokensAfter,
1210
1241
  savedTokens: compacted.savedTokens,
1242
+ missingAnchors: compacted.missingAnchors ?? [],
1211
1243
  };
1212
1244
  }
1213
1245
 
@@ -1232,7 +1264,7 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1232
1264
  const scopedConfig = scopeCapacityConfig(config, projectRoot);
1233
1265
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1234
1266
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1235
- const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1267
+ const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
1236
1268
  const current = await buildCompactPressureState(record, scopedConfig);
1237
1269
  const next = await mutator(current);
1238
1270
  const normalized = await buildCompactPressureState(next, scopedConfig);
@@ -1247,7 +1279,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
1247
1279
  const scopedConfig = scopeCapacityConfig(config, projectRoot);
1248
1280
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1249
1281
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1250
- const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1282
+ const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
1251
1283
  const normalized = await buildCompactPressureState(state, scopedConfig);
1252
1284
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1253
1285
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
@@ -1086,33 +1086,48 @@ async function mergeContinuationSidecar(ledger, target) {
1086
1086
  // Reads the merged ledger view (main file + existing sidecar) so consecutive ticks
1087
1087
  // accumulate even while the main lock stays busy. Returns { ok } — a sidecar write
1088
1088
  // failure means the tick is genuinely lost and the caller reports the rejection.
1089
- async function bumpContinuationSidecar(target, event) {
1090
- try {
1091
- const base = (await mergeContinuationSidecar(await readJson(target, null), target)) || {};
1092
- const applied = event.type === 'notified'
1093
- ? { ...base, notified: true, updatedAt: Date.now() }
1094
- : applyContinuationToLedger(base, event);
1095
- // The merge gate requires sidecarAt > ledgerAt. A notified tick inherits the
1096
- // base stamp unchanged, so it must be bumped past the base it was computed
1097
- // from — otherwise the tick is invisible to every evaluator and the cap's
1098
- // final notice loops forever while contention persists. The +1 floor also
1099
- // covers a continuation landing in the same millisecond as the base stamp.
1100
- const baseAt = Number(base.lastContinuationAt) || 0;
1101
- const stampedAt = Math.max(Number(applied.lastContinuationAt) || 0, baseAt + 1);
1102
- await writeJsonAtomic(continuationSidecarPathFor(target), {
1103
- requestKey: event?.requestKey ?? applied.requestKey ?? applied.continuationRequestKey ?? null,
1104
- promptKey: event?.promptKey ?? applied.promptKey ?? null,
1105
- continuationCount: Number(applied.continuationCount) || 0,
1106
- noProgressCount: Number(applied.noProgressCount) || 0,
1107
- lastProgressDigest: applied.lastProgressDigest ?? null,
1108
- notified: applied.notified === true,
1109
- lastContinuationAt: stampedAt,
1110
- updatedAt: Date.now(),
1111
- });
1112
- return { ok: true };
1113
- } catch {
1114
- return { ok: false };
1115
- }
1089
+ // W3-LED1: the RMW runs under a sidecar-local lock — two racing continuations
1090
+ // must both tick, not clobber each other's read-merge-write. The main ledger
1091
+ // lock stays unavailable here (that is why this path runs), so the sidecar
1092
+ // lock is the ONLY lock taken: lock order sidecar-alone can never deadlock
1093
+ // with the main→journal order. A busy sidecar lock fails closed — the tick is
1094
+ // reported rejected rather than written unlocked over a holder's mid-flight
1095
+ // merge — and the caller reports the rejection.
1096
+ async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {}) {
1097
+ const sidecarPath = continuationSidecarPathFor(target);
1098
+ const outcome = await withAsyncLock(
1099
+ sidecarPath,
1100
+ { signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
1101
+ async () => {
1102
+ try {
1103
+ const base = (await mergeContinuationSidecar(await readJson(target, null), target)) || {};
1104
+ const applied = event.type === 'notified'
1105
+ ? { ...base, notified: true, updatedAt: Date.now() }
1106
+ : applyContinuationToLedger(base, event);
1107
+ // The merge gate requires sidecarAt > ledgerAt. A notified tick inherits the
1108
+ // base stamp unchanged, so it must be bumped past the base it was computed
1109
+ // from — otherwise the tick is invisible to every evaluator and the cap's
1110
+ // final notice loops forever while contention persists. The +1 floor also
1111
+ // covers a continuation landing in the same millisecond as the base stamp.
1112
+ const baseAt = Number(base.lastContinuationAt) || 0;
1113
+ const stampedAt = Math.max(Number(applied.lastContinuationAt) || 0, baseAt + 1);
1114
+ await writeJsonAtomic(sidecarPath, {
1115
+ requestKey: event?.requestKey ?? applied.requestKey ?? applied.continuationRequestKey ?? null,
1116
+ promptKey: event?.promptKey ?? applied.promptKey ?? null,
1117
+ continuationCount: Number(applied.continuationCount) || 0,
1118
+ noProgressCount: Number(applied.noProgressCount) || 0,
1119
+ lastProgressDigest: applied.lastProgressDigest ?? null,
1120
+ notified: applied.notified === true,
1121
+ lastContinuationAt: stampedAt,
1122
+ updatedAt: Date.now(),
1123
+ });
1124
+ return { ok: true };
1125
+ } catch {
1126
+ return { ok: false };
1127
+ }
1128
+ },
1129
+ );
1130
+ return outcome.ok === true ? outcome.value : { ok: false };
1116
1131
  }
1117
1132
 
1118
1133
  function newEventId() {
@@ -1998,9 +2013,27 @@ export async function recordLedgerEvent(event, {
1998
2013
  if (drained.commit) await drained.commit();
1999
2014
  // The continuation sidecar was already merged into `current` (and therefore into
2000
2015
  // the ledger just written): its ticks are now durable state, so the sidecar is
2001
- // retired. A tick landing between the read and this rm is lost — same accepted
2002
- // window as the journal drain — but the breaker still advances.
2003
- try { await fs.rm(continuationSidecarPathFor(target), { force: true }); } catch {}
2016
+ // retired. W3-LED1: the retire runs under the sidecar-local lock and re-merges
2017
+ // a tick that landed between the ledger read and the write — removing the
2018
+ // sidecar unlocked deleted a durable tick outright. A busy sidecar lock skips
2019
+ // the retire entirely: the file re-merges idempotently on the next read
2020
+ // (merge gate sidecarAt > ledgerAt, notified OR-ed) and the next commit retires it.
2021
+ try {
2022
+ await withAsyncLock(
2023
+ continuationSidecarPathFor(target),
2024
+ { signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
2025
+ async () => {
2026
+ const base = await readJson(target, null);
2027
+ const remerged = await mergeContinuationSidecar(base, target);
2028
+ if (remerged && remerged !== base) {
2029
+ await writeJsonAtomic(target, remerged);
2030
+ }
2031
+ try { await fs.rm(continuationSidecarPathFor(target), { force: true }); } catch {}
2032
+ },
2033
+ );
2034
+ } catch {
2035
+ // retire is best-effort post-commit; the sidecar re-merges idempotently
2036
+ }
2004
2037
  return { committed: true, eventId, value, drained: drained.applied, quarantined: drained.quarantined };
2005
2038
  });
2006
2039
  if (outcome.ok) {
@@ -56,21 +56,55 @@ const matchBrace = (s, open) => {
56
56
  return -1;
57
57
  };
58
58
 
59
- // Find `"key"` used as an object key inside region [lo, hi); returns the index
60
- // of its `:` or -1. A bare `"key"` inside a string value cannot produce this
61
- // shape (its quotes are escaped), and non-key uses lack the `:` — both are
62
- // skipped by scanning forward.
59
+ // Index of the closer matching the `{`/`[` at `open` (string-aware, mixed
60
+ // brackets), or -1 when unclosed/mismatched. A strict mismatch also aborts:
61
+ // depth bookkeeping past it would be meaningless.
62
+ const matchEnclosure = (s, open) => {
63
+ const stack = [];
64
+ for (let i = open; i < s.length; i += 1) {
65
+ const c = s[i];
66
+ if (c === '"') {
67
+ const end = scanStringEnd(s, i);
68
+ if (end === -1) return -1;
69
+ i = end;
70
+ continue;
71
+ }
72
+ if (c === '{' || c === '[') { stack.push(c); continue; }
73
+ if (c === '}' || c === ']') {
74
+ const top = stack.pop();
75
+ if ((c === '}' && top !== '{') || (c === ']' && top !== '[')) return -1;
76
+ if (stack.length === 0) return i;
77
+ }
78
+ }
79
+ return -1;
80
+ };
81
+
82
+ // BUG-C90-14 (FR-002): find `"key"` at THIS object's depth inside [lo, hi) —
83
+ // the region bounds are the parent object's braces, so every candidate here is
84
+ // top-level by construction. A same-named key inside a NESTED value (object or
85
+ // array) must be skipped structurally, never matched: the previous flat
86
+ // indexOf scan let `{"nested":{"command":"echo hi"},"command":"rm -rf /"}`
87
+ // salvage "echo hi" instead of the real outer field (fail-open on the deny
88
+ // gates). Returns the index of the key's `:` or -1.
63
89
  const findKey = (s, key, lo, hi) => {
64
90
  const needle = `"${key}"`;
65
- let pos = s.indexOf(needle, lo);
66
- while (pos !== -1 && pos < hi) {
67
- const colon = skipWs(s, pos + needle.length);
68
- if (colon < hi && colon < s.length && s[colon] === ':') {
69
- const prev = pos - 1;
70
- const pc = prev >= 0 ? s[prev] : '';
71
- if (prev < 0 || pc === '{' || pc === ',' || isWs(pc)) return colon;
91
+ for (let i = lo; i < hi; i += 1) {
92
+ const c = s[i];
93
+ if (c === '"') {
94
+ if (s.startsWith(needle, i)) {
95
+ const colon = skipWs(s, i + needle.length);
96
+ if (colon < hi && s[colon] === ':') return colon;
97
+ }
98
+ const end = scanStringEnd(s, i);
99
+ if (end === -1) return -1; // truncated string — nothing provable beyond
100
+ i = end;
101
+ continue;
102
+ }
103
+ if (c === '{' || c === '[') {
104
+ const close = matchEnclosure(s, i);
105
+ if (close === -1 || close >= hi) return -1;
106
+ i = close;
72
107
  }
73
- pos = s.indexOf(needle, pos + 1);
74
108
  }
75
109
  return -1;
76
110
  };
@@ -84,7 +118,9 @@ export function salvageField(text, dotPath) {
84
118
  .filter(Boolean);
85
119
  if (!data || data[0] !== '{' || dotted.length === 0 || dotted.length > 4) return INCOMPLETE;
86
120
 
87
- let regionLo = 0;
121
+ // The scan region is INSIDE the current object: [lo, hi) bounds the braces,
122
+ // so start after the opening `{` (data[0] === '{' is guaranteed above).
123
+ let regionLo = 1;
88
124
  let regionHi = data.length;
89
125
  for (let k = 0; k < dotted.length; k += 1) {
90
126
  const colon = findKey(data, dotted[k], regionLo, regionHi);
@@ -233,26 +233,61 @@ const matchBrace = (s, open) => {
233
233
  }
234
234
  return -1;
235
235
  };
236
- // Find `"key"` used as an object key inside region [lo, hi); returns the index
237
- // of its `:` or -1. A bare `"key"` inside a string value cannot produce this
238
- // shape (its quotes are escaped), and non-key uses lack the `:` — both are
239
- // skipped by scanning forward.
236
+ // Index of the closer matching the `{`/`[` at `open` (string-aware, mixed
237
+ // brackets), or -1 when unclosed/mismatched. A strict mismatch also aborts:
238
+ // depth bookkeeping past it would be meaningless.
239
+ const matchEnclosure = (s, open) => {
240
+ const stack = [];
241
+ for (let i = open; i < s.length; i += 1) {
242
+ const c = s[i];
243
+ if (c === '"') {
244
+ const end = scanStringEnd(s, i);
245
+ if (end === -1) return -1;
246
+ i = end;
247
+ continue;
248
+ }
249
+ if (c === '{' || c === '[') { stack.push(c); continue; }
250
+ if (c === '}' || c === ']') {
251
+ const top = stack.pop();
252
+ if ((c === '}' && top !== '{') || (c === ']' && top !== '[')) return -1;
253
+ if (stack.length === 0) return i;
254
+ }
255
+ }
256
+ return -1;
257
+ };
258
+ // BUG-C90-14 (FR-002): find `"key"` at THIS object's depth inside [lo, hi) —
259
+ // the region bounds are the parent object's braces, so every candidate here is
260
+ // top-level by construction. A same-named key inside a NESTED value (object or
261
+ // array) must be skipped structurally, never matched: the previous flat
262
+ // indexOf scan let `{"nested":{"command":"echo hi"},"command":"rm -rf /"}`
263
+ // salvage "echo hi" instead of the real outer field (fail-open on the deny
264
+ // gates). Returns the index of the key's `:` or -1.
240
265
  const findKey = (s, key, lo, hi) => {
241
266
  const needle = `"${key}"`;
242
- let pos = s.indexOf(needle, lo);
243
- while (pos !== -1 && pos < hi) {
244
- const colon = skipWs(s, pos + needle.length);
245
- if (colon < hi && colon < s.length && s[colon] === ':') {
246
- const prev = pos - 1;
247
- const pc = prev >= 0 ? s[prev] : '';
248
- if (prev < 0 || pc === '{' || pc === ',' || isWs(pc)) return colon;
267
+ for (let i = lo; i < hi; i += 1) {
268
+ const c = s[i];
269
+ if (c === '"') {
270
+ if (s.startsWith(needle, i)) {
271
+ const colon = skipWs(s, i + needle.length);
272
+ if (colon < hi && s[colon] === ':') return colon;
273
+ }
274
+ const end = scanStringEnd(s, i);
275
+ if (end === -1) return -1; // truncated string — nothing provable beyond
276
+ i = end;
277
+ continue;
278
+ }
279
+ if (c === '{' || c === '[') {
280
+ const close = matchEnclosure(s, i);
281
+ if (close === -1 || close >= hi) return -1;
282
+ i = close;
249
283
  }
250
- pos = s.indexOf(needle, pos + 1);
251
284
  }
252
285
  return -1;
253
286
  };
254
287
 
255
- let regionLo = 0;
288
+ // The scan region is INSIDE the current object: [lo, hi) bounds the braces, so
289
+ // start after the opening `{` (data[0] === '{' is guaranteed above).
290
+ let regionLo = 1;
256
291
  let regionHi = data.length;
257
292
  for (let k = 0; k < dotted.length; k += 1) {
258
293
  const colon = findKey(data, dotted[k], regionLo, regionHi);