@caddis/cli 0.4.51 → 0.4.53

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 (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +86 -2
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. 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}")
@@ -667,6 +547,151 @@ def gate_review_trigger(repo_root: Path, rev_range: str | None = None) -> int:
667
547
  return EXIT_NOTE
668
548
 
669
549
 
550
+ TIER_REVIEWERS = {
551
+ "0": 0,
552
+ "1-small": 0,
553
+ "1": 1,
554
+ "2": 2,
555
+ }
556
+
557
+
558
+ def is_test_file(path: str) -> bool:
559
+ norm = path.replace("\\", "/").lower()
560
+ if norm.startswith("./"):
561
+ norm = norm[2:]
562
+ segs = norm.split("/")
563
+ filename = segs[-1]
564
+ if any(s in ("tests", "test") for s in segs[:-1]):
565
+ return True
566
+ if filename.startswith("test_") and filename.endswith(".py"):
567
+ return True
568
+ if filename.endswith("_test.py"):
569
+ return True
570
+ for ext in (".test.ts", ".test.tsx", ".test.js", ".test.jsx"):
571
+ if filename.endswith(ext):
572
+ return True
573
+ return False
574
+
575
+
576
+ def is_tier0_file(path: str) -> bool:
577
+ norm = path.replace("\\", "/").lower()
578
+ if norm.startswith("./"):
579
+ norm = norm[2:]
580
+ if norm.startswith("docs/") or norm.startswith(".caddis/"):
581
+ return True
582
+ if "/" not in norm:
583
+ stem = norm.rsplit(".", 1)[0]
584
+ ext = norm.rsplit(".", 1)[1] if "." in norm else ""
585
+ if ext == "md" and (stem.startswith("readme") or stem.startswith("changelog")):
586
+ return True
587
+ if is_test_file(norm):
588
+ return True
589
+ return False
590
+
591
+
592
+ BINARY_LINES = 41 # a binary change has no line count: count it above the 40-line small limit, never "1-small"
593
+
594
+
595
+ def _rename_paths(path: str) -> list[str]:
596
+ """Both sides of a `--numstat` rename path (`old => new` or `pre/{old => new}/post`); [path] when not a rename."""
597
+ if " => " not in path:
598
+ return [path]
599
+ if "{" in path and "}" in path:
600
+ prefix, rest = path.split("{", 1)
601
+ inner, suffix = rest.split("}", 1)
602
+ old, new = inner.split(" => ", 1)
603
+ return [f"{prefix}{side}{suffix}".replace("//", "/").lstrip("/") for side in (old, new)]
604
+ old, new = path.split(" => ", 1)
605
+ return [old, new]
606
+
607
+
608
+ def _numstat(rng: str | None = None, repo_root: Path = Path(".")) -> list[tuple[str, int]]:
609
+ """(path, changed-lines) per file for an explicit range or the working tree.
610
+
611
+ A rename lists BOTH the old and the new path, so moving code into `docs/` or `tests/` is not tier 0. A binary
612
+ change counts `BINARY_LINES`. A failed `git diff` raises RuntimeError: the caller must not read it as an empty
613
+ diff (tier 0)."""
614
+ args = ["git", "-C", str(repo_root), "diff", "--numstat"]
615
+ if rng:
616
+ args.append(rng)
617
+ res = subprocess.run(args, capture_output=True, text=True, timeout=30)
618
+ if res.returncode != 0:
619
+ reason = (res.stderr or "").strip().splitlines()
620
+ raise RuntimeError(f"git diff --numstat exited {res.returncode}: {reason[0] if reason else 'no message'}")
621
+ files: list[tuple[str, int]] = []
622
+ for row in res.stdout.splitlines():
623
+ parts = row.split("\t")
624
+ if len(parts) != 3:
625
+ continue
626
+ add, dele, path = parts
627
+ if add == "-" or dele == "-":
628
+ line_count = BINARY_LINES
629
+ else:
630
+ line_count = (int(add) if add.isdigit() else 0) + (int(dele) if dele.isdigit() else 0)
631
+ paths = _rename_paths(path.strip())
632
+ if len(paths) == 2:
633
+ # A rename moves code out of its old path; a pure rename has 0 changed lines, so without this a move
634
+ # into tests/ would read as a small, covered change (tier 1-small, no review). The old path counts as large.
635
+ files.append((paths[0], max(line_count, BINARY_LINES)))
636
+ files.append((paths[1], max(line_count, BINARY_LINES))) # tests/x.py -> x.py moves test code into production
637
+ else:
638
+ for p in paths:
639
+ files.append((p, line_count))
640
+ return files
641
+
642
+
643
+ def review_tier(files: list[tuple[str, int]], plan_risk: str = "normal") -> str:
644
+ """Return '0', '1-small', '1' or '2' by the model routing review budget."""
645
+ non_tier0 = [(p, lines) for p, lines in files if not is_tier0_file(p)]
646
+ if not non_tier0:
647
+ return "0"
648
+ if str(plan_risk).strip().lower() == "high":
649
+ return "2"
650
+ non_tier0_paths = [p for p, _ in non_tier0]
651
+ non_tier0_lines = sum(lines for _, lines in non_tier0)
652
+ if review_triggers(non_tier0_paths, non_tier0_lines):
653
+ return "2"
654
+ has_test = any(is_test_file(p) for p, _ in files)
655
+ if non_tier0_lines <= 40 and has_test:
656
+ return "1-small"
657
+ return "1"
658
+
659
+
660
+ def gate_review_tier(
661
+ repo_root: Path,
662
+ rev_range: str | None = None,
663
+ plan: Path | str | None = None,
664
+ phase: int | str | None = None,
665
+ ) -> int:
666
+ """Print the tier and reviewer count for the diff and plan phase."""
667
+ risk = "normal"
668
+ if plan and phase is None:
669
+ risk = "high" # fail closed: a plan without a phase cannot give a risk
670
+ elif plan and phase is not None:
671
+ try:
672
+ try:
673
+ import caddis_review
674
+ except ModuleNotFoundError:
675
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
676
+ import caddis_review
677
+ risk = caddis_review.phase_risk(Path(plan), phase)
678
+ except Exception:
679
+ # Fail closed: a plan risk we cannot read counts as high (tier 2).
680
+ risk = "high"
681
+ try:
682
+ files = _numstat(rev_range, repo_root)
683
+ except Exception as exc: # noqa: BLE001 - a diff we cannot read is reviewed in full, never skipped
684
+ sys.stderr.write(f"caddis-gate: review-tier could not read the diff ({exc}); counting it as tier 2\n")
685
+ print("TIER: 2")
686
+ print("REVIEWERS: 2")
687
+ return EXIT_OK
688
+ tier = review_tier(files, plan_risk=risk)
689
+ reviewers = TIER_REVIEWERS.get(tier, 2)
690
+ print(f"TIER: {tier}")
691
+ print(f"REVIEWERS: {reviewers}")
692
+ return EXIT_OK
693
+
694
+
670
695
  def gate_docs_check(root: Path) -> int:
671
696
  """Report every typed .caddis Markdown document with an invalid header."""
672
697
  failures = 0
@@ -686,21 +711,24 @@ def gate_docs_check(root: Path) -> int:
686
711
  def main(argv: list[str] | None = None) -> int:
687
712
  ap = argparse.ArgumentParser(description="caddis machine gates")
688
713
  sub = ap.add_subparsers(dest="cmd", required=True)
689
- for name in ("lane-check", "verdict-gate"):
714
+ for name in ("lane-check",):
690
715
  s = sub.add_parser(name)
691
716
  s.add_argument("--plan", required=True)
692
717
  s.add_argument("--phase", type=int, required=True)
693
718
  t = sub.add_parser("tracker-vs-git")
694
719
  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
720
  pl = sub.add_parser("parking-lot")
699
721
  pl.add_argument("--repo-root", default=".")
700
722
  rt = sub.add_parser("review-trigger")
701
723
  rt.add_argument("--repo-root", default=".")
702
724
  rt.add_argument("--range", dest="rev_range", default=None,
703
725
  help="revision range, e.g. origin/main...HEAD (default: the working tree)")
726
+ rw = sub.add_parser("review-tier")
727
+ rw.add_argument("--repo-root", default=".")
728
+ rw.add_argument("--range", dest="rev_range", default=None,
729
+ help="revision range, e.g. origin/main...HEAD (default: the working tree)")
730
+ rw.add_argument("--plan", default=None, help="path to plan markdown")
731
+ rw.add_argument("--phase", default=None, help="phase number or heading")
704
732
  vd = sub.add_parser("vendor-drift")
705
733
  vd.add_argument("--repo-root", default=".")
706
734
  hc = sub.add_parser("handover-check")
@@ -722,14 +750,17 @@ def main(argv: list[str] | None = None) -> int:
722
750
  try:
723
751
  if a.cmd == "lane-check":
724
752
  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
753
  if a.cmd == "parking-lot":
730
754
  return gate_parking_lot(Path(a.repo_root).resolve())
731
755
  if a.cmd == "review-trigger":
732
756
  return gate_review_trigger(Path(a.repo_root).resolve(), a.rev_range)
757
+ if a.cmd == "review-tier":
758
+ return gate_review_tier(
759
+ Path(a.repo_root).resolve(),
760
+ a.rev_range,
761
+ getattr(a, "plan", None),
762
+ getattr(a, "phase", None),
763
+ )
733
764
  if a.cmd == "vendor-drift":
734
765
  return gate_vendor_drift(Path(a.repo_root).resolve())
735
766
  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
@@ -426,13 +426,46 @@ def close_job(job: int) -> None:
426
426
 
427
427
  # ── other platforms ─────────────────────────────────────────────────────────
428
428
 
