@davesheffer/hunch 1.43.0 → 1.44.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 (47) hide show
  1. package/README.md +10 -0
  2. package/dist/cli/index.js +114 -19
  3. package/dist/core/changeProof.js +2 -1
  4. package/dist/core/commitrepair.d.ts +9 -3
  5. package/dist/core/commitrepair.js +23 -6
  6. package/dist/core/correction.d.ts +1 -1
  7. package/dist/core/correction.js +4 -3
  8. package/dist/core/docanchors.d.ts +11 -2
  9. package/dist/core/docanchors.js +232 -65
  10. package/dist/core/drift.d.ts +1 -1
  11. package/dist/core/drift.js +10 -3
  12. package/dist/core/eol.d.ts +2 -0
  13. package/dist/core/eol.js +6 -0
  14. package/dist/core/procstart.d.ts +15 -4
  15. package/dist/core/procstart.js +56 -18
  16. package/dist/core/runbookDraft.d.ts +7 -0
  17. package/dist/core/runbookDraft.js +43 -0
  18. package/dist/core/taskTouched.d.ts +2 -0
  19. package/dist/core/taskTouched.js +1 -1
  20. package/dist/extractors/git.d.ts +36 -6
  21. package/dist/extractors/git.js +86 -9
  22. package/dist/extractors/indexer.js +53 -14
  23. package/dist/extractors/k8sManifest.d.ts +31 -1
  24. package/dist/extractors/k8sManifest.js +133 -8
  25. package/dist/integrations/claudemd.d.ts +2 -0
  26. package/dist/integrations/claudemd.js +4 -2
  27. package/dist/integrations/gitignore.d.ts +7 -0
  28. package/dist/integrations/gitignore.js +18 -0
  29. package/dist/integrations/hooks.d.ts +15 -0
  30. package/dist/integrations/hooks.js +49 -1
  31. package/dist/integrations/mergeDriver.d.ts +2 -0
  32. package/dist/integrations/mergeDriver.js +2 -2
  33. package/dist/integrations/providers.d.ts +7 -0
  34. package/dist/integrations/providers.js +4 -4
  35. package/dist/integrations/scaffold.d.ts +5 -0
  36. package/dist/integrations/scaffold.js +12 -7
  37. package/dist/integrations/uninstall.d.ts +40 -0
  38. package/dist/integrations/uninstall.js +435 -0
  39. package/dist/mcp/server.d.ts +2 -1
  40. package/dist/mcp/server.js +58 -4
  41. package/dist/serve/writelock.d.ts +7 -0
  42. package/dist/serve/writelock.js +30 -3
  43. package/dist/store/changeLedger.d.ts +5 -2
  44. package/dist/store/changeLedger.js +88 -37
  45. package/dist/store/hunchStore.js +4 -1
  46. package/package.json +3 -3
  47. package/server.json +2 -2
package/README.md CHANGED
@@ -106,6 +106,16 @@ hunch integrations check --harness codex --require context
106
106
 
107
107
  The first command checks configuration. The probe starts a fresh MCP process and reads memory. The context check requires observed hook delivery on the expected version, so run it after the new assistant session begins. `--require` fails when the named capability is not verified; none of these checks proves that a model followed the advice.
108
108
 
109
+ ## Remove Hunch from a repository
110
+
111
+ ```bash
112
+ hunch uninstall --dry-run # show what would change
113
+ hunch uninstall # remove what Hunch installed; keep .hunch/ (your memory)
114
+ hunch uninstall --purge # also delete .hunch/, the derived cache and Hunch's .gitignore blocks
115
+ ```
116
+
117
+ It removes the instruction blocks, MCP entries, agent and git hooks, merge driver, slash commands and CI workflow that Hunch installed, and leaves your own content in those files alone. If a file it needs to edit can't be parsed, it changes nothing and says which file. It writes `.hunch-removed`: while that file exists, no Hunch command sets Hunch up here again. Commit it so every clone stays opted out; to reinstall, delete it and run `hunch init`. Close running `hunch mcp` / `hunch serve` processes first, so they can't recreate files.
118
+
109
119
  ## See what Hunch contributed
