claude-code-session-manager 0.122.1 → 0.123.1

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-C2qUX9WU.js → AgentLibrary-T14aM8ie.js} +2 -2
  2. package/dist/assets/{Button-ssBzvFha.js → Button-Dz1kV8Z_.js} +1 -1
  3. package/dist/assets/DataModel-DdYRn2Ec.js +1 -0
  4. package/dist/assets/{History-BRdRp1J_.js → History-zQPvqwE7.js} +2 -2
  5. package/dist/assets/{Hooks--hiGPBQy.js → Hooks-BTrbT742.js} +3 -3
  6. package/dist/assets/{Library-0XYvkhh2.js → Library-BwQ6GRHR.js} +7 -7
  7. package/dist/assets/ListDetail-CunxAhRC.js +1 -0
  8. package/dist/assets/{McpServers-CCjHSwFm.js → McpServers-DpoyxXaO.js} +2 -2
  9. package/dist/assets/{Memory-C7RXoVO4.js → Memory-KDtGYMc3.js} +8 -8
  10. package/dist/assets/{Panel-C92O_aAH.js → Panel-xXmUBo8l.js} +1 -1
  11. package/dist/assets/{Permissions-DuTJ1FUb.js → Permissions-lIzTUrW2.js} +3 -3
  12. package/dist/assets/{Plugins-DSXsP4hM.js → Plugins-BA1Zqk69.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-CWdDkU1m.js → ProvenanceBadge-CmeJG2Kw.js} +1 -1
  14. package/dist/assets/{SaveBar-ob2dJJjH.js → SaveBar-BTJg_9Uc.js} +1 -1
  15. package/dist/assets/{Scheduler-Du4HgPKY.js → Scheduler-CISTDNMU.js} +7 -7
  16. package/dist/assets/{ScopeSwitcher-Dv_6M_Ci.js → ScopeSwitcher-C6NWvzoj.js} +1 -1
  17. package/dist/assets/Settings-BBoGJ34v.js +4 -0
  18. package/dist/assets/{SkillReferenceGraph-Dx1Qs8Y0.js → SkillReferenceGraph-IJv60QAJ.js} +1 -1
  19. package/dist/assets/Skills-L6nGcNF1.js +2 -0
  20. package/dist/assets/SystemPrompt-Cm7I2gJz.js +1 -0
  21. package/dist/assets/TagLibrary-BxPwoPE6.js +1 -0
  22. package/dist/assets/{TiptapBody-Cihe5er3.js → TiptapBody-CR-Zr3Pz.js} +1 -1
  23. package/dist/assets/{Toggle-CpYVC8bF.js → Toggle-BFhlpZ-n.js} +1 -1
  24. package/dist/assets/{ViewTabs-C3FN6P_C.js → ViewTabs-CuGlZ9Ed.js} +1 -1
  25. package/dist/assets/{historyTotals-C4Rs37Cd.js → historyTotals-Dxv9Fzrs.js} +1 -1
  26. package/dist/assets/{index-Dd7UQabV.css → index-BnKaYERU.css} +1 -1
  27. package/dist/assets/{index-S-F5jp_h.js → index-Dx8owDeQ.js} +151 -151
  28. package/dist/assets/{settingsSchema-BZcdzHTQ.js → settingsSchema-DaqFSAQH.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-EfNERt4b.js → skillFrontmatter-BIJddxVB.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-BfbrFvB3.js → useAgentsDirWatch-1hjZ4knk.js} +1 -1
  31. package/dist/index.html +2 -2
  32. package/package.json +3 -3
  33. package/plugins/session-manager-dev/skills/develop/SKILL.md +29 -25
  34. package/scripts/scheduler-mcp-server.cjs +124 -74
  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 +31 -2
  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 +205 -32
  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 +124 -29
  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-CENoTvc2.js +0 -1
  84. package/dist/assets/ListDetail-CkKXAM59.js +0 -1
  85. package/dist/assets/RemoteMachines-BFmbIGpW.js +0 -3
  86. package/dist/assets/Settings-DMoI0cqB.js +0 -4
  87. package/dist/assets/Skills-DYWQTQB1.js +0 -2
  88. package/dist/assets/SystemPrompt-Df0IfOuI.js +0 -1
  89. package/dist/assets/TagLibrary-BuMsGx-2.js +0 -1
  90. package/dist/assets/useRemotePeers-CKVakQPc.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,8 +107,9 @@ 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
115
  + '(a) this tool call is PRESENT in your tool list but ERRORS — NEVER hand-write a PRD file. '
@@ -134,6 +135,34 @@ const MCP_TOOL_CATALOG = [
134
135
  },
135
136
  notes: 'See /develop.',
136
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
+ },
137
166
  {
138
167
  name: 'scheduler_list_prds',
139
168
  group: 'scheduler',
@@ -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
  {
@@ -15,6 +15,8 @@
15
15
 
16
16
  const { personaFileExists, resolvePersonaPaths } = require('../epic/epicMint.cjs');
17
17
  const { listPersonas } = require('../../agentLibrary.cjs');
18
+ const { splitFrontmatter } = require('./prdFrontmatter.cjs');
19
+ const fs = require('node:fs');
18
20
 
19
21
  /** PRD execution's documented default persona (dev-lead.md's own frontmatter role). */
20
22
  const DEFAULT_PRD_AGENT_TYPE = 'dev-lead';
@@ -77,8 +79,34 @@ async function assertAgentTypeWritable(cwd, agentType, deps = {}) {
77
79
  );
78
80
  }
79
81
 
82
+ /**
83
+ * resolvePersonaDefaultEstimate(cwd, agentType, deps) — the persona's
84
+ * optional `defaultEstimateMinutes` frontmatter (integer 1..60), project
85
+ * overlay before global. Returns a number or null; never throws (missing,
86
+ * malformed or out-of-range → null). Uncached sync read: called once per
87
+ * job spawn.
88
+ */
89
+ function resolvePersonaDefaultEstimate(cwd, agentType, deps = {}) {
90
+ try {
91
+ if (!cwd || typeof agentType !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(agentType)) return null;
92
+ const { projectPath, globalPath } = resolvePersonaPaths(cwd, agentType, deps);
93
+ for (const file of [projectPath, globalPath]) {
94
+ let text;
95
+ try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
96
+ const raw = splitFrontmatter(text).fm.defaultEstimateMinutes;
97
+ if (raw === undefined) continue;
98
+ const str = String(raw).trim();
99
+ if (!/^\d+$/.test(str)) continue;
100
+ const n = Number(str);
101
+ if (n >= 1 && n <= 60) return n;
102
+ }
103
+ } catch { /* never throws */ }
104
+ return null;
105
+ }
106
+
80
107
  module.exports = {
81
108
  DEFAULT_PRD_AGENT_TYPE,
109
+ resolvePersonaDefaultEstimate,
82
110
  assertAgentTypeWritable,
83
111
  reportDanglingAgentTypeOnce,
84
112
  };