@caddis/cli 0.4.6 → 0.4.8

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.93",
3
+ "version": "1.3.95",
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
  }
@@ -480,6 +480,107 @@ def gate_handover_check(doc: Path, repo_root: Path) -> int:
480
480
  return EXIT_BLOCKED
481
481
 
482
482
 
483
+ # ── cross-review trigger ────────────────────────────────────────────────────────────────────
484
+ # serve-sight shipped FOUR production releases in one day, sixteen fixes across SQL, caches,
485
+ # refresh jobs and React. `/caddis:cross-review` ran ZERO times. Not because it was broken:
486
+ # `oss_review.py --check-config` reported it ready with two providers keyed. Nothing called it.
487
+ #
488
+ # Measured there: 4 of 39 plan files mention cross-review, and all four are /feature-plan outputs,
489
+ # because that command writes the line into the plan template it emits. So planned feature work
490
+ # got reviewed and a bug batch did not — REVIEW COVERAGE INVERSELY CORRELATED WITH URGENCY.
491
+ #
492
+ # Two of that day's sixteen fixes were diff-visible defects: a cache whose docstring said "cached
493
+ # for the instance's life" while a FastAPI dependency rebuilt the object per request, so the cache
494
+ # was never once read (5.03s per call, and the docstring described the bug while reading as a
495
+ # design note); and two endpoints with no cache at all on a page with a 1s standard.
496
+ #
497
+ # Every rule below is checkable from `git diff --name-only` plus a line count. NO JUDGEMENT — that
498
+ # is the point, because judgement is exactly what failed. Prose in a command would be one more
499
+ # thing to remember, and 008 section 2 is this repo's record of a rule that lived only in a
500
+ # document and lost to the artefact.
501
+ REVIEW_BIG_DIFF_LINES = 400
502
+
503
+ # (label, why, path-segment needles). Matched against lowercased POSIX path segments and the file
504
+ # stem, never a bare substring of the whole path — `services` must be a directory or a filename
505
+ # part, so `deprecated_services_notes.md` matches and `microservices-README` in a docs tree does
506
+ # not sneak in through a parent directory nobody meant.
507
+ REVIEW_TRIGGERS = [
508
+ ("sql", "where a wrong number is born — presence is not correctness",
509
+ ("sql", "queries", "query", "repositories", "repository", "migrations", "migration")),
510
+ ("services", "the layer the 2026-08-31 cache defects lived in, with no SQL in the diff",
511
+ ("services", "service")),
512
+ ("caching", "hit twice in one day at serve-sight",
513
+ ("cache", "caches", "caching", "refresh", "memo", "memoize")),
514
+ ("auth", "a mistake here is a breach, not a bug",
515
+ ("auth", "rbac", "permission", "permissions", "role", "roles", "authz", "authn")),
516
+ ]
517
+
518
+
519
+ def _changed(repo_root: Path, rev_range: str | None) -> tuple[list[str], int]:
520
+ """(paths, changed-line-count) for the working tree, or for an explicit range."""
521
+ args = ["git", "-C", str(repo_root), "diff", "--numstat"]
522
+ if rev_range:
523
+ args.append(rev_range)
524
+ out = subprocess.run(args, capture_output=True, text=True, timeout=30).stdout
525
+ paths, lines = [], 0
526
+ for row in out.splitlines():
527
+ parts = row.split(" ")
528
+ if len(parts) != 3:
529
+ continue
530
+ add, dele, path = parts
531
+ paths.append(path.strip())
532
+ for n in (add, dele):
533
+ if n.isdigit():
534
+ lines += int(n)
535
+ return paths, lines
536
+
537
+
538
+ def review_triggers(paths: list[str], changed_lines: int) -> list[tuple[str, str, list[str]]]:
539
+ """[(label, why, matching paths)] — empty means the diff does not warrant a second opinion."""
540
+ fired: list[tuple[str, str, list[str]]] = []
541
+ for label, why, needles in REVIEW_TRIGGERS:
542
+ hits = []
543
+ for p in paths:
544
+ segs = [seg.lower() for seg in p.replace("\\", "/").split("/")]
545
+ stem = segs[-1].rsplit(".", 1)[0] if segs else ""
546
+ parts = set(segs[:-1]) | set(stem.replace("-", "_").split("_")) | {stem}
547
+ # `.sql` belongs to the sql rule ONLY. Left in the shared condition it made every
548
+ # trigger fire on any .sql file, so one migration reported sql+services+caching+auth
549
+ # and the reason column became noise.
550
+ if parts & set(needles) or (label == "sql" and segs[-1].endswith(".sql")):
551
+ hits.append(p)
552
+ if hits:
553
+ fired.append((label, why, sorted(hits)))
554
+ if changed_lines > REVIEW_BIG_DIFF_LINES:
555
+ fired.append(("size", f"{changed_lines} changed lines — size alone stops being reviewable",
556
+ []))
557
+ return fired
558
+
559
+
560
+ def gate_review_trigger(repo_root: Path, rev_range: str | None = None) -> int:
561
+ """EXIT_NOTE when the diff warrants cross-review, EXIT_OK when it does not.
562
+
563
+ NOTE, never BLOCKED. This decides when to ASK for a second opinion; it is not itself a verdict
564
+ and must not stop a ship. The honest limit, from the item that raised it: a trigger does not
565
+ make the review good, it makes it happen — and on the measured evidence that was the binding
566
+ constraint, with a configured, capable tool idle through sixteen chances to use it.
567
+ """
568
+ paths, changed = _changed(repo_root, rev_range)
569
+ if not paths:
570
+ print("[gate] review-trigger: empty diff — nothing to review")
571
+ return EXIT_OK
572
+ fired = review_triggers(paths, changed)
573
+ if not fired:
574
+ print(f"[gate] review-trigger: {len(paths)} file(s), {changed} line(s) — no trigger")
575
+ return EXIT_OK
576
+ print(f"[gate] review-trigger: RUN CROSS-REVIEW — {len(paths)} file(s), {changed} line(s)")
577
+ for label, why, hits in fired:
578
+ shown = ", ".join(hits[:4]) + (", ..." if len(hits) > 4 else "")
579
+ print(f" {label}: {why}" + (f"{chr(10)} {shown}" if hits else ""))
580
+ print(" /caddis:cross-review (or: python .github/tools/oss_review.py)")
581
+ return EXIT_NOTE
582
+
583
+
483
584
  def main(argv: list[str] | None = None) -> int:
