claude-code-session-manager 0.43.0 → 0.44.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-rWzd7CPW.js → TiptapBody-CmDMR4ip.js} +1 -1
- package/dist/assets/{index-6w1gIeyB.js → index-BGEZVIeo.js} +461 -457
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +32 -10
- package/scripts/lib/watchdogHelpers.cjs +1 -0
- package/scripts/mint-epic.cjs +26 -13
- package/scripts/propose-epic.cjs +1 -0
- package/src/main/__tests__/chat-dead-channels.test.cjs +63 -0
- package/src/main/__tests__/epicMint.test.cjs +132 -1
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +81 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +41 -14
- package/src/main/ipcSchemas.cjs +15 -7
- package/src/main/lib/__tests__/sessionSlots.test.cjs +59 -15
- package/src/main/lib/epicMint.cjs +139 -12
- package/src/main/lib/rcaFeedbackHook.cjs +23 -2
- package/src/main/lib/sessionSlots.cjs +74 -9
- package/src/main/scheduler.cjs +33 -88
- package/src/main/templates/PRD_AUTHORING.md +5 -1
- package/src/main/webRemote.cjs +0 -32
- package/src/preload/api.d.ts +10 -2
- package/src/preload/index.cjs +1 -0
package/dist/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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-BGEZVIeo.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
13
|
<link rel="stylesheet" crossorigin href="./assets/index-CxGY-V5G.css">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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",
|
|
@@ -19,9 +19,23 @@ description: >-
|
|
|
19
19
|
|
|
20
20
|
**Role:** `/develop` owns the *pipeline*: it turns a development request into one or more
|
|
21
21
|
self-contained PRDs, queues them, and tracks them to completion. It is the convergence point
|
|
22
|
-
for both entry paths — an interactive human prompt
|
|
23
|
-
arrives as an approved Epic proposal (`/propose-epic`),
|
|
24
|
-
|
|
22
|
+
for both entry paths — an interactive human prompt inside an Epic's own conversation comes
|
|
23
|
+
straight here; an agent feedback file arrives as an approved Epic proposal (`/propose-epic`),
|
|
24
|
+
which the user approves and then calls this skill. Everything from here on is identical
|
|
25
|
+
regardless of who asked.
|
|
26
|
+
|
|
27
|
+
**Epic-gated — non-negotiable.** `/develop` may only run inside an already-existing,
|
|
28
|
+
already-human-approved Epic's own conversation (Chat or Terminal — the two views over one Epic
|
|
29
|
+
session, per the domain model). It never creates an Epic itself and never writes a PRD against
|
|
30
|
+
one it had to guess at. Before doing anything else:
|
|
31
|
+
1. Resolve `<epic-id>`: this conversation's own claudeSessionId must already match an Epic's
|
|
32
|
+
`claudeSessionId` in `<cwd>/session-manager-operations/prompt-sessions/active-index.json` —
|
|
33
|
+
that Epic's `id` is `<epic-id>` for every PRD authored in this pass.
|
|
34
|
+
2. If it doesn't match any Epic — this was invoked as a bare standalone command outside any
|
|
35
|
+
Epic context — **stop and say so**. Tell the human to create the Epic first (the New Epic
|
|
36
|
+
card in the app, or `/propose-epic` + **Approve & start**), then re-run `/develop` from
|
|
37
|
+
inside that Epic. Do not mint one yourself, do not fall back to writing an epicless PRD, and
|
|
38
|
+
do not proceed with authoring.
|
|
25
39
|
|
|
26
40
|
**Never** hand-implement the work inline in chat, and never restate rules that live elsewhere:
|
|
27
41
|
the engineering rules belong to `standards.md`. Reference it; don't fork it.
|
|
@@ -184,7 +198,11 @@ can't load skills.
|
|
|
184
198
|
**Prefer creating each PRD via the `scheduler_create_prd` MCP tool**
|
|
185
199
|
(`mcp__session-manager-scheduler__scheduler_create_prd`) over hand-writing the file. Its input
|
|
186
200
|
(`title`, `cwd`, `estimateMinutes`, `goal`, `acceptanceCriteria[]`, `implementationNotes`,
|
|
187
|
-
`outOfScope[]`) maps directly onto the sections below — pass them straight through.
|
|
201
|
+
`outOfScope[]`) maps directly onto the sections below — pass them straight through. **Always
|
|
202
|
+
pass `sourcePromptId` explicitly, set to the `<epic-id>` resolved in the Epic-gated step
|
|
203
|
+
above** — never omit it and rely on the server-side session-id fallback; that fallback exists
|
|
204
|
+
only to cover a model that forgot, not as this skill's normal path, and the server refuses to
|
|
205
|
+
write the PRD at all if no existing Epic resolves. It
|
|
188
206
|
allocates a strictly-unique `NN` atomically (no read-then-write race against another
|
|
189
207
|
concurrent `/develop` invocation, never reused across the project — PRD
|
|
190
208
|
832), derives and collision-checks the slug, and embeds the standards pointer for you.
|
|
@@ -220,17 +238,21 @@ can't load skills.
|
|
|
220
238
|
token-window resets, with auto-pause on rate-limit and auto-resume.
|
|
221
239
|
|
|
222
240
|
**Canonical location — non-negotiable.** Every PRD belongs to an **Epic** (the TAB → EPIC →
|
|
223
|
-
PRD domain model in the project CLAUDE.md)
|
|
241
|
+
PRD domain model in the project CLAUDE.md) — specifically the `<epic-id>` resolved in the
|
|
242
|
+
Epic-gated step above. PRDs MUST be written to, inside the target repo:
|
|
224
243
|
```
|
|
225
244
|
<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/<NN>-<kebab-slug>.md
|
|
226
245
|
```
|
|
227
|
-
|
|
246
|
+
Always pass that `<epic-id>` as `sourcePromptId` when creating each PRD — via the MCP
|
|
247
|
+
`scheduler_create_prd` tool's `sourcePromptId` input, or, for the manual-write fallback, by
|
|
248
|
+
resolving the directory with:
|
|
228
249
|
```bash
|
|
229
|
-
node <session-manager-repo>/scripts/mint-epic.cjs <cwd>
|
|
250
|
+
node <session-manager-repo>/scripts/mint-epic.cjs <cwd> <epic-id>
|
|
230
251
|
```
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
252
|
+
This only JOINS an existing Epic and prints its prds/ directory on the last stdout line — it
|
|
253
|
+
never creates one. If `<epic-id>` doesn't exist yet, that means the Epic-gated step above
|
|
254
|
+
wasn't satisfied; go back and get a human to create/approve the Epic first, don't work around
|
|
255
|
+
this by minting one.
|
|
234
256
|
|
|
235
257
|
**Anywhere else doesn't get scheduled or gets retired.** `data/prds/`, `docs/prds/`, and the
|
|
236
258
|
old global `prds/` dir under `~/.claude/session-manager/scheduled-plans/` are invisible to
|
package/scripts/mint-epic.cjs
CHANGED
|
@@ -1,28 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* mint-epic.cjs — CLI wrapper over lib/epicMint.cjs for PRD
|
|
4
|
-
* without the app's admin API (the /develop skill's
|
|
3
|
+
* mint-epic.cjs — CLI wrapper over lib/epicMint.cjs's JOIN path, for PRD
|
|
4
|
+
* authors working without the app's admin API (the /develop skill's
|
|
5
|
+
* manual-write fallback).
|
|
6
|
+
*
|
|
7
|
+
* This script never creates an Epic. It only resolves an EXISTING Epic's
|
|
8
|
+
* prds/ write directory. An Epic may only come into existence through
|
|
9
|
+
* explicit human intent — the New Epic UI, or /propose-epic followed by a
|
|
10
|
+
* human pressing Approve & start — never implicitly from a PRD-authoring
|
|
11
|
+
* path. /develop must already be running inside that Epic's own
|
|
12
|
+
* conversation (or have the approved Epic's id in hand) before it calls this.
|
|
5
13
|
*
|
|
6
14
|
* Usage:
|
|
7
|
-
* node scripts/mint-epic.cjs <cwd>
|
|
15
|
+
* node scripts/mint-epic.cjs <cwd> <epic-id>
|
|
8
16
|
*
|
|
9
|
-
* Prints the Epic's prds/ write directory on stdout (last line)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* same goal is idempotent and returns the same directory.
|
|
17
|
+
* Prints the Epic's prds/ write directory on stdout (last line). Exits 1
|
|
18
|
+
* with no stdout output if <epic-id> does not exist in
|
|
19
|
+
* <cwd>/session-manager-operations/prompt-sessions/active-index.json.
|
|
13
20
|
*/
|
|
14
21
|
'use strict';
|
|
15
22
|
|
|
16
23
|
const { ensureEpic } = require('../src/main/lib/epicMint.cjs');
|
|
17
24
|
|
|
18
|
-
const [cwd,
|
|
19
|
-
if (!cwd || !
|
|
20
|
-
process.stderr.write('usage: mint-epic.cjs <cwd>
|
|
25
|
+
const [cwd, epicId] = process.argv.slice(2);
|
|
26
|
+
if (!cwd || !epicId) {
|
|
27
|
+
process.stderr.write('usage: mint-epic.cjs <cwd> <epic-id>\n');
|
|
28
|
+
process.stderr.write('(epic-id must already exist — this script only joins, never creates)\n');
|
|
21
29
|
process.exit(1);
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
(async () => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
try {
|
|
34
|
+
const { prdDir } = await ensureEpic(cwd, { epicId, mintIfMissing: false });
|
|
35
|
+
process.stderr.write(`joined epic ${epicId}\n`);
|
|
36
|
+
process.stdout.write(`${prdDir}\n`);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
process.stderr.write(`${e?.message ?? 'no such Epic'}\n`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
28
41
|
})();
|
package/scripts/propose-epic.cjs
CHANGED
|
@@ -49,6 +49,7 @@ function readStdin() {
|
|
|
49
49
|
status: 'proposed',
|
|
50
50
|
reuseByGoal: true,
|
|
51
51
|
openingPrompt: body || title,
|
|
52
|
+
source: { producer: 'propose-epic' },
|
|
52
53
|
});
|
|
53
54
|
process.stderr.write(`${created ? 'proposed' : 'joined existing proposal'} epic ${epicId}\n`);
|
|
54
55
|
process.stdout.write(`${epicId}\n`);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat-dead-channels.test.cjs — regression guard for the removed
|
|
3
|
+
* chat:probe-context / chat:context-usage channels (PRD 897).
|
|
4
|
+
*
|
|
5
|
+
* These IPC channels were deliberately deleted (handler, broadcast, preload
|
|
6
|
+
* and zod schema all removed) rather than left half-wired. This test walks
|
|
7
|
+
* the source tree and fails if either literal string reappears anywhere
|
|
8
|
+
* under src/main, src/preload, or src/renderer — such a reintroduction must
|
|
9
|
+
* be wired end-to-end (handler + preload + zod schema + broadcast) or not
|
|
10
|
+
* added at all.
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/chat-dead-channels.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
import { test, expect } from 'vitest';
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
23
|
+
const SCAN_DIRS = ['src/main', 'src/preload', 'src/renderer'];
|
|
24
|
+
const FORBIDDEN_STRINGS = ['chat:probe-context', 'chat:context-usage'];
|
|
25
|
+
const SELF_PATH = path.resolve(__filename);
|
|
26
|
+
|
|
27
|
+
function walk(dir, files) {
|
|
28
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
if (entry.name === 'node_modules') continue;
|
|
30
|
+
if (entry.isSymbolicLink()) continue;
|
|
31
|
+
const full = path.join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
walk(full, files);
|
|
34
|
+
} else {
|
|
35
|
+
files.push(full);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('chat:probe-context / chat:context-usage channels stay removed', () => {
|
|
41
|
+
const files = [];
|
|
42
|
+
for (const dir of SCAN_DIRS) {
|
|
43
|
+
walk(path.join(REPO_ROOT, dir), files);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hits = [];
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
if (path.resolve(file) === SELF_PATH) continue;
|
|
49
|
+
const contents = fs.readFileSync(file, 'utf8');
|
|
50
|
+
for (const needle of FORBIDDEN_STRINGS) {
|
|
51
|
+
if (contents.includes(needle)) {
|
|
52
|
+
hits.push(`${path.relative(REPO_ROOT, file)}: contains "${needle}"`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(
|
|
58
|
+
hits,
|
|
59
|
+
'chat:probe-context / chat:context-usage were deliberately removed as a dead/half-wired ' +
|
|
60
|
+
'channel (PRD 897). If reintroducing this feature, wire it end-to-end (handler, preload, ' +
|
|
61
|
+
'zod schema, broadcast) in the same change — do not add just one side.\n' + hits.join('\n'),
|
|
62
|
+
).toEqual([]);
|
|
63
|
+
});
|
|
@@ -14,7 +14,7 @@ const fs = require('node:fs');
|
|
|
14
14
|
const fsp = require('node:fs/promises');
|
|
15
15
|
const os = require('node:os');
|
|
16
16
|
const path = require('node:path');
|
|
17
|
-
const { ensureEpic, removeEpic, readActiveIndex } = require('../lib/epicMint.cjs');
|
|
17
|
+
const { ensureEpic, removeEpic, readActiveIndex, findJoinableEpic } = require('../lib/epicMint.cjs');
|
|
18
18
|
|
|
19
19
|
const tmpDirs = [];
|
|
20
20
|
afterEach(async () => {
|
|
@@ -83,3 +83,134 @@ test('removeEpic is a no-op (returns false) for an unknown epicId', async () =>
|
|
|
83
83
|
expect(removed).toBe(false);
|
|
84
84
|
expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
// A plain object's bracket lookup with a prototype-chain key like
|
|
88
|
+
// "__proto__"/"constructor" returns a truthy Object.prototype member even
|
|
89
|
+
// when no such Epic was ever written — without an own-property check, this
|
|
90
|
+
// would make mintIfMissing:false's "join an existing Epic" gate falsely
|
|
91
|
+
// report a match, defeating the restriction that a PRD write may only join
|
|
92
|
+
// a real, already-approved Epic.
|
|
93
|
+
for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
|
|
94
|
+
test(`ensureEpic refuses to "join" the prototype-chain key "${pollutedKey}" (mintIfMissing:false)`, async () => {
|
|
95
|
+
const cwd = await mkCwd();
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
ensureEpic(cwd, { epicId: pollutedKey, mintIfMissing: false }),
|
|
99
|
+
).rejects.toThrow(/no existing Epic found/);
|
|
100
|
+
|
|
101
|
+
const index = readActiveIndex(cwd);
|
|
102
|
+
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
|
|
107
|
+
const cwd = await mkCwd();
|
|
108
|
+
const removed = removeEpic(cwd, '__proto__');
|
|
109
|
+
expect(removed).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('ensureEpic persists the passed source onto the written active-index.json session record', async () => {
|
|
113
|
+
const cwd = await mkCwd();
|
|
114
|
+
const source = { producer: 'rca-hook', prdSlug: 'x', runId: 'y' };
|
|
115
|
+
|
|
116
|
+
const minted = await ensureEpic(cwd, { goalText: 'auto-filed epic', source });
|
|
117
|
+
|
|
118
|
+
const index = readActiveIndex(cwd);
|
|
119
|
+
expect(index.sessions[minted.epicId].source).toEqual(source);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('ensureEpic omits source from the written record when not passed', async () => {
|
|
123
|
+
const cwd = await mkCwd();
|
|
124
|
+
|
|
125
|
+
const minted = await ensureEpic(cwd, { goalText: 'human-filed epic' });
|
|
126
|
+
|
|
127
|
+
const index = readActiveIndex(cwd);
|
|
128
|
+
expect('source' in index.sessions[minted.epicId]).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('ensureEpic joins an existing open Epic when goalText is a near-identical paraphrase (no preferEpicId)', async () => {
|
|
132
|
+
const cwd = await mkCwd();
|
|
133
|
+
|
|
134
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
135
|
+
expect(first.created).toBe(true);
|
|
136
|
+
|
|
137
|
+
const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue' });
|
|
138
|
+
|
|
139
|
+
expect(second.created).toBe(false);
|
|
140
|
+
expect(second.epicId).toBe(first.epicId);
|
|
141
|
+
|
|
142
|
+
const index = readActiveIndex(cwd);
|
|
143
|
+
expect(Object.keys(index.sessions)).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('ensureEpic mints a distinct Epic when goalText is genuinely unrelated', async () => {
|
|
147
|
+
const cwd = await mkCwd();
|
|
148
|
+
|
|
149
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
150
|
+
expect(first.created).toBe(true);
|
|
151
|
+
|
|
152
|
+
const second = await ensureEpic(cwd, { goalText: 'Add CSV export to History tab' });
|
|
153
|
+
|
|
154
|
+
expect(second.created).toBe(true);
|
|
155
|
+
expect(second.epicId).not.toBe(first.epicId);
|
|
156
|
+
|
|
157
|
+
const index = readActiveIndex(cwd);
|
|
158
|
+
expect(Object.keys(index.sessions)).toHaveLength(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('findJoinableEpic joins the preferEpicId Epic even when goalText similarity is near-zero', async () => {
|
|
162
|
+
const cwd = await mkCwd();
|
|
163
|
+
|
|
164
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
165
|
+
expect(first.created).toBe(true);
|
|
166
|
+
|
|
167
|
+
const joined = findJoinableEpic(cwd, {
|
|
168
|
+
goalText: 'Completely unrelated topic about database migrations',
|
|
169
|
+
preferEpicId: first.epicId,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(joined).toEqual({ epicId: first.epicId, matchedBy: 'preferEpicId' });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('findJoinableEpic falls through to the similarity check when preferEpicId points at a completed Epic', async () => {
|
|
176
|
+
const cwd = await mkCwd();
|
|
177
|
+
|
|
178
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
179
|
+
const index = readActiveIndex(cwd);
|
|
180
|
+
index.sessions[first.epicId].status = 'completed';
|
|
181
|
+
fs.writeFileSync(
|
|
182
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
183
|
+
JSON.stringify(index, null, 2),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// No other open Epic shares tokens with the completed one, so the
|
|
187
|
+
// fall-through similarity check finds nothing rather than joining the
|
|
188
|
+
// dead (completed) preferEpicId Epic.
|
|
189
|
+
const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: first.epicId });
|
|
190
|
+
|
|
191
|
+
expect(joined).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('findJoinableEpic falls through to the similarity check when preferEpicId points at a nonexistent Epic, and can still find a match there', async () => {
|
|
195
|
+
const cwd = await mkCwd();
|
|
196
|
+
const sibling = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
197
|
+
|
|
198
|
+
const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: 'nonexistent-epic-id' });
|
|
199
|
+
|
|
200
|
+
expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
|
|
204
|
+
const cwd = await mkCwd();
|
|
205
|
+
|
|
206
|
+
const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
|
|
207
|
+
expect(first.created).toBe(true);
|
|
208
|
+
|
|
209
|
+
const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue', forceNewEpic: true });
|
|
210
|
+
|
|
211
|
+
expect(second.created).toBe(true);
|
|
212
|
+
expect(second.epicId).not.toBe(first.epicId);
|
|
213
|
+
|
|
214
|
+
const index = readActiveIndex(cwd);
|
|
215
|
+
expect(Object.keys(index.sessions)).toHaveLength(2);
|
|
216
|
+
});
|
|
@@ -147,6 +147,87 @@ test('fileRcaFeedback: files into the job\'s own project, no feedback dir requir
|
|
|
147
147
|
expect(readSessions(cwd)[res.epicId].status).toBe('proposed');
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
// ─── epic targeting (job.epicId → preferred join target) ───────────────────
|
|
151
|
+
|
|
152
|
+
function writeActiveIndex(cwd, sessions) {
|
|
153
|
+
const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
154
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
155
|
+
const events = {};
|
|
156
|
+
for (const id of Object.keys(sessions)) {
|
|
157
|
+
events[id] = [{ id: `${id}-e0`, promptSessionId: id, kind: 'prompt', causedByEventId: null, at: new Date(0).toISOString(), text: sessions[id].goalText }];
|
|
158
|
+
}
|
|
159
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test('fileRcaFeedback: joins the job\'s known origin Epic when it is still open, and tags source', async () => {
|
|
163
|
+
const cwd = makeProjectWithInbox();
|
|
164
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
165
|
+
writeRun(runDir, 'testslug');
|
|
166
|
+
writePrd(cwd, 'testslug');
|
|
167
|
+
|
|
168
|
+
writeActiveIndex(cwd, {
|
|
169
|
+
'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'proposed', createdAt: new Date(0).toISOString(), completedAt: null },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({
|
|
173
|
+
job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(res.filed).toBe(true);
|
|
177
|
+
expect(res.epicId).toBe('origin-epic-1');
|
|
178
|
+
expect(res.created).toBe(false);
|
|
179
|
+
expect(Object.keys(readSessions(cwd)).length).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('fileRcaFeedback: does not join a completed origin Epic, falls through to mint', async () => {
|
|
183
|
+
const cwd = makeProjectWithInbox();
|
|
184
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
185
|
+
writeRun(runDir, 'testslug');
|
|
186
|
+
writePrd(cwd, 'testslug');
|
|
187
|
+
|
|
188
|
+
writeActiveIndex(cwd, {
|
|
189
|
+
'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'completed', createdAt: new Date(0).toISOString(), completedAt: new Date(0).toISOString() },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({
|
|
193
|
+
job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(res.filed).toBe(true);
|
|
197
|
+
expect(res.epicId).not.toBe('origin-epic-1');
|
|
198
|
+
expect(res.created).toBe(true);
|
|
199
|
+
const sessions = readSessions(cwd);
|
|
200
|
+
expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('fileRcaFeedback: a nonexistent job.epicId falls through to mint without erroring', async () => {
|
|
204
|
+
const cwd = makeProjectWithInbox();
|
|
205
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
206
|
+
writeRun(runDir, 'testslug');
|
|
207
|
+
writePrd(cwd, 'testslug');
|
|
208
|
+
|
|
209
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({
|
|
210
|
+
job: baseJob({ cwd, epicId: 'does-not-exist' }), runDir, verdict: 'transcript_errors',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(res.filed).toBe(true);
|
|
214
|
+
expect(res.epicId).not.toBe('does-not-exist');
|
|
215
|
+
expect(res.created).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('fileRcaFeedback: no job.epicId preserves current mint-or-reuseByGoal behavior', async () => {
|
|
219
|
+
const cwd = makeProjectWithInbox();
|
|
220
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
221
|
+
writeRun(runDir, 'testslug');
|
|
222
|
+
writePrd(cwd, 'testslug');
|
|
223
|
+
|
|
224
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
225
|
+
expect(res.filed).toBe(true);
|
|
226
|
+
expect(res.created).toBe(true);
|
|
227
|
+
const sessions = readSessions(cwd);
|
|
228
|
+
expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
|
|
229
|
+
});
|
|
230
|
+
|
|
150
231
|
test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
|
|
151
232
|
const cwd = makeProjectWithInbox();
|
|
152
233
|
const runDir = path.join(tmpHome, 'run1');
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* scheduler-writeprd-epic-rollback.test.cjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* scheduler-writeprd-epic-rollback.test.cjs — remote.writePrd may only JOIN
|
|
3
|
+
* an existing, already-human-approved Epic (via `sourcePromptId`); it must
|
|
4
|
+
* never mint a new one (mintIfMissing:false, see epicMint.cjs's ensureEpic).
|
|
5
|
+
* A write with no resolvable Epic fails outright with zero residual
|
|
6
|
+
* active-index.json entries — no orphaned Epic can ever be left behind by
|
|
7
|
+
* this path because none is ever created by it.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and then fails must leave that Epic's history untouched.
|
|
9
|
+
* A PRD write that joins a pre-existing Epic and then fails (e.g. an invalid
|
|
10
|
+
* slug) must leave that Epic's history untouched.
|
|
12
11
|
*
|
|
13
12
|
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs
|
|
14
13
|
*/
|
|
@@ -21,6 +20,7 @@ const os = require('node:os');
|
|
|
21
20
|
const path = require('node:path');
|
|
22
21
|
const { remote } = require('../scheduler.cjs');
|
|
23
22
|
const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
|
|
23
|
+
const config = require('../config.cjs');
|
|
24
24
|
|
|
25
25
|
const tmpDirs = [];
|
|
26
26
|
|
|
@@ -38,18 +38,20 @@ afterEach(() => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
// A slug that escapes safeSlugPathIn's containment check, tripping the
|
|
41
|
-
// "invalid slug" branch of writePrd (scheduler.cjs
|
|
42
|
-
//
|
|
41
|
+
// "invalid slug" branch of writePrd (scheduler.cjs's writePrd). Only the
|
|
42
|
+
// second test below (joining a pre-existing Epic) actually reaches this
|
|
43
|
+
// check — the first test fails earlier, at Epic resolution, before the slug
|
|
44
|
+
// is ever considered.
|
|
43
45
|
const ESCAPING_SLUG = '../escape-attempt';
|
|
44
46
|
|
|
45
|
-
test('writePrd:
|
|
47
|
+
test('writePrd: no sourcePromptId refuses to write (no Epic to join) and mints nothing', async () => {
|
|
46
48
|
const cwd = makeFixtureCwd();
|
|
47
|
-
const body = '---\ntitle:
|
|
49
|
+
const body = '---\ntitle: No epic given\n---\n\nbody text\n';
|
|
48
50
|
|
|
49
51
|
const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
|
|
50
52
|
|
|
51
53
|
expect(result.ok).toBe(false);
|
|
52
|
-
expect(result.error).
|
|
54
|
+
expect(result.error).toMatch(/no existing Epic to join/);
|
|
53
55
|
|
|
54
56
|
const index = readActiveIndex(cwd);
|
|
55
57
|
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
@@ -76,3 +78,28 @@ test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves tha
|
|
|
76
78
|
expect(afterIndex.sessions[preexisting.epicId]).toEqual(beforeIndex.sessions[preexisting.epicId]);
|
|
77
79
|
expect(afterIndex.events[preexisting.epicId]).toEqual(beforeIndex.events[preexisting.epicId]);
|
|
78
80
|
});
|
|
81
|
+
|
|
82
|
+
// Provenance (PRD 905): writePrd's ensureEpic call passes
|
|
83
|
+
// source: { producer: 'scheduler-dispatch', prdSlug }, but this call is
|
|
84
|
+
// join-only (mintIfMissing:false, explicit epicId) — ensureEpic's
|
|
85
|
+
// explicitEpicId branch returns before ever reading `source`, so a
|
|
86
|
+
// successful join must NOT write/alter the joined Epic's `source` field.
|
|
87
|
+
test('writePrd: successful join does not write a source field onto the pre-existing Epic (join-only no-op)', async () => {
|
|
88
|
+
// Must live under the home dir — config.cjs's writeTextAtomic validates
|
|
89
|
+
// paths against allowedRoots=home dir, unlike the failure-path tests above
|
|
90
|
+
// (which never reach the write) os.tmpdir() is not sufficient here.
|
|
91
|
+
const cwd = fs.mkdtempSync(path.join(os.homedir(), '.sm-writeprd-epic-source-'));
|
|
92
|
+
tmpDirs.push(cwd);
|
|
93
|
+
config.addAllowedRoot(cwd);
|
|
94
|
+
|
|
95
|
+
const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join' });
|
|
96
|
+
expect(preexisting.created).toBe(true);
|
|
97
|
+
|
|
98
|
+
const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
|
|
99
|
+
const result = await remote.writePrd('valid-slug', body, cwd);
|
|
100
|
+
|
|
101
|
+
expect(result.ok).toBe(true);
|
|
102
|
+
|
|
103
|
+
const index = readActiveIndex(cwd);
|
|
104
|
+
expect('source' in index.sessions[preexisting.epicId]).toBe(false);
|
|
105
|
+
});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -377,6 +377,14 @@ const setConfigSchema = z.object({
|
|
|
377
377
|
}).optional(),
|
|
378
378
|
}).strict();
|
|
379
379
|
|
|
380
|
+
// Machine-wide claude -p slot pool cap (sessionSlots.cjs) — deliberately its own
|
|
381
|
+
// schema/channel rather than folded into setConfigSchema: it governs the shared
|
|
382
|
+
// process pool, not any one project's scheduler config, and its range (0-10, 0
|
|
383
|
+
// pauses new launches) differs from concurrencyCap's (1-20).
|
|
384
|
+
const setSessionSlotsSchema = z.object({
|
|
385
|
+
cap: z.number().int().min(0).max(10),
|
|
386
|
+
}).strict();
|
|
387
|
+
|
|
380
388
|
// ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
|
|
381
389
|
// Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
|
|
382
390
|
// Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
|
|
@@ -754,9 +762,6 @@ const READ_COMMANDS = new Set([
|
|
|
754
762
|
// Sensitive reads — return user data; require SAS confirmation same as MUTATE.
|
|
755
763
|
const SAS_GATED_READS = new Set([
|
|
756
764
|
'cmd:sessions:load',
|
|
757
|
-
'cmd:schedule:state',
|
|
758
|
-
'cmd:schedule:read-prd',
|
|
759
|
-
'cmd:schedule:read-log',
|
|
760
765
|
'cmd:history:aggregate',
|
|
761
766
|
// subscribe initiates a live stream of session state/summary — sensitive.
|
|
762
767
|
'cmd:session:subscribe',
|
|
@@ -764,6 +769,12 @@ const SAS_GATED_READS = new Set([
|
|
|
764
769
|
// no dispatch handler for it, so an allowlist entry would only fail closed with
|
|
765
770
|
// an opaque reject. Re-add here together with the handler when remote exchanges
|
|
766
771
|
// are wired, so the allowlist always mirrors an actual capability.
|
|
772
|
+
//
|
|
773
|
+
// Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
|
|
774
|
+
// (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
|
|
775
|
+
// internals at all; a future Remote rebuild will talk to Scheduler/Epics
|
|
776
|
+
// through whatever higher-level surface that redesign lands on, not this
|
|
777
|
+
// per-command allowlist.
|
|
767
778
|
]);
|
|
768
779
|
|
|
769
780
|
const MUTATE_COMMANDS = new Set([
|
|
@@ -776,10 +787,6 @@ const MUTATE_COMMANDS = new Set([
|
|
|
776
787
|
// mobile mirror has no business killing or resizing the desktop's session.
|
|
777
788
|
'cmd:pty:kill',
|
|
778
789
|
'cmd:pty:resize',
|
|
779
|
-
'cmd:schedule:write-prd',
|
|
780
|
-
'cmd:schedule:reset-job',
|
|
781
|
-
'cmd:schedule:run-now',
|
|
782
|
-
'cmd:schedule:set-config',
|
|
783
790
|
// Pushes a prompt into an open tab's chat queue — a real mutation of that
|
|
784
791
|
// tab's session, same tier as pty:write.
|
|
785
792
|
'cmd:chat:send',
|
|
@@ -839,6 +846,7 @@ module.exports = {
|
|
|
839
846
|
scheduleArchivePrd,
|
|
840
847
|
scheduleRetagPrd,
|
|
841
848
|
setConfigSchema,
|
|
849
|
+
setSessionSlotsSchema,
|
|
842
850
|
shellOpen,
|
|
843
851
|
archiveProject,
|
|
844
852
|
historyAggregate,
|