@caddis/cli 0.4.7 → 0.4.9

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.
@@ -6,7 +6,7 @@ description: Use this agent to validate an implementation plan (or a phase of it
6
6
  You are a plan-validation specialist. Your job: catch the plan's wrong assumptions **before** they
7
7
  become wasted implementation. You diagnose; you do not fix and you do not write code.
8
8
 
9
- ## What to validate (7 categories)
9
+ ## What to validate (8 categories)
10
10
  For the plan (or the named phase) check each claim against the codebase:
11
11
  1. **File paths** — every file the plan says to create/edit: does the parent dir exist? Does an
12
12
  existing file already cover it (collision/duplication)?
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.94",
3
+ "version": "1.3.96",
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
  }
@@ -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())
@@ -470,8 +470,17 @@ def capture_order(repo_root: Path, spawn_id: str) -> tuple[list[str], list[str]]
470
470
  CHASE_AFTER_S = 1800
471
471
 
472
472
  # Ordered. Each event may only move the handshake forward.
473
- HANDSHAKE_STATES = ("awaiting-answers", "answered", "verdict-sent", "acknowledged")
474
- _EVENT_STATE = {"answers": "answered", "verdict": "verdict-sent", "ack": "acknowledged"}
473
+ # Ordered. An event may only move the handshake FORWARD along this list.
474
+ #
475
+ # Two entry points, one list. PARENT-FIRST (the product owner starts the child, then names it)
476
+ # begins at `awaiting-readback`, because the parent speaks first and must not take the answers on
477
+ # trust. CHILD-FIRST (008's original: print a prompt, a human pastes it) begins at
478
+ # `awaiting-answers`, because there the child's first message is itself the evidence of reading
479
+ # and a read-back would be asking for something already proven.
480
+ HANDSHAKE_STATES = ("awaiting-readback", "awaiting-answers", "answered", "verdict-sent",
481
+ "acknowledged")
482
+ _EVENT_STATE = {"readback": "awaiting-answers", "answers": "answered",
483
+ "verdict": "verdict-sent", "ack": "acknowledged"}
475
484
 
476
485
 
477
486
  def detect_transport(env: dict | None = None) -> tuple[str, str]:
@@ -501,7 +510,8 @@ def _handshake_path(repo_root: Path, spawn_id: str) -> Path:
501
510
  return repo_root / SPAWN_DIR / (spawn_id + "-handshake.json")
502
511
 
503
512
 
504
- def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto") -> dict:
513
+ def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto",
514
+ peer: str = "") -> dict:
505
515
  """Record that a handover was issued. Written by the PARENT, at the moment it prints the
506
516
  prompt — not when the child replies, because the whole point is to make an unanswered
507
517
  handshake visible."""
@@ -510,9 +520,16 @@ def handshake_open(repo_root: Path, spawn_id: str, transport: str = "auto") -> d
510
520
  chosen, why = transport, "forced with --transport"
511
521
  state = {
512
522
  "id": spawn_id,
513
- "state": HANDSHAKE_STATES[0],
523
+ # Parent-first only when we can actually reach the child: a name AND a transport.
524
+ "state": HANDSHAKE_STATES[0] if (peer and chosen == "message") else HANDSHAKE_STATES[1],
514
525
  "transport": chosen,
515
526
  "transport_reason": why,
527
+ # HOW TO ADDRESS THE CHILD. `SendMessage` takes the name `ListAgents` shows, and until
528
+ # 2026-09-08 this file recorded none — so the parent knew a handshake was open and had no
529
+ # idea who to send the chase or the verdict to. Usually EMPTY at open: the child does not
530
+ # exist yet. The parent fills it in from the `from-name` of the child's first message,
531
+ # which is the only moment either side reliably knows that name.
532
+ "peer": peer,
516
533
  "opened_at": int(time.time()),
517
534
  "history": [],
518
535
  }
@@ -529,7 +546,8 @@ def handshake_read(repo_root: Path, spawn_id: str) -> dict | None:
529
546
  return None
