claude-code-session-manager 0.119.1 → 0.121.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.
Files changed (76) hide show
  1. package/dist/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-FO2ei2wB.js} +1 -1
  2. package/dist/assets/{Button-DZX5guWb.js → Button-CeIOXUPU.js} +1 -1
  3. package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-D0p__Hil.js} +1 -1
  4. package/dist/assets/{History-D58whvn_.js → History-DVqQ5azN.js} +1 -1
  5. package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-DkTnRHyT.js} +1 -1
  6. package/dist/assets/{Library-YqMi-vsA.js → Library-l7KS6pkE.js} +1 -1
  7. package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-DDw8fuRT.js} +1 -1
  8. package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-UUNIxk_3.js} +1 -1
  9. package/dist/assets/{Memory-BODDUBy4.js → Memory-CNYUoQ8C.js} +1 -1
  10. package/dist/assets/{Panel-CeOfUS9B.js → Panel-Bb54soc2.js} +1 -1
  11. package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-D-dNdV3f.js} +1 -1
  12. package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-DhcIXRhQ.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-DEAkDNjA.js} +1 -1
  14. package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-xCEgvTGU.js} +1 -1
  15. package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-BSEs7ecA.js} +1 -1
  16. package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-BIKJIU-C.js} +5 -5
  17. package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-DcaFyAV-.js} +1 -1
  18. package/dist/assets/Settings-vcdWy_Jx.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-CLrJWTPd.js} +1 -1
  20. package/dist/assets/{Skills-BeCMBQ3l.js → Skills-CYM4rBEC.js} +1 -1
  21. package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-BWNIu-iY.js} +1 -1
  22. package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-CYdwHK6m.js} +1 -1
  23. package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-Kt90lHrD.js} +1 -1
  24. package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-D1OXWYOz.js} +1 -1
  25. package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-BeQR3CKh.js} +1 -1
  26. package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-B0qafrRo.js} +1 -1
  27. package/dist/assets/{index-DdEMkmbi.js → index-Bq9uH08R.js} +201 -201
  28. package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
  29. package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-0ZksSAPB.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-C0nh8yfa.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-DPkoBybS.js} +1 -1
  32. package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-BDJMUihy.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +1 -1
  35. package/src/main/build-info.json +4 -4
  36. package/src/main/chatRunner.cjs +54 -14
  37. package/src/main/crashDiagnostics.cjs +18 -13
  38. package/src/main/health.cjs +129 -4
  39. package/src/main/index.cjs +57 -18
  40. package/src/main/ipcSchemas.cjs +18 -4
  41. package/src/main/lib/activeSessions.cjs +51 -34
  42. package/src/main/lib/claude/agentModelResolve.cjs +5 -3
  43. package/src/main/lib/claude/personaToolScope.cjs +68 -0
  44. package/src/main/lib/epic/archivedTranscript.cjs +91 -0
  45. package/src/main/lib/epic/archivedTranscriptMigration.cjs +84 -0
  46. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  47. package/src/main/lib/epic/epicSettle.cjs +21 -5
  48. package/src/main/lib/epic/epicValidationHook.cjs +7 -0
  49. package/src/main/lib/git/depHealth.cjs +161 -0
  50. package/src/main/lib/git/worktreeDeps.cjs +158 -5
  51. package/src/main/lib/machineProfile.cjs +2 -0
  52. package/src/main/lib/mcpToolCatalog.cjs +3 -1
  53. package/src/main/lib/opsRetentionSettings.cjs +183 -0
  54. package/src/main/lib/persona-macro/macroLibrary.cjs +75 -0
  55. package/src/main/lib/prd/prdCreate.cjs +25 -0
  56. package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
  57. package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
  58. package/src/main/lib/process/procGroupRss.cjs +61 -0
  59. package/src/main/lib/remoteTranscriptTail.cjs +69 -2
  60. package/src/main/lib/scheduler/queueHistory.cjs +68 -9
  61. package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
  62. package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
  63. package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
  64. package/src/main/lib/telemetry/auditLog.cjs +51 -6
  65. package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
  66. package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
  67. package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
  68. package/src/main/scheduler/prdParser.cjs +50 -0
  69. package/src/main/scheduler/prdPaths.cjs +61 -16
  70. package/src/main/scheduler/reviewLadder.cjs +24 -1
  71. package/src/main/scheduler.cjs +214 -37
  72. package/src/main/templates/PRD_AUTHORING.md +2 -2
  73. package/src/main/webRemote.cjs +82 -91
  74. package/src/preload/api.d.ts +41 -3
  75. package/src/preload/index.cjs +9 -0
  76. package/dist/assets/Settings-B4LuoRnd.js +0 -4
@@ -7,6 +7,11 @@
7
7
  * state a project's single-writer law needs to arbitrate, and it must
8
8
  * survive even if the project's ops root doesn't exist yet.
9
9
  *
10
+ * Rotation: on the first append in a new calendar month (UTC) the live file is
11
+ * gzipped to audit-log-<YYYY-MM>.jsonl.gz beside it (YYYY-MM = the month of the
12
+ * file's last write) and a fresh live file starts; the newest `auditLogMonths` rolled
13
+ * files are kept. The live file stays plain JSONL, so readers are unaffected.
14
+ *
10
15
  * Best-effort only: a logging failure must never block the mint/create it is
11
16
  * observing (same posture as rcaReport.cjs / dodDrainHook.cjs).
12
17
  *
@@ -17,6 +22,7 @@
17
22
 
18
23
  const fs = require('node:fs');
19
24
  const path = require('node:path');
25
+ const zlib = require('node:zlib');
20
26
 
21
27
  const { auditLogPath } = require('../scheduler/schedulerPaths.cjs');
22
28
 
@@ -36,21 +42,60 @@ function callerContext() {
36
42
  };
37
43
  }
38
44
 
45
+ const DEFAULT_MAX_ROLLED = 6;
46
+
47
+ /** Rolled-file cap from ops-retention.json (one file read per rotation); 6 if unreadable. */
48
+ function maxRolled() {
49
+ try {
50
+ const n = require('../opsRetentionSettings.cjs').read().auditLogMonths;
51
+ return Number.isFinite(n) && n >= 1 ? n : DEFAULT_MAX_ROLLED;
52
+ } catch { return DEFAULT_MAX_ROLLED; }
53
+ }
54
+ const ROLLED_RE = /^audit-log-\d{4}-\d{2}\.jsonl\.gz$/;
55
+
56
+ function monthKey(d) { return d.toISOString().slice(0, 7); }
57
+
58
+ /** Roll the live file if its last write was in an earlier month than `now`; prune old rolls. */
59
+ function rotateIfNewMonth(target, now) {
60
+ let st;
61
+ try { st = fs.statSync(target); } catch { return; }
62
+ if (!st.isFile() || st.size === 0) return;
63
+ const oldMonth = monthKey(st.mtime);
64
+ if (oldMonth === monthKey(now)) return;
65
+ const dir = path.dirname(target);
66
+ const base = path.basename(target, path.extname(target));
67
+ const rolled = path.join(dir, `${base}-${oldMonth}.jsonl.gz`);
68
+ // appendFileSync: gzip members concatenate, so a pre-existing roll for that month is extended.
69
+ fs.appendFileSync(rolled, zlib.gzipSync(fs.readFileSync(target)));
70
+ fs.unlinkSync(target);
71
+ const rolls = fs.readdirSync(dir).filter((f) => ROLLED_RE.test(f)).sort();
72
+ for (const f of rolls.slice(0, Math.max(0, rolls.length - maxRolled()))) {
73
+ try { fs.unlinkSync(path.join(dir, f)); } catch { /* best-effort */ }
74
+ }
75
+ }
76
+
39
77
  /**
40
- * appendAuditEvent(kind, fields) — append one JSONL record. `kind` is a short
78
+ * appendAuditEvent(kind, fields, opts?) — append one JSONL record. `kind` is a short
41
79
  * label ('epic_mint' | 'prd_create' | ...); `fields` is event-specific detail
42
- * (cwd, epicId, status, title, slug, etc). Never throws.
80
+ * (cwd, epicId, status, title, slug, etc). `opts.now` (Date) / `opts.target` are
81
+ * test seams. Never throws; a rotation failure never drops the append.
43
82
  */
