@davesheffer/hunch 1.39.0 → 1.39.1

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.
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";
@@ -341,6 +341,10 @@ program
341
341
  const gi = ensureGitignore(root);
342
342
  if (gi.action !== "unchanged")
343
343
  console.log(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
344
+ // A repo migrated to a private overlay by an older release: bring its private-only
345
+ // block up to date (and stop publishing newly ignored memory dirs).
346
+ for (const line of describeGitignoreUpgrade(upgradeManagedGitignore(root)))
347
+ console.log(` ✓ ${line}`);
344
348
  if (opts.index !== false) {
345
349
  if (isGitRepo(root) && revExists("HEAD", root)) {
346
350
  const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
@@ -393,11 +397,14 @@ program
393
397
  if (isGitRepo(root)) {
394
398
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
395
399
  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" : ""}`);
400
+ for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`))
401
+ console.log(line);
397
402
  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)`);
403
+ 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)"))
404
+ console.log(line);
399
405
  const pc = installPostCheckoutHook(root, inv.shell);
400
- console.log(` ✓ post-checkout hook ${pc.action} (workspace ledger: records this machine's branches + worktrees on checkout)`);
406
+ for (const line of formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"))
407
+ console.log(line);
401
408
  const m = installMergeDriver(root, inv.shell);
402
409
  console.log(` ✓ team merge driver ${m.action}`);
403
410
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -406,7 +413,8 @@ program
406
413
  if (opts.enforce !== false || opts.enforceStrict) {
407
414
  const strict = !!opts.enforceStrict;
408
415
  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"})`);
416
+ 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"})`))
417
+ console.log(line);
410
418
  }
411
419
  }
412
420
  else {
@@ -478,7 +486,10 @@ program
478
486
  .action((opts) => {
479
487
  const { store, root } = storeFor();
480
488
  store.json.ensureDirs();
481
- ensureGitignore(root); // keep the derived SQLite index out of git (idempotent)
489
+ // Keep the derived SQLite index out of git (idempotent). Adds a missing block but
490
+ // never rewrites an existing one: `index` runs in CI/release gates on a clean
491
+ // checkout. Older blocks are upgraded by `hunch update` / init / private setup.
492
+ ensureGitignore(root, { upgradeExisting: false });
482
493
  // The post-merge hook only ever got installed by `hunch init`/`hunch
483
494
  // private`/`hunch shared` — a repo that already ran init before this hook
484
495
  // existed never receives it. `hunch index` already self-heals gitignore
@@ -1250,11 +1261,12 @@ function configureOverlay(dir, opts, mode) {
1250
1261
  if (opts.hook && isGitRepo(root)) {
1251
1262
  freshSetup?.markHookWrite();
1252
1263
  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`;
1264
+ const note = (lines) => lines.map((l) => `${l}\n`).join("");
1265
+ hookNote = note(formatHookInstall(root, "post-commit hook", h, ` — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}`));
1254
1266
  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`;
1267
+ hookNote += note(formatHookInstall(root, "post-merge hook", pm, " (squash-merge provenance repair + re-syncs grounding docs after a merge that brought memory in)"));
1256
1268
  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`;
1269
+ hookNote += note(formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: this machine's branches + worktrees sync through the overlay)"));
1258
1270
  }
1259
1271
  // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
1260
1272
  // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
@@ -1314,6 +1326,13 @@ function configureOverlay(dir, opts, mode) {
1314
1326
  ` next: review, then commit the PUBLIC repo:\n` +
1315
1327
  ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
1316
1328
  }
1329
+ else {
1330
+ // Re-running setup on a repo migrated by an older release upgrades its private-only
1331
+ // block in place and stops publishing memory dirs that block did not yet list.
1332
+ const upgraded = describeGitignoreUpgrade(upgradeManagedGitignore(root));
1333
+ if (upgraded.length)
1334
+ migrateNote = upgraded.map((line) => ` ✓ ${line}\n`).join("");
1335
+ }
1317
1336
  const lead = mode === "private"
1318
1337
  ? `✓ private overlay enabled → ${hunchDir}\n`
1319
1338
  : `✓ shared overlay enabled → ${hunchDir}\n`;
@@ -1520,7 +1539,7 @@ workspacesCmd
1520
1539
  if (!isGitRepo(root))
1521
1540
  return fail("`hunch workspaces prune` needs a git repo");
1522
1541
  const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1523
- const plan = prunePlanFor(view);
1542
+ const plan = prunePlanFor(view, root);
1524
1543
  if (!opts.apply) {
1525
1544
  if (opts.json)
1526
1545
  return console.log(JSON.stringify({ machine: view.machine.label, plan }, null, 2));
@@ -1536,8 +1555,7 @@ workspacesCmd
1536
1555
  if (!opts.json)
1537
1556
  console.log(renderPrunePlan(view, plan));
1538
1557
  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}?`);
1558
+ const ok = await confirmPrune(pruneConfirmQuestion(view, plan));
1541
1559
  if (!ok)
1542
1560
  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
1561
  }
@@ -1555,11 +1573,12 @@ workspacesCmd
1555
1573
  return console.log(JSON.stringify({ machine: view.machine.label, plan, results, recorded }, null, 2));
1556
1574
  console.log("");
1557
1575
  for (const r of results)
1558
- console.log(` ${r.outcome === "deleted" ? "✓" : "✗"} ${r.step.branch}: ${r.detail}`);
1576
+ console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
1559
1577
  const failed = results.filter((r) => r.outcome === "failed").length;
1578
+ const skipped = results.filter((r) => r.outcome === "skipped").length;
1560
1579
  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)
1580
+ console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
1581
+ if (failed || skipped)
1563
1582
  process.exitCode = 1;
1564
1583
  }
1565
1584
  finally {
@@ -4025,14 +4044,17 @@ program
4025
4044
  // vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
4026
4045
  // such a PR enumerates zero files) — including a deletion of the very symbol a
4027
4046
  // 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
- : "";
4047
+ // The COMPLETE diff (no synthesis byte budget); an incomplete one is flagged so
4048
+ // content-matched blocking invariants fail closed instead of passing unseen.
4049
+ const gate = files.length
4050
+ ? (exactCommit ? commitGateDiff(exactCommit, root) : opts.base ? rangeGateDiff(opts.base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root))
4051
+ : { diff: "" };
4031
4052
  const report = files.length
4032
- ? store.buildCheckReport(files, diff, {
4053
+ ? store.buildCheckReport(files, gate.diff, {
4033
4054
  strict: !!opts.strict,
4034
4055
  lastChange: (f) => lastChangeDate(f, root),
4035
4056
  publicOnly: !!opts.publicOnly,
4057
+ diffStatus: gate,
4036
4058
  })
4037
4059
  : emptyReport;
4038
4060
  if (opts.blast && !markdown && !sarif && files.length) {
@@ -4170,8 +4192,8 @@ const vetoCmd = program
4170
4192
  store.close();
4171
4193
  return;
4172
4194
  }
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) });
4195
+ const gate = opts.commit ? commitGateDiff(opts.commit, root) : opts.base ? rangeGateDiff(opts.base, root) : stagedGateDiff(root);
4196
+ const full = store.buildCheckReport(files, gate.diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
4175
4197
  if (!full.vetoes.length) {
4176
4198
  console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
4177
4199
  store.close();
@@ -4732,11 +4754,14 @@ program
4732
4754
  const before = st;
4733
4755
  let activity = null;
4734
4756
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
4735
- const p = evt.tool_input?.file_path;
4736
- if (p) {
4757
+ // A Codex apply_patch touches every file it lists (and each Move-to
4758
+ // destination); the Stop gate must see all of them, not only the first.
4759
+ const patchPaths = evt.tool_input?.patch_files?.flatMap((f) => f.moved_to ? [f.path, f.moved_to] : [f.path]);
4760
+ const edited = patchPaths?.length ? patchPaths : evt.tool_input?.file_path ? [evt.tool_input.file_path] : [];
4761
+ for (const p of edited)
4737
4762
  st = onEdit(st, toRepoRel(root, p));
4763
+ if (edited.length)
4738
4764
  activity = { kind: "edit" };
4739
- }
4740
4765
  }
4741
4766
  else if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
4742
4767
  const command = String(evt.tool_input?.command ?? "");
@@ -5046,18 +5071,16 @@ program
5046
5071
  }
5047
5072
  if (evt.hook_event_name !== "PreToolUse")
5048
5073
  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))
