@davesheffer/hunch 1.39.2 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
package/dist/cli/index.js CHANGED
@@ -14,13 +14,13 @@
14
14
  * doctor environment diagnostics
15
15
  */
16
16
  import "./preflight.js"; // MUST stay the first import — Node-version gate before node:sqlite loads
17
- import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
17
+ import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
18
18
  import { execFileSync, spawnSync } from "node:child_process";
19
19
  import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
20
20
  import { tmpdir } from "node:os";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { Command } from "commander";
23
- import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir } from "../core/paths.js";
23
+ import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
24
24
  import { writeFileAtomic } from "../core/io.js";
25
25
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
26
26
  import { HUNCH_VERSION } from "../core/version.js";
@@ -36,7 +36,10 @@ import { publishedStatus } from "../integrations/registry.js";
36
36
  import { HunchStore } from "../store/hunchStore.js";
37
37
  import { JsonStore } from "../store/jsonStore.js";
38
38
  import { selectEmbedder } from "../store/embedder.js";
39
- import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
39
+ import { assertCompleteRepoScan, indexRepo, mergeScannedEdges, scanRepo } from "../extractors/indexer.js";
40
+ // Importing this module is free (the addon load is a function call, not a
41
+ // top-level side effect); only `doctor` below actually calls the loader.
42
+ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTreeSitter.js";
40
43
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
41
44
  import { parseTestReport } from "../extractors/testreport.js";
42
45
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
@@ -52,7 +55,7 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
52
55
  import { extractInlineIntent } from "../extractors/comments.js";
53
56
  import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
54
57
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
55
- import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, formatHookInstall } from "../integrations/hooks.js";
58
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall, sharedHooksNote } from "../integrations/hooks.js";
56
59
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
57
60
  import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
58
61
  import { installMergeDriver } from "../integrations/mergeDriver.js";
@@ -72,7 +75,7 @@ import { rankingStatusLine, resolveTaskRankingMode } from "../core/taskRankingMo
72
75
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
73
76
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
74
77
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
75
- import { buildDeliveryEnvelope, DELIVERY_PROFILES } from "../core/delivery.js";
78
+ import { buildDeliveryEnvelope, deliveryDedupeInput, DELIVERY_PROFILES } from "../core/delivery.js";
76
79
  import { deriveChangeIdentity } from "../core/changeIdentity.js";
77
80
  import { deriveChangeProof } from "../core/changeProof.js";
78
81
  import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
@@ -80,7 +83,7 @@ import { diffProjectDna } from "../core/projectDnaDelta.js";
80
83
  import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
81
84
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
82
85
  import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
83
- import { worktreeRows, branchRows } from "../core/workspace.js";
86
+ import { worktreeRows, branchRows, publicWorkspaceRemovalRecipe } from "../core/workspace.js";
84
87
  import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune, pruneConfirmQuestion } from "../integrations/workspaceLedger.js";
85
88
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
86
89
  import { isHumanConfirmed } from "../core/strictgate.js";
@@ -88,7 +91,7 @@ import { appendEvent, readEvents } from "../core/events.js";
88
91
  import { computeStats, formatStats } from "../core/stats.js";
89
92
  import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
90
93
  import { recordServed, servedSummary } from "../core/served.js";
91
- import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
94
+ import { recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
92
95
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
93
96
  import { renderRecalledLine } from "../core/taskReportRender.js";
94
97
  import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
@@ -363,7 +366,7 @@ program
363
366
  // any mutator. With no commit identity there is no safe public source,
364
367
  // so leave no source-derived records behind for a future pump.
365
368
  store.json.replaceAll("symbols", []);
366
- store.json.replaceAll("edges", []);
369
+ store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
367
370
  store.json.replaceAll("components", []);
368
371
  store.reindex();
369
372
  console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
@@ -395,15 +398,21 @@ program
395
398
  console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
396
399
  }
397
400
  }
