@caddis/cli 0.4.52 → 0.4.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  5. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
  12. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  13. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  14. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
  16. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
  19. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  22. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  23. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  25. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
  27. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  29. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  30. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  31. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  32. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  33. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  34. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  35. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  36. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  37. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  38. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  39. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  40. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  41. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  42. package/bundles/antigravity-plugin/skills/_registry.md +4 -6
  43. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  44. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  45. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  46. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
  47. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  49. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
  50. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  51. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  52. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  53. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  55. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
  56. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  57. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  58. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  59. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
  60. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  61. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  62. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
  64. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  65. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  66. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  67. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  68. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  69. package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
  70. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
  71. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
  72. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  73. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  74. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  76. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  77. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  78. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  79. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  80. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  81. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  82. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  83. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  84. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  85. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
  86. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  87. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  88. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  89. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  90. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  91. package/bundles/codex/AGENTS.md +5 -4
  92. package/bundles/manifest.json +3 -3
  93. package/package.json +1 -1
  94. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  95. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  96. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  97. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  98. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  99. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  100. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  101. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  102. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  103. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  104. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  105. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  106. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  107. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  108. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  109. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  110. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  111. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -5,11 +5,9 @@ WHY
5
5
  ---
6
6
  Most of caddis's guarantees are sentences in markdown that a model may or may not honour
7
7
  (measured: 7.0% machine-enforced at the time this landed — `scripts/enforcement_inventory.py`).
8
- The three gates below are the ones that have actually bitten, so they become exit codes:
8
+ The two gates below are the ones that have actually bitten, so they become exit codes:
9
9
 
10
10
  lane-check the assigned execution lane was never read, so a "cheap" lane ran nothing
11
- verdict-gate an Advisory-Hub verdict from a DIFFERENT plan could unlock the next phase,
12
- and a batched verdict (`phase-10-11`) falsely blocked the phase after it
13
11
  tracker-vs-git a stray staged edit reverted four completed Tracker rows to "not started"
14
12
  and it was caught two milestones later
15
13
 
@@ -62,9 +60,7 @@ def _degrade(msg: str) -> int:
62
60
 
63
61
  def is_headless(env=None) -> bool:
64
62
  e = env if env is not None else os.environ
65
- if str(e.get("CADDIS_HEADLESS", "")).strip().lower() in _TRUTHY:
66
- return True
67
- return bool(e.get("DOCKET_PLAN") or e.get("DOCKET_BRANCH"))
63
+ return str(e.get("CADDIS_HEADLESS", "")).strip().lower() in _TRUTHY
68
64
 
69
65
 
70
66
  # ── plan parsing ────────────────────────────────────────────────────────────
@@ -93,10 +89,11 @@ def phase_lane(block: str) -> str:
93
89
  def launch_command(block: str) -> str:
94
90
  """The literal command for a non-claude lane, if the phase names one.
95
91
 
96
- Also matches a caddis_lanes.py run command or a direct codex/agy command.
92
+ Also matches a caddis_lanes.py run command, a direct codex/agy command, and an agy command
93
+ wrapped in the spend cap (`python .../caddis_spend.py run --caller X -- agy ...`).
97
94
  """
98
95
  m = re.search(
99
- r'`((?:claude-(?:glm|oss|deepseek)\b|' + _LANES_RUN
96
+ r'`((?:claude-(?:glm|oss|deepseek)\b|' + _LANES_RUN + r'\b|' + _SPEND_RUN
100
97
  + r'\b|codex\s+exec\b|agy\b)[^`]*)`',
101
98
  block,
102
99
  )
@@ -104,6 +101,22 @@ def launch_command(block: str) -> str:
104
101
 
105
102
 
106
103
  _LANES_RUN = r'python\s+"?\$\{CLAUDE_PLUGIN_ROOT\}/scripts/caddis_lanes\.py"?\s+run'