5074
+ const targets = editTargets(root, evt.tool_input);
5075
+ // Nothing inside the repo → nothing for Hunch to say.
5076
+ if (!targets.length)
5056
5077
  return;
5078
+ // Grounding below stays about the first in-repo file; the strict gate checks every target.
5079
+ const { abs, target } = targets[0];
5057
5080
  // A compiled red→green probe is only meaningful if its red receipt exists
5058
5081
  // before implementation. Firm/strict may deny two edits per prompt, then
5059
5082
  // fail open so a malformed or unavailable probe can never deadlock work.
5060
- if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && isProductPath(target)) {
5083
+ if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && targets.some((t) => isProductPath(t.target))) {
5061
5084
  const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
5062
5085
  if (baseline.block) {
5063
5086
  savePipelineState(evt.session_id, baseline.state);
@@ -5089,23 +5112,28 @@ program
5089
5112
  store.reindex();
5090
5113
  // The lines this edit would ADD — so a content-matched invariant denies only
5091
5114
  // 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;
5115
+ // Guard can test the proposed text. Covers Edit/Write/MultiEdit, and each
5116
+ // file of a Codex apply_patch with that file's own added lines.
5117
+ const denials = [];
5118
+ for (const t of targets) {
5119
+ // Veto Guard (live): the proposed edit text re-introduces an approach an
5120
+ // in-force decision REJECTED. The agent self-corrects before staging;
5121
+ // only human-confirmed tripwires deny.
5122
+ const hit = blockingInScope(store, t.target, t.lines) ?? (t.lines.length ? vetoInScope(store, t.target, t.lines) : null);
5123
+ if (hit)
5124
+ denials.push({ target: t.target, hit });
5100
5125
  }
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);
5126
+ if (denials.length) {
5127
+ for (const d of denials)
5128
+ appendEvent(paths, { at: new Date().toISOString(), file: d.target, ...d.hit.event });
5129
+ // One denied file keeps the single-file reason verbatim; several are
5130
+ // listed so the agent knows every file it must reconsider.
5131
+ const reason = denials.length === 1
5132
+ ? denials[0].hit.reason
5133
+ : `Hunch: this patch is denied for ${denials.length} files (${denials.map((d) => d.target).join(", ")}).\n${denials.map((d) => d.hit.reason).join("\n")}`;
5134
+ emitDeny(provider, reason);
5135
+ for (const d of denials)
5136
+ observeHookDenial(root, provider, evt, d.target, d.hit);
5109
5137
  return;
5110
5138
  }
5111
5139
  }