401
+ // Collected so the linked-worktree note below can say what actually happened
402
+ // to the SHARED hooks dir (issue #316) rather than assuming.
403
+ const installs = [];
398
404
  if (isGitRepo(root)) {
399
405
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
400
406
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
407
+ installs.push(h);
401
408
  for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`))
402
409
  console.log(line);
403
410
  const pm = installPostMergeHook(root, inv.shell);
411
+ installs.push(pm);
404
412
  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
413
  console.log(line);
406
414
  const pc = installPostCheckoutHook(root, inv.shell);
415
+ installs.push(pc);
407
416
  for (const line of formatHookInstall(root, "post-checkout hook", pc, " (workspace ledger: records this machine's branches + worktrees on checkout)"))
408
417
  console.log(line);
409
418
  const m = installMergeDriver(root, inv.shell);
@@ -414,6 +423,7 @@ program
414
423
  if (opts.enforce !== false || opts.enforceStrict) {
415
424
  const strict = !!opts.enforceStrict;
416
425
  const p = installPreCommitHook(root, inv.shell, strict);
426
+ installs.push(p);
417
427
  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
428
  console.log(line);
419
429
  }
@@ -469,9 +479,8 @@ program
469
479
  if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
470
480
  console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
471
481
  }
472
- if (isLinkedWorktree(root)) {
473
- console.log(` ✓ linked worktree — sharing the repo's hooks + memory (no separate setup needed)`);
474
- }
482
+ for (const line of sharedHooksNote(root, installs))
483
+ console.log(line);
475
484
  store.close();
476
485
  console.log("\n" + formatIntegrationHealth(inspectIntegrations(root)));
477
486
  console.log("\nTask reporting is configured through Hunch's agent instructions; actual agent activity has not been verified by setup. Reconnect the agent, then work normally. Completed task reports are available with `hunch report`.");
@@ -497,8 +506,10 @@ program
497
506
  // the same way; do the same for the hook so an upgrade doesn't require
498
507
  // re-running init by hand. Gated on already having post-commit: `index`
499
508
  // is not a setup command (it runs in CI, on any git repo), so it must
500
- // never be what FIRST hooks a repo that never ran init at all.
501
- if (isGitRepo(root) && hookStatus(root).postCommit) {
509
+ // never be what FIRST hooks a repo that never ran init at all. A STALE
510
+ // post-commit block counts too (issue #315): it proves init ran, so the
511
+ // missing post-merge/post-checkout hooks are still this repo's to heal.
512
+ if (isGitRepo(root) && ["installed", "stale"].includes(hookReport(root).postCommit.state)) {
502
513
  const inv = resolveInvocation().shell;
503
514
  installPostMergeHook(root, inv);
504
515
  installPostCheckoutHook(root, inv); // same upgrade path for the workspace-ledger hook
@@ -1434,6 +1445,20 @@ program
1434
1445
  indexNote = `\n ✓ code graph present in the checkout — blast-radius ready`;
1435
1446
  }
1436
1447
  }
1448
+ catch (error) {
1449
+ // The code graph is this command's CONVENIENCE; the worktree + branch
1450
+ // are its promise, and git has already created them. A dead native
1451
+ // parser (loaded on first parse, so it surfaces here rather than at
1452
+ // import) used to abort between the two: no success line, the ledger
1453
+ // step skipped, and a re-run then hitting "path already exists" with no
1454
+ // way forward. Downgrade it to the same note channel the other optional
1455
+ // steps use — the worktree stands, the user is told the index was
1456
+ // skipped and why, and `hunch index` there fixes it once TMPDIR/the
1457
+ // install is sound. Anything else is still a real failure of this step.
1458
+ if (!isParserLoadError(error))
1459
+ throw error;
1460
+ indexNote = `\n · code graph skipped — ${error.message}\n (the worktree is ready; run \`hunch index\` there once the parser loads)`;
1461
+ }
1437
1462
  finally {
1438
1463
  wstore.close();
1439
1464
  openStore = null;
@@ -1517,8 +1542,15 @@ workspacesCmd
1517
1542
  return console.log(" · or set .hunch/config.json {\"workspaces\":{\"publish_public\":true}} to commit it into this repo's .hunch/");
1518
1543
  case "unchanged":
1519
1544
  return console.log(`✓ unchanged since ${out.previous.observed_at} (${out.record.id}) — nothing written`);
1545
+ case "deferred":
1546
+ return console.log(out.reason === "detached-head"
1547
+ ? "deferred: HEAD is detached — the next checkout or ledger read records this machine"
1548
+ : "deferred: a git operation is in progress — the next checkout or ledger read records this machine");
1520
1549
  case "collision":
1521
- 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`);
1550
+ // The capture is routed to the overlay, so the only home it can collide with is
1551
+ // this repo's committed `.hunch/`. `forget` refuses that record (an additive pump
1552
+ // never stages a deletion), so the recipe here is the manual git removal.
1553
+ return fail(`not written: ${out.reason}\n the stale copy lives in this repo's .hunch/ — remove it by hand, then snapshot again:\n${publicWorkspaceRemovalRecipe(out.record.id, out.record.machine.label)}`);
1522
1554
  case "written":
1523
1555
  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}` : ""}`);