104
+ # Every agy lane goes through the global spend cap (caddis-next Phase 4). Any path that ends in
105
+ # scripts/caddis_spend.py: `${CLAUDE_PLUGIN_ROOT}/...`, an expanded plugin-cache path (the codex bundle
106
+ # rewrites the variable), or the bare repo-relative `scripts/caddis_spend.py` - which gate_lane accepts
107
+ # only in the caddis source repo, because in an app repo that path is a file the agent can write.
108
+ _SPEND_RUN = r'python3?\s+"?[^"\s`]*scripts/caddis_spend\.py"?\s+run'
109
+ _SPEND_PATH = re.compile(r'python3?\s+"?([^"\s`]*scripts/caddis_spend\.py)"?\s+run')
110
+ # Shell control characters outside the quoted prompt would chain a second, uncapped command.
111
+ _SHELL_CHAIN = re.compile(r"[;&|`\n]|\$\(")
112
+
113
+
114
+ def _caddis_source_root(plan: Path) -> bool:
115
+ """True when the plan lives in the caddis source repo (the only place a repo-relative wrapper is trusted)."""
116
+ for parent in plan.resolve().parents:
117
+ if (parent / ".git").exists():
118
+ return (parent / "claude-harness" / "scripts" / "caddis_governor.py").is_file()
119
+ return False
107
120
 
108
121
 
109
122
  # ── gate: lane-check ────────────────────────────────────────────────────────
@@ -140,6 +153,46 @@ def gate_lane(plan: Path, phase: int) -> int:
140
153
  if not cmd:
141
154
  sys.stderr.write(f"[caddis-gate] phase {phase} is lane '{lane}' but names no launch command\n")
142
155
  return EXIT_MALFORMED
156
+ if re.match(r"agy\b", cmd):
157
+ sys.stderr.write(
158
+ f"[caddis-gate] phase {phase}'s agy command is not wrapped in the spend cap: {cmd}\n"
159
+ " agy lanes must go through caddis_spend.py run --caller <name> -- agy ... Refusing to run it.\n")
160
+ return EXIT_MALFORMED
161
+ inner = cmd
162
+ if re.match(_SPEND_RUN, cmd):
163
+ wrapped = re.match(_SPEND_RUN + r'\s+--caller\s+\S+\s+--\s+(agy\b.*)$', cmd, re.S)
164
+ if not wrapped:
165
+ sys.stderr.write(
166
+ f"[caddis-gate] phase {phase}'s spend-cap command is malformed: {cmd}\n"
167
+ " Expected: caddis_spend.py run --caller <name> -- agy ... Refusing to run it.\n")
168
+ return EXIT_MALFORMED
169
+ inner = wrapped.group(1)
170
+ # Command substitution runs even inside double quotes (bash and PowerShell), so `$(` and
171
+ # backticks are refused anywhere; `${` is refused after the `--` (the wrapper path may use it).
172
+ if (_SHELL_CHAIN.search(_QUOTED.sub('""', cmd)) or "$(" in cmd or "`" in cmd
173
+ or "${" in inner):
174
+ sys.stderr.write(
175
+ f"[caddis-gate] phase {phase}'s agy command chains another command: {cmd}\n"
176
+ " A second command after the wrapped agy would run outside the spend cap. Refusing to run it.\n")
177
+ return EXIT_MALFORMED
178
+ formats = re.findall(r"--output-format(?:\s+|=)(\S+)", _QUOTED.sub('""', inner))
179
+ if formats != ["json"]:
180
+ sys.stderr.write(
181
+ f"[caddis-gate] phase {phase}'s agy command must have exactly one --output-format json: {cmd}\n"
182
+ " Without JSON output the spend cap cannot read the token counts. Refusing to run it.\n")
183
+ return EXIT_MALFORMED
184
+ path = _SPEND_PATH.match(cmd).group(1)
185
+ norm = path.replace("\\", "/")
186
+ trusted = (norm == "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spend.py"
187
+ or ("/plugins/cache/" in norm and (norm.startswith("/") or re.match(r"[A-Za-z]:/", norm)
188
+ or norm.startswith("${HOME}/")))
189
+ or (norm == "scripts/caddis_spend.py" and _caddis_source_root(plan)))
190
+ if not trusted:
191
+ sys.stderr.write(
192
+ f"[caddis-gate] phase {phase}'s agy command uses an untrusted caddis_spend.py path: {cmd}\n"
193
+ " Use \"${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spend.py\" (or the installed plugin-cache path;\n"
194
+ " the bare scripts/caddis_spend.py only in the caddis source repo). Refusing to run it.\n")
195
+ return EXIT_MALFORMED
143
196
  if re.match(_LANES_RUN, cmd):
144
197
  named = re.search(r"--phase[\s=]+(\d+)", cmd)
145
198
  if not named:
@@ -152,13 +205,13 @@ def gate_lane(plan: Path, phase: int) -> int:
152
205
  f"[caddis-gate] phase {phase}'s caddis_lanes command names --phase {named.group(1)}: {cmd}\n"
153
206
  " The lane would run another phase's work. Refusing to run it.\n")
154
207
  return EXIT_MALFORMED
155
- elif re.match(r"(?:codex\s+exec|agy)\b", cmd):
156
- if "HEADLESS RUN RULES" not in cmd:
208
+ elif re.match(r"(?:codex\s+exec|agy)\b", inner):
209
+ if "HEADLESS RUN RULES" not in inner:
157
210
  sys.stderr.write(
158
211
  f"[caddis-gate] phase {phase}'s direct command has no HEADLESS RUN RULES: {cmd}\n"
159
212
  " The child has no headless run rules. Refusing to run it.\n")
160
213
  return EXIT_MALFORMED
161
- named = re.search(r"Implement Phase (\d+) only", cmd)
214
+ named = re.search(r"Implement Phase (\d+) only", inner)
162
215
  if not named:
163
216
  sys.stderr.write(
164
217
  f"[caddis-gate] phase {phase}'s direct command has no phase restriction: {cmd}\n"
@@ -169,7 +222,7 @@ def gate_lane(plan: Path, phase: int) -> int:
169
222
  f"[caddis-gate] phase {phase}'s direct command names Phase {named.group(1)}: {cmd}\n"
170
223
  " The lane would run another phase's work. Refusing to run it.\n")
171
224
  return EXIT_MALFORMED
172
- if cmd.startswith("agy") and not _has_print_flag(cmd):
225
+ if inner.startswith("agy") and not _has_print_flag(inner):
173
226
  sys.stderr.write(
174
227
  f"[caddis-gate] phase {phase}'s direct agy command has no -p/--print: {cmd}\n"
175
228
  " The child would not run headless. Refusing to run it.\n")
@@ -185,83 +238,6 @@ def gate_lane(plan: Path, phase: int) -> int:
185
238
  return EXIT_WRONG_LANE
186
239
 
187
240
 
188
- # ── gate: verdict-gate ──────────────────────────────────────────────────────
189
-
190
- def _frontmatter(p: Path) -> dict:
191
- return caddis_frontmatter.parse(p.read_text(encoding="utf-8", errors="ignore"))
192
-
193
-
194
- def _covers(phase_field: str, n: int) -> bool:
195
- s = str(phase_field).strip()
196
- m = re.fullmatch(r"(\d+)\s*-\s*(\d+)", s)
197
- if m:
198
- return int(m.group(1)) <= n <= int(m.group(2))
199
- return s.isdigit() and int(s) == n
200
-
201
-
202
- def _hub_context_for(plan: Path) -> Path | None:
203
- """The advisory-context doc guarding this plan, or None if Hub mode is off.
204
-
205
- Primary: the documented `<plan-stem>-advisory-context.md`.
206
-
207
- Fallback by IDENTITY, and it is load-bearing: a real plan was renamed
208
- `genesys-rebuild.md` -> `genesys-rebuild-ui-revamp.md` and its context doc kept the old stem.
209
- The filename convention therefore stopped matching, Advisory-Hub mode silently switched OFF
210
- for every subsequent run, and nobody noticed because the Hub kept being driven by hand — 31
211
- artifacts were written while the gate meant to enforce them was dark. Both files still carry
212
- `feature: genesys-rebuild-ui-revamp`, so identity survives a rename where a filename does not.
213
-
214
- Requires an explicit matching `feature:` on BOTH sides, so it cannot switch Hub mode on for a
215
- plan that never opted in — "no file -> zero behaviour change" still holds.
216
- """
217
- exact = plan.with_name(plan.stem + "-advisory-context.md")
218
- if exact.is_file():
219
- return exact
220
- mine = _frontmatter(plan).get("feature", "").strip()
221
- if not mine:
222
- return None
223
- for cand in sorted(plan.parent.glob("*-advisory-context.md")):
224
- if _frontmatter(cand).get("feature", "").strip() == mine:
225
- sys.stderr.write(
226
- "[caddis-gate] Hub context matched by feature slug, not filename: "
227
- f"{cand.name} (expected {exact.name} - a rename left the convention broken)\n")
228
- return cand
229
- return None
230
-
231
-
232
- def gate_verdict(plan: Path, phase: int, reports: Path | None = None) -> int:
233
- if phase <= 1:
234
- return EXIT_OK # no predecessor
235
- if not _hub_context_for(plan):
236
- return EXIT_OK # Advisory-Hub mode is OFF — the normal case, zero behaviour change
237
- d = reports or (plan.parent.parent / "advisory-hub-reports")
238
- if not d.is_dir():
239
- sys.stderr.write(f"[caddis-gate] hub mode ON but no {d} — phase {phase-1} unvalidated\n")
240
- return EXIT_BLOCKED
241
-
242
- want = phase - 1
243
- slug, path_s = plan.stem, str(plan).replace("\\", "/")
244
- for f in sorted(d.glob("*.verdict.md")):
245
- fm = _frontmatter(f)
246
- if not _covers(fm.get("phase", ""), want):
247
- continue
248
- ident = fm.get("plan") or fm.get("feature") or ""
249
- # Accept either spelling: both are in live fleet use and neither is wrong.
250
- if ident and not (slug in ident or ident.replace("\\", "/") in path_s or ident == slug):
251
- continue
252
- v = fm.get("verdict", "")
253
- if v in ("accept", "accept-with-correction"):
254
- return EXIT_OK
255
- if v == "accept-degraded":
256
- sys.stderr.write(f"[caddis-gate] {f.name} is accept-degraded — validated WITHOUT an "
257
- "advisory context; record that you proceeded on it\n")
258
- return EXIT_NOTE
259
- sys.stderr.write(f"[caddis-gate] {f.name} is '{v}' — phase {want} must be redone\n")
260
- return EXIT_BLOCKED
261
- sys.stderr.write(f"[caddis-gate] no verdict covering phase {want} for plan '{slug}'\n")
262
- return EXIT_BLOCKED
263
-
264
-
265
241
  # ── gate: tracker-vs-git ────────────────────────────────────────────────────
266
242
 
267
243
  _ROW = re.compile(r"^\|\s*(\d+(?:-\d+)?)\s*\|(.*)$", re.M)
@@ -294,102 +270,6 @@ def gate_tracker(plan: Path, cwd: Path | None = None) -> int:
294
270
  return EXIT_NOTE
295
271
 
296
272
 
297
-
298
- # ── gate: hub-artifacts ─────────────────────────────────────────────────────
299
-
300
- def gate_hub_artifacts(reports: Path, extra_refs: list[Path] | None = None) -> int:
301
- """An artifact that exists on disk but is referenced nowhere the next Hub will look.
302
-
303
- `/spawn-hub` already conserves LEDGER IDS mechanically and fails the spawn when one vanishes.
304
- There was no equivalent for the artifacts the Hub itself writes, so the failure mode was exactly
305
- inverted: the ledger check catches an id that DISAPPEARED; nothing caught a file that EXISTS and
306
- is invisible.
307
-
308
- Observed 2026-08-05: a Hub wrote `phase-15-16.prompt.md` and `phase-17.prompt.md`, and its spawn
309
- prompt named only the first. The spawn passed cleanly - ids conserved, every context question
310
- answered, plan drift caught. Nothing failed because nothing was looking. The successor would have
311
- found no prompt for Phase 17, written a fresh one, and silently lost the single load-bearing
312
- paragraph explaining why that phase must run from a cold process - the whole reason it was split
313
- out of its batch. A regenerated prompt would have looked complete.
314
-
315
- Degrades open like every gate here: no directory, no artifacts, nothing readable -> exit 0.
316
- """
317
- if not reports.is_dir():
318
- return _degrade(f"no reports dir at {reports}")
319
- # PROMPTS ONLY, and deliberately so. A successor Hub regenerates a phase PROMPT it does not
320
- # know about - that is the documented near-miss. It does not regenerate reports (the
321
- # implementer writes those) or verdicts (it writes those after the fact, per phase). Checking
322
- # all three flagged 11 of 37 artifacts in one live repo and 25 of 37 in another, which is a
323
- # gate nobody keeps switched on. Narrow to the file class that can actually be clobbered.
324
- artifacts = sorted(reports.glob("phase-*.prompt.md"))
325
- if not artifacts:
326
- return _degrade(f"no phase artifacts in {reports}")
327
-
328
- # Everywhere a successor Hub is told to look: the dir's own index/rules, the most recent
329
- # spawn docs, and any relay handed over. Read them all as one corpus.
330
- # This gate is about HANDOVER conservation. With no spawn doc, no handover has happened,
331
- # so there is no successor and nothing can be orphaned from one. Flagging every prompt in a
332
- # single-Hub repo would be pure noise: one live repo with 8 prompts and zero handovers was
333
- # reporting all 8 until this check was added.
334
- if not any(reports.glob("hub-*.spawn.md")):
335
- return _degrade(f"no handover has occurred in {reports.name} (no spawn doc)")
336
-
337
- ref_files: list[Path] = []
338
- for pat in ("README.md", "AGENTS.md", "hub-*.spawn.md"):
339
- ref_files += sorted(reports.glob(pat))
340
- for extra in (extra_refs or []):
341
- if extra.is_file():
342
- ref_files.append(extra)
343
- corpus = ""
344
- for f in ref_files:
345
- try:
346
- corpus += f.read_text(encoding="utf-8", errors="ignore")
347
- except Exception:
348
- continue
349
- if not corpus:
350
- return _degrade(f"nothing references artifacts in {reports} (no index, no spawn doc)")
351
-
352
- # A spawn prompt that never names the resume doc produces a Hub that keeps no tracker.
353
- #
354
- # Measured: a spawned Hub read relay.md, passed a thirteen-question context check, found four
355
- # real defects in its handover — and created no tracker, because nothing asked it to. The work
356
- # survived only because the OUTGOING Hub had written one into relay.md an hour earlier, unasked.
357
- #
358
- # The parking-lot note suggested checking that relay.md was "modified in the same commit range".
359
- # That cannot work: relay.md is GITIGNORED by design, so git never sees it. What is checkable is
360
- # the instruction — does the spawn prompt tell its successor to keep the tracker at all?
361
- # ONLY the newest spawn doc, by hub number. Older ones are immutable history: a spawn prompt
362
- # that was already used cannot be fixed, only falsified, and a gate that stays red on a record
363
- # nobody may edit is the "always red, therefore ignored" failure this file keeps warning about.
364
- spawns = sorted(reports.glob("hub-*.spawn.md"),
365
- key=lambda p: (int(m.group(1)) if (m := re.search(r"hub-(\d+)", p.name)) else 0,
366
- p.name))
367
- newest = spawns[-1] if spawns else None
368
- if newest and "relay" not in newest.read_text(encoding="utf-8", errors="ignore").lower():
369
- sys.stderr.write(
370
- f"[caddis-gate] the newest spawn prompt never mentions the resume doc:\n"
371
- f" {newest.name}\n")
372
- sys.stderr.write(
373
- " The succession table records WHO HELD THE ROLE; .caddis/relay.md records WHERE THE\n"
374
- " WORK IS. The table is only written when a Hub ends, so nothing covers the hours a\n"
375
- " Hub spends working — the exact period this mechanism exists to protect. Tell the\n"
376
- " incoming Hub to mirror its task list into relay.md as it goes.\n")
377
- return EXIT_BLOCKED
378
-
379
- orphans = [f.name for f in artifacts if f.name not in corpus and f.stem not in corpus]
380
- if not orphans:
381
- return EXIT_OK
382
- sys.stderr.write(
383
- f"[caddis-gate] {len(orphans)} Hub artifact(s) exist on disk but are referenced nowhere "
384
- "the next Hub will look:\n")
385
- for o in orphans:
386
- sys.stderr.write(f" {o}\n")
387
- sys.stderr.write(
388
- " A successor will not know these exist, will regenerate them, and will lose whatever\n"
389
- " reasoning they carried. Reference them in the spawn doc or the index before handing over.\n")
390
- return EXIT_BLOCKED
391
-
392
-
393
273
  def gate_parking_lot(repo_root: Path) -> int:
394
274
  """Every future-work item conforms to the one-register contract, or the push stops.
395
275
 
@@ -491,8 +371,8 @@ def gate_handover_check(doc: Path, repo_root: Path, max_chars: int | None = None
491
371
 
492
372
  Degrades open on a missing/unreadable doc. Blocks on a path the doc claims and the repo does
493
373
  not have, and -- only when the caller passes ``max_chars`` -- on a doc longer than that. The
494
- cap is opt-in: /handoff caps a workstream relay at 4,000 characters, but a /spawn-hub prompt
495
- must embed the full inventory verbatim and would never fit.
374
+ cap is opt-in: /handoff caps a workstream relay at 4,000 characters, but a /spawn-session
375
+ handover may embed a full inventory verbatim and would not fit.
496
376
  """
497
377
  if not doc.is_file():
498
378
  return _degrade(f"no handover doc at {doc}")
@@ -507,6 +387,35 @@ def gate_handover_check(doc: Path, repo_root: Path, max_chars: int | None = None
507
387
  )
508
388
  return EXIT_BLOCKED
509
389
 
390
+ # `/handoff` Step 3c saves the open tasks beside the relay. A tasks file the resume prompt
391
+ # never names is a task list the next session never reloads.
392
+ tasks_file = doc.with_name(doc.stem + ".tasks.md")
393
+ if tasks_file.is_file() and tasks_file.name not in text:
394
+ sys.stderr.write(f"handover-check: the resume prompt does not load {tasks_file.name}\n")
395
+ return EXIT_BLOCKED
396
+
397
+ done_bullets = 0
398
+ in_done = in_fence = False
399
+ for line in text.splitlines():
400
+ stripped = line.strip()
401
+ if stripped.startswith("```"): # a heading or bullet inside a code block is text, not structure
402
+ in_fence = not in_fence
403
+ continue
404
+ if in_fence:
405
+ continue
406
+ if stripped.startswith("## Done"):
407
+ in_done = True
408
+ continue
409
+ if in_done:
410
+ if re.match(r"#{1,2} ", stripped): # the next ## (or #) section ends Done; a ### stays inside it
411
+ in_done = False
412
+ continue
413
+ if line.lstrip().startswith(("- ", "* ")):
414
+ done_bullets += 1
415
+ if done_bullets > 8:
416
+ sys.stderr.write(f"handover-check: Done has {done_bullets} bullets; the cap is 8\n")
417
+ return EXIT_BLOCKED
418
+
510
419
  missing: list[str] = []
