claude-code-session-manager 0.41.1 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-Cr-O75Yg.js → TiptapBody-7JlgGW3C.js} +1 -1
- package/dist/assets/{index-CKY3mHgV.css → index-CxGY-V5G.css} +1 -1
- package/dist/assets/{index-CV9TNjxX.js → index-D4ga1iZ5.js} +681 -681
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/watchdogHelpers.cjs +7 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +30 -11
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +94 -0
- package/src/main/lib/auditLog.cjs +60 -0
- package/src/main/lib/epicMint.cjs +6 -0
- package/src/main/lib/localAdminHttp.cjs +9 -0
- package/src/main/lib/prdCreate.cjs +12 -0
- package/src/main/lib/schedulerBatch.cjs +27 -5
- package/src/main/pty.cjs +8 -0
- package/src/main/scheduler.cjs +51 -0
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-D4ga1iZ5.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CxGY-V5G.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -313,9 +313,16 @@ async function emitFeedbackPRD(cwd, {
|
|
|
313
313
|
// Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
|
|
314
314
|
// one standing "Inbound feedback" Epic per project (reuseByGoal) so
|
|
315
315
|
// recurring ticks chain into a single Epic rather than minting one each.
|
|
316
|
+
// This is an unattended automated sweep, not a human action — status
|
|
317
|
+
// MUST be 'proposed' (same as lib/rcaFeedbackHook.cjs) so the Epic lands
|
|
318
|
+
// behind the Approve & start human gate instead of running immediately;
|
|
319
|
+
// an 'active' default here would let an unattended process mint and
|
|
320
|
+
// execute PRDs with zero human approval, exactly the hole the 'proposed'
|
|
321
|
+
// gate exists to close.
|
|
316
322
|
const epic = await ensureEpic(cwd, {
|
|
317
323
|
goalText: 'Inbound feedback processing',
|
|
318
324
|
tag: 'discussion',
|
|
325
|
+
status: 'proposed',
|
|
319
326
|
reuseByGoal: true,
|
|
320
327
|
});
|
|
321
328
|
epicId = epic.epicId;
|
|
@@ -11,12 +11,24 @@
|
|
|
11
11
|
|
|
12
12
|
'use strict';
|
|
13
13
|
|
|
14
|
-
import { test, expect } from 'vitest';
|
|
14
|
+
import { test, expect, afterAll } from 'vitest';
|
|
15
15
|
const http = require('node:http');
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const path = require('node:path');
|
|
18
|
+
const os = require('node:os');
|
|
18
19
|
const { createAdminHttp, TOKEN_PATH, resolveTokenPath } = require('../localAdminHttp.cjs');
|
|
19
20
|
|
|
21
|
+
// Redirect every start() in this file away from the PRODUCTION admin-api.json.
|
|
22
|
+
// Without this, these tests overwrote the running app's port+token with a
|
|
23
|
+
// throwaway server's, orphaning scheduler-mcp-server.cjs until an app restart
|
|
24
|
+
// (confirmed live 2026-08-01). Tests that specifically exercise path
|
|
25
|
+
// resolution unset this override themselves.
|
|
26
|
+
// Must live under the home dir: config.writeJson enforces allowedRoots.
|
|
27
|
+
const TEST_TOKEN_PATH = path.join(path.dirname(TOKEN_PATH), `admin-api.test-${process.pid}.json`);
|
|
28
|
+
process.env.SM_ADMIN_TOKEN_PATH = TEST_TOKEN_PATH;
|
|
29
|
+
|
|
30
|
+
afterAll(() => { fs.rmSync(TEST_TOKEN_PATH, { force: true }); });
|
|
31
|
+
|
|
20
32
|
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
21
33
|
return new Promise((resolve, reject) => {
|
|
22
34
|
const headers = {};
|
|
@@ -109,11 +121,11 @@ test('start() persists token file with port + token, mode 0600', async () => {
|
|
|
109
121
|
const admin = createAdminHttp();
|
|
110
122
|
const { port, token } = await admin.start();
|
|
111
123
|
try {
|
|
112
|
-
const raw = fs.readFileSync(
|
|
124
|
+
const raw = fs.readFileSync(TEST_TOKEN_PATH, 'utf8');
|
|
113
125
|
const parsed = JSON.parse(raw);
|
|
114
126
|
expect(parsed.port).toBe(port);
|
|
115
127
|
expect(parsed.token).toBe(token);
|
|
116
|
-
const mode = fs.statSync(
|
|
128
|
+
const mode = fs.statSync(TEST_TOKEN_PATH).mode & 0o777;
|
|
117
129
|
expect(mode).toBe(0o600);
|
|
118
130
|
} finally {
|
|
119
131
|
await admin.stop();
|
|
@@ -124,6 +136,8 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
|
|
|
124
136
|
const devPath = path.join(path.dirname(TOKEN_PATH), 'admin-api.dev.json');
|
|
125
137
|
const prevMtime = fs.existsSync(TOKEN_PATH) ? fs.statSync(TOKEN_PATH).mtimeMs : null;
|
|
126
138
|
const prevEnv = process.env.SM_DEV;
|
|
139
|
+
const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
|
|
140
|
+
delete process.env.SM_ADMIN_TOKEN_PATH;
|
|
127
141
|
process.env.SM_DEV = '1';
|
|
128
142
|
const admin = createAdminHttp();
|
|
129
143
|
try {
|
|
@@ -141,32 +155,36 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
|
|
|
141
155
|
} finally {
|
|
142
156
|
await admin.stop();
|
|
143
157
|
if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
|
|
158
|
+
if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
|
|
144
159
|
fs.rmSync(devPath, { force: true });
|
|
145
160
|
}
|
|
146
161
|
});
|
|
147
162
|
|
|
148
|
-
test('with neither SM_DEV nor SM_E2E set,
|
|
163
|
+
test('with neither SM_DEV nor SM_E2E set, resolves to the original admin-api.json path', () => {
|
|
164
|
+
// Path RESOLUTION only — deliberately does not start() a server here. This
|
|
165
|
+
// test used to boot one with no override set, which wrote a throwaway
|
|
166
|
+
// port+token straight into the live app's admin-api.json and cut
|
|
167
|
+
// scheduler-mcp-server.cjs off from the running app. Persistence itself is
|
|
168
|
+
// covered by the 0600 test above, against the redirected test path.
|
|
149
169
|
const prevDev = process.env.SM_DEV;
|
|
150
170
|
const prevE2e = process.env.SM_E2E;
|
|
171
|
+
const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
|
|
151
172
|
delete process.env.SM_DEV;
|
|
152
173
|
delete process.env.SM_E2E;
|
|
153
|
-
|
|
174
|
+
delete process.env.SM_ADMIN_TOKEN_PATH;
|
|
154
175
|
try {
|
|
155
176
|
expect(resolveTokenPath()).toBe(TOKEN_PATH);
|
|
156
|
-
admin = createAdminHttp();
|
|
157
|
-
const { port, token } = await admin.start();
|
|
158
|
-
const parsed = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
|
|
159
|
-
expect(parsed.port).toBe(port);
|
|
160
|
-
expect(parsed.token).toBe(token);
|
|
161
177
|
} finally {
|
|
162
|
-
if (admin) await admin.stop();
|
|
163
178
|
if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
|
|
164
179
|
if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
|
|
180
|
+
if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
|
|
165
181
|
}
|
|
166
182
|
});
|
|
167
183
|
|
|
168
184
|
test('dev and production token paths never collide', () => {
|
|
169
185
|
const prevEnv = process.env.SM_DEV;
|
|
186
|
+
const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
|
|
187
|
+
delete process.env.SM_ADMIN_TOKEN_PATH;
|
|
170
188
|
try {
|
|
171
189
|
process.env.SM_DEV = '1';
|
|
172
190
|
const devPath = resolveTokenPath();
|
|
@@ -175,6 +193,7 @@ test('dev and production token paths never collide', () => {
|
|
|
175
193
|
expect(devPath).not.toBe(prodPath);
|
|
176
194
|
} finally {
|
|
177
195
|
if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
|
|
196
|
+
if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
|
|
178
197
|
}
|
|
179
198
|
});
|
|
180
199
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchDepends.test.cjs
|
|
4
|
+
//
|
|
5
|
+
// Regression coverage for dependsOn eligibility in pickForProject. The
|
|
6
|
+
// bare-slug cases below are the ones that shipped broken: a PRD's filename
|
|
7
|
+
// carries the auto-allocated `NN-` prefix, but `dependsOn:` is authored with
|
|
8
|
+
// the bare name (the author can't know the number the allocator will issue),
|
|
9
|
+
// so every such dep silently resolved to "no row -> already done" and the
|
|
10
|
+
// gate never held anything.
|
|
11
|
+
|
|
12
|
+
// vitest globals (test) — same convention as the other .cjs tests.
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { pickForProject, DEFAULT_PROJECT_CWD } = require('../schedulerBatch.cjs');
|
|
15
|
+
|
|
16
|
+
const CWD = DEFAULT_PROJECT_CWD;
|
|
17
|
+
|
|
18
|
+
function job(slug, status, extra = {}) {
|
|
19
|
+
return {
|
|
20
|
+
slug,
|
|
21
|
+
status,
|
|
22
|
+
cwd: CWD,
|
|
23
|
+
parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
|
|
24
|
+
dependsOn: [],
|
|
25
|
+
...extra,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pick(jobs, running = new Set(), slots = 3) {
|
|
30
|
+
return pickForProject(jobs, running, slots);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('holds a dependent whose bare-named dep is still running (NN- prefix stripped)', () => {
|
|
34
|
+
const jobs = [
|
|
35
|
+
job('873-leftnav-two-face-framework', 'running'),
|
|
36
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
37
|
+
];
|
|
38
|
+
const { batch } = pick(jobs, new Set(['873-leftnav-two-face-framework']));
|
|
39
|
+
assert.deepEqual(batch.map((j) => j.slug), []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('holds a dependent whose bare-named dep is still pending', () => {
|
|
43
|
+
const jobs = [
|
|
44
|
+
job('873-leftnav-two-face-framework', 'pending'),
|
|
45
|
+
job('895-home-global-behavior-settings', 'pending', {
|
|
46
|
+
dependsOn: ['leftnav-two-face-framework', 'nav-face-session-manager-config'],
|
|
47
|
+
}),
|
|
48
|
+
job('890-nav-face-session-manager-config', 'pending', {
|
|
49
|
+
dependsOn: ['leftnav-two-face-framework'],
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
52
|
+
const { batch } = pick(jobs);
|
|
53
|
+
// Only the framework PRD (no deps) is eligible; both dependents are held.
|
|
54
|
+
assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('releases a dependent once its bare-named dep is completed', () => {
|
|
58
|
+
const jobs = [
|
|
59
|
+
job('873-leftnav-two-face-framework', 'completed'),
|
|
60
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
61
|
+
];
|
|
62
|
+
const { batch } = pick(jobs);
|
|
63
|
+
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('still resolves an exact-slug dep (dep written WITH the NN- prefix)', () => {
|
|
67
|
+
const jobs = [
|
|
68
|
+
job('873-leftnav-two-face-framework', 'pending'),
|
|
69
|
+
job('874-nav-face-project-home', 'pending', {
|
|
70
|
+
dependsOn: ['873-leftnav-two-face-framework'],
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
const { batch } = pick(jobs);
|
|
74
|
+
assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a dep with no row at all is treated as already done (archived/retired)', () => {
|
|
78
|
+
const jobs = [
|
|
79
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['some-long-archived-prd'] }),
|
|
80
|
+
];
|
|
81
|
+
const { batch } = pick(jobs);
|
|
82
|
+
assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a FAILED bare-named dep holds the dependent and reports an explicit reason', () => {
|
|
86
|
+
const jobs = [
|
|
87
|
+
job('873-leftnav-two-face-framework', 'failed'),
|
|
88
|
+
job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
|
|
89
|
+
];
|
|
90
|
+
const { batch, reason } = pick(jobs);
|
|
91
|
+
assert.deepEqual(batch, []);
|
|
92
|
+
assert.match(reason, /depends-gate/);
|
|
93
|
+
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
94
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auditLog.cjs — append-only trace of every Epic/PRD creation, so a rogue or
|
|
3
|
+
* unexpected Epic/PRD can always be traced back to its origin.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately machine-level (~/.claude/session-manager/), not under any
|
|
6
|
+
* project's session-manager-operations/ — this is a security trace, not app
|
|
7
|
+
* state a project's single-writer law needs to arbitrate, and it must
|
|
8
|
+
* survive even if the project's ops root doesn't exist yet.
|
|
9
|
+
*
|
|
10
|
+
* Best-effort only: a logging failure must never block the mint/create it is
|
|
11
|
+
* observing (same posture as rcaFeedbackHook.cjs / dodDrainHook.cjs).
|
|
12
|
+
*
|
|
13
|
+
* Plain Node module (no Electron deps) so scripts/mint-epic.cjs and
|
|
14
|
+
* scripts/propose-epic.cjs (which run outside Electron) can require it too.
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const AUDIT_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'audit-log.jsonl');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort identity of the process making this call — there is no
|
|
26
|
+
* cryptographic caller identity available across the admin HTTP API (bearer
|
|
27
|
+
* token proves "runs as this OS user", not "which agent/session"), so this
|
|
28
|
+
* captures every breadcrumb that IS available: this process's pid/ppid, and
|
|
29
|
+
* (when set) the claude session id chatRunner.cjs stamps on headless
|
|
30
|
+
* `claude -p` children and passes down to MCP server children.
|
|
31
|
+
*/
|
|
32
|
+
function callerContext() {
|
|
33
|
+
return {
|
|
34
|
+
pid: process.pid,
|
|
35
|
+
ppid: process.ppid,
|
|
36
|
+
claudeSessionId: process.env.SM_CHAT_SESSION_ID || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* appendAuditEvent(kind, fields) — append one JSONL record. `kind` is a short
|
|
42
|
+
* label ('epic_mint' | 'prd_create' | ...); `fields` is event-specific detail
|
|
43
|
+
* (cwd, epicId, status, title, slug, etc). Never throws.
|
|
44
|
+
*/
|
|
45
|
+
function appendAuditEvent(kind, fields = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const record = {
|
|
48
|
+
at: new Date().toISOString(),
|
|
49
|
+
kind,
|
|
50
|
+
...fields,
|
|
51
|
+
caller: callerContext(),
|
|
52
|
+
};
|
|
53
|
+
fs.mkdirSync(path.dirname(AUDIT_LOG_PATH), { recursive: true });
|
|
54
|
+
fs.appendFileSync(AUDIT_LOG_PATH, `${JSON.stringify(record)}\n`);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error('[auditLog] failed to append audit event', e?.message ?? String(e));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { appendAuditEvent, AUDIT_LOG_PATH };
|
|
@@ -22,6 +22,7 @@ const path = require('node:path');
|
|
|
22
22
|
const crypto = require('node:crypto');
|
|
23
23
|
const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
|
|
24
24
|
const { assertOpsWrite } = require('./opsOwnership.cjs');
|
|
25
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
25
26
|
|
|
26
27
|
function activeIndexPath(cwd) {
|
|
27
28
|
return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
@@ -164,6 +165,11 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
164
165
|
index.events[epicId] = [firstEvent];
|
|
165
166
|
writeActiveIndex(cwd, index);
|
|
166
167
|
|
|
168
|
+
// Every mint is logged, whether the Epic starts 'proposed' (human gate
|
|
169
|
+
// ahead) or 'active' (started immediately) — this is the trace-back point
|
|
170
|
+
// for "who/what created this Epic" (see auditLog.cjs).
|
|
171
|
+
appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText });
|
|
172
|
+
|
|
167
173
|
const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
168
174
|
fs.mkdirSync(prdDir, { recursive: true });
|
|
169
175
|
return { epicId, prdDir, created: true };
|
|
@@ -49,6 +49,15 @@ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-
|
|
|
49
49
|
* harmless tie-break, since real launches only ever set one or the other.
|
|
50
50
|
*/
|
|
51
51
|
function resolveTokenPath() {
|
|
52
|
+
// Explicit override, highest priority. Exists because the unit tests call
|
|
53
|
+
// createAdminHttp().start() directly with neither SM_DEV nor SM_E2E set,
|
|
54
|
+
// which resolved to the PRODUCTION admin-api.json and overwrote the
|
|
55
|
+
// running app's port+token with a throwaway test server's — silently
|
|
56
|
+
// orphaning scheduler-mcp-server.cjs's access to the live app until the
|
|
57
|
+
// next app restart (confirmed live 2026-08-01: `npm run test:unit` made
|
|
58
|
+
// every scheduler_* MCP tool report "app is not running"). Tests point
|
|
59
|
+
// this at a tmp file; production never sets it.
|
|
60
|
+
if (process.env.SM_ADMIN_TOKEN_PATH) return process.env.SM_ADMIN_TOKEN_PATH;
|
|
52
61
|
if (process.env.SM_DEV === '1') {
|
|
53
62
|
return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.dev.json');
|
|
54
63
|
}
|
|
@@ -24,6 +24,7 @@ const config = require('../config.cjs');
|
|
|
24
24
|
const { expandHome } = require('./expandHome.cjs');
|
|
25
25
|
const { readBody, sendJson } = require('./localAdminHttp.cjs');
|
|
26
26
|
const { readActiveIndex } = require('./epicMint.cjs');
|
|
27
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
27
28
|
|
|
28
29
|
const STANDARDS_PATH = path.join(
|
|
29
30
|
__dirname, '..', '..', '..',
|
|
@@ -192,6 +193,17 @@ async function createPrd(input, remote) {
|
|
|
192
193
|
return { ok: false, status: 500, error: writeResult?.error ?? 'write failed' };
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
// Trace-back point for "who/what created this PRD" (auditLog.cjs) — every
|
|
197
|
+
// PRD write, whether from the admin/MCP route or the renderer's chat:create-prd
|
|
198
|
+
// IPC handler, lands one record here regardless of outcome path above.
|
|
199
|
+
appendAuditEvent('prd_create', {
|
|
200
|
+
cwd: input.cwd,
|
|
201
|
+
slug: filenameSlug,
|
|
202
|
+
title: input.title,
|
|
203
|
+
sourcePromptId: input.sourcePromptId ?? null,
|
|
204
|
+
sourceTabId: input.sourceTabId ?? null,
|
|
205
|
+
});
|
|
206
|
+
|
|
195
207
|
return { ok: true, nn, filename: `${filenameSlug}.md` };
|
|
196
208
|
}
|
|
197
209
|
|
|
@@ -49,10 +49,32 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
49
49
|
// unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
|
|
50
50
|
// its order without migration.
|
|
51
51
|
const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// A PRD's filename slug carries the auto-allocated `NN-` prefix
|
|
53
|
+
// (prdCreate.cjs builds `${nn}-${slug}`), but a human/agent authoring
|
|
54
|
+
// `dependsOn:` writes the BARE name it chose — it cannot know the number
|
|
55
|
+
// the allocator will hand out. Without this, every such dep resolved to
|
|
56
|
+
// undefined and was silently treated as "already done", turning the whole
|
|
57
|
+
// dependency gate into a no-op (observed live 2026-08-01: 22 PRDs listing
|
|
58
|
+
// `leftnav-two-face-framework` while the real row was
|
|
59
|
+
// `873-leftnav-two-face-framework`, all of them eligible immediately even
|
|
60
|
+
// though the framework PRD they build on had not landed). Resolve a dep by
|
|
61
|
+
// exact slug first, then fall back to matching rows whose slug is that dep
|
|
62
|
+
// with a leading `NN-` stripped.
|
|
63
|
+
const rowsByBareSlug = new Map();
|
|
64
|
+
for (const j of projectJobs) {
|
|
65
|
+
const bare = String(j.slug ?? '').replace(/^\d+-/, '');
|
|
66
|
+
if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
|
|
67
|
+
rowsByBareSlug.get(bare).push(j);
|
|
68
|
+
}
|
|
69
|
+
/** Every queue row a dep string refers to (exact match, else bare-name match). */
|
|
70
|
+
const rowsForDep = (slug) => {
|
|
71
|
+
const exact = rowBySlug.get(slug);
|
|
72
|
+
if (exact) return [exact];
|
|
73
|
+
return rowsByBareSlug.get(String(slug ?? '').replace(/^\d+-/, '')) ?? [];
|
|
74
|
+
};
|
|
75
|
+
const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => (
|
|
76
|
+
rowsForDep(slug).some((dep) => dep.status !== 'completed')
|
|
77
|
+
));
|
|
56
78
|
|
|
57
79
|
const allPending = projectJobs.filter(
|
|
58
80
|
(j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
|
|
@@ -64,7 +86,7 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
64
86
|
for (const j of allPending) {
|
|
65
87
|
const dep = blockingDep(j);
|
|
66
88
|
if (!dep) { pending.push(j); continue; }
|
|
67
|
-
if (
|
|
89
|
+
if (rowsForDep(dep).some((d) => d.status === 'failed')) heldByFailedDep.push({ job: j, dep });
|
|
68
90
|
// running/pending/needs_review dep — simply not eligible this tick.
|
|
69
91
|
}
|
|
70
92
|
if (pending.length === 0) {
|
package/src/main/pty.cjs
CHANGED
|
@@ -147,6 +147,14 @@ class PtyManager {
|
|
|
147
147
|
FORCE_COLOR: '1',
|
|
148
148
|
// Tag the session so hook server / transcript tail can correlate.
|
|
149
149
|
SESSION_MANAGER_TAB_ID: tabId,
|
|
150
|
+
// Tab ID = claudeSessionId by design (see CLAUDE.md domain model), and
|
|
151
|
+
// for an Epic, Terminal/Chat are two views over the SAME session — so
|
|
152
|
+
// this is exactly the id chatRunner.cjs sets for headless chat runs.
|
|
153
|
+
// Without it, a PRD queued via scheduler_create_prd from an
|
|
154
|
+
// interactive Epic terminal (not headless chat) had no way to resolve
|
|
155
|
+
// back to its originating Epic and minted an unrelated sibling one
|
|
156
|
+
// instead (prdCreate.cjs's resolveSourcePromptIdFromClaudeSession).
|
|
157
|
+
SM_CHAT_SESSION_ID: tabId,
|
|
150
158
|
});
|
|
151
159
|
|
|
152
160
|
const shell = process.env.SHELL || '/bin/bash';
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -1071,6 +1071,11 @@ function reconcileSourcePromptId(job, parsedSourcePromptId) {
|
|
|
1071
1071
|
* Status is preserved: pending stays pending, completed stays completed.
|
|
1072
1072
|
* Newly-discovered PRDs land as `pending`.
|
|
1073
1073
|
*/
|
|
1074
|
+
// Epic ids already warned about (PRD-bearing but absent from
|
|
1075
|
+
// active-index.json). Module-scoped so the warning is one line per Epic per
|
|
1076
|
+
// process rather than one per 60s reconcile tick.
|
|
1077
|
+
const warnedUnregisteredEpics = new Set();
|
|
1078
|
+
|
|
1074
1079
|
async function reconcile(state) {
|
|
1075
1080
|
// Defence in depth — tickQueue already gates on this, but reconcile is the
|
|
1076
1081
|
// function that would do the damage (every unmatched PRD .md becomes a
|
|
@@ -1163,8 +1168,54 @@ async function reconcile(state) {
|
|
|
1163
1168
|
// for them (that row already exists, durably, in history.jsonl).
|
|
1164
1169
|
const historyArchiveCandidates = [];
|
|
1165
1170
|
|
|
1171
|
+
// Cache active-index.json reads per cwd across this reconcile pass — many
|
|
1172
|
+
// unmatched PRDs typically share a project, and readActiveIndex is a
|
|
1173
|
+
// synchronous fs.readFileSync.
|
|
1174
|
+
const epicStatusCache = new Map(); // cwd -> { sessions }
|
|
1175
|
+
function epicStatus(cwd, epicId) {
|
|
1176
|
+
if (!cwd || !epicId) return null;
|
|
1177
|
+
let idx = epicStatusCache.get(cwd);
|
|
1178
|
+
if (!idx) {
|
|
1179
|
+
idx = readActiveIndex(cwd);
|
|
1180
|
+
epicStatusCache.set(cwd, idx);
|
|
1181
|
+
}
|
|
1182
|
+
return idx.sessions[epicId]?.status ?? null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1166
1185
|
for (const [slug, p] of onDisk) {
|
|
1167
1186
|
if (seen.has(slug)) continue;
|
|
1187
|
+
// Security gate: a PRD's file location IS its Epic membership
|
|
1188
|
+
// (prdLocations.deriveEpicIdFromPrdPath / p.epicId). Before this check,
|
|
1189
|
+
// the scheduler queued ANY .md file it found under an Epic's prds/ dir
|
|
1190
|
+
// regardless of that Epic's status or even whether it was ever properly
|
|
1191
|
+
// minted — so a PRD filed under a 'proposed' Epic (agent-proposed,
|
|
1192
|
+
// awaiting human Approve & start via EpicApprovalBar), or one dropped
|
|
1193
|
+
// straight onto disk under a directory that was never registered via
|
|
1194
|
+
// ensureEpic()/active-index.json at all (bypassing the app entirely),
|
|
1195
|
+
// would still execute as a claude -p job with --dangerously-skip-permissions.
|
|
1196
|
+
// Block ONLY an explicit 'proposed' — the actual human gate. The human's
|
|
1197
|
+
// Approve action flips status to 'active' (state/promptSessions.ts) and
|
|
1198
|
+
// the very next reconcile pass picks the PRD up with no other change.
|
|
1199
|
+
//
|
|
1200
|
+
// This deliberately does NOT require a positive 'active' registration.
|
|
1201
|
+
// That stricter form shipped briefly and caused a 6-hour silent outage
|
|
1202
|
+
// (2026-08-01): 23 real PRDs whose Epic dir existed on disk but had no
|
|
1203
|
+
// active-index.json entry were skipped forever with no log line, so the
|
|
1204
|
+
// scheduler looked simply dead. Absence of an entry is not evidence of a
|
|
1205
|
+
// rogue writer — an Epic archived on completion also leaves active-index,
|
|
1206
|
+
// and other paths can create the dir without registering it. Fail-closed
|
|
1207
|
+
// is right for the approval gate; it is wrong as a blanket requirement,
|
|
1208
|
+
// because the failure mode is invisible and unrecoverable by the user.
|
|
1209
|
+
const ownerStatus = epicStatus(p.cwd, p.epicId);
|
|
1210
|
+
if (p.epicId && ownerStatus === 'proposed') {
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
// Unregistered Epic dir: allowed, but never silently — this is the only
|
|
1214
|
+
// signal that a PRD's Epic is missing from the index it should be in.
|
|
1215
|
+
if (p.epicId && ownerStatus === null && !warnedUnregisteredEpics.has(p.epicId)) {
|
|
1216
|
+
warnedUnregisteredEpics.add(p.epicId);
|
|
1217
|
+
console.warn(`[scheduler] epic ${p.epicId} (${p.cwd}) has PRDs on disk but no active-index.json entry — queuing anyway; register or archive it to silence this`);
|
|
1218
|
+
}
|
|
1168
1219
|
const hist = historyBySlug.get(slug);
|
|
1169
1220
|
if (hist) {
|
|
1170
1221
|
if (hist.status === 'completed') {
|