1524
1556
  }
@@ -1577,7 +1609,7 @@ workspacesCmd
1577
1609
  console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
1578
1610
  const failed = results.filter((r) => r.outcome === "failed").length;
1579
1611
  const skipped = results.filter((r) => r.outcome === "skipped").length;
1580
- const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
1612
+ const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : recorded === "deferred" ? " · ledger deferred (git operation in progress or detached HEAD)" : ` · ledger not updated (${recorded})`;
1581
1613
  console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
1582
1614
  if (failed || skipped)
1583
1615
  process.exitCode = 1;
@@ -1605,12 +1637,25 @@ workspacesCmd
1605
1637
  const victims = store.recs("workspaces").filter((r) => r.machine.label === machine || r.machine.id === machine || r.id === machine);
1606
1638
  if (!victims.length)
1607
1639
  return fail(`no workspace record for "${machine}" — \`hunch workspaces\` lists the machines in memory`);
1608
- // Decide each record's home BEFORE deleting it, then flush exactly those homes.
1609
- const homes = victims.map((v) => store.getPrivateRec("workspaces", v.id) ? "private" : "public");
1610
- for (const v of victims)
1640
+ // Decide each record's home BEFORE deleting anything, then flush exactly those homes.
1641
+ // A PUBLIC record (workspaces.publish_public) is refused rather than deleted: the
1642
+ // memory pump never stages a tracked deletion, so removing the file here would strand
1643
+ // `D .hunch/workspaces/<id>.json` and wedge every later auto-commit — see
1644
+ // publicWorkspaceRemovalRecipe.
1645
+ const overlay = victims.filter((v) => store.getPrivateRec("workspaces", v.id));
1646
+ const refused = victims.filter((v) => !store.getPrivateRec("workspaces", v.id));
1647
+ for (const v of overlay)
1611
1648
  store.deleteWhereItLives("workspaces", v.id);
1612
- pumpMemoryHomes(store, root, homes, `hunch: forget workspace ${machine}`);
1613
- console.log(`✓ forgot ${victims.length} record(s) for ${machine}`);
1649
+ if (overlay.length) {
1650
+ pumpMemoryHome(store, root, "private", `hunch: forget workspace ${machine}`);
1651
+ console.log(`✓ forgot ${overlay.length} record(s) for ${machine}`);
1652
+ }
1653
+ for (const v of refused) {
1654
+ console.log(`refused: ${v.id} (${v.machine.label}) lives in this repo's .hunch/ — Hunch publication is additive and never stages a deletion, so it is removed by hand:\n`
1655
+ + publicWorkspaceRemovalRecipe(v.id, v.machine.label));
1656
+ }
1657
+ if (refused.length && !overlay.length)
1658
+ process.exitCode = 1;
1614
1659
  }
1615
1660
  finally {
1616
1661
  store.close();
@@ -4502,18 +4547,26 @@ program
4502
4547
  .requiredOption("--by <new>", "decision id that supersedes it")
4503
4548
  .action((oldId, opts) => {
4504
4549
  const { store, root } = storeFor();
4505
- const by = store.json.get("decisions", opts.by);
4550
+ // `old`'s home decides which store the close is written to; `--by` must resolve in
4551
+ // that SAME store, or a private `by` linked against a public `old` would write the
4552
+ // private id straight into the committed public store.
4553
+ const home = decisionMemoryHome(store, oldId);
4554
+ if (!store.decisionInStore(oldId, home === "private")) {
4555
+ store.close();
4556
+ return fail(`decision "${oldId}" not found`);
4557
+ }
4558
+ const by = store.decisionInStore(opts.by, home === "private");
4506
4559
  if (!by) {
4507
4560
  store.close();
4508
- return fail(`--by decision "${opts.by}" not found`);
4561
+ return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
4509
4562
  }
4510
- const closed = store.supersede(oldId, by);
4563
+ const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4511
4564
  if (!closed) {
4512
4565
  store.close();
4513
- return fail(`decision "${oldId}" not found (or same as --by)`);
4566
+ return fail(`decision "${oldId}" cannot supersede itself`);
4514
4567
  }
4515
4568
  store.reindex();
4516
- pumpMemoryHome(store, root, "public", `hunch: supersede ${oldId} by ${opts.by}`);
4569
+ pumpMemoryHome(store, root, home, `hunch: supersede ${oldId} by ${opts.by}`);
4517
4570
  console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
4518
4571
  store.close();
4519
4572
  });
@@ -4757,10 +4810,12 @@ program
4757
4810
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
4758
4811
  // A Codex apply_patch touches every file it lists (and each Move-to
4759
4812
  // 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)
4763
- st = onEdit(st, toRepoRel(root, p));
4813
+ // Same in-repo filter as the pre-edit path: a scratch file outside the
4814
+ // repository is not a product edit, and recording it would block Stop on
4815
+ // a path no check can ever verify.
4816
+ const edited = editTargets(root, evt.tool_input);
4817
+ for (const e of edited)
4818
+ st = onEdit(st, e.target);
4764
4819
  if (edited.length)
4765
4820
  activity = { kind: "edit" };
4766
4821
  }
@@ -5170,22 +5225,23 @@ program
5170
5225
  docGround;
5171
5226
  if (!hasContent)
5172
5227
  return; // no noise on files Hunch hasn't learned yet
