claude-code-session-manager 0.51.0 → 0.52.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-DOVjN6Gc.js → TiptapBody-CQtxxn1C.js} +1 -1
- package/dist/assets/{index-BXzDjHNE.css → index-CKP60_3d.css} +1 -1
- package/dist/assets/index-hNso_ykJ.js +3218 -0
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/src/main/__tests__/prdLocationsArchived.test.cjs +152 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +103 -0
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +9 -1
- package/src/main/lib/epicMint.cjs +14 -4
- package/src/main/lib/prdLocations.cjs +40 -2
- package/src/main/projectPages.cjs +65 -0
- package/src/main/scheduler.cjs +108 -8
- package/src/preload/api.d.ts +28 -2
- package/src/preload/index.cjs +3 -0
- package/dist/assets/index-DPY5vv68.js +0 -3218
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-hNso_ykJ.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-CKP60_3d.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.52.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",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"dev:renderer": "vite",
|
|
29
29
|
"dev:electron": "wait-on http://localhost:5173 && SM_DEV=1 electron .",
|
|
30
30
|
"build": "vite build",
|
|
31
|
+
"build:project-pages": "node scripts/build-project-pages-renderer.mjs",
|
|
32
|
+
"build:project-pages-logic": "node scripts/build-project-pages-logic.mjs",
|
|
31
33
|
"typecheck": "tsc --noEmit",
|
|
32
34
|
"start": "electron .",
|
|
33
35
|
"postinstall": "node scripts/postinstall.cjs",
|
|
@@ -124,6 +126,7 @@
|
|
|
124
126
|
"@vitejs/plugin-react": "^4.3.3",
|
|
125
127
|
"autoprefixer": "^10.4.20",
|
|
126
128
|
"concurrently": "^9.1.0",
|
|
129
|
+
"esbuild": "^0.25.12",
|
|
127
130
|
"jsdom": "^29.1.1",
|
|
128
131
|
"postcss": "^8.4.49",
|
|
129
132
|
"react": "^18.3.1",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdLocationsArchived.test.cjs — unit tests for the archived-PRD discovery
|
|
3
|
+
* that backs `schedule:list-prds` including archived PRDs (PRD 926): an
|
|
4
|
+
* Epic's "PRDs" tab count must reflect its REAL historical PRD count, not
|
|
5
|
+
* just whatever is still sitting in its live `prds/` dir — a completed
|
|
6
|
+
* PRD's source .md moves to the sibling `prds-archived/` dir the instant its
|
|
7
|
+
* scheduler job finishes (scheduler.cjs's archiveCompletedPrd), which the
|
|
8
|
+
* old list-prds handler never read.
|
|
9
|
+
*
|
|
10
|
+
* Covers:
|
|
11
|
+
* - resolveArchivedPrdsDirs finds an Epic's own `prds-archived/` dir
|
|
12
|
+
* alongside the project's flat sibling one.
|
|
13
|
+
* - deriveEpicIdFromPrdPath resolves an epicId for a file under
|
|
14
|
+
* `prds-archived/`, not just the live `prds/` dir.
|
|
15
|
+
* - scheduler.cjs's candidateArchivedPrdsDirs + candidatePrdsDirs
|
|
16
|
+
* together surface both a still-pending PRD and an already-archived one
|
|
17
|
+
* for the same Epic, without double-counting.
|
|
18
|
+
*
|
|
19
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdLocationsArchived.test.cjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
import { test, expect, afterEach } from 'vitest';
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const fsp = require('node:fs/promises');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const {
|
|
30
|
+
resolveArchivedPrdsDirs,
|
|
31
|
+
resolvePrdsDirs,
|
|
32
|
+
resolveEpicPrdWriteDir,
|
|
33
|
+
deriveEpicIdFromPrdPath,
|
|
34
|
+
} = require('../lib/prdLocations.cjs');
|
|
35
|
+
const { candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus } = require('../scheduler.cjs');
|
|
36
|
+
|
|
37
|
+
const tmpDirs = [];
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
while (tmpDirs.length) {
|
|
40
|
+
const d = tmpDirs.pop();
|
|
41
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function mkEpicProject() {
|
|
46
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-cwd-'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('resolveArchivedPrdsDirs finds an Epic\'s own prds-archived/ dir for an active project', async () => {
|
|
50
|
+
const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-projects-'));
|
|
51
|
+
tmpDirs.push(projectsDir);
|
|
52
|
+
const projectCwd = await mkEpicProject();
|
|
53
|
+
tmpDirs.push(projectCwd);
|
|
54
|
+
|
|
55
|
+
const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-1');
|
|
56
|
+
const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
|
|
57
|
+
fs.mkdirSync(epicArchiveDir, { recursive: true });
|
|
58
|
+
|
|
59
|
+
const projDir = path.join(projectsDir, 'epic-project');
|
|
60
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
61
|
+
const transcript = path.join(projDir, 'session1.jsonl');
|
|
62
|
+
fs.writeFileSync(transcript, `${JSON.stringify({ cwd: projectCwd })}\n`);
|
|
63
|
+
|
|
64
|
+
const dirs = resolveArchivedPrdsDirs(90, { projectsDir });
|
|
65
|
+
expect(dirs).toContain(path.resolve(epicArchiveDir));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('deriveEpicIdFromPrdPath resolves epicId for a file under prds-archived/, not just prds/', async () => {
|
|
69
|
+
const projectCwd = await mkEpicProject();
|
|
70
|
+
tmpDirs.push(projectCwd);
|
|
71
|
+
|
|
72
|
+
const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-42');
|
|
73
|
+
const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
|
|
74
|
+
fs.mkdirSync(epicArchiveDir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
const archivedFile = path.join(epicArchiveDir, '917-example.md');
|
|
77
|
+
fs.writeFileSync(archivedFile, '---\ntitle: example\n---\n\n# Goal\n');
|
|
78
|
+
|
|
79
|
+
expect(deriveEpicIdFromPrdPath(archivedFile)).toBe('epic-42');
|
|
80
|
+
// Still works for the live dir too — no regression.
|
|
81
|
+
const liveFile = path.join(epicPrdsDir, '918-example.md');
|
|
82
|
+
fs.mkdirSync(epicPrdsDir, { recursive: true });
|
|
83
|
+
fs.writeFileSync(liveFile, '---\ntitle: example\n---\n\n# Goal\n');
|
|
84
|
+
expect(deriveEpicIdFromPrdPath(liveFile)).toBe('epic-42');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('deriveEpicIdFromPrdPath returns null for an unrelated dir name', () => {
|
|
88
|
+
expect(deriveEpicIdFromPrdPath('/some/random/dir/917-example.md')).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('resolvePrdsDirs + resolveArchivedPrdsDirs together surface a pending PRD dir and an archived PRD dir for the same Epic, with no overlap', async () => {
|
|
92
|
+
const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-both-projects-'));
|
|
93
|
+
tmpDirs.push(projectsDir);
|
|
94
|
+
const projectCwd = await mkEpicProject();
|
|
95
|
+
tmpDirs.push(projectCwd);
|
|
96
|
+
|
|
97
|
+
const epicId = `test-epic-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
98
|
+
const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, epicId);
|
|
99
|
+
const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
|
|
100
|
+
fs.mkdirSync(epicPrdsDir, { recursive: true });
|
|
101
|
+
fs.mkdirSync(epicArchiveDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
const pendingSlug = `926-pending-${process.pid}`;
|
|
104
|
+
const archivedSlug = `917-archived-${process.pid}`;
|
|
105
|
+
fs.writeFileSync(path.join(epicPrdsDir, `${pendingSlug}.md`), `---\ntitle: pending\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
|
|
106
|
+
fs.writeFileSync(path.join(epicArchiveDir, `${archivedSlug}.md`), `---\ntitle: archived\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
|
|
107
|
+
|
|
108
|
+
const projDir = path.join(projectsDir, 'both-project');
|
|
109
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(projDir, 'session1.jsonl'), `${JSON.stringify({ cwd: projectCwd })}\n`);
|
|
111
|
+
|
|
112
|
+
const liveDirs = resolvePrdsDirs(90, { projectsDir });
|
|
113
|
+
const archivedDirs = resolveArchivedPrdsDirs(90, { projectsDir });
|
|
114
|
+
expect(liveDirs).toContain(epicPrdsDir);
|
|
115
|
+
expect(archivedDirs.map((d) => path.resolve(d))).toContain(path.resolve(epicArchiveDir));
|
|
116
|
+
|
|
117
|
+
// No overlap between the two dir sets for this Epic — an archived PRD's
|
|
118
|
+
// dir is never also scanned as a live one, and vice versa.
|
|
119
|
+
expect(liveDirs).not.toContain(epicArchiveDir);
|
|
120
|
+
expect(archivedDirs).not.toContain(epicPrdsDir);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('resolveArchivedPrdStatus prefers the live queue.json row over history', () => {
|
|
124
|
+
const liveStatusBySlug = new Map([['917-slug', 'failed']]);
|
|
125
|
+
const histBySlug = new Map([['917-slug', { status: 'completed' }]]);
|
|
126
|
+
expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('resolveArchivedPrdStatus falls back to history.jsonl when there is no live row', () => {
|
|
130
|
+
const liveStatusBySlug = new Map();
|
|
131
|
+
const histBySlug = new Map([['917-slug', { status: 'failed' }]]);
|
|
132
|
+
expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('resolveArchivedPrdStatus defaults to completed when neither source has the slug', () => {
|
|
136
|
+
expect(resolveArchivedPrdStatus('917-slug', new Map(), new Map())).toBe('completed');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('resolveArchivedPrdStatus normalizes any non-failed status (e.g. needs_review) to completed', () => {
|
|
140
|
+
const liveStatusBySlug = new Map([['917-slug', 'needs_review']]);
|
|
141
|
+
expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, new Map())).toBe('completed');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('scheduler.cjs exports candidateArchivedPrdsDirs alongside candidatePrdsDirs', () => {
|
|
145
|
+
expect(typeof candidatePrdsDirs).toBe('function');
|
|
146
|
+
expect(typeof candidateArchivedPrdsDirs).toBe('function');
|
|
147
|
+
// Both scan the real machine's projects — just assert they run without
|
|
148
|
+
// throwing and return arrays; per-Epic coverage is exercised above via
|
|
149
|
+
// the opts-injectable prdLocations functions directly.
|
|
150
|
+
expect(Array.isArray(candidatePrdsDirs())).toBe(true);
|
|
151
|
+
expect(Array.isArray(candidateArchivedPrdsDirs())).toBe(true);
|
|
152
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reap-dead-running-jobs.test.cjs — regression cover for PRD 935
|
|
3
|
+
* (reapDeadRunningJobs runningSet desync).
|
|
4
|
+
*
|
|
5
|
+
* spawnJob()'s completion path can write terminal run artifacts to disk and
|
|
6
|
+
* have its child process fully exit, yet still throw between executeJob()
|
|
7
|
+
* resolving and the completion mutate() finishing (e.g. writeQueue's
|
|
8
|
+
* unreadable guard). That throw is swallowed by spawnJob()'s catch, but its
|
|
9
|
+
* `finally` unconditionally deletes the job's slug from the in-memory
|
|
10
|
+
* runningSet — leaving queue.json stuck at status:"running" with a dead pid
|
|
11
|
+
* while runningSet no longer names it. reapDeadRunningJobs() used to gate its
|
|
12
|
+
* entire body on `runningSet.size === 0`, so this exact state made it return
|
|
13
|
+
* before ever reading queue.json — permanently invisible to reconciliation.
|
|
14
|
+
*
|
|
15
|
+
* This test reproduces that desync directly: a project queue.json job row
|
|
16
|
+
* with status:"running" and a dead pid, while runningSet (freshly loaded,
|
|
17
|
+
* never populated) does not contain its slug. It asserts the job still gets
|
|
18
|
+
* reconciled to a terminal status.
|
|
19
|
+
*
|
|
20
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs, since every
|
|
21
|
+
* path this code touches (queueStore's MACHINE_STATE_PATH, activeSessions'
|
|
22
|
+
* project scan root, scheduler.cjs's ROOT/RUNS_DIR, schedulerBatch's
|
|
23
|
+
* DEFAULT_PROJECT_CWD) is baked into a top-level const from os.homedir() at
|
|
24
|
+
* require time — this test must never be able to read or write real state.
|
|
25
|
+
*
|
|
26
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const assert = require('node:assert/strict');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
|
|
37
|
+
process.env.HOME = tmpHome;
|
|
38
|
+
|
|
39
|
+
const { reapDeadRunningJobs } = require('../scheduler.cjs');
|
|
40
|
+
|
|
41
|
+
function registerActiveProject(cwd) {
|
|
42
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
43
|
+
const slugDir = path.join(projectsDir, 'fake-project-slug');
|
|
44
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeProjectQueue(cwd, jobs) {
|
|
49
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
50
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
52
|
+
return path.join(stateDir, 'queue.json');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeRunLog(runId, slug, lines) {
|
|
56
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
57
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running with a dead pid, even when runningSet does not name its slug (desync repro)', async () => {
|
|
62
|
+
const projectCwd = path.join(tmpHome, 'a-project');
|
|
63
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
64
|
+
registerActiveProject(projectCwd);
|
|
65
|
+
|
|
66
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
67
|
+
{
|
|
68
|
+
slug: 'desynced-job',
|
|
69
|
+
status: 'running',
|
|
70
|
+
cwd: projectCwd,
|
|
71
|
+
runId: 'run-desynced',
|
|
72
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid (see reaperHelpers tests)
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
writeRunLog('run-desynced', 'desynced-job', [
|
|
76
|
+
'{"type":"result","subtype":"success","result":"done","is_error":false}',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// runningSet is a fresh module-level Set with nothing in it — this is the
|
|
80
|
+
// exact desynced state spawnJob()'s finally block can leave behind.
|
|
81
|
+
await reapDeadRunningJobs();
|
|
82
|
+
|
|
83
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
84
|
+
assert.equal(jobs.length, 1);
|
|
85
|
+
assert.equal(jobs[0].status, 'completed', 'job must be reconciled from disk state, not skipped via the runningSet gate');
|
|
86
|
+
assert.equal(jobs[0].exitCode, 0);
|
|
87
|
+
assert.equal(jobs[0].runtime, undefined);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('reapDeadRunningJobs is a no-op when no job is actually running in queue.json', async () => {
|
|
91
|
+
const projectCwd = path.join(tmpHome, 'b-project');
|
|
92
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
93
|
+
registerActiveProject(projectCwd);
|
|
94
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
95
|
+
{ slug: 'already-done', status: 'completed', cwd: projectCwd, exitCode: 0 },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
await assert.doesNotReject(() => reapDeadRunningJobs());
|
|
99
|
+
|
|
100
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
101
|
+
assert.equal(jobs.length, 1);
|
|
102
|
+
assert.equal(jobs[0].status, 'completed');
|
|
103
|
+
});
|
package/src/main/index.cjs
CHANGED
|
@@ -58,6 +58,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
|
|
|
58
58
|
const memoryTool = require('./memoryTool.cjs');
|
|
59
59
|
const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
|
|
60
60
|
const { registerProjectBriefIpc } = require('./projectBrief.cjs');
|
|
61
|
+
const { registerProjectPagesIpc } = require('./projectPages.cjs');
|
|
61
62
|
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
62
63
|
const agentMemory = require('./agentMemory.cjs');
|
|
63
64
|
const git = require('./git.cjs');
|
|
@@ -789,6 +790,7 @@ pluginInstall.registerPluginInstallHandlers();
|
|
|
789
790
|
memoryTool.registerMemoryHandlers();
|
|
790
791
|
registerMemoryAggregateIpc();
|
|
791
792
|
registerProjectBriefIpc();
|
|
793
|
+
registerProjectPagesIpc();
|
|
792
794
|
ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
|
|
793
795
|
const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
|
|
794
796
|
return { ok };
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -313,7 +313,7 @@ const schedulerCreatePrd = z.object({
|
|
|
313
313
|
originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
314
314
|
// User-selected Feature/Bug tag (PRD 774) carried from the originating
|
|
315
315
|
// PromptTicket — deterministic, never LLM-classified.
|
|
316
|
-
tag: z.enum(['feature', 'bug', 'discussion', 'build']).optional(),
|
|
316
|
+
tag: z.enum(['feature', 'bug', 'discussion', 'build', 'project-home-builder']).optional(),
|
|
317
317
|
});
|
|
318
318
|
|
|
319
319
|
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
@@ -446,6 +446,13 @@ const PROJECT_BRIEF_BLOCK = z.enum(['what', 'conventions']);
|
|
|
446
446
|
const projectBriefCwd = z.object({
|
|
447
447
|
cwd: z.string().min(1).max(4096),
|
|
448
448
|
}).strict();
|
|
449
|
+
|
|
450
|
+
// ──────────────────────────────────────────── Project Pages (PRD 932)
|
|
451
|
+
// Same validation split as projectBriefCwd above: real path validation is
|
|
452
|
+
// config.cjs's validatePath at first fs access in projectPages.cjs.
|
|
453
|
+
const projectPagesCwd = z.object({
|
|
454
|
+
cwd: z.string().min(1).max(4096),
|
|
455
|
+
}).strict();
|
|
449
456
|
const projectBriefSetPin = z.object({
|
|
450
457
|
cwd: z.string().min(1).max(4096),
|
|
451
458
|
block: PROJECT_BRIEF_BLOCK,
|
|
@@ -877,6 +884,7 @@ module.exports = {
|
|
|
877
884
|
memoryAggregate,
|
|
878
885
|
memoryStale,
|
|
879
886
|
projectBriefCwd,
|
|
887
|
+
projectPagesCwd,
|
|
880
888
|
projectBriefSetPin,
|
|
881
889
|
projectBriefUpdate,
|
|
882
890
|
promptSessionTranscriptAppend,
|
|
@@ -208,11 +208,21 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
|
|
|
208
208
|
const index = readActiveIndex(cwd);
|
|
209
209
|
|
|
210
210
|
// A dispatch that already knows its Epic (sourcePromptId frontmatter from
|
|
211
|
-
// an Epic-conversation dispatch) joins it rather than minting a sibling
|
|
211
|
+
// an Epic-conversation dispatch) joins it rather than minting a sibling —
|
|
212
|
+
// but only while that Epic is still open. Joining unconditionally here
|
|
213
|
+
// let a stale/hallucinated sourcePromptId silently attach a PRD (and its
|
|
214
|
+
// follow-on events/chat activity) to an unrelated or even completed
|
|
215
|
+
// Epic — the "this session ran again without my knowledge, and it was
|
|
216
|
+
// really another Epic's prompt" cross-contamination bug. Mirrors
|
|
217
|
+
// findJoinableEpicInIndex()'s preferEpicId open-check (line ~124).
|
|
212
218
|
if (explicitEpicId && hasOwn(index.sessions, explicitEpicId)) {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
const preferred = index.sessions[explicitEpicId];
|
|
220
|
+
if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
|
|
221
|
+
const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
|
|
222
|
+
fs.mkdirSync(prdDir, { recursive: true });
|
|
223
|
+
return { epicId: explicitEpicId, prdDir, created: false };
|
|
224
|
+
}
|
|
225
|
+
console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through`);
|
|
216
226
|
}
|
|
217
227
|
|
|
218
228
|
if (reuseByGoal) {
|
|
@@ -145,6 +145,39 @@ function resolvePrdsDirs(maxAgeMin, opts) {
|
|
|
145
145
|
return dirs;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* resolveArchivedPrdsDirs(maxAgeMin?, opts?) → string[]
|
|
150
|
+
*
|
|
151
|
+
* The `prds-archived/` counterpart of resolvePrdsDirs — every archive dir
|
|
152
|
+
* (the flat per-project sibling plus each Epic's own sibling, per
|
|
153
|
+
* listArchivedPrdDirs) that exists on disk, across every historical AND
|
|
154
|
+
* currently-active project. Same "not recency-filtered" rationale as
|
|
155
|
+
* resolvePrdsDirs: a quiet project's completed-PRD history is still real
|
|
156
|
+
* history, not something list-prds should stop counting.
|
|
157
|
+
*/
|
|
158
|
+
function resolveArchivedPrdsDirs(maxAgeMin, opts) {
|
|
159
|
+
const dirs = [];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const add = (dir) => {
|
|
162
|
+
if (seen.has(dir)) return;
|
|
163
|
+
seen.add(dir);
|
|
164
|
+
dirs.push(dir);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
for (const cwd of allProjectCwds(opts)) {
|
|
168
|
+
for (const dir of listArchivedPrdDirs(cwd)) {
|
|
169
|
+
if (fs.existsSync(dir)) add(dir);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const cwd of activeProjectCwds(maxAgeMin, opts)) {
|
|
173
|
+
for (const dir of listArchivedPrdDirs(cwd)) {
|
|
174
|
+
if (fs.existsSync(dir)) add(dir);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return dirs;
|
|
179
|
+
}
|
|
180
|
+
|
|
148
181
|
/**
|
|
149
182
|
* deriveEpicIdFromPrdPath(filePath) → epicId | null
|
|
150
183
|
*
|
|
@@ -160,7 +193,11 @@ function resolvePrdsDirs(maxAgeMin, opts) {
|
|
|
160
193
|
function deriveEpicIdFromPrdPath(filePath) {
|
|
161
194
|
if (!filePath || typeof filePath !== 'string') return null;
|
|
162
195
|
const prdsDir = path.dirname(filePath);
|
|
163
|
-
|
|
196
|
+
// Accept both an Epic's live `prds/` dir and its sibling `prds-archived/`
|
|
197
|
+
// dir — archived PRDs still belong to the Epic they were dispatched from,
|
|
198
|
+
// and callers (parsePrdRaw, list-prds) need epicId for archived files too.
|
|
199
|
+
const dirName = path.basename(prdsDir);
|
|
200
|
+
if (dirName !== 'prds' && dirName !== 'prds-archived') return null;
|
|
164
201
|
const epicDir = path.dirname(prdsDir);
|
|
165
202
|
const epicId = path.basename(epicDir);
|
|
166
203
|
if (!epicId || epicId === '.' || epicId === path.sep) return null;
|
|
@@ -168,13 +205,14 @@ function deriveEpicIdFromPrdPath(filePath) {
|
|
|
168
205
|
for (let i = 0; i < EPICS_SUBPATH.length; i++) projectCwd = path.dirname(projectCwd);
|
|
169
206
|
let epicsRoot;
|
|
170
207
|
try { epicsRoot = resolveEpicsRoot(projectCwd); } catch { return null; }
|
|
171
|
-
if (path.join(epicsRoot, epicId,
|
|
208
|
+
if (path.join(epicsRoot, epicId, dirName) !== prdsDir) return null;
|
|
172
209
|
return epicId;
|
|
173
210
|
}
|
|
174
211
|
|
|
175
212
|
module.exports = {
|
|
176
213
|
resolvePrdWriteDir,
|
|
177
214
|
resolvePrdsDirs,
|
|
215
|
+
resolveArchivedPrdsDirs,
|
|
178
216
|
resolveEpicsRoot,
|
|
179
217
|
resolveEpicPrdWriteDir,
|
|
180
218
|
listEpicPrdDirs,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* projectPages.cjs — read-only backend for Project Home's Project Pages
|
|
5
|
+
* display (PRD 932). Reads what the `project-home-builder` Epic's own
|
|
6
|
+
* session writes directly with its Write tool
|
|
7
|
+
* (`session-manager-operations/project-pages/output/*.html` + manifest.json)
|
|
8
|
+
* — this module never writes anything, matching the corrected "not an
|
|
9
|
+
* OWNERS namespace" storage/ownership note in
|
|
10
|
+
* session-manager-operations/architecture/project-pages-pipeline.md and
|
|
11
|
+
* session-manager-operations/project-pages/README.md. Structure mirrors
|
|
12
|
+
* projectBrief.cjs's `get()`: validatePath first, config.cjs read helpers,
|
|
13
|
+
* one ipcMain.handle registered from index.cjs's registerXHandlers() pattern.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { ipcMain } = require('electron');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const config = require('./config.cjs');
|
|
19
|
+
|
|
20
|
+
function outputDir(cwd) {
|
|
21
|
+
return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LENSES = ['marketing', 'feature', 'architecture'];
|
|
25
|
+
|
|
26
|
+
async function get({ cwd }) {
|
|
27
|
+
const realCwd = config.validatePath(cwd);
|
|
28
|
+
const dir = outputDir(realCwd);
|
|
29
|
+
const manifestResult = await config.readJson(path.join(dir, 'manifest.json'));
|
|
30
|
+
if (!manifestResult.exists || !manifestResult.data || manifestResult.parseError) {
|
|
31
|
+
return { output: null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const htmlResults = await Promise.all(
|
|
35
|
+
LENSES.map((lens) => config.readText(path.join(dir, `${lens}.html`))),
|
|
36
|
+
);
|
|
37
|
+
if (htmlResults.some((r) => !r.exists)) {
|
|
38
|
+
return { output: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [marketing, feature, architecture] = htmlResults;
|
|
42
|
+
const generatedAt = typeof manifestResult.data.generatedAt === 'string' ? manifestResult.data.generatedAt : null;
|
|
43
|
+
if (!generatedAt) {
|
|
44
|
+
return { output: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
output: {
|
|
49
|
+
marketing: marketing.text,
|
|
50
|
+
feature: feature.text,
|
|
51
|
+
architecture: architecture.text,
|
|
52
|
+
generatedAt,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function registerProjectPagesIpc() {
|
|
58
|
+
const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
|
|
59
|
+
ipcMain.handle('project-pages:get', v(s.projectPagesCwd, get));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
registerProjectPagesIpc,
|
|
64
|
+
get,
|
|
65
|
+
};
|