@davesheffer/hunch 1.39.2 → 1.39.3

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 (46) hide show
  1. package/dist/cli/index.js +97 -50
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/core/correction.js +12 -7
  5. package/dist/core/docanchors.js +16 -2
  6. package/dist/core/glob.d.ts +19 -0
  7. package/dist/core/glob.js +37 -0
  8. package/dist/core/paths.d.ts +27 -5
  9. package/dist/core/paths.js +51 -6
  10. package/dist/core/procstart.d.ts +94 -0
  11. package/dist/core/procstart.js +244 -0
  12. package/dist/core/served.d.ts +6 -3
  13. package/dist/core/served.js +10 -6
  14. package/dist/core/taskReport.d.ts +2 -0
  15. package/dist/core/taskReport.js +46 -11
  16. package/dist/core/taskReportEvidence.js +17 -1
  17. package/dist/core/taskReportHook.js +14 -2
  18. package/dist/core/workspace.d.ts +9 -0
  19. package/dist/core/workspace.js +15 -0
  20. package/dist/extractors/git.d.ts +5 -0
  21. package/dist/extractors/git.js +87 -10
  22. package/dist/extractors/indexer.d.ts +10 -0
  23. package/dist/extractors/indexer.js +17 -1
  24. package/dist/extractors/k8sManifest.js +104 -16
  25. package/dist/integrations/hookmatch.d.ts +25 -0
  26. package/dist/integrations/hookmatch.js +58 -0
  27. package/dist/integrations/hooks.d.ts +38 -7
  28. package/dist/integrations/hooks.js +250 -24
  29. package/dist/integrations/providers.js +2 -17
  30. package/dist/integrations/scaffold.d.ts +1 -1
  31. package/dist/integrations/scaffold.js +24 -21
  32. package/dist/integrations/workspaceLedger.d.ts +10 -0
  33. package/dist/integrations/workspaceLedger.js +9 -1
  34. package/dist/mcp/server.d.ts +10 -0
  35. package/dist/mcp/server.js +21 -13
  36. package/dist/serve/writelock.d.ts +21 -1
  37. package/dist/serve/writelock.js +157 -16
  38. package/dist/store/hunchStore.js +41 -11
  39. package/dist/store/jsonStore.d.ts +6 -4
  40. package/dist/store/jsonStore.js +225 -32
  41. package/dist/store/merge.d.ts +7 -4
  42. package/dist/store/merge.js +65 -5
  43. package/dist/store/privateMigrate.js +10 -0
  44. package/dist/wiki/adopt.js +13 -2
  45. package/package.json +1 -1
  46. 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,7 @@ 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
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";
@@ -52,7 +52,7 @@ 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, hookReport, formatHookInstall } from "../integrations/hooks.js";
55
+ import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, 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";
@@ -80,7 +80,7 @@ import { diffProjectDna } from "../core/projectDnaDelta.js";
80
80
  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
- import { worktreeRows, branchRows } from "../core/workspace.js";
83
+ import { worktreeRows, branchRows, publicWorkspaceRemovalRecipe } from "../core/workspace.js";
84
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";
@@ -88,7 +88,7 @@ import { appendEvent, readEvents } from "../core/events.js";
88
88
  import { computeStats, formatStats } from "../core/stats.js";
89
89
  import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
90
90
  import { recordServed, servedSummary } from "../core/served.js";
