claude-code-session-manager 0.75.3 → 0.77.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 (164) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
  15. package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
  16. package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
  26. package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.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/lib/activeSessions.cjs +116 -6
  32. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  33. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  34. package/scripts/render-project-pages.cjs +70 -0
  35. package/scripts/scheduler-mcp-server.cjs +269 -96
  36. package/scripts/validate-project-pages-summary.cjs +62 -0
  37. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  38. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  39. package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
  40. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  41. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  42. package/src/main/__tests__/prdCreate.test.cjs +247 -0
  43. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  44. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  45. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  46. package/src/main/__tests__/projectPages.test.cjs +73 -1
  47. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  48. package/src/main/__tests__/runVerify.test.cjs +94 -0
  49. package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
  50. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  51. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  52. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  54. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  55. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  56. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  57. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  58. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  59. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  60. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  61. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
  62. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  63. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  64. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  65. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  66. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  67. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  68. package/src/main/bilkoHost.cjs +4 -3
  69. package/src/main/chatRunner.cjs +6 -1
  70. package/src/main/config.cjs +25 -33
  71. package/src/main/health.cjs +153 -2
  72. package/src/main/index.cjs +64 -5
  73. package/src/main/ipcSchemas.cjs +69 -1
  74. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  75. package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
  76. package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
  77. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  78. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
  79. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  80. package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
  81. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  82. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  83. package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
  84. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  85. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  86. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  87. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
  88. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  89. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  90. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  91. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  92. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  93. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  94. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  95. package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
  96. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  97. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  98. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  99. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  100. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  101. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  102. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  103. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  104. package/src/main/lib/activeIndexMerge.cjs +15 -0
  105. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  106. package/src/main/lib/agentModelResolve.cjs +58 -0
  107. package/src/main/lib/buildTarget.cjs +3 -2
  108. package/src/main/lib/childWithLog.cjs +69 -2
  109. package/src/main/lib/claudeBin.cjs +54 -1
  110. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  111. package/src/main/lib/definitionOfDone.cjs +3 -2
  112. package/src/main/lib/delegationReadiness.cjs +514 -26
  113. package/src/main/lib/ephemeralCwd.cjs +78 -0
  114. package/src/main/lib/epicDelegationStats.cjs +2 -1
  115. package/src/main/lib/epicMint.cjs +17 -1
  116. package/src/main/lib/epicStatusMirror.cjs +95 -0
  117. package/src/main/lib/epicValidationHook.cjs +2 -1
  118. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  119. package/src/main/lib/fixChainDepth.cjs +45 -0
  120. package/src/main/lib/gitWorktree.cjs +520 -21
  121. package/src/main/lib/jobWorktree.cjs +2 -0
  122. package/src/main/lib/landedSinceRun.cjs +55 -0
  123. package/src/main/lib/launchFailure.cjs +357 -0
  124. package/src/main/lib/loadGate.cjs +134 -0
  125. package/src/main/lib/mcpToolCatalog.cjs +370 -0
  126. package/src/main/lib/opsErrorLog.cjs +12 -1
  127. package/src/main/lib/opsOwnership.cjs +106 -0
  128. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  129. package/src/main/lib/prdAgentType.cjs +84 -0
  130. package/src/main/lib/prdCreate.cjs +103 -15
  131. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  132. package/src/main/lib/prdFrontmatter.cjs +17 -3
  133. package/src/main/lib/prdLocations.cjs +13 -6
  134. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  135. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  136. package/src/main/lib/projectRootResolve.cjs +134 -0
  137. package/src/main/lib/promptSessionSchema.cjs +7 -0
  138. package/src/main/lib/queueHealth.cjs +38 -0
  139. package/src/main/lib/queueStore.cjs +40 -7
  140. package/src/main/lib/quietMachineLease.cjs +48 -0
  141. package/src/main/lib/rcaReport.cjs +54 -4
  142. package/src/main/lib/reaperHelpers.cjs +64 -1
  143. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  144. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  145. package/src/main/lib/schedulerBatch.cjs +301 -55
  146. package/src/main/lib/schedulerConfig.cjs +99 -0
  147. package/src/main/projectBrief.cjs +3 -2
  148. package/src/main/projectPages.cjs +162 -3
  149. package/src/main/promptSessionTranscript.cjs +0 -0
  150. package/src/main/pty.cjs +5 -0
  151. package/src/main/queueOps.cjs +15 -8
  152. package/src/main/runVerify.cjs +50 -9
  153. package/src/main/scheduler/prdParser.cjs +18 -1
  154. package/src/main/scheduler.cjs +1701 -130
  155. package/src/main/seedAgentPersonas.cjs +62 -21
  156. package/src/main/seedSchedulerMcp.cjs +58 -4
  157. package/src/main/templates/project-pages-catalog.json +741 -0
  158. package/src/main/templates/project-pages-pipeline.md +417 -0
  159. package/src/preload/api.d.ts +187 -3
  160. package/src/preload/index.cjs +9 -0
  161. package/src/seed/agents/project-home-builder.md +59 -0
  162. package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
  163. package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
  164. package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * projectRootResolve.cjs — server-side project/Epic resolution for every
