@davesheffer/hunch 1.38.1 → 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.
Files changed (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  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/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. 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, 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";
@@ -78,7 +78,10 @@ import { deriveChangeProof } from "../core/changeProof.js";
78
78
  import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
79
79
  import { diffProjectDna } from "../core/projectDnaDelta.js";
80
80
  import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
81
- import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
81
+ import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
82
+ import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
83
+ import { worktreeRows, branchRows } from "../core/workspace.js";
84
+ import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune, pruneConfirmQuestion } from "../integrations/workspaceLedger.js";
82
85
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
83
86
  import { isHumanConfirmed } from "../core/strictgate.js";
84
87
  import { appendEvent, readEvents } from "../core/events.js";
@@ -338,6 +341,10 @@ program
338
341
  const gi = ensureGitignore(root);
339
342
  if (gi.action !== "unchanged")
340
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}`);
341
348
  if (opts.index !== false) {
342
349
  if (isGitRepo(root) && revExists("HEAD", root)) {
343
350
  const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
@@ -390,9 +397,14 @@ program
390
397
  if (isGitRepo(root)) {
391
398
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
392
399
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
393
- 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);
394
402
  const pm = installPostMergeHook(root, inv.shell);
395
- 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);
405
+ const pc = installPostCheckoutHook(root, inv.shell);
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);
396
408
  const m = installMergeDriver(root, inv.shell);
397
409
  console.log(` ✓ team merge driver ${m.action}`);
398
410
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -401,7 +413,8 @@ program
401
413
  if (opts.enforce !== false || opts.enforceStrict) {
402
414
  const strict = !!opts.enforceStrict;
403
415
  const p = installPreCommitHook(root, inv.shell, strict);
404
- 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);
405
418
  }
406
419
  }
407
420
  else {
@@ -473,7 +486,10 @@ program
473
486
  .action((opts) => {
474
487
  const { store, root } = storeFor();
475
488
  store.json.ensureDirs();
476
- 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 });
477
493
  // The post-merge hook only ever got installed by `hunch init`/`hunch
478
494
  // private`/`hunch shared` — a repo that already ran init before this hook
479
495
  // existed never receives it. `hunch index` already self-heals gitignore
@@ -481,8 +497,11 @@ program
481
497
  // re-running init by hand. Gated on already having post-commit: `index`
482
498
  // is not a setup command (it runs in CI, on any git repo), so it must
483
499
  // never be what FIRST hooks a repo that never ran init at all.
484
- if (isGitRepo(root) && hookStatus(root).postCommit)
485
- installPostMergeHook(root, resolveInvocation().shell);
500
+ if (isGitRepo(root) && hookStatus(root).postCommit) {
501
+ const inv = resolveInvocation().shell;
502
+ installPostMergeHook(root, inv);
503
+ installPostCheckoutHook(root, inv); // same upgrade path for the workspace-ledger hook
504
+ }
486
505
  const res = indexRepo(store, root, { requireClean: true });
487
506
  const { counts } = store.reindex();
488
507
  const correctionSweep = new ConstitutionService(store, root).upgradeCorrections();
@@ -707,7 +726,7 @@ program
707
726
  const sweep = constitution.g2ShadowSweep();
708
727
  g2Recorded = sweep.recorded.length;
709
728
  if (!opts.quiet) {
710
- console.log(` ↳ G2 shadow: ${sweep.recorded.length} recorded · ${sweep.existing.length} existing · ${sweep.failures.length} failed; authority none`);
729
+ console.log(` ↳ G2 shadow: ${sweep.recorded.length} recorded · ${sweep.existing.length} existing · ${sweep.failures.length} failed${sweep.retired.length ? ` · ${sweep.retired.length} retired (skipped)` : ""}; authority none`);
711
730
  }
712
731
  }
713
732
  }
@@ -1242,9 +1261,12 @@ function configureOverlay(dir, opts, mode) {
1242
1261
  if (opts.hook && isGitRepo(root)) {
1243
1262
  freshSetup?.markHookWrite();
1244
1263
  const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
1245
- 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)" : ""}`));
1246
1266
  const pm = installPostMergeHook(root, inv.shell);
1247
- 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)"));
1268
+ const pc = installPostCheckoutHook(root, inv.shell);
1269
+ hookNote += note(formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: this machine's branches + worktrees sync through the overlay)"));
1248
1270
  }
1249
1271
  // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
1250
1272
  // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
@@ -1304,6 +1326,13 @@ function configureOverlay(dir, opts, mode) {
1304
1326
  ` next: review, then commit the PUBLIC repo:\n` +
1305
1327
  ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
1306
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
+ }
1307
1336
  const lead = mode === "private"
1308
1337
  ? `✓ private overlay enabled → ${hunchDir}\n`
1309
1338
  : `✓ shared overlay enabled → ${hunchDir}\n`;
@@ -1409,11 +1438,220 @@ program
1409
1438
  openStore = null;
1410
1439
  }
1411
1440
  }
1441
+ // 4) the workspace ledger: the new worktree is exactly what other machines want to know about.
1442
+ let ledgerNote = "";
1443
+ try {
1444
+ const lstore = openTeamStore(root).store;
1445
+ try {
1446
+ const out = recordWorkspaceSnapshot(lstore, root);
1447
+ if (out.status === "written")
1448
+ ledgerNote = `\n ✓ workspace ledger updated (${out.record.worktrees.length} worktree(s) on this machine → ${out.home === "private" ? "overlay" : "public .hunch/"})`;
1449
+ }
1450
+ finally {
1451
+ lstore.close();
1452
+ openStore = null;
1453
+ }
1454
+ }
1455
+ catch { /* the ledger is a side effect; the worktree itself is what this command promised */ }
1412
1456
  console.log(`✓ worktree created → ${dest}${opts.branch ? ` (new branch ${opts.branch})` : ""}\n` +
1413
- `${shareNote}${indexNote}\n` +
1457
+ `${shareNote}${indexNote}${ledgerNote}\n` +
1414
1458
  ` hooks + MCP server are shared (worktree-aware) — open your assistant in the new worktree to start.\n` +
1415
1459
  ` (needs \`hunch\` installed globally; a worktree has no node_modules of its own)`);
1416
1460
  });
