@ttfw/envoi 1.0.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 (283) hide show
  1. package/README.md +238 -0
  2. package/dist/commands/app.d.ts +2 -0
  3. package/dist/commands/app.d.ts.map +1 -0
  4. package/dist/commands/app.js +31 -0
  5. package/dist/commands/app.js.map +1 -0
  6. package/dist/commands/autonomy.d.ts +6 -0
  7. package/dist/commands/autonomy.d.ts.map +1 -0
  8. package/dist/commands/autonomy.js +89 -0
  9. package/dist/commands/autonomy.js.map +1 -0
  10. package/dist/commands/builder.d.ts +13 -0
  11. package/dist/commands/builder.d.ts.map +1 -0
  12. package/dist/commands/builder.js +142 -0
  13. package/dist/commands/builder.js.map +1 -0
  14. package/dist/commands/idea.d.ts +12 -0
  15. package/dist/commands/idea.d.ts.map +1 -0
  16. package/dist/commands/idea.js +79 -0
  17. package/dist/commands/idea.js.map +1 -0
  18. package/dist/commands/init.d.ts +18 -0
  19. package/dist/commands/init.d.ts.map +1 -0
  20. package/dist/commands/init.js +423 -0
  21. package/dist/commands/init.js.map +1 -0
  22. package/dist/commands/mode.d.ts +13 -0
  23. package/dist/commands/mode.d.ts.map +1 -0
  24. package/dist/commands/mode.js +96 -0
  25. package/dist/commands/mode.js.map +1 -0
  26. package/dist/commands/onboard.d.ts +37 -0
  27. package/dist/commands/onboard.d.ts.map +1 -0
  28. package/dist/commands/onboard.js +743 -0
  29. package/dist/commands/onboard.js.map +1 -0
  30. package/dist/commands/pr-note.d.ts +8 -0
  31. package/dist/commands/pr-note.d.ts.map +1 -0
  32. package/dist/commands/pr-note.js +27 -0
  33. package/dist/commands/pr-note.js.map +1 -0
  34. package/dist/commands/undo.d.ts +7 -0
  35. package/dist/commands/undo.d.ts.map +1 -0
  36. package/dist/commands/undo.js +59 -0
  37. package/dist/commands/undo.js.map +1 -0
  38. package/dist/commands/update.d.ts +24 -0
  39. package/dist/commands/update.d.ts.map +1 -0
  40. package/dist/commands/update.js +248 -0
  41. package/dist/commands/update.js.map +1 -0
  42. package/dist/constants/report_codes.d.ts +29 -0
  43. package/dist/constants/report_codes.d.ts.map +1 -0
  44. package/dist/constants/report_codes.js +69 -0
  45. package/dist/constants/report_codes.js.map +1 -0
  46. package/dist/index.d.ts +3 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +675 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/lib/autonomy.d.ts +16 -0
  51. package/dist/lib/autonomy.d.ts.map +1 -0
  52. package/dist/lib/autonomy.js +38 -0
  53. package/dist/lib/autonomy.js.map +1 -0
  54. package/dist/lib/blocked.d.ts +87 -0
  55. package/dist/lib/blocked.d.ts.map +1 -0
  56. package/dist/lib/blocked.js +134 -0
  57. package/dist/lib/blocked.js.map +1 -0
  58. package/dist/lib/branding.d.ts +13 -0
  59. package/dist/lib/branding.d.ts.map +1 -0
  60. package/dist/lib/branding.js +19 -0
  61. package/dist/lib/branding.js.map +1 -0
  62. package/dist/lib/claude.d.ts +42 -0
  63. package/dist/lib/claude.d.ts.map +1 -0
  64. package/dist/lib/claude.js +291 -0
  65. package/dist/lib/claude.js.map +1 -0
  66. package/dist/lib/config.d.ts +71 -0
  67. package/dist/lib/config.d.ts.map +1 -0
  68. package/dist/lib/config.js +410 -0
  69. package/dist/lib/config.js.map +1 -0
  70. package/dist/lib/diff.d.ts +150 -0
  71. package/dist/lib/diff.d.ts.map +1 -0
  72. package/dist/lib/diff.js +257 -0
  73. package/dist/lib/diff.js.map +1 -0
  74. package/dist/lib/doctor.d.ts +67 -0
  75. package/dist/lib/doctor.d.ts.map +1 -0
  76. package/dist/lib/doctor.js +211 -0
  77. package/dist/lib/doctor.js.map +1 -0
  78. package/dist/lib/fingerprint.d.ts +27 -0
  79. package/dist/lib/fingerprint.d.ts.map +1 -0
  80. package/dist/lib/fingerprint.js +116 -0
  81. package/dist/lib/fingerprint.js.map +1 -0
  82. package/dist/lib/fs.d.ts +93 -0
  83. package/dist/lib/fs.d.ts.map +1 -0
  84. package/dist/lib/fs.js +179 -0
  85. package/dist/lib/fs.js.map +1 -0
  86. package/dist/lib/git.d.ts +177 -0
  87. package/dist/lib/git.d.ts.map +1 -0
  88. package/dist/lib/git.js +355 -0
  89. package/dist/lib/git.js.map +1 -0
  90. package/dist/lib/git_branching.d.ts +84 -0
  91. package/dist/lib/git_branching.d.ts.map +1 -0
  92. package/dist/lib/git_branching.js +327 -0
  93. package/dist/lib/git_branching.js.map +1 -0
  94. package/dist/lib/gitignore.d.ts +26 -0
  95. package/dist/lib/gitignore.d.ts.map +1 -0
  96. package/dist/lib/gitignore.js +119 -0
  97. package/dist/lib/gitignore.js.map +1 -0
  98. package/dist/lib/guardrails.d.ts +232 -0
  99. package/dist/lib/guardrails.d.ts.map +1 -0
  100. package/dist/lib/guardrails.js +323 -0
  101. package/dist/lib/guardrails.js.map +1 -0
  102. package/dist/lib/history.d.ts +110 -0
  103. package/dist/lib/history.d.ts.map +1 -0
  104. package/dist/lib/history.js +236 -0
  105. package/dist/lib/history.js.map +1 -0
  106. package/dist/lib/index.d.ts +29 -0
  107. package/dist/lib/index.d.ts.map +1 -0
  108. package/dist/lib/index.js +29 -0
  109. package/dist/lib/index.js.map +1 -0
  110. package/dist/lib/json-extract.d.ts +42 -0
  111. package/dist/lib/json-extract.d.ts.map +1 -0
  112. package/dist/lib/json-extract.js +201 -0
  113. package/dist/lib/json-extract.js.map +1 -0
  114. package/dist/lib/judge.d.ts +237 -0
  115. package/dist/lib/judge.d.ts.map +1 -0
  116. package/dist/lib/judge.js +501 -0
  117. package/dist/lib/judge.js.map +1 -0
  118. package/dist/lib/lock.d.ts +79 -0
  119. package/dist/lib/lock.d.ts.map +1 -0
  120. package/dist/lib/lock.js +254 -0
  121. package/dist/lib/lock.js.map +1 -0
  122. package/dist/lib/migration.d.ts +9 -0
  123. package/dist/lib/migration.d.ts.map +1 -0
  124. package/dist/lib/migration.js +74 -0
  125. package/dist/lib/migration.js.map +1 -0
  126. package/dist/lib/paths.d.ts +18 -0
  127. package/dist/lib/paths.d.ts.map +1 -0
  128. package/dist/lib/paths.js +27 -0
  129. package/dist/lib/paths.js.map +1 -0
  130. package/dist/lib/preflight.d.ts +33 -0
  131. package/dist/lib/preflight.d.ts.map +1 -0
  132. package/dist/lib/preflight.js +177 -0
  133. package/dist/lib/preflight.js.map +1 -0
  134. package/dist/lib/prompt_budget.d.ts +18 -0
  135. package/dist/lib/prompt_budget.d.ts.map +1 -0
  136. package/dist/lib/prompt_budget.js +36 -0
  137. package/dist/lib/prompt_budget.js.map +1 -0
  138. package/dist/lib/report.d.ts +102 -0
  139. package/dist/lib/report.d.ts.map +1 -0
  140. package/dist/lib/report.js +347 -0
  141. package/dist/lib/report.js.map +1 -0
  142. package/dist/lib/reviewer-flow.d.ts +80 -0
  143. package/dist/lib/reviewer-flow.d.ts.map +1 -0
  144. package/dist/lib/reviewer-flow.js +138 -0
  145. package/dist/lib/reviewer-flow.js.map +1 -0
  146. package/dist/lib/reviewer.d.ts +53 -0
  147. package/dist/lib/reviewer.d.ts.map +1 -0
  148. package/dist/lib/reviewer.js +199 -0
  149. package/dist/lib/reviewer.js.map +1 -0
  150. package/dist/lib/risk.d.ts +127 -0
  151. package/dist/lib/risk.d.ts.map +1 -0
  152. package/dist/lib/risk.js +192 -0
  153. package/dist/lib/risk.js.map +1 -0
  154. package/dist/lib/rollback.d.ts +143 -0
  155. package/dist/lib/rollback.d.ts.map +1 -0
  156. package/dist/lib/rollback.js +244 -0
  157. package/dist/lib/rollback.js.map +1 -0
  158. package/dist/lib/schema.d.ts +47 -0
  159. package/dist/lib/schema.d.ts.map +1 -0
  160. package/dist/lib/schema.js +91 -0
  161. package/dist/lib/schema.js.map +1 -0
  162. package/dist/lib/scope.d.ts +89 -0
  163. package/dist/lib/scope.d.ts.map +1 -0
  164. package/dist/lib/scope.js +135 -0
  165. package/dist/lib/scope.js.map +1 -0
  166. package/dist/lib/self_update.d.ts +13 -0
  167. package/dist/lib/self_update.d.ts.map +1 -0
  168. package/dist/lib/self_update.js +172 -0
  169. package/dist/lib/self_update.js.map +1 -0
  170. package/dist/lib/state.d.ts +143 -0
  171. package/dist/lib/state.d.ts.map +1 -0
  172. package/dist/lib/state.js +258 -0
  173. package/dist/lib/state.js.map +1 -0
  174. package/dist/lib/tick.d.ts +310 -0
  175. package/dist/lib/tick.d.ts.map +1 -0
  176. package/dist/lib/tick.js +424 -0
  177. package/dist/lib/tick.js.map +1 -0
  178. package/dist/lib/transport.d.ts +145 -0
  179. package/dist/lib/transport.d.ts.map +1 -0
  180. package/dist/lib/transport.js +237 -0
  181. package/dist/lib/transport.js.map +1 -0
  182. package/dist/lib/verdict_labels.d.ts +5 -0
  183. package/dist/lib/verdict_labels.d.ts.map +1 -0
  184. package/dist/lib/verdict_labels.js +25 -0
  185. package/dist/lib/verdict_labels.js.map +1 -0
  186. package/dist/lib/verify-safety.d.ts +63 -0
  187. package/dist/lib/verify-safety.d.ts.map +1 -0
  188. package/dist/lib/verify-safety.js +123 -0
  189. package/dist/lib/verify-safety.js.map +1 -0
  190. package/dist/lib/verify.d.ts +139 -0
  191. package/dist/lib/verify.d.ts.map +1 -0
  192. package/dist/lib/verify.js +311 -0
  193. package/dist/lib/verify.js.map +1 -0
  194. package/dist/lib/workspace_state.d.ts +79 -0
  195. package/dist/lib/workspace_state.d.ts.map +1 -0
  196. package/dist/lib/workspace_state.js +283 -0
  197. package/dist/lib/workspace_state.js.map +1 -0
  198. package/dist/runner/builder.d.ts +58 -0
  199. package/dist/runner/builder.d.ts.map +1 -0
  200. package/dist/runner/builder.js +775 -0
  201. package/dist/runner/builder.js.map +1 -0
  202. package/dist/runner/builder_parse.d.ts +37 -0
  203. package/dist/runner/builder_parse.d.ts.map +1 -0
  204. package/dist/runner/builder_parse.js +76 -0
  205. package/dist/runner/builder_parse.js.map +1 -0
  206. package/dist/runner/index.d.ts +9 -0
  207. package/dist/runner/index.d.ts.map +1 -0
  208. package/dist/runner/index.js +7 -0
  209. package/dist/runner/index.js.map +1 -0
  210. package/dist/runner/loop.d.ts +51 -0
  211. package/dist/runner/loop.d.ts.map +1 -0
  212. package/dist/runner/loop.js +221 -0
  213. package/dist/runner/loop.js.map +1 -0
  214. package/dist/runner/orchestrator.d.ts +67 -0
  215. package/dist/runner/orchestrator.d.ts.map +1 -0
  216. package/dist/runner/orchestrator.js +376 -0
  217. package/dist/runner/orchestrator.js.map +1 -0
  218. package/dist/runner/tick.d.ts +10 -0
  219. package/dist/runner/tick.d.ts.map +1 -0
  220. package/dist/runner/tick.js +1639 -0
  221. package/dist/runner/tick.js.map +1 -0
  222. package/dist/types/blocked.d.ts +52 -0
  223. package/dist/types/blocked.d.ts.map +1 -0
  224. package/dist/types/blocked.js +8 -0
  225. package/dist/types/blocked.js.map +1 -0
  226. package/dist/types/builder.d.ts +25 -0
  227. package/dist/types/builder.d.ts.map +1 -0
  228. package/dist/types/builder.js +7 -0
  229. package/dist/types/builder.js.map +1 -0
  230. package/dist/types/claude.d.ts +86 -0
  231. package/dist/types/claude.d.ts.map +1 -0
  232. package/dist/types/claude.js +48 -0
  233. package/dist/types/claude.js.map +1 -0
  234. package/dist/types/config.d.ts +384 -0
  235. package/dist/types/config.d.ts.map +1 -0
  236. package/dist/types/config.js +7 -0
  237. package/dist/types/config.js.map +1 -0
  238. package/dist/types/index.d.ts +18 -0
  239. package/dist/types/index.d.ts.map +1 -0
  240. package/dist/types/index.js +8 -0
  241. package/dist/types/index.js.map +1 -0
  242. package/dist/types/lock.d.ts +21 -0
  243. package/dist/types/lock.d.ts.map +1 -0
  244. package/dist/types/lock.js +8 -0
  245. package/dist/types/lock.js.map +1 -0
  246. package/dist/types/preflight.d.ts +49 -0
  247. package/dist/types/preflight.d.ts.map +1 -0
  248. package/dist/types/preflight.js +8 -0
  249. package/dist/types/preflight.js.map +1 -0
  250. package/dist/types/report.d.ts +161 -0
  251. package/dist/types/report.d.ts.map +1 -0
  252. package/dist/types/report.js +8 -0
  253. package/dist/types/report.js.map +1 -0
  254. package/dist/types/reviewer.d.ts +66 -0
  255. package/dist/types/reviewer.d.ts.map +1 -0
  256. package/dist/types/reviewer.js +5 -0
  257. package/dist/types/reviewer.js.map +1 -0
  258. package/dist/types/state.d.ts +124 -0
  259. package/dist/types/state.d.ts.map +1 -0
  260. package/dist/types/state.js +20 -0
  261. package/dist/types/state.js.map +1 -0
  262. package/dist/types/task.d.ts +117 -0
  263. package/dist/types/task.d.ts.map +1 -0
  264. package/dist/types/task.js +7 -0
  265. package/dist/types/task.js.map +1 -0
  266. package/dist/types/workspace_state.d.ts +125 -0
  267. package/dist/types/workspace_state.d.ts.map +1 -0
  268. package/dist/types/workspace_state.js +10 -0
  269. package/dist/types/workspace_state.js.map +1 -0
  270. package/envoi.config.json +191 -0
  271. package/package.json +52 -0
  272. package/relais/prompts/.gitkeep +0 -0
  273. package/relais/prompts/builder.system.txt +13 -0
  274. package/relais/prompts/builder.user.txt +15 -0
  275. package/relais/prompts/orchestrator.system.txt +37 -0
  276. package/relais/prompts/orchestrator.user.txt +34 -0
  277. package/relais/prompts/reviewer.system.txt +33 -0
  278. package/relais/prompts/reviewer.user.txt +35 -0
  279. package/relais/schemas/.gitkeep +0 -0
  280. package/relais/schemas/builder_result.schema.json +29 -0
  281. package/relais/schemas/report.schema.json +195 -0
  282. package/relais/schemas/reviewer_result.schema.json +70 -0
  283. package/relais/schemas/task.schema.json +155 -0
