@davesheffer/hunch 1.46.0 → 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 (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -746,12 +746,11 @@ program
746
746
  // never rewrites an existing one: `index` runs in CI/release gates on a clean
747
747
  // checkout. Older blocks are upgraded by `hunch update` / init / private setup.
748
748
  ensureGitignore(root, { upgradeExisting: false });
749
- // The post-merge hook only ever got installed by `hunch init`/`hunch
750
- // private`/`hunch shared` — a repo that already ran init before this hook
751
- // existed never receives it. `hunch index` already self-heals gitignore
752
- // the same way; do the same for the hook so an upgrade doesn't require
753
- // re-running init by hand. Gated on already having post-commit: `index`
754
- // 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
755
754
  // never be what FIRST hooks a repo that never ran init at all. A STALE
756
755
  // post-commit block counts too (issue #315): it proves init ran, so the
757
756
  // missing post-merge/post-checkout hooks are still this repo's to heal.
@@ -825,9 +824,9 @@ program
825
824
  const commits = logSince(opts.since, root, Number(opts.max));
826
825
  const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
827
826
  console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
828
- // Best-effort context-window advisory (issue #11): printed ONCE, before any
829
- // commit is drafted — not per-commit, and not under --deep (an ensemble may
830
- // 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.
831
830
  if (!opts.deep && commits.length > 0) {
832
831
  // { root }: the advisory must resolve the SAME provider the synthesis
833
832
  // inside syncCommit resolves (persisted local.json preference included) —
@@ -835,7 +834,7 @@ program
835
834
  // num_ctx warning for an actual Ollama run (issue #46).
836
835
  const ctxProvider = await selectProvider({ root });
837
836
  const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
838
- if (ctxWarning)
837
+ if (ctxWarning !== null)
839
838
  console.log(ctxWarning);
840
839
  }
841
840
  let written = 0, skipped = 0, llm = 0, heuristic = 0;
@@ -1254,9 +1253,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1254
1253
  const backLink = commonDir ? separateGitDirLink(commonDir) : "";
1255
1254
  const configuredHooks = includeHook ? hooksDir(root) : "";
1256
1255
  const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
1257
- // Setup installs post-commit AND post-merge together, so both must be in the
1258
- // ledger — restoring one while leaving the other pointing at a just-deleted
1259
- // 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.
1260
1258
  const hookFiles = hookDir ? [join(hookDir, "post-commit"), join(hookDir, "post-merge")] : [];
1261
1259
  const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer, backLink] : []), ...hookFiles];
1262
1260
  const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
@@ -1278,7 +1276,8 @@ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
1278
1276
  markGitignoreWrite: () => mark(codeGitignore),
1279
1277
  markTeamWrite: () => mark(teamFile),
1280
1278
  markSharedPointerWrite: () => { mark(backLink); mark(sharedPointer); },
1281
- markHookWrite: () => hookFiles.forEach(mark),
1279
+ markHookWrite: () => { for (const f of hookFiles)
1280
+ mark(f); },
1282
1281
  // Migration is a one-way ownership handoff. Once public records have been
1283
1282
  // durably copied into this clone, a later setup failure may restore routing
1284
1283
  // files but must not delete the clone that now holds their surviving copy.
@@ -4910,20 +4909,21 @@ program
4910
4909
  .requiredOption("--by <new>", "decision id that supersedes it")
4911
4910
  .action((oldId, opts) => {
4912
4911
  const { store, root } = storeFor();
4913
- // `old`'s home decides which store the close is written to; `--by` must resolve in
4914
- // that SAME store, or a private `by` linked against a public `old` would write the
4915
- // private id straight into the committed public store.
4912
+ // The old decision's own memory home decides which store both ids must live in.
4916
4913
  const home = decisionMemoryHome(store, oldId);
4917
- 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)) {
4918
4917
  store.close();
4919
4918
  return fail(`decision "${oldId}" not found`);
4920
4919
  }
4921
- 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);
4922
4922
  if (!by) {
4923
4923
  store.close();
4924
4924
  return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
4925
4925
  }
