@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
@@ -53,17 +53,50 @@ async function readJson(filePath) {
53
53
  // shape as ring entries; malformed lines are skipped like parseSidecarLines.
54
54
  const SEGMENTS_REL = 'route-audit.segments.jsonl';
55
55
 
56
+ // C92-H-05: the sidecar is append-only with no writer-side cap, so the readers
57
+ // bound consumption instead — at most the newest SEGMENTS_MAX_BYTES tail. Same
58
+ // 256 KiB as RETRIEVER_LANES_MAX_BYTES (src/core/codeintel/retriever.js:273),
59
+ // the sibling JSONL budget. A mid-slice read starts on a partial line — the
60
+ // first fragment is dropped, mirroring the truncated-tail tolerance below.
61
+ // Keeping the tail (not the head) is load-bearing: a head-anchored read would
62
+ // report year-old routing accuracy as current.
63
+ export const SEGMENTS_MAX_BYTES = 256 * 1024;
64
+
56
65
  async function readJsonLines(filePath) {
57
66
  const rows = [];
58
67
  try {
59
- const raw = await fs.readFile(filePath, 'utf8');
60
- for (const line of raw.split('\n')) {
68
+ const stat = await fs.stat(filePath);
69
+ if (!stat.isFile() || stat.size <= 0) {
70
+ return rows;
71
+ }
72
+ let raw;
73
+ if (stat.size <= SEGMENTS_MAX_BYTES) {
74
+ raw = await fs.readFile(filePath, 'utf8');
75
+ } else {
76
+ // Tail-anchored read: newest bytes only, so cost stays flat no matter
77
+ // how old the sidecar gets.
78
+ const handle = await fs.open(filePath, 'r');
79
+ let bytesRead = 0;
80
+ const buffer = Buffer.allocUnsafe(SEGMENTS_MAX_BYTES);
81
+ try {
82
+ ({ bytesRead } = await handle.read(buffer, 0, SEGMENTS_MAX_BYTES, stat.size - SEGMENTS_MAX_BYTES));
83
+ } finally {
84
+ await handle.close().catch(() => {});
85
+ }
86
+ raw = buffer.toString('utf8', 0, bytesRead);
87
+ }
88
+ const lines = raw.split('\n');
89
+ if (stat.size > SEGMENTS_MAX_BYTES) {
90
+ // First line of a byte-offset slice is a fragment — always drop it.
91
+ lines.shift();
92
+ }
93
+ for (const line of lines) {
61
94
  if (!line.trim()) continue;
62
95
  try {
63
96
  const item = JSON.parse(line);
64
97
  if (item && typeof item === 'object' && !Array.isArray(item)) rows.push(item);
65
98
  } catch {
66
- // skip malformed segment lines
99
+ // skip malformed segment lines (incl. a truncated tail mid-append)
67
100
  }
68
101
  }
69
102
  } catch {
@@ -123,9 +156,14 @@ export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } =
123
156
  }
124
157
  }
125
158
  const ledgerKeys = new Set();
159
+ // C92-H-05: keyed Map replaces the per-row ledgers.find — O(ledgers) once
160
+ // instead of O(rows × ledgers) inside the join loop (mirrors
161
+ // skillAccuracy.js's ledgerByKey).
162
+ const ledgerByKey = new Map();
126
163
  for (const ledger of ledgers) {
127
164
  if (ledger && typeof ledger.requestKey === 'string' && ledger.requestKey) {
128
165
  ledgerKeys.add(ledger.requestKey);
166
+ ledgerByKey.set(ledger.requestKey, ledger);
129
167
  }
130
168
  }
131
169
 
@@ -139,7 +177,7 @@ export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } =
139
177
  modeBucket.routes += 1;
140
178
  taskBucket.routes += 1;
141
179
  modeTierBucket.routes += 1;
142
- const ledger = ledgers.find((candidate) => candidate.requestKey === entry.requestKey);
180
+ const ledger = ledgerByKey.get(entry.requestKey);
143
181
  if (!ledger) continue;
144
182
  result.joined += 1;
145
183
  result.joinedRows.push({ audit: entry, ledger });
@@ -24,6 +24,13 @@ const AUDIT_REL = path.join(CACHE_DIR_REL, 'route-audit.json');
24
24
  // BL-004: rows evicted past the 40-entry ring cap spill to this append-only
25
25
  // JSONL sidecar — same row shape; malformed lines skipped like parseSidecarLines.
26
26
  const SEGMENTS_REL = path.join(CACHE_DIR_REL, 'route-audit.segments.jsonl');