484
585
  ap = argparse.ArgumentParser(description="caddis machine gates")
485
586
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -494,6 +595,10 @@ def main(argv: list[str] | None = None) -> int:
494
595
  h.add_argument("--refs", nargs="*", default=[])
495
596
  pl = sub.add_parser("parking-lot")
496
597
  pl.add_argument("--repo-root", default=".")
598
+ rt = sub.add_parser("review-trigger")
599
+ rt.add_argument("--repo-root", default=".")
600
+ rt.add_argument("--range", dest="rev_range", default=None,
601
+ help="revision range, e.g. origin/main...HEAD (default: the working tree)")
497
602
  vd = sub.add_parser("vendor-drift")
498
603
  vd.add_argument("--repo-root", default=".")
499
604
  hc = sub.add_parser("handover-check")
@@ -511,6 +616,8 @@ def main(argv: list[str] | None = None) -> int:
511
616
  return gate_hub_artifacts(Path(a.reports), [Path(r) for r in a.refs])
512
617
  if a.cmd == "parking-lot":
513
618
  return gate_parking_lot(Path(a.repo_root).resolve())
619
+ if a.cmd == "review-trigger":
620
+ return gate_review_trigger(Path(a.repo_root).resolve(), a.rev_range)
514
621
  if a.cmd == "vendor-drift":
515
622
  return gate_vendor_drift(Path(a.repo_root).resolve())
