@caddis/cli 0.4.53 → 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 (32) hide show
  1. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +5 -1
  5. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +29 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +127 -10
  8. package/bundles/antigravity-plugin/scripts/caddis_notify.py +5 -1
  9. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +56 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +39 -6
  12. package/bundles/antigravity-plugin/scripts/caddis_routing.py +4 -4
  13. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  14. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +4 -0
  15. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +6 -0
  16. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  17. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  18. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  19. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +101 -1
  20. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +3 -2
  21. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +20 -6
  22. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  23. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  24. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +1 -0
  25. package/bundles/antigravity-plugin/skills/ship/SKILL.md +2 -2
  26. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +9 -5
  27. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +3 -2
  28. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  29. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  30. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +1 -0
  31. package/bundles/manifest.json +2 -2
  32. package/package.json +1 -1
@@ -32,6 +32,7 @@ from __future__ import annotations
32
32
  import json
33
33
  import os
34
34
  import re
35
+ import shlex
35
36
  import sys
36
37
 
37
38
  # Functional identity, mirrored from scripts/claudster_config.py.
@@ -87,6 +88,187 @@ _CATASTROPHIC_TARGET = re.compile(
87
88
  _FORK_BOMB = re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")
88
89
 
89
90
 
91
+ # `git add` of everything. A tokenizer, not a regex: three regex rounds each opened a new gap, and one backtracked
92
+ # exponentially. Linear in the command length. Copied from hooks/guard.py; test_guard_agy.py checks they agree.
93
+ _GA_SEPARATORS = {";", "&", "&&", "|", "||", "(", ")", "{", "}", "$", "!", "\n"}
94
+ _GA_PUNCT = set(";&|(){}$!\n") # a token made only of these is a separator (`$(`, `&&`, `);`, `|!`)
95
+ _GA_KEYWORDS = {"if", "then", "else", "elif", "do", "while", "until"} # the next word starts a command
96
+ _GA_WRAPPERS = {"sudo", "command", "env", "exec", "nohup", "time", "timeout", "nice", "xargs", "call", "wsl",
97
+ "busybox"}
98
+ _GA_SHELLS = {"bash", "sh", "zsh", "dash", "pwsh", "powershell", "cmd"}
99
+ _GA_JOINING_SHELLS = {"cmd", "pwsh", "powershell"} # they run ALL the words after the flag; bash -c runs one
100
+ _GA_SHELL_RUN_FLAGS = {"-c", "/c", "/k", "-command", "-commandwithargs"}
101
+ _GA_GIT_VALUE_OPTS = {"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env"}
102
+ _GA_EVERYTHING = {"-A", "--all", ".", "./", ".\\", ":/", "*"}
103
+
104
+
105
+ def _ga_sep(token: str) -> bool:
106
+ """A command separator: one of _GA_SEPARATORS, or a token made only of their characters (`$(`, `|!`)."""
107
+ return token in _GA_SEPARATORS or (bool(token) and set(token) <= _GA_PUNCT)
108
+
109
+
110
+ def _ga_name(token: str) -> str:
111
+ """`/usr/bin/git`, `"C:\\Program Files\\Git\\cmd\\git.exe"`, `GIT` -> `git`."""
112
+ base = re.split(r"[\\/]", token)[-1].lower()
113
+ return base[:-4] if base.endswith(".exe") else base
114
+
115
+
116
+ def _ga_tokens(text: str) -> list[str]:
117
+ """Shell tokens, quotes removed (also mid-token: --work-tree="my dir"), backslashes kept (Windows paths).
118
+ ; & | ( ) { } $ ! and newlines become separator tokens."""
119
+ def lex(quotes: str) -> list[str]:
120
+ lexer = shlex.shlex(text.replace("`", "\n"), posix=True, punctuation_chars=";&|(){}$!\n")
121
+ lexer.whitespace, lexer.whitespace_split, lexer.escape, lexer.quotes = " \t\r", True, "", quotes
122
+ return list(lexer)
123
+ try:
124
+ return lex("'\"")
125
+ except ValueError: # unbalanced quotes: read it again without quote handling
126
+ return lex("")
127
+
128
+
129
+ def git_add_everything(command: str, depth: int = 0) -> bool:
130
+ """True when `command` runs `git add` with -A/--all (also bundled, e.g. -Av), `.`, `./`, `:/` or `*`.
131
+
132
+ `git` must start a command (after a separator, a shell keyword such as `do`, VAR=value, or a wrapper such as
133
+ sudo, env, xargs, nice -n 10), with any path, case or .exe. `bash -c "..."`, `pwsh -Command ...` and
134
+ `cmd /c ...` are read inside. Only git's global options may stand between `git` and `add`, so
135
+ `git commit -m "add ."` and `printf "git add ."` pass. Callers pass heredoc-free text and must treat an
136
+ exception as "no match": this check must never stop the guard's other rules.
137
+ """
138
+ if depth > 3:
139
+ return False
140
+ text = re.sub(r"\\\r?\n|`\r?\n", " ", command) # line continuations (bash \, PowerShell `)
141
+ tokens = _ga_tokens(text)
142
+ i, start = 0, True
143
+ while i < len(tokens):
144
+ tok = tokens[i]
145
+ if _ga_sep(tok):
146
+ i, start = i + 1, True
147
+ continue
148
+ if not start:
149
+ i += 1
150
+ continue
151
+ if tok in _GA_KEYWORDS or re.match(r"^\w+=", tok): # `do git ...`, VAR=value before the command
152
+ i += 1
153
+ continue
154
+ name = _ga_name(tok)
155
+ if name in _GA_WRAPPERS: # skip the wrapper's own flags and values up to the real command
156
+ i += 1
157
+ while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in ({"git"} | _GA_SHELLS):
158
+ i += 1
159
+ continue
160
+ start = False
161
+ if name in _GA_SHELLS:
162
+ j = i + 1
163
+ while j < len(tokens) and not _ga_sep(tokens[j]):
164
+ if tokens[j].lower() in _GA_SHELL_RUN_FLAGS:
165
+ rest = tokens[j + 1:]
166
+ end = next((k for k, t in enumerate(rest) if _ga_sep(t)), len(rest))
167
+ if end == 0: # `bash -c` with nothing after it
168
+ break
169
+ inner = " ".join(rest[:end]) if name in _GA_JOINING_SHELLS else rest[0]
170
+ if git_add_everything(inner, depth + 1):
171
+ return True
172
+ break
173
+ j += 1
174
+ i = j
175
+ continue
176
+ if name != "git":
177
+ i += 1
178
+ continue
179
+ j = i + 1
180
+ while j < len(tokens) and tokens[j].startswith("-") and not _ga_sep(tokens[j]):
181
+ j += 2 if tokens[j] in _GA_GIT_VALUE_OPTS else 1
182
+ if j >= len(tokens) or tokens[j] != "add":
183
+ i = j
184
+ continue
185
+ after_dashdash = False
186
+ for arg in tokens[j + 1:]:
187
+ if _ga_sep(arg):
188
+ break
189
+ if arg == "--":
190
+ after_dashdash = True
191
+ continue
192
+ if after_dashdash and arg.startswith("-"): # `git add -- -A` names a file called -A
193
+ continue
194
+ if arg in _GA_EVERYTHING or re.fullmatch(r"-[a-zA-Z]*A[a-zA-Z]*", arg):
195
+ return True
196
+ i = j + 1
197
+ return False
198
+
199
+
200
+ # Copied from hooks/guard.py: a heredoc body that only carries data is not a command.
201
+ _HEREDOC_MARKER = re.compile(
202
+ r"<<(-?)[ \t]*(?:'([A-Za-z_][A-Za-z_0-9]*)'|\"([A-Za-z_][A-Za-z_0-9]*)\"|([A-Za-z_][A-Za-z_0-9]*))"
203
+ )
204
+ _HEREDOC_INTERPRETER = re.compile(
205
+ r"^\s*(?:\S*/)?(?:bash|sh|zsh|dash|python|python3|py|node|ruby|perl|pwsh|powershell)\b",
206
+ re.I,
207
+ )
208
+
209
+
210
+ def _unquoted_at(line: str, position: int) -> bool:
211
+ """Whether a token at position is outside single and double quotes on this line."""
212
+ quote = ""
213
+ escaped = False
214
+ for char in line[:position]:
215
+ if escaped:
216
+ escaped = False
217
+ elif char == "\\" and quote != "'":
218
+ escaped = True
219
+ elif quote:
220
+ if char == quote:
221
+ quote = ""
222
+ elif char in "'\"":
223
+ quote = char
224
+ return not quote and not escaped
225
+
226
+
227
+ def _without_heredoc_bodies(command: str) -> str:
228
+ """Skip complete data-only heredocs; preserve executable and incomplete bodies."""
229
+ lines = command.splitlines(keepends=True)
230
+ kept: list[str] = []
231
+ i = 0
232
+ while i < len(lines):
233
+ line = lines[i]
234
+ markers = []
235
+ executable = False
236
+ for marker in _HEREDOC_MARKER.finditer(line):
237
+ pos = marker.start()
238
+ if (pos and not (line[pos - 1].isspace() or line[pos - 1] in ";&|")
239
+ or not _unquoted_at(line, pos)):
240
+ continue
241
+ markers.append((next(group for group in marker.groups()[1:] if group is not None),
242
+ marker.group(1) == "-"))
243
+ owner = re.split(r"[;&|]", line[:pos])[-1]
244
+ executable |= bool(_HEREDOC_INTERPRETER.match(owner))
245
+ if not markers:
246
+ kept.append(line)
247
+ i += 1
248
+ continue
249
+ end = i
250
+ for tag, strip_tabs in markers:
251
+ end += 1
252
+ while end < len(lines):
253
+ candidate = lines[end].rstrip("\r\n")
254
+ if (candidate.lstrip("\t") if strip_tabs else candidate) == tag:
255
+ break
256
+ end += 1
257
+ if end == len(lines):
258
+ return command
259
+ kept.extend(lines[i:end + 1] if executable else [line])
260
+ i = end + 1
261
+ return "".join(kept)
262
+
263
+
264
+ def _git_add_everything_safe(text: str) -> bool:
265
+ """git_add_everything, but an error is "no match": one rule must never switch off the rest of the guard."""
266
+ try:
267
+ return git_add_everything(text)
268
+ except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
269
+ return False
270
+
271
+
90
272
  def _strip_quotes(s: str) -> str:
91
273
  """Drop quotes so quoted flags/targets still match (rm '-rf' \"/\" → rm -rf /)."""
92
274
  return s.replace('"', "").replace("'", "")
@@ -191,6 +373,8 @@ def classify_bash(command: str) -> tuple[str, str]:
191
373
  return "deny", "fork bomb"
192
374
  if re.search(r"\bdd\b.*\bof=/dev/", nl) or re.search(r"\bmkfs", nl) or re.search(r">\s*/dev/sd[a-z]", nl):
193
375
  return "deny", "writes directly to a disk device"
376
+ if _git_add_everything_safe(_without_heredoc_bodies(c)):
377
+ return "deny", "git add of everything (-A, --all, . or :/): stage named files only"
194
378
 
195
379
  # ── ask: destructive-but-legit ──
196
380
  if rm_rf:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.139",
3
+ "version": "1.3.140",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -0,0 +1,283 @@
1
+ """Growth control: size budgets and archive rules for handoff-grown files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ import re
8
+ import sys
9
+ from typing import NamedTuple
10
+
11
+ try:
12
+ import caddis_exit
13
+ except ModuleNotFoundError as exc:
14
+ if exc.name != "caddis_exit":
15
+ raise
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
18
+ import caddis_exit
19
+
20
+
21
+ class Budget(NamedTuple):
22
+ glob: str
23
+ unit: str
24
+ limit: int
25
+ rule: str
26
+
27
+
28
+ class Over(NamedTuple):
29
+ path: str
30
+ unit: str
31
+ value: int
32
+ limit: int
33
+ rule: str
34
+
35
+
36
+ BUDGETS: tuple[Budget, ...] = (
37
+ Budget(
38
+ ".caddis/relay/*.md",
39
+ "chars",
40
+ 4000,
41
+ "rewrite: current state only, git keeps history",
42
+ ),
43
+ Budget(
44
+ ".caddis/relay/*.tasks.md",
45
+ "table rows",
46
+ 40,
47
+ "done rows are never written (Phase 1)",
48
+ ),
49
+ Budget(
50
+ ".caddis/relay.md",
51
+ "lines",
52
+ 60,
53
+ "rendered by render-index; drop relays with status closed",
54
+ ),
55
+ Budget(
56
+ ".caddis/open-work.md",
57
+ "rows with Status done",
58
+ 10,
59
+ "move done rows to .caddis/done/open-work-archive.md with a one-line pointer",
60
+ ),
61
+ Budget(
62
+ "DECISIONS.md, .caddis/DECISIONS.md, .caddis/decisions/DECISIONS.md", # the last is the fleet's real path
63
+ "History table rows",
64
+ 30,
65
+ "move older rows to DECISIONS-archive.md beside it",
66
+ ),
67
+ Budget(
68
+ ".caddis/usage-log.jsonl",
69
+ "bytes",
70
+ 5000000,
71
+ "rotate to usage-log-<YYYYMM>.jsonl",
72
+ ),
73
+ Budget(
74
+ ".caddis/session-state/*.md",
75
+ "files",
76
+ 8,
77
+ "session_state.py prune (not in a shared checkout)",
78
+ ),
79
+ Budget(
80
+ ".caddis/kb/*.md",
81
+ "lines per file",
82
+ 300,
83
+ "split, or move closed parts to .caddis/kb/done/",
84
+ ),
85
+ )
86
+
87
+
88
+ class _ParsedTable(NamedTuple):
89
+ heading: str | None
90
+ columns: list[str]
91
+ data_rows: list[list[str]]
92
+
93
+
94
+ def _split_cells(line: str) -> list[str]:
95
+ s = line.strip()
96
+ if not s:
97
+ return []
98
+ parts = re.split(r"(?<!\\)\|", s)
99
+ if parts and parts[0] == "":
100
+ parts.pop(0)
101
+ if parts and parts[-1] == "":
102
+ parts.pop(-1)
103
+ return [p.strip() for p in parts]
104
+
105
+
106
+ def _is_separator(line: str) -> bool:
107
+ cells = _split_cells(line)
108
+ if not cells:
109
+ return False
110
+ for cell in cells:
111
+ c = cell.strip()
112
+ if not c or any(ch not in "-: " for ch in c) or "-" not in c:
113
+ return False
114
+ return True
115
+
116
+
117
+ def _parse_tables(text: str) -> tuple[list[_ParsedTable], int]:
118
+ lines = text.splitlines()
119
+ tables: list[_ParsedTable] = []
120
+ loose_rows = 0
121
+ n = len(lines)
122
+ i = 0
123
+ while i < n:
124
+ if lines[i].lstrip().startswith("|"):
125
+ run_start = i
126
+ while i < n and lines[i].lstrip().startswith("|"):
127
+ i += 1
128
+ run = lines[run_start:i]
129
+ if len(run) >= 2 and _is_separator(run[1]):
130
+ heading: str | None = None
131
+ for k in range(run_start - 1, -1, -1):
132
+ s = lines[k].strip()
133
+ if s.startswith("#"):
134
+ heading = s.lstrip("#").strip()
135
+ break
136
+ columns = _split_cells(run[0])
137
+ data_rows = [_split_cells(r) for r in run[2:] if not _is_separator(r)]
138
+ tables.append(_ParsedTable(heading=heading, columns=columns, data_rows=data_rows))
139
+ else:
140
+ for r in run:
141
+ if not _is_separator(r) and not r.lstrip().startswith(("| Old #", "| # |")):
142
+ loose_rows += 1
143
+ else:
144
+ i += 1
145
+ return tables, loose_rows
146
+
147
+
148
+ def _count_table_rows(text: str) -> int:
149
+ tables, loose_rows = _parse_tables(text)
150
+ return sum(len(t.data_rows) for t in tables) + loose_rows
151
+
152
+
153
+ def _norm_status(cell: str) -> str:
154
+ return cell.replace("**", "").replace("__", "").replace("`", "").strip().lower()
155
+
156
+
157
+ def _count_done_rows(text: str) -> int:
158
+ tables, _ = _parse_tables(text)
159
+ done_re = re.compile(r"^done\b")
160
+ count = 0
161
+ if tables:
162
+ for t in tables:
163
+ col_map = {c.lower(): idx for idx, c in enumerate(t.columns)}
164
+ if "status" in col_map:
165
+ s_idx = col_map["status"]
166
+ for row in t.data_rows:
167
+ if len(row) > s_idx and done_re.search(_norm_status(row[s_idx])):
168
+ count += 1
169
+ else:
170
+ for row in t.data_rows:
171
+ if any(_norm_status(c) == "done" for c in row):
172
+ count += 1
173
+ return count
174
+
175
+ for line in text.splitlines():
176
+ if line.lstrip().startswith("|") and not _is_separator(line):
177
+ cells = _split_cells(line)
178
+ if any(_norm_status(c) == "done" for c in cells):
179
+ count += 1
180
+ return count
181
+
182
+
183
+ def _count_history_rows(text: str) -> int:
184
+ tables, loose_rows = _parse_tables(text)
185
+ history_tables = [t for t in tables if t.heading and "history" in t.heading.lower()]
186
+ if history_tables:
187
+ return sum(len(t.data_rows) for t in history_tables)
188
+ return sum(len(t.data_rows) for t in tables) + loose_rows
189
+
190
+
191
+ def _measure_file(path: Path, unit: str) -> int:
192
+ if unit == "bytes":
193
+ return path.stat().st_size
194
+ text = path.read_text(encoding="utf-8", errors="ignore")
195
+ if unit == "chars":
196
+ return len(text)
197
+ if unit == "table rows":
198
+ return _count_table_rows(text)
199
+ if unit in ("lines", "lines per file"):
200
+ return len(text.splitlines())
201
+ if unit == "rows with Status done":
202
+ return _count_done_rows(text)
203
+ if unit == "History table rows":
204
+ return _count_history_rows(text)
205
+ raise ValueError(f"unknown budget unit: {unit}")
206
+
207
+
208
+ def check(root: Path) -> list[Over]:
209
+ """Return every file under ``root`` that exceeds its configured size budget."""
210
+ root = Path(root)
211
+ overs: list[Over] = []
212
+
213
+ for budget in BUDGETS:
214
+ if budget.unit == "files":
215
+ files = [p for p in sorted(root.glob(budget.glob)) if p.is_file()]
216
+ value = len(files)
217
+ if value > budget.limit:
218
+ overs.append(Over(budget.glob, budget.unit, value, budget.limit, budget.rule))
219
+ continue
220
+
221
+ patterns = [g.strip() for g in budget.glob.split(",")]
222
+ for pattern in patterns:
223
+ if "*" in pattern:
224
+ candidates = [p for p in sorted(root.glob(pattern)) if p.is_file()]
225
+ else:
226
+ candidate = root / pattern
227
+ candidates = [candidate] if candidate.is_file() else []
228
+
229
+ for p in candidates:
230
+ if pattern == ".caddis/relay/*.md" and p.name.endswith(".tasks.md"):
231
+ continue
232
+ rel = p.relative_to(root).as_posix()
233
+ value = _measure_file(p, budget.unit)
234
+ if value > budget.limit:
235
+ overs.append(Over(rel, budget.unit, value, budget.limit, budget.rule))
236
+
237
+ return overs
238
+
239
+
240
+ def main(argv: list[str] | None = None) -> int:
241
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
242
+ try:
243
+ sys.stdout.reconfigure(encoding="utf-8")
244
+ except Exception:
245
+ pass
246
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
247
+ try:
248
+ sys.stderr.reconfigure(encoding="utf-8")
249
+ except Exception:
250
+ pass
251
+
252
+ parser = argparse.ArgumentParser(
253
+ prog="caddis_budget.py",
254
+ description="Check handoff-grown files against their size budgets.",
255
+ )
256
+ sub = parser.add_subparsers(dest="cmd", required=True)
257
+ p_check = sub.add_parser("check", help="Check files under --root against BUDGETS.")
258
+ p_check.add_argument("--root", type=Path, default=Path("."), help="Repository root.")
259
+ p_check.add_argument(
260
+ "--report",
261
+ action="store_true",
262
+ help="Report files over budget but always exit 0.",
263
+ )
264
+
265
+ args = parser.parse_args(argv)
266
+ try:
267
+ overs = check(args.root)
268
+ except OSError as exc: # a locked or unreadable file (Windows): --report still never fails the caller
269
+ print(f"[BUDGET] could not read every file: {exc}")
270
+ return caddis_exit.CLEAN if args.report else caddis_exit.NOT_RUN
271
+ if not overs:
272
+ print("[BUDGET] all within budget")
273
+ return caddis_exit.CLEAN
274
+
275
+ for over in overs:
276
+ print(f"OVER {over.path}: {over.value} {over.unit} > {over.limit}; fix: {over.rule}")
277
+ if args.report:
278
+ return caddis_exit.CLEAN
279
+ return caddis_exit.BLOCKED
280
+
281
+
282
+ if __name__ == "__main__":
283
+ raise SystemExit(main())
@@ -59,7 +59,11 @@ def get_porcelain_lines(repo: Path) -> tuple[int, list[str]]:
59
59
  return 0, entries
60
60
 
61
61
 
62
- SHARED_RE = re.compile(r"^Shared checkout:\s*yes\b", re.IGNORECASE | re.MULTILINE)
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)
63
67
 
64
68
 
65
69
  def entry_paths(entry: str) -> list[str]: