claude-code-session-manager 0.86.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
  27. package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +14 -14
  30. package/plugins/CLAUDE.md +61 -0
  31. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  32. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
  33. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
  34. package/scripts/scheduler-mcp-server.cjs +7 -0
  35. package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
  36. package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
  37. package/src/main/__tests__/epicMint.test.cjs +2 -2
  38. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  39. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  40. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  41. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  42. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  43. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  44. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  45. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  46. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  47. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  48. package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
  49. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  50. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  51. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  52. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  53. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  54. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  55. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  56. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  57. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  58. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  59. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  60. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  61. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  62. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
  63. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  64. package/src/main/chatRunner.cjs +8 -5
  65. package/src/main/health.cjs +1 -1
  66. package/src/main/historyAggregator.cjs +5 -0
  67. package/src/main/index.cjs +95 -44
  68. package/src/main/ipcSchemas.cjs +47 -0
  69. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  70. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  71. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  72. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  73. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  74. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  75. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  76. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  77. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  78. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  79. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  80. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  81. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  82. package/src/main/lib/agentModelResolve.cjs +65 -27
  83. package/src/main/lib/bootSelfHeal.cjs +88 -0
  84. package/src/main/lib/delegationReadiness.cjs +290 -225
  85. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  86. package/src/main/lib/ephemeralCwd.cjs +1 -1
  87. package/src/main/lib/epicMint.cjs +3 -3
  88. package/src/main/lib/gitWorktree.cjs +42 -12
  89. package/src/main/lib/guardShims.cjs +156 -0
  90. package/src/main/lib/jobDirtFilter.cjs +7 -2
  91. package/src/main/lib/launchFailure.cjs +2 -1
  92. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  93. package/src/main/lib/needsReviewLedger.cjs +205 -0
  94. package/src/main/lib/opsErrorLog.cjs +1 -1
  95. package/src/main/lib/opsOwnership.cjs +1 -1
  96. package/src/main/lib/prdCreate.cjs +56 -1
  97. package/src/main/lib/prdDisposition.cjs +199 -0
  98. package/src/main/lib/prdFrontmatter.cjs +8 -2
  99. package/src/main/lib/prdLocations.cjs +167 -45
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  101. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  102. package/src/main/lib/projectRootResolve.cjs +1 -1
  103. package/src/main/lib/queueHistory.cjs +19 -1
  104. package/src/main/lib/queueStore.cjs +6 -1
  105. package/src/main/lib/reaperHelpers.cjs +181 -15
  106. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  107. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  108. package/src/main/lib/schedulerConfig.cjs +24 -0
  109. package/src/main/lib/usageCircuit.cjs +159 -0
  110. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  111. package/src/main/scheduler/prdParser.cjs +13 -0
  112. package/src/main/scheduler.cjs +1285 -143
  113. package/src/main/templates/PRD_AUTHORING.md +50 -0
  114. package/src/main/templates/project-pages-catalog.json +1 -1
  115. package/src/main/usage.cjs +21 -3
  116. package/src/preload/api.d.ts +92 -1
  117. package/src/preload/index.cjs +10 -0
  118. package/web/README.md +41 -0
  119. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  120. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  121. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  122. package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
  123. package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
  124. package/dist/assets/Permissions-BiYZNGYW.js +0 -3
  125. package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
  126. package/dist/assets/Settings-Cv-pRyms.js +0 -3
  127. package/dist/assets/settingsSchema-BJVciriw.js +0 -3
  128. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-CXFQIPhO.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-fc_JjdxL.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-CDo9xBR9.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-DV3PorRY.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.86.0",
3
+ "version": "0.87.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -12,13 +12,12 @@
12
12
  ".claude-plugin/",
13
13
  "plugins/",
14
14
  "scripts/postinstall.cjs",
15
- "scripts/lib/",
16
15
  "scripts/mint-epic.cjs",
17
16
  "scripts/scheduler-mcp-server.cjs",
18
- "scripts/render-project-pages.cjs",
19
- "scripts/render-project-pages/dist/",
20
- "scripts/project-pages-logic/dist/",
21
- "scripts/validate-project-pages-summary.cjs",
17
+ "web/project-pages/render.cjs",
18
+ "web/project-pages/renderer/dist/",
19
+ "web/project-pages/logic/dist/",
20
+ "web/project-pages/validate-summary.cjs",
22
21
  "scripts/hooks/guard-prd-writes.cjs",
23
22
  "scripts/hooks/guard-destructive-git.cjs",
24
23
  "scripts/hooks/guard-inline-implementation.cjs",
@@ -36,10 +35,10 @@
36
35
  "dev:renderer": "vite",
37
36
  "dev:electron": "wait-on http://localhost:5173 && SM_DEV=1 electron .",
38
37
  "build": "vite build",
39
- "build:project-pages": "node scripts/build-project-pages-renderer.mjs",
40
- "build:project-pages-logic": "node scripts/build-project-pages-logic.mjs",
41
- "build:project-pages-assets": "node scripts/project-pages-assets.cjs",
42
- "project-pages:gate": "node scripts/project-pages-publish-gate.cjs",
38
+ "build:project-pages": "node web/project-pages/build-renderer.mjs",
39
+ "build:project-pages-logic": "node web/project-pages/build-logic.mjs",
40
+ "build:project-pages-assets": "node web/project-pages/assets.cjs",
41
+ "project-pages:gate": "node web/project-pages/publish-gate.cjs",
43
42
  "typecheck": "tsc --noEmit",
