@caddis/cli 0.4.11 → 0.4.13
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/guard_agy.py +62 -13
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +217 -18
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +59 -7
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +10 -0
- package/bundles/antigravity-plugin/skills/kb/SKILL.md +4 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +104 -16
- package/bundles/antigravity-plugin/skills/ui-brief/SKILL.md +6 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -79,8 +79,9 @@ def classify_write(file_path: str) -> tuple[str, str]:
|
|
|
79
79
|
# subpath (E:\proj\dist falls through to ask).
|
|
80
80
|
_CATASTROPHIC_TARGET = re.compile(
|
|
81
81
|
r"--no-preserve-root"
|
|
82
|
-
|
|
83
|
-
r"|(?:^|\s)
|
|
82
|
+
# A closing bracket also ends the target: `echo $(rm -rf ~)` was ask, not deny (review catch).
|
|
83
|
+
r"|(?:^|\s)(?:/|/\*|~/|~|\$\{?HOME\}?)(?:\s|$|;|&|\||[)}])"
|
|
84
|
+
r"|(?:^|\s)[A-Za-z]:[\\/]?(?:\s|$|;|&|\||[)}])",
|
|
84
85
|
re.I,
|
|
85
86
|
)
|
|
86
87
|
_FORK_BOMB = re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")
|
|
@@ -115,6 +116,61 @@ def _is_win_recursive_delete(cmd: str) -> bool:
|
|
|
115
116
|
return ps_recurse_force or cmd_slash_s
|
|
116
117
|
|
|
117
118
|
|
|
119
|
+
_FIND_DELETE = r"\bfind\b.*?(?:-delete\b|-exec\s+rm\b)"
|
|
120
|
+
# Line continuations join a statement across physical lines: bash `\` and PowerShell backtick.
|
|
121
|
+
_CONTINUATION = re.compile(r"[\\`]\r?\n")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _statements(c: str) -> list[str]:
|
|
125
|
+
"""Split a command on `;`, `&&`, `||` and newlines — ONLY at the top level. Same rule as
|
|
126
|
+
hooks/guard.py: a separator inside quotes, a subshell/block or after a line continuation does
|
|
127
|
+
not end a statement, and an unclosed quote or bracket errs toward one statement (deny)."""
|
|
128
|
+
c = _CONTINUATION.sub(" ", c)
|
|
129
|
+
out: list[str] = []
|
|
130
|
+
cur: list[str] = []
|
|
131
|
+
quote, depth, i = "", 0, 0
|
|
132
|
+
while i < len(c):
|
|
133
|
+
ch = c[i]
|
|
134
|
+
if quote:
|
|
135
|
+
quote = "" if ch == quote else quote
|
|
136
|
+
elif ch in "'\"":
|
|
137
|
+
quote = ch
|
|
138
|
+
elif ch in "({":
|
|
139
|
+
depth += 1
|
|
140
|
+
elif ch in ")}" and depth:
|
|
141
|
+
depth -= 1
|
|
142
|
+
elif depth == 0 and (ch in ";\n" or c[i:i + 2] in ("&&", "||")):
|
|
143
|
+
out.append("".join(cur))
|
|
144
|
+
cur = []
|
|
145
|
+
i += 1 if ch in ";\n" else 2
|
|
146
|
+
continue
|
|
147
|
+
cur.append(ch)
|
|
148
|
+
i += 1
|
|
149
|
+
out.append("".join(cur))
|
|
150
|
+
return [_strip_quotes(s) for s in out]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _catastrophic_statement(c: str) -> str:
|
|
154
|
+
"""The deny reason when ONE statement both destroys recursively and names a root, else ''.
|
|
155
|
+
|
|
156
|
+
Same rule as hooks/guard.py. Tested per statement, not per command: an unrelated `~` or `C:`
|
|
157
|
+
on another line used to turn a scratchpad cleanup into a root/home deny (found 2026-09-10)."""
|
|
158
|
+
for seg in _statements(c):
|
|
159
|
+
if not _CATASTROPHIC_TARGET.search(seg):
|
|
160
|
+
continue
|
|
161
|
+
sl = seg.lower()
|
|
162
|
+
if _is_rm_recursive_force(seg):
|
|
163
|
+
return "recursive force-delete of a root/home path (rm -rf)"
|
|
164
|
+
if re.search(_FIND_DELETE, sl):
|
|
165
|
+
return "find -delete from a root/home path"
|
|
166
|
+
if _is_win_recursive_delete(seg):
|
|
167
|
+
return "recursive force-delete of a root/home path (Windows Remove-Item/rmdir)"
|
|
168
|
+
if (re.search(r"\bchmod\b", sl) and _has_flag(seg, "r", "--recursive")
|
|
169
|
+
and re.search(r"\b0?777\b", sl)):
|
|
170
|
+
return "recursive chmod 777 on a root path"
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
|
|
118
174
|
def classify_bash(command: str) -> tuple[str, str]:
|
|
119
175
|
"""Risk tier (deny|ask|allow) + reason for a shell command."""
|
|
120
176
|
c = command.strip()
|
|
@@ -122,26 +178,19 @@ def classify_bash(command: str) -> tuple[str, str]:
|
|
|
122
178
|
n = _strip_quotes(c) # quote-normalized: defeats `rm '-rf' "/"` evasion
|
|
123
179
|
nl = n.lower()
|
|
124
180
|
rm_rf = _is_rm_recursive_force(n)
|
|
125
|
-
|
|
126
|
-
find_delete = bool(re.search(r"\bfind\b.*?(?:-delete\b|-exec\s+rm\b)", nl))
|
|
181
|
+
find_delete = bool(re.search(_FIND_DELETE, nl))
|
|
127
182
|
win_del = _is_win_recursive_delete(n)
|
|
128
183
|
|
|
129
184
|
# ── deny: catastrophic ──
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return "deny", "find -delete from a root/home path"
|
|
134
|
-
if win_del and catastrophic:
|
|
135
|
-
return "deny", "recursive force-delete of a root/home path (Windows Remove-Item/rmdir)"
|
|
185
|
+
why = _catastrophic_statement(c)
|
|
186
|
+
if why:
|
|
187
|
+
return "deny", why
|
|
136
188
|
if re.search(r"\brmtree\s*\(\s*['\"]?(?:/|~|[A-Za-z]:\\?)['\"]?\s*\)", c, re.I):
|
|
137
189
|
return "deny", "recursive tree delete of a root/home path (rmtree)"
|
|
138
190
|
if _FORK_BOMB.search(c):
|
|
139
191
|
return "deny", "fork bomb"
|
|
140
192
|
if re.search(r"\bdd\b.*\bof=/dev/", nl) or re.search(r"\bmkfs", nl) or re.search(r">\s*/dev/sd[a-z]", nl):
|
|
141
193
|
return "deny", "writes directly to a disk device"
|
|
142
|
-
if (re.search(r"\bchmod\b", nl) and _has_flag(n, "r", "--recursive")
|
|
143
|
-
and re.search(r"\b0?777\b", nl) and catastrophic):
|
|
144
|
-
return "deny", "recursive chmod 777 on a root path"
|
|
145
194
|
|
|
146
195
|
# ── ask: destructive-but-legit ──
|
|
147
196
|
if rm_rf:
|
|
@@ -325,12 +325,16 @@ def _run_tests(cmd: list[str], cwd: Path, timeout: int = 900) -> tuple[str, str]
|
|
|
325
325
|
return (f"could not run the suite ({type(exc).__name__})", shown)
|
|
326
326
|
|
|
327
327
|
blob = (r.stdout or "") + chr(10) + (r.stderr or "")
|
|
328
|
-
#
|
|
328
|
+
# The summary is the last line naming passed/failed/error. Case-insensitive, because Pester
|
|
329
|
+
# capitalises it (`Tests Passed: 22, Failed: 0`) — found 2026-09-10 in an all-Pester repo.
|
|
329
330
|
tail = [l.strip() for l in blob.splitlines()
|
|
330
|
-
if (
|
|
331
|
+
if any(w in l.lower() for w in ("passed", "failed", "error")) and "==" not in l[:2]]
|
|
331
332
|
if tail:
|
|
332
333
|
return (tail[-1], shown)
|
|
333
|
-
|
|
334
|
+
# Say WHAT was run and how to change it. A bare "no summary" read as "this repo has no tests"
|
|
335
|
+
# when the truth was "the tool and the repo disagree about how to run them".
|
|
336
|
+
return (f"no test summary from `{shown}` (exit {r.returncode}) — if this repo's tests are "
|
|
337
|
+
"not pytest, set [handover] test_cmd in .caddis/config.toml", shown)
|
|
334
338
|
|
|
335
339
|
|
|
336
340
|
def _test_command(root: Path) -> list[str]:
|
|
@@ -104,6 +104,9 @@ class DocFindings:
|
|
|
104
104
|
stale_prone: list[tuple[int, str]] = field(default_factory=list)
|
|
105
105
|
historical: list[tuple[int, str]] = field(default_factory=list)
|
|
106
106
|
missing_paths: list[str] = field(default_factory=list)
|
|
107
|
+
# Bare filenames that match NO file in this repo. Advisory: in one real handover every such
|
|
108
|
+
# name was a file in another repository, which no local check could ever satisfy.
|
|
109
|
+
unresolved_names: list[str] = field(default_factory=list)
|
|
107
110
|
# A THIRD refusing tier, kept separate from stale_prone so the message can say something
|
|
108
111
|
# different. A stale hash is an accident the successor will notice; a stored answer key
|
|
109
112
|
# defeats the exercise SILENTLY — it certifies a reader who understood nothing.
|
|
@@ -139,6 +142,18 @@ _INLINE_ANSWER = re.compile(r"\bQ\s*\d{1,2}\b.*?\b(?:A|Ans|Answer)\s*[:.\)]", re
|
|
|
139
142
|
|
|
140
143
|
_DOC_PATH = re.compile(r"`([^`\s<>{}*?]+?\.(?:md|py|ts|tsx|json|ya?ml|sh|ps1|html|toml|sql))`")
|
|
141
144
|
|
|
145
|
+
# A count QUOTED FROM an earlier document ("a previous handover said '1,066 tests'") is a citation,
|
|
146
|
+
# not a claim about now — found 2026-09-10, when the sentence warning the reader not to quote
|
|
147
|
+
# counts had to lose its example to pass. Quotation marks alone are NOT enough: `status: "312
|
|
148
|
+
# tests passed"` is a current claim in quotes (review catch). So three things must hold: the count
|
|
149
|
+
# sits inside a quoted span, a reporting verb comes BEFORE that span, and the line makes no
|
|
150
|
+
# now-claim. A single quote only opens a span at a word boundary, so "it's" cannot start one.
|
|
151
|
+
_QUOTED = re.compile(r"(?<!\w)'[^'\n]*'(?!\w)|\"[^\"\n]*\"|“[^”\n]*”|‘[^’\n]*’")
|
|
152
|
+
_REPORTED = re.compile(r"\b(?:said|says|claimed|claims|wrote|reported|quoted|stated)\b", re.I)
|
|
153
|
+
_NOW_CLAIM = re.compile(
|
|
154
|
+
r"\b(?:now|still|currently|current|today|at present|as of|remains?|unchanged|holds|stands"
|
|
155
|
+
r"|nothing has changed)\b", re.I)
|
|
156
|
+
|
|
142
157
|
|
|
143
158
|
def check_document(text: str, repo_root: Path | None = None) -> DocFindings:
|
|
144
159
|
"""Round 0: the parent validates its OWN handover before anyone reads it.
|
|
@@ -185,17 +200,42 @@ def check_document(text: str, repo_root: Path | None = None) -> DocFindings:
|
|
|
185
200
|
out.historical.append((n, f"commit hash `{m.group(0)}` — reads as a historical "
|
|
186
201
|
"citation, which is durable. Check that it is not "
|
|
187
202
|
"standing in for current state"))
|
|
203
|
+
cited = [] if _NOW_CLAIM.search(line) else [
|
|
204
|
+
(q.start(), q.end()) for q in _QUOTED.finditer(line)
|
|
205
|
+
if _REPORTED.search(line[:q.start()])]
|
|
188
206
|
for m in _COUNT.finditer(line):
|
|
207
|
+
if any(a <= m.start() and m.end() <= b for a, b in cited):
|
|
208
|
+
out.historical.append((n, f"`{m.group(0)}` quoted from an earlier document — "
|
|
209
|
+
"reads as a citation. Check it is not standing in for "
|
|
210
|
+
"current state"))
|
|
211
|
+
continue
|
|
189
212
|
out.stale_prone.append((n, f"`{m.group(0)}` stated as a fact — write the command "
|
|
190
213
|
"instead; this was wrong within the hour, twice"))
|
|
191
214
|
if repo_root is not None:
|
|
215
|
+
tracked: list[str] | None = None
|
|
192
216
|
for raw in sorted(set(_DOC_PATH.findall(text))):
|
|
193
217
|
cand = next((tok for tok in raw.split() if "/" in tok or tok.endswith(".md")), raw)
|
|
194
218
|
cand = cand.strip("(),;:'\"")
|
|
195
219
|
if cand.startswith(("http://", "https://", "~", "$")) or ".." in cand:
|
|
196
220
|
continue
|
|
197
|
-
if
|
|
221
|
+
if (repo_root / cand).exists():
|
|
222
|
+
continue
|
|
223
|
+
if "/" in cand or "\\" in cand:
|
|
198
224
|
out.missing_paths.append(cand)
|
|
225
|
+
continue
|
|
226
|
+
# A BARE filename. Block only when it names a file this repo has — that is the catch
|
|
227
|
+
# worth keeping (`deploy.ps1` named the canonical copy when the task was about another
|
|
228
|
+
# file). A name found nowhere is far more likely a file in another repository.
|
|
229
|
+
if tracked is None:
|
|
230
|
+
tracked = _run(["git", "ls-files"], repo_root).splitlines()
|
|
231
|
+
hits = [t for t in tracked if t.rsplit("/", 1)[-1] == cand]
|
|
232
|
+
if not hits:
|
|
233
|
+
out.unresolved_names.append(cand)
|
|
234
|
+
elif len(hits) == 1:
|
|
235
|
+
out.missing_paths.append(f"{cand} — did you mean `{hits[0]}`? Name the full path")
|
|
236
|
+
else:
|
|
237
|
+
out.missing_paths.append(f"{cand} — matches {len(hits)} files ("
|
|
238
|
+
+ ", ".join(hits[:3]) + "). Name the full path")
|
|
199
239
|
return out
|
|
200
240
|
|
|
201
241
|
|
|
@@ -256,6 +296,29 @@ class Preflight:
|
|
|
256
296
|
return not self.refusals
|
|
257
297
|
|
|
258
298
|
|
|
299
|
+
def in_flight_branches(repo_root: Path) -> list[str]:
|
|
300
|
+
"""Local branches holding commits the successor cannot see: never pushed, or ahead of upstream.
|
|
301
|
+
|
|
302
|
+
Found 2026-09-10: the parent had an unmerged branch correcting a plan the successor was told to
|
|
303
|
+
edit next. The successor caught it by luck — branching off main would have reverted the fix.
|
|
304
|
+
Empty when the repo has no remote, where every branch is local by design and naming them all
|
|
305
|
+
would be noise. Open PRs are NOT detected: that needs `gh` and the right account.
|
|
306
|
+
"""
|
|
307
|
+
if not _run(["git", "remote"], repo_root):
|
|
308
|
+
return []
|
|
309
|
+
out = _run(["git", "for-each-ref",
|
|
310
|
+
"--format=%(refname:short)|%(upstream:short)|%(upstream:track)", "refs/heads"],
|
|
311
|
+
repo_root)
|
|
312
|
+
found: list[str] = []
|
|
313
|
+
for line in out.splitlines():
|
|
314
|
+
name, up, track = (line.split("|") + ["", ""])[:3]
|
|
315
|
+
if not up:
|
|
316
|
+
found.append(f"{name} (never pushed)")
|
|
317
|
+
elif "ahead" in track:
|
|
318
|
+
found.append(f"{name} ({track.strip('[]')} of {up})")
|
|
319
|
+
return found
|
|
320
|
+
|
|
321
|
+
|
|
259
322
|
def preflight(repo_root: Path, context_pct: float | None = None) -> Preflight:
|
|
260
323
|
"""Conditions under which a spawn must not happen at all.
|
|
261
324
|
|
|
@@ -273,6 +336,12 @@ def preflight(repo_root: Path, context_pct: float | None = None) -> Preflight:
|
|
|
273
336
|
p.refusals.append(
|
|
274
337
|
f"{p.dirty} uncommitted file(s). The successor pulls, so uncommitted work is invisible "
|
|
275
338
|
"to it — it will redo the work or build on a state that does not exist. Commit first.")
|
|
339
|
+
flight = in_flight_branches(repo_root)
|
|
340
|
+
if flight:
|
|
341
|
+
p.notes.append(
|
|
342
|
+
"in-flight work the successor cannot see: " + ", ".join(flight) + ". Push each one, "
|
|
343
|
+
"name it in the relay, and say whether the successor must wait for it. Open PRs are "
|
|
344
|
+
"not detected here — list them yourself.")
|
|
276
345
|
|
|
277
346
|
d = repo_root / ART / "parking-lot"
|
|
278
347
|
if d.is_dir():
|
|
@@ -352,9 +421,22 @@ def fingerprint(repo_root: Path, with_tests: bool = False) -> dict:
|
|
|
352
421
|
"tests": "not measured",
|
|
353
422
|
}
|
|
354
423
|
if with_tests:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
424
|
+
# The inventory's runner, not a second copy of it. The copy that lived here hard-coded
|
|
425
|
+
# pytest and ran it through `_run`, which returns "" on a non-zero exit — so a Pester repo
|
|
426
|
+
# AND a red pytest suite both read "suite reported no summary" (found 2026-09-10).
|
|
427
|
+
# caddis_inventory had already fixed both: `[handover] test_cmd`, and a runner that keeps
|
|
428
|
+
# a failing suite's summary. The two files sit side by side in source and in the bundle.
|
|
429
|
+
# Loaded by FILE, not by name: a `caddis_inventory` already imported from another path
|
|
430
|
+
# would otherwise be silently reused, and sys.path would be changed for the whole process.
|
|
431
|
+
import importlib.util
|
|
432
|
+
spec = importlib.util.spec_from_file_location(
|
|
433
|
+
"_caddis_inventory_for_spawn", Path(__file__).resolve().parent / "caddis_inventory.py")
|
|
434
|
+
_inv = importlib.util.module_from_spec(spec)
|
|
435
|
+
# Registered BEFORE exec: `@dataclass` looks its module up in sys.modules while the class
|
|
436
|
+
# body runs, and fails with "'NoneType' object has no attribute '__dict__'" otherwise.
|
|
437
|
+
sys.modules[spec.name] = _inv
|
|
438
|
+
spec.loader.exec_module(_inv)
|
|
439
|
+
fp["tests"], _shown = _inv._run_tests(_inv._test_command(repo_root), repo_root)
|
|
358
440
|
return fp
|
|
359
441
|
|
|
360
442
|
|
|
@@ -398,6 +480,26 @@ def verify_answerable(repo_root: Path, answer_in: str, needle: str) -> tuple[boo
|
|
|
398
480
|
PARENT_RELAY = "parent-relay.md"
|
|
399
481
|
PARENT_STATE = "parent-session-state.md"
|
|
400
482
|
|
|
483
|
+
# One task per line, `- [ ]` open and `- [x]` done. Counted so the successor's read-back can be
|
|
484
|
+
# checked against the file instead of taken on trust.
|
|
485
|
+
_TASK_LINE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]", re.M)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def count_open_tasks(repo_root: Path) -> int | None:
|
|
489
|
+
"""Open checkbox items in the parent's task list, or None when there is nothing to count.
|
|
490
|
+
|
|
491
|
+
None covers "no file" and "a file with no checkbox lines". Either way there is nothing to
|
|
492
|
+
compare a reported count with, and refusing would demand a format the parent never used.
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
text = (repo_root / ART / PARENT_STATE).read_text(encoding="utf-8", errors="ignore")
|
|
496
|
+
except OSError:
|
|
497
|
+
return None
|
|
498
|
+
marks = _TASK_LINE.findall(text)
|
|
499
|
+
if not marks:
|
|
500
|
+
return None
|
|
501
|
+
return sum(1 for m in marks if m == " ")
|
|
502
|
+
|
|
401
503
|
|
|
402
504
|
def _mtime(path: Path) -> float | None:
|
|
403
505
|
try:
|
|
@@ -428,18 +530,24 @@ def capture_order(repo_root: Path, spawn_id: str) -> tuple[list[str], list[str]]
|
|
|
428
530
|
return refusals, notes
|
|
429
531
|
|
|
430
532
|
if t_state is None:
|
|
431
|
-
notes.append(f"no {ART}/{PARENT_STATE}.
|
|
432
|
-
"
|
|
433
|
-
"handover rather than leaving the
|
|
533
|
+
notes.append(f"no {ART}/{PARENT_STATE}. Task state is per-session — the successor's "
|
|
534
|
+
"tracker starts empty, so this file is the only way the list reaches it. If "
|
|
535
|
+
"the list is genuinely empty, SAY so in the handover rather than leaving the "
|
|
536
|
+
"reader to infer it.")
|
|
434
537
|
elif t_state > t_relay:
|
|
435
538
|
refusals.append(f"{PARENT_STATE} is NEWER than {PARENT_RELAY}. The relay cites the task "
|
|
436
539
|
"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."
|
|
540
|
+
"that never existed. Rewrite the relay, do not re-touch the list. If you "
|
|
541
|
+
"corrected the list on purpose, re-read the relay against it and re-save "
|
|
542
|
+
"the relay — a save that only moves its timestamp is the sanctioned fix.")
|
|
438
543
|
|
|
439
544
|
if t_relay > t_prompt:
|
|
440
545
|
refusals.append(f"{PARENT_RELAY} is NEWER than the prompt. The questions must target what "
|
|
441
546
|
"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."
|
|
547
|
+
"they test your memory of it, which is the thing under suspicion. If you "
|
|
548
|
+
"corrected the relay on purpose, re-check the questions against it and "
|
|
549
|
+
"re-save the prompt — a save that only moves its timestamp is the "
|
|
550
|
+
"sanctioned fix.")
|
|
443
551
|
|
|
444
552
|
# Anything the relay quotes that moved after it was written.
|
|
445
553
|
for sub_dir in ("kb", "parking-lot"):
|
|
@@ -530,6 +638,10 @@ def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto",
|
|
|
530
638
|
# exist yet. The parent fills it in from the `from-name` of the child's first message,
|
|
531
639
|
# which is the only moment either side reliably knows that name.
|
|
532
640
|
"peer": peer,
|
|
641
|
+
# Tells the caddis guard to refuse tree-moving git commands until `close` stamps
|
|
642
|
+
# `closed_at`. An explicit flag, so handshake files written before the lock existed —
|
|
643
|
+
# which never got a `closed_at` — do not suddenly lock a tree.
|
|
644
|
+
"tree_lock": True,
|
|
533
645
|
"opened_at": int(time.time()),
|
|
534
646
|
"history": [],
|
|
535
647
|
}
|
|
@@ -546,14 +658,59 @@ def handshake_read(repo_root: Path, spawn_id: str) -> dict | None:
|
|
|
546
658
|
return None
|
|
547
659
|
|
|
548
660
|
|
|
661
|
+
MAX_REJECTS = 2
|
|
662
|
+
|
|
663
|
+
|
|
549
664
|
def handshake_record(repo_root: Path, spawn_id: str, event: str,
|
|
550
|
-
peer: str = ""
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
665
|
+
peer: str = "", verdict: str = "",
|
|
666
|
+
revived: int | None = None) -> tuple[dict | None, str]:
|
|
667
|
+
"""Advance the handshake. One step forward, with exactly one named exception.
|
|
668
|
+
|
|
669
|
+
**REJECT sends it back to `awaiting-answers`, and that is the whole point of this function
|
|
670
|
+
having a rewind at all.** `spawn-session.md` defines REJECT as "an instruction to re-read named
|
|
671
|
+
sections and re-answer", and says "two REJECTs on one spawn means the HANDOVER is at fault —
|
|
672
|
+
regenerate it". Neither was possible until 2026-09-10: the states were strictly linear, so
|
|
673
|
+
after `verdict-sent` the only legal event was `ack`, and the handshake did not even record
|
|
674
|
+
WHICH verdict was sent. ACCEPT and REJECT were indistinguishable in the state file, so nothing
|
|
675
|
+
could count REJECTs and the two-strike rule was unenforceable prose.
|
|
676
|
+
|
|
677
|
+
Every other rewind is still refused. An out-of-order event is an operator who has lost track of
|
|
678
|
+
the round, which is exactly when a silent overwrite would hurt.
|
|
679
|
+
"""
|
|
554
680
|
state = handshake_read(repo_root, spawn_id)
|
|
555
681
|
if state is None:
|
|
556
682
|
return None, f"no handshake for {spawn_id} — run `handshake open --id {spawn_id}` first"
|
|
683
|
+
|
|
684
|
+
verdict = (verdict or "").strip().upper()
|
|
685
|
+
if event == "verdict" and verdict == "REJECT":
|
|
686
|
+
if state["state"] != "answered":
|
|
687
|
+
return state, (f"at `{state['state']}` — a REJECT only follows `answered`. "
|
|
688
|
+
"There is nothing to reject yet.")
|
|
689
|
+
n = int(state.get("rejects", 0)) + 1
|
|
690
|
+
if n > MAX_REJECTS - 1:
|
|
691
|
+
state["rejects"] = n
|
|
692
|
+
# Recorded even though it is refused. The counter is the enforceable fact, but a
|
|
693
|
+
# history showing one REJECT beside a count of two is a state file that contradicts
|
|
694
|
+
# itself, and the next reader has to work out which half to believe.
|
|
695
|
+
state["history"].append({"event": "verdict", "verdict": "REJECT", "refused": True,
|
|
696
|
+
"at": int(time.time()),
|
|
697
|
+
**({"peer": peer} if peer else {})})
|
|
698
|
+
_handshake_path(repo_root, spawn_id).write_text(
|
|
699
|
+
json.dumps(state, indent=2) + chr(10), encoding="utf-8")
|
|
700
|
+
return state, (
|
|
701
|
+
f"REJECT #{n}. `spawn-session.md`: two REJECTs on one spawn means the HANDOVER is "
|
|
702
|
+
"at fault, not the successor. Regenerate it — do not coach the child through a "
|
|
703
|
+
"third round. Close this handshake and open a new one on a rewritten handover.")
|
|
704
|
+
state["rejects"] = n
|
|
705
|
+
state["state"] = "awaiting-answers"
|
|
706
|
+
state["history"].append({"event": "verdict", "verdict": "REJECT", "at": int(time.time()),
|
|
707
|
+
**({"peer": peer} if peer else {})})
|
|
708
|
+
if peer:
|
|
709
|
+
state["peer"] = peer
|
|
710
|
+
_handshake_path(repo_root, spawn_id).write_text(
|
|
711
|
+
json.dumps(state, indent=2) + chr(10), encoding="utf-8")
|
|
712
|
+
return state, ""
|
|
713
|
+
|
|
557
714
|
target = _EVENT_STATE[event]
|
|
558
715
|
here = HANDSHAKE_STATES.index(state["state"])
|
|
559
716
|
there = HANDSHAKE_STATES.index(target)
|
|
@@ -568,9 +725,28 @@ def handshake_record(repo_root: Path, spawn_id: str, event: str,
|
|
|
568
725
|
+ ("skip a step" if there > here + 1 else "move backwards")
|
|
569
726
|
+ " — refusing rather than rewriting, because a skipped step here is a "
|
|
570
727
|
"gate that never ran.")
|
|
728
|
+
# The task list crosses the session boundary only as a file. Task state is per-session, so the
|
|
729
|
+
# successor's tracker starts empty — measured 2026-09-10: a parent board of 59 tasks, and the
|
|
730
|
+
# child's `TaskList` said "No tasks found". So the read-back must say how many items the child
|
|
731
|
+
# rebuilt from the file. Required on `readback` (spawn-session's parent-first flow). Optional
|
|
732
|
+
# on child-first `answers`, because spawn-hub shares that flow and hands over a role, not a list.
|
|
733
|
+
if event == "readback" and revived is None:
|
|
734
|
+
return state, (f"a read-back must say how many open tasks the successor rebuilt from "
|
|
735
|
+
f"{ART}/{PARENT_STATE} into its own tracker. Ask for the number, then record "
|
|
736
|
+
"it with `--revived <n>` (0 is a valid answer when the list is empty).")
|
|
737
|
+
if revived is not None:
|
|
738
|
+
expected = count_open_tasks(repo_root)
|
|
739
|
+
if expected is not None and revived != expected:
|
|
740
|
+
return state, (f"the successor reports {revived} revived task(s), but "
|
|
741
|
+
f"{ART}/{PARENT_STATE} has {expected} open. A successor that revives "
|
|
742
|
+
"fewer has dropped some silently. Ask it to re-read the file and "
|
|
743
|
+
"report again.")
|
|
744
|
+
state["revived"] = revived
|
|
571
745
|
if peer:
|
|
572
746
|
state["peer"] = peer
|
|
573
747
|
state["history"].append({"event": event, "at": int(time.time()),
|
|
748
|
+
**({"verdict": verdict} if event == "verdict" and verdict else {}),
|
|
749
|
+
**({"revived": revived} if revived is not None else {}),
|
|
574
750
|
**({"peer": peer} if peer else {})})
|
|
575
751
|
state["state"] = target
|
|
576
752
|
_handshake_path(repo_root, spawn_id).write_text(
|
|
@@ -632,7 +808,13 @@ def handshake_close(repo_root: Path, spawn_id: str) -> tuple[bool, str]:
|
|
|
632
808
|
"ALIVE until the child confirms it has the verdict — closing on an "
|
|
633
809
|
"unanswered handshake is the same as never running one. (It may stop "
|
|
634
810
|
"WRITING to the repo now; that is a different rule and both hold.)")
|
|
635
|
-
|
|
811
|
+
# Stamp it. The guard's shared-tree lock holds while a handshake is open and lifts on this.
|
|
812
|
+
if not state.get("closed_at"):
|
|
813
|
+
state["closed_at"] = int(time.time())
|
|
814
|
+
_handshake_path(repo_root, spawn_id).write_text(
|
|
815
|
+
json.dumps(state, indent=2) + chr(10), encoding="utf-8")
|
|
816
|
+
return True, ("acknowledged — closed. Commit and push the handshake file, send the child one "
|
|
817
|
+
"last message saying you are stopping, then EXIT (see \"The parent's ending\")")
|
|
636
818
|
|
|
637
819
|
|
|
638
820
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -659,10 +841,20 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
659
841
|
h.add_argument("--id", required=True)
|
|
660
842
|
h.add_argument("--repo-root", default=".")
|
|
661
843
|
h.add_argument("--event", choices=sorted(_EVENT_STATE))
|
|
844
|
+
h.add_argument("--verdict", default="",
|
|
845
|
+
choices=["", "ACCEPT", "ACCEPT-WITH-CORRECTION", "REJECT"],
|
|
846
|
+
help="with `--event verdict`. REJECT returns the handshake to "
|
|
847
|
+
"`awaiting-answers` so the child re-reads and re-answers, and counts the "
|
|
848
|
+
"strike; the second REJECT refuses and tells you to regenerate the "
|
|
849
|
+
"handover instead of coaching the child through a third round")
|
|
662
850
|
h.add_argument("--transport", choices=["auto", "message", "paste"], default="auto")
|
|
663
851
|
h.add_argument("--peer", default="",
|
|
664
852
|
help="the child's name as ListAgents shows it — take it from the `from-name` "
|
|
665
853
|
"of its first message; that is the only moment it is reliably known")
|
|
854
|
+
h.add_argument("--revived", type=int, default=None,
|
|
855
|
+
help="with `--event readback` (required) or `--event answers` (optional): how "
|
|
856
|
+
"many open tasks the successor rebuilt from parent-session-state.md. "
|
|
857
|
+
"Checked against the file's `- [ ]` lines")
|
|
666
858
|
q = sub.add_parser("verify-question")
|
|
667
859
|
q.add_argument("--answer-in", required=True)
|
|
668
860
|
q.add_argument("--needle", required=True)
|
|
@@ -695,6 +887,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
695
887
|
f = check_document(doc.read_text(encoding="utf-8", errors="ignore"), root)
|
|
696
888
|
for n, msg in f.historical:
|
|
697
889
|
print(f" note line {n}: {msg}")
|
|
890
|
+
for name in f.unresolved_names:
|
|
891
|
+
print(f" note `{name}` matches no file in this repo — fine if it lives elsewhere; "
|
|
892
|
+
"give the full path if it is here")
|
|
698
893
|
if f.ok():
|
|
699
894
|
print(f"[spawn] {doc.name} passes round 0 — nothing stale-prone, every path exists")
|
|
700
895
|
return EXIT_OK
|
|
@@ -734,8 +929,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
734
929
|
f"({st['transport_reason']}), state `{st['state']}`")
|
|
735
930
|
if st["state"] == HANDSHAKE_STATES[0]:
|
|
736
931
|
print(f" PARENT-FIRST. You have the address, so message \"{st['peer']}\" now: "
|
|
737
|
-
"tell it what to read, then ask it to reply with WHICH FILES it opened "
|
|
738
|
-
"
|
|
932
|
+
"tell it what to read, then ask it to reply with WHICH FILES it opened, and "
|
|
933
|
+
f"how many open tasks it rebuilt from {ART}/{PARENT_STATE}, before it "
|
|
934
|
+
"answers anything. Record that with `--event readback --revived <n>`.")
|
|
739
935
|
elif st["transport"] == "message" and not st["peer"]:
|
|
740
936
|
print(" No --peer given, so this is the child-first flow: print the prompt and "
|
|
741
937
|
"wait. If you already started the child, re-open with `--peer <its "
|
|
@@ -763,11 +959,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
763
959
|
if not a.event:
|
|
764
960
|
sys.stderr.write("[spawn] --event is required for `record`" + chr(10))
|
|
765
961
|
return EXIT_REFUSED
|
|
766
|
-
st, err = handshake_record(root, a.id, a.event, a.peer)
|
|
962
|
+
st, err = handshake_record(root, a.id, a.event, a.peer, a.verdict, a.revived)
|
|
767
963
|
if err:
|
|
768
964
|
sys.stderr.write("[spawn] " + err + chr(10))
|
|
769
965
|
return EXIT_REFUSED
|
|
770
|
-
|
|
966
|
+
rejects = int((st or {}).get("rejects", 0))
|
|
967
|
+
tail = f" (REJECT #{rejects} — re-read and re-answer)" if \
|
|
968
|
+
(a.verdict.upper() == "REJECT" and rejects) else ""
|
|
969
|
+
print(f"[spawn] {a.id}: {st['state']}{tail}")
|
|
771
970
|
return EXIT_OK
|
|
772
971
|
if a.action == "close" and not (st.get("peer") or "").strip() \
|
|
773
972
|
and st["state"] != HANDSHAKE_STATES[0]:
|
|
@@ -192,7 +192,7 @@ def extract_docmap_entries(docmap_text: str) -> set[str]:
|
|
|
192
192
|
# the other; the instruction file carries the mapping. Legacy synonyms included so old docs stay quiet.
|
|
193
193
|
TRUST_STATUS_VALUES: frozenset[str] = frozenset({
|
|
194
194
|
"draft", "stable", "deprecated", # OKF v0.2
|
|
195
|
-
"current", "done", "superseded", "ready",
|
|
195
|
+
"current", "done", "superseded", "ready", "proposed", # caddis
|
|
196
196
|
"shipped", "implemented", # caddis legacy synonyms for `done`
|
|
197
197
|
"open", "doing", "dropped", # caddis parking-lot (`done` shared above)
|
|
198
198
|
})
|
|
@@ -675,6 +675,43 @@ def insert_table_rows(text: str, heading_contains: str, rows: list[str]) -> str:
|
|
|
675
675
|
return out + "\n" if text.endswith("\n") else out # preserve trailing newline (git-clean)
|
|
676
676
|
|
|
677
677
|
|
|
678
|
+
# A link to a SIBLING note — `[x](x.md)` or `[x](./x.md)` — is what marks a table as the KB index.
|
|
679
|
+
# A link that climbs out (`../../docs/guide.md`) marks a table of other docs, which must not get
|
|
680
|
+
# KB rows appended to it.
|
|
681
|
+
_SIBLING_NOTE_LINK = re.compile(r"\]\((?:\./)?[^/()\s]+\.md\)")
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def insert_into_note_table(text: str, rows: list[str]) -> str:
|
|
685
|
+
"""Append ``rows`` to the first table that already links sibling KB notes, whatever its heading.
|
|
686
|
+
|
|
687
|
+
The fallback for a DOC-MAP written by hand before the scaffold existed. Found 2026-09-10 in
|
|
688
|
+
a consumer repo: a working 15-row table with no ``## Knowledge base`` heading, invisible to
|
|
689
|
+
``insert_table_rows``, so every new note had to be indexed by hand. Returns ``text`` unchanged
|
|
690
|
+
when no such table exists. Pure.
|
|
691
|
+
"""
|
|
692
|
+
if not rows:
|
|
693
|
+
return text
|
|
694
|
+
lines = text.splitlines()
|
|
695
|
+
i = 0
|
|
696
|
+
while i < len(lines):
|
|
697
|
+
if not lines[i].strip().startswith("|"):
|
|
698
|
+
i += 1
|
|
699
|
+
continue
|
|
700
|
+
start = i
|
|
701
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
702
|
+
i += 1
|
|
703
|
+
end = i - 1
|
|
704
|
+
if end - start < 1 or set(lines[start + 1].strip()) - set("|-: "):
|
|
705
|
+
continue # no |---| separator: not a table
|
|
706
|
+
first_cells = [r.split("|")[1] for r in lines[start + 2:end + 1] if r.count("|") > 1]
|
|
707
|
+
if any(_SIBLING_NOTE_LINK.search(c) for c in first_cells):
|
|
708
|
+
head = lines[start:start + 2]
|
|
709
|
+
body = [b for b in lines[start + 2:end + 1] if not _is_placeholder_row(b)]
|
|
710
|
+
out = "\n".join(lines[:start] + head + body + rows + lines[end + 1:])
|
|
711
|
+
return out + "\n" if text.endswith("\n") else out
|
|
712
|
+
return text
|
|
713
|
+
|
|
714
|
+
|
|
678
715
|
def build_docmap(root: Path, project_name: str = "project") -> str:
|
|
679
716
|
"""Full DOC-MAP text for a fresh repo: scaffold + discovered reference docs + existing KB notes.
|
|
680
717
|
|
|
@@ -779,7 +816,10 @@ def reindex(
|
|
|
779
816
|
# Index orphan KB notes (additive). Report success ONLY if the rows actually landed — insert is a
|
|
780
817
|
# no-op when the "Knowledge base" heading/table is absent, and we must not claim work we didn't do.
|
|
781
818
|
if orphans:
|
|
782
|
-
|
|
819
|
+
new_rows = kb_note_rows(root, entries)
|
|
820
|
+
after = insert_table_rows(updated, "Knowledge base", new_rows)
|
|
821
|
+
if after == updated:
|
|
822
|
+
after = insert_into_note_table(updated, new_rows) # a hand-made map, no heading
|
|
783
823
|
if after != updated:
|
|
784
824
|
updated = after
|
|
785
825
|
summary.append(f"indexed {len(orphans)} KB note(s): " + ", ".join(orphans))
|
|
@@ -886,6 +926,20 @@ def run(root: Path, check: bool) -> int:
|
|
|
886
926
|
return 0
|
|
887
927
|
|
|
888
928
|
|
|
929
|
+
def run_reindex(root: Path, prune: bool = False) -> int:
|
|
930
|
+
"""Run the reindex, print its summary, and return the exit code.
|
|
931
|
+
|
|
932
|
+
Non-zero when any part of the work was NOT done — a note it could not index, or a DOC-MAP it
|
|
933
|
+
could not write. Until 2026-09-10 this always exited 0, so a partial success looked exactly
|
|
934
|
+
like a full one and a repo could sit with unindexed notes indefinitely.
|
|
935
|
+
"""
|
|
936
|
+
root = Path(root)
|
|
937
|
+
_changed, summary = reindex(root, root.name, prune=prune)
|
|
938
|
+
for line in summary:
|
|
939
|
+
print(f"[kb] {line}")
|
|
940
|
+
return 1 if any(s.startswith(("could not", "WARNING")) for s in summary) else 0
|
|
941
|
+
|
|
942
|
+
|
|
889
943
|
def main() -> None:
|
|
890
944
|
if hasattr(sys.stdout, "reconfigure"):
|
|
891
945
|
sys.stdout.reconfigure(encoding="utf-8") # Windows cp1252 can't encode some doc names
|
|
@@ -909,12 +963,10 @@ def main() -> None:
|
|
|
909
963
|
)
|
|
910
964
|
args = parser.parse_args()
|
|
911
965
|
root = _repo_root()
|
|
912
|
-
# Reindex/prune are maintenance actions (the `/kb` command drives them), not a gate —
|
|
966
|
+
# Reindex/prune are maintenance actions (the `/kb` command drives them), not a gate — but they
|
|
967
|
+
# still exit non-zero when part of the work could not be done.
|
|
913
968
|
if args.reindex or args.prune:
|
|
914
|
-
|
|
915
|
-
for line in summary:
|
|
916
|
-
print(f"[kb] {line}")
|
|
917
|
-
sys.exit(0)
|
|
969
|
+
sys.exit(run_reindex(root, prune=args.prune))
|
|
918
970
|
# Resolve the repo root from git (cwd fallback) — the pre-push gate runs from the repo root,
|
|
919
971
|
# so this is correct wherever the checker is copied. Tests call run() with an explicit root.
|
|
920
972
|
sys.exit(run(root, args.check))
|
|
@@ -699,6 +699,16 @@ repo**: an access request, a decision from another team, a change only someone e
|
|
|
699
699
|
table stops a sent message from going unchased. Comms are almost always sent to get an *action*, and
|
|
700
700
|
an unanswered ask looks exactly like a forgotten one unless something is tracking it.
|
|
701
701
|
|
|
702
|
+
**Read that rule the other way round: THIS TABLE IS WHERE YOU LOOK BEFORE YOU WRITE ONE.** Check it
|
|
703
|
+
before drafting, not only after filing. A handover transfers a task, and for the whole overlap
|
|
704
|
+
window two sessions can both believe an outbound note is theirs - on 2026-09-08 two of them wrote
|
|
705
|
+
the same note five minutes apart, and both sat here marked ready to send. Two notes on one subject
|
|
706
|
+
reaching one team reads as one team not talking to itself.
|
|
707
|
+
|
|
708
|
+
**If you do find a duplicate, do not delete it.** Verify its unique contributions independently,
|
|
709
|
+
fold them into the survivor, and archive the loser with a banner saying why. The second draft is a
|
|
710
|
+
second reading - the one in that incident found a defect in the first.
|
|
711
|
+
|
|
702
712
|
| Raised | Audience | Subject | Status | Blocks | File |
|
|
703
713
|
|---|---|---|---|---|---|
|
|
704
714
|
| _(none yet)_ | | | | | |
|
|
@@ -21,6 +21,10 @@ It is **additive and safe** — never deletes your rows:
|
|
|
21
21
|
- **Missing map** → creates `.caddis/kb/DOC-MAP.md` from a scaffold, pre-linking the repo's obvious
|
|
22
22
|
reference docs (README, `docs/…`) and any existing `.caddis/kb/*.md` notes.
|
|
23
23
|
- **Existing map** → indexes any KB note that isn't yet linked (adds a row with a placeholder description).
|
|
24
|
+
It appends to the `## Knowledge base` table, or — in a map written by hand with no such heading —
|
|
25
|
+
to the first table that already links sibling notes (`[x](x.md)`).
|
|
26
|
+
- **Could not index a note** (no table of notes anywhere) → says so and **exits 1**. Report it; do not
|
|
27
|
+
treat the run as done.
|
|
24
28
|
- **Dangling links** (a linked file that's gone) → **reported, not removed** — handle them in Step 3.
|
|
25
29
|
|
|
26
30
|
## Step 3 — finish by hand
|
|
@@ -49,7 +49,10 @@ human to carry, which meant the child did not exist yet and its address could no
|
|
|
49
49
|
first message was itself proof it had read the handover. Here the parent speaks first, so that
|
|
50
50
|
proof is a separate step: **your opening message asks the child to reply with WHICH FILES it
|
|
51
51
|
opened, before it answers anything.** Ask for paths — a summary can be written without opening
|
|
52
|
-
anything.
|
|
52
|
+
anything. **Ask also how many open tasks it rebuilt** from `.caddis/parent-session-state.md` into
|
|
53
|
+
its own tracker, whatever tracker it has. Record both with `--event readback --revived <n>`. The
|
|
54
|
+
handshake refuses a read-back with no count, and a count that disagrees with the file's `- [ ]`
|
|
55
|
+
lines. Until the read-back is recorded, it refuses `--event answers`.
|
|
53
56
|
|
|
54
57
|
**No name given?** The command falls back to the older printed-prompt flow. That is also the only
|
|
55
58
|
route on agy and Codex, which have no `SendMessage`.
|
|
@@ -67,7 +70,7 @@ and the active plan).
|
|
|
67
70
|
|---|---|---|
|
|
68
71
|
| 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 |
|
|
69
72
|
| 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 |
|
|
70
|
-
| 3 | **Write the TASK LIST to a file**
|
|
73
|
+
| 3 | **Write the TASK LIST to a file** — one task per line, `- [ ]` open, `- [x]` done | **Task state is per-session.** The successor's tracker starts empty: a parent board held 59 tasks and the child's `TaskList` said "No tasks found". The child may not even have the same tracker tool. The file is the only medium both sessions can read, so never tell the successor it shares your board |
|
|
71
74
|
| 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 |
|
|
72
75
|
| 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 |
|
|
73
76
|
|
|
@@ -170,9 +173,19 @@ or from a file quoted by path.
|
|
|
170
173
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" check --doc .caddis/spawn-session/<id>-prompt.md
|
|
171
174
|
```
|
|
172
175
|
|
|
173
|
-
It **blocks** on a commit hash or test count given as current state, and on
|
|
174
|
-
exist. It only **notes**
|
|
175
|
-
|
|
176
|
+
It **blocks** on a commit hash or test count given as current state, and on a path that does not
|
|
177
|
+
exist. It only **notes** three things:
|
|
178
|
+
|
|
179
|
+
- a historical hash — durable;
|
|
180
|
+
- a count quoted from an earlier document — `a previous handover said "1,066 tests"` is a citation,
|
|
181
|
+
not a claim. It needs a reporting verb before the quote and no now-claim on the line; quotation
|
|
182
|
+
marks alone still block;
|
|
183
|
+
- a bare filename that matches no file in this repo — usually a file in another repository.
|
|
184
|
+
|
|
185
|
+
A bare filename that DOES match a file here still blocks, and the refusal names the full path. That
|
|
186
|
+
is the case that caught a real error: `deploy.ps1` named the canonical copy when the task was about
|
|
187
|
+
a different file. Flagging citations was how the first version produced seven findings on one
|
|
188
|
+
document and taught the reader to skim.
|
|
176
189
|
|
|
177
190
|
**A claim that cannot be re-derived does not go in the handover.**
|
|
178
191
|
|
|
@@ -300,18 +313,54 @@ a handover, it is a file — and grading RE-DERIVES, so it audits the document a
|
|
|
300
313
|
reader. In the manual run that pass found a defect in the parent's own handover. `/clear` in your
|
|
301
314
|
terminal destroys the only thing that can do that.
|
|
302
315
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
316
|
+
### Grading goes to a fresh subagent — not to you
|
|
317
|
+
|
|
318
|
+
**This command's trigger is a nearly-full context, and grading is its most expensive step.** Round 1
|
|
319
|
+
re-derives every answer from the repository, because no answer key is stored. In the first real run
|
|
320
|
+
that was about 30 tool calls, and round 2 repeated it for the delivered work. A parent near its limit
|
|
321
|
+
cannot finish that. It runs out mid-grade, while the successor waits for a verdict.
|
|
322
|
+
|
|
323
|
+
Grading never needed your memory. It needs the repo, the questions and the child's answers. So send
|
|
324
|
+
each round to a **fresh subagent** and keep only its verdict:
|
|
325
|
+
|
|
326
|
+
> Grade `.caddis/spawn-session/<id>-answers.md` against the questions in
|
|
327
|
+
> `.caddis/spawn-session/<id>-prompt.md`. Re-derive every answer from the repository — run the
|
|
328
|
+
> commands, open the files — and do not trust the child's citations. Return ACCEPT, ACCEPT WITH
|
|
329
|
+
> CORRECTIONS or REJECT, and for each wrong answer the file or command that proves it. List
|
|
330
|
+
> separately any error you find in the handover documents themselves.
|
|
331
|
+
|
|
332
|
+
Use a read-only agent that can open files and run commands (`caddis:anchor` fits). You relay the
|
|
333
|
+
verdict, record it, and fix any handover document it proves wrong. On a runtime with no subagents,
|
|
334
|
+
grade in a fresh session and say so. Never grade from a nearly-full context.
|
|
335
|
+
|
|
336
|
+
**ALIVE until `handshake close`; not WRITING, with two named exceptions.** `/caddis:spawn-hub`
|
|
337
|
+
carries a single-writer rule, because two sessions committed to one repo concurrently and one's work
|
|
338
|
+
landed inside the other's commit. Here it has exactly two exceptions: your own handover artefacts,
|
|
339
|
+
and a document a verdict proves wrong. **Commit and push each one before you reply.** Never hold an
|
|
340
|
+
uncommitted change while the child is live, because a checkout on its side can take it with no copy
|
|
341
|
+
anywhere.
|
|
308
342
|
|
|
309
343
|
Note the handshake file lands in `.caddis/spawn-session/`, so commit it with the others before
|
|
310
344
|
re-running `preflight`, which refuses on a dirty tree.
|
|
311
345
|
|
|
312
|
-
###
|
|
346
|
+
### The tree is never handed over while two sessions are attached
|
|
347
|
+
|
|
348
|
+
**Stopping writing does not transfer the workspace.** Never tell the child "the tree is yours now".
|
|
349
|
+
That sentence describes your intent at one moment; it is not a property of the tree. In the first
|
|
350
|
+
real run the successor read it as permission and switched the shared checkout within the hour.
|
|
351
|
+
|
|
352
|
+
**The guard enforces this in Claude Code.** While a handshake in `.caddis/spawn-session/` is open,
|
|
353
|
+
the caddis guard denies `git switch`, `checkout`, `restore`, `stash`, `reset`, `rebase` and `clean`
|
|
354
|
+
in either session, and names the worktree command instead. The lock lifts at `handshake close`. A
|
|
355
|
+
handshake older than 24 hours is treated as abandoned and does not hold it. The agy guard does not
|
|
356
|
+
carry this lock yet, so on agy the prompt below is the only protection.
|
|
357
|
+
|
|
358
|
+
If the child needs another branch before close, it adds a worktree **at that moment**, not at spawn,
|
|
359
|
+
and removes it when the work lands. Leftover worktrees already pile up on this machine, and one made
|
|
360
|
+
eagerly adds to the pile. One consumer repo's `AGENTS.md` records that `git worktree remove --force`
|
|
361
|
+
can follow a junction and delete its target, so check a worktree for junctions before removing it.
|
|
313
362
|
|
|
314
|
-
**
|
|
363
|
+
**Still say it in the prompt.** A fresh session's instinct on seeing a dirty tree is to tidy it, and
|
|
315
364
|
you are still in that tree.
|
|
316
365
|
|
|
317
366
|
> While both sessions are live, do **not** run `git checkout`, `switch`, `stash`, `reset`,
|
|
@@ -331,7 +380,7 @@ See `.caddis/kb/shared-worktree-branch-switch.md` for detection and the recovery
|
|
|
331
380
|
Three lines — messaged directly, or carried by the user on the paste route:
|
|
332
381
|
|
|
333
382
|
```
|
|
334
|
-
SPAWN <id> | head <sha> | <n> tests | answers in .caddis/spawn-session/<id>-answers.md
|
|
383
|
+
SPAWN <id> | head <sha> | <n> tests | <n> tasks revived | answers in .caddis/spawn-session/<id>-answers.md
|
|
335
384
|
Q1-Q6 answered with citations. <n> commands run. <n> facts I could not find in a file.
|
|
336
385
|
<one line: the single most important thing I believe about the current state>
|
|
337
386
|
```
|
|
@@ -360,10 +409,25 @@ The vocabulary matches `/caddis:validate-phase` on purpose: same discipline, dif
|
|
|
360
409
|
**Two REJECTs on one spawn means the HANDOVER is at fault, not the successor. Regenerate it** rather
|
|
361
410
|
than coaching the child through it.
|
|
362
411
|
|
|
412
|
+
**Record the verdict — the machine enforces both of those rules only if you tell it which one:**
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake record --id <id> --event verdict --verdict REJECT
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
A REJECT returns the handshake to `awaiting-answers`, so `record --event answers` is legal again
|
|
419
|
+
and the child re-answers. The **second** REJECT is refused: it tells you to regenerate the handover,
|
|
420
|
+
and the only way forward is then ACCEPT or abandoning the handshake.
|
|
421
|
+
|
|
422
|
+
Until 2026-09-10 `--verdict` did not exist. The states were strictly linear, so after `verdict-sent`
|
|
423
|
+
the only legal event was `ack`, and nothing recorded WHICH verdict was sent — ACCEPT and REJECT were
|
|
424
|
+
indistinguishable in the state file. So "re-read and re-answer" was impossible and "two REJECTs
|
|
425
|
+
means regenerate" was unenforceable, because nothing counted them.
|
|
426
|
+
|
|
363
427
|
### When the fault is yours, fix the class
|
|
364
428
|
|
|
365
429
|
If validation finds an error that came from your handover, **fix the source document in the same
|
|
366
|
-
turn** — and fix the class, not the instance. In the manual run a successor found a stale commit
|
|
430
|
+
turn, and commit and push it before you reply** — and fix the class, not the instance. In the manual run a successor found a stale commit
|
|
367
431
|
hash; the fix was not a fresher hash, it was to **remove hashes from both documents** and write the
|
|
368
432
|
command instead. A handover patched per-instance goes stale again on the next commit.
|
|
369
433
|
|
|
@@ -387,8 +451,8 @@ Round 1's verdict sets round 2's size:
|
|
|
387
451
|
| ACCEPT WITH CORRECTIONS | a **small, reversible** item that writes nothing permanent; review closely |
|
|
388
452
|
| REJECT | no task — re-read and re-answer |
|
|
389
453
|
|
|
390
|
-
Review the result **by re-deriving it**, exactly as you reviewed the answers —
|
|
391
|
-
successor's account of it.
|
|
454
|
+
Review the result **by re-deriving it**, exactly as you reviewed the answers — through a fresh
|
|
455
|
+
subagent, not by reading the successor's account of it.
|
|
392
456
|
|
|
393
457
|
### Then stop gating
|
|
394
458
|
|
|
@@ -399,6 +463,27 @@ to remove.
|
|
|
399
463
|
|
|
400
464
|
---
|
|
401
465
|
|
|
466
|
+
## The parent's ending
|
|
467
|
+
|
|
468
|
+
The first real run had no ending. The parent stayed live for hours after the handover: it graded
|
|
469
|
+
twice, corrected four documents, merged two PRs and relayed decisions — still paying the token cost
|
|
470
|
+
the handover exists to escape. The owner expected it to close once the successor was proven. It now
|
|
471
|
+
does, in this order:
|
|
472
|
+
|
|
473
|
+
1. **Before you issue the prompt, land or name your in-flight work.** `preflight` lists local
|
|
474
|
+
branches that were never pushed or are ahead of their upstream. Push each one and name it in the
|
|
475
|
+
relay, with what the successor must wait for. Open PRs are not detected — list them yourself. In
|
|
476
|
+
the first run the successor nearly reverted a fix that sat on the parent's unmerged PR.
|
|
477
|
+
2. **After round 2's review is sent**, run `handshake close`. The handshake reaches `acknowledged`
|
|
478
|
+
in round 1, but do not close before round 2's review is out. `close` stamps `closed_at`, which
|
|
479
|
+
lifts the guard's tree lock.
|
|
480
|
+
3. **Commit and push the handshake file.**
|
|
481
|
+
4. **Send the child one last message:** what you handed over, and that you are stopping.
|
|
482
|
+
5. **EXIT.** Tell the product owner the terminal can be closed. Do not stay on as an adviser. The
|
|
483
|
+
successor has the repo, and the repo is the record.
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
402
487
|
## Files — all three committed
|
|
403
488
|
|
|
404
489
|
```
|
|
@@ -424,6 +509,9 @@ chat cannot be reviewed**, and cannot be re-read when the next handover asks wha
|
|
|
424
509
|
| Write a question whose answer is not in a committed file | it would test memory |
|
|
425
510
|
| Store an answer key | it freezes your belief, and the child can read it. **`check` enforces this** — see round 1 |
|
|
426
511
|
| Grade your own answers | you validate; the child answers |
|
|
512
|
+
| Grade from a nearly-full context | send each round to a fresh subagent; grading needs the repo, not your memory |
|
|
513
|
+
| Tell the child the tree is its own | stopping writing does not transfer the workspace; the guard holds the lock until `close` |
|
|
514
|
+
| Stay on after `close` | the parent's job ends there — see "The parent's ending" |
|
|
427
515
|
| Close on an unanswered handshake | it is the same as never running one. `handshake close` refuses |
|
|
428
516
|
| Read silence as success | a child that skipped the gate looks exactly like one still reading |
|
|
429
517
|
| Proceed when the open-item count is unknown | that count is the integrity check |
|
|
@@ -74,7 +74,12 @@ Every component must reflect the agreed colour story and font pairing — no fal
|
|
|
74
74
|
|
|
75
75
|
**Motion discipline:**
|
|
76
76
|
- Use animation as communication, not decoration
|
|
77
|
-
- Always respect `prefers-reduced-motion` media query
|
|
77
|
+
- Always respect `prefers-reduced-motion` media query — but treat it as a DEFAULT, not a verdict.
|
|
78
|
+
Windows Server ships with animations off with no user preference expressed, so a build that only
|
|
79
|
+
checks the media query once at load and never offers a way back can freeze a genuinely correct
|
|
80
|
+
animation for the whole session with no override (found in `.caddis/second-brain.html`: the OS
|
|
81
|
+
read is captured into a `var` at script start and never re-read). Read the OS default into a
|
|
82
|
+
visible toggle, and have the draw loop read the toggle's current state, not a captured constant.
|
|
78
83
|
- One hero animation moment — not every element animating on load
|
|
79
84
|
- 44px minimum touch targets on mobile
|
|
80
85
|
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED