claude-code-session-manager 0.95.0 → 0.97.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 (114) hide show
  1. package/dist/assets/{DataModel-DZK9QZpV.js → DataModel-D93FoaOm.js} +1 -1
  2. package/dist/assets/History-CXxxqNP1.js +2 -0
  3. package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CzeYFQjq.js} +3 -3
  4. package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-nESrTRIg.js} +1 -1
  5. package/dist/assets/{Library-DTxpOjx8.js → Library-D2zPkwOC.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-DbinP78A.js} +1 -1
  7. package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-Bu_sVGLr.js} +2 -2
  8. package/dist/assets/Memory-BgiI6LZ_.js +8 -0
  9. package/dist/assets/{Permissions-DhMMp27n.js → Permissions-C1MMzZRm.js} +3 -3
  10. package/dist/assets/Plugins-Dhu6wAZi.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-C3AcWKMG.js} +1 -1
  12. package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-plwYm0iO.js} +1 -1
  13. package/dist/assets/Scheduler-BVYTeh39.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-DFy0vv_o.js} +1 -1
  15. package/dist/assets/Settings-DIhGgdfs.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-DsI7hRPB.js} +1 -1
  17. package/dist/assets/Skills-3E748UfB.js +3 -0
  18. package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt-BqmfUJOd.js} +1 -1
  19. package/dist/assets/TagLibrary-CPz6fO8Q.js +1 -0
  20. package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-D9EoBQ1P.js} +1 -1
  21. package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BHAmZNDn.js} +1 -1
  22. package/dist/assets/index-tn5JIVPj.js +3079 -0
  23. package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-Crqy_aNz.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +8 -2
  26. package/plugins/CLAUDE.md +3 -3
  27. package/plugins/session-manager-dev/skills/develop/SKILL.md +90 -67
  28. package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
  29. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
  30. package/scripts/README.md +1 -0
  31. package/scripts/hooks/guard-destructive-git.cjs +9 -374
  32. package/scripts/hooks/guard-inline-implementation.cjs +9 -128
  33. package/scripts/hooks/guard-prd-writes.cjs +9 -74
  34. package/scripts/hooks/guard-self-schedule.cjs +9 -61
  35. package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
  36. package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
  37. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
  38. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
  39. package/scripts/scheduler-mcp-server.cjs +8 -1
  40. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
  41. package/src/main/__tests__/exchanges.test.cjs +41 -96
  42. package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
  43. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  44. package/src/main/__tests__/health-credentials.test.cjs +81 -0
  45. package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +39 -0
  46. package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
  47. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
  48. package/src/main/__tests__/planValidator.test.cjs +125 -0
  49. package/src/main/__tests__/prdCreate.test.cjs +3 -1
  50. package/src/main/__tests__/prdSizing.test.cjs +106 -0
  51. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
  52. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
  54. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
  55. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
  56. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  57. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  58. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  59. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  60. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  61. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  62. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  63. package/src/main/build-info.json +4 -4
  64. package/src/main/config.cjs +26 -14
  65. package/src/main/exchanges.cjs +26 -21
  66. package/src/main/health.cjs +66 -4
  67. package/src/main/index.cjs +1 -1
  68. package/src/main/ipcSchemas.cjs +15 -7
  69. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  70. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  71. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  72. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  73. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  74. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  75. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  76. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  77. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  78. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  79. package/src/main/lib/agentModelResolve.cjs +1 -0
  80. package/src/main/lib/credentials.cjs +62 -0
  81. package/src/main/lib/cwdClassify.cjs +15 -0
  82. package/src/main/lib/definitionOfDone.cjs +9 -13
  83. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  84. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  85. package/src/main/lib/loadGate.cjs +29 -5
  86. package/src/main/lib/opsOwnership.cjs +23 -0
  87. package/src/main/lib/planValidator.cjs +80 -0
  88. package/src/main/lib/prdCreate.cjs +8 -4
  89. package/src/main/lib/prdSizing.cjs +83 -0
  90. package/src/main/lib/runLogRetention.cjs +2 -8
  91. package/src/main/lib/telemetryBacklog.cjs +9 -3
  92. package/src/main/lib/telemetryClient.cjs +12 -3
  93. package/src/main/lib/telemetrySettings.cjs +8 -2
  94. package/src/main/lib/validationSentinels.cjs +29 -0
  95. package/src/main/memoryAggregate.cjs +36 -11
  96. package/src/main/otelSettings.cjs +8 -2
  97. package/src/main/scheduler.cjs +215 -31
  98. package/src/main/seedAgentPersonas.cjs +1 -1
  99. package/src/main/sessionsStore.cjs +8 -2
  100. package/src/main/supervisor.cjs +3 -3
  101. package/src/main/templates/PRD_AUTHORING.md +9 -9
  102. package/src/main/voiceSettings.cjs +8 -2
  103. package/src/preload/api.d.ts +3 -3
  104. package/src/preload/index.cjs +5 -5
  105. package/src/seed/agents/dev-lead.md +24 -15
  106. package/src/seed/agents/validator.md +33 -0
  107. package/dist/assets/History-BDyJUGtR.js +0 -2
  108. package/dist/assets/Memory-B-9GlHqe.js +0 -8
  109. package/dist/assets/Plugins-DKfAlmMY.js +0 -2
  110. package/dist/assets/Scheduler--rn1QBYc.js +0 -16
  111. package/dist/assets/Settings-Dsx6XZP5.js +0 -3
  112. package/dist/assets/Skills-D522Zfol.js +0 -3
  113. package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
  114. package/dist/assets/index-Yf8e7VCB.js +0 -3079
