@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 +55 -0
- package/dist/agentops.js +551 -0
- package/dist/channel.js +226 -0
- package/dist/channelstore.js +269 -0
- package/dist/cli-tickets.js +131 -0
- package/dist/cli.js +77 -0
- package/dist/daemon-lifecycle.js +372 -0
- package/dist/daemon.js +805 -0
- package/dist/daemonviews.js +691 -0
- package/dist/db.js +385 -0
- package/dist/docstore.js +110 -0
- package/dist/doctor.js +230 -0
- package/dist/init-service.js +206 -0
- package/dist/labelstore.js +34 -0
- package/dist/linear.js +60 -0
- package/dist/mcp-merge.js +145 -0
- package/dist/mirrorstore.js +128 -0
- package/dist/release-version.js +39 -0
- package/dist/resolve-project.js +82 -0
- package/dist/seed.js +76 -0
- package/dist/server.js +134 -0
- package/dist/shim.js +146 -0
- package/dist/ticketwrite.js +147 -0
- package/dist/tooldefs.js +147 -0
- package/dist/topicstore.js +174 -0
- package/package.json +91 -0
package/dist/channel.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
// ── Shared channel helpers (DL-26): extracted so the MCP server (server.ts) and the daemon
|
|
3
|
+
// (daemon.ts) drive ONE implementation of channel selection / cred resolution / gating and cannot
|
|
4
|
+
// drift. All are pure (db/projectId passed in); resolveCreds reads ONLY env-var NAMES (§16). ──
|
|
5
|
+
export const CHANNEL_DRYRUN = process.env.DEVLOOP_CHANNEL_DRYRUN === "1"; // test/offline: build, no network
|
|
6
|
+
export const CHANNEL_SEND_CAP = 60; // per-process loop-safety throttle
|
|
7
|
+
export const getEnabledChannel = (db, projectId) => db.prepare("SELECT * FROM channels WHERE project_id=? AND enabled=1 ORDER BY created_at LIMIT 1").get(projectId);
|
|
8
|
+
// Resolve creds from env-var NAMES (§16). DL-52: a 'webhook' channel reads its incoming-webhook URL from
|
|
9
|
+
// process.env[config_ref] and the optional Lark sign-secret from process.env[secret_ref] — still NAMES, the
|
|
10
|
+
// DB never holds the URL/secret. A 'bot' channel (default / absent) is byte-for-byte the prior behavior.
|
|
11
|
+
export const resolveCreds = (c) => c.transport === "webhook"
|
|
12
|
+
? { webhookUrl: process.env[c.config_ref], signSecret: c.secret_ref ? process.env[c.secret_ref] : undefined }
|
|
13
|
+
: c.provider === "slack"
|
|
14
|
+
? { token: process.env[c.config_ref] }
|
|
15
|
+
: { appId: process.env[c.config_ref], appSecret: c.secret_ref ? process.env[c.secret_ref] : undefined };
|
|
16
|
+
export function resolveNotifyWebhook(notify) {
|
|
17
|
+
if (!notify || typeof notify !== "object")
|
|
18
|
+
return null;
|
|
19
|
+
const n = notify;
|
|
20
|
+
const provider = n.type === "slack" ? "slack" : n.type === "lark" ? "lark" : null;
|
|
21
|
+
if (!provider)
|
|
22
|
+
return null; // only slack/lark webhooks
|
|
23
|
+
if (Array.isArray(n.events) && !n.events.includes("human-parked"))
|
|
24
|
+
return null; // operator scoped this event out (§9 events)
|
|
25
|
+
if (typeof n.webhook !== "string" && typeof n.webhookEnv !== "string")
|
|
26
|
+
return null; // no webhook configured ⇒ not a target
|
|
27
|
+
const webhookUrl = typeof n.webhook === "string" ? n.webhook
|
|
28
|
+
: typeof n.webhookEnv === "string" ? process.env[n.webhookEnv] : undefined; // env unset ⇒ undefined ⇒ fail closed at send
|
|
29
|
+
const signSecret = typeof n.secret === "string" ? n.secret
|
|
30
|
+
: typeof n.secretEnv === "string" ? process.env[n.secretEnv] : undefined;
|
|
31
|
+
return { provider, creds: { webhookUrl, signSecret } };
|
|
32
|
+
}
|
|
33
|
+
// redact anything token-shaped + bound the length before any provider error is persisted/returned/logged.
|
|
34
|
+
export const scrubErr = (m) => m.replace(/\b(xox[abp]-[\w-]+|lin_(?:api|oauth)_[\w-]+|sk-[\w-]+|ghp_[\w-]+|eyJ[\w.-]{20,})\b/g, "***").slice(0, 120);
|
|
35
|
+
// strip control chars + truncate — outbound text never carries raw bytes that could break a payload (§16/§9).
|
|
36
|
+
export const cleanLine = (s, max) => s.replace(/[\x00-\x1f\x7f]+/g, " ").trim().slice(0, max);
|
|
37
|
+
// mirror §9 notify's `curl --max-time 10`; overridable for tests (the timeout path must be fast to assert)
|
|
38
|
+
const timeoutMs = () => Number(process.env.DEVLOOP_CHANNEL_TIMEOUT_MS) || 10_000;
|
|
39
|
+
// ── timeout-wrapped JSON fetch ───────────────────────────────────────────────
|
|
40
|
+
async function httpJson(fetchImpl, url, init) {
|
|
41
|
+
const ctl = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs());
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetchImpl(url, { ...init, signal: ctl.signal });
|
|
45
|
+
const body = (await res.json().catch(() => ({})));
|
|
46
|
+
return { status: res.status, body };
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
// AbortError (timeout) / network error → a clean, secret-free message
|
|
50
|
+
throw new Error(`network error: ${e.name === "AbortError" ? "timeout" : e.name}`);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// ── Lark tenant_access_token (internal app): exchange app_id+app_secret, cache in-memory only ──
|
|
57
|
+
// §16: the token is held ONLY in this process map, never persisted/logged/returned. ~2h expiry.
|
|
58
|
+
const larkTokenCache = new Map();
|
|
59
|
+
async function larkToken(fetchImpl, appId, appSecret) {
|
|
60
|
+
const cached = larkTokenCache.get(appId);
|
|
61
|
+
if (cached && cached.expiresAt > Date.now() + 60_000)
|
|
62
|
+
return cached.token;
|
|
63
|
+
const { status, body } = await httpJson(fetchImpl, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
64
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
66
|
+
});
|
|
67
|
+
if (status !== 200 || body.code !== 0 || typeof body.tenant_access_token !== "string") {
|
|
68
|
+
throw new Error(`lark auth failed: code ${body.code ?? status}`); // code is Lark's error number, not the secret
|
|
69
|
+
}
|
|
70
|
+
const expire = typeof body.expire === "number" ? body.expire : 7200;
|
|
71
|
+
larkTokenCache.set(appId, { token: body.tenant_access_token, expiresAt: Date.now() + (expire - 120) * 1000 });
|
|
72
|
+
return body.tenant_access_token;
|
|
73
|
+
}
|
|
74
|
+
// ── OUTBOUND ─────────────────────────────────────────────────────────────────
|
|
75
|
+
// `transport` (DL-52) defaults to 'bot' so every existing caller (server.ts channel.send) is byte-for-byte
|
|
76
|
+
// unchanged; the DL-26 daemon notifier passes the channel's transport so a 'webhook' channel pings a pasted
|
|
77
|
+
// incoming-webhook URL (no bot app). The webhook send is one-way (no inbound poll over a webhook).
|
|
78
|
+
export async function sendVia(provider, creds, channelRef, msg, fetchImpl, transport = "bot") {
|
|
79
|
+
const text = msg.lines.join("\n");
|
|
80
|
+
if (transport === "webhook")
|
|
81
|
+
return sendWebhook(provider, creds, text, fetchImpl);
|
|
82
|
+
if (provider === "slack") {
|
|
83
|
+
if (!creds.token)
|
|
84
|
+
throw new Error("slack token unset");
|
|
85
|
+
const { status, body } = await httpJson(fetchImpl, "https://slack.com/api/chat.postMessage", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.token}` },
|
|
88
|
+
body: JSON.stringify({ channel: channelRef, text }),
|
|
89
|
+
});
|
|
90
|
+
if (status !== 200 || body.ok !== true)
|
|
91
|
+
throw new Error(`slack send failed: ${body.error ?? status}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// lark
|
|
95
|
+
if (!creds.appId || !creds.appSecret)
|
|
96
|
+
throw new Error("lark app_id/app_secret unset");
|
|
97
|
+
const token = await larkToken(fetchImpl, creds.appId, creds.appSecret);
|
|
98
|
+
const { status, body } = await httpJson(fetchImpl, "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
101
|
+
body: JSON.stringify({ receive_id: channelRef, msg_type: "text", content: JSON.stringify({ text }) }),
|
|
102
|
+
});
|
|
103
|
+
if (status !== 200 || body.code !== 0)
|
|
104
|
+
throw new Error(`lark send failed: ${body.code ?? status}`);
|
|
105
|
+
}
|
|
106
|
+
// ── DL-52: one-way incoming-webhook send (no bot app, no token exchange) ─────
|
|
107
|
+
// Slack: POST {text} → success = HTTP 2xx (the classic incoming webhook returns the literal text "ok", not
|
|
108
|
+
// JSON — httpJson's res.json() fails → body {}, so we gate on STATUS only). Lark custom-bot: POST
|
|
109
|
+
// {msg_type,content} (+ a {timestamp,sign} when a sign-secret is set) → success = HTTP 2xx AND body code==0.
|
|
110
|
+
// §16: webhookUrl + signSecret are creds (resolved from env NAMES by resolveCreds, never in the DB); a thrown
|
|
111
|
+
// error carries ONLY the status/code, never the URL/secret. The message is JSON-encoded — never shell-spliced.
|
|
112
|
+
const ok2xx = (s) => s >= 200 && s < 300; // incoming-webhook success gate (shared by both branches)
|
|
113
|
+
async function sendWebhook(provider, creds, text, fetchImpl) {
|
|
114
|
+
if (!creds.webhookUrl)
|
|
115
|
+
throw new Error(`${provider} webhook url unset`); // env NAME resolved to nothing ⇒ fail closed
|
|
116
|
+
if (provider === "slack") {
|
|
117
|
+
const { status } = await httpJson(fetchImpl, creds.webhookUrl, {
|
|
118
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }),
|
|
119
|
+
});
|
|
120
|
+
if (!ok2xx(status))
|
|
121
|
+
throw new Error(`slack webhook failed: ${status}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// lark custom-bot incoming webhook
|
|
125
|
+
const payload = { msg_type: "text", content: { text } };
|
|
126
|
+
if (creds.signSecret) {
|
|
127
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
128
|
+
payload.timestamp = String(ts);
|
|
129
|
+
payload.sign = larkSign(ts, creds.signSecret);
|
|
130
|
+
}
|
|
131
|
+
const { status, body } = await httpJson(fetchImpl, creds.webhookUrl, {
|
|
132
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
|
|
133
|
+
});
|
|
134
|
+
if (!ok2xx(status) || body.code !== 0)
|
|
135
|
+
throw new Error(`lark webhook failed: ${body.code ?? status}`);
|
|
136
|
+
}
|
|
137
|
+
// Lark custom-bot signature: base64(HMAC-SHA256(key="<ts>\n<secret>", data="")) — the "<ts>\n<secret>" is the
|
|
138
|
+
// HMAC KEY and the signed message is EMPTY (Lark's scheme; mirrors conventions §9's notify sign helper).
|
|
139
|
+
function larkSign(timestamp, secret) {
|
|
140
|
+
return createHmac("sha256", `${timestamp}\n${secret}`).update("").digest("base64");
|
|
141
|
+
}
|
|
142
|
+
// ── INBOUND (history read; cursor = provider monotonic marker) ───────────────
|
|
143
|
+
// Returns normalized human-operator messages strictly AFTER `cursor`, plus the new cursor. The
|
|
144
|
+
// bot's OWN messages are dropped (SECURITY: never ingest our own digest/reply as "operator
|
|
145
|
+
// direction" — a self-echo/injection loop vector). authorRef is the OPAQUE provider sender id —
|
|
146
|
+
// it is NEVER equated with operator authority (the instruction-source boundary, §16).
|
|
147
|
+
// PAGINATED (Codex review): a single 50-item page would SKIP older messages when >1 page arrived
|
|
148
|
+
// since the cursor (advancing to the page max past unfetched older ones). We page until the provider
|
|
149
|
+
// reports no more, with a runaway guard that THROWS (cursor unadvanced, surfaced) rather than silently
|
|
150
|
+
// skip. normalize()'s strictly-after-cursor filter + the UNIQUE dedup make over-fetch harmless.
|
|
151
|
+
const MAX_POLL_PAGES = 40; // a regular loop poll exits after 1 page; this only bites a huge backlog
|
|
152
|
+
export async function pollVia(provider, creds, channelRef, cursor, fetchImpl) {
|
|
153
|
+
const collected = [];
|
|
154
|
+
if (provider === "slack") {
|
|
155
|
+
if (!creds.token)
|
|
156
|
+
throw new Error("slack token unset");
|
|
157
|
+
let pageCursor;
|
|
158
|
+
let pages = 0;
|
|
159
|
+
for (;;) {
|
|
160
|
+
const p = new URLSearchParams({ channel: channelRef, limit: "100" });
|
|
161
|
+
if (cursor)
|
|
162
|
+
p.set("oldest", cursor);
|
|
163
|
+
if (pageCursor)
|
|
164
|
+
p.set("cursor", pageCursor);
|
|
165
|
+
const { status, body } = await httpJson(fetchImpl, `https://slack.com/api/conversations.history?${p}`, { headers: { Authorization: `Bearer ${creds.token}` } });
|
|
166
|
+
if (status !== 200 || body.ok !== true)
|
|
167
|
+
throw new Error(`slack history failed: ${body.error ?? status}`);
|
|
168
|
+
for (const m of (Array.isArray(body.messages) ? body.messages : [])) {
|
|
169
|
+
if (m.bot_id || m.subtype === "bot_message")
|
|
170
|
+
continue; // self/bot echo guard (security)
|
|
171
|
+
collected.push({ providerMsgId: String(m.ts), authorRef: String(m.user ?? "unknown"), text: String(m.text ?? ""), providerTs: String(m.ts) });
|
|
172
|
+
}
|
|
173
|
+
const meta = body.response_metadata;
|
|
174
|
+
pageCursor = body.has_more && meta?.next_cursor ? String(meta.next_cursor) : undefined;
|
|
175
|
+
if (!pageCursor)
|
|
176
|
+
break;
|
|
177
|
+
if (++pages >= MAX_POLL_PAGES)
|
|
178
|
+
throw new Error("slack history exceeded max pages (backlog too large for one poll; widen cadence)");
|
|
179
|
+
}
|
|
180
|
+
return normalize(collected, cursor);
|
|
181
|
+
}
|
|
182
|
+
// lark
|
|
183
|
+
if (!creds.appId || !creds.appSecret)
|
|
184
|
+
throw new Error("lark app_id/app_secret unset");
|
|
185
|
+
const token = await larkToken(fetchImpl, creds.appId, creds.appSecret);
|
|
186
|
+
let pageToken;
|
|
187
|
+
let pages = 0;
|
|
188
|
+
for (;;) {
|
|
189
|
+
const p = new URLSearchParams({ container_id_type: "chat", container_id: channelRef, page_size: "50" });
|
|
190
|
+
if (cursor)
|
|
191
|
+
p.set("start_time", cursor);
|
|
192
|
+
if (pageToken)
|
|
193
|
+
p.set("page_token", pageToken);
|
|
194
|
+
const { status, body } = await httpJson(fetchImpl, `https://open.feishu.cn/open-apis/im/v1/messages?${p}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
195
|
+
if (status !== 200 || body.code !== 0)
|
|
196
|
+
throw new Error(`lark history failed: ${body.code ?? status}`);
|
|
197
|
+
const data = body.data;
|
|
198
|
+
for (const m of (Array.isArray(data?.items) ? data.items : [])) {
|
|
199
|
+
if (m.sender?.sender_type === "app")
|
|
200
|
+
continue; // self/app echo guard
|
|
201
|
+
collected.push({ providerMsgId: String(m.message_id), authorRef: String(m.sender?.id ?? "unknown"), text: larkText(m.body), providerTs: String(m.create_time) });
|
|
202
|
+
}
|
|
203
|
+
pageToken = data?.has_more && data?.page_token ? String(data.page_token) : undefined;
|
|
204
|
+
if (!pageToken)
|
|
205
|
+
break;
|
|
206
|
+
if (++pages >= MAX_POLL_PAGES)
|
|
207
|
+
throw new Error("lark history exceeded max pages (backlog too large for one poll; widen cadence)");
|
|
208
|
+
}
|
|
209
|
+
return normalize(collected, cursor);
|
|
210
|
+
}
|
|
211
|
+
function larkText(body) {
|
|
212
|
+
try {
|
|
213
|
+
const c = JSON.parse(String(body?.content ?? "{}"));
|
|
214
|
+
return String(c.text ?? "");
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return "";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// strictly-after-cursor + advance the cursor to the max provider_ts ACTUALLY returned (never the
|
|
221
|
+
// window end) — so a message can never be skipped by an over-eager cursor advance.
|
|
222
|
+
function normalize(msgs, cursor) {
|
|
223
|
+
const fresh = msgs.filter((m) => cursor === null || m.providerTs > cursor);
|
|
224
|
+
const next = fresh.reduce((acc, m) => (acc === null || m.providerTs > acc ? m.providerTs : acc), cursor);
|
|
225
|
+
return { messages: fresh, cursor: next };
|
|
226
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Shared P6 IM-channel store (DL-67) — the channel register/send/poll/ack/status HANDLER logic + the DL-4
|
|
2
|
+
// roadmap-over-chat bridge, used by BOTH the MCP server (server.ts) and the daemon op-API (agentops.ts). The
|
|
3
|
+
// provider TRANSPORT (send/poll/cred-resolution/gating/scrub) stays in channel.ts and is reused AS-IS; this
|
|
4
|
+
// module is the handler layer channel.ts's transport serves — the docstore.ts/topicstore.ts precedent that
|
|
5
|
+
// lets the stdio server and the daemon op-API share ONE implementation and never drift.
|
|
6
|
+
//
|
|
7
|
+
// SIDE-EFFECT-FREE entrypoint (no top-level db; identity (actor) + scope (projectId/projectKey) are passed in
|
|
8
|
+
// by the caller — the daemon resolves the actor from X-Devloop-Actor, the stdio server passes its ACTOR — so
|
|
9
|
+
// every channel event is attributed to the REAL caller on both paths, exactly the topicstore precedent). The
|
|
10
|
+
// only module state is the per-process send throttle (below), the same loop-safety cap server.ts held before.
|
|
11
|
+
//
|
|
12
|
+
// §16: secrets are read by channel.ts from env NAMES (config_ref/secret_ref) SERVER-SIDE; this module NEVER
|
|
13
|
+
// stores/returns/logs a token (a failed send throws the scrubbed status, never the URL/secret — DL-52). §17
|
|
14
|
+
// firewall (structural): every write here is an INSERT/UPDATE on the `channels` / `channel_messages` DB tables
|
|
15
|
+
// — there is NO filesystem path anywhere in this module, so a channel op can never target a SKILL/conventions/
|
|
16
|
+
// code file; the only external effect is the network send via channel.ts's transport. The DL-4 bridge lands an
|
|
17
|
+
// operator chat edit as a roadmap DRAFT only — the operator-publish gate (docstore) is never bypassed, so an
|
|
18
|
+
// injected edit can never go live.
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { nowIso, logEvent } from "./db.js";
|
|
21
|
+
import { sendVia, pollVia, getEnabledChannel, resolveCreds, scrubErr, cleanLine, CHANNEL_DRYRUN, CHANNEL_SEND_CAP } from "./channel.js";
|
|
22
|
+
import { resolveDoc, latestVersion, docSave } from "./docstore.js";
|
|
23
|
+
// Map a channelstore error to an HTTP status: a missing inbound message (ack) → 404; everything else
|
|
24
|
+
// (no-channel-register-first / reply-needs-text / send-cap / send|poll failed / a non-env-NAME *Ref) → 400.
|
|
25
|
+
// (No 403/409 here: the op-API's origin/actor/mode gates are upstream in the daemon, and channel has no CAS.)
|
|
26
|
+
export const statusForChannelErr = (msg) => /^no inbound message\b/.test(msg) ? 404 : 400;
|
|
27
|
+
// §16 (Codex review): a *Ref is an ENV-VAR NAME, never a literal secret — reject anything that isn't an
|
|
28
|
+
// env-name shape, and anything that looks like an actual token, so a caller can't persist a secret to the DB.
|
|
29
|
+
// Exported because the P7 mirror's tokenEnv check (server.ts) reuses the SAME validator — one definition, no drift.
|
|
30
|
+
const TOKEN_PREFIXES = /^(xox[abp]-|lin_api_|lin_oauth_|sk-|ghp_|Bearer\s)/i;
|
|
31
|
+
export const isEnvName = (s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s) && !TOKEN_PREFIXES.test(s) && s.length <= 100;
|
|
32
|
+
// strip control chars + truncate — channel.ts's cleanLine IS server.ts's old local `clean` (byte-identical)
|
|
33
|
+
const clean = cleanLine;
|
|
34
|
+
const INBOX_GC_DAYS = 14;
|
|
35
|
+
// per-process send throttle (was server.ts's `channelSendsThisProcess`): module-scope here so the stdio server
|
|
36
|
+
// AND the daemon op-API each keep their OWN per-process cap — identical loop-safety semantics on both paths.
|
|
37
|
+
let sendsThisProcess = 0;
|
|
38
|
+
export function channelRegister(db, projectId, actor, a) {
|
|
39
|
+
if (!isEnvName(a.configRef))
|
|
40
|
+
return { ok: false, error: `configRef must be an ENV-VAR NAME (e.g. DEVLOOP_CHANNEL_TOKEN), not the secret value itself` };
|
|
41
|
+
if (a.secretRef && !isEnvName(a.secretRef))
|
|
42
|
+
return { ok: false, error: `secretRef must be an ENV-VAR NAME, not the secret value itself` };
|
|
43
|
+
const t = nowIso();
|
|
44
|
+
const existing = db.prepare("SELECT id FROM channels WHERE project_id=? AND provider=? AND channel_ref=?").get(projectId, a.provider, a.channelRef);
|
|
45
|
+
if (existing) {
|
|
46
|
+
db.prepare("UPDATE channels SET config_ref=?, secret_ref=?, enabled=1, updated_at=? WHERE id=?").run(a.configRef, a.secretRef ?? null, t, existing.id);
|
|
47
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.register", data: { provider: a.provider, channelRef: a.channelRef, updated: true } });
|
|
48
|
+
return { ok: true, data: { id: existing.id, provider: a.provider, channelRef: a.channelRef, updated: true } };
|
|
49
|
+
}
|
|
50
|
+
const id = randomUUID();
|
|
51
|
+
db.prepare("INSERT INTO channels(id,project_id,provider,config_ref,secret_ref,channel_ref,enabled,created_at,updated_at) VALUES (?,?,?,?,?,?,1,?,?)")
|
|
52
|
+
.run(id, projectId, a.provider, a.configRef, a.secretRef ?? null, a.channelRef, t, t);
|
|
53
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.register", data: { provider: a.provider, channelRef: a.channelRef } });
|
|
54
|
+
return { ok: true, data: { id, provider: a.provider, channelRef: a.channelRef } };
|
|
55
|
+
}
|
|
56
|
+
export async function channelSend(db, projectId, projectKey, actor, a) {
|
|
57
|
+
const ch = getEnabledChannel(db, projectId);
|
|
58
|
+
if (!ch)
|
|
59
|
+
return { ok: false, error: `no enabled channel for ${projectKey} — channel.register first` };
|
|
60
|
+
const lines = [];
|
|
61
|
+
if (a.kind === "notify") {
|
|
62
|
+
const tk = a.ticketId ? db.prepare("SELECT title FROM tickets WHERE id=? AND project_id=?").get(a.ticketId, projectId) : undefined;
|
|
63
|
+
const title = tk ? clean(tk.title, 80) : a.ticketId ? `(unknown ${a.ticketId})` : "(no ticket)";
|
|
64
|
+
lines.push(`[${projectKey}] ${a.bailShape ?? "blocked"}: ${a.ticketId ?? "—"} ${title}`);
|
|
65
|
+
}
|
|
66
|
+
else if (a.kind === "digest") {
|
|
67
|
+
const d = a.digest ?? {};
|
|
68
|
+
lines.push(`[${projectKey}] dev-loop digest`);
|
|
69
|
+
if (d.headline)
|
|
70
|
+
lines.push(clean(d.headline, 200));
|
|
71
|
+
lines.push(`topics chaired ${d.topicsChaired ?? 0} · decisions ${d.decisionsClosed ?? 0} · roadmap draft v${d.roadmapDraftVersion ?? "—"}`);
|
|
72
|
+
if (d.throughput)
|
|
73
|
+
lines.push(`tickets: done ${d.throughput.done ?? 0} · in-review ${d.throughput.inReview ?? 0} · todo ${d.throughput.todo ?? 0}`);
|
|
74
|
+
if (d.openProposals?.length)
|
|
75
|
+
lines.push(`open proposals: ${d.openProposals.slice(0, 20).map((p) => clean(p, 24)).join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (!a.text)
|
|
79
|
+
return { ok: false, error: "reply requires text" };
|
|
80
|
+
lines.push(clean(a.text, 800));
|
|
81
|
+
}
|
|
82
|
+
const msg = { kind: a.kind, lines };
|
|
83
|
+
if (CHANNEL_DRYRUN) {
|
|
84
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.send", data: { kind: a.kind, dryrun: true } });
|
|
85
|
+
return { ok: true, data: { ok: true, dryrun: true, provider: ch.provider, kind: a.kind, lines } };
|
|
86
|
+
}
|
|
87
|
+
if (sendsThisProcess >= CHANNEL_SEND_CAP)
|
|
88
|
+
return { ok: false, error: `channel send cap (${CHANNEL_SEND_CAP}/process) reached — loop-safety throttle` };
|
|
89
|
+
sendsThisProcess++;
|
|
90
|
+
try {
|
|
91
|
+
await sendVia(ch.provider, resolveCreds(ch), ch.channel_ref, msg, fetch);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return { ok: false, error: `channel send failed: ${scrubErr(e.message)}` }; // secret-free by construction (channel.ts) + scrubbed
|
|
95
|
+
}
|
|
96
|
+
const t = nowIso();
|
|
97
|
+
db.prepare("INSERT INTO channel_messages(id,channel_id,project_id,direction,provider_msg_id,body,kind,created_at) VALUES (?,?,?,?,?,?,?,?)")
|
|
98
|
+
.run(randomUUID(), ch.id, projectId, "outbound", null, lines.join(" | ").slice(0, 500), a.kind, t);
|
|
99
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.send", data: { kind: a.kind } });
|
|
100
|
+
return { ok: true, data: { ok: true, provider: ch.provider, kind: a.kind } };
|
|
101
|
+
}
|
|
102
|
+
// ── DL-4: roadmap-over-chat bridge (handled INSIDE channelPoll) — moved verbatim from server.ts ────────────
|
|
103
|
+
// Recognize an operator roadmap command in an inbound message: a bare `roadmap` (summary) or `roadmap edit
|
|
104
|
+
// <text>` (an edit). null ⇒ a normal message → the Director's inbox. There is deliberately NO publish command
|
|
105
|
+
// — publishing stays the operator-actor doc.publish gate (DL-3/§25), so a chat message can never push the
|
|
106
|
+
// roadmap live; an edit only ever lands as a DRAFT. A bare `roadmap: <musing>` is NOT captured as an edit.
|
|
107
|
+
function parseRoadmapCommand(text) {
|
|
108
|
+
const t = text.trim();
|
|
109
|
+
const m = t.match(/^\/?roadmap\s+edit\s+([\s\S]+)$/i);
|
|
110
|
+
if (m) {
|
|
111
|
+
const body = m[1].trim();
|
|
112
|
+
if (body)
|
|
113
|
+
return { type: "edit", body };
|
|
114
|
+
}
|
|
115
|
+
if (/^\/?roadmap(?:\s+(?:show|view|status))?\??$/i.test(t))
|
|
116
|
+
return { type: "summary" };
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// Scrub channel-originated content before it lands in a doc or an outbound summary (§16/AC4 — no secrets or
|
|
120
|
+
// PII pasted from chat). Broadened past the loop's own creds to common third-party secret shapes + PII; secret
|
|
121
|
+
// shapes never occur in real roadmap prose so aggressive is safe, the operator reviews the DRAFT before
|
|
122
|
+
// publishing, so light over-redaction is acceptable. No truncation here — the caller bounds length (DL-4).
|
|
123
|
+
const scrubChannel = (s) => s
|
|
124
|
+
.replace(/\b(xox[abprs]-[\w-]+|xapp-[\w-]+|AKIA[0-9A-Z]{16}|AIza[\w-]{35}|gh[opusr]_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9-]+|[sr]k_(?:live|test)_[A-Za-z0-9]+|lin_(?:api|oauth)_[\w-]+|eyJ[\w.-]{20,})\b/g, "***") // API tokens/keys
|
|
125
|
+
.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "***") // email
|
|
126
|
+
.replace(/\b\+?\d{1,4}[ .-]\(?\d{2,4}\)?[ .-]\d{3,4}(?:[ .-]\d{2,4})?\b/g, "***") // phone (multi-segment, avoids plain numbers)
|
|
127
|
+
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "***") // IPv4
|
|
128
|
+
.replace(/(?:\d[ -]?){13,19}/g, (m) => m.replace(/\D/g, "").length >= 13 ? "***" : m); // card-shaped digit run
|
|
129
|
+
// A §16-safe one-shot summary of the current kind:"roadmap" doc for the channel (DL-4 AC1): title, status,
|
|
130
|
+
// versions, and a bounded, scrubbed excerpt — never a secret/PII, never the full history.
|
|
131
|
+
function roadmapSummaryLines(db, projectId, projectKey) {
|
|
132
|
+
const d = resolveDoc(db, projectId, undefined, "roadmap");
|
|
133
|
+
if (!d)
|
|
134
|
+
return [`[${projectKey}] roadmap — no roadmap document yet`];
|
|
135
|
+
const latest = latestVersion(db, d.id), published = d.current_version;
|
|
136
|
+
const head = `[${projectKey}] roadmap "${clean(d.title, 80)}" — ${published > 0 ? `published v${published}` : "unpublished"}${latest > published ? `, latest draft v${latest}` : ""}`;
|
|
137
|
+
const v = latest > 0 ? db.prepare("SELECT body FROM document_versions WHERE doc_id=? AND version=?").get(d.id, latest) : undefined;
|
|
138
|
+
return [head, v?.body ? scrubChannel(clean(v.body, 600)) : "(empty)"];
|
|
139
|
+
}
|
|
140
|
+
// Send pre-built lines to the channel as a reply (the roadmap auto-reply). Respects CHANNEL_DRYRUN (log, no
|
|
141
|
+
// network) + the per-process send cap; the token never crosses this boundary.
|
|
142
|
+
async function sendChannelLines(db, projectId, actor, ch, lines) {
|
|
143
|
+
if (CHANNEL_DRYRUN) {
|
|
144
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.send", data: { kind: "reply", dryrun: true } });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (sendsThisProcess >= CHANNEL_SEND_CAP)
|
|
148
|
+
return;
|
|
149
|
+
sendsThisProcess++;
|
|
150
|
+
await sendVia(ch.provider, resolveCreds(ch), ch.channel_ref, { kind: "reply", lines }, fetch);
|
|
151
|
+
db.prepare("INSERT INTO channel_messages(id,channel_id,project_id,direction,provider_msg_id,body,kind,created_at) VALUES (?,?,?,?,?,?,?,?)")
|
|
152
|
+
.run(randomUUID(), ch.id, projectId, "outbound", null, lines.join(" | ").slice(0, 500), "reply", nowIso());
|
|
153
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.send", data: { kind: "reply" } });
|
|
154
|
+
}
|
|
155
|
+
// ── channel.poll — TWO-PHASE (fetch lock-free → atomic dedup-insert+cursor advance) + the DL-4 bridge ─────
|
|
156
|
+
export async function channelPoll(db, projectId, projectKey, actor) {
|
|
157
|
+
const ch = getEnabledChannel(db, projectId);
|
|
158
|
+
if (!ch)
|
|
159
|
+
return { ok: false, error: `no enabled channel for ${projectKey} — channel.register first` };
|
|
160
|
+
const cursor = ch.inbound_cursor; // PHASE 1 — lock-free read
|
|
161
|
+
// PHASE 2 — fetch OUTSIDE any lock (network I/O must never be held under busy_timeout)
|
|
162
|
+
let fetched;
|
|
163
|
+
try {
|
|
164
|
+
if (CHANNEL_DRYRUN) {
|
|
165
|
+
const fixture = JSON.parse(process.env.DEVLOOP_CHANNEL_FIXTURE ?? "[]");
|
|
166
|
+
const fresh = fixture.filter((m) => cursor === null || m.providerTs > cursor);
|
|
167
|
+
const next = fresh.reduce((acc, m) => (acc === null || m.providerTs > acc ? m.providerTs : acc), cursor);
|
|
168
|
+
fetched = { messages: fresh, cursor: next };
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
fetched = await pollVia(ch.provider, resolveCreds(ch), ch.channel_ref, cursor, fetch);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
return { ok: false, error: `channel poll failed: ${scrubErr(e.message)}` }; // cursor unchanged → next fire retries
|
|
176
|
+
}
|
|
177
|
+
const t = nowIso();
|
|
178
|
+
db.exec("BEGIN IMMEDIATE"); // PHASE 3 — atomic dedup-insert + cursor advance
|
|
179
|
+
try {
|
|
180
|
+
// ON CONFLICT DO NOTHING: suppress ONLY the dedup-key conflict — any OTHER constraint failure must throw → ROLLBACK → cursor NOT advanced.
|
|
181
|
+
const ins = db.prepare("INSERT INTO channel_messages(id,channel_id,project_id,direction,provider_msg_id,author_ref,body,acted,created_at,provider_ts) VALUES (?,?,?,?,?,?,?,0,?,?) ON CONFLICT(channel_id,direction,provider_msg_id) DO NOTHING");
|
|
182
|
+
let inserted = 0;
|
|
183
|
+
for (const m of fetched.messages) {
|
|
184
|
+
const r = ins.run(randomUUID(), ch.id, projectId, "inbound", m.providerMsgId, m.authorRef, m.text, t, m.providerTs);
|
|
185
|
+
if (r.changes > 0)
|
|
186
|
+
inserted++;
|
|
187
|
+
}
|
|
188
|
+
if (fetched.cursor !== null)
|
|
189
|
+
db.prepare("UPDATE channels SET inbound_cursor=?, last_poll_at=? WHERE id=?").run(fetched.cursor, t, ch.id);
|
|
190
|
+
else
|
|
191
|
+
db.prepare("UPDATE channels SET last_poll_at=? WHERE id=?").run(t, ch.id);
|
|
192
|
+
db.prepare("DELETE FROM channel_messages WHERE project_id=? AND direction='inbound' AND acted=1 AND created_at < ?")
|
|
193
|
+
.run(projectId, new Date(Date.now() - INBOX_GC_DAYS * 86400000).toISOString());
|
|
194
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.poll", data: { new: inserted } });
|
|
195
|
+
db.exec("COMMIT");
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
try {
|
|
199
|
+
db.exec("ROLLBACK");
|
|
200
|
+
}
|
|
201
|
+
catch { /* */ }
|
|
202
|
+
throw e;
|
|
203
|
+
}
|
|
204
|
+
// ── DL-4: auto-handle roadmap commands among the now-ingested inbox — a §16-safe summary reply, or a
|
|
205
|
+
// roadmap DRAFT via doc.save (NEVER published). Run OUTSIDE the poll txn (docSave has its own; sendVia
|
|
206
|
+
// is network). Handled messages are ack'd so they never reach the Director's `pending`; non-roadmap
|
|
207
|
+
// messages flow through unchanged. A chat author is UNVERIFIED, but a draft is non-live + reversible
|
|
208
|
+
// (§16/§25) — only the operator can publish it, so an injected edit can never go live.
|
|
209
|
+
const roadmapHandled = [];
|
|
210
|
+
for (const msg of db.prepare("SELECT id,body FROM channel_messages WHERE project_id=? AND direction='inbound' AND acted=0 ORDER BY provider_ts").all(projectId)) {
|
|
211
|
+
const cmd = parseRoadmapCommand(msg.body);
|
|
212
|
+
if (!cmd)
|
|
213
|
+
continue;
|
|
214
|
+
// ATOMIC CLAIM (cross-process safety §7/§18/§26): flip acted 0→1 in one statement, proceed ONLY if we won
|
|
215
|
+
// it, so a second overlapping poll (another Director fire / a 2nd CLI) can't double-process the command.
|
|
216
|
+
if (db.prepare("UPDATE channel_messages SET acted=1, acted_into='roadmap:handling' WHERE id=? AND project_id=? AND direction='inbound' AND acted=0").run(msg.id, projectId).changes === 0)
|
|
217
|
+
continue;
|
|
218
|
+
let lines, actedInto, result;
|
|
219
|
+
if (cmd.type === "summary") {
|
|
220
|
+
lines = roadmapSummaryLines(db, projectId, projectKey);
|
|
221
|
+
actedInto = "roadmap:summary";
|
|
222
|
+
result = "summary";
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const existing = resolveDoc(db, projectId, undefined, "roadmap");
|
|
226
|
+
const r = docSave(db, projectId, actor, { slug: existing?.slug ?? "roadmap", kind: "roadmap", body: scrubChannel(cmd.body).slice(0, 8000), baseVersion: existing ? latestVersion(db, existing.id) : 0, summary: "via channel" });
|
|
227
|
+
if (r.ok) {
|
|
228
|
+
lines = [`[${projectKey}] roadmap draft v${r.data.version} saved from chat — awaiting operator publish`];
|
|
229
|
+
actedInto = `roadmap:draft:v${r.data.version}`;
|
|
230
|
+
result = `draft v${r.data.version}`;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
lines = [`[${projectKey}] roadmap edit not applied — ${clean(r.error, 160)}`];
|
|
234
|
+
actedInto = "roadmap:edit-rejected";
|
|
235
|
+
result = "rejected";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await sendChannelLines(db, projectId, actor, ch, lines);
|
|
240
|
+
}
|
|
241
|
+
catch { /* a failed reply must not wedge the poll or undo a persisted draft */ }
|
|
242
|
+
db.prepare("UPDATE channel_messages SET acted_into=? WHERE id=? AND project_id=?").run(actedInto, msg.id, projectId);
|
|
243
|
+
roadmapHandled.push({ messageId: msg.id, type: cmd.type, result, lines });
|
|
244
|
+
}
|
|
245
|
+
const pending = db.prepare("SELECT id,author_ref,body,provider_ts FROM channel_messages WHERE project_id=? AND direction='inbound' AND acted=0 ORDER BY provider_ts")
|
|
246
|
+
.all(projectId);
|
|
247
|
+
return { ok: true, data: { new: fetched.messages.length, cursor: fetched.cursor, roadmapHandled, pending: pending.map((p) => ({ messageId: p.id, author: p.author_ref, text: p.body, ts: p.provider_ts })) } };
|
|
248
|
+
}
|
|
249
|
+
export function channelAck(db, projectId, projectKey, actor, a) {
|
|
250
|
+
const r = db.prepare("UPDATE channel_messages SET acted=1, acted_into=? WHERE id=? AND project_id=? AND direction='inbound'")
|
|
251
|
+
.run(a.actedInto ?? null, a.messageId, projectId);
|
|
252
|
+
if (r.changes === 0)
|
|
253
|
+
return { ok: false, error: `no inbound message ${a.messageId} in ${projectKey}` };
|
|
254
|
+
logEvent(db, { project_id: projectId, actor, kind: "channel.ack", data: { messageId: a.messageId, actedInto: a.actedInto ?? null } });
|
|
255
|
+
return { ok: true, data: { messageId: a.messageId, acted: true, actedInto: a.actedInto ?? null } };
|
|
256
|
+
}
|
|
257
|
+
// ── channel.status (read; never origin/actor-gated — parity with the read ticket/doc/topic ops) ──────────
|
|
258
|
+
// Returns the ENV-VAR NAMES' SET-or-not as booleans, NEVER the secret values (§16).
|
|
259
|
+
export function channelStatus(db, projectId) {
|
|
260
|
+
const ch = getEnabledChannel(db, projectId);
|
|
261
|
+
if (!ch)
|
|
262
|
+
return { configured: false };
|
|
263
|
+
const pending = db.prepare("SELECT count(*) c FROM channel_messages WHERE project_id=? AND direction='inbound' AND acted=0").get(projectId).c;
|
|
264
|
+
return {
|
|
265
|
+
configured: true, provider: ch.provider, channelRef: ch.channel_ref, cursor: ch.inbound_cursor, lastPoll: ch.last_poll_at,
|
|
266
|
+
configRefSet: process.env[ch.config_ref] !== undefined, secretRefSet: ch.secret_ref ? process.env[ch.secret_ref] !== undefined : null,
|
|
267
|
+
inboxPending: pending,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `dev-loop tickets` + `dev-loop ticket <id>` — the read-only TERMINAL board-read client (DL-90).
|
|
3
|
+
// The Vision (docs/STRATEGY.md §Vision) names the `dev-loop` CLI as one of the interchangeable board-READ
|
|
4
|
+
// clients (alongside the stdio MCP shim + the localhost web UI). The web UI binds 127.0.0.1 only (§16), so a
|
|
5
|
+
// terminal-first / SSH'd operator had no way to see the board. This closes that gap — the `gh issue list`/
|
|
6
|
+
// `gh issue view` of the hub. Opens the hub SoR the SAME way server.ts/seed.ts do (openDb + DEVLOOP_HUB_DB) and
|
|
7
|
+
// resolves the project via the SAME DEVLOOP_PROJECT/cwd ladder (resolveIdentity, §11). STRICTLY read-only:
|
|
8
|
+
// `PRAGMA query_only` after open makes any write/event throw; needs NO daemon and NO DEVLOOP_ACTOR (identity is
|
|
9
|
+
// irrelevant to a read). Routed from cli.ts (`tickets`/`ticket` → this file with the subcommand as argv[0]).
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { openDb } from "./db.js";
|
|
12
|
+
import { resolveIdentity } from "./resolve-project.js";
|
|
13
|
+
import { findProject } from "./seed.js";
|
|
14
|
+
const TERMINAL = new Set(["Done", "Canceled", "Duplicate"]); // §3 terminal states — hidden unless --all
|
|
15
|
+
const PRIORITY = { 1: "Urgent", 2: "High", 3: "Medium", 4: "Low", 0: "None" }; // §5 (mirrors daemonviews)
|
|
16
|
+
const prioOf = (p) => PRIORITY[p] ?? String(p);
|
|
17
|
+
// owner = the §4 routing label (mirrors daemonviews.ownerOf); the CLI keeps a local copy to stay decoupled
|
|
18
|
+
// from the HTML views module, matching the codebase's existing per-module toTicket copies.
|
|
19
|
+
const ownerOf = (labels) => (labels.includes("pm") ? "pm" : labels.includes("qa") ? "qa" : "—");
|
|
20
|
+
const parseArr = (j) => { try {
|
|
21
|
+
const a = JSON.parse(j);
|
|
22
|
+
return Array.isArray(a) ? a : [];
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
} };
|
|
27
|
+
// `dev-loop tickets [--all] [--state <name>] [--type <T>] [--owner <pm|qa>] [--label <name>] [--q <text>|<text>]` — board list, one line per ticket.
|
|
28
|
+
function listTickets(db, projectId, args) {
|
|
29
|
+
let all = false, state, q, type, owner, label;
|
|
30
|
+
for (let i = 0; i < args.length; i++) {
|
|
31
|
+
const a = args[i];
|
|
32
|
+
if (a === "--all")
|
|
33
|
+
all = true;
|
|
34
|
+
else if (a === "--state" || a === "--q" || a === "--type" || a === "--owner" || a === "--label") {
|
|
35
|
+
const v = args[++i];
|
|
36
|
+
if (v === undefined) {
|
|
37
|
+
console.error(`dev-loop: ${a} needs a value`);
|
|
38
|
+
return 2;
|
|
39
|
+
} // a dangling flag is a usage error, not a silent no-filter (DL-91)
|
|
40
|
+
if (a === "--state")
|
|
41
|
+
state = v;
|
|
42
|
+
else if (a === "--q")
|
|
43
|
+
q = v;
|
|
44
|
+
else if (a === "--type")
|
|
45
|
+
type = v;
|
|
46
|
+
else if (a === "--owner")
|
|
47
|
+
owner = v;
|
|
48
|
+
else
|
|
49
|
+
label = v;
|
|
50
|
+
}
|
|
51
|
+
else if (a.startsWith("-")) {
|
|
52
|
+
console.error(`dev-loop: unknown flag '${a}'`);
|
|
53
|
+
return 2;
|
|
54
|
+
} // DL-93: reject unknown flags — never swallow the following arg as positional --q (the `--type Bug` footgun)
|
|
55
|
+
else if (q === undefined)
|
|
56
|
+
q = a; // positional free-text (parity with the web board's `q`)
|
|
57
|
+
}
|
|
58
|
+
// board order (priority ASC, updated_at DESC) — verbatim from daemonviews.boardPage so the terminal view matches the web view.
|
|
59
|
+
let rows = db.prepare("SELECT id,title,type,state,assignee,priority,labels,updated_at FROM tickets WHERE project_id=? ORDER BY priority ASC, updated_at DESC").all(projectId);
|
|
60
|
+
if (!all && !state)
|
|
61
|
+
rows = rows.filter((r) => !TERMINAL.has(r.state)); // default (only when no explicit --state): non-terminal only — an explicit --state always wins, incl. a terminal one (DL-91)
|
|
62
|
+
if (state)
|
|
63
|
+
rows = rows.filter((r) => r.state === state);
|
|
64
|
+
if (type)
|
|
65
|
+
rows = rows.filter((r) => r.type === type); // DL-93: exact type match (r.type already selected at the query); composes (AND) with the others & is orthogonal to the non-terminal default
|
|
66
|
+
if (owner)
|
|
67
|
+
rows = rows.filter((r) => ownerOf(parseArr(r.labels)) === owner); // DL-93: owner via the §4 routing-label helper (same helper the render uses below)
|
|
68
|
+
if (label)
|
|
69
|
+
rows = rows.filter((r) => parseArr(r.labels).includes(label)); // DL-93: arbitrary label membership (e.g. --label blocked / edge-case / tech-debt)
|
|
70
|
+
if (q) {
|
|
71
|
+
const needle = q.toLowerCase();
|
|
72
|
+
rows = rows.filter((r) => r.id.toLowerCase().includes(needle) || (r.title ?? "").toLowerCase().includes(needle));
|
|
73
|
+
}
|
|
74
|
+
if (rows.length === 0) {
|
|
75
|
+
console.log("No tickets.");
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
for (const r of rows) {
|
|
79
|
+
console.log([
|
|
80
|
+
r.id.padEnd(7), r.state.padEnd(13), r.type.padEnd(11),
|
|
81
|
+
ownerOf(parseArr(r.labels)).padEnd(2), prioOf(r.priority).padEnd(6), r.title,
|
|
82
|
+
].join(" · "));
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
// `dev-loop ticket <id>` — one ticket's full detail + its comments (chronological).
|
|
87
|
+
function showTicket(db, projectId, args) {
|
|
88
|
+
const id = args.find((a) => !a.startsWith("-"));
|
|
89
|
+
if (!id) {
|
|
90
|
+
console.error("dev-loop: usage: dev-loop ticket <id>");
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
// §2 isolation: scope by project_id so a read can never reach another project's ticket.
|
|
94
|
+
const t = db.prepare("SELECT * FROM tickets WHERE id=? AND project_id=?").get(id, projectId);
|
|
95
|
+
if (!t) {
|
|
96
|
+
console.error(`dev-loop: ticket '${id}' not found in this project.`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const labels = parseArr(t.labels);
|
|
100
|
+
const related = parseArr(t.related_to); // DL-92: SELECT * already carries related_to (JSON array) + duplicate_of (scalar) — no new query
|
|
101
|
+
const out = [
|
|
102
|
+
`${t.id} · ${t.title}`,
|
|
103
|
+
`state: ${t.state} type: ${t.type} owner: ${ownerOf(labels)} priority: ${prioOf(t.priority)} assignee: ${t.assignee ?? "—"}`,
|
|
104
|
+
`labels: ${labels.join(", ") || "—"}`,
|
|
105
|
+
];
|
|
106
|
+
if (related.length)
|
|
107
|
+
out.push(`related: ${related.join(", ")}`); // DL-92: ids render plainly so the operator can `dev-loop ticket <id>` to follow the chain (web detail / DL-8 parity)
|
|
108
|
+
if (t.duplicate_of)
|
|
109
|
+
out.push(`duplicate of: ${t.duplicate_of}`); // DL-92: shown only when set — a relation-less ticket omits these lines (no awkward empty label)
|
|
110
|
+
out.push("", t.description?.trim() || "(no description)");
|
|
111
|
+
const comments = db.prepare("SELECT author,body,created_at FROM comments WHERE ticket_id=? ORDER BY created_at").all(id);
|
|
112
|
+
out.push("", comments.length ? `── Comments (${comments.length}) ──` : "── No comments ──");
|
|
113
|
+
for (const c of comments)
|
|
114
|
+
out.push("", `${c.created_at} — ${c.author}`, c.body);
|
|
115
|
+
console.log(out.join("\n"));
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
function main() {
|
|
119
|
+
const [sub, ...rest] = process.argv.slice(2); // sub = "tickets" | "ticket" (cli.ts passes it as argv[0])
|
|
120
|
+
const { projectKey, projectFromCwd } = resolveIdentity(); // a read needs no DEVLOOP_ACTOR
|
|
121
|
+
const db = openDb(process.env.DEVLOOP_HUB_DB ?? `${homedir()}/.dev-loop/hub.db`);
|
|
122
|
+
db.exec("PRAGMA query_only=1"); // AC5: structurally read-only — any write/event from here on throws
|
|
123
|
+
const projectId = findProject(db, projectKey);
|
|
124
|
+
if (!projectId) {
|
|
125
|
+
const srcDesc = projectFromCwd ? `resolved from cwd '${process.cwd()}'` : `from DEVLOOP_PROJECT='${projectKey}'`;
|
|
126
|
+
console.error(`dev-loop: project '${projectKey}' (${srcDesc}) is not seeded in the hub DB. Seed it once (\`dev-loop seed ${projectKey} "<name>" <UNIQUE_PREFIX>\`), or set DEVLOOP_PROJECT / run from inside the project repo.`);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
return sub === "ticket" ? showTicket(db, projectId, rest) : listTickets(db, projectId, rest);
|
|
130
|
+
}
|
|
131
|
+
process.exit(main());
|