@davesheffer/hunch 1.46.0 → 1.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/cli/index.js +280 -358
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/cli/taskReport.d.ts +6 -0
  5. package/dist/cli/taskReport.js +31 -8
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/sourceMutation.js +28 -18
  8. package/dist/core/commitrepair.d.ts +43 -138
  9. package/dist/core/commitrepair.js +76 -167
  10. package/dist/core/correction.d.ts +2 -0
  11. package/dist/core/correction.js +23 -10
  12. package/dist/core/docanchors.js +9 -9
  13. package/dist/core/drift.js +22 -27
  14. package/dist/core/escalations.d.ts +16 -58
  15. package/dist/core/escalations.js +39 -121
  16. package/dist/core/glob.d.ts +7 -7
  17. package/dist/core/glob.js +12 -11
  18. package/dist/core/groundingLag.d.ts +2 -9
  19. package/dist/core/groundingLag.js +5 -11
  20. package/dist/core/groundingMerge.d.ts +2 -7
  21. package/dist/core/groundingMerge.js +35 -54
  22. package/dist/core/paths.d.ts +17 -24
  23. package/dist/core/paths.js +37 -43
  24. package/dist/core/refrepair.d.ts +2 -1
  25. package/dist/core/refrepair.js +4 -4
  26. package/dist/core/repairqueue.d.ts +17 -34
  27. package/dist/core/repairqueue.js +48 -98
  28. package/dist/core/taskRecord.d.ts +1 -1
  29. package/dist/core/taskRecord.js +2 -2
  30. package/dist/core/taskReport.d.ts +8 -3
  31. package/dist/core/taskReport.js +7 -2
  32. package/dist/core/taskReportEvidence.d.ts +7 -0
  33. package/dist/core/taskReportEvidence.js +16 -4
  34. package/dist/core/taskReportHook.js +2 -2
  35. package/dist/core/taskReportPublic.js +2 -2
  36. package/dist/core/taskReportRender.js +2 -2
  37. package/dist/core/types.d.ts +3 -3
  38. package/dist/core/types.js +1 -1
  39. package/dist/extractors/diff.d.ts +4 -4
  40. package/dist/extractors/diff.js +25 -29
  41. package/dist/extractors/git.d.ts +18 -23
  42. package/dist/extractors/git.js +72 -64
  43. package/dist/extractors/helm.d.ts +10 -0
  44. package/dist/extractors/helm.js +43 -59
  45. package/dist/extractors/indexer.d.ts +6 -0
  46. package/dist/extractors/indexer.js +126 -178
  47. package/dist/extractors/k8sManifest.d.ts +23 -80
  48. package/dist/extractors/k8sManifest.js +319 -690
  49. package/dist/extractors/languages.d.ts +28 -37
  50. package/dist/extractors/languages.js +55 -54
  51. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  52. package/dist/extractors/nativeTreeSitter.js +17 -14
  53. package/dist/extractors/parse.d.ts +7 -13
  54. package/dist/extractors/parse.js +39 -51
  55. package/dist/integrations/claudemd.js +1 -0
  56. package/dist/integrations/gitignore.js +3 -5
  57. package/dist/integrations/health.js +1 -1
  58. package/dist/integrations/hooks.d.ts +9 -14
  59. package/dist/integrations/hooks.js +19 -28
  60. package/dist/integrations/mergeDriver.js +13 -14
  61. package/dist/integrations/probe.d.ts +11 -0
  62. package/dist/integrations/probe.js +29 -4
  63. package/dist/integrations/sync.d.ts +16 -4
  64. package/dist/integrations/sync.js +54 -3
  65. package/dist/integrations/workspaceLedger.d.ts +1 -1
  66. package/dist/mcp/server.d.ts +4 -5
  67. package/dist/mcp/server.js +71 -83
  68. package/dist/mcp/taskReportTools.js +4 -4
  69. package/dist/serve/app.js +2 -2
  70. package/dist/store/hunchStore.js +12 -18
  71. package/dist/synthesis/provider.d.ts +19 -21
  72. package/dist/synthesis/provider.js +67 -76
  73. package/dist/synthesis/synthesize.d.ts +8 -8
  74. package/dist/synthesis/synthesize.js +32 -49
  75. package/dist/taskReports.d.ts +1 -0
  76. package/dist/taskReports.js +2 -2
  77. package/package.json +2 -2
  78. package/server.json +2 -2
@@ -111,20 +111,15 @@ export declare function installPostCommitHook(root: string, invocation: string,
111
111
  * blocking invariant (see strictgate.ts), so it's safe on a shared repo.
112
112
  * Preserves any existing pre-commit hook. */
113
113
  export declare function installPreCommitHook(root: string, invocation: string, strict?: boolean): HookInstall;
114
- /** Install a post-merge hook carrying TWO independently-managed blocks:
115
- * re-sync the committed grounding docs when a merge brought memory in behind
116
- * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
117
- * files and can never fail the merge), and opportunistically DETECT a
118
- * decision's commit provenance going orphaned right after a squash-merged
119
- * branch lands locally (including a fast-forward from `git pull`) — while
120
- * the original commits are still fully intact and matchable — queuing the
121
- * match for a human to confirm via `hunch repair-provenance --apply`
122
- * (HUNCH_MERGE_SYNC-guarded, backgrounded; own env var since this hook makes
123
- * no commit of its own and so can't reuse HUNCH_SYNC's re-trigger guard).
124
- * Each block is keyed by its own marker pair (installManagedBlock), so
125
- * re-running updates only its own block, preserves the other untouched, and
126
- * a repo carrying only one half (an older install, or a hand-edited hook)
127
- * gets the other appended rather than clobbered. */
114
+ /** Install the post-merge hook. It carries two independently managed blocks:
115
+ * - grounding: re-syncs committed grounding docs when a merge brought in memory.
116
+ * Guarded by HUNCH_SYNC, runs in the foreground, never fails the merge.
117
+ * - repair: detects a decision's commit provenance being orphaned by a
118
+ * squash-merge (including a fast-forward from `git pull`) and queues the match
119
+ * for `hunch repair-provenance --apply`. Guarded by HUNCH_MERGE_SYNC, runs in
120
+ * the background.
121
+ * Each block is keyed by its own marker pair, so a re-run updates only its own
122
+ * block, and a hook that has only one half gets the other appended, not clobbered. */
128
123
  export declare function installPostMergeHook(root: string, invocation: string): HookInstall;
129
124
  export declare function installPostCheckoutHook(root: string, invocation: string): HookInstall;
130
125
  export type HookState = BlockState;
@@ -793,14 +793,12 @@ export function installPreCommitHook(root, invocation, strict = false) {
793
793
  };
794
794
  return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
795
795
  }
796
- // Original marker, kept byte-for-byte for backward compat: an existing install's
797
- // grounding-refresh block must still be found and updated in place by its own
798
- // exact marker text (fnd_c402046ac7).
796
+ // The original grounding marker is kept unchanged for backward compatibility, so an
797
+ // existing grounding block is still found and updated in place.
799
798
  const GROUNDING_MERGE_MARK = "# >>> hunch post-merge >>>";
800
799
  const GROUNDING_MERGE_END = "# <<< hunch post-merge <<<";
801
- // Distinct marker for the (newer) repair-provenance half, so the two blocks
802
- // never collide inside the same post-merge hook file and each can be
803
- // independently created/updated/removed without touching the other.
800
+ // The repair-provenance block gets a SEPARATE marker so the two blocks never collide
801
+ // in one post-merge file and each can be created, updated or removed on its own.
804
802
  const REPAIR_MERGE_MARK = "# >>> hunch post-merge (repair-provenance) >>>";
805
803
  const REPAIR_MERGE_END = "# <<< hunch post-merge (repair-provenance) <<<";
