@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
@@ -53,6 +53,7 @@ from pptx import Presentation
53
53
  # Constants
54
54
  THUMBNAIL_WIDTH = 300 # Fixed thumbnail width in pixels
55
55
  CONVERSION_DPI = 100 # DPI for PDF to image conversion
56
+ MIN_COLS = 3 # Minimum number of columns
56
57
  MAX_COLS = 6 # Maximum number of columns
57
58
  DEFAULT_COLS = 5 # Default number of columns
58
59
  JPEG_QUALITY = 95 # JPEG compression quality
@@ -89,7 +90,11 @@ def main():
89
90
 
90
91
  args = parser.parse_args()
91
92
 
92
- # Validate columns
93
+ # Validate columns — C90-13: reject below-range values as a clean option
94
+ # error at argv time so the range(…, cols*(cols+1)) ValueError path in
95
+ # create_grids() is unreachable.
96
+ if args.cols < MIN_COLS:
97
+ parser.error(f"--cols must be between {MIN_COLS} and {MAX_COLS} (got {args.cols})")
93
98
  cols = min(args.cols, MAX_COLS)
94
99
  if args.cols > MAX_COLS:
95
100
  print(f"Warning: Columns limited to {MAX_COLS} (requested {args.cols})")
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { withFileLock } from '../../runtime/token-utils.mjs';
6
+ import { withAsyncLock } from '../../runtime/async-lock.mjs';
6
7
 
7
8
  // One wall-clock ceiling for the WHOLE discovery phase: both `git ls-files`
8
9
  // enumerations AND the non-git fallback walk share this single budget. Every
@@ -28,6 +29,23 @@ export class IndexDiscoveryTimeoutError extends Error {
28
29
  }
29
30
  }
30
31
 
32
+ /**
33
+ * Fail-closed lock timeout (C92-B-01): a contended index-build lock that stays
34
+ * held past INDEX_BUILD_LOCK_WAIT_MS means another builder is still publishing
35
+ * (or a dead holder's lock could not be reclaimed). Building unlocked would
36
+ * interleave tmp+rename writes into a torn artifact set, so the caller gets a
37
+ * typed failure instead of a race.
38
+ */
39
+ export class IndexBuildLockTimeoutError extends Error {
40
+ constructor({ waitedMs, lockPath }) {
41
+ super(`Index build lock ${lockPath} was still held after ${waitedMs}ms; refusing to publish unlocked.`);
42
+ this.name = 'IndexBuildLockTimeoutError';
43
+ this.code = 'INDEX_BUILD_LOCK_TIMEOUT';
44
+ this.waitedMs = waitedMs;
45
+ this.lockPath = lockPath;
46
+ }
47
+ }
48
+
31
49
  // Upper bound on in-flight filesystem stat calls during discovery: git
32
50
  // candidate stats and the non-git fallback walk share this fixed pool.
33
51
  // The previous `Promise.all(candidates.map(stat))` produced one unbounded
