@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.
- package/dist/cli/index.js +255 -9
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskRecord.js +6 -3
- package/dist/core/taskReport.d.ts +25 -2
- package/dist/core/taskReport.js +92 -18
- package/dist/core/taskReportEvidence.d.ts +4 -1
- package/dist/core/taskReportEvidence.js +5 -2
- package/dist/core/taskReportHook.d.ts +16 -3
- package/dist/core/taskReportHook.js +65 -13
- package/dist/core/taskTouched.d.ts +4 -0
- package/dist/core/taskTouched.js +30 -10
- package/dist/core/types.d.ts +66 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +234 -0
- package/dist/core/workspace.js +335 -0
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +18 -0
- package/dist/extractors/workspaces.js +350 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/hooks.d.ts +2 -0
- package/dist/integrations/hooks.js +25 -0
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +73 -0
- package/dist/integrations/workspaceLedger.js +201 -0
- package/dist/mcp/server.js +54 -0
- package/dist/mcp/taskReportTools.d.ts +9 -0
- package/dist/mcp/taskReportTools.js +13 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/package.json +1 -1
- 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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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 =
|
|
60
|
-
const argv = [process.execPath, ...(
|
|
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
|
|
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");
|
package/dist/serve/app.d.ts
CHANGED
|
@@ -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
|
|
123
|
-
|
|
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"))
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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 (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return
|
|
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,
|
|
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>;
|