@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
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The git adapter's moves between branches: list the commits between two,
3
+ * replay them onto the run branch, undo a replay, and move a worktree's
4
+ * change onto another commit.
5
+ */
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ import type { GitAdapter } from './git-adapter'
11
+ import { gitOk, gitRun, head, lines } from './git-run'
12
+
13
+ /**
14
+ * Lines added plus lines removed in `git diff --numstat` output. A binary
15
+ * file (`-` for both counts) counts 0.
16
+ */
17
+ const linesChanged = (numstat: string): number =>
18
+ lines(numstat).reduce((total, line) => {
19
+ const [added = '', removed = ''] = line.split('\t')
20
+ return total + (Number(added) || 0) + (Number(removed) || 0)
21
+ }, 0)
22
+
23
+ export const joinOps: Pick<
24
+ GitAdapter,
25
+ 'commitsBetween' | 'replay' | 'undoReplay' | 'rebaseWorktree'
26
+ > = {
27
+ commitsBetween: async ({ cwd, from, to }) =>
28
+ lines(
29
+ await gitOk({
30
+ cwd,
31
+ args: ['rev-list', '--reverse', `${from}..${to}`],
32
+ })
33
+ ),
34
+ replay: async ({ cwd, commits }) => {
35
+ const before = await head({ cwd })
36
+ const picked = await gitRun({
37
+ cwd,
38
+ args: ['cherry-pick', ...commits],
39
+ })
40
+ if (picked.exit_code !== 0) {
41
+ await gitRun({ cwd, args: ['cherry-pick', '--abort'] })
42
+ return { ok: false, error: picked.stderr || picked.stdout }
43
+ }
44
+ return {
45
+ ok: true,
46
+ shas: lines(
47
+ await gitOk({
48
+ cwd,
49
+ args: ['rev-list', '--reverse', `${before}..HEAD`],
50
+ })
51
+ ),
52
+ lines_changed: linesChanged(
53
+ await gitOk({
54
+ cwd,
55
+ args: ['diff', '--numstat', '--no-renames', before, 'HEAD'],
56
+ })
57
+ ),
58
+ }
59
+ },
60
+ undoReplay: async ({ cwd, first_sha, only }) => {
61
+ const short = first_sha.slice(0, 7)
62
+ const parent = await gitRun({
63
+ cwd,
64
+ args: [
65
+ 'rev-parse',
66
+ '--verify',
67
+ '--quiet',
68
+ `${first_sha}^{commit}^`,
69
+ ],
70
+ })
71
+ if (parent.exit_code !== 0) {
72
+ return {
73
+ ok: false,
74
+ error: `Commit ${short} is not in the repo (or has no parent), so there is no join to undo.`,
75
+ }
76
+ }
77
+ const before = parent.stdout.trim()
78
+ const tip = await head({ cwd })
79
+ const ancestor = await gitRun({
80
+ cwd,
81
+ args: ['merge-base', '--is-ancestor', first_sha, 'HEAD'],
82
+ })
83
+ if (ancestor.exit_code !== 0) {
84
+ // Already undone, such as a redo after a crash.
85
+ if (tip === before) return { ok: true, undone: [] }
86
+ return {
87
+ ok: false,
88
+ error: `Commit ${short} is not on the branch, so undoing it (a reset to its parent) would rewind the branch over other work.`,
89
+ }
90
+ }
91
+ const undone = lines(
92
+ await gitOk({
93
+ cwd,
94
+ args: ['rev-list', '--reverse', `${before}..HEAD`],
95
+ })
96
+ )
97
+ const others = undone.filter((sha) => !only.includes(sha))
98
+ if (others.length > 0) {
99
+ return {
100
+ ok: false,
101
+ error: `Undoing ${short} would also undo other work on top of it: ${others.map((sha) => sha.slice(0, 7)).join(', ')}.`,
102
+ }
103
+ }
104
+ await gitOk({ cwd, args: ['reset', '--quiet', '--hard', before] })
105
+ return { ok: true, undone }
106
+ },
107
+ rebaseWorktree: async ({ cwd, from, to, onto }) => {
108
+ const folder = await mkdtemp(join(tmpdir(), 'luca-rebase-'))
109
+ try {
110
+ const patch = join(folder, 'change.patch')
111
+ await gitOk({
112
+ cwd,
113
+ args: ['diff', '--binary', `--output=${patch}`, from, to],
114
+ })
115
+ await gitOk({ cwd, args: ['reset', '--quiet', '--hard', onto] })
116
+ if ((await Bun.file(patch).size) === 0) return { conflicts: [] }
117
+ const applied = await gitRun({
118
+ cwd,
119
+ args: ['apply', '--3way', patch],
120
+ })
121
+ const conflicts = lines(
122
+ await gitOk({
123
+ cwd,
124
+ args: ['diff', '--name-only', '--diff-filter=U'],
125
+ })
126
+ ).toSorted()
127
+ if (applied.exit_code !== 0 && conflicts.length === 0) {
128
+ throw new Error(
129
+ `git apply --3way failed in ${cwd}:\n${applied.stderr || applied.stdout}`
130
+ )
131
+ }
132
+ // Everything becomes plain uncommitted changes, markers kept.
133
+ await gitOk({ cwd, args: ['reset', '--quiet'] })
134
+ return { conflicts }
135
+ } finally {
136
+ await rm(folder, { recursive: true, force: true })
137
+ }
138
+ },
139
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The git adapter's reads: what changed in a worktree or between commits,
3
+ * which files exist, and where a commit is. None of them changes the repo.
4
+ */
5
+ import sortBy from 'lodash/sortBy'
6
+ import uniq from 'lodash/uniq'
7
+
8
+ import type { FileChange, GitAdapter } from './git-adapter'
9
+ import { gitOk, gitRun, head, lines } from './git-run'
10
+
11
+ /** A full or short commit id, in hex. Anything else is no commit id. */
12
+ const COMMIT_ID = /^[0-9a-f]{4,64}$/i
13
+
14
+ /** Parses `git status --porcelain=v1 -z --untracked-files=all`. */
15
+ export const parseStatus = ({ text }: { text: string }): FileChange[] => {
16
+ const parts = text.split('\0').filter((part) => part.length > 0)
17
+ const changes: FileChange[] = []
18
+ for (let index = 0; index < parts.length; index += 1) {
19
+ const part = parts[index] ?? ''
20
+ const [x = ' ', y = ' '] = part
21
+ const path = part.slice(3)
22
+ if (x === 'R' || x === 'C') index += 1 // the next part is the old path
23
+ if (x === 'D' || y === 'D') changes.push({ path, change: 'deleted' })
24
+ else if (x === '?' || x === 'A' || x === 'R' || x === 'C')
25
+ changes.push({ path, change: 'added' })
26
+ else changes.push({ path, change: 'modified' })
27
+ }
28
+ return sortBy(changes, 'path')
29
+ }
30
+
31
+ export const readOps: Pick<
32
+ GitAdapter,
33
+ | 'changes'
34
+ | 'changedSince'
35
+ | 'fileAt'
36
+ | 'filesBetween'
37
+ | 'untracked'
38
+ | 'listFiles'
39
+ | 'filesMentioning'
40
+ | 'head'
41
+ | 'commitOnBase'
42
+ > = {
43
+ changes: async ({ cwd }) =>
44
+ parseStatus({
45
+ text: await gitOk({
46
+ cwd,
47
+ args: [
48
+ 'status',
49
+ '--porcelain=v1',
50
+ '-z',
51
+ '--untracked-files=all',
52
+ ],
53
+ }),
54
+ }),
55
+ changedSince: async ({ cwd, from }) => {
56
+ const tracked = lines(
57
+ await gitOk({ cwd, args: ['diff', '--name-only', from] })
58
+ )
59
+ const untracked = lines(
60
+ await gitOk({
61
+ cwd,
62
+ args: ['ls-files', '--others', '--exclude-standard'],
63
+ })
64
+ )
65
+ return uniq([...tracked, ...untracked]).toSorted()
66
+ },
67
+ fileAt: async ({ cwd, sha, path }) => {
68
+ const result = await gitRun({
69
+ cwd,
70
+ args: ['show', `${sha}:${path}`],
71
+ })
72
+ return result.exit_code === 0 ? result.stdout : null
73
+ },
74
+ filesBetween: async ({ cwd, from, to }) =>
75
+ lines(
76
+ await gitOk({ cwd, args: ['diff', '--name-only', from, to] })
77
+ ).toSorted(),
78
+ untracked: async ({ cwd }) =>
79
+ (
80
+ await gitOk({
81
+ cwd,
82
+ args: ['ls-files', '--others', '--exclude-standard', '-z'],
83
+ })
84
+ )
85
+ .split('\0')
86
+ .filter((path) => path !== ''),
87
+ listFiles: async ({ cwd }) =>
88
+ lines(
89
+ await gitOk({
90
+ cwd,
91
+ args: [
92
+ 'ls-files',
93
+ '--cached',
94
+ '--others',
95
+ '--exclude-standard',
96
+ ],
97
+ })
98
+ ),
99
+ filesMentioning: async ({ cwd, text }) => {
100
+ // git grep exits 1 when nothing matches; that is an empty answer.
101
+ const result = await gitRun({
102
+ cwd,
103
+ args: ['grep', '-l', '-F', '-e', text, '--', '.'],
104
+ })
105
+ return result.exit_code === 0 ? lines(result.stdout) : []
106
+ },
107
+ head,
108
+ commitOnBase: async ({ cwd, sha, base }) => {
109
+ if (!COMMIT_ID.test(sha)) return 'unknown'
110
+ const found = await gitRun({
111
+ cwd,
112
+ args: ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
113
+ })
114
+ if (found.exit_code !== 0) return 'unknown'
115
+ const args = ['merge-base', '--is-ancestor', sha, base]
116
+ const ancestor = await gitRun({ cwd, args })
117
+ if (ancestor.exit_code === 0) return 'on_base'
118
+ if (ancestor.exit_code === 1) return 'not_on_base'
119
+ throw new Error(
120
+ `git ${args.join(' ')} failed in ${cwd}:\n${ancestor.stderr || ancestor.stdout}`
121
+ )
122
+ },
123
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Runs `git` in a folder: `gitRun` returns the result as it is, `gitOk`
3
+ * throws when git fails. Also the line splitter and `head` the other git
4
+ * modules share.
5
+ */
6
+ import { runCommand } from '../shell/run-command'
7
+
8
+ export const gitRun = ({ cwd, args }: { cwd: string; args: string[] }) =>
9
+ runCommand({ cmd: ['git', ...args], cwd, timeout_ms: 120_000 })
10
+
11
+ export const gitOk = async ({
12
+ cwd,
13
+ args,
14
+ }: {
15
+ cwd: string
16
+ args: string[]
17
+ }): Promise<string> => {
18
+ const result = await gitRun({ cwd, args })
19
+ if (result.exit_code !== 0) {
20
+ throw new Error(
21
+ `git ${args.join(' ')} failed in ${cwd}:\n${result.stderr || result.stdout}`
22
+ )
23
+ }
24
+ return result.stdout
25
+ }
26
+
27
+ export const lines = (text: string): string[] =>
28
+ text.split('\n').filter((line) => line.trim() !== '')
29
+
30
+ /** The commit checked out at `cwd`. */
31
+ export const head = async ({ cwd }: { cwd: string }) =>
32
+ (await gitOk({ cwd, args: ['rev-parse', 'HEAD'] })).trim()
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The git adapter's worktrees: fetch the base branch, make the run branch and
3
+ * each ticket's worktree (safe to repeat after a crash), and remove them.
4
+ * These are the calls that need the repo's root.
5
+ */
6
+ import { existsSync, realpathSync } from 'node:fs'
7
+ import { rm } from 'node:fs/promises'
8
+
9
+ import type { GitAdapter } from './git-adapter'
10
+ import { gitOk, gitRun, head, lines } from './git-run'
11
+
12
+ export const createWorktreeOps = ({
13
+ repo_root,
14
+ }: {
15
+ repo_root: string
16
+ }): Pick<
17
+ GitAdapter,
18
+ 'fetchBase' | 'createRunBranch' | 'createWorktree' | 'removeWorktree'
19
+ > => {
20
+ /** The branch git has checked out in a worktree at `path`, if any. */
21
+ const worktreeBranchAt = async ({
22
+ path,
23
+ }: {
24
+ path: string
25
+ }): Promise<{ branch: string | null } | null> => {
26
+ if (!existsSync(path)) return null
27
+ const wanted = realpathSync(path)
28
+ const listed = await gitOk({
29
+ cwd: repo_root,
30
+ args: ['worktree', 'list', '--porcelain'],
31
+ })
32
+ for (const block of listed.split('\n\n')) {
33
+ const fields = lines(block)
34
+ const at = fields
35
+ .find((line) => line.startsWith('worktree '))
36
+ ?.slice('worktree '.length)
37
+ if (at === undefined || !existsSync(at)) continue
38
+ if (realpathSync(at) !== wanted) continue
39
+ const ref = fields
40
+ .find((line) => line.startsWith('branch '))
41
+ ?.slice('branch '.length)
42
+ return { branch: ref?.replace(/^refs\/heads\//, '') ?? null }
43
+ }
44
+ return null
45
+ }
46
+
47
+ const branchExists = async ({ branch }: { branch: string }) =>
48
+ (
49
+ await gitRun({
50
+ cwd: repo_root,
51
+ args: [
52
+ 'rev-parse',
53
+ '--verify',
54
+ '--quiet',
55
+ `refs/heads/${branch}`,
56
+ ],
57
+ })
58
+ ).exit_code === 0
59
+
60
+ const addWorktree = async ({
61
+ branch,
62
+ from,
63
+ path,
64
+ }: {
65
+ branch: string
66
+ from: string
67
+ path: string
68
+ }) => {
69
+ const known = await worktreeBranchAt({ path })
70
+ if (known !== null && known.branch !== branch) {
71
+ throw new Error(
72
+ `${path} is already a worktree on ${known.branch ?? 'no branch'}, not ${branch}.`
73
+ )
74
+ }
75
+ if (known !== null) {
76
+ // Made before a crash: nothing has worked in it since, so finish
77
+ // a checkout the crash may have cut off, and adopt it.
78
+ const reset = await gitRun({
79
+ cwd: path,
80
+ args: ['reset', '--quiet', '--hard', 'HEAD'],
81
+ })
82
+ if (reset.exit_code === 0) {
83
+ return { base_sha: await head({ cwd: path }) }
84
+ }
85
+ await gitRun({
86
+ cwd: repo_root,
87
+ args: ['worktree', 'remove', '--force', '--force', path],
88
+ })
89
+ }
90
+ // A half-made folder git doesn't know (or no longer does) goes.
91
+ await rm(path, { recursive: true, force: true })
92
+ await gitOk({ cwd: repo_root, args: ['worktree', 'prune'] })
93
+ await gitOk({
94
+ cwd: repo_root,
95
+ args: (await branchExists({ branch }))
96
+ ? ['worktree', 'add', '--quiet', path, branch]
97
+ : [
98
+ 'worktree',
99
+ 'add',
100
+ '--quiet',
101
+ '--no-track',
102
+ '-b',
103
+ branch,
104
+ path,
105
+ from,
106
+ ],
107
+ })
108
+ return { base_sha: await head({ cwd: path }) }
109
+ }
110
+
111
+ return {
112
+ fetchBase: async ({ base_branch }) => {
113
+ const fetched = await gitRun({
114
+ cwd: repo_root,
115
+ args: [
116
+ 'fetch',
117
+ '--quiet',
118
+ 'origin',
119
+ `+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}`,
120
+ ],
121
+ })
122
+ return fetched.exit_code === 0
123
+ ? { ok: true }
124
+ : {
125
+ ok: false,
126
+ error: (fetched.stderr || fetched.stdout).trim(),
127
+ }
128
+ },
129
+ createRunBranch: ({ branch, base_branch, path }) =>
130
+ addWorktree({ branch, from: `origin/${base_branch}`, path }),
131
+ createWorktree: addWorktree,
132
+ removeWorktree: async ({ path }) => {
133
+ if (existsSync(path)) {
134
+ // A folder that is no worktree (or not any more) is fine.
135
+ await gitRun({
136
+ cwd: repo_root,
137
+ args: ['worktree', 'remove', '--force', path],
138
+ })
139
+ }
140
+ await gitOk({ cwd: repo_root, args: ['worktree', 'prune'] })
141
+ },
142
+ }
143
+ }
@@ -0,0 +1,55 @@
1
+ import { lstat, readlink, realpath } from 'node:fs/promises'
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+
4
+ import uniq from 'lodash/uniq'
5
+
6
+ /** Whether `target` is `root` or somewhere under it. */
7
+ const isInside = ({ root, target }: { root: string; target: string }) => {
8
+ const path = relative(root, target)
9
+ return (
10
+ path === '' ||
11
+ (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path))
12
+ )
13
+ }
14
+
15
+ /**
16
+ * The paths among `paths` that are symlinks pointing outside the checkout
17
+ * at `cwd`, such as a build's link to a vendor folder in the owner's own
18
+ * checkout. Such a link only works on one machine, so no commit should hold
19
+ * it. A link's target is read, never followed; a relative target counts
20
+ * from the link's folder. A path that is not a link, or can't be read, is
21
+ * never one.
22
+ *
23
+ * @param cwd - The checkout's root.
24
+ * @param paths - Checkout-relative paths, as git lists them.
25
+ * @returns The outside-pointing links, in the order given.
26
+ *
27
+ * @example
28
+ * await outsideLinks({ cwd, paths: ['tools/bin', 'src/sum.ts'] })
29
+ * // ['tools/bin'], when tools/bin links to /home/me/vendor/bin
30
+ */
31
+ export const outsideLinks = async ({
32
+ cwd,
33
+ paths,
34
+ }: {
35
+ cwd: string
36
+ paths: string[]
37
+ }): Promise<string[]> => {
38
+ // A link may name the checkout by its real path (macOS's /private/var
39
+ // for /var, say), so both count as inside.
40
+ const roots = uniq([resolve(cwd), await realpath(cwd).catch(() => cwd)])
41
+ const found: string[] = []
42
+ for (const path of paths) {
43
+ const full = join(cwd, path)
44
+ const target = await lstat(full)
45
+ .then(async (stat) =>
46
+ stat.isSymbolicLink()
47
+ ? resolve(dirname(full), await readlink(full))
48
+ : null
49
+ )
50
+ .catch(() => null)
51
+ if (target === null) continue
52
+ if (!roots.some((root) => isInside({ root, target }))) found.push(path)
53
+ }
54
+ return found
55
+ }
@@ -22,7 +22,10 @@ export type GitState = {
22
22
  head_ref: string | null
23
23
  /** The ticket branch's commit, `null` if the branch is gone. */
24
24
  branch_ref: string | null
25
- /** Other refs pointing at HEAD. The repo's refs are shared, so only these count. */
25
+ /**
26
+ * Other refs pointing at HEAD. The repo's refs are shared, so only these
27
+ * count, and only when HEAD is a commit made during the turn.
28
+ */
26
29
  refs_at_head: string[]
27
30
  /** Stash entries naming the branch. The stash is shared too. */
28
31
  stash: string[]
@@ -146,6 +149,29 @@ export const pathViolations = ({
146
149
  const added = ({ before, after }: { before: string[]; after: string[] }) =>
147
150
  after.filter((item) => !before.includes(item))
148
151
 
152
+ /**
153
+ * The new refs at HEAD the agent made. The refs are shared, so a new ref
154
+ * counts only when HEAD is a commit made during the turn: one at a commit
155
+ * that already existed may be another process's, such as a helper's
156
+ * worktree branch.
157
+ *
158
+ * @example
159
+ * refsMadeInTurn({ before, after, head_is_new: false }) // []
160
+ */
161
+ export const refsMadeInTurn = ({
162
+ before,
163
+ after,
164
+ head_is_new,
165
+ }: {
166
+ before: GitState
167
+ after: GitState
168
+ /** Whether HEAD is now a commit made during the turn. */
169
+ head_is_new: boolean
170
+ }): string[] =>
171
+ head_is_new
172
+ ? added({ before: before.refs_at_head, after: after.refs_at_head })
173
+ : []
174
+
149
175
  /**
150
176
  * Every git change between two snapshots. No role may change git state, so
151
177
  * each one is a violation.
@@ -153,9 +179,12 @@ const added = ({ before, after }: { before: string[]; after: string[] }) =>
153
179
  export const gitViolations = ({
154
180
  before,
155
181
  after,
182
+ head_is_new,
156
183
  }: {
157
184
  before: GitState
158
185
  after: GitState
186
+ /** Whether HEAD is now a commit made during the turn. */
187
+ head_is_new: boolean
159
188
  }): Violation[] => {
160
189
  const details: string[] = []
161
190
  if (before.head !== after.head) {
@@ -171,10 +200,7 @@ export const gitViolations = ({
171
200
  `the ticket branch moved from ${before.branch_ref} to ${after.branch_ref}`
172
201
  )
173
202
  }
174
- const refs = added({
175
- before: before.refs_at_head,
176
- after: after.refs_at_head,
177
- })
203
+ const refs = refsMadeInTurn({ before, after, head_is_new })
178
204
  if (refs.length > 0) details.push(`new refs at HEAD: ${refs.join(', ')}`)
179
205
  const stash = added({ before: before.stash, after: after.stash })
180
206
  if (stash.length > 0) details.push(`new stash entries: ${stash.join('; ')}`)
@@ -3,6 +3,7 @@ import type {
3
3
  HookJSONOutput,
4
4
  } from '@anthropic-ai/claude-agent-sdk'
5
5
 
6
+ import { checkHelperTool, HELPER_TOOL, MAX_HELPERS } from './helper-rules'
6
7
  import { checkToolCall, type GuardRole } from './role-rules'
7
8
 
8
9
  import type { EngineConfig } from '../config/engine-config'
@@ -13,6 +14,13 @@ import type { EngineConfig } from '../config/engine-config'
13
14
  * reason. An allowed call gets no opinion from the hook, so the SDK's own
14
15
  * permission rules still apply. Any error in the guard itself denies.
15
16
  *
17
+ * Helpers (#627): a call from inside a helper (one with the SDK's
18
+ * `agent_id`) may only read and search, whatever the agent may do. An
19
+ * allowed helper goes into `helpers`, by the id of the call that started it,
20
+ * until its result comes back (the launcher takes it out); a helper past
21
+ * `MAX_HELPERS` running is denied. `on_helper` is called for each one
22
+ * started.
23
+ *
16
24
  * @example
17
25
  * const hook = createGuardHook({ role: 'implementer', may_edit_tests: false, worktree, config, on_deny: (denial) => denials.push(denial) })
18
26
  * const options = { hooks: { PreToolUse: [{ hooks: [hook] }] } }
@@ -23,6 +31,9 @@ export const createGuardHook = ({
23
31
  worktree,
24
32
  config,
25
33
  on_deny,
34
+ helpers = new Set(),
35
+ on_helper,
36
+ on_allow,
26
37
  }: {
27
38
  role: GuardRole
28
39
  /** Whether the agent may edit test files. */
@@ -32,6 +43,16 @@ export const createGuardHook = ({
32
43
  config: EngineConfig
33
44
  /** Called for each denied call, such as to count it. */
34
45
  on_deny?: (denial: { tool_name: string; reason: string }) => void
46
+ /** The agent's running helpers, by the tool call that started each. */
47
+ helpers?: Set<string>
48
+ /** Called for each helper started, such as to count it. */
49
+ on_helper?: () => void
50
+ /** Called for each of the agent's own calls let through (not a helper's), such as to show it running. */
51
+ on_allow?: (call: {
52
+ tool_name: string
53
+ tool_input: unknown
54
+ tool_use_id: string
55
+ }) => void
35
56
  }): HookCallback => {
36
57
  const denied = (reason: string): HookJSONOutput => ({
37
58
  hookSpecificOutput: {
@@ -40,20 +61,47 @@ export const createGuardHook = ({
40
61
  permissionDecisionReason: reason,
41
62
  },
42
63
  })
43
- return async (input): Promise<HookJSONOutput> => {
64
+ return async (input, tool_use_id): Promise<HookJSONOutput> => {
44
65
  if (input.hook_event_name !== 'PreToolUse') return {}
45
66
  try {
46
- const decision = checkToolCall({
47
- role,
48
- may_edit_tests,
49
- tool_name: input.tool_name,
50
- tool_input: input.tool_input,
51
- worktree,
52
- config,
53
- })
54
- if (decision.allow) return {}
55
- on_deny?.({ tool_name: input.tool_name, reason: decision.reason })
56
- return denied(decision.reason)
67
+ const fromHelper = input.agent_id !== undefined
68
+ const helperTool = fromHelper
69
+ ? checkHelperTool({ tool_name: input.tool_name })
70
+ : null
71
+ const decision =
72
+ helperTool !== null && !helperTool.allow
73
+ ? helperTool
74
+ : checkToolCall({
75
+ role,
76
+ may_edit_tests,
77
+ tool_name: input.tool_name,
78
+ tool_input: input.tool_input,
79
+ worktree,
80
+ config,
81
+ })
82
+ const full =
83
+ decision.allow &&
84
+ input.tool_name === HELPER_TOOL &&
85
+ helpers.size >= MAX_HELPERS
86
+ if (decision.allow && !full) {
87
+ if (input.tool_name === HELPER_TOOL) {
88
+ helpers.add(tool_use_id ?? input.tool_use_id)
89
+ on_helper?.()
90
+ }
91
+ if (!fromHelper) {
92
+ on_allow?.({
93
+ tool_name: input.tool_name,
94
+ tool_input: input.tool_input,
95
+ tool_use_id: tool_use_id ?? input.tool_use_id,
96
+ })
97
+ }
98
+ return {}
99
+ }
100
+ const reason = decision.allow
101
+ ? `At most ${MAX_HELPERS} helpers run at once: wait for one to finish.`
102
+ : decision.reason
103
+ on_deny?.({ tool_name: input.tool_name, reason })
104
+ return denied(reason)
57
105
  } catch (error) {
58
106
  return denied(
59
107
  `The guard could not check this call: ${String(error)}`