@caddis/cli 0.4.12 → 0.4.14

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.
@@ -0,0 +1,393 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [switch]$Apply,
4
+ [string]$NssmSha256,
5
+ [ValidateSet('report', 'enforce')]
6
+ [string]$Mode = 'report'
7
+ )
8
+
9
+ Set-StrictMode -Version Latest
10
+ $ErrorActionPreference = 'Stop'
11
+
12
+ $sourceNssm = 'C:\Tools\nssm\nssm.exe'
13
+ $serviceName = 'caddis-host-guard'
14
+ $programRoot = Join-Path $env:ProgramFiles 'caddis'
15
+ $nssmDir = Join-Path $programRoot 'nssm'
16
+ $nssm = Join-Path $nssmDir 'nssm.exe'
17
+ $pythonDir = Join-Path $env:ProgramFiles 'caddis\python'
18
+ $privatePython = Join-Path $pythonDir 'python.exe'
19
+ $serviceDir = Join-Path $env:ProgramData 'caddis'
20
+ $guardScript = Join-Path $serviceDir 'caddis_host_guard.py'
21
+ $governorScript = Join-Path $serviceDir 'caddis_governor.py'
22
+ $budget = Join-Path $env:ProgramData 'caddis\host-budget.toml'
23
+ $stdoutLog = Join-Path $serviceDir 'guard.out.log'
24
+ $stderrLog = Join-Path $serviceDir 'guard.err.log'
25
+ $sourceGuard = Join-Path $PSScriptRoot 'caddis_host_guard.py'
26
+ $sourceGovernor = Join-Path $PSScriptRoot 'caddis_governor.py'
27
+
28
+ if ($Apply -and $NssmSha256 -notmatch '\A[0-9a-fA-F]{64}\z') {
29
+ [Console]::Error.WriteLine('-Apply requires -NssmSha256 with the trusted NSSM SHA256 (64 hexadecimal characters).')
30
+ exit 3
31
+ }
32
+
33
+ if (-not (Test-Path -LiteralPath $sourceNssm -PathType Leaf)) {
34
+ [Console]::Error.WriteLine("NSSM was not found at $sourceNssm.")
35
+ exit 3
36
+ }
37
+
38
+ if (-not $Apply) {
39
+ Write-Output '# Dry run. Run again with -Apply -NssmSha256 <trusted SHA256> from an elevated shell.'
40
+ Write-Output "New-Item -ItemType Directory -Force -Path `"$programRoot`""
41
+ Write-Output "New-Item -ItemType Directory -Force -Path `"$serviceDir`""
42
+ Write-Output "Get-ChildItem -LiteralPath `"$programRoot`" -Force -Recurse # refuse reparse points"
43
+ Write-Output "Get-ChildItem -LiteralPath `"$serviceDir`" -Force -Recurse # refuse reparse points"
44
+ Write-Output "icacls `"$programRoot`" /setowner `"*S-1-5-32-544`" /T /C"
45
+ Write-Output "icacls `"$programRoot`" /reset /T /C"
46
+ Write-Output "icacls `"$programRoot`" /inheritance:r /grant:r `"*S-1-5-32-544:(OI)(CI)F`" `"*S-1-5-18:(OI)(CI)F`" `"*S-1-5-32-545:(OI)(CI)RX`""
47
+ Write-Output "icacls `"$serviceDir`" /setowner `"*S-1-5-32-544`" /T /C"
48
+ Write-Output "icacls `"$serviceDir`" /reset /T /C"
49
+ Write-Output "icacls `"$serviceDir`" /inheritance:r /grant:r `"*S-1-5-32-544:(OI)(CI)F`" `"*S-1-5-18:(OI)(CI)F`" `"*S-1-5-32-545:(OI)(CI)RX`""
50
+ Write-Output "New-Item -ItemType Directory -Force -Path `"$nssmDir`""
51
+ Write-Output "Lock-CaddisTree -Path `"$nssmDir`""
52
+ Write-Output "Copy-Item -LiteralPath `"$sourceNssm`" -Destination `"$nssm`" -Force"
53
+ Write-Output "Lock-CaddisTree -Path `"$nssmDir`""
54
+ Write-Output "Get-TrustedFileHash -Path `"$nssm`" # must equal -NssmSha256; refuse with exit 3 otherwise"
55
+ Write-Output '$sourcePython = (Get-Command python -CommandType Application -ErrorAction Stop).Source # require a valid PSF signature'
56
+ Write-Output '$basePrefix = Split-Path -Parent $sourcePython # refuse venvs, python*._pth and python*.zip'
57
+ Write-Output ('robocopy "$basePrefix" "{0}" /MIR /XD Lib\site-packages /XF python*._pth python*.zip' -f $pythonDir)
58
+ Write-Output ('& "{0}" -I -S -c ''import sys; print(sys.base_prefix)'' # must resolve to the private copy' -f $privatePython)
59
+ Write-Output "Copy-Item -LiteralPath `"$sourceGuard`" -Destination `"$guardScript`" -Force"
60
+ Write-Output "Copy-Item -LiteralPath `"$sourceGovernor`" -Destination `"$governorScript`" -Force"
61
+ Write-Output "Write default host budget to `"$budget`" with guard mode `"$Mode`" if it is missing"
62
+ Write-Output "icacls `"$programRoot`" /setowner `"*S-1-5-32-544`" /T /C"
63
+ Write-Output "icacls `"$serviceDir`" /setowner `"*S-1-5-32-544`" /T /C"
64
+ Write-Output "& `"$nssm`" install $serviceName `"$privatePython`" -I -S `"$guardScript`" run --budget `"$budget`""
65
+ Write-Output "Update the existing $serviceName service PathName to the quoted protected path `"$nssm`" using Win32_Service.Change."
66
+ Write-Output "& `"$nssm`" set $serviceName AppStdout `"$stdoutLog`""
67
+ Write-Output "& `"$nssm`" set $serviceName AppStderr `"$stderrLog`""
68
+ Write-Output "& `"$nssm`" set $serviceName ObjectName LocalSystem"
69
+ Write-Output "& `"$nssm`" set $serviceName Start SERVICE_AUTO_START"
70
+ Write-Output "& `"$nssm`" start $serviceName"
71
+ exit 0
72
+ }
73
+
74
+ $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
75
+ [Security.Principal.WindowsBuiltInRole]::Administrator
76
+ )
77
+ if (-not $isAdmin) {
78
+ [Console]::Error.WriteLine('install-host-guard.ps1 -Apply requires an elevated shell.')
79
+ exit 3
80
+ }
81
+
82
+ foreach ($source in @($sourceGuard, $sourceGovernor)) {
83
+ if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
84
+ throw "Required service file not found: $source"
85
+ }
86
+ }
87
+
88
+ $serviceAlreadyInstalled = $null -ne (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)
89
+ $budgetAlreadyExists = Test-Path -LiteralPath $budget -PathType Leaf
90
+ if ($budgetAlreadyExists -and -not $serviceAlreadyInstalled) {
91
+ [Console]::Error.WriteLine(
92
+ "Refusing the pre-existing $budget because no installed service proves that it is admin-owned. " +
93
+ 'Inspect it, remove it, and run the installer again.'
94
+ )
95
+ exit 3
96
+ }
97
+
98
+ function Lock-CaddisTree {
99
+ param([Parameter(Mandatory)][string]$Path)
100
+
101
+ $rootItem = Get-Item -LiteralPath $Path -Force
102
+ $reparsePoint = @($rootItem) + @(Get-ChildItem -LiteralPath $Path -Force -Recurse)
103
+ $reparsePoint = $reparsePoint | Where-Object {
104
+ ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0
105
+ } | Select-Object -First 1
106
+ if ($null -ne $reparsePoint) {
107
+ throw "Refusing reparse point in service tree: $($reparsePoint.FullName)"
108
+ }
109
+
110
+ # Remove inherited and explicit grants from any pre-existing tree before the LocalSystem
111
+ # service can read it. New files then inherit only the three grants below.
112
+ & icacls.exe $Path '/setowner' '*S-1-5-32-544' '/T' '/C'
113
+ if ($LASTEXITCODE -ne 0) {
114
+ throw "icacls owner change failed for $Path with exit code $LASTEXITCODE"
115
+ }
116
+ & icacls.exe $Path '/reset' '/T' '/C'
117
+ if ($LASTEXITCODE -ne 0) {
118
+ throw "icacls reset failed for $Path with exit code $LASTEXITCODE"
119
+ }
120
+ & icacls.exe $Path '/inheritance:r' '/grant:r' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX'
121
+ if ($LASTEXITCODE -ne 0) {
122
+ throw "icacls lock failed for $Path with exit code $LASTEXITCODE"
123
+ }
124
+ }
125
+
126
+ function Set-CaddisTreeOwner {
127
+ param([Parameter(Mandatory)][string]$Path)
128
+
129
+ & icacls.exe $Path '/setowner' '*S-1-5-32-544' '/T' '/C'
130
+ if ($LASTEXITCODE -ne 0) {
131
+ throw "icacls owner change failed for $Path with exit code $LASTEXITCODE"
132
+ }
133
+ }
134
+
135
+ function Assert-NoPythonPathOverride {
136
+ param([Parameter(Mandatory)][string]$Path)
137
+
138
+ if (Test-Path -LiteralPath $Path -PathType Container) {
139
+ foreach ($pattern in @('python*._pth', 'python*.zip')) {
140
+ $override = Get-ChildItem -LiteralPath $Path -Filter $pattern -File -Force |
141
+ Select-Object -First 1
142
+ if ($null -ne $override) {
143
+ [Console]::Error.WriteLine("Refusing Python path override file: $($override.FullName)")
144
+ exit 3
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ function Assert-AdminOwned {
151
+ param(
152
+ [Parameter(Mandatory)][string]$Path,
153
+ [switch]$RequireReadOnlyFile
154
+ )
155
+
156
+ $acl = Get-Acl -LiteralPath $Path
157
+ $ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate(
158
+ [Security.Principal.SecurityIdentifier]
159
+ ).Value
160
+ $trustedSids = @('S-1-5-18', 'S-1-5-32-544')
161
+ if ($ownerSid -notin $trustedSids) {
162
+ [Console]::Error.WriteLine("Refusing non-admin-owned path: $Path (owner $ownerSid)")
163
+ exit 3
164
+ }
165
+
166
+ if ($RequireReadOnlyFile) {
167
+ $writeRights = [Security.AccessControl.FileSystemRights]::Write -bor
168
+ [Security.AccessControl.FileSystemRights]::Delete -bor
169
+ [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
170
+ [Security.AccessControl.FileSystemRights]::ChangePermissions -bor
171
+ [Security.AccessControl.FileSystemRights]::TakeOwnership
172
+ foreach ($rule in $acl.Access) {
173
+ $ruleSid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
174
+ if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and
175
+ $ruleSid -notin $trustedSids -and ($rule.FileSystemRights -band $writeRights)) {
176
+ [Console]::Error.WriteLine("Refusing user-writable path: $Path ($ruleSid)")
177
+ exit 3
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ function Get-TrustedFileHash {
184
+ param([Parameter(Mandatory)][string]$Path)
185
+
186
+ $stream = [IO.File]::Open(
187
+ $Path,
188
+ [IO.FileMode]::Open,
189
+ [IO.FileAccess]::Read,
190
+ [IO.FileShare]::Read
191
+ )
192
+ try {
193
+ # The open handle denies writes and deletes, so this ACL check and hash describe
194
+ # the same file even when the parent directory permits users to add files.
195
+ Assert-AdminOwned -Path $Path -RequireReadOnlyFile
196
+ $sha256 = [Security.Cryptography.SHA256]::Create()
197
+ try {
198
+ return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '')
199
+ } finally {
200
+ $sha256.Dispose()
201
+ }
202
+ } finally {
203
+ $stream.Dispose()
204
+ }
205
+ }
206
+
207
+ function Get-TrustedPythonFiles {
208
+ param([Parameter(Mandatory)][string]$BasePrefix)
209
+
210
+ $trustedFiles = [Collections.Generic.Dictionary[string, string]]::new(
211
+ [StringComparer]::OrdinalIgnoreCase
212
+ )
213
+ $sitePackages = [IO.Path]::GetFullPath((Join-Path $BasePrefix 'Lib\site-packages')).TrimEnd('\') + '\'
214
+ foreach ($item in Get-ChildItem -LiteralPath $BasePrefix -Force -Recurse -ErrorAction Stop) {
215
+ $fullPath = [IO.Path]::GetFullPath($item.FullName)
216
+ if ($fullPath.StartsWith($sitePackages, [StringComparison]::OrdinalIgnoreCase)) {
217
+ continue
218
+ }
219
+
220
+ if ($item.PSIsContainer) {
221
+ Assert-AdminOwned -Path $fullPath
222
+ continue
223
+ }
224
+
225
+ $relativePath = [IO.Path]::GetRelativePath($BasePrefix, $fullPath)
226
+ if ($item.Extension -in @('.dll', '.pyd') -and
227
+ -not $relativePath.StartsWith('tcl\', [StringComparison]::OrdinalIgnoreCase)) {
228
+ $signature = Get-AuthenticodeSignature -LiteralPath $fullPath
229
+ if ($signature.Status -ne 'Valid' -or
230
+ $signature.SignerCertificate.Subject -notmatch
231
+ 'O=(?:Python Software Foundation|Microsoft Corporation)(?:,|$)') {
232
+ [Console]::Error.WriteLine("Refusing an unsigned or unexpected Python binary: $fullPath")
233
+ exit 3
234
+ }
235
+ }
236
+ $trustedFiles[$relativePath] = Get-TrustedFileHash -Path $fullPath
237
+ }
238
+ return ,$trustedFiles
239
+ }
240
+
241
+ $sourcePython = (Get-Command python -CommandType Application -ErrorAction Stop).Source
242
+ $sourcePythonDir = Split-Path -Parent $sourcePython
243
+ Assert-NoPythonPathOverride -Path $sourcePythonDir
244
+ $sourcePythonItem = Get-Item -LiteralPath $sourcePython -Force
245
+ if (($sourcePythonItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
246
+ [Console]::Error.WriteLine("Refusing a reparse-point Python executable: $sourcePython")
247
+ exit 3
248
+ }
249
+ Assert-AdminOwned -Path $sourcePython -RequireReadOnlyFile
250
+ $sourceSignature = Get-AuthenticodeSignature -LiteralPath $sourcePython
251
+ if ($sourceSignature.Status -ne 'Valid' -or
252
+ $sourceSignature.SignerCertificate.Subject -notmatch 'O=Python Software Foundation(?:,|$)') {
253
+ [Console]::Error.WriteLine("Refusing an untrusted Python executable: $sourcePython")
254
+ exit 3
255
+ }
256
+
257
+ $basePrefix = $sourcePythonDir
258
+ if (-not (Test-Path -LiteralPath (Join-Path $basePrefix 'Lib') -PathType Container) -or
259
+ (Test-Path -LiteralPath (Join-Path (Split-Path -Parent $basePrefix) 'pyvenv.cfg') -PathType Leaf)) {
260
+ [Console]::Error.WriteLine('The Python on PATH must be a base installation, not a virtual environment.')
261
+ exit 3
262
+ }
263
+ $sourceReparsePoint = Get-ChildItem -LiteralPath $basePrefix -Force -Recurse | Where-Object {
264
+ ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0
265
+ } | Select-Object -First 1
266
+ if ($null -ne $sourceReparsePoint) {
267
+ [Console]::Error.WriteLine("Refusing reparse point in the source Python tree: $($sourceReparsePoint.FullName)")
268
+ exit 3
269
+ }
270
+ Assert-NoPythonPathOverride -Path $basePrefix
271
+ $trustedSourceFiles = Get-TrustedPythonFiles -BasePrefix $basePrefix
272
+
273
+ New-Item -ItemType Directory -Force -Path $programRoot | Out-Null
274
+ New-Item -ItemType Directory -Force -Path $serviceDir | Out-Null
275
+ Lock-CaddisTree -Path $programRoot
276
+ Lock-CaddisTree -Path $serviceDir
277
+
278
+ New-Item -ItemType Directory -Force -Path $nssmDir | Out-Null
279
+ Lock-CaddisTree -Path $nssmDir
280
+ Copy-Item -LiteralPath $sourceNssm -Destination $nssm -Force
281
+ Lock-CaddisTree -Path $nssmDir
282
+ Assert-AdminOwned -Path $nssmDir -RequireReadOnlyFile
283
+ if ((Get-TrustedFileHash -Path $nssm) -ine $NssmSha256) {
284
+ [Console]::Error.WriteLine('The protected NSSM copy does not match -NssmSha256.')
285
+ exit 3
286
+ }
287
+
288
+ & robocopy.exe $basePrefix $pythonDir /MIR /XD 'Lib\site-packages' /XF 'python*._pth' 'python*.zip'
289
+ if ($LASTEXITCODE -ge 8) {
290
+ throw "robocopy failed with exit code $LASTEXITCODE"
291
+ }
292
+ if (-not (Test-Path -LiteralPath $privatePython -PathType Leaf)) {
293
+ throw "The private Python copy does not contain $privatePython"
294
+ }
295
+ Assert-NoPythonPathOverride -Path $pythonDir
296
+ Set-CaddisTreeOwner -Path $programRoot
297
+ $trustedPrivateFiles = Get-TrustedPythonFiles -BasePrefix $pythonDir
298
+ if ($trustedPrivateFiles.Count -ne $trustedSourceFiles.Count) {
299
+ throw 'The private Python copy does not contain the validated source file set.'
300
+ }
301
+ foreach ($entry in $trustedSourceFiles.GetEnumerator()) {
302
+ $privateHash = $null
303
+ if (-not $trustedPrivateFiles.TryGetValue($entry.Key, [ref]$privateHash) -or
304
+ $privateHash -ne $entry.Value) {
305
+ throw "The private Python copy does not match the validated source: $($entry.Key)"
306
+ }
307
+ }
308
+ $privateSignature = Get-AuthenticodeSignature -LiteralPath $privatePython
309
+ if ($privateSignature.Status -ne 'Valid' -or
310
+ $privateSignature.SignerCertificate.Subject -notmatch 'O=Python Software Foundation(?:,|$)') {
311
+ throw "The private Python copy failed signature validation: $privatePython"
312
+ }
313
+ $copiedBasePrefix = (& $privatePython -I -S -c 'import sys; print(sys.base_prefix)').Trim()
314
+ if ($LASTEXITCODE -ne 0 -or $copiedBasePrefix -ine $pythonDir) {
315
+ throw "The private Python resolved sys.base_prefix to $copiedBasePrefix instead of $pythonDir"
316
+ }
317
+
318
+ Copy-Item -LiteralPath $sourceGuard -Destination $guardScript -Force
319
+ Copy-Item -LiteralPath $sourceGovernor -Destination $governorScript -Force
320
+
321
+ if (-not (Test-Path -LiteralPath $budget -PathType Leaf)) {
322
+ $budgetText = @"
323
+ [session]
324
+ cpu_percent = 50
325
+ memory_mb = 24576
326
+ max_processes = 256
327
+
328
+ [lane]
329
+ cpu_percent = 25
330
+ memory_mb = 8192
331
+ max_processes = 64
332
+
333
+ [guard]
334
+ mode = "$Mode"
335
+ host_cpu_percent = 85
336
+ sustain_seconds = 60
337
+ sample_seconds = 10
338
+ min_free_memory_mb = 4096
339
+ root_images = ["claude.exe", "codex.exe", "agy.exe"]
340
+ "@
341
+ [System.IO.File]::WriteAllText($budget, $budgetText, [System.Text.UTF8Encoding]::new($false))
342
+ }
343
+
344
+ $modeReader = 'import pathlib, sys, tomllib; p = pathlib.Path(sys.argv[1]); data = tomllib.loads(p.read_text(encoding="utf-8")); print(data.get("guard", {}).get("mode", "enforce"))'
345
+ $configuredMode = (& $privatePython -I -S -c $modeReader $budget).Trim()
346
+ if ($LASTEXITCODE -ne 0 -or -not $configuredMode) {
347
+ throw "Could not read the guard mode from $budget"
348
+ }
349
+ if ($configuredMode -ne $Mode) {
350
+ [Console]::Error.WriteLine(
351
+ "$budget is in $configuredMode mode, not requested $Mode mode. Change it from an elevated shell and run again."
352
+ )
353
+ exit 3
354
+ }
355
+
356
+ Set-CaddisTreeOwner -Path $programRoot
357
+ Set-CaddisTreeOwner -Path $serviceDir
358
+
359
+ if ($serviceAlreadyInstalled) {
360
+ # Updating Application alone leaves the old wrapper in the service ImagePath.
361
+ $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'"
362
+ $change = Invoke-CimMethod -InputObject $service -MethodName Change -Arguments @{
363
+ PathName = '"{0}"' -f $nssm
364
+ }
365
+ if ($change.ReturnValue -ne 0) {
366
+ throw "Service binary update failed with code $($change.ReturnValue)"
367
+ }
368
+ $appParameters = '-I -S "{0}" run --budget "{1}"' -f $guardScript, $budget
369
+ & $nssm set $serviceName Application $privatePython
370
+ if ($LASTEXITCODE -ne 0) { throw "nssm Application failed with exit code $LASTEXITCODE" }
371
+ & $nssm set $serviceName AppParameters $appParameters
372
+ if ($LASTEXITCODE -ne 0) { throw "nssm AppParameters failed with exit code $LASTEXITCODE" }
373
+ } else {
374
+ & $nssm install $serviceName $privatePython '-I' '-S' $guardScript 'run' '--budget' $budget
375
+ if ($LASTEXITCODE -ne 0) { throw "nssm install failed with exit code $LASTEXITCODE" }
376
+ }
377
+ & $nssm set $serviceName AppStdout $stdoutLog
378
+ if ($LASTEXITCODE -ne 0) { throw "nssm AppStdout failed with exit code $LASTEXITCODE" }
379
+ & $nssm set $serviceName AppStderr $stderrLog
380
+ if ($LASTEXITCODE -ne 0) { throw "nssm AppStderr failed with exit code $LASTEXITCODE" }
381
+ & $nssm set $serviceName ObjectName LocalSystem
382
+ if ($LASTEXITCODE -ne 0) { throw "nssm ObjectName failed with exit code $LASTEXITCODE" }
383
+ & $nssm set $serviceName Start SERVICE_AUTO_START
384
+ if ($LASTEXITCODE -ne 0) { throw "nssm Start failed with exit code $LASTEXITCODE" }
385
+ if ($serviceAlreadyInstalled) {
386
+ & $nssm restart $serviceName
387
+ if ($LASTEXITCODE -ne 0) { throw "nssm restart failed with exit code $LASTEXITCODE" }
388
+ } else {
389
+ & $nssm start $serviceName
390
+ if ($LASTEXITCODE -ne 0) { throw "nssm start failed with exit code $LASTEXITCODE" }
391
+ }
392
+
393
+ Write-Output "installed in $Mode mode; switch with: set mode in $budget then & `"$nssm`" restart $serviceName"
@@ -126,6 +126,24 @@ A tier can be served by an OSS provider instead of Anthropic (cheat sheet:
126
126
  `claude-glm -p "/caddis:implement <plan-path> — Phase N only"` (from the repo root, on the
127
127
  feature branch). The local-coder gate below exists exactly so these phases survive a weaker
128
128
  implementer — a phase may only take this lane if it passes that gate.
129
+ - **codex-sol / codex-astra / agy / glm (via caddis_lanes)** — select one of these five task types
130
+ and its default chain:
131
+ - `backend`: `glm`, `codex-sol`, `claude`
132
+ - `hard`: `claude`, `codex-astra`
133
+ - `ui`: `claude`, `codex-astra`
134
+ - `tests`: `codex-sol`, `glm`, `claude`
135
+ - `docs`: `agy`, `glm`, `claude`
136
+ Use `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" run --plan <plan-path> --phase N
137
+ --type <type>` as the launch command. The chains are defaults, not limits on Claude: while Claude
138
+ has budget, give any phase the `claude` lane. Override chains and model pins per project in
139
+ `.caddis/config.toml` `[lanes.chains]` and `[lanes.models]`.
140
+
141
+ **Evidence standard — never plan a load test on a shared host.** Prove timing robustness with
142
+ event-driven fakes, ordering assertions and an A/B check that fails against the racy version. A phase
143
+ must never ask for CPU burners, stress tests, load tests or "every core". `caddis_lanes.py` refuses
144
+ this wording. The host guard kills runaway agent processes. On 2026-09-13, 32 all-core busy loops
145
+ froze CI on the dev box.
146
+
129
147
  - **review lane (every phase that touches code)** — cross-vendor review by a vendor that did NOT
130
148
  write the code: `/caddis:cross-review --range main..HEAD`
131
149
  (Claude or GLM implemented) or `--provider glm` / a Claude `code-reviewer` pass (DeepSeek-adjacent
@@ -219,6 +237,7 @@ Creating Model: <model-id>
219
237
  **Milestone:** M1 *(omit on plans without milestones)*
220
238
  **Model:** <tier> (`/model <alias>`) — <one-line rationale tied to this phase's difficulty>
221
239
  **Lane:** claude — this session | glm-headless — `claude-glm -p "/caddis:implement <this plan's path> — Phase 1 only"`
240
+ **Type:** backend | hard | ui | tests | docs *(required when the Lane runs through caddis_lanes.py)*
222
241
  **Session:** continue | **fresh** (start a new session for this phase — note why: lane change / heavy context)
223
242
  **Risk:** high — token-verification seam; a wrong answer here is a silent auth bypass *(omit this line when normal)*
224
243
  **Goal:** <one sentence>
@@ -273,6 +292,11 @@ yourself (relay + Tracker) before stopping — do not hand back "run `/caddis:ha
273
292
  - **Ship gate:** NEVER push or merge to main from a phase — phases only commit to the feature branch.
274
293
  When all phases are ✅: `/caddis:ship-pr` (stops at green CI), then STOP and wait for the human's
275
294
  explicit go before `/caddis:ship-merge`.
295
+ - **Finish:** after the last phase, run `caddis_lanes.py finish --plan <plan>`; the plan is not done
296
+ until it prints `LEFTOVERS: 0` (merged lane worktrees and branches removed, unmerged or dirty ones
297
+ reported, feature branch pushed; merging to main stays with `/caddis:ship-merge`).
298
+ - **No load wording** — no phase asks for a CPU burner, stress test, load test or "every core"; prove
299
+ timing by design.
276
300
 
277
301
  ## Affected files
278
302
  | File | Action |
@@ -303,16 +327,18 @@ done:
303
327
  - **No abbreviation** — never "etc.", "similar to Phase 1", "and so on". Write every item in full.
304
328
  - **Model tier named** — every phase names a tier (cheap/mid/frontier/ultra) + a one-line rationale; no
305
329
  phase silently defaults to the most expensive model. Default mid; frontier/ultra only where justified.
306
- - **Lane + reviewer named** — every phase names its execution lane (`claude` or `glm-headless`, with
307
- the literal launch command for glm phases) and a cross-review provider that is a different vendor
308
- than the implementer. A glm-headless phase MUST pass every bullet of this gate — that lane has no
309
- slack for reasoning out gaps.
330
+ - **Lane + reviewer named** — every phase names its execution lane (`claude`, `glm-headless`, or a
331
+ `caddis_lanes.py` lane with its `**Type:**`, with the literal launch command for every non-claude
332
+ phase) and a cross-review provider that is a different vendor than the implementer. A glm-headless
333
+ phase MUST pass every bullet of this gate — that lane has no slack for reasoning out gaps.
310
334
  - **Launch command is spawnable** — every `glm-headless` phase's command carries **`-p`** (or
311
335
  `--print`) and ends with **`— Phase N only`**. Both are load-bearing, not style: the launchers set
312
336
  `CADDIS_HEADLESS` *only* on `-p`, and that variable is the guard that stops a spawned session
313
337
  spawning another one forever; the `Phase N only` suffix is what stops the child running to the end
314
338
  of the plan. The command is executed verbatim by a model with no slack to notice a missing flag,
315
339
  and `/caddis:implement` now refuses a command without `-p` rather than running it.
340
+ - **caddis_lanes command carries --phase** — a `caddis_lanes.py` launch command must name `--phase N`
341
+ and `--type`; lane-check refuses one without `--phase`.
316
342
  - **Gate-detectable** — if this phase's instruction were WRONG, its exit gate would go red. A phase
317
343
  whose gates pass either way (copy, nav entries, badges, ordering, wording, config defaults) must
318
344
  not take a lane that cannot escalate, no matter how completely it is specified. Every other bullet
@@ -101,6 +101,51 @@ the bug the lane routing exists to fix.
101
101
  lane the plan planned. A deviation then shows up as a diff between the phase block and the tracker
102
102
  row, visible to any later reader without an Advisory Hub having to catch it live.
103
103
 
104
+ **Multi-lane phases — `caddis_lanes.py`.** Use this procedure when the phase launch command is
105
+ `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" run …`.
106
+
107
+ 1. In an interactive session, run the phase's literal command. The script creates a temporary
108
+ worktree. It runs the lane or its fallback chain inside a resource-capped job. It re-runs the
109
+ gates with the repo interpreter. It runs the DeepSeek review. It then prints a `NEXT:` line.
110
+ 2. Obey `NEXT:`:
111
+ - `review` → read the packet JSON and inspect `git -C <worktree> diff`. Then decide ACCEPT or REDO.
112
+ - `redo` → run `abandon`. Then run `run` with `--attempt 2 --findings <file with your findings>`.
113
+ - `takeover` → run `abandon`. Then implement the phase yourself on the `claude` lane.
114
+ - `claude-implement` → write the code in the printed worktree. Then run `check`.
115
+ - `stop` → report the result and halt. `stop (host guard killed the lane…)` means
116
+ `FAILED-RESOURCE`. The lane saturated the host. Never re-run it with more load. Fix the phase
117
+ so it proves timing by design.
118
+ 3. **ACCEPT** means run `caddis_lanes.py accept --plan <plan> --phase N --attempt K --message
119
+ "<the phase's commit message>"`. It runs only on the programme branch, never on `main`. It
120
+ refuses a `BLOCKING` review. If you checked the finding and it is false, add
121
+ `--override-review "<why the finding is false>"`. The script records that reason in the packet.
122
+ 4. Allow at most **2 attempts** per phase on a non-Claude lane. An out-of-budget switch is not an
123
+ attempt. The owner can request TAKEOVER at any time.
124
+ 5. A UI phase needs a `ui-design-reviewer` screenshot review before ACCEPT. This rule applies
125
+ whichever lane wrote the phase.
126
+ 6. A phase that Claude wrote needs a vendor review that is not MISSING. If `review.status` is
127
+ `MISSING`, run `codex exec -m gpt-5.6-sol -s read-only -C <worktree> --skip-git-repo-check -`.
128
+ Give the diff to the command on stdin.
129
+ 6b. **Orchestrator artefacts live under `.caddis/orchestrator/`.** Store packets and locks in
130
+ `lane-runs/`. Store evidence notes as markdown directly in `.caddis/orchestrator/`. Follow its
131
+ `AGENTS.md`. Never write orchestration notes to `.caddis/kb/`.
132
+ 7. **Never write a load or stress test into a lane prompt or findings file.** The script refuses
133
+ this wording without `--allow-load`. Only the owner gives that flag.
134
+ 8. Record these Tracker values:
135
+ - `Model` = the packet's `model`.
136
+ - `Lane` = the lanes tried, joined with ` -> ` and followed by the reason. For example,
137
+ `glm -> codex-sol (glm out of budget)`.
138
+ - `Cross-review` = `CLEAN`, `BLOCKING` or `missing`.
139
+ - `Notes` = `attempt K, ACCEPT|TAKEOVER|FAILED-RESOURCE`.
140
+ 9. The PR body lists every phase accepted with `review.status == MISSING`. It also lists every phase
141
+ accepted while Codex astra stood in for Claude.
142
+ 9b. **Finish.** After the last phase, run `caddis_lanes.py finish --plan <plan>`. It removes merged
143
+ lane worktrees and branches. It reports unmerged or dirty ones. It pushes the feature branch.
144
+ The plan is not done until it prints `LEFTOVERS: 0`. Then run `/caddis:ship-pr`. Merging and
145
+ post-merge cleanup stay with `/caddis:ship-merge` after the owner's go.
146
+ 10. **When Claude is out of budget**, the owner opens `caddis-run codex -m gpt-6-astra` and asks it
147
+ to follow this same subsection.
148
+
104
149
  **Advisory-Hub mode (conditional — OFF by default).** Check whether a companion file
105
150
  `<plan-dir>/<plan-stem>-advisory-context.md` exists (e.g. plan `.caddis/plans/foo.md` → look for
106
151
  `.caddis/plans/foo-advisory-context.md`). **If it does not exist — the normal case, including every
@@ -187,7 +232,9 @@ report — do not redo completed work.
187
232
  first, every phase, not just the one you resumed at. Step 1's check only ever saw the *resume*
188
233
  phase: on a plan whose phase 1 is `claude` and phase 2 is `glm-headless`, it finds no mismatch and
189
234
  this loop then carries you straight through phase 2 — so the lane silently executes nothing for
190
- every phase after the first, which is the original bug wearing a different hat.
235
+ every phase after the first, which is the original bug wearing a different hat. For a
236
+ `caddis_lanes.py` launch command, follow "Multi-lane phases" above instead of spawning the command
237
+ blind.
191
238
  - **RED** — write the failing test(s) the phase names (`<test file>::<case>`). Run them; confirm they
192
239
  fail for the right reason (the missing behavior, not an import error). If a phase genuinely has no
193
240
  testable surface, say so in the review file and implement the minimal change directly.
@@ -21,6 +21,10 @@ It is **additive and safe** — never deletes your rows:
21
21
  - **Missing map** → creates `.caddis/kb/DOC-MAP.md` from a scaffold, pre-linking the repo's obvious
22
22
  reference docs (README, `docs/…`) and any existing `.caddis/kb/*.md` notes.
23
23
  - **Existing map** → indexes any KB note that isn't yet linked (adds a row with a placeholder description).
24
+ It appends to the `## Knowledge base` table, or — in a map written by hand with no such heading —
25
+ to the first table that already links sibling notes (`[x](x.md)`).
26
+ - **Could not index a note** (no table of notes anywhere) → says so and **exits 1**. Report it; do not
27
+ treat the run as done.
24
28
  - **Dangling links** (a linked file that's gone) → **reported, not removed** — handle them in Step 3.
25
29
 
26
30
  ## Step 3 — finish by hand