@davesheffer/hunch 1.43.0 → 1.45.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 (57) hide show
  1. package/README.md +16 -16
  2. package/dist/cli/index.js +267 -89
  3. package/dist/core/agenthook.d.ts +1 -0
  4. package/dist/core/agenthook.js +11 -0
  5. package/dist/core/changeProof.js +2 -1
  6. package/dist/core/commitrepair.d.ts +9 -3
  7. package/dist/core/commitrepair.js +23 -6
  8. package/dist/core/correction.d.ts +1 -1
  9. package/dist/core/correction.js +4 -3
  10. package/dist/core/docanchors.d.ts +11 -2
  11. package/dist/core/docanchors.js +232 -65
  12. package/dist/core/drift.d.ts +1 -1
  13. package/dist/core/drift.js +10 -3
  14. package/dist/core/eol.d.ts +2 -0
  15. package/dist/core/eol.js +6 -0
  16. package/dist/core/escalations.d.ts +20 -0
  17. package/dist/core/escalations.js +47 -0
  18. package/dist/core/hookText.js +1 -1
  19. package/dist/core/procstart.d.ts +15 -4
  20. package/dist/core/procstart.js +56 -18
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskTouched.d.ts +2 -0
  24. package/dist/core/taskTouched.js +1 -1
  25. package/dist/extractors/git.d.ts +36 -6
  26. package/dist/extractors/git.js +86 -9
  27. package/dist/extractors/indexer.js +53 -14
  28. package/dist/extractors/k8sManifest.d.ts +31 -1
  29. package/dist/extractors/k8sManifest.js +133 -8
  30. package/dist/integrations/claudemd.d.ts +2 -0
  31. package/dist/integrations/claudemd.js +5 -3
  32. package/dist/integrations/gitignore.d.ts +23 -0
  33. package/dist/integrations/gitignore.js +48 -0
  34. package/dist/integrations/hooks.d.ts +15 -0
  35. package/dist/integrations/hooks.js +49 -1
  36. package/dist/integrations/mergeDriver.d.ts +2 -0
  37. package/dist/integrations/mergeDriver.js +2 -2
  38. package/dist/integrations/providers.d.ts +16 -0
  39. package/dist/integrations/providers.js +35 -6
  40. package/dist/integrations/scaffold.d.ts +5 -0
  41. package/dist/integrations/scaffold.js +13 -8
  42. package/dist/integrations/uninstall.d.ts +40 -0
  43. package/dist/integrations/uninstall.js +435 -0
  44. package/dist/mcp/leanToolList.d.ts +9 -0
  45. package/dist/mcp/leanToolList.js +89 -0
  46. package/dist/mcp/server.d.ts +2 -1
  47. package/dist/mcp/server.js +62 -6
  48. package/dist/serve/mcpHttp.js +2 -0
  49. package/dist/serve/writelock.d.ts +7 -0
  50. package/dist/serve/writelock.js +30 -3
  51. package/dist/store/changeLedger.d.ts +5 -2
  52. package/dist/store/changeLedger.js +88 -37
  53. package/dist/store/hunchStore.js +4 -1
  54. package/package.json +3 -6
  55. package/server.json +2 -2
  56. package/tooling/md1-benchmark.mjs +1 -1
  57. package/tooling/competitive-watch.mjs +0 -142
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";
@@ -59,15 +60,16 @@ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, inst
59
60
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
60
61
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
61
62
  import { installMergeDriver } from "../integrations/mergeDriver.js";
62
- import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
63
+ import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } 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";
67
69
  import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
68
70
  import { mergeGroundingFile } from "../core/groundingMerge.js";
69
71
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
70
- import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
72
+ import { scaffoldProviders, detectAssistants, PROVIDER_ASSISTANTS, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding, GROUNDING_DOC_PATHS } from "../integrations/providers.js";
71
73
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
72
74
  import { formatSearchHit, formatStructure } from "../core/format.js";
73
75
  import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
@@ -92,7 +94,7 @@ import { isHumanConfirmed } from "../core/strictgate.js";
92
94
  import { appendEvent, readEvents } from "../core/events.js";
93
95
  import { computeStats, formatStats } from "../core/stats.js";
94
96
  import { clearTaskSelection, injectionMode, loadTaskSelection, resetSessionInjections, saveTaskSelection, taskSelectionEnabled } from "../core/hookcache.js";
95
- import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, selectForTask } from "../core/taskSelection.js";
97
+ import { isBareFollowUp, isFilterableSelectionId, liveSelectionRecords, promptPaths, selectForTask } from "../core/taskSelection.js";
96
98
  import { recordServed, servedSummary } from "../core/served.js";