4926
- const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4926
+ const closed = priv ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4927
4927
  if (!closed) {
4928
4928
  store.close();
4929
4929
  return fail(`decision "${oldId}" cannot supersede itself`);
@@ -5006,7 +5006,8 @@ program
5006
5006
  .action(async (value) => {
5007
5007
  const root = findRoot();
5008
5008
  if (value != null) {
5009
- const preference = normalizeProviderName(value.trim()) ?? value.trim();
5009
+ const trimmed = value.trim();
5010
+ const preference = normalizeProviderName(trimmed) || trimmed;
5010
5011
  if (!SYNTH_PREFERENCES.includes(preference)) {
5011
5012
  return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
5012
5013
  }
@@ -5019,7 +5020,7 @@ program
5019
5020
  console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
5020
5021
  }
5021
5022
  const resolution = await resolveSynthesisProvider({ root });
5022
- const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER?.trim());
5023
+ const envValue = normalizeProviderName((process.env.HUNCH_SYNTH_PROVIDER ?? "").trim()) || undefined;
5023
5024
  const local = readSynthesisPreference(root);
5024
5025
  const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
5025
5026
  console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
@@ -5536,8 +5537,8 @@ program
5536
5537
  // so its orientation event keeps asking every actionable entry.
5537
5538
  const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
5538
5539
  if (!decisions.length && !actionableEsc.length) {
5539
- // Fresh graph and nothing else to raise: nothing to orient on, but
5540
- // 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
5541
5542
  // (checked against the full store) is enough reason NOT to bail here
5542
5543
  // even when the visible decisions list is empty.
5543
5544
  if (pipelineEnabled())
@@ -6237,7 +6238,7 @@ program
6237
6238
  // ---- escalations (the inline "ask the human" surface) ---------------------
6238
6239
  program
6239
6240
  .command("escalations")
6240
- .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.")
6241
6242
  .option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
6242
6243
  .action(async (opts) => {
6243
6244
  const { store, root } = storeFor();
@@ -6247,8 +6248,8 @@ program
6247
6248
  // Premise decay rides the same inline surface: a decision whose recorded
6248
6249
  // reason died is a QUESTION for the human — authority never changes here.
6249
6250
  items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
6250
- const escalationsQueue = readActivePendingRepairs(root);
6251
- items.push(...commitRepairEscalations(escalationsQueue, decisionsForEsc, decisionsForEsc, withheldRewrites(root, escalationsQueue)));
6251
+ const queuedRepairs = readActivePendingRepairs(root);
6252
+ items.push(...commitRepairEscalations(queuedRepairs, decisionsForEsc, decisionsForEsc, withheldRewrites(root, queuedRepairs)));
6252
6253
  // Constitution moments ride the same inline surface (§59.5.3) — never a queue.
6253
6254
  // Fail open: a broken policy store must not take the memory escalations down.
6254
6255
  try {
@@ -6256,10 +6257,10 @@ program
6256
6257
  items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
6257
6258
  }
6258
6259
  catch { /* constitution unavailable — memory escalations still surface */ }
6259
- // Gate (exit code + tallies) on the ACTIONABLE subset only — a duplicate-id
6260
- // commit-repair follower whose own resolution says "act on a different
6261
- // entry first" still surfaces below for transparency, but must not count
6262
- // 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.
6263
6264
  const actionable = actionableEscalations(items);
6264
6265
  if (opts.json) {
6265
6266
  console.log(JSON.stringify(items));
@@ -6271,18 +6272,12 @@ program
6271
6272
  console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
6272
6273
  return;
6273
6274
  }
6274
- // escalationHeadline's own "nothing actionable, N shown for context"
6275
- // fallback is currently unreachable here: every escalation-producing
6276
- // function today guarantees at least one actionable entry whenever it
6277
- // emits anything at all — see commitRepairEscalations' own docstring on
6278
- // `firstFor`/dropTarget. Kept anyway (not assumed away) because that
6279
- // guarantee lives in the PRODUCERS, not in `Escalation.actionable`'s own
6280
- // contract, which a future producer could legitimately violate.
6281
- 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`);
6282
6279
  for (const e of items) {
6283
- // A non-actionable row (a duplicate-id follower) still surfaces for
6284
- // transparency, but must not read like its own question — marked
6285
- // 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.
6286
6281
  console.log(` ${e.actionable === false ? "·" : "⚖"} ${e.question}`);
6287
6282
  console.log(` ${dim(e.detail)}`);
6288
6283
  console.log(` ${dim("→ " + e.resolution)}\n`);
@@ -6425,17 +6420,17 @@ program
6425
6420
  store.close();
6426
6421
  }
6427
6422
  });
6428
- // ---- repair-provenance (squash-merge commit provenance repair) ------------
6423
+ // ---- repair-provenance (squash-merge commit-provenance self-repair) -------
6429
6424
  program
6430
6425
  .command("repair-provenance")
6431
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.")
6432
6427
  .option("--apply", "apply the reviewed queued candidates (requires --expect; does not scan for new matches)")
6433
6428
  .option("--expect <hash>", "exact queue review hash printed by the preview or escalation; required for --apply/--drop")
6434
- .option("--only <dec_id>", "with --apply, rewrite only this decision id — everything else stays queued untouched")
6435
- .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)")
6436
6431
  .option("--from-hook", "invoked by the git post-merge hook")
6437
6432
  .option("--quiet", "minimal output")
6438
- .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)")
6439
6434
  .action((opts) => {
6440
6435
  const { store, root } = storeFor();
6441
6436
  try {
@@ -6450,41 +6445,32 @@ program
6450
6445
  const parts = opts.range.split("..");
6451
6446
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
6452
6447
  if (!opts.fromHook)
6453
- fail('--range must look like "old..new"');
6448
+ fail(`--range must look like "old..new"`);
6454
6449
  return;
6455
6450
  }
6456
- [oldRef, newRef] = parts;
6451
+ oldRef = parts[0];
6452
+ newRef = parts[1];
6457
6453
  }
6458
6454
  const rangeResolves = revExists(oldRef, root) && revExists(newRef, root);
6459
6455
  if (!rangeResolves && opts.range) {
6460
- // An explicitly-given range that doesn't resolve is a usage mistake, not
6461
- // "no merge happened yet" — surface it. (The installed hook never passes
6462
- // --range, so --from-hook never reaches this branch in practice.)
6463
6456
  if (!opts.fromHook)
6464
6457
  fail(`range "${oldRef}..${newRef}" does not resolve in this repository`);
6465
6458
  return;
6466
6459
  }
6460
+ // An unresolvable DEFAULT range just means no merge happened: detection is skipped below.
6467
6461
  const decisions = store.recs("decisions");
6468
- // One save path for every queue mutation below — a future change to
6469
- // queue semantics has exactly one write call to touch, not several
6470
- // independently-reasoned ones. This file has twice shipped the same
6471
- // failure class from that fragmentation: one write site's "is this
6472
- // entry resolved" logic gets fixed while a sibling write site quietly
6473
- // keeps reasoning about the queue differently, deleting an entry the
6474
- // fixed site would have left alone.
6462
+ // One save path per file, so queue semantics cannot drift across write sites.
6475
6463
  let queue = readPendingRepairs(root);
6476
- const save = (next) => {
6477
- queue = [...next];
6464
+ const saveQueue = (next) => {
6465
+ queue = next;
6478
6466
  writePendingRepairs(root, queue);
6479
6467
  };
6480
- // Tombstones for exact {id, from, to} triples a human already rejected
6481
- // via --drop — durable across runs so detection re-deriving the
6482
- // identical match on a later merge doesn't re-queue what was already
6483
- // rejected. A genuinely different `to` for the same {id, from} is a new
6484
- // 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.
6485
6471
  let dropped = readDroppedRepairs(root);
6486
6472
  const saveDropped = (next) => {
6487
- dropped = [...next];
6473
+ dropped = next;
6488
6474
  writeDroppedRepairs(root, dropped);
6489
6475
  };
6490
6476
  // Review commands act only on the queue that was presented. Validate
@@ -6498,63 +6484,27 @@ program
6498
6484
  if (opts.fromHook)
6499
6485
  return fail("the post-merge hook may only detect repairs, never approve them.");
6500
6486
  }
6501
- // The queue file itself must never carry a tombstoned entry, regardless
6502
- // of how it got there — the fresh-match filtering further down only
6503
- // covers the merge this run performs. A concurrently-racing writer (the
6504
- // post-merge hook's backgrounded detection can read the queue/dropped
6505
- // files independently of a human's --drop landing in between) could
6506
- // still leave a tombstoned entry sitting in the queue file; sweeping on
6507
- // every load makes "never contains a rejected triple" a property of the
6508
- // queue file itself, not of one write site. Same fragmentation lesson
6509
- // as the comment on `save` above. Every OTHER reader of the queue
6510
- // (escalations, SessionStart, the MCP tools) calls
6511
- // readActivePendingRepairs instead of reading the raw file, so a
6512
- // tombstoned entry can never surface as a question even before this
6513
- // sweep next runs.
6514
- //
6515
- // Deletes unconditionally, without the prune's visibility caveats below
6516
- // — safe here because the swept entry's exact triple is preserved in
6517
- // dropped-commit-repairs.json, so nothing about it is unrecoverable
6518
- // (unlike the prune, which could otherwise destroy the queue's only
6519
- // durable record of a match).
6520
- const activeInQueue = withoutDropped(queue, dropped);
6521
- const swept = queue.filter((r) => !activeInQueue.includes(r));
6522
- // Remembered for --only below, same reasoning as onlyWasPruned: targeting
6523
- // an id whose only queued entry was just swept as already-rejected is not
6524
- // the same usage error as targeting an id that never existed.
6525
- 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);
6526
6493
  if (swept.length) {
6527
- save(activeInQueue);
6494
+ saveQueue(activeQueue);
6528
6495
  if (!opts.quiet)
6529
6496
  console.log(`Swept ${swept.length} already-rejected match${swept.length === 1 ? "" : "es"} from the queue: ${swept.map((r) => r.id).join(", ")}`);
6530
6497
  }
6531
- // Prune only PROVEN-dead entries (deadRewrites: the decision is present
6532
- // and demonstrably moved on/superseded/rejected) — never an id merely
6533
- // absent from `decisions` right now, which can just mean this run
6534
- // checked out a branch that predates the decision, or a private
6535
- // overlay isn't mounted. That's the reader's transient view, not proof
6536
- // the match is stale, and the queue file is the one durable record of
6537
- // it (see repairqueue.ts) — deleting on absence would destroy it
6538
- // unrecoverably. This keeps the queue from accumulating an
6539
- // actually-dead entry that outlives any number of --only runs
6540
- // targeting other ids, without risking a false prune. Deferred until
6541
- // after --range validation so a pure usage error changes nothing.
6542
- const deadEntries = deadRewrites(queue, decisions);
6543
- // Remembered for --only below: targeting an id that was pruned THIS
6544
- // run is not the same usage error as targeting an id that never
6545
- // existed — the human's target was real and is now resolved, not
6546
- // unrecognized.
6547
- const onlyWasPruned = !!opts.only && deadEntries.some((r) => r.id === opts.only);
6548
- if (deadEntries.length) {
6549
- // Object identity, not id — a corrupted queue file can carry a dead
6550
- // entry and a still-live one sharing an id (#53); deadRewrites
6551
- // already preserves identity via a plain filter, so pruning by id
6552
- // here would destroy the live sibling right alongside its dead
6553
- // namesake instead of leaving it for repairDecisionCommit to apply.
6554
- const deadSet = new Set(deadEntries);
6555
- 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)));
6556
6506
  if (!opts.quiet)
6557
- 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(", ")}`);
6558
6508
  }
6559
6509
  const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
6560
6510
  // Orphaned means HEAD no longer reaches the commit either: a historical
@@ -6562,82 +6512,42 @@ program
6562
6512
  const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
6563
6513
  const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
6564
6514
  const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
6565
- // Detection always queues a fresh match — local-only, never committed, so
6566
- // it's safe to persist unconditionally. This is what makes running
6567
- // opportunistically (from the hook) worth anything: the match survives
6568
- // past this process exiting and past ORIG_HEAD getting overwritten by the
6569
- // next merge, so a human can confirm it later with `--apply` alone.
6570
- // A match whose exact {id, from, to} triple was already tombstoned by
6571
- // an earlier --drop is filtered out first — the human already rejected
6572
- // exactly this proposed replacement, and a later merge re-deriving it
6573
- // independently isn't a reason to ask again. A different `to` for the
6574
- // same still-orphaned commit is a new proposal and passes through.
6575
- const freshRewrites = withoutDropped(freshPlan.rewrites, dropped);
6576
- if (freshRewrites.length)
6577
- 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));
6578
6519
  if (opts.drop) {
6579
6520
  const before = queue.length;
6580
- // firstFor is the single first-match-by-id rule — the same one
6581
- // pickRewrite and mergeRewrites go through (#53, #56, #58) — to
6582
- // decide which same-id entry would actually apply. A corrupted
6583
- // queue file can carry two entries sharing an id, and dropping by
6584
- // id would destroy the untargeted sibling too, with no tombstone
6585
- // recording what it was. Filtering by object identity (not
6586
- // `r.id !== opts.drop`) removes only the one entry actually
6587
- // dropped; any other entry sharing the id is untouched and stays
6588
- // queued.
6589
6521
  const target = firstFor(queue, opts.drop);
6590
- save(queue.filter((r) => r !== target));
6591
- // Tombstone only when something real was actually queued for this id —
6592
- // an id that was never queued has no {from, to} to record, and
6593
- // "nothing queued" is a usage-mistake signal that shouldn't quietly
6594
- // create a tombstone file.
6522
+ saveQueue(queue.filter((r) => r !== target));
6595
6523
  if (target) {
6596
6524
  saveDropped(addDropped([{ id: target.id, from: target.from, to: target.to }], dropped));
6597
- // An identical {id, from, to} sibling (a corrupted queue file, a
6598
- // hand edit) is now tombstoned but still sitting in the queue this
6599
- // run just saved above — the load-time sweep near the top of this
6600
- // action already promises the queue file can never carry a
6601
- // tombstoned entry, so re-run it here. Without this, --apply later
6602
- // in this same invocation would happily write the exact rewrite
6603
- // the human just rejected (#53 follow-up).
6604
- save(withoutDropped(queue, dropped));
6525
+ // Re-sweep so an identical sibling cannot be applied later in this invocation.
6526
+ saveQueue(withoutDropped(queue, dropped));
6605
6527
  }
6606
6528
  if (!opts.quiet) {
6607
- console.log(queue.length === before
6608
- ? `Nothing queued or matched for "${opts.drop}" to drop.`
6609
- : `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.`);
6610
6533
  }
6611
6534
  if (!opts.apply)
6612
6535
  return;
6613
6536
  }
6614
- // Deliberately checked before --only ever gets a chance to consult
6615
- // onlyWasPruned/onlyWasSwept below: when the prune or the sweep just
6616
- // emptied the queue entirely, --only's own explanatory messages never
6617
- // get a chance to fire — this generic message covers that case too,
6618
- // and still exits 0 either way, so no usage-error bug survives here.
6537
+ // Deliberately before the --only messages.
6619
6538
  if (!queue.length) {
6620
6539
  if (!opts.quiet)
6621
6540
  console.log("✓ Nothing to repair — no orphaned commit reference matched unambiguously, and nothing queued from an earlier run.");
6622
6541
  return;
6623
6542
  }
6624
- // --only restricts the apply (or dry-run preview) to one decision id,
6625
- // leaving every other queued/matched candidate exactly as it was — the
6626
- // escalation asks a per-decision question, so the answer surface should
6627
- // let a human accept one without also accepting everything else queued.
6628
6543
  const toApply = opts.only ? queue.filter((r) => r.id === opts.only) : queue;
6629
6544
  if (opts.only && !toApply.length) {
6630
- if (onlyWasSwept) {
6631
- // Not a usage error either: the human already rejected exactly this
6632
- // match via an earlier --drop, and this run's sweep just confirmed
6633
- // the rejection still holds — nothing left to apply.
6545
+ if (onlySwept) {
6634
6546
  if (!opts.quiet)
6635
6547
  console.log(`"${opts.only}" was already rejected via --drop — nothing left to do.`);
6636
6548
  return;
6637
6549
  }
6638
- if (onlyWasPruned) {
6639
- // Not a usage error: the human's target was real, and this run
6640
- // already resolved it above (see the prune's own message for why).
6550
+ if (onlyPruned) {
6641
6551
  if (!opts.quiet)
6642
6552
  console.log(`"${opts.only}" was pruned earlier in this run — nothing left to do.`);
6643
6553
  return;
@@ -6646,79 +6556,32 @@ program
6646
6556
  fail(`no queued or matched entry for "${opts.only}"`);
6647
6557
  return;
6648
6558
  }
6649
- // Whether a listed entry's decision is visible at all this run — a row
6650
- // that isn't can't actually be resolved by --apply (see
6651
- // resolvedRewriteIds below), so dry-run/apply output must say so
6652
- // instead of listing it as though --apply would act on it. Same
6653
- // reasoning applies to a `to` that doesn't resolve to a real commit
6654
- // here (issue #48): never write an unverified `to` into a decision's
6655
- // commit field, and never claim --apply would either. Computed before
6656
- // the dry-run branch below so the preview and the real run agree on
6657
- // what --apply can actually do. `null` from commitsExist means the
6658
- // check itself failed to run (not a git repo, git missing, timeout)
6659
- // and is treated as fail-open — same discipline as drift.ts's own use
6660
- // of commitsExist, though the asymmetry is worth naming: drift.ts only
6661
- // decides what to REPORT on a `null`, while this gates a WRITE that
6662
- // gets auto-committed into shared team memory. Fail-open is still the
6663
- // right call here: `isGitRepo(root)` already passed above, so `null`
6664
- // in practice means the batched `cat-file` call itself timed out —
6665
- // rare, and a corrupted `to` that slips through is fully recoverable
6666
- // (the entry survives durably in the queue either way; a bad write
6667
- // is a revert). Fail-closed's cost — treating every entry as withheld
6668
- // on a transient git error, indefinitely, on every run until the
6669
- // 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.
6670
6560
  const visibleIds = new Set(decisions.map((d) => d.id));
6671
- const existingTargets = commitsExist(toApply.map((r) => r.to), root);
6672
- 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);
6673
6566
  const withheldSet = new Set(withheld);
6674
- // The plan --apply would act on if it ran right now — built here, before
6675
- // the dry-run branch, so the preview reasons about the exact same plan
6676
- // repairDecisionCommit would. `pickRewrite` (src/core/commitrepair.ts, a
6677
- // plan-scoped `firstFor`) decides which entry wins when `applicable`
6678
- // carries two sharing a decision id (a corrupted queue file, a hand
6679
- // edit): first match, by construction. `wasChosen` asks that same
6680
- // question by object identity rather than re-deriving it, so this file
6681
- // and commitrepair.ts can never independently drift on which entry
6682
- // "won" (#51, #58).
6683
6567
  const plan = { rewrites: applicable, records: [...new Set(applicable.map((r) => r.id))] };
6684
- const applicableSet = new Set(applicable);
6685
- const wasChosen = (r) => pickRewrite(plan, r.id) === r;
6686
- // Whether an entry will still be sitting in the queue after this run,
6687
- // and why — computed ONCE and shared by the dry-run preview, the queue
6688
- // sweep, and the post-apply "left queued" report, so the three can
6689
- // never independently drift out of sync about the same entry (they
6690
- // did, twice, across two review rounds, before this was unified). The
6691
- // sweep walks the FULL `queue`, not just `toApply` — under --only, an
6692
- // untargeted id's entries never entered `applicable` at all, so
6693
- // `wasChosen` would read "not chosen" for them too. Gating "duplicate"
6694
- // on `applicableSet.has(r)` keeps the label meaning what it says: a
6695
- // SIBLING actually competed for this exact id and lost, not merely
6696
- // "this id wasn't this run's `applicable` set for some other reason."
6697
- //
6698
- // Precedence, most to least eclipsing: invisible, then withheld, then
6699
- // duplicate. Invisible and withheld can each resolve on their own (a
6700
- // later run sees the decision, or its `to` starts existing) or never
6701
- // will (withheld, permanently, unless dropped) — "duplicate" is neither:
6702
- // it means a SIBLING entry for the same id already won and got applied,
6703
- // which repairDecisionCommit's own from-mismatch bail already makes true
6704
- // by the time anything downstream would check it, so it's only worth
6705
- // naming for entries that are otherwise resolvable.
6706
- 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"
6707
6570
  : withheldSet.has(r) ? "withheld"
6708
- : applicableSet.has(r) && !wasChosen(r) ? "duplicate"
6571
+ : applicableSet.has(r) && !isChosen(r) ? "duplicate"
6709
6572
  : null;
6710
- const staysQueued = (r) => reasonQueued(r) !== null;
6573
+ const staysQueued = (r) => stayReason(r) !== null;
6711
6574
  if (!opts.apply) {
6712
6575
  if (!opts.quiet) {
6713
- const unresolvedCount = toApply.filter(staysQueued).length;
6714
- console.log(unresolvedCount
6715
- ? `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:`
6716
6579
  : `Would repair ${toApply.length} commit reference(s):`);
6717
6580
  // Match strength, so a reviewer can tell an identical change from a
6718
6581
  // file-overlap guess before applying it into shared memory (#450).
6719
6582
  const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
6720
6583
  for (const r of toApply) {
6721
- const reason = reasonQueued(r);
6584
+ const reason = stayReason(r);
6722
6585
  const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
6723
6586
  const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
6724
6587
  : reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
@@ -6736,7 +6599,7 @@ program
6736
6599
  }
6737
6600
  return;
6738
6601
  }
6739
- const touchedHomes = new Set();
6602
+ const homes = new Set();
6740
6603
  const appliedIds = new Set();
6741
6604
  for (const d of decisions) {
6742
6605
  const healed = repairDecisionCommit(d, plan);
@@ -6744,47 +6607,25 @@ program
6744
6607
  continue;
6745
6608
  const home = decisionMemoryHome(store, d.id);
6746
6609
  store.putWhereItLives("decisions", healed);
6747
- touchedHomes.add(home);
6610
+ homes.add(home);
6748
6611
  appliedIds.add(d.id);
6749
6612
  }
6750
- // Only a candidate whose decision was actually VISIBLE this run AND
6751
- // whose `to` resolved in this repository AND wasn't beaten out by a
6752
- // same-id sibling is resolved (applied, or found stale by
6753
- // repairDecisionCommit's own from-mismatch bail) — resolvedRewriteIds
6754
- // shares deadRewrites' own reasoning: an id we could not see this run,
6755
- // or whose replacement doesn't exist, is not proof of anything, so it
6756
- // stays queued rather than being swept up just because it was in
6757
- // `toApply`. This also covers --only: toApply is just the one targeted
6758
- // id, so an invisible, unresolvable, or beaten-out target is left
6759
- // queued instead of deleted unresolved.
6760
- //
6761
- // `staysQueued` (OBJECT identity for both the withheld half, via
6762
- // `withheldSet.has(r)`, and the duplicate half, via `wasChosen`) is
6763
- // OR'd in ahead of the id-keyed `!resolvedIds.has(r.id)`: a corrupted
6764
- // queue file could carry two entries sharing an id — one resolvable,
6765
- // one not, or both resolvable — and an id-keyed check alone would treat
6766
- // the pair as one unit, deleting/misreporting whichever entry didn't
6767
- // actually get applied (#48, #51).
6768
- const resolvedIds = resolvedRewriteIds(applicable, decisions);
6769
- 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));
6770
6618
  if (!appliedIds.size) {
6771
- // deadRewrites already pruned any VISIBLE decision that moved on or
6772
- // was superseded/rejected, so a targeted, resolvable id that IS
6773
- // visible always reaches repairDecisionCommit with a matching
6774
- // `from` and is always healed into a new object (appliedIds.add
6775
- // fires unconditionally for every visible match) — reaching here
6776
- // means every targeted candidate stayed queued (invisible or
6777
- // withheld, reported independently so neither reason disappears).
6778
- const invisibleIds = toApply.filter((r) => reasonQueued(r) === "invisible").map((r) => r.id);
6779
- 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);
6780
6623
  if (!opts.quiet) {
6781
6624
  if (invisibleIds.length) {
6782
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.`);
6783
6626
  }
6784
6627
  if (withheldIds.length) {
6785
- const subject = withheldIds.length === 1
6786
- ? "an entry whose proposed replacement commit doesn't"
6787
- : "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";
6788
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.`);
6789
6630
  }
6790
6631
  }
@@ -6792,37 +6633,28 @@ program
6792
6633
  }
