@davesheffer/hunch 1.46.0 → 1.47.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 (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -111,20 +111,15 @@ export declare function installPostCommitHook(root: string, invocation: string,
111
111
  * blocking invariant (see strictgate.ts), so it's safe on a shared repo.
112
112
  * Preserves any existing pre-commit hook. */
113
113
  export declare function installPreCommitHook(root: string, invocation: string, strict?: boolean): HookInstall;
114
- /** Install a post-merge hook carrying TWO independently-managed blocks:
115
- * re-sync the committed grounding docs when a merge brought memory in behind
116
- * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
117
- * files and can never fail the merge), and opportunistically DETECT a
118
- * decision's commit provenance going orphaned right after a squash-merged
119
- * branch lands locally (including a fast-forward from `git pull`) — while
120
- * the original commits are still fully intact and matchable — queuing the
121
- * match for a human to confirm via `hunch repair-provenance --apply`
122
- * (HUNCH_MERGE_SYNC-guarded, backgrounded; own env var since this hook makes
123
- * no commit of its own and so can't reuse HUNCH_SYNC's re-trigger guard).
124
- * Each block is keyed by its own marker pair (installManagedBlock), so
125
- * re-running updates only its own block, preserves the other untouched, and
126
- * a repo carrying only one half (an older install, or a hand-edited hook)
127
- * gets the other appended rather than clobbered. */
114
+ /** Install the post-merge hook. It carries two independently managed blocks:
115
+ * - grounding: re-syncs committed grounding docs when a merge brought in memory.
116
+ * Guarded by HUNCH_SYNC, runs in the foreground, never fails the merge.
117
+ * - repair: detects a decision's commit provenance being orphaned by a
118
+ * squash-merge (including a fast-forward from `git pull`) and queues the match
119
+ * for `hunch repair-provenance --apply`. Guarded by HUNCH_MERGE_SYNC, runs in
120
+ * the background.
121
+ * Each block is keyed by its own marker pair, so a re-run updates only its own
122
+ * block, and a hook that has only one half gets the other appended, not clobbered. */
128
123
  export declare function installPostMergeHook(root: string, invocation: string): HookInstall;
129
124
  export declare function installPostCheckoutHook(root: string, invocation: string): HookInstall;
130
125
  export type HookState = BlockState;
@@ -793,14 +793,12 @@ export function installPreCommitHook(root, invocation, strict = false) {
793
793
  };
794
794
  return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
795
795
  }
796
- // Original marker, kept byte-for-byte for backward compat: an existing install's
797
- // grounding-refresh block must still be found and updated in place by its own
798
- // exact marker text (fnd_c402046ac7).
796
+ // The original grounding marker is kept unchanged for backward compatibility, so an
797
+ // existing grounding block is still found and updated in place.
799
798
  const GROUNDING_MERGE_MARK = "# >>> hunch post-merge >>>";
800
799
  const GROUNDING_MERGE_END = "# <<< hunch post-merge <<<";
801
- // Distinct marker for the (newer) repair-provenance half, so the two blocks
802
- // never collide inside the same post-merge hook file and each can be
803
- // independently created/updated/removed without touching the other.
800
+ // The repair-provenance block gets a SEPARATE marker so the two blocks never collide
801
+ // in one post-merge file and each can be created, updated or removed on its own.
804
802
  const REPAIR_MERGE_MARK = "# >>> hunch post-merge (repair-provenance) >>>";
805
803
  const REPAIR_MERGE_END = "# <<< hunch post-merge (repair-provenance) <<<";
806
804
  function groundingMergeBlock(invocation) {
@@ -819,37 +817,30 @@ function repairProvenanceMergeBlock(invocation) {
819
817
  REPAIR_MERGE_MARK,
820
818
  'if [ -z "$HUNCH_MERGE_SYNC" ]; then',
821
819
  " export HUNCH_MERGE_SYNC=1",
822
- // No --apply: this only detects a squash-merge orphaning a decision's commit
823
- // and queues the match (.hunch/pending-commit-repairs.json, local-only) for a
824
- // human to confirm via `hunch repair-provenance --apply` — the match signal
825
- // (file-set overlap, not git's own rename detection) isn't strong enough to
826
- // trust an unattended, backgrounded write into shared team memory.
820
+ // Detect-and-queue only, never --apply: the file-overlap match is not strong
821
+ // enough to trust an unattended write to shared memory. A human confirms the
822
+ // queue (.hunch/pending-commit-repairs.json) with `repair-provenance --apply`.
827
823
  ` ( ${invocation} repair-provenance --from-hook --quiet >/dev/null 2>&1 || true ) &`,
828
824
  "fi",
829
825
  REPAIR_MERGE_END,
830
826
  ].join("\n");
831
827
  }
832
- /** How significant a combined install result is, for picking one HookInstall
833
- * action out of two independent sub-installs into the same file — "created"
834
- * (the file itself is new) outranks "appended"/"updated" (an existing file
828
+ /** Rank used to pick ONE HookInstall action out of two independent sub-installs
829
+ * into the same file. "created" (the file itself is new) outranks "appended" /
830
+ * "updated" (an existing file
835
831
  * changed), which outrank "unchanged". Non-writing results are handled before
836
832
  * ranking (they must never be masked by a sibling's success). */
837
833
  const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0, "opted-out": 0 };