530
547
 
531
548
 
532
- def handshake_record(repo_root: Path, spawn_id: str, event: str) -> tuple[dict | None, str]:
549
+ def handshake_record(repo_root: Path, spawn_id: str, event: str,
550
+ peer: str = "") -> tuple[dict | None, str]:
533
551
  """Advance the handshake. Never rewinds: a verdict cannot un-answer the questions, and an
534
552
  out-of-order event is a sign the operator has lost track of which round they are in — which
535
553
  is exactly when a silent overwrite would hurt."""
@@ -537,17 +555,48 @@ def handshake_record(repo_root: Path, spawn_id: str, event: str) -> tuple[dict |
537
555
  if state is None:
538
556
  return None, f"no handshake for {spawn_id} — run `handshake open --id {spawn_id}` first"
539
557
  target = _EVENT_STATE[event]
540
- if HANDSHAKE_STATES.index(target) <= HANDSHAKE_STATES.index(state["state"]):
541
- return state, (f"already at `{state['state']}`; `{event}` would not move it forward. "
542
- "Refusing rather than rewriting — an out-of-order event means the round "
543
- "is not the one you think it is.")
544
- state["history"].append({"event": event, "at": int(time.time())})
558
+ here = HANDSHAKE_STATES.index(state["state"])
559
+ there = HANDSHAKE_STATES.index(target)
560
+ # EXACTLY one step. Monotonic-only let `answers` jump straight over `awaiting-readback`,
561
+ # which silently skipped the read-back gate — the one thing the parent-first flow adds.
562
+ # Backwards is an operator who has lost track of the round; forwards-by-two is a gate not run.
563
+ if there != here + 1:
564
+ expected = HANDSHAKE_STATES[here + 1] if here + 1 < len(HANDSHAKE_STATES) else "nothing"
565
+ wanted = next((e for e, st in _EVENT_STATE.items() if st == expected), "—")
566
+ return state, (f"at `{state['state']}`, so the only event that fits is `{wanted}` "
567
+ f"(-> `{expected}`). `{event}` would "
568
+ + ("skip a step" if there > here + 1 else "move backwards")
569
+ + " — refusing rather than rewriting, because a skipped step here is a "
570
+ "gate that never ran.")
571
+ if peer:
572
+ state["peer"] = peer
573
+ state["history"].append({"event": event, "at": int(time.time()),
574
+ **({"peer": peer} if peer else {})})
545
575
  state["state"] = target
546
576
  _handshake_path(repo_root, spawn_id).write_text(
547
577
  json.dumps(state, indent=2) + chr(10), encoding="utf-8")
548
578
  return state, ""
549
579
 
550
580
 
581
+ def chase_instruction(state: dict) -> str:
582
+ """What to actually DO when a chase is due — addressed if we can, honest if we cannot."""
583
+ peer = (state.get("peer") or "").strip()
584
+ if state.get("state") == HANDSHAKE_STATES[0] and peer:
585
+ return (f'No read-back from "{peer}". It was messaged and has not said what it READ, which '
586
+ "is the cheapest signal that it skipped the gate and started working. Ask again, "
587
+ "and ask for file paths — a summary can be written without opening anything.")
588
+ if state.get("transport") != "message":
589
+ return ("This handshake is on the paste route, so the chase goes through the human. Say "
590
+ "so explicitly — otherwise they watch nothing happen and conclude it is broken.")
591
+ if peer:
592
+ return f'SendMessage to: "{peer}" — carry TWO FRESH questions, not just a reminder.'
593
+ return ("No peer address recorded, so the child has not made contact yet — which is itself "
594
+ "the signal. Run `ListAgents` and look for a session in this repo; a session started "
595
+ "seconds ago may be absent from one listing, so a single empty result is not proof it "
596
+ "does not exist. Once it replies, capture the name: "
597
+ "`handshake record --id <id> --event answers --peer <from-name>`.")
598
+
599
+
551
600
  def handshake_chase_due(state: dict, now: int | None = None) -> int:
552
601
  """Seconds overdue for a chase, or 0. Only meaningful while awaiting answers.
553
602
 
@@ -555,7 +604,7 @@ def handshake_chase_due(state: dict, now: int | None = None) -> int:
555
604
  pre-read in the handover file — a stronger test than the original set, and free, because the
556
605
  parent is holding the context anyway.
557
606
  """
558
- if state.get("state") != HANDSHAKE_STATES[0]:
607
+ if state.get("state") not in (HANDSHAKE_STATES[0], HANDSHAKE_STATES[1]):
559
608
  return 0
560
609
  now = int(time.time()) if now is None else now
561
610
  return max(0, now - int(state.get("opened_at", now)) - CHASE_AFTER_S)
@@ -611,6 +660,9 @@ def main(argv: list[str] | None = None) -> int:
611
660
  h.add_argument("--repo-root", default=".")
612
661
  h.add_argument("--event", choices=sorted(_EVENT_STATE))
613
662
  h.add_argument("--transport", choices=["auto", "message", "paste"], default="auto")
663
+ h.add_argument("--peer", default="",
664
+ help="the child's name as ListAgents shows it — take it from the `from-name` "
665
+ "of its first message; that is the only moment it is reliably known")
614
666
  q = sub.add_parser("verify-question")
615
667
  q.add_argument("--answer-in", required=True)
616
668
  q.add_argument("--needle", required=True)
@@ -677,9 +729,17 @@ def main(argv: list[str] | None = None) -> int:
677
729
 
678
730
  if a.cmd == "handshake":
679
731
  if a.action == "open":
680
- st = handshake_open(root, a.id, a.transport)
732
+ st = handshake_open(root, a.id, a.transport, a.peer)
681
733
  print(f"[spawn] handshake {a.id} open — transport `{st['transport']}` "
682
- f"({st['transport_reason']})")
734
+ f"({st['transport_reason']}), state `{st['state']}`")
735
+ if st["state"] == HANDSHAKE_STATES[0]:
736
+ print(f" PARENT-FIRST. You have the address, so message \"{st['peer']}\" now: "
737
+ "tell it what to read, then ask it to reply with WHICH FILES it opened "
738
+ "before it answers anything. Record that with `--event readback`.")
739
+ elif st["transport"] == "message" and not st["peer"]:
740
+ print(" No --peer given, so this is the child-first flow: print the prompt and "
741
+ "wait. If you already started the child, re-open with `--peer <its "
742
+ "ListAgents name>` to message it directly instead.")
683
743
  if st["transport"] == "paste":