5228
+ const supplements = [
5229
+ ...(retired.length ? [{
5230
+ id: "retired-code",
5231
+ kind: "retired-code",
5232
+ priority: 200,
5233
+ text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
5234
+ }] : []),
5235
+ ...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
5236
+ ...recentTasks,
5237
+ ];
5173
5238
  const envelope = buildDeliveryEnvelope(ctx, {
5174
5239
  profile: "builder",
5175
5240
  root,
5176
5241
  symbols: store.recs("symbols"),
5177
5242
  components: store.recs("components"),
5178
5243
  decisionCorpus: store.recs("decisions"),
5179
- supplements: [
5180
- ...(retired.length ? [{
5181
- id: "retired-code",
5182
- kind: "retired-code",
5183
- priority: 200,
5184
- text: `⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
5185
- }] : []),
5186
- ...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
5187
- ...recentTasks,
5188
- ],
5244
+ supplements,
5189
5245
  });
5190
5246
  const text = envelope.text.trim();
5191
5247
  // Identical grounding already shown this session → one-line delta instead of
@@ -5218,7 +5274,16 @@ program
5218
5274
  const reportTaskId = hookReportTaskId(root, provider, evt);
5219
5275
  // A new authoritative prompt gets its own full delivery. An earlier
5220
5276
  // prompt's session-level delta cannot establish this task's receipt.
5221
- if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}`, text) === "delta") {
5277
+ // A subagent reports to the prompt's task but starts with FRESH context:
5278
+ // it never saw that grounding, so its dedup is scoped by its own agent
5279
+ // identity (hashed — the raw agent_id is never retained in the key).
5280
+ const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
5281
+ // Dedup on the envelope's stable IDENTITY projection, never on the
5282
+ // rendered block: serving the full text writes delivery receipts, and the
5283
+ // next call's task ranking reads them back and moves the wording ("today"
5284
+ // → "delivered today"), so hashing the presentation made this grounding
5285
+ // self-invalidating and re-sent the full block for unchanged records.
5286
+ if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text, deliveryDedupeInput(envelope, supplements)) === "delta") {
5222
5287
  receipts("refreshed");
5223
5288
  emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
5224
5289
  return;
@@ -7080,9 +7145,23 @@ program
7080
7145
  // hook manager's regenerated file — issue #311) is NOT installed.
7081
7146
  const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7082
7147
  .filter(([, e]) => e.state === "unreachable");
7148
+ // Present where git runs it, but its command is not a Hunch launcher that
7149
+ // exists (hand-edited, or an absolute path gone after a reinstall) — issue #315.
7150
+ const stale = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7151
+ .filter(([, e]) => e.state === "stale");
7152
+ // A bare `hunch init` regenerates post-commit without --private/--commit
7153
+ // and pre-commit without --strict, so pointing at it without naming the
7154
+ // options that keep them silently DOWNGRADES the install. --commit is left
7155
+ // out: init's auto-commit is ON unless --no-auto-commit is passed, so a
7156
+ // bare re-run keeps it and naming it here would claim a loss that never happens.
7157
+ const carried = [...new Set(stale.flatMap(([, e]) => e.flags ?? []))].filter((f) => f !== "--commit");
7158
+ const keep = carried.map((f) => (f === "--strict" ? "--enforce-strict" : f === "--private" ? "--private-sync (or --shared-sync)" : "")).filter(Boolean);
7159
+ const staleKeep = carried.length
7160
+ ? ` — the stale block carried ${carried.join(", ")}${keep.length ? `: pass ${keep.join(", ")} or they are dropped` : ""}`
7161
+ : "";
7083
7162
  // `hunch index` only ever installs post-merge onto an existing
7084
- // post-commit install (it never hooks an un-hooked repo — see
7085
- // isGitRepo(root) && hookStatus(root).postCommit above) and never writes
7163
+ // post-commit install (it never hooks an un-hooked repo — see the
7164
+ // hookReport(root).postCommit.state gate above) and never writes
7086
7165
  // into a manager-owned hook — so the fix hint must not point there when
7087
7166
  // post-commit itself is missing or a hook manager is in play.
7088
7167
  const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
@@ -7092,6 +7171,7 @@ program
7092
7171
  const problems = [
7093
7172
  missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
7094
7173
  unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
7174
+ stale.length ? `⚠ stale: ${stale.map(([n]) => n).join(", ")} — the hook's command is not a working Hunch launcher; run \`hunch init\` to regenerate it${staleKeep}` : "",
7095
7175
  ].filter(Boolean);
7096
7176
  console.log(`hooks: ${problems.length
7097
7177
  ? problems.join("\n ")
@@ -7099,6 +7179,13 @@ program
7099
7179
  for (const [name, e] of unreachable) {
7100
7180
  console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
7101
7181
  }
7182
+ for (const [name, e] of stale) {
7183
+ console.log(dim(` ↳ ${name}: ${e.reason ?? "the block's command is not a Hunch launcher"} (${rel(root, e.path)})`));
7184
+ }
7185
+ // Where the live hooks point — informational (a hook may legitimately run
7186
+ // another install), but the fastest way to spot a stale/foreign launcher.
7187
+ for (const line of hookInvocationLines(report, resolveInvocation().shell))
7188
+ console.log(dim(` ↳ ${line}`));
7102
7189
  // Workspace ledger: what this machine is called, whether its record is in memory,
7103
7190
  // and whether the checkout hook that keeps it fresh is installed.
7104
7191
  try {
@@ -7107,7 +7194,7 @@ program
7107
7194
  const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
7108
7195
  const leak = labelLeaksIdentity(machine.label);
7109
7196
  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)` +
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)" : ""}`);
7197
+ `${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : report.postCheckout.state === "stale" ? " · ⚠ post-checkout hook stale" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
7111
7198
  }
7112
7199
  catch { /* no machine file writable: nothing to report */ }
7113
7200
  }
@@ -7133,6 +7220,20 @@ program
7133
7220
  const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
7134
7221
  if (ctxWarning)
7135
7222
  console.log(ctxWarning);
7223
+ // The native addons load on first PARSE, not at import, so no other command
7224
+ // that merely starts up proves the parser works. Doctor is the one place
7225
+ // that should pay the ~1.5s load: without this line a broken parser (an
7226
+ // unwritable TMPDIR, a missing or wrong-arch prebuild, an addon preloaded
7227
+ // past the isolation guard) is invisible until an index run refuses.
7228
+ try {
7229
+ loadNativeTreeSitter();
7230
+ console.log(`parser: native tree-sitter addons load`);
7231
+ }
7232
+ catch (e) {
7233
+ console.log(`parser: ⛔ ${e.message}`);
7234
+ console.log(dim(` no file can be parsed — \`hunch index\` refuses rather than emptying the graph`));
7235
+ process.exitCode = 1;
7236
+ }
7136
7237
  const c = store.reindex().counts;
7137
7238
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
7138
7239
  try {
@@ -7252,28 +7353,17 @@ function readStdin() {
7252
7353
  });
7253
7354
  }
