claude-code-session-manager 0.76.0 → 0.78.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 (123) hide show
  1. package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-13pfo8uY.js} +2 -2
  2. package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-SUyQbFlg.js} +1 -1
  3. package/dist/assets/{History-CpdtWhC8.js → History-2GJMS703.js} +2 -2
  4. package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-DM2nS3RT.js} +2 -2
  5. package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-BLeC-lpp.js} +1 -1
  6. package/dist/assets/{Library-CQmo4QVC.js → Library-BaRkU9m0.js} +1 -1
  7. package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-D5scjSKq.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-B1lgAo9T.js} +1 -1
  9. package/dist/assets/{McpServers-CLarzwqA.js → McpServers-BzyThQSM.js} +1 -1
  10. package/dist/assets/{Memory-B0sCdIy1.js → Memory-7UdaOTtl.js} +6 -6
  11. package/dist/assets/{Panel-BhWPVOCD.js → Panel-JbTMaOPq.js} +1 -1
  12. package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-UBam0bJG.js} +2 -2
  13. package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-B3gUDkeb.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-CeHOub7m.js} +1 -1
  15. package/dist/assets/SaveBar-BcvQEq6h.js +1 -0
  16. package/dist/assets/Scheduler-Dc5qiP24.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-BvGQmw4Y.js} +1 -1
  18. package/dist/assets/{Settings-2Vx3X5SI.js → Settings-C2dEFb-v.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-CIwlosBc.js} +1 -1
  20. package/dist/assets/{Skills-Cmrz_LeN.js → Skills-C0GzzVrQ.js} +2 -2
  21. package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-mtGPK8zo.js} +1 -1
  22. package/dist/assets/TagLibrary-DX54-mpd.js +1 -0
  23. package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-yADC2RWE.js} +1 -1
  24. package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-CRxaCYLI.js} +1 -1
  25. package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
  26. package/dist/assets/{index-B_4PNh9T.js → index-D6ymGESc.js} +469 -469
  27. package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-TtMvT5Sx.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  32. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  33. package/scripts/render-project-pages.cjs +70 -0
  34. package/scripts/scheduler-mcp-server.cjs +115 -1
  35. package/scripts/validate-project-pages-summary.cjs +62 -0
  36. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  37. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  38. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  39. package/src/main/__tests__/prdCreate.test.cjs +138 -0
  40. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  41. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  42. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  43. package/src/main/__tests__/projectPages.test.cjs +73 -1
  44. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  45. package/src/main/__tests__/runVerify.test.cjs +94 -0
  46. package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
  47. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  48. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  49. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  50. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  51. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  52. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  53. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  54. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  55. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  56. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  57. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
  58. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  59. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  60. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  61. package/src/main/config.cjs +4 -1
  62. package/src/main/index.cjs +16 -4
  63. package/src/main/ipcSchemas.cjs +53 -0
  64. package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
  65. package/src/main/lib/__tests__/delegationReadiness.test.cjs +197 -2
  66. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
  67. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  68. package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
  69. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  70. package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
  71. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  72. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  73. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  75. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  76. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  77. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  78. package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
  79. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  80. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  81. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  82. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  83. package/src/main/lib/agentModelResolve.cjs +58 -0
  84. package/src/main/lib/childWithLog.cjs +40 -5
  85. package/src/main/lib/claudeBin.cjs +54 -1
  86. package/src/main/lib/definitionOfDone.cjs +3 -2
  87. package/src/main/lib/delegationReadiness.cjs +115 -9
  88. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  89. package/src/main/lib/fixChainDepth.cjs +45 -0
  90. package/src/main/lib/gitWorktree.cjs +464 -19
  91. package/src/main/lib/jobWorktree.cjs +1 -0
  92. package/src/main/lib/landedSinceRun.cjs +55 -0
  93. package/src/main/lib/launchFailure.cjs +357 -0
  94. package/src/main/lib/mcpToolCatalog.cjs +87 -2
  95. package/src/main/lib/opsOwnership.cjs +12 -0
  96. package/src/main/lib/prdAgentType.cjs +84 -0
  97. package/src/main/lib/prdCreate.cjs +57 -1
  98. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  99. package/src/main/lib/prdFrontmatter.cjs +17 -3
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  101. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  102. package/src/main/lib/queueHealth.cjs +38 -0
  103. package/src/main/lib/queueStore.cjs +9 -2
  104. package/src/main/lib/quietMachineLease.cjs +48 -0
  105. package/src/main/lib/rcaReport.cjs +53 -3
  106. package/src/main/lib/reaperHelpers.cjs +18 -1
  107. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  108. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  109. package/src/main/lib/schedulerBatch.cjs +133 -29
  110. package/src/main/lib/schedulerConfig.cjs +19 -0
  111. package/src/main/projectPages.cjs +160 -2
  112. package/src/main/runVerify.cjs +50 -9
  113. package/src/main/scheduler/prdParser.cjs +18 -1
  114. package/src/main/scheduler.cjs +1371 -97
  115. package/src/main/seedAgentPersonas.cjs +62 -21
  116. package/src/main/templates/project-pages-catalog.json +741 -0
  117. package/src/main/templates/project-pages-pipeline.md +417 -0
  118. package/src/preload/api.d.ts +126 -6
  119. package/src/preload/index.cjs +8 -0
  120. package/src/seed/agents/project-home-builder.md +59 -0
  121. package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
  122. package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
  123. package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ // Bash-invocable CLI wrapping the Stage 0 renderer (render.tsx →