806
804
  function groundingMergeBlock(invocation) {
@@ -819,37 +817,30 @@ function repairProvenanceMergeBlock(invocation) {
819
817
  REPAIR_MERGE_MARK,
820
818
  'if [ -z "$HUNCH_MERGE_SYNC" ]; then',
821
819
  " export HUNCH_MERGE_SYNC=1",
822
- // No --apply: this only detects a squash-merge orphaning a decision's commit
823
- // and queues the match (.hunch/pending-commit-repairs.json, local-only) for a
824
- // human to confirm via `hunch repair-provenance --apply` — the match signal
825
- // (file-set overlap, not git's own rename detection) isn't strong enough to
826
- // trust an unattended, backgrounded write into shared team memory.
820
+ // Detect-and-queue only, never --apply: the file-overlap match is not strong
821
+ // enough to trust an unattended write to shared memory. A human confirms the
822
+ // queue (.hunch/pending-commit-repairs.json) with `repair-provenance --apply`.
827
823
  ` ( ${invocation} repair-provenance --from-hook --quiet >/dev/null 2>&1 || true ) &`,
828
824
  "fi",
829
825
  REPAIR_MERGE_END,
830
826
  ].join("\n");
831
827
  }
832
- /** How significant a combined install result is, for picking one HookInstall
833
- * action out of two independent sub-installs into the same file — "created"
834
- * (the file itself is new) outranks "appended"/"updated" (an existing file
828
+ /** Rank used to pick ONE HookInstall action out of two independent sub-installs
829
+ * into the same file. "created" (the file itself is new) outranks "appended" /
830
+ * "updated" (an existing file
835
831
  * changed), which outrank "unchanged". Non-writing results are handled before
836
832
  * ranking (they must never be masked by a sibling's success). */
837
833
  const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0, "opted-out": 0 };
838
834
  const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable" && h.action !== "opted-out";
