@davesheffer/hunch 1.46.0 → 1.47.1

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 (78) hide show
  1. package/dist/cli/index.js +280 -358
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/cli/taskReport.d.ts +6 -0
  5. package/dist/cli/taskReport.js +31 -8
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/sourceMutation.js +28 -18
  8. package/dist/core/commitrepair.d.ts +43 -138
  9. package/dist/core/commitrepair.js +76 -167
  10. package/dist/core/correction.d.ts +2 -0
  11. package/dist/core/correction.js +23 -10
  12. package/dist/core/docanchors.js +9 -9
  13. package/dist/core/drift.js +22 -27
  14. package/dist/core/escalations.d.ts +16 -58
  15. package/dist/core/escalations.js +39 -121
  16. package/dist/core/glob.d.ts +7 -7
  17. package/dist/core/glob.js +12 -11
  18. package/dist/core/groundingLag.d.ts +2 -9
  19. package/dist/core/groundingLag.js +5 -11
  20. package/dist/core/groundingMerge.d.ts +2 -7
  21. package/dist/core/groundingMerge.js +35 -54
  22. package/dist/core/paths.d.ts +17 -24
  23. package/dist/core/paths.js +37 -43
  24. package/dist/core/refrepair.d.ts +2 -1
  25. package/dist/core/refrepair.js +4 -4
  26. package/dist/core/repairqueue.d.ts +17 -34
  27. package/dist/core/repairqueue.js +48 -98
  28. package/dist/core/taskRecord.d.ts +1 -1
  29. package/dist/core/taskRecord.js +2 -2
  30. package/dist/core/taskReport.d.ts +8 -3
  31. package/dist/core/taskReport.js +7 -2
  32. package/dist/core/taskReportEvidence.d.ts +7 -0
  33. package/dist/core/taskReportEvidence.js +16 -4
  34. package/dist/core/taskReportHook.js +2 -2
  35. package/dist/core/taskReportPublic.js +2 -2
  36. package/dist/core/taskReportRender.js +2 -2
  37. package/dist/core/types.d.ts +3 -3
  38. package/dist/core/types.js +1 -1
  39. package/dist/extractors/diff.d.ts +4 -4
  40. package/dist/extractors/diff.js +25 -29
  41. package/dist/extractors/git.d.ts +18 -23
  42. package/dist/extractors/git.js +72 -64
  43. package/dist/extractors/helm.d.ts +10 -0
  44. package/dist/extractors/helm.js +43 -59
  45. package/dist/extractors/indexer.d.ts +6 -0
  46. package/dist/extractors/indexer.js +126 -178
  47. package/dist/extractors/k8sManifest.d.ts +23 -80
  48. package/dist/extractors/k8sManifest.js +319 -690
  49. package/dist/extractors/languages.d.ts +28 -37
  50. package/dist/extractors/languages.js +55 -54
  51. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  52. package/dist/extractors/nativeTreeSitter.js +17 -14
  53. package/dist/extractors/parse.d.ts +7 -13
  54. package/dist/extractors/parse.js +39 -51
  55. package/dist/integrations/claudemd.js +1 -0
  56. package/dist/integrations/gitignore.js +3 -5
  57. package/dist/integrations/health.js +1 -1
  58. package/dist/integrations/hooks.d.ts +9 -14
  59. package/dist/integrations/hooks.js +19 -28
  60. package/dist/integrations/mergeDriver.js +13 -14
  61. package/dist/integrations/probe.d.ts +11 -0
  62. package/dist/integrations/probe.js +29 -4
  63. package/dist/integrations/sync.d.ts +16 -4
  64. package/dist/integrations/sync.js +54 -3
  65. package/dist/integrations/workspaceLedger.d.ts +1 -1
  66. package/dist/mcp/server.d.ts +4 -5
  67. package/dist/mcp/server.js +71 -83
  68. package/dist/mcp/taskReportTools.js +4 -4
  69. package/dist/serve/app.js +2 -2
  70. package/dist/store/hunchStore.js +12 -18
  71. package/dist/synthesis/provider.d.ts +19 -21
  72. package/dist/synthesis/provider.js +67 -76
  73. package/dist/synthesis/synthesize.d.ts +8 -8
  74. package/dist/synthesis/synthesize.js +32 -49
  75. package/dist/taskReports.d.ts +1 -0
  76. package/dist/taskReports.js +2 -2
  77. package/package.json +2 -2
  78. package/server.json +2 -2
@@ -11,6 +11,7 @@ export interface ResolvedInvocation {
11
11
  * or npx cache), never from a source checkout. */
12
12
  installed: boolean;
13
13
  }
14
+ /** Faint (dim) ANSI styling. */
14
15
  export declare function dim(s: string): string;
15
16
  /** Portable invocation written into committed MCP/provider configuration.
16
17
  * Keep it independently testable so the distribution pin cannot silently
@@ -20,20 +21,14 @@ export declare function publishedMcpInvocation(): Invocation;
20
21
  * PowerShell can execute the first token; paths and other unsafe tokens use
21
22
  * JSON string quoting, which is accepted by PowerShell, cmd, and POSIX sh. */
