claude-code-session-manager 0.24.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.
@@ -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 };