@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,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())
@@ -36,6 +36,7 @@ ALLOWED_TYPES: set[str] = {
36
36
  "hub-spawn",
37
37
  "advisory-context",
38
38
  "phase-prompt",
39
+ "open-work",
39
40
  }
40
41
 
41
42