claude-code-session-manager 0.86.0 → 0.87.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 (128) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
  27. package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +14 -14
  30. package/plugins/CLAUDE.md +61 -0
  31. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  32. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
  33. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
  34. package/scripts/scheduler-mcp-server.cjs +7 -0
  35. package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
  36. package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
  37. package/src/main/__tests__/epicMint.test.cjs +2 -2
  38. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  39. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  40. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  41. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  42. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  43. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  44. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  45. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  46. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  47. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  48. package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
  49. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  50. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  51. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  52. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  53. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  54. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  55. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  56. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  57. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  58. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  59. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  60. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  61. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  62. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
  63. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  64. package/src/main/chatRunner.cjs +8 -5
  65. package/src/main/health.cjs +1 -1
  66. package/src/main/historyAggregator.cjs +5 -0
  67. package/src/main/index.cjs +95 -44
  68. package/src/main/ipcSchemas.cjs +47 -0
  69. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  70. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  71. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  72. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  73. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  74. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  75. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  76. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  77. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  78. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  79. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  80. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  81. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  82. package/src/main/lib/agentModelResolve.cjs +65 -27
  83. package/src/main/lib/bootSelfHeal.cjs +88 -0
  84. package/src/main/lib/delegationReadiness.cjs +290 -225
  85. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  86. package/src/main/lib/ephemeralCwd.cjs +1 -1
  87. package/src/main/lib/epicMint.cjs +3 -3
  88. package/src/main/lib/gitWorktree.cjs +42 -12
  89. package/src/main/lib/guardShims.cjs +156 -0
  90. package/src/main/lib/jobDirtFilter.cjs +7 -2
  91. package/src/main/lib/launchFailure.cjs +2 -1
  92. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  93. package/src/main/lib/needsReviewLedger.cjs +205 -0
  94. package/src/main/lib/opsErrorLog.cjs +1 -1
  95. package/src/main/lib/opsOwnership.cjs +1 -1
  96. package/src/main/lib/prdCreate.cjs +56 -1
  97. package/src/main/lib/prdDisposition.cjs +199 -0
  98. package/src/main/lib/prdFrontmatter.cjs +8 -2
  99. package/src/main/lib/prdLocations.cjs +167 -45
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  101. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  102. package/src/main/lib/projectRootResolve.cjs +1 -1
  103. package/src/main/lib/queueHistory.cjs +19 -1
  104. package/src/main/lib/queueStore.cjs +6 -1
  105. package/src/main/lib/reaperHelpers.cjs +181 -15
  106. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  107. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  108. package/src/main/lib/schedulerConfig.cjs +24 -0
  109. package/src/main/lib/usageCircuit.cjs +159 -0
  110. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  111. package/src/main/scheduler/prdParser.cjs +13 -0
  112. package/src/main/scheduler.cjs +1285 -143
  113. package/src/main/templates/PRD_AUTHORING.md +50 -0
  114. package/src/main/templates/project-pages-catalog.json +1 -1
  115. package/src/main/usage.cjs +21 -3
  116. package/src/preload/api.d.ts +92 -1
  117. package/src/preload/index.cjs +10 -0
  118. package/web/README.md +41 -0
  119. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  120. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  121. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  122. package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
  123. package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
  124. package/dist/assets/Permissions-BiYZNGYW.js +0 -3
  125. package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
  126. package/dist/assets/Settings-Cv-pRyms.js +0 -3
  127. package/dist/assets/settingsSchema-BJVciriw.js +0 -3
  128. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -12,12 +12,22 @@
12
12
  * live. Active-project discovery reuses activeSessions.cjs's
13
13
  * activeProjectCwds (the same discovery watchdogHelpers.cjs's sweep() uses)
14
14
  * rather than re-implementing transcript scanning here.
15
+ *
16
+ * CACHING NOTE (PRD adding mtime-keyed memoization to this module): what's
17
+ * cached here is only WHICH DIRECTORIES EXIST, never their CONTENTS. A new
18
+ * PRD .md file dropped into an already-known `prds/` dir does not change
19
+ * that dir's own existence, so it needs no cache invalidation here at all —
20
+ * `schedule:rescan` (scheduler.cjs's `ipcMain.handle('schedule:rescan', …)`)
21
+ * still reports it because the actual file-listing happens one layer down,
22
+ * in scheduler/prdParser.cjs's `listPrdFiles(dir)`, which has its OWN
23
+ * separate mtime cache keyed on that specific dir's mtime (bumped by the new
24
+ * file) and is unaffected by anything cached in this module.
15
25
  */
16
26
  'use strict';
17
27
 
18
28
  const fs = require('node:fs');
19
29
  const path = require('node:path');
20
- const { activeProjectCwds, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
30
+ const { activeProjectCwds, allProjectCwds } = require('./activeSessions.cjs');
21
31
 
22
32
  const { opsPath, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
23
33
 
@@ -53,18 +63,49 @@ function resolveEpicPrdWriteDir(cwd, epicId) {
53
63
  return path.join(resolveEpicsRoot(cwd), epicId, 'prds');
54
64
  }
55
65
 
66
+ // listEpicPrdDirs' readdir-the-Epics-root-plus-existsSync-per-subdir walk is
67
+ // the expensive half of prdLocations' per-reconcile()-pass cost (159/166
68
+ // dirs, ~430ms/~400ms measured). A new Epic's prds/ dir is always created in
69
+ // the SAME recursive mkdirSync call that creates `epics/<newId>/` itself
70
+ // (epicMint.cjs's ensureEpic, both the mint branch and the join branch's
71
+ // belt-and-suspenders mkdirSync) — so a brand-new `epics/<id>/prds` dir
72
+ // first coming into existence always bumps the Epics root's OWN mtime (a
73
+ // new directory entry under it), never just an existing subdir's mtime.
74
+ // Cache keyed on that mtime, same dir-mtime idiom as
75
+ // scheduler/prdParser.cjs's dirCache — no TTL, so the very next call after a
76
+ // mint sees the new dir with no wait.
77
+ const epicPrdDirsCache = new Map(); // epicsRootPath -> { mtimeMs, result }
78
+
56
79
  /** Enumerate every existing `epics/<id>/prds` dir under one project cwd. */
57
80
  function listEpicPrdDirs(cwd) {
58
81
  let root;
59
82
  try { root = resolveEpicsRoot(cwd); } catch { return []; }
83
+ let mtimeMs;
84
+ try {
85
+ mtimeMs = fs.statSync(root).mtimeMs;
86
+ } catch {
87
+ // Missing/unreadable root: nothing to scan, and any prior cache entry
88
+ // must not survive to be handed back once the dir later appears.
89
+ epicPrdDirsCache.delete(root);
90
+ return [];
91
+ }
92
+ const cached = epicPrdDirsCache.get(root);
93
+ if (cached && cached.mtimeMs === mtimeMs) return cached.result;
94
+
60
95
  let entries;
61
- try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
96
+ try {
97
+ entries = fs.readdirSync(root, { withFileTypes: true });
98
+ } catch {
99
+ epicPrdDirsCache.delete(root);
100
+ return [];
101
+ }
62
102
  const dirs = [];
63
103
  for (const ent of entries) {
64
104
  if (!ent.isDirectory()) continue;
65
105
  const prdDir = path.join(root, ent.name, 'prds');
66
106
  if (fs.existsSync(prdDir)) dirs.push(prdDir);
67
107
  }
108
+ epicPrdDirsCache.set(root, { mtimeMs, result: dirs });
68
109
  return dirs;
69
110
  }
70
111
 
@@ -101,6 +142,83 @@ function resolvePrdWriteDir(cwd) {
101
142
  return opsPath(cwd, ...PRD_SUBPATH);
102
143
  }
103
144
 
145
+ // resolvePrdsDirs/resolveArchivedPrdsDirs each redo a fs.existsSync(dir) +
146
+ // listEpicPrdDirs(cwd) walk across EVERY project on every call, and
147
+ // reconcile() calls each of them once per pass. Cache the assembled result
148
+ // keyed on the caller's own (maxAgeMin, opts) tuple — a call with an
149
+ // explicit `{ projectsDir }` opts object is a DIFFERENT key from the
150
+ // no-opts default, so the two never read or write each other's entry — PLUS
151
+ // a freshness key built from every visited project's Epics-root mtimeMs,
152
+ // same composite-mtime-key idiom as queueHistory.cjs's historyCacheKey. The
153
+ // freshness key is cheap to rebuild every call (one statSync per project,
154
+ // far cheaper than the readdir+existsSync-per-Epic walk it guards), so a
155
+ // cache hit still costs O(projects) stats but skips the expensive walk
156
+ // entirely — "one filesystem walk" per reconcile() pass, not the full
157
+ // listEpicPrdDirs cost N times over.
158
+ //
159
+ // The Epics-root mtime alone has a real blind spot: `prds-archived/` is
160
+ // created (archiveCompletedPrd, scheduler.cjs) as a NEW entry INSIDE an
161
+ // ALREADY-EXISTING `epics/<id>/` dir — unlike a brand-new Epic's `prds/`,
162
+ // which is always created in the SAME mkdirSync call as `epics/<id>/`
163
+ // itself (see listEpicPrdDirs's header), that does not bump the Epics
164
+ // root's own mtime. Re-stating every Epic subdirectory's own mtime on every
165
+ // call to close that gap would cost about what the walk itself costs,
166
+ // defeating the cache — so this cache pairs the mtime key (instant
167
+ // invalidation for the case the non-negotiable freshness test covers: a
168
+ // brand-new Epic's `prds/` dir) with the SAME short TTL backstop
169
+ // src/main/lib/activeSessions.cjs's cwdScanCache already established for
170
+ // this exact module chain, bounding the archived-dir blind spot to one
171
+ // TTL window instead of leaving it stale indefinitely. Nothing reads
172
+ // resolveArchivedPrdsDirs off the dispatch-correctness path — the
173
+ // archived-twin stale-queue-row guard (scheduler.cjs's archivedTwinExists)
174
+ // calls listArchivedPrdDirs directly, uncached — so this is purely a
175
+ // reporting-freshness bound (schedule:list-prds's per-Epic PRD/run counts).
176
+ const ASSEMBLED_DIRS_CACHE_TTL_MS = 30_000;
177
+ const assembledPrdsDirsCache = new Map(); // callerKey -> { freshnessKey, cachedAt, result }
178
+ const assembledArchivedPrdsDirsCache = new Map();
179
+
180
+ // Sorted-keys JSON.stringify: a plain JSON.stringify(opts) would key
181
+ // { a: 1, b: 2 } and { b: 2, a: 1 } as two different cache entries — a real
182
+ // (if currently latent, since every caller today passes at most one opts
183
+ // key) correctness trap for a cache key.
184
+ function stableStringify(obj) {
185
+ return JSON.stringify(obj, Object.keys(obj).sort());
186
+ }
187
+
188
+ function callerOptsKey(maxAgeMin, opts) {
189
+ return `${maxAgeMin}::${opts ? stableStringify(opts) : ''}`;
190
+ }
191
+
192
+ // mtimeMs of each visited project's Epics root, joined into one string. Any
193
+ // project whose root doesn't exist/isn't readable contributes a stable
194
+ // sentinel so the key still changes the moment that root is created.
195
+ function epicsRootsFreshnessKey(cwds) {
196
+ return cwds
197
+ .map((cwd) => {
198
+ let root;
199
+ try { root = resolveEpicsRoot(cwd); } catch { return `${cwd}:noroot`; }
200
+ try { return `${root}:${fs.statSync(root).mtimeMs}`; } catch { return `${root}:-1`; }
201
+ })
202
+ .join('|');
203
+ }
204
+
205
+ function memoizedAssembledDirs(cache, maxAgeMin, opts, compute) {
206
+ const callerKey = callerOptsKey(maxAgeMin, opts);
207
+ const allCwds = allProjectCwds(opts);
208
+ const activeCwds = activeProjectCwds(maxAgeMin, opts);
209
+ const cwdUnion = [...new Set([...allCwds, ...activeCwds])];
210
+ const freshnessKey = `${allCwds.join(',')}#${activeCwds.join(',')}#${epicsRootsFreshnessKey(cwdUnion)}`;
211
+
212
+ const cached = cache.get(callerKey);
213
+ if (cached && cached.freshnessKey === freshnessKey && Date.now() - cached.cachedAt < ASSEMBLED_DIRS_CACHE_TTL_MS) {
214
+ return cached.result;
215
+ }
216
+
217
+ const result = compute(allCwds, activeCwds);
218
+ cache.set(callerKey, { freshnessKey, cachedAt: Date.now(), result });
219
+ return result;
220
+ }
221
+
104
222
  /**
105
223
  * resolvePrdsDirs(maxAgeMin?, opts?) → string[]
106
224
  *
@@ -122,34 +240,36 @@ function resolvePrdWriteDir(cwd) {
122
240
  * underlying scan (e.g. `projectsDir` override for tests).
123
241
  */
124
242
  function resolvePrdsDirs(maxAgeMin, opts) {
125
- const dirs = [];
126
- const seen = new Set();
127
- const add = (dir) => {
128
- if (seen.has(dir)) return;
129
- seen.add(dir);
130
- dirs.push(dir);
131
- };
132
-
133
- // Every historical project that has a PRD dir on disk — the set that
134
- // matters for discovery, regardless of when it was last touched.
135
- for (const cwd of allProjectCwds(opts)) {
136
- let dir;
137
- try { dir = resolvePrdWriteDir(cwd); } catch { continue; }
138
- if (fs.existsSync(dir)) add(dir);
139
- // Epic-scoped dirs (the write layout for all new PRDs) are first-class
140
- // scan sources alongside the legacy flat dir.
141
- for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
142
- }
243
+ return memoizedAssembledDirs(assembledPrdsDirsCache, maxAgeMin, opts, (allCwds, activeCwds) => {
244
+ const dirs = [];
245
+ const seen = new Set();
246
+ const add = (dir) => {
247
+ if (seen.has(dir)) return;
248
+ seen.add(dir);
249
+ dirs.push(dir);
250
+ };
143
251
 
144
- // Active projects are added unconditionally: a brand-new project has no
145
- // prds/ dir yet, and callers that resolve a write destination must still
146
- // find it. Scans over a non-existent dir are a harmless ENOENT no-op.
147
- for (const cwd of activeProjectCwds(maxAgeMin, opts)) {
148
- try { add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
149
- for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
150
- }
252
+ // Every historical project that has a PRD dir on disk — the set that
253
+ // matters for discovery, regardless of when it was last touched.
254
+ for (const cwd of allCwds) {
255
+ let dir;
256
+ try { dir = resolvePrdWriteDir(cwd); } catch { continue; }
257
+ if (fs.existsSync(dir)) add(dir);
258
+ // Epic-scoped dirs (the write layout for all new PRDs) are first-class
259
+ // scan sources alongside the legacy flat dir.
260
+ for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
261
+ }
151
262
 
152
- return dirs;
263
+ // Active projects are added unconditionally: a brand-new project has no
264
+ // prds/ dir yet, and callers that resolve a write destination must still
265
+ // find it. Scans over a non-existent dir are a harmless ENOENT no-op.
266
+ for (const cwd of activeCwds) {
267
+ try { add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
268
+ for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
269
+ }
270
+
271
+ return dirs;
272
+ });
153
273
  }
154
274
 
155
275
  /**
@@ -163,26 +283,28 @@ function resolvePrdsDirs(maxAgeMin, opts) {
163
283
  * history, not something list-prds should stop counting.
164
284
  */
165
285
  function resolveArchivedPrdsDirs(maxAgeMin, opts) {
166
- const dirs = [];
167
- const seen = new Set();
168
- const add = (dir) => {
169
- if (seen.has(dir)) return;
170
- seen.add(dir);
171
- dirs.push(dir);
172
- };
173
-
174
- for (const cwd of allProjectCwds(opts)) {
175
- for (const dir of listArchivedPrdDirs(cwd)) {
176
- if (fs.existsSync(dir)) add(dir);
286
+ return memoizedAssembledDirs(assembledArchivedPrdsDirsCache, maxAgeMin, opts, (allCwds, activeCwds) => {
287
+ const dirs = [];
288
+ const seen = new Set();
289
+ const add = (dir) => {
290
+ if (seen.has(dir)) return;
291
+ seen.add(dir);
292
+ dirs.push(dir);
293
+ };
294
+
295
+ for (const cwd of allCwds) {
296
+ for (const dir of listArchivedPrdDirs(cwd)) {
297
+ if (fs.existsSync(dir)) add(dir);
298
+ }
177
299
  }
178
- }
179
- for (const cwd of activeProjectCwds(maxAgeMin, opts)) {
180
- for (const dir of listArchivedPrdDirs(cwd)) {
181
- if (fs.existsSync(dir)) add(dir);
300
+ for (const cwd of activeCwds) {
301
+ for (const dir of listArchivedPrdDirs(cwd)) {
302
+ if (fs.existsSync(dir)) add(dir);
303
+ }
182
304
  }
183
- }
184
305
 
185
- return dirs;
306
+ return dirs;
307
+ });
186
308
  }
187
309
 
188
310
  /**
@@ -7,8 +7,8 @@
7
7
  * first) — which is exactly why generation silently hung on a foreign
8
8
  * machine with no repo checked out. These four routes make the app itself
9
9
  * the authority on paths/schema/catalog/rendering, reusing the shipped
10
- * assets PRD 1088 put in the npm tarball (scripts/render-project-pages/dist/
11
- * renderer.cjs, scripts/project-pages-logic/dist/logic.cjs,
10
+ * assets PRD 1088 put in the npm tarball (web/project-pages/renderer/dist/
11
+ * renderer.cjs, web/project-pages/logic/dist/logic.cjs,
12
12
  * src/main/templates/project-pages-catalog.json,
13
13
  * src/main/templates/project-pages-pipeline.md) rather than reimplementing
14
14
  * any of them.
@@ -40,8 +40,8 @@ const LENSES = ['home', 'marketing', 'feature', 'architecture', 'brief'];
40
40
  // requires an app restart to pick up during dev, same as that module).
41
41
  const CATALOG_PATH = path.join(__dirname, '..', 'templates', 'project-pages-catalog.json');
42
42
  const SPEC_PATH = path.join(__dirname, '..', 'templates', 'project-pages-pipeline.md');
43
- const RENDERER_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', 'scripts', 'render-project-pages', 'dist', 'renderer.cjs');
44
- const LOGIC_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', 'scripts', 'project-pages-logic', 'dist', 'logic.cjs');
43
+ const RENDERER_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', 'web', 'project-pages', 'renderer', 'dist', 'renderer.cjs');
44
+ const LOGIC_BUNDLE_PATH = path.join(__dirname, '..', '..', '..', 'web', 'project-pages', 'logic', 'dist', 'logic.cjs');
45
45
 
46
46
  let cachedCatalog = null;
47
47
  function loadCatalog() {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * This schema is DESCRIPTIVE, not the enforcement mechanism — the actual
9
9
  * gate a posted summary must pass is `validateProjectPageSummary` (shipped
10
- * in scripts/project-pages-logic/dist/logic.cjs, called by
10
+ * in web/project-pages/logic/dist/logic.cjs, called by
11
11
  * /admin/project-home/validate-summary and /admin/project-home/render).
12
12
  * Kept hand-written rather than derived at runtime so the contract route
13
13
  * never needs to load the TypeScript source; if summaryType.ts's shape
@@ -34,7 +34,7 @@
34
34
  */
35
35
 
36
36
  const path = require('node:path');
37
- const { projectRootOf, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
37
+ const { projectRootOf, allProjectCwds } = require('./activeSessions.cjs');
38
38
  const { readActiveIndex } = require('./epicMint.cjs');
39
39
  const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
40
40
 
@@ -53,8 +53,15 @@ function historyPathFor(entry) {
53
53
  return HISTORY_PATH;
54
54
  }
55
55
 
56
+ // `kind` keeps a needs_review_entry/needs_review_resolution line (see
57
+ // needsReviewLedger.cjs) from colliding with a TERMINAL row that shares the
58
+ // same slug+runId — an entry and its resolution deliberately reference the
59
+ // SAME runId (that's how they're paired), so without `kind` in the dedupe
60
+ // key the second append would look like a replay of the first and silently
61
+ // never be written. Absent `kind` (every existing terminal row) buckets
62
+ // under 'terminal', so today's on-disk rows and callers are unaffected.
56
63
  function jobKey(job) {
57
- return `${job?.slug ?? ''}|${job?.runId ?? ''}`;
64
+ return `${job?.slug ?? ''}|${job?.runId ?? ''}|${job?.kind ?? 'terminal'}`;
58
65
  }
59
66
 
60
67
  /**
@@ -259,6 +266,12 @@ async function historyTerminalBySlug() {
259
266
  if (!line.trim()) continue;
260
267
  try {
261
268
  const j = JSON.parse(line);
269
+ // needs_review_entry/needs_review_resolution lines (needsReviewLedger.cjs)
270
+ // share this file but aren't a job's terminal record — skip them, or a
271
+ // needs_review episode logged for a slug AFTER its real terminal row
272
+ // (e.g. it re-parked on a later run) would overwrite a true 'completed'
273
+ // with a kind-less, status-less row read as "not terminal".
274
+ if (j?.kind && j.kind !== 'terminal') continue;
262
275
  if (j?.slug) map.set(j.slug, { status: j.status, finishedAt: j.finishedAt, landedCommit: j.landedCommit ?? null });
263
276
  } catch {
264
277
  // corrupt/partial line — ignore
@@ -301,6 +314,11 @@ async function completedSlugsForCwd(cwd) {
301
314
  if (!line.trim()) continue;
302
315
  try {
303
316
  const j = JSON.parse(line);
317
+ // Skip needs_review ledger lines (needsReviewLedger.cjs) — they carry
318
+ // no `status` field, so treating them as a real record would let a
319
+ // later needs_review park/resolution silently blank out this slug's
320
+ // last true terminal status.
321
+ if (j?.kind && j.kind !== 'terminal') continue;
304
322
  if (j?.slug) statusBySlug.set(j.slug, j.status);
305
323
  } catch {
306
324
  // corrupt/partial line — ignore
@@ -33,7 +33,7 @@ const fsp = require('node:fs/promises');
33
33
  const path = require('node:path');
34
34
  const os = require('node:os');
35
35
  const crypto = require('node:crypto');
36
- const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
36
+ const { allProjectCwds, activeProjectCwds, bustProjectCwdCache } = require('./activeSessions.cjs');
37
37
  const { assertOpsWrite, resolveOpsRoot, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
38
38
  const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
39
39
 
@@ -211,6 +211,11 @@ function stateCwds(opts) {
211
211
 
212
212
  function bustCwdCache() {
213
213
  cwdCache = { at: 0, cwds: [] };
214
+ // Without this chain, activeSessions' own scan cache hands stateCwds a
215
+ // stale list even after this bust — turning a caller's "I just wrote a
216
+ // brand-new project's first state file, make it visible now" into a
217
+ // no-op until activeSessions' independent TTL expires.
218
+ bustProjectCwdCache();
214
219
  }
215
220
 
216
221
  // ---------- merged read ----------
@@ -36,26 +36,37 @@ function claudePidAlive(pid) {
36
36
  }
37
37
 
38
38
  /**
39
- * findLiveProcessForJob(job, { worktreeDir }) → pid | null
39
+ * findLiveProcessForJob(job, { worktreeDir, runCwd }) → pid | null
40
40
  *
41
41
  * Positive liveness scan for a 'running' row whose `runtime.pid` is missing —
42
42
  * the case a missing pid must NOT be read as "the process is gone" (2026-09-06
43
43
  * incident: 234-uranus-eight-tails-ox marked failed/never_ran while PID
44
44
  * 2174739 was a live `claude -p` still writing to that job's own worktree).
45
45
  *
46
- * Linux-`/proc` only. Scans every numeric `/proc/<pid>` entry and matches
47
- * either: `/proc/<pid>/cwd` resolves to `worktreeDir` (or a path nested under
48
- * it), or `/proc/<pid>/cmdline` contains both `claude` and the job's slug
49
- * (fallback for a worktree-disabled/in-place run, where there is no dedicated
50
- * worktreeDir to match against). Returns the first matching pid, or null if
51
- * none is found.
46
+ * Linux-`/proc` only. Scans every numeric `/proc/<pid>` entry and matches any
47
+ * of, in order:
48
+ * - `/proc/<pid>/cwd` resolves to `worktreeDir` (or a path nested under it)
49
+ * — sufficient alone, no cmdline check needed.
50
+ * - `/proc/<pid>/cmdline` contains both `claude` and the job's slug —
51
+ * fallback for a worktree-disabled/in-place run with no dedicated
52
+ * worktreeDir to match against.
53
+ * - `/proc/<pid>/cwd` resolves to `runCwd` (or nested under it) AND
54
+ * `/proc/<pid>/cmdline` contains `claude` (2026-09-13 incident:
55
+ * runId 2026-09-13T16-15-56-818Z ran in-place in the shared tree — no
56
+ * worktree, and dispatch never puts the slug into argv — so neither of
57
+ * the above could ever match a live pid 3138458). `runCwd` is deliberately
58
+ * NEVER matched alone: several concurrent jobs commonly share one tree's
59
+ * cwd, so a bare cwd match without the process-identity check would
60
+ * cross-match the wrong job's process.
61
+ *
62
+ * Returns the first matching pid, or null if none is found.
52
63
  *
53
64
  * Safe fallback by construction: on any platform without `/proc` (macOS,
54
65
  * Windows) `fs.readdirSync('/proc')` throws and this returns null immediately
55
66
  * — i.e. exactly today's behaviour (fail toward terminalizing), never a hang
56
67
  * or a thrown error propagating to the caller.
57
68
  */
58
- function findLiveProcessForJob(job, { worktreeDir } = {}) {
69
+ function findLiveProcessForJob(job, { worktreeDir, runCwd } = {}) {
59
70
  const slug = job?.slug;
60
71
  let entries;
61
72
  try {
@@ -67,27 +78,107 @@ function findLiveProcessForJob(job, { worktreeDir } = {}) {
67
78
  if (!/^\d+$/.test(name)) continue;
68
79
  const pid = Number(name);
69
80
  if (!pid || pid <= 1) continue;
70
- if (worktreeDir) {
81
+
82
+ let cwdLink = null;
83
+ if (worktreeDir || runCwd) {
71
84
  try {
72
- const cwdLink = fs.readlinkSync(`/proc/${pid}/cwd`);
73
- if (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep)) return pid;
85
+ cwdLink = fs.readlinkSync(`/proc/${pid}/cwd`);
74
86
  } catch {
75
87
  // Process exited mid-scan, or permission denied — try the argv
76
88
  // fallback below before giving up on this pid.
77
89
  }
78
90
  }
79
- if (slug) {
91
+
92
+ if (worktreeDir && cwdLink && (cwdLink === worktreeDir || cwdLink.startsWith(worktreeDir + path.sep))) {
93
+ return pid;
94
+ }
95
+
96
+ let cmd = null;
97
+ if (slug || (runCwd && cwdLink)) {
80
98
  try {
81
- const cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
82
- if (/\bclaude\b/.test(cmd) && cmd.includes(slug)) return pid;
99
+ cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
83
100
  } catch {
84
101
  // Same as above — process gone or unreadable, keep scanning.
85
102
  }
86
103
  }
104
+
105
+ if (slug && cmd && /\bclaude\b/.test(cmd) && cmd.includes(slug)) return pid;
106
+
107
+ if (runCwd && cwdLink && cmd && /\bclaude\b/.test(cmd) &&
108
+ (cwdLink === runCwd || cwdLink.startsWith(runCwd + path.sep))) {
109
+ return pid;
110
+ }
87
111
  }
88
112
  return null;
89
113
  }
90
114
 
115
+ /**
116
+ * readSpawnedPidFromLog(logPath) → pid | null
117
+ *
118
+ * Recovers the pid a pidless row's spawn actually produced by reading the
119
+ * run's own log — proof of life that pre-dates and is independent of the
120
+ * `runtime.pid` persistence this reap path exists to route around (2026-09-13
121
+ * incident: the mutate that stamps runtime.pid failed silently while the
122
+ * spawn itself, and this exact log line, succeeded). Reads only the first
123
+ * ~8KB — scheduler.cjs's spawnJob writes `[scheduler] spawned pid=<n> ...`
124
+ * synchronously right after fork, always near the top of the log — so this
125
+ * stays cheap against a multi-MB log. Pure IO, never throws: a missing file,
126
+ * an empty file, or a log with no such line all return null.
127
+ */
128
+ function readSpawnedPidFromLog(logPath) {
129
+ if (!logPath) return null;
130
+ let fd;
131
+ try {
132
+ fd = fs.openSync(logPath, 'r');
133
+ const buf = Buffer.alloc(8192);
134
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
135
+ const text = buf.toString('utf8', 0, bytesRead);
136
+ const match = text.match(/\[scheduler\] spawned pid=(\d+)/);
137
+ if (!match) return null;
138
+ const pid = Number(match[1]);
139
+ return Number.isFinite(pid) && pid > 1 ? pid : null;
140
+ } catch {
141
+ return null;
142
+ } finally {
143
+ if (fd !== undefined) {
144
+ try { fs.closeSync(fd); } catch { /* already closed / never opened */ }
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * readLogMtimeMs(logPath) → number | null
151
+ *
152
+ * Same never-throws IO contract as logHasOutput — returns the run log's
153
+ * last-modified time, or null when it can't be statted (missing file,
154
+ * permission denied).
155
+ */
156
+ function readLogMtimeMs(logPath) {
157
+ if (!logPath) return null;
158
+ try {
159
+ return fs.statSync(logPath).mtimeMs;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * isLogFresh(logMtimeMs, now, windowMs) → boolean
167
+ *
168
+ * Pure predicate: true when a run log was modified within `windowMs` of
169
+ * `now`. A log that grew recently is positive, independent proof its process
170
+ * is still alive — stronger evidence than "no runtime.pid recorded", which is
171
+ * only bookkeeping about a side-channel write that can fail on its own (see
172
+ * readSpawnedPidFromLog's header). Returns false for a missing/invalid mtime
173
+ * or window so a caller that couldn't stat the log falls through to the
174
+ * existing reap logic rather than treating "unknown" as "fresh".
175
+ */
176
+ function isLogFresh(logMtimeMs, now, windowMs) {
177
+ if (typeof logMtimeMs !== 'number' || Number.isNaN(logMtimeMs)) return false;
178
+ if (typeof windowMs !== 'number' || windowMs <= 0) return false;
179
+ return (now - logMtimeMs) < windowMs;
180
+ }
181
+
91
182
  /**
92
183
  * True when `logPath` exists and has non-zero content — the literal "did the
93
184
  * run dir produce any log output" check that gates whether a pidless reap may
@@ -224,6 +315,20 @@ const ORPHAN_REQUEUE_CAP = 5;
224
315
  * work (2026-09-06 incident — see findLiveProcessForJob's header). Omitting
225
316
  * `findLiveProcess` (existing callers/tests) preserves prior behaviour
226
317
  * exactly: every pidless row past grace reaps, none are ever recovered.
318
+ *
319
+ * Two more optional injections, checked BEFORE `findLiveProcess`, for a
320
+ * pidless row past grace (2026-09-13 incident — see readSpawnedPidFromLog's
321
+ * and isLogFresh's own headers):
322
+ * - `getLogMtimeMs` (`(job) → number | null`): if the row's run log was
323
+ * modified within `logFreshWindowMs`, the row is skipped entirely — not
324
+ * pushed to `reapable` OR `recovered` — because a growing log is positive
325
+ * proof of life even with no pid evidence at all. One `warnings` entry is
326
+ * still emitted so a permanently-pidless-but-chatty row stays visible.
327
+ * - `getLogPid` (`(job) → pid | null`): reads the run's own log for the pid
328
+ * its spawn actually produced. If `pidAlive(pid)` is true, the row is
329
+ * diverted into `recovered` exactly like a `findLiveProcess` hit.
330
+ * Both default to a no-op when omitted, so existing callers/tests are
331
+ * unaffected.
227
332
  */
228
333
  /**
229
334
  * Pure, never-throws formatter for the dispatch-phase breadcrumb appended to
@@ -246,7 +351,47 @@ function formatDispatchPhaseSuffix(j) {
246
351
  : ` (last dispatch phase: ${j.dispatchPhase})`;
247
352
  }
248
353
 
249
- function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}) {
354
+ /**
355
+ * resolvePidlessFailureOverride(job) → { verdict, landedCommit, reason } | null
356
+ *
357
+ * The evidence-before-failure guard this PRD adds (1173): a pidless reap
358
+ * about to stamp 'failed' purely because `runtime.pid` was never recorded
359
+ * must first check whether THIS ROW already carries a `landedCommit` — proof
360
+ * (from an earlier dispatch of the same slug, since `landedCommit` is
361
+ * deliberately never cleared by a reset — see scheduler.cjs's comment near
362
+ * resetJobFields) that a real commit landed on the branch. Returns null when
363
+ * there is no such evidence, leaving the existing 'failed' transition for a
364
+ * genuinely-never-spawned row completely untouched.
365
+ *
366
+ * DESTINATION CHOICE — needs_review, never completed. `landedCommit` is
367
+ * real git evidence (it is only ever stamped from an actual HEAD advance or
368
+ * a proven branch-integration — see jobLandedCommitThisRun / the dead-pid
369
+ * reap's own isBranchAlreadyIntegrated+computeCommittedDuringRun proof in
370
+ * scheduler.cjs), so it is never speculative in the sense of "guessed before
371
+ * the work ran". But the pidless-reap path performs NO fresh integration
372
+ * check of its own for the CURRENT run (that proof only runs for the dead-pid
373
+ * branch's `dead` entries with outcome==='success' — see
374
+ * scheduler.cjs's integrationResults loop) — so at this call site there is no
375
+ * re-verified guarantee that the recorded commit corresponds to *this*
376
+ * dispatch rather than surviving, unreaped, from an earlier one. Real
377
+ * evidence that under-specifies correspondence to the current run is exactly
378
+ * the needs_review shape, not completed: a human (or the finish-protocol
379
+ * commit-guard on the next dispatch) can resolve it conclusively with
380
+ * `git show <sha>`, but this pure predicate must not guess.
381
+ */
382
+ function resolvePidlessFailureOverride(job) {
383
+ const landedCommit = job?.landedCommit || null;
384
+ if (!landedCommit) return null;
385
+ return {
386
+ verdict: 'pidless_reap_with_landed_commit',
387
+ landedCommit,
388
+ reason: `pidless reap found no runtime.pid, but this row already carries landedCommit ${landedCommit} — routing to needs_review instead of failed; verify with \`git show ${landedCommit}\``,
389
+ };
390
+ }
391
+
392
+ function selectReapableJobs(jobs, now, {
393
+ pidAlive, grace, findLiveProcess, getLogMtimeMs, getLogPid, logFreshWindowMs,
394
+ } = {}) {
250
395
  const reapable = [];
251
396
  const warnings = [];
252
397
  const recovered = [];
@@ -265,6 +410,22 @@ function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}
265
410
  }
266
411
  const ageMs = now - startedAt;
267
412
  if (ageMs < grace) continue; // spawn may still be mid-flight
413
+
414
+ const logMtimeMs = typeof getLogMtimeMs === 'function' ? getLogMtimeMs(j) : null;
415
+ if (isLogFresh(logMtimeMs, now, logFreshWindowMs)) {
416
+ warnings.push({
417
+ slug: j.slug,
418
+ reason: `pidless row skipped — run log modified ${Math.round((now - logMtimeMs) / 1000)}s ago, still alive by log evidence`,
419
+ });
420
+ continue; // neither reapable nor recovered — the log proves it's alive
421
+ }
422
+
423
+ const logPid = typeof getLogPid === 'function' ? getLogPid(j) : null;
424
+ if (logPid && pidAlive(logPid)) {
425
+ recovered.push({ slug: j.slug, pid: logPid });
426
+ continue;
427
+ }
428
+
268
429
  const livePid = typeof findLiveProcess === 'function' ? findLiveProcess(j) : null;
269
430
  if (livePid) {
270
431
  recovered.push({ slug: j.slug, pid: livePid });
@@ -275,6 +436,7 @@ function selectReapableJobs(jobs, now, { pidAlive, grace, findLiveProcess } = {}
275
436
  pid: null,
276
437
  pidless: true,
277
438
  reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed${formatDispatchPhaseSuffix(j)}`,
439
+ failureOverride: resolvePidlessFailureOverride(j),
278
440
  });
279
441
  }
280
442
  return { reapable, warnings, recovered };
@@ -343,7 +505,11 @@ module.exports = {
343
505
  findLiveProcessForJob,
344
506
  logHasOutput,
345
507
  resolvePidlessGateOutcome,
508
+ resolvePidlessFailureOverride,
346
509
  isAlreadySatisfiedOnMain,
347
510
  resolveCommitGuardOutcome,
348
511
  formatDispatchPhaseSuffix,
512
+ readSpawnedPidFromLog,
513
+ readLogMtimeMs,
514
+ isLogFresh,
349
515
  };
@@ -124,6 +124,14 @@ const ScheduleJobSchema = z
124
124
  // finalize (both the normal completion path and reapDeadRunningJobs).
125
125
  dispatchPhase: z.string().optional(),
126
126
  dispatchPhaseAt: z.string().optional(),
127
+ // needs_review episode bookkeeping (needsReviewLedger.cjs / PRD: needs_review
128
+ // durability): stamped by scheduleJobTransitions.cjs the instant a job
129
+ // transitions INTO 'needs_review', so the RESOLUTION line written when it
130
+ // later transitions back OUT can reference the same runId and compute a
131
+ // truthful dwell time — same lifecycle as heldReason/dispatchPhase (stamped
132
+ // at entry, deleted at resolution).
133
+ needsReviewEntryRunId: z.string().nullable().optional(),
134
+ needsReviewEnteredAt: z.string().nullable().optional(),
127
135
  })
128
136
  .passthrough();
129
137