@caddis/cli 0.4.12 → 0.4.14

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.
@@ -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))