@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +107 -0
- package/bundles/antigravity-plugin/scripts/caddis_harness_path.py +132 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +43 -8
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +13 -3
- package/bundles/antigravity-plugin/skills/glossary/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +16 -1
- package/bundles/antigravity-plugin/skills/kb/SKILL.md +18 -3
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +3 -3
- package/bundles/antigravity-plugin/skills/park/SKILL.md +32 -3
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +17 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +27 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +14 -0
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +4 -4
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
699
|
-
|
|
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"
|
|
68
|
-
python "$TOOL"
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43
|
-
python
|
|
44
|
-
python
|
|
45
|
-
python
|
|
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:
|
package/bundles/manifest.json
CHANGED
package/package.json
CHANGED