@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 +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/extractors/languages.d.ts +9 -2
- package/dist/extractors/languages.js +13 -0
- package/dist/extractors/parse.js +41 -13
- 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) {
|
|
@@ -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.
|
|
46
|
-
*
|
|
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
|
package/dist/extractors/parse.js
CHANGED
|
@@ -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
|
|
109
|
+
existing.name = originalText(node);
|
|
86
110
|
else
|
|
87
|
-
pendingDefs.set(defNode.id, { kind: spec.defKindOf[spec.nameToDef[cname]], def: defNode, name: node
|
|
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
|
|
114
|
+
namespace = originalText(node);
|
|
91
115
|
}
|
|
92
116
|
else if (cname === "import.src") {
|
|
93
|
-
imports.push(node.
|
|
117
|
+
imports.push(originalText(node).replace(STR_QUOTES, ""));
|
|
94
118
|
}
|
|
95
119
|
else if (cname === "call.id") {
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
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.
|
|
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 ||
|
|
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
|
|
206
|
+
for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
|
|
181
207
|
for (const scope of scopes) {
|
|
182
|
-
if (ancestor.type === scope.node
|
|
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
|
-
|
|
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;
|