@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/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # dev-loop
2
+
3
+ The standalone local **coordination hub** for the [dev-loop](https://github.com/dyzsasd/dev-loop)
4
+ agents — a **zero-build, zero-native-dependency** MCP system-of-record over `node:sqlite` with
5
+ **per-agent identity**, a localhost **web-UI daemon**, an opt-in agent **op-API + thin stdio shim**,
6
+ and a **CLI-portable** transport (Claude Code · Codex · opencode).
7
+
8
+ > One trusted host, localhost-only. Identity is **cooperative attribution** (not anti-spoof). Secrets
9
+ > live in env by **name** only. See the security envelope in
10
+ > [`docs/HUB-ARCHITECTURE.md`](https://github.com/dyzsasd/dev-loop/blob/main/docs/HUB-ARCHITECTURE.md).
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install -g @dyzsasd/dev-loop # requires Node >= 23.6 (built-in node:sqlite + .ts type-stripping; zero build); installs the `dev-loop` + `dev-loop-hub` bins
16
+ ```
17
+
18
+ This puts two bins on `PATH`: **`dev-loop`** (the CLI) and **`dev-loop-hub`** (the MCP server entry).
19
+
20
+ ## CLI
21
+
22
+ ```
23
+ dev-loop serve run the stdio MCP server (the agent transport)
24
+ dev-loop shim the thin stdio MCP shim → the loopback daemon op-API
25
+ dev-loop daemon up|down|status per-project daemon lifecycle — idempotent, auto web UI
26
+ dev-loop init-service <key> <name> <PREFIX> turnkey-bootstrap a service-backend project
27
+ dev-loop mcp-merge <args> merge dev-loop-hub into a product .mcp.json (never clobbers)
28
+ dev-loop seed <key> <name> [PREFIX] seed a project + actors + labels
29
+ dev-loop doctor health-check the system-of-record (DOCTOR_OK)
30
+ dev-loop identity-check [--expect <actor>[/<project>]] the portability gate
31
+ dev-loop version | help
32
+ ```
33
+
34
+ ## Identity & project (the env contract)
35
+
36
+ Every launcher sets, **per pane**, the identity the write is attributed to:
37
+
38
+ | Env var | Meaning |
39
+ |---|---|
40
+ | `DEVLOOP_ACTOR` | the per-agent identity (`pm`/`qa`/`dev`/…) — the attribution |
41
+ | `DEVLOOP_PROJECT` | the pinned project key (or resolved from the cwd) |
42
+ | `DEVLOOP_HUB_DB` | the SQLite system-of-record (default `~/.dev-loop/hub.db`) |
43
+
44
+ Register it as an MCP server for your CLI — `{ "command": "dev-loop", "args": ["serve"] }` (or
45
+ `["shim"]` for the daemon transport). Per-CLI recipes + the identity gate:
46
+ [`docs/PORTABILITY.md`](https://github.com/dyzsasd/dev-loop/blob/main/docs/PORTABILITY.md).
47
+
48
+ ## Docs
49
+
50
+ - [Architecture + safety envelope](https://github.com/dyzsasd/dev-loop/blob/main/docs/HUB-ARCHITECTURE.md)
51
+ - [Running the loop](https://github.com/dyzsasd/dev-loop/blob/main/docs/RUNNING.md) ·
52
+ [The daemon](https://github.com/dyzsasd/dev-loop/blob/main/docs/DAEMON.md) ·
53
+ [Portability (Codex / opencode)](https://github.com/dyzsasd/dev-loop/blob/main/docs/PORTABILITY.md)
54
+
55
+ MIT © Shuai
@@ -0,0 +1,551 @@
1
+ // dev-loop hub — the agent op-API ops as plain functions: the SINGLE definition of every ticket/read policy
2
+ // the hub exposes. BOTH transports dispatch through these: the DL-43 daemon agent op-API (/api/op/*) and —
3
+ // since DL-69 (the dispatch-sharing refactor) — the stdio MCP server (server.ts), whose 27 op-backed tool
4
+ // handlers are now thin call-throughs to agentOp() (server.ts's toMcp() maps {status,body}→MCP ok()/err()).
5
+ // So each policy — the read SELECTs, the save_issue/save_comment orchestration (the DL-24 per-transition
6
+ // assignTo + the DL-32 prod-promotion gate + the REPLACE-labels/APPEND-relatedTo merge), and the doc/topic/
7
+ // channel/mirror/label families (which also reuse the shared ticketwrite/docstore/topicstore/channelstore/
8
+ // mirrorstore/labelstore) — has EXACTLY ONE definition. The old "edit both files" drift tripwire is RETIRED:
9
+ // a change to any policy now lands in ONE place, and the differential-parity suite (test/shim.ts +
10
+ // test/agent-api.ts, shim ≡ stdio for all 29 tools) is the structural guard against a future re-divergence.
11
+ //
12
+ // Each function takes a hub connection + the caller's already-resolved+validated actor (server.ts resolves it
13
+ // from DEVLOOP_ACTOR + the G1 phantom-actor guard; the daemon from the X-Devloop-Actor header) and returns an
14
+ // HTTP-shaped { status, body }: the daemon serializes it as JSON; server.ts's toMcp() maps it to ok()/err()
15
+ // (a 200 → ok(body); a non-200 → err(body.error)). NO env read, NO mode gate, NO transport here — each
16
+ // transport owns its own pipeline (server.ts the stdio identity; the daemon op-API writeOriginOk → actor →
17
+ // mode-honoring) AROUND these pure-policy ops. (whoami + create_issue_label stay native in server.ts: whoami
18
+ // is transport-specific identity, not an op; create_issue_label's policy already lives once in labelstore, and
19
+ // the op logs an op-API-only label.create attribution event server.ts deliberately does not — DL-69 kept it
20
+ // native so the stdio path stays byte-identical.)
21
+ import { DatabaseSync } from "node:sqlite";
22
+ import { TOOL_NAMES } from "./tooldefs.js"; // DL-85: the ONE tool/op name source; AGENT_OPS derives from it
23
+ import { actorExists, listActorHandles, logEvent, unifiedDiff, STATES } from "./db.js";
24
+ import { insertTicket, updateTicketRow, insertComment, loadRelease } from "./ticketwrite.js";
25
+ // DL-62 doc/event family — the doc WRITES (docSave/docPublish, incl. the CAS + the single operator-publish
26
+ // gate) + the docstore-error→HTTP-status map are reused VERBATIM from the shared, side-effect-free docstore
27
+ // (exactly as the 5 ticket ops reuse ticketwrite.ts), so both transports share one publish gate + one CAS.
28
+ // The doc READS (doc.list/get/history/diff) + list_events are the SINGLE definition of those SELECTs — since
29
+ // DL-69 server.ts's handlers dispatch through them (no longer a 1:1 duplicate of a server.ts copy).
30
+ import { resolveDoc, latestVersion, docSave, docPublish, statusForDocErr, DOC_KINDS } from "./docstore.js";
31
+ // DL-64 discussion-board family — the topic/post reads + writes (incl. the §25 chair/invited role gates +
32
+ // the round/append rules) + the error→HTTP-status map are reused VERBATIM from the shared, side-effect-free
33
+ // topicstore (exactly as the doc family reuses docstore.ts), so the op-API and the stdio server.ts can never
34
+ // drift on a gate or a response shape. The op-API parses raw JSON, so each handler hand-validates the input
35
+ // shapes server.ts gets from zod (the DL-63 read-handler lesson — a non-string id/body must 400, never a 500).
36
+ import { topicList, topicGet, topicOpen, postAdd, topicSynthesize, topicClose, statusForTopicErr } from "./topicstore.js";
37
+ // DL-67 channel family — the channel register/send/poll/ack/status HANDLER logic + the DL-4 roadmap bridge are
38
+ // reused VERBATIM from the shared, side-effect-free channelstore (exactly as the doc/topic families reuse
39
+ // docstore/topicstore), so the op-API and the stdio server.ts can never drift. channel.send/poll are ASYNC
40
+ // (network/dryrun), so agentOp returns OpResult|Promise<OpResult> and the daemon awaits it. The op-API parses
41
+ // raw JSON → each handler hand-validates the shapes server.ts gets from zod (DL-63: a non-string arg → 400, never a 500).
42
+ import { channelRegister, channelSend, channelPoll, channelAck, channelStatus, statusForChannelErr } from "./channelstore.js";
43
+ // DL-68 P7 mirror + label/project — the FINAL slice. mirror.push's handler (reusing linear.ts's transport AS-IS)
44
+ // + mirror.status are reused VERBATIM from the shared mirrorstore (so the op-API + server.ts can't drift on the
45
+ // DL-11 DRYRUN invariant / reconcile-by-marker idempotency), and the label/project ops + the SINGLE LABEL_KINDS /
46
+ // DL-22 reject from labelstore. mirror.push is ASYNC (Linear network / dryrun build) → agentOp returns a Promise.
47
+ import { mirrorPush, mirrorStatus } from "./mirrorstore.js";
48
+ import { createLabel, listLabels, getProject } from "./labelstore.js";
49
+ const okR = (body) => ({ status: 200, body });
50
+ const errR = (status, error) => ({ status, body: { error } });
51
+ export const AGENT_OPS = TOOL_NAMES.filter((n) => n !== "whoami");
52
+ // The MUTATING subset — the daemon applies writeOriginOk + the dry-run mode gate to exactly these (reads
53
+ // never mutate, so they bypass both). Kept here next to AGENT_OPS so the two lists can't drift. doc.save /
54
+ // doc.publish join the ticket writes; the doc/event reads stay read-only (parity with the read ticket ops).
55
+ export const AGENT_WRITE_OPS = new Set(["save_issue", "save_comment", "doc.save", "doc.publish",
56
+ "topic.open", "post.add", "topic.synthesize", "topic.close", // DL-64: the 4 board writes
57
+ "channel.register", "channel.send", "channel.poll", "channel.ack", // DL-67: the 4 channel writes (register/send/poll/ack mutate the channels/channel_messages tables); channel.status stays a read (query_only)
58
+ "mirror.push", "create_issue_label"]); // DL-68: the 2 writes (mirror.push → mirror_map + the one-way Linear network write; create_issue_label → labels). mirror.status/list_issue_labels/get_project stay reads (query_only)
59
+ export const isAgentOp = (s) => AGENT_OPS.includes(s);
60
+ const toTicket = (r) => ({
61
+ id: r.id, project_id: r.project_id, title: r.title, description: r.description, type: r.type,
62
+ state: r.state, assignee: r.assignee, priority: r.priority,
63
+ labels: JSON.parse(r.labels),
64
+ duplicateOf: r.duplicate_of, relatedTo: JSON.parse(r.related_to),
65
+ created_by: r.created_by, created_at: r.created_at, updated_at: r.updated_at,
66
+ });
67
+ const getRow = (db, projectId, id) => db.prepare("SELECT * FROM tickets WHERE id=? AND project_id=?").get(id, projectId);
68
+ // "me" → the caller's actor (the per-agent attribution win); empty/whitespace → unassigned; else verbatim.
69
+ const resolveAssignee = (actor, a) => a === undefined || a === null ? null
70
+ : a === "me" ? actor
71
+ : a.trim() === "" ? null
72
+ : a;
73
+ // ─── DL-24 per-transition assignTo directive (mirror of server.ts) ─────────────
74
+ const ownerHandleOf = (labels) => labels.includes("pm") ? "pm" : labels.includes("qa") ? "qa" : null;
75
+ function loadTransitions(db, projectId) {
76
+ try {
77
+ const row = db.prepare("SELECT settings_json FROM projects WHERE id=?").get(projectId);
78
+ const tr = (row?.settings_json ? JSON.parse(row.settings_json) : {})?.workflow?.transitions;
79
+ return tr && typeof tr === "object" ? tr : {};
80
+ }
81
+ catch {
82
+ return {};
83
+ } // malformed config ⇒ absent (fail-open), never bricks a write
84
+ }
85
+ function resolveAssignTo(db, projectId, actor, from, to, labels) {
86
+ const dir = loadTransitions(db, projectId)[`${from}->${to}`];
87
+ if (!dir || dir.assignTo === undefined || dir.assignTo === null)
88
+ return null;
89
+ const v = dir.assignTo;
90
+ if (v === "owner") {
91
+ const o = ownerHandleOf(labels);
92
+ if (!o)
93
+ console.error(`[assignTo] ${from}->${to}: owner directive but ticket has no pm/qa label — assignee left untouched`);
94
+ return o;
95
+ }
96
+ if (v === "self")
97
+ return actor;
98
+ if (actorExists(db, v))
99
+ return v;
100
+ console.error(`[assignTo] ${from}->${to}: unknown handle '${v}' — assignee left untouched`);
101
+ return null;
102
+ }
103
+ // ─── DL-32 prod-promotion gate (mirror of server.ts) ───────────────────────────
104
+ const ENV_LABELS = ["env:dev", "env:prod"];
105
+ const envLabelsOf = (labels) => labels.filter((l) => ENV_LABELS.includes(l)).sort();
106
+ function prodPromotionRejection(db, projectId, actor, oldLabels, newLabels) {
107
+ if (loadRelease(db, projectId).prodPromotionGate !== "human")
108
+ return null;
109
+ const adding = newLabels.includes("env:prod") && !oldLabels.includes("env:prod");
110
+ return adding && actor !== "operator"
111
+ ? `env:prod promotion is human-gated (prodPromotionGate:"human"): only the operator may add env:prod`
112
+ : null;
113
+ }
114
+ // ─── the 5 ops ─────────────────────────────────────────────────────────────────
115
+ // Shared input-shape guard: a JSON array whose every element is a string — mirrors zod's z.array(z.string()).
116
+ // The op-API parses raw JSON (no zod), so list_issues + save_issue both re-check `labels` by hand with this
117
+ // (a non-array would crash a `[...]` spread or be JSON.stringify'd into the column → a 500); one definition the
118
+ // two ops share so they can't drift (DL-65 hoisted opSaveIssue's original local helper to module scope).
119
+ const isStrArr = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
120
+ function opListIssues(db, projectId, actor, a) {
121
+ // Re-validate the raw-JSON arg shapes the stdio path gets from zod (server.ts: query/assignee
122
+ // z.string().optional(), labels z.array(z.string()).optional()). Without this a non-string `query`
123
+ // (.toLowerCase() below), a non-array `labels` (the [...] spread below), or a non-string truthy `assignee`
124
+ // (resolveAssignee → .trim()) throws a TypeError → the daemon's catch → an HTTP 500 echoing the raw JS error,
125
+ // where the zod path returns a clean 400. Same guard class as opSaveIssue's labels / the doc-READ selectors
126
+ // (docSelectorErr, DL-63) — the last unguarded read op (DL-65). state/type/label are compared (never bound or
127
+ // method-called), so they keep today's behavior and need no guard.
128
+ if (a.query !== undefined && typeof a.query !== "string")
129
+ return errR(400, "query must be a string");
130
+ if (a.labels !== undefined && !isStrArr(a.labels))
131
+ return errR(400, "labels must be an array of strings");
132
+ if (a.assignee !== undefined && typeof a.assignee !== "string")
133
+ return errR(400, "assignee must be a string");
134
+ let out = db.prepare("SELECT * FROM tickets WHERE project_id=? ORDER BY updated_at DESC").all(projectId).map(toTicket);
135
+ if (a.state)
136
+ out = out.filter((t) => t.state === a.state);
137
+ if (a.assignee)
138
+ out = out.filter((t) => t.assignee === resolveAssignee(actor, a.assignee));
139
+ if (a.type)
140
+ out = out.filter((t) => t.type === a.type);
141
+ const want = [...(a.labels ?? []), ...(a.label ? [a.label] : [])];
142
+ if (want.length)
143
+ out = out.filter((t) => want.every((l) => t.labels.includes(l)));
144
+ if (a.query) {
145
+ const q = a.query.toLowerCase();
146
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.description.toLowerCase().includes(q));
147
+ }
148
+ return okR(a.limit ? out.slice(0, a.limit) : out);
149
+ }
150
+ function opGetIssue(db, projectId, projectKey, a) {
151
+ if (a.id === undefined)
152
+ return errR(400, "id required"); // === undefined, NOT falsy: a zod-valid empty-string id ("" passes the bare z.string()) must fall through to the not-found lookup, byte-identical to the pre-DL-69 native handler; this guard exists only to stop an undefined → node:sqlite bind-crash
153
+ const r = getRow(db, projectId, a.id);
154
+ if (!r)
155
+ return errR(404, `no such ticket ${a.id} in ${projectKey}`);
156
+ const comments = db.prepare("SELECT id,author,body,created_at FROM comments WHERE ticket_id=? ORDER BY created_at").all(a.id);
157
+ return okR({ ...toTicket(r), comments });
158
+ }
159
+ // MIRRORS server.ts save_issue exactly: validate → create (insertTicket) OR update (atomic read-merge-write
160
+ // under BEGIN IMMEDIATE: REPLACE labels, APPEND-only relatedTo union, DL-24 assignTo, DL-32 promo gate, the
161
+ // DL-38 staging gate inside updateTicketRow, the issue.promote env event). `db` MUST be a WRITABLE connection.
162
+ function opSaveIssue(db, projectId, projectKey, actor, a) {
163
+ // Input validation the stdio path gets from its zod schema (server.ts) — the op-API parses raw JSON, so it
164
+ // re-checks the SAME shapes by hand. The array fields are load-bearing: a non-array labels/relatedTo would
165
+ // be JSON.stringify'd into the column and later crash a `t.labels.includes()` / `[...]` spread (a 500
166
+ // poison-pill on every subsequent list_issues), so reject them up front — matching zod's array-of-strings.
167
+ if (a.labels !== undefined && !isStrArr(a.labels))
168
+ return errR(400, "labels must be an array of strings");
169
+ if (a.relatedTo !== undefined && !isStrArr(a.relatedTo))
170
+ return errR(400, "relatedTo must be an array of strings");
171
+ if (a.priority !== undefined && (typeof a.priority !== "number" || !Number.isInteger(a.priority) || a.priority < 0 || a.priority > 4))
172
+ return errR(400, `invalid priority; an integer 0..4`);
173
+ if (a.state && !STATES.includes(a.state))
174
+ return errR(400, `invalid state '${a.state}'; one of ${STATES.join(", ")}`);
175
+ if (a.assignee && a.assignee !== "me" && !actorExists(db, a.assignee))
176
+ return errR(400, `unknown assignee '${a.assignee}'; one of ${listActorHandles(db).join(", ")} (or "me"/null)`); // DL-69: the message is byte-identical to server.ts's (the single source) — agent-api.ts asserts only status 400
177
+ if (!a.id) {
178
+ if (!a.title)
179
+ return errR(400, "title required to create a ticket");
180
+ const promoReject = prodPromotionRejection(db, projectId, actor, [], a.labels ?? []);
181
+ if (promoReject)
182
+ return errR(403, promoReject);
183
+ const id = insertTicket(db, projectId, actor, { title: a.title, description: a.description ?? "", type: a.type ?? "Feature", state: a.state ?? "Todo",
184
+ assignee: resolveAssignee(actor, a.assignee), priority: a.priority ?? 0, labels: a.labels ?? [],
185
+ duplicateOf: a.duplicateOf ?? null, relatedTo: a.relatedTo ?? [] }, { title: a.title, type: a.type });
186
+ return okR(toTicket(getRow(db, projectId, id)));
187
+ }
188
+ // update — atomic read-merge-write (the APPEND-only relatedTo union must not lose a concurrent link).
189
+ db.exec("BEGIN IMMEDIATE");
190
+ try {
191
+ const cur = getRow(db, projectId, a.id);
192
+ if (!cur) {
193
+ db.exec("ROLLBACK");
194
+ return errR(404, `no such ticket ${a.id} in ${projectKey}`);
195
+ }
196
+ const next = {
197
+ title: a.title ?? cur.title, description: a.description ?? cur.description, type: a.type ?? cur.type,
198
+ state: a.state ?? cur.state,
199
+ assignee: a.assignee === undefined ? cur.assignee : resolveAssignee(actor, a.assignee),
200
+ priority: a.priority ?? cur.priority,
201
+ labels: a.labels ? JSON.stringify(a.labels) : cur.labels, // REPLACE-style (§10#1)
202
+ duplicate_of: a.duplicateOf === undefined ? cur.duplicate_of : a.duplicateOf, // scalar; undefined=keep
203
+ related_to: a.relatedTo // APPEND-only union (§18)
204
+ ? JSON.stringify([...new Set([...JSON.parse(cur.related_to), ...a.relatedTo])])
205
+ : cur.related_to,
206
+ };
207
+ if (next.state !== cur.state && a.assignee === undefined) { // DL-24 assignTo (implicit assignee only)
208
+ const resolved = resolveAssignTo(db, projectId, actor, cur.state, next.state, JSON.parse(next.labels));
209
+ if (resolved !== null)
210
+ next.assignee = resolved;
211
+ }
212
+ const oldLabels = JSON.parse(cur.labels), newLabels = JSON.parse(next.labels);
213
+ const promoReject = prodPromotionRejection(db, projectId, actor, oldLabels, newLabels); // DL-32 prod gate
214
+ if (promoReject) {
215
+ db.exec("ROLLBACK");
216
+ return errR(403, promoReject);
217
+ }
218
+ const wr = updateTicketRow(db, projectId, actor, a.id, cur.state, next); // DL-38 staging gate inside ⇒ may reject
219
+ if (!wr.ok) {
220
+ db.exec("ROLLBACK");
221
+ return errR(wr.status, wr.error);
222
+ }
223
+ const fromEnv = envLabelsOf(oldLabels).join(","), toEnv = envLabelsOf(newLabels).join(","); // DL-32 issue.promote on env change
224
+ if (fromEnv !== toEnv)
225
+ logEvent(db, { project_id: projectId, ticket_id: a.id, actor, kind: "issue.promote", data: { from: fromEnv, to: toEnv } });
226
+ db.exec("COMMIT");
227
+ }
228
+ catch (e) {
229
+ try {
230
+ db.exec("ROLLBACK");
231
+ }
232
+ catch { /* */ }
233
+ throw e;
234
+ }
235
+ return okR(toTicket(getRow(db, projectId, a.id)));
236
+ }
237
+ // `db` MUST be a WRITABLE connection (the comment INSERT + comment.add event go through insertComment).
238
+ function opSaveComment(db, projectId, actor, a) {
239
+ if (a.issueId === undefined)
240
+ return errR(400, "issueId required"); // === undefined, NOT falsy (DL-69): a zod-valid empty-string issueId must fall through to the not-found lookup, byte-identical to the pre-refactor native handler
241
+ if (typeof a.body !== "string")
242
+ return errR(400, "body required");
243
+ if (!getRow(db, projectId, a.issueId))
244
+ return errR(404, `no such ticket ${a.issueId}`);
245
+ const { id, createdAt } = insertComment(db, projectId, actor, a.issueId, a.body);
246
+ return okR({ id, ticket_id: a.issueId, author: actor, body: a.body, created_at: createdAt });
247
+ }
248
+ function opListComments(db, projectId, projectKey, a) {
249
+ if (a.issueId === undefined)
250
+ return errR(400, "issueId required"); // === undefined, NOT falsy (DL-69): a zod-valid empty-string issueId must fall through to the not-found lookup, byte-identical to the pre-refactor native handler
251
+ if (!getRow(db, projectId, a.issueId))
252
+ return errR(404, `no such ticket ${a.issueId} in ${projectKey}`);
253
+ return okR(db.prepare("SELECT id,author,body,created_at FROM comments WHERE ticket_id=? ORDER BY created_at").all(a.issueId));
254
+ }
255
+ // ─── DL-62: the doc/event family (verbatim mirror of server.ts list_events + doc.* handlers) ──────
256
+ // The doc READS + list_events are the SAME SELECTs server.ts runs (a JSON round-trip → byte-identical
257
+ // to the stdio ok() body — the differential-parity tripwire). The doc WRITES delegate to the shared
258
+ // docstore (docSave/docPublish), so the CAS + the single operator-publish gate live in ONE place.
259
+ function opListEvents(db, projectId, a) {
260
+ // mirror server.ts's zod (limit: int 1..500) — the op-API parses raw JSON, so a bad limit must be a clean
261
+ // 400 here, never bound into LIMIT (a non-int bind throws in node:sqlite → a 500; an uncapped limit drifts).
262
+ if (a.limit !== undefined && (!Number.isInteger(a.limit) || a.limit <= 0 || a.limit > 500))
263
+ return errR(400, "limit must be an integer 1..500");
264
+ return okR(db.prepare("SELECT actor,kind,ticket_id,data,created_at FROM events WHERE project_id=? ORDER BY id DESC LIMIT ?").all(projectId, a.limit ?? 50));
265
+ }
266
+ // Mirror server.ts's zod (the doc tools' `slug`/`kind` are OPTIONAL STRINGS). The op-API parses raw JSON
267
+ // with no zod, so a present-but-non-string slug/kind must 400 HERE — otherwise it binds into resolveDoc's
268
+ // parameterized query and node:sqlite throws "Provided value cannot be bound" → an HTTP 500 echoing the raw
269
+ // driver string (same class as opSaveIssue's non-array / opDocDiff's non-int guards, extended to the doc-READ
270
+ // selectors — DL-63). Absent (undefined) is fine: a read selects by slug OR kind, and doc.list by neither.
271
+ const docSelectorErr = (a) => a.slug !== undefined && typeof a.slug !== "string" ? "slug must be a string"
272
+ : a.kind !== undefined && typeof a.kind !== "string" ? "kind must be a string"
273
+ : null;
274
+ function opDocList(db, projectId, a) {
275
+ const bad = docSelectorErr(a);
276
+ if (bad)
277
+ return errR(400, bad);
278
+ return okR(a.kind
279
+ ? db.prepare("SELECT id,kind,slug,title,status,current_version,created_by,updated_at FROM documents WHERE project_id=? AND kind=? ORDER BY kind").all(projectId, a.kind)
280
+ : db.prepare("SELECT id,kind,slug,title,status,current_version,created_by,updated_at FROM documents WHERE project_id=? ORDER BY kind").all(projectId));
281
+ }
282
+ function opDocGet(db, projectId, projectKey, a) {
283
+ const bad = docSelectorErr(a);
284
+ if (bad)
285
+ return errR(400, bad);
286
+ // mirror server.ts's zod (version: int>0, optional). Re-check by hand (no zod on the op-API path): an
287
+ // out-of-range version must 400 like the stdio path, not fall through to the version===0 empty-doc branch.
288
+ if (a.version !== undefined && (!Number.isInteger(a.version) || a.version <= 0))
289
+ return errR(400, "version must be a positive integer");
290
+ const d = resolveDoc(db, projectId, a.slug, a.kind);
291
+ if (!d)
292
+ return errR(404, `no document ${a.slug ?? a.kind} in ${projectKey}`);
293
+ const ver = a.version ?? (d.current_version > 0 ? d.current_version : latestVersion(db, d.id));
294
+ if (ver === 0)
295
+ return okR({ ...d, version: 0, body: "", unpublished: true, empty: true });
296
+ const v = db.prepare("SELECT version,body,status,summary,base_version,author,created_at FROM document_versions WHERE doc_id=? AND version=?").get(d.id, ver);
297
+ if (!v)
298
+ return errR(404, `no version ${ver} of ${d.slug}`);
299
+ return okR({ id: d.id, kind: d.kind, slug: d.slug, title: d.title, status: d.status, current_version: d.current_version, ...v, ...(d.current_version === 0 ? { unpublished: true } : {}) });
300
+ }
301
+ function opDocHistory(db, projectId, a) {
302
+ const bad = docSelectorErr(a);
303
+ if (bad)
304
+ return errR(400, bad);
305
+ const d = resolveDoc(db, projectId, a.slug, a.kind);
306
+ if (!d)
307
+ return errR(404, `no document ${a.slug ?? a.kind}`);
308
+ return okR(db.prepare("SELECT version,status,author,summary,base_version,created_at FROM document_versions WHERE doc_id=? ORDER BY version DESC").all(d.id));
309
+ }
310
+ function opDocDiff(db, projectId, a) {
311
+ const bad = docSelectorErr(a);
312
+ if (bad)
313
+ return errR(400, bad);
314
+ // from/to come from zod (int>0) on the stdio/shim path; the op-API parses raw JSON, so re-check by hand —
315
+ // a non-int bind would otherwise throw inside node:sqlite → a 500 instead of a clean 400 (opSaveIssue precedent).
316
+ if (!Number.isInteger(a.from) || a.from <= 0)
317
+ return errR(400, "from must be a positive integer");
318
+ if (!Number.isInteger(a.to) || a.to <= 0)
319
+ return errR(400, "to must be a positive integer");
320
+ const d = resolveDoc(db, projectId, a.slug, a.kind);
321
+ if (!d)
322
+ return errR(404, `no document ${a.slug ?? a.kind}`);
323
+ const body = (n) => db.prepare("SELECT body FROM document_versions WHERE doc_id=? AND version=?").get(d.id, n)?.body;
324
+ const fromBody = body(a.from), toBody = body(a.to);
325
+ if (fromBody === undefined || toBody === undefined)
326
+ return errR(404, `missing version (have up to ${latestVersion(db, d.id)})`);
327
+ return okR({ from: a.from, to: a.to, fromBody, toBody, unified: unifiedDiff(fromBody, toBody) });
328
+ }
329
+ // `db` MUST be a WRITABLE connection (docSave does BEGIN IMMEDIATE + INSERTs + a doc.save event). The CAS
330
+ // (a stale baseVersion → CONFLICT, never last-write-wins) lives inside docSave, shared with server.ts.
331
+ function opDocSave(db, projectId, actor, a) {
332
+ // re-validate the zod shapes the stdio/shim path enforces (slug/body required, kind ∈ DOC_KINDS, baseVersion int≥0)
333
+ if (typeof a.slug !== "string")
334
+ return errR(400, "slug required (a string)"); // type-only, NOT non-empty (DL-69): a zod-valid empty-string slug must reach docSave (which creates/handles it), byte-identical to the pre-refactor native handler; only undefined/non-string is rejected (the INSERT-bind guard)
335
+ if (typeof a.body !== "string")
336
+ return errR(400, "body required (a string)");
337
+ if (a.title !== undefined && typeof a.title !== "string")
338
+ return errR(400, "title must be a string"); // server.ts zod: title/summary optional strings — a non-string would bind into the INSERT → a 500
339
+ if (a.summary !== undefined && typeof a.summary !== "string")
340
+ return errR(400, "summary must be a string");
341
+ if (!Number.isInteger(a.baseVersion) || a.baseVersion < 0)
342
+ return errR(400, "baseVersion must be a non-negative integer");
343
+ if (!DOC_KINDS.includes(a.kind))
344
+ return errR(400, `invalid kind '${a.kind}'; one of ${DOC_KINDS.join(", ")}`);
345
+ const r = docSave(db, projectId, actor, a);
346
+ return r.ok ? okR(r.data) : errR(statusForDocErr(r.error), r.error);
347
+ }
348
+ // `db` MUST be a WRITABLE connection (docPublish does BEGIN IMMEDIATE + UPDATEs + a doc.publish event). The
349
+ // OPERATOR-only gate lives inside docPublish (shared with server.ts) — cooperative role-attribution, not
350
+ // anti-spoof on one host (§18): only the actor the daemon resolved from X-Devloop-Actor as "operator" passes.
351
+ function opDocPublish(db, projectId, actor, a) {
352
+ if (!Number.isInteger(a.version) || a.version <= 0)
353
+ return errR(400, "version must be a positive integer");
354
+ const r = docPublish(db, projectId, actor, a);
355
+ return r.ok ? okR(r.data) : errR(statusForDocErr(r.error), r.error);
356
+ }
357
+ // ─── DL-64: the discussion-board family (topic.*/post.add) — thin op-API wrappers over the shared topicstore ──
358
+ // Mirror the doc-family pattern: hand-validate the raw-JSON inputs to a clean 400 (server.ts gets these from
359
+ // zod), then delegate to topicstore (which owns the §25 role gates + round/append rules); a TopicResult error
360
+ // maps to its HTTP status via statusForTopicErr. The reads (topic.list/topic.get) take the query_only db; the
361
+ // writes (topic.open/post.add/topic.synthesize/topic.close ∈ AGENT_WRITE_OPS) take writeDb — the daemon routes.
362
+ function opTopicList(db, projectId, actor, a) {
363
+ if (a.status !== undefined && a.status !== "open" && a.status !== "closed")
364
+ return errR(400, `status must be "open" or "closed"`);
365
+ return okR(topicList(db, projectId, actor, a.status));
366
+ }
367
+ function opTopicGet(db, projectId, projectKey, a) {
368
+ if (typeof a.id !== "string")
369
+ return errR(400, "id must be a string");
370
+ const r = topicGet(db, projectId, projectKey, a.id);
371
+ return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
372
+ }
373
+ function opTopicOpen(db, projectId, actor, a) {
374
+ if (typeof a.question !== "string" || !a.question)
375
+ return errR(400, "question required (a non-empty string)");
376
+ if (!isStrArr(a.invited) || a.invited.length === 0)
377
+ return errR(400, "invited required (a non-empty array of strings)");
378
+ const r = topicOpen(db, projectId, actor, a);
379
+ return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
380
+ }
381
+ function opPostAdd(db, projectId, projectKey, actor, a) {
382
+ if (typeof a.topicId !== "string")
383
+ return errR(400, "topicId must be a string");
384
+ if (typeof a.body !== "string" || !a.body)
385
+ return errR(400, "body required (a non-empty string)");
386
+ const r = postAdd(db, projectId, projectKey, actor, a);
387
+ return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
388
+ }
389
+ function opTopicSynthesize(db, projectId, projectKey, actor, a) {
390
+ if (typeof a.topicId !== "string")
391
+ return errR(400, "topicId must be a string");
392
+ if (typeof a.body !== "string" || !a.body)
393
+ return errR(400, "body required (a non-empty string)");
394
+ if (a.nextRound !== undefined && typeof a.nextRound !== "boolean")
395
+ return errR(400, "nextRound must be a boolean");
396
+ const r = topicSynthesize(db, projectId, projectKey, actor, a);
397
+ return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
398
+ }
399
+ function opTopicClose(db, projectId, projectKey, actor, a) {
400
+ if (typeof a.topicId !== "string")
401
+ return errR(400, "topicId must be a string");
402
+ if (typeof a.decision !== "string" || !a.decision)
403
+ return errR(400, "decision required (a non-empty string)");
404
+ const r = topicClose(db, projectId, projectKey, actor, a);
405
+ return r.ok ? okR(r.data) : errR(statusForTopicErr(r.error), r.error);
406
+ }
407
+ // ─── DL-67: the IM channel family (channel.*) — thin op-API wrappers over the shared channelstore ──
408
+ // Mirror the doc/topic pattern: hand-validate the raw-JSON inputs to a clean 400 (server.ts gets these from
409
+ // zod — the DL-63 lesson), then delegate to channelstore (which owns the §16 line-build, the DL-4 roadmap
410
+ // bridge, the per-process send cap, and the channels/channel_messages writes); a ChannelResult error maps to
411
+ // its HTTP status via statusForChannelErr. channel.send/poll are ASYNC (network/dryrun) → they return Promises.
412
+ // channel.status is the only READ (query_only db, not in AGENT_WRITE_OPS); the 4 writes take writeDb.
413
+ function opChannelRegister(db, projectId, actor, a) {
414
+ if (a.provider !== "slack" && a.provider !== "lark")
415
+ return errR(400, `provider must be "slack" or "lark"`);
416
+ if (typeof a.configRef !== "string" || !a.configRef)
417
+ return errR(400, "configRef required (a non-empty string)");
418
+ if (a.secretRef !== undefined && typeof a.secretRef !== "string")
419
+ return errR(400, "secretRef must be a string");
420
+ if (typeof a.channelRef !== "string" || !a.channelRef)
421
+ return errR(400, "channelRef required (a non-empty string)");
422
+ const r = channelRegister(db, projectId, actor, a);
423
+ return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
424
+ }
425
+ // ASYNC (channelSend awaits sendVia / the DRYRUN build). Hand-validate the fields channelSend method-calls or
426
+ // binds (a wrong type would 500): ticketId (a SELECT bind), text + digest.headline (cleanLine→.replace),
427
+ // digest.openProposals (.slice/.map + cleanLine each). The numeric digest fields only feed template strings
428
+ // (coerced), so they need no guard — parity with the doc-family numeric handling.
429
+ async function opChannelSend(db, projectId, projectKey, actor, a) {
430
+ if (a.kind !== "notify" && a.kind !== "digest" && a.kind !== "reply")
431
+ return errR(400, `kind must be one of notify, digest, reply`);
432
+ if (a.ticketId !== undefined && typeof a.ticketId !== "string")
433
+ return errR(400, "ticketId must be a string");
434
+ if (a.text !== undefined && typeof a.text !== "string")
435
+ return errR(400, "text must be a string");
436
+ if (a.bailShape !== undefined && typeof a.bailShape !== "string")
437
+ return errR(400, "bailShape must be a string");
438
+ if (a.digest !== undefined) {
439
+ if (typeof a.digest !== "object" || a.digest === null || Array.isArray(a.digest))
440
+ return errR(400, "digest must be an object");
441
+ const d = a.digest;
442
+ if (d.headline !== undefined && typeof d.headline !== "string")
443
+ return errR(400, "digest.headline must be a string");
444
+ if (d.openProposals !== undefined && !isStrArr(d.openProposals))
445
+ return errR(400, "digest.openProposals must be an array of strings");
446
+ }
447
+ const r = await channelSend(db, projectId, projectKey, actor, a);
448
+ return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
449
+ }
450
+ // ASYNC (channelPoll awaits pollVia / the DRYRUN build + the DL-4 bridge). No input (the fixture rides env).
451
+ async function opChannelPoll(db, projectId, projectKey, actor) {
452
+ const r = await channelPoll(db, projectId, projectKey, actor);
453
+ return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
454
+ }
455
+ function opChannelAck(db, projectId, projectKey, actor, a) {
456
+ if (typeof a.messageId !== "string")
457
+ return errR(400, "messageId must be a string");
458
+ if (a.actedInto !== undefined && typeof a.actedInto !== "string")
459
+ return errR(400, "actedInto must be a string");
460
+ const r = channelAck(db, projectId, projectKey, actor, a);
461
+ return r.ok ? okR(r.data) : errR(statusForChannelErr(r.error), r.error);
462
+ }
463
+ function opChannelStatus(db, projectId) {
464
+ return okR(channelStatus(db, projectId)); // read; never origin/actor-gated upstream (parity with the read ticket/doc/topic ops)
465
+ }
466
+ // ─── DL-68: P7 mirror (mirror.push/mirror.status) + label/project (list_issue_labels/create_issue_label/
467
+ // get_project) — thin op-API wrappers over the shared mirrorstore/labelstore ──
468
+ // Mirror the doc/topic/channel pattern: hand-validate the raw-JSON inputs to a clean 400 (server.ts gets these
469
+ // from zod — the DL-63/DL-65 lesson), then delegate to the shared store. mirror.push / create_issue_label errors
470
+ // are all client 400s (bad input / unset-or-literal token / DL-22 empty-name / bad-kind) — no 404/409 here, and
471
+ // a failed Linear network call is counted in `failed`, never an op error. mirror.push is ASYNC (Linear network /
472
+ // dryrun build) → it returns a Promise. The 3 reads take the query_only db (not in AGENT_WRITE_OPS).
473
+ // ASYNC (mirrorPush awaits the Linear transport / the DRYRUN build). Hand-validate the shapes server.ts gets
474
+ // from zod (teamId/tokenEnv non-empty strings, projectId optional string, stateMap an object, limit int 1..500)
475
+ // so a bad type is a clean 400, never a node:sqlite bind-throw 500 or a crash inside mirrorPush.
476
+ async function opMirrorPush(db, projectId, actor, a) {
477
+ if (typeof a.teamId !== "string" || !a.teamId)
478
+ return errR(400, "teamId required (a non-empty string)");
479
+ if (typeof a.tokenEnv !== "string" || !a.tokenEnv)
480
+ return errR(400, "tokenEnv required (a non-empty string)");
481
+ if (a.projectId !== undefined && typeof a.projectId !== "string")
482
+ return errR(400, "projectId must be a string");
483
+ if (a.stateMap !== undefined && (typeof a.stateMap !== "object" || a.stateMap === null || Array.isArray(a.stateMap)))
484
+ return errR(400, "stateMap must be an object");
485
+ if (a.limit !== undefined && (!Number.isInteger(a.limit) || a.limit < 1 || a.limit > 500))
486
+ return errR(400, "limit must be an integer 1..500");
487
+ const r = await mirrorPush(db, projectId, actor, a);
488
+ return r.ok ? okR(r.data) : errR(400, r.error); // §16-safe error (isEnvName / scrubErr inside mirrorstore); the token never appears
489
+ }
490
+ function opMirrorStatus(db, projectId) {
491
+ return okR(mirrorStatus(db, projectId)); // read; coverage counts, no secret, no Linear read
492
+ }
493
+ function opListLabels(db, projectId) {
494
+ return okR(listLabels(db, projectId)); // read
495
+ }
496
+ // `db` MUST be a WRITABLE connection. Validation (DL-22 empty-name + LABEL_KINDS) lives in the shared createLabel
497
+ // so server.ts + the op-API can't drift. The attributed `label.create` event is logged HERE (the identity win).
498
+ // DL-69 note: this is the ONE op server.ts does NOT dispatch through — its create_issue_label stays a native
499
+ // createLabel call that logs NO event, so the stdio path stays byte-identical (routing it here would ADD that
500
+ // event to stdio = new behavior, out of scope for a behavior-preserving refactor). Unifying that attribution
501
+ // onto the stdio path is a deliberate behavior change for a follow-up; both paths return {name,kind} identically.
502
+ function opCreateLabel(db, projectId, actor, a) {
503
+ if (typeof a.name !== "string")
504
+ return errR(400, "name required (a string)"); // server.ts zod: name z.string() — a non-string would crash createLabel's .trim()
505
+ if (a.kind !== undefined && typeof a.kind !== "string")
506
+ return errR(400, "kind must be a string");
507
+ const r = createLabel(db, projectId, a);
508
+ if (!r.ok)
509
+ return errR(400, r.error); // DL-22: empty-name / bad-kind → a clean 400, never a fake success with a dropped row
510
+ logEvent(db, { project_id: projectId, actor, kind: "label.create", data: { name: r.data.name, kind: r.data.kind } });
511
+ return okR(r.data);
512
+ }
513
+ function opGetProject(db, projectId) {
514
+ return okR(getProject(db, projectId)); // read
515
+ }
516
+ // Dispatch one op. `db` is the WRITABLE connection for the write ops (save_issue/save_comment) and may be
517
+ // the daemon's query_only read connection for the read ops — the daemon passes the right one per op. `actor`
518
+ // is already resolved+validated by the daemon (the G1 guard). `args` is the parsed JSON body (a non-object
519
+ // body is normalized to {} by the caller). Throws only on a genuine DB fault (→ the daemon's 500 catch).
520
+ export function agentOp(op, db, projectId, projectKey, actor, args) {
521
+ switch (op) {
522
+ case "list_issues": return opListIssues(db, projectId, actor, args);
523
+ case "get_issue": return opGetIssue(db, projectId, projectKey, args);
524
+ case "save_issue": return opSaveIssue(db, projectId, projectKey, actor, args);
525
+ case "save_comment": return opSaveComment(db, projectId, actor, args);
526
+ case "list_comments": return opListComments(db, projectId, projectKey, args);
527
+ case "list_events": return opListEvents(db, projectId, args);
528
+ case "doc.list": return opDocList(db, projectId, args);
529
+ case "doc.get": return opDocGet(db, projectId, projectKey, args);
530
+ case "doc.history": return opDocHistory(db, projectId, args);
531
+ case "doc.diff": return opDocDiff(db, projectId, args);
532
+ case "doc.save": return opDocSave(db, projectId, actor, args);
533
+ case "doc.publish": return opDocPublish(db, projectId, actor, args);
534
+ case "topic.list": return opTopicList(db, projectId, actor, args);
535
+ case "topic.get": return opTopicGet(db, projectId, projectKey, args);
536
+ case "topic.open": return opTopicOpen(db, projectId, actor, args);
537
+ case "post.add": return opPostAdd(db, projectId, projectKey, actor, args);
538
+ case "topic.synthesize": return opTopicSynthesize(db, projectId, projectKey, actor, args);
539
+ case "topic.close": return opTopicClose(db, projectId, projectKey, actor, args);
540
+ case "channel.register": return opChannelRegister(db, projectId, actor, args);
541
+ case "channel.send": return opChannelSend(db, projectId, projectKey, actor, args);
542
+ case "channel.poll": return opChannelPoll(db, projectId, projectKey, actor);
543
+ case "channel.ack": return opChannelAck(db, projectId, projectKey, actor, args);
544
+ case "channel.status": return opChannelStatus(db, projectId);
545
+ case "mirror.push": return opMirrorPush(db, projectId, actor, args);
546
+ case "mirror.status": return opMirrorStatus(db, projectId);
547
+ case "list_issue_labels": return opListLabels(db, projectId);
548
+ case "create_issue_label": return opCreateLabel(db, projectId, actor, args);
549
+ case "get_project": return opGetProject(db, projectId);
550
+ }
551
+ }