@@ -56,7 +56,7 @@ function freshTelemetry(home) {
56
56
  process.env.HOME = home;
57
57
  // Explicit opt-in: overrides the test-environment no-op guard so this
58
58
  // suite's real telemetryClient calls actually persist into an isolated dir.
59
- process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
59
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
60
60
  for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
61
61
  const resolved = require.resolve(p);
62
62
  delete require.cache[resolved];
@@ -133,7 +133,7 @@ function makeDeps({ home, telemetryClient, config, tabCwds }) {
133
133
  }
134
134
 
135
135
  function watermarksFileFor(home) {
136
- return path.join(home, '.config', 'session-manager', 'telemetry-watermarks.json');
136
+ return path.join(home, '.claude', 'session-manager', 'telemetry-watermarks.json');
137
137
  }
138
138
 
139
139
  async function readWatermarks(home) {
@@ -569,7 +569,7 @@ test('bootDrain always drains with reason "boot", and drains a second time with
569
569
 
570
570
  test('drainBacklog resolves (never rejects) when readdir fails, telemetryClient throws, and the watermarks file is corrupt', async () => {
571
571
  const home = await mkHome();
572
- await fsp.mkdir(path.join(home, '.config', 'session-manager'), { recursive: true });
572
+ await fsp.mkdir(path.join(home, '.claude', 'session-manager'), { recursive: true });
573
573
  await fsp.writeFile(watermarksFileFor(home), 'not valid { json', 'utf8');
574
574
  const project = await mkProject('contained');
575
575
  writeErrorsFile(project, recentDateStr(), [errLine(1)]);
@@ -584,7 +584,7 @@ test('drainBacklog resolves (never rejects) when readdir fails, telemetryClient
584
584
  logLine: async () => { throw new Error('boom client'); },
585
585
  flush: async () => { throw new Error('boom flush'); },
586
586
  isPending: async () => { throw new Error('boom pending'); },
587
- sentPath: () => path.join(home, '.config', 'session-manager', 'telemetry-sent.json'),
587
+ sentPath: () => path.join(home, '.claude', 'session-manager', 'telemetry-sent.json'),
588
588
  };
589
589
  const deps = {
590
590
  sessionsStore: { load: async () => ({ tabs: [{ cwd: project }] }) },
@@ -40,7 +40,7 @@ function freshModules(home) {
40
40
  process.env.HOME = home;
41
41
  // Explicit opt-in: overrides the test-environment no-op guard so this
42
42
  // suite's real telemetryClient calls actually persist into an isolated dir.
43
- process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
43
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
44
44
  for (const p of ['../telemetryConsent.cjs', '../telemetryClient.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs', '../../config.cjs']) {
45
45
  const resolved = require.resolve(p);
46
46
  delete require.cache[resolved];
@@ -105,7 +105,7 @@ test('turning off does NOT clear telemetry-sent.json or the watermarks — re-en
105
105
  expect(JSON.parse(sentPathContent).ids.length).toBeGreaterThan(0);
106
106
 
107
107
  // Also seed a watermarks file to prove opt-out leaves it untouched.
108
- const watermarksDir = path.join(home, '.config', 'session-manager');
108
+ const watermarksDir = path.join(home, '.claude', 'session-manager');
109
109
  await fsp.mkdir(watermarksDir, { recursive: true });
110
110
  const watermarksPath = path.join(watermarksDir, 'telemetry-watermarks.json');
111
111
  const watermarksBefore = { 'abc123/errors-2026-01-01.jsonl': { bytesEnqueued: 100, bytesConfirmed: 100 } };
@@ -37,7 +37,7 @@ async function mkHome() {
37
37
  process.env.HOME = dir;
38
38
  // Explicit opt-in: overrides the test-environment no-op guard so this
39
39
  // suite's real telemetryClient calls actually persist into an isolated dir.
40
- process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
40
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.claude', 'session-manager');
41
41
  return dir;
42
42
  }
43
43
 
@@ -238,6 +238,7 @@ async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLB
238
238
 
239
239
  module.exports = {
240
240
  FALLBACK_MODEL,
241
+ AGENT_BODY_CHAR_CAP,
241
242
  resolveEpicModel,
242
243
  findAgentTypeByClaudeSessionId,
243
244
  findEpicByClaudeSessionId,
@@ -120,6 +120,38 @@ function isExpiringSoon(creds, withinMs = 5 * 60_000) {
120
120
  return ms !== null && ms - Date.now() < withinMs;
121
121
  }
122
122
 
123
+ /**
124
+ * isRefreshFutile(creds) → boolean
125
+ *
126
+ * True when the token is already expired AND there is no refresh token to
127
+ * present. In that state every refresh avenue is a dead end: tryOAuthRefresh
128
+ * short-circuits to 'unsupported' (it needs `creds.refreshToken`), and the
129
+ * `claude --version` CLI fallback performs its own silent OAuth refresh from
130
+ * that SAME missing refresh token, so it cannot help either. The only recovery
131
+ * is an out-of-band `claude login` — which refreshIfNeeded picks up on its very
132
+ * next call via the isExpiringSoon early-return, since fresh creds never reach
133
+ * this path. Left ungated, this state made pollLoop spawn a `claude --version`
134
+ * process and write the oauth_refresh_unsupported → cli_fallback_ok →
135
+ * auth_failed_expired triple on EVERY 15 s poll, forever (observed 2026-09-23:
136
+ * expiredAtMs 1775277875159, identical triple every ~15 min across the log).
137
+ */
138
+ function isRefreshFutile(creds) {
139
+ return isExpired(creds) && !creds.refreshToken;
140
+ }
141
+
142
+ // Throttle for the futile-refresh state above: once seen, don't respawn the
143
+ // CLI fallback or re-log the auth triple on every poll. Re-probe at most once
144
+ // per this window (an external `claude login` is still caught immediately by
145
+ // refreshIfNeeded's isExpiringSoon early-return, and by the cheap re-read
146
+ // below — neither needs the storm). Mirrors loadGate.cjs's AUDIT_INTERVAL_MS.
147
+ const FUTILE_REFRESH_COOLDOWN_MS = 15 * 60_000;
148
+ let lastFutileRefreshLoggedAt = null;
149
+
150
+ /** Test-only: reset the futile-refresh throttle so each test starts clean. */
151
+ function __resetFutileRefreshThrottle() {
152
+ lastFutileRefreshLoggedAt = null;
153
+ }
154
+
123
155
  async function writeCredentials(rawData, freshOauth, source = 'file') {
124
156
  const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
125
157
  if (source === 'keychain') {
@@ -222,6 +254,33 @@ async function refreshIfNeeded(forceRefresh = false) {
222
254
 
223
255
  const alreadyExpired = isExpired(creds);
224
256
 
257
+ // Futile-refresh short-circuit: token already expired AND no refresh token
258
+ // to present. Every downstream avenue (OAuth grant, `claude --version` CLI
259
+ // fallback) is a guaranteed dead end from the same missing token, so running
260
+ // them on every 15 s poll only spawns a doomed child process and re-logs the
261
+ // same triple forever (2026-09-23 storm). Re-read once — cheap, and the ONE
262
+ // real recovery (`claude login`) lands here — then, if still futile, return
263
+ // the auth verdict WITHOUT the OAuth call or the CLI-fallback spawn, logging
264
+ // at most once per FUTILE_REFRESH_COOLDOWN_MS instead of every poll.
265
+ if (isRefreshFutile(creds)) {
266
+ const recheckCr = await readCredentials();
267
+ if (recheckCr.kind === 'ok' && !isRefreshFutile(recheckCr.creds) && !isExpired(recheckCr.creds)) {
268
+ appendRefreshLog({ event: 'externally_refreshed_ok', recheckExpiresAt: recheckCr.creds.expiresAt ?? null });
269
+ lastFutileRefreshLoggedAt = null;
270
+ return { kind: 'ok', creds: recheckCr.creds };
271
+ }
272
+ const nowMs = Date.now();
273
+ if (lastFutileRefreshLoggedAt === null || nowMs - lastFutileRefreshLoggedAt >= FUTILE_REFRESH_COOLDOWN_MS) {
274
+ lastFutileRefreshLoggedAt = nowMs;
275
+ appendRefreshLog({ event: 'auth_failed_expired_no_refresh_token', expiredAtMs: expiresAtMs(creds) });
276
+ }
277
+ return {
278
+ kind: 'auth',
279
+ message: 'Credentials expired and cannot be auto-refreshed (no refresh token). Run `claude` in a terminal to log in.',
280
+ expiredAt: expiresAtMs(creds),
281
+ };
282
+ }
283
+
225
284
  // Stretch: try OAuth refresh endpoint first.
226
285
  const oauthResult = await tryOAuthRefresh(creds);
227
286
  appendRefreshLog({ event: `oauth_refresh_${oauthResult.kind}`, message: oauthResult.message ?? null });
@@ -277,8 +336,11 @@ module.exports = {
277
336
  expiresAtMs,
278
337
  isExpired,
279
338
  isExpiringSoon,
339
+ isRefreshFutile,
280
340
  refreshIfNeeded,
281
341
  parseCredsRaw,
342
+ __resetFutileRefreshThrottle,
343
+ FUTILE_REFRESH_COOLDOWN_MS,
282
344
  KEYCHAIN_SERVICE,
283
345
  CREDS_PATH,
284
346
  };
@@ -152,6 +152,21 @@ function classifyCwd(cwd) {
152
152
  if (!cwd || typeof cwd !== 'string') {
153
153
  return { kind: 'unknown', projectRoot: null, outermostOpsRoot: null, innermostOpsRoot: null, reason: 'cwd is not a non-empty string' };
154
154
  }
155
+ // Must run BEFORE inspectGit: nearestGitEntry walks ancestors via
156
+ // fs.statSync, which silently resolves a relative `dir` against
157
+ // process.cwd() — so a relative cwd that happens to be a real subpath of
158
+ // WHATEVER PROCESS IS RUNNING THIS CODE (not the transcript's actual
159
+ // project) can walk up into a real `.git`, including a linked worktree's
160
+ // `.git` FILE, and return that worktree's real main-tree root as if it
161
+ // were the relative fragment's own project. The 'worktree' branch below
162
+ // returns early on g.shape alone with no absolute check of its own, so
163
+ // this guard cannot be deferred to after inspectGit runs (confirmed via
164
+ // opsRootAbsoluteCwd.test.cjs: reproduces in every git worktree, not just
165
+ // job worktrees — process.cwd() there is a worktree whose .git FILE
166
+ // resolves back to the real main tree root).
167
+ if (!path.isAbsolute(cwd)) {
168
+ return { kind: 'unknown', projectRoot: null, outermostOpsRoot: null, innermostOpsRoot: null, reason: 'cwd is not an absolute path' };
169
+ }
155
170
  const parts = cwd.split(path.sep);
156
171
  const first = parts.indexOf(OPS_DIRNAME);
157
172
  const last = parts.lastIndexOf(OPS_DIRNAME);
@@ -14,6 +14,7 @@ const path = require('node:path');
14
14
  const { spawn, spawnSync } = require('node:child_process');
15
15
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
16
16
  const { resolvePrdWriteDir } = require('./prdLocations.cjs');
17
+ const schedulerPaths = require('./schedulerPaths.cjs');
17
18
 
18
19
  // Regex identifying meta/dod slugs that must NOT influence the batchKey.
19
20
  // This is the load-bearing loop-avoidance filter: when the gate job itself
@@ -21,11 +22,6 @@ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
21
22
  // a no-op (idempotent) instead of re-firing forever.
22
23
  const DOD_SLUG_RE = /(^|-)dod(-|$)|definition-of-done/i;
23
24
 
24
- const RUNS_DIR = path.join(
25
- os.homedir(),
26
- '.claude', 'session-manager', 'scheduled-plans', 'runs'
27
- );
28
-
29
25
  /**
30
26
  * Compute a stable short hash for a completed job-set.
31
27
  *
@@ -62,7 +58,7 @@ function batchKey(jobs) {
62
58
  function reportPathFor(key) {
63
59
  if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
64
60
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
65
- return path.join(RUNS_DIR, ts, `definition-of-done-${key}.md`);
61
+ return path.join(schedulerPaths.runsDir(), ts, `definition-of-done-${key}.md`);
66
62
  }
67
63
 
68
64
  /**
@@ -70,10 +66,10 @@ function reportPathFor(key) {
70
66
  * run subdirectory. Scans runs/<ts>/ (shallow, one level).
71
67
  *
72
68
  * @param {string} key Output of batchKey()
73
- * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
69
+ * @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
74
70
  * @returns {boolean}
75
71
  */
76
- function reportExists(key, runsDir = RUNS_DIR) {
72
+ function reportExists(key, runsDir = schedulerPaths.runsDir()) {
77
73
  if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
78
74
  let entries;
79
75
  try {
@@ -604,7 +600,7 @@ const STATUS_EMOJI = { pass: '✅', fail: '❌', unverifiable: '⚠️' };
604
600
  function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
605
601
  if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
606
602
 
607
- const resolvedRunsDir = runsDir ?? RUNS_DIR;
603
+ const resolvedRunsDir = runsDir ?? schedulerPaths.runsDir();
608
604
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
609
605
  const dir = path.join(resolvedRunsDir, ts);
610
606
  const reportPath = path.join(dir, `definition-of-done-${key}.md`);
@@ -781,10 +777,10 @@ const WATERMARK_FILENAME = '.dod-watermark.json';
781
777
  * sidecar means "beginning of time" (process everything), matching the
782
778
  * pre-watermark unbounded behavior on first-ever drain.
783
779
  *
784
- * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
780
+ * @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
785
781
  * @returns {string|null} ISO8601 timestamp, or null if unset/unreadable.
786
782
  */
787
- function readWatermark(runsDir = RUNS_DIR) {
783
+ function readWatermark(runsDir = schedulerPaths.runsDir()) {
788
784
  try {
789
785
  const raw = fs.readFileSync(path.join(runsDir, WATERMARK_FILENAME), 'utf8');
790
786
  const parsed = JSON.parse(raw);
@@ -799,9 +795,9 @@ function readWatermark(runsDir = RUNS_DIR) {
799
795
  * jobs that completed after it.
800
796
  *
801
797
  * @param {string} lastFinishedAt ISO8601 timestamp.
802
- * @param {string} [runsDir] Override for testing; defaults to RUNS_DIR
798
+ * @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
803
799
  */
804
- function writeWatermark(lastFinishedAt, runsDir = RUNS_DIR) {
800
+ function writeWatermark(lastFinishedAt, runsDir = schedulerPaths.runsDir()) {
805
801
  fs.mkdirSync(runsDir, { recursive: true });
806
802
  _writeFileAtomic(
807
803
  path.join(runsDir, WATERMARK_FILENAME),
@@ -28,6 +28,7 @@ const path = require('node:path');
28
28
  const os = require('node:os');
29
29
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
30
30
  const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
31
+ const schedulerPaths = require('./schedulerPaths.cjs');
31
32
 
32
33
  // Deliberately stays a FIXED alias set (reviewed against the catalog-driven raw picker): it answers
33
34
  // "alias vs concrete id", not "which options to offer", so live catalog ids must not enter it.
@@ -51,13 +52,6 @@ const MAX_RUN_LOG_HEAD_BYTES = 64 * 1024;
51
52
  // pass over a transcript that can reach tens of MB. O(min(size, cap)).
52
53
  const MAX_TRANSCRIPT_TAIL_BYTES = 8 * 1024 * 1024;
53
54
 
54
- // Mirrors scheduler.cjs's own ROOT/RUNS_DIR constants (same convention as
55
- // queueStore.cjs's LEGACY_QUEUE_PATH, which duplicates this literal rather
56
- // than requiring scheduler.cjs's ~530KB module graph for one path).
57
- function runsDir(homeDir) {
58
- return path.join(homeDir, '.claude', 'session-manager', 'scheduled-plans', 'runs');
59
- }
60
-
61
55
  function isConcreteModelId(modelAlias) {
62
56
  if (!modelAlias) return false;
63
57
  // 'opus[1m]' is an alias with a context suffix; 'claude-opus-4-7[1m]' keeps its id.
@@ -232,10 +226,9 @@ function findLatestSchedulerModel(cwd, agentType, deps) {
232
226
  const matches = jobs.filter((j) => j && j.agentType === agentType && j.runId && j.slug);
233
227
  matches.sort((a, b) => jobTimestampMs(b) - jobTimestampMs(a));
234
228
 
235
- const homeDir = deps.homeDir || os.homedir();
236
229
  const re = /\[scheduler\] agentType=\S+ persona=.*? model=(\S+)/g;
237
230
  for (const job of matches) {
238
- const logPath = path.join(runsDir(homeDir), job.runId, `${job.slug}.log`);
231
+ const logPath = path.join(schedulerPaths.runsDir(), job.runId, `${job.slug}.log`);
239
232
  const head = readHead(logPath, MAX_RUN_LOG_HEAD_BYTES, deps);
240
233
  if (!head) continue;
241
234
  let match;
@@ -2,21 +2,30 @@
2
2
 
3
3
  /**
4
4
  * epicWorktreeProjectConfig.cjs — per-project UI toggle for Epic worktree
5
- * isolation (PRD 1035, final link of the epic-worktree-isolation chain).
5
+ * isolation (PRD 1035, final link of the epic-worktree-isolation chain;
6
+ * moved off the machine-wide global map onto the per-project ops root by
7
+ * PRD 1390).
6
8
  *
7
9
  * `SM_EPIC_WORKTREE_DISABLE` (gitWorktree.cjs) is an env var, checked once at
8
10
  * process start — there was previously no way to turn the feature off for
9
11
  * one project from the app itself. This module is that per-project knob:
10
- * a single JSON file mapping project cwd -> disabled, read by
12
+ * `<cwd>/session-manager-operations/prompt-sessions/epic-worktree-config.json`
13
+ * holding `{ disabled: boolean }` for that one project, read by
11
14
  * gitWorktree.cjs's `isWorktreeDisabled('epic', cwd)` on every worktree
12
15
  * creation attempt, and read/written by Settings.tsx's per-project toggle
13
16
  * over the two IPC handlers registered below.
14
17
  *
15
- * Lives under `~/.claude/session-manager/` — machine-runtime bookkeeping,
16
- * the same tier as scheduler-machine.json (see queueStore.cjs's header
17
- * comment) — NOT under any project's `session-manager-operations/`, so the
18
- * single-writer OWNERS law (opsOwnership.cjs) doesn't apply here; this file
19
- * only ever has one writer (this module) by construction.
18
+ * By shape the old single global file (`{ "<project-cwd>": true|false }` under
19
+ * `~/.claude/session-manager/`) was per-project config keyed by project path —
20
+ * the wrong tier. It now lives in the `prompt-sessions` ops namespace, already
21
+ * owned by writer 'epics' (opsOwnership.cjs), the same as every other Epic
22
+ * record. Migration is lossless and one-shot: on first read for a cwd whose
23
+ * new per-project file doesn't exist yet, the legacy global map is checked for
24
+ * that cwd's key; if present (the map only ever stores `true` — `false` is
25
+ * represented by key absence), the new file is seeded with it. The legacy file
26
+ * itself is left in place — untouched, never deleted — so any OTHER project's
27
+ * still-unmigrated key keeps resolving correctly until that project is itself
28
+ * touched.
20
29
  *
21
30
  * Plain Node (no Electron deps in the read/write helpers) so gitWorktree.cjs
22
31
  * — itself Electron-free — can require this lazily without pulling Electron
@@ -26,19 +35,22 @@
26
35
  const fs = require('node:fs');
27
36
  const path = require('node:path');
28
37
  const os = require('node:os');
38
+ const { assertOpsWrite, opsPath } = require('./opsOwnership.cjs');
29
39
 
30
- const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'epic-worktree-project-config.json');
40
+ // The old machine-wide map. Read-only from here on (migration source only) —
41
+ // never written again, and deliberately never deleted so unmigrated projects
42
+ // keep working. Resolved per-call (not a frozen const) so tests can point
43
+ // this at a throwaway tmpdir file via SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH
44
+ // instead of touching the real machine-level file.
45
+ const DEFAULT_LEGACY_CONFIG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'epic-worktree-project-config.json');
31
46
 
32
- // Resolved per-call (not a frozen const) so tests can point this at a
33
- // throwaway tmpdir file via SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH instead of
34
- // mutating the real machine-level file at DEFAULT_CONFIG_PATH.
35
- function configPath() {
36
- return process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH || DEFAULT_CONFIG_PATH;
47
+ function legacyConfigPath() {
48
+ return process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH || DEFAULT_LEGACY_CONFIG_PATH;
37
49
  }
38
50
 
39
- function readConfig() {
51
+ function readLegacyConfig() {
40
52
  try {
41
- const raw = fs.readFileSync(configPath(), 'utf8');
53
+ const raw = fs.readFileSync(legacyConfigPath(), 'utf8');
42
54
  const parsed = JSON.parse(raw);
43
55
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
44
56
  } catch {
@@ -46,31 +58,62 @@ function readConfig() {
46
58
  }
47
59
  }
48
60
 
49
- function writeConfig(config) {
50
- const file = configPath();
61
+ /** `<cwd>/session-manager-operations/prompt-sessions/epic-worktree-config.json` */
62
+ function perProjectConfigPath(cwd) {
63
+ return opsPath(cwd, 'prompt-sessions', 'epic-worktree-config.json');
64
+ }
65
+
66
+ function readPerProjectConfigFile(cwd) {
67
+ try {
68
+ const raw = fs.readFileSync(perProjectConfigPath(cwd), 'utf8');
69
+ const parsed = JSON.parse(raw);
70
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function writePerProjectConfig(cwd, config, writer = 'epics') {
77
+ const file = perProjectConfigPath(cwd);
78
+ assertOpsWrite(file, writer);
51
79
  fs.mkdirSync(path.dirname(file), { recursive: true });
52
80
  const tmp = `${file}.tmp-${process.pid}`;
53
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2));
81
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
54
82
  fs.renameSync(tmp, file);
55
83
  }
56
84
 
85
+ /**
86
+ * One-shot migration: when the per-project file doesn't exist yet, seed it
87
+ * from the legacy global map's entry for this cwd (present only when it was
88
+ * explicitly disabled — the legacy map deletes the key on re-enable). Never
89
+ * touches the legacy file itself.
90
+ */
91
+ function readOrMigratePerProjectConfig(cwd) {
92
+ const existing = readPerProjectConfigFile(cwd);
93
+ if (existing) return existing;
94
+ const legacyValue = readLegacyConfig()[cwd];
95
+ if (legacyValue !== true) return { disabled: false };
96
+ const seeded = { disabled: true };
97
+ writePerProjectConfig(cwd, seeded);
98
+ return seeded;
99
+ }
100
+
57
101
  /** True when the project at `cwd` has explicitly turned Epic worktree isolation off. */
58
102
  function isEpicWorktreeDisabledForProject(cwd) {
59
103
  if (!cwd || typeof cwd !== 'string') return false;
60
- return readConfig()[cwd] === true;
104
+ try {
105
+ return readOrMigratePerProjectConfig(cwd).disabled === true;
106
+ } catch {
107
+ return false;
108
+ }
61
109
  }
62
110
 
63
- /** Persists (or clears, when `disabled` is false) the per-project toggle. */
64
- function setEpicWorktreeDisabledForProject(cwd, disabled) {
111
+ /** Persists the per-project toggle. */
112
+ function setEpicWorktreeDisabledForProject(cwd, disabled, writer = 'epics') {
65
113
  if (!cwd || typeof cwd !== 'string') throw new Error('setEpicWorktreeDisabledForProject: cwd is required');
66
- const config = readConfig();
67
- if (disabled) {
68
- config[cwd] = true;
69
- } else {
70
- delete config[cwd];
71
- }
72
- writeConfig(config);
73
- return disabled;
114
+ const value = !!disabled;
115
+ writePerProjectConfig(cwd, { disabled: value }, writer);
116
+ return value;
74
117
  }
75
118
 
76
119
  function registerEpicWorktreeProjectConfigHandlers() {
@@ -94,8 +137,9 @@ function registerEpicWorktreeProjectConfigHandlers() {
94
137
  }
95
138
 
96
139
  module.exports = {
97
- DEFAULT_CONFIG_PATH,
98
- configPath,
140
+ DEFAULT_LEGACY_CONFIG_PATH,
141
+ legacyConfigPath,
142
+ perProjectConfigPath,
99
143
  isEpicWorktreeDisabledForProject,
100
144
  setEpicWorktreeDisabledForProject,
101
145
  registerEpicWorktreeProjectConfigHandlers,
@@ -57,12 +57,36 @@ function isLoadGated(loadavg1, cores, threshold) {
57
57
  return loadavg1 / cores > threshold;
58
58
  }
59
59
 
60
- /** Best-effort top-N CPU consumers (Linux only). Returns [] anywhere else or on error. */
61
- function topCpuConsumers(n = 3) {
62
- if (process.platform !== 'linux') return [];
60
+ /**
61
+ * Best-effort top-N CPU consumers, for the audit line the scheduler warn-logs
62
+ * when the load gate has held jobs past its escalation window. Diagnostic only
63
+ * — nothing downstream branches on the result, so it fails to [] rather than
64
+ * throwing. Supported on Linux AND macOS (the two platforms this app targets);
65
+ * [] anywhere else or on any error.
66
+ *
67
+ * The `ps` invocation is platform-specific and NOT interchangeable:
68
+ * - Linux (GNU/procps): `ps -eo pid,pcpu,comm --sort=-pcpu`. `--sort` is a
69
+ * GNU long option.
70
+ * - macOS (BSD ps): has NO `--sort` (`ps: illegal option -- -`, which threw
71
+ * and left the gate audit logging `top CPU: n/a` on every Mac). BSD sorts
72
+ * with `-r` (by CPU usage) and takes `-A` for all processes, `-c` for the
73
+ * executable comm without its path. `ps -Aco pid,pcpu,comm -r` is the
74
+ * column-and-sort equivalent.
75
+ * Both print a `PID %CPU COMM` header we skip, so downstream parsing is shared.
76
+ * `execImpl` is injected only by the unit test; production uses execFileSync.
77
+ */
78
+ function topCpuConsumers(n = 3, { execImpl = execFileSync, platform = process.platform } = {}) {
79
+ let args;
80
+ if (platform === 'linux') {
81
+ args = ['-eo', 'pid,pcpu,comm', '--sort=-pcpu'];
82
+ } else if (platform === 'darwin') {
83
+ args = ['-Aco', 'pid,pcpu,comm', '-r'];
84
+ } else {
85
+ return [];
86
+ }
63
87
  try {
64
- const out = execFileSync('ps', ['-eo', 'pid,pcpu,comm', '--sort=-pcpu'], { encoding: 'utf8', timeout: 2000 });
65
- return out.split('\n').slice(1, 1 + n).map((l) => l.trim()).filter(Boolean);
88
+ const out = execImpl('ps', args, { encoding: 'utf8', timeout: 2000 });
89
+ return String(out).split('\n').slice(1, 1 + n).map((l) => l.trim()).filter(Boolean);
66
90
  } catch {
67
91
  return [];
68
92
  }
@@ -60,6 +60,15 @@ const OWNERS = Object.freeze({
60
60
  // bilko-host-publisher Epic authors beyond dist/ is agent-Write-tool
61
61
  // output, same unenforceable-by-construction class as project-pages/output.
62
62
  'bilko-host': 'bilko-host',
63
+ // Memory Clusters owns its own regenerable per-project cache (PRD 1389) —
64
+ // memoryAggregate.cjs's clustering result, rebuilt on any explicit
65
+ // refresh:true. See memory-clusters/README.md.
66
+ 'memory-clusters': 'memory-clusters',
67
+ // Per-project UI state (PRD 1398) — small renderer prefs that used to live
68
+ // in cwd-scoped or (worse) global localStorage keys. Written directly by
69
+ // the renderer through the generic config:write-json IPC with writer:
70
+ // 'ui-prefs'; no dedicated main-process module. See ui-prefs/README.md.
71
+ 'ui-prefs': 'ui-prefs',
63
72
  });
64
73
 
65
74
  /**
@@ -170,6 +179,20 @@ function assertOpsWrite(absPath, writer) {
170
179
  // worktree-cwd hazard (PRD 1082; incidents 2026-08-30, 2026-09-01).
171
180
  const { inOps } = parseOpsPath(absPath);
172
181
  if (inOps) {
182
+ // classifyCwd fails closed on a non-absolute absPath (returns
183
+ // innermostOpsRoot: null) — path.dirname(null) throws a raw, untagged
184
+ // TypeError, which would crash this "last line of defense" instead of
185
+ // producing the graceful, catchable refusal every other branch here
186
+ // gives. A caller that skips the absolute-path helpers (opsPath/
187
+ // resolveProjectRoot) and hands assertOpsWrite a relative fragment
188
+ // containing an OPS_ROOT_DIR segment must still fail closed, not crash.
189
+ if (typeof absPath !== 'string' || !path.isAbsolute(absPath)) {
190
+ const err = new Error(
191
+ `refusing to write ${OPS_ROOT_DIR}/ state: absPath must be absolute, got "${absPath}"`,
192
+ );
193
+ err.unknownCwd = true;
194
+ throw err;
195
+ }
173
196
  // innermostOpsRoot (lastIndexOf) is the fact THIS gate keys on; activeSessions
174
197
  // truncates at the outermost instead — cwdClassify returns both, unified in
175
198
  // neither direction.
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * planValidator.cjs — pure predicate telling a work-item's dispatch whether a
5
+ * plan-level `validator` job (PRD 1405/1407) will re-review its diff once the
6
+ * plan finishes, so the per-job finish protocol and per-PRD validation prompt
7
+ * (PRD 986) can both yield to it instead of duplicating the same review. No
8
+ * filesystem or scheduler knowledge — the caller supplies the queue rows.
9
+ */
10
+
11
+ const { bareSlug } = require('./depSlugResolve.cjs');
12
+
13
+ /**
14
+ * hasDownstreamValidator(job, jobs) → boolean
15
+ *
16
+ * True iff `jobs` contains a row with `agentType === 'validator'`, status
17
+ * `pending` or `running`, that reaches `job.slug` transitively by walking
18
+ * `dependsOn` edges through the supplied rows (e.g. validator -> sink ->
19
+ * job). A validator job itself never has a downstream validator (it IS the
20
+ * plan's validation pass). Plans over the `dependsOn` cap point the
21
+ * validator at only the plan's sink PRDs, so a direct-only check would
22
+ * wrongly say every non-sink work item has no downstream validator.
23
+ *
24
+ * `jobs` may be the machine-wide queue (every project's rows merged), so
25
+ * when `job.cwd` is known the walk excludes rows carrying a DIFFERENT
26
+ * explicit `cwd` — a same-bare-named PRD in an unrelated project can never
27
+ * satisfy this job's validator check. A row with no `cwd` recorded is kept
28
+ * (queue rows may omit it; `scheduleJobSchema.cjs` makes it optional) rather
29
+ * than guessed at, since a wrong exclusion (a real validator missed) is far
30
+ * worse than the narrow residual risk of an unstamped row colliding by bare
31
+ * name. Slug matching within that scope reuses the canonical
32
+ * `depSlugResolve.cjs` rule (exact slug, else bare-name with the leading
33
+ * `NN-` stripped) so this walk agrees with the scheduler's own dependency
34
+ * gate on what a `dependsOn` entry resolves to.
35
+ */
36
+ function hasDownstreamValidator(job, jobs) {
37
+ if (!job || !Array.isArray(jobs)) return false;
38
+ if (job.agentType === 'validator') return false;
39
+
40
+ const projectJobs = job.cwd
41
+ ? jobs.filter((row) => row && (row.cwd == null || row.cwd === job.cwd))
42
+ : jobs.filter(Boolean);
43
+
44
+ const rowBySlug = new Map();
45
+ const rowsByBareSlug = new Map();
46
+ for (const row of projectJobs) {
47
+ rowBySlug.set(row.slug, row);
48
+ const bare = bareSlug(row.slug);
49
+ if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
50
+ rowsByBareSlug.get(bare).push(row);
51
+ }
52
+ const rowsForSlug = (slug) => {
53
+ const exact = rowBySlug.get(slug);
54
+ if (exact) return [exact];
55
+ return rowsByBareSlug.get(bareSlug(slug)) ?? [];
56
+ };
57
+ const matchesJob = (slug) => slug === job.slug || bareSlug(slug) === bareSlug(job.slug);
58
+
59
+ const validators = projectJobs.filter((row) => row.agentType === 'validator'
60
+ && (row.status === 'pending' || row.status === 'running'));
61
+
62
+ for (const validator of validators) {
63
+ const visited = new Set();
64
+ const queue = Array.isArray(validator.dependsOn) ? [...validator.dependsOn] : [];
65
+ while (queue.length) {
66
+ const slug = queue.shift();
67
+ if (visited.has(slug)) continue;
68
+ visited.add(slug);
69
+ if (matchesJob(slug)) return true;
70
+ for (const row of rowsForSlug(slug)) {
71
+ for (const depSlug of row.dependsOn ?? []) {
72
+ if (!visited.has(depSlug)) queue.push(depSlug);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
80
+ module.exports = { hasDownstreamValidator };
@@ -31,6 +31,7 @@ const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentT
31
31
  const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
32
32
  const { isFixPlanSlug } = require('./fixPlanSlug.cjs');
33
33
  const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
34
+ const { sizingWarnings } = require('./prdSizing.cjs');
34
35
 
35
36
  // A caller-supplied slug that already starts with its own `NN-` (e.g.
36
37
  // "254-perf-x") used to silently become the double-prefixed row
@@ -133,10 +134,10 @@ function buildPrdBody(input) {
133
134
  const oosLines = oosSource.map((line) => `- ${line}`).join('\n');
134
135
 
135
136
  const standardsPointer = [
136
- `Before writing any code, read \`${STANDARDS_PATH}\` — it has the Performance, Debugging,`,
137
- 'API-reuse, TDD, and Execution-discipline rules that apply to this PRD. Every rule in it is',
138
- 'mandatory, especially Execution discipline (bounded commands, verify before done, the',
139
- 'finish-protocol sentinel).',
137
+ 'Your system prompt carries the ordered run contract.',
138
+ `\`${STANDARDS_PATH}\` holds the reasoning behind each contract line (Performance, Debugging,`,
139
+ 'API reuse, TDD, Execution discipline) — read the section a line points at when it is unclear;',
140
+ 'do not re-read the whole file every run.',
140
141
  ].join('\n');
141
142
 
142
143
  const bodyLines = [
@@ -496,6 +497,8 @@ async function createPrd(input, remote) {
496
497
  epicId: writeResult.epicId ?? null,
497
498
  enqueued: false,
498
499
  note: 'PRD file written; the queue row is derived by the next scheduler reconcile pass, not created here',
500
+ // Advisory only (PRD 1403) — never blocks the write. See prdSizing.cjs.
501
+ warnings: sizingWarnings(input),
499
502
  };
500
503
  }
501
504
 
@@ -541,6 +544,7 @@ function registerAdminRoute(adminHttp, remote) {
541
544
  epicId: result.epicId ?? null,
542
545
  enqueued: false,
543
546
  note: result.note,
547
+ warnings: result.warnings,
544
548
  });
545
549
  });
546
550
  }