684
744
  print(" The child cannot message you. Print the prompt for the human to carry, "
685
745
  "and say so — otherwise they watch nothing happen and conclude it is "
@@ -691,23 +751,29 @@ def main(argv: list[str] | None = None) -> int:
691
751
  return EXIT_REFUSED
692
752
  if a.action == "status":
693
753
  over = handshake_chase_due(st)
694
- print(f"[spawn] {a.id}: {st['state']} via {st['transport']}")
754
+ peer = st.get("peer") or "(not recorded — see below)"
755
+ print(f"[spawn] {a.id}: {st['state']} via {st['transport']}, peer {peer}")
695
756
  if over:
696
757
  print(f" CHASE DUE — {over}s past the {CHASE_AFTER_S}s window. Silence is not "
697
758
  "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.")
759
+ "like one still reading.")
760
+ print(" " + chase_instruction(st))
700
761
  return EXIT_OK
701
762
  if a.action == "record":
702
763
  if not a.event:
703
764
  sys.stderr.write("[spawn] --event is required for `record`" + chr(10))
704
765
  return EXIT_REFUSED
705
- st, err = handshake_record(root, a.id, a.event)
766
+ st, err = handshake_record(root, a.id, a.event, a.peer)
706
767
  if err:
707
768
  sys.stderr.write("[spawn] " + err + chr(10))
708
769
  return EXIT_REFUSED
