@isaacriehm/cairn 0.1.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 (562) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/backprop/id.d.ts +14 -0
  5. package/dist/backprop/id.js +40 -0
  6. package/dist/backprop/id.js.map +1 -0
  7. package/dist/backprop/index.d.ts +23 -0
  8. package/dist/backprop/index.js +21 -0
  9. package/dist/backprop/index.js.map +1 -0
  10. package/dist/backprop/prompt.d.ts +16 -0
  11. package/dist/backprop/prompt.js +101 -0
  12. package/dist/backprop/prompt.js.map +1 -0
  13. package/dist/backprop/runner.d.ts +18 -0
  14. package/dist/backprop/runner.js +95 -0
  15. package/dist/backprop/runner.js.map +1 -0
  16. package/dist/backprop/schema.d.ts +61 -0
  17. package/dist/backprop/schema.js +55 -0
  18. package/dist/backprop/schema.js.map +1 -0
  19. package/dist/backprop/types.d.ts +101 -0
  20. package/dist/backprop/types.js +24 -0
  21. package/dist/backprop/types.js.map +1 -0
  22. package/dist/backprop/writer.d.ts +27 -0
  23. package/dist/backprop/writer.js +301 -0
  24. package/dist/backprop/writer.js.map +1 -0
  25. package/dist/claude/error.d.ts +33 -0
  26. package/dist/claude/error.js +58 -0
  27. package/dist/claude/error.js.map +1 -0
  28. package/dist/claude/index.d.ts +3 -0
  29. package/dist/claude/index.js +3 -0
  30. package/dist/claude/index.js.map +1 -0
  31. package/dist/claude/runner.d.ts +11 -0
  32. package/dist/claude/runner.js +132 -0
  33. package/dist/claude/runner.js.map +1 -0
  34. package/dist/claude/types.d.ts +52 -0
  35. package/dist/claude/types.js +14 -0
  36. package/dist/claude/types.js.map +1 -0
  37. package/dist/cli/attention.d.ts +11 -0
  38. package/dist/cli/attention.js +234 -0
  39. package/dist/cli/attention.js.map +1 -0
  40. package/dist/cli/daemon.d.ts +54 -0
  41. package/dist/cli/daemon.js +351 -0
  42. package/dist/cli/daemon.js.map +1 -0
  43. package/dist/cli/doctor.d.ts +8 -0
  44. package/dist/cli/doctor.js +116 -0
  45. package/dist/cli/doctor.js.map +1 -0
  46. package/dist/cli/gc.d.ts +15 -0
  47. package/dist/cli/gc.js +139 -0
  48. package/dist/cli/gc.js.map +1 -0
  49. package/dist/cli/hook.d.ts +18 -0
  50. package/dist/cli/hook.js +57 -0
  51. package/dist/cli/hook.js.map +1 -0
  52. package/dist/cli/index.d.ts +2 -0
  53. package/dist/cli/index.js +127 -0
  54. package/dist/cli/index.js.map +1 -0
  55. package/dist/cli/init.d.ts +1 -0
  56. package/dist/cli/init.js +77 -0
  57. package/dist/cli/init.js.map +1 -0
  58. package/dist/cli/install.d.ts +52 -0
  59. package/dist/cli/install.js +308 -0
  60. package/dist/cli/install.js.map +1 -0
  61. package/dist/cli/join.d.ts +1 -0
  62. package/dist/cli/join.js +84 -0
  63. package/dist/cli/join.js.map +1 -0
  64. package/dist/cli/mcp.d.ts +1 -0
  65. package/dist/cli/mcp.js +62 -0
  66. package/dist/cli/mcp.js.map +1 -0
  67. package/dist/cli/mirror.d.ts +1 -0
  68. package/dist/cli/mirror.js +97 -0
  69. package/dist/cli/mirror.js.map +1 -0
  70. package/dist/cli/run.d.ts +1 -0
  71. package/dist/cli/run.js +174 -0
  72. package/dist/cli/run.js.map +1 -0
  73. package/dist/cli/scope.d.ts +8 -0
  74. package/dist/cli/scope.js +65 -0
  75. package/dist/cli/scope.js.map +1 -0
  76. package/dist/cli/task.d.ts +18 -0
  77. package/dist/cli/task.js +137 -0
  78. package/dist/cli/task.js.map +1 -0
  79. package/dist/cli/watch.d.ts +1 -0
  80. package/dist/cli/watch.js +73 -0
  81. package/dist/cli/watch.js.map +1 -0
  82. package/dist/decision-capture/capture.d.ts +57 -0
  83. package/dist/decision-capture/capture.js +186 -0
  84. package/dist/decision-capture/capture.js.map +1 -0
  85. package/dist/decision-capture/extractor.d.ts +20 -0
  86. package/dist/decision-capture/extractor.js +103 -0
  87. package/dist/decision-capture/extractor.js.map +1 -0
  88. package/dist/decision-capture/id.d.ts +14 -0
  89. package/dist/decision-capture/id.js +44 -0
  90. package/dist/decision-capture/id.js.map +1 -0
  91. package/dist/decision-capture/index.d.ts +25 -0
  92. package/dist/decision-capture/index.js +21 -0
  93. package/dist/decision-capture/index.js.map +1 -0
  94. package/dist/decision-capture/prompt.d.ts +15 -0
  95. package/dist/decision-capture/prompt.js +68 -0
  96. package/dist/decision-capture/prompt.js.map +1 -0
  97. package/dist/decision-capture/refinement-prompt.d.ts +25 -0
  98. package/dist/decision-capture/refinement-prompt.js +146 -0
  99. package/dist/decision-capture/refinement-prompt.js.map +1 -0
  100. package/dist/decision-capture/refinement-schema.d.ts +52 -0
  101. package/dist/decision-capture/refinement-schema.js +61 -0
  102. package/dist/decision-capture/refinement-schema.js.map +1 -0
  103. package/dist/decision-capture/refinement.d.ts +60 -0
  104. package/dist/decision-capture/refinement.js +439 -0
  105. package/dist/decision-capture/refinement.js.map +1 -0
  106. package/dist/decision-capture/schema.d.ts +70 -0
  107. package/dist/decision-capture/schema.js +71 -0
  108. package/dist/decision-capture/schema.js.map +1 -0
  109. package/dist/decision-capture/types.d.ts +201 -0
  110. package/dist/decision-capture/types.js +20 -0
  111. package/dist/decision-capture/types.js.map +1 -0
  112. package/dist/decision-capture/writer.d.ts +90 -0
  113. package/dist/decision-capture/writer.js +267 -0
  114. package/dist/decision-capture/writer.js.map +1 -0
  115. package/dist/frontend/discord/acl.d.ts +6 -0
  116. package/dist/frontend/discord/acl.js +19 -0
  117. package/dist/frontend/discord/acl.js.map +1 -0
  118. package/dist/frontend/discord/channels.d.ts +29 -0
  119. package/dist/frontend/discord/channels.js +58 -0
  120. package/dist/frontend/discord/channels.js.map +1 -0
  121. package/dist/frontend/discord/classifier.d.ts +16 -0
  122. package/dist/frontend/discord/classifier.js +29 -0
  123. package/dist/frontend/discord/classifier.js.map +1 -0
  124. package/dist/frontend/discord/index.d.ts +118 -0
  125. package/dist/frontend/discord/index.js +1104 -0
  126. package/dist/frontend/discord/index.js.map +1 -0
  127. package/dist/frontend/discord/slash.d.ts +18 -0
  128. package/dist/frontend/discord/slash.js +90 -0
  129. package/dist/frontend/discord/slash.js.map +1 -0
  130. package/dist/frontend/inbox.d.ts +17 -0
  131. package/dist/frontend/inbox.js +30 -0
  132. package/dist/frontend/inbox.js.map +1 -0
  133. package/dist/frontend/index.d.ts +8 -0
  134. package/dist/frontend/index.js +6 -0
  135. package/dist/frontend/index.js.map +1 -0
  136. package/dist/frontend/stub/index.d.ts +58 -0
  137. package/dist/frontend/stub/index.js +144 -0
  138. package/dist/frontend/stub/index.js.map +1 -0
  139. package/dist/frontend/types.d.ts +247 -0
  140. package/dist/frontend/types.js +15 -0
  141. package/dist/frontend/types.js.map +1 -0
  142. package/dist/gc/apply.d.ts +26 -0
  143. package/dist/gc/apply.js +48 -0
  144. package/dist/gc/apply.js.map +1 -0
  145. package/dist/gc/canary.d.ts +42 -0
  146. package/dist/gc/canary.js +134 -0
  147. package/dist/gc/canary.js.map +1 -0
  148. package/dist/gc/classify.d.ts +25 -0
  149. package/dist/gc/classify.js +89 -0
  150. package/dist/gc/classify.js.map +1 -0
  151. package/dist/gc/doc-gardening.d.ts +29 -0
  152. package/dist/gc/doc-gardening.js +146 -0
  153. package/dist/gc/doc-gardening.js.map +1 -0
  154. package/dist/gc/frontmatter.d.ts +35 -0
  155. package/dist/gc/frontmatter.js +111 -0
  156. package/dist/gc/frontmatter.js.map +1 -0
  157. package/dist/gc/generator-drift.d.ts +28 -0
  158. package/dist/gc/generator-drift.js +53 -0
  159. package/dist/gc/generator-drift.js.map +1 -0
  160. package/dist/gc/index.d.ts +35 -0
  161. package/dist/gc/index.js +26 -0
  162. package/dist/gc/index.js.map +1 -0
  163. package/dist/gc/quality-update.d.ts +23 -0
  164. package/dist/gc/quality-update.js +69 -0
  165. package/dist/gc/quality-update.js.map +1 -0
  166. package/dist/gc/stub-hits.d.ts +31 -0
  167. package/dist/gc/stub-hits.js +125 -0
  168. package/dist/gc/stub-hits.js.map +1 -0
  169. package/dist/gc/sweep.d.ts +56 -0
  170. package/dist/gc/sweep.js +178 -0
  171. package/dist/gc/sweep.js.map +1 -0
  172. package/dist/gc/types.d.ts +129 -0
  173. package/dist/gc/types.js +26 -0
  174. package/dist/gc/types.js.map +1 -0
  175. package/dist/ground/drift.d.ts +8 -0
  176. package/dist/ground/drift.js +23 -0
  177. package/dist/ground/drift.js.map +1 -0
  178. package/dist/ground/frontmatter.d.ts +20 -0
  179. package/dist/ground/frontmatter.js +49 -0
  180. package/dist/ground/frontmatter.js.map +1 -0
  181. package/dist/ground/glob.d.ts +10 -0
  182. package/dist/ground/glob.js +46 -0
  183. package/dist/ground/glob.js.map +1 -0
  184. package/dist/ground/index.d.ts +14 -0
  185. package/dist/ground/index.js +10 -0
  186. package/dist/ground/index.js.map +1 -0
  187. package/dist/ground/ledgers.d.ts +18 -0
  188. package/dist/ground/ledgers.js +103 -0
  189. package/dist/ground/ledgers.js.map +1 -0
  190. package/dist/ground/manifest.d.ts +10 -0
  191. package/dist/ground/manifest.js +88 -0
  192. package/dist/ground/manifest.js.map +1 -0
  193. package/dist/ground/paths.d.ts +20 -0
  194. package/dist/ground/paths.js +61 -0
  195. package/dist/ground/paths.js.map +1 -0
  196. package/dist/ground/quality-grades.d.ts +11 -0
  197. package/dist/ground/quality-grades.js +98 -0
  198. package/dist/ground/quality-grades.js.map +1 -0
  199. package/dist/ground/schemas.d.ts +306 -0
  200. package/dist/ground/schemas.js +188 -0
  201. package/dist/ground/schemas.js.map +1 -0
  202. package/dist/ground/walk.d.ts +7 -0
  203. package/dist/ground/walk.js +53 -0
  204. package/dist/ground/walk.js.map +1 -0
  205. package/dist/index.d.ts +8 -0
  206. package/dist/index.js +9 -0
  207. package/dist/index.js.map +1 -0
  208. package/dist/init/detect.d.ts +25 -0
  209. package/dist/init/detect.js +336 -0
  210. package/dist/init/detect.js.map +1 -0
  211. package/dist/init/index.d.ts +14 -0
  212. package/dist/init/index.js +9 -0
  213. package/dist/init/index.js.map +1 -0
  214. package/dist/init/init.d.ts +68 -0
  215. package/dist/init/init.js +673 -0
  216. package/dist/init/init.js.map +1 -0
  217. package/dist/init/mapper.d.ts +160 -0
  218. package/dist/init/mapper.js +248 -0
  219. package/dist/init/mapper.js.map +1 -0
  220. package/dist/init/prompts.d.ts +70 -0
  221. package/dist/init/prompts.js +80 -0
  222. package/dist/init/prompts.js.map +1 -0
  223. package/dist/init/secrets.d.ts +18 -0
  224. package/dist/init/secrets.js +76 -0
  225. package/dist/init/secrets.js.map +1 -0
  226. package/dist/init/seed.d.ts +21 -0
  227. package/dist/init/seed.js +75 -0
  228. package/dist/init/seed.js.map +1 -0
  229. package/dist/init/setup-runners.d.ts +17 -0
  230. package/dist/init/setup-runners.js +70 -0
  231. package/dist/init/setup-runners.js.map +1 -0
  232. package/dist/init/types.d.ts +59 -0
  233. package/dist/init/types.js +10 -0
  234. package/dist/init/types.js.map +1 -0
  235. package/dist/init/walker.d.ts +53 -0
  236. package/dist/init/walker.js +460 -0
  237. package/dist/init/walker.js.map +1 -0
  238. package/dist/init/workflow-block.d.ts +34 -0
  239. package/dist/init/workflow-block.js +110 -0
  240. package/dist/init/workflow-block.js.map +1 -0
  241. package/dist/logger.d.ts +3 -0
  242. package/dist/logger.js +23 -0
  243. package/dist/logger.js.map +1 -0
  244. package/dist/mcp/context.d.ts +16 -0
  245. package/dist/mcp/context.js +8 -0
  246. package/dist/mcp/context.js.map +1 -0
  247. package/dist/mcp/errors.d.ts +17 -0
  248. package/dist/mcp/errors.js +23 -0
  249. package/dist/mcp/errors.js.map +1 -0
  250. package/dist/mcp/index.d.ts +10 -0
  251. package/dist/mcp/index.js +7 -0
  252. package/dist/mcp/index.js.map +1 -0
  253. package/dist/mcp/path-allowlist.d.ts +25 -0
  254. package/dist/mcp/path-allowlist.js +66 -0
  255. package/dist/mcp/path-allowlist.js.map +1 -0
  256. package/dist/mcp/result.d.ts +8 -0
  257. package/dist/mcp/result.js +18 -0
  258. package/dist/mcp/result.js.map +1 -0
  259. package/dist/mcp/schemas.d.ts +153 -0
  260. package/dist/mcp/schemas.js +135 -0
  261. package/dist/mcp/schemas.js.map +1 -0
  262. package/dist/mcp/server.d.ts +11 -0
  263. package/dist/mcp/server.js +58 -0
  264. package/dist/mcp/server.js.map +1 -0
  265. package/dist/mcp/telemetry.d.ts +15 -0
  266. package/dist/mcp/telemetry.js +13 -0
  267. package/dist/mcp/telemetry.js.map +1 -0
  268. package/dist/mcp/tools/append.d.ts +8 -0
  269. package/dist/mcp/tools/append.js +33 -0
  270. package/dist/mcp/tools/append.js.map +1 -0
  271. package/dist/mcp/tools/archive.d.ts +8 -0
  272. package/dist/mcp/tools/archive.js +49 -0
  273. package/dist/mcp/tools/archive.js.map +1 -0
  274. package/dist/mcp/tools/ask-operator.d.ts +34 -0
  275. package/dist/mcp/tools/ask-operator.js +93 -0
  276. package/dist/mcp/tools/ask-operator.js.map +1 -0
  277. package/dist/mcp/tools/canonical-for-topic.d.ts +6 -0
  278. package/dist/mcp/tools/canonical-for-topic.js +40 -0
  279. package/dist/mcp/tools/canonical-for-topic.js.map +1 -0
  280. package/dist/mcp/tools/decision-get.d.ts +6 -0
  281. package/dist/mcp/tools/decision-get.js +49 -0
  282. package/dist/mcp/tools/decision-get.js.map +1 -0
  283. package/dist/mcp/tools/decisions-for-symbol.d.ts +7 -0
  284. package/dist/mcp/tools/decisions-for-symbol.js +42 -0
  285. package/dist/mcp/tools/decisions-for-symbol.js.map +1 -0
  286. package/dist/mcp/tools/decisions-in-scope.d.ts +7 -0
  287. package/dist/mcp/tools/decisions-in-scope.js +47 -0
  288. package/dist/mcp/tools/decisions-in-scope.js.map +1 -0
  289. package/dist/mcp/tools/drop-task.d.ts +12 -0
  290. package/dist/mcp/tools/drop-task.js +47 -0
  291. package/dist/mcp/tools/drop-task.js.map +1 -0
  292. package/dist/mcp/tools/get-full.d.ts +7 -0
  293. package/dist/mcp/tools/get-full.js +46 -0
  294. package/dist/mcp/tools/get-full.js.map +1 -0
  295. package/dist/mcp/tools/ground-get.d.ts +7 -0
  296. package/dist/mcp/tools/ground-get.js +80 -0
  297. package/dist/mcp/tools/ground-get.js.map +1 -0
  298. package/dist/mcp/tools/index.d.ts +3 -0
  299. package/dist/mcp/tools/index.js +44 -0
  300. package/dist/mcp/tools/index.js.map +1 -0
  301. package/dist/mcp/tools/invariant-get.d.ts +6 -0
  302. package/dist/mcp/tools/invariant-get.js +49 -0
  303. package/dist/mcp/tools/invariant-get.js.map +1 -0
  304. package/dist/mcp/tools/invariants-in-scope.d.ts +7 -0
  305. package/dist/mcp/tools/invariants-in-scope.js +65 -0
  306. package/dist/mcp/tools/invariants-in-scope.js.map +1 -0
  307. package/dist/mcp/tools/query-history.d.ts +9 -0
  308. package/dist/mcp/tools/query-history.js +33 -0
  309. package/dist/mcp/tools/query-history.js.map +1 -0
  310. package/dist/mcp/tools/record-decision.d.ts +14 -0
  311. package/dist/mcp/tools/record-decision.js +101 -0
  312. package/dist/mcp/tools/record-decision.js.map +1 -0
  313. package/dist/mcp/tools/record-run-event.d.ts +10 -0
  314. package/dist/mcp/tools/record-run-event.js +28 -0
  315. package/dist/mcp/tools/record-run-event.js.map +1 -0
  316. package/dist/mcp/tools/search.d.ts +9 -0
  317. package/dist/mcp/tools/search.js +165 -0
  318. package/dist/mcp/tools/search.js.map +1 -0
  319. package/dist/mcp/tools/supersedes-chain.d.ts +6 -0
  320. package/dist/mcp/tools/supersedes-chain.js +66 -0
  321. package/dist/mcp/tools/supersedes-chain.js.map +1 -0
  322. package/dist/mcp/tools/timeline.d.ts +9 -0
  323. package/dist/mcp/tools/timeline.js +65 -0
  324. package/dist/mcp/tools/timeline.js.map +1 -0
  325. package/dist/mcp/tools/types.d.ts +9 -0
  326. package/dist/mcp/tools/types.js +2 -0
  327. package/dist/mcp/tools/types.js.map +1 -0
  328. package/dist/mirror/clone.d.ts +6 -0
  329. package/dist/mirror/clone.js +48 -0
  330. package/dist/mirror/clone.js.map +1 -0
  331. package/dist/mirror/dirty-overlap.d.ts +13 -0
  332. package/dist/mirror/dirty-overlap.js +77 -0
  333. package/dist/mirror/dirty-overlap.js.map +1 -0
  334. package/dist/mirror/index.d.ts +7 -0
  335. package/dist/mirror/index.js +7 -0
  336. package/dist/mirror/index.js.map +1 -0
  337. package/dist/mirror/paths.d.ts +18 -0
  338. package/dist/mirror/paths.js +45 -0
  339. package/dist/mirror/paths.js.map +1 -0
  340. package/dist/mirror/push.d.ts +9 -0
  341. package/dist/mirror/push.js +27 -0
  342. package/dist/mirror/push.js.map +1 -0
  343. package/dist/mirror/state.d.ts +4 -0
  344. package/dist/mirror/state.js +36 -0
  345. package/dist/mirror/state.js.map +1 -0
  346. package/dist/mirror/sync.d.ts +9 -0
  347. package/dist/mirror/sync.js +33 -0
  348. package/dist/mirror/sync.js.map +1 -0
  349. package/dist/mirror/types.d.ts +77 -0
  350. package/dist/mirror/types.js +2 -0
  351. package/dist/mirror/types.js.map +1 -0
  352. package/dist/orchestrator/activity-summarizer.d.ts +33 -0
  353. package/dist/orchestrator/activity-summarizer.js +120 -0
  354. package/dist/orchestrator/activity-summarizer.js.map +1 -0
  355. package/dist/orchestrator/inbox.d.ts +78 -0
  356. package/dist/orchestrator/inbox.js +115 -0
  357. package/dist/orchestrator/inbox.js.map +1 -0
  358. package/dist/orchestrator/index.d.ts +9 -0
  359. package/dist/orchestrator/index.js +7 -0
  360. package/dist/orchestrator/index.js.map +1 -0
  361. package/dist/orchestrator/orchestrator.d.ts +154 -0
  362. package/dist/orchestrator/orchestrator.js +2437 -0
  363. package/dist/orchestrator/orchestrator.js.map +1 -0
  364. package/dist/orchestrator/prompt.d.ts +19 -0
  365. package/dist/orchestrator/prompt.js +50 -0
  366. package/dist/orchestrator/prompt.js.map +1 -0
  367. package/dist/orchestrator/queue.d.ts +21 -0
  368. package/dist/orchestrator/queue.js +80 -0
  369. package/dist/orchestrator/queue.js.map +1 -0
  370. package/dist/orchestrator/run-log.d.ts +53 -0
  371. package/dist/orchestrator/run-log.js +92 -0
  372. package/dist/orchestrator/run-log.js.map +1 -0
  373. package/dist/orchestrator/runner.d.ts +56 -0
  374. package/dist/orchestrator/runner.js +172 -0
  375. package/dist/orchestrator/runner.js.map +1 -0
  376. package/dist/orchestrator/tool-digest.d.ts +35 -0
  377. package/dist/orchestrator/tool-digest.js +116 -0
  378. package/dist/orchestrator/tool-digest.js.map +1 -0
  379. package/dist/orchestrator/types.d.ts +263 -0
  380. package/dist/orchestrator/types.js +2 -0
  381. package/dist/orchestrator/types.js.map +1 -0
  382. package/dist/orchestrator/workspace.d.ts +21 -0
  383. package/dist/orchestrator/workspace.js +31 -0
  384. package/dist/orchestrator/workspace.js.map +1 -0
  385. package/dist/profiles/index.d.ts +3 -0
  386. package/dist/profiles/index.js +3 -0
  387. package/dist/profiles/index.js.map +1 -0
  388. package/dist/profiles/registry.d.ts +5 -0
  389. package/dist/profiles/registry.js +31 -0
  390. package/dist/profiles/registry.js.map +1 -0
  391. package/dist/profiles/types.d.ts +48 -0
  392. package/dist/profiles/types.js +11 -0
  393. package/dist/profiles/types.js.map +1 -0
  394. package/dist/profiles/unknown.d.ts +9 -0
  395. package/dist/profiles/unknown.js +17 -0
  396. package/dist/profiles/unknown.js.map +1 -0
  397. package/dist/reviewer/index.d.ts +6 -0
  398. package/dist/reviewer/index.js +5 -0
  399. package/dist/reviewer/index.js.map +1 -0
  400. package/dist/reviewer/prompt.d.ts +11 -0
  401. package/dist/reviewer/prompt.js +132 -0
  402. package/dist/reviewer/prompt.js.map +1 -0
  403. package/dist/reviewer/remediation.d.ts +15 -0
  404. package/dist/reviewer/remediation.js +61 -0
  405. package/dist/reviewer/remediation.js.map +1 -0
  406. package/dist/reviewer/reviewer.d.ts +9 -0
  407. package/dist/reviewer/reviewer.js +89 -0
  408. package/dist/reviewer/reviewer.js.map +1 -0
  409. package/dist/reviewer/schema.d.ts +45 -0
  410. package/dist/reviewer/schema.js +43 -0
  411. package/dist/reviewer/schema.js.map +1 -0
  412. package/dist/reviewer/types.d.ts +74 -0
  413. package/dist/reviewer/types.js +14 -0
  414. package/dist/reviewer/types.js.map +1 -0
  415. package/dist/sensors/attestation.d.ts +44 -0
  416. package/dist/sensors/attestation.js +262 -0
  417. package/dist/sensors/attestation.js.map +1 -0
  418. package/dist/sensors/catalog.d.ts +41 -0
  419. package/dist/sensors/catalog.js +123 -0
  420. package/dist/sensors/catalog.js.map +1 -0
  421. package/dist/sensors/decisions.d.ts +30 -0
  422. package/dist/sensors/decisions.js +393 -0
  423. package/dist/sensors/decisions.js.map +1 -0
  424. package/dist/sensors/diff.d.ts +27 -0
  425. package/dist/sensors/diff.js +148 -0
  426. package/dist/sensors/diff.js.map +1 -0
  427. package/dist/sensors/index.d.ts +13 -0
  428. package/dist/sensors/index.js +9 -0
  429. package/dist/sensors/index.js.map +1 -0
  430. package/dist/sensors/remediation.d.ts +20 -0
  431. package/dist/sensors/remediation.js +65 -0
  432. package/dist/sensors/remediation.js.map +1 -0
  433. package/dist/sensors/runner.d.ts +44 -0
  434. package/dist/sensors/runner.js +95 -0
  435. package/dist/sensors/runner.js.map +1 -0
  436. package/dist/sensors/structural.d.ts +30 -0
  437. package/dist/sensors/structural.js +204 -0
  438. package/dist/sensors/structural.js.map +1 -0
  439. package/dist/sensors/stub-catalog.d.ts +39 -0
  440. package/dist/sensors/stub-catalog.js +115 -0
  441. package/dist/sensors/stub-catalog.js.map +1 -0
  442. package/dist/sensors/types.d.ts +135 -0
  443. package/dist/sensors/types.js +14 -0
  444. package/dist/sensors/types.js.map +1 -0
  445. package/dist/tier0/classify.d.ts +5 -0
  446. package/dist/tier0/classify.js +91 -0
  447. package/dist/tier0/classify.js.map +1 -0
  448. package/dist/tier0/index.d.ts +3 -0
  449. package/dist/tier0/index.js +3 -0
  450. package/dist/tier0/index.js.map +1 -0
  451. package/dist/tier0/ollama.d.ts +22 -0
  452. package/dist/tier0/ollama.js +63 -0
  453. package/dist/tier0/ollama.js.map +1 -0
  454. package/dist/tier0/types.d.ts +24 -0
  455. package/dist/tier0/types.js +7 -0
  456. package/dist/tier0/types.js.map +1 -0
  457. package/dist/tightener/index.d.ts +4 -0
  458. package/dist/tightener/index.js +4 -0
  459. package/dist/tightener/index.js.map +1 -0
  460. package/dist/tightener/prompt.d.ts +3 -0
  461. package/dist/tightener/prompt.js +67 -0
  462. package/dist/tightener/prompt.js.map +1 -0
  463. package/dist/tightener/schema.d.ts +68 -0
  464. package/dist/tightener/schema.js +44 -0
  465. package/dist/tightener/schema.js.map +1 -0
  466. package/dist/tightener/tighten.d.ts +2 -0
  467. package/dist/tightener/tighten.js +66 -0
  468. package/dist/tightener/tighten.js.map +1 -0
  469. package/dist/tightener/types.d.ts +74 -0
  470. package/dist/tightener/types.js +6 -0
  471. package/dist/tightener/types.js.map +1 -0
  472. package/dist/uat/bundle.d.ts +68 -0
  473. package/dist/uat/bundle.js +168 -0
  474. package/dist/uat/bundle.js.map +1 -0
  475. package/dist/uat/index.d.ts +15 -0
  476. package/dist/uat/index.js +10 -0
  477. package/dist/uat/index.js.map +1 -0
  478. package/dist/uat/persistent.d.ts +64 -0
  479. package/dist/uat/persistent.js +206 -0
  480. package/dist/uat/persistent.js.map +1 -0
  481. package/dist/uat/probes/cli.d.ts +11 -0
  482. package/dist/uat/probes/cli.js +107 -0
  483. package/dist/uat/probes/cli.js.map +1 -0
  484. package/dist/uat/probes/http.d.ts +12 -0
  485. package/dist/uat/probes/http.js +139 -0
  486. package/dist/uat/probes/http.js.map +1 -0
  487. package/dist/uat/probes/index.d.ts +21 -0
  488. package/dist/uat/probes/index.js +30 -0
  489. package/dist/uat/probes/index.js.map +1 -0
  490. package/dist/uat/probes/integration.d.ts +18 -0
  491. package/dist/uat/probes/integration.js +188 -0
  492. package/dist/uat/probes/integration.js.map +1 -0
  493. package/dist/uat/probes/sql/config.d.ts +14 -0
  494. package/dist/uat/probes/sql/config.js +57 -0
  495. package/dist/uat/probes/sql/config.js.map +1 -0
  496. package/dist/uat/probes/sql/index.d.ts +29 -0
  497. package/dist/uat/probes/sql/index.js +43 -0
  498. package/dist/uat/probes/sql/index.js.map +1 -0
  499. package/dist/uat/probes/sql/mysql.d.ts +12 -0
  500. package/dist/uat/probes/sql/mysql.js +96 -0
  501. package/dist/uat/probes/sql/mysql.js.map +1 -0
  502. package/dist/uat/probes/sql/pg.d.ts +20 -0
  503. package/dist/uat/probes/sql/pg.js +102 -0
  504. package/dist/uat/probes/sql/pg.js.map +1 -0
  505. package/dist/uat/probes/sql/sqlite.d.ts +9 -0
  506. package/dist/uat/probes/sql/sqlite.js +58 -0
  507. package/dist/uat/probes/sql/sqlite.js.map +1 -0
  508. package/dist/uat/probes/sql/types.d.ts +46 -0
  509. package/dist/uat/probes/sql/types.js +10 -0
  510. package/dist/uat/probes/sql/types.js.map +1 -0
  511. package/dist/uat/probes/sql.d.ts +9 -0
  512. package/dist/uat/probes/sql.js +119 -0
  513. package/dist/uat/probes/sql.js.map +1 -0
  514. package/dist/uat/probes/ui.d.ts +19 -0
  515. package/dist/uat/probes/ui.js +244 -0
  516. package/dist/uat/probes/ui.js.map +1 -0
  517. package/dist/uat/prompt.d.ts +10 -0
  518. package/dist/uat/prompt.js +85 -0
  519. package/dist/uat/prompt.js.map +1 -0
  520. package/dist/uat/question.d.ts +50 -0
  521. package/dist/uat/question.js +139 -0
  522. package/dist/uat/question.js.map +1 -0
  523. package/dist/uat/rejection.d.ts +58 -0
  524. package/dist/uat/rejection.js +163 -0
  525. package/dist/uat/rejection.js.map +1 -0
  526. package/dist/uat/runner.d.ts +6 -0
  527. package/dist/uat/runner.js +96 -0
  528. package/dist/uat/runner.js.map +1 -0
  529. package/dist/uat/schema.d.ts +322 -0
  530. package/dist/uat/schema.js +189 -0
  531. package/dist/uat/schema.js.map +1 -0
  532. package/dist/uat/types.d.ts +268 -0
  533. package/dist/uat/types.js +18 -0
  534. package/dist/uat/types.js.map +1 -0
  535. package/dist/uat/uat.d.ts +89 -0
  536. package/dist/uat/uat.js +256 -0
  537. package/dist/uat/uat.js.map +1 -0
  538. package/dist/voice/index.d.ts +4 -0
  539. package/dist/voice/index.js +4 -0
  540. package/dist/voice/index.js.map +1 -0
  541. package/dist/voice/model.d.ts +23 -0
  542. package/dist/voice/model.js +46 -0
  543. package/dist/voice/model.js.map +1 -0
  544. package/dist/voice/pipe.d.ts +9 -0
  545. package/dist/voice/pipe.js +47 -0
  546. package/dist/voice/pipe.js.map +1 -0
  547. package/dist/voice/transcribe.d.ts +3 -0
  548. package/dist/voice/transcribe.js +43 -0
  549. package/dist/voice/transcribe.js.map +1 -0
  550. package/dist/voice/types.d.ts +26 -0
  551. package/dist/voice/types.js +9 -0
  552. package/dist/voice/types.js.map +1 -0
  553. package/dist/watch/daemon.d.ts +21 -0
  554. package/dist/watch/daemon.js +143 -0
  555. package/dist/watch/daemon.js.map +1 -0
  556. package/dist/watch/index.d.ts +4 -0
  557. package/dist/watch/index.js +3 -0
  558. package/dist/watch/index.js.map +1 -0
  559. package/dist/watch/regenerate.d.ts +25 -0
  560. package/dist/watch/regenerate.js +51 -0
  561. package/dist/watch/regenerate.js.map +1 -0
  562. package/package.json +75 -0
@@ -0,0 +1,2437 @@
1
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import chokidar from "chokidar";
5
+ import { dirname, join } from "node:path";
6
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
+ import { logger } from "../logger.js";
8
+ import { matchAnyGlob } from "../ground/glob.js";
9
+ import { requireMirrorRecord } from "../mirror/index.js";
10
+ import { formatReviewerRemediation, runReviewer, } from "../reviewer/index.js";
11
+ import { decisionsInScope, getDiff, loadAcceptedDecisions, loadStubCatalog, runDecisionAssertions, runDtoNoFakeFields, runRouteHandlerNonEmpty, runSensors, runStubCatalog, } from "../sensors/index.js";
12
+ import { asClaudeError, ClaudeError, isQuotaKind, } from "../claude/error.js";
13
+ import { tightenSpec } from "../tightener/index.js";
14
+ import { summarizeActivity } from "./activity-summarizer.js";
15
+ import { appendRunLogEntry, readRunLogTail, } from "./run-log.js";
16
+ import { digestIsEmpty, extractToolDigest } from "./tool-digest.js";
17
+ import { captureUatRejection, formatUatRejectionRemediation, runQuestionAgent, runUat, } from "../uat/index.js";
18
+ import { directionAuthorOf, directionChannelOf, directionTextOf, ensureInboxDirs, isDirectionRow, isSlashRow, isTaskRow, listInboxFiles, moveToProcessed, readInboxRow, } from "./inbox.js";
19
+ import { TaskQueue } from "./queue.js";
20
+ import { loadWorkflowTemplate, renderTemplate } from "./prompt.js";
21
+ import { runImplementer } from "./runner.js";
22
+ const log = logger("orchestrator");
23
+ const RUNS_ACTIVE_REL = ".harness/runs/active";
24
+ /** §3.5 — pause dispatch after this many consecutive rate_limit/overloaded errors. */
25
+ const QUOTA_PAUSE_THRESHOLD = 3;
26
+ /**
27
+ * Phase 8 orchestrator. Single-task FIFO pipeline:
28
+ *
29
+ * inbox row → enqueue → tightening (optional) → workspace prep → run agent
30
+ * → write run meta → surface phase transitions to adapters
31
+ *
32
+ * No commit, no push, no UAT, no reviewer — those are Phases 9-11+.
33
+ */
34
+ export class Orchestrator {
35
+ opts;
36
+ queue;
37
+ seenInboxFiles = new Set();
38
+ watcher;
39
+ questionsWatcher;
40
+ answeredQuestions = new Set();
41
+ pollTimer;
42
+ running = false;
43
+ dispatching = false;
44
+ stopped = false;
45
+ adapterUnsubs = [];
46
+ /**
47
+ * Handle on the in-flight implementer run. Set by `dispatch` before
48
+ * the runImplementer call; cleared in `completeRun` (or via abort).
49
+ * `/halt` reads this to fire the abort controller; `/status` reads it
50
+ * to render running-task line.
51
+ */
52
+ activeRun;
53
+ watchdogTimer;
54
+ /**
55
+ * §3.5 plan-quota — count of consecutive runs that hit a quota error
56
+ * (rate_limit / overloaded). Reset on any successful agent call.
57
+ * After `quotaPauseThreshold` (default 3) the dispatch loop pauses
58
+ * and pages the operator. /unpause clears it.
59
+ */
60
+ consecutiveQuotaErrors = 0;
61
+ dispatchPaused = false;
62
+ dispatchPauseReason = "";
63
+ dispatchPausedAt;
64
+ constructor(opts) {
65
+ this.opts = opts;
66
+ this.queue = new TaskQueue(opts.repoRoot);
67
+ }
68
+ async start() {
69
+ if (this.running)
70
+ return;
71
+ this.running = true;
72
+ await ensureInboxDirs(this.opts.repoRoot);
73
+ await this.queue.load();
74
+ await this.absorbExistingInbox();
75
+ const inboxDir = join(this.opts.repoRoot, ".harness", "inbox");
76
+ this.watcher = chokidar.watch(inboxDir, {
77
+ ignored: (p) => p.includes("/processed/"),
78
+ persistent: true,
79
+ ignoreInitial: true,
80
+ depth: 0,
81
+ });
82
+ this.watcher.on("add", (path) => {
83
+ if (path.endsWith(".json"))
84
+ void this.absorbInboxFile(path);
85
+ });
86
+ // Phase 16.x — agent-initiated operator questions land under
87
+ // .harness/runs/active/<run_id>/questions/<id>.q.json (written by
88
+ // the harness_ask_operator MCP tool). Watch the runs/active root
89
+ // so any new question file fires the dialog flow.
90
+ const runsActiveDir = join(this.opts.repoRoot, ".harness", "runs", "active");
91
+ await mkdir(runsActiveDir, { recursive: true });
92
+ this.questionsWatcher = chokidar.watch(runsActiveDir, {
93
+ persistent: true,
94
+ ignoreInitial: false,
95
+ depth: 3,
96
+ });
97
+ this.questionsWatcher.on("add", (path) => {
98
+ if (path.endsWith(".q.json"))
99
+ void this.absorbQuestionFile(path);
100
+ });
101
+ // Subscribe to adapter task callbacks so live ingests still drive the
102
+ // queue even before chokidar's filesystem event lands. Idempotent — the
103
+ // queue dedupes by run_id.
104
+ for (const adapter of this.opts.adapters) {
105
+ adapter.onTask(() => {
106
+ // Don't push from the callback payload directly; rely on the inbox
107
+ // row that the adapter has just written. The chokidar handler will
108
+ // pick it up. This keeps a single source of truth.
109
+ void this.absorbExistingInbox();
110
+ });
111
+ }
112
+ const intervalMs = this.opts.pollIntervalMs ?? 2000;
113
+ this.pollTimer = setInterval(() => {
114
+ if (this.stopped)
115
+ return;
116
+ void this.absorbExistingInbox();
117
+ void this.tick();
118
+ }, intervalMs);
119
+ // Watchdog: detect dispatches that go silent in a "should be making
120
+ // progress" phase. Operator-pending phases (`blocked`) are excluded
121
+ // — those are intentionally idle until the operator clicks. Fires
122
+ // once per stall (resets on next log event). Cheap (in-memory check).
123
+ this.watchdogTimer = setInterval(() => {
124
+ if (this.stopped)
125
+ return;
126
+ void this.checkRunWatchdog();
127
+ }, 30_000);
128
+ log.info({ project: this.opts.projectName, repoRoot: this.opts.repoRoot }, "orchestrator started");
129
+ }
130
+ async stop() {
131
+ if (!this.running)
132
+ return;
133
+ this.stopped = true;
134
+ if (this.pollTimer) {
135
+ clearInterval(this.pollTimer);
136
+ this.pollTimer = undefined;
137
+ }
138
+ if (this.watchdogTimer) {
139
+ clearInterval(this.watchdogTimer);
140
+ this.watchdogTimer = undefined;
141
+ }
142
+ if (this.watcher) {
143
+ await this.watcher.close();
144
+ this.watcher = undefined;
145
+ }
146
+ if (this.questionsWatcher) {
147
+ await this.questionsWatcher.close();
148
+ this.questionsWatcher = undefined;
149
+ }
150
+ for (const fn of this.adapterUnsubs)
151
+ fn();
152
+ this.adapterUnsubs = [];
153
+ this.running = false;
154
+ await this.queue.persist();
155
+ log.info("orchestrator stopped");
156
+ }
157
+ /** Synchronous queue-size accessor for smoke. */
158
+ queueSize() {
159
+ return this.queue.size();
160
+ }
161
+ // ── private ────────────────────────────────────────────────────────────
162
+ async absorbExistingInbox() {
163
+ const files = await listInboxFiles(this.opts.repoRoot);
164
+ for (const file of files)
165
+ await this.absorbInboxFile(file);
166
+ }
167
+ async absorbInboxFile(file) {
168
+ if (this.seenInboxFiles.has(file))
169
+ return;
170
+ this.seenInboxFiles.add(file);
171
+ let row;
172
+ try {
173
+ row = await readInboxRow(file);
174
+ }
175
+ catch (err) {
176
+ log.warn({ err: String(err), file }, "failed to read inbox row");
177
+ this.seenInboxFiles.delete(file); // allow retry on next tick
178
+ return;
179
+ }
180
+ if (isDirectionRow(row)) {
181
+ // Phase 14 — decision-capture flow. Run inline (independent of the
182
+ // task FIFO) since it's adapter-driven and short-circuits without
183
+ // burning sensors/UAT quota. Best-effort: failures log + drop the
184
+ // row without poisoning the queue.
185
+ void this.handleDirectionRow(row, file);
186
+ return;
187
+ }
188
+ if (isSlashRow(row)) {
189
+ // §3.2 steering surface — /halt /status /queue /eval /resume /oops.
190
+ // Routed inline; never enqueued. Per WORKFLOW_GUIDE §3 trust posture.
191
+ void this.handleSlashRow(row, file);
192
+ return;
193
+ }
194
+ if (!isTaskRow(row)) {
195
+ // Non-task rows (voice, interaction, unrecognized slash) are not
196
+ // dispatched here — leave them for downstream consumers. Keep them
197
+ // out of the inbox dir scan so we don't loop on them.
198
+ return;
199
+ }
200
+ const taskRow = row;
201
+ const taskId = taskRow.task_id ?? `TSK-${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`;
202
+ const runId = `run-${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`;
203
+ const entry = {
204
+ run_id: runId,
205
+ task_id: taskId,
206
+ enqueued_at: new Date().toISOString(),
207
+ row: { ...taskRow, task_id: taskId },
208
+ inbox_file: file,
209
+ };
210
+ if (this.queue.enqueue(entry)) {
211
+ log.info({ task_id: taskId, run_id: runId, file }, "task enqueued");
212
+ await this.queue.persist();
213
+ // Try to dispatch immediately; tick() also covers this on the timer.
214
+ void this.tick();
215
+ }
216
+ }
217
+ async tick() {
218
+ if (this.dispatching || this.stopped)
219
+ return;
220
+ if (this.dispatchPaused)
221
+ return;
222
+ const next = this.queue.peek();
223
+ if (!next)
224
+ return;
225
+ this.dispatching = true;
226
+ try {
227
+ await this.dispatch(next);
228
+ this.queue.dequeue();
229
+ await this.queue.persist();
230
+ }
231
+ catch (err) {
232
+ log.error({ err: String(err), task_id: next.task_id }, "dispatch threw");
233
+ this.queue.dequeue();
234
+ await this.queue.persist();
235
+ try {
236
+ await moveToProcessed(this.opts.repoRoot, next.inbox_file, "failed");
237
+ this.seenInboxFiles.delete(next.inbox_file);
238
+ }
239
+ catch {
240
+ // best effort
241
+ }
242
+ }
243
+ finally {
244
+ this.dispatching = false;
245
+ }
246
+ }
247
+ async dispatch(entry) {
248
+ // Pre-flight: skip the run if the per-task channel is gone (operator
249
+ // deleted it). Otherwise we'd burn LLM quota on tightener / agent /
250
+ // sensors with no operator visibility, and dialogs would silently
251
+ // time-out into failed runs. Cleaner to abandon up front.
252
+ const channelId = entry.row.task.channelId;
253
+ if (channelId !== undefined) {
254
+ const alive = await this.isChannelAlive(channelId);
255
+ if (!alive) {
256
+ log.warn({ task_id: entry.task_id, channelId }, "task abandoned — channel gone (operator deleted it before dispatch)");
257
+ try {
258
+ await moveToProcessed(this.opts.repoRoot, entry.inbox_file, "failed");
259
+ }
260
+ catch (err) {
261
+ log.warn({ err: String(err), file: entry.inbox_file }, "abandon-move failed");
262
+ }
263
+ this.seenInboxFiles.delete(entry.inbox_file);
264
+ return;
265
+ }
266
+ }
267
+ const tier = this.opts.defaultTier ?? "haiku";
268
+ const taskBody = entry.row.task.rawText;
269
+ const taskTitle = entry.row.title ?? taskBody.slice(0, 80);
270
+ const meta = {
271
+ run_id: entry.run_id,
272
+ task_id: entry.task_id,
273
+ agent_role: "implementer",
274
+ phase: "queued",
275
+ started_at: new Date().toISOString(),
276
+ tier,
277
+ model: tier,
278
+ mirror_path: requireMirrorRecord(this.opts.projectName).mirrorPath,
279
+ events_count: 0,
280
+ ...(entry.row.task.channelId !== undefined
281
+ ? { channel_id: entry.row.task.channelId }
282
+ : {}),
283
+ };
284
+ await this.writeMeta(meta);
285
+ await this.logRunEvent(entry, {
286
+ kind: "run_started",
287
+ summary: `${tier} · ${taskTitle.slice(0, 60)}`,
288
+ data: { tier, title: taskTitle },
289
+ });
290
+ // ── Tightener (Phase 7) ────────────────────────────────────────────
291
+ let tightenedSpec;
292
+ if (this.opts.bypassTightener !== true) {
293
+ await this.surfacePhase(entry, meta, "tightening");
294
+ const stopTighteningTyping = this.startTaskTyping(entry);
295
+ try {
296
+ const tightened = await tightenSpec({
297
+ title: taskTitle,
298
+ body: taskBody,
299
+ ...(entry.row.ship_anyway === true ? { ship_anyway: true } : {}),
300
+ });
301
+ stopTighteningTyping();
302
+ meta.tightener_score = tightened.output.spec_quality_score;
303
+ meta.tightener_ready = tightened.ready;
304
+ tightenedSpec = tightened.output.tightened_spec_proposal;
305
+ await this.logRunEvent(entry, {
306
+ kind: "tightener_done",
307
+ summary: `score ${tightened.output.spec_quality_score}/10 · ready=${tightened.ready}`,
308
+ data: {
309
+ score: tightened.output.spec_quality_score,
310
+ ready: tightened.ready,
311
+ ambiguities: tightened.output.ambiguities.length,
312
+ },
313
+ });
314
+ if (!tightened.ready) {
315
+ const walkable = tightened.output.ambiguities.filter((a) => a.candidate_resolutions.length >= 2);
316
+ const blockedNote = walkable.length > 0
317
+ ? `spec quality ${tightened.output.spec_quality_score}/10 — walking ${walkable.length} ambiguit${walkable.length === 1 ? "y" : "ies"}…`
318
+ : `spec quality ${tightened.output.spec_quality_score}/10 — no candidate resolutions to walk.`;
319
+ await this.surfacePhaseWithBody(entry, meta, "blocked", blockedNote);
320
+ const result = await this.requestTightenerDecision({
321
+ entry,
322
+ meta,
323
+ tightened,
324
+ });
325
+ if (result.decision === "approve") {
326
+ tightenedSpec =
327
+ result.resolved_spec ?? tightened.output.tightened_spec_proposal;
328
+ meta.tightener_user_choice = "approve_proposed";
329
+ await this.surfacePhase(entry, meta, "tightening");
330
+ }
331
+ else if (result.decision === "ship_anyway") {
332
+ tightenedSpec = taskBody;
333
+ meta.tightener_user_choice = "ship_anyway";
334
+ await this.surfacePhase(entry, meta, "tightening");
335
+ }
336
+ else {
337
+ meta.tightener_user_choice = result.decision;
338
+ await this.completeRun(entry, meta, "failed", `tightener: operator chose ${result.decision}`);
339
+ return;
340
+ }
341
+ }
342
+ }
343
+ catch (err) {
344
+ stopTighteningTyping();
345
+ await this.completeRun(entry, meta, "failed", `tightener: ${String(err)}`);
346
+ return;
347
+ }
348
+ finally {
349
+ stopTighteningTyping();
350
+ }
351
+ }
352
+ // ── Workspace prep ────────────────────────────────────────────────
353
+ await this.surfacePhase(entry, meta, "prepping");
354
+ const { prepareWorkspace } = await import("./workspace.js");
355
+ let prep;
356
+ try {
357
+ prep = await prepareWorkspace({
358
+ projectName: this.opts.projectName,
359
+ ...(entry.row.target_path_globs !== undefined
360
+ ? { targetGlobs: entry.row.target_path_globs }
361
+ : {}),
362
+ });
363
+ }
364
+ catch (err) {
365
+ await this.completeRun(entry, meta, "failed", `workspace: ${String(err)}`);
366
+ return;
367
+ }
368
+ meta.sha_pin = prep.sha_pin;
369
+ if (prep.dirty_overlap?.overlap === true) {
370
+ // Phase 8 minimum: log + fail. Phase 17 wires the operator dialog
371
+ // (stash / cancel / wait) via adapter.requestDialog.
372
+ await this.completeRun(entry, meta, "failed", `dirty-overlap: ${prep.dirty_overlap.overlappingFiles.join(", ")}`);
373
+ return;
374
+ }
375
+ // ── Agent run + sensor retry loop ─────────────────────────────────
376
+ const eventsLogPath = join(this.opts.repoRoot, RUNS_ACTIVE_REL, entry.run_id, "events.jsonl");
377
+ const basePrompt = await this.renderPrompt({
378
+ runId: entry.run_id,
379
+ mirrorPath: meta.mirror_path,
380
+ shaPin: prep.sha_pin,
381
+ taskBody,
382
+ acceptance: entry.row.acceptance_criteria ?? [],
383
+ });
384
+ const maxAttempts = this.opts.maxAttempts ?? 3;
385
+ let remediationBody = "";
386
+ meta.attempts = 0;
387
+ meta.sensor_history = [];
388
+ meta.reviewer_history = [];
389
+ let lastSweep;
390
+ let lastReviewer;
391
+ meta.uat_history = [];
392
+ let attempt = 1;
393
+ while (attempt <= maxAttempts) {
394
+ meta.attempts = attempt;
395
+ await this.surfacePhase(entry, meta, "running");
396
+ const promptBody = remediationBody.length > 0
397
+ ? `${basePrompt}\n\n${remediationBody}`
398
+ : basePrompt;
399
+ let runResult;
400
+ const stopRunnerTyping = this.startTaskTyping(entry);
401
+ // Tier-0 activity feed — sliding window of recent events that
402
+ // gets summarized every 8s and surfaced into the live status
403
+ // embed's `activity` field. Operator sees what the agent is
404
+ // doing in present tense ("Reading X", "Editing Y") instead of
405
+ // a static "running" badge.
406
+ const activityWindow = [];
407
+ const stopActivityFeed = this.startActivityFeed(entry, meta, activityWindow);
408
+ // Per-run abort controller — `/halt` flips this to interrupt the
409
+ // claude subprocess (SIGTERM via spawn signal; SIGKILL after 30s
410
+ // grace via runner.ts escalation).
411
+ const abortController = new AbortController();
412
+ this.activeRun = {
413
+ entry,
414
+ meta,
415
+ abortController,
416
+ startedAt: Date.now(),
417
+ lastEventAt: Date.now(),
418
+ };
419
+ try {
420
+ runResult = await runImplementer({
421
+ tier,
422
+ prompt: promptBody,
423
+ cwd: meta.mirror_path,
424
+ eventsLogPath,
425
+ addDirs: [meta.mirror_path],
426
+ abortSignal: abortController.signal,
427
+ ...(this.opts.allowedTools !== undefined
428
+ ? { allowedTools: this.opts.allowedTools }
429
+ : {}),
430
+ ...(this.opts.runTimeoutMs !== undefined
431
+ ? { timeoutMs: this.opts.runTimeoutMs }
432
+ : {}),
433
+ onEvent: (e) => {
434
+ meta.events_count += 1;
435
+ if (e["type"] === "assistant" || e["type"] === "result") {
436
+ void this.writeMeta(meta);
437
+ }
438
+ activityWindow.push(e);
439
+ // Keep window bounded so memory doesn't grow on long runs.
440
+ if (activityWindow.length > 60)
441
+ activityWindow.splice(0, activityWindow.length - 60);
442
+ },
443
+ });
444
+ }
445
+ catch (err) {
446
+ stopRunnerTyping();
447
+ stopActivityFeed();
448
+ const reason = abortController.signal.aborted
449
+ ? `halted by operator (/halt)`
450
+ : `agent: ${String(err)}`;
451
+ // §3.5 — classify quota errors so the dispatch loop can pause
452
+ // before draining the operator's coding-plan budget on doomed
453
+ // retries. Non-quota errors don't increment.
454
+ const claudeErr = err instanceof ClaudeError ? err : asClaudeError(err);
455
+ await this.recordQuotaSignal(claudeErr.kind, claudeErr.message);
456
+ await this.completeRun(entry, meta, "failed", reason);
457
+ return;
458
+ }
459
+ finally {
460
+ stopRunnerTyping();
461
+ stopActivityFeed();
462
+ }
463
+ meta.events_count = runResult.events;
464
+ meta.duration_ms = runResult.durationMs;
465
+ // Successful agent dispatch — clear consecutive quota counter.
466
+ this.consecutiveQuotaErrors = 0;
467
+ if (!runResult.ok) {
468
+ await this.completeRun(entry, meta, "failed", "agent reported is_error=true");
469
+ return;
470
+ }
471
+ // ── Sensor sweep ────────────────────────────────────────────────
472
+ if (this.opts.bypassSensors === true) {
473
+ await this.completeRun(entry, meta, "succeeded");
474
+ return;
475
+ }
476
+ await this.surfacePhase(entry, meta, "sensing");
477
+ const finalText = typeof runResult.result["result"] === "string"
478
+ ? runResult.result["result"]
479
+ : "";
480
+ try {
481
+ lastSweep = await runSensors({
482
+ mirrorPath: meta.mirror_path,
483
+ shaPin: prep.sha_pin,
484
+ finalAssistantText: finalText,
485
+ languages: this.opts.sensorLanguages ?? ["typescript"],
486
+ projectGlobs: this.opts.projectGlobs ?? {},
487
+ runId: entry.run_id,
488
+ attempt,
489
+ maxAttempts,
490
+ });
491
+ }
492
+ catch (err) {
493
+ await this.completeRun(entry, meta, "failed", `sensors: ${String(err)}`);
494
+ return;
495
+ }
496
+ await this.persistSensorAttempt(entry.run_id, attempt, lastSweep);
497
+ meta.sensor_history.push({
498
+ attempt,
499
+ ok: lastSweep.ok,
500
+ hard_failures: lastSweep.hard_failures,
501
+ soft_findings: lastSweep.soft_findings,
502
+ sensor_ids_failed: lastSweep.results
503
+ .filter((r) => !r.ok)
504
+ .map((r) => r.sensor_id),
505
+ });
506
+ meta.last_sensor_sweep = {
507
+ ok: lastSweep.ok,
508
+ hard_failures: lastSweep.hard_failures,
509
+ soft_findings: lastSweep.soft_findings,
510
+ };
511
+ await this.writeMeta(meta);
512
+ await this.logRunEvent(entry, {
513
+ kind: "sensor_sweep",
514
+ summary: `attempt ${attempt} · ${lastSweep.ok ? "PASS" : "FAIL"} · ${lastSweep.hard_failures} hard / ${lastSweep.soft_findings} soft`,
515
+ data: {
516
+ attempt,
517
+ ok: lastSweep.ok,
518
+ hard_failures: lastSweep.hard_failures,
519
+ soft_findings: lastSweep.soft_findings,
520
+ },
521
+ });
522
+ if (!lastSweep.ok) {
523
+ // Sensor hard fail. If attempts left, append remediation + loop.
524
+ if (attempt >= maxAttempts) {
525
+ await this.completeRun(entry, meta, "failed", `sensors failed-honesty-check after ${attempt} attempt(s); ${lastSweep.hard_failures} hard failure(s)`);
526
+ return;
527
+ }
528
+ remediationBody = lastSweep.remediation_prompt;
529
+ attempt += 1;
530
+ continue;
531
+ }
532
+ // ── Reviewer subagent (Layer C, Phase 10) ────────────────────────
533
+ if (this.opts.bypassReviewer === true) {
534
+ await this.completeRun(entry, meta, "succeeded");
535
+ return;
536
+ }
537
+ await this.surfacePhase(entry, meta, "reviewing");
538
+ const stopReviewerTyping = this.startTaskTyping(entry);
539
+ try {
540
+ lastReviewer = await this.runReviewerStep({
541
+ mirrorPath: meta.mirror_path,
542
+ shaPin: prep.sha_pin,
543
+ tightenedSpec: tightenedSpec ?? taskBody,
544
+ acceptanceCriteria: entry.row.acceptance_criteria ?? [],
545
+ tier,
546
+ softFindings: lastSweep.results.flatMap((r) => r.findings.filter((f) => f.severity === "soft")),
547
+ highStakesGlobs: this.opts.projectGlobs?.high_stakes_globs ?? [],
548
+ });
549
+ }
550
+ catch (err) {
551
+ stopReviewerTyping();
552
+ await this.completeRun(entry, meta, "failed", `reviewer: ${String(err)}`);
553
+ return;
554
+ }
555
+ finally {
556
+ stopReviewerTyping();
557
+ }
558
+ await this.persistReviewerAttempt(entry.run_id, attempt, lastReviewer);
559
+ const hardGapCount = lastReviewer.output.gaps.filter((g) => g.severity === "hard").length;
560
+ const softGapCount = lastReviewer.output.gaps.filter((g) => g.severity === "soft").length;
561
+ meta.reviewer_history.push({
562
+ attempt,
563
+ ok: lastReviewer.ok,
564
+ verdict: lastReviewer.output.verdict,
565
+ hard_gaps: hardGapCount,
566
+ soft_gaps: softGapCount,
567
+ confidence_signal: lastReviewer.output.confidence_signal,
568
+ });
569
+ meta.last_reviewer = {
570
+ ok: lastReviewer.ok,
571
+ verdict: lastReviewer.output.verdict,
572
+ hard_gaps: hardGapCount,
573
+ soft_gaps: softGapCount,
574
+ confidence_signal: lastReviewer.output.confidence_signal,
575
+ };
576
+ await this.writeMeta(meta);
577
+ await this.logRunEvent(entry, {
578
+ kind: "reviewer_verdict",
579
+ summary: `${lastReviewer.output.verdict} · ${hardGapCount} hard / ${softGapCount} soft · conf=${lastReviewer.output.confidence_signal}`,
580
+ data: {
581
+ verdict: lastReviewer.output.verdict,
582
+ hard_gaps: hardGapCount,
583
+ soft_gaps: softGapCount,
584
+ confidence: lastReviewer.output.confidence_signal,
585
+ },
586
+ });
587
+ if (!lastReviewer.ok) {
588
+ // Reviewer rejected. Retry if attempts left, else fail.
589
+ if (attempt >= maxAttempts) {
590
+ await this.completeRun(entry, meta, "failed", `reviewer failed-honesty-check after ${attempt} attempt(s); ${hardGapCount} hard gap(s); verdict=${lastReviewer.output.verdict}`);
591
+ return;
592
+ }
593
+ remediationBody = formatReviewerRemediation(lastReviewer.output, {
594
+ attempt,
595
+ maxAttempts,
596
+ });
597
+ attempt += 1;
598
+ continue;
599
+ }
600
+ // ── UAT pipeline (Layer U, Phase 11) ───────────────────────────────
601
+ if (this.opts.bypassUat === true) {
602
+ await this.completeRun(entry, meta, "succeeded");
603
+ return;
604
+ }
605
+ await this.surfacePhase(entry, meta, "uat");
606
+ let uatResult;
607
+ const stopUatTyping = this.startTaskTyping(entry);
608
+ try {
609
+ uatResult = await this.runUatStep({
610
+ mirrorPath: meta.mirror_path,
611
+ shaPin: prep.sha_pin,
612
+ tightenedSpec: tightenedSpec ?? taskBody,
613
+ acceptanceCriteria: entry.row.acceptance_criteria ?? [],
614
+ taskId: entry.task_id,
615
+ runId: entry.run_id,
616
+ tier,
617
+ sensorIdsPassed: lastSweep.results.filter((r) => r.ok).map((r) => r.sensor_id),
618
+ highStakesGlobs: this.opts.projectGlobs?.high_stakes_globs ?? [],
619
+ });
620
+ }
621
+ catch (err) {
622
+ stopUatTyping();
623
+ await this.completeRun(entry, meta, "failed", `uat: ${String(err)}`);
624
+ return;
625
+ }
626
+ finally {
627
+ stopUatTyping();
628
+ }
629
+ const probeFailures = uatResult.probe_results.filter((r) => !r.passed && !r.skipped_reason).length;
630
+ meta.uat_history.push({
631
+ attempt,
632
+ ok: uatResult.ok,
633
+ all_passed: uatResult.summary.all_passed,
634
+ probe_failures: probeFailures,
635
+ operator_decision: uatResult.operator_decision ?? "pending",
636
+ });
637
+ meta.last_uat = {
638
+ ok: uatResult.ok,
639
+ all_passed: uatResult.summary.all_passed,
640
+ probe_failures: probeFailures,
641
+ operator_decision: uatResult.operator_decision ?? "pending",
642
+ };
643
+ await this.writeMeta(meta);
644
+ await this.logRunEvent(entry, {
645
+ kind: "uat_decision",
646
+ summary: `${uatResult.operator_decision ?? "pending"} · ${uatResult.summary.all_passed ? "all probes passed" : `${probeFailures} probe failures`}`,
647
+ data: {
648
+ operator_decision: uatResult.operator_decision ?? "pending",
649
+ all_passed: uatResult.summary.all_passed,
650
+ probe_failures: probeFailures,
651
+ },
652
+ });
653
+ if (uatResult.ok) {
654
+ // ── Backprop subagent (Phase 13) ───────────────────────────────
655
+ if (this.opts.bypassBackprop !== true) {
656
+ await this.surfacePhase(entry, meta, "backpropping");
657
+ await this.runBackpropStep({
658
+ mirrorPath: meta.mirror_path,
659
+ shaPin: prep.sha_pin,
660
+ tightenedSpec: tightenedSpec ?? taskBody,
661
+ acceptanceCriteria: entry.row.acceptance_criteria ?? [],
662
+ runId: entry.run_id,
663
+ tier: this.opts.backpropTier ?? tier,
664
+ softFindings: (lastSweep?.results ?? []).flatMap((r) => r.findings.filter((f) => f.severity === "soft").map((f) => f.message)),
665
+ uatRejectionNote: uatResult.rejection?.operator_note,
666
+ taskBody,
667
+ meta,
668
+ });
669
+ await this.writeMeta(meta);
670
+ }
671
+ await this.completeRun(entry, meta, "succeeded");
672
+ return;
673
+ }
674
+ // UAT-rejection-driven retry (Phase 11.x). When operator rejects with
675
+ // a captured A/B/C/D rejection AND attempts remain, write the
676
+ // rejection back to the implementer as remediation context and let
677
+ // the loop re-dispatch. Probe-only failures with operator approval
678
+ // path through `uatResult.ok=true`; probe-only failures without
679
+ // operator action terminal-fail since the operator never weighed in.
680
+ const isOperatorReject = uatResult.operator_decision === "reject" && uatResult.rejection !== undefined;
681
+ if (isOperatorReject && attempt < maxAttempts) {
682
+ remediationBody = formatUatRejectionRemediation({
683
+ rejection: uatResult.rejection,
684
+ summary: uatResult.summary,
685
+ attempt: attempt + 1,
686
+ maxAttempts,
687
+ });
688
+ attempt += 1;
689
+ continue;
690
+ }
691
+ const reason = isOperatorReject
692
+ ? `uat rejected by operator after ${attempt} attempt(s) [${uatResult.rejection.category}]: ${uatResult.rejection.operator_note || "(no note)"}`
693
+ : !uatResult.summary.all_passed
694
+ ? `uat probes failed: ${probeFailures} of ${uatResult.probe_results.length}`
695
+ : `uat decision=${uatResult.operator_decision ?? "pending"}; not approved`;
696
+ await this.completeRun(entry, meta, "failed", reason);
697
+ return;
698
+ }
699
+ }
700
+ async persistSensorAttempt(runId, attempt, sweep) {
701
+ const dir = join(this.opts.repoRoot, RUNS_ACTIVE_REL, runId, "sensors");
702
+ await mkdir(dir, { recursive: true });
703
+ await writeFile(join(dir, `attempt-${attempt}.json`), JSON.stringify(sweep, null, 2), "utf8");
704
+ }
705
+ async persistReviewerAttempt(runId, attempt, result) {
706
+ const dir = join(this.opts.repoRoot, RUNS_ACTIVE_REL, runId, "reviewer");
707
+ await mkdir(dir, { recursive: true });
708
+ await writeFile(join(dir, `attempt-${attempt}.json`), JSON.stringify(result, null, 2), "utf8");
709
+ }
710
+ /**
711
+ * Phase 14 — process a Discord-issued direction row through the
712
+ * decision-capture flow. Runs the Tier-1 extractor → writes a draft →
713
+ * fires the confirm dialog through the first-registered adapter →
714
+ * accepts / edits / rejects + regenerates the ledger.
715
+ *
716
+ * Independent of the task FIFO. Failures are logged and the inbox row
717
+ * is moved to processed/ regardless so we never replay.
718
+ */
719
+ /**
720
+ * Phase 16.x — agent-initiated operator question (`harness_ask_operator`
721
+ * MCP tool). The implementer agent writes a `<id>.q.json` under
722
+ * `.harness/runs/active/<run_id>/questions/`; chokidar fires this
723
+ * handler. We:
724
+ * 1. Find the active run's task channel.
725
+ * 2. Build a pinging dialog spec (alerts the operator on mobile).
726
+ * 3. requestDialog → operator answers (or times out).
727
+ * 4. Write `<id>.a.json` with the answer; the MCP tool's poller
728
+ * picks it up and returns to the agent.
729
+ */
730
+ async absorbQuestionFile(file) {
731
+ if (this.answeredQuestions.has(file))
732
+ return;
733
+ this.answeredQuestions.add(file);
734
+ const { readFile, writeFile: writeFileAsync } = await import("node:fs/promises");
735
+ let payload;
736
+ try {
737
+ const raw = await readFile(file, "utf8");
738
+ payload = JSON.parse(raw);
739
+ }
740
+ catch (err) {
741
+ log.warn({ err: String(err), file }, "failed to parse question file");
742
+ return;
743
+ }
744
+ const adapter = this.opts.adapters[0];
745
+ const aPath = file.replace(/\.q\.json$/, ".a.json");
746
+ if (adapter === undefined) {
747
+ log.warn({ file }, "no adapter — answering question with timed_out");
748
+ try {
749
+ await writeFileAsync(aPath, JSON.stringify({
750
+ answered_at: new Date().toISOString(),
751
+ answer: "",
752
+ timed_out: true,
753
+ }), "utf8");
754
+ }
755
+ catch (err) {
756
+ log.warn({ err: String(err), aPath }, "failed to write answer");
757
+ }
758
+ return;
759
+ }
760
+ // Look up the channelId from active runs/<run_id>/meta.json.
761
+ let channelId;
762
+ try {
763
+ const metaPath = join(this.opts.repoRoot, ".harness", "runs", "active", payload.run_id, "meta.json");
764
+ const raw = await readFile(metaPath, "utf8");
765
+ const meta = JSON.parse(raw);
766
+ if (typeof meta.channel_id === "string")
767
+ channelId = meta.channel_id;
768
+ }
769
+ catch {
770
+ // best-effort
771
+ }
772
+ const options = payload.options ?? [];
773
+ const choices = options.length > 0
774
+ ? options.slice(0, 4).map((label, idx) => ({
775
+ id: String.fromCharCode(0x61 + idx),
776
+ label: String.fromCharCode(0x41 + idx),
777
+ }))
778
+ : [{ id: "ack", label: "👍 noted (free-form reply not yet supported)" }];
779
+ const optionsBlock = options.length > 0
780
+ ? options
781
+ .slice(0, 4)
782
+ .map((label, idx) => `**${String.fromCharCode(0x41 + idx)}.** ${label}`)
783
+ .join("\n\n")
784
+ : "_(agent did not provide options — replying with 👍 records `ack`; future versions support free-form replies)_";
785
+ const categoryBadge = payload.category
786
+ ? `**[${payload.category.toUpperCase()}]** `
787
+ : "";
788
+ const dialogSpec = {
789
+ bundleId: `ask:${payload.run_id}:${payload.id}`,
790
+ prompt: `🛑 **Agent paused — ${payload.run_id}**\n\n${categoryBadge}${payload.question}\n\n${optionsBlock}`,
791
+ choices,
792
+ timeoutMs: payload.timeout_ms ?? 10 * 60_000,
793
+ pingOperators: true,
794
+ };
795
+ if (channelId !== undefined)
796
+ dialogSpec.channelId = channelId;
797
+ let answer = {
798
+ answered_at: new Date().toISOString(),
799
+ answer: "",
800
+ timed_out: true,
801
+ };
802
+ try {
803
+ const response = await adapter.requestDialog(dialogSpec);
804
+ if (response.timedOut) {
805
+ answer = {
806
+ answered_at: new Date().toISOString(),
807
+ answer: "",
808
+ timed_out: true,
809
+ };
810
+ }
811
+ else if (options.length > 0) {
812
+ const idx = response.choiceId.charCodeAt(0) - 0x61;
813
+ const chosen = options[idx];
814
+ answer = {
815
+ answered_at: new Date().toISOString(),
816
+ answer: chosen ?? response.choiceId,
817
+ choice_id: response.choiceId,
818
+ };
819
+ }
820
+ else {
821
+ answer = {
822
+ answered_at: new Date().toISOString(),
823
+ answer: response.freeText ?? "ack",
824
+ choice_id: response.choiceId,
825
+ };
826
+ }
827
+ }
828
+ catch (err) {
829
+ log.warn({ err: String(err), file }, "ask-operator dialog threw");
830
+ }
831
+ try {
832
+ await writeFileAsync(aPath, JSON.stringify(answer, null, 2), "utf8");
833
+ }
834
+ catch (err) {
835
+ log.warn({ err: String(err), aPath }, "failed to write answer");
836
+ }
837
+ }
838
+ async handleDirectionRow(row, file) {
839
+ const adapter = this.opts.adapters[0];
840
+ const rawText = directionTextOf(row);
841
+ const authorId = directionAuthorOf(row);
842
+ const channelId = directionChannelOf(row);
843
+ if (rawText.length === 0 || adapter === undefined) {
844
+ log.warn({ file, has_adapter: adapter !== undefined, raw_text_len: rawText.length }, "direction row dropped — empty text or no adapter");
845
+ await moveToProcessed(this.opts.repoRoot, file, "ignored");
846
+ return;
847
+ }
848
+ const { runDecisionCapture } = await import("../decision-capture/index.js");
849
+ try {
850
+ const result = await runDecisionCapture({
851
+ repoRoot: this.opts.repoRoot,
852
+ rawText,
853
+ authorId,
854
+ source: `${row.source}:${row.kind}`,
855
+ receivedAt: row.received_at,
856
+ adapter,
857
+ ...(channelId !== undefined ? { channelId } : {}),
858
+ tier: this.opts.decisionExtractorTier ?? "haiku",
859
+ ...(this.opts.decisionConfirmTimeoutMs !== undefined
860
+ ? { confirmTimeoutMs: this.opts.decisionConfirmTimeoutMs }
861
+ : {}),
862
+ ...(this.opts.bypassRefinement === true
863
+ ? { bypassRefinement: true }
864
+ : {}),
865
+ ...(this.opts.refinementTier !== undefined
866
+ ? { refinementTier: this.opts.refinementTier }
867
+ : {}),
868
+ ...(this.opts.refinementDialogTimeoutMs !== undefined
869
+ ? { refinementDialogTimeoutMs: this.opts.refinementDialogTimeoutMs }
870
+ : {}),
871
+ });
872
+ log.info({
873
+ source: row.source,
874
+ kind: row.kind,
875
+ short_circuited: result.short_circuited,
876
+ decision: result.confirm?.decision,
877
+ accepted_path: result.confirm?.accepted_path,
878
+ }, "decision-capture complete");
879
+ }
880
+ catch (err) {
881
+ log.error({ err: String(err), file }, "decision-capture threw");
882
+ }
883
+ await moveToProcessed(this.opts.repoRoot, file, "succeeded");
884
+ }
885
+ async runBackpropStep(args) {
886
+ const { runBackprop } = await import("../backprop/index.js");
887
+ const { simpleGit } = await import("simple-git");
888
+ const diff = await getDiff({ mirrorPath: args.mirrorPath, shaPin: args.shaPin });
889
+ const decisions = loadAcceptedDecisions(args.mirrorPath);
890
+ const inScope = decisionsInScope(decisions, diff);
891
+ const decisionIds = inScope.map((d) => d.id);
892
+ const failureSummaryParts = [];
893
+ if (args.uatRejectionNote)
894
+ failureSummaryParts.push(`UAT rejection: ${args.uatRejectionNote}`);
895
+ if (args.softFindings.length > 0) {
896
+ failureSummaryParts.push(`Soft sensor findings on this run: ${args.softFindings.join("; ")}`);
897
+ }
898
+ if (failureSummaryParts.length === 0) {
899
+ failureSummaryParts.push(`No prior failure recorded — task body: ${args.taskBody.slice(0, 500)}`);
900
+ }
901
+ const failure_summary = failureSummaryParts.join("\n\n");
902
+ let result;
903
+ try {
904
+ result = await runBackprop({
905
+ mirrorPath: args.mirrorPath,
906
+ tightened_spec: args.tightenedSpec,
907
+ acceptance_criteria: args.acceptanceCriteria,
908
+ diff,
909
+ failure_summary,
910
+ run_id: args.runId,
911
+ in_scope_decision_ids: decisionIds,
912
+ tier: args.tier,
913
+ });
914
+ }
915
+ catch (err) {
916
+ args.meta.last_backprop = {
917
+ ok: false,
918
+ invariant_id: "",
919
+ invariant_path: "",
920
+ sensor_path: "",
921
+ enforcement_kind: "regex_sensor",
922
+ error: String(err),
923
+ };
924
+ log.warn({ run_id: args.runId, err: String(err) }, "backprop failed");
925
+ return;
926
+ }
927
+ // Persist result for telemetry.
928
+ const dir = join(this.opts.repoRoot, RUNS_ACTIVE_REL, args.runId, "backprop");
929
+ await mkdir(dir, { recursive: true });
930
+ await writeFile(join(dir, "result.json"), JSON.stringify(result, null, 2), "utf8");
931
+ // Commit the invariant + sensor in the mirror.
932
+ let commitSha;
933
+ try {
934
+ const git = simpleGit({ baseDir: args.mirrorPath });
935
+ await git.add([result.invariant_path, result.sensor_path]);
936
+ const subject = `chore(invariants): add §${result.id} from run ${args.runId}`;
937
+ const body = `Backprop subagent extracted invariant ${result.id} (${result.output.title}) from this run.\n\nEnforcement: ${result.output.enforcement.kind}\nInvariant: ${result.invariant_path}\nSensor: ${result.sensor_path}\n`;
938
+ await git.commit(`${subject}\n\n${body}`);
939
+ commitSha = (await git.revparse(["HEAD"])).trim();
940
+ }
941
+ catch (err) {
942
+ log.warn({ run_id: args.runId, err: String(err) }, "backprop commit failed — invariant + sensor written but uncommitted");
943
+ }
944
+ args.meta.last_backprop = {
945
+ ok: true,
946
+ invariant_id: result.id,
947
+ invariant_path: result.invariant_path,
948
+ sensor_path: result.sensor_path,
949
+ enforcement_kind: result.output.enforcement.kind,
950
+ ...(commitSha !== undefined ? { commit_sha: commitSha } : {}),
951
+ };
952
+ }
953
+ async runUatStep(args) {
954
+ const diff = await getDiff({ mirrorPath: args.mirrorPath, shaPin: args.shaPin });
955
+ const isHighStakes = args.highStakesGlobs.length > 0 &&
956
+ diff.some((d) => matchAnyGlob(d.path, args.highStakesGlobs));
957
+ const linesAdded = diff.reduce((n, e) => n + countAddedLines(e.beforeContent, e.afterContent), 0);
958
+ const linesRemoved = diff.reduce((n, e) => n + countAddedLines(e.afterContent, e.beforeContent), 0);
959
+ const approvalGate = async (gateArgs) => {
960
+ const adapter = this.opts.adapters[0];
961
+ if (!adapter) {
962
+ // No adapter — auto-approve when all probes passed; otherwise reject
963
+ // with a synthetic D-category rejection so the retry loop has a
964
+ // stable shape (the smoke env exercises this branch).
965
+ if (gateArgs.summary.all_passed)
966
+ return { decision: "approve" };
967
+ return {
968
+ decision: "reject",
969
+ rejection: {
970
+ category: "D",
971
+ operator_note: "no frontend adapter configured; auto-rejected on probe failure",
972
+ rejected_at: new Date().toISOString(),
973
+ },
974
+ };
975
+ }
976
+ const approval = await adapter.requestApproval({
977
+ bundleId: `uat-${gateArgs.runId}`,
978
+ runId: gateArgs.runId,
979
+ taskId: gateArgs.taskId,
980
+ goal: gateArgs.summary.goal_one_liner,
981
+ diffSummary: `${gateArgs.summary.diff_stats.files_changed} files / +${gateArgs.summary.diff_stats.lines_added} -${gateArgs.summary.diff_stats.lines_removed}`,
982
+ acceptance: gateArgs.summary.acceptance_results.map((r) => ({
983
+ id: r.id,
984
+ status: r.status === "skipped" ? "pending" : r.status,
985
+ ...(r.failure_reason !== undefined ? { note: r.failure_reason } : {}),
986
+ })),
987
+ artifacts: gateArgs.summary.artifacts.map((a) => ({
988
+ kind: a.kind === "screenshot"
989
+ ? "screenshot"
990
+ : a.kind === "video"
991
+ ? "gif"
992
+ : a.kind === "transcript"
993
+ ? "log"
994
+ : a.kind === "log"
995
+ ? "log"
996
+ : "text",
997
+ path: a.path,
998
+ ...(a.caption !== undefined ? { label: a.caption } : {}),
999
+ })),
1000
+ });
1001
+ const decision = approval.decision === "approve"
1002
+ ? "approve"
1003
+ : approval.decision === "reject"
1004
+ ? "reject"
1005
+ : "ask";
1006
+ const out = {
1007
+ decision: approval.timedOut === true ? "abandoned" : decision,
1008
+ };
1009
+ if (decision === "reject") {
1010
+ // Run the post-reject A/B/C/D dialog (with optional voice URL
1011
+ // transcription) to get a structured UatRejection.
1012
+ out.rejection = await captureUatRejection({
1013
+ adapter,
1014
+ runId: gateArgs.runId,
1015
+ taskId: gateArgs.taskId,
1016
+ ...(approval.reason !== undefined ? { initialReason: approval.reason } : {}),
1017
+ ...(this.opts.uatRejectDialogTimeoutMs !== undefined
1018
+ ? { timeoutMs: this.opts.uatRejectDialogTimeoutMs }
1019
+ : {}),
1020
+ });
1021
+ }
1022
+ if (decision === "ask") {
1023
+ // Pass the operator's question text through (Approval.reason
1024
+ // carries it). runUat's loop will call questionHandler + notifier.
1025
+ if (approval.reason !== undefined)
1026
+ out.questionText = approval.reason;
1027
+ }
1028
+ return out;
1029
+ };
1030
+ // Build the question handler for the ❓ Ask loop. Reuses the same tier
1031
+ // as the implementer (cheap; reading-only) — operator can override
1032
+ // explicitly via opts.uatQuestionTier.
1033
+ const questionHandler = (() => {
1034
+ // Always offer a handler — runUat gracefully degrades if absent.
1035
+ return async (qArgs) => {
1036
+ return runQuestionAgent({
1037
+ question: qArgs.question,
1038
+ tightened_spec: args.tightenedSpec,
1039
+ acceptance_criteria: args.acceptanceCriteria,
1040
+ changed_files: diff.map((d) => ({ path: d.path, status: d.status })),
1041
+ summary: qArgs.summary,
1042
+ ...(this.opts.uatQuestionTier !== undefined
1043
+ ? { tier: this.opts.uatQuestionTier }
1044
+ : {}),
1045
+ });
1046
+ };
1047
+ })();
1048
+ const notifier = (() => {
1049
+ const adapter = this.opts.adapters[0];
1050
+ if (!adapter)
1051
+ return undefined;
1052
+ return async (level, message) => {
1053
+ try {
1054
+ await adapter.notify(level, message);
1055
+ }
1056
+ catch (err) {
1057
+ log.warn({ err: String(err) }, "uat notifier failed");
1058
+ }
1059
+ };
1060
+ })();
1061
+ const hints = this.opts.uatHints ?? {};
1062
+ return runUat({
1063
+ repoRoot: args.mirrorPath,
1064
+ runId: args.runId,
1065
+ taskId: args.taskId,
1066
+ runnerInput: {
1067
+ tightened_spec: args.tightenedSpec,
1068
+ acceptance_criteria: args.acceptanceCriteria,
1069
+ changed_files: diff.map((d) => ({ path: d.path, status: d.status })),
1070
+ hints,
1071
+ is_high_stakes: isHighStakes,
1072
+ tier: args.tier,
1073
+ },
1074
+ diffStats: {
1075
+ files_changed: diff.length,
1076
+ lines_added: linesAdded,
1077
+ lines_removed: linesRemoved,
1078
+ },
1079
+ sensorsPassed: args.sensorIdsPassed,
1080
+ reviewerVerdict: this.opts.bypassReviewer === true ? "skipped" : "pass",
1081
+ approvalGate,
1082
+ ...(this.opts.uatColdStartCommand !== undefined
1083
+ ? { coldStartCommand: this.opts.uatColdStartCommand }
1084
+ : {}),
1085
+ ...(questionHandler !== undefined ? { questionHandler } : {}),
1086
+ ...(notifier !== undefined ? { notifier } : {}),
1087
+ ...(this.opts.uatMaxQuestionRounds !== undefined
1088
+ ? { maxQuestionRounds: this.opts.uatMaxQuestionRounds }
1089
+ : {}),
1090
+ });
1091
+ }
1092
+ async runReviewerStep(args) {
1093
+ const diff = await getDiff({
1094
+ mirrorPath: args.mirrorPath,
1095
+ shaPin: args.shaPin,
1096
+ });
1097
+ const accepted = loadAcceptedDecisions(args.mirrorPath);
1098
+ const inScope = decisionsInScope(accepted, diff);
1099
+ const isHighStakes = args.highStakesGlobs.length > 0 &&
1100
+ diff.some((d) => matchAnyGlob(d.path, args.highStakesGlobs));
1101
+ return runReviewer({
1102
+ tightened_spec: args.tightenedSpec,
1103
+ acceptance_criteria: args.acceptanceCriteria,
1104
+ diff,
1105
+ decisions_in_scope: inScope,
1106
+ soft_findings: args.softFindings,
1107
+ is_high_stakes: isHighStakes,
1108
+ tier: args.tier,
1109
+ });
1110
+ }
1111
+ async renderPrompt(args) {
1112
+ let template;
1113
+ try {
1114
+ template = loadWorkflowTemplate(this.opts.repoRoot);
1115
+ }
1116
+ catch {
1117
+ // Repo doesn't ship a workflow.md (e.g. fresh smoke env). Use a
1118
+ // minimal inline default — same shape, fewer placeholders.
1119
+ template = [
1120
+ "## Identity",
1121
+ "You are running inside the harness as agent role `{{agent_role}}` for project `{{project_name}}`. Your run-id is `{{run_id}}`. The mirror checkout is at `{{mirror_path}}` pinned to origin/main SHA `{{sha_pin}}`. Do not modify files outside the mirror. Do not commit; do not push.",
1122
+ "",
1123
+ "## Task",
1124
+ "{{tightened_spec_body}}",
1125
+ "",
1126
+ "## Acceptance criteria",
1127
+ "{{#each acceptance_criteria}}",
1128
+ "- {{this}}",
1129
+ "{{/each}}",
1130
+ ].join("\n");
1131
+ }
1132
+ return renderTemplate(template, {
1133
+ agent_role: "implementer",
1134
+ project_name: this.opts.projectName,
1135
+ run_id: args.runId,
1136
+ mirror_path: args.mirrorPath,
1137
+ sha_pin: args.shaPin,
1138
+ tightened_spec_body: args.taskBody,
1139
+ acceptance_criteria: args.acceptance,
1140
+ in_scope_decisions: [],
1141
+ in_scope_invariants: [],
1142
+ off_limits: [".git", ".env", ".env.local"],
1143
+ scoped_sensors: [],
1144
+ });
1145
+ }
1146
+ async surfacePhase(entry, meta, phase) {
1147
+ return this.surfacePhaseWithBody(entry, meta, phase);
1148
+ }
1149
+ /**
1150
+ * Ask any adapter that supports it whether `channelId` is still
1151
+ * reachable. Adapters that don't expose `isChannelAlive` (CLI / stub)
1152
+ * are treated as "always alive." If multiple adapters answer, ALL
1153
+ * must say true — any single dead vote → false (dispatching to a
1154
+ * dead channel for one adapter would lose surfacing on that adapter).
1155
+ */
1156
+ async isChannelAlive(channelId) {
1157
+ let answered = false;
1158
+ for (const adapter of this.opts.adapters) {
1159
+ if (typeof adapter.isChannelAlive === "function") {
1160
+ try {
1161
+ const alive = await adapter.isChannelAlive(channelId);
1162
+ if (!alive)
1163
+ return false;
1164
+ answered = true;
1165
+ }
1166
+ catch (err) {
1167
+ log.warn({ err: String(err), adapter: adapter.name }, "isChannelAlive threw — treating as dead");
1168
+ return false;
1169
+ }
1170
+ }
1171
+ }
1172
+ // No adapter answered → no channel-aware adapter registered. Treat
1173
+ // as alive so CLI / stub dispatch normally.
1174
+ void answered;
1175
+ return true;
1176
+ }
1177
+ /**
1178
+ * During the implementer phase, summarize recent stream-jsonl events
1179
+ * via Tier-0 every ~8s and patch the live status embed's `activity`
1180
+ * field. Operator sees the agent's tool calls + text in plain
1181
+ * English ("Reading X", "Running tsc") instead of a static
1182
+ * "running" badge.
1183
+ *
1184
+ * Returns a stop fn that clears the interval. Safe to call when
1185
+ * adapters are missing or channelId is undefined.
1186
+ */
1187
+ startActivityFeed(entry, meta, window) {
1188
+ const channelId = entry.row.task.channelId;
1189
+ if (channelId === undefined)
1190
+ return () => { };
1191
+ let stopped = false;
1192
+ let lastSummary = "";
1193
+ const tick = async () => {
1194
+ if (stopped)
1195
+ return;
1196
+ // Skip when the window's barely populated to avoid burning the
1197
+ // first call on system-init noise.
1198
+ if (window.length < 2)
1199
+ return;
1200
+ try {
1201
+ const summary = await summarizeActivity({
1202
+ events: window.slice(),
1203
+ });
1204
+ if (stopped)
1205
+ return;
1206
+ // §3.3 win 2 — second-source tool digest from raw events.
1207
+ // Independent of Ollama; surfaces even when summary fails.
1208
+ const tools = extractToolDigest(window);
1209
+ const recentEvents = await this.readLogTailFormatted(entry.run_id, 5);
1210
+ if (summary === lastSummary && digestIsEmpty(tools))
1211
+ return;
1212
+ lastSummary = summary;
1213
+ const taskBody = entry.row.task.rawText;
1214
+ for (const adapter of this.opts.adapters) {
1215
+ try {
1216
+ await adapter.postTaskUpdate({
1217
+ taskId: entry.task_id,
1218
+ runId: entry.run_id,
1219
+ status: meta.phase,
1220
+ activity: summary,
1221
+ ...(taskBody !== undefined && taskBody.length > 0
1222
+ ? { taskBody }
1223
+ : {}),
1224
+ ...(digestIsEmpty(tools) ? {} : { tools }),
1225
+ ...(recentEvents.length > 0 ? { recentEvents } : {}),
1226
+ ...(channelId !== undefined ? { channelId } : {}),
1227
+ });
1228
+ }
1229
+ catch (err) {
1230
+ log.warn({ err: String(err), adapter: adapter.name }, "activity-feed postTaskUpdate failed");
1231
+ }
1232
+ }
1233
+ }
1234
+ catch (err) {
1235
+ log.warn({ err: String(err) }, "activity-feed tick failed");
1236
+ }
1237
+ };
1238
+ // First summary fires after ~5s so the agent has time to do
1239
+ // something interesting; subsequent ticks every 8s.
1240
+ const initial = setTimeout(() => void tick(), 5_000);
1241
+ const interval = setInterval(() => void tick(), 8_000);
1242
+ return () => {
1243
+ stopped = true;
1244
+ clearTimeout(initial);
1245
+ clearInterval(interval);
1246
+ };
1247
+ }
1248
+ /**
1249
+ * Start a Discord-style "typing" indicator on the task's channel for
1250
+ * any adapter that supports it. Returns a stop fn that clears the
1251
+ * heartbeat. Safe to call when no adapter / no channelId — returns
1252
+ * a no-op stop fn.
1253
+ */
1254
+ startTaskTyping(entry) {
1255
+ const channelId = entry.row.task.channelId;
1256
+ if (channelId === undefined)
1257
+ return () => { };
1258
+ const stops = [];
1259
+ for (const adapter of this.opts.adapters) {
1260
+ if (typeof adapter.startTyping === "function") {
1261
+ try {
1262
+ stops.push(adapter.startTyping(channelId));
1263
+ }
1264
+ catch (err) {
1265
+ log.warn({ err: String(err), adapter: adapter.name }, "startTyping threw — skipping");
1266
+ }
1267
+ }
1268
+ }
1269
+ return () => {
1270
+ for (const s of stops) {
1271
+ try {
1272
+ s();
1273
+ }
1274
+ catch {
1275
+ // best-effort
1276
+ }
1277
+ }
1278
+ };
1279
+ }
1280
+ async surfacePhaseWithBody(entry, meta, phase, body, extras) {
1281
+ meta.phase = phase;
1282
+ await this.writeMeta(meta);
1283
+ // §3.3 — log the transition before reading the tail so the entry
1284
+ // appears in the embed's recent-events strip.
1285
+ await this.logRunEvent(entry, {
1286
+ kind: "phase_changed",
1287
+ summary: `→ ${phase}${body !== undefined && body.length > 0 ? ` (with body ${body.length} chars)` : ""}`,
1288
+ });
1289
+ const channelId = entry.row.task.channelId;
1290
+ const recentEvents = await this.readLogTailFormatted(entry.run_id, 5);
1291
+ const tools = await this.readToolDigestFromEvents(entry.run_id);
1292
+ const taskBody = entry.row.task.rawText;
1293
+ for (const adapter of this.opts.adapters) {
1294
+ try {
1295
+ await adapter.postTaskUpdate({
1296
+ taskId: entry.task_id,
1297
+ runId: entry.run_id,
1298
+ status: phase,
1299
+ ...(taskBody !== undefined && taskBody.length > 0
1300
+ ? { taskBody }
1301
+ : {}),
1302
+ ...(body !== undefined ? { body } : {}),
1303
+ ...(channelId !== undefined ? { channelId } : {}),
1304
+ ...(recentEvents.length > 0 ? { recentEvents } : {}),
1305
+ ...(digestIsEmpty(tools) ? {} : { tools }),
1306
+ ...(extras?.failureClass !== undefined
1307
+ ? { failureClass: extras.failureClass }
1308
+ : {}),
1309
+ ...(extras?.remediation !== undefined
1310
+ ? { remediation: extras.remediation }
1311
+ : {}),
1312
+ });
1313
+ }
1314
+ catch (err) {
1315
+ log.warn({ err: String(err), adapter: adapter.name }, "postTaskUpdate failed");
1316
+ }
1317
+ }
1318
+ }
1319
+ // §3.3 — run-log + tool-digest helpers used by surfacePhase + activityFeed.
1320
+ async logRunEvent(entry, args) {
1321
+ await appendRunLogEntry({
1322
+ repoRoot: this.opts.repoRoot,
1323
+ runId: entry.run_id,
1324
+ taskId: entry.task_id,
1325
+ kind: args.kind,
1326
+ summary: args.summary,
1327
+ ...(args.data !== undefined ? { data: args.data } : {}),
1328
+ });
1329
+ // Self-emitted watchdog events (and the phase_changed they trigger
1330
+ // via surfacePhaseWithBody) do NOT count as progress — otherwise the
1331
+ // watchdog would reset its own idle clock and re-fire forever.
1332
+ if (this.activeRun?.entry.run_id === entry.run_id &&
1333
+ args.kind !== "watchdog_stall") {
1334
+ this.activeRun.lastEventAt = Date.now();
1335
+ }
1336
+ }
1337
+ /**
1338
+ * Watchdog tick — runs every 30s. If the active run has been silent
1339
+ * past `stallSeconds` AND the phase is one that should be producing
1340
+ * events, post a single remediation embed pointing the operator at
1341
+ * `/halt` + `/status`. Operator-pending phases (`blocked`) are
1342
+ * excluded — those wait for the human and are silent on purpose.
1343
+ * Fires once per stall episode; resets on the next log event.
1344
+ */
1345
+ async checkRunWatchdog() {
1346
+ const a = this.activeRun;
1347
+ if (a === undefined)
1348
+ return;
1349
+ const stallSeconds = this.opts.watchdogStallSeconds ?? 90;
1350
+ const now = Date.now();
1351
+ const idleMs = now - a.lastEventAt;
1352
+ if (idleMs < stallSeconds * 1000)
1353
+ return;
1354
+ // Throttle: don't re-post within stallSeconds of the last post.
1355
+ if (a.lastWatchdogPostedAt !== undefined &&
1356
+ now - a.lastWatchdogPostedAt < stallSeconds * 1000) {
1357
+ return;
1358
+ }
1359
+ const phase = a.meta.phase;
1360
+ const watchedPhases = [
1361
+ "tightening",
1362
+ "prepping",
1363
+ "running",
1364
+ "sensing",
1365
+ "reviewing",
1366
+ "uat",
1367
+ "backpropping",
1368
+ ];
1369
+ if (!watchedPhases.includes(phase))
1370
+ return;
1371
+ a.lastWatchdogPostedAt = now;
1372
+ log.warn({ run_id: a.meta.run_id, phase, idle_seconds: Math.floor(idleMs / 1000) }, "watchdog: run silent past threshold");
1373
+ await this.logRunEvent(a.entry, {
1374
+ kind: "watchdog_stall",
1375
+ summary: `silent ${Math.floor(idleMs / 1000)}s in ${phase}`,
1376
+ data: { phase, idle_seconds: Math.floor(idleMs / 1000) },
1377
+ });
1378
+ try {
1379
+ await this.surfacePhaseWithBody(a.entry, a.meta, phase, `Run ${a.meta.run_id} is idle in **${phase}** for ${Math.floor(idleMs / 1000)}s. If you think it's hung, \`/halt\` clears it; \`/status\` shows queue + recent runs.`, {
1380
+ remediation: {
1381
+ reason: `no events in ${Math.floor(idleMs / 1000)}s during ${phase}`,
1382
+ suggestedActions: [
1383
+ `\`/halt ${a.meta.run_id}\` — kill this run`,
1384
+ `\`/status\` — see queue + recent runs`,
1385
+ `wait — agents can pause briefly between tool calls`,
1386
+ ],
1387
+ },
1388
+ });
1389
+ }
1390
+ catch (err) {
1391
+ log.warn({ err: String(err) }, "watchdog surface failed");
1392
+ }
1393
+ }
1394
+ async readLogTailFormatted(runId, n) {
1395
+ const entries = await readRunLogTail({
1396
+ repoRoot: this.opts.repoRoot,
1397
+ runId,
1398
+ n,
1399
+ });
1400
+ return entries.map((e) => formatRunLogLine(e));
1401
+ }
1402
+ async readToolDigestFromEvents(runId) {
1403
+ const path = join(this.opts.repoRoot, RUNS_ACTIVE_REL, runId, "events.jsonl");
1404
+ if (!existsSync(path))
1405
+ return { files: [], bash: [], searches: [] };
1406
+ let text;
1407
+ try {
1408
+ text = await readFile(path, "utf8");
1409
+ }
1410
+ catch {
1411
+ return { files: [], bash: [], searches: [] };
1412
+ }
1413
+ const lines = text.split("\n").filter((s) => s.length > 0);
1414
+ const tail = lines.slice(Math.max(0, lines.length - 250));
1415
+ const events = [];
1416
+ for (const line of tail) {
1417
+ try {
1418
+ const parsed = JSON.parse(line);
1419
+ events.push(parsed);
1420
+ }
1421
+ catch {
1422
+ // skip malformed
1423
+ }
1424
+ }
1425
+ return extractToolDigest(events);
1426
+ }
1427
+ /**
1428
+ * Walk the tightener's ambiguities one-at-a-time, then dispatch with
1429
+ * the resolved spec.
1430
+ *
1431
+ * Per operator pivot: replace the single-blob dialog with per-question
1432
+ * A/B/C walks. Each ambiguity dialog has up to 4 buttons (the LLM's
1433
+ * candidate_resolutions, capped at 4) plus a final approve/ship-anyway/
1434
+ * cancel confirm after the walk.
1435
+ *
1436
+ * Returns:
1437
+ * - { decision: "approve", resolved_spec } → run with this spec
1438
+ * - { decision: "ship_anyway" } → run with original body
1439
+ * - { decision: "edit"|"cancel"|"timeout" } → fail run
1440
+ */
1441
+ async requestTightenerDecision(args) {
1442
+ const adapter = this.opts.adapters[0];
1443
+ if (adapter === undefined) {
1444
+ log.warn({ task_id: args.entry.task_id }, "no adapter registered — cannot dialog; defaulting to cancel");
1445
+ return { decision: "cancel" };
1446
+ }
1447
+ const channelId = args.entry.row.task.channelId;
1448
+ const ambiguities = args.tightened.output.ambiguities;
1449
+ const walkable = ambiguities.filter((a) => a.candidate_resolutions.length >= 2);
1450
+ const resolutions = [];
1451
+ // §3.4 win 1 — per-Q walk threads replaceBundleId so the adapter edits
1452
+ // ONE message in place across all steps + the final confirm. First
1453
+ // step is a fresh send; subsequent steps reuse the same message id.
1454
+ let prevBundleId;
1455
+ // ── Per-ambiguity walk (only when we have ≥2 candidates each). ─────
1456
+ for (let i = 0; i < walkable.length; i++) {
1457
+ const a = walkable[i];
1458
+ // Cap at 4 candidates so the dialog stays readable.
1459
+ const candidates = a.candidate_resolutions.slice(0, 4);
1460
+ // Tightened prompt: bold step indicator, single-line options
1461
+ // anchored by bold letter + middle-dot. No double-newline mush.
1462
+ const optionsBlock = candidates
1463
+ .map((label, idx) => `**${String.fromCharCode(0x41 + idx)}** · ${label}`)
1464
+ .join("\n");
1465
+ const stepBundleId = `${args.entry.task_id}:${a.id}`;
1466
+ const dialogSpec = {
1467
+ bundleId: stepBundleId,
1468
+ prompt: `**${a.id} of ${walkable.length}** — ${a.question}\n\n${optionsBlock}`,
1469
+ choices: candidates.map((_, idx) => ({
1470
+ id: String.fromCharCode(0x61 + idx), // a, b, c, d
1471
+ label: String.fromCharCode(0x41 + idx), // A, B, C, D
1472
+ })),
1473
+ timeoutMs: 5 * 60_000,
1474
+ // Walk steps must NOT compact — the next requestDialog edits
1475
+ // this same message in place via replaceBundleId. Compaction
1476
+ // would race the edit and the answer annotation can drop the
1477
+ // next prompt entirely (operator stuck on Qn answered, no Qn+1).
1478
+ compactOnAnswer: false,
1479
+ };
1480
+ if (channelId !== undefined)
1481
+ dialogSpec.channelId = channelId;
1482
+ if (prevBundleId !== undefined)
1483
+ dialogSpec.replaceBundleId = prevBundleId;
1484
+ try {
1485
+ const response = await adapter.requestDialog(dialogSpec);
1486
+ if (response.timedOut) {
1487
+ await this.logRunEvent(args.entry, {
1488
+ kind: "tightener_q_timeout",
1489
+ summary: `${a.id} timed out`,
1490
+ data: { ambiguity: a.id },
1491
+ });
1492
+ return { decision: "timeout" };
1493
+ }
1494
+ const idx = response.choiceId.charCodeAt(0) - 0x61;
1495
+ const chosenText = candidates[idx];
1496
+ if (chosenText === undefined) {
1497
+ log.warn({
1498
+ task_id: args.entry.task_id,
1499
+ ambiguity: a.id,
1500
+ choice: response.choiceId,
1501
+ }, "unknown ambiguity choice — treating as cancel");
1502
+ return { decision: "cancel" };
1503
+ }
1504
+ resolutions.push({ id: a.id, question: a.question, choice: chosenText });
1505
+ prevBundleId = stepBundleId;
1506
+ await this.logRunEvent(args.entry, {
1507
+ kind: "tightener_q_answered",
1508
+ summary: `${a.id}: ${response.choiceId.toUpperCase()} — ${chosenText.slice(0, 60)}`,
1509
+ data: {
1510
+ ambiguity: a.id,
1511
+ choice_id: response.choiceId,
1512
+ choice_text: chosenText,
1513
+ step: i + 1,
1514
+ total: walkable.length,
1515
+ },
1516
+ });
1517
+ // Refresh the live status with the answered Q so operator sees
1518
+ // walk progress in the recent feed even before the next Q's
1519
+ // edit-in-place lands.
1520
+ await this.surfacePhase(args.entry, args.meta, "blocked");
1521
+ }
1522
+ catch (err) {
1523
+ log.error({ err: String(err), task_id: args.entry.task_id, ambiguity: a.id }, "ambiguity dialog threw — defaulting to cancel");
1524
+ return { decision: "cancel" };
1525
+ }
1526
+ }
1527
+ // ── Final confirm. ────────────────────────────────────────────────
1528
+ const summary = resolutions.length > 0
1529
+ ? `Resolutions captured for ${resolutions.length} ambiguit${resolutions.length === 1 ? "y" : "ies"}. Dispatch?`
1530
+ : `Spec quality ${args.tightened.output.spec_quality_score}/10 below floor ${args.tightened.quality_floor} (no per-question walk available). How to proceed?`;
1531
+ const confirmSpec = {
1532
+ bundleId: `${args.entry.task_id}:confirm`,
1533
+ prompt: summary,
1534
+ choices: [
1535
+ { id: "approve", label: "🟢 dispatch with resolved spec" },
1536
+ { id: "ship_anyway", label: "⚡ /ship-anyway — use original body" },
1537
+ { id: "cancel", label: "🔴 cancel" },
1538
+ ],
1539
+ timeoutMs: 5 * 60_000,
1540
+ };
1541
+ if (channelId !== undefined)
1542
+ confirmSpec.channelId = channelId;
1543
+ if (prevBundleId !== undefined)
1544
+ confirmSpec.replaceBundleId = prevBundleId;
1545
+ try {
1546
+ const response = await adapter.requestDialog(confirmSpec);
1547
+ if (response.timedOut)
1548
+ return { decision: "timeout" };
1549
+ switch (response.choiceId) {
1550
+ case "approve": {
1551
+ const resolvedSpec = renderResolvedSpec({
1552
+ proposal: args.tightened.output.tightened_spec_proposal,
1553
+ resolutions,
1554
+ });
1555
+ return { decision: "approve", resolved_spec: resolvedSpec };
1556
+ }
1557
+ case "ship_anyway":
1558
+ return { decision: "ship_anyway" };
1559
+ case "cancel":
1560
+ return { decision: "cancel" };
1561
+ default:
1562
+ log.warn({ task_id: args.entry.task_id, choice: response.choiceId }, "unknown confirm choice — treating as cancel");
1563
+ return { decision: "cancel" };
1564
+ }
1565
+ }
1566
+ catch (err) {
1567
+ log.error({ err: String(err), task_id: args.entry.task_id }, "confirm dialog threw — defaulting to cancel");
1568
+ return { decision: "cancel" };
1569
+ }
1570
+ }
1571
+ async completeRun(entry, meta, phase, error) {
1572
+ meta.phase = phase;
1573
+ meta.finished_at = new Date().toISOString();
1574
+ if (error !== undefined)
1575
+ meta.error = error;
1576
+ await this.writeMeta(meta);
1577
+ await this.logRunEvent(entry, {
1578
+ kind: "run_completed",
1579
+ summary: error !== undefined
1580
+ ? `${phase} · ${error.slice(0, 80)}`
1581
+ : `${phase} · ${meta.attempts ?? 1} attempt(s)`,
1582
+ data: {
1583
+ phase,
1584
+ attempts: meta.attempts ?? 1,
1585
+ ...(error !== undefined ? { error } : {}),
1586
+ },
1587
+ });
1588
+ if (phase === "failed") {
1589
+ const failureClass = classifyFailure(error);
1590
+ const remediation = buildRemediation(failureClass, error, entry.run_id);
1591
+ await this.surfacePhaseWithBody(entry, meta, phase, undefined, {
1592
+ failureClass,
1593
+ remediation,
1594
+ });
1595
+ }
1596
+ else {
1597
+ await this.surfacePhase(entry, meta, phase);
1598
+ }
1599
+ try {
1600
+ await moveToProcessed(this.opts.repoRoot, entry.inbox_file, phase === "succeeded" ? "succeeded" : "failed");
1601
+ this.seenInboxFiles.delete(entry.inbox_file);
1602
+ }
1603
+ catch (err) {
1604
+ log.warn({ err: String(err), file: entry.inbox_file }, "inbox move failed");
1605
+ }
1606
+ if (this.activeRun?.entry.run_id === entry.run_id) {
1607
+ this.activeRun = undefined;
1608
+ }
1609
+ log.info({ task_id: entry.task_id, run_id: entry.run_id, phase, error }, "run complete");
1610
+ }
1611
+ async writeMeta(meta) {
1612
+ const dir = join(this.opts.repoRoot, RUNS_ACTIVE_REL, meta.run_id);
1613
+ await mkdir(dir, { recursive: true });
1614
+ await writeFile(join(dir, "meta.json"), JSON.stringify(meta, null, 2), "utf8");
1615
+ }
1616
+ // ── Slash command surface (§3.2 — operator steering primitives) ─────
1617
+ adapterForSource(source) {
1618
+ return this.opts.adapters.find((a) => a.name === source);
1619
+ }
1620
+ async handleSlashRow(row, file) {
1621
+ const cmd = row.slash.command;
1622
+ log.info({ cmd, by: row.slash.authorId, channel: row.slash.channelId }, "slash dispatched");
1623
+ try {
1624
+ switch (cmd) {
1625
+ case "halt":
1626
+ await this.handleHalt(row);
1627
+ break;
1628
+ case "status":
1629
+ await this.handleStatus(row);
1630
+ break;
1631
+ case "queue":
1632
+ await this.handleQueue(row);
1633
+ break;
1634
+ case "eval":
1635
+ await this.handleEval(row);
1636
+ break;
1637
+ case "resume":
1638
+ await this.handleResume(row);
1639
+ break;
1640
+ case "oops":
1641
+ await this.handleOops(row);
1642
+ break;
1643
+ case "help":
1644
+ await this.handleHelp(row);
1645
+ break;
1646
+ case "archive":
1647
+ await this.handleArchive(row);
1648
+ break;
1649
+ case "unpause":
1650
+ await this.handleUnpause(row);
1651
+ break;
1652
+ default: {
1653
+ log.info({ cmd }, "slash command not handled by orchestrator");
1654
+ const a = this.adapterForSource(row.source);
1655
+ if (a)
1656
+ await a.notify("info", `/${cmd} — not handled by orchestrator`);
1657
+ }
1658
+ }
1659
+ }
1660
+ catch (err) {
1661
+ log.warn({ err: String(err), cmd }, "slash handler threw");
1662
+ const a = this.adapterForSource(row.source);
1663
+ if (a)
1664
+ await a.notify("error", `/${cmd} failed: ${String(err)}`);
1665
+ }
1666
+ finally {
1667
+ try {
1668
+ await moveToProcessed(this.opts.repoRoot, file, "succeeded");
1669
+ this.seenInboxFiles.delete(file);
1670
+ }
1671
+ catch (err) {
1672
+ log.warn({ err: String(err), file }, "slash inbox move failed");
1673
+ }
1674
+ }
1675
+ }
1676
+ async handleHalt(row) {
1677
+ const adapter = this.adapterForSource(row.source);
1678
+ const requestedRunId = typeof row.slash.options["run-id"] === "string"
1679
+ ? row.slash.options["run-id"]
1680
+ : "";
1681
+ const active = this.activeRun;
1682
+ if (active === undefined) {
1683
+ await adapter?.notify("info", "/halt — no active run");
1684
+ return;
1685
+ }
1686
+ if (requestedRunId.length > 0 && requestedRunId !== active.meta.run_id) {
1687
+ await adapter?.notify("warn", `/halt — run-id mismatch (requested ${requestedRunId}, active ${active.meta.run_id})`);
1688
+ return;
1689
+ }
1690
+ log.warn({ run_id: active.meta.run_id, by: row.slash.authorId }, "/halt requested");
1691
+ await this.logRunEvent(active.entry, {
1692
+ kind: "halt_requested",
1693
+ summary: `by ${row.slash.authorId} during ${active.meta.phase}`,
1694
+ data: { by: row.slash.authorId, during: active.meta.phase },
1695
+ });
1696
+ active.abortController.abort("halted");
1697
+ await adapter?.notify("warn", `🟥 /halt — SIGTERM sent to ${active.meta.run_id} (phase: ${active.meta.phase}); SIGKILL after 30s grace`);
1698
+ }
1699
+ async handleStatus(row) {
1700
+ const adapter = this.adapterForSource(row.source);
1701
+ if (adapter === undefined)
1702
+ return;
1703
+ await adapter.notify("info", this.collectStatus());
1704
+ }
1705
+ collectStatus() {
1706
+ const lines = [];
1707
+ lines.push("📊 Harness status");
1708
+ if (this.dispatchPaused) {
1709
+ lines.push(` ⛔ DISPATCH PAUSED — ${this.dispatchPauseReason} (paused at ${this.dispatchPausedAt ?? "?"}; \`/unpause\` to clear)`);
1710
+ }
1711
+ if (this.activeRun !== undefined) {
1712
+ const a = this.activeRun;
1713
+ const ageS = ((Date.now() - a.startedAt) / 1000).toFixed(0);
1714
+ lines.push(` ▶ active run: ${a.meta.run_id} (task ${a.entry.task_id})`);
1715
+ lines.push(` phase: ${a.meta.phase} · attempt: ${a.meta.attempts ?? 1} · ${ageS}s in flight`);
1716
+ }
1717
+ else {
1718
+ lines.push(" ▶ active run: none");
1719
+ }
1720
+ lines.push(` ▦ queue depth: ${this.queue.size()}`);
1721
+ if (this.consecutiveQuotaErrors > 0 && !this.dispatchPaused) {
1722
+ lines.push(` ⚠ consecutive quota errors: ${this.consecutiveQuotaErrors}/${QUOTA_PAUSE_THRESHOLD}`);
1723
+ }
1724
+ const peeked = this.queue.peek();
1725
+ if (peeked !== undefined) {
1726
+ lines.push(` next: ${peeked.task_id} (enqueued ${peeked.enqueued_at})`);
1727
+ }
1728
+ const recent = this.collectRecentRuns(5);
1729
+ if (recent.length > 0) {
1730
+ lines.push(` ◷ recent runs (${recent.length}):`);
1731
+ for (const r of recent) {
1732
+ const dur = r.duration_ms !== undefined
1733
+ ? `${(r.duration_ms / 1000).toFixed(1)}s`
1734
+ : "—";
1735
+ lines.push(` - ${r.run_id} · ${r.phase} · ${dur}`);
1736
+ }
1737
+ }
1738
+ const gcAge = this.gcAgeMs();
1739
+ if (gcAge !== null) {
1740
+ const days = (gcAge / (1000 * 60 * 60 * 24)).toFixed(1);
1741
+ lines.push(` 🗑 GC last ran: ${days}d ago`);
1742
+ }
1743
+ return lines.join("\n");
1744
+ }
1745
+ collectRecentRuns(limit) {
1746
+ const runsActiveDir = join(this.opts.repoRoot, RUNS_ACTIVE_REL);
1747
+ if (!existsSync(runsActiveDir))
1748
+ return [];
1749
+ let entries;
1750
+ try {
1751
+ entries = readdirSync(runsActiveDir);
1752
+ }
1753
+ catch {
1754
+ return [];
1755
+ }
1756
+ const collected = [];
1757
+ for (const id of entries) {
1758
+ const metaPath = join(runsActiveDir, id, "meta.json");
1759
+ if (!existsSync(metaPath))
1760
+ continue;
1761
+ try {
1762
+ const text = readFileSync(metaPath, "utf8");
1763
+ const meta = JSON.parse(text);
1764
+ const stat = statSync(metaPath);
1765
+ const item = {
1766
+ run_id: typeof meta["run_id"] === "string"
1767
+ ? meta["run_id"]
1768
+ : id,
1769
+ phase: typeof meta["phase"] === "string"
1770
+ ? meta["phase"]
1771
+ : "unknown",
1772
+ mtime: stat.mtimeMs,
1773
+ };
1774
+ if (typeof meta["duration_ms"] === "number") {
1775
+ item.duration_ms = meta["duration_ms"];
1776
+ }
1777
+ collected.push(item);
1778
+ }
1779
+ catch {
1780
+ // skip malformed
1781
+ }
1782
+ }
1783
+ return collected
1784
+ .sort((a, b) => b.mtime - a.mtime)
1785
+ .slice(0, limit)
1786
+ .map(({ mtime: _mtime, ...rest }) => rest);
1787
+ }
1788
+ gcAgeMs() {
1789
+ const manifestPath = join(this.opts.repoRoot, ".harness", "ground", "manifest.yaml");
1790
+ if (!existsSync(manifestPath))
1791
+ return null;
1792
+ try {
1793
+ const stat = statSync(manifestPath);
1794
+ return Date.now() - stat.mtimeMs;
1795
+ }
1796
+ catch {
1797
+ return null;
1798
+ }
1799
+ }
1800
+ async handleQueue(row) {
1801
+ const adapter = this.adapterForSource(row.source);
1802
+ if (adapter === undefined)
1803
+ return;
1804
+ const entries = this.queue.list();
1805
+ if (entries.length === 0) {
1806
+ await adapter.notify("info", "▦ queue: empty");
1807
+ return;
1808
+ }
1809
+ const lines = [`▦ queue (${entries.length}):`];
1810
+ for (const e of entries) {
1811
+ const channel = e.row.task.channelId
1812
+ ? ` · <#${e.row.task.channelId}>`
1813
+ : "";
1814
+ lines.push(` - ${e.task_id} (run ${e.run_id})${channel} · enqueued ${e.enqueued_at}`);
1815
+ }
1816
+ await adapter.notify("info", lines.join("\n"));
1817
+ }
1818
+ async handleEval(row) {
1819
+ const adapter = this.adapterForSource(row.source);
1820
+ const scope = typeof row.slash.options["scope"] === "string"
1821
+ ? row.slash.options["scope"]
1822
+ : "";
1823
+ let mirrorPath;
1824
+ try {
1825
+ mirrorPath = requireMirrorRecord(this.opts.projectName).mirrorPath;
1826
+ }
1827
+ catch (err) {
1828
+ await adapter?.notify("error", `/eval — mirror not registered for ${this.opts.projectName}: ${String(err)}`);
1829
+ return;
1830
+ }
1831
+ let baseSha;
1832
+ try {
1833
+ const { simpleGit } = await import("simple-git");
1834
+ const git = simpleGit({ baseDir: mirrorPath });
1835
+ baseSha = (await git.revparse(["origin/main"])).trim();
1836
+ }
1837
+ catch (err) {
1838
+ await adapter?.notify("error", `/eval — could not resolve origin/main on mirror: ${String(err)}`);
1839
+ return;
1840
+ }
1841
+ let diff;
1842
+ try {
1843
+ diff = await getDiff({ mirrorPath, shaPin: baseSha });
1844
+ }
1845
+ catch (err) {
1846
+ await adapter?.notify("error", `/eval — diff failed: ${String(err)}`);
1847
+ return;
1848
+ }
1849
+ const projectGlobs = this.opts.projectGlobs ?? {
1850
+ route_handler_globs: [],
1851
+ dto_globs: [],
1852
+ generator_source_globs: [],
1853
+ high_stakes_globs: [],
1854
+ };
1855
+ let stubCatalog;
1856
+ try {
1857
+ stubCatalog = loadStubCatalog(mirrorPath);
1858
+ }
1859
+ catch (err) {
1860
+ await adapter?.notify("error", `/eval — stub catalog load failed: ${String(err)}`);
1861
+ return;
1862
+ }
1863
+ const accepted = loadAcceptedDecisions(mirrorPath);
1864
+ const inScope = decisionsInScope(accepted, diff);
1865
+ const results = [];
1866
+ results.push(runStubCatalog({
1867
+ diff,
1868
+ catalog: stubCatalog,
1869
+ languages: this.opts.sensorLanguages ?? ["typescript"],
1870
+ }));
1871
+ results.push(runRouteHandlerNonEmpty({
1872
+ diff,
1873
+ globs: projectGlobs.route_handler_globs,
1874
+ }));
1875
+ results.push(runDtoNoFakeFields({ diff, globs: projectGlobs.dto_globs }));
1876
+ results.push(runDecisionAssertions({ mirrorPath, diff, decisions: inScope }));
1877
+ const hard = results.filter((r) => !r.ok).length;
1878
+ const soft = results.reduce((n, r) => n + r.findings.filter((f) => f.severity === "soft").length, 0);
1879
+ const lines = [];
1880
+ lines.push(`🧪 /eval${scope.length > 0 ? ` (scope: ${scope})` : ""}`);
1881
+ lines.push(` diff files: ${diff.length} · base: ${baseSha.slice(0, 8)}`);
1882
+ lines.push(` hard failures: ${hard} · soft findings: ${soft}`);
1883
+ for (const r of results) {
1884
+ const tag = r.ok ? "✓" : "✗";
1885
+ const findingsCount = r.findings.length;
1886
+ lines.push(` ${tag} ${r.sensor_id}${findingsCount > 0 ? ` — ${findingsCount} finding(s)` : ""}`);
1887
+ }
1888
+ await adapter?.notify(hard === 0 ? "info" : "warn", lines.join("\n"));
1889
+ }
1890
+ async handleResume(row) {
1891
+ const adapter = this.adapterForSource(row.source);
1892
+ const runId = typeof row.slash.options["run-id"] === "string"
1893
+ ? row.slash.options["run-id"]
1894
+ : "";
1895
+ if (runId.length === 0) {
1896
+ await adapter?.notify("warn", "/resume — run-id required");
1897
+ return;
1898
+ }
1899
+ const metaPath = join(this.opts.repoRoot, RUNS_ACTIVE_REL, runId, "meta.json");
1900
+ if (!existsSync(metaPath)) {
1901
+ await adapter?.notify("warn", `/resume — no meta.json at .harness/runs/active/${runId}/`);
1902
+ return;
1903
+ }
1904
+ let meta;
1905
+ try {
1906
+ meta = JSON.parse(await readFile(metaPath, "utf8"));
1907
+ }
1908
+ catch (err) {
1909
+ await adapter?.notify("error", `/resume — meta.json unreadable: ${String(err)}`);
1910
+ return;
1911
+ }
1912
+ const decision = meta.last_uat?.operator_decision;
1913
+ if (decision !== undefined && decision !== "pending") {
1914
+ await adapter?.notify("info", `/resume — run ${runId} already decided: ${decision}`);
1915
+ return;
1916
+ }
1917
+ const summaryPath = join(this.opts.repoRoot, RUNS_ACTIVE_REL, runId, "uat", "summary.yaml");
1918
+ if (!existsSync(summaryPath)) {
1919
+ await adapter?.notify("warn", `/resume — no UAT bundle at .harness/runs/active/${runId}/uat/`);
1920
+ return;
1921
+ }
1922
+ let summary;
1923
+ try {
1924
+ summary = (parseYaml(await readFile(summaryPath, "utf8")) ?? {});
1925
+ }
1926
+ catch (err) {
1927
+ await adapter?.notify("error", `/resume — summary.yaml unreadable: ${String(err)}`);
1928
+ return;
1929
+ }
1930
+ const goal = typeof summary["goal"] === "string"
1931
+ ? summary["goal"]
1932
+ : `Run ${runId}`;
1933
+ const acceptanceRaw = Array.isArray(summary["acceptance"])
1934
+ ? summary["acceptance"]
1935
+ : [];
1936
+ const acceptance = acceptanceRaw.map((a) => {
1937
+ const status = a["status"] === "pass" || a["status"] === "fail"
1938
+ ? a["status"]
1939
+ : "pending";
1940
+ const entry = {
1941
+ id: typeof a["id"] === "string" ? a["id"] : "ac-?",
1942
+ status,
1943
+ };
1944
+ if (typeof a["note"] === "string")
1945
+ entry.note = a["note"];
1946
+ return entry;
1947
+ });
1948
+ const bundle = {
1949
+ bundleId: `resume:${runId}:${Date.now().toString(36)}`,
1950
+ runId,
1951
+ ...(meta.task_id ? { taskId: meta.task_id } : {}),
1952
+ goal,
1953
+ acceptance,
1954
+ ...(meta.channel_id ? { channelId: meta.channel_id } : {}),
1955
+ };
1956
+ if (adapter === undefined)
1957
+ return;
1958
+ await adapter.notify("info", `🔁 /resume — re-firing UAT approval dialog for ${runId}`);
1959
+ const approval = await adapter.requestApproval(bundle);
1960
+ log.info({ runId, decision: approval.decision }, "/resume — approval response");
1961
+ if (meta.last_uat !== undefined) {
1962
+ meta.last_uat = {
1963
+ ...meta.last_uat,
1964
+ operator_decision: approval.decision,
1965
+ };
1966
+ await this.writeMeta(meta);
1967
+ }
1968
+ }
1969
+ async handleOops(row) {
1970
+ const adapter = this.adapterForSource(row.source);
1971
+ if (adapter === undefined)
1972
+ return;
1973
+ const bundleBase = `oops:${Date.now().toString(36)}:${randomBytes(2).toString("hex")}`;
1974
+ const root = await adapter.requestDialog({
1975
+ bundleId: `${bundleBase}:root`,
1976
+ prompt: "Looking back at last 24h. What happened?",
1977
+ choices: [
1978
+ { id: "a", label: "A) Recent run produced wrong code" },
1979
+ { id: "b", label: "B) Doc became stale / contradicts current code" },
1980
+ { id: "c", label: "C) Decision was missed / ignored by an agent" },
1981
+ { id: "d", label: "D) Sensor false-positive / false-negative" },
1982
+ { id: "e_other", label: "E) Other (describe)" },
1983
+ ],
1984
+ ...(row.slash.channelId ? { channelId: row.slash.channelId } : {}),
1985
+ });
1986
+ if (root.timedOut === true) {
1987
+ await adapter.notify("info", "/oops — dialog timed out");
1988
+ return;
1989
+ }
1990
+ if (root.choiceId === "a") {
1991
+ const detail = await adapter.requestDialog({
1992
+ bundleId: `${bundleBase}:a-detail`,
1993
+ prompt: "What's wrong with the run?",
1994
+ choices: [
1995
+ {
1996
+ id: "wrong-direction",
1997
+ label: "A1) Wrong direction — revert + redo",
1998
+ },
1999
+ { id: "missed-edge", label: "A2) Right idea, missed edge case" },
2000
+ {
2001
+ id: "introduced-stub",
2002
+ label: "A3) Introduced a stub I want caught next time",
2003
+ },
2004
+ {
2005
+ id: "decision-conflict",
2006
+ label: "A4) Conflicts with a decision I haven't recorded yet",
2007
+ },
2008
+ { id: "e_other", label: "E) Other" },
2009
+ ],
2010
+ ...(row.slash.channelId ? { channelId: row.slash.channelId } : {}),
2011
+ });
2012
+ if (detail.choiceId === "introduced-stub") {
2013
+ await this.captureStubPatternFromOops({
2014
+ adapter,
2015
+ bundleBase,
2016
+ ...(row.slash.channelId ? { channelId: row.slash.channelId } : {}),
2017
+ });
2018
+ return;
2019
+ }
2020
+ await this.captureOopsLog({
2021
+ branch: `a:${detail.choiceId}`,
2022
+ ...(detail.freeText !== undefined ? { freeText: detail.freeText } : {}),
2023
+ row,
2024
+ });
2025
+ await adapter.notify("info", `/oops — recorded under .harness/staleness/oops.jsonl (branch a:${detail.choiceId})`);
2026
+ return;
2027
+ }
2028
+ await this.captureOopsLog({
2029
+ branch: root.choiceId,
2030
+ ...(root.freeText !== undefined ? { freeText: root.freeText } : {}),
2031
+ row,
2032
+ });
2033
+ await adapter.notify("info", `/oops — recorded under .harness/staleness/oops.jsonl (branch ${root.choiceId})`);
2034
+ }
2035
+ async captureStubPatternFromOops(args) {
2036
+ const patternResp = await args.adapter.requestDialog({
2037
+ bundleId: `${args.bundleBase}:stub-pattern`,
2038
+ prompt: "Paste the regex pattern (ECMAScript, multiline-mode). Choose `E) type pattern` then enter it as free text.",
2039
+ choices: [{ id: "e_other", label: "E) type pattern" }],
2040
+ ...(args.channelId ? { channelId: args.channelId } : {}),
2041
+ });
2042
+ const pattern = (patternResp.freeText ?? "").trim();
2043
+ if (pattern.length === 0) {
2044
+ await args.adapter.notify("warn", "/oops — empty pattern; aborted");
2045
+ return;
2046
+ }
2047
+ try {
2048
+ new RegExp(pattern, "m");
2049
+ }
2050
+ catch (err) {
2051
+ await args.adapter.notify("error", `/oops — pattern invalid: ${String(err)}`);
2052
+ return;
2053
+ }
2054
+ const sevResp = await args.adapter.requestDialog({
2055
+ bundleId: `${args.bundleBase}:stub-severity`,
2056
+ prompt: "Severity?",
2057
+ choices: [
2058
+ { id: "hard", label: "🟥 hard — match fails the run" },
2059
+ {
2060
+ id: "soft",
2061
+ label: "🟨 soft — match contributes to attestation cross-check",
2062
+ },
2063
+ ],
2064
+ ...(args.channelId ? { channelId: args.channelId } : {}),
2065
+ });
2066
+ const severity = sevResp.choiceId === "hard" ? "hard" : "soft";
2067
+ const id = `oops-${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`;
2068
+ await this.appendStubPattern({ id, pattern, severity });
2069
+ await args.adapter.notify("info", `/oops — added pattern \`${id}\` (${severity}) to .harness/config/stub-patterns.yaml`);
2070
+ }
2071
+ async appendStubPattern(args) {
2072
+ const path = join(this.opts.repoRoot, ".harness", "config", "stub-patterns.yaml");
2073
+ let doc = {
2074
+ version: 1,
2075
+ patterns: [],
2076
+ };
2077
+ if (existsSync(path)) {
2078
+ try {
2079
+ doc = (parseYaml(await readFile(path, "utf8")) ?? {
2080
+ version: 1,
2081
+ patterns: [],
2082
+ });
2083
+ if (!Array.isArray(doc.patterns))
2084
+ doc.patterns = [];
2085
+ }
2086
+ catch (err) {
2087
+ log.warn({ err: String(err) }, "stub-patterns.yaml parse failed; rewriting");
2088
+ doc = { version: 1, patterns: [] };
2089
+ }
2090
+ }
2091
+ doc.patterns.push({
2092
+ id: args.id,
2093
+ languages: ["typescript", "javascript"],
2094
+ description: "Added via /oops dialog",
2095
+ regex: args.pattern,
2096
+ severity: args.severity,
2097
+ });
2098
+ await mkdir(join(this.opts.repoRoot, ".harness", "config"), {
2099
+ recursive: true,
2100
+ });
2101
+ await writeFile(path, stringifyYaml(doc), "utf8");
2102
+ }
2103
+ async captureOopsLog(args) {
2104
+ const dir = join(this.opts.repoRoot, ".harness", "staleness");
2105
+ await mkdir(dir, { recursive: true });
2106
+ const line = JSON.stringify({
2107
+ ts: new Date().toISOString(),
2108
+ branch: args.branch,
2109
+ free_text: args.freeText ?? "",
2110
+ author: args.row.slash.authorId,
2111
+ channel: args.row.slash.channelId,
2112
+ source: args.row.source,
2113
+ });
2114
+ await writeFile(join(dir, "oops.jsonl"), `${line}\n`, {
2115
+ flag: "a",
2116
+ encoding: "utf8",
2117
+ });
2118
+ }
2119
+ async handleHelp(row) {
2120
+ const adapter = this.adapterForSource(row.source);
2121
+ if (adapter === undefined)
2122
+ return;
2123
+ const lines = [
2124
+ "Harness slash commands:",
2125
+ " /task <body> submit a task",
2126
+ " /direction <text> capture a binding decision change",
2127
+ " /halt [run-id] kill the active run (SIGTERM → 30s grace → SIGKILL)",
2128
+ " /status queue depth, active run, recent runs, GC age, quota state",
2129
+ " /queue FIFO queue + per-task channel links",
2130
+ " /eval [scope] on-demand sensor sweep (no implementer dispatch)",
2131
+ " /resume <run-id> re-attach an AFK-timed-out UAT approval dialog",
2132
+ " /oops multi-step dialog: stub pattern / doc staleness / sensor false-pos",
2133
+ " /archive <path> move a stale doc/file to .archive/<date>/<path> + commit",
2134
+ " /unpause clear a quota-triggered dispatch pause",
2135
+ " /ship-anyway override the spec-tightener gate (logged)",
2136
+ ];
2137
+ await adapter.notify("info", lines.join("\n"));
2138
+ }
2139
+ // §3.5 plan-quota — record + maybe pause dispatch.
2140
+ async recordQuotaSignal(kind, message) {
2141
+ const isQuota = isQuotaKind(kind);
2142
+ if (!isQuota) {
2143
+ // Non-quota error doesn't reset the counter (we don't know if it
2144
+ // was a transient API blip vs an unrelated agent failure). The
2145
+ // counter only resets on a successful agent run. But we DO log
2146
+ // the event for retrospective analysis.
2147
+ await this.appendQuotaJsonl({ kind, message, paused: false });
2148
+ return;
2149
+ }
2150
+ this.consecutiveQuotaErrors += 1;
2151
+ log.warn({ kind, consecutive: this.consecutiveQuotaErrors, message }, "quota error recorded");
2152
+ await this.appendQuotaJsonl({
2153
+ kind,
2154
+ message,
2155
+ consecutive: this.consecutiveQuotaErrors,
2156
+ paused: false,
2157
+ });
2158
+ if (this.consecutiveQuotaErrors >= QUOTA_PAUSE_THRESHOLD) {
2159
+ this.dispatchPaused = true;
2160
+ this.dispatchPauseReason = `${this.consecutiveQuotaErrors} consecutive ${kind} errors — coding-plan quota likely exhausted`;
2161
+ this.dispatchPausedAt = new Date().toISOString();
2162
+ await this.appendQuotaJsonl({
2163
+ kind,
2164
+ message,
2165
+ consecutive: this.consecutiveQuotaErrors,
2166
+ paused: true,
2167
+ });
2168
+ log.error({ reason: this.dispatchPauseReason }, "dispatch PAUSED on quota threshold");
2169
+ for (const adapter of this.opts.adapters) {
2170
+ try {
2171
+ await adapter.notify("error", `⛔ Dispatch PAUSED — ${this.dispatchPauseReason}.\nUse \`/unpause\` to resume after the rate-limit window resets.`);
2172
+ }
2173
+ catch (err) {
2174
+ log.warn({ err: String(err), adapter: adapter.name }, "pause notify failed");
2175
+ }
2176
+ }
2177
+ }
2178
+ }
2179
+ async appendQuotaJsonl(entry) {
2180
+ const dir = join(this.opts.repoRoot, ".harness", "staleness");
2181
+ try {
2182
+ await mkdir(dir, { recursive: true });
2183
+ const line = JSON.stringify({
2184
+ ts: new Date().toISOString(),
2185
+ ...entry,
2186
+ message: entry.message.slice(0, 200),
2187
+ });
2188
+ await appendFile(join(dir, "quota.jsonl"), `${line}\n`, "utf8");
2189
+ }
2190
+ catch (err) {
2191
+ log.warn({ err: String(err) }, "quota.jsonl append failed");
2192
+ }
2193
+ }
2194
+ async handleUnpause(row) {
2195
+ const adapter = this.adapterForSource(row.source);
2196
+ if (!this.dispatchPaused) {
2197
+ await adapter?.notify("info", "/unpause — dispatch was not paused");
2198
+ return;
2199
+ }
2200
+ this.dispatchPaused = false;
2201
+ const reason = this.dispatchPauseReason;
2202
+ this.dispatchPauseReason = "";
2203
+ this.dispatchPausedAt = undefined;
2204
+ this.consecutiveQuotaErrors = 0;
2205
+ log.info({ by: row.slash.authorId, prior: reason }, "/unpause cleared");
2206
+ await adapter?.notify("info", `▶ Dispatch UNPAUSED (was: ${reason}). Counter reset to 0; tick will resume on next interval.`);
2207
+ }
2208
+ // §3.5 /archive — quarantine a file under .archive/<date>/<path>.
2209
+ async handleArchive(row) {
2210
+ const adapter = this.adapterForSource(row.source);
2211
+ const rawPath = typeof row.slash.options["path"] === "string"
2212
+ ? row.slash.options["path"].trim()
2213
+ : "";
2214
+ if (rawPath.length === 0) {
2215
+ await adapter?.notify("warn", "/archive — path required");
2216
+ return;
2217
+ }
2218
+ if (rawPath.startsWith("/") || rawPath.includes("..")) {
2219
+ await adapter?.notify("warn", `/archive — path must be repo-relative without "..": got "${rawPath}"`);
2220
+ return;
2221
+ }
2222
+ const FORBIDDEN = [".git/", ".harness/", ".archive/", "node_modules/"];
2223
+ for (const forbidden of FORBIDDEN) {
2224
+ if (rawPath.startsWith(forbidden)) {
2225
+ await adapter?.notify("warn", `/archive — refusing to archive inside ${forbidden}`);
2226
+ return;
2227
+ }
2228
+ }
2229
+ let mirrorPath;
2230
+ try {
2231
+ mirrorPath = requireMirrorRecord(this.opts.projectName).mirrorPath;
2232
+ }
2233
+ catch (err) {
2234
+ await adapter?.notify("error", `/archive — mirror not registered: ${String(err)}`);
2235
+ return;
2236
+ }
2237
+ const sourceAbs = join(mirrorPath, rawPath);
2238
+ if (!existsSync(sourceAbs)) {
2239
+ await adapter?.notify("warn", `/archive — file not found: ${rawPath}`);
2240
+ return;
2241
+ }
2242
+ const today = new Date().toISOString().slice(0, 10);
2243
+ const archiveRel = join(".archive", today, rawPath);
2244
+ const archiveAbs = join(mirrorPath, archiveRel);
2245
+ try {
2246
+ await mkdir(dirname(archiveAbs), { recursive: true });
2247
+ const { simpleGit } = await import("simple-git");
2248
+ const git = simpleGit({ baseDir: mirrorPath });
2249
+ await git.mv(rawPath, archiveRel);
2250
+ await git.add(archiveRel);
2251
+ await git.commit(`chore(archive): move ${rawPath} → ${archiveRel}\n\nVia /archive by ${row.slash.authorId}.`);
2252
+ const sha = (await git.revparse(["HEAD"])).trim();
2253
+ log.info({ from: rawPath, to: archiveRel, sha, by: row.slash.authorId }, "/archive committed");
2254
+ await adapter?.notify("info", `📦 /archive — moved \`${rawPath}\` → \`${archiveRel}\` (commit \`${sha.slice(0, 8)}\`). Push manually via \`harness mirror push\`.`);
2255
+ }
2256
+ catch (err) {
2257
+ await adapter?.notify("error", `/archive failed: ${String(err)}`);
2258
+ }
2259
+ }
2260
+ }
2261
+ /**
2262
+ * §3.4 — classify a `completeRun(failed, error)` reason into one of five
2263
+ * failure classes the embed renders distinctly. Falls back to "hard" when
2264
+ * the error string doesn't match a known prefix.
2265
+ */
2266
+ function classifyFailure(error) {
2267
+ if (error === undefined)
2268
+ return "hard";
2269
+ const e = error.toLowerCase();
2270
+ if (e.includes("halted by operator"))
2271
+ return "halt";
2272
+ if (e.startsWith("sensors"))
2273
+ return "sensor";
2274
+ if (e.startsWith("reviewer"))
2275
+ return "reviewer";
2276
+ if (e.startsWith("uat"))
2277
+ return "uat";
2278
+ if (e.startsWith("tightener"))
2279
+ return "hard";
2280
+ if (e.startsWith("workspace"))
2281
+ return "hard";
2282
+ if (e.startsWith("agent"))
2283
+ return "hard";
2284
+ return "hard";
2285
+ }
2286
+ /**
2287
+ * Per failure class, build a remediation block — reason + 1-3 next-action
2288
+ * suggestions the operator can act on directly. Sensor / reviewer fails
2289
+ * usually want `/ship-anyway` or re-submit; UAT fails want `/resume`;
2290
+ * halt wants re-submit; hard errors point at the log.
2291
+ */
2292
+ function buildRemediation(cls, error, runId) {
2293
+ const reason = error ?? "unknown failure";
2294
+ switch (cls) {
2295
+ case "sensor":
2296
+ return {
2297
+ reason,
2298
+ suggestedActions: [
2299
+ "Re-submit task with the corrections the remediation prompt named",
2300
+ "`/ship-anyway` — override the sensor gate (logged for audit)",
2301
+ "`/oops` — propose a new stub-pattern if a false-negative slipped through",
2302
+ ],
2303
+ };
2304
+ case "reviewer":
2305
+ return {
2306
+ reason,
2307
+ suggestedActions: [
2308
+ "Re-submit with the reviewer's gaps addressed (see log.jsonl `reviewer_verdict` entry)",
2309
+ "`/ship-anyway` — override the reviewer (logged for audit)",
2310
+ ],
2311
+ };
2312
+ case "uat":
2313
+ return {
2314
+ reason,
2315
+ suggestedActions: [
2316
+ `\`/resume ${runId}\` — re-fire the UAT approval dialog`,
2317
+ "Re-submit task with the rejection feedback baked in",
2318
+ ],
2319
+ };
2320
+ case "halt":
2321
+ return {
2322
+ reason,
2323
+ suggestedActions: [
2324
+ "Re-submit task to retry from scratch",
2325
+ "`/oops` — describe what was wrong so it gets caught next time",
2326
+ ],
2327
+ };
2328
+ case "hard":
2329
+ default:
2330
+ return {
2331
+ reason,
2332
+ suggestedActions: [
2333
+ `Read \`.harness/runs/active/${runId}/log.jsonl\` for the full transition trace`,
2334
+ "Re-submit task to retry",
2335
+ ],
2336
+ };
2337
+ }
2338
+ }
2339
+ /** Format one log entry for the live status embed's recent-events strip. */
2340
+ function formatRunLogLine(e) {
2341
+ const time = e.ts.slice(11, 19);
2342
+ const kind = e.kind.replace(/_/g, " ");
2343
+ const summaryRoom = Math.max(0, 90 - time.length - kind.length - 12);
2344
+ const summary = e.summary.slice(0, summaryRoom);
2345
+ return `\`${time}\` · **${kind}** · ${summary}`;
2346
+ }
2347
+ /** Count lines present in `after` but not in `before`. */
2348
+ function countAddedLines(before, after) {
2349
+ if (after === undefined)
2350
+ return 0;
2351
+ if (before === undefined)
2352
+ return after.split(/\r?\n/).length;
2353
+ const beforeLines = new Set(before.split(/\r?\n/));
2354
+ let added = 0;
2355
+ for (const line of after.split(/\r?\n/)) {
2356
+ if (!beforeLines.has(line))
2357
+ added += 1;
2358
+ }
2359
+ return added;
2360
+ }
2361
+ /**
2362
+ * Build the spec the implementer agent will see, baking the operator's
2363
+ * per-ambiguity resolutions into the tightener's proposal so the agent
2364
+ * works against settled answers instead of the LLM's defaults.
2365
+ */
2366
+ function renderResolvedSpec(args) {
2367
+ if (args.resolutions.length === 0)
2368
+ return args.proposal;
2369
+ const lines = [];
2370
+ lines.push(args.proposal.trim());
2371
+ lines.push("");
2372
+ lines.push("## Operator-resolved ambiguities");
2373
+ for (const r of args.resolutions) {
2374
+ lines.push(`- **${r.id}** — ${r.question}`);
2375
+ lines.push(` → ${r.choice}`);
2376
+ }
2377
+ return lines.join("\n");
2378
+ }
2379
+ /**
2380
+ * Format the tightener's gap analysis as a per-task channel post body.
2381
+ * The operator's actionable output: ambiguities + missing acceptance +
2382
+ * scope concerns + the proposed tightened spec they can copy/paste/edit
2383
+ * + a `/ship-anyway` override hint.
2384
+ */
2385
+ function renderTightenerFeedback(output, qualityFloor) {
2386
+ const lines = [];
2387
+ lines.push(`**spec quality: ${output.spec_quality_score}/10** (floor ${qualityFloor}) — needs sharpening before dispatch`);
2388
+ if (output.ambiguities.length > 0) {
2389
+ lines.push("");
2390
+ lines.push("**Ambiguities:**");
2391
+ for (const a of output.ambiguities.slice(0, 5)) {
2392
+ const candidates = a.candidate_resolutions.length > 0
2393
+ ? ` → ${a.candidate_resolutions.slice(0, 3).join(" | ")}`
2394
+ : "";
2395
+ lines.push(`- **${a.id}** ${a.question}${candidates}`);
2396
+ }
2397
+ }
2398
+ if (output.missing_acceptance.length > 0) {
2399
+ lines.push("");
2400
+ lines.push("**Missing acceptance criteria:**");
2401
+ for (const a of output.missing_acceptance.slice(0, 5))
2402
+ lines.push(`- ${a}`);
2403
+ }
2404
+ if (output.scope_concerns.length > 0) {
2405
+ lines.push("");
2406
+ lines.push("**Scope concerns:**");
2407
+ for (const a of output.scope_concerns.slice(0, 5))
2408
+ lines.push(`- ${a}`);
2409
+ }
2410
+ if (output.conflicts.length > 0) {
2411
+ lines.push("");
2412
+ lines.push("**Conflicts:**");
2413
+ for (const a of output.conflicts.slice(0, 5))
2414
+ lines.push(`- ${a}`);
2415
+ }
2416
+ if (output.existing_stub_overlap.length > 0) {
2417
+ lines.push("");
2418
+ lines.push("**Stub overlap:**");
2419
+ for (const a of output.existing_stub_overlap.slice(0, 5))
2420
+ lines.push(`- ${a}`);
2421
+ }
2422
+ if (output.tightened_spec_proposal.trim().length > 0) {
2423
+ lines.push("");
2424
+ lines.push("**Proposed tightened spec (copy / edit / re-submit):**");
2425
+ lines.push("```");
2426
+ const cap = 1500;
2427
+ const proposal = output.tightened_spec_proposal.trim();
2428
+ lines.push(proposal.length > cap ? proposal.slice(0, cap) + "\n…[truncated]" : proposal);
2429
+ lines.push("```");
2430
+ }
2431
+ lines.push("");
2432
+ lines.push("_To bypass the quality gate (e.g. cosmetic / one-off), re-submit with `/ship-anyway`._");
2433
+ // Discord 2000-char message cap with safety margin.
2434
+ const body = lines.join("\n");
2435
+ return body.length > 1900 ? body.slice(0, 1900) + "\n…[truncated]" : body;
2436
+ }
2437
+ //# sourceMappingURL=orchestrator.js.map