@grifhinz/logics-manager 2.0.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.
@@ -0,0 +1,604 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from .config import find_repo_root
12
+ from .lint import expected_workflow_mermaid_signature
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class WorkflowDocModel:
17
+ kind: str
18
+ path: str
19
+ ref: str
20
+ title: str
21
+ indicators: dict[str, str]
22
+ sections: dict[str, list[str]]
23
+ refs: dict[str, list[str]]
24
+ ai_context: dict[str, str]
25
+ schema_version: str
26
+
27
+
28
+ DOC_KINDS = {
29
+ "request": {"directory": "logics/request", "prefix": "req"},
30
+ "backlog": {"directory": "logics/backlog", "prefix": "item"},
31
+ "task": {"directory": "logics/tasks", "prefix": "task"},
32
+ }
33
+
34
+ _find_repo_root = find_repo_root
35
+
36
+ REF_PREFIXES = ("req", "item", "task", "prod", "adr", "spec")
37
+ _CONTEXT_PACK_CACHE: dict[str, dict[str, object]] = {}
38
+ MERMAID_BLOCK_PATTERN = re.compile(r"```mermaid\s*\n(.*?)\n```", re.DOTALL)
39
+ MERMAID_SIGNATURE_PATTERN = re.compile(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", re.MULTILINE)
40
+
41
+
42
+ def _read_text(path: Path) -> str:
43
+ return path.read_text(encoding="utf-8")
44
+
45
+
46
+ def _read_lines(path: Path) -> list[str]:
47
+ return _read_text(path).splitlines()
48
+
49
+
50
+ def _indicator_value(lines: list[str], key: str) -> str | None:
51
+ pattern = re.compile(rf"^\s*>\s*{re.escape(key)}\s*:\s*(.+?)\s*$")
52
+ for line in lines:
53
+ match = pattern.match(line)
54
+ if match:
55
+ return match.group(1).strip()
56
+ return None
57
+
58
+
59
+ def _section_lines(lines: list[str], heading: str) -> list[str]:
60
+ target = heading.strip().lower()
61
+ start_idx = None
62
+ for idx, line in enumerate(lines):
63
+ if line.startswith("# ") and line[2:].strip().lower() == target:
64
+ start_idx = idx + 1
65
+ break
66
+ if start_idx is None:
67
+ return []
68
+ out: list[str] = []
69
+ for idx in range(start_idx, len(lines)):
70
+ line = lines[idx]
71
+ if line.startswith("# "):
72
+ break
73
+ out.append(line)
74
+ return out
75
+
76
+
77
+ def _extract_refs(text: str, prefix: str) -> list[str]:
78
+ pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
79
+ return sorted({match.group(0) for match in pattern.finditer(text)})
80
+
81
+
82
+ def _strip_mermaid_blocks(text: str) -> str:
83
+ return MERMAID_BLOCK_PATTERN.sub("", text)
84
+
85
+
86
+ def _extract_title(lines: list[str]) -> str:
87
+ for line in lines:
88
+ if line.startswith("## "):
89
+ payload = line.removeprefix("## ").strip()
90
+ if " - " in payload:
91
+ return payload.split(" - ", 1)[1].strip()
92
+ return payload
93
+ return ""
94
+
95
+
96
+ def _extract_ai_context(sections: dict[str, list[str]]) -> dict[str, str]:
97
+ fields: dict[str, str] = {}
98
+ for line in sections.get("AI Context", []):
99
+ match = re.match(r"^\s*-\s*([^:]+)\s*:\s*(.+?)\s*$", line.strip())
100
+ if match:
101
+ fields[match.group(1).strip().lower()] = match.group(2).strip()
102
+ return fields
103
+
104
+
105
+ def _extract_sections(text: str) -> dict[str, list[str]]:
106
+ sections: dict[str, list[str]] = {}
107
+ current: str | None = None
108
+ for line in text.splitlines():
109
+ if line.startswith("# "):
110
+ current = line[2:].strip()
111
+ sections.setdefault(current, [])
112
+ continue
113
+ if current is not None:
114
+ sections[current].append(line)
115
+ return sections
116
+
117
+
118
+ def _detect_workflow_kind(path: Path) -> str:
119
+ normalized = path.as_posix()
120
+ for kind, spec in DOC_KINDS.items():
121
+ if f"/{spec['directory']}/" in f"/{normalized}":
122
+ return kind
123
+ return "unknown"
124
+
125
+
126
+ def parse_workflow_doc(path: Path, *, repo_root: Path | None = None) -> WorkflowDocModel:
127
+ text = _read_text(path)
128
+ lines = text.splitlines()
129
+ sections = _extract_sections(text)
130
+ indicators = {key: value for key in ("From version", "Schema version", "Status", "Understanding", "Confidence", "Progress", "Complexity", "Theme", "Date", "Drivers", "Related request", "Related backlog", "Related task", "Reminder") if (value := _indicator_value(lines, key)) is not None}
131
+ return WorkflowDocModel(
132
+ kind=_detect_workflow_kind(path),
133
+ path=(path.relative_to(repo_root).as_posix() if repo_root is not None else path.as_posix()),
134
+ ref=path.stem,
135
+ title=_extract_title(lines) or path.stem,
136
+ indicators=indicators,
137
+ sections=sections,
138
+ refs={prefix: _extract_refs(_strip_mermaid_blocks(text), prefix) for prefix in REF_PREFIXES},
139
+ ai_context=_extract_ai_context(sections),
140
+ schema_version=indicators.get("Schema version", "1.0"),
141
+ )
142
+
143
+
144
+ def _load_workflow_docs(repo_root: Path) -> dict[str, WorkflowDocModel]:
145
+ docs: dict[str, WorkflowDocModel] = {}
146
+ for kind in DOC_KINDS.values():
147
+ directory = repo_root / kind["directory"]
148
+ if not directory.is_dir():
149
+ continue
150
+ for path in sorted(directory.glob(f"{kind['prefix']}_*.md")):
151
+ doc = parse_workflow_doc(path, repo_root=repo_root)
152
+ docs[doc.ref] = doc
153
+ return docs
154
+
155
+
156
+ def _workflow_neighborhood(seed: WorkflowDocModel, docs: dict[str, WorkflowDocModel]) -> list[WorkflowDocModel]:
157
+ ordered: list[WorkflowDocModel] = [seed]
158
+ seen = {seed.ref}
159
+ linked_refs = []
160
+ for values in seed.refs.values():
161
+ linked_refs.extend(values)
162
+ for ref in linked_refs:
163
+ candidate = docs.get(ref)
164
+ if candidate is None or candidate.ref in seen:
165
+ continue
166
+ ordered.append(candidate)
167
+ seen.add(candidate.ref)
168
+ for candidate in docs.values():
169
+ if candidate.ref in seen:
170
+ continue
171
+ if seed.ref in sum(candidate.refs.values(), []):
172
+ ordered.append(candidate)
173
+ seen.add(candidate.ref)
174
+ return ordered
175
+
176
+
177
+ def _context_profile_limit(profile: str) -> int:
178
+ return {"tiny": 2, "normal": 4, "deep": 8}[profile]
179
+
180
+
181
+ def _git_changed_paths(repo_root: Path) -> list[str]:
182
+ try:
183
+ result = __import__("subprocess").run(
184
+ ["git", "diff", "--name-only", "--relative=."],
185
+ cwd=repo_root,
186
+ stdout=__import__("subprocess").PIPE,
187
+ stderr=__import__("subprocess").PIPE,
188
+ text=True,
189
+ check=False,
190
+ )
191
+ except OSError:
192
+ return []
193
+ if result.returncode != 0:
194
+ return []
195
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
196
+
197
+
198
+ def _context_pack_doc_entry(doc: WorkflowDocModel, mode: str) -> dict[str, object]:
199
+ entry = {
200
+ "ref": doc.ref,
201
+ "kind": doc.kind,
202
+ "path": doc.path,
203
+ "title": doc.title,
204
+ "status": doc.indicators.get("Status", ""),
205
+ "schema_version": doc.schema_version,
206
+ "ai_context": doc.ai_context,
207
+ "linked_refs": {prefix: refs for prefix, refs in doc.refs.items() if refs},
208
+ }
209
+ if mode == "summary-only":
210
+ return entry
211
+ section_names = {
212
+ "request": ["Needs", "Acceptance criteria"],
213
+ "backlog": ["Problem", "Acceptance criteria"],
214
+ "task": ["Context", "Validation"],
215
+ }.get(doc.kind, [])
216
+ entry["sections"] = {heading: [line for line in doc.sections.get(heading, []) if line.strip()][:6] for heading in section_names}
217
+ return entry
218
+
219
+
220
+ def _context_pack_cache_key(
221
+ repo_root: Path,
222
+ seed_ref: str,
223
+ *,
224
+ mode: str,
225
+ profile: str,
226
+ changed_paths: list[str],
227
+ ordered_docs: list[WorkflowDocModel],
228
+ ) -> str:
229
+ payload = {
230
+ "repo_root": str(repo_root.resolve()),
231
+ "seed_ref": seed_ref,
232
+ "mode": mode,
233
+ "profile": profile,
234
+ "changed_paths": changed_paths,
235
+ "docs": [
236
+ {
237
+ "ref": doc.ref,
238
+ "kind": doc.kind,
239
+ "path": doc.path,
240
+ "schema_version": doc.schema_version,
241
+ "status": doc.indicators.get("Status", ""),
242
+ "linked_refs": {prefix: refs for prefix, refs in doc.refs.items() if refs},
243
+ }
244
+ for doc in ordered_docs
245
+ ],
246
+ }
247
+ return __import__("hashlib").sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()
248
+
249
+
250
+ def _build_context_pack(
251
+ repo_root: Path,
252
+ seed_ref: str,
253
+ *,
254
+ mode: str,
255
+ profile: str,
256
+ config: dict[str, object] | None = None,
257
+ ) -> dict[str, object]:
258
+ docs = _load_workflow_docs(repo_root)
259
+ seed = docs.get(seed_ref)
260
+ if seed is None:
261
+ raise SystemExit(f"Unknown workflow ref `{seed_ref}`.")
262
+ ordered = _workflow_neighborhood(seed, docs)[: _context_profile_limit(profile)]
263
+ changed_paths = _git_changed_paths(repo_root) if mode == "diff-first" else []
264
+ cache_key = _context_pack_cache_key(
265
+ repo_root,
266
+ seed_ref,
267
+ mode=mode,
268
+ profile=profile,
269
+ changed_paths=changed_paths,
270
+ ordered_docs=ordered,
271
+ )
272
+ cached_pack = _CONTEXT_PACK_CACHE.get(cache_key)
273
+ if isinstance(cached_pack, dict):
274
+ return deepcopy(cached_pack)
275
+ pack_docs = [_context_pack_doc_entry(doc, mode) for doc in ordered]
276
+ payload = {
277
+ "ref": seed_ref,
278
+ "mode": mode,
279
+ "profile": profile,
280
+ "budgets": {"max_docs": _context_profile_limit(profile)},
281
+ "changed_paths": changed_paths,
282
+ "docs": pack_docs,
283
+ "estimates": {
284
+ "doc_count": len(pack_docs),
285
+ "char_count": sum(len(json.dumps(entry, sort_keys=True)) for entry in pack_docs),
286
+ },
287
+ }
288
+ _CONTEXT_PACK_CACHE[cache_key] = deepcopy(payload)
289
+ return payload
290
+
291
+
292
+ def _resolve_target_docs(repo_root: Path, sources: list[str]) -> list[tuple[str, Path]]:
293
+ if not sources:
294
+ targets: list[tuple[str, Path]] = []
295
+ for kind_name, kind in DOC_KINDS.items():
296
+ directory = repo_root / kind["directory"]
297
+ if not directory.is_dir():
298
+ continue
299
+ for path in sorted(directory.glob(f"{kind['prefix']}_*.md")):
300
+ targets.append((kind_name, path))
301
+ return targets
302
+
303
+ resolved: list[tuple[str, Path]] = []
304
+ for source in sources:
305
+ candidate = (repo_root / source).resolve()
306
+ if candidate.is_file():
307
+ for kind_name, kind in DOC_KINDS.items():
308
+ if candidate.parent == (repo_root / kind["directory"]).resolve():
309
+ resolved.append((kind_name, candidate))
310
+ break
311
+ continue
312
+ for kind_name, kind in DOC_KINDS.items():
313
+ path = repo_root / kind["directory"] / f"{source}.md"
314
+ if path.is_file():
315
+ resolved.append((kind_name, path))
316
+ break
317
+ else:
318
+ raise SystemExit(f"Could not resolve workflow doc target `{source}`.")
319
+ return resolved
320
+
321
+
322
+ def _schema_status(repo_root: Path, targets: list[str]) -> dict[str, object]:
323
+ docs = [parse_workflow_doc(path, repo_root=repo_root) for _kind, path in _resolve_target_docs(repo_root, targets)]
324
+ counts: dict[str, int] = {}
325
+ outdated: list[str] = []
326
+ missing: list[str] = []
327
+ for doc in docs:
328
+ schema_version = doc.indicators.get("Schema version", "")
329
+ if not schema_version:
330
+ missing.append(doc.path)
331
+ schema_version = "(missing)"
332
+ counts[schema_version] = counts.get(schema_version, 0) + 1
333
+ if schema_version not in {"(missing)", "1.0"}:
334
+ outdated.append(doc.path)
335
+ return {
336
+ "current_schema_version": "1.0",
337
+ "counts": dict(sorted(counts.items())),
338
+ "missing": missing,
339
+ "outdated": outdated,
340
+ "doc_count": len(docs),
341
+ }
342
+
343
+
344
+ def _graph_payload(repo_root: Path, *, config: dict[str, object] | None = None) -> dict[str, object]:
345
+ docs = _load_workflow_docs(repo_root)
346
+ nodes = []
347
+ edges = []
348
+ for doc in docs.values():
349
+ nodes.append(
350
+ {
351
+ "ref": doc.ref,
352
+ "kind": doc.kind,
353
+ "title": doc.title,
354
+ "path": doc.path,
355
+ "status": doc.indicators.get("Status", ""),
356
+ }
357
+ )
358
+ for refs in doc.refs.values():
359
+ for ref in refs:
360
+ if ref in docs:
361
+ edges.append({"from": doc.ref, "to": ref})
362
+ return {"nodes": nodes, "edges": edges}
363
+
364
+
365
+ def _collect_docs_linking_ref(repo_root: Path, kind: str, ref: str) -> list[Path]:
366
+ directory = repo_root / DOC_KINDS[kind]["directory"]
367
+ linked: list[Path] = []
368
+ for path in sorted(directory.glob("*.md")):
369
+ if ref in _read_text(path):
370
+ linked.append(path)
371
+ return linked
372
+
373
+
374
+ def _is_doc_done(path: Path, kind: str) -> bool:
375
+ lines = _read_lines(path)
376
+ status_value = _indicator_value(lines, "Status")
377
+ if status_value is not None and " ".join(status_value.split()).lower() in {"done", "archived"}:
378
+ return True
379
+ if kind in {"backlog", "task"}:
380
+ progress_value = _indicator_value(lines, "Progress")
381
+ if progress_value is not None and progress_value.strip() == "100%":
382
+ return True
383
+ return False
384
+
385
+
386
+ def _close_doc(path: Path, kind: str, dry_run: bool) -> None:
387
+ if dry_run:
388
+ return
389
+ lines = _read_lines(path)
390
+ updated = []
391
+ saw_status = False
392
+ saw_progress = False
393
+ for line in lines:
394
+ if line.startswith("> Status:"):
395
+ updated.append("> Status: Done")
396
+ saw_status = True
397
+ elif kind in {"backlog", "task"} and line.startswith("> Progress:"):
398
+ updated.append("> Progress: 100%")
399
+ saw_progress = True
400
+ else:
401
+ updated.append(line)
402
+ if not saw_status:
403
+ updated.insert(1, "> Status: Done")
404
+ if kind in {"backlog", "task"} and not saw_progress:
405
+ insert_at = 2 if saw_status else 3
406
+ updated.insert(insert_at, "> Progress: 100%")
407
+ path.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
408
+
409
+
410
+ def _refresh_workflow_mermaid_signature_text(text: str, kind: str, *, repo_root: Path | None = None, dry_run: bool = False) -> tuple[str, bool]:
411
+ match = MERMAID_BLOCK_PATTERN.search(text)
412
+ if match is None:
413
+ return text, False
414
+ lines = text.splitlines()
415
+ title = _extract_title(lines)
416
+ if not title:
417
+ return text, False
418
+ expected_signature = expected_workflow_mermaid_signature(kind, lines)
419
+ if not expected_signature:
420
+ return text, False
421
+ block = match.group(1)
422
+ signature_match = MERMAID_SIGNATURE_PATTERN.search(block)
423
+ if signature_match is None:
424
+ return text, False
425
+ current = signature_match.group(1).strip()
426
+ if current == expected_signature:
427
+ return text, False
428
+ refreshed_block = MERMAID_SIGNATURE_PATTERN.sub(f"%% logics-signature: {expected_signature}", block, count=1)
429
+ refreshed_text = text[: match.start()] + "```mermaid\n" + refreshed_block + "\n```" + text[match.end() :]
430
+ return refreshed_text, True
431
+
432
+
433
+ def refresh_workflow_mermaid_signature_file(path: Path, kind: str, dry_run: bool, *, repo_root: Path | None = None) -> bool:
434
+ original = _read_text(path)
435
+ refreshed, changed = _refresh_workflow_mermaid_signature_text(original, kind, repo_root=repo_root, dry_run=dry_run)
436
+ if not changed:
437
+ return False
438
+ if not dry_run:
439
+ path.write_text(refreshed.rstrip() + "\n", encoding="utf-8")
440
+ return True
441
+
442
+
443
+ def _close_eligible_requests(repo_root: Path, dry_run: bool) -> tuple[int, int]:
444
+ request_dir = repo_root / DOC_KINDS["request"]["directory"]
445
+ closed = 0
446
+ scanned = 0
447
+ for request_path in sorted(request_dir.glob("req_*.md")):
448
+ scanned += 1
449
+ if _is_doc_done(request_path, "request"):
450
+ continue
451
+ request_ref = request_path.stem
452
+ linked_items = _collect_docs_linking_ref(repo_root, "backlog", request_ref)
453
+ if not linked_items:
454
+ continue
455
+ if all(_is_doc_done(item_path, "backlog") for item_path in linked_items):
456
+ _close_doc(request_path, "request", dry_run)
457
+ print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
458
+ closed += 1
459
+ return scanned, closed
460
+
461
+
462
+ def build_parser() -> argparse.ArgumentParser:
463
+ parser = argparse.ArgumentParser(
464
+ prog="logics-manager sync",
465
+ description="Synchronize workflow closure transitions.",
466
+ )
467
+ sub = parser.add_subparsers(dest="command", required=True)
468
+
469
+ close_eligible = sub.add_parser("close-eligible-requests", help="Auto-close requests when all linked backlog items are done.")
470
+ close_eligible.add_argument("--format", choices=("text", "json"), default="text")
471
+ close_eligible.add_argument("--dry-run", action="store_true")
472
+ close_eligible.set_defaults(func=cmd_close_eligible_requests)
473
+
474
+ refresh_mermaid = sub.add_parser("refresh-mermaid-signatures", help="Refresh stale workflow Mermaid signatures without rewriting the full diagram body.")
475
+ refresh_mermaid.add_argument("--format", choices=("text", "json"), default="text")
476
+ refresh_mermaid.add_argument("--dry-run", action="store_true")
477
+ refresh_mermaid.set_defaults(func=cmd_refresh_mermaid_signatures)
478
+
479
+ schema_status = sub.add_parser("schema-status", help="Report schema-version coverage for workflow docs.")
480
+ schema_status.add_argument("sources", nargs="*", help="Optional workflow refs or paths to scope the scan.")
481
+ schema_status.add_argument("--format", choices=("text", "json"), default="text")
482
+ schema_status.set_defaults(func=cmd_schema_status)
483
+
484
+ context_pack = sub.add_parser("context-pack", help="Build a compact context pack from workflow docs.")
485
+ context_pack.add_argument("ref", help="Seed workflow ref for the context pack.")
486
+ context_pack.add_argument("--mode", choices=("summary-only", "diff-first", "full"), default="summary-only")
487
+ context_pack.add_argument("--profile", choices=("tiny", "normal", "deep"), default="normal")
488
+ context_pack.add_argument("--out", help="Write the JSON artifact to this relative path.")
489
+ context_pack.add_argument("--format", choices=("text", "json"), default="text")
490
+ context_pack.add_argument("--dry-run", action="store_true")
491
+ context_pack.set_defaults(func=cmd_context_pack)
492
+
493
+ export_graph = sub.add_parser("export-graph", help="Export workflow relationships as a machine-readable graph.")
494
+ export_graph.add_argument("--out", help="Write the JSON graph to this relative path.")
495
+ export_graph.add_argument("--format", choices=("text", "json"), default="text")
496
+ export_graph.add_argument("--dry-run", action="store_true")
497
+ export_graph.set_defaults(func=cmd_export_graph)
498
+
499
+ return parser
500
+
501
+
502
+ def cmd_close_eligible_requests(args: argparse.Namespace) -> dict[str, object]:
503
+ repo_root = _find_repo_root(Path.cwd())
504
+ scanned, closed = _close_eligible_requests(repo_root, args.dry_run)
505
+ payload = {
506
+ "command": "sync",
507
+ "kind": "close-eligible-requests",
508
+ "repo_root": repo_root.as_posix(),
509
+ "scanned": scanned,
510
+ "closed": closed,
511
+ "dry_run": args.dry_run,
512
+ }
513
+ if args.format == "json":
514
+ print(json.dumps(payload, indent=2, sort_keys=True))
515
+ else:
516
+ print(f"Scanned {scanned} request(s); closed {closed}.")
517
+ return payload
518
+
519
+
520
+ def cmd_refresh_mermaid_signatures(args: argparse.Namespace) -> dict[str, object]:
521
+ repo_root = _find_repo_root(Path.cwd())
522
+ modified: list[str] = []
523
+ for kind in ("request", "backlog", "task"):
524
+ directory = repo_root / DOC_KINDS[kind]["directory"]
525
+ for path in sorted(directory.glob("*.md")):
526
+ if refresh_workflow_mermaid_signature_file(path, kind, args.dry_run, repo_root=repo_root):
527
+ modified.append(path.relative_to(repo_root).as_posix())
528
+
529
+ payload = {
530
+ "command": "sync",
531
+ "kind": "refresh-mermaid-signatures",
532
+ "repo_root": repo_root.as_posix(),
533
+ "modified_files": modified,
534
+ "dry_run": args.dry_run,
535
+ }
536
+ if args.format == "json":
537
+ print(json.dumps(payload, indent=2, sort_keys=True))
538
+ else:
539
+ if args.dry_run:
540
+ print(f"Dry run: {len(modified)} Mermaid signature update(s) would be applied.")
541
+ else:
542
+ print(f"Refreshed Mermaid signatures in {len(modified)} workflow doc(s).")
543
+ for rel_path in modified:
544
+ print(f"- {rel_path}")
545
+ return payload
546
+
547
+
548
+ def cmd_schema_status(args: argparse.Namespace) -> dict[str, object]:
549
+ repo_root = _find_repo_root(Path.cwd())
550
+ payload = _schema_status(repo_root, args.sources)
551
+ if args.format == "json":
552
+ print(json.dumps(payload, indent=2, sort_keys=True))
553
+ else:
554
+ print(f"Schema status: {payload['doc_count']} workflow doc(s) scanned.")
555
+ for version, count in payload["counts"].items():
556
+ print(f"- {version}: {count}")
557
+ return {"command": "sync", "kind": "schema-status", "repo_root": repo_root.as_posix(), **payload}
558
+
559
+
560
+ def cmd_context_pack(args: argparse.Namespace) -> dict[str, object]:
561
+ repo_root = _find_repo_root(Path.cwd())
562
+ payload = _build_context_pack(repo_root, args.ref, mode=args.mode, profile=args.profile, config=None)
563
+ if args.out:
564
+ out_path = (repo_root / args.out).resolve()
565
+ serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
566
+ out_path.parent.mkdir(parents=True, exist_ok=True)
567
+ if not args.dry_run:
568
+ out_path.write_text(serialized, encoding="utf-8")
569
+ print(f"Wrote {out_path.relative_to(repo_root)}")
570
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
571
+ else:
572
+ if args.format == "json":
573
+ print(json.dumps(payload, indent=2, sort_keys=True))
574
+ else:
575
+ print(f"Context pack: {payload['ref']} ({payload['mode']}, {payload['profile']})")
576
+ print(f"- docs: {payload['estimates']['doc_count']}")
577
+ return {"command": "sync", "kind": "context-pack", "repo_root": repo_root.as_posix(), **payload}
578
+
579
+
580
+ def cmd_export_graph(args: argparse.Namespace) -> dict[str, object]:
581
+ repo_root = _find_repo_root(Path.cwd())
582
+ payload = _graph_payload(repo_root, config=None)
583
+ payload["repo_root"] = repo_root.as_posix()
584
+ if args.out:
585
+ out_path = (repo_root / args.out).resolve()
586
+ serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
587
+ out_path.parent.mkdir(parents=True, exist_ok=True)
588
+ if not args.dry_run:
589
+ out_path.write_text(serialized, encoding="utf-8")
590
+ print(f"Wrote {out_path.relative_to(repo_root)}")
591
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
592
+ else:
593
+ if args.format == "json":
594
+ print(json.dumps(payload, indent=2, sort_keys=True))
595
+ else:
596
+ print(f"Graph: {len(payload['nodes'])} node(s), {len(payload['edges'])} edge(s).")
597
+ return {"command": "sync", "kind": "export-graph", "repo_root": repo_root.as_posix(), **payload}
598
+
599
+
600
+ def main(argv: list[str]) -> int:
601
+ parser = build_parser()
602
+ args = parser.parse_args(argv)
603
+ payload = args.func(args)
604
+ return 0 if isinstance(payload, dict) else 1