@davesheffer/hunch 1.40.0 → 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 +60 -6
- 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.d.ts +3 -0
- package/dist/core/taskReportRender.js +31 -13
- package/dist/extractors/git.d.ts +37 -0
- package/dist/extractors/git.js +56 -1
- package/dist/extractors/indexer.d.ts +5 -0
- package/dist/extractors/indexer.js +17 -2
- package/dist/extractors/parse.d.ts +3 -1
- package/dist/extractors/parse.js +6 -2
- 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/dist/store/stateBinding.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -36,7 +36,7 @@ import { publishedStatus } from "../integrations/registry.js";
|
|
|
36
36
|
import { HunchStore } from "../store/hunchStore.js";
|
|
37
37
|
import { JsonStore } from "../store/jsonStore.js";
|
|
38
38
|
import { selectEmbedder } from "../store/embedder.js";
|
|
39
|
-
import { assertCompleteRepoScan, indexRepo, mergeScannedEdges, scanRepo } from "../extractors/indexer.js";
|
|
39
|
+
import { assertCompleteRepoScan, assertNoTotalParseFailure, indexRepo, mergeScannedEdges, scanRepo } from "../extractors/indexer.js";
|
|
40
40
|
// Importing this module is free (the addon load is a function call, not a
|
|
41
41
|
// top-level side effect); only `doctor` below actually calls the loader.
|
|
42
42
|
import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTreeSitter.js";
|
|
@@ -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 ||
|
|
@@ -7227,11 +7280,12 @@ program
|
|
|
7227
7280
|
// past the isolation guard) is invisible until an index run refuses.
|
|
7228
7281
|
try {
|
|
7229
7282
|
loadNativeTreeSitter();
|
|
7283
|
+
assertNoTotalParseFailure(scanRepo(store, root, { churn: false }));
|
|
7230
7284
|
console.log(`parser: native tree-sitter addons load`);
|
|
7231
7285
|
}
|
|
7232
7286
|
catch (e) {
|
|
7233
7287
|
console.log(`parser: ⛔ ${e.message}`);
|
|
7234
|
-
console.log(dim(`
|
|
7288
|
+
console.log(dim(` \`hunch index\` refuses rather than replacing the graph after a parser failure`));
|
|
7235
7289
|
process.exitCode = 1;
|
|
7236
7290
|
}
|
|
7237
7291
|
const c = store.reindex().counts;
|
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)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type LessonHistory, type TaskReport } from "./taskReport.js";
|
|
2
2
|
export declare function writeTaskReportHtml(root: string, taskId: string, publicOnly?: boolean): string;
|
|
3
|
+
/** Delivered lessons are named on the card up to this many; beyond it the
|
|
4
|
+
* count of the rest is stated, never a silent cut. */
|
|
5
|
+
export declare const CARD_RECALLED_LIMIT = 8;
|
|
3
6
|
/** One short line for the first time a lesson reaches a task; null when every
|
|
4
7
|
* delivered revision was already seen in this task. Never a banner per delivery. */
|
|
5
8
|
export declare function renderRecalledLine(fresh: readonly {
|
|
@@ -21,8 +21,15 @@ export function writeTaskReportHtml(root, taskId, publicOnly = false) {
|
|
|
21
21
|
writeFileAtomic(file, publicOnly ? renderPublicTaskReportHtml(publicTaskReport(root, taskId)) : renderTaskReportHtml(report, undefined, histories));
|
|
22
22
|
return file;
|
|
23
23
|
}
|
|
24
|
+
/** Card text is copied verbatim into an agent's final response, so every
|
|
25
|
+
* field is shown whole: a title cut mid-word with an ellipsis reads as a
|
|
26
|
+
* truncated tool result and the agent refuses to reproduce the card. Every
|
|
27
|
+
* rendered field is schema-bounded (task title and check label ≤ 200
|
|
28
|
+
* characters, record titles ≤ 500), so whole never means unbounded. */
|
|
24
29
|
function plain(value) { return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim(); }
|
|
25
|
-
|
|
30
|
+
/** Delivered lessons are named on the card up to this many; beyond it the
|
|
31
|
+
* count of the rest is stated, never a silent cut. */
|
|
32
|
+
export const CARD_RECALLED_LIMIT = 8;
|
|
26
33
|
function uniqueRecords(report) {
|
|
27
34
|
return [...new Map(report.deliveries.flatMap(d => d.records).map(r => [`${r.kind}:${r.record_id}:${r.content_hash}`, r])).values()];
|
|
28
35
|
}
|
|
@@ -42,44 +49,55 @@ export function renderRecalledLine(fresh) {
|
|
|
42
49
|
if (!fresh.length)
|
|
43
50
|
return null;
|
|
44
51
|
const rest = fresh.length - 1;
|
|
45
|
-
return `Hunch recalled: ${
|
|
52
|
+
return `Hunch recalled: ${plain(fresh[0].title)}${rest ? ` (+${rest} more lesson${rest === 1 ? "" : "s"})` : ""}`;
|
|
46
53
|
}
|
|
47
54
|
export function renderTaskReport(report) {
|
|
48
55
|
const records = uniqueRecords(report);
|
|
49
|
-
const lines = [`Hunch · ${
|
|
56
|
+
const lines = [`Hunch · ${plain(report.task.title)}`, `Task ${report.task.task_id} · ${report.task.state}`];
|
|
50
57
|
if (!report.deliveries.length)
|
|
51
58
|
lines.push("No task-linked delivery observed; connection/use is unverified.");
|
|
52
59
|
else if (!records.length)
|
|
53
60
|
lines.push(report.coverage === "no-relevant-memory" ? "No relevant memory returned for this task." : "Memory delivered; exact record snapshots unavailable.");
|
|
54
61
|
else {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
const named = records.slice(0, CARD_RECALLED_LIMIT);
|
|
63
|
+
lines.push(`Recalled ${plain(named[0].title)}`);
|
|
64
|
+
for (const record of named.slice(1))
|
|
65
|
+
lines.push(` ${plain(record.title)}`);
|
|
66
|
+
if (records.length > named.length)
|
|
67
|
+
lines.push(` ${records.length - named.length} more lesson(s) in the evidence view`);
|
|
58
68
|
}
|
|
59
69
|
const standing = ruleStanding(report);
|
|
60
70
|
const violated = standing.find(r => r.outcome === "violated");
|
|
61
71
|
const held = standing.find(r => r.outcome === "satisfied" && r.current);
|
|
62
72
|
if (report.claims.length) {
|
|
63
73
|
const claim = report.claims.find(c => c.supported_by) ?? report.claims[0];
|
|
64
|
-
lines.push(`Applied ${
|
|
74
|
+
lines.push(`Applied ${plain(claim.action)} · ${claim.supported_by ? "rule-supported" : "agent-reported"}`);
|
|
65
75
|
}
|
|
66
76
|
else if (held)
|
|
67
|
-
lines.push(`Conformed ${
|
|
77
|
+
lines.push(`Conformed ${plain(recordTitle(report, held))} · rule held on ${held.files.length} changed file(s)`);
|
|
68
78
|
else if (records.length)
|
|
69
79
|
lines.push("Impact Memory delivered; contribution unverified.");
|
|
70
80
|
if (violated)
|
|
71
|
-
lines.push(`Violated ${
|
|
81
|
+
lines.push(`Violated ${plain(recordTitle(report, violated))} · rule broken on changed files`);
|
|
72
82
|
if (report.saves.length)
|
|
73
|
-
lines.push(`Saved ${
|
|
83
|
+
lines.push(`Saved ${plain(report.saves[0].record.title)} · ${report.saves[0].durability}`);
|
|
74
84
|
if (report.refusals.length)
|
|
75
|
-
lines.push(`Guarded Denial emitted · ${
|
|
85
|
+
lines.push(`Guarded Denial emitted · ${plain(report.refusals[0].record_id)}`);
|
|
76
86
|
const check = report.checks.at(-1);
|
|
77
87
|
if (check) {
|
|
78
88
|
const state = check.cancelled ? "cancelled" : check.timed_out ? "timed out" : check.exit_code === 0 ? "passed" : "failed";
|
|
79
|
-
lines.push(`Checked ${
|
|
89
|
+
lines.push(`Checked ${plain(check.label)} · ${state}${check.current ? " · current source snapshot" : " · current source unverified"}`);
|
|
80
90
|
}
|
|
81
91
|
else
|
|
82
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
|
+
}
|
|
83
101
|
lines.push(`Evidence hunch report ${report.task.task_id} --html`);
|
|
84
102
|
return lines.join("\n");
|
|
85
103
|
}
|
|
@@ -99,7 +117,7 @@ export function renderTaskReportHtml(report, generatedAt = new Date().toISOStrin
|
|
|
99
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("");
|
|
100
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("");
|
|
101
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>
|
|
102
|
-
: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}}
|
|
103
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>`;
|
|
104
122
|
}
|
|
105
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) {
|
|
@@ -46,6 +46,11 @@ export interface IndexRepoOptions {
|
|
|
46
46
|
* false satisfied receipt. Call this after scanRepo when the consumer cannot
|
|
47
47
|
* represent partial-graph uncertainty directly. */
|
|
48
48
|
export declare function assertCompleteRepoScan(scan: RepoScan): void;
|
|
49
|
+
/** A whole-language failure can be a broken grammar, not a set of bad files.
|
|
50
|
+
* Refuse the entire publication so cross-language edges and curated components
|
|
51
|
+
* remain consistent with the previous symbols. Read-only scans retain coverage
|
|
52
|
+
* and issues for diagnostics; a valid empty scan is still publishable. */
|
|
53
|
+
export declare function assertNoTotalParseFailure(scan: RepoScan): void;
|
|
49
54
|
/** Derive the current repository graph without writing JSON or rebuilding SQLite.
|
|
50
55
|
* Read-only gates use this so checking changed code can never rewrite or publish
|
|
51
56
|
* the durable graph merely by inspecting it. Existing public graph records remain
|
|
@@ -35,6 +35,20 @@ export function assertCompleteRepoScan(scan) {
|
|
|
35
35
|
const more = scan.issues.length > 5 ? ` (+${scan.issues.length - 5} more)` : "";
|
|
36
36
|
throw new Error(`incomplete semantic source scan rejected ${scan.issues.length} file(s): ${sample}${more}`);
|
|
37
37
|
}
|
|
38
|
+
/** A whole-language failure can be a broken grammar, not a set of bad files.
|
|
39
|
+
* Refuse the entire publication so cross-language edges and curated components
|
|
40
|
+
* remain consistent with the previous symbols. Read-only scans retain coverage
|
|
41
|
+
* and issues for diagnostics; a valid empty scan is still publishable. */
|
|
42
|
+
export function assertNoTotalParseFailure(scan) {
|
|
43
|
+
const failed = scan.result.coverage.filter((item) => item.eligible > 0 && item.reasons.parse_failed === item.eligible);
|
|
44
|
+
if (!failed.length)
|
|
45
|
+
return;
|
|
46
|
+
const details = failed.map((item) => {
|
|
47
|
+
const first = scan.issues.find((issue) => issue.code === "parse_failed" && languageFor(issue.path)?.id === item.language);
|
|
48
|
+
return `${item.language}: all ${item.eligible} eligible file(s) failed to parse; ${first?.detail ?? "unknown parse failure"}`;
|
|
49
|
+
});
|
|
50
|
+
throw new Error(`index refused — ${details.join("; ")}. Previous graph preserved.`);
|
|
51
|
+
}
|
|
38
52
|
/** Derive the current repository graph without writing JSON or rebuilding SQLite.
|
|
39
53
|
* Read-only gates use this so checking changed code can never rewrite or publish
|
|
40
54
|
* the durable graph merely by inspecting it. Existing public graph records remain
|
|
@@ -150,7 +164,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
150
164
|
// one bad/oversized file must never abort the whole index run
|
|
151
165
|
let parsed;
|
|
152
166
|
try {
|
|
153
|
-
parsed = parseSource(rel, src);
|
|
167
|
+
parsed = parseSource(rel, src, { throwOnParseError: true });
|
|
154
168
|
}
|
|
155
169
|
catch (error) {
|
|
156
170
|
// …but a dead PARSER is not a bad file. The native addons load on first
|
|
@@ -163,7 +177,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
163
177
|
if (isParserLoadError(error))
|
|
164
178
|
throw error;
|
|
165
179
|
skipped++;
|
|
166
|
-
issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed` });
|
|
180
|
+
issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed: ${error instanceof Error ? error.message : String(error)}` });
|
|
167
181
|
noteSkip(rel, "parse_failed");
|
|
168
182
|
continue;
|
|
169
183
|
}
|
|
@@ -619,6 +633,7 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
619
633
|
? { kind: "commit", ref: "HEAD" }
|
|
620
634
|
: opts.source;
|
|
621
635
|
const scan = scanRepo(store, root, { churn: opts.churn, source });
|
|
636
|
+
assertNoTotalParseFailure(scan);
|
|
622
637
|
if (opts.requireComplete)
|
|
623
638
|
assertCompleteRepoScan(scan);
|
|
624
639
|
store.json.replaceAll("symbols", scan.symbols);
|
|
@@ -34,7 +34,9 @@ export interface ParsedFile {
|
|
|
34
34
|
/** Cap on a stored symbol's bodyText — large enough for review context, small
|
|
35
35
|
* enough that a huge function/file doesn't bloat every JSON symbol record. */
|
|
36
36
|
export declare const MAX_BODY_TEXT_CHARS = 4000;
|
|
37
|
-
export declare function parseSource(file: string, source: string
|
|
37
|
+
export declare function parseSource(file: string, source: string, opts?: {
|
|
38
|
+
throwOnParseError?: boolean;
|
|
39
|
+
}): ParsedFile | null;
|
|
38
40
|
/** Map each call site to the innermost symbol whose byte-range contains it.
|
|
39
41
|
* Keyed by the symbol's position (index) in `parsed.symbols` — NOT its
|
|
40
42
|
* startByte, which is not a reliable per-symbol identity: a language whose
|
package/dist/extractors/parse.js
CHANGED
|
@@ -23,7 +23,7 @@ const STR_QUOTES = /^['"`]|['"`]$/g;
|
|
|
23
23
|
/** Cap on a stored symbol's bodyText — large enough for review context, small
|
|
24
24
|
* enough that a huge function/file doesn't bloat every JSON symbol record. */
|
|
25
25
|
export const MAX_BODY_TEXT_CHARS = 4000;
|
|
26
|
-
export function parseSource(file, source) {
|
|
26
|
+
export function parseSource(file, source, opts = {}) {
|
|
27
27
|
const spec = languageFor(file);
|
|
28
28
|
if (!spec)
|
|
29
29
|
return null;
|
|
@@ -49,7 +49,11 @@ export function parseSource(file, source) {
|
|
|
49
49
|
try {
|
|
50
50
|
tree = parser.parse(source, undefined, { bufferSize: Math.max(32 * 1024, source.length * 2 + 1024) });
|
|
51
51
|
}
|
|
52
|
-
catch {
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Index scans need the underlying diagnostic for whole-language failures.
|
|
54
|
+
// Other callers retain the historical best-effort null result.
|
|
55
|
+
if (opts.throwOnParseError)
|
|
56
|
+
throw error;
|
|
53
57
|
return null;
|
|
54
58
|
}
|
|
55
59
|
const symbols = [];
|
|
@@ -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)}` };
|