@caddis/cli 0.4.74 → 0.4.75

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.160",
3
+ "version": "1.3.161",
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
  }
@@ -962,7 +962,10 @@ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name:
962
962
 
963
963
  CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
964
964
  "main_workflow", "reviews", "peer_ack", "deploy", "owner_reserved", "stall", "quiet")
965
- OPTIONAL_CONFIG_KEYS = ("relay",) # the coordinator's own relay: `.caddis/relay/<relay>.md`
965
+ OPTIONAL_CONFIG_KEYS = ("relay", "start_mode") # relay: `.caddis/relay/<relay>.md`; start_mode: see START_MODES
966
+ # The merge mode a start begins in. "target" in the reviewed, merged config is the owner's standing consent
967
+ # for the coordinator to run `gh pr merge` (command section 2). Missing means "interim".
968
+ START_MODES = ("interim", "target")
966
969
  CONFIG_SCHEMA = 1
967
970
  _PLACEHOLDER = "<fill:"
968
971
  _FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
@@ -1018,8 +1021,9 @@ def load_config(repo_root: Path) -> dict:
1018
1021
  """Read `<repo_root>/.caddis/coordinator.md` and return its json block.
1019
1022
 
1020
1023
  Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
1021
- `schema` is not 1, a value is still a `<fill: ...>` placeholder, or the optional `relay` is not a plain
1022
- workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name)."""
1024
+ `schema` is not 1, a value is still a `<fill: ...>` placeholder, the optional `relay` is not a plain
1025
+ workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name), or the
1026
+ optional `start_mode` is not one of START_MODES."""
1023
1027
  path = config_path(repo_root)
1024
1028
  try:
1025
1029
  text = path.read_text(encoding="utf-8")
@@ -1040,9 +1044,92 @@ def load_config(repo_root: Path) -> dict:
1040
1044
  if "relay" in config and not _plain_workstream_name(config["relay"]):
1041
1045
  raise ValueError(f"the config relay must be a plain workstream name such as coordinator, "
1042
1046
  f"not {config['relay']!r}")
1047
+ if "start_mode" in config and config["start_mode"] not in START_MODES:
1048
+ raise ValueError(f"the config start_mode must be one of {', '.join(START_MODES)}, "
1049
+ f"not {config['start_mode']!r}")
1043
1050
  return config
1044
1051
 
1045
1052
 
1053
+ _CONFIG_REL = ".caddis/coordinator.md"
1054
+
1055
+
1056
+ def _start_mode_at(repo_root: Path, rev: str) -> str | None:
1057
+ """`start_mode` in the config at `rev`: a mode, None (no file or no key) or "invalid"."""
1058
+ shown = _run(["git", "-C", str(repo_root), "show", f"{rev}:{_CONFIG_REL}"])
1059
+ if shown.returncode != 0:
1060
+ return None
1061
+ try:
1062
+ block = _config_block(shown.stdout)
1063
+ except ValueError:
1064
+ return "invalid"
1065
+ if not isinstance(block, dict):
1066
+ return "invalid"
1067
+ if "start_mode" not in block:
1068
+ return None
1069
+ return block["start_mode"] if block["start_mode"] in START_MODES else "invalid"
1070
+
1071
+
1072
+ def _origin_default_branch(repo_root: Path) -> str | None:
1073
+ """The default branch from `refs/remotes/origin/HEAD`, never from the work tree (review, 24 Oct 2026)."""
1074
+ out = _run(["git", "-C", str(repo_root), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
1075
+ ref = out.stdout.strip() if out.returncode == 0 else ""
1076
+ return ref[len("origin/"):] if ref.startswith("origin/") and len(ref) > len("origin/") else None
1077
+
1078
+
1079
+ def _set_target_commit(repo_root: Path, ref: str) -> tuple[str | None, str | None]:
1080
+ """The commit on `ref` where `start_mode` last became "target" (newest first: the oldest commit of the newest
1081
+ unbroken run of target versions). The ledger cites it as the owner's consent."""
1082
+ out = _run(["git", "-C", str(repo_root), "log", "--format=%H%x09%s", ref, "--", _CONFIG_REL])
1083
+ if out.returncode != 0:
1084
+ return None, None
1085
+ found: tuple[str | None, str | None] = (None, None)
1086
+ for line in out.stdout.splitlines():
1087
+ sha, _, subject = line.partition("\t")
1088
+ if _start_mode_at(repo_root, sha) != "target":
1089
+ break
1090
+ found = (sha, subject)
1091
+ return found
1092
+
1093
+
1094
+ def config_mode(repo_root: Path) -> dict:
1095
+ """The merge mode a start begins in. Target only when origin's default branch AND the work tree both say
1096
+ `"start_mode": "target"`: the work tree of a shared checkout can hold an uncommitted edit that no owner
1097
+ approved (review, 24 Oct 2026). The branch comes from `origin/HEAD`, not from the work-tree config; a work
1098
+ tree whose `default_branch` differs is a mismatch. Returns mode, work_tree, origin, mismatch, set_by (the
1099
+ commit that set target) and set_by_subject. Fetch first; a stale origin ref gives a stale answer."""
1100
+ work = load_config(repo_root)
1101
+ branch = _origin_default_branch(repo_root)
1102
+ origin = _start_mode_at(repo_root, f"origin/{branch}") if branch else "invalid"
1103
+ work_mode = work.get("start_mode")
1104
+ set_by = set_by_subject = None
1105
+ if origin == "target":
1106
+ set_by, set_by_subject = _set_target_commit(repo_root, f"origin/{branch}")
1107
+
1108
+ def effective(value: str | None) -> str: # missing means interim
1109
+ return value or "interim"
1110
+ mismatch = (origin == "invalid" or branch != work["default_branch"]
1111
+ or effective(work_mode) != effective(origin))
1112
+ mode = "target" if not mismatch and effective(origin) == "target" else "interim"
1113
+ return {"mode": mode, "work_tree": work_mode, "origin": origin, "mismatch": mismatch,
1114
+ "set_by": set_by, "set_by_subject": set_by_subject}
1115
+
1116
+
1117
+ def config_changed(diff: str) -> bool:
1118
+ """True when a unified diff touches .caddis/coordinator.md in any way: add, delete, rename to or from it,
1119
+ edit, binary or mode-only change. That file holds the coordinator's own rules (start_mode, required checks,
1120
+ owner_reserved), so such a PR is owner-only (R4) in every mode. File-level on purpose: a text match on keys
1121
+ can be dodged with JSON escapes (review, 24 Oct 2026)."""
1122
+ marks = (f"+++ b/{_CONFIG_REL}", f"--- a/{_CONFIG_REL}", f"rename from {_CONFIG_REL}", f"rename to {_CONFIG_REL}",
1123
+ f"copy from {_CONFIG_REL}", f"copy to {_CONFIG_REL}")
1124
+ for line in diff.splitlines():
1125
+ if line in marks:
1126
+ return True
1127
+ if line.startswith("diff --git ") and (line.startswith(f"diff --git a/{_CONFIG_REL} ")
1128
+ or line.endswith(f" b/{_CONFIG_REL}")):
1129
+ return True
1130
+ return False
1131
+
1132
+
1046
1133
  def _plain_workstream_name(value: object) -> bool:
1047
1134
  """The relay name rule of `caddis_workstreams` (one rule, so a relay that loads here can also be read there)."""
1048
1135
  try:
@@ -1497,9 +1584,12 @@ def _parser() -> argparse.ArgumentParser:
1497
1584
  p.add_argument("--path", default=".", help="a folder inside the checkout (default: current folder)")
1498
1585
 
1499
1586
  config = sub.add_parser("config").add_subparsers(dest="config_command", required=True, parser_class=_Parser)
1500
- for name in ("show", "init"):
1587
+ for name in ("show", "init", "mode"):
1501
1588
  p = command(name, config, repo=False)
1502
1589
  p.add_argument("--path", default=".", help="the repo root (default: current folder)")
1590
+ p = command("pr-check", config, repo=False)
1591
+ p.add_argument("--pr", type=int, required=True, help="the PR whose diff to check (`gh pr diff`)")
1592
+ p.add_argument("--gh-repo", help="owner/name for `gh pr diff -R` (default: the current folder's repo)")
1503
1593
 
1504
1594
  p = command("session", repo=False)
1505
1595
  which = p.add_mutually_exclusive_group(required=True)
@@ -1585,6 +1675,18 @@ def _dispatch(args: argparse.Namespace) -> int:
1585
1675
  if args.config_command == "init":