709
770
  print(f"[spawn] {a.id}: {st['state']}")
710
771
  return EXIT_OK
772
+ if a.action == "close" and not (st.get("peer") or "").strip() \
773
+ and st["state"] != HANDSHAKE_STATES[0]:
774
+ # Contact happened but nobody wrote down who with. The verdict has to reach someone.
775
+ print(" note no peer recorded although the handshake progressed — re-run "
776
+ "`record --peer <from-name>` so the next session can see who this was with.")
711
777
  ok, msg = handshake_close(root, a.id)
712
778
  (print if ok else sys.stderr.write)("[spawn] " + msg + ("" if ok else chr(10)))
713
779
  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
 
@@ -22,6 +22,13 @@ description**, and there may be **no codebase, PROJECT-FACTS, or prior PRD** to
22
22
  write a complete best-effort plan anyway.
23
23
 
24
24
  Absolute rules in this mode (they override everything below):
25
+
26
+ > **The same policy exists in `/caddis:prd`, and the two are deliberately NOT identical.**
27
+ > Each is specialised to the artefact its command writes — where the output goes, and
28
+ > where an unresolved decision is recorded. **Do not merge them, and do not replace
29
+ > either with a pointer:** headless mode is precisely when no human is present and the
30
+ > session will not go and read another command to find its own rules.
31
+
25
32
  - **NEVER ask a question, request clarification, or end your turn with questions.** Replying with a list
26
33
  of scoping questions is a hard failure. Never use AskUserQuestion, never pause, never wait for input.
27
34
  - **A bare title (or PRD path) is sufficient input.** Where information is missing, invent a reasonable,
@@ -112,7 +119,7 @@ escalation, never a planned default.
112
119
 
113
120
  ### Assign an execution LANE per phase (which harness runs it — not just which model)
