claude-code-session-manager 0.64.0 → 0.66.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/AgentLibrary-Bzg89D5Y.js +3 -0
- package/dist/assets/{History-Dp3-_bjQ.js → History-CNH9vA0A.js} +2 -2
- package/dist/assets/{Hooks-DMQFgGDd.js → Hooks-CyTksPza.js} +3 -3
- package/dist/assets/{HostBilko-zNU1NtBh.js → HostBilko-DXQVDNHn.js} +1 -1
- package/dist/assets/{Library-BrODBbeL.js → Library-9E2UOIsm.js} +1 -1
- package/dist/assets/{ListDetail-C5vbA1gC.js → ListDetail-CQWU_Yn5.js} +1 -1
- package/dist/assets/{MarkdownEditor-CpzAEiiG.js → MarkdownEditor-DQgfpSef.js} +1 -1
- package/dist/assets/{McpServers-DWuw-flj.js → McpServers-r3qwDIj2.js} +2 -2
- package/dist/assets/{Memory-B4TC9SsJ.js → Memory-BaxOpj-3.js} +6 -6
- package/dist/assets/{Panel-Bap7G-pI.js → Panel-BhFD8Lqo.js} +1 -1
- package/dist/assets/{Permissions-BIpNWItH.js → Permissions-Cj-mODQQ.js} +3 -3
- package/dist/assets/{Plugins-DrwklmW4.js → Plugins-DEL3Fqng.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CCp_EhjV.js → ProvenanceBadge-CIAg6-JQ.js} +1 -1
- package/dist/assets/Scheduler-YOuZKkES.js +14 -0
- package/dist/assets/{ScopeSwitcher-BlQvpyLE.js → ScopeSwitcher-DZ_3gEus.js} +1 -1
- package/dist/assets/{Settings-BzvmZvGa.js → Settings-B0x4oflz.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-CGtwmCjm.js → SkillReferenceGraph-CoIwsol8.js} +1 -1
- package/dist/assets/{Skills-Cauvbtk7.js → Skills-CN8R6AWn.js} +2 -2
- package/dist/assets/{SystemPrompt-BqzM76PV.js → SystemPrompt-DceChpAi.js} +1 -1
- package/dist/assets/{TagLibrary-DVKPjeRu.js → TagLibrary-BKmz2W7B.js} +1 -1
- package/dist/assets/{TiptapBody-CUWdBAYf.js → TiptapBody-W7n5SwPM.js} +1 -1
- package/dist/assets/{Toggle-Bos67yDJ.js → Toggle-QuxlVHGI.js} +1 -1
- package/dist/assets/{index-LlWpj2VJ.css → index-14dBLqE_.css} +1 -1
- package/dist/assets/{index-BfPVknuV.js → index-TejhHSzN.js} +526 -525
- package/dist/assets/{settingsSchema-DKuyaEK5.js → settingsSchema-DNqx6BKJ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +120 -0
- package/plugins/session-manager-dev/skills/builder/SKILL.md +13 -3
- package/plugins/session-manager-dev/skills/develop/SKILL.md +41 -13
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
- package/src/main/__tests__/agentLibrary.test.cjs +40 -0
- package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
- package/src/main/__tests__/prdCreate.test.cjs +7 -2
- package/src/main/__tests__/prdMigration.test.cjs +17 -0
- package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
- package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
- package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
- package/src/main/agentLibrary.cjs +40 -2
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +60 -0
- package/src/main/lib/localAdminHttp.cjs +10 -3
- package/src/main/lib/prdAdminRoutes.cjs +175 -0
- package/src/main/lib/prdCreate.cjs +37 -2
- package/src/main/lib/prdFrontmatter.cjs +179 -1
- package/src/main/lib/prdMigration.cjs +82 -5
- package/src/main/lib/queueStore.cjs +41 -7
- package/src/main/lib/scheduleJobSchema.cjs +114 -0
- package/src/main/lib/scheduleJobTransitions.cjs +164 -0
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +649 -137
- package/src/preload/api.d.ts +54 -2
- package/src/preload/index.cjs +12 -0
- package/dist/assets/AgentLibrary-9KJ4fedH.js +0 -1
- package/dist/assets/Scheduler-DfoTegGG.js +0 -14
- /package/plugins/session-manager-dev/skills/builder/{4-report → 5-report}/SKILL.md +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reconcile-invalid-repair.test.cjs — end-to-end reproduction of
|
|
3
|
+
* the 2026-08-07 1021/1022 incident: a queue.json row with `"status":
|
|
4
|
+
* "queued"` (a value outside ScheduleJobStatus) sat unpicked for 4+ hours.
|
|
5
|
+
*
|
|
6
|
+
* queueStore.shapeJobs (scheduleJobSchema.cjs, landed by a prior PRD)
|
|
7
|
+
* already quarantines such a row into `invalidJobs` on read rather than
|
|
8
|
+
* passing it through — but reconcile() itself was still add-only (`if
|
|
9
|
+
* (seen.has(slug)) continue`), so a quarantined row was never repaired, only
|
|
10
|
+
* silently absent from state.jobs with no log of what its bad status had
|
|
11
|
+
* been.
|
|
12
|
+
*
|
|
13
|
+
* This test exercises the real quarantine path (queueStore.shapeJobs on the
|
|
14
|
+
* raw incident JSON) against a real on-disk PRD, runs the real reconcile(),
|
|
15
|
+
* and asserts: computeStallSummary flags the PRE-repair state as stalled,
|
|
16
|
+
* reconcile() repairs the row to 'pending', logs the repair at warn level,
|
|
17
|
+
* and audits it.
|
|
18
|
+
*
|
|
19
|
+
* HOME-isolated (mirrors scheduler-prd-missing-skip.test.cjs): stub HOME to
|
|
20
|
+
* a mkdtemp dir before requiring scheduler.cjs/queueStore.cjs, and register
|
|
21
|
+
* the fixture project as "active" via a fake ~/.claude/projects transcript —
|
|
22
|
+
* reconcile's PRD/history discovery (prdLocations.cjs, queueHistory.cjs)
|
|
23
|
+
* only scans cwds it can find that way.
|
|
24
|
+
*
|
|
25
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
import { test, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const os = require('node:os');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
let tmpHome;
|
|
36
|
+
let originalHome;
|
|
37
|
+
let scheduler;
|
|
38
|
+
let queueStore;
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
originalHome = process.env.HOME;
|
|
42
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-repair-home-'));
|
|
43
|
+
process.env.HOME = tmpHome;
|
|
44
|
+
|
|
45
|
+
scheduler = require('../scheduler.cjs');
|
|
46
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
47
|
+
|
|
48
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
49
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
process.env.HOME = originalHome;
|
|
55
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function registerActiveProject(cwd) {
|
|
63
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
64
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
65
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeFixtureProject(prefix) {
|
|
70
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
71
|
+
registerActiveProject(cwd);
|
|
72
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
73
|
+
const prdsDir = path.join(opsRoot, 'scheduler', 'prds');
|
|
74
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
75
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
76
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
77
|
+
return { cwd, prdsDir, stateDir };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test('reconcile() repairs a queue row with an invalid status back to pending, logs, and audits it', async () => {
|
|
81
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-reconcile-repair-proj-');
|
|
82
|
+
try {
|
|
83
|
+
// Real on-disk PRD (its file location is the reconcile source of truth).
|
|
84
|
+
fs.writeFileSync(
|
|
85
|
+
path.join(prdsDir, '1021-fix-the-thing.md'),
|
|
86
|
+
'---\ntitle: Fix the thing\ncwd: ' + cwd + '\nestimateMinutes: 15\n---\n\n# Goal\nFix it.\n',
|
|
87
|
+
'utf8',
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// The exact incident shape: a row whose status is 'queued', outside
|
|
91
|
+
// ScheduleJobStatus entirely.
|
|
92
|
+
const badRow = {
|
|
93
|
+
slug: '1021-fix-the-thing',
|
|
94
|
+
title: 'Fix the thing',
|
|
95
|
+
cwd,
|
|
96
|
+
status: 'queued',
|
|
97
|
+
runId: null,
|
|
98
|
+
startedAt: null,
|
|
99
|
+
finishedAt: null,
|
|
100
|
+
exitCode: null,
|
|
101
|
+
error: null,
|
|
102
|
+
};
|
|
103
|
+
const queuePath = path.join(stateDir, 'queue.json');
|
|
104
|
+
fs.writeFileSync(queuePath, JSON.stringify({ jobs: [badRow] }, null, 2), 'utf8');
|
|
105
|
+
|
|
106
|
+
// Exercise the REAL quarantine gate (same one queueStore.readMerged runs
|
|
107
|
+
// at boot/every IPC read) rather than hand-building invalidJobs.
|
|
108
|
+
const { jobs, invalid } = queueStore.shapeJobs(fs.readFileSync(queuePath, 'utf8'), queuePath);
|
|
109
|
+
expect(jobs.length).toBe(0); // the bad row never reaches state.jobs
|
|
110
|
+
expect(invalid.length).toBe(1);
|
|
111
|
+
expect(invalid[0].slug).toBe(badRow.slug);
|
|
112
|
+
expect(invalid[0].row.status).toBe('queued');
|
|
113
|
+
|
|
114
|
+
const state = { jobs, invalidJobs: invalid, paused: null };
|
|
115
|
+
|
|
116
|
+
// Stall detector must flag the PRE-repair state: the queue holds a
|
|
117
|
+
// (quarantined) job but nothing is running or pending and it isn't
|
|
118
|
+
// paused — this is exactly the condition that was silent for 4+ hours.
|
|
119
|
+
const preRepairStall = scheduler.computeStallSummary(state);
|
|
120
|
+
expect(preRepairStall.stalled).toBe(true);
|
|
121
|
+
expect(preRepairStall.total).toBe(1);
|
|
122
|
+
expect(preRepairStall.running).toBe(0);
|
|
123
|
+
expect(preRepairStall.pending).toBe(0);
|
|
124
|
+
|
|
125
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
126
|
+
// scheduler.cjs destructures `appendAuditEvent` out of auditLog.cjs at
|
|
127
|
+
// require time, so spying on the module's export property after the
|
|
128
|
+
// fact wouldn't intercept scheduler.cjs's already-bound reference —
|
|
129
|
+
// assert against the real (tmpHome-isolated) audit log file instead.
|
|
130
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
131
|
+
expect(AUDIT_LOG_PATH.startsWith(tmpHome)).toBe(true);
|
|
132
|
+
|
|
133
|
+
queueStore.bustCwdCache();
|
|
134
|
+
await scheduler.reconcile(state);
|
|
135
|
+
|
|
136
|
+
const repaired = state.jobs.find((j) => j.slug === badRow.slug);
|
|
137
|
+
expect(repaired).toBeDefined();
|
|
138
|
+
expect(repaired.status).toBe('pending');
|
|
139
|
+
|
|
140
|
+
const warnedRepair = warnSpy.mock.calls.some((args) =>
|
|
141
|
+
String(args[0]).includes('repaired invalid queue row') && String(args[0]).includes(badRow.slug),
|
|
142
|
+
);
|
|
143
|
+
expect(warnedRepair).toBe(true);
|
|
144
|
+
|
|
145
|
+
const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
146
|
+
const auditedRepair = auditLines.some(
|
|
147
|
+
(rec) => rec.kind === 'scheduler_row_repaired' && rec.slug === badRow.slug && rec.oldStatus === 'queued',
|
|
148
|
+
);
|
|
149
|
+
expect(auditedRepair).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Post-repair, the same condition must no longer read as stalled.
|
|
152
|
+
const postRepairStall = scheduler.computeStallSummary(state);
|
|
153
|
+
expect(postRepairStall.stalled).toBe(false);
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('reconcile() does not resurrect an invalid row whose slug already has a terminal history record', async () => {
|
|
160
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-reconcile-repair-done-');
|
|
161
|
+
try {
|
|
162
|
+
fs.writeFileSync(
|
|
163
|
+
path.join(prdsDir, '1022-already-done.md'),
|
|
164
|
+
'---\ntitle: Already done\ncwd: ' + cwd + '\nestimateMinutes: 15\n---\n\n# Goal\nDone.\n',
|
|
165
|
+
'utf8',
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const badRow = {
|
|
169
|
+
slug: '1022-already-done',
|
|
170
|
+
title: 'Already done',
|
|
171
|
+
cwd,
|
|
172
|
+
status: 'queued',
|
|
173
|
+
runId: 'run-xyz',
|
|
174
|
+
startedAt: new Date().toISOString(),
|
|
175
|
+
finishedAt: null,
|
|
176
|
+
exitCode: null,
|
|
177
|
+
error: null,
|
|
178
|
+
};
|
|
179
|
+
const queuePath = path.join(stateDir, 'queue.json');
|
|
180
|
+
fs.writeFileSync(queuePath, JSON.stringify({ jobs: [badRow] }, null, 2), 'utf8');
|
|
181
|
+
|
|
182
|
+
const { jobs, invalid } = queueStore.shapeJobs(fs.readFileSync(queuePath, 'utf8'), queuePath);
|
|
183
|
+
expect(invalid.length).toBe(1);
|
|
184
|
+
|
|
185
|
+
const state = { jobs, invalidJobs: invalid, paused: null };
|
|
186
|
+
|
|
187
|
+
// A terminal record already exists for this slug — reconcile must never
|
|
188
|
+
// repair the corrupted row back into a runnable 'pending' state on top
|
|
189
|
+
// of already-shipped work.
|
|
190
|
+
fs.writeFileSync(
|
|
191
|
+
path.join(stateDir, 'history.jsonl'),
|
|
192
|
+
JSON.stringify({ slug: badRow.slug, status: 'completed', finishedAt: new Date().toISOString(), runId: 'run-xyz' }) + '\n',
|
|
193
|
+
'utf8',
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
queueStore.bustCwdCache();
|
|
197
|
+
await scheduler.reconcile(state);
|
|
198
|
+
|
|
199
|
+
expect(state.jobs.find((j) => j.slug === badRow.slug)).toBeUndefined();
|
|
200
|
+
} finally {
|
|
201
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reconcile-quarantine.test.cjs — PRD-authoring-lockdown provenance
|
|
3
|
+
* gate: reconcile() must never turn a discovered PRD with no `createdVia`
|
|
4
|
+
* frontmatter into a runnable 'pending' queue row. It quarantines it instead
|
|
5
|
+
* (a distinct, non-runnable status), logs a warning naming the file, and
|
|
6
|
+
* audits the event — then, once the file is stamped (the Scheduler tab's
|
|
7
|
+
* "adopt PRD" action / scheduler_update_prd, both routed through
|
|
8
|
+
* remote.updatePrd), promotes the row to 'pending' on the very next pass.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors scheduler-reconcile-invalid-repair.test.cjs's HOME-isolation setup.
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reconcile-quarantine.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
let tmpHome;
|
|
23
|
+
let originalHome;
|
|
24
|
+
let scheduler;
|
|
25
|
+
let queueStore;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
originalHome = process.env.HOME;
|
|
29
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-quarantine-home-'));
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
|
|
32
|
+
scheduler = require('../scheduler.cjs');
|
|
33
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
34
|
+
|
|
35
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
36
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.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
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function registerActiveProject(cwd) {
|
|
50
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
51
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
52
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeFixtureProject(prefix) {
|
|
57
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
58
|
+
registerActiveProject(cwd);
|
|
59
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
60
|
+
// Epic-scoped, not the retired flat dir: a flat PRD with no live queue row
|
|
61
|
+
// gets swept into prds-archived/ by reconcile()'s own consolidation pass
|
|
62
|
+
// (see consolidateAllFlatPrds) BEFORE it's ever scanned as a discoverable
|
|
63
|
+
// PRD — exactly the scenario every test below needs to observe, so the
|
|
64
|
+
// flat layout would race the very thing under test.
|
|
65
|
+
const prdsDir = path.join(opsRoot, 'scheduler', 'epics', 'test-epic-1', 'prds');
|
|
66
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
67
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
68
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
69
|
+
return { cwd, prdsDir, stateDir };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('reconcile() quarantines a newly-discovered PRD with no createdVia provenance', async () => {
|
|
73
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-new-');
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
path.join(prdsDir, '9001-hand-written.md'),
|
|
77
|
+
`---\ntitle: Hand-written PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nDo the thing.\n`,
|
|
78
|
+
'utf8',
|
|
79
|
+
);
|
|
80
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
81
|
+
|
|
82
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
83
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
84
|
+
|
|
85
|
+
queueStore.bustCwdCache();
|
|
86
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
87
|
+
await scheduler.reconcile(state);
|
|
88
|
+
|
|
89
|
+
const row = state.jobs.find((j) => j.slug === '9001-hand-written');
|
|
90
|
+
expect(row).toBeDefined();
|
|
91
|
+
expect(row.status).toBe('quarantined');
|
|
92
|
+
|
|
93
|
+
const warned = warnSpy.mock.calls.some((args) =>
|
|
94
|
+
String(args[0]).includes('quarantining unstamped PRD') && String(args[0]).includes('9001-hand-written'),
|
|
95
|
+
);
|
|
96
|
+
expect(warned).toBe(true);
|
|
97
|
+
|
|
98
|
+
const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
99
|
+
expect(auditLines.some((rec) => rec.kind === 'prd_quarantined' && rec.slug === '9001-hand-written')).toBe(true);
|
|
100
|
+
} finally {
|
|
101
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('reconcile() queues a newly-discovered PRD stamped with createdVia as pending, not quarantined', async () => {
|
|
106
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-stamped-');
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(
|
|
109
|
+
path.join(prdsDir, '9002-api-written.md'),
|
|
110
|
+
`---\ntitle: API-written PRD\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
|
|
111
|
+
'utf8',
|
|
112
|
+
);
|
|
113
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
114
|
+
|
|
115
|
+
queueStore.bustCwdCache();
|
|
116
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
117
|
+
await scheduler.reconcile(state);
|
|
118
|
+
|
|
119
|
+
const row = state.jobs.find((j) => j.slug === '9002-api-written');
|
|
120
|
+
expect(row).toBeDefined();
|
|
121
|
+
expect(row.status).toBe('pending');
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('reconcile() exempts a fix-plan PRD (NN-fix-*) from quarantine even with no createdVia', async () => {
|
|
128
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-fixplan-');
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(
|
|
131
|
+
path.join(prdsDir, '9003-fix-something.md'),
|
|
132
|
+
`---\ntitle: Fix something\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nHeal it.\n`,
|
|
133
|
+
'utf8',
|
|
134
|
+
);
|
|
135
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
136
|
+
|
|
137
|
+
queueStore.bustCwdCache();
|
|
138
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
139
|
+
await scheduler.reconcile(state);
|
|
140
|
+
|
|
141
|
+
const row = state.jobs.find((j) => j.slug === '9003-fix-something');
|
|
142
|
+
expect(row).toBeDefined();
|
|
143
|
+
expect(row.status).toBe('pending');
|
|
144
|
+
} finally {
|
|
145
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('reconcile() adopts a quarantined row to pending once its PRD file is stamped', async () => {
|
|
150
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-adopt-');
|
|
151
|
+
try {
|
|
152
|
+
const prdPath = path.join(prdsDir, '9004-adopt-me.md');
|
|
153
|
+
fs.writeFileSync(
|
|
154
|
+
prdPath,
|
|
155
|
+
`---\ntitle: Adopt me\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nAdopt.\n`,
|
|
156
|
+
'utf8',
|
|
157
|
+
);
|
|
158
|
+
const quarantinedRow = {
|
|
159
|
+
slug: '9004-adopt-me',
|
|
160
|
+
title: 'Adopt me',
|
|
161
|
+
cwd,
|
|
162
|
+
status: 'quarantined',
|
|
163
|
+
runId: null,
|
|
164
|
+
startedAt: null,
|
|
165
|
+
finishedAt: null,
|
|
166
|
+
exitCode: null,
|
|
167
|
+
error: null,
|
|
168
|
+
};
|
|
169
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [quarantinedRow] }, null, 2), 'utf8');
|
|
170
|
+
|
|
171
|
+
// Simulate the adopt action: stamp the file via the same update-prd
|
|
172
|
+
// logic the IPC handler/admin route call (parsePrdFile/serializePrdFile
|
|
173
|
+
// round-trip), rather than hand-writing raw text, to exercise the real
|
|
174
|
+
// round-trip path this feature depends on.
|
|
175
|
+
const { parsePrdFile, serializePrdFile } = require('../lib/prdFrontmatter.cjs');
|
|
176
|
+
const raw = fs.readFileSync(prdPath, 'utf8');
|
|
177
|
+
const { frontmatter: fm, body } = parsePrdFile(raw);
|
|
178
|
+
fm.createdVia = 'legacy-adopted';
|
|
179
|
+
fm.issuedAt = '2026-08-07T01:00:00.000Z';
|
|
180
|
+
fs.writeFileSync(prdPath, serializePrdFile(fm, body), 'utf8');
|
|
181
|
+
|
|
182
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
183
|
+
queueStore.bustCwdCache();
|
|
184
|
+
const state = { jobs: [quarantinedRow], invalidJobs: [], paused: null };
|
|
185
|
+
await scheduler.reconcile(state);
|
|
186
|
+
|
|
187
|
+
const row = state.jobs.find((j) => j.slug === '9004-adopt-me');
|
|
188
|
+
expect(row).toBeDefined();
|
|
189
|
+
expect(row.status).toBe('pending');
|
|
190
|
+
|
|
191
|
+
const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
192
|
+
expect(auditLines.some((rec) => rec.kind === 'scheduler_prd_adopted' && rec.slug === '9004-adopt-me')).toBe(true);
|
|
193
|
+
} finally {
|
|
194
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
@@ -34,6 +34,25 @@ function parseTools(raw) {
|
|
|
34
34
|
/** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
|
|
35
35
|
const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
36
36
|
|
|
37
|
+
/** Sentinel `projects:` entry meaning "this agent's Action button shows in every project". */
|
|
38
|
+
const ALL_PROJECTS = '*';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Frontmatter values are single-line (see lib/prdFrontmatter.cjs) but an
|
|
42
|
+
* Action's opening instruction is genuinely multi-line prose (the builder
|
|
43
|
+
* action is `/builder\n\n<instruction>`). Encode newlines as a literal `\n`
|
|
44
|
+
* two-char sequence on the way out, decode on the way in — one place, so the
|
|
45
|
+
* round-trip is structural rather than something each caller remembers.
|
|
46
|
+
*/
|
|
47
|
+
function encodeLine(s) {
|
|
48
|
+
return String(s).replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeLine(s) {
|
|
52
|
+
if (!s) return '';
|
|
53
|
+
return String(s).replace(/\\([\\n])/g, (_m, c) => (c === 'n' ? '\n' : '\\'));
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
/**
|
|
38
57
|
* Serializes a persona's frontmatter + body back to `.md` file text.
|
|
39
58
|
* `tags` is a Claude-Code-agnostic extension of the frontmatter (Claude Code
|
|
@@ -41,14 +60,25 @@ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
41
60
|
* this persona is associated with, so Agent Library and Tag Library can each
|
|
42
61
|
* assign/remove the relationship from either side. Stored the same
|
|
43
62
|
* comma-list way as `tools` for one parser to cover both.
|
|
63
|
+
*
|
|
64
|
+
* `projects` / `action` / `actionLabel` are the same kind of extension, for
|
|
65
|
+
* the Sessions workspace's per-project **Action buttons**: `projects` is the
|
|
66
|
+
* list of project cwds (or the `*` sentinel = every project) whose Sessions
|
|
67
|
+
* toolbar shows a one-click button for this agent, `action` is the opening
|
|
68
|
+
* instruction that button sends into the new session, and `actionLabel`
|
|
69
|
+
* overrides the button's caption (defaults to the persona name). Claude Code
|
|
70
|
+
* ignores all three; only this app reads them.
|
|
44
71
|
*/
|
|
45
|
-
function serializePersona({ name, description, tools, model, color, tags, title, body }) {
|
|
72
|
+
function serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body }) {
|
|
46
73
|
const lines = ['---', `name: ${name}`];
|
|
47
74
|
if (description) lines.push(`description: ${description}`);
|
|
48
75
|
if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
|
|
49
76
|
if (model && model !== 'inherit') lines.push(`model: ${model}`);
|
|
50
77
|
if (color) lines.push(`color: ${color}`);
|
|
51
78
|
if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
|
|
79
|
+
if (projects && projects.length) lines.push(`projects: ${projects.join(', ')}`);
|
|
80
|
+
if (action) lines.push(`action: ${encodeLine(action)}`);
|
|
81
|
+
if (actionLabel) lines.push(`actionLabel: ${encodeLine(actionLabel)}`);
|
|
52
82
|
if (title) lines.push(`title: ${title}`);
|
|
53
83
|
lines.push('---', '');
|
|
54
84
|
return lines.join('\n') + (body || '').trim() + '\n';
|
|
@@ -67,6 +97,9 @@ async function savePersona({
|
|
|
67
97
|
model,
|
|
68
98
|
color,
|
|
69
99
|
tags,
|
|
100
|
+
projects,
|
|
101
|
+
action,
|
|
102
|
+
actionLabel,
|
|
70
103
|
title,
|
|
71
104
|
body,
|
|
72
105
|
globalDir = path.join(os.homedir(), '.claude', 'agents'),
|
|
@@ -77,7 +110,7 @@ async function savePersona({
|
|
|
77
110
|
throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
|
|
78
111
|
}
|
|
79
112
|
const target = validatePath(path.join(globalDir, `${name}.md`));
|
|
80
|
-
const text = serializePersona({ name, description, tools, model, color, tags, title, body });
|
|
113
|
+
const text = serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body });
|
|
81
114
|
await writeTextAtomic(target, text);
|
|
82
115
|
if (originalName && originalName !== name) {
|
|
83
116
|
const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
|
|
@@ -203,6 +236,9 @@ async function listPersonas({
|
|
|
203
236
|
model: fm.model || null,
|
|
204
237
|
color: fm.color || null,
|
|
205
238
|
tags: parseTools(fm.tags),
|
|
239
|
+
projects: parseTools(fm.projects),
|
|
240
|
+
action: decodeLine(fm.action) || null,
|
|
241
|
+
actionLabel: decodeLine(fm.actionLabel) || null,
|
|
206
242
|
title: fm.title || null,
|
|
207
243
|
path: real,
|
|
208
244
|
body: body.trim(),
|
|
@@ -220,5 +256,7 @@ module.exports = {
|
|
|
220
256
|
savePersona,
|
|
221
257
|
deletePersona,
|
|
222
258
|
removeOverride,
|
|
259
|
+
serializePersona,
|
|
223
260
|
PERSONA_NAME_RE,
|
|
261
|
+
ALL_PROJECTS,
|
|
224
262
|
};
|
package/src/main/index.cjs
CHANGED
|
@@ -26,6 +26,7 @@ const voiceWizard = require('./voiceWizard.cjs');
|
|
|
26
26
|
const scheduler = require('./scheduler.cjs');
|
|
27
27
|
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
28
28
|
const prdCreate = require('./lib/prdCreate.cjs');
|
|
29
|
+
const prdAdminRoutes = require('./lib/prdAdminRoutes.cjs');
|
|
29
30
|
const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
30
31
|
const chatRunner = require('./chatRunner.cjs');
|
|
31
32
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
@@ -37,6 +38,7 @@ const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
|
|
|
37
38
|
const adminHttp = createAdminHttp();
|
|
38
39
|
scheduler.registerAdminRoutes(adminHttp);
|
|
39
40
|
prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
|
|
41
|
+
prdAdminRoutes.registerAdminRoute(adminHttp, scheduler.remote);
|
|
40
42
|
chatRunner.registerAdminRoute(adminHttp);
|
|
41
43
|
const supervisor = require('./supervisor.cjs');
|
|
42
44
|
const watchers = require('./watchers.cjs');
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -327,6 +327,62 @@ const scheduleRetagPrd = z.object({
|
|
|
327
327
|
items: z.array(scheduleRetagItem).min(1).max(500),
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
+
// ──────────────────────────────────────────── Admin API — PRD lifecycle (PRD 1024)
|
|
331
|
+
//
|
|
332
|
+
// GET routes parse their input from URLSearchParams (all values arrive as
|
|
333
|
+
// strings), so numeric/array fields aren't accepted here — only slug/cwd/
|
|
334
|
+
// epicId/status, matching what a query string can actually carry.
|
|
335
|
+
const adminListPrdsQuery = z.object({
|
|
336
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
337
|
+
epicId: z.string().min(1).max(200).optional(),
|
|
338
|
+
status: z.string().min(1).max(40).optional(),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const adminGetPrdQuery = z.object({
|
|
342
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
343
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Partial frontmatter patch for update-prd — same recognized-key set as
|
|
347
|
+
// prdFrontmatter.cjs's parsePrdFile/serializePrdFile round-trip pair. Every
|
|
348
|
+
// field optional; the route refuses an empty patch+no-body request via the
|
|
349
|
+
// top-level .refine below.
|
|
350
|
+
const adminPrdFrontmatterPatch = z.object({
|
|
351
|
+
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
352
|
+
cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
353
|
+
estimateMinutes: z.number().int().min(1).max(100000).optional(),
|
|
354
|
+
parallelGroup: z.number().int().min(0).max(999999).optional(),
|
|
355
|
+
sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
356
|
+
sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
357
|
+
// 'discussion' deliberately excluded: matches prdFrontmatter.cjs's
|
|
358
|
+
// parsePrdFile, which never parses that value for a PRD's tag key (a
|
|
359
|
+
// discussion-tagged Epic never reaches PRD authoring).
|
|
360
|
+
tag: z.enum(['feature', 'bug', 'build']).optional(),
|
|
361
|
+
// Provenance stamp (PRD-authoring lockdown). Free-text rather than an enum
|
|
362
|
+
// — 'scheduler-api' (create-time) and 'legacy-adopted' (migration/manual
|
|
363
|
+
// adopt) are the two values this codebase writes today, but the field
|
|
364
|
+
// itself is descriptive, not a closed set.
|
|
365
|
+
createdVia: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
366
|
+
issuedAt: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const adminUpdatePrd = z.object({
|
|
370
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
371
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
372
|
+
frontmatter: adminPrdFrontmatterPatch.optional(),
|
|
373
|
+
body: z.string().refine(
|
|
374
|
+
(s) => Buffer.byteLength(s, 'utf8') <= PRD_WRITE_MAX_BYTES,
|
|
375
|
+
`body must be ≤ ${PRD_WRITE_MAX_BYTES} bytes`,
|
|
376
|
+
).optional(),
|
|
377
|
+
}).refine(
|
|
378
|
+
(it) => (it.frontmatter && Object.keys(it.frontmatter).length > 0) || it.body !== undefined,
|
|
379
|
+
'at least one of frontmatter or body is required',
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const adminCancelJob = z.object({
|
|
383
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
384
|
+
});
|
|
385
|
+
|
|
330
386
|
// ──────────────────────────────────────────── Projects
|
|
331
387
|
const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
|
|
332
388
|
|
|
@@ -812,6 +868,10 @@ module.exports = {
|
|
|
812
868
|
schedulerCreatePrd,
|
|
813
869
|
scheduleArchivePrd,
|
|
814
870
|
scheduleRetagPrd,
|
|
871
|
+
adminListPrdsQuery,
|
|
872
|
+
adminGetPrdQuery,
|
|
873
|
+
adminUpdatePrd,
|
|
874
|
+
adminCancelJob,
|
|
815
875
|
setConfigSchema,
|
|
816
876
|
setSessionSlotsSchema,
|
|
817
877
|
shellOpen,
|
|
@@ -151,17 +151,24 @@ function createAdminHttp() {
|
|
|
151
151
|
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// Route lookup is keyed by pathname only — req.url carries the raw query
|
|
155
|
+
// string too, which would otherwise never match a registered route (every
|
|
156
|
+
// route before PRD 1024 was query-free, so this was a latent gap, not a
|
|
157
|
+
// behavior change for them). `query` is handed to the route handler as a
|
|
158
|
+
// third argument (URLSearchParams) for routes that need it (e.g. GET
|
|
159
|
+
// /admin/scheduler/prds?cwd=&epicId=&status=).
|
|
160
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
161
|
+
if (req.method === 'GET' && urlObj.pathname === HEALTH_PATH) {
|
|
155
162
|
sendJson(res, 200, { ok: true });
|
|
156
163
|
return;
|
|
157
164
|
}
|
|
158
165
|
try {
|
|
159
|
-
const handler = routes.get(`${req.method} ${
|
|
166
|
+
const handler = routes.get(`${req.method} ${urlObj.pathname}`);
|
|
160
167
|
if (!handler) {
|
|
161
168
|
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
162
169
|
return;
|
|
163
170
|
}
|
|
164
|
-
await handler(req, res);
|
|
171
|
+
await handler(req, res, urlObj.searchParams);
|
|
165
172
|
} catch (e) {
|
|
166
173
|
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
167
174
|
}
|