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