7254
7355
  /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
7255
- * forward-slash globs even on Windows). */
7256
- /** realpath a path even if it doesn't exist yet (a new file an agent is about to
7257
- * Write): resolve the longest existing ancestor, then re-append the missing tail.
7258
- * Idempotent on already-resolved paths. */
7259
- function realpathNorm(p) {
7260
- try {
7261
- return realpathSync.native(p);
7262
- }
7263
- catch {
7264
- const parent = dirname(p);
7265
- if (parent === p)
7266
- return p; // hit the root; nothing more to resolve
7267
- return join(realpathNorm(parent), basename(p));
7268
- }
7269
- }
7270
- /** Repo-relative POSIX path. BOTH ends are realpath-normalized first: on macOS
7271
- * `process.cwd()` (hence findRoot) resolves /var→/private/var, but a hook event's
7272
- * file_path arrives UN-resolved — so a naive relative() yields a bogus "../" path
7273
- * under any symlinked root (/var, /tmp, symlinked $HOME) and the caller treats the
7274
- * file as outside the repo, silently dropping all context (dec_e0a36efbf5). */
7356
+ * forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
7357
+ * inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
7358
+ * which realpath-normalizes both ends first — see its docstring for why, incl.
7359
+ * dec_e0a36efbf5): that function passes an unresolvable absolute path through
7360
+ * UNCHANGED (still absolute) rather than signaling failure directly, since other
7361
+ * callers want the original target back to fail their own match safely. This
7362
+ * adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
7363
+ * and `editTargets` below already treat as "skip, not in this repo". */
7275
7364
  function toRepoRel(root, abs) {
7276
- return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
7365
+ const rel = repoRelativeTarget(abs, root);
7366
+ return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
7277
7367
  }
