@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,412 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readdirSync,
5
+ readlinkSync,
6
+ type Stats,
7
+ } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ import sortBy from 'lodash/sortBy'
11
+ import uniq from 'lodash/uniq'
12
+
13
+ import {
14
+ DELETED,
15
+ watchesIgnored,
16
+ type ConfigEntry,
17
+ type GitState,
18
+ type OutsideGitState,
19
+ type WorktreeState,
20
+ } from './after-turn-check'
21
+ import {
22
+ commonDirOf,
23
+ excludeArgs,
24
+ excludeRules,
25
+ git,
26
+ gitOk,
27
+ globalExcludesFile,
28
+ nulSplit,
29
+ withExcludeFile,
30
+ } from './worktree-git'
31
+
32
+ import type { EngineConfig } from '../config/engine-config'
33
+
34
+ /** What the check and the undo of git state need beyond the comparison. */
35
+ export type SavedGit = {
36
+ /** The repo's shared `.git` folder. */
37
+ common_dir: string
38
+ /** Every branch and tag, and the commit or tag object it names. */
39
+ refs: Record<string, string>
40
+ /**
41
+ * The ignore rules from before the turn: the global excludes file, then
42
+ * the shared `.git/info/exclude`. The check lists files with these, not
43
+ * the live ones, so an excludes entry added mid-turn hides nothing.
44
+ */
45
+ exclude_rules: Uint8Array
46
+ /** The rest of the shared `.git`, to tell what others changed. */
47
+ outside: OutsideGitState
48
+ }
49
+
50
+ /**
51
+ * A snapshot taken before an agent turn: what the after-turn check compares
52
+ * against, plus what it needs to undo a violation exactly, even on a
53
+ * worktree that was not clean.
54
+ */
55
+ export type TurnSnapshot = WorktreeState & {
56
+ /**
57
+ * Bytes of each path in `files` (`null` if it was deleted, or is under
58
+ * `node_modules`, which the engine's install puts back instead).
59
+ */
60
+ saved: Record<string, Uint8Array | null>
61
+ saved_git: SavedGit
62
+ }
63
+
64
+ /**
65
+ * Every changed path: tracked and staged changes (renamed-from paths
66
+ * included), and untracked files the snapshot's rules don't ignore.
67
+ */
68
+ const changedPaths = async ({
69
+ cwd,
70
+ exclude_file,
71
+ }: {
72
+ cwd: string
73
+ exclude_file: string
74
+ }): Promise<string[]> => {
75
+ const [status, others] = await Promise.all([
76
+ gitOk({
77
+ cwd,
78
+ args: ['status', '--porcelain=v1', '-z', '--untracked-files=no'],
79
+ }),
80
+ gitOk({
81
+ cwd,
82
+ args: ['ls-files', '--others', '-z', ...excludeArgs(exclude_file)],
83
+ }),
84
+ ])
85
+ const parts = nulSplit(status)
86
+ const paths: string[] = []
87
+ for (let index = 0; index < parts.length; index += 1) {
88
+ const part = parts[index] ?? ''
89
+ paths.push(part.slice(3))
90
+ if (part[0] === 'R' || part[0] === 'C') {
91
+ index += 1
92
+ paths.push(parts[index] ?? '')
93
+ }
94
+ }
95
+ return [...paths, ...nulSplit(others)]
96
+ }
97
+
98
+ /** The ignored paths the after-turn check watches (see `watchesIgnored`). */
99
+ const watchedIgnoredPaths = async ({
100
+ cwd,
101
+ config,
102
+ exclude_file,
103
+ }: {
104
+ cwd: string
105
+ config: EngineConfig
106
+ exclude_file: string
107
+ }): Promise<string[]> =>
108
+ nulSplit(
109
+ await gitOk({
110
+ cwd,
111
+ args: [
112
+ 'ls-files',
113
+ '--others',
114
+ '--ignored',
115
+ ...excludeArgs(exclude_file),
116
+ '-z',
117
+ ],
118
+ })
119
+ ).filter((path) => watchesIgnored({ path, config }))
120
+
121
+ /** Whether a worktree-relative path is under a `node_modules` folder. */
122
+ export const inPackages = (path: string): boolean =>
123
+ path.split('/').includes('node_modules')
124
+
125
+ const hashBytes = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16)
126
+
127
+ /** `lstat`, or `null` when nothing is there. A dangling link still is. */
128
+ const lstatOrNull = (full: string): Stats | null => {
129
+ try {
130
+ return lstatSync(full)
131
+ } catch {
132
+ return null
133
+ }
134
+ }
135
+
136
+ /**
137
+ * A path's fingerprint: a file's content hash, a link's target, or
138
+ * `DELETED`. Under `node_modules`, where hashing every file each turn is
139
+ * slow, a file gets its size, inode, and change time instead: writing a
140
+ * file always moves its change time, and no process can set it back.
141
+ */
142
+ const hashPath = async ({
143
+ cwd,
144
+ path,
145
+ }: {
146
+ cwd: string
147
+ path: string
148
+ }): Promise<string> => {
149
+ const full = join(cwd, path)
150
+ const stat = lstatOrNull(full)
151
+ if (stat === null) return DELETED
152
+ if (stat.isSymbolicLink()) return `link:${readlinkSync(full)}`
153
+ if (stat.isDirectory()) return 'dir'
154
+ if (inPackages(path)) {
155
+ const big = lstatSync(full, { bigint: true })
156
+ return `stat:${big.size}:${big.ino}:${big.ctimeNs}:${big.mode}`
157
+ }
158
+ return hashBytes(await Bun.file(full).bytes())
159
+ }
160
+
161
+ /** Name, mode, and content hash of each `.git/hooks` entry. */
162
+ const hooksState = async ({
163
+ common_dir,
164
+ }: {
165
+ common_dir: string
166
+ }): Promise<string[]> => {
167
+ const dir = join(common_dir, 'hooks')
168
+ if (!existsSync(dir)) return ['missing']
169
+ const hooks: string[] = []
170
+ for (const name of sortBy(readdirSync(dir))) {
171
+ const stat = lstatSync(join(dir, name))
172
+ hooks.push(
173
+ `${name} ${stat.mode.toString(8)} ${await hashPath({ cwd: dir, path: name })}`
174
+ )
175
+ }
176
+ return hooks
177
+ }
178
+
179
+ /** Every entry of the shared `.git/config`, in file order, includes not followed. */
180
+ const configEntries = async ({
181
+ cwd,
182
+ common_dir,
183
+ }: {
184
+ cwd: string
185
+ common_dir: string
186
+ }): Promise<ConfigEntry[]> =>
187
+ nulSplit(
188
+ await gitOk({
189
+ cwd,
190
+ args: [
191
+ 'config',
192
+ '--file',
193
+ join(common_dir, 'config'),
194
+ '--list',
195
+ '-z',
196
+ ],
197
+ })
198
+ ).map((entry) => {
199
+ const at = entry.indexOf('\n')
200
+ return at === -1
201
+ ? { key: entry, value: null }
202
+ : { key: entry.slice(0, at), value: entry.slice(at + 1) }
203
+ })
204
+
205
+ /**
206
+ * Whether a config key is in the ticket branch's own section,
207
+ * `branch.<branch>.<name>`. The branch name is matched exactly, and the
208
+ * name after it has no dots, so `branch.a.b.remote` is branch `a.b`'s.
209
+ */
210
+ const isOwnKey = ({ key, branch }: { key: string; branch: string }) => {
211
+ const prefix = `branch.${branch}.`
212
+ return key.startsWith(prefix) && !key.slice(prefix.length).includes('.')
213
+ }
214
+
215
+ /** The shared `.git` as the check sees it: the branch's section, and the rest. */
216
+ const sharedGitState = async ({
217
+ cwd,
218
+ branch,
219
+ common_dir,
220
+ }: {
221
+ cwd: string
222
+ branch: string
223
+ common_dir: string
224
+ }): Promise<{ branch_config: ConfigEntry[]; outside: OutsideGitState }> => {
225
+ const [entries, exclude, hooks] = await Promise.all([
226
+ configEntries({ cwd, common_dir }),
227
+ existsSync(join(common_dir, 'info/exclude'))
228
+ ? hashPath({ cwd: common_dir, path: 'info/exclude' })
229
+ : DELETED,
230
+ hooksState({ common_dir }),
231
+ ])
232
+ return {
233
+ branch_config: entries.filter(({ key }) => isOwnKey({ key, branch })),
234
+ outside: {
235
+ config: entries
236
+ .filter(({ key }) => !isOwnKey({ key, branch }))
237
+ .map((entry) => JSON.stringify(entry)),
238
+ exclude,
239
+ hooks,
240
+ },
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Refs this ticket's worktree is not the one to judge: its own branch, and
246
+ * the engine's run branch and other tickets' branches, which the engine
247
+ * moves while agents work.
248
+ */
249
+ const isEngineRef = ({ ref, branch }: { ref: string; branch: string }) => {
250
+ const run = branch.replace(/--ticket-\d+$/, '')
251
+ const name = ref.replace(/^refs\/heads\//, '')
252
+ return (
253
+ ref.startsWith('refs/heads/') &&
254
+ (name === branch || name === run || name.startsWith(`${run}--ticket-`))
255
+ )
256
+ }
257
+
258
+ /** Every branch and tag: the object it names, and the commit it peels to. */
259
+ const allRefs = async ({
260
+ cwd,
261
+ }: {
262
+ cwd: string
263
+ }): Promise<{ ref: string; object: string; commit: string }[]> =>
264
+ (
265
+ await gitOk({
266
+ cwd,
267
+ args: [
268
+ 'for-each-ref',
269
+ '--format=%(refname) %(objectname) %(*objectname)',
270
+ 'refs/heads',
271
+ 'refs/tags',
272
+ ],
273
+ })
274
+ )
275
+ .split('\n')
276
+ .filter((line) => line.length > 0)
277
+ .map((line) => {
278
+ const [ref = '', object = '', peeled = ''] = line.split(' ')
279
+ return { ref, object, commit: peeled === '' ? object : peeled }
280
+ })
281
+
282
+ /**
283
+ * The git state an agent must not change, every ref, and the rest of the
284
+ * shared `.git`, which others may change. The reads don't depend on each
285
+ * other, so they run at once.
286
+ */
287
+ export const gitState = async ({
288
+ cwd,
289
+ branch,
290
+ common_dir,
291
+ }: {
292
+ cwd: string
293
+ branch: string
294
+ common_dir: string
295
+ }): Promise<{
296
+ state: GitState
297
+ refs: Record<string, string>
298
+ outside: OutsideGitState
299
+ }> => {
300
+ const [headLines, refs, stash, shared, staged] = await Promise.all([
301
+ // The commit HEAD names, then the ref it points at (`HEAD` when
302
+ // detached).
303
+ gitOk({
304
+ cwd,
305
+ args: ['rev-parse', 'HEAD', '--symbolic-full-name', 'HEAD'],
306
+ }),
307
+ allRefs({ cwd }),
308
+ git({ cwd, args: ['stash', 'list', '--format=%H %gs'] }),
309
+ sharedGitState({ cwd, branch, common_dir }),
310
+ gitOk({ cwd, args: ['diff', '--cached', '--name-only', '-z'] }),
311
+ ])
312
+ const [head = '', headRef = ''] = headLines.trim().split('\n')
313
+ const byRef = Object.fromEntries(
314
+ refs.map(({ ref, object }) => [ref, object])
315
+ )
316
+ return {
317
+ outside: shared.outside,
318
+ refs: byRef,
319
+ state: {
320
+ head,
321
+ head_ref: headRef === '' || headRef === 'HEAD' ? null : headRef,
322
+ branch_ref: byRef[`refs/heads/${branch}`] ?? null,
323
+ refs_at_head: sortBy(
324
+ refs
325
+ .filter(
326
+ ({ ref, commit }) =>
327
+ commit === head && !isEngineRef({ ref, branch })
328
+ )
329
+ .map(({ ref }) => ref)
330
+ ),
331
+ stash: stash.stdout
332
+ .split('\n')
333
+ .filter((line) => line.includes(`${branch}:`)),
334
+ staged: sortBy(nulSplit(staged)),
335
+ branch_config: shared.branch_config,
336
+ },
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The fingerprint of each changed path, each watched ignored path, and each
342
+ * path in `also` (the paths seen before the turn, so an ignored file that
343
+ * is gone shows as deleted). Ignore rules come from `exclude_file`.
344
+ */
345
+ export const fileStates = async ({
346
+ cwd,
347
+ config,
348
+ exclude_file,
349
+ also,
350
+ }: {
351
+ cwd: string
352
+ config: EngineConfig
353
+ exclude_file: string
354
+ also?: string[]
355
+ }): Promise<Record<string, string>> => {
356
+ const files: Record<string, string> = {}
357
+ const [changed, ignored] = await Promise.all([
358
+ changedPaths({ cwd, exclude_file }),
359
+ watchedIgnoredPaths({ cwd, config, exclude_file }),
360
+ ])
361
+ const paths = uniq([...changed, ...ignored, ...(also ?? [])])
362
+ for (const path of paths) {
363
+ files[path] = await hashPath({ cwd, path })
364
+ }
365
+ return files
366
+ }
367
+
368
+ /**
369
+ * Snapshots a worktree before an agent turn: each changed path's content
370
+ * hash and bytes, the ignored paths that matter, and the git state no agent
371
+ * may change, with what it takes to put that back.
372
+ *
373
+ * @example
374
+ * const before = await snapshotWorktree({ cwd, branch, config })
375
+ * // ... the agent works ...
376
+ * const { violations } = await enforceAfterTurn({ cwd, branch, role, may_edit_tests, config, before })
377
+ */
378
+ export const snapshotWorktree = async ({
379
+ cwd,
380
+ branch,
381
+ config,
382
+ }: {
383
+ cwd: string
384
+ branch: string
385
+ config: EngineConfig
386
+ }): Promise<TurnSnapshot> => {
387
+ const [common_dir, global] = await Promise.all([
388
+ commonDirOf({ cwd }),
389
+ globalExcludesFile({ cwd }),
390
+ ])
391
+ const exclude_rules = await excludeRules({ global, common_dir })
392
+ const [files, { state, refs, outside }] = await Promise.all([
393
+ withExcludeFile({
394
+ rules: exclude_rules,
395
+ use: (exclude_file) => fileStates({ cwd, config, exclude_file }),
396
+ }),
397
+ gitState({ cwd, branch, common_dir }),
398
+ ])
399
+ const saved: Record<string, Uint8Array | null> = {}
400
+ for (const [path, hash] of Object.entries(files)) {
401
+ const full = join(cwd, path)
402
+ const plain =
403
+ hash !== DELETED && !inPackages(path) && lstatSync(full).isFile()
404
+ saved[path] = plain ? await Bun.file(full).bytes() : null
405
+ }
406
+ return {
407
+ files,
408
+ saved,
409
+ git: state,
410
+ saved_git: { common_dir, refs, exclude_rules, outside },
411
+ }
412
+ }