1461
+ // ---- workspaces / branches (workspace ledger — docs/workspace-ledger.md) ---------------
1462
+ // One shared code path (src/integrations/workspaceLedger.ts): this machine is always read
1463
+ // LIVE from git, other machines from the store, and stored records are display-only.
1464
+ const workspacesCmd = program
1465
+ .command("workspaces")
1466
+ .description("Workspace ledger: which worktrees are open on which machine (this machine live, other machines from memory). Read-only unless you run `snapshot`.");
1467
+ workspacesCmd
1468
+ .command("list", { isDefault: true })
1469
+ .description("Every worktree across machines: branch, dirty, last commit, when the machine last reported.")
1470
+ .option("--machine <label>", "only this machine")
1471
+ .option("--branch <name>", "only worktrees on this branch")
1472
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1473
+ .option("--json", "emit the rows as JSON")
1474
+ .action((opts) => {
1475
+ const { store, root } = storeFor();
1476
+ try {
1477
+ if (!isGitRepo(root))
1478
+ return fail("`hunch workspaces` needs a git repo");
1479
+ const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1480
+ let rows = worktreeRows(view.records, { staleAfterDays: view.config.stale_after_days });
1481
+ if (opts.machine)
1482
+ rows = rows.filter((r) => r.machine === opts.machine);
1483
+ if (opts.branch)
1484
+ rows = rows.filter((r) => r.branch === opts.branch);
1485
+ if (opts.json)
1486
+ return console.log(JSON.stringify({ machine: view.machine.label, worktrees: rows }, null, 2));
1487
+ console.log(renderWorktreeTable(view, rows));
1488
+ }
1489
+ finally {
1490
+ store.close();
1491
+ }
1492
+ });
1493
+ workspacesCmd
1494
+ .command("snapshot")
1495
+ .description("Record this machine's worktrees and branches into memory (the overlay when one is configured). Offline unless --fetch. Also run by the post-checkout / post-commit hooks and at MCP session start.")
1496
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1497
+ .option("--dry-run", "print the record that WOULD be written and write nothing")
1498
+ .option("--json", "print the record as JSON")
1499
+ .option("--quiet", "no output on success (for hooks)")
1500
+ .action((opts) => {
1501
+ const { store, root } = storeFor();
1502
+ try {
1503
+ if (!isGitRepo(root))
1504
+ return fail("`hunch workspaces snapshot` needs a git repo");
1505
+ const out = recordWorkspaceSnapshot(store, root, { fetch: opts.fetch, dryRun: opts.dryRun });
1506
+ if (out.status !== "off" && (opts.dryRun || opts.json))
1507
+ console.log(JSON.stringify(out.record, null, 2));
1508
+ if (opts.quiet || out.status === "dry-run")
1509
+ return;
1510
+ switch (out.status) {
1511
+ case "off":
1512
+ return console.log("workspaces.publish is \"off\" in .hunch/config.json — nothing recorded.");
1513
+ case "no-home":
1514
+ console.log("No memory overlay is configured, so this machine's record is not written (queries read this machine live).");
1515
+ console.log(" · run `hunch private` or `hunch shared --repo <url>` to sync workspaces across machines");
1516
+ return console.log(" · or set .hunch/config.json {\"workspaces\":{\"publish_public\":true}} to commit it into this repo's .hunch/");
1517
+ case "unchanged":
1518
+ return console.log(`✓ unchanged since ${out.previous.observed_at} (${out.record.id}) — nothing written`);
1519
+ case "collision":
1520
+ return fail(`not written: ${out.reason}\n · \`hunch workspaces forget ${out.record.id}\` removes the stale copy (a normal, revertable memory move), then snapshot again`);
1521
+ case "written":
1522
+ return console.log(`✓ recorded ${out.record.worktrees.length} worktree(s), ${out.record.branches.length} branch(es) as ${out.record.machine.label} (${out.record.id}, publish=${out.record.publish}) → ${out.home === "private" ? "overlay" : "public .hunch/"}${out.flushed ? `, ${out.flushed}` : ""}`);
1523
+ }
1524
+ }
1525
+ finally {
1526
+ store.close();
1527
+ }
1528
+ });
1529
+ workspacesCmd
1530
+ .command("prune")
1531
+ .description("Branches and worktrees that are provably merged and safe to delete. Prints the exact git commands per machine (dry run). --apply runs them on THIS machine only — never a remote, never another machine — from a live snapshot, with `git branch -d` / `git worktree remove` (no force flags), after confirmation.")
1532
+ .option("--apply", "execute this machine's commands (asks for confirmation; --yes in a non-interactive shell)")
1533
+ .option("--yes", "skip the confirmation prompt (required with --apply when stdin is not a terminal)")
1534
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1535
+ .option("--json", "emit the plan (and results) as JSON")
1536
+ .action(async (opts) => {
1537
+ const { store, root } = storeFor();
1538
+ try {
1539
+ if (!isGitRepo(root))
1540
+ return fail("`hunch workspaces prune` needs a git repo");
1541
+ const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1542
+ const plan = prunePlanFor(view, root);
1543
+ if (!opts.apply) {
1544
+ if (opts.json)
1545
+ return console.log(JSON.stringify({ machine: view.machine.label, plan }, null, 2));
1546
+ console.log(renderPrunePlan(view, plan));
1547
+ return console.log(plan.local.length ? "\n(dry run — `hunch workspaces prune --apply` runs this machine's commands after confirmation)" : "\n(dry run — nothing to apply on this machine)");
1548
+ }
1549
+ if (!plan.local.length) {
1550
+ if (opts.json)
1551
+ return console.log(JSON.stringify({ machine: view.machine.label, plan, results: [] }, null, 2));
1552
+ console.log(renderPrunePlan(view, plan));
1553
+ return console.log("\nnothing to apply on this machine");
1554
+ }
1555
+ if (!opts.json)
1556
+ console.log(renderPrunePlan(view, plan));
1557
+ if (!opts.yes) {
1558
+ const ok = await confirmPrune(pruneConfirmQuestion(view, plan));
1559
+ if (!ok)
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");
1561
+ }
1562
+ const results = applyPrune(root, plan.local);
1563
+ // Memory follows what actually happened: re-snapshot so other machines see the change.
1564
+ // Best effort — the deletions above are real whatever the ledger write says.
1565
+ let recorded;
1566
+ try {
1567
+ recorded = recordWorkspaceSnapshot(store, root).status;
1568
+ }
1569
+ catch (error) {
1570
+ recorded = `failed: ${error.message}`;
1571
+ }
1572
+ if (opts.json)
1573
+ return console.log(JSON.stringify({ machine: view.machine.label, plan, results, recorded }, null, 2));
1574
+ console.log("");
1575
+ for (const r of results)
1576
+ console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
1577
+ const failed = results.filter((r) => r.outcome === "failed").length;
1578
+ const skipped = results.filter((r) => r.outcome === "skipped").length;
1579
+ const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
1580
+ console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
1581
+ if (failed || skipped)
1582
+ process.exitCode = 1;
1583
+ }
1584
+ finally {
1585
+ store.close();
1586
+ }
1587
+ });
1588
+ workspacesCmd
1589
+ .command("label [label]")
1590
+ .description("Show or set this machine's label (what other machines see). Defaults to machine-<id>; never the hostname.")
1591
+ .action((label) => {
1592
+ const identity = label ? setMachineLabel(label) : loadOrCreateMachine();
1593
+ console.log(`${identity.label} (${identity.id}, ${machineFile()})`);
1594
+ const leak = labelLeaksIdentity(identity.label);
1595
+ if (leak)
1596
+ console.log(` ⚠ the label equals this machine's ${leak}; in a shared store every teammate sees it`);
1597
+ });
1598
+ workspacesCmd
1599
+ .command("forget <machine>")
1600
+ .description("Remove a machine's stored workspace record (by label or id) — e.g. a retired laptop. A memory move like any other: revertable via `hunch log`.")
1601
+ .action((machine) => {
1602
+ const { store, root } = storeFor();
1603
+ try {
1604
+ const victims = store.recs("workspaces").filter((r) => r.machine.label === machine || r.machine.id === machine || r.id === machine);
1605
+ if (!victims.length)
1606
+ return fail(`no workspace record for "${machine}" — \`hunch workspaces\` lists the machines in memory`);
1607
+ // Decide each record's home BEFORE deleting it, then flush exactly those homes.
1608
+ const homes = victims.map((v) => store.getPrivateRec("workspaces", v.id) ? "private" : "public");
1609
+ for (const v of victims)
1610
+ store.deleteWhereItLives("workspaces", v.id);
1611
+ pumpMemoryHomes(store, root, homes, `hunch: forget workspace ${machine}`);
1612
+ console.log(`✓ forgot ${victims.length} record(s) for ${machine}`);
1613
+ }
1614
+ finally {
1615
+ store.close();
1616
+ }
1617
+ });
1618
+ program
1619
+ .command("branches")
1620
+ .description("Every local branch across machines with a deterministic verdict: merged (ancestry / squash / rebase), pushed, dirty worktree, and a recommended action. Read-only; never deletes anything.")
1621
+ .option("--merged", "only branches proven merged")
1622
+ .option("--unpushed", "only branches with no upstream")
1623
+ .option("--stale <days>", "only branches whose last commit is older than N days")
1624
+ .option("--machine <label>", "only branches present on this machine")
1625
+ .option("--fetch", "run `git fetch --prune` first (network; off by default)")
1626
+ .option("--json", "emit the rows as JSON")
1627
+ .action((opts) => {
1628
+ const { store, root } = storeFor();
1629
+ try {
1630
+ if (!isGitRepo(root))
1631
+ return fail("`hunch branches` needs a git repo");
1632
+ const view = workspaceLedgerView(store, root, { fetch: opts.fetch });
1633
+ const now = new Date();
1634
+ let rows = branchRows(view.records, { staleAfterDays: view.config.stale_after_days, now });
1635
+ if (opts.merged)
1636
+ rows = rows.filter((r) => r.merged.status === "merged");
1637
+ if (opts.unpushed)
1638
+ rows = rows.filter((r) => r.upstream === null);
1639
+ if (opts.machine)
1640
+ rows = rows.filter((r) => r.machines.includes(opts.machine));
1641
+ if (opts.stale) {
1642
+ const days = Number(opts.stale);
1643
+ if (!Number.isFinite(days) || days < 0)
1644
+ return fail("--stale takes a number of days");
1645
+ rows = rows.filter((r) => !r.last_commit_at || now.getTime() - Date.parse(r.last_commit_at) > days * 86_400_000);
1646
+ }
1647
+ if (opts.json)
1648
+ return console.log(JSON.stringify({ machine: view.machine.label, branches: rows }, null, 2));
1649
+ console.log(renderBranchTable(view, rows));
1650
+ }
1651
+ finally {
1652
+ store.close();
1653
+ }
1654
+ });
1417
1655
  // ---- query ----------------------------------------------------------------