22
23
  export declare function shellInvocation(inv: Invocation): string;
23
- /** The doctor command's synthesis-status line(s) for a resolved provider.
24
- * Exported for testing — the previous version (a bare provider-name switch,
25
- * before the resolveSynthesisProvider preference system existed) had zero
26
- * test coverage, which is how issue #8 (openai-compat misreported as "no
27
- * assistant CLI found") shipped unnoticed through three review passes. That
28
- * bug resurfaces here for the same reason: resolution.statuses carries a
29
- * `subscription` field for the CLI providers but openai-compat's is null (it
30
- * isn't a subscription), so it must be special-cased explicitly rather than
31
- * falling through to the "no assistant CLI" branch. */
24
+ /** Render doctor's synthesis status lines. Exported for testing: an earlier,
25
+ * untested version reported openai-compat as "no assistant CLI found". Statuses
26
+ * carry a `subscription` for CLI providers but openai-compat's is null, so it is
27
+ * special-cased explicitly. */
32
28
  export declare function synthesisStatusLines(resolution: ProviderResolution, env: NodeJS.ProcessEnv): string[];
33
- /** Gate + fetch the Ollama context-window advisory (issue #11): only relevant
34
- * for the openai-compat provider, so every other provider is a no-op. Kept
35
- * separate from synthesisStatusLines (sync, already fully covered) because
36
- * this one makes a best-effort network call. */
29
+ /** Gate + fetch the Ollama context-window advisory. Only openai-compat is
30
+ * relevant; kept apart from the sync status lines because it makes a
31
+ * best-effort network call. */
37
32
  export declare function maybeWarnOllamaContext(providerName: string, env: NodeJS.ProcessEnv): Promise<string | null>;
38
33
  /** Classify the entry path without faking import.meta.url in tests. */
39
34
  export declare function classifyEntry(entry: string): {
@@ -1,12 +1,15 @@
1
- /** Side-effect-free shared CLI logic — safe for any module (including tests)
2
- * to import, unlike src/cli/index.ts, which runs the whole program at
3
- * import time. Holds: how to re-invoke this CLI from a git hook / .mcp.json
4
- * (working both when running the built dist and in dev via tsx), plus small
5
- * formatting helpers (dim(), doctor's synthesisStatusLines()) that need the
6
- * same import-safety to be unit-testable. */
1
+ /**
2
+ * Shared CLI logic that is free of import-time side effects, so any module
3
+ * (tests included) can import it safely, unlike src/cli/index.ts which runs the
4
+ * program as soon as it loads. It holds how to re-invoke this CLI from a git hook
5
+ * or `.mcp.json` (from a built dist and in dev via tsx), plus small formatting
6
+ * helpers (`dim()`, doctor's `synthesisStatusLines()`) that need the same import
7
+ * safety to be unit-testable.
8
+ */
7
9
  import { fileURLToPath } from "node:url";
8
10
  import { probeOllamaNumCtx } from "../synthesis/provider.js";
9
11
  import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
12
+ /** Faint (dim) ANSI styling. */
10
13
  export function dim(s) {
11
14
  return `\x1b[2m${s}\x1b[0m`;
12
15
  }
@@ -23,15 +26,10 @@ export function shellInvocation(inv) {
23
26
  const token = (part) => /^[A-Za-z0-9_@:=+.,/-]+$/.test(part) ? part : JSON.stringify(part);
24
27
  return [inv.command, ...inv.args].map(token).join(" ");
25
28
  }
26
- /** The doctor command's synthesis-status line(s) for a resolved provider.
27
- * Exported for testing — the previous version (a bare provider-name switch,
28
- * before the resolveSynthesisProvider preference system existed) had zero
29
- * test coverage, which is how issue #8 (openai-compat misreported as "no
30
- * assistant CLI found") shipped unnoticed through three review passes. That
31
- * bug resurfaces here for the same reason: resolution.statuses carries a
32
- * `subscription` field for the CLI providers but openai-compat's is null (it
33
- * isn't a subscription), so it must be special-cased explicitly rather than
34
- * falling through to the "no assistant CLI" branch. */
29
+ /** Render doctor's synthesis status lines. Exported for testing: an earlier,
30
+ * untested version reported openai-compat as "no assistant CLI found". Statuses
31
+ * carry a `subscription` for CLI providers but openai-compat's is null, so it is
32
+ * special-cased explicitly. */
35
33
  export function synthesisStatusLines(resolution, env) {
36
34
  const provider = resolution.provider;
37
35
  if (resolution.source === "unavailable-initiator") {
@@ -43,39 +41,38 @@ export function synthesisStatusLines(resolution, env) {
43
41
  if (resolution.source === "initiator") {
44
42
  return [` ↳ agent calls remain with ${resolution.initiator}, using that CLI's configured authentication.`];
45
43
  }
46
- const selected = resolution.statuses.find((s) => s.name === provider.name);
47
- if (selected?.subscription) {
48
- return [` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`];
44
+ const status = resolution.statuses.find((s) => s.name === provider.name);
45
+ if (status?.subscription) {
46
+ return [` ↳ LLM synthesis uses your ${status.subscription}; provider API credentials are not used.`];
49
47
  }
50
48
  if (provider.name === "openai-compat") {
51
- const base = env.HUNCH_SYNTH_BASE_URL ?? "(unset)";
52
- const model = env.HUNCH_SYNTH_MODEL ?? "(unset)";
49
+ const base = env.HUNCH_SYNTH_BASE_URL || "(unset)";
50
+ const model = env.HUNCH_SYNTH_MODEL || "(unset)";
53
51
  const keyNote = env.HUNCH_SYNTH_API_KEY ? " (HUNCH_SYNTH_API_KEY set)" : " (no API key)";
54
52
  return [` ↳ LLM synthesis via local/self-hosted endpoint ${base} (model: ${model})${keyNote}`];
55
53
  }
56
54
  if (resolution.source === "ambiguous") {
57
- const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
55
+ const names = resolution.statuses.filter((s) => s.available && s.name !== "deterministic").map((s) => s.name);
58
56
  return [
59
57
  dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which provider to use.`),
60
- dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`),
58
+ dim(` choose one locally: ${names.map((n) => `hunch provider ${n}`).join(" or ")}`),
61
59
  ];
62
60
  }
63
61
  if (resolution.source === "unavailable-preference") {
64
62
  return [dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`)];
65
63
  }
66
64
  return [
67
- dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`),
68
- dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`),
65
+ dim(" ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence)."),
66
+ dim(" install or log into Claude Code, Codex, or Cursor; then select one with `hunch provider <name>`."),
69
67
  ];
70
68
  }
71
- /** Gate + fetch the Ollama context-window advisory (issue #11): only relevant
72
- * for the openai-compat provider, so every other provider is a no-op. Kept
73
- * separate from synthesisStatusLines (sync, already fully covered) because
74
- * this one makes a best-effort network call. */
69
+ /** Gate + fetch the Ollama context-window advisory. Only openai-compat is
70
+ * relevant; kept apart from the sync status lines because it makes a
71
+ * best-effort network call. */
75
72
  export async function maybeWarnOllamaContext(providerName, env) {
76
73
  if (providerName !== "openai-compat")
77
74
  return null;
78
- return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL ?? "", env.HUNCH_SYNTH_MODEL ?? "");
75
+ return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL || "", env.HUNCH_SYNTH_MODEL || "");
79
76
  }
80
77
  /** Classify the entry path without faking import.meta.url in tests. */
81
78
  export function classifyEntry(entry) {
@@ -4,3 +4,9 @@ export declare function registerTaskReportCommands(program: Command, openStore:
4
4
  store: HunchStore;
5
5
  root: string;
6
6
  }): void;
7
+ /** The root whose task ledger holds `id`: `root` itself, else a sibling worktree of
8
+ * the same repository that has a .hunch/. A hook rooted at the main checkout opens
9
+ * the task while the agent works in a linked worktree (or the reverse); the check
10
+ * still runs, and is snapshotted, where the caller stands. `root` when no sibling
11
+ * has the task, so the usual "task not found" stays the error. */
12
+ export declare function taskLedgerRoot(root: string, id: string): string;
@@ -1,9 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { findRoot } from "../core/paths.js";
2
+ import { join } from "node:path";
3
+ import { findRoot, isDir } from "../core/paths.js";
4
+ import { sameFilesystemEntry, worktreePaths } from "../extractors/git.js";
3
5
  import { writeFileAtomic } from "../core/io.js";
4
- import { finishReportTask, forgetReportTask, listReportTasks, listTaskSummaries, pruneReportHistory, readTaskReport, readLessonHistory, renderTaskStatusLine, startReportTask, summarizeTaskReport, taskReportStats } from "../core/taskReport.js";
6
+ import { finishReportTask, forgetReportTask, reportTaskExists, listReportTasks, listTaskSummaries, pruneReportHistory, readTaskReport, readLessonHistory, renderTaskStatusLine, startReportTask, summarizeTaskReport, taskReportStats } from "../core/taskReport.js";
5
7
  import { promptTaskId } from "../core/taskReportHook.js";
6
- import { DEFAULT_CHECK_TIMEOUT_MS, MAX_CHECK_TIMEOUT_MS, reportSourceSnapshot, runReportCheck, runReportConformance } from "../core/taskReportEvidence.js";
8
+ import { DEFAULT_CHECK_TIMEOUT_MS, MAX_CHECK_TIMEOUT_MS, currentSource, runReportCheck, runReportConformance } from "../core/taskReportEvidence.js";
7
9
  import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
8
10
  import { assertReportPath } from "../core/taskReportPaths.js";
9
11
  import { publicTaskReport } from "../core/taskReportPublic.js";
@@ -61,7 +63,7 @@ export function registerTaskReportCommands(program, openStore) {
61
63
  catch (error) {
62
64
  graph = `\nGraph not saved: ${error.message}`;
63
65
  }
64
- console.log(renderTaskReport(readTaskReport(root, id, reportSourceSnapshot(root).hash)) + graph);
66
+ console.log(renderTaskReport(readTaskReport(root, id, currentSource(root))) + graph);
65
67
  });
66
68
  task.command("list").description("Recent tasks observed in this repository with what Hunch delivered, saved, guarded, and checked")
67
69
  .option("--limit <n>", "how many recent tasks (max 30)", "30")
@@ -69,7 +71,7 @@ export function registerTaskReportCommands(program, openStore) {
69
71
  .action((opts) => {
70
72
  const root = findRoot();
71
73
  const limit = Number(opts.limit) || 30;
72
- let summaries = listTaskSummaries(root, limit, reportSourceSnapshot(root).hash);
74
+ let summaries = listTaskSummaries(root, limit, currentSource(root));
73
75
  // Graph records (this machine's or a teammate's) join the local ledger view.
74
76
  try {
75
77
  const opened = openStore();
@@ -161,7 +163,7 @@ export function registerTaskReportCommands(program, openStore) {
161
163
  catch { /* a malformed host payload falls back to the most recent task */ }
162
164
  let summary = null;
163
165
  try {
164
- const snapshot = reportSourceSnapshot(root).hash;
166
+ const snapshot = currentSource(root);
165
167
  summary = taskId ? summarizeTaskReport(root, taskId, snapshot) : listTaskSummaries(root, 1, snapshot)[0] ?? null;
166
168
  }
167
169
  catch {
@@ -207,7 +209,9 @@ export function registerTaskReportCommands(program, openStore) {
207
209
  process.on("SIGINT", interrupt);
208
210
  process.on("SIGTERM", terminate);
209
211
  try {
210
- const result = await runReportCheck(findRoot(), id, command, opts.label, seconds * 1000, {
212
+ const here = findRoot();
213
+ const result = await runReportCheck(taskLedgerRoot(here, id), id, command, opts.label, seconds * 1000, {
214
+ workRoot: here,
211
215
  signal: controller.signal,
212
216
  onStdout: opts.json ? undefined : chunk => { process.stdout.write(chunk); },
213
217
  onStderr: opts.json ? undefined : chunk => { process.stderr.write(chunk); },
@@ -258,7 +262,7 @@ export function registerTaskReportCommands(program, openStore) {
258
262
  }
259
263
  let report;
260
264
  try {
261
- report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
265
+ report = readTaskReport(root, id, currentSource(root));
262
266
  }
263
267
  catch (error) {
264
268
  // Not in this machine's ledger: the graph record (if any) is what remains.
@@ -277,6 +281,25 @@ export function registerTaskReportCommands(program, openStore) {
277
281
  console.log(opts.json ? JSON.stringify(report, null, 2) : renderTaskReport(report));
278
282
  });
279
283
  }
284
+ /** The root whose task ledger holds `id`: `root` itself, else a sibling worktree of
285
+ * the same repository that has a .hunch/. A hook rooted at the main checkout opens
286
+ * the task while the agent works in a linked worktree (or the reverse); the check
287
+ * still runs, and is snapshotted, where the caller stands. `root` when no sibling
288
+ * has the task, so the usual "task not found" stays the error. */
289
+ export function taskLedgerRoot(root, id) {
290
+ if (reportTaskExists(root, id))
291
+ return root;
292
+ for (const other of worktreePaths(root)) {
293
+ if (sameFilesystemEntry(other, root) || !isDir(join(other, ".hunch")) || !existsSync(join(other, ".hunch-cache", "served.db")))
294
+ continue;
295
+ try {
296
+ if (reportTaskExists(other, id))
297
+ return other;
298
+ }
299
+ catch { /* unreadable ledger: keep looking */ }
300
+ }
301
+ return root;
302
+ }
280
303
  function readStdinText() {
281
304
  return new Promise(resolve => {
282
305
  let data = "";
@@ -27,10 +27,10 @@ function view(file, source) {
27
27
  }
28
28
  function viewOfParsed(parsed) {
29
29
  const symbols = new Map(parsed.symbols.map((s) => [symbolKey(s), s]));
30
- const byIndex = new Map(parsed.symbols.map((s, i) => [i, s]));
30
+ const symbolAt = new Map(parsed.symbols.map((s, i) => [i, s]));
31
31
  const calls = new Map();
32
32
  for (const [index, callees] of attributeCalls(parsed)) {
33
- const caller = byIndex.get(index);
33
+ const caller = symbolAt.get(index);
34
34
  if (!caller)
35
35
  continue;
36
36
  for (const [callee, member] of callees) {
@@ -93,21 +93,23 @@ function parsedSymbolFor(graphSymbol, parsed) {
93
93
  const base = symbolId(graphSymbol.file, graphSymbol.name, graphSymbol.kind);
94
94
  return matches.find((_symbol, index) => (index === 0 ? base : `${base}_${index}`) === graphSymbol.id) ?? null;
95
95
  }
96
- /** `start`/`end` are JS string (UTF-16 code unit) indices. Despite their
97
- * names, parse.ts's startByte/endByte/atByte carry the same units — native
98
- * tree-sitter indexes the JS string it was handed, not its UTF-8 encoding —
99
- * so every scan and splice against them must be string-based, never Buffer-
100
- * based. */
101
- function spliceChars(source, replacements) {
96
+ /** Apply `{ start, end, text }` replacements to `source`. Offsets are JavaScript
97
+ * string (UTF-16 code unit) indices: the parser's `startByte`/`endByte`/`atByte`
98
+ * fields, despite their names, carry the same units (tree-sitter indexes the JS
99
+ * string it was given, not its UTF-8 bytes), so every scan and splice against
100
+ * them must be string-based, never Buffer/byte-based. */
101
+ function applyReplacements(source, replacements) {
102
102
  let result = source;
103
103
  for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
104
104
  result = result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end);
105
105
  }
106
106
  return result;
107
107
  }
108
- /** Where a new top-level statement (an import) can be inserted without splitting a shebang line. */
109
- function insertionPoint(source) {
110
- return source.startsWith("#!") ? Math.max(0, source.indexOf("\n") + 1) : 0;
108
+ /** Offset where a new top-level import may go: 0, or just past a leading shebang line. */
109
+ function importInsertionOffset(source) {
110
+ if (!source.startsWith("#!"))
111
+ return 0;
112
+ return Math.max(0, source.indexOf("\n") + 1);
111
113
  }
112
114
  function mutateSource(policy, base, sourceFile, source) {
113
115
  const assertion = policy.assertion;
@@ -150,10 +152,14 @@ function mutateSource(policy, base, sourceFile, source) {
150
152
  const specifier = relativeSpecifier(sourceFile, targetFile);
151
153
  if (parsed.imports.some((candidate) => candidate === specifier))
152
154
  return { error: "mutation-component-import-already-present" };
153
- const insertion = insertionPoint(source);
155
+ const insertion = importInsertionOffset(source);
154
156
  return {
155
157
  file: sourceFile,
156
- source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n` }]),
158
+ source: applyReplacements(source, [{
159
+ start: insertion,
160
+ end: insertion,
161
+ text: `import ${JSON.stringify(specifier)}; // hunch deterministic component mutation\n`,
162
+ }]),
157
163
  };
158
164
  }
