@caddis/cli 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ # Pipeline Glossary
2
+
3
+ > **Shipped as `CADDIS-GLOSSARY.md`.** This defines CADDIS's own vocabulary — the words the
4
+ > toolchain coined, which a user meets constantly and cannot guess from the plain word. Your
5
+ > project's own terms belong in `.caddis/kb/GLOSSARY.md` instead; run `/caddis:glossary` to
6
+ > build one. Two files, two subjects, deliberately different names.
7
+
8
+ Canonical terms used across all agents, skills, and pipeline infrastructure.
9
+ When writing agent instructions, skills, or documentation, use ONLY these terms.
10
+
11
+ Two vocabularies live here and they are NOT interchangeable. **Core Terms** onward describe the
12
+ agent PIPELINE (orchestrator, stages, `pipeline-state.json`). **Toolchain Terms** describe caddis
13
+ as a distributed product (pool, mirror, export target). A word can be canonical in one and absent
14
+ from the other; where they genuinely collide, the collision is named under Flagged ambiguities
15
+ rather than resolved by fiat.
16
+
17
+ Admission test, borrowed from `last30days-skill`'s CONCEPTS.md: a term belongs here when its
18
+ project meaning is **distinct enough from its ordinary technical sense that a newcomer would
19
+ misread it**. `commit` does not belong. `pool` does.
20
+
21
+ ---
22
+
23
+ ## Core Terms
24
+
25
+ | Canonical Term | Definition | DO NOT USE |
26
+ |---------------|------------|------------|
27
+ | artefact | Any file produced by an agent as pipeline output. This is the correct spelling in all contexts — field names, prose, section headers. | artifact, deliverable, output file |
28
+ | stage | A pipeline-level progression step (intent, prd, architect, plan, implement, tester, review, closed) | phase (for pipeline steps), step |
29
+ | phase | A subdivision of work within a Plan (Phase 1, Phase 2…). Phases exist WITHIN the implement stage. | stage (for plan subdivisions), step |
30
+ | chain_id | Feature tracking ID format: `FEAT-YYYY-MMDD-slug`. Links all artefacts for a feature. | feature_id, tracking_id |
31
+ | handoff | Transfer of work from one agent to another via Orchestrator | routing (for the transfer act), delegation, dispatch, hand-off |
32
+ | handoff payload | The `_notes.handoff_payload` object written by Orchestrator via `update_notes` MCP tool before routing to a specialist | routing context, task context |
33
+ | evidence bundle | Anchor's structured proof-of-work document (`agent-docs/anchor-evidence-*.md`) | proof, verification report |
34
+ | gate | A supervision checkpoint requiring approval before pipeline advances (`supervision_gates.*`) | checkpoint, approval point |
35
+ | HARD STOP | Absolute refusal to proceed — security violation, out-of-scope request, or rogue state edit | halt, block, refuse |
36
+ | skill | A reusable knowledge pack in `.github/skills/{category}/{name}/SKILL.md` | SKILL (when referring to the concept, not the filename) |
37
+ | prompt | A reusable prompt file in `.github/prompts/*.prompt.md` | skill (when referring to prompt files — prompts are not skills) |
38
+ | onboarding prompt | The project bootstrap prompt at `.github/prompts/onboarding.prompt.md` | onboarding skill |
39
+
40
+ ## Pipeline Modes
41
+
42
+ | Term | Definition |
43
+ |------|------------|
44
+ | supervised | All gates require manual user approval. Default mode. |
45
+ | assisted | Manual gates with AI guidance hints. Auto-routing between stages. |
46
+ | autopilot | All gates auto-satisfied except `intent_approved`. Fully hands-free after intent approval. |
47
+
48
+ ## Result Statuses
49
+
50
+ ### MCP `notify_orchestrator` — `result_status` parameter
51
+
52
+ | Value | Meaning | Used By |
53
+ |-------|---------|---------|
54
+ | `complete` | Standard stage completion | All agents |
55
+ | `phase_complete` | Multi-phase loop — implement stays in implement for next phase | Implement agent |
56
+ | `recovered` | Recovery after guard failure | Orchestrator (debug flow) |
57
+
58
+ ### Agent-Specific Result Fields
59
+
60
+ | Agent | Field | Possible Values | Meaning |
61
+ |-------|-------|-----------------|---------|
62
+ | Tester | `tester_result.status` | `passed`, `failed` | All tests pass / any test fails |
63
+ | Code Reviewer | `Verdict` | `approved`, `revision-requested` | Code meets standards / needs changes |
64
+ | All agents (artefact YAML header) | `approval` | `approved`, `pending`, `revision-requested` | Artefact acceptance status |
65
+
66
+ ### Orchestrator Artefact Validation Guards
67
+
68
+ | Guard | Checks |
69
+ |-------|--------|
70
+ | `artefact_exists` | File at `artefact_path` exists on disk |
71
+ | `artefact_approved` | YAML header `approval:` field is `approved` |
72
+ | `all_phases_done` | All implementation phases complete |
73
+
74
+ ## File Naming Conventions
75
+
76
+ | Pattern | Example | Usage |
77
+ |---------|---------|-------|
78
+ | Agent file | `frontend-developer.agent.md` | Lowercase kebab-case matching `name` frontmatter field |
79
+ | Skill file | `.github/skills/{category}/{name}/SKILL.md` | Category folder + name folder + SKILL.md |
80
+ | Plan file | `.github/plans/{feature-slug}.md` | Feature slug from chain_id |
81
+ | Evidence file | `agent-docs/anchor-evidence-{feature}.md` | Per-feature evidence bundle |
82
+ | Artefact registry | `agent-docs/ARTIFACTS.md` | Single registry for all pipeline artefacts |
83
+ | Pipeline state | `.github/pipeline-state.json` | Live pipeline state — written ONLY by MCP tools |
84
+
85
+ ## Pipeline State Field Ownership
86
+
87
+ All writes to `pipeline-state.json` go through MCP tools. Only `stages[*].status`, `stages[*].artefact`, `stages[*].completed_at`, and `blocked_by` may be set via `editFiles`.
88
+
89
+ | Field | Writer | Tool |
90
+ |-------|--------|------|
91
+ | `current_stage` | Pipeline runner | `notify_orchestrator` |
92
+ | `supervision_gates[*]` | Orchestrator | `satisfy_gate` |
93
+ | `pipeline_mode` | Orchestrator | `set_pipeline_mode` |
94
+ | `_notes.*` | Orchestrator / agents | `update_notes` |
95
+ | `project`, `feature`, `type` | Orchestrator | `pipeline_init` / `pipeline_reset` |
96
+
97
+ ## Common Conflations to Avoid
98
+
99
+ | Wrong | Correct | Why |
100
+ |-------|---------|-----|
101
+ | "Phase 3 of the pipeline" | "The implement stage" | Phases are within a Plan; stages are pipeline-level |
102
+ | "The artifact registry" | "The artefact registry" | British spelling is canonical (matches all field names) |
103
+ | "Run the onboarding skill" | "Run the onboarding prompt" | `.prompt.md` files are prompts, not skills |
104
+ | "Route to the agent" | "Hand off to the agent" | Routing is Orchestrator's internal logic; handoff is the transfer act |
105
+ | `[Stage/Phase N]` | `[Phase N]` (within Plan) or `[Stage: implement]` (pipeline-level) | Don't conflate the two with a slash |
106
+
107
+ ---
108
+
109
+ ## Toolchain Terms
110
+
111
+ caddis-the-product, as opposed to the agent pipeline above. These are the words caddis invented
112
+ out of ordinary English, which is exactly why they need defining: a reader who guesses from the
113
+ plain word gets them wrong. Usage counts were measured across 444 markdown files on 2026-08-29.
114
+
115
+ | Canonical Term | Definition | DO NOT USE |
116
+ |---|---|---|
117
+ | pool | The single source of every shipped resource, living in `.github/` (skills, agents, commands, prompts). Everything a user installs is EXPORTED from the pool; nothing is authored in a bundle. Versioned independently of the CLI, and bumped only by `caddis-push` — never by hand. *(222 uses)* | bundle (for the source), library |
118
+ | bundle | One EXPORTED copy of the pool, shaped for a single target — `dist/runtime-resources/<target>/`. A bundle is generated output; editing one is always a mistake. | pool (for a built copy) |
119
+ | export target | A named consumer in `.github/runtime-targets.json` — claude, antigravity, antigravity-plugin, codex, copilot, plus `-extras` variants. Each takes a different subset and layout from the same pool. | platform, host (for a target) |
120
+ | mirror | The public repo `saajunaid/caddis-plugin`, refreshed by `caddis-push`. The SOURCE repo is private, so the mirror is what any other machine can actually reach. | upstream, remote |
121
+ | relay | `.caddis/relay.md` — the durable resume doc, rewritten by `/caddis:handoff` and injected at SessionStart. Machine-local and gitignored. Only changes when someone runs a handoff, so it is stale between them by design. *(168 uses)* | handoff doc, session notes |
122
+ | session state | `.caddis/session-state.md` — auto-captured by the `Stop` hook at the end of EVERY turn. Distinct from the relay: current without anyone deciding to make it current, and it holds no agreed next step. | relay (they are different files with different guarantees) |
123
+ | drift | A gap between what a consumer HAS installed and what the pool currently ships. `caddis status` reports it per agent. Not code drift, not schema drift. *(160 uses)* | staleness, lag |
124
+ | live-fire | Driving a real binary end to end rather than asserting against a manifest. Coined because three defects in one month passed every test and still gave a human a broken install: the tests asked "does the export match the manifest", never "would this work". An export target is not trusted until it has been live-fired once. | smoke test, integration test, manual test |
125
+ | parking-lot | `.caddis/parking-lot/` — the ONE register of future work. It exists because future work previously scattered to nine places and no session could answer "what is left". Items carry `severity` and `status`; the backlog IS the plan when no plan is active. *(41 uses)* | backlog file, TODO list, icebox |
126
+ | model lane | A named provider route reached by its own binary — `claude-oss`, `claude-glm`, `claude-deepseek` — all speaking the Anthropic protocol. A lane is a route, not a model. | provider, backend, endpoint |
127
+ | core / extras | The two shipping tiers. Core is an EXPLICIT allowlist in `.github/runtime-targets.json`; anything not named there lands in extras. A skill defaults to extras, so placement is a decision, never an accident. | tier 1 / tier 2, full / lite |
128
+ | harness | The agent runtime that loads caddis — Claude Code, agy, Codex, Copilot. caddis is agent-agnostic; "the harness" without a name means whichever one is running. *(135 uses)* | client, IDE, platform |
129
+
130
+ ## Flagged ambiguities
131
+
132
+ Named rather than resolved. Pretending the vocabulary is clean is how a glossary starts lying.
133
+
134
+ - **`gate` is overloaded, badly.** Core Terms defines it as a supervision checkpoint requiring
135
+ approval. That is one of at least twelve senses in live use: *exit gate* (20), *quality gate*
136
+ (9), *evidence gate* (5), *decision gate* (3), plus pre-publish, handover and privacy gates —
137
+ none of which involve pipeline supervision. The word appears 1019 times across 168 files.
138
+ **Prefer the full two-word term everywhere**; a bare "gate" is ambiguous and the reader cannot
139
+ tell which one you meant.
140
+
141
+ - **`artefact` vs `artifact`.** Core Terms makes `artefact` canonical, and for pipeline outputs it
142
+ is. But the toolchain's own code says `artifact_root` and "artifact dir" (17 uses in
143
+ `scripts/setup_project_ai.py` alone), and those are API names that cannot be respelled without
144
+ a breaking change. So: **artefact** in pipeline prose and field names; **artifact dir /
145
+ `artifact_root`** when naming the `.caddis/` directory mechanism. Do not "fix" one into the other.
146
+
147
+ - **`skill` means two things across harnesses.** In the pool it is
148
+ `.github/skills/<category>/<name>/SKILL.md`. But agy CONVERTS commands into skills at install
149
+ time, so `/caddis:catchup` ships as a command and arrives as an agy skill. A skill count that
150
+ disagrees between two agents is usually this, not a bug.
@@ -292,6 +292,42 @@ def ram_facts():
292
292
  return None