838
834
  const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable" && h.action !== "opted-out";
839
- /** Install a post-merge hook carrying TWO independently-managed blocks:
840
- * re-sync the committed grounding docs when a merge brought memory in behind
841
- * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
842
- * files and can never fail the merge), and opportunistically DETECT a
843
- * decision's commit provenance going orphaned right after a squash-merged
844
- * branch lands locally (including a fast-forward from `git pull`) — while
845
- * the original commits are still fully intact and matchable — queuing the
846
- * match for a human to confirm via `hunch repair-provenance --apply`
847
- * (HUNCH_MERGE_SYNC-guarded, backgrounded; own env var since this hook makes
848
- * no commit of its own and so can't reuse HUNCH_SYNC's re-trigger guard).
849
- * Each block is keyed by its own marker pair (installManagedBlock), so
850
- * re-running updates only its own block, preserves the other untouched, and
851
- * a repo carrying only one half (an older install, or a hand-edited hook)
852
- * gets the other appended rather than clobbered. */
835
+ /** Install the post-merge hook. It carries two independently managed blocks:
836
+ * - grounding: re-syncs committed grounding docs when a merge brought in memory.
837
+ * Guarded by HUNCH_SYNC, runs in the foreground, never fails the merge.
838
+ * - repair: detects a decision's commit provenance being orphaned by a
839
+ * squash-merge (including a fast-forward from `git pull`) and queues the match
840
+ * for `hunch repair-provenance --apply`. Guarded by HUNCH_MERGE_SYNC, runs in
841
+ * the background.
842
+ * Each block is keyed by its own marker pair, so a re-run updates only its own
843
+ * block, and a hook that has only one half gets the other appended, not clobbered. */
853
844
  export function installPostMergeHook(root, invocation) {
854
845
  // Only the grounding half regenerates committed docs; repair-provenance writes
855
846
  // no docs (it only queues a match for a human to confirm), so it keeps the
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Wires up two independent git merge drivers:
3
- * - `merge=hunch` — the structured `.hunch/` JSON driver (store/merge.ts):
4
- * resolves concurrent edits by record id.
5
- * - `merge=hunch-grounding` — the generated grounding docs (core/groundingMerge.ts):
6
- * auto-resolves a hard conflict confined to the record-counts sentence,
7
- * leaving any other conflict untouched (dec_ba5b0dfa22).
8
- * Both routes live in `.gitattributes` (committed, travels with the repo);
9
- * both drivers are registered in LOCAL git config only (per-clone, since each
10
- * references this machine's node + cli path — teammates re-run `hunch init`).
3
+ * - `merge=hunch`: the structured `.hunch/` JSON driver (store/merge.ts), which
4
+ * resolves concurrent edits by record id;
5
+ * - `merge=hunch-grounding`: the generated grounding docs driver
6
+ * (core/groundingMerge.ts), which auto-resolves only a hard conflict confined
7
+ * to the record-counts sentence.
8
+ * Both routes live in the committed `.gitattributes`. Both drivers are
9
+ * registered only in local, per-clone git config, because each references this
10
+ * machine's node and CLI path; teammates re-run `hunch init`.
11
11
  */
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { readFileSync, existsSync } from "node:fs";
@@ -18,13 +18,12 @@ import { mergeDriverShell } from "../cli/invocation.js";
18
18
  // Route the .hunch JSON records through the structured driver — but NOT the
19
19
  // manifest (an id-less `{schema_version}` object the driver can't merge by id; a
20
20
  // normal text merge with conflict markers is the right behavior for it).
21
- //
22
- // The five generated grounding docs get the OTHER driver — narrower in scope
23
- // (it only ever touches a hard conflict confined to the counts sentence).
21
+ // The five generated grounding docs go to the other driver, which is narrower:
22
+ // it only ever touches a hard conflict confined to the counts sentence.
24
23
  export const ATTR_LINES = [
25
24
  ".hunch/**/*.json merge=hunch",
26
25
  ".hunch/manifest.json merge=text",
27
- ...GROUNDING_DOC_PATHS.map((f) => `${f} merge=hunch-grounding`),
26
+ ...GROUNDING_DOC_PATHS.map((p) => `${p} merge=hunch-grounding`),
28
27
  ];
29
28
  export function targetRepositoryEnv() {
30
29
  const env = { ...process.env };
@@ -57,8 +56,8 @@ export function installMergeDriver(root, invShell) {
57
56
  assertSafeTopLevelConfigFile(root, ".gitattributes");
58
57
  writeFileAtomic(attrPath, text);
59
58
  }
60
- // 2. Local git config — the driver definitions are per-clone (they reference
61
- // this machine's node + cli path), so they are NOT committed; teammates
59
+ // 2. The driver definitions go into local git config. They are per-clone (they
60
+ // reference this machine's node and CLI path), so they are never committed; teammates
62
61
  // re-run init. Dev mode swaps `npx tsx` for Hunch's own loader (#492).
63
62
  const shell = mergeDriverShell(invShell);
64
63
  const driver = `${shell} merge-driver "%O" "%A" "%B" "%P"`;
@@ -359,12 +359,12 @@ declare const DELIVERY_OUTPUT_SCHEMA: z.ZodObject<{
359
359
  type: z.ZodEnum<{
360
360
  depends_on: "depends_on";
361
361
  calls: "calls";
362
+ references: "references";
362
363
  imports: "imports";
363
364
  contains: "contains";
364
365
  implements: "implements";
365
366
  supersedes: "supersedes";
366
367
  related_to: "related_to";
367
- references: "references";
368
368
  provides: "provides";
369
369
  belongs_to: "belongs_to";
370
370
  implemented_by: "implemented_by";
@@ -624,10 +624,9 @@ export declare function compactEnvelope(envelope: z.infer<typeof DELIVERY_OUTPUT
624
624
  * receipts and receipt_id are unchanged. */
625
625
  export declare function compactOmissions(envelope: z.infer<typeof DELIVERY_OUTPUT_SCHEMA>): z.infer<typeof DELIVERY_ADVERTISED_OUTPUT_SCHEMA>;
626
626
  export declare function decisionSubstanceChanged(before: Decision, after: Decision): boolean;
627
- /** Resolve a free-form target (symbol id / name / file path) to symbol records.
628
- * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
629
- * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
630
- * apart on the same question. */
627
+ /** Resolve a free-form target (symbol id, name, or file path) to symbol records using
628
+ * tiered matching — exact id, then exact name, then exact file, then a path-segment-anchored
629
+ * suffix. Shared with HunchStore.why() through matchSymbolsTiered so the two never disagree. */
631
630
  export declare function resolveSymbols(store: HunchStore, target: string): Symbol[];
632
631
  /** Resolve a target to canonical indexed file path(s) (for file-granular blast
633
632
  * radius). Falls back to the literal target so direct-scope checks still run. */
@@ -90,24 +90,25 @@ const err = (text) => ({ content: [{ type: "text", text }], isError: true });
90
90
  * Transient states that say "retry" in their own words stay unprefixed. */
91
91
  const refused = (text) => err(`Refused: ${text}`);
92
92
  const invalid = (text) => err(`Invalid: ${text}`);
93
- /** Shared by every auto-committing write tool (issue #20): the MCP `roots` protocol
94
- * cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
95
- * never moves on its own — this is the client-agnostic fallback, resolved fresh on
96
- * every call by the generic tool wrapper below (see extractCwdHint). */
93
+ /** Shared by every auto-committing write tool. The MCP roots protocol cannot observe an
94
+ * agent-driven directory change or worktree entry, so a stdio server's cached root never
95
+ * moves by itself; this field is the client-agnostic fallback, resolved fresh on every
96
+ * call by the generic tool wrapper. */
97
97
  const cwdHintField = z.string().optional().describe("Your current directory, when it differs from where this session started (e.g. a worktree); " +
98
98
  "writes then commit there, not to the original root.");
99
99
  /** Shared by hunch_record_decision/correction/finding — identical text repeated 3x
100
100
  * otherwise. hunch_context's task_id field differs (delivery vs. save) and keeps its
101
101
  * own description. */
102
102
  const saveTaskIdField = TaskIdSchema.optional().describe("Task ID, to attribute this save to it; never affects capture authority.");
103
- /** Pull `cwd` out of a tool call's already-parsed input without assuming any one
104
- * tool's exact input shape — every write tool spreads the same cwdHintField in,
105
- * but the wrapper below runs for every tool, read or write. */
106
- function extractCwdHint(input) {
107
- if (!input || typeof input !== "object")
103
+ /** Pulls the cwd hint out of a tool call's already-parsed first argument. The wrapper
104
+ * runs for EVERY tool (read and write), so this assumes nothing about any one tool's
105
+ * input shape: anything that is not an object with a non-blank string `cwd` yields
106
+ * undefined. The original (untrimmed) string is returned. */
107
+ function cwdHintOf(input) {
108
+ if (typeof input !== "object" || input === null)
108
109
  return undefined;
109
110
  const cwd = input.cwd;
110
- return typeof cwd === "string" && cwd.trim() ? cwd : undefined;
111
+ return typeof cwd === "string" && cwd.trim() !== "" ? cwd : undefined;
111
112
  }
112
113
  /** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
113
114
  * commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
@@ -150,11 +151,9 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
150
151
  return risk;
151
152
  return "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store." + risk;
152
153
  };
153
- /** Self-diagnosing destination report (issue #17/#20): the exact failure mode this
154
- * guards against is silent — a capture landing in the wrong repo/branch with no
155
- * sign of it short of a manual `git log` audit. Every auto-committing write tool
156
- * appends this so the destination is always visible in the response, whether or
157
- * not a cwd hint was involved in choosing it. */
154
+ /** Self-diagnosing destination suffix for every auto-committing write tool. A capture
155
+ * silently landing in the wrong repo or branch is only discoverable by auditing git log
156
+ * by hand, so the destination is ALWAYS shown, whether or not a cwd hint chose it. */
158
157
  const destinationNote = (destRoot) => {
159
158
  const branch = currentBranch(destRoot);
160
159
  return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
@@ -586,12 +585,10 @@ function nuryelWriteSubject(facet, record) {
586
585
  const kind = facet.endsWith("s") ? facet.slice(0, -1) : facet;
587
586
  return `${kind}${typeof label === "string" ? ` "${label.slice(0, 60)}"` : ""} (nuryel_write)`;
588
587
  }
589
- /** Where a capture keyed to `home` actually lands: the private overlay directory when
590
- * one is configured, else the public repo root. Centralizes the branch used at every
591
- * destination-reporting call site below — `hunch_policy_upgrade_correction` once
592
- * diverged from this (computing its own `artifactHome` but reporting the public root
593
- * regardless), silently misreporting the destination for a private-homed proof. */
594
- const resolveDestRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
588
+ /** Where a capture keyed to a storage home actually lands. Centralized so every
589
+ * destination-reporting call site agrees: hunch_policy_upgrade_correction once computed
590
+ * its own home but reported the public root regardless, misreporting a private-homed proof. */
591
+ const destinationRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
595
592
  // Read-side token budgets: every tool result is injected into a Claude Code
596
593
  // session, so an uncapped list pollutes the context window. Cap each list to its
597
594
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
@@ -993,13 +990,12 @@ function qualityNudge(rec) {
993
990
  }
994
991
  return "";
995
992
  }
996
- /** Resolve a free-form target (symbol id / name / file path) to symbol records.
997
- * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
998
- * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
999
- * apart on the same question. */
993
+ /** Resolve a free-form target (symbol id, name, or file path) to symbol records using
994
+ * tiered matching — exact id, then exact name, then exact file, then a path-segment-anchored
995
+ * suffix. Shared with HunchStore.why() through matchSymbolsTiered so the two never disagree. */
1000
996
  export function resolveSymbols(store, target) {
1001
- // An absolute target (an agent's edit-payload path, verbatim) never matched
1002
- // the repo-relative stored file paths below (issue #296).
997
+ // An absolute path (an agent's verbatim edit-payload path) must match the repo-relative
998
+ // stored file paths (issue #296).
1003
999
  target = repoRelativeTarget(target, store.publicRoot);
1004
1000
  const syms = store.json.loadAll("symbols");
1005
1001
  const components = store.json.loadAll("components");
@@ -1372,23 +1368,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1372
1368
  };
1373
1369
  const registerTool = server.registerTool.bind(server);
1374
1370
  server.registerTool = ((name, config, callback) => hiddenTools.has(name) ? undefined : registerTool(name, config, async (...args) => {
1375
- // Claude Code CLI never advertises `roots`/`roots/list_changed` for an agent-driven
1376
- // `cd` or EnterWorktree (issue #20) — the cached `root` above just never moves, so a
1377
- // write silently lands wherever the process was spawned. Write tools accept an
1378
- // optional `cwd` argument (see cwdHintField) as a client-agnostic fallback: resolved
1379
- // fresh on EVERY call instead of trusted from a cache, and re-homing this stdio
1380
- // process the same way a `roots` notification would. Only safe when this is the
1381
- // sole in-flight request — re-homing under a concurrent request would tear its
1382
- // root/store out from under it, so that case is refused rather than risked.
1383
- const cwdHint = extractCwdHint(args[0]);
1371
+ // The Claude Code CLI never advertises roots/list_changed for an agent `cd` or worktree
1372
+ // entry, so the cached root never moves and writes land where the process was spawned.
1373
+ // Write tools therefore accept an optional `cwd` (see cwdHintField) as a client-agnostic
1374
+ // fallback, resolved fresh on every call rather than cached, re-homing this stdio
1375
+ // process the way a roots notification would. That is only safe when no other request
1376
+ // is in flight, so that case is refused instead of risked.
1377
+ const cwdHint = cwdHintOf(args[0]);
1384
1378
  // A pinned root is the whole point of `hunch mcp --root`: a served partition must not
1385
1379
  // follow the caller's working directory into some other checkout.
1386
1380
  if (cwdHint !== undefined && !pinned) {
1387
1381
  const target = canonicalRootPath(findRoot(cwdHint));
1388
1382
  if (target !== canonicalRootPath(root)) {
1389
- if (activeRequests) {
1390
- return err(`Hunch is mid-request against ${root} and cannot safely switch to the working directory you passed ` +
1391
- `(resolves to ${target}) while another call is in flight. Retry this call once the other one completes.`);
1383
+ if (activeRequests !== 0) {
1384
+ return err(`Hunch is serving ${root} and cannot safely switch to your working directory (${target}) while another call is in flight. Retry once the other call completes.`);
1392
1385
  }
1393
1386
  try {
1394
1387
  setRoot(cwdHint);
@@ -1923,13 +1916,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1923
1916
  // -- hunch_now (the hot view: recent activity + roadmap) --------------------
1924
1917
  // PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
1925
1918
  // paste this anywhere, so it must be publishable by construction. Union view
1926
- // stays behind `hunch now --private` on the local terminal. EXCEPTION: a
1927
- // queued commit-repair's liveness is checked against the full store (see
1928
- // below), so a private-overlay decision's id and commit shas — never its
1929
- // title — can surface in the escalation line.
1919
+ // is available only through the local terminal command `hunch now --private`.
1920
+ // Exception: a queued commit-repair's liveness is checked against the full store, so a
1921
+ // private-overlay decision's id and commit shas (never its title) may appear in the
1922
+ // escalation line.
1930
1923
  server.registerTool("hunch_now", {
1931
1924
  title: "Recent activity + the roadmap (the hot view)",
1932
- description: "What just happened and what's next, straight from the graph: the last N decisions, the ROADMAP, and any inline human question such as an imported ADR awaiting explicit approve/decline. Call at session start to orient, or before planning what to work on. Same data as the wiki's now.md. Public store only, EXCEPT a queued commit-repair's liveness is checked against the full store (so a private-overlay decision's fully-answerable repair doesn't go silently unanswerable); only its id and the commit shas ever surface, never its title.",
1925
+ description: "What just happened and what is next, straight from the graph: the last N decisions, the ROADMAP, and any inline human question such as an imported ADR awaiting explicit approve/decline. Call at session start to orient, or before planning. Same data as the wiki's now.md. Public store only — except that a queued commit-repair's liveness is checked against the full store, so a private-overlay decision's answerable repair does not go silently unanswerable; only its id and commit shas ever surface, never its title.",
1933
1926
  inputSchema: {
1934
1927
  recent_limit: z.number().optional().describe("How many recent decisions to include (default 5)."),
1935
1928
  roadmap_limit: z.number().optional().describe("How many roadmap entries to include (default 2); the rest are counted."),
@@ -1962,18 +1955,15 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1962
1955
  L.push("", ws);
1963
1956
  const escalations = pendingEscalations(store.advisoryRecs("decisions"));
1964
1957
  escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
1965
- // liveness checked against the full store (repair-provenance reads the
1966
- // full store too) even though the title stays scoped to advisoryRecs —
1967
- // an overlay decision's repair is fully answerable, so it must not go
1968
- // silent here just because its title is private.
1969
- const hunchNowQueue = readActivePendingRepairs(root);
1970
- escalations.push(...commitRepairEscalations(hunchNowQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, hunchNowQueue)));
1971
- // Only ACTIONABLE entries are a question the assistant can put to the human
1972
- // directly — a duplicate-id commit-repair follower whose own resolution
1973
- // says "act on a different entry first" isn't one (#61).
1974
- const actionableNow = actionableEscalations(escalations);
1975
- if (actionableNow.length) {
1976
- L.push("", `⚖ ${actionableNow.length} decision(s) need the human's call — ASK inline (never queue): ${actionableNow.map((e) => e.question).join(" · ")}`);
1958
+ // Liveness is checked against the FULL store (the title stays scoped to advisory
1959
+ // records), so an overlay decision's repair does not go silent.
1960
+ const repairQueue = readActivePendingRepairs(root);
1961
+ escalations.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
1962
+ // A duplicate-id commit-repair follower whose own answer is "act on a different
1963
+ // entry first" is not a question to raise (#61).
1964
+ const actionable = actionableEscalations(escalations);
1965
+ if (actionable.length) {
1966
+ L.push("", `⚖ ${actionable.length} decision(s) need the human's call — ASK inline (never queue): ${actionable.map((e) => e.question).join(" · ")}`);
1977
1967
  }
1978
1968
  return ok(L.join("\n"));
1979
1969
  });
@@ -2039,24 +2029,20 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2039
2029
  // Premise decay: a live decision whose recorded reason died. Question-framed
2040
2030
  // like every entry here — authority never changes until the human answers.
2041
2031
  items.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
2042
- // See the sibling hunch_now handler above for why `live` is the full store.
2043
- const hunchEscalationsQueue = readActivePendingRepairs(root);
2044
- items.push(...commitRepairEscalations(hunchEscalationsQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, hunchEscalationsQueue)));
2032
+ // Live set is the full store, for the reason given in the sibling hunch_now handler.
2033
+ const repairQueue = readActivePendingRepairs(root);
2034
+ items.push(...commitRepairEscalations(repairQueue, store.advisoryRecs("decisions"), store.recs("decisions"), withheldRewrites(root, repairQueue)));
2045
2035
  try {
2046
2036
  items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
2047
2037
  }
2048
2038
  catch { /* constitution unavailable — memory escalations still surface */ }
2049
2039
  if (!items.length)
2050
2040
  return ok("✓ Nothing needs a human decision — memory is auto-trusted and self-consistent.");
2051
- // Only ACTIONABLE entries are a question to ask the human directly — a
2052
- // duplicate-id commit-repair follower whose own resolution says "act on
2053
- // a different entry first" still surfaces below for transparency, but
2054
- // isn't itself something to raise as a decision (#61).
2041
+ // Only actionable entries are questions to raise; non-actionable followers still
2042
+ // appear below for transparency (#61).
2055
2043
  const L = [escalationHeadline(items, "mcp"), ""];
2056
2044
  for (const e of items) {
2057
- // Marked distinctly (never the "⚖ question" glyph) when it isn't
2058
- // itself something to raise — an entry whose own answer is "act on a
2059
- // different entry first" reads as informational, not as a question.
2045
+ // Non-actionable entries are marked distinctly so they read as informational, not as a question.
2060
2046
  L.push(`${e.actionable === false ? "·" : "⚖"} ${e.question}`);
2061
2047
  L.push(` ${e.detail}`);
2062
2048
  L.push(` → ${e.resolution}`, "");
@@ -2474,7 +2460,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2474
2460
  const where = decision.private
2475
2461
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
2476
2462
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
2477
- const dest = destinationNote(resolveDestRoot(home, store, root));
2463
+ const dest = destinationNote(destinationRoot(home, store, root));
2478
2464
  // The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
2479
2465
  // this decision by id + the hash ON FILE + this repository's partition. Hand the ref
2480
2466
  // over now so the agent never rests on a pre-store hash or re-derives the scope.
@@ -2585,7 +2571,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2585
2571
  const tierNote = `
2586
2572
 
2587
2573
  ⚠ ${why} — this rule is agent_recorded TESTIMONY${capped ? ` and was capped from "${requested.severity}" to "${rec.severity}"` : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human confirmed outside the agent channel may block. The human confirms it by running: ${confirmCommand(rec.id, { private: home === "private", severity: requested.severity })}`;
2588
- const dest = destinationNote(resolveDestRoot(home, store, root));
2574
+ const dest = destinationNote(destinationRoot(home, store, root));
2589
2575
  return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
2590
2576
  }
2591
2577
  catch (e) {
@@ -2666,8 +2652,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2666
2652
  ? `\n\n△ violates_constraint ${rec.violates_constraint} resolves to no known constraint — if the rule isn't recorded yet, hunch_record_correction it and re-record this finding with the real id.`
2667
2653
  : "";
2668
2654
  const noEvidence = rec.evidence.length ? "" : "\n\n△ No evidence attached — a finding without the query/output that produced it is an opinion. Re-record with evidence when you have it.";
2669
- const dest = destinationNote(resolveDestRoot(home, store, root));
2670
- return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${[...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)"}.${danglingCon}${noEvidence}`);
2655
+ const dest = destinationNote(destinationRoot(home, store, root));
2656
+ const grounds = [...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)";
2657
+ return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${grounds}.${danglingCon}${noEvidence}`);
2671
2658
  }
2672
2659
  catch (e) {
2673
2660
  return err(`Failed to record finding: ${e.message}`);
@@ -2950,7 +2937,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2950
2937
  if (artifactHome === "private") {
2951
2938
  flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
2952
2939
  }
2953
- const destRoot = resolveDestRoot(artifactHome, store, root);
2940
+ const destRoot = destinationRoot(artifactHome, store, root);
2954
2941
  const destination = { root: destRoot, branch: currentBranch(destRoot) };
2955
2942
  if (include_artifacts)
2956
2943
  return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
@@ -3139,7 +3126,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3139
3126
  if (!home)
3140
3127
  throw new Error(`policy ${policy_id} has no exact storage home`);
3141
3128
  flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
3142
- const destRoot = resolveDestRoot(home, store, root);
3129
+ const destRoot = destinationRoot(home, store, root);
3143
3130
  return ok(JSON.stringify({ ...plan, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
3144
3131
  }
3145
3132
  catch (e) {
@@ -3437,7 +3424,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
3437
3424
  dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
3438
3425
  });
3439
3426
  flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
3440
- const destRoot = resolveDestRoot("private", store, root);
3427
+ const destRoot = destinationRoot("private", store, root);
3441
3428
  return ok(JSON.stringify({ ...materialized, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
3442
3429
  }
3443
3430
  catch (e) {
@@ -1341,8 +1341,8 @@ export class HunchStore {
1341
1341
  * instant — "what did we believe as of commit X?". Omit `asOf` for the full,
1342
1342
  * history-inclusive view (backward-compatible default). */
1343
1343
  why(target, opts = {}) {
1344
- // An absolute target (an agent's edit-payload path, verbatim) never matched
1345
- // the repo-relative stored file paths below (issue #299).
1344
+ // An agent's verbatim edit-payload path is absolute; stored paths are
1345
+ // repo-relative, so rewrite before any matching (issue #299).
1346
1346
  target = repoRelativeTarget(target, this.paths.root);
1347
1347
  // A record id (every delivered brief line ends in `hunch_why("dec_…")`) resolves
1348
1348
  // to that record directly (issue #474). With `asOf`, a record not in force then
@@ -1364,11 +1364,11 @@ export class HunchStore {
1364
1364
  // so an unanchored suffix pulled unrelated files' records into why()/the
1365
1365
  // pre-edit grounding block (issue #32). Segment-anchored matching only.
1366
1366
  //
1367
- // A target that IS a path Hunch's index already knows about (README.md,
1368
- // vscode-extension/index.ts, ...) names exactly one file — suffix-matching
1369
- // it too pulls in every OTHER file that merely shares a basename (issue
1370
- // #299: root index.ts delivered vscode-extension/index.ts's rules and vice
1371
- // versa). "Known to the index" is answered from already-loaded graph data
1367
+ // A target that is a path the index already knows (README.md,
1368
+ // `vscode-extension/index.ts`) names exactly ONE file. Suffix-matching it too
1369
+ // would pull in every other file sharing its basename (issue #299: root
1370
+ // `index.ts` received `vscode-extension/index.ts`'s rules and vice versa). "Known
1371
+ // to the index" is answered from already-loaded graph data, looked up
1372
1372
  // first (a symbol's exact file, or an indexed component's path glob — so a
1373
1373
  // symbol-less indexed file still counts as real), and only then from the
1374
1374
  // working tree, for a real file the index cannot see at all (issue #334).
@@ -1696,9 +1696,8 @@ export class HunchStore {
1696
1696
  * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
1697
1697
  * that instant (time-travel: "what must I not have broken as of commit X?"). */
1698
1698
  checkConstraints(scope, opts = {}) {
1699
- // Constraint scopes are always repo-relative; an absolute target (an
1700
- // agent's edit-payload path, verbatim) matched nothing even when a
1701
- // blocking rule plainly applied (issue #296).
1699
+ // Scopes are repo-relative; an absolute target matched nothing even when a
1700
+ // blocking rule applied (issue #296).
1702
1701
  scope = repoRelativeTarget(scope, this.paths.root);
1703
1702
  const all = this.recs("constraints");
1704
1703
  const asOf = opts.asOf;
@@ -1713,11 +1712,8 @@ export class HunchStore {
1713
1712
  * glob, and the queried scope may be either too. Advisory only — findings never
1714
1713
  * enter any block path. Sorted worst-first, then id for stable output. */
1715
1714
  liveFindingsFor(scope) {
1716
- // Same absolute-target and same-basename-leak gaps as why() (issue #299): an
1717
- // un-rewritten absolute scope never matched the repo-relative `affected_files`
1718
- // below, and an un-guarded suffix fallback could pull in an unrelated file
1719
- // that merely shares a basename. Normalize once and reuse the same "is this
1720
- // path known to the index" guard.
1715
+ // Same absolute-target and same-basename-leak gaps as why() (issue #299):
1716
+ // normalize once and reuse the known-path guard.
1721
1717
  const t = repoRelativeTarget(scope, this.paths.root);
1722
1718
  const indexed = this.isKnownPath(t);
1723
1719
  const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
@@ -2340,9 +2336,7 @@ export class HunchStore {
2340
2336
  * a task on `target`, ordered by what matters most — invariants first, then the
2341
2337
  * why, then blast radius and bug history — trimmed to a rough token budget. */
2342
2338
  assembleContext(target, budget = 1500, opts = {}) {
2343
- // An absolute target (an agent's edit-payload path, verbatim) must be
2344
- // repo-relative BEFORE it reaches why()/liveFindingsFor() below, or an
2345
- // absolute path never matches the repo-relative stored data (issue #299).
2339
+ // Normalize before it reaches why()/liveFindingsFor() below (issue #299).
2346
2340
  target = repoRelativeTarget(target, this.paths.root);
2347
2341
  const w = this.why(target, opts);
2348
2342
  const symIds = w.symbols.map((s) => s.id);