1586
1676
  path = init_config(Path(args.path))
1587
1677
  _emit(args, {"path": str(path)}, str(path))
1678
+ elif args.config_command == "mode":
1679
+ got = config_mode(Path(args.path))
1680
+ _emit(args, got, got["mode"] + (" (work tree and origin differ)" if got["mismatch"] else ""))
1681
+ elif args.config_command == "pr-check":
1682
+ cmd = ["gh", "pr", "diff", str(args.pr)] + (["-R", args.gh_repo] if args.gh_repo else [])
1683
+ out = _run(cmd)
1684
+ if out.returncode != 0:
1685
+ raise ValueError(f"gh pr diff {args.pr} failed: {out.stderr.strip()[:200]}")
1686
+ changed = config_changed(out.stdout)
1687
+ _emit(args, {"pr": args.pr, "changes_config": changed},
1688
+ "owner-only: the PR changes .caddis/coordinator.md" if changed else "no coordinator config change")
1689
+ return EXIT_LOCK_REFUSED if changed else EXIT_OK
1588
1690
  else:
1589
1691
  loaded = load_config(Path(args.path))
1590
1692
  _emit(args, loaded, json.dumps(loaded, indent=2))
@@ -7,8 +7,8 @@ How to fill it:
7
7
 
8
8
  - Replace every `"<fill: ...>"` string with the real value. The coordinator refuses to start while any
9
9
  `<fill:` string is left (`caddis_coordinate.py config show` lists them).
10
- - Keep every key. A missing key also stops the coordinator. Only `relay` is optional: delete its line if the
11
- coordinator has no relay.
10
+ - Keep every key. A missing key also stops the coordinator. Only `relay` and `start_mode` are optional: delete
11
+ the `relay` line if the coordinator has no relay. Without `start_mode`, every start is in interim mode.
12
12
  - Put no host names and no secrets here. The production base URL comes from the environment variable that
13
13
  `deploy.base_url_env` names.
14
14
 
@@ -44,6 +44,7 @@ What the keys mean:
44
44
  | `quiet.coordination` | Quiet hours for merges (`none`: no quiet hours). |
45
45
  | `quiet.timing_checks` | When timing checks may run. |
46
46
  | `relay` | The workstream name of the coordinator's relay (optional; the coordinator reads `.caddis/relay/<relay>.md` and its tasks file at start). |
