@alecsibilia/luca 14.0.0-alpha.2 → 14.0.0-alpha.21

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 (321) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +7 -2
  3. package/board/client/board-look.ts +31 -1
  4. package/board/client/board-panel-parts.tsx +150 -0
  5. package/board/client/board-panel-stages.ts +99 -0
  6. package/board/client/board-panel-styles.ts +223 -0
  7. package/board/client/board-panel.tsx +77 -1275
  8. package/board/client/engine-life.ts +48 -0
  9. package/board/client/final-review-stack.tsx +144 -0
  10. package/board/client/heartbeat.tsx +61 -0
  11. package/board/client/helper-lines.tsx +69 -0
  12. package/board/client/live-feed.tsx +86 -0
  13. package/board/client/live-line.ts +73 -0
  14. package/board/client/model-line.tsx +178 -0
  15. package/board/client/needs-you-section.tsx +133 -0
  16. package/board/client/one-line.tsx +193 -0
  17. package/board/client/repo-wait-banner.tsx +69 -0
  18. package/board/client/run-footer.tsx +68 -0
  19. package/board/client/run-header.tsx +189 -0
  20. package/board/client/run-picker.tsx +98 -0
  21. package/board/client/status-bar.tsx +131 -0
  22. package/board/client/step-dots.tsx +127 -0
  23. package/board/client/stuck-actions.tsx +269 -0
  24. package/board/client/ticket-card-view.tsx +238 -0
  25. package/board/client/ticket-detail.tsx +87 -0
  26. package/board/client/ticket-stack.tsx +119 -0
  27. package/board/client/ticket-wait.ts +188 -0
  28. package/board/client/timeline-rows.tsx +41 -14
  29. package/board/client/usage-line.tsx +51 -0
  30. package/board/client/wait-line.tsx +39 -0
  31. package/board/index.client.tsx +1 -1
  32. package/board/index.server.ts +7 -1
  33. package/board/package.json +1 -1
  34. package/board/server/board-panel-state-helpers.ts +67 -0
  35. package/board/server/board-server-check.ts +294 -0
  36. package/board/server/board-server-engine.ts +129 -0
  37. package/board/server/board-server-events.ts +134 -0
  38. package/board/server/board-server-read.ts +190 -0
  39. package/board/server/board-server-resume.ts +164 -0
  40. package/board/server/board-server-start.ts +174 -0
  41. package/board/server/board-server-store.ts +281 -0
  42. package/board/server/board-server-types.ts +114 -0
  43. package/board/server/board-server-util.ts +4 -0
  44. package/board/server/board-server.ts +52 -943
  45. package/board/server/board-vocabulary-engine.ts +144 -0
  46. package/board/server/board-vocabulary-review.ts +115 -0
  47. package/board/server/board-vocabulary-run.ts +145 -0
  48. package/board/server/board-vocabulary-shapes.ts +153 -0
  49. package/board/server/board-vocabulary.ts +20 -375
  50. package/board/server/current-step-panel-helpers.ts +108 -0
  51. package/board/server/describe-record.ts +403 -0
  52. package/board/server/engine-launch.ts +20 -3
  53. package/board/server/engine-restart-helpers.ts +105 -0
  54. package/board/server/engine-watch.ts +45 -5
  55. package/board/server/journal-rebuild-helpers.ts +169 -0
  56. package/board/server/live-file.ts +130 -0
  57. package/board/server/luca-version.ts +1 -1
  58. package/board/server/make-rows.ts +14 -629
  59. package/board/server/record-texts.ts +350 -0
  60. package/board/server/reduce-board-agent-start.ts +224 -0
  61. package/board/server/reduce-board-agents.ts +160 -0
  62. package/board/server/reduce-board-counters.ts +138 -0
  63. package/board/server/reduce-board-create.ts +148 -0
  64. package/board/server/reduce-board-final-review.ts +174 -0
  65. package/board/server/reduce-board-needs-you.ts +283 -0
  66. package/board/server/reduce-board-reasons.ts +59 -0
  67. package/board/server/reduce-board-run.ts +255 -0
  68. package/board/server/reduce-board-settle.ts +88 -0
  69. package/board/server/reduce-board-steps.ts +226 -0
  70. package/board/server/reduce-board-text.ts +139 -0
  71. package/board/server/reduce-board-ticket-checks.ts +168 -0
  72. package/board/server/reduce-board-ticket-join.ts +144 -0
  73. package/board/server/reduce-board-ticket-start.ts +132 -0
  74. package/board/server/reduce-board-update.ts +117 -0
  75. package/board/server/reduce-board-usage.ts +247 -0
  76. package/board/server/reduce-board.ts +100 -1917
  77. package/board/server/reply-post.ts +407 -0
  78. package/board/server/run-registry.ts +8 -4
  79. package/board/server/ticket-stages-panel-helpers.ts +296 -0
  80. package/board/shared/board-rows.ts +9 -0
  81. package/board/shared/board-rpc.ts +42 -0
  82. package/board/shared/board-state-counts.ts +77 -0
  83. package/board/shared/board-state-live.ts +99 -0
  84. package/board/shared/board-state-review.ts +59 -0
  85. package/board/shared/board-state-run.ts +236 -0
  86. package/board/shared/board-state-ticket.ts +257 -0
  87. package/board/shared/board-state-usage.ts +107 -0
  88. package/board/shared/board-state.ts +97 -567
  89. package/board/shared/reply-actions.ts +207 -0
  90. package/engine/agents/agent-launcher.ts +27 -4
  91. package/engine/agents/claude-launcher-helpers.ts +233 -0
  92. package/engine/agents/claude-launcher.ts +28 -669
  93. package/engine/agents/claude-messages.ts +212 -0
  94. package/engine/agents/claude-options.ts +43 -8
  95. package/engine/agents/common-instructions.ts +138 -0
  96. package/engine/agents/input-channel.ts +36 -0
  97. package/engine/agents/launch-paths.ts +29 -0
  98. package/engine/agents/launch-session.ts +227 -0
  99. package/engine/agents/learner-instructions.ts +31 -0
  100. package/engine/agents/open-session.ts +210 -0
  101. package/engine/agents/prompt-sections.ts +95 -0
  102. package/engine/agents/rejoin-prompts.ts +135 -0
  103. package/engine/agents/reviewer-instructions.ts +99 -0
  104. package/engine/agents/role-instructions.ts +31 -244
  105. package/engine/agents/role-prompts.ts +39 -109
  106. package/engine/agents/role-results.ts +106 -7
  107. package/engine/agents/scripted-launcher.ts +21 -6
  108. package/engine/agents/session-pool.ts +124 -0
  109. package/engine/agents/turn-progress.ts +283 -0
  110. package/engine/agents/writer-instructions.ts +151 -0
  111. package/engine/cli/computer-adapters-real.ts +15 -3
  112. package/engine/cli/computer-adapters.ts +2 -0
  113. package/engine/cli/computer-checks.ts +87 -4
  114. package/engine/cli/config-reload-text.ts +39 -0
  115. package/engine/cli/decision-model-setup.ts +70 -0
  116. package/engine/cli/doctor-command.ts +1 -0
  117. package/engine/cli/doctor.ts +21 -3
  118. package/engine/cli/going-runs.ts +192 -36
  119. package/engine/cli/init-command.ts +1 -0
  120. package/engine/cli/init.ts +23 -1
  121. package/engine/cli/live-runs.ts +73 -0
  122. package/engine/cli/luca-run.ts +42 -7
  123. package/engine/cli/luca-skills.ts +183 -0
  124. package/engine/cli/luca.ts +8 -1
  125. package/engine/cli/models-command.ts +119 -0
  126. package/engine/cli/read-run.ts +81 -0
  127. package/engine/cli/run-args.ts +33 -6
  128. package/engine/cli/run-demo.ts +143 -0
  129. package/engine/cli/run-end.ts +184 -0
  130. package/engine/cli/run-modes-helpers.ts +81 -0
  131. package/engine/cli/run-modes.ts +62 -439
  132. package/engine/cli/setup.ts +5 -48
  133. package/engine/cli/unfinished-runs.ts +122 -0
  134. package/engine/cli/upgrade-command.ts +212 -30
  135. package/engine/cli/upgrade-finish.ts +149 -0
  136. package/engine/cli/upgrade.ts +112 -71
  137. package/engine/config/config-fields.ts +127 -0
  138. package/engine/config/config-reload.ts +250 -0
  139. package/engine/config/engine-config-schema.ts +235 -0
  140. package/engine/config/engine-config.ts +72 -142
  141. package/engine/config/test-commands.ts +102 -0
  142. package/engine/core/agent-guards-helpers.ts +84 -0
  143. package/engine/core/agent-turn.ts +395 -0
  144. package/engine/core/already-done.ts +44 -1
  145. package/engine/core/build-action.ts +214 -0
  146. package/engine/core/build-context.ts +135 -0
  147. package/engine/core/build-launch.ts +259 -0
  148. package/engine/core/commit-in.ts +282 -0
  149. package/engine/core/decide-build-early.ts +108 -0
  150. package/engine/core/decide-build-ending.ts +145 -0
  151. package/engine/core/decide-build-helpers.ts +87 -0
  152. package/engine/core/decide-build-tickets.ts +207 -0
  153. package/engine/core/decide-build-view.ts +103 -0
  154. package/engine/core/decide-build.ts +101 -1368
  155. package/engine/core/decide-final-fix.ts +203 -0
  156. package/engine/core/decide-final-review.ts +55 -310
  157. package/engine/core/decide-red-green.ts +398 -0
  158. package/engine/core/decide-rejoin.ts +259 -0
  159. package/engine/core/decide-repo-wait.ts +129 -0
  160. package/engine/core/decide-run-budget.ts +86 -12
  161. package/engine/core/decide-stuck-helpers.ts +127 -0
  162. package/engine/core/decide-stuck.ts +60 -8
  163. package/engine/core/decide-ticket-review.ts +95 -0
  164. package/engine/core/decide-ticket.ts +267 -0
  165. package/engine/core/decide-waits.ts +47 -0
  166. package/engine/core/decide.ts +74 -23
  167. package/engine/core/engine-chores.ts +163 -0
  168. package/engine/core/environment-rerun.ts +83 -0
  169. package/engine/core/evidence-text.ts +84 -0
  170. package/engine/core/execute-action.ts +303 -0
  171. package/engine/core/execute-already-done-check.ts +133 -0
  172. package/engine/core/execute-build.ts +68 -944
  173. package/engine/core/execute-changeset.ts +5 -7
  174. package/engine/core/execute-final-review.ts +125 -13
  175. package/engine/core/execute-gates.ts +182 -0
  176. package/engine/core/execute-intake.ts +130 -0
  177. package/engine/core/execute-join.ts +276 -0
  178. package/engine/core/execute-memory.ts +12 -0
  179. package/engine/core/execute-pull-request.ts +84 -0
  180. package/engine/core/execute-red-check.ts +219 -0
  181. package/engine/core/execute-repo-wait.ts +129 -0
  182. package/engine/core/execute-stuck.ts +25 -10
  183. package/engine/core/execute-waits.ts +158 -0
  184. package/engine/core/execute-with-jev.ts +118 -0
  185. package/engine/core/execute.ts +124 -831
  186. package/engine/core/final-failure-cap.ts +38 -0
  187. package/engine/core/final-review-action.ts +125 -0
  188. package/engine/core/final-review-text.ts +63 -17
  189. package/engine/core/final-stuck-text.ts +86 -0
  190. package/engine/core/fix-loop-text.ts +49 -2
  191. package/engine/core/left-out.ts +120 -0
  192. package/engine/core/live-file.ts +372 -0
  193. package/engine/core/loop-caps.ts +25 -2
  194. package/engine/core/not-in-run.ts +7 -3
  195. package/engine/core/pull-request-text.ts +255 -6
  196. package/engine/core/repo-wait-text.ts +81 -0
  197. package/engine/core/review-text.ts +55 -1
  198. package/engine/core/run-crash-recovery-helpers.ts +16 -0
  199. package/engine/core/run-session-close-helpers.ts +96 -0
  200. package/engine/core/run-start.ts +148 -0
  201. package/engine/core/run-stuck-text.ts +86 -0
  202. package/engine/core/schedule-rules.ts +193 -0
  203. package/engine/core/stuck-detail-text.ts +89 -0
  204. package/engine/core/stuck-format.ts +22 -0
  205. package/engine/core/stuck-text.ts +29 -332
  206. package/engine/core/ticket-stuck-text.ts +192 -0
  207. package/engine/core/worktree-changes.ts +106 -0
  208. package/engine/gates/already-done-check.ts +120 -0
  209. package/engine/gates/feature-map.ts +96 -0
  210. package/engine/gates/gate-runner.ts +108 -108
  211. package/engine/gates/gate-schemas.ts +61 -2
  212. package/engine/gates/leftover-scan.ts +154 -31
  213. package/engine/gates/new-tests.ts +45 -0
  214. package/engine/gates/own-files-check.ts +98 -0
  215. package/engine/gates/prepare-check.ts +60 -0
  216. package/engine/gates/prepare-slots.ts +99 -0
  217. package/engine/gates/red-check.ts +184 -16
  218. package/engine/gates/shell-check.ts +64 -0
  219. package/engine/gates/slot-waits.ts +55 -0
  220. package/engine/gates/slow-test-runner.ts +59 -0
  221. package/engine/gates/test-checks.ts +161 -0
  222. package/engine/gates/test-runner.ts +164 -5
  223. package/engine/gates/test-slots.ts +48 -0
  224. package/engine/git/git-adapter.ts +55 -396
  225. package/engine/git/git-commits.ts +132 -0
  226. package/engine/git/git-join.ts +139 -0
  227. package/engine/git/git-read.ts +123 -0
  228. package/engine/git/git-run.ts +32 -0
  229. package/engine/git/git-worktrees.ts +143 -0
  230. package/engine/git/outside-links.ts +55 -0
  231. package/engine/guards/after-turn-check.ts +31 -5
  232. package/engine/guards/guard-hook.ts +60 -12
  233. package/engine/guards/guard-roles.ts +68 -0
  234. package/engine/guards/helper-rules.ts +92 -0
  235. package/engine/guards/permission-rules.ts +161 -0
  236. package/engine/guards/role-rules-helpers.ts +48 -0
  237. package/engine/guards/role-rules.ts +34 -606
  238. package/engine/guards/shell-rules.ts +344 -0
  239. package/engine/guards/worktree-git.ts +208 -0
  240. package/engine/guards/worktree-snapshot.ts +412 -0
  241. package/engine/guards/worktree-state.ts +28 -825
  242. package/engine/guards/worktree-undo.ts +260 -0
  243. package/engine/guards/write-rules.ts +149 -0
  244. package/engine/index.ts +30 -9
  245. package/engine/intake/criteria-checks.ts +114 -0
  246. package/engine/intake/intake-checks.ts +164 -30
  247. package/engine/intake/intake-schemas.ts +37 -2
  248. package/engine/jev/clef-client.ts +259 -0
  249. package/engine/jev/decision-model-credentials.ts +212 -0
  250. package/engine/jev/jev-jobs.ts +144 -60
  251. package/engine/jev/jev-schemas.ts +86 -5
  252. package/engine/jev/jev-shadow-helpers.ts +218 -0
  253. package/engine/jev/jev-shadow.ts +109 -64
  254. package/engine/jev/model-pick.ts +275 -0
  255. package/engine/journal/agent-entries.ts +167 -0
  256. package/engine/journal/agent-message-entries.ts +72 -0
  257. package/engine/journal/agent-turn-replay.ts +203 -0
  258. package/engine/journal/already-done-entries.ts +55 -0
  259. package/engine/journal/commit-entries.ts +56 -0
  260. package/engine/journal/cross-ticket-replay.ts +222 -0
  261. package/engine/journal/decision-model-entries.ts +86 -0
  262. package/engine/journal/entry-fields.ts +10 -0
  263. package/engine/journal/final-review-entries.ts +128 -0
  264. package/engine/journal/final-review-replay.ts +324 -0
  265. package/engine/journal/final-review-round.ts +320 -0
  266. package/engine/journal/final-review-state.ts +180 -0
  267. package/engine/journal/gate-entries.ts +81 -0
  268. package/engine/journal/intake-entries.ts +87 -0
  269. package/engine/journal/join-entries.ts +126 -0
  270. package/engine/journal/journal-entry-schema.ts +218 -0
  271. package/engine/journal/journal-kinds.ts +97 -0
  272. package/engine/journal/journal-record-schema.ts +218 -0
  273. package/engine/journal/journal-record.ts +43 -1400
  274. package/engine/journal/journal.ts +6 -1
  275. package/engine/journal/limit-entries.ts +90 -0
  276. package/engine/journal/memory-entries.ts +124 -0
  277. package/engine/journal/memory-replay.ts +157 -0
  278. package/engine/journal/plan-replay.ts +139 -0
  279. package/engine/journal/pull-request-entries.ts +41 -0
  280. package/engine/journal/replay.ts +157 -2144
  281. package/engine/journal/reply-replay.ts +144 -0
  282. package/engine/journal/repo-wait-entries.ts +53 -0
  283. package/engine/journal/run-entries.ts +172 -0
  284. package/engine/journal/run-state.ts +264 -0
  285. package/engine/journal/shared-failure-replay.ts +133 -0
  286. package/engine/journal/stuck-entries.ts +193 -0
  287. package/engine/journal/ticket-progress.ts +392 -0
  288. package/engine/journal/ticket-replay.ts +411 -0
  289. package/engine/journal/ticket-result-replay.ts +364 -0
  290. package/engine/journal/ticket-retry-replay.ts +142 -0
  291. package/engine/journal/worktree-entries.ts +122 -0
  292. package/engine/models/model-score.ts +512 -0
  293. package/engine/shell/run-command.ts +187 -18
  294. package/engine/testing/agent-turn-fixtures.ts +287 -0
  295. package/engine/testing/already-done-fixtures.ts +97 -0
  296. package/engine/testing/build-fixtures.ts +68 -973
  297. package/engine/testing/checkouts-fixtures.ts +48 -0
  298. package/engine/testing/final-review-fixtures.ts +62 -1
  299. package/engine/testing/final-review-steps.ts +143 -0
  300. package/engine/testing/limit-fixtures.ts +85 -0
  301. package/engine/testing/live-file-fixtures.ts +57 -0
  302. package/engine/testing/practice-repo.ts +10 -4
  303. package/engine/testing/practice-run.ts +1 -1
  304. package/engine/testing/reply-fixtures.ts +88 -0
  305. package/engine/testing/run-budget-fixtures.ts +165 -0
  306. package/engine/testing/run-fixtures.ts +219 -0
  307. package/engine/testing/sdk-play.ts +177 -0
  308. package/engine/testing/setup-repo.ts +194 -0
  309. package/engine/testing/step-record-fixtures.ts +55 -0
  310. package/engine/testing/ticket-step-fixtures.ts +272 -0
  311. package/engine/testing/wait-scenarios.ts +186 -0
  312. package/engine/tracker/gh-retry.ts +217 -0
  313. package/engine/tracker/github-tracker.ts +310 -90
  314. package/engine/tracker/in-memory-tracker.ts +8 -4
  315. package/engine/tracker/tracker.ts +131 -6
  316. package/package.json +2 -1
  317. package/skills/luca-retro/SKILL.md +74 -0
  318. package/skills/luca-retro/scripts/retro-summary.ts +1356 -0
  319. package/skills/luca-unstick/SKILL.md +153 -0
  320. package/skills/luca-unstick/scripts/stuck-summary.ts +1063 -0
  321. package/engine/jev/jev-client.ts +0 -173
