claude-code-session-manager 0.37.1 → 0.38.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.
Files changed (60) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-GMk5LS9Y.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index--a8m5_ET.js → index-CQqSHpIq.js} +444 -436
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +25 -0
  9. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
  10. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
  11. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
  12. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
  13. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
  14. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
  15. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
  16. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
  17. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
  18. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
  19. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
  20. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
  21. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
  22. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
  23. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
  24. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
  25. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
  26. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
  27. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  28. package/scripts/lib/watchdogHelpers.cjs +296 -166
  29. package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
  30. package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
  31. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  32. package/src/main/__tests__/docEdit.test.cjs +164 -2
  33. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  34. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  35. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  36. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
  37. package/src/main/browserAgentServer.cjs +11 -10
  38. package/src/main/chatRunner.cjs +58 -14
  39. package/src/main/docEdit.cjs +125 -5
  40. package/src/main/health.cjs +15 -0
  41. package/src/main/index.cjs +12 -6
  42. package/src/main/ipcSchemas.cjs +16 -3
  43. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  44. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  45. package/src/main/lib/localAdminHttp.cjs +157 -0
  46. package/src/main/lib/personaImportHealth.cjs +161 -0
  47. package/src/main/lib/prdCreate.cjs +107 -17
  48. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  49. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  50. package/src/main/scheduler.cjs +223 -56
  51. package/src/main/sessionsStore.cjs +4 -5
  52. package/src/main/templates/PRD_AUTHORING.md +4 -4
  53. package/src/main/transcripts.cjs +1 -85
  54. package/src/main/webRemote.cjs +3 -3
  55. package/src/preload/api.d.ts +16 -6
  56. package/src/preload/index.cjs +9 -2
  57. package/dist/assets/index-CTTjT08J.css +0 -32
  58. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  59. package/src/main/__tests__/adminServer.test.cjs +0 -380
  60. package/src/main/adminServer.cjs +0 -242
@@ -0,0 +1,120 @@
1
+ /**
2
+ * localAdminHttp.test.cjs — unit tests for the generic loopback-only HTTP
3
+ * transport (PRD 689 — extracted from the former standalone admin HTTP server
4
+ * module). Covers
5
+ * only transport concerns: auth rejection, token persistence, route dispatch,
6
+ * and 404 on unknown routes. Route *logic* tests live beside their owning
7
+ * module (scheduler.cjs's admin routes, prdCreate.cjs's create-prd route).
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/localAdminHttp.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect } from 'vitest';
15
+ const http = require('node:http');
16
+ const fs = require('node:fs');
17
+ const { createAdminHttp, TOKEN_PATH } = require('../localAdminHttp.cjs');
18
+
19
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
20
+ return new Promise((resolve, reject) => {
21
+ const headers = {};
22
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
23
+ let payload;
24
+ if (body !== undefined) {
25
+ payload = JSON.stringify(body);
26
+ headers['Content-Type'] = 'application/json';
27
+ headers['Content-Length'] = Buffer.byteLength(payload);
28
+ }
29
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
30
+ const chunks = [];
31
+ res.on('data', (c) => chunks.push(c));
32
+ res.on('end', () => {
33
+ const text = Buffer.concat(chunks).toString('utf8');
34
+ let json = null;
35
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
36
+ resolve({ status: res.statusCode, json, text });
37
+ });
38
+ });
39
+ req.on('error', reject);
40
+ if (payload) req.write(payload);
41
+ req.end();
42
+ });
43
+ }
44
+
45
+ test('request without bearer token returns 401', async () => {
46
+ const admin = createAdminHttp();
47
+ admin.registerRoute('GET', '/ping', async (req, res) => { res.writeHead(200); res.end('{}'); });
48
+ const { port } = await admin.start();
49
+ try {
50
+ const res = await request(port, { path: '/ping' });
51
+ expect(res.status).toBe(401);
52
+ expect(res.json.ok).toBe(false);
53
+ } finally {
54
+ await admin.stop();
55
+ }
56
+ });
57
+
58
+ test('request with wrong bearer token returns 401', async () => {
59
+ const admin = createAdminHttp();
60
+ admin.registerRoute('GET', '/ping', async (req, res) => { res.writeHead(200); res.end('{}'); });
61
+ const { port } = await admin.start();
62
+ try {
63
+ const res = await request(port, { path: '/ping', token: 'not-the-token' });
64
+ expect(res.status).toBe(401);
65
+ } finally {
66
+ await admin.stop();
67
+ }
68
+ });
69
+
70
+ test('registered route with correct token is dispatched', async () => {
71
+ const admin = createAdminHttp();
72
+ admin.registerRoute('GET', '/ping', async (req, res) => {
73
+ res.writeHead(200, { 'Content-Type': 'application/json' });
74
+ res.end(JSON.stringify({ ok: true, pong: true }));
75
+ });
76
+ const { port, token } = await admin.start();
77
+ try {
78
+ const res = await request(port, { path: '/ping', token });
79
+ expect(res.status).toBe(200);
80
+ expect(res.json).toEqual({ ok: true, pong: true });
81
+ } finally {
82
+ await admin.stop();
83
+ }
84
+ });
85
+
86
+ test('unregistered path/method returns 404', async () => {
87
+ const admin = createAdminHttp();
88
+ const { port, token } = await admin.start();
89
+ try {
90
+ const res = await request(port, { path: '/nope', token });
91
+ expect(res.status).toBe(404);
92
+ } finally {
93
+ await admin.stop();
94
+ }
95
+ });
96
+
97
+ test('server binds only to 127.0.0.1', async () => {
98
+ const admin = createAdminHttp();
99
+ await admin.start();
100
+ try {
101
+ expect(admin.server.address().address).toBe('127.0.0.1');
102
+ } finally {
103
+ await admin.stop();
104
+ }
105
+ });
106
+
107
+ test('start() persists token file with port + token, mode 0600', async () => {
108
+ const admin = createAdminHttp();
109
+ const { port, token } = await admin.start();
110
+ try {
111
+ const raw = fs.readFileSync(TOKEN_PATH, 'utf8');
112
+ const parsed = JSON.parse(raw);
113
+ expect(parsed.port).toBe(port);
114
+ expect(parsed.token).toBe(token);
115
+ const mode = fs.statSync(TOKEN_PATH).mode & 0o777;
116
+ expect(mode).toBe(0o600);
117
+ } finally {
118
+ await admin.stop();
119
+ }
120
+ });
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const MAX_RAW_STR = 4096;
4
+
5
+ // Block types whose text/content fields are parsed structurally by
6
+ // orchestrator.ts / race.ts — truncating them produces mid-token "…" and
7
+ // unparseable JSON, so they are exempt from the size cap.
8
+ const EXEMPT_TYPES = new Set(['tool_result', 'tool_use']);
9
+
10
+ /**
11
+ * Cap string fields in a content block array so arbitrary tool output doesn't
12
+ * bloat the ring buffer. Blocks whose type is in EXEMPT_TYPES are passed
13
+ * through intact so that structured result payloads survive to the digest
14
+ * parsers in race.ts / orchestrator.ts.
15
+ */
16
+ function trimContentArray(content) {
17
+ if (!Array.isArray(content)) return content;
18
+ return content.map((block) => {
19
+ if (!block || typeof block !== 'object') return block;
20
+ if (EXEMPT_TYPES.has(block.type)) return block;
21
+ const b = { ...block };
22
+ if (typeof b.text === 'string' && b.text.length > MAX_RAW_STR) {
23
+ b.text = b.text.slice(0, MAX_RAW_STR) + '…';
24
+ }
25
+ if (typeof b.content === 'string' && b.content.length > MAX_RAW_STR) {
26
+ b.content = b.content.slice(0, MAX_RAW_STR) + '…';
27
+ }
28
+ if (Array.isArray(b.content)) {
29
+ b.content = trimContentArray(b.content);
30
+ }
31
+ return b;
32
+ });
33
+ }
34
+
35
+ /** Build the slim raw projection used by race.ts and orchestrator.ts. */
36
+ function makeRaw(obj) {
37
+ const msgContent = obj?.message?.content;
38
+ return { message: { content: trimContentArray(msgContent) } };
39
+ }
40
+
41
+ /**
42
+ * Parse one JSONL line defensively. Real schema drifts, so we pass through
43
+ * anything that parses and tag a coarse `kind`.
44
+ */
45
+ function classifyLine(obj) {
46
+ if (!obj || typeof obj !== 'object') return null;
47
+ // Many shapes exist — try several common fields.
48
+ const type = obj.type || obj.event || obj.role;
49
+ const msg = obj.message || obj;
50
+ const content = msg?.content;
51
+
52
+ // Usage rollups arrive as summary events.
53
+ if (obj.usage || msg?.usage) {
54
+ return { kind: 'usage', data: obj.usage || msg.usage, raw: makeRaw(obj) };
55
+ }
56
+
57
+ // Tool uses: scan content array for tool_use blocks.
58
+ if (Array.isArray(content)) {
59
+ for (const block of content) {
60
+ if (block?.type === 'tool_use') {
61
+ if (block.name === 'TodoWrite') {
62
+ return { kind: 'todo_write', data: block.input?.todos || block.input || [], raw: makeRaw(obj) };
63
+ }
64
+ if (block.name === 'ExitPlanMode' || block.name === 'EnterPlanMode') {
65
+ return { kind: 'plan', data: block.input, raw: makeRaw(obj) };
66
+ }
67
+ if (block.name === 'Agent' || block.name === 'Task') {
68
+ // Include block.id as toolUseId so the live store can match the
69
+ // corresponding tool_result and update per-agent lastActivityAt.
70
+ return { kind: 'agent_spawn', data: { ...block.input, toolUseId: block.id }, raw: makeRaw(obj) };
71
+ }
72
+ return {
73
+ kind: 'tool_use',
74
+ data: { name: block.name, input: block.input, id: block.id },
75
+ raw: makeRaw(obj),
76
+ };
77
+ }
78
+ // tool_result carries the tool_use_id of the completed Task/Agent call.
79
+ // The live store uses this to update the agent's lastActivityAt bookend.
80
+ if (block?.type === 'tool_result' && block.tool_use_id) {
81
+ return { kind: 'tool_result', data: { toolUseId: block.tool_use_id }, raw: makeRaw(obj) };
82
+ }
83
+ }
84
+ }
85
+
86
+ return { kind: type || 'message', data: obj, raw: makeRaw(obj) };
87
+ }
88
+
89
+ module.exports = { MAX_RAW_STR, EXEMPT_TYPES, trimContentArray, makeRaw, classifyLine };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * localAdminHttp.cjs — generic loopback-only HTTP transport with bearer-token
3
+ * auth, extracted from the former standalone admin HTTP server module
4
+ * (PRD 689). This module owns
5
+ * ONLY the transport: token bootstrap/persistence, timing-safe comparison,
6
+ * body reading, JSON responses, and a route-registration/dispatch mechanism.
7
+ * Route *logic* (job-management, PRD creation) lives beside the module that
8
+ * already owns that capability — see scheduler.cjs's registerAdminRoutes and
9
+ * prdCreate.cjs's registerAdminRoute.
10
+ *
11
+ * Security posture (unchanged from the former module):
12
+ * - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
13
+ * off-box.
14
+ * - Bearer token, regenerated every app boot, written to
15
+ * ~/.claude/session-manager/admin-api.json with 0600 perms.
16
+ * - Token compared with crypto.timingSafeEqual to avoid a timing
17
+ * side-channel on the comparison itself.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const http = require('node:http');
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+ const crypto = require('node:crypto');
26
+ const fsp = require('node:fs/promises');
27
+ const config = require('../config.cjs');
28
+
29
+ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
30
+
31
+ function timingSafeEqualStrings(a, b) {
32
+ const bufA = Buffer.from(String(a ?? ''));
33
+ const bufB = Buffer.from(String(b ?? ''));
34
+ if (bufA.length !== bufB.length) {
35
+ // Compare against a same-length buffer anyway so the failure path still
36
+ // takes constant time relative to the (fixed) token length, rather than
37
+ // returning early on a length mismatch.
38
+ crypto.timingSafeEqual(bufA, bufA);
39
+ return false;
40
+ }
41
+ return crypto.timingSafeEqual(bufA, bufB);
42
+ }
43
+
44
+ function readBody(req, maxBytes = 1024 * 1024) {
45
+ return new Promise((resolve, reject) => {
46
+ let total = 0;
47
+ const chunks = [];
48
+ req.on('data', (chunk) => {
49
+ total += chunk.length;
50
+ if (total > maxBytes) {
51
+ reject(new Error('body too large'));
52
+ req.destroy();
53
+ return;
54
+ }
55
+ chunks.push(chunk);
56
+ });
57
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
58
+ req.on('error', reject);
59
+ });
60
+ }
61
+
62
+ function sendJson(res, status, obj) {
63
+ const body = JSON.stringify(obj);
64
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
65
+ res.end(body);
66
+ }
67
+
68
+ /**
69
+ * Create the admin HTTP transport. Route handlers are registered via
70
+ * `registerRoute(method, url, handler)`, where `handler` is
71
+ * `async (req, res, rawBody) => void` — raw body reading is the transport's
72
+ * job (routes call `JSON.parse` themselves as needed, matching the original
73
+ * per-route body handling in the former module).
74
+ */
75
+ function createAdminHttp() {
76
+ let server = null;
77
+ let token = null;
78
+ const routes = new Map();
79
+
80
+ async function ensureToken() {
81
+ token = crypto.randomBytes(32).toString('hex');
82
+ await config.writeJson(TOKEN_PATH, { port: null, token });
83
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
84
+ return token;
85
+ }
86
+
87
+ async function persistPort(port) {
88
+ await config.writeJson(TOKEN_PATH, { port, token });
89
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
90
+ }
91
+
92
+ function authorized(req) {
93
+ const header = req.headers.authorization || '';
94
+ const match = /^Bearer (.+)$/.exec(header);
95
+ if (!match) return false;
96
+ return timingSafeEqualStrings(match[1], token);
97
+ }
98
+
99
+ function registerRoute(method, url, handler) {
100
+ routes.set(`${method} ${url}`, handler);
101
+ }
102
+
103
+ async function handleRequest(req, res) {
104
+ if (!authorized(req)) {
105
+ sendJson(res, 401, { ok: false, error: 'unauthorized' });
106
+ return;
107
+ }
108
+ try {
109
+ const handler = routes.get(`${req.method} ${req.url}`);
110
+ if (!handler) {
111
+ sendJson(res, 404, { ok: false, error: 'not found' });
112
+ return;
113
+ }
114
+ await handler(req, res);
115
+ } catch (e) {
116
+ sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
117
+ }
118
+ }
119
+
120
+ async function start() {
121
+ await ensureToken();
122
+ server = http.createServer((req, res) => {
123
+ handleRequest(req, res).catch(() => {
124
+ try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
125
+ });
126
+ });
127
+ await new Promise((resolve, reject) => {
128
+ server.once('error', reject);
129
+ server.listen(0, '127.0.0.1', resolve);
130
+ });
131
+ const { port } = server.address();
132
+ await persistPort(port);
133
+ return { port, token };
134
+ }
135
+
136
+ async function stop() {
137
+ if (!server) return;
138
+ await new Promise((resolve) => server.close(() => resolve()));
139
+ server = null;
140
+ }
141
+
142
+ return {
143
+ start,
144
+ stop,
145
+ registerRoute,
146
+ get token() { return token; },
147
+ get server() { return server; },
148
+ };
149
+ }
150
+
151
+ module.exports = {
152
+ createAdminHttp,
153
+ TOKEN_PATH,
154
+ timingSafeEqualStrings,
155
+ readBody,
156
+ sendJson,
157
+ };
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * personaImportHealth.cjs — integrity check for Claude Code's `@path` import
5
+ * syntax in ~/.claude/CLAUDE.md.
6
+ *
7
+ * The `@path` import resolves a missing/moved target to nothing with zero
8
+ * warning — a renamed or deleted persona file silently drops that entire
9
+ * block of instructions from every session. This module parses the import
10
+ * chain (recursively, since imported files can themselves import further
11
+ * files) and asserts every resolved target exists and is non-empty, plus
12
+ * reports git ahead/behind for each unique repo the imports live in.
13
+ *
14
+ * Pure functions taking explicit inputs (a root file path), following the
15
+ * evaluateTickLiveness/readFreshHeartbeat pattern in health.cjs so this is
16
+ * independently unit-testable without touching the real home directory.
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+
24
+ const MAX_IMPORT_DEPTH = 5;
25
+ // Matches a line consisting of `@` followed by an absolute or ~-relative path,
26
+ // e.g. "@/home/user/foo.md" or "@~/Projects/Agents/shared/core.md".
27
+ const IMPORT_LINE_RE = /^@(~?\/\S+)\s*$/;
28
+
29
+ function resolveImportPath(rawPath) {
30
+ if (rawPath.startsWith('~/')) return path.join(os.homedir(), rawPath.slice(2));
31
+ if (rawPath === '~') return os.homedir();
32
+ return rawPath;
33
+ }
34
+
35
+ // Extracts every `@<path>` import reference from a file's raw text content.
36
+ function extractImportPaths(content) {
37
+ const paths = [];
38
+ for (const line of content.split('\n')) {
39
+ const match = IMPORT_LINE_RE.exec(line.trim());
40
+ if (match) paths.push(resolveImportPath(match[1]));
41
+ }
42
+ return paths;
43
+ }
44
+
45
+ // Recursively walks the import chain starting at rootPath, collecting a
46
+ // result per resolved import: { importPath, exists, nonEmpty, ok }.
47
+ // Depth-capped to guard against a cyclic-import pathological case. Visited
48
+ // paths are deduped so a diamond-shaped import graph doesn't get reported
49
+ // twice or blow the depth budget.
50
+ function walkImports(rootPath, depth = MAX_IMPORT_DEPTH, seen = new Set()) {
51
+ const results = [];
52
+ if (depth <= 0) return results;
53
+ if (!fs.existsSync(rootPath)) return results;
54
+
55
+ let content;
56
+ try {
57
+ content = fs.readFileSync(rootPath, 'utf8');
58
+ } catch {
59
+ return results;
60
+ }
61
+
62
+ for (const importPath of extractImportPaths(content)) {
63
+ if (seen.has(importPath)) continue;
64
+ seen.add(importPath);
65
+
66
+ let exists = false;
67
+ let nonEmpty = false;
68
+ try {
69
+ const stat = fs.statSync(importPath);
70
+ exists = true;
71
+ nonEmpty = stat.size > 0;
72
+ } catch {
73
+ exists = false;
74
+ }
75
+
76
+ results.push({ importPath, exists, nonEmpty, ok: exists && nonEmpty });
77
+
78
+ if (exists && nonEmpty) {
79
+ results.push(...walkImports(importPath, depth - 1, seen));
80
+ }
81
+ }
82
+
83
+ return results;
84
+ }
85
+
86
+ // Walks upward from startDir looking for a `.git` directory, bounded at
87
+ // os.homedir() or the filesystem root.
88
+ function findGitRepoRoot(startDir) {
89
+ const home = os.homedir();
90
+ let dir = startDir;
91
+ for (;;) {
92
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
93
+ if (dir === home) return null;
94
+ const parent = path.dirname(dir);
95
+ if (parent === dir) return null;
96
+ dir = parent;
97
+ }
98
+ }
99
+
100
+ // Reports ahead/behind vs upstream for a git repo, without ever fetching
101
+ // (rev-list reports against whatever the local remote-tracking ref already
102
+ // has cached). Non-fatal on any failure — no upstream configured, detached
103
+ // HEAD, git not installed — annotated as a caveat, never thrown.
104
+ function checkRepoAheadBehind(repoRoot) {
105
+ try {
106
+ const out = execFileSync(
107
+ 'git',
108
+ ['-C', repoRoot, 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'],
109
+ { encoding: 'utf8', timeout: 5000 }
110
+ ).trim();
111
+ const [ahead, behind] = out.split(/\s+/).map(Number);
112
+ return { repoRoot, ok: true, ahead, behind };
113
+ } catch (e) {
114
+ return {
115
+ repoRoot,
116
+ ok: false,
117
+ noUpstream: true,
118
+ error: (e.message || String(e)).split('\n')[0],
119
+ };
120
+ }
121
+ }
122
+
123
+ // Top-level entry point: parses ~/.claude/CLAUDE.md's import chain, checks
124
+ // every resolved target, and reports ahead/behind for each unique repo those
125
+ // imports live in. claudeMdPath is injectable for testing.
126
+ function checkPersonaImports(claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md')) {
127
+ if (!fs.existsSync(claudeMdPath)) {
128
+ return { ok: true, path: claudeMdPath, exists: false, imports: [], repos: [] };
129
+ }
130
+
131
+ const imports = walkImports(claudeMdPath);
132
+ const broken = imports.filter((i) => !i.ok);
133
+
134
+ const repoRoots = new Set();
135
+ for (const imp of imports) {
136
+ if (!imp.ok) continue;
137
+ const repoRoot = findGitRepoRoot(path.dirname(imp.importPath));
138
+ if (repoRoot) repoRoots.add(repoRoot);
139
+ }
140
+
141
+ const repos = [...repoRoots].map(checkRepoAheadBehind);
142
+
143
+ return {
144
+ ok: broken.length === 0,
145
+ path: claudeMdPath,
146
+ exists: true,
147
+ imports,
148
+ brokenImports: broken,
149
+ repos,
150
+ };
151
+ }
152
+
153
+ module.exports = {
154
+ checkPersonaImports,
155
+ walkImports,
156
+ extractImportPaths,
157
+ resolveImportPath,
158
+ findGitRepoRoot,
159
+ checkRepoAheadBehind,
160
+ MAX_IMPORT_DEPTH,
161
+ };
@@ -1,22 +1,28 @@
1
1
  /**
2
- * prdCreate.cjs — PRD-body builder for the create-prd admin route (PRD 549,
3
- * gh-issue-6). Pure functions only: no filesystem writes, no NN allocation,
4
- * no HTTP. adminServer.cjs owns orchestration (auth, cwd validation via
5
- * config.cjs's validatePath, NN allocation via the injected remote,
6
- * writing via remote.writePrd -> config.cjs's writeTextAtomic) so this
7
- * module stays trivially unit-testable.
2
+ * prdCreate.cjs — PRD-body builder + create-prd admin HTTP route (PRD 549,
3
+ * gh-issue-6; route consolidated here from the former standalone admin HTTP
4
+ * server module by PRD 689). buildPrdBody/deriveSlugFromTitle/readStandards are pure
5
+ * functions: no filesystem writes, no NN allocation, no HTTP. registerAdminRoute
6
+ * owns orchestration (auth is the injected transport's job, cwd validation
7
+ * via config.cjs's validatePath, NN allocation via the injected remote,
8
+ * writing via remote.writePrd -> config.cjs's writeTextAtomic).
8
9
  *
9
- * Standards are read fresh from disk on every call (no in-process caching)
10
- * so a live edit to standards.md is picked up by the next create-prd call
11
- * without an app restart same one-concept-one-implementation reasoning
12
- * that keeps the /develop skill re-reading it fresh per PRD (see SKILL.md).
10
+ * Neither this route nor the /develop skill embeds standards.md's contents
11
+ * anymore both just point the headless executor at STANDARDS_PATH with an
12
+ * instruction to read it before starting. There's nothing to go stale: the
13
+ * executor always reads the live file at run time, same one-concept-one-
14
+ * implementation reasoning that keeps the two PRD-creation paths in sync
15
+ * (see SKILL.md).
13
16
  */
14
17
  'use strict';
15
18
 
16
19
  const fsp = require('node:fs/promises');
17
20
  const path = require('node:path');
18
- const { PRD_CREATE_SLUG_RE } = require('../ipcSchemas.cjs');
21
+ const { schemas, PRD_CREATE_SLUG_RE } = require('../ipcSchemas.cjs');
19
22
  const { kebabCase } = require('./kebabCase.cjs');
23
+ const config = require('../config.cjs');
24
+ const { expandHome } = require('./expandHome.cjs');
25
+ const { readBody, sendJson } = require('./localAdminHttp.cjs');
20
26
 
21
27
  const STANDARDS_PATH = path.join(
22
28
  __dirname, '..', '..', '..',
@@ -33,12 +39,13 @@ function deriveSlugFromTitle(title) {
33
39
  }
34
40
 
35
41
  /**
36
- * Build the full PRD markdown body (frontmatter + required sections +
37
- * verbatim engineering standards), matching the structure `/develop`'s
38
- * SKILL.md documents: frontmatter, then Goal / Acceptance criteria /
39
- * Implementation notes / Out of scope / Engineering standards, in order.
42
+ * Build the full PRD markdown body (frontmatter + required sections + a
43
+ * pointer at the engineering standards file), matching the structure
44
+ * `/develop`'s SKILL.md documents: frontmatter, then Goal / Acceptance
45
+ * criteria / Implementation notes / Out of scope / Engineering standards,
46
+ * in order.
40
47
  */
41
- function buildPrdBody(input, standardsText) {
48
+ function buildPrdBody(input) {
42
49
  const {
43
50
  title, cwd, estimateMinutes, goal, acceptanceCriteria,
44
51
  implementationNotes, outOfScope,
@@ -53,21 +60,104 @@ function buildPrdBody(input, standardsText) {
53
60
  const oosSource = outOfScope && outOfScope.length ? outOfScope : ['(none)'];
54
61
  const oosLines = oosSource.map((line) => `- ${line}`).join('\n');
55
62
 
63
+ const standardsPointer = [
64
+ `Before writing any code, read \`${STANDARDS_PATH}\` — it has the Performance, Debugging,`,
65
+ 'API-reuse, TDD, and Execution-discipline rules that apply to this PRD. Every rule in it is',
66
+ 'mandatory, especially Execution discipline (bounded commands, verify before done, the',
67
+ 'finish-protocol sentinel).',
68
+ ].join('\n');
69
+
56
70
  const bodyLines = [
57
71
  '# Goal', '', goal, '',
58
72
  '# Acceptance criteria', '', acLines, '',
59
73
  '# Implementation notes', '', implementationNotes, '',
60
74
  '# Out of scope', '', oosLines, '',
61
- '## Engineering standards', '', standardsText.trimEnd(), '',
75
+ '## Engineering standards', '', standardsPointer, '',
62
76
  ];
63
77
 
64
78
  return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
65
79
  }
66
80
 
81
+ /**
82
+ * Registers the create-prd admin HTTP route (PRD 689 — moved verbatim out of
83
+ * the former standalone admin HTTP server module's handleRequest, no behavior
84
+ * change) against an injected localAdminHttp.cjs transport. `remote` is
85
+ * scheduler.cjs's remote object, passed explicitly (not required directly) so
86
+ * this stays testable without booting Electron, matching that former
87
+ * module's original dependency-injection pattern.
88
+ */
89
+ function registerAdminRoute(adminHttp, remote) {
90
+ adminHttp.registerRoute('POST', '/admin/scheduler/create-prd', async (req, res) => {
91
+ const raw = await readBody(req);
92
+ let parsed;
93
+ try {
94
+ parsed = raw ? JSON.parse(raw) : {};
95
+ } catch {
96
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
97
+ return;
98
+ }
99
+
100
+ let input;
101
+ try {
102
+ input = schemas.schedulerCreatePrd.parse(parsed);
103
+ } catch (e) {
104
+ sendJson(res, 400, { ok: false, error: 'invalid PRD payload', details: e?.issues ?? e?.message });
105
+ return;
106
+ }
107
+
108
+ // cwd is untrusted: this is the first *creating* mutation on a
109
+ // token-authed API whose product is "a command that will later run
110
+ // with --dangerously-skip-permissions in a chosen cwd". Route it
111
+ // through config.cjs's validatePath (allowedRoots = home dir) —
112
+ // same boundary every other fs-touching IPC handler uses — never a
113
+ // bespoke check here.
114
+ try {
115
+ config.validatePath(expandHome(input.cwd));
116
+ } catch (e) {
117
+ sendJson(res, 400, { ok: false, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` });
118
+ return;
119
+ }
120
+
121
+ const slug = input.slug || deriveSlugFromTitle(input.title);
122
+ if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
123
+ sendJson(res, 400, { ok: false, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' });
124
+ return;
125
+ }
126
+
127
+ // NN allocation is delegated to allocateParallelGroup() (PRD 548) via
128
+ // the injected remote — never re-derived here — unless the caller
129
+ // opted into an existing group explicitly.
130
+ const nn = input.parallelGroup ?? await remote.allocateParallelGroup();
131
+ const filenameSlug = `${nn}-${slug}`;
132
+
133
+ // An explicit `parallelGroup` bypasses allocateParallelGroup()'s
134
+ // collision-proof reservation, so re-check for an existing file at
135
+ // this exact destination before writing — remote.writePrd itself has
136
+ // no existence guard (by design, it doubles as the edit-in-place
137
+ // path for Scheduler UI PRD edits), so "create" must not silently
138
+ // clobber an existing job's PRD.
139
+ const existing = await remote.readPrd(filenameSlug);
140
+ if (existing?.ok) {
141
+ sendJson(res, 409, { ok: false, error: `PRD already exists: ${filenameSlug}.md` });
142
+ return;
143
+ }
144
+
145
+ const body = buildPrdBody(input);
146
+ const writeResult = await remote.writePrd(filenameSlug, body);
147
+ if (!writeResult?.ok) {
148
+ sendJson(res, 500, { ok: false, error: writeResult?.error ?? 'write failed' });
149
+ return;
150
+ }
151
+
152
+ sendJson(res, 200, { nn, filename: `${filenameSlug}.md`, status: 'queued' });
153
+ });
154
+ }
155
+
67
156
  module.exports = {
68
157
  PRD_CREATE_SLUG_RE,
69
158
  STANDARDS_PATH,
70
159
  readStandards,
71
160
  deriveSlugFromTitle,
72
161
  buildPrdBody,
162
+ registerAdminRoute,
73
163
  };