159
165
  const subject = symbolForSelector(base, assertion.subject);
@@ -166,7 +172,7 @@ function mutateSource(policy, base, sourceFile, source) {
166
172
  if (!definition)
167
173
  return { error: "mutation-subject-definition-unresolved" };
168
174
  if (assertion.kind === "exists") {
169
- return { file: subject.file, source: spliceChars(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
175
+ return { file: subject.file, source: applyReplacements(source, [{ start: definition.startByte, end: definition.endByte, text: "" }]) };
170
176
  }
171
177
  if (assertion.kind === "not-reaches"
172
178
  && assertion.relation.edges.length === 1
@@ -178,10 +184,14 @@ function mutateSource(policy, base, sourceFile, source) {
178
184
  if (parsed.imports.some((specifier) => externalPackage(specifier) === dependency)) {
179
185
  return { error: "mutation-forbidden-import-already-present" };
180
186
  }
181
- const insertion = insertionPoint(source);
187
+ const insertion = importInsertionOffset(source);
182
188
  return {
183
189
  file: subject.file,
184
- source: spliceChars(source, [{ start: insertion, end: insertion, text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n` }]),
190
+ source: applyReplacements(source, [{
191
+ start: insertion,
192
+ end: insertion,
193
+ text: `import ${JSON.stringify(dependency)}; // hunch deterministic source mutation\n`,
194
+ }]),
185
195
  };
186
196
  }
187
197
  const object = symbolForSelector(base, assertion.object);
@@ -198,11 +208,11 @@ function mutateSource(policy, base, sourceFile, source) {
198
208
  .map((call) => ({ start: call.atByte, end: call.endByte, text: "hunchMutationRemovedCall" }));
199
209
  if (!replacements.length)
200
210
  return { error: "mutation-required-call-unresolved" };
201
- return { file: subject.file, source: spliceChars(source, replacements) };
211
+ return { file: subject.file, source: applyReplacements(source, replacements) };
202
212
  }
203
213
  if (!assertion.relation.edges.includes("calls"))
204
214
  return { error: "mutation-call-edge-not-supported" };
205
- // String search, matching definition.startByte/endByte's actual units -- see spliceChars' doc comment.
215
+ // String search: offsets are string indices (see applyReplacements), not bytes.
206
216
  const open = source.indexOf("{", definition.startByte);
207
217
  if (open < 0 || open >= definition.endByte)
208
218
  return { error: "mutation-subject-body-unsupported" };
@@ -210,7 +220,7 @@ function mutateSource(policy, base, sourceFile, source) {
210
220
  if (object.file !== subject.file) {
211
221
  const specifier = relativeSpecifier(subject.file, object.file);
212
222
  if (!parsed.imports.includes(specifier)) {
213
- const insertion = insertionPoint(source);
223
+ const insertion = importInsertionOffset(source);
214
224
  replacements.push({
215
225
  start: insertion,
216
226
  end: insertion,
@@ -220,7 +230,7 @@ function mutateSource(policy, base, sourceFile, source) {
220
230
  }
221
231
  return {
222
232
  file: subject.file,
223
- source: spliceChars(source, replacements),
233
+ source: applyReplacements(source, replacements),
224
234
  };
225
235
  }
226
236
  function removeWorktree(root, hooks, env, checkout) {
@@ -1,5 +1,6 @@
1
1
  import type { Decision } from "./types.js";
2
2
  import type { CommitCandidate, CommitRepairStatus } from "../extractors/git.js";
3
+ /** One proposed provenance fix: decision `id` cites `from`, which should cite `to`. */
3
4
  export interface CommitRewrite {
4
5
  id: string;
5
6
  from: string;
@@ -8,14 +9,12 @@ export interface CommitRewrite {
8
9
  /** Bind review to the ordered queue and which targets are currently resolvable.
9
10
  * Queue order matters: duplicate decision ids use first-match selection. */
10
11
  export declare function commitRepairReviewHash(queued: readonly CommitRewrite[], withheld?: ReadonlySet<CommitRewrite>): string;
11
- /** A tombstone: the exact {id, from, to} rewrite a human explicitly rejected
12
- * via `--drop`. Keyed on the full triple, not just {id, from} — the human's
13
- * "no" answers the specific question the commit-repair-pending escalation
14
- * asked ("apply THIS proposed replacement, from -> to?"), not a blanket
15
- * refusal of every future replacement for this decision's still-orphaned
16
- * commit. A later merge proposing a genuinely different `to` for the same
17
- * {id, from} is a NEW proposal — not a repeat of the one that was rejected —
18
- * and surfaces normally. */
12
+ /**
13
+ * Tombstone for a rewrite a human explicitly rejected with `--drop`. The key is
14
+ * the full `{id, from, to}` triple, not just `{id, from}`: a later merge that
15
+ * proposes a DIFFERENT `to` for the same `{id, from}` is a new proposal and
16
+ * must surface normally.
17
+ */
19
18
  export interface DroppedRewrite {
20
19
  id: string;
21
20
  from: string;
@@ -23,12 +22,10 @@ export interface DroppedRewrite {
23
22
  }
24
23
  export interface CommitRepairPlan {
25
24
  rewrites: CommitRewrite[];
25
+ /** Distinct decision ids the rewrites touch. */
26
26
  records: string[];
27
27
  }
28
- /** Live decisions whose cited commit still exists here but is no longer an
29
- * ancestor of the current tip. Only "orphaned" qualifies: a commit that
30
- * doesn't resolve at all ("unresolvable") is out of repair scope, and a git
31
- * failure ("unknown") must never be mistaken for repair-eligible. */
28
+ /** Live decisions whose cited commit is orphaned (unresolvable/unknown/current are excluded). */
32
29
  export declare function orphanedCommitDecisions(decisions: readonly Decision[], status: (sha: string) => CommitRepairStatus): Decision[];
33
30
  /** Match each orphaned decision to exactly one candidate. A candidate with the
34
31
  * same `git patch-id --stable` as the orphaned commit (a rebase- or
@@ -39,139 +36,47 @@ export declare function orphanedCommitDecisions(decisions: readonly Decision[],
39
36
  * patch-id never equals any one of them, but weak: shared files like
40
37
  * CHANGELOG.md often have one wrong superset (#450). */
41
38
  export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[], patchIdOf?: (sha: string) => string | null): CommitRepairPlan;
42
- /** Whether a queued rewrite is still worth ASKING about: the decision it
43
- * names still exists, still cites the commit the entry expects to replace (a
44
- * decision that already moved on would make repairDecisionCommit's own
45
- * stale-plan bail refuse it), and hasn't since been superseded/rejected —
46
- * same exclusion as orphanedCommitDecisions, since repairing dead history's
47
- * provenance isn't worth asking about. Backs the read-only escalation
48
- * surface only (commitRepairEscalations) — absence-means-not-askable is
49
- * safe there because it self-heals the moment visibility returns.
50
- * repair-provenance itself does NOT use this: it reads the full store and
51
- * may legitimately list (dry-run) or leave queued (apply) an entry this
52
- * predicate would call not-live, so the escalation surface and the CLI can
53
- * disagree about the same entry — see deadRewrites and resolvedRewriteIds
54
- * for the CLI's own, looser-on-purpose reasoning. */
39
+ /** Queued entries that are still answerable: the decision exists, still cites `from`, and is live. */
55
40
  export declare function liveRewrites(queued: readonly CommitRewrite[], decisions: readonly Decision[]): CommitRewrite[];
56
- /** Entries a decision has demonstrably outgrown: the decision IS present in
57
- * `decisions`, and it either moved on from `from` or has since been
58
- * superseded/rejected. An id this list can't see is deliberately NOT
59
- * included — absence is a property of the READER (a branch checkout that
60
- * predates the decision, an unmounted private overlay), not proof the match
61
- * is stale. This predicate backs a DESTRUCTIVE prune (repair-provenance
62
- * deletes what it returns from the queue file); repairqueue.ts's own
63
- * docstring is why that matters — the queue is the one durable record of a
64
- * match once ORIG_HEAD moves on and the matched-away commit can be gc'd, so
65
- * deleting an entry on a merely-absent id would destroy it unrecoverably.
66
- * Contrast liveRewrites, whose absence-means-not-askable rule is safe on
67
- * the read-only escalation surface, which self-heals once visibility
68
- * returns.
69
- *
70
- * Caveat: "demonstrably" is only as strong as this run's view of
71
- * `decisions`, which comes from the same git-tracked, branch-dependent
72
- * files that motivate treating ABSENCE as inconclusive. A decision whose
73
- * `commit`/`status` genuinely differ between branches (e.g. a repair
74
- * already applied and committed on one branch, checked out here from a
75
- * branch that predates it) could still read as "moved on" when it hasn't,
76
- * from this branch's perspective. Far narrower than the absence case this
77
- * function exists to fix, and not addressed here. */
41
+ /**
42
+ * Queued entries whose decision IS visible but moved on (different or null
43
+ * commit) or was superseded/rejected. An id absent from `decisions` is never
44
+ * dead: absence comes from the reader's view (a branch checkout, an unmounted
45
+ * private overlay) and the CLI deletes whatever this returns. Caveat: a decision
46
+ * that differs between branches could still read as "moved on".
47
+ */
78
48
  export declare function deadRewrites(queued: readonly CommitRewrite[], decisions: readonly Decision[]): CommitRewrite[];
79
- /** Among `toApply` (the queued/matched candidates this run is about to act
80
- * on), the ids repair-provenance may treat as RESOLVED once it's done: the
81
- * decision is present in `decisions` this run, whether or not
82
- * repairDecisionCommit actually changed it (a present decision that already
83
- * moved on is "resolved" too — repairDecisionCommit's own bail refused it,
84
- * and there is nothing more this run can do about it). An id whose decision
85
- * is NOT present is never resolved — same reasoning as deadRewrites:
86
- * absence is the reader's transient view, not proof the match is settled,
87
- * so it must stay queued for a run where the decision is visible again. */
49
+ /** Ids of `toApply` whose decision is visible, whether or not the rewrite changed it. */
88
50
  export declare function resolvedRewriteIds(toApply: readonly CommitRewrite[], decisions: readonly Decision[]): Set<string>;
89
- /** Remove any match whose exact {id, from, to} triple was already tombstoned
90
- * by an earlier `--drop` — otherwise a later merge that re-derives the
91
- * identical candidate would re-queue exactly what the human rejected. A
92
- * match sharing {id, from} but proposing a different `to` is unaffected: it
93
- * is a different proposal, not the rejected one.
94
- *
95
- * Preserves object identity for every surviving entry (a plain filter, never
96
- * a clone or a rebuild) — repair-provenance's own action relies on this to
97
- * diff its swept-vs-active queue by reference (`queue.filter(r =>
98
- * !active.includes(r))`) rather than re-spelling the {id, from, to} key a
99
- * third time. Do not change this to normalize or reconstruct entries
100
- * without updating that call site too. */
101
- export declare function withoutDropped(rewrites: readonly CommitRewrite[], dropped: readonly DroppedRewrite[]): CommitRewrite[];
102
- /** Append newly-dropped {id, from, to} triples to the tombstone list,
103
- * deduped — same append-only, dedupe-by-key shape as mergeRewrites. */
51
+ /** Drop rewrites whose exact {id, from, to} triple is tombstoned. A plain filter: survivors keep identity. */
52
+ export declare function withoutDropped<T extends CommitRewrite>(rewrites: readonly T[], dropped: readonly DroppedRewrite[]): T[];
53
+ /** `existing` followed by the entries of `newly` whose exact triple is not already tombstoned. */
104
54
  export declare function addDropped(newly: readonly DroppedRewrite[], existing: readonly DroppedRewrite[]): DroppedRewrite[];
105
- /** Combine a freshly-computed plan's rewrites with anything already queued
106
- * (src/core/repairqueue.ts) from an earlier detection. A fresh match —
107
- * computed just now, against the current range — overrides a queued one for
108
- * the same decision; queued entries no longer reachable are simply not
109
- * repeated by the fresh scan.
110
- *
111
- * Overrides by object identity, targeting exactly the one queued entry
112
- * firstFor (id) would pick — the same first-match rule --drop applies, and
113
- * the same identity-not-id targeting the dead-entry prune applies (#53; the
114
- * prune itself removes every provably-dead entry, not just a first match).
115
- * A corrupted queue file, a hand edit, or a bug upstream could otherwise
116
- * carry a SECOND entry sharing that id; overriding by id alone would
117
- * silently destroy that still-queued sibling too, with no tombstone and no
118
- * way to recover it, even though it was never the entry that would have
119
- * been applied. Any such sibling is left queued and untouched.
120
- *
121
- * How a survivor resolves: the dead-entry prune (deadRewrites), once
122
- * --apply has moved the decision past the sibling's `from`; or a --drop
123
- * whose tombstone matches the sibling's exact {id, from, to} — the same
124
- * --drop that dropped the fresh entry, when the sibling happens to share
125
- * its triple (the drop's own re-sweep, withoutDropped, catches it too), a
126
- * second explicit --drop otherwise. NOT a later fresh detection: by this
127
- * same first-match rule it always overrides the fresh entry sitting ahead
128
- * of it, never the sibling. */
55
+ /**
56
+ * Fresh detections first, then the queued entries they did not override. A fresh
57
+ * entry overrides only the FIRST queued entry with its id (see `firstFor`);
58
+ * later duplicate-id siblings survive untouched.
59
+ */
129
60
  export declare function mergeRewrites(fresh: readonly CommitRewrite[], queued: readonly CommitRewrite[]): CommitRewrite[];
130
- /** Partition the entries --apply is about to act on by whether their proposed
131
- * `to` commit actually exists in this repository. `existing` is
132
- * `commitsExist`'s result over every `to` in `toApply`: `null` means the
133
- * check itself failed to run (not a git repo, git missing, timeout) and is
134
- * treated as fail-open, same discipline as drift.ts's own use of
135
- * commitsExist — nothing is withheld just because the check couldn't run.
136
- *
137
- * A corrupted queue file, a hand-edited entry, or a bug upstream could
138
- * otherwise get a `to` that never resolves to a real commit written
139
- * straight into a decision's `commit` field and provenance evidence, then
140
- * auto-committed into shared team memory. `withheld` entries must stay
141
- * queued (never deleted, never passed to repairDecisionCommit) — the queue
142
- * file is the one durable record of the match, same reasoning as
143
- * deadRewrites/resolvedRewriteIds.
144
- *
145
- * Both returned arrays preserve object identity (never clones or rebuilds
146
- * an entry) — the CLI's queue sweep and its "still queued" reporting both
147
- * key a `Set` off the exact objects in `withheld`, by reference, precisely
148
- * because a corrupted queue file can carry two entries sharing an id (one
149
- * resolvable, one not): an id alone can't tell them apart. Do not change
150
- * this to normalize or reconstruct entries without updating those call
151
- * sites too — same caveat withoutDropped's own docstring carries. */
152
- export declare function withheldForUnresolvableTo(toApply: readonly CommitRewrite[], existing: Set<string> | null): {
61
+ /**
62
+ * Split entries by whether their proposed `to` resolves here. `existing === null`
63
+ * means the check could not run, so it fails open (everything applicable).
64
+ * Order and object references are preserved.
65
+ */
66
+ export declare function withheldForUnresolvableTo(toApply: readonly CommitRewrite[], existing: ReadonlySet<string> | null): {
153
67
  applicable: CommitRewrite[];
154
68
  withheld: CommitRewrite[];
155
69
  };
156
- /** The one rule for which entry wins when more than one shares a decision id
157
- * (a corrupted queue file, a hand edit, or a bug upstream): first match, by
158
- * construction. Every caller goes through here so no two can independently
159
- * drift on which entry "won" — this file's own duplicate-id queue handling
160
- * has drifted between call sites three times already (see withoutDropped's,
161
- * withheldForUnresolvableTo's, and mergeRewrites' own docstrings). */
162
- export declare function firstFor(entries: readonly CommitRewrite[], id: string): CommitRewrite | undefined;
163
- /** pickRewrite is firstFor scoped to a plan's own rewrites (planCommitRepair
164
- * itself never produces same-id duplicates, but a plan carrying them isn't
165
- * ruled out here — see firstFor). repairDecisionCommit uses this to decide
166
- * what to WRITE; a caller that needs to know which entry WOULD be picked
167
- * without writing anything (the CLI's --apply summary and queue sweep)
168
- * calls this same function, so the two can never independently drift on
169
- * which entry "won". */
70
+ /** The single first-match rule for duplicate decision ids. */
71
+ export declare function firstFor<T extends {
72
+ id: string;
73
+ }>(entries: readonly T[], id: string): T | undefined;
74
+ /** The plan's rewrite for `id` (first match). */
170
75
  export declare function pickRewrite(plan: CommitRepairPlan, id: string): CommitRewrite | undefined;
171
- /** Pure: rewrite `commit` for one decision, and its matching `commit:<sha>`
172
- * evidence entry too if one is present (replaceExact is a no-op when the
173
- * evidence array never cited the old sha) — or return the same reference
174
- * when the plan doesn't touch it. If the record moved on since the plan was
175
- * built (its commit no longer equals the plan's `from`), bail entirely
176
- * rather than applying a now-stale match. */
76
+ /**
77
+ * Pure: apply the plan's rewrite to a decision. Returns the same reference when
78
+ * there is no entry or the record moved on (`commit` no longer equals `from`);
79
+ * otherwise a copy with `commit` and the `commit:<from>` evidence moved to `to`
80
+ * (deduped if `to` was already cited).
81
+ */
177
82
  export declare function repairDecisionCommit(d: Decision, plan: CommitRepairPlan): Decision;