511
420
  try:
512
421
  # One rule for both gates: `caddis_spawn.check_document` already treats a bare filename
@@ -667,6 +576,151 @@ def gate_review_trigger(repo_root: Path, rev_range: str | None = None) -> int:
667
576
  return EXIT_NOTE
668
577
 
669
578
 
579
+ TIER_REVIEWERS = {
580
+ "0": 0,
581
+ "1-small": 0,
582
+ "1": 1,
583
+ "2": 2,
584
+ }
585
+
586
+
587
+ def is_test_file(path: str) -> bool:
588
+ norm = path.replace("\\", "/").lower()
589
+ if norm.startswith("./"):
590
+ norm = norm[2:]
591
+ segs = norm.split("/")
592
+ filename = segs[-1]
593
+ if any(s in ("tests", "test") for s in segs[:-1]):
594
+ return True
595
+ if filename.startswith("test_") and filename.endswith(".py"):
596
+ return True
597
+ if filename.endswith("_test.py"):
598
+ return True
599
+ for ext in (".test.ts", ".test.tsx", ".test.js", ".test.jsx"):
600
+ if filename.endswith(ext):
601
+ return True
602
+ return False
603
+
604
+
605
+ def is_tier0_file(path: str) -> bool:
606
+ norm = path.replace("\\", "/").lower()
607
+ if norm.startswith("./"):
608
+ norm = norm[2:]
609
+ if norm.startswith("docs/") or norm.startswith(".caddis/"):
610
+ return True
611
+ if "/" not in norm:
612
+ stem = norm.rsplit(".", 1)[0]
613
+ ext = norm.rsplit(".", 1)[1] if "." in norm else ""
614
+ if ext == "md" and (stem.startswith("readme") or stem.startswith("changelog")):
615
+ return True
616
+ if is_test_file(norm):
617
+ return True
618
+ return False
619
+
620
+
621
+ BINARY_LINES = 41 # a binary change has no line count: count it above the 40-line small limit, never "1-small"
622
+
623
+
624
+ def _rename_paths(path: str) -> list[str]:
625
+ """Both sides of a `--numstat` rename path (`old => new` or `pre/{old => new}/post`); [path] when not a rename."""
626
+ if " => " not in path:
627
+ return [path]
628
+ if "{" in path and "}" in path:
629
+ prefix, rest = path.split("{", 1)
630
+ inner, suffix = rest.split("}", 1)
631
+ old, new = inner.split(" => ", 1)
632
+ return [f"{prefix}{side}{suffix}".replace("//", "/").lstrip("/") for side in (old, new)]
633
+ old, new = path.split(" => ", 1)
634
+ return [old, new]
635
+
636
+
637
+ def _numstat(rng: str | None = None, repo_root: Path = Path(".")) -> list[tuple[str, int]]:
638
+ """(path, changed-lines) per file for an explicit range or the working tree.
639
+
640
+ A rename lists BOTH the old and the new path, so moving code into `docs/` or `tests/` is not tier 0. A binary
641
+ change counts `BINARY_LINES`. A failed `git diff` raises RuntimeError: the caller must not read it as an empty
642
+ diff (tier 0)."""
643
+ args = ["git", "-C", str(repo_root), "diff", "--numstat"]
644
+ if rng:
645
+ args.append(rng)
646
+ res = subprocess.run(args, capture_output=True, text=True, timeout=30)
647
+ if res.returncode != 0:
648
+ reason = (res.stderr or "").strip().splitlines()
649
+ raise RuntimeError(f"git diff --numstat exited {res.returncode}: {reason[0] if reason else 'no message'}")
650
+ files: list[tuple[str, int]] = []
651
+ for row in res.stdout.splitlines():
652
+ parts = row.split("\t")
653
+ if len(parts) != 3:
654
+ continue
655
+ add, dele, path = parts
656
+ if add == "-" or dele == "-":
657
+ line_count = BINARY_LINES
658
+ else:
659
+ line_count = (int(add) if add.isdigit() else 0) + (int(dele) if dele.isdigit() else 0)
660
+ paths = _rename_paths(path.strip())
661
+ if len(paths) == 2:
662
+ # A rename moves code out of its old path; a pure rename has 0 changed lines, so without this a move
663
+ # into tests/ would read as a small, covered change (tier 1-small, no review). The old path counts as large.
664
+ files.append((paths[0], max(line_count, BINARY_LINES)))
665
+ files.append((paths[1], max(line_count, BINARY_LINES))) # tests/x.py -> x.py moves test code into production
666
+ else:
667
+ for p in paths:
668
+ files.append((p, line_count))
669
+ return files
670
+
671
+
672
+ def review_tier(files: list[tuple[str, int]], plan_risk: str = "normal") -> str:
673
+ """Return '0', '1-small', '1' or '2' by the model routing review budget."""
674
+ non_tier0 = [(p, lines) for p, lines in files if not is_tier0_file(p)]
675
+ if not non_tier0:
676
+ return "0"
677
+ if str(plan_risk).strip().lower() == "high":
678
+ return "2"
679
+ non_tier0_paths = [p for p, _ in non_tier0]
680
+ non_tier0_lines = sum(lines for _, lines in non_tier0)
681
+ if review_triggers(non_tier0_paths, non_tier0_lines):
682
+ return "2"
683
+ has_test = any(is_test_file(p) for p, _ in files)
684
+ if non_tier0_lines <= 40 and has_test:
685
+ return "1-small"
686
+ return "1"
687
+
688
+
689
+ def gate_review_tier(
690
+ repo_root: Path,
691
+ rev_range: str | None = None,
692
+ plan: Path | str | None = None,
693
+ phase: int | str | None = None,
694
+ ) -> int:
695
+ """Print the tier and reviewer count for the diff and plan phase."""
696
+ risk = "normal"
697
+ if plan and phase is None:
698
+ risk = "high" # fail closed: a plan without a phase cannot give a risk
699
+ elif plan and phase is not None:
700
+ try:
701
+ try:
702
+ import caddis_review
703
+ except ModuleNotFoundError:
704
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
705
+ import caddis_review
706
+ risk = caddis_review.phase_risk(Path(plan), phase)
707
+ except Exception:
708
+ # Fail closed: a plan risk we cannot read counts as high (tier 2).
709
+ risk = "high"
710
+ try:
711
+ files = _numstat(rev_range, repo_root)
712
+ except Exception as exc: # noqa: BLE001 - a diff we cannot read is reviewed in full, never skipped
713
+ sys.stderr.write(f"caddis-gate: review-tier could not read the diff ({exc}); counting it as tier 2\n")
714
+ print("TIER: 2")
715
+ print("REVIEWERS: 2")
716
+ return EXIT_OK
717
+ tier = review_tier(files, plan_risk=risk)
718
+ reviewers = TIER_REVIEWERS.get(tier, 2)
719
+ print(f"TIER: {tier}")
720
+ print(f"REVIEWERS: {reviewers}")
721
+ return EXIT_OK
722
+
723
+
670
724
  def gate_docs_check(root: Path) -> int:
671
725
  """Report every typed .caddis Markdown document with an invalid header."""
672
726
  failures = 0
@@ -686,21 +740,24 @@ def gate_docs_check(root: Path) -> int:
686
740
  def main(argv: list[str] | None = None) -> int:
687
741
  ap = argparse.ArgumentParser(description="caddis machine gates")
688
742
  sub = ap.add_subparsers(dest="cmd", required=True)
689
- for name in ("lane-check", "verdict-gate"):
743
+ for name in ("lane-check",):
690
744
  s = sub.add_parser(name)
691
745
  s.add_argument("--plan", required=True)
692
746
  s.add_argument("--phase", type=int, required=True)
693
747
  t = sub.add_parser("tracker-vs-git")
694
748
  t.add_argument("--plan", required=True)
695
- h = sub.add_parser("hub-artifacts")
696
- h.add_argument("--reports", required=True)
697
- h.add_argument("--refs", nargs="*", default=[])
698
749
  pl = sub.add_parser("parking-lot")
699
750
  pl.add_argument("--repo-root", default=".")
700
751
  rt = sub.add_parser("review-trigger")
701
752
  rt.add_argument("--repo-root", default=".")
702
753
  rt.add_argument("--range", dest="rev_range", default=None,
703
754
  help="revision range, e.g. origin/main...HEAD (default: the working tree)")