516
623
  if a.cmd == "handover-check":
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """Find the caddis SOURCE checkout from anywhere, so friction can be parked where it belongs.
3
+
4
+ Almost every insight about the harness is noticed while NOT working on the harness — a session in
5
+ an app repo hits friction and is in the wrong place to do anything about it. Today's options are
6
+ all bad: park it locally where the maintainer never looks, say it in chat where it dies with the
7
+ session, switch repos mid-task (nobody does), or remember it later (this is the one that actually
8
+ happens, and later is usually never).
9
+
10
+ So harness improvement is whatever someone recalls at the end of a session. The observations that
11
+ survive are not a random sample: small, frequent friction — the kind that would most improve daily
12
+ use — is exactly what does not survive, because it is not dramatic enough to recall.
13
+
14
+ This resolves the target so `/caddis:park --harness` is a flag rather than a context switch.
15
+
16
+ NOT the installed plugin. `CLAUDE_PLUGIN_ROOT` points at a read-only copy that is replaced on
17
+ every update; an item written there is gone at the next `claude plugin update`. The maintainer
18
+ works in the source checkout, so that is what this finds.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ EXIT_OK, EXIT_NOT_FOUND = 0, 1
28
+
29
+ ENV_VAR = "CADDIS_HARNESS_ROOT"
30
+ POINTER = Path.home() / ".caddis" / "harness-root"
31
+
32
+ # A source checkout has BOTH of these. The installed plugin has neither pair, and an app repo
33
+ # scaffolded by caddis has `.caddis/parking-lot/` but no `claude-harness/` — which is the
34
+ # distinction that matters, because writing into an app repo is the exact failure to avoid.
35
+ SIGNATURE = ("claude-harness", os.path.join(".caddis", "parking-lot"))
36
+
37
+
38
+ def looks_like_harness(path: Path) -> bool:
39
+ try:
40
+ return all((path / part).is_dir() for part in SIGNATURE)
41
+ except OSError:
42
+ return False
43
+
44
+
45
+ def resolve_harness_root(cwd: Path | None = None,
46
+ env: dict | None = None,
47
+ pointer: Path | None = None) -> tuple[Path | None, str]:
48
+ """(path, how-it-was-found) or (None, why-not).
49
+
50
+ Order is deliberate: an explicit answer beats a remembered one, and a remembered one beats a
51
+ guess. The search is last and is deliberately narrow.
52
+ """
53
+ env = os.environ if env is None else env
54
+ pointer = POINTER if pointer is None else pointer
55
+
56
+ raw = (env.get(ENV_VAR) or "").strip()
57
+ if raw:
58
+ p = Path(raw).expanduser()
59
+ if looks_like_harness(p):
60
+ return p, f"{ENV_VAR}"
61
+ return None, (f"{ENV_VAR} is set to {raw!r}, which is not a caddis source checkout "
62
+ f"(needs both {SIGNATURE[0]}/ and {SIGNATURE[1]}/). Fix or unset it — an "
63
+ "explicit setting is never silently ignored.")
64
+
65
+ try:
66
+ noted = pointer.read_text(encoding="utf-8").strip()
67
+ except OSError:
68
+ noted = ""
69
+ if noted:
70
+ p = Path(noted).expanduser()
71
+ if looks_like_harness(p):
72
+ return p, str(pointer)
73
+ return None, (f"{pointer} points at {noted!r}, which is no longer a caddis source "
74
+ "checkout. Re-run with --set <path>.")
75
+
76
+ # Last resort: siblings of the current repo, then siblings of its parent. Every project on a
77
+ # given machine tends to live under one or two roots, so the checkout is usually a sibling of
78
+ # whatever repo noticed the friction. Deliberately shallow — a filesystem-wide search would
79
+ # be slow and would eventually find a stale clone.
80
+ start = (cwd or Path.cwd()).resolve()
81
+ seen: set[Path] = set()
82
+ for base in (start, *start.parents):
83
+ parent = base.parent
84
+ if parent in seen or parent == base:
85
+ continue
86
+ seen.add(parent)
87
+ try:
88
+ candidates = sorted(d for d in parent.iterdir() if d.is_dir())
89
+ except OSError:
90
+ continue
91
+ for d in candidates:
92
+ if looks_like_harness(d):
93
+ return d, f"found beside {base.name}"
94
+ if len(seen) >= 3:
95
+ break
96
+
97
+ return None, ("no caddis source checkout found. Set it once and it is remembered:"
98
+ f"{os.linesep} python caddis_harness_path.py --set <path-to-checkout>"
99
+ f"{os.linesep} or export {ENV_VAR}. NOT the installed plugin — an item written "
100
+ "there is discarded by the next `claude plugin update`.")
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
105
+ ap.add_argument("--set", dest="set_to", default=None,
106
+ help="remember this path as the caddis source checkout")
107
+ ap.add_argument("--cwd", default=".", help="resolve as if from here")
108
+ a = ap.parse_args(argv)
109
+
110
+ if a.set_to:
111
+ p = Path(a.set_to).expanduser().resolve()
112
+ if not looks_like_harness(p):
113
+ sys.stderr.write(f"[harness] {p} is not a caddis source checkout — needs both "
114
+ f"{SIGNATURE[0]}/ and {SIGNATURE[1]}/{os.linesep}")
115
+ return EXIT_NOT_FOUND
116
+ POINTER.parent.mkdir(parents=True, exist_ok=True)
117
+ POINTER.write_text(str(p) + "\n", encoding="utf-8")
118
+ print(f"[harness] remembered {p} in {POINTER}")
119
+ return EXIT_OK
120
+
121
+ root, how = resolve_harness_root(Path(a.cwd).resolve())
122
+ if root is None:
123
+ # FAIL LOUDLY. Silently writing into the local repo is the failure this exists to prevent:
124
+ # the item would sit in an app repo the maintainer never reads, which is the status quo.
125
+ sys.stderr.write("[harness] " + how + os.linesep)
126
+ return EXIT_NOT_FOUND
127
+ print(str(root))
128
+ return EXIT_OK
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())
@@ -501,7 +501,8 @@ def _handshake_path(repo_root: Path, spawn_id: str) -> Path:
501
501
  return repo_root / SPAWN_DIR / (spawn_id + "-handshake.json")
502
502
 
503
503
 
504
- def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto") -> dict:
504
+ def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto",
505
+ peer: str = "") -> dict:
505
506
  """Record that a handover was issued. Written by the PARENT, at the moment it prints the
