claude-code-session-manager 0.119.1 → 0.120.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 (72) hide show
  1. package/dist/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-LBg3mVL8.js} +1 -1
  2. package/dist/assets/{Button-DZX5guWb.js → Button-C4BN5ISR.js} +1 -1
  3. package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-CHhjk61s.js} +1 -1
  4. package/dist/assets/{History-D58whvn_.js → History-CL-T6Lv3.js} +1 -1
  5. package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-ByWVgXIf.js} +1 -1
  6. package/dist/assets/{Library-YqMi-vsA.js → Library-QvYVAEmY.js} +1 -1
  7. package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-FvNe6fo5.js} +1 -1
  8. package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-C7EmPm8B.js} +1 -1
  9. package/dist/assets/{Memory-BODDUBy4.js → Memory-nyLR4Yw4.js} +1 -1
  10. package/dist/assets/{Panel-CeOfUS9B.js → Panel-thElCbLX.js} +1 -1
  11. package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-FQ9GM6Ca.js} +1 -1
  12. package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-D9KE7XTI.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-lAViG8yV.js} +1 -1
  14. package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-AWpARk7N.js} +1 -1
  15. package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-c15EEGB_.js} +1 -1
  16. package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-FvduP1FG.js} +5 -5
  17. package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-BLep1rtB.js} +1 -1
  18. package/dist/assets/Settings-D1kTUIDB.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-_40_f0t3.js} +1 -1
  20. package/dist/assets/{Skills-BeCMBQ3l.js → Skills-DaTkSOuU.js} +1 -1
  21. package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-DxNf-soM.js} +1 -1
  22. package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-L6KpBV-x.js} +1 -1
  23. package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-C_XjtJ82.js} +1 -1
  24. package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-H0rERKNI.js} +1 -1
  25. package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-ZUWphDat.js} +1 -1
  26. package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-BxFKOztb.js} +1 -1
  27. package/dist/assets/{index-DdEMkmbi.js → index-B5wj0DEX.js} +8 -8
  28. package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
  29. package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-BRcYkXJN.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-DFGp4DST.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-VvrgalfC.js} +1 -1
  32. package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-mvXKNEE3.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 +11 -8
  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 +3 -3
  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/prd/prdWorktreeReach.cjs +2 -2
  55. package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
  56. package/src/main/lib/process/procGroupRss.cjs +61 -0
  57. package/src/main/lib/scheduler/queueHistory.cjs +68 -9
  58. package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
  59. package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
  60. package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
  61. package/src/main/lib/telemetry/auditLog.cjs +51 -6
  62. package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
  63. package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
  64. package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
  65. package/src/main/scheduler/prdParser.cjs +50 -0
  66. package/src/main/scheduler/prdPaths.cjs +61 -16
  67. package/src/main/scheduler/reviewLadder.cjs +24 -1
  68. package/src/main/scheduler.cjs +214 -37
  69. package/src/main/templates/PRD_AUTHORING.md +2 -2
  70. package/src/preload/api.d.ts +28 -3
  71. package/src/preload/index.cjs +7 -0
  72. package/dist/assets/Settings-B4LuoRnd.js +0 -4
