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.
- package/bin/cli.cjs +12 -1
- package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-GMk5LS9Y.js} +1 -1
- package/dist/assets/index-CD_LuJZF.css +32 -0
- package/dist/assets/{index--a8m5_ET.js → index-CQqSHpIq.js} +444 -436
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
- package/plugins/session-manager-dev/skills/develop/standards.md +25 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
- package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
- package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
- package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
- package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
- package/scripts/lib/watchdogHelpers.cjs +296 -166
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
- package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
- package/src/main/__tests__/docEdit.test.cjs +164 -2
- package/src/main/__tests__/prdCreate.test.cjs +265 -25
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
- package/src/main/browserAgentServer.cjs +11 -10
- package/src/main/chatRunner.cjs +58 -14
- package/src/main/docEdit.cjs +125 -5
- package/src/main/health.cjs +15 -0
- package/src/main/index.cjs +12 -6
- package/src/main/ipcSchemas.cjs +16 -3
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
- package/src/main/lib/classifyTranscriptLine.cjs +89 -0
- package/src/main/lib/localAdminHttp.cjs +157 -0
- package/src/main/lib/personaImportHealth.cjs +161 -0
- package/src/main/lib/prdCreate.cjs +107 -17
- package/src/main/lib/rcaFeedbackHook.cjs +2 -2
- package/src/main/lib/singleInstanceGuard.cjs +20 -0
- package/src/main/scheduler.cjs +223 -56
- package/src/main/sessionsStore.cjs +4 -5
- package/src/main/templates/PRD_AUTHORING.md +4 -4
- package/src/main/transcripts.cjs +1 -85
- package/src/main/webRemote.cjs +3 -3
- package/src/preload/api.d.ts +16 -6
- package/src/preload/index.cjs +9 -2
- package/dist/assets/index-CTTjT08J.css +0 -32
- package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
- package/src/main/__tests__/adminServer.test.cjs +0 -380
- 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
|
|
3
|
-
* gh-issue-6
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
38
|
-
* SKILL.md documents: frontmatter, then Goal / Acceptance
|
|
39
|
-
* Implementation notes / Out of scope / Engineering standards,
|
|
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
|
|
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', '',
|
|
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
|
};
|