91
- import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
91
+ import { recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
92
92
  import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
93
93
  import { renderRecalledLine } from "../core/taskReportRender.js";
94
94
  import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
@@ -363,7 +363,7 @@ program
363
363
  // any mutator. With no commit identity there is no safe public source,
364
364
  // so leave no source-derived records behind for a future pump.
365
365
  store.json.replaceAll("symbols", []);
366
- store.json.replaceAll("edges", []);
366
+ store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
367
367
  store.json.replaceAll("components", []);
368
368
  store.reindex();
369
369
  console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
@@ -497,8 +497,10 @@ program
497
497
  // the same way; do the same for the hook so an upgrade doesn't require
498
498
  // re-running init by hand. Gated on already having post-commit: `index`
499
499
  // 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) {
500
+ // never be what FIRST hooks a repo that never ran init at all. A STALE
501
+ // post-commit block counts too (issue #315): it proves init ran, so the
502
+ // missing post-merge/post-checkout hooks are still this repo's to heal.
503
+ if (isGitRepo(root) && ["installed", "stale"].includes(hookReport(root).postCommit.state)) {
502
504
  const inv = resolveInvocation().shell;
503
505
  installPostMergeHook(root, inv);
504
506
  installPostCheckoutHook(root, inv); // same upgrade path for the workspace-ledger hook
@@ -1517,8 +1519,15 @@ workspacesCmd
1517
1519
  return console.log(" · or set .hunch/config.json {\"workspaces\":{\"publish_public\":true}} to commit it into this repo's .hunch/");
1518
1520
  case "unchanged":
1519
1521
  return console.log(`✓ unchanged since ${out.previous.observed_at} (${out.record.id}) — nothing written`);
1522
+ case "deferred":
1523
+ return console.log(out.reason === "detached-head"
1524
+ ? "deferred: HEAD is detached — the next checkout or ledger read records this machine"
1525
+ : "deferred: a git operation is in progress — the next checkout or ledger read records this machine");
1520
1526
  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`);
1527
+ // The capture is routed to the overlay, so the only home it can collide with is
1528
+ // this repo's committed `.hunch/`. `forget` refuses that record (an additive pump
1529
+ // never stages a deletion), so the recipe here is the manual git removal.
1530
+ 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
1531
  case "written":
1523
1532
  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
1533
  }
@@ -1577,7 +1586,7 @@ workspacesCmd
1577
1586
  console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
1578
1587
  const failed = results.filter((r) => r.outcome === "failed").length;
1579
1588
  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})`;
1589
+ 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
1590
  console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
1582
1591
  if (failed || skipped)
1583
1592
  process.exitCode = 1;
@@ -1605,12 +1614,25 @@ workspacesCmd
1605
1614
  const victims = store.recs("workspaces").filter((r) => r.machine.label === machine || r.machine.id === machine || r.id === machine);
1606
1615
  if (!victims.length)
1607
1616
  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)
1617
+ // Decide each record's home BEFORE deleting anything, then flush exactly those homes.
1618
+ // A PUBLIC record (workspaces.publish_public) is refused rather than deleted: the
1619
+ // memory pump never stages a tracked deletion, so removing the file here would strand
1620
+ // `D .hunch/workspaces/<id>.json` and wedge every later auto-commit — see
1621
+ // publicWorkspaceRemovalRecipe.
1622
+ const overlay = victims.filter((v) => store.getPrivateRec("workspaces", v.id));
1623
+ const refused = victims.filter((v) => !store.getPrivateRec("workspaces", v.id));
1624
+ for (const v of overlay)
1611
1625
  store.deleteWhereItLives("workspaces", v.id);
1612
- pumpMemoryHomes(store, root, homes, `hunch: forget workspace ${machine}`);
1613
- console.log(`✓ forgot ${victims.length} record(s) for ${machine}`);
1626
+ if (overlay.length) {
1627
+ pumpMemoryHome(store, root, "private", `hunch: forget workspace ${machine}`);
1628
+ console.log(`✓ forgot ${overlay.length} record(s) for ${machine}`);
1629
+ }
1630
+ for (const v of refused) {
1631
+ 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`
1632
+ + publicWorkspaceRemovalRecipe(v.id, v.machine.label));
1633
+ }
1634
+ if (refused.length && !overlay.length)
1635
+ process.exitCode = 1;
1614
1636
  }
1615
1637
  finally {
1616
1638
  store.close();
@@ -4502,18 +4524,26 @@ program
4502
4524
  .requiredOption("--by <new>", "decision id that supersedes it")
4503
4525
  .action((oldId, opts) => {
4504
4526
  const { store, root } = storeFor();
4505
- const by = store.json.get("decisions", opts.by);
4527
+ // `old`'s home decides which store the close is written to; `--by` must resolve in
4528
+ // that SAME store, or a private `by` linked against a public `old` would write the
4529
+ // private id straight into the committed public store.
4530
+ const home = decisionMemoryHome(store, oldId);
4531
+ if (!store.decisionInStore(oldId, home === "private")) {
4532
+ store.close();
4533
+ return fail(`decision "${oldId}" not found`);
4534
+ }
4535
+ const by = store.decisionInStore(opts.by, home === "private");
4506
4536
  if (!by) {
4507
4537
  store.close();
4508
- return fail(`--by decision "${opts.by}" not found`);
4538
+ return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
4509
4539
  }
4510
- const closed = store.supersede(oldId, by);
4540
+ const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
4511
4541
  if (!closed) {
4512
4542
  store.close();
4513
- return fail(`decision "${oldId}" not found (or same as --by)`);
4543
+ return fail(`decision "${oldId}" cannot supersede itself`);
4514
4544
  }
4515
4545
  store.reindex();
4516
- pumpMemoryHome(store, root, "public", `hunch: supersede ${oldId} by ${opts.by}`);
4546
+ pumpMemoryHome(store, root, home, `hunch: supersede ${oldId} by ${opts.by}`);
4517
4547
  console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
4518
4548
  store.close();
4519
4549
  });
@@ -4757,10 +4787,12 @@ program
4757
4787
  if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
4758
4788
  // A Codex apply_patch touches every file it lists (and each Move-to
4759
4789
  // 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));
4790
+ // Same in-repo filter as the pre-edit path: a scratch file outside the
4791
+ // repository is not a product edit, and recording it would block Stop on
4792
+ // a path no check can ever verify.
4793
+ const edited = editTargets(root, evt.tool_input);
4794
+ for (const e of edited)
4795
+ st = onEdit(st, e.target);
4764
4796
  if (edited.length)
4765
4797
  activity = { kind: "edit" };
4766
4798
  }
@@ -5218,7 +5250,11 @@ program
5218
5250
  const reportTaskId = hookReportTaskId(root, provider, evt);
5219
5251
  // A new authoritative prompt gets its own full delivery. An earlier
5220
5252
  // prompt's session-level delta cannot establish this task's receipt.
5221
- if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}`, text) === "delta") {
5253
+ // A subagent reports to the prompt's task but starts with FRESH context:
5254
+ // it never saw that grounding, so its dedup is scoped by its own agent
5255
+ // identity (hashed — the raw agent_id is never retained in the key).
5256
+ const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
5257
+ if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text) === "delta") {
5222
5258
  receipts("refreshed");
5223
5259
  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
5260
  return;
@@ -7080,9 +7116,23 @@ program
7080
7116
  // hook manager's regenerated file — issue #311) is NOT installed.
7081
7117
  const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7082
7118
  .filter(([, e]) => e.state === "unreachable");
7119
+ // Present where git runs it, but its command is not a Hunch launcher that
7120
+ // exists (hand-edited, or an absolute path gone after a reinstall) — issue #315.
7121
+ const stale = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
7122
+ .filter(([, e]) => e.state === "stale");
7123
+ // A bare `hunch init` regenerates post-commit without --private/--commit
7124
+ // and pre-commit without --strict, so pointing at it without naming the
7125
+ // options that keep them silently DOWNGRADES the install. --commit is left
7126
+ // out: init's auto-commit is ON unless --no-auto-commit is passed, so a
7127
+ // bare re-run keeps it and naming it here would claim a loss that never happens.
7128
+ const carried = [...new Set(stale.flatMap(([, e]) => e.flags ?? []))].filter((f) => f !== "--commit");
7129
+ const keep = carried.map((f) => (f === "--strict" ? "--enforce-strict" : f === "--private" ? "--private-sync (or --shared-sync)" : "")).filter(Boolean);
7130
+ const staleKeep = carried.length
7131
+ ? ` — the stale block carried ${carried.join(", ")}${keep.length ? `: pass ${keep.join(", ")} or they are dropped` : ""}`
7132
+ : "";
7083
7133
  // `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
7134
+ // post-commit install (it never hooks an un-hooked repo — see the
7135
+ // hookReport(root).postCommit.state gate above) and never writes
7086
7136
  // into a manager-owned hook — so the fix hint must not point there when
7087
7137
  // post-commit itself is missing or a hook manager is in play.
7088
7138
  const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
@@ -7092,6 +7142,7 @@ program
7092
7142
  const problems = [
7093
7143
  missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
7094
7144
  unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
7145
+ 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
7146
  ].filter(Boolean);
7096
7147
  console.log(`hooks: ${problems.length
7097
7148
  ? problems.join("\n ")
@@ -7099,6 +7150,13 @@ program
7099
7150
  for (const [name, e] of unreachable) {
7100
7151
  console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
7101
7152
  }
7153
+ for (const [name, e] of stale) {
7154
+ console.log(dim(` ↳ ${name}: ${e.reason ?? "the block's command is not a Hunch launcher"} (${rel(root, e.path)})`));
7155
+ }
7156
+ // Where the live hooks point — informational (a hook may legitimately run
7157
+ // another install), but the fastest way to spot a stale/foreign launcher.
7158
+ for (const line of hookInvocationLines(report, resolveInvocation().shell))
7159
+ console.log(dim(` ↳ ${line}`));
7102
7160
  // Workspace ledger: what this machine is called, whether its record is in memory,
7103
7161
  // and whether the checkout hook that keeps it fresh is installed.
7104
7162
  try {
@@ -7107,7 +7165,7 @@ program
7107
7165
  const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
7108
7166
  const leak = labelLeaksIdentity(machine.label);
7109
7167
  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)" : ""}`);
7168
+ `${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
7169
  }
7112
7170
  catch { /* no machine file writable: nothing to report */ }
7113
7171
  }
@@ -7252,28 +7310,17 @@ function readStdin() {
7252
7310
  });
7253
7311
  }
7254
7312
  /** 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). */
7313
+ * forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
7314
+ * inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
7315
+ * which realpath-normalizes both ends first — see its docstring for why, incl.
7316
+ * dec_e0a36efbf5): that function passes an unresolvable absolute path through
7317
+ * UNCHANGED (still absolute) rather than signaling failure directly, since other
7318
+ * callers want the original target back to fail their own match safely. This
7319
+ * adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
7320
+ * and `editTargets` below already treat as "skip, not in this repo". */
7275
7321
  function toRepoRel(root, abs) {
7276
- return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
7322
+ const rel = repoRelativeTarget(abs, root);
7323
+ return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
7277
7324
  }
7278
7325
  /** The in-repo files a pre-edit event would change, each with the lines the edit
7279
7326
  * 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;
@@ -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
@@ -4,7 +4,9 @@ const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))
4
4
  * documentation EXAMPLE of a marker never registers as a live anchor.
5
5
  * CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
6
6
  * line of ≥N of the same char and nothing else; an unclosed fence runs to
7
- * EOF; a backtick fence's info string may not itself contain a backtick. */
7
+ * EOF; a backtick fence's info string may not itself contain a backtick.
8
+ * Expects LF-normalized text — see parseDocAnchors's normalization; a
9
+ * caller that skips it re-opens the CRLF fence-detection bug. */
8
10
  function fencedRanges(text) {
9
11
  const ranges = [];
10
12
  let open = null;
@@ -31,7 +33,11 @@ function fencedRanges(text) {
31
33
  /** Character ranges covered by inline code spans (`…`), same rationale as
32
34
  * fencedRanges: prose quoting a marker in backticks is showing an example.
33
35
  * CommonMark-lite: an opener run pairs with the next run of the SAME length
34
- * on the same line; unpaired runs never open a span. */
36
+ * on the same line; unpaired runs never open a span.
37
+ * Expects LF-normalized text — see parseDocAnchors's normalization.
38
+ * `split("\n")` does not split a bare CR, so on CR-only input the whole
39
+ * document reads as one line and stray backticks on different lines
40
+ * falsely pair. */
35
41
  function inlineSpanRanges(text) {
36
42
  const ranges = [];
37
43
  let offset = 0;
@@ -55,6 +61,14 @@ function inlineSpanRanges(text) {
55
61
  * fenced code blocks or inline code spans are examples, not declarations,
56
62
  * and are skipped. */
57
63
  export function parseDocAnchors(text) {
64
+ // fencedRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
65
+ // "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
66
+ // lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
67
+ // docblock). Either way an example marker inside a fence/span registered as
68
+ // a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
69
+ // fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
70
+ // and with the line numbers reported below.
71
+ text = text.replace(/\r\n?/g, "\n");
58
72
  const out = [];
59
73
  const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
60
74
  MARKER.lastIndex = 0;
@@ -7,3 +7,22 @@ export declare function pathMatchesGlob(path: string, glob: string): boolean;
7
7
  /** Do two concrete repo paths identify the same path at different prefix depth?
8
8
  * Segment-anchored: `x/scenario.ts` relates to `scenario.ts`, never `io.ts`. */
9
9
  export declare function pathsRelated(left: string, right: string): boolean;
10
+ /** True when `target` is a path Hunch's index already knows about: an indexed
11
+ * symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
12
+ * The latter catches a symbol-less-but-indexed file (README.md, package.json,
13
+ * Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
14
+ * known file. Derived entirely from already-loaded graph data — never the
15
+ * filesystem — so the answer doesn't depend on untracked working-tree state
16
+ * (a deleted-but-still-indexed path stays "real"; issue #299). */
17
+ export declare function isIndexedPath(target: string, symbolFiles: Iterable<string>, componentPaths: Iterable<readonly string[]>): boolean;
18
+ /** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
19
+ * (only when `target` is NOT a path already known to the index) segment-anchored
20
+ * suffix. A real indexed file with zero symbols must return [] rather than fall
21
+ * through to the suffix tier, which would leak an unrelated same-basename file's
22
+ * records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
23
+ * the "is this a real path" question is answered identically everywhere. */
24
+ export declare function matchSymbolsTiered<S extends {
25
+ id: string;
26
+ file: string;
27
+ name: string;
28
+ }>(target: string, symbols: readonly S[], indexed: boolean): S[];
package/dist/core/glob.js CHANGED
@@ -66,4 +66,41 @@ export function pathsRelated(left, right) {
66
66
  const b = norm(right);
67
67
  return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
68
68
  }
69
+ /** True when `target` is a path Hunch's index already knows about: an indexed
70
+ * symbol's exact file, or a path an indexed component's `paths` glob(s) cover.
71
+ * The latter catches a symbol-less-but-indexed file (README.md, package.json,
72
+ * Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
73
+ * known file. Derived entirely from already-loaded graph data — never the
74
+ * filesystem — so the answer doesn't depend on untracked working-tree state
75
+ * (a deleted-but-still-indexed path stays "real"; issue #299). */
76
+ export function isIndexedPath(target, symbolFiles, componentPaths) {
77
+ for (const f of symbolFiles)
78
+ if (f === target)
79
+ return true;
80
+ for (const globs of componentPaths)
81
+ for (const g of globs)
82
+ if (pathMatchesGlob(target, g))
83
+ return true;
84
+ return false;
85
+ }
86
+ /** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
87
+ * (only when `target` is NOT a path already known to the index) segment-anchored
88
+ * suffix. A real indexed file with zero symbols must return [] rather than fall
89
+ * through to the suffix tier, which would leak an unrelated same-basename file's
90
+ * records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
91
+ * the "is this a real path" question is answered identically everywhere. */
92
+ export function matchSymbolsTiered(target, symbols, indexed) {
93
+ const byId = symbols.find((s) => s.id === target);
94
+ if (byId)
95
+ return [byId];
96
+ const byName = symbols.filter((s) => s.name === target);
97
+ if (byName.length)
98
+ return byName;
99
+ const exact = symbols.filter((s) => s.file === target);
100
+ if (exact.length)
101
+ return exact;
102
+ if (indexed)
103
+ return [];
104
+ return symbols.filter((s) => pathsRelated(s.file, target));
105
+ }
69
106
  //# sourceMappingURL=glob.js.map
@@ -1,10 +1,32 @@
1
1
  export declare const HUNCH_DIR = ".hunch";
2
- /** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
3
- * every path with forward slashes (git emits "/" on all OSes), so any user- or
4
- * agent-supplied target must be normalized before comparison — otherwise a
5
- * Windows caller passing `src\auth\session.ts` never matches the stored
6
- * `src/auth/session.ts`. Safe on symbol names too: they contain no backslashes. */
2
+ /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
3
+ * path with "/" — git emits it on all OSes — so any user- or agent-supplied
4
+ * target must be normalized before comparison, otherwise a Windows caller passing
5
+ * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
6
+ * symbol names too: they contain no backslashes. This does NOT make a path
7
+ * repo-relative — an absolute path passes through with its separators flipped,
8
+ * unchanged otherwise; use `repoRelativeTarget` for that. */
7
9
  export declare function toPosixTarget(target: string): string;
10
+ /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
11
+ * to Write, or a glob whose concrete segments aren't literal): resolve the longest
12
+ * existing ancestor, then re-append the missing tail. Idempotent on an
13
+ * already-resolved path. */
14
+ export declare function realpathNorm(p: string): string;
15
+ /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
16
+ * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
17
+ * repo-relative, so a caller that hands over an absolute edit-payload path
18
+ * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
19
+ * returned empty for an absolute target that plainly had a matching rule). A
20
+ * relative target, a glob, or an absolute path outside `root` passes through
21
+ * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
22
+ *
23
+ * BOTH ends are realpath-normalized before the relative-path computation: on
24
+ * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
25
+ * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
26
+ * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
27
+ * caller treats the file as outside the repo, silently dropping all context
28
+ * (dec_e0a36efbf5). */
29
+ export declare function repoRelativeTarget(target: string, root: string): string;
8
30
  export interface HunchPaths {
9
31
  /** Repo root (where .hunch/ lives). */
10
32
  root: string;
@@ -1,16 +1,61 @@
1
1
  /** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
2
2
  import { join } from "node:path";
3
3
  import { existsSync, realpathSync, statSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
5
5
  export const HUNCH_DIR = ".hunch";
6
- /** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
7
- * every path with forward slashes (git emits "/" on all OSes), so any user- or
8
- * agent-supplied target must be normalized before comparison — otherwise a
9
- * Windows caller passing `src\auth\session.ts` never matches the stored
10
- * `src/auth/session.ts`. Safe on symbol names too: they contain no backslashes. */
6
+ /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
7
+ * path with "/" — git emits it on all OSes — so any user- or agent-supplied
8
+ * target must be normalized before comparison, otherwise a Windows caller passing
9
+ * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
10
+ * symbol names too: they contain no backslashes. This does NOT make a path
11
+ * repo-relative — an absolute path passes through with its separators flipped,
12
+ * unchanged otherwise; use `repoRelativeTarget` for that. */
11
13
  export function toPosixTarget(target) {
12
14
  return target.replace(/\\/g, "/").replace(/^\.\//, "");
13
15
  }
16
+ /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
17
+ * to Write, or a glob whose concrete segments aren't literal): resolve the longest
18
+ * existing ancestor, then re-append the missing tail. Idempotent on an
19
+ * already-resolved path. */
20
+ export function realpathNorm(p) {
21
+ try {
22
+ return realpathSync.native(p);
23
+ }
24
+ catch {
25
+ const parent = dirname(p);
26
+ if (parent === p)
27
+ return p; // hit the root; nothing more to resolve
28
+ return join(realpathNorm(parent), basename(p));
29
+ }
30
+ }
31
+ /** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
32
+ * `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
33
+ * repo-relative, so a caller that hands over an absolute edit-payload path
34
+ * matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
35
+ * returned empty for an absolute target that plainly had a matching rule). A
36
+ * relative target, a glob, or an absolute path outside `root` passes through
37
+ * unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
38
+ *
39
+ * BOTH ends are realpath-normalized before the relative-path computation: on
40
+ * macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
41
+ * hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
42
+ * bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
43
+ * caller treats the file as outside the repo, silently dropping all context
44
+ * (dec_e0a36efbf5). */
45
+ export function repoRelativeTarget(target, root) {
46
+ const t = toPosixTarget(target);
47
+ const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
48
+ if (!looksAbsolute)
49
+ return t;
50
+ // `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
51
+ // by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
52
+ // so an un-posix'd root and a posix'd target would decompose into differently-shaped
53
+ // ancestor chains and never share a common realpath'd prefix.
54
+ const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
55
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
56
+ return t;
57
+ return rel;
58
+ }
14
59
  export function hunchPaths(root) {
15
60
  const hunch = join(root, HUNCH_DIR);
16
61
  return {