@ngockhoale/ukit 3.0.3 → 3.0.5

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 (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +13 -9
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +26 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +22 -9
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -3,16 +3,28 @@ import path from 'node:path';
3
3
 
4
4
  import { triageBug } from '../../src/bug/triageBug.js';
5
5
 
6
- const args = process.argv.slice(2);
7
- const rootArg = readFlagValue(args, '--root');
8
- const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
6
+ async function main() {
7
+ const args = process.argv.slice(2);
8
+ const { value: rootArg, args: remainingArgs } = extractFlagValue(args, '--root');
9
+ const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
9
10
 
10
- const signature = args.filter((arg) => !arg.startsWith('--')).join(' ').trim();
11
+ // Everything after `--` is signature text; before it, only positional args
12
+ // are — the --root value was already consumed by extractFlagValue.
13
+ const separatorIndex = remainingArgs.indexOf('--');
14
+ const positional = separatorIndex === -1 ? remainingArgs : remainingArgs.slice(0, separatorIndex);
15
+ const afterSeparator = separatorIndex === -1 ? [] : remainingArgs.slice(separatorIndex + 1);
16
+ const unknown = positional.filter((arg) => arg.startsWith('--'));
17
+ if (unknown.length > 0) {
18
+ throw new Error(`unknown argument: ${unknown[0]}`);
19
+ }
20
+ const signature = [...positional, ...afterSeparator].join(' ').trim();
21
+
22
+ if (!signature) {
23
+ console.error('Usage: yarn bug:triage -- "<error signature>"');
24
+ process.exitCode = 1;
25
+ return;
26
+ }
11
27
 
12
- if (!signature) {
13
- console.error('Usage: yarn bug:triage -- "<error signature>"');
14
- process.exitCode = 1;
15
- } else {
16
28
  const result = await triageBug({ rootDir, signature });
17
29
  console.log('[bug:triage]');
18
30
  console.log(`lane: ${result.lane}`);
@@ -22,16 +34,43 @@ if (!signature) {
22
34
  console.log(`test: ${result.recommendedTestCommand}`);
23
35
  }
24
36
 
25
- function readFlagValue(argv, flag) {
26
- const exact = argv.indexOf(flag);
27
- if (exact >= 0 && argv[exact + 1]) {
28
- return argv[exact + 1];
29
- }
37
+ function extractFlagValue(argv, flag) {
38
+ let value = null;
39
+ const nextArgs = [];
40
+
41
+ for (let i = 0; i < argv.length; i += 1) {
42
+ const arg = argv[i];
30
43
 
31
- const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
32
- if (withEquals) {
33
- return withEquals.slice(flag.length + 1);
44
+ if (arg === flag) {
45
+ if (value !== null) {
46
+ nextArgs.push(arg);
47
+ continue;
48
+ }
49
+ const maybeValue = argv[i + 1];
50
+ if (!maybeValue || maybeValue.startsWith('--')) {
51
+ throw new Error(`Missing value for ${flag}.`);
52
+ }
53
+ value = maybeValue;
54
+ i += 1;
55
+ continue;
56
+ }
57
+
58
+ if (arg.startsWith(`${flag}=`)) {
59
+ if (value === null) {
60
+ value = arg.slice(flag.length + 1);
61
+ continue;
62
+ }
63
+ }
64
+
65
+ nextArgs.push(arg);
34
66
  }
35
67
 
36
- return null;
68
+ return { value, args: nextArgs };
69
+ }
70
+
71
+ try {
72
+ await main();
73
+ } catch (error) {
74
+ console.error(error && error.message ? error.message : error);
75
+ process.exit(1);
37
76
  }
@@ -1,36 +1,102 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
 
4
- import { buildCodeIndex, buildDerivedIndexArtifacts } from '../../src/index/buildIndex.js';
5
-
6
- const args = process.argv.slice(2);
7
- const rootArg = readFlagValue(args, '--root');
8
- const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
9
-
10
- const summary = await buildCodeIndex({ rootDir });
11
- await buildDerivedIndexArtifacts(rootDir);
12
-
13
- console.log('[index:build] completed');
14
- console.log(`root: ${rootDir}`);
15
- console.log(`files: ${summary.fileCount}`);
16
- console.log(`symbols: ${summary.symbolCount}`);
17
- console.log(`imports: ${summary.importCount}`);
18
- console.log(`tests-map: ${summary.testsMapCount}`);
19
- console.log(`hotspots: ${summary.hotspotCount}`);
20
- console.log(`parsed: ${summary.parsedCodeFileCount}`);
21
- console.log(`reused: ${summary.reusedCodeFileCount}`);
22
- console.log(`indexDir: ${summary.indexDir}`);
23
-
24
- function readFlagValue(argv, flag) {
25
- const exact = argv.indexOf(flag);
26
- if (exact >= 0 && argv[exact + 1]) {
27
- return argv[exact + 1];
5
+ import {
6
+ buildCodeIndex,
7
+ buildDerivedIndexArtifacts,
8
+ inspectIndexStaleness,
9
+ mapLimit,
10
+ INDEX_IO_CONCURRENCY,
11
+ } from '../../src/index/buildIndex.js';
12
+
13
+ async function main() {
14
+ const args = process.argv.slice(2);
15
+ const { value: rootArg, args: remainingArgs } = extractFlagValue(args, '--root');
16
+ const unexpected = remainingArgs.filter((arg) => arg.startsWith('--'));
17
+ if (unexpected.length > 0) {
18
+ throw new Error(`unexpected argument: ${unexpected[0]}`);
19
+ }
20
+ const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
21
+
22
+ // One enumeration serves both the readability check and the build: the
23
+ // snapshot is handed to buildCodeIndex, which reuses it instead of
24
+ // rediscovering the repository.
25
+ const { snapshot } = await inspectIndexStaleness({ rootDir, maxAgeMs: 0 });
26
+ const unreadable = await collectUnreadableFiles(rootDir, snapshot?.files ?? []);
27
+ for (const filePath of unreadable) {
28
+ console.error(`[index:build] skipped unreadable file: ${filePath}`);
28
29
  }
29
30
 
30
- const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
31
- if (withEquals) {
32
- return withEquals.slice(flag.length + 1);
31
+ const summary = await buildCodeIndex({ rootDir, discoverySnapshot: snapshot });
32
+ await buildDerivedIndexArtifacts(rootDir);
33
+
34
+ console.log('[index:build] completed');
35
+ console.log(`root: ${rootDir}`);
36
+ console.log(`files: ${summary.fileCount}`);
37
+ console.log(`symbols: ${summary.symbolCount}`);
38
+ console.log(`imports: ${summary.importCount}`);
39
+ console.log(`tests-map: ${summary.testsMapCount}`);
40
+ console.log(`hotspots: ${summary.hotspotCount}`);
41
+ console.log(`parsed: ${summary.parsedCodeFileCount}`);
42
+ console.log(`reused: ${summary.reusedCodeFileCount}`);
43
+ if (unreadable.length > 0) {
44
+ console.log(`skipped-unreadable: ${unreadable.length}`);
33
45
  }
46
+ console.log(`indexDir: ${summary.indexDir}`);
47
+ }
48
+
49
+ // The parse worker inside buildCodeIndex skips files it cannot read; the CLI
50
+ // surfaces them so a partial index is never silent.
51
+ async function collectUnreadableFiles(rootDir, entries) {
52
+ const unreadable = await mapLimit(entries, INDEX_IO_CONCURRENCY, async (entry) => {
53
+ try {
54
+ await fs.access(entry.absolutePath, fs.constants.R_OK);
55
+ return null;
56
+ } catch {
57
+ return path.relative(rootDir, entry.absolutePath).split(path.sep).join('/');
58
+ }
59
+ });
60
+ return unreadable.filter(Boolean).sort();
61
+ }
62
+
63
+ function extractFlagValue(argv, flag) {
64
+ let value = null;
65
+ const nextArgs = [];
66
+
67
+ for (let i = 0; i < argv.length; i += 1) {
68
+ const arg = argv[i];
69
+
70
+ if (arg === flag) {
71
+ if (value !== null) {
72
+ nextArgs.push(arg);
73
+ continue;
74
+ }
75
+ const maybeValue = argv[i + 1];
76
+ if (!maybeValue || maybeValue.startsWith('--')) {
77
+ throw new Error(`Missing value for ${flag}.`);
78
+ }
79
+ value = maybeValue;
80
+ i += 1;
81
+ continue;
82
+ }
83
+
84
+ if (arg.startsWith(`${flag}=`)) {
85
+ if (value === null) {
86
+ value = arg.slice(flag.length + 1);
87
+ continue;
88
+ }
89
+ }
90
+
91
+ nextArgs.push(arg);
92
+ }
93
+
94
+ return { value, args: nextArgs };
95
+ }
34
96
 
35
- return null;
97
+ try {
98
+ await main();
99
+ } catch (error) {
100
+ console.error(error && error.message ? error.message : error);
101
+ process.exit(1);
36
102
  }
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
 
4
5
  import {
@@ -7,28 +8,40 @@ import {
7
8
  import {
8
9
  buildCodeIndex,
9
10
  buildDerivedIndexArtifacts,
11
+ inspectIndexStaleness,
10
12
  isIndexStale,
13
+ mapLimit,
14
+ INDEX_IO_CONCURRENCY,
11
15
  DEFAULT_INDEX_CACHE_MAX_AGE_MS,
12
16
  } from '../../src/index/buildIndex.js';
13
17
  import { parseIndexArgs, QUERY_FLAG_DEFINITIONS } from '../../src/cli/commands/indexArgs.js';
14
18
 
15
- const args = process.argv.slice(2);
16
- const { value: rootArg, args: afterRootArgs } = extractFlagValue(args, '--root');
17
- const { value: changedArg, args: remainingArgs } = extractFlagValue(afterRootArgs, '--changed');
18
- const changedFiles = changedArg
19
- ? changedArg.split(',').map((item) => item.trim()).filter(Boolean)
20
- : [];
21
- const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
19
+ async function main() {
20
+ const args = process.argv.slice(2);
21
+ const { value: rootArg, args: afterRootArgs } = extractFlagValue(args, '--root');
22
+ const { value: changedArg, args: remainingArgs } = extractFlagValue(afterRootArgs, '--changed');
23
+ const changedFiles = changedArg
24
+ ? changedArg.split(',').map((item) => item.trim()).filter(Boolean)
25
+ : [];
26
+ const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
27
+
28
+ const { tokens, flags } = parseIndexArgs(remainingArgs, QUERY_FLAG_DEFINITIONS);
29
+ const query = tokens.join(' ').trim();
30
+ if (!query) {
31
+ console.error('Usage: yarn index:query -- "<error|symbol|path>" [--limit N] [--changed fileA,fileB]');
32
+ process.exitCode = 1;
33
+ return;
34
+ }
22
35
 
23
- const { tokens, flags } = parseIndexArgs(remainingArgs, QUERY_FLAG_DEFINITIONS);
24
- const query = tokens.join(' ').trim();
25
- if (!query) {
26
- console.error('Usage: yarn index:query -- "<error|symbol|path>" [--limit N] [--changed fileA,fileB]');
27
- process.exitCode = 1;
28
- } else {
29
36
  const stale = await isIndexStale({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
30
37
  if (stale) {
31
- await buildCodeIndex({ rootDir });
38
+ // One enumeration serves the readability check and the rebuild.
39
+ const { snapshot } = await inspectIndexStaleness({ rootDir, maxAgeMs: 0 });
40
+ const unreadable = await collectUnreadableFiles(rootDir, snapshot?.files ?? []);
41
+ for (const filePath of unreadable) {
42
+ console.error(`[index:query] skipped unreadable file: ${filePath}`);
43
+ }
44
+ await buildCodeIndex({ rootDir, discoverySnapshot: snapshot });
32
45
  await buildDerivedIndexArtifacts(rootDir);
33
46
  }
34
47
 
@@ -59,6 +72,20 @@ if (!query) {
59
72
  }
60
73
  }
61
74
 
75
+ // The parse worker inside buildCodeIndex skips files it cannot read; the CLI
76
+ // surfaces them so a partial index is never silent.
77
+ async function collectUnreadableFiles(rootDir, entries) {
78
+ const unreadable = await mapLimit(entries, INDEX_IO_CONCURRENCY, async (entry) => {
79
+ try {
80
+ await fs.access(entry.absolutePath, fs.constants.R_OK);
81
+ return null;
82
+ } catch {
83
+ return path.relative(rootDir, entry.absolutePath).split(path.sep).join('/');
84
+ }
85
+ });
86
+ return unreadable.filter(Boolean).sort();
87
+ }
88
+
62
89
  function extractFlagValue(argv, flag) {
63
90
  let value = null;
64
91
  const nextArgs = [];
@@ -92,3 +119,10 @@ function extractFlagValue(argv, flag) {
92
119
 
93
120
  return { value, args: nextArgs };
94
121
  }
122
+
123
+ try {
124
+ await main();
125
+ } catch (error) {
126
+ console.error(error && error.message ? error.message : error);
127
+ process.exit(1);
128
+ }
@@ -5,46 +5,63 @@ import path from 'node:path';
5
5
  import {
6
6
  buildCodeIndex,
7
7
  inspectIndexStaleness,
8
+ mapLimit,
9
+ INDEX_IO_CONCURRENCY,
8
10
  DEFAULT_INDEX_CACHE_MAX_AGE_MS,
9
11
  } from '../../src/index/buildIndex.js';
10
12
 
11
- const args = process.argv.slice(2);
12
- const rootArg = readFlagValue(args, '--root');
13
- const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
13
+ async function main() {
14
+ const args = process.argv.slice(2);
15
+ const { value: rootArg, args: afterRootArgs } = extractFlagValue(args, '--root');
16
+ const { value: changedArg, args: remainingArgs } = extractFlagValue(afterRootArgs, '--changed');
17
+ const force = remainingArgs.includes('--force');
18
+ const unexpected = remainingArgs.filter((arg) => arg !== '--force');
19
+ if (unexpected.length > 0) {
20
+ throw new Error(`unexpected argument: ${unexpected[0]}`);
21
+ }
14
22
 
15
- const changedArg = readFlagValue(args, '--changed');
16
- const changedFiles = changedArg
17
- ? changedArg.split(',').map((item) => item.trim()).filter(Boolean)
18
- : [];
23
+ const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
24
+ const hintedFiles = changedArg
25
+ ? changedArg.split(',').map((item) => item.trim()).filter(Boolean)
26
+ : [];
19
27
 
20
- const force = readBooleanFlag(args, '--force');
21
- const lastRefreshMs = await getLastRefreshTime(rootDir);
28
+ // Hints that exist but cannot be read would be silently dropped by the parse
29
+ // worker; surface them and keep them out of the merge set. ENOENT hints are
30
+ // kept — a deleted file is a legitimate removal hint.
31
+ const { readable: changedFiles, unreadable: unreadableHints } = await partitionReadableHints(rootDir, hintedFiles);
32
+ for (const filePath of unreadableHints) {
33
+ console.error(`[index:refresh] skipped unreadable file: ${filePath}`);
34
+ }
22
35
 
23
- if (changedFiles.length > 0 && !force) {
24
- // Hints carry the change set, so no staleness enumeration is needed:
25
- // buildCodeIndex merges the hinted paths into the existing artifacts and
26
- // falls back to the bounded full discovery path on its own when the hints
27
- // are unsafe or the previous artifacts cannot be merged (TASK-023).
28
- const summary = await buildCodeIndex({ rootDir, changedFiles });
36
+ const lastRefreshMs = await getLastRefreshTime(rootDir);
29
37
 
30
- console.log('[index:refresh] completed');
31
- console.log(`mode: ${summary.mode}`);
32
- if (lastRefreshMs !== null) {
33
- console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
34
- }
35
- console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
36
- console.log(`root: ${rootDir}`);
37
- console.log(`files: ${summary.fileCount}`);
38
- console.log(`parsed: ${summary.parsedCodeFileCount}`);
39
- console.log(`reused: ${summary.reusedCodeFileCount}`);
40
- if (summary.removedFiles.length > 0) {
41
- console.log(`removed: ${summary.removedFiles.length}`);
38
+ if (changedFiles.length > 0 && !force) {
39
+ // Hints carry the change set, so no staleness enumeration is needed:
40
+ // buildCodeIndex merges the hinted paths into the existing artifacts and
41
+ // falls back to the bounded full discovery path on its own when the hints
42
+ // are unsafe or the previous artifacts cannot be merged (TASK-023).
43
+ const summary = await buildCodeIndex({ rootDir, changedFiles });
44
+
45
+ console.log('[index:refresh] completed');
46
+ console.log(`mode: ${summary.mode}`);
47
+ if (lastRefreshMs !== null) {
48
+ console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
49
+ }
50
+ console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
51
+ console.log(`root: ${rootDir}`);
52
+ console.log(`files: ${summary.fileCount}`);
53
+ console.log(`parsed: ${summary.parsedCodeFileCount}`);
54
+ console.log(`reused: ${summary.reusedCodeFileCount}`);
55
+ if (summary.removedFiles.length > 0) {
56
+ console.log(`removed: ${summary.removedFiles.length}`);
57
+ }
58
+ console.log(`indexDir: ${summary.indexDir}`);
59
+ return;
42
60
  }
43
- console.log(`indexDir: ${summary.indexDir}`);
44
- } else {
45
- const staleness = force
46
- ? null
47
- : await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
61
+
62
+ // One enumeration serves the staleness check, the readability check, and the
63
+ // build: the snapshot is handed to buildCodeIndex, which reuses it.
64
+ const staleness = await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
48
65
  const stale = force || staleness.stale;
49
66
 
50
67
  if (!stale) {
@@ -56,45 +73,101 @@ if (changedFiles.length > 0 && !force) {
56
73
  console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
57
74
  }
58
75
  console.log(`root: ${rootDir}`);
59
- } else {
60
- // Reuse the staleness check's discovery snapshot: one enumeration per refresh.
61
- const summary = await buildCodeIndex({
62
- rootDir,
63
- discoverySnapshot: staleness ? staleness.snapshot : null,
64
- });
76
+ return;
77
+ }
65
78
 
66
- console.log('[index:refresh] completed');
67
- console.log(`mode: ${summary.mode}`);
68
- if (lastRefreshMs !== null) {
69
- console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
79
+ const unreadable = await collectUnreadableFiles(rootDir, staleness.snapshot?.files ?? []);
80
+ for (const filePath of unreadable) {
81
+ console.error(`[index:refresh] skipped unreadable file: ${filePath}`);
82
+ }
83
+
84
+ const summary = await buildCodeIndex({
85
+ rootDir,
86
+ discoverySnapshot: staleness.snapshot,
87
+ });
88
+
89
+ console.log('[index:refresh] completed');
90
+ console.log(`mode: ${summary.mode}`);
91
+ if (lastRefreshMs !== null) {
92
+ console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
93
+ }
94
+ if (changedFiles.length > 0) {
95
+ console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
96
+ }
97
+ console.log(`root: ${rootDir}`);
98
+ console.log(`files: ${summary.fileCount}`);
99
+ console.log(`parsed: ${summary.parsedCodeFileCount}`);
100
+ console.log(`reused: ${summary.reusedCodeFileCount}`);
101
+ if (unreadable.length > 0) {
102
+ console.log(`skipped-unreadable: ${unreadable.length}`);
103
+ }
104
+ console.log(`indexDir: ${summary.indexDir}`);
105
+ }
106
+
107
+ // The parse worker inside buildCodeIndex skips files it cannot read; the CLI
108
+ // surfaces them so a partial index is never silent.
109
+ async function collectUnreadableFiles(rootDir, entries) {
110
+ const unreadable = await mapLimit(entries, INDEX_IO_CONCURRENCY, async (entry) => {
111
+ try {
112
+ await fs.access(entry.absolutePath, fs.constants.R_OK);
113
+ return null;
114
+ } catch {
115
+ return path.relative(rootDir, entry.absolutePath).split(path.sep).join('/');
70
116
  }
71
- if (changedFiles.length > 0) {
72
- console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
117
+ });
118
+ return unreadable.filter(Boolean).sort();
119
+ }
120
+
121
+ async function partitionReadableHints(rootDir, hints) {
122
+ const readable = [];
123
+ const unreadable = [];
124
+ for (const hint of hints) {
125
+ try {
126
+ await fs.access(path.resolve(rootDir, hint), fs.constants.R_OK);
127
+ readable.push(hint);
128
+ } catch (error) {
129
+ if (error?.code === 'ENOENT') {
130
+ readable.push(hint); // deleted file — a legitimate removal hint
131
+ } else {
132
+ unreadable.push(hint);
133
+ }
73
134
  }
74
- console.log(`root: ${rootDir}`);
75
- console.log(`files: ${summary.fileCount}`);
76
- console.log(`parsed: ${summary.parsedCodeFileCount}`);
77
- console.log(`reused: ${summary.reusedCodeFileCount}`);
78
- console.log(`indexDir: ${summary.indexDir}`);
79
135
  }
136
+ return { readable, unreadable };
80
137
  }
81
138
 
82
- function readFlagValue(argv, flag) {
83
- const exact = argv.indexOf(flag);
84
- if (exact >= 0 && argv[exact + 1]) {
85
- return argv[exact + 1];
86
- }
139
+ function extractFlagValue(argv, flag) {
140
+ let value = null;
141
+ const nextArgs = [];
87
142
 
88
- const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
89
- if (withEquals) {
90
- return withEquals.slice(flag.length + 1);
91
- }
143
+ for (let i = 0; i < argv.length; i += 1) {
144
+ const arg = argv[i];
92
145
 
93
- return null;
94
- }
146
+ if (arg === flag) {
147
+ if (value !== null) {
148
+ nextArgs.push(arg);
149
+ continue;
150
+ }
151
+ const maybeValue = argv[i + 1];
152
+ if (!maybeValue || maybeValue.startsWith('--')) {
153
+ throw new Error(`Missing value for ${flag}.`);
154
+ }
155
+ value = maybeValue;
156
+ i += 1;
157
+ continue;
158
+ }
159
+
160
+ if (arg.startsWith(`${flag}=`)) {
161
+ if (value === null) {
162
+ value = arg.slice(flag.length + 1);
163
+ continue;
164
+ }
165
+ }
95
166
 
96
- function readBooleanFlag(argv, flag) {
97
- return argv.includes(flag);
167
+ nextArgs.push(arg);
168
+ }
169
+
170
+ return { value, args: nextArgs };
98
171
  }
99
172
 
100
173
  async function getLastRefreshTime(rootDir) {
@@ -113,3 +186,10 @@ async function getLastRefreshTime(rootDir) {
113
186
  return null;
114
187
  }
115
188
  }
189
+
190
+ try {
191
+ await main();
192
+ } catch (error) {
193
+ console.error(error && error.message ? error.message : error);
194
+ process.exit(1);
195
+ }
@@ -53,6 +53,12 @@ const CLUSTER_GAP_MS = 1500;
53
53
  // is a finding in its own right, not merely a term inside a chain total.
54
54
  const HOTSPOT_P50_MS = 50;
55
55
 
56
+ // TASK-010: every spawned child carries an explicit timeout + killSignal — a
57
+ // wedged hook script or git child must fail the benchmark sample, not hang the
58
+ // audit forever (same class as BUG-C22-20/21). UKIT_PERF_SPAWN_TIMEOUT_MS
59
+ // overrides the budget for tests.
60
+ const SPAWN_TIMEOUT_MS = Number(process.env.UKIT_PERF_SPAWN_TIMEOUT_MS) || 60 * 1000; // 1 min
61
+
56
62
  // A hook row costs the wrapper `bash`, its `node` runtime, and the advisory
57
63
  // telemetry `node --finish` child armed by hook-telemetry.sh.
58
64
  const PROCESSES_PER_HOOK_ROW = 3;
@@ -420,7 +426,7 @@ function benchSpawn(file, args, iterations, env) {
420
426
  const samples = [];
421
427
  for (let i = 0; i < iterations; i += 1) {
422
428
  const start = process.hrtime.bigint();
423
- spawnSync(file, args, { env, input: '{}', stdio: ['pipe', 'ignore', 'ignore'] });
429
+ spawnSync(file, args, { env, input: '{}', stdio: ['pipe', 'ignore', 'ignore'], timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL' });
424
430
  samples.push(Number(process.hrtime.bigint() - start) / 1e6);
425
431
  }
426
432
  return summarise(samples);
@@ -560,7 +566,7 @@ function recentHotPathAdditions(root, sinceDays) {
560
566
  '.claude/hooks',
561
567
  '.claude/ukit/runtime',
562
568
  ],
563
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
569
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL' },
564
570
  );
565
571
  const commits = [];
566
572
  let current = null;