506
507
  prompt — not when the child replies, because the whole point is to make an unanswered
507
508
  handshake visible."""
@@ -513,6 +514,12 @@ def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto") -> d
513
514
  "state": HANDSHAKE_STATES[0],
514
515
  "transport": chosen,
515
516
  "transport_reason": why,
517
+ # HOW TO ADDRESS THE CHILD. `SendMessage` takes the name `ListAgents` shows, and until
518
+ # 2026-09-08 this file recorded none — so the parent knew a handshake was open and had no
519
+ # idea who to send the chase or the verdict to. Usually EMPTY at open: the child does not
520
+ # exist yet. The parent fills it in from the `from-name` of the child's first message,
521
+ # which is the only moment either side reliably knows that name.
522
+ "peer": peer,
516
523
  "opened_at": int(time.time()),
517
524
  "history": [],
518
525
  }
@@ -529,7 +536,8 @@ def handshake_read(repo_root: Path, spawn_id: str) -> dict | None:
529
536
  return None
530
537
 
531
538
 
532
- def handshake_record(repo_root: Path, spawn_id: str, event: str) -> tuple[dict | None, str]:
539
+ def handshake_record(repo_root: Path, spawn_id: str, event: str,
540
+ peer: str = "") -> tuple[dict | None, str]:
533
541
  """Advance the handshake. Never rewinds: a verdict cannot un-answer the questions, and an
534
542
  out-of-order event is a sign the operator has lost track of which round they are in — which
535
543
  is exactly when a silent overwrite would hurt."""
@@ -541,13 +549,31 @@ def handshake_record(repo_root: Path, spawn_id: str, event: str) -> tuple[dict |
541
549
  return state, (f"already at `{state['state']}`; `{event}` would not move it forward. "
542
550
  "Refusing rather than rewriting — an out-of-order event means the round "
543
551
  "is not the one you think it is.")
544
- state["history"].append({"event": event, "at": int(time.time())})
552
+ if peer:
553
+ state["peer"] = peer
554
+ state["history"].append({"event": event, "at": int(time.time()),
555
+ **({"peer": peer} if peer else {})})
545
556
  state["state"] = target
546
557
  _handshake_path(repo_root, spawn_id).write_text(
547
558
  json.dumps(state, indent=2) + chr(10), encoding="utf-8")
548
559
  return state, ""
549
560
 
550
561
 
562
+ def chase_instruction(state: dict) -> str:
563
+ """What to actually DO when a chase is due — addressed if we can, honest if we cannot."""
564
+ peer = (state.get("peer") or "").strip()
565
+ if state.get("transport") != "message":
566
+ return ("This handshake is on the paste route, so the chase goes through the human. Say "
567
+ "so explicitly — otherwise they watch nothing happen and conclude it is broken.")
568
+ if peer:
569
+ return f'SendMessage to: "{peer}" — carry TWO FRESH questions, not just a reminder.'
570
+ return ("No peer address recorded, so the child has not made contact yet — which is itself "
571
+ "the signal. Run `ListAgents` and look for a session in this repo; a session started "
572
+ "seconds ago may be absent from one listing, so a single empty result is not proof it "
573
+ "does not exist. Once it replies, capture the name: "
574
+ "`handshake record --id <id> --event answers --peer <from-name>`.")
575
+
576
+
551
577
  def handshake_chase_due(state: dict, now: int | None = None) -> int:
552
578
  """Seconds overdue for a chase, or 0. Only meaningful while awaiting answers.
553
579
 
@@ -611,6 +637,9 @@ def main(argv: list[str] | None = None) -> int:
611
637
  h.add_argument("--repo-root", default=".")
612
638
  h.add_argument("--event", choices=sorted(_EVENT_STATE))
613
639
  h.add_argument("--transport", choices=["auto", "message", "paste"], default="auto")
640
+ h.add_argument("--peer", default="",
641
+ help="the child's name as ListAgents shows it — take it from the `from-name` "
642
+ "of its first message; that is the only moment it is reliably known")
614
643
  q = sub.add_parser("verify-question")
615
644
  q.add_argument("--answer-in", required=True)
616
645
  q.add_argument("--needle", required=True)
@@ -677,7 +706,7 @@ def main(argv: list[str] | None = None) -> int:
677
706
 
