opencode-codeops 1.4.0

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 (102) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/LICENSE +21 -0
  3. package/README.md +171 -0
  4. package/_shared/auto-design.md +129 -0
  5. package/_shared/layout-convention.md +198 -0
  6. package/_shared/quality-profile.md +134 -0
  7. package/_shared/recommendation-hardening.md +166 -0
  8. package/_shared/scope-expansion-control.md +176 -0
  9. package/_shared/spec-first-ordering.md +79 -0
  10. package/_shared/zero-ambiguity-gate.md +311 -0
  11. package/agent-templates/codebase-scout.md +17 -0
  12. package/agent-templates/concurrency-auditor.md +5 -0
  13. package/agent-templates/design-challenger.md +26 -0
  14. package/agent-templates/financial-integrity-auditor.md +5 -0
  15. package/agent-templates/perf-auditor.md +23 -0
  16. package/agent-templates/phase-reviewer.md +54 -0
  17. package/agent-templates/plan-task-executor-opus.md +46 -0
  18. package/agent-templates/plan-task-executor.md +43 -0
  19. package/agent-templates/preflight-auditor.md +45 -0
  20. package/agent-templates/security-auditor.md +42 -0
  21. package/agent-templates/semantics-reviewer.md +5 -0
  22. package/agent-templates/spec-test-author.md +29 -0
  23. package/agents/concurrency-auditor.md +15 -0
  24. package/agents/correctness-reviewer.md +66 -0
  25. package/agents/demanding-executor.md +58 -0
  26. package/agents/design-challenger.md +38 -0
  27. package/agents/executor.md +55 -0
  28. package/agents/explorer.md +29 -0
  29. package/agents/financial-integrity-auditor.md +15 -0
  30. package/agents/performance-auditor.md +35 -0
  31. package/agents/preflight-auditor.md +57 -0
  32. package/agents/security-auditor.md +54 -0
  33. package/agents/semantics-reviewer.md +15 -0
  34. package/agents/spec-test-author.md +41 -0
  35. package/bin/codeops-worktree +244 -0
  36. package/bin/index.mjs +106 -0
  37. package/bin/install-agents.mjs +453 -0
  38. package/bin/install-skills.mjs +466 -0
  39. package/bin/lib/opencode-install.mjs +185 -0
  40. package/install.sh +55 -0
  41. package/package.json +73 -0
  42. package/plugin/index.ts +181 -0
  43. package/references/domains/compiler-and-language.md +28 -0
  44. package/references/domains/data-and-migration.md +22 -0
  45. package/references/domains/distributed-and-concurrent.md +26 -0
  46. package/references/domains/financial-system.md +28 -0
  47. package/references/domains/selection.md +19 -0
  48. package/references/domains/web-application.md +23 -0
  49. package/schemas/codeops-config.schema.json +56 -0
  50. package/scripts/check-version.mjs +163 -0
  51. package/scripts/codeops-migrate.sh +355 -0
  52. package/scripts/codeops-roadmap-compact.sh +232 -0
  53. package/scripts/codeops-roadmap-sync.sh +275 -0
  54. package/scripts/codeops_outcomes.py +155 -0
  55. package/scripts/codeops_plan.py +239 -0
  56. package/scripts/codeops_plan_migrate.py +318 -0
  57. package/scripts/codeops_worktree_snapshot.py +99 -0
  58. package/scripts/install_agents.py +288 -0
  59. package/scripts/release.mjs +533 -0
  60. package/skills/analyze-project/SKILL.md +28 -0
  61. package/skills/clean-comments/SKILL.md +22 -0
  62. package/skills/exec-plan/SKILL.md +267 -0
  63. package/skills/exec-plan/commit-modes.md +113 -0
  64. package/skills/exec-plan/execution-protocol.md +471 -0
  65. package/skills/git-commit/SKILL.md +35 -0
  66. package/skills/github-issues/SKILL.md +38 -0
  67. package/skills/grill-me/SKILL.md +342 -0
  68. package/skills/make-plan/SKILL.md +282 -0
  69. package/skills/make-plan/quality-checklist.md +96 -0
  70. package/skills/make-plan/templates.md +535 -0
  71. package/skills/make-plan/zero-ambiguity-gate.md +19 -0
  72. package/skills/make-requirements/SKILL.md +268 -0
  73. package/skills/make-requirements/discovery-phases.md +255 -0
  74. package/skills/make-requirements/review-and-add.md +73 -0
  75. package/skills/make-requirements/templates.md +296 -0
  76. package/skills/make-requirements/zero-ambiguity-gate.md +18 -0
  77. package/skills/outcome-review/SKILL.md +34 -0
  78. package/skills/preflight/SKILL.md +310 -0
  79. package/skills/preflight/dimensions.md +181 -0
  80. package/skills/preflight/report-format.md +300 -0
  81. package/skills/retro-requirements/SKILL.md +218 -0
  82. package/skills/retro-requirements/confidence-classification.md +45 -0
  83. package/skills/retro-requirements/phases.md +609 -0
  84. package/skills/retro-requirements/triage-gate.md +135 -0
  85. package/skills/roadmap/SKILL.md +381 -0
  86. package/skills/roadmap/stage-hooks.md +80 -0
  87. package/skills/roadmap/template.md +200 -0
  88. package/skills/setup-codeops/SKILL.md +94 -0
  89. package/skills/setup-codeops/migration.md +106 -0
  90. package/skills/setup-codeops/scaffold.md +99 -0
  91. package/skills/setup-routing/SKILL.md +102 -0
  92. package/skills/setup-routing/routing.md +44 -0
  93. package/skills/techdocs/SKILL.md +199 -0
  94. package/skills/techdocs/authoring-and-update.md +178 -0
  95. package/skills/techdocs/templates.md +655 -0
  96. package/skills/techdocs/vitepress-setup.md +143 -0
  97. package/skills/upgrade-plan/SKILL.md +75 -0
  98. package/skills/upgrade-plan/content-quality-gate.md +35 -0
  99. package/skills/upgrade-plan/upgrade-checklists.md +107 -0
  100. package/standards/coding-standards-full.md +124 -0
  101. package/standards/coding-standards.md +64 -0
  102. package/standards/output-style.md +17 -0
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """One-shot migration from traceability graphs to authoritative Markdown plans.
3
+
4
+ The command previews by default. ``--apply`` updates plan ``Implements`` metadata
5
+ and deletes obsolete feature ``traceability.json`` files only when every mapping
6
+ is unambiguous. It creates no replacement state or compatibility layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ try:
20
+ from scripts.codeops_plan import BLOCKED_REASON_RE, IMPLEMENTS_RE, parse_implements, parse_tasks
21
+ except ModuleNotFoundError: # Direct execution adds scripts/, not the repository root, to sys.path.
22
+ from codeops_plan import BLOCKED_REASON_RE, IMPLEMENTS_RE, parse_implements, parse_tasks
23
+
24
+
25
+ TARGET_ID_RE = re.compile(
26
+ r"^(?:RD-(?:[A-Za-z0-9]+-)*\d+|T-\d+|REQ-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)$"
27
+ )
28
+ RD_FILE_RE = re.compile(r"^(RD-(?:[A-Za-z0-9]+-)*\d+)(?:[-.].*)?$", re.IGNORECASE)
29
+ MARKDOWN_LINK_RE = re.compile(r"\[[^]]*\]\(([^)]+)\)")
30
+ HEADING_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PlanMigration:
35
+ index: Path
36
+ implements: tuple[str, ...]
37
+ source: str
38
+ updated_text: str
39
+ changed: bool
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Migration:
44
+ root: Path
45
+ plans: tuple[PlanMigration, ...]
46
+ graphs: tuple[Path, ...]
47
+ problems: tuple[str, ...]
48
+
49
+
50
+ def _qualify(feature: str, rd_id: str) -> str:
51
+ return rd_id if "/" in rd_id else f"{feature}/{rd_id}"
52
+
53
+
54
+ def _rd_ids(feature_dir: Path) -> tuple[str, ...]:
55
+ result: list[str] = []
56
+ requirements = feature_dir / "requirements"
57
+ if requirements.is_dir():
58
+ for path in requirements.glob("RD-*.md"):
59
+ match = RD_FILE_RE.match(path.name)
60
+ if match:
61
+ result.append(_qualify(feature_dir.name, match.group(1).upper()))
62
+ return tuple(dict.fromkeys(sorted(result)))
63
+
64
+
65
+ def _roadmap_links(feature_dir: Path) -> dict[Path, tuple[str, ...]]:
66
+ """Map plan directories to requirement or tracker IDs from roadmap rows."""
67
+ roadmap = feature_dir / "00-roadmap.md"
68
+ if not roadmap.is_file():
69
+ return {}
70
+ found: dict[Path, list[str]] = {}
71
+ for line in roadmap.read_text(encoding="utf-8").splitlines():
72
+ if not line.startswith("|"):
73
+ continue
74
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
75
+ if len(cells) < 4 or not TARGET_ID_RE.fullmatch(cells[0]):
76
+ continue
77
+ for link in MARKDOWN_LINK_RE.findall(cells[3]):
78
+ link_path = link.split("#", 1)[0]
79
+ if not link_path.endswith(("00-index.md", "99-execution-plan.md")):
80
+ continue
81
+ plan_dir = (roadmap.parent / link_path).resolve().parent
82
+ found.setdefault(plan_dir, []).append(_qualify(feature_dir.name, cells[0]))
83
+ return {path: tuple(dict.fromkeys(ids)) for path, ids in found.items()}
84
+
85
+
86
+ def _source_plan(source_path: str, feature: str) -> str | None:
87
+ parts = Path(source_path.replace("\\", "/")).parts
88
+ marker = ("features", feature, "plans")
89
+ for offset in range(len(parts) - 3):
90
+ if tuple(parts[offset : offset + 3]) == marker:
91
+ return parts[offset + 3]
92
+ return None
93
+
94
+
95
+ def _graph_links(feature_dir: Path) -> tuple[dict[Path, tuple[str, ...]], str | None]:
96
+ """Extract the last useful plan mapping before the legacy graph is deleted."""
97
+ graph = feature_dir / "traceability.json"
98
+ if not graph.is_file():
99
+ return {}, None
100
+ try:
101
+ payload = json.loads(graph.read_text(encoding="utf-8"))
102
+ except (OSError, json.JSONDecodeError) as error:
103
+ return {}, f"{graph}: cannot read legacy graph: {error}"
104
+ found: dict[Path, list[str]] = {}
105
+ requirements_by_plan: dict[str, list[str]] = {}
106
+ plan_nodes: dict[str, list[dict[str, object]]] = {}
107
+ for node in payload.get("nodes", []):
108
+ if not isinstance(node, dict):
109
+ continue
110
+ source_plans = {
111
+ plan
112
+ for source in node.get("semanticSources", [])
113
+ if isinstance(source, dict)
114
+ and isinstance(source.get("path"), str)
115
+ and (plan := _source_plan(source["path"], feature_dir.name))
116
+ }
117
+ node_id = node.get("id")
118
+ if (
119
+ node.get("type") == "requirement"
120
+ and isinstance(node_id, str)
121
+ and TARGET_ID_RE.fullmatch(node_id)
122
+ ):
123
+ for plan in source_plans:
124
+ requirements_by_plan.setdefault(plan, []).append(_qualify(feature_dir.name, node_id))
125
+ if node.get("type") == "plan":
126
+ for plan in source_plans:
127
+ plan_nodes.setdefault(plan, []).append(node)
128
+ for plan, nodes in plan_nodes.items():
129
+ targets: list[str] = []
130
+ for node in nodes:
131
+ for edge in node.get("edges", []):
132
+ if not isinstance(edge, dict) or edge.get("relation") != "depends-on":
133
+ continue
134
+ target = edge.get("target")
135
+ if isinstance(target, str) and TARGET_ID_RE.fullmatch(target.rsplit("/", 1)[-1]):
136
+ targets.append(_qualify(feature_dir.name, target))
137
+ targets = targets or requirements_by_plan.get(plan, [])
138
+ if targets:
139
+ found[(feature_dir / "plans" / plan).resolve()] = list(dict.fromkeys(targets))
140
+ for plan, targets in requirements_by_plan.items():
141
+ found.setdefault((feature_dir / "plans" / plan).resolve(), list(dict.fromkeys(targets)))
142
+ return {path: tuple(targets) for path, targets in found.items()}, None
143
+
144
+
145
+ def _metadata_targets(feature_dir: Path, index_text: str) -> tuple[str, ...]:
146
+ """Read explicit targets from the title and index metadata block only."""
147
+ leading = index_text.splitlines()[:20]
148
+ metadata = "\n".join(line for line in leading if line.startswith(("# ", ">")))
149
+ return tuple(
150
+ dict.fromkeys(
151
+ _qualify(feature_dir.name, match.group(0))
152
+ for match in re.finditer(
153
+ r"RD-(?:[A-Za-z0-9]+-)*\d+|T-\d+|REQ-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*",
154
+ metadata,
155
+ )
156
+ )
157
+ )
158
+
159
+
160
+ def _new_index(execution_text: str, implements: tuple[str, ...]) -> str:
161
+ heading = HEADING_RE.search(execution_text)
162
+ title = heading.group(1) if heading else "Implementation Plan"
163
+ return (
164
+ f"# {title}\n\n> **Implements**: {', '.join(implements)}\n"
165
+ "> **CodeOps Artifact Schema**: 1\n"
166
+ )
167
+
168
+
169
+ def _replace_implements(index_text: str, implements: tuple[str, ...]) -> str:
170
+ line = f"> **Implements**: {', '.join(implements)}"
171
+ if IMPLEMENTS_RE.search(index_text):
172
+ return IMPLEMENTS_RE.sub(line, index_text, count=1)
173
+ lines = index_text.splitlines(keepends=True)
174
+ insert_at = 1 if lines and lines[0].lstrip().startswith("#") else 0
175
+ newline = "\r\n" if "\r\n" in index_text else "\n"
176
+ insertion = line + newline
177
+ if insert_at and len(lines) > insert_at and lines[insert_at].strip():
178
+ insertion = newline + insertion + newline
179
+ lines.insert(insert_at, insertion)
180
+ return "".join(lines)
181
+
182
+
183
+ def inspect_migration(codeops_root: Path) -> Migration:
184
+ root = codeops_root.resolve()
185
+ problems: list[str] = []
186
+ plans: list[PlanMigration] = []
187
+ if root.name.lower() != "codeops" or not (root / "features").is_dir():
188
+ return Migration(root, (), (), ("target must be a nested CodeOps directory named 'codeops' with features/",))
189
+
190
+ feature_dirs = [path for path in (root / "features").iterdir() if path.is_dir()]
191
+ archive = root / "_archive"
192
+ if archive.is_dir():
193
+ feature_dirs.extend(
194
+ path for path in archive.iterdir() if path.is_dir() and (path / "traceability.json").is_file()
195
+ )
196
+ graphs = tuple(
197
+ sorted(
198
+ feature_dir / "traceability.json"
199
+ for feature_dir in feature_dirs
200
+ if (feature_dir / "traceability.json").is_file()
201
+ )
202
+ )
203
+ for feature_dir in sorted(feature_dirs):
204
+ plan_dirs = sorted((feature_dir / "plans").glob("*/99-execution-plan.md"))
205
+ roadmap_links = _roadmap_links(feature_dir)
206
+ graph_links, graph_problem = _graph_links(feature_dir)
207
+ if graph_problem:
208
+ problems.append(graph_problem)
209
+ feature_rds = _rd_ids(feature_dir)
210
+ for execution in plan_dirs:
211
+ plan_dir = execution.parent
212
+ index = plan_dir / "00-index.md"
213
+ index_text = index.read_text(encoding="utf-8") if index.is_file() else ""
214
+ declared = tuple(_qualify(feature_dir.name, item) for item in parse_implements(index_text))
215
+ if declared:
216
+ implements, source = declared, "existing declaration"
217
+ elif plan_dir.resolve() in roadmap_links:
218
+ implements, source = roadmap_links[plan_dir.resolve()], "feature roadmap"
219
+ elif plan_dir.resolve() in graph_links:
220
+ implements, source = graph_links[plan_dir.resolve()], "legacy graph"
221
+ elif metadata := _metadata_targets(
222
+ feature_dir, index_text or execution.read_text(encoding="utf-8")
223
+ ):
224
+ implements, source = metadata, "plan metadata"
225
+ elif len(plan_dirs) == 1 and len(feature_rds) == 1:
226
+ implements, source = feature_rds, "single plan and single RD"
227
+ else:
228
+ problems.append(
229
+ f"{index}: cannot infer implemented targets; add an Implements declaration or roadmap link"
230
+ )
231
+ continue
232
+ execution_text = execution.read_text(encoding="utf-8")
233
+ tasks = parse_tasks(execution_text)
234
+ if not tasks:
235
+ problems.append(f"{execution}: contains no execution tasks")
236
+ for task in tasks:
237
+ if task.marker == "!" and not BLOCKED_REASON_RE.search(task.text):
238
+ problems.append(f"{execution}: blocked task lacks a visible 'Blocked: <reason>': {task.text}")
239
+ updated = (
240
+ _replace_implements(index_text, implements)
241
+ if index_text
242
+ else _new_index(execution_text, implements)
243
+ )
244
+ plans.append(PlanMigration(index, implements, source, updated, updated != index_text))
245
+ return Migration(root, tuple(plans), graphs, tuple(problems))
246
+
247
+
248
+ def _require_clean_git_tree(root: Path) -> str | None:
249
+ probe = subprocess.run(
250
+ ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
251
+ text=True,
252
+ capture_output=True,
253
+ check=False,
254
+ )
255
+ if probe.returncode:
256
+ return "--apply requires the codeops directory to be inside a Git repository"
257
+ repo = Path(probe.stdout.strip()).resolve()
258
+ status = subprocess.run(
259
+ ["git", "-C", str(repo), "status", "--porcelain"],
260
+ text=True,
261
+ capture_output=True,
262
+ check=False,
263
+ )
264
+ if status.returncode:
265
+ return "unable to inspect Git working tree"
266
+ if status.stdout.strip():
267
+ return "--apply requires a clean Git working tree"
268
+ return None
269
+
270
+
271
+ def apply_migration(migration: Migration) -> None:
272
+ for plan in migration.plans:
273
+ plan.index.write_text(plan.updated_text, encoding="utf-8", newline="")
274
+ for graph in migration.graphs:
275
+ graph.unlink()
276
+
277
+
278
+ def render(migration: Migration, applying: bool) -> None:
279
+ mode = "APPLY" if applying else "PREVIEW"
280
+ print(f"codeops-plan-migrate: {mode} {migration.root}")
281
+ for plan in migration.plans:
282
+ relative = plan.index.relative_to(migration.root).as_posix()
283
+ values = ", ".join(plan.implements)
284
+ action = "UPDATE" if plan.changed else "KEEP"
285
+ print(f" {action} {relative}: {values} ({plan.source})")
286
+ for graph in migration.graphs:
287
+ print(f" DELETE {graph.relative_to(migration.root).as_posix()}")
288
+ for problem in migration.problems:
289
+ print(f" BLOCKED {problem}")
290
+
291
+
292
+ def build_parser() -> argparse.ArgumentParser:
293
+ parser = argparse.ArgumentParser(description=__doc__)
294
+ parser.add_argument("codeops", type=Path, help="nested codeops/ directory to migrate")
295
+ parser.add_argument("--apply", action="store_true", help="apply the previewed Markdown edits and deletions")
296
+ return parser
297
+
298
+
299
+ def main(argv: list[str] | None = None) -> int:
300
+ args = build_parser().parse_args(argv)
301
+ migration = inspect_migration(args.codeops)
302
+ if migration.problems:
303
+ render(migration, args.apply)
304
+ return 1
305
+ if args.apply:
306
+ git_problem = _require_clean_git_tree(migration.root)
307
+ if git_problem:
308
+ print(f"codeops-plan-migrate: BLOCKED {git_problem}", file=sys.stderr)
309
+ return 1
310
+ apply_migration(migration)
311
+ render(migration, args.apply)
312
+ if not args.apply:
313
+ print("Run again with --apply to perform this migration.")
314
+ return 0
315
+
316
+
317
+ if __name__ == "__main__":
318
+ sys.exit(main())
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """Create and compare complete Git worktree snapshots without changing the real index.
3
+
4
+ Examples:
5
+ python3 scripts/codeops_worktree_snapshot.py snapshot --root .
6
+ python3 scripts/codeops_worktree_snapshot.py diff --root . --baseline <tree>
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+
19
+ OBJECT_ID_RE = re.compile(r"[0-9a-f]{40,64}")
20
+
21
+
22
+ class SnapshotError(RuntimeError):
23
+ """Report a safe, user-facing snapshot failure."""
24
+
25
+
26
+ def run_git(root: Path, args: list[str], *, index_path: Path | None = None) -> str:
27
+ """Run one Git command and return stdout, raising a concise error on failure."""
28
+ environment = os.environ.copy()
29
+ if index_path is not None:
30
+ environment["GIT_INDEX_FILE"] = str(index_path)
31
+ result = subprocess.run(
32
+ ["git", "-C", str(root), *args],
33
+ text=True,
34
+ capture_output=True,
35
+ check=False,
36
+ env=environment,
37
+ )
38
+ if result.returncode != 0:
39
+ message = result.stderr.strip() or result.stdout.strip() or "Git command failed"
40
+ raise SnapshotError(message)
41
+ return result.stdout
42
+
43
+
44
+ def snapshot_worktree(root: Path) -> str:
45
+ """Write the complete non-ignored worktree to a Git tree without staging files."""
46
+ descriptor, raw_path = tempfile.mkstemp(prefix="codeops-phase-index-")
47
+ os.close(descriptor)
48
+ index_path = Path(raw_path)
49
+ index_path.unlink()
50
+ try:
51
+ run_git(root, ["read-tree", "HEAD"], index_path=index_path)
52
+ run_git(root, ["add", "-A", "--", "."], index_path=index_path)
53
+ tree = run_git(root, ["write-tree"], index_path=index_path).strip()
54
+ finally:
55
+ index_path.unlink(missing_ok=True)
56
+ if not OBJECT_ID_RE.fullmatch(tree):
57
+ raise SnapshotError("Git returned an invalid tree identifier")
58
+ return tree
59
+
60
+
61
+ def diff_worktree(root: Path, baseline: str) -> str:
62
+ """Return a binary-safe diff from a baseline tree to the current worktree."""
63
+ if not OBJECT_ID_RE.fullmatch(baseline):
64
+ raise SnapshotError("baseline must be a full Git object identifier")
65
+ run_git(root, ["cat-file", "-e", f"{baseline}^{{tree}}"])
66
+ current = snapshot_worktree(root)
67
+ return run_git(root, ["diff", "--binary", "--no-ext-diff", baseline, current])
68
+
69
+
70
+ def parse_args() -> argparse.Namespace:
71
+ """Parse the snapshot or diff command."""
72
+ parser = argparse.ArgumentParser(description=__doc__)
73
+ parser.add_argument("command", choices=("snapshot", "diff"))
74
+ parser.add_argument("--root", default=".", help="Git repository root")
75
+ parser.add_argument("--baseline", help="baseline tree identifier required by diff")
76
+ return parser.parse_args()
77
+
78
+
79
+ def main() -> int:
80
+ """Execute the requested worktree snapshot operation."""
81
+ args = parse_args()
82
+ root = Path(args.root).resolve()
83
+ try:
84
+ if args.command == "snapshot":
85
+ if args.baseline is not None:
86
+ raise SnapshotError("--baseline is valid only with diff")
87
+ print(snapshot_worktree(root))
88
+ else:
89
+ if args.baseline is None:
90
+ raise SnapshotError("diff requires --baseline")
91
+ print(diff_worktree(root, args.baseline), end="")
92
+ except SnapshotError as exc:
93
+ print(f"ERROR: {exc}")
94
+ return 1
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())