429
- def posix_argv(argv: list[str], limits: JobLimits, which=shutil.which, cpu_count=os.cpu_count) -> list[str] | None:
429
+ _SYSTEMD_USER_OK: bool | None = None
430
+
431
+
432
+ def systemd_user_usable(run=subprocess.run) -> bool:
433
+ """True when `systemd-run --user --scope` can actually start a scope here.
434
+
435
+ The binary can exist with no user D-Bus session behind it: a claude.ai cloud container on
436
+ 2026-10-11 had systemd-run on PATH and every call exited 1 with "Failed to connect to bus: No
437
+ medium found". Treating "on PATH" as "works" then made every governed lane fail without
438
+ running. Probe once with a harmless `true`, using the same property kinds as the real call (an
439
+ old systemd can reject `MemoryMax`); any failure means the cap does not exist."""
440
+ try:
441
+ done = run(["systemd-run", "--user", "--scope", "--quiet",
442
+ "-p", "CPUQuota=50%", "-p", "MemoryMax=512M", "-p", "TasksMax=64", "--", "true"],
443
+ capture_output=True, text=True, timeout=15)
444
+ except (OSError, subprocess.SubprocessError):
445
+ return False
446
+ return done.returncode == 0
447
+
448
+
449
+ def _systemd_user_usable_cached() -> bool:
450
+ global _SYSTEMD_USER_OK
451
+ if _SYSTEMD_USER_OK is None:
452
+ _SYSTEMD_USER_OK = systemd_user_usable()
453
+ return _SYSTEMD_USER_OK
454
+
455
+
456
+ def posix_argv(argv: list[str], limits: JobLimits, which=shutil.which, cpu_count=os.cpu_count,
457
+ usable=None) -> list[str] | None:
430
458
  """systemd user scope limits. Weaker than a Job Object: user scopes do not nest (a lane scope is
431
459
  a sibling of its session scope), an agent can call `systemd-run --user` itself, and CPUQuota is
432
460
  silently ignored when the cpu controller is not delegated to the user manager. TasksMax counts
433
- THREADS, not processes, so on Linux `max_processes` is a thread limit."""
461
+ THREADS, not processes, so on Linux `max_processes` is a thread limit.
462
+
463
+ None when systemd-run is missing OR present but unusable (`usable`, default: one cached probe);
464
+ the caller then refuses unless --allow-uncapped, exactly as for a missing binary."""
434
465
  if not which("systemd-run"):
435
466
  return None
467
+ if not (usable or _systemd_user_usable_cached)():
468
+ return None
436
469
  return ["systemd-run", "--user", "--scope", "--quiet",
437
470
  "-p", f"CPUQuota={limits.cpu_percent * (cpu_count() or 1)}%",
438
471
  "-p", f"MemoryMax={limits.memory_mb}M",
@@ -456,6 +489,17 @@ def _run_child(argv: list[str], env: dict) -> int:
456
489
  return 130
457
490
 
458
491
 
492
+ def cap_available() -> bool:
493
+ """True when this host can cap a governed child: a Job Object on native Windows, a usable
494
+ systemd user scope on posix. Tests that launch a real governed lane skip when this is False."""
495
+ kind = _host_kind()
496
+ if kind == "windows":
497
+ return True
498
+ if kind == "non-native-windows":
499
+ return False
500
+ return posix_argv(["true"], JobLimits(100, 1024, 64)) is not None
501
+
502
+
459
503
  def _host_kind() -> str:
460
504
  """Return "windows" (native Python), "non-native-windows" (MSYS2/Cygwin on Windows) or "posix"."""
461
505
  if sys.platform in ("cygwin", "msys") or (os.name == "nt" and sys.platform != "win32"):
@@ -502,7 +546,7 @@ def exec_governed(argv: list[str], scope: str, budget: Path | None = None,
502
546
  wrapped = posix_argv(argv, limits)
503
547
  if wrapped is None:
504
548
  if not allow_uncapped:
505
- raise GovernorError("no systemd-run on this platform, so no cap can be applied; "
549
+ raise GovernorError("no usable systemd-run on this platform (missing, or `systemd-run --user` cannot reach a user D-Bus session, as in many containers), so no cap can be applied; "
506
550
  "pass --allow-uncapped to run at lower priority only")
507
551
  try:
508
552
  os.nice(10)
@@ -534,7 +578,7 @@ def main(argv: list[str] | None = None) -> int:
534
578
  p_exec.add_argument("--machine-budget", type=Path, help=argparse.SUPPRESS) # additional ceiling only
535
579
  p_exec.add_argument("--job-name", help=argparse.SUPPRESS)
536
580
  p_exec.add_argument("--allow-uncapped", action="store_true",
537
- help="where no cap can be applied (no systemd-run, macOS), run at lower priority anyway")
581
+ help="where no cap can be applied (systemd-run missing or unusable, macOS), run at lower priority anyway")
538
582
  p_show = sub.add_parser("show", help="print the effective limits")
539
583
  p_show.add_argument("--budget", type=Path)
540
584
  p_show.add_argument("--machine-budget", type=Path, help=argparse.SUPPRESS)