678
707
  if a.cmd == "handshake":
679
708
  if a.action == "open":
680
- st = handshake_open(root, a.id, a.transport)
709
+ st = handshake_open(root, a.id, a.transport, a.peer)
681
710
  print(f"[spawn] handshake {a.id} open — transport `{st['transport']}` "
682
711
  f"({st['transport_reason']})")
683
712
  if st["transport"] == "paste":
@@ -691,23 +720,29 @@ def main(argv: list[str] | None = None) -> int:
691
720
  return EXIT_REFUSED
692
721
  if a.action == "status":
693
722
  over = handshake_chase_due(st)
694
- print(f"[spawn] {a.id}: {st['state']} via {st['transport']}")
723
+ peer = st.get("peer") or "(not recorded — see below)"
724
+ print(f"[spawn] {a.id}: {st['state']} via {st['transport']}, peer {peer}")
695
725
  if over:
696
726
  print(f" CHASE DUE — {over}s past the {CHASE_AFTER_S}s window. Silence is not "
697
727
  "success: a child that skipped the gate and started work looks exactly "
698
- "like one still reading. Ask, and carry TWO FRESH questions, which cannot "
699
- "have been pre-read in the handover.")
728
+ "like one still reading.")
729
+ print(" " + chase_instruction(st))
700
730
  return EXIT_OK
701
731
  if a.action == "record":
702
732
  if not a.event:
703
733
  sys.stderr.write("[spawn] --event is required for `record`" + chr(10))
704
734
  return EXIT_REFUSED
705
- st, err = handshake_record(root, a.id, a.event)
735
+ st, err = handshake_record(root, a.id, a.event, a.peer)
706
736
  if err:
707
737
  sys.stderr.write("[spawn] " + err + chr(10))
708
738
  return EXIT_REFUSED
709
739
  print(f"[spawn] {a.id}: {st['state']}")
710
740
  return EXIT_OK
741
+ if a.action == "close" and not (st.get("peer") or "").strip() \
742
+ and st["state"] != HANDSHAKE_STATES[0]:
743
+ # Contact happened but nobody wrote down who with. The verdict has to reach someone.
744
+ print(" note no peer recorded although the handshake progressed — re-run "
745
+ "`record --peer <from-name>` so the next session can see who this was with.")
711
746
  ok, msg = handshake_close(root, a.id)
712
747
  (print if ok else sys.stderr.write)("[spawn] " + msg + ("" if ok else chr(10)))
713
748
  return EXIT_OK if ok else EXIT_REFUSED
@@ -64,9 +64,19 @@ fi
64
64
  > had been fixed on 2026-08-01 — the fix just never reached the caller, because a file someone
65
65
  > copied weeks earlier silently won. `caddis_gate.py vendor-drift` now fails on the same condition.
66
66
  ```
67
- python "$TOOL" # DeepSeek eyes (default)
68
- python "$TOOL" --provider glm # GLM eyes
69
- python "$TOOL" --range $ARGUMENTS # review a git range, e.g. origin/main..HEAD
67
+ python "$TOOL" $ARGUMENTS # pass the user's flags THROUGH, raw
68
+ python "$TOOL" # no arguments: DeepSeek eyes on the working tree
69
+ ```
70
+
71
+ **`$ARGUMENTS` goes through raw, and must not be re-prefixed.** Callers are told to type the flag
72
+ themselves — `feature-plan.md` emits `/caddis:cross-review --range main..HEAD` into every plan it
73
+ writes. This line used to prefix the flag itself, which turned that into a doubled
74
+ `--range --range main..HEAD` and made every plan-emitted call fail. Whatever the user typed is already a complete flag list:
75
+
76
+ ```
77
+ /caddis:cross-review -> the working tree, DeepSeek
78
+ /caddis:cross-review --range main..HEAD -> that range
79
+ /caddis:cross-review --provider glm -> GLM eyes
70
80
  ```
71
81
  Optional: `--base-url <url>`, `--model <id>` (env always overrides the preset).
72
82
 
@@ -15,7 +15,7 @@ Your job is the half a script cannot do: **write what the words mean here.**
15
15
  ## Step 1 — get the candidates
16
16
 
17
17
  ```
18
- python <caddis-plugin-root>/scripts/caddis_glossary.py
18
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_glossary.py"
19
19
  ```
20
20
 
21
21
  It ranks the words this repo says often that plain English does not, and marks which already
@@ -26,7 +26,7 @@ is to run it.
26
26
  git status --short
27
27
  git branch --show-current
28
28
  git log --oneline -5
29
- python scripts/caddis_inventory.py # what EXISTS — do not compose this from memory
29
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_inventory.py" # what EXISTS — do not compose this from memory
30
30
  ```
31
31
  Then read the active plan in `.caddis/plans/` (falling back to legacy `.github/plans/` if present) and its tracker.
32
32
 
@@ -85,6 +85,21 @@ had developed by then.
85
85
 
86
86
  Record the outcome in relay.md's `## Learnings captured` line (below).
87
87
 
88
+ ## Step 2c — was any of it the harness?
89
+
90
+ **One question, asked here because here is where you are already reflecting.** The observations
91
+ that reach caddis today are the ones someone happened to recall days later — so small, frequent
92
+ friction never arrives, which is exactly the friction worth fixing.
93
+
94
+ If a command was awkward, a gate fired wrongly, a doc sent you the wrong way: park it against the
95
+ harness, not this repo.
96
+
97
+ ```
98
+ /caddis:park --harness <what was awkward, and what you expected>
99
+ ```
100
+
101
+ Nothing to say is the normal answer. Say nothing and move on — this must not become a ritual.
102
+
88
103
  ## Step 3 — write the resume doc (overwrite) with exactly these sections
89
104
  > **Where to write:** solo / single active branch → `.caddis/relay.md` (default).
90
105
  > **Team / parallel branches** → write `.caddis/relay/<current-branch>.md` instead, so two
@@ -15,7 +15,7 @@ It ships with the harness. Try, in order:
15
15
 
16
16
  ## Step 2 — run the reindexer
17
17
  ```
18
- python <path>/check_doc_coverage.py --reindex
18
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/check_doc_coverage.py" --reindex
19
19
  ```
20
20
  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
@@ -32,18 +32,33 @@ Read the `[kb]` summary it printed, then:
32
32
  - The note is **gone for good** → remove its row. To clear all dangling rows at once, use the
33
33
  destructive opt-in — but **show the dangling list and confirm with the user first**:
34
34
  ```
35
- python <path>/check_doc_coverage.py --prune
35
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/check_doc_coverage.py" --prune
36
36
  ```
37
37
  `--prune` removes *only* index rows that link to missing files (never valid rows, never prose),
38
38
  and still indexes any orphan notes in the same run.
39
39
 
40
40
  ## Step 4 — verify clean
41
41
  ```
42
- python <path>/check_doc_coverage.py --check
42
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/check_doc_coverage.py" --check
43
43
  ```
44
44
  Exit `0` = the index is honest (no dangling links; every note indexed). The SessionStart hook will now
45
45
  surface the `[DOC-MAP]` "read the index first" pointer for future sessions in this repo.
46
46
 
47
+ ## Step 5 — was any of it the harness?
48
+
49
+ **One question, asked here because here is where you are already reflecting.** The observations
50
+ that reach caddis today are the ones someone happened to recall days later — so small, frequent
51
+ friction never arrives, which is exactly the friction worth fixing.
52
+
53
+ If a command was awkward, a gate fired wrongly, a doc sent you the wrong way: park it against the
54
+ harness, not this repo.
55
+
56
+ ```
57
+ /caddis:park --harness <what was awkward, and what you expected>
58
+ ```
59
+
60
+ Nothing to say is the normal answer. Say nothing and move on — this must not become a ritual.
61
+
47
62
  ## KB note format (OKF-lite — mandatory for every new note)
48
63
 
49
64
  Every new `.caddis/kb/*.md` note starts with this frontmatter block. **`type` is the only required
@@ -29,7 +29,7 @@ pass `--allow-dirty` on a tree whose changes you didn't make.
29
29
 
30
30
  ## Step 3 — dry run
31
31
  ```
32
- python <path>/caddis_migrate_dir.py <target>
32
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_migrate_dir.py" <target>
33
33
  ```
34
34
  Read the plan it prints. Two shapes:
35
35
  - **Simple** — only `.claudster/` exists → one `git mv .claudster .caddis` (history preserved).
@@ -42,7 +42,7 @@ Show the user the dry-run output before applying.
42
42
 
43
43
  ## Step 4 — apply
44
44
  ```
45
- python <path>/caddis_migrate_dir.py <target> --apply
45
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_migrate_dir.py" <target> --apply
46
46
  ```
47
47
  It also rewrites `.claudster/…` path references inside the repo's **live** state — `.caddis/relay.md`