97
99
  import { readTaskReport, recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
98
100
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
@@ -100,7 +102,7 @@ import { renderRecalledLine } from "../core/taskReportRender.js";
100
102
  import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
101
103
  import { persistTaskRecord } from "../core/taskRecord.js";
102
104
  import { recordHookObservation } from "../core/hookObservations.js";
103
- import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
105
+ import { contextHookOutput, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
104
106
  import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, onLessonsDelivered, lessonReminder, unverifiedNag, } from "../core/pipeline.js";
105
107
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
106
108
  import { planAutoReview, planMutations } from "../core/autoreview.js";
@@ -116,7 +118,7 @@ import { countersignConstraint, countersignDecision } from "../core/countersign.
116
118
  import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
117
119
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
118
120
  import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
119
- import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline } from "../core/escalations.js";
121
+ import { pendingEscalations, policyEscalations, commitRepairEscalations, actionableEscalations, escalationHeadline, relevantEscalations } from "../core/escalations.js";
120
122
  import { premiseEscalations } from "../core/premises.js";
121
123
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
122
124
  import { pathMatchesGlob } from "../core/glob.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.
@@ -332,6 +354,8 @@ program
332
354
  .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
333
355
  .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
334
356
  .option("--no-auto-commit", "DON'T auto-commit captures (default: ON in every mode — the overlay repo is committed+pushed; the public .hunch/ is committed only and rides your next push)")
357
+ .option("--all-assistants", "configure every supported assistant, not only those found on this machine or in this repository")
358
+ .option("--details", "print every setup step and the per-assistant integration status")
335
359
  .action((opts) => {
336
360
  // Validate --firmness up front, before any side effects (indexing, git hooks,
337
361
  // .mcp.json) or opening the store — a bad value must not leave a half-init.
@@ -345,32 +369,51 @@ program
345
369
  // teammate/agent resolves the same single source of truth with zero manual setup.
346
370
  const { store, teamWired } = openTeamStore(root);
347
371
  const inv = resolveInvocation();
348
- console.log(`🧠 Initializing Hunch at ${root}`);
372
+ // Step lines are collected: the default output is a short summary with one next
373
+ // step; `--details` prints every step as written. Warnings always print.
374
+ const lines = [];
375
+ const say = (text) => { lines.push({ text, warn: text.includes("⚠") }); };
376
+ // A hook warning is followed by the snippet to add by hand: keep the group together.
377
+ const sayHook = (group, installed) => {
378
+ const warn = group.some((l) => l.includes("⚠"));
379
+ for (const text of group)
380
+ lines.push({ text, warn });
381
+ if (!warn)
382
+ summary.hooks.push(installed);
383
+ };
384
+ const summary = { index: "", hooks: [], assistants: ["Claude Code"], skipped: [] };
385
+ say(`🧠 Initializing Hunch at ${root}`);
349
386
  store.json.ensureDirs(); // stamps the manifest at the current version when fresh
350
- console.log(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
387
+ say(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
351
388
  if (teamWired)
352
- console.log(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
389
+ say(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
353
390
  // Exclude the derived SQLite index BEFORE it's written, so the working tree
354
391
  // never goes dirty on the MCP server's index writes (which blocks branch
355
392
  // switches). The .hunch/*.json graph stays tracked.
356
393
  const gi = ensureGitignore(root);
357
394
  if (gi.action !== "unchanged")
358
- console.log(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
395
+ say(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
359
396
  // A repo migrated to a private overlay by an older release: bring its private-only
360
397
  // block up to date (and stop publishing newly ignored memory dirs).
361
398
  for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
362
- console.log(` ✓ ${line}`);
399
+ say(` ✓ ${line}`);
400
+ if (!store.privateDir) {
401
+ const ignored = memoryIgnoreRule(root);
402
+ if (ignored)
403
+ say(` ⚠ ${describeMemoryIgnoreRule(ignored)}`);
404
+ }
363
405
  if (opts.index !== false) {
364
406
  if (isGitRepo(root) && revExists("HEAD", root)) {
365
407
  const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
366
408
  store.reindex();
367
- console.log(` ✓ indexed committed HEAD (${res.files} files) → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
409
+ say(` ✓ indexed committed HEAD (${res.files} files) → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
410
+ summary.index = `indexed committed HEAD (${res.files} files, ${res.symbols} symbols)`;
368
411
  for (const item of res.coverage) {
369
412
  const reasons = Object.entries(item.reasons).map(([reason, count]) => `${reason}:${count}`).join(", ");
370
- console.log(` coverage ${item.language}: ${item.eligible} eligible · ${item.parsed} parsed · ${item.skipped} skipped${reasons ? ` (${reasons})` : ""}`);
413
+ say(` coverage ${item.language}: ${item.eligible} eligible · ${item.parsed} parsed · ${item.skipped} skipped${reasons ? ` (${reasons})` : ""}`);
371
414
  }
372
415
  if (res.skipped)
373
- console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
416
+ say(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
374
417
  }
375
418
  else {
376
419
  // A checkout-derived graph can be swept into a later memory commit by
@@ -380,7 +423,7 @@ program
380
423
  store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
381
424
  store.json.replaceAll("components", []);
382
425
  store.reindex();
383
- console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
426
+ say(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
384
427
  }
385
428
  }
386
429
  // Auto-commit is ON by default in every mode; --no-auto-commit persists the opt-out in
@@ -402,11 +445,11 @@ program
402
445
  }
403
446
  }
404
447
  if (existing === null) {
405
- console.warn(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
448
+ say(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
406
449
  }
407
450
  else {
408
451
  writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
409
- console.log(" ✓ local auto-commit preference OFF (an explicit hook --commit flag still takes precedence)");
452
+ say(" ✓ local auto-commit preference OFF (an explicit hook --commit flag still takes precedence)");
410
453
  }
411
454
  }
412
455
  // Collected so the linked-worktree note below can say what actually happened
@@ -424,21 +467,18 @@ program
424
467
  }
425
468
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: hookCommit, localOnly: syncToOverlay });
426
469
  installs.push(h);
427
- for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${hookCommit ? " — auto-commit on" : ""}`))
428
- console.log(line);
470
+ sayHook(formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${hookCommit ? " — auto-commit on" : ""}`), hookCommit ? "post-commit (auto-commit on)" : "post-commit");
429
471
  if (opts.autoCommit === false && hookReport(root).postCommit.flags?.includes("--commit")) {
430
- console.log(" ⚠ the shared post-commit hook still auto-commits. To change it, re-run `hunch init --no-auto-commit` using a global install or the main checkout's Hunch.");
472
+ say(" ⚠ the shared post-commit hook still auto-commits. To change it, re-run `hunch init --no-auto-commit` using a global install or the main checkout's Hunch.");
431
473
  }
432
474
  const pm = installPostMergeHook(root, inv.shell);
433
475
  installs.push(pm);
434
- for (const line of formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"))
435
- console.log(line);
476
+ sayHook(formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"), "post-merge");
436
477
  const pc = installPostCheckoutHook(root, inv.shell);
437
478
  installs.push(pc);
438
- for (const line of formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"))
439
- console.log(line);
479
+ sayHook(formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"), "post-checkout");
440
480
  const m = installMergeDriver(root, inv.shell);
441
- console.log(` ✓ team merge driver ${m.action}`);
481
+ say(` ✓ team merge driver ${m.action}`);
442
482
  // Auto-install the pre-commit guard by default (advisory: flags invariants
443
483
  // touched directly OR via blast radius, never blocks). Opt out with
444
484
  // --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
@@ -446,12 +486,11 @@ program
446
486
  const strict = !!opts.enforceStrict;
447
487
  const p = installPreCommitHook(root, inv.shell, strict);
448
488
  installs.push(p);
449
- for (const line of formatHookInstall(root, "pre-commit constraint guard", p, ` (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`))
450
- console.log(line);
489
+ sayHook(formatHookInstall(root, "pre-commit constraint guard", p, ` (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`), `pre-commit guard (${strict ? "strict" : "advisory"})`);
451
490
  }
452
491
  }
453
492
  else {
454
- console.log(" ⚠ not a git repo — skipped hooks (run `git init` to enable the learning loop)");
493
+ say(" ⚠ not a git repo — skipped hooks (run `git init` to enable the learning loop)");
455
494
  }
456
495
  // .mcp.json is the CANONICAL registration: Claude Code resolves it by file path,
457
496
  // so it's immune to the Windows ~/.claude.json drive-letter case-split that a
@@ -459,18 +498,18 @@ program
459
498
  // existing file is refused (con_8460b6770f) — warn and continue, never clobber.
460
499
  try {
461
500
  const mcp = writeMcpJson(root, inv.mcp);
462
- console.log(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server — canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
501
+ say(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server — canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
463
502
  }
464
503
  catch (e) {
465
- console.log(` ⚠ skipped .mcp.json: ${e.message}`);
504
+ say(` ⚠ skipped .mcp.json: ${e.message}`);
466
505
  }
467
506
  const cmds = writeSlashCommands(root);
468
- console.log(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
507
+ say(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
469
508
  for (const s of cmds.skipped) {
470
- console.log(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
509
+ say(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
471
510
  }
472
511
  const cmd = updateClaudeMd(root, store);
473
- console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
512
+ say(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
474
513
  // Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
475
514
  // level even before the user runs `hunch firmness` (--firmness validated above).
476
515
  const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
@@ -478,18 +517,25 @@ program
478
517
  // adapter reads firmness at run time, so changing it needs no config rewrite.
479
518
  if (opts.agentHooks !== false) {
480
519
  const a = installClaudeHooks(root, `${inv.agentHookShell} hook`);
481
- console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
520
+ say(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
482
521
  }
483
522
  // Multi-assistant compatibility: MCP + grounding + lifecycle adapters share
484
523
  // the same .hunch/ graph across Cursor / VS Code / Windsurf / Antigravity.
485
524
  if (opts.providers !== false) {
486
- const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false });
525
+ const detected = detectAssistants(root);
526
+ const only = opts.allAssistants || detected.size === 0 ? undefined : detected;
527
+ const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false, only });
487
528
  const ok = ps.filter((p) => !p.error);
529
+ summary.assistants.push(...ok.map((p) => p.assistant === "Any (AGENTS.md)" ? "any tool that reads AGENTS.md" : p.assistant));
530
+ if (only)
531
+ summary.skipped = PROVIDER_ASSISTANTS.filter((a) => !only.has(a));
532
+ if (summary.skipped.length)
533
+ say(` ✓ not configured (not found on this machine or in this repository): ${summary.skipped.join(", ")} — add them with \`hunch init --all-assistants\``);
488
534
  const total = ok.reduce((a, p) => a + p.files.length, 0);
489
- console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
535
+ say(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
490
536
  for (const p of ps)
491
537
  if (p.error)
492
- console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
538
+ say(` ⚠ skipped ${p.assistant}: ${p.error}`);
493
539
  }
494
540
  // Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
495
541
  // case-split in ~/.claude.json, merge it so hunch resolves under either casing.
@@ -499,16 +545,76 @@ program
499
545
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
500
546
  // and note when we're initializing inside a linked worktree (memory is shared, not separate).
501
547
  if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private", true)) {
502
- console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
548
+ say(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
503
549
  }
504
550
  for (const line of sharedHooksNote(root, installs))
505
- console.log(line);
551
+ say(line);
506
552
  store.close();
507
- console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
508
- console.log("\nTask reporting is configured through Hunch's agent instructions; actual agent activity has not been verified by setup. Reconnect the agent, then work normally. Completed task reports are available with `hunch report`.");
509
- console.log(reportActivity(root));
510
- console.log("Cold start? Seed from history: hunch backfill --since 90d");
511
- console.log("\n⭐ If Hunch earns its keep, a star helps others find it → https://github.com/davesheffer/hunch");
553
+ if (opts.details) {
554
+ for (const l of lines)
555
+ console.log(l.text);
556
+ console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
557
+ console.log("\nTask reporting is configured through Hunch's agent instructions; actual agent activity has not been verified by setup. Reconnect the agent, then work normally. Completed task reports are available with `hunch report`.");
558
+ console.log(reportActivity(root));
559
+ console.log("Cold start? Seed from history: hunch backfill --since 90d");
560
+ console.log("\n⭐ If Hunch earns its keep, a star helps others find it → https://github.com/davesheffer/hunch");
561
+ return;
562
+ }
563
+ console.log(`🧠 Hunch is set up in ${root}`);
564
+ console.log(` ✓ memory: .hunch/${teamWired ? ` (connected to your team's shared store)` : ""}`);
565
+ if (summary.index)
566
+ console.log(` ✓ code index: ${summary.index}`);
567
+ if (summary.hooks.length)
568
+ console.log(` ✓ git hooks: ${summary.hooks.join(", ")}`);
569
+ console.log(` ✓ assistants: ${summary.assistants.join(", ")}`);
570
+ if (summary.skipped.length)
571
+ console.log(` not found here, so not configured: ${summary.skipped.join(", ")} (add with --all-assistants)`);
572
+ for (const l of lines)
573
+ if (l.warn)
574
+ console.log(l.text);
575
+ console.log("\nNext: reload your assistant, then ask it why a file you're about to change is built that way.");
576
+ console.log("No memory yet? Draft it from this repository's history: hunch backfill --since 90d");
577
+ console.log("More: hunch init --details (everything that was written) · hunch doctor (check the setup)");
578
+ });
579
+ // ---- uninstall ------------------------------------------------------------
580
+ program
581
+ .command("uninstall")
582
+ .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.")
583
+ .option("--dry-run", "print what would change and write nothing")
584
+ .option("--purge", "also delete .hunch/ (the engineering memory), the derived cache, Hunch's .gitignore blocks and this clone's pointer in the git dir")
585
+ .action((opts) => {
586
+ const root = findRoot();
587
+ const plan = planUninstall(root, { purge: !!opts.purge });
588
+ if (plan.refused.length) {
589
+ console.error(`Hunch uninstall at ${root}: nothing was changed — fix the file(s) below, then re-run.`);
590
+ for (const line of formatUninstallPlan(plan, !!opts.dryRun))
591
+ console.error(line);
592
+ process.exitCode = 1;
593
+ return;
594
+ }
595
+ const { applied, failed } = opts.dryRun ? { applied: plan.steps, failed: [] } : applyUninstall(plan);
596
+ // The header follows what actually ran: a failed opt-out marker stops
597
+ // everything, and saying "uninstalled" then would be a lie.
598
+ const header = opts.dryRun ? "Hunch uninstall (dry run — nothing written)"
599
+ : !failed.length ? "Hunch uninstalled"
600
+ : applied.some((s) => s.kind !== "keep") ? "Hunch partly uninstalled"
601
+ : "Hunch uninstall: nothing was changed";
602
+ console.log(`${header} at ${root}${opts.purge ? " (--purge)" : ""}`);
603
+ if (!plan.steps.some((s) => s.kind !== "keep"))
604
+ console.log(" nothing to remove — Hunch is not installed here");
605
+ for (const line of formatUninstallPlan({ ...plan, steps: applied }, !!opts.dryRun))
606
+ console.log(line);
607
+ if (failed.length) {
608
+ console.error(`
609
+ ✗ ${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" : ""}\`:`);
610
+ for (const f of failed)
611
+ console.error(` ✗ ${f.path}: ${f.error}`);
612
+ process.exitCode = 1;
613
+ return;
614
+ }
615
+ if (!opts.dryRun && plan.steps.some((s) => s.path === OPT_OUT_FILE)) {
616
+ 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\`.`);
617
+ }
512
618
  });
513
619
  // ---- index ----------------------------------------------------------------
514
620
  program
@@ -1975,11 +2081,14 @@ program
1975
2081
  store.close();
1976
2082
  return fail(`base ref "${base ?? ""}" not found (range: ${range})`);
1977
2083
  }
1978
- const steps = rangeSubjects(base, root, head);
1979
- const files = rangeFiles(base, root, head);
2084
+ // Hunch's own capture commits and store/grounding files are bookkeeping, not steps (#425).
2085
+ const commits = rangeCommits(base, root, head);
2086
+ const { steps, files } = runbookDraft(commits, rangeFiles(base, root, head));
1980
2087
  if (!steps.length && !files.length) {
1981
2088
  store.close();
1982
- return fail(`no commits or changes in range ${range}`);
2089
+ return fail(commits.length
2090
+ ? `range ${range} only holds Hunch's own capture commits and files; nothing to draft`
2091
+ : `no commits or changes in range ${range}`);
1983
2092
  }
1984
2093
  const now = new Date().toISOString();
1985
2094
  const rec = {
@@ -4109,7 +4218,7 @@ program
4109
4218
  .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
4219
  .option("--staged", "check git staged files (default)")
4111
4220
  .option("--working", "check all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
4112
- .option("--commit <sha>", "check a specific commit's files")
4221
+ .option("--commit <sha>", "check a specific commit's files (judged alone, against its parent; use --base to gate a whole branch)")
4113
4222
  .option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
4114
4223
  .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
4115
4224
  .option("--format <fmt>", "output: text (default) | markdown (a PR comment) | sarif (SARIF 2.1.0 for code scanning)", "text")
@@ -4125,11 +4234,14 @@ program
4125
4234
  const sarif = opts.format === "sarif";
4126
4235
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
4127
4236
  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`
4237
+ const teamFreshnessFailure = teamPullStatus !== null && !teamPullIsFresh(teamPullStatus)
4238
+ ? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass` +
4239
+ (teamPullStatus === "unconfigured" ? " — the shared memory remote has no published branch while this clone has memory; publish it with `hunch shared --sync`" : "")
4132
4240
  : null;
4241
+ // An empty team remote is in sync with a never-committed clone, but it is also what
4242
+ // lost team memory looks like: never let that strict pass be silent (#427).
4243
+ if (teamPullStatus === "empty")
4244
+ 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
4245
  // Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
4134
4246
  // branch) — otherwise the diff is empty and the guard passes vacuously.
4135
4247
  if (opts.base && !revExists(opts.base, root)) {
@@ -4195,13 +4307,14 @@ program
4195
4307
  // blocking conformance predicate or an active policy guards.
4196
4308
  // The COMPLETE diff (no synthesis byte budget); an incomplete one is flagged so
4197
4309
  // content-matched blocking invariants fail closed instead of passing unseen.
4310
+ const freshAt = checkFreshnessRevision({ base: opts.base, commit: exactCommit }, root);
4198
4311
  const gate = files.length
4199
4312
  ? (exactCommit ? commitGateDiff(exactCommit, root) : opts.base ? rangeGateDiff(opts.base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root))
4200
4313
  : { diff: "" };
4201
4314
  const report = files.length
4202
4315
  ? store.buildCheckReport(files, gate.diff, {
4203
4316
  strict: !!opts.strict,
4204
- lastChange: (f) => lastChangeDate(f, root),
4317
+ lastChange: (f) => lastChangeDate(f, root, freshAt),
4205
4318
  publicOnly: !!opts.publicOnly,
4206
4319
  diffStatus: gate,
4207
4320
  })
@@ -4342,7 +4455,8 @@ const vetoCmd = program
4342
4455
  return;
4343
4456
  }
4344
4457
  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 });
4458
+ const freshAt = checkFreshnessRevision({ base: opts.base, commit: opts.commit }, root);
4459
+ const full = store.buildCheckReport(files, gate.diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root, freshAt), diffStatus: gate });
4346
4460
  if (!full.vetoes.length) {
4347
4461
  console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
4348
4462
  store.close();
@@ -4984,8 +5098,7 @@ program
4984
5098
  const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
4985
5099
  store = opened.store;
4986
5100
  // 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";
5101
+ const stale = firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus);
4989
5102
  if (!stale)
4990
5103
  shellGround = shellWriteGrounding(root, store, provider, evt, written);
4991
5104
  }
@@ -5121,6 +5234,16 @@ program
5121
5234
  }
5122
5235
  }
5123
5236
  catch { /* the next Stop or prompt retries */ }
5237
+ // Escalations this prompt's task touches (dec_12ba37db1c): asked once per
5238
+ // session, never answered for the human; the rest stay pending in
5239
+ // `hunch escalations`. A broken store must not take the prompt hook down.
5240
+ try {
5241
+ store ??= new HunchStore(paths);
5242
+ const ask = await promptEscalations(root, store, provider, evt);
5243
+ if (ask)
5244
+ parts.push(ask);
5245
+ }
5246
+ catch { /* escalations stay listed by `hunch escalations` */ }
5124
5247
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
5125
5248
  // repeat: edits from an earlier turn still unverified.
5126
5249
  if (evt.session_id && pipelineEnabled()) {
@@ -5282,36 +5405,17 @@ program
5282
5405
  // further than a terminal.
5283
5406
  const decisions = s.advisoryRecs("decisions");
5284
5407
  const { pendingReview } = nowData(decisions, 3);
5285
- const escalations = pendingEscalations(decisions);
5286
- escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
5287
- // liveness checked against the full store even in private mode — a
5288
- // private-overlay decision's repair is fully answerable via
5289
- // `hunch repair-provenance` (which reads the full store), so it
5290
- // must not go silently unanswerable just because its title stays
5291
- // out of session transcripts. Only the id and commit shas surface.
5292
- const sessionStartQueue = readActivePendingRepairs(paths.root);
5293
- escalations.push(...commitRepairEscalations(sessionStartQueue, decisions, s.recs("decisions"), withheldRewrites(paths.root, sessionStartQueue)));
5294
- try {
5295
- // Constitution human moments ride the same line; a broken policy store
5296
- // must never take session-start orientation down (fail open). Public
5297
- // store only — session transcripts travel further than a terminal.
5298
- const { ConstitutionService: CS } = await import("../constitution/service.js");
5299
- escalations.push(...policyEscalations(new CS(s, paths.root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
5300
- }
5301
- catch { /* constitution unavailable */ }
5302
- // Only ACTIONABLE entries are worth asking inline — a duplicate-id
5303
- // commit-repair follower whose own resolution says "act on a
5304
- // different entry first" isn't a question the assistant can put to
5305
- // the human directly (#61). Filtered BEFORE the bail check below so
5306
- // "is there anything to say" and "what do we say" share one
5307
- // predicate — an escalations list that's entirely non-actionable
5308
- // must bail exactly like an empty one would.
5309
- const actionableEsc = actionableEscalations(escalations);
5408
+ // Escalations are task-scoped (dec_12ba37db1c): session start has no task
5409
+ // yet, so it asks nothing when the provider delivers prompts — the prompt
5410
+ // hook asks about the ones a prompt touches; every entry stays pending in
5411
+ // `hunch escalations`. A provider with no prompt event would never ask,
5412
+ // so its orientation event keeps asking every actionable entry.
5413
+ const actionableEsc = deliversPromptEvent(provider) ? [] : (await actionableEscalationsWithAnchors(paths.root, s)).actionable;
5310
5414
  if (!decisions.length && !actionableEsc.length) {
5311
5415
  // Fresh graph and nothing else to raise: nothing to orient on, but
5312
5416
  // the operating loop still ships. A queued commit-repair escalation
5313
- // (checked against the full store above) is enough reason NOT to
5314
- // bail here even when the visible decisions list is empty.
5417
+ // (checked against the full store) is enough reason NOT to bail here
5418
+ // even when the visible decisions list is empty.
5315
5419
  if (pipelineEnabled())
5316
5420
  emitContext(provider, "SessionStart", [PIPELINE_LOOP, controllerBrief].filter(Boolean).join("\n\n"));
5317
5421
  return;
@@ -5375,8 +5479,7 @@ program
5375
5479
  // never grounding from public/stale memory (dec_77d99014e0).
5376
5480
  const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
5377
5481
  store = opened.store;
5378
- if (firmness === "strict" && opened.teamPullStatus
5379
- && opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current") {
5482
+ if (firmness === "strict" && opened.teamPullStatus && !teamPullIsFresh(opened.teamPullStatus)) {
5380
5483
  // A strict deny is only trustworthy when it includes the latest team
5381
5484
  // rules. Offline/busy/unconfigured team memory is unavailable, so the
5382
5485
  // non-blocking hook says so instead of denying from stale state.
@@ -6323,8 +6426,11 @@ program
6323
6426
  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
6427
  }
6325
6428
  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: [] };
6429
+ // Orphaned means HEAD no longer reaches the commit either: a historical
6430
+ // --range tip alone would flag every newer, perfectly reachable commit (#450).
6431
+ const orphaned = candidates.length ? orphanedCommitDecisions(decisions, (sha) => commitRepairStatusAgainst(sha, [newRef, "HEAD"], root)) : [];
6432
+ const patchIds = orphaned.length ? commitPatchIds([...orphaned.map((d) => d.commit), ...candidates.map((c) => c.sha)], root) : new Map();
6433
+ const freshPlan = candidates.length ? planCommitRepair(orphaned, candidates, (sha) => patchIds.get(sha) ?? null) : { rewrites: [], records: [] };
6328
6434
  // Detection always queues a fresh match — local-only, never committed, so
6329
6435
  // it's safe to persist unconditionally. This is what makes running
6330
6436
  // opportunistically (from the hook) worth anything: the match survives
@@ -6477,12 +6583,17 @@ program
6477
6583
  console.log(unresolvedCount
6478
6584
  ? `Would repair ${toApply.length - unresolvedCount} of ${toApply.length} listed:`
6479
6585
  : `Would repair ${toApply.length} commit reference(s):`);
6586
+ // Match strength, so a reviewer can tell an identical change from a
6587
+ // file-overlap guess before applying it into shared memory (#450).
6588
+ const previewPatchIds = commitPatchIds(toApply.flatMap((r) => [r.from, r.to]), root);
6480
6589
  for (const r of toApply) {
6481
6590
  const reason = reasonQueued(r);
6591
+ const samePatch = !!previewPatchIds.get(r.from) && previewPatchIds.get(r.from) === previewPatchIds.get(r.to);
6482
6592
  const label = reason === "invisible" ? "not visible this run — --apply would leave it queued"
6483
6593
  : reason === "withheld" ? "proposed replacement commit doesn't resolve in this repository — --apply would leave it queued"
6484
6594
  : reason === "duplicate" ? "another queued entry for this same decision would be applied instead — --apply would leave this one queued"
6485
- : null;
6595
+ : samePatch ? "same patch-id: identical change"
6596
+ : "file match only — check the diff before applying";
6486
6597
  console.log(` ${r.id} ${r.from} → ${r.to}${label ? ` (${label})` : ""}`);
6487
6598
  }
6488
6599
  const reviewHash = commitRepairReviewHash(queue, withheldRewrites(root, queue));
@@ -7128,6 +7239,13 @@ program
7128
7239
  console.log(`· ${f.id} — ${f.detail}`);
7129
7240
  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
7241
  }
7242
+ const fenceUnclosed = kind("doc-fence-unclosed");
7243
+ if (fenceUnclosed.length) {
7244
+ console.log(`${fenceUnclosed.length} markdown file(s) open a code fence that never closes, hiding the topic markers below it:\n`);
7245
+ for (const f of fenceUnclosed)
7246
+ console.log(`· ${f.id} — ${f.detail}`);
7247
+ 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`);
7248
+ }
7131
7249
  console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
7132
7250
  }
7133
7251
  finally {
@@ -7250,7 +7368,19 @@ program
7250
7368
  .command("doctor")
7251
7369
  .description("Diagnose the environment (git, synthesis provider, index freshness).")
7252
7370
  .action(async () => {
7253
- const integrations = inspectIntegrations(findRoot());
7371
+ const doctorRoot = findRoot();
7372
+ if (isOptedOut(doctorRoot)) {
7373
+ console.log(`Hunch: not installed (opted out — ${OPT_OUT_FILE} is present).`);
7374
+ const left = planUninstall(doctorRoot).steps.filter((s) => s.kind === "edit" || s.kind === "delete");
7375
+ if (left.length) {
7376
+ console.log(` ⚠ ${left.length} Hunch leftover(s) remain; \`hunch uninstall\` removes them:`);
7377
+ for (const s of left)
7378
+ console.log(` - ${s.path}`);
7379
+ }
7380
+ console.log(` To reinstall: delete ${OPT_OUT_FILE}, then run \`hunch init\`.`);
7381
+ return;
7382
+ }
7383
+ const integrations = inspectIntegrations(doctorRoot);
7254
7384
  console.log(formatIntegrationHealth(integrations));
7255
7385
  // A pin npm cannot serve kills every npx launcher (hooks and MCP) before Hunch
7256
7386
  // runs, and the hosts report nothing. Name it here; bounded, offline-safe.
@@ -7406,6 +7536,9 @@ program
7406
7536
  console.log(team
7407
7537
  ? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — ${isTeamStoreTrusted(root, team) ? "run \`hunch init\` to auto-connect" : "if it is your team's store, run \`hunch shared --trust\` to connect"}`
7408
7538
  : dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
7539
+ const ignored = memoryIgnoreRule(root);
7540
+ if (ignored)
7541
+ console.log(`memory: ⚠ ${describeMemoryIgnoreRule(ignored)}`);
7409
7542
  }
7410
7543
  // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
7411
7544
  // surfaced in a linked worktree (no noise in a normal single checkout), so a
@@ -7530,6 +7663,51 @@ function shellWriteGrounding(root, store, provider, evt, written) {
7530
7663
  * A follow-up prompt widens the qualifying set rather than replacing it, so a
7531
7664
  * "go" cannot unselect the task's memory. */
7532
7665
  const TASK_SELECTION_TITLE_CHARS = 80;
7666
+ /** Every actionable escalation plus each entry id's anchor paths (a decision's
7667
+ * related_files, a policy's scope paths/globs). Titles come from the advisory
7668
+ * scope (public-only in private mode: session transcripts travel further than
7669
+ * a terminal); commit-repair liveness and the anchors read the full store, so a
7670
+ * private decision's repair stays answerable by id. */
7671
+ async function actionableEscalationsWithAnchors(root, store) {
7672
+ const decisions = store.advisoryRecs("decisions");
7673
+ const all = store.recs("decisions");
7674
+ const anchors = new Map(all.map((d) => [d.id, d.related_files]));
7675
+ const escalations = pendingEscalations(decisions);
7676
+ escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
7677
+ const queue = readActivePendingRepairs(root);
7678
+ escalations.push(...commitRepairEscalations(queue, decisions, all, withheldRewrites(root, queue)));
7679
+ try {
7680
+ // Constitution human moments ride the same line; a broken policy store must
7681
+ // never take the hook down (fail open). Public store only.
7682
+ const { ConstitutionService: CS } = await import("../constitution/service.js");
7683
+ const policies = new CS(store, root).list({ publicOnly: true });
7684
+ for (const p of policies)
7685
+ anchors.set(p.id, p.scope.paths);
7686
+ escalations.push(...policyEscalations(policies.map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
7687
+ }
7688
+ catch { /* constitution unavailable */ }
7689
+ // Only ACTIONABLE entries are worth asking inline — a duplicate-id commit-repair
7690
+ // follower whose resolution says "act on a different entry first" isn't a
7691
+ // question the assistant can put to the human directly (#61).
7692
+ return { actionable: actionableEscalations(escalations), anchors };
7693
+ }
7694
+ /** The ASK line for the escalations one prompt touches, or "" (dec_12ba37db1c). */
7695
+ async function promptEscalations(root, store, provider, evt) {
7696
+ if (!evt.prompt || isNotificationPrompt(evt.prompt))
7697
+ return "";
7698
+ const { actionable, anchors } = await actionableEscalationsWithAnchors(root, store);
7699
+ if (!actionable.length)
7700
+ return "";
7701
+ // A qualifying record outside the top-K is still part of this task.
7702
+ const selection = loadTaskSelection(hookReportTaskId(root, provider, evt));
7703
+ const selectedIds = new Set([...(selection?.top ?? []), ...(selection?.qualifying ?? [])]);
7704
+ const relevant = relevantEscalations(actionable, { prompt: evt.prompt, paths: promptPaths(evt.prompt, root), selectedIds }, (id) => anchors.get(id) ?? []);
7705
+ // Each question is asked once per session, however the touched set changes.
7706
+ const unasked = relevant.filter((e) => injectionMode(evt.session_id, `prompt-escalation:${e.kind}:${e.topic}:${e.decisionIds.join(",")}`, e.question) === "full");
7707
+ if (!unasked.length)
7708
+ return "";
7709
+ return `⚖ ${unasked.length} decision(s) this task touches need the human's call — ASK the user inline, never decide for them: ${unasked.map((e) => e.question).join(" · ")}`;
7710
+ }
7533
7711
  function promptTaskSelection(root, store, provider, evt) {
7534
7712
  // A host notification is not a task prompt: the earlier selection stands untouched.
7535
7713
  if (!taskSelectionEnabled() || isNotificationPrompt(evt.prompt))