27
+
28
+ // C92-H-05: the sidecar is append-only with no writer-side cap, so the readers
29
+ // bound consumption instead — at most the newest SEGMENTS_MAX_BYTES tail. Same
30
+ // 256 KiB as RETRIEVER_LANES_MAX_BYTES (src/core/codeintel/retriever.js:273) and
31
+ // routeOutcomes.js's SEGMENTS_MAX_BYTES. Tail-anchored (newest rows kept): a
32
+ // head-anchored read would report year-old trigger accuracy as current.
33
+ export const SEGMENTS_MAX_BYTES = 256 * 1024;
27
34
  const ARTIFACT_REL = path.join('.ukit', 'storage', 'learning', 'skill-accuracy.json');
28
35
  const CONFIG_REL = path.join('.ukit', 'storage', 'config.json');
29
36
  const MAX_SKILL_IDS = 8;
@@ -68,14 +75,38 @@ async function readJson(filePath) {
68
75
  async function readJsonLines(filePath) {
69
76
  const rows = [];
70
77
  try {
71
- const raw = await fs.readFile(filePath, 'utf8');
72
- for (const line of raw.split('\n')) {
78
+ const stat = await fs.stat(filePath);
79
+ if (!stat.isFile() || stat.size <= 0) {
80
+ return rows;
81
+ }
82
+ let raw;
83
+ if (stat.size <= SEGMENTS_MAX_BYTES) {
84
+ raw = await fs.readFile(filePath, 'utf8');
85
+ } else {
86
+ // Tail-anchored read: newest bytes only, so cost stays flat no matter
87
+ // how old the sidecar gets.
88
+ const handle = await fs.open(filePath, 'r');
89
+ let bytesRead = 0;
90
+ const buffer = Buffer.allocUnsafe(SEGMENTS_MAX_BYTES);
91
+ try {
92
+ ({ bytesRead } = await handle.read(buffer, 0, SEGMENTS_MAX_BYTES, stat.size - SEGMENTS_MAX_BYTES));
93
+ } finally {
94
+ await handle.close().catch(() => {});
95
+ }
96
+ raw = buffer.toString('utf8', 0, bytesRead);
97
+ }
98
+ const lines = raw.split('\n');
99
+ if (stat.size > SEGMENTS_MAX_BYTES) {
100
+ // First line of a byte-offset slice is a fragment — always drop it.
101
+ lines.shift();
102
+ }
103
+ for (const line of lines) {
73
104
  if (!line.trim()) continue;
74
105
  try {
75
106
  const item = JSON.parse(line);
76
107
  if (item && typeof item === 'object') rows.push(item);
77
108
  } catch {
78
- // skip malformed segment lines
109
+ // skip malformed segment lines (incl. a truncated tail mid-append)
79
110
  }
80
111
  }
81
112
  } catch {
@@ -99,7 +130,7 @@ function skillIdsOf(entry) {
99
130
 
100
131
  async function writeArtifact(filePath, result) {
101
132
  const dir = path.dirname(filePath);
102
- const tmp = path.join(dir, `.skill-accuracy-${process.pid}.tmp`);
133
+ const tmp = path.join(dir, `.skill-accuracy-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
103
134
  try {
104
135
  await fs.mkdir(dir, { recursive: true });
105
136
  await fs.writeFile(tmp, JSON.stringify(result, null, 2));
@@ -3,6 +3,12 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
5
 
6
+ // Cross-process build serialization (C92-B-01): the same withAsyncLock
7
+ // protocol ensureIndexFresh uses in the installed index-core mirror, so every
8
+ // buildCodeIndex caller — CLI, git hook, install — serializes without needing
9
+ // its own call-site lock.
10
+ import { withAsyncLock } from '../../template_project/.claude/ukit/runtime/async-lock.mjs';
11
+
6
12
  import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, isLikelyTestFilePath, normalizeRelative } from './paths.js';
7
13
  import { importsNeedAliasContext, loadImportAliasContextState, resolveImportSpecifier } from './importResolution.js';
8
14
  import { clearIndexArtifactCache } from './queryIndex.js';
@@ -105,6 +111,23 @@ export class IndexDiscoveryTimeoutError extends Error {
105
111
  }
106
112
  }
107
113
 
114
+ /**
115
+ * Fail-closed lock timeout (C92-B-01): a contended index-build lock that stays
116
+ * held past INDEX_BUILD_LOCK_WAIT_MS means another builder is still publishing
117
+ * (or a dead holder's lock could not be reclaimed). Building unlocked would
118
+ * interleave tmp+rename writes into a torn artifact set, so the caller gets a
119
+ * typed failure instead of a race.
120
+ */
121
+ export class IndexBuildLockTimeoutError extends Error {
122
+ constructor({ waitedMs, lockPath }) {
123
+ super(`Index build lock ${lockPath} was still held after ${waitedMs}ms; refusing to publish unlocked.`);
124
+ this.name = 'IndexBuildLockTimeoutError';
125
+ this.code = 'INDEX_BUILD_LOCK_TIMEOUT';
126
+ this.waitedMs = waitedMs;
127
+ this.lockPath = lockPath;
128
+ }
129
+ }
130
+
108
131
  const EXCLUDED_DIR_NAMES = new Set([
109
132
  'node_modules',
110
133
  '.git',
@@ -142,7 +165,56 @@ const CALL_IGNORE_WORDS = new Set([
142
165
  'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
143
166
  ]);
144
167
 
168
+ export const INDEX_BUILD_LOCK_NAME = 'index-build';
169
+
170
+ /**
171
+ * How long a builder waits for a build already in flight before failing
172
+ * closed. Must comfortably cover a worst-case build of a large repo — the
173
+ * wait replaces what used to be a silent interleaved publish, so an overly
174
+ * short budget would trade torn artifacts for spurious timeouts.
175
+ */
176
+ export const INDEX_BUILD_LOCK_WAIT_MS = 60_000;
177
+
178
+ /**
179
+ * C92-B-01: every buildCodeIndex entry point publishes under the shared
180
+ * `index-build` lock. Per-artifact tmp+rename was never the problem — two
181
+ * builders with ASYMMETRIC write sets interleaved their renames, so a reader
182
+ * (or the next incremental merge) could consume meta.json from build A and
183
+ * files.json from build B. The lock lives inside the builder because the
184
+ * callers (refresh-index.mjs, build-index.mjs, indexTools, install) were all
185
+ * unlocked; ensureIndexFresh's own lock on the same path stays compatible —
186
+ * it drives buildCodeIndexInner directly while it holds the lock.
187
+ * Fail-closed: a lock that cannot be acquired inside the wait budget throws
188
+ * IndexBuildLockTimeoutError instead of publishing unlocked.
189
+ */
145
190
  export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
191
+ // A signal aborted before we even look at the lock must classify exactly
192
+ // like a discovery cancellation: callers/tests rely on the typed
193
+ // IndexDiscoveryTimeoutError(aborted) and on no artifact directory ever
194
+ // appearing for a build that never started.
195
+ if (signal?.aborted) {
196
+ throw new IndexDiscoveryTimeoutError({ deadlineMs, phase: 'build', elapsedMs: 0, aborted: true });
197
+ }
198
+ const absoluteRoot = path.resolve(rootDir);
199
+ const lockBase = path.join(getIndexDir(absoluteRoot), INDEX_BUILD_LOCK_NAME);
200
+ const outcome = await withAsyncLock(
201
+ lockBase,
202
+ { signal, deadlineMs: INDEX_BUILD_LOCK_WAIT_MS },
203
+ () => buildCodeIndexInner({ rootDir: absoluteRoot, changedFiles, discoverySnapshot, signal, deadlineMs }),
204
+ );
205
+ if (outcome?.ok === true) {
206
+ return outcome.value;
207
+ }
208
+ if (outcome?.reason === 'aborted') {
209
+ throw new IndexCancelledError({ phase: 'build-lock' });
210
+ }
211
+ throw new IndexBuildLockTimeoutError({
212
+ waitedMs: outcome?.waitedMs ?? INDEX_BUILD_LOCK_WAIT_MS,
213
+ lockPath: `${lockBase}.lock`,
214
+ });
215
+ }
216
+
217
+ async function buildCodeIndexInner({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
146
218
  const absoluteRoot = path.resolve(rootDir);
147
219
  const indexDir = getIndexDir(absoluteRoot);
148
220
 
@@ -153,11 +225,22 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
153
225
  // full path below — correctness wins over the optimization, and incremental
154
226
  // output must stay equivalent to a clean full rebuild.
155
227
  const changedHintPlan = normalizeChangedFileHints(changedFiles);
156
- if (changedHintPlan) {
157
- const incremental = changedHintPlan.ok
158
- ? await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal })
159
- : null;
160
- if (incremental) {
228
+ let incrementalFallbackReason = changedHintPlan && !changedHintPlan.ok
229
+ ? 'unsafe-changed-file-hints'
230
+ : null;
231
+ if (changedHintPlan?.ok) {
232
+ const incremental = await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal });
233
+ if (incremental?.rejected) {
234
+ // C92-B-02: a rejected merge is no longer silent. The reason lands on the
235
+ // build summary, and corruption-class rejections are also warned so the
236
+ // CLI callers that ignore the summary still surface it.
237
+ incrementalFallbackReason = incremental.detail
238
+ ? `${incremental.rejected} (${incremental.detail})`
239
+ : incremental.rejected;
240
+ if (incremental.detail) {
241
+ console.warn(`[index] incremental refresh rejected: meta.artifactItemCounts ${incremental.rejected} (${incremental.detail}) — falling back to full rebuild`);
242
+ }
243
+ } else if (incremental) {
161
244
  return incremental;
162
245
  }
163
246
  }
@@ -531,6 +614,11 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
531
614
  reusedAnalogs: canReuseAnalogs,
532
615
  preservedRuntimeCaches,
533
616
  mode: 'full',
617
+ // C92-B-02: when changed-file hints were offered but the incremental merge
618
+ // refused them, this names the rejection (e.g.
619
+ // 'artifact-item-counts-mismatch (files.json: recorded 4, actual 5)') so
620
+ // the fallback is diagnosable instead of silently returning 'full' forever.
621
+ incrementalFallbackReason,
534
622
  parsedFiles: filesToParse.slice(),
535
623
  removedFiles: [],
536
624
  };
@@ -654,20 +742,11 @@ function isValidCoveredItems(items, fileRecords, keyFn) {
654
742
  }
655
743
 
656
744
  /**
657
- * Item shapes alone cannot detect a schema-current artifact whose `items` array
658
- * lost entries: every surviving entry still looks valid, yet the merge would
659
- * reuse it verbatim and publish an artifact missing the unchanged files. Each
660
- * required artifact therefore records its item count in meta, and the count must
661
- * match the array actually loaded — a mismatch (or a pre-count meta) falls back
662
- * to full discovery.
745
+ * C92-B-02: every rejection from tryIncrementalIndexUpdate carries a reason
746
+ * (and, for integrity failures, a detail string) instead of a bare null, so a
747
+ * torn or corrupted artifact set lands on the build summary as
748
+ * incrementalFallbackReason rather than silently forcing 'full' forever.
663
749
  */
664
- function artifactItemCountsMatch(metaArtifact, counts) {
665
- const recorded = metaArtifact?.artifactItemCounts;
666
- if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded)) {
667
- return false;
668
- }
669
- return Object.entries(counts).every(([name, count]) => Number(recorded[name]) === count);
670
- }
671
750
 
672
751
  /**
673
752
  * Stat every hinted path and classify it as an upsert, a removal, or — for
@@ -801,7 +880,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
801
880
  [INDEX_ARTIFACTS.calls]: callsArtifact?.items?.length ?? -1,
802
881
  [INDEX_ARTIFACTS.relations]: relationsArtifact?.items?.length ?? -1,
803
882
  };
804
- const previousArtifactsUsable = [
883
+ const previousArtifactsSchemaAndShapeUsable = [
805
884
  metaArtifact,
806
885
  filesArtifact,
807
886
  symbolsArtifact,
@@ -816,16 +895,29 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
816
895
  && isValidCoveredItems(importsArtifact.items, filesArtifact.items, (item) => item.from)
817
896
  && isValidCoveredItems(callsArtifact.items, filesArtifact.items, (item) => item.filePath)
818
897
  && isValidCoveredItems(relationsArtifact.items, filesArtifact.items, (item) => item.filePath)
819
- && Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles)
820
- && artifactItemCountsMatch(metaArtifact, artifactItemCounts);
821
- if (!previousArtifactsUsable) {
822
- return null;
898
+ && Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles);
899
+ if (!previousArtifactsSchemaAndShapeUsable) {
900
+ return { rejected: 'previous-artifacts-unusable' };
901
+ }
902
+ // C92-B-02: the count gate used to fold silently into the same null as every
903
+ // other unusability, so a torn publish permanently disabled incremental
904
+ // refresh with no diagnostic. Name the artifacts that disagree with meta.
905
+ const recordedCounts = metaArtifact?.artifactItemCounts;
906
+ const countsRecorded = recordedCounts && typeof recordedCounts === 'object' && !Array.isArray(recordedCounts);
907
+ const countMismatchDetail = !countsRecorded
908
+ ? 'artifactItemCounts missing from meta.json'
909
+ : Object.entries(artifactItemCounts)
910
+ .filter(([name, actual]) => Number(recordedCounts[name]) !== actual)
911
+ .map(([name, actual]) => `${name}: recorded ${recordedCounts[name] ?? 'missing'}, actual ${actual}`)
912
+ .join(', ');
913
+ if (!countsRecorded || countMismatchDetail) {
914
+ return { rejected: 'artifact-item-counts-mismatch', detail: countMismatchDetail };
823
915
  }
824
916
 
825
917
  const previousRecordsByPath = new Map(filesArtifact.items.map((item) => [item.filePath, item]));
826
918
  const hintUpdates = await resolveChangedHintUpdates(absoluteRoot, changedPaths, previousRecordsByPath);
827
919
  if (!hintUpdates) {
828
- return null;
920
+ return { rejected: 'changed-hints-unresolvable' };
829
921
  }
830
922
 
831
923
  // Merge the hint outcome into one sorted record set — the same shape and
@@ -894,7 +986,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
894
986
  }
895
987
  }, signal, 'stat-style'));
896
988
  if (styleEntries.some((entry) => entry?.statError)) {
897
- return null;
989
+ return { rejected: 'style-stat-unresolvable' };
898
990
  }
899
991
  const styleFilePaths = styleEntries.filter(Boolean).map((entry) => normalizeRelative(absoluteRoot, entry.absolutePath));
900
992
 
@@ -2869,7 +2961,12 @@ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, e
2869
2961
  || Number.isNaN(maxAgeMs)
2870
2962
  || maxAgeMs < 0
2871
2963
  || (now - effectiveGeneratedAtMs) >= maxAgeMs
2872
- || !metaArtifact?.sourceFingerprint;
2964
+ || !metaArtifact?.sourceFingerprint
2965
+ // C92-B-03: an artifact set written under a different INDEX_SCHEMA_VERSION
2966
+ // — older OR newer — is not fresh. `!==` covers the package-downgrade
2967
+ // direction too; serving the old schema made `outline:` silently vanish
2968
+ // while ranking kept answering.
2969
+ || metaArtifact?.schemaVersion !== INDEX_SCHEMA_VERSION;
2873
2970
 
2874
2971
  // Legacy boolean callers (query-index, triage, verify-context) stop here and
2875
2972
  // let buildCodeIndex do its own discovery when a rebuild is needed.
@@ -377,6 +377,9 @@ export async function applyFixLoopEscalation({
377
377
  } catch {
378
378
  batchResult = null;
379
379
  }
380
+ if (!isPlainObject(batchResult)) {
381
+ batchResult = { status: 'unavailable', fallbackCode: 'adapter-failed', answers: [] };
382
+ }
380
383
  }
381
384
 
382
385
  const modelLane = batchResult
@@ -1,14 +1,15 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
 
4
- import { escapeRegExp } from '../core/fileOps.js';
5
+ import { escapeRegExp, writeFileAtomic, withFileLock } from '../core/fileOps.js';
5
6
 
6
7
  const HOOK_NAMES = ['post-commit', 'post-merge', 'post-checkout'];
7
8
  const BEGIN = '# UKit index auto-refresh (begin)';
8
9
  const END = '# UKit index auto-refresh (end)';
9
10
 
10
11
  export async function installIndexRefreshHooks({ projectRoot }) {
11
- const hooksDir = path.join(projectRoot, '.git', 'hooks');
12
+ const hooksDir = resolveHooksDir(projectRoot);
12
13
  await ensureHooksDir(hooksDir);
13
14
 
14
15
  let installed = 0;
@@ -16,19 +17,31 @@ export async function installIndexRefreshHooks({ projectRoot }) {
16
17
 
17
18
  for (const hookName of HOOK_NAMES) {
18
19
  const hookPath = path.join(hooksDir, hookName);
19
- const existing = await readTextOrEmpty(hookPath);
20
- const base = existing || '#!/bin/sh\n';
21
-
22
- if (hasCompleteManagedBlock(base)) {
23
- unchanged += 1;
24
- continue;
25
- }
26
-
27
- const cleanedBase = removeManagedBlockFragments(base);
28
- const next = `${cleanedBase.trimEnd()}\n\n${buildManagedBlock()}\n`;
29
- await fs.writeFile(hookPath, next, { mode: 0o755 });
30
- await fs.chmod(hookPath, 0o755);
31
- installed += 1;
20
+ // C90-20: serialize the read-modify-write — two ukit processes touching the
21
+ // same hook file interleave without a lock, and writeFileAtomic's tmp+rename
22
+ // guarantees a crash mid-write never publishes a torn hook to git.
23
+ const result = await withFileLock(hookPath, async () => {
24
+ const existing = await readTextOrEmpty(hookPath);
25
+ const base = existing || '#!/bin/sh\n';
26
+
27
+ // C91-007 review fix: a COMPLETE block is only 'unchanged' when its content
28
+ // matches the current generated block byte-for-byte. Pre-C90-17 installs
29
+ // produced a complete but synchronous, deadline-free block — marker-only
30
+ // detection would keep counting it 'unchanged' forever and the deadline
31
+ // fix would never reach exactly the users who already installed.
32
+ const existingBlock = extractManagedBlock(base);
33
+ const wanted = buildManagedBlock();
34
+ if (existingBlock === wanted) return 'unchanged';
35
+
36
+ const cleanedBase = removeManagedBlockFragments(base);
37
+ const next = `${cleanedBase.trimEnd()}\n\n${wanted}\n`;
38
+ await writeFileAtomic(hookPath, next);
39
+ await fs.chmod(hookPath, 0o755);
40
+ return 'installed';
41
+ });
42
+
43
+ if (result === 'installed') installed += 1;
44
+ else unchanged += 1; // includes lock-wait-expired (dropped mutation, journaled)
32
45
  }
33
46
 
34
47
  return {
@@ -39,7 +52,7 @@ export async function installIndexRefreshHooks({ projectRoot }) {
39
52
  }
40
53
 
41
54
  export async function removeIndexRefreshHooks({ projectRoot }) {
42
- const hooksDir = path.join(projectRoot, '.git', 'hooks');
55
+ const hooksDir = resolveHooksDir(projectRoot);
43
56
  await ensureHooksDir(hooksDir);
44
57
 
45
58
  let removed = 0;
@@ -47,17 +60,19 @@ export async function removeIndexRefreshHooks({ projectRoot }) {
47
60
 
48
61
  for (const hookName of HOOK_NAMES) {
49
62
  const hookPath = path.join(hooksDir, hookName);
50
- const existing = await readTextOrEmpty(hookPath);
51
- if (!existing || !hasCompleteManagedBlock(existing)) {
52
- unchanged += 1;
53
- continue;
54
- }
55
-
56
- const next = removeManagedBlock(existing);
57
- const cleaned = next.trim().length === 0 ? '#!/bin/sh\n' : `${next.trimEnd()}\n`;
58
- await fs.writeFile(hookPath, cleaned, { mode: 0o755 });
59
- await fs.chmod(hookPath, 0o755);
60
- removed += 1;
63
+ const result = await withFileLock(hookPath, async () => {
64
+ const existing = await readTextOrEmpty(hookPath);
65
+ if (!existing || !hasCompleteManagedBlock(existing)) return 'unchanged';
66
+
67
+ const next = removeManagedBlock(existing);
68
+ const cleaned = next.trim().length === 0 ? '#!/bin/sh\n' : `${next.trimEnd()}\n`;
69
+ await writeFileAtomic(hookPath, cleaned);
70
+ await fs.chmod(hookPath, 0o755);
71
+ return 'removed';
72
+ });
73
+
74
+ if (result === 'removed') removed += 1;
75
+ else unchanged += 1;
61
76
  }
62
77
 
63
78
  return {
@@ -73,11 +88,40 @@ function buildManagedBlock() {
73
88
  'ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0',
74
89
  'SCRIPT="$ROOT/.claude/ukit/index/refresh-index.mjs"',
75
90
  '[ -f "$SCRIPT" ] || exit 0',
76
- 'node "$SCRIPT" >/dev/null 2>&1 || true',
91
+ // C90-17: never run the rebuild synchronously inside a git operation — the
92
+ // refresh is backgrounded (`&`) and armed with the same UKIT_HOOK_DEADLINE_MS
93
+ // convention other UKit hooks use. The sleeper kills the node child if it
94
+ // wedges (stalled mount, pathological enumeration); the env var also reaches
95
+ // the child so a deadline-aware script can exit cleanly first.
96
+ // Non-numeric or empty env values are sanitized to the default (same
97
+ // ''|*[!0-9]* guard as completion-gate.sh) — otherwise `sleep $((...))`
98
+ // aborts the killer subshell and the refresh runs unbounded.
99
+ 'case "$UKIT_HOOK_DEADLINE_MS" in \'\'|*[!0-9]*) UKIT_HOOK_DEADLINE_MS=30000 ;; esac',
100
+ 'export UKIT_HOOK_DEADLINE_MS',
101
+ 'node "$SCRIPT" >/dev/null 2>&1 &',
102
+ 'UKIT_REFRESH_PID=$!',
103
+ // The killer sleeps past the deadline before signaling, so the PID was
104
+ // captured before the delay — a recycled PID is only at risk in the narrow
105
+ // window after node exits, and `kill -0` closes most of it (kills the
106
+ // signal if our child is already gone; a recycled PID reused in-window is
107
+ // a documented theoretical residual).
108
+ '( sleep $((UKIT_HOOK_DEADLINE_MS / 1000 + 1)) 2>/dev/null; kill -0 "$UKIT_REFRESH_PID" 2>/dev/null && kill -9 "$UKIT_REFRESH_PID" 2>/dev/null ) </dev/null >/dev/null 2>&1 &',
77
109
  END,
78
110
  ].join('\n');
79
111
  }
80
112
 
113
+ /**
114
+ * Extract the complete managed block (BEGIN..END inclusive), or null when the
115
+ * block is absent, partial, or the markers are misordered.
116
+ */
117
+ function extractManagedBlock(content) {
118
+ const beginIndex = content.indexOf(BEGIN);
119
+ if (beginIndex < 0) return null;
120
+ const endIndex = content.indexOf(END, beginIndex + BEGIN.length);
121
+ if (endIndex < 0) return null;
122
+ return content.slice(beginIndex, endIndex + END.length);
123
+ }
124
+
81
125
  function hasCompleteManagedBlock(content) {
82
126
  const beginIndex = content.indexOf(BEGIN);
83
127
  const endIndex = content.indexOf(END, beginIndex + BEGIN.length);
@@ -113,6 +157,32 @@ function removeManagedBlockFragments(content) {
113
157
  return kept.join('\n').replace(/\n{3,}/g, '\n\n');
114
158
  }
115
159
 
160
+ /**
161
+ * C90-03: resolve the hooks dir through git, never from `.git` layout — linked
162
+ * worktrees have a `.git` FILE and `core.hooksPath` relocates the dir entirely.
163
+ * `git rev-parse --git-path hooks` honors both. Output may be relative (resolved
164
+ * against the cwd we spawned in = projectRoot) or absolute. Falls back to the
165
+ * classic `<root>/.git/hooks` when git isn't usable in this root, preserving
166
+ * the pre-C90 behavior for stub fixtures.
167
+ */
168
+ function resolveHooksDir(projectRoot) {
169
+ try {
170
+ const result = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], {
171
+ cwd: projectRoot,
172
+ encoding: 'utf8',
173
+ timeout: 5000,
174
+ maxBuffer: 64 * 1024,
175
+ });
176
+ const resolved = (result.stdout ?? '').trim();
177
+ if (!result.error && result.status === 0 && resolved) {
178
+ return path.isAbsolute(resolved) ? resolved : path.resolve(projectRoot, resolved);
179
+ }
180
+ } catch {
181
+ // git unavailable — fall through to the legacy path
182
+ }
183
+ return path.join(projectRoot, '.git', 'hooks');
184
+ }
185
+
116
186
  async function ensureHooksDir(hooksDir) {
117
187
  try {
118
188
  const stat = await fs.stat(hooksDir);
@@ -120,7 +190,7 @@ async function ensureHooksDir(hooksDir) {
120
190
  throw new Error();
121
191
  }
122
192
  } catch {
123
- throw new Error('No .git/hooks directory found. Run this inside a git project.');
193
+ throw new Error(`No git hooks directory found at ${hooksDir}. Run this inside a git project.`);
124
194
  }
125
195
  }
126
196
 
@@ -111,11 +111,17 @@ export function resolveImportSpecifier({
111
111
  indexedFileSet,
112
112
  aliasContext,
113
113
  }) {
114
- if (!specifier?.trim()) {
114
+ if (typeof specifier !== 'string' || !specifier.trim()) {
115
115
  return null;
116
116
  }
117
117
 
118
118
  if (specifier.startsWith('.')) {
119
+ // A relative specifier cannot be resolved without the importing file, so a
120
+ // malformed (non-string) edge.from must skip the edge instead of throwing
121
+ // inside path.posix.dirname.
122
+ if (typeof fromFilePath !== 'string') {
123
+ return null;
124
+ }
119
125
  const fromDir = path.posix.dirname(fromFilePath);
120
126
  const base = path.posix.normalize(path.posix.join(fromDir, specifier));
121
127
  return resolveCandidateBase(base, indexedFileSet);
@@ -18,7 +18,6 @@
18
18
  // locked by tests/consistency/registryParity.test.js.
19
19
 
20
20
  import fs from 'node:fs/promises';
21
- import fsSync from 'node:fs';
22
21
  import os from 'node:os';
23
22
  import path from 'node:path';
24
23
  import { fileURLToPath } from 'node:url';
@@ -78,12 +77,14 @@ const AUTONOMOUS_TRIGGER_RE = /\b(finish (?:this|the) backlog|bounded (?:long )?
78
77
  const RELEASE_TRIGGER_RE = /\b(publish|ship it|release|bump (?:the )?version|deploy|cut a release|tag (?:the )?release)\b/i;
79
78
  const HANDOFF_CYCLE_TRIGGER_RE = /\b(handoff|multi[- ]?task cycle|task pipeline|execute the plan|worktree)\b/i;
80
79
 
81
- // session-pickup discriminator needs the resumable-record check.
82
- function hasResumableRecord(projectRoot) {
80
+ // session-pickup discriminator needs the resumable-record check. ASYNC scan
81
+ // (W3-PB2): the resolve path runs inside hook processes where a sync
82
+ // readdirSync parks the event loop and can starve the hook self-deadline.
83
+ async function hasResumableRecord(projectRoot) {
83
84
  if (!projectRoot) return false;
84
85
  try {
85
86
  const runsDir = path.join(projectRoot, '.ukit', 'storage', 'runs');
86
- const entries = fsSync.readdirSync(runsDir, { withFileTypes: true });
87
+ const entries = await fs.readdir(runsDir, { withFileTypes: true });
87
88
  return entries.some((entry) => entry.isFile() && entry.name.endsWith('.json'));
88
89
  } catch {
89
90
  return false;
@@ -289,7 +290,7 @@ function matchFirst(text, regexes) {
289
290
  return -1;
290
291
  }
291
292
 
292
- function buildRouteSignals({
293
+ async function buildRouteSignals({
293
294
  signalText,
294
295
  executionMode,
295
296
  escalationTriggers,
@@ -358,7 +359,7 @@ function buildRouteSignals({
358
359
  testHarness: TEST_HARNESS_RE.test(signalText),
359
360
  measurable: /\d/.test(signalText) || MEASURABLE_RE.test(signalText),
360
361
  dataMove: DATA_MOVE_RE.test(signalText),
361
- hasResumableRecord: hasResumableRecord(projectRoot),
362
+ hasResumableRecord: await hasResumableRecord(projectRoot),
362
363
  // BL-026 review fix: ask to convert/author a skill — beats defect verbs in
363
364
  // the same sentence ("make this repeated fix flow a skill").
364
365
  skillAsk: SKILL_ASK_RE.test(signalText),
@@ -376,14 +377,17 @@ const MEASURABLE_RE = /\b(latency|baseline|benchmark|profile|cpu|memory|heap|tak
376
377
  // REQUIRED — a registry file without them is malformed (FR-007), where the
377
378
  // workflow-policy loader tolerates filename-stem ids.
378
379
  function parsePlaybookMeta(raw, filePath) {
379
- if (!raw.startsWith('---\n')) {
380
+ // W3-PB1: normalize CRLF before fence matching — a Windows-authored
381
+ // frontmatter block is still frontmatter, not malformed.
382
+ const text = String(raw).replace(/\r\n/g, '\n');
383
+ if (!text.startsWith('---\n')) {
380
384
  return null;
381
385
  }
382
- const fenceIndex = raw.indexOf('\n---', 3);
386
+ const fenceIndex = text.indexOf('\n---', 3);
383
387
  if (fenceIndex === -1) {
384
388
  return null; // unterminated frontmatter
385
389
  }
386
- const metaText = raw.slice(4, fenceIndex);
390
+ const metaText = text.slice(4, fenceIndex);
387
391
  const meta = {};
388
392
  for (const line of metaText.split('\n')) {
389
393
  const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
@@ -408,7 +412,7 @@ function parsePlaybookMeta(raw, filePath) {
408
412
  if (lanes.length === 0) {
409
413
  return null;
410
414
  }
411
- const body = raw.slice(fenceIndex + 4).replace(/^\n/, '');
415
+ const body = text.slice(fenceIndex + 4).replace(/^\n/, '');
412
416
  if (body.trim() === '') {
413
417
  return null; // empty body → not a playbook
414
418
  }
@@ -553,7 +557,7 @@ export async function resolvePlaybookId({
553
557
  ...(Array.isArray(escalationTriggers) ? escalationTriggers : []),
554
558
  ...deriveEscalationTriggers(floor),
555
559
  ])];
556
- const signals = buildRouteSignals({
560
+ const signals = await buildRouteSignals({
557
561
  signalText,
558
562
  executionMode,
559
563
  escalationTriggers: mergedTriggers,