@dyzsasd/dev-loop 0.22.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/shim.js ADDED
@@ -0,0 +1,146 @@
1
+ // dev-loop hub — P2 (DL-55): a THIN stdio MCP shim that proxies the 5 core ticket tools to the loopback
2
+ // daemon's DL-43 agent op-API (POST /api/op/<op>) instead of opening hub.db directly. It is an OPT-IN
3
+ // alternative entry to the default `node src/server.ts` (direct-db stdio), documented in config/mcp.example.json.
4
+ //
5
+ // WHY: the Vision's "daemon owns coordination — agents act through one running service". server.ts stays the
6
+ // canonical direct-db transport (DL-43 AC: 100% untouched); this shim is the additive client that routes the
7
+ // core ticket tools through the one running daemon. Identity rides env→header (design Decision #2/#5): the
8
+ // shim reads its OWN DEVLOOP_ACTOR and forwards it as the X-Devloop-Actor header on the loopback HTTP call IT
9
+ // makes — so the CLI never makes an authed HTTP call and the headless `claude -p` Authorization-header drop
10
+ // (HUB-ARCHITECTURE §6) never touches identity.
11
+ //
12
+ // SCOPE: the 5 core ticket tools (list_issues/get_issue/save_issue/save_comment/list_comments) + a LOCAL
13
+ // whoami (DL-55), PLUS (DL-62) the doc/event family — list_events + doc.list/get/history/diff/save/publish,
14
+ // PLUS (DL-64) the discussion-board family — topic.list/get/open + post.add + topic.synthesize/close,
15
+ // PLUS (DL-67) the IM channel family — channel.register/send/poll/ack/status, PLUS (DL-68) P7 mirror +
16
+ // label/project — mirror.push/mirror.status + list_issue_labels/create_issue_label/get_project. That is the
17
+ // FINAL slice: the shim now proxies ALL 29 server.ts tools — a 100% server.ts drop-in.
18
+ // The shim holds NO SoR / NO ticket/doc/topic/channel/mirror logic (Decision #3): a pure thin client over the
19
+ // op-API (which mirrors server.ts 1:1 via agentops.ts + the shared docstore/topicstore/channelstore/mirrorstore/labelstore).
20
+ //
21
+ // DL-85: the tool { name, description, inputSchema } registry is now SHARED from tooldefs.ts (registerTools),
22
+ // so the names/schemas can no longer drift between this shim and server.ts by hand — the old "PARITY TRIPWIRE:
23
+ // keep the copy byte-identical" convention is retired (the single source IS the guarantee). Each entrypoint
24
+ // supplies only its handler factory (server.ts → dispatch; this shim → proxy below).
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
27
+ import { request as httpRequest } from "node:http";
28
+ import { readFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { dirname, join } from "node:path";
31
+ import { resolveIdentity } from "./resolve-project.js";
32
+ import { ok, err, registerTools } from "./tooldefs.js"; // DL-85: the ONE {name,description,inputSchema} registry + the shared ok()/err() + the McpResult type
33
+ // ─── identity + project ──────────────────────────────────────────────────────
34
+ // DL-85: the DEVLOOP_ACTOR + DEVLOOP_PROJECT/cwd resolution lives ONCE in resolve-project.ts (was re-derived
35
+ // here AND in server.ts) — same rule, so the shim names the same per-project daemon runfile the direct-db
36
+ // server would attribute writes to. (The shim ignores projectFromCwd — only server.ts's not-seeded error uses it.)
37
+ const { actor: ACTOR, projectKey: PROJECT_KEY } = resolveIdentity();
38
+ // ─── DL-41 lifecycle runfile path (REPLICATES daemon.ts lcDbPath/lcRunDir/lcRunfile, :959-961) ──────────────
39
+ // The shim is a standalone thin client and must NOT import the 92KB daemon (DL-55 affected-area: NOT daemon.ts),
40
+ // so it re-derives the stable runfile path convention here — this comment is the drift tripwire against
41
+ // daemon.ts. runDir = DEVLOOP_RUN_DIR ?? dirname(DEVLOOP_HUB_DB ?? ~/.dev-loop/hub.db); file = daemon-<key>.json.
42
+ const DB_PATH = process.env.DEVLOOP_HUB_DB ?? join(homedir(), ".dev-loop", "hub.db");
43
+ const RUN_DIR = process.env.DEVLOOP_RUN_DIR ?? dirname(DB_PATH);
44
+ const RUNFILE = join(RUN_DIR, `daemon-${PROJECT_KEY}.json`);
45
+ // Resolve the daemon's loopback port WITHOUT hardcoding 8787 (folded critique #89): an explicit
46
+ // DEVLOOP_HUB_PORT override wins (a foreground `npm run daemon` writes NO runfile; tests inject the in-process
47
+ // port), else the DL-41 lifecycle runfile's recorded port. null ⇒ neither is available (→ a clear MCP error).
48
+ // Re-read per call ON PURPOSE (not memoized): the DL-41 daemon can restart on a new port mid-session, and the
49
+ // shim must follow the live runfile without itself restarting — a cached port would go stale → false ECONNREFUSED.
50
+ function resolvePort() {
51
+ const envPort = process.env.DEVLOOP_HUB_PORT?.trim();
52
+ if (envPort) {
53
+ const n = Number(envPort);
54
+ if (Number.isInteger(n) && n > 0 && n < 65536)
55
+ return n;
56
+ }
57
+ try {
58
+ const info = JSON.parse(readFileSync(RUNFILE, "utf8"));
59
+ if (typeof info.port === "number" && Number.isInteger(info.port) && info.port > 0)
60
+ return info.port;
61
+ }
62
+ catch { /* no/garbled runfile → the daemon was not lifecycle-started here */ }
63
+ return null;
64
+ }
65
+ // ─── MCP result helpers + the McpResult type are imported from tooldefs.ts (DL-85 — one definition; a 2xx body ──
66
+ // produces an IDENTICAL tool result to server.ts's stdio path because both use the SAME ok()/err()). ───────────
67
+ // The two "can't reach a working op-API" failure modes get a CLEAR, actionable MCP error (DL-55 AC), never a
68
+ // silent hang or an opaque 500. Loopback only (§16) — the shim only ever talks to 127.0.0.1.
69
+ const daemonDown = (detail) => err(`dev-loop daemon for project '${PROJECT_KEY}' is not reachable on 127.0.0.1${detail}. Start it ` +
70
+ `(\`cd hub && DEVLOOP_PROJECT=${PROJECT_KEY} npm run daemon\`, or the DL-42 SessionStart hook runs ` +
71
+ `\`dev-loop-hub daemon up\`), or set DEVLOOP_HUB_PORT. This daemon-transport shim proxies to the loopback ` +
72
+ `op-API and needs the daemon running; the default \`node hub/src/server.ts\` entry needs no daemon.`);
73
+ const opApiDormant = () => err(`dev-loop daemon is running but its agent op-API is dormant for project '${PROJECT_KEY}'. Opt in by setting ` +
74
+ `settings_json.hub.transport="daemon" (DL-43), or use the default direct-db entry \`node hub/src/server.ts\`.`);
75
+ // ─── proxy one core op → POST http://127.0.0.1:<port>/api/op/<op> (X-Devloop-Actor: ACTOR), as the MCP shape ──
76
+ // daemon {status,body}: a 2xx → ok(body) (identical to server.ts's ok()); a DORMANT-mount 404 (body
77
+ // {error:"not found: …"}) → the dormant hint; any other non-2xx → err(body.error) (a genuine op result —
78
+ // 400/403/404-not-found/500 forwarded verbatim, parity with the stdio path); a dead/absent daemon (no
79
+ // runfile / ECONNREFUSED / timeout) → the daemon-down hint.
80
+ function proxy(op, args) {
81
+ const port = resolvePort();
82
+ if (port === null) {
83
+ return Promise.resolve(daemonDown(` (no lifecycle runfile at ${RUNFILE}, and DEVLOOP_HUB_PORT is unset)`));
84
+ }
85
+ const body = JSON.stringify(args ?? {});
86
+ return new Promise((resolve) => {
87
+ let settled = false;
88
+ const finish = (r) => { if (!settled) {
89
+ settled = true;
90
+ resolve(r);
91
+ } };
92
+ const req = httpRequest({
93
+ hostname: "127.0.0.1", port, method: "POST", path: `/api/op/${op}`,
94
+ headers: {
95
+ "content-type": "application/json",
96
+ "content-length": Buffer.byteLength(body),
97
+ "x-devloop-actor": ACTOR, // identity env→header (Decision #2/#5) — the only attribution the daemon trusts
98
+ },
99
+ }, (res) => {
100
+ let d = "";
101
+ res.setEncoding("utf8");
102
+ res.on("data", (c) => (d += c));
103
+ res.on("end", () => {
104
+ const status = res.statusCode ?? 0;
105
+ let parsed = null;
106
+ try {
107
+ parsed = d ? JSON.parse(d) : null;
108
+ }
109
+ catch { /* non-JSON body (a bare daemon error) */ }
110
+ if (status >= 200 && status < 300) {
111
+ finish(ok(parsed));
112
+ return;
113
+ }
114
+ const emsg = typeof parsed?.error === "string" ? parsed.error : "";
115
+ // A dormant mount answers EVERY /api/op/* with 404 {error:"not found: <path>"} (daemon.ts:759),
116
+ // distinct from a genuine op-level 404 ({error:"no such ticket …"}) which is a real result to forward.
117
+ if (status === 404 && (parsed === null || /^not found:/.test(emsg))) {
118
+ finish(opApiDormant());
119
+ return;
120
+ }
121
+ finish(err(emsg || `op '${op}' failed: HTTP ${status}`));
122
+ });
123
+ });
124
+ req.on("error", (e) => {
125
+ const why = e.code === "ECONNREFUSED" ? " (connection refused — a stale runfile / a daemon that died?)"
126
+ : e.message === "timeout" ? " (no response within 30s — the daemon hung?)"
127
+ : ` (${e.code ?? e.message})`;
128
+ finish(daemonDown(why));
129
+ });
130
+ req.setTimeout(30000, () => { req.destroy(new Error("timeout")); }); // never a silent hang
131
+ req.end(body);
132
+ });
133
+ }
134
+ // ─── the MCP server — the SAME 29 tool names/schemas as server.ts (a 100% drop-in transport, DL-85) ──────────
135
+ const server = new McpServer({ name: "dev-loop-hub", version: "0.1.0" });
136
+ // tooldefs.ts owns every tool's { name, description, inputSchema } (shared with server.ts); the shim supplies
137
+ // ONLY the handler. whoami is answered LOCALLY from env + cwd-resolution (so it works even when the daemon is
138
+ // down) and reports the daemon transport + resolved URL; every other tool proxies to the loopback op-API.
139
+ registerTools(server, (name) => {
140
+ if (name === "whoami") {
141
+ return () => { const port = resolvePort(); return ok({ actor: ACTOR, project: PROJECT_KEY, transport: "daemon", url: port ? `http://127.0.0.1:${port}` : null }); };
142
+ }
143
+ return (a) => proxy(name, a);
144
+ });
145
+ await server.connect(new StdioServerTransport());
146
+ console.error(`[shim] dev-loop-hub daemon-transport shim ready: actor=${ACTOR} project=${PROJECT_KEY} runfile=${RUNFILE}`);
@@ -0,0 +1,147 @@
1
+ // dev-loop hub — the single home for ticket/comment writes (DL-29 daemon routes + DL-35 server.ts convergence).
2
+ // EVERY ticket INSERT/UPDATE and comment INSERT in the hub lives here (grep: no other src file writes the
3
+ // tickets/comments tables). Two callers share these:
4
+ // • the MCP server (server.ts save_issue/save_comment) — the agent write path; it computes its own merge
5
+ // (REPLACE labels, APPEND-only relatedTo, DL-24 assignTo) inside its own BEGIN IMMEDIATE txn, then calls
6
+ // the raw mechanics below to do the write + log the event.
7
+ // • the daemon's opt-in human web-write routes (create/comment/move/assign) — the board write path; the
8
+ // narrow primitives (createTicket/addComment/moveTicket/assignTicket) wrap the same mechanics.
9
+ // The mechanics take a WRITABLE connection (NEVER the daemon's query_only read connection) and the caller's
10
+ // resolved actor. Attribution + the event-log discipline (logEvent) + the unknown-assignee guard
11
+ // (actorExists) + the state set (STATES) are uniform across both paths by construction.
12
+ import { randomUUID } from "node:crypto";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { nowIso, nextTicketId, logEvent, actorExists, STATES } from "./db.js";
15
+ const exists = (db, projectId, id) => !!db.prepare("SELECT 1 FROM tickets WHERE id=? AND project_id=?").get(id, projectId);
16
+ const rowFor = (db, projectId, id) => db.prepare("SELECT title,description,type,state,assignee,priority,labels,duplicate_of,related_to FROM tickets WHERE id=? AND project_id=?")
17
+ .get(id, projectId);
18
+ // Read settings_json.workflow.release fresh (a live, operator-set, opt-in config). Malformed ⇒ {} (fail-open).
19
+ export function loadRelease(db, projectId) {
20
+ try {
21
+ const row = db.prepare("SELECT settings_json FROM projects WHERE id=?").get(projectId);
22
+ const r = (row?.settings_json ? JSON.parse(row.settings_json) : {})?.workflow?.release;
23
+ return r && typeof r === "object" ? r : {};
24
+ }
25
+ catch {
26
+ return {};
27
+ } // never brick a write on malformed config
28
+ }
29
+ // DL-38 staging-deploy gate (design §7). Enforced in updateTicketRow below — the shared write path — so it
30
+ // covers BOTH the MCP save_issue transition AND the daemon board-move automatically. The In Progress → In
31
+ // Review transition is REJECTED when requireDeployBeforeReview is on AND the ticket's repo deploys (its
32
+ // repo:<name> ∈ deployRepos, or single-repo hasDeploy) AND it lacks env:dev. A non-deploying repo bypasses
33
+ // (carve-out — else docs-only/no-deploy work could never earn env:dev and would deadlock). No ACTOR context.
34
+ function stagingDeployRejection(db, projectId, fromState, next) {
35
+ if (!(fromState === "In Progress" && next.state === "In Review"))
36
+ return null; // only this edge is gated
37
+ const rel = loadRelease(db, projectId);
38
+ if (rel.requireDeployBeforeReview !== true)
39
+ return null; // default off ⇒ unchanged behavior
40
+ const labels = JSON.parse(next.labels);
41
+ const repoLabel = labels.find((l) => l.startsWith("repo:"));
42
+ const repoDeploys = repoLabel
43
+ ? Array.isArray(rel.deployRepos) && rel.deployRepos.includes(repoLabel.slice(5))
44
+ : rel.hasDeploy === true; // single-repo (no repo:<name> label)
45
+ if (!repoDeploys)
46
+ return null; // carve-out: a non-deploying repo never needs env:dev (no deadlock)
47
+ if (labels.includes("env:dev"))
48
+ return null; // gate satisfied — staged
49
+ return `staging-deploy gate: In Progress → In Review requires env:dev (this repo deploys and requireDeployBeforeReview is on)`;
50
+ }
51
+ // DL-77 verify gate (the Ralph-Wiggum guard). Enforced in updateTicketRow below — the SAME single-choke-point
52
+ // placement as stagingDeployRejection — so it covers BOTH the MCP save_issue transition AND the daemon board-move
53
+ // automatically. The maker-self-accept edge In Progress → Done is REJECTED: Done is the OWNER's verdict and must
54
+ // be reached via In Review (owner verification). Every OTHER path to Done stays legal — In Review → Done (the
55
+ // verified close), Todo → Done / Backlog → Done (the §9a intake parent-close, which MUST stay legal or it breaks
56
+ // PM's grooming), and In Progress → Canceled/Duplicate (terminal, NOT Done). Unlike the DL-38 gate this is
57
+ // UNCONDITIONAL (no opt-in config): "Done means verified" is a §3 loop invariant, not an operator preference.
58
+ function verifyGateRejection(fromState, next) {
59
+ if (fromState === "In Progress" && next.state === "Done")
60
+ return `verify gate: In Progress → Done is not allowed — Done must be reached via In Review (owner verification); move to In Review first`;
61
+ return null; // every other transition is the caller's concern
62
+ }
63
+ // ─── the raw mechanics: the ONLY tickets/comments writers in the hub ──────────
64
+ // THE ticket INSERT. Allocates the id, writes all 14 columns, logs issue.create. `createEventData` is passed
65
+ // in so each caller logs exactly what it logged before this convergence (the MCP path logs the RAW {title,type}
66
+ // — type possibly undefined when omitted — which differs from the resolved type written to the row).
67
+ export function insertTicket(db, projectId, actor, f, createEventData) {
68
+ const id = nextTicketId(db, projectId);
69
+ const t = nowIso();
70
+ db.prepare(`INSERT INTO tickets(id,project_id,title,description,type,state,assignee,priority,labels,duplicate_of,related_to,created_by,created_at,updated_at)
71
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
72
+ .run(id, projectId, f.title, f.description, f.type, f.state, f.assignee, f.priority, JSON.stringify(f.labels), f.duplicateOf, JSON.stringify(f.relatedTo), actor, t, t);
73
+ logEvent(db, { project_id: projectId, ticket_id: id, actor, kind: "issue.create", data: createEventData });
74
+ return id;
75
+ }
76
+ // THE ticket UPDATE — the post-DL-35 converged "applyTicketWrite" path. Enforces the transition gates FIRST
77
+ // (the DL-38 staging-deploy gate + the DL-77 verify gate — so both the MCP save_issue transition and the daemon
78
+ // board-move are covered automatically), then writes the caller-merged `next` row and logs issue.transition (with the resolved assignee) on a real
79
+ // state change else issue.update. TXN-AGNOSTIC: it never BEGINs/COMMITs — the MCP's atomic read-merge-write
80
+ // txn (and the daemon's single-op writes) stay the caller's concern; a gate rejection writes NOTHING.
81
+ export function updateTicketRow(db, projectId, actor, id, fromState, next) {
82
+ const gate = stagingDeployRejection(db, projectId, fromState, next) ?? verifyGateRejection(fromState, next);
83
+ if (gate)
84
+ return { ok: false, status: 400, error: gate };
85
+ const t = nowIso();
86
+ db.prepare(`UPDATE tickets SET title=?,description=?,type=?,state=?,assignee=?,priority=?,labels=?,duplicate_of=?,related_to=?,updated_at=? WHERE id=? AND project_id=?`)
87
+ .run(next.title, next.description, next.type, next.state, next.assignee, next.priority, next.labels, next.duplicate_of, next.related_to, t, id, projectId);
88
+ logEvent(db, next.state !== fromState
89
+ ? { project_id: projectId, ticket_id: id, actor, kind: "issue.transition", data: { from: fromState, to: next.state, assignee: next.assignee } }
90
+ : { project_id: projectId, ticket_id: id, actor, kind: "issue.update", data: {} });
91
+ return { ok: true, id };
92
+ }
93
+ // THE comment INSERT. Mechanic only — existence/body policy is the caller's. Returns the new id + timestamp
94
+ // (the MCP echoes them back to the caller). Body is operator/agent DATA — stored verbatim, esc()'d at render
95
+ // (never a command-verb parser, never a channel scrub).
96
+ export function insertComment(db, projectId, actor, ticketId, body) {
97
+ const id = randomUUID();
98
+ const t = nowIso();
99
+ db.prepare("INSERT INTO comments(id,ticket_id,author,body,created_at) VALUES (?,?,?,?,?)").run(id, ticketId, actor, body, t);
100
+ logEvent(db, { project_id: projectId, ticket_id: ticketId, actor, kind: "comment.add", data: {} });
101
+ return { id, createdAt: t };
102
+ }
103
+ // ─── daemon human-write primitives: narrow wrappers over the mechanics above ──
104
+ // Create a Todo ticket (no labels/assignee by default — a human can move/assign/label it after).
105
+ export function createTicket(db, projectId, actor, a) {
106
+ const title = (a.title ?? "").trim();
107
+ if (!title)
108
+ return { ok: false, status: 400, error: "title required" };
109
+ const type = a.type ?? "Feature";
110
+ const id = insertTicket(db, projectId, actor, { title, description: a.description ?? "", type, state: "Todo", assignee: null, priority: 0, labels: [], duplicateOf: null, relatedTo: [] }, { title, type });
111
+ return { ok: true, id };
112
+ }
113
+ // Add a comment (author = actor). A web form must not post an empty body → 400 (the MCP agent path does not
114
+ // enforce this; the guard is the daemon's policy, the INSERT mechanic is shared).
115
+ export function addComment(db, projectId, actor, id, body) {
116
+ if (!exists(db, projectId, id))
117
+ return { ok: false, status: 404, error: `no such ticket ${id}` };
118
+ if (!(body ?? "").trim())
119
+ return { ok: false, status: 400, error: "comment body required" };
120
+ insertComment(db, projectId, actor, id, body);
121
+ return { ok: true, id };
122
+ }
123
+ // Move a ticket to a new state. Honors the STATES set (the tickets.state CHECK's mirror) — an unknown state is
124
+ // rejected, never written. A deliberate single-field intent: it reads the row and rewrites it with only `state`
125
+ // changed (so the shared UPDATE mechanic does the write). Does NOT apply the DL-24 assignTo directive — a human
126
+ // board move is an explicit state set (that directive is the agent save_issue path's).
127
+ export function moveTicket(db, projectId, actor, id, toState) {
128
+ if (!STATES.includes(toState))
129
+ return { ok: false, status: 400, error: `invalid state '${toState}'; one of ${STATES.join(", ")}` };
130
+ const cur = rowFor(db, projectId, id);
131
+ if (!cur)
132
+ return { ok: false, status: 404, error: `no such ticket ${id}` };
133
+ return updateTicketRow(db, projectId, actor, id, cur.state, { ...cur, state: toState }); // propagates the DL-38 gate
134
+ }
135
+ // Assign (or unassign) a ticket. Empty/whitespace → unassigned (null); a non-empty handle must be a known actor
136
+ // (mirrors the MCP unknown-assignee guard) — no "me" alias here (a web form names a handle). Reads the row and
137
+ // rewrites it with only `assignee` changed (state unchanged ⇒ the shared mechanic logs issue.update).
138
+ export function assignTicket(db, projectId, actor, id, assignee) {
139
+ const cur = rowFor(db, projectId, id);
140
+ if (!cur)
141
+ return { ok: false, status: 404, error: `no such ticket ${id}` };
142
+ const raw = (assignee ?? "").trim();
143
+ const resolved = raw === "" ? null : raw;
144
+ if (resolved !== null && !actorExists(db, resolved))
145
+ return { ok: false, status: 400, error: `unknown assignee '${resolved}'` };
146
+ return updateTicketRow(db, projectId, actor, id, cur.state, { ...cur, assignee: resolved }); // assignee-only ⇒ no transition ⇒ gate never fires
147
+ }
@@ -0,0 +1,147 @@
1
+ // dev-loop hub — the SINGLE source of the MCP tool surface (DL-85). Before this, server.ts (direct-db) and
2
+ // shim.ts (daemon-transport) each copy-pasted all 29 `registerTool(name, {description, inputSchema}, handler)`
3
+ // triples byte-identically; the only per-file difference is the handler (dispatch vs proxy). Here the 29
4
+ // {name, description, inputSchema} triples live ONCE; each entrypoint calls registerTools() and supplies only
5
+ // its per-name handler factory. The ok()/err() MCP-result helpers are shared from here too.
6
+ //
7
+ // THIN-CLIENT BOUNDARY: this is a LEAF — it imports only `zod` + the DOC_KINDS enum (docstore.ts, already in
8
+ // the shim's graph). It must NEVER import agentops.ts / the SoR (that would drag the whole system of record into
9
+ // the thin shim). The op-name list is OWNED here (TOOL_NAMES); agentops.ts DERIVES AGENT_OPS from it (reuse,
10
+ // not a 2nd copy) — so the name list lives in exactly one place AND the shim stays thin.
11
+ import { z } from "zod";
12
+ import { DOC_KINDS } from "./docstore.js"; // the doc-kind enum for doc.save's zod schema (a shared schema constant, not SoR/doc logic)
13
+ export const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
14
+ export const err = (message) => ({ isError: true, content: [{ type: "text", text: JSON.stringify({ error: message }) }] });
15
+ // ─── the canonical tool-name list — whoami (answered locally per transport) + the 28 op-backed tools ────────
16
+ // agentops.ts derives AGENT_OPS = TOOL_NAMES minus "whoami" (the only tool that is NOT an op-API op), so this
17
+ // is the ONE source of the tool/op names. Order matches the historical AGENT_OPS order (registration order is
18
+ // irrelevant to MCP — tools resolve by name — but keeping it stable keeps diffs/feeds readable).
19
+ export const TOOL_NAMES = [
20
+ "whoami",
21
+ "list_issues", "get_issue", "save_issue", "save_comment", "list_comments",
22
+ "list_events", "doc.list", "doc.get", "doc.history", "doc.diff", "doc.save", "doc.publish",
23
+ "topic.list", "topic.get", "topic.open", "post.add", "topic.synthesize", "topic.close",
24
+ "channel.register", "channel.send", "channel.poll", "channel.ack", "channel.status",
25
+ "mirror.push", "mirror.status", "list_issue_labels", "create_issue_label", "get_project",
26
+ ];
27
+ // ─── the {description, inputSchema} for every tool — the ONE definition (was copy-pasted in BOTH entrypoints) ──
28
+ const DEFS = {
29
+ whoami: { description: "The identity this session is acting as, and the active project.", inputSchema: {} },
30
+ list_issues: {
31
+ description: "List tickets in the active project. Filter by state, assignee, type, label(s), or a title query.",
32
+ inputSchema: {
33
+ state: z.string().optional(), assignee: z.string().optional(), type: z.string().optional(),
34
+ label: z.string().optional(), labels: z.array(z.string()).optional(), query: z.string().optional(),
35
+ limit: z.number().int().positive().max(250).optional(),
36
+ },
37
+ },
38
+ get_issue: { description: "Get one ticket with its comments.", inputSchema: { id: z.string() } },
39
+ save_issue: {
40
+ description: "Create (omit id) or update (with id) a ticket. labels REPLACE the full set (re-pass all). assignee 'me' = you, null clears.",
41
+ inputSchema: {
42
+ id: z.string().optional(), title: z.string().optional(), description: z.string().optional(),
43
+ type: z.string().optional(), state: z.string().optional(),
44
+ assignee: z.string().nullable().optional(), priority: z.number().int().min(0).max(4).optional(),
45
+ labels: z.array(z.string()).optional(),
46
+ duplicateOf: z.string().nullable().optional(), // §8 dedupe scalar (pair with state Duplicate); undefined=keep
47
+ relatedTo: z.array(z.string()).optional(), // §4 splits / §15 coverage; APPEND-ONLY union (§18 line 965)
48
+ },
49
+ },
50
+ save_comment: { description: "Add a comment to a ticket (authored as you).", inputSchema: { issueId: z.string(), body: z.string() } },
51
+ list_comments: { description: "List a ticket's comments (chronological; the tail is the latest).", inputSchema: { issueId: z.string() } },
52
+ list_issue_labels: { description: "List the project's labels.", inputSchema: {} },
53
+ create_issue_label: { description: "Create a label if missing (idempotent).", inputSchema: { name: z.string(), kind: z.string().optional() } },
54
+ get_project: { description: "The active project.", inputSchema: {} },
55
+ list_events: { description: "Recent attribution/audit events (who did what).", inputSchema: { limit: z.number().int().positive().max(500).optional() } },
56
+ "doc.list": { description: "List this project's documents (no bodies).", inputSchema: { kind: z.string().optional() } },
57
+ "doc.get": {
58
+ description: "Get a document by slug or kind. Omit version → the published (current) version; if never published, the latest DRAFT with unpublished:true. version=N → that historical version.",
59
+ inputSchema: { slug: z.string().optional(), kind: z.string().optional(), version: z.number().int().positive().optional() },
60
+ },
61
+ "doc.save": {
62
+ description: "Create (baseVersion 0) or append a new DRAFT version. Optimistic CAS: baseVersion MUST equal the doc's latest version, else CONFLICT (never last-write-wins). NEVER publishes — only the operator can (doc.publish).",
63
+ inputSchema: { slug: z.string(), kind: z.enum(DOC_KINDS), title: z.string().optional(), body: z.string(), baseVersion: z.number().int().min(0), summary: z.string().optional() },
64
+ },
65
+ "doc.history": { description: "A document's version ledger (no bodies; newest first).", inputSchema: { slug: z.string().optional(), kind: z.string().optional() } },
66
+ "doc.diff": { description: "Line diff between two versions of a document.", inputSchema: { slug: z.string().optional(), kind: z.string().optional(), from: z.number().int().positive(), to: z.number().int().positive() } },
67
+ "doc.publish": {
68
+ description: "OPERATOR-ONLY: publish a draft version → current (the live doc). Cooperative role-gate (DEVLOOP_ACTOR=operator), not anti-spoof — see §18/HUB-ARCHITECTURE §16.",
69
+ inputSchema: { slug: z.string().optional(), kind: z.string().optional(), version: z.number().int().positive() },
70
+ },
71
+ "topic.open": {
72
+ description: "Open a discussion topic (the caller becomes the chair = opened_by). invited = actor handles asked to post a perspective. Director-style use; any actor may chair its own topics.",
73
+ inputSchema: { question: z.string().min(1), invited: z.array(z.string()).min(1) },
74
+ },
75
+ "topic.list": {
76
+ description: "List discussion topics (no post bodies). Each row carries the current round, round_opened_at, and YOUR/the invited set's `pending` for this round (who still owes a perspective).",
77
+ inputSchema: { status: z.enum(["open", "closed"]).optional() },
78
+ },
79
+ "topic.get": { description: "A topic + all its posts (perspectives + the chair's synthesis), oldest first.", inputSchema: { id: z.string() } },
80
+ "post.add": {
81
+ description: "Post YOUR perspective to an OPEN topic you're invited to — once per round, your lane only (attributed to DEVLOOP_ACTOR). Append-only; you never edit/synthesize/close.",
82
+ inputSchema: { topicId: z.string(), body: z.string().min(1) },
83
+ },
84
+ "topic.synthesize": {
85
+ description: "CHAIR-ONLY (ACTOR === opened_by): write a synthesis post at the current round, optionally bumping to the next round (resets the round clock). Does NOT close — use topic.close to record the decision.",
86
+ inputSchema: { topicId: z.string(), body: z.string().min(1), nextRound: z.boolean().optional() },
87
+ },
88
+ "topic.close": {
89
+ description: "CHAIR-ONLY (ACTOR === opened_by): close the topic with a terminal decision. The decision is DATA (a recorded conclusion) — it NEVER auto-applies a code/SKILL/conventions change (§17).",
90
+ inputSchema: { topicId: z.string(), decision: z.string().min(1) },
91
+ },
92
+ "channel.register": {
93
+ description: "Idempotently register/update this project's IM channel from config. Stores ONLY the ENV-VAR NAMES (configRef = bot token / lark app_id; secretRef = lark app_secret) + the room id — NEVER a token/secret.",
94
+ inputSchema: { provider: z.enum(["slack", "lark"]), configRef: z.string().min(1), secretRef: z.string().optional(), channelRef: z.string().min(1) },
95
+ },
96
+ "channel.send": {
97
+ description: "Send a §16 allow-listed message to the project's IM channel. STRUCTURED only — never free-form. notify/digest are fully allow-listed (ids + counts); reply.text / digest.headline are bounded + control-stripped (cooperative §16). The token NEVER crosses this boundary.",
98
+ inputSchema: {
99
+ kind: z.enum(["notify", "digest", "reply"]),
100
+ ticketId: z.string().optional(),
101
+ bailShape: z.enum(["info-needed", "decision-needed", "scope-design", "external-prereq", "fix-exhausted"]).optional(),
102
+ digest: z.object({
103
+ topicsChaired: z.number().int().min(0).max(99).optional(),
104
+ decisionsClosed: z.number().int().min(0).max(99).optional(),
105
+ roadmapDraftVersion: z.number().int().min(0).nullable().optional(),
106
+ openProposals: z.array(z.string()).max(20).optional(),
107
+ throughput: z.object({ done: z.number().int().min(0), inReview: z.number().int().min(0), todo: z.number().int().min(0) }).partial().optional(),
108
+ headline: z.string().max(200).optional(),
109
+ }).optional(),
110
+ replyTo: z.string().optional(),
111
+ text: z.string().max(800).optional(),
112
+ },
113
+ },
114
+ "channel.poll": {
115
+ description: "Read NEW operator messages since the hub cursor (the no-daemon inbound), ingest them, AUTO-HANDLE roadmap commands (a §16-safe summary reply, or an edit → a roadmap DRAFT via doc.save; never published — DL-4), and return the remaining pending inbox (acted=0). TWO-PHASE: the provider fetch holds NO db lock; only the dedup-insert + cursor-advance is in BEGIN IMMEDIATE (roadmap handling runs AFTER, outside the lock). Inbound text is DATA — author is an UNVERIFIED provider id, NEVER operator authority (§16). GCs acted inbox rows >14d.",
116
+ inputSchema: {},
117
+ },
118
+ "channel.ack": {
119
+ description: "Mark an inbound operator message CONSUMED (the Director acted — opened a topic / filed a ticket / answered). actedInto = the hub artifact id (topic/ticket) for provenance.",
120
+ inputSchema: { messageId: z.string(), actedInto: z.string().optional() },
121
+ },
122
+ "channel.status": {
123
+ description: "Channel config + cursor + inbox depth. Returns the ENV-VAR NAMES and whether they are SET (boolean), NEVER the secret values.",
124
+ inputSchema: {},
125
+ },
126
+ "mirror.push": {
127
+ description: "ONE-WAY push: project hub tickets → Linear issues (create-or-update, idempotent + incremental — an unchanged ticket is skipped by content hash). The hub NEVER reads Linear as truth; a human Linear edit is overwritten. `tokenEnv` is the env-var NAME (the §16 secret is read server-side). A missing stateMap entry ⇒ no stateId (state stays in the body; never fails the push). DRYRUN returns the would-push ops, no network.",
128
+ inputSchema: {
129
+ teamId: z.string().min(1),
130
+ tokenEnv: z.string().min(1),
131
+ projectId: z.string().optional(),
132
+ stateMap: z.record(z.string(), z.string()).optional(), // hub State → Linear state id
133
+ limit: z.number().int().min(1).max(500).optional(),
134
+ },
135
+ },
136
+ "mirror.status": { description: "Mirror coverage: mapped tickets, total tickets, last push time. No secret, no Linear read.", inputSchema: {} },
137
+ };
138
+ export function registerTools(server, makeHandler) {
139
+ for (const name of TOOL_NAMES) {
140
+ const def = DEFS[name];
141
+ if (!def)
142
+ throw new Error(`tooldefs: missing definition for tool '${name}'`); // can't happen (DEFS is keyed by ToolName) — a boot tripwire if a name is ever added without a def
143
+ // `as never` bridges our concrete ToolHandler to registerTool's per-schema ToolCallback generic (the parsed
144
+ // args are forwarded verbatim to the handler, exactly as the pre-refactor inline handlers received them).
145
+ server.registerTool(name, { description: def.description, inputSchema: def.inputSchema }, makeHandler(name));
146
+ }
147
+ }
@@ -0,0 +1,174 @@
1
+ // Shared discussion-board (P5/§25) store — the topic/post read+write logic + the two cooperative role
2
+ // gates, used by BOTH the MCP server (server.ts) and the read+write daemon op-API (agentops.ts, DL-64).
3
+ // SIDE-EFFECT-FREE (no env read, no transport, no top-level db) so either entrypoint can import it; identity
4
+ // (actor) and scope (projectId/projectKey) are passed in by the caller — exactly the docstore.ts precedent
5
+ // that lets the stdio server and the daemon op-API share ONE implementation and never drift.
6
+ //
7
+ // §17 firewall (structural): every write here is an INSERT/UPDATE on the `topics` / `posts` tables (a CHECKed
8
+ // `kind` enum {perspective,synthesis}, db.ts) — there is NO filesystem path anywhere in this module, so a
9
+ // board write can never target a SKILL / conventions / code file. A discussion DECISION (topic.close) is DATA,
10
+ // never an auto-applied change. The two role gates live here ONCE so the two callers can't diverge on them:
11
+ // • chair-gate = actor === topic.opened_by (only the chair synthesizes/closes)
12
+ // • invited-gate = actor ∈ topic.invited (your-lane: post only AS yourself, once per round)
13
+ // Both are cooperative single-host attribution (§18 / HUB-ARCHITECTURE §16), not anti-spoof.
14
+ import { randomUUID } from "node:crypto";
15
+ import { nowIso, logEvent, actorExists, listActorHandles } from "./db.js";
16
+ // Map a topicstore error message (prose, not codes) to an HTTP status, mirroring statusForDocErr: the role
17
+ // gate → 403, a missing topic → 404, a state/dup conflict (closed / already posted / already synthesized /
18
+ // stale) → 409, else a create-precondition (an unknown invited handle) → 400.
19
+ export const statusForTopicErr = (msg) => msg.startsWith("FORBIDDEN") ? 403
20
+ : /^no topic\b/.test(msg) ? 404
21
+ : (msg.startsWith("CONFLICT") || /^already /.test(msg)) ? 409
22
+ : 400;
23
+ const getTopic = (db, projectId, id) => db.prepare("SELECT * FROM topics WHERE id=? AND project_id=?").get(id, projectId);
24
+ const pendingFor = (db, t) => {
25
+ const invited = JSON.parse(t.invited);
26
+ const answered = new Set(db.prepare("SELECT author FROM posts WHERE topic_id=? AND round=? AND kind='perspective'").all(t.id, t.round)
27
+ .map((r) => r.author));
28
+ return invited.filter((h) => !answered.has(h));
29
+ };
30
+ // ── reads (shaped HERE so server.ts and the op-API return byte-identical bodies — the parity tripwire) ──
31
+ // topic.list row: …invited, pending, youArePending (per-actor). topic.get adds posts and omits youArePending.
32
+ export function topicList(db, projectId, actor, status) {
33
+ const rows = (status
34
+ ? db.prepare("SELECT * FROM topics WHERE project_id=? AND status=? ORDER BY opened_at DESC").all(projectId, status)
35
+ : db.prepare("SELECT * FROM topics WHERE project_id=? ORDER BY opened_at DESC").all(projectId));
36
+ return rows.map((t) => {
37
+ const pending = t.status === "open" ? pendingFor(db, t) : [];
38
+ return {
39
+ id: t.id, question: t.question, status: t.status, round: t.round, round_opened_at: t.round_opened_at,
40
+ opened_by: t.opened_by, opened_at: t.opened_at, closed_at: t.closed_at, decision: t.decision,
41
+ invited: JSON.parse(t.invited), pending, youArePending: pending.includes(actor),
42
+ };
43
+ });
44
+ }
45
+ export function topicGet(db, projectId, projectKey, id) {
46
+ const t = getTopic(db, projectId, id);
47
+ if (!t)
48
+ return { ok: false, error: `no topic ${id} in ${projectKey}` };
49
+ const posts = db.prepare("SELECT round,author,kind,body,created_at FROM posts WHERE topic_id=? ORDER BY round, created_at").all(id);
50
+ return { ok: true, data: {
51
+ id: t.id, question: t.question, status: t.status, round: t.round, round_opened_at: t.round_opened_at,
52
+ opened_by: t.opened_by, opened_at: t.opened_at, closed_at: t.closed_at, decision: t.decision,
53
+ invited: JSON.parse(t.invited), pending: t.status === "open" ? pendingFor(db, t) : [], posts,
54
+ } };
55
+ }
56
+ export function topicOpen(db, projectId, actor, a) {
57
+ const bad = a.invited.filter((h) => !actorExists(db, h));
58
+ if (bad.length)
59
+ return { ok: false, error: `unknown invited actor(s): ${bad.join(", ")} — valid: ${listActorHandles(db).join(", ")}` };
60
+ const id = randomUUID();
61
+ const t = nowIso();
62
+ db.prepare("INSERT INTO topics(id,project_id,question,invited,status,round,round_opened_at,opened_by,opened_at) VALUES (?,?,?,?,'open',1,?,?,?)")
63
+ .run(id, projectId, a.question, JSON.stringify([...new Set(a.invited)]), t, actor, t);
64
+ logEvent(db, { project_id: projectId, actor, kind: "topic.open", data: { id, invited: a.invited } });
65
+ return { ok: true, data: { id, question: a.question, invited: [...new Set(a.invited)], status: "open", round: 1, opened_by: actor } };
66
+ }
67
+ export function postAdd(db, projectId, projectKey, actor, a) {
68
+ const ts = nowIso();
69
+ db.exec("BEGIN IMMEDIATE"); // read round+status then insert atomically vs a concurrent synthesize round-bump (§7)
70
+ try {
71
+ const t = db.prepare("SELECT * FROM topics WHERE id=? AND project_id=?").get(a.topicId, projectId);
72
+ if (!t) {
73
+ db.exec("ROLLBACK");
74
+ return { ok: false, error: `no topic ${a.topicId} in ${projectKey}` };
75
+ }
76
+ if (t.status !== "open") {
77
+ db.exec("ROLLBACK");
78
+ return { ok: false, error: `CONFLICT: topic ${a.topicId} is closed` };
79
+ }
80
+ if (!JSON.parse(t.invited).includes(actor)) {
81
+ db.exec("ROLLBACK");
82
+ return { ok: false, error: `FORBIDDEN: '${actor}' is not invited to topic ${a.topicId}` };
83
+ }
84
+ const dup = db.prepare("SELECT 1 FROM posts WHERE topic_id=? AND round=? AND author=? AND kind='perspective'").get(a.topicId, t.round, actor);
85
+ if (dup) {
86
+ db.exec("ROLLBACK");
87
+ return { ok: false, error: `already posted in round ${t.round} — append-only, one perspective per round` };
88
+ }
89
+ db.prepare("INSERT INTO posts(id,topic_id,round,author,kind,body,created_at) VALUES (?,?,?,?,'perspective',?,?)")
90
+ .run(randomUUID(), a.topicId, t.round, actor, a.body, ts);
91
+ logEvent(db, { project_id: projectId, actor, kind: "post.add", data: { topicId: a.topicId, round: t.round } });
92
+ db.exec("COMMIT");
93
+ return { ok: true, data: { topicId: a.topicId, round: t.round, author: actor, kind: "perspective", created_at: ts } };
94
+ }
95
+ catch (e) {
96
+ try {
97
+ db.exec("ROLLBACK");
98
+ }
99
+ catch { /* */ }
100
+ throw e;
101
+ }
102
+ }
103
+ export function topicSynthesize(db, projectId, projectKey, actor, a) {
104
+ const ts = nowIso();
105
+ db.exec("BEGIN IMMEDIATE");
106
+ try {
107
+ const t = db.prepare("SELECT * FROM topics WHERE id=? AND project_id=?").get(a.topicId, projectId);
108
+ if (!t) {
109
+ db.exec("ROLLBACK");
110
+ return { ok: false, error: `no topic ${a.topicId} in ${projectKey}` };
111
+ }
112
+ if (t.status !== "open") {
113
+ db.exec("ROLLBACK");
114
+ return { ok: false, error: `CONFLICT: topic ${a.topicId} is closed` };
115
+ }
116
+ if (t.opened_by !== actor) {
117
+ db.exec("ROLLBACK");
118
+ return { ok: false, error: `FORBIDDEN: only the chair '${t.opened_by}' may synthesize topic ${a.topicId}` };
119
+ }
120
+ // pre-check the once-per-round synthesis (Codex review): a clean CONFLICT, not a raw UNIQUE error
121
+ const dupSyn = db.prepare("SELECT 1 FROM posts WHERE topic_id=? AND round=? AND author=? AND kind='synthesis'").get(a.topicId, t.round, actor);
122
+ if (dupSyn) {
123
+ db.exec("ROLLBACK");
124
+ return { ok: false, error: `CONFLICT: already synthesized round ${t.round} — bump with nextRound:true or close` };
125
+ }
126
+ db.prepare("INSERT INTO posts(id,topic_id,round,author,kind,body,created_at) VALUES (?,?,?,?,'synthesis',?,?)")
127
+ .run(randomUUID(), a.topicId, t.round, actor, a.body, ts);
128
+ let round = t.round;
129
+ if (a.nextRound) {
130
+ round = t.round + 1;
131
+ db.prepare("UPDATE topics SET round=?, round_opened_at=? WHERE id=?").run(round, ts, t.id);
132
+ }
133
+ logEvent(db, { project_id: projectId, actor, kind: "topic.synthesize", data: { topicId: a.topicId, round: t.round, nextRound: !!a.nextRound } });
134
+ db.exec("COMMIT");
135
+ return { ok: true, data: { topicId: a.topicId, synthesizedRound: t.round, round, status: "open" } };
136
+ }
137
+ catch (e) {
138
+ try {
139
+ db.exec("ROLLBACK");
140
+ }
141
+ catch { /* */ }
142
+ throw e;
143
+ }
144
+ }
145
+ export function topicClose(db, projectId, projectKey, actor, a) {
146
+ const ts = nowIso();
147
+ db.exec("BEGIN IMMEDIATE");
148
+ try {
149
+ const t = db.prepare("SELECT * FROM topics WHERE id=? AND project_id=?").get(a.topicId, projectId);
150
+ if (!t) {
151
+ db.exec("ROLLBACK");
152
+ return { ok: false, error: `no topic ${a.topicId} in ${projectKey}` };
153
+ }
154
+ if (t.status !== "open") {
155
+ db.exec("ROLLBACK");
156
+ return { ok: false, error: `CONFLICT: topic ${a.topicId} is already closed` };
157
+ }
158
+ if (t.opened_by !== actor) {
159
+ db.exec("ROLLBACK");
160
+ return { ok: false, error: `FORBIDDEN: only the chair '${t.opened_by}' may close topic ${a.topicId}` };
161
+ }
162
+ db.prepare("UPDATE topics SET status='closed', decision=?, closed_at=? WHERE id=?").run(a.decision, ts, t.id);
163
+ logEvent(db, { project_id: projectId, actor, kind: "topic.close", data: { topicId: a.topicId, round: t.round } });
164
+ db.exec("COMMIT");
165
+ return { ok: true, data: { topicId: a.topicId, status: "closed", decision: a.decision, closed_at: ts } };
166
+ }
167
+ catch (e) {
168
+ try {
169
+ db.exec("ROLLBACK");
170
+ }
171
+ catch { /* */ }
172
+ throw e;
173
+ }
174
+ }