@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.
- package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/guard_agy.py +184 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +4 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
- package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
- package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- 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
|
}
|