110
120
 
111
121
  Task reports answer: what memory reached this task, what did the agent say it used, and what checks actually ran?
package/dist/cli/index.js CHANGED
@@ -43,7 +43,8 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
43
43
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
44
44
  import { parseTestReport } from "../extractors/testreport.js";
45
45
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
46
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
46
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
47
+ import { runbookDraft } from "../core/runbookDraft.js";
47
48
  import { parseMemoryLog } from "../core/memorylog.js";
48
49
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
49
50
  import { orphanedCommitDecisions, planCommitRepair, repairDecisionCommit, pickRewrite, commitRepairReviewHash, mergeRewrites, firstFor, deadRewrites, resolvedRewriteIds, withoutDropped, addDropped, withheldForUnresolvableTo } from "../core/commitrepair.js";
@@ -61,6 +62,7 @@ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, share
61
62
  import { installMergeDriver } from "../integrations/mergeDriver.js";
62
63
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
63
64
  import { writeCiWorkflow } from "../integrations/ciAction.js";
65
+ import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMessage, planUninstall } from "../integrations/uninstall.js";
64
66
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
65
67
  import { HOOK_REMINDER } from "../core/hookText.js";
66
68
  import { measureFootprint } from "../core/footprint.js";
@@ -218,6 +220,26 @@ registerReviewMemoryCommands(program, (records, repository, privateOnly) => {
218
220
  return { root, existing: store.captureHome(privateOnly) === "private"
219
221
  ? store.recs("constraints") : store.json.loadAll("constraints") };
220
222
  });
223
+ // Opt-out gate (#449): a repository `hunch uninstall` opted out stays out. Setup
224
+ // and every other command refuse; the commands a leftover git or agent hook may
225
+ // still call exit 0 so they never break the user's commit, merge or agent turn
226
+ // (`hook` silently: its stdout is the agent's context). The merge drivers still
227
+ // run: a leftover driver that exits without writing %A would let git drop `theirs`.
228
+ const OPT_OUT_ALLOWED = new Set(["uninstall", "doctor", "help", "merge-driver", "merge-driver-grounding"]);
229
+ const OPT_OUT_QUIET = new Set(["hook", "sync", "check", "grounding", "repair-provenance", "workspaces"]);
230
+ program.hook("preAction", (_thisCommand, actionCommand) => {
231
+ let top = actionCommand;
232
+ while (top.parent && top.parent !== program)
233
+ top = top.parent;
234
+ const name = top.name();
235
+ if (OPT_OUT_ALLOWED.has(name))
236
+ return;
237
+ if (!isOptedOut(findRoot()))
238
+ return;
239
+ if (name !== "hook")
240
+ console.error(optOutMessage());
241
+ process.exit(OPT_OUT_QUIET.has(name) ? 0 : 1);
242
+ });
221
243
  // Read an already-known update and schedule any registry refresh in a detached
222
244
  // worker. No network handle is opened in this command's process, so the
223
245
  // advisory cannot delay command completion or process exit.
@@ -510,6 +532,46 @@ program
510
532
  console.log("Cold start? Seed from history: hunch backfill --since 90d");
511
533
  console.log("\n⭐ If Hunch earns its keep, a star helps others find it → https://github.com/davesheffer/hunch");
512
534
  });
