@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 +57 -4
- package/dist/core/paths.d.ts +9 -1
- package/dist/core/paths.js +9 -1
- package/dist/core/taskQuery.d.ts +2 -0
- package/dist/core/taskQuery.js +2 -0
- package/dist/core/taskReportEvidence.js +3 -1
- package/dist/core/taskReportRender.js +9 -1
- package/dist/extractors/git.d.ts +37 -0
- package/dist/extractors/git.js +56 -1
- package/dist/integrations/hooks.js +60 -7
- package/dist/integrations/team.js +11 -1
- package/dist/mcp/server.d.ts +125 -0
- package/dist/mcp/server.js +455 -3
- package/dist/store/hunchStore.d.ts +8 -0
- package/dist/store/hunchStore.js +14 -1
- package/package.json +1 -1
- package/server.json +2 -2
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 (
|
|
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
|
-
|
|
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" : ""}${
|
|
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 ||
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -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
|
package/dist/core/paths.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/taskQuery.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/taskQuery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/extractors/git.d.ts
CHANGED
|
@@ -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;
|
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
-
|
|
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
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "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.
|
|
16
|
+
"version": "1.41.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|