@davesheffer/hunch 1.39.0 → 1.39.2

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 (48) hide show
  1. package/dist/cli/index.js +193 -65
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/client/state.d.ts +2 -1
  4. package/dist/client/state.js +1 -0
  5. package/dist/core/agenthook.d.ts +14 -0
  6. package/dist/core/agenthook.js +55 -8
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/changeProof.js +5 -1
  10. package/dist/core/checkreport.d.ts +7 -0
  11. package/dist/core/checkreport.js +20 -3
  12. package/dist/core/compare.js +3 -2
  13. package/dist/core/correction.d.ts +10 -4
  14. package/dist/core/correction.js +7 -4
  15. package/dist/core/countersign.d.ts +28 -0
  16. package/dist/core/countersign.js +50 -0
  17. package/dist/core/reviewqueue.js +6 -1
  18. package/dist/core/spawnCommand.js +41 -9
  19. package/dist/core/stateHttp.d.ts +1 -1
  20. package/dist/core/stateHttp.js +3 -1
  21. package/dist/core/taskReportEvidence.js +31 -11
  22. package/dist/core/topics.js +1 -1
  23. package/dist/core/types.d.ts +1 -0
  24. package/dist/core/workspace.d.ts +23 -1
  25. package/dist/core/workspace.js +31 -7
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/workspaces.d.ts +10 -0
  31. package/dist/extractors/workspaces.js +92 -15
  32. package/dist/integrations/gitignore.d.ts +27 -2
  33. package/dist/integrations/gitignore.js +103 -17
  34. package/dist/integrations/hooks.d.ts +61 -7
  35. package/dist/integrations/hooks.js +330 -43
  36. package/dist/integrations/scaffold.js +1 -1
  37. package/dist/integrations/workspaceLedger.d.ts +23 -3
  38. package/dist/integrations/workspaceLedger.js +114 -8
  39. package/dist/mcp/server.js +115 -56
  40. package/dist/serve/app.d.ts +4 -0
  41. package/dist/serve/app.js +56 -36
  42. package/dist/store/hunchStore.d.ts +4 -2
  43. package/dist/store/hunchStore.js +23 -6
  44. package/dist/store/stateBinding.js +111 -42
  45. package/dist/wiki/wiki.d.ts +7 -0
  46. package/dist/wiki/wiki.js +19 -8
  47. package/package.json +1 -1
  48. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -40,7 +40,7 @@ import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/index
40
40
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
41
41
  import { parseTestReport } from "../extractors/testreport.js";
42
42
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
43
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
43
+ 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, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatus, mergeRangeChanges, commitsExist } from "../extractors/git.js";
44
44
  import { parseMemoryLog } from "../core/memorylog.js";
45
45
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
46
46
  import { orphanedCommitDecisions, planCommitRepair, repairDecisionCommit, pickRewrite, commitRepairReviewHash, mergeRewrites, firstFor, deadRewrites, resolvedRewriteIds, withoutDropped, addDropped, withheldForUnresolvableTo } from "../core/commitrepair.js";
@@ -52,11 +52,11 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
52
52
  import { extractInlineIntent } from "../extractors/comments.js";
53
53
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
54
54
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
55
- import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus } from "../integrations/hooks.js";
55
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, formatHookInstall } from "../integrations/hooks.js";
56
56
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
57
57
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
58
58
  import { installMergeDriver } from "../integrations/mergeDriver.js";
59
- import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
59
+ import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade } from "../integrations/gitignore.js";
60
60
  import { writeCiWorkflow } from "../integrations/ciAction.js";
61
61
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
62
62
  import { classifyGroundingBlock, describeGroundingFreshness } from "../core/groundingLag.js";
@@ -81,7 +81,7 @@ import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
81
81
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
82
82
  import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
83
83
  import { worktreeRows, branchRows } from "../core/workspace.js";
84
- import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune } from "../integrations/workspaceLedger.js";
84
+ import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune, pruneConfirmQuestion } from "../integrations/workspaceLedger.js";
85
85
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
86
86
  import { isHumanConfirmed } from "../core/strictgate.js";
87
87
  import { appendEvent, readEvents } from "../core/events.js";
@@ -102,10 +102,11 @@ import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval
102
102
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
103
103
  import { DRIFT_KINDS, computeDrift } from "../core/drift.js";
104
104
  import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
