@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.
@@ -0,0 +1,145 @@
1
+ // DL-61 (design U2) — merge (never clobber) the `dev-loop-hub` MCP server into a PRODUCT repo's `.mcp.json`,
2
+ // so init's `service` auto-wiring registers the hub server WITHOUT destroying any other MCP servers the
3
+ // product already declares. Composes onto DL-60's init-service seam (c). §16: env-NAME-only — the entry
4
+ // carries only `${VAR:-default}` env references (copied from the committed template), never a literal secret;
5
+ // the hub DB path is intentionally omitted (the server defaults to ~/.dev-loop/hub.db). §17: this is a
6
+ // data-file utility — it can only ever write the product `.mcp.json`, never a SKILL/conventions/code file.
7
+ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ const SERVER_NAME = "dev-loop-hub";
11
+ // The published npm package packs only hub/dist + README — config/ lives OUTSIDE the packed dir, so the repo
12
+ // template is absent when installed (Codex review 2026-06-27). This embedded default is the fallback: the
13
+ // canonical dev-loop-hub entry shape (env NAME-only; an args slot ending in server.ts that buildEntry rewrites
14
+ // to the real absolute path). Keep it in sync with config/mcp.example.json's dev-loop-hub entry.
15
+ const DEFAULT_TEMPLATE = {
16
+ mcpServers: {
17
+ [SERVER_NAME]: {
18
+ command: "node",
19
+ args: ["server.ts"],
20
+ env: { DEVLOOP_ACTOR: "${DEVLOOP_ACTOR:-operator}" },
21
+ },
22
+ },
23
+ };
24
+ // Resolve the template object: an EXPLICIT path (tests) must read it (throw if unreadable — preserves the §15
25
+ // suite's behavior); otherwise prefer the repo template file and fall back to the embedded default when it's
26
+ // absent (the installed-package path, where config/ wasn't packed).
27
+ function resolveTemplate(explicitPath, repoPath) {
28
+ if (explicitPath)
29
+ return JSON.parse(readFileSync(explicitPath, "utf8"));
30
+ if (existsSync(repoPath)) {
31
+ try {
32
+ return JSON.parse(readFileSync(repoPath, "utf8"));
33
+ }
34
+ catch { /* corrupt repo template → embedded default */ }
35
+ }
36
+ return DEFAULT_TEMPLATE;
37
+ }
38
+ // Build the dev-loop-hub entry FROM the resolved template (the single source of truth for its shape — so a
39
+ // future template change propagates), filling the absolute hub server path into `args` and pinning the
40
+ // DEVLOOP_PROJECT default to the project key (matches the dogfood `.mcp.json` `${DEVLOOP_PROJECT:-<key>}`).
41
+ function buildEntry(tmpl, hubServerPath, projectKey) {
42
+ const src = tmpl.mcpServers?.[SERVER_NAME];
43
+ if (!src || typeof src !== "object")
44
+ throw new Error(`template has no mcpServers["${SERVER_NAME}"] entry`);
45
+ // §16/DL-44: the key becomes the `${DEVLOOP_PROJECT:-<key>}` default; a key carrying `$`/`{`/`}` would
46
+ // produce a NESTED ${...} (the DL-44 SoR-fork footgun) in the product .mcp.json — reject it loudly rather
47
+ // than write a malformed config. Real project keys are plain identifiers, so this never bites in practice.
48
+ if (/[${}]/.test(projectKey))
49
+ throw new Error(`project key ${JSON.stringify(projectKey)} contains '$', '{', or '}', which would break the .mcp.json \${VAR:-default} interpolation (DL-44) — use a plain identifier key`);
50
+ // DL-66: the hub server path lands verbatim in the entry's `args` (line below) — another interpolated
51
+ // .mcp.json string position — so a path carrying `$`/`{`/`}` would nest a `${...}` that Claude Code
52
+ // mis-expands at parse-time interpolation, corrupting the resolved hub path and breaking the launch in that
53
+ // pane. Guard it symmetrically with projectKey above; a real absolute checkout path never contains these
54
+ // (same defense-in-depth bar the team set for the projectKey side in DL-44).
55
+ if (/[${}]/.test(hubServerPath))
56
+ throw new Error(`hub server path ${JSON.stringify(hubServerPath)} contains '$', '{', or '}', which would nest a \${...} in the .mcp.json args that Claude Code mis-expands at parse-time interpolation, corrupting the resolved hub path (DL-66) — use a path without those characters`);
57
+ const e = structuredClone(src);
58
+ const args = (e.args ?? []);
59
+ const idx = args.findIndex((a) => typeof a === "string" && a.endsWith("server.ts"));
60
+ if (idx < 0)
61
+ throw new Error(`template ${SERVER_NAME} entry has no server.ts arg to fill`);
62
+ args[idx] = hubServerPath; // the real absolute path, replacing the <ABS-PATH-TO-dev-loop>/... placeholder
63
+ e.args = args;
64
+ // env stays NAME-only; pin the project key as the DEVLOOP_PROJECT default (single-level, no nested ${...} — DL-44)
65
+ e.env = { ...(e.env ?? {}), DEVLOOP_PROJECT: `\${DEVLOOP_PROJECT:-${projectKey}}` };
66
+ return e;
67
+ }
68
+ function writeAtomic(path, obj) {
69
+ const tmp = `${path}.tmp-${process.pid}`; // same dir → rename is atomic on one filesystem (never a half-written .mcp.json)
70
+ try {
71
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
72
+ renameSync(tmp, path);
73
+ }
74
+ catch (e) {
75
+ try {
76
+ if (existsSync(tmp))
77
+ unlinkSync(tmp);
78
+ }
79
+ catch { /* best-effort — never leave a stray temp in the product repo */ }
80
+ throw e; // caller maps this to a clean {ok:false}, so a write failure warns rather than crashing the bootstrap
81
+ }
82
+ }
83
+ export function mergeMcpServer(opts) {
84
+ const { mcpJsonPath, hubServerPath, projectKey } = opts;
85
+ const here = dirname(fileURLToPath(import.meta.url)); // hub/src (dev) | dist (published)
86
+ let entry;
87
+ try {
88
+ const tmpl = resolveTemplate(opts.templatePath, join(here, "..", "..", "config", "mcp.example.json"));
89
+ entry = buildEntry(tmpl, hubServerPath, projectKey);
90
+ }
91
+ catch (e) {
92
+ return { ok: false, error: `could not build the ${SERVER_NAME} entry: ${e.message}` };
93
+ }
94
+ // No existing file → create a fresh `.mcp.json` carrying just our server.
95
+ if (!existsSync(mcpJsonPath)) {
96
+ try {
97
+ writeAtomic(mcpJsonPath, { mcpServers: { [SERVER_NAME]: entry } });
98
+ }
99
+ catch (e) {
100
+ return { ok: false, error: `could not write ${mcpJsonPath}: ${e.message}` };
101
+ }
102
+ return { ok: true, action: "created", servers: [SERVER_NAME] };
103
+ }
104
+ // Existing file → MERGE, never clobber. A malformed / partial file is an ERROR, left UNTOUCHED (never destroyed).
105
+ let cfg;
106
+ try {
107
+ cfg = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
108
+ }
109
+ catch (e) {
110
+ return { ok: false, error: `${mcpJsonPath} is malformed JSON — left untouched (${e.message})` };
111
+ }
112
+ if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg))
113
+ return { ok: false, error: `${mcpJsonPath} is not a JSON object — left untouched` };
114
+ const existingServers = cfg.mcpServers;
115
+ if ("mcpServers" in cfg && (typeof existingServers !== "object" || existingServers === null || Array.isArray(existingServers)))
116
+ return { ok: false, error: `${mcpJsonPath} has a non-object "mcpServers" — left untouched (partial/malformed)` };
117
+ const servers = (existingServers ?? {});
118
+ const existed = SERVER_NAME in servers;
119
+ if (existed && JSON.stringify(servers[SERVER_NAME]) === JSON.stringify(entry))
120
+ return { ok: true, action: "unchanged", servers: Object.keys(servers) }; // idempotent: identical → no write
121
+ servers[SERVER_NAME] = entry; // add or update IN PLACE — never a duplicate key; other servers untouched
122
+ cfg.mcpServers = servers;
123
+ try {
124
+ writeAtomic(mcpJsonPath, cfg);
125
+ } // re-serializes the WHOLE cfg → preserves every other server + top-level key
126
+ catch (e) {
127
+ return { ok: false, error: `could not write ${mcpJsonPath}: ${e.message}` };
128
+ }
129
+ return { ok: true, action: existed ? "updated" : "merged", servers: Object.keys(servers) };
130
+ }
131
+ // CLI: `node src/mcp-merge.ts <.mcp.json path> <abs hub/src/server.ts> <project-key> [template]`
132
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
133
+ const [mcpJsonPath, hubServerPath, projectKey, templatePath] = process.argv.slice(2);
134
+ if (!mcpJsonPath || !hubServerPath || !projectKey) {
135
+ console.error(`[hub] usage: node src/mcp-merge.ts <.mcp.json path> <abs hub/src/server.ts> <project-key> [template]`);
136
+ process.exit(2);
137
+ }
138
+ const r = mergeMcpServer({ mcpJsonPath, hubServerPath, projectKey, templatePath });
139
+ if (r.ok) {
140
+ console.log(`✅ ${SERVER_NAME} ${r.action} in ${mcpJsonPath} (servers: ${r.servers.join(", ")})`);
141
+ process.exit(0);
142
+ }
143
+ console.error(`❌ ${r.error}`);
144
+ process.exit(1);
145
+ }
@@ -0,0 +1,128 @@
1
+ // Shared P7 mirror store (DL-68) — the `mirror.push` HANDLER logic (ticket-fetch → content-hash skip →
2
+ // mapping-row-FIRST → reconcile-by-marker → create/update/skip/fail orchestration, the DL-11 side-effect-free
3
+ // DRYRUN) + `mirror.status`, used by BOTH the MCP server (server.ts) and the daemon op-API (agentops.ts). The
4
+ // Linear TRANSPORT (issueCreate/issueUpdate/findByMarker/the §16 token-never-thrown gql) stays in linear.ts and
5
+ // is reused AS-IS; this module is the handler layer linear.ts's transport serves — the docstore/topicstore/
6
+ // channelstore precedent that lets the stdio server and the daemon op-API share ONE implementation and never
7
+ // drift (the DL-11 DRYRUN invariant + the reconcile-by-marker idempotency live in exactly one place).
8
+ //
9
+ // SIDE-EFFECT-FREE entrypoint (no top-level db; identity (actor) + scope (projectId) are passed in by the
10
+ // caller — the daemon resolves the actor from X-Devloop-Actor, the stdio server passes its ACTOR — so every
11
+ // mirror.push/mirror.error event is attributed to the REAL caller on both paths). `fetchImpl` is injectable
12
+ // (default = the global fetch) so the adapter units can drive it; the live endpoint is env-overridable inside
13
+ // linear.ts (DEVLOOP_LINEAR_API_URL), exactly as before — the MIRROR_OK suite relies on that, unchanged.
14
+ //
15
+ // §16: the Linear token is read SERVER-SIDE from env[tokenEnv]; the caller passes only the NAME (validated by
16
+ // isEnvName, reused from channelstore — one definition, no drift). A literal token → a clean error, never
17
+ // persisted/echoed; a failed Linear call throws only a scrubbed status/message (linear.ts) which the catch
18
+ // records via scrubErr — the token never appears in any mirror.* response or event (the DL-52 invariant). §17
19
+ // firewall (structural): every write here is an INSERT/UPDATE on the `mirror_map` DB table — there is NO
20
+ // filesystem path anywhere in this module; the only external effect is the one-way network write via linear.ts
21
+ // (the hub NEVER reads Linear as truth — findByMarker only reconciles its own mapping).
22
+ import { randomUUID, createHash } from "node:crypto";
23
+ import { nowIso, logEvent } from "./db.js";
24
+ import { findByMarker, createIssue, updateIssue } from "./linear.js";
25
+ import { scrubErr } from "./channel.js";
26
+ import { isEnvName } from "./channelstore.js";
27
+ // Read at module load (a const, byte-identical to server.ts's MIRROR_DRYRUN) — DEVLOOP_MIRROR_DRYRUN=1 makes
28
+ // mirror.push side-effect-free: it previews the would-push `ops`, hits NO network, and persists NO mirror_map
29
+ // row (DL-11). Set it in the spawned process env (the MIRROR_OK suite + the agent-api/shim npm scripts do).
30
+ const MIRROR_DRYRUN = process.env.DEVLOOP_MIRROR_DRYRUN === "1";
31
+ const MIRROR_BANNER = "> 🤖 Mirrored from the dev-loop hub — edits here are IGNORED and overwritten on the next push. Give direction via the Director (conventions §25).";
32
+ const toTicket = (r) => ({
33
+ id: r.id, project_id: r.project_id, title: r.title, description: r.description, type: r.type,
34
+ state: r.state, assignee: r.assignee, priority: r.priority,
35
+ labels: JSON.parse(r.labels),
36
+ duplicateOf: r.duplicate_of, relatedTo: JSON.parse(r.related_to),
37
+ created_by: r.created_by, created_at: r.created_at, updated_at: r.updated_at,
38
+ });
39
+ const mirrorTitle = (t) => `${t.title} [hub:${t.id}]`;
40
+ const mirrorBody = (t) => [
41
+ MIRROR_BANNER, "",
42
+ `**hub:** ${t.id} · **type:** ${t.type} · **state:** ${t.state} · **priority:** ${t.priority} · **owner:** ${t.assignee ?? "—"}`,
43
+ t.labels.length ? `**labels:** ${t.labels.join(", ")}` : "",
44
+ t.relatedTo.length ? `**related:** ${t.relatedTo.join(", ")}` : "",
45
+ t.duplicateOf ? `**duplicate of:** ${t.duplicateOf}` : "",
46
+ "", t.description || "_(no description)_",
47
+ ].filter((l) => l !== "").join("\n");
48
+ const mirrorHash = (t, stateId) => createHash("sha256").update(JSON.stringify({ title: mirrorTitle(t), body: mirrorBody(t), stateId: stateId ?? null })).digest("hex");
49
+ // ONE-WAY push: project hub tickets → Linear issues (create-or-update, idempotent + incremental). Verbatim
50
+ // from server.ts (ACTOR → the passed `actor`, the global fetch → the injectable `fetchImpl`), so server.ts's
51
+ // externally-observable behavior is unchanged. `db` MUST be a WRITABLE connection for a live push.
52
+ export async function mirrorPush(db, projectId, actor, a, fetchImpl = fetch) {
53
+ if (!isEnvName(a.tokenEnv))
54
+ return { ok: false, error: `tokenEnv must be an ENV-VAR NAME (e.g. DEVLOOP_LINEAR_TOKEN), not the secret value itself` };
55
+ const token = process.env[a.tokenEnv];
56
+ if (!token && !MIRROR_DRYRUN)
57
+ return { ok: false, error: `mirror token env '${a.tokenEnv}' is unset` };
58
+ const rows = db.prepare("SELECT * FROM tickets WHERE project_id=? ORDER BY updated_at DESC LIMIT ?").all(projectId, a.limit ?? 500);
59
+ const tickets = rows.map(toTicket);
60
+ let created = 0, updated = 0, skipped = 0, failed = 0;
61
+ const ops = [];
62
+ for (const t of tickets) {
63
+ const stateId = a.stateMap?.[t.state]; // missing ⇒ undefined ⇒ no stateId (fallback: state is in the body)
64
+ const issue = { title: mirrorTitle(t), description: mirrorBody(t), stateId };
65
+ const hash = mirrorHash(t, stateId);
66
+ let row = db.prepare("SELECT id,hub_id,linear_id,last_pushed_hash FROM mirror_map WHERE project_id=? AND hub_kind='ticket' AND hub_id=?").get(projectId, t.id);
67
+ if (row && row.linear_id && row.last_pushed_hash === hash) {
68
+ skipped++;
69
+ continue;
70
+ } // incremental skip (unchanged)
71
+ if (!row) {
72
+ // mapping-row-FIRST: record intent BEFORE the remote create → a crash never orphans a Linear
73
+ // issue (a NULL-id row on the next fire reconciles by marker). The UNIQUE(project,kind,hub_id)
74
+ // makes two concurrent pushers' INSERTs serialize — the loser throws + retries (no dup row).
75
+ const rid = randomUUID();
76
+ // DRYRUN is side-effect-free (§12, DL-11): keep the mapping row IN MEMORY only. Persisting it
77
+ // poisons a later live push — an unchanged ticket is skipped (never created) and a changed one
78
+ // gets stuck updating a non-existent `dry-<id>`. The in-memory row still drives the logic + ops.
79
+ if (!MIRROR_DRYRUN)
80
+ db.prepare("INSERT INTO mirror_map(id,project_id,hub_kind,hub_id,created_at) VALUES (?,?,'ticket',?,?)").run(rid, projectId, t.id, nowIso());
81
+ row = { id: rid, hub_id: t.id, linear_id: null, last_pushed_hash: null };
82
+ }
83
+ try {
84
+ if (!row.linear_id) {
85
+ // ALWAYS reconcile-by-marker before creating (Codex review): closes the concurrent-create
86
+ // window (a racing pusher's issue is found, not duplicated), and on a crashed-create retry
87
+ // the existing issue is ADOPTED + UPDATED to current content (never left stale). A genuinely
88
+ // new ticket: findByMarker returns null → create. (Full concurrency-safety still assumes the
89
+ // single-Sweep-per-project model; a lease is over-engineering for one writer.)
90
+ const found = MIRROR_DRYRUN ? null : await findByMarker(fetchImpl, token, `[hub:${t.id}]`);
91
+ let linearId;
92
+ if (found) {
93
+ await updateIssue(fetchImpl, token, found, issue);
94
+ linearId = found;
95
+ } // adopt + push current content (fixes stale-reconcile)
96
+ else {
97
+ linearId = MIRROR_DRYRUN ? `dry-${t.id}` : await createIssue(fetchImpl, token, a.teamId, a.projectId ?? null, issue);
98
+ }
99
+ if (!MIRROR_DRYRUN)
100
+ db.prepare("UPDATE mirror_map SET linear_id=?, last_pushed_hash=?, last_pushed_at=? WHERE id=?").run(linearId, hash, nowIso(), row.id); // DRYRUN: never persist the dry-<id> sentinel/hash (DL-11)
101
+ created++;
102
+ ops.push({ op: found ? "reconcile" : "create", hubId: t.id, title: issue.title, body: issue.description, stateId: stateId ?? null });
103
+ }
104
+ else {
105
+ if (!MIRROR_DRYRUN)
106
+ await updateIssue(fetchImpl, token, row.linear_id, issue);
107
+ if (!MIRROR_DRYRUN)
108
+ db.prepare("UPDATE mirror_map SET last_pushed_hash=?, last_pushed_at=? WHERE id=?").run(hash, nowIso(), row.id); // DRYRUN: don't advance the persisted hash (DL-11)
109
+ updated++;
110
+ ops.push({ op: "update", hubId: t.id, title: issue.title, body: issue.description, stateId: stateId ?? null });
111
+ }
112
+ }
113
+ catch (e) {
114
+ // leave the row (linear_id as-is, hash NOT advanced) → next push retries; never persist the token
115
+ failed++;
116
+ logEvent(db, { project_id: projectId, actor, kind: "mirror.error", data: { hubId: t.id, error: scrubErr(e.message) } });
117
+ }
118
+ }
119
+ logEvent(db, { project_id: projectId, actor, kind: "mirror.push", data: { created, updated, skipped, failed } });
120
+ return { ok: true, data: { created, updated, skipped, failed, dryrun: MIRROR_DRYRUN, ...(MIRROR_DRYRUN ? { ops } : {}) } };
121
+ }
122
+ // mirror.status — coverage counts; no secret, no Linear read. Shared so server.ts + the op-API are byte-identical.
123
+ export function mirrorStatus(db, projectId) {
124
+ const mapped = db.prepare("SELECT count(*) c FROM mirror_map WHERE project_id=? AND hub_kind='ticket'").get(projectId).c;
125
+ const tickets = db.prepare("SELECT count(*) c FROM tickets WHERE project_id=?").get(projectId).c;
126
+ const last = db.prepare("SELECT max(last_pushed_at) m FROM mirror_map WHERE project_id=?").get(projectId).m;
127
+ return { mapped, tickets, lastPush: last };
128
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // `dev-loop release-version <semver>` — the SINGLE-VERSION stamp (P4, design daemon-multicli §6).
3
+ // Writes ONE version into the three manifests that MUST ship in lockstep, so a release can never drift
4
+ // (the marketplace-cache class of bug: a bumped plugin.json with a stale marketplace.json serves the old
5
+ // cached SKILLs): hub/package.json, .claude-plugin/plugin.json, .claude-plugin/marketplace.json
6
+ // (plugins[0].version). Surgical single-line text replace per file ⇒ a 1-line diff, formatting preserved.
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); // hub/src → repo root
11
+ const version = process.argv[2];
12
+ if (!version || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
13
+ console.error(`usage: dev-loop release-version <semver> (e.g. 0.21.0)\n got: ${version ?? "(none)"}`);
14
+ process.exit(2);
15
+ }
16
+ const files = [
17
+ { rel: "hub/package.json", cur: (j) => j.version },
18
+ { rel: ".claude-plugin/plugin.json", cur: (j) => j.version },
19
+ { rel: ".claude-plugin/marketplace.json", cur: (j) => j.plugins[0].version },
20
+ ];
21
+ let changed = 0;
22
+ for (const f of files) {
23
+ const path = join(repoRoot, f.rel);
24
+ const txt = readFileSync(path, "utf8");
25
+ const cur = f.cur(JSON.parse(txt)); // validate it parses + locate the current value
26
+ if (cur === version) {
27
+ console.log(`= ${f.rel}: already ${version}`);
28
+ continue;
29
+ }
30
+ const needle = `"version": "${cur}"`; // the only version field in each file (plugins[0] in marketplace)
31
+ if (!txt.includes(needle)) {
32
+ console.error(`✗ ${f.rel}: could not find ${needle} to replace`);
33
+ process.exit(1);
34
+ }
35
+ writeFileSync(path, txt.replace(needle, `"version": "${version}"`));
36
+ console.log(`✓ ${f.rel}: ${cur} → ${version}`);
37
+ changed++;
38
+ }
39
+ console.log(`\nstamped ${version} into ${changed} manifest(s) (lockstep — package.json + plugin.json + marketplace.json).`);
@@ -0,0 +1,82 @@
1
+ // DL-13: resolve a project KEY from a cwd by matching it against the configured projects' repo paths, so
2
+ // an agent launched inside a project folder auto-pins that project with no manual DEVLOOP_PROJECT. Pure +
3
+ // side-effect-light so it is trivially unit-testable AND the launcher can reuse the SAME matcher via the
4
+ // `dev-loop-hub resolve-project` subcommand (one rule, no prose/code drift). Backward-compatible: an
5
+ // explicit DEVLOOP_PROJECT always wins (the caller checks that first); this runs only when it is unset.
6
+ import { realpathSync, readFileSync, existsSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { relative, isAbsolute, join } from "node:path";
9
+ // The candidate repo paths for a project (§19): repos[].path if present, else [repoPath].
10
+ function projectPaths(p) {
11
+ if (p.repos?.length)
12
+ return p.repos.map((r) => r.path).filter((x) => !!x);
13
+ return p.repoPath ? [p.repoPath] : [];
14
+ }
15
+ // Is `child` the same as, or a descendant of, `parent` on a SEGMENT boundary — so `/work/repo` does NOT
16
+ // match `/work/repo-2`? Both must be realpath-canonical absolute paths.
17
+ function isWithin(child, parent) {
18
+ if (child === parent)
19
+ return true;
20
+ const rel = relative(parent, child);
21
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
22
+ }
23
+ const canon = (p) => { try {
24
+ return realpathSync(p);
25
+ }
26
+ catch {
27
+ return null;
28
+ } };
29
+ // Resolve at most ONE project key whose repo path is the NEAREST ancestor of cwd. A tie between two
30
+ // DISTINCT projects at the same longest depth → null (never guess); cwd outside every repo → null.
31
+ export function resolveProjectFromCwd(cwd, config) {
32
+ const c = canon(cwd);
33
+ if (!c)
34
+ return null;
35
+ let best = null;
36
+ let tie = false;
37
+ for (const [key, proj] of Object.entries(config.projects ?? {})) {
38
+ for (const raw of projectPaths(proj)) {
39
+ const P = canon(raw);
40
+ if (!P || !isWithin(c, P))
41
+ continue;
42
+ const depth = P.length; // a longer canonical ancestor path = a nearer ancestor
43
+ if (!best || depth > best.depth) {
44
+ best = { key, depth };
45
+ tie = false;
46
+ }
47
+ else if (depth === best.depth && key !== best.key) {
48
+ tie = true;
49
+ }
50
+ }
51
+ }
52
+ return best && !tie ? best.key : null;
53
+ }
54
+ // DL-85: the ONE DEVLOOP_ACTOR + DEVLOOP_PROJECT/cwd identity resolution (was re-derived in server.ts:21-32
55
+ // AND shim.ts:38-46). An EXPLICIT DEVLOOP_PROJECT wins; else resolve from cwd (DL-13); else the "demo" default.
56
+ // `projectFromCwd` is true only on the cwd-resolved branch (server.ts uses it for a clearer not-seeded error).
57
+ export function resolveIdentity() {
58
+ const actor = process.env.DEVLOOP_ACTOR ?? "operator"; // who this MCP client IS (the attribution win)
59
+ const explicit = process.env.DEVLOOP_PROJECT?.trim(); // a present-but-empty "" must NOT become the literal key
60
+ if (explicit)
61
+ return { actor, projectKey: explicit, projectFromCwd: false };
62
+ const cfg = loadProjectsConfig();
63
+ const resolved = cfg ? resolveProjectFromCwd(process.cwd(), cfg) : null;
64
+ return resolved ? { actor, projectKey: resolved, projectFromCwd: true } : { actor, projectKey: "demo", projectFromCwd: false };
65
+ }
66
+ // Locate + parse projects.json the way the skills do (§11): DEVLOOP_PROJECTS_JSON, then CLAUDE_PLUGIN_DATA,
67
+ // then the canonical dev-loop data dir. Returns null when not found/parseable (caller keeps its default).
68
+ export function loadProjectsConfig() {
69
+ const candidates = [
70
+ process.env.DEVLOOP_PROJECTS_JSON,
71
+ process.env.CLAUDE_PLUGIN_DATA ? join(process.env.CLAUDE_PLUGIN_DATA, "projects.json") : undefined,
72
+ join(homedir(), ".claude", "plugins", "data", "dev-loop", "projects.json"),
73
+ ].filter((x) => !!x);
74
+ for (const p of candidates) {
75
+ try {
76
+ if (existsSync(p))
77
+ return JSON.parse(readFileSync(p, "utf8"));
78
+ }
79
+ catch { /* try next */ }
80
+ }
81
+ return null;
82
+ }
package/dist/seed.js ADDED
@@ -0,0 +1,76 @@
1
+ // Idempotent bootstrap: a project, the agent/operator actors, and the §4 label taxonomy.
2
+ // Run directly (`node src/seed.ts <key> <name>`) or called by the server on first run.
3
+ import { randomUUID } from "node:crypto";
4
+ import { openDb, nowIso } from "./db.js";
5
+ // The live dev-loop agents + the human operator. P5 repurposed `signal` → `director`.
6
+ // DL split (senior/junior dev): `senior-dev` + `junior-dev` join as ACTIVE actors; the legacy single
7
+ // `dev` STAYS ACTIVE (NOT retired) — it remains the canonical single-pane fallback for non-split
8
+ // projects (e.g. monpick on Linear), so adding the two-tier model breaks no existing project.
9
+ const AGENT_HANDLES = ["pm", "qa", "dev", "senior-dev", "junior-dev", "sweep", "reflect", "ops", "architect", "director"];
10
+ // `signal` retired into `director` (P5): kept as an INACTIVE actor so its historical comment/event
11
+ // attribution stays readable, but refused for NEW writes (actorExists/G1 filter active=1).
12
+ const RETIRED_HANDLES = ["signal"];
13
+ // §4 label taxonomy (+ the `notified` workflow label from §9 notify).
14
+ const LABELS = [
15
+ { name: "dev-loop", kind: "marker" },
16
+ { name: "Feature", kind: "type" }, { name: "Bug", kind: "type" }, { name: "Improvement", kind: "type" },
17
+ { name: "pm", kind: "owner" }, { name: "qa", kind: "owner" },
18
+ // DL split: dev-tier ROUTING labels (per-backend §18 encoding — the label distinguishes the dev tier
19
+ // on shared-identity backends where `assignee` cannot). Distinct from the pm/qa VERIFIER owner labels;
20
+ // ride this INSERT-OR-IGNORE backfill, no migration (plain strings, like the §4 labels).
21
+ { name: "senior-dev", kind: "owner" }, { name: "junior-dev", kind: "owner" },
22
+ { name: "edge-case", kind: "subtype" }, { name: "incident", kind: "subtype" },
23
+ { name: "tech-debt", kind: "subtype" }, { name: "signal", kind: "subtype" }, { name: "coverage", kind: "subtype" },
24
+ { name: "blocked", kind: "workflow" }, { name: "needs-pm", kind: "workflow" },
25
+ { name: "needs-qa", kind: "workflow" }, { name: "notified", kind: "workflow" },
26
+ // DL-32 (design §7): release/env labels — no new state, no schema ALTER. They ride this ensureLabels
27
+ // backfill (INSERT OR IGNORE, idempotent), not a dedicated migration.
28
+ { name: "env:dev", kind: "workflow" }, { name: "env:prod", kind: "workflow" },
29
+ ];
30
+ export function ensureActors(db) {
31
+ const ins = db.prepare("INSERT OR IGNORE INTO actors(id,handle,kind,display_name,active,created_at) VALUES (?,?,?,?,?,?)");
32
+ for (const h of AGENT_HANDLES)
33
+ ins.run(randomUUID(), h, "agent", h.toUpperCase(), 1, nowIso());
34
+ for (const h of RETIRED_HANDLES)
35
+ ins.run(randomUUID(), h, "agent", h.toUpperCase(), 0, nowIso());
36
+ ins.run(randomUUID(), "operator", "human", "Operator", 1, nowIso());
37
+ }
38
+ export function findProject(db, key) {
39
+ const r = db.prepare("SELECT id FROM projects WHERE key=?").get(key);
40
+ return r?.id ?? null;
41
+ }
42
+ export function ensureProject(db, key, name, prefix = "DL") {
43
+ const existing = db.prepare("SELECT id FROM projects WHERE key=?").get(key);
44
+ if (existing)
45
+ return existing.id;
46
+ // ticket ids are a GLOBAL primary key, so two projects sharing one hub.db MUST have distinct
47
+ // prefixes or their tickets collide on insert (the real multi-project bug P3 closes).
48
+ const clash = db.prepare("SELECT key FROM projects WHERE ticket_prefix=?").get(prefix);
49
+ if (clash)
50
+ throw new Error(`ticket prefix '${prefix}' already used by project '${clash.key}'; pick a unique prefix for '${key}'`);
51
+ const id = randomUUID();
52
+ db.prepare("INSERT INTO projects(id,key,name,ticket_prefix,ticket_seq,created_at) VALUES (?,?,?,?,0,?)").run(id, key, name, prefix, nowIso());
53
+ const insL = db.prepare("INSERT OR IGNORE INTO labels(id,project_id,name,kind) VALUES (?,?,?,?)");
54
+ for (const l of LABELS)
55
+ insL.run(randomUUID(), id, l.name, l.kind);
56
+ return id;
57
+ }
58
+ export function ensureSeed(db, key, name, prefix = "DL") {
59
+ ensureActors(db);
60
+ return ensureProject(db, key, name, prefix);
61
+ }
62
+ // CLI: node src/seed.ts <key> <name> [prefix] [dbpath]
63
+ if (import.meta.url === `file://${process.argv[1]}`) {
64
+ const args = process.argv.slice(2);
65
+ // --help/-h is the near-universal convention; guard it BEFORE binding argv[0] to `key`, or it
66
+ // silently seeds a junk project literally keyed `--help` + its actors + labels (DL-88).
67
+ if (args[0] === "--help" || args[0] === "-h") {
68
+ console.log("Usage: seed <key> <name> [PREFIX] [DBPATH] — seed a project + actors + labels into the hub db");
69
+ process.exit(0);
70
+ }
71
+ const [key = "demo", name = "Demo Project", prefix = "DL", dbPath = process.env.DEVLOOP_HUB_DB ?? "./hub.db"] = args;
72
+ const db = openDb(dbPath);
73
+ const id = ensureSeed(db, key, name, prefix);
74
+ console.log(`seeded project ${key} (${id}) + actors + labels in ${dbPath}`);
75
+ db.close();
76
+ }
package/dist/server.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // dev-loop hub — stdio MCP server. The loop's system of record for ONE project.
3
+ // Identity rides DEVLOOP_ACTOR (launcher-set per pane); project rides DEVLOOP_PROJECT; db DEVLOOP_HUB_DB.
4
+ // Tools mirror the Linear MCP op-shapes 1:1 so the agent SKILLs port unchanged (conventions §18).
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { homedir } from "node:os";
8
+ import { openDb, actorExists, listActorHandles } from "./db.js";
9
+ import { ensureActors, ensureProject, findProject } from "./seed.js";
10
+ import { createLabel } from "./labelstore.js"; // DL-69: create_issue_label stays a native handler (see agentops.ts opCreateLabel — the only op server.ts does NOT dispatch through, to keep the stdio path byte-identical)
11
+ import { resolveProjectFromCwd, loadProjectsConfig, resolveIdentity } from "./resolve-project.js";
12
+ import { agentOp } from "./agentops.js"; // DL-69: the SINGLE definition of every ticket/read policy — every op-backed handler below dispatches through agentOp()
13
+ import { ok, err, registerTools } from "./tooldefs.js"; // DL-85: the ONE {name,description,inputSchema} registry + the shared ok()/err()
14
+ // ─── Environment / identity ──────────────────────────────────────────────────
15
+ const DB_PATH = process.env.DEVLOOP_HUB_DB ?? `${homedir()}/.dev-loop/hub.db`;
16
+ // DL-85: the DEVLOOP_ACTOR + DEVLOOP_PROJECT/cwd resolution lives ONCE in resolve-project.ts (was re-derived
17
+ // here AND in shim.ts). An EXPLICIT DEVLOOP_PROJECT wins; else cwd-resolve (DL-13: an agent launched inside a
18
+ // project folder auto-pins it); else the "demo" default. projectFromCwd drives the clearer not-seeded error below.
19
+ const { actor: ACTOR, projectKey: PROJECT_KEY, projectFromCwd } = resolveIdentity();
20
+ // `dev-loop-hub resolve-project [--cwd <path>]` (DL-13) — print the project KEY whose repo CONTAINS the
21
+ // cwd (default: process.cwd()), or exit non-zero with no output. The launcher reuses THIS matcher so the
22
+ // launcher, the hub fallback, and any prose agree on exactly ONE rule.
23
+ if (process.argv[2] === "resolve-project") {
24
+ const cwd = process.argv[3] === "--cwd" && process.argv[4] ? process.argv[4] : process.cwd();
25
+ const cfg = loadProjectsConfig();
26
+ const key = cfg ? resolveProjectFromCwd(cwd, cfg) : null;
27
+ if (key) {
28
+ console.log(key);
29
+ process.exit(0);
30
+ }
31
+ process.exit(1); // no match → empty stdout, non-zero → the launcher leaves DEVLOOP_PROJECT unset
32
+ }
33
+ // `dev-loop-hub doctor` — read-only health check (no server, no auto-create).
34
+ if (process.argv[2] === "doctor") {
35
+ const { runDoctor } = await import("./doctor.js");
36
+ process.exit((await runDoctor(DB_PATH, { reconcile: true })) ? 0 : 1);
37
+ }
38
+ // `dev-loop-hub identity-check [--expect <actor>[/<project>]]` — P8 portability helper: print what THIS
39
+ // process's env resolves to (the per-agent identity the hub would attribute writes to) + whether the
40
+ // server would start. NOTE: this reflects the CURRENT process env; the REAL per-CLI gate is calling
41
+ // `whoami` THROUGH the CLI's MCP spawn (docs/PORTABILITY.md) — only that proves the CLI propagates env
42
+ // to the spawned subprocess. With `--expect` (or DEVLOOP_EXPECT_ACTOR / DEVLOOP_EXPECT_PROJECT) it ALSO
43
+ // catches MIS-attribution: a wrong-but-valid actor (Codex review) fails, not just an unknown one. Exit 1
44
+ // if the actor would be REFUSED (db present + unknown actor → the G1 guard) OR mismatches the expectation.
45
+ if (process.argv[2] === "identity-check") {
46
+ const { existsSync } = await import("node:fs");
47
+ const expFlag = process.argv[3] === "--expect" ? process.argv[4] : undefined;
48
+ const expectActor = (expFlag?.split("/")[0]) || process.env.DEVLOOP_EXPECT_ACTOR || undefined;
49
+ const expectProject = (expFlag?.split("/")[1]) || process.env.DEVLOOP_EXPECT_PROJECT || undefined;
50
+ const dbPresent = existsSync(DB_PATH);
51
+ let actorKnown = null;
52
+ if (dbPresent) {
53
+ try {
54
+ const d = openDb(DB_PATH);
55
+ actorKnown = actorExists(d, ACTOR);
56
+ d.close();
57
+ }
58
+ catch {
59
+ actorKnown = null;
60
+ }
61
+ }
62
+ const matchesExpectation = (!expectActor || expectActor === ACTOR) && (!expectProject || expectProject === PROJECT_KEY);
63
+ const wouldStart = !dbPresent || actorKnown === true; // db absent ⇒ would be seeded; else the actor must be known
64
+ const pass = wouldStart && matchesExpectation;
65
+ console.log(JSON.stringify({ actor: ACTOR, project: PROJECT_KEY, db: DB_PATH, dbPresent, actorKnown, wouldStart, expectActor: expectActor ?? null, expectProject: expectProject ?? null, matchesExpectation, pass }));
66
+ process.exit(pass ? 0 : 1);
67
+ }
68
+ // `dev-loop-hub daemon <up|down|status>` — DL-41 per-project daemon lifecycle (the named command the
69
+ // DL-42 auto-start hook invokes). Delegated to daemon-lifecycle.ts (DL-74 extracted it from daemon.ts);
70
+ // importing it is side-effect-free here (that module is pure declarations — no top-level boot), so the
71
+ // MCP boot path below is 100% untouched — the bare `dev-loop-hub` (argv[2] undefined) skips this block.
72
+ if (process.argv[2] === "daemon") {
73
+ const { daemonLifecycle, LIFECYCLE_SUBS } = await import("./daemon-lifecycle.js");
74
+ const sub = process.argv[3] ?? "";
75
+ if (!LIFECYCLE_SUBS.includes(sub)) {
76
+ console.error(`[hub] usage: dev-loop-hub daemon <${LIFECYCLE_SUBS.join("|")}> (got '${sub || "—"}')`);
77
+ process.exit(2);
78
+ }
79
+ await daemonLifecycle(sub); // resolves project from env/cwd; calls process.exit
80
+ }
81
+ const db = openDb(DB_PATH);
82
+ ensureActors(db); // the 8 agents + operator are always present (needed for attribution + the guard below)
83
+ // P3/G1 — phantom-actor guard: a typo'd DEVLOOP_ACTOR would silently write an unattributable
84
+ // author into created_by / events.actor / comments.author. Refuse to start instead (exit non-zero
85
+ // ⇒ the MCP client can't connect ⇒ the failure is visible to the launching pane).
86
+ if (!actorExists(db, ACTOR)) {
87
+ console.error(`[hub] DEVLOOP_ACTOR='${ACTOR}' is not a known actor. Valid: ${listActorHandles(db).join(", ")}. Fix DEVLOOP_ACTOR in the launcher.`);
88
+ process.exit(1);
89
+ }
90
+ // P3/G2 — phantom-project guard: a typo'd DEVLOOP_PROJECT must NOT silently auto-create an empty
91
+ // board the agent then works in by mistake. The project must already exist; create it deliberately
92
+ // once (`node src/seed.ts <key> <name> <UNIQUE_PREFIX>`) or opt in with DEVLOOP_CREATE_PROJECT=1.
93
+ const projectId = process.env.DEVLOOP_CREATE_PROJECT === "1"
94
+ ? ensureProject(db, PROJECT_KEY, process.env.DEVLOOP_PROJECT_NAME ?? PROJECT_KEY, process.env.DEVLOOP_TICKET_PREFIX ?? "DL")
95
+ : findProject(db, PROJECT_KEY);
96
+ if (!projectId) {
97
+ // DL-13: a cwd-RESOLVED project that isn't seeded errors LOUDLY here (clear source) — it must not have
98
+ // silently fallen through to `demo` (it didn't: a cwd match returns the real key, which lands here).
99
+ const src = projectFromCwd ? `resolved from cwd '${process.cwd()}'` : `from DEVLOOP_PROJECT='${PROJECT_KEY}'`;
100
+ console.error(`[hub] project '${PROJECT_KEY}' (${src}) is not seeded in the hub DB. Create it once: \`node ${import.meta.dirname}/seed.ts ${PROJECT_KEY} "<name>" <UNIQUE_PREFIX>\` (or set DEVLOOP_CREATE_PROJECT=1). Refusing to auto-create a phantom board.`);
101
+ process.exit(1);
102
+ }
103
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
104
+ // ok()/err() (the MCP result shape) are imported from tooldefs.ts (DL-85) — one definition, shared with shim.ts.
105
+ // ─── DL-69 dispatch-sharing: every op-backed handler is a thin call-through to the shared agentOp() ──────
106
+ // Each ticket/read policy lives ONCE in agentops.ts; dispatch() forwards the zod-validated args to agentOp()
107
+ // and maps the returned { status, body } to the MCP ok()/err() shape via toMcp() — the SAME mapping the DL-55
108
+ // stdio shim applies to the op-API HTTP response (200 → ok(body); non-200 → err(body.error)), so a dispatched
109
+ // handler is BYTE-IDENTICAL to the pre-refactor native one (the differential-parity suite, shim ≡ stdio for all
110
+ // 29 tools, is the structural guard). agentOp reads NO env/mode/transport (the agentops.ts contract): server.ts
111
+ // owns the DEVLOOP_ACTOR identity + the G1 guard (above) and passes ACTOR in; the daemon op-API owns its own
112
+ // pipeline around the SAME ops. whoami + create_issue_label stay native below (see the makeHandler overrides).
113
+ const toMcp = (r) => (r.status === 200 ? ok(r.body) : err(r.body.error));
114
+ const dispatch = async (op, a) => toMcp(await agentOp(op, db, projectId, PROJECT_KEY, ACTOR, (a ?? {})));
115
+ const server = new McpServer({ name: "dev-loop-hub", version: "0.1.0" });
116
+ // ─── register the 29 tools from the ONE shared registry (DL-85) ────────────────────────────────────────────
117
+ // tooldefs.ts owns every tool's { name, description, inputSchema }; this server supplies only the per-name
118
+ // handler. The DEFAULT handler dispatches the op through agentOp() (above). Two tools are NATIVE (not ops):
119
+ // • whoami — answered locally from THIS process's resolved identity ({actor, project, db}).
120
+ // • create_issue_label — DL-69 kept native (a direct createLabel call) so the stdio path stays byte-identical
121
+ // and does NOT emit the op-API-only label.create event; every other tool dispatches through agentOp().
122
+ registerTools(server, (name) => {
123
+ if (name === "whoami")
124
+ return () => ok({ actor: ACTOR, project: PROJECT_KEY, db: DB_PATH });
125
+ if (name === "create_issue_label") {
126
+ return (a) => {
127
+ const { name: labelName, kind } = a;
128
+ const r = createLabel(db, projectId, { name: labelName, kind });
129
+ return r.ok ? ok(r.data) : err(r.error);
130
+ };
131
+ }
132
+ return (a) => dispatch(name, a);
133
+ });
134
+ await server.connect(new StdioServerTransport());