@@ -6562,8 +6590,8 @@ program
6562
6590
  console.log(`No changed files in ${scope}.`);
6563
6591
  return;
6564
6592
  }
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));
6593
+ const gate = opts.commit ? commitGateDiff(opts.commit, root) : base ? rangeGateDiff(base, root) : opts.working ? workingGateDiff(root) : stagedGateDiff(root);
6594
+ console.log(renderImpact(store.prImpact(files, gate.diff, gate), scope));
6567
6595
  }
6568
6596
  finally {
6569
6597
  store.close();
@@ -6983,16 +7011,32 @@ program
6983
7011
  // squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
6984
7012
  // so its absence is informational only, never a warning.
6985
7013
  if (isGitRepo(root)) {
7014
+ const report = hookReport(root);
6986
7015
  const hooks = hookStatus(root);
6987
- const missing = [!hooks.postCommit && "post-commit", !hooks.postMerge && "post-merge"].filter((h) => !!h);
7016
+ const missing = [report.postCommit.state === "missing" && "post-commit", report.postMerge.state === "missing" && "post-merge"].filter((h) => !!h);
7017
+ // A block that is present but never runs (after an exec/exit, or inside a
7018
+ // hook manager's regenerated file — issue #311) is NOT installed.
7019
+ const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7020
+ .filter(([, e]) => e.state === "unreachable");
6988
7021
  // `hunch index` only ever installs post-merge onto an existing
6989
7022
  // 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}`
7023
+ // isGitRepo(root) && hookStatus(root).postCommit above) and never writes
7024
+ // into a manager-owned hook — so the fix hint must not point there when
7025
+ // post-commit itself is missing or a hook manager is in play.
7026
+ const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
7027
+ const fix = managedMissing
7028
+ ? `${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)}`
7029
+ : !hooks.postCommit ? "run `hunch init` for the full setup" : "run `hunch index` to install it";
7030
+ const problems = [
7031
+ missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
7032
+ unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
7033
+ ].filter(Boolean);
7034
+ console.log(`hooks: ${problems.length
7035
+ ? problems.join("\n ")
6995
7036
  : `post-commit, post-merge installed${hooks.preCommit ? " (+ pre-commit)" : ""}`}`);
7037
+ for (const [name, e] of unreachable) {
7038
+ console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
7039
+ }
6996
7040
  // Workspace ledger: what this machine is called, whether its record is in memory,
6997
7041
  // and whether the checkout hook that keeps it fresh is installed.
6998
7042
  try {
@@ -7001,7 +7045,7 @@ program
7001
7045
  const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
7002
7046
  const leak = labelLeaksIdentity(machine.label);
7003
7047
  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)" : ""}`);
7048
+ `${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
7049
  }
7006
7050
  catch { /* no machine file writable: nothing to report */ }
7007
7051
  }
@@ -7169,6 +7213,28 @@ function realpathNorm(p) {
7169
7213
  function toRepoRel(root, abs) {
7170
7214
  return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
7171
7215
  }
7216
+ /** The in-repo files a pre-edit event would change, each with the lines the edit
7217
+ * ADDS to that file. A Codex apply_patch yields one entry per touched path (a
7218
+ * Move-to destination is its own entry, carrying the section's added lines);
7219
+ * every other edit tool yields its single `file_path`. Paths outside the repo
7220
+ * ("..") or on another drive ("D:/…") are skipped. */
7221
+ function editTargets(root, input) {
7222
+ const raw = input?.patch_files?.length
7223
+ ? input.patch_files.flatMap((f) => (f.moved_to ? [f.path, f.moved_to] : [f.path]).map((abs) => ({ abs, lines: f.added_lines })))
7224
+ : input?.file_path ? [{ abs: input.file_path, lines: proposedEditLines(input) }] : [];
7225
+ const byTarget = new Map();
7226
+ for (const { abs, lines } of raw) {
7227
+ const target = toRepoRel(root, abs);
7228
+ if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
7229
+ continue;
7230
+ const seen = byTarget.get(target);
7231
+ if (seen)
7232
+ seen.lines = [...seen.lines, ...lines];
7233
+ else
7234
+ byTarget.set(target, { abs, target, lines });
7235
+ }
7236
+ return [...byTarget.values()];
7237
+ }
7172
7238
  function emitContext(provider, event, text,
7173
7239
  /** One user-facing line where the host shows hook messages (Claude Code's
7174
7240
  * `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.`);
@@ -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;
@@ -49,16 +49,59 @@ function edits(value) {
49
49
  .map((item) => ({ new_string: stringAt(item, "new_string", "newString", "ReplacementContent", "replacementContent") }));
50
50
  return normalized.length ? normalized : undefined;
51
51
  }
52
+ /** Parse Codex `apply_patch` text into one entry per touched file. Only `+` lines
53
+ * inside a file section count as added: removed (`-`) and context (` `) lines are
54
+ * text the edit takes away or leaves alone, so a content-matched gate must not read
55
+ * them as proposed content. Paths are returned exactly as written in the patch;
56
+ * the CLI normalizes them against the repository root. */
57
+ const PATCH_HEADER = /^\*\*\* (Update|Add|Delete) File: (.+?)\s*$/;
58
+ const PATCH_MOVE = /^\*\*\* Move to: (.+?)\s*$/;
59
+ export function parseApplyPatch(patch) {
60
+ const files = [];
61
+ let current;
62
+ for (const line of patch.split(/\r?\n/)) {
63
+ const header = PATCH_HEADER.exec(line);
64
+ if (header) {
65
+ current = { path: header[2], action: header[1].toLowerCase(), added_lines: [] };
66
+ files.push(current);
67
+ continue;
68
+ }
69
+ if (!current)
70
+ continue;
71
+ const move = PATCH_MOVE.exec(line);
72
+ if (move) {
73
+ current.moved_to = move[1];
74
+ continue;
75
+ }
76
+ // `*** End Patch` / `*** End of File` close sections; they carry no content.
77
+ if (line.startsWith("*** "))
78
+ continue;
79
+ // A unified-diff style file header is not content; apply_patch has none, but
80
+ // a model can still emit one.
81
+ if (/^\+\+\+ (?:[ab]\/|\/dev\/null)/.test(line))
82
+ continue;
83
+ if (line.startsWith("+"))
84
+ current.added_lines.push(line.slice(1));
85
+ }
86
+ return files;
87
+ }
52
88
  /** Codex edits files through `apply_patch`, whose input is the patch text itself
53
- * (`*** Update File: path`). The first touched path becomes the edit target so the
54
- * per-file pre-edit gate applies; the whole patch stands in for the new content. */
55
- const PATCH_FILE = /^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$/m;
89
+ * (`*** Update File: path`). Every touched file is listed in `patch_files` so the
90
+ * pre-edit gate runs per file; the first path stays `file_path` and the whole patch
91
+ * stays `content` for consumers that read the single-file shape.
92
+ *
93
+ * TODO(codex-exec-patch): a Codex code-mode `exec` tool call that embeds an
94
+ * apply_patch does NOT reach this parser: `.codex/hooks.json` (integrations/
95
+ * providers.ts) matches PreToolUse on `apply_patch` only, and `normalizeHookEvent`
96
+ * enables patch parsing only for tool names `apply_patch`/`patch`. No captured
97
+ * payload of such a call exists in the repo, so its shape is not guessed here;
98
+ * capture a real hook payload before extending the matcher or this parser. */
56
99
  function applyPatchInput(raw) {
57
100
  const patch = [raw.input, raw.patch, raw.content].find((v) => typeof v === "string" && /\*\*\* Begin Patch/.test(v));
58
101
  if (!patch)
59
102
  return undefined;
60
- const file = PATCH_FILE.exec(patch)?.[1];
61
- return file ? { file_path: file, content: patch } : undefined;
103
+ const files = parseApplyPatch(patch);
104
+ return files.length ? { file_path: files[0].path, content: patch, patch_files: files } : undefined;
62
105
  }
63
106
  function normalizeToolInput(value, allowPatch = false) {
64
107
  const raw = obj(value);
@@ -94,7 +94,8 @@ function exactChangedPaths(root, baseRevision, resultRevision) {
94
94
  }
95
95
  function exactDiff(root, baseRevision, resultRevision) {
96
96
  const raw = gitBytes(root, [
97
- "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=2",
97
+ "-c", "core.quotePath=false",
98
+ "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=2", "--src-prefix=a/", "--dst-prefix=b/",
98
99
  baseRevision, resultRevision, "--",
99
100
  ]);
100
101
  if (raw.byteLength <= MAX_DIFF_BYTES)
@@ -309,6 +310,9 @@ export function deriveChangeProof(root, store, baseRef, resultRef = "HEAD", opti
309
310
  const guardReport = store.buildCheckReport(changed.paths, diff.diff, {
310
311
  strict: true,
311
312
  publicOnly,
313
+ // A diff cut at MAX_DIFF_BYTES is a prefix: content-matched blocking rules over the
314
+ // files it omits fail closed rather than read as compliant (dec_20db57c576).
315
+ diffStatus: diff.gaps.length ? { incomplete: `the guard diff exceeded ${MAX_DIFF_BYTES} bytes and was cut`, truncated: true } : undefined,
312
316
  lastChange: (path) => lastChangeAt(root, change.head_revision, path),
313
317
  });
314
318
  const allStrictBlockerIds = sortedUnique([
@@ -29,6 +29,13 @@ export interface CheckDirect {
29
29
  strictBlocks: boolean;
30
30
  /** If a blocking invariant is downgraded to advisory under strict, why. */
31
31
  downgrade?: "stale" | "low-confidence";
32
+ /** Set when a content-matched blocking invariant could NOT be evaluated because the
33
+ * added lines of some scoped files are missing from the diff (truncated diff, git
34
+ * failure, unreadable file). Reported as a hit, never as compliance: it fails closed. */
35
+ unevaluable?: {
36
+ reason: string;
37
+ files: string[];
38
+ };
32
39
  /** The causal citation (the "why this guard exists") — present when the graph links it. */
33
40
  why?: CausalWhy;
34
41
  }
@@ -44,6 +44,16 @@ export function renderImpact(im, scope) {
44
44
  }
45
45
  return out.join("\n");
46
46
  }
47
+ /** Strict-failure reason fragments for direct invariants: proven hits and the
48
+ * content-matched invariants that could not be evaluated are named separately. */
49
+ function strictBlockerReasons(r) {
50
+ const unevaluable = r.direct.filter((d) => d.strictBlocks && d.unevaluable).length;
51
+ const proven = r.strictBlockers - unevaluable;
52
+ return [
53
+ proven > 0 ? `${proven} high-confidence blocking invariant(s) directly in scope` : "",
54
+ unevaluable > 0 ? `${unevaluable} blocking invariant(s) that could not be evaluated against the complete diff` : "",
55
+ ];
56
+ }
47
57
  /** True when --strict should FAIL the commit/PR. */
48
58
  export function reportFailsStrict(r) {
49
59
  return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0);
@@ -93,7 +103,10 @@ export function renderText(r) {
93
103
  const note = r.strict && c.severity === "blocking" && !c.strictBlocks
94
104
  ? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
95
105
  : "";
96
- out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${whyText(c.why)}`);
106
+ const unevaluable = c.unevaluable
107
+ ? `\n ‼ NOT EVALUATED — ${c.unevaluable.reason}; added lines unavailable for: ${c.unevaluable.files.join(", ")} (fails closed)`
108
+ : "";
109
+ out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${unevaluable}${whyText(c.why)}`);
97
110
  }
98
111
  }
99
112
  if (r.near.length) {
@@ -122,7 +135,7 @@ export function renderText(r) {
122
135
  }
123
136
  if (reportFailsStrict(r)) {
124
137
  const reasons = [
125
- r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
138
+ ...strictBlockerReasons(r),
126
139
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
127
140
  r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
128
141
  ].filter(Boolean).join(" + ");
@@ -153,6 +166,8 @@ export function renderMarkdown(r) {
153
166
  : "";
154
167
  out.push(`- **[${c.severity}] ${c.statement}** — \`${c.id}\`${note}`);
