@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,439 @@
|
|
|
1
|
+
"""Fleet handoff rules: session names, acks, the roster and the restart sheet.
|
|
2
|
+
|
|
3
|
+
Plan: .caddis/plans/fleet-handoff.md (Phase 4). `/caddis:fleet-close` and `/caddis:fleet-open` only call
|
|
4
|
+
this script, so every fleet rule lives here and has a test.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import caddis_exit
|
|
20
|
+
except ModuleNotFoundError as exc:
|
|
21
|
+
if exc.name != "caddis_exit":
|
|
22
|
+
raise
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
25
|
+
import caddis_exit
|
|
26
|
+
|
|
27
|
+
ACK_FIELDS = ("name", "role", "machine", "folder", "relay", "tasks", "mode", "owns-watchers", "worktrees")
|
|
28
|
+
NO_ACK = "no ack"
|
|
29
|
+
NO_NEW_SESSION = ("keeps-running", "nothing", NO_ACK)
|
|
30
|
+
TASKS_HEADER = "| Old # | Status | Subject |"
|
|
31
|
+
|
|
32
|
+
_STAMP = re.compile(r"-\d{4}-\d{2}$")
|
|
33
|
+
_SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
34
|
+
_ACK_LINE = re.compile(r"^\s*FLEET-ACK\s+(.*)$", re.MULTILINE)
|
|
35
|
+
_PAIR = re.compile(r"([a-z][a-z-]*)=(.*?)(?=\s+[a-z][a-z-]*=|\s*$)")
|
|
36
|
+
_SHARED_MARKER = re.compile(r"^Shared checkout:\s*yes\b", re.IGNORECASE | re.MULTILINE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AckError(ValueError):
|
|
40
|
+
"""An ack that cannot be used; the message names the missing or bad field."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Ack:
|
|
45
|
+
name: str
|
|
46
|
+
role: str
|
|
47
|
+
machine: str
|
|
48
|
+
folder: str
|
|
49
|
+
relay: str
|
|
50
|
+
tasks: str
|
|
51
|
+
mode: str
|
|
52
|
+
owns_watchers: bool
|
|
53
|
+
worktrees: str
|
|
54
|
+
prompt: str = ""
|
|
55
|
+
tasks_inline: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---- names ------------------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def role_of(name: str) -> str:
|
|
61
|
+
"""The name without a trailing `-MMDD-HH`; a name without it is its own role."""
|
|
62
|
+
return _STAMP.sub("", name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def new_name(role: str, now: datetime) -> str:
|
|
66
|
+
return f"{role}-{now:%m%d}-{now:%H}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def newest(role: str, names: list[str]) -> str | None:
|
|
70
|
+
"""The name of `role` with the largest `MMDD-HH`; a bare `role` ranks lowest.
|
|
71
|
+
|
|
72
|
+
Across a year end (`1231-23` vs `0101-00`) this compares wrong; accepted, the roster holds the
|
|
73
|
+
true name.
|
|
74
|
+
"""
|
|
75
|
+
pattern = re.compile(r"^" + re.escape(role) + r"-(\d{4}-\d{2})$")
|
|
76
|
+
best: tuple[str, str] | None = None
|
|
77
|
+
for name in names:
|
|
78
|
+
name = name.strip()
|
|
79
|
+
if name == role:
|
|
80
|
+
key = ""
|
|
81
|
+
else:
|
|
82
|
+
m = pattern.match(name)
|
|
83
|
+
if not m:
|
|
84
|
+
continue
|
|
85
|
+
key = m.group(1)
|
|
86
|
+
if best is None or key > best[0]:
|
|
87
|
+
best = (key, name)
|
|
88
|
+
return best[1] if best else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---- acks -------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _split_prompt(body: str) -> tuple[str, str]:
|
|
94
|
+
"""Pull an inline tasks table (remote sessions) out of the resume prompt."""
|
|
95
|
+
lines = body.splitlines()
|
|
96
|
+
for i, line in enumerate(lines):
|
|
97
|
+
if line.strip() == TASKS_HEADER:
|
|
98
|
+
end = i + 1
|
|
99
|
+
while end < len(lines) and lines[end].strip().startswith("|"):
|
|
100
|
+
end += 1
|
|
101
|
+
table = "\n".join(lines[i:end])
|
|
102
|
+
prompt = "\n".join(lines[:i] + lines[end:])
|
|
103
|
+
return prompt.strip(), table
|
|
104
|
+
return body.strip(), ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_ack(text: str) -> Ack:
|
|
108
|
+
"""Read a `FLEET-ACK` block and the resume prompt after it (all text after the fenced block)."""
|
|
109
|
+
m = _ACK_LINE.search(text)
|
|
110
|
+
if not m:
|
|
111
|
+
raise AckError("no FLEET-ACK line")
|
|
112
|
+
fields = {k: v.strip() for k, v in _PAIR.findall(m.group(1))}
|
|
113
|
+
for key in ACK_FIELDS:
|
|
114
|
+
if not fields.get(key):
|
|
115
|
+
raise AckError(f"missing field: {key}")
|
|
116
|
+
mode = fields["mode"]
|
|
117
|
+
if mode not in ("new", "keeps-running", "nothing") and not re.fullmatch(r"restore-on:\S+", mode):
|
|
118
|
+
raise AckError(f"bad field: mode={mode} (new, keeps-running, restore-on:<machine> or nothing)")
|
|
119
|
+
watchers = fields["owns-watchers"].lower()
|
|
120
|
+
if watchers not in ("yes", "no"):
|
|
121
|
+
raise AckError(f"bad field: owns-watchers={fields['owns-watchers']} (yes or no)")
|
|
122
|
+
if not _SAFE_NAME.match(fields["name"]):
|
|
123
|
+
raise AckError(f"bad field: name={fields['name']}")
|
|
124
|
+
rest = text[m.end():]
|
|
125
|
+
fence = rest.find("```")
|
|
126
|
+
body = rest
|
|
127
|
+
if fence >= 0: # skip the closing fence line of the FLEET-ACK block
|
|
128
|
+
after = rest[fence:]
|
|
129
|
+
body = after.split("\n", 1)[1] if "\n" in after else ""
|
|
130
|
+
prompt, inline = _split_prompt(body)
|
|
131
|
+
return Ack(
|
|
132
|
+
name=fields["name"], role=fields["role"], machine=fields["machine"], folder=fields["folder"],
|
|
133
|
+
relay=fields["relay"], tasks=fields["tasks"], mode=mode, owns_watchers=watchers == "yes",
|
|
134
|
+
worktrees=fields["worktrees"], prompt=prompt, tasks_inline=inline,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def no_ack(name: str) -> Ack:
|
|
139
|
+
"""A roster row for a session that never acked."""
|
|
140
|
+
return Ack(name=name, role=role_of(name), machine="-", folder="-", relay="-", tasks="-", mode=NO_ACK,
|
|
141
|
+
owns_watchers=False, worktrees="-")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---- order, roster, sheet ---------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _norm_folder(folder: str) -> str:
|
|
147
|
+
return folder.replace("\\", "/").rstrip("/").lower()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _group(ack: Ack, coord_folder: str | None) -> int:
|
|
151
|
+
"""1 owns watchers; 2 coordinator's repo; 3 other repos; 4 restore-on; 5 no new session.
|
|
152
|
+
|
|
153
|
+
A session that needs no new session is group 5 even when it owns watchers: its watchers keep running.
|
|
154
|
+
"""
|
|
155
|
+
if ack.mode in NO_NEW_SESSION:
|
|
156
|
+
return 5
|
|
157
|
+
if ack.owns_watchers:
|
|
158
|
+
return 1
|
|
159
|
+
if ack.mode.startswith("restore-on:"):
|
|
160
|
+
return 4
|
|
161
|
+
if coord_folder is not None and _norm_folder(ack.folder) == coord_folder:
|
|
162
|
+
return 2
|
|
163
|
+
return 3
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def order(acks: list[Ack], coordinator: str) -> list[Ack]:
|
|
167
|
+
coord = next((a for a in acks if a.name == coordinator), None)
|
|
168
|
+
coord_folder = _norm_folder(coord.folder) if coord else None
|
|
169
|
+
return sorted(acks, key=lambda a: (_group(a, coord_folder), a.name != coordinator, a.name))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _cell(value: str) -> str:
|
|
173
|
+
return value.replace("|", "\\|")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_roster(acks: list[Ack], coordinator: str) -> str:
|
|
177
|
+
lines = [
|
|
178
|
+
f"# Fleet roster (coordinator {coordinator})",
|
|
179
|
+
"",
|
|
180
|
+
"| Role | Current name | Machine | Folder | Relay | Tasks file | Mode | Worktrees |",
|
|
181
|
+
"|---|---|---|---|---|---|---|---|",
|
|
182
|
+
]
|
|
183
|
+
for a in order(acks, coordinator):
|
|
184
|
+
cells = (a.role, a.name, a.machine, a.folder, a.relay, a.tasks, a.mode, a.worktrees)
|
|
185
|
+
lines.append("| " + " | ".join(_cell(c) for c in cells) + " |")
|
|
186
|
+
return "\n".join(lines) + "\n"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _fenced(text: str) -> str:
|
|
190
|
+
longest = max((len(r) for r in re.findall(r"`+", text)), default=0)
|
|
191
|
+
fence = "`" * max(3, longest + 1)
|
|
192
|
+
return f"{fence}text\n{text}\n{fence}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def start_line(ack: Ack, stamp: datetime) -> str:
|
|
196
|
+
"""The one line the owner pastes into a new terminal (works in PowerShell and bash): go to the row's folder, start
|
|
197
|
+
claude named for the role at the sheet's hour, with `/caddis:fleet-open <role>` as the first prompt.
|
|
198
|
+
|
|
199
|
+
No permission or Remote Control flags: every start reads `defaultMode` and `remoteControlAtStartup` from the
|
|
200
|
+
owner's settings (owner, 16 Oct). caddis starts no process; the owner does (`claude --bg` decided against)."""
|
|
201
|
+
return f'cd "{ack.folder}"; claude --name {new_name(ack.role, stamp)} "/caddis:fleet-open {ack.role}"'
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def render_sheet(acks: list[Ack], coordinator: str, stamp: datetime) -> str:
|
|
205
|
+
ordered = order(acks, coordinator)
|
|
206
|
+
coord = next((a for a in acks if a.name == coordinator), None)
|
|
207
|
+
coord_folder = _norm_folder(coord.folder) if coord else None
|
|
208
|
+
lines = [
|
|
209
|
+
f"# Restart sheet (coordinator {coordinator}, {stamp:%Y-%m-%d %H:%M})",
|
|
210
|
+
"",
|
|
211
|
+
"| Order | Role | Start claude in | Start line |",
|
|
212
|
+
"|---|---|---|---|",
|
|
213
|
+
]
|
|
214
|
+
for i, a in enumerate(ordered, 1):
|
|
215
|
+
where = a.folder if not a.mode.startswith("restore-on:") else f"{a.mode[len('restore-on:'):]}: {a.folder}"
|
|
216
|
+
first = "no new session" if _group(a, coord_folder) == 5 else f"`{start_line(a, stamp)}`"
|
|
217
|
+
lines.append(f"| {i} | {_cell(a.role)} | {_cell(where)} | {first} |")
|
|
218
|
+
for i, a in enumerate(ordered, 1):
|
|
219
|
+
lines += ["", f"### {i}. {a.role}", ""]
|
|
220
|
+
if a.mode == NO_ACK:
|
|
221
|
+
lines.append(f"No ack from {a.name}: run `/caddis:handoff` in that session by hand.")
|
|
222
|
+
continue
|
|
223
|
+
if _group(a, coord_folder) == 5:
|
|
224
|
+
lines.append(f"Mode `{a.mode}`: no new session.")
|
|
225
|
+
lines.append("")
|
|
226
|
+
lines.append(_fenced(a.prompt))
|
|
227
|
+
if a.tasks_inline:
|
|
228
|
+
lines += ["", "Tasks (inline, remote session):", "", a.tasks_inline]
|
|
229
|
+
return "\n".join(lines) + "\n"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---- check ------------------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _roster_rows(roster_text: str) -> list[dict[str, str]]:
|
|
235
|
+
rows: list[dict[str, str]] = []
|
|
236
|
+
header: list[str] | None = None
|
|
237
|
+
for line in roster_text.splitlines():
|
|
238
|
+
if not line.startswith("|"):
|
|
239
|
+
continue
|
|
240
|
+
cells = [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", line.strip())[1:-1]]
|
|
241
|
+
if header is None:
|
|
242
|
+
header = cells
|
|
243
|
+
continue
|
|
244
|
+
if all(set(c) <= {"-"} for c in cells):
|
|
245
|
+
continue
|
|
246
|
+
rows.append(dict(zip(header, cells)))
|
|
247
|
+
return rows
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _sheet_prompts(sheet_text: str) -> dict[str, str]:
|
|
251
|
+
"""Role -> the text of the first fenced block in its `### <n>. <role>` section."""
|
|
252
|
+
prompts: dict[str, str] = {}
|
|
253
|
+
sections = re.split(r"^### \d+\. ", sheet_text, flags=re.MULTILINE)[1:]
|
|
254
|
+
for section in sections:
|
|
255
|
+
role, _, body = section.partition("\n")
|
|
256
|
+
m = re.search(r"^(`{3,})[^\n]*\n(.*?)\n\1\s*$", body, re.MULTILINE | re.DOTALL)
|
|
257
|
+
prompts[role.strip()] = m.group(2).strip() if m else ""
|
|
258
|
+
return prompts
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _is_shared(folder: str, folder_counts: dict[str, int]) -> bool:
|
|
262
|
+
"""Shared when the folder's AGENTS.md says so, or two sessions that restart both work in it."""
|
|
263
|
+
if folder_counts.get(_norm_folder(folder), 0) > 1:
|
|
264
|
+
return True
|
|
265
|
+
try:
|
|
266
|
+
return bool(_SHARED_MARKER.search((Path(folder) / "AGENTS.md").read_text(encoding="utf-8")))
|
|
267
|
+
except (OSError, ValueError):
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check_sheet(sheet_text: str, roster_text: str) -> list[str]:
|
|
272
|
+
rows = [r for r in _roster_rows(roster_text)
|
|
273
|
+
if r.get("Mode") == "new" or r.get("Mode", "").startswith("restore-on:")]
|
|
274
|
+
prompts = _sheet_prompts(sheet_text)
|
|
275
|
+
counts: dict[str, int] = {}
|
|
276
|
+
for r in rows:
|
|
277
|
+
key = _norm_folder(r.get("Folder", ""))
|
|
278
|
+
counts[key] = counts.get(key, 0) + 1
|
|
279
|
+
errors: list[str] = []
|
|
280
|
+
for r in rows:
|
|
281
|
+
role = r.get("Role", "")
|
|
282
|
+
prompt = prompts.get(role, "")
|
|
283
|
+
if not prompt:
|
|
284
|
+
errors.append(f"{role}: no resume prompt on the sheet")
|
|
285
|
+
continue
|
|
286
|
+
tasks = r.get("Tasks file", "none")
|
|
287
|
+
if tasks not in ("none", "-", "") and tasks not in prompt:
|
|
288
|
+
errors.append(f"{role}: the resume prompt does not load {tasks}")
|
|
289
|
+
if "is now" not in prompt:
|
|
290
|
+
errors.append(f"{role}: the resume prompt does not greet peers with `is now`")
|
|
291
|
+
if _is_shared(r.get("Folder", ""), counts) and "origin/main" not in prompt:
|
|
292
|
+
errors.append(f"{role}: shared checkout, but the resume prompt does not say origin/main")
|
|
293
|
+
return errors
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---- session presence -------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def whoami(session_id: str, name: str, root: Path) -> Path:
|
|
299
|
+
"""Write `<artifacts>/session-state/<sid>.name`: one tab-separated line `name repo root branch`."""
|
|
300
|
+
slug = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64]
|
|
301
|
+
if not slug:
|
|
302
|
+
raise ValueError("empty session id")
|
|
303
|
+
try:
|
|
304
|
+
from claudster_config import artifact_root
|
|
305
|
+
art = Path(artifact_root(str(root)))
|
|
306
|
+
except Exception:
|
|
307
|
+
art = root / ".caddis"
|
|
308
|
+
branch = "-"
|
|
309
|
+
for cmd in (["symbolic-ref", "--short", "-q", "HEAD"], ["rev-parse", "--short", "HEAD"]): # branch, else detached
|
|
310
|
+
proc = subprocess.run(["git", "-C", str(root), *cmd], capture_output=True, text=True,
|
|
311
|
+
encoding="utf-8", errors="replace")
|
|
312
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
313
|
+
branch = proc.stdout.strip()
|
|
314
|
+
break
|
|
315
|
+
out = art / "session-state" / f"{slug}.name"
|
|
316
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
out.write_text(f"{name}\t{root}\t{branch}\n", encoding="utf-8")
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---- CLI --------------------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def _load_acks(fleet_dir: Path) -> list[Ack]:
|
|
324
|
+
acks_dir = fleet_dir / "acks"
|
|
325
|
+
if not acks_dir.is_dir():
|
|
326
|
+
return []
|
|
327
|
+
return [parse_ack(p.read_text(encoding="utf-8")) for p in sorted(acks_dir.glob("*.md"))]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def main(argv: list[str] | None = None) -> int:
|
|
331
|
+
parser = argparse.ArgumentParser(prog="caddis_fleet.py", description=__doc__.splitlines()[0])
|
|
332
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
333
|
+
p = sub.add_parser("role-of", help="the role of a session name")
|
|
334
|
+
p.add_argument("name")
|
|
335
|
+
p = sub.add_parser("new-name", help="<role>-<MMDD>-<HH> for now, local time")
|
|
336
|
+
p.add_argument("role")
|
|
337
|
+
p = sub.add_parser("newest", help="the newest name of a role")
|
|
338
|
+
p.add_argument("role")
|
|
339
|
+
p.add_argument("--names-file", required=True, type=Path, help="one session name per line")
|
|
340
|
+
ack_p = sub.add_parser("ack", help="acks from closing sessions")
|
|
341
|
+
ack_sub = ack_p.add_subparsers(dest="ack_cmd", required=True)
|
|
342
|
+
p = ack_sub.add_parser("add", help="validate an ack and save it as acks/<name>.md")
|
|
343
|
+
p.add_argument("--dir", required=True, type=Path)
|
|
344
|
+
p.add_argument("--text-file", required=True, type=Path)
|
|
345
|
+
p = sub.add_parser("write", help="write roster.md and restart-sheet.md, print the sheet")
|
|
346
|
+
p.add_argument("--dir", required=True, type=Path)
|
|
347
|
+
p.add_argument("--coordinator", required=True)
|
|
348
|
+
p.add_argument("--no-ack", action="append", default=[], metavar="NAME",
|
|
349
|
+
help="a session that never acked (repeatable)")
|
|
350
|
+
p = sub.add_parser("check", help="check the restart sheet against the roster")
|
|
351
|
+
p.add_argument("--dir", required=True, type=Path)
|
|
352
|
+
p = sub.add_parser("whoami", help="record this session's name, repo root and branch")
|
|
353
|
+
p.add_argument("--session-id", required=True)
|
|
354
|
+
p.add_argument("--name", required=True)
|
|
355
|
+
p.add_argument("--root", default=".", type=Path)
|
|
356
|
+
args = parser.parse_args(argv)
|
|
357
|
+
|
|
358
|
+
if args.cmd == "role-of":
|
|
359
|
+
print(role_of(args.name))
|
|
360
|
+
return caddis_exit.CLEAN
|
|
361
|
+
if args.cmd == "new-name":
|
|
362
|
+
print(new_name(args.role, datetime.now()))
|
|
363
|
+
return caddis_exit.CLEAN
|
|
364
|
+
if args.cmd == "newest":
|
|
365
|
+
try:
|
|
366
|
+
names = args.names_file.read_text(encoding="utf-8").splitlines()
|
|
367
|
+
except OSError as exc:
|
|
368
|
+
sys.stderr.write(f"cannot read {args.names_file}: {exc}\n")
|
|
369
|
+
return caddis_exit.NOT_RUN
|
|
370
|
+
found = newest(args.role, names)
|
|
371
|
+
if found is None:
|
|
372
|
+
return caddis_exit.BLOCKED
|
|
373
|
+
print(found)
|
|
374
|
+
return caddis_exit.CLEAN
|
|
375
|
+
if args.cmd == "ack":
|
|
376
|
+
try:
|
|
377
|
+
text = args.text_file.read_text(encoding="utf-8")
|
|
378
|
+
except OSError as exc:
|
|
379
|
+
sys.stderr.write(f"cannot read {args.text_file}: {exc}\n")
|
|
380
|
+
return caddis_exit.NOT_RUN
|
|
381
|
+
try:
|
|
382
|
+
ack = parse_ack(text)
|
|
383
|
+
except AckError as exc:
|
|
384
|
+
print(f"[FLEET] bad ack: {exc}")
|
|
385
|
+
return caddis_exit.BLOCKED
|
|
386
|
+
out = args.dir / "acks" / f"{ack.name}.md"
|
|
387
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
out.write_text(text, encoding="utf-8")
|
|
389
|
+
print(f"[FLEET] ack saved: {ack.name} ({ack.mode}) -> {out}")
|
|
390
|
+
return caddis_exit.CLEAN
|
|
391
|
+
if args.cmd == "write":
|
|
392
|
+
try:
|
|
393
|
+
acks = _load_acks(args.dir)
|
|
394
|
+
except AckError as exc:
|
|
395
|
+
print(f"[FLEET] bad saved ack: {exc}")
|
|
396
|
+
return caddis_exit.BLOCKED
|
|
397
|
+
known = {a.name for a in acks}
|
|
398
|
+
acks += [no_ack(n) for n in args.no_ack if n not in known]
|
|
399
|
+
if not acks:
|
|
400
|
+
print(f"[FLEET] no acks in {args.dir / 'acks'}")
|
|
401
|
+
return caddis_exit.NOT_RUN
|
|
402
|
+
sheet = render_sheet(acks, args.coordinator, datetime.now())
|
|
403
|
+
args.dir.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
(args.dir / "roster.md").write_text(render_roster(acks, args.coordinator), encoding="utf-8")
|
|
405
|
+
(args.dir / "restart-sheet.md").write_text(sheet, encoding="utf-8")
|
|
406
|
+
print(sheet, end="")
|
|
407
|
+
return caddis_exit.CLEAN
|
|
408
|
+
if args.cmd == "check":
|
|
409
|
+
try:
|
|
410
|
+
sheet = (args.dir / "restart-sheet.md").read_text(encoding="utf-8")
|
|
411
|
+
roster = (args.dir / "roster.md").read_text(encoding="utf-8")
|
|
412
|
+
except OSError as exc:
|
|
413
|
+
sys.stderr.write(f"cannot read the fleet files in {args.dir}: {exc}\n")
|
|
414
|
+
return caddis_exit.NOT_RUN
|
|
415
|
+
errors = check_sheet(sheet, roster)
|
|
416
|
+
for error in errors:
|
|
417
|
+
print(error)
|
|
418
|
+
if errors:
|
|
419
|
+
return caddis_exit.BLOCKED
|
|
420
|
+
print("[FLEET] sheet ok")
|
|
421
|
+
return caddis_exit.CLEAN
|
|
422
|
+
# whoami
|
|
423
|
+
try:
|
|
424
|
+
out = whoami(args.session_id, args.name, args.root.resolve())
|
|
425
|
+
except (OSError, ValueError) as exc:
|
|
426
|
+
sys.stderr.write(f"whoami failed: {exc}\n")
|
|
427
|
+
return caddis_exit.NOT_RUN
|
|
428
|
+
print(f"[FLEET] {args.name} -> {out}")
|
|
429
|
+
return caddis_exit.CLEAN
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == "__main__":
|
|
433
|
+
_reconfig = getattr(sys.stdout, "reconfigure", None)
|
|
434
|
+
if _reconfig:
|
|
435
|
+
try:
|
|
436
|
+
_reconfig(encoding="utf-8")
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
sys.exit(main())
|