@@ -211,7 +229,56 @@ const VIETNAMESE_TOKEN_ALIASES = new Map([
211
229
  // snapshots from a different version instead of trusting unknown fields.
212
230
  export const DISCOVERY_SNAPSHOT_VERSION = 1;
213
231
 
232
+ export const INDEX_BUILD_LOCK_NAME = 'index-build';
233
+
234
+ /**
235
+ * How long a builder waits for a build already in flight before failing
236
+ * closed. Must comfortably cover a worst-case build of a large repo — the
237
+ * wait replaces what used to be a silent interleaved publish, so an overly
238
+ * short budget would trade torn artifacts for spurious timeouts.
239
+ */
240
+ export const INDEX_BUILD_LOCK_WAIT_MS = 60_000;
241
+
242
+ /**
243
+ * C92-B-01: every buildCodeIndex entry point publishes under the shared
244
+ * `index-build` lock (the same path ensureIndexFresh locks). Per-artifact
245
+ * tmp+rename was never the problem — two builders with ASYMMETRIC write sets
246
+ * interleaved their renames, so a reader could consume meta.json from build A
247
+ * and files.json from build B. The lock lives inside the builder because the
248
+ * callers (refresh-index.mjs, build-index.mjs, git hooks) were all unlocked;
249
+ * ensureIndexFresh drives buildCodeIndexInner directly while it already
250
+ * holds the lock, so its outer hold cannot self-deadlock.
251
+ * Fail-closed: a lock that cannot be acquired inside the wait budget throws
252
+ * IndexBuildLockTimeoutError instead of publishing unlocked.
253
+ */
214
254
  export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
255
+ // A signal aborted before we even look at the lock must classify exactly
256
+ // like a discovery cancellation: callers rely on the typed
257
+ // IndexDiscoveryTimeoutError(aborted) and on no artifact directory ever
258
+ // appearing for a build that never started.
259
+ if (signal?.aborted) {
260
+ throw new IndexDiscoveryTimeoutError({ deadlineMs, phase: 'build', elapsedMs: 0, aborted: true });
261
+ }
262
+ const absoluteRoot = path.resolve(rootDir);
263
+ const lockBase = path.join(getIndexDir(absoluteRoot), INDEX_BUILD_LOCK_NAME);
264
+ const outcome = await withAsyncLock(
265
+ lockBase,
266
+ { signal, deadlineMs: INDEX_BUILD_LOCK_WAIT_MS },
267
+ () => buildCodeIndexInner({ rootDir: absoluteRoot, changedFiles, discoverySnapshot, signal, deadlineMs }),
268
+ );
269
+ if (outcome?.ok === true) {
270
+ return outcome.value;
271
+ }
272
+ if (outcome?.reason === 'aborted') {
273
+ throw new IndexCancelledError({ phase: 'build-lock' });
274
+ }
275
+ throw new IndexBuildLockTimeoutError({
276
+ waitedMs: outcome?.waitedMs ?? INDEX_BUILD_LOCK_WAIT_MS,
277
+ lockPath: `${lockBase}.lock`,
278
+ });
279
+ }
280
+
281
+ async function buildCodeIndexInner({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
215
282
  const absoluteRoot = path.resolve(rootDir);
216
283
  const indexDir = getIndexDir(absoluteRoot);
217
284
 
@@ -222,11 +289,22 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
222
289
  // wins over the optimization, and incremental output must stay equivalent to
223
290
  // a clean full rebuild.
224
291
  const changedHintPlan = normalizeChangedFileHints(changedFiles);
225
- if (changedHintPlan) {
226
- const incremental = changedHintPlan.ok
227
- ? await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal })
228
- : null;
229
- if (incremental) {
292
+ let incrementalFallbackReason = changedHintPlan && !changedHintPlan.ok
293
+ ? 'unsafe-changed-file-hints'
294
+ : null;
295
+ if (changedHintPlan?.ok) {
296
+ const incremental = await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal });
297
+ if (incremental?.rejected) {
298
+ // C92-B-02: a rejected merge is no longer silent. The reason lands on the
299
+ // build summary, and corruption-class rejections are also warned so the
300
+ // CLI callers that ignore the summary still surface it.
301
+ incrementalFallbackReason = incremental.detail
302
+ ? `${incremental.rejected} (${incremental.detail})`
303
+ : incremental.rejected;
304
+ if (incremental.detail) {
305
+ console.warn(`[index] incremental refresh rejected: meta.artifactItemCounts ${incremental.rejected} (${incremental.detail}) — falling back to full rebuild`);
306
+ }
307
+ } else if (incremental) {
230
308
  return incremental;
231
309
  }
232
310
  }
@@ -538,6 +616,11 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
538
616
  reusedAnalogs: canReuseAnalogs,
539
617
  preservedRuntimeCaches,
540
618
  mode: 'full',
619
+ // C92-B-02: when changed-file hints were offered but the incremental merge
620
+ // refused them, this names the rejection (e.g.
621
+ // 'artifact-item-counts-mismatch (files.json: recorded 4, actual 5)') so
622
+ // the fallback is diagnosable instead of silently returning 'full' forever.
623
+ incrementalFallbackReason,
541
624
  parsedFiles: filesToParse.slice(),
542
625
  removedFiles: [],
543
626
  };
@@ -658,19 +741,10 @@ function isValidCoveredItems(items, fileRecords, keyFn) {
658
741
  });
659
742
  }
660
743
 