535
+ // ---- uninstall ------------------------------------------------------------
536
+ program
537
+ .command("uninstall")
538
+ .description("Remove everything Hunch installed in this repository (instruction blocks, MCP entries, agent and git hooks, merge driver, slash commands, CI workflow) and opt the repository out so Hunch never sets itself up here again. Keeps .hunch/ (your engineering memory) unless --purge.")
539
+ .option("--dry-run", "print what would change and write nothing")
540
+ .option("--purge", "also delete .hunch/ (the engineering memory), the derived cache, Hunch's .gitignore blocks and this clone's pointer in the git dir")
541
+ .action((opts) => {
542
+ const root = findRoot();
543
+ const plan = planUninstall(root, { purge: !!opts.purge });
544
+ if (plan.refused.length) {
545
+ console.error(`Hunch uninstall at ${root}: nothing was changed — fix the file(s) below, then re-run.`);
546
+ for (const line of formatUninstallPlan(plan, !!opts.dryRun))
547
+ console.error(line);
548
+ process.exitCode = 1;
549
+ return;
550
+ }
551
+ const { applied, failed } = opts.dryRun ? { applied: plan.steps, failed: [] } : applyUninstall(plan);
552
+ // The header follows what actually ran: a failed opt-out marker stops
553
+ // everything, and saying "uninstalled" then would be a lie.
554
+ const header = opts.dryRun ? "Hunch uninstall (dry run — nothing written)"
555
+ : !failed.length ? "Hunch uninstalled"
556
+ : applied.some((s) => s.kind !== "keep") ? "Hunch partly uninstalled"
557
+ : "Hunch uninstall: nothing was changed";
558
+ console.log(`${header} at ${root}${opts.purge ? " (--purge)" : ""}`);
559
+ if (!plan.steps.some((s) => s.kind !== "keep"))
560
+ console.log(" nothing to remove — Hunch is not installed here");
561
+ for (const line of formatUninstallPlan({ ...plan, steps: applied }, !!opts.dryRun))
562
+ console.log(line);
563
+ if (failed.length) {
564
+ console.error(`
565
+ ✗ ${failed.length} step(s) could not be applied — close running Hunch processes (\`hunch mcp\`, \`hunch serve\`, editors using them), then re-run \`hunch uninstall${opts.purge ? " --purge" : ""}\`:`);
566
+ for (const f of failed)
567
+ console.error(` ✗ ${f.path}: ${f.error}`);
568
+ process.exitCode = 1;
569
+ return;
570
+ }
571
+ if (!opts.dryRun && plan.steps.some((s) => s.path === OPT_OUT_FILE)) {
572
+ console.log(`\nCommit ${OPT_OUT_FILE} (and the removals) so every clone stays opted out. To reinstall later, delete ${OPT_OUT_FILE} and run \`hunch init\`.`);
573
+ }
574
+ });
513
575
  // ---- index ----------------------------------------------------------------
514
576
  program
515
577
  .command("index")
@@ -1975,11 +2037,14 @@ program
1975
2037
  store.close();
1976
2038
  return fail(`base ref "${base ?? ""}" not found (range: ${range})`);
1977
2039
  }
1978
- const steps = rangeSubjects(base, root, head);
1979
- const files = rangeFiles(base, root, head);
2040
+ // Hunch's own capture commits and store/grounding files are bookkeeping, not steps (#425).
2041
+ const commits = rangeCommits(base, root, head);
2042
+ const { steps, files } = runbookDraft(commits, rangeFiles(base, root, head));
1980
2043
  if (!steps.length && !files.length) {
1981
2044
  store.close();
1982
- return fail(`no commits or changes in range ${range}`);
2045
+ return fail(commits.length
2046
+ ? `range ${range} only holds Hunch's own capture commits and files; nothing to draft`
2047
+ : `no commits or changes in range ${range}`);
1983
2048
  }
1984
2049
  const now = new Date().toISOString();