44
43
  "start": "electron .",
45
44
  "postinstall": "node scripts/postinstall.cjs",
@@ -51,13 +50,14 @@
51
50
  "test:e2e:gen-fixture": "espeak-ng -w /tmp/sm-raw.wav -s 140 'testing one two three four five' && ffmpeg -y -i /tmp/sm-raw.wav -ar 48000 -ac 2 -sample_fmt s16 e2e/fixtures/speech.wav",
52
51
  "refresh:vad-assets": "cp node_modules/@ricky0123/vad-web/dist/silero_vad_v5.onnx node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js src/renderer/public/vad/ && cp node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs src/renderer/public/vad/",
53
52
  "health": "node src/main/health.cjs",
54
- "manual:check": "node scripts/build-manual.mjs --check",
55
- "manual:build": "node scripts/build-manual.mjs",
56
- "manual:figures": "xvfb-run -a node scripts/capture-manual-figures.mjs",
53
+ "manual:check": "node web/manual/build.mjs --check",
54
+ "manual:build": "node web/manual/build.mjs",
55
+ "manual:figures": "xvfb-run -a node web/manual/capture-figures.mjs",
57
56
  "lint:selectors": "node scripts/check-unstable-selectors.cjs",
58
57
  "lint:hooks": "node scripts/check-conditional-hooks.cjs",
59
58
  "lint:unregistered-tests": "node scripts/check-unregistered-tests.cjs",
60
- "lint": "npm run lint:selectors && npm run lint:hooks && npm run lint:unregistered-tests"
59
+ "lint": "npm run lint:selectors && npm run lint:hooks && npm run lint:unregistered-tests",
60
+ "sync:settings-schema": "node scripts/sync-settings-schema.cjs"
61
61
  },