7278
7368
  /** The in-repo files a pre-edit event would change, each with the lines the edit
7279
7369
  * ADDS to that file. A Codex apply_patch yields one entry per touched path (a
@@ -1,2 +1,16 @@
1
1
  import type { Command } from "commander";
2
+ /** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
3
+ * partition write lock like every other writer (#286): without it a live server's append between
4
+ * the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
5
+ * in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
6
+ export declare function compactPartition(root: string, scope: {
7
+ kind: "organization" | "team" | "user" | "repository";
8
+ id: string;
9
+ }, keep: number, lockOptions?: {
10
+ timeoutMs?: number;
11
+ }): Promise<{
12
+ dropped: number;
13
+ floor_seq: number;
14
+ head_seq: number;
15
+ }>;
2
16
  export declare function registerServeCommands(program: Command): void;
package/dist/cli/serve.js CHANGED
@@ -6,9 +6,9 @@ import { initServeConfig, partitionFor, readServeConfig, writeServeConfig } from
6
6
  import { compactLedger } from "../store/changeLedger.js";
7
7
  import { HunchStore } from "../store/hunchStore.js";
8
8
  import { hunchPaths } from "../core/paths.js";
9
- import { partitionOf } from "../store/stateBinding.js";
9
+ import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
10
10
  import { formatReplayReport, verifyReplay } from "../store/replay.js";
11
- import { join } from "node:path";
11
+ import { withWriteLock } from "../serve/writelock.js";
12
12
  import { ScopeSchema, scopePath } from "../core/stateContract.js";
13
13
  import { HUNCH_VERSION } from "../core/version.js";
14
14
  function parseScopeArg(value) {
@@ -18,6 +18,20 @@ function parseScopeArg(value) {
18
18
  throw new Error(`partition must be kind:id (organization|team|user|repository), got "${value}"`);
19
19
  return parsed.data;
20
20
  }
21
+ /** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
22
+ * partition write lock like every other writer (#286): without it a live server's append between
23
+ * the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
24
+ * in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
25
+ export async function compactPartition(root, scope, keep, lockOptions = {}) {
26
+ const store = new HunchStore(hunchPaths(root));
27
+ try {
28
+ const { hunchDir } = stateHomeFor(store, scope);
29
+ return await withWriteLock(hunchDir, () => compactLedger(hunchDir, scope, { keep }), lockOptions);
30
+ }
31
+ finally {
32
+ store.close();
33
+ }
34
+ }
21
35
  export function registerServeCommands(program) {
22
36
  const DEFAULT_CONFIG = "hunch-serve.json";
23
37
  const serve = program.command("serve")
@@ -57,7 +71,7 @@ export function registerServeCommands(program) {
57
71
  .requiredOption("--partition <kind:id>", "the partition whose ledger to compact")
58
72
  .option("--keep <n>", "events to keep", "1000")
59
73
  .option("--json", "machine-readable output")
60
- .action((opts) => {
74
+ .action(async (opts) => {
61
75
  const parent = serve.opts();
62
76
  const config = readServeConfig(resolve(parent.config ?? DEFAULT_CONFIG));
63
77
  const scope = parseScopeArg(opts.partition);
@@ -67,7 +81,7 @@ export function registerServeCommands(program) {
67
81
  const keep = Number(opts.keep);
68
82
  if (!Number.isInteger(keep) || keep < 0)
69
83
  throw new Error("--keep must be a non-negative integer");
70
- const result = compactLedger(join(partition.root, ".hunch"), scope, { keep });
84
+ const result = await compactPartition(partition.root, scope, keep);
71
85
  if (opts.json) {
72
86
  console.log(JSON.stringify({ partition: scopePath(scope), ...result }));
73
87
  return;
@@ -25,7 +25,11 @@ const DIRECT_TEST_DELTA_LIMITATIONS = [
25
25
  "Direct decision candidates cover newly named node:test test()/it() cases and existing literal-named cases whose body contains an added fixing-commit line.",
26
26
  ...LIMITATIONS.slice(1),
27
27
  ];
28
- const { Parser, typescript: tsLanguage, tsx: tsxLanguage } = loadNativeTreeSitter();
28
+ /** Resolved on first parse, not at import: loading the native addons copies six
29
+ * `.node` files into a temp dir and dlopens them (~1.5s+ cold), which every CLI
30
+ * command and every editor hook would otherwise pay just for importing this
31
+ * module. loadNativeTreeSitter() memoizes the runtime itself. */
32
+ const treeSitter = () => loadNativeTreeSitter();
29
33
  function safeTestFile(file) {
30
34
  return !!file
31
35
  && !isAbsolute(file)
@@ -175,6 +179,7 @@ function literalTestName(raw) {
175
179
  return name.trim() ? name : null;
176
180
  }
177
181
  function literalNodeTestCases(file, source) {
182
+ const { Parser, typescript: tsLanguage, tsx: tsxLanguage } = treeSitter();
178
183
  const language = /\.[cm]?[jt]sx$/.test(file) && /x$/.test(file) ? tsxLanguage : tsLanguage;
179
184
  const parser = new Parser();
180
185
  parser.setLanguage(language);
@@ -5,6 +5,7 @@ import { shortHash } from "../core/ids.js";
5
5
  import { hunchPathsForDir } from "../core/paths.js";
6
6
  import { commitMeta, fixCommits } from "../extractors/git.js";
7
7
  import { indexRepo } from "../extractors/indexer.js";
8
+ import { isParserLoadError } from "../extractors/nativeTreeSitter.js";
8
9
  import { HunchStore } from "../store/hunchStore.js";
9
10
  import { canonicalHash } from "./canonical.js";
10
11
  import { extractStructuralDelta } from "./delta.js";
@@ -62,10 +63,15 @@ function privateGrounding(decisionStore, graphStore, root) {
62
63
  });
63
64
  byCommit.set(meta.sha, list);
64
65
  }
65
- catch {
66
+ catch (error) {
66
67
  // A human decision can be real while still lacking a supported structural
67
68
  // binding. It contributes no candidate attestation rather than being
68
69
  // stretched over a coincidental fact from the same commit.
70
+ // A dead native parser is not that: it makes EVERY decision look
71
+ // unbindable, so the review packet would silently downgrade exact human
72
+ // grounding to unattested coincidence. Fail instead of under-attesting.
73
+ if (isParserLoadError(error))
74
+ throw error;
69
75
  }
70
76
  }
71
77
  return byCommit;
@@ -142,6 +148,10 @@ function buildFromIndexedGraph(decisionStore, graphStore, root, opts, resolution
142
148
  }