@@ -2,10 +2,12 @@ import groupBy from 'lodash/groupBy'
2
2
  import sortBy from 'lodash/sortBy'
3
3
  import uniq from 'lodash/uniq'
4
4
 
5
+ import { criteriaProblems } from './criteria-checks'
5
6
  import type {
6
7
  IntakeOutcome,
7
8
  IntakeProblem,
8
9
  IntakeRead,
10
+ LeftOutTicket,
9
11
  TicketSnapshot,
10
12
  } from './intake-schemas'
11
13
 
@@ -15,6 +17,7 @@ import {
15
17
  type EngineConfig,
16
18
  } from '../config/engine-config'
17
19
  import {
20
+ HUMAN_LABEL,
18
21
  READY_LABEL,
19
22
  REFACTOR_LABEL,
20
23
  RELEASE_LABEL_PREFIX,
@@ -94,6 +97,88 @@ export const outsideBlockerNumbers = ({
94
97
 
95
98
  type Finding = { ticket: number | null; message: string }
96
99
 
100
+ /** Whether a ticket is for a person (#499): it has `ready-for-human`. */
101
+ export const isForAPerson = ({ issue }: { issue: TrackerIssue }): boolean =>
102
+ issue.labels.includes(HUMAN_LABEL)
103
+
104
+ /**
105
+ * The open tickets intake leaves out of the run (#499), by number: each one
106
+ * for a person, and each other one blocked by one, directly or through
107
+ * other open tickets of the spec. A ticket for a person stops the walk: what
108
+ * blocks it doesn't matter to the tickets that wait on it. Pure.
109
+ *
110
+ * @example
111
+ * leftOutTickets({ open: [agent11, human12, agent13BlockedBy12] })
112
+ * // [{ number: 12, reason: 'for_a_person', ... },
113
+ * // { number: 13, reason: 'waits_on_person', waits_on: [12], through: [], ... }]
114
+ */
115
+ export const leftOutTickets = ({
116
+ open,
117
+ }: {
118
+ /** The spec's open sub-tickets. */
119
+ open: TrackerIssue[]
120
+ }): LeftOutTicket[] => {
121
+ const byNumber = new Map(open.map((issue) => [issue.number, issue]))
122
+ const blockersOf = (issue: TrackerIssue): TrackerIssue[] =>
123
+ blockerNumbers({ issue }).flatMap((number) => {
124
+ const blocker = byNumber.get(number)
125
+ return blocker === undefined ? [] : [blocker]
126
+ })
127
+ const found = new Map<number, number[]>()
128
+ // The tickets for a person that `issue` waits on. A blocker loop among
129
+ // agent tickets refuses the run, so a loop just stops the walk here.
130
+ const waitsOn = (issue: TrackerIssue, walking: number[]): number[] => {
131
+ const known = found.get(issue.number)
132
+ if (known !== undefined) return known
133
+ if (walking.includes(issue.number)) return []
134
+ const people = sortBy(
135
+ uniq(
136
+ blockersOf(issue).flatMap((blocker) =>
137
+ isForAPerson({ issue: blocker })
138
+ ? [blocker.number]
139
+ : waitsOn(blocker, [...walking, issue.number])
140
+ )
141
+ )
142
+ )
143
+ found.set(issue.number, people)
144
+ return people
145
+ }
146
+ return sortBy(open, 'number').flatMap((issue): LeftOutTicket[] => {
147
+ const { number, title, url } = issue
148
+ if (isForAPerson({ issue })) {
149
+ return [
150
+ {
151
+ number,
152
+ title,
153
+ url,
154
+ reason: 'for_a_person',
155
+ waits_on: [],
156
+ through: [],
157
+ },
158
+ ]
159
+ }
160
+ const waits_on = waitsOn(issue, [])
161
+ if (waits_on.length === 0) return []
162
+ const through = blockersOf(issue)
163
+ .filter(
164
+ (blocker) =>
165
+ !isForAPerson({ issue: blocker }) &&
166
+ waitsOn(blocker, []).length > 0
167
+ )
168
+ .map((blocker) => blocker.number)
169
+ return [
170
+ {
171
+ number,
172
+ title,
173
+ url,
174
+ reason: 'waits_on_person',
175
+ waits_on,
176
+ through: sortBy(through),
177
+ },
178
+ ]
179
+ })
180
+ }
181
+
97
182
  /**
98
183
  * Walks blockers depth-first, blockers before the tickets they block. Returns
99
184
  * that order and every loop found, as a path such as `[12, 13, 12]`.
@@ -142,14 +227,61 @@ const groupFindings = ({
142
227
  }))
143
228
  }
144
229
 
230
+ /**
231
+ * Intake's checks on the engine config, as messages for a person: a test
232
+ * command, and one with bun's per-test results unless every ticket in
233
+ * `tickets` is a refactor ticket (the red check reads per-test results,
234
+ * which only bun's give today). Pure. Empty when the config is fine.
235
+ *
236
+ * Intake runs it on the open tickets agents build; a resume runs it on the
237
+ * repo's config file before taking its new build fields (#516).
238
+ *
239
+ * @example
240
+ * configProblems({ config: { ...config, checks: {} }, tickets: [] })
241
+ * // ['The engine config (.luca/config.json) has no test command at checks.test.']
242
+ */
243
+ export const configProblems = ({
244
+ config,
245
+ tickets,
246
+ }: {
247
+ config: EngineConfig
248
+ tickets: { labels: string[] }[]
249
+ }): string[] => {
250
+ const tests = testCommands({ config })
251
+ if (tests.length === 0) {
252
+ return [
253
+ `The engine config (${ENGINE_CONFIG_FILE}) has no test command at checks.test.`,
254
+ ]
255
+ }
256
+ if (
257
+ !tests.some(({ results }) => results === 'bun') &&
258
+ tickets.some((ticket) => !ticket.labels.includes(REFACTOR_LABEL))
259
+ ) {
260
+ return [
261
+ `The engine config (${ENGINE_CONFIG_FILE}) has no test command with bun results at checks.test, so the red check can't prove new tests fail first. Add a \`bun test\` command, or label every open ticket \`${REFACTOR_LABEL}\`.`,
262
+ ]
263
+ }
264
+ return []
265
+ }
266
+
145
267
  /**
146
268
  * Intake's checks: is the spec, and every open ticket in it, ready to build?
147
269
  *
148
270
  * Pure. Every miss refuses the whole run, and every problem is collected (not
149
271
  * just the first), grouped by the spec or ticket it is on. Config and spec
150
- * problems refuse even an empty spec; otherwise a spec with no open tickets
151
- * has nothing to do. When everything passes, the result is the snapshot the
152
- * run builds from, with tickets in blocker order.
272
+ * problems refuse even an empty spec; otherwise a spec with nothing left to
273
+ * build has nothing to do. When everything passes, the result is the
274
+ * snapshot the run builds from, with tickets in blocker order.
275
+ *
276
+ * Tickets for a person (#499): an open ticket with `ready-for-human` (even
277
+ * with `ready-for-agent` too) is left out of the run, unchecked. An agent
278
+ * ticket that waits on one, directly or through other tickets, is left out
279
+ * too, but still checked, so a later run can build it. The run builds the
280
+ * rest. A ticket with neither ready label is still refused.
281
+ *
282
+ * Each open agent ticket's acceptance criteria must hold no placeholders, no
283
+ * untestable asks (#541), and nothing about behaviour that doesn't change
284
+ * (#653), per `criteriaProblems`; the spec's text isn't checked.
153
285
  */
154
286
  export const checkIntake = ({
155
287
  config,
@@ -160,28 +292,19 @@ export const checkIntake = ({
160
292
  }): IntakeOutcome => {
161
293
  const { spec, sub_tickets, outside_blockers } = intake_read
162
294
  const findings: Finding[] = []
295
+ const open = sortBy(
296
+ sub_tickets.filter((ticket) => ticket.state === 'open'),
297
+ 'number'
298
+ )
299
+ const left_out = leftOutTickets({ open })
300
+ const leftOut = new Set(left_out.map(({ number }) => number))
301
+ // Every open ticket an agent builds: in this run, or once a person is done.
302
+ const forAgents = open.filter((issue) => !isForAPerson({ issue }))
163
303
  const onSpec = (message: string) =>
164
304
  findings.push({ ticket: spec.number, message })
165
305
 
166
- const tests = testCommands({ config })
167
- if (tests.length === 0) {
168
- findings.push({
169
- ticket: null,
170
- message: `The engine config (${ENGINE_CONFIG_FILE}) has no test command at checks.test.`,
171
- })
172
- } else if (
173
- !tests.some(({ results }) => results === 'bun') &&
174
- sub_tickets.some(
175
- (ticket) =>
176
- ticket.state === 'open' &&
177
- !ticket.labels.includes(REFACTOR_LABEL)
178
- )
179
- ) {
180
- // The red check reads per-test results, which only bun's give today.
181
- findings.push({
182
- ticket: null,
183
- message: `The engine config (${ENGINE_CONFIG_FILE}) has no test command with bun results at checks.test, so the red check can't prove new tests fail first. Add a \`bun test\` command, or label every open ticket \`${REFACTOR_LABEL}\`.`,
184
- })
306
+ for (const message of configProblems({ config, tickets: forAgents })) {
307
+ findings.push({ ticket: null, message })
185
308
  }
186
309
  if (spec.state !== 'open') onSpec('The spec is closed.')
187
310
  if (section({ body: spec.body, heading: 'Testing Decisions' }) === '') {
@@ -196,10 +319,6 @@ export const checkIntake = ({
196
319
  )
197
320
  }
198
321
 
199
- const open = sortBy(
200
- sub_tickets.filter((ticket) => ticket.state === 'open'),
201
- 'number'
202
- )
203
322
  const closedTickets = sortBy(
204
323
  sub_tickets
205
324
  .filter((ticket) => ticket.state === 'closed')
@@ -211,7 +330,7 @@ export const checkIntake = ({
211
330
  outside_blockers.map((issue) => [issue.number, issue])
212
331
  )
213
332
 
214
- const tickets: TicketSnapshot[] = open.map((ticket) => {
333
+ const tickets: TicketSnapshot[] = forAgents.map((ticket) => {
215
334
  const onTicket = (message: string) =>
216
335
  findings.push({ ticket: ticket.number, message })
217
336
  if (section({ body: ticket.body, heading: 'What to build' }) === '') {
@@ -228,6 +347,12 @@ export const checkIntake = ({
228
347
  if (criteria.length === 0) {
229
348
  onTicket('The ticket has no checkbox under "Acceptance criteria".')
230
349
  }
350
+ for (const message of criteriaProblems({
351
+ ticket: ticket.number,
352
+ criteria,
353
+ })) {
354
+ onTicket(message)
355
+ }
231
356
  if (!ticket.labels.includes(READY_LABEL)) {
232
357
  onTicket(`The ticket does not have the ${READY_LABEL} label.`)
233
358
  }
@@ -273,10 +398,18 @@ export const checkIntake = ({
273
398
  if (findings.length > 0) {
274
399
  return { outcome: 'refused', problems: groupFindings({ findings }) }
275
400
  }
276
- if (tickets.length === 0) {
277
- return { outcome: 'nothing_to_do', closed_tickets: closedTickets }
401
+ const byNumber = new Map(
402
+ tickets
403
+ .filter((ticket) => !leftOut.has(ticket.number))
404
+ .map((ticket) => [ticket.number, ticket])
405
+ )
406
+ if (byNumber.size === 0) {
407
+ return {
408
+ outcome: 'nothing_to_do',
409
+ closed_tickets: closedTickets,
410
+ left_out,
411
+ }
278
412
  }
279
- const byNumber = new Map(tickets.map((ticket) => [ticket.number, ticket]))
280
413
  return {
281
414
  outcome: 'passed',
282
415
  snapshot: {
@@ -290,6 +423,7 @@ export const checkIntake = ({
290
423
  },
291
424
  tickets: order.flatMap((number) => byNumber.get(number) ?? []),
292
425
  closed_tickets: closedTickets,
426
+ left_out,
293
427
  },
294
428
  }
295
429
  }
@@ -62,11 +62,41 @@ export const TicketSnapshotSchema = z.object({
62
62
 
63
63
  export type TicketSnapshot = z.infer<typeof TicketSnapshotSchema>
64
64
 
65
- /** The spec and its open tickets, in blocker order (blockers first). */
65
+ /**
66
+ * Why intake left an open ticket out of the run (#499): `for_a_person`, as
67
+ * it has the `ready-for-human` label, or `waits_on_person`, as it is
68
+ * blocked, directly or through other tickets, by an open one that has.
69
+ */
70
+ export const LeftOutReasonSchema = z.enum(['for_a_person', 'waits_on_person'])
71
+
72
+ export type LeftOutReason = z.infer<typeof LeftOutReasonSchema>
73
+
74
+ /** An open ticket intake left out of the run, and why (#499). */
75
+ export const LeftOutTicketSchema = z.object({
76
+ number: z.number().int().positive(),
77
+ title: z.string(),
78
+ url: z.string().default(''),
79
+ reason: LeftOutReasonSchema,
80
+ /** For `waits_on_person`: the tickets for a person it waits on. */
81
+ waits_on: z.array(z.number().int().positive()).default([]),
82
+ /**
83
+ * For `waits_on_person`: its own blockers that wait on a person too,
84
+ * when it waits through them rather than (or as well as) directly.
85
+ */
86
+ through: z.array(z.number().int().positive()).default([]),
87
+ })
88
+
89
+ export type LeftOutTicket = z.infer<typeof LeftOutTicketSchema>
90
+
91
+ /**
92
+ * The spec and its open tickets, in blocker order (blockers first), and
93
+ * the open tickets left out for a person (#499), by number.
94
+ */
66
95
  export const IntakeSnapshotSchema = z.object({
67
96
  spec: SpecSnapshotSchema,
68
97
  tickets: z.array(TicketSnapshotSchema),
69
98
  closed_tickets: z.array(z.number().int().positive()),
99
+ left_out: z.array(LeftOutTicketSchema).default([]),
70
100
  })
71
101
 
72
102
  export type IntakeSnapshot = z.infer<typeof IntakeSnapshotSchema>
@@ -74,5 +104,10 @@ export type IntakeSnapshot = z.infer<typeof IntakeSnapshotSchema>
74
104
  /** The result of intake's checks. */
75
105
  export type IntakeOutcome =
76
106
  | { outcome: 'refused'; problems: IntakeProblem[] }
77
- | { outcome: 'nothing_to_do'; closed_tickets: number[] }
107
+ | {
108
+ outcome: 'nothing_to_do'
109
+ closed_tickets: number[]
110
+ /** Open tickets left out for a person (#499): none left to build. */
111
+ left_out: LeftOutTicket[]
112
+ }
78
113
  | { outcome: 'passed'; snapshot: IntakeSnapshot }
@@ -0,0 +1,259 @@
1
+ import mapValues from 'lodash/mapValues'
2
+ import { z } from 'zod'
3
+
4
+ import type { JevAnswer, JevFailureReason, JevRequest } from './jev-schemas'
5
+
6
+ /** Workers AI's REST API: `<base>/<account id>/ai/run/<model id>`. */
7
+ const WORKERS_AI_ACCOUNTS_URL = 'https://api.cloudflare.com/client/v4/accounts'
8
+
9
+ /** How much of an error body a failed call keeps. */
10
+ const ERROR_SNIPPET_CHARS = 300
11
+
12
+ /**
13
+ * The decision model's answers by question id, and the model that gave
14
+ * them; or why there are none. Never a throw.
15
+ */
16
+ export type DecisionModelReply =
17
+ | {
18
+ ok: true
19
+ answers: Record<string, JevAnswer>
20
+ /** The model that answered; left out, the client's own. */
21
+ model?: string
22
+ }
23
+ | { ok: false; reason: JevFailureReason; error: string }
24
+
25
+ /** Asks the decision model one request. The signal cuts the call short. */
26
+ export type DecisionModelClient = {
27
+ /** The model it asks, such as `@cf/cloudflare/clef`; journaled on each ask. */
28
+ model: string
29
+ ask: (args: {
30
+ request: JevRequest
31
+ signal: AbortSignal
32
+ }) => Promise<DecisionModelReply>
33
+ }
34
+
35
+ /** The part of `fetch` the client uses, so tests can hand in a fake. */
36
+ export type DecisionModelFetch = (
37
+ url: string,
38
+ init: RequestInit
39
+ ) => Promise<Response>
40
+
41
+ /** The Cloudflare account and Workers AI token the client calls with. */
42
+ export type ClefCredentials = { account_id: string; api_token: string }
43
+
44
+ /**
45
+ * Clef's output: its answers by question id, and the model's short name.
46
+ * Workers AI wraps it as `{ result, success, errors, messages }`, so it is
47
+ * read inside `result` or bare.
48
+ */
49
+ const ClefOutputSchema = z.object({
50
+ model: z.string().min(1).nullish().catch(null),
51
+ answers: z.record(z.string(), z.unknown()),
52
+ })
53
+
54
+ const EnvelopeSchema = z.object({ result: ClefOutputSchema })
55
+
56
+ /** A Cloudflare v4 error body: `{ success: false, errors: [{ code, message }] }`. */
57
+ const ErrorEnvelopeSchema = z.object({
58
+ errors: z.array(z.object({ message: z.string() })).min(1),
59
+ })
60
+
61
+ /**
62
+ * The answer fields the engine knows: Clef's `choice` (a criteria key),
63
+ * `score` (a place on the scale), and `noul` (the probability of yes), each
64
+ * with a `confidence` except noul. Jev's `probability` and a plain `value`
65
+ * read too. A field that doesn't fit falls back to `null`.
66
+ */
67
+ const LooseAnswerSchema = z.object({
68
+ choice: z.string().nullish().catch(null),
69
+ score: z.union([z.string(), z.number()]).nullish().catch(null),
70
+ noul: z.number().nullish().catch(null),
71
+ probability: z.number().nullish().catch(null),
72
+ value: z.union([z.string(), z.number()]).nullish().catch(null),
73
+ confidence: z.number().nullish().catch(null),
74
+ })
75
+
76
+ const normalizeAnswer = (raw: unknown): JevAnswer => {
77
+ const parsed = LooseAnswerSchema.safeParse(raw)
78
+ if (!parsed.success) return { value: null, confidence: null, raw }
79
+ const { choice, score, noul, probability, value, confidence } = parsed.data
80
+ return {
81
+ value: choice ?? score ?? noul ?? probability ?? value ?? null,
82
+ confidence: confidence ?? null,
83
+ raw,
84
+ }
85
+ }
86
+
87
+ const isAbort = ({
88
+ error,
89
+ signal,
90
+ }: {
91
+ error: unknown
92
+ signal: AbortSignal
93
+ }): boolean =>
94
+ signal.aborted ||
95
+ (error instanceof Error &&
96
+ (error.name === 'AbortError' || error.name === 'TimeoutError'))
97
+
98
+ /** Removes the token from any text, so it never lands in the journal. */
99
+ const hideToken = ({ text, token }: { text: string; token: string }): string =>
100
+ text.split(token).join('[token]')
101
+
102
+ const parseJson = (text: string): unknown => {
103
+ try {
104
+ return JSON.parse(text)
105
+ } catch {
106
+ return undefined
107
+ }
108
+ }
109
+
110
+ /** What a failed call's body says: Cloudflare's error messages, or its start. */
111
+ const errorBodyText = (text: string): string => {
112
+ const envelope = ErrorEnvelopeSchema.safeParse(parseJson(text))
113
+ return envelope.success
114
+ ? envelope.data.errors.map(({ message }) => message).join('; ')
115
+ : text.slice(0, ERROR_SNIPPET_CHARS)
116
+ }
117
+
118
+ /**
119
+ * The URL that runs `model` on Workers AI for `account_id`. The model id's
120
+ * `@` and `/` stay as they are, as Cloudflare's docs write them. Pure.
121
+ *
122
+ * @example
123
+ * clefUrl({ account_id: 'abc', model: '@cf/cloudflare/clef' })
124
+ * // 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run/@cf/cloudflare/clef'
125
+ */
126
+ export const clefUrl = ({
127
+ account_id,
128
+ model,
129
+ }: {
130
+ account_id: string
131
+ model: string
132
+ }): string =>
133
+ `${WORKERS_AI_ACCOUNTS_URL}/${encodeURIComponent(account_id)}/ai/run/${model}`
134
+
135
+ /** The name Clef wants in the body: the model id's last part, `clef` or `clef-flash`. */
136
+ const shortName = (model: string): string => model.split('/').at(-1) ?? model
137
+
138
+ /**
139
+ * The decision model client for Cloudflare's **Clef** on Workers AI (#534).
140
+ * It never throws: missing credentials, a timeout, a bad status, or a bad
141
+ * body each come back as a failed reply, and no error text ever holds the
142
+ * token. HTTP 401 and 403 are `rejected` (the token is wrong or lacks Workers
143
+ * AI), which turns the decision model off for the run; anything else is an
144
+ * `error` or a `timeout`, and the next ask tries again.
145
+ *
146
+ * @param credentials - The account id and token (see
147
+ * `loadDecisionModelCredentials`); `null` or an empty one is
148
+ * `missing_credentials`, and then `fetch` is never called.
149
+ * @param model - The Workers AI model id, such as `@cf/cloudflare/clef`.
150
+ * @param fetch - Defaults to the global `fetch`.
151
+ *
152
+ * @example
153
+ * const clef = createClefClient({ credentials: { account_id, api_token }, model: '@cf/cloudflare/clef' })
154
+ * const reply = await clef.ask({ request, signal: AbortSignal.timeout(10_000) })
155
+ * if (reply.ok) console.log(reply.answers)
156
+ */
157
+ export const createClefClient = ({
158
+ credentials,
159
+ model,
160
+ fetch: fetchClef,
161
+ }: {
162
+ credentials: ClefCredentials | null
163
+ model: string
164
+ fetch?: DecisionModelFetch
165
+ }): DecisionModelClient => ({
166
+ model,
167
+ ask: async ({ request, signal }) => {
168
+ if (
169
+ credentials === null ||
170
+ credentials.account_id === '' ||
171
+ credentials.api_token === ''
172
+ ) {
173
+ return {
174
+ ok: false,
175
+ reason: 'missing_credentials',
176
+ error: 'No Cloudflare account id or token is set.',
177
+ }
178
+ }
179
+ const token = credentials.api_token
180
+ try {
181
+ const response = await (fetchClef ?? fetch)(
182
+ clefUrl({ account_id: credentials.account_id, model }),
183
+ {
184
+ method: 'POST',
185
+ headers: {
186
+ authorization: `Bearer ${token}`,
187
+ 'content-type': 'application/json',
188
+ },
189
+ body: JSON.stringify({
190
+ model: shortName(model),
191
+ state: request.state,
192
+ questions: request.questions,
193
+ }),
194
+ signal,
195
+ }
196
+ )
197
+ const text = await response.text()
198
+ if (!response.ok) {
199
+ const said = hideToken({ text: errorBodyText(text), token })
200
+ const rejected =
201
+ response.status === 401 || response.status === 403
202
+ return rejected
203
+ ? {
204
+ ok: false,
205
+ reason: 'rejected',
206
+ error: `Cloudflare turned the token down (HTTP ${response.status}): ${said}`,
207
+ }
208
+ : {
209
+ ok: false,
210
+ reason: 'error',
211
+ error: `Clef answered ${response.status}: ${said}`,
212
+ }
213
+ }
214
+ const json = parseJson(text)
215
+ if (json === undefined) {
216
+ return {
217
+ ok: false,
218
+ reason: 'error',
219
+ error: 'Clef answered with a body that is not JSON.',
220
+ }
221
+ }
222
+ const wrapped = EnvelopeSchema.safeParse(json)
223
+ const output = wrapped.success
224
+ ? { success: true as const, data: wrapped.data.result }
225
+ : ClefOutputSchema.safeParse(json)
226
+ if (!output.success) {
227
+ return {
228
+ ok: false,
229
+ reason: 'error',
230
+ error: hideToken({
231
+ text: `Clef answered with no answers: ${z.prettifyError(output.error)}`,
232
+ token,
233
+ }),
234
+ }
235
+ }
236
+ return {
237
+ ok: true,
238
+ model: output.data.model ?? model,
239
+ answers: mapValues(output.data.answers, normalizeAnswer),
240
+ }
241
+ } catch (error) {
242
+ if (isAbort({ error, signal })) {
243
+ return {
244
+ ok: false,
245
+ reason: 'timeout',
246
+ error: 'Clef did not answer in time.',
247
+ }
248
+ }
249
+ return {
250
+ ok: false,
251
+ reason: 'error',
252
+ error: hideToken({
253
+ text: `Clef call failed: ${String(error)}`,
254
+ token,
255
+ }),
256
+ }
257
+ }
258
+ },
259
+ })