48
48
  and `.caddis/workstreams.json` (parked-workstream plan paths). Historical artifacts (past plans,
@@ -53,7 +53,7 @@ Resolve any CONFLICT it reported by hand, then re-run — the command is idempot
53
53
  ## Step 5 — verify, then commit
54
54
  ```
55
55
  git -C <target> status --short # renames should show as R (history preserved)
56
- python <path>/caddis_migrate_dir.py <target> --check # exit 0 = no legacy dir left
56
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_migrate_dir.py" <target> --check # exit 0 = no legacy dir left
57
57
  ```
58
58
  Then run the repo's own gate (its tests, and `check_doc_coverage.py --check` if present) and commit
59
59
  the rename **on its own**, e.g. `chore(caddis): migrate .claudster/ -> .caddis/`.
@@ -20,12 +20,41 @@ The request is **$ARGUMENTS**.
20
20
 
21
21
  | `$ARGUMENTS` | Do this |
22
22
  |---|---|
23
+ | starts with `--harness` | Go to **Harness mode** — the item is about caddis, not this repo |
23
24
  | empty or `list` | Go to **List mode** |
24
25
  | `done <slug>` or `drop <slug>` | Go to **Close mode** |
25
26
  | anything else | Go to **File mode** — the text is what to park |
26
27
 
27
28
  ---
28
29
 
30
+ ## Harness mode — `--harness`
31
+
32
+ **Use it when the friction was caused by the harness, not the project.** You are almost certainly
33
+ in an app repo right now, because that is where harness friction is noticed. Parking it here sends
34
+ it somewhere the maintainer never reads.
35
+
36
+ ```bash
37
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_harness_path.py"
38
+ ```
39
+
40
+ Prints the caddis SOURCE checkout, or **fails with exit 1 and says how to set it**. Write the item
41
+ to `<that path>/.caddis/parking-lot/<slug>.md`, following File mode below, plus:
42
+
43
+ ```yaml
44
+ scope: harness # so a later sweep can find it mechanically
45
+ found-by: <this repo> / session <id>
46
+ ```
47
+
48
+ **On a non-zero exit, STOP and show the message.** Do not fall back to the local repo — an item
49
+ filed there is the status quo this flag exists to end. Setting it is one command and is remembered.
50
+
51
+ **Never commit it.** Capture is cheap and safe; deciding is not. Leave the file for the maintainer.
52
+
53
+ Not the installed plugin: `CLAUDE_PLUGIN_ROOT` is a read-only copy replaced on every
54
+ `claude plugin update`, so an item written there is discarded.
55
+
56
+ ---
57
+
29
58
  ## File mode
30
59
 
31
60
  ### Step 1 — check it belongs here
@@ -88,7 +117,7 @@ Rules the gate enforces — get them right the first time:
88
117
  Run the gate and show the result:
89
118
 
90
119
  ```bash
91
- python scripts/caddis_gate.py parking-lot --repo-root .
120
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" parking-lot --repo-root .
92
121
  ```
93
122
 
94
123
  Exit 0 means it conforms. Exit 1 means fix what it printed, then re-run. Do not report the item as
@@ -109,7 +138,7 @@ Read every `.caddis/parking-lot/*.md` (skip `README.md` and `done/`). Print one
109
138
  |---|---|---|---|---|
110
139
 
111
140
  Below the table, print the counts: how many open, how many committed (`future: yes`). Then run
112
- `python scripts/caddis_gate.py parking-lot --repo-root .` and report any violation it prints.
141
+ `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" parking-lot --repo-root .` and report any violation it prints.
113
142
 
114
143
  If the directory is empty, say so in one line. Do not invent items.
115
144
 
@@ -122,5 +151,5 @@ If the directory is empty, say so in one line. Do not invent items.
122
151
  3. **For `dropped`, append a `## Why dropped` section.** This is not optional. An item deleted with
123
152
  no reason gets re-raised by the next session that has the same idea; an item kept with "no,
124
153
  because ..." does not.
125
- 4. Run `python scripts/caddis_tidy.py --apply`, which moves it into `.caddis/parking-lot/done/`.
154
+ 4. Run `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_tidy.py" --apply`, which moves it into `.caddis/parking-lot/done/`.
126
155
  5. Report the file's new path in one line.
@@ -44,6 +44,23 @@ git rev-parse HEAD # record SHA for monitoring
44
44
  ```
45
45
  Use conventional commits: `fix:`, `feat:`, `chore:`, `refactor:`, `docs:`. Scope = affected module.
46
46
 
47
+ **2b. CROSS-REVIEW TRIGGER** — `ship` has no review pause at all, which is how sixteen fixes reached production unreviewed in one day.
48
+
49
+ **Run the trigger, do not eyeball the diff:**
50
+
51
+ ```bash
52
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" review-trigger --range <base>...HEAD
53
+ ```
54
+
55
+ Exit **0** — say nothing, carry on. Exit **2** — it prints which rule fired and on which files.
56
+ Run `/caddis:cross-review`, show the findings, and let the user decide. It **never blocks**: this
57
+ picks when to ask for a second opinion, it is not a verdict, and a review gate that blocks a ship
58
+ gets switched off inside a week.
59
+
60
+ It fires on SQL and repositories, `services/`, caches and refresh jobs, auth and RBAC, or a diff
61
+ over 400 changed lines. No judgement in any of those — deliberately, because judgement is exactly
62
+ what failed.
63
+
47
64
  **3. PUSH** — to the repo's default branch (confirm the branch first; don't assume `main`):
48
65
  ```
49
66
  git push origin <branch>
@@ -50,6 +50,33 @@ are uncommitted.)
50
50
  - Without a named guard, apply the generic infra-vs-product mixed-diff heuristic as a **warning
