@davesheffer/hunch 1.40.1 → 1.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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) {
@@ -42,12 +42,19 @@ export interface LanguageSpec {
42
42
  /** Ancestor shapes in which an ERROR node is a known limitation of THIS grammar
43
43
  * rather than a real syntax error. parse.ts forgives an error only when some
44
44
  * ancestor has type `node` AND that ancestor's own parent has type `parentIs` —
45
- * the pair is what keeps the tolerance narrow. Omit for a language with no known
46
- * grammar false positives; that spec then stays strictly fail-closed. */
45
+ * the pair is what keeps the tolerance narrow. `textPattern`, when present,
46
+ * must also match that ancestor's text. Omit for a language with no
47
+ * known grammar false positives; that spec then stays strictly fail-closed. */
47
48
  toleratedErrorScopes?: ReadonlyArray<{
48
49
  readonly node: string;
49
50
  readonly parentIs: string;
51
+ readonly textPattern?: RegExp;
50
52
  }>;
53
+ /** Optional same-length recovery used only when the original syntax tree is
54
+ * not parseable. A recovered tree may classify structure, but parse.ts reads
55
+ * every captured value from the original source at the tree's unchanged
56
+ * offsets. Keep replacements syntax-equivalent outside the grammar defect. */
57
+ parseErrorRecovery?: (source: string) => string;
51
58
  /** Patterns whose presence anywhere in the source mean "this isn't actually
52
59
  * {id} text yet — it's a template that renders to {id} later" (Go/Jinja/Helm
53
60
  * delimiters in a .yaml file, e.g.). parse.ts still runs the real parse — a
@@ -81,6 +81,19 @@ const TSX = {
81
81
  extensions: [".tsx", ".jsx"],
82
82
  grammarKey: "tsx",
83
83
  loadGrammar: () => loadNativeTreeSitter().tsx,
84
+ // tree-sitter-javascript rejects a bare ampersand in JSX text even though JSX
85
+ // accepts it (tree-sitter-javascript#366). The ERROR is a direct jsx_element
86
+ // child and its text begins at the ampersand. Require both facts plus text that
87
+ // cannot cross into a tag or expression; real JSX errors beside it remain fail-closed.
88
+ toleratedErrorScopes: [
89
+ ...TS_SHARED.toleratedErrorScopes,
90
+ { node: "ERROR", parentIs: "jsx_element", textPattern: /^&[^<>{}]*$/u },
91
+ ],
92
+ // More than one bare ampersand can make the grammar collapse the entire TSX
93
+ // tree instead of emitting the narrow ERROR above. `|` is accepted as JSX
94
+ // text and has the same length; in JS/TS syntax it preserves ampersand
95
+ // operators' arity, so malformed expressions remain malformed on the retry.
96
+ parseErrorRecovery: (source) => source.replaceAll("&", "|"),
84
97
  };
85
98
  const PY_QUERY = `
86
99
  (class_definition
@@ -56,11 +56,35 @@ export function parseSource(file, source, opts = {}) {
56
56
  throw error;
57
57
  return null;
58
58
  }
59
+ let parseable = isParseable(tree.rootNode, spec);
60
+ let usingRecoveredTree = false;
61
+ if (!parseable && spec.parseErrorRecovery) {
62
+ const recoveredSource = spec.parseErrorRecovery(source);
63
+ // Offsets from the recovery tree are used against the original source below.
64
+ // Refuse a misconfigured recovery rather than corrupt captured names/text.
65
+ if (recoveredSource.length === source.length) {
66
+ try {
67
+ const recoveredTree = parser.parse(recoveredSource, undefined, { bufferSize: Math.max(32 * 1024, recoveredSource.length * 2 + 1024) });
68
+ if (isParseable(recoveredTree.rootNode, spec)) {
69
+ tree = recoveredTree;
70
+ parseable = true;
71
+ usingRecoveredTree = true;
72
+ }
73
+ }
74
+ catch (error) {
75
+ if (opts.throwOnParseError)
76
+ throw error;
77
+ }
78
+ }
79
+ }
59
80
  const symbols = [];
60
81
  const imports = [];
61
82
  const calls = [];
62
83
  const relations = [];
63
84
  let namespace = null;
85
+ const originalText = (node) => usingRecoveredTree
86
+ ? source.slice(node.startIndex, node.endIndex)
87
+ : node.text;
64
88
  // group captures by their enclosing @*.def via a quick pass: we record names
65
89
  // keyed by the def node, then emit a symbol per def.
66
90
  const pendingDefs = new Map();
@@ -82,30 +106,32 @@ export function parseSource(file, source, opts = {}) {
82
106
  if (defNode) {
83
107
  const existing = pendingDefs.get(defNode.id);
84
108
  if (existing)
85
- existing.name = node.text;
109
+ existing.name = originalText(node);
86
110
  else
87
- pendingDefs.set(defNode.id, { kind: spec.defKindOf[spec.nameToDef[cname]], def: defNode, name: node.text });
111
+ pendingDefs.set(defNode.id, { kind: spec.defKindOf[spec.nameToDef[cname]], def: defNode, name: originalText(node) });
88
112
  }
89
113
  if (cname === "namespace.name")
90
- namespace = node.text;
114
+ namespace = originalText(node);
91
115
  }
92
116
  else if (cname === "import.src") {
93
- imports.push(node.text.replace(STR_QUOTES, ""));
117
+ imports.push(originalText(node).replace(STR_QUOTES, ""));
94
118
  }
95
119
  else if (cname === "call.id") {
96
- if (!spec.builtinFunctions?.has(node.text)) {
97
- calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: false });
120
+ const text = originalText(node);
121
+ if (!spec.builtinFunctions?.has(text)) {
122
+ calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: false });
98
123
  }
99
124
  }
100
125
  else if (cname === "call.member") {
101
126
  // skip builtin method names to avoid false edges to similarly-named symbols
102
- if (!spec.builtinMethods.has(node.text))
103
- calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: true });
127
+ const text = originalText(node);
128
+ if (!spec.builtinMethods.has(text))
129
+ calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true });
104
130
  }
105
131
  else if (spec.relationKindOf?.[cname]) {
106
132
  const relation = spec.relationKindOf[cname];
107
133
  relations.push({
108
- target: node.text,
134
+ target: originalText(node),
109
135
  atByte: node.startIndex,
110
136
  endByte: node.endIndex,
111
137
  edgeType: relation.edgeType,
@@ -121,7 +147,7 @@ export function parseSource(file, source, opts = {}) {
121
147
  symbols.push({
122
148
  name: resolvedName, kind,
123
149
  startByte: def.startIndex, endByte: def.endIndex, loc,
124
- bodyText: def.text.slice(0, MAX_BODY_TEXT_CHARS),
150
+ bodyText: originalText(def).slice(0, MAX_BODY_TEXT_CHARS),
125
151
  });
126
152
  }
127
153
  // Every other successfully-parsed YAML file gets at least a file-root symbol
@@ -141,7 +167,7 @@ export function parseSource(file, source, opts = {}) {
141
167
  });
142
168
  }
143
169
  symbols.sort((a, b) => a.startByte - b.startByte);
144
- return { symbols, imports, calls, relations, namespace, parseable: templated || isParseable(tree.rootNode, spec) };
170
+ return { symbols, imports, calls, relations, namespace, parseable: templated || parseable };
145
171
  }
146
172
  /** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
147
173
  * language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
@@ -177,9 +203,11 @@ function isParseable(root, spec) {
177
203
  return ok;
178
204
  }
179
205
  function inToleratedScope(node, scopes) {
180
- for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
206
+ for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
181
207
  for (const scope of scopes) {
182
- if (ancestor.type === scope.node && ancestor.parent?.type === scope.parentIs)
208
+ if (ancestor.type === scope.node
209
+ && ancestor.parent?.type === scope.parentIs
210
+ && (!scope.textPattern || ancestor.text.search(scope.textPattern) !== -1))
183
211
  return true;
184
212
  }
185
213
  }
@@ -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;