@davesheffer/hunch 1.38.0 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cli/index.js +255 -9
  2. package/dist/cli/serve.js +1 -0
  3. package/dist/client/readOrCompute.d.ts +77 -0
  4. package/dist/client/readOrCompute.js +85 -0
  5. package/dist/client/state.d.ts +1 -0
  6. package/dist/client/state.js +1 -0
  7. package/dist/constitution/g2.d.ts +1 -0
  8. package/dist/constitution/service.js +8 -0
  9. package/dist/constitution/sourceMutation.js +23 -18
  10. package/dist/core/config.d.ts +16 -0
  11. package/dist/core/config.js +13 -0
  12. package/dist/core/machine.d.ts +20 -0
  13. package/dist/core/machine.js +101 -0
  14. package/dist/core/taskRecord.js +6 -3
  15. package/dist/core/taskReport.d.ts +25 -2
  16. package/dist/core/taskReport.js +92 -18
  17. package/dist/core/taskReportEvidence.d.ts +4 -1
  18. package/dist/core/taskReportEvidence.js +5 -2
  19. package/dist/core/taskReportHook.d.ts +16 -3
  20. package/dist/core/taskReportHook.js +65 -13
  21. package/dist/core/taskTouched.d.ts +4 -0
  22. package/dist/core/taskTouched.js +30 -10
  23. package/dist/core/types.d.ts +66 -1
  24. package/dist/core/types.js +3 -0
  25. package/dist/core/workspace.d.ts +234 -0
  26. package/dist/core/workspace.js +335 -0
  27. package/dist/extractors/helm.d.ts +17 -28
  28. package/dist/extractors/helm.js +12 -12
  29. package/dist/extractors/indexer.js +171 -7
  30. package/dist/extractors/k8sManifest.d.ts +59 -0
  31. package/dist/extractors/k8sManifest.js +507 -0
  32. package/dist/extractors/workspaces.d.ts +18 -0
  33. package/dist/extractors/workspaces.js +350 -0
  34. package/dist/integrations/claudemd.js +1 -0
  35. package/dist/integrations/hooks.d.ts +2 -0
  36. package/dist/integrations/hooks.js +25 -0
  37. package/dist/integrations/scaffold.js +11 -0
  38. package/dist/integrations/workspaceLedger.d.ts +73 -0
  39. package/dist/integrations/workspaceLedger.js +201 -0
  40. package/dist/mcp/server.js +54 -0
  41. package/dist/mcp/taskReportTools.d.ts +9 -0
  42. package/dist/mcp/taskReportTools.js +13 -5
  43. package/dist/serve/app.d.ts +2 -0
  44. package/dist/serve/app.js +107 -92
  45. package/dist/serve/mcpHttp.d.ts +27 -0
  46. package/dist/serve/mcpHttp.js +95 -0
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -0,0 +1,201 @@
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 } 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 { ago, branchRows, isSafeBranchName, latestPerMachine, isUnverified, planPrune, sameWorkspaceContent, withPublishMode, worktreeRows, } from "../core/workspace.js";
19
+ import { 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" : "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
+ export function prunePlanFor(view) {
127
+ return planPrune(view.live, view.records);
128
+ }
129
+ /** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
130
+ * validated by the record schema and is passed after `--`; the worktree path comes from
131
+ * `git worktree list` on this machine. A failure stops that step, never the others. */
132
+ export function applyPrune(root, steps) {
133
+ const main = mainWorktreeRoot(root);
134
+ const env = foreignRepoEnv(process.env);
135
+ const git = (args) => execFileSync("git", args, { cwd: main, env, encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
136
+ const results = [];
137
+ for (const step of steps) {
138
+ if (!isSafeBranchName(step.branch)) {
139
+ results.push({ step, outcome: "failed", detail: "refused: unsafe branch name" });
140
+ continue;
141
+ }
142
+ try {
143
+ const detail = [];
144
+ if (step.worktree?.path) {
145
+ git(["worktree", "remove", "--", step.worktree.path]);
146
+ detail.push(`removed worktree ${step.worktree.path}`);
147
+ }
148
+ else if (step.worktree) {
149
+ results.push({ step, outcome: "failed", detail: "refused: the worktree's path is not known on this machine" });
150
+ continue;
151
+ }
152
+ git(["branch", "-d", "--", step.branch]);
153
+ detail.push(`deleted branch ${step.branch} (was ${step.head.slice(0, 12)})`);
154
+ results.push({ step, outcome: "deleted", detail: detail.join("; ") });
155
+ }
156
+ catch (error) {
157
+ const stderr = error.stderr?.toString().trim().split("\n")[0] ?? error.message;
158
+ results.push({ step, outcome: "failed", detail: `git refused: ${stderr}` });
159
+ }
160
+ }
161
+ return results;
162
+ }
163
+ /** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
164
+ export async function confirmPrune(question) {
165
+ if (!process.stdin.isTTY)
166
+ return false;
167
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
168
+ try {
169
+ const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
170
+ return /^y(es)?$/i.test(answer.trim());
171
+ }
172
+ finally {
173
+ rl.close();
174
+ }
175
+ }
176
+ export function renderPrunePlan(view, plan) {
177
+ const L = [];
178
+ L.push(`This machine (${view.machine.label}) — ${plan.local.length} branch(es) provably merged and safe to delete:`);
179
+ if (!plan.local.length)
180
+ L.push(" (nothing)");
181
+ for (const step of plan.local) {
182
+ L.push(` ${step.branch} — ${step.why}`);
183
+ for (const c of step.commands)
184
+ L.push(` ${c}`);
185
+ }
186
+ if (plan.skipped.length) {
187
+ L.push("", "Merged but left alone on this machine:");
188
+ for (const s of plan.skipped)
189
+ L.push(` ${s.branch} — ${s.reason}`);
190
+ }
191
+ for (const [label, steps] of Object.entries(plan.others)) {
192
+ const record = view.records.find((r) => r.machine.label === label);
193
+ const stale = record && isUnverified(record, { staleAfterDays: view.config.stale_after_days }) ? " (unverified — the record is old)" : "";
194
+ L.push("", `On ${label}${stale} — run there, from that machine's stored record (never executed from here):`);
195
+ for (const step of steps)
196
+ for (const c of step.commands)
197
+ L.push(` ${c}`);
198
+ }
199
+ return L.join("\n");
200
+ }
201
+ //# sourceMappingURL=workspaceLedger.js.map
@@ -26,6 +26,8 @@ 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 { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
30
+ import { workspacesConfig } from "../core/config.js";
29
31
  import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, 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";
@@ -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
@@ -193,4 +193,13 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
193
193
  };
194
194
  full_report: string;
195
195
  };
196
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
197
+ * loader; a published `.js` build needs nothing) and `resolve` is that
198
+ * module's `import.meta.resolve`. The loader is resolved ONLY on the source
199
+ * path: `import.meta.resolve` throws for a package that is not installed, and
200
+ * `tsx` is a devDependency absent from every published install (#261). */
201
+ export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string): {
202
+ argv: string[];
203
+ shell: string;
204
+ };
196
205
  export declare function registerTaskReportTools(server: McpServer, getRoot: () => string, getStore: () => HunchStore): void;
@@ -51,13 +51,21 @@ export function boundedTaskReportForHost(report) {
51
51
  /** Reuse the MCP server's installation, not a potentially stale global binary.
52
52
  * Structured argv is authoritative; the shell hint uses literal quoting. */
53
53
  function verificationLauncher() {
54
- const dev = import.meta.url.endsWith(".ts");
55
- const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, import.meta.url));
54
+ return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
55
+ }
56
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
57
+ * loader; a published `.js` build needs nothing) and `resolve` is that
58
+ * module's `import.meta.resolve`. The loader is resolved ONLY on the source
59
+ * path: `import.meta.resolve` throws for a package that is not installed, and
60
+ * `tsx` is a devDependency absent from every published install (#261). */
61
+ export function verificationLauncherFor(metaUrl, resolve) {
62
+ const dev = metaUrl.endsWith(".ts");
63
+ const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
56
64
  // `--import` takes a URL. Converting the resolved loader to a path made Node on
57
65
  // Windows reject it ("Received protocol 'c:'"), so every verification launched
58
66
  // from a source checkout there failed before running and cards showed no check.
59
- const loader = import.meta.resolve("tsx");
60
- const argv = [process.execPath, ...(dev ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
67
+ const loader = dev ? resolve("tsx") : null;
68
+ const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
61
69
  const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
62
70
  return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
63
71
  }
@@ -91,7 +99,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
91
99
  task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
92
100
  }
93
101
  const launcher = verificationLauncher();
94
- return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 2 minutes; add --timeout <seconds> before -- for a long suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
102
+ return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
95
103
  }
96
104
  if (!task_id)
97
105
  throw new Error("finish requires the exact task_id");
@@ -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";
package/dist/serve/app.js CHANGED
@@ -16,6 +16,8 @@ import { STATE_PROOF_CAPABILITY } from '../core/stateProof.js';
16
16
  * POST /nuryel/v1/write → writeState (under the partition's write lock)
17
17
  * POST /nuryel/v1/subscribe → subscribeState
18
18
  * POST /nuryel/v1/records → recordsState (by id, grants first)
19
+ * POST /nuryel/v1/mcp → the same verbs as nuryel_* tools over MCP streamable HTTP
20
+ * (src/serve/mcpHttp.ts), through the same dispatcher
19
21
  * Request bodies are the contract's request schemas minus `schema` and `principal`.
20
22
  */
21
23
  import { createServer } from "node:http";
@@ -30,7 +32,9 @@ import { HUNCH_VERSION } from "../core/version.js";
30
32
  import { captureState, captureBatchState } from "../store/stateCapture.js";
31
33
  import { STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
32
34
  import { operatorHtml, operatorCss, operatorJs } from "./operator.js";
35
+ import { MCP_PATH, handleMcpRequest } from "./mcpHttp.js";
33
36
  export const BODY_LIMIT_BYTES = 1024 * 1024;
37
+ const POST_ROUTES = new Map([["/nuryel/v1/read", "read"], ["/nuryel/v1/write", "write"], ["/nuryel/v1/capture", "capture"], ["/nuryel/v1/capture-batch", "capture-batch"], ["/nuryel/v1/subscribe", "subscribe"], ["/nuryel/v1/records", "records"]]);
34
38
  export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
35
39
  export class HttpProblem extends Error {
36
40
  status;
@@ -119,8 +123,87 @@ export function createServeApp(config, opts = {}) {
119
123
  res.writeHead(status, { "content-type": `${type}; charset=utf-8`, "content-length": Buffer.byteLength(text), "cache-control": "no-store", "x-hunch-version": version });
120
124
  res.end(text);
121
125
  };
122
- const sendProblem = (res, p) => {
123
- send(res, p.status, { type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra }, "application/problem+json");
126
+ const problemBody = (p) => ({ type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra });
127
+ const sendProblem = (res, p) => { send(res, p.status, problemBody(p), "application/problem+json"); };
128
+ /** Anything thrown → problem. Shared by REST (problem+json) and MCP (tool error results). */
129
+ const problemOf = (error) => {
130
+ if (error instanceof HttpProblem)
131
+ return error;
132
+ if (error instanceof StateRefusal)
133
+ return problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {});
134
+ if (error instanceof WriteLockTimeout)
135
+ return problem(503, "write-lock-timeout", error.message, { "retry-after": 1 });
136
+ if (error && typeof error === "object" && error.name === "ZodError") {
137
+ const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
138
+ return problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues });
139
+ }
140
+ return problem(500, "internal", error.message);
141
+ };
142
+ /** One authenticated state verb. The REST routes and the MCP tools both call this, so every
143
+ * rule — grants, the write lock, flushes, refusals — is the same whichever transport carried it. */
144
+ const dispatch = async (route, principal, body, activeConfig) => {
145
+ const requestStore = (scope) => storeFor(scope, activeConfig);
146
+ const isGranted = (s) => principal.grants.some((g) => scopePath(g) === scopePath(s));
147
+ if (route === "capabilities") {
148
+ let scope = principal.grants[0];
149
+ if (body.scope !== undefined) {
150
+ const parsed = ScopeSchema.safeParse(body.scope);
151
+ if (!parsed.success)
152
+ throw problem(400, "invalid-scope", "scope must be { kind, id }");
153
+ scope = parsed.data;
154
+ }
155
+ if (!isGranted(scope))
156
+ throw problem(403, "outside-grants", `scope ${scopePath(scope)} is outside the principal's grants`);
157
+ const offered = capabilities(requestStore(scope).store);
158
+ return { status: 200, payload: { ...offered, capabilities: [...offered.capabilities, STATE_PROOF_CAPABILITY], principal: { id: principal.id, kind: principal.kind, grants: principal.grants } } };
159
+ }
160
+ // The body never names the principal: the credential did.
161
+ delete body.principal;
162
+ delete body.schema;
163
+ const scope = requireScope(principal, body);
164
+ // Only authenticated grants select stores for cross-partition source visibility.
165
+ const accessOptions = { additionalStores: principal.grants.map(grant => requestStore(grant).store) };
166
+ const { store, root } = requestStore(scope);
167
+ const flush = (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message);
168
+ if (route === "read") {
169
+ if (body.observed_page !== undefined && body.scopes !== undefined)
170
+ throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
171
+ if (body.scopes === undefined) {
172
+ const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body }, accessOptions);
173
+ return { status: 200, payload: { ...response, envelope } };
174
+ }
175
+ // Union read. The primary `scope` was gated above as always; every extra scope is
176
+ // either granted (read from ITS partition — 404 no-partition if this server lacks it)
177
+ // or named in denied_scopes. One ungranted extra never refuses the whole call.
178
+ const requested = ReadScopesSchema.safeParse(body.scopes);
179
+ if (!requested.success)
180
+ throw problem(400, "invalid-scope", "scopes must be 1..64 entries of { kind, id }");
181
+ const { scopes: _scopes, ...rest } = body;
182
+ const ungranted = requested.data.filter((s) => !isGranted(s));
183
+ const others = new Map();
184
+ for (const s of requested.data)
185
+ if (isGranted(s) && scopePath(s) !== scopePath(scope) && !others.has(scopePath(s)))
186
+ others.set(scopePath(s), s);
187
+ const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope }, accessOptions);
188
+ const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(requestStore(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }, accessOptions).response), ungranted);
189
+ return { status: 200, payload: { ...merged, envelope: primary.envelope } };
190
+ }
191
+ if (route === "write" || route === "capture" || route === "capture-batch") {
192
+ const { hunchDir } = stateHomeFor(store, scope);
193
+ const opts = { ...accessOptions, flush };
194
+ if (route === "write") {
195
+ const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts));
196
+ return { status: result.outcome === "created" ? 201 : 200, payload: result };
197
+ }
198
+ if (route === "capture") {
199
+ const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts));
200
+ return { status: result.outcome === "created" ? 201 : 200, payload: result };
201
+ }
202
+ return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts)) };
203
+ }
204
+ if (route === "subscribe")
205
+ return { status: 200, payload: subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions) };
206
+ return { status: 200, payload: recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions) };
124
207
  };
