@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
@@ -1,32 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
  import { initSkillAudit, recordAuditEntry, statusSkillAudit } from '../../src/skill/auditSkill.js';
3
3
 
4
- const [command, skillName] = process.argv.slice(2);
5
- const rootDir = process.cwd();
4
+ const USAGE = 'Usage: yarn skill:audit <init|status|record> <skill-name>';
6
5
 
7
- if (!command || !skillName) {
8
- console.error('Usage: yarn skill:audit <init|status|record> <skill-name>');
9
- process.exitCode = 1;
10
- } else if (command === 'init') {
11
- const result = await initSkillAudit({ rootDir, skillName });
12
- console.log(`[skill:audit init] ${skillName}`);
13
- console.log(`dir: ${result.auditDir}`);
14
- console.log(`copied: ${result.copied.join(', ') || 'none'}`);
15
- console.log(`skipped (already present): ${result.skipped.join(', ') || 'none'}`);
16
- } else if (command === 'status') {
17
- const result = await statusSkillAudit({ rootDir, skillName });
18
- console.log(`[skill:audit status] ${skillName}`);
19
- console.log(`entries: ${result.entryCount}`);
20
- console.log(`latest: ${result.latest ? JSON.stringify(result.latest) : 'none'}`);
21
- console.log(`open rationalizations: ${result.openRationalizations.length}`);
22
- } else if (command === 'record') {
23
- const stdinRaw = await readStdin();
24
- const entry = JSON.parse(stdinRaw);
25
- const record = await recordAuditEntry({ rootDir, skillName, entry });
26
- console.log(`[skill:audit record] ${skillName}`);
27
- console.log(JSON.stringify(record, null, 2));
28
- } else {
29
- console.error(`Unknown command: ${command}. Usage: yarn skill:audit <init|status|record> <skill-name>`);
6
+ async function main() {
7
+ const [command, skillName, ...extra] = process.argv.slice(2);
8
+ const rootDir = process.cwd();
9
+
10
+ if (!command || !skillName) {
11
+ console.error(USAGE);
12
+ process.exitCode = 1;
13
+ return;
14
+ }
15
+ if (extra.length > 0) {
16
+ throw new Error(`unexpected argument: ${extra[0]}. ${USAGE}`);
17
+ }
18
+
19
+ if (command === 'init') {
20
+ const result = await initSkillAudit({ rootDir, skillName });
21
+ console.log(`[skill:audit init] ${skillName}`);
22
+ console.log(`dir: ${result.auditDir}`);
23
+ console.log(`copied: ${result.copied.join(', ') || 'none'}`);
24
+ console.log(`skipped (already present): ${result.skipped.join(', ') || 'none'}`);
25
+ return;
26
+ }
27
+
28
+ if (command === 'status') {
29
+ const result = await statusSkillAudit({ rootDir, skillName });
30
+ console.log(`[skill:audit status] ${skillName}`);
31
+ console.log(`entries: ${result.entryCount}`);
32
+ console.log(`latest: ${result.latest ? JSON.stringify(result.latest) : 'none'}`);
33
+ console.log(`open rationalizations: ${result.openRationalizations.length}`);
34
+ return;
35
+ }
36
+
37
+ if (command === 'record') {
38
+ const stdinRaw = await readStdin();
39
+ let entry;
40
+ try {
41
+ entry = JSON.parse(stdinRaw);
42
+ } catch (error) {
43
+ throw new Error(`skill:audit record expects a JSON entry on stdin: ${error.message}`);
44
+ }
45
+ const record = await recordAuditEntry({ rootDir, skillName, entry });
46
+ console.log(`[skill:audit record] ${skillName}`);
47
+ console.log(JSON.stringify(record, null, 2));
48
+ return;
49
+ }
50
+
51
+ console.error(`Unknown command: ${command}. ${USAGE}`);
30
52
  process.exitCode = 1;
31
53
  }
32
54
 
@@ -37,3 +59,10 @@ async function readStdin() {
37
59
  }
38
60
  return Buffer.concat(chunks).toString('utf8');
39
61
  }
62
+
63
+ try {
64
+ await main();
65
+ } catch (error) {
66
+ console.error(error && error.message ? error.message : error);
67
+ process.exit(1);
68
+ }
@@ -2,6 +2,7 @@ import { queryCodeIndex } from '../index/queryIndex.js';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { detectPackageManager } from '../core/packageManager.js';
5
+ import { readJsonIfExists } from '../core/fileOps.js';
5
6
  import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from '../index/relatedTests.js';
6
7
 
7
8
  const DEEP_KEYWORDS = ['race', 'flaky', 'intermittent', 'timeout', 'deadlock'];
@@ -28,10 +29,9 @@ export async function triageBug({ rootDir = process.cwd(), signature } = {}) {
28
29
  const directRecommendedTestFile = prioritizedResults
29
30
  .flatMap((item) => item.tests ?? [])
30
31
  .find(Boolean);
31
- const shouldLoadRelatedArtifacts = Boolean(topResult) || !directRecommendedTestFile;
32
- const relatedArtifacts = shouldLoadRelatedArtifacts
33
- ? await loadRelatedTestArtifacts({ rootDir })
34
- : null;
32
+ // `Boolean(topResult) || !directRecommendedTestFile` is always true (no
33
+ // topResult ⇒ no tests ⇒ no direct file), so artifacts load unconditionally.
34
+ const relatedArtifacts = await loadRelatedTestArtifacts({ rootDir });
35
35
  const inferredRecommendedTests = directRecommendedTestFile
36
36
  ? []
37
37
  : inferRelatedTestsFromArtifacts({
@@ -70,9 +70,12 @@ export async function triageBug({ rootDir = process.cwd(), signature } = {}) {
70
70
  ],
71
71
  };
72
72
  }
73
-
74
73
  async function buildTestCommand(rootDir, testFile) {
75
- const packageManager = await detectPackageManager(rootDir);
74
+ // Pass the parsed package.json so a declared `packageManager` field wins over
75
+ // lockfile/npm defaults — detectPackageManager(rootDir) alone ignored it.
76
+ const packageJson = await readJsonIfExists(path.join(rootDir, 'package.json')).catch(() => null);
77
+ const packageManager = await detectPackageManager(rootDir, packageJson);
78
+
76
79
 
77
80
  if (packageManager === 'pnpm') {
78
81
  return `pnpm test ${testFile}`;
@@ -105,6 +105,12 @@ export function resolveOptionalToolKeys(toolsArg) {
105
105
 
106
106
  const normalized = toolsArg.trim().toLowerCase();
107
107
 
108
+ if (!normalized) {
109
+ // `--tools=` / `--tools ""` used to fall through to an empty selection —
110
+ // silently deselecting every optional adapter. Treat it as a missing value.
111
+ throw new Error(`Missing value for --tools. Use --tools=${TOOLS_FLAG_HELP}`);
112
+ }
113
+
108
114
  if (normalized === 'all') {
109
115
  return [...DEFAULT_OPTIONAL_TOOL_KEYS];
110
116
  }
@@ -12,8 +12,14 @@ const SUBCOMMANDS = new Set(['peek', 'search', 'context', 'impact']);
12
12
  function extractFlag(args, flag) {
13
13
  const index = args.indexOf(flag);
14
14
  if (index < 0) return { value: null, rest: args };
15
+ const value = args[index + 1];
16
+ // A missing value — or the next flag swallowed as the value — used to pass
17
+ // silently (e.g. `--mode --budget 5` set mode='--budget' and dropped --budget).
18
+ if (typeof value !== 'string' || value.startsWith('--')) {
19
+ throw new Error(`Missing value for ${flag}.`);
20
+ }
15
21
  return {
16
- value: args[index + 1] ?? null,
22
+ value,
17
23
  rest: [...args.slice(0, index), ...args.slice(index + 2)],
18
24
  };
19
25
  }
@@ -36,8 +36,10 @@ export function parseIndexArgs(argv, flagDefinitions) {
36
36
  continue;
37
37
  }
38
38
 
39
- const hasEquals = arg.includes('=');
40
- const [flag, valueFromEquals = null] = hasEquals ? arg.split('=', 2) : [arg, null];
39
+ const equalsIndex = arg.indexOf('=');
40
+ const [flag, valueFromEquals = null] = equalsIndex >= 0
41
+ ? [arg.slice(0, equalsIndex), arg.slice(equalsIndex + 1)]
42
+ : [arg, null];
41
43
  const definition = flagDefinitions.get(flag);
42
44
  if (!definition) {
43
45
  throw new Error(`Unknown option: ${flag}`);
@@ -192,7 +192,14 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
192
192
  const taskType = flags.get('--type') ?? null;
193
193
  const commandText = flags.get('--tool-command') ?? '';
194
194
  const lastExplicitUserPromptText = flags.get('--last-prompt') ?? '';
195
- const adapter = flags.get('--adapter') ?? 'claude';
195
+ const rawAdapter = flags.get('--adapter') ?? 'claude';
196
+ // Same posture as the shipped route-task.mjs helper: unknown adapters warn
197
+ // and fall back to claude instead of silently picking a namespace.
198
+ const KNOWN_ROUTE_ADAPTERS = new Set(['claude', 'codex']);
199
+ const adapter = KNOWN_ROUTE_ADAPTERS.has(rawAdapter) ? rawAdapter : 'claude';
200
+ if (adapter !== rawAdapter) {
201
+ console.error(`[UKit] unknown --adapter "${rawAdapter}" — falling back to claude`);
202
+ }
196
203
  const commandNamespace = adapter === 'codex' ? '.codex' : '.claude';
197
204
 
198
205
  if (!promptText && !commandText && !targetFile) {
@@ -10,6 +10,7 @@ import { removeTrackedPathsFromMetadata } from '../../core/metadata.js';
10
10
  import {
11
11
  ADAPTER_BY_KEY,
12
12
  DEFAULT_OPTIONAL_TOOL_KEYS,
13
+ TOOLS_FLAG_HELP,
13
14
  parseToolsArg,
14
15
  resolveOptionalToolKeys,
15
16
  toSelectedAdapterItemIds,
@@ -245,6 +246,18 @@ export async function pruneDeselectedAdapters({
245
246
  }
246
247
 
247
248
  export async function runInstall({ packageRoot, projectRoot, packageVersion, argv = [] }) {
249
+ if (argv.includes('--help') || argv.includes('-h')) {
250
+ console.log('Usage: ukit install [options]');
251
+ console.log('');
252
+ console.log('Install/update UKit assets into the current project (default command).');
253
+ console.log('');
254
+ console.log('Options:');
255
+ console.log(` --tools=<list> Adapters to enable (default: all). Values: ${TOOLS_FLAG_HELP}`);
256
+ console.log(' --with-codegraph Enable CodeGraph MCP integration in CLAUDE.md');
257
+ console.log(' --help, -h Show this help message');
258
+ return;
259
+ }
260
+
248
261
  // parseToolsArg owns only --tools and throws on anything else, so hand it the argv with
249
262
  // the flags consumed here (--with-codegraph) already removed — order-dependent parsing
250
263
  // used to make the documented `ukit install --with-codegraph` fail 100% of the time.
@@ -18,9 +18,8 @@ import {
18
18
  import {
19
19
  addUserRecord,
20
20
  getUserRecord,
21
- loadUserRecords,
22
21
  queryUserRecords,
23
- saveUserRecords,
22
+ removeUserRecord,
24
23
  updateUserRecord,
25
24
  userMemoryStats,
26
25
  } from '../../core/memory/userMemory.js';
@@ -40,8 +39,14 @@ const HELP_FLAGS = new Set(['--help', '-h', 'help']);
40
39
  function extractFlag(args, flag) {
41
40
  const index = args.indexOf(flag);
42
41
  if (index < 0) return { value: null, rest: args };
42
+ const value = args[index + 1];
43
+ // A missing value — or the next flag swallowed as the value — used to pass
44
+ // silently (e.g. `--project --category bug` set projectId='--category').
45
+ if (typeof value !== 'string' || value.startsWith('--')) {
46
+ throw new Error(`Missing value for ${flag}.`);
47
+ }
43
48
  return {
44
- value: args[index + 1] ?? null,
49
+ value,
45
50
  rest: [...args.slice(0, index), ...args.slice(index + 2)],
46
51
  };
47
52
  }
@@ -291,12 +296,10 @@ async function runUserMemory(homeDir, args) {
291
296
  if (!id) {
292
297
  throw new Error('Missing record id. Usage: ukit memory --user forget <id>');
293
298
  }
294
- const records = await loadUserRecords({ homeDir });
295
- const remaining = records.filter((r) => r.id !== id);
296
- if (remaining.length === records.length) {
299
+ const removed = await removeUserRecord(id, { homeDir });
300
+ if (!removed) {
297
301
  throw new Error(`User memory record not found: ${id}`);
298
302
  }
299
- await saveUserRecords(remaining, { homeDir });
300
303
  console.log(`[UKit] Forgot user record ${id}.`);
301
304
  return;
302
305
  }
@@ -437,8 +440,9 @@ function episodeText(ledger, sessionId) {
437
440
  ? 'not-run'
438
441
  : ledger.verificationSucceeded === true ? 'ok' : 'fail';
439
442
  const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
440
- const lastCommand = receipts.length > 0
441
- ? String(receipts[receipts.length - 1].command ?? '').split('\n')[0].trim() || 'n/a'
443
+ const lastReceipt = receipts.length > 0 ? receipts[receipts.length - 1] : null;
444
+ const lastCommand = lastReceipt
445
+ ? String(lastReceipt?.command ?? '').split('\n')[0].trim() || 'n/a'
442
446
  : 'n/a';
443
447
  const text = `Session ${sessionId}: ${write}, verify=${verify}, `
444
448
  + `${receipts.length} receipts, last command ${lastCommand}`;
@@ -1,6 +1,22 @@
1
1
  import { buildStatusReport, formatStatusReport } from '../../core/status.js';
2
2
 
3
- export async function runStatus({ projectRoot }) {
3
+ const KNOWN_STATUS_FLAGS = new Set(['--help', '-h']);
4
+
5
+ export async function runStatus({ projectRoot, argv = [] }) {
6
+ if (argv.includes('--help') || argv.includes('-h')) {
7
+ console.log('Usage: ukit status');
8
+ console.log('');
9
+ console.log('Show UKit runtime status for the current project.');
10
+ return;
11
+ }
12
+
13
+ const unknownArgs = argv.filter((a) => !KNOWN_STATUS_FLAGS.has(a));
14
+ if (unknownArgs.length > 0) {
15
+ throw new Error(
16
+ `Unknown option(s): ${unknownArgs.join(', ')}. Run \`ukit status --help\` for usage.`,
17
+ );
18
+ }
19
+
4
20
  const report = await buildStatusReport(projectRoot);
5
21
  for (const line of formatStatusReport(report)) {
6
22
  console.log(line);
@@ -20,6 +20,13 @@ export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd()
20
20
  );
21
21
  }
22
22
 
23
+ const positionalArgs = argv.filter((a) => !a.startsWith('-'));
24
+ if (positionalArgs.length > 0) {
25
+ throw new Error(
26
+ `Unexpected argument(s): ${positionalArgs.join(', ')}. Run \`ukit update --help\` for usage.`,
27
+ );
28
+ }
29
+
23
30
  console.log(`[UKit] Current version: ${packageVersion}`);
24
31
  console.log(`[UKit] Running: npm install -g ${UKIT_PACKAGE_NAME}`);
25
32
 
@@ -12,7 +12,9 @@ function inferProjectName(projectRoot, packageJson) {
12
12
 
13
13
  export async function detectProjectContext(projectRoot) {
14
14
  const packageJsonPath = path.join(projectRoot, 'package.json');
15
- const packageJson = await readJsonIfExists(packageJsonPath);
15
+ // A corrupt package.json must degrade to basename naming, not crash the
16
+ // status/memory CLIs with a raw SyntaxError.
17
+ const packageJson = await readJsonIfExists(packageJsonPath).catch(() => null);
16
18
 
17
19
  return {
18
20
  project: {
@@ -190,7 +190,7 @@ export async function findAnalogies(projectRoot, query, { limit = DEFAULT_LIMIT
190
190
  }
191
191
 
192
192
  analogies.sort((a, b) => b.score - a.score || a.ref.localeCompare(b.ref));
193
- if (analogies.length === 0 && !memory.why && filesArtifact && symbolsArtifact) {
193
+ if (analogies.length === 0 && (memory.records !== null || filesArtifact || symbolsArtifact)) {
194
194
  omitted.push({ what: 'analogy-lane', why: 'no-match' });
195
195
  }
196
196
  return { analogies: analogies.slice(0, effectiveLimit), omitted };
@@ -12,15 +12,18 @@ const SYNTAX_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
12
12
  const MAX_DIAGNOSTICS = 50;
13
13
 
14
14
  function parseCheckLine(stderr) {
15
- const line = String(stderr ?? '')
15
+ const lines = String(stderr ?? '')
16
16
  .split('\n')
17
17
  .map((l) => l.trim())
18
- .find((l) => l.length > 0);
19
- if (!line) return { message: 'syntax check failed', line: null };
20
- const match = line.match(/:(\d+)\s*$/);
18
+ .filter((l) => l.length > 0);
19
+ if (lines.length === 0) return { message: 'syntax check failed', line: null };
20
+ // `node --check` stderr opens with `<file>:<line>` then the offending source
21
+ // line and a caret; the real error is the first *Error line below that.
22
+ const errorLine = lines.find((l) => /\b\w*Error\b/.test(l));
23
+ const lineMatch = lines[0].match(/:(\d+)\s*$/);
21
24
  return {
22
- message: line,
23
- line: match ? Number.parseInt(match[1], 10) : null,
25
+ message: errorLine ?? lines[0],
26
+ line: lineMatch ? Number.parseInt(lineMatch[1], 10) : null,
24
27
  };
25
28
  }
26
29
 
@@ -96,8 +96,8 @@ export async function buildGraphArtifact(projectRoot) {
96
96
  }
97
97
  return id;
98
98
  };
99
- const addEdge = (edge, dedupeExtra = '') => {
100
- const key = `${edge.from}»${edge.to}»${edge.kind}»${dedupeExtra}`;
99
+ const addEdge = (edge) => {
100
+ const key = `${edge.from}»${edge.to}»${edge.kind}`;
101
101
  if (!edges.has(key)) edges.set(key, edge);
102
102
  };
103
103
 
@@ -132,16 +132,18 @@ export async function buildGraphArtifact(projectRoot) {
132
132
  for (const name of call.calls ?? []) {
133
133
  const target = symbolToFile.get(name);
134
134
  if (!target || target === call.filePath) continue;
135
+ // The callee file is a graph node even when it has no calls.json row of
136
+ // its own — an edge must never point at a non-existent node.
137
+ addFileNode(target);
135
138
  addEdge(createEdge({
136
139
  from: call.filePath,
137
140
  to: target,
138
141
  kind: 'call',
139
142
  provider: 'json-graph',
140
143
  evidence: INDEX_ARTIFACTS.calls,
141
- }), name);
144
+ }));
142
145
  }
143
146
  }
144
-
145
147
  // co-change edges: pair endpoints become file nodes; count feeds confidence.
146
148
  for (const pair of cochangeArtifact?.pairs ?? []) {
147
149
  if (!pair?.a || !pair?.b) continue;
@@ -155,7 +157,7 @@ export async function buildGraphArtifact(projectRoot) {
155
157
  confidence,
156
158
  provider: 'json-graph',
157
159
  evidence: INDEX_ARTIFACTS.cochange,
158
- }), String(pair.count ?? ''));
160
+ }));
159
161
  addEdge(createEdge({
160
162
  from: pair.b,
161
163
  to: pair.a,
@@ -163,7 +165,7 @@ export async function buildGraphArtifact(projectRoot) {
163
165
  confidence,
164
166
  provider: 'json-graph',
165
167
  evidence: INDEX_ARTIFACTS.cochange,
166
- }), String(pair.count ?? ''));
168
+ }));
167
169
  }
168
170
 
169
171
  const artifact = {
@@ -205,11 +207,15 @@ export class JsonGraphStore {
205
207
  capabilities() {
206
208
  return { nodes: true, edges: true, queries: true };
207
209
  }
208
-
209
210
  async _load() {
210
211
  if (!this._artifactPromise) {
211
212
  this._artifactPromise = (async () => {
212
- const artifact = await readJsonIfExists(getArtifactPath(this.rootDir, INDEX_ARTIFACTS.codegraph));
213
+ let artifact = null;
214
+ try {
215
+ artifact = await readJsonIfExists(getArtifactPath(this.rootDir, INDEX_ARTIFACTS.codegraph));
216
+ } catch {
217
+ return null; // corrupt JSON → empty results, never throws
218
+ }
213
219
  if (!artifact || typeof artifact !== 'object') return null;
214
220
  if (artifact.schemaVersion !== GRAPH_SCHEMA_VERSION) return null;
215
221
  if (!Array.isArray(artifact.nodes) || !Array.isArray(artifact.edges)) return null;
@@ -98,7 +98,6 @@ export async function impactSet(projectRoot, seeds, {
98
98
  const nodeCap = clampInt(maxNodes ?? defaults.maxNodes, 1, Number.MAX_SAFE_INTEGER);
99
99
  const requestedDepth = typeof depth === 'number' ? depth : defaults.defaultDepth;
100
100
  const depthLimit = clampInt(requestedDepth, 1, maxDepthCap);
101
- const depthClamped = requestedDepth > depthLimit;
102
101
 
103
102
  const seedList = Array.isArray(seeds) ? seeds.filter((s) => typeof s === 'string' && s.length > 0) : [];
104
103
  if (seedList.length === 0) {
@@ -140,15 +140,17 @@ export async function readDirty(projectRoot) {
140
140
 
141
141
  /**
142
142
  * Return the accumulated dirty set and clear the file (index-refresh lane).
143
- * @returns {Promise<string[]>} sorted paths, `[]` on missing/corrupt.
143
+ * `saturated` is surfaced so callers can treat a saturated set as full
144
+ * invalidation — a saturated drain with few/no paths is NOT a clean state.
145
+ * @returns {Promise<{ paths: string[], saturated: boolean }>}
144
146
  */
145
147
  export async function drainDirty(projectRoot) {
146
148
  const { paths, saturated } = await readDirtyData(projectRoot);
147
- if (paths.length === 0 && !saturated) return [];
149
+ if (paths.length === 0 && !saturated) return { paths: [], saturated: false };
148
150
  try {
149
151
  await writeDirtyData(projectRoot, [], false);
150
152
  } catch {
151
153
  // never throw — drained set is still returned
152
154
  }
153
- return [...paths].sort();
155
+ return { paths: [...paths].sort(), saturated };
154
156
  }
@@ -125,6 +125,17 @@ export function packetToText(packet) {
125
125
  for (const t of packet.tests ?? []) lines.push(`- ${t}`);
126
126
  if ((packet.tests ?? []).length === 0) lines.push('(none)');
127
127
 
128
+ lines.push('');
129
+ lines.push('## Memory');
130
+ for (const m of packet.memory ?? []) {
131
+ if (m && typeof m === 'object') {
132
+ lines.push(`- ${m.id ?? m.ref ?? ''}${typeof m.score === 'number' ? ` (score ${m.score})` : ''}`);
133
+ } else {
134
+ lines.push(`- ${m}`);
135
+ }
136
+ }
137
+ if ((packet.memory ?? []).length === 0) lines.push('(none)');
138
+
128
139
  lines.push('');
129
140
  lines.push('## Omitted');
130
141
  for (const o of packet.omitted ?? []) lines.push(`- ${o.what ?? ''} — ${o.why ?? ''}`);
@@ -36,7 +36,21 @@ const ERROR_RE = /\b(error|exception|stack ?trace|bug|crash|failing|fails|failur
36
36
  const EXPLORE_RE = /\b(where|how does|how do|find|explore|understand|what does|which file|locate|handled)\b/i;
37
37
  const DEEP_FLOW_RE = /\b(refactor|migrate|migration|flow|end[- ]?to[- ]?end|trace|across|pipeline|lifecycle)\b/i;
38
38
  const ANALOGY_RE = /\b(similar to|analogy|like\b|same as|pattern|equivalent)\b/i;
39
- const PATH_RE = /(?:[\w.-]+\/)+[\w.-]+|[\w.-]+\.(?:js|ts|mjs|cjs|jsx|tsx|py|rb|go|rs|java|md|json|yml|yaml|css|html|sh)\b/i;
39
+ const PATH_RE = /(?:[\w.-]+\/)+[\w.-]+|[\w.-]+\.(?:js|ts|mjs|cjs|jsx|tsx|py|rb|go|rs|java|md|json|yml|yaml|css|html|sh)\b/gi;
40
+
41
+ // English slash-words that look like paths to PATH_RE but never are —
42
+ // "and/or", "n/a" etc. must not trigger the explicit-target rule.
43
+ const SLASH_WORD_STOPLIST = new Set([
44
+ 'and/or', 'n/a', 'yes/no', 'true/false', 'on/off', 'either/or', 'w/o', 'w/', 'b/c',
45
+ ]);
46
+
47
+ function hasPathMention(prompt) {
48
+ const text = String(prompt ?? '');
49
+ for (const match of text.matchAll(PATH_RE)) {
50
+ if (!SLASH_WORD_STOPLIST.has(match[0].toLowerCase())) return true;
51
+ }
52
+ return false;
53
+ }
40
54
 
41
55
  function resolveBudgets(options) {
42
56
  const configured = options?.config?.codeIntel?.budgets ?? options?.budgets;
@@ -63,7 +77,7 @@ function decide(prompt, { hasError = false, filesHinted } = {}) {
63
77
  }
64
78
 
65
79
  // Rule 3 — explicit path/symbol mention, clear scope, no error.
66
- const hasExplicitTarget = hintedFiles.length > 0 || PATH_RE.test(prompt);
80
+ const hasExplicitTarget = hintedFiles.length > 0 || hasPathMention(prompt);
67
81
  const errorSignalled = hasError || ERROR_RE.test(prompt);
68
82
  if (!errorSignalled && hasExplicitTarget) {
69
83
  reasons.push('explicit path or symbol mention with clear scope');
@@ -95,7 +109,7 @@ function decide(prompt, { hasError = false, filesHinted } = {}) {
95
109
  }
96
110
 
97
111
  // Rule 8 — fallback: single-file verb (single hinted file or lone path mention).
98
- if (hintedFiles.length === 1 || PATH_RE.test(prompt)) {
112
+ if (hintedFiles.length === 1 || hasPathMention(prompt)) {
99
113
  reasons.push('fallback: single-file hint');
100
114
  return { mode: 'peek', reasons };
101
115
  }
@@ -87,7 +87,7 @@ export class TypeScriptSemanticProvider {
87
87
  try {
88
88
  const filesPath = getArtifactPath(this.projectRoot, INDEX_ARTIFACTS.files);
89
89
  const raw = JSON.parse(fs.readFileSync(filesPath, 'utf8'));
90
- const list = Array.isArray(raw) ? raw : (raw.files ?? []);
90
+ const list = Array.isArray(raw) ? raw : (raw.items ?? raw.files ?? []);
91
91
  return list
92
92
  .map((entry) => (typeof entry === 'string' ? entry : entry?.filePath ?? entry?.path))
93
93
  .filter(isSemanticFile)
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- import { getSyntaxProvider } from './providers.js';
4
+ import { getSyntaxProvider, IndexFileSyntaxProvider } from './providers.js';
5
5
  import { resolveProjectRelativePath } from '../fileOps.js';
6
6
 
7
7
  // Deterministic extractive summaries (SPEC §7/§8, CI-304) — the L3 detail tier.
@@ -118,12 +118,16 @@ function scanDeclarations(lines, maxSymbols) {
118
118
 
119
119
  async function symbolNames(rootDir, relPath, lines, maxSymbols) {
120
120
  try {
121
- const provider = getSyntaxProvider(relPath);
122
- if (provider) {
123
- const syms = await provider.symbols(relPath);
124
- if (Array.isArray(syms) && syms.length > 0) {
125
- return syms.slice(0, maxSymbols).map((s) => s.name).filter(Boolean);
126
- }
121
+ let provider = getSyntaxProvider(relPath);
122
+ // The registry's built-in 'index-file' provider is bound to process.cwd()
123
+ // at ensureBuiltins() time — rebind it to this call's rootDir so summaries
124
+ // read the project index even when cwd differs (or supports() failed).
125
+ if (!provider || provider.name === 'index-file') {
126
+ provider = new IndexFileSyntaxProvider({ rootDir });
127
+ }
128
+ const syms = await provider.symbols(relPath);
129
+ if (Array.isArray(syms) && syms.length > 0) {
130
+ return syms.slice(0, maxSymbols).map((s) => s.name).filter(Boolean);
127
131
  }
128
132
  } catch {
129
133
  // provider failure falls through to the regex lane — never throws
@@ -27,6 +27,31 @@ export function deriveAutoCompactWindow(hardCapTokens, ratio) {
27
27
  return Math.round(hardCapTokens * safeRatio);
28
28
  }
29
29
 
30
+ // A malformed hard cap must fall back to a safe ceiling, never collapse to a
31
+ // non-positive or fractional cap that would brick every Edit/Write/Bash call or
32
+ // invert the autoCompactWindow < hardCapTokens invariant — same operator
33
+ // contract as positiveInteger() in compact/threshold.js.
34
+ function positiveInteger(value, fallback) {
35
+ return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
36
+ ? value
37
+ : fallback;
38
+ }
39
+
40
+ // Pure normalization of a raw `compact` config object into the budget triple.
41
+ // Exported so the fallback rules are testable without touching the shipped file.
42
+ export function deriveCompactBudget(compact = {}) {
43
+ const source = compact && typeof compact === 'object' ? compact : {};
44
+ const tokenThreshold = Number.isFinite(source.tokenThreshold)
45
+ ? source.tokenThreshold
46
+ : EMERGENCY_BUDGET.tokenThreshold;
47
+ const hardCapTokens = positiveInteger(source.hardCapTokens, EMERGENCY_BUDGET.hardCapTokens);
48
+ return {
49
+ tokenThreshold,
50
+ hardCapTokens,
51
+ autoCompactWindow: deriveAutoCompactWindow(hardCapTokens, source.autoCompactWindowRatio),
52
+ };
53
+ }
54
+
30
55
  let cachedBudget = null;
31
56
 
32
57
  export function loadShippedCompactBudget() {
@@ -41,17 +66,6 @@ export function loadShippedCompactBudget() {
41
66
  compact = EMERGENCY_BUDGET;
42
67
  }
43
68
 
44
- const tokenThreshold = Number.isFinite(compact.tokenThreshold)
45
- ? compact.tokenThreshold
46
- : EMERGENCY_BUDGET.tokenThreshold;
47
- const hardCapTokens = Number.isFinite(compact.hardCapTokens)
48
- ? compact.hardCapTokens
49
- : EMERGENCY_BUDGET.hardCapTokens;
50
-
51
- cachedBudget = {
52
- tokenThreshold,
53
- hardCapTokens,
54
- autoCompactWindow: deriveAutoCompactWindow(hardCapTokens, compact.autoCompactWindowRatio),
55
- };
69
+ cachedBudget = deriveCompactBudget(compact);
56
70
  return cachedBudget;
57
71
  }