1418
1656
  program
1419
1657
  .command("query")
@@ -3806,14 +4044,17 @@ program
3806
4044
  // vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
3807
4045
  // such a PR enumerates zero files) — including a deletion of the very symbol a
3808
4046
  // blocking conformance predicate or an active policy guards.
3809
- const diff = files.length
3810
- ? (exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root))
3811
- : "";
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: "" };
3812
4052
  const report = files.length
3813
- ? store.buildCheckReport(files, diff, {
4053
+ ? store.buildCheckReport(files, gate.diff, {
3814
4054
  strict: !!opts.strict,
3815
4055
  lastChange: (f) => lastChangeDate(f, root),
3816
4056
  publicOnly: !!opts.publicOnly,
4057
+ diffStatus: gate,
3817
4058
  })
3818
4059
  : emptyReport;
3819
4060
  if (opts.blast && !markdown && !sarif && files.length) {
@@ -3951,8 +4192,8 @@ const vetoCmd = program
3951
4192
  store.close();
3952
4193
  return;
3953
4194
  }
3954
- const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
3955
- 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 });
3956
4197
  if (!full.vetoes.length) {
3957
4198
  console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
3958
4199
  store.close();
@@ -4513,11 +4754,14 @@ program
4513
4754
  const before = st;
4514
4755
  let activity = null;
4515
4756
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
4516
- const p = evt.tool_input?.file_path;
4517
- 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)
4518
4762
  st = onEdit(st, toRepoRel(root, p));