143
149
  }
144
150
  catch (error) {
151
+ // A dead native parser would turn every commit into a per-commit failure
152
+ // entry instead of an honest abort, so it is never recorded as one.
153
+ if (isParserLoadError(error))
154
+ throw error;
145
155
  failures.push({ commit, error: error.message });
146
156
  }
147
157
  }
@@ -5,6 +5,7 @@ import { pathMatchesGlob } from "../core/glob.js";
5
5
  import { shortHash } from "../core/ids.js";
6
6
  import { resolveRelativeImport } from "../core/relativeImports.js";
7
7
  import { commitMeta } from "../extractors/git.js";
8
+ import { isParserLoadError } from "../extractors/nativeTreeSitter.js";
8
9
  import { canonicalHash } from "./canonical.js";
9
10
  import { clampCandidateLimit, durationCutoff } from "./bootstrap.js";
10
11
  import { compileStructuralPolicy } from "./compiler.js";
@@ -645,6 +646,15 @@ export function bootstrapStructuralPolicies(store, root, repository, opts = {})
645
646
  report.compiled.push({ evidence: event, policy });
646
647
  }
647
648
  catch (e) {
649
+ // "uncompilable" is a verdict ABOUT the decision, and it is written to
650
+ // evidence. A dead native parser (extractStructuralDelta parses the
651
+ // commit's sources) says nothing about the decision — recording it would
652
+ // stamp every eligible decision uncompilable with a TMPDIR error as the
653
+ // stated reason, and a later run with a working parser would only
654
+ // reclassify what `canReclassify` still allows. Fail the bootstrap
655
+ // instead, the way the import-time load did.
656
+ if (isParserLoadError(e))
657
+ throw e;
648
658
  report.uncompilable++;
649
659
  const reason = e.message;
650
660
  if (canReclassify(prior) && (prior?.compiler?.status !== "uncompilable" || prior.compiler.reason !== reason)) {
@@ -8,9 +8,9 @@
8
8
  * - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
9
9
  * scoped conservatively) that the pre-edit hook + CI guard then enforce.
10
10
  */
11
- import { isAbsolute, relative } from "node:path";
11
+ import { isAbsolute } from "node:path";
12
12
  import { constraintId } from "./ids.js";
13
- import { toPosixTarget } from "./paths.js";
13
+ import { toPosixTarget, repoRelativeTarget } from "./paths.js";
14
14
  import { deriveForbids } from "./constraintmatch.js";
15
15
  /** Correction cues. Deliberately conservative — anchored to imperative/rebuke
16
16
  * phrasing, not bare "no", so ordinary conversational negation ("no idea",
@@ -57,7 +57,13 @@ export const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correc
57
57
  * Returns "" when the hint cannot be made repo-relative (no root, or a path outside the
58
58
  * repo). The caller then falls back to "**", where the existing severity guard
59
59
  * down-ranks a non-explicit blocking rule to a warning — fail-safe and honest, rather
60
- * than a blocking rule enforced nowhere. */
60
+ * than a blocking rule enforced nowhere.
61
+ *
62
+ * Thin adapter over the shared `repoRelativeTarget` (core/paths.ts): that function
63
+ * passes an unresolvable absolute hint through UNCHANGED (still absolute) rather
64
+ * than signaling failure directly, since other callers (checkConstraints, why())
65
+ * want the original target back to fail their own match safely. This adapter
66
+ * converts that "still absolute" signal to "" — the fail-safe this caller needs. */
61
67
  function repoRelativeHint(rawHint, root) {
62
68
  if (!rawHint)
63
69
  return "";
@@ -66,10 +72,9 @@ function repoRelativeHint(rawHint, root) {
66
72
  return rawHint;
67
73
  if (!root)
68
74
  return "";
69
- const rel = toPosixTarget(relative(root, rawHint));
70
- if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
71
- return "";
72
- return rel;
75
+ const rewritten = repoRelativeTarget(rawHint, root);
76
+ const stillAbsolute = isAbsolute(rewritten) || /^[a-zA-Z]:/.test(rewritten);
77
+ return stillAbsolute ? "" : rewritten;
73
78
  }
74
79
  /**
75
80
  * Build the Constraint a correction mints. Pure (caller passes `now`), so the