125
208
  const server = createServer(async (req, res) => {
126
209
  try {
@@ -131,7 +214,6 @@ export function createServeApp(config, opts = {}) {
131
214
  catch {
132
215
  throw problem(503, 'configuration-unavailable', 'server configuration is unavailable; authentication is refused');
133
216
  }
134
- const requestStore = (scope) => storeFor(scope, activeConfig);
135
217
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
136
218
  // Public shell only: all workspace data still uses the authenticated state routes below.
137
219
  const asset = new Map([["/operator", [operatorHtml, "text/html"]], ["/operator/", [operatorHtml, "text/html"]], ["/operator.css", [operatorCss, "text/css"]], ["/operator.js", [operatorJs, "text/javascript"]]]).get(url.pathname);
@@ -171,101 +253,34 @@ export function createServeApp(config, opts = {}) {
171
253
  throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
172
254
  const principal = { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
173
255
  if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
174
- const scope = parseScopeParam(url.searchParams.get("scope")) ?? principal.grants[0];
175
- if (!principal.grants.some((g) => scopePath(g) === scopePath(scope)))
176
- throw problem(403, "outside-grants", `scope ${scopePath(scope)} is outside the principal's grants`);
177
- const { store } = requestStore(scope);
178
- const offered = capabilities(store);
179
- return send(res, 200, { ...offered, capabilities: [...offered.capabilities, STATE_PROOF_CAPABILITY], principal: { id: principal.id, kind: principal.kind, grants: principal.grants } });
256
+ const scope = parseScopeParam(url.searchParams.get("scope"));
257
+ const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
258
+ return send(res, status, payload);
180
259
  }
181
- if (req.method !== "POST")
182
- throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
183
- const body = await readBody(req);
184
- // The body never names the principal: the token did.
185
- delete body.principal;
186
- delete body.schema;
187
- // Only authenticated grants select stores for cross-partition source visibility.
188
- const accessOptions = { additionalStores: principal.grants.map(grant => requestStore(grant).store) };
189
- if (url.pathname === "/nuryel/v1/read") {
190
- const scope = requireScope(principal, body);
191
- if (body.observed_page !== undefined && body.scopes !== undefined)
192
- throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
193
- const { store } = requestStore(scope);
194
- if (body.scopes === undefined) {
195
- const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body }, accessOptions);
196
- return send(res, 200, { ...response, envelope });
260
+ if (url.pathname === MCP_PATH) {
261
+ // Stateless MCP: no server-initiated stream to open (GET) and no session to end (DELETE).
262
+ if (req.method !== "POST") {
263
+ res.setHeader("allow", "POST");
264
+ throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}; MCP here is stateless POST`);
197
265
  }
198
- // Union read. The primary `scope` was gated above as always; every extra scope is
199
- // either granted (read from ITS partition — 404 no-partition if this server lacks it)
200
- // or named in denied_scopes. One ungranted extra never refuses the whole call.
201
- const requested = ReadScopesSchema.safeParse(body.scopes);
202
- if (!requested.success)
203
- throw problem(400, "invalid-scope", "scopes must be 1..64 entries of { kind, id }");
204
- const { scopes: _scopes, ...rest } = body;
205
- const isGranted = (s) => principal.grants.some((g) => scopePath(g) === scopePath(s));
206
- const ungranted = requested.data.filter((s) => !isGranted(s));
207
- const others = new Map();
208
- for (const s of requested.data)
209
- if (isGranted(s) && scopePath(s) !== scopePath(scope) && !others.has(scopePath(s)))
210
- others.set(scopePath(s), s);
211
- const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope }, accessOptions);
212
- const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(requestStore(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }, accessOptions).response), ungranted);
213
- return send(res, 200, { ...merged, envelope: primary.envelope });
214
- }
215
- if (url.pathname === "/nuryel/v1/write") {
216
- const scope = requireScope(principal, body);
217
- const { store, root } = requestStore(scope);
218
- const { hunchDir } = stateHomeFor(store, scope);
219
- const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, {
220
- ...accessOptions,
221
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
222
- }));
223
- return send(res, result.outcome === "created" ? 201 : 200, result);
224
- }
225
- if (url.pathname === "/nuryel/v1/capture") {
226
- const scope = requireScope(principal, body);
227
- const { store, root } = requestStore(scope);
228
- const { hunchDir } = stateHomeFor(store, scope);
229
- const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, {
230
- ...accessOptions,
231
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
232
- }));
233
- return send(res, result.outcome === "created" ? 201 : 200, result);
234
- }
235
- if (url.pathname === "/nuryel/v1/capture-batch") {
236
- const scope = requireScope(principal, body);
237
- const { store, root } = requestStore(scope);
238
- const { hunchDir } = stateHomeFor(store, scope);
239
- const result = await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, {
240
- ...accessOptions,
241
- flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
242
- }));
243
- return send(res, 200, result);
266
+ const message = await readBody(req);
267
+ return await handleMcpRequest(req, res, message, (route, body) => dispatch(route, principal, body, activeConfig), (error) => problemBody(problemOf(error)), version);
244
268
  }
245
- if (url.pathname === "/nuryel/v1/subscribe") {
246
- const scope = requireScope(principal, body);
247
- const { store } = requestStore(scope);
248
- return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions));
249
- }
250
- if (url.pathname === "/nuryel/v1/records") {
251
- const scope = requireScope(principal, body);
252
- const { store } = requestStore(scope);
253
- return send(res, 200, recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions));
254
- }
255
- throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
269
+ if (req.method !== "POST")
270
+ throw problem(405, "method-not-allowed", `${req.method} is not allowed on ${url.pathname}`);
271
+ const route = POST_ROUTES.get(url.pathname);
272
+ if (!route)
273
+ throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
274
+ const { status, payload } = await dispatch(route, principal, await readBody(req), activeConfig);
275
+ return send(res, status, payload);
256
276
  }
257
277
  catch (error) {
258
- if (error instanceof HttpProblem)
259
- return sendProblem(res, error);
260
- if (error instanceof StateRefusal)
261
- return sendProblem(res, problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {}));
262
- if (error instanceof WriteLockTimeout)
263
- return sendProblem(res, problem(503, "write-lock-timeout", error.message, { "retry-after": 1 }));
264
- if (error && typeof error === "object" && error.name === "ZodError") {
265
- const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
266
- return sendProblem(res, problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues }));
278
+ // An MCP response may already be on the wire; a second status line would corrupt it.
279
+ if (res.headersSent) {
280
+ res.end();
281
+ return;
267
282
  }
268
- return sendProblem(res, problem(500, "internal", error.message));
283
+ return sendProblem(res, problemOf(error));
269
284
  }
270
285
  });
271
286
  const closeStores = () => { for (const store of stores.values())
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MCP over streamable HTTP for `hunch serve` — the nuryel.state/1 tools behind the same
3
+ * authenticated server, so an HTTP MCP client (an agent gateway, a remote orchestrator) can
4
+ * add the state layer as a tool target without a stdio process or a wrapper.
5
+ *
6
+ * A binding of the served routes, never a second implementation: every tool call goes through
7
+ * the dispatcher `createServeApp` uses for REST, so grants, the write lock, flushes and refusals
8
+ * are identical. The credential already resolved the principal before this runs; no tool
9
+ * accepts one. Stateless (a fresh server and transport per request, no session id) with JSON
10
+ * responses, because every verb is a single request/response.
11
+ */
12
+ import type { IncomingMessage, ServerResponse } from "node:http";
13
+ export declare const MCP_PATH = "/nuryel/v1/mcp";
14
+ export type StateRoute = "capabilities" | "read" | "write" | "capture" | "capture-batch" | "subscribe" | "records";
15
+ export type StateDispatch = (route: StateRoute, body: Record<string, unknown>) => Promise<{
16
+ status: number;
17
+ payload: unknown;
18
+ }>;
19
+ export interface ProblemShape {
20
+ type: string;
21
+ title: string;
22
+ status: number;
23
+ detail: string;
24
+ [extra: string]: unknown;
25
+ }
26
+ /** Serve one authenticated MCP POST. The caller has already authenticated and parsed the body. */
27
+ export declare function handleMcpRequest(req: IncomingMessage, res: ServerResponse, body: unknown, dispatch: StateDispatch, problemOf: (error: unknown) => ProblemShape, version: string): Promise<void>;