44
- function appendAuditEvent(kind, fields = {}) {
83
+ function appendAuditEvent(kind, fields = {}, opts = {}) {
45
84
  try {
85
+ const now = opts.now ?? new Date();
46
86
  const record = {
47
- at: new Date().toISOString(),
87
+ at: now.toISOString(),
48
88
  kind,
49
89
  ...fields,
50
90
  caller: callerContext(),
51
91
  };
52
- const target = auditLogPath();
92
+ const target = opts.target ?? auditLogPath();
53
93
  fs.mkdirSync(path.dirname(target), { recursive: true });
94
+ try {
95
+ rotateIfNewMonth(target, now);
96
+ } catch (e) {
97
+ console.error('[auditLog] rotation failed', e?.message ?? String(e));
98
+ }
54
99
  fs.appendFileSync(target, `${JSON.stringify(record)}\n`);
55
100
  } catch (e) {
56
101
  console.error('[auditLog] failed to append audit event', e?.message ?? String(e));
@@ -60,7 +105,7 @@ function appendAuditEvent(kind, fields = {}) {
60
105
  /**
61
106
  * readTail(maxBytes, target?) → string[] — the complete JSONL lines within the
62
107
  * last `maxBytes` of the log, oldest first. O(maxBytes), never O(file): the
63
- * audit log is a never-rotated provenance record and grows without bound. A
108
+ * live log is a provenance record that rolls monthly but can still be large. A
64
109
  * line cut by the window's start is dropped. Missing/unreadable file → [].
65
110
  */
66
111
  function readTail(maxBytes, target = auditLogPath()) {
@@ -34,4 +34,52 @@ function reportCrash({ reason, meta, deps } = {}) {
34
34
  }
35
35
  }
36
36
 
37
- module.exports = { reportCrash };
37
+ // A 10 s+ main-loop stall means the main thread was wedged (the 2026-10-19
38
+ // hang death showed 23 s stalls at 972 MB, far below any memory pressure), so
39
+ // a stall outranks memory when picking the likely cause.
40
+ const HANG_LOOP_DELAY_MS = 10000;
41
+ const MEMORY_PRESSURE_MB = 3000;
42
+
43
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
44
+
45
+ /**
46
+ * Turns the previous run's last sample into a likely cause. silentGapSec is
47
+ * accepted for the caller's meta but does not change the verdict.
48
+ * @param {{ lastLoopDelayMaxMs?: unknown, lastTotalMb?: unknown, silentGapSec?: unknown }} [sample]
49
+ * @returns {'main-hang' | 'memory-pressure' | 'unknown'}
50
+ */
51
+ function classifyUncleanShutdown({ lastLoopDelayMaxMs, lastTotalMb } = {}) {
52
+ if (isNum(lastLoopDelayMaxMs) && lastLoopDelayMaxMs >= HANG_LOOP_DELAY_MS) return 'main-hang';
53
+ if (isNum(lastTotalMb) && lastTotalMb >= MEMORY_PRESSURE_MB) return 'memory-pressure';
54
+ return 'unknown';
55
+ }
56
+
57
+ /**
58
+ * Routes a Chromium child-process-gone event. Chromium restarts the Utility
59
+ * 'Network Service' itself, so a non-oom exit there is a warn log line; all
60
+ * else is a crash error via reportCrash. Fail-inert: never throws.
61
+ */
62
+ function reportChildProcessGone({ details, lastTotalMb, deps } = {}) {
63
+ try {
64
+ const d = details || {};
65
+ const type = d.type;
66
+ const name = d.name || d.serviceName;
67
+ if (type === 'Utility' && name === 'Network Service' && d.reason !== 'oom') {
68
+ const telemetryClient = (deps && deps.telemetryClient) || require('./telemetryClient.cjs');
69
+ return telemetryClient.logLine({
70
+ level: 'warn',
71
+ msg: 'crash.child-restartable',
72
+ fields: { type, name, reason: d.reason, exitCode: d.exitCode, lastTotalMb },
73
+ });
74
+ }
75
+ return reportCrash({
76
+ reason: d.reason || 'unknown',
77
+ meta: { type, name, exitCode: d.exitCode, lastTotalMb },
78
+ deps,
79
+ });
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ module.exports = { reportCrash, classifyUncleanShutdown, reportChildProcessGone };
@@ -45,7 +45,14 @@ function todayFile(cwd) {
45
45
  return path.join(logsDir(cwd), `errors-${yyyy}-${mm}-${dd}.jsonl`);
46
46
  }
47
47
 
48
- function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta }) {
48
+ // Non-PII triage code: short lowercase slug. Anything else is dropped (never thrown).
49
+ const CODE_RE = /^[a-z0-9][a-z0-9:._-]{0,47}$/;
50
+
51
+ function normalizeCode(code) {
52
+ return typeof code === 'string' && CODE_RE.test(code) ? code : undefined;
53
+ }
54
+
55
+ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta, safeCode }) {
49
56
  const file = todayFile(cwd);
50
57
  try {
51
58
  assertOpsWrite(file, 'logs');
@@ -58,6 +65,7 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
58
65
  `scope:${scope || 'unknown'}`,
59
66
  ...(tabId ? [`tab:${tabId}`] : []),
60
67
  ...(epicId ? [`epic:${epicId}`] : []),
68
+ ...(safeCode ? [`code:${safeCode}`] : []),
61
69
  ];
62
70
  const allTags = Array.from(new Set([...autoTags, ...tags]));
63
71
 
@@ -68,6 +76,7 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
68
76
  tabId: tabId || null,
69
77
  epicId: epicId || null,
70
78
  tags: allTags,
79
+ ...(safeCode ? { code: safeCode } : {}),
71
80
  message: String(message ?? ''),
72
81
  ...(meta !== undefined ? { meta: sanitizeMeta(meta) } : {}),
73
82
  };
@@ -83,9 +92,13 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
83
92
  }
84
93
 
85
94
  /**
86
- * Mirrors every appendError() line to telemetryClient — the single tap that
95
+ * Mirrors appendError() lines to telemetryClient — the single tap that
87
96
  * covers pty/chatRunner/scheduler/logs.cjs/renderer in one place, since they
88
- * all already funnel through appendError. Runs AFTER the local write and is
97
+ * all already funnel through appendError. Level routing: 'error' ships as a
98
+ * telemetry error, 'warn' as a warn log line, anything else ('info', 'debug')
99
+ * ships nothing (it is a success/diagnostic, not an error). An optional
100
+ * validated `code` rides in context/fields and the msg label so host-side
101
+ * reports are triageable though `message` is always redacted. Runs AFTER the local write and is
89
102
  * independently wrapped so a throwing telemetry stub can never prevent or
90
103
  * corrupt it. Reported regardless of the local ephemeral-cwd refusal — an
91
104
  * Epic/job worktree still has a REAL project behind it, just not one that
@@ -105,7 +118,8 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
105
118
  * `scope`, a small closed vocabulary of subsystem names — structural, never
106
119
  * free text.
107
120
  */
108
- function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message }) {
121
+ function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message, safeCode }) {
122
+ if (level !== 'error' && level !== 'warn') return;
109
123
  try {
110
124
  const telemetryClient = require('./telemetryClient.cjs');
111
125
  const { projectRootOf } = require('../activeSessions.cjs');
@@ -116,8 +130,11 @@ function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message })
116
130
  ...(epicId ? [`epic:${epicId}`] : []),