1985
2050
  const rec = {
@@ -4109,7 +4174,7 @@ program
4109
4174
  .description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard. Also flags (advisory) symbols you add that already exist elsewhere — possible re-implementation/sprawl.")
4110
4175
  .option("--staged", "check git staged files (default)")
4111
4176
  .option("--working", "check all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
4112
- .option("--commit <sha>", "check a specific commit's files")
4177
+ .option("--commit <sha>", "check a specific commit's files (judged alone, against its parent; use --base to gate a whole branch)")
4113
4178
  .option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
4114
4179
  .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
4115
4180
  .option("--format <fmt>", "output: text (default) | markdown (a PR comment) | sarif (SARIF 2.1.0 for code scanning)", "text")
@@ -4125,11 +4190,14 @@ program
4125
4190
  const sarif = opts.format === "sarif";
4126
4191
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
4127
4192
  const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
4128
- const teamFreshnessFailure = teamPullStatus !== null
4129
- && teamPullStatus !== "updated"
4130
- && teamPullStatus !== "current"
4131
- ? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass`
4193
+ const teamFreshnessFailure = teamPullStatus !== null && !teamPullIsFresh(teamPullStatus)
4194
+ ? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass` +
4195
+ (teamPullStatus === "unconfigured" ? " — the shared memory remote has no published branch while this clone has memory; publish it with `hunch shared --sync`" : "")
4132
4196
  : null;
4197
+ // An empty team remote is in sync with a never-committed clone, but it is also what
4198
+ // lost team memory looks like: never let that strict pass be silent (#427).
4199
+ if (teamPullStatus === "empty")
4200
+ console.error("· the advertised team memory has no published branch yet; this strict check ran with no team rules (publish with `hunch shared --sync` after the first capture)");
4133
4201
  // Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
4134
4202
  // branch) — otherwise the diff is empty and the guard passes vacuously.
4135
4203
  if (opts.base && !revExists(opts.base, root)) {
@@ -4195,13 +4263,14 @@ program
4195
4263
  // blocking conformance predicate or an active policy guards.
4196
4264
  // The COMPLETE diff (no synthesis byte budget); an incomplete one is flagged so
4197
4265
  // content-matched blocking invariants fail closed instead of passing unseen.
4266
+ const freshAt = checkFreshnessRevision({ base: opts.base, commit: exactCommit }, root);
4198
4267
  const gate = files.length
4199
4268
  ? (exactCommit ? commitGateDiff(exactCommit, root) : opts.base ? rangeGateDiff(opts.base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root))
4200
4269
  : { diff: "" };
4201
4270
  const report = files.length
4202
4271
  ? store.buildCheckReport(files, gate.diff, {
4203
4272
  strict: !!opts.strict,
4204
- lastChange: (f) => lastChangeDate(f, root),
4273
+ lastChange: (f) => lastChangeDate(f, root, freshAt),
4205
4274
  publicOnly: !!opts.publicOnly,
4206
4275
  diffStatus: gate,
4207
4276
  })
@@ -4342,7 +4411,8 @@ const vetoCmd = program
4342
4411
  return;
4343
4412
  }
4344
4413
  const gate = opts.commit ? commitGateDiff(opts.commit, root) : opts.base ? rangeGateDiff(opts.base, root) : stagedGateDiff(root);
4345
- const full = store.buildCheckReport(files, gate.diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
4414
+ const freshAt = checkFreshnessRevision({ base: opts.base, commit: opts.commit }, root);
4415
+ const full = store.buildCheckReport(files, gate.diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root, freshAt), diffStatus: gate });
4346
4416
  if (!full.vetoes.length) {
4347
4417
  console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
4348
4418
  store.close();
@@ -4984,8 +5054,7 @@ program
4984
5054
  const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
4985
5055
  store = opened.store;
4986
5056
  // Same rule as the pre-edit path: strict never grounds from stale team rules.
4987
- const stale = firmness === "strict" && opened.teamPullStatus
4988
- && opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current";
5057
+ const stale = firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus);
4989
5058
  if (!stale)
4990
5059
  shellGround = shellWriteGrounding(root, store, provider, evt, written);
4991
5060
  }
@@ -5375,8 +5444,7 @@ program
5375
5444
  // never grounding from public/stale memory (dec_77d99014e0).
5376
5445
  const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
5377
5446
  store = opened.store;
5378
- if (firmness === "strict" && opened.teamPullStatus
5379
- && opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current") {
5447
+ if (firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus)) {
5380
5448
  // A strict deny is only trustworthy when it includes the latest team
5381
5449
  // rules. Offline/busy/unconfigured team memory is unavailable, so the
5382
5450
  // non-blocking hook says so instead of denying from stale state.
@@ -6323,8 +6391,11 @@ program
6323
6391
  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(", ")}`);
6324
6392
  }
6325
6393
  const candidates = rangeResolves && !opts.apply && !opts.drop ? mergeRangeChanges(oldRef, newRef, root) : [];
6326
- const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatus(sha, newRef, root)) : [];
6327
- const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates) : { rewrites: [], records: [] };
6394
+ // Orphaned means HEAD no longer reaches the commit either: a historical
6395
+ // --range tip alone would flag every newer, perfectly reachable commit (#450).
6396
+ const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
6397
+ const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
6398
+ const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
6328
6399
  // Detection always queues a fresh match — local-only, never committed, so
6329
6400
  // it's safe to persist unconditionally. This is what makes running
6330
6401
  // opportunistically (from the hook) worth anything: the match survives
@@ -6477,12 +6548,17 @@ program
6477
6548
  console.log(unresolvedCount
6478
6549
  ? `Would repair ${toApply.length - unresolvedCount} of ${toApply.length} listed:`
6479
6550
  : `Would repair ${toApply.length} commit reference(s):`);
6551
+ // Match strength, so a reviewer can tell an identical change from a
6552
+ // file-overlap guess before applying it into shared memory (#450).
6553
+ const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
6480
6554
  for (const r of toApply) {
6481
6555
  const reason = reasonQueued(r);
6556
+ const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
6482
6557
  const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
6483
6558
  : reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
6484
6559
  : reason === "duplicate" ? "another queued entry for this same decision would be applied instead — --apply would leave this one queued"
6485
- : null;
6560
+ : samePatch ? "same patch-id: identical change"
6561
+ : "file match only — check the diff before applying";
6486
6562
  console.log(` ${r.id} ${r.from} → ${r.to}${label ? ` (${label})` : ""}`);
6487
6563
  }
6488
6564
  const reviewHash = commitRepairReviewHash(queue, withheldRewrites(root, queue));
@@ -7128,6 +7204,13 @@ program
7128
7204
  console.log(`· ${f.id} — ${f.detail}`);
7129
7205
  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`);
7130
7206
  }
7207
+ const fenceUnclosed = kind("doc-fence-unclosed");
7208
+ if (fenceUnclosed.length) {
7209
+ console.log(`${fenceUnclosed.length} markdown file(s) open a code fence that never closes, hiding the topic markers below it:\n`);
7210
+ for (const f of fenceUnclosed)
7211
+ console.log(`· ${f.id} — ${f.detail}`);
7212
+ console.log(`\nHeal: close the fence (a line of the same \`\`\` or ~~~ run) where the example ends; the markers after it become live again.\n`);
7213
+ }
7131
7214
  console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
7132
7215
  }
