claude-code-session-manager 0.35.15 → 0.35.17
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-B7PHDZwk.js → TiptapBody-BM9Kz1Nm.js} +1 -1
- package/dist/assets/index-DX2w2YhJ.css +32 -0
- package/dist/assets/index-DuoC6oCy.js +3559 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/main/__tests__/adminServer.test.cjs +205 -0
- package/src/main/__tests__/chat-queue.test.cjs +27 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
- package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
- package/src/main/__tests__/prdCreate.test.cjs +82 -0
- package/src/main/__tests__/runVerify.test.cjs +146 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
- package/src/main/adminServer.cjs +87 -2
- package/src/main/browserCapture.cjs +21 -7
- package/src/main/chatRunner.cjs +5 -1
- package/src/main/health.cjs +114 -3
- package/src/main/hives.cjs +2 -1
- package/src/main/ipcSchemas.cjs +33 -4
- package/src/main/lib/kebabCase.cjs +12 -0
- package/src/main/lib/memorySlug.cjs +10 -0
- package/src/main/lib/prdCreate.cjs +73 -0
- package/src/main/memoryAggregate.cjs +1 -1
- package/src/main/memoryTool.cjs +2 -1
- package/src/main/pty.cjs +2 -1
- package/src/main/runVerify.cjs +119 -6
- package/src/main/scheduler/prdParser.cjs +88 -0
- package/src/main/scheduler.cjs +176 -9
- package/src/main/templates/PRD_AUTHORING.md +126 -0
- package/src/main/usage.cjs +4 -0
- package/src/main/webRemote.cjs +13 -18
- package/src/preload/api.d.ts +1 -0
- package/dist/assets/index-B39Qiq0n.css +0 -32
- package/dist/assets/index-DRSHENEg.js +0 -3559
package/src/main/health.cjs
CHANGED
|
@@ -9,10 +9,27 @@ const fsp = require('node:fs/promises');
|
|
|
9
9
|
const path = require('node:path');
|
|
10
10
|
const os = require('node:os');
|
|
11
11
|
const { execFileSync } = require('node:child_process');
|
|
12
|
+
const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
|
|
12
13
|
|
|
13
14
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
14
15
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
15
16
|
|
|
17
|
+
// tickQueue() only fires (and updates lastRunAt) when a batch is actually
|
|
18
|
+
// spawned — it does not tick on a fixed cadence — but the poll loop that
|
|
19
|
+
// *invokes* tickQueue backs off starting from POLL_INTERVAL_MS (scheduler.cjs
|
|
20
|
+
// pollLoop). 3x gives the poll loop three chances to notice free capacity +
|
|
21
|
+
// pending work before we call it a stall, absorbing normal jitter (backoff,
|
|
22
|
+
// memory-gate deferrals, boot warmup) without waiting so long that a real
|
|
23
|
+
// outage goes unnoticed for hours (the 2026-07-14 incident sat stalled for
|
|
24
|
+
// 16.5h before anything asserted on it).
|
|
25
|
+
const TICK_STALL_MULTIPLIER = 3;
|
|
26
|
+
const TICK_STALL_THRESHOLD_MS = TICK_STALL_MULTIPLIER * POLL_INTERVAL_MS;
|
|
27
|
+
// A heartbeat line older than this is treated as "the app isn't running /
|
|
28
|
+
// we can't observe live utilization" rather than "utilization is low" —
|
|
29
|
+
// scheduler-heartbeat.log is only appended to while Electron is running, so
|
|
30
|
+
// a stale line here is silent-on-purpose, not evidence of anything.
|
|
31
|
+
const HEARTBEAT_STALE_MS = 5 * 60_000;
|
|
32
|
+
|
|
16
33
|
function runCheck(cmd, cwd = PROJECT_ROOT) {
|
|
17
34
|
try {
|
|
18
35
|
execFileSync('bash', ['-c', cmd], {
|
|
@@ -26,6 +43,80 @@ function runCheck(cmd, cwd = PROJECT_ROOT) {
|
|
|
26
43
|
}
|
|
27
44
|
}
|
|
28
45
|
|
|
46
|
+
// Reads the last line of scheduler-heartbeat.log, if fresh enough to trust.
|
|
47
|
+
// Returns null when the file is missing, empty, unparseable, or stale — all
|
|
48
|
+
// of which mean "can't observe live utilization right now", not "utilization
|
|
49
|
+
// is low".
|
|
50
|
+
function readFreshHeartbeat(heartbeatPath) {
|
|
51
|
+
let lines;
|
|
52
|
+
try {
|
|
53
|
+
lines = fs.readFileSync(heartbeatPath, 'utf8').split('\n').filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (lines.length === 0) return null;
|
|
58
|
+
let entry;
|
|
59
|
+
try {
|
|
60
|
+
entry = JSON.parse(lines[lines.length - 1]);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (typeof entry.ts !== 'number' || Date.now() - entry.ts > HEARTBEAT_STALE_MS) return null;
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Evaluates whether the scheduler tick looks stalled: pending work exists,
|
|
69
|
+
// there's free capacity to run it, and nothing about the queue's own state
|
|
70
|
+
// explains why it hasn't. Kept as a pure function of (queueState, heartbeat,
|
|
71
|
+
// now) so it's testable without touching the filesystem.
|
|
72
|
+
function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
73
|
+
const jobs = queueState.jobs || [];
|
|
74
|
+
const pending = jobs.filter((j) => j.status === 'pending');
|
|
75
|
+
const running = runningCount ?? jobs.filter((j) => j.status === 'running').length;
|
|
76
|
+
const config = queueState.config || {};
|
|
77
|
+
const concurrencyCap = config.concurrencyCap ?? Infinity;
|
|
78
|
+
|
|
79
|
+
if (pending.length === 0) return { stalled: false, reason: 'no-pending-jobs' };
|
|
80
|
+
if (queueState.paused) return { stalled: false, reason: 'paused' };
|
|
81
|
+
if (config.enabled === false) return { stalled: false, reason: 'disabled' };
|
|
82
|
+
if (running >= concurrencyCap) return { stalled: false, reason: 'at-capacity' };
|
|
83
|
+
|
|
84
|
+
const lastRunAt = queueState.lastRunAt ? Date.parse(queueState.lastRunAt) : null;
|
|
85
|
+
// No lastRunAt at all (fresh install, never ticked) — nothing to measure
|
|
86
|
+
// staleness against yet; don't manufacture a false positive.
|
|
87
|
+
if (lastRunAt == null || Number.isNaN(lastRunAt)) {
|
|
88
|
+
return { stalled: false, reason: 'no-lastRunAt', caveat: true };
|
|
89
|
+
}
|
|
90
|
+
const tickAgeMs = now - lastRunAt;
|
|
91
|
+
if (tickAgeMs <= TICK_STALL_THRESHOLD_MS) return { stalled: false, reason: 'recent-tick' };
|
|
92
|
+
|
|
93
|
+
// Candidate stall. The 'when-available' firePolicy legitimately holds
|
|
94
|
+
// pending jobs when billing utilization is at/above utilizationThreshold —
|
|
95
|
+
// rule that out before calling it a stall.
|
|
96
|
+
if (config.firePolicy === 'when-available' && typeof config.utilizationThreshold === 'number') {
|
|
97
|
+
if (!heartbeat) {
|
|
98
|
+
return {
|
|
99
|
+
stalled: false,
|
|
100
|
+
caveat: true,
|
|
101
|
+
reason: 'cannot-verify-utilization',
|
|
102
|
+
tickAgeMs,
|
|
103
|
+
oldestPendingSlug: pending[0]?.slug,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (typeof heartbeat.utilization === 'number' && heartbeat.utilization >= config.utilizationThreshold) {
|
|
107
|
+
return { stalled: false, reason: 'utilization-at-threshold', utilization: heartbeat.utilization };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
stalled: true,
|
|
113
|
+
reason: 'stalled',
|
|
114
|
+
tickAgeMs,
|
|
115
|
+
oldestPendingSlug: pending[0]?.slug,
|
|
116
|
+
pendingCount: pending.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
29
120
|
async function check() {
|
|
30
121
|
const start = Date.now();
|
|
31
122
|
const status = {
|
|
@@ -106,13 +197,33 @@ async function check() {
|
|
|
106
197
|
const failedCount = Object.values(queueState.jobs || {}).filter(
|
|
107
198
|
(j) => j.status === 'failed'
|
|
108
199
|
).length;
|
|
200
|
+
const heartbeatPath = path.join(
|
|
201
|
+
os.homedir(),
|
|
202
|
+
'.claude/session-manager/scheduler-heartbeat.log'
|
|
203
|
+
);
|
|
204
|
+
const heartbeat = readFreshHeartbeat(heartbeatPath);
|
|
205
|
+
const liveness = evaluateTickLiveness(queueState, heartbeat, Date.now(), runningCount);
|
|
109
206
|
status.components.scheduler_queue = {
|
|
110
|
-
ok:
|
|
207
|
+
ok: !liveness.stalled,
|
|
111
208
|
path: queuePath,
|
|
112
209
|
jobs: Object.keys(queueState.jobs || {}).length,
|
|
113
210
|
running: runningCount,
|
|
114
211
|
failed: failedCount,
|
|
212
|
+
tickLiveness: liveness.reason,
|
|
115
213
|
};
|
|
214
|
+
if (liveness.stalled) {
|
|
215
|
+
const ageMin = Math.round(liveness.tickAgeMs / 60_000);
|
|
216
|
+
status.components.scheduler_queue.stalledJob = liveness.oldestPendingSlug;
|
|
217
|
+
status.components.scheduler_queue.tickAgeMs = liveness.tickAgeMs;
|
|
218
|
+
status.issues.push(
|
|
219
|
+
`Scheduler tick appears stalled: "${liveness.oldestPendingSlug}" (and ${liveness.pendingCount - 1} other pending job(s)) has been waiting ~${ageMin}m with free capacity and no tick progress`
|
|
220
|
+
);
|
|
221
|
+
} else if (liveness.caveat) {
|
|
222
|
+
status.components.scheduler_queue.caveat =
|
|
223
|
+
liveness.reason === 'cannot-verify-utilization'
|
|
224
|
+
? `Tick hasn't advanced in a while but scheduler-heartbeat.log is missing/stale, so current billing utilization can't be checked — cannot rule out a legitimate when-available hold`
|
|
225
|
+
: 'No lastRunAt recorded yet — cannot assess tick liveness';
|
|
226
|
+
}
|
|
116
227
|
} catch (e) {
|
|
117
228
|
if (e.code !== 'ENOENT') {
|
|
118
229
|
status.issues.push(`Scheduler queue unreadable: ${e.message}`);
|
|
@@ -197,7 +308,7 @@ async function check() {
|
|
|
197
308
|
// Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
|
|
198
309
|
// Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
|
|
199
310
|
// Informational: app log age (shows if app is running, but not blocking).
|
|
200
|
-
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure'];
|
|
311
|
+
const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue'];
|
|
201
312
|
status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
|
|
202
313
|
|
|
203
314
|
status.elapsedMs = Date.now() - start;
|
|
@@ -213,4 +324,4 @@ if (require.main === module) {
|
|
|
213
324
|
})();
|
|
214
325
|
}
|
|
215
326
|
|
|
216
|
-
module.exports = { check };
|
|
327
|
+
module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
|
package/src/main/hives.cjs
CHANGED
|
@@ -33,6 +33,7 @@ const path = require('node:path');
|
|
|
33
33
|
const os = require('node:os');
|
|
34
34
|
const { z } = require('zod');
|
|
35
35
|
const config = require('./config.cjs');
|
|
36
|
+
const { kebabCase: sharedKebabCase } = require('./lib/kebabCase.cjs');
|
|
36
37
|
|
|
37
38
|
// ──────────────────────────────────────────── caps
|
|
38
39
|
const SLUG_RE = /^[a-z0-9-_]{1,64}$/;
|
|
@@ -89,7 +90,7 @@ async function ensureRoot() {
|
|
|
89
90
|
|
|
90
91
|
// ──────────────────────────────────────────── helpers
|
|
91
92
|
function kebabCase(s) {
|
|
92
|
-
return s
|
|
93
|
+
return sharedKebabCase(s, { maxLen: 64, fallback: 'agent' });
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
// ──────────────────────────────────────────── legacy migration
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -228,6 +228,33 @@ const scheduleWritePrd = z.object({
|
|
|
228
228
|
),
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
// PRD create (admin API — scheduler_create_prd, PRD 549). This slug is
|
|
232
|
+
// deliberately STRICTER than SCHEDULE_SLUG_RE: it becomes a brand-new
|
|
233
|
+
// filename segment written to disk by the create route, not a lookup key
|
|
234
|
+
// for an existing file, so it excludes '.' and '_' too (matches
|
|
235
|
+
// pluginInstall.cjs's slug precedent, minus '/' — this slug must never
|
|
236
|
+
// introduce a subdirectory).
|
|
237
|
+
const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
238
|
+
// title/cwd become frontmatter VALUES (`title: ${title}`) in prdCreate.cjs's
|
|
239
|
+
// hand-rolled `key: value` serializer, which — unlike a real YAML
|
|
240
|
+
// library — has no escaping. A title/cwd containing '\n' could inject a
|
|
241
|
+
// bare `\n---\n` that terminates the frontmatter block early (per
|
|
242
|
+
// prdFrontmatter.cjs's `indexOf('\n---')` parse) and smuggle extra
|
|
243
|
+
// frontmatter-shaped lines into the body. Block newlines at the boundary
|
|
244
|
+
// instead of trying to escape them later.
|
|
245
|
+
const NO_NEWLINE_RE = /^[^\r\n]*$/;
|
|
246
|
+
const schedulerCreatePrd = z.object({
|
|
247
|
+
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
248
|
+
cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines'),
|
|
249
|
+
estimateMinutes: z.number().int().min(1).max(100000),
|
|
250
|
+
goal: z.string().min(1).max(20000),
|
|
251
|
+
acceptanceCriteria: z.array(z.string().min(1).max(2000)).min(1).max(100),
|
|
252
|
+
implementationNotes: z.string().min(1).max(20000),
|
|
253
|
+
outOfScope: z.array(z.string().min(1).max(2000)).max(100).optional(),
|
|
254
|
+
slug: z.string().min(1).max(60).regex(PRD_CREATE_SLUG_RE).optional(),
|
|
255
|
+
parallelGroup: z.number().int().min(1).max(999999).optional(),
|
|
256
|
+
});
|
|
257
|
+
|
|
231
258
|
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
232
259
|
const scheduleArchivePrd = z.object({
|
|
233
260
|
slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
|
|
@@ -290,11 +317,11 @@ const setConfigSchema = z.object({
|
|
|
290
317
|
}).strict();
|
|
291
318
|
|
|
292
319
|
// ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
|
|
293
|
-
// Workspace-scoped markdown store at ~/.claude/
|
|
294
|
-
// Slug regex
|
|
295
|
-
// encodeWorkspace() output (alphanumeric + dash) plus 'default'.
|
|
320
|
+
// Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
|
|
321
|
+
// Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
|
|
322
|
+
// workspace regex matches encodeWorkspace() output (alphanumeric + dash) plus 'default'.
|
|
296
323
|
const MEMORY_WORKSPACE_RE = /^[a-zA-Z0-9-_]{1,256}$/;
|
|
297
|
-
const MEMORY_SLUG_RE =
|
|
324
|
+
const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
|
|
298
325
|
// 1 MiB hard cap — matches MAX_FILE_BYTES in memoryTool.cjs.
|
|
299
326
|
const MEMORY_MAX_BYTES = 1024 * 1024;
|
|
300
327
|
|
|
@@ -613,6 +640,7 @@ module.exports = {
|
|
|
613
640
|
// direct test()/match() containment checks alongside the zod parses.
|
|
614
641
|
SCHEDULE_SLUG_RE,
|
|
615
642
|
SCHEDULE_RUN_ID_RE,
|
|
643
|
+
PRD_CREATE_SLUG_RE,
|
|
616
644
|
READ_COMMANDS,
|
|
617
645
|
SAS_GATED_READS,
|
|
618
646
|
MUTATE_COMMANDS,
|
|
@@ -651,6 +679,7 @@ module.exports = {
|
|
|
651
679
|
scheduleSlug,
|
|
652
680
|
scheduleReadLog,
|
|
653
681
|
scheduleWritePrd,
|
|
682
|
+
schedulerCreatePrd,
|
|
654
683
|
scheduleArchivePrd,
|
|
655
684
|
scheduleRetagPrd,
|
|
656
685
|
setConfigSchema,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kebabCase.cjs — shared lowercase/kebab-case slugify. Extracted from
|
|
3
|
+
* hives.cjs (private `kebabCase` helper) so prdCreate.cjs doesn't fork a
|
|
4
|
+
* second, subtly-different copy (API reuse: one concept, one implementation).
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
function kebabCase(s, { maxLen = 64, fallback = '' } = {}) {
|
|
9
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, maxLen) || fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { kebabCase };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared workspace-memory filename regex. memoryTool.cjs (CRUD), memoryAggregate.cjs
|
|
5
|
+
* (clustering), and ipcSchemas.cjs (IPC validation) all gate on the same slug shape —
|
|
6
|
+
* single source of truth so the three don't drift.
|
|
7
|
+
*/
|
|
8
|
+
const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
|
|
9
|
+
|
|
10
|
+
module.exports = { MEMORY_SLUG_RE };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdCreate.cjs — PRD-body builder for the create-prd admin route (PRD 549,
|
|
3
|
+
* gh-issue-6). Pure functions only: no filesystem writes, no NN allocation,
|
|
4
|
+
* no HTTP. adminServer.cjs owns orchestration (auth, cwd validation via
|
|
5
|
+
* config.cjs's validatePath, NN allocation via the injected remote,
|
|
6
|
+
* writing via remote.writePrd -> config.cjs's writeTextAtomic) so this
|
|
7
|
+
* module stays trivially unit-testable.
|
|
8
|
+
*
|
|
9
|
+
* Standards are read fresh from disk on every call (no in-process caching)
|
|
10
|
+
* so a live edit to standards.md is picked up by the next create-prd call
|
|
11
|
+
* without an app restart — same one-concept-one-implementation reasoning
|
|
12
|
+
* that keeps the /develop skill re-reading it fresh per PRD (see SKILL.md).
|
|
13
|
+
*/
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fsp = require('node:fs/promises');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const { PRD_CREATE_SLUG_RE } = require('../ipcSchemas.cjs');
|
|
19
|
+
const { kebabCase } = require('./kebabCase.cjs');
|
|
20
|
+
|
|
21
|
+
const STANDARDS_PATH = path.join(
|
|
22
|
+
__dirname, '..', '..', '..',
|
|
23
|
+
'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
async function readStandards() {
|
|
27
|
+
return fsp.readFile(STANDARDS_PATH, 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Lowercase, kebab-case, strip anything outside [a-z0-9-], cap at 60 chars. */
|
|
31
|
+
function deriveSlugFromTitle(title) {
|
|
32
|
+
return kebabCase(String(title), { maxLen: 60 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the full PRD markdown body (frontmatter + required sections +
|
|
37
|
+
* verbatim engineering standards), matching the structure `/develop`'s
|
|
38
|
+
* SKILL.md documents: frontmatter, then Goal / Acceptance criteria /
|
|
39
|
+
* Implementation notes / Out of scope / Engineering standards, in order.
|
|
40
|
+
*/
|
|
41
|
+
function buildPrdBody(input, standardsText) {
|
|
42
|
+
const {
|
|
43
|
+
title, cwd, estimateMinutes, goal, acceptanceCriteria,
|
|
44
|
+
implementationNotes, outOfScope,
|
|
45
|
+
} = input;
|
|
46
|
+
|
|
47
|
+
// No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
|
|
48
|
+
// filename prefix is the single source of truth for grouping; adding a
|
|
49
|
+
// second one here would let the two drift out of sync.
|
|
50
|
+
const fmLines = ['---', `title: ${title}`, `cwd: ${cwd}`, `estimateMinutes: ${estimateMinutes}`, '---', ''];
|
|
51
|
+
|
|
52
|
+
const acLines = acceptanceCriteria.map((line) => `- [ ] ${line}`).join('\n');
|
|
53
|
+
const oosSource = outOfScope && outOfScope.length ? outOfScope : ['(none)'];
|
|
54
|
+
const oosLines = oosSource.map((line) => `- ${line}`).join('\n');
|
|
55
|
+
|
|
56
|
+
const bodyLines = [
|
|
57
|
+
'# Goal', '', goal, '',
|
|
58
|
+
'# Acceptance criteria', '', acLines, '',
|
|
59
|
+
'# Implementation notes', '', implementationNotes, '',
|
|
60
|
+
'# Out of scope', '', oosLines, '',
|
|
61
|
+
'## Engineering standards', '', standardsText.trimEnd(), '',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
PRD_CREATE_SLUG_RE,
|
|
69
|
+
STANDARDS_PATH,
|
|
70
|
+
readStandards,
|
|
71
|
+
deriveSlugFromTitle,
|
|
72
|
+
buildPrdBody,
|
|
73
|
+
};
|
|
@@ -27,10 +27,10 @@ const { encodeCwd } = require('./lib/encodeCwd.cjs');
|
|
|
27
27
|
const { extractJson } = require('./lib/extractJson.cjs');
|
|
28
28
|
const { writeJson } = require('./config.cjs');
|
|
29
29
|
const config = require('./config.cjs');
|
|
30
|
+
const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
|
|
30
31
|
|
|
31
32
|
const HOME = os.homedir();
|
|
32
33
|
const CLUSTERS_DIR = path.join(HOME, '.claude', 'session-manager', 'memory-clusters');
|
|
33
|
-
const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
|
|
34
34
|
|
|
35
35
|
function memoryDir(workspace) {
|
|
36
36
|
return path.join(HOME, '.claude', 'projects', workspace, 'memory');
|
package/src/main/memoryTool.cjs
CHANGED
|
@@ -30,9 +30,10 @@ const path = require('node:path');
|
|
|
30
30
|
const os = require('node:os');
|
|
31
31
|
const config = require('./config.cjs');
|
|
32
32
|
|
|
33
|
+
const { MEMORY_SLUG_RE: SLUG_RE } = require('./lib/memorySlug.cjs');
|
|
34
|
+
|
|
33
35
|
const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
|
|
34
36
|
const MAX_ENTRIES = 1000;
|
|
35
|
-
const SLUG_RE = /^[a-z0-9-_]+\.md$/;
|
|
36
37
|
|
|
37
38
|
const { encodeCwd: encodeWorkspace } = require('./lib/encodeCwd.cjs');
|
|
38
39
|
|
package/src/main/pty.cjs
CHANGED
|
@@ -245,8 +245,9 @@ function registerPtyHandlers() {
|
|
|
245
245
|
ipcMain.on('pty:resize', (_e, payload) => { try { manager.resize(s.ptyResize.parse(payload)); } catch { /* ignore */ } });
|
|
246
246
|
ipcMain.on('pty:kill', (_e, tabId) => {
|
|
247
247
|
if (typeof tabId !== 'string') return;
|
|
248
|
+
// manager.kill() already drops superagent run state for tabId — see kill()
|
|
249
|
+
// above. Do not duplicate that call here.
|
|
248
250
|
manager.kill(tabId);
|
|
249
|
-
try { require('./superagent.cjs').dropTab(tabId); } catch { /* superagent module not initialized (e2e) */ }
|
|
250
251
|
});
|
|
251
252
|
}
|
|
252
253
|
|
package/src/main/runVerify.cjs
CHANGED
|
@@ -36,9 +36,13 @@
|
|
|
36
36
|
|
|
37
37
|
const fs = require('node:fs');
|
|
38
38
|
const path = require('node:path');
|
|
39
|
+
const { execFileSync } = require('node:child_process');
|
|
39
40
|
|
|
40
41
|
const VERDICTS_SCHEMA_VERSION = 1;
|
|
41
42
|
|
|
43
|
+
// Bound on the `gh pr view` postcondition check below (§ merge-main exemption).
|
|
44
|
+
const GH_CHECK_TIMEOUT_MS = 15_000;
|
|
45
|
+
|
|
42
46
|
// ─── content pattern detectors ───────────────────────────────────────────────
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -445,6 +449,69 @@ function scanSentinel(resultEvent, events) {
|
|
|
445
449
|
return null;
|
|
446
450
|
}
|
|
447
451
|
|
|
452
|
+
// ─── merge-main postcondition exemption ──────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* True for the `NN-prXXX-merge-main` / `NN-fix-prXXX-merge-main` slug
|
|
456
|
+
* convention already used dozens of times in this queue (merge-current-main
|
|
457
|
+
* PRDs targeting a shared repo's PR branch). Narrow, mechanical string check —
|
|
458
|
+
* this is the only slug shape exempted from the `pass_no_commit` flag below.
|
|
459
|
+
*/
|
|
460
|
+
function isMergeMainSlug(slug) {
|
|
461
|
+
return typeof slug === 'string' && /-merge-main$/.test(slug);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Extract the target PR number for a `-merge-main` PRD. The convention embeds
|
|
466
|
+
* it two ways: in the frontmatter title/body as "PR #<n>" (preferred — the
|
|
467
|
+
* human-authored, unambiguous source), and in the slug itself as "prNNN"
|
|
468
|
+
* (fallback, for PRD text that doesn't spell it out). Returns null if neither
|
|
469
|
+
* source yields a number.
|
|
470
|
+
*/
|
|
471
|
+
function extractMergeMainPrNumber(slug, prdFullText) {
|
|
472
|
+
if (typeof prdFullText === 'string') {
|
|
473
|
+
const m = prdFullText.match(/PR\s*#(\d+)/i);
|
|
474
|
+
if (m) return parseInt(m[1], 10);
|
|
475
|
+
}
|
|
476
|
+
const sm = typeof slug === 'string' ? slug.match(/pr(\d+)-merge-main$/) : null;
|
|
477
|
+
if (sm) return parseInt(sm[1], 10);
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Independently re-check a PR's mergeable state via `gh pr view`. Used only to
|
|
483
|
+
* decide whether a `-merge-main` PRD's unsubstantiated PASS (no commit landed
|
|
484
|
+
* during the run) reflects a target that was already satisfied by an
|
|
485
|
+
* out-of-band actor before this run started — see the 2026-07-18 false-positive
|
|
486
|
+
* feedback item.
|
|
487
|
+
*
|
|
488
|
+
* Bounded (15s) and fully fail-safe: ANY error (gh missing/unauthed, network,
|
|
489
|
+
* timeout, malformed JSON, non-existent PR) resolves `{ ok: false }` rather
|
|
490
|
+
* than throwing, so a failure here only ever falls back to today's behavior —
|
|
491
|
+
* it can never turn a real failure into a false "verified".
|
|
492
|
+
*
|
|
493
|
+
* `execImpl` is injectable (defaults to `child_process.execFileSync`) so unit
|
|
494
|
+
* tests can stub the subprocess call without shelling out to a real `gh`.
|
|
495
|
+
*
|
|
496
|
+
* @returns {{ ok: boolean, data?: { mergeable: string, mergeStateStatus: string }, error?: string }}
|
|
497
|
+
*/
|
|
498
|
+
function checkMergeablePr({ cwd, prNumber, timeoutMs = GH_CHECK_TIMEOUT_MS, execImpl = execFileSync }) {
|
|
499
|
+
try {
|
|
500
|
+
const out = execImpl(
|
|
501
|
+
'gh',
|
|
502
|
+
['pr', 'view', String(prNumber), '--json', 'mergeable,mergeStateStatus'],
|
|
503
|
+
{ cwd, timeout: timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
504
|
+
);
|
|
505
|
+
const data = JSON.parse(out);
|
|
506
|
+
if (data && data.mergeable === 'MERGEABLE' && data.mergeStateStatus !== 'CONFLICTING') {
|
|
507
|
+
return { ok: true, data };
|
|
508
|
+
}
|
|
509
|
+
return { ok: false, data };
|
|
510
|
+
} catch (e) {
|
|
511
|
+
return { ok: false, error: e?.message ?? String(e) };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
448
515
|
// ─── main verifier ────────────────────────────────────────────────────────────
|
|
449
516
|
|
|
450
517
|
/**
|
|
@@ -467,9 +534,13 @@ function scanSentinel(resultEvent, events) {
|
|
|
467
534
|
* even without a PASS sentinel. Only
|
|
468
535
|
* set by the boot reverify self-heal
|
|
469
536
|
* pass for pre-sentinel legacy runs.
|
|
537
|
+
* @param {Function} [params.ghExecImpl] Test-only override for the `gh pr view`
|
|
538
|
+
* subprocess call used by the -merge-main
|
|
539
|
+
* postcondition exemption. Defaults to
|
|
540
|
+
* child_process.execFileSync.
|
|
470
541
|
* @returns {Promise<{verdict:string, reason:string, downgradeTo:string|null}>}
|
|
471
542
|
*/
|
|
472
|
-
async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false }) {
|
|
543
|
+
async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false, ghExecImpl }) {
|
|
473
544
|
const { slug } = queueEntry;
|
|
474
545
|
const logPath = path.join(runDir, `${slug}.log`);
|
|
475
546
|
const verdictsPath = path.join(runDir, `${slug}.verdicts.json`);
|
|
@@ -491,8 +562,10 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
|
|
|
491
562
|
try {
|
|
492
563
|
// ── Read PRD body (strip frontmatter) ──────────────────────────────────
|
|
493
564
|
let prdBody = '';
|
|
565
|
+
let prdFullText = '';
|
|
494
566
|
try {
|
|
495
567
|
const prdText = fs.readFileSync(prdPath, 'utf8');
|
|
568
|
+
prdFullText = prdText;
|
|
496
569
|
if (prdText.startsWith('---\n')) {
|
|
497
570
|
const end = prdText.indexOf('\n---', 4);
|
|
498
571
|
prdBody = end !== -1 ? prdText.slice(end + 4).trim() : prdText;
|
|
@@ -650,11 +723,48 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
|
|
|
650
723
|
// signal and stays covered by this check.
|
|
651
724
|
const isFixPlanJob = /^\d+-fix-/.test(queueEntry?.slug || '');
|
|
652
725
|
if (sentinel === 'pass' && !committedDuringRun && !isFixPlanJob) {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
726
|
+
// EXEMPTION: `-merge-main` PRDs (see isMergeMainSlug) can genuinely and
|
|
727
|
+
// correctly find nothing left to do when an out-of-band actor (a human,
|
|
728
|
+
// another agent, a sibling scheduler job) already merged/updated the
|
|
729
|
+
// target PR branch before this run started. Before flagging, independently
|
|
730
|
+
// re-check the PR's real mergeable state via `gh` — if it confirms the
|
|
731
|
+
// target is already clean, this is not a false PASS, it's a correct one.
|
|
732
|
+
// Fails safe: any `gh` error falls straight through to the pass_no_commit
|
|
733
|
+
// flag below, exactly as before this exemption existed.
|
|
734
|
+
let mergeMainVerified = false;
|
|
735
|
+
if (isMergeMainSlug(slug)) {
|
|
736
|
+
const prNumber = extractMergeMainPrNumber(slug, prdFullText);
|
|
737
|
+
if (prNumber != null) {
|
|
738
|
+
const ghResult = checkMergeablePr({
|
|
739
|
+
cwd: queueEntry?.cwd,
|
|
740
|
+
prNumber,
|
|
741
|
+
...(ghExecImpl ? { execImpl: ghExecImpl } : {}),
|
|
742
|
+
});
|
|
743
|
+
if (ghResult.ok) {
|
|
744
|
+
mergeMainVerified = true;
|
|
745
|
+
return conclude(
|
|
746
|
+
'pass_no_commit_target_verified',
|
|
747
|
+
`SCHEDULER_VERDICT: PASS with no commit, but independently verified PR #${prNumber} is mergeable `
|
|
748
|
+
+ `(mergeStateStatus: ${ghResult.data.mergeStateStatus}) — target was already satisfied by an `
|
|
749
|
+
+ 'out-of-band actor before this run started',
|
|
750
|
+
null,
|
|
751
|
+
{
|
|
752
|
+
...(annotations.length ? { annotations } : {}),
|
|
753
|
+
sentinel,
|
|
754
|
+
verifiedPrNumber: prNumber,
|
|
755
|
+
ghMergeState: ghResult.data,
|
|
756
|
+
},
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (!mergeMainVerified) {
|
|
762
|
+
issues.push({
|
|
763
|
+
verdict: 'pass_no_commit',
|
|
764
|
+
reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
|
|
765
|
+
priority: 1,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
658
768
|
}
|
|
659
769
|
|
|
660
770
|
if (issues.length === 0) {
|
|
@@ -726,4 +836,7 @@ module.exports = {
|
|
|
726
836
|
checkDeps,
|
|
727
837
|
parseLog,
|
|
728
838
|
scanSentinel,
|
|
839
|
+
isMergeMainSlug,
|
|
840
|
+
extractMergeMainPrNumber,
|
|
841
|
+
checkMergeablePr,
|
|
729
842
|
};
|
|
@@ -141,10 +141,98 @@ function _resetCache() {
|
|
|
141
141
|
prdFileCache.clear();
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// ────────────────────────────────────────────── group allocation
|
|
145
|
+
//
|
|
146
|
+
// `NN` (the filename prefix, parsed above by groupFromName) is both the
|
|
147
|
+
// authoring counter AND the parallel-group key the scheduler groups by.
|
|
148
|
+
// Two authors computing "max NN in prds/" concurrently can allocate the
|
|
149
|
+
// same NN for unrelated PRDs, which silently merges two projects' work
|
|
150
|
+
// into one parallel group (incident 2026-07-14/2026-07-15, PRDs 05-11 vs
|
|
151
|
+
// 06-09, then 545 collided again). allocateParallelGroup() closes that
|
|
152
|
+
// race with an O(n) full-directory scan (not a caller-narrowed glob — see
|
|
153
|
+
// PRD_AUTHORING.md's `'^10[0-9]'` cautionary example) plus an O_EXCL
|
|
154
|
+
// reservation file, so two concurrent callers can never walk away with
|
|
155
|
+
// the same new NN.
|
|
156
|
+
//
|
|
157
|
+
// A reservation is a zero-byte `.reserved-<NN>` file created with the
|
|
158
|
+
// 'wx' (O_CREAT|O_EXCL) flag — the OS guarantees only one of two racing
|
|
159
|
+
// `open()` calls for the same path succeeds. Reservations are counted
|
|
160
|
+
// alongside real `NN-slug.md` files when computing the next max, so a
|
|
161
|
+
// crash between "reserve NN" and "author NN-slug.md" just permanently
|
|
162
|
+
// retires that one NN rather than wedging future allocations. This keeps
|
|
163
|
+
// the filesystem itself as the single source of truth (no separate
|
|
164
|
+
// `.next-nn`/registry file that could drift out of sync with prds/).
|
|
165
|
+
//
|
|
166
|
+
// Callers who want an EXISTING group (deliberate parallel siblings, e.g.
|
|
167
|
+
// three PRDs sharing 545) never call this — they just write the shared
|
|
168
|
+
// `NN-` prefix directly, same as today. This function only mints NEW
|
|
169
|
+
// group numbers.
|
|
170
|
+
|
|
171
|
+
const RESERVATION_RE = /^\.reserved-(\d+)$/;
|
|
172
|
+
const GROUP_PREFIX_RE = /^(\d+)-/;
|
|
173
|
+
const MAX_RESERVE_ATTEMPTS = 1000;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Highest `NN` currently in use under prdsDir, across real `NN-slug.md`
|
|
177
|
+
* PRD files (any digit count, unpadded or not — matches groupFromName
|
|
178
|
+
* above) AND in-flight `.reserved-NN` markers. Full directory scan, no
|
|
179
|
+
* narrowed glob. Returns 0 if the directory is empty/missing.
|
|
180
|
+
*/
|
|
181
|
+
async function maxParallelGroupInUse(prdsDir) {
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = await fsp.readdir(prdsDir);
|
|
185
|
+
} catch {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
let max = 0;
|
|
189
|
+
for (const name of entries) {
|
|
190
|
+
if (name.startsWith('.')) {
|
|
191
|
+
const rm = name.match(RESERVATION_RE);
|
|
192
|
+
if (rm) max = Math.max(max, Number(rm[1]));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!name.endsWith('.md')) continue;
|
|
196
|
+
const m = name.match(GROUP_PREFIX_RE);
|
|
197
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
198
|
+
}
|
|
199
|
+
return max;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Atomically allocate a brand-new parallel-group number under prdsDir.
|
|
204
|
+
* Returns the reserved NN. Reservation survives a crash mid-allocation
|
|
205
|
+
* (the `.reserved-NN` marker just sits there forever, retiring that one
|
|
206
|
+
* number) and never wedges future callers, since each attempt only needs
|
|
207
|
+
* the marker for its OWN candidate to not already exist.
|
|
208
|
+
*/
|
|
209
|
+
async function allocateParallelGroup(prdsDir) {
|
|
210
|
+
await fsp.mkdir(prdsDir, { recursive: true });
|
|
211
|
+
let candidate = (await maxParallelGroupInUse(prdsDir)) + 1;
|
|
212
|
+
for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
|
|
213
|
+
const markerPath = path.join(prdsDir, `.reserved-${candidate}`);
|
|
214
|
+
let fh;
|
|
215
|
+
try {
|
|
216
|
+
fh = await fsp.open(markerPath, 'wx');
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (e && e.code === 'EEXIST') {
|
|
219
|
+
candidate += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
await fh.close();
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`allocateParallelGroup: exhausted ${MAX_RESERVE_ATTEMPTS} reservation attempts starting at ${candidate}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
144
230
|
module.exports = {
|
|
145
231
|
parsePrdRaw,
|
|
146
232
|
parsePrd,
|
|
147
233
|
listPrdFiles,
|
|
234
|
+
allocateParallelGroup,
|
|
235
|
+
maxParallelGroupInUse,
|
|
148
236
|
PRD_READ_MAX_BYTES,
|
|
149
237
|
_resetCache,
|
|
150
238
|
};
|