@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.
- package/CHANGELOG.md +76 -0
- package/README.md +7 -2
- package/board/client/board-look.ts +31 -1
- package/board/client/board-panel-parts.tsx +150 -0
- package/board/client/board-panel-stages.ts +99 -0
- package/board/client/board-panel-styles.ts +223 -0
- package/board/client/board-panel.tsx +77 -1275
- package/board/client/engine-life.ts +48 -0
- package/board/client/final-review-stack.tsx +144 -0
- package/board/client/heartbeat.tsx +61 -0
- package/board/client/helper-lines.tsx +69 -0
- package/board/client/live-feed.tsx +86 -0
- package/board/client/live-line.ts +73 -0
- package/board/client/model-line.tsx +178 -0
- package/board/client/needs-you-section.tsx +133 -0
- package/board/client/one-line.tsx +193 -0
- package/board/client/repo-wait-banner.tsx +69 -0
- package/board/client/run-footer.tsx +68 -0
- package/board/client/run-header.tsx +189 -0
- package/board/client/run-picker.tsx +98 -0
- package/board/client/status-bar.tsx +131 -0
- package/board/client/step-dots.tsx +127 -0
- package/board/client/stuck-actions.tsx +269 -0
- package/board/client/ticket-card-view.tsx +238 -0
- package/board/client/ticket-detail.tsx +87 -0
- package/board/client/ticket-stack.tsx +119 -0
- package/board/client/ticket-wait.ts +188 -0
- package/board/client/timeline-rows.tsx +41 -14
- package/board/client/usage-line.tsx +51 -0
- package/board/client/wait-line.tsx +39 -0
- package/board/index.client.tsx +1 -1
- package/board/index.server.ts +7 -1
- package/board/package.json +1 -1
- package/board/server/board-panel-state-helpers.ts +67 -0
- package/board/server/board-server-check.ts +294 -0
- package/board/server/board-server-engine.ts +129 -0
- package/board/server/board-server-events.ts +134 -0
- package/board/server/board-server-read.ts +190 -0
- package/board/server/board-server-resume.ts +164 -0
- package/board/server/board-server-start.ts +174 -0
- package/board/server/board-server-store.ts +281 -0
- package/board/server/board-server-types.ts +114 -0
- package/board/server/board-server-util.ts +4 -0
- package/board/server/board-server.ts +52 -943
- package/board/server/board-vocabulary-engine.ts +144 -0
- package/board/server/board-vocabulary-review.ts +115 -0
- package/board/server/board-vocabulary-run.ts +145 -0
- package/board/server/board-vocabulary-shapes.ts +153 -0
- package/board/server/board-vocabulary.ts +20 -375
- package/board/server/current-step-panel-helpers.ts +108 -0
- package/board/server/describe-record.ts +403 -0
- package/board/server/engine-launch.ts +20 -3
- package/board/server/engine-restart-helpers.ts +105 -0
- package/board/server/engine-watch.ts +45 -5
- package/board/server/journal-rebuild-helpers.ts +169 -0
- package/board/server/live-file.ts +130 -0
- package/board/server/luca-version.ts +1 -1
- package/board/server/make-rows.ts +14 -629
- package/board/server/record-texts.ts +350 -0
- package/board/server/reduce-board-agent-start.ts +224 -0
- package/board/server/reduce-board-agents.ts +160 -0
- package/board/server/reduce-board-counters.ts +138 -0
- package/board/server/reduce-board-create.ts +148 -0
- package/board/server/reduce-board-final-review.ts +174 -0
- package/board/server/reduce-board-needs-you.ts +283 -0
- package/board/server/reduce-board-reasons.ts +59 -0
- package/board/server/reduce-board-run.ts +255 -0
- package/board/server/reduce-board-settle.ts +88 -0
- package/board/server/reduce-board-steps.ts +226 -0
- package/board/server/reduce-board-text.ts +139 -0
- package/board/server/reduce-board-ticket-checks.ts +168 -0
- package/board/server/reduce-board-ticket-join.ts +144 -0
- package/board/server/reduce-board-ticket-start.ts +132 -0
- package/board/server/reduce-board-update.ts +117 -0
- package/board/server/reduce-board-usage.ts +247 -0
- package/board/server/reduce-board.ts +100 -1917
- package/board/server/reply-post.ts +407 -0
- package/board/server/run-registry.ts +8 -4
- package/board/server/ticket-stages-panel-helpers.ts +296 -0
- package/board/shared/board-rows.ts +9 -0
- package/board/shared/board-rpc.ts +42 -0
- package/board/shared/board-state-counts.ts +77 -0
- package/board/shared/board-state-live.ts +99 -0
- package/board/shared/board-state-review.ts +59 -0
- package/board/shared/board-state-run.ts +236 -0
- package/board/shared/board-state-ticket.ts +257 -0
- package/board/shared/board-state-usage.ts +107 -0
- package/board/shared/board-state.ts +97 -567
- package/board/shared/reply-actions.ts +207 -0
- package/engine/agents/agent-launcher.ts +27 -4
- package/engine/agents/claude-launcher-helpers.ts +233 -0
- package/engine/agents/claude-launcher.ts +28 -669
- package/engine/agents/claude-messages.ts +212 -0
- package/engine/agents/claude-options.ts +43 -8
- package/engine/agents/common-instructions.ts +138 -0
- package/engine/agents/input-channel.ts +36 -0
- package/engine/agents/launch-paths.ts +29 -0
- package/engine/agents/launch-session.ts +227 -0
- package/engine/agents/learner-instructions.ts +31 -0
- package/engine/agents/open-session.ts +210 -0
- package/engine/agents/prompt-sections.ts +95 -0
- package/engine/agents/rejoin-prompts.ts +135 -0
- package/engine/agents/reviewer-instructions.ts +99 -0
- package/engine/agents/role-instructions.ts +31 -244
- package/engine/agents/role-prompts.ts +39 -109
- package/engine/agents/role-results.ts +106 -7
- package/engine/agents/scripted-launcher.ts +21 -6
- package/engine/agents/session-pool.ts +124 -0
- package/engine/agents/turn-progress.ts +283 -0
- package/engine/agents/writer-instructions.ts +151 -0
- package/engine/cli/computer-adapters-real.ts +15 -3
- package/engine/cli/computer-adapters.ts +2 -0
- package/engine/cli/computer-checks.ts +87 -4
- package/engine/cli/config-reload-text.ts +39 -0
- package/engine/cli/decision-model-setup.ts +70 -0
- package/engine/cli/doctor-command.ts +1 -0
- package/engine/cli/doctor.ts +21 -3
- package/engine/cli/going-runs.ts +192 -36
- package/engine/cli/init-command.ts +1 -0
- package/engine/cli/init.ts +23 -1
- package/engine/cli/live-runs.ts +73 -0
- package/engine/cli/luca-run.ts +42 -7
- package/engine/cli/luca-skills.ts +183 -0
- package/engine/cli/luca.ts +8 -1
- package/engine/cli/models-command.ts +119 -0
- package/engine/cli/read-run.ts +81 -0
- package/engine/cli/run-args.ts +33 -6
- package/engine/cli/run-demo.ts +143 -0
- package/engine/cli/run-end.ts +184 -0
- package/engine/cli/run-modes-helpers.ts +81 -0
- package/engine/cli/run-modes.ts +62 -439
- package/engine/cli/setup.ts +5 -48
- package/engine/cli/unfinished-runs.ts +122 -0
- package/engine/cli/upgrade-command.ts +212 -30
- package/engine/cli/upgrade-finish.ts +149 -0
- package/engine/cli/upgrade.ts +112 -71
- package/engine/config/config-fields.ts +127 -0
- package/engine/config/config-reload.ts +250 -0
- package/engine/config/engine-config-schema.ts +235 -0
- package/engine/config/engine-config.ts +72 -142
- package/engine/config/test-commands.ts +102 -0
- package/engine/core/agent-guards-helpers.ts +84 -0
- package/engine/core/agent-turn.ts +395 -0
- package/engine/core/already-done.ts +44 -1
- package/engine/core/build-action.ts +214 -0
- package/engine/core/build-context.ts +135 -0
- package/engine/core/build-launch.ts +259 -0
- package/engine/core/commit-in.ts +282 -0
- package/engine/core/decide-build-early.ts +108 -0
- package/engine/core/decide-build-ending.ts +145 -0
- package/engine/core/decide-build-helpers.ts +87 -0
- package/engine/core/decide-build-tickets.ts +207 -0
- package/engine/core/decide-build-view.ts +103 -0
- package/engine/core/decide-build.ts +101 -1368
- package/engine/core/decide-final-fix.ts +203 -0
- package/engine/core/decide-final-review.ts +55 -310
- package/engine/core/decide-red-green.ts +398 -0
- package/engine/core/decide-rejoin.ts +259 -0
- package/engine/core/decide-repo-wait.ts +129 -0
- package/engine/core/decide-run-budget.ts +86 -12
- package/engine/core/decide-stuck-helpers.ts +127 -0
- package/engine/core/decide-stuck.ts +60 -8
- package/engine/core/decide-ticket-review.ts +95 -0
- package/engine/core/decide-ticket.ts +267 -0
- package/engine/core/decide-waits.ts +47 -0
- package/engine/core/decide.ts +74 -23
- package/engine/core/engine-chores.ts +163 -0
- package/engine/core/environment-rerun.ts +83 -0
- package/engine/core/evidence-text.ts +84 -0
- package/engine/core/execute-action.ts +303 -0
- package/engine/core/execute-already-done-check.ts +133 -0
- package/engine/core/execute-build.ts +68 -944
- package/engine/core/execute-changeset.ts +5 -7
- package/engine/core/execute-final-review.ts +125 -13
- package/engine/core/execute-gates.ts +182 -0
- package/engine/core/execute-intake.ts +130 -0
- package/engine/core/execute-join.ts +276 -0
- package/engine/core/execute-memory.ts +12 -0
- package/engine/core/execute-pull-request.ts +84 -0
- package/engine/core/execute-red-check.ts +219 -0
- package/engine/core/execute-repo-wait.ts +129 -0
- package/engine/core/execute-stuck.ts +25 -10
- package/engine/core/execute-waits.ts +158 -0
- package/engine/core/execute-with-jev.ts +118 -0
- package/engine/core/execute.ts +124 -831
- package/engine/core/final-failure-cap.ts +38 -0
- package/engine/core/final-review-action.ts +125 -0
- package/engine/core/final-review-text.ts +63 -17
- package/engine/core/final-stuck-text.ts +86 -0
- package/engine/core/fix-loop-text.ts +49 -2
- package/engine/core/left-out.ts +120 -0
- package/engine/core/live-file.ts +372 -0
- package/engine/core/loop-caps.ts +25 -2
- package/engine/core/not-in-run.ts +7 -3
- package/engine/core/pull-request-text.ts +255 -6
- package/engine/core/repo-wait-text.ts +81 -0
- package/engine/core/review-text.ts +55 -1
- package/engine/core/run-crash-recovery-helpers.ts +16 -0
- package/engine/core/run-session-close-helpers.ts +96 -0
- package/engine/core/run-start.ts +148 -0
- package/engine/core/run-stuck-text.ts +86 -0
- package/engine/core/schedule-rules.ts +193 -0
- package/engine/core/stuck-detail-text.ts +89 -0
- package/engine/core/stuck-format.ts +22 -0
- package/engine/core/stuck-text.ts +29 -332
- package/engine/core/ticket-stuck-text.ts +192 -0
- package/engine/core/worktree-changes.ts +106 -0
- package/engine/gates/already-done-check.ts +120 -0
- package/engine/gates/feature-map.ts +96 -0
- package/engine/gates/gate-runner.ts +108 -108
- package/engine/gates/gate-schemas.ts +61 -2
- package/engine/gates/leftover-scan.ts +154 -31
- package/engine/gates/new-tests.ts +45 -0
- package/engine/gates/own-files-check.ts +98 -0
- package/engine/gates/prepare-check.ts +60 -0
- package/engine/gates/prepare-slots.ts +99 -0
- package/engine/gates/red-check.ts +184 -16
- package/engine/gates/shell-check.ts +64 -0
- package/engine/gates/slot-waits.ts +55 -0
- package/engine/gates/slow-test-runner.ts +59 -0
- package/engine/gates/test-checks.ts +161 -0
- package/engine/gates/test-runner.ts +164 -5
- package/engine/gates/test-slots.ts +48 -0
- package/engine/git/git-adapter.ts +55 -396
- package/engine/git/git-commits.ts +132 -0
- package/engine/git/git-join.ts +139 -0
- package/engine/git/git-read.ts +123 -0
- package/engine/git/git-run.ts +32 -0
- package/engine/git/git-worktrees.ts +143 -0
- package/engine/git/outside-links.ts +55 -0
- package/engine/guards/after-turn-check.ts +31 -5
- package/engine/guards/guard-hook.ts +60 -12
- package/engine/guards/guard-roles.ts +68 -0
- package/engine/guards/helper-rules.ts +92 -0
- package/engine/guards/permission-rules.ts +161 -0
- package/engine/guards/role-rules-helpers.ts +48 -0
- package/engine/guards/role-rules.ts +34 -606
- package/engine/guards/shell-rules.ts +344 -0
- package/engine/guards/worktree-git.ts +208 -0
- package/engine/guards/worktree-snapshot.ts +412 -0
- package/engine/guards/worktree-state.ts +28 -825
- package/engine/guards/worktree-undo.ts +260 -0
- package/engine/guards/write-rules.ts +149 -0
- package/engine/index.ts +30 -9
- package/engine/intake/criteria-checks.ts +114 -0
- package/engine/intake/intake-checks.ts +164 -30
- package/engine/intake/intake-schemas.ts +37 -2
- package/engine/jev/clef-client.ts +259 -0
- package/engine/jev/decision-model-credentials.ts +212 -0
- package/engine/jev/jev-jobs.ts +144 -60
- package/engine/jev/jev-schemas.ts +86 -5
- package/engine/jev/jev-shadow-helpers.ts +218 -0
- package/engine/jev/jev-shadow.ts +109 -64
- package/engine/jev/model-pick.ts +275 -0
- package/engine/journal/agent-entries.ts +167 -0
- package/engine/journal/agent-message-entries.ts +72 -0
- package/engine/journal/agent-turn-replay.ts +203 -0
- package/engine/journal/already-done-entries.ts +55 -0
- package/engine/journal/commit-entries.ts +56 -0
- package/engine/journal/cross-ticket-replay.ts +222 -0
- package/engine/journal/decision-model-entries.ts +86 -0
- package/engine/journal/entry-fields.ts +10 -0
- package/engine/journal/final-review-entries.ts +128 -0
- package/engine/journal/final-review-replay.ts +324 -0
- package/engine/journal/final-review-round.ts +320 -0
- package/engine/journal/final-review-state.ts +180 -0
- package/engine/journal/gate-entries.ts +81 -0
- package/engine/journal/intake-entries.ts +87 -0
- package/engine/journal/join-entries.ts +126 -0
- package/engine/journal/journal-entry-schema.ts +218 -0
- package/engine/journal/journal-kinds.ts +97 -0
- package/engine/journal/journal-record-schema.ts +218 -0
- package/engine/journal/journal-record.ts +43 -1400
- package/engine/journal/journal.ts +6 -1
- package/engine/journal/limit-entries.ts +90 -0
- package/engine/journal/memory-entries.ts +124 -0
- package/engine/journal/memory-replay.ts +157 -0
- package/engine/journal/plan-replay.ts +139 -0
- package/engine/journal/pull-request-entries.ts +41 -0
- package/engine/journal/replay.ts +157 -2144
- package/engine/journal/reply-replay.ts +144 -0
- package/engine/journal/repo-wait-entries.ts +53 -0
- package/engine/journal/run-entries.ts +172 -0
- package/engine/journal/run-state.ts +264 -0
- package/engine/journal/shared-failure-replay.ts +133 -0
- package/engine/journal/stuck-entries.ts +193 -0
- package/engine/journal/ticket-progress.ts +392 -0
- package/engine/journal/ticket-replay.ts +411 -0
- package/engine/journal/ticket-result-replay.ts +364 -0
- package/engine/journal/ticket-retry-replay.ts +142 -0
- package/engine/journal/worktree-entries.ts +122 -0
- package/engine/models/model-score.ts +512 -0
- package/engine/shell/run-command.ts +187 -18
- package/engine/testing/agent-turn-fixtures.ts +287 -0
- package/engine/testing/already-done-fixtures.ts +97 -0
- package/engine/testing/build-fixtures.ts +68 -973
- package/engine/testing/checkouts-fixtures.ts +48 -0
- package/engine/testing/final-review-fixtures.ts +62 -1
- package/engine/testing/final-review-steps.ts +143 -0
- package/engine/testing/limit-fixtures.ts +85 -0
- package/engine/testing/live-file-fixtures.ts +57 -0
- package/engine/testing/practice-repo.ts +10 -4
- package/engine/testing/practice-run.ts +1 -1
- package/engine/testing/reply-fixtures.ts +88 -0
- package/engine/testing/run-budget-fixtures.ts +165 -0
- package/engine/testing/run-fixtures.ts +219 -0
- package/engine/testing/sdk-play.ts +177 -0
- package/engine/testing/setup-repo.ts +194 -0
- package/engine/testing/step-record-fixtures.ts +55 -0
- package/engine/testing/ticket-step-fixtures.ts +272 -0
- package/engine/testing/wait-scenarios.ts +186 -0
- package/engine/tracker/gh-retry.ts +217 -0
- package/engine/tracker/github-tracker.ts +310 -90
- package/engine/tracker/in-memory-tracker.ts +8 -4
- package/engine/tracker/tracker.ts +131 -6
- package/package.json +2 -1
- package/skills/luca-retro/SKILL.md +74 -0
- package/skills/luca-retro/scripts/retro-summary.ts +1356 -0
- package/skills/luca-unstick/SKILL.md +153 -0
- package/skills/luca-unstick/scripts/stuck-summary.ts +1063 -0
- 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
|
|
840
|
-
* the ticket branch's own settings
|
|
841
|
-
* key), then each path the role may not
|
|
842
|
-
* new paths removed, `node_modules`
|
|
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 =
|