@caddis/cli 0.4.52 → 0.4.54

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.
Files changed (111) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  5. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
  12. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  13. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  14. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
  16. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
  19. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  22. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  23. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  25. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
  27. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  29. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  30. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  31. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  32. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  33. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  34. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  35. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  36. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  37. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  38. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  39. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  40. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  41. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  42. package/bundles/antigravity-plugin/skills/_registry.md +4 -6
  43. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  44. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  45. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  46. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
  47. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  49. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
  50. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  51. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  52. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  53. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  55. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
  56. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  57. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  58. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  59. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
  60. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  61. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  62. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
  64. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  65. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  66. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  67. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  68. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  69. package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
  70. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
  71. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
  72. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  73. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  74. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  76. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  77. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  78. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  79. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  80. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  81. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  82. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  83. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  84. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  85. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
  86. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  87. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  88. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  89. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  90. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  91. package/bundles/codex/AGENTS.md +5 -4
  92. package/bundles/manifest.json +3 -3
  93. package/package.json +1 -1
  94. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  95. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  96. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  97. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  98. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  99. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  100. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  101. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  102. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  103. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  104. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  105. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  106. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  107. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  108. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  109. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  110. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  111. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -0,0 +1,227 @@
1
+ """Protect a shared checkout from lanes writing into it, and tell handoff when a checkout is shared.
2
+
3
+ `shared` (handoff-integrity Phase 5, the design of fleet-handoff Phase 2): a checkout is shared when its root
4
+ `AGENTS.md` says `Shared checkout: yes`, or when `git status` lists a path this session did not change. In a
5
+ shared checkout handoff writes no tracked file there; it uses `caddis_docs_pr.py` instead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ from pathlib import Path
13
+ import re
14
+ import subprocess
15
+ import sys
16
+
17
+ try:
18
+ import caddis_exit
19
+ except ModuleNotFoundError as exc:
20
+ if exc.name != "caddis_exit":
21
+ raise
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
24
+ import caddis_exit
25
+
26
+
27
+ def get_porcelain_lines(repo: Path) -> tuple[int, list[str]]:
28
+ """Run git status --porcelain=v1 --untracked-files=all -z and parse entries."""
29
+ proc = subprocess.run(
30
+ ["git", "-C", str(repo), "status", "--porcelain=v1", "--untracked-files=all", "-z"],
31
+ capture_output=True,
32
+ text=True,
33
+ encoding="utf-8",
34
+ errors="replace",
35
+ )
36
+ if proc.returncode != 0:
37
+ return proc.returncode, []
38
+
39
+ raw = proc.stdout
40
+ if not raw:
41
+ return 0, []
42
+
43
+ parts = raw.split("\0")
44
+ if parts and parts[-1] == "":
45
+ parts.pop()
46
+
47
+ entries: list[str] = []
48
+ idx = 0
49
+ while idx < len(parts):
50
+ part = parts[idx]
51
+ # A rename or copy (X or Y is R or C) is followed by one more field: the original path.
52
+ if ("R" in part[:2] or "C" in part[:2]) and idx + 1 < len(parts):
53
+ orig_path = parts[idx + 1]
54
+ entries.append(f"{part} -> {orig_path}")
55
+ idx += 2
56
+ else:
57
+ entries.append(part)
58
+ idx += 1
59
+ return 0, entries
60
+
61
+
62
+ # A plain line, or a list item and/or bold: `- **Shared checkout: yes** (...)` (watch-sight's form until 16 Oct).
63
+ # After `yes` only the line end or a reason may follow: `(`, a dash, `.`, `:`, `,`, `;` or a `#` comment. A word
64
+ # after `yes` is prose ("Shared checkout: yes means ..."), not a declaration.
65
+ SHARED_RE = re.compile(r"^[ \t]*(?:[-*][ \t]+)?(?:\*\*)?Shared checkout:[ \t]*yes(?:\*\*)?[ \t]*(?:$|[(\-–—.:,;#])",
66
+ re.IGNORECASE | re.MULTILINE)
67
+
68
+
69
+ def entry_paths(entry: str) -> list[str]:
70
+ """The paths of one `get_porcelain_lines` entry: `XY path`, or `XY new -> orig` (both count) for a rename."""
71
+ rest = entry[3:]
72
+ if ("R" in entry[:2] or "C" in entry[:2]) and " -> " in rest:
73
+ new, orig = rest.split(" -> ", 1)
74
+ return [new, orig]
75
+ return [rest]
76
+
77
+
78
+ def _norm(path: str) -> str:
79
+ return path.strip().replace("\\", "/")
80
+
81
+
82
+ def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
83
+ """(True, reason) when handoff must not write tracked files in `root`; else (False, "").
84
+
85
+ `Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
86
+ run, the checkout counts as shared: a wrong "no" could commit another session's files.
87
+ """
88
+ agents = Path(root) / "AGENTS.md"
89
+ try:
90
+ text = agents.read_text(encoding="utf-8", errors="replace") if agents.is_file() else ""
91
+ except OSError:
92
+ text = ""
93
+ if SHARED_RE.search(text):
94
+ return True, "AGENTS.md says Shared checkout: yes"
95
+ ret, lines = get_porcelain_lines(Path(root))
96
+ if ret != 0:
97
+ return True, "git status failed, so changes by other sessions cannot be ruled out"
98
+ own = {_norm(p) for p in own_files}
99
+ foreign: list[str] = []
100
+ for entry in lines:
101
+ for path in entry_paths(entry):
102
+ if _norm(path) not in own and path not in foreign:
103
+ foreign.append(path)
104
+ if foreign:
105
+ return True, "changes not made by this session: " + ", ".join(foreign[:3])
106
+ return False, ""
107
+
108
+
109
+ def read_own_files(path: Path | None) -> set[str]:
110
+ """One path per line. The session-state list form `- `path`` is read too; blank lines are skipped.
111
+
112
+ A missing or unreadable file gives an empty set, so every dirty path counts as foreign (the safe side).
113
+ """
114
+ if path is None:
115
+ return set()
116
+ try:
117
+ raw = Path(path).read_text(encoding="utf-8", errors="replace")
118
+ except OSError:
119
+ return set()
120
+ own: set[str] = set()
121
+ for line in raw.splitlines():
122
+ item = line.strip()
123
+ if item.startswith("- "):
124
+ item = item[2:].strip()
125
+ if len(item) >= 2 and item.startswith("`") and item.endswith("`"):
126
+ item = item[1:-1]
127
+ if item:
128
+ own.add(_norm(item))
129
+ return own
130
+
131
+
132
+ def shared(root: Path, own_files_path: Path | None) -> int:
133
+ """Print `SHARED: yes <reason>` or `SHARED: no`; always exit 0."""
134
+ is_shared, reason = is_shared_checkout(root, read_own_files(own_files_path))
135
+ print(f"SHARED: {'yes' if is_shared else 'no'} {reason}".rstrip())
136
+ return caddis_exit.CLEAN
137
+
138
+
139
+ def snapshot(repo: Path, out_path: Path) -> int:
140
+ """Take a snapshot of current porcelain entries."""
141
+ ret, lines = get_porcelain_lines(repo)
142
+ if ret != 0:
143
+ sys.stderr.write(f"git status failed on {repo}\n")
144
+ return caddis_exit.NOT_RUN
145
+
146
+ data = {
147
+ "repo": str(repo),
148
+ "lines": lines,
149
+ }
150
+ try:
151
+ out_path.parent.mkdir(parents=True, exist_ok=True)
152
+ out_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
153
+ except Exception as exc:
154
+ sys.stderr.write(f"failed to write snapshot to {out_path}: {exc}\n")
155
+ return caddis_exit.NOT_RUN
156
+ return caddis_exit.CLEAN
157
+
158
+
159
+ def compare(repo: Path, snapshot_path: Path) -> int:
160
+ """Compare current entries to snapshot."""
161
+ if not snapshot_path.is_file():
162
+ sys.stderr.write(f"snapshot file not found: {snapshot_path}\n")
163
+ return caddis_exit.NOT_RUN
164
+
165
+ try:
166
+ data = json.loads(snapshot_path.read_text(encoding="utf-8"))
167
+ if not isinstance(data, dict) or not isinstance(data.get("lines"), list):
168
+ raise ValueError("no 'lines' list")
169
+ snapshot_lines = set(data["lines"])
170
+ except Exception as exc:
171
+ sys.stderr.write(f"invalid snapshot JSON {snapshot_path}: {exc}\n")
172
+ return caddis_exit.NOT_RUN
173
+
174
+ ret, current_lines = get_porcelain_lines(repo)
175
+ if ret != 0:
176
+ sys.stderr.write(f"git status failed on {repo}\n")
177
+ return caddis_exit.NOT_RUN
178
+
179
+ new_entries = [entry for entry in current_lines if entry not in snapshot_lines]
180
+ if new_entries:
181
+ for entry in new_entries:
182
+ print(f"NEW {entry}")
183
+ return caddis_exit.BLOCKED
184
+ else:
185
+ print("checkout unchanged")
186
+ return caddis_exit.CLEAN
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ """CLI entry point for caddis_checkout_guard."""
191
+ if argv is None:
192
+ argv = sys.argv[1:]
193
+
194
+ parser = argparse.ArgumentParser(description="Shared checkout guard.")
195
+ subparsers = parser.add_subparsers(dest="command")
196
+
197
+ snap_parser = subparsers.add_parser("snapshot")
198
+ snap_parser.add_argument("--repo", required=True, type=Path, help="Repository path")
199
+ snap_parser.add_argument("--out", required=True, type=Path, help="Snapshot output path")
200
+
201
+ cmp_parser = subparsers.add_parser("compare")
202
+ cmp_parser.add_argument("--repo", required=True, type=Path, help="Repository path")
203
+ cmp_parser.add_argument("--snapshot", required=True, type=Path, help="Snapshot file path")
204
+
205
+ shared_parser = subparsers.add_parser("shared", help="print SHARED: yes|no <reason> for handoff")
206
+ shared_parser.add_argument("--root", required=True, type=Path, help="Repository root")
207
+ shared_parser.add_argument("--own-files", type=Path, default=None,
208
+ help="File with one path per line: the files this session changed")
209
+
210
+ try:
211
+ args = parser.parse_args(argv)
212
+ except SystemExit:
213
+ return caddis_exit.NOT_RUN
214
+
215
+ if args.command == "snapshot":
216
+ return snapshot(args.repo, args.out)
217
+ elif args.command == "compare":
218
+ return compare(args.repo, args.snapshot)
219
+ elif args.command == "shared":
220
+ return shared(args.root, args.own_files)
221
+ else:
222
+ parser.print_help(sys.stderr)
223
+ return caddis_exit.NOT_RUN
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main())
@@ -0,0 +1,284 @@
1
+ """Write handoff's tracked doc edits in a temp worktree this session owns, then open one docs PR.
2
+
3
+ A shared checkout (see `caddis_checkout_guard.py shared`) may hold another session's uncommitted edits, so
4
+ handoff must not commit there. Instead:
5
+
6
+ caddis_docs_pr.py start --repo <root>
7
+ git fetch origin, then `git worktree add -b docs/handoff-<utc> <tmp> origin/<default>`;
8
+ prints `WORKTREE: <path>`. The worktree is new and belongs to this session only, so no other
9
+ session's edits can enter it.
10
+ caddis_docs_pr.py finish --worktree <path> --title T --body B
11
+ first stages every change in the worktree and saves the full diff (staged, unstaged and
12
+ untracked) to a new patch file. Then it commits, pushes, opens a PR with
13
+ `gh api repos/<owner>/<repo>/pulls`, prints `PR: <url>` and one `FILE: <path>` line per file.
14
+ With no change it prints `PR: none`. On any failure after the patch is saved it prints
15
+ `PATCH: <path>`. It removes the worktree and the local branch only after a PR opened, after the
16
+ patch was saved, or when there was no change. If the patch cannot be saved, it keeps the worktree
17
+ and prints `WORKTREE KEPT: <path>`. It never merges.
18
+
19
+ `finish` acts only on a linked worktree that `start` made: its git dir differs from the common git dir, it is
20
+ not the main worktree, it is under the temp folder, and its branch is `docs/handoff-<utc>[-n]`. Anything else
21
+ is refused (exit 4) and left as it is.
22
+
23
+ Exit codes (caddis_exit): 0 clean (PR opened, or no change), 1 blocked (the PR could not be opened),
24
+ 3 could not run (git or the push failed, no GitHub origin), 4 error (bad usage, not a docs-PR worktree).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import datetime
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import tempfile
38
+ from pathlib import Path
39
+
40
+ try:
41
+ import caddis_exit
42
+ import caddis_checkout_guard
43
+ import caddis_roadmap as roadmap
44
+ except ModuleNotFoundError as exc:
45
+ if exc.name not in {"caddis_exit", "caddis_checkout_guard", "caddis_roadmap"}:
46
+ raise
47
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
48
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
49
+ import caddis_exit
50
+ import caddis_checkout_guard
51
+ import caddis_roadmap as roadmap
52
+
53
+ BRANCH_PREFIX = "docs/handoff-"
54
+ DIR_PREFIX = "caddis-docs-pr-"
55
+ BRANCH_RE = re.compile(r"^docs/handoff-\d{14}(?:-\d+)?$")
56
+
57
+
58
+ class DocsPrError(Exception):
59
+ def __init__(self, message: str, code: int):
60
+ super().__init__(message)
61
+ self.code = code
62
+
63
+
64
+ def _git(root: Path, *args: str, check: bool = True):
65
+ """git through caddis_roadmap: no credential prompt, and a timeout on fetch and push."""
66
+ return roadmap._git(Path(root), *args, check=check)
67
+
68
+
69
+ def _utc() -> str:
70
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
71
+
72
+
73
+ def start(repo: Path, out=print) -> int:
74
+ repo = Path(repo).resolve()
75
+ base = roadmap.fetch(repo)
76
+ stamp = _utc()
77
+ tmp_root = Path(tempfile.gettempdir())
78
+ # Two starts in one second must not collide on the path or the branch.
79
+ suffix = ""
80
+ n = 1
81
+ while (tmp_root / f"{DIR_PREFIX}{stamp}{suffix}").exists() or \
82
+ _git(repo, "rev-parse", "--verify", "--quiet", f"refs/heads/{BRANCH_PREFIX}{stamp}{suffix}",
83
+ check=False).returncode == 0:
84
+ n += 1
85
+ suffix = f"-{n}"
86
+ worktree = tmp_root / f"{DIR_PREFIX}{stamp}{suffix}"
87
+ branch = f"{BRANCH_PREFIX}{stamp}{suffix}"
88
+ _git(repo, "worktree", "add", "--quiet", "-b", branch, str(worktree), f"origin/{base}")
89
+ out(f"WORKTREE: {worktree}")
90
+ return caddis_exit.CLEAN
91
+
92
+
93
+ def _abs(base: Path, raw: str) -> Path:
94
+ path = Path(raw.strip())
95
+ if not path.is_absolute():
96
+ path = base / path
97
+ return path.resolve()
98
+
99
+
100
+ def _main_repo(worktree: Path) -> Path:
101
+ """The main checkout: the first entry of `git worktree list --porcelain`."""
102
+ for line in _git(worktree, "worktree", "list", "--porcelain").stdout.splitlines():
103
+ if line.startswith("worktree "):
104
+ return Path(line[len("worktree "):]).resolve()
105
+ raise DocsPrError(f"git worktree list gave no main worktree for {worktree}", caddis_exit.ERROR)
106
+
107
+
108
+ def _is_under(path: Path, parent: Path) -> bool:
109
+ try:
110
+ path.relative_to(parent)
111
+ except ValueError:
112
+ return False
113
+ return path != parent
114
+
115
+
116
+ def _check_owned(worktree: Path) -> tuple[Path, str]:
117
+ """(main repo, branch) of a worktree `start` made; DocsPrError(ERROR) for anything else."""
118
+ if not worktree.is_dir():
119
+ raise DocsPrError(f"not a directory: {worktree}", caddis_exit.ERROR)
120
+ proc = _git(worktree, "rev-parse", "--show-toplevel", check=False)
121
+ if proc.returncode != 0:
122
+ raise DocsPrError(f"not a git worktree: {worktree}", caddis_exit.ERROR)
123
+ if Path(proc.stdout.strip()).resolve() != worktree.resolve():
124
+ raise DocsPrError(f"not the top of a worktree: {worktree}", caddis_exit.ERROR)
125
+ git_dir = _abs(worktree, _git(worktree, "rev-parse", "--git-dir").stdout)
126
+ common = _abs(worktree, _git(worktree, "rev-parse", "--git-common-dir").stdout)
127
+ if git_dir == common:
128
+ raise DocsPrError(f"{worktree} is not a linked worktree; nothing done", caddis_exit.ERROR)
129
+ main = _main_repo(worktree)
130
+ if main == worktree.resolve():
131
+ raise DocsPrError(f"{worktree} is the main checkout, not a docs-PR worktree; nothing done",
132
+ caddis_exit.ERROR)
133
+ branch = _git(worktree, "branch", "--show-current").stdout.strip()
134
+ if not BRANCH_RE.match(branch):
135
+ raise DocsPrError(f"{worktree} is on branch '{branch}', not a {BRANCH_PREFIX}<utc> branch; nothing done",
136
+ caddis_exit.ERROR)
137
+ resolved = worktree.resolve()
138
+ if resolved.parent != Path(tempfile.gettempdir()).resolve() or not resolved.name.startswith(DIR_PREFIX):
139
+ raise DocsPrError(f"{worktree} is not a {DIR_PREFIX}* folder directly in the temp folder that start uses; "
140
+ "nothing done", caddis_exit.ERROR)
141
+ return main, branch
142
+
143
+
144
+ def _changed_files(worktree: Path) -> list[str]:
145
+ """Every path in `git status --porcelain -z`, parsed on NUL, so spaces, ' -> ' and non-ASCII stay exact.
146
+
147
+ A rename or copy entry is followed by one more NUL field, the original path; both paths count.
148
+ """
149
+ proc = subprocess.run(["git", "-C", str(worktree), "status", "--porcelain=v1", "--untracked-files=all", "-z"],
150
+ capture_output=True, stdin=subprocess.DEVNULL)
151
+ if proc.returncode != 0:
152
+ raise DocsPrError(f"git status failed in {worktree}", caddis_exit.NOT_RUN)
153
+ parts = proc.stdout.decode("utf-8", errors="surrogateescape").split("\0")
154
+ files: list[str] = []
155
+ idx = 0
156
+ while idx < len(parts):
157
+ entry = parts[idx]
158
+ idx += 1
159
+ if len(entry) < 4:
160
+ continue
161
+ xy, paths = entry[:2], [entry[3:]]
162
+ if ("R" in xy or "C" in xy) and idx < len(parts):
163
+ paths.append(parts[idx])
164
+ idx += 1
165
+ for path in paths:
166
+ if path and path not in files:
167
+ files.append(path)
168
+ return files
169
+
170
+
171
+ def open_pr(gh_repo: str, branch: str, base: str, title: str, body: str) -> str:
172
+ """Open a PR with the REST API and return its URL. `gh` takes the account from GH_TOKEN when it is set."""
173
+ proc = roadmap.run_bounded(["gh", "api", f"repos/{gh_repo}/pulls", "-f", f"title={title}", "-f",
174
+ f"head={branch}", "-f", f"base={base}", "-f", f"body={body}"],
175
+ roadmap.GH_TIMEOUT, "gh api (open the PR)")
176
+ if proc.returncode != 0:
177
+ raise DocsPrError(f"could not open the PR: {(proc.stderr or proc.stdout).strip()[:300]}",
178
+ caddis_exit.BLOCKED)
179
+ try:
180
+ return str(json.loads(proc.stdout)["html_url"])
181
+ except (ValueError, KeyError, TypeError) as exc:
182
+ raise DocsPrError(f"gh api gave no PR URL: {exc}", caddis_exit.BLOCKED) from exc
183
+
184
+
185
+ def _save_patch(worktree: Path) -> Path:
186
+ """Stage every change and write the full staged diff to a new patch file (mkstemp: no predictable path).
187
+
188
+ `git add -A` is safe here: this worktree was made by `start` for this session only (see _check_owned).
189
+ """
190
+ _git(worktree, "add", "-A")
191
+ proc = subprocess.run(["git", "-C", str(worktree), "diff", "--cached", "--binary"],
192
+ capture_output=True, stdin=subprocess.DEVNULL)
193
+ if proc.returncode != 0:
194
+ raise DocsPrError(f"git diff failed in {worktree}", caddis_exit.NOT_RUN)
195
+ fd, name = tempfile.mkstemp(prefix=DIR_PREFIX, suffix=".patch")
196
+ with os.fdopen(fd, "wb") as handle:
197
+ handle.write(proc.stdout)
198
+ return Path(name)
199
+
200
+
201
+ def _remove(main: Path, worktree: Path, branch: str) -> None:
202
+ for args in (("worktree", "remove", "--force", "--force", str(worktree)), ("worktree", "prune"),
203
+ ("branch", "-D", branch)):
204
+ try:
205
+ _git(main, *args, check=False)
206
+ except (roadmap.RoadmapError, OSError):
207
+ pass
208
+ if worktree.exists():
209
+ shutil.rmtree(worktree, ignore_errors=True)
210
+
211
+
212
+ def finish(worktree: Path, title: str, body: str, out=print) -> int:
213
+ worktree = Path(worktree).resolve()
214
+ main, branch = _check_owned(worktree) # refuses before anything is changed or removed
215
+ # Save the session's edits FIRST. Until the patch exists, nothing may remove the worktree.
216
+ try:
217
+ files = _changed_files(worktree)
218
+ patch = _save_patch(worktree) if files else None
219
+ except (DocsPrError, roadmap.RoadmapError, OSError) as exc:
220
+ out(f"docs PR failed: could not save the change: {exc}")
221
+ out(f"WORKTREE KEPT: {worktree}")
222
+ return getattr(exc, "code", caddis_exit.NOT_RUN)
223
+ if patch is None:
224
+ _remove(main, worktree, branch)
225
+ out("PR: none")
226
+ return caddis_exit.CLEAN
227
+ pushed = False
228
+ try:
229
+ gh_repo = roadmap.gh_repo_of(main)
230
+ if gh_repo is None:
231
+ raise DocsPrError("origin is not a GitHub remote; cannot open a docs PR", caddis_exit.NOT_RUN)
232
+ base = roadmap.default_branch(main)
233
+ _git(worktree, "commit", "--quiet", "-m", title)
234
+ _git(worktree, "push", "--quiet", "-u", "origin", branch)
235
+ pushed = True
236
+ url = open_pr(gh_repo, branch, base, title, body)
237
+ except (DocsPrError, roadmap.RoadmapError, OSError) as exc:
238
+ out(f"docs PR failed: {exc}")
239
+ out(f"PATCH: {patch}")
240
+ if pushed:
241
+ try:
242
+ _git(main, "push", "--quiet", "origin", "--delete", branch, check=False)
243
+ except (roadmap.RoadmapError, OSError):
244
+ pass
245
+ _remove(main, worktree, branch)
246
+ return getattr(exc, "code", caddis_exit.NOT_RUN)
247
+ out(f"PR: {url}")
248
+ for path in files:
249
+ out(f"FILE: {path}")
250
+ _remove(main, worktree, branch)
251
+ try:
252
+ patch.unlink()
253
+ except OSError:
254
+ pass
255
+ return caddis_exit.CLEAN
256
+
257
+
258
+ def main(argv: list[str] | None = None) -> int:
259
+ parser = argparse.ArgumentParser(description="Handoff's docs PR from a temp worktree.")
260
+ sub = parser.add_subparsers(dest="command")
261
+ p_start = sub.add_parser("start", help="make the worktree from origin/<default>")
262
+ p_start.add_argument("--repo", required=True, type=Path)
263
+ p_finish = sub.add_parser("finish", help="save a patch, commit, push and open the PR, then remove the worktree")
264
+ p_finish.add_argument("--worktree", required=True, type=Path)
265
+ p_finish.add_argument("--title", required=True)
266
+ p_finish.add_argument("--body", required=True)
267
+ try:
268
+ args = parser.parse_args(argv)
269
+ except SystemExit:
270
+ return caddis_exit.ERROR
271
+ try:
272
+ if args.command == "start":
273
+ return start(args.repo)
274
+ if args.command == "finish":
275
+ return finish(args.worktree, args.title, args.body)
276
+ except (DocsPrError, roadmap.RoadmapError) as exc:
277
+ print(f"caddis_docs_pr: {exc}", file=sys.stderr)
278
+ return exc.code
279
+ parser.print_help(sys.stderr)
280
+ return caddis_exit.ERROR
281
+
282
+
283
+ if __name__ == "__main__":
284
+ sys.exit(main())
@@ -5,6 +5,7 @@ BLOCKED = 1
5
5
  ADVISORY = 2
6
6
  NOT_RUN = 3
7
7
  ERROR = 4
8
+ ROUND_LIMIT = 5 # caddis_review.py --plan: round limit reached, stop, the owner decides
8
9
 
9
10
  MEANING = {
10
11
  CLEAN: "clean",
@@ -12,4 +13,5 @@ MEANING = {
12
13
  ADVISORY: "advisory",
13
14
  NOT_RUN: "could not run",
14
15
  ERROR: "error or bad usage",
16
+ ROUND_LIMIT: "round limit reached, the owner decides",
15
17
  }