114
121
  A tier can be served by an OSS provider instead of Anthropic (cheat sheet:
115
- `docs/guide/multi-model-workflow.md`; wiring: the caddis repo's `.caddis/plans/model-access.md`):
122
+ `docs/guide/multi-model-workflow.md` (in the caddis **source** repo — `docs/` is not shipped inside the installed plugin); wiring: the caddis repo's `.caddis/plans/model-access.md`):
116
123
  - **claude** (default) — this session, `/model <alias>` as above. Required for judgment-heavy,
117
124
  security-sensitive, or novel-architecture phases.
118
125
  - **glm-headless** — mechanical, fully-specced phases run on GLM without touching Claude quota:
@@ -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,23 @@ 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
+ <!-- shared:harness-friction-question — keep byte-identical across commands; a test pins it -->
91
+ **One question, asked here because here is where you are already reflecting.** The observations
92
+ that reach caddis today are the ones someone happened to recall days later — so small, frequent
93
+ friction never arrives, which is exactly the friction worth fixing.
94
+
95
+ If a command was awkward, a gate fired wrongly, a doc sent you the wrong way: park it against the
96
+ harness, not this repo.
97
+
98
+ ```
99
+ /caddis:park --harness <what was awkward, and what you expected>
100
+ ```
101
+
102
+ Nothing to say is the normal answer. Say nothing and move on — this must not become a ritual.
103
+ <!-- /shared:harness-friction-question -->
104
+
88
105
  ## Step 3 — write the resume doc (overwrite) with exactly these sections
89
106
  > **Where to write:** solo / single active branch → `.caddis/relay.md` (default).
90
107
  > **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,35 @@ 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
+ <!-- shared:harness-friction-question — keep byte-identical across commands; a test pins it -->
50
+ **One question, asked here because here is where you are already reflecting.** The observations
51
+ that reach caddis today are the ones someone happened to recall days later — so small, frequent
52
+ friction never arrives, which is exactly the friction worth fixing.
53
+
54
+ If a command was awkward, a gate fired wrongly, a doc sent you the wrong way: park it against the
55
+ harness, not this repo.
56
+
57
+ ```
58
+ /caddis:park --harness <what was awkward, and what you expected>
59
+ ```
60
+
61
+ Nothing to say is the normal answer. Say nothing and move on — this must not become a ritual.
62
+ <!-- /shared:harness-friction-question -->
63
+
47
64
  ## KB note format (OKF-lite — mandatory for every new note)
48
65
 
49
66
  Every new `.caddis/kb/*.md` note starts with this frontmatter block. **`type` is the only required
@@ -68,7 +85,7 @@ generated: { by: caddis/<model-id>, at: 2026-07-28T09:00:00Z }
68
85
 
69
86
  The full schema, the caddis↔OKF `status` mapping, and what caddis deliberately skips from OKF v0.2
70
87
  (`sources:`, `Attested Computation`) live in
71
- [`.github/instructions/document-frontmatter.instructions.md`](../../.github/instructions/document-frontmatter.instructions.md).
88
+ `.github/instructions/document-frontmatter.instructions.md` in the caddis source repo. (Not a link: a relative path out of the plugin directory resolves nowhere once installed.)
72
89
 
73
90
  **Backward compatible:** an existing note with no trust fields is still correct. Do not bulk-migrate
74
91
  old notes — add the fields as notes are next touched.
@@ -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.
@@ -30,6 +30,13 @@ forbidden**. This mode exists for one-line ideas: the card may be nothing more t
30
30
  fully actionable — a terse title is enough to write a complete draft PRD.
31
31
 
32
32
  Absolute rules in this mode (they override everything above):
33
+
34
+ > **The same policy exists in `/caddis:feature-plan`, and the two are deliberately NOT identical.**
35
+ > Each is specialised to the artefact its command writes — where the output goes, and
36
+ > where an unresolved decision is recorded. **Do not merge them, and do not replace
37
+ > either with a pointer:** headless mode is precisely when no human is present and the
38
+ > session will not go and read another command to find its own rules.
39
+
33
40
  - **NEVER ask a question. NEVER request clarification. NEVER end your turn with questions.** Replying with
34
41
  something like *"a few questions to scope this before I write the PRD"* is a hard failure. Never use
35
42
  AskUserQuestion, never pause for approval, never wait for input.
@@ -35,7 +35,7 @@ alias claude-glm="<path-to>/claude-harness/scripts/claude-oss.sh"
35
35
  ```
36
36
  Resolve `<path-to>` per the same plugin-vs-source rule as the generator itself (`${CLAUDE_PLUGIN_ROOT}`
37
37
  for a plugin install, the harness checkout path for caddis). Also set `CADDIS_KEYS_FILE`
38
- (default `~/.caddis/keys.env`) with the provider keys — see `docs/guide/providers-and-keys.md`.
38
+ (default `~/.caddis/keys.env`) with the provider keys — see `docs/guide/providers-and-keys.md` (in the caddis **source** repo — `docs/` is not shipped inside the installed plugin).
39
39
 
40
40
  ## After the deterministic step — deploy private harness skills (optional)
41
41
 
@@ -46,6 +46,7 @@ Use conventional commits: `fix:`, `feat:`, `chore:`, `refactor:`, `docs:`. Scope
46
46
 
47
47
  **2b. CROSS-REVIEW TRIGGER** — `ship` has no review pause at all, which is how sixteen fixes reached production unreviewed in one day.
48
48
 
49
+ <!-- shared:cross-review-trigger — keep byte-identical across commands; a test pins it -->
49
50
  **Run the trigger, do not eyeball the diff:**
50
51
 
51
52
  ```bash
@@ -60,6 +61,7 @@ gets switched off inside a week.
60
61
  It fires on SQL and repositories, `services/`, caches and refresh jobs, auth and RBAC, or a diff
61
62
  over 400 changed lines. No judgement in any of those — deliberately, because judgement is exactly
62
63
  what failed.
64
+ <!-- /shared:cross-review-trigger -->
63
65
 
64
66
  **3. PUSH** — to the repo's default branch (confirm the branch first; don't assume `main`):