105
- import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
105
+ import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData, unconfirmedRoadmapMarker } from "../wiki/wiki.js";
106
106
  import { adoptProsePrompt } from "../wiki/adopt.js";
107
107
  import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
108
108
  import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
109
+ import { countersignConstraint, countersignDecision } from "../core/countersign.js";
109
110
  import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
110
111
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
111
112
  import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
@@ -341,6 +342,10 @@ program
341
342
  const gi = ensureGitignore(root);
342
343
  if (gi.action !== "unchanged")
343
344
  console.log(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
345
+ // A repo migrated to a private overlay by an older release: bring its private-only
346
+ // block up to date (and stop publishing newly ignored memory dirs).
347
+ for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
348
+ console.log(` ✓ ${line}`);
344
349
  if (opts.index !== false) {
345
350
  if (isGitRepo(root) && revExists("HEAD", root)) {
346
351
  const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
@@ -393,11 +398,14 @@ program
393
398
  if (isGitRepo(root)) {
394
399
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
395
400
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
396
- console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
401
+ for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`))
402
+ console.log(line);
397
403
  const pm = installPostMergeHook(root, inv.shell);
398
- console.log(` ✓ post-merge hook ${pm.action} (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)`);
404
+ 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)"))
405
+ console.log(line);
399
406
  const pc = installPostCheckoutHook(root, inv.shell);
400
- console.log(` ✓ post-checkout hook ${pc.action} (workspace ledger: records this machine's branches + worktrees on checkout)`);
407
+ for (const line of formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"))
408
+ console.log(line);
401
409
  const m = installMergeDriver(root, inv.shell);
402
410
  console.log(` ✓ team merge driver ${m.action}`);
403
411
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -406,7 +414,8 @@ program
406
414
  if (opts.enforce !== false || opts.enforceStrict) {
407
415
  const strict = !!opts.enforceStrict;
408
416
  const p = installPreCommitHook(root, inv.shell, strict);
409
- console.log(` ✓ pre-commit constraint guard ${p.action} (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`);
417
+ 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"})`))
418
+ console.log(line);
410
419
  }
411
420
  }
412
421
  else {
@@ -478,7 +487,10 @@ program
478
487
  .action((opts) => {
479
488
  const { store, root } = storeFor();
480
489
  store.json.ensureDirs();
481
- ensureGitignore(root); // keep the derived SQLite index out of git (idempotent)
490
+ // Keep the derived SQLite index out of git (idempotent). Adds a missing block but
491
+ // never rewrites an existing one: `index` runs in CI/release gates on a clean
492
+ // checkout. Older blocks are upgraded by `hunch update` / init / private setup.
493
+ ensureGitignore(root, { upgradeExisting: false });
482
494
  // The post-merge hook only ever got installed by `hunch init`/`hunch
483
495
  // private`/`hunch shared` — a repo that already ran init before this hook
484
496
  // existed never receives it. `hunch index` already self-heals gitignore
@@ -1250,11 +1262,12 @@ function configureOverlay(dir, opts, mode) {
1250
1262
  if (opts.hook && isGitRepo(root)) {
1251
1263
  freshSetup?.markHookWrite();
1252
1264
  const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
1253
- hookNote = ` ✓ post-commit hook ${h.action} captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
1265
+ const note = (lines) => lines.map((l) => `${l}\n`).join("");
1266
+ hookNote = note(formatHookInstall(root, "post-commit hook", h, ` — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}`));
1254
1267
  const pm = installPostMergeHook(root, inv.shell);
1255
- hookNote += ` ✓ post-merge hook ${pm.action} (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)\n`;
1268
+ hookNote += note(formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"));
1256
1269
  const pc = installPostCheckoutHook(root, inv.shell);
1257
- hookNote += ` ✓ post-checkout hook ${pc.action} (workspace ledger: this machine's branches + worktrees sync through the overlay)\n`;
1270
+ hookNote += note(formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: this machine's branches + worktrees sync through the overlay)"));
1258
1271
  }
1259
1272
  // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
1260
1273
  // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
@@ -1314,6 +1327,13 @@ function configureOverlay(dir, opts, mode) {
1314
1327
  ` next: review, then commit the PUBLIC repo:\n` +
1315
1328
  ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
1316
1329
  }
1330
+ else {
1331
+ // Re-running setup on a repo migrated by an older release upgrades its private-only
1332
+ // block in place and stops publishing memory dirs that block did not yet list.
1333
+ const upgraded = describeGitignoreUpgrade(upgradeManagedGitignore(root));
1334
+ if (upgraded.length)
1335
+ migrateNote = upgraded.map((line) => ` ✓ ${line}\n`).join("");
1336
+ }
1317
1337
  const lead = mode === "private"
1318
1338
  ? `✓ private overlay enabled → ${hunchDir}\n`
1319
1339
  : `✓ shared overlay enabled → ${hunchDir}\n`;
@@ -1520,7 +1540,7 @@ workspacesCmd
1520
1540
  if (!isGitRepo(root))
1521
1541
  return fail("`hunch workspaces prune` needs a git repo");
1522
1542
  const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1523
- const plan = prunePlanFor(view);
1543
+ const plan = prunePlanFor(view, root);
1524
1544
  if (!opts.apply) {
1525
1545
  if (opts.json)
1526
1546
  return console.log(JSON.stringify({ machine: view.machine.label, plan }, null, 2));
@@ -1536,8 +1556,7 @@ workspacesCmd
1536
1556
  if (!opts.json)
1537
1557
  console.log(renderPrunePlan(view, plan));
1538
1558
  if (!opts.yes) {
1539
- const worktrees = plan.local.filter((s) => s.worktree).length;
1540
- const ok = await confirmPrune(`Delete ${plan.local.length} branch(es)${worktrees ? ` and remove ${worktrees} worktree(s)` : ""} on ${view.machine.label}?`);
1559
+ const ok = await confirmPrune(pruneConfirmQuestion(view, plan));
1541
1560
  if (!ok)
1542
1561
  return fail(process.stdin.isTTY ? "not confirmed — nothing was deleted" : "refusing to apply without confirmation: stdin is not a terminal; pass --yes to confirm explicitly");
1543
1562
  }
@@ -1555,11 +1574,12 @@ workspacesCmd
1555
1574
  return console.log(JSON.stringify({ machine: view.machine.label, plan, results, recorded }, null, 2));
1556
1575
  console.log("");
1557
1576
  for (const r of results)
1558
- console.log(` ${r.outcome === "deleted" ? "✓" : "✗"} ${r.step.branch}: ${r.detail}`);
1577
+ console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
1559
1578
  const failed = results.filter((r) => r.outcome === "failed").length;
1579
+ const skipped = results.filter((r) => r.outcome === "skipped").length;
1560
1580
  const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
1561
- console.log(`\n${results.length - failed} deleted, ${failed} refused by git${ledger}`);
1562
- if (failed)
1581
+ console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
1582
+ if (failed || skipped)
1563
1583
  process.exitCode = 1;
1564
1584
  }
1565
1585
  finally {
@@ -4025,14 +4045,17 @@ program
4025
4045
  // vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
4026
4046
  // such a PR enumerates zero files) — including a deletion of the very symbol a
4027
4047
  // blocking conformance predicate or an active policy guards.
4028
- const diff = files.length
4029
- ? (exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root))
4030
- : "";
4048
+ // The COMPLETE diff (no synthesis byte budget); an incomplete one is flagged so
4049
+ // content-matched blocking invariants fail closed instead of passing unseen.
4050
+ const gate = files.length
4051
+ ? (exactCommit ? commitGateDiff(exactCommit, root) : opts.base ? rangeGateDiff(opts.base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root))
4052
+ : { diff: "" };
4031
4053
  const report = files.length
4032
- ? store.buildCheckReport(files, diff, {
4054
+ ? store.buildCheckReport(files, gate.diff, {
4033
4055
  strict: !!opts.strict,
4034
4056
  lastChange: (f) => lastChangeDate(f, root),
4035
4057
  publicOnly: !!opts.publicOnly,
4058
+ diffStatus: gate,
4036
4059
  })
4037
4060
  : emptyReport;
4038
4061
  if (opts.blast && !markdown && !sarif && files.length) {
@@ -4170,8 +4193,8 @@ const vetoCmd = program
4170
4193
  store.close();
4171
4194
  return;
4172
4195
  }
4173
- const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
4174
- const full = store.buildCheckReport(files, diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root) });
4196
+ const gate = opts.commit ? commitGateDiff(opts.commit, root) : opts.base ? rangeGateDiff(opts.base, root) : stagedGateDiff(root);
4197
+ const full = store.buildCheckReport(files, gate.diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
4175
4198
  if (!full.vetoes.length) {
4176
4199
  console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
4177
4200
  store.close();
@@ -4732,11 +4755,14 @@ program
4732
4755
  const before = st;
4733
4756
  let activity = null;
4734
4757
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
4735
- const p = evt.tool_input?.file_path;
4736
- if (p) {
4758
+ // A Codex apply_patch touches every file it lists (and each Move-to
4759
+ // destination); the Stop gate must see all of them, not only the first.
4760
+ const patchPaths = evt.tool_input?.patch_files?.flatMap((f) => f.moved_to ? [f.path, f.moved_to] : [f.path]);
4761
+ const edited = patchPaths?.length ? patchPaths : evt.tool_input?.file_path ? [evt.tool_input.file_path] : [];
4762
+ for (const p of edited)
4737
4763
  st = onEdit(st, toRepoRel(root, p));
4764
+ if (edited.length)
4738
4765
  activity = { kind: "edit" };
4739
- }
4740
4766
  }
4741
4767
  else if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
4742
4768
  const command = String(evt.tool_input?.command ?? "");
@@ -5017,7 +5043,10 @@ program
5017
5043
  L.push(` ${r.date} [${r.status}] ${r.title} (${r.id})`);
5018
5044
  }
5019
5045
  if (roadmap.length) {
5020
- L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
5046
+ L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => (r.unconfirmed ? `${r.title} [unconfirmed, ${r.id}]` : r.title)).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
5047
+ const unconfirmed = roadmap.filter((r) => r.unconfirmed).length;
5048
+ if (unconfirmed)
5049
+ L.push(`${unconfirmed} roadmap item(s) are unconfirmed agent testimony — the human confirms each with \`hunch review --confirm <id>${s.unified ? " --private" : ""}\`.`);
5021
5050
  }
5022
5051
  if (pendingReview > 0)
5023
5052
  L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
@@ -5046,18 +5075,16 @@ program
5046
5075
  }
5047
5076
  if (evt.hook_event_name !== "PreToolUse")
5048
5077
  return;
5049
- const abs = evt.tool_input?.file_path;
5050
- if (!abs)
5051
- return;
5052
- const target = toRepoRel(root, abs);
5053
- // Outside the repo (".." prefix) or on another drive (absolute, e.g. "D:/…")
5054
- // → nothing for Hunch to say.
5055
- if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
5078
+ const targets = editTargets(root, evt.tool_input);
5079
+ // Nothing inside the repo → nothing for Hunch to say.
5080
+ if (!targets.length)
5056
5081
  return;
5082
+ // Grounding below stays about the first in-repo file; the strict gate checks every target.
5083
+ const { abs, target } = targets[0];
5057
5084
  // A compiled red→green probe is only meaningful if its red receipt exists
5058
5085
  // before implementation. Firm/strict may deny two edits per prompt, then
5059
5086
  // fail open so a malformed or unavailable probe can never deadlock work.
5060
- if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && isProductPath(target)) {
5087
+ if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && targets.some((t) => isProductPath(t.target))) {
5061
5088
  const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
5062
5089
  if (baseline.block) {
5063
5090
  savePipelineState(evt.session_id, baseline.state);
@@ -5089,23 +5116,28 @@ program
5089
5116
  store.reindex();
5090
5117
  // The lines this edit would ADD — so a content-matched invariant denies only
5091
5118
  // when the edit actually trips it (not on every edit in scope), and the Veto
5092
- // Guard can test the proposed text. Covers Edit/Write/MultiEdit.
5093
- const proposedLines = proposedEditLines(evt.tool_input);
5094
- const deny = blockingInScope(store, target, proposedLines);
5095
- if (deny) {
5096
- appendEvent(paths, { at: new Date().toISOString(), file: target, ...deny.event });
5097
- emitDeny(provider, deny.reason);
5098
- observeHookDenial(root, provider, evt, target, deny);
5099
- return;
5119
+ // Guard can test the proposed text. Covers Edit/Write/MultiEdit, and each
5120
+ // file of a Codex apply_patch with that file's own added lines.
5121
+ const denials = [];
5122
+ for (const t of targets) {
5123
+ // Veto Guard (live): the proposed edit text re-introduces an approach an
5124
+ // in-force decision REJECTED. The agent self-corrects before staging;
5125
+ // only human-confirmed tripwires deny.
5126
+ const hit = blockingInScope(store, t.target, t.lines) ?? (t.lines.length ? vetoInScope(store, t.target, t.lines) : null);
5127
+ if (hit)
5128
+ denials.push({ target: t.target, hit });
5100
5129
  }
5101
- // Veto Guard (live): the proposed edit text re-introduces an approach an
5102
- // in-force decision REJECTED. The agent self-corrects before staging;
5103
- // only human-confirmed tripwires deny.
5104
- const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
5105
- if (vetoDeny) {
5106
- appendEvent(paths, { at: new Date().toISOString(), file: target, ...vetoDeny.event });
5107
- emitDeny(provider, vetoDeny.reason);
5108
- observeHookDenial(root, provider, evt, target, vetoDeny);
5130
+ if (denials.length) {
5131
+ for (const d of denials)
5132
+ appendEvent(paths, { at: new Date().toISOString(), file: d.target, ...d.hit.event });
5133
+ // One denied file keeps the single-file reason verbatim; several are
5134
+ // listed so the agent knows every file it must reconsider.
5135
+ const reason = denials.length === 1
5136
+ ? denials[0].hit.reason
5137
+ : `Hunch: this patch is denied for ${denials.length} files (${denials.map((d) => d.target).join(", ")}).\n${denials.map((d) => d.hit.reason).join("\n")}`;
5138
+ emitDeny(provider, reason);
5139
+ for (const d of denials)
5140
+ observeHookDenial(root, provider, evt, d.target, d.hit);
5109
5141
  return;
5110
5142
  }
5111
5143
  }
@@ -5330,6 +5362,8 @@ program
5330
5362
  .description("Answer hash-bound imported-ADR questions, or triage deliberate proposed drafts.")
5331
5363
  .option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
5332
5364
  .option("--reject <id>", "reject a draft decision with a durable lifecycle tombstone")
5365
+ .option("--confirm <id>", "countersign an agent-recorded decision (dec_…) or correction (con_…) as human-confirmed WITHOUT changing its status or content — the human act a capture token alone cannot stand in for")
5366
+ .option("--severity <s>", "with --confirm on a correction: the severity you grant (advisory | warning | blocking); default keeps its current severity")
5333
5367
  .option("--approve-import <id>", "approve one exact imported ADR as human-confirmed authority")
5334
5368
  .option("--decline-import <id>", "record that one exact imported ADR was reviewed and must stay advisory")
5335
5369
  .option("--expected-source-hash <hash>", "exact sha256 source hash printed with the imported-ADR question")
@@ -5346,11 +5380,15 @@ program
5346
5380
  store.close();
5347
5381
  return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
5348
5382
  }
5349
- const actionCount = [opts.accept, opts.reject, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
5383
+ const actionCount = [opts.accept, opts.reject, opts.confirm, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
5350
5384
  if (actionCount > 1) {
5351
5385
  store.close();
5352
5386
  return fail("choose exactly one review action at a time");
5353
5387
  }
5388
+ if (opts.severity && !opts.confirm) {
5389
+ store.close();
5390
+ return fail("--severity applies only with --confirm on a correction");
5391
+ }
5354
5392
  const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
5355
5393
  let publicGroundingChanged = false;
5356
5394
  const touchedHomes = new Set();
@@ -5390,6 +5428,58 @@ program
5390
5428
  return fail(error instanceof Error ? error.message : String(error));
5391
5429
  }
5392
5430
  }
5431
+ else if (opts.confirm) {
5432
+ // Human countersign of agent testimony. A capture token proves only that the
5433
+ // interview tool was called (inside the agent's channel), so agent-written records
5434
+ // land as agent_recorded; this is the out-of-channel human act that signs them.
5435
+ // Content and status are untouched: confirming a proposed decision is not shipping it.
5436
+ const id = opts.confirm;
5437
+ const SEV = ["advisory", "warning", "blocking"];
5438
+ if (opts.severity && !SEV.includes(opts.severity)) {
5439
+ store.close();
5440
+ return fail(`--severity must be one of: ${SEV.join(", ")}`);
5441
+ }
5442
+ const now = new Date().toISOString();
5443
+ const d = opts.private ? store.getRec("decisions", id) : store.json.get("decisions", id);
5444
+ const c = d ? undefined : opts.private ? store.getRec("constraints", id) : store.json.get("constraints", id);
5445
+ if (!d && !c) {
5446
+ store.close();
5447
+ return fail(`no decision or constraint ${id} found${opts.private ? "" : " in the public home (add --private for overlay records)"}`);
5448
+ }
5449
+ let home;
5450
+ if (d) {
5451
+ if (opts.severity) {
5452
+ store.close();
5453
+ return fail("--severity applies only when confirming a correction (con_…), not a decision");
5454
+ }
5455
+ if (isImportedAdrDecision(d)) {
5456
+ store.close();
5457
+ return fail(`imported ADR ${d.id} requires the hash-bound --approve-import flow shown by hunch review`);
5458
+ }
5459
+ home = opts.private ? decisionMemoryHome(store, d.id) : "public";
5460
+ putDecisionInHome(store, countersignDecision(d, now), home);
5461
+ console.log(`✓ confirmed decision ${id} as human_confirmed (status ${d.status} unchanged)`);
5462
+ }
5463
+ else {
5464
+ home = opts.private && store.getPrivateRec("constraints", id) ? "private" : "public";
5465
+ const next = countersignConstraint(c, now, opts.severity);
5466
+ if (home === "private")
5467
+ store.putPrivate("constraints", next);
5468
+ else
5469
+ store.json.put("constraints", next);
5470
+ const repoWide = next.scope.length === 1 && next.scope[0] === "**";
5471
+ console.log(`✓ confirmed ${next.severity} constraint ${id} as human_confirmed (scope: ${next.scope.join(", ")})`);
5472
+ if (next.severity === "blocking" && repoWide)
5473
+ console.log(" ⚠ repo-wide blocking rule: it can deny any edit at strict firmness and fail hunch check --strict.");
5474
+ }
5475
+ touchedHomes.add(home);
5476
+ store.reindex();
5477
+ if (home === "public") {
5478
+ publicGroundingChanged = true;
5479
+ if (!store.autoCommit)
5480
+ refreshExistingGrounding(root, store);
5481
+ }
5482
+ }
5393
5483
  else if (opts.accept) {
5394
5484
  const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
5395
5485
  if (!d) {
@@ -5513,7 +5603,7 @@ program
5513
5603
  if (dupCount)
5514
5604
  console.log(`\n Batch-reject the ${dupCount} duplicate(s): hunch review --reject-duplicates`);
5515
5605
  }
5516
- console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
5606
+ console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id> Confirm (sign, keep status): hunch review --confirm <id>`);
5517
5607
  }
5518
5608
  }
5519
5609
  if (touchedHomes.size)
@@ -6562,8 +6652,8 @@ program
6562
6652
  console.log(`No changed files in ${scope}.`);
6563
6653
  return;
6564
6654
  }
6565
- const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
6566
- console.log(renderImpact(store.prImpact(files, diff), scope));
6655
+ const gate = opts.commit ? commitGateDiff(opts.commit, root) : base ? rangeGateDiff(base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root);
6656
+ console.log(renderImpact(store.prImpact(files, gate.diff, gate), scope));
6567
6657
  }
6568
6658
  finally {
6569
6659
  store.close();
@@ -6713,7 +6803,7 @@ program
6713
6803
  if (!roadmap.length)
6714
6804
  console.log(" (empty — record what's next as a PROPOSED decision via /capture and it appears here)");
6715
6805
  for (const r of roadmap)
6716
- console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
6806
+ console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})${r.unconfirmed ? `\n ${unconfirmedRoadmapMarker(r, { private: !!opts.private })}` : ""}\n ${r.note}`);
6717
6807
  if (pendingReview > 0)
6718
6808
  console.log(`\n (${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` to auto-trust them as advisory)`);
6719
6809
  // Task-record ranking: evaluated automatically on every task write; the kill rule applies itself.
@@ -6983,16 +7073,32 @@ program
6983
7073
  // squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
6984
7074
  // so its absence is informational only, never a warning.
6985
7075
  if (isGitRepo(root)) {
7076
+ const report = hookReport(root);
6986
7077
  const hooks = hookStatus(root);
6987
- const missing = [!hooks.postCommit && "post-commit", !hooks.postMerge && "post-merge"].filter((h) => !!h);
7078
+ const missing = [report.postCommit.state === "missing" && "post-commit", report.postMerge.state === "missing" && "post-merge"].filter((h) => !!h);
7079
+ // A block that is present but never runs (after an exec/exit, or inside a
7080
+ // hook manager's regenerated file — issue #311) is NOT installed.
7081
+ const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7082
+ .filter(([, e]) => e.state === "unreachable");
6988
7083
  // `hunch index` only ever installs post-merge onto an existing
6989
7084
  // post-commit install (it never hooks an un-hooked repo — see
6990
- // isGitRepo(root) && hookStatus(root).postCommit above) so the fix
6991
- // hint must not point there when post-commit itself is missing.
6992
- const fix = !hooks.postCommit ? "run `hunch init` for the full setup" : "run `hunch index` to install it";
6993
- console.log(`hooks: ${missing.length
6994
- ? `⚠ missing ${missing.join(", ")} — ${fix}`
7085
+ // isGitRepo(root) && hookStatus(root).postCommit above) and never writes
7086
+ // into a manager-owned hook — so the fix hint must not point there when
7087
+ // post-commit itself is missing or a hook manager is in play.
7088
+ const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
7089
+ const fix = managedMissing
7090
+ ? `${managedMissing.manager === "exec-exit" ? "the existing hook exits before an appended block would run" : `a hook manager (${managedMissing.manager}) owns these hooks`}; run \`hunch init\` to print the snippet to add to ${rel(root, managedMissing.path)}`
7091
+ : !hooks.postCommit ? "run `hunch init` for the full setup" : "run `hunch index` to install it";
7092
+ const problems = [
7093
+ missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
7094
+ unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
7095
+ ].filter(Boolean);
7096
+ console.log(`hooks: ${problems.length
7097
+ ? problems.join("\n ")
6995
7098
  : `post-commit, post-merge installed${hooks.preCommit ? " (+ pre-commit)" : ""}`}`);
7099
+ for (const [name, e] of unreachable) {
7100
+ console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
7101
+ }
6996
7102
  // Workspace ledger: what this machine is called, whether its record is in memory,
6997
7103
  // and whether the checkout hook that keeps it fresh is installed.
6998
7104
  try {
@@ -7001,7 +7107,7 @@ program
7001
7107
  const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
7002
7108
  const leak = labelLeaksIdentity(machine.label);
7003
7109
  console.log(`workspaces: this machine is ${machine.label}${leak ? ` (⚠ label equals the ${leak})` : ""} · record in memory: ${stored ? `yes (${stored.observed_at})` : "no"} · ${others} other machine(s)` +
7004
- `${hooks.postCheckout ? " · post-checkout hook installed" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
7110
+ `${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
7005
7111
  }
7006
7112
  catch { /* no machine file writable: nothing to report */ }
7007
7113
  }
@@ -7169,6 +7275,28 @@ function realpathNorm(p) {
7169
7275
  function toRepoRel(root, abs) {
7170
7276
  return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
7171
7277
  }
7278
+ /** The in-repo files a pre-edit event would change, each with the lines the edit
7279
+ * ADDS to that file. A Codex apply_patch yields one entry per touched path (a
7280
+ * Move-to destination is its own entry, carrying the section's added lines);
7281
+ * every other edit tool yields its single `file_path`. Paths outside the repo
7282
+ * ("..") or on another drive ("D:/…") are skipped. */
7283
+ function editTargets(root, input) {
7284
+ const raw = input?.patch_files?.length
7285
+ ? input.patch_files.flatMap((f) => (f.moved_to ? [f.path, f.moved_to] : [f.path]).map((abs) => ({ abs, lines: f.added_lines })))
7286
+ : input?.file_path ? [{ abs: input.file_path, lines: proposedEditLines(input) }] : [];
7287
+ const byTarget = new Map();
7288
+ for (const { abs, lines } of raw) {
7289
+ const target = toRepoRel(root, abs);
7290
+ if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
7291
+ continue;
7292
+ const seen = byTarget.get(target);
7293
+ if (seen)
7294
+ seen.lines = [...seen.lines, ...lines];
7295
+ else
7296
+ byTarget.set(target, { abs, target, lines });
7297
+ }
7298
+ return [...byTarget.values()];
7299
+ }
7172
7300
  function emitContext(provider, event, text,
7173
7301
  /** One user-facing line where the host shows hook messages (Claude Code's
7174
7302
  * `systemMessage`); never a block, never a second model turn. */
@@ -1,6 +1,7 @@
1
1
  import { findRoot } from "../core/paths.js";
2
2
  import { CAPABILITIES, HARNESSES, inspectIntegrations, integrationHealthFails, formatIntegrationHealth, repairIntegrationPins } from "../integrations/health.js";
3
3
  import { probeIntegration } from "../integrations/probe.js";
4
+ import { describeGitignoreUpgrade, upgradeManagedGitignore } from "../integrations/gitignore.js";
4
5
  export function registerIntegrationCommands(program, refreshGrounding) {
5
6
  const integrations = program.command("integrations").description("Check harness coverage and repair stale repository Hunch pins");
6
7
  integrations.command("check")
@@ -34,6 +35,15 @@ export function registerIntegrationCommands(program, refreshGrounding) {
34
35
  if (files.includes(".codex/hooks.json")) {
35
36
  console.log("Codex: open /hooks to review and trust the changed commands, then start a new session. Command changes require renewed trust; Hunch does not grant it automatically.");
36
37
  }
38
+ // `hunch update` runs this command with the NEW release, so existing repos pick up
39
+ // managed .gitignore entries added since they were set up (no re-init needed).
40
+ try {
41
+ for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
42
+ console.log(line);
43
+ }
44
+ catch (error) {
45
+ console.log(`.gitignore not upgraded: ${error.message}`);
46
+ }
37
47
  const grounding = refreshGrounding();
38
48
  if (grounding.length)
39
49
  console.log(`Updated Hunch instructions: ${grounding.join(", ")}. Reconnect the agent to load task reporting instructions.`);
@@ -306,11 +306,12 @@ export declare function createStateClient(opts: StateClientOptions): {
306
306
  missing: string[];
307
307
  denied: string[];
308
308
  }>;
309
+ /** Liveness; `partitions` is present because this client sends its credential. */
309
310
  health: () => Promise<{
310
311
  ok: boolean;
311
312
  version: string;
312
313
  protocol: string;
313
- partitions: string[];
314
+ partitions?: string[];
314
315
  }>;
315
316
  };
316
317
  export type StateClient = ReturnType<typeof createStateClient>;
@@ -59,6 +59,7 @@ export function createStateClient(opts) {
59
59
  captureBatch: (request) => call("POST", "/nuryel/v1/capture-batch", request),
60
60
  subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
61
61
  records: (request) => call("POST", "/nuryel/v1/records", request),
62
+ /** Liveness; `partitions` is present because this client sends its credential. */
62
63
  health: () => call("GET", "/nuryel/v1/health"),
63
64
  };
64
65
  }
@@ -8,6 +8,16 @@
8
8
  export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
9
9
  export type HookProvider = (typeof HOOK_PROVIDERS)[number];
10
10
  export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
11
+ /** One file section of a Codex `apply_patch` payload. */
12
+ export interface HunchPatchFile {
13
+ /** The path as written in the patch (may be absolute). */
14
+ path: string;
15
+ action: "update" | "add" | "delete";
16
+ /** `*** Move to:` destination, when the section renames the file. */
17
+ moved_to?: string;
18
+ /** Only the lines this section adds (`+` prefix stripped). */
19
+ added_lines: string[];
20
+ }
11
21
  export interface HunchToolInput {
12
22
  file_path?: string;
13
23
  new_string?: string;
@@ -17,6 +27,9 @@ export interface HunchToolInput {
17
27
  }>;
18
28
  command?: string;
19
29
  skill?: string;
30
+ /** Codex `apply_patch` only: every file the patch touches. `file_path` is the
31
+ * first entry's path and `content` the raw patch, for single-file consumers. */
32
+ patch_files?: HunchPatchFile[];
20
33
  }
21
34
  /** A provider-neutral observation of the tool result. Output is ephemeral: the
22
35
  * pipeline compares it with bounded expectations but never persists it. */
@@ -41,6 +54,7 @@ export interface HunchHookInput {
41
54
  /** SubagentStart: the delegated agent's type (e.g. "Explore", "Plan"). */
42
55
  agent_type?: string;
43
56
  }
57
+ export declare function parseApplyPatch(patch: string): HunchPatchFile[];
44
58
  /** Parse a provider name supplied by a hook config. Unknown values intentionally
45
59
  * return null so a bad config cannot make an edit fail. */
46
60
  export declare function hookProvider(value: unknown): HookProvider | null;