@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
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { withFileLock } from '../fileOps.js';
6
6
  import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from '../../index/paths.js';
7
7
  import { loadRuntimeConfig } from '../runtimeConfig.js';
8
- import { createEdge, getSemanticProvider, IndexFileSyntaxProvider } from './providers.js';
8
+ import { createEdge, getSemanticProvider } from './providers.js';
9
9
  import { createSemanticProvider } from './semanticProvider.js';
10
10
  import { createEmbeddingProvider } from './vectorProvider.js';
11
11
 
@@ -14,6 +14,13 @@ import { createEmbeddingProvider } from './vectorProvider.js';
14
14
  // codeIntel.retriever.weights). merge:'concat' preserves the exact C26
15
15
  // sequential ordering as an escape hatch. A missing/failing lane is skipped
16
16
  // and the reason lands in `omitted` — never fatal, never fabricated anchors.
17
+ // C92-F-04: `fileSet` is the path allow-list built from files.json, and every
18
+ // lane applies the same rule — a path is accepted iff fileSet.has(path). An
19
+ // UNREADABLE files.json (missing or schema-mismatched) makes the gate
20
+ // unavailable: retrieve() refuses and names the artifact in `omitted` instead
21
+ // of treating an empty allow-list as "allow everything" (fail closed, not
22
+ // fail open). A readable-but-empty files.json is a genuinely empty universe —
23
+ // also closed — which is distinguishable via the `omitted` entries.
17
24
 
18
25
  const DEFAULT_LIMIT = 20;
19
26
  const DEFAULT_BM25 = { k1: 1.2, b: 0.75 };
@@ -123,7 +130,7 @@ function symbolLane(symbolItems, normalizedQuery, fileSet) {
123
130
  for (const sym of symbolItems) {
124
131
  if (!sym?.name || !sym?.filePath || seen.has(sym.filePath)) continue;
125
132
  if (sym.name === normalizedQuery || sym.name.toLowerCase() === normalizedQuery.toLowerCase()) {
126
- if (fileSet.size === 0 || fileSet.has(sym.filePath)) {
133
+ if (fileSet.has(sym.filePath)) {
127
134
  seen.add(sym.filePath);
128
135
  lane.push(laneEntry(
129
136
  sym.filePath,
@@ -136,7 +143,7 @@ function symbolLane(symbolItems, normalizedQuery, fileSet) {
136
143
  return lane;
137
144
  }
138
145
 
139
- function bm25Lane(fileItems, symbolItems, queryTokens, bm25) {
146
+ function bm25Lane(fileItems, symbolItems, queryTokens, bm25, fileSet) {
140
147
  if (queryTokens.length === 0) return null;
141
148
  const docs = new Map();
142
149
  for (const item of fileItems) {
@@ -144,7 +151,9 @@ function bm25Lane(fileItems, symbolItems, queryTokens, bm25) {
144
151
  docs.set(item.filePath, tokenize(item.filePath));
145
152
  }
146
153
  for (const sym of symbolItems) {
147
- if (!sym?.filePath) continue;
154
+ // C92-F-04: symbol rows for paths outside the files.json universe must
155
+ // not create emit-table docs — the gate is fileSet.has(path) for all lanes.
156
+ if (!sym?.filePath || !fileSet.has(sym.filePath)) continue;
148
157
  const doc = docs.get(sym.filePath) ?? [];
149
158
  doc.push(...tokenize(sym.name));
150
159
  docs.set(sym.filePath, doc);
@@ -189,7 +198,7 @@ async function semanticLane(rootDir, normalizedQuery, config, fileSet, snapshotI
189
198
  if (!target) continue;
190
199
  const rel = path.isAbsolute(target) ? normalizeRelative(rootDir, target) : target;
191
200
  if (seen.has(rel)) continue;
192
- if (fileSet.size > 0 && !fileSet.has(rel)) continue;
201
+ if (!fileSet.has(rel)) continue;
193
202
  seen.add(rel);
194
203
  lane.push(laneEntry(rel, `semantic ${edge.kind ?? 'match'} for "${normalizedQuery}"`));
195
204
  }
@@ -199,7 +208,7 @@ async function semanticLane(rootDir, normalizedQuery, config, fileSet, snapshotI
199
208
  // Vector lane (SPEC §2/§3): dep-free hashed embedding provider; mirrors the
200
209
  // semantic lane. Edges' `to` carries "file:0" — reduce to a ranked file list.
201
210
  async function vectorLane(rootDir, normalizedQuery, config, fileSet, snapshotId) {
202
- const provider = createEmbeddingProvider({ projectRoot: rootDir, config });
211
+ const provider = embeddingProviderFor(rootDir, config);
203
212
  if (!provider || provider.name === 'null' || typeof provider.resolve !== 'function') {
204
213
  return { lane: null, why: 'unavailable' };
205
214
  }
@@ -222,13 +231,37 @@ async function vectorLane(rootDir, normalizedQuery, config, fileSet, snapshotId)
222
231
  if (!target) continue;
223
232
  const rel = path.isAbsolute(target) ? normalizeRelative(rootDir, target) : target;
224
233
  if (seen.has(rel)) continue;
225
- if (fileSet.size > 0 && !fileSet.has(rel)) continue;
234
+ if (!fileSet.has(rel)) continue;
226
235
  seen.add(rel);
227
236
  lane.push(laneEntry(rel, `vector ${edge.kind ?? 'match'} for "${normalizedQuery}"`));
228
237
  }
229
238
  return { lane, why: lane.length === 0 ? 'no-match' : null };
230
239
  }
231
240
 
241
+ // C92-F-10: reuse the embedding provider across retrieve() calls for the same
242
+ // (projectRoot, embedding config) so its doc-vector cache survives between
243
+ // queries — a fresh provider per call re-embeds the whole corpus every time.
244
+ // Bounded: insertion-ordered LRU over at most PROVIDER_CACHE_MAX instances.
245
+ const PROVIDER_CACHE_MAX = 16;
246
+ const embeddingProviders = new Map();
247
+
248
+ function embeddingProviderFor(rootDir, config) {
249
+ const embedding = config?.codeIntel?.embedding;
250
+ const key = `${rootDir}\n${embedding?.provider ?? 'hashed'}\n${embedding?.dimensions ?? 'default'}`;
251
+ let provider = embeddingProviders.get(key);
252
+ if (provider) {
253
+ embeddingProviders.delete(key);
254
+ embeddingProviders.set(key, provider);
255
+ return provider;
256
+ }
257
+ provider = createEmbeddingProvider({ projectRoot: rootDir, config });
258
+ embeddingProviders.set(key, provider);
259
+ if (embeddingProviders.size > PROVIDER_CACHE_MAX) {
260
+ embeddingProviders.delete(embeddingProviders.keys().next().value);
261
+ }
262
+ return provider;
263
+ }
264
+
232
265
  function rrfMerge(lanes, weights, rrfK) {
233
266
  const scores = new Map();
234
267
  const meta = new Map();
@@ -361,6 +394,17 @@ export async function retrieve(projectRoot, query, { mode = 'search', limit, sna
361
394
  return { anchors: [], evidence: [], relations: [], omitted };
362
395
  }
363
396
 
397
+ // C92-F-04 (fail closed): files.json is the path allow-list. When it is
398
+ // missing or schema-mismatched the gate is UNAVAILABLE, and an empty
399
+ // allow-list must not be read as "allow everything" — refuse the retrieval
400
+ // and name the unusable artifact so callers can distinguish a complete
401
+ // result from a degraded one.
402
+ if (!filesArtifact) {
403
+ omitted.push({ what: INDEX_ARTIFACTS.files, why: 'index-unavailable' });
404
+ omitted.push({ what: 'retrieval', why: 'path-gate-unavailable' });
405
+ return { anchors: [], evidence: [], relations: [], omitted };
406
+ }
407
+
364
408
  const fileItems = filesArtifact?.items ?? [];
365
409
  const symbolItems = symbolsArtifact?.items ?? [];
366
410
  const fileSet = new Set(fileItems.map((f) => f.filePath));
@@ -374,26 +418,15 @@ export async function retrieve(projectRoot, query, { mode = 'search', limit, sna
374
418
  const exact = exactLane(fileItems, normalizedQuery, queryBase);
375
419
  if (exact.length > 0) lanes.set('exact', exact);
376
420
 
377
- // Lane 2 — symbol-name match via the syntax provider (index-file fallback).
378
- const provider = new IndexFileSyntaxProvider({ rootDir });
421
+ // Lane 2 — symbol-name match against the symbols.json universe.
379
422
  if (!symbolsArtifact) {
380
423
  omitted.push({ what: 'symbol-lane', why: 'index-missing' });
381
424
  }
382
425
  const symbol = symbolLane(symbolItems, normalizedQuery, fileSet);
383
426
  if (symbol.length > 0) lanes.set('symbol', symbol);
384
- // Enrich anchors with provider symbols when the lane matched (best-effort).
385
- for (const entry of symbol) {
386
- try {
387
- if (provider.supports(entry.path)) {
388
- await provider.symbols(entry.path);
389
- }
390
- } catch {
391
- // Provider enrichment is best-effort; anchors already stand.
392
- }
393
- }
394
427
 
395
428
  // Lane 3 — BM25-lite over file/symbol tokens.
396
- const bm25 = bm25Lane(fileItems, symbolItems, queryTokens, defaults.bm25);
429
+ const bm25 = bm25Lane(fileItems, symbolItems, queryTokens, defaults.bm25, fileSet);
397
430
  if (bm25 === null) {
398
431
  if (exact.length === 0 && symbol.length === 0) {
399
432
  omitted.push({ what: 'bm25-lane', why: 'empty-query-tokens' });
@@ -35,7 +35,13 @@ const TRIVIAL_RE = /\b(typo|label|rename|spacing|toggle|whitespace|comment fix|s
35
35
  const ERROR_RE = /\b(error|exception|stack ?trace|bug|crash|failing|fails|failure|regression|TypeError|ReferenceError|SyntaxError|ENOENT|segfault)\b/i;
36
36
  const EXPLORE_RE = /\b(where|how does|how do|find|explore|understand|what does|which file|locate|handled)\b/i;
37
37
  const DEEP_FLOW_RE = /\b(refactor|migrate|migration|flow|end[- ]?to[- ]?end|trace|across|pipeline|lifecycle)\b/i;
38
- const ANALOGY_RE = /\b(similar to|analogy|like\b|same as|pattern|equivalent)\b/i;
38
+ // C92-F-09: bare `like\b` matched the most common English filler verb ("I
39
+ // would like to…"), misrouting everyday prompts to analogy. Keep only
40
+ // comparative uses: quantifier + like ("something/anything like X") and
41
+ // behaviour-verb + like ("behaves/looks/works like X"). "like <determiner>"
42
+ // is deliberately excluded — "I'd like the docs updated" is a polite request,
43
+ // not a similarity probe.
44
+ const ANALOGY_RE = /\b(similar to|analogy|same as|pattern|equivalent)\b|\b(?:something|anything|nothing) like\b|\b(?:looks?|behav(?:e|es)|acts?|works?|performs?|functions?|feels?) like\b/i;
39
45
  const PATH_RE = /(?:[\w.-]+\/)+[\w.-]+|[\w.-]+\.(?:js|ts|mjs|cjs|jsx|tsx|py|rb|go|rs|java|md|json|yml|yaml|css|html|sh)\b/gi;
40
46
 
41
47
  // English slash-words that look like paths to PATH_RE but never are —
@@ -90,24 +96,28 @@ function decide(prompt, { hasError = false, filesHinted } = {}) {
90
96
  return { mode: 'impact', reasons };
91
97
  }
92
98
 
93
- // Rule 5 — open-ended exploration wording.
99
+ // Rule 5 — analogy / pattern-search wording (C92-F-09): similarity intent is
100
+ // the more specific signal, so it wins over generic explore/find wording and
101
+ // over cross-cutting deep_flow wording. The regex no longer matches a bare
102
+ // verb "like" — only comparative idioms ("similar to", "same as", "something
103
+ // like X", "behaves like X").
104
+ if (ANALOGY_RE.test(prompt)) {
105
+ reasons.push('similar-to / analogy pattern-search wording');
106
+ return { mode: 'analogy', reasons };
107
+ }
108
+
109
+ // Rule 6 — open-ended exploration wording.
94
110
  if (EXPLORE_RE.test(prompt)) {
95
111
  reasons.push('open-ended where/how/find/explore wording');
96
112
  return { mode: 'explore', reasons };
97
113
  }
98
114
 
99
- // Rule 6 — cross-cutting refactor/migrate/flow wording.
115
+ // Rule 7 — cross-cutting refactor/migrate/flow wording.
100
116
  if (DEEP_FLOW_RE.test(prompt)) {
101
117
  reasons.push('cross-cutting refactor/migrate/flow wording');
102
118
  return { mode: 'deep_flow', reasons };
103
119
  }
104
120
 
105
- // Rule 7 — analogy / pattern search wording.
106
- if (ANALOGY_RE.test(prompt)) {
107
- reasons.push('similar-to / analogy pattern-search wording');
108
- return { mode: 'analogy', reasons };
109
- }
110
-
111
121
  // Rule 8 — fallback: single-file verb (single hinted file or lone path mention).
112
122
  if (hintedFiles.length === 1 || hasPathMention(prompt)) {
113
123
  reasons.push('fallback: single-file hint');
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  import { getSyntaxProvider, IndexFileSyntaxProvider } from './providers.js';
5
- import { resolveProjectRelativePath } from '../fileOps.js';
5
+ import { resolveProjectPathReal } from '../fileOps.js';
6
6
 
7
7
  // Deterministic extractive summaries (SPEC §7/§8, CI-304) — the L3 detail tier.
8
8
  // No model calls, no clock/random, no new deps. File reads are bounded to the
@@ -18,7 +18,9 @@ const DECL_RE = /^\s*(?:export\s+default\s+|export\s+)?(?:async\s+)?(?:function\
18
18
 
19
19
  async function readHead(rootDir, relPath) {
20
20
  try {
21
- const abs = resolveProjectRelativePath(rootDir, relPath);
21
+ // Post-realpath containment: a symlinked path that passes the lexical
22
+ // check but resolves outside the root must never reach fs.readFile.
23
+ const abs = await resolveProjectPathReal(rootDir, relPath);
22
24
  if (!abs) return null;
23
25
  const content = await fs.readFile(abs, 'utf8');
24
26
  return { content, lines: content.split('\n').slice(0, MAX_READ_LINES), bytes: Buffer.byteLength(content, 'utf8') };
@@ -54,7 +56,6 @@ function docblockFirstSentence(lines, endIndex = lines.length) {
54
56
  }
55
57
  }
56
58
  const text = block.join(' ').replace(/\s+/g, ' ').trim();
57
- if (!text) return null;
58
59
  const m = text.match(/^.+?[.!?](?:\s|$)/);
59
60
  return (m ? m[0] : text).trim();
60
61
  }
@@ -9,9 +9,19 @@ import { createEdge } from './providers.js';
9
9
  // no deps — real embeddings plug in later through the injectable `loader`
10
10
  // seam (config.codeIntel.embedding.provider === 'auto'), degrading to the
11
11
  // hashed provider on ANY failure. Never throws.
12
+ // C92-F-10: corpus doc vectors are cached on the provider instance (bounded —
13
+ // EMBED_CACHE_MAX docs) and scoring streams docs one at a time, so a query no
14
+ // longer re-embeds the corpus or holds every vector live at once. Retrievals
15
+ // share one provider per (rootDir, embedding config) via retriever.js
16
+ // embeddingProviderFor, which is what lets this cache pay off across calls.
12
17
 
13
18
  const DEFAULT_DIMENSIONS = 256;
14
19
  const DEFAULT_LIMIT = 20;
20
+ // Doc-vector cache bound (C92-F-10): ~2 KiB per cached 256-dim vector, so the
21
+ // cap keeps the cache near ~8 MiB per provider instance. On overflow the cache
22
+ // resets (doc texts are content-addressed, so eviction can only cost a
23
+ // re-embed — never a wrong score).
24
+ const EMBED_CACHE_MAX = 4096;
15
25
 
16
26
  function fnv1a(str) {
17
27
  let h = 0x811c9dc5;
@@ -97,6 +107,19 @@ export class HashedEmbeddingProvider {
97
107
  this._embedFn = typeof embedFn === 'function' ? embedFn : null;
98
108
  }
99
109
 
110
+ // Doc-vector cache: docText → vector. Doc texts are content-addressed, so a
111
+ // stale key can never alias onto different content; index churn only costs a
112
+ // re-embed of the changed doc text.
113
+ async #embedDoc(text) {
114
+ if (!this._docVec) this._docVec = new Map();
115
+ const cached = this._docVec.get(text);
116
+ if (cached) return cached;
117
+ const vec = await this.embed(text);
118
+ if (this._docVec.size >= EMBED_CACHE_MAX) this._docVec.clear();
119
+ this._docVec.set(text, vec);
120
+ return vec;
121
+ }
122
+
100
123
  async available() {
101
124
  return true;
102
125
  }
@@ -139,10 +162,13 @@ export class HashedEmbeddingProvider {
139
162
  docs.set(sym.filePath, `${docs.get(sym.filePath) ?? sym.filePath} ${sym.name}`);
140
163
  }
141
164
  const snapshot = ctx?.snapshot ?? null;
142
- const scored = await Promise.all([...docs.entries()].map(async ([filePath, docText]) => ({
143
- filePath,
144
- score: cosineSimilarity(queryVec, await this.embed(docText)),
145
- })));
165
+ // C92-F-10: score docs one at a time (cached vectors reused; at most one
166
+ // embed is ever in flight) rather than Promise.all over the corpus, which
167
+ // kept every 256-float vector live simultaneously.
168
+ const scored = [];
169
+ for (const [filePath, docText] of docs) {
170
+ scored.push({ filePath, score: cosineSimilarity(queryVec, await this.#embedDoc(docText)) });
171
+ }
146
172
  return scored
147
173
  .filter((entry) => entry.score > 0)
148
174
  .sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
@@ -101,12 +101,19 @@ export function compactContextBlock(
101
101
  forceFirstCount: (header ? 1 : 0) + normalizedAnchorLines.length,
102
102
  },
103
103
  ).join('\n').trim();
104
- validationMode = 'forced-anchors';
104
+ // 'forced-anchors' means every anchor is present — verify it. When the block
105
+ // budget cannot hold them all, the mode must report the shortfall instead of
106
+ // claiming an anchor-complete block that silently dropped the tail anchors
107
+ // (C92-H-03: the dropped tail used to be memory-bridge + ask-before-drop).
108
+ validationMode = findMissingAnchors(text, normalizedAnchorLines).length === 0
109
+ ? 'forced-anchors'
110
+ : 'anchor-shortfall';
105
111
  }
106
112
  }
107
113
 
108
114
  const tokensBefore = estimateTokenCount(rawText);
109
115
  const tokensAfter = estimateTokenCount(text);
116
+ const missingAnchors = findMissingAnchors(text, normalizedAnchorLines);
110
117
 
111
118
  return {
112
119
  text,
@@ -115,6 +122,7 @@ export function compactContextBlock(
115
122
  savedTokens: Math.max(0, tokensBefore - tokensAfter),
116
123
  validationMode,
117
124
  anchorCount: normalizedAnchorLines.length,
125
+ missingAnchors,
118
126
  };
119
127
  }
120
128
 
@@ -167,6 +175,7 @@ function buildCompactedContextLines(
167
175
  const seen = new Set();
168
176
  let usedTokens = 0;
169
177
  let nonEmptyCount = 0;
178
+ let forcedRemaining = forceFirstCount;
170
179
 
171
180
  for (const line of sourceLines) {
172
181
  const compressed = compressLine(line);
@@ -180,12 +189,18 @@ function buildCompactedContextLines(
180
189
  }
181
190
 
182
191
  const tokens = estimateTokenCount(compressed);
183
- const forceLine = nonEmptyCount < forceFirstCount;
192
+ // Forced lines (header + anchors) are the first `forceFirstCount` distinct
193
+ // non-empty source lines: they bypass the line cap and the soft token budget —
194
+ // dropping a forced anchor would silently break the anchor contract this
195
+ // function exists to enforce. The quota is consumed on the candidate itself,
196
+ // whether or not it fits, so a dropped forced anchor never slides its slot
197
+ // onto a following content line.
198
+ const forceLine = forcedRemaining > 0;
199
+ if (forceLine) {
200
+ forcedRemaining -= 1;
201
+ }
184
202
  const wouldExceedLines = nonEmptyCount >= maxLines;
185
203
 
186
- // Forced lines (header + anchors) are guaranteed placement: they bypass the
187
- // line cap — dropping a forced anchor would silently break the anchor
188
- // contract this function exists to enforce.
189
204
  if ((!forceLine && wouldExceedLines) || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
190
205
  break;
191
206
  }
@@ -195,8 +210,11 @@ function buildCompactedContextLines(
195
210
  : compressed;
196
211
  const selectedTokens = estimateTokenCount(selectedLine);
197
212
 
213
+ // A forced line whose remaining slice of hardTokenCap is empty used to break
214
+ // the loop, dropping every anchor after it. Keep going so later anchors still
215
+ // get their chance — the caller reports whichever anchors never landed.
198
216
  if (!selectedLine) {
199
- break;
217
+ continue;
200
218
  }
201
219
 
202
220
  selected.push(selectedLine);
@@ -204,7 +222,6 @@ function buildCompactedContextLines(
204
222
  usedTokens += selectedTokens;
205
223
  nonEmptyCount += 1;
206
224
  }
207
-
208
225
  if (selected.length === 0) {
209
226
  return compressMarkdownLines(sourceLines, {
210
227
  maxTokens,
@@ -469,12 +469,18 @@ export async function buildCompactThresholds(config = {}) {
469
469
  const hardCapTokens = negotiated
470
470
  ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
471
471
  : shippedHardCap;
472
- // An explicit operator tokenThreshold is honored as-is; otherwise the advisory phase is
473
- // derived from the negotiating cap so it can never sit above the cap it precedes.
472
+ // An explicit operator tokenThreshold is honoured but still clamped under the
473
+ // negotiated cap: an advisory soft/hard pair sitting at or above hardCapTokens can
474
+ // never fire, because context-hardcap-gate.sh already refuses tools at the cap
475
+ // (C92-H-01 — on a default install tokenThreshold is the shipped 150k default, not a
476
+ // real operator choice, so this is correcting a number, not overriding intent).
474
477
  const softThreshold = explicitSoftThreshold > 0
475
- ? Math.max(1, explicitSoftThreshold)
478
+ ? Math.max(1, Math.min(explicitSoftThreshold, hardCapTokens - 1))
476
479
  : Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
477
- const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
480
+ const hardThreshold = Math.min(
481
+ hardCapTokens,
482
+ Math.max(softThreshold + 1, Math.round(softThreshold * 1.6)),
483
+ );
478
484
  const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
479
485
 
480
486
  return {
@@ -607,6 +613,9 @@ export function resolveThresholdCompactBudget({
607
613
  // this shape (raw JSON readers) still see a coherent state.
608
614
  // Mirrors template_project/.claude/ukit/runtime/compact-threshold.mjs — keep in lockstep.
609
615
  const PRESSURE_SESSIONS_MAX = 8;
616
+ // C90-16: sessionless writers (omp tool events without session_id/transcript)
617
+ // accumulate here — never on the newest sibling's record.
618
+ const SESSIONLESS_PRESSURE_BUCKET = '_sessionless';
610
619
 
611
620
  function normalizeSessionId(value) {
612
621
  const id = typeof value === 'string' ? value.trim() : '';
@@ -627,12 +636,20 @@ function readPressureDocument(raw) {
627
636
  return raw && typeof raw === 'object' ? { default: raw } : {};
628
637
  }
629
638
 
630
- function pickPressureSession(sessions, sessionId) {
639
+ function pickPressureSession(sessions, sessionId, { forWrite = false } = {}) {
631
640
  if (sessionId) {
632
641
  // A session id that has no record yet starts clean — it must not inherit another
633
642
  // session's totals, and the other way round nothing here touches that session.
634
643
  return { id: sessionId, record: sessions[sessionId] ?? null };
635
644
  }
645
+ if (forWrite) {
646
+ // C90-16: a writer without a session id must never accumulate onto the
647
+ // newest sibling's record — cross-session pressure corruption survives a
648
+ // SessionStart reset and wedges that session's hard-cap gate. Sessionless
649
+ // mutations accumulate on a dedicated bucket instead, even when the
650
+ // document holds no records yet.
651
+ return { id: SESSIONLESS_PRESSURE_BUCKET, record: sessions[SESSIONLESS_PRESSURE_BUCKET] ?? null };
652
+ }
636
653
  const entries = Object.entries(sessions);
637
654
  if (!entries.length) {
638
655
  return { id: 'default', record: null };
@@ -669,11 +686,22 @@ export async function buildCompactPressureState(rawState = null, config = {}) {
669
686
  const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
670
687
  const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
671
688
  if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
672
- thresholds.softThreshold = rawSoftThreshold;
673
- thresholds.hardThreshold = rawHardThreshold > rawSoftThreshold
674
- ? rawHardThreshold
675
- : Math.max(rawSoftThreshold + 1, Math.round(rawSoftThreshold * 1.6));
676
- thresholds.baselineTokens = Math.max(120, Math.min(18_000, Math.round(thresholds.softThreshold * 0.18)));
689
+ // A persisted pair is only honoured while it still sits inside the negotiated cap.
690
+ // The record outlives capacity negotiation: an install that ran before the record
691
+ // existed, or before the model was verified, carries thresholds tuned for the
692
+ // shipped 500k ceiling — restoring them re-pins the advisory ABOVE the live
693
+ // hardCapTokens forever (C92-H-02). Falling through keeps the freshly derived
694
+ // pair, which is also what the rewritten record persists, so the file self-heals.
695
+ if (rawSoftThreshold < thresholds.hardCapTokens) {
696
+ thresholds.softThreshold = rawSoftThreshold;
697
+ thresholds.hardThreshold = Math.min(
698
+ thresholds.hardCapTokens,
699
+ rawHardThreshold > rawSoftThreshold
700
+ ? rawHardThreshold
701
+ : Math.max(rawSoftThreshold + 1, Math.round(rawSoftThreshold * 1.6)),
702
+ );
703
+ thresholds.baselineTokens = Math.max(120, Math.min(18_000, Math.round(thresholds.softThreshold * 0.18)));
704
+ }
677
705
  }
678
706
  const recentPrompts = (Array.isArray(rawState?.recentPrompts) ? rawState.recentPrompts : [])
679
707
  .map((entry) => normalizePromptEntry(entry))
@@ -1017,17 +1045,23 @@ export async function buildThresholdCompactPlan({
1017
1045
  const compacted = compactContextBlock(rawLines, {
1018
1046
  maxTokens: budget.maxTokens,
1019
1047
  maxLines: budget.maxLines,
1048
+ // Guardrail anchors are listed FIRST: under hard pressure the block budget may
1049
+ // not fit every anchor, and the lines that protect the user (ask-before-drop)
1050
+ // and the session (memory bridge) are the ones that must never be the tail
1051
+ // casualty (C92-H-03).
1020
1052
  anchorLines: [
1021
1053
  thresholdLine,
1054
+ askBeforeDropLine,
1055
+ memoryBridgeLine,
1022
1056
  routeLine,
1023
1057
  previousLine,
1024
1058
  outputLine,
1025
- memoryBridgeLine,
1026
- askBeforeDropLine,
1027
1059
  ].filter(Boolean),
1028
1060
  maxAnchors: 6,
1029
1061
  });
1030
1062
 
1063
+ const missingAnchors = compacted.missingAnchors ?? [];
1064
+
1031
1065
  return {
1032
1066
  active: true,
1033
1067
  phase,
@@ -1040,6 +1074,7 @@ export async function buildThresholdCompactPlan({
1040
1074
  tokensBefore: compacted.tokensBefore,
1041
1075
  tokensAfter: compacted.tokensAfter,
1042
1076
  savedTokens: compacted.savedTokens,
1077
+ missingAnchors,
1043
1078
  };
1044
1079
  }
1045
1080
 
@@ -1057,7 +1092,7 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1057
1092
  const runtimePaths = buildRuntimePaths(projectRoot);
1058
1093
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1059
1094
  const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1060
- const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1095
+ const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
1061
1096
  const current = await buildCompactPressureState(record, config);
1062
1097
  const next = await mutator(current);
1063
1098
  const normalized = await buildCompactPressureState(next, config);
@@ -1071,7 +1106,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
1071
1106
  const runtimePaths = buildRuntimePaths(projectRoot);
1072
1107
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1073
1108
  const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1074
- const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1109
+ const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId), { forWrite: true });
1075
1110
  const normalized = await buildCompactPressureState(state, config);
1076
1111
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1077
1112
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
@@ -5,17 +5,34 @@ import { isOmpConfigTarget, mergeOmpConfig } from './ompConfigMerge.js';
5
5
 
6
6
  const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
7
7
 
8
- async function readFileOrNull(filePath, encoding = 'utf8') {
8
+ // Read errors split in two: ENOENT is the ONLY state that means "absent" (→ a
9
+ // 'create' plan). Every other error — EACCES, EISDIR, EIO, ENOTDIR — means the
10
+ // target may exist but cannot be compared. Collapsing those into `null` would
11
+ // look identical to "missing" and the apply step would either overwrite the
12
+ // live file atomically with no .bak (W2-C4) or crash mid-install on a 'wx'
13
+ // create (W2-C6). Unreadable targets get a non-write action instead.
14
+ async function readFileOrStatus(filePath, encoding = 'utf8') {
9
15
  try {
10
16
  if (encoding === null) {
11
- return await fs.readFile(filePath);
17
+ return { status: 'ok', content: await fs.readFile(filePath) };
12
18
  }
13
- return await fs.readFile(filePath, encoding);
14
- } catch {
15
- return null;
19
+ return { status: 'ok', content: await fs.readFile(filePath, encoding) };
20
+ } catch (error) {
21
+ if (error?.code === 'ENOENT') {
22
+ return { status: 'missing' };
23
+ }
24
+ return { status: 'unreadable', error };
16
25
  }
17
26
  }
18
27
 
28
+ function conflictResult(entry, error) {
29
+ const code = error?.code ?? 'unknown';
30
+ console.warn(
31
+ `[UKit] Warning: cannot read ${entry.targetPath} (${code}) — leaving it untouched instead of treating it as absent. Fix permissions or remove it manually.`,
32
+ );
33
+ return { ...entry, exists: true, action: 'conflict', existingContent: null };
34
+ }
35
+
19
36
  async function checkLinkStatus(targetPath, linkTarget) {
20
37
  try {
21
38
  const stat = await fs.lstat(targetPath);
@@ -201,33 +218,41 @@ function resolveFileAction(entry, existingContent) {
201
218
 
202
219
  // A `mergeStrategy: skip` target must never be classified as "missing" just because it
203
220
  // cannot be READ — a directory, broken symlink, FIFO/device, or unreadable (EACCES) file
204
- // at the seed path all mean "exists, do not touch". readFileOrNull would turn every one
205
- // of those into null → 'create' → an overwrite attempt. Existence for skip entries is
206
- // decided by lstat (no symlink follow, no device open — reading a FIFO would hang).
221
+ // at the seed path all mean "exists, do not touch". An lstat that fails for a reason
222
+ // OTHER than ENOENT (W2-C6: EACCES on a parent, ELOOP, ENOTDIR) proves nothing at all —
223
+ // treating it as "absent" would send a 'create' action to apply, whose exclusive 'wx'
224
+ // write then crashes mid-install on a target that may have been there all along.
225
+ // Existence for skip entries is decided by lstat (no symlink follow, no device open —
226
+ // reading a FIFO would hang).
207
227
  async function resolveSkipEntryAction(entry) {
208
228
  let stat;
209
229
  try {
210
230
  stat = await fs.lstat(entry.targetPath);
211
- } catch {
212
- // ENOENT (or a stat failure we cannot distinguish from it) is the only state that
213
- // means "seed me"; the apply step still uses an exclusive 'wx' create for the race.
214
- return { ...entry, exists: false, action: 'create', existingContent: null };
231
+ } catch (statError) {
232
+ if (statError?.code === 'ENOENT') {
233
+ // Truly absent — seed it. The apply step still uses an exclusive 'wx'
234
+ // create so a target appearing between diff and apply is not clobbered.
235
+ return { ...entry, exists: false, action: 'create', existingContent: null };
236
+ }
237
+ return conflictResult(entry, statError);
215
238
  }
216
239
 
217
240
  // Only a regular, readable file can prove byte-equality for an 'unchanged' verdict.
218
241
  // Anything else — directory, symlink (valid or broken), FIFO, socket, device, or a
219
242
  // file we cannot read — is simply 'skip': present, owner-owned, hands off.
220
- let existingContent = null;
221
- if (stat.isFile() && !stat.isSymbolicLink()) {
222
- existingContent = await readFileOrNull(
223
- entry.targetPath,
224
- Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
225
- );
226
- }
227
- if (existingContent === null) {
243
+ if (!stat.isFile() || stat.isSymbolicLink()) {
228
244
  return { ...entry, exists: true, action: 'skip', existingContent: null };
229
245
  }
230
- return resolveFileAction(entry, existingContent);
246
+ const read = await readFileOrStatus(
247
+ entry.targetPath,
248
+ Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
249
+ );
250
+ if (read.status !== 'ok') {
251
+ // 'missing' here means it vanished between lstat and read; 'unreadable'
252
+ // means present-but-inaccessible. Either way: owner territory, skip it.
253
+ return { ...entry, exists: read.status === 'unreadable', action: 'skip', existingContent: null };
254
+ }
255
+ return resolveFileAction(entry, read.content);
231
256
  }
232
257
 
233
258
  export async function diffInstallPlan(plan) {
@@ -243,11 +268,14 @@ export async function diffInstallPlan(plan) {
243
268
  return resolveSkipEntryAction(entry);
244
269
  }
245
270
 
246
- const existingContent = await readFileOrNull(
271
+ const read = await readFileOrStatus(
247
272
  entry.targetPath,
248
273
  Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
249
274
  );
250
- return resolveFileAction(entry, existingContent);
275
+ if (read.status === 'unreadable') {
276
+ return conflictResult(entry, read.error);
277
+ }
278
+ return resolveFileAction(entry, read.status === 'ok' ? read.content : null);
251
279
  }),
252
280
  );
253
281
 
@@ -73,7 +73,12 @@ function parseGitignore(content) {
73
73
  * The block spans `[startIdx, endIdx]` inclusive. The search for the closing
74
74
  * marker is bounded by the NEXT start marker, so an excision can never run past
75
75
  * a second block. If no end marker exists inside that bound the block is
76
- * "unterminated" and ends just before the next start marker (or at EOF).
76
+ * "unterminated" (C92-C-01) and its excision boundary stops at the contiguous
77
+ * run of canonical entries following the start marker — a `.gitignore` has no
78
+ * marker where a deleted `# /UKit` should end, so only exact canonical-entry
79
+ * lines provably written by UKit are managed. Everything else — blank lines,
80
+ * user comments, stale or custom rules — is user content and is never spliced
81
+ * out.
77
82
  *
78
83
  * @param {string[]} lines `\r`-free lines from {@link parseGitignore}
79
84
  * @param {number} [from]
@@ -94,7 +99,19 @@ function locateBlock(lines, from = 0) {
94
99
  }
95
100
 
96
101
  const terminated = endIdx !== -1;
97
- return { startIdx, endIdx: terminated ? endIdx : bound - 1, terminated };
102
+ if (terminated) {
103
+ return { startIdx, endIdx, terminated };
104
+ }
105
+
106
+ // Unterminated: own only the contiguous canonical-entry run right after the
107
+ // start marker. The canonical block is regenerated in full anyway, so this
108
+ // run is information-free; stopping at the first non-entry line is what keeps
109
+ // arbitrary user rules below the header safe (C92-C-01).
110
+ let lastManaged = startIdx;
111
+ while (lastManaged + 1 < bound && UKIT_ENTRIES.includes(lines[lastManaged + 1])) {
112
+ lastManaged += 1;
113
+ }
114
+ return { startIdx, endIdx: lastManaged, terminated };
98
115
  }
99
116
 
100
117
  export async function ensureGitignore(projectRoot) {