@davesheffer/hunch 1.40.1 → 1.41.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.
package/dist/cli/index.js CHANGED
@@ -395,7 +395,7 @@ program
395
395
  }
396
396
  else {
397
397
  writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
398
- console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
398
+ console.log(" ✓ local auto-commit preference OFF (an explicit hook --commit flag still takes precedence)");
399
399
  }
400
400
  }
401
401
  // Collected so the linked-worktree note below can say what actually happened
@@ -403,10 +403,21 @@ program
403
403
  const installs = [];
404
404
  if (isGitRepo(root)) {
405
405
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
406
- const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
406
+ // A default is not an explicit request to change the repo-wide hook.
407
+ // Linked init keeps an installed shared block's existing commit choice.
408
+ let hookCommit = opts.autoCommit;
409
+ if (hookCommit && isLinkedWorktree(root)) {
410
+ const existingHook = hookReport(root).postCommit;
411
+ if (existingHook.state === "installed")
412
+ hookCommit = existingHook.flags?.includes("--commit") ?? false;
413
+ }
414
+ const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: hookCommit, localOnly: syncToOverlay });
407
415
  installs.push(h);
408
- for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`))
416
+ for (const line of formatHookInstall(root, "post-commit hook", h, ` (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${hookCommit ? " — auto-commit on" : ""}`))
409
417
  console.log(line);
418
+ if (opts.autoCommit === false && hookReport(root).postCommit.flags?.includes("--commit")) {
419
+ console.log(" ⚠ the shared post-commit hook still auto-commits. To change it, re-run `hunch init --no-auto-commit` using a global install or the main checkout's Hunch.");
420
+ }
410
421
  const pm = installPostMergeHook(root, inv.shell);
411
422
  installs.push(pm);
412
423
  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)"))
@@ -4570,6 +4581,48 @@ program
4570
4581
  console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
4571
4582
  store.close();
4572
4583
  });
4584
+ // ---- retire-constraint -----------------------------------------------------
4585
+ program
4586
+ .command("retire-constraint")
4587
+ .description("Retire an active constraint: close its valid-time window (invalidate, don't delete) so `hunch check` and the strict hook stop enforcing it.")
4588
+ .argument("<id>", "constraint id (con_*)")
4589
+ .option("--reason <text>", "why it's being retired — recorded in the commit body, never the subject")
4590
+ .action((id, opts) => {
4591
+ const { store, root } = storeFor();
4592
+ const existing = store.getRec("constraints", id);
4593
+ if (!existing) {
4594
+ store.close();
4595
+ return fail(`constraint "${id}" not found`);
4596
+ }
4597
+ if (existing.status === "retired") {
4598
+ store.close();
4599
+ return fail(`constraint "${id}" is already retired — window closed at ${existing.valid_to?.slice(0, 10) ?? "unknown"}.`);
4600
+ }
4601
+ // Which store already holds the record decides where the close is written —
4602
+ // same rule supersede applies to a decision's home (decisionMemoryHome).
4603
+ const home = store.getPrivateRec("constraints", id) ? "private" : "public";
4604
+ const retired = store.retireConstraint(existing);
4605
+ store.reindex();
4606
+ // Public grounding docs (CLAUDE.md's Top invariants list) are a publishable
4607
+ // artifact: flushMemoryHome regenerates them on the SAME commit when auto-commit
4608
+ // is on, but no commit happens to carry that refresh when it's off, so do it here
4609
+ // too — otherwise a retired constraint keeps showing as enforced on disk (the same
4610
+ // gap record-constraint and `conform --add` close for their own capture path).
4611
+ if (home === "public" && !store.autoCommit)
4612
+ refreshExistingGrounding(root, store);
4613
+ // `--reason` goes in the commit BODY, never the subject: `hunch log`'s move
4614
+ // classifier (src/core/memorylog.ts) regexes the commit SUBJECT for keywords
4615
+ // like "supersed"/"repair"/"adopt"/"capture", so free-form reason text landing
4616
+ // in the subject could accidentally match one and misclassify the move.
4617
+ const willCommit = home === "private" ? store.privateAutoCommit : store.autoCommit;
4618
+ const message = `hunch: retire constraint ${id}${opts.reason ? `\n\n${opts.reason}` : ""}`;
4619
+ pumpMemoryHome(store, root, home, message);
4620
+ console.log(`✓ ${retired.id} retired — window closed at ${retired.valid_to?.slice(0, 10)}.`);
4621
+ if (opts.reason && !willCommit) {
4622
+ console.log(" ⚠ --reason is not recorded anywhere: auto-commit is off, so no commit message captured it.");
4623
+ }
4624
+ store.close();
4625
+ });
4573
4626
  // ---- firmness (agent-hook enforcement level) ------------------------------
4574
4627
  program
4575
4628
  .command("firmness")
@@ -5212,7 +5265,7 @@ program
5212
5265
  // from this file. No diff exists yet, so this is context — "don't re-add X" —
5213
5266
  // not a block; the commit-time `hunch check` does the actual gating.
5214
5267
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
5215
- const recentTasks = taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target)), target);
5268
+ const recentTasks = taskSelectionSupplements(store.selectTasksAuto(target, buildTaskRankingQuery(root, hookReportTaskId(root, provider, evt), target, { excludeTargetDeliveries: true })), target);
5216
5269
  const hasContent = ctx.constraints.length ||
5217
5270
  ctx.decisions.length ||
5218
5271
  ctx.bugs.length ||
@@ -5,7 +5,15 @@ export declare const HUNCH_DIR = ".hunch";
5
5
  * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
6
6
  * symbol names too: they contain no backslashes. This does NOT make a path
7
7
  * repo-relative — an absolute path passes through with its separators flipped,
8
- * unchanged otherwise; use `repoRelativeTarget` for that. */
8
+ * unchanged otherwise; use `repoRelativeTarget` for that.
9
+ *
10
+ * Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
11
+ * Windows-style path with a literal separator, or a POSIX path whose filename
12
+ * legitimately contains a backslash BYTE (illegal on Windows, legal on
13
+ * POSIX/git) — the string alone can't say which, and this function always
14
+ * assumes the former. A caller that can check the filesystem/git history and
15
+ * needs the correct answer for a real file should decide from that evidence
16
+ * instead of trusting this blindly. */
9
17
  export declare function toPosixTarget(target: string): string;
10
18
  /** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
11
19
  * to Write, or a glob whose concrete segments aren't literal): resolve the longest
@@ -9,7 +9,15 @@ export const HUNCH_DIR = ".hunch";
9
9
  * `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
10
10
  * symbol names too: they contain no backslashes. This does NOT make a path
11
11
  * repo-relative — an absolute path passes through with its separators flipped,
12
- * unchanged otherwise; use `repoRelativeTarget` for that. */
12
+ * unchanged otherwise; use `repoRelativeTarget` for that.
13
+ *
14
+ * Inherently ambiguous for a string like `docs/notes\notes.md`: it could be a
15
+ * Windows-style path with a literal separator, or a POSIX path whose filename
16
+ * legitimately contains a backslash BYTE (illegal on Windows, legal on
17
+ * POSIX/git) — the string alone can't say which, and this function always
18
+ * assumes the former. A caller that can check the filesystem/git history and
19
+ * needs the correct answer for a real file should decide from that evidence
20
+ * instead of trusting this blindly. */
13
21
  export function toPosixTarget(target) {
14
22
  return target.replace(/\\/g, "/").replace(/^\.\//, "");
15
23
  }
@@ -2,6 +2,8 @@ import { type RankingQuery } from "./taskRanking.js";
2
2
  export interface TaskQueryOptions {
3
3
  /** A phrase the caller has (hunch_context's target when it is not a path). */
4
4
  phrase?: string | null;
5
+ /** Automatic hooks must not feed their own target delivery back into ranking. */
6
+ excludeTargetDeliveries?: boolean;
5
7
  now?: number;
6
8
  }
7
9
  export declare function buildTaskRankingQuery(root: string, taskId: string | null | undefined, target: string, options?: TaskQueryOptions): RankingQuery;
@@ -22,6 +22,8 @@ export function buildTaskRankingQuery(root, taskId, target, options = {}) {
22
22
  try {
23
23
  const report = readTaskReport(root, taskId);
24
24
  for (const d of report.deliveries) {
25
+ if (options.excludeTargetDeliveries && d.target && normalizePath(d.target) === normalizePath(target))
26
+ continue;
25
27
  if (d.target && targetLooksLikePath(d.target))
26
28
  files.add(normalizePath(d.target));
27
29
  for (const r of d.records)
@@ -257,7 +257,9 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
257
257
  clearTimeout(drainTimer);
258
258
  child.stdout.destroy();
259
259
  child.stderr.destroy();
260
- resolveResult({ code, timedOut, cancelled, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
260
+ // Windows taskkill produces exit code 1. That is the runner terminating
261
+ // the process, not an independently observed command result.
262
+ resolveResult({ code: timedOut || cancelled ? null : code, timedOut, cancelled, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
261
263
  };
262
264
  const stopTree = () => {
263
265
  if (settled || cleanupTimer)
@@ -90,6 +90,14 @@ export function renderTaskReport(report) {
90
90
  }
91
91
  else
92
92
  lines.push("Checked No independent command result recorded.");
93
+ // Only a later run of the same argv supersedes a check. A different check
94
+ // passing (or an edit to source) cannot clear an earlier failure.
95
+ const latestChecks = new Map(report.checks.map(c => [JSON.stringify(c.command), c]));
96
+ const failures = [...latestChecks.values()].filter(c => c.cancelled || c.timed_out || c.exit_code !== 0);
97
+ if (failures.length) {
98
+ const names = failures.slice(0, 3).map(c => plain(c.label)).join("; ");
99
+ lines.push(`Failures ${failures.length} unresolved check(s): ${names}${failures.length > 3 ? "; more in the evidence view" : ""}`);
100
+ }
93
101
  lines.push(`Evidence hunch report ${report.task.task_id} --html`);
94
102
  return lines.join("\n");
95
103
  }
@@ -109,7 +117,7 @@ export function renderTaskReportHtml(report, generatedAt = new Date().toISOStrin
109
117
  const refusals = report.refusals.map(r => `<article><span class="badge">DENIAL EMITTED</span><h3>${esc(r.record_id)}</h3><p>Hunch emitted a ${esc(r.kind)} denial for ${esc(r.target)}.</p><p>This does not establish that the host honored the denial or that a bug was prevented.</p><details><summary>Inspect gate evidence</summary><dl><dt>Observed</dt><dd>${esc(r.at)}</dd><dt>Event</dt><dd>${esc(r.event_id)}</dd><dt>Exact response reason digest</dt><dd>${esc(r.reason_hash)}</dd></dl></details></article>`).join("");
110
118
  const history = histories === null ? empty("Lesson history is unavailable. The task evidence above is retained.") : histories.map(h => `<article><h3>${esc(h.entries[0]?.record.title ?? h.reference.record_id)}</h3><p>Save and delivery observations show where a lesson appeared; they do not prove use or additional impact.</p>${h.entries.map(e => `<details><summary>${esc(e.task.title)} · ${esc(e.task.state)}</summary><p>${e.event === "save" ? "Saved" : "Delivered"} ${esc(e.at)}</p><p class="lesson">${esc(e.record.lesson)}</p><dl><dt>Exact revision</dt><dd>${esc(e.record.content_hash)}</dd><dt>Evidence reference</dt><dd>${esc(e.receipt_id ?? e.event_id)}</dd></dl><p>Inspect this task: <code>hunch report ${esc(e.task.task_id)} --html</code></p></details>`).join("")}${!h.index_complete ? empty("History indexing is incomplete. Refresh for additional retained evidence.") : ""}${h.truncated ? empty("Additional deliveries are available through the lesson history command.") : ""}</article>`).join("");
111
119
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>Hunch · ${esc(report.task.title)}</title><style>
112
- :root{color-scheme:light dark;--bg:#f4f7f4;--paper:#fff;--ink:#162e24;--muted:#53685b;--line:#d5e1d8;--accent:#276540}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 system-ui,sans-serif}main{max-width:920px;margin:auto;padding:48px 24px 80px}header{border-top:5px solid var(--accent);padding-top:24px;margin-bottom:40px}.eyebrow{font-size:12px;letter-spacing:.09em;color:var(--muted);font-weight:650}h1{font-size:clamp(28px,5vw,46px);line-height:1.15;letter-spacing:-.035em;margin:16px 0}h2{font-size:22px;margin:32px 0 12px}h3{font-size:18px;margin:12px 0 6px}p{margin:8px 0}article{background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:22px;margin:12px 0}.muted,dt{color:var(--muted)}.badge{font-size:11px;letter-spacing:.06em;border:1px solid var(--line);border-radius:30px;padding:5px 10px}.lesson{white-space:pre-wrap}a{color:var(--accent);text-underline-offset:3px}a:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:5px}summary{cursor:pointer;font-weight:600;padding:8px 0}details{margin-top:16px}pre,code,dd{overflow-wrap:anywhere;word-break:break-word}pre{white-space:pre-wrap;font:13px/1.6 ui-monospace,monospace;padding:14px;background:var(--bg);border-radius:8px}dd{margin:0 0 12px}footer{border-top:1px solid var(--line);margin-top:36px;padding-top:20px;font-size:12px;color:var(--muted)}@media(prefers-color-scheme:dark){:root{--bg:#101b16;--paper:#17271e;--ink:#e3eee6;--muted:#a7b9ac;--line:#365041;--accent:#9cdbb0}}@media print{details>*{display:block}body{background:white;color:black}article{break-inside:avoid}}
120
+ :root{color-scheme:light dark;--bg:#f4f7f4;--paper:#fff;--ink:#162e24;--muted:#53685b;--line:#d5e1d8;--accent:#276540}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 system-ui,sans-serif}main{max-width:920px;margin:auto;padding:48px 24px 80px}header{border-top:5px solid var(--accent);padding-top:24px;margin-bottom:40px}.eyebrow{font-size:12px;letter-spacing:.09em;color:var(--muted);font-weight:650}h1{font-size:clamp(28px,5vw,46px);line-height:1.15;letter-spacing:-.035em;margin:16px 0}h2{font-size:22px;margin:32px 0 12px}h3{font-size:18px;margin:12px 0 6px}p{margin:8px 0}article{background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:22px;margin:12px 0}.muted,dt{color:var(--muted)}.badge{font-size:11px;letter-spacing:.06em;border:1px solid var(--line);border-radius:30px;padding:5px 10px}.lesson{white-space:pre-wrap}a{color:var(--accent);text-underline-offset:3px}a:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:5px}summary{cursor:pointer;font-weight:600;padding:8px 0}details{margin-top:16px}pre,code,dd,footer{overflow-wrap:anywhere;word-break:break-word}pre{white-space:pre-wrap;font:13px/1.6 ui-monospace,monospace;padding:14px;background:var(--bg);border-radius:8px}dd{margin:0 0 12px}footer{border-top:1px solid var(--line);margin-top:36px;padding-top:20px;font-size:12px;color:var(--muted)}@media(prefers-color-scheme:dark){:root{--bg:#101b16;--paper:#17271e;--ink:#e3eee6;--muted:#a7b9ac;--line:#365041;--accent:#9cdbb0}}@media print{details>*{display:block}body{background:white;color:black}article{break-inside:avoid}}
113
121
  </style></head><body><main><header><div class="eyebrow">HUNCH · TASK EVIDENCE · LOCAL REPORT</div><h1>${esc(report.task.title)}</h1><p>${records.length ? "Project experience carried into this task." : "What Hunch could observe in this task."}</p><p class="muted">${esc(report.task.state)} · ${esc(report.task.started_at)} · ${esc(report.task.task_id)}</p></header>${section("1. What the project remembered", lessons || empty("No lesson snapshot is available for this task."))}${section("2. What reached the agent", deliveries || empty("No task-linked delivery was observed. This does not prove the agent was disconnected."))}${section("3. How the agent says it applied", claims || empty("Contribution is unverified. A context delivery alone does not establish use."))}${section("4. What was checked", checks || empty("No independent command result was recorded."))}${section("5. How the change conforms to delivered rules", rules || empty("No delivered rule was evaluated. Finishing a task, or hunch task conform, evaluates each delivered lesson's declared rule against the changed files."))}${saves ? section("What was saved for future tasks", saves) : ""}${refusals ? section("When Hunch intervened", refusals) : ""}${section("Where this lesson appeared", (history || empty("No lesson history is included in this view.")) + (histories && historyRecords(report).length > histories.length ? empty(`History shown for ${histories.length} of ${historyRecords(report).length} lesson revisions. Inspect another revision with hunch report --lesson <record-id> --kind <kind> --revision <content-hash>.`) : ""))}${section("What remains unknown", report.unknowns.length ? `<ul>${report.unknowns.map(x => `<li>${esc(x)}</li>`).join("")}</ul>` : empty("The report makes no additional causal claim."))}<footer>Generated ${esc(generatedAt)}. This is a saved snapshot, not a live source check.<br>After editing source, refresh with <code>hunch report ${esc(report.task.task_id)} --html</code>.<br>Observed evidence, not a score or an estimate of bugs prevented.<br>Report ${esc(report.content_hash)}<br>Local report may contain private project memory. Keep it within the project's authorized audience.</footer></main></body></html>`;
114
122
  }
115
123
  //# sourceMappingURL=taskReportRender.js.map
@@ -159,6 +159,43 @@ export declare function gitCommonDir(cwd: string): string;
159
159
  /** True when `cwd` is inside a LINKED worktree (not the main checkout): its own git
160
160
  * dir differs from the shared common dir. Used by `hunch doctor` and setup messaging. */
161
161
  export declare function isLinkedWorktree(cwd: string): boolean;
162
+ /** Every worktree of this repo (the main checkout AND every linked one), as absolute
163
+ * paths — parsed from `git worktree list --porcelain`. Named for what it returns, not
164
+ * `linkedWorktreePaths`: unlike `isLinkedWorktree`, this deliberately includes the main
165
+ * checkout, since callers need it for self-exclusion. Lets a write tool guess where an
166
+ * auto-commit actually belongs when its own evidence (e.g. a decision's related_files)
167
+ * doesn't exist at the resolved root but does exist in a sibling worktree — a caller
168
+ * working in a linked worktree that forgot to pass a cwd hint. Empty on any error /
169
+ * non-repo. */
170
+ export declare function worktreePaths(root: string): string[];
171
+ /** True when `root`'s OWN history has ever tracked `file` — EXACTLY `file`, not
172
+ * merely something under it — at HEAD. Distinguishes an ordinary delete/rename
173
+ * recorded correctly at the resolved root (the file is gone here because THIS
174
+ * checkout removed it, and a sibling worktree that branched earlier simply
175
+ * predates the change) from a genuine misroute: without this check, deleting or
176
+ * renaming a tracked file at the correct root reads as evidence the write
177
+ * belonged in whichever sibling still has the old path, refusing a correct write
178
+ * and pointing the caller at the wrong worktree. `git log --name-only` alone
179
+ * treats `file` as an ordinary PATHSPEC: a directory name or a glob matches
180
+ * anything under/matching it, so "src" or "*.ts" would read as "known to
181
+ * history" whenever ANYTHING under that directory was ever tracked, anywhere in
182
+ * the repo. `:(literal)` disables glob/magic interpretation, and checking the
183
+ * commit's OWN changed-file list for an exact string match (not just "the
184
+ * pathspec matched something") confirms `file` was itself a tracked PATH.
185
+ *
186
+ * `--diff-merges=first-parent` makes a merge commit report its own changes like
187
+ * an ordinary commit instead of being skipped by diff simplification (the
188
+ * `--name-only` default prints nothing at all for a merge). `-z` (NUL-separated,
189
+ * read via the untrimmed `gitRawSafe`) sidesteps git's path-quoting rules
190
+ * entirely, so a name containing a quote, backslash, control character, or a
191
+ * leading/trailing space compares exactly rather than being C-quoted or trimmed
192
+ * away.
193
+ *
194
+ * `file` must be non-empty: `-z` NUL-terminates every entry rather than
195
+ * separating them, so splitting on "\0" always yields a trailing "" element —
196
+ * and an empty pathspec matches everything, so an empty `file` would otherwise
197
+ * read as "known to history" for any repo with history at all. */
198
+ export declare function pathKnownToHistory(root: string, file: string): boolean;
162
199
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
163
200
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
164
201
  export declare function currentBranch(cwd: string): string;
@@ -3,7 +3,7 @@
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { devNull, tmpdir } from "node:os";
6
- import { isAbsolute, resolve, join, basename, dirname, relative, sep } from "node:path";
6
+ import { isAbsolute, resolve, join, basename, dirname, relative, sep, posix } from "node:path";
7
7
  import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
@@ -1927,6 +1927,61 @@ export function isLinkedWorktree(cwd) {
1927
1927
  // macOS, or long vs 8.3/case variants on Windows. Compare physical identity.
1928
1928
  return !sameFilesystemEntry(own, common);
1929
1929
  }
1930
+ /** Every worktree of this repo (the main checkout AND every linked one), as absolute
1931
+ * paths — parsed from `git worktree list --porcelain`. Named for what it returns, not
1932
+ * `linkedWorktreePaths`: unlike `isLinkedWorktree`, this deliberately includes the main
1933
+ * checkout, since callers need it for self-exclusion. Lets a write tool guess where an
1934
+ * auto-commit actually belongs when its own evidence (e.g. a decision's related_files)
1935
+ * doesn't exist at the resolved root but does exist in a sibling worktree — a caller
1936
+ * working in a linked worktree that forgot to pass a cwd hint. Empty on any error /
1937
+ * non-repo. */
1938
+ export function worktreePaths(root) {
1939
+ const out = gitSafe(["worktree", "list", "--porcelain"], root);
1940
+ if (!out)
1941
+ return [];
1942
+ const paths = [];
1943
+ for (const line of out.split("\n")) {
1944
+ if (line.startsWith("worktree "))
1945
+ paths.push(line.slice("worktree ".length).trim());
1946
+ }
1947
+ return paths;
1948
+ }
1949
+ /** True when `root`'s OWN history has ever tracked `file` — EXACTLY `file`, not
1950
+ * merely something under it — at HEAD. Distinguishes an ordinary delete/rename
1951
+ * recorded correctly at the resolved root (the file is gone here because THIS
1952
+ * checkout removed it, and a sibling worktree that branched earlier simply
1953
+ * predates the change) from a genuine misroute: without this check, deleting or
1954
+ * renaming a tracked file at the correct root reads as evidence the write
1955
+ * belonged in whichever sibling still has the old path, refusing a correct write
1956
+ * and pointing the caller at the wrong worktree. `git log --name-only` alone
1957
+ * treats `file` as an ordinary PATHSPEC: a directory name or a glob matches
1958
+ * anything under/matching it, so "src" or "*.ts" would read as "known to
1959
+ * history" whenever ANYTHING under that directory was ever tracked, anywhere in
1960
+ * the repo. `:(literal)` disables glob/magic interpretation, and checking the
1961
+ * commit's OWN changed-file list for an exact string match (not just "the
1962
+ * pathspec matched something") confirms `file` was itself a tracked PATH.
1963
+ *
1964
+ * `--diff-merges=first-parent` makes a merge commit report its own changes like
1965
+ * an ordinary commit instead of being skipped by diff simplification (the
1966
+ * `--name-only` default prints nothing at all for a merge). `-z` (NUL-separated,
1967
+ * read via the untrimmed `gitRawSafe`) sidesteps git's path-quoting rules
1968
+ * entirely, so a name containing a quote, backslash, control character, or a
1969
+ * leading/trailing space compares exactly rather than being C-quoted or trimmed
1970
+ * away.
1971
+ *
1972
+ * `file` must be non-empty: `-z` NUL-terminates every entry rather than
1973
+ * separating them, so splitting on "\0" always yields a trailing "" element —
1974
+ * and an empty pathspec matches everything, so an empty `file` would otherwise
1975
+ * read as "known to history" for any repo with history at all. */
1976
+ export function pathKnownToHistory(root, file) {
1977
+ if (!file)
1978
+ return false;
1979
+ const out = gitRawSafe(["log", "-n", "1", "--format=", "--name-only", "-z", "--diff-merges=first-parent", "HEAD", "--", `:(literal)${file}`], root);
1980
+ // Git reports repository-relative paths without dot segments. Normalize only
1981
+ // slash segments for comparison, retaining literal backslashes and whitespace.
1982
+ const comparable = posix.normalize(file);
1983
+ return !!out && out.split("\0").some((entry) => entry === comparable);
1984
+ }
1930
1985
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
1931
1986
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
1932
1987
  export function currentBranch(cwd) {
@@ -17,13 +17,13 @@ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpath
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
19
19
  import { homedir } from "node:os";
20
- import { hooksDir, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
20
+ import { hooksDir, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
21
21
  import { initiatorChildEnv } from "../synthesis/initiator.js";
22
- import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
22
+ import { HUNCH_NPX_PACKAGE_SPEC, HUNCH_PACKAGE_NAME } from "../core/version.js";
23
23
  const MARK = "# >>> hunch post-commit >>>";
24
24
  const ENDMARK = "# <<< hunch post-commit <<<";
25
- const GIT_CONTEXT = { checkoutType: "$3" };
26
- const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE" };
25
+ const GIT_CONTEXT = { checkoutType: "$3", portable: false };
26
+ const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE", portable: true };
27
27
  const LOCAL_ONLY_LINE = " export HUNCH_SYNTH_PROVIDER=deterministic";
28
28
  // Tolerates hand-added quotes: missing the line would let a re-run delete it.
29
29
  const LOCAL_ONLY_RE = /^\s*export HUNCH_SYNTH_PROVIDER=["']?deterministic["']?\s*$/m;
@@ -497,7 +497,7 @@ function snippetFor(t, mark, build, localInvocation) {
497
497
  // Untracked local hook: this machine's invocation is fine; placement is the fix.
498
498
  return `# insert ABOVE the final exec/exit in ${t.hookName}\n${build(localInvocation, GIT_CONTEXT)}`;
499
499
  }
500
- return build(PORTABLE_HOOK_INVOCATION, GIT_CONTEXT);
500
+ return build(PORTABLE_HOOK_INVOCATION, { ...GIT_CONTEXT, portable: true });
501
501
  }
502
502
  /** The repo's worktree tops as `git worktree list --porcelain` reports them:
503
503
  * `linked` is every entry AFTER the first (the first is always the main
@@ -711,8 +711,58 @@ function installManagedBlock(root, hookName, mark, end, build, invocation) {
711
711
  chmodSync(hookPath, 0o755);
712
712
  return { path: hookPath, action: "appended", ...sharedNote };
713
713
  }
714
+ /** True when `root` is a checkout of Hunch's OWN source tree (its own
715
+ * package.json declares this exact package name), not merely a project that
716
+ * depends on Hunch — the same detection `hunch update` already uses to
717
+ * refuse running inside this repo (src/cli/update.ts). */
718
+ function isHunchRepoRoot(root) {
719
+ try {
720
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
721
+ return pkg.name === HUNCH_PACKAGE_NAME;
722
+ }
723
+ catch {
724
+ return false;
725
+ }
726
+ }
727
+ /** For a doc-regenerating hook (post-commit's `sync`, post-merge's grounding
728
+ * refresh) installed inside Hunch's own checkout, force the invocation to
729
+ * that checkout's own in-tree build; every other project keeps the passed
730
+ * invocation unchanged. Without this, the hook bakes in whatever build the
731
+ * INSTALLING process happened to be running (a stale/newer global install,
732
+ * npx, another checkout) — which then regenerates CLAUDE.md/AGENTS.md/etc.
733
+ * from a DIFFERENT generator than this checkout's own, silently drifting the
734
+ * committed docs out of sync (caught only by a doc-freshness test).
735
+ *
736
+ * Anchored to the MAIN worktree, not `root` itself: the hook FILE this
737
+ * produces is the one `hooksDir` resolves to, shared across every linked
738
+ * worktree of the repo, but `root` is merely whichever worktree happened to
739
+ * run the install — often an ephemeral one under an agent-worktree workflow.
740
+ * Baking in a linked worktree's own path would silently break every OTHER
741
+ * worktree's shared hook the moment that one is `git worktree remove`d.
742
+ *
743
+ * Existence-checked rather than assumed: a Hunch-named checkout that ships
744
+ * no `src/` (a sparse checkout, or one built from the published tarball —
745
+ * `dist/**` only) would otherwise get a hook block that silently no-ops
746
+ * forever (it's wrapped `|| true`) — the exact invisible-staleness failure
747
+ * this override exists to close, just moved one step. */
748
+ function selfBuildInvocation(root, invocation) {
749
+ const main = mainWorktreeRoot(root);
750
+ if (!isHunchRepoRoot(main))
751
+ return invocation;
752
+ const entry = join(main, "src", "cli", "index.ts");
753
+ const tsx = join(main, "node_modules", "tsx", "dist", "cli.mjs");
754
+ if (!existsSync(entry) || !existsSync(tsx))
755
+ return invocation;
756
+ // process.execPath against tsx's own cli.mjs, not a bare `npx tsx`: a GUI git
757
+ // client or non-interactive shell commonly lacks nvm's npx/tsx on PATH — the
758
+ // same reason resolveInvocation's installed/dist branches avoid a bare `node`
759
+ // (src/cli/invocation.ts).
760
+ return `${JSON.stringify(process.execPath)} ${JSON.stringify(tsx)} ${JSON.stringify(entry)}`;
761
+ }
714
762
  export function installPostCommitHook(root, invocation, opts = {}) {
715
- return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv, _ctx, keep) => block(inv, opts, keep), invocation);
763
+ // Resolve shared invocation/flags before selecting the in-tree generator.
764
+ // Rewriting the caller first hides that it came from a linked worktree.
765
+ return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv, ctx, keep) => block(ctx.portable ? inv : selfBuildInvocation(root, inv), opts, keep), invocation);
716
766
  }
717
767
  const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
718
768
  const PRE_END = "# <<< hunch pre-commit <<<";
@@ -787,7 +837,10 @@ const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreacha
787
837
  * a repo carrying only one half (an older install, or a hand-edited hook)
788
838
  * gets the other appended rather than clobbered. */
789
839
  export function installPostMergeHook(root, invocation) {
790
- const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, groundingMergeBlock, invocation);
840
+ // Only the grounding half regenerates committed docs; repair-provenance writes
841
+ // no docs (it only queues a match for a human to confirm), so it keeps the
842
+ // passed invocation unchanged — see selfBuildInvocation.
843
+ const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, (inv, ctx) => groundingMergeBlock(ctx.portable ? inv : selfBuildInvocation(root, inv)), invocation);
791
844
  const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock, invocation);
792
845
  if (!writes(grounding) && !writes(repair)) {
793
846
  return { ...grounding, snippet: `${grounding.snippet}\n${stripComment(repair.snippet ?? "", grounding.manager)}` };
@@ -19,15 +19,25 @@ import { installMergeDriver } from "./mergeDriver.js";
19
19
  import { ensureGitignore } from "./gitignore.js";
20
20
  import { resolveInvocation } from "../cli/invocation.js";
21
21
  export const DEFAULT_TEAM_REF = "refs/heads/main";
22
+ // check-ref-format without --branch checks syntax only, independent of the
23
+ // repository, ref existence, or remote state. Retain just its last successful
24
+ // input: one shared sync can otherwise spawn Git dozens of times for the same
25
+ // name. Route/remote validation still runs afresh, and failed invocations retry.
26
+ let lastValidTeamRef;
22
27
  export function safeTeamRef(value) {
23
28
  const ref = value.trim();
24
29
  if (!ref.startsWith("refs/heads/") || ref === "refs/heads/")
25
30
  return null;
31
+ if (ref === lastValidTeamRef)
32
+ return ref;
26
33
  const checked = spawnSync("git", ["check-ref-format", ref], {
27
34
  stdio: "ignore",
28
35
  env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" },
29
36
  });
30
- return checked.status === 0 ? ref : null;
37
+ if (checked.status !== 0)
38
+ return null;
39
+ lastValidTeamRef = ref;
40
+ return ref;
31
41
  }
32
42
  export function teamSharedRef(team) {
33
43
  return team.shared_ref ?? DEFAULT_TEAM_REF;
@@ -8,8 +8,133 @@
8
8
  */
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { HunchStore } from "../store/hunchStore.js";
11
+ import { type StateFacet } from "../core/stateContract.js";
11
12
  import type { Symbol } from "../core/types.js";
12
13
  export declare const publicationVocabulary: (hunchDir: string) => RegExp[];
14
+ /** The cwd-hint fallback (see `cwdHintField` above) only helps when the CALLER
15
+ * remembers to pass it — a subagent spawned straight into its own linked
16
+ * worktree has no reason to think of the session's "starting directory" as
17
+ * different from its own, so the hint is silently omitted and an auto-committing
18
+ * write lands wherever `root` last was (often the primary checkout, on its
19
+ * default branch). This is a backstop, NOT a complete fix: it only catches a
20
+ * related file that's new/untracked at the resolved root but already exists in
21
+ * a sibling worktree — the common "edited an existing tracked file" case is
22
+ * invisible to a pure existence check (the file exists at every worktree, just
23
+ * with different content).
24
+ *
25
+ * Used by `misrouteGuard` below to cover every auto-committing write tool that
26
+ * names files: hunch_record_decision's related_files, hunch_record_correction's
27
+ * scope_hint_file, hunch_record_finding's affected_files, and nuryel_write's
28
+ * decisions/findings/bugs/constraints facets via `fileEvidenceFor`. Every
29
+ * matching sibling is named as a candidate `cwd` and the write is refused
30
+ * rather than risked; it returns every match rather than the first, since
31
+ * confidently naming just one would let a caller that blindly retries as
32
+ * instructed land in the WRONG worktree — the same failure mode one level
33
+ * removed.
34
+ *
35
+ * Two false-positive guards, both required, not optional hardening:
36
+ * - Empty related_files, or no sibling worktree doing any better, is silent — a
37
+ * decision that legitimately precedes its code (no files yet) is unaffected.
38
+ * - A related_files entry ABSENT at the root is not automatically suspicious: an
39
+ * ordinary delete/rename recorded correctly at the resolved root produces the
40
+ * exact same shape (file gone here, still present in whichever sibling
41
+ * worktree branched before the change) as a genuine misroute.
42
+ * `pathKnownToHistory` distinguishes them — a path this checkout's own HEAD
43
+ * has ever tracked explains its absence without invoking a worktree guess.
44
+ *
45
+ * Coverage boundary: only checks the file evidence passed in THIS call (related_files
46
+ * / scope_hint_file / affected_files), not values inherited from an existing record
47
+ * on re-record/supersede — a call that omits its file field to rely on inheritance
48
+ * won't trip this guard even if it's happening in the wrong worktree. That's a
49
+ * deliberate tradeoff against false positives on stale evidence, not full coverage
50
+ * of every misrouted write.
51
+ *
52
+ * Absolute paths: agents naturally send them (edit-tool payloads and MCP roots
53
+ * are absolute), and a NAIVE `join(dir, "/abs/path")` produces a nonsense
54
+ * concatenated path that exists nowhere — the guard would find nothing to
55
+ * compare against ANY directory and stay silent, reproducing the exact bug the
56
+ * guard exists to catch. `existsUnder` checks an absolute entry AS ITSELF,
57
+ * scoped to whichever directory is under test (root, or each candidate worktree
58
+ * in turn) via `deepestContainer`, not `join()` — so an absolute path naming a
59
+ * file that exists only under a worktree's own tree is direct positive evidence
60
+ * for that worktree specifically, no existence heuristic required. Relativizing
61
+ * it against `root` alone can't represent a SIBLING worktree's absolute path at
62
+ * all: it's never under root's tree, so it relativizes to "../…" and gets
63
+ * dropped — silently reintroducing the bypass one level removed. A NESTED
64
+ * worktree (`root/.worktrees/x`) is the opposite trap: it IS lexically under
65
+ * root's own tree, so a plain "is this path under root" check wrongly
66
+ * attributes it to root — `deepestContainer` picks the MOST SPECIFIC
67
+ * (longest-path) containing worktree, not just any containing ancestor, so root
68
+ * never swallows a worktree nested inside it. An absolute path outside every
69
+ * known worktree matches none and correctly contributes nothing: not "absent
70
+ * here, check the siblings", just nothing to compare. Canonicalized
71
+ * (`canonicalRootPath`, the same symlink/case resolution `resolveActiveRoot`
72
+ * uses above) on both sides before comparing, so a worktree reached through a
73
+ * symlink or a case-different spelling isn't silently missed, and compared by
74
+ * exact path SEGMENT, not string prefix, so a real sibling `foo-other` doesn't
75
+ * false-match `foo` and a file genuinely named `..odd.ts` isn't mistaken for a
76
+ * `..` traversal. A DIRECTORY entry (or the empty-string/"." an agent might
77
+ * send meaning "the repo itself") contributes nothing either: `existsUnder`
78
+ * checks `isFile`, never mere existence, since a directory match would silently
79
+ * disable the guard for every OTHER entry in the same call — and
80
+ * `pathKnownToHistory` (below) confirms a git pathspec matched the EXACT entry,
81
+ * not merely something under/matching it, for the identical reason.
82
+ *
83
+ * A RELATIVE entry containing a ".." segment that escapes root's own tree when
84
+ * resolved against root is the same misroute as a worktree-rooted absolute
85
+ * path, merely spelled relatively — resolved against ROOT specifically (the
86
+ * only base the server actually has; never re-resolved per candidate in the
87
+ * loop below, which would answer a different, meaningless question) and then
88
+ * run through the identical absolute-path containment logic. A relative entry
89
+ * that does NOT escape root's tree keeps the original, intentional
90
+ * multi-location check instead: the SAME relative suffix tried against every
91
+ * candidate directory in turn — that's how a plain "this file's name" evidence
92
+ * has always found a sibling worktree holding a file by that name, and it must
93
+ * keep doing so for a NESTED worktree reached by a non-escaping relative path,
94
+ * whose containing worktree this does not (yet) distinguish from root itself —
95
+ * that residual gap fails OPEN (silently uncaught), never toward a false
96
+ * positive.
97
+ *
98
+ * Exported for direct unit testing — the candidate logic is otherwise reachable
99
+ * only through a full MCP client/server integration test. */
100
+ export declare const misroutedWorktreeCandidates: (root: string, relatedFiles: readonly string[]) => string[];
101
+ /** Normalizes file evidence for the misroute guard specifically. A literal
102
+ * backslash byte is a legal POSIX filename character, not a path separator,
103
+ * so blindly running every entry through `toPosixTarget` before
104
+ * `misroutedWorktreeCandidates` sees it can rewrite a real file's name into a
105
+ * fake extra path segment. The string alone can't disambiguate "Windows
106
+ * separator" from "literal POSIX byte", so this asks the filesystem AND git
107
+ * history instead (`namesRealEvidence`): when the RAW, un-normalized entry
108
+ * already names a real file, or is a relative path git knows was tracked at
109
+ * `root` (possibly since deleted/renamed), that settles it — the byte was
110
+ * literal — and the raw string is kept as-is, bypassing `toPosixTarget`
111
+ * entirely. Otherwise it normalizes through `toPosixTarget` exactly as
112
+ * before, preserving the legitimate Windows-separator case (a Windows-style
113
+ * path can never collide with a real or once-tracked POSIX file, since `\`
114
+ * cannot appear in a Windows filename to begin with). Scoped to the
115
+ * misroute-guard call sites only — the stored related_files/affected_files
116
+ * fields are still normalized separately, unaffected by this.
117
+ *
118
+ * Known accepted gap: a contrived collision — a file legitimately deleted
119
+ * from `root`'s history AND a genuine Windows-style path meaning something
120
+ * else present in a sibling worktree — resolves in favor of history and
121
+ * fails OPEN (no misroute reported), the same fail-open tradeoff
122
+ * `misroutedWorktreeCandidates` itself already documents elsewhere. */
123
+ export declare function guardEvidence(root: string, files: readonly string[]): string[];
124
+ /** The file-evidence field, if any, each nuryel_write facet carries: related_files
125
+ * for decisions, affected_files for findings/bugs, and scope (the glob list a
126
+ * constraint applies to — the same role hunch_record_correction's
127
+ * scope_hint_file plays) for constraints. A TOTAL map over StateFacet, not an
128
+ * open-ended ternary chain: adding a facet to STATE_FACETS without adding it
129
+ * here is a compile error, not a silent gap. null means the facet carries no
130
+ * comparable field and is left unguarded rather than inventing one. Exported so
131
+ * tests can assert the mapping directly instead of only probing it through live
132
+ * misroute behavior. */
133
+ export declare const FILE_EVIDENCE_FIELD: Record<StateFacet, string | null>;
134
+ /** `record` is caller-supplied z.record(string, unknown) — defensive by
135
+ * construction, so anything other than an array of strings reads as no
136
+ * evidence instead of throwing. */
137
+ export declare function fileEvidenceFor(facet: StateFacet, record: Record<string, unknown>): string[];
13
138
  /** Resolve a free-form target (symbol id / name / file path) to symbol records.
14
139
  * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
15
140
  * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
@@ -30,7 +30,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
30
30
  import { refreshExistingGrounding } from "../integrations/providers.js";
31
31
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
32
32
  import { workspacesConfig } from "../core/config.js";
33
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
33
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
34
34
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
35
35
  import { withWriteLock } from "../serve/writelock.js";
36
36
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
@@ -73,8 +73,8 @@ import { premiseEscalations } from "../core/premises.js";
73
73
  import { applyImportedAdrReview, pendingImportedAdrReviews } from "../core/importReview.js";
74
74
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken, HUMAN_CONFIRMATION_SCHEMA, isHumanConfirmationAnswer } from "../core/capturetoken.js";
75
75
  import { randomUUID } from "node:crypto";
76
- import { existsSync } from "node:fs";
77
- import { join } from "node:path";
76
+ import { existsSync, statSync, lstatSync } from "node:fs";
77
+ import { join, isAbsolute, resolve, relative, sep, parse, dirname } from "node:path";
78
78
  import { initiatorFromClient, withInitiator } from "../synthesis/initiator.js";
79
79
  const ok = (text) => ({ content: [{ type: "text", text }] });
80
80
  const err = (text) => ({ content: [{ type: "text", text }], isError: true });
@@ -153,6 +153,433 @@ const destinationNote = (destRoot) => {
153
153
  const branch = currentBranch(destRoot);
154
154
  return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
155
155
  };
156
+ /** The cwd-hint fallback (see `cwdHintField` above) only helps when the CALLER
157
+ * remembers to pass it — a subagent spawned straight into its own linked
158
+ * worktree has no reason to think of the session's "starting directory" as
159
+ * different from its own, so the hint is silently omitted and an auto-committing
160
+ * write lands wherever `root` last was (often the primary checkout, on its
161
+ * default branch). This is a backstop, NOT a complete fix: it only catches a
162
+ * related file that's new/untracked at the resolved root but already exists in
163
+ * a sibling worktree — the common "edited an existing tracked file" case is
164
+ * invisible to a pure existence check (the file exists at every worktree, just
165
+ * with different content).
166
+ *
167
+ * Used by `misrouteGuard` below to cover every auto-committing write tool that
168
+ * names files: hunch_record_decision's related_files, hunch_record_correction's
169
+ * scope_hint_file, hunch_record_finding's affected_files, and nuryel_write's
170
+ * decisions/findings/bugs/constraints facets via `fileEvidenceFor`. Every
171
+ * matching sibling is named as a candidate `cwd` and the write is refused
172
+ * rather than risked; it returns every match rather than the first, since
173
+ * confidently naming just one would let a caller that blindly retries as
174
+ * instructed land in the WRONG worktree — the same failure mode one level
175
+ * removed.
176
+ *
177
+ * Two false-positive guards, both required, not optional hardening:
178
+ * - Empty related_files, or no sibling worktree doing any better, is silent — a
179
+ * decision that legitimately precedes its code (no files yet) is unaffected.
180
+ * - A related_files entry ABSENT at the root is not automatically suspicious: an
181
+ * ordinary delete/rename recorded correctly at the resolved root produces the
182
+ * exact same shape (file gone here, still present in whichever sibling
183
+ * worktree branched before the change) as a genuine misroute.
184
+ * `pathKnownToHistory` distinguishes them — a path this checkout's own HEAD
185
+ * has ever tracked explains its absence without invoking a worktree guess.
186
+ *
187
+ * Coverage boundary: only checks the file evidence passed in THIS call (related_files
188
+ * / scope_hint_file / affected_files), not values inherited from an existing record
189
+ * on re-record/supersede — a call that omits its file field to rely on inheritance
190
+ * won't trip this guard even if it's happening in the wrong worktree. That's a
191
+ * deliberate tradeoff against false positives on stale evidence, not full coverage
192
+ * of every misrouted write.
193
+ *
194
+ * Absolute paths: agents naturally send them (edit-tool payloads and MCP roots
195
+ * are absolute), and a NAIVE `join(dir, "/abs/path")` produces a nonsense
196
+ * concatenated path that exists nowhere — the guard would find nothing to
197
+ * compare against ANY directory and stay silent, reproducing the exact bug the
198
+ * guard exists to catch. `existsUnder` checks an absolute entry AS ITSELF,
199
+ * scoped to whichever directory is under test (root, or each candidate worktree
200
+ * in turn) via `deepestContainer`, not `join()` — so an absolute path naming a
201
+ * file that exists only under a worktree's own tree is direct positive evidence
202
+ * for that worktree specifically, no existence heuristic required. Relativizing
203
+ * it against `root` alone can't represent a SIBLING worktree's absolute path at
204
+ * all: it's never under root's tree, so it relativizes to "../…" and gets
205
+ * dropped — silently reintroducing the bypass one level removed. A NESTED
206
+ * worktree (`root/.worktrees/x`) is the opposite trap: it IS lexically under
207
+ * root's own tree, so a plain "is this path under root" check wrongly
208
+ * attributes it to root — `deepestContainer` picks the MOST SPECIFIC
209
+ * (longest-path) containing worktree, not just any containing ancestor, so root
210
+ * never swallows a worktree nested inside it. An absolute path outside every
211
+ * known worktree matches none and correctly contributes nothing: not "absent
212
+ * here, check the siblings", just nothing to compare. Canonicalized
213
+ * (`canonicalRootPath`, the same symlink/case resolution `resolveActiveRoot`
214
+ * uses above) on both sides before comparing, so a worktree reached through a
215
+ * symlink or a case-different spelling isn't silently missed, and compared by
216
+ * exact path SEGMENT, not string prefix, so a real sibling `foo-other` doesn't
217
+ * false-match `foo` and a file genuinely named `..odd.ts` isn't mistaken for a
218
+ * `..` traversal. A DIRECTORY entry (or the empty-string/"." an agent might
219
+ * send meaning "the repo itself") contributes nothing either: `existsUnder`
220
+ * checks `isFile`, never mere existence, since a directory match would silently
221
+ * disable the guard for every OTHER entry in the same call — and
222
+ * `pathKnownToHistory` (below) confirms a git pathspec matched the EXACT entry,
223
+ * not merely something under/matching it, for the identical reason.
224
+ *
225
+ * A RELATIVE entry containing a ".." segment that escapes root's own tree when
226
+ * resolved against root is the same misroute as a worktree-rooted absolute
227
+ * path, merely spelled relatively — resolved against ROOT specifically (the
228
+ * only base the server actually has; never re-resolved per candidate in the
229
+ * loop below, which would answer a different, meaningless question) and then
230
+ * run through the identical absolute-path containment logic. A relative entry
231
+ * that does NOT escape root's tree keeps the original, intentional
232
+ * multi-location check instead: the SAME relative suffix tried against every
233
+ * candidate directory in turn — that's how a plain "this file's name" evidence
234
+ * has always found a sibling worktree holding a file by that name, and it must
235
+ * keep doing so for a NESTED worktree reached by a non-escaping relative path,
236
+ * whose containing worktree this does not (yet) distinguish from root itself —
237
+ * that residual gap fails OPEN (silently uncaught), never toward a false
238
+ * positive.
239
+ *
240
+ * Exported for direct unit testing — the candidate logic is otherwise reachable
241
+ * only through a full MCP client/server integration test. */
242
+ export const misroutedWorktreeCandidates = (root, relatedFiles) => {
243
+ if (!relatedFiles.length)
244
+ return [];
245
+ const worktrees = worktreePaths(root);
246
+ const canonRoot = canonicalRootPath(root);
247
+ // Resolve against the CANONICAL root, not the raw `root` string. `f` almost
248
+ // always does NOT exist yet (that's the whole point of checking it) --
249
+ // canonicalRootPath's realpath then throws and falls back to the raw,
250
+ // un-resolved path. Resolving against raw `root` first and canonicalizing
251
+ // second means that fallback returns a path still spelled however `root` was
252
+ // spelled -- if `root` itself reaches the repo through a symlink (reachable
253
+ // via `hunch mcp --root <path through a symlink>`, which pins the root and
254
+ // skips the client-root canonicalization path entirely), an ORDINARY relative
255
+ // filename that never escapes root's own tree gets compared against the
256
+ // CANONICAL root and reads as escaping, silently disabling the guard for the
257
+ // exact shape it exists to catch. Resolving against the already-canonical
258
+ // root first means the common non-existent-file case is correctly rooted even
259
+ // when realpath's later canonicalization attempt on the (still nonexistent)
260
+ // resolved path has nothing to resolve and simply returns it unchanged.
261
+ //
262
+ // The escape check below is deliberately LEXICAL (plain resolve(), never
263
+ // canonicalRootPath) on the resolved path: whether a relative entry escapes
264
+ // root is a question about ".." SEGMENTS, answered by pure string math, not
265
+ // about where a SYMLINK at that location happens to point. Canonicalizing
266
+ // (following symlinks) here is the wrong move: a symlink that physically
267
+ // lives inside root but targets a sibling worktree (e.g. a shared cache file)
268
+ // would resolve to a target outside canonRoot, misclassifying an entry that
269
+ // legitimately exists at root as "escaping", and promoting it to an absolute
270
+ // path that deepestContainer then wrongly attributes to the sibling -- a
271
+ // false positive, refusing a write that was already correctly homed.
272
+ // Symlink-following still happens, and is still needed, one step later in
273
+ // deepestContainer/existsUnder, whose job (does this path's CONTENT belong to
274
+ // this specific worktree) is a genuinely different question from whether a
275
+ // relative STRING escapes root.
276
+ const evidence = relatedFiles.filter(Boolean).map((f) => {
277
+ if (isAbsolute(f))
278
+ return f;
279
+ const resolved = resolve(canonRoot, f);
280
+ return isWithin(canonRoot, resolved) ? f : resolved;
281
+ });
282
+ if (!evidence.length)
283
+ return [];
284
+ if (evidence.some((f) => existsUnder(root, f, worktrees)))
285
+ return [];
286
+ if (relatedFiles.some((f) => f && !isAbsolute(f) && pathKnownToHistory(root, f)))
287
+ return [];
288
+ const here = canonRoot;
289
+ const candidates = [];
290
+ for (const candidate of worktrees) {
291
+ if (canonicalRootPath(candidate) === here)
292
+ continue;
293
+ if (evidence.some((f) => existsUnder(candidate, f, worktrees)))
294
+ candidates.push(candidate);
295
+ }
296
+ return candidates;
297
+ };
298
+ /** True when path segment `child` (canonical) lies inside directory `parent`
299
+ * (canonical) — an exact segment boundary, not a string prefix, so a sibling
300
+ * `foo-other` never false-matches `foo` and `parent` itself counts as inside. */
301
+ function isWithin(parent, child) {
302
+ const rel = relative(parent, child);
303
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
304
+ }
305
+ /** The worktree (from `worktrees`, which always includes root itself — see
306
+ * `worktreePaths`) whose OWN directory tree most SPECIFICALLY contains ABSOLUTE
307
+ * path `f` — the deepest/longest match, not merely any containing ancestor. Null
308
+ * when `f` is under none of the known worktrees. `canonicalRootPath` resolves the
309
+ * WHOLE path including `f`'s own final component, so a symlink reached from
310
+ * OUTSIDE a worktree that happens to point INSIDE one is still found (a symlink
311
+ * ALIAS to a whole worktree). This is the CANONICAL (symlink-resolved) ownership
312
+ * lens; `existsUnder` also checks a LEXICAL lens via `lexicalDeepestContainer`
313
+ * below, for the opposite symlink shape this lens alone cannot see. */
314
+ function deepestContainer(f, worktrees) {
315
+ const canonF = canonicalRootPath(f);
316
+ let best = null;
317
+ let bestLen = -1;
318
+ for (const wt of worktrees) {
319
+ const canonWt = canonicalRootPath(wt);
320
+ if (!isWithin(canonWt, canonF))
321
+ continue;
322
+ if (canonWt.length > bestLen) {
323
+ best = wt;
324
+ bestLen = canonWt.length;
325
+ }
326
+ }
327
+ return best;
328
+ }
329
+ /** Walks ABSOLUTE path `f` component by component to find its PHYSICAL
330
+ * location: a plain component is appended lexically and never resolved; a
331
+ * ".." only consults the kernel (fully resolving whatever symlink chain led
332
+ * there, via `canonicalRootPath`) when the component it's popping is ITSELF
333
+ * a symlink -- the one case where the kernel's ".."-cancellation point (the
334
+ * symlink's TARGET's parent) differs from the lexical parent. A ".." popping
335
+ * a plain directory stays a lexical `dirname()`, since lexical and kernel
336
+ * resolution already agree there. `usedKernel` tells the caller whether any
337
+ * kernel call actually happened, so it knows whether `path` is canonical (and
338
+ * therefore whether a worktree path must be canonicalized too before
339
+ * comparing) or still purely lexical. Splits on `\` as well as `/` ONLY when
340
+ * the platform separator is itself `\` (win32) -- on POSIX, `\` is an
341
+ * ordinary, legal filename byte, not a separator; unconditionally treating it
342
+ * as one chops a single real directory entry into two synthetic components,
343
+ * which can misattribute a file whose name happens to contain a literal
344
+ * backslash to whichever worktree the FIRST synthetic component's name
345
+ * matches. A win32 path may legitimately use either separator, so both are
346
+ * split there. */
347
+ function walkPhysicalLocation(f) {
348
+ const root = parse(f).root;
349
+ const splitter = sep === "\\" ? /[\\/]/ : "/";
350
+ const segments = f.slice(root.length).split(splitter).filter(Boolean);
351
+ let phys = root || sep;
352
+ let usedKernel = false;
353
+ for (const seg of segments) {
354
+ if (seg === ".")
355
+ continue;
356
+ if (seg !== "..") {
357
+ phys = join(phys, seg);
358
+ continue;
359
+ }
360
+ let base = phys;
361
+ try {
362
+ if (lstatSync(phys).isSymbolicLink()) {
363
+ base = canonicalRootPath(phys);
364
+ usedKernel = true;
365
+ }
366
+ }
367
+ catch {
368
+ // unreadable or nonexistent -- fall through to a plain lexical pop
369
+ }
370
+ phys = dirname(base);
371
+ }
372
+ return { path: phys, usedKernel };
373
+ }
374
+ /** The worktree whose own directory tree most specifically contains ABSOLUTE path
375
+ * `f` AS PHYSICALLY PLACED — its own final path component is never resolved
376
+ * through a symlink, unlike `deepestContainer`. A symlink physically sitting
377
+ * INSIDE a worktree, whose target lives elsewhere (e.g. a shared cache file), is
378
+ * still legitimately "at" that worktree: `deepestContainer` alone resolves such
379
+ * an `f` to whichever worktree the symlink's TARGET lives in, misattributing a
380
+ * file that genuinely exists where it's spelled and refusing an
381
+ * already-correctly-homed write. Ranked by the SAME "deepest/longest match" rule
382
+ * as the canonical lens, so a NESTED worktree still correctly out-ranks its own
383
+ * physically-containing parent root here too — this lens does not, on its own,
384
+ * relax that boundary.
385
+ *
386
+ * Delegates to `walkPhysicalLocation` (above), which decides PER PATH COMPONENT
387
+ * whether the kernel needs consulting at all: a ".." only triggers a kernel call
388
+ * when the specific component it pops is itself a symlink, never merely because
389
+ * `f` happens to contain a ".." somewhere. A purely lexical comparison (plain
390
+ * `resolve()`/`relative()`, no realpath anywhere) disagrees with the kernel on
391
+ * any `f` containing symlink-then-"..": direct kernel-level testing (a real
392
+ * `open()`/`readFile()` on a constructed symlink+".." path, comparing file
393
+ * identity, not just `resolve()`/`realpath()` string output) shows the kernel
394
+ * cancels ".." against the parent of the CURRENT LOOKUP DIRECTORY, which after
395
+ * traversing a symlink component is the symlink's TARGET's parent — exactly
396
+ * what `path_resolution(7)` documents. A purely lexical comparison disagrees
397
+ * with the kernel both as a false positive (a symlinked dir physically inside a
398
+ * sibling worktree, entry `<worktree>/cache/../stray.ts` really resolving
399
+ * OUTSIDE every worktree, wrongly attributed to `<worktree>`) and a false
400
+ * negative (an alias symlink at root pointing into a sibling worktree, entry
401
+ * `<root>/alias/../only-in-wt.ts` really resolving INSIDE the sibling, wrongly
402
+ * attributed to nothing). Resolving `dirname(f)` through the kernel
403
+ * UNCONDITIONALLY (regardless of whether "..") is stronger than the problem
404
+ * requires: it follows every symlink in the directory chain even with no ".."
405
+ * present at all, resolving straight through the plain symlinked-DIRECTORY
406
+ * shape this function exists to serve. Gating the kernel resolution on whether
407
+ * `f` contains ANY ".." segment, then resolving the WHOLE directory chain once
408
+ * triggered, is also too coarse: a harmless ".." with no symlink anywhere near
409
+ * it (e.g. `<worktree>/subdir/../shared-src/helper.ts`, where `subdir` is a
410
+ * plain directory and `shared-src` is a symlink) would still route the WHOLE
411
+ * path through the kernel, following `shared-src` and misattributing it. This
412
+ * function decides PER COMPONENT instead: walking `f` left to right, a plain
413
+ * component is appended lexically (never resolved); a ".." only consults the
414
+ * kernel when the component it's popping is ITSELF a symlink — the one case
415
+ * where the kernel's cancellation point differs from the lexical parent. Any
416
+ * symlink NOT immediately followed by a ".." is never resolved (preserving the
417
+ * physical-location contract), and every ".." that actually needs the kernel
418
+ * gets it, regardless of how many harmless ".."s or plain directories surround
419
+ * it. */
420
+ function lexicalDeepestContainer(f, worktrees) {
421
+ const { path: spelled, usedKernel } = walkPhysicalLocation(f);
422
+ let best = null;
423
+ let bestLen = -1;
424
+ for (const wt of worktrees) {
425
+ // `wt` must be canonicalized too, but ONLY to match a canonical `spelled`
426
+ // -- comparing a canonical spelled path against a raw wt string would
427
+ // spuriously fail to match even a worktree with no symlinks in its own
428
+ // path, on any platform where the canonical form differs cosmetically
429
+ // (e.g. a case-preserving vs case-folding mount). `git worktree list`
430
+ // already returns realpaths in every fixture this suite has produced, so
431
+ // this branch is defence-in-depth against a `worktrees` source that one
432
+ // day doesn't.
433
+ const compareWt = usedKernel ? canonicalRootPath(wt) : wt;
434
+ if (!isWithin(compareWt, spelled))
435
+ continue;
436
+ if (compareWt.length > bestLen) {
437
+ best = wt;
438
+ bestLen = compareWt.length;
439
+ }
440
+ }
441
+ return best;
442
+ }
443
+ /** True when `p` names an existing FILE — never a directory. `existsSync` alone
444
+ * would be true for a directory too; since a match here short-circuits the whole
445
+ * misroute check (`misroutedWorktreeCandidates`'s early `.some()`), a single
446
+ * DIRECTORY entry among a call's file evidence would silently disable the guard
447
+ * for every other entry in the same call — including the natural "." / "" an
448
+ * agent might send meaning "the repo" (`existsSync(join(dir, ""))` is
449
+ * `existsSync(dir)`, always true). */
450
+ function isFile(p) {
451
+ try {
452
+ return statSync(p).isFile();
453
+ }
454
+ catch {
455
+ return false;
456
+ }
457
+ }
458
+ function existsUnder(dir, f, worktrees) {
459
+ if (!f)
460
+ return false; // "" / "./" normalize to "" — nothing to compare, never `dir` itself
461
+ if (isAbsolute(f)) {
462
+ if (!isFile(f))
463
+ return false;
464
+ // `dir` owns `f` if EITHER lens says so, checked independently -- not a single
465
+ // merged ranking across both, which would let a longer-named sibling
466
+ // out-rank root's own genuine lexical claim by string length alone. Each
467
+ // lens resolves nested-vs-parent ambiguity within itself (see each
468
+ // function's own doc comment), so this OR never lets root reclaim a file
469
+ // that legitimately belongs to a worktree nested inside it.
470
+ const canonicalOwner = deepestContainer(f, worktrees);
471
+ if (canonicalOwner && canonicalRootPath(canonicalOwner) === canonicalRootPath(dir))
472
+ return true;
473
+ const lexicalOwner = lexicalDeepestContainer(f, worktrees);
474
+ return !!lexicalOwner && canonicalRootPath(lexicalOwner) === canonicalRootPath(dir);
475
+ }
476
+ return isFile(join(dir, f));
477
+ }
478
+ /** True when `f` names evidence the disambiguation in `guardEvidence` can trust
479
+ * as real: an existing file on disk (absolute, or relative under `root`), or —
480
+ * for a relative entry only — one git already knows was tracked at `root`
481
+ * (covers a since-deleted/renamed file, which has nothing left on disk to
482
+ * check). */
483
+ function namesRealEvidence(root, f) {
484
+ return isFile(isAbsolute(f) ? f : join(root, f)) || (!isAbsolute(f) && pathKnownToHistory(root, f));
485
+ }
486
+ /** Normalizes file evidence for the misroute guard specifically. A literal
487
+ * backslash byte is a legal POSIX filename character, not a path separator,
488
+ * so blindly running every entry through `toPosixTarget` before
489
+ * `misroutedWorktreeCandidates` sees it can rewrite a real file's name into a
490
+ * fake extra path segment. The string alone can't disambiguate "Windows
491
+ * separator" from "literal POSIX byte", so this asks the filesystem AND git
492
+ * history instead (`namesRealEvidence`): when the RAW, un-normalized entry
493
+ * already names a real file, or is a relative path git knows was tracked at
494
+ * `root` (possibly since deleted/renamed), that settles it — the byte was
495
+ * literal — and the raw string is kept as-is, bypassing `toPosixTarget`
496
+ * entirely. Otherwise it normalizes through `toPosixTarget` exactly as
497
+ * before, preserving the legitimate Windows-separator case (a Windows-style
498
+ * path can never collide with a real or once-tracked POSIX file, since `\`
499
+ * cannot appear in a Windows filename to begin with). Scoped to the
500
+ * misroute-guard call sites only — the stored related_files/affected_files
501
+ * fields are still normalized separately, unaffected by this.
502
+ *
503
+ * Known accepted gap: a contrived collision — a file legitimately deleted
504
+ * from `root`'s history AND a genuine Windows-style path meaning something
505
+ * else present in a sibling worktree — resolves in favor of history and
506
+ * fails OPEN (no misroute reported), the same fail-open tradeoff
507
+ * `misroutedWorktreeCandidates` itself already documents elsewhere. */
508
+ export function guardEvidence(root, files) {
509
+ return files.map((f) => {
510
+ const normalized = toPosixTarget(f);
511
+ return f && normalized !== f && namesRealEvidence(root, f) ? f : normalized;
512
+ });
513
+ }
514
+ /** Shared misroute-guard refusal for the auto-committing write tools that name
515
+ * files (hunch_record_decision, hunch_record_correction, hunch_record_finding,
516
+ * and nuryel_write's decisions/findings/bugs/constraints facets via
517
+ * `fileEvidenceFor`) — one message so every call site stays in lockstep instead
518
+ * of drifting. Returns the refusal ToolResult when `misroutedWorktreeCandidates`
519
+ * finds a better home, else null (proceed as normal). `subject` names what's
520
+ * being recorded, e.g. `"Foo"` or `finding "Foo"`, for the refusal text. `extra`,
521
+ * when given, is appended as a further sentence — for a caller (nuryel_write)
522
+ * whose retry needs more than just `cwd` moved. Callers pass file evidence
523
+ * through `guardEvidence` — `misroutedWorktreeCandidates` itself understands
524
+ * both relative and absolute entries, so no pre-relativization step is needed or
525
+ * correct here (see its doc comment on why relativizing against `root` alone
526
+ * would drop the exact sibling-worktree case this guard exists to catch). */
527
+ function misrouteGuard(root, subject, relatedFiles, extra) {
528
+ const misroutes = misroutedWorktreeCandidates(root, relatedFiles);
529
+ if (!misroutes.length)
530
+ return null;
531
+ const branch = currentBranch(root);
532
+ const plural = misroutes.length > 1;
533
+ const where = plural
534
+ ? `they do in these linked worktrees: ${misroutes.join(", ")}`
535
+ : `they do in the linked worktree ${misroutes[0]}`;
536
+ const retry = plural
537
+ ? `retry with cwd pointing at whichever of those is actually correct`
538
+ : `retry with cwd:"${misroutes[0]}"`;
539
+ return err(`Refusing to record ${subject} in ${root}${branch ? ` (branch ${branch})` : ""}: ` +
540
+ `none of the files it names exist there, but ${where}. This call is very likely missing the cwd ` +
541
+ `argument — ${retry}, or wherever this work actually happened.${extra ? ` ${extra}` : ""}`);
542
+ }
543
+ /** The file-evidence field, if any, each nuryel_write facet carries: related_files
544
+ * for decisions, affected_files for findings/bugs, and scope (the glob list a
545
+ * constraint applies to — the same role hunch_record_correction's
546
+ * scope_hint_file plays) for constraints. A TOTAL map over StateFacet, not an
547
+ * open-ended ternary chain: adding a facet to STATE_FACETS without adding it
548
+ * here is a compile error, not a silent gap. null means the facet carries no
549
+ * comparable field and is left unguarded rather than inventing one. Exported so
550
+ * tests can assert the mapping directly instead of only probing it through live
551
+ * misroute behavior. */
552
+ export const FILE_EVIDENCE_FIELD = {
553
+ decisions: "related_files",
554
+ findings: "affected_files",
555
+ bugs: "affected_files",
556
+ constraints: "scope",
557
+ receipts: null,
558
+ commitments: null,
559
+ derived: null,
560
+ entities: null,
561
+ relationships: null,
562
+ conventions: null,
563
+ };
564
+ /** `record` is caller-supplied z.record(string, unknown) — defensive by
565
+ * construction, so anything other than an array of strings reads as no
566
+ * evidence instead of throwing. */
567
+ export function fileEvidenceFor(facet, record) {
568
+ const field = FILE_EVIDENCE_FIELD[facet];
569
+ if (!field)
570
+ return [];
571
+ const value = record[field];
572
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
573
+ }
574
+ /** The misrouteGuard `subject` for a nuryel_write call, matching the
575
+ * "<kind> \"<label>\"" style hunch_record_decision/_correction/_finding use for
576
+ * the same refusal text. Constraints have no title, only a statement. */
577
+ function nuryelWriteSubject(facet, record) {
578
+ const labelField = facet === "decisions" || facet === "findings" || facet === "bugs" ? "title" : facet === "constraints" ? "statement" : null;
579
+ const label = labelField ? record[labelField] : undefined;
580
+ const kind = facet.endsWith("s") ? facet.slice(0, -1) : facet;
581
+ return `${kind}${typeof label === "string" ? ` "${label.slice(0, 60)}"` : ""} (nuryel_write)`;
582
+ }
156
583
  /** Where a capture keyed to `home` actually lands: the private overlay directory when
157
584
  * one is configured, else the public repo root. Centralizes the branch used at every
158
585
  * destination-reporting call site below — `hunch_policy_upgrade_correction` once
@@ -1550,6 +1977,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1550
1977
  },
1551
1978
  }, async ({ decision, capture_token, task_id }) => {
1552
1979
  try {
1980
+ const misroute = misrouteGuard(root, `"${decision.title.slice(0, 60)}"`, guardEvidence(root, decision.related_files ?? []));
1981
+ if (misroute)
1982
+ return misroute;
1553
1983
  // Commit-keyed on the CANONICAL full sha (resolved via git rev-parse), so a
1554
1984
  // human passing the short sha they see in `commit` produces the SAME id as
1555
1985
  // the auto-sync path (which keys on the full sha) — UPGRADING the auto-draft
@@ -1784,6 +2214,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1784
2214
  try {
1785
2215
  if (!input.rule || !input.rule.trim())
1786
2216
  return invalid("rule is required — state the invariant in plain words.");
2217
+ // Same misroute guard as hunch_record_decision: a scope_hint_file that exists in a
2218
+ // sibling linked worktree but not here is very likely a subagent that forgot cwd,
2219
+ // about to silently scope-and-commit a constraint against the wrong checkout.
2220
+ // Passed through guardEvidence — misroutedWorktreeCandidates understands absolute
2221
+ // paths itself (see its doc comment). The constraint's own scope glob below is a
2222
+ // DIFFERENT job, still relativized against root by buildCorrectionConstraint
2223
+ // internally — a sibling worktree's file evidence never reaches that path.
2224
+ const correctionMisroute = misrouteGuard(root, `correction "${input.rule.slice(0, 60)}"`, input.scope_hint_file ? guardEvidence(root, [input.scope_hint_file]) : []);
2225
+ if (correctionMisroute)
2226
+ return correctionMisroute;
1787
2227
  // root: relativizes an ABSOLUTE scope_hint_file. Agents naturally send absolute
1788
2228
  // paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
1789
2229
  // repo-relative — without this the rule would be blocking-but-inert and would leak
@@ -1880,6 +2320,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1880
2320
  return invalid("title is required.");
1881
2321
  if (!finding.observation.trim())
1882
2322
  return invalid("observation is required — state what you saw.");
2323
+ // Same misroute guard as hunch_record_decision.
2324
+ const findingMisroute = misrouteGuard(root, `finding "${finding.title.slice(0, 60)}"`, guardEvidence(root, finding.affected_files ?? []));
2325
+ if (findingMisroute)
2326
+ return findingMisroute;
1883
2327
  const id = findingId(finding.title);
1884
2328
  const home = store.captureHome(!!finding.private);
1885
2329
  const existing = home === "private" ? store.getPrivateRec("findings", id) : store.json.get("findings", id);
@@ -2024,6 +2468,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2024
2468
  outputSchema: WriteResultSchema.shape,
2025
2469
  }, async ({ cwd: _cwd, ...input }) => {
2026
2470
  try {
2471
+ // Same misroute guard as hunch_record_decision/_correction/_finding. Unlike those
2472
+ // three, nuryel_write's `scope`/`principal.grants` are a repository partition tied
2473
+ // to the CURRENT root: retrying with only `cwd` moved re-homes the store but leaves
2474
+ // the request scoped to the old root's partition, which fails loudly (not silently)
2475
+ // on the retry — misrouteGuard's `extra` spells out the additional step.
2476
+ const misroute = misrouteGuard(root, nuryelWriteSubject(input.facet, input.record), guardEvidence(root, fileEvidenceFor(input.facet, input.record)), "Also move `scope` (and `principal.grants`) to the repository partition for that worktree — nuryel_write's scope does not follow `cwd` automatically.");
2477
+ if (misroute)
2478
+ return misroute;
2027
2479
  // Same cross-process lock `hunch serve` takes: a second agent writing over stdio must
2028
2480
  // not race the HTTP server between the ledger read and the record write.
2029
2481
  const { hunchDir } = stateHomeFor(store, input.scope);
@@ -505,6 +505,14 @@ export declare class HunchStore {
505
505
  * newest-first, with its valid-time window and supersession links. Answers
506
506
  * "what did we believe, and when/why did it change?" (hunch_timeline). */
507
507
  timeline(target: string): Decision[];
508
+ /** Invalidate, don't delete: close an active constraint's valid-time window
509
+ * (status → "retired", valid_to → now) so `hunch check` and the strict hook
510
+ * stop enforcing it while its full history stays on record. The caller has
511
+ * already resolved `existing` (id lookup + already-retired refusal are the
512
+ * CLI's job, not this method's), so there is no internal re-lookup and no
513
+ * nullable return. Writes to whichever store already holds the record
514
+ * (`putWhereItLives`), so a public/private twin can never be forked. */
515
+ retireConstraint(existing: Constraint): Constraint;
508
516
  /** Invalidate, don't delete (Zep edge-invalidation): close `oldId`'s valid-time
509
517
  * window at the superseding decision's `valid_from`, mark it superseded + linked,
510
518
  * and write a `supersedes` edge. Returns the updated old decision, or null if it
@@ -148,7 +148,10 @@ export class HunchStore {
148
148
  const distinctNestedRoot = nestedBoundary ? gitWorktreeRoot(publicationProbe) : null;
149
149
  if (canonical(candidate) === canonical(this.paths.hunch) ||
150
150
  (nestedBoundary && (!distinctNestedRoot || sameGitPublication(distinctNestedRoot, this.paths.root))) ||
151
- sameGitPublication(publicationProbe, this.paths.root)) {
151
+ // If these paths are identical, the nested-boundary clause just ran
152
+ // this exact proof. Different probe paths still need their own check;
153
+ // no result is retained across store opens or publication operations.
154
+ (distinctNestedRoot !== publicationProbe && sameGitPublication(publicationProbe, this.paths.root))) {
152
155
  throw new Error(`Unsafe private overlay "${candidate}" shares the public code repository's local or remote publication boundary. ` +
153
156
  "Run `hunch private` or `hunch shared --repo <url>` to create a standalone overlay repository.");
154
157
  }
@@ -1846,6 +1849,16 @@ export class HunchStore {
1846
1849
  timeline(target) {
1847
1850
  return this.why(target).decisions.sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date));
1848
1851
  }
1852
+ /** Invalidate, don't delete: close an active constraint's valid-time window
1853
+ * (status → "retired", valid_to → now) so `hunch check` and the strict hook
1854
+ * stop enforcing it while its full history stays on record. The caller has
1855
+ * already resolved `existing` (id lookup + already-retired refusal are the
1856
+ * CLI's job, not this method's), so there is no internal re-lookup and no
1857
+ * nullable return. Writes to whichever store already holds the record
1858
+ * (`putWhereItLives`), so a public/private twin can never be forked. */
1859
+ retireConstraint(existing) {
1860
+ return this.putWhereItLives("constraints", { ...existing, status: "retired", valid_to: new Date().toISOString() });
1861
+ }
1849
1862
  /** Invalidate, don't delete (Zep edge-invalidation): close `oldId`'s valid-time
1850
1863
  * window at the superseding decision's `valid_from`, mark it superseded + linked,
1851
1864
  * and write a `supersedes` edge. Returns the updated old decision, or null if it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.40.1",
3
+ "version": "1.41.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.40.1",
10
+ "version": "1.41.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.40.1",
16
+ "version": "1.41.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {