@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
@@ -1,845 +1,40 @@
1
- import { randomUUID } from 'node:crypto'
2
- import {
3
- existsSync,
4
- lstatSync,
5
- readdirSync,
6
- readlinkSync,
7
- type Stats,
8
- } from 'node:fs'
9
- import { mkdir, rm } from 'node:fs/promises'
10
- import { homedir, tmpdir } from 'node:os'
11
- import { dirname, join } from 'node:path'
12
-
13
1
  import omitBy from 'lodash/omitBy'
14
- import sortBy from 'lodash/sortBy'
15
- import uniq from 'lodash/uniq'
16
2
 
17
3
  import {
18
4
  branchConfigChanges,
19
- DELETED,
20
5
  gitViolations,
21
6
  outsideGitChanges,
22
7
  pathViolations,
23
- watchesIgnored,
24
- type ConfigEntry,
25
- type GitState,
26
- type OutsideGitState,
27
8
  type Violation,
28
- type WorktreeState,
29
9
  } from './after-turn-check'
30
10
  import type { GuardRole } from './role-rules'
11
+ import { withExcludeFile } from './worktree-git'
12
+ import {
13
+ fileStates,
14
+ gitState,
15
+ inPackages,
16
+ type TurnSnapshot,
17
+ } from './worktree-snapshot'
18
+ import {
19
+ headIsNew,
20
+ reinstallPackages,
21
+ restoreBranchConfig,
22
+ restoreGit,
23
+ restorePath,
24
+ } from './worktree-undo'
31
25
 
32
26
  import type { EngineConfig } from '../config/engine-config'