3
+ // scripts/render-project-pages/dist/renderer.cjs, built by
4
+ // `npm run build:project-pages`). Requires that build to have already run —
5
+ // this script does NOT bundle on the fly, since PRD authoring standards
6
+ // forbid unbounded/slow steps inside a headless job.
7
+ //
8
+ // Usage:
9
+ // node scripts/render-project-pages.cjs <summary.json> <picks.json> <output dir> <generatedAt>
10
+ //
11
+ // generatedAt (4th arg, required) is an ISO 8601 timestamp string the CALLER
12
+ // stamps — Date.now() is unavailable in some execution contexts (e.g.
13
+ // workflow scripts) that may drive this CLI, so this script never generates
14
+ // its own timestamp. This is the "accept generatedAt as a 4th CLI arg"
15
+ // option from the PRD's two documented choices (the alternative — omitting
16
+ // generatedAt from manifest.json entirely — was not taken).
17
+ //
18
+ // Writes <output dir>/home.html, marketing.html, feature.html,
19
+ // architecture.html, brief.html, and manifest.json ({ generatedAt }).
20
+ //
21
+ // Path arguments are used as-is (no allowedRoots validation like
22
+ // config.cjs's validatePath) — this is a standalone local dev/ops script run
23
+ // by a trusted human or agent shell, not an IPC-reachable surface, so it's
24
+ // out of scope for that guard by design.
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ function fail(message) {
31
+ console.error(`render-project-pages: ${message}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ function main() {
36
+ const [summaryPath, picksPath, outDir, generatedAt] = process.argv.slice(2);
37
+ if (!summaryPath || !picksPath || !outDir || !generatedAt) {
38
+ fail('usage: node scripts/render-project-pages.cjs <summary.json> <picks.json> <output dir> <generatedAt>');
39
+ }
40
+ if (Number.isNaN(Date.parse(generatedAt))) {
41
+ fail(`generatedAt must be an ISO 8601 timestamp, got: ${generatedAt}`);
42
+ }
43
+
44
+ const bundlePath = path.join(__dirname, 'render-project-pages', 'dist', 'renderer.cjs');
45
+ if (!fs.existsSync(bundlePath)) {
46
+ fail(`build bundle not found at ${bundlePath} — run "npm run build:project-pages" first`);
47
+ }
48
+
49
+ let renderProjectPages;
50
+ try {
51
+ ({ renderProjectPages } = require(bundlePath));
52
+ } catch (err) {
53
+ fail(`failed to load ${bundlePath}: ${err.message}`);
54
+ }
55
+
56
+ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
57
+ const picks = JSON.parse(fs.readFileSync(picksPath, 'utf8'));
58
+
59
+ const pages = renderProjectPages(summary, picks);
60
+
61
+ fs.mkdirSync(outDir, { recursive: true });
62
+ for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
63
+ fs.writeFileSync(path.join(outDir, `${lens}.html`), pages[lens]);
64
+ }
65
+ fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify({ generatedAt }, null, 2));
66
+
67
+ console.log(`Wrote home.html, marketing.html, feature.html, architecture.html, brief.html, manifest.json to ${outDir}`);
68
+ }
69
+
70
+ main();
@@ -11,6 +11,11 @@
11
11
  * feedback_list_projects() -> GET /admin/feedback/targets
12
12
  * feedback_open_session({ ... }) -> POST /admin/feedback/open-session
13
13
  *
14
+ * project_home_get_contract({ cwd? }) -> GET /admin/project-home/contract
15
+ * project_home_validate_summary({ cwd?, summary }) -> POST /admin/project-home/validate-summary
16
+ * project_home_render({ cwd?, summary, picks }) -> POST /admin/project-home/render
17
+ * project_home_status({ cwd? }) -> GET /admin/project-home/status
18
+ *
14
19
  * This is a separate process from the Electron app — it only ever reaches
15
20
  * it over the token-authed loopback HTTP API in admin-api.json, never by
16
21
  * requiring scheduler.cjs/localAdminHttp.cjs directly. The admin server IS the
@@ -57,6 +62,14 @@ function errorResult(text) {
57
62
  const NOT_RUNNING_ERROR =
58
63
  `session-manager app is not running (admin API unreachable) — start it first${HELP_POINTER}`;
59
64
 
65
+ // Same resolution fetchReadiness() below uses: a project-home-builder session
66
+ // running inside an Epic worktree still targets the real project cwd, not
67
+ // the worktree's own pwd.
68
+ function resolveCwdArg(args) {
69
+ if (args && typeof args.cwd === 'string' && args.cwd) return args.cwd;
70
+ return process.env.SM_PROJECT_ROOT || process.cwd();
71
+ }
72
+
60
73
  async function readAdminConfig() {
61
74
  const raw = await fsp.readFile(TOKEN_PATH, 'utf8');
62
75
  const parsed = JSON.parse(raw);
@@ -156,7 +169,15 @@ const TOOLS = [
156
169
  tag: {
157
170
  type: 'string',
158
171
  enum: PRD_WORK_TYPES,
159
- description: 'Optional: the work type of THIS PRD — independent of the parent Epic\'s own tag. An Epic is the plan; a PRD is one unit of work inside it, and a single plan may legitimately contain several different work types. Never derived or inherited from the Epic.',
172
+ description: 'Optional: the WORK TYPE of THIS PRD — independent of the parent Epic\'s own tag. An Epic is the plan; a PRD is one unit of work inside it, and a single plan may legitimately contain several different work types. Never derived or inherited from the Epic.',
173
+ },
174
+ agentType: {
175
+ type: 'string',
176
+ description: 'Optional: WHO executes this PRD — the persona name (a file at ~/.claude/agents/<name>.md, e.g. "dev-lead") the executor should adopt. Distinct from `tag` (what kind of work); this is who does it. Defaults to "dev-lead" when omitted. Rejected at write time if the name does not resolve to a readable persona file.',
177
+ },
178
+ quietMachine: {
179
+ type: 'boolean',
180
+ description: 'Optional: set true only when this PRD\'s acceptance criteria are wall-clock/timing measurements (frame time, performance fences) that CPU contention from concurrent jobs would invalidate. The scheduler dispatches it only once zero other jobs are running machine-wide, and holds every other job off the whole slot pool for its run — a whole-machine exclusive lease, not a per-project one. If the machine never goes quiet within the configured wait window (default 30 minutes), it dispatches anyway and is marked degraded. Opt-in only; omit for ordinary PRDs.',
160
181
  },
161
182
  },
162
183
  required: ['title', 'estimateMinutes', 'goal', 'acceptanceCriteria', 'implementationNotes'],
@@ -208,6 +229,8 @@ const TOOLS = [
208
229
  sourcePromptId: { type: 'string' },
209
230
  sourceTabId: { type: 'string' },
210
231
  tag: { type: 'string', enum: PRD_WORK_TYPES },
232
+ agentType: { type: 'string', description: 'WHO executes this PRD (persona name) — patchable after creation too, see scheduler_create_prd' },
233
+ quietMachine: { type: 'boolean', description: 'Optional: the exclusive machine-wide quiet lease flag (see scheduler_create_prd) — patchable after creation too' },
211
234
  },
212
235
  },
213
236
  body: { type: 'string', description: 'Optional: full replacement body (everything after the frontmatter)' },
@@ -316,6 +339,51 @@ const TOOLS = [
316
339
  required: ['toCwd', 'fromCwd', 'title', 'body'],
317
340
  },
318
341
  },
342
+ {
343
+ name: 'project_home_get_contract',
344
+ description: descriptionFor('project_home_get_contract'),
345
+ inputSchema: {
346
+ type: 'object',
347
+ properties: {
348
+ cwd: { type: 'string', description: "Optional: absolute path to the target project. Defaults to the calling session's own project root (SM_PROJECT_ROOT or process.cwd()) when omitted." },
349
+ },
350
+ },
351
+ },
352
+ {
353
+ name: 'project_home_validate_summary',
354
+ description: descriptionFor('project_home_validate_summary'),
355
+ inputSchema: {
356
+ type: 'object',
357
+ properties: {
358
+ cwd: { type: 'string', description: "Optional: absolute path to the target project. Defaults to the calling session's own project root when omitted." },
359
+ summary: { type: 'object', description: 'ProjectPageSummary object matching the summarySchema from project_home_get_contract' },
360
+ },
361
+ required: ['summary'],
362
+ },
363
+ },
364
+ {
365
+ name: 'project_home_render',
366
+ description: descriptionFor('project_home_render'),
367
+ inputSchema: {
368
+ type: 'object',
369
+ properties: {
370
+ cwd: { type: 'string', description: "Optional: absolute path to the target project. Defaults to the calling session's own project root when omitted." },
371
+ summary: { type: 'object', description: 'ProjectPageSummary object matching the summarySchema from project_home_get_contract' },
372
+ picks: { type: 'object', description: 'ProjectPagePicks object (lensId -> slotId -> variantId) matching the picksSchema from project_home_get_contract' },
373
+ },
374
+ required: ['summary', 'picks'],
375
+ },
376
+ },
377
+ {
378
+ name: 'project_home_status',
379
+ description: descriptionFor('project_home_status'),
380
+ inputSchema: {
381
+ type: 'object',
382
+ properties: {
383
+ cwd: { type: 'string', description: "Optional: absolute path to the target project. Defaults to the calling session's own project root when omitted." },
384
+ },
385
+ },
386
+ },
319
387
  {
320
388
  name: 'session_manager_help',
321
389
  description: descriptionFor('session_manager_help'),
@@ -420,6 +488,21 @@ async function handleCallTool(request) {
420
488
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
421
489
  }
422
490
  if (name === 'scheduler_create_prd') {
491
+ // Hard refusal at the authoring boundary (issue #11, list C1): a
492
+ // headless scheduled executor performs its own acceptance criteria and
493
+ // never queues follow-on work — PRD 460 called /develop from inside its
494
+ // run, spawned a duplicate PRD, and exited 0 having done nothing. The
495
+ // standards prose already says so; this makes the harness enforce it.
496
+ // scheduler.cjs stamps SM_SCHEDULER_JOB_SLUG on every job spawn and
497
+ // SM_SCHEDULER_JOB_MAY_QUEUE=1 only for personas whose whole job is
498
+ // decomposition (architect), so a planning PRD still can.
499
+ if (process.env.SM_SCHEDULER_JOB_SLUG && process.env.SM_SCHEDULER_JOB_MAY_QUEUE !== '1') {
500
+ return errorResult(
501
+ `refused: scheduler_create_prd was called from inside headless scheduled job '${process.env.SM_SCHEDULER_JOB_SLUG}'. `
502
+ + 'A PRD executor performs its own acceptance criteria directly and never queues follow-on work (self-queue failure class). '
503
+ + 'If more work is genuinely needed, say so in your final result text — the authoring Epic decides whether to queue it.',
504
+ );
505
+ }
423
506
  // If the caller (the model) didn't pass sourcePromptId, forward this
424
507
  // process's own SM_CHAT_SESSION_ID (set by chatRunner.cjs on the
425
508
  // parent claude -p process this MCP server inherited its env from) so
@@ -485,6 +568,37 @@ async function handleCallTool(request) {
485
568
  const result = await adminRequest('POST', '/admin/chat/send-prompt', { tabId, prompt });
486
569
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
487
570
  }
571
+ if (name === 'project_home_get_contract') {
572
+ const cwd = resolveCwdArg(args);
573
+ const qs = new URLSearchParams({ cwd });
574
+ const result = await adminRequest('GET', `/admin/project-home/contract?${qs.toString()}`);
575
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
576
+ }
577
+ if (name === 'project_home_validate_summary') {
578
+ if (!args || typeof args.summary !== 'object' || args.summary === null) {
579
+ return errorResult('missing required argument: summary');
580
+ }
581
+ const cwd = resolveCwdArg(args);
582
+ const result = await adminRequest('POST', '/admin/project-home/validate-summary', { cwd, summary: args.summary });
583
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
584
+ }
585
+ if (name === 'project_home_render') {
586
+ if (!args || typeof args.summary !== 'object' || args.summary === null) {
587
+ return errorResult('missing required argument: summary');
588
+ }
589
+ if (typeof args.picks !== 'object' || args.picks === null) {
590
+ return errorResult('missing required argument: picks');
591
+ }
592
+ const cwd = resolveCwdArg(args);
593
+ const result = await adminRequest('POST', '/admin/project-home/render', { cwd, summary: args.summary, picks: args.picks });
594
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
595
+ }
596
+ if (name === 'project_home_status') {
597
+ const cwd = resolveCwdArg(args);
598
+ const qs = new URLSearchParams({ cwd });
599
+ const result = await adminRequest('GET', `/admin/project-home/status?${qs.toString()}`);
600
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
601
+ }
488
602
  if (name === 'session_manager_help') {
489
603
  const toolName = args && typeof args.tool === 'string' ? args.tool : null;
490
604
  const topic = args && typeof args.topic === 'string' ? args.topic : null;
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // Thin CLI wrapper around validateProjectPageSummary (src/renderer/lib/
3
+ // projectPages/summaryValidate.ts), compiled into
4
+ // scripts/project-pages-logic/dist/logic.cjs by `npm run
5
+ // build:project-pages-logic`. The project-home-builder Epic runs this
6
+ // against its own summary.json before proceeding to Stage 2/3 (see
7
+ // .claude/agents/project-home-builder.md).
8
+ //
9
+ // Usage: node scripts/validate-project-pages-summary.cjs <summary.json path>
10
+ // Exits 0 and prints "valid" on success; exits 1 and prints each error on
11
+ // failure. Same CLI-wrapper pattern as scripts/render-project-pages.cjs.
12
+ //
13
+ // The path argument is used as-is (no allowedRoots validation like
14
+ // config.cjs's validatePath) — this is a standalone local dev/ops script run
15
+ // by a trusted human or agent shell, not an IPC-reachable surface, so it's
16
+ // out of scope for that guard by design.
17
+ 'use strict';
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ function fail(message) {
23
+ console.error(`validate-project-pages-summary: ${message}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ function main() {
28
+ const [summaryPath] = process.argv.slice(2);
29
+ if (!summaryPath) {
30
+ fail('usage: node scripts/validate-project-pages-summary.cjs <summary.json path>');
31
+ }
32
+
33
+ const bundlePath = path.join(__dirname, 'project-pages-logic', 'dist', 'logic.cjs');
34
+ if (!fs.existsSync(bundlePath)) {
35
+ fail(`build bundle not found at ${bundlePath} — run "npm run build:project-pages-logic" first`);
36
+ }
37
+
38
+ let validateProjectPageSummary;
39
+ try {
40
+ ({ validateProjectPageSummary } = require(bundlePath));
41
+ } catch (err) {
42
+ fail(`failed to load ${bundlePath}: ${err.message}`);
43
+ }
44
+
45
+ let summary;
46
+ try {
47
+ summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
48
+ } catch (err) {
49
+ fail(`could not read/parse ${summaryPath}: ${err.message}`);
50
+ }
51
+
52
+ const result = validateProjectPageSummary(summary);
53
+ if (!result.ok) {
54
+ console.error('validate-project-pages-summary: invalid summary:');
55
+ for (const err of result.errors) console.error(` - ${err}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ console.log('valid');
60
+ }
61
+
62
+ main();
@@ -18,6 +18,7 @@ const {
18
18
  resolveEpicModel,
19
19
  findAgentTypeByClaudeSessionId,
20
20
  readPersonaModel,
21
+ resolvePrdPersonaForSpawn,
21
22
  } = require('../lib/agentModelResolve.cjs');
22
23
 
23
24
  const tmpDirs = [];
@@ -136,3 +137,68 @@ test('readPersonaModel returns null (does not throw) for a persona deleted after
136
137
  expect(matches).toHaveLength(1);
137
138
  expect(matches[0].level).toBe('warn');
138
139
  });
140
+
141
+ // resolvePrdPersonaForSpawn — the PRD-path resolver scheduler.cjs's executeJob
142
+ // calls to turn a job's agentType into a --append-system-prompt body and a
143
+ // --model value (PRD 1115).
144
+
145
+ test('resolvePrdPersonaForSpawn returns the persona body and its own model, overriding the fallback', async () => {
146
+ const result = await resolvePrdPersonaForSpawn({
147
+ cwd: '/irrelevant',
148
+ agentType: 'dev-lead',
149
+ deps: {
150
+ getPersonaBody: async ({ name }) => {
151
+ expect(name).toBe('dev-lead');
152
+ return { path: '/home/user/.claude/agents/dev-lead.md', text: '---\nmodel: opus\n---\nOperate methodically.' };
153
+ },
154
+ },
155
+ });
156
+ expect(result.model).toBe('opus');
157
+ expect(result.systemPrompt).toBe('Operate methodically.');
158
+ expect(result.personaPath).toBe('/home/user/.claude/agents/dev-lead.md');
159
+ });
160
+
161
+ test("resolvePrdPersonaForSpawn falls back to the fallback model when the persona's model is 'inherit'", async () => {
162
+ const result = await resolvePrdPersonaForSpawn({
163
+ cwd: '/irrelevant',
164
+ agentType: 'dev-lead',
165
+ deps: { getPersonaBody: async () => ({ path: '/x/dev-lead.md', text: '---\nmodel: inherit\n---\nBody text.' }) },
166
+ });
167
+ expect(result.model).toBe(FALLBACK_MODEL);
168
+ });
169
+
170
+ test('resolvePrdPersonaForSpawn falls back without throwing when agentType no longer resolves to a persona file, and logs it once', async () => {
171
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
172
+ const call = () => resolvePrdPersonaForSpawn({
173
+ cwd,
174
+ agentType: 'ghost-persona',
175
+ deps: { getPersonaBody: async () => null },
176
+ });
177
+ await expect(call()).resolves.toEqual({ model: FALLBACK_MODEL, systemPrompt: null, personaPath: null });
178
+ await call(); // second call must not log a second line (dedup)
179
+
180
+ const { todayFile } = require('../lib/opsErrorLog.cjs');
181
+ const lines = fs.readFileSync(todayFile(cwd), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
182
+ const matches = lines.filter((l) => l.message.includes('ghost-persona'));
183
+ expect(matches).toHaveLength(1);
184
+ expect(matches[0].level).toBe('warn');
185
+ });
186
+
187
+ test('resolvePrdPersonaForSpawn returns the fallback (no persona applied) when agentType is absent, without touching getPersonaBody', async () => {
188
+ const getPersonaBody = async () => { throw new Error('must not be called'); };
189
+ const result = await resolvePrdPersonaForSpawn({ cwd: '/irrelevant', agentType: null, deps: { getPersonaBody } });
190
+ expect(result).toEqual({ model: FALLBACK_MODEL, systemPrompt: null, personaPath: null });
191
+ });
192
+
193
+ test('resolvePrdPersonaForSpawn caps the persona body at 6000 characters with a truncation notice naming the persona path', async () => {
194
+ const longBody = 'x'.repeat(6500);
195
+ const result = await resolvePrdPersonaForSpawn({
196
+ cwd: '/irrelevant',
197
+ agentType: 'dev-lead',
198
+ deps: { getPersonaBody: async () => ({ path: '/home/user/.claude/agents/dev-lead.md', text: longBody }) },
199
+ });
200
+ expect(result.systemPrompt.startsWith('x'.repeat(6000))).toBe(true);
201
+ expect(result.systemPrompt.length).toBeLessThan(longBody.length);
202
+ expect(result.systemPrompt.toLowerCase()).toContain('truncat');
203
+ expect(result.systemPrompt).toContain('/home/user/.claude/agents/dev-lead.md');
204
+ });
@@ -86,7 +86,7 @@ test('failing check with no fix omits the "(fix: ...)" suffix', () => {
86
86
  test('end-to-end: check() reports delegation_chain for this repo, expected to pass here', async () => {
87
87
  const status = await check();
88
88
  expect(status.components.delegation_chain).toBeDefined();
89
- expect(status.components.delegation_chain.checks).toHaveLength(6);
89
+ expect(status.components.delegation_chain.checks).toHaveLength(7);
90
90
  const ids = status.components.delegation_chain.checks.map((c) => c.id);
91
91
  expect(ids).toEqual([
92
92
  'scheduler-mcp',
@@ -95,6 +95,7 @@ test('end-to-end: check() reports delegation_chain for this repo, expected to pa
95
95
  'dev-plugin',
96
96
  'agent-personas',
97
97
  'prd-write-guard',
98
+ 'destructive-git-guard',
98
99
  ]);
99
100
  // This repo registers the scheduler MCP at user scope (~/.claude.json) and
100
101
  // wires guard-prd-writes in .claude/settings.json, and no longer carries a
@@ -0,0 +1,103 @@
1
+ /**
2
+ * prdAgentType.test.cjs — the PRD `agentType` FK (lib/prdAgentType.cjs):
3
+ * throw on write, report on read, mirroring agentModelResolve.cjs's/
4
+ * epicMint.cjs's established convention for the Epic-level agentType FK.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdAgentType.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ import { test, expect } from 'vitest';
12
+
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const {
18
+ DEFAULT_PRD_AGENT_TYPE,
19
+ assertAgentTypeWritable,
20
+ reportDanglingAgentTypeOnce,
21
+ } = require('../lib/prdAgentType.cjs');
22
+ const { todayFile } = require('../lib/opsErrorLog.cjs');
23
+
24
+ async function mkTmpDir(prefix) {
25
+ return fsp.mkdtemp(path.join(os.tmpdir(), prefix));
26
+ }
27
+
28
+ test('DEFAULT_PRD_AGENT_TYPE is dev-lead', () => {
29
+ expect(DEFAULT_PRD_AGENT_TYPE).toBe('dev-lead');
30
+ });
31
+
32
+ test('assertAgentTypeWritable is a no-op when agentType is omitted', async () => {
33
+ await expect(assertAgentTypeWritable('/some/cwd', null)).resolves.toBeUndefined();
34
+ await expect(assertAgentTypeWritable('/some/cwd', undefined)).resolves.toBeUndefined();
35
+ await expect(assertAgentTypeWritable('/some/cwd', '')).resolves.toBeUndefined();
36
+ });
37
+
38
+ test('assertAgentTypeWritable resolves without throwing when a project-overlay persona file exists', async () => {
39
+ const cwd = await mkTmpDir('sm-prd-agenttype-');
40
+ try {
41
+ const agentsDir = path.join(cwd, '.claude', 'agents');
42
+ await fsp.mkdir(agentsDir, { recursive: true });
43
+ await fsp.writeFile(path.join(agentsDir, 'my-persona.md'), '---\nname: my-persona\n---\nbody\n', 'utf8');
44
+ await expect(assertAgentTypeWritable(cwd, 'my-persona')).resolves.toBeUndefined();
45
+ } finally {
46
+ await fsp.rm(cwd, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ test('assertAgentTypeWritable rejects an unknown persona name, naming available personas in the error', async () => {
51
+ const cwd = await mkTmpDir('sm-prd-agenttype-');
52
+ try {
53
+ await expect(
54
+ assertAgentTypeWritable(cwd, 'zzz-definitely-not-a-real-persona-12345'),
55
+ ).rejects.toThrow(/does not resolve to a readable persona file/);
56
+ await expect(
57
+ assertAgentTypeWritable(cwd, 'zzz-definitely-not-a-real-persona-12345'),
58
+ ).rejects.toThrow(/Available personas/);
59
+ } finally {
60
+ await fsp.rm(cwd, { recursive: true, force: true });
61
+ }
62
+ });
63
+
64
+ // READ side of the FK, mirroring agentModelResolve.test.cjs's sibling test
65
+ // for the Epic-level FK: verify against the REAL opsErrorLog write path
66
+ // (todayFile), not a mock — a plain mkdtemp() cwd is not "ephemeral"
67
+ // (ephemeralCwd.cjs only refuses os.tmpdir() itself / managed worktree
68
+ // roots), so appendError writes a real line here.
69
+ test('reportDanglingAgentTypeOnce logs once via opsErrorLog when the persona does not resolve, and never throws', async () => {
70
+ const cwd = await mkTmpDir('sm-prd-agenttype-log-');
71
+ try {
72
+ expect(() => {
73
+ reportDanglingAgentTypeOnce(cwd, 'ghost-persona-xyz', { personaExists: () => false });
74
+ reportDanglingAgentTypeOnce(cwd, 'ghost-persona-xyz', { personaExists: () => false });
75
+ }).not.toThrow();
76
+
77
+ const lines = fs.readFileSync(todayFile(cwd), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
78
+ const matches = lines.filter((l) => l.message.includes('ghost-persona-xyz'));
79
+ // Logged once despite two calls above — dedup key is (cwd, agentType).
80
+ expect(matches).toHaveLength(1);
81
+ expect(matches[0].level).toBe('warn');
82
+ expect(matches[0].scope).toBe('prdAgentType');
83
+ } finally {
84
+ await fsp.rm(cwd, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test('reportDanglingAgentTypeOnce is a no-op when the persona resolves', async () => {
89
+ const cwd = await mkTmpDir('sm-prd-agenttype-log-');
90
+ try {
91
+ reportDanglingAgentTypeOnce(cwd, 'real-persona', { personaExists: () => true });
92
+ expect(fs.existsSync(todayFile(cwd))).toBe(false);
93
+ } finally {
94
+ await fsp.rm(cwd, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ test('reportDanglingAgentTypeOnce is a no-op when cwd or agentType is missing', () => {
99
+ expect(() => {
100
+ reportDanglingAgentTypeOnce(null, 'x', { personaExists: () => false });
101
+ reportDanglingAgentTypeOnce('/some/cwd-missing-agenttype', null, { personaExists: () => false });
102
+ }).not.toThrow();
103
+ });
@@ -80,6 +80,23 @@ test('buildPrdBody emits required frontmatter keys and body sections in order',
80
80
  expect(body.includes('Lay out hot data contiguously')).toBeFalsy();
81
81
  });
82
82
 
83
+ test('buildPrdBody emits agentType: dev-lead by default when omitted (PRD 1114)', () => {
84
+ const body = buildPrdBody({
85
+ title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
86
+ acceptanceCriteria: ['a'], implementationNotes: 'n',
87
+ });
88
+ expect(body).toMatch(/^agentType: dev-lead$/m);
89
+ });
90
+
91
+ test('buildPrdBody emits the supplied agentType instead of the default', () => {
92
+ const body = buildPrdBody({
93
+ title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
94
+ acceptanceCriteria: ['a'], implementationNotes: 'n',
95
+ agentType: 'architect',
96
+ });
97
+ expect(body).toMatch(/^agentType: architect$/m);
98
+ });
99
+
83
100
  test('buildPrdBody omits parallelGroup frontmatter key when not supplied', () => {
84
101
  const body = buildPrdBody({
85
102
  title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
@@ -530,6 +547,56 @@ test('POST /admin/scheduler/create-prd with tag writes it into the created PRD f
530
547
  }
531
548
  });
532
549
 
550
+ // ──────────────────────────────────────────── PRD 1114: agentType (WHO
551
+ // executes a PRD) — throw on write for an unresolvable persona, default to
552
+ // dev-lead when omitted.
553
+
554
+ test('createPrd rejects an unknown agentType at write time, naming available personas, and writes no file', async () => {
555
+ const prdsDir = await mkTmpPrdsDir();
556
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
557
+ const writePrdSpy = vi.fn(remote.writePrd);
558
+ remote.writePrd = writePrdSpy;
559
+
560
+ const result = await createPrd(
561
+ validCreateBody({ agentType: 'zzz-definitely-not-a-real-persona-12345' }),
562
+ remote,
563
+ );
564
+
565
+ expect(result.ok).toBe(false);
566
+ expect(result.status).toBe(400);
567
+ expect(result.error).toMatch(/does not resolve to a readable persona file/);
568
+ expect(result.error).toMatch(/Available personas/);
569
+ expect(writePrdSpy).not.toHaveBeenCalled();
570
+ const entries = await fsp.readdir(prdsDir);
571
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
572
+ });
573
+
574
+ test('createPrd accepts a caller-supplied agentType that resolves to a project-overlay persona file', async () => {
575
+ const prdsDir = await mkTmpPrdsDir();
576
+ const cwd = path.dirname(path.dirname(prdsDir)); // mkTmpPrdsDir's root (prdsDir = root/.claude/prds)
577
+ const agentsDir = path.join(cwd, '.claude', 'agents');
578
+ await fsp.mkdir(agentsDir, { recursive: true });
579
+ await fsp.writeFile(path.join(agentsDir, 'my-persona.md'), '---\nname: my-persona\n---\nbody\n', 'utf8');
580
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
581
+
582
+ const result = await createPrd(validCreateBody({ cwd, slug: 'with-agent-type', agentType: 'my-persona' }), remote);
583
+
584
+ expect(result.ok).toBe(true);
585
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
586
+ expect(written).toMatch(/^agentType: my-persona$/m);
587
+ });
588
+
589
+ test('createPrd defaults agentType to dev-lead when omitted, without any persona-existence check', async () => {
590
+ const prdsDir = await mkTmpPrdsDir();
591
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
592
+
593
+ const result = await createPrd(validCreateBody({ slug: 'no-agent-type-supplied' }), remote);
594
+
595
+ expect(result.ok).toBe(true);
596
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
597
+ expect(written).toMatch(/^agentType: dev-lead$/m);
598
+ });
599
+
533
600
  // ──────────────────────────────────────────── originClaudeSessionId fallback
534
601
  // (2026-08-01 fix: a PRD queued via the scheduler_create_prd MCP tool from
535
602
  // inside an Epic's own chat session, without an explicit sourcePromptId,
@@ -692,6 +759,77 @@ test('createPrd with neither cwd nor a resolvable Epic fails with a clear cwd-re
692
759
  expect(result.error).toMatch(/cwd is required/);
693
760
  });
694
761
 
762
+ // ──────────────────────────────────────────── PRD 1113: fix-chain depth guard
763
+ // at the authoring boundary. lib/prdCreate.cjs's createPrd() is the shared
764
+ // write path behind both scheduler_create_prd (MCP) and chat:create-prd
765
+ // (IPC) — enforcing here covers both callers with one check.
766
+
767
+ test('createPrd refuses a depth-2 fix-chain slug (fix-of-a-fix) with a distinct, human-escalation error and writes no file', async () => {
768
+ const prdsDir = await mkTmpPrdsDir();
769
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
770
+ const writePrdSpy = vi.fn(remote.writePrd);
771
+ remote.writePrd = writePrdSpy;
772
+
773
+ const result = await createPrd(validCreateBody({ slug: 'fix-fix-widget-frobnication' }), remote);
774
+
775
+ expect(result.ok).toBe(false);
776
+ expect(result.status).toBe(409);
777
+ // Distinct from the provenance/validation error text elsewhere in this
778
+ // file ('cwd rejected', 'invalid PRD payload', 'already exists') and
779
+ // explicitly states escalation, not another attempt, is correct.
780
+ expect(result.error).toMatch(/fix-chain depth cap/i);
781
+ expect(result.error).toMatch(/not the correct response/i);
782
+ expect(result.error).toMatch(/human/i);
783
+ expect(writePrdSpy).not.toHaveBeenCalled();
784
+ const entries = await fsp.readdir(prdsDir);
785
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
786
+ });
787
+
788
+ test('createPrd refuses a depth-3 fix-chain slug the same way as depth-2', async () => {
789
+ const prdsDir = await mkTmpPrdsDir();
790
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
791
+
792
+ const result = await createPrd(validCreateBody({ slug: 'fix-fix-fix-widget-frobnication' }), remote);
793
+
794
+ expect(result.ok).toBe(false);
795
+ expect(result.status).toBe(409);
796
+ const entries = await fsp.readdir(prdsDir);
797
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
798
+ });
799
+
800
+ test('createPrd allows a depth-1 fix slug through the same code path that rejects depth 2 (matches MAX_INVESTIGATION_DEPTH=1)', async () => {
801
+ const prdsDir = await mkTmpPrdsDir();
802
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
803
+
804
+ const result = await createPrd(validCreateBody({ slug: 'fix-widget-frobnication' }), remote);
805
+
806
+ expect(result.ok).toBe(true);
807
+ expect(result.filename).toMatch(/^\d+-fix-widget-frobnication\.md$/);
808
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
809
+ expect(written).toMatch(/title: Add widget frobnication/);
810
+ });
811
+
812
+ test('createPrd is unaffected by the fix-chain guard for an ordinary slug with no fix chain at all', async () => {
813
+ const prdsDir = await mkTmpPrdsDir();
814
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
815
+
816
+ const result = await createPrd(validCreateBody(), remote);
817
+
818
+ expect(result.ok).toBe(true);
819
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
820
+ expect(written).toMatch(/title: Add widget frobnication/);
821
+ });
822
+
823
+ test('createPrd is not fooled by a slug where a later segment merely reads "fix" (false-positive bait)', async () => {
824
+ const prdsDir = await mkTmpPrdsDir();
825
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
826
+
827
+ const result = await createPrd(validCreateBody({ slug: 'fix-loop-extraction-and-round-trip' }), remote);
828
+
829
+ expect(result.ok).toBe(true);
830
+ expect(result.filename).toMatch(/^\d+-fix-loop-extraction-and-round-trip\.md$/);
831
+ });
832
+
695
833
  test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
696
834
  const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
697
835
  config.addAllowedRoot(root);