@caddis/cli 0.4.12 → 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.
@@ -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
- r"|(?:^|\s)(?:/|/\*|~/|~|\$\{?HOME\}?)(?:\s|$|;|&|\|)"
83
- r"|(?:^|\s)[A-Za-z]:[\\/]?(?:\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
- catastrophic = bool(_CATASTROPHIC_TARGET.search(n))
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
- if rm_rf and catastrophic:
131
- return "deny", "recursive force-delete of a root/home path (rm -rf)"
132
- if find_delete and catastrophic:
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:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.101",
3
+ "version": "1.3.103",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -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
- # pytest's summary line is the last one naming passed/failed/error.
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 ("passed" in l or "failed" in l or "error" in l.lower()) and "==" not in l[:2]]
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
- return (f"suite did not report a summary (exit {r.returncode}) — investigate", shown)
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 not (repo_root / cand).exists():
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
- out = _run([sys.executable, "-m", "pytest", "-q", "--tb=no"], repo_root)
356
- tail = [l for l in out.splitlines() if "passed" in l or "failed" in l]
357
- fp["tests"] = tail[-1] if tail else "suite reported no summary"
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}. 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.")
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
  }
@@ -550,7 +662,8 @@ MAX_REJECTS = 2
550
662
 
551
663
 
552
664
  def handshake_record(repo_root: Path, spawn_id: str, event: str,
553
- peer: str = "", verdict: str = "") -> tuple[dict | None, str]:
665
+ peer: str = "", verdict: str = "",
666
+ revived: int | None = None) -> tuple[dict | None, str]:
554
667
  """Advance the handshake. One step forward, with exactly one named exception.
555
668
 
556
669
  **REJECT sends it back to `awaiting-answers`, and that is the whole point of this function
@@ -612,10 +725,28 @@ def handshake_record(repo_root: Path, spawn_id: str, event: str,
612
725
  + ("skip a step" if there > here + 1 else "move backwards")
613
726
  + " — refusing rather than rewriting, because a skipped step here is a "
614
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
615
745
  if peer:
616
746
  state["peer"] = peer
617
747
  state["history"].append({"event": event, "at": int(time.time()),
618
748
  **({"verdict": verdict} if event == "verdict" and verdict else {}),
749
+ **({"revived": revived} if revived is not None else {}),
619
750
  **({"peer": peer} if peer else {})})
620
751
  state["state"] = target
621
752
  _handshake_path(repo_root, spawn_id).write_text(
@@ -677,7 +808,13 @@ def handshake_close(repo_root: Path, spawn_id: str) -> tuple[bool, str]:
677
808
  "ALIVE until the child confirms it has the verdict — closing on an "
678
809
  "unanswered handshake is the same as never running one. (It may stop "
679
810
  "WRITING to the repo now; that is a different rule and both hold.)")
680
- return True, "acknowledged — the parent may close"
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\")")
681
818
 
682
819
 
683
820
  def main(argv: list[str] | None = None) -> int:
@@ -714,6 +851,10 @@ def main(argv: list[str] | None = None) -> int:
714
851
  h.add_argument("--peer", default="",
715
852
  help="the child's name as ListAgents shows it — take it from the `from-name` "
716
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")
717
858
  q = sub.add_parser("verify-question")
718
859
  q.add_argument("--answer-in", required=True)
719
860
  q.add_argument("--needle", required=True)
@@ -746,6 +887,9 @@ def main(argv: list[str] | None = None) -> int:
746
887
  f = check_document(doc.read_text(encoding="utf-8", errors="ignore"), root)
747
888
  for n, msg in f.historical:
748
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")
749
893
  if f.ok():
750
894
  print(f"[spawn] {doc.name} passes round 0 — nothing stale-prone, every path exists")
751
895
  return EXIT_OK
@@ -785,8 +929,9 @@ def main(argv: list[str] | None = None) -> int:
785
929
  f"({st['transport_reason']}), state `{st['state']}`")
786
930
  if st["state"] == HANDSHAKE_STATES[0]:
787
931
  print(f" PARENT-FIRST. You have the address, so message \"{st['peer']}\" now: "
788
- "tell it what to read, then ask it to reply with WHICH FILES it opened "
789
- "before it answers anything. Record that with `--event readback`.")
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>`.")
790
935
  elif st["transport"] == "message" and not st["peer"]:
791
936
  print(" No --peer given, so this is the child-first flow: print the prompt and "
792
937
  "wait. If you already started the child, re-open with `--peer <its "
@@ -814,7 +959,7 @@ def main(argv: list[str] | None = None) -> int:
814
959
  if not a.event:
815
960
  sys.stderr.write("[spawn] --event is required for `record`" + chr(10))
816
961
  return EXIT_REFUSED
817
- st, err = handshake_record(root, a.id, a.event, a.peer, a.verdict)
962
+ st, err = handshake_record(root, a.id, a.event, a.peer, a.verdict, a.revived)
818
963
  if err:
819
964
  sys.stderr.write("[spawn] " + err + chr(10))
820
965
  return EXIT_REFUSED
@@ -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", # caddis
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
- after = insert_table_rows(updated, "Knowledge base", kb_note_rows(root, entries))
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 — run, exit 0.
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
- _changed, summary = reindex(root, root.name, prune=args.prune)
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))
@@ -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. Record it with `--event readback`; until you do, the handshake refuses `--event answers`.
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** | A task widget does not survive a `/clear`. This project has already lost one |
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 any path that does not
174
- exist. It only **notes** a historical hash — those are durable, and flagging them was how the first
175
- version produced seven findings on one document and taught the reader to skim.
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
- > **You stay ALIVE. You stop WRITING.** `/caddis:spawn-hub` carries a single-writer rule — the
304
- > outgoing session stops writing to the repository the moment the prompt is issued, because two
305
- > sessions committed to one repo concurrently and one's work landed inside the other's commit.
306
- > That is a different rule from this one and **both hold.** Said no other way, a reader reconciles
307
- > them by closing the parent early, which removes the grader.
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
- ### Tell the child it does not own the tree
346
+ ### The tree is never handed over while two sessions are attached
313
347
 
314
- **Say this in the prompt.** A fresh session's instinct on seeing a dirty tree is to tidy it, and
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.
362
+
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
  ```
@@ -378,7 +427,7 @@ means regenerate" was unenforceable, because nothing counted them.
378
427
  ### When the fault is yours, fix the class
379
428
 
380
429
  If validation finds an error that came from your handover, **fix the source document in the same
381
- 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
382
431
  hash; the fix was not a fresher hash, it was to **remove hashes from both documents** and write the
383
432
  command instead. A handover patched per-instance goes stale again on the next commit.
384
433
 
@@ -402,8 +451,8 @@ Round 1's verdict sets round 2's size:
402
451
  | ACCEPT WITH CORRECTIONS | a **small, reversible** item that writes nothing permanent; review closely |
403
452
  | REJECT | no task — re-read and re-answer |
404
453
 
405
- Review the result **by re-deriving it**, exactly as you reviewed the answers — not by reading the
406
- 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.
407
456
 
408
457
  ### Then stop gating
409
458
 
@@ -414,6 +463,27 @@ to remove.
414
463
 
415
464
  ---
416
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
+
417
487
  ## Files — all three committed
418
488
 
419
489
  ```
@@ -439,6 +509,9 @@ chat cannot be reviewed**, and cannot be re-read when the next handover asks wha
439
509
  | Write a question whose answer is not in a committed file | it would test memory |
440
510
  | Store an answer key | it freezes your belief, and the child can read it. **`check` enforces this** — see round 1 |
441
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" |
442
515
  | Close on an unanswered handshake | it is the same as never running one. `handshake close` refuses |
443
516
  | Read silence as success | a child that skipped the gate looks exactly like one still reading |
444
517
  | Proceed when the open-item count is unknown | that count is the integrity check |
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.101",
2
+ "poolVersion": "1.3.103",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.101",
4
+ "antigravity-plugin": "1.3.103",
5
5
  "antigravity-plugin-extras": "1.3.29",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Install, update and diagnose the caddis dev harness across every coding agent you use (Claude Code, agy/Antigravity, and more).",
5
5
  "keywords": [
6
6
  "caddis",