117
131
  ];
118
132
  const allTags = Array.from(new Set([...autoTags, ...(tags || [])]));
119
- const label = `opsError:${scope || 'unknown'}`;
120
- const context = { scope: scope || 'unknown', tags: allTags, cwd: normalizedCwd, message };
133
+ const label = safeCode ? `opsError:${scope || 'unknown'}:${safeCode}` : `opsError:${scope || 'unknown'}`;
134
+ const context = {
135
+ scope: scope || 'unknown', tags: allTags, cwd: normalizedCwd, message,
136
+ ...(safeCode ? { code: safeCode } : {}),
137
+ };
121
138
  if (level === 'warn') {
122
139
  telemetryClient.logLine({ level: 'warn', msg: label, fields: context });
123
140
  } else {
@@ -159,7 +176,8 @@ function isInsideTestScratch(cwd) {
159
176
  * @param {{
160
177
  * cwd: string, // required — which project's ops root owns this line
161
178
  * scope: string, // subsystem name, e.g. 'pty', 'chatRunner', 'voice'
162
- * level?: string, // default 'error'; 'warn' also accepted
179
+ * level?: string, // default 'error'; telemetry: 'error' -> error, 'warn' -> warn log, else nothing
180
+ * code?: string, // optional non-PII triage slug, /^[a-z0-9][a-z0-9:._-]{0,47}$/; invalid is dropped
163
181
  * tabId?: string, // claudeSessionId of the tab this error belongs to, if any
164
182
  * epicId?: string, // Epic id, if this error happened inside an Epic-backed run
165
183
  * tags?: string[], // extra caller-supplied tags, merged with the auto-derived ones
@@ -167,13 +185,15 @@ function isInsideTestScratch(cwd) {
167
185
  * meta?: unknown,
168
186
  * }} entry
169
187
  */
170
- function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
188
+ function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta, code }) {
171
189
  if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
172
190
 
173
191
  // Test-process guard: a vitest fixture passing a real project root must never
174
192
  // write to that project's logs/ NOR ship a fake event to production telemetry.
175
193
  if (process.env.VITEST && !isInsideTestScratch(cwd)) return;
176
194
 
195
+ const safeCode = normalizeCode(code);
196
+
177
197
  if (isEphemeralCwd(cwd)) {
178
198
  // A worktree is torn down when its Epic/job ends and os.tmpdir() is
179
199
  // scratch space either way — never materialize logs there. See
@@ -183,10 +203,10 @@ function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], me
183
203
  // tap below still fires — this refusal is about the LOCAL write only.
184
204
  console.warn(`[opsErrorLog] appendError: refusing ephemeral cwd "${cwd}" (scope=${scope || 'unknown'})`);
185
205
  } else {
186
- writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta });
206
+ writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta, safeCode });
187
207
  }
