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
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * needsReviewLedger.cjs — pure helpers that make `needs_review` episodes
5
+ * durable and queryable in history.jsonl.
6
+ *
7
+ * Before this module, `queueHistory.cjs` archived a job to history.jsonl
8
+ * only on a TERMINAL status (completed/failed/skipped) — a needs_review
9
+ * park never left queue.json's hot jobs[] (it isn't terminal; see
10
+ * queueHistory.cjs's partitionJobs), so it never reached durable history
11
+ * either. Profiling history.jsonl for "how much needs_review is really
12
+ * happening" therefore always returned zero, however loud the problem
13
+ * looked in the UI.
14
+ *
15
+ * Two line shapes are appended to the SAME history.jsonl a terminal row
16
+ * lands in, distinguished by `kind` so existing terminal-only readers can
17
+ * filter them out on sight (see scheduleJobTransitions.cjs, the actual
18
+ * write choke point, and its callers' audit in the owning PRD):
19
+ * - kind: 'needs_review_entry' — one per transition INTO needs_review
20
+ * - kind: 'needs_review_resolution' — one per transition OUT of needs_review,
21
+ * referencing the entry's own runId so the two can be paired
22
+ *
23
+ * Kept fs-free and pure (mirrors reaperHelpers.cjs's rationale) so the
24
+ * ladder-rung classification and rollup reduction are unit-testable without
25
+ * touching disk or importing scheduler.cjs (which requires electron).
26
+ */
27
+
28
+ // The 6 named ladder rungs a needs_review episode can resolve through
29
+ // (spawnJob's mechanical/resume/quarantine/auto-fix branches, the periodic
30
+ // reverifyNeedsReview heal pass, or an explicit human/API reset). Any
31
+ // resolution whose transitionJob `source` doesn't match a known automated
32
+ // rung is bucketed as 'manual-reset' — the conservative default for
33
+ // anything that isn't a recognized self-healing path.
34
+ const LADDER_RUNGS = [
35
+ 'mechanical-recovery',
36
+ 'resume-recovery',
37
+ 'quarantine',
38
+ 'auto-fix',
39
+ 'reverify',
40
+ 'manual-reset',
41
+ ];
42
+
43
+ /**
44
+ * classifyLadderRung({ source, reason }) → one of LADDER_RUNGS.
45
+ *
46
+ * Matches the exact `source` strings scheduleJobTransitions.cjs's callers in
47
+ * scheduler.cjs pass for each recovery-ladder rung (see that module's
48
+ * LEGAL_TRANSITIONS doc comment for the full call-site inventory). Pattern
49
+ * matching, not an exhaustive enum lookup, because 'spawnJob:dispatch' is
50
+ * shared between the ordinary pending->running dispatch and the
51
+ * needs_review->running resume-recovery dispatch — the two are only
52
+ * distinguishable by `reason` text.
53
+ */
54
+ function classifyLadderRung({ source, reason } = {}) {
55
+ const s = typeof source === 'string' ? source : '';
56
+ const r = typeof reason === 'string' ? reason : '';
57
+
58
+ if (s === 'scheduler:mechanicalRecovery') return 'mechanical-recovery';
59
+ if (s === 'spawnJob:dispatch' && /resume-recovery/i.test(r)) return 'resume-recovery';
60
+ if (s === 'spawnInvestigation:start' || s === 'spawnJob:auto-promote' || s === 'reverifyNeedsReview:auto-promote') {
61
+ return 'auto-fix';
62
+ }
63
+ if (s === 'reverifyNeedsReview:heal' || s === 'needsReviewAutoResolve') return 'reverify';
64
+ // No transitionJob call site resolves needs_review with a 'quarantine'
65
+ // source today (performLeftoverQuarantine cleans up leftover paths without
66
+ // itself changing job.status — the row stays needs_review until a later
67
+ // reverify/auto-resolve pass actually resolves it), but the rung is kept
68
+ // in LADDER_RUNGS/this classifier so a future direct-resolving quarantine
69
+ // path is classified correctly without another migration.
70
+ return 'manual-reset';
71
+ }
72
+
73
+ /**
74
+ * buildNeedsReviewEntryLine(job, entry) → JSONL-ready object.
75
+ *
76
+ * `entry` is the statusHistory record scheduleJobTransitions.cjs just
77
+ * pushed for this transition ({ from, to, reason, source, at }). The park
78
+ * reason prefers the structured `verifierVerdict` (a short, bucketable
79
+ * code) over the free-text `heldReason`/`error` fields, since those are the
80
+ * only reason evidence available for parks that never went through the
81
+ * verifier (e.g. a pidless-reap needs_review park).
82
+ */
83
+ function buildNeedsReviewEntryLine(job, entry) {
84
+ return {
85
+ kind: 'needs_review_entry',
86
+ slug: job?.slug ?? null,
87
+ cwd: job?.cwd ?? null,
88
+ epicId: job?.epicId ?? null,
89
+ runId: job?.runId ?? null,
90
+ at: entry?.at ?? new Date().toISOString(),
91
+ reason: job?.verifierVerdict ?? job?.heldReason ?? job?.error ?? null,
92
+ source: entry?.source ?? null,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * buildNeedsReviewResolutionLine(job, entry, episode) → JSONL-ready object.
98
+ *
99
+ * `episode` is `{ runId, enteredAt }` captured off the job at entry time
100
+ * (needsReviewEntryRunId/needsReviewEnteredAt) — the resolution line
101
+ * references the ENTRY's runId (which may differ from `job.runId` by
102
+ * resolution time, e.g. after a resume-recovery re-dispatch minted a fresh
103
+ * one), not whatever runId the job carries right now.
104
+ */
105
+ function buildNeedsReviewResolutionLine(job, entry, episode) {
106
+ const enteredAtMs = episode?.enteredAt ? Date.parse(episode.enteredAt) : NaN;
107
+ const resolvedAtMs = entry?.at ? Date.parse(entry.at) : NaN;
108
+ const dwellMs = Number.isFinite(enteredAtMs) && Number.isFinite(resolvedAtMs)
109
+ ? Math.max(0, resolvedAtMs - enteredAtMs)
110
+ : null;
111
+ return {
112
+ kind: 'needs_review_resolution',
113
+ slug: job?.slug ?? null,
114
+ cwd: job?.cwd ?? null,
115
+ epicId: job?.epicId ?? null,
116
+ runId: episode?.runId ?? job?.runId ?? null,
117
+ at: entry?.at ?? new Date().toISOString(),
118
+ resolvedTo: entry?.to ?? null,
119
+ ladderRung: classifyLadderRung({ source: entry?.source, reason: entry?.reason }),
120
+ source: entry?.source ?? null,
121
+ dwellMs,
122
+ };
123
+ }
124
+
125
+ function percentile(sortedValues, p) {
126
+ if (!sortedValues.length) return null;
127
+ const idx = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil((p / 100) * sortedValues.length) - 1));
128
+ return sortedValues[idx];
129
+ }
130
+
131
+ /**
132
+ * reduceNeedsReviewLedger(lines) → rollup.
133
+ *
134
+ * Pure O(n log n) reduction (the log n is the dwell-time sort for the
135
+ * percentiles) over a flat array of already-parsed history.jsonl rows
136
+ * (mixing needs_review_entry/needs_review_resolution/terminal rows is fine —
137
+ * anything that isn't one of the two kinds this module writes is ignored).
138
+ *
139
+ * Pairs an entry with its resolution by (slug, runId) — the same pairing
140
+ * key buildNeedsReviewResolutionLine's `episode.runId` guarantees matches
141
+ * the entry line's `runId`. A slug that parks twice (two different runIds)
142
+ * produces two independent pairs, matching two independent recovery
143
+ * episodes rather than being conflated into one.
144
+ *
145
+ * Returns:
146
+ * - byReason: { [reason]: entryCount } (reason = entry line's `reason`, or 'unknown')
147
+ * - byLadderRung: { [rung]: resolutionCount } (rung = one of LADDER_RUNGS)
148
+ * - unresolvedCount: entries with no matching resolution line yet
149
+ * - dwellMsP50 / dwellMsP90: percentiles over resolved episodes' dwellMs (null if none)
150
+ */
151
+ function reduceNeedsReviewLedger(lines) {
152
+ const list = Array.isArray(lines) ? lines : [];
153
+ const entriesByKey = new Map();
154
+ const resolutionsByKey = new Map();
155
+
156
+ for (const line of list) {
157
+ if (!line || typeof line !== 'object') continue;
158
+ const key = `${line.slug ?? ''}|${line.runId ?? ''}`;
159
+ if (line.kind === 'needs_review_entry') {
160
+ entriesByKey.set(key, line);
161
+ } else if (line.kind === 'needs_review_resolution') {
162
+ resolutionsByKey.set(key, line);
163
+ }
164
+ }
165
+
166
+ const byReason = {};
167
+ let unresolvedCount = 0;
168
+ for (const [key, entry] of entriesByKey) {
169
+ const reason = entry.reason ?? 'unknown';
170
+ byReason[reason] = (byReason[reason] ?? 0) + 1;
171
+ if (!resolutionsByKey.has(key)) unresolvedCount += 1;
172
+ }
173
+
174
+ const byLadderRung = {};
175
+ const dwellMsValues = [];
176
+ for (const [key, resolution] of resolutionsByKey) {
177
+ const rung = LADDER_RUNGS.includes(resolution.ladderRung) ? resolution.ladderRung : 'manual-reset';
178
+ byLadderRung[rung] = (byLadderRung[rung] ?? 0) + 1;
179
+ // Only count dwell for episodes that actually matched an entry — a
180
+ // resolution line with no paired entry (e.g. history truncated by
181
+ // retention before the entry aged out — never happens for needs_review
182
+ // rows today since they're excluded from partitionJobs' archivable set,
183
+ // but this stays defensive) has no truthful dwell to report.
184
+ if (entriesByKey.has(key) && typeof resolution.dwellMs === 'number' && Number.isFinite(resolution.dwellMs)) {
185
+ dwellMsValues.push(resolution.dwellMs);
186
+ }
187
+ }
188
+ dwellMsValues.sort((a, b) => a - b);
189
+
190
+ return {
191
+ byReason,
192
+ byLadderRung,
193
+ unresolvedCount,
194
+ dwellMsP50: percentile(dwellMsValues, 50),
195
+ dwellMsP90: percentile(dwellMsValues, 90),
196
+ };
197
+ }
198
+
199
+ module.exports = {
200
+ LADDER_RUNGS,
201
+ classifyLadderRung,
202
+ buildNeedsReviewEntryLine,
203
+ buildNeedsReviewResolutionLine,
204
+ reduceNeedsReviewLedger,
205
+ };
@@ -107,7 +107,7 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
107
107
  function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message }) {
108
108
  try {
109
109
  const telemetryClient = require('./telemetryClient.cjs');
110
- const { projectRootOf } = require('../../../scripts/lib/activeSessions.cjs');
110
+ const { projectRootOf } = require('./activeSessions.cjs');
111
111
  const normalizedCwd = projectRootOf(cwd) || cwd;
112
112
  const autoTags = [
113
113
  `scope:${scope || 'unknown'}`,
@@ -237,7 +237,7 @@ function resolveProjectRoot(cwd, { opsInternal = 'normalize' } = {}) {
237
237
  );
238
238
  }
239
239
  // Lazy: activeSessions → gitWorktree → (lazily) config.cjs → this module.
240
- const { projectRootOf } = require('../../../scripts/lib/activeSessions.cjs');
240
+ const { projectRootOf } = require('./activeSessions.cjs');
241
241
  const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
242
242
  const root = projectRootOf(cwd);
243
243
  if (isEphemeralCwd(root)) {
@@ -30,6 +30,7 @@ const { fixChainDepthOf, baseSlugOf } = require('./fixChainDepth.cjs');
30
30
  const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
31
31
  const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
32
32
  const { isFixPlanSlug } = require('./fixPlanSlug.cjs');
33
+ const { isIncomplete, resolveChainTerminals } = require('./prdDisposition.cjs');
33
34
 
34
35
  // A caller-supplied slug that already starts with its own `NN-` (e.g.
35
36
  // "254-perf-x") used to silently become the double-prefixed row
@@ -74,7 +75,7 @@ function deriveSlugFromTitle(title) {
74
75
  function buildPrdBody(input) {
75
76
  const {
76
77
  title, cwd, estimateMinutes, goal, acceptanceCriteria,
77
- implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine,
78
+ implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition,
78
79
  } = input;
79
80
 
80
81
  // No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
@@ -108,6 +109,11 @@ function buildPrdBody(input) {
108
109
  fmLines.push(`agentType: ${agentType || DEFAULT_PRD_AGENT_TYPE}`);
109
110
  // Explicit ordering (PRD 832): replaces the retired shared-NN convention.
110
111
  if (dependsOn && dependsOn.length) fmLines.push(`dependsOn: [${dependsOn.join(', ')}]`);
112
+ // Wave-authoring decision (scheduler wave-disposition PRD) — only ever set
113
+ // when createPrd() actually had a disposition to record (see the call
114
+ // site above); a first-ever PRD in an Epic, or one with its own explicit
115
+ // dependsOn, has nothing to decide against and omits this key.
116
+ if (disposition) fmLines.push(`disposition: ${disposition}`);
111
117
  // Opt-in exclusive-lease flag (PRD 1107): serializes this job against
112
118
  // every other job machine-wide for its run, for a PRD whose acceptance
113
119
  // criteria are wall-clock/timing measurements that CPU contention from
@@ -237,6 +243,55 @@ async function createPrd(input, remote) {
237
243
  if (fallback) input = { ...input, sourcePromptId: fallback };
238
244
  }
239
245
 
246
+ // Wave-disposition decision point (scheduler wave-disposition PRD): only
247
+ // matters for a PRD that would become a ROOT of its own wave — one the
248
+ // caller didn't already give an explicit dependsOn (that already fixes
249
+ // its position in some chain) — joining an Epic whose sourcePromptId this
250
+ // PRD shares. See prdDisposition.cjs's header for the full contract.
251
+ if (input.sourcePromptId && (!input.dependsOn || input.dependsOn.length === 0)) {
252
+ let epicRows = [];
253
+ try {
254
+ const listing = await remote.listPrds({ cwd: input.cwd, fields: 'full', limit: Number.MAX_SAFE_INTEGER });
255
+ epicRows = (listing?.prds ?? []).filter((p) => p.sourcePromptId === input.sourcePromptId);
256
+ } catch (e) {
257
+ console.warn(`[prdCreate] disposition resolution skipped (listPrds failed): ${e?.message ?? e}`);
258
+ }
259
+ if (epicRows.some((p) => isIncomplete(p.status))) {
260
+ let disposition = input.disposition ?? null;
261
+ if (!disposition) {
262
+ // A headless PRD executor's own job has SM_SCHEDULER_JOB_SLUG set on
263
+ // its env (scheduler.cjs stamps every spawned job's child env with
264
+ // it) — no human is present to ask, so the conservative default
265
+ // ('append') applies and is logged as a default, never as a silent
266
+ // choice. Anything else (a live interactive Epic chat session, or a
267
+ // direct admin-route call) is refused instead of guessed — see this
268
+ // PRD's AC2.
269
+ const nonInteractive = Boolean(process.env.SM_SCHEDULER_JOB_SLUG);
270
+ if (!nonInteractive) {
271
+ return {
272
+ ok: false,
273
+ status: 400,
274
+ error: `Epic ${input.sourcePromptId} already has incomplete PRDs — pass disposition: "append" ` +
275
+ '(extend the existing chain behind its current tail) or "new-head" (an independent root, ' +
276
+ 'eligible to run in parallel) to record this wave\'s relationship to the existing plan.',
277
+ };
278
+ }
279
+ disposition = 'append';
280
+ console.warn(`[prdCreate] disposition defaulted to "append" for a non-interactive caller (epic ${input.sourcePromptId})`);
281
+ appendAuditEvent('prd_disposition_defaulted', { cwd: input.cwd, epicId: input.sourcePromptId, disposition });
282
+ }
283
+ if (disposition === 'append') {
284
+ // Only the Epic's still-incomplete rows count as "the active plan" to
285
+ // append behind — a completed, undepended-upon row would otherwise
286
+ // also read as a terminal, silently attaching this wave to already-
287
+ // finished work instead of purely the current chain's live tail(s).
288
+ const terminals = resolveChainTerminals(epicRows.filter((p) => isIncomplete(p.status)));
289
+ if (terminals.length) input = { ...input, dependsOn: terminals };
290
+ }
291
+ input = { ...input, disposition };
292
+ }
293
+ }
294
+
240
295
  if (input.slug) {
241
296
  const nnMatch = SLUG_HAS_NN_PREFIX_RE.exec(input.slug);
242
297
  if (nnMatch) {
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * prdDisposition.cjs — the wave-authoring decision (scheduler wave-
5
+ * disposition PRD): when a new PRD wave joins an Epic that already has
6
+ * incomplete PRDs, does the wave APPEND behind the existing chain's tail, or
7
+ * start an independent NEW-HEAD root eligible to run in parallel? Before
8
+ * this module the choice didn't exist — every new root PRD silently landed
9
+ * as a parallel-eligible row, so waves interleaved with no recorded
10
+ * relationship and a human reading the backlog couldn't tell a plan from a
11
+ * pile.
12
+ *
13
+ * Two call sites:
14
+ * - prdCreate.cjs's createPrd() — AUTHORING TIME. Resolves 'append' into a
15
+ * concrete dependsOn via resolveChainTerminals() over the Epic's
16
+ * existing rows.
17
+ * - scheduler.cjs's `schedule:set-prd-disposition` IPC handler — CHANGING
18
+ * an already-written PRD's disposition from the Scheduler UI. Uses
19
+ * computeDispositionRewrite() to validate the rewrite is safe before
20
+ * handing the new dependsOn to remote.updatePrd (which itself refuses a
21
+ * non-pending/quarantined row — see scheduler.cjs's updatePrd).
22
+ *
23
+ * `rows` throughout is the same shape listPrdsInternal() already produces:
24
+ * { slug, status, dependsOn }. dependsOn entries are resolved against `rows`
25
+ * via depSlugResolve.cjs's resolveDepSlug — the SAME rule
26
+ * schedulerBatch.cjs's findBlockingDep and prdCreate.cjs's write-time FK
27
+ * check use, so a terminal/cycle computed here can never disagree with what
28
+ * the scheduler considers a row's real blockers at run time.
29
+ */
30
+
31
+ const { resolveDepSlug } = require('./depSlugResolve.cjs');
32
+
33
+ /** A row is "incomplete" (still part of the Epic's active plan) unless it
34
+ * has actually finished. Treats every other status — including a brand-new
35
+ * row with no queue entry yet (`status: null`) — as incomplete, since none
36
+ * of those represent shipped work a wave could safely ignore. */
37
+ function isIncomplete(status) {
38
+ return status !== 'completed';
39
+ }
40
+
41
+ /** Resolve each row's dependsOn entries to actual row slugs present in
42
+ * `rows`, via the shared resolution rule. A dep that doesn't resolve to
43
+ * anything in this row set (dangling, or lives outside this Epic) is
44
+ * dropped — it can't affect this Epic's own terminal computation. */
45
+ function resolvedDependents(rows) {
46
+ const slugs = rows.map((r) => r.slug);
47
+ const dependedUpon = new Set();
48
+ for (const r of rows) {
49
+ for (const dep of r.dependsOn ?? []) {
50
+ for (const resolved of resolveDepSlug(dep, slugs)) dependedUpon.add(resolved);
51
+ }
52
+ }
53
+ return dependedUpon;
54
+ }
55
+
56
+ /** Every row participating in a dependsOn cycle within `rows` (self-loops
57
+ * included) — mirrors backlogTree.ts's detectCycles (renderer-only, can't
58
+ * be required from this CJS main-process module; kept in sync by hand). */
59
+ function detectCycles(rows) {
60
+ const bySlug = new Map(rows.map((r) => [r.slug, r]));
61
+ const slugs = rows.map((r) => r.slug);
62
+ const color = new Map();
63
+ const cyclic = new Set();
64
+ const stack = [];
65
+
66
+ function visit(slug) {
67
+ color.set(slug, 1);
68
+ stack.push(slug);
69
+ const r = bySlug.get(slug);
70
+ const deps = new Set();
71
+ for (const dep of r?.dependsOn ?? []) {
72
+ for (const resolved of resolveDepSlug(dep, slugs)) deps.add(resolved);
73
+ }
74
+ for (const dep of deps) {
75
+ const c = color.get(dep);
76
+ if (c === 1) {
77
+ const idx = stack.indexOf(dep);
78
+ for (let i = idx; i < stack.length; i++) cyclic.add(stack[i]);
79
+ } else if (c === undefined) {
80
+ visit(dep);
81
+ }
82
+ }
83
+ stack.pop();
84
+ color.set(slug, 2);
85
+ }
86
+
87
+ for (const r of rows) {
88
+ if (!color.has(r.slug)) visit(r.slug);
89
+ }
90
+ return cyclic;
91
+ }
92
+
93
+ /**
94
+ * Terminal (leaf) slugs of the dependency forest formed by `rows` — the
95
+ * row(s) nothing else in `rows` depends on. This is what an 'append'
96
+ * disposition attaches a new wave's root behind: depending on every current
97
+ * terminal means the new wave runs only once everything already queued (in
98
+ * every existing head) has finished, the conservative reading of "append".
99
+ * A row participating in a cycle is excluded — never used as a rewrite
100
+ * target, since a cycle indicates the existing graph shouldn't be extended
101
+ * until it's fixed by hand.
102
+ */
103
+ function resolveChainTerminals(rows) {
104
+ if (!rows.length) return [];
105
+ const dependedUpon = resolvedDependents(rows);
106
+ const cyclic = detectCycles(rows);
107
+ return rows
108
+ .map((r) => r.slug)
109
+ .filter((slug) => !dependedUpon.has(slug) && !cyclic.has(slug));
110
+ }
111
+
112
+ /**
113
+ * Would setting `slug`'s dependsOn to `newDependsOn` create a cycle? Checks
114
+ * reachability from each (resolved) proposed dependency back to `slug`
115
+ * following the EXISTING graph in `rows` (never `slug`'s own current
116
+ * dependsOn, which is being replaced) — a direct self-reference is always a
117
+ * cycle, checked first without needing a graph walk.
118
+ */
119
+ function wouldCreateCycle(rows, slug, newDependsOn) {
120
+ const slugs = rows.map((r) => r.slug);
121
+ const bySlug = new Map(rows.map((r) => [r.slug, r]));
122
+ const resolvedTargets = new Set();
123
+ for (const dep of newDependsOn) {
124
+ for (const resolved of resolveDepSlug(dep, slugs)) resolvedTargets.add(resolved);
125
+ }
126
+ if (resolvedTargets.has(slug)) return true;
127
+
128
+ const visited = new Set();
129
+ function reachesSlug(from) {
130
+ if (from === slug) return true;
131
+ if (visited.has(from)) return false;
132
+ visited.add(from);
133
+ const r = bySlug.get(from);
134
+ for (const dep of r?.dependsOn ?? []) {
135
+ for (const resolved of resolveDepSlug(dep, slugs)) {
136
+ if (reachesSlug(resolved)) return true;
137
+ }
138
+ }
139
+ return false;
140
+ }
141
+ for (const target of resolvedTargets) {
142
+ if (reachesSlug(target)) return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Validates and computes the new dependsOn for a disposition change on an
149
+ * already-written PRD (the Scheduler UI's "promote to head" / "attach
150
+ * behind another chain" action). Pure — returns the new dependsOn value on
151
+ * success; the caller (scheduler.cjs's IPC handler) is responsible for
152
+ * actually persisting it via remote.updatePrd, which independently refuses
153
+ * a non-pending/quarantined row (defense in depth, not duplicated here).
154
+ *
155
+ * Refuses (ok: false) when:
156
+ * - `slug` isn't a known row.
157
+ * - the row itself is 'running' or 'completed' — never touch live or
158
+ * shipped work.
159
+ * - any of the row's CURRENT dependsOn entries already resolved
160
+ * ('completed') — the row may already be scheduler-eligible, and
161
+ * rewriting its ordering out from under a resolved blocker risks
162
+ * resurrecting/racing already-finished work (see this module's header).
163
+ * - the proposed dependsOn would create a cycle.
164
+ */
165
+ function computeDispositionRewrite({ slug, disposition, dependsOn, rows }) {
166
+ const row = rows.find((r) => r.slug === slug);
167
+ if (!row) return { ok: false, error: `PRD not found: ${slug}` };
168
+ if (row.status === 'running' || row.status === 'completed') {
169
+ return { ok: false, error: `cannot change disposition of a "${row.status}" PRD — it is no longer part of the open plan` };
170
+ }
171
+
172
+ const slugs = rows.map((r) => r.slug);
173
+ for (const dep of row.dependsOn ?? []) {
174
+ for (const resolved of resolveDepSlug(dep, slugs)) {
175
+ const depRow = rows.find((r) => r.slug === resolved);
176
+ if (depRow?.status === 'completed') {
177
+ return {
178
+ ok: false,
179
+ error: `refusing to rewrite dependsOn: blocker "${resolved}" has already completed — this PRD may ` +
180
+ 'already be eligible to run; changing its ordering now risks resurrecting finished work',
181
+ };
182
+ }
183
+ }
184
+ }
185
+
186
+ const newDependsOn = disposition === 'new-head' ? [] : Array.from(new Set(dependsOn ?? []));
187
+ if (newDependsOn.length && wouldCreateCycle(rows, slug, newDependsOn)) {
188
+ return { ok: false, error: `dependsOn ${JSON.stringify(newDependsOn)} would create a dependency cycle` };
189
+ }
190
+
191
+ return { ok: true, dependsOn: newDependsOn };
192
+ }
193
+
194
+ module.exports = {
195
+ isIncomplete,
196
+ resolveChainTerminals,
197
+ wouldCreateCycle,
198
+ computeDispositionRewrite,
199
+ };
@@ -77,8 +77,8 @@ function splitFrontmatter(raw) {
77
77
  * never emitted, which is how `scheduler_update_prd` clears a dependency.
78
78
  */
79
79
 
80
- const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine']);
81
- const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine'];
80
+ const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition']);
81
+ const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition'];
82
82
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
83
83
 
84
84
  function indentOf(line) {
@@ -179,6 +179,12 @@ function applyKey(fm, key, after) {
179
179
  // a no-op line for the overwhelming majority of PRDs that omit it.
180
180
  if (v === true) fm.quietMachine = true;
181
181
  return;
182
+ case 'disposition':
183
+ // Wave-authoring decision ('append'/'new-head') — see
184
+ // src/renderer/lib/prdFrontmatter.ts's mirror of this field for the
185
+ // full rationale. Only these two values are recognized.
186
+ if (v === 'append' || v === 'new-head') fm.disposition = v;
187
+ return;
182
188
  }
183
189
  }
184
190