@davesheffer/hunch 1.47.0 → 1.47.2

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.
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Whether a delegated agent can call Hunch's MCP tools. A named role defined in
5
+ * `.claude/agents/<type>.md` (project first, then user) with a `tools:` list that
6
+ * names no `mcp__hunch` tool and no wildcard cannot: telling it to call
7
+ * hunch_why only costs it a failed lookup. Built-in and unknown types inherit
8
+ * the parent's tools. */
9
+ export function roleHasHunchTools(root, agentType, home = homedir()) {
10
+ const type = agentType.trim();
11
+ if (!type || !/^[\w.-]+$/.test(type))
12
+ return true;
13
+ for (const dir of [join(root, ".claude", "agents"), join(home, ".claude", "agents")]) {
14
+ let text;
15
+ try {
16
+ text = readFileSync(join(dir, `${type}.md`), "utf8");
17
+ }
18
+ catch {
19
+ continue;
20
+ }
21
+ const front = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
22
+ if (!front)
23
+ return true;
24
+ // A block list continues on indented "- x" lines; a flow list runs to its "]".
25
+ const tools = /^tools:[ \t]*(\[[^\]]*\]?|.*(?:\r?\n[ \t]+-.*)*)/m.exec(front)?.[1];
26
+ if (tools === undefined || !tools.trim())
27
+ return true;
28
+ return /\*|mcp__hunch/.test(tools);
29
+ }
30
+ return true;
31
+ }
32
+ //# sourceMappingURL=subagentrole.js.map
@@ -35,7 +35,7 @@ export declare function computeSupersedes(record: TaskRecord, others: readonly T
35
35
  export interface PersistedTaskRecord {
36
36
  record: TaskRecord;
37
37
  home: TaskRecordHome;
38
- flushed: "pushed" | "committed" | null;
38
+ flushed: "pushed" | "committed" | "held" | null;
39
39
  /** False when the same report revision was already in the graph. */
40
40
  changed: boolean;
41
41
  }
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { flushCapture } from "../integrations/sync.js";
16
16
  import { hunchPaths } from "./paths.js";
17
17
  import { episodeTasks, isEmptyTaskReport, readTaskReport, reportHash, sessionsOverlap } from "./taskReport.js";
18
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
18
+ import { currentSource } from "./taskReportEvidence.js";
19
19
  import { ENTITY_KINDS, TaskRecordSchema } from "./types.js";
20
20
  import { refreshRankEval } from "./taskRankingMode.js";
21
21
  import { gitTouchedFiles } from "./taskTouched.js";
@@ -172,7 +172,7 @@ export function computeSupersedes(record, others, limit = 20) {
172
172
  export function persistTaskRecord(root, store, taskId, options = {}) {
173
173
  if (!taskRecordsEnabled(root))
174
174
  return null;
175
- const snapshot = reportSourceSnapshot(root).hash;
175
+ const snapshot = currentSource(root);
176
176
  const own = readTaskReport(root, taskId, snapshot);
177
177
  if (own.task.state === "open")
178
178
  return null;
@@ -30,6 +30,7 @@ export declare const ReportCheckSchema: z.ZodObject<{
30
30
  snapshot_limitations: z.ZodArray<z.ZodString>;
31
31
  timed_out: z.ZodBoolean;
32
32
  cancelled: z.ZodOptional<z.ZodBoolean>;
33
+ work_root: z.ZodOptional<z.ZodString>;
33
34
  source: z.ZodLiteral<"local-command-runner">;
34
35
  }, z.core.$strict>;
35
36
  export type ReportCheck = z.infer<typeof ReportCheckSchema>;
@@ -321,8 +322,8 @@ export interface TaskSummary {
321
322
  }
322
323
  /** One bounded summary per recent task for status lines and host views; the
323
324
  * card and evidence view remain the authoritative renderings. */
324
- export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskSummary;
325
- export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: string | null): TaskSummary[];
325
+ export declare function summarizeTaskReport(root: string, taskId: string, currentSnapshot?: CurrentSnapshot): TaskSummary;
326
+ export declare function listTaskSummaries(root: string, limit?: number, currentSnapshot?: CurrentSnapshot): TaskSummary[];
326
327
  /** One line for a terminal status line. Empty string when nothing was observed
327
328
  * for the task, so a bare prompt shows no Hunch noise at all. */
328
329
  export declare function renderTaskStatusLine(summary: TaskSummary | null): string;
@@ -348,5 +349,9 @@ export declare function reportActivity(root: string): string;
348
349
  export declare function forgetReportTask(root: string, taskId: string): void;
349
350
  export declare function pruneReportHistory(root: string, olderThanDays?: number): number;
350
351
  export declare function reportPresentationEnabled(root: string): boolean;
351
- export declare function readTaskReport(root: string, taskId: string, currentSnapshot?: string | null): TaskReport;
352
+ /** What a reader compares evidence snapshots against: the report root's current source
353
+ * hash, or a resolver that also hashes another worktree a check ran in. A plain hash
354
+ * never vouches for a check that ran elsewhere. */
355
+ export type CurrentSnapshot = string | null | ((dir?: string) => string | null);
356
+ export declare function readTaskReport(root: string, taskId: string, currentSnapshot?: CurrentSnapshot): TaskReport;
352
357
  export {};
@@ -43,6 +43,9 @@ export const ReportCheckSchema = z.object({
43
43
  snapshot_limitations: z.array(z.string().max(300)).max(16),
44
44
  timed_out: z.boolean(),
45
45
  cancelled: z.boolean().optional(),
46
+ /** Where the command ran, recorded only when that is not this ledger's root (a
47
+ * sibling worktree): its currentness is judged against that tree's source. */
48
+ work_root: z.string().min(1).max(4_096).optional(),
46
49
  source: z.literal("local-command-runner"),
47
50
  }).strict();
48
51
  export const ReportSaveSchema = z.object({
@@ -717,6 +720,7 @@ export function reportPresentationEnabled(root) {
717
720
  }
718
721
  }
719
722
  export function readTaskReport(root, taskId, currentSnapshot = null) {
723
+ const snapshotAt = (dir) => typeof currentSnapshot === "function" ? currentSnapshot(dir) : dir === undefined ? currentSnapshot : null;
720
724
  const { task, events } = taskDb(root, db => transaction(db, () => {
721
725
  const task = readTask(db, root, taskId);
722
726
  const { bytes } = db.prepare("SELECT COALESCE(SUM(length(CAST(body AS BLOB))), 0) AS bytes FROM report_events WHERE task_id = ?").get(taskId);
@@ -743,7 +747,7 @@ export function readTaskReport(root, taskId, currentSnapshot = null) {
743
747
  claims.push({ ...ReportClaimSchema.parse(value), at: event.at, attribution: "agent-reported", supported_by: null });
744
748
  else if (event.kind === "conformance") {
745
749
  const rule = ReportConformanceSchema.parse(value);
746
- conformance.push({ ...rule, event_id: event.event_id, at: event.at, current: currentSnapshot !== null && rule.snapshot === currentSnapshot });
750
+ conformance.push({ ...rule, event_id: event.event_id, at: event.at, current: snapshotAt() !== null && rule.snapshot === snapshotAt() });
747
751
  }
748
752
  else if (event.kind === "save")
749
753
  saves.push({ ...ReportSaveSchema.parse(value), event_id: event.event_id, at: event.at, durability: "local", proofs: [] });
@@ -760,7 +764,8 @@ export function readTaskReport(root, taskId, currentSnapshot = null) {
760
764
  refusals.push({ ...ReportRefusalSchema.parse(value), event_id: event.event_id, at: event.at });
761
765
  else if (event.kind === "check") {
762
766
  const check = ReportCheckSchema.parse(value);
763
- checks.push({ ...check, at: event.at, current: currentSnapshot !== null && check.before_snapshot === currentSnapshot && check.after_snapshot === currentSnapshot });
767
+ const now = snapshotAt(check.work_root);
768
+ checks.push({ ...check, at: event.at, current: now !== null && check.before_snapshot === now && check.after_snapshot === now });
764
769
  }
765
770
  else if (event.kind !== "check-start")
766
771
  throw new Error("unsupported report event kind; update Hunch before reading this report");
@@ -9,6 +9,9 @@ export interface ReportSnapshot {
9
9
  /** Bounded source snapshot, not a committed change proof. Ignored files and
10
10
  * Hunch's own memory/cache are excluded and explicitly disclosed. */
11
11
  export declare function reportSourceSnapshot(root: string): ReportSnapshot;
12
+ /** The current source of `root` for report readers, as a resolver that also hashes the
13
+ * sibling worktree a check ran in (its `work_root`). Each tree is hashed at most once. */
14
+ export declare function currentSource(root: string): (dir?: string) => string | null;
12
15
  /** Evaluate every delivered lesson's DECLARED rule against the files this task
13
16
  * changed (working tree vs HEAD). Deterministic and Hunch-owned: the agent
14
17
  * neither chooses the rule nor submits the verdict. A constraint is judged by its
@@ -28,4 +31,8 @@ export declare function runReportCheck(root: string, taskId: string, command: st
28
31
  signal?: AbortSignal;
29
32
  onStdout?: (chunk: Buffer) => void;
30
33
  onStderr?: (chunk: Buffer) => void;
34
+ /** Where the command runs and whose source is snapshotted, when the task's
35
+ * ledger lives in a sibling worktree of the same repository (the hook opened
36
+ * the task at the main checkout, the agent works in a linked one). */
37
+ workRoot?: string;
31
38
  }): Promise<ReportCheck>;
@@ -87,6 +87,16 @@ export function reportSourceSnapshot(root) {
87
87
  return { hash: null, limitations: [...limitations, "A complete bounded source snapshot was unavailable; no current-change verification is claimed."] };
88
88
  }
89
89
  }
90
+ /** The current source of `root` for report readers, as a resolver that also hashes the
91
+ * sibling worktree a check ran in (its `work_root`). Each tree is hashed at most once. */
92
+ export function currentSource(root) {
93
+ const seen = new Map();
94
+ return (dir = root) => {
95
+ if (!seen.has(dir))
96
+ seen.set(dir, reportSourceSnapshot(dir).hash);
97
+ return seen.get(dir);
98
+ };
99
+ }
90
100
  /** Evaluate every delivered lesson's DECLARED rule against the files this task
91
101
  * changed (working tree vs HEAD). Deterministic and Hunch-owned: the agent
92
102
  * neither chooses the rule nor submits the verdict. A constraint is judged by its
@@ -210,7 +220,8 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
210
220
  const task = readTaskReport(root, taskId).task;
211
221
  if (task.state !== "open" && task.closed_by !== "host")
212
222
  throw new Error("cannot verify a closed task");
213
- const before = reportSourceSnapshot(root);
223
+ const workRoot = options.workRoot ?? root;
224
+ const before = reportSourceSnapshot(workRoot);
214
225
  // Validate sensitive arguments before executing or writing anything.
215
226
  ReportCheckSchema.parse({ label, command, exit_code: null, output_hash: reportHash(""), before_snapshot: before.hash, after_snapshot: null, snapshot_limitations: before.limitations, timed_out: false, source: "local-command-runner" });
216
227
  const checkId = beginReportCheck(root, taskId, label, timeoutMs);
@@ -228,7 +239,7 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
228
239
  const started = (() => {
229
240
  try {
230
241
  const resolved = resolveSpawnCommand(command);
231
- return spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
242
+ return spawn(resolved.file, resolved.args, { cwd: workRoot, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
232
243
  }
233
244
  catch (error) {
234
245
  // An argument the launcher cannot carry, or a synchronous spawn refusal
@@ -319,9 +330,10 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
319
330
  if (options.signal?.aborted)
320
331
  cancel();
321
332
  });
322
- const after = reportSourceSnapshot(root);
333
+ const after = reportSourceSnapshot(workRoot);
323
334
  const check = { check_id: checkId, label, command, exit_code: result.code, output_hash: result.hash, before_snapshot: before.hash, after_snapshot: after.hash,
324
- snapshot_limitations: [...new Set([...before.limitations, ...after.limitations])], timed_out: result.timedOut, cancelled: result.cancelled, source: "local-command-runner" };
335
+ snapshot_limitations: [...new Set([...before.limitations, ...after.limitations])], timed_out: result.timedOut, cancelled: result.cancelled,
336
+ ...(workRoot !== root ? { work_root: workRoot } : {}), source: "local-command-runner" };
325
337
  recordReportCheck(root, taskId, check);
326
338
  return check;
327
339
  }
@@ -6,7 +6,7 @@ import { findRoot } from "./paths.js";
6
6
  import { canonicalReportRoot } from "./taskReportPaths.js";
7
7
  import { isCredentialFreeText } from "./types.js";
8
8
  import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, reportTaskExists, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
9
+ import { currentSource } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
11
  import { verificationLauncher } from "./verifyLauncher.js";
12
12
  import { injectionMode } from "./hookcache.js";
@@ -290,7 +290,7 @@ export function stopHookReport(root, provider, event) {
290
290
  if (id === "legacy")
291
291
  return { systemMessage: "Hunch hook active. This host version does not provide an exact prompt identifier, so contribution for this response is unverified. Explicit task reports remain available with hunch report." };
292
292
  try {
293
- const report = readTaskReport(root, id, reportSourceSnapshot(root).hash);
293
+ const report = readTaskReport(root, id, currentSource(root));
294
294
  if (isEmptyTaskReport(report))
295
295
  return null;
296
296
  // The HTML evidence view is a rendering of the local ledger, generated on
@@ -3,9 +3,9 @@
3
3
  import { JsonStore } from "../store/jsonStore.js";
4
4
  import { hunchPaths } from "./paths.js";
5
5
  import { readTaskReport, reportHash } from "./taskReport.js";
6
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
6
+ import { currentSource } from "./taskReportEvidence.js";
7
7
  export function publicTaskReport(root, taskId) {
8
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
8
+ const report = readTaskReport(root, taskId, currentSource(root));
9
9
  const publicStore = new JsonStore(hunchPaths(root));
10
10
  const records = new Map();
11
11
  const deliveries = [];
@@ -1,11 +1,11 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { writeFileAtomic } from "./io.js";
3
3
  import { readTaskReport, readLessonHistory } from "./taskReport.js";
4
- import { reportSourceSnapshot } from "./taskReportEvidence.js";
4
+ import { currentSource } from "./taskReportEvidence.js";
5
5
  import { assertReportPath } from "./taskReportPaths.js";
6
6
  import { publicTaskReport, renderPublicTaskReportHtml } from "./taskReportPublic.js";
7
7
  export function writeTaskReportHtml(root, taskId, publicOnly = false) {
8
- const report = readTaskReport(root, taskId, reportSourceSnapshot(root).hash);
8
+ const report = readTaskReport(root, taskId, currentSource(root));
9
9
  const directory = assertReportPath(root, ".hunch-cache", "reports");
10
10
  mkdirSync(directory, { recursive: true });
11
11
  const file = assertReportPath(root, ".hunch-cache", "reports", `${report.task.task_id}${publicOnly ? ".public" : ""}.html`);
@@ -266,6 +266,16 @@ export declare function pathKnownToHistory(root: string, file: string): boolean;
266
266
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
267
267
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
268
268
  export declare function currentBranch(cwd: string): string;
269
+ /** Whether `cwd` has its repository's default branch checked out: origin/HEAD's branch
270
+ * when the remote names one, else `main` or `master`. False on a detached HEAD or
271
+ * outside a repository, so a caller keeps its pre-existing behavior there. */
272
+ export declare function onDefaultBranch(cwd: string): boolean;
273
+ /** Whether a memory commit of `hunchDir` would have anything to stage: a tracked file git
274
+ * sees as changed (after its own line-ending normalization) or an untracked, non-ignored
275
+ * file, skipping the derived artifacts and the per-machine local.json the commit never
276
+ * stages. A hook's inherited GIT_DIR/GIT_WORK_TREE is stripped, so the repository is
277
+ * resolved from `hunchDir` itself. False when git cannot answer. */
278
+ export declare function hunchDirHasChanges(hunchDir: string): boolean;
269
279
  /** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
270
280
  * ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
271
281
  * null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
@@ -2283,6 +2283,33 @@ export function currentBranch(cwd) {
2283
2283
  const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
2284
2284
  return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" — treat as no branch
2285
2285
  }
2286
+ /** Whether `cwd` has its repository's default branch checked out: origin/HEAD's branch
2287
+ * when the remote names one, else `main` or `master`. False on a detached HEAD or
2288
+ * outside a repository, so a caller keeps its pre-existing behavior there. */
2289
+ export function onDefaultBranch(cwd) {
2290
+ const branch = currentBranch(cwd);
2291
+ if (!branch)
2292
+ return false;
2293
+ const remoteHead = gitSafe(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], cwd);
2294
+ if (remoteHead)
2295
+ return branch === remoteHead.replace(/^origin\//, "");
2296
+ return branch === "main" || branch === "master";
2297
+ }
2298
+ /** Whether a memory commit of `hunchDir` would have anything to stage: a tracked file git
2299
+ * sees as changed (after its own line-ending normalization) or an untracked, non-ignored
2300
+ * file, skipping the derived artifacts and the per-machine local.json the commit never
2301
+ * stages. A hook's inherited GIT_DIR/GIT_WORK_TREE is stripped, so the repository is
2302
+ * resolved from `hunchDir` itself. False when git cannot answer. */
2303
+ export function hunchDirHasChanges(hunchDir) {
2304
+ const changed = [
2305
+ ...gitSafeIsolated(["diff", "--name-only", "--relative", "--no-renames", "HEAD", "--", "."], hunchDir).split("\n"),
2306
+ ...gitSafeIsolated(["ls-files", "--others", "--exclude-standard", "--", "."], hunchDir).split("\n"),
2307
+ ];
2308
+ return changed.some((raw) => {
2309
+ const path = raw.trim().replace(/\\/g, "/");
2310
+ return path !== "" && path !== "local.json" && !isDerivedStoreArtifact(path);
2311
+ });
2312
+ }
2286
2313
  /** The state files git writes while it is replaying history. `git rev-parse --git-path`
2287
2314
  * resolves each against THIS worktree's private git dir (a linked worktree keeps its own
2288
2315
  * rebase-merge/MERGE_HEAD), so a rebase in one worktree never reads as one in another. */
@@ -197,7 +197,7 @@ export function inspectIntegrations(root, selected) {
197
197
  try {
198
198
  const launcher = readLauncher(root, harness);
199
199
  recordPins(spec.mcp, [launcher.command, ...launcher.args]);
200
- capabilities.mcp.detail = "Configured locally; use --probe to verify a fresh server, then reconnect the host";
200
+ capabilities.mcp.detail = `Configured locally; run \`hunch integrations check --probe --harness ${harness}\` to verify a fresh server, then reconnect the host`;
201
201
  }
202
202
  catch (e) {
203
203
  // A harness can be detected here via its hooks file alone —
@@ -1,4 +1,15 @@
1
1
  import { type Harness, type IntegrationHealth } from "./health.js";
2
+ /** A launcher that runs THIS Hunch build directly (`node <build>/cli/index.js mcp`) —
3
+ * a local checkout or an unpacked test build. It is exactly the code already
4
+ * running the probe, so starting it executes nothing new; any other custom
5
+ * command stays inspect-only. The server identity/version check still applies.
6
+ * The command is a bare `node` (PATH lookup) or this exact Node binary — a
7
+ * directory-qualified `./tools/node` in a cloned repo is not, whatever its name —
8
+ * and the probe spawns process.execPath itself, never the configured name. */
9
+ export declare function isSelfLauncher(launcher: {
10
+ command: string;
11
+ args: string[];
12
+ }, entry?: string): boolean;
2
13
  /** Opt-in: starts a fresh configured MCP process, never claims the current
3
14
  * harness connection or hook delivery has been tested. No model is invoked. */
4
15
  export declare function probeIntegration(root: string, harness: Harness, report: IntegrationHealth, timeoutMs?: number): Promise<void>;
@@ -1,6 +1,27 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { existsSync } from "node:fs";
4
+ import { isAbsolute } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { sameFilesystemEntry } from "../extractors/git.js";
3
7
  import { HARNESSES, readLauncher } from "./health.js";
8
+ /** A launcher that runs THIS Hunch build directly (`node <build>/cli/index.js mcp`) —
9
+ * a local checkout or an unpacked test build. It is exactly the code already
10
+ * running the probe, so starting it executes nothing new; any other custom
11
+ * command stays inspect-only. The server identity/version check still applies.
12
+ * The command is a bare `node` (PATH lookup) or this exact Node binary — a
13
+ * directory-qualified `./tools/node` in a cloned repo is not, whatever its name —
14
+ * and the probe spawns process.execPath itself, never the configured name. */
15
+ export function isSelfLauncher(launcher, entry = fileURLToPath(new URL("../cli/index.js", import.meta.url))) {
16
+ const bareNode = /^node(?:\.exe)?$/i.test(launcher.command);
17
+ const node = bareNode || (isAbsolute(launcher.command) && existsSync(launcher.command) && sameFilesystemEntry(launcher.command, process.execPath));
18
+ if (!node || launcher.args.length !== 2 || launcher.args[1] !== "mcp")
19
+ return false;
20
+ const script = launcher.args[0];
21
+ // Generated launchers name the build entry absolutely; a relative script would resolve
22
+ // against whatever cwd the host spawns in.
23
+ return isAbsolute(script) && existsSync(script) && existsSync(entry) && sameFilesystemEntry(script, entry);
24
+ }
4
25
  /** Opt-in: starts a fresh configured MCP process, never claims the current
5
26
  * harness connection or hook delivery has been tested. No model is invoked. */
6
27
  export async function probeIntegration(root, harness, report, timeoutMs = 15_000) {
@@ -16,11 +37,15 @@ export async function probeIntegration(root, harness, report, timeoutMs = 15_000
16
37
  throw new Error("custom MCP environment or working directory requires a host-side probe; no settings were ignored");
17
38
  // Only execute the exact published launcher Hunch generates. Custom
18
39
  // commands may need credentials or have unrelated effects; inspect only.
19
- if (!/^npx(?:\.cmd)?$/.test(launcher.command) || JSON.stringify(launcher.args) !== JSON.stringify([
40
+ const generated = /^npx(?:\.cmd)?$/.test(launcher.command) && JSON.stringify(launcher.args) === JSON.stringify([
20
41
  "-y", `--package=hunch-exact@npm:@davesheffer/hunch@${report.expectedVersion}`, "hunch", "mcp",
21
- ]))
22
- throw new Error("probe requires the generated exact-version npx launcher; repair stale pins first; custom launchers remain untested");
23
- transport = new StdioClientTransport({ ...launcher, cwd: root, stderr: "pipe" });
42
+ ]);
43
+ const self = !generated && isSelfLauncher(launcher);
44
+ if (!generated && !self)
45
+ throw new Error("probe requires the generated exact-version npx launcher, or a `node <build>/cli/index.js mcp` launcher probed by that same build; repair stale pins first; other custom launchers remain untested");
46
+ // A self launcher runs this exact Node binary: no PATH/cwd lookup of `node` can
47
+ // resolve to an executable planted in the repository.
48
+ transport = new StdioClientTransport({ ...launcher, ...(self ? { command: process.execPath } : {}), cwd: root, stderr: "pipe" });
24
49
  transport.stderr?.on("data", () => { });
25
50
  await Promise.race([
26
51
  (async () => {
@@ -11,11 +11,11 @@ export type MemoryHome = "public" | "private";
11
11
  /** Flush one exact artifact home. Constitution repositories can explicitly
12
12
  * choose public even in unified mode, so this must not infer routing through
13
13
  * captureHome(isPrivate). The caller supplies the repository's actual home. */
14
- export declare function flushMemoryHome(store: HunchStore, publicHunchDir: string, home: MemoryHome, message: string, remoteOverride?: HunchRemoteContract): "pushed" | "committed" | null;
14
+ export declare function flushMemoryHome(store: HunchStore, publicHunchDir: string, home: MemoryHome, message: string, remoteOverride?: HunchRemoteContract): "pushed" | "committed" | "held" | null;
15
15
  /** One completion flush per touched home. A mixed ingest/bootstrap can write
16
16
  * both homes; unchanged homes are cheap no-ops after the memory-only stage
17
17
  * check, while each real home becomes durable exactly once. */
18
- export declare function flushMemoryHomes(store: HunchStore, publicHunchDir: string, homes: Iterable<MemoryHome>, message: string, remoteOverride?: HunchRemoteContract): Partial<Record<MemoryHome, "pushed" | "committed" | null>>;
18
+ export declare function flushMemoryHomes(store: HunchStore, publicHunchDir: string, homes: Iterable<MemoryHome>, message: string, remoteOverride?: HunchRemoteContract): Partial<Record<MemoryHome, "pushed" | "committed" | "held" | null>>;
19
19
  /** Auto-commit + push the overlay after a private write, when auto-commit is on. No-op
20
20
  * otherwise (manual `hunch private --sync` still works). Never throws. */
21
21
  export declare function flushPrivate(store: HunchStore, message: string): void;
@@ -24,7 +24,19 @@ export declare function flushPrivate(store: HunchStore, message: string): void;
24
24
  * "committed" (commit created but not pushed — public .hunch/ rides the next push; an
25
25
  * overlay commit whose merge/push failed retries on the next flush), or null (auto-commit
26
26
  * off, no overlay for a private record, or the commit was skipped — lock held, safety
27
- * backstop, nothing staged; the record stays on disk and the next flush sweeps it up). */
27
+ * backstop, nothing staged; the record stays on disk and the next flush sweeps it up),
28
+ * or "held" (a public capture left uncommitted because the default branch is checked
29
+ * out; callers say so with HELD_ON_DEFAULT_BRANCH). */
28
30
  export declare function flushCapture(store: HunchStore, publicHunchDir: string, isPrivate: boolean, message: string,
29
31
  /** Long-lived callers can pin the route snapshot that admitted the write. */
30
- remoteOverride?: HunchRemoteContract, observe?: GitMemoryObserver): "pushed" | "committed" | null;
32
+ remoteOverride?: HunchRemoteContract, observe?: GitMemoryObserver): "pushed" | "committed" | "held" | null;
33
+ /** Auto-commit a state-contract write (`nuryel_write`, `hunch serve`). A state root is a
34
+ * dedicated data repository whose writes commit on whatever branch it serves, so the
35
+ * default-branch hold for captures never applies; outcomes as flushCapture otherwise. */
36
+ export declare function flushStateWrite(store: HunchStore, publicHunchDir: string, isPrivate: boolean, message: string, remoteOverride?: HunchRemoteContract): "pushed" | "committed" | null;
37
+ /** What a caller prints for a "held" flush, so a solo user on main is never left
38
+ * wondering why the capture commit stopped appearing. */
39
+ export declare const HELD_ON_DEFAULT_BRANCH = "not auto-committed: the default branch is checked out \u2014 your next commit that adds .hunch/ carries it (to restore auto-commit there, set \"autoCommitDefaultBranch\": true in .hunch/local.json)";
40
+ /** Whether an automatic public memory commit must be held: the default branch is checked
41
+ * out and the user has not opted back in. Shared by flushStore and `hunch sync`. */
42
+ export declare function holdsOnDefaultBranch(publicHunchDir: string): boolean;
@@ -7,8 +7,9 @@
7
7
  * commits (bug_overlay_clobber); the memory commit rides their next push instead.
8
8
  * Used by every CLI/MCP path that writes a record.
9
9
  */
10
- import { dirname } from "node:path";
11
- import { commitAndPushHunch } from "../extractors/git.js";
10
+ import { readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { commitAndPushHunch, hunchDirHasChanges, onDefaultBranch } from "../extractors/git.js";
12
13
  import { refreshCommittableGrounding } from "./providers.js";
13
14
  import { advertisedTeamRemoteContract } from "./team.js";
14
15
  const pinnedSharedRoutes = new WeakMap();
@@ -41,6 +42,10 @@ export function flushMemoryHome(store, publicHunchDir, home, message, remoteOver
41
42
  }
42
43
  if (!store.autoCommit)
43
44
  return null;
45
+ // Same hold as flushCapture: the public commit stages all of .hunch/, so it would
46
+ // otherwise sweep a capture that was just reported as held onto the default branch.
47
+ if (holdsOnDefaultBranch(publicHunchDir))
48
+ return heldOrNothing(publicHunchDir);
44
49
  const grounding = refreshCommittableGrounding(dirname(publicHunchDir), store);
45
50
  return commitAndPushHunch(publicHunchDir, message, { push: false, alsoStage: grounding });
46
51
  }
@@ -70,10 +75,21 @@ export function flushPrivate(store, message) {
70
75
  * "committed" (commit created but not pushed — public .hunch/ rides the next push; an
71
76
  * overlay commit whose merge/push failed retries on the next flush), or null (auto-commit
72
77
  * off, no overlay for a private record, or the commit was skipped — lock held, safety
73
- * backstop, nothing staged; the record stays on disk and the next flush sweeps it up). */
78
+ * backstop, nothing staged; the record stays on disk and the next flush sweeps it up),
79
+ * or "held" (a public capture left uncommitted because the default branch is checked
80
+ * out; callers say so with HELD_ON_DEFAULT_BRANCH). */
74
81
  export function flushCapture(store, publicHunchDir, isPrivate, message,
75
82
  /** Long-lived callers can pin the route snapshot that admitted the write. */
76
83
  remoteOverride, observe) {
84
+ return flushStore(store, publicHunchDir, isPrivate, message, remoteOverride, observe, true);
85
+ }
86
+ /** Auto-commit a state-contract write (`nuryel_write`, `hunch serve`). A state root is a
87
+ * dedicated data repository whose writes commit on whatever branch it serves, so the
88
+ * default-branch hold for captures never applies; outcomes as flushCapture otherwise. */
89
+ export function flushStateWrite(store, publicHunchDir, isPrivate, message, remoteOverride) {
90
+ return flushStore(store, publicHunchDir, isPrivate, message, remoteOverride, undefined, false);
91
+ }
92
+ function flushStore(store, publicHunchDir, isPrivate, message, remoteOverride, observe, holdOnDefaultBranch) {
77
93
  // Follow the same routing as HunchStore.captureHome: unified ("shared") mode homes
78
94
  // EVERY capture in the overlay, so the flush must go there too — one source of truth.
79
95
  if (store.captureHome(isPrivate) === "private") {
@@ -89,10 +105,45 @@ remoteOverride, observe) {
89
105
  }
90
106
  if (!store.autoCommit)
91
107
  return null;
108
+ // A capture never commits onto the default branch by itself: task and capture commits
109
+ // landed on main between the user's own. The record stays on disk; the user's next
110
+ // commit carries it, or the next capture on a feature branch sweeps it up. Held before
111
+ // the grounding refresh, so an automatic capture (hook sync, MCP) leaves tracked docs at
112
+ // HEAD instead of counting a record that `git commit -a` would leave behind; by-hand CLI
113
+ // commands refresh them on disk themselves (publicFlushCommitsGrounding in the CLI).
114
+ if (holdOnDefaultBranch && holdsOnDefaultBranch(publicHunchDir))
115
+ return heldOrNothing(publicHunchDir);
92
116
  // A public capture changes record counts, so refresh git-clean grounding docs and fold
93
117
  // them into the SAME memory commit — otherwise every capture re-stales the committed
94
118
  // counts and the release gate's clean-tree check fails on the next CI index.
95
119
  const grounding = refreshCommittableGrounding(dirname(publicHunchDir), store);
96
120
  return commitAndPushHunch(publicHunchDir, message, { push: false, alsoStage: grounding, observe });
97
121
  }
122
+ /** What a caller prints for a "held" flush, so a solo user on main is never left
123
+ * wondering why the capture commit stopped appearing. */
124
+ export const HELD_ON_DEFAULT_BRANCH = "not auto-committed: the default branch is checked out — your next commit that adds .hunch/ carries it (to restore auto-commit there, set \"autoCommitDefaultBranch\": true in .hunch/local.json)";
125
+ /** A hold reports "held" only when there is something left for the next commit; a clean
126
+ * .hunch/ is null, as an unheld flush with nothing staged would be. */
127
+ function heldOrNothing(publicHunchDir) {
128
+ return hunchDirHasChanges(publicHunchDir) ? "held" : null;
129
+ }
130
+ /** Whether an automatic public memory commit must be held: the default branch is checked
131
+ * out and the user has not opted back in. Shared by flushStore and `hunch sync`. */
132
+ export function holdsOnDefaultBranch(publicHunchDir) {
133
+ return !defaultBranchCommitsAllowed(publicHunchDir) && onDefaultBranch(dirname(publicHunchDir));
134
+ }
135
+ /** Opt back in to capture commits on the default branch: `autoCommitDefaultBranch: true`
136
+ * in the per-machine .hunch/local.json, or HUNCH_AUTO_COMMIT_DEFAULT_BRANCH=1. */
137
+ function defaultBranchCommitsAllowed(publicHunchDir) {
138
+ if (process.env.HUNCH_AUTO_COMMIT_DEFAULT_BRANCH === "1")
139
+ return true;
140
+ try {
141
+ const raw = readFileSync(join(publicHunchDir, "local.json"), "utf8");
142
+ const local = JSON.parse(raw.startsWith("\uFEFF") ? raw.slice(1) : raw);
143
+ return !!local && typeof local === "object" && local.autoCommitDefaultBranch === true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
98
149
  //# sourceMappingURL=sync.js.map
@@ -47,7 +47,7 @@ export type SnapshotOutcome = {
47
47
  status: "written";
48
48
  record: Workspace;
49
49
  home: "private" | "public";
50
- flushed: "pushed" | "committed" | null;
50
+ flushed: "pushed" | "committed" | "held" | null;
51
51
  };
52
52
  /** Record this machine's snapshot. Honors `workspaces.publish`, skips a write when the
53
53
  * content is unchanged and the stored record is under a day old (an idle machine's hooks
@@ -34,7 +34,7 @@ import { refreshExistingGrounding } from "../integrations/providers.js";
34
34
  import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
35
35
  import { workspacesConfig } from "../core/config.js";
36
36
  import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatusDetailed, describePullFailure, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
37
- import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
37
+ import { flushCapture, flushStateWrite, flushMemoryHome, pinSharedRemote, HELD_ON_DEFAULT_BRANCH } from "../integrations/sync.js";
38
38
  import { withWriteLock } from "../serve/writelock.js";
39
39
  import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
40
40
  import { formatSearchHit, formatStructure } from "../core/format.js";
@@ -113,12 +113,13 @@ function cwdHintOf(input) {
113
113
  /** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
114
114
  * commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
115
115
  * next flush sweeps it up; claiming "auto-committed" there would be a lie. */
116
- const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
117
- : flush === "committed"
118
- ? home === "private"
119
- ? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
120
- : " (auto-committed to .hunch/ — rides your next push)"
121
- : "";
116
+ const flushNote = (flush, home, mode) => flush === "held" ? ` (${HELD_ON_DEFAULT_BRANCH})`
117
+ : flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
118
+ : flush === "committed"
119
+ ? home === "private"
120
+ ? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
121
+ : " (auto-committed to .hunch/ — rides your next push)"
122
+ : "";
122
123
  /** When an overlay exists, a PUBLIC write deserves one visible line: a record that
123
124
  * lands in the committed store publishes on the next push, and an agent writing
124
125
  * strategy/competitive content there is a leak nobody notices until it ships
@@ -2767,7 +2768,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2767
2768
  // not race the HTTP server between the ledger read and the record write.
2768
2769
  const { hunchDir } = stateHomeFor(store, input.scope);
2769
2770
  const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
2770
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2771
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2771
2772
  }));
2772
2773
  return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
2773
2774
  }
@@ -2784,7 +2785,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2784
2785
  try {
2785
2786
  const { hunchDir } = stateHomeFor(store, input.scope);
2786
2787
  const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, ...input }, {
2787
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2788
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2788
2789
  }));
2789
2790
  return stateResult(`${result.outcome} observation ${result.record_id} (${result.durability}); this does not assert currentness. ${result.record_hash}`, result);
2790
2791
  }
@@ -2801,7 +2802,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2801
2802
  try {
2802
2803
  const { hunchDir } = stateHomeFor(store, input.scope);
2803
2804
  const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, ...input }, {
2804
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2805
+ flush: (isPrivate, message) => flushStateWrite(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
2805
2806
  }));
2806
2807
  return stateResult(`Capture batch: ${result.results.filter(r => r.status === "saved").length} saved/replayed, ${result.results.filter(r => r.status === "refused").length} refused.${result.reviews ? ` Reviews: ${result.reviews.filter(r => r.status === "saved").length} withdrawn/replayed, ${result.reviews.filter(r => r.status === "refused").length} refused.` : ''} Inspect each indexed result.`, result);
2807
2808
  }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { verificationLauncherFor } from "../core/verifyLauncher.js";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
4
  import { persistTaskRecord } from "../core/taskRecord.js";
5
- import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
5
+ import { currentSource, runReportConformance } from "../core/taskReportEvidence.js";
6
6
  import { renderTaskReport, writeTaskReportHtml } from "../core/taskReportRender.js";
7
7
  function applicationReferences(report) {
8
8
  return report.deliveries.flatMap(d => d.records.map(r => ({ occurrence_id: d.occurrence_id, record_id: r.record_id, content_hash: r.content_hash, title: r.title }))).slice(-100);
@@ -85,7 +85,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
85
85
  // from the model must not fork a second report or fail the start.
86
86
  if (!task_id || !/different title/.test(error.message))
87
87
  throw error;
88
- task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
88
+ task = readTaskReport(root, task_id, currentSource(root)).task;
89
89
  }
90
90
  const launcher = verificationLauncher();
91
91
  // Compact by design (#370): the rules an agent needs to act, and exact
@@ -120,7 +120,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
120
120
  catch (error) {
121
121
  graphNote = `\nGraph not saved: ${error.message}`;
122
122
  }
123
- const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
123
+ const report = readTaskReport(root, task_id, currentSource(root));
124
124
  const show = reportPresentationEnabled(root);
125
125
  // The HTML evidence view is rendered on demand (hunch_report(html: true),
126
126
  // `hunch report <id> --html`, or the VS Code view); finish writes no file.
@@ -165,7 +165,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
165
165
  const tasks = listReportTasks(root);
166
166
  return { content: [{ type: "text", text: tasks.length ? tasks.map(t => `${t.task_id} · ${t.state} · ${t.title}`).join("\n") : "No task reports yet. Start with hunch_task." }], structuredContent: { tasks } };
167
167
  }
168
- const report = readTaskReport(root, task_id, reportSourceSnapshot(root).hash);
168
+ const report = readTaskReport(root, task_id, currentSource(root));
169
169
  const file = html ? writeTaskReportHtml(root, task_id) : null;
170
170
  return { content: [{ type: "text", text: `${renderTaskReport(report)}${file ? `\nLocal evidence view: ${file}` : ""}` }], structuredContent: { ...boundedTaskReportForHost(report), application_references: applicationReferences(report), contribution_card: renderTaskReport(report), report_path: file } };
171
171
  }