155
168
  out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
169
+ if (c.unevaluable)
170
+ out.push(` - ‼ **Not evaluated** — ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.map((f) => `\`${f}\``).join(", ")} _(fails closed)_`);
156
171
  if (c.rationale)
157
172
  out.push(` - _${c.rationale}_`);
158
173
  for (const line of whyMd(c.why))
@@ -196,7 +211,7 @@ export function renderMarkdown(r) {
196
211
  out.push("---");
197
212
  if (reportFailsStrict(r)) {
198
213
  const reasons = [
199
- r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
214
+ ...strictBlockerReasons(r),
200
215
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
201
216
  r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
202
217
  ].filter(Boolean).join(" + ");
@@ -233,6 +248,8 @@ export function renderSarif(r, version, extras = {}) {
233
248
  text += ` — ${c.rationale}`;
234
249
  if (c.downgrade)
235
250
  text += ` (advisory under strict: ${c.downgrade})`;
251
+ if (c.unevaluable)
252
+ text += `\nNOT EVALUATED (fails closed): ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.join(", ")}`;
236
253
  if (c.why?.decision)
237
254
  text += `\nwhy: “${c.why.decision.title}” (${c.why.decision.id})`;
238
255
  if (c.why?.bug)
@@ -1,5 +1,5 @@
1
1
  import { verdict } from "./checkreport.js";
2
- import { revExists, rangeFiles, rangeDiff } from "../extractors/git.js";
2
+ import { revExists, rangeFiles, rangeGateDiff } from "../extractors/git.js";
3
3
  /** A lower fit score is better. Verdict dominates (pass < warn < block), then the
4
4
  * blocking count, then total advisory hits. Errored candidates sort last. */
5
5
  function fitKey(c) {
@@ -14,7 +14,8 @@ export function compareCandidates(store, root, base, candidates) {
14
14
  const files = rangeFiles(base, root, ref);
15
15
  if (!files.length)
16
16
  return { ...zero, verdict: "pass", error: `no changes vs ${base}` };
17
- const r = store.buildCheckReport(files, rangeDiff(base, root, ref), { strict: true });
17
+ const gate = rangeGateDiff(base, root, ref);
18
+ const r = store.buildCheckReport(files, gate.diff, { strict: true, diffStatus: gate });
18
19
  return {
19
20
  ref,
20
21
  verdict: verdict(r),