661
- // Item shapes alone cannot detect a schema-current artifact whose `items` array
662
- // lost entries: every surviving entry still looks valid, yet the merge would
663
- // reuse it verbatim and publish an artifact missing the unchanged files. Each
664
- // required artifact therefore records its item count in meta, and the count must
665
- // match the array actually loaded — a mismatch (or a pre-count meta) falls back
666
- // to full discovery.
667
- function artifactItemCountsMatch(metaArtifact, counts) {
668
- const recorded = metaArtifact?.artifactItemCounts;
669
- if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded)) {
670
- return false;
671
- }
672
- return Object.entries(counts).every(([name, count]) => Number(recorded[name]) === count);
673
- }
744
+ // C92-B-02: every rejection from tryIncrementalIndexUpdate carries a reason
745
+ // (and, for integrity failures, a detail string) instead of a bare null, so a
746
+ // torn or corrupted artifact set lands on the build summary as
747
+ // incrementalFallbackReason rather than silently forcing 'full' forever.
674
748
 
675
749
  // Stat every hinted path and classify it as an upsert, a removal, or — for
676
750
  // extensions discovery never indexes — a no-op. Returns null when a hint
@@ -802,7 +876,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
802
876
  [INDEX_ARTIFACTS.calls]: callsArtifact?.items?.length ?? -1,
803
877
  [INDEX_ARTIFACTS.relations]: relationsArtifact?.items?.length ?? -1,
804
878
  };
805
- const previousArtifactsUsable = [
879
+ const previousArtifactsSchemaAndShapeUsable = [
806
880
  metaArtifact,
807
881
  filesArtifact,
808
882
  symbolsArtifact,
@@ -817,16 +891,29 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
817
891
  && isValidCoveredItems(importsArtifact.items, filesArtifact.items, (item) => item.from)
818
892
  && isValidCoveredItems(callsArtifact.items, filesArtifact.items, (item) => item.filePath)
819
893
  && isValidCoveredItems(relationsArtifact.items, filesArtifact.items, (item) => item.filePath)
820
- && Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles)
821
- && artifactItemCountsMatch(metaArtifact, artifactItemCounts);
822
- if (!previousArtifactsUsable) {
823
- return null;
894
+ && Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles);
895
+ if (!previousArtifactsSchemaAndShapeUsable) {
896
+ return { rejected: 'previous-artifacts-unusable' };
897
+ }
898
+ // C92-B-02: the count gate used to fold silently into the same null as every
899
+ // other unusability, so a torn publish permanently disabled incremental
900
+ // refresh with no diagnostic. Name the artifacts that disagree with meta.
901
+ const recordedCounts = metaArtifact?.artifactItemCounts;
902
+ const countsRecorded = recordedCounts && typeof recordedCounts === 'object' && !Array.isArray(recordedCounts);
903
+ const countMismatchDetail = !countsRecorded
904
+ ? 'artifactItemCounts missing from meta.json'
905
+ : Object.entries(artifactItemCounts)
906
+ .filter(([name, actual]) => Number(recordedCounts[name]) !== actual)
907
+ .map(([name, actual]) => `${name}: recorded ${recordedCounts[name] ?? 'missing'}, actual ${actual}`)
908
+ .join(', ');
909
+ if (!countsRecorded || countMismatchDetail) {
910
+ return { rejected: 'artifact-item-counts-mismatch', detail: countMismatchDetail };
824
911
  }
825
912
 
826
913
  const previousRecordsByPath = new Map(filesArtifact.items.map((item) => [item.filePath, item]));
827
914
  const hintUpdates = await resolveChangedHintUpdates(absoluteRoot, changedPaths, previousRecordsByPath);
828
915
  if (!hintUpdates) {
829
- return null;
916
+ return { rejected: 'changed-hints-unresolvable' };
830
917
  }
831
918
 
832
919
  // Merge the hint outcome into one sorted record set — the same shape and
@@ -895,7 +982,7 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
895
982
  }
896
983
  }, signal, 'stat-style'));
897
984
  if (styleEntries.some((entry) => entry?.statError)) {
898
- return null;
985
+ return { rejected: 'style-stat-unresolvable' };
899
986
  }
900
987
  const styleFilePaths = styleEntries.filter(Boolean).map((entry) => normalizeRelative(absoluteRoot, entry.absolutePath));
901
988
 
@@ -1209,7 +1296,7 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
1209
1296
  const directScores = new Map();