65
67
  ```
@@ -52,6 +52,7 @@ are uncommitted.)
52
52
 
53
53
  ## Step 3b — Cross-review trigger (before anything is pushed)
54
54
 
55
+ <!-- shared:cross-review-trigger — keep byte-identical across commands; a test pins it -->
55
56
  **Run the trigger, do not eyeball the diff:**
56
57
 
57
58
  ```bash
@@ -66,6 +67,7 @@ gets switched off inside a week.
66
67
  It fires on SQL and repositories, `services/`, caches and refresh jobs, auth and RBAC, or a diff
67
68
  over 400 changed lines. No judgement in any of those — deliberately, because judgement is exactly
68
69
  what failed.
70
+ <!-- /shared:cross-review-trigger -->
69
71
 
70
72
  > **Why this is mechanical and not "consider a review here".** serve-sight shipped four
71
73
  > production releases in one day — sixteen fixes across SQL, caches, refresh jobs and React — and
@@ -24,6 +24,36 @@ A long session does not forget. **It recalls superseded facts fluently.** Measur
24
24
  Each was stated with conviction and was wrong. Asking that agent what it knows cannot find any of
25
25
  it.
26
26
 
27
+ ## Start the successor FIRST, then name it
28
+
29
+ **Preferred flow.** Start the child with a name you choose, then pass that same name here:
30
+
31
+ ```
32
+ claude --name successor-9c2 # in a new terminal
33
+ /caddis:spawn-session successor-9c2 finish the harvester # here
34
+ ```
35
+
36
+ `claude -n/--name` sets the session's display name, and that is exactly the name `SendMessage`
37
+ addresses. You pick it, so nothing has to be looked up.
38
+
39
+ **Check the row before you trust the name.** Run `ListAgents` once and confirm the child is there
40
+ and reads `interactive`, not `offline` — a listing carries long-dead sessions, so presence alone
41
+ proves nothing. Names are also **not unique**: if two rows share one, `SendMessage` needs the
42
+ ` [ref]` shown beside it. Pick a short distinct name and this never arises.
43
+
44
+ **Why this ordering.** With the child already running you hold its address from the start, so the
45
+ parent messages it directly and nothing has to be pasted. The older flow printed a prompt for a
46
+ human to carry, which meant the child did not exist yet and its address could not be known.
47
+
48
+ **What you give up, and how it is replaced.** In the older flow the child spoke first, so its
49
+ first message was itself proof it had read the handover. Here the parent speaks first, so that
50
+ proof is a separate step: **your opening message asks the child to reply with WHICH FILES it
51
+ opened, before it answers anything.** Ask for paths — a summary can be written without opening
52
+ anything. Record it with `--event readback`; until you do, the handshake refuses `--event answers`.
53
+
54
+ **No name given?** The command falls back to the older printed-prompt flow. That is also the only
55
+ route on agy and Codex, which have no `SendMessage`.
56
+
27
57
  The successor is what to pick up first: **$ARGUMENTS** (if empty, derive it from the parking-lot
28
58
  and the active plan).
29
59
 
@@ -242,6 +272,20 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_spawn.py" handshake status --id <id
242
272
  Two questions asked at chase time cannot have been pre-read in the handover, which is a stronger
243
273
  test than the original set — and it costs nothing, since you are holding the context anyway.
244
274
 
275
+ **Write down HOW to reach the child the moment it first messages you.** `SendMessage` addresses a
276
+ peer by the name `ListAgents` shows, and you cannot know that name before the child exists — it
277
+ arrives as the `from-name` on its first message. Capture it there or you will be holding an open
278
+ handshake with no way to send the chase or the verdict:
279
+
280
+ ```bash
281
+ ... handshake record --id <id> --event answers --peer <from-name>
282
+ ```
283
+
284
+ `handshake status` then prints the address, so a chase is a copy-paste. With no peer recorded it
285
+ says so plainly, because **that is itself the signal**: the child has not made contact. Run
286
+ `ListAgents` — and note a session started seconds ago can be missing from one listing, so a single
287
+ empty result is not proof it does not exist.
288
+
245
289
  Record each step, and the parent may not finish until the child has acknowledged:
246
290
 
247
291
  ```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:
@@ -20,9 +20,11 @@ python "<resolved-path>" [--days $ARGUMENTS]
20
20
  ```
21
21
 
22
22
  - Default window is **7 days**. If the user passed a number (e.g. `/usage-review 14`), use it.
23
- - Run from the project root so `.claude/usage-log.jsonl` is found correctly.
23
+ - Run from the project root so `.caddis/usage-log.jsonl` is found correctly. (The legacy
24
+ the legacy `.claude/usage-log.jsonl` is still read as a fallback during the migration.)
24
25
  - If the script exits with "No sessions found", tell the user and stop — no further steps.
25
- - The script prints a markdown report to stdout and writes `.claude/usage-review.html`.
26
+ - The script prints a markdown report to stdout and writes
27
+ `.caddis/reviews/usage-review.html` (override with `--output-dir`).
26
28
 
27
29
  ## Step 2 — present the report
28
30
 
@@ -50,21 +52,22 @@ the two `---` delimiters) of the named agent file and change its value. Show a d
50
52
  After any applies, tell the user which files changed and suggest:
51
53
  "Run `/usage-review` again after a few sessions to see the effect."
52
54
 
53
- ## Step 5 — offer to schedule a recurring review (optional)
55
+ ## Step 5 — do NOT offer to schedule this as a cloud routine
54
56
 
55
- After reporting the outcome, ask once:
57
+ **This command cannot run as a cloud routine, and offering one produces a routine that fails.**
56
58
 
57
- > "Want me to schedule this to run automatically every Wednesday? I can set it up with `/schedule`."
59
+ `/schedule` creates a **cloud** agent. This command reads `.caddis/usage-log.jsonl`, which the
60
+ local `Stop` hook writes on THIS machine and which is **gitignored** — so it is neither on the
61
+ cloud runner's filesystem nor in the clone it makes. The routine would fire on time and find
62
+ nothing.
58
63
 
59
- If the user says yes, use the `schedule` skill to create a weekly routine:
64
+ That also settles a contradiction this file used to carry: the description says "Data stays
65
+ on-machine — no telemetry, no server", and the old Step 5 offered a cloud routine two screens
66
+ later. The description is the correct half.
60
67
 
61
- ```
62
- Schedule a recurring cloud routine: every Wednesday, run /usage-review
63
- ```
64
-
65
- The routine should fire mid-week so findings are fresh for the next work sprint. If the user
66
- declines, skip silently — the SessionStart nudge in `inject_relay.py` already covers the
67
- "you haven't reviewed in 7 days" case.
68
+ **The recurring nudge already exists and is local.** Each run updates
69
+ `.caddis/.last-usage-review`, and the SessionStart hook reads it to remind you when a review is
70
+ overdue. Nothing to schedule.
68
71
 
69
72
  ## Notes
70
73
 
@@ -7,7 +7,7 @@ description: Explain (or apply) the model lanes — claude / claude-glm / cross-
7
7
 
8
8
  Explain the model-switching lanes so the user (or you, on their behalf) can move a session onto an
9
9
  OSS provider (GLM, DeepSeek, OpenRouter, or a custom endpoint) in one command, with no hardcoded key
10
- path. Full detail lives in the **Providers & keys** guide (`docs/guide/providers-and-keys.md`) — this
10
+ path. Full detail lives in the **Providers & keys** guide (`docs/guide/providers-and-keys.md` (in the caddis **source** repo — `docs/` is not shipped inside the installed plugin)) — this
11
11
  command is the quick-reference + do-it-now version.
12
12
 
13
13
  ## The lanes
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.94",
2
+ "poolVersion": "1.3.96",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.94",
4
+ "antigravity-plugin": "1.3.96",
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.7",
3
+ "version": "0.4.9",
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",