@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,344 @@
1
+ /**
2
+ * Pure: which shell commands each role may run, checked one plain command at
3
+ * a time.
4
+ */
5
+ import {
6
+ ALLOW,
7
+ deny,
8
+ isWriter,
9
+ type GuardArgs,
10
+ type GuardRole,
11
+ type ToolDecision,
12
+ } from './guard-roles'
13
+ import { insideWorktree, mayWrite } from './write-rules'
14
+
15
+ import {
16
+ slowTestCommands,
17
+ testCommands,
18
+ type EngineConfig,
19
+ type TestCommand,
20
+ } from '../config/engine-config'
21
+
22
+ /** One word of a shell command, and whether the shell would expand it. */
23
+ type Word = { text: string; expands: boolean }
24
+
25
+ /**
26
+ * Splits a command the way `sh` would, for the plain commands the guard
27
+ * allows. Fails on anything that chains, pipes, redirects, substitutes, or
28
+ * runs in the background, so one call is one command.
29
+ */
30
+ const splitCommand = (
31
+ command: string
32
+ ): { ok: true; words: Word[] } | { ok: false; error: string } => {
33
+ const words: Word[] = []
34
+ let text = ''
35
+ let expands = false
36
+ let inWord = false
37
+ let quote: "'" | '"' | null = null
38
+ const bad = (char: string) => ({
39
+ ok: false as const,
40
+ error: `"${char}" is not allowed: run one plain command per shell call, with no chains, pipes, redirects, or subshells.`,
41
+ })
42
+ for (const char of command) {
43
+ if (quote === "'") {
44
+ if (char === "'") quote = null
45
+ else text += char
46
+ continue
47
+ }
48
+ if (quote === '"') {
49
+ if (char === '"') quote = null
50
+ else if ('$`\\'.includes(char)) return bad(char)
51
+ else text += char
52
+ continue
53
+ }
54
+ if (char === "'" || char === '"') {
55
+ quote = char
56
+ inWord = true
57
+ continue
58
+ }
59
+ if (char === ' ' || char === '\t') {
60
+ if (inWord) words.push({ text, expands })
61
+ text = ''
62
+ expands = false
63
+ inWord = false
64
+ continue
65
+ }
66
+ if (';&|<>`$(){}\\!#\n\r'.includes(char)) return bad(char)
67
+ if ('*?[]~'.includes(char)) expands = true
68
+ text += char
69
+ inWord = true
70
+ }
71
+ if (quote !== null) return { ok: false, error: 'An unclosed quote.' }
72
+ if (inWord) words.push({ text, expands })
73
+ return { ok: true, words }
74
+ }
75
+
76
+ export const wordsOf = (command: string): string[] | null => {
77
+ const split = splitCommand(command)
78
+ return split.ok ? split.words.map(({ text }) => text) : null
79
+ }
80
+
81
+ const startsWith = ({
82
+ words,
83
+ prefix,
84
+ }: {
85
+ words: string[]
86
+ prefix: string[]
87
+ }): boolean => prefix.every((word, index) => words[index] === word)
88
+
89
+ /**
90
+ * The check commands from the config a role may run: each test command,
91
+ * each slow test command (#563), types, lint. A `bun` test command may take extra arguments; the rest run
92
+ * only as written.
93
+ */
94
+ export const checkCommands = ({
95
+ role,
96
+ config,
97
+ }: {
98
+ role: GuardRole
99
+ config: EngineConfig
100
+ }): { tests: TestCommand[]; others: string[] } => {
101
+ if (!isWriter(role)) return { tests: [], others: [] }
102
+ const { types, lint } = config.checks
103
+ return {
104
+ tests: [...testCommands({ config }), ...slowTestCommands({ config })],
105
+ others: [types, lint].filter(
106
+ (command): command is string => command !== undefined
107
+ ),
108
+ }
109
+ }
110
+
111
+ /** The configured commands that run only as written, tests included. */
112
+ const exactCommands = ({
113
+ tests,
114
+ others,
115
+ }: {
116
+ tests: TestCommand[]
117
+ others: string[]
118
+ }): string[] => [
119
+ ...tests.filter(({ results }) => results !== 'bun').map(({ run }) => run),
120
+ ...others,
121
+ ]
122
+
123
+ export const GIT_READ_SUBCOMMANDS = [
124
+ 'status',
125
+ 'diff',
126
+ 'log',
127
+ 'show',
128
+ 'ls-files',
129
+ 'blame',
130
+ ]
131
+
132
+ const FIND_WRITES = [
133
+ '-exec',
134
+ '-execdir',
135
+ '-ok',
136
+ '-okdir',
137
+ '-delete',
138
+ '-fprint',
139
+ '-fprint0',
140
+ '-fprintf',
141
+ '-fls',
142
+ ]
143
+
144
+ /** `sed -n` with a line-range print script only: `5p`, `1,20p`, `$p`. */
145
+ const SED_PRINT = /^(\d+|\$)(,(\d+|\$))?p$/
146
+
147
+ /**
148
+ * The read-only commands every role with a shell may run, each a check of
149
+ * its words (the command name is `words[0]`).
150
+ */
151
+ const READ_ONLY: Record<string, (words: string[]) => boolean> = {
152
+ ls: () => true,
153
+ cat: () => true,
154
+ head: () => true,
155
+ wc: () => true,
156
+ grep: () => true,
157
+ pwd: (words) => words.length === 1,
158
+ tail: (words) =>
159
+ !words.some(
160
+ (word) =>
161
+ /^-[a-zA-Z]*[fF]/.test(word) || word.startsWith('--follow')
162
+ ),
163
+ rg: (words) => !words.some((word) => word.startsWith('--pre')),
164
+ find: (words) => !words.some((word) => FIND_WRITES.includes(word)),
165
+ sed: (words) =>
166
+ words[1] === '-n' &&
167
+ SED_PRINT.test(words[2] ?? '') &&
168
+ words.slice(3).every((word) => !word.startsWith('-')),
169
+ git: (words) =>
170
+ GIT_READ_SUBCOMMANDS.includes(words[1] ?? '') &&
171
+ !words.some(
172
+ (word) => word.startsWith('--output') || word === '--ext-diff'
173
+ ),
174
+ }
175
+
176
+ /** The read-only commands, as a person reads them. */
177
+ export const READ_ONLY_COMMANDS = [
178
+ 'ls',
179
+ 'cat',
180
+ 'head',
181
+ 'tail',
182
+ 'wc',
183
+ 'grep',
184
+ 'rg',
185
+ 'find (no -exec, -delete, or -fprint)',
186
+ 'pwd',
187
+ "sed -n '<from>,<to>p' <file>",
188
+ `git ${GIT_READ_SUBCOMMANDS.join('|')} (no options before the subcommand)`,
189
+ ]
190
+
191
+ /** Package managers other than Bun: any use of one may install. */
192
+ export const PACKAGE_MANAGERS = ['npm', 'npx', 'yarn', 'pnpm', 'pnpx']
193
+
194
+ /** `bun` subcommands that install, remove, or fetch packages. */
195
+ const BUN_INSTALLS = [
196
+ 'install',
197
+ 'i',
198
+ 'add',
199
+ 'a',
200
+ 'remove',
201
+ 'rm',
202
+ 'update',
203
+ 'upgrade',
204
+ 'link',
205
+ 'unlink',
206
+ 'pm',
207
+ 'patch',
208
+ 'create',
209
+ 'x',
210
+ ]
211
+
212
+ /** Bun's flags that turn on installing missing packages in any command. */
213
+ const isInstallFlag = (word: string): boolean =>
214
+ word === '-i' || word === '--install' || word.startsWith('--install=')
215
+
216
+ /**
217
+ * Whether a command runs a package install, or may: a package manager, a
218
+ * `bun` install subcommand, `bunx`, or `bun` with an auto-install flag.
219
+ */
220
+ const isInstallCommand = (words: string[]): boolean => {
221
+ const [name, sub] = words
222
+ if (name === undefined) return false
223
+ if (PACKAGE_MANAGERS.includes(name) || name === 'bunx') return true
224
+ if (name !== 'bun') return false
225
+ return BUN_INSTALLS.includes(sub ?? '') || words.some(isInstallFlag)
226
+ }
227
+
228
+ const INSTALL_DENIED =
229
+ 'The engine runs the package install, never an agent: if you change a package manifest, the engine installs after your turn and commits the lockfile.'
230
+
231
+ const checkRm = ({
232
+ role,
233
+ may_edit_tests,
234
+ words,
235
+ worktree,
236
+ config,
237
+ }: GuardArgs & {
238
+ words: Word[]
239
+ worktree: string
240
+ config: EngineConfig
241
+ }): ToolDecision => {
242
+ if (!isWriter(role)) return deny(`A ${role} may not delete files.`)
243
+ const paths: Word[] = []
244
+ let options = true
245
+ for (const word of words.slice(1)) {
246
+ if (options && word.text === '--') {
247
+ options = false
248
+ continue
249
+ }
250
+ if (options && word.text.startsWith('-')) {
251
+ if (word.text !== '-f') {
252
+ return deny(
253
+ 'rm takes only -f: delete files one by one, not folders.'
254
+ )
255
+ }
256
+ continue
257
+ }
258
+ paths.push(word)
259
+ }
260
+ if (paths.length === 0) return deny('rm needs at least one path.')
261
+ for (const word of paths) {
262
+ const inner = insideWorktree({ worktree, path: word.text })
263
+ if (word.expands || inner === null) {
264
+ return deny(
265
+ `rm ${word.text}: name each file inside your worktree, with no wildcards or ~.`
266
+ )
267
+ }
268
+ if (!mayWrite({ role, may_edit_tests, path: inner, config })) {
269
+ return deny(`A ${role} may not delete ${inner}.`)
270
+ }
271
+ }
272
+ return ALLOW
273
+ }
274
+
275
+ const shellHelp = ({
276
+ role,
277
+ config,
278
+ }: {
279
+ role: GuardRole
280
+ config: EngineConfig
281
+ }): string => {
282
+ const { tests, others } = checkCommands({ role, config })
283
+ const checks = [
284
+ ...tests.map(({ run, results }) =>
285
+ results === 'bun' ? `${run} [args]` : run
286
+ ),
287
+ ...others,
288
+ ...(isWriter(role) ? ['rm [-f] <files you may write>'] : []),
289
+ ]
290
+ return [
291
+ `A ${role} may run: ${[...checks, ...READ_ONLY_COMMANDS].join('; ')}.`,
292
+ 'One command per call.',
293
+ ].join(' ')
294
+ }
295
+
296
+ export const checkBash = ({
297
+ role,
298
+ may_edit_tests,
299
+ command,
300
+ worktree,
301
+ config,
302
+ }: GuardArgs & {
303
+ command: string
304
+ worktree: string
305
+ config: EngineConfig
306
+ }): ToolDecision => {
307
+ if (role === 'learner') return deny('The learner has no shell.')
308
+ const trimmed = command.trim()
309
+ const { tests, others } = checkCommands({ role, config })
310
+ // The config's own commands may use shell syntax; only exact copies run.
311
+ if (tests.some(({ run }) => run === trimmed) || others.includes(trimmed)) {
312
+ return ALLOW
313
+ }
314
+ const split = splitCommand(trimmed)
315
+ if (!split.ok) return deny(`${split.error} ${shellHelp({ role, config })}`)
316
+ const words = split.words.map(({ text }) => text)
317
+ const [name] = words
318
+ if (name === undefined) return deny('An empty command.')
319
+ const exact = exactCommands({ tests, others })
320
+ if (exact.some((other) => wordsOf(other)?.join(' ') === words.join(' '))) {
321
+ return ALLOW
322
+ }
323
+ if (isInstallCommand(words)) return deny(INSTALL_DENIED)
324
+ for (const { run, results } of tests) {
325
+ const testWords = results === 'bun' ? wordsOf(run) : null
326
+ if (testWords !== null && startsWith({ words, prefix: testWords })) {
327
+ return ALLOW
328
+ }
329
+ }
330
+ if (name === 'rm') {
331
+ return checkRm({
332
+ role,
333
+ may_edit_tests,
334
+ words: split.words,
335
+ worktree,
336
+ config,
337
+ })
338
+ }
339
+ const readOnly = READ_ONLY[name]
340
+ if (readOnly !== undefined && readOnly(words)) return ALLOW
341
+ return deny(
342
+ `"${trimmed}" is not allowed. ${shellHelp({ role, config })} No git writes, GitHub, or network.`
343
+ )
344
+ }
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { existsSync, lstatSync } from 'node:fs'
3
+ import { rm } from 'node:fs/promises'
4
+ import { homedir, tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import uniq from 'lodash/uniq'
8
+
9
+ import { runCommand, type CommandResult } from '../shell/run-command'
10
+
11
+ /**
12
+ * Put before every git command the check runs, so an fsmonitor or hook
13
+ * someone planted in the shared `.git` never runs for the engine. Content
14
+ * filters are switched off too, by `noFilters`.
15
+ */
16
+ const SAFE_GIT = [
17
+ '-c',
18
+ 'core.fsmonitor=false',
19
+ '-c',
20
+ 'core.hooksPath=/dev/null',
21
+ ]
22
+
23
+ export const nulSplit = (text: string): string[] =>
24
+ text.split('\0').filter((part) => part.length > 0)
25
+
26
+ /** The environment variables `noFilters` points git's settings at. */
27
+ const NO_FILTER_ENV = { LUCA_GIT_EMPTY: '', LUCA_GIT_FALSE: 'false' }
28
+
29
+ /**
30
+ * Every content filter (`filter.<name>.*`) git's config names, read fresh
31
+ * for each command, since another process may add one at any time.
32
+ */
33
+ const filterNames = async ({ cwd }: { cwd: string }): Promise<string[]> => {
34
+ const listed = await runCommand({
35
+ cmd: ['git', ...SAFE_GIT, 'config', '-z', '--get-regexp', '^filter\\.'],
36
+ cwd,
37
+ timeout_ms: 60_000,
38
+ })
39
+ return uniq(
40
+ nulSplit(listed.stdout).flatMap((entry) => {
41
+ const key = entry.split('\n')[0] ?? ''
42
+ const last = key.lastIndexOf('.')
43
+ // `>=` keeps the empty name (`filter..clean`): git runs a filter named "".
44
+ return last >= 'filter.'.length
45
+ ? [key.slice('filter.'.length, last)]
46
+ : []
47
+ })
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Options that switch each named filter off: no clean, smudge, or process
53
+ * command, and not required (git gives up on a required filter with no
54
+ * command). Set through `--config-env`, which takes any filter name, even
55
+ * one with `=` in it.
56
+ */
57
+ const noFilters = (names: string[]): string[] =>
58
+ names.flatMap((name) => [
59
+ `--config-env=filter.${name}.clean=LUCA_GIT_EMPTY`,
60
+ `--config-env=filter.${name}.smudge=LUCA_GIT_EMPTY`,
61
+ `--config-env=filter.${name}.process=LUCA_GIT_EMPTY`,
62
+ `--config-env=filter.${name}.required=LUCA_GIT_FALSE`,
63
+ ])
64
+
65
+ /** A git run, and why git could not start (such as `cwd` being gone). */
66
+ type GitRun = CommandResult & { start_error: string | null }
67
+
68
+ const runGit = async ({
69
+ cwd,
70
+ args,
71
+ }: {
72
+ cwd: string
73
+ args: string[]
74
+ }): Promise<GitRun> => {
75
+ try {
76
+ const names = await filterNames({ cwd })
77
+ const result = await runCommand({
78
+ cmd: ['git', ...SAFE_GIT, ...noFilters(names), ...args],
79
+ cwd,
80
+ timeout_ms: 60_000,
81
+ env: NO_FILTER_ENV,
82
+ })
83
+ return { ...result, start_error: null }
84
+ } catch (error) {
85
+ return {
86
+ exit_code: null,
87
+ stdout: '',
88
+ stderr: '',
89
+ timed_out: false,
90
+ start_error: error instanceof Error ? error.message : String(error),
91
+ }
92
+ }
93
+ }
94
+
95
+ export const git = async ({
96
+ cwd,
97
+ args,
98
+ }: {
99
+ cwd: string
100
+ args: string[]
101
+ }): Promise<{ ok: boolean; stdout: string }> => {
102
+ const result = await runGit({ cwd, args })
103
+ return { ok: result.exit_code === 0, stdout: result.stdout }
104
+ }
105
+
106
+ /** Why a git run failed, said even when git printed nothing. */
107
+ const gitFailure = ({ cwd, run }: { cwd: string; run: GitRun }): string =>
108
+ [
109
+ run.start_error !== null
110
+ ? `could not start (${run.start_error})`
111
+ : run.timed_out
112
+ ? 'timed out'
113
+ : run.exit_code === null
114
+ ? 'killed'
115
+ : `exit code ${run.exit_code}`,
116
+ ...(existsSync(cwd) ? [] : ['the folder no longer exists']),
117
+ ].join(', ')
118
+
119
+ export const gitOk = async ({
120
+ cwd,
121
+ args,
122
+ }: {
123
+ cwd: string
124
+ args: string[]
125
+ }): Promise<string> => {
126
+ const result = await runGit({ cwd, args })
127
+ if (result.exit_code !== 0) {
128
+ const output = (result.stderr || result.stdout).trim()
129
+ throw new Error(
130
+ `git ${args.join(' ')} failed in ${cwd}: ${gitFailure({ cwd, run: result })}${output === '' ? '' : `\n${output}`}`
131
+ )
132
+ }
133
+ return result.stdout
134
+ }
135
+
136
+ /** The global excludes file git would use. */
137
+ export const globalExcludesFile = async ({
138
+ cwd,
139
+ }: {
140
+ cwd: string
141
+ }): Promise<string> => {
142
+ const set = await git({
143
+ cwd,
144
+ args: ['config', '--path', '--get', 'core.excludesFile'],
145
+ })
146
+ const xdg = process.env.XDG_CONFIG_HOME
147
+ return set.ok
148
+ ? set.stdout.trim()
149
+ : join(
150
+ xdg !== undefined && xdg !== ''
151
+ ? xdg
152
+ : join(homedir(), '.config'),
153
+ 'git/ignore'
154
+ )
155
+ }
156
+
157
+ /**
158
+ * The ignore rules git would use but for `.gitignore` files: the global
159
+ * excludes file, then the shared `.git/info/exclude` (whose lines win).
160
+ */
161
+ export const excludeRules = async ({
162
+ global,
163
+ common_dir,
164
+ }: {
165
+ global: string
166
+ common_dir: string
167
+ }): Promise<Uint8Array> => {
168
+ const read = async (full: string) =>
169
+ existsSync(full) && lstatSync(full).isFile()
170
+ ? Bun.file(full).text()
171
+ : ''
172
+ const [globalRules, shared] = await Promise.all([
173
+ read(global),
174
+ read(join(common_dir, 'info/exclude')),
175
+ ])
176
+ return new TextEncoder().encode(`${globalRules}\n${shared}`)
177
+ }
178
+
179
+ /** Runs `use` with the rules written to a temp file, removed after. */
180
+ export const withExcludeFile = async <Result>({
181
+ rules,
182
+ use,
183
+ }: {
184
+ rules: Uint8Array
185
+ use: (file: string) => Promise<Result>
186
+ }): Promise<Result> => {
187
+ const file = join(tmpdir(), `luca-engine-exclude-${randomUUID()}`)
188
+ await Bun.write(file, rules)
189
+ try {
190
+ return await use(file)
191
+ } finally {
192
+ await rm(file, { force: true })
193
+ }
194
+ }
195
+
196
+ /** The `ls-files` options that ignore by the snapshot's rules. */
197
+ export const excludeArgs = (file: string): string[] => [
198
+ `--exclude-from=${file}`,
199
+ '--exclude-per-directory=.gitignore',
200
+ ]
201
+
202
+ export const commonDirOf = async ({ cwd }: { cwd: string }): Promise<string> =>
203
+ (
204
+ await gitOk({
205
+ cwd,
206
+ args: ['rev-parse', '--path-format=absolute', '--git-common-dir'],
207
+ })
208
+ ).trim()