@ngockhoale/ukit 3.0.3 → 3.0.4

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 +17 -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 +10 -3
  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 +11 -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 +16 -6
  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 +71 -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 +123 -39
  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
@@ -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.
@@ -40,8 +40,14 @@ const HELP_FLAGS = new Set(['--help', '-h', 'help']);
40
40
  function extractFlag(args, flag) {
41
41
  const index = args.indexOf(flag);
42
42
  if (index < 0) return { value: null, rest: args };
43
+ const value = args[index + 1];
44
+ // A missing value — or the next flag swallowed as the value — used to pass
45
+ // silently (e.g. `--project --category bug` set projectId='--category').
46
+ if (typeof value !== 'string' || value.startsWith('--')) {
47
+ throw new Error(`Missing value for ${flag}.`);
48
+ }
43
49
  return {
44
- value: args[index + 1] ?? null,
50
+ value,
45
51
  rest: [...args.slice(0, index), ...args.slice(index + 2)],
46
52
  };
47
53
  }
@@ -437,8 +443,9 @@ function episodeText(ledger, sessionId) {
437
443
  ? 'not-run'
438
444
  : ledger.verificationSucceeded === true ? 'ok' : 'fail';
439
445
  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'
446
+ const lastReceipt = receipts.length > 0 ? receipts[receipts.length - 1] : null;
447
+ const lastCommand = lastReceipt
448
+ ? String(lastReceipt?.command ?? '').split('\n')[0].trim() || 'n/a'
442
449
  : 'n/a';
443
450
  const text = `Session ${sessionId}: ${write}, verify=${verify}, `
444
451
  + `${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
  }
@@ -1,4 +1,4 @@
1
- import { readJsonIfExists, writeJson } from '../fileOps.js';
1
+ import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
2
2
  import { buildRuntimePaths } from '../runtimePaths.js';
3
3
  import { compressLine, compressMarkdownLines, estimateTokenCount, normalizeLineForDedupe, uniqueLines } from '../token/index.js';
4
4
 
@@ -183,7 +183,10 @@ function buildCompactedContextLines(
183
183
  const forceLine = nonEmptyCount < forceFirstCount;
184
184
  const wouldExceedLines = nonEmptyCount >= maxLines;
185
185
 
186
- if (wouldExceedLines || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
186
+ // Forced lines (header + anchors) are guaranteed placement: they bypass the
187
+ // line cap — dropping a forced anchor would silently break the anchor
188
+ // contract this function exists to enforce.
189
+ if ((!forceLine && wouldExceedLines) || (!forceLine && selected.length > 0 && (usedTokens + tokens) > maxTokens)) {
187
190
  break;
188
191
  }
189
192
 
@@ -247,12 +250,16 @@ export async function appendCompactHistory(
247
250
  }
248
251
 
249
252
  const runtimePaths = buildRuntimePaths(projectRoot);
250
- const history = await readCompactHistory(projectRoot, { maxEntries });
251
- const nextDocument = normalizeCompactHistoryDocument({
252
- entries: [normalizedEntry, ...history.entries],
253
- }, { maxEntries });
254
- await writeJson(runtimePaths.compactHistoryPath, nextDocument);
255
- return nextDocument;
253
+ // Same read-modify-write race as compact-pressure.json: concurrent hook
254
+ // processes appending history must not drop each other's entries.
255
+ return withFileLock(runtimePaths.compactHistoryPath, async () => {
256
+ const history = await readCompactHistory(projectRoot, { maxEntries });
257
+ const nextDocument = normalizeCompactHistoryDocument({
258
+ entries: [normalizedEntry, ...history.entries],
259
+ }, { maxEntries });
260
+ await writeJson(runtimePaths.compactHistoryPath, nextDocument);
261
+ return nextDocument;
262
+ });
256
263
  }
257
264
 
258
265
  export async function recordCompaction(
@@ -367,6 +367,14 @@ async function checkCommands(rootDir, registry) {
367
367
  return makeCheck(id, label, 'error', { passed: true });
368
368
  }
369
369
 
370
+ // Logical line count: a trailing newline terminates the last line, it does not
371
+ // start a phantom extra one — 'a\n' is 1 line, not 2.
372
+ function countLines(content) {
373
+ if (!content) return 0;
374
+ const n = content.split('\n').length;
375
+ return content.endsWith('\n') ? n - 1 : n;
376
+ }
377
+
370
378
  async function checkBudgets(rootDir, registry) {
371
379
  const id = 'budgets';
372
380
  const label = 'registry line budgets obeyed (error enforcement blocks)';
@@ -377,7 +385,7 @@ async function checkBudgets(rootDir, registry) {
377
385
  if (!Number.isInteger(e.budget.max_lines)) continue;
378
386
  const content = await readText(path.join(rootDir, e.path));
379
387
  if (content === null) continue;
380
- const lines = content.split('\n').length;
388
+ const lines = countLines(content);
381
389
  if (lines <= e.budget.max_lines) continue;
382
390
  const msg = `${e.path}: ${lines} lines > max_lines ${e.budget.max_lines}`;
383
391
  if (e.budget.enforcement === 'error') errorViolations.push(msg);
@@ -531,7 +539,7 @@ async function checkArchiveSuggestions(rootDir, registry, nowMs) {
531
539
  const signals = [];
532
540
  if ((inbound.get(e.path)?.size ?? 0) === 0) signals.push('unreferenced');
533
541
  if (e.budget && Number.isInteger(e.budget.max_lines)) {
534
- const lines = content.split('\n').length;
542
+ const lines = countLines(content);
535
543
  if (lines > e.budget.max_lines) signals.push('over-budget');
536
544
  }
537
545
  let st = null;