188
208
 
189
- reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message });
209
+ reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message, safeCode });
190
210
  }
191
211
 
192
212
  module.exports = { appendError, logsDir, todayFile };
@@ -137,7 +137,8 @@ function sentPath() {
137
137
  }
138
138
 
139
139
  /**
140
- * True when running under a test runner (VITEST, NODE_ENV=test) with no
140
+ * True when running under a test runner (VITEST, NODE_ENV=test, or Playwright
141
+ * e2e via SM_E2E=1, set by tests/e2e/_helpers/launchApp.ts) with no
141
142
  * explicit SM_TELEMETRY_SPOOL override. Gates appendRecord() below so a test
142
143
  * that merely exercises epicMint/scheduler/etc. code paths — without itself
143
144
  * setting up an isolated spool — can never accumulate fake records into a
@@ -148,7 +149,7 @@ function sentPath() {
148
149
  */
149
150
  function isTestEnvironment() {
150
151
  if (process.env.SM_TELEMETRY_SPOOL) return false;
151
- return !!process.env.VITEST || process.env.NODE_ENV === 'test';
152
+ return !!process.env.VITEST || process.env.NODE_ENV === 'test' || process.env.SM_E2E === '1';
152
153
  }
153
154
 
154
155
  function getBeaconToken() {
@@ -826,4 +827,6 @@ module.exports = {
826
827
  _setFetchImpl,
827
828
  _setMachineProfileBuilder,
828
829
  _setLogger,
830
+ _currentEnv: currentEnv,
831
+ _buildInstallWire: buildInstallWire,
829
832
  };
@@ -325,6 +325,45 @@ async function maxParallelGroupInUse(prdsDir) {
325
325
  return max;
326
326
  }
327
327
 
328
+ /**
329
+ * Bound the `.reserved-NN` marker pile. Markers far below the highest
330
+ * reserved NN protect nothing (the O_EXCL race only matters for candidates
331
+ * near the floor, and `.max-allocated-group` already keeps the allocator
332
+ * monotonic), so keep the newest `keep` numbers and delete older markers
333
+ * whose mtime is also older than `minAgeMs`. Returns { removed, kept }.
334
+ */
335
+ async function pruneStaleReservations(prdsDir, { keep = 100, minAgeMs = 3600000, now = Date.now() } = {}) {
336
+ let entries;
337
+ try {
338
+ entries = await fsp.readdir(prdsDir);
339
+ } catch {
340
+ return { removed: 0, kept: 0 };
341
+ }
342
+ const markers = [];
343
+ let max = 0;
344
+ for (const name of entries) {
345
+ const m = name.match(RESERVATION_RE);
346
+ if (!m) continue;
347
+ const nn = Number(m[1]);
348
+ markers.push({ name, nn });
349
+ max = Math.max(max, nn);
350
+ }
351
+ const cutoff = max - keep;
352
+ let removed = 0;
353
+ for (const { name, nn } of markers) {
354
+ if (nn > cutoff) continue;
355
+ try {
356
+ const st = await fsp.stat(path.join(prdsDir, name));
357
+ if (now - st.mtimeMs <= minAgeMs) continue;
358
+ await fsp.unlink(path.join(prdsDir, name));
359
+ removed += 1;
360
+ } catch {
361
+ // raced with another remover, or unreadable — leave it
362
+ }
363
+ }
364
+ return { removed, kept: markers.length - removed };
365
+ }
366
+
328
367
  /**
329
368
  * Atomically allocate a brand-new parallel-group number under prdsDir.
330
369
  * Returns the reserved NN. Reservation survives a crash mid-allocation
@@ -360,6 +399,16 @@ async function allocateParallelGroup(prdsDir, { extraFloor = 0 } = {}) {
360
399
  if (candidate > highWater) {
361
400
  await writeHighWaterMark(prdsDir, candidate);
362
401
  }
402
+ try {
403
+ let keep = 100;
404
+ try {
405
+ const k = require('../lib/opsRetentionSettings.cjs').read().reservedMarkersKeep;
406
+ if (Number.isFinite(k) && k >= 0) keep = k;
407
+ } catch { /* default */ }
408
+ await pruneStaleReservations(prdsDir, { keep });
409
+ } catch {
410
+ // best-effort; never fail an allocation over housekeeping
411
+ }
363
412
  return candidate;
