claude-code-session-manager 0.122.0 → 0.123.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 (93) hide show
  1. package/dist/assets/{AgentLibrary-BmJ8hIPw.js → AgentLibrary-DxWN3FSN.js} +2 -2
  2. package/dist/assets/{Button-BS4kmz4p.js → Button-CISLFSKb.js} +1 -1
  3. package/dist/assets/DataModel-CYPbhGrx.js +1 -0
  4. package/dist/assets/{History-pg00b2Gd.js → History-Fhouektw.js} +2 -2
  5. package/dist/assets/{Hooks-BTEs0UZY.js → Hooks-COfIcyYV.js} +3 -3
  6. package/dist/assets/{Library-DuaJ-VSn.js → Library-Bt5WOyyu.js} +7 -7
  7. package/dist/assets/ListDetail-DH7uzYd5.js +1 -0
  8. package/dist/assets/{McpServers-Dy-wnG_X.js → McpServers-CwyVEzMN.js} +2 -2
  9. package/dist/assets/{Memory-Bs1T1Jo7.js → Memory-k9TWT9N4.js} +8 -8
  10. package/dist/assets/{Panel-8UhLXEOW.js → Panel-Di8JqCYN.js} +1 -1
  11. package/dist/assets/{Permissions-BfwzvmBS.js → Permissions-FFs6Rq1H.js} +3 -3
  12. package/dist/assets/{Plugins-McjyvvKh.js → Plugins-uK5vl_RE.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-D0Ov_6WJ.js → ProvenanceBadge-CGDyGDoN.js} +1 -1
  14. package/dist/assets/{SaveBar-Cd6hJR6K.js → SaveBar-Cnwc_XfF.js} +1 -1
  15. package/dist/assets/{Scheduler-BZXKqYJP.js → Scheduler-aACa6_mC.js} +7 -7
  16. package/dist/assets/{ScopeSwitcher-C24ysV1K.js → ScopeSwitcher-CoG3CSlC.js} +1 -1
  17. package/dist/assets/Settings-BG0RM6Di.js +4 -0
  18. package/dist/assets/{SkillReferenceGraph-BsWuiTbw.js → SkillReferenceGraph-COffGu_A.js} +1 -1
  19. package/dist/assets/Skills--qQpFjjf.js +2 -0
  20. package/dist/assets/SystemPrompt-Cr9RRSiZ.js +1 -0
  21. package/dist/assets/TagLibrary-D_ZbXwBv.js +1 -0
  22. package/dist/assets/{TiptapBody-C4kIvIMH.js → TiptapBody-CCHsa8Jv.js} +1 -1
  23. package/dist/assets/{Toggle-pP19zlyk.js → Toggle-BUOj4pws.js} +1 -1
  24. package/dist/assets/{ViewTabs-CpzN8e5I.js → ViewTabs-BdpsL7yr.js} +1 -1
  25. package/dist/assets/{historyTotals-Caq78bb5.js → historyTotals-Dx6ca881.js} +1 -1
  26. package/dist/assets/{index-Dd7UQabV.css → index-BnKaYERU.css} +1 -1
  27. package/dist/assets/{index-Bjtguv6I.js → index-CkNGj8FA.js} +151 -151
  28. package/dist/assets/{settingsSchema-CC2ySu3d.js → settingsSchema-BZhkpr9e.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-hDhNo8jE.js → skillFrontmatter-N13KGqbs.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-BuOtY25U.js → useAgentsDirWatch-De9UsbDt.js} +1 -1
  31. package/dist/index.html +2 -2
  32. package/package.json +1 -1
  33. package/plugins/session-manager-dev/skills/develop/SKILL.md +29 -24
  34. package/scripts/scheduler-mcp-server.cjs +142 -78
  35. package/src/main/agentLibrary.cjs +8 -3
  36. package/src/main/build-info.json +4 -4
  37. package/src/main/crashDiagnostics.cjs +24 -0
  38. package/src/main/historyAggregator.cjs +41 -7
  39. package/src/main/index.cjs +13 -3
  40. package/src/main/ipcSchemas.cjs +30 -22
  41. package/src/main/lib/activeSessions.cjs +93 -32
  42. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  43. package/src/main/lib/epic/delegationSelfHeal.cjs +77 -0
  44. package/src/main/lib/epic/epicMint.cjs +1 -1
  45. package/src/main/lib/epic/epicStandingInstructions.cjs +73 -0
  46. package/src/main/lib/epic/guardSpawnSettings.cjs +76 -0
  47. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +9 -2
  48. package/src/main/lib/feedback-remote/remoteClientSessions.cjs +3 -11
  49. package/src/main/lib/feedback-remote/remoteE2eCrypto.cjs +1 -1
  50. package/src/main/lib/git/gitWorktree.cjs +9 -4
  51. package/src/main/lib/git/worktreeDeps.cjs +13 -2
  52. package/src/main/lib/git/worktreeSetup.cjs +212 -0
  53. package/src/main/lib/mcpToolCatalog.cjs +36 -9
  54. package/src/main/lib/persona-macro/agentPersonaSchema.cjs +4 -0
  55. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +23 -0
  56. package/src/main/lib/prd/prdAgentType.cjs +28 -0
  57. package/src/main/lib/prd/prdBodyPatch.cjs +138 -0
  58. package/src/main/lib/prd/prdCreate.cjs +245 -27
  59. package/src/main/lib/prd/prdGateFiles.cjs +31 -0
  60. package/src/main/lib/scheduler/definitionOfDone.cjs +24 -3
  61. package/src/main/lib/scheduler/queueStore.cjs +5 -4
  62. package/src/main/lib/scheduler/scheduleJobSchema.cjs +1 -0
  63. package/src/main/lib/scheduler/schedulerBatch.cjs +43 -5
  64. package/src/main/lib/scheduler/schedulerPaths.cjs +2 -0
  65. package/src/main/lib/scheduler/watchdogHelpers.cjs +114 -22
  66. package/src/main/lib/telemetry/mainHeapSnapshot.cjs +69 -0
  67. package/src/main/lib/telemetry/telemetryBoot.cjs +25 -3
  68. package/src/main/lib/telemetry/telemetryClient.cjs +12 -0
  69. package/src/main/lib/telemetry/telemetryCounters.cjs +25 -2
  70. package/src/main/pty.cjs +0 -53
  71. package/src/main/runVerify.cjs +38 -11
  72. package/src/main/scheduler/prdParser.cjs +3 -0
  73. package/src/main/scheduler/prdPaths.cjs +48 -2
  74. package/src/main/scheduler.cjs +135 -31
  75. package/src/main/seedDevPlugin.cjs +1 -1
  76. package/src/main/seedSchedulerMcp.cjs +1 -1
  77. package/src/main/templates/PRD_AUTHORING.md +8 -4
  78. package/src/main/webRemote.cjs +38 -132
  79. package/src/preload/api.d.ts +4 -52
  80. package/src/preload/index.cjs +0 -24
  81. package/src/seed/agents/dev-lead.md +2 -1
  82. package/src/seed/agents/validator.md +2 -1
  83. package/dist/assets/DataModel-DZHBZrfL.js +0 -1
  84. package/dist/assets/ListDetail-Cud72s-O.js +0 -1
  85. package/dist/assets/RemoteMachines-gOlFTvWq.js +0 -3
  86. package/dist/assets/Settings-DWnN2qxn.js +0 -4
  87. package/dist/assets/Skills-6pHn6glu.js +0 -2
  88. package/dist/assets/SystemPrompt-DT0LBH-K.js +0 -1
  89. package/dist/assets/TagLibrary-DbJ0zbYL.js +0 -1
  90. package/dist/assets/useRemotePeers-CNQmRBpt.js +0 -1
  91. package/src/main/lib/feedback-remote/relayClientAuth.cjs +0 -133
  92. package/src/main/remoteClient.cjs +0 -334
  93. package/src/main/remoteClientIpc.cjs +0 -133
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ /**
3
+ * epicStandingInstructions.cjs — turns an Epic record into the text Session
4
+ * Manager passes as --append-system-prompt on EVERY turn of that Epic, so the
5
+ * Actor's operating rules survive later turns and compaction.
6
+ *
7
+ * buildEpicStandingInstructions is pure; the persona reader is injected.
8
+ */
9
+
10
+ const HEADER = 'Standing instructions for this Epic (re-sent every turn by Session Manager):';
11
+ const MAX_CHARS = 12000;
12
+ const TRUNCATION_NOTICE = '\n\n[…standing instructions truncated at 12000 characters]';
13
+ const INCLUDED_KINDS = new Set(['actor', 'persona-body', 'injection', 'mission']);
14
+
15
+ // Must equal CONTEXT_INJECTIONS['delegate-implementation'].text in
16
+ // src/renderer/lib/contextInjections.ts (guarded by delegateTextDrift.test.ts).
17
+ const DELEGATE_IMPLEMENTATION_TEXT =
18
+ 'This interactive session plans and decides; it does not implement. Once scope is clear, ' +
19
+ 'queue the implementation as scheduled PRDs via the /develop skill and let it run headless — ' +
20
+ 'do not edit application source inline in this conversation. If the scheduler tools are ' +
21
+ 'unavailable, say so explicitly rather than falling back to implementing the work yourself.';
22
+
23
+ function cap(text) {
24
+ if (text.length <= MAX_CHARS) return text;
25
+ return text.slice(0, MAX_CHARS - TRUNCATION_NOTICE.length) + TRUNCATION_NOTICE;
26
+ }
27
+
28
+ function buildEpicStandingInstructions(epic, { readPersonaBody } = {}) {
29
+ if (!epic || typeof epic !== 'object') return null;
30
+ const sections = Array.isArray(epic.sections) ? epic.sections : [];
31
+ const parts = [];
32
+ if (sections.length > 0) {
33
+ for (const s of sections) {
34
+ if (!s || !INCLUDED_KINDS.has(s.kind)) continue;
35
+ const t = typeof s.text === 'string' ? s.text.trim() : '';
36
+ if (t) parts.push(t);
37
+ }
38
+ } else if (epic.agentType && typeof readPersonaBody === 'function') {
39
+ let body = null;
40
+ try { body = readPersonaBody(epic.agentType); } catch { body = null; }
41
+ const t = typeof body === 'string' ? body.trim() : '';
42
+ if (t) parts.push(t);
43
+ }
44
+
45
+ const wantsDelegate = epic.tag === 'feature' || epic.tag === 'bug' || epic.agentType === 'architect';
46
+ const hasDelegate = sections.some(
47
+ (s) => s && s.kind === 'injection' && s.source === 'delegate-implementation',
48
+ );
49
+ if (wantsDelegate && !hasDelegate) parts.push(DELEGATE_IMPLEMENTATION_TEXT);
50
+
51
+ if (parts.length === 0) return null;
52
+ return cap(`${HEADER}\n\n${parts.join('\n\n')}`);
53
+ }
54
+
55
+ /** Overlay-aware persona body (project .claude/agents wins over ~/.claude/agents), frontmatter stripped. */
56
+ function defaultReadPersonaBody(cwd, name) {
57
+ try {
58
+ const { resolveMergedPersona } = require('../persona-macro/personaMerge.cjs');
59
+ const merged = resolveMergedPersona(cwd, name);
60
+ const body = merged && typeof merged.body === 'string' ? merged.body.trim() : '';
61
+ return body || null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ module.exports = {
68
+ buildEpicStandingInstructions,
69
+ defaultReadPersonaBody,
70
+ DELEGATE_IMPLEMENTATION_TEXT,
71
+ HEADER,
72
+ MAX_CHARS,
73
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * guardSpawnSettings.cjs — builds the Claude Code settings object carrying
3
+ * Session Manager's four PreToolUse guard hooks for ONE claude spawn, and
4
+ * writes it to a stable file for `claude --settings <file>`.
5
+ *
6
+ * Why: Epic chats and scheduler jobs run in git worktrees that carry only
7
+ * tracked files, so guards written into an untracked project
8
+ * .claude/settings.json never load there; `--settings` does.
9
+ */
10
+ 'use strict';
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const os = require('node:os');
15
+ const crypto = require('node:crypto');
16
+ const { GUARD_DEFS, checkGuard } = require('./delegationReadiness.cjs');
17
+ const { shimPath: guardShimPath, ensureGuardShimsOrError } = require('../guardShims.cjs');
18
+ const { nodeShellCommand } = require('../appRuntime.cjs');
19
+ const { writeTextAtomic } = require('../util/atomicFs.cjs');
20
+
21
+ /**
22
+ * @param {{ spawnCwd: string, homeDir?: string }} opts
23
+ * @returns {{ hooks: { PreToolUse: Array<{ matcher: string, hooks: Array<{ type: string, command: string }> }> } } | null}
24
+ */
25
+ function buildGuardSpawnSettings({ spawnCwd, homeDir = os.homedir() }) {
26
+ const byMatcher = new Map();
27
+ for (const def of Object.values(GUARD_DEFS)) {
28
+ if (checkGuard(def, { cwd: spawnCwd, homeDir }).ok) continue;
29
+ if (!byMatcher.has(def.matcher)) byMatcher.set(def.matcher, []);
30
+ byMatcher.get(def.matcher).push({
31
+ type: 'command',
32
+ command: nodeShellCommand(guardShimPath(def.scriptBasename, homeDir)),
33
+ });
34
+ }
35
+ if (byMatcher.size === 0) return null;
36
+ return {
37
+ hooks: {
38
+ PreToolUse: [...byMatcher].map(([matcher, hooks]) => ({ matcher, hooks })),
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Write the settings to <homeDir>/.claude/session-manager/spawn-settings/
45
+ * guards-<sha1[0..12]>.json (content-addressed) and return its absolute path.
46
+ * Returns null (logged, never thrown) when nothing needs injecting or the
47
+ * shims cannot be ensured.
48
+ *
49
+ * @param {{ spawnCwd: string, homeDir?: string, deps?: { ensureShims?: (homeDir: string) => Promise<any> } }} opts
50
+ * @returns {Promise<string | null>}
51
+ */
52
+ async function writeGuardSpawnSettingsFile({ spawnCwd, homeDir = os.homedir(), deps = {} }) {
53
+ const ensureShims = deps.ensureShims || ensureGuardShimsOrError;
54
+ try {
55
+ const settings = buildGuardSpawnSettings({ spawnCwd, homeDir });
56
+ if (!settings) return null;
57
+ const shimError = await ensureShims(homeDir);
58
+ if (shimError) {
59
+ console.warn(`[guardSpawnSettings] guard shims unavailable: ${shimError.error ?? shimError}`);
60
+ return null;
61
+ }
62
+ const content = `${JSON.stringify(settings, null, 2)}\n`;
63
+ const hash = crypto.createHash('sha1').update(content).digest('hex').slice(0, 12);
64
+ const dir = path.join(homeDir, '.claude', 'session-manager', 'spawn-settings');
65
+ const file = path.join(dir, `guards-${hash}.json`);
66
+ if (fs.existsSync(file)) return file;
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ await writeTextAtomic(file, content);
69
+ return file;
70
+ } catch (err) {
71
+ console.warn(`[guardSpawnSettings] could not write spawn settings: ${err?.message ?? err}`);
72
+ return null;
73
+ }
74
+ }
75
+
76
+ module.exports = { buildGuardSpawnSettings, writeGuardSpawnSettingsFile };
@@ -79,6 +79,7 @@ const {
79
79
  const { appendAuditEvent } = require('../telemetry/auditLog.cjs');
80
80
  const { OPS_ROOT_DIR } = require('../opsOwnership.cjs');
81
81
  const { resolveProjectContext } = require('../projectRootResolve.cjs');
82
+ const { isEphemeralCwd } = require('../ephemeralCwd.cjs');
82
83
 
83
84
  /** Only these three Epic tags make sense for an inbound report — they are
84
85
  * exactly the tags that route through /develop (tagLibrary.ts). 'build' and
@@ -680,8 +681,14 @@ async function listFeedbackTargets({ limit = TARGET_SCAN_LIMIT, projectsDir } =
680
681
 
681
682
  const byCwd = new Map();
682
683
  for (const { full, mtimeMs } of scanned) {
683
- const cwd = await resolveCwdFromTranscriptDir(full);
684
- if (!cwd) continue;
684
+ const rawCwd = await resolveCwdFromTranscriptDir(full);
685
+ if (!rawCwd) continue;
686
+ // Job/Epic worktrees carry a tracked operations root too, so each would
687
+ // list as a project. Collapse to the main checkout (same resolver the
688
+ // admin routes use); a worktree whose main checkout can't be proven
689
+ // stays ephemeral/worktree-shaped and is dropped, never listed.
690
+ const cwd = resolveProjectContext({ cwd: rawCwd }).cwd;
691
+ if (!cwd || isEphemeralCwd(cwd)) continue;
685
692
  const prior = byCwd.get(cwd);
686
693
  if (!prior || prior.lastActivity < mtimeMs) byCwd.set(cwd, { cwd, lastActivity: mtimeMs });
687
694
  }
@@ -24,14 +24,12 @@ function clientKeyOf(envelope) {
24
24
  return LEGACY_CLIENT_KEY
25
25
  }
26
26
 
27
- function makeRecord(key, clientId) {
27
+ function makeRecord(key) {
28
28
  return {
29
29
  key,
30
- clientId: typeof clientId === 'string' ? clientId : null,
31
30
  e2e: makeState(),
32
31
  peerPubKey: null,
33
32
  deviceId: null,
34
- ptyStreams: new Map(),
35
33
  subscriptions: new Set(),
36
34
  lastSessionListJson: null,
37
35
  }
@@ -43,10 +41,6 @@ function createClientSessions() {
43
41
  function teardown(key) {
44
42
  const rec = records.get(key)
45
43
  if (!rec) return null
46
- for (const unsub of rec.ptyStreams.values()) {
47
- try { if (typeof unsub === 'function') unsub() } catch { /* swallow */ }
48
- }
49
- rec.ptyStreams.clear()
50
44
  rec.subscriptions.clear()
51
45
  records.delete(key)
52
46
  return rec
@@ -55,13 +49,11 @@ function createClientSessions() {
55
49
  return {
56
50
  clientKeyOf,
57
51
  get: (key) => records.get(key) || null,
58
- getOrCreate(key, { clientId = null } = {}) {
52
+ getOrCreate(key) {
59
53
  let rec = records.get(key)
60
54
  if (!rec) {
61
- rec = makeRecord(key, clientId)
55
+ rec = makeRecord(key)
62
56
  records.set(key, rec)
63
- } else if (rec.clientId === null && typeof clientId === 'string') {
64
- rec.clientId = clientId
65
57
  }
66
58
  return rec
67
59
  },
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // E2E encryption helpers (P-256 ECDH + AES-256-GCM, ADR §5.2).
4
- // Shared by the agent side (webRemote.cjs) and the desktop client role.
4
+ // Used by the agent side (webRemote.cjs).
5
5
 
6
6
  const crypto = require('node:crypto');
7
7
 
@@ -894,15 +894,20 @@ async function createWorktree({ kind, cwd, key, fromRef }) {
894
894
  carriedPaths = carry.paths;
895
895
  }
896
896
 
897
+ let setup = { ran: false, ok: false, reason: 'dep-link-disabled', waitedMs: 0, ms: 0, commands: [] };
897
898
  if (process.env.SM_WORKTREE_DEP_LINK_DISABLE !== '1') {
898
899
  try {
899
- const { linked } = await require('./worktreeDeps.cjs').linkDependencyDirs({ mainCwd: cwd, worktreeCwd: dir });
900
- if (linked.length) console.log(`[gitWorktree] linked deps: ${linked.join(', ')} -> ${dir}`);
901
- } catch { /* best-effort — never fails worktree creation */ }
900
+ const deps = await require('./worktreeDeps.cjs').linkDependencyDirs({ mainCwd: cwd, worktreeCwd: dir });
901
+ setup = deps.setup;
902
+ if (deps.linked.length) console.log(`[gitWorktree] linked deps: ${deps.linked.join(', ')} -> ${dir}`);
903
+ } catch (err) {
904
+ // best-effort — never fails worktree creation
905
+ setup = { ran: false, ok: false, reason: 'error', error: String((err && err.message) || err), waitedMs: 0, ms: 0, commands: [] };
906
+ }
902
907
  }
903
908
 
904
909
  registeredCheckouts.set(dir, { kind, branch });
905
- const result = { ok: true, dir, branch, baseCwd: cwd, carriedPaths, baseBranch };
910
+ const result = { ok: true, dir, branch, baseCwd: cwd, carriedPaths, baseBranch, setup };
906
911
  if (resolvedFromRefSha) result.forkedFrom = fromRef;
907
912
  return result;
908
913
  }
@@ -44,6 +44,7 @@ const fs = require('node:fs');
44
44
  const path = require('node:path');
45
45
  const { execFile } = require('node:child_process');
46
46
  const { execGit } = require('./gitExec.cjs');
47
+ const { runWorktreeSetup } = require('./worktreeSetup.cjs');
47
48
 
48
49
  const DEFAULT_DEP_DIRS = ['node_modules', '.venv', 'venv'];
49
50
 
@@ -191,7 +192,7 @@ function addExcludes(mainCwd, names) {
191
192
 
192
193
  /**
193
194
  * @param {{ mainCwd: string, worktreeCwd: string, names?: string[] }} opts
194
- * @returns {Promise<{ linked: string[], skipped: string[], installed: string[] }>}
195
+ * @returns {Promise<{ linked: string[], skipped: string[], installed: string[], setup: object }>}
195
196
  */
196
197
  async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DIRS }) {
197
198
  const linked = [];
@@ -224,7 +225,17 @@ async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DI
224
225
  skipped.push(name);
225
226
  }
226
227
  }
227
- return { linked, skipped, installed };
228
+ // Project warm-up (.session-manager.json worktreeSetup) runs after linking so it sees node_modules.
229
+ // Both createWorktree and the scheduler's verify scratch worktree come through here. mainCwd lets
230
+ // same-project setups serialize and reports a config missing from the worktree; the outcome is
231
+ // returned unchanged as `setup`. SM_WORKTREE_DEP_LINK_DISABLE=1 skips this at the gitWorktree.cjs call site.
232
+ let setup;
233
+ try {
234
+ setup = await runWorktreeSetup({ worktreeCwd, mainCwd });
235
+ } catch (err) {
236
+ setup = { ran: false, error: String(err && err.message) };
237
+ }
238
+ return { linked, skipped, installed, setup };
228
239
  }
229
240
 
230
241
  module.exports = { linkDependencyDirs, isPnpmProject, isUvProject, DEFAULT_DEP_DIRS };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * worktreeSetup.cjs — run a project's one-time warm-up commands in a fresh
3
+ * worktree (e.g. Godot's first `--import` core-dumps; the second is clean).
4
+ *
5
+ * The project declares them in a TRACKED `.session-manager.json` at its root:
6
+ * { "worktreeSetup": ["timeout 600 godot --headless --path game --import"] }
7
+ * Entries use gate syntax, are validated by prdGateFiles.validateGate, and run
8
+ * in order with cwd = the WORKTREE ROOT, so a project whose code lives in a
9
+ * subfolder names it itself (Godot's `--path game`).
10
+ *
11
+ * Execution: definitionOfDone.runIsolatedSequence — it never takes the shadow
12
+ * gate flag, so a setup is never skipped as "busy" and never makes a gate busy.
13
+ * Setups of the same project (key = path.resolve(mainCwd || worktreeCwd)) run
14
+ * strictly one at a time: a later call waits and is never skipped. Different
15
+ * projects run in parallel.
16
+ *
17
+ * Never throws. Always resolves to one outcome object:
18
+ * { ran, ok, reason, error?, waitedMs, ms, commands }
19
+ * commands: one { cmd, status, code, ms, timedOut, tail } per started command
20
+ * (tail <= 1000 chars). ok is true only when every command passed.
21
+ * reason when commands ran: 'passed' | 'failed' | 'unavailable'
22
+ * reason otherwise: 'no-config' | 'not-in-worktree' | 'unreadable' |
23
+ * 'not-declared' | 'invalid' | 'opt-out' | 'no-commands' | 'error'
24
+ */
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { validateGate } = require('../prd/prdGateFiles.cjs');
30
+ const { parseChain, runIsolatedSequence } = require('../scheduler/definitionOfDone.cjs');
31
+
32
+ const CONFIG_NAME = '.session-manager.json';
33
+ const TAIL_MAX = 1000;
34
+
35
+ /** Per-project chain of in-flight setups; idle keys are deleted. */
36
+ const setupChains = new Map();
37
+
38
+ function log(msg) {
39
+ try { console.warn(`[worktreeSetup] ${msg}`); } catch { /* best-effort */ }
40
+ }
41
+
42
+ function skipped(reason, extra) {
43
+ return { ran: false, ok: false, reason, waitedMs: 0, ms: 0, commands: [], ...extra };
44
+ }
45
+
46
+ /** True when mainCwd's config declares a real (non-empty, non-"none") worktreeSetup. */
47
+ function mainDeclaresSetup(mainCwd) {
48
+ if (typeof mainCwd !== 'string' || !mainCwd) return false;
49
+ try {
50
+ const cfg = JSON.parse(fs.readFileSync(path.join(mainCwd, CONFIG_NAME), 'utf8'));
51
+ const ws = cfg && cfg.worktreeSetup;
52
+ if (!Array.isArray(ws) || !ws.length) return false;
53
+ if (ws.length === 1 && typeof ws[0] === 'string' && /^none$/i.test(ws[0].trim())) return false;
54
+ return true;
55
+ } catch { return false; }
56
+ }
57
+
58
+ /**
59
+ * Resolve the sequence to run, or a skipped outcome.
60
+ * @returns {{ sequence: any[] } | { outcome: object }}
61
+ */
62
+ function resolveConfig(worktreeCwd, mainCwd) {
63
+ let cfg;
64
+ try {
65
+ cfg = JSON.parse(fs.readFileSync(path.join(worktreeCwd, CONFIG_NAME), 'utf8'));
66
+ } catch (err) {
67
+ if (err && err.code === 'ENOENT') {
68
+ if (mainDeclaresSetup(mainCwd)) {
69
+ log(`${CONFIG_NAME} declares worktreeSetup in the main checkout but is not in this worktree's commit — commit it`);
70
+ return { outcome: skipped('not-in-worktree') };
71
+ }
72
+ return { outcome: skipped('no-config') };
73
+ }
74
+ const msg = String(err && err.message);
75
+ log(`${CONFIG_NAME} unreadable, setup skipped: ${msg}`);
76
+ return { outcome: skipped('unreadable', { error: msg }) };
77
+ }
78
+ if (!cfg || typeof cfg !== 'object' || !('worktreeSetup' in cfg)) return { outcome: skipped('not-declared') };
79
+ if (!Array.isArray(cfg.worktreeSetup)) {
80
+ const error = 'worktreeSetup must be an array of gate-syntax strings';
81
+ log(`invalid worktreeSetup, skipped: ${error}`);
82
+ return { outcome: skipped('invalid', { error }) };
83
+ }
84
+ const v = validateGate(cfg.worktreeSetup);
85
+ if (!v.ok) {
86
+ log(`invalid worktreeSetup, skipped: ${v.error}`);
87
+ return { outcome: skipped('invalid', { error: v.error }) };
88
+ }
89
+ if (v.commands.length === 1 && /^none$/i.test(v.commands[0])) return { outcome: skipped('opt-out') };
90
+ const sequence = [];
91
+ for (const chain of v.commands) sequence.push(...parseChain(chain));
92
+ if (!sequence.length) return { outcome: skipped('no-commands') };
93
+ return { sequence };
94
+ }
95
+
96
+ async function execute(sequence, worktreeCwd, startedAt) {
97
+ const t0 = Date.now();
98
+ const res = await runIsolatedSequence(sequence, { cwd: worktreeCwd });
99
+ const results = Array.isArray(res.results) ? res.results : [];
100
+ const commands = results.map((r, i) => ({
101
+ cmd: sequence[i]?.raw ?? r.cmd,
102
+ status: r.status,
103
+ code: r.code,
104
+ ms: r.ms,
105
+ timedOut: r.timedOut,
106
+ tail: typeof r.tail === 'string' ? r.tail.slice(-TAIL_MAX) : r.tail,
107
+ }));
108
+ const reason = res.status === 'green' ? 'passed' : res.status === 'red' ? 'failed' : 'unavailable';
109
+ if (reason !== 'passed') {
110
+ const last = commands[commands.length - 1] || {};
111
+ log(`setup ${reason}: ${last.cmd ?? '(no command started)'} (exit ${last.code ?? 'n/a'}, ${last.status ?? res.status})`);
112
+ }
113
+ return {
114
+ ran: commands.length > 0,
115
+ ok: reason === 'passed',
116
+ reason,
117
+ waitedMs: t0 - startedAt,
118
+ ms: Date.now() - t0,
119
+ commands,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * @param {{ worktreeCwd: string, mainCwd?: string }} opts
125
+ * @returns {Promise<{ ran: boolean, ok: boolean, reason: string, error?: string, waitedMs: number, ms: number, commands: object[] }>}
126
+ */
127
+ async function runWorktreeSetup(opts) {
128
+ try {
129
+ const { worktreeCwd, mainCwd } = opts || {};
130
+ if (typeof worktreeCwd !== 'string' || !worktreeCwd) return skipped('no-config');
131
+ const cfg = resolveConfig(worktreeCwd, mainCwd);
132
+ if (cfg.outcome) return cfg.outcome;
133
+
134
+ const key = path.resolve(mainCwd || worktreeCwd);
135
+ const startedAt = Date.now();
136
+ const prev = setupChains.get(key) || Promise.resolve();
137
+ const mine = prev.catch(() => {}).then(() => execute(cfg.sequence, worktreeCwd, startedAt));
138
+ setupChains.set(key, mine);
139
+ try {
140
+ return await mine;
141
+ } finally {
142
+ if (setupChains.get(key) === mine) setupChains.delete(key);
143
+ }
144
+ } catch (err) {
145
+ const error = String(err && err.message);
146
+ log(`unexpected error: ${error}`);
147
+ return skipped('error', { error });
148
+ }
149
+ }
150
+
151
+ const LINE_PREFIX = '[scheduler] worktree setup:';
152
+ const LINE_MAX = 500;
153
+ /** Reasons that mean "nothing to log" (no setup applies). */
154
+ const SILENT_REASONS = new Set(['no-config', 'in-place']);
155
+ /** Skip reasons where the worktree is still trustworthy for a gate. */
156
+ const SETTLED_REASONS = new Set(['no-config', 'not-declared', 'opt-out']);
157
+
158
+ function clip(line) {
159
+ return line.length <= LINE_MAX ? line : `${line.slice(0, LINE_MAX - 1)}…`;
160
+ }
161
+ const secs = (ms) => (Math.round((Number(ms) || 0) / 100) / 10).toString();
162
+
163
+ /**
164
+ * Run-log lines for a setup outcome. Pure; never throws. Lines are
165
+ * `[scheduler]`-prefixed and never carry command output (tails stay in
166
+ * meta.json) so the run-log detectors cannot be tripped by a setup command.
167
+ * @param {any} outcome
168
+ * @returns {string[]}
169
+ */
170
+ function formatSetupOutcome(outcome) {
171
+ try {
172
+ if (!outcome || typeof outcome !== 'object') return [];
173
+ const reason = String(outcome.reason);
174
+ if (SILENT_REASONS.has(reason)) return [];
175
+ if (!outcome.ran) {
176
+ let line = `${LINE_PREFIX} skipped — ${reason}`;
177
+ if (outcome.error) line += `: ${String(outcome.error)}`;
178
+ const lines = [clip(line)];
179
+ if (reason === 'not-in-worktree') {
180
+ lines.push(clip(`${LINE_PREFIX} .session-manager.json is in the main checkout but not in this worktree's commit`));
181
+ }
182
+ return lines;
183
+ }
184
+ const commands = Array.isArray(outcome.commands) ? outcome.commands : [];
185
+ let summary = `${LINE_PREFIX} ${reason} — ${commands.length} command(s) in ${secs(outcome.ms)}s`;
186
+ if (Number(outcome.waitedMs) >= 1000) summary += `, waited ${secs(outcome.waitedMs)}s`;
187
+ const lines = [clip(summary)];
188
+ for (const c of commands) {
189
+ const cc = c && typeof c === 'object' ? c : {};
190
+ const code = cc.code === null || cc.code === undefined ? 'n/a' : String(cc.code);
191
+ lines.push(clip(`${LINE_PREFIX} exit ${code} in ${Number(cc.ms) || 0}ms${cc.timedOut ? ', timed out' : ''}: ${String(cc.cmd ?? '')}`));
192
+ }
193
+ if (outcome.ok === false) {
194
+ lines.push(clip(`${LINE_PREFIX} output tail is in the run's meta.json under worktreeSetup.commands[].tail`));
195
+ }
196
+ return lines;
197
+ } catch { return []; }
198
+ }
199
+
200
+ /**
201
+ * True when a gate can trust the worktree: setup passed, or none is declared.
202
+ * @param {any} outcome
203
+ * @returns {boolean}
204
+ */
205
+ function isSetupSettled(outcome) {
206
+ try {
207
+ if (!outcome || typeof outcome !== 'object') return false;
208
+ return outcome.ok === true || SETTLED_REASONS.has(outcome.reason);
209
+ } catch { return false; }
210
+ }
211
+
212
+ module.exports = { runWorktreeSetup, formatSetupOutcome, isSetupSettled, CONFIG_NAME };
@@ -107,16 +107,15 @@ const MCP_TOOL_CATALOG = [
107
107
  + 'no shell metacharacters), or exactly `["none"]` for a PRD with no runnable check; the scheduler '
108
108
  + 're-runs these commands and reads them back from the rendered `# Gate` section verbatim, so a stray '
109
109
  + '```gate fence anywhere else in the PRD text is rejected. `files` lists the 1-50 repo-relative files '
110
- + 'or folders this PRD may change; PRDs that can run at the same time must not share a file — chain '
111
- + 'them with `dependsOn` instead.',
110
+ + 'or folders this PRD may change; The scheduler never runs two PRDs with overlapping files at once '
111
+ + '(dispatch holds the later one), so `dependsOn` is for real output or order dependencies, not for '
112
+ + 'avoiding file clashes.',
112
113
  whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
113
114
  whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
114
- + '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
115
- + 'API unreachable — that is the ONLY case where hand-authoring the PRD file directly on '
116
- + 'disk is an acceptable DEGRADED, LAST-RESORT fallback; the caller MUST say so explicitly '
117
- + 'and visibly in its report (which file, why the tool was unreachable, that it needs '
118
- + 'verification) since the server-side validation, atomic NN allocation, and '
119
- + 'Epic-existence check this tool performs did not run for that file. '
115
+ + '(a) this tool call is PRESENT in your tool list but ERRORS — NEVER hand-write a PRD file. '
116
+ + 'On "not running" (admin API unreachable): stop, ask the human to start the app, then retry the same call. '
117
+ + 'On "did not answer": the app is busy and the PRD may already exist — check with scheduler_list_prds '
118
+ + '(no status filter) or retry the same slug. '
120
119
  + '(b) this tool is ABSENT from your tool list entirely — you were never offered it, so '
121
120
  + 'there is no error to catch. That means the session-manager-scheduler MCP server is not '
122
121
  + 'registered for this project: a MISCONFIGURATION, not an offline app. In that case DO '
@@ -136,6 +135,34 @@ const MCP_TOOL_CATALOG = [
136
135
  },
137
136
  notes: 'See /develop.',
138
137
  },
138
+ {
139
+ name: 'scheduler_create_prds',
140
+ group: 'scheduler',
141
+ purpose: 'Write a whole plan of PRDs in ONE all-or-nothing call (1 to 40 entries) via the session-manager '
142
+ + "app's admin API. Each entry is exactly a scheduler_create_prd input (gate and files REQUIRED per entry). "
143
+ + 'Every entry is validated first; only if all pass are NNs allocated and files written, otherwise NOTHING is '
144
+ + 'written and every per-entry error is returned as { index, slug, error }. Returns each slug and its NN, plus '
145
+ + 'any advisory warnings. Like scheduler_create_prd it only writes files; the queue rows are derived by the '
146
+ + 'next reconcile pass. All entries must resolve to the same project and Epic; top-level cwd, sourcePromptId '
147
+ + 'and origin fields are forwarded the same way as scheduler_create_prd.',
148
+ whenToUse: 'Use to queue a whole plan at once, validate PRD included: dependsOn may name other entries of the same call by bare slug (earlier or later), so a chain needs no ordering of calls.',
149
+ whenNotToUse: 'Do not use for a single PRD — use scheduler_create_prd. Not callable from inside a headless scheduled job (same refusal as scheduler_create_prd). If the app is not running, stop and ask the human to start it; never hand-write PRD files.',
150
+ exampleArgs: {
151
+ prds: [
152
+ {
153
+ title: 'Add retry backoff helper',
154
+ goal: 'Add retryWithBackoff.cjs.',
155
+ acceptanceCriteria: ['Helper exists', 'Unit tests pass'],
156
+ implementationNotes: 'See existing helpers under src/main/lib/.',
157
+ gate: ['timeout 300 npm run test:unit'],
158
+ files: ['src/main/lib/retryWithBackoff.cjs'],
159
+ slug: 'add-retry-backoff',
160
+ sourcePromptId: 'epic-id-of-an-already-approved-session',
161
+ },
162
+ ],
163
+ },
164
+ notes: 'See /develop.',
165
+ },
139
166
  {
140
167
  name: 'scheduler_list_prds',
141
168
  group: 'scheduler',
@@ -400,7 +427,7 @@ const MCP_RECIPES = [
400
427
  title: 'Queue work into an already-approved Epic (/develop)',
401
428
  steps: [
402
429
  'Confirm an Epic already exists for this work (proposed or active) — scheduler_create_prd never mints one.',
403
- 'Call scheduler_create_prd with sourcePromptId set to that Epic id, plus title/cwd/estimateMinutes/goal/acceptanceCriteria/implementationNotes.',
430
+ 'Call scheduler_create_prd with sourcePromptId set to that Epic id, plus title/cwd/goal/acceptanceCriteria/implementationNotes/gate/files.',
404
431
  'The response has enqueued:false — the queue row is derived by the scheduler\'s next reconcile pass (~1 minute), not by this call.',
405
432
  'Optionally call scheduler_list_prds with cwd/status to confirm the PRD picked up a job row.',
406
433
  ],
@@ -58,6 +58,8 @@ const AgentPersonaSaveSchema = z.object({
58
58
  tools: z.array(BoundedString),
59
59
  model: BoundedString,
60
60
  effort: BoundedString.optional(),
61
+ // Wall-clock budget default for PRDs without estimateMinutes (60 × factor 3 reaches the 180-min ceiling).
62
+ defaultEstimateMinutes: z.number().int().min(1).max(60).optional(),
61
63
  color: BoundedString,
62
64
  // A legacy tag value (e.g. the retired 'bilko-host-publisher') must keep
63
65
  // loading on an old on-disk persona — dropped silently, never thrown. Any
@@ -77,6 +79,8 @@ const AgentPersonaSaveSchema = z.object({
77
79
  // Without this field zod's default "strip unknown keys" behavior would
78
80
  // silently drop it from a save payload that did carry one.
79
81
  seedVersion: z.number().int().optional(),
82
+ // Executor-only persona (e.g. dev-lead): New Epic warns when picked as the planning Actor. Never blocks.
83
+ executorOnly: z.boolean().optional(),
80
84
  // Unbounded — a persona's body is arbitrary system-prompt prose.
81
85
  body: z.string(),
82
86
  });
@@ -135,6 +135,18 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
135
135
  tools: "Read, Grep, Glob, Bash, Edit, Write",
136
136
  },
137
137
  },
138
+ {
139
+ commit: "9e400786",
140
+ bodySha256: "82b93c1da9a5e3b7dfcfadd1b4689d988a4bd19de2d897ebd3f3158969d741fc",
141
+ fm: {
142
+ description: "Executes exactly one already-scoped PRD at a time, headless, start to finish — reads the PRD's Goal/Acceptance Criteria/Implementation notes and standards.md, implements it, verifies against its own AC, and reports. Has no visibility into the overall plan — that's architect's job. This is the default persona a scheduled PRD runs as: the scheduler resolves a PRD's `agentType` frontmatter field (default `dev-lead`) to this file and launches the headless executor AS this persona via `--append-system-prompt`.",
143
+ effort: "medium",
144
+ executorOnly: "true",
145
+ model: "sonnet",
146
+ title: "Engineering — Software Engineer",
147
+ tools: "Read, Grep, Glob, Bash, Edit, Write",
148
+ },
149
+ },
138
150
  ],
139
151
  "project-home-builder": [
140
152
  {
@@ -228,6 +240,17 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
228
240
  tools: "Read, Grep, Glob, Bash",
229
241
  },
230
242
  },
243
+ {
244
+ commit: "9e400786",
245
+ bodySha256: "ca159cd47ac4b618af842c6ccd59107fac4a34b0efcfa5dfe7d06875736156ed",
246
+ fm: {
247
+ description: "Validates a finished PLAN (the PRDs its validate PRD lists) once, after its last PRD lands — re-runs each PRD's gate, checks every acceptance criterion against the real tree, reviews the plan's combined diff, and reports one VERIFIED/REFUTED verdict per PRD via sentinel lines. Runs headless as a scheduled PRD (agentType: validator); never edits product code and never queues work.",
248
+ executorOnly: "true",
249
+ model: "sonnet",
250
+ title: "Engineering — Plan Validator",
251
+ tools: "Read, Grep, Glob, Bash",
252
+ },
253
+ },
231
254
  ],
232
255
  "demo-video-builder": [
233
256
  {