6793
6634
  store.reindex();
6794
6635
  const rangeLabel = rangeResolves ? `${oldRef}..${newRef}` : "queued";
6795
- 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})`);
6796
6637
  if (!opts.quiet) {
6797
6638
  console.log(`✓ Repaired ${appliedIds.size} commit reference(s):`);
6798
- // `wasChosen` restricts this to the one entry per id that
6799
- // repairDecisionCommit actually wrote — `appliedIds` alone is keyed by
6800
- // decision id, so without it a same-id sibling that never got written
6801
- // would print here too (#51).
6802
- for (const r of applicable)
6803
- 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))
6804
6642
  console.log(` ${r.id} ${r.from} → ${r.to}`);
6805
- const stillQueued = toApply.filter((r) => staysQueued(r) || !resolvedIds.has(r.id));
6806
- const stillInvisible = stillQueued.filter((r) => reasonQueued(r) === "invisible");
6807
- const stillWithheld = stillQueued.filter((r) => reasonQueued(r) === "withheld");
6808
- const stillDuplicate = stillQueued.filter((r) => reasonQueued(r) === "duplicate");
6809
- if (stillInvisible.length) {
6810
- 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.`);
6811
6643
  }
6812
- if (stillWithheld.length) {
6813
- 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.`);
6814
6654
  }
6815
- if (stillDuplicate.length) {
6816
- // Every entry here shares its id with the sibling that won (that's
6817
- // what "duplicate" means) — listing ids would just repeat one
6818
- // value, so name the `to` each one proposed instead. No
6819
- // `--drop <dec_id>` pointer, unlike the other two buckets: `--drop`
6820
- // targets the first queued match for an id (the same one
6821
- // pickRewrite would apply), which by now is the winning sibling
6822
- // that's already gone from the queue, not the losing one named
6823
- // here — pointing at `--drop <dec_id>` would read as an offer to
6824
- // reject this exact entry when it can't (#53).
6825
- 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(", ")}`);
6826
6658
  }
6827
6659
  }
6828
6660
  }
@@ -7362,13 +7194,13 @@ program
7362
7194
  console.log(`· ${f.id} — ${f.detail}`);
7363
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`);
7364
7196
  }
7365
- // Every drift kind heals here — see bug_drift_heal_asymmetry above.
7197
+ // Every drift kind must heal here.
7366
7198
  const commitUnresolvable = kind("commit-unresolvable");
7367
7199
  if (commitUnresolvable.length) {
7368
7200
  console.log(`${commitUnresolvable.length} decision(s) cite a commit that no longer resolves in this repository:\n`);
7369
7201
  for (const f of commitUnresolvable)
7370
7202
  console.log(`· ${f.id} — ${f.detail}`);
7371
- 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`);
7372
7204
  }
7373
7205
  const fenceUnclosed = kind("doc-fence-unclosed");
7374
7206
  if (fenceUnclosed.length) {
@@ -7479,20 +7311,21 @@ program
7479
7311
  process.exitCode = 1; // conflict markers remain → block the commit for review
7480
7312
  }
7481
7313
  });
7482
- // ---- merge-driver-grounding (internal; git invokes this) ------------------
7314
+ // ---- merge-driver-grounding (internal) -----------------------------------
7483
7315
  program
7484
7316
  .command("merge-driver-grounding")
7485
- .description("(internal) git merge driver for the generated grounding docs — auto-resolves a hard conflict confined to the record-counts sentence.")
7486
- .argument("<base>", "%O — common ancestor")
7487
- .argument("<ours>", "%A — current branch (also the OUTPUT file)")
7488
- .argument("<theirs>", "%B — other branch")
7489
- .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)")
7490
7322
  .action((base, ours, theirs) => {
7491
- const res = mergeGroundingFile(base, ours, theirs);
7492
- if (res.write !== null)
7493
- writeFileSync(ours, res.write);
7494
- if (res.conflict)
7495
- 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;
7496
7329
  });
7497
7330
  // ---- doctor ---------------------------------------------------------------
7498
7331
  program
@@ -7535,11 +7368,9 @@ program
7535
7368
  const { store, root } = storeFor();
7536
7369
  console.log(`Hunch root: ${root}`);
7537
7370
  console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
7538
- // post-commit/post-merge are the two hooks every setup path installs; a
7539
- // repo that ran `hunch init` before the post-merge hook existed never
7540
- // received it, and there was no way to discover that short of noticing a
7541
- // squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
7542
- // 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.
7543
7374
  if (isGitRepo(root)) {
7544
7375
  const report = hookReport(root);
7545
7376
  const hooks = hookStatus(root);
@@ -7621,7 +7452,7 @@ program
7621
7452
  for (const line of synthesisStatusLines(resolution, process.env))
7622
7453
  console.log(line);
7623
7454
  const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
7624
- if (ctxWarning)
7455
+ if (ctxWarning !== null)
7625
7456
  console.log(ctxWarning);
7626
7457
  // The native addons load on first PARSE, not at import, so no other command
7627
7458
  // that merely starts up proves the parser works. Doctor is the one place
@@ -7757,17 +7588,16 @@ function readStdin() {
7757
7588
  });
7758
7589
  }
7759
7590
  /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
7760
- * forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
7761
- * inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
7762
- * which realpath-normalizes both ends first — see its docstring for why, incl.
7763
- * dec_e0a36efbf5): that function passes an unresolvable absolute path through
7764
- * UNCHANGED (still absolute) rather than signaling failure directly, since other
7765
- * callers want the original target back to fail their own match safely. This
7766
- * adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
7767
- * 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". */
7768
7596
  function toRepoRel(root, abs) {
7769
7597
  const rel = repoRelativeTarget(abs, root);
7770
- return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
7598
+ if (isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
7599
+ return "";
7600
+ return rel;
7771
7601
  }
7772
7602
  /** Files a shell command wrote get the grounding the edit tools would have
7773
7603
  * delivered before the edit — late, but while the agent can still revise.