51
51
  only** — surface it and proceed (repos without a guard allow mixed PRs by construction).
52
52
 
53
+ ## Step 3b — Cross-review trigger (before anything is pushed)
54
+
55
+ **Run the trigger, do not eyeball the diff:**
56
+
57
+ ```bash
58
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_gate.py" review-trigger --range <base>...HEAD
59
+ ```
60
+
61
+ Exit **0** — say nothing, carry on. Exit **2** — it prints which rule fired and on which files.
62
+ Run `/caddis:cross-review`, show the findings, and let the user decide. It **never blocks**: this
63
+ picks when to ask for a second opinion, it is not a verdict, and a review gate that blocks a ship
64
+ gets switched off inside a week.
65
+
66
+ It fires on SQL and repositories, `services/`, caches and refresh jobs, auth and RBAC, or a diff
67
+ over 400 changed lines. No judgement in any of those — deliberately, because judgement is exactly
68
+ what failed.
69
+
70
+ > **Why this is mechanical and not "consider a review here".** serve-sight shipped four
71
+ > production releases in one day — sixteen fixes across SQL, caches, refresh jobs and React — and
72
+ > `/caddis:cross-review` ran **zero times**. Nothing was broken: the tool reported itself ready
73
+ > with two providers keyed. Nothing decided *when* to call it. Measured there: 4 of 39 plan files
74
+ > mention cross-review, and all four came from `/caddis:feature-plan`, which writes the line into
75
+ > the plan template it emits. So planned feature work got reviewed and a bug batch did not —
76
+ > **review coverage inversely correlated with urgency.** Two of that day's sixteen fixes were
77
+ > diff-visible defects, including a cache whose docstring said "cached for the instance's life"
78
+ > while a dependency rebuilt the object per request, so the cache was never once read.
79
+
53
80
  ## Step 4 — Rebase-safe currency
54
81
 
55
82
  - If the branch is **behind** the default branch, offer to rebase onto it (stale branches are how
@@ -242,6 +242,20 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake status --id <id
242
242
  Two questions asked at chase time cannot have been pre-read in the handover, which is a stronger
243
243
  test than the original set — and it costs nothing, since you are holding the context anyway.
244
244
 
245
+ **Write down HOW to reach the child the moment it first messages you.** `SendMessage` addresses a
246
+ peer by the name `ListAgents` shows, and you cannot know that name before the child exists — it
247
+ arrives as the `from-name` on its first message. Capture it there or you will be holding an open
248
+ handshake with no way to send the chase or the verdict:
249
+
250
+ ```bash
251
+ ... handshake record --id <id> --event answers --peer <from-name>
252
+ ```
253
+
254
+ `handshake status` then prints the address, so a chase is a copy-paste. With no peer recorded it
255
+ says so plainly, because **that is itself the signal**: the child has not made contact. Run
256
+ `ListAgents` — and note a session started seconds ago can be missing from one listing, so a single
257
+ empty result is not proof it does not exist.
258
+
245
259
  Record each step, and the parent may not finish until the child has acknowledged:
246
260
 
247
261
  ```bash
@@ -39,10 +39,10 @@ Find the renderer in this install and run it. Try these paths in order and use t
39
39
  Then:
40
40
 
41
41
  ```bash
42
- python <path>/caddis_statusline.py --install # both hosts
43
- python <path>/caddis_statusline.py --install --host claude
44
- python <path>/caddis_statusline.py --install --host agy
45
- python <path>/caddis_statusline.py --install --dry-run # print, write nothing
42
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install # both hosts
43
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install --host claude
44
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install --host agy
45
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_statusline.py" --install --dry-run # print, write nothing
46
46
  ```
47
47
 
48
48
  `--install` does four things:
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.93",
2
+ "poolVersion": "1.3.95",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.93",
4
+ "antigravity-plugin": "1.3.95",
5
5
  "antigravity-plugin-extras": "1.3.28",
6
6
  "codex": "unknown"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caddis/cli",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
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",