@grifhinz/logics-manager 2.0.4 → 2.1.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.
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
 
11
11
  from .config import find_repo_root
12
12
  from .lint import expected_workflow_mermaid_signature
13
+ from .termstyle import colorize_help
13
14
 
14
15
 
15
16
  @dataclass(frozen=True)
@@ -37,6 +38,8 @@ REF_PREFIXES = ("req", "item", "task", "prod", "adr", "spec")
37
38
  _CONTEXT_PACK_CACHE: dict[str, dict[str, object]] = {}
38
39
  MERMAID_BLOCK_PATTERN = re.compile(r"```mermaid\s*\n(.*?)\n```", re.DOTALL)
39
40
  MERMAID_SIGNATURE_PATTERN = re.compile(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", re.MULTILINE)
41
+ APPROVED_WORKFLOW_INDICATORS = ("Status", "Progress", "Understanding", "Confidence", "Theme", "Complexity")
42
+ MAX_MUTATION_TEXT_CHARS = 2000
40
43
 
41
44
 
42
45
  def _read_text(path: Path) -> str:
@@ -302,6 +305,9 @@ def _resolve_target_docs(repo_root: Path, sources: list[str]) -> list[tuple[str,
302
305
 
303
306
  resolved: list[tuple[str, Path]] = []
304
307
  for source in sources:
308
+ raw_source = Path(source)
309
+ if raw_source.is_absolute() or any(part == ".." for part in raw_source.parts):
310
+ raise SystemExit(f"Unsupported workflow doc target `{source}`.")
305
311
  candidate = (repo_root / source).resolve()
306
312
  if candidate.is_file():
307
313
  for kind_name, kind in DOC_KINDS.items():
@@ -341,6 +347,249 @@ def _schema_status(repo_root: Path, targets: list[str]) -> dict[str, object]:
341
347
  }
342
348
 
343
349
 
350
+ def build_context_pack_payload(repo_root: Path, ref: str, *, mode: str = "summary-only", profile: str = "normal", config: dict[str, object] | None = None) -> dict[str, object]:
351
+ return _build_context_pack(repo_root, ref, mode=mode, profile=profile, config=config)
352
+
353
+
354
+ def _default_section_names(kind: str) -> list[str]:
355
+ return {
356
+ "request": ["Needs", "Context", "Acceptance criteria", "Backlog", "Tasks", "AI Context"],
357
+ "backlog": ["Problem", "Scope", "Acceptance criteria", "AC Traceability", "Tasks", "AI Context"],
358
+ "task": ["Definition of Done (DoD)", "Backlog", "Acceptance criteria", "Validation", "Report", "AI Context"],
359
+ }.get(kind, ["AI Context"])
360
+
361
+
362
+ def read_logics_doc_payload(repo_root: Path, source: str, *, max_chars: int = 4000, sections: list[str] | None = None) -> dict[str, object]:
363
+ targets = _resolve_target_docs(repo_root, [source])
364
+ if len(targets) != 1:
365
+ raise SystemExit(f"Expected one workflow doc target for `{source}`.")
366
+ kind, path = targets[0]
367
+ doc = parse_workflow_doc(path, repo_root=repo_root)
368
+ requested_sections = sections or _default_section_names(kind)
369
+ selected_sections = {
370
+ heading: [line for line in doc.sections.get(heading, []) if line.strip()]
371
+ for heading in requested_sections
372
+ if heading in doc.sections
373
+ }
374
+ text = _read_text(path)
375
+ return {
376
+ "ref": doc.ref,
377
+ "kind": doc.kind,
378
+ "path": doc.path,
379
+ "title": doc.title,
380
+ "status": doc.indicators.get("Status", ""),
381
+ "indicators": doc.indicators,
382
+ "linked_refs": {prefix: refs for prefix, refs in doc.refs.items() if refs},
383
+ "sections": selected_sections,
384
+ "content": text[:max_chars],
385
+ "truncated": len(text) > max_chars,
386
+ "max_chars": max_chars,
387
+ }
388
+
389
+
390
+ def list_logics_docs_payload(
391
+ repo_root: Path,
392
+ *,
393
+ kind: str = "all",
394
+ status: str | None = None,
395
+ ref_prefix: str | None = None,
396
+ limit: int = 50,
397
+ ) -> dict[str, object]:
398
+ docs = sorted(_load_workflow_docs(repo_root).values(), key=lambda doc: doc.path)
399
+ if kind != "all":
400
+ docs = [doc for doc in docs if doc.kind == kind]
401
+ if status:
402
+ expected_status = " ".join(status.split()).lower()
403
+ docs = [doc for doc in docs if " ".join(doc.indicators.get("Status", "").split()).lower() == expected_status]
404
+ if ref_prefix:
405
+ docs = [doc for doc in docs if doc.ref.startswith(ref_prefix)]
406
+ limited = docs[:limit]
407
+ return {
408
+ "items": [
409
+ {
410
+ "ref": doc.ref,
411
+ "kind": doc.kind,
412
+ "path": doc.path,
413
+ "title": doc.title,
414
+ "status": doc.indicators.get("Status", ""),
415
+ "linked_refs": {prefix: refs for prefix, refs in doc.refs.items() if refs},
416
+ }
417
+ for doc in limited
418
+ ],
419
+ "total_count": len(docs),
420
+ "returned_count": len(limited),
421
+ "truncated": len(docs) > len(limited),
422
+ "limit": limit,
423
+ }
424
+
425
+
426
+ def _snippet_for_line(lines: list[str], index: int, *, max_chars: int) -> str:
427
+ start = max(0, index - 1)
428
+ end = min(len(lines), index + 2)
429
+ snippet = "\n".join(line for line in lines[start:end] if line.strip())
430
+ return snippet[:max_chars]
431
+
432
+
433
+ def search_logics_docs_payload(
434
+ repo_root: Path,
435
+ query: str,
436
+ *,
437
+ kind: str = "all",
438
+ status: str | None = None,
439
+ limit: int = 20,
440
+ max_snippet_chars: int = 240,
441
+ ) -> dict[str, object]:
442
+ normalized_query = query.strip().lower()
443
+ if not normalized_query:
444
+ raise SystemExit("Search query is required.")
445
+ docs_payload = list_logics_docs_payload(repo_root, kind=kind, status=status, limit=10000)
446
+ docs_by_ref = _load_workflow_docs(repo_root)
447
+ matches: list[dict[str, object]] = []
448
+ for item in docs_payload["items"]:
449
+ ref = str(item["ref"])
450
+ doc = docs_by_ref.get(ref)
451
+ if doc is None:
452
+ continue
453
+ text = _strip_mermaid_blocks(_read_text(repo_root / doc.path))
454
+ lines = text.splitlines()
455
+ for idx, line in enumerate(lines):
456
+ if normalized_query in line.lower():
457
+ matches.append(
458
+ {
459
+ "ref": doc.ref,
460
+ "kind": doc.kind,
461
+ "path": doc.path,
462
+ "title": doc.title,
463
+ "status": doc.indicators.get("Status", ""),
464
+ "line": idx + 1,
465
+ "snippet": _snippet_for_line(lines, idx, max_chars=max_snippet_chars),
466
+ }
467
+ )
468
+ break
469
+ if len(matches) >= limit:
470
+ break
471
+ return {
472
+ "query": query,
473
+ "matches": matches,
474
+ "returned_count": len(matches),
475
+ "truncated": len(matches) >= limit,
476
+ "limit": limit,
477
+ }
478
+
479
+
480
+ def _clean_mutation_text(text: str, *, field: str) -> str:
481
+ cleaned = " ".join(text.split())
482
+ if not cleaned:
483
+ raise SystemExit(f"{field} is required.")
484
+ if len(cleaned) > MAX_MUTATION_TEXT_CHARS:
485
+ raise SystemExit(f"{field} exceeds {MAX_MUTATION_TEXT_CHARS} characters.")
486
+ if cleaned.startswith("#") or "```" in cleaned:
487
+ raise SystemExit(f"{field} contains unsupported Markdown structure.")
488
+ return cleaned
489
+
490
+
491
+ def _replace_indicator(lines: list[str], key: str, value: str) -> tuple[list[str], bool]:
492
+ rendered = f"> {key}: {value}"
493
+ for idx, line in enumerate(lines):
494
+ if line.startswith(f"> {key}:"):
495
+ if line == rendered:
496
+ return lines, False
497
+ updated = list(lines)
498
+ updated[idx] = rendered
499
+ return updated, True
500
+ insert_at = 1
501
+ while insert_at < len(lines) and lines[insert_at].startswith("> "):
502
+ insert_at += 1
503
+ updated = list(lines)
504
+ updated.insert(insert_at, rendered)
505
+ return updated, True
506
+
507
+
508
+ def update_workflow_indicators_payload(repo_root: Path, source: str, indicators: dict[str, str], *, dry_run: bool = False) -> dict[str, object]:
509
+ unknown = sorted(set(indicators) - set(APPROVED_WORKFLOW_INDICATORS))
510
+ if unknown:
511
+ raise SystemExit(f"Unsupported workflow indicator(s): {', '.join(unknown)}.")
512
+ cleaned = {key: _clean_mutation_text(value, field=key) for key, value in indicators.items() if value is not None}
513
+ if not cleaned:
514
+ raise SystemExit("At least one workflow indicator is required.")
515
+
516
+ targets = _resolve_target_docs(repo_root, [source])
517
+ if len(targets) != 1:
518
+ raise SystemExit(f"Expected one workflow doc target for `{source}`.")
519
+ kind, path = targets[0]
520
+ lines = _read_lines(path)
521
+ changed = False
522
+ for key in APPROVED_WORKFLOW_INDICATORS:
523
+ if key not in cleaned:
524
+ continue
525
+ if key == "Progress" and kind not in {"backlog", "task"}:
526
+ raise SystemExit("Progress is only supported for backlog and task documents.")
527
+ lines, key_changed = _replace_indicator(lines, key, cleaned[key])
528
+ changed = changed or key_changed
529
+ if changed and not dry_run:
530
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
531
+ refresh_workflow_mermaid_signature_file(path, kind, dry_run=False, repo_root=repo_root)
532
+ return {
533
+ "path": path.relative_to(repo_root).as_posix(),
534
+ "ref": path.stem,
535
+ "kind": kind,
536
+ "updated_indicators": cleaned,
537
+ "changed": changed,
538
+ "dry_run": dry_run,
539
+ }
540
+
541
+
542
+ def _section_for_note(kind: str, note_kind: str) -> str:
543
+ if note_kind == "report":
544
+ if kind != "task":
545
+ raise SystemExit("Report entries are only supported for task documents.")
546
+ return "Report"
547
+ if note_kind == "validation":
548
+ return "Validation"
549
+ if note_kind == "decision":
550
+ return "Decision framing" if kind == "backlog" else "Notes"
551
+ raise SystemExit(f"Unsupported note kind `{note_kind}`.")
552
+
553
+
554
+ def append_workflow_note_payload(repo_root: Path, source: str, *, note_kind: str, text: str, dry_run: bool = False) -> dict[str, object]:
555
+ targets = _resolve_target_docs(repo_root, [source])
556
+ if len(targets) != 1:
557
+ raise SystemExit(f"Expected one workflow doc target for `{source}`.")
558
+ kind, path = targets[0]
559
+ section = _section_for_note(kind, note_kind)
560
+ cleaned = _clean_mutation_text(text, field="text")
561
+ bullet = f"- {cleaned}"
562
+ lines = _read_lines(path)
563
+ insert_at = None
564
+ for idx, line in enumerate(lines):
565
+ if line.startswith("# ") and line[2:].strip().lower() == section.lower():
566
+ insert_at = idx + 1
567
+ while insert_at < len(lines) and lines[insert_at].strip().startswith("- "):
568
+ insert_at += 1
569
+ break
570
+ changed = True
571
+ if insert_at is None:
572
+ lines.extend(["", f"# {section}", bullet])
573
+ else:
574
+ existing = {line.strip() for line in lines if line.strip().startswith("- ")}
575
+ if bullet in existing:
576
+ changed = False
577
+ else:
578
+ lines.insert(insert_at, bullet)
579
+ if changed and not dry_run:
580
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
581
+ refresh_workflow_mermaid_signature_file(path, kind, dry_run=False, repo_root=repo_root)
582
+ return {
583
+ "path": path.relative_to(repo_root).as_posix(),
584
+ "ref": path.stem,
585
+ "kind": kind,
586
+ "section": section,
587
+ "text": cleaned,
588
+ "changed": changed,
589
+ "dry_run": dry_run,
590
+ }
591
+
592
+
344
593
  def _graph_payload(repo_root: Path, *, config: dict[str, object] | None = None) -> dict[str, object]:
345
594
  docs = _load_workflow_docs(repo_root)
346
595
  nodes = []
@@ -481,6 +730,50 @@ def build_parser() -> argparse.ArgumentParser:
481
730
  schema_status.add_argument("--format", choices=("text", "json"), default="text")
482
731
  schema_status.set_defaults(func=cmd_schema_status)
483
732
 
733
+ read_doc = sub.add_parser("read-doc", help="Read a bounded workflow document payload by ref or path.")
734
+ read_doc.add_argument("source", help="Workflow ref or repo-relative path.")
735
+ read_doc.add_argument("--max-chars", type=int, default=4000)
736
+ read_doc.add_argument("--section", action="append", default=[], help="Section heading to include; repeatable.")
737
+ read_doc.add_argument("--format", choices=("text", "json"), default="text")
738
+ read_doc.set_defaults(func=cmd_read_doc)
739
+
740
+ list_docs = sub.add_parser("list-docs", help="List workflow docs by bounded criteria.")
741
+ list_docs.add_argument("--kind", choices=("all", "request", "backlog", "task"), default="all")
742
+ list_docs.add_argument("--status", default=None)
743
+ list_docs.add_argument("--ref-prefix", default=None)
744
+ list_docs.add_argument("--limit", type=int, default=50)
745
+ list_docs.add_argument("--format", choices=("text", "json"), default="text")
746
+ list_docs.set_defaults(func=cmd_list_docs)
747
+
748
+ search_docs = sub.add_parser("search-docs", help="Search approved workflow docs with bounded snippets.")
749
+ search_docs.add_argument("query")
750
+ search_docs.add_argument("--kind", choices=("all", "request", "backlog", "task"), default="all")
751
+ search_docs.add_argument("--status", default=None)
752
+ search_docs.add_argument("--limit", type=int, default=20)
753
+ search_docs.add_argument("--max-snippet-chars", type=int, default=240)
754
+ search_docs.add_argument("--format", choices=("text", "json"), default="text")
755
+ search_docs.set_defaults(func=cmd_search_docs)
756
+
757
+ update_indicators = sub.add_parser("update-indicators", help="Update approved indicators on one workflow doc.")
758
+ update_indicators.add_argument("source", help="Workflow ref or repo-relative path.")
759
+ update_indicators.add_argument("--status")
760
+ update_indicators.add_argument("--progress")
761
+ update_indicators.add_argument("--understanding")
762
+ update_indicators.add_argument("--confidence")
763
+ update_indicators.add_argument("--theme")
764
+ update_indicators.add_argument("--complexity")
765
+ update_indicators.add_argument("--format", choices=("text", "json"), default="text")
766
+ update_indicators.add_argument("--dry-run", action="store_true")
767
+ update_indicators.set_defaults(func=cmd_update_indicators)
768
+
769
+ append_note = sub.add_parser("append-note", help="Append a bounded note to an approved workflow section.")
770
+ append_note.add_argument("source", help="Workflow ref or repo-relative path.")
771
+ append_note.add_argument("--section", choices=("report", "validation", "decision"), required=True)
772
+ append_note.add_argument("--text", required=True)
773
+ append_note.add_argument("--format", choices=("text", "json"), default="text")
774
+ append_note.add_argument("--dry-run", action="store_true")
775
+ append_note.set_defaults(func=cmd_append_note)
776
+
484
777
  context_pack = sub.add_parser("context-pack", help="Build a compact context pack from workflow docs.")
485
778
  context_pack.add_argument("ref", help="Seed workflow ref for the context pack.")
486
779
  context_pack.add_argument("--mode", choices=("summary-only", "diff-first", "full"), default="summary-only")
@@ -499,6 +792,242 @@ def build_parser() -> argparse.ArgumentParser:
499
792
  return parser
500
793
 
501
794
 
795
+ def _build_help() -> str:
796
+ return "\n".join(
797
+ [
798
+ "Logics Sync CLI",
799
+ "Manage workflow transitions and exports.",
800
+ "",
801
+ "Usage:",
802
+ " logics-manager sync <command> [args...]",
803
+ "",
804
+ "Commands:",
805
+ " close-eligible-requests",
806
+ " Auto-close requests when all linked backlog items are done.",
807
+ " Flags: --format {text,json}, --dry-run",
808
+ "",
809
+ " refresh-mermaid-signatures",
810
+ " Refresh stale Mermaid signatures without rewriting diagram bodies.",
811
+ " Flags: --format {text,json}, --dry-run",
812
+ "",
813
+ " schema-status [sources...]",
814
+ " Report schema-version coverage for selected workflow docs.",
815
+ " Flags: --format {text,json}",
816
+ "",
817
+ " context-pack <ref>",
818
+ " Build a compact JSON context pack from workflow docs.",
819
+ " Flags: --mode {summary-only,diff-first,full}, --profile {tiny,normal,deep}, --out, --format {text,json}, --dry-run",
820
+ "",
821
+ " read-doc <source>",
822
+ " Read a bounded workflow document payload by ref or path.",
823
+ " Flags: --max-chars, --section, --format {text,json}",
824
+ "",
825
+ " list-docs",
826
+ " List workflow docs by bounded criteria.",
827
+ " Flags: --kind {all,request,backlog,task}, --status, --ref-prefix, --limit, --format {text,json}",
828
+ "",
829
+ " search-docs <query>",
830
+ " Search approved workflow docs with bounded snippets.",
831
+ " Flags: --kind {all,request,backlog,task}, --status, --limit, --max-snippet-chars, --format {text,json}",
832
+ "",
833
+ " update-indicators <source>",
834
+ " Update approved indicators on one workflow doc.",
835
+ " Flags: --status, --progress, --understanding, --confidence, --theme, --complexity, --format {text,json}, --dry-run",
836
+ "",
837
+ " append-note <source>",
838
+ " Append a bounded note to an approved workflow section.",
839
+ " Flags: --section {report,validation,decision}, --text, --format {text,json}, --dry-run",
840
+ "",
841
+ " export-graph",
842
+ " Export workflow relationships as a machine-readable graph.",
843
+ " Flags: --out, --format {text,json}, --dry-run",
844
+ "",
845
+ "Examples:",
846
+ " logics-manager sync schema-status",
847
+ " logics-manager sync context-pack req_001_my_request --out logics/context-pack.json",
848
+ " logics-manager sync export-graph --format json",
849
+ ]
850
+ )
851
+
852
+
853
+ def _build_subcommand_help(command: str) -> str:
854
+ if command == "close-eligible-requests":
855
+ return "\n".join(
856
+ [
857
+ "Logics Sync Close Eligible Requests",
858
+ "Auto-close requests when all linked backlog items are done.",
859
+ "",
860
+ "Usage:",
861
+ " logics-manager sync close-eligible-requests [args...]",
862
+ "",
863
+ "Flags:",
864
+ " --format {text,json}",
865
+ " --dry-run",
866
+ "",
867
+ "Example:",
868
+ " logics-manager sync close-eligible-requests --dry-run",
869
+ ]
870
+ )
871
+ if command == "refresh-mermaid-signatures":
872
+ return "\n".join(
873
+ [
874
+ "Logics Sync Refresh Mermaid Signatures",
875
+ "Refresh stale workflow Mermaid signatures without rewriting diagram bodies.",
876
+ "",
877
+ "Usage:",
878
+ " logics-manager sync refresh-mermaid-signatures [args...]",
879
+ "",
880
+ "Flags:",
881
+ " --format {text,json}",
882
+ " --dry-run",
883
+ ]
884
+ )
885
+ if command == "schema-status":
886
+ return "\n".join(
887
+ [
888
+ "Logics Sync Schema Status",
889
+ "Report schema-version coverage for workflow docs.",
890
+ "",
891
+ "Usage:",
892
+ " logics-manager sync schema-status [sources...]",
893
+ "",
894
+ "Flags:",
895
+ " --format {text,json}",
896
+ "",
897
+ "Example:",
898
+ " logics-manager sync schema-status logics/request",
899
+ ]
900
+ )
901
+ if command == "context-pack":
902
+ return "\n".join(
903
+ [
904
+ "Logics Sync Context Pack",
905
+ "Build a compact JSON context pack from workflow docs.",
906
+ "",
907
+ "Usage:",
908
+ " logics-manager sync context-pack <ref> [args...]",
909
+ "",
910
+ "Flags:",
911
+ " --mode {summary-only,diff-first,full}",
912
+ " --profile {tiny,normal,deep}",
913
+ " --out",
914
+ " --format {text,json}",
915
+ " --dry-run",
916
+ "",
917
+ "Example:",
918
+ " logics-manager sync context-pack req_001_my_request --out logics/context-pack.json",
919
+ ]
920
+ )
921
+ if command == "read-doc":
922
+ return "\n".join(
923
+ [
924
+ "Logics Sync Read Doc",
925
+ "Read a bounded workflow document payload by ref or path.",
926
+ "",
927
+ "Usage:",
928
+ " logics-manager sync read-doc <source> [args...]",
929
+ "",
930
+ "Flags:",
931
+ " --max-chars",
932
+ " --section",
933
+ " --format {text,json}",
934
+ ]
935
+ )
936
+ if command == "list-docs":
937
+ return "\n".join(
938
+ [
939
+ "Logics Sync List Docs",
940
+ "List workflow docs by bounded criteria.",
941
+ "",
942
+ "Usage:",
943
+ " logics-manager sync list-docs [args...]",
944
+ "",
945
+ "Flags:",
946
+ " --kind {all,request,backlog,task}",
947
+ " --status",
948
+ " --ref-prefix",
949
+ " --limit",
950
+ " --format {text,json}",
951
+ ]
952
+ )
953
+ if command == "search-docs":
954
+ return "\n".join(
955
+ [
956
+ "Logics Sync Search Docs",
957
+ "Search approved workflow docs with bounded snippets.",
958
+ "",
959
+ "Usage:",
960
+ " logics-manager sync search-docs <query> [args...]",
961
+ "",
962
+ "Flags:",
963
+ " --kind {all,request,backlog,task}",
964
+ " --status",
965
+ " --limit",
966
+ " --max-snippet-chars",
967
+ " --format {text,json}",
968
+ ]
969
+ )
970
+ if command == "update-indicators":
971
+ return "\n".join(
972
+ [
973
+ "Logics Sync Update Indicators",
974
+ "Update approved indicators on one workflow doc.",
975
+ "",
976
+ "Usage:",
977
+ " logics-manager sync update-indicators <source> [args...]",
978
+ "",
979
+ "Flags:",
980
+ " --status",
981
+ " --progress",
982
+ " --understanding",
983
+ " --confidence",
984
+ " --theme",
985
+ " --complexity",
986
+ " --format {text,json}",
987
+ " --dry-run",
988
+ ]
989
+ )
990
+ if command == "append-note":
991
+ return "\n".join(
992
+ [
993
+ "Logics Sync Append Note",
994
+ "Append a bounded note to an approved workflow section.",
995
+ "",
996
+ "Usage:",
997
+ " logics-manager sync append-note <source> --section <section> --text <text> [args...]",
998
+ "",
999
+ "Flags:",
1000
+ " --section {report,validation,decision}",
1001
+ " --text",
1002
+ " --format {text,json}",
1003
+ " --dry-run",
1004
+ ]
1005
+ )
1006
+ if command == "export-graph":
1007
+ return "\n".join(
1008
+ [
1009
+ "Logics Sync Export Graph",
1010
+ "Export workflow relationships as a machine-readable graph.",
1011
+ "",
1012
+ "Usage:",
1013
+ " logics-manager sync export-graph [args...]",
1014
+ "",
1015
+ "Flags:",
1016
+ " --out",
1017
+ " --format {text,json}",
1018
+ " --dry-run",
1019
+ "",
1020
+ "Example:",
1021
+ " logics-manager sync export-graph --format json",
1022
+ ]
1023
+ )
1024
+ return _build_help()
1025
+
1026
+
1027
+ def _print_help(text: str) -> None:
1028
+ print(colorize_help(text))
1029
+
1030
+
502
1031
  def cmd_close_eligible_requests(args: argparse.Namespace) -> dict[str, object]:
503
1032
  repo_root = _find_repo_root(Path.cwd())
504
1033
  scanned, closed = _close_eligible_requests(repo_root, args.dry_run)
@@ -557,6 +1086,84 @@ def cmd_schema_status(args: argparse.Namespace) -> dict[str, object]:
557
1086
  return {"command": "sync", "kind": "schema-status", "repo_root": repo_root.as_posix(), **payload}
558
1087
 
559
1088
 
1089
+ def _bounded_positive(value: int, *, default: int, maximum: int) -> int:
1090
+ if value <= 0:
1091
+ return default
1092
+ return min(value, maximum)
1093
+
1094
+
1095
+ def cmd_read_doc(args: argparse.Namespace) -> dict[str, object]:
1096
+ repo_root = _find_repo_root(Path.cwd())
1097
+ payload = read_logics_doc_payload(repo_root, args.source, max_chars=_bounded_positive(args.max_chars, default=4000, maximum=12000), sections=args.section or None)
1098
+ if args.format == "json":
1099
+ print(json.dumps(payload, indent=2, sort_keys=True))
1100
+ else:
1101
+ print(f"{payload['ref']} ({payload['kind']}): {payload['title']}")
1102
+ print(f"- path: {payload['path']}")
1103
+ print(f"- status: {payload['status']}")
1104
+ print(f"- truncated: {payload['truncated']}")
1105
+ return {"command": "sync", "kind": "read-doc", "repo_root": repo_root.as_posix(), **payload}
1106
+
1107
+
1108
+ def cmd_list_docs(args: argparse.Namespace) -> dict[str, object]:
1109
+ repo_root = _find_repo_root(Path.cwd())
1110
+ payload = list_logics_docs_payload(repo_root, kind=args.kind, status=args.status, ref_prefix=args.ref_prefix, limit=_bounded_positive(args.limit, default=50, maximum=200))
1111
+ if args.format == "json":
1112
+ print(json.dumps(payload, indent=2, sort_keys=True))
1113
+ else:
1114
+ print(f"Workflow docs: {payload['returned_count']} returned of {payload['total_count']}")
1115
+ for item in payload["items"]:
1116
+ print(f"- {item['ref']} [{item['status']}]: {item['title']}")
1117
+ return {"command": "sync", "kind": "list-docs", "repo_root": repo_root.as_posix(), **payload}
1118
+
1119
+
1120
+ def cmd_search_docs(args: argparse.Namespace) -> dict[str, object]:
1121
+ repo_root = _find_repo_root(Path.cwd())
1122
+ payload = search_logics_docs_payload(
1123
+ repo_root,
1124
+ args.query,
1125
+ kind=args.kind,
1126
+ status=args.status,
1127
+ limit=_bounded_positive(args.limit, default=20, maximum=100),
1128
+ max_snippet_chars=_bounded_positive(args.max_snippet_chars, default=240, maximum=1000),
1129
+ )
1130
+ if args.format == "json":
1131
+ print(json.dumps(payload, indent=2, sort_keys=True))
1132
+ else:
1133
+ print(f"Search `{payload['query']}`: {payload['returned_count']} match(es)")
1134
+ for match in payload["matches"]:
1135
+ print(f"- {match['ref']}:{match['line']} {match['title']}")
1136
+ return {"command": "sync", "kind": "search-docs", "repo_root": repo_root.as_posix(), **payload}
1137
+
1138
+
1139
+ def cmd_update_indicators(args: argparse.Namespace) -> dict[str, object]:
1140
+ repo_root = _find_repo_root(Path.cwd())
1141
+ indicators = {
1142
+ "Status": args.status,
1143
+ "Progress": args.progress,
1144
+ "Understanding": args.understanding,
1145
+ "Confidence": args.confidence,
1146
+ "Theme": args.theme,
1147
+ "Complexity": args.complexity,
1148
+ }
1149
+ payload = update_workflow_indicators_payload(repo_root, args.source, {key: value for key, value in indicators.items() if value is not None}, dry_run=args.dry_run)
1150
+ if args.format == "json":
1151
+ print(json.dumps(payload, indent=2, sort_keys=True))
1152
+ else:
1153
+ print(f"Updated indicators for {payload['path']} (changed: {payload['changed']}).")
1154
+ return {"command": "sync", "kind": "update-indicators", "repo_root": repo_root.as_posix(), **payload}
1155
+
1156
+
1157
+ def cmd_append_note(args: argparse.Namespace) -> dict[str, object]:
1158
+ repo_root = _find_repo_root(Path.cwd())
1159
+ payload = append_workflow_note_payload(repo_root, args.source, note_kind=args.section, text=args.text, dry_run=args.dry_run)
1160
+ if args.format == "json":
1161
+ print(json.dumps(payload, indent=2, sort_keys=True))
1162
+ else:
1163
+ print(f"Appended {args.section} note to {payload['path']} (changed: {payload['changed']}).")
1164
+ return {"command": "sync", "kind": "append-note", "repo_root": repo_root.as_posix(), **payload}
1165
+
1166
+
560
1167
  def cmd_context_pack(args: argparse.Namespace) -> dict[str, object]:
561
1168
  repo_root = _find_repo_root(Path.cwd())
562
1169
  payload = _build_context_pack(repo_root, args.ref, mode=args.mode, profile=args.profile, config=None)
@@ -598,6 +1205,12 @@ def cmd_export_graph(args: argparse.Namespace) -> dict[str, object]:
598
1205
 
599
1206
 
600
1207
  def main(argv: list[str]) -> int:
1208
+ if not argv or argv[0] in ("-h", "--help"):
1209
+ _print_help(_build_help())
1210
+ return 0
1211
+ if argv[0] in {"close-eligible-requests", "refresh-mermaid-signatures", "schema-status", "read-doc", "list-docs", "search-docs", "update-indicators", "append-note", "context-pack", "export-graph"} and len(argv) > 1 and argv[1] in ("-h", "--help"):
1212
+ _print_help(_build_subcommand_help(argv[0]))
1213
+ return 0
601
1214
  parser = build_parser()
602
1215
  args = parser.parse_args(argv)
603
1216
  payload = args.func(args)