@davesheffer/hunch 1.45.2 → 1.47.0

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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -22,13 +22,16 @@ import { fileURLToPath } from "node:url";
22
22
  import { Command } from "commander";
23
23
  import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
24
24
  import { writeFileAtomic } from "../core/io.js";
25
+ import { boundedSpawnSync } from "../core/boundedSpawn.js";
25
26
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
27
  import { HUNCH_VERSION } from "../core/version.js";
27
28
  import { registerIntegrationCommands } from "./integrations.js";
28
29
  import { registerTaskReportCommands } from "./taskReport.js";
29
30
  import { registerStateCommands } from "./state.js";
30
31
  import { DEFAULT_CONFIG as SERVE_DEFAULT_CONFIG_FILE, registerServeCommands } from "./serve.js";
32
+ import { readServeConfig } from "../serve/config.js";
31
33
  import { registerUpdateCommand } from "./update.js";
34
+ import { registerVerifyCommand } from "./verify.js";
32
35
  import { registerReviewMemoryCommands } from "./reviewMemory.js";
33
36
  import { detectInitiator, normalizeInitiator } from "../synthesis/initiator.js";
34
37
  import { inspectIntegrations, formatIntegrationHealth, integrationHealthFails, integrationSessionWarning } from "../integrations/health.js";
@@ -62,7 +65,7 @@ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, share
62
65
  import { installMergeDriver } from "../integrations/mergeDriver.js";
63
66
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
64
67
  import { writeCiWorkflow } from "../integrations/ciAction.js";
65
- import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optedOutAt, optOutMessage, planUninstall } from "../integrations/uninstall.js";
68
+ import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMarkerAt, optOutMessage, planUninstall } from "../integrations/uninstall.js";
66
69
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
67
70
  import { HOOK_REMINDER } from "../core/hookText.js";
68
71
  import { measureFootprint } from "../core/footprint.js";