293
293
 
294
294
 
295
+ def record_context(cwd: str, pct: float) -> None:
296
+ """Cache the runtime context figure for tools that never see the statusline payload.
297
+
298
+ Claude Code hands `context_window.used_percentage` to the statusline and to NOTHING else.
299
+ `/caddis:spawn-session` must refuse above 95% (008 section 7a) and runs as a plain bash
300
+ command, so without this it would have to ask the model to estimate its own usage — which
301
+ is the one thing that section forbids, because the faculty being gated is the faculty
302
+ doing the reporting.
303
+
304
+ Only writes when the value CHANGED, so an idle statusline does not rewrite a file on every
305
+ render. Only writes where `.caddis/` already exists, so cd-ing into an unrelated folder
306
+ never scatters artifact dirs. Fail-open and silent: a status line must never break a
307
+ prompt, and this is the least important thing it does.
308
+ """
309
+ try:
310
+ if not cwd:
311
+ return
312
+ art = os.path.join(cwd, ".caddis")
313
+ if not os.path.isdir(art):
314
+ return
315
+ target = os.path.join(art, "context-window.json")
316
+ payload = {"used_percentage": round(float(pct), 1), "at": int(time.time())}
317
+ try:
318
+ with open(target, encoding="utf-8") as fh:
319
+ if json.load(fh).get("used_percentage") == payload["used_percentage"]:
320
+ return
321
+ except Exception:
322
+ pass
323
+ tmp = target + ".tmp"
324
+ with open(tmp, "w", encoding="utf-8") as fh:
325
+ json.dump(payload, fh)
326
+ os.replace(tmp, target)
327
+ except Exception:
328
+ pass
329
+
330
+
295
331
  def relay_present(cwd: str) -> bool:
296
332
  """A parked resume pointer exists for this repo. `.caddis/relay.md` is canonical;
297
333
  a bare `relay.md` at the root is the pre-rename legacy location."""
@@ -428,6 +464,7 @@ def render_claude(data: dict, cfg: dict) -> str:
428
464
  used = as_int(cw.get("total_input_tokens"))
429
465
  max_tokens = as_int(cw.get("context_window_size"))
430
466
  pct = float(as_int(cw.get("used_percentage")))
467
+ record_context(cwd, pct) # feeds /caddis:spawn-session's 95% gate; see record_context
431
468
  parts.append(context_segment(c, cfg, used, max_tokens, pct))
432
469
  loud = cached_segment(c, cfg, cached)
433
470
  if loud:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.90",
3
+ "version": "1.3.93",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -31,9 +31,12 @@ Usage:
31
31
  from __future__ import annotations
32
32
 
33
33
  import argparse
34
+ import json
34
35
  import re
36
+ import os
35
37
  import subprocess
36
38
  import sys
39
+ import time
37
40
  from dataclasses import dataclass, field
38
41
  from pathlib import Path
39
42
 
@@ -101,9 +104,37 @@ class DocFindings:
101
104
  stale_prone: list[tuple[int, str]] = field(default_factory=list)
102
105
  historical: list[tuple[int, str]] = field(default_factory=list)
103
106
  missing_paths: list[str] = field(default_factory=list)
107
+ # A THIRD refusing tier, kept separate from stale_prone so the message can say something
108
+ # different. A stale hash is an accident the successor will notice; a stored answer key
109
+ # defeats the exercise SILENTLY — it certifies a reader who understood nothing.
110
+ answer_key: list[tuple[int, str]] = field(default_factory=list)
104
111
 
105
112
  def ok(self) -> bool:
106
- return not self.stale_prone and not self.missing_paths
113
+ return not self.stale_prone and not self.missing_paths and not self.answer_key
114
+
115
+
116
+ # ── answer-key detection ────────────────────────────────────────────────────────────────────
117
+ # 008 §2: "store no answer key" was ALREADY written down, in 007 §5 — and the session that had
118
+ # that rule available wrote one anyway, into the same file the successor reads, inside a
119
+ # <details> block, as though that were a lock. A model reads the whole file, so the questionnaire
120
+ # tested nothing: the successor could answer perfectly having understood none of it.
121
+ #
122
+ # The lesson is not "say it louder in the doc". A rule and an artefact that contradict each other
123
+ # do not fail loudly — the artefact wins silently, because the artefact is what gets read. So the
124
+ # check lives HERE, in the tooling, and it REFUSES rather than warns.
125
+ #
126
+ # Deliberately STRUCTURAL rather than semantic. A check that tries to judge whether prose reveals
127
+ # an answer is wrong in both directions, and a check that cries wolf gets skimmed — which is how
128
+ # the hash tier earned its two-tier split above.
129
+ _DETAILS = re.compile(r"<details" + r"\b", re.I)
130
+ _ANSWER_KEY_HEADING = re.compile(
131
+ r"^\s*(?:#{1,6}\s*|\*\*|<summary>)?\s*"
132
+ r"(?:answer\s*key|expected\s+answers?|correct\s+answers?|model\s+answers?|"
133
+ r"answers?\s*[-—]\s*for\s+the)",
134
+ re.I,
135
+ )
136
+ # `Q3: ... A: ...` — a question and its answer on one line.
137
+ _INLINE_ANSWER = re.compile(r"\bQ\s*\d{1,2}\b.*?\b(?:A|Ans|Answer)\s*[:.\)]", re.I)
107
138
 
108
139
 
109
140
  _DOC_PATH = re.compile(r"`([^`\s<>{}*?]+?\.(?:md|py|ts|tsx|json|ya?ml|sh|ps1|html|toml|sql))`")
@@ -124,6 +155,21 @@ def check_document(text: str, repo_root: Path | None = None) -> DocFindings:
124
155
  if line.lstrip().startswith("```"):
125
156
  in_fence = not in_fence
126
157
  continue
158
+ # These three run BEFORE the fence exemption and are not subject to it. A fence exempts
159
+ # a hash because quoting one there is the cure; an answer key inside a fence is still an
160
+ # answer key, and a template showing `Q1: ... A: ...` is precisely the shape that teaches
161
+ # the next author to fill it in.
162
+ if _DETAILS.search(line):
163
+ out.answer_key.append((n, "<details> block — a rendering hint, not a lock. The child "
164
+ "reads the whole file. Move whatever it hides out of any "
165
+ "document the successor is told to open"))
166
+ if _ANSWER_KEY_HEADING.search(line):
167
+ out.answer_key.append((n, "reads as an answer key. It must not exist in a file the "
168
+ "examinee can reach — re-derive each answer from the repo "
169
+ "at validation time instead"))
170
+ if _INLINE_ANSWER.search(line):
171
+ out.answer_key.append((n, "a question and its answer on one line. The questions may "
172
+ "ship; the answers may not"))
127
173
  if _is_exempt(line, in_fence):
128
174
  continue
129
175
  for m in _HASH.finditer(line):
@@ -155,23 +201,72 @@ def check_document(text: str, repo_root: Path | None = None) -> DocFindings:
155
201
 
156
202
  # ── refusals ────────────────────────────────────────────────────────────────────────────────
157
203
 
204
+ # ── the context gate (008 section 7a) ───────────────────────────────────────────────────────
205
+ # REFUSE above 95%, do not warn. The thing that degrades first is exactly the thing this command
206
+ # depends on: 007 section 1 names the failure as confident recall of SUPERSEDED facts, not
207
+ # forgetting. A handover written from a nearly-full context is written by the failing faculty, and
208
+ # it comes out fluent, cited and wrong. A warning does not help, because the agent that most needs
209
+ # to heed it is the one least able to judge that it should.
210
+ #
211
+ # The costs are not symmetric either. Refusing early costs one session's remaining headroom.
212
+ # A confident wrong handover costs the successor's whole session plus everything built on the
213
+ # error, and is not discovered until much later, if at all.
214
+ CONTEXT_REFUSE_ABOVE = 95.0
215
+ CONTEXT_NOTE_ABOVE = 85.0
216
+ # Generous on purpose. The cached figure can only be a LOWER bound — context goes up, never down —
217
+ # so an old reading that already says "over 95" is still a valid refusal, and one that says 90 may
218
+ # really be higher. Staleness is reported rather than silently trusted.
219
+ CONTEXT_MAX_AGE_S = 900
220
+
221
+
222
+ def read_context_pct(repo_root: Path) -> tuple[float | None, str]:
223
+ """(percentage, provenance) from the statusline's cache, or (None, why not).
224
+
225
+ Written by `caddis_statusline.record_context`. Claude Code gives the figure to the status
226
+ line and to nothing else, so this file is the only runtime-sourced number a plain command
227
+ can reach.
228
+ """
229
+ target = repo_root / ART / "context-window.json"
230
+ try:
231
+ raw = json.loads(target.read_text(encoding="utf-8"))
232
+ pct = float(raw["used_percentage"])
233
+ age = max(0, int(time.time()) - int(raw.get("at", 0)))
234
+ except Exception:
235
+ return None, ("no runtime context figure at "
236
+ + f"{ART}/context-window.json — run `/caddis:statusline` to install the "
237
+ "status line that writes it")
238
+ if age > CONTEXT_MAX_AGE_S:
239
+ return pct, f"runtime figure, STALE by {age}s (a lower bound — the real number is >= this)"
240
+ return pct, f"runtime figure, {age}s old"
241
+
242
+
158
243
  @dataclass
159
244
  class Preflight:
160
245
  refusals: list[str] = field(default_factory=list)
161
246
  dirty: int = 0
162
247
  parking_open: int | None = None
248
+ # The context figure and where it came from. Provenance is carried, not just the number,
249
+ # because a self-reported figure and a runtime one are not the same evidence and the
250
+ # handover has to say which it had (008 section 7a: the 85-95 band must state the figure).
251
+ context_pct: float | None = None
252
+ context_source: str = ""
253
+ notes: list[str] = field(default_factory=list)
163
254
 
164
255
  def ok(self) -> bool:
165
256
  return not self.refusals
166
257
 
167
258
 
168
- def preflight(repo_root: Path) -> Preflight:
259
+ def preflight(repo_root: Path, context_pct: float | None = None) -> Preflight:
169
260
  """Conditions under which a spawn must not happen at all.
170
261
 
171
262
  A dirty tree is the sharp one: the successor starts by pulling, so **uncommitted work is
172
263
  invisible to it**. It will then either redo that work or build on a state that does not exist.
264
+
265
+ `context_pct` is the caller's own reading, for machines with no caddis status line — see
266
+ _gate_context for why it can only ever tighten the gate.
173
267
  """
174
268
  p = Preflight()
269
+ _gate_context(p, repo_root, context_pct)
175
270
  status = _run(["git", "status", "--short"], repo_root)
176
271
  p.dirty = len([l for l in status.splitlines() if l.strip()])
177
272
  if p.dirty:
@@ -191,6 +286,58 @@ def preflight(repo_root: Path) -> Preflight:
191
286
  return p
192
287
 
193
288
 
289
+ def _gate_context(p: Preflight, repo_root: Path, self_reported: float | None) -> None:
290
+ """008 section 7a: refuse above 95%, note between 85 and 95, and never silently proceed.
291
+
292
+ Two sources, and the rule between them is the whole design:
293
+
294
+ * the status line's cache — runtime-sourced, and the only such number a plain command can
295
+ reach, because Claude Code hands `used_percentage` to the status line and nothing else;
296
+ * `--context-pct`, the caller's own reading, for a machine with no caddis status line.
297
+
298
+ **A self-reported figure may only TIGHTEN the gate, never loosen it** — so the two are
299
+ combined with max(). That is what makes the fallback safe rather than theatre. 007 section 1
300
+ names the failure as confident recall by a degraded context; a self-report that can talk its
301
+ way THROUGH the gate is exactly that failure wearing the gate's clothes, while one that can
302
+ only trip it earlier cannot be gamed into a pass.
303
+
304
+ With NO figure from either source it refuses, per 008: "an unmeasurable gate that defaults to
305
+ proceed is not a gate". But it refuses with two ways out, because a gate that cannot be
306
+ satisfied on a machine without the status line would simply be switched off.
307
+ """
308
+ pct, source = read_context_pct(repo_root)
309
+ if self_reported is not None:
310
+ if pct is None or self_reported > pct:
311
+ pct = self_reported
312
+ source = "SELF-REPORTED via --context-pct, not verified against the runtime"
313
+ else:
314
+ source = source + f"; --context-pct said {self_reported:.0f}%, kept the higher figure"
315
+ p.context_pct, p.context_source = pct, source
316
+
317
+ if pct is None:
318
+ p.refusals.append(
319
+ "no context figure, so the 95% gate cannot be applied — and a gate that defaults to "
320
+ "proceed is not a gate. Either run `/caddis:statusline` (its status line caches the "
321
+ "runtime figure for this repo), or pass `--context-pct <n>` with the number your "
322
+ "harness reports. A self-reported figure is accepted and recorded as such.")
323
+ return
324
+ if pct > CONTEXT_REFUSE_ABOVE:
325
+ p.refusals.append(
326
+ f"context is {pct:.0f}% ({source}). A handover written from here would be fluent and "
327
+ "unreliable, which is the failure this command exists to prevent. Do this instead, "
328
+ "in order: (1) commit and push everything now; (2) write ONLY the task list to "
329
+ f"{ART}/parent-session-state.md, the smallest durable artefact and the one a task "
330
+ "widget loses; (3) TELL the product owner which capture steps you skipped; (4) let "
331
+ "the successor rebuild the relay from the repo, which beats this session's memory of "
332
+ "it anyway. A partial handover that SAYS it is partial is worth far more than a "
333
+ "complete-looking one nobody can trust.")
334
+ return
335
+ if pct > CONTEXT_NOTE_ABOVE:
336
+ p.notes.append(
337
+ f"context is {pct:.0f}% ({source}). Under the refusal line, but STATE THIS FIGURE in "
338
+ "the handover so the reader can weigh what it says.")
339
+
340
+
194
341
  # ── fingerprint ─────────────────────────────────────────────────────────────────────────────
195
342
 
196
343
  def fingerprint(repo_root: Path, with_tests: bool = False) -> dict:
@@ -240,6 +387,205 @@ def verify_answerable(repo_root: Path, answer_in: str, needle: str) -> tuple[boo
240
387
  return True, f"{answer_in} contains {needle!r} and is committed"
241
388
 
242
389
 
390
+ # ── the capture order (008 section 4) ──────────────────────────────────────────────────────
391
+ # Knowledge -> durable state -> task list -> relay -> questions. Out of sequence it produces a
392
+ # handover that describes a state that never existed: the relay CITES the first three, so writing
393
+ # it earlier cites things that have since moved, and questions written first test the outgoing
394
+ # agent's MEMORY, which is the thing under suspicion.
395
+ #
396
+ # mtimes are the evidence. This does not check that each step was done well — nothing can — it
397
+ # checks the one property that is mechanically knowable and that a tired session gets wrong.
398
+ PARENT_RELAY = "parent-relay.md"
399
+ PARENT_STATE = "parent-session-state.md"
400
+
401
+
402
+ def _mtime(path: Path) -> float | None:
403
+ try:
404
+ return path.stat().st_mtime
405
+ except OSError:
406
+ return None
407
+
408
+
409
+ def capture_order(repo_root: Path, spawn_id: str) -> tuple[list[str], list[str]]:
410
+ """(refusals, notes) on the ORDER the handover artefacts were written in.
411
+
412
+ Required: the relay and the prompt. The task list is advisory, because a session can
413
+ legitimately end with nothing on it — but a MISSING step must be declared, not inferred,
414
+ which is why it is a note rather than silence.
415
+ """
416
+ refusals: list[str] = []
417
+ notes: list[str] = []
418
+ art = repo_root / ART
419
+ relay, state = art / PARENT_RELAY, art / PARENT_STATE
420
+ prompt = repo_root / SPAWN_DIR / (spawn_id + "-prompt.md")
421
+
422
+ t_relay, t_state, t_prompt = _mtime(relay), _mtime(state), _mtime(prompt)
423
+ if t_relay is None:
424
+ refusals.append(f"no {ART}/{PARENT_RELAY} — step 4 of the capture order never happened")
425
+ if t_prompt is None:
426
+ refusals.append(f"no {SPAWN_DIR}/{spawn_id}-prompt.md — step 5 never happened")
427
+ if refusals:
428
+ return refusals, notes
429
+
430
+ if t_state is None:
431
+ notes.append(f"no {ART}/{PARENT_STATE}. A task widget does not survive a `/clear` and this "
432
+ "project has already lost one — if the list is genuinely empty, SAY so in the "
433
+ "handover rather than leaving the reader to infer it.")
434
+ elif t_state > t_relay:
435
+ refusals.append(f"{PARENT_STATE} is NEWER than {PARENT_RELAY}. The relay cites the task "
436
+ "list, so a list written afterwards means the relay describes a state "
437
+ "that never existed. Rewrite the relay, do not re-touch the list.")
438
+
439
+ if t_relay > t_prompt:
440
+ refusals.append(f"{PARENT_RELAY} is NEWER than the prompt. The questions must target what "
441
+ "the relay actually says — derived from a relay that has since changed, "
442
+ "they test your memory of it, which is the thing under suspicion.")
443
+
444
+ # Anything the relay quotes that moved after it was written.
445
+ for sub_dir in ("kb", "parking-lot"):
446
+ d = art / sub_dir
447
+ if not d.is_dir():
448
+ continue
449
+ moved = sorted(f.name for f in d.glob("*.md") if (_mtime(f) or 0) > t_relay)
450
+ if moved:
451
+ notes.append(f"{sub_dir}/ changed AFTER the relay was written: "
452
+ + ", ".join(moved[:5]) + ("" if len(moved) <= 5 else ", ...")
453
+ + ". The relay quotes these, so re-read them before issuing it.")
454
+ return refusals, notes
455
+
456
+
457
+ # ── the handshake (008 section 3) ───────────────────────────────────────────────────────────
458
+ # 007 routed every round trip through a human paste. Sessions can message each other now, so the
459
+ # human is an approver rather than a transport — but only where a transport exists.
460
+ #
461
+ # THE CHILD INITIATES. It cannot message until it has answers, so the message itself is evidence
462
+ # it did the reading. But child-initiated ALONE leaves the worst failure invisible: a successor
463
+ # that skips the gate and just starts working produces silence, and silence is indistinguishable
464
+ # from "still reading". So the parent holds a timeout and CHASES.
465
+ #
466
+ # None of that can be enforced by prose. Section 2 of 008 is the proof: a rule written in a
467
+ # document lost to the artefact that contradicted it, within a day, in this very command. So the
468
+ # state lives in a file and `close` refuses to let the parent finish while the handshake is open.
469
+ # "Closing on an unanswered handshake is the same as never running one."
470
+ CHASE_AFTER_S = 1800
471
+
472
+ # Ordered. Each event may only move the handshake forward.
473
+ HANDSHAKE_STATES = ("awaiting-answers", "answered", "verdict-sent", "acknowledged")
474
+ _EVENT_STATE = {"answers": "answered", "verdict": "verdict-sent", "ack": "acknowledged"}
475
+
476
+
477
+ def detect_transport(env: dict | None = None) -> tuple[str, str]:
478
+ """('message' | 'paste', why) — tests the CAPABILITY, not the vendor.
479
+
480
+ `SendMessage` is Claude Code only; the harness also ships an `agy/` directory and targets
481
+ Codex, so a Claude-only command would simply not run on two of three targets. That makes the
482
+ paste path a supported route, not a degraded one.
483
+
484
+ The signal is `CLAUDE_CODE_MESSAGING_SOCKET`, which IS the cross-session transport — a peer
485
+ message arrives addressed from that socket. Keying on the socket rather than on "is this
486
+ Claude Code" means a runtime that gains messaging later works with no change here, and a
487
+ Claude Code with messaging switched off correctly falls back instead of failing at the moment
488
+ it tries to send.
489
+ """
490
+ env = os.environ if env is None else env
491
+ if env.get("CLAUDE_CODE_MESSAGING_SOCKET"):
492
+ return "message", "CLAUDE_CODE_MESSAGING_SOCKET is set — peers are reachable directly"
493
+ if env.get("CLAUDECODE"):
494
+ return "paste", ("Claude Code, but no messaging socket — the transport is off or this "
495
+ "build predates it. The human carries the round trip.")
496
+ return "paste", ("no messaging transport (agy, Codex or a plain shell). The human carries "
497
+ "the round trip — this is a supported path, not a broken one.")
498
+
499
+
500
+ def _handshake_path(repo_root: Path, spawn_id: str) -> Path:
501
+ return repo_root / SPAWN_DIR / (spawn_id + "-handshake.json")
502
+
503
+
504
+ def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto") -> dict:
505
+ """Record that a handover was issued. Written by the PARENT, at the moment it prints the
506
+ prompt — not when the child replies, because the whole point is to make an unanswered
507
+ handshake visible."""
508
+ chosen, why = detect_transport()
509
+ if transport != "auto":
510
+ chosen, why = transport, "forced with --transport"
511
+ state = {
512
+ "id": spawn_id,
513
+ "state": HANDSHAKE_STATES[0],
514
+ "transport": chosen,
515
+ "transport_reason": why,
516
+ "opened_at": int(time.time()),
517
+ "history": [],
518
+ }
519
+ path = _handshake_path(repo_root, spawn_id)
520
+ path.parent.mkdir(parents=True, exist_ok=True)
521
+ path.write_text(json.dumps(state, indent=2) + chr(10), encoding="utf-8")
522
+ return state
523
+
524
+
525
+ def handshake_read(repo_root: Path, spawn_id: str) -> dict | None:
526
+ try:
527
+ return json.loads(_handshake_path(repo_root, spawn_id).read_text(encoding="utf-8"))
528
+ except Exception:
529
+ return None
530
+
531
+
532
+ def handshake_record(repo_root: Path, spawn_id: str, event: str) -> tuple[dict | None, str]:
533
+ """Advance the handshake. Never rewinds: a verdict cannot un-answer the questions, and an
534
+ out-of-order event is a sign the operator has lost track of which round they are in — which
535
+ is exactly when a silent overwrite would hurt."""
536
+ state = handshake_read(repo_root, spawn_id)
537
+ if state is None:
538
+ return None, f"no handshake for {spawn_id} — run `handshake open --id {spawn_id}` first"
539
+ target = _EVENT_STATE[event]
540
+ if HANDSHAKE_STATES.index(target) <= HANDSHAKE_STATES.index(state["state"]):
541
+ return state, (f"already at `{state['state']}`; `{event}` would not move it forward. "
542
+ "Refusing rather than rewriting — an out-of-order event means the round "
543
+ "is not the one you think it is.")
544
+ state["history"].append({"event": event, "at": int(time.time())})
545
+ state["state"] = target
546
+ _handshake_path(repo_root, spawn_id).write_text(
547
+ json.dumps(state, indent=2) + chr(10), encoding="utf-8")
548
+ return state, ""
549
+
550
+
551
+ def handshake_chase_due(state: dict, now: int | None = None) -> int:
552
+ """Seconds overdue for a chase, or 0. Only meaningful while awaiting answers.
553
+
554
+ A chase is not a reminder. It should carry two FRESH questions, which cannot have been
555
+ pre-read in the handover file — a stronger test than the original set, and free, because the
556
+ parent is holding the context anyway.
557
+ """
558
+ if state.get("state") != HANDSHAKE_STATES[0]:
559
+ return 0
560
+ now = int(time.time()) if now is None else now
561
+ return max(0, now - int(state.get("opened_at", now)) - CHASE_AFTER_S)
562
+
563
+
564
+ def handshake_close(repo_root: Path, spawn_id: str) -> tuple[bool, str]:
565
+ """May the parent stop now? The lifecycle rule, made mechanical.
566
+
567
+ A handover nobody has read is not a handover, it is a file. Grading RE-DERIVES, so it audits
568
+ the document as well as the reader — in the manual run that pass found a defect in the
569
+ parent's own handover. `/clear` in the parent's terminal destroys the only thing that can do
570
+ that, so the parent must outlive the handshake.
571
+
572
+ NOTE the distinction this makes explicit, because two documents look like they disagree:
573
+ `spawn-hub` says the outgoing session STOPS WRITING the moment the prompt is issued (two
574
+ sessions committed to one repo concurrently and one's work landed inside the other's commit).
575
+ 008 says the parent is not CLOSED until the child passes. Alive is not writing. Both hold.
576
+ """
577
+ state = handshake_read(repo_root, spawn_id)
578
+ if state is None:
579
+ return False, (f"no handshake recorded for {spawn_id}. If one was run, it was not "
580
+ "recorded, and an unrecorded handshake cannot be distinguished from none.")
581
+ if state["state"] != HANDSHAKE_STATES[-1]:
582
+ return False, (f"handshake is at `{state['state']}`, not `acknowledged`. The parent stays "
583
+ "ALIVE until the child confirms it has the verdict — closing on an "
584
+ "unanswered handshake is the same as never running one. (It may stop "
585
+ "WRITING to the repo now; that is a different rule and both hold.)")
586
+ return True, "acknowledged — the parent may close"
587
+
588
+
243
589
  def main(argv: list[str] | None = None) -> int:
244
590
  ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
245
591
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -248,9 +594,23 @@ def main(argv: list[str] | None = None) -> int:
248
594
  s.add_argument("--repo-root", default=".")
249
595
  if name == "fingerprint":
250
596
  s.add_argument("--with-tests", action="store_true")
597
+ else:
598
+ s.add_argument("--context-pct", type=float, default=None,
599
+ help="your own context-usage reading, 0-100, for a machine with no "
600
+ "caddis status line. Only ever tightens the gate: the higher of "
601
+ "this and the cached runtime figure wins.")
251
602
  c = sub.add_parser("check")
252
603
  c.add_argument("--doc", required=True)
253
604
  c.add_argument("--repo-root", default=".")
605
+ o = sub.add_parser("capture-check")
606
+ o.add_argument("--id", required=True)
607
+ o.add_argument("--repo-root", default=".")
608
+ h = sub.add_parser("handshake")
609
+ h.add_argument("action", choices=["open", "status", "record", "close"])
610
+ h.add_argument("--id", required=True)
611
+ h.add_argument("--repo-root", default=".")
612
+ h.add_argument("--event", choices=sorted(_EVENT_STATE))
613
+ h.add_argument("--transport", choices=["auto", "message", "paste"], default="auto")
254
614
  q = sub.add_parser("verify-question")
255
615
  q.add_argument("--answer-in", required=True)
256
616
  q.add_argument("--needle", required=True)
@@ -259,9 +619,12 @@ def main(argv: list[str] | None = None) -> int:
259
619
  root = Path(a.repo_root).resolve()
260
620
 
261
621
  if a.cmd == "preflight":
262
- p = preflight(root)
622
+ p = preflight(root, context_pct=a.context_pct)
623
+ for note in p.notes:
624
+ print(f" note {note}")
263
625
  if p.ok():
264
- print(f"[spawn] ready — clean tree, {p.parking_open} open parking-lot item(s)")
626
+ ctx = f", context {p.context_pct:.0f}%" if p.context_pct is not None else ""
627
+ print(f"[spawn] ready — clean tree, {p.parking_open} open parking-lot item(s){ctx}")
265
628
  return EXIT_OK
266
629
  sys.stderr.write("[spawn] REFUSING to spawn:\n")
267
630
  for r in p.refusals:
@@ -284,13 +647,71 @@ def main(argv: list[str] | None = None) -> int:
284
647
  print(f"[spawn] {doc.name} passes round 0 — nothing stale-prone, every path exists")
285
648
  return EXIT_OK
286
649
  sys.stderr.write(f"[spawn] {doc.name} FAILS round 0:\n")
650
+ for n, msg in f.answer_key:
651
+ sys.stderr.write(f" line {n}: ANSWER KEY — {msg}" + chr(10))
287
652
  for n, msg in f.stale_prone:
288
653
  sys.stderr.write(f" line {n}: {msg}\n")
289
654
  for p_ in f.missing_paths:
290
655
  sys.stderr.write(f" names a path that does not exist: {p_}\n")
291
656
  sys.stderr.write(" A claim that cannot be re-derived does not go in the handover.\n")
657
+ if f.answer_key:
658
+ sys.stderr.write(" And an answer the examinee can read is not a check. This REFUSES "
659
+ "rather than warns because the rule was already written down, in "
660
+ "007 section 5, and a session that had it broke it within a day."
661
+ + chr(10))
292
662
  return EXIT_REFUSED
293
663
 
664
+ if a.cmd == "capture-check":
665
+ refusals, notes = capture_order(root, a.id)
666
+ for n in notes:
667
+ print(f" note {n}")
668
+ if not refusals:
669
+ print("[spawn] capture order holds — relay after the task list, questions after both")
670
+ return EXIT_OK
671
+ sys.stderr.write("[spawn] capture order is WRONG:" + chr(10))
672
+ for r in refusals:
673
+ sys.stderr.write(" " + r + chr(10))
674
+ sys.stderr.write(" Out of sequence, a handover describes a state that never existed."
675
+ + chr(10))
676
+ return EXIT_REFUSED
677
+
678
+ if a.cmd == "handshake":
679
+ if a.action == "open":
680
+ st = handshake_open(root, a.id, a.transport)
681
+ print(f"[spawn] handshake {a.id} open — transport `{st['transport']}` "
682
+ f"({st['transport_reason']})")
683
+ if st["transport"] == "paste":
684
+ print(" The child cannot message you. Print the prompt for the human to carry, "
685
+ "and say so — otherwise they watch nothing happen and conclude it is "
686
+ "broken.")
687
+ return EXIT_OK
688
+ st = handshake_read(root, a.id)
689
+ if st is None:
690
+ sys.stderr.write(f"[spawn] no handshake for {a.id}" + chr(10))
691
+ return EXIT_REFUSED
692
+ if a.action == "status":
693
+ over = handshake_chase_due(st)
694
+ print(f"[spawn] {a.id}: {st['state']} via {st['transport']}")
695
+ if over:
696
+ print(f" CHASE DUE — {over}s past the {CHASE_AFTER_S}s window. Silence is not "
697
+ "success: a child that skipped the gate and started work looks exactly "
698
+ "like one still reading. Ask, and carry TWO FRESH questions, which cannot "
699
+ "have been pre-read in the handover.")
700
+ return EXIT_OK
701
+ if a.action == "record":
702
+ if not a.event:
703
+ sys.stderr.write("[spawn] --event is required for `record`" + chr(10))
704
+ return EXIT_REFUSED
705
+ st, err = handshake_record(root, a.id, a.event)
706
+ if err:
707
+ sys.stderr.write("[spawn] " + err + chr(10))
708
+ return EXIT_REFUSED
709
+ print(f"[spawn] {a.id}: {st['state']}")
710
+ return EXIT_OK
711
+ ok, msg = handshake_close(root, a.id)
712
+ (print if ok else sys.stderr.write)("[spawn] " + msg + ("" if ok else chr(10)))
713
+ return EXIT_OK if ok else EXIT_REFUSED
714
+
294
715
  ok, msg = verify_answerable(root, a.answer_in, a.needle)
295
716
  (print if ok else sys.stderr.write)(f"[spawn] {msg}" + ("" if ok else "\n"))
296
717
  return EXIT_OK if ok else EXIT_REFUSED
@@ -292,6 +292,42 @@ def ram_facts():
292
292
  return None
293
293
 
294
294
 
295
+ def record_context(cwd: str, pct: float) -> None:
296
+ """Cache the runtime context figure for tools that never see the statusline payload.
297
+
298
+ Claude Code hands `context_window.used_percentage` to the statusline and to NOTHING else.
299
+ `/caddis:spawn-session` must refuse above 95% (008 section 7a) and runs as a plain bash
300
+ command, so without this it would have to ask the model to estimate its own usage — which
301
+ is the one thing that section forbids, because the faculty being gated is the faculty
302
+ doing the reporting.
303
+
304
+ Only writes when the value CHANGED, so an idle statusline does not rewrite a file on every
305
+ render. Only writes where `.caddis/` already exists, so cd-ing into an unrelated folder
306
+ never scatters artifact dirs. Fail-open and silent: a status line must never break a
307
+ prompt, and this is the least important thing it does.
308
+ """
309
+ try:
310
+ if not cwd:
311
+ return
312
+ art = os.path.join(cwd, ".caddis")
313
+ if not os.path.isdir(art):
314
+ return
315
+ target = os.path.join(art, "context-window.json")
316
+ payload = {"used_percentage": round(float(pct), 1), "at": int(time.time())}
317
+ try:
318
+ with open(target, encoding="utf-8") as fh:
319
+ if json.load(fh).get("used_percentage") == payload["used_percentage"]:
320
+ return
321
+ except Exception:
322
+ pass
323
+ tmp = target + ".tmp"
324
+ with open(tmp, "w", encoding="utf-8") as fh:
325
+ json.dump(payload, fh)
326
+ os.replace(tmp, target)
327
+ except Exception:
328
+ pass
329
+
330
+
295
331
  def relay_present(cwd: str) -> bool:
296
332
  """A parked resume pointer exists for this repo. `.caddis/relay.md` is canonical;
297
333
  a bare `relay.md` at the root is the pre-rename legacy location."""
@@ -428,6 +464,7 @@ def render_claude(data: dict, cfg: dict) -> str:
428
464
  used = as_int(cw.get("total_input_tokens"))
429
465
  max_tokens = as_int(cw.get("context_window_size"))
430
466
  pct = float(as_int(cw.get("used_percentage")))
467
+ record_context(cwd, pct) # feeds /caddis:spawn-session's 95% gate; see record_context
431
468
  parts.append(context_segment(c, cfg, used, max_tokens, pct))
432
469
  loud = cached_segment(c, cfg, cached)
433
470
  if loud:
@@ -897,6 +897,8 @@ agent-log.jsonl
897
897
  relay.md
898
898
  relay/
899
899
  session-state.md
900
+ session-state/
901
+ context-window.json
900
902
  PROJECT-FACTS.md
901
903
  memory.jsonl
902
904
  """
@@ -919,6 +921,10 @@ English does not, and marks which already have an entry:
919
921
  It cannot write the definitions - only a person or the model reading the code can say what
920
922
  a coined word MEANS here. Run `/caddis:glossary` to have them drafted, or write them.
921
923
 
924
+ This file is for YOUR project's vocabulary. caddis's own coined words - pool, relay, drift,
925
+ export target - are defined separately in `CADDIS-GLOSSARY.md` at the plugin root, which also
926
+ serves as a worked example of what a good entry looks like. Do not copy those terms in here.
927
+
922
928
  | Term | Definition | DO NOT USE |
923
929
  |---|---|---|
924
930
  | _example_ | Replace this row. Say what the word means HERE, and why the ordinary reading is wrong. | the synonyms that cause confusion |
@@ -85,6 +85,7 @@
85
85
  | Park | `park/` | File a future-work item in the one backlog — .caddis/parking-lot/ — or list what is open |
86
86
  | Prd | `prd/` | Capture requirements through structured discovery and write a PRD |
87
87
  | Resume | `resume/` | Pop the most recently parked workstream off the stack and resume it at its exact resume point |
88
+ | Review | `review/` | Review someone else's OPEN pull request end to end — what changed, what CI says, what is risky — then approve or request changes. Never approves on its own. |
88
89
  | Ship | `ship/` | Commit, push, and monitor the deploy pipeline (auto-detects Gitea, GitHub Actions, or local-only) |
89
90
  | Ship Merge | `ship-merge/` | Merge an already-green, already-reviewed PR behind an explicit deploy-confirm, monitor the deploy, validate prod, then clean up the branch — only on green. |
90
91
  | Ship Pr | `ship-pr/` | Open a reviewed PR for the current feature branch — rebase-safe push, PR create/update, monitor CI, STOP at green. Never merges, never deploys (that's /ship-merge). |
@@ -13,9 +13,14 @@ be a coin toss every time.
13
13
 
14
14
  ## Read these, in this order, and stop as soon as you can answer
15
15
 
16
- 1. **`.caddis/session-state.md`** — the primary source. The `Stop` hook rewrites it at the end of
17
- every turn, so it is current even when the session ended abruptly and nobody ran `/handoff`.
18
- It holds the last real request, the task list with statuses, and the files touched.
16
+ 1. **`.caddis/session-state/<session-id>.md`** — the primary source; read the NEWEST. The `Stop`
17
+ hook rewrites one per session at the end of every turn, so it is current even when the session
18
+ ended abruptly and nobody ran `/handoff`. It holds the last real request, the task list with
19
+ statuses, and the files touched. Repos that have not stopped a session since 2026-09-08 have a
20
+ single `.caddis/session-state.md` instead; read that when the directory is absent.
21
+ **Check the `Session:` id in the header.** Sessions share working trees here, so the newest file
22
+ can belong to a live PEER rather than to the session you are catching up on — if the id is not
23
+ the one you are resuming, say so instead of reporting its work as yours.
19
24
  2. **`.caddis/relay.md`** — only refreshed by `/handoff`, so it may be days behind, but it carries
20
25
  what the state file cannot: the agreed **next step** and the open blockers.
21
26
  3. **The current turn's own context**, if this session has already done work. What you have done
@@ -52,6 +52,10 @@ Each row gets three things:
52
52
  | **Definition** | What it means HERE, and — where it helps — why the ordinary reading misleads. Say the thing a newcomer would get wrong. |
53
53
  | **DO NOT USE** | The synonyms actually causing confusion in this repo. Leave blank rather than inventing some. |
54
54
 
55
+ **A worked example ships with caddis.** `CADDIS-GLOSSARY.md` at the plugin root defines caddis's
56
+ own coined vocabulary the same way — read a few entries before writing yours. It is caddis's
57
+ words, not this project's: never copy its terms into a project glossary.
58
+
55
59
  Good: *"pool — the single source of every shipped resource, in `.github/`. Everything installed is EXPORTED from it; nothing is authored in a bundle."*
56
60
 
57
61
  Bad: *"pool — a pool of resources."*
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: review
3
+ description: Review someone else's OPEN pull request end to end — what changed, what CI says, what is risky — then approve or request changes. Never approves on its own.
4
+ ---
5
+
6
+ # /caddis:review — review an open PR, then decide
7
+
8
+ Review pull request **$ARGUMENTS** and walk the operator to an approve-or-not decision.
9
+
10
+ > **This is the only caddis review surface aimed at a PULL REQUEST.** The others review a local
11
+ > diff: `/caddis:gate-review` (adversarial, same vendor), `/caddis:cross-review` (a different
12
+ > vendor's model). `/caddis:ship-pr` and `/caddis:ship-merge` create and merge PRs. This one is for
13
+ > the case none of those covers — somebody else has opened a PR and you have to decide about it.
14
+ >
15
+ > Do not reimplement the review itself. Gather the context, then hand the diff to
16
+ > `/caddis:gate-review` (or Claude Code's native `/code-review`, which is better where it exists).
17
+
18
+ ## Absolute rule
19
+
20
+ **Never approve, request changes, merge, or close on the operator's behalf.** Present the evidence
21
+ and the exact command; the human runs it. An approval is the one human judgement in the pipeline —
22
+ a review tool that also approves has removed the thing it was built to protect.
23
+
24
+ ## 1. Resolve the PR
25
+
26
+ `$ARGUMENTS` is a PR number, optionally followed by `owner/repo`. With no repo, use the current
27
+ directory's `origin`. If the number is missing, list the open PRs and stop.
28
+
29
+ ```bash
30
+ gh pr view <n> --repo <owner/repo> --json number,title,author,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,additions,deletions,changedFiles,url
31
+ ```
32
+
33
+ **Stop and say so, before reviewing anything**, if:
34
+ - the PR is **merged or closed** — there is nothing to decide;
35
+ - it is a **draft** — say so and ask whether to continue;
36
+ - **the operator is the author.** GitHub does not allow approving your own PR. Say it plainly, and
37
+ say the alternatives: have someone else approve it, or merge with an admin bypass
38
+ (`gh pr merge <n> --squash --admin`). Do not silently produce a review they cannot submit.
39
+
40
+ ## 2. Gather what the decision needs
41
+
42
+ Four things, and report them even when they are boring — "CI is green" is information:
43
+
44
+ ```bash
45
+ gh pr checks <n> --repo <owner/repo> # every check and its state
46
+ gh pr diff <n> --repo <owner/repo> # the change itself
47
+ gh pr view <n> --repo <owner/repo> --comments # review comments, and any bot review
48
+ ```
49
+
50
+ If an automated review has already commented, **read it and say whether you agree**. Two reviews
51
+ that disagree is a finding; two that agree is worth stating once, not twice.
52
+
53
+ `mergeStateStatus` explains a blocked PR without guessing: `BLOCKED` (needs a review),
54
+ `BEHIND` (needs *Update branch*), `UNSTABLE` (a check failed), `DIRTY` (conflicts).
55
+
56
+ ## 3. Review the diff
57
+
58
+ Hand it to `/caddis:gate-review` — or the native `/code-review` on Claude Code. Then, on top of its
59
+ verdict, check the things that have actually cost this fleet time, because a generic reviewer will
60
+ not know to look:
61
+
62
+ - **A gate that cannot fail.** A check whose failure path cannot execute — piped to `Out-Null`,
63
+ wrapped in a `catch{}` a native non-zero exit can never trigger, or asserting something too broad
64
+ to attribute. It manufactures confidence. Worse than no check.
65
+ - **A wall-clock assertion.** A timing threshold on a shared runner measures load, not the code.
66
+ Assert ordering instead.
67
+ - **Staged directories.** `git add -A` / `git add .` sweeps in files nobody reviewed.
68
+ - **CI shell assumptions.** The runner may be Windows PowerShell 5.1, where a native command's
69
+ stderr becomes a terminating error under `$ErrorActionPreference='Stop'`, and where `Should -Be`
70
+ (Pester 4 syntax) fails on a Pester 3 runner.
71
+ - **Deploy-script changes.** If the pipeline runs the deploy script from a checkout it also updates,
72
+ the change does not take effect on the deploy that delivers it. Budget two deploys.
73
+ - **Verification by proxy.** An HTTP 200 or a version string is not proof a page renders.
74
+ - **Credentials.** A token scoped to the wrong host; a permission raised to make a test pass; a
75
+ secret in a URL where git will echo it into a log.
76
+ - **A doc the change makes untrue.** Comments and runbooks that were accurate until this diff.
77
+
78
+ ## 4. Report, then hand over
79
+
80
+ Lead with the verdict. Keep it short enough to read before deciding:
81
+
82
+ - **What this PR does** — one or two sentences, not a file list.
83
+ - **Blocking issues** — each with file, line, and why it matters. None is a valid answer, and say it.
84
+ - **Should-fix / nits** — grouped, brief.
85
+ - **CI** — green, red, or still running. Name the failing check.
86
+ - **Merge state** — and what would clear it.
87
+
88
+ Then print the exact commands, and stop:
89
+
90
+ ```bash
91
+ # approve
92
+ gh pr review <n> --repo <owner/repo> --approve
93
+ gh pr merge <n> --repo <owner/repo> --squash --delete-branch
94
+
95
+ # or ask for changes
96
+ gh pr review <n> --repo <owner/repo> --request-changes --body "..."
97
+ ```
98
+
99
+ **If you found a blocking issue, say so in the verdict line** rather than burying it under the
100
+ summary. The operator should be able to read the first line and know whether to keep reading.
@@ -186,6 +186,27 @@ Then write the judgement half yourself. It must contain:
186
186
  - name **one thing a green automated check does NOT prove**, with the example that taught it
187
187
  - **verify the tree independently** — `git fetch`, count divergence, and `git status`/diff any dirty
188
188
  file before treating it as authority
189
+ > **THE NEW HUB MUST NOT MARK ITS OWN SELF-CHECK.** Until 2026-09-08 nothing graded these
190
+ > answers: this command does not start the new session, so nothing routed them back. A
191
+ > self-marked exam tests willingness, not comprehension. Run the handshake instead — the
192
+ > incoming Hub answers, **you re-derive each answer from the files**, then you send the
193
+ > verdict:
194
+ >
195
+ > ```bash
196
+ > python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake open --id <id>
197
+ > python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake status --id <id>
198
+ > python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake record --id <id> --event answers
199
+ > python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake close --id <id>
200
+ > ```
201
+ >
202
+ > It reports whether this runtime can message a peer at all and falls back to the paste route
203
+ > on agy and Codex, which have no `SendMessage`. `close` refuses until the incoming Hub has
204
+ > acknowledged the verdict. See `/caddis:spawn-session` for the full direction table and the
205
+ > chase rule.
206
+ >
207
+ > **This does not soften the single-writer rule below.** You stay ALIVE to grade; you stop
208
+ > WRITING when the prompt is issued. Both hold.
209
+
189
210
  4. **At least one question whose naive answer is WRONG.** This is the load-bearing part. A Hub that
190
211
  only skimmed must fail *visibly* rather than silently.
191
212
 
@@ -29,6 +29,51 @@ and the active plan).
29
29
 
30
30
  ---
31
31
 
32
+ ## The capture ORDER — it is an order, not a list
33
+
34
+ **Out of sequence, you produce a handover that describes a state that never existed.**
35
+
36
+ | # | Step | Why it must be here |
37
+ |---|---|---|
38
+ | 1 | **Capture the knowledge** — KB notes for anything that generalises | FIRST, while the reasoning is still in context. Written last it becomes a summary of a summary |
39
+ | 2 | **Update the durable state** — the register, the parking lot | These are the authority. The relay quotes them, so they must be true before it does |
40
+ | 3 | **Write the TASK LIST to a file** | A task widget does not survive a `/clear`. This project has already lost one |
41
+ | 4 | **Write the relay** — what happened, who owns what, what is blocked | It CITES 1-3. Written earlier, it cites things that have since moved |
42
+ | 5 | **Derive the questions** | LAST. They must target what steps 1-4 actually say. Questions written first test **your memory**, which is the thing under suspicion |
43
+
44
+ Named, so a fresh session knows what to open with no prompt engineering:
45
+
46
+ ```
47
+ .caddis/parent-relay.md what happened, who owns what, what blocks
48
+ .caddis/parent-session-state.md the task list
49
+ ```
50
+
51
+ **`parent-` rather than overwriting `relay.md`:** the live relay belongs to the WORK and outlives
52
+ any one session. A handover is a snapshot.
53
+
54
+ > **Do not confuse `.caddis/parent-session-state.md` with `.caddis/session-state/`.** The names
55
+ > are close and the handling is opposite. The directory is written by the Stop hook every turn,
56
+ > one file per session id, and is **gitignored** — a recovery aid. The `parent-` file is written
57
+ > by you, once, deliberately, and is **committed** — a handover artefact. Reaching for the wrong
58
+ > one means either handing over an auto-generated stub, or committing conversation text.
59
+
60
+ Check the order before you issue anything:
61
+
62
+ ```bash
63
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" capture-check --id <id>
64
+ ```
65
+
66
+ It compares write times, which is the one property here that is mechanically knowable. It cannot
67
+ tell you a step was done *well* — nothing can — but it refuses a relay written before the task
68
+ list it cites, and questions derived before the relay they are supposed to target. It also flags
69
+ any KB note or register item that changed **after** the relay was written, because the relay
70
+ quotes those and they have since moved.
71
+
72
+ **A step that produced nothing is a note, not a failure — but you must SAY it produced nothing.**
73
+ A missing task list that the reader has to infer is worse than one declared empty.
74
+
75
+ ---
76
+
32
77
  ## Round 0 — validate your OWN handover. No relay trip.
33
78
 
34
79
  **Do this before writing the prompt.** It is the cheapest round and the one this whole command is
@@ -44,6 +89,33 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" fingerprint --with-tests
44
89
  `preflight` **refuses on a dirty tree** — the successor pulls, so uncommitted work is invisible to
45
90
  it and it will either redo that work or build on a state that does not exist. Commit first.
46
91
 
92
+ ### It also refuses above 95% context
93
+
94
+ **A refusal, not a warning.** Under 85% it runs normally; between 85% and 95% it runs and prints
95
+ the figure, which you must then **state in the handover** so the reader can weigh it.
96
+
97
+ Why a refusal: the thing that degrades first is the thing this command depends on. The failure is
98
+ not forgetting, it is **confident recall of superseded facts** — so a handover written from a
99
+ nearly-full context comes out fluent, cited and wrong. A warning cannot help, because the agent
100
+ that most needs to heed it is the one least able to judge that it should. The costs are not
101
+ symmetric either: refusing early costs this session's remaining headroom, while a confident wrong
102
+ handover costs the successor's whole session and everything built on the error.
103
+
104
+ **Where the number comes from.** Claude Code hands `context_window.used_percentage` to the status
105
+ line and to nothing else, so the caddis status line caches it per repo and `preflight` reads it
106
+ back. **No status line on this machine?** Pass your own reading:
107
+
108
+ ```bash
109
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" preflight --context-pct 72
110
+ ```
111
+
112
+ **A self-reported figure may only tighten the gate, never loosen it** — the higher of the two
113
+ always wins, so it can refuse earlier but can never talk its way through. That asymmetry is what
114
+ makes the fallback safe: the faculty being gated is the faculty doing the reporting.
115
+
116
+ With **no figure from either source, it refuses** — an unmeasurable gate that defaults to proceed
117
+ is not a gate — and the refusal names both ways out rather than leaving you stuck.
118
+
47
119
  ### Capture from the repo, never from recall
48
120
 
49
121
  ```bash
@@ -102,7 +174,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" verify-question \
102
174
  A question whose answer is not written down tests memory — the thing being replaced. It is not a
103
175
  harder question; it is the old failure wearing an exam's clothes.
104
176
 
105
- ### Store NO answer key
177
+ ### Store NO answer key — and `check` now REFUSES one
106
178
 
107
179
  Do not write `<id>-key.md`. Two reasons, and the second is the one that matters:
108
180
 
@@ -110,13 +182,109 @@ Do not write `<id>-key.md`. Two reasons, and the second is the one that matters:
110
182
  2. **A stored key freezes your belief at capture time.** If you were wrong, the key is wrong, and
111
183
  the check certifies the error.
112
184
 
185
+ **This rule is no longer only written down.** It was written down before — in `007` §5, in these
186
+ words — and a session that had it available wrote a key anyway: into the file the successor reads,
187
+ inside a collapsed `details`/`summary` block, as though that were a lock. A model reads the whole file, so the
188
+ questionnaire tested nothing.
189
+
190
+ `caddis_spawn.py check` now **refuses** three shapes, and does not merely warn:
191
+
192
+ | Refused | Why |
193
+ |---|---|
194
+ | any collapsed `details` element | a rendering hint for a browser, not an access control |
195
+ | an answer-key heading | `ANSWER KEY`, `Expected answers`, `Correct answers`, `answers — for the …` |
196
+ | a numbered question and its answer on the same line | the questions may ship; the answers may not |
197
+
198
+ Questions on their own still pass — a check that blocked those would be switched off within a day.
199
+ The check is structural, not semantic, on purpose: judging whether prose *reveals* an answer is
200
+ wrong in both directions, and a check that cries wolf gets skimmed.
201
+
202
+ **The general class is bigger than this command.** A rule and an artefact that contradict each
203
+ other do not fail loudly. The artefact wins, silently, because the artefact is what gets read. So
204
+ put the check in the tooling, never in a document that says the tooling should have one.
205
+
113
206
  **Re-derive each answer from the repo at validation time instead.** That validates the DOCUMENT and
114
207
  the READER at once — which is exactly what `.caddis/parking-lot/done/004-*` said was missing, where
115
208
  a successor passed a thirteen-question check on a handover containing four factual errors.
116
209
 
210
+ ### The handshake — who sends what, and in which direction
211
+
212
+ 007 routed every round trip through a human paste. Sessions can message each other now, so the
213
+ human becomes an approver rather than a transport — **where a transport exists.** Open the
214
+ handshake when you issue the prompt, not when the child replies:
215
+
216
+ ```bash
217
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake open --id <id>
218
+ ```
219
+
220
+ It reports which route you are on. **`SendMessage` is Claude Code only** — the harness ships an
221
+ `agy/` directory and targets Codex — so detection tests `CLAUDE_CODE_MESSAGING_SOCKET`, the
222
+ transport itself, rather than the vendor. **The paste route is a supported path, not a broken
223
+ one.** On it, say so in the printed instructions, or the user watches nothing happen and concludes
224
+ the command is broken.
225
+
226
+ | Direction | When |
227
+ |---|---|
228
+ | **child → parent** | **Primary.** As soon as it has answers. It cannot message before it has them, so the message IS evidence it did the reading |
229
+ | **parent → child** | **A chase**, after the timeout, or whenever the product owner asks |
230
+ | **parent → child** | **The verdict.** Always, and always after re-deriving |
231
+ | child → parent | An acknowledgement, so the parent knows it may close |
232
+
233
+ **The parent chases, because child-initiated alone cannot see the worst failure.** A successor
234
+ that skips the gate and starts working produces silence — and silence is indistinguishable from
235
+ "still reading". Check it:
236
+
237
+ ```bash
238
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake status --id <id>
239
+ ```
240
+
241
+ **A chase carries two FRESH questions, not just a reminder.** A reminder invites a hurried reply.
242
+ Two questions asked at chase time cannot have been pre-read in the handover, which is a stronger
243
+ test than the original set — and it costs nothing, since you are holding the context anyway.
244
+
245
+ Record each step, and the parent may not finish until the child has acknowledged:
246
+
247
+ ```bash
248
+ ... handshake record --id <id> --event answers # the child replied
249
+ ... handshake record --id <id> --event verdict # you replied, after re-deriving
250
+ ... handshake record --id <id> --event ack # the child confirmed
251
+ ... handshake close --id <id> # REFUSES unless acknowledged
252
+ ```
253
+
254
+ **`close` refuses on an open handshake, and that is the point.** A handover nobody has read is not
255
+ a handover, it is a file — and grading RE-DERIVES, so it audits the document as well as the
256
+ reader. In the manual run that pass found a defect in the parent's own handover. `/clear` in your
257
+ terminal destroys the only thing that can do that.
258
+
259
+ > **You stay ALIVE. You stop WRITING.** `/caddis:spawn-hub` carries a single-writer rule — the
260
+ > outgoing session stops writing to the repository the moment the prompt is issued, because two
261
+ > sessions committed to one repo concurrently and one's work landed inside the other's commit.
262
+ > That is a different rule from this one and **both hold.** Said no other way, a reader reconciles
263
+ > them by closing the parent early, which removes the grader.
264
+
265
+ Note the handshake file lands in `.caddis/spawn-session/`, so commit it with the others before
266
+ re-running `preflight`, which refuses on a dirty tree.
267
+
268
+ ### Tell the child it does not own the tree
269
+
270
+ **Say this in the prompt.** A fresh session's instinct on seeing a dirty tree is to tidy it, and
271
+ you are still in that tree.
272
+
273
+ > While both sessions are live, do **not** run `git checkout`, `switch`, `stash`, `reset`,
274
+ > `rebase` or `clean`. A branch switch is visible to every session in this working tree and
275
+ > carries or destroys the other's uncommitted work. For parallel work, add a `git worktree` off
276
+ > `origin/main` instead of checking out here. Commit early — only committed objects are safe
277
+ > from another session's checkout.
278
+
279
+ The harness prints a live-peer warning at SessionStart when it can see one, but it **under-reports
280
+ by design**: a peer that has not finished a turn has written no state yet. No warning means no
281
+ evidence of a peer, not "you are alone". `git worktree list` finds what it cannot.
282
+
283
+ See `.caddis/kb/shared-worktree-branch-switch.md` for detection and the recovery pattern.
284
+
117
285
  ### What the child sends back
118
286
 
119
- Three lines, carried by the user:
287
+ Three lines — messaged directly, or carried by the user on the paste route:
120
288
 
121
289
  ```
122
290
  SPAWN <id> | head <sha> | <n> tests | answers in .caddis/spawn-session/<id>-answers.md
@@ -206,8 +374,12 @@ chat cannot be reviewed**, and cannot be re-read when the next handover asks wha
206
374
  | Refusal | Why |
207
375
  |---|---|
208
376
  | Run with a dirty tree | uncommitted work is invisible to the successor |
377
+ | Run above 95% context | the handover would be written by the faculty that is failing — fluently, with citations, and wrong |
378
+ | Run with no context figure at all | a gate that defaults to proceed is not a gate |
209
379
  | Write a commit hash or test count as current state | both went stale within the hour, twice |
210
380
  | Write a question whose answer is not in a committed file | it would test memory |
211
- | Store an answer key | it freezes your belief, and the child can read it |
381
+ | Store an answer key | it freezes your belief, and the child can read it. **`check` enforces this** — see round 1 |
212
382
  | Grade your own answers | you validate; the child answers |
383
+ | Close on an unanswered handshake | it is the same as never running one. `handshake close` refuses |
384
+ | Read silence as success | a child that skipped the gate looks exactly like one still reading |
213
385
  | Proceed when the open-item count is unknown | that count is the integrity check |
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.90",
2
+ "poolVersion": "1.3.93",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.90",
4
+ "antigravity-plugin": "1.3.93",
5
5
  "antigravity-plugin-extras": "1.3.28",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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",