@@ -0,0 +1,1639 @@
1
+ /**
2
+ * Main tick execution runner.
3
+ *
4
+ * Implements the Envoi state machine:
5
+ * LOCK → PREFLIGHT → ORCHESTRATE → BUILD → JUDGE → REPORT → END
6
+ */
7
+ import { join } from 'node:path';
8
+ import { isValidReportCode } from '../constants/report_codes.js';
9
+ import { TickPhase } from '../types/state.js';
10
+ import { acquireLock, releaseLock, LockHeldError } from '../lib/lock.js';
11
+ import { runPreflight } from '../lib/preflight.js';
12
+ import { atomicWriteJson } from '../lib/fs.js';
13
+ import { createInitialState, transitionPhase, } from '../lib/state.js';
14
+ import { runOrchestrator } from './orchestrator.js';
15
+ import { requestStop } from './loop.js';
16
+ import { writeBlocked, buildOrchestratorBlockedData, buildBlockedData, deleteBlocked } from '../lib/blocked.js';
17
+ import { renderReportMarkdown, writeReportMarkdown } from '../lib/report.js';
18
+ import { runBuilder } from './builder.js';
19
+ import { isTransportStallError } from '../lib/transport.js';
20
+ import { handleTransportStall } from '../lib/tick.js';
21
+ import { getTouchedFiles, checkScopeViolations, computeBlastRadius, checkDiffLimits, checkHeadMoved, } from '../lib/judge.js';
22
+ import { rollbackToCommit, verifyCleanWorktree } from '../lib/rollback.js';
23
+ import { validateAllParams } from '../lib/verify-safety.js';
24
+ import { readWorkspaceState, writeWorkspaceState, ensureMilestone } from '../lib/workspace_state.js';
25
+ import { spawn } from 'node:child_process';
26
+ import { isInterruptedError, isTimeoutError } from '../types/claude.js';
27
+ import { persistBuilderFailure, persistOrchestratorFailure } from '../lib/history.js';
28
+ import { ensureBranchPerTick, ensureBranchPerNTasks, ensureBranchPerMilestone, } from '../lib/git_branching.js';
29
+ import { runReviewerIfNeeded } from '../lib/reviewer-flow.js';
30
+ import { computeRiskFlags } from '../lib/risk.js';
31
+ const TOKEN_WARNING_ORCHESTRATOR_PREFIX = '[tokens] orchestrator';
32
+ const TOKEN_WARNING_BUILDER_PREFIX = '[tokens] builder';
33
+ const TOKEN_WARNING_TOTAL_PREFIX = '[tokens] tick_total';
34
+ const MAX_WARNING_CHARS = 500;
35
+ let activeTickTokenUsage = null;
36
+ function isDebugEnabled() {
37
+ return process.env.ENVOI_DEBUG === '1';
38
+ }
39
+ function tokenNumber(value) {
40
+ return typeof value === 'number' ? String(value) : 'n/a';
41
+ }
42
+ function formatTokenUsageForLog(usage) {
43
+ if (!usage)
44
+ return 'input=n/a output=n/a total=n/a';
45
+ return `input=${tokenNumber(usage.input_tokens)} output=${tokenNumber(usage.output_tokens)} total=${tokenNumber(usage.total_tokens)}`;
46
+ }
47
+ function pushUniqueWarning(warnings, warning) {
48
+ if (!warnings.includes(warning)) {
49
+ warnings.push(warning);
50
+ }
51
+ }
52
+ function compactWarningText(raw, maxChars = MAX_WARNING_CHARS) {
53
+ const normalized = raw.replace(/\s+/g, ' ').trim();
54
+ if (normalized.length <= maxChars)
55
+ return normalized;
56
+ return `${normalized.slice(0, Math.max(0, maxChars - 38))}… [truncated ${normalized.length - maxChars} chars]`;
57
+ }
58
+ function annotateReportWithTokenUsage(report) {
59
+ if (!activeTickTokenUsage)
60
+ return report;
61
+ const warnings = report.budgets.warnings;
62
+ const orchestrator = activeTickTokenUsage.orchestrator;
63
+ const builder = activeTickTokenUsage.builder;
64
+ if (orchestrator) {
65
+ pushUniqueWarning(warnings, `${TOKEN_WARNING_ORCHESTRATOR_PREFIX} input=${tokenNumber(orchestrator.input_tokens)} output=${tokenNumber(orchestrator.output_tokens)} total=${tokenNumber(orchestrator.total_tokens)}`);
66
+ }
67
+ if (builder) {
68
+ pushUniqueWarning(warnings, `${TOKEN_WARNING_BUILDER_PREFIX} input=${tokenNumber(builder.input_tokens)} output=${tokenNumber(builder.output_tokens)} total=${tokenNumber(builder.total_tokens)}`);
69
+ }
70
+ const total = (orchestrator?.total_tokens ?? 0) + (builder?.total_tokens ?? 0);
71
+ const hasKnownTotal = orchestrator?.total_tokens !== null || builder?.total_tokens !== null;
72
+ pushUniqueWarning(warnings, `${TOKEN_WARNING_TOTAL_PREFIX} total=${hasKnownTotal ? total : 'n/a'}`);
73
+ return report;
74
+ }
75
+ function applyPlanningDecisionToWorkspaceState(wsState, task) {
76
+ const planningDecision = task.planning_decision;
77
+ if (!planningDecision)
78
+ return wsState;
79
+ const now = new Date().toISOString();
80
+ const considered = new Set(planningDecision.idea_ids_considered);
81
+ const mappedStatus = planningDecision.decision === 'defer' ? 'deferred' : 'scheduled';
82
+ const updatedInbox = (wsState.idea_inbox ?? []).map((entry) => {
83
+ if (!considered.has(entry.id))
84
+ return entry;
85
+ return {
86
+ ...entry,
87
+ status: mappedStatus,
88
+ triaged_by_task_id: task.task_id,
89
+ triaged_at: now,
90
+ };
91
+ });
92
+ const summaryPrefix = planningDecision.decision === 'schedule_now'
93
+ ? 'Scheduled now'
94
+ : planningDecision.decision === 'schedule_next'
95
+ ? 'Scheduled next'
96
+ : 'Deferred';
97
+ const summary = `${summaryPrefix}: ${planningDecision.rationale_short}`;
98
+ return {
99
+ ...wsState,
100
+ idea_inbox: updatedInbox,
101
+ planning_digest: {
102
+ updated_at: now,
103
+ summary,
104
+ last_task_id: task.task_id,
105
+ suggested_milestone: planningDecision.suggested_milestone,
106
+ },
107
+ };
108
+ }
109
+ function upsertOpenProductQuestion(wsState, task, runId) {
110
+ if (task.task_kind !== 'question' || !task.question?.prompt) {
111
+ return wsState;
112
+ }
113
+ const openQuestions = wsState.open_product_questions ?? [];
114
+ const existingOpen = openQuestions.find((question) => !question.resolved && question.prompt.trim() === task.question?.prompt.trim());
115
+ if (existingOpen)
116
+ return wsState;
117
+ const question = {
118
+ id: `pq-${runId}`,
119
+ prompt: task.question.prompt,
120
+ choices: Array.isArray(task.question.choices) ? task.question.choices : undefined,
121
+ created_at: new Date().toISOString(),
122
+ resolved: false,
123
+ };
124
+ return {
125
+ ...wsState,
126
+ open_product_questions: [...openQuestions, question],
127
+ };
128
+ }
129
+ /**
130
+ * Generates a basic report from tick state.
131
+ *
132
+ * This is a placeholder implementation. Full report generation will be
133
+ * implemented in M6.
134
+ *
135
+ * @param state - Tick state
136
+ * @param code - Report code
137
+ * @param verdict - Verdict (success/stop/blocked)
138
+ * @returns Basic report data
139
+ */
140
+ function generateReport(state, code, verdict) {
141
+ const endedAt = new Date().toISOString();
142
+ const startedAt = new Date(state.started_at);
143
+ const endedAtDate = new Date(endedAt);
144
+ const durationMs = endedAtDate.getTime() - startedAt.getTime();
145
+ return {
146
+ run_id: state.run_id,
147
+ started_at: state.started_at,
148
+ ended_at: endedAt,
149
+ duration_ms: durationMs,
150
+ base_commit: state.base_commit,
151
+ head_commit: state.base_commit, // Placeholder - will be updated in JUDGE phase
152
+ task: state.task
153
+ ? {
154
+ task_id: state.task.task_id,
155
+ milestone_id: state.task.milestone_id,
156
+ task_kind: state.task.task_kind,
157
+ intent: state.task.intent,
158
+ }
159
+ : {
160
+ task_id: 'none',
161
+ milestone_id: 'none',
162
+ task_kind: 'execute',
163
+ intent: 'No task assigned',
164
+ },
165
+ verdict,
166
+ code,
167
+ blast_radius: {
168
+ files_touched: 0,
169
+ lines_added: 0,
170
+ lines_deleted: 0,
171
+ new_files: 0,
172
+ },
173
+ scope: {
174
+ ok: true,
175
+ violations: [],
176
+ touched_paths: [],
177
+ },
178
+ diff: {
179
+ files_changed: 0,
180
+ lines_changed: 0,
181
+ diff_patch_path: '',
182
+ },
183
+ verification: {
184
+ exec_mode: 'argv_no_shell',
185
+ runs: [],
186
+ verify_log_path: '',
187
+ },
188
+ budgets: {
189
+ milestone_id: state.task?.milestone_id || 'none',
190
+ ticks: 0,
191
+ orchestrator_calls: 0,
192
+ builder_calls: 0,
193
+ verify_runs: 0,
194
+ estimated_cost_usd: 0,
195
+ warnings: [],
196
+ },
197
+ };
198
+ }
199
+ /**
200
+ * Persists all run artifacts atomically.
201
+ *
202
+ * This is the single point of artifact persistence for all tick outcomes.
203
+ * Every tick must call this before returning, regardless of verdict.
204
+ *
205
+ * Writes:
206
+ * 1. REPORT.json - Always written
207
+ * 2. REPORT.md - Written if config.runner.render_report_md.enabled (with hard truncation)
208
+ * 3. BLOCKED.json - Written if blocked, deleted otherwise (cleans up stale blocked files)
209
+ *
210
+ * @param options - Persistence options including config, report, and optional blocked data
211
+ */
212
+ async function persistRunArtifacts(options) {
213
+ const { config, blockedData } = options;
214
+ const report = annotateReportWithTokenUsage(options.report);
215
+ const workspaceDir = config.workspace_dir;
216
+ // 1. ALWAYS write REPORT.json first (most critical)
217
+ await atomicWriteJson(join(workspaceDir, 'REPORT.json'), report);
218
+ // 1a. Update STATE.json with run metadata and budgets (non-critical)
219
+ try {
220
+ const wsState = await readWorkspaceState(workspaceDir);
221
+ const updatedState = {
222
+ ...wsState,
223
+ last_run_id: report.run_id,
224
+ last_verdict: report.verdict,
225
+ budgets: {
226
+ ticks: wsState.budgets.ticks + report.budgets.ticks,
227
+ orchestrator_calls: wsState.budgets.orchestrator_calls + report.budgets.orchestrator_calls,
228
+ builder_calls: wsState.budgets.builder_calls + report.budgets.builder_calls,
229
+ verify_runs: wsState.budgets.verify_runs + report.budgets.verify_runs,
230
+ },
231
+ };
232
+ await writeWorkspaceState(workspaceDir, updatedState);
233
+ }
234
+ catch (stateError) {
235
+ console.error(`Failed to update STATE.json: ${stateError}`);
236
+ }
237
+ // 2. Write REPORT.md if enabled (non-critical, don't block on failure)
238
+ if (config.runner.render_report_md?.enabled) {
239
+ try {
240
+ let markdown = renderReportMarkdown(report);
241
+ const maxChars = config.runner.render_report_md.max_chars;
242
+ if (markdown.length > maxChars) {
243
+ markdown = markdown.slice(0, maxChars - 4) + '\n...';
244
+ }
245
+ await writeReportMarkdown(markdown, join(workspaceDir, 'REPORT.md'));
246
+ }
247
+ catch (mdError) {
248
+ console.error(`Failed to write REPORT.md: ${mdError}`);
249
+ }
250
+ }
251
+ // 3. Handle BLOCKED.json (best-effort, don't block on failure)
252
+ const blockedPath = join(workspaceDir, 'BLOCKED.json');
253
+ try {
254
+ if (report.verdict === 'blocked' && blockedData) {
255
+ await writeBlocked(blockedData, blockedPath);
256
+ }
257
+ else {
258
+ await deleteBlocked(blockedPath);
259
+ }
260
+ }
261
+ catch (blockedError) {
262
+ console.error(`Failed to handle BLOCKED.json: ${blockedError}`);
263
+ }
264
+ }
265
+ /**
266
+ * Generates a BLOCKED report for transport stall.
267
+ */
268
+ function generateStallReport(state, stallResult) {
269
+ const endedAt = new Date().toISOString();
270
+ const startedAt = new Date(state.started_at);
271
+ const endedAtDate = new Date(endedAt);
272
+ const durationMs = endedAtDate.getTime() - startedAt.getTime();
273
+ return {
274
+ run_id: state.run_id,
275
+ started_at: state.started_at,
276
+ ended_at: endedAt,
277
+ duration_ms: durationMs,
278
+ base_commit: state.base_commit,
279
+ head_commit: state.base_commit,
280
+ task: state.task
281
+ ? {
282
+ task_id: state.task.task_id,
283
+ milestone_id: state.task.milestone_id,
284
+ task_kind: state.task.task_kind,
285
+ intent: state.task.intent,
286
+ }
287
+ : {
288
+ task_id: 'none',
289
+ milestone_id: 'none',
290
+ task_kind: 'execute',
291
+ intent: 'Transport stalled before task assignment',
292
+ },
293
+ verdict: 'blocked',
294
+ code: 'BLOCKED_TRANSPORT_STALLED',
295
+ blast_radius: {
296
+ files_touched: 0,
297
+ lines_added: 0,
298
+ lines_deleted: 0,
299
+ new_files: 0,
300
+ },
301
+ scope: {
302
+ ok: true,
303
+ violations: [],
304
+ touched_paths: [],
305
+ },
306
+ diff: {
307
+ files_changed: 0,
308
+ lines_changed: 0,
309
+ diff_patch_path: '',
310
+ },
311
+ verification: {
312
+ exec_mode: 'argv_no_shell',
313
+ runs: [],
314
+ verify_log_path: '',
315
+ },
316
+ budgets: {
317
+ milestone_id: state.task?.milestone_id || 'none',
318
+ ticks: 0,
319
+ orchestrator_calls: 0,
320
+ builder_calls: 0,
321
+ verify_runs: 0,
322
+ estimated_cost_usd: 0,
323
+ warnings: [`Transport stalled during ${stallResult.stage}. Request ID: ${stallResult.requestId || 'unknown'}`],
324
+ },
325
+ };
326
+ }
327
+ /**
328
+ * Generates a STOP report for judge violations.
329
+ */
330
+ function generateJudgeStopReport(state, stopCode, blastRadius, touchedPaths, violations, reason) {
331
+ const endedAt = new Date().toISOString();
332
+ const startedAt = new Date(state.started_at);
333
+ const endedAtDate = new Date(endedAt);
334
+ const durationMs = endedAtDate.getTime() - startedAt.getTime();
335
+ return {
336
+ run_id: state.run_id,
337
+ started_at: state.started_at,
338
+ ended_at: endedAt,
339
+ duration_ms: durationMs,
340
+ base_commit: state.base_commit,
341
+ head_commit: state.base_commit,
342
+ task: state.task
343
+ ? {
344
+ task_id: state.task.task_id,
345
+ milestone_id: state.task.milestone_id,
346
+ task_kind: state.task.task_kind,
347
+ intent: state.task.intent,
348
+ }
349
+ : {
350
+ task_id: 'none',
351
+ milestone_id: 'none',
352
+ task_kind: 'execute',
353
+ intent: 'No task',
354
+ },
355
+ verdict: 'stop',
356
+ code: stopCode,
357
+ blast_radius: blastRadius,
358
+ scope: {
359
+ ok: false,
360
+ violations,
361
+ touched_paths: touchedPaths,
362
+ },
363
+ diff: {
364
+ files_changed: blastRadius.files_touched,
365
+ lines_changed: blastRadius.lines_added + blastRadius.lines_deleted,
366
+ diff_patch_path: '',
367
+ },
368
+ verification: {
369
+ exec_mode: 'argv_no_shell',
370
+ runs: [],
371
+ verify_log_path: '',
372
+ },
373
+ budgets: {
374
+ milestone_id: state.task?.milestone_id || 'none',
375
+ ticks: 0,
376
+ orchestrator_calls: 1,
377
+ builder_calls: 1,
378
+ verify_runs: 0,
379
+ estimated_cost_usd: 0,
380
+ warnings: [reason],
381
+ },
382
+ };
383
+ }
384
+ /**
385
+ * Performs rollback and verifies worktree cleanliness.
386
+ *
387
+ * If rollback fails or worktree remains dirty, returns a BLOCKED code.
388
+ * Otherwise returns null (rollback succeeded and worktree is clean).
389
+ *
390
+ * @param baseCommit - Commit to rollback to
391
+ * @param untrackedPaths - Untracked paths to remove
392
+ * @returns RollbackWithCleanCheckResult with blocked code if needed
393
+ */
394
+ function performRollbackWithCleanCheck(baseCommit, untrackedPaths) {
395
+ console.log(`[${TickPhase.JUDGE}] Rolling back to ${baseCommit}`);
396
+ const rollbackResult = rollbackToCommit(baseCommit, untrackedPaths);
397
+ if (!rollbackResult.ok) {
398
+ console.log(`[${TickPhase.JUDGE}] Rollback failed: ${rollbackResult.error}`);
399
+ return {
400
+ blockedCode: 'BLOCKED_ROLLBACK_FAILED',
401
+ reason: `Rollback failed: ${rollbackResult.error}`,
402
+ };
403
+ }
404
+ // Verify worktree is clean after rollback
405
+ const isClean = verifyCleanWorktree();
406
+ if (!isClean) {
407
+ console.log(`[${TickPhase.JUDGE}] Rollback succeeded but worktree is dirty`);
408
+ return {
409
+ blockedCode: 'BLOCKED_ROLLBACK_DIRTY',
410
+ reason: 'Rollback succeeded but worktree remains dirty (uncommitted changes or untracked files)',
411
+ };
412
+ }
413
+ console.log(`[${TickPhase.JUDGE}] Rollback succeeded and worktree is clean`);
414
+ return {
415
+ blockedCode: null,
416
+ reason: null,
417
+ };
418
+ }
419
+ /**
420
+ * Generates a BLOCKED report for rollback failures.
421
+ */
422
+ function generateRollbackBlockedReport(state, blockedCode, reason, blastRadius, touchedPaths) {
423
+ const endedAt = new Date().toISOString();
424
+ const startedAt = new Date(state.started_at);
425
+ const endedAtDate = new Date(endedAt);
426
+ const durationMs = endedAtDate.getTime() - startedAt.getTime();
427
+ return {
428
+ run_id: state.run_id,
429
+ started_at: state.started_at,
430
+ ended_at: endedAt,
431
+ duration_ms: durationMs,
432
+ base_commit: state.base_commit,
433
+ head_commit: state.base_commit,
434
+ task: state.task
435
+ ? {
436
+ task_id: state.task.task_id,
437
+ milestone_id: state.task.milestone_id,
438
+ task_kind: state.task.task_kind,
439
+ intent: state.task.intent,
440
+ }
441
+ : {
442
+ task_id: 'none',
443
+ milestone_id: 'none',
444
+ task_kind: 'execute',
445
+ intent: 'No task',
446
+ },
447
+ verdict: 'blocked',
448
+ code: blockedCode,
449
+ blast_radius: blastRadius,
450
+ scope: {
451
+ ok: false,
452
+ violations: [],
453
+ touched_paths: touchedPaths,
454
+ },
455
+ diff: {
456
+ files_changed: blastRadius.files_touched,
457
+ lines_changed: blastRadius.lines_added + blastRadius.lines_deleted,
458
+ diff_patch_path: '',
459
+ },
460
+ verification: {
461
+ exec_mode: 'argv_no_shell',
462
+ runs: [],
463
+ verify_log_path: '',
464
+ },
465
+ budgets: {
466
+ milestone_id: state.task?.milestone_id || 'none',
467
+ ticks: 0,
468
+ orchestrator_calls: 1,
469
+ builder_calls: 1,
470
+ verify_runs: 0,
471
+ estimated_cost_usd: 0,
472
+ warnings: [reason],
473
+ },
474
+ };
475
+ }
476
+ /**
477
+ * Runs a single verification command with timeout.
478
+ *
479
+ * @param cmd - Command to execute
480
+ * @param args - Command arguments
481
+ * @param timeoutSeconds - Timeout in seconds
482
+ * @returns VerifyCommandResult
483
+ */
484
+ async function runVerifyCommand(cmd, args, timeoutSeconds) {
485
+ const startTime = Date.now();
486
+ return new Promise((resolve) => {
487
+ let stdout = '';
488
+ let stderr = '';
489
+ let timedOut = false;
490
+ const child = spawn(cmd, args, {
491
+ stdio: ['ignore', 'pipe', 'pipe'],
492
+ timeout: timeoutSeconds * 1000,
493
+ });
494
+ child.stdout?.on('data', (data) => {
495
+ stdout += data.toString();
496
+ });
497
+ child.stderr?.on('data', (data) => {
498
+ stderr += data.toString();
499
+ });
500
+ const timer = setTimeout(() => {
501
+ timedOut = true;
502
+ child.kill('SIGTERM');
503
+ }, timeoutSeconds * 1000);
504
+ child.on('close', (code) => {
505
+ clearTimeout(timer);
506
+ const durationMs = Date.now() - startTime;
507
+ resolve({
508
+ ok: code === 0 && !timedOut,
509
+ exitCode: code ?? -1,
510
+ timedOut,
511
+ stdout: stdout.slice(-2000),
512
+ stderr: stderr.slice(-2000),
513
+ durationMs,
514
+ });
515
+ });
516
+ child.on('error', (err) => {
517
+ clearTimeout(timer);
518
+ const durationMs = Date.now() - startTime;
519
+ resolve({
520
+ ok: false,
521
+ exitCode: -1,
522
+ timedOut: false,
523
+ stdout: '',
524
+ stderr: err.message,
525
+ durationMs,
526
+ });
527
+ });
528
+ });
529
+ }
530
+ /**
531
+ * Expands a verification template with params.
532
+ *
533
+ * @param template - Template from config
534
+ * @param params - Params from task verification
535
+ * @returns Expanded command and args
536
+ */
537
+ function expandTemplate(template, params) {
538
+ const expandArg = (arg) => {
539
+ return arg.replace(/\{\{(\w+)\}\}/g, (_, key) => {
540
+ const value = params[key];
541
+ return value !== null && value !== undefined ? String(value) : '';
542
+ });
543
+ };
544
+ return {
545
+ cmd: template.cmd,
546
+ args: template.args.map(expandArg),
547
+ };
548
+ }
549
+ /**
550
+ * Runs verification commands for a phase (fast or slow).
551
+ *
552
+ * @param templateIds - Template IDs to run
553
+ * @param templates - Available templates from config
554
+ * @param params - Params from task
555
+ * @param phase - 'fast' or 'slow'
556
+ * @param timeoutSeconds - Timeout per command
557
+ * @returns VerificationPhaseResult
558
+ */
559
+ async function runVerificationPhase(templateIds, templates, params, phase, timeoutSeconds) {
560
+ const runs = [];
561
+ for (const templateId of templateIds) {
562
+ const template = templates.find(t => t.id === templateId);
563
+ if (!template) {
564
+ console.log(`[VERIFY] Template not found: ${templateId}`);
565
+ continue;
566
+ }
567
+ const templateParams = params[templateId] || {};
568
+ const expanded = expandTemplate(template, templateParams);
569
+ console.log(`[VERIFY] Running ${phase}: ${expanded.cmd} ${expanded.args.join(' ')}`);
570
+ const result = await runVerifyCommand(expanded.cmd, expanded.args, timeoutSeconds);
571
+ runs.push({
572
+ template_id: templateId,
573
+ phase,
574
+ cmd: expanded.cmd,
575
+ args: expanded.args,
576
+ exit_code: result.exitCode,
577
+ duration_ms: result.durationMs,
578
+ timed_out: result.timedOut,
579
+ });
580
+ if (result.timedOut) {
581
+ return {
582
+ ok: false,
583
+ stopCode: 'STOP_VERIFY_FLAKY_OR_TIMEOUT',
584
+ runs,
585
+ reason: `Verification timed out: ${templateId} (${timeoutSeconds}s)`,
586
+ };
587
+ }
588
+ if (!result.ok) {
589
+ const stopCode = phase === 'fast' ? 'STOP_VERIFY_FAILED_FAST' : 'STOP_VERIFY_FAILED_SLOW';
590
+ return {
591
+ ok: false,
592
+ stopCode,
593
+ runs,
594
+ reason: `Verification failed: ${templateId} (exit code ${result.exitCode})`,
595
+ };
596
+ }
597
+ }
598
+ return {
599
+ ok: true,
600
+ stopCode: null,
601
+ runs,
602
+ reason: null,
603
+ };
604
+ }
605
+ /**
606
+ * Executes one complete tick of the envoi loop.
607
+ *
608
+ * Phases:
609
+ * 1. LOCK: Acquire lock to prevent concurrent runs
610
+ * 2. PREFLIGHT: Run safety checks
611
+ * 3. ORCHESTRATE: Get task from orchestrator (placeholder)
612
+ * 4. BUILD: Execute task via builder (placeholder)
613
+ * 5. JUDGE: Validate changes and run verifications (placeholder)
614
+ * 6. REPORT: Generate REPORT.json and REPORT.md
615
+ * 7. END: Release lock and return report
616
+ *
617
+ * @param config - Envoi configuration
618
+ * @param signal - Optional AbortSignal for cancellation
619
+ * @returns Report data for this tick
620
+ */
621
+ /**
622
+ * Sets up SIGINT/SIGTERM handlers for graceful shutdown.
623
+ * Must be called after lock acquisition and cleaned up before lock release.
624
+ * Also notifies the loop to stop after this tick completes.
625
+ */
626
+ function setupSignalHandlers() {
627
+ const sigintHandler = () => {
628
+ console.log('\nSIGINT received during tick');
629
+ requestStop(); // Notify loop to stop after this tick
630
+ };
631
+ const sigtermHandler = () => {
632
+ console.log('\nSIGTERM received during tick');
633
+ requestStop(); // Notify loop to stop after this tick
634
+ };
635
+ process.on('SIGINT', sigintHandler);
636
+ process.on('SIGTERM', sigtermHandler);
637
+ return () => {
638
+ process.off('SIGINT', sigintHandler);
639
+ process.off('SIGTERM', sigtermHandler);
640
+ };
641
+ }
642
+ export async function runTick(config, signal) {
643
+ let state;
644
+ let lockAcquired = false;
645
+ let signalCleanup = null;
646
+ let currentBranchName = null;
647
+ const lockPath = config.runner.lockfile;
648
+ activeTickTokenUsage = { orchestrator: null, builder: null };
649
+ try {
650
+ // Phase 1: LOCK
651
+ console.log(`[${TickPhase.LOCK}] Acquiring lock...`);
652
+ try {
653
+ const lockInfo = await acquireLock(lockPath);
654
+ lockAcquired = true;
655
+ console.log(`[${TickPhase.LOCK}] Lock acquired (PID: ${lockInfo.pid})`);
656
+ // Install signal handlers AFTER lock acquisition
657
+ signalCleanup = setupSignalHandlers();
658
+ // Initialize state without touching git yet.
659
+ // Preflight is responsible for git checks (and for deriving base_commit).
660
+ state = createInitialState(config, '');
661
+ }
662
+ catch (error) {
663
+ if (error instanceof LockHeldError) {
664
+ const report = generateReport({
665
+ phase: TickPhase.LOCK,
666
+ run_id: 'lock-failed',
667
+ started_at: new Date().toISOString(),
668
+ base_commit: '',
669
+ config,
670
+ task: null,
671
+ builder_result: null,
672
+ errors: [],
673
+ }, 'BLOCKED_LOCK_HELD', 'blocked');
674
+ report.budgets.ticks = 1;
675
+ const blockedData = buildBlockedData('BLOCKED_LOCK_HELD', 'Another process is holding the lock');
676
+ await persistRunArtifacts({ config, report, blockedData });
677
+ return report;
678
+ }
679
+ throw error;
680
+ }
681
+ // Phase 2: PREFLIGHT
682
+ console.log(`[${TickPhase.PREFLIGHT}] Running preflight checks...`);
683
+ state = transitionPhase(state, TickPhase.PREFLIGHT);
684
+ const preflightResult = await runPreflight(config);
685
+ if (!preflightResult.ok) {
686
+ // Preflight failed - cleanup handlers and release lock
687
+ if (signalCleanup) {
688
+ signalCleanup();
689
+ signalCleanup = null;
690
+ }
691
+ if (lockAcquired) {
692
+ await releaseLock(lockPath);
693
+ lockAcquired = false;
694
+ }
695
+ const blockedCode = preflightResult.blocked_code || 'BLOCKED_MISSING_CONFIG';
696
+ const report = generateReport({
697
+ ...state,
698
+ errors: preflightResult.blocked_reason
699
+ ? [preflightResult.blocked_reason]
700
+ : [],
701
+ }, blockedCode, 'blocked');
702
+ report.budgets.ticks = 1;
703
+ const blockedData = buildBlockedData(blockedCode, preflightResult.blocked_reason || 'Preflight check failed');
704
+ await persistRunArtifacts({ config, report, blockedData });
705
+ return report;
706
+ }
707
+ if (preflightResult.warnings.length > 0) {
708
+ console.log(`[${TickPhase.PREFLIGHT}] Warnings:`);
709
+ for (const warning of preflightResult.warnings) {
710
+ console.log(` - ${warning}`);
711
+ }
712
+ }
713
+ if (preflightResult.base_commit) {
714
+ // Update base commit from preflight if available
715
+ state = {
716
+ ...state,
717
+ base_commit: preflightResult.base_commit,
718
+ };
719
+ }
720
+ console.log(`[${TickPhase.PREFLIGHT}] Preflight passed (base: ${state.base_commit})`);
721
+ // Phase 3: ORCHESTRATE
722
+ console.log(`[${TickPhase.ORCHESTRATE}] Running orchestrator...`);
723
+ state = transitionPhase(state, TickPhase.ORCHESTRATE);
724
+ let orchestratorResult;
725
+ try {
726
+ orchestratorResult = await runOrchestrator(state, signal);
727
+ activeTickTokenUsage.orchestrator = orchestratorResult.tokenUsage ?? null;
728
+ console.log(`[${TickPhase.ORCHESTRATE}] Tokens: ${formatTokenUsageForLog(orchestratorResult.tokenUsage)}`);
729
+ }
730
+ catch (error) {
731
+ // Check if this is a transport stall
732
+ if (isTransportStallError(error)) {
733
+ console.log(`[${TickPhase.ORCHESTRATE}] Transport stall detected`);
734
+ const stallResult = await handleTransportStall(error, state.base_commit);
735
+ if (lockAcquired) {
736
+ await releaseLock(lockPath);
737
+ lockAcquired = false;
738
+ }
739
+ const report = generateStallReport(state, stallResult);
740
+ report.budgets.ticks = 1;
741
+ report.budgets.orchestrator_calls = 1;
742
+ const blockedData = buildBlockedData('BLOCKED_TRANSPORT_STALLED', `Transport stalled during orchestrator: ${stallResult.requestId || 'unknown'}`);
743
+ await persistRunArtifacts({ config, report, blockedData });
744
+ return report;
745
+ }
746
+ // Check if this is an orchestrator timeout
747
+ if (isTimeoutError(error)) {
748
+ console.log(`[${TickPhase.ORCHESTRATE}] Orchestrator timeout detected`);
749
+ if (lockAcquired) {
750
+ await releaseLock(lockPath);
751
+ lockAcquired = false;
752
+ }
753
+ // Get configured timeout for display
754
+ const timeoutSeconds = config.orchestrator.timeout_seconds ?? config.runner.max_tick_seconds;
755
+ const timeoutDisplay = `${timeoutSeconds}s`;
756
+ const report = generateReport(state, 'STOP_ORCHESTRATOR_TIMEOUT', 'stop');
757
+ report.budgets.ticks = 1;
758
+ report.budgets.orchestrator_calls = 1;
759
+ report.task.intent = `Orchestrator timed out after ${timeoutDisplay}`;
760
+ report.budgets.warnings.push(`Orchestrator timed out after ${timeoutDisplay}`);
761
+ await persistRunArtifacts({ config, report });
762
+ return report;
763
+ }
764
+ // Not a stall or timeout - rethrow
765
+ throw error;
766
+ }
767
+ if (!orchestratorResult.success || !orchestratorResult.task) {
768
+ // Orchestrator failed - release lock and return blocked report
769
+ if (lockAcquired) {
770
+ await releaseLock(lockPath);
771
+ lockAcquired = false;
772
+ }
773
+ // Build BLOCKED.json with diagnostics
774
+ const schemaErrors = orchestratorResult.diagnostics?.schemaErrors?.map((e) => ({
775
+ instancePath: e.instancePath,
776
+ schemaPath: e.schemaPath,
777
+ keyword: e.keyword,
778
+ params: e.params,
779
+ message: e.message,
780
+ }));
781
+ const blockedData = buildOrchestratorBlockedData(orchestratorResult.error || 'Orchestrator output invalid', {
782
+ schema_errors: schemaErrors,
783
+ stdout_excerpt: (orchestratorResult.rawCliStdout ?? orchestratorResult.rawResponse ?? '').slice(-2000),
784
+ stderr_excerpt: (orchestratorResult.rawStderr ?? '').slice(-2000),
785
+ json_excerpt: (() => {
786
+ const extracted = orchestratorResult.diagnostics?.extractedJson;
787
+ if (extracted !== undefined && extracted !== null) {
788
+ try {
789
+ return JSON.stringify(extracted).slice(0, 1000);
790
+ }
791
+ catch {
792
+ // fall through
793
+ }
794
+ }
795
+ return (orchestratorResult.rawCliStdout ?? orchestratorResult.rawResponse ?? '').slice(0, 1000);
796
+ })(),
797
+ extract_method: orchestratorResult.diagnostics?.extractMethod || 'direct_parse',
798
+ });
799
+ // Generate REPORT.json with correct orchestrator_calls and warnings
800
+ const report = generateReport({
801
+ ...state,
802
+ errors: orchestratorResult.error ? [orchestratorResult.error] : [],
803
+ }, 'BLOCKED_ORCHESTRATOR_OUTPUT_INVALID', 'blocked');
804
+ report.budgets.ticks = 1;
805
+ report.budgets.orchestrator_calls = orchestratorResult.attempts;
806
+ report.budgets.warnings.push(`Orchestrator output invalid after ${orchestratorResult.attempts} attempt(s): ${orchestratorResult.error || 'unknown error'}`);
807
+ // Persist orchestrator failure artifacts for debugging
808
+ try {
809
+ const meta = {
810
+ run_id: state.run_id,
811
+ phase: 'orchestrator',
812
+ model: config.models.orchestrator_model,
813
+ timeout_ms: config.runner.max_tick_seconds * 1000,
814
+ prompt_chars: 0, // Not available at this level
815
+ system_prompt_chars: 0, // Not available at this level
816
+ cwd: process.cwd(),
817
+ args_summary_redacted: `--max-turns ${config.orchestrator.max_turns} --permission-mode ${config.orchestrator.permission_mode} --model <model>`,
818
+ };
819
+ await persistOrchestratorFailure(state.run_id, orchestratorResult.rawCliStdout ?? orchestratorResult.rawResponse, orchestratorResult.rawStderr, orchestratorResult.diagnostics?.extractedJson ?? null, orchestratorResult.diagnostics?.schemaErrors ?? null, meta, config);
820
+ // Add pointer to history artifacts in warnings
821
+ report.budgets.warnings.push(`Orchestrator output invalid; see ${config.workspace_dir}/history/${state.run_id}/orchestrator/`);
822
+ }
823
+ catch (persistError) {
824
+ console.warn(`Failed to persist orchestrator failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
825
+ }
826
+ await persistRunArtifacts({ config, report, blockedData });
827
+ console.log(`[${TickPhase.ORCHESTRATE}] Artifacts persisted`);
828
+ return report;
829
+ }
830
+ // Update state with the task from orchestrator
831
+ state = {
832
+ ...state,
833
+ task: orchestratorResult.task,
834
+ };
835
+ console.log(`[${TickPhase.ORCHESTRATE}] Task proposed: ${orchestratorResult.task.task_id} (${orchestratorResult.task.task_kind})`);
836
+ const task = orchestratorResult.task;
837
+ // Persist planning metadata and milestone context early for crash tolerance.
838
+ let wsState = await readWorkspaceState(config.workspace_dir);
839
+ wsState = applyPlanningDecisionToWorkspaceState(wsState, task);
840
+ wsState = upsertOpenProductQuestion(wsState, task, state.run_id);
841
+ if (task.milestone_id) {
842
+ const result = ensureMilestone(wsState, task.milestone_id);
843
+ wsState = result.state;
844
+ if (result.changed) {
845
+ console.log(`Milestone persisted early: ${task.milestone_id}`);
846
+ }
847
+ }
848
+ await writeWorkspaceState(config.workspace_dir, wsState);
849
+ // control.action='stop' acts as completion stop for non-question tasks.
850
+ if (task.task_kind !== 'question' && task.control?.action === 'stop') {
851
+ console.log(`[${TickPhase.ORCHESTRATE}] Control action: stop (reason: ${task.control.reason || 'none'})`);
852
+ if (lockAcquired) {
853
+ await releaseLock(lockPath);
854
+ lockAcquired = false;
855
+ }
856
+ const report = generateReport(state, 'SUCCESS', 'success');
857
+ report.budgets.ticks = 1;
858
+ report.budgets.orchestrator_calls = 1;
859
+ report.budgets.warnings.push(`Orchestrator signaled stop: ${task.control.reason || 'no reason given'}`);
860
+ await persistRunArtifacts({ config, report });
861
+ return report;
862
+ }
863
+ // Question tasks: ask the user and stop immediately (no builder).
864
+ if (task.task_kind === 'question') {
865
+ // Safety: question tasks must have zero side effects. If anything changed between base_commit and now,
866
+ // rollback and STOP with STOP_QUESTION_SIDE_EFFECTS.
867
+ const touched = getTouchedFiles(state.base_commit);
868
+ if (touched.all.length > 0) {
869
+ const blastRadius = computeBlastRadius(state.base_commit, touched);
870
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
871
+ if (rollbackCheck.blockedCode) {
872
+ if (lockAcquired) {
873
+ await releaseLock(lockPath);
874
+ lockAcquired = false;
875
+ }
876
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
877
+ report.budgets.ticks = 1;
878
+ report.budgets.orchestrator_calls = orchestratorResult.attempts;
879
+ report.budgets.builder_calls = 0;
880
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
881
+ await persistRunArtifacts({ config, report, blockedData });
882
+ return report;
883
+ }
884
+ if (lockAcquired) {
885
+ await releaseLock(lockPath);
886
+ lockAcquired = false;
887
+ }
888
+ const report = generateJudgeStopReport(state, 'STOP_QUESTION_SIDE_EFFECTS', blastRadius, touched.all, touched.all, 'Question task had side effects (git diff not empty)');
889
+ report.budgets.ticks = 1;
890
+ report.budgets.orchestrator_calls = orchestratorResult.attempts;
891
+ report.budgets.builder_calls = 0;
892
+ await persistRunArtifacts({ config, report });
893
+ return report;
894
+ }
895
+ const prompt = task.question?.prompt ?? '(missing question.prompt)';
896
+ const choices = Array.isArray(task.question?.choices) ? task.question.choices : [];
897
+ console.log('\n[QUESTION]');
898
+ console.log(prompt);
899
+ if (choices.length > 0) {
900
+ console.log('\nChoices:');
901
+ for (const c of choices)
902
+ console.log(`- ${c}`);
903
+ }
904
+ console.log('');
905
+ if (lockAcquired) {
906
+ await releaseLock(lockPath);
907
+ lockAcquired = false;
908
+ }
909
+ const report = generateReport(state, 'STOP_ORCHESTRATOR_ASK_QUESTION', 'stop');
910
+ report.budgets.ticks = 1;
911
+ report.budgets.orchestrator_calls = orchestratorResult.attempts;
912
+ report.budgets.builder_calls = 0;
913
+ report.task.intent = `Orchestrator asked a question:\n${prompt}${choices.length > 0 ? `\n\nChoices:\n${choices.map((c) => `- ${c}`).join('\n')}` : ''}`;
914
+ report.budgets.warnings.push('Answer the question, then rerun: envoi tick');
915
+ await persistRunArtifacts({ config, report });
916
+ return report;
917
+ }
918
+ // Optional reviewer gate (Second Brain) before builder execution.
919
+ if (task.task_kind === 'execute' && config.reviewer?.enabled && config.reviewer.trigger) {
920
+ const emptyAnalysis = {
921
+ files_touched: 0,
922
+ lines_added: 0,
923
+ lines_deleted: 0,
924
+ new_files: 0,
925
+ touched_paths: [],
926
+ };
927
+ const currentTick = wsState.budgets.ticks + 1;
928
+ const riskFlags = computeRiskFlags({
929
+ analysis: emptyAnalysis,
930
+ limits: task.diff_limits,
931
+ scope: task.scope,
932
+ trigger: config.reviewer.trigger,
933
+ stopHistory: [],
934
+ currentTick,
935
+ verifyFailed: false,
936
+ budgetWarning: wsState.budget_warning,
937
+ });
938
+ const reviewerResult = await runReviewerIfNeeded(config, {
939
+ riskFlags,
940
+ task,
941
+ stopHistory: [],
942
+ currentTick,
943
+ verifyFailed: false,
944
+ budgetWarning: wsState.budget_warning,
945
+ touchedPaths: [],
946
+ });
947
+ if (reviewerResult.stopCode) {
948
+ if (lockAcquired) {
949
+ await releaseLock(lockPath);
950
+ lockAcquired = false;
951
+ }
952
+ if (reviewerResult.stopCode === 'STOP_REVIEWER_ASK_QUESTION' && reviewerResult.question) {
953
+ console.log('\n[REVIEW QUESTION]');
954
+ console.log(reviewerResult.question.prompt);
955
+ if (reviewerResult.question.choices && reviewerResult.question.choices.length > 0) {
956
+ console.log('\nChoices:');
957
+ for (const choice of reviewerResult.question.choices) {
958
+ console.log(`- ${choice}`);
959
+ }
960
+ }
961
+ console.log('');
962
+ }
963
+ const report = generateReport(state, reviewerResult.stopCode, 'stop');
964
+ report.budgets.ticks = 1;
965
+ report.budgets.orchestrator_calls = orchestratorResult.attempts;
966
+ report.budgets.builder_calls = 0;
967
+ if (reviewerResult.reviewerError) {
968
+ report.reviewer_error = reviewerResult.reviewerError;
969
+ report.budgets.warnings.push(`Reviewer error: ${compactWarningText(reviewerResult.reviewerError)}`);
970
+ }
971
+ if (reviewerResult.stopCode === 'STOP_REVIEWER_FORCED_PATCH') {
972
+ report.budgets.warnings.push('Reviewer requested manual intervention before builder execution.');
973
+ }
974
+ else if (reviewerResult.stopCode === 'STOP_REVIEWER_ASK_QUESTION') {
975
+ report.budgets.warnings.push('Reviewer asked a product question; answer then rerun.');
976
+ }
977
+ await persistRunArtifacts({ config, report });
978
+ return report;
979
+ }
980
+ }
981
+ // Branching: Create/switch branch before BUILD (runner-owned)
982
+ if (config.git?.branching && config.git.branching.mode !== 'off' && task.task_kind === 'execute') {
983
+ console.log(`[BRANCHING] Ensuring branch for mode=${config.git.branching.mode}...`);
984
+ let branchResult;
985
+ let branchingError = null;
986
+ // Validate config based on mode
987
+ if (config.git.branching.mode === 'per_n_tasks') {
988
+ if (!config.git.branching.n_tasks || config.git.branching.n_tasks < 1) {
989
+ branchingError = `per_n_tasks mode requires n_tasks >= 1, but got ${config.git.branching.n_tasks}`;
990
+ }
991
+ else {
992
+ // Calculate batch index: use builder_calls as proxy for task count
993
+ // Each execute task results in a builder call
994
+ const batchIndex = Math.floor(wsState.budgets.builder_calls / config.git.branching.n_tasks);
995
+ branchResult = ensureBranchPerNTasks(config.git.branching, {
996
+ task_id: task.task_id,
997
+ milestone_id: task.milestone_id,
998
+ run_id: state.run_id,
999
+ tick_count: wsState.budgets.ticks + 1,
1000
+ seq: batchIndex,
1001
+ batch_index: batchIndex,
1002
+ });
1003
+ }
1004
+ }
1005
+ else if (config.git.branching.mode === 'per_milestone') {
1006
+ if (!task.milestone_id) {
1007
+ branchingError = 'per_milestone mode requires task.milestone_id';
1008
+ }
1009
+ else {
1010
+ branchResult = ensureBranchPerMilestone(config.git.branching, {
1011
+ task_id: task.task_id,
1012
+ milestone_id: task.milestone_id,
1013
+ run_id: state.run_id,
1014
+ tick_count: wsState.budgets.ticks + 1,
1015
+ });
1016
+ }
1017
+ }
1018
+ else if (config.git.branching.mode === 'per_tick') {
1019
+ branchResult = ensureBranchPerTick(config.git.branching, {
1020
+ task_id: task.task_id,
1021
+ milestone_id: task.milestone_id,
1022
+ run_id: state.run_id,
1023
+ tick_count: wsState.budgets.ticks + 1,
1024
+ });
1025
+ }
1026
+ else {
1027
+ // Unknown mode - should not happen due to TypeScript, but handle gracefully
1028
+ branchingError = `Unsupported branching mode: ${config.git.branching.mode}`;
1029
+ }
1030
+ // Handle branching errors or failures
1031
+ if (branchingError || (branchResult && !branchResult.ok)) {
1032
+ const errorMsg = branchingError || branchResult?.error || 'Unknown branching error';
1033
+ // Branching failed or config invalid - return BLOCKED report
1034
+ if (lockAcquired) {
1035
+ await releaseLock(lockPath);
1036
+ lockAcquired = false;
1037
+ }
1038
+ const report = generateReport({
1039
+ ...state,
1040
+ errors: [`Branch creation/switch failed: ${errorMsg}`],
1041
+ }, 'BLOCKED_BRANCH_FAILED', 'blocked');
1042
+ report.budgets.ticks = 1;
1043
+ report.budgets.orchestrator_calls = 1;
1044
+ report.budgets.warnings.push(`Failed to create/switch branch: ${errorMsg}. ` +
1045
+ `Check git repository state and ensure branching configuration is valid.`);
1046
+ const blockedData = buildBlockedData('BLOCKED_BRANCH_FAILED', `Branch creation/switch failed: ${errorMsg}`);
1047
+ await persistRunArtifacts({ config, report, blockedData });
1048
+ return report;
1049
+ }
1050
+ if (branchResult) {
1051
+ currentBranchName = branchResult.branchName;
1052
+ console.log(`[BRANCHING] Branch ensured: ${currentBranchName} (existed: ${branchResult.existed})`);
1053
+ }
1054
+ }
1055
+ // Phase 4: BUILD
1056
+ console.log(`[${TickPhase.BUILD}] Running builder...`);
1057
+ if (isDebugEnabled() && state.task) {
1058
+ console.log(`[BUILD_DEBUG] task_id=${state.task.task_id}, task_kind=${state.task.task_kind}, max_turns=${state.task.builder?.max_turns ?? 'N/A'}`);
1059
+ }
1060
+ state = transitionPhase(state, TickPhase.BUILD);
1061
+ if (!state.task) {
1062
+ // This should not happen, but handle gracefully
1063
+ if (lockAcquired) {
1064
+ await releaseLock(lockPath);
1065
+ lockAcquired = false;
1066
+ }
1067
+ const report = generateReport({
1068
+ ...state,
1069
+ errors: ['No task available for builder'],
1070
+ }, 'STOP_INTERRUPTED', 'stop');
1071
+ report.budgets.ticks = 1;
1072
+ report.budgets.orchestrator_calls = 1;
1073
+ await persistRunArtifacts({ config, report });
1074
+ return report;
1075
+ }
1076
+ let builderResult;
1077
+ try {
1078
+ builderResult = await runBuilder(state, state.task, signal);
1079
+ activeTickTokenUsage.builder = builderResult.tokenUsage ?? null;
1080
+ console.log(`[${TickPhase.BUILD}] Tokens: ${formatTokenUsageForLog(builderResult.tokenUsage)}`);
1081
+ }
1082
+ catch (error) {
1083
+ // Check if this is a transport stall
1084
+ if (isTransportStallError(error)) {
1085
+ console.log(`[${TickPhase.BUILD}] Transport stall detected`);
1086
+ const stallResult = await handleTransportStall(error, state.base_commit);
1087
+ if (lockAcquired) {
1088
+ await releaseLock(lockPath);
1089
+ lockAcquired = false;
1090
+ }
1091
+ const report = generateStallReport(state, stallResult);
1092
+ report.budgets.ticks = 1;
1093
+ report.budgets.orchestrator_calls = 1;
1094
+ report.budgets.builder_calls = 1;
1095
+ const blockedData = buildBlockedData('BLOCKED_TRANSPORT_STALLED', `Transport stalled during builder: ${stallResult.requestId || 'unknown'}`);
1096
+ await persistRunArtifacts({ config, report, blockedData });
1097
+ return report;
1098
+ }
1099
+ // Not a stall - rethrow
1100
+ throw error;
1101
+ }
1102
+ if (!builderResult.success) {
1103
+ // Builder invocation failed - release lock and return stopped report
1104
+ if (lockAcquired) {
1105
+ await releaseLock(lockPath);
1106
+ lockAcquired = false;
1107
+ }
1108
+ // Check for cursor missing config - this is a deterministic BLOCKED condition
1109
+ if (builderResult.validationErrors.includes('STOP_CURSOR_CONFIG_MISSING')) {
1110
+ // Persist builder failure artifacts for debugging
1111
+ if (builderResult.rawResponse) {
1112
+ try {
1113
+ await persistBuilderFailure(state.run_id, builderResult.rawResponse, null, // stderr not available from builder
1114
+ {
1115
+ kind: 'cli_error',
1116
+ message: builderResult.validationErrors.join('; ') || 'Builder invocation failed',
1117
+ details: { validationErrors: builderResult.validationErrors },
1118
+ }, config);
1119
+ }
1120
+ catch (persistError) {
1121
+ // Log but don't fail the tick due to persistence issues
1122
+ console.warn(`Failed to persist builder failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
1123
+ }
1124
+ }
1125
+ const report = generateReport({
1126
+ ...state,
1127
+ builder_result: builderResult.result,
1128
+ errors: ['Builder mode is cursor but cursor config is missing'],
1129
+ }, 'BLOCKED_MISSING_CONFIG', 'blocked');
1130
+ report.budgets.ticks = 1;
1131
+ report.budgets.orchestrator_calls = 1;
1132
+ report.budgets.builder_calls = 1;
1133
+ report.budgets.warnings.push('Task requested builder.mode="cursor" but envoi.config.json does not have builder.cursor configured. ' +
1134
+ 'Either configure builder.cursor in envoi.config.json or change the task to use a different builder mode.');
1135
+ const blockedData = buildBlockedData('BLOCKED_MISSING_CONFIG', 'Cursor builder mode selected but cursor config is missing. Configure builder.cursor in envoi.config.json or use a different builder mode.');
1136
+ await persistRunArtifacts({ config, report, blockedData });
1137
+ return report;
1138
+ }
1139
+ // Check if validationErrors contains a known BLOCKED_* report code
1140
+ const explicitBlockedCode = builderResult.validationErrors.find((err) => isValidReportCode(err) && err.startsWith('BLOCKED_'));
1141
+ if (explicitBlockedCode) {
1142
+ // Persist builder failure artifacts for debugging
1143
+ if (builderResult.rawResponse) {
1144
+ try {
1145
+ await persistBuilderFailure(state.run_id, builderResult.rawResponse, null, // stderr not available from builder
1146
+ {
1147
+ kind: 'cli_error',
1148
+ message: builderResult.validationErrors.join('; ') || 'Builder invocation failed',
1149
+ details: { validationErrors: builderResult.validationErrors },
1150
+ }, config);
1151
+ }
1152
+ catch (persistError) {
1153
+ // Log but don't fail the tick due to persistence issues
1154
+ console.warn(`Failed to persist builder failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
1155
+ }
1156
+ }
1157
+ const report = generateReport({
1158
+ ...state,
1159
+ builder_result: builderResult.result,
1160
+ errors: builderResult.rawResponse
1161
+ ? [`Builder preflight failed: ${builderResult.rawResponse.substring(0, 200)}`]
1162
+ : ['Builder preflight failed'],
1163
+ }, explicitBlockedCode, 'blocked');
1164
+ report.budgets.ticks = 1;
1165
+ report.budgets.orchestrator_calls = 1;
1166
+ report.budgets.builder_calls = 1;
1167
+ if (builderResult.rawResponse) {
1168
+ report.budgets.warnings.push(compactWarningText(builderResult.rawResponse));
1169
+ }
1170
+ const blockedData = buildBlockedData(explicitBlockedCode, builderResult.rawResponse || 'Builder preflight failed');
1171
+ await persistRunArtifacts({ config, report, blockedData });
1172
+ return report;
1173
+ }
1174
+ // Prefer explicit STOP_* codes from validationErrors over parseErrorKind mapping
1175
+ let reportCode = 'STOP_INTERRUPTED';
1176
+ // Check if validationErrors contains a known STOP_* report code
1177
+ const explicitStopCode = builderResult.validationErrors.find((err) => isValidReportCode(err) && err.startsWith('STOP_'));
1178
+ if (explicitStopCode) {
1179
+ reportCode = explicitStopCode;
1180
+ }
1181
+ else if (builderResult.parseErrorKind) {
1182
+ // Fall back to parseErrorKind mapping if no explicit code found
1183
+ switch (builderResult.parseErrorKind) {
1184
+ case 'json_parse':
1185
+ reportCode = 'STOP_BUILDER_JSON_PARSE';
1186
+ break;
1187
+ case 'schema':
1188
+ reportCode = 'STOP_BUILDER_SCHEMA_INVALID';
1189
+ break;
1190
+ case 'shape':
1191
+ reportCode = 'STOP_BUILDER_SHAPE_INVALID';
1192
+ break;
1193
+ case 'cli_error':
1194
+ reportCode = 'STOP_BUILDER_CLI_ERROR';
1195
+ break;
1196
+ }
1197
+ }
1198
+ // Persist builder failure artifacts for debugging
1199
+ if (builderResult.rawResponse) {
1200
+ try {
1201
+ await persistBuilderFailure(state.run_id, builderResult.rawResponse, null, // stderr not available from builder
1202
+ {
1203
+ kind: builderResult.parseErrorKind ?? 'cli_error',
1204
+ message: builderResult.validationErrors.join('; ') || 'Builder invocation failed',
1205
+ details: { validationErrors: builderResult.validationErrors },
1206
+ }, config);
1207
+ }
1208
+ catch (persistError) {
1209
+ // Log but don't fail the tick due to persistence issues
1210
+ console.warn(`Failed to persist builder failure: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
1211
+ }
1212
+ }
1213
+ const report = generateReport({
1214
+ ...state,
1215
+ builder_result: builderResult.result,
1216
+ errors: builderResult.rawResponse
1217
+ ? [`Builder invocation failed: ${builderResult.rawResponse.substring(0, 200)}`]
1218
+ : ['Builder invocation failed'],
1219
+ }, reportCode, 'stop');
1220
+ report.budgets.ticks = 1;
1221
+ report.budgets.orchestrator_calls = 1;
1222
+ report.budgets.builder_calls = 1;
1223
+ await persistRunArtifacts({ config, report });
1224
+ return report;
1225
+ }
1226
+ // Update state with builder result
1227
+ state = {
1228
+ ...state,
1229
+ builder_result: builderResult.result,
1230
+ };
1231
+ if (builderResult.builderOutputValid) {
1232
+ console.log(`[${TickPhase.BUILD}] Builder completed successfully`);
1233
+ }
1234
+ else {
1235
+ console.log(`[${TickPhase.BUILD}] Builder completed but output was invalid JSON (lenient mode)`);
1236
+ }
1237
+ // Phase 5: JUDGE
1238
+ console.log(`[${TickPhase.JUDGE}] Running judge phase...`);
1239
+ state = transitionPhase(state, TickPhase.JUDGE);
1240
+ // Step 1: Check if HEAD moved externally
1241
+ const headCheck = checkHeadMoved(state.base_commit);
1242
+ if (!headCheck.ok) {
1243
+ console.log(`[${TickPhase.JUDGE}] HEAD moved externally`);
1244
+ if (lockAcquired) {
1245
+ await releaseLock(lockPath);
1246
+ lockAcquired = false;
1247
+ }
1248
+ const report = generateJudgeStopReport(state, 'STOP_HEAD_MOVED', { files_touched: 0, lines_added: 0, lines_deleted: 0, new_files: 0 }, [], [], headCheck.reason || 'HEAD moved externally');
1249
+ report.budgets.ticks = 1;
1250
+ report.budgets.orchestrator_calls = 1;
1251
+ report.budgets.builder_calls = 1;
1252
+ await persistRunArtifacts({ config, report });
1253
+ return report;
1254
+ }
1255
+ // Step 2: Get touched files
1256
+ let touched;
1257
+ try {
1258
+ touched = getTouchedFiles(state.base_commit);
1259
+ }
1260
+ catch (error) {
1261
+ console.log(`[${TickPhase.JUDGE}] Failed to get touched files: ${error}`);
1262
+ if (lockAcquired) {
1263
+ await releaseLock(lockPath);
1264
+ lockAcquired = false;
1265
+ }
1266
+ const report = generateReport(state, 'STOP_INTERRUPTED', 'stop');
1267
+ report.budgets.ticks = 1;
1268
+ report.budgets.orchestrator_calls = 1;
1269
+ report.budgets.builder_calls = 1;
1270
+ await persistRunArtifacts({ config, report });
1271
+ return report;
1272
+ }
1273
+ console.log(`[${TickPhase.JUDGE}] Touched files: ${touched.all.length}`);
1274
+ // Step 3: Compute blast radius
1275
+ let blastRadius;
1276
+ try {
1277
+ blastRadius = computeBlastRadius(state.base_commit, touched);
1278
+ }
1279
+ catch (error) {
1280
+ console.log(`[${TickPhase.JUDGE}] Failed to compute blast radius: ${error}`);
1281
+ blastRadius = { files_touched: touched.all.length, lines_added: 0, lines_deleted: 0, new_files: 0 };
1282
+ }
1283
+ console.log(`[${TickPhase.JUDGE}] Blast radius: ${blastRadius.files_touched} files, ${blastRadius.lines_added}+ ${blastRadius.lines_deleted}- lines`);
1284
+ // Step 4: Check scope violations
1285
+ if (state.task) {
1286
+ const scopeCheck = checkScopeViolations(touched, state.task.scope, config.scope, config.runner.runner_owned_globs);
1287
+ if (!scopeCheck.ok && scopeCheck.stopCode) {
1288
+ console.log(`[${TickPhase.JUDGE}] Scope violation: ${scopeCheck.stopCode}`);
1289
+ // Rollback and check cleanliness
1290
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
1291
+ if (rollbackCheck.blockedCode) {
1292
+ // Rollback failed or worktree dirty - return BLOCKED
1293
+ if (lockAcquired) {
1294
+ await releaseLock(lockPath);
1295
+ lockAcquired = false;
1296
+ }
1297
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
1298
+ report.budgets.ticks = 1;
1299
+ report.budgets.orchestrator_calls = 1;
1300
+ report.budgets.builder_calls = 1;
1301
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
1302
+ await persistRunArtifacts({ config, report, blockedData });
1303
+ return report;
1304
+ }
1305
+ // Rollback succeeded and worktree is clean - proceed with STOP code
1306
+ if (lockAcquired) {
1307
+ await releaseLock(lockPath);
1308
+ lockAcquired = false;
1309
+ }
1310
+ const report = generateJudgeStopReport(state, scopeCheck.stopCode, blastRadius, touched.all, scopeCheck.violatingFiles, scopeCheck.reason || 'Scope violation');
1311
+ report.budgets.ticks = 1;
1312
+ report.budgets.orchestrator_calls = 1;
1313
+ report.budgets.builder_calls = 1;
1314
+ await persistRunArtifacts({ config, report });
1315
+ return report;
1316
+ }
1317
+ }
1318
+ // Step 5: Check diff limits
1319
+ const diffLimits = state.task?.diff_limits || {
1320
+ max_files_touched: config.diff_limits.default_max_files_touched,
1321
+ max_lines_changed: config.diff_limits.default_max_lines_changed,
1322
+ };
1323
+ const diffCheck = checkDiffLimits(blastRadius, diffLimits);
1324
+ if (!diffCheck.ok && diffCheck.stopCode) {
1325
+ console.log(`[${TickPhase.JUDGE}] Diff too large: ${diffCheck.reason}`);
1326
+ // Rollback and check cleanliness
1327
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
1328
+ if (rollbackCheck.blockedCode) {
1329
+ // Rollback failed or worktree dirty - return BLOCKED
1330
+ if (lockAcquired) {
1331
+ await releaseLock(lockPath);
1332
+ lockAcquired = false;
1333
+ }
1334
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
1335
+ report.budgets.ticks = 1;
1336
+ report.budgets.orchestrator_calls = 1;
1337
+ report.budgets.builder_calls = 1;
1338
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
1339
+ await persistRunArtifacts({ config, report, blockedData });
1340
+ return report;
1341
+ }
1342
+ // Rollback succeeded and worktree is clean - proceed with STOP code
1343
+ if (lockAcquired) {
1344
+ await releaseLock(lockPath);
1345
+ lockAcquired = false;
1346
+ }
1347
+ const report = generateJudgeStopReport(state, diffCheck.stopCode, blastRadius, touched.all, [], diffCheck.reason || 'Diff too large');
1348
+ report.budgets.ticks = 1;
1349
+ report.budgets.orchestrator_calls = 1;
1350
+ report.budgets.builder_calls = 1;
1351
+ await persistRunArtifacts({ config, report });
1352
+ return report;
1353
+ }
1354
+ // Step 6: Check task_kind side effects
1355
+ if (state.task && touched.all.length > 0) {
1356
+ if (state.task.task_kind === 'question') {
1357
+ console.log(`[${TickPhase.JUDGE}] Question task has side effects`);
1358
+ // Rollback and check cleanliness
1359
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
1360
+ if (rollbackCheck.blockedCode) {
1361
+ // Rollback failed or worktree dirty - return BLOCKED
1362
+ if (lockAcquired) {
1363
+ await releaseLock(lockPath);
1364
+ lockAcquired = false;
1365
+ }
1366
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
1367
+ report.budgets.ticks = 1;
1368
+ report.budgets.orchestrator_calls = 1;
1369
+ report.budgets.builder_calls = 1;
1370
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
1371
+ await persistRunArtifacts({ config, report, blockedData });
1372
+ return report;
1373
+ }
1374
+ // Rollback succeeded and worktree is clean - proceed with STOP code
1375
+ if (lockAcquired) {
1376
+ await releaseLock(lockPath);
1377
+ lockAcquired = false;
1378
+ }
1379
+ const report = generateJudgeStopReport(state, 'STOP_QUESTION_SIDE_EFFECTS', blastRadius, touched.all, touched.all, 'Question task made file changes');
1380
+ report.budgets.ticks = 1;
1381
+ report.budgets.orchestrator_calls = 1;
1382
+ report.budgets.builder_calls = 1;
1383
+ await persistRunArtifacts({ config, report });
1384
+ return report;
1385
+ }
1386
+ if (state.task.task_kind === 'verify_only') {
1387
+ console.log(`[${TickPhase.JUDGE}] Verify-only task has side effects`);
1388
+ // Rollback and check cleanliness
1389
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
1390
+ if (rollbackCheck.blockedCode) {
1391
+ // Rollback failed or worktree dirty - return BLOCKED
1392
+ if (lockAcquired) {
1393
+ await releaseLock(lockPath);
1394
+ lockAcquired = false;
1395
+ }
1396
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
1397
+ report.budgets.ticks = 1;
1398
+ report.budgets.orchestrator_calls = 1;
1399
+ report.budgets.builder_calls = 1;
1400
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
1401
+ await persistRunArtifacts({ config, report, blockedData });
1402
+ return report;
1403
+ }
1404
+ // Rollback succeeded and worktree is clean - proceed with STOP code
1405
+ if (lockAcquired) {
1406
+ await releaseLock(lockPath);
1407
+ lockAcquired = false;
1408
+ }
1409
+ const report = generateJudgeStopReport(state, 'STOP_VERIFY_ONLY_SIDE_EFFECTS', blastRadius, touched.all, touched.all, 'Verify-only task made file changes');
1410
+ report.budgets.ticks = 1;
1411
+ report.budgets.orchestrator_calls = 1;
1412
+ report.budgets.builder_calls = 1;
1413
+ await persistRunArtifacts({ config, report });
1414
+ return report;
1415
+ }
1416
+ }
1417
+ console.log(`[${TickPhase.JUDGE}] All checks passed`);
1418
+ // Step 7: Validate verification params
1419
+ if (state.task && state.task.verification) {
1420
+ const allParams = {};
1421
+ const taskParams = state.task.verification.params || {};
1422
+ for (const [templateId, templateParams] of Object.entries(taskParams)) {
1423
+ for (const [key, value] of Object.entries(templateParams)) {
1424
+ if (typeof value === 'string') {
1425
+ allParams[`${templateId}.${key}`] = value;
1426
+ }
1427
+ }
1428
+ }
1429
+ if (Object.keys(allParams).length > 0) {
1430
+ const paramCheck = validateAllParams(allParams, config.verification);
1431
+ if (!paramCheck.ok) {
1432
+ console.log(`[${TickPhase.JUDGE}] Verification params tainted: ${paramCheck.reason}`);
1433
+ // Rollback and check cleanliness
1434
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
1435
+ if (rollbackCheck.blockedCode) {
1436
+ // Rollback failed or worktree dirty - return BLOCKED
1437
+ if (lockAcquired) {
1438
+ await releaseLock(lockPath);
1439
+ lockAcquired = false;
1440
+ }
1441
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
1442
+ report.budgets.ticks = 1;
1443
+ report.budgets.orchestrator_calls = 1;
1444
+ report.budgets.builder_calls = 1;
1445
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
1446
+ await persistRunArtifacts({ config, report, blockedData });
1447
+ return report;
1448
+ }
1449
+ // Rollback succeeded and worktree is clean - proceed with STOP code
1450
+ if (lockAcquired) {
1451
+ await releaseLock(lockPath);
1452
+ lockAcquired = false;
1453
+ }
1454
+ const report = generateJudgeStopReport(state, 'STOP_VERIFY_TAINTED', blastRadius, touched.all, [], paramCheck.reason || 'Verification params tainted');
1455
+ report.budgets.ticks = 1;
1456
+ report.budgets.orchestrator_calls = 1;
1457
+ report.budgets.builder_calls = 1;
1458
+ await persistRunArtifacts({ config, report });
1459
+ return report;
1460
+ }
1461
+ }
1462
+ }
1463
+ // Step 8: Run fast verification
1464
+ if (state.task?.verification?.fast && state.task.verification.fast.length > 0) {
1465
+ console.log(`[${TickPhase.JUDGE}] Running fast verification...`);
1466
+ const fastResult = await runVerificationPhase(state.task.verification.fast, config.verification.templates, state.task.verification.params || {}, 'fast', config.verification.timeout_fast_seconds);
1467
+ if (!fastResult.ok && fastResult.stopCode) {
1468
+ console.log(`[${TickPhase.JUDGE}] Fast verification failed: ${fastResult.reason}`);
1469
+ // Rollback and check cleanliness
1470
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
1471
+ if (rollbackCheck.blockedCode) {
1472
+ // Rollback failed or worktree dirty - return BLOCKED
1473
+ if (lockAcquired) {
1474
+ await releaseLock(lockPath);
1475
+ lockAcquired = false;
1476
+ }
1477
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
1478
+ report.verification.runs = fastResult.runs;
1479
+ report.budgets.ticks = 1;
1480
+ report.budgets.orchestrator_calls = 1;
1481
+ report.budgets.builder_calls = 1;
1482
+ report.budgets.verify_runs = fastResult.runs.length;
1483
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
1484
+ await persistRunArtifacts({ config, report, blockedData });
1485
+ return report;
1486
+ }
1487
+ // Rollback succeeded and worktree is clean - proceed with STOP code
1488
+ if (lockAcquired) {
1489
+ await releaseLock(lockPath);
1490
+ lockAcquired = false;
1491
+ }
1492
+ const report = generateJudgeStopReport(state, fastResult.stopCode, blastRadius, touched.all, [], fastResult.reason || 'Fast verification failed');
1493
+ report.verification.runs = fastResult.runs;
1494
+ report.budgets.ticks = 1;
1495
+ report.budgets.orchestrator_calls = 1;
1496
+ report.budgets.builder_calls = 1;
1497
+ report.budgets.verify_runs = fastResult.runs.length;
1498
+ await persistRunArtifacts({ config, report });
1499
+ return report;
1500
+ }
1501
+ console.log(`[${TickPhase.JUDGE}] Fast verification passed`);
1502
+ }
1503
+ // Step 9: Run slow verification (only if fast passed)
1504
+ if (state.task?.verification?.slow && state.task.verification.slow.length > 0) {
1505
+ console.log(`[${TickPhase.JUDGE}] Running slow verification...`);
1506
+ const slowResult = await runVerificationPhase(state.task.verification.slow, config.verification.templates, state.task.verification.params || {}, 'slow', config.verification.timeout_slow_seconds);
1507
+ if (!slowResult.ok && slowResult.stopCode) {
1508
+ console.log(`[${TickPhase.JUDGE}] Slow verification failed: ${slowResult.reason}`);
1509
+ // Rollback and check cleanliness
1510
+ const rollbackCheck = performRollbackWithCleanCheck(state.base_commit, touched.untracked);
1511
+ if (rollbackCheck.blockedCode) {
1512
+ // Rollback failed or worktree dirty - return BLOCKED
1513
+ if (lockAcquired) {
1514
+ await releaseLock(lockPath);
1515
+ lockAcquired = false;
1516
+ }
1517
+ const report = generateRollbackBlockedReport(state, rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty', blastRadius, touched.all);
1518
+ report.verification.runs = slowResult.runs;
1519
+ // Count fast verification runs that passed (from state.task.verification.fast)
1520
+ const fastVerifyCount = state.task?.verification?.fast?.length || 0;
1521
+ report.budgets.ticks = 1;
1522
+ report.budgets.orchestrator_calls = 1;
1523
+ report.budgets.builder_calls = 1;
1524
+ report.budgets.verify_runs = fastVerifyCount + slowResult.runs.length;
1525
+ const blockedData = buildBlockedData(rollbackCheck.blockedCode, rollbackCheck.reason || 'Rollback failed or worktree dirty');
1526
+ await persistRunArtifacts({ config, report, blockedData });
1527
+ return report;
1528
+ }
1529
+ // Rollback succeeded and worktree is clean - proceed with STOP code
1530
+ if (lockAcquired) {
1531
+ await releaseLock(lockPath);
1532
+ lockAcquired = false;
1533
+ }
1534
+ const report = generateJudgeStopReport(state, slowResult.stopCode, blastRadius, touched.all, [], slowResult.reason || 'Slow verification failed');
1535
+ report.verification.runs = slowResult.runs;
1536
+ // Count fast verification runs that passed (from state.task.verification.fast)
1537
+ const fastVerifyCount = state.task?.verification?.fast?.length || 0;
1538
+ report.budgets.ticks = 1;
1539
+ report.budgets.orchestrator_calls = 1;
1540
+ report.budgets.builder_calls = 1;
1541
+ report.budgets.verify_runs = fastVerifyCount + slowResult.runs.length;
1542
+ await persistRunArtifacts({ config, report });
1543
+ return report;
1544
+ }
1545
+ console.log(`[${TickPhase.JUDGE}] Slow verification passed`);
1546
+ }
1547
+ console.log(`[${TickPhase.JUDGE}] Verification complete`);
1548
+ // Phase 6: REPORT
1549
+ console.log(`[${TickPhase.REPORT}] Generating report...`);
1550
+ state = transitionPhase(state, TickPhase.REPORT);
1551
+ const report = generateReport(state, 'SUCCESS', 'success');
1552
+ // Count verification runs for budgets
1553
+ const fastVerifyCount = state.task?.verification?.fast?.length || 0;
1554
+ const slowVerifyCount = state.task?.verification?.slow?.length || 0;
1555
+ report.budgets.ticks = 1;
1556
+ report.budgets.orchestrator_calls = 1;
1557
+ report.budgets.builder_calls = 1;
1558
+ report.budgets.verify_runs = fastVerifyCount + slowVerifyCount;
1559
+ // Add branch name to warnings for traceability (if branching was used)
1560
+ if (currentBranchName) {
1561
+ report.budgets.warnings.push(`Branch: ${currentBranchName}`);
1562
+ }
1563
+ const orchestratorTotal = activeTickTokenUsage?.orchestrator?.total_tokens ?? null;
1564
+ const builderTotal = activeTickTokenUsage?.builder?.total_tokens ?? null;
1565
+ const tickTotal = orchestratorTotal !== null || builderTotal !== null
1566
+ ? (orchestratorTotal ?? 0) + (builderTotal ?? 0)
1567
+ : null;
1568
+ console.log(`[${TickPhase.REPORT}] Token totals: orchestrator=${tokenNumber(orchestratorTotal)} builder=${tokenNumber(builderTotal)} tick_total=${tokenNumber(tickTotal)}`);
1569
+ await persistRunArtifacts({ config, report });
1570
+ console.log(`[${TickPhase.REPORT}] Artifacts persisted`);
1571
+ // Phase 7: END
1572
+ console.log(`[${TickPhase.END}] Releasing lock...`);
1573
+ state = transitionPhase(state, TickPhase.END);
1574
+ // Cleanup signal handlers before releasing lock
1575
+ if (signalCleanup) {
1576
+ signalCleanup();
1577
+ signalCleanup = null;
1578
+ }
1579
+ await releaseLock(lockPath);
1580
+ lockAcquired = false;
1581
+ console.log(`[${TickPhase.END}] Lock released`);
1582
+ return report;
1583
+ }
1584
+ catch (error) {
1585
+ // Cleanup signal handlers before releasing lock
1586
+ if (signalCleanup) {
1587
+ signalCleanup();
1588
+ signalCleanup = null;
1589
+ }
1590
+ // Ensure lock is released on error
1591
+ if (lockAcquired) {
1592
+ try {
1593
+ await releaseLock(lockPath);
1594
+ }
1595
+ catch (releaseError) {
1596
+ console.error(`Failed to release lock: ${releaseError}`);
1597
+ }
1598
+ }
1599
+ // Generate error report
1600
+ const errorState = state || {
1601
+ phase: TickPhase.END,
1602
+ run_id: 'error',
1603
+ started_at: new Date().toISOString(),
1604
+ base_commit: '',
1605
+ config,
1606
+ task: null,
1607
+ builder_result: null,
1608
+ errors: [error instanceof Error ? error.message : String(error)],
1609
+ };
1610
+ const report = generateReport(errorState, 'STOP_INTERRUPTED', 'stop');
1611
+ report.budgets.ticks = 1;
1612
+ // Set budget counts based on which phase we reached
1613
+ if (state) {
1614
+ const phase = state.phase;
1615
+ // If we got past ORCHESTRATE, count orchestrator call
1616
+ if (phase !== TickPhase.LOCK && phase !== TickPhase.PREFLIGHT && phase !== TickPhase.ORCHESTRATE) {
1617
+ report.budgets.orchestrator_calls = 1;
1618
+ }
1619
+ // If we got past BUILD, count builder call
1620
+ if (phase === TickPhase.JUDGE || phase === TickPhase.REPORT || phase === TickPhase.END) {
1621
+ report.budgets.builder_calls = 1;
1622
+ }
1623
+ }
1624
+ // Try to write error report
1625
+ try {
1626
+ await persistRunArtifacts({ config, report });
1627
+ }
1628
+ catch (writeError) {
1629
+ console.error(`Failed to write error report: ${writeError}`);
1630
+ }
1631
+ // For interrupt, return gracefully (don't re-throw)
1632
+ if (isInterruptedError(error)) {
1633
+ console.log('[INTERRUPT] Abort signal received; persisting STOP_INTERRUPTED report');
1634
+ return report;
1635
+ }
1636
+ throw error;
1637
+ }
1638
+ }
1639
+ //# sourceMappingURL=tick.js.map