@@ -0,0 +1,183 @@
1
+ /**
2
+ * opsRetentionSettings — machine-level retention policy for the ops sweeps.
3
+ *
4
+ * Storage: ~/.claude/session-manager/ops-retention.json
5
+ * Shape (DEFAULTS): { runLogs:{maxAgeDays,keepPerSlug}, runsArchiveDays, auditLogMonths,
6
+ * reservedMarkersKeep }
7
+ * Archived transcripts are kept forever (fixed policy, not a setting).
8
+ *
9
+ * Top-level in lib/ (no domain fits: read by scheduler, telemetry and epic sweeps alike).
10
+ * preview() runs the sweeps' own eligibility code in dry-run mode — it deletes nothing.
11
+ * Modelled on updaterSettings.cjs: frozen DEFAULTS, never-throwing read, strict write.
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const os = require('node:os');
18
+ const atomicFs = require('./util/atomicFs.cjs');
19
+
20
+ const DEFAULTS = Object.freeze({
21
+ runLogs: Object.freeze({ maxAgeDays: 14, keepPerSlug: 3 }),
22
+ runsArchiveDays: 30,
23
+ auditLogMonths: 6,
24
+ reservedMarkersKeep: 100,
25
+ });
26
+
27
+ // [min, max] per integer field; out-of-range values clamp, non-integers round.
28
+ const BOUNDS = Object.freeze({
29
+ 'runLogs.maxAgeDays': [3, 3650],
30
+ 'runLogs.keepPerSlug': [1, 1000],
31
+ runsArchiveDays: [1, 3650],
32
+ auditLogMonths: [1, 120],
33
+ reservedMarkersKeep: [1, 100000],
34
+ });
35
+
36
+ function storePath() {
37
+ return path.join(require('./scheduler/schedulerPaths.cjs').claudeHome(), 'session-manager', 'ops-retention.json');
38
+ }
39
+
40
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
41
+
42
+ function clamp(key, v) {
43
+ const [min, max] = BOUNDS[key];
44
+ return Math.min(max, Math.max(min, Math.round(v)));
45
+ }
46
+
47
+ /**
48
+ * Strict patch validation (used by write() and the IPC set handler). Returns a
49
+ * partial settings object with clamped integers; throws on unknown keys,
50
+ * or wrong types.
51
+ */
52
+ function validatePatch(patch) {
53
+ if (!isObj(patch)) throw new Error('Invalid ops retention config');
54
+ const out = {};
55
+ const num = (key, v) => {
56
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`Invalid ops retention config: ${key}`);
57
+ return clamp(key, v);
58
+ };
59
+ for (const [k, v] of Object.entries(patch)) {
60
+ if (k === 'runLogs') {
61
+ if (!isObj(v)) throw new Error('Invalid ops retention config: runLogs');
62
+ out.runLogs = {};
63
+ for (const [rk, rv] of Object.entries(v)) {
64
+ if (rk !== 'maxAgeDays' && rk !== 'keepPerSlug') throw new Error(`Invalid ops retention config: runLogs.${rk}`);
65
+ out.runLogs[rk] = num(`runLogs.${rk}`, rv);
66
+ }
67
+ } else if (k === 'runsArchiveDays' || k === 'auditLogMonths' || k === 'reservedMarkersKeep') {
68
+ out[k] = num(k, v);
69
+ } else {
70
+ throw new Error(`Invalid ops retention config: unknown key ${k}`);
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** Lenient field-by-field sanitize of on-disk data: bad fields are dropped, not fatal. */
77
+ function sanitizeStored(data) {
78
+ if (!isObj(data)) return null;
79
+ const out = {};
80
+ const int = (key, v) => (typeof v === 'number' && Number.isFinite(v) ? clamp(key, v) : undefined);
81
+ if (isObj(data.runLogs)) {
82
+ const m = int('runLogs.maxAgeDays', data.runLogs.maxAgeDays);
83
+ const k = int('runLogs.keepPerSlug', data.runLogs.keepPerSlug);
84
+ if (m !== undefined || k !== undefined) {
85
+ out.runLogs = {
86
+ maxAgeDays: m !== undefined ? m : DEFAULTS.runLogs.maxAgeDays,
87
+ keepPerSlug: k !== undefined ? k : DEFAULTS.runLogs.keepPerSlug,
88
+ };
89
+ }
90
+ }
91
+ for (const key of ['runsArchiveDays', 'auditLogMonths', 'reservedMarkersKeep']) {
92
+ const v = int(key, data[key]);
93
+ if (v !== undefined) out[key] = v;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** What is explicitly stored (sanitized), or null when absent/corrupt. Never throws. */
99
+ function readStored() {
100
+ try {
101
+ const raw = JSON.parse(fs.readFileSync(storePath(), 'utf8'));
102
+ return sanitizeStored(raw);
103
+ } catch (e) {
104
+ if (e.code !== 'ENOENT') console.warn('[opsRetentionSettings] read failed:', e.message);
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function withDefaults(stored) {
110
+ const s = stored || {};
111
+ return {
112
+ runLogs: { ...DEFAULTS.runLogs, ...(s.runLogs || {}) },
113
+ runsArchiveDays: s.runsArchiveDays ?? DEFAULTS.runsArchiveDays,
114
+ auditLogMonths: s.auditLogMonths ?? DEFAULTS.auditLogMonths,
115
+ reservedMarkersKeep: s.reservedMarkersKeep ?? DEFAULTS.reservedMarkersKeep,
116
+ };
117
+ }
118
+
119
+ /** Effective settings. Never throws: missing/corrupt file yields DEFAULTS. */
120
+ function read() {
121
+ return withDefaults(readStored());
122
+ }
123
+
124
+ /** Validate + merge `patch` over the current settings and persist the full object. */
125
+ function write(patch) {
126
+ const valid = validatePatch(patch);
127
+ const cur = read();
128
+ const next = {
129
+ ...cur,
130
+ ...valid,
131
+ runLogs: { ...cur.runLogs, ...(valid.runLogs || {}) },
132
+ };
133
+ atomicFs.writeJsonAtomicSync(storePath(), next, { mode: 0o600 });
134
+ return next;
135
+ }
136
+
137
+ /**
138
+ * Dry-run report of what each policy would free, via the sweeps' own
139
+ * eligibility code. Read-only. opts: { now, runsDir, plansRoot, jobs, liveKeys }.
140
+ */
141
+ function preview(opts) {
142
+ const o = opts || {};
143
+ const schedulerPaths = require('./scheduler/schedulerPaths.cjs');
144
+ const runLogRetention = require('./scheduler/runLogRetention.cjs');
145
+ const staleTmpSweep = require('./scheduler/staleTmpSweep.cjs');
146
+ const s = read();
147
+ const now = typeof o.now === 'number' ? o.now : Date.now();
148
+ const runsDir = o.runsDir || schedulerPaths.runsDir();
149
+ const plansRoot = o.plansRoot || schedulerPaths.scheduledPlansRoot();
150
+
151
+ let liveKeys = o.liveKeys;
152
+ if (!liveKeys) {
153
+ let jobs = o.jobs;
154
+ if (!jobs) {
155
+ try {
156
+ jobs = require('./scheduler/queueStore.cjs').readMergedSync().jobs || [];
157
+ } catch {
158
+ jobs = [];
159
+ }
160
+ }
161
+ liveKeys = runLogRetention.liveKeysFromJobs(jobs, { runsDir });
162
+ }
163
+ const report = runLogRetention.computeReport(runsDir, { ...s.runLogs }, { now, liveKeys });
164
+ const archive = staleTmpSweep.pruneRunsArchive(path.join(plansRoot, 'runs-archive'), {
165
+ maxAgeDays: s.runsArchiveDays,
166
+ now,
167
+ dryRun: true,
168
+ });
169
+ const tmp = staleTmpSweep.sweepStaleTmp(plansRoot, { now, dryRun: true });
170
+ return {
171
+ runLogs: { files: report.eligible.reduce((n, e) => n + e.removeFiles.length, 0) + report.dodFiles.length, bytes: report.eligibleSummary.bytes },
172
+ runsArchive: { entries: archive.removedFiles, bytes: archive.freedBytes },
173
+ tmp: { files: tmp.removedFiles, bytes: tmp.freedBytes },
174
+ };
175
+ }
176
+
177
+ function registerIpc(ipcMain) {
178
+ ipcMain.handle('opsRetention:get', () => read());
179
+ ipcMain.handle('opsRetention:set', (_e, patch) => write(patch));
180
+ ipcMain.handle('opsRetention:preview', () => preview());
181
+ }
182
+
183
+ module.exports = { storePath, read, readStored, write, validatePatch, preview, registerIpc, DEFAULTS, BOUNDS };
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Incident: burrow PRD 1516-fb-bilko-first-pumpkin named a gitignored runtime path by RELATIVE
5
5
  * path (downloads/fb-tracker.db) and an install-cron.sh that baked a worktree path. Scheduled
6
- * PRDs run in an isolated git job worktree holding only tracked files plus symlinked dependency
7
- * dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
6
+ * PRDs run in an isolated git job worktree holding only tracked files plus symlinked or (pnpm) worktree-local
7
+ * dependency dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
8
8
  * parked needs_human.
9
9
  *
10
10
  * Two exports: `extractRepoPathCandidates(text)` pulls repo-relative path-looking tokens out of
@@ -27,6 +27,8 @@ const WORK_TYPES = Object.freeze([
27
27
  'discussion',
28
28
  'build',
29
29
  'project-home-builder',
30
+ 'art',
31
+ 'portfolio',
30
32
  ]);
31
33
 
32
34
  const WorkTypeSchema = z.enum(WORK_TYPES);
@@ -37,17 +39,19 @@ const WorkTypeSchema = z.enum(WORK_TYPES);
37
39
  // legacy-drop preprocessing.
38
40
  const LEGACY_WORK_TYPES = Object.freeze(['bilko-host-publisher']);
39
41
 
40
- // A 'discussion'-tagged ticket never reaches PRD authoring (a domain
42
+ // 'discussion' and 'portfolio' tickets never reach PRD authoring (a domain
41
43
  // invariant unrelated to the Epic/PRD tag unification above), so the PRD
42
44
  // frontmatter `tag` field — parsed in prdFrontmatter.cjs/.ts and patched via
43
- // ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes it even though
45
+ // ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes them even though
44
46
  // WorkTypeSchema itself (used at PRD-create time) does not.
45
- const PRD_WORK_TYPES = Object.freeze(WORK_TYPES.filter((t) => t !== 'discussion'));
47
+ const NON_PRD_WORK_TYPES = Object.freeze(['discussion', 'portfolio']);
48
+ const PRD_WORK_TYPES = Object.freeze(WORK_TYPES.filter((t) => !NON_PRD_WORK_TYPES.includes(t)));
46
49
  const PrdWorkTypeSchema = z.enum(PRD_WORK_TYPES);
47
50
 
48
51
  module.exports = {
49
52
  WORK_TYPES,
50
53
  WorkTypeSchema,
54
+ NON_PRD_WORK_TYPES,
51
55
  PRD_WORK_TYPES,
52
56
  PrdWorkTypeSchema,
53
57
  LEGACY_WORK_TYPES,
@@ -0,0 +1,61 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * procGroupRss.cjs — summed resident memory (MB) of every process in a
6
+ * process group, read from /proc (Linux only).
7
+ *
8
+ * Why: the scheduler's memory gate double-counted running jobs (2026-10-18).
9
+ * Each job reserves 2500 MB, but part of that is already consumed and thus
10
+ * already missing from MemAvailable; subtracting the full reservation again
11
+ * counts it twice. Scheduler jobs are spawned `detached: true`, so a job's
12
+ * pid leads its own process group and its tsc/vite/node children share that
13
+ * pgid — summing the group's RSS tells the gate how much is already used.
14
+ *
15
+ * Parsing: /proc/<pid>/stat is `pid (comm) state ppid pgrp ... rss ...` and
16
+ * comm may contain spaces and parentheses, so split only after the LAST ')'.
17
+ * In the remainder (0-based): state=0, ppid=1, pgrp=2, rss=21 (pages).
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+
22
+ const PGRP_IDX = 2;
23
+ const RSS_IDX = 21;
24
+
25
+ /**
26
+ * @param {number} pgid
27
+ * @param {{ procRoot?: string, pageSizeKb?: number }} [opts]
28
+ * @returns {number} integer MB (floor); 0 on any non-applicable/failed case
29
+ */
30
+ function processGroupRssMb(pgid, opts = {}) {
31
+ if (!Number.isInteger(pgid) || pgid <= 0) return 0;
32
+ if (process.platform !== 'linux') return 0;
33
+ const procRoot = opts.procRoot ?? '/proc';
34
+ const pageSizeKb = opts.pageSizeKb ?? 4;
35
+
36
+ let names;
37
+ try {
38
+ names = fs.readdirSync(procRoot);
39
+ } catch {
40
+ return 0;
41
+ }
42
+
43
+ let totalKb = 0;
44
+ for (const name of names) {
45
+ if (!/^\d+$/.test(name)) continue;
46
+ try {
47
+ const stat = fs.readFileSync(`${procRoot}/${name}/stat`, 'utf8');
48
+ const close = stat.lastIndexOf(')');
49
+ if (close < 0) continue;
50
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
51
+ if (Number(fields[PGRP_IDX]) !== pgid) continue;
52
+ const rssPages = Number(fields[RSS_IDX]);
53
+ if (Number.isFinite(rssPages) && rssPages > 0) totalKb += rssPages * pageSizeKb;
54
+ } catch {
55
+ // vanished or unreadable entry — skip it
56
+ }
57
+ }
58
+ return Math.floor(totalKb / 1024);
59
+ }
60
+
61
+ module.exports = { processGroupRssMb };
@@ -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
  }