364
413
  }
365
414
  throw new Error(`allocateParallelGroup: exhausted ${MAX_RESERVE_ATTEMPTS} reservation attempts starting at ${candidate}`);
@@ -370,6 +419,7 @@ module.exports = {
370
419
  parsePrd,
371
420
  listPrdFiles,
372
421
  allocateParallelGroup,
422
+ pruneStaleReservations,
373
423
  maxParallelGroupInUse,
374
424
  PRD_READ_MAX_BYTES,
375
425
  _resetCache,
@@ -13,9 +13,10 @@ const schedulerPaths = require('../lib/scheduler/schedulerPaths.cjs');
13
13
  const logs = require('../logs.cjs');
14
14
  const { SCHEDULE_SLUG_RE } = require('../ipcSchemas.cjs');
15
15
  const { DEFAULT_PROJECT_CWD } = require('../lib/scheduler/schedulerBatch.cjs');
16
- const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs, ancestorEpicPrdDirs, ancestorEpicArchivedPrdDirs } = require('../lib/prd/prdLocations.cjs');
16
+ const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, resolveEpicPrdWriteDir, isSafeEpicId, listArchivedPrdDirs, ancestorEpicPrdDirs, ancestorEpicArchivedPrdDirs } = require('../lib/prd/prdLocations.cjs');
17
17
  const { migratePrds, consolidateFlatPrds, legacyAdoptExistingPrds } = require('../lib/prd/prdMigration.cjs');
18
- const { allProjectCwds } = require('../lib/activeSessions.cjs');
18
+ const { opsPath } = require('../lib/opsOwnership.cjs');
19
+ const { allProjectCwds, projectRootOf } = require('../lib/activeSessions.cjs');
19
20
 
20
21
  /**
21
22
  * Every PRD-source directory currently in play: the legacy global dir (still
@@ -145,23 +146,69 @@ async function findPrdDir(slug) {
145
146
  }
146
147
 
147
148
  /**
148
- * Job-aware widening of findPrdDir: tries the ordinary global candidate
149
- * search first (unchanged), then a stored absolute PRD path on the row (if
149
+ * Deterministic, scan-free candidate PRD dirs for a job, derived only from
150
+ * `job.cwd` (projectRootOf-normalized): the exact `job.epicId` Epic dir (when
151
+ * the id is safe and the dir exists), then every Epic prds dir of the project,
152
+ * then the project's own prds dir. Ordered, deduplicated; [] when job.cwd is
153
+ * missing or not absolute. All paths are built with path.join.
154
+ */
155
+ async function jobScopedPrdDirs(job) {
156
+ const cwd = job && job.cwd;
157
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return [];
158
+ const root = projectRootOf(cwd);
159
+ const out = [];
160
+ const add = (dir) => { if (dir && !out.includes(dir)) out.push(dir); };
161
+ if (isSafeEpicId(job.epicId)) {
162
+ try {
163
+ const exact = resolveEpicPrdWriteDir(root, job.epicId);
164
+ if (fs.existsSync(exact)) add(exact);
165
+ } catch { /* unsafe epicId — skip the exact dir */ }
166
+ }
167
+ for (const dir of listEpicPrdDirs(root)) add(dir);
168
+ add(module.exports.prdDirForCwd(root));
169
+ return out;
170
+ }
171
+
172
+ async function dirHasSlug(dir, slug) {
173
+ try {
174
+ await fsp.access(path.join(dir, `${slug}.md`));
175
+ return true;
176
+ } catch { return false; }
177
+ }
178
+
179
+ /**
180
+ * Job-aware widening of findPrdDir: tries the job's exact Epic dir from
181
+ * jobScopedPrdDirs first, then the ordinary global candidate search, then the
182
+ * remaining job-scoped dirs, then a stored absolute PRD path on the row (if
150
183
  * the queue schema ever carries one — checked defensively), then walks up
151
184
  * to 3 ancestor directories above `job.cwd` for that EXACT `job.epicId`'s
152
185
  * `prds` dir (prdLocations.ancestorEpicPrdDirs).
153
186
  *
154
- * Fixes the incident where an Epic lives in a non-git PARENT folder P
155
- * (P/session-manager-operations/scheduler/epics/<epicId>/prds/*.md) but its
156
- * PRDs' frontmatter `cwd` is the sub-repo P/repo — P is never itself a
157
- * tracked project cwd, so findPrdDir's candidatePrdsDirs() never visits it
158
- * and the job was wrongly retired as `prd-missing`. epicId is matched
159
- * exactly (never a glob); every path is built with path.join, never string
160
- * concatenation with user input.
187
+ * The job-scoped lookup is what keeps a live PRD resolvable when its project
188
+ * has fallen out of allProjectCwds (a transcript-tail heuristic), which would
189
+ * otherwise hide it from findPrdDir (incident: Rabbits-Game
190
+ * 41-bunny-run-cycle-art, 2026-10-20, retired as `prd-missing`).
191
+ *
192
+ * The ancestor walk fixes the incident where an Epic lives in a non-git
193
+ * PARENT folder P (P/session-manager-operations/scheduler/epics/<epicId>/
194
+ * prds/*.md) but its PRDs' frontmatter `cwd` is the sub-repo P/repo — P is
195
+ * never itself a tracked project cwd, so candidatePrdsDirs() never visits it.
196
+ * epicId is matched exactly (never a glob); every path is built with
197
+ * path.join, never string concatenation with user input.
161
198
  */