1210
1297
  for (const file of safeItems(files)) {
1211
1298
  const filePath = file.filePath;
1212
- if (isLikelyTestFilePath(filePath) && !includeLikelyTestFiles) continue;
1299
+ if (typeof filePath !== 'string' || !filePath || (isLikelyTestFilePath(filePath) && !includeLikelyTestFiles)) continue;
1213
1300
  const searchDescriptor = searchDescriptorsByFile.get(filePath) ?? createFileSearchDescriptor({ filePath, symbols: [] });
1214
1301
  const { score, reasons } = scoreDirectFileMatch({
1215
1302
  queryDescriptor,
@@ -1378,8 +1465,24 @@ function ensureArtifact(artifact) {
1378
1465
  };
1379
1466
  }
1380
1467
 
1468
+ // C92-B-03 consumer symmetry: an artifact written under a different
1469
+ // INDEX_SCHEMA_VERSION (older OR newer — a package downgrade leaves newer
1470
+ // artifacts on disk) is treated as absent, exactly like getFileOutline's
1471
+ // schema gate on symbols.json. Without it, queryCodeIndex scores the
1472
+ // mismatched set and looks healthy while outline: silently disappears.
1473
+ function safeSchemaArtifact(artifact) {
1474
+ const normalized = ensureArtifact(artifact);
1475
+ return normalized.schemaVersion === INDEX_SCHEMA_VERSION ? normalized : { items: [] };
1476
+ }
1477
+
1478
+ // The cache keys matched against this escaped value are produced by
1479
+ // JSON.stringify, which escapes control characters (\n, \b, \u00XX, lone
1480
+ // surrogates) on top of backslash/quote. Hand-escaping only \\ and \" means a
1481
+ // rootDir containing control characters never matches its own cache keys, so
1482
+ // clearIndexArtifactCache leaves stale results behind. Reuse JSON.stringify
1483
+ // itself and strip the surrounding quotes to stay in lockstep.
1381
1484
  function escapeJsonString(value) {
1382
- return String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"');
1485
+ return JSON.stringify(String(value)).slice(1, -1);
1383
1486
  }
1384
1487
 
1385
1488
  // ── Context Resolver ──
@@ -2939,8 +3042,10 @@ async function loadQuerySearchBundle(rootDir) {
2939
3042
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols),
2940
3043
  ])
2941
3044
  .then(([files, symbols]) => {
2942
- const safeFiles = ensureArtifact(files);
2943
- const safeSymbols = ensureArtifact(symbols);
3045
+ // C92-B-03: same schema contract getFileOutline applies — a mismatched
3046
+ // artifact reads as empty instead of feeding ranking.
3047
+ const safeFiles = safeSchemaArtifact(files);
3048
+ const safeSymbols = safeSchemaArtifact(symbols);
2944
3049
  const symbolMap = groupBy(safeItems(safeSymbols), (item) => item.filePath);
2945
3050
  return {
2946
3051
  files: safeFiles,
@@ -2972,11 +3077,11 @@ async function loadQuerySupportBundle(rootDir) {
2972
3077
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.archetypes),
2973
3078
  ]))
