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
@@ -2,6 +2,7 @@
2
2
 
3
3
  // Bounded-tail reader for Claude Code JSONL transcripts: the last N human-readable
4
4
  // chat messages (user prompts + assistant text) without reading the whole file.
5
+ // Also builds the phone's conversation page (exchanges + live tail) in pure code.
5
6
  // Top-level (no domain fits: epic/ transcripts are path-only; util/ is domain-free).
6
7
  // Transcripts reach hundreds of MB, so cost is O(maxBytes), never O(file).
7
8
 
@@ -11,6 +12,10 @@ const DEFAULT_LIMIT = 30;
11
12
  const MAX_LIMIT = 100;
12
13
  const DEFAULT_MAX_BYTES = 524288;
13
14
  const MAX_TEXT_CHARS = 4000;
15
+ const MAX_PROMPT_CHARS = 2000;
16
+ const MAX_RESULT_CHARS = 16000;
17
+ const MAX_LIVE_MESSAGES = 20;
18
+ const MAX_PAGE_TEXT_BYTES = 100000; // relay closes any message over 256 KB
14
19
 
15
20
  /** Join the plain-text parts of a message `content` (string or block array). */
16
21
  function textOf(content) {
@@ -22,8 +27,12 @@ function textOf(content) {
22
27
  .join('\n');
23
28
  }
24
29
 
30
+ function cap(text, max) {
31
+ return text.length > max ? text.slice(0, max - 1) + '…' : text;
32
+ }
33
+
25
34
  function truncate(text) {
26
- return text.length > MAX_TEXT_CHARS ? text.slice(0, MAX_TEXT_CHARS - 1) + '…' : text;
35
+ return cap(text, MAX_TEXT_CHARS);
27
36
  }
28
37
 
29
38
  /** Parse one JSONL line into a chat message, or null when it is not one. */
@@ -87,4 +96,62 @@ async function readRecentChatMessages(filePath, { limit = DEFAULT_LIMIT, maxByte
87
96
  }
88
97
  }
89
98
 
90
- module.exports = { readRecentChatMessages };
99
+ /**
100
+ * Pure: build one phone-ready conversation page from exchanges (NEWEST-FIRST,
101
+ * callers fetch `limit + 1` so `hasMore` is derivable) plus the live transcript tail.
102
+ * @param {{ exchanges?: any[], tail?: any[], limit?: number, before?: number }} [opts]
103
+ * @returns {{ messages: Array<{ role: string, text: string, ts: string|null, uuid: string|null, live?: true }>, hasMore: boolean, nextBefore: number|null }}
104
+ * messages oldest-first: history, then live.
105
+ */
106
+ function buildRemoteConversation({ exchanges = [], tail = [], limit = 10, before = 0 } = {}) {
107
+ const n = Math.floor(Number(limit));
108
+ const lim = Number.isFinite(n) && n >= 1 ? n : 10;
109
+ const b = Math.max(0, Math.floor(Number(before)) || 0);
110
+ const valid = (Array.isArray(exchanges) ? exchanges : []).filter((e) => e && typeof e === 'object' && typeof e.ts === 'string');
111
+ const candidates = valid.slice(0, lim);
112
+ let hasMore = valid.length > lim;
113
+ let budget = MAX_PAGE_TEXT_BYTES;
114
+ const size = (text) => Buffer.byteLength(text, 'utf8');
115
+
116
+ // Live tail (first page only): newest-first until the budget is hit.
117
+ const live = [];
118
+ if (b === 0 && Array.isArray(tail)) {
119
+ const newest = valid.reduce((m, e) => Math.max(m, Date.parse(e.ts) || 0), 0);
120
+ const fresh = tail.filter((m) => {
121
+ if (!m || typeof m !== 'object' || typeof m.text !== 'string') return false;
122
+ if (!valid.length) return true;
123
+ const t = typeof m.ts === 'string' ? Date.parse(m.ts) : NaN;
124
+ return Number.isFinite(t) && t > newest;
125
+ });
126
+ for (const m of fresh.slice(-MAX_LIVE_MESSAGES).reverse()) {
127
+ const bytes = size(m.text);
128
+ if (bytes > budget) break;
129
+ budget -= bytes;
130
+ live.push({ role: m.role, text: m.text, ts: m.ts ?? null, uuid: m.uuid ?? null, live: true });
131
+ }
132
+ live.reverse();
133
+ }
134
+
135
+ // History: whole exchanges newest-first, oldest dropped when over budget.
136
+ const history = [];
137
+ let returned = 0;
138
+ for (const e of candidates) {
139
+ const pair = [];
140
+ const prompt = typeof e.prompt === 'string' ? e.prompt.trim() : '';
141
+ const result = typeof e.result === 'string' ? e.result.trim() : '';
142
+ if (prompt) pair.push({ role: 'user', text: cap(prompt, MAX_PROMPT_CHARS), ts: e.ts, uuid: `ex:${e.ts}:u` });
143
+ if (result) pair.push({ role: 'assistant', text: cap(result, MAX_RESULT_CHARS), ts: e.ts, uuid: `ex:${e.ts}:a` });
144
+ const bytes = pair.reduce((s, m) => s + size(m.text), 0);
145
+ if (bytes > budget) {
146
+ hasMore = true;
147
+ break;
148
+ }
149
+ budget -= bytes;
150
+ history.unshift(...pair);
151
+ returned++;
152
+ }
153
+
154
+ return { messages: [...history, ...live], hasMore, nextBefore: hasMore ? b + returned : null };
155
+ }
156
+
157
+ module.exports = { readRecentChatMessages, buildRemoteConversation };
@@ -111,9 +111,72 @@ function partitionJobs(jobs, nowMs, opts = {}) {
111
111
  return { hot, toArchive };
112
112
  }
113
113
 
114
+ const SLIM_ARRAY_FIELDS = ['preRunDirtyPaths', 'guardBaseline', 'leftoverPaths'];
115
+ const SALVAGE_PATCH_MAX_BYTES = 2048;
116
+ // Dedupe only needs recent rows: a crash-replay re-submits the batch that was
117
+ // just appended, so the tail of the file always contains its keys.
118
+ const DEDUPE_TAIL_BYTES = 2 * 1024 * 1024;
119
+
114
120
  /**
115
- * Append-only JSONL write, deduped by slug+runId against what's already on
116
- * disk. The dedupe exists for crash-safety: reconcile() appends BEFORE
121
+ * Pure: returns a copy of `row` with the bulky snapshot arrays
122
+ * (preRunDirtyPaths, guardBaseline, leftoverPaths) replaced by `<field>Count`,
123
+ * and a salvagePatch string over 2 KB replaced by `salvagePatchBytes`. Every
124
+ * other field passes through unchanged. Rows without those fields are
125
+ * returned as-is (same shape, no extra keys).
126
+ */
127
+ function slimHistoryRow(row) {
128
+ if (!row || typeof row !== 'object') return row;
129
+ let out = row;
130
+ const slim = () => (out === row ? (out = { ...row }) : out);
131
+ for (const f of SLIM_ARRAY_FIELDS) {
132
+ if (Array.isArray(row[f])) {
133
+ const o = slim();
134
+ o[`${f}Count`] = row[f].length;
135
+ delete o[f];
136
+ }
137
+ }
138
+ if (typeof row.salvagePatch === 'string') {
139
+ const bytes = Buffer.byteLength(row.salvagePatch, 'utf8');
140
+ if (bytes > SALVAGE_PATCH_MAX_BYTES) {
141
+ const o = slim();
142
+ o.salvagePatchBytes = bytes;
143
+ delete o.salvagePatch;
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** Text of the last `maxBytes` of a file, cut to whole lines ('' if absent). */
150
+ async function readTailText(target, maxBytes) {
151
+ let fh;
152
+ try {
153
+ fh = await fsp.open(target, 'r');
154
+ } catch (e) {
155
+ if (e.code === 'ENOENT') return '';
156
+ throw e;
157
+ }
158
+ try {
159
+ const { size } = await fh.stat();
160
+ if (size === 0) return '';
161
+ const len = Math.min(maxBytes, size);
162
+ const start = size - len;
163
+ const buf = Buffer.alloc(len);
164
+ await fh.read(buf, 0, len, start);
165
+ let text = buf.toString('utf8');
166
+ if (start > 0) {
167
+ // Window may begin mid-line; drop the partial first fragment.
168
+ const nl = text.indexOf('\n');
169
+ text = nl === -1 ? '' : text.slice(nl + 1);
170
+ }
171
+ return text;
172
+ } finally {
173
+ await fh.close();
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Append-only JSONL write, deduped by slug+runId against the last 2 MB of
179
+ * what's already on disk. Rows are slimmed (slimHistoryRow) before writing. The dedupe exists for crash-safety: reconcile() appends BEFORE
117
180
  * dropping archived jobs from state.jobs, so a crash between those two steps
118
181
  * replays the same batch through partitionJobs on the next boot — dedupe
119
182
  * here is what stops that replay from double-writing history.
@@ -133,12 +196,7 @@ async function appendHistory(entries) {
133
196
 
134
197
  let appended = 0;
135
198
  for (const [target, group] of byPath) {
136
- let existingText = '';
137
- try {
138
- existingText = await fsp.readFile(target, 'utf8');
139
- } catch (e) {
140
- if (e.code !== 'ENOENT') throw e;
141
- }
199
+ const existingText = await readTailText(target, DEDUPE_TAIL_BYTES);
142
200
 
143
201
  const existingKeys = new Set();
144
202
  if (existingText) {
@@ -156,7 +214,7 @@ async function appendHistory(entries) {
156
214
  if (fresh.length === 0) continue;
157
215
 
158
216
  await fsp.mkdir(path.dirname(target), { recursive: true });
159
- const text = fresh.map((e) => JSON.stringify(e)).join('\n') + '\n';
217
+ const text = fresh.map((e) => JSON.stringify(slimHistoryRow(e))).join('\n') + '\n';
160
218
  await fsp.appendFile(target, text, 'utf8');
161
219
  appended += fresh.length;
162
220
  }
@@ -407,6 +465,7 @@ module.exports = {
407
465
  historyPath,
408
466
  partitionJobs,
409
467
  appendHistory,
468
+ slimHistoryRow,
410
469
  readHistory,
411
470
  historyTerminalBySlug,
412
471
  completedSlugsForCwd,
@@ -24,21 +24,32 @@
24
24
  * directory from being fully removed, but never blocks removal of a claimed
25
25
  * slug's own files.
26
26
  *
27
- * SAFETY MODEL (the point of this module — see the PRD that added it):
27
+ * FILE KINDS. Each (runId, slug) entry owns two kinds of file:
28
+ * - BULK: `<slug>.log`, `<slug>.supervisor.json`, `<slug>*.patch`,
29
+ * `root-cause-<slug>.md` — the space hogs.
30
+ * - METADATA: `<slug>.meta.json`, `<slug>.verdicts.json`,
31
+ * `<slug>.outcome.json` — tiny; terminalRunOutcome.cjs reads the newest
32
+ * run's copies as the re-run guard fallback.
33
+ *
34
+ * SEMANTICS (age-driven):
28
35
  * - Retention is ON by default (DEFAULT_RUN_LOG_RETENTION) when settings
29
36
  * carry no `schedulerRunLogRetention`. An explicit `enabled: false` (or a
30
37
  * config with no non-empty policy) opts out: computeReport, and
31
38
  * applyRetention when opted out, only READ the filesystem.
32
- * - An entry belonging to a job that is currently live — status `pending`
33
- * (queued), `running`, `needs_review`, or `investigating` (the exact
34
- * literals scheduler.cjs uses; see LIVE_STATUSES) — is never eligible,
35
- * regardless of age or count policy.
36
- * - The most recent entry for any given slug is never eligible, even past
37
- * an age cap — every PRD keeps at least its latest evidence.
38
- * - Age and count policies are COMBINABLE and combine conservatively
39
- * (AND): when both are set, an entry must clear both floors to be
40
- * eligible. A policy is a safety floor, not a removal trigger — setting
41
- * one dimension should never make the other one weaker.
39
+ * - A LIVE entry (status pending/running/needs_review/investigating — see
40
+ * LIVE_STATUSES) loses nothing, regardless of age or count.
41
+ * - With `maxAgeDays` set, age alone decides: a non-live entry older than
42
+ * maxAgeDays loses its BULK files even when it is the slug's newest/only
43
+ * run. `keepPerSlug` applies within the age window only — entries younger
44
+ * than maxAgeDays are always kept, so it never rescues an old entry.
45
+ * - With only `keepPerSlug` set (no maxAgeDays), bulk files of entries ranked
46
+ * beyond the newest N are eligible.
47
+ * - METADATA is removed only from entries that are bulk-eligible AND not the
48
+ * slug's newest run: the newest run of every slug always keeps its
49
+ * meta/verdicts/outcome files.
50
+ * - A directory left holding only definition-of-done-*.md files (batch-keyed,
51
+ * claimed by no entry) whose mtime is older than maxAgeDays is removed with
52
+ * them; a directory left empty is removed.
42
53
  */
43
54
 
44
55
  const fs = require('node:fs');
@@ -135,19 +146,26 @@ function scanRunEntries(runsDir) {
135
146
  const metaFiles = files.filter((f) => f.endsWith(META_SUFFIX));
136
147
  for (const metaFile of metaFiles) {
137
148
  const slug = metaFile.slice(0, -META_SUFFIX.length);
138
- const relatedNames = files.filter(
139
- (f) => f === metaFile || f === `${slug}.log` || f === `${slug}.supervisor.json` || f === `root-cause-${slug}.md`
140
- );
149
+ const isBulk = (f) =>
150
+ f === `${slug}.log` ||
151
+ f === `${slug}.supervisor.json` ||
152
+ f === `root-cause-${slug}.md` ||
153
+ (f.startsWith(`${slug}.`) && f.endsWith('.patch'));
154
+ const isMetadata = (f) =>
155
+ f === metaFile || f === `${slug}.verdicts.json` || f === `${slug}.outcome.json`;
156
+ const relatedNames = files.filter((f) => isBulk(f) || isMetadata(f));
141
157
 
142
158
  let sizeBytes = 0;
143
159
  let mtimeMs = null;
144
160
  const filePaths = [];
161
+ const fileInfos = [];
145
162
  for (const name of relatedNames) {
146
163
  const fp = path.join(dir, name);
147
164
  filePaths.push(fp);
148
165
  try {
149
166
  const st = fs.statSync(fp);
150
167
  sizeBytes += st.size;
168
+ fileInfos.push({ path: fp, size: st.size, kind: isBulk(name) ? 'bulk' : 'metadata' });
151
169
  if (mtimeMs === null || st.mtimeMs > mtimeMs) mtimeMs = st.mtimeMs;
152
170
  } catch {
153
171
  // file vanished mid-scan — skip its contribution
@@ -168,6 +186,7 @@ function scanRunEntries(runsDir) {
168
186
  slug,
169
187
  dir,
170
188
  files: filePaths,
189
+ fileInfos,
171
190
  sizeBytes,
172
191
  timeMs: recordedAtMs ?? mtimeMs ?? 0,
173
192
  });
@@ -206,10 +225,23 @@ function computeEligibility(entries, policy, opts) {
206
225
  const isMostRecent = rank === 0;
207
226
  const isLive = liveKeys.has(`${e.slug}|${e.runId}`);
208
227
  const ageDays = (now - e.timeMs) / DAY_MS;
209
- const ageOk = maxAgeDays == null || ageDays > maxAgeDays;
210
- const countOk = keepPerSlug == null || rank >= keepPerSlug;
211
- const eligible = hasPolicy && !isLive && !isMostRecent && ageOk && countOk;
212
- return { ...e, ageDays, rank, isMostRecent, isLive, eligible };
228
+ // Age alone decides when set; keepPerSlug only governs count-only policies.
229
+ const retained = maxAgeDays != null ? !(ageDays > maxAgeDays) : !(rank >= keepPerSlug);
230
+ const bulkEligible = hasPolicy && !isLive && !retained;
231
+ const metaEligible = bulkEligible && !isMostRecent;
232
+ const infos = e.fileInfos || [];
233
+ const removeFiles = infos.filter((f) => (f.kind === 'bulk' ? bulkEligible : metaEligible));
234
+ return {
235
+ ...e,
236
+ ageDays,
237
+ rank,
238
+ isMostRecent,
239
+ isLive,
240
+ bulkEligible,
241
+ metaEligible,
242
+ removeFiles,
243
+ eligible: removeFiles.length > 0,
244
+ };
213
245
  });
214
246
  }
215
247
 
@@ -228,27 +260,66 @@ function computeReport(runsDir, policy, opts) {
228
260
  const oldestRunAt = entries.length ? Math.min(...entries.map((e) => e.timeMs)) : null;
229
261
 
230
262
  const eligible = evaluated.filter((e) => e.eligible);
231
- const eligibleBytes = eligible.reduce((sum, e) => sum + e.sizeBytes, 0);
263
+ let bulkBytes = 0;
264
+ let metadataBytes = 0;
265
+ for (const e of eligible) {
266
+ for (const f of e.removeFiles) {
267
+ if (f.kind === 'bulk') bulkBytes += f.size;
268
+ else metadataBytes += f.size;
269
+ }
270
+ }
271
+
272
+ // A directory is fully removable when, after the eligible files are
273
+ // unlinked, nothing remains except definition-of-done reports older than
274
+ // maxAgeDays (which go with it). Any other leftover (a non-eligible entry's
275
+ // files, an unclaimed file, a young DoD report) keeps the directory alive.
276
+ const maxAgeDays = policy && policy.maxAgeDays;
277
+ const removing = new Set();
278
+ for (const e of eligible) for (const f of e.removeFiles) removing.add(f.path);
232
279
 
233
- // A directory is fully removable only when every file physically present
234
- // in it belongs to an eligible entry — an unclaimed file (a DoD report, or
235
- // a slug entry that isn't eligible) keeps the directory itself alive even
236
- // though the eligible entries' own files can still be unlinked.
280
+ let allDirs;
281
+ try {
282
+ allDirs = fs.readdirSync(runsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(runsDir, d.name));
283
+ } catch {
284
+ allDirs = [];
285
+ }
237
286
  const removableDirs = [];
238
- for (const dir of dirSet) {
287
+ const dodFiles = [];
288
+ for (const dir of allDirs) {
239
289
  let actualFiles;
240
290
  try {
241
291
  actualFiles = fs.readdirSync(dir);
242
292
  } catch {
243
293
  continue;
244
294
  }
245
- if (actualFiles.length === 0) continue;
246
- const claimedEligible = new Set();
247
- for (const e of evaluated) {
248
- if (e.dir !== dir || !e.eligible) continue;
249
- for (const fp of e.files) claimedEligible.add(path.basename(fp));
295
+ if (actualFiles.length === 0) continue; // empty orphans: sweepOrphanDirs
296
+ const dirDod = [];
297
+ let removable = true;
298
+ for (const f of actualFiles) {
299
+ const fp = path.join(dir, f);
300
+ if (removing.has(fp)) continue;
301
+ if (maxAgeDays != null && /^definition-of-done-.*\.md$/.test(f)) {
302
+ try {
303
+ const st = fs.statSync(fp);
304
+ if ((now - st.mtimeMs) / DAY_MS > maxAgeDays) {
305
+ dirDod.push({ path: fp, size: st.size, kind: 'metadata' });
306
+ continue;
307
+ }
308
+ } catch {
309
+ // vanished — treat as gone
310
+ continue;
311
+ }
312
+ }
313
+ removable = false;
314
+ break;
315
+ }
316
+ if (removable) {
317
+ removableDirs.push(dir);
318
+ for (const d of dirDod) {
319
+ dodFiles.push(d);
320
+ metadataBytes += d.size;
321
+ }
250
322
  }
251
- if (actualFiles.every((f) => claimedEligible.has(f))) removableDirs.push(dir);
252
323
  }
253
324
 
254
325
  return {
@@ -262,16 +333,30 @@ function computeReport(runsDir, policy, opts) {
262
333
  oldestRunAt,
263
334
  },
264
335
  eligible,
265
- eligibleSummary: { count: eligible.length, bytes: eligibleBytes },
336
+ eligibleSummary: {
337
+ count: eligible.length,
338
+ bytes: bulkBytes + metadataBytes,
339
+ bulkBytes,
340
+ metadataBytes,
341
+ },
342
+ dodFiles,
266
343
  removableDirs,
267
344
  };
268
345
  }
269
346
 
270
347
  /**
271
- * The effective retention config: the caller's `schedulerRunLogRetention`
272
- * when present, else DEFAULT_RUN_LOG_RETENTION.
348
+ * The effective retention config: ops-retention.json `runLogs` when stored,
349
+ * else the caller's `schedulerRunLogRetention` when present, else DEFAULT_RUN_LOG_RETENTION.
273
350
  */
274
351
  function resolveRetentionConfig(settings) {
352
+ // Machine-level ops-retention.json wins over the legacy scheduler-machine.json key.
353
+ // Lazy require: opsRetentionSettings requires this module (preview).
354
+ try {
355
+ const stored = require('../opsRetentionSettings.cjs').readStored();
356
+ if (stored && stored.runLogs) return { enabled: true, policy: { ...stored.runLogs } };
357
+ } catch {
358
+ // unreadable settings never block retention — fall through to legacy/default
359
+ }
275
360
  const cfg = settings && settings.schedulerRunLogRetention;
276
361
  return cfg == null ? DEFAULT_RUN_LOG_RETENTION : cfg;
277
362
  }
@@ -409,16 +494,17 @@ function applyRetention(runsDir, settings, opts) {
409
494
  let freedBytes = 0;
410
495
  const errors = [];
411
496
 
412
- for (const entry of report.eligible) {
413
- for (const fp of entry.files) {
414
- try {
415
- const st = fs.statSync(fp);
416
- fs.unlinkSync(fp);
417
- removedFiles += 1;
418
- freedBytes += st.size;
419
- } catch (e) {
420
- if (e && e.code !== 'ENOENT') errors.push({ path: fp, error: e.message });
421
- }
497
+ const toRemove = [];
498
+ for (const entry of report.eligible) for (const f of entry.removeFiles) toRemove.push(f.path);
499
+ for (const f of report.dodFiles) toRemove.push(f.path);
500
+ for (const fp of toRemove) {
501
+ try {
502
+ const st = fs.statSync(fp);
503
+ fs.unlinkSync(fp);
504
+ removedFiles += 1;
505
+ freedBytes += st.size;
506
+ } catch (e) {
507
+ if (e && e.code !== 'ENOENT') errors.push({ path: fp, error: e.message });
422
508
  }
423
509
  }
424
510
 
@@ -430,9 +430,12 @@ function enqueueTimestamp(job) {
430
430
  * nothing. A single project can therefore never drain every free slot while
431
431
  * another project with eligible work gets none in the same tick.
432
432
  * 1. fewest jobs currently `running` in that project, ascending;
433
- * 2. tiebreak: the oldest pending job's enqueue timestamp
433
+ * 2. tiebreak: the oldest DISPATCHABLE pending job's enqueue timestamp
434
434
  * (enqueueTimestamp — createdAt → queuedAt → statusHistory → startedAt),
435
- * ascending, unprovable ages last;
435
+ * ascending, unprovable ages last. Rows blocked by an unfinished
436
+ * dependsOn do NOT count (a project with only blocked rows gets
437
+ * Infinity). 2026-10-18: Game-Academy's dep-blocked 02:16Z row kept it
438
+ * ahead of Self's runnable 02:50Z rows, so it took every freed slot.
436
439
  * 3. final deterministic tiebreak: the project cwd string.
437
440
  *
438
441
  * WHY NOT THE PRD NUMBER. The previous sort key was each project's lowest
@@ -576,8 +579,9 @@ function pickNextBatch(allJobs, running, freeSlots, quietOpts = {}) {
576
579
  projectJobs.filter((j) => running.has(j.slug)).map((j) => j.slug),
577
580
  );
578
581
  const runningCount = projectJobs.filter((j) => j.status === 'running').length;
582
+ const satisfied = satisfiedSlugsFor(cwd);
579
583
  const oldestPendingMs = projectJobs
580
- .filter(pickable)
584
+ .filter((j) => pickable(j) && !findBlockingDep(j, projectJobs, satisfied))
581
585
  .reduce((min, j) => Math.min(min, enqueueTimestamp(j)), Infinity);
582
586
  candidates.push({ cwd, view, runningSlugsInProject, runningCount, oldestPendingMs, active: true });
583
587
  }
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * staleTmpSweep.cjs — reaps tmp files leaked by dead writers in scheduled-plans/
5
+ * (`queue.json.tmp-<pid>-*`, `.max-allocated-group.tmp-<pid>-*`) and prunes
6
+ * `scheduled-plans/runs-archive/` by age.
7
+ *
8
+ * Safety: a tmp file is removed only when it is older than minAgeMs AND the pid
9
+ * embedded in its name is not alive. `queue.json.bak*` files are never deleted —
10
+ * they are reported only.
11
+ */
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const TMP_RE = /\.tmp-(\d+)-/;
18
+
19
+ function defaultIsPidAlive(pid) {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ } catch (e) {
24
+ // EPERM = process exists but belongs to someone else → alive.
25
+ return !!e && e.code === 'EPERM';
26
+ }
27
+ }
28
+
29
+ function sweepStaleTmp(dir, opts) {
30
+ const o = opts || {};
31
+ const minAgeMs = typeof o.minAgeMs === 'number' ? o.minAgeMs : 3600000;
32
+ const now = typeof o.now === 'number' ? o.now : Date.now();
33
+ const isPidAlive = typeof o.isPidAlive === 'function' ? o.isPidAlive : defaultIsPidAlive;
34
+ const dryRun = o.dryRun === true;
35
+ const result = { removedFiles: 0, freedBytes: 0, removed: [], backups: [], errors: [] };
36
+ let names;
37
+ try {
38
+ names = fs.readdirSync(dir);
39
+ } catch (e) {
40
+ if (e && e.code !== 'ENOENT') result.errors.push(e.message);
41
+ return result;
42
+ }
43
+ for (const name of names) {
44
+ if (name.startsWith('queue.json.bak')) {
45
+ result.backups.push(name);
46
+ continue;
47
+ }
48
+ const m = TMP_RE.exec(name);
49
+ if (!m) continue;
50
+ const full = path.join(dir, name);
51
+ try {
52
+ const st = fs.statSync(full);
53
+ if (!st.isFile() || now - st.mtimeMs <= minAgeMs) continue;
54
+ if (isPidAlive(Number(m[1]))) continue;
55
+ if (!dryRun) fs.unlinkSync(full);
56
+ result.removedFiles += 1;
57
+ result.freedBytes += st.size;
58
+ result.removed.push(name);
59
+ } catch (e) {
60
+ if (e && e.code !== 'ENOENT') result.errors.push(`${name}: ${e.message}`);
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function sizeOf(p, st) {
67
+ if (!st.isDirectory()) return st.size;
68
+ let total = 0;
69
+ let kids = [];
70
+ try { kids = fs.readdirSync(p); } catch { return 0; }
71
+ for (const k of kids) {
72
+ try { total += sizeOf(path.join(p, k), fs.lstatSync(path.join(p, k))); } catch { /* raced */ }
73
+ }
74
+ return total;
75
+ }
76
+
77
+ // Lazy require: opsRetentionSettings requires this module (preview), so a
78
+ // top-level require here would be circular.
79
+ function defaultRunsArchiveDays() {
80
+ try {
81
+ return require('../opsRetentionSettings.cjs').read().runsArchiveDays;
82
+ } catch {
83
+ return 30;
84
+ }
85
+ }
86
+
87
+ function pruneRunsArchive(archiveDir, opts) {
88
+ const o = opts || {};
89
+ const maxAgeDays = typeof o.maxAgeDays === 'number' ? o.maxAgeDays : defaultRunsArchiveDays();
90
+ const dryRun = o.dryRun === true;
91
+ const now = typeof o.now === 'number' ? o.now : Date.now();
92
+ const result = { removedFiles: 0, freedBytes: 0, removed: [], errors: [] };
93
+ let names;
94
+ try {
95
+ names = fs.readdirSync(archiveDir);
96
+ } catch (e) {
97
+ if (e && e.code !== 'ENOENT') result.errors.push(e.message);
98
+ return result;
99
+ }
100
+ for (const name of names) {
101
+ const full = path.join(archiveDir, name);
102
+ try {
103
+ const st = fs.lstatSync(full);
104
+ if (now - st.mtimeMs <= maxAgeDays * DAY_MS) continue;
105
+ const bytes = sizeOf(full, st);
106
+ if (!dryRun) fs.rmSync(full, { recursive: true, force: true });
107
+ result.removedFiles += 1;
108
+ result.freedBytes += bytes;
109
+ result.removed.push(name);
110
+ } catch (e) {
111
+ if (e && e.code !== 'ENOENT') result.errors.push(`${name}: ${e.message}`);
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+
117
+ module.exports = { sweepStaleTmp, pruneRunsArchive };