162
199
  async function findPrdDirForJob(job) {
163
- const viaGlobalSearch = await module.exports.findPrdDir(job && job.slug);
200
+ const slug = job && job.slug;
201
+ const scoped = typeof slug === 'string' && slug && path.basename(slug) === slug
202
+ ? await module.exports.jobScopedPrdDirs(job) : [];
203
+ const exactDir = scoped.length && isSafeEpicId(job.epicId)
204
+ ? opsPath(job.cwd, 'scheduler', 'epics', job.epicId, 'prds')
205
+ : null;
206
+ if (exactDir && scoped[0] === exactDir && await dirHasSlug(exactDir, slug)) return exactDir;
207
+ const viaGlobalSearch = await module.exports.findPrdDir(slug);
164
208
  if (viaGlobalSearch) return viaGlobalSearch;
209
+ for (const dir of scoped) {
210
+ if (await dirHasSlug(dir, slug)) return dir;
211
+ }
165
212
  if (job && typeof job.prdPath === 'string' && job.prdPath) {
166
213
  try {
167
214
  await fsp.access(job.prdPath);
@@ -170,10 +217,7 @@ async function findPrdDirForJob(job) {
170
217
  }
171
218
  if (job && job.cwd && job.epicId) {
172
219
  for (const dir of ancestorEpicPrdDirs(job.cwd, job.epicId)) {
173
- try {
174
- await fsp.access(path.join(dir, `${job.slug}.md`));
175
- return dir;
176
- } catch { /* not here — try the next ancestor */ }
220
+ if (await dirHasSlug(dir, slug)) return dir;
177
221
  }
178
222
  }
179
223
  return null;
@@ -474,6 +518,7 @@ module.exports = {
474
518
  archivedTwinExists,
475
519
  findPrdDir,
476
520
  findPrdDirForJob,
521
+ jobScopedPrdDirs,
477
522
  safeSlugPathIn,
478
523
  realPrdsDir,
479
524
  safeSlugPath,
@@ -507,6 +507,23 @@ function healRefusalReason(job, verdict, committedDuringRun) {
507
507
  * gate or a real non-zero-exit error) is excluded — that failure is
508
508
  * evidence, not silence, and must never become a heal candidate (PRD 1102).
509
509
  */
510
+ // A human cancelled this row (scheduler_cancel_job). Incident 2026-10-19
511
+ // (git-viewer): cancelled jobs were parked as 'failed' (source
512
+ // 'remote:cancelJob'), then selectFailedAutoResetTargets re-queued them 10
513
+ // minutes later and they ran and merged unwanted commits. Marker is
514
+ // `cancelledAt`; the statusHistory source is the fallback for rows cancelled
515
+ // before the marker existed. Every automated selector must skip these; only a
516
+ // human scheduler_reset_job revives one.
517
+ function isCancelledJob(job) {
518
+ if (!job) return false;
519
+ if (typeof job.cancelledAt === 'string' && job.cancelledAt) return true;
520
+ const history = Array.isArray(job.statusHistory) ? job.statusHistory : [];
521
+ for (let i = history.length - 1; i >= 0; i--) {
522
+ if (history[i] && history[i].to === job.status) return history[i].source === 'remote:cancelJob';
523
+ }
524
+ return false;
525
+ }
526
+
510
527
  function isFailedUnverifiedShaped(job) {
511
528
  if (!job || job.status !== 'failed') return false;
512
529
  if (job.verifierVerdict && RESCANNABLE_VERDICTS.has(job.verifierVerdict)) return true;
@@ -517,7 +534,7 @@ function isFailedUnverifiedShaped(job) {
517
534
  }
518
535
 
519
536
  function isRescanCandidate(job) {
520
- if (!job) return false;
537
+ if (!job || isCancelledJob(job)) return false;
521
538
  // Default-ELIGIBLE: every needs_review row is a heal candidate unless its
522
539
  // verdict is in RESCAN_EXCLUDED_VERDICTS. No runId requirement here — a row
523
540
  // without one still gets the evidence rung and the unresolvable annotation.
@@ -549,6 +566,7 @@ function isTranscriptRescannable(job) {
549
566
  function selectEvidenceScanTargets(jobs, now = Date.now()) {
550
567
  const due = [];
551
568
  for (const j of jobs ?? []) {
569
+ if (isCancelledJob(j)) continue;
552
570
  if (j.status !== 'needs_review' || !isRescanCandidate(j)) continue;
553
571
  if (isTranscriptRescannable(j)) continue;
554
572
  if (j.looksDone) continue;
@@ -678,6 +696,7 @@ function failedAutoResetDisabled() {
678
696
  function selectFailedAutoResetTargets(jobs, now, thresholdMs) {
679
697
  const targets = [];
680
698
  for (const j of jobs ?? []) {
699
+ if (isCancelledJob(j)) continue;
681
700
  if (j.status !== 'failed') continue;
682
701
  const attempts = j.failedAutoResetAttempts ?? 0;
683
702
  if (attempts >= FAILED_AUTORESET_CAP) continue;
@@ -729,6 +748,7 @@ function selectFailedAutoResetTargets(jobs, now, thresholdMs) {
729
748
  function findStuckFailedJobs(jobs, now, thresholdMs) {
730
749
  const stuck = [];
731
750
  for (const j of jobs ?? []) {
751
+ if (isCancelledJob(j)) continue;
732
752
  if (j.status !== 'failed') continue;
733
753
  if (j.stuckFailedNotified === true) continue;
734
754
  if (!isRescanCandidate(j)) continue;
@@ -797,6 +817,7 @@ function needsReviewAutoResolveDisabled() {
797
817
  function selectExhaustedNeedsReviewTargets(jobs, now, thresholdMs) {
798
818
  const targets = [];
799
819
  for (const j of jobs ?? []) {
820
+ if (isCancelledJob(j)) continue;
800
821
  if (j.status !== 'needs_review') continue;
801
822
  if (isNeedsHuman(j)) continue;
802
823
  if (!isEligibleForNeedsReviewAutoResolve(j, jobs)) continue;
@@ -840,6 +861,7 @@ function selectExhaustedNeedsReviewTargets(jobs, now, thresholdMs) {
840
861
  function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRunId }) {
841
862
  const slugsInQueue = new Set(jobs.map((j) => j.slug));
842
863
  return jobs.filter((job) => {
864
+ if (isCancelledJob(job)) return false;
843
865
  if (job.status !== 'needs_review') return false;
844
866
  if (isNeedsHuman(job)) return false;
845
867
  // A job parked here because it was blocked by a sibling's foreign WIP
@@ -983,6 +1005,7 @@ function selectGateShadowTarget(jobs, headByCwd, { authorityDisabled = false, ba
983
1005
 
984
1006
  module.exports = {
985
1007
  isNeedsHuman,
1008
+ isCancelledJob,
986
1009
  selectResumeRecoveryTarget,
987
1010
  MECHANICALLY_RESOLVABLE_VERDICTS,
988
1011
  isMechanicalRecoveryFutile,