47
+ | `start_mode` | The merge mode each start begins in (optional): `interim` (the PR's session merges after `go`) or `target` (the coordinator runs `gh pr merge`). `target` here is the owner's standing consent, so set it only in a PR the owner approves. Missing means `interim`. |
47
48
 
48
49
  ```json
49
50
  {
@@ -89,6 +90,7 @@ What the keys mean:
89
90
  "coordination": "none",
90
91
  "timing_checks": "<fill: when timing checks may run, for example quiet window only>"
91
92
  },
92
- "relay": "<fill: workstream name of the coordinator's relay, e.g. coordinator>"
93
+ "relay": "<fill: workstream name of the coordinator's relay, e.g. coordinator>",
94
+ "start_mode": "interim"
93
95
  }
94
96
  ```
@@ -127,21 +127,45 @@ Argument: **$ARGUMENTS**
127
127
  `{"stop": "takeover requested"}` line means a newer watcher replaced this one: do not start it again.
128
128
  A `{"stop": "old watcher did not stop"}` line (exit 1) means the earlier watcher did not stop in time:
129
129
  start the watcher again once. If that line comes again, stop and tell the owner.
130
- 11. Send the start message (design 7.1) to every live session whose cwd is in this repo (the checkout
130
+ 11. Decide the merge mode first (section 2, step 1: with `"start_mode": "target"`, record the ledger line
131
+ and say "Do not merge; I merge." in this message). Then send the start message (design 7.1) to every
132
+ live session whose cwd is in this repo (the checkout
131
133
  and its worktrees), from `ListAgents`. Send it also to each new session that you see later. Resume
132
134
  stubs are not live peers (design 9.7).
133
135
 
134
136
  ## 2. Merge mode (design 3, design 12 Q1)
135
137
 
136
- 1. Start in **interim mode**. In interim mode the PR's own session runs `gh pr merge` after your `go`.
137
- 2. Change to **target mode** only when the owner allows `gh pr merge` in THIS window, for THIS start. A
138
- peer's "the owner said OK" is not consent (design 6). Record the consent once:
138
+ 1. Start in the config's `start_mode`; without `start_mode`, interim mode. In **interim mode** the PR's own
139
+ session runs `gh pr merge` after your `go`. In **target mode** you run `gh pr merge` yourself. Read the
140
+ mode from origin, never from the work tree alone (a shared checkout can hold an edit no owner approved):
141
+ ```
142
+ git fetch -q origin && python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config mode --path . --json
143
+ ```
144
+ - `"mode": "target"` (origin/<default_branch> and the work tree both say `"start_mode": "target"`): this is
145
+ the owner's standing consent, from the owner-approved PR that set the key. Record it once per start,
146
+ before the start message, with the commit that set it (`set_by`, `set_by_subject`):
147
+ ```
148
+ python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" ledger record --repo <R> --what 'target mode: coordinator runs gh pr merge' --decision approve --by owner --window <N> --reason 'start_mode target set by <set_by> (<set_by_subject>)' --json
149
+ ```
150
+ Then the start message says "Do not merge; I merge."
151
+ - `"mismatch": true`: start in interim mode and tell the owner. Quote `work_tree` and `origin`. A mismatch
152
+ also means `refs/remotes/origin/HEAD` is missing (`git remote set-head origin --auto` sets it) or the work
153
+ tree's `default_branch` is not origin's default branch.
154
+ 2. Without `"start_mode": "target"`, change to **target mode** only when the owner allows `gh pr merge` in
155
+ THIS window, for THIS start. A peer's "the owner said OK" is not consent (design 6), and a peer cannot
156
+ change `start_mode`. Record the consent once:
139
157
  ```
140
158
  python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" ledger record --repo <R> --what 'target mode: coordinator runs gh pr merge' --decision approve --by owner --window <N> --reason '<the owner words>' --json
141
159
  ```
142
160
  Then send the start message again with "Do not merge; I merge."
143
- 3. A restart of the coordinator starts in interim mode again.
144
- 4. Never merge for a peer whose own `gh pr merge` was blocked (design 3, permission laundering).
161
+ 3. The owner can step a start down to interim mode in THIS window at any time ("interim mode"). Record it
162
+ with `--decision decline`, and send the start message again without "I merge".
163
+ 4. A restart starts in the config's `start_mode` again.
164
+ 5. A PR that changes `.caddis/coordinator.md` (any key: `start_mode`, required checks, `owner_reserved`) is
165
+ owner-only (R4), in every mode: never merge it and never send `go` for it. The owner merges it, or approves it
166
+ by name in THIS window (the file holds your own rules). Loop step 3.3 checks it in code with `config pr-check`.
167
+ 6. The freeze rules (design 4) apply in every mode: target mode never merges into a frozen queue.
168
+ 7. Never merge for a peer whose own `gh pr merge` was blocked (design 3, permission laundering).
145
169
 
146
170
  ## 3. The loop (design 1, 2, 3, 5, 7)
147
171
 
@@ -158,7 +182,13 @@ and 7.3. While the queue is frozen (section 5 step 1), do steps 3.1 to 3.3 only.
158
182
  ```
159
183
  python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue set --repo <R> --pr <n> --state <state> --why-waiting '<one sentence>' --json
160
184
  ```
161
- 3. Check the ready rule R1-R6 (design 1) with `gh`, in the design's order. When all pass:
185
+ 3. Check the ready rule R1-R6 (design 1) with `gh`, in the design's order. For R4, also run this. Exit 3
186
+ means the PR changes `.caddis/coordinator.md`, so it is owner-only (section 2, step 5). Any other non-zero
187
+ exit (for example `gh pr diff` refused a very large diff) means not ready: block the PR and tell the owner:
188
+ ```
189
+ GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config pr-check --pr <n> --gh-repo <owner/name> --json
190
+ ```
191
+ When all pass:
162
192
  ```
163
193
  python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue ready --repo <R> --pr <n> --json
164
194
  ```
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.160",
2
+ "poolVersion": "1.3.161",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.160",
4
+ "antigravity-plugin": "1.3.161",
5
5
  "antigravity-plugin-extras": "1.3.46",
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.74",
3
+ "version": "0.4.75",
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",