claude-code-session-manager 0.52.0 → 0.53.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-CQtxxn1C.js → TiptapBody-BYG18cfk.js} +1 -1
- package/dist/assets/{index-CKP60_3d.css → index-BoOiJJOr.css} +1 -1
- package/dist/assets/{index-hNso_ykJ.js → index-D1v4VU6k.js} +683 -683
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/epicMint.test.cjs +110 -0
- package/src/main/__tests__/prdCreate.test.cjs +6 -0
- package/src/main/__tests__/prdMigration.test.cjs +5 -0
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +38 -5
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +89 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +8 -0
- package/src/main/index.cjs +7 -0
- package/src/main/ipcSchemas.cjs +20 -0
- package/src/main/lib/epicMint.cjs +32 -8
- package/src/main/scheduler.cjs +41 -12
- package/src/preload/api.d.ts +9 -0
- package/src/preload/index.cjs +3 -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-D1v4VU6k.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-BoOiJJOr.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.53.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",
|
|
@@ -200,6 +200,116 @@ test('findJoinableEpic falls through to the similarity check when preferEpicId p
|
|
|
200
200
|
expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test('ensureEpic mints with status "proposed" by default', async () => {
|
|
204
|
+
const cwd = await mkCwd();
|
|
205
|
+
|
|
206
|
+
const minted = await ensureEpic(cwd, { goalText: 'default status epic' });
|
|
207
|
+
|
|
208
|
+
const index = readActiveIndex(cwd);
|
|
209
|
+
expect(index.sessions[minted.epicId].status).toBe('proposed');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('ensureEpic throws when a mint is requested with status "active" (born-proposed law), and appends an audit event', async () => {
|
|
213
|
+
const cwd = await mkCwd();
|
|
214
|
+
const auditLog = require('../lib/auditLog.cjs');
|
|
215
|
+
|
|
216
|
+
await expect(
|
|
217
|
+
ensureEpic(cwd, { goalText: 'should never mint active', status: 'active' }),
|
|
218
|
+
).rejects.toThrow(/born 'proposed'/);
|
|
219
|
+
|
|
220
|
+
const index = readActiveIndex(cwd);
|
|
221
|
+
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
222
|
+
|
|
223
|
+
const records = fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
|
|
224
|
+
.split('\n')
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.map((line) => JSON.parse(line))
|
|
227
|
+
.filter((r) => r.cwd === cwd);
|
|
228
|
+
expect(records).toHaveLength(1);
|
|
229
|
+
expect(records[0].kind).toBe('epic_mint_refused');
|
|
230
|
+
expect(records[0].status).toBe('active');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('ensureEpic still joins an existing active Epic via preferEpicId even when status:"active" is requested', async () => {
|
|
234
|
+
const cwd = await mkCwd();
|
|
235
|
+
const first = await ensureEpic(cwd, { goalText: 'already running epic' });
|
|
236
|
+
const index = readActiveIndex(cwd);
|
|
237
|
+
index.sessions[first.epicId].status = 'active';
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
240
|
+
JSON.stringify(index, null, 2),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const second = await ensureEpic(cwd, { goalText: 'unrelated text', epicId: first.epicId, status: 'active' });
|
|
244
|
+
|
|
245
|
+
expect(second.created).toBe(false);
|
|
246
|
+
expect(second.epicId).toBe(first.epicId);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('ensureEpic falls through to a "proposed" mint when explicit epicId is archived/completed, never throwing', async () => {
|
|
250
|
+
const cwd = await mkCwd();
|
|
251
|
+
const first = await ensureEpic(cwd, { goalText: 'completed epic' });
|
|
252
|
+
const index = readActiveIndex(cwd);
|
|
253
|
+
index.sessions[first.epicId].status = 'completed';
|
|
254
|
+
fs.writeFileSync(
|
|
255
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
256
|
+
JSON.stringify(index, null, 2),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const second = await ensureEpic(cwd, { goalText: 'totally unrelated goal text here', epicId: first.epicId });
|
|
260
|
+
|
|
261
|
+
expect(second.created).toBe(true);
|
|
262
|
+
expect(second.epicId).not.toBe(first.epicId);
|
|
263
|
+
const after = readActiveIndex(cwd);
|
|
264
|
+
expect(after.sessions[second.epicId].status).toBe('proposed');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
function readAuditRecordsForCwd(auditLog, cwd) {
|
|
268
|
+
if (!fs.existsSync(auditLog.AUDIT_LOG_PATH)) return [];
|
|
269
|
+
return fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
|
|
270
|
+
.split('\n')
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.map((line) => {
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(line);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
.filter((record) => record && record.cwd === cwd);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
test('mintIfMissing:false refusal appends an epic_mint_refused audit event', async () => {
|
|
283
|
+
const cwd = await mkCwd();
|
|
284
|
+
const auditLog = require('../lib/auditLog.cjs');
|
|
285
|
+
|
|
286
|
+
await expect(ensureEpic(cwd, { goalText: 'no epic yet', mintIfMissing: false })).rejects.toThrow();
|
|
287
|
+
|
|
288
|
+
const records = readAuditRecordsForCwd(auditLog, cwd);
|
|
289
|
+
expect(records).toHaveLength(1);
|
|
290
|
+
expect(records[0].kind).toBe('epic_mint_refused');
|
|
291
|
+
expect(records[0].cwd).toBe(cwd);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('not-open explicit-epicId fall-through appends an epic_mint_refused audit event', async () => {
|
|
295
|
+
const cwd = await mkCwd();
|
|
296
|
+
const first = await ensureEpic(cwd, { goalText: 'to be completed' });
|
|
297
|
+
const index = readActiveIndex(cwd);
|
|
298
|
+
index.sessions[first.epicId].status = 'completed';
|
|
299
|
+
fs.writeFileSync(
|
|
300
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
301
|
+
JSON.stringify(index, null, 2),
|
|
302
|
+
);
|
|
303
|
+
const auditLog = require('../lib/auditLog.cjs');
|
|
304
|
+
|
|
305
|
+
await ensureEpic(cwd, { goalText: 'unrelated fresh goal xyz', epicId: first.epicId });
|
|
306
|
+
|
|
307
|
+
const records = readAuditRecordsForCwd(auditLog, cwd);
|
|
308
|
+
const refusal = records.find((r) => r.kind === 'epic_mint_refused');
|
|
309
|
+
expect(refusal).toBeDefined();
|
|
310
|
+
expect(refusal.epicId).toBe(first.epicId);
|
|
311
|
+
});
|
|
312
|
+
|
|
203
313
|
test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
|
|
204
314
|
const cwd = await mkCwd();
|
|
205
315
|
|
|
@@ -532,6 +532,11 @@ test('resolveSourcePromptIdFromClaudeSession finds the Epic whose claudeSessionI
|
|
|
532
532
|
}
|
|
533
533
|
});
|
|
534
534
|
|
|
535
|
+
// Real-homedir-rooted like the chat-only-epic test above (see its comment at
|
|
536
|
+
// line ~449): validatePath's read boundary is home-dir-wide, and this
|
|
537
|
+
// exercises createPrd's real config.addAllowedRoot/writeTextAtomic path. Safe
|
|
538
|
+
// because the random suffix is never a registered project the live scheduler
|
|
539
|
+
// scans for PRDs, and it's removed in `finally`.
|
|
535
540
|
test('createPrd resolves sourcePromptId from originClaudeSessionId when the caller omitted sourcePromptId', async () => {
|
|
536
541
|
const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-epic-'));
|
|
537
542
|
config.addAllowedRoot(root);
|
|
@@ -555,6 +560,7 @@ test('createPrd resolves sourcePromptId from originClaudeSessionId when the call
|
|
|
555
560
|
}
|
|
556
561
|
});
|
|
557
562
|
|
|
563
|
+
// Same real-homedir rationale as the two tests above.
|
|
558
564
|
test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
|
|
559
565
|
const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
|
|
560
566
|
config.addAllowedRoot(root);
|
|
@@ -105,6 +105,11 @@ test('migratePrds ignores non-.md and dotfiles, and no-ops on a missing legacy d
|
|
|
105
105
|
|
|
106
106
|
test('migratePrds expands a tilde-prefixed cwd and moves the file', async () => {
|
|
107
107
|
const legacyDir = await mkLegacyDir();
|
|
108
|
+
// Deliberately rooted under the REAL os.homedir() (not os.tmpdir()) — the
|
|
109
|
+
// assertion under test is tilde-expansion relative to the actual home dir,
|
|
110
|
+
// so a real homedir path is load-bearing here, not incidental. Still safe:
|
|
111
|
+
// the random suffix is never a registered project the live scheduler scans
|
|
112
|
+
// for PRDs, and it's removed in afterEach via tmpDirs/fsp.rm.
|
|
108
113
|
const projectCwd = await fsp.mkdtemp(path.join(os.homedir(), '.sm-prdmigration-tilde-'));
|
|
109
114
|
tmpDirs.push(projectCwd);
|
|
110
115
|
const tildeCwd = path.join('~', path.relative(os.homedir(), projectCwd));
|
|
@@ -6,20 +6,53 @@
|
|
|
6
6
|
* `<cwd>/session-manager-operations/scheduler/prds/`) must still resolve,
|
|
7
7
|
* since findPrdDir's candidatePrdsDirs() searches the legacy dir first.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* os.homedir()
|
|
11
|
-
*
|
|
9
|
+
* HOME-isolated (PRD 812 incident fix): scheduler.cjs derives PRDS_DIR from
|
|
10
|
+
* os.homedir() at module load. This test used to write straight into the
|
|
11
|
+
* REAL `~/.claude/session-manager/scheduled-plans/prds/` — the live
|
|
12
|
+
* scheduler's `reconcile()` scans that dir every 60s and enqueued the
|
|
13
|
+
* fixture as a phantom job (job 812-test-legacy-only-1975929-233060) before
|
|
14
|
+
* this test's own cleanup ran. Now HOME is stubbed to a mkdtemp dir BEFORE
|
|
15
|
+
* scheduler.cjs is first required, so PRDS_DIR resolves under the temp dir
|
|
16
|
+
* and nothing here can reach the real scheduler root. The
|
|
17
|
+
* `PRDS_DIR.startsWith(tmpHome)` guard is the regression test for that
|
|
18
|
+
* incident: if module-load ordering ever changes so the real homedir gets
|
|
19
|
+
* snapshotted first, this throws loudly instead of silently poisoning the
|
|
20
|
+
* live queue again.
|
|
12
21
|
*
|
|
13
22
|
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-find-prd-dir.test.cjs
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
'use strict';
|
|
17
26
|
|
|
18
|
-
import { test, expect } from 'vitest';
|
|
27
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
19
28
|
const fs = require('node:fs');
|
|
20
29
|
const os = require('node:os');
|
|
21
30
|
const path = require('node:path');
|
|
22
|
-
|
|
31
|
+
|
|
32
|
+
let tmpHome;
|
|
33
|
+
let originalHome;
|
|
34
|
+
let findPrdDir;
|
|
35
|
+
let PRDS_DIR;
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
originalHome = process.env.HOME;
|
|
39
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-find-prd-dir-'));
|
|
40
|
+
process.env.HOME = tmpHome;
|
|
41
|
+
|
|
42
|
+
// Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
|
|
43
|
+
// PRDS_DIR at module load, not lazily.
|
|
44
|
+
({ findPrdDir, PRDS_DIR } = require('../scheduler.cjs'));
|
|
45
|
+
|
|
46
|
+
// Hard guard: never let this test write into the real scheduler root.
|
|
47
|
+
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
48
|
+
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
process.env.HOME = originalHome;
|
|
54
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
55
|
+
});
|
|
23
56
|
|
|
24
57
|
test('findPrdDir resolves a slug that exists only in the legacy global PRDs dir', async () => {
|
|
25
58
|
const slug = `812-test-legacy-only-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-prd-missing-skip.test.cjs — regression test for PRD 812's
|
|
3
|
+
* failure-classification fix: a queued job whose PRD source has disappeared
|
|
4
|
+
* between enqueue and dispatch (e.g. a test fixture that leaked into the
|
|
5
|
+
* live queue and was cleaned up before the job ran) must be retired as a
|
|
6
|
+
* stale row (exitCode: 0, skipped: 'prd-missing'), not treated as a hard
|
|
7
|
+
* job failure (exitCode: -1). A genuinely unreadable/unparseable PRD (a
|
|
8
|
+
* real, non-ENOENT error) must still fail with exitCode: -1.
|
|
9
|
+
*
|
|
10
|
+
* HOME-isolated per the same incident: stub HOME to a mkdtemp dir before
|
|
11
|
+
* requiring scheduler.cjs, and never write into the real scheduler root.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-prd-missing-skip.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
let tmpHome;
|
|
24
|
+
let originalHome;
|
|
25
|
+
let executeJob;
|
|
26
|
+
let PRDS_DIR;
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
originalHome = process.env.HOME;
|
|
30
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-skip-'));
|
|
31
|
+
process.env.HOME = tmpHome;
|
|
32
|
+
|
|
33
|
+
({ executeJob, PRDS_DIR } = require('../scheduler.cjs'));
|
|
34
|
+
|
|
35
|
+
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
36
|
+
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
process.env.HOME = originalHome;
|
|
42
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a job whose PRD source is gone everywhere is retired, not failed', async () => {
|
|
46
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-project-'));
|
|
47
|
+
// config.cjs's writeJsonSync (used for the job's meta.json) restricts
|
|
48
|
+
// writes to WRITE_PREFIXES, which includes `<home>/.claude` — so runDir
|
|
49
|
+
// must live under the stubbed tmpHome's .claude/, not the system tmpdir.
|
|
50
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
51
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-missing-run-'));
|
|
52
|
+
try {
|
|
53
|
+
const slug = `812-test-missing-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
54
|
+
const job = { slug, cwd: projectCwd };
|
|
55
|
+
|
|
56
|
+
const result = await executeJob(job, runDir, projectCwd, () => {});
|
|
57
|
+
|
|
58
|
+
expect(result.exitCode).toBe(0);
|
|
59
|
+
expect(result.skipped).toBe('prd-missing');
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
62
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a job whose PRD source exists but is unreadable for a non-ENOENT reason still fails', async () => {
|
|
67
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-toobig-project-'));
|
|
68
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
69
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-toobig-run-'));
|
|
70
|
+
try {
|
|
71
|
+
const slug = `812-test-toobig-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
72
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
73
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
74
|
+
// parsePrd rejects any PRD over 1MB with a plain Error (no .code) — this
|
|
75
|
+
// is the "found but unparseable" case, distinct from ENOENT, and must
|
|
76
|
+
// stay a real failure.
|
|
77
|
+
const oversized = '---\ntitle: too big\n---\n\n' + 'x'.repeat(1024 * 1024 + 1);
|
|
78
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), oversized, 'utf8');
|
|
79
|
+
|
|
80
|
+
const job = { slug, cwd: projectCwd };
|
|
81
|
+
const result = await executeJob(job, runDir, projectCwd, () => {});
|
|
82
|
+
|
|
83
|
+
expect(result.exitCode).toBe(-1);
|
|
84
|
+
expect(result.skipped).toBeUndefined();
|
|
85
|
+
} finally {
|
|
86
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
87
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -6,6 +6,14 @@ import fs from 'node:fs';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
|
+
// HOME stubbed BEFORE requiring scheduler.cjs (PRD 812 fix): the last
|
|
10
|
+
// describe block below calls scheduler.reconcile(), which internally calls
|
|
11
|
+
// ensureDirs() → fs.mkdirSync on PRDS_DIR/RUNS_DIR — paths baked from
|
|
12
|
+
// os.homedir() at require time. Without this stub that mkdirSync lands in
|
|
13
|
+
// the real `~/.claude/session-manager/scheduled-plans/` tree.
|
|
14
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-832-home-'));
|
|
15
|
+
process.env.HOME = tmpHome;
|
|
16
|
+
|
|
9
17
|
const prdParser = require('../scheduler/prdParser.cjs');
|
|
10
18
|
const { pickForProject } = require('../lib/schedulerBatch.cjs');
|
|
11
19
|
|
package/src/main/index.cjs
CHANGED
|
@@ -28,6 +28,7 @@ const voiceWizard = require('./voiceWizard.cjs');
|
|
|
28
28
|
const scheduler = require('./scheduler.cjs');
|
|
29
29
|
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
30
30
|
const prdCreate = require('./lib/prdCreate.cjs');
|
|
31
|
+
const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
31
32
|
const chatRunner = require('./chatRunner.cjs');
|
|
32
33
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
33
34
|
const agentLibrary = require('./agentLibrary.cjs');
|
|
@@ -799,6 +800,12 @@ ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTr
|
|
|
799
800
|
const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
|
|
800
801
|
return { turns };
|
|
801
802
|
}));
|
|
803
|
+
// Renderer Epic lifecycle trace (PRD 940) — append-only, no read/list IPC.
|
|
804
|
+
// Kind allowlist enforced by schemas.auditLogAppend, not here.
|
|
805
|
+
ipcMain.handle('auditLog:append', validated(schemas.auditLogAppend, async ({ kind, cwd, epicId, source }) => {
|
|
806
|
+
appendAuditEvent(kind, { cwd, epicId, source });
|
|
807
|
+
return { ok: true };
|
|
808
|
+
}));
|
|
802
809
|
agentMemory.registerAgentMemoryHandlers();
|
|
803
810
|
git.register(ipcMain);
|
|
804
811
|
filesIpc.registerFilesHandlers();
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -493,6 +493,25 @@ const promptSessionTranscriptRead = z.object({
|
|
|
493
493
|
limit: z.number().int().positive().max(10_000).optional(),
|
|
494
494
|
}).strict();
|
|
495
495
|
|
|
496
|
+
// ──────────────────────────────────────────── Audit log (renderer Epic
|
|
497
|
+
// lifecycle trace, PRD 940). Kind allowlist is enforced HERE, main-side — the
|
|
498
|
+
// renderer cannot write arbitrary kinds through this channel, only the six
|
|
499
|
+
// Epic lifecycle transitions it emits from promptSessions.ts.
|
|
500
|
+
const AUDIT_LOG_KIND = z.enum([
|
|
501
|
+
'epic_create',
|
|
502
|
+
'epic_approve',
|
|
503
|
+
'epic_complete',
|
|
504
|
+
'epic_delete',
|
|
505
|
+
'epic_resume',
|
|
506
|
+
'epic_duplicate',
|
|
507
|
+
]);
|
|
508
|
+
const auditLogAppend = z.object({
|
|
509
|
+
kind: AUDIT_LOG_KIND,
|
|
510
|
+
cwd: z.string().min(1).max(4096),
|
|
511
|
+
epicId: z.string().min(1).max(256),
|
|
512
|
+
source: z.string().min(1).max(256),
|
|
513
|
+
}).strict();
|
|
514
|
+
|
|
496
515
|
// ──────────────────────────────────────────── Per-subagent memory
|
|
497
516
|
// Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
|
|
498
517
|
// subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
|
|
@@ -889,6 +908,7 @@ module.exports = {
|
|
|
889
908
|
projectBriefUpdate,
|
|
890
909
|
promptSessionTranscriptAppend,
|
|
891
910
|
promptSessionTranscriptRead,
|
|
911
|
+
auditLogAppend,
|
|
892
912
|
agentMemoryList,
|
|
893
913
|
agentMemoryGet,
|
|
894
914
|
agentMemorySet,
|
|
@@ -182,9 +182,13 @@ function withPathLock(lockPath, task) {
|
|
|
182
182
|
*
|
|
183
183
|
* `status` defaults to 'proposed' — a fail-safe default so any caller that
|
|
184
184
|
* forgets to pass it files an Epic that waits for human approval rather than
|
|
185
|
-
* one that starts running immediately.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
185
|
+
* one that starts running immediately. Every Epic is BORN 'proposed' — the
|
|
186
|
+
* mint branch below ignores/rejects any other requested status; it is
|
|
187
|
+
* fail-closed, mirroring opsOwnership.cjs's assertOpsWrite. Activation
|
|
188
|
+
* ('proposed' → 'active') happens exactly once, entirely in the renderer
|
|
189
|
+
* store's `approveProposed` (`state/promptSessions.ts`) — that code path
|
|
190
|
+
* never calls ensureEpic(). Joining an already-'active' Epic (this function's
|
|
191
|
+
* join branches, above the mint branch) remains legal and unchanged.
|
|
188
192
|
*
|
|
189
193
|
* `mintIfMissing` defaults to true for the small set of callers that are
|
|
190
194
|
* themselves the human-intent gate (propose-epic, the RCA hook, the feedback
|
|
@@ -223,6 +227,12 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
223
227
|
return { epicId: explicitEpicId, prdDir, created: false };
|
|
224
228
|
}
|
|
225
229
|
console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through`);
|
|
230
|
+
appendAuditEvent('epic_mint_refused', {
|
|
231
|
+
cwd,
|
|
232
|
+
epicId: explicitEpicId,
|
|
233
|
+
status,
|
|
234
|
+
reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through to mint`,
|
|
235
|
+
});
|
|
226
236
|
}
|
|
227
237
|
|
|
228
238
|
if (reuseByGoal) {
|
|
@@ -251,11 +261,11 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
251
261
|
}
|
|
252
262
|
|
|
253
263
|
if (!mintIfMissing) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
+ '
|
|
257
|
-
|
|
258
|
-
);
|
|
264
|
+
const reason = 'no existing Epic found and mintIfMissing is false — a new Epic can only be created by '
|
|
265
|
+
+ 'explicit human intent (New Epic UI, or /propose-epic + Approve & start), never implicitly by a '
|
|
266
|
+
+ 'PRD-authoring path';
|
|
267
|
+
appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
|
|
268
|
+
throw new Error(`ensureEpic: ${reason} (epicId=${explicitEpicId ?? 'none'})`);
|
|
259
269
|
}
|
|
260
270
|
|
|
261
271
|
if (!forceNewEpic) {
|
|
@@ -267,6 +277,20 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
267
277
|
}
|
|
268
278
|
}
|
|
269
279
|
|
|
280
|
+
// BORN-PROPOSED LAW (fail-closed, mirrors opsOwnership.cjs's
|
|
281
|
+
// assertOpsWrite): a mint always writes 'proposed', regardless of what
|
|
282
|
+
// status the caller requested. A caller explicitly asking to mint
|
|
283
|
+
// 'active' is refused outright rather than silently downgraded — that
|
|
284
|
+
// shape (mint + already-active) should never occur, so it is treated as
|
|
285
|
+
// a bug in the caller, not a normal fallback path.
|
|
286
|
+
if (status !== 'proposed') {
|
|
287
|
+
const reason = `ensureEpic: refusing to mint a new Epic with status '${status}' — every Epic is born `
|
|
288
|
+
+ "'proposed' (CLAUDE.md domain model); activation happens only via the renderer store's "
|
|
289
|
+
+ 'approveProposed, never through ensureEpic()';
|
|
290
|
+
appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
|
|
291
|
+
throw new Error(reason);
|
|
292
|
+
}
|
|
293
|
+
|
|
270
294
|
const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
271
295
|
const now = new Date().toISOString();
|
|
272
296
|
const session = {
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -565,22 +565,33 @@ async function archivedTwinExists(job) {
|
|
|
565
565
|
}
|
|
566
566
|
|
|
567
567
|
/**
|
|
568
|
-
* Build the non-failure result + run meta for a job whose PRD
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
568
|
+
* Build the non-failure result + run meta for a queued job whose PRD source
|
|
569
|
+
* is gone. Shared by both PRD-read failure exits in executeJob so the
|
|
570
|
+
* stale-skip logic isn't duplicated. Two reasons, both exitCode: 0 (no RCA
|
|
571
|
+
* feedback item, since neither is a real work failure):
|
|
572
|
+
* - 'prd-archived': the source was archived — archivedTwinExists found it
|
|
573
|
+
* under a prds-archived/ dir. The work already shipped.
|
|
574
|
+
* - 'prd-missing': the source is gone everywhere (ENOENT on every
|
|
575
|
+
* candidate dir, no archived twin either) — most commonly a PRD that
|
|
576
|
+
* existed at enqueue time and was deleted before dispatch (e.g. a test
|
|
577
|
+
* fixture that leaked into the live queue and was cleaned up by its own
|
|
578
|
+
* `finally` block). Retire the row rather than fail it.
|
|
572
579
|
*/
|
|
573
|
-
function prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath) {
|
|
574
|
-
const
|
|
575
|
-
|
|
580
|
+
function prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, reason = 'prd-archived') {
|
|
581
|
+
const msg = reason === 'prd-missing'
|
|
582
|
+
? 'PRD source no longer exists on disk — retiring stale queue entry'
|
|
583
|
+
: (() => {
|
|
584
|
+
const archivedTwin = archivedPrdPathForJob(job);
|
|
585
|
+
return `PRD already archived (${archivedTwin}) — work shipped; retiring stale queue entry`;
|
|
586
|
+
})();
|
|
576
587
|
safeLog(`[scheduler] ${msg}\n`);
|
|
577
588
|
closeFd();
|
|
578
589
|
const finishedAt = Date.now();
|
|
579
590
|
config.writeJsonSync(metaPath, {
|
|
580
|
-
slug: job.slug, cwd, sessionId, exitCode: 0, skipped:
|
|
591
|
+
slug: job.slug, cwd, sessionId, exitCode: 0, skipped: reason,
|
|
581
592
|
note: msg, startedAt, finishedAt, durationMs: 0,
|
|
582
593
|
});
|
|
583
|
-
return { exitCode: 0, durationMs: 0, skipped:
|
|
594
|
+
return { exitCode: 0, durationMs: 0, skipped: reason, note: msg, sessionId };
|
|
584
595
|
}
|
|
585
596
|
|
|
586
597
|
/**
|
|
@@ -2032,16 +2043,34 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
2032
2043
|
prompt = parsed.body + FINISH_PROTOCOL;
|
|
2033
2044
|
prdPath = fallbackPath;
|
|
2034
2045
|
} catch (e2) {
|
|
2046
|
+
// Found the dir a moment ago but the read still failed. Case A: the
|
|
2047
|
+
// source has since been archived — stale-skip as usual. Case B: it
|
|
2048
|
+
// was deleted out from under us in the window between findPrdDir and
|
|
2049
|
+
// parsePrd (ENOENT) with no archived twin — also a stale row, not a
|
|
2050
|
+
// real failure. Anything else (malformed/unreadable PRD) is a real
|
|
2051
|
+
// failure and keeps exitCode: -1.
|
|
2035
2052
|
if (await archivedTwinExists(job)) {
|
|
2036
|
-
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath);
|
|
2053
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
|
|
2054
|
+
}
|
|
2055
|
+
if (e2 && e2.code === 'ENOENT') {
|
|
2056
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
|
|
2037
2057
|
}
|
|
2038
2058
|
safeLog(`[scheduler] failed to read PRD: ${e2?.message}\n`);
|
|
2039
2059
|
closeFd();
|
|
2040
2060
|
return { exitCode: -1, durationMs: 0, error: e2?.message };
|
|
2041
2061
|
}
|
|
2042
2062
|
} else {
|
|
2063
|
+
// No candidate dir has the slug at all. Case A: it was archived —
|
|
2064
|
+
// stale-skip. Case B: the read error is ENOENT (the common case: a
|
|
2065
|
+
// source that existed at enqueue time and is gone by dispatch, e.g. a
|
|
2066
|
+
// leaked test fixture) with no archived twin — retire as stale rather
|
|
2067
|
+
// than a hard failure. Anything else (a real read/parse error) still
|
|
2068
|
+
// fails the job.
|
|
2043
2069
|
if (await archivedTwinExists(job)) {
|
|
2044
|
-
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath);
|
|
2070
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
|
|
2071
|
+
}
|
|
2072
|
+
if (e && e.code === 'ENOENT') {
|
|
2073
|
+
return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
|
|
2045
2074
|
}
|
|
2046
2075
|
safeLog(`[scheduler] failed to read PRD: ${e?.message}\n`);
|
|
2047
2076
|
closeFd();
|
|
@@ -4480,4 +4509,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
4480
4509
|
});
|
|
4481
4510
|
}
|
|
4482
4511
|
|
|
4483
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
|
|
4512
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
|
package/src/preload/api.d.ts
CHANGED
|
@@ -1556,6 +1556,15 @@ export interface SessionManagerAPI {
|
|
|
1556
1556
|
/** Read back an Epic's full-text turns (optionally capped to the last `limit`). Skips corrupt lines rather than throwing. */
|
|
1557
1557
|
read: (cwd: string, epicId: string, limit?: number) => Promise<{ turns: PromptSessionTranscriptTurn[] }>;
|
|
1558
1558
|
};
|
|
1559
|
+
auditLog: {
|
|
1560
|
+
/** Append one Epic-lifecycle audit event to ~/.claude/session-manager/audit-log.jsonl.
|
|
1561
|
+
* Kind allowlist is enforced main-side (ipcSchemas.cjs) — this channel is
|
|
1562
|
+
* append-only, no read/list IPC exists. */
|
|
1563
|
+
append: (
|
|
1564
|
+
kind: 'epic_create' | 'epic_approve' | 'epic_complete' | 'epic_delete' | 'epic_resume' | 'epic_duplicate',
|
|
1565
|
+
fields: { cwd: string; epicId: string; source: string },
|
|
1566
|
+
) => Promise<{ ok: boolean }>;
|
|
1567
|
+
};
|
|
1559
1568
|
agentMemory: {
|
|
1560
1569
|
/** List all memory entries for one subagent. Sorted newest first. */
|
|
1561
1570
|
list: (agentId: string) => Promise<AgentMemoryListResult>;
|
package/src/preload/index.cjs
CHANGED
|
@@ -339,6 +339,9 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
339
339
|
read: (cwd, epicId, limit) =>
|
|
340
340
|
ipcRenderer.invoke('promptSessionTranscript:read', { cwd, epicId, ...(limit ? { limit } : {}) }),
|
|
341
341
|
},
|
|
342
|
+
auditLog: {
|
|
343
|
+
append: (kind, fields) => ipcRenderer.invoke('auditLog:append', { kind, ...fields }),
|
|
344
|
+
},
|
|
342
345
|
agentMemory: {
|
|
343
346
|
list: (agentId) => ipcRenderer.invoke('agent-memory:list', { agentId }),
|
|
344
347
|
get: (agentId, entryId) => ipcRenderer.invoke('agent-memory:get', { agentId, entryId }),
|