@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/doctor.js ADDED
@@ -0,0 +1,230 @@
1
+ // `dev-loop-hub doctor` — operator health check. READ-ONLY: it never auto-creates a db
2
+ // (a typo'd path reports MISSING, it does not spin an empty one). Backs the §17/§18 promises:
3
+ // data home is machine-local + never committed, the SoR is intact.
4
+ // DL-81: the `doctor` COMMAND additionally runs a service runtime-wiring reconcile (reads the product
5
+ // .mcp.json / daemon runfile / hooks.json + a localhost /api/health GET) — still READ-ONLY (no writes,
6
+ // no auto-create) and NON-FATAL; see serviceReconcile. Library callers (init-service) skip it.
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { execFileSync } from "node:child_process";
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import { loadProjectsConfig, resolveProjectFromCwd } from "./resolve-project.js";
13
+ // DL-81: the `doctor` COMMAND (server.ts / `node src/doctor.ts`) passes { reconcile: true } to ALSO report
14
+ // the service runtime wiring (below). Library callers that only want the DB-integrity verdict (init-service
15
+ // step (d)) call runDoctor(dbPath) with no opts → no reconcile, behavior byte-for-byte unchanged.
16
+ export async function runDoctor(dbPath, opts = {}) {
17
+ let ok = true;
18
+ const pass = (m) => console.log("✅ " + m);
19
+ const fail = (m) => { console.log("❌ " + m); ok = false; };
20
+ const info = (m) => console.log("• " + m);
21
+ console.log(`dev-loop-hub doctor — ${dbPath}`);
22
+ // 1. Exists (never create on doctor)
23
+ if (!existsSync(dbPath)) {
24
+ fail(`db MISSING — nothing to check (create it: node src/seed.ts <key> "<name>" <PREFIX>). NOT auto-creating.`);
25
+ return false;
26
+ }
27
+ // 2. Open the db READ-ONLY. doctor's whole contract is to be non-destructive (§17/§18): it must
28
+ // NEVER create or initialize a db. openDb() runs `CREATE TABLE IF NOT EXISTS`, which would
29
+ // materialize the full schema into an empty / truncated file (0 → ~200KB) and falsely green a
30
+ // destroyed SoR (DL-54). Read-only mode makes create-if-not-exists impossible for ANY input.
31
+ let db;
32
+ try {
33
+ db = new DatabaseSync(dbPath, { readOnly: true });
34
+ db.exec("PRAGMA busy_timeout=5000");
35
+ db.exec("PRAGMA foreign_keys=ON");
36
+ }
37
+ catch (e) {
38
+ fail(`db not openable (read-only): ${e.message}`);
39
+ return false;
40
+ }
41
+ // 2b. A 0-byte file IS a valid (empty) SQLite db, so the read-only open above SUCCEEDS on a
42
+ // truncated / zeroed / placeholder file — it just carries no schema; a non-SQLite file throws
43
+ // on the first read. Either way it is not a system-of-record: report INVALID and write nothing.
44
+ const HUB_TABLES = ["projects", "tickets", "documents", "topics", "actors", "events"]; // every table step 4 below counts — so a partial/foreign db fails HERE, cleanly, not mid-check
45
+ let missing;
46
+ try {
47
+ const present = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name));
48
+ missing = HUB_TABLES.filter((t) => !present.has(t));
49
+ }
50
+ catch (e) {
51
+ fail(`db INVALID — not a readable SQLite database: ${e.message}`);
52
+ db.close();
53
+ return false;
54
+ }
55
+ if (missing.length) {
56
+ fail(`db INVALID — empty / truncated / non-hub file (missing hub tables: ${missing.join(", ")}); not a system-of-record`);
57
+ db.close();
58
+ return false;
59
+ }
60
+ pass("db opens read-only and carries the hub schema");
61
+ // 3. PRAGMAs
62
+ const jm = db.prepare("PRAGMA journal_mode").get().journal_mode;
63
+ jm === "wal" ? pass("journal_mode = WAL") : fail(`journal_mode = ${jm} (expected wal)`);
64
+ const fk = db.prepare("PRAGMA foreign_keys").get().foreign_keys;
65
+ info(`foreign_keys = ${fk} (set per-connection; informational)`);
66
+ const qc = db.prepare("PRAGMA quick_check").get();
67
+ Object.values(qc)[0] === "ok" ? pass("quick_check ok (no corruption)") : fail(`quick_check: ${JSON.stringify(qc)}`);
68
+ // 4. Counts + per-project, and the unique-prefix integrity check (the real multi-project guard)
69
+ const c = (sql) => db.prepare(sql).get().c;
70
+ info(`projects=${c("SELECT count(*) c FROM projects")} tickets=${c("SELECT count(*) c FROM tickets")} docs=${c("SELECT count(*) c FROM documents")} topics=${c("SELECT count(*) c FROM topics")} actors=${c("SELECT count(*) c FROM actors")} events=${c("SELECT count(*) c FROM events")}`);
71
+ const projects = db.prepare("SELECT id, key, ticket_prefix FROM projects ORDER BY key").all();
72
+ const countByProject = db.prepare("SELECT count(*) c FROM tickets WHERE project_id = ?");
73
+ for (const p of projects) {
74
+ const n = countByProject.get(p.id).c;
75
+ info(` project ${p.key} [${p.ticket_prefix}] — ${n} tickets`);
76
+ }
77
+ const prefixes = projects.map((p) => p.ticket_prefix);
78
+ const dupes = prefixes.filter((p, i) => prefixes.indexOf(p) !== i);
79
+ dupes.length
80
+ ? fail(`duplicate ticket_prefix across projects: ${[...new Set(dupes)].join(", ")} — ticket ids will collide on the shared db`)
81
+ : pass(`ticket prefixes unique across projects`);
82
+ info(`valid DEVLOOP_ACTOR values: ${db.prepare("SELECT handle FROM actors WHERE active=1 ORDER BY handle").all().map((r) => r.handle).join(", ")}`);
83
+ // 5. §17 secrecy guard — the db must NOT be tracked by git (it's machine-local runtime state)
84
+ const dir = dirname(dbPath);
85
+ let inRepo = false;
86
+ try {
87
+ inRepo = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() === "true";
88
+ }
89
+ catch { /* not a repo */ }
90
+ if (!inRepo) {
91
+ pass("data home is outside any git repo (machine-local, never committed)");
92
+ }
93
+ else {
94
+ let leaked = false;
95
+ for (const f of [dbPath, dbPath + "-wal", dbPath + "-shm"]) {
96
+ if (!existsSync(f))
97
+ continue;
98
+ try {
99
+ execFileSync("git", ["-C", dir, "check-ignore", "-q", f], { stdio: "ignore" });
100
+ } // exit 0 = ignored
101
+ catch {
102
+ fail(`${f} is INSIDE a git repo and NOT gitignored — the hub DB must never be committed`);
103
+ leaked = true;
104
+ }
105
+ }
106
+ if (!leaked)
107
+ pass("db files are inside a repo but gitignored");
108
+ }
109
+ db.close();
110
+ // DL-81: optional, additive service runtime-wiring reconcile (only for the `doctor` COMMAND, not library
111
+ // callers like init-service). READ-ONLY + NON-FATAL — it never touches `ok`, so the verdict below is still
112
+ // decided SOLELY by the DB-integrity checks above (§18 SoR contract preserved).
113
+ if (opts.reconcile)
114
+ await serviceReconcile(projects.map((p) => p.key), dbPath);
115
+ console.log(ok ? "\nDOCTOR_OK" : "\nDOCTOR_FAILED");
116
+ return ok;
117
+ }
118
+ // ── DL-81: service runtime-wiring reconcile ──────────────────────────────────────────────────────────────
119
+ // ADDITIVE, READ-ONLY, NON-FATAL. After the DB-integrity checks (the ONLY hard-fail gate), if this doctor run
120
+ // is for a service-backend project that LIVES IN THE DB WE JUST INSPECTED, ALSO report whether the runtime an
121
+ // operator wired at init (init-service.ts steps (c)/(e) + the DL-42 hook) is still in place — the idempotent
122
+ // "is my service backend wired and up?" reconcile. init already runs doctor (DL-60), so its Step-8 readiness
123
+ // checklist inherits these lines with no SKILL change. Every line is PASS/WARN, NEVER a fail: a stopped daemon
124
+ // / absent .mcp.json / missing hook is operator-actionable info, not a broken SoR. With NO service context
125
+ // this prints NOTHING — the DB-only verdict stays byte-for-byte today's.
126
+ async function serviceReconcile(dbProjectKeys, dbPath) {
127
+ const pass = (m) => console.log("✅ " + m);
128
+ const warn = (m) => console.log("⚠️ " + m);
129
+ // Resolve the project context exactly as the MCP server / DL-13 launcher do: an explicit DEVLOOP_PROJECT
130
+ // wins, else match cwd against the configured repo paths. null ⇒ no context.
131
+ const cfg = loadProjectsConfig();
132
+ const key = process.env.DEVLOOP_PROJECT?.trim() || (cfg ? resolveProjectFromCwd(process.cwd(), cfg) : null);
133
+ // The reconcile is about the wiring of a project that lives in the db doctor just checked. A key resolved
134
+ // from cwd/env but ABSENT from this db (doctor pointed at a temp/other db, or cwd resolved a SIBLING project)
135
+ // is not this db's context — skip silently, keeping the DB-only verdict byte-for-byte unchanged.
136
+ if (!key || !dbProjectKeys.includes(key))
137
+ return;
138
+ console.log(`\nservice runtime wiring — '${key}' (best-effort; informational, not a hard-fail gate):`);
139
+ // (1) the product repo .mcp.json registers dev-loop-hub with a real server path + DEVLOOP_ACTOR wiring.
140
+ const repoPath = cfg?.projects?.[key]?.repoPath;
141
+ if (!repoPath)
142
+ warn(`.mcp.json — no repoPath for '${key}' in projects.json; cannot verify the dev-loop-hub registration (set repoPath, or register by hand from config/mcp.example.json)`);
143
+ else
144
+ reconcileMcpJson(join(repoPath, ".mcp.json"), pass, warn);
145
+ // (2) the per-project daemon /api/health is reachable (url from the lifecycle runfile beside the db).
146
+ await reconcileDaemonHealth(key, dbPath, pass, warn);
147
+ // (3) the DL-42 SessionStart hook (the steady-state daemon owner) is installed.
148
+ reconcileSessionStartHook(pass, warn);
149
+ }
150
+ function reconcileMcpJson(mcpJsonPath, pass, warn) {
151
+ if (!existsSync(mcpJsonPath)) {
152
+ warn(`.mcp.json — ${mcpJsonPath} not found; dev-loop-hub is not registered (re-run init, or merge from config/mcp.example.json)`);
153
+ return;
154
+ }
155
+ let cfg;
156
+ try {
157
+ cfg = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
158
+ }
159
+ catch (e) {
160
+ warn(`.mcp.json — ${mcpJsonPath} is malformed JSON, cannot verify the registration (${e.message})`);
161
+ return;
162
+ }
163
+ const entry = cfg?.mcpServers?.["dev-loop-hub"];
164
+ if (!entry || typeof entry !== "object") {
165
+ warn(`.mcp.json — no mcpServers["dev-loop-hub"] entry in ${mcpJsonPath} (re-run init to register it)`);
166
+ return;
167
+ }
168
+ const serverArg = (Array.isArray(entry.args) ? entry.args : []).find((a) => typeof a === "string" && /server\.(ts|js)$/.test(a));
169
+ const actorWired = !!entry.env && typeof entry.env === "object" && "DEVLOOP_ACTOR" in entry.env;
170
+ if (!serverArg) {
171
+ warn(`.mcp.json — the dev-loop-hub entry has no server.ts/.js arg in ${mcpJsonPath} (re-run init to repair)`);
172
+ return;
173
+ }
174
+ if (!existsSync(serverArg)) {
175
+ warn(`.mcp.json — the dev-loop-hub server path is missing on disk: ${serverArg} (the dev-loop checkout moved? re-run init)`);
176
+ return;
177
+ }
178
+ if (!actorWired) {
179
+ warn(`.mcp.json — the dev-loop-hub entry has no DEVLOOP_ACTOR env wiring in ${mcpJsonPath} (re-run init to repair)`);
180
+ return;
181
+ }
182
+ pass(`.mcp.json registers dev-loop-hub → ${serverArg} (DEVLOOP_ACTOR wired)`);
183
+ }
184
+ async function reconcileDaemonHealth(key, dbPath, pass, warn) {
185
+ const runDir = process.env.DEVLOOP_RUN_DIR ?? dirname(dbPath); // mirrors the lifecycle's lcRunDir (DL-41)
186
+ const runfile = join(runDir, `daemon-${key}.json`);
187
+ let url;
188
+ try {
189
+ url = JSON.parse(readFileSync(runfile, "utf8")).url;
190
+ }
191
+ catch { /* no runfile ⇒ not running */ }
192
+ if (!url) {
193
+ warn(`daemon — not running (no lifecycle runfile ${runfile}); start it with \`dev-loop-hub daemon up\` from the repo`);
194
+ return;
195
+ }
196
+ try {
197
+ const ac = new AbortController();
198
+ const t = setTimeout(() => ac.abort(), 1500); // short bound — doctor is a one-shot liveness probe, never a wait
199
+ const r = await fetch(`${url}/api/health`, { signal: ac.signal }).finally(() => clearTimeout(t));
200
+ const b = r.status === 200 ? (await r.json().catch(() => null)) : null;
201
+ if (b && b.ok === true && b.project === key)
202
+ pass(`daemon /api/health reachable → ${url} (project '${key}')`);
203
+ else
204
+ warn(`daemon — ${url}/api/health did not return {ok:true} for '${key}' (wedged/restarting? \`dev-loop-hub daemon up\`)`);
205
+ }
206
+ catch {
207
+ warn(`daemon — ${url}/api/health unreachable (not running?); start it with \`dev-loop-hub daemon up\``);
208
+ }
209
+ }
210
+ function reconcileSessionStartHook(pass, warn) {
211
+ const here = dirname(fileURLToPath(import.meta.url)); // hub/src (dev) | dist (published)
212
+ const pluginRoot = process.env.DEVLOOP_PLUGIN_ROOT ?? join(here, "..", ".."); // the repo/plugin root holds hooks/
213
+ const hookFile = join(pluginRoot, "hooks", "hooks.json");
214
+ try {
215
+ const j = JSON.parse(readFileSync(hookFile, "utf8"));
216
+ const cmds = (j.hooks?.SessionStart ?? []).flatMap((e) => (e.hooks ?? []).map((h) => h.command ?? ""));
217
+ if (cmds.some((c) => /daemon\s+up/.test(c)))
218
+ pass(`DL-42 SessionStart hook installed → ${hookFile} (daemon auto-starts each session)`);
219
+ else
220
+ warn(`DL-42 SessionStart hook — ${hookFile} has no \`daemon up\` SessionStart command; re-sync/reinstall the dev-loop plugin`);
221
+ }
222
+ catch {
223
+ warn(`DL-42 SessionStart hook — ${hookFile} not found/readable; re-sync/reinstall the dev-loop plugin so the daemon auto-starts`);
224
+ }
225
+ }
226
+ // CLI: node src/doctor.ts (or `dev-loop-hub doctor` via server.ts dispatch / `npm run doctor`)
227
+ if (import.meta.url === `file://${process.argv[1]}`) {
228
+ const dbPath = process.env.DEVLOOP_HUB_DB ?? `${process.env.HOME}/.dev-loop/hub.db`;
229
+ process.exit((await runDoctor(dbPath, { reconcile: true })) ? 0 : 1);
230
+ }
@@ -0,0 +1,206 @@
1
+ // DL-60 — `dev-loop-hub init-service <key> "<name>" <PREFIX> [--dry-run]`: the idempotent CODE that
2
+ // init's "Step 0.5 — choose your ticket system" flow INVOKES for a backend:"service" project. (The SKILL
3
+ // prose that calls it is DL-53, operator-applied — agents never self-edit a SKILL, §17.) It PERFORMS
4
+ // (not prints) the turnkey service-backend bootstrap by ORCHESTRATING the existing pieces — no
5
+ // re-implementation of any of them:
6
+ // (a) `npm install` in the hub if node_modules is absent
7
+ // (b) seed the project row + the agent/operator actors + the §4 labels (ensureSeed — idempotent on
8
+ // key; a duplicate PREFIX hard-throws, surfaced as a clear "pick a unique prefix" error, never
9
+ // swallowed — seed.ts:42-47)
10
+ // (c) merge (never clobber) the dev-loop-hub server into the PRODUCT repo's .mcp.json, env-name-only
11
+ // (DL-61's mergeMcpServer; skipped cleanly when the project config carries no repoPath)
12
+ // (d) `runDoctor(dbPath)` → assert DOCTOR_OK (doctor.ts — read-only, never auto-creates a db)
13
+ // (e) one-shot `daemon up` (the shipped DL-41 lifecycle) → confirm `/api/health {ok:true}` → report
14
+ // the board URL
15
+ // then VERIFY the DL-42 `hooks/hooks.json` SessionStart hook is present — the STEADY-STATE lifecycle
16
+ // owner (C1-mustFix-2). init's `daemon up` above is a one-time CURRENT-SESSION bootstrap convenience
17
+ // only; if the hook is absent we WARN (tell the operator to re-sync/reinstall the plugin) and NEVER
18
+ // install a competing lifecycle path.
19
+ //
20
+ // Idempotent: a re-run is a clean no-op (seed idempotent-on-key, daemon already-up detected). Honors
21
+ // config: a NON-"service" backend → exit-0 no-op (back-compat — the DL-41/42 safety contract); a
22
+ // `mode:"dry-run"` project (or `--dry-run`) prints every step and performs NONE. §16: localhost-only;
23
+ // identity by env-var NAME; no secrets. §17: this is CODE only — it never edits skills/init/SKILL.md
24
+ // (DL-53) and can never name/write a SKILL/conventions/code file.
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { spawnSync } from "node:child_process";
27
+ import { homedir } from "node:os";
28
+ import { join, dirname } from "node:path";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
+ import { openDb } from "./db.js";
31
+ import { ensureSeed } from "./seed.js";
32
+ import { runDoctor } from "./doctor.js";
33
+ import { loadProjectsConfig } from "./resolve-project.js";
34
+ import { mergeMcpServer } from "./mcp-merge.js";
35
+ const log = (m) => console.log(m);
36
+ // Resolve the project's backend + mode from projects.json (honors DEVLOOP_PROJECTS_JSON, which tests set,
37
+ // via the shared §11 locator in resolve-project.ts). §18: a missing `backend` ⇒ "linear". A key ABSENT
38
+ // from config ⇒ the explicit `init-service` invocation is taken as service intent (init invokes this only
39
+ // when setting up a service project); a key PRESENT with a non-"service" backend is honored as a no-op.
40
+ function resolveProjectCfg(key) {
41
+ const cfg = loadProjectsConfig();
42
+ const proj = cfg?.projects?.[key];
43
+ if (!proj)
44
+ return { backend: "service", mode: "live" };
45
+ return { backend: proj.backend ?? "linear", mode: proj.mode ?? "live", repoPath: proj.repoPath };
46
+ }
47
+ // DL-42 (C1-mustFix-2): the SessionStart hook is the STEADY-STATE lifecycle owner; init only VERIFIES it
48
+ // ships (a `daemon up` SessionStart command in hooks/hooks.json) and WARNS if absent — it must never
49
+ // install a competing lifecycle path.
50
+ function sessionStartHookPresent(pluginRoot) {
51
+ try {
52
+ const j = JSON.parse(readFileSync(join(pluginRoot, "hooks", "hooks.json"), "utf8"));
53
+ const cmds = (j.hooks?.SessionStart ?? []).flatMap((e) => (e.hooks ?? []).map((h) => h.command ?? ""));
54
+ return cmds.some((c) => /daemon\s+up/.test(c));
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ export async function runInitService(opts) {
61
+ const { key, name, prefix, dbPath } = opts;
62
+ const here = dirname(fileURLToPath(import.meta.url)); // hub/src (dev) | dist (published)
63
+ // Resolve the server entry by THIS file's own extension — .ts in-repo (zero-build dev), .js when run
64
+ // from the compiled npm package (node won't type-strip under node_modules; the published build is dist/*.js).
65
+ const selfExt = fileURLToPath(import.meta.url).endsWith(".js") ? ".js" : ".ts";
66
+ const hubDir = opts.hubDir ?? join(here, "..");
67
+ const pluginRoot = opts.pluginRoot ?? process.env.DEVLOOP_PLUGIN_ROOT ?? join(here, "..", "..");
68
+ const serverEntry = opts.serverEntry ?? join(here, `server${selfExt}`);
69
+ const { backend, mode, repoPath } = resolveProjectCfg(key);
70
+ const dryRun = !!opts.dryRun || mode === "dry-run";
71
+ log(`dev-loop-hub init-service — project '${key}' (prefix ${prefix}), backend '${backend}'${dryRun ? " [dry-run]" : ""}`);
72
+ // ── Back-compat: a non-"service" backend is a clean no-op (the DL-41/42 safety contract). ──
73
+ if (backend !== "service") {
74
+ log(`• backend is '${backend}', not 'service' — nothing to bootstrap (no-op).`);
75
+ return 0;
76
+ }
77
+ // ── (a) `npm install` if the hub deps are absent ──
78
+ if (existsSync(join(hubDir, "node_modules"))) {
79
+ log("✅ hub dependencies present (node_modules) — skipping install");
80
+ }
81
+ else if (dryRun) {
82
+ log(`[dry-run] would: npm install (in ${hubDir})`);
83
+ }
84
+ else {
85
+ log(`• installing hub dependencies (npm install in ${hubDir}) …`);
86
+ const r = spawnSync("npm", ["install"], { cwd: hubDir, encoding: "utf8", stdio: "inherit" });
87
+ if (r.status !== 0) {
88
+ log(`❌ npm install failed (exit ${r.status}) — install the hub deps and re-run`);
89
+ return 1;
90
+ }
91
+ log("✅ hub dependencies installed");
92
+ }
93
+ // ── (b) seed the project row + actors + labels (idempotent on key; a prefix clash → a clear error) ──
94
+ if (dryRun) {
95
+ log(`[dry-run] would: seed project '${key}' ("${name}", prefix ${prefix}) + actors + labels in ${dbPath}`);
96
+ }
97
+ else {
98
+ try {
99
+ const db = openDb(dbPath);
100
+ try {
101
+ ensureSeed(db, key, name, prefix);
102
+ }
103
+ finally {
104
+ db.close();
105
+ }
106
+ log(`✅ project '${key}' seeded (idempotent on key) + actors + labels in ${dbPath}`);
107
+ }
108
+ catch (e) {
109
+ // ensureProject hard-throws on a duplicate prefix — its message already says "pick a unique prefix".
110
+ log(`❌ seed failed: ${e.message}`);
111
+ return 1;
112
+ }
113
+ }
114
+ // ── (c) [DL-61] merge the dev-loop-hub server into the PRODUCT repo's .mcp.json, env-name-only (never
115
+ // clobbering other servers). Needs the product repoPath (from config); absent ⇒ skip cleanly.
116
+ // A malformed product .mcp.json is reported (left untouched) but does NOT abort the bootstrap. ──
117
+ if (!repoPath) {
118
+ log("• no repoPath in config — skipping .mcp.json registration (register dev-loop-hub by hand from config/mcp.example.json)");
119
+ }
120
+ else if (dryRun) {
121
+ log(`[dry-run] would: merge the dev-loop-hub MCP server into ${join(repoPath, ".mcp.json")} (env-name-only, preserving any other servers)`);
122
+ }
123
+ else {
124
+ const m = mergeMcpServer({ mcpJsonPath: join(repoPath, ".mcp.json"), hubServerPath: serverEntry, projectKey: key });
125
+ if (m.ok)
126
+ log(`✅ .mcp.json ${m.action}: dev-loop-hub registered in ${join(repoPath, ".mcp.json")} (servers: ${m.servers.join(", ")})`);
127
+ else
128
+ log(`⚠️ .mcp.json registration skipped: ${m.error}\n register dev-loop-hub by hand from config/mcp.example.json, then re-run.`);
129
+ }
130
+ // ── (d) doctor → assert DOCTOR_OK ──
131
+ if (dryRun) {
132
+ log(`[dry-run] would: run doctor on ${dbPath} and assert DOCTOR_OK`);
133
+ }
134
+ else if (!(await runDoctor(dbPath))) {
135
+ log("❌ doctor did not report DOCTOR_OK — the SoR is not healthy; not starting the daemon");
136
+ return 1;
137
+ }
138
+ else {
139
+ log("✅ doctor: DOCTOR_OK");
140
+ }
141
+ // ── (e) one-shot `daemon up` (DL-41) → confirm `/api/health {ok:true}` → report the board URL ──
142
+ let boardUrl = null;
143
+ if (dryRun) {
144
+ log(`[dry-run] would: start the daemon once (${serverEntry} daemon up) and confirm /api/health {ok:true}, then report the board URL`);
145
+ }
146
+ else {
147
+ const r = spawnSync(process.execPath, [serverEntry, "daemon", "up"], {
148
+ encoding: "utf8",
149
+ env: { ...process.env, DEVLOOP_HUB_DB: dbPath, DEVLOOP_PROJECT: key, DEVLOOP_ACTOR: "operator" },
150
+ });
151
+ if (r.status !== 0) {
152
+ log(`❌ daemon up failed (exit ${r.status})${r.stderr ? "\n " + r.stderr.trim() : ""}`);
153
+ return 1;
154
+ }
155
+ const out = (r.stdout ?? "").trim();
156
+ if (out)
157
+ log(out.split("\n").map((l) => " " + l).join("\n"));
158
+ // Confirm health + learn the URL from the runfile the lifecycle just wrote (runDir mirrors lcRunDir).
159
+ const runDir = process.env.DEVLOOP_RUN_DIR ?? dirname(dbPath);
160
+ try {
161
+ const run = JSON.parse(readFileSync(join(runDir, `daemon-${key}.json`), "utf8"));
162
+ const h = (await fetch(`${run.url}/api/health`).then((x) => x.json()).catch(() => null));
163
+ if (!h || h.ok !== true) {
164
+ log(`❌ daemon started but /api/health is not ok at ${run.url}`);
165
+ return 1;
166
+ }
167
+ boardUrl = run.url;
168
+ log("✅ daemon healthy → /api/health {ok:true}");
169
+ }
170
+ catch (e) {
171
+ log(`❌ could not confirm daemon health: ${e.message}`);
172
+ return 1;
173
+ }
174
+ }
175
+ // ── DL-42 hook presence: VERIFY (don't install — C1-mustFix-2) ──
176
+ if (sessionStartHookPresent(pluginRoot)) {
177
+ log("✅ DL-42 SessionStart hook present — the per-project daemon auto-starts each session (steady-state owner)");
178
+ }
179
+ else {
180
+ log("⚠️ DL-42 SessionStart hook NOT found in hooks/hooks.json — re-sync / reinstall the dev-loop plugin so the daemon auto-starts on session start. (The `daemon up` above was a one-time current-session bootstrap; init never installs a competing lifecycle path.)");
181
+ }
182
+ // ── report ──
183
+ if (dryRun)
184
+ log("\n[dry-run] init-service preview complete — no changes made.");
185
+ else
186
+ log(`\n✅ service backend ready for '${key}'.${boardUrl ? ` Board: ${boardUrl}` : ""}`);
187
+ return 0;
188
+ }
189
+ // CLI: `node src/init-service.ts <key> "<name>" <PREFIX> [--dry-run]` (also `npm run init-service -- …`).
190
+ // A standalone entry, deliberately NOT wired into the `dev-loop-hub` (server.ts) subcommand surface — the
191
+ // ticket fences server.ts off, and the init SKILL (DL-53) invokes the mechanics generically, so this needs
192
+ // no server.ts dispatch. Importing this module is side-effect-free — the guard keys on argv[1].
193
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
194
+ const rest = process.argv.slice(2);
195
+ const dryRun = rest.includes("--dry-run");
196
+ const [key, name, prefix] = rest.filter((a) => a !== "--dry-run");
197
+ if (!key || !name || !prefix) {
198
+ console.error(`[hub] usage: dev-loop-hub init-service <key> "<name>" <PREFIX> [--dry-run]`);
199
+ process.exit(2);
200
+ }
201
+ const code = await runInitService({
202
+ key, name, prefix, dryRun,
203
+ dbPath: process.env.DEVLOOP_HUB_DB ?? join(homedir(), ".dev-loop", "hub.db"),
204
+ });
205
+ process.exit(code);
206
+ }
@@ -0,0 +1,34 @@
1
+ // Shared label/project metadata store (DL-68) — the trivial "label/project ops" the MCP server (server.ts)
2
+ // and the daemon op-API (agentops.ts) both serve: list_issue_labels / create_issue_label / get_project. These
3
+ // are thin project-scoped DB reads + one guarded insert, so this module is small — but it is the SINGLE shared
4
+ // source for `create_issue_label`'s LABEL_KINDS + empty-name reject (the DL-22 regression class: a bad kind
5
+ // silently dropping the row while returning ok{}), and for the read SELECTs (so the two paths can't drift on a
6
+ // column list / order → the differential-parity AC). The docstore/topicstore/channelstore precedent: one impl,
7
+ // no drift. §17 firewall (structural): every write is an INSERT on the `labels` DB table — no filesystem path,
8
+ // no external effect; a label can never name a SKILL/conventions/code file.
9
+ import { randomUUID } from "node:crypto";
10
+ // The kinds the labels.kind CHECK constraint allows (db.ts). Validated UP FRONT so INSERT OR IGNORE can only
11
+ // ever ignore a genuine duplicate name — never silently swallow a CHECK(kind) violation and then masquerade as
12
+ // success (DL-22). The SINGLE shared source: server.ts + the op-API both import this, so they can't drift.
13
+ export const LABEL_KINDS = ["marker", "type", "owner", "subtype", "workflow", "repo"];
14
+ // list_issue_labels — the project's labels (no event; a read).
15
+ export function listLabels(db, projectId) {
16
+ return db.prepare("SELECT name,kind FROM labels WHERE project_id=? ORDER BY kind,name").all(projectId);
17
+ }
18
+ // create_issue_label — validate (DL-22: empty-name + LABEL_KINDS, UP FRONT) then INSERT OR IGNORE (idempotent
19
+ // on UNIQUE(project_id,name)). Returns the {name,kind} on success. NO event here — the op-API wrapper logs an
20
+ // attributed `label.create` (the identity win) while server.ts's tool stays byte-identical (it never logged one).
21
+ export function createLabel(db, projectId, a) {
22
+ const nm = a.name.trim();
23
+ if (!nm)
24
+ return { ok: false, error: "label name required (non-empty, non-whitespace)" }; // DL-22: reject empty/whitespace, no junk row
25
+ const k = a.kind ?? "workflow";
26
+ if (!LABEL_KINDS.includes(k))
27
+ return { ok: false, error: `invalid kind '${k}'; one of ${LABEL_KINDS.join("/")}` }; // DL-22: clean err, never a fake success
28
+ db.prepare("INSERT OR IGNORE INTO labels(id,project_id,name,kind) VALUES (?,?,?,?)").run(randomUUID(), projectId, nm, k);
29
+ return { ok: true, data: { name: nm, kind: k } }; // idempotent: UNIQUE(project_id,name) → re-create of an existing name is a no-op, still ok
30
+ }
31
+ // get_project — the active project row (no event; a read). Same column list/shape on both paths.
32
+ export function getProject(db, projectId) {
33
+ return db.prepare("SELECT id,key,name,ticket_prefix,mode,autonomy FROM projects WHERE id=?").get(projectId);
34
+ }
package/dist/linear.js ADDED
@@ -0,0 +1,60 @@
1
+ const timeoutMs = () => Number(process.env.DEVLOOP_MIRROR_TIMEOUT_MS) || 10_000;
2
+ // The endpoint defaults to the real Linear; DEVLOOP_LINEAR_API_URL overrides it (an integration-test /
3
+ // self-hosted seam). §16 is unaffected — the token is still a function arg, never placed in the URL —
4
+ // and env is already the trust boundary for the token, so this adds no new exposure. Read at call time.
5
+ const endpoint = () => process.env.DEVLOOP_LINEAR_API_URL || "https://api.linear.app/graphql";
6
+ async function gql(fetchImpl, token, query, variables) {
7
+ const ctl = new AbortController();
8
+ const timer = setTimeout(() => ctl.abort(), timeoutMs());
9
+ try {
10
+ const res = await fetchImpl(endpoint(), {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json", Authorization: token }, // Linear personal API key (no "Bearer")
13
+ body: JSON.stringify({ query, variables }),
14
+ signal: ctl.signal,
15
+ });
16
+ const body = (await res.json().catch(() => ({})));
17
+ if (res.status !== 200)
18
+ throw new Error(`linear http ${res.status}`); // status only — never the body/token
19
+ const errors = body.errors;
20
+ if (errors?.length)
21
+ throw new Error(`linear error: ${String(errors[0].message ?? "unknown").slice(0, 80)}`);
22
+ return (body.data ?? {});
23
+ }
24
+ catch (e) {
25
+ if (e.name === "AbortError")
26
+ throw new Error("linear network error: timeout");
27
+ throw e;
28
+ }
29
+ finally {
30
+ clearTimeout(timer);
31
+ }
32
+ }
33
+ // reconcile-by-marker: find a previously-mirrored issue by the `[hub:<id>]` marker in its title,
34
+ // so a crash between issueCreate and recording the mapping never double-creates on retry.
35
+ export async function findByMarker(fetchImpl, token, marker) {
36
+ const d = await gql(fetchImpl, token, "query($q:String!){ issues(filter:{ title:{ containsIgnoreCase:$q } }, first:1){ nodes{ id } } }", { q: marker });
37
+ const nodes = (d.issues?.nodes ?? []);
38
+ return nodes[0]?.id ?? null;
39
+ }
40
+ export async function createIssue(fetchImpl, token, teamId, projectId, issue) {
41
+ const input = { teamId, title: issue.title, description: issue.description };
42
+ if (projectId)
43
+ input.projectId = projectId;
44
+ if (issue.stateId)
45
+ input.stateId = issue.stateId;
46
+ const d = await gql(fetchImpl, token, "mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue{ id } } }", { i: input });
47
+ const r = d.issueCreate;
48
+ if (!r?.success || !r.issue?.id)
49
+ throw new Error("linear issueCreate failed");
50
+ return r.issue.id;
51
+ }
52
+ export async function updateIssue(fetchImpl, token, id, issue) {
53
+ const input = { title: issue.title, description: issue.description };
54
+ if (issue.stateId)
55
+ input.stateId = issue.stateId;
56
+ const d = await gql(fetchImpl, token, "mutation($id:String!,$i:IssueUpdateInput!){ issueUpdate(id:$id, input:$i){ success } }", { id, i: input });
57
+ const r = d.issueUpdate;
58
+ if (!r?.success)
59
+ throw new Error("linear issueUpdate failed");
60
+ }