4763
+ if (edited.length)
4519
4764
  activity = { kind: "edit" };
4520
- }
4521
4765
  }
4522
4766
  else if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
4523
4767
  const command = String(evt.tool_input?.command ?? "");
@@ -4827,18 +5071,16 @@ program
4827
5071
  }
4828
5072
  if (evt.hook_event_name !== "PreToolUse")
4829
5073
  return;
4830
- const abs = evt.tool_input?.file_path;
4831
- if (!abs)
4832
- return;
4833
- const target = toRepoRel(root, abs);
4834
- // Outside the repo (".." prefix) or on another drive (absolute, e.g. "D:/…")
4835
- // → nothing for Hunch to say.
4836
- 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)
4837
5077
  return;
5078
+ // Grounding below stays about the first in-repo file; the strict gate checks every target.
5079
+ const { abs, target } = targets[0];
4838
5080
  // A compiled red→green probe is only meaningful if its red receipt exists
4839
5081
  // before implementation. Firm/strict may deny two edits per prompt, then
4840
5082
  // fail open so a malformed or unavailable probe can never deadlock work.
4841
- 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))) {
4842
5084
  const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
4843
5085
  if (baseline.block) {
4844
5086
  savePipelineState(evt.session_id, baseline.state);
@@ -4870,23 +5112,28 @@ program
4870
5112
  store.reindex();
4871
5113
  // The lines this edit would ADD — so a content-matched invariant denies only
4872
5114
  // when the edit actually trips it (not on every edit in scope), and the Veto
4873
- // Guard can test the proposed text. Covers Edit/Write/MultiEdit.
4874
- const proposedLines = proposedEditLines(evt.tool_input);
4875
- const deny = blockingInScope(store, target, proposedLines);
4876
- if (deny) {
4877
- appendEvent(paths, { at: new Date().toISOString(), file: target, ...deny.event });
4878
- emitDeny(provider, deny.reason);
4879
- observeHookDenial(root, provider, evt, target, deny);
4880
- 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 });
4881
5125
  }