62
62
  "keywords": [
63
63
  "claude",
@@ -0,0 +1,61 @@
1
+ # plugins/ — scoped context
2
+
3
+ Part of the **AGENT LAYER** partition — see
4
+ [`project-partition.md`](../session-manager-operations/architecture/project-partition.md).
5
+
6
+ ## What's here
7
+
8
+ `session-manager-dev/skills/` — 15 skills (`blog-for-project-feature`, `builder`, `develop`,
9
+ `discover-features`, `explain-to-me`, `find-opportunity`, `issue-address`,
10
+ `local-project-health`, `memory-sanitation`, `ops-sweep`, `project-status`, `pr-review-sweep`,
11
+ `pr-signal`, `requesting-code-review`, `send-feedback`) — verified against `ls
12
+ plugins/session-manager-dev/skills` 2026-09-13; keep in sync with `project-partition.md` and
13
+ `seedDevPlugin.cjs`'s header count.
14
+
15
+ Two manifests: root [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json)
16
+ declares the one plugin (`source: "./plugins/session-manager-dev"`); the plugin's own
17
+ [`session-manager-dev/.claude-plugin/plugin.json`](session-manager-dev/.claude-plugin/plugin.json)
18
+ carries the `description` shown in the marketplace UI — list only shipped skills.
19
+
20
+ `src/main/seedDevPlugin.cjs` is the first-boot offline installer — installs this plugin from its
21
+ bundled marketplace (no GitHub/registry call) as a default on a fresh machine, then marks
22
+ `~/.claude/session-manager/.dev-plugin-seeded`.
23
+
24
+ **`skills/develop/standards.md` is a frozen absolute-path contract.** Every scheduler PRD, in
25
+ every consuming repo, points its headless executor at it by resolved absolute path, baked
26
+ in at authoring time — including PRDs already archived in OTHER repos. Renaming or moving it
27
+ breaks them all, undetectably from here.
28
+
29
+ **Path literals inside shipped skills.** A skill runs in OTHER repos, so a
30
+ session-manager-internal path (`src/main/...`, `src/renderer/...`, bare `scripts/...`) inside one
31
+ is a portability defect unless `$SM_ROOT`-qualified (see `ops-sweep/SKILL.md`'s resolution) or
32
+ removed. Current offenders, not precedent: `memory-sanitation/SKILL.md`, `builder/SKILL.md` +
33
+ `builder/0-diff/SKILL.md`, `develop/SKILL.md`, `develop/standards.md`, `ops-sweep/SKILL.md`.
34
+
35
+ ## Who consumes this
36
+
37
+ **Other repos**, not (only) this one. A project installs `claude-code-session-manager` from npm,
38
+ or references its skills/hooks directly, for the same dev workflow session-manager uses on itself.
39
+ Editing a skill here changes behavior in every consuming repo. See
40
+ [`../session-manager-operations/reviews/2026-09-12-agent-layer-consumers.md`](../session-manager-operations/reviews/2026-09-12-agent-layer-consumers.md)
41
+ for live sibling consumers.
42
+
43
+ ## What must NOT be assumed
44
+
45
+ - Treat every skill edit here as a cross-repo API change, not a local-only one.
46
+ - Do not vendor `scripts/hooks/guard-*.cjs` into a skill/repo — adopt **by reference** via the
47
+ stable shim (`src/main/lib/guardShims.cjs`) at `~/.claude/session-manager/hooks/guard-*.cjs`,
48
+ never this repo's own absolute path or a copy.
49
+ - Do not confuse this with `.claude/` at the repo root — this repo's own unshipped local dev
50
+ config. `.claude/agents/*.md` are per-project OVERLAYS that win over
51
+ `~/.claude/agents/*.md` (Claude Code's own precedence, per `src/main/agentLibrary.cjs`);
52
+ `src/seed/agents/` is the shipped source for exactly the three personas
53
+ `src/main/seedAgentPersonas.cjs` copies into `~/.claude/agents/` on first boot (`architect`,
54
+ `dev-lead`, `project-home-builder`); `builder` and `bilko-host-publisher` have no seed source
55
+ and exist only as this repo's overlays. Tracked on purpose: `.claude/settings.json` (guard
56
+ hooks) plus the `builder.md`/`project-home-builder.md` overlays (auditable edits) —
57
+ `bilko-host-publisher.md` is untracked despite being in use; nothing else under `.claude/` is.
58
+ - `.mcp.json` at the repo root registers only `bilko-host` — the scheduler MCP server
59
+ (`session-manager-scheduler`) is registered at USER scope in `~/.claude.json`, not here.
60
+ - A skill may run headless via a scheduled PRD in another project — don't rely on
61
+ interactive-session-only features; check `/develop`'s scheduler flow first.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-manager-dev",
3
- "description": "The default end-to-end development skillset for the Claude Code Session Manager: decompose work into scheduler PRDs (/develop), hand a finding to another project on this machine (/send-feedback), rank what to work on next in a shared repo (/find-opportunity), roll up operational status and optimize the North-Star KPI (/project-status, /optimize-kpi, /local-project-health), gate with review (/requesting-code-review, /security-review), document how the project works (/explain-to-me), and keep the auto-memory store clean (/memory-sanitation). Together with the session-manager scheduler these form a closed develop → queue → track → review → status → improve loop.",
3
+ "description": "The default end-to-end development skillset for the Claude Code Session Manager: decompose work into scheduler PRDs (/develop), hand a finding to another project on this machine (/send-feedback), rank what to work on next in a shared repo (/find-opportunity), roll up operational status (/project-status, /local-project-health), gate with review (/requesting-code-review), document how the project works (/explain-to-me), and keep the auto-memory store clean (/memory-sanitation). Together with the session-manager scheduler these form a closed develop → queue → track → review → status → improve loop.",
4
4
  "version": "0.1.0",
5
5
  "author": {
6
6
  "name": "Stanislav Georgiev",
@@ -54,7 +54,7 @@ Hard rules — these are the ones that protect a paying customer:
54
54
  the one thing people get wrong, the workflow that pays off. If a paragraph would be equally
55
55
  at home in `CLAUDE.md`, cut it.
56
56
  - **Preserve the free chapter.** At least one chapter must stay `free: true` — it is the
57
- marketing sample, and `scripts/build-manual.mjs` refuses to build without one.
57
+ marketing sample, and `web/manual/build.mjs` refuses to build without one.
58
58
 
59
59
  ### 3. Bump the manual's own version
60
60
 
@@ -133,5 +133,5 @@ id(s) or PRD(s) filed.
133
133
  directly; don't run the full sweep for a question with a known answer.
134
134
  - Cron/scheduled sweeping across every active project cwd is an explicit
135
135
  stretch goal, not implemented here — this skill is manual, single-project,
136
- invoked per run. `scripts/lib/watchdogHelpers.cjs`'s `activeProjectCwds()`
136
+ invoked per run. `src/main/lib/watchdogHelpers.cjs`'s `activeProjectCwds()`
137
137
  is the reusable piece if that's ever built.
@@ -164,6 +164,11 @@ const TOOLS = [
164
164
  slug: { type: 'string', description: 'Optional kebab-case slug; derived from title if omitted' },
165
165
  parallelGroup: { type: 'number', description: 'DEPRECATED and ignored (PRD 832): numbers are strictly unique per project; use dependsOn for ordering' },
166
166
  dependsOn: { type: 'array', items: { type: 'string' }, description: 'Optional: PRD slugs that must complete before this one becomes eligible (replaces the retired shared-NN-parallel convention)' },
167
+ disposition: {
168
+ type: 'string',
169
+ enum: ['append', 'new-head'],
170
+ description: 'Required when this PRD would land as a new root (no dependsOn given) in an Epic that already has incomplete PRDs: "append" depends this wave on the existing chain\'s current terminal PRD(s); "new-head" leaves it independent, eligible to run in parallel. Omit when the Epic has no incomplete PRDs yet, or when dependsOn already places this PRD explicitly.',
171
+ },
167
172
  sourcePromptId: { type: 'string', description: "Effectively required: an EXISTING, already-human-approved Epic's promptSessionId (the id shown in the Epics list / active-index.json sessions key) to join — NOT a PromptTicket.id (those are distinct fields). The server never mints a new Epic; if this is omitted and no Epic can be resolved (via the SM_CHAT_SESSION_ID fallback below), the write is refused with an error telling the caller to create/approve an Epic first." },
168
173
  sourceTabId: { type: 'string', description: 'Optional: tab id (claudeSessionId) this PRD was queued from, so the scheduler can route a completion status prompt back to it' },
169
174
  tag: {
@@ -231,6 +236,8 @@ const TOOLS = [
231
236
  tag: { type: 'string', enum: PRD_WORK_TYPES },
232
237
  agentType: { type: 'string', description: 'WHO executes this PRD (persona name) — patchable after creation too, see scheduler_create_prd' },
233
238
  quietMachine: { type: 'boolean', description: 'Optional: the exclusive machine-wide quiet lease flag (see scheduler_create_prd) — patchable after creation too' },
239
+ dependsOn: { type: 'array', items: { type: 'string' }, description: 'Optional: replaces the dependsOn list; an explicit empty array CLEARS it — see scheduler_create_prd' },
240
+ disposition: { type: 'string', enum: ['append', 'new-head'], description: 'Optional: patches the recorded wave-authoring decision — see scheduler_create_prd' },
234
241
  },
235
242
  },
236
243
  body: { type: 'string', description: 'Optional: full replacement body (everything after the frontmatter)' },
@@ -17,7 +17,7 @@ const {
17
17
  FALLBACK_MODEL,
18
18
  resolveEpicModel,
19
19
  findAgentTypeByClaudeSessionId,
20
- readPersonaModel,
20
+ readOverlayAwarePersonaModel,
21
21
  resolvePrdPersonaForSpawn,
22
22
  } = require('../lib/agentModelResolve.cjs');
23
23
 
@@ -103,37 +103,39 @@ test('findAgentTypeByClaudeSessionId returns null for no match', async () => {
103
103
  expect(findAgentTypeByClaudeSessionId(cwd, 'missing')).toBeNull();
104
104
  });
105
105
 
106
- test('readPersonaModel returns null for a persona with no model field', async () => {
106
+ test('readOverlayAwarePersonaModel returns null for a persona with no model field', async () => {
107
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
107
108
  const globalDir = await mkTmpDir('sm-agentmodel-agents-');
108
109
  writePersona(globalDir, 'no-model-persona', []);
109
- expect(readPersonaModel('no-model-persona', { globalDir, validatePath: noopValidatePath })).toBeNull();
110
+ expect(readOverlayAwarePersonaModel('no-model-persona', { cwd, globalDir, validatePath: noopValidatePath })).toBeNull();
110
111
  });
111
112
 
112
- test('readPersonaModel returns null (never throws) for a path-traversal agentType rejected by validatePath', async () => {
113
+ test('readOverlayAwarePersonaModel returns null (never throws) for a path-traversal agentType rejected by validatePath', async () => {
114
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
113
115
  const globalDir = await mkTmpDir('sm-agentmodel-agents-');
114
116
  const realValidatePath = (p) => {
115
117
  const real = path.resolve(p);
116
118
  if (!real.startsWith(path.resolve(globalDir) + path.sep)) throw new Error('outside allowed boundaries');
117
119
  return real;
118
120
  };
119
- expect(readPersonaModel('../../../etc/passwd', { globalDir, validatePath: realValidatePath })).toBeNull();
121
+ expect(readOverlayAwarePersonaModel('../../../etc/passwd', { cwd, globalDir, validatePath: realValidatePath })).toBeNull();
120
122
  });
121
123
 
122
124
  // READ side of the agentType FK: a persona that was valid when the Epic was
123
125
  // created but got deleted afterward (rename/deletion in Agent Library) must
124
126
  // still resolve to null, not throw — the WRITE side (epicMint.cjs's
125
127
  // ensureEpic) is what refuses a bad reference at creation time.
126
- test('readPersonaModel returns null (does not throw) for a persona deleted after the Epic was created, and logs it once via opsErrorLog', async () => {
128
+ test('readOverlayAwarePersonaModel returns null (does not throw) for a persona deleted after the Epic was created, and logs it once via opsErrorLog', async () => {
127
129
  const cwd = await mkTmpDir('sm-agentmodel-cwd-');
128
130
  const globalDir = await mkTmpDir('sm-agentmodel-agents-'); // never written to — simulates a deleted persona
129
131
 
130
- expect(() => readPersonaModel('deleted-persona', { globalDir, validatePath: noopValidatePath, cwd })).not.toThrow();
131
- expect(readPersonaModel('deleted-persona', { globalDir, validatePath: noopValidatePath, cwd })).toBeNull();
132
+ expect(() => readOverlayAwarePersonaModel('deleted-persona', { globalDir, validatePath: noopValidatePath, cwd })).not.toThrow();
133
+ expect(readOverlayAwarePersonaModel('deleted-persona', { globalDir, validatePath: noopValidatePath, cwd })).toBeNull();
132
134
 
133
135
  const { todayFile } = require('../lib/opsErrorLog.cjs');
134
136
  const lines = fs.readFileSync(todayFile(cwd), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
135
137
  const matches = lines.filter((l) => l.message.includes('deleted-persona'));
136
- // Logged once despite two readPersonaModel calls above — dedup key is (cwd, agentType).
138
+ // Logged once despite two readOverlayAwarePersonaModel calls above — dedup key is (cwd, agentType).
137
139
  expect(matches).toHaveLength(1);
138
140
  expect(matches[0].level).toBe('warn');
139
141
  });
@@ -190,6 +192,95 @@ test('resolvePrdPersonaForSpawn returns the fallback (no persona applied) when a
190
192
  expect(result).toEqual({ model: FALLBACK_MODEL, systemPrompt: null, personaPath: null });
191
193
  });
192
194
 
195
+ // Overlay-aware resolution (unify-epic-model-resolution): resolveEpicModel
196
+ // must use the SAME project-overlay-then-global precedence
197
+ // resolvePrdPersonaForSpawn already uses — so a project's
198
+ // `.claude/agents/<name>.md` wins for a launched Chat/Terminal session
199
+ // exactly like it already does for a scheduled PRD.
200
+
201
+ test('resolveEpicModel prefers a project-overlay persona over the global one with the same name', async () => {
202
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
203
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-');
204
+ writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-overlay', agentType: 'shared-persona' } });
205
+ writePersona(globalDir, 'shared-persona', ['model: opus']);
206
+ const projectAgentsDir = path.join(cwd, '.claude', 'agents');
207
+ writePersona(projectAgentsDir, 'shared-persona', ['model: haiku']);
208
+
209
+ const model = resolveEpicModel({
210
+ cwd,
211
+ claudeSessionId: 'sess-overlay',
212
+ deps: { globalDir, validatePath: noopValidatePath },
213
+ });
214
+
215
+ expect(model).toBe('haiku');
216
+ });
217
+
218
+ test('resolveEpicModel falls through to the global persona when no project overlay exists', async () => {
219
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
220
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-');
221
+ writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-global-only', agentType: 'global-only-persona' } });
222
+ writePersona(globalDir, 'global-only-persona', ['model: opus']);
223
+
224
+ const model = resolveEpicModel({
225
+ cwd,
226
+ claudeSessionId: 'sess-global-only',
227
+ deps: { globalDir, validatePath: noopValidatePath },
228
+ });
229
+
230
+ expect(model).toBe('opus');
231
+ });
232
+
233
+ test('resolveEpicModel falls back to FALLBACK_MODEL (never throws) for a dangling agentType with no persona file anywhere, and logs it once', async () => {
234
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
235
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-'); // never written to
236
+ writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-dangling', agentType: 'ghost-persona' } });
237
+
238
+ const call = () => resolveEpicModel({
239
+ cwd,
240
+ claudeSessionId: 'sess-dangling',
241
+ deps: { globalDir, validatePath: noopValidatePath },
242
+ });
243
+
244
+ expect(call).not.toThrow();
245
+ expect(call()).toBe(FALLBACK_MODEL);
246
+ call(); // second call must not log a second line (dedup per (cwd, agentType))
247
+
248
+ const { todayFile } = require('../lib/opsErrorLog.cjs');
249
+ const lines = fs.readFileSync(todayFile(cwd), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
250
+ const matches = lines.filter((l) => l.message.includes('ghost-persona'));
251
+ expect(matches).toHaveLength(1);
252
+ });
253
+
254
+ // Chat (chatRunner.cjs, in-process) and Terminal (EpicTerminalPane.tsx, via
255
+ // the agents:resolve-epic-model IPC added in this change) both call this
256
+ // exact function with the same { cwd, claudeSessionId } shape — so "the same
257
+ // Epic resolves identically in both views" is structural (one function, one
258
+ // call signature), not a coincidence of two implementations agreeing. This
259
+ // asserts the function is a pure, deterministic read of that Epic's overlay-
260
+ // resolved persona model, which is what makes that guarantee hold.
261
+ test('resolveEpicModel resolves identically for the same Epic across repeated calls (the guarantee Chat and Terminal both rely on)', async () => {
262
+ const cwd = await mkTmpDir('sm-agentmodel-cwd-');
263
+ const globalDir = await mkTmpDir('sm-agentmodel-agents-');
264
+ writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-shared', agentType: 'shared-epic-persona' } });
265
+ writePersona(globalDir, 'shared-epic-persona', ['model: haiku']);
266
+
267
+ const callArgs = { cwd, claudeSessionId: 'sess-shared', deps: { globalDir, validatePath: noopValidatePath } };
268
+ const chatResult = resolveEpicModel(callArgs);
269
+ const terminalResult = resolveEpicModel(callArgs); // simulates the IPC-forwarded call Terminal makes
270
+
271
+ expect(chatResult).toBe('haiku');
272
+ expect(terminalResult).toBe(chatResult);
273
+ });
274
+
275
+ // --model must never be left unpinned (CLAUDE.md model-pinning rule) on any
276
+ // of the three launch paths. resolvePrdPersonaForSpawn's own miss-path tests
277
+ // above already cover the scheduler call site; this covers the shared
278
+ // Chat/Terminal resolver.
279
+ test('resolveEpicModel never returns an empty/falsy --model value, even on every miss path', async () => {
280
+ expect(resolveEpicModel({})).toBeTruthy();
281
+ expect(resolveEpicModel({ cwd: '/nonexistent-cwd-xyz', claudeSessionId: 'no-such-session' })).toBeTruthy();
282
+ });
283
+
193
284
  test('resolvePrdPersonaForSpawn caps the persona body at 6000 characters with a truncation notice naming the persona path', async () => {
194
285
  const longBody = 'x'.repeat(6500);
195
286
  const result = await resolvePrdPersonaForSpawn({
@@ -101,4 +101,22 @@ test('flush() with no pending timer still sends (no-arm case)', async () => {
101
101
  expect(sent).toEqual(['state-1']);
102
102
  });
103
103
 
104
+ test('a getPayload() that throws does not wedge the coalescer — the next schedule() still sends', async () => {
105
+ const sent = [];
106
+ const send = vi.fn((payload) => sent.push(payload));
107
+ const getPayload = vi.fn()
108
+ .mockRejectedValueOnce(new Error('boom'))
109
+ .mockResolvedValue('state-recovered');
110
+ const coalescer = createBroadcastCoalescer({ delayMs: 200, send, getPayload });
111
+
112
+ coalescer.schedule();
113
+ await vi.advanceTimersByTimeAsync(200);
114
+ expect(sent).toEqual([]);
115
+
116
+ coalescer.schedule();
117
+ await vi.advanceTimersByTimeAsync(200);
118
+ expect(sent).toEqual(['state-recovered']);
119
+ expect(getPayload).toHaveBeenCalledTimes(2);
120
+ });
121
+
104
122
  console.log('broadcastCoalescer tests: PASS');
@@ -287,8 +287,8 @@ test('ensureEpic refuses the mint and appends an epic_mint_refused audit event w
287
287
  // "throw on write, report on read" — a mint whose agentType names a persona
288
288
  // that doesn't resolve is refused outright; a persona that goes dangling
289
289
  // LATER (renamed/deleted after the Epic was minted) is legitimate history,
290
- // left to agentModelResolve.cjs's readPersonaModel (stays permissive) and the
291
- // ops-hygiene sweep to report.
290
+ // left to agentModelResolve.cjs's readOverlayAwarePersonaModel (stays
291
+ // permissive) and the ops-hygiene sweep to report.
292
292
 
293
293
  test('minting with an unknown agentType throws and names the two paths checked', async () => {
294
294
  const cwd = await mkCwd();
@@ -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(8);
89
+ expect(status.components.delegation_chain.checks).toHaveLength(9);
90
90
  const ids = status.components.delegation_chain.checks.map((c) => c.id);
91
91
  expect(ids).toEqual([
92
92
  'scheduler-mcp',
@@ -97,6 +97,7 @@ test('end-to-end: check() reports delegation_chain for this repo, expected to pa
97
97
  'prd-write-guard',
98
98
  'destructive-git-guard',
99
99
  'inline-implementation-guard',
100
+ 'self-schedule-guard',
100
101
  ]);
101
102
  // This repo registers the scheduler MCP at user scope (~/.claude.json) and
102
103
  // wires guard-prd-writes in .claude/settings.json, and no longer carries a
@@ -0,0 +1,162 @@
1
+ /**
2
+ * needsReviewLedger.test.cjs — unit tests for src/main/lib/needsReviewLedger.cjs's
3
+ * pure ladder-rung classification, line builders, and rollup reduction.
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/__tests__/needsReviewLedger.test.cjs
6
+ */
7
+ 'use strict';
8
+
9
+ import { test, expect } from 'vitest';
10
+ const {
11
+ LADDER_RUNGS,
12
+ classifyLadderRung,
13
+ buildNeedsReviewEntryLine,
14
+ buildNeedsReviewResolutionLine,
15
+ reduceNeedsReviewLedger,
16
+ } = require('../lib/needsReviewLedger.cjs');
17
+
18
+ // ---------- classifyLadderRung ----------
19
+
20
+ test('classifyLadderRung maps every known automated source to its named rung', () => {
21
+ expect(classifyLadderRung({ source: 'scheduler:mechanicalRecovery' })).toBe('mechanical-recovery');
22
+ expect(classifyLadderRung({ source: 'spawnJob:dispatch', reason: 'dispatched for resume-recovery' })).toBe('resume-recovery');
23
+ expect(classifyLadderRung({ source: 'spawnInvestigation:start' })).toBe('auto-fix');
24
+ expect(classifyLadderRung({ source: 'spawnJob:auto-promote' })).toBe('auto-fix');
25
+ expect(classifyLadderRung({ source: 'reverifyNeedsReview:auto-promote' })).toBe('auto-fix');
26
+ expect(classifyLadderRung({ source: 'reverifyNeedsReview:heal' })).toBe('reverify');
27
+ expect(classifyLadderRung({ source: 'needsReviewAutoResolve' })).toBe('reverify');
28
+ });
29
+
30
+ test('classifyLadderRung: ordinary spawnJob:dispatch (no resume-recovery reason) is NOT resume-recovery', () => {
31
+ // 'spawnJob:dispatch' is shared with the everyday pending->running dispatch
32
+ // — only the resume-recovery REASON text distinguishes the two.
33
+ expect(classifyLadderRung({ source: 'spawnJob:dispatch', reason: 'dispatched for execution' })).toBe('manual-reset');
34
+ });
35
+
36
+ test('classifyLadderRung: unrecognized/explicit-reset sources default to manual-reset', () => {
37
+ expect(classifyLadderRung({ source: 'ipc:schedule:reset-job' })).toBe('manual-reset');
38
+ expect(classifyLadderRung({ source: 'remote:resetJob' })).toBe('manual-reset');
39
+ expect(classifyLadderRung({ source: 'clear-queue' })).toBe('manual-reset');
40
+ expect(classifyLadderRung({})).toBe('manual-reset');
41
+ });
42
+
43
+ test('LADDER_RUNGS names exactly the 6 documented rungs', () => {
44
+ expect(LADDER_RUNGS).toEqual(['mechanical-recovery', 'resume-recovery', 'quarantine', 'auto-fix', 'reverify', 'manual-reset']);
45
+ });
46
+
47
+ // ---------- buildNeedsReviewEntryLine / buildNeedsReviewResolutionLine ----------
48
+
49
+ test('buildNeedsReviewEntryLine: park reason prefers verifierVerdict, then heldReason, then error', () => {
50
+ const entry = { to: 'needs_review', source: 'spawnJob:finalize', at: '2026-09-13T00:00:00.000Z' };
51
+ expect(buildNeedsReviewEntryLine({ slug: 's1', verifierVerdict: 'uncommitted_changes', heldReason: 'held', error: 'err' }, entry).reason).toBe('uncommitted_changes');
52
+ expect(buildNeedsReviewEntryLine({ slug: 's1', heldReason: 'held', error: 'err' }, entry).reason).toBe('held');
53
+ expect(buildNeedsReviewEntryLine({ slug: 's1', error: 'err' }, entry).reason).toBe('err');
54
+ expect(buildNeedsReviewEntryLine({ slug: 's1' }, entry).reason).toBeNull();
55
+ });
56
+
57
+ test('buildNeedsReviewEntryLine: carries slug/cwd/epicId/runId/at/source verbatim', () => {
58
+ const job = { slug: '42-x', cwd: '/proj', epicId: 'epic-1', runId: 'run-9' };
59
+ const entry = { to: 'needs_review', source: 'spawnJob:finalize', at: '2026-09-13T00:00:00.000Z' };
60
+ const line = buildNeedsReviewEntryLine(job, entry);
61
+ expect(line).toEqual({
62
+ kind: 'needs_review_entry',
63
+ slug: '42-x',
64
+ cwd: '/proj',
65
+ epicId: 'epic-1',
66
+ runId: 'run-9',
67
+ at: '2026-09-13T00:00:00.000Z',
68
+ reason: null,
69
+ source: 'spawnJob:finalize',
70
+ });
71
+ });
72
+
73
+ test('buildNeedsReviewResolutionLine: references the EPISODE runId, not job.runId, and computes dwellMs', () => {
74
+ const job = { slug: '42-x', cwd: '/proj', epicId: 'epic-1', runId: 'run-LATER' };
75
+ const entry = { to: 'completed', source: 'reverifyNeedsReview:heal', at: '2026-09-13T00:10:00.000Z' };
76
+ const episode = { runId: 'run-ORIGINAL', enteredAt: '2026-09-13T00:00:00.000Z' };
77
+ const line = buildNeedsReviewResolutionLine(job, entry, episode);
78
+ expect(line.runId).toBe('run-ORIGINAL');
79
+ expect(line.resolvedTo).toBe('completed');
80
+ expect(line.ladderRung).toBe('reverify');
81
+ expect(line.dwellMs).toBe(10 * 60_000);
82
+ });
83
+
84
+ test('buildNeedsReviewResolutionLine: dwellMs is null when the episode has no enteredAt (defensive — should not happen in practice)', () => {
85
+ const job = { slug: '42-x', runId: 'run-1' };
86
+ const entry = { to: 'failed', source: 'ipc:schedule:reset-job', at: '2026-09-13T00:00:00.000Z' };
87
+ const line = buildNeedsReviewResolutionLine(job, entry, { runId: 'run-1', enteredAt: null });
88
+ expect(line.dwellMs).toBeNull();
89
+ });
90
+
91
+ // ---------- reduceNeedsReviewLedger ----------
92
+
93
+ function entryLine({ slug, runId, at, reason = 'uncommitted_changes', source = 'spawnJob:finalize' }) {
94
+ return { kind: 'needs_review_entry', slug, cwd: null, epicId: null, runId, at, reason, source };
95
+ }
96
+ function resolutionLine({ slug, runId, at, resolvedTo = 'completed', ladderRung = 'reverify', source = 'reverifyNeedsReview:heal', dwellMs = 1000 }) {
97
+ return { kind: 'needs_review_resolution', slug, cwd: null, epicId: null, runId, at, resolvedTo, ladderRung, source, dwellMs };
98
+ }
99
+
100
+ test('reduceNeedsReviewLedger: entry-only (unresolved) episode counts toward byReason and unresolvedCount, not byLadderRung', () => {
101
+ const lines = [entryLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:00.000Z', reason: 'silent_no_op' })];
102
+ const rollup = reduceNeedsReviewLedger(lines);
103
+ expect(rollup.byReason).toEqual({ silent_no_op: 1 });
104
+ expect(rollup.byLadderRung).toEqual({});
105
+ expect(rollup.unresolvedCount).toBe(1);
106
+ expect(rollup.dwellMsP50).toBeNull();
107
+ expect(rollup.dwellMsP90).toBeNull();
108
+ });
109
+
110
+ test('reduceNeedsReviewLedger: entry+resolution pair resolves and contributes dwell', () => {
111
+ const lines = [
112
+ entryLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:00.000Z' }),
113
+ resolutionLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:05.000Z', dwellMs: 5000, ladderRung: 'mechanical-recovery' }),
114
+ ];
115
+ const rollup = reduceNeedsReviewLedger(lines);
116
+ expect(rollup.unresolvedCount).toBe(0);
117
+ expect(rollup.byLadderRung).toEqual({ 'mechanical-recovery': 1 });
118
+ expect(rollup.dwellMsP50).toBe(5000);
119
+ expect(rollup.dwellMsP90).toBe(5000);
120
+ });
121
+
122
+ test('reduceNeedsReviewLedger: repeated entries for the same slug (different runIds) count as independent episodes', () => {
123
+ const lines = [
124
+ entryLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:00.000Z', reason: 'uncommitted_changes' }),
125
+ resolutionLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:10.000Z', dwellMs: 10_000, ladderRung: 'manual-reset' }),
126
+ entryLine({ slug: 'a', runId: 'r2', at: '2026-09-13T01:00:00.000Z', reason: 'uncommitted_changes' }),
127
+ // r2 never resolves.
128
+ ];
129
+ const rollup = reduceNeedsReviewLedger(lines);
130
+ expect(rollup.byReason).toEqual({ uncommitted_changes: 2 });
131
+ expect(rollup.unresolvedCount).toBe(1);
132
+ expect(rollup.byLadderRung).toEqual({ 'manual-reset': 1 });
133
+ expect(rollup.dwellMsP50).toBe(10_000);
134
+ });
135
+
136
+ test('reduceNeedsReviewLedger: p50/p90 over several resolved episodes', () => {
137
+ const dwells = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10_000];
138
+ const lines = dwells.flatMap((d, i) => [
139
+ entryLine({ slug: `s${i}`, runId: `r${i}`, at: '2026-09-13T00:00:00.000Z' }),
140
+ resolutionLine({ slug: `s${i}`, runId: `r${i}`, at: '2026-09-13T00:00:00.000Z', dwellMs: d }),
141
+ ]);
142
+ const rollup = reduceNeedsReviewLedger(lines);
143
+ expect(rollup.dwellMsP50).toBe(5000);
144
+ expect(rollup.dwellMsP90).toBe(9000);
145
+ });
146
+
147
+ test('reduceNeedsReviewLedger: ignores unrelated (terminal) rows mixed into the same lines array', () => {
148
+ const lines = [
149
+ { kind: 'terminal', slug: 'a', status: 'completed', runId: 'r1' },
150
+ { slug: 'b', status: 'failed', runId: 'r2' }, // legacy row, no kind at all
151
+ entryLine({ slug: 'c', runId: 'r3', at: '2026-09-13T00:00:00.000Z' }),
152
+ ];
153
+ const rollup = reduceNeedsReviewLedger(lines);
154
+ expect(rollup.unresolvedCount).toBe(1);
155
+ expect(Object.keys(rollup.byReason)).toEqual(['uncommitted_changes']);
156
+ });
157
+
158
+ test('reduceNeedsReviewLedger: empty/non-array input returns a zeroed rollup, never throws', () => {
159
+ expect(reduceNeedsReviewLedger([])).toEqual({ byReason: {}, byLadderRung: {}, unresolvedCount: 0, dwellMsP50: null, dwellMsP90: null });
160
+ expect(reduceNeedsReviewLedger(null)).toEqual({ byReason: {}, byLadderRung: {}, unresolvedCount: 0, dwellMsP50: null, dwellMsP90: null });
161
+ expect(reduceNeedsReviewLedger(undefined)).toEqual({ byReason: {}, byLadderRung: {}, unresolvedCount: 0, dwellMsP50: null, dwellMsP90: null });
162
+ });
@@ -24,7 +24,7 @@ afterEach(async () => {
24
24
  if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
25
25
  const telemetryPath = require.resolve('../lib/telemetryClient.cjs');
26
26
  delete require.cache[telemetryPath];
27
- const activeSessionsPath = require.resolve('../../../scripts/lib/activeSessions.cjs');
27
+ const activeSessionsPath = require.resolve('../lib/activeSessions.cjs');
28
28
  delete require.cache[activeSessionsPath];
29
29
  while (tmpDirs.length) {
30
30
  const d = tmpDirs.pop();
@@ -89,8 +89,8 @@ test('an ephemeral cwd yields zero local lines but one telemetry record, with a
89
89
  const worktreeCwd = path.join(KIND_CONFIG.epic.root, 'fakehash', 'fake-epic-id');
90
90
  const realRoot = '/home/bilko/Projects/real-project';
91
91
 
92
- const activeSessionsPath = require.resolve('../../../scripts/lib/activeSessions.cjs');
93
- const original = require('../../../scripts/lib/activeSessions.cjs');
92
+ const activeSessionsPath = require.resolve('../lib/activeSessions.cjs');
93
+ const original = require('../lib/activeSessions.cjs');
94
94
  require.cache[activeSessionsPath] = {
95
95
  id: activeSessionsPath,
96
96
  filename: activeSessionsPath,
@@ -73,10 +73,24 @@ function mkProject() {
73
73
  return { cwd, slug };
74
74
  }
75
75
 
76
+ // dependsOn deliberately references a slug that can never resolve (no such
77
+ // row, no history/archive entry) so pickNextBatch's dep gate holds this job
78
+ // forever instead of ever handing it to spawnJob. tickQueue() still reaches
79
+ // the picker and stamps lastDispatchAttemptAt (that stamp is unconditional —
80
+ // see lastDispatchAttemptAt's own header comment), which is the only thing
81
+ // these tests assert on. Without this, the picker actually dispatches the
82
+ // job: spawnJob() is fire-and-forget (scheduler.cjs's own comment: "spawnJob
83
+ // is fire-and-forget; it calls tickQueue() on completion") and its real
84
+ // child-process exit handler later calls mutate() independently of the
85
+ // tickTail chain flushPendingTick() drains — round-tripping whatever
86
+ // lastDispatchAttemptAt was in memory at ITS read time. Reproduced outside
87
+ // vitest: that stray write landed ~2s after this suite's own reset-to-null,
88
+ // clobbering the NEXT test's "still null" assertion with the previous test's
89
+ // stamp. A held (never-dispatched) job has no such background continuation.
76
90
  async function seedOnePendingJob() {
77
91
  const { cwd, slug } = mkProject();
78
92
  await scheduler.writeQueue({
79
- jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
93
+ jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: ['unresolvable-dep-never-completes'] }],
80
94
  config: {},
81
95
  paused: null,
82
96
  });