755
+ rw = sub.add_parser("review-tier")
756
+ rw.add_argument("--repo-root", default=".")
757
+ rw.add_argument("--range", dest="rev_range", default=None,
758
+ help="revision range, e.g. origin/main...HEAD (default: the working tree)")
759
+ rw.add_argument("--plan", default=None, help="path to plan markdown")
760
+ rw.add_argument("--phase", default=None, help="phase number or heading")
704
761
  vd = sub.add_parser("vendor-drift")
705
762
  vd.add_argument("--repo-root", default=".")
706
763
  hc = sub.add_parser("handover-check")
@@ -722,14 +779,17 @@ def main(argv: list[str] | None = None) -> int:
722
779
  try:
723
780
  if a.cmd == "lane-check":
724
781
  return gate_lane(plan, a.phase)
725
- if a.cmd == "verdict-gate":
726
- return gate_verdict(plan, a.phase)
727
- if a.cmd == "hub-artifacts":
728
- return gate_hub_artifacts(Path(a.reports), [Path(r) for r in a.refs])
729
782
  if a.cmd == "parking-lot":
730
783
  return gate_parking_lot(Path(a.repo_root).resolve())
731
784
  if a.cmd == "review-trigger":
732
785
  return gate_review_trigger(Path(a.repo_root).resolve(), a.rev_range)
786
+ if a.cmd == "review-tier":
787
+ return gate_review_tier(
788
+ Path(a.repo_root).resolve(),
789
+ a.rev_range,
790
+ getattr(a, "plan", None),
791
+ getattr(a, "phase", None),
792
+ )
733
793
  if a.cmd == "vendor-drift":
734
794
  return gate_vendor_drift(Path(a.repo_root).resolve())
735
795
  if a.cmd == "handover-check":
@@ -20,7 +20,7 @@ stop-list below is the load-bearing part, and it is deliberately generous: a fal
20
20
  costs a human three seconds to ignore, while a false negative leaves a coined word undefined
21
21
  forever, which is the failure being fixed.
22
22
 
23
- ACCURACY, MEASURED. On a normal application repo it is good: run against `docket` it proposed
23
+ ACCURACY, MEASURED. On a normal application repo it is good: run against a task-board app it proposed
24
24
  `board`, `lane`, `card`, `gate` — that project's actual coined vocabulary, top of the list. On a
25
25
  HARNESS repo full of documentation ABOUT agents it is noisier, because words like `protocol` and
26
26
  `intent` are both ordinary English and genuinely overloaded here. It is a suggester, not an
@@ -107,8 +107,8 @@ MAX_SUGGESTIONS = 25
107
107
  # A coined term is specific, so in a LARGE corpus it shows up in a minority of files. Measured:
108
108
  # `description` appeared in 2112 of ~2500 caddis docs and outranked every real term.
109
109
  #
110
- # But this rule INVERTS on a small repo. Applied unconditionally it deleted docket's four best
111
- # candidates — `board`, `lane`, `card`, `docket` — because in a 50-file repo the core vocabulary
110
+ # But this rule INVERTS on a small repo. Applied unconditionally it deleted a task-board app's four best
111
+ # candidates — `board`, `lane`, `card` and the app's own name — because in a 50-file repo the core vocabulary
112
112
  # genuinely is in most files. So it only engages once there are enough files for "share" to
113
113
  # mean anything.
114
114
  MAX_FILE_SHARE = 0.25