5
+ * cwd-accepting ops-root entry point (PRD: worktree-cwd Epic-lookup hazard).
6
+ *
7
+ * Problem this closes. An agent running inside an Epic's git worktree has
8
+ * `pwd` = `/tmp/session-manager-epic-worktrees/<hash>/<epicId>` and naturally
9
+ * passes that as `cwd` to `scheduler_create_prd` / `feedback_open_session`.
10
+ * `active-index.json` (the Epic registry) is git-tracked, so the worktree's
11
+ * copy is a snapshot frozen at branch time — it usually does NOT contain the
12
+ * very Epic the agent is running inside. Verified live 2026-09-01 on
13
+ * starry-night-ships: main's index had 5 rows including the running Epic; the
14
+ * worktree's copy had 1 row and did not contain it, so a naive per-cwd lookup
15
+ * returns null and the write is refused with "create/approve an Epic first" —
16
+ * reading to the human as "the Epic got lost", when it never did.
17
+ *
18
+ * `resolveProjectContext` fixes this at the API boundary, not by trying to
19
+ * keep every cwd-accepting caller worktree-aware individually:
20
+ * 1. Normalizes any supplied cwd through `activeSessions.projectRootOf`
21
+ * (worktree -> main tree, ops-internal path -> project root).
22
+ * 2. When an `originClaudeSessionId` (or explicit `epicId`) is known,
23
+ * searches for the owning Epic first in the normalized cwd's own
24
+ * active-index, then across every project this machine has ever opened
25
+ * (`activeSessions.allProjectCwds`) — because the Epic's OWN project may
26
+ * not even be the cwd the agent guessed (e.g. a worktree whose gitdir
27
+ * pointer is stale/unresolvable, or a cross-project mixup).
28
+ * 3. A resolved Epic's own project cwd (`epicCwd`) always wins over the
29
+ * supplied/hinted cwd when they differ — the Epic is the ground truth.
30
+ *
31
+ * Pure, synchronous, fs-only — no Electron deps — so it can be required from
32
+ * the standalone scheduler-mcp-server.cjs process and from every ops-root
33
+ * path helper a sibling PRD (ops-root-single-resolver) folds in later.
34
+ */
35
+
36
+ const path = require('node:path');
37
+ const { projectRootOf, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
38
+ const { readActiveIndex } = require('./epicMint.cjs');
39
+ const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
40
+
41
+ /**
42
+ * resolveProjectContext({ cwd, originClaudeSessionId, epicId, originProjectRoot }, deps?)
43
+ * → { cwd, epicId, epicCwd, source }
44
+ *
45
+ * `cwd` — the caller-supplied cwd (may be inside a worktree/ops-internal path,
46
+ * or absent entirely when the caller is relying purely on Epic resolution).
47
+ * `originClaudeSessionId` — the calling session's own claude session id
48
+ * (chatRunner.cjs's SM_CHAT_SESSION_ID / pty.cjs's tabId) — matched against
49
+ * each Epic's `claudeSessionId`.
50
+ * `epicId` — an explicit already-known Epic id (e.g. a caller-supplied
51
+ * `sourcePromptId`) — matched against each Epic's `id`. Either identifier
52
+ * (or both) may be supplied; a match on either counts.
53
+ * `originProjectRoot` — a trusted hint (e.g. SM_PROJECT_ROOT env, stamped by
54
+ * the process that actually knows the real project root) — normalized the
55
+ * same way as `cwd` and preferred over it when both are given, but still
56
+ * loses to a resolved Epic's own `epicCwd`.
57
+ *
58
+ * `source` records which path produced the returned `cwd`/`epicId`:
59
+ * 'cwd' — no Epic identifier given, or none resolved; cwd is
60
+ * just the normalized supplied/hinted value.
61
+ * 'epic-index' — the Epic was found in the (single) normalized
62
+ * base cwd's own active-index.
63
+ * 'cross-project-scan' — the Epic was found only by scanning other known
64
+ * projects (or a duplicate id required tie-breaking).
65
+ *
66
+ * Never throws: an unresolvable cwd/hint/Epic id just yields a smaller
67
+ * result, never an error — refusal is the caller's decision, not this
68
+ * resolver's.
69
+ */
70
+ function resolveProjectContext({ cwd, originClaudeSessionId, epicId: wantedEpicId, originProjectRoot } = {}, deps = {}) {
71
+ const projectRootOfFn = deps.projectRootOf || projectRootOf;
72
+ const allProjectCwdsFn = deps.allProjectCwds || allProjectCwds;
73
+ const readActiveIndexFn = deps.readActiveIndex || readActiveIndex;
74
+ const isEphemeralCwdFn = deps.isEphemeralCwd || isEphemeralCwd;
75
+ const warn = deps.warn || console.warn;
76
+
77
+ let baseCwd = null;
78
+ if (cwd && typeof cwd === 'string') {
79
+ baseCwd = projectRootOfFn(path.resolve(cwd));
80
+ }
81
+ if (originProjectRoot && typeof originProjectRoot === 'string') {
82
+ const hintRoot = projectRootOfFn(path.resolve(originProjectRoot));
83
+ // A trusted hint outranks the agent-supplied cwd, but a resolved Epic
84
+ // (below) still outranks both.
85
+ if (hintRoot) baseCwd = hintRoot;
86
+ }
87
+
88
+ const result = { cwd: baseCwd, epicId: null, epicCwd: null, source: 'cwd' };
89
+
90
+ if (!originClaudeSessionId && !wantedEpicId) return result;
91
+
92
+ const candidates = [];
93
+ if (baseCwd) candidates.push(baseCwd);
94
+ for (const p of allProjectCwdsFn()) {
95
+ if (!candidates.includes(p)) candidates.push(p);
96
+ }
97
+
98
+ const matches = [];
99
+ for (const projectCwd of candidates) {
100
+ const { sessions } = readActiveIndexFn(projectCwd);
101
+ for (const session of Object.values(sessions)) {
102
+ if (!session) continue;
103
+ const idHit = wantedEpicId && session.id === wantedEpicId;
104
+ const claudeHit = originClaudeSessionId && session.claudeSessionId === originClaudeSessionId;
105
+ if (idHit || claudeHit) {
106
+ matches.push({ projectCwd, epicId: session.id, status: session.status ?? null });
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ if (matches.length === 0) return result;
113
+
114
+ let chosen = matches[0];
115
+ if (matches.length > 1) {
116
+ const score = (m) => (isEphemeralCwdFn(m.projectCwd) ? 0 : 2) + (m.status === 'active' ? 1 : 0);
117
+ chosen = matches.reduce((best, m) => (score(m) > score(best) ? m : best), matches[0]);
118
+ warn(
119
+ `[projectRootResolve] Epic id ${chosen.epicId} matched in multiple projects: `
120
+ + `${matches.map((m) => m.projectCwd).join(', ')} — choosing ${chosen.projectCwd} `
121
+ + '(prefers a non-worktree/tmp project with status=active)',
122
+ );
123
+ }
124
+
125
+ result.epicId = chosen.epicId;
126
+ result.epicCwd = chosen.projectCwd;
127
+ result.cwd = chosen.projectCwd;
128
+ result.source = matches.length > 1
129
+ ? 'cross-project-scan'
130
+ : (chosen.projectCwd === baseCwd ? 'epic-index' : 'cross-project-scan');
131
+ return result;
132
+ }
133
+
134
+ module.exports = { resolveProjectContext };
@@ -79,6 +79,13 @@ const PromptSessionSchema = z.object({
79
79
  // Passthrough only as of PRD 1032 — nothing populates this yet; the next
80
80
  // PRD in this chain wires Epic worktree minting.
81
81
  worktree: EpicWorktreeSchema.optional(),
82
+ // Status-mirror fields (epicStatusMirror.cjs) written onto prompt-sessions/
83
+ // <id>.json alongside the active-index.json row — never onto the index row
84
+ // itself, so a plain PromptSession read from active-index.json normally
85
+ // omits them. Optional here only so activeIndexRebuild.cjs can validate a
86
+ // mirrored file's reconstructed row against this same schema.
87
+ archivedAt: z.string().nullable().optional(),
88
+ indexedAt: z.string().optional(),
82
89
  });
83
90
 
84
91
  /**
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * queueHealth.cjs — pure per-project rollup for the periodic queue-health
5
+ * sweep (PRD 1109). Kept separate from scheduler.cjs so it can be unit
6
+ * tested without importing electron/ipcMain, matching reaperHelpers.cjs.
7
+ */
8
+
9
+ // Non-terminal statuses that mean a row is parked waiting on a human rather
10
+ // than actively progressing (pending/running are expected to move on their
11
+ // own; investigating is mid-flight and already tracked by
12
+ // findStrandedInvestigations).
13
+ const STUCK_STATUSES = new Set(['needs_review', 'quarantined']);
14
+
15
+ /**
16
+ * computeQueueHealth(jobs) → [{ cwd, neverRan, looksDone, stuck }]
17
+ *
18
+ * Read-only: never mutates a row. Counts, per project cwd, how many rows
19
+ * carry `gateOutcome === 'never_ran'`, how many carry a `looksDone`
20
+ * annotation, and how many sit in a stuck (parked, non-terminal) status.
21
+ * Only projects with a non-zero total are returned — the sweep logs one
22
+ * line per non-zero project, nothing for a clean queue.
23
+ */
24
+ function computeQueueHealth(jobs) {
25
+ const byCwd = new Map();
26
+ for (const j of Array.isArray(jobs) ? jobs : []) {
27
+ if (!j) continue;
28
+ const cwd = j.cwd || '(unknown)';
29
+ if (!byCwd.has(cwd)) byCwd.set(cwd, { cwd, neverRan: 0, looksDone: 0, stuck: 0 });
30
+ const bucket = byCwd.get(cwd);
31
+ if (j.gateOutcome === 'never_ran') bucket.neverRan += 1;
32
+ if (j.looksDone) bucket.looksDone += 1;
33
+ if (STUCK_STATUSES.has(j.status)) bucket.stuck += 1;
34
+ }
35
+ return [...byCwd.values()].filter((b) => b.neverRan + b.looksDone + b.stuck > 0);
36
+ }
37
+
38
+ module.exports = { computeQueueHealth, STUCK_STATUSES };
@@ -33,13 +33,13 @@ const fsp = require('node:fs/promises');
33
33
  const path = require('node:path');
34
34
  const os = require('node:os');
35
35
  const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
36
- const { assertOpsWrite } = require('./opsOwnership.cjs');
36
+ const { assertOpsWrite, resolveOpsRoot, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
37
37
  const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
38
38
 
39
39
  const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
40
40
  const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
41
- const OPS_DIRNAME = 'session-manager-operations';
42
- const STATE_SUBPATH = [OPS_DIRNAME, 'scheduler', 'state'];
41
+ const OPS_DIRNAME = OPS_ROOT_DIR;
42
+ const STATE_SUBPATH = ['scheduler', 'state'];
43
43
 
44
44
  /**
45
45
  * A project's `session-manager-operations/scheduler/state/` directory.
@@ -75,7 +75,29 @@ function projectStateDir(cwd) {
75
75
  `projectStateDir: cwd must be a project root, not a path inside ${OPS_DIRNAME}/, got "${cwd}"`,
76
76
  );
77
77
  }
78
- return path.join(cwd, ...STATE_SUBPATH);
78
+ // ...and it must not be an EPHEMERAL cwd (inside os.tmpdir(), or a linked
79
+ // git worktree root) — a worktree is torn down when its Epic/job ends, so
80
+ // any state written there is silently destroyed (verified live 2026-09-01
81
+ // as a scheduler shard rewritten every reconcile pass inside a since-deleted
82
+ // epic worktree). That check, and the worktree → main-tree normalization,
83
+ // live in opsOwnership.resolveOpsRoot (PRD 1082) — the one resolver every
84
+ // ops-root reader/writer goes through. `opsInternal: 'refuse'` keeps this
85
+ // WRITER fail-closed on an ops-internal cwd (the guard above shapes the
86
+ // message; the resolver is the backstop).
87
+ let opsRoot;
88
+ try {
89
+ opsRoot = resolveOpsRoot(cwd, { opsInternal: 'refuse' });
90
+ } catch (e) {
91
+ if (e?.ephemeral) {
92
+ const err = new Error(
93
+ `projectStateDir: refusing ephemeral cwd (tmpdir or linked git worktree), got "${cwd}"`,
94
+ );
95
+ err.ephemeral = true;
96
+ throw err;
97
+ }
98
+ throw e;
99
+ }
100
+ return path.join(opsRoot, ...STATE_SUBPATH);
79
101
  }
80
102
 
81
103
  function projectQueuePath(cwd) {
@@ -97,7 +119,11 @@ function queuePathOrSkip(cwd, context) {
97
119
  try {
98
120
  return projectQueuePath(cwd);
99
121
  } catch (e) {
100
- console.error(`[queueStore] ${context}: skipping invalid project cwd — ${e?.message}`);
122
+ if (e?.ephemeral) {
123
+ console.warn(`[queueStore] ${context}: refusing ephemeral cwd "${cwd}" — ${e.message}`);
124
+ } else {
125
+ console.error(`[queueStore] ${context}: skipping invalid project cwd — ${e?.message}`);
126
+ }
101
127
  return null;
102
128
  }
103
129
  }
@@ -157,6 +183,11 @@ function shapeMachine(raw) {
157
183
  scheduledFor: data.scheduledFor ?? null,
158
184
  lastRunAt: data.lastRunAt ?? null,
159
185
  paused: data.paused ?? null,
186
+ // Launch circuit breaker (lib/launchFailure.cjs): per-persona blocks and
187
+ // the degraded-mode env a persona is currently launching with. Machine
188
+ // state, not per-project: the broken thing is the installed CLI.
189
+ launchBlocks: data.launchBlocks && typeof data.launchBlocks === 'object' ? data.launchBlocks : {},
190
+ launchMitigations: data.launchMitigations && typeof data.launchMitigations === 'object' ? data.launchMitigations : {},
160
191
  };
161
192
  }
162
193
 
@@ -203,7 +234,7 @@ function shapeJobs(raw, file) {
203
234
  * consulted so writeSplit can persist "this project now has zero jobs".
204
235
  */
205
236
  function readMergedSync(opts) {
206
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
237
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
207
238
  const sourceCwds = [];
208
239
  try {
209
240
  Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
@@ -233,7 +264,7 @@ function readMergedSync(opts) {
233
264
 
234
265
  /** Async twin of readMergedSync for IPC hot paths. */
235
266
  async function readMerged(opts) {
236
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
267
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
237
268
  const sourceCwds = [];
238
269
  try {
239
270
  Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
@@ -283,6 +314,8 @@ async function writeSplit(state, defaultCwd) {
283
314
  scheduledFor: state.scheduledFor ?? null,
284
315
  lastRunAt: state.lastRunAt ?? null,
285
316
  paused: state.paused ?? null,
317
+ launchBlocks: state.launchBlocks ?? {},
318
+ launchMitigations: state.launchMitigations ?? {},
286
319
  });
287
320
 
288
321
  const byCwd = new Map();
@@ -0,0 +1,48 @@
1
+ /**
2
+ * quietMachineLease.cjs — the exclusive, machine-wide dispatch lock a
3
+ * `quietMachine: true` PRD holds for the duration of its run (PRD 1107).
4
+ *
5
+ * Process-local, like sessionSlots.cjs's holder map — reset on restart by
6
+ * design; a crashed lease must never wedge the queue permanently. This is
7
+ * NOT a second concurrency pool: sessionSlots.cjs remains the sole slot
8
+ * pool. This module only answers "is a quiet-machine job currently running,
9
+ * and if so, hold every other dispatch until it releases?" — schedulerBatch
10
+ * .cjs's pickNextBatch reads isHeld() to gate the WHOLE tick, not just one
11
+ * project.
12
+ */
13
+ 'use strict';
14
+
15
+ // slug of the job currently holding the lease, or null.
16
+ let heldBySlug = null;
17
+
18
+ function isHeld() {
19
+ return heldBySlug !== null;
20
+ }
21
+
22
+ /** acquire(slug) → true if the lease was free and is now held by `slug`. */
23
+ function acquire(slug) {
24
+ if (heldBySlug !== null) return false;
25
+ heldBySlug = String(slug);
26
+ return true;
27
+ }
28
+
29
+ /** release(slug) — idempotent; releasing when not held, or held by a
30
+ * different slug, is a no-op (mirrors sessionSlots.release's tolerance for
31
+ * an already-released token). */
32
+ function release(slug) {
33
+ if (heldBySlug === null) return false;
34
+ if (heldBySlug !== String(slug)) return false;
35
+ heldBySlug = null;
36
+ return true;
37
+ }
38
+
39
+ function holder() {
40
+ return heldBySlug;
41
+ }
42
+
43
+ /** Test hook: force the lease back to free. */
44
+ function __resetForTests() {
45
+ heldBySlug = null;
46
+ }
47
+
48
+ module.exports = { isHeld, acquire, release, holder, __resetForTests };
@@ -47,6 +47,7 @@ const VERDICT_LABELS = {
47
47
  verify_unavailable: 'verify unavailable',
48
48
  uncommitted_changes: 'uncommitted changes',
49
49
  no_verdict_sentinel: 'no commit or verdict sentinel',
50
+ abandoned_background_task: 'abandoned auto-backgrounded task',
50
51
  pass_no_commit: 'PASS sentinel but no commit landed',
51
52
  pass_no_commit_already_shipped: 'PASS with no commit — deliverables already shipped',
52
53
  pass_no_commit_prior_run_verified: 'PASS with no commit — prior run of this slug already landed the work',
@@ -67,14 +68,47 @@ const FAILURE_CLASSES = {
67
68
  NO_SENTINEL: 'no-sentinel',
68
69
  UNCOMMITTED: 'uncommitted-changes',
69
70
  TRANSCRIPT_ERRORS: 'transcript-errors',
71
+ ABANDONED_BACKGROUND_TASK: 'abandoned-background-task',
70
72
  UNKNOWN: 'unknown',
71
73
  };
72
74
 
75
+ // ─── Recovery actions — one per failure class, machine-readable ────────────
76
+ // Closed set the scheduler routes on: 'archive' (stale re-run, do not
77
+ // re-queue), 'resume-and-commit' (PRD 1111's --resume dispatch owns this),
78
+ // 'verify-and-close' (the work likely landed, just missing its sentinel),
79
+ // 'investigate' (the only class that still buys a fix-plan investigation).
80
+ const RECOVERY_ACTIONS = {
81
+ [FAILURE_CLASSES.ALREADY_SHIPPED]: 'archive',
82
+ [FAILURE_CLASSES.SELF_QUEUE]: 'investigate',
83
+ [FAILURE_CLASSES.STUCK_LOOP]: 'investigate',
84
+ [FAILURE_CLASSES.POST_AC_OVERRUN]: 'investigate',
85
+ [FAILURE_CLASSES.NO_SENTINEL]: 'verify-and-close',
86
+ [FAILURE_CLASSES.UNCOMMITTED]: 'resume-and-commit',
87
+ [FAILURE_CLASSES.TRANSCRIPT_ERRORS]: 'investigate',
88
+ // NOT 'resume-and-commit': that action is only ever actually dispatched by
89
+ // selectResumeRecoveryTarget in scheduler.cjs, which gates strictly on
90
+ // verifierVerdict === 'uncommitted_changes' plus a sessionId + recorded
91
+ // uncommittedPaths — none of which an abandoned_background_task job has.
92
+ // Labeling it 'resume-and-commit' here would be pure telemetry fiction (the
93
+ // RCA report / job.rcaRecoveryAction would claim a resume attempt that
94
+ // never happens); 'investigate' is what actually runs (spawnInvestigation),
95
+ // and buildInvestigationPrompt's abandoned-background-task note is what
96
+ // carries the "check for salvaged work, commit before re-implementing"
97
+ // guidance into that cold-read investigation.
98
+ [FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]: 'investigate',
99
+ [FAILURE_CLASSES.UNKNOWN]: 'investigate',
100
+ };
101
+
102
+ /** Pure — returns the closed-set recovery action for a failure class, defaulting to 'investigate'. */
103
+ function recoveryActionFor(failureClass) {
104
+ return RECOVERY_ACTIONS[failureClass] ?? 'investigate';
105
+ }
106
+
73
107
  const PREVENTION_HINTS = {
74
108
  [FAILURE_CLASSES.ALREADY_SHIPPED]:
75
109
  "This run found its acceptance criteria already satisfied by a prior commit and correctly made no change, so no commit landed and the verifier returned `pass_no_commit`. This is a stale re-run, not an execution failure — the PRD's `.md` was never moved out of `session-manager-operations/scheduler/prds/` after the work shipped. Archive the PRD into `session-manager-operations/scheduler/prds-archived/` instead of re-queuing or re-running it.",
76
110
  [FAILURE_CLASSES.SELF_QUEUE]:
77
- "This run either invoked /develop or /process-feedback from inside its own headless execution, or backgrounded a long-running command and called ScheduleWakeup to check back later — both are the 'you ARE the executor — never re-queue or self-schedule' anti-pattern. A headless PRD run must perform its own acceptance criteria directly and has no next turn to resume it (standards.md → Execution discipline).",
111
+ "This run either invoked /develop or /process-feedback from inside its own headless execution, or backgrounded a long-running command (e.g. a verification/test run) and called Monitor, TaskOutput, or ScheduleWakeup to check back later — all are the 'you ARE the executor — never re-queue or self-schedule' anti-pattern. A headless PRD run must perform its own acceptance criteria directly and has no next turn to resume it (standards.md → Execution discipline).",
78
112
  [FAILURE_CLASSES.STUCK_LOOP]:
79
113
  'Bound every command with `timeout <N> <cmd>` — never leave an unbounded `until`/`while true`/`sleep` poll in a PRD body (PRD_AUTHORING.md loop-hang guidance).',
80
114
  [FAILURE_CLASSES.POST_AC_OVERRUN]:
@@ -85,6 +119,8 @@ const PREVENTION_HINTS = {
85
119
  'Stage the exact paths you created or modified — never a blanket/wildcard git-add of the whole tree, since the queue can run sibling jobs against this same working tree and a blanket add sweeps up their in-flight edits — and `git commit` as the final finish-protocol step before printing the verdict sentinel; never end a run with a dirty working tree.',
86
120
  [FAILURE_CLASSES.TRANSCRIPT_ERRORS]:
87
121
  'Recover or annotate every error within ~10 lines (e.g. `# expected/handled: <why>`) instead of leaving a bare Traceback near the end of the transcript.',
122
+ [FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]:
123
+ 'A headless run cannot receive a background-task completion notification — never let a long Bash command auto-background past its foreground timeout and then wait for it. Run long commands with an explicit bound (`timeout <N> <cmd>`) so they finish in the foreground, or poll their output file directly instead of waiting on Monitor/notification.',
88
124
  [FAILURE_CLASSES.UNKNOWN]:
89
125
  'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
90
126
  };
@@ -139,6 +175,16 @@ function classifyFailure({ verdict, logTail }) {
139
175
  return FAILURE_CLASSES.ALREADY_SHIPPED;
140
176
  }
141
177
 
178
+ // Checked before SELF_QUEUE/STUCK_LOOP: the verifier already did the real
179
+ // detection work (scanning for the harness's own auto-background marker
180
+ // text) to produce this verdict — trust it rather than re-deriving from the
181
+ // log tail, which can otherwise false-match SELF_QUEUE_WAKEUP_RE/STUCK_LOOP_RE
182
+ // on words like "sleep"/"background" that appear in the harness's own
183
+ // auto-background tool_result.
184
+ if (verdict === 'abandoned_background_task') {
185
+ return FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK;
186
+ }
187
+
142
188
  // Checked before STUCK_LOOP: a backgrounded command + ScheduleWakeup
143
189
  // (variant b) can land words like "sleep"/"poll" in the tail via the PRD's
144
190
  // own AC text, which would otherwise false-match STUCK_LOOP_RE (see PRD 771).
@@ -226,7 +272,8 @@ function readPrdBody(cwd, slug) {
226
272
  */
227
273
  function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, investigationText }) {
228
274
  const title = `Root cause: ${job.slug} → needs_review (${humanVerdict(verdict)})`;
229
- const fm = ['---', `title: ${title}`, 'source: scheduler-rca', 'type: rca', 'severity: normal', `prdSlug: ${job.slug}`, `runId: ${job.runId}`, `verdict: ${verdict}`, '---'].join('\n');
275
+ const recoveryAction = recoveryActionFor(failureClass);
276
+ const fm = ['---', `title: ${title}`, 'source: scheduler-rca', 'type: rca', 'severity: normal', `prdSlug: ${job.slug}`, `runId: ${job.runId}`, `verdict: ${verdict}`, `failure-class: ${failureClass}`, `recovery-action: ${recoveryAction}`, '---'].join('\n');
230
277
 
231
278
  const last60 = lastNLines(logTail, 60);
232
279
  const exitCode = meta?.exitCode ?? job.exitCode ?? 'unknown';
@@ -315,8 +362,9 @@ async function writeRcaReport({ job, runDir, verdict, annotations, investigation
315
362
  fs.renameSync(tmp, reportPath);
316
363
 
317
364
  const summary = rcaSummaryLine(job, verdict);
318
- console.log(`[rca] wrote ${reportPath} (${failureClass})`);
319
- return { filed: true, path: reportPath, summary, failureClass };
365
+ const recoveryAction = recoveryActionFor(failureClass);
366
+ console.log(`[rca] wrote ${reportPath} (${failureClass} → ${recoveryAction})`);
367
+ return { filed: true, path: reportPath, summary, failureClass, recoveryAction };
320
368
  } catch (e) {
321
369
  console.error('[rca] error writing RCA report', e?.message ?? String(e));
322
370
  return { filed: false, reason: 'error', error: e?.message ?? String(e) };
@@ -334,4 +382,6 @@ module.exports = {
334
382
  buildRcaMarkdown,
335
383
  FAILURE_CLASSES,
336
384
  VERDICT_LABELS,
385
+ RECOVERY_ACTIONS,
386
+ recoveryActionFor,
337
387
  };
@@ -64,10 +64,73 @@ function classifyRunOutcome(logPath) {
64
64
  }
65
65
  }
66
66
 
67
+ /**
68
+ * Map classifyRunOutcome()'s four-way result onto the smaller, persisted
69
+ * `gateOutcome` a job row carries. 'no_result' really means the verdict gate
70
+ * never fired — the process died or was killed before it could emit one —
71
+ * a different fact from 'failed' (a verdict event exists and says error,
72
+ * i.e. the gate ran and went red). Collapsing both behind the same free-text
73
+ * `error` string is exactly the ambiguity this field exists to remove.
74
+ */
75
+ function mapOutcomeToGateOutcome(outcome) {
76
+ switch (outcome) {
77
+ case 'success': return 'passed';
78
+ case 'failed': return 'failed';
79
+ case 'no_result': return 'never_ran';
80
+ default: return 'unknown';
81
+ }
82
+ }
83
+
67
84
  // Max times an orphaned job may be re-queued before giving up (marking failed).
68
85
  // Single source of truth: both the in-app reaper (scheduler.cjs) and the external
69
86
  // offline watchdog (watchdogHelpers.cjs) import this so their give-up budgets can
70
87
  // never drift apart (they increment the SAME j.orphanRetries field).
71
88
  const ORPHAN_REQUEUE_CAP = 5;
72
89
 
73
- module.exports = { claudePidAlive, classifyRunOutcome, ORPHAN_REQUEUE_CAP };
90
+ /**
91
+ * selectReapableJobs(jobs, now, { pidAlive, grace }) → { reapable, warnings }
92
+ *
93
+ * Pure predicate (no IO — `pidAlive` is injected) deciding which 'running'
94
+ * rows reapDeadRunningJobs() should finalize this cycle. Two distinct dead
95
+ * shapes:
96
+ * - has a runtime.pid, but the process is gone (pidAlive(pid) === false):
97
+ * the existing, unchanged behaviour.
98
+ * - has NO runtime.pid at all: previously skipped forever ("spawn may be
99
+ * mid-flight; give it a cycle" with no age bound). Now reaped once
100
+ * `startedAt` is older than `grace` — the spawn never got far enough to
101
+ * record a pid and nothing pid-bound can ever catch it (see
102
+ * PIDLESS_SPAWN_GRACE_MS's header for why the grace window is safe).
103
+ *
104
+ * A pidless row whose `startedAt` is missing or unparseable is neither
105
+ * reaped nor skipped silently — age can't be proven, so it is surfaced in
106
+ * `warnings` instead (the caller logs it) and left alone.
107
+ */
108
+ function selectReapableJobs(jobs, now, { pidAlive, grace } = {}) {
109
+ const reapable = [];
110
+ const warnings = [];
111
+ for (const j of jobs ?? []) {
112
+ if (j.status !== 'running') continue;
113
+ const pid = j.runtime?.pid;
114
+ if (pid) {
115
+ if (pidAlive(pid)) continue;
116
+ reapable.push({ slug: j.slug, pid, pidless: false });
117
+ continue;
118
+ }
119
+ const startedAt = Date.parse(j.startedAt ?? '');
120
+ if (Number.isNaN(startedAt)) {
121
+ warnings.push({ slug: j.slug, reason: 'pidless row with missing/unparseable startedAt — cannot prove age' });
122
+ continue;
123
+ }
124
+ const ageMs = now - startedAt;
125
+ if (ageMs < grace) continue; // spawn may still be mid-flight
126
+ reapable.push({
127
+ slug: j.slug,
128
+ pid: null,
129
+ pidless: true,
130
+ reason: `reaped: no runtime.pid recorded after ${Math.round(grace / 60_000)}m — spawn never completed`,
131
+ });
132
+ }
133
+ return { reapable, warnings };
134
+ }
135
+
136
+ module.exports = { claudePidAlive, classifyRunOutcome, mapOutcomeToGateOutcome, ORPHAN_REQUEUE_CAP, selectReapableJobs };
@@ -77,6 +77,11 @@ const ScheduleJobSchema = z
77
77
  finishedAt: z.string().nullable().optional(),
78
78
  exitCode: z.number().nullable().optional(),
79
79
  error: z.string().nullable().optional(),
80
+ // Structured terminal-outcome label set alongside the free-text `error`
81
+ // (never replacing it) so a queue reader can tell "the gate ran and went
82
+ // red" (failed) apart from "the gate never got to run" (never_ran) —
83
+ // see reaperHelpers.cjs's mapOutcomeToGateOutcome (PRD 1109).
84
+ gateOutcome: z.enum(['passed', 'failed', 'never_ran', 'unknown']).optional(),
80
85
  sessionId: z.string().optional(),
81
86
  runtime: ScheduleJobRuntimeSchema.optional(),
82
87
  verifierVerdict: z.string().optional(),
@@ -86,6 +91,32 @@ const ScheduleJobSchema = z
86
91
  sourcePromptId: z.string().nullable().optional(),
87
92
  epicId: z.string().nullable().optional(),
88
93
  statusHistory: z.array(ScheduleJobStatusHistoryEntrySchema).optional(),
94
+ // Opt-in exclusive-lease PRD flag (PRD 1107) — serializes this job against
95
+ // every other job machine-wide for its run so a timing-sensitive gate
96
+ // (frame time, performance fence) measures without CPU contention. See
97
+ // quietMachineLease.cjs + schedulerBatch.cjs's pickNextBatch.
98
+ quietMachine: z.boolean().optional(),
99
+ // Set true when the job was dispatched anyway after waiting past
100
+ // quietMachineWaitMs without the machine going quiet — tells a reader the
101
+ // job's measurements ran under contention, not on a quiet machine.
102
+ quietLeaseDegraded: z.boolean().optional(),
103
+ // Salvaged uncommitted diff patch path (PRD 1098).
104
+ salvagePatch: z.string().nullable().optional(),
105
+ // Leftover-attribution fields: the newly-dirty paths a terminal run left
106
+ // uncommitted, diffed against the persisted pre-run baseline
107
+ // (guardBaseline/guardHeadBefore below). Set on EVERY terminal outcome
108
+ // that left dirt (completed/failed/needs_review alike), not just the
109
+ // exit=0 commit-guard path — deleted when the run left nothing. Capped
110
+ // at LEFTOVER_PATHS_CAP paths (leftoverCount carries the true total).
111
+ leftoverPaths: z.array(z.string()).optional(),
112
+ leftoverCount: z.number().optional(),
113
+ leftoverPathsTruncated: z.boolean().optional(),
114
+ // Pre-run working-tree snapshot, persisted at dispatch so a finalizer
115
+ // that never reaches spawnJob's own completion path (reapDeadRunningJobs)
116
+ // can still compute a truthful newly-dirty delta. Cleared once the run
117
+ // finalizes — never meant to outlive one run.
118
+ guardBaseline: z.array(z.string()).optional(),
119
+ guardHeadBefore: z.string().nullable().optional(),
89
120
  })
90
121
  .passthrough();
91
122
 
@@ -55,7 +55,11 @@ const STATUS_HISTORY_CAP = 20;
55
55
  * (admin reset can target a job mid-investigation)
56
56
  * - failed->investigating (spawn a probe), failed->pending (admin reset /
57
57
  * transient retry), failed->completed (auto-promote a fix-plan's healed
58
- * original)
58
+ * original), failed->needs_review (reverifyNeedsReview's looksDone pass,
59
+ * PRD 1102: an unverified-shaped failed row — no sentinel, no result
60
+ * event — with a commit landed after its run window that touches its own
61
+ * declared paths is surfaced to a human as needs_review, never silently
62
+ * auto-completed)
59
63
  * - needs_review->investigating, needs_review->pending, needs_review->completed
60
64
  * (heal on reverify, or auto-promote) — same shape as `failed`
61
65
  * - completed->pending (force-only reset, gated separately by
@@ -80,7 +84,7 @@ const LEGAL_TRANSITIONS = {
80
84
  pending: ['running', 'completed', 'failed', 'skipped'],
81
85
  running: ['completed', 'failed', 'needs_review', 'skipped', 'pending'],
82
86
  investigating: ['failed', 'needs_review', 'completed', 'pending', 'skipped'],
83
- failed: ['investigating', 'pending', 'completed'],
87
+ failed: ['investigating', 'pending', 'completed', 'needs_review'],
84
88
  needs_review: ['investigating', 'pending', 'completed', 'skipped'],
85
89
  completed: ['pending'],
86
90
  skipped: ['pending'],