839
- /** Install a post-merge hook carrying TWO independently-managed blocks:
840
- * re-sync the committed grounding docs when a merge brought memory in behind
841
- * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
842
- * files and can never fail the merge), and opportunistically DETECT a
843
- * decision's commit provenance going orphaned right after a squash-merged
844
- * branch lands locally (including a fast-forward from `git pull`) — while
845
- * the original commits are still fully intact and matchable — queuing the
846
- * match for a human to confirm via `hunch repair-provenance --apply`
847
- * (HUNCH_MERGE_SYNC-guarded, backgrounded; own env var since this hook makes
848
- * no commit of its own and so can't reuse HUNCH_SYNC's re-trigger guard).
849
- * Each block is keyed by its own marker pair (installManagedBlock), so
850
- * re-running updates only its own block, preserves the other untouched, and
851
- * a repo carrying only one half (an older install, or a hand-edited hook)
852
- * gets the other appended rather than clobbered. */
835
+ /** Install the post-merge hook. It carries two independently managed blocks:
836
+ * - grounding: re-syncs committed grounding docs when a merge brought in memory.
837
+ * Guarded by HUNCH_SYNC, runs in the foreground, never fails the merge.
838
+ * - repair: detects a decision's commit provenance being orphaned by a
839
+ * squash-merge (including a fast-forward from `git pull`) and queues the match
840
+ * for `hunch repair-provenance --apply`. Guarded by HUNCH_MERGE_SYNC, runs in
841
+ * the background.
842
+ * Each block is keyed by its own marker pair, so a re-run updates only its own
843
+ * block, and a hook that has only one half gets the other appended, not clobbered. */
853
844
  export function installPostMergeHook(root, invocation) {
854
845
  // Only the grounding half regenerates committed docs; repair-provenance writes
855
846
  // no docs (it only queues a match for a human to confirm), so it keeps the
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Wires up two independent git merge drivers:
3
- * - `merge=hunch` — the structured `.hunch/` JSON driver (store/merge.ts):
4
- * resolves concurrent edits by record id.
5
- * - `merge=hunch-grounding` — the generated grounding docs (core/groundingMerge.ts):
6
- * auto-resolves a hard conflict confined to the record-counts sentence,
7
- * leaving any other conflict untouched (dec_ba5b0dfa22).
8
- * Both routes live in `.gitattributes` (committed, travels with the repo);
9
- * both drivers are registered in LOCAL git config only (per-clone, since each
10
- * references this machine's node + cli path — teammates re-run `hunch init`).
3
+ * - `merge=hunch`: the structured `.hunch/` JSON driver (store/merge.ts), which
4
+ * resolves concurrent edits by record id;
5
+ * - `merge=hunch-grounding`: the generated grounding docs driver
6
+ * (core/groundingMerge.ts), which auto-resolves only a hard conflict confined
7
+ * to the record-counts sentence.
8
+ * Both routes live in the committed `.gitattributes`. Both drivers are
9
+ * registered only in local, per-clone git config, because each references this
10
+ * machine's node and CLI path; teammates re-run `hunch init`.
11
11
  */
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { readFileSync, existsSync } from "node:fs";
@@ -18,13 +18,12 @@ import { mergeDriverShell } from "../cli/invocation.js";
18
18
  // Route the .hunch JSON records through the structured driver — but NOT the
19
19
  // manifest (an id-less `{schema_version}` object the driver can't merge by id; a
20
20
  // normal text merge with conflict markers is the right behavior for it).
21
- //
22
- // The five generated grounding docs get the OTHER driver — narrower in scope
23
- // (it only ever touches a hard conflict confined to the counts sentence).
21
+ // The five generated grounding docs go to the other driver, which is narrower:
22
+ // it only ever touches a hard conflict confined to the counts sentence.
24
23
  export const ATTR_LINES = [
25
24
  ".hunch/**/*.json merge=hunch",
26
25
  ".hunch/manifest.json merge=text",
27
- ...GROUNDING_DOC_PATHS.map((f) => `${f} merge=hunch-grounding`),
26
+ ...GROUNDING_DOC_PATHS.map((p) => `${p} merge=hunch-grounding`),
28
27
  ];
29
28
  export function targetRepositoryEnv() {
30
29
  const env = { ...process.env };
@@ -57,8 +56,8 @@ export function installMergeDriver(root, invShell) {
57
56
  assertSafeTopLevelConfigFile(root, ".gitattributes");
58
57
  writeFileAtomic(attrPath, text);
59
58
  }
60
- // 2. Local git config — the driver definitions are per-clone (they reference
61
- // this machine's node + cli path), so they are NOT committed; teammates
59
+ // 2. The driver definitions go into local git config. They are per-clone (they
60
+ // reference this machine's node and CLI path), so they are never committed; teammates
62
61
  // re-run init. Dev mode swaps `npx tsx` for Hunch's own loader (#492).
63
62
  const shell = mergeDriverShell(invShell);
64
63
  const driver = `${shell} merge-driver "%O" "%A" "%B" "%P"`;
@@ -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
@@ -359,12 +359,12 @@ declare const DELIVERY_OUTPUT_SCHEMA: z.ZodObject<{
359
359
  type: z.ZodEnum<{
360
360
  depends_on: "depends_on";
361
361
  calls: "calls";
362
+ references: "references";
362
363
  imports: "imports";
363
364
  contains: "contains";
364
365
  implements: "implements";
365
366
  supersedes: "supersedes";
366
367
  related_to: "related_to";
367
- references: "references";
368
368
  provides: "provides";
369
369
  belongs_to: "belongs_to";
370
370
  implemented_by: "implemented_by";
@@ -624,10 +624,9 @@ export declare function compactEnvelope(envelope: z.infer<typeof DELIVERY_OUTPUT
624
624
  * receipts and receipt_id are unchanged. */
625
625
  export declare function compactOmissions(envelope: z.infer<typeof DELIVERY_OUTPUT_SCHEMA>): z.infer<typeof DELIVERY_ADVERTISED_OUTPUT_SCHEMA>;
626
626
  export declare function decisionSubstanceChanged(before: Decision, after: Decision): boolean;
627
- /** Resolve a free-form target (symbol id / name / file path) to symbol records.
628
- * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
629
- * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
630
- * apart on the same question. */
627
+ /** Resolve a free-form target (symbol id, name, or file path) to symbol records using
628
+ * tiered matching — exact id, then exact name, then exact file, then a path-segment-anchored
629
+ * suffix. Shared with HunchStore.why() through matchSymbolsTiered so the two never disagree. */
631
630
  export declare function resolveSymbols(store: HunchStore, target: string): Symbol[];
632
631
  /** Resolve a target to canonical indexed file path(s) (for file-granular blast
633
632
  * radius). Falls back to the literal target so direct-scope checks still run. */