7133
7216
  finally {
@@ -7250,7 +7333,19 @@ program
7250
7333
  .command("doctor")
7251
7334
  .description("Diagnose the environment (git, synthesis provider, index freshness).")
7252
7335
  .action(async () => {
7253
- const integrations = inspectIntegrations(findRoot());
7336
+ const doctorRoot = findRoot();
7337
+ if (isOptedOut(doctorRoot)) {
7338
+ console.log(`Hunch: not installed (opted out — ${OPT_OUT_FILE} is present).`);
7339
+ const left = planUninstall(doctorRoot).steps.filter((s) => s.kind === "edit" || s.kind === "delete");
7340
+ if (left.length) {
7341
+ console.log(` ⚠ ${left.length} Hunch leftover(s) remain; \`hunch uninstall\` removes them:`);
7342
+ for (const s of left)
7343
+ console.log(` - ${s.path}`);
7344
+ }
7345
+ console.log(` To reinstall: delete ${OPT_OUT_FILE}, then run \`hunch init\`.`);
7346
+ return;
7347
+ }
7348
+ const integrations = inspectIntegrations(doctorRoot);
7254
7349
  console.log(formatIntegrationHealth(integrations));
7255
7350
  // A pin npm cannot serve kills every npx launcher (hooks and MCP) before Hunch
7256
7351
  // runs, and the hosts report nothing. Name it here; bounded, offline-safe.
@@ -313,7 +313,8 @@ export function deriveChangeProof(root, store, baseRef, resultRef = "HEAD", opti
313
313
  // A diff cut at MAX_DIFF_BYTES is a prefix: content-matched blocking rules over the
314
314
  // files it omits fail closed rather than read as compliant (dec_20db57c576).
315
315
  diffStatus: diff.gaps.length ? { incomplete: `the guard diff exceeded ${MAX_DIFF_BYTES} bytes and was cut`, truncated: true } : undefined,
316
- lastChange: (path) => lastChangeAt(root, change.head_revision, path),
316
+ // Freshness as of the base: the change under proof must not make its own guard stale (#426).
317
+ lastChange: (path) => lastChangeAt(root, change.base_revision, path),
317
318
  });
318
319
  const allStrictBlockerIds = sortedUnique([
319
320
  ...guardReport.direct.filter((item) => item.strictBlocks).map((item) => item.id),
@@ -30,9 +30,15 @@ export interface CommitRepairPlan {
30
30
  * doesn't resolve at all ("unresolvable") is out of repair scope, and a git
31
31
  * failure ("unknown") must never be mistaken for repair-eligible. */
32
32
  export declare function orphanedCommitDecisions(decisions: readonly Decision[], status: (sha: string) => CommitRepairStatus): Decision[];
33
- /** Match each orphaned decision to exactly one candidate whose changed files are
34
- * a superset of the decision's related_files (non-empty, no globs). */
35
- export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[]): CommitRepairPlan;
33
+ /** Match each orphaned decision to exactly one candidate. A candidate with the
34
+ * same `git patch-id --stable` as the orphaned commit (a rebase- or
35
+ * single-commit squash-merge of it) is the strong match and wins. Only when
36
+ * no candidate shares the patch-id does it fall back to the one candidate
37
+ * whose changed files are a superset of the decision's related_files
38
+ * (non-empty, no globs) -- needed for a squash of several commits, whose
39
+ * patch-id never equals any one of them, but weak: shared files like
40
+ * CHANGELOG.md often have one wrong superset (#450). */
41
+ export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[], patchIdOf?: (sha: string) => string | null): CommitRepairPlan;
36
42
  /** Whether a queued rewrite is still worth ASKING about: the decision it
37
43
  * names still exists, still cites the commit the entry expects to replace (a
38
44
  * decision that already moved on would make repairDecisionCommit's own
@@ -4,9 +4,10 @@
4
4
  * A squash-merge produces one new commit whose parent is the pre-merge target
5
5
  * tip — the original per-commit SHAs it squashed are never ancestors of it.
6
6
  * This module matches an orphaned decision to the ONE newly-merged commit
7
- * whose changed files are a superset of the decision's related_files. Zero or
8
- * multiple qualifying candidates means "don't guess" — same discipline as
9
- * repair.ts's rename-repair.
7
+ * with the same patch-id, or failing that the ONE whose changed files are a
8
+ * superset of the decision's related_files. Zero or multiple qualifying
9
+ * candidates means "don't guess" — same discipline as repair.ts's
10
+ * rename-repair.
10
11
  */
11
12
  import { createHash } from "node:crypto";
12
13
  import { replaceExact } from "./refrepair.js";
@@ -33,11 +34,27 @@ export function orphanedCommitDecisions(decisions, status) {
33
34
  return status(d.commit) === "orphaned";
34
35
  });
35
36
  }
36
- /** Match each orphaned decision to exactly one candidate whose changed files are
37
- * a superset of the decision's related_files (non-empty, no globs). */
38
- export function planCommitRepair(orphaned, candidates) {
37
+ /** Match each orphaned decision to exactly one candidate. A candidate with the
38
+ * same `git patch-id --stable` as the orphaned commit (a rebase- or
39
+ * single-commit squash-merge of it) is the strong match and wins. Only when
40
+ * no candidate shares the patch-id does it fall back to the one candidate
41
+ * whose changed files are a superset of the decision's related_files
42
+ * (non-empty, no globs) -- needed for a squash of several commits, whose
43
+ * patch-id never equals any one of them, but weak: shared files like
44
+ * CHANGELOG.md often have one wrong superset (#450). */
45
+ export function planCommitRepair(orphaned, candidates, patchIdOf = () => null) {
39
46
  const rewrites = [];
40
47
  for (const d of orphaned) {
48
+ const patchId = patchIdOf(d.commit);
49
+ if (patchId) {
50
+ const same = candidates.filter((c) => patchIdOf(c.sha) === patchId);
51
+ if (same.length === 1) {
52
+ rewrites.push({ id: d.id, from: d.commit, to: same[0].sha });
53
+ continue;
54
+ }
55
+ if (same.length > 1)
56
+ continue; // ambiguous: never guess
57
+ }
41
58
  const files = (d.related_files ?? []).filter(isExactPath);
42
59
  if (!files.length)
43
60
  continue;
@@ -30,7 +30,7 @@ export interface CorrectionInput {
30
30
  * enough — any agent can mint and consume one. Determines the TIER, never whether
31
31
  * the write lands: an un-vouched correction still records immediately and still
32
32
  * surfaces at edit time and in CI. Only the authority to DENY waits for a countersign
33
- * (`hunch review --confirm <id>`). */
33
+ * (`hunch review --confirm <id>`). Omitted means false. */
34
34
  vouched?: boolean;
35
35
  }
36
36
  /** Default rationales, exported so a later human countersign can replace the testimony
@@ -5,8 +5,8 @@
5
5
  * agent hook so they are unit-testable without spinning either up:
6
6
  * - looksLikeCorrection(): does a user prompt read like "no / that's wrong /
7
7
  * never do X" — the cue to nudge the agent to persist it.
8
- * - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
9
- * scoped conservatively) that the pre-edit hook + CI guard then enforce.
8
+ * - buildCorrectionConstraint(): mint the Constraint record (human-confirmed
9
+ * only when vouched, scoped conservatively) that the pre-edit hook + CI guard then enforce.
10
10
  */
11
11
  import { isAbsolute } from "node:path";
12
12
  import { constraintId } from "./ids.js";
@@ -108,7 +108,8 @@ export function buildCorrectionConstraint(input, now) {
108
108
  // still recorded immediately and still held against every assistant at edit time and
109
109
  // in CI — Never Twice keeps its promise. What waits for a countersign is only the
110
110
  // authority to DENY.
111
- const vouched = input.vouched !== false;
111
+ // Fails closed: an omitted flag is testimony, never a signature.
112
+ const vouched = input.vouched === true;
112
113
  if (!vouched && severity === "blocking")
113
114
  severity = "warning";
114
115
  return {
@@ -22,9 +22,18 @@ export interface DocAnchor {
22
22
  line: number;
23
23
  }
24
24
  /** Parse every hunch:topic marker out of a markdown document. Markers inside
25
- * fenced code blocks or inline code spans are examples, not declarations,
26
- * and are skipped. */
25
+ * code blocks or inline code spans are examples, not declarations, and are
26
+ * skipped. */
27
27
  export declare function parseDocAnchors(text: string): DocAnchor[];
28
+ /** parseDocAnchors plus what drift reports about the document: a fence that
29
+ * never closes hides every marker below it, almost always by mistake (#381). */
30
+ export declare function scanDocAnchors(text: string): {
31
+ anchors: DocAnchor[];
32
+ unclosedFence: {
33
+ line: number;
34
+ hidden: number;
35
+ } | null;
36
+ };
28
37
  /** Pre-edit grounding for a markdown document that carries topic anchors: the
29
38
  * CURRENT decision per declared topic (graph over prose), what it rejected,
30
39
  * and a stale-pin warning the editor can heal inline. Empty when no anchor