claude-code-session-manager 0.25.0 → 0.25.1
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-BIuH7h34.js → TiptapBody-_BRc_wPu.js} +1 -1
- package/dist/assets/{index-H0IXEKiC.js → index-CK5Ob11w.js} +4 -4
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/main/__tests__/dod-batchkey.test.cjs +183 -0
- package/src/main/__tests__/dod-reverify.test.cjs +285 -0
- package/src/main/index.cjs +26 -1
- package/src/main/lib/definitionOfDone.cjs +316 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* definitionOfDone.cjs — pure helpers for the definition-of-done drain gate.
|
|
5
|
+
*
|
|
6
|
+
* No scheduler imports; no side effects beyond fs reads in reportExists.
|
|
7
|
+
* The scheduler wires these in (PRD 111).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('node:crypto');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { spawn } = require('node:child_process');
|
|
15
|
+
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
16
|
+
|
|
17
|
+
// Regex identifying meta/dod slugs that must NOT influence the batchKey.
|
|
18
|
+
// This is the load-bearing loop-avoidance filter: when the gate job itself
|
|
19
|
+
// completes, the real batchKey must remain unchanged so the drain branch stays
|
|
20
|
+
// a no-op (idempotent) instead of re-firing forever.
|
|
21
|
+
const DOD_SLUG_RE = /(^|-)dod(-|$)|definition-of-done/i;
|
|
22
|
+
|
|
23
|
+
const RUNS_DIR = path.join(
|
|
24
|
+
os.homedir(),
|
|
25
|
+
'.claude', 'session-manager', 'scheduled-plans', 'runs'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute a stable short hash for a completed job-set.
|
|
30
|
+
*
|
|
31
|
+
* Complexity: O(n log n) for the sort over n completed jobs; n is small
|
|
32
|
+
* (the scheduler queue, not user-scaled data).
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<{slug: string, runId: string}>} jobs
|
|
35
|
+
* @returns {string} 8-char hex prefix of SHA-1 over sorted identity strings
|
|
36
|
+
*/
|
|
37
|
+
function batchKey(jobs) {
|
|
38
|
+
const identities = jobs
|
|
39
|
+
.filter(j => !DOD_SLUG_RE.test(j.slug))
|
|
40
|
+
.map(j => `${j.slug}@${j.runId}`)
|
|
41
|
+
.sort();
|
|
42
|
+
|
|
43
|
+
return crypto
|
|
44
|
+
.createHash('sha1')
|
|
45
|
+
.update(identities.join('\n'))
|
|
46
|
+
.digest('hex')
|
|
47
|
+
.slice(0, 8);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Canonical path for a DoD report file in a new timestamped run directory.
|
|
52
|
+
* Callers that write the report must create the directory themselves.
|
|
53
|
+
*
|
|
54
|
+
* NOTE: each call mints a fresh timestamp, so every call returns a DIFFERENT
|
|
55
|
+
* path even for the same key. Call once, save the result, reuse it — do not
|
|
56
|
+
* call twice expecting the same directory.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} key Output of batchKey()
|
|
59
|
+
* @returns {string} Absolute path under runs/<iso-ts>/definition-of-done-<key>.md
|
|
60
|
+
*/
|
|
61
|
+
function reportPathFor(key) {
|
|
62
|
+
if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
|
|
63
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
64
|
+
return path.join(RUNS_DIR, ts, `definition-of-done-${key}.md`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Return true if a DoD report for this batchKey already exists in any
|
|
69
|
+
* run subdirectory. Scans runs/<ts>/ (shallow, one level).
|
|
70
|
+
*
|
|
71
|
+
* @param {string} key Output of batchKey()
|
|
72
|
+
* @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
function reportExists(key, runsDir = RUNS_DIR) {
|
|
76
|
+
if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = fs.readdirSync(runsDir, { withFileTypes: true });
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const target = `definition-of-done-${key}.md`;
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!entry.isDirectory()) continue;
|
|
87
|
+
const candidate = path.join(runsDir, entry.name, target);
|
|
88
|
+
if (fs.existsSync(candidate)) return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Shell metacharacters that require shell:true — prohibited by CLAUDE.md for
|
|
94
|
+
// non-user-supplied strings. Commands containing these are marked unverifiable
|
|
95
|
+
// rather than running under a shell, since CLAUDE.md restricts shell:true to
|
|
96
|
+
// watchers.cjs and app:test-fire-hook only.
|
|
97
|
+
const SHELL_META_RE = /[|>&;<`]|\$[({]/;
|
|
98
|
+
|
|
99
|
+
const PRDS_DIR = path.join(
|
|
100
|
+
os.homedir(),
|
|
101
|
+
'.claude', 'session-manager', 'scheduled-plans', 'prds'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract the first bounded AC test command from a PRD body.
|
|
106
|
+
*
|
|
107
|
+
* Searches the # Acceptance criteria section for lines containing a
|
|
108
|
+
* `timeout NNN cmd [args...]` invocation. Returns the command string or null.
|
|
109
|
+
*
|
|
110
|
+
* Commands containing shell metacharacters (pipe, redirect, subshell) are
|
|
111
|
+
* skipped — they cannot be run without shell:true which is prohibited here.
|
|
112
|
+
* The parser falls through to the next AC line in that case.
|
|
113
|
+
*
|
|
114
|
+
* Complexity: O(L) where L = number of lines in the body (not user-scaled;
|
|
115
|
+
* PRD bodies are bounded documents, typically < 200 lines).
|
|
116
|
+
*
|
|
117
|
+
* @param {string} prdBody PRD markdown with frontmatter already stripped.
|
|
118
|
+
* @returns {string|null}
|
|
119
|
+
*/
|
|
120
|
+
function extractAcCommand(prdBody) {
|
|
121
|
+
if (!prdBody || typeof prdBody !== 'string') return null;
|
|
122
|
+
|
|
123
|
+
// Parse line-by-line to locate the # Acceptance criteria section.
|
|
124
|
+
// A regex lookahead approach with the `m` flag misidentifies `$` as
|
|
125
|
+
// end-of-line (not end-of-string), causing the lazy capture to terminate
|
|
126
|
+
// immediately. Line-by-line parsing avoids that pitfall.
|
|
127
|
+
const lines = prdBody.split('\n');
|
|
128
|
+
let inAcSection = false;
|
|
129
|
+
let hasAcSection = false;
|
|
130
|
+
let acHeadingLevel = 0;
|
|
131
|
+
const acLines = [];
|
|
132
|
+
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
if (/^#+\s/i.test(line)) {
|
|
135
|
+
const level = line.match(/^(#+)/)[1].length;
|
|
136
|
+
if (/^#+\s*Acceptance\s+criteria/i.test(line)) {
|
|
137
|
+
inAcSection = true;
|
|
138
|
+
hasAcSection = true;
|
|
139
|
+
acHeadingLevel = level;
|
|
140
|
+
} else if (inAcSection && level <= acHeadingLevel) {
|
|
141
|
+
// A sibling or parent heading ends the AC section;
|
|
142
|
+
// sub-headings (level > acHeadingLevel) stay inside it.
|
|
143
|
+
inAcSection = false;
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (inAcSection) acLines.push(line);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fall back to full body if no AC section header was found.
|
|
151
|
+
const candidates = hasAcSection ? acLines : lines;
|
|
152
|
+
|
|
153
|
+
for (const line of candidates) {
|
|
154
|
+
// Prefer backtick-delimited inline code: `timeout NNN cmd ...`
|
|
155
|
+
const backtickMatch = line.match(/`(timeout\s+\d+\s+[^`]+)`/i);
|
|
156
|
+
if (backtickMatch) {
|
|
157
|
+
const cmd = backtickMatch[1].trim();
|
|
158
|
+
if (!SHELL_META_RE.test(cmd)) return cmd;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Fall back: bare `timeout NNN cmd ...` anywhere on the line.
|
|
162
|
+
// Trim trailing all-lowercase-alpha tokens (prose words like "passes",
|
|
163
|
+
// "and", "the") while keeping at least 4 tokens (timeout, N, binary,
|
|
164
|
+
// first-arg). This prevents over-matching into prose that follows the
|
|
165
|
+
// command on the same AC line (e.g. "timeout 60 npm test and verify").
|
|
166
|
+
const rawMatch = line.match(/\btimeout\s+\d+\s+\S+(?:\s+\S+)*/);
|
|
167
|
+
if (rawMatch) {
|
|
168
|
+
const tokens = rawMatch[0].trim().split(/\s+/);
|
|
169
|
+
while (tokens.length > 4 && /^[a-z]+$/.test(tokens[tokens.length - 1])) {
|
|
170
|
+
tokens.pop();
|
|
171
|
+
}
|
|
172
|
+
const cmd = tokens.join(' ');
|
|
173
|
+
if (!SHELL_META_RE.test(cmd)) return cmd;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-run the AC test command for a single completed job and report the result.
|
|
181
|
+
*
|
|
182
|
+
* Never touches queue.json or spawns claude — only re-runs the already-authored
|
|
183
|
+
* test command. That is what keeps this function loop-safe and cheap.
|
|
184
|
+
*
|
|
185
|
+
* @param {{ slug: string, cwd: string }} job
|
|
186
|
+
* @param {{ timeoutMs?: number, prdsDir?: string }} opts
|
|
187
|
+
* timeoutMs Hard kill ceiling for the child (default 60s).
|
|
188
|
+
* prdsDir Override PRD directory (for tests).
|
|
189
|
+
* @returns {Promise<{ slug: string, status: 'pass'|'fail'|'unverifiable', code: number|null, ms: number }>}
|
|
190
|
+
*/
|
|
191
|
+
function reverifyAc(job, { timeoutMs = 60_000, prdsDir } = {}) {
|
|
192
|
+
const resolvedPrdsDir = prdsDir ?? PRDS_DIR;
|
|
193
|
+
const startNs = process.hrtime.bigint();
|
|
194
|
+
|
|
195
|
+
function elapsedMs() {
|
|
196
|
+
return Math.round(Number(process.hrtime.bigint() - startNs) / 1e6);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function unverifiable() {
|
|
200
|
+
return { slug: job.slug, status: 'unverifiable', code: null, ms: elapsedMs() };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Guard: cwd must exist (target project may have been deleted).
|
|
204
|
+
try {
|
|
205
|
+
fs.statSync(job.cwd);
|
|
206
|
+
} catch {
|
|
207
|
+
return Promise.resolve(unverifiable());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Read and strip PRD frontmatter via the shared parser.
|
|
211
|
+
const prdPath = path.join(resolvedPrdsDir, `${job.slug}.md`);
|
|
212
|
+
let prdBody;
|
|
213
|
+
try {
|
|
214
|
+
const raw = fs.readFileSync(prdPath, 'utf8');
|
|
215
|
+
prdBody = splitFrontmatter(raw).body;
|
|
216
|
+
} catch {
|
|
217
|
+
return Promise.resolve(unverifiable());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const cmd = extractAcCommand(prdBody);
|
|
221
|
+
if (!cmd) return Promise.resolve(unverifiable());
|
|
222
|
+
|
|
223
|
+
// Simple whitespace split — SHELL_META_RE in extractAcCommand already excludes
|
|
224
|
+
// commands that would need a shell. Paths with spaces are not expected in AC
|
|
225
|
+
// commands (PRD authoring convention: use relative paths from cwd).
|
|
226
|
+
const argv = cmd.trim().split(/\s+/);
|
|
227
|
+
if (argv.length < 2) return Promise.resolve(unverifiable());
|
|
228
|
+
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
let settled = false;
|
|
231
|
+
|
|
232
|
+
let child;
|
|
233
|
+
try {
|
|
234
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
235
|
+
cwd: job.cwd,
|
|
236
|
+
stdio: 'ignore',
|
|
237
|
+
// No shell:true — extractAcCommand rejects commands with shell metacharacters.
|
|
238
|
+
});
|
|
239
|
+
} catch (err) {
|
|
240
|
+
resolve(unverifiable());
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let escalate;
|
|
245
|
+
const killTimer = setTimeout(() => {
|
|
246
|
+
try { child.kill('SIGTERM'); } catch { /* already dead */ }
|
|
247
|
+
escalate = setTimeout(() => {
|
|
248
|
+
try { child.kill('SIGKILL'); } catch { /* race */ }
|
|
249
|
+
}, 5_000);
|
|
250
|
+
if (escalate.unref) escalate.unref();
|
|
251
|
+
}, timeoutMs);
|
|
252
|
+
if (killTimer.unref) killTimer.unref();
|
|
253
|
+
|
|
254
|
+
child.on('error', () => {
|
|
255
|
+
if (settled) return;
|
|
256
|
+
settled = true;
|
|
257
|
+
clearTimeout(killTimer);
|
|
258
|
+
clearTimeout(escalate);
|
|
259
|
+
resolve(unverifiable());
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
child.on('close', (code) => {
|
|
263
|
+
if (settled) return;
|
|
264
|
+
settled = true;
|
|
265
|
+
clearTimeout(killTimer);
|
|
266
|
+
clearTimeout(escalate);
|
|
267
|
+
const exitCode = typeof code === 'number' ? code : -1;
|
|
268
|
+
resolve({
|
|
269
|
+
slug: job.slug,
|
|
270
|
+
status: exitCode === 0 ? 'pass' : 'fail',
|
|
271
|
+
code: exitCode,
|
|
272
|
+
ms: elapsedMs(),
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Re-run AC commands sequentially over a batch of completed jobs.
|
|
280
|
+
*
|
|
281
|
+
* Sequential execution respects the machine's max-3-concurrent rule — a drain
|
|
282
|
+
* event that fires reverifyBatch is already consuming one slot; sequential
|
|
283
|
+
* children ensure we never pile additional pressure on top.
|
|
284
|
+
*
|
|
285
|
+
* Total wall-time is bounded by batchTimeoutMs. When the cap is reached,
|
|
286
|
+
* remaining jobs are returned as unverifiable without being started — the batch
|
|
287
|
+
* cannot hang regardless of how many jobs are queued.
|
|
288
|
+
*
|
|
289
|
+
* Complexity: O(n) sequential spawns; n = number of completed jobs (bounded
|
|
290
|
+
* by the scheduler queue size, not user-scaled data).
|
|
291
|
+
*
|
|
292
|
+
* @param {Array<{ slug: string, cwd: string }>} jobs
|
|
293
|
+
* @param {{ timeoutMs?: number, batchTimeoutMs?: number, prdsDir?: string }} opts
|
|
294
|
+
* timeoutMs Per-job kill ceiling (default 60s).
|
|
295
|
+
* batchTimeoutMs Total wall-time cap for the whole batch (default 10m).
|
|
296
|
+
* prdsDir Override PRD directory (for tests).
|
|
297
|
+
* @returns {Promise<Array<{ slug: string, status: string, code: number|null, ms: number }>>}
|
|
298
|
+
*/
|
|
299
|
+
async function reverifyBatch(jobs, { timeoutMs = 60_000, batchTimeoutMs = 600_000, prdsDir } = {}) {
|
|
300
|
+
const batchStartNs = process.hrtime.bigint();
|
|
301
|
+
const results = [];
|
|
302
|
+
|
|
303
|
+
for (const job of jobs) {
|
|
304
|
+
const elapsedMs = Number(process.hrtime.bigint() - batchStartNs) / 1e6;
|
|
305
|
+
if (elapsedMs >= batchTimeoutMs) {
|
|
306
|
+
results.push({ slug: job.slug, status: 'unverifiable', code: null, ms: 0 });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const result = await reverifyAc(job, { timeoutMs, prdsDir });
|
|
310
|
+
results.push(result);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return results;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = { batchKey, reportPathFor, reportExists, extractAcCommand, reverifyAc, reverifyBatch };
|