claude-code-session-manager 0.87.0 → 0.88.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 (290) hide show
  1. package/README.md +26 -53
  2. package/dist/assets/{AgentLibrary-DyLWzZDf.js → AgentLibrary-BZ6IY2g1.js} +1 -1
  3. package/dist/assets/{DataModel--mISIJ6h.js → DataModel-dAoGAODX.js} +1 -1
  4. package/dist/assets/{History-C2ahUXTg.js → History-CF157HyX.js} +1 -1
  5. package/dist/assets/{Hooks-BiC6oyR2.js → Hooks-CCPPipBk.js} +1 -1
  6. package/dist/assets/{HostBilko-BPleEOld.js → HostBilko-Dlmz2m06.js} +1 -1
  7. package/dist/assets/{Library-Dc8Qst1R.js → Library-BaXcboWX.js} +1 -1
  8. package/dist/assets/{ListDetail-DIXh-OLX.js → ListDetail-CoUCzG5S.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-C90bkLXK.js → MarkdownEditor-DTg3JlOT.js} +1 -1
  10. package/dist/assets/{McpServers-DqcbLOLZ.js → McpServers-DdjY1sA3.js} +1 -1
  11. package/dist/assets/{Memory-CW62MXlh.js → Memory-BJ3Jlw0C.js} +1 -1
  12. package/dist/assets/{Panel-Bw1FhRuF.js → Panel-ygRSgc41.js} +1 -1
  13. package/dist/assets/{Permissions-BcUC-5y8.js → Permissions-m0KAnccF.js} +1 -1
  14. package/dist/assets/{Plugins-BnKx9flD.js → Plugins-CJRl6B3Q.js} +2 -2
  15. package/dist/assets/{ProvenanceBadge-Bw5vNVPT.js → ProvenanceBadge-w8cnlVvW.js} +1 -1
  16. package/dist/assets/{SaveBar-CWr0O_w-.js → SaveBar-BpyPcUIk.js} +1 -1
  17. package/dist/assets/{Scheduler-DYdLuUqq.js → Scheduler-D2ppxSfy.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-CrBLbg8s.js → ScopeSwitcher-EyuIwt-j.js} +1 -1
  19. package/dist/assets/{Settings-DluB-vN1.js → Settings-BHSGGFEc.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-CHLSseay.js → SkillReferenceGraph-BNaZ2JCF.js} +1 -1
  21. package/dist/assets/{Skills-gNdo_HNK.js → Skills-Dl3q75QV.js} +1 -1
  22. package/dist/assets/{SystemPrompt-Cru05-Ia.js → SystemPrompt-Bg6F-Cyp.js} +1 -1
  23. package/dist/assets/{TagLibrary-DNHY0xou.js → TagLibrary-BSutflCy.js} +1 -1
  24. package/dist/assets/{TiptapBody-I4lmbCgP.js → TiptapBody-CXih4xrc.js} +1 -1
  25. package/dist/assets/{Toggle-bWMHjmRh.js → Toggle-2qtUHhA9.js} +1 -1
  26. package/dist/assets/{index-fc_JjdxL.js → index-B3emy2oI.js} +356 -356
  27. package/dist/assets/{index-DV3PorRY.css → index-BHTX4OTc.css} +1 -1
  28. package/dist/assets/{settingsSchema-BfhtZnGD.js → settingsSchema-CdzxaqwM.js} +1 -1
  29. package/dist/assets/{whisperWorker-Dbia1OpC.js → whisperWorker-C7ZGQwKg.js} +7 -7
  30. package/dist/index.html +2 -2
  31. package/dist/vad/ort-wasm-simd-threaded.asyncify.mjs +106 -110
  32. package/dist/vad/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  33. package/dist/vad/ort-wasm-simd-threaded.jsep.mjs +98 -98
  34. package/dist/vad/ort-wasm-simd-threaded.jsep.wasm +0 -0
  35. package/dist/vad/ort-wasm-simd-threaded.jspi.mjs +99 -102
  36. package/dist/vad/ort-wasm-simd-threaded.jspi.wasm +0 -0
  37. package/dist/vad/ort-wasm-simd-threaded.mjs +46 -46
  38. package/dist/vad/ort-wasm-simd-threaded.wasm +0 -0
  39. package/package.json +10 -13
  40. package/scripts/README.md +59 -0
  41. package/scripts/audit-ops-hygiene.cjs +350 -0
  42. package/scripts/hooks/guard-destructive-git.cjs +10 -34
  43. package/scripts/hooks/guard-inline-implementation.cjs +14 -8
  44. package/scripts/hooks/guard-prd-writes.cjs +7 -36
  45. package/scripts/hooks/guard-self-schedule.cjs +175 -0
  46. package/scripts/ops-sweep.cjs +355 -0
  47. package/scripts/scheduler-mcp-server.cjs +28 -71
  48. package/src/main/__tests__/bilkoHost-integration.test.cjs +3 -3
  49. package/src/main/__tests__/chat-cancel-terminal.test.cjs +6 -9
  50. package/src/main/__tests__/chat-exit-close-race.test.cjs +3 -3
  51. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +4 -5
  52. package/src/main/__tests__/chat-queue.test.cjs +2 -2
  53. package/src/main/__tests__/chat-stop-signal.test.cjs +2 -2
  54. package/src/main/__tests__/dep-orphan-archive-health.test.cjs +77 -0
  55. package/src/main/__tests__/dod-batchkey.test.cjs +2 -2
  56. package/src/main/__tests__/dod-drain-hook.test.cjs +2 -2
  57. package/src/main/__tests__/dod-report.test.cjs +2 -2
  58. package/src/main/__tests__/dod-reverify.test.cjs +2 -2
  59. package/src/main/__tests__/epicMint.test.cjs +2 -2
  60. package/src/main/__tests__/exchanges.test.cjs +2 -2
  61. package/src/main/__tests__/extractJson.test.cjs +2 -2
  62. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  63. package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +556 -0
  64. package/src/main/__tests__/health-build-freshness.test.cjs +39 -0
  65. package/src/main/__tests__/health-delegation-chain.test.cjs +15 -1
  66. package/src/main/__tests__/health-queue-dispatch.test.cjs +58 -7
  67. package/src/main/__tests__/health-tick-liveness.test.cjs +11 -3
  68. package/src/main/__tests__/health-usage-poller.test.cjs +70 -23
  69. package/src/main/__tests__/historyRollup.test.cjs +2 -2
  70. package/src/main/__tests__/kg-augment.test.cjs +2 -2
  71. package/src/main/__tests__/mcpStatus.test.cjs +2 -2
  72. package/src/main/__tests__/memoryAggregate.test.cjs +1 -1
  73. package/src/main/__tests__/memoryStale.test.cjs +1 -1
  74. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +25 -1
  75. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +33 -3
  76. package/src/main/__tests__/prd-group-allocator.test.cjs +2 -2
  77. package/src/main/__tests__/prdAdminRouteParity.test.cjs +2 -0
  78. package/src/main/__tests__/prdAdminRoutes.test.cjs +14 -2
  79. package/src/main/__tests__/prdAuthoringSeed.test.cjs +39 -0
  80. package/src/main/__tests__/prdLocationsArchived.test.cjs +20 -13
  81. package/src/main/__tests__/proc-role-env.test.cjs +125 -0
  82. package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +304 -0
  83. package/src/main/__tests__/procname-sm-processes.test.cjs +127 -0
  84. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +81 -401
  85. package/src/main/__tests__/projectPages.test.cjs +63 -149
  86. package/src/main/__tests__/queue-health-verdict.test.cjs +9 -9
  87. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +117 -20
  88. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  89. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +38 -3
  90. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +181 -0
  91. package/src/main/__tests__/runVerify.test.cjs +5 -5
  92. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +3 -3
  93. package/src/main/__tests__/scheduleJobTransitions.test.cjs +2 -2
  94. package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +143 -0
  95. package/src/main/__tests__/scheduler-autofix-select.test.cjs +2 -2
  96. package/src/main/__tests__/scheduler-autopromote.test.cjs +2 -2
  97. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +3 -5
  98. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +78 -97
  99. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +161 -0
  100. package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +58 -0
  101. package/src/main/__tests__/scheduler-epic-digest.test.cjs +3 -5
  102. package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +2 -2
  103. package/src/main/__tests__/scheduler-gate-shadow.test.cjs +119 -0
  104. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +46 -0
  105. package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +80 -0
  106. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +25 -18
  107. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +4 -4
  108. package/src/main/__tests__/scheduler-launch-failure.test.cjs +3 -5
  109. package/src/main/__tests__/scheduler-looks-done.test.cjs +26 -7
  110. package/src/main/__tests__/scheduler-manual-pause.test.cjs +118 -0
  111. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +26 -3
  112. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +17 -2
  113. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +3 -5
  114. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +41 -6
  115. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +62 -6
  116. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +3 -5
  117. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +23 -4
  118. package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +100 -0
  119. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +3 -3
  120. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +4 -4
  121. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +110 -0
  122. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +81 -1
  123. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +4 -4
  124. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +245 -0
  125. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +81 -0
  126. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +2 -2
  127. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +172 -0
  128. package/src/main/__tests__/scheduler-transient-failure.test.cjs +2 -2
  129. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +37 -11
  130. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +3 -5
  131. package/src/main/__tests__/usageSingleFlight.test.cjs +157 -0
  132. package/src/main/__tests__/workTypeLibrary.test.cjs +1 -1
  133. package/src/main/bilkoHost.cjs +16 -7
  134. package/src/main/build-info.json +8 -0
  135. package/src/main/chatRunner.cjs +4 -2
  136. package/src/main/config.cjs +2 -3
  137. package/src/main/docEdit.cjs +4 -2
  138. package/src/main/health.cjs +267 -51
  139. package/src/main/heapSnapshot.cjs +2 -2
  140. package/src/main/historyAggregator.cjs +1 -1
  141. package/src/main/index.cjs +61 -10
  142. package/src/main/ipcSchemas.cjs +6 -17
  143. package/src/main/lib/__tests__/auditLog.test.cjs +38 -0
  144. package/src/main/lib/__tests__/buildIdentity.test.cjs +121 -0
  145. package/src/main/lib/__tests__/cwdClassify.test.cjs +111 -0
  146. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +95 -0
  147. package/src/main/lib/__tests__/delegationReadiness.test.cjs +1 -1
  148. package/src/main/lib/__tests__/dispatchLoop.test.cjs +63 -0
  149. package/src/main/lib/__tests__/gateFixtures.json +20 -0
  150. package/src/main/lib/__tests__/gitWorktree.test.cjs +8 -1
  151. package/src/main/lib/__tests__/instanceLock.test.cjs +93 -7
  152. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +78 -0
  153. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +11 -0
  154. package/src/main/lib/__tests__/localAdminHttp.test.cjs +1 -1
  155. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +6 -1
  156. package/src/main/lib/__tests__/procIdentity.test.cjs +119 -0
  157. package/src/main/lib/__tests__/procName.test.cjs +92 -0
  158. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +67 -0
  159. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +6 -7
  160. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +37 -204
  161. package/src/main/lib/__tests__/schedulerPaths.test.cjs +226 -0
  162. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +94 -0
  163. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +56 -0
  164. package/src/main/lib/__tests__/sessionSlots.test.cjs +45 -0
  165. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +1 -1
  166. package/src/main/lib/__tests__/upgradeDrain.test.cjs +130 -0
  167. package/src/main/lib/__tests__/usageCircuit.test.cjs +61 -0
  168. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +63 -0
  169. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +73 -0
  170. package/src/main/lib/activeIndexRebuild.cjs +1 -3
  171. package/src/main/lib/activeSessions.cjs +20 -88
  172. package/src/main/lib/adoptedRunSupervisor.cjs +136 -0
  173. package/src/main/lib/agentModelResolve.cjs +33 -1
  174. package/src/main/lib/agentPersonaSchema.cjs +2 -2
  175. package/src/main/lib/auditLog.cjs +30 -5
  176. package/src/main/lib/buildIdentity.cjs +113 -0
  177. package/src/main/lib/classifyPromptTicket.cjs +3 -2
  178. package/src/main/lib/claudeBin.cjs +37 -2
  179. package/src/main/lib/cleanEnv.cjs +27 -1
  180. package/src/main/lib/credentials.cjs +4 -2
  181. package/src/main/lib/cwdClassify.cjs +185 -0
  182. package/src/main/lib/definitionOfDone.cjs +296 -52
  183. package/src/main/lib/dispatchLoop.cjs +40 -0
  184. package/src/main/lib/effectiveModelInfo.cjs +9 -10
  185. package/src/main/lib/ephemeralCwd.cjs +7 -28
  186. package/src/main/lib/gitWorktree.cjs +95 -8
  187. package/src/main/lib/guardShims.cjs +3 -3
  188. package/src/main/lib/historyRollup.cjs +6 -7
  189. package/src/main/lib/instanceLock.cjs +32 -5
  190. package/src/main/lib/jobSupervisorRecord.cjs +147 -0
  191. package/src/main/lib/jobWorktreeBootLive.cjs +9 -5
  192. package/src/main/lib/localAdminHttp.cjs +9 -27
  193. package/src/main/lib/mcpToolCatalog.cjs +29 -77
  194. package/src/main/lib/opsOwnership.cjs +27 -19
  195. package/src/main/lib/prdAuthoringSeed.cjs +36 -0
  196. package/src/main/lib/prdLocations.cjs +48 -1
  197. package/src/main/lib/procIdentity.cjs +126 -0
  198. package/src/main/lib/procName.cjs +98 -0
  199. package/src/main/lib/projectHomeAdminRoutes.cjs +56 -327
  200. package/src/main/lib/queueHistory.cjs +8 -7
  201. package/src/main/lib/queueStore.cjs +57 -37
  202. package/src/main/lib/quietMachineLease.cjs +19 -2
  203. package/src/main/lib/reservationExpiry.cjs +30 -0
  204. package/src/main/lib/runClaudeP.cjs +4 -2
  205. package/src/main/lib/runLogRetention.cjs +3 -2
  206. package/src/main/lib/scheduleJobSchema.cjs +2 -2
  207. package/src/main/lib/scheduleJobTransitions.cjs +29 -2
  208. package/src/main/lib/schedulerBatch.cjs +27 -2
  209. package/src/main/lib/schedulerPaths.cjs +175 -0
  210. package/src/main/lib/schedulerRuntimeState.cjs +59 -0
  211. package/src/main/lib/sessionSlots.cjs +47 -9
  212. package/src/main/lib/smProcNames.cjs +51 -0
  213. package/src/main/lib/upgradeDrain.cjs +188 -0
  214. package/src/main/lib/usageCircuit.cjs +53 -6
  215. package/src/main/lib/watchdogHelpers.cjs +70 -37
  216. package/src/main/lib/withTimeout.cjs +33 -0
  217. package/src/main/mcpStatus.cjs +4 -2
  218. package/src/main/pluginInstall.cjs +6 -2
  219. package/src/main/projectPages.cjs +41 -145
  220. package/src/main/pty.cjs +8 -1
  221. package/src/main/queueOps.cjs +10 -10
  222. package/src/main/runVerify.cjs +69 -3
  223. package/src/main/scheduler.cjs +1598 -381
  224. package/src/main/seedAgentPersonas.cjs +1 -1
  225. package/src/main/seedDevPlugin.cjs +1 -1
  226. package/src/main/seedSchedulerMcp.cjs +7 -6
  227. package/src/main/seedStatus.cjs +1 -1
  228. package/src/main/supervisor.cjs +5 -3
  229. package/src/main/usage.cjs +126 -62
  230. package/src/preload/api.d.ts +18 -64
  231. package/src/preload/index.cjs +2 -4
  232. package/src/seed/agents/project-home-builder.md +31 -48
  233. package/screenshots/.gitkeep +0 -0
  234. package/screenshots/README-screenshots.md +0 -13
  235. package/src/main/lib/projectPageSummarySchema.cjs +0 -181
  236. package/src/main/teams.cjs +0 -95
  237. package/src/main/templates/project-pages-catalog.json +0 -741
  238. package/src/main/templates/project-pages-default-home.html +0 -123
  239. package/src/main/templates/project-pages-pipeline.md +0 -417
  240. package/src/seed/prompts/code-review/ac-coverage-check.md +0 -8
  241. package/src/seed/prompts/code-review/correctness-only.md +0 -8
  242. package/src/seed/prompts/code-review/full-spectrum-high.md +0 -8
  243. package/src/seed/prompts/code-review/hallucination-check.md +0 -8
  244. package/src/seed/prompts/code-review/public-api-compat.md +0 -8
  245. package/src/seed/prompts/code-review/readability-naming.md +0 -8
  246. package/src/seed/prompts/debugging/bug-as-failing-test.md +0 -8
  247. package/src/seed/prompts/debugging/git-bisect-regression.md +0 -8
  248. package/src/seed/prompts/debugging/instrument-intermittent-bug.md +0 -8
  249. package/src/seed/prompts/debugging/localize-pipeline-failure.md +0 -8
  250. package/src/seed/prompts/debugging/reproduce-then-diagnose.md +0 -8
  251. package/src/seed/prompts/documentation/adr-from-change.md +0 -8
  252. package/src/seed/prompts/documentation/module-readme.md +0 -8
  253. package/src/seed/prompts/documentation/onboarding-plan.md +0 -8
  254. package/src/seed/prompts/documentation/refresh-claude-md.md +0 -8
  255. package/src/seed/prompts/documentation/tsdoc-public-exports.md +0 -8
  256. package/src/seed/prompts/git-pr/conventional-commit.md +0 -8
  257. package/src/seed/prompts/git-pr/draft-pr-title-body.md +0 -8
  258. package/src/seed/prompts/git-pr/pre-commit-safety-sweep.md +0 -8
  259. package/src/seed/prompts/git-pr/release-notes-block.md +0 -8
  260. package/src/seed/prompts/git-pr/split-large-pr.md +0 -8
  261. package/src/seed/prompts/performance/bundle-startup-audit.md +0 -8
  262. package/src/seed/prompts/performance/complexity-audit.md +0 -8
  263. package/src/seed/prompts/performance/cpu-profile-hot-path.md +0 -8
  264. package/src/seed/prompts/performance/db-query-plan-review.md +0 -8
  265. package/src/seed/prompts/performance/memory-leak-hunt.md +0 -8
  266. package/src/seed/prompts/qa/api-contract-tests.md +0 -8
  267. package/src/seed/prompts/qa/e2e-critical-path.md +0 -8
  268. package/src/seed/prompts/qa/failing-test-for-bug.md +0 -8
  269. package/src/seed/prompts/qa/find-missing-test-coverage.md +0 -8
  270. package/src/seed/prompts/qa/stabilize-flaky-test.md +0 -8
  271. package/src/seed/prompts/qa/tdd-red-first.md +0 -8
  272. package/src/seed/prompts/qa/visual-regression-review.md +0 -8
  273. package/src/seed/prompts/qa/wcag-axe-scan.md +0 -8
  274. package/src/seed/prompts/refactoring/dead-code-sweep.md +0 -8
  275. package/src/seed/prompts/refactoring/extract-duplicated-pattern.md +0 -8
  276. package/src/seed/prompts/refactoring/modernize-legacy-file.md +0 -8
  277. package/src/seed/prompts/refactoring/reduce-cyclomatic-complexity.md +0 -8
  278. package/src/seed/prompts/refactoring/tighten-module-boundaries.md +0 -8
  279. package/src/seed/prompts/security/authz-audit.md +0 -8
  280. package/src/seed/prompts/security/crypto-correctness.md +0 -8
  281. package/src/seed/prompts/security/cwe-top-25-hunt.md +0 -8
  282. package/src/seed/prompts/security/dependency-audit.md +0 -8
  283. package/src/seed/prompts/security/ipc-boundary-hardening.md +0 -8
  284. package/src/seed/prompts/security/owasp-top-10-staged-diff.md +0 -8
  285. package/src/seed/prompts/security/secret-credential-scan.md +0 -8
  286. package/web/README.md +0 -41
  287. package/web/project-pages/logic/dist/logic.cjs +0 -4709
  288. package/web/project-pages/render.cjs +0 -70
  289. package/web/project-pages/renderer/dist/renderer.cjs +0 -18900
  290. package/web/project-pages/validate-summary.cjs +0 -62
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cwdClassify.cjs — THE one answer to "what project is this path?".
5
+ *
6
+ * activeSessions.projectRootOf, ephemeralCwd.isEphemeralCwd and
7
+ * opsOwnership.resolveProjectRoot each used to re-derive that answer and each
8
+ * failed OPEN: when a worktree's `.git` FILE survives but its admin-dir
9
+ * back-reference is gone, worktreeMainRootOf returned null, projectRootOf
10
+ * handed the worktree path back unchanged, isLinkedWorktreeRoot said false, and
11
+ * resolveOpsRoot built `<worktree>/session-manager-operations` — the shape
12
+ * behind the lost-Epics incidents. classifyCwd reports BOTH facts (which
13
+ * project, and how sure we are) so a caller can refuse WRITES for the
14
+ * unprovable case (`unknown`) while dispatch and reads carry on.
15
+ *
16
+ * Kinds:
17
+ * worktree — `.git` is a FILE whose gitdir sits under `<main>/.git/worktrees/`
18
+ * AND the main tree's admin dir back-references it (round trip).
19
+ * projectRoot = <main>.
20
+ * project — `.git` is a directory, OR a FILE whose gitdir is NOT under a
21
+ * worktrees/ dir (separate-git-dir / submodule), OR no `.git` at
22
+ * all. projectRoot = the dir holding `.git` (else the cwd).
23
+ * ephemeral — os.tmpdir() itself, or inside a managed job/epic worktree root,
24
+ * and not provably a live worktree.
25
+ * unknown — a `.git` FILE we cannot resolve (garbled, unreadable, worktrees
26
+ * gitdir with a missing/mismatched back-reference). projectRoot is
27
+ * the (ops-truncated) cwd unchanged; ops WRITES must be refused.
28
+ *
29
+ * Two ops-root facts are returned and deliberately NOT unified:
30
+ * outermostOpsRoot — first `session-manager-operations` segment (indexOf).
31
+ * activeSessions truncates here: a stray nested ops root is itself the bug.
32
+ * innermostOpsRoot — last segment (lastIndexOf). opsOwnership.assertOpsWrite
33
+ * derives the project a write targets from here.
34
+ * Both are null when the path is not inside an ops root.
35
+ *
36
+ * Pure fs, synchronous, never throws. Must stay light — required by
37
+ * scripts/hooks/guard-inline-implementation.cjs (via nearestGitEntry) and
38
+ * activeSessions.cjs; no gitWorktree/config requires.
39
+ */
40
+
41
+ const fs = require('node:fs');
42
+ const os = require('node:os');
43
+ const path = require('node:path');
44
+ const schedulerPaths = require('./schedulerPaths.cjs');
45
+
46
+ const OPS_DIRNAME = 'session-manager-operations';
47
+ const WORKTREES_MARKER = `${path.sep}.git${path.sep}worktrees${path.sep}`;
48
+ // Bound on the ancestor walk — past any real fs depth, guarantees termination.
49
+ const MAX_WALK = 40;
50
+
51
+ /**
52
+ * nearestGitEntry(cwd) → { dir, isFile } for the nearest ancestor-or-self that
53
+ * has a `.git` entry (`isFile` true = linked worktree / submodule pointer file),
54
+ * or null. The tiny pure helper for callers that must not load git tooling.
55
+ */
56
+ function nearestGitEntry(cwd) {
57
+ let dir = cwd;
58
+ for (let i = 0; i < MAX_WALK; i++) {
59
+ let stat = null;
60
+ try { stat = fs.statSync(path.join(dir, '.git')); } catch { /* none here */ }
61
+ if (stat) return { dir, isFile: stat.isFile() };
62
+ const parent = path.dirname(dir);
63
+ if (parent === dir) return null;
64
+ dir = parent;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Classify the git shape at/above `start`. O(depth) stats + ≤2 small reads.
71
+ * → { shape: 'none' | 'gitdir' | 'worktree' | 'separate' | 'unknown', dir, mainRoot, reason }
72
+ */
73
+ function inspectGit(start) {
74
+ const entry = nearestGitEntry(start);
75
+ if (!entry) return { shape: 'none', dir: null, mainRoot: null, reason: 'no .git found' };
76
+ if (!entry.isFile) return { shape: 'gitdir', dir: entry.dir, mainRoot: null, reason: '.git is a directory' };
77
+
78
+ const gitPath = path.join(entry.dir, '.git');
79
+ let body;
80
+ try {
81
+ body = fs.readFileSync(gitPath, 'utf8');
82
+ } catch (e) {
83
+ return { shape: 'unknown', dir: entry.dir, mainRoot: null, reason: `unreadable .git file: ${e?.code || e?.message}` };
84
+ }
85
+ const firstLine = body.split('\n', 1)[0].trim();
86
+ if (!firstLine.startsWith('gitdir:')) {
87
+ return { shape: 'unknown', dir: entry.dir, mainRoot: null, reason: '.git file has no "gitdir:" line' };
88
+ }
89
+ const gitdirRaw = firstLine.slice('gitdir:'.length).trim();
90
+ if (!gitdirRaw) {
91
+ return { shape: 'unknown', dir: entry.dir, mainRoot: null, reason: '.git file has an empty gitdir' };
92
+ }
93
+ // Submodules point at a RELATIVE gitdir; resolve against the file's own dir.
94
+ const gitdir = path.resolve(entry.dir, gitdirRaw);
95
+ const markerIdx = gitdir.indexOf(WORKTREES_MARKER);
96
+ if (markerIdx === -1) {
97
+ // Not a linked worktree: separate-git-dir / submodule. Its own project.
98
+ return { shape: 'separate', dir: entry.dir, mainRoot: null, reason: 'gitdir is not under .git/worktrees/ (separate-git-dir or submodule)' };
99
+ }
100
+ if (markerIdx <= 0) {
101
+ return { shape: 'unknown', dir: entry.dir, mainRoot: null, reason: 'gitdir worktrees marker has no main root above it' };
102
+ }
103
+ const candidateMain = gitdir.slice(0, markerIdx);
104
+ const worktreeName = gitdir.slice(markerIdx + WORKTREES_MARKER.length).split(path.sep)[0];
105
+ if (!worktreeName) {
106
+ return { shape: 'unknown', dir: entry.dir, mainRoot: null, reason: 'gitdir names no worktree' };
107
+ }
108
+ // Round-trip: the .git file is plain text anything could have written, so it
109
+ // must not redirect callers to an arbitrary path. The main tree's own admin
110
+ // dir must point back at THIS .git file.
111
+ const adminGitdirFile = path.join(candidateMain, '.git', 'worktrees', worktreeName, 'gitdir');
112
+ let backRef;
113
+ try {
114
+ backRef = fs.readFileSync(adminGitdirFile, 'utf8').trim();
115
+ } catch {
116
+ return { shape: 'unknown', dir: entry.dir, mainRoot: null, reason: `worktree admin back-reference missing (${adminGitdirFile})` };
117
+ }
118
+ if (path.resolve(backRef) !== path.resolve(gitPath)) {
119
+ return { shape: 'unknown', dir: entry.dir, mainRoot: null, reason: `worktree admin back-reference points elsewhere (${adminGitdirFile})` };
120
+ }
121
+ return { shape: 'worktree', dir: entry.dir, mainRoot: candidateMain, reason: 'linked worktree, back-reference verified' };
122
+ }
123
+
124
+ /**
125
+ * worktreeMainRootOf(cwd) → the main tree's root when cwd sits inside a
126
+ * verified linked worktree, the ancestor itself when `.git` is a directory,
127
+ * else null (no git, separate-git-dir, or anything unprovable).
128
+ */
129
+ function worktreeMainRootOf(cwd) {
130
+ const g = inspectGit(cwd);
131
+ if (g.shape === 'worktree') return g.mainRoot;
132
+ if (g.shape === 'gitdir') return g.dir;
133
+ return null;
134
+ }
135
+
136
+ function isExactlyTmpdir(absCwd) {
137
+ return absCwd === path.resolve(os.tmpdir());
138
+ }
139
+
140
+ function isUnderManagedWorktreeRoot(absCwd) {
141
+ return ['job', 'epic'].map((k) => path.resolve(schedulerPaths.worktreeRoot(k))).some((root) => {
142
+ const rel = path.relative(root, absCwd);
143
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
144
+ });
145
+ }
146
+
147
+ /**
148
+ * classifyCwd(cwd) → { kind, projectRoot, outermostOpsRoot, innermostOpsRoot, reason }
149
+ * See the file header. A non-string/empty cwd is `unknown` with a null root.
150
+ */
151
+ function classifyCwd(cwd) {
152
+ if (!cwd || typeof cwd !== 'string') {
153
+ return { kind: 'unknown', projectRoot: null, outermostOpsRoot: null, innermostOpsRoot: null, reason: 'cwd is not a non-empty string' };
154
+ }
155
+ const parts = cwd.split(path.sep);
156
+ const first = parts.indexOf(OPS_DIRNAME);
157
+ const last = parts.lastIndexOf(OPS_DIRNAME);
158
+ const opsAt = (i) => (i >= 0 ? (parts.slice(0, i + 1).join(path.sep) || path.sep) : null);
159
+ const outermostOpsRoot = opsAt(first);
160
+ const innermostOpsRoot = opsAt(last);
161
+ // Outermost truncation (the activeSessions law). i === 0 is a relative
162
+ // fragment, never truncated.
163
+ const truncated = first > 0 ? (parts.slice(0, first).join(path.sep) || path.sep) : cwd;
164
+
165
+ const g = inspectGit(truncated);
166
+ const facts = { outermostOpsRoot, innermostOpsRoot };
167
+ if (g.shape === 'worktree') {
168
+ return { kind: 'worktree', projectRoot: g.mainRoot, ...facts, reason: g.reason };
169
+ }
170
+ if (path.isAbsolute(cwd)) {
171
+ const abs = path.resolve(cwd);
172
+ if (isExactlyTmpdir(abs) || isUnderManagedWorktreeRoot(abs)) {
173
+ return { kind: 'ephemeral', projectRoot: truncated, ...facts, reason: 'tmpdir or managed job/epic worktree root' };
174
+ }
175
+ }
176
+ if (g.shape === 'unknown') {
177
+ return { kind: 'unknown', projectRoot: truncated, ...facts, reason: g.reason };
178
+ }
179
+ if (g.shape === 'none') {
180
+ return { kind: 'project', projectRoot: truncated, ...facts, reason: g.reason };
181
+ }
182
+ return { kind: 'project', projectRoot: g.dir, ...facts, reason: g.reason };
183
+ }
184
+
185
+ module.exports = { classifyCwd, nearestGitEntry, worktreeMainRootOf, OPS_DIRNAME };
@@ -91,41 +91,104 @@ function reportExists(key, runsDir = RUNS_DIR) {
91
91
  return false;
92
92
  }
93
93
 
94
- // Shell metacharacters that require shell:true — prohibited by CLAUDE.md for
95
- // non-user-supplied strings. Commands containing these are marked unverifiable
96
- // rather than running under a shell, since CLAUDE.md restricts shell:true to
97
- // watchers.cjs and app:test-fire-hook only.
98
- const SHELL_META_RE = /[|>&;<`]|\$[({]/;
94
+ const DEFAULT_GATE_TIMEOUT_MS = 300_000;
99
95
 
100
96
  /**
101
- * Extract the first bounded AC test command from a PRD body.
102
- *
103
- * Searches the # Acceptance criteria section for lines containing a
104
- * `timeout NNN cmd [args...]` invocation. Returns the command string or null.
105
- *
106
- * Commands containing shell metacharacters (pipe, redirect, subshell) are
107
- * skipped — they cannot be run without shell:true which is prohibited here.
108
- * The parser falls through to the next AC line in that case.
109
- *
110
- * Complexity: O(L) where L = number of lines in the body (not user-scaled;
111
- * PRD bodies are bounded documents, typically < 200 lines).
112
- *
113
- * @param {string} prdBody PRD markdown with frontmatter already stripped.
114
- * @returns {string|null}
97
+ * Quote-aware tokenizer for ONE command segment — no shell. Returns the argv
98
+ * tokens, or null when an unquoted shell metacharacter survives (pipe,
99
+ * redirect, `;`, lone `&`, backtick, `$(`/`${`) or a quote is unbalanced.
100
+ * Complexity: O(n) over the segment's characters.
115
101
  */
116
- function extractAcCommand(prdBody) {
117
- if (!prdBody || typeof prdBody !== 'string') return null;
102
+ function tokenizeNoShell(seg) {
103
+ const tokens = [];
104
+ let cur = '';
105
+ let inTok = false;
106
+ let quote = null;
107
+ for (let i = 0; i < seg.length; i++) {
108
+ const c = seg[i];
109
+ if (quote) {
110
+ if (c === quote) quote = null;
111
+ else cur += c;
112
+ continue;
113
+ }
114
+ if (c === '"' || c === "'") { quote = c; inTok = true; continue; }
115
+ if (/\s/.test(c)) {
116
+ if (inTok) { tokens.push(cur); cur = ''; inTok = false; }
117
+ continue;
118
+ }
119
+ if ('|<>;&`'.includes(c) || (c === '$' && (seg[i + 1] === '(' || seg[i + 1] === '{'))) return null;
120
+ cur += c;
121
+ inTok = true;
122
+ }
123
+ if (quote) return null;
124
+ if (inTok) tokens.push(cur);
125
+ return tokens;
126
+ }
127
+
128
+ /**
129
+ * Split a command chain on `&&` outside quotes and backticks.
130
+ * Complexity: O(n).
131
+ */
132
+ function splitOnAndAnd(text) {
133
+ const segs = [];
134
+ let cur = '';
135
+ let quote = null;
136
+ for (let i = 0; i < text.length; i++) {
137
+ const c = text[i];
138
+ if (quote) {
139
+ cur += c;
140
+ if (c === quote) quote = null;
141
+ continue;
142
+ }
143
+ if (c === '"' || c === "'" || c === '`') { quote = c; cur += c; continue; }
144
+ if (c === '&' && text[i + 1] === '&') { segs.push(cur); cur = ''; i++; continue; }
145
+ cur += c;
146
+ }
147
+ segs.push(cur);
148
+ return segs;
149
+ }
150
+
151
+ /**
152
+ * Parse one `&&` chain into [{argv, timeoutMs, env, raw}]. Returns [] when any
153
+ * segment is empty or needs a shell — a half-parsed gate must never run.
154
+ * A leading `timeout N` (or `Ns`) token sets timeoutMs; leading literal
155
+ * `NAME=value` tokens become env (a `TMPDIR=` assignment is dropped — the
156
+ * shadow runner always supplies its own isolated TMPDIR; its value is
157
+ * typically the un-runnable `$(mktemp -d)`, which is handled here without a
158
+ * shell by simply never evaluating it).
159
+ */
160
+ function parseChain(text) {
161
+ const out = [];
162
+ for (const seg of splitOnAndAnd(text)) {
163
+ const raw = seg.trim();
164
+ if (!raw) return [];
165
+ // A dropped TMPDIR=$(mktemp -d) prefix is the one substitution we accept.
166
+ const stripped = raw.replace(/^TMPDIR=\$\(mktemp -d\)\s+/, '');
167
+ const tokens = tokenizeNoShell(stripped);
168
+ if (!tokens || !tokens.length) return [];
169
+ const env = {};
170
+ while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) {
171
+ const [k, ...rest] = tokens.shift().split('=');
172
+ if (k !== 'TMPDIR') env[k] = rest.join('=');
173
+ }
174
+ let timeoutMs = DEFAULT_GATE_TIMEOUT_MS;
175
+ if (tokens[0] === 'timeout' && /^\d+s?$/.test(tokens[1] || '')) {
176
+ timeoutMs = parseInt(tokens[1], 10) * 1000;
177
+ tokens.splice(0, 2);
178
+ }
179
+ if (!tokens.length) return [];
180
+ out.push({ argv: tokens, timeoutMs, env, raw });
181
+ }
182
+ return out;
183
+ }
118
184
 
119
- // Parse line-by-line to locate the # Acceptance criteria section.
120
- // A regex lookahead approach with the `m` flag misidentifies `$` as
121
- // end-of-line (not end-of-string), causing the lazy capture to terminate
122
- // immediately. Line-by-line parsing avoids that pitfall.
123
- const lines = prdBody.split('\n');
185
+ /** Locate the `# Acceptance criteria` lines (whole body when the heading is absent). */
186
+ function acCandidateLines(body) {
187
+ const lines = body.split('\n');
124
188
  let inAcSection = false;
125
189
  let hasAcSection = false;
126
190
  let acHeadingLevel = 0;
127
191
  const acLines = [];
128
-
129
192
  for (const line of lines) {
130
193
  if (/^#+\s/i.test(line)) {
131
194
  const level = line.match(/^(#+)/)[1].length;
@@ -134,42 +197,127 @@ function extractAcCommand(prdBody) {
134
197
  hasAcSection = true;
135
198
  acHeadingLevel = level;
136
199
  } else if (inAcSection && level <= acHeadingLevel) {
137
- // A sibling or parent heading ends the AC section;
138
- // sub-headings (level > acHeadingLevel) stay inside it.
139
200
  inAcSection = false;
140
201
  }
141
202
  continue;
142
203
  }
143
204
  if (inAcSection) acLines.push(line);
144
205
  }
206
+ return hasAcSection ? acLines : lines;
207
+ }
145
208
 
146
- // Fall back to full body if no AC section header was found.
147
- const candidates = hasAcSection ? acLines : lines;
209
+ /**
210
+ * Read an explicit gate spec: frontmatter `gate:` (`none`, inline `[a, b]`, or
211
+ * a `- item` list) or a fenced ```gate block (one command chain per line).
212
+ * Returns { kind: 'none' } | { kind: 'commands', chains: string[] } | null.
213
+ */
214
+ function readExplicitGate(prdText) {
215
+ const fmMatch = prdText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
216
+ if (fmMatch) {
217
+ const fmLines = fmMatch[1].split(/\r?\n/);
218
+ for (let i = 0; i < fmLines.length; i++) {
219
+ const m = fmLines[i].match(/^gate:\s*(.*)$/);
220
+ if (!m) continue;
221
+ const rest = m[1].trim();
222
+ if (/^(['"]?)none\1$/i.test(rest)) return { kind: 'none' };
223
+ const chains = [];
224
+ if (rest.startsWith('[') && rest.endsWith(']')) {
225
+ // Inline list — items may hold commas inside quotes, so split quote-aware.
226
+ let cur = ''; let q = null;
227
+ for (const c of rest.slice(1, -1)) {
228
+ if (q) { if (c === q) q = null; else cur += c; continue; }
229
+ if (c === '"' || c === "'") { q = c; continue; }
230
+ if (c === ',') { chains.push(cur.trim()); cur = ''; continue; }
231
+ cur += c;
232
+ }
233
+ chains.push(cur.trim());
234
+ } else if (rest === '') {
235
+ for (let j = i + 1; j < fmLines.length; j++) {
236
+ const li = fmLines[j].match(/^\s+-\s+(.*)$/);
237
+ if (!li) break;
238
+ chains.push(li[1].trim().replace(/^(['"])(.*)\1$/, '$2'));
239
+ }
240
+ }
241
+ const kept = chains.filter(Boolean);
242
+ if (kept.length) return { kind: 'commands', chains: kept };
243
+ }
244
+ }
245
+ const fence = prdText.match(/^```gate[ \t]*\r?\n([\s\S]*?)^```/m);
246
+ if (fence) {
247
+ const chains = fence[1].split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
248
+ if (chains.length === 1 && /^none$/i.test(chains[0])) return { kind: 'none' };
249
+ if (chains.length) return { kind: 'commands', chains };
250
+ }
251
+ return null;
252
+ }
148
253
 
149
- for (const line of candidates) {
150
- // Prefer backtick-delimited inline code: `timeout NNN cmd ...`
151
- const backtickMatch = line.match(/`(timeout\s+\d+\s+[^`]+)`/i);
254
+ /**
255
+ * Resolve a PRD's gate: explicit spec first (frontmatter `gate:` / ```gate
256
+ * fence), else the first acceptance-criteria line carrying a parseable
257
+ * `timeout N …` chain (backtick span preferred, else bare from the first
258
+ * `timeout N`). Accepts the PRD with or without frontmatter.
259
+ *
260
+ * @param {string} prdText
261
+ * @returns {{ source: 'none'|'explicit'|'ac-line'|'absent', sequence: Array<{argv:string[], timeoutMs:number, env:object, raw:string}> }}
262
+ */
263
+ function resolveGate(prdText) {
264
+ if (!prdText || typeof prdText !== 'string') return { source: 'absent', sequence: [] };
265
+ const explicit = readExplicitGate(prdText);
266
+ if (explicit) {
267
+ if (explicit.kind === 'none') return { source: 'none', sequence: [] };
268
+ const sequence = [];
269
+ for (const chain of explicit.chains) {
270
+ const parsed = parseChain(chain);
271
+ if (!parsed.length) return { source: 'explicit', sequence: [] };
272
+ sequence.push(...parsed);
273
+ }
274
+ return { source: 'explicit', sequence };
275
+ }
276
+ const body = splitFrontmatter(prdText).body;
277
+ for (const line of acCandidateLines(body)) {
278
+ const backtickMatch = line.match(/`([^`]*\btimeout\s+\d+\s[^`]*)`/i);
152
279
  if (backtickMatch) {
153
- const cmd = backtickMatch[1].trim();
154
- if (!SHELL_META_RE.test(cmd)) return cmd;
280
+ const seq = parseChain(backtickMatch[1]);
281
+ if (seq.length) return { source: 'ac-line', sequence: seq };
155
282
  }
156
-
157
- // Fall back: bare `timeout NNN cmd ...` anywhere on the line.
158
- // Trim trailing all-lowercase-alpha tokens (prose words like "passes",
159
- // "and", "the") while keeping at least 4 tokens (timeout, N, binary,
160
- // first-arg). This prevents over-matching into prose that follows the
161
- // command on the same AC line (e.g. "timeout 60 npm test and verify").
283
+ // Bare fallback: from the first `timeout N` to end of line, dropping
284
+ // trailing all-lowercase prose words (keeping ≥ 4 tokens, as before).
162
285
  const rawMatch = line.match(/\btimeout\s+\d+\s+\S+(?:\s+\S+)*/);
163
286
  if (rawMatch) {
164
- const tokens = rawMatch[0].trim().split(/\s+/);
165
- while (tokens.length > 4 && /^[a-z]+$/.test(tokens[tokens.length - 1])) {
166
- tokens.pop();
167
- }
168
- const cmd = tokens.join(' ');
169
- if (!SHELL_META_RE.test(cmd)) return cmd;
287
+ const segs = splitOnAndAnd(rawMatch[0]);
288
+ const tokens = segs[segs.length - 1].trim().split(/\s+/);
289
+ while (tokens.length > 4 && /^[a-z]+$/.test(tokens[tokens.length - 1])) tokens.pop();
290
+ segs[segs.length - 1] = tokens.join(' ');
291
+ const seq = parseChain(segs.join('&&'));
292
+ if (seq.length) return { source: 'ac-line', sequence: seq };
170
293
  }
171
294
  }
172
- return null;
295
+ return { source: 'absent', sequence: [] };
296
+ }
297
+
298
+ /**
299
+ * The gate as an ordered list of no-shell commands: [{argv, timeoutMs}, …].
300
+ * Empty when there is no parseable gate or the PRD opted out with `gate: none`
301
+ * (resolveGate distinguishes the two). Complexity: O(L) over the PRD's lines.
302
+ *
303
+ * @param {string} prdBody PRD markdown (frontmatter optional).
304
+ * @returns {Array<{argv:string[], timeoutMs:number, env:object, raw:string}>}
305
+ */
306
+ function extractAcSequence(prdBody) {
307
+ return resolveGate(prdBody).sequence;
308
+ }
309
+
310
+ /**
311
+ * The single-command view of the gate, as the original `timeout NNN cmd …`
312
+ * string, or null when the gate is absent or is a multi-command chain
313
+ * (reverifyAc runs one command; the chain is the shadow runner's job).
314
+ *
315
+ * @param {string} prdBody PRD markdown with frontmatter already stripped.
316
+ * @returns {string|null}
317
+ */
318
+ function extractAcCommand(prdBody) {
319
+ const seq = extractAcSequence(prdBody);
320
+ return seq.length === 1 && seq[0].raw.startsWith('timeout') ? seq[0].raw : null;
173
321
  }
174
322
 
175
323
  /**
@@ -217,8 +365,8 @@ function reverifyAc(job, { timeoutMs = 60_000, prdsDir } = {}) {
217
365
  const cmd = extractAcCommand(prdBody);
218
366
  if (!cmd) return Promise.resolve(unverifiable());
219
367
 
220
- // Simple whitespace split — SHELL_META_RE in extractAcCommand already excludes
221
- // commands that would need a shell. Paths with spaces are not expected in AC
368
+ // Simple whitespace split — extractAcCommand already excludes
369
+ // commands that would need a shell (tokenizeNoShell). Paths with spaces are not expected in AC
222
370
  // commands (PRD authoring convention: use relative paths from cwd).
223
371
  const argv = cmd.trim().split(/\s+/);
224
372
  if (argv.length < 2) return Promise.resolve(unverifiable());
@@ -231,7 +379,7 @@ function reverifyAc(job, { timeoutMs = 60_000, prdsDir } = {}) {
231
379
  child = spawn(argv[0], argv.slice(1), {
232
380
  cwd: job.cwd,
233
381
  stdio: 'ignore',
234
- // No shell:true — extractAcCommand rejects commands with shell metacharacters.
382
+ // No shell:true — tokenizeNoShell rejects commands with shell metacharacters.
235
383
  });
236
384
  } catch (err) {
237
385
  resolve(unverifiable());
@@ -532,6 +680,99 @@ function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
532
680
  return reportPath;
533
681
  }
534
682
 
683
+
684
+ // ─── Shadow gate runner ────────────────────────────────────────────────────────
685
+ // Runs a PRD's parsed gate sequence WITHOUT deciding anything: the caller
686
+ // records the outcome next to the verdict. Not a claude -p run, so it takes no
687
+ // slot from sessionSlots; instead a single module-level flag keeps at most one
688
+ // shadow gate in flight machine-wide (the scheduler is one process).
689
+ let gateInFlight = false;
690
+ const OUTPUT_TAIL_CHARS = 1000;
691
+
692
+ function runOneGateCommand(cmd, { cwd, env }) {
693
+ return new Promise((resolve) => {
694
+ const startNs = process.hrtime.bigint();
695
+ const ms = () => Math.round(Number(process.hrtime.bigint() - startNs) / 1e6);
696
+ const label = cmd.argv.join(' ');
697
+ let child;
698
+ try {
699
+ child = spawn(cmd.argv[0], cmd.argv.slice(1), {
700
+ cwd,
701
+ env: { ...env, ...cmd.env },
702
+ stdio: ['ignore', 'pipe', 'pipe'],
703
+ // No shell:true — the sequence was tokenized by tokenizeNoShell.
704
+ });
705
+ } catch {
706
+ resolve({ cmd: label, status: 'unavailable', code: null, ms: ms(), timedOut: false, tail: '' });
707
+ return;
708
+ }
709
+ let tail = '';
710
+ const onData = (d) => { tail = (tail + d).slice(-OUTPUT_TAIL_CHARS); };
711
+ child.stdout.on('data', onData);
712
+ child.stderr.on('data', onData);
713
+ let settled = false;
714
+ let timedOut = false;
715
+ let escalate;
716
+ const killTimer = setTimeout(() => {
717
+ timedOut = true;
718
+ try { child.kill('SIGTERM'); } catch { /* already dead */ }
719
+ escalate = setTimeout(() => {
720
+ try { child.kill('SIGKILL'); } catch { /* race */ }
721
+ }, 5_000);
722
+ if (escalate.unref) escalate.unref();
723
+ }, cmd.timeoutMs);
724
+ if (killTimer.unref) killTimer.unref();
725
+ const finish = (r) => {
726
+ if (settled) return;
727
+ settled = true;
728
+ clearTimeout(killTimer);
729
+ clearTimeout(escalate);
730
+ resolve({ cmd: label, ms: ms(), timedOut, tail, ...r });
731
+ };
732
+ child.on('error', () => finish({ status: 'unavailable', code: null }));
733
+ child.on('close', (code) => {
734
+ const exitCode = typeof code === 'number' ? code : -1;
735
+ finish({ status: exitCode === 0 ? 'pass' : 'fail', code: exitCode });
736
+ });
737
+ });
738
+ }
739
+
740
+ /**
741
+ * Run a gate sequence (extractAcSequence output) in `cwd` with `&&` semantics:
742
+ * stop at the first non-pass. Each run gets its own isolated TMPDIR (mkdtemp,
743
+ * removed afterwards) and CI=1, so a suite that sweeps its temp root can never
744
+ * touch a live one.
745
+ *
746
+ * @param {Array<{argv:string[], timeoutMs:number, env?:object}>} sequence
747
+ * @param {{ cwd: string }} opts
748
+ * @returns {Promise<{ status: 'green'|'red'|'unavailable'|'busy', results: object[] }>}
749
+ * 'busy' = another shadow gate is in flight; nothing ran, nothing to record.
750
+ */
751
+ async function runGateSequence(sequence, { cwd }) {
752
+ if (gateInFlight) return { status: 'busy', results: [] };
753
+ if (!Array.isArray(sequence) || !sequence.length) return { status: 'unavailable', results: [] };
754
+ try { fs.statSync(cwd); } catch { return { status: 'unavailable', results: [] }; }
755
+ gateInFlight = true;
756
+ let tmp = null;
757
+ try {
758
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gate-shadow-'));
759
+ const env = { ...process.env, TMPDIR: tmp, CI: '1' };
760
+ const results = [];
761
+ for (const cmd of sequence) {
762
+ const r = await runOneGateCommand(cmd, { cwd, env });
763
+ results.push(r);
764
+ if (r.status === 'unavailable') return { status: 'unavailable', results };
765
+ if (r.status === 'fail') return { status: 'red', results };
766
+ }
767
+ return { status: 'green', results };
768
+ } catch {
769
+ return { status: 'unavailable', results: [] };
770
+ } finally {
771
+ gateInFlight = false;
772
+ if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } }
773
+ }
774
+ }
775
+
535
776
  const WATERMARK_FILENAME = '.dod-watermark.json';
536
777
 
537
778
  /**
@@ -573,6 +814,9 @@ module.exports = {
573
814
  reportPathFor,
574
815
  reportExists,
575
816
  extractAcCommand,
817
+ extractAcSequence,
818
+ resolveGate,
819
+ runGateSequence,
576
820
  extractSection,
577
821
  reverifyAc,
578
822
  reverifyBatch,
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * dispatchLoop.cjs — dispatch's own periodic driver. Cadence is owned here and
5
+ * nothing else can degrade it: no billing backoff, utilization, fire policy or
6
+ * cancel token is read. It only calls the injected `tick` (scheduler's
7
+ * tickQueue, which funnels through sessionSlots/pickNextBatch).
8
+ */
9
+
10
+ const RECONCILE_INTERVAL_MS = 30_000;
11
+ const MIN_INTERVAL_MS = 10_000;
12
+ const MAX_INTERVAL_MS = 300_000;
13
+
14
+ /** Pure: env override (SM_DISPATCH_LOOP_INTERVAL_MS) or explicit value, clamped to [10s, 300s]. */
15
+ function resolveIntervalMs(intervalMs, env = process.env) {
16
+ const raw = intervalMs ?? env.SM_DISPATCH_LOOP_INTERVAL_MS;
17
+ const n = Number(raw);
18
+ if (raw === undefined || raw === null || raw === '' || !Number.isFinite(n)) return RECONCILE_INTERVAL_MS;
19
+ return Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Math.floor(n)));
20
+ }
21
+
22
+ /**
23
+ * @param {{ tick: () => any, intervalMs?: number, onError?: (e: Error) => void }} opts
24
+ * @returns {{ stop: () => void }}
25
+ */
26
+ function startDispatchLoop({ tick, intervalMs, onError } = {}) {
27
+ if (typeof tick !== 'function') throw new TypeError('startDispatchLoop: tick must be a function');
28
+ if (process.env.SM_DISPATCH_LOOP_DISABLE === '1') return { stop() {} };
29
+ let running = false;
30
+ const report = (e) => { try { if (onError) onError(e); } catch { /* reporting must not throw */ } };
31
+ const timer = setInterval(async () => {
32
+ if (running) return; // previous pass still in flight — no pile-up
33
+ running = true;
34
+ try { await tick(); } catch (e) { report(e); } finally { running = false; }
35
+ }, resolveIntervalMs(intervalMs));
36
+ if (timer.unref) timer.unref();
37
+ return { stop() { clearInterval(timer); } };
38
+ }
39
+
40
+ module.exports = { startDispatchLoop, resolveIntervalMs, RECONCILE_INTERVAL_MS, MIN_INTERVAL_MS, MAX_INTERVAL_MS };
@@ -91,7 +91,14 @@ function computeEffortReachable(deps) {
91
91
  */
92
92
  async function resolvePersonaModelAlias(cwd, agentType, deps) {
93
93
  const getPersonaBody = deps.getPersonaBody || require('../agentLibrary.cjs').getPersonaBody;
94
- const resolvePersonaPaths = deps.resolvePersonaPaths || require('./epicMint.cjs').resolvePersonaPaths;
94
+ // The overlay-vs-global provenance question itself ("which of
95
+ // resolvePersonaPaths' two candidates won") is answered by
96
+ // agentModelResolve.cjs's isProjectOverlayPersonaPath, not re-derived here
97
+ // — that module already owns this precedence decision for the launch
98
+ // path (readOverlayAwarePersonaModel), and this observer must never let
99
+ // its own copy of the comparison drift from the one that actually decides
100
+ // what gets spawned.
101
+ const { isProjectOverlayPersonaPath } = deps.agentModelResolve || require('./agentModelResolve.cjs');
95
102
 
96
103
  let persona;
97
104
  try {
@@ -102,15 +109,7 @@ async function resolvePersonaModelAlias(cwd, agentType, deps) {
102
109
  if (!persona) return { modelAlias: null, modelSource: 'fallback' };
103
110
 
104
111
  const { fm } = splitFrontmatter(persona.text);
105
-
106
- let fromOverlay = false;
107
- try {
108
- const { projectPath } = resolvePersonaPaths(cwd, agentType);
109
- fromOverlay = Boolean(projectPath) && path.resolve(projectPath) === path.resolve(persona.path);
110
- } catch {
111
- // Best-effort overlay signal only — a resolution failure here just means
112
- // "assume global", never a reason to fail the whole lookup.
113
- }
112
+ const fromOverlay = isProjectOverlayPersonaPath(cwd, agentType, persona.path, deps);
114
113
 
115
114
  if (fm.model && fm.model !== 'inherit') {
116
115
  return { modelAlias: fm.model, modelSource: fromOverlay ? 'persona-overlay' : 'persona' };