@caddis/cli 0.4.5 → 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.
- package/bundles/antigravity-plugin/caddis_statusline.py +37 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +425 -4
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +37 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +2 -0
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +8 -3
- package/bundles/antigravity-plugin/skills/review/SKILL.md +100 -0
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +21 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +175 -3
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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:
|
|
@@ -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
|
-
|
|
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:
|
|
@@ -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
|
|
17
|
-
every turn, so it is current even when the session
|
|
18
|
-
It holds the last real request, the task list with
|
|
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
|
|
@@ -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 |
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED