@caddis/cli 0.4.28 → 0.4.30

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.
@@ -74,6 +74,13 @@ facts - services, scheduled tasks, web sites - can only be read on the box itsel
74
74
  flag yourself.** A credential that was ever committed must be ROTATED, not only deleted.
75
75
  - **What runs it.** If nothing on the host references the folder and the app is live, it runs
76
76
  somewhere you have not looked. Find out where before planning anything.
77
+ - **COMMITS NO PORT WOULD CARRY.** A worktree is an extra folder, not an extra repository, so
78
+ its branch travels with a clone like any other. A **detached HEAD** does not: a clone copies
79
+ `refs/heads/*` and `refs/tags/*`, and a detached HEAD is neither. Nothing points at those
80
+ commits, so git may also collect them. If this section appears, fix it before anything else -
81
+ the report prints the exact `git branch` command, it changes nothing else, and it is the
82
+ difference between porting the work and losing it. Stashes are the same problem: `refs/stash`
83
+ is not copied by a clone or a push.
77
84
 
78
85
  Junctions and symlinks are reported and never followed. A junction inside a tree that git manages
79
86
  is a data-loss hazard: git's recursive remove follows it and deletes the target's contents.
@@ -1,6 +1,6 @@
1
1
  # The adoption standard
2
2
 
3
- `Test-AppConformance.ps1` (phase 3) measures an app against 20 items, in the order below - the
3
+ `Test-AppConformance.ps1` (phase 3) measures an app against 21 items, in the order below - the
4
4
  same order the script adds them in. Each one gets a verdict, never a guess:
5
5
 
6
6
  | Verdict | Means |
@@ -247,7 +247,40 @@ run it - should not require finding and interrupting someone.
247
247
  **Why it is on the list:** without it, every coding agent that touches the app re-derives its
248
248
  non-obvious rules from scratch, and different agents derive them differently.
249
249
 
250
- ### 19. `no-junctions` - No junction or symlink inside the tree git manages
250
+ ### 19. `work-is-in-a-ref` - Every commit is reachable from a branch or a tag
251
+
252
+ **Blocking.**
253
+
254
+ **Decided by:** `GAP` when a worktree is on a DETACHED HEAD that no branch and no tag contains.
255
+ `ASK` when there are no unreachable commits but there are stashes, or worktrees with
256
+ uncommitted files. `ASK` when git could not be run. `PASS` otherwise.
257
+
258
+ **Why it is on the list.** A port copies refs. A clone takes `refs/heads/*` and `refs/tags/*`,
259
+ so a worktree's BRANCH travels with it like any other - a worktree is an extra folder, not an
260
+ extra repository. A **detached HEAD is neither a branch nor a tag**, so its commits are not
261
+ copied, and because nothing points at them git is free to collect them. The work is not
262
+ protected by being on disk; it is protected by being in a ref.
263
+
264
+ Measured on a real app: two detached worktrees under a temporary path that the app's own
265
+ documents called a "recurring external cleanup hazard", with three recorded incidents. They
266
+ held **200 commits** of certified work. `branch --contains` returned 0 for both. Every other
267
+ check passed; a port would have carried 127 branches and left behind the only copy of the part
268
+ that mattered.
269
+
270
+ **The fix costs nothing** and the report prints it per worktree:
271
+
272
+ ```
273
+ git -C <repo> branch rescue/<worktree-name> <sha>
274
+ ```
275
+
276
+ It creates a branch name and changes nothing else. After it, `branch --contains` returns 1 and
277
+ the commits port like anything else.
278
+
279
+ Stashes are the same class of problem for a smaller amount of work: `refs/stash` is not copied
280
+ by a clone or a push. Uncommitted files in a worktree are invisible to the main checkout's
281
+ `git status`, which is why the inventory reports each worktree's own count.
282
+
283
+ ### 20. `no-junctions` - No junction or symlink inside the tree git manages
251
284
 
252
285
  **Not blocking.**
253
286
 
@@ -261,7 +294,7 @@ Ordinary Windows delete operations (`Remove-Item -Recurse`, `cmd /c rmdir /s /q`
261
294
  this trap easy to miss: a delete you tested yourself can look completely safe right up until git
262
295
  does the deleting instead.
263
296
 
264
- ### 20. `profile` - The stack is declared, not assumed
297
+ ### 21. `profile` - The stack is declared, not assumed
265
298
 
266
299
  **Blocking.**
267
300
 
@@ -308,9 +341,9 @@ what it really is.
308
341
  | `1` | At least one blocking item is `GAP` and not waived. Checked first - if both a blocking `GAP` and a blocking unanswered `ASK` exist at once, exit code `1` wins. |
309
342
  | `2` | No blocking `GAP` remains, but at least one blocking item is still an unanswered `ASK` and not waived. |
310
343
 
311
- The eight blocking items, as the code actually enforces them, are: `git-repo`,
312
- `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `run-declared`, `owner`,
313
- and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
344
+ The nine blocking items, as the code actually enforces them, are: `git-repo`,
345
+ `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `work-is-in-a-ref`,
346
+ `run-declared`, `owner` and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
314
347
  item, and not by the project config. An earlier draft of the config carried a `blockingItems`
315
348
  list that nothing read and that had drifted out of step with this set; it was removed from the
316
349
  code and from `config.example.json` together, rather than documented as a thing that looks
@@ -196,7 +196,8 @@ $script:AdoptScanBlock = {
196
196
  # a key that is missing because one git call did not run.
197
197
  $g = @{ isRepo = $true; gitAvailable = $gitAvailable; gitError = $gitError; commits = $null; branch = $null
198
198
  localBranches = 0; remoteBranches = 0; tags = 0; dirtyFiles = $null
199
- remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null }
199
+ remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null
200
+ worktrees = @(); unreachableCommits = @(); stashes = 0 }
200
201
  $result.git = $g
201
202
  if (-not $gitAvailable) {
202
203
  $result.partial = $true
@@ -231,6 +232,63 @@ $script:AdoptScanBlock = {
231
232
  if ($ahead) { $g.unpushed = [int]"$ahead" }
232
233
  }
233
234
  else { $g.upstream = $null; $g.unpushed = $null }
235
+
236
+ # ---- worktrees, and the commits a port would silently leave behind ----------
237
+ #
238
+ # A worktree is an extra FOLDER, not an extra repository: its branch lives in
239
+ # this same .git, so a mirror clone carries it. What a mirror does NOT carry is
240
+ # a DETACHED HEAD, because a mirror copies refs/heads/* and refs/tags/* and a
241
+ # detached HEAD is neither. Nothing points at those commits, so git is also free
242
+ # to collect them.
243
+ #
244
+ # This is not hypothetical. A real app had two detached worktrees on a temp path
245
+ # its own documents called a "recurring cleanup hazard", holding 200 commits of
246
+ # certified work. `branches containing it` was 0 for both. One `git worktree add`
247
+ # months earlier, and a port would have taken everything except the part that
248
+ # mattered.
249
+ $g.worktrees = @()
250
+ $g.unreachableCommits = @()
251
+ $wtOut = & $runGit @('worktree', 'list', '--porcelain')
252
+ if ($wtOut) {
253
+ $cur = $null
254
+ foreach ($line in @($wtOut)) {
255
+ $s = "$line"
256
+ if ($s -match '^worktree\s+(.+)$') {
257
+ if ($cur) { $g.worktrees += $cur }
258
+ $cur = @{ path = $Matches[1]; branch = $null; head = $null; detached = $false; dirtyFiles = $null; isMain = $false }
259
+ }
260
+ elseif ($s -match '^HEAD\s+([0-9a-f]{40})$' -and $cur) { $cur.head = $Matches[1] }
261
+ elseif ($s -match '^branch\s+refs/heads/(.+)$' -and $cur) { $cur.branch = $Matches[1] }
262
+ elseif ($s -eq 'detached' -and $cur) { $cur.detached = $true }
263
+ }
264
+ if ($cur) { $g.worktrees += $cur }
265
+ }
266
+
267
+ foreach ($w in @($g.worktrees)) {
268
+ $w.isMain = ($w.path.TrimEnd('\', '/') -eq $root)
269
+ # Its OWN uncommitted files. A worktree has a separate working tree, so the
270
+ # main checkout's `git status` says nothing about it.
271
+ if (Test-Path -LiteralPath $w.path) {
272
+ $st = & git -c safe.directory=* -C $w.path status --porcelain 2>&1
273
+ if ($LASTEXITCODE -eq 0) { $w.dirtyFiles = @($st | Where-Object { "$_" -ne '' }).Count }
274
+ }
275
+ # The finding that matters: is this HEAD reachable from any branch or tag?
276
+ if ($w.detached -and $w.head) {
277
+ $containing = @(& git -c safe.directory=* -C $root branch --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
278
+ $tagged = @(& git -c safe.directory=* -C $root tag --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
279
+ if ($containing.Count -eq 0 -and $tagged.Count -eq 0) {
280
+ $ahead = & git -c safe.directory=* -C $root rev-list --count "$($w.head)" 2>&1
281
+ $g.unreachableCommits += @{
282
+ path = $w.path; head = $w.head
283
+ commits = $(if ($LASTEXITCODE -eq 0) { [int]"$ahead" } else { $null })
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ # A stash is a ref, but `refs/stash` is not copied by a normal clone or push.
290
+ $stash = & $runGit @('stash', 'list')
291
+ $g.stashes = @($stash | Where-Object { "$_" -ne '' }).Count
234
292
  }
235
293
 
236
294
  # ---- which files git actually tracks ---------------------------------------------
@@ -166,6 +166,45 @@ elseif ($inventory.git.isRepo) {
166
166
  }
167
167
  else { Write-Host " repo : NO - there is no history to port; adoption starts with one import commit" -ForegroundColor Yellow }
168
168
 
169
+ if ($inventory.git.isRepo) {
170
+ $wts = @(Get-AdoptProp $inventory.git 'worktrees' @())
171
+ $stashes = [int](Get-AdoptProp $inventory.git 'stashes' 0)
172
+ if ($wts.Count -gt 1 -or $stashes -gt 0) {
173
+ Write-AdoptHeading 'Worktrees and stashes'
174
+ foreach ($w in $wts) {
175
+ $what = if ($w.branch) { "on $($w.branch)" } else { "DETACHED at $("$($w.head)".Substring(0,9))" }
176
+ $dirty = if ($null -eq $w.dirtyFiles) { 'unreadable' } else { "$($w.dirtyFiles) uncommitted" }
177
+ $tag = if ($w.isMain) { ' (the main checkout)' } else { '' }
178
+ Write-Host (" {0,-58} {1,-34} {2}{3}" -f $w.path, $what, $dirty, $tag)
179
+ }
180
+ if ($stashes -gt 0) {
181
+ Write-Host " $stashes stash entr(ies) - `refs/stash` is NOT copied by a clone or a push." -ForegroundColor Yellow
182
+ }
183
+ }
184
+
185
+ # THE ONE THAT LOSES WORK. A mirror copies refs/heads/* and refs/tags/*; a detached
186
+ # HEAD is neither, so its commits are not carried - and with nothing pointing at them,
187
+ # git may collect them. Found on a real app: 200 commits of certified work on two
188
+ # detached worktrees, under a path its own docs called a recurring cleanup hazard.
189
+ $unreachable = @(Get-AdoptProp $inventory.git 'unreachableCommits' @())
190
+ if ($unreachable.Count -gt 0) {
191
+ Write-AdoptHeading 'COMMITS NO PORT WOULD CARRY'
192
+ Write-Host " These worktrees are on a DETACHED HEAD that no branch and no tag points at." -ForegroundColor Red
193
+ Write-Host " A clone copies branches and tags. These are neither, so they would be left" -ForegroundColor Red
194
+ Write-Host " behind - and git is free to delete them." -ForegroundColor Red
195
+ foreach ($u in $unreachable) {
196
+ $n = if ($u.commits) { "$($u.commits) commits" } else { 'unknown depth' }
197
+ Write-Host (" {0}`n HEAD {1} ({2})" -f $u.path, "$($u.head)".Substring(0,9), $n) -ForegroundColor Red
198
+ }
199
+ Write-Host ""
200
+ Write-Host " FIX IT BEFORE PORTING - one command each, and it changes nothing else:" -ForegroundColor Yellow
201
+ foreach ($u in $unreachable) {
202
+ $leaf = (Split-Path $u.path -Leaf) -replace '[^A-Za-z0-9._-]', '-'
203
+ Write-Host " git -C <repo> branch rescue/$leaf $("$($u.head)".Substring(0,9))" -ForegroundColor Yellow
204
+ }
205
+ }
206
+ }
207
+
169
208
  # Neither committed nor ignored. This is the state that turns one careless `git add -A`
170
209
  # into gigabytes of runtime data in the history for ever, and no filename pattern finds
171
210
  # it - only git can tell an undeclared data tree from a source folder.
@@ -254,6 +254,22 @@ Add-Item 'agent-rules' 'A rules file the coding agents read' $false `
254
254
  $(if ($hasAgentRules) { (@($inv.docs | Where-Object { $_ -match '(?i)^(agents|claude)\.md$' }) -join ', ') } else { 'none' }) `
255
255
  '' 'Without it every agent re-derives the project rules, and each one derives them differently.'
256
256
 
257
+ # A port copies refs. Work that is in no ref is work a port silently drops.
258
+ $unreachable = @(Get-AdoptProp $inv.git 'unreachableCommits' @())
259
+ $stashCount = [int](Get-AdoptProp $inv.git 'stashes' 0)
260
+ $wtDirty = @(@(Get-AdoptProp $inv.git 'worktrees' @()) | Where-Object { $_.dirtyFiles -and $_.dirtyFiles -gt 0 })
261
+ Add-Item 'work-is-in-a-ref' 'Every commit is reachable from a branch or a tag' $true `
262
+ $(if (-not $gitUsable) { 'ASK' } elseif ($unreachable.Count -gt 0) { 'GAP' } elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) { 'ASK' } else { 'PASS' }) `
263
+ $(if ($unreachable.Count -gt 0) {
264
+ "DETACHED and unreferenced: " + ((@($unreachable | ForEach-Object { "$($_.path) ($($_.commits) commits)" })) -join '; ')
265
+ }
266
+ elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) {
267
+ "no unreachable commits, but $stashCount stash(es) and $($wtDirty.Count) worktree(s) with uncommitted files - none of that is in a ref either"
268
+ }
269
+ else { 'every worktree is on a branch; no stashes' }) `
270
+ 'For each stash and each uncommitted file: does that work matter? Anything that does needs a branch before the port.' `
271
+ 'A clone copies refs/heads/* and refs/tags/*. A detached HEAD is neither, so its commits are not carried - and nothing points at them, so git may collect them. `git branch <name> <sha>` costs nothing and fixes it.'
272
+
257
273
  Add-Item 'no-junctions' 'No junction or symlink inside the tree git manages' $false `
258
274
  $(if ($junctionsInTree.Count -eq 0) { 'PASS' } else { 'GAP' }) `
259
275
  $(if ($junctionsInTree.Count -eq 0) { 'none outside dependency folders' } else { "$($junctionsInTree.Count): $((@($junctionsInTree | Select-Object -First 3 | ForEach-Object { $_.path })) -join ', ')" }) `
@@ -26,7 +26,7 @@ Run the End-to-End Data Lineage generation procedure:
26
26
  3. **Convert to Confluence Storage Format XHTML:**
27
27
  - Run the PlantUML-enabled converter:
28
28
  ```powershell
29
- python scripts/convert_doc_to_confluence.py docs/<app>/<app>-data-lineage.md --output docs/<app>/<app>-data-lineage.html --diagram-type plantuml
29
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/convert_doc_to_confluence.py" docs/<app>/<app>-data-lineage.md --output docs/<app>/<app>-data-lineage.html --diagram-type plantuml
30
30
  ```
31
31
  - Verify that all diagrams convert to native `<ac:structured-macro ac:name="plantuml">` blocks with scale 1.2, natural splines, auto-wrapped edge labels, crimson red borders on impacted components, and embedded Creole legends.
32
32
  - Verify that output validates as 100% well-formed XML.
@@ -32,7 +32,7 @@ Run the High-Level Design (HLD) generation procedure:
32
32
  4. **Convert to Confluence Storage Format XHTML:**
33
33
  - Run the PlantUML-enabled converter:
34
34
  ```powershell
35
- python scripts/convert_doc_to_confluence.py docs/<app>/<app>-hld.md --output docs/<app>/<app>-hld.html --diagram-type plantuml
35
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/convert_doc_to_confluence.py" docs/<app>/<app>-hld.md --output docs/<app>/<app>-hld.html --diagram-type plantuml
36
36
  ```
37
37
  - Verify that all diagrams are converted to native `<ac:structured-macro ac:name="plantuml">` blocks with scale 1.2, natural splines, auto-wrapped edge labels, crimson red borders on impacted components, and embedded Creole legends.
38
38
  - Ensure multi-stage operational flows use horizontal side-by-side stages (`flowchart LR`) and architecture topologies follow clean 3-tier hierarchies (`flowchart TD`).
@@ -39,7 +39,7 @@ Run the Lower-Level Design (LLD) generation procedure:
39
39
  4. **Convert to Confluence Storage Format XHTML:**
40
40
  - Run the PlantUML-enabled converter:
41
41
  ```powershell
42
- python scripts/convert_doc_to_confluence.py docs/<app>/<app>-lld.md --output docs/<app>/<app>-lld.html --diagram-type plantuml
42
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/convert_doc_to_confluence.py" docs/<app>/<app>-lld.md --output docs/<app>/<app>-lld.html --diagram-type plantuml
43
43
  ```
44
44
  - Verify that all diagrams are converted to native `<ac:structured-macro ac:name="plantuml">` blocks with scale 1.2, natural splines, auto-wrapped edge labels, crimson red borders on impacted components, and embedded Creole legends.
45
45
  - Verify that output validates as well-formed XML.
@@ -12,6 +12,9 @@ simple Verdict Card.
12
12
 
13
13
  When invoked without a task prompt (or with a git range), `/mmr` runs a direct cross-model diff review.
14
14
 
15
+ The user typed: **$ARGUMENTS**. Route it to one of the commands below: `keep`, `drop` or `status`
16
+ as given; a git range to `review --range`; empty to `review`; anything else is the task for `run`.
17
+
15
18
  ## Natural Language Triggers
16
19
  You can trigger MMR naturally without typing the full slash command:
17
20
  - *"Run this on another lane: &lt;task&gt;"* &rarr; `/mmr "<task>"`
@@ -27,9 +30,9 @@ You can trigger MMR naturally without typing the full slash command:
27
30
 
28
31
  ### 1. Run an Ad-Hoc Task
29
32
  ```bash
30
- python scripts/caddis_adhoc.py run "<task description>" [--type backend|tests|docs|ui|hard]
33
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" run "<task description>" [--type backend|tests|docs|ui|hard]
31
34
  # Shorthand:
32
- python scripts/caddis_adhoc.py "<task description>"
35
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" "<task description>"
33
36
  ```
34
37
  - **Sandbox Isolation:** Creates an ephemeral 1-phase plan at `.caddis/orchestrator/adhoc/<slug>.md`.
35
38
  - **Pre-flight Checks:** Verifies dirty tree and API keys. Warns if local edits exist.
@@ -41,7 +44,7 @@ python scripts/caddis_adhoc.py "<task description>"
41
44
 
42
45
  ### 2. Keep the Result
43
46
  ```bash
44
- python scripts/caddis_adhoc.py keep
47
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" keep
45
48
  ```
46
49
  - Keeps the output branch `lane/adhoc-<slug>`.
47
50
  - **Main Branch Protection:** If you are on `main` or `master`, direct merge is disabled to protect trunk. Recommends running `/ship-pr`.
@@ -49,7 +52,7 @@ python scripts/caddis_adhoc.py keep
49
52
 
50
53
  ### 3. Drop the Result
51
54
  ```bash
52
- python scripts/caddis_adhoc.py drop
55
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" drop
53
56
  ```
54
57
  - Deletes the branch `lane/adhoc-<slug>`.
55
58
  - Deletes the ephemeral plan file and run logs.
@@ -57,7 +60,7 @@ python scripts/caddis_adhoc.py drop
57
60
 
58
61
  ### 4. Check Status
59
62
  ```bash
60
- python scripts/caddis_adhoc.py status
63
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" status
61
64
  ```
62
65
  - Displays API key readiness for all lanes (`Anthropic`, `DeepSeek`, `GLM`, `OpenAI`).
63
66
  - Shows the latest task status, model used, and gates/review verdicts.
@@ -65,9 +68,9 @@ python scripts/caddis_adhoc.py status
65
68
 
66
69
  ### 5. Review Diff Only
67
70
  ```bash
68
- python scripts/caddis_adhoc.py review [--range <range>]
71
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py" review [--range <range>]
69
72
  # Shorthand (no args):
70
- python scripts/caddis_adhoc.py
73
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_adhoc.py"
71
74
  ```
72
75
  - Fast cross-model review of working tree or git range (e.g., `origin/main..HEAD`).
73
76
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis-extras",
3
- "version": "1.3.36",
3
+ "version": "1.3.37",
4
4
  "description": "caddis-extras — the long-tail skills for the Antigravity (agy) caddis plugin; install alongside caddis for the full set."
5
5
  }
@@ -74,6 +74,13 @@ facts - services, scheduled tasks, web sites - can only be read on the box itsel
74
74
  flag yourself.** A credential that was ever committed must be ROTATED, not only deleted.
75
75
  - **What runs it.** If nothing on the host references the folder and the app is live, it runs
76
76
  somewhere you have not looked. Find out where before planning anything.
77
+ - **COMMITS NO PORT WOULD CARRY.** A worktree is an extra folder, not an extra repository, so
78
+ its branch travels with a clone like any other. A **detached HEAD** does not: a clone copies
79
+ `refs/heads/*` and `refs/tags/*`, and a detached HEAD is neither. Nothing points at those
80
+ commits, so git may also collect them. If this section appears, fix it before anything else -
81
+ the report prints the exact `git branch` command, it changes nothing else, and it is the
82
+ difference between porting the work and losing it. Stashes are the same problem: `refs/stash`
83
+ is not copied by a clone or a push.
77
84
 
78
85
  Junctions and symlinks are reported and never followed. A junction inside a tree that git manages
79
86
  is a data-loss hazard: git's recursive remove follows it and deletes the target's contents.
@@ -1,6 +1,6 @@
1
1
  # The adoption standard
2
2
 
3
- `Test-AppConformance.ps1` (phase 3) measures an app against 20 items, in the order below - the
3
+ `Test-AppConformance.ps1` (phase 3) measures an app against 21 items, in the order below - the
4
4
  same order the script adds them in. Each one gets a verdict, never a guess:
5
5
 
6
6
  | Verdict | Means |
@@ -247,7 +247,40 @@ run it - should not require finding and interrupting someone.
247
247
  **Why it is on the list:** without it, every coding agent that touches the app re-derives its
248
248
  non-obvious rules from scratch, and different agents derive them differently.
249
249
 
250
- ### 19. `no-junctions` - No junction or symlink inside the tree git manages
250
+ ### 19. `work-is-in-a-ref` - Every commit is reachable from a branch or a tag
251
+
252
+ **Blocking.**
253
+
254
+ **Decided by:** `GAP` when a worktree is on a DETACHED HEAD that no branch and no tag contains.
255
+ `ASK` when there are no unreachable commits but there are stashes, or worktrees with
256
+ uncommitted files. `ASK` when git could not be run. `PASS` otherwise.
257
+
258
+ **Why it is on the list.** A port copies refs. A clone takes `refs/heads/*` and `refs/tags/*`,
259
+ so a worktree's BRANCH travels with it like any other - a worktree is an extra folder, not an
260
+ extra repository. A **detached HEAD is neither a branch nor a tag**, so its commits are not
261
+ copied, and because nothing points at them git is free to collect them. The work is not
262
+ protected by being on disk; it is protected by being in a ref.
263
+
264
+ Measured on a real app: two detached worktrees under a temporary path that the app's own
265
+ documents called a "recurring external cleanup hazard", with three recorded incidents. They
266
+ held **200 commits** of certified work. `branch --contains` returned 0 for both. Every other
267
+ check passed; a port would have carried 127 branches and left behind the only copy of the part
268
+ that mattered.
269
+
270
+ **The fix costs nothing** and the report prints it per worktree:
271
+
272
+ ```
273
+ git -C <repo> branch rescue/<worktree-name> <sha>
274
+ ```
275
+
276
+ It creates a branch name and changes nothing else. After it, `branch --contains` returns 1 and
277
+ the commits port like anything else.
278
+
279
+ Stashes are the same class of problem for a smaller amount of work: `refs/stash` is not copied
280
+ by a clone or a push. Uncommitted files in a worktree are invisible to the main checkout's
281
+ `git status`, which is why the inventory reports each worktree's own count.
282
+
283
+ ### 20. `no-junctions` - No junction or symlink inside the tree git manages
251
284
 
252
285
  **Not blocking.**
253
286
 
@@ -261,7 +294,7 @@ Ordinary Windows delete operations (`Remove-Item -Recurse`, `cmd /c rmdir /s /q`
261
294
  this trap easy to miss: a delete you tested yourself can look completely safe right up until git
262
295
  does the deleting instead.
263
296
 
264
- ### 20. `profile` - The stack is declared, not assumed
297
+ ### 21. `profile` - The stack is declared, not assumed
265
298
 
266
299
  **Blocking.**
267
300
 
@@ -308,9 +341,9 @@ what it really is.
308
341
  | `1` | At least one blocking item is `GAP` and not waived. Checked first - if both a blocking `GAP` and a blocking unanswered `ASK` exist at once, exit code `1` wins. |
309
342
  | `2` | No blocking `GAP` remains, but at least one blocking item is still an unanswered `ASK` and not waived. |
310
343
 
311
- The eight blocking items, as the code actually enforces them, are: `git-repo`,
312
- `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `run-declared`, `owner`,
313
- and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
344
+ The nine blocking items, as the code actually enforces them, are: `git-repo`,
345
+ `remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `work-is-in-a-ref`,
346
+ `run-declared`, `owner` and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
314
347
  item, and not by the project config. An earlier draft of the config carried a `blockingItems`
315
348
  list that nothing read and that had drifted out of step with this set; it was removed from the
316
349
  code and from `config.example.json` together, rather than documented as a thing that looks
@@ -196,7 +196,8 @@ $script:AdoptScanBlock = {
196
196
  # a key that is missing because one git call did not run.
197
197
  $g = @{ isRepo = $true; gitAvailable = $gitAvailable; gitError = $gitError; commits = $null; branch = $null
198
198
  localBranches = 0; remoteBranches = 0; tags = 0; dirtyFiles = $null
199
- remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null }
199
+ remotes = @(); upstream = $null; unpushed = $null; lastCommit = $null
200
+ worktrees = @(); unreachableCommits = @(); stashes = 0 }
200
201
  $result.git = $g
201
202
  if (-not $gitAvailable) {
202
203
  $result.partial = $true
@@ -231,6 +232,63 @@ $script:AdoptScanBlock = {
231
232
  if ($ahead) { $g.unpushed = [int]"$ahead" }
232
233
  }
233
234
  else { $g.upstream = $null; $g.unpushed = $null }
235
+
236
+ # ---- worktrees, and the commits a port would silently leave behind ----------
237
+ #
238
+ # A worktree is an extra FOLDER, not an extra repository: its branch lives in
239
+ # this same .git, so a mirror clone carries it. What a mirror does NOT carry is
240
+ # a DETACHED HEAD, because a mirror copies refs/heads/* and refs/tags/* and a
241
+ # detached HEAD is neither. Nothing points at those commits, so git is also free
242
+ # to collect them.
243
+ #
244
+ # This is not hypothetical. A real app had two detached worktrees on a temp path
245
+ # its own documents called a "recurring cleanup hazard", holding 200 commits of
246
+ # certified work. `branches containing it` was 0 for both. One `git worktree add`
247
+ # months earlier, and a port would have taken everything except the part that
248
+ # mattered.
249
+ $g.worktrees = @()
250
+ $g.unreachableCommits = @()
251
+ $wtOut = & $runGit @('worktree', 'list', '--porcelain')
252
+ if ($wtOut) {
253
+ $cur = $null
254
+ foreach ($line in @($wtOut)) {
255
+ $s = "$line"
256
+ if ($s -match '^worktree\s+(.+)$') {
257
+ if ($cur) { $g.worktrees += $cur }
258
+ $cur = @{ path = $Matches[1]; branch = $null; head = $null; detached = $false; dirtyFiles = $null; isMain = $false }
259
+ }
260
+ elseif ($s -match '^HEAD\s+([0-9a-f]{40})$' -and $cur) { $cur.head = $Matches[1] }
261
+ elseif ($s -match '^branch\s+refs/heads/(.+)$' -and $cur) { $cur.branch = $Matches[1] }
262
+ elseif ($s -eq 'detached' -and $cur) { $cur.detached = $true }
263
+ }
264
+ if ($cur) { $g.worktrees += $cur }
265
+ }
266
+
267
+ foreach ($w in @($g.worktrees)) {
268
+ $w.isMain = ($w.path.TrimEnd('\', '/') -eq $root)
269
+ # Its OWN uncommitted files. A worktree has a separate working tree, so the
270
+ # main checkout's `git status` says nothing about it.
271
+ if (Test-Path -LiteralPath $w.path) {
272
+ $st = & git -c safe.directory=* -C $w.path status --porcelain 2>&1
273
+ if ($LASTEXITCODE -eq 0) { $w.dirtyFiles = @($st | Where-Object { "$_" -ne '' }).Count }
274
+ }
275
+ # The finding that matters: is this HEAD reachable from any branch or tag?
276
+ if ($w.detached -and $w.head) {
277
+ $containing = @(& git -c safe.directory=* -C $root branch --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
278
+ $tagged = @(& git -c safe.directory=* -C $root tag --contains $w.head 2>&1 | Where-Object { "$_" -notmatch '^(error|fatal)' -and "$_" -ne '' })
279
+ if ($containing.Count -eq 0 -and $tagged.Count -eq 0) {
280
+ $ahead = & git -c safe.directory=* -C $root rev-list --count "$($w.head)" 2>&1
281
+ $g.unreachableCommits += @{
282
+ path = $w.path; head = $w.head
283
+ commits = $(if ($LASTEXITCODE -eq 0) { [int]"$ahead" } else { $null })
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ # A stash is a ref, but `refs/stash` is not copied by a normal clone or push.
290
+ $stash = & $runGit @('stash', 'list')
291
+ $g.stashes = @($stash | Where-Object { "$_" -ne '' }).Count
234
292
  }
235
293
 
236
294
  # ---- which files git actually tracks ---------------------------------------------
@@ -166,6 +166,45 @@ elseif ($inventory.git.isRepo) {
166
166
  }
167
167
  else { Write-Host " repo : NO - there is no history to port; adoption starts with one import commit" -ForegroundColor Yellow }
168
168
 
169
+ if ($inventory.git.isRepo) {
170
+ $wts = @(Get-AdoptProp $inventory.git 'worktrees' @())
171
+ $stashes = [int](Get-AdoptProp $inventory.git 'stashes' 0)
172
+ if ($wts.Count -gt 1 -or $stashes -gt 0) {
173
+ Write-AdoptHeading 'Worktrees and stashes'
174
+ foreach ($w in $wts) {
175
+ $what = if ($w.branch) { "on $($w.branch)" } else { "DETACHED at $("$($w.head)".Substring(0,9))" }
176
+ $dirty = if ($null -eq $w.dirtyFiles) { 'unreadable' } else { "$($w.dirtyFiles) uncommitted" }
177
+ $tag = if ($w.isMain) { ' (the main checkout)' } else { '' }
178
+ Write-Host (" {0,-58} {1,-34} {2}{3}" -f $w.path, $what, $dirty, $tag)
179
+ }
180
+ if ($stashes -gt 0) {
181
+ Write-Host " $stashes stash entr(ies) - `refs/stash` is NOT copied by a clone or a push." -ForegroundColor Yellow
182
+ }
183
+ }
184
+
185
+ # THE ONE THAT LOSES WORK. A mirror copies refs/heads/* and refs/tags/*; a detached
186
+ # HEAD is neither, so its commits are not carried - and with nothing pointing at them,
187
+ # git may collect them. Found on a real app: 200 commits of certified work on two
188
+ # detached worktrees, under a path its own docs called a recurring cleanup hazard.
189
+ $unreachable = @(Get-AdoptProp $inventory.git 'unreachableCommits' @())
190
+ if ($unreachable.Count -gt 0) {
191
+ Write-AdoptHeading 'COMMITS NO PORT WOULD CARRY'
192
+ Write-Host " These worktrees are on a DETACHED HEAD that no branch and no tag points at." -ForegroundColor Red
193
+ Write-Host " A clone copies branches and tags. These are neither, so they would be left" -ForegroundColor Red
194
+ Write-Host " behind - and git is free to delete them." -ForegroundColor Red
195
+ foreach ($u in $unreachable) {
196
+ $n = if ($u.commits) { "$($u.commits) commits" } else { 'unknown depth' }
197
+ Write-Host (" {0}`n HEAD {1} ({2})" -f $u.path, "$($u.head)".Substring(0,9), $n) -ForegroundColor Red
198
+ }
199
+ Write-Host ""
200
+ Write-Host " FIX IT BEFORE PORTING - one command each, and it changes nothing else:" -ForegroundColor Yellow
201
+ foreach ($u in $unreachable) {
202
+ $leaf = (Split-Path $u.path -Leaf) -replace '[^A-Za-z0-9._-]', '-'
203
+ Write-Host " git -C <repo> branch rescue/$leaf $("$($u.head)".Substring(0,9))" -ForegroundColor Yellow
204
+ }
205
+ }
206
+ }
207
+
169
208
  # Neither committed nor ignored. This is the state that turns one careless `git add -A`
170
209
  # into gigabytes of runtime data in the history for ever, and no filename pattern finds
171
210
  # it - only git can tell an undeclared data tree from a source folder.
@@ -254,6 +254,22 @@ Add-Item 'agent-rules' 'A rules file the coding agents read' $false `
254
254
  $(if ($hasAgentRules) { (@($inv.docs | Where-Object { $_ -match '(?i)^(agents|claude)\.md$' }) -join ', ') } else { 'none' }) `
255
255
  '' 'Without it every agent re-derives the project rules, and each one derives them differently.'
256
256
 
257
+ # A port copies refs. Work that is in no ref is work a port silently drops.
258
+ $unreachable = @(Get-AdoptProp $inv.git 'unreachableCommits' @())
259
+ $stashCount = [int](Get-AdoptProp $inv.git 'stashes' 0)
260
+ $wtDirty = @(@(Get-AdoptProp $inv.git 'worktrees' @()) | Where-Object { $_.dirtyFiles -and $_.dirtyFiles -gt 0 })
261
+ Add-Item 'work-is-in-a-ref' 'Every commit is reachable from a branch or a tag' $true `
262
+ $(if (-not $gitUsable) { 'ASK' } elseif ($unreachable.Count -gt 0) { 'GAP' } elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) { 'ASK' } else { 'PASS' }) `
263
+ $(if ($unreachable.Count -gt 0) {
264
+ "DETACHED and unreferenced: " + ((@($unreachable | ForEach-Object { "$($_.path) ($($_.commits) commits)" })) -join '; ')
265
+ }
266
+ elseif ($stashCount -gt 0 -or $wtDirty.Count -gt 0) {
267
+ "no unreachable commits, but $stashCount stash(es) and $($wtDirty.Count) worktree(s) with uncommitted files - none of that is in a ref either"
268
+ }
269
+ else { 'every worktree is on a branch; no stashes' }) `
270
+ 'For each stash and each uncommitted file: does that work matter? Anything that does needs a branch before the port.' `
271
+ 'A clone copies refs/heads/* and refs/tags/*. A detached HEAD is neither, so its commits are not carried - and nothing points at them, so git may collect them. `git branch <name> <sha>` costs nothing and fixes it.'
272
+
257
273
  Add-Item 'no-junctions' 'No junction or symlink inside the tree git manages' $false `
258
274
  $(if ($junctionsInTree.Count -eq 0) { 'PASS' } else { 'GAP' }) `
259
275
  $(if ($junctionsInTree.Count -eq 0) { 'none outside dependency folders' } else { "$($junctionsInTree.Count): $((@($junctionsInTree | Select-Object -First 3 | ForEach-Object { $_.path })) -join ', ')" }) `
@@ -1,8 +1,8 @@
1
1
  {
2
- "poolVersion": "1.3.116",
2
+ "poolVersion": "1.3.119",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.116",
5
- "antigravity-plugin-extras": "1.3.36",
4
+ "antigravity-plugin": "1.3.119",
5
+ "antigravity-plugin-extras": "1.3.37",
6
6
  "codex": "unknown"
7
7
  }
8
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",