33
- import { FROZEN_INSTALL } from '../gates/lockfile-install'
34
- import { runCommand, runShell, type CommandResult } from '../shell/run-command'
35
-
36
- /** What the check and the undo of git state need beyond the comparison. */
37
- type SavedGit = {
38
- /** The repo's shared `.git` folder. */
39
- common_dir: string
40
- /** Every branch and tag, and the commit or tag object it names. */
41
- refs: Record<string, string>
42
- /**
43
- * The ignore rules from before the turn: the global excludes file, then
44
- * the shared `.git/info/exclude`. The check lists files with these, not
45
- * the live ones, so an excludes entry added mid-turn hides nothing.
46
- */
47
- exclude_rules: Uint8Array
48
- /** The rest of the shared `.git`, to tell what others changed. */
49
- outside: OutsideGitState
50
- }
51
-
52
- /**
53
- * A snapshot taken before an agent turn: what the after-turn check compares
54
- * against, plus what it needs to undo a violation exactly, even on a
55
- * worktree that was not clean.
56
- */
57
- export type TurnSnapshot = WorktreeState & {
58
- /**
59
- * Bytes of each path in `files` (`null` if it was deleted, or is under
60
- * `node_modules`, which the engine's install puts back instead).
61
- */
62
- saved: Record<string, Uint8Array | null>
63
- saved_git: SavedGit
64
- }
65
-
66
- /**
67
- * Put before every git command the check runs, so an fsmonitor or hook
68
- * someone planted in the shared `.git` never runs for the engine. Content
69
- * filters are switched off too, by `noFilters`.
70
- */
71
- const SAFE_GIT = [
72
- '-c',
73
- 'core.fsmonitor=false',
74
- '-c',
75
- 'core.hooksPath=/dev/null',
76
- ]
77
-
78
- const nulSplit = (text: string): string[] =>
79
- text.split('\0').filter((part) => part.length > 0)
80
-
81
- /** The environment variables `noFilters` points git's settings at. */
82
- const NO_FILTER_ENV = { LUCA_GIT_EMPTY: '', LUCA_GIT_FALSE: 'false' }
83
-
84
- /**
85
- * Every content filter (`filter.<name>.*`) git's config names, read fresh
86
- * for each command, since another process may add one at any time.
87
- */
88
- const filterNames = async ({ cwd }: { cwd: string }): Promise<string[]> => {
89
- const listed = await runCommand({
90
- cmd: ['git', ...SAFE_GIT, 'config', '-z', '--get-regexp', '^filter\\.'],
91
- cwd,
92
- timeout_ms: 60_000,
93
- })
94
- return uniq(
95
- nulSplit(listed.stdout).flatMap((entry) => {
96
- const key = entry.split('\n')[0] ?? ''
97
- const last = key.lastIndexOf('.')
98
- // `>=` keeps the empty name (`filter..clean`): git runs a filter named "".
99
- return last >= 'filter.'.length
100
- ? [key.slice('filter.'.length, last)]
101
- : []
102
- })
103
- )
104
- }
105
-
106
- /**
107
- * Options that switch each named filter off: no clean, smudge, or process
108
- * command, and not required (git gives up on a required filter with no
109
- * command). Set through `--config-env`, which takes any filter name, even
110
- * one with `=` in it.
111
- */
112
- const noFilters = (names: string[]): string[] =>
113
- names.flatMap((name) => [
114
- `--config-env=filter.${name}.clean=LUCA_GIT_EMPTY`,
115
- `--config-env=filter.${name}.smudge=LUCA_GIT_EMPTY`,
116
- `--config-env=filter.${name}.process=LUCA_GIT_EMPTY`,
117
- `--config-env=filter.${name}.required=LUCA_GIT_FALSE`,
118
- ])
119
-
120
- /** A git run, and why git could not start (such as `cwd` being gone). */
121
- type GitRun = CommandResult & { start_error: string | null }
122
-
123
- const runGit = async ({
124
- cwd,
125
- args,
126
- }: {
127
- cwd: string
128
- args: string[]
129
- }): Promise<GitRun> => {
130
- try {
131
- const names = await filterNames({ cwd })
132
- const result = await runCommand({
133
- cmd: ['git', ...SAFE_GIT, ...noFilters(names), ...args],
134
- cwd,
135
- timeout_ms: 60_000,
136
- env: NO_FILTER_ENV,
137
- })
138
- return { ...result, start_error: null }
139
- } catch (error) {
140
- return {
141
- exit_code: null,
142
- stdout: '',
143
- stderr: '',
144
- timed_out: false,
145
- start_error: error instanceof Error ? error.message : String(error),
146
- }
147
- }
148
- }
149
-
150
- const git = async ({
151
- cwd,
152
- args,
153
- }: {
154
- cwd: string
155
- args: string[]
156
- }): Promise<{ ok: boolean; stdout: string }> => {
157
- const result = await runGit({ cwd, args })
158
- return { ok: result.exit_code === 0, stdout: result.stdout }
159
- }
160
-
161
- /** Why a git run failed, said even when git printed nothing. */
162
- const gitFailure = ({ cwd, run }: { cwd: string; run: GitRun }): string =>
163
- [
164
- run.start_error !== null
165
- ? `could not start (${run.start_error})`
166
- : run.timed_out
167
- ? 'timed out'
168
- : run.exit_code === null
169
- ? 'killed'
170
- : `exit code ${run.exit_code}`,
171
- ...(existsSync(cwd) ? [] : ['the folder no longer exists']),
172
- ].join(', ')
173
-
174
- const gitOk = async ({
175
- cwd,
176
- args,
177
- }: {
178
- cwd: string
179
- args: string[]
180
- }): Promise<string> => {
181
- const result = await runGit({ cwd, args })
182
- if (result.exit_code !== 0) {
183
- const output = (result.stderr || result.stdout).trim()
184
- throw new Error(
185
- `git ${args.join(' ')} failed in ${cwd}: ${gitFailure({ cwd, run: result })}${output === '' ? '' : `\n${output}`}`
186
- )
187
- }
188
- return result.stdout
189
- }
190
-
191
- /** The global excludes file git would use. */
192
- const globalExcludesFile = async ({
193
- cwd,
194
- }: {
195
- cwd: string
196
- }): Promise<string> => {
197
- const set = await git({
198
- cwd,
199
- args: ['config', '--path', '--get', 'core.excludesFile'],
200
- })
201
- const xdg = process.env.XDG_CONFIG_HOME
202
- return set.ok
203
- ? set.stdout.trim()
204
- : join(
205
- xdg !== undefined && xdg !== ''
206
- ? xdg
207
- : join(homedir(), '.config'),
208
- 'git/ignore'
209
- )
210
- }
211
-
212
- /**
213
- * The ignore rules git would use but for `.gitignore` files: the global
214
- * excludes file, then the shared `.git/info/exclude` (whose lines win).
215
- */
216
- const excludeRules = async ({
217
- global,
218
- common_dir,
219
- }: {
220
- global: string
221
- common_dir: string
222
- }): Promise<Uint8Array> => {
223
- const read = async (full: string) =>
224
- existsSync(full) && lstatSync(full).isFile()
225
- ? Bun.file(full).text()
226
- : ''
227
- const [globalRules, shared] = await Promise.all([
228
- read(global),
229
- read(join(common_dir, 'info/exclude')),
230
- ])
231
- return new TextEncoder().encode(`${globalRules}\n${shared}`)
232
- }
233
-
234
- /** Runs `use` with the rules written to a temp file, removed after. */
235
- const withExcludeFile = async <Result>({
236
- rules,
237
- use,
238
- }: {
239
- rules: Uint8Array
240
- use: (file: string) => Promise<Result>
241
- }): Promise<Result> => {
242
- const file = join(tmpdir(), `luca-engine-exclude-${randomUUID()}`)
243
- await Bun.write(file, rules)
244
- try {
245
- return await use(file)
246
- } finally {
247
- await rm(file, { force: true })
248
- }
249
- }
250
-
251
- /** The `ls-files` options that ignore by the snapshot's rules. */
252
- const excludeArgs = (file: string): string[] => [
253
- `--exclude-from=${file}`,
254
- '--exclude-per-directory=.gitignore',
255
- ]
256
-
257
- /**
258
- * Every changed path: tracked and staged changes (renamed-from paths
259
- * included), and untracked files the snapshot's rules don't ignore.
260
- */
261
- const changedPaths = async ({
262
- cwd,
263
- exclude_file,
264
- }: {
265
- cwd: string
266
- exclude_file: string
267
- }): Promise<string[]> => {
268
- const [status, others] = await Promise.all([
269
- gitOk({
270
- cwd,
271
- args: ['status', '--porcelain=v1', '-z', '--untracked-files=no'],
272
- }),
273
- gitOk({
274
- cwd,
275
- args: ['ls-files', '--others', '-z', ...excludeArgs(exclude_file)],
276
- }),
277
- ])
278
- const parts = nulSplit(status)
279
- const paths: string[] = []
280
- for (let index = 0; index < parts.length; index += 1) {
281
- const part = parts[index] ?? ''
282
- paths.push(part.slice(3))
283
- if (part[0] === 'R' || part[0] === 'C') {
284
- index += 1
285
- paths.push(parts[index] ?? '')
286
- }
287
- }
288
- return [...paths, ...nulSplit(others)]
289
- }
290
-
291
- /** The ignored paths the after-turn check watches (see `watchesIgnored`). */
292
- const watchedIgnoredPaths = async ({
293
- cwd,
294
- config,
295
- exclude_file,
296
- }: {
297
- cwd: string
298
- config: EngineConfig
299
- exclude_file: string
300
- }): Promise<string[]> =>
301
- nulSplit(
302
- await gitOk({
303
- cwd,
304
- args: [
305
- 'ls-files',
306
- '--others',
307
- '--ignored',
308
- ...excludeArgs(exclude_file),
309
- '-z',
310
- ],
311
- })
312
- ).filter((path) => watchesIgnored({ path, config }))
313
-
314
- /** Whether a worktree-relative path is under a `node_modules` folder. */
315
- const inPackages = (path: string): boolean =>
316
- path.split('/').includes('node_modules')
317
-
318
- const hashBytes = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16)
319
-
320
- /** `lstat`, or `null` when nothing is there. A dangling link still is. */
321
- const lstatOrNull = (full: string): Stats | null => {
322
- try {
323
- return lstatSync(full)
324
- } catch {
325
- return null
326
- }
327
- }
328
-
329
- /**
330
- * A path's fingerprint: a file's content hash, a link's target, or
331
- * `DELETED`. Under `node_modules`, where hashing every file each turn is
332
- * slow, a file gets its size, inode, and change time instead: writing a
333
- * file always moves its change time, and no process can set it back.
334
- */
335
- const hashPath = async ({
336
- cwd,
337
- path,
338
- }: {
339
- cwd: string
340
- path: string
341
- }): Promise<string> => {
342
- const full = join(cwd, path)
343
- const stat = lstatOrNull(full)
344
- if (stat === null) return DELETED
345
- if (stat.isSymbolicLink()) return `link:${readlinkSync(full)}`
346
- if (stat.isDirectory()) return 'dir'
347
- if (inPackages(path)) {
348
- const big = lstatSync(full, { bigint: true })
349
- return `stat:${big.size}:${big.ino}:${big.ctimeNs}:${big.mode}`
350
- }
351
- return hashBytes(await Bun.file(full).bytes())
352
- }
353
-
354
- /** Name, mode, and content hash of each `.git/hooks` entry. */
355
- const hooksState = async ({
356
- common_dir,
357
- }: {
358
- common_dir: string
359
- }): Promise<string[]> => {
360
- const dir = join(common_dir, 'hooks')
361
- if (!existsSync(dir)) return ['missing']
362
- const hooks: string[] = []
363
- for (const name of sortBy(readdirSync(dir))) {
364
- const stat = lstatSync(join(dir, name))
365
- hooks.push(
366
- `${name} ${stat.mode.toString(8)} ${await hashPath({ cwd: dir, path: name })}`
367
- )
368
- }
369
- return hooks
370
- }
371
-
372
- /** Every entry of the shared `.git/config`, in file order, includes not followed. */
373
- const configEntries = async ({
374
- cwd,
375
- common_dir,
376
- }: {
377
- cwd: string
378
- common_dir: string
379
- }): Promise<ConfigEntry[]> =>
380
- nulSplit(
381
- await gitOk({
382
- cwd,
383
- args: [
384
- 'config',
385
- '--file',
386
- join(common_dir, 'config'),
387
- '--list',
388
- '-z',
389
- ],
390
- })
391
- ).map((entry) => {
392
- const at = entry.indexOf('\n')
393
- return at === -1
394
- ? { key: entry, value: null }
395
- : { key: entry.slice(0, at), value: entry.slice(at + 1) }
396
- })
397
-
398
- /**
399
- * Whether a config key is in the ticket branch's own section,
400
- * `branch.<branch>.<name>`. The branch name is matched exactly, and the
401
- * name after it has no dots, so `branch.a.b.remote` is branch `a.b`'s.
402
- */
403
- const isOwnKey = ({ key, branch }: { key: string; branch: string }) => {
404
- const prefix = `branch.${branch}.`
405
- return key.startsWith(prefix) && !key.slice(prefix.length).includes('.')
406
- }
407
-
408
- /** The shared `.git` as the check sees it: the branch's section, and the rest. */
409
- const sharedGitState = async ({
410
- cwd,
411
- branch,
412
- common_dir,
413
- }: {
414
- cwd: string
415
- branch: string
416
- common_dir: string
417
- }): Promise<{ branch_config: ConfigEntry[]; outside: OutsideGitState }> => {
418
- const [entries, exclude, hooks] = await Promise.all([
419
- configEntries({ cwd, common_dir }),
420
- existsSync(join(common_dir, 'info/exclude'))
421
- ? hashPath({ cwd: common_dir, path: 'info/exclude' })
422
- : DELETED,
423
- hooksState({ common_dir }),
424
- ])
425
- return {
426
- branch_config: entries.filter(({ key }) => isOwnKey({ key, branch })),
427
- outside: {
428
- config: entries
429
- .filter(({ key }) => !isOwnKey({ key, branch }))
430
- .map((entry) => JSON.stringify(entry)),
431
- exclude,
432
- hooks,
433
- },
434
- }
435
- }
436
-
437
- /**
438
- * Refs this ticket's worktree is not the one to judge: its own branch, and
439
- * the engine's run branch and other tickets' branches, which the engine
440
- * moves while agents work.
441
- */
442
- const isEngineRef = ({ ref, branch }: { ref: string; branch: string }) => {
443
- const run = branch.replace(/--ticket-\d+$/, '')
444
- const name = ref.replace(/^refs\/heads\//, '')
445
- return (
446
- ref.startsWith('refs/heads/') &&
447
- (name === branch || name === run || name.startsWith(`${run}--ticket-`))
448
- )
449
- }
450
-
451
- /** Every branch and tag: the object it names, and the commit it peels to. */
452
- const allRefs = async ({
453
- cwd,
454
- }: {
455
- cwd: string
456
- }): Promise<{ ref: string; object: string; commit: string }[]> =>
457
- (
458
- await gitOk({
459
- cwd,
460
- args: [
461
- 'for-each-ref',
462
- '--format=%(refname) %(objectname) %(*objectname)',
463
- 'refs/heads',
464
- 'refs/tags',
465
- ],
466
- })
467
- )
468
- .split('\n')
469
- .filter((line) => line.length > 0)
470
- .map((line) => {
471
- const [ref = '', object = '', peeled = ''] = line.split(' ')
472
- return { ref, object, commit: peeled === '' ? object : peeled }
473
- })
474
-
475
- const commonDirOf = async ({ cwd }: { cwd: string }): Promise<string> =>
476
- (
477
- await gitOk({
478
- cwd,
479
- args: ['rev-parse', '--path-format=absolute', '--git-common-dir'],
480
- })
481
- ).trim()
482
-
483
- /**
484
- * The git state an agent must not change, every ref, and the rest of the
485
- * shared `.git`, which others may change. The reads don't depend on each
486
- * other, so they run at once.
487
- */
488
- const gitState = async ({
489
- cwd,
490
- branch,
491
- common_dir,
492
- }: {
493
- cwd: string
494
- branch: string
495
- common_dir: string
496
- }): Promise<{
497
- state: GitState
498
- refs: Record<string, string>
499
- outside: OutsideGitState
500
- }> => {
501
- const [headLines, refs, stash, shared, staged] = await Promise.all([
502
- // The commit HEAD names, then the ref it points at (`HEAD` when
503
- // detached).
504
- gitOk({
505
- cwd,
506
- args: ['rev-parse', 'HEAD', '--symbolic-full-name', 'HEAD'],
507
- }),
508
- allRefs({ cwd }),
509
- git({ cwd, args: ['stash', 'list', '--format=%H %gs'] }),
510
- sharedGitState({ cwd, branch, common_dir }),
511
- gitOk({ cwd, args: ['diff', '--cached', '--name-only', '-z'] }),
512
- ])
513
- const [head = '', headRef = ''] = headLines.trim().split('\n')
514
- const byRef = Object.fromEntries(
515
- refs.map(({ ref, object }) => [ref, object])
516
- )
517
- return {
518
- outside: shared.outside,
519
- refs: byRef,
520
- state: {
521
- head,
522
- head_ref: headRef === '' || headRef === 'HEAD' ? null : headRef,
523
- branch_ref: byRef[`refs/heads/${branch}`] ?? null,
524
- refs_at_head: sortBy(
525
- refs
526
- .filter(
527
- ({ ref, commit }) =>
528
- commit === head && !isEngineRef({ ref, branch })
529
- )
530
- .map(({ ref }) => ref)
531
- ),
532
- stash: stash.stdout
533
- .split('\n')
534
- .filter((line) => line.includes(`${branch}:`)),
535
- staged: sortBy(nulSplit(staged)),
536
- branch_config: shared.branch_config,
537
- },
538
- }
539
- }
540
-
541
- /**
542
- * The fingerprint of each changed path, each watched ignored path, and each
543
- * path in `also` (the paths seen before the turn, so an ignored file that
544
- * is gone shows as deleted). Ignore rules come from `exclude_file`.
545
- */
546
- const fileStates = async ({
547
- cwd,
548
- config,
549
- exclude_file,
550
- also,
551
- }: {
552
- cwd: string
553
- config: EngineConfig
554
- exclude_file: string
555
- also?: string[]
556
- }): Promise<Record<string, string>> => {
557
- const files: Record<string, string> = {}
558
- const [changed, ignored] = await Promise.all([
559
- changedPaths({ cwd, exclude_file }),
560
- watchedIgnoredPaths({ cwd, config, exclude_file }),
561
- ])
562
- const paths = uniq([...changed, ...ignored, ...(also ?? [])])
563
- for (const path of paths) {
564
- files[path] = await hashPath({ cwd, path })
565
- }
566
- return files
567
- }
568
-
569
- /**
570
- * Snapshots a worktree before an agent turn: each changed path's content
571
- * hash and bytes, the ignored paths that matter, and the git state no agent
572
- * may change, with what it takes to put that back.
573
- *
574
- * @example
575
- * const before = await snapshotWorktree({ cwd, branch, config })
576
- * // ... the agent works ...
577
- * const { violations } = await enforceAfterTurn({ cwd, branch, role, may_edit_tests, config, before })
578
- */
579
- export const snapshotWorktree = async ({
580
- cwd,
581
- branch,
582
- config,
583
- }: {
584
- cwd: string
585
- branch: string
586
- config: EngineConfig
587
- }): Promise<TurnSnapshot> => {
588
- const [common_dir, global] = await Promise.all([
589
- commonDirOf({ cwd }),
590
- globalExcludesFile({ cwd }),
591
- ])
592
- const exclude_rules = await excludeRules({ global, common_dir })
593
- const [files, { state, refs, outside }] = await Promise.all([
594
- withExcludeFile({
595
- rules: exclude_rules,
596
- use: (exclude_file) => fileStates({ cwd, config, exclude_file }),
597
- }),
598
- gitState({ cwd, branch, common_dir }),
599
- ])
600
- const saved: Record<string, Uint8Array | null> = {}
601
- for (const [path, hash] of Object.entries(files)) {
602
- const full = join(cwd, path)
603
- const plain =
604
- hash !== DELETED && !inPackages(path) && lstatSync(full).isFile()
605
- saved[path] = plain ? await Bun.file(full).bytes() : null
606
- }
607
- return {
608
- files,
609
- saved,
610
- git: state,
611
- saved_git: { common_dir, refs, exclude_rules, outside },
612
- }
613
- }
614
-
615
- /**
616
- * Takes a ref an agent made off, keeping its commit under `refs/luca-undone/`
617
- * so nothing is lost: the refs are shared, and the engine can't tell who
618
- * made one. A ref that existed before goes back to where it was.
619
- */
620
- const undoRef = async ({
621
- cwd,
622
- ref,
623
- before,
624
- }: {
625
- cwd: string
626
- ref: string
627
- before: string | undefined
628
- }): Promise<void> => {
629
- const now = await git({ cwd, args: ['rev-parse', '--verify', '-q', ref] })
630
- if (!now.ok) return
631
- const object = now.stdout.trim()
632
- if (before !== undefined) {
633
- await gitOk({ cwd, args: ['update-ref', ref, before, object] })
634
- return
635
- }
636
- const kept = ref.replace(/^refs\//, 'refs/luca-undone/')
637
- await gitOk({ cwd, args: ['update-ref', kept, object] })
638
- await gitOk({ cwd, args: ['update-ref', '-d', ref, object] })
639
- }
640
-
641
- /**
642
- * Drops the stash entries an agent made. The stash is shared, so each is
643
- * found by its commit just before it is dropped, newest first.
644
- */
645
- const dropStashes = async ({
646
- cwd,
647
- entries,
648
- }: {
649
- cwd: string
650
- entries: string[]
651
- }): Promise<void> => {
652
- const shas = entries.map((entry) => entry.split(' ')[0] ?? '')
653
- for (const sha of shas) {
654
- const list = await git({ cwd, args: ['stash', 'list', '--format=%H'] })
655
- const index = list.stdout.split('\n').indexOf(sha)
656
- if (index !== -1) {
657
- await gitOk({
658
- cwd,
659
- args: ['stash', 'drop', '-q', `stash@{${index}}`],
660
- })
661
- }
662
- }
663
- }
664
-
665
- /** Puts HEAD, the branch, the index, refs, and stashes back; the files stay. */
666
- const restoreGit = async ({
667
- cwd,
668
- branch,
669
- before,
670
- after,
671
- saved,
672
- }: {
673
- cwd: string
674
- branch: string
675
- before: GitState
676
- after: GitState
677
- saved: SavedGit
678
- }): Promise<void> => {
679
- if (before.head_ref !== null) {
680
- await gitOk({ cwd, args: ['symbolic-ref', 'HEAD', before.head_ref] })
681
- }
682
- // A mixed reset: HEAD and the branch go back, the index is emptied of
683
- // the agent's staging, and the files in the worktree are left alone.
684
- await gitOk({ cwd, args: ['reset', '-q', before.head] })
685
- if (before.branch_ref !== null) {
686
- await gitOk({
687
- cwd,
688
- args: ['update-ref', `refs/heads/${branch}`, before.branch_ref],
689
- })
690
- }
691
- for (const ref of after.refs_at_head) {
692
- if (!before.refs_at_head.includes(ref)) {
693
- await undoRef({ cwd, ref, before: saved.refs[ref] })
694
- }
695
- }
696
- await dropStashes({
697
- cwd,
698
- entries: after.stash.filter((entry) => !before.stash.includes(entry)),
699
- })
700
- }
701
-
702
- /** Puts one path back how it was before the turn. */
703
- const restorePath = async ({
704
- cwd,
705
- path,
706
- before,
707
- }: {
708
- cwd: string
709
- path: string
710
- before: TurnSnapshot
711
- }): Promise<void> => {
712
- const full = join(cwd, path)
713
- const bytes = before.saved[path]
714
- if (bytes !== undefined && bytes !== null) {
715
- await mkdir(dirname(full), { recursive: true })
716
- await rm(full, { force: true, recursive: true })
717
- await Bun.write(full, bytes)
718
- return
719
- }
720
- if (bytes === undefined) {
721
- const tracked = await git({
722
- cwd,
723
- args: ['cat-file', '-e', `${before.git.head}:${path}`],
724
- })
725
- if (tracked.ok) {
726
- await rm(full, { force: true, recursive: true })
727
- await gitOk({
728
- cwd,
729
- args: ['checkout', before.git.head, '--', path],
730
- })
731
- await gitOk({ cwd, args: ['reset', '-q', '--', path] })
732
- return
733
- }
734
- }
735
- await rm(full, { force: true, recursive: true })
736
- await pruneEmptyFolders({ cwd, path })
737
- }
738
-
739
- /** Removes the folders a new path left empty, up to the worktree. */
740
- const pruneEmptyFolders = async ({
741
- cwd,
742
- path,
743
- }: {
744
- cwd: string
745
- path: string
746
- }): Promise<void> => {
747
- for (let folder = dirname(path); folder !== '.'; folder = dirname(folder)) {
748
- const full = join(cwd, folder)
749
- if (!existsSync(full) || readdirSync(full).length > 0) return
750
- await rm(full, { recursive: true, force: true })
751
- }
752
- }
753
-
754
- /**
755
- * The package folder a path under `node_modules` belongs to, such as
756
- * `node_modules/@scope/name` for `node_modules/@scope/name/lib/a.js`.
757
- */
758
- const packageFolder = (path: string): string => {
759
- const parts = path.split('/')
760
- const at = parts.lastIndexOf('node_modules')
761
- const scoped = parts[at + 1]?.startsWith('@') === true
762
- return parts.slice(0, at + (scoped ? 3 : 2)).join('/')
763
- }
764
-
765
- /**
766
- * Puts back files an agent changed or deleted under `node_modules`. Their
767
- * bytes are not kept (there are too many), so each package they belong to
768
- * is removed and the engine's own install puts it back from the lockfile.
769
- *
770
- * @returns Why the install failed, or `null`.
771
- */
772
- const reinstallPackages = async ({
773
- cwd,
774
- paths,
775
- }: {
776
- cwd: string
777
- paths: string[]
778
- }): Promise<string | null> => {
779
- for (const folder of uniq(paths.map(packageFolder))) {
780
- await rm(join(cwd, folder), { force: true, recursive: true })
781
- }
782
- const result = await runShell({ command: FROZEN_INSTALL, cwd })
783
- return result.exit_code === 0
784
- ? null
785
- : `the engine's install (${FROZEN_INSTALL}) failed, so node_modules may still hold what the agent changed:\n${result.stderr || result.stdout}`
786
- }
787
27
 
788
- /**
789
- * Puts the ticket branch's own config section back, key by key: each of its
790
- * keys now there is unset, then each saved entry is added back in order.
791
- * Every other key in the shared `.git/config` stays as it is now, since
792
- * other processes write there too. A key with no value comes back as
793
- * `true`, which git reads the same.
794
- *
795
- * @returns Why the undo failed (such as another process holding the
796
- * config's lock), or `null`.
797
- */
798
- const restoreBranchConfig = async ({
799
- cwd,
800
- before,
801
- after,
802
- common_dir,
803
- }: {
804
- cwd: string
805
- before: GitState
806
- after: GitState
807
- common_dir: string
808
- }): Promise<string | null> => {
809
- const file = join(common_dir, 'config')
810
- const steps = [
811
- ...uniq(after.branch_config.map(({ key }) => key)).map((key) => [
812
- 'config',
813
- '--file',
814
- file,
815
- '--unset-all',
816
- key,
817
- ]),
818
- ...before.branch_config.map(({ key, value }) => [
819
- 'config',
820
- '--file',
821
- file,
822
- '--add',
823
- key,
824
- value ?? 'true',
825
- ]),
826
- ]
827
- for (const args of steps) {
828
- const result = await git({ cwd, args })
829
- if (!result.ok) {
830
- return `git ${args.join(' ')} failed, so the ticket branch's settings in the shared .git/config may still hold what the agent changed`
831
- }
832
- }
833
- return null
834
- }
28
+ export { snapshotWorktree, type TurnSnapshot } from './worktree-snapshot'
835
29
 
836
30
  /**
837
31
  * The after-turn check: compares the worktree and its git state with the
838
32
  * snapshot from before the turn, against the role's rules. Undoes every
839
- * violation: HEAD, the branch, the index, new refs, new stash entries, and
840
- * the ticket branch's own settings in the shared `.git/config` (key by
841
- * key), then each path the role may not write (tracked paths as they were,
842
- * new paths removed, `node_modules` reinstalled).
33
+ * violation: HEAD, the branch, the index, the new refs the agent made at a
34
+ * commit it made, new stash entries, and the ticket branch's own settings
35
+ * in the shared `.git/config` (key by key), then each path the role may not
36
+ * write (tracked paths as they were, new paths removed, `node_modules`
37
+ * reinstalled).
843
38
  *
844
39
  * The rest of the shared `.git` (other config keys, `info/exclude`, hooks)
845
40
  * is other processes' to change: a change there is returned in `outside`,
@@ -890,9 +85,16 @@ export const enforceAfterTurn = async ({
890
85
  gitState({ cwd, branch, common_dir }),
891
86
  listFiles(),
892
87
  ])
88
+ const head_is_new = await headIsNew({
89
+ cwd,
90
+ before: before.git,
91
+ after: now.state,
92
+ saved: before.saved_git,
93
+ })
893
94
  const problems = gitViolations({
894
95
  before: before.git,
895
96
  after: now.state,
97
+ head_is_new,
896
98
  })
897
99
  if (problems.length > 0) {
898
100
  await restoreGit({
@@ -901,6 +103,7 @@ export const enforceAfterTurn = async ({
901
103
  before: before.git,
902
104
  after: now.state,
903
105
  saved: before.saved_git,
106
+ head_is_new,
904
107
  })
905
108
  }
906
109
  const failed =