2974
3079
  .then(async ([files, imports, testsMap, hotspots, archetypes]) => {
2975
- const safeFiles = ensureArtifact(files);
2976
- const safeImports = ensureArtifact(imports);
2977
- const safeTestsMap = ensureArtifact(testsMap);
2978
- const safeHotspots = ensureArtifact(hotspots);
2979
- const safeArchetypes = ensureArtifact(archetypes);
3080
+ const safeFiles = safeSchemaArtifact(files);
3081
+ const safeImports = safeSchemaArtifact(imports);
3082
+ const safeTestsMap = safeSchemaArtifact(testsMap);
3083
+ const safeHotspots = safeSchemaArtifact(hotspots);
3084
+ const safeArchetypes = safeSchemaArtifact(archetypes);
2980
3085
  const indexedFileSet = new Set(safeItems(safeFiles).map((item) => item.filePath));
2981
3086
  const importAliasContext = importsNeedAliasContext(safeItems(safeImports))
2982
3087
  ? await loadImportAliasContext({ rootDir: absoluteRoot })
@@ -3399,7 +3504,7 @@ function buildResolvedImportGraphs(rootDir, imports, indexedFileSet, importAlias
3399
3504
  const importersByTarget = new Map();
3400
3505
 
3401
3506
  for (const edge of imports) {
3402
- if (!edge?.from || !edge?.to) continue;
3507
+ if (typeof edge?.from !== 'string' || typeof edge?.to !== 'string' || !edge.from || !edge.to) continue;
3403
3508
 
3404
3509
  const target = resolveImportSpecifier({
3405
3510
  rootDir,
@@ -3525,9 +3630,13 @@ function resolveImportSpecifier({
3525
3630
  indexedFileSet,
3526
3631
  aliasContext,
3527
3632
  }) {
3528
- if (!specifier?.trim()) return null;
3633
+ if (typeof specifier !== 'string' || !specifier.trim()) return null;
3529
3634
 
3530
3635
  if (specifier.startsWith('.')) {
3636
+ // A relative specifier cannot be resolved without the importing file, so a
3637
+ // malformed (non-string) edge.from must skip the edge instead of throwing
3638
+ // inside path.posix.dirname.
3639
+ if (typeof fromFilePath !== 'string') return null;
3531
3640
  const fromDir = path.posix.dirname(fromFilePath);
3532
3641
  const base = path.posix.normalize(path.posix.join(fromDir, specifier));
3533
3642
  return resolveCandidateBase(base, indexedFileSet);
@@ -4495,7 +4604,12 @@ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, e
4495
4604
  || Number.isNaN(maxAgeMs)
4496
4605
  || maxAgeMs < 0
4497
4606
  || (now - effectiveGeneratedAtMs) >= maxAgeMs
4498
- || !metaArtifact?.sourceFingerprint;
4607
+ || !metaArtifact?.sourceFingerprint
4608
+ // C92-B-03: an artifact set written under a different INDEX_SCHEMA_VERSION
4609
+ // — older OR newer — is not fresh. `!==` covers the package-downgrade
4610
+ // direction too; serving the old schema made `outline:` silently vanish
4611
+ // while ranking kept answering.
4612
+ || metaArtifact?.schemaVersion !== INDEX_SCHEMA_VERSION;
4499
4613
 
4500
4614
  // Legacy boolean callers (query-index, triage, verify-context) stop here and
4501
4615
  // let buildCodeIndex do its own discovery when a rebuild is needed.
@@ -4578,7 +4692,10 @@ export async function ensureIndexFresh({
4578
4692
  return lastRefreshMs;
4579
4693
  }
4580
4694
 
4581
- const lockPath = `${getIndexDir(absoluteRoot)}/index-build.lock`;
4695
+ // Shares the builder's lock: buildCodeIndex acquires `<base>.lock` for
4696
+ // base `index-build`, so this must name the same base — and drives the
4697
+ // unwrapped inner builder since this critical section already holds it.
4698
+ const lockPath = `${getIndexDir(absoluteRoot)}/${INDEX_BUILD_LOCK_NAME}`;
4582
4699
  const result = await withFileLock(lockPath, async () => {
4583
4700
  const lockedRefreshMs = await getIndexArtifactGeneratedAt({ rootDir: absoluteRoot });
4584
4701
  const lockedStale = lockedRefreshMs === null
@@ -4592,7 +4709,7 @@ export async function ensureIndexFresh({
4592
4709
  if (!lockedStale) {
4593
4710
  return { refreshed: false, generatedAtMs: lockedRefreshMs };
4594
4711
  }
4595
- const summary = await buildCodeIndex({ rootDir: absoluteRoot, ...discoveryOptions, discoverySnapshot });
4712
+ const summary = await buildCodeIndexInner({ rootDir: absoluteRoot, ...discoveryOptions, discoverySnapshot });
4596
4713
  return { refreshed: true, summary, generatedAtMs: summary?.generatedAtMs ?? Date.now() };
4597
4714
  });
4598
4715
 
@@ -17,7 +17,6 @@
17
17
  // Parity is locked by tests/consistency/registryParity.test.js.
18
18
 
19
19
  import fs from 'node:fs/promises';
20
- import fsSync from 'node:fs';
21
20
  import os from 'node:os';
22
21
  import path from 'node:path';
23
22
  import { fileURLToPath } from 'node:url';
@@ -77,12 +76,14 @@ const AUTONOMOUS_TRIGGER_RE = /\b(finish (?:this|the) backlog|bounded (?:long )?
77
76
  const RELEASE_TRIGGER_RE = /\b(publish|ship it|release|bump (?:the )?version|deploy|cut a release|tag (?:the )?release)\b/i;
78
77
  const HANDOFF_CYCLE_TRIGGER_RE = /\b(handoff|multi[- ]?task cycle|task pipeline|execute the plan|worktree)\b/i;
79
78
 
80
- // session-pickup discriminator needs the resumable-record check.
81
- function hasResumableRecord(projectRoot) {
79
+ // session-pickup discriminator needs the resumable-record check. ASYNC scan
80
+ // (W3-PB2): the resolve path runs inside hook processes where a sync
81
+ // readdirSync parks the event loop and can starve the hook self-deadline.
82
+ async function hasResumableRecord(projectRoot) {
82
83
  if (!projectRoot) return false;
83
84
  try {
84
85
  const runsDir = path.join(projectRoot, '.ukit', 'storage', 'runs');
85
- const entries = fsSync.readdirSync(runsDir, { withFileTypes: true });
86
+ const entries = await fs.readdir(runsDir, { withFileTypes: true });
86
87
  return entries.some((entry) => entry.isFile() && entry.name.endsWith('.json'));
87
88
  } catch {
88
89
  return false;
@@ -288,7 +289,7 @@ function matchFirst(text, regexes) {
288
289
  return -1;
289
290
  }
290
291
 
291
- function buildRouteSignals({
292
+ async function buildRouteSignals({
292
293
  signalText,
293
294
  executionMode,
294
295
  escalationTriggers,
@@ -357,7 +358,7 @@ function buildRouteSignals({
357
358
  testHarness: TEST_HARNESS_RE.test(signalText),
358
359
  measurable: /\d/.test(signalText) || MEASURABLE_RE.test(signalText),
359
360
  dataMove: DATA_MOVE_RE.test(signalText),
360
- hasResumableRecord: hasResumableRecord(projectRoot),
361
+ hasResumableRecord: await hasResumableRecord(projectRoot),
361
362
  // BL-026 review fix: ask to convert/author a skill — beats defect verbs in
362
363
  // the same sentence ("make this repeated fix flow a skill").
363
364
  skillAsk: SKILL_ASK_RE.test(signalText),
@@ -375,14 +376,17 @@ const MEASURABLE_RE = /\b(latency|baseline|benchmark|profile|cpu|memory|heap|tak
375
376
  // REQUIRED — a registry file without them is malformed (FR-007), where the
376
377
  // workflow-policy loader tolerates filename-stem ids.
377
378
  function parsePlaybookMeta(raw, filePath) {
378
- if (!raw.startsWith('---\n')) {
379
+ // W3-PB1: normalize CRLF before fence matching — a Windows-authored
380
+ // frontmatter block is still frontmatter, not malformed.
381
+ const text = String(raw).replace(/\r\n/g, '\n');
382
+ if (!text.startsWith('---\n')) {
379
383
  return null;
380
384
  }
381
- const fenceIndex = raw.indexOf('\n---', 3);
385
+ const fenceIndex = text.indexOf('\n---', 3);
382
386
  if (fenceIndex === -1) {
383
387
  return null; // unterminated frontmatter
384
388
  }
385
- const metaText = raw.slice(4, fenceIndex);
389
+ const metaText = text.slice(4, fenceIndex);
386
390
  const meta = {};
387
391
  for (const line of metaText.split('\n')) {
388
392
  const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
@@ -407,7 +411,7 @@ function parsePlaybookMeta(raw, filePath) {
407
411
  if (lanes.length === 0) {
408
412
  return null;
409
413
  }
410
- const body = raw.slice(fenceIndex + 4).replace(/^\n/, '');
414
+ const body = text.slice(fenceIndex + 4).replace(/^\n/, '');
411
415
  if (body.trim() === '') {
412
416
  return null; // empty body → not a playbook
413
417
  }
@@ -552,7 +556,7 @@ export async function resolvePlaybookId({
552
556
  ...(Array.isArray(escalationTriggers) ? escalationTriggers : []),
553
557
  ...deriveEscalationTriggers(floor),
554
558
  ])];
555
- const signals = buildRouteSignals({
559
+ const signals = await buildRouteSignals({
556
560
  signalText,
557
561
  executionMode,
558
562
  escalationTriggers: mergedTriggers,
@@ -74,15 +74,27 @@ async function readJsonl(filePath) {
74
74
  .filter(Boolean);
75
75
  }
76
76
 
77
+ // C92-D-03: a pre-edit manifest row is never rewritten once a post-edit pass
78
+ // consumes it, so "the last row with a rollbackPath and no afterHash" keeps
79
+ // matching every later edit of the same path — the delta gate then diffs
80
+ // against a two-generations-old blob and its BLOCKED remediation points at a
81
+ // restore target that discards landed work. Manifest ORDER is the consumption
82
+ // marker instead: scan newest → oldest and decide from the FIRST row seen for
83
+ // this path. If that row is a post-edit audit row (or carries no usable
84
+ // rollbackPath), the backup for it was already consumed — there is no baseline
85
+ // for this edit, and no older row may be resurrected.
77
86
  async function findLatestBackup(projectRoot, relativePath) {
78
87
  const backupsRoot = path.join(projectRoot, '.ukit', 'storage', 'backups');
79
88
  for (const manifestPath of await listManifestPaths(backupsRoot)) {
80
89
  const entries = await readJsonl(manifestPath);
81
90
  for (let index = entries.length - 1; index >= 0; index -= 1) {
82
91
  const entry = entries[index];
83
- if (entry.file === relativePath && entry.rollbackPath && !entry.afterHash) {
84
- return { entry, manifestPath };
92
+ if (entry.file !== relativePath) continue;
93
+ const isPostEditRow = entry.event === 'post-edit' || Boolean(entry.afterHash);
94
+ if (isPostEditRow || !entry.rollbackPath) {
95
+ return { consumed: true, manifestPath };
85
96
  }
97
+ return { entry, manifestPath };
86
98
  }
87
99
  }
88
100
  return null;
@@ -171,11 +183,20 @@ export async function verifyPostEdit({ projectRoot = process.cwd(), payload = {}
171
183
  if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'missing-after', file: resolved.relative };
172
184
 
173
185
  const latest = await findLatestBackup(projectRoot, resolved.relative);
174
- if (!latest) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
186
+ // Consumed (or absent) baseline → there is nothing truthful to diff against.
187
+ // Skipping is deliberate: a fabricated delta is strictly worse than no gate.
188
+ if (!latest || latest.consumed) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
175
189
 
176
190
  const rollbackPath = path.resolve(projectRoot, latest.entry.rollbackPath);
177
- const beforeBuffer = await fs.readFile(rollbackPath);
191
+ const beforeBuffer = await fs.readFile(rollbackPath).catch(() => null);
192
+ if (!beforeBuffer) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
178
193
  const beforeProfile = analyzeTextBuffer(beforeBuffer);
194
+ // Self-validating baseline: when the producer recorded beforeHash, the blob
195
+ // must hash to exactly that value. A mismatch means the blob is stale or
196
+ // corrupt — treat it as missing rather than diff against wrong content.
197
+ if (latest.entry.beforeHash && beforeProfile.sha256 !== latest.entry.beforeHash) {
198
+ return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
199
+ }
179
200
  const afterProfile = await analyzeTextFile(resolved.absolute);
180
201
  const risk = classifySafePatchRisk(resolved.relative, afterProfile, config);
181
202
  const delta = beforeProfile.utf8Valid && afterProfile.utf8Valid
@@ -64,6 +64,10 @@ async function backupIfNeeded({ projectRoot, payload }) {
64
64
  await fs.copyFile(resolved.absolute, rollbackPath);
65
65
  const manifestPath = path.join(backupRoot, 'manifest.jsonl');
66
66
  const entry = {
67
+ // C92-D-03: explicit row typing — post-edit-verify consumes a baseline by
68
+ // manifest order, so readers must be able to tell a pre-edit backup row
69
+ // from a post-edit audit row without inferring it from field presence.
70
+ event: 'pre-edit',
67
71
  file: resolved.relative,
68
72
  beforeHash: profile.sha256,
69
73
  profile: publicTextProfile(profile),
@@ -123,12 +123,16 @@ function excludeNodeModulesFromGit(root) {
123
123
  }
124
124
  }
125
125
 
126
- // The `.claude` copy must never clobber a git-TRACKED file (today exactly
127
- // `.claude/commands/ukit/handoff-fullstack.md` — owned by TASK-005). A fresh
128
- // worktree already has that file checked out by `git worktree add`; if the
129
- // gitignored dev mirror in mainRoot has locally drifted from the committed
130
- // copy, an unconditional recursive copy would silently overwrite the
131
- // worktree's tracked file with main's unreviewed local state.
126
+ // The `.claude` copy must never clobber a file that is git-TRACKED **in the
127
+ // worktree being populated** (today exactly `.claude/commands/ukit/
128
+ // handoff-fullstack.md` — owned by TASK-005). A fresh worktree already has
129
+ // that file checked out by `git worktree add`; if the gitignored dev mirror
130
+ // in mainRoot has locally drifted from the committed copy, an unconditional
131
+ // recursive copy would silently overwrite the worktree's tracked file with
132
+ // main's unreviewed local state. W3-02: the probe must run against the
133
+ // DESTINATION worktree's index (`git -C <wtPath> ls-files`), not the main
134
+ // tree's — a file tracked only on the worktree's branch is invisible to the
135
+ // main index and would be clobbered.
132
136
  function trackedClaudeFiles(root) {
133
137
  try {
134
138
  const res = gitProbe(['ls-files', '--', '.claude'], root);
@@ -183,10 +187,11 @@ if (!fs.existsSync(srcClaude)) {
183
187
  } else {
184
188
  // Always (re)copy: a fresh worktree may already carry a partial, git-tracked
185
189
  // .claude/ (e.g. commands), which must not make us skip the hooks/scripts.
186
- // Never overwrite a git-tracked file under .claude/ (e.g.
187
- // handoff-fullstack.md) — leave whatever `git worktree add` already
188
- // checked out for it alone.
189
- const trackedInClaude = trackedClaudeFiles(mainRoot);
190
+ // Never overwrite a file tracked in the WORKTREE's index (e.g.
191
+ // handoff-fullstack.md, or a file committed only on the worktree's branch)
192
+ // — leave whatever `git worktree add` already checked out for it alone.
193
+ // W3-02: probe wtPath, not mainRoot — each linked worktree has its own index.
194
+ const trackedInClaude = trackedClaudeFiles(wtPath);
190
195
  fs.cpSync(srcClaude, dstClaude, {
191
196
  recursive: true,
192
197
  filter: (src) => {
@@ -24,7 +24,7 @@ const rootDir = getRootDir(args);
24
24
  const limitArg = readFlagValue(args, '--limit');
25
25
  const limit = Number.parseInt(limitArg ?? '5', 10);
26
26
  const showOutline = !args.includes('--no-outline');
27
- const query = collectPositionalArgs(args, ['--root', '--limit']);
27
+ const query = collectPositionalArgs(args, ['--no-outline']);
28
28
 
29
29
  if (!query) {
30
30
  console.error('Usage: node .claude/ukit/index/query-index.mjs "<error|symbol|path>"');
@@ -167,23 +167,30 @@ function getRootDir(argv) {
167
167
 
168
168
  function readFlagValue(argv, flag) {
169
169
  const exact = argv.indexOf(flag);
170
- if (exact >= 0 && argv[exact + 1]) return argv[exact + 1];
170
+ if (exact >= 0 && argv[exact + 1] && !argv[exact + 1].startsWith('--')) {
171
+ return argv[exact + 1];
172
+ }
171
173
  const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
172
174
  return withEquals ? withEquals.slice(flag.length + 1) : null;
173
175
  }
174
176
 
175
- function collectPositionalArgs(argv, flagsWithValues = []) {
177
+ function collectPositionalArgs(argv, booleanFlags = []) {
176
178
  return argv
177
- .filter((arg, index) => !isFlagOrValue(argv, index, flagsWithValues))
179
+ .filter((arg, index) => !isFlagOrValue(argv, index, booleanFlags))
178
180
  .join(' ')
179
181
  .trim();
180
182
  }
181
183
 
182
- function isFlagOrValue(argv, index, flagsWithValues = []) {
184
+ function isFlagOrValue(argv, index, booleanFlags = []) {
183
185
  const arg = argv[index];
184
186
  if (!arg.startsWith('--')) {
185
187
  const prev = argv[index - 1];
186
- return Boolean(prev && flagsWithValues.includes(prev));
188
+ // A bare token following a `--flag` is that flag's value — the previous
189
+ // parser let unknown flags (e.g. `--foo bar`) leak `bar` into the query.
190
+ // Boolean flags (`--no-outline`) and `--flag=value` forms consume nothing.
191
+ if (!prev || !prev.startsWith('--')) return false;
192
+ if (booleanFlags.includes(prev) || prev.includes('=')) return false;
193
+ return true;
187
194
  }
188
195
  return true;
189
196
  }