@@ -188,6 +191,7 @@ registerTaskReportCommands(program, () => { const { store, root } = storeFor();
188
191
  registerServeCommands(program);
189
192
  registerStateCommands(program);
190
193
  registerUpdateCommand(program);
194
+ registerVerifyCommand(program);
191
195
  registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
192
196
  const { store, root } = storeFor();
193
197
  if (!repositoryUsesRemote(root, `https://github.com/${repository}.git`)) {
@@ -228,6 +232,103 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
228
232
  // run: a leftover driver that exits without writing %A would let git drop `theirs`.
229
233
  const OPT_OUT_ALLOWED = new Set(["uninstall", "doctor", "help", "merge-driver", "merge-driver-grounding"]);
230
234
  const OPT_OUT_QUIET = new Set(["hook", "sync", "check", "grounding", "repair-provenance", "workspaces"]);
235
+ /** Locations a command writes to (#488), keyed by command path: an opted-out repository
236
+ * must never receive them, so their repository is gated IN ADDITION to the cwd's.
237
+ * `base` is what the command itself resolves a relative value against: the cwd's
238
+ * repository root (`findRoot`), Git's top level (`verify`), or the cwd; `join` marks a
239
+ * command that joins the value onto that base even when absolute (`export-adr`).
240
+ * `checkout` marks a command that also writes into the MAIN checkout (the overlay
241
+ * anchor) and the git common dir (the shared-overlay pointer, the shared git hooks),
242
+ * so both are gated; with no target (`init`) only those are. */
243
+ const OPT_OUT_OUTPUTS = {
244
+ "init": { base: "root", checkout: true },
245
+ "private": { arg: 0, base: "root", checkout: true },
246
+ "shared": { arg: 0, base: "root", checkout: true },
247
+ "worktree": { arg: 0, base: "root", checkout: true },
248
+ "export-adr": { arg: 0, base: "root", join: true },
249
+ "review-memory auto": { option: "output", base: "cwd" },
250
+ "verify": { option: "map", base: "toplevel" },
251
+ };
252
+ /** Where `hunch private`/`shared` anchor the default overlay store: the MAIN worktree
253
+ * root, shared by the write and the opt-out gate so they cannot diverge. */
254
+ function overlayAnchor(root) {
255
+ return mainWorktreeRoot(root);
256
+ }
257
+ /** Git's top level for the cwd, as `hunch verify` resolves it; findRoot() outside Git. */
258
+ function gitToplevel() {
259
+ try {
260
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim() || findRoot();
261
+ }
262
+ catch {
263
+ return findRoot();
264
+ }
265
+ }
266
+ /** The marker gating `path` (the cwd, or a location a command writes to). Its own store
267
+ * or repository root (`findRoot`: git-less stores included) counts first; inside a
268
+ * repository the walk then passes any nested `.hunch` store up to the repository
269
+ * (#488). A stray marker in a non-store, non-repository ancestor (e.g. ~) gates nothing. */
270
+ function markerFor(path) {
271
+ const real = realpathNorm(path);
272
+ const root = findRoot(real);
273
+ if (isOptedOut(root))
274
+ return join(root, OPT_OUT_FILE);
275
+ return optOutMarkerAt(real, { withinRepo: true });
276
+ }
277
+ /** Every location whose repository the opt-out gate judges for this invocation (#458,
278
+ * #488). A `--root` or a `serve` config names the repository the command acts on, so
279
+ * it REPLACES the cwd; `serve` also gates every partition root its config names (an
280
+ * unreadable config gates its directory only; the command itself then fails). An
281
+ * output location only ADDS to the cwd. */
282
+ function optOutTargets(top, action) {
283
+ const path = [];
284
+ for (let c = action; c && c !== program; c = c.parent)
285
+ path.unshift(c.name());
286
+ const given = (v) => typeof v === "string" && v.trim() !== "";
287
+ const actionOpts = action.opts();
288
+ const targets = [];
289
+ let cwd = true;
290
+ if (given(actionOpts.root)) {
291
+ targets.push(resolve(actionOpts.root));
292
+ cwd = false;
293
+ }
294
+ if (top.name() === "serve" && (path[1] === "init" || cwd)) {
295
+ // `serve` and `serve init` both take --config; the command reads the parent's first.
296
+ const configOpt = top.opts().config ?? actionOpts.config;
297
+ const configFile = resolve(given(configOpt) ? configOpt : SERVE_DEFAULT_CONFIG_FILE);
298
+ targets.push(dirname(configFile));
299
+ if (cwd) {
300
+ try {
301
+ for (const p of readServeConfig(configFile).partitions)
302
+ targets.push(p.root);
303
+ }
304
+ catch { /* the command reports it */ }
305
+ }
306
+ cwd = false;
307
+ }
308
+ // Output locations follow the cwd rule: a marker gates them only inside a repository,
309
+ // never from a non-repository ancestor (bare / --separate-git-dir common dirs included).
310
+ const outputs = [];
311
+ const out = OPT_OUT_OUTPUTS[path.join(" ")];
312
+ if (out) {
313
+ const value = out.option !== undefined ? actionOpts[out.option] : action.processedArgs[out.arg ?? 0];
314
+ if (given(value)) {
315
+ const base = out.base === "root" ? findRoot() : out.base === "toplevel" ? gitToplevel() : process.cwd();
316
+ // `join`: the path the command writes, plus the absolute path the user named.
317
+ if (out.join)
318
+ outputs.push(join(base, toPosixTarget(value)));
319
+ outputs.push(resolve(base, value));
320
+ }
321
+ if (out.checkout) {
322
+ const root = findRoot();
323
+ const common = gitCommonDir(root);
324
+ // Only a real linked worktree shares a common dir with a main checkout; a submodule's
325
+ // common dir (super/.git/modules/x) must not pull the superproject's marker in.
326
+ if (common && mainWorktreeRoot(root) !== root)
327
+ outputs.push(overlayAnchor(root), resolve(root, common));
328
+ }
329
+ }
330
+ return { targets, outputs, cwd };
331
+ }
231
332
  program.hook("preAction", (_thisCommand, actionCommand) => {
232
333
  let top = actionCommand;
233
334
  while (top.parent && top.parent !== program)
@@ -235,23 +336,26 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
235
336
  const name = top.name();
236
337
  if (OPT_OUT_ALLOWED.has(name))
237
338
  return;
238
- // Gate the repository the command acts on (#458): a `--root` names it (`hunch mcp
239
- // --root`, `hunch serve ... --root`), so its real path is gated with every ancestor
240
- // up to its store or repository and the cwd's repository is irrelevant; without
241
- // one, the command acts on the cwd's repository. `serve init` also writes its
242
- // config file (resolved against the cwd), so the directory it lands in gates it too.
243
- const rootOpt = actionCommand.opts().root;
244
- const rootGiven = typeof rootOpt === "string" && rootOpt.trim() !== "";
245
- let gated = rootGiven ? optedOutAt(realpathNorm(resolve(rootOpt))) : isOptedOut(findRoot());
246
- if (!gated && name === "serve" && actionCommand.name() === "init") {
247
- const configOpt = top.opts().config ?? actionCommand.opts().config;
248
- const configFile = resolve(typeof configOpt === "string" ? configOpt : SERVE_DEFAULT_CONFIG_FILE);
249
- gated = optedOutAt(realpathNorm(dirname(configFile)));
250
- }
251
- if (!gated)
339
+ // Gate the repository each location acts on (#458, #488): its real path is walked up
340
+ // through its ancestors to its repository, past any nested `.hunch` store (fail
341
+ // closed, so a cwd inside a nested store is gated too), and the refusal names the
342
+ // marker that actually gates it.
343
+ const { targets, outputs, cwd } = optOutTargets(top, actionCommand);
344
+ let marker = cwd ? markerFor(process.cwd()) : null;
345
+ for (const target of targets) {
346
+ if (marker)
347
+ break;
348
+ marker = optOutMarkerAt(realpathNorm(target));
349
+ }
350
+ for (const target of outputs) {
351
+ if (marker)
352
+ break;
353
+ marker = markerFor(target);
354
+ }
355
+ if (!marker)
252
356
  return;
253
357
  if (name !== "hook")
254
- console.error(optOutMessage());
358
+ console.error(optOutMessage(marker));
255
359
  process.exit(OPT_OUT_QUIET.has(name) ? 0 : 1);
256
360
  });
257
361
  // Read an already-known update and schedule any registry refresh in a detached
@@ -553,8 +657,8 @@ program
553
657
  }
554
658
  // Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
555
659
  // case-split in ~/.claude.json, merge it so hunch resolves under either casing.
556
- // No-op (silent) off Windows.
557
- reportClaudeConfigHeal();
660
+ // No-op (silent) off Windows; the no-op line belongs to --details (#493).
661
+ reportClaudeConfigHeal(say);
558
662
  // Worktree-seamless: register any configured overlay at the git common dir so EVERY
559
663
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
560
664
  // and note when we're initializing inside a linked worktree (memory is shared, not separate).
@@ -642,12 +746,11 @@ program
642
746
  // never rewrites an existing one: `index` runs in CI/release gates on a clean
643
747
  // checkout. Older blocks are upgraded by `hunch update` / init / private setup.
644
748
  ensureGitignore(root, { upgradeExisting: false });
645
- // The post-merge hook only ever got installed by `hunch init`/`hunch
646
- // private`/`hunch shared` — a repo that already ran init before this hook
647
- // existed never receives it. `hunch index` already self-heals gitignore
648
- // the same way; do the same for the hook so an upgrade doesn't require
649
- // re-running init by hand. Gated on already having post-commit: `index`
650
- // is not a setup command (it runs in CI, on any git repo), so it must
749
+ // The post-merge hook used to be installed only by init / private / shared, so a
750
+ // repo initialized before it existed never got it. `index` already self-heals the
751
+ // gitignore and does the same for the hooks, so upgrading needs no re-init. The
752
+ // heal is gated on post-commit already being present: `index` is not a setup
753
+ // command (it runs in CI on arbitrary repos) and so it must
651
754
  // never be what FIRST hooks a repo that never ran init at all. A STALE
652
755
  // post-commit block counts too (issue #315): it proves init ran, so the
653
756
  // missing post-merge/post-checkout hooks are still this repo's to heal.
@@ -721,9 +824,9 @@ program
721
824
  const commits = logSince(opts.since, root, Number(opts.max));
722
825
  const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
723
826
  console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
724
- // Best-effort context-window advisory (issue #11): printed ONCE, before any
725
- // commit is drafted — not per-commit, and not under --deep (an ensemble may
726
- // fan out to several distinct workers, each with its own configuration).
827
+ // One-time, best-effort context-window advisory: printed once before any commit is
828
+ // drafted (never per commit), and skipped under --deep since an ensemble may fan out
829
+ // to several workers with differing configurations.
727
830
  if (!opts.deep && commits.length > 0) {
728
831
  // { root }: the advisory must resolve the SAME provider the synthesis
729
832
  // inside syncCommit resolves (persisted local.json preference included) —
@@ -731,7 +834,7 @@ program
731
834
  // num_ctx warning for an actual Ollama run (issue #46).
732
835
  const ctxProvider = await selectProvider({ root });
733
836
  const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
734
- if (ctxWarning)
837
+ if (ctxWarning !== null)
735
838
  console.log(ctxWarning);
736
839
  }
737
840
  let written = 0, skipped = 0, llm = 0, heuristic = 0;
@@ -985,13 +1088,12 @@ function discoverSharedRemote(remoteUrl) {
985
1088
  && remoteUrl.startsWith(prefix))
986
1089
  return null;
987
1090
  }
988
- const listed = spawnSync("git", [
1091
+ // Bounded so a timeout kills git's whole tree, not just the root (#498).
1092
+ const listed = boundedSpawnSync("git", [
989
1093
  "-C", probe,
990
1094
  "-c", `core.hooksPath=${hooksDir}`,
991
1095
  "ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", remoteUrl,
992
1096
  ], {
993
- encoding: "utf8",
994
- stdio: ["ignore", "pipe", "ignore"],
995
1097
  env: boundedTeamGitEnv(),
996
1098
  timeout: 15_000,
997
1099
  });
@@ -1151,9 +1253,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1151
1253
  const backLink = commonDir ? separateGitDirLink(commonDir) : "";
1152
1254
  const configuredHooks = includeHook ? hooksDir(root) : "";
1153
1255
  const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
1154
- // Setup installs post-commit AND post-merge together, so both must be in the
1155
- // ledger — restoring one while leaving the other pointing at a just-deleted
1156
- // overlay is not a rollback.
1256
+ // Setup installs both hooks together, so both must be snapshotted: restoring only one
1257
+ // while the other still points at a deleted overlay is not a rollback.
1157
1258
  const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
1158
1259
  const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
1159
1260
  const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
@@ -1175,7 +1276,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1175
1276
  markGitignoreWrite: () => mark(codeGitignore),
1176
1277
  markTeamWrite: () => mark(teamFile),
1177
1278
  markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
1178
- markHookWrite: () => hookFiles.forEach(mark),
1279
+ markHookWrite: () => { for (const f of hookFiles)
1280
+ mark(f); },
1179
1281
  // Migration is a one-way ownership handoff. Once public records have been
1180
1282
  // durably copied into this clone, a later setup failure may restore routing
1181
1283
  // files but must not delete the clone that now holds their surviving copy.
@@ -1324,7 +1426,7 @@ function configureOverlay(dir, opts, mode) {
1324
1426
  // Anchor the default store at the MAIN worktree root: a linked worktree can be
1325
1427
  // `git worktree remove`d, which would take the store (and every other worktree's
1326
1428
  // absolute pointer to it) down with it. An explicit [dir] still resolves from here.
1327
- const anchor = mainWorktreeRoot(root);
1429
+ const anchor = overlayAnchor(root);
1328
1430
  if (opts.repo) {
1329
1431
  const dest = join(anchor, ".hunch-private");
1330
1432
  // Canonicalize once in the invocation context so clone, attach, fetch, and
@@ -4807,20 +4909,21 @@ program
4807
4909
  .requiredOption("--by <new>", "decision id that supersedes it")
4808
4910
  .action((oldId, opts) => {
4809
4911
  const { store, root } = storeFor();
4810
- // `old`'s home decides which store the close is written to; `--by` must resolve in
4811
- // that SAME store, or a private `by` linked against a public `old` would write the
4812
- // private id straight into the committed public store.
4912
+ // The old decision's own memory home decides which store both ids must live in.
4813
4913
  const home = decisionMemoryHome(store, oldId);
4814
- if (!store.decisionInStore(oldId, home === "private")) {
4914
+ const priv = home === "private";
4915
+ // Check the old id first so a missing old decision is never blamed on --by.
4916
+ if (!store.decisionInStore(oldId, priv)) {
4815
4917
  store.close();
4816
4918
  return fail(`decision "${oldId}" not found`);
4817
4919
  }
4818
- const by = store.decisionInStore(opts.by, home === "private");
4920
+ // Resolve --by in the SAME store: a private --by must never be linked into a committed public record.
4921
+ const by = store.decisionInStore(opts.by, priv);
4819
4922
  if (!by) {
4820
4923
  store.close();
4821
4924
  return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
4822
4925
  }
4823
- const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4926
+ const closed = priv ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4824
4927
  if (!closed) {
4825
4928
  store.close();
4826
4929
  return fail(`decision "${oldId}" cannot supersede itself`);
@@ -4903,7 +5006,8 @@ program
4903
5006
  .action(async (value) => {
4904
5007
  const root = findRoot();
4905
5008
  if (value != null) {
4906
- const preference = normalizeProviderName(value.trim()) ?? value.trim();
5009
+ const trimmed = value.trim();
5010
+ const preference = normalizeProviderName(trimmed) || trimmed;
4907
5011
  if (!SYNTH_PREFERENCES.includes(preference)) {
4908
5012
  return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
4909
5013
  }
@@ -4916,7 +5020,7 @@ program
4916
5020
  console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
4917
5021
  }
4918
5022
  const resolution = await resolveSynthesisProvider({ root });
4919
- const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER?.trim());
5023
+ const envValue = normalizeProviderName((process.env.HUNCH_SYNTH_PROVIDER ?? "").trim()) || undefined;
4920
5024
  const local = readSynthesisPreference(root);
4921
5025
  const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
4922
5026
  console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
@@ -5433,8 +5537,8 @@ program
5433
5537
  // so its orientation event keeps asking every actionable entry.
5434
5538
  const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
5435
5539
  if (!decisions.length && !actionableEsc.length) {
5436
- // Fresh graph and nothing else to raise: nothing to orient on, but
5437
- // the operating loop still ships. A queued commit-repair escalation
5540
+ // A fresh graph with nothing to raise has nothing to orient on, but the
5541
+ // operating loop is still delivered. A queued commit-repair escalation
5438
5542
  // (checked against the full store) is enough reason NOT to bail here
5439
5543
  // even when the visible decisions list is empty.
5440
5544
  if (pipelineEnabled())
@@ -6094,12 +6198,15 @@ program
6094
6198
  return;
6095
6199
  }
6096
6200
  const res = target.persistMigration();
6097
- writeManifest(paths, SCHEMA_VERSION);
6201
+ writeManifest(paths, res.version); // held at `from` while any record failed to migrate
6098
6202
  store.reindex();
6099
- pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${SCHEMA_VERSION}`);
6100
- console.log(`✓ Migrated v${from} → v${SCHEMA_VERSION}: ${res.migrated} record(s) upgraded.`);
6203
+ pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${res.version}`);
6204
+ console.log(`✓ Migrated v${from} → v${res.version}: ${res.migrated} record(s) upgraded.`);
6101
6205
  if (res.skipped) {
6102
- console.warn(`⚠ ${res.skipped} record(s) could NOT be migrated and will no longer load. They are preserved on disk in their old shape under .hunch/ for manual recovery.`);
6206
+ console.warn(`⚠ ${res.skipped} record(s) could not be migrated. They are preserved on disk in their old shape under .hunch/ for manual recovery; the schema stays at v${res.version}, so re-running \`hunch migrate\` after a fix completes the migration to v${SCHEMA_VERSION}.`);
6207
+ }
6208
+ if (res.invalid) {
6209
+ console.warn(`⚠ ${res.invalid} record(s) are invalid or unreadable and no pending migration applies to them. They were left untouched under .hunch/; fix them by hand.`);
6103
6210
  }
6104
6211
  store.close();
6105
6212
  });
@@ -6131,7 +6238,7 @@ program
6131
6238
  // ---- escalations (the inline "ask the human" surface) ---------------------
6132
6239
  program
6133
6240
  .command("escalations")
6134
- .description("The decisions a human must make NOW — surfaced to be asked INLINE (in the prompt), never a background queue. Captured memory auto-trusts on landing; this lists only what the graph genuinely can't resolve itself: topic conflicts, premise-stale decisions, one exact imported ADR at a time, a queued commit-provenance repair awaiting `--apply`, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty. Exits non-zero when any are open, so an assistant/CI knows to raise them.")
6241
+ .description("The decisions a human must make now — ask them inline in the prompt, never queued in the background. Captured memory auto-trusts on landing, so this lists only what the graph cannot resolve itself: topic conflicts, premise-stale decisions, one exact imported ADR at a time, a queued commit-provenance repair awaiting `--apply`, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty; exits non-zero when any are open so an assistant or CI knows to raise them.")
6135
6242
  .option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
6136
6243
  .action(async (opts) => {
6137
6244
  const { store, root } = storeFor();
@@ -6141,8 +6248,8 @@ program
6141
6248
  // Premise decay rides the same inline surface: a decision whose recorded
6142
6249
  // reason died is a QUESTION for the human — authority never changes here.
6143
6250
  items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
6144
- const escalationsQueue = readActivePendingRepairs(root);
6145
- items.push(...commitRepairEscalations(escalationsQueue, decisionsForEsc, decisionsForEsc, withheldRewrites(root, escalationsQueue)));
6251
+ const queuedRepairs = readActivePendingRepairs(root);
6252
+ items.push(...commitRepairEscalations(queuedRepairs, decisionsForEsc, decisionsForEsc, withheldRewrites(root, queuedRepairs)));
6146
6253
  // Constitution moments ride the same inline surface (§59.5.3) — never a queue.
6147
6254
  // Fail open: a broken policy store must not take the memory escalations down.
6148
6255
  try {
@@ -6150,10 +6257,10 @@ program
6150
6257
  items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
6151
6258
  }
6152
6259
  catch { /* constitution unavailable — memory escalations still surface */ }
6153
- // Gate (exit code + tallies) on the ACTIONABLE subset only — a duplicate-id
6154
- // commit-repair follower whose own resolution says "act on a different
6155
- // entry first" still surfaces below for transparency, but must not count
6156
- // as its own thing needing a decision (#61). --json keeps the full list.
6260
+ // The exit code and tallies gate only on the actionable subset: a duplicate-id
6261
+ // commit-repair follower whose own resolution says to act on a different entry
6262
+ // first still surfaces for transparency, but is not its own decision. --json
6263
+ // keeps the full list.
6157
6264
  const actionable = actionableEscalations(items);
6158
6265
  if (opts.json) {
6159
6266
  console.log(JSON.stringify(items));
@@ -6165,18 +6272,12 @@ program
6165
6272
  console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
6166
6273
  return;
6167
6274
  }
6168
- // escalationHeadline's own "nothing actionable, N shown for context"
6169
- // fallback is currently unreachable here: every escalation-producing
6170
- // function today guarantees at least one actionable entry whenever it
6171
- // emits anything at all — see commitRepairEscalations' own docstring on
6172
- // `firstFor`/dropTarget. Kept anyway (not assumed away) because that
6173
- // guarantee lives in the PRODUCERS, not in `Escalation.actionable`'s own
6174
- // contract, which a future producer could legitimately violate.
6175
- console.log(escalationHeadline(items, "cli") + "\n");
6275
+ // The headline's "nothing actionable, N shown for context" fallback is currently
6276
+ // unreachable (every producer guarantees at least one actionable entry), but that
6277
+ // guarantee lives in the producers, not in Escalation.actionable's contract.
6278
+ console.log(`${escalationHeadline(items, "cli")}\n`);
6176
6279
  for (const e of items) {
6177
- // A non-actionable row (a duplicate-id follower) still surfaces for
6178
- // transparency, but must not read like its own question — marked
6179
- // distinctly so a skim doesn't mistake it for one of the tally above.
6280
+ // A non-actionable follower is shown for context only, so it gets a quieter marker.
6180
6281
  console.log(` ${e.actionable === false ? "·" : "⚖"} ${e.question}`);
6181
6282
  console.log(` ${dim(e.detail)}`);
6182
6283
  console.log(` ${dim("→ " + e.resolution)}\n`);
@@ -6319,17 +6420,17 @@ program
6319
6420
  store.close();
6320
6421
  }
6321
6422
  });
6322
- // ---- repair-provenance (squash-merge commit provenance repair) ------------
6423
+ // ---- repair-provenance (squash-merge commit-provenance self-repair) -------
6323
6424
  program
6324
6425
  .command("repair-provenance")
6325
6426
  .description("Self-repair: detect a decision's commit provenance going orphaned by a squash-merge, matched by exact related_files overlap against the newly merged commit range — zero guessing beyond that. A fresh match not already rejected via --drop is queued (.hunch/pending-commit-repairs.json, local-only); --apply/--drop require the --expect hash printed by the preview or escalation; --only <dec_id> limits approval to one decision. Approval never scans for fresh matches. The post-merge hook runs detection automatically in the background but never passes --apply — the match signal isn't strong enough to trust an unattended write into shared team memory.")
6326
6427
  .option("--apply", "apply the reviewed queued candidates (requires --expect; does not scan for new matches)")
6327
6428
  .option("--expect <hash>", "exact queue review hash printed by the preview or escalation; required for --apply/--drop")
6328
- .option("--only <dec_id>", "with --apply, rewrite only this decision id — everything else stays queued untouched")
6329
- .option("--drop <dec_id>", "reject the queued match for this decision id, without applying it — tombstoned durably, so an identical future match for the same still-orphaned commit won't resurface (a genuinely different candidate still can)")
6429
+ .option("--only <dec_id>", "with --apply, rewrite only this decision id; everything else stays queued untouched")
6430
+ .option("--drop <dec_id>", "reject the queued match for this decision id without applying it; tombstoned so an identical future match for the same still-orphaned commit does not resurface (a genuinely different candidate still can)")
6330
6431
  .option("--from-hook", "invoked by the git post-merge hook")
6331
6432
  .option("--quiet", "minimal output")
6332
- .option("--range <old..new>", "commit range to scan for replacement commits (default: ORIG_HEAD..HEAD)")
6433
+ .option("--range <old..new>", "commit range to scan for replacement commits (default ORIG_HEAD..HEAD)")
6333
6434
  .action((opts) => {
6334
6435
  const { store, root } = storeFor();
6335
6436
  try {
@@ -6344,41 +6445,32 @@ program
6344
6445
  const parts = opts.range.split("..");
6345
6446
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
6346
6447
  if (!opts.fromHook)
6347
- fail('--range must look like "old..new"');
6448
+ fail(`--range must look like "old..new"`);
6348
6449
  return;
6349
6450
  }
6350
- [oldRef, newRef] = parts;
6451
+ oldRef = parts[0];
6452
+ newRef = parts[1];
6351
6453
  }
6352
6454
  const rangeResolves = revExists(oldRef, root) && revExists(newRef, root);
6353
6455
  if (!rangeResolves && opts.range) {
6354
- // An explicitly-given range that doesn't resolve is a usage mistake, not
6355
- // "no merge happened yet" — surface it. (The installed hook never passes
6356
- // --range, so --from-hook never reaches this branch in practice.)
6357
6456
  if (!opts.fromHook)
6358
6457
  fail(`range "${oldRef}..${newRef}" does not resolve in this repository`);
6359
6458
  return;
6360
6459
  }
6460
+ // An unresolvable DEFAULT range just means no merge happened: detection is skipped below.
6361
6461
  const decisions = store.recs("decisions");
6362
- // One save path for every queue mutation below — a future change to
6363
- // queue semantics has exactly one write call to touch, not several
6364
- // independently-reasoned ones. This file has twice shipped the same
6365
- // failure class from that fragmentation: one write site's "is this
6366
- // entry resolved" logic gets fixed while a sibling write site quietly
6367
- // keeps reasoning about the queue differently, deleting an entry the
6368
- // fixed site would have left alone.
6462
+ // One save path per file, so queue semantics cannot drift across write sites.
6369
6463
  let queue = readPendingRepairs(root);
6370
- const save = (next) => {
6371
- queue = [...next];
6464
+ const saveQueue = (next) => {
6465
+ queue = next;
6372
6466
  writePendingRepairs(root, queue);
6373
6467
  };
6374
- // Tombstones for exact {id, from, to} triples a human already rejected
6375
- // via --drop — durable across runs so detection re-deriving the
6376
- // identical match on a later merge doesn't re-queue what was already
6377
- // rejected. A genuinely different `to` for the same {id, from} is a new
6378
- // proposal and is unaffected.
6468
+ // Tombstones hold the exact {id, from, to} triples a human rejected, so a later
6469
+ // merge re-deriving the identical match does not re-queue it; a different `to`
6470
+ // is a new proposal.
6379
6471
  let dropped = readDroppedRepairs(root);
6380
6472
  const saveDropped = (next) => {
6381
- dropped = [...next];
6473
+ dropped = next;
6382
6474
  writeDroppedRepairs(root, dropped);
6383
6475
  };
6384
6476
  // Review commands act only on the queue that was presented. Validate
@@ -6392,63 +6484,27 @@ program
6392
6484
  if (opts.fromHook)
6393
6485
  return fail("the post-merge hook may only detect repairs, never approve them.");
6394
6486
  }
6395
- // The queue file itself must never carry a tombstoned entry, regardless
6396
- // of how it got there — the fresh-match filtering further down only
6397
- // covers the merge this run performs. A concurrently-racing writer (the
6398
- // post-merge hook's backgrounded detection can read the queue/dropped
6399
- // files independently of a human's --drop landing in between) could
6400
- // still leave a tombstoned entry sitting in the queue file; sweeping on
6401
- // every load makes "never contains a rejected triple" a property of the
6402
- // queue file itself, not of one write site. Same fragmentation lesson
6403
- // as the comment on `save` above. Every OTHER reader of the queue
6404
- // (escalations, SessionStart, the MCP tools) calls
6405
- // readActivePendingRepairs instead of reading the raw file, so a
6406
- // tombstoned entry can never surface as a question even before this
6407
- // sweep next runs.
6408
- //
6409
- // Deletes unconditionally, without the prune's visibility caveats below
6410
- // — safe here because the swept entry's exact triple is preserved in
6411
- // dropped-commit-repairs.json, so nothing about it is unrecoverable
6412
- // (unlike the prune, which could otherwise destroy the queue's only
6413
- // durable record of a match).
6414
- const activeInQueue = withoutDropped(queue, dropped);
6415
- const swept = queue.filter((r) => !activeInQueue.includes(r));
6416
- // Remembered for --only below, same reasoning as onlyWasPruned: targeting
6417
- // an id whose only queued entry was just swept as already-rejected is not
6418
- // the same usage error as targeting an id that never existed.
6419
- const onlyWasSwept = !!opts.only && swept.some((r) => r.id === opts.only);
6487
+ // Sweep: entries whose exact triple is tombstoned. Deleted unconditionally since
6488
+ // the triple survives in the tombstone file.
6489
+ const activeQueue = withoutDropped(queue, dropped);
6490
+ const activeSet = new Set(activeQueue);
6491
+ const swept = queue.filter((r) => !activeSet.has(r));
6492
+ const onlySwept = !!opts.only && swept.some((r) => r.id === opts.only);
6420
6493
  if (swept.length) {
6421
- save(activeInQueue);
6494
+ saveQueue(activeQueue);
6422
6495
  if (!opts.quiet)
6423
6496
  console.log(`Swept ${swept.length} already-rejected match${swept.length === 1 ? "" : "es"} from the queue: ${swept.map((r) => r.id).join(", ")}`);
6424
6497
  }
6425
- // Prune only PROVEN-dead entries (deadRewrites: the decision is present
6426
- // and demonstrably moved on/superseded/rejected) — never an id merely
6427
- // absent from `decisions` right now, which can just mean this run
6428
- // checked out a branch that predates the decision, or a private
6429
- // overlay isn't mounted. That's the reader's transient view, not proof
6430
- // the match is stale, and the queue file is the one durable record of
6431
- // it (see repairqueue.ts) — deleting on absence would destroy it
6432
- // unrecoverably. This keeps the queue from accumulating an
6433
- // actually-dead entry that outlives any number of --only runs
6434
- // targeting other ids, without risking a false prune. Deferred until
6435
- // after --range validation so a pure usage error changes nothing.
6436
- const deadEntries = deadRewrites(queue, decisions);
6437
- // Remembered for --only below: targeting an id that was pruned THIS
6438
- // run is not the same usage error as targeting an id that never
6439
- // existed — the human's target was real and is now resolved, not
6440
- // unrecognized.
6441
- const onlyWasPruned = !!opts.only && deadEntries.some((r) => r.id === opts.only);
6442
- if (deadEntries.length) {
6443
- // Object identity, not id — a corrupted queue file can carry a dead
6444
- // entry and a still-live one sharing an id (#53); deadRewrites
6445
- // already preserves identity via a plain filter, so pruning by id
6446
- // here would destroy the live sibling right alongside its dead
6447
- // namesake instead of leaving it for repairDecisionCommit to apply.
6448
- const deadSet = new Set(deadEntries);
6449
- save(queue.filter((r) => !deadSet.has(r)));
6498
+ // Prune: proven-dead entries only (never ones whose decision is merely absent this run).
6499
+ // Removed by object identity, not id: a corrupted queue may hold a dead and a live
6500
+ // entry sharing an id, and the live sibling must survive.
6501
+ const dead = deadRewrites(queue, decisions);
6502
+ const onlyPruned = !!opts.only && dead.some((r) => r.id === opts.only);
6503
+ if (dead.length) {
6504
+ const deadSet = new Set(dead);
6505
+ saveQueue(queue.filter((r) => !deadSet.has(r)));
6450
6506
  if (!opts.quiet)
6451
- console.log(`Pruned ${deadEntries.length} dead queue entr${deadEntries.length === 1 ? "y" : "ies"} (decision moved on, has no commit on record, or was superseded/rejected): ${deadEntries.map((r) => r.id).join(", ")}`);
6507
+ console.log(`Pruned ${dead.length} dead queue ${dead.length === 1 ? "entry" : "entries"} (decision moved on, has no commit on record, or was superseded/rejected): ${dead.map((r) => r.id).join(", ")}`);
6452
6508
  }
6453
6509
  const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
6454
6510
  // Orphaned means HEAD no longer reaches the commit either: a historical
@@ -6456,82 +6512,42 @@ program
6456
6512
  const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
6457
6513
  const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
6458
6514
  const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
6459
- // Detection always queues a fresh match — local-only, never committed, so
6460
- // it's safe to persist unconditionally. This is what makes running
6461
- // opportunistically (from the hook) worth anything: the match survives
6462
- // past this process exiting and past ORIG_HEAD getting overwritten by the
6463
- // next merge, so a human can confirm it later with `--apply` alone.
6464
- // A match whose exact {id, from, to} triple was already tombstoned by
6465
- // an earlier --drop is filtered out first — the human already rejected
6466
- // exactly this proposed replacement, and a later merge re-deriving it
6467
- // independently isn't a reason to ask again. A different `to` for the
6468
- // same still-orphaned commit is a new proposal and passes through.
6469
- const freshRewrites = withoutDropped(freshPlan.rewrites, dropped);
6470
- if (freshRewrites.length)
6471
- save(mergeRewrites(freshRewrites, queue));
6515
+ // Always persisted (local-only file): a hook-time detection survives for a later human --apply.
6516
+ const freshMatches = withoutDropped(freshPlan.rewrites, dropped);
6517
+ if (freshMatches.length)
6518
+ saveQueue(mergeRewrites(freshMatches, queue));
6472
6519
  if (opts.drop) {
6473
6520
  const before = queue.length;
6474
- // firstFor is the single first-match-by-id rule — the same one
6475
- // pickRewrite and mergeRewrites go through (#53, #56, #58) — to
6476
- // decide which same-id entry would actually apply. A corrupted
6477
- // queue file can carry two entries sharing an id, and dropping by
6478
- // id would destroy the untargeted sibling too, with no tombstone
6479
- // recording what it was. Filtering by object identity (not
6480
- // `r.id !== opts.drop`) removes only the one entry actually
6481
- // dropped; any other entry sharing the id is untouched and stays
6482
- // queued.
6483
6521
  const target = firstFor(queue, opts.drop);
6484
- save(queue.filter((r) => r !== target));
6485
- // Tombstone only when something real was actually queued for this id —
6486
- // an id that was never queued has no {from, to} to record, and
6487
- // "nothing queued" is a usage-mistake signal that shouldn't quietly
6488
- // create a tombstone file.
6522
+ saveQueue(queue.filter((r) => r !== target));
6489
6523
  if (target) {
6490
6524
  saveDropped(addDropped([{ id: target.id, from: target.from, to: target.to }], dropped));
6491
- // An identical {id, from, to} sibling (a corrupted queue file, a
6492
- // hand edit) is now tombstoned but still sitting in the queue this
6493
- // run just saved above — the load-time sweep near the top of this
6494
- // action already promises the queue file can never carry a
6495
- // tombstoned entry, so re-run it here. Without this, --apply later
6496
- // in this same invocation would happily write the exact rewrite
6497
- // the human just rejected (#53 follow-up).
6498
- save(withoutDropped(queue, dropped));
6525
+ // Re-sweep so an identical sibling cannot be applied later in this invocation.
6526
+ saveQueue(withoutDropped(queue, dropped));
6499
6527
  }
6500
6528
  if (!opts.quiet) {
6501
- console.log(queue.length === before
6502
- ? `Nothing queued or matched for "${opts.drop}" to drop.`
6503
- : `Dropped "${opts.drop}" from the queue — the same match won't resurface on its own; a genuinely different candidate for this decision still can.`);
6529
+ if (queue.length === before && !target)
6530
+ console.log(`Nothing queued or matched for "${opts.drop}" to drop.`);
6531
+ else
6532
+ console.log(`Dropped "${opts.drop}" from the queue — the same match won't resurface on its own; a genuinely different candidate for this decision still can.`);
6504
6533
  }
6505
6534
  if (!opts.apply)
6506
6535
  return;
6507
6536
  }
6508
- // Deliberately checked before --only ever gets a chance to consult
6509
- // onlyWasPruned/onlyWasSwept below: when the prune or the sweep just
6510
- // emptied the queue entirely, --only's own explanatory messages never
6511
- // get a chance to fire — this generic message covers that case too,
6512
- // and still exits 0 either way, so no usage-error bug survives here.
6537
+ // Deliberately before the --only messages.
6513
6538
  if (!queue.length) {
6514
6539
  if (!opts.quiet)
6515
6540
  console.log("✓ Nothing to repair — no orphaned commit reference matched unambiguously, and nothing queued from an earlier run.");
6516
6541
  return;
6517
6542
  }
6518
- // --only restricts the apply (or dry-run preview) to one decision id,
6519
- // leaving every other queued/matched candidate exactly as it was — the
6520
- // escalation asks a per-decision question, so the answer surface should
6521
- // let a human accept one without also accepting everything else queued.
6522
6543
  const toApply = opts.only ? queue.filter((r) => r.id === opts.only) : queue;
6523
6544
  if (opts.only && !toApply.length) {
6524
- if (onlyWasSwept) {
6525
- // Not a usage error either: the human already rejected exactly this
6526
- // match via an earlier --drop, and this run's sweep just confirmed
6527
- // the rejection still holds — nothing left to apply.
6545
+ if (onlySwept) {
6528
6546
  if (!opts.quiet)
6529
6547
  console.log(`"${opts.only}" was already rejected via --drop — nothing left to do.`);
6530
6548
  return;
6531
6549
  }
6532
- if (onlyWasPruned) {
6533
- // Not a usage error: the human's target was real, and this run
6534
- // already resolved it above (see the prune's own message for why).
6550
+ if (onlyPruned) {
6535
6551
  if (!opts.quiet)
6536
6552
  console.log(`"${opts.only}" was pruned earlier in this run — nothing left to do.`);
6537
6553
  return;
@@ -6540,79 +6556,32 @@ program
6540
6556
  fail(`no queued or matched entry for "${opts.only}"`);
6541
6557
  return;
6542
6558
  }
6543
- // Whether a listed entry's decision is visible at all this run — a row
6544
- // that isn't can't actually be resolved by --apply (see
6545
- // resolvedRewriteIds below), so dry-run/apply output must say so
6546
- // instead of listing it as though --apply would act on it. Same
6547
- // reasoning applies to a `to` that doesn't resolve to a real commit
6548
- // here (issue #48): never write an unverified `to` into a decision's
6549
- // commit field, and never claim --apply would either. Computed before
6550
- // the dry-run branch below so the preview and the real run agree on
6551
- // what --apply can actually do. `null` from commitsExist means the
6552
- // check itself failed to run (not a git repo, git missing, timeout)
6553
- // and is treated as fail-open — same discipline as drift.ts's own use
6554
- // of commitsExist, though the asymmetry is worth naming: drift.ts only
6555
- // decides what to REPORT on a `null`, while this gates a WRITE that
6556
- // gets auto-committed into shared team memory. Fail-open is still the
6557
- // right call here: `isGitRepo(root)` already passed above, so `null`
6558
- // in practice means the batched `cat-file` call itself timed out —
6559
- // rare, and a corrupted `to` that slips through is fully recoverable
6560
- // (the entry survives durably in the queue either way; a bad write
6561
- // is a revert). Fail-closed's cost — treating every entry as withheld
6562
- // on a transient git error, indefinitely, on every run until the
6563
- // error clears — is the worse failure mode to default to.
6559
+ // Apply-ability is computed before the dry-run branch so preview and apply agree.
6564
6560
  const visibleIds = new Set(decisions.map((d) => d.id));
6565
- const existingTargets = commitsExist(toApply.map((r) => r.to), root);
6566
- const { applicable, withheld } = withheldForUnresolvableTo(toApply, existingTargets);
6561
+ // A null existence result is fail-open (treated as existing): isGitRepo already
6562
+ // passed, so null is a rare timeout, and a bad write is recoverable whereas
6563
+ // failing closed would withhold every entry indefinitely.
6564
+ const { applicable, withheld } = withheldForUnresolvableTo(toApply, commitsExist(toApply.map((r) => r.to), root));
6565
+ const applicableSet = new Set(applicable);
6567
6566
  const withheldSet = new Set(withheld);
6568
- // The plan --apply would act on if it ran right now — built here, before
6569
- // the dry-run branch, so the preview reasons about the exact same plan
6570
- // repairDecisionCommit would. `pickRewrite` (src/core/commitrepair.ts, a
6571
- // plan-scoped `firstFor`) decides which entry wins when `applicable`
6572
- // carries two sharing a decision id (a corrupted queue file, a hand
6573
- // edit): first match, by construction. `wasChosen` asks that same
6574
- // question by object identity rather than re-deriving it, so this file
6575
- // and commitrepair.ts can never independently drift on which entry
6576
- // "won" (#51, #58).
6577
6567
  const plan = { rewrites: applicable, records: [...new Set(applicable.map((r) => r.id))] };
6578
- const applicableSet = new Set(applicable);
6579
- const wasChosen = (r) => pickRewrite(plan, r.id) === r;
6580
- // Whether an entry will still be sitting in the queue after this run,
6581
- // and why — computed ONCE and shared by the dry-run preview, the queue
6582
- // sweep, and the post-apply "left queued" report, so the three can
6583
- // never independently drift out of sync about the same entry (they
6584
- // did, twice, across two review rounds, before this was unified). The
6585
- // sweep walks the FULL `queue`, not just `toApply` — under --only, an
6586
- // untargeted id's entries never entered `applicable` at all, so
6587
- // `wasChosen` would read "not chosen" for them too. Gating "duplicate"
6588
- // on `applicableSet.has(r)` keeps the label meaning what it says: a
6589
- // SIBLING actually competed for this exact id and lost, not merely
6590
- // "this id wasn't this run's `applicable` set for some other reason."
6591
- //
6592
- // Precedence, most to least eclipsing: invisible, then withheld, then
6593
- // duplicate. Invisible and withheld can each resolve on their own (a
6594
- // later run sees the decision, or its `to` starts existing) or never
6595
- // will (withheld, permanently, unless dropped) — "duplicate" is neither:
6596
- // it means a SIBLING entry for the same id already won and got applied,
6597
- // which repairDecisionCommit's own from-mismatch bail already makes true
6598
- // by the time anything downstream would check it, so it's only worth
6599
- // naming for entries that are otherwise resolvable.
6600
- const reasonQueued = (r) => !visibleIds.has(r.id) ? "invisible"
6568
+ const isChosen = (r) => pickRewrite(plan, r.id) === r;
6569
+ const stayReason = (r) => !visibleIds.has(r.id) ? "invisible"
6601
6570
  : withheldSet.has(r) ? "withheld"
6602
- : applicableSet.has(r) && !wasChosen(r) ? "duplicate"
6571
+ : applicableSet.has(r) && !isChosen(r) ? "duplicate"
6603
6572
  : null;
6604
- const staysQueued = (r) => reasonQueued(r) !== null;
6573
+ const staysQueued = (r) => stayReason(r) !== null;
6605
6574
  if (!opts.apply) {
6606
6575
  if (!opts.quiet) {
6607
- const unresolvedCount = toApply.filter(staysQueued).length;
6608
- console.log(unresolvedCount
6609
- ? `Would repair ${toApply.length - unresolvedCount} of ${toApply.length} listed:`
6576
+ const stayingCount = toApply.filter(staysQueued).length;
6577
+ console.log(stayingCount > 0
6578
+ ? `Would repair ${toApply.length - stayingCount} of ${toApply.length} listed:`
6610
6579
  : `Would repair ${toApply.length} commit reference(s):`);
6611
6580
  // Match strength, so a reviewer can tell an identical change from a
6612
6581
  // file-overlap guess before applying it into shared memory (#450).
6613
6582
  const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
6614
6583
  for (const r of toApply) {
6615
- const reason = reasonQueued(r);
6584
+ const reason = stayReason(r);
6616
6585
  const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
6617
6586
  const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
6618
6587
  : reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
@@ -6630,7 +6599,7 @@ program
6630
6599
  }
6631
6600
  return;
6632
6601
  }
6633
- const touchedHomes = new Set();
6602
+ const homes = new Set();
6634
6603
  const appliedIds = new Set();
6635
6604
  for (const d of decisions) {
6636
6605
  const healed = repairDecisionCommit(d, plan);
@@ -6638,47 +6607,25 @@ program
6638
6607
  continue;
6639
6608
  const home = decisionMemoryHome(store, d.id);
6640
6609
  store.putWhereItLives("decisions", healed);
6641
- touchedHomes.add(home);
6610
+ homes.add(home);
6642
6611
  appliedIds.add(d.id);
6643
6612
  }
6644
- // Only a candidate whose decision was actually VISIBLE this run AND
6645
- // whose `to` resolved in this repository AND wasn't beaten out by a
6646
- // same-id sibling is resolved (applied, or found stale by
6647
- // repairDecisionCommit's own from-mismatch bail) — resolvedRewriteIds
6648
- // shares deadRewrites' own reasoning: an id we could not see this run,
6649
- // or whose replacement doesn't exist, is not proof of anything, so it
6650
- // stays queued rather than being swept up just because it was in
6651
- // `toApply`. This also covers --only: toApply is just the one targeted
6652
- // id, so an invisible, unresolvable, or beaten-out target is left
6653
- // queued instead of deleted unresolved.
6654
- //
6655
- // `staysQueued` (OBJECT identity for both the withheld half, via
6656
- // `withheldSet.has(r)`, and the duplicate half, via `wasChosen`) is
6657
- // OR'd in ahead of the id-keyed `!resolvedIds.has(r.id)`: a corrupted
6658
- // queue file could carry two entries sharing an id — one resolvable,
6659
- // one not, or both resolvable — and an id-keyed check alone would treat
6660
- // the pair as one unit, deleting/misreporting whichever entry didn't
6661
- // actually get applied (#48, #51).
6662
- const resolvedIds = resolvedRewriteIds(applicable, decisions);
6663
- save(queue.filter((r) => staysQueued(r) || !resolvedIds.has(r.id)));
6613
+ // Invisible / unresolvable / beaten-out entries are never deleted just because they
6614
+ // were targeted. Identity-keyed checks come first so same-id pairs are handled per entry.
6615
+ const resolved = resolvedRewriteIds(applicable, decisions);
6616
+ const keep = (r) => staysQueued(r) || !resolved.has(r.id);
6617
+ saveQueue(queue.filter(keep));
6664
6618
  if (!appliedIds.size) {
6665
- // deadRewrites already pruned any VISIBLE decision that moved on or
6666
- // was superseded/rejected, so a targeted, resolvable id that IS
6667
- // visible always reaches repairDecisionCommit with a matching
6668
- // `from` and is always healed into a new object (appliedIds.add
6669
- // fires unconditionally for every visible match) — reaching here
6670
- // means every targeted candidate stayed queued (invisible or
6671
- // withheld, reported independently so neither reason disappears).
6672
- const invisibleIds = toApply.filter((r) => reasonQueued(r) === "invisible").map((r) => r.id);
6673
- const withheldIds = toApply.filter((r) => reasonQueued(r) === "withheld").map((r) => r.id);
6619
+ // Counted per entry (not per distinct id), the same way the post-apply
6620
+ // "left queued" summary below counts, so two same-id entries read as two.
6621
+ const invisibleIds = toApply.filter((r) => stayReason(r) === "invisible").map((r) => r.id);
6622
+ const withheldIds = toApply.filter((r) => stayReason(r) === "withheld").map((r) => r.id);
6674
6623
  if (!opts.quiet) {
6675
6624
  if (invisibleIds.length) {
6676
6625
  console.log(`Nothing applied — none of the targeted decision(s) are visible this run (branch checkout, or a private overlay not mounted?): ${invisibleIds.join(", ")}. Left queued for a run where they are — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
6677
6626
  }
6678
6627
  if (withheldIds.length) {
6679
- const subject = withheldIds.length === 1
6680
- ? "an entry whose proposed replacement commit doesn't"
6681
- : "entries whose proposed replacement commits don't";
6628
+ const subject = withheldIds.length === 1 ? "an entry whose proposed replacement commit doesn't" : "entries whose proposed replacement commits don't";
6682
6629
  console.log(`${invisibleIds.length ? "Also nothing" : "Nothing"} applied for ${subject} resolve in this repository: ${withheldIds.join(", ")}. Left queued — this won't self-heal on its own; \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
6683
6630
  }
6684
6631
  }
@@ -6686,37 +6633,28 @@ program
6686
6633
  }
6687
6634
  store.reindex();
6688
6635
  const rangeLabel = rangeResolves ? `${oldRef}..${newRef}` : "queued";
6689
- pumpMemoryHomes(store, root, touchedHomes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
6636
+ pumpMemoryHomes(store, root, homes, `hunch: repair ${appliedIds.size} commit reference(s) after squash-merge (${rangeLabel})`);
6690
6637
  if (!opts.quiet) {
6691
6638
  console.log(`✓ Repaired ${appliedIds.size} commit reference(s):`);
6692
- // `wasChosen` restricts this to the one entry per id that
6693
- // repairDecisionCommit actually wrote — `appliedIds` alone is keyed by
6694
- // decision id, so without it a same-id sibling that never got written
6695
- // would print here too (#51).
6696
- for (const r of applicable)
6697
- if (appliedIds.has(r.id) && wasChosen(r))
6639
+ for (const r of applicable) {
6640
+ // A losing same-id sibling must not be listed.
6641
+ if (appliedIds.has(r.id) && isChosen(r))
6698
6642
  console.log(` ${r.id} ${r.from} → ${r.to}`);
6699
- const stillQueued = toApply.filter((r) => staysQueued(r) || !resolvedIds.has(r.id));
6700
- const stillInvisible = stillQueued.filter((r) => reasonQueued(r) === "invisible");
6701
- const stillWithheld = stillQueued.filter((r) => reasonQueued(r) === "withheld");
6702
- const stillDuplicate = stillQueued.filter((r) => reasonQueued(r) === "duplicate");
6703
- if (stillInvisible.length) {
6704
- console.log(`\n${stillInvisible.length} entr${stillInvisible.length === 1 ? "y" : "ies"} not visible this run, left queued: ${stillInvisible.map((r) => r.id).join(", ")} — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
6705
6643
  }
6706
- if (stillWithheld.length) {
6707
- console.log(`\n${stillWithheld.length} entr${stillWithheld.length === 1 ? "y" : "ies"} left queued — the proposed replacement commit doesn't resolve in this repository: ${stillWithheld.map((r) => r.id).join(", ")} — \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
6644
+ const left = toApply.filter(keep);
6645
+ const plural = (n) => (n === 1 ? "entry" : "entries");
6646
+ const invisibleLeft = left.filter((r) => stayReason(r) === "invisible");
6647
+ const withheldLeft = left.filter((r) => stayReason(r) === "withheld");
6648
+ const duplicateLeft = left.filter((r) => stayReason(r) === "duplicate");
6649
+ if (invisibleLeft.length) {
6650
+ console.log(`\n${invisibleLeft.length} ${plural(invisibleLeft.length)} not visible this run, left queued: ${invisibleLeft.map((r) => r.id).join(", ")} — or \`hunch repair-provenance --drop <dec_id>\` if a decision is gone for good.`);
6651
+ }
6652
+ if (withheldLeft.length) {
6653
+ console.log(`\n${withheldLeft.length} ${plural(withheldLeft.length)} left queued — the proposed replacement commit doesn't resolve in this repository: ${withheldLeft.map((r) => r.id).join(", ")} — \`hunch repair-provenance --drop <dec_id>\` to reject it.`);
6708
6654
  }
6709
- if (stillDuplicate.length) {
6710
- // Every entry here shares its id with the sibling that won (that's
6711
- // what "duplicate" means) — listing ids would just repeat one
6712
- // value, so name the `to` each one proposed instead. No
6713
- // `--drop <dec_id>` pointer, unlike the other two buckets: `--drop`
6714
- // targets the first queued match for an id (the same one
6715
- // pickRewrite would apply), which by now is the winning sibling
6716
- // that's already gone from the queue, not the losing one named
6717
- // here — pointing at `--drop <dec_id>` would read as an offer to
6718
- // reject this exact entry when it can't (#53).
6719
- console.log(`\n${stillDuplicate.length} entr${stillDuplicate.length === 1 ? "y" : "ies"} left queued — a sibling entry for the same decision was applied instead (only the first queued match per decision is ever applied): ${stillDuplicate.map((r) => `${r.id} (${r.to})`).join(", ")}`);
6655
+ if (duplicateLeft.length) {
6656
+ // No --drop pointer: drop targets the first match, which is the winner already gone.
6657
+ console.log(`\n${duplicateLeft.length} ${plural(duplicateLeft.length)} left queued — a sibling entry for the same decision was applied instead (only the first queued match per decision is ever applied): ${duplicateLeft.map((r) => `${r.id} (${r.to})`).join(", ")}`);
6720
6658
  }
6721
6659
  }
6722
6660
  }
@@ -7256,13 +7194,13 @@ program
7256
7194
  console.log(`· ${f.id} — ${f.detail}`);
7257
7195
  console.log(`\nHeal: this is a HUMAN call — the decision's authority is unchanged until you make it. Re-attest (update the premise's review_by/attested), supersede via /capture, or retire the decision. Keeping it for consistency is a valid answer.\n`);
7258
7196
  }
7259
- // Every drift kind heals here — see bug_drift_heal_asymmetry above.
7197
+ // Every drift kind must heal here.
7260
7198
  const commitUnresolvable = kind("commit-unresolvable");
7261
7199
  if (commitUnresolvable.length) {
7262
7200
  console.log(`${commitUnresolvable.length} decision(s) cite a commit that no longer resolves in this repository:\n`);
7263
7201
  for (const f of commitUnresolvable)
7264
7202
  console.log(`· ${f.id} — ${f.detail}`);
7265
- console.log(`\nHeal: this is a HUMAN call — check \`hunch repair-provenance\` for a queued match (the post-merge hook detects these automatically while the commit is still resolvable, but never applies one unattended); run \`hunch repair-provenance --apply\` to confirm it, or if the commit is already gone, manually correct the decision's provenance or leave it as historical record.\n`);
7203
+ console.log(`\nHeal: this is a HUMAN call. Check \`hunch repair-provenance\` for a queued match (the post-merge hook detects them while the commit is still resolvable, but never applies one unattended), then run \`hunch repair-provenance --apply\` to confirm it; if the commit is already gone, correct the provenance by hand or keep it as historical record.\n`);
7266
7204
  }
7267
7205
  const fenceUnclosed = kind("doc-fence-unclosed");
7268
7206
  if (fenceUnclosed.length) {
@@ -7373,20 +7311,21 @@ program
7373
7311
  process.exitCode = 1; // conflict markers remain → block the commit for review
7374
7312
  }
7375
7313
  });
7376
- // ---- merge-driver-grounding (internal; git invokes this) ------------------
7314
+ // ---- merge-driver-grounding (internal) -----------------------------------
7377
7315
  program
7378
7316
  .command("merge-driver-grounding")
7379
- .description("(internal) git merge driver for the generated grounding docs — auto-resolves a hard conflict confined to the record-counts sentence.")
7380
- .argument("<base>", "%O — common ancestor")
7381
- .argument("<ours>", "%A — current branch (also the OUTPUT file)")
7382
- .argument("<theirs>", "%B — other branch")
7383
- .argument("[path]", "%P — pathname being merged")
7317
+ .description("(internal) git's merge driver for the generated grounding docs: auto-resolves a hard conflict confined to the record-counts sentence.")
7318
+ .argument("<base>", "common ancestor (%O)")
7319
+ .argument("<ours>", "current branch version, and the output file (%A)")
7320
+ .argument("<theirs>", "other branch version (%B)")
7321
+ .argument("[path]", "pathname (%P)")
7384
7322
  .action((base, ours, theirs) => {
7385
- const res = mergeGroundingFile(base, ours, theirs);
7386
- if (res.write !== null)
7387
- writeFileSync(ours, res.write);
7388
- if (res.conflict)
7389
- process.exitCode = 1; // real conflict, or git itself errored → leave for a human
7323
+ const result = mergeGroundingFile(base, ours, theirs);
7324
+ if (result.write !== null)
7325
+ writeFileSync(ours, result.write);
7326
+ // A real conflict or a git error: leave it for a human.
7327
+ if (result.conflict)
7328
+ process.exitCode = 1;
7390
7329
  });
7391
7330
  // ---- doctor ---------------------------------------------------------------
7392
7331
  program
@@ -7429,11 +7368,9 @@ program
7429
7368
  const { store, root } = storeFor();
7430
7369
  console.log(`Hunch root: ${root}`);
7431
7370
  console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
7432
- // post-commit/post-merge are the two hooks every setup path installs; a
7433
- // repo that ran `hunch init` before the post-merge hook existed never
7434
- // received it, and there was no way to discover that short of noticing a
7435
- // squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
7436
- // so its absence is informational only, never a warning.
7371
+ // post-commit and post-merge are the two hooks every setup path installs; a repo
7372
+ // initialized before post-merge existed never got it and had no way to notice.
7373
+ // pre-commit is opt-out (--no-enforce), so its absence is informational only.
7437
7374
  if (isGitRepo(root)) {
7438
7375
  const report = hookReport(root);
7439
7376
  const hooks = hookStatus(root);
@@ -7515,7 +7452,7 @@ program
7515
7452
  for (const line of synthesisStatusLines(resolution, process.env))
7516
7453
  console.log(line);
7517
7454
  const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
7518
- if (ctxWarning)
7455
+ if (ctxWarning !== null)
7519
7456
  console.log(ctxWarning);
7520
7457
  // The native addons load on first PARSE, not at import, so no other command
7521
7458
  // that merely starts up proves the parser works. Doctor is the one place
@@ -7610,26 +7547,27 @@ function rel(root, p) {
7610
7547
  }
7611
7548
  /** Run the Windows ~/.claude.json drive-letter case-split heal and print what it
7612
7549
  * did. Silent + no-op off Windows. A parse refusal is surfaced as a warning, never
7613
- * thrown out of doctor/init (those commands must still complete). */
7614
- function reportClaudeConfigHeal() {
7550
+ * thrown out of doctor/init (those commands must still complete). `say` lets `init`
7551
+ * route the lines into its buffered output (shown with --details; warnings always). */
7552
+ function reportClaudeConfigHeal(say = (text) => console.log(text)) {
7615
7553
  let res;
7616
7554
  try {
7617
7555
  res = healClaudeConfigCaseSplit();
7618
7556
  }
7619
7557
  catch (e) {
7620
- console.log(` ⚠ Claude config: ${e.message}`);
7558
+ say(` ⚠ Claude config: ${e.message}`);
7621
7559
  return;
7622
7560
  }
7623
7561
  if (!res.applicable)
7624
7562
  return; // non-Windows: the case-split bug can't occur
7625
7563
  if (!res.changed) {
7626
- console.log(dim(`Claude config: no drive-letter project split (${res.file})`));
7564
+ say(dim(`Claude config: no drive-letter project split (${res.file})`));
7627
7565
  return;
7628
7566
  }
7629
7567
  for (const g of res.groups) {
7630
- console.log(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
7568
+ say(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
7631
7569
  }
7632
- console.log(dim(` ↳ backup: ${res.backup}`));
7570
+ say(dim(` ↳ backup: ${res.backup}`));
7633
7571
  }
7634
7572
  function fail(msg) {
7635
7573
  console.error(`error: ${msg}`);
@@ -7650,17 +7588,16 @@ function readStdin() {
7650
7588
  });
7651
7589
  }
7652
7590
  /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
7653
- * forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
7654
- * inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
7655
- * which realpath-normalizes both ends first — see its docstring for why, incl.
7656
- * dec_e0a36efbf5): that function passes an unresolvable absolute path through
7657
- * UNCHANGED (still absolute) rather than signaling failure directly, since other
7658
- * callers want the original target back to fail their own match safely. This
7659
- * adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
7660
- * and `editTargets` below already treat as "skip, not in this repo". */
7591
+ * forward-slash globs even on Windows). Returns "" when the path isn't resolvably
7592
+ * inside `root`. A thin adapter over the shared `repoRelativeTarget` in core/paths.ts,
7593
+ * which realpath-normalizes both ends but passes an unresolvable absolute path through
7594
+ * unchanged; this converts that "still absolute" result into "", the sentinel that
7595
+ * `onEdit` and `editTargets` treat as "skip, not in this repo". */
7661
7596
  function toRepoRel(root, abs) {
7662
7597
  const rel = repoRelativeTarget(abs, root);
7663
- return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
7598
+ if (isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
7599
+ return "";
7600
+ return rel;
7664
7601
  }
7665
7602
  /** Files a shell command wrote get the grounding the edit tools would have
7666
7603
  * delivered before the edit — late, but while the agent can still revise.