@davesheffer/hunch 1.38.1 → 1.39.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 (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. package/server.json +2 -2
@@ -0,0 +1,93 @@
1
+ import { type WorkspacesConfig } from "../core/config.js";
2
+ import { type MachineIdentity } from "../core/machine.js";
3
+ import { branchRows, worktreeRows, type BranchRow, type PrunePlan, type PruneStep, type Workspace, type WorktreeRow } from "../core/workspace.js";
4
+ import type { HunchStore } from "../store/hunchStore.js";
5
+ export interface LedgerView {
6
+ machine: MachineIdentity;
7
+ /** This machine, live (paths included; never written). */
8
+ live: Workspace;
9
+ /** live + every OTHER machine's stored record. */
10
+ records: Workspace[];
11
+ config: WorkspacesConfig;
12
+ }
13
+ export declare function workspaceLedgerView(store: HunchStore, root: string, opts?: {
14
+ fetch?: boolean;
15
+ }): LedgerView;
16
+ export type SnapshotOutcome = {
17
+ status: "off";
18
+ } | {
19
+ status: "dry-run";
20
+ record: Workspace;
21
+ } | {
22
+ status: "no-home";
23
+ record: Workspace;
24
+ } | {
25
+ status: "unchanged";
26
+ record: Workspace;
27
+ previous: Workspace;
28
+ }
29
+ /** The OTHER memory home already holds a record with this machine's id (an old public
30
+ * copy, or a record someone else wrote under this id): the store refuses a twin, and so
31
+ * do we — `hunch workspaces forget <id>` removes the stale copy. */
32
+ | {
33
+ status: "collision";
34
+ record: Workspace;
35
+ reason: string;
36
+ } | {
37
+ status: "written";
38
+ record: Workspace;
39
+ home: "private" | "public";
40
+ flushed: "pushed" | "committed" | null;
41
+ };
42
+ /** Record this machine's snapshot. Honors `workspaces.publish`, skips a write when the
43
+ * content is unchanged and the stored record is under a day old (an idle machine's hooks
44
+ * must not commit a record per checkout), and reports exactly what happened. */
45
+ export declare function recordWorkspaceSnapshot(store: HunchStore, root: string, opts?: {
46
+ fetch?: boolean;
47
+ dryRun?: boolean;
48
+ live?: Workspace;
49
+ }): SnapshotOutcome;
50
+ /** Whether a snapshot could land anywhere on this root — used to skip work that would
51
+ * write nothing. */
52
+ export declare function snapshotHasHome(store: HunchStore, root: string): boolean;
53
+ export declare function padTable(header: string[], rows: string[][]): string;
54
+ export declare function renderWorktreeTable(view: LedgerView, rows: WorktreeRow[], now?: Date): string;
55
+ export declare function describeUpstream(r: BranchRow): string;
56
+ export declare function renderBranchTable(view: LedgerView, rows: BranchRow[]): string;
57
+ /** One line for `hunch now` / `hunch_now`, from STORED records only (no git, so the hot
58
+ * view stays fast); null when memory holds no workspace record. */
59
+ export declare function workspaceSummaryLine(records: readonly Workspace[], config: WorkspacesConfig, now?: Date): string | null;
60
+ export { branchRows, worktreeRows };
61
+ /** This machine's plan: the pure plan from the live record, then two live checks per step.
62
+ * A step `git branch -d` would refuse is moved to `skipped` BEFORE anything runs (removing
63
+ * the worktree and then failing the branch delete would half-apply the step), and a
64
+ * worktree's ignored files — which `git worktree remove` deletes without asking — are
65
+ * attached so the plan and the confirmation name them. */
66
+ export declare function prunePlanFor(view: LedgerView, root: string): PrunePlan;
67
+ /** Why `git branch -d -- <branch>` would refuse right now, or null when it would delete.
68
+ * Mirrors git's own rule (builtin/branch.c `branch_merged`): the branch head must be an
69
+ * ancestor of its upstream when one is configured AND resolves, otherwise of HEAD of the
70
+ * worktree the command runs in (the main worktree). Squash and rebase merges never pass it
71
+ * once the upstream is gone or unset, so such a step is reported, not half-applied. The
72
+ * branch name is matched in JS; only refs git printed and SHAs reach git as arguments. */
73
+ export declare function branchDeleteRefusal(root: string, step: Pick<PruneStep, "branch" | "head" | "method">): string | null;
74
+ export interface PruneResult {
75
+ step: PruneStep;
76
+ /** `skipped`: a live precondition failed before anything ran — the step is untouched. */
77
+ outcome: "deleted" | "skipped" | "failed";
78
+ detail: string;
79
+ }
80
+ /** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
81
+ * validated by the record schema and is passed after `--`; the worktree path comes from
82
+ * `git worktree list` on this machine. `git branch -d`'s own precondition is re-checked
83
+ * BEFORE the worktree is removed, so a step either runs whole or not at all. A failure
84
+ * stops that step, never the others. */
85
+ export declare function applyPrune(root: string, steps: readonly PruneStep[]): PruneResult[];
86
+ export declare function describeIgnored(ignored: NonNullable<PruneStep["ignored"]>): string;
87
+ /** The confirmation question for `prune --apply`: counts, and — because `git worktree
88
+ * remove` deletes ignored files (.env, build output) without asking — every worktree's
89
+ * ignored paths by name. */
90
+ export declare function pruneConfirmQuestion(view: LedgerView, plan: PrunePlan): string;
91
+ /** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
92
+ export declare function confirmPrune(question: string): Promise<boolean>;
93
+ export declare function renderPrunePlan(view: LedgerView, plan: PrunePlan): string;
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Workspace ledger wiring (docs/workspace-ledger.md, Phase 2): the ONE code path every
3
+ * surface uses to read the ledger (CLI `workspaces` / `branches`, the `hunch_workspaces`
4
+ * MCP tool, `hunch now`, `doctor`) and to record this machine's snapshot (CLI `snapshot`,
5
+ * `hunch worktree`, the git hooks, and the MCP server's session-start refresh).
6
+ *
7
+ * This machine is always read LIVE from git and never from a stored record; stored
8
+ * records (other machines) are display-only. A snapshot writes through the same capture
9
+ * funnel as every other record: the overlay when one is configured, the public .hunch/
10
+ * only when `workspaces.publish_public` opts in, nothing otherwise.
11
+ */
12
+ import { execFileSync, spawnSync } from "node:child_process";
13
+ import { createInterface } from "node:readline";
14
+ import { foreignRepoEnv, mainWorktreeRoot } from "../extractors/git.js";
15
+ import { hunchPaths } from "../core/paths.js";
16
+ import { readConfig, workspacesConfig } from "../core/config.js";
17
+ import { loadOrCreateMachine } from "../core/machine.js";
18
+ import { CONTROL_CHARS, ago, branchRows, isSafeBranchName, latestPerMachine, isUnverified, planPrune, sameWorkspaceContent, withPublishMode, worktreeRows, } from "../core/workspace.js";
19
+ import { ignoredPaths, snapshotWorkspace } from "../extractors/workspaces.js";
20
+ import { flushCapture } from "./sync.js";
21
+ export function workspaceLedgerView(store, root, opts = {}) {
22
+ const machine = loadOrCreateMachine();
23
+ const config = workspacesConfig(readConfig(hunchPaths(root)));
24
+ const live = snapshotWorkspace(root, { machine, publish: "full", fetch: !!opts.fetch });
25
+ const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id);
26
+ return { machine, live, records: [live, ...others], config };
27
+ }
28
+ /** Record this machine's snapshot. Honors `workspaces.publish`, skips a write when the
29
+ * content is unchanged and the stored record is under a day old (an idle machine's hooks
30
+ * must not commit a record per checkout), and reports exactly what happened. */
31
+ export function recordWorkspaceSnapshot(store, root, opts = {}) {
32
+ const config = workspacesConfig(readConfig(hunchPaths(root)));
33
+ if (config.publish === "off")
34
+ return { status: "off" };
35
+ const machine = loadOrCreateMachine();
36
+ // A caller that already took a live snapshot (a ledger read) publishes THAT observation
37
+ // rather than paying for a second pass over git.
38
+ const record = opts.live && !opts.fetch
39
+ ? withPublishMode(opts.live, config.publish)
40
+ : snapshotWorkspace(root, { machine, publish: config.publish, fetch: !!opts.fetch });
41
+ if (opts.dryRun)
42
+ return { status: "dry-run", record };
43
+ const isPrivate = store.hasPrivate;
44
+ if (!isPrivate && !config.publish_public)
45
+ return { status: "no-home", record };
46
+ const previous = store.getRec("workspaces", record.id);
47
+ if (previous && Date.now() - Date.parse(previous.observed_at) < 86_400_000 && sameWorkspaceContent(previous, record)) {
48
+ return { status: "unchanged", record, previous };
49
+ }
50
+ try {
51
+ store.putCapture("workspaces", record, isPrivate);
52
+ }
53
+ catch (error) {
54
+ const reason = error.message;
55
+ if (/already exists in the other memory home/.test(reason))
56
+ return { status: "collision", record, reason };
57
+ throw error;
58
+ }
59
+ const flushed = flushCapture(store, hunchPaths(root).hunch, isPrivate, `hunch: workspace snapshot ${machine.label}`);
60
+ return { status: "written", record, home: isPrivate ? "private" : "public", flushed };
61
+ }
62
+ /** Whether a snapshot could land anywhere on this root — used to skip work that would
63
+ * write nothing. */
64
+ export function snapshotHasHome(store, root) {
65
+ const config = workspacesConfig(readConfig(hunchPaths(root)));
66
+ return config.publish !== "off" && (store.hasPrivate || config.publish_public);
67
+ }
68
+ // ---- rendering (shared by the CLI and the MCP tool) -----------------------------------------
69
+ export function padTable(header, rows) {
70
+ const all = [header, ...rows];
71
+ const widths = header.map((_, i) => Math.max(...all.map((r) => (r[i] ?? "").length)));
72
+ return all.map((r) => r.map((c, i) => (i === r.length - 1 ? c ?? "" : (c ?? "").padEnd(widths[i]))).join(" ").trimEnd()).join("\n");
73
+ }
74
+ export function renderWorktreeTable(view, rows, now = new Date()) {
75
+ const table = padTable(["MACHINE", "WORKTREE", "BRANCH", "DIRTY", "LAST COMMIT", "SEEN"], rows.map((r) => [
76
+ r.machine + (r.machine === view.machine.label ? " (this)" : ""),
77
+ r.path ?? "yes",
78
+ r.branch ?? `(detached ${r.head.slice(0, 10)})`,
79
+ r.dirty === null ? "?" : r.dirty ? "yes" : "-",
80
+ r.last_commit_at ? ago(r.last_commit_at, now) : "-",
81
+ (r.machine === view.machine.label ? "live" : ago(r.seen_at, now)) + (r.unverified ? " (unverified)" : "") + (r.prunable ? " (path missing)" : "") + (r.locked ? " (locked)" : ""),
82
+ ]));
83
+ return `${table}\n\n${rows.length} worktree(s) · this machine is ${view.machine.label} · ${view.records.length - 1} other machine(s) in memory`;
84
+ }
85
+ export function describeUpstream(r) {
86
+ if (r.upstream === null)
87
+ return "never pushed";
88
+ if (r.upstream_gone)
89
+ return "gone";
90
+ return [r.ahead ? `ahead ${r.ahead}` : "", r.behind ? `behind ${r.behind}` : ""].filter(Boolean).join(", ") || "synced";
91
+ }
92
+ export function renderBranchTable(view, rows) {
93
+ const table = padTable(["BRANCH", "MACHINES", "WORKTREE", "UPSTREAM", "MERGED", "ACTION"], rows.map((r) => [
94
+ r.name,
95
+ r.machines.join(","),
96
+ r.worktree_on.length ? r.worktree_on.join(",") + (r.dirty_on.length ? " (dirty)" : "") : "-",
97
+ describeUpstream(r),
98
+ r.merged.status === "merged" ? `yes (${r.merged.method}${r.merged.pr ? `, PR #${r.merged.pr}` : ""})` : r.merged.status === "unmerged" ? "no" : r.merged.status === "no-commits" ? "no commits" : "unknown",
99
+ r.action,
100
+ ]));
101
+ const deletable = rows.filter((r) => r.action.startsWith("delete local")).length;
102
+ const warn = view.live.default_branch === null ? "\n ⚠ no default branch resolved (origin/HEAD, origin/main|master, main|master) — merge verdicts are unknown" : "";
103
+ return `${table}\n\n${rows.length} branch(es) · ${deletable} deletable · this machine is ${view.machine.label}${warn}`;
104
+ }
105
+ /** One line for `hunch now` / `hunch_now`, from STORED records only (no git, so the hot
106
+ * view stays fast); null when memory holds no workspace record. */
107
+ export function workspaceSummaryLine(records, config, now = new Date()) {
108
+ const latest = latestPerMachine(records);
109
+ if (!latest.length)
110
+ return null;
111
+ const opts = { staleAfterDays: config.stale_after_days, now };
112
+ const worktrees = worktreeRows(latest, opts);
113
+ const branches = branchRows(latest, opts);
114
+ const unverified = latest.filter((r) => isUnverified(r, opts)).length;
115
+ const deletable = branches.filter((b) => b.action.startsWith("delete local")).length;
116
+ const dirty = worktrees.filter((w) => w.dirty === true).length;
117
+ return `🗂 Workspaces in memory: ${latest.length} machine(s)${unverified ? ` (${unverified} unverified)` : ""} · ${worktrees.length} worktree(s)${dirty ? ` (${dirty} dirty)` : ""} · ${branches.length} branch(es), ${deletable} deletable — \`hunch branches\` for the verdicts`;
118
+ }
119
+ export { branchRows, worktreeRows };
120
+ // ---- prune (Phase 3) ------------------------------------------------------------------------
121
+ //
122
+ // `--apply` acts on THIS machine only, from a snapshot taken moments ago (never a stored
123
+ // record), with `git worktree remove` (no --force) and `git branch -d` (no -D), so git itself
124
+ // re-checks "clean" and "merged" as a second line of defense. Nothing here touches a remote
125
+ // or another machine; their commands are printed for a human to run there.
126
+ /** This machine's plan: the pure plan from the live record, then two live checks per step.
127
+ * A step `git branch -d` would refuse is moved to `skipped` BEFORE anything runs (removing
128
+ * the worktree and then failing the branch delete would half-apply the step), and a
129
+ * worktree's ignored files — which `git worktree remove` deletes without asking — are
130
+ * attached so the plan and the confirmation name them. */
131
+ export function prunePlanFor(view, root) {
132
+ const plan = planPrune(view.live, view.records);
133
+ const local = [];
134
+ for (const step of plan.local) {
135
+ const refusal = branchDeleteRefusal(root, step);
136
+ if (refusal) {
137
+ plan.skipped.push({ branch: step.branch, reason: refusal });
138
+ continue;
139
+ }
140
+ if (step.worktree?.path) {
141
+ const ignored = ignoredPaths(step.worktree.path);
142
+ if (ignored === null) {
143
+ plan.skipped.push({ branch: step.branch, reason: "the worktree's ignored files could not be listed" });
144
+ continue;
145
+ }
146
+ if (ignored.total)
147
+ step.ignored = ignored;
148
+ }
149
+ local.push(step);
150
+ }
151
+ plan.local = local;
152
+ return plan;
153
+ }
154
+ function gitEnv() {
155
+ return { ...foreignRepoEnv(process.env), GIT_OPTIONAL_LOCKS: "0" };
156
+ }
157
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
158
+ /** Why `git branch -d -- <branch>` would refuse right now, or null when it would delete.
159
+ * Mirrors git's own rule (builtin/branch.c `branch_merged`): the branch head must be an
160
+ * ancestor of its upstream when one is configured AND resolves, otherwise of HEAD of the
161
+ * worktree the command runs in (the main worktree). Squash and rebase merges never pass it
162
+ * once the upstream is gone or unset, so such a step is reported, not half-applied. The
163
+ * branch name is matched in JS; only refs git printed and SHAs reach git as arguments. */
164
+ export function branchDeleteRefusal(root, step) {
165
+ const main = mainWorktreeRoot(root);
166
+ const env = gitEnv();
167
+ const read = (args) => {
168
+ try {
169
+ return execFileSync("git", args, { cwd: main, env, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ };
175
+ const unchecked = "git branch -d precondition could not be checked; nothing was changed";
176
+ const refs = read(["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)", "refs/heads/"]);
177
+ if (refs === null)
178
+ return unchecked;
179
+ const entry = refs.split("\n").map((line) => line.split("\0")).find(([ref]) => ref === `refs/heads/${step.branch}`);
180
+ if (!entry)
181
+ return "branch no longer exists";
182
+ const [, head = "", upstream = ""] = entry;
183
+ if (head !== step.head)
184
+ return `branch moved since the snapshot (now ${head.slice(0, 12)})`;
185
+ let reference = null;
186
+ let referenceName = "HEAD";
187
+ if (upstream.startsWith("refs/")) {
188
+ const resolved = read(["rev-parse", "--verify", "-q", "--end-of-options", `${upstream}^{commit}`]);
189
+ if (resolved && SHA.test(resolved)) {
190
+ reference = resolved;
191
+ referenceName = upstream.replace(/^refs\/(?:remotes|heads)\//, "");
192
+ }
193
+ }
194
+ if (!reference) {
195
+ const resolved = read(["rev-parse", "--verify", "-q", "--end-of-options", "HEAD^{commit}"]);
196
+ if (!resolved || !SHA.test(resolved))
197
+ return "git branch -d would refuse: the main worktree's HEAD does not resolve; delete manually after checking";
198
+ reference = resolved;
199
+ }
200
+ const r = spawnSync("git", ["merge-base", "--is-ancestor", step.head, reference], { cwd: main, env, timeout: 30_000, stdio: "ignore" });
201
+ if (r.error || r.status === null || (r.status !== 0 && r.status !== 1))
202
+ return unchecked;
203
+ if (r.status === 0)
204
+ return null;
205
+ return step.method === "squash" || step.method === "rebase"
206
+ ? `${step.method}-merged: git branch -d would refuse (not merged into ${referenceName}); delete manually after checking`
207
+ : `git branch -d would refuse: not merged into ${referenceName}; update it, or delete manually after checking`;
208
+ }
209
+ /** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
210
+ * validated by the record schema and is passed after `--`; the worktree path comes from
211
+ * `git worktree list` on this machine. `git branch -d`'s own precondition is re-checked
212
+ * BEFORE the worktree is removed, so a step either runs whole or not at all. A failure
213
+ * stops that step, never the others. */
214
+ export function applyPrune(root, steps) {
215
+ const main = mainWorktreeRoot(root);
216
+ const env = gitEnv();
217
+ const git = (args) => execFileSync("git", args, { cwd: main, env, encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
218
+ const results = [];
219
+ for (const step of steps) {
220
+ if (!isSafeBranchName(step.branch)) {
221
+ results.push({ step, outcome: "failed", detail: "refused: unsafe branch name" });
222
+ continue;
223
+ }
224
+ const refusal = branchDeleteRefusal(root, step);
225
+ if (refusal) {
226
+ results.push({ step, outcome: "skipped", detail: `skipped (worktree and branch kept): ${refusal}` });
227
+ continue;
228
+ }
229
+ try {
230
+ const detail = [];
231
+ if (step.worktree?.path) {
232
+ git(["worktree", "remove", "--", step.worktree.path]);
233
+ detail.push(`removed worktree ${step.worktree.path}`);
234
+ }
235
+ else if (step.worktree) {
236
+ results.push({ step, outcome: "failed", detail: "refused: the worktree's path is not known on this machine" });
237
+ continue;
238
+ }
239
+ git(["branch", "-d", "--", step.branch]);
240
+ detail.push(`deleted branch ${step.branch} (was ${step.head.slice(0, 12)})`);
241
+ results.push({ step, outcome: "deleted", detail: detail.join("; ") });
242
+ }
243
+ catch (error) {
244
+ const stderr = error.stderr?.toString().trim().split("\n")[0] ?? error.message;
245
+ results.push({ step, outcome: "failed", detail: `git refused: ${stderr}` });
246
+ }
247
+ }
248
+ return results;
249
+ }
250
+ /** A file name is repository content a terminal would interpret: control characters are
251
+ * shown escaped, never emitted. */
252
+ function printable(text) {
253
+ return text.replace(new RegExp(CONTROL_CHARS.source, "g"), (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
254
+ }
255
+ export function describeIgnored(ignored) {
256
+ const more = ignored.total - ignored.shown.length;
257
+ return `${ignored.total} ignored path(s) in the worktree: ${ignored.shown.map(printable).join(", ")}${more > 0 ? `, … and ${more} more` : ""}`;
258
+ }
259
+ /** The confirmation question for `prune --apply`: counts, and — because `git worktree
260
+ * remove` deletes ignored files (.env, build output) without asking — every worktree's
261
+ * ignored paths by name. */
262
+ export function pruneConfirmQuestion(view, plan) {
263
+ const worktrees = plan.local.filter((s) => s.worktree).length;
264
+ const details = plan.local.filter((s) => s.ignored).map((s) => ` ${s.branch}: removing its worktree also deletes ${describeIgnored(s.ignored)}`);
265
+ return [...details, `Delete ${plan.local.length} branch(es)${worktrees ? ` and remove ${worktrees} worktree(s)` : ""} on ${view.machine.label}?`].join("\n");
266
+ }
267
+ /** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
268
+ export async function confirmPrune(question) {
269
+ if (!process.stdin.isTTY)
270
+ return false;
271
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
272
+ try {
273
+ const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
274
+ return /^y(es)?$/i.test(answer.trim());
275
+ }
276
+ finally {
277
+ rl.close();
278
+ }
279
+ }
280
+ export function renderPrunePlan(view, plan) {
281
+ const L = [];
282
+ L.push(`This machine (${view.machine.label}) — ${plan.local.length} branch(es) provably merged and safe to delete:`);
283
+ if (!plan.local.length)
284
+ L.push(" (nothing)");
285
+ for (const step of plan.local) {
286
+ L.push(` ${step.branch} — ${step.why}`);
287
+ for (const c of step.commands)
288
+ L.push(` ${c}`);
289
+ if (step.ignored)
290
+ L.push(` ⚠ also deletes ${describeIgnored(step.ignored)}`);
291
+ }
292
+ if (plan.skipped.length) {
293
+ L.push("", "Merged but left alone on this machine:");
294
+ for (const s of plan.skipped)
295
+ L.push(` ${s.branch} — ${s.reason}`);
296
+ }
297
+ for (const [label, steps] of Object.entries(plan.others)) {
298
+ const record = view.records.find((r) => r.machine.label === label);
299
+ const stale = record && isUnverified(record, { staleAfterDays: view.config.stale_after_days }) ? " (unverified — the record is old)" : "";
300
+ L.push("", `On ${label}${stale} — run there, from that machine's stored record (never executed from here):`);
301
+ for (const step of steps)
302
+ for (const c of step.commands)
303
+ L.push(` ${c}`);
304
+ }
305
+ return L.join("\n");
306
+ }
307
+ //# sourceMappingURL=workspaceLedger.js.map
@@ -26,7 +26,9 @@ import { decisionId, findingId } from "../core/ids.js";
26
26
  import { buildCorrectionConstraint } from "../core/correction.js";
27
27
  import { knownRepoDeps } from "../synthesis/tripwires.js";
28
28
  import { refreshExistingGrounding } from "../integrations/providers.js";
29
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
29
+ import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
30
+ import { workspacesConfig } from "../core/config.js";
31
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
30
32
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
31
33
  import { withWriteLock } from "../serve/writelock.js";
32
34
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
@@ -1231,6 +1233,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1231
1233
  L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
1232
1234
  if (pendingReview > 0)
1233
1235
  L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
1236
+ // Workspace ledger, from stored PUBLIC records only (same jurisdiction rule as the rest
1237
+ // of this view; no git, so the hot view stays fast). Machine labels are user-chosen
1238
+ // and the default is anonymous, so the line is publishable by construction.
1239
+ const ws = workspaceSummaryLine(store.json.loadAll("workspaces"), workspacesConfig(readConfig(hunchPaths(root))));
1240
+ if (ws)
1241
+ L.push("", ws);
1234
1242
  const escalations = pendingEscalations(store.advisoryRecs("decisions"));
1235
1243
  escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
1236
1244
  // liveness checked against the full store (repair-provenance reads the
@@ -1248,6 +1256,52 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1248
1256
  }
1249
1257
  return ok(L.join("\n"));
1250
1258
  });
1259
+ // -- hunch_workspaces (the workspace ledger: worktrees + branches across machines) --
1260
+ // READ-ONLY by design (docs/workspace-ledger.md): an agent can see what is prunable but
1261
+ // can only act through the CLI, where a human confirms. This machine is read live from
1262
+ // git; other machines come from stored records, which are display-only.
1263
+ server.registerTool("hunch_workspaces", {
1264
+ title: "Worktrees and branches across machines",
1265
+ description: "The workspace ledger: which git worktrees are open on which machine, and every local branch with a deterministic verdict — merged (ancestry / squash / rebase), never pushed, upstream gone, dirty worktree — plus a recommended action per branch. Call this INSTEAD of running git branch / git worktree list / git log to answer 'what is open, what is stale, what can be deleted'. This machine is read live; other machines from memory (a machine older than the staleness window is marked unverified). Read-only: it never deletes anything. Not for design rationale (hunch_why) or code structure (hunch_structure).",
1266
+ inputSchema: {
1267
+ view: z.enum(["inventory", "branches"]).optional().describe("inventory = one row per worktree (default); branches = one row per branch with its verdict and action."),
1268
+ machine: z.string().optional().describe("Only this machine label."),
1269
+ branch: z.string().optional().describe("Only this branch name."),
1270
+ merged_only: z.boolean().optional().describe("branches view: only branches proven merged."),
1271
+ },
1272
+ }, async ({ view, machine, branch, merged_only }) => {
1273
+ const ledger = workspaceLedgerView(store, root);
1274
+ // Publish the observation this read just took, so the machines that ask about the
1275
+ // ledger are also the machines visible IN it — the one moment the data provably
1276
+ // matters, with no timer and no child process (a detached snapshot child held a
1277
+ // Windows clone directory open and broke an unrelated test's teardown). The git
1278
+ // hooks remain the normal path; this covers a host that has none yet. Best effort:
1279
+ // the read never fails because memory could not be written.
1280
+ // HUNCH_WORKSPACE_REFRESH=0 opts out entirely.
1281
+ if (process.env.HUNCH_WORKSPACE_REFRESH !== "0" && snapshotHasHome(store, root)) {
1282
+ try {
1283
+ recordWorkspaceSnapshot(store, root, { live: ledger.live });
1284
+ }
1285
+ catch { /* the ledger is a side effect of the read, never its blocker */ }
1286
+ }
1287
+ const opts = { staleAfterDays: ledger.config.stale_after_days };
1288
+ if (view === "branches") {
1289
+ let rows = branchRows(ledger.records, opts);
1290
+ if (machine)
1291
+ rows = rows.filter((r) => r.machines.includes(machine));
1292
+ if (branch)
1293
+ rows = rows.filter((r) => r.name === branch);
1294
+ if (merged_only)
1295
+ rows = rows.filter((r) => r.merged.status === "merged");
1296
+ return { content: [{ type: "text", text: renderBranchTable(ledger, rows) }], structuredContent: { machine: ledger.machine.label, branches: rows } };
1297
+ }
1298
+ let rows = worktreeRows(ledger.records, opts);
1299
+ if (machine)
1300
+ rows = rows.filter((r) => r.machine === machine);
1301
+ if (branch)
1302
+ rows = rows.filter((r) => r.branch === branch);
1303
+ return { content: [{ type: "text", text: renderWorktreeTable(ledger, rows) }], structuredContent: { machine: ledger.machine.label, worktrees: rows } };
1304
+ });
1251
1305
  // -- hunch_escalations (the inline "ask the human" surface) -----------------
1252
1306
  // Captured memory auto-trusts; this returns ONLY what the graph can't resolve
1253
1307
  // itself, framed as questions to raise in conversation: topic conflicts, plus the
@@ -2084,8 +2138,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2084
2138
  const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
2085
2139
  if (!files.length)
2086
2140
  return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
2087
- const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
2088
- const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
2141
+ const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
2142
+ const report = store.buildCheckReport(files, gate.diff, { strict: true, lastChange: (f) => lastChangeDate(f, root), diffStatus: gate });
2089
2143
  const v = verdict(report);
2090
2144
  const head = v === "block"
2091
2145
  ? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
@@ -2127,8 +2181,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2127
2181
  const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
2128
2182
  if (!files.length)
2129
2183
  return ok(`No changed files in ${scope}.`);
2130
- const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
2131
- return ok(renderImpact(store.prImpact(files, diff), scope));
2184
+ const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
2185
+ return ok(renderImpact(store.prImpact(files, gate.diff, gate), scope));
2132
2186
  }
2133
2187
  catch (e) {
2134
2188
  return err(`Failed to compute impact: ${e.message}`);
@@ -13,6 +13,8 @@
13
13
  * POST /nuryel/v1/write → writeState (under the partition's write lock)
14
14
  * POST /nuryel/v1/subscribe → subscribeState
15
15
  * POST /nuryel/v1/records → recordsState (by id, grants first)
16
+ * POST /nuryel/v1/mcp → the same verbs as nuryel_* tools over MCP streamable HTTP
17
+ * (src/serve/mcpHttp.ts), through the same dispatcher
16
18
  * Request bodies are the contract's request schemas minus `schema` and `principal`.
17
19
  */
18
20
  import { type Server } from "node:http";