4882
- // Veto Guard (live): the proposed edit text re-introduces an approach an
4883
- // in-force decision REJECTED. The agent self-corrects before staging;
4884
- // only human-confirmed tripwires deny.
4885
- const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
4886
- if (vetoDeny) {
4887
- appendEvent(paths, { at: new Date().toISOString(), file: target, ...vetoDeny.event });
4888
- emitDeny(provider, vetoDeny.reason);
4889
- 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);
4890
5137
  return;
4891
5138
  }
4892
5139
  }
@@ -6343,8 +6590,8 @@ program
6343
6590
  console.log(`No changed files in ${scope}.`);
6344
6591
  return;
6345
6592
  }
6346
- const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
6347
- 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));
6348
6595
  }
6349
6596
  finally {
6350
6597
  store.close();
@@ -6502,6 +6749,10 @@ program
6502
6749
  console.log(`\n📊 ${rankingStatusLine(resolveTaskRankingMode(store.publicRoot, store))}`);
6503
6750
  }
6504
6751
  catch { /* no task records or no cache dir: nothing to say */ }
6752
+ // Workspace ledger, from stored records only (no git) so the hot view stays fast.
6753
+ const ws = workspaceSummaryLine(opts.private ? store.recs("workspaces") : store.json.loadAll("workspaces"), workspacesConfig(readConfig(hunchPaths(store.publicRoot))));
6754
+ if (ws)
6755
+ console.log(`\n${ws}`);
6505
6756
  }
6506
6757
  finally {
6507
6758
  store.close();
@@ -6760,16 +7011,43 @@ program
6760
7011
  // squash-merge went unrepaired. pre-commit is opt-out (`--no-enforce`),
6761
7012
  // so its absence is informational only, never a warning.
6762
7013
  if (isGitRepo(root)) {
7014
+ const report = hookReport(root);
6763
7015
  const hooks = hookStatus(root);
6764
- 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");
6765
7021
  // `hunch index` only ever installs post-merge onto an existing
6766
7022
  // post-commit install (it never hooks an un-hooked repo — see
6767
- // isGitRepo(root) && hookStatus(root).postCommit above) so the fix
6768
- // hint must not point there when post-commit itself is missing.
6769
- const fix = !hooks.postCommit ? "run `hunch init` for the full setup" : "run `hunch index` to install it";
6770
- console.log(`hooks: ${missing.length
6771
- ? `⚠ 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 ")
6772
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
+ }
7040
+ // Workspace ledger: what this machine is called, whether its record is in memory,
7041
+ // and whether the checkout hook that keeps it fresh is installed.
7042
+ try {
7043
+ const machine = loadOrCreateMachine();
7044
+ const stored = store.recs("workspaces").find((r) => r.machine.id === machine.id);
7045
+ const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
7046
+ const leak = labelLeaksIdentity(machine.label);
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)` +
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)" : ""}`);
7049
+ }
7050
+ catch { /* no machine file writable: nothing to report */ }
6773
7051
  }
6774
7052
  // In unified mode the public .hunch directory is only a routing shell.
6775
7053
  // Report the same effective manifest that `hunch migrate` reads and stamps,
@@ -6935,6 +7213,28 @@ function realpathNorm(p) {
6935
7213
  function toRepoRel(root, abs) {
6936
7214
  return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
6937
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
+ }
6938
7238
  function emitContext(provider, event, text,
6939
7239
  /** One user-facing line where the host shows hook messages (Claude Code's
6940
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.`);
package/dist/cli/serve.js CHANGED
@@ -35,6 +35,7 @@ export function registerServeCommands(program) {
35
35
  app.listen(port, "127.0.0.1", () => {
36
36
  console.log(`hunch ${HUNCH_VERSION} serving nuryel.state/1 on http://127.0.0.1:${port} — ${config.partitions.map((p) => scopePath(p.scope)).join(", ")} (${config.principals.length} principal(s))`);
37
37
  console.log(`Shared state view: http://127.0.0.1:${port}/operator`);
38
+ console.log(`MCP (streamable HTTP): http://127.0.0.1:${port}/nuryel/v1/mcp`);
38
39
  });
39
40
  const stop = () => { app.close(() => { app.closeStores(); process.exit(0); }); };
40
41
  process.on("SIGINT", stop);