@grifhinz/logics-manager 2.1.2 → 2.3.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 (49) hide show
  1. package/README.md +106 -4
  2. package/VERSION +1 -1
  3. package/clients/README.md +9 -0
  4. package/clients/shared-web/media/css/board.css +658 -0
  5. package/clients/shared-web/media/css/details.css +457 -0
  6. package/clients/shared-web/media/css/layout.css +123 -0
  7. package/clients/shared-web/media/css/toolbar.css +576 -0
  8. package/clients/shared-web/media/harnessApi.js +324 -0
  9. package/clients/shared-web/media/hostApi.js +213 -0
  10. package/clients/shared-web/media/hostApiContract.js +55 -0
  11. package/clients/shared-web/media/icon.png +0 -0
  12. package/clients/shared-web/media/layoutController.js +246 -0
  13. package/clients/shared-web/media/logics.svg +7 -0
  14. package/clients/shared-web/media/logicsModel.js +910 -0
  15. package/clients/shared-web/media/main.css +112 -0
  16. package/clients/shared-web/media/main.js +3 -0
  17. package/clients/shared-web/media/mainApp.js +1005 -0
  18. package/clients/shared-web/media/mainCore.js +604 -0
  19. package/clients/shared-web/media/mainInteractionHandlers.js +324 -0
  20. package/clients/shared-web/media/mainInteractions.js +378 -0
  21. package/clients/shared-web/media/renderBoard.js +3 -0
  22. package/clients/shared-web/media/renderBoardApp.js +1339 -0
  23. package/clients/shared-web/media/renderDetails.js +685 -0
  24. package/clients/shared-web/media/renderMarkdown.js +449 -0
  25. package/clients/shared-web/media/toolsPanelLayout.js +172 -0
  26. package/clients/shared-web/media/uiStatus.js +54 -0
  27. package/clients/shared-web/media/webviewChrome.js +405 -0
  28. package/clients/shared-web/media/webviewPersistence.js +116 -0
  29. package/clients/shared-web/media/webviewSelectors.js +491 -0
  30. package/clients/viewer/README.md +5 -0
  31. package/clients/viewer/browser-host.js +847 -0
  32. package/clients/viewer/index.html +237 -0
  33. package/clients/viewer/viewer.css +433 -0
  34. package/logics_manager/assist.py +94 -63
  35. package/logics_manager/assist_handoff.py +132 -0
  36. package/logics_manager/assist_surface.py +38 -0
  37. package/logics_manager/cli.py +152 -12
  38. package/logics_manager/cli_output.py +18 -0
  39. package/logics_manager/flow.py +1360 -84
  40. package/logics_manager/flow_evidence.py +63 -0
  41. package/logics_manager/index.py +3 -7
  42. package/logics_manager/insights.py +418 -0
  43. package/logics_manager/mcp.py +50 -0
  44. package/logics_manager/path_utils.py +31 -0
  45. package/logics_manager/sync.py +24 -12
  46. package/logics_manager/update_check.py +138 -0
  47. package/logics_manager/viewer.py +533 -0
  48. package/package.json +12 -6
  49. package/pyproject.toml +1 -1
@@ -7,6 +7,14 @@ from dataclasses import dataclass
7
7
  from datetime import date
8
8
  from pathlib import Path
9
9
 
10
+ from .audit import audit_payload
11
+ from .cli_output import print_payload
12
+ from .flow_evidence import has_ac_proof as _has_ac_proof
13
+ from .flow_evidence import has_validation_evidence as _has_validation_evidence
14
+ from .flow_evidence import structured_validation_line as _structured_validation_line
15
+ from .index import index_payload
16
+ from .lint import expected_workflow_mermaid_signature, lint_payload
17
+ from .path_utils import ensure_relative_to
10
18
  from .termstyle import colorize_help
11
19
 
12
20
 
@@ -202,6 +210,22 @@ def _build_help() -> str:
202
210
  " Create a companion doc from the integrated runtime.",
203
211
  " Flags: --title, --source-ref, --request-ref, --backlog-ref, --task-ref, --format {text,json}, --dry-run",
204
212
  "",
213
+ " deliver --from-product <source>",
214
+ " Create a linked request, backlog item, and task from a product brief.",
215
+ " Flags: --title, --finish, --format {text,json}, --dry-run",
216
+ "",
217
+ " validate-closeout <task>",
218
+ " Preflight whether a task can be safely closed.",
219
+ " Flags: --format {text,json}",
220
+ "",
221
+ " repair <gates|ac-traceability|links|mermaid>",
222
+ " Apply deterministic closeout repairs.",
223
+ " Flags: --format {text,json}, --dry-run",
224
+ "",
225
+ " closeout <task>",
226
+ " Append validation, repair deterministic gaps, finish, and optionally validate/index.",
227
+ " Flags: --validation, --index, --lint, --audit, --format {text,json}, --dry-run",
228
+ "",
205
229
  " promote request-to-backlog <source>",
206
230
  " Create a backlog slice from a request.",
207
231
  "",
@@ -226,6 +250,10 @@ def _build_help() -> str:
226
250
  "",
227
251
  "Examples:",
228
252
  ' logics-manager flow new request --title "My request"',
253
+ " logics-manager flow deliver --from-product prod_017_delivery_loop",
254
+ " logics-manager flow validate-closeout task_003_fix_docs",
255
+ " logics-manager flow repair gates task_003_fix_docs",
256
+ " logics-manager flow closeout task_003_fix_docs --validation \"pytest passed\" --index --lint --audit",
229
257
  " logics-manager flow promote request-to-backlog req_001_my_request",
230
258
  " logics-manager flow close task task_003_fix_docs --dry-run",
231
259
  ]
@@ -352,6 +380,122 @@ def _build_companion_kind_help(kind: str) -> str:
352
380
  )
353
381
 
354
382
 
383
+ def _build_deliver_help() -> str:
384
+ return "\n".join(
385
+ [
386
+ "Logics Flow Deliver",
387
+ "Create a delivery chain from a product brief.",
388
+ "",
389
+ "Usage:",
390
+ " logics-manager flow deliver --from-product <source> [args...]",
391
+ "",
392
+ "Flags:",
393
+ " --from-product <source>",
394
+ " --title",
395
+ " --finish",
396
+ " --format {text,json}",
397
+ " --dry-run",
398
+ "",
399
+ "Examples:",
400
+ " logics-manager flow deliver --from-product prod_017_logics_delivery_loop_ergonomics",
401
+ ' logics-manager flow deliver --from-product logics/product/prod_017_logics_delivery_loop_ergonomics.md --title "Implement flow deliver"',
402
+ ]
403
+ )
404
+
405
+
406
+ def _build_validate_closeout_help() -> str:
407
+ return "\n".join(
408
+ [
409
+ "Logics Flow Validate Closeout",
410
+ "Preflight whether a task can be safely closed.",
411
+ "",
412
+ "Usage:",
413
+ " logics-manager flow validate-closeout <task> [args...]",
414
+ "",
415
+ "Flags:",
416
+ " --format {text,json}",
417
+ "",
418
+ "Examples:",
419
+ " logics-manager flow validate-closeout task_164_implement_flow_deliver_from_product",
420
+ ]
421
+ )
422
+
423
+
424
+ def _build_repair_help() -> str:
425
+ return "\n".join(
426
+ [
427
+ "Logics Flow Repair",
428
+ "Apply deterministic closeout repairs.",
429
+ "",
430
+ "Usage:",
431
+ " logics-manager flow repair <gates|ac-traceability|links|mermaid> [args...]",
432
+ "",
433
+ "Commands:",
434
+ " gates <task>",
435
+ " Check task Plan/DoD and linked request DoR boxes.",
436
+ " ac-traceability <request>",
437
+ " Add missing request AC traceability entries to linked backlog/task docs.",
438
+ " links <task>",
439
+ " Ensure linked backlog/product docs reference the task chain.",
440
+ " mermaid --refs <refs...>",
441
+ " Insert or refresh workflow Mermaid signatures for selected docs.",
442
+ "",
443
+ "Flags:",
444
+ " --format {text,json}",
445
+ " --dry-run",
446
+ ]
447
+ )
448
+
449
+
450
+ def _build_repair_kind_help(kind: str) -> str:
451
+ examples = {
452
+ "gates": " logics-manager flow repair gates task_164_implement_flow_deliver_from_product",
453
+ "ac-traceability": " logics-manager flow repair ac-traceability req_199_implement_flow_deliver_from_product",
454
+ "links": " logics-manager flow repair links task_164_implement_flow_deliver_from_product",
455
+ "mermaid": " logics-manager flow repair mermaid --refs req_199 item_363 task_164",
456
+ }
457
+ usage = " logics-manager flow repair mermaid --refs <refs...> [args...]" if kind == "mermaid" else f" logics-manager flow repair {kind} <source> [args...]"
458
+ return "\n".join(
459
+ [
460
+ f"Logics Flow Repair {kind.title()}",
461
+ "Apply a deterministic closeout repair.",
462
+ "",
463
+ "Usage:",
464
+ usage,
465
+ "",
466
+ "Flags:",
467
+ " --format {text,json}",
468
+ " --dry-run",
469
+ "",
470
+ "Example:",
471
+ examples[kind],
472
+ ]
473
+ )
474
+
475
+
476
+ def _build_closeout_help() -> str:
477
+ return "\n".join(
478
+ [
479
+ "Logics Flow Closeout",
480
+ "Append validation, repair deterministic gaps, finish, and optionally validate/index.",
481
+ "",
482
+ "Usage:",
483
+ " logics-manager flow closeout <task> [args...]",
484
+ "",
485
+ "Flags:",
486
+ " --validation",
487
+ " --index",
488
+ " --lint",
489
+ " --audit",
490
+ " --format {text,json}",
491
+ " --dry-run",
492
+ "",
493
+ "Example:",
494
+ ' logics-manager flow closeout task_164 --validation "pytest passed" --index --lint --audit',
495
+ ]
496
+ )
497
+
498
+
355
499
  def _build_promote_help() -> str:
356
500
  return "\n".join(
357
501
  [
@@ -587,7 +731,8 @@ def _find_repo_root(start: Path) -> Path:
587
731
 
588
732
  def _plan_doc(repo_root: Path, directory: str, prefix: str, title: str, dry_run: bool = False) -> PlannedDoc:
589
733
  target_dir = repo_root / directory
590
- target_dir.mkdir(parents=True, exist_ok=True)
734
+ if not dry_run:
735
+ target_dir.mkdir(parents=True, exist_ok=True)
591
736
  slug = _slugify(title)
592
737
  highest = -1
593
738
  pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)_.*\.md$")
@@ -600,6 +745,22 @@ def _plan_doc(repo_root: Path, directory: str, prefix: str, title: str, dry_run:
600
745
  return PlannedDoc(ref=ref, path=path)
601
746
 
602
747
 
748
+ def _ensure_new_doc_paths_available(paths: list[Path]) -> None:
749
+ collisions = [path for path in paths if path.exists()]
750
+ if collisions:
751
+ rendered = ", ".join(path.as_posix() for path in collisions)
752
+ raise SystemExit(f"Ref collision while creating Logics doc(s): {rendered}. Re-run the command to allocate a fresh id.")
753
+
754
+
755
+ def _write_new_doc(path: Path, content: str) -> None:
756
+ path.parent.mkdir(parents=True, exist_ok=True)
757
+ try:
758
+ with path.open("x", encoding="utf-8") as handle:
759
+ handle.write(content)
760
+ except FileExistsError as exc:
761
+ raise SystemExit(f"Ref collision while creating Logics doc: {path.as_posix()}. Re-run the command to allocate a fresh id.") from exc
762
+
763
+
603
764
  def _extract_refs(text: str, prefix: str) -> list[str]:
604
765
  pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
605
766
  return sorted({match.group(0) for match in pattern.finditer(text)})
@@ -609,11 +770,107 @@ def _strip_mermaid_blocks(text: str) -> str:
609
770
  return re.sub(r"```mermaid\s*\n.*?\n```", "", text, flags=re.DOTALL)
610
771
 
611
772
 
773
+ def _workflow_mermaid_block(kind: str, signature: str) -> list[str]:
774
+ if kind == "request":
775
+ body = [
776
+ "flowchart TD",
777
+ " Need[Request need] --> Backlog[Backlog slice]",
778
+ " Backlog --> Task[Delivery task]",
779
+ ]
780
+ elif kind == "backlog":
781
+ body = [
782
+ "flowchart TD",
783
+ " Request[Request source] --> Scope[Backlog scope]",
784
+ " Scope --> Task[Delivery task]",
785
+ ]
786
+ else:
787
+ body = [
788
+ "flowchart TD",
789
+ " Backlog[Backlog item] --> Build[Implementation]",
790
+ " Build --> Validate[Validation]",
791
+ " Validate --> Close[Finish workflow]",
792
+ ]
793
+ return [
794
+ "```mermaid",
795
+ f"%% logics-kind: {kind}",
796
+ f"%% logics-signature: {signature}",
797
+ *body,
798
+ "```",
799
+ ]
800
+
801
+
802
+ def _with_workflow_mermaid_overview(kind: str, content: str) -> str:
803
+ lines = content.rstrip().splitlines()
804
+ signature = expected_workflow_mermaid_signature(kind, lines)
805
+ if not signature:
806
+ return content
807
+ block = _workflow_mermaid_block(kind, signature)
808
+ heading = {"request": "Context", "backlog": "Scope", "task": "Backlog"}[kind]
809
+ insert_at = len(lines)
810
+ for idx, line in enumerate(lines):
811
+ if line.startswith("# ") and line[2:].strip().lower() == heading.lower():
812
+ insert_at = idx + 1
813
+ while insert_at < len(lines) and not lines[insert_at].startswith("# "):
814
+ insert_at += 1
815
+ break
816
+ updated = [*lines[:insert_at], "", *block, "", *lines[insert_at:]]
817
+ return "\n".join(updated).rstrip() + "\n"
818
+
819
+
612
820
  def _resolve_doc_path(repo_root: Path, kind: DocKind, ref: str) -> Path | None:
613
821
  path = repo_root / kind.directory / f"{ref}.md"
614
822
  return path if path.is_file() else None
615
823
 
616
824
 
825
+ def _resolve_workflow_source(repo_root: Path, kind: DocKind, source: str) -> Path:
826
+ raw = Path(source)
827
+ if raw.is_absolute():
828
+ candidate = raw.resolve()
829
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
830
+ elif any(part == ".." for part in raw.parts):
831
+ raise SystemExit(f"Unsupported source `{source}`. Use a {kind.prefix}_... ref or repo-relative Logics path.")
832
+ elif len(raw.parts) == 1 and raw.suffix != ".md":
833
+ path = _resolve_doc_path(repo_root, kind, source)
834
+ if path is None:
835
+ raise SystemExit(f"Source not found: {source}")
836
+ return path
837
+ else:
838
+ candidate = (repo_root / raw).resolve()
839
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
840
+ expected_dir = Path(kind.directory)
841
+ if candidate.parent != (repo_root / kind.directory).resolve():
842
+ raise SystemExit(f"Expected source under `{kind.directory}`. Got: `{rel_path.as_posix()}`.")
843
+ if not candidate.is_file():
844
+ raise SystemExit(f"Source not found: {rel_path.as_posix()}")
845
+ if not candidate.stem.startswith(f"{kind.prefix}_"):
846
+ raise SystemExit(f"Expected a `{kind.prefix}_...` file for kind `{kind.kind}`. Got: {candidate.name}")
847
+ if rel_path.parent != expected_dir:
848
+ raise SystemExit(f"Expected source under `{kind.directory}`. Got: `{rel_path.as_posix()}`.")
849
+ return candidate
850
+
851
+
852
+ def _resolve_product_source(repo_root: Path, source: str) -> Path:
853
+ raw = Path(source)
854
+ if raw.is_absolute():
855
+ candidate = raw.resolve()
856
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
857
+ elif any(part == ".." for part in raw.parts):
858
+ raise SystemExit("Unsupported product source. Use a prod_... ref or repo-relative product path.")
859
+ elif len(raw.parts) == 1 and raw.suffix != ".md":
860
+ candidate = repo_root / "logics" / "product" / f"{source}.md"
861
+ rel_path = candidate.relative_to(repo_root)
862
+ else:
863
+ candidate = (repo_root / raw).resolve()
864
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
865
+ if candidate.parent != (repo_root / "logics" / "product").resolve():
866
+ raise SystemExit(f"Expected product source under `logics/product`. Got: `{rel_path.as_posix()}`.")
867
+ if not candidate.is_file():
868
+ raise SystemExit(f"Product source not found: {rel_path.as_posix()}")
869
+ if not candidate.stem.startswith("prod_"):
870
+ raise SystemExit(f"Expected a `prod_...` product brief. Got: {candidate.name}")
871
+ return candidate
872
+
873
+
617
874
  def _append_section_bullets(path: Path, heading: str, bullets: list[str], dry_run: bool) -> None:
618
875
  if dry_run:
619
876
  return
@@ -708,6 +965,430 @@ def _is_doc_done(path: Path, kind: DocKind) -> bool:
708
965
  return False
709
966
 
710
967
 
968
+ def _section_text(text: str, heading: str) -> str:
969
+ return "\n".join(_section_lines(text.splitlines(), heading)).strip()
970
+
971
+
972
+ def _section_has_unchecked_checkbox(text: str, heading: str) -> bool:
973
+ return any("- [ ]" in line for line in _section_lines(text.splitlines(), heading))
974
+
975
+
976
+ def _section_has_checked_checkbox(text: str, heading: str) -> bool:
977
+ return any("- [x]" in line.lower() for line in _section_lines(text.splitlines(), heading))
978
+
979
+
980
+ def _request_ac_ids(text: str) -> list[str]:
981
+ ids: list[str] = []
982
+ for line in _section_lines(text.splitlines(), "Acceptance criteria"):
983
+ match = re.search(r"\bAC(\d+)\s*:", line, flags=re.IGNORECASE)
984
+ if match:
985
+ ids.append(f"AC{int(match.group(1))}")
986
+ return ids
987
+
988
+
989
+ def _first_product_path(repo_root: Path, product_ref: str) -> Path | None:
990
+ path = repo_root / "logics" / "product" / f"{product_ref}.md"
991
+ return path if path.is_file() else None
992
+
993
+
994
+ def _mermaid_closeout_issue(path: Path, kind: str) -> str | None:
995
+ text = path.read_text(encoding="utf-8")
996
+ match = re.search(r"```mermaid\s*\n(.*?)\n```", text, flags=re.DOTALL)
997
+ if match is None:
998
+ return "missing Mermaid overview block"
999
+ signature_match = re.search(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", match.group(1), flags=re.MULTILINE)
1000
+ expected = expected_workflow_mermaid_signature(kind, text.splitlines())
1001
+ if signature_match is None:
1002
+ return "missing Mermaid context signature comment"
1003
+ if expected and signature_match.group(1).strip() != expected:
1004
+ return f"stale Mermaid signature, expected `{expected}`"
1005
+ return None
1006
+
1007
+
1008
+ def _closeout_issue(path: Path, code: str, message: str, repair_command: str | None = None) -> dict[str, str]:
1009
+ issue = {
1010
+ "path": path.as_posix(),
1011
+ "code": code,
1012
+ "message": message,
1013
+ }
1014
+ if repair_command:
1015
+ issue["repair_command"] = repair_command
1016
+ return issue
1017
+
1018
+
1019
+ def validate_closeout_payload(repo_root: Path, source: str) -> dict[str, object]:
1020
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
1021
+ task_ref = task_path.stem
1022
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
1023
+ raw_task_text = task_path.read_text(encoding="utf-8")
1024
+ issues: list[dict[str, str]] = []
1025
+ related_paths = [task_path.relative_to(repo_root).as_posix()]
1026
+
1027
+ for heading in ("Plan", "Definition of Done (DoD)"):
1028
+ if _section_has_unchecked_checkbox(task_text, heading):
1029
+ issues.append(
1030
+ _closeout_issue(
1031
+ task_path.relative_to(repo_root),
1032
+ "task_gate_unchecked",
1033
+ f"`# {heading}` contains unchecked items",
1034
+ f"python3 -m logics_manager flow repair gates {task_ref}",
1035
+ )
1036
+ )
1037
+ if not _section_has_checked_checkbox(task_text, "Definition of Done (DoD)"):
1038
+ issues.append(
1039
+ _closeout_issue(
1040
+ task_path.relative_to(repo_root),
1041
+ "task_missing_done_gate",
1042
+ "`# Definition of Done (DoD)` has no checked completion evidence",
1043
+ f"python3 -m logics_manager flow repair gates {task_ref}",
1044
+ )
1045
+ )
1046
+ if not _has_validation_evidence(task_text):
1047
+ issues.append(
1048
+ _closeout_issue(
1049
+ task_path.relative_to(repo_root),
1050
+ "validation_evidence_missing",
1051
+ "`# Validation` has no concrete passing validation evidence",
1052
+ f"python3 -m logics_manager flow closeout {task_ref} --validation \"... passed\"",
1053
+ )
1054
+ )
1055
+
1056
+ mermaid_issue = _mermaid_closeout_issue(task_path, "task")
1057
+ if mermaid_issue:
1058
+ issues.append(
1059
+ _closeout_issue(
1060
+ task_path.relative_to(repo_root),
1061
+ "mermaid_signature_stale",
1062
+ mermaid_issue,
1063
+ f"python3 -m logics_manager flow repair mermaid --refs {task_ref}",
1064
+ )
1065
+ )
1066
+
1067
+ item_refs = sorted(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
1068
+ if not item_refs:
1069
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "task_missing_backlog", "task has no linked backlog item reference"))
1070
+
1071
+ request_refs: set[str] = set(_extract_refs(task_text, DOC_KINDS["request"].prefix))
1072
+ item_paths: list[Path] = []
1073
+ for item_ref in item_refs:
1074
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
1075
+ if item_path is None:
1076
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "task_missing_backlog_target", f"task references missing backlog item `{item_ref}`"))
1077
+ continue
1078
+ item_paths.append(item_path)
1079
+ related_paths.append(item_path.relative_to(repo_root).as_posix())
1080
+ item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8"))
1081
+ request_refs.update(_extract_refs(item_text, DOC_KINDS["request"].prefix))
1082
+ if task_ref not in item_text:
1083
+ issues.append(
1084
+ _closeout_issue(
1085
+ item_path.relative_to(repo_root),
1086
+ "backlog_missing_task_link",
1087
+ f"backlog item does not link task `{task_ref}`",
1088
+ f"python3 -m logics_manager flow repair links {task_ref}",
1089
+ )
1090
+ )
1091
+ mermaid_issue = _mermaid_closeout_issue(item_path, "backlog")
1092
+ if mermaid_issue:
1093
+ issues.append(
1094
+ _closeout_issue(
1095
+ item_path.relative_to(repo_root),
1096
+ "mermaid_signature_stale",
1097
+ mermaid_issue,
1098
+ f"python3 -m logics_manager flow repair mermaid --refs {item_ref}",
1099
+ )
1100
+ )
1101
+
1102
+ for request_ref in sorted(request_refs):
1103
+ request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
1104
+ if request_path is None:
1105
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "missing_request_target", f"linked request `{request_ref}` is missing"))
1106
+ continue
1107
+ related_paths.append(request_path.relative_to(repo_root).as_posix())
1108
+ request_text = _strip_mermaid_blocks(request_path.read_text(encoding="utf-8"))
1109
+ if _section_has_unchecked_checkbox(request_text, "Definition of Ready (DoR)"):
1110
+ issues.append(
1111
+ _closeout_issue(
1112
+ request_path.relative_to(repo_root),
1113
+ "request_dor_unchecked",
1114
+ "`# Definition of Ready (DoR)` contains unchecked items",
1115
+ f"python3 -m logics_manager flow repair gates {task_ref}",
1116
+ )
1117
+ )
1118
+ mermaid_issue = _mermaid_closeout_issue(request_path, "request")
1119
+ if mermaid_issue:
1120
+ issues.append(
1121
+ _closeout_issue(
1122
+ request_path.relative_to(repo_root),
1123
+ "mermaid_signature_stale",
1124
+ mermaid_issue,
1125
+ f"python3 -m logics_manager flow repair mermaid --refs {request_ref}",
1126
+ )
1127
+ )
1128
+ for ac_id in _request_ac_ids(request_text):
1129
+ if item_paths and not any(_has_ac_proof(path.read_text(encoding="utf-8"), ac_id) for path in item_paths):
1130
+ issues.append(
1131
+ _closeout_issue(
1132
+ request_path.relative_to(repo_root),
1133
+ "ac_missing_item_traceability",
1134
+ f"`{ac_id}` missing backlog-level proof",
1135
+ f"python3 -m logics_manager flow repair ac-traceability {request_ref}",
1136
+ )
1137
+ )
1138
+ if not _has_ac_proof(raw_task_text, ac_id):
1139
+ issues.append(
1140
+ _closeout_issue(
1141
+ request_path.relative_to(repo_root),
1142
+ "ac_missing_task_traceability",
1143
+ f"`{ac_id}` missing task-level proof",
1144
+ f"python3 -m logics_manager flow repair ac-traceability {request_ref}",
1145
+ )
1146
+ )
1147
+
1148
+ product_refs = sorted(_extract_refs(raw_task_text, "prod"))
1149
+ for product_ref in product_refs:
1150
+ product_path = _first_product_path(repo_root, product_ref)
1151
+ if product_path is None:
1152
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "missing_product_target", f"linked product brief `{product_ref}` is missing"))
1153
+ continue
1154
+ related_paths.append(product_path.relative_to(repo_root).as_posix())
1155
+ product_text = product_path.read_text(encoding="utf-8")
1156
+ if task_ref not in product_text:
1157
+ issues.append(
1158
+ _closeout_issue(
1159
+ product_path.relative_to(repo_root),
1160
+ "companion_link_missing",
1161
+ f"product brief does not link task `{task_ref}`",
1162
+ f"python3 -m logics_manager flow repair links {task_ref}",
1163
+ )
1164
+ )
1165
+
1166
+ unique_issues = []
1167
+ seen_issue_keys: set[tuple[str, str, str]] = set()
1168
+ for issue in issues:
1169
+ key = (issue["path"], issue["code"], issue["message"])
1170
+ if key in seen_issue_keys:
1171
+ continue
1172
+ seen_issue_keys.add(key)
1173
+ unique_issues.append(issue)
1174
+
1175
+ return {
1176
+ "command": "validate-closeout",
1177
+ "ok": not unique_issues,
1178
+ "source": task_path.relative_to(repo_root).as_posix(),
1179
+ "task_ref": task_ref,
1180
+ "issue_count": len(unique_issues),
1181
+ "issues": unique_issues,
1182
+ "related_paths": sorted(set(related_paths)),
1183
+ }
1184
+
1185
+
1186
+ def _changed_rel(repo_root: Path, changed_paths: set[Path], path: Path, before: str | None) -> None:
1187
+ if before is not None and path.read_text(encoding="utf-8") != before:
1188
+ changed_paths.add(path.relative_to(repo_root))
1189
+
1190
+
1191
+ def repair_gates_payload(repo_root: Path, source: str, *, dry_run: bool) -> dict[str, object]:
1192
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
1193
+ changed_paths: set[Path] = set()
1194
+ planned_paths: set[Path] = set()
1195
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
1196
+ item_refs = sorted(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
1197
+ request_refs: set[str] = set(_extract_refs(task_text, DOC_KINDS["request"].prefix))
1198
+
1199
+ before = task_path.read_text(encoding="utf-8")
1200
+ if _section_has_unchecked_checkbox(before, "Plan") or _section_has_unchecked_checkbox(before, "Definition of Done (DoD)"):
1201
+ planned_paths.add(task_path.relative_to(repo_root))
1202
+ _mark_section_checkboxes_done(task_path, "Plan", dry_run)
1203
+ _mark_section_checkboxes_done(task_path, "Definition of Done (DoD)", dry_run)
1204
+ if not dry_run:
1205
+ _changed_rel(repo_root, changed_paths, task_path, before)
1206
+
1207
+ for item_ref in item_refs:
1208
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
1209
+ if item_path is None:
1210
+ continue
1211
+ request_refs.update(_extract_refs(_strip_mermaid_blocks(item_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
1212
+
1213
+ for request_ref in sorted(request_refs):
1214
+ request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
1215
+ if request_path is None:
1216
+ continue
1217
+ before = request_path.read_text(encoding="utf-8")
1218
+ if _section_has_unchecked_checkbox(before, "Definition of Ready (DoR)"):
1219
+ planned_paths.add(request_path.relative_to(repo_root))
1220
+ _mark_section_checkboxes_done(request_path, "Definition of Ready (DoR)", dry_run)
1221
+ if not dry_run:
1222
+ _changed_rel(repo_root, changed_paths, request_path, before)
1223
+
1224
+ return {
1225
+ "command": "repair",
1226
+ "kind": "gates",
1227
+ "source": task_path.relative_to(repo_root).as_posix(),
1228
+ "changed_files": sorted(path.as_posix() for path in (planned_paths if dry_run else changed_paths)),
1229
+ "dry_run": dry_run,
1230
+ }
1231
+
1232
+
1233
+ def _request_ac_entries(request_path: Path) -> list[tuple[str, str]]:
1234
+ entries: list[tuple[str, str]] = []
1235
+ for line in _section_lines(request_path.read_text(encoding="utf-8").splitlines(), "Acceptance criteria"):
1236
+ match = re.search(r"\bAC(\d+)\s*:\s*(.+)", line, flags=re.IGNORECASE)
1237
+ if match:
1238
+ entries.append((f"AC{int(match.group(1))}", match.group(2).strip()))
1239
+ return entries
1240
+
1241
+
1242
+ def _ac_traceability_entry(ac_id: str, target: str, text: str, proof: str | None, proof_source: str | None) -> str:
1243
+ if proof and proof.strip():
1244
+ rendered = f"request-{ac_id} -> {target}. Proof: {proof.strip()}"
1245
+ if proof_source and proof_source.strip():
1246
+ rendered += f" Source: `{proof_source.strip()}`"
1247
+ return rendered
1248
+ return f"request-{ac_id} -> {target}. Evidence needed: {text}"
1249
+
1250
+
1251
+ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: bool, proof: str | None = None, proof_source: str | None = None) -> dict[str, object]:
1252
+ request_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], source)
1253
+ request_ref = request_path.stem
1254
+ ac_entries = _request_ac_entries(request_path)
1255
+ changed_paths: set[Path] = set()
1256
+ linked_items = _collect_docs_linking_ref(repo_root, DOC_KINDS["backlog"], request_ref)
1257
+ linked_task_paths = {
1258
+ path
1259
+ for path in _collect_docs_linking_ref(repo_root, DOC_KINDS["task"], request_ref)
1260
+ }
1261
+
1262
+ for item_path in linked_items:
1263
+ item_before = item_path.read_text(encoding="utf-8")
1264
+ item_missing = [
1265
+ _ac_traceability_entry(ac_id, "This backlog slice", text, proof, proof_source)
1266
+ for ac_id, text in ac_entries
1267
+ if not _has_ac_proof(item_before, ac_id)
1268
+ ]
1269
+ if _append_doc_section_bullets_changed(item_path, "AC Traceability", item_missing, dry_run=dry_run):
1270
+ changed_paths.add(item_path.relative_to(repo_root))
1271
+
1272
+ item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8") if not dry_run else item_before)
1273
+ for task_ref in sorted(_extract_refs(item_text, DOC_KINDS["task"].prefix)):
1274
+ task_path = _resolve_doc_path(repo_root, DOC_KINDS["task"], task_ref)
1275
+ if task_path is None:
1276
+ continue
1277
+ linked_task_paths.add(task_path)
1278
+
1279
+ for task_path in sorted(linked_task_paths):
1280
+ task_before = task_path.read_text(encoding="utf-8")
1281
+ task_missing = [
1282
+ _ac_traceability_entry(ac_id, "This task", text, proof, proof_source)
1283
+ for ac_id, text in ac_entries
1284
+ if not _has_ac_proof(task_before, ac_id)
1285
+ ]
1286
+ if _append_doc_section_bullets_changed(task_path, "AC Traceability", task_missing, dry_run=dry_run):
1287
+ changed_paths.add(task_path.relative_to(repo_root))
1288
+
1289
+ return {
1290
+ "command": "repair",
1291
+ "kind": "ac-traceability",
1292
+ "source": request_path.relative_to(repo_root).as_posix(),
1293
+ "proof_recorded": bool(proof and proof.strip()),
1294
+ "proof_source": proof_source.strip() if proof_source and proof_source.strip() else None,
1295
+ "changed_files": sorted(path.as_posix() for path in changed_paths),
1296
+ "dry_run": dry_run,
1297
+ }
1298
+
1299
+
1300
+ def repair_links_payload(repo_root: Path, source: str, *, dry_run: bool) -> dict[str, object]:
1301
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
1302
+ task_ref = task_path.stem
1303
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
1304
+ item_refs = sorted(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
1305
+ request_refs = sorted(_extract_refs(task_text, DOC_KINDS["request"].prefix))
1306
+ product_refs = sorted(_extract_refs(task_path.read_text(encoding="utf-8"), "prod"))
1307
+ changed_paths: set[Path] = set()
1308
+
1309
+ for item_ref in item_refs:
1310
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
1311
+ if item_path is None:
1312
+ continue
1313
+ if _append_doc_section_bullets_changed(item_path, "Tasks", [f"`{task_ref}`"], dry_run=dry_run):
1314
+ changed_paths.add(item_path.relative_to(repo_root))
1315
+ before = item_path.read_text(encoding="utf-8")
1316
+ lines = before.splitlines()
1317
+ lines = _replace_or_append_prefixed_section_bullet(lines, "Links", "Primary task(s)", f"`{task_ref}`")
1318
+ if request_refs:
1319
+ lines = _replace_or_append_prefixed_section_bullet(lines, "Links", "Request", f"`{request_refs[0]}`")
1320
+ after = "\n".join(lines).rstrip() + "\n"
1321
+ if after != before:
1322
+ changed_paths.add(item_path.relative_to(repo_root))
1323
+ if not dry_run:
1324
+ item_path.write_text(after, encoding="utf-8")
1325
+
1326
+ for product_ref in product_refs:
1327
+ product_path = _first_product_path(repo_root, product_ref)
1328
+ if product_path is None:
1329
+ continue
1330
+ before = product_path.read_text(encoding="utf-8")
1331
+ backlog_ref = item_refs[0] if item_refs else None
1332
+ request_ref = request_refs[0] if request_refs else None
1333
+ lines = before.splitlines()
1334
+ if request_ref:
1335
+ lines = _replace_indicator_line(lines, "Related request", f"`{request_ref}`")
1336
+ if backlog_ref:
1337
+ lines = _replace_indicator_line(lines, "Related backlog", f"`{backlog_ref}`")
1338
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Product back-reference", f"`{backlog_ref}`")
1339
+ lines = _replace_indicator_line(lines, "Related task", f"`{task_ref}`")
1340
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Task back-reference", f"`{task_ref}`")
1341
+ after = "\n".join(lines).rstrip() + "\n"
1342
+ if after != before:
1343
+ changed_paths.add(product_path.relative_to(repo_root))
1344
+ if not dry_run:
1345
+ product_path.write_text(after, encoding="utf-8")
1346
+
1347
+ return {
1348
+ "command": "repair",
1349
+ "kind": "links",
1350
+ "source": task_path.relative_to(repo_root).as_posix(),
1351
+ "changed_files": sorted(path.as_posix() for path in changed_paths),
1352
+ "dry_run": dry_run,
1353
+ }
1354
+
1355
+
1356
+ def _resolve_any_workflow_source(repo_root: Path, source: str) -> tuple[Path, str]:
1357
+ for kind in ("request", "backlog", "task"):
1358
+ try:
1359
+ return _resolve_workflow_source(repo_root, DOC_KINDS[kind], source), kind
1360
+ except SystemExit:
1361
+ continue
1362
+ raise SystemExit(f"Workflow source not found: {source}")
1363
+
1364
+
1365
+ def repair_mermaid_payload(repo_root: Path, refs: list[str], *, dry_run: bool) -> dict[str, object]:
1366
+ changed_paths: set[Path] = set()
1367
+ for ref in refs:
1368
+ path, kind = _resolve_any_workflow_source(repo_root, ref)
1369
+ before = path.read_text(encoding="utf-8")
1370
+ if "```mermaid" not in before:
1371
+ repaired = _with_workflow_mermaid_overview(kind, before)
1372
+ if repaired != before:
1373
+ changed_paths.add(path.relative_to(repo_root))
1374
+ if not dry_run:
1375
+ path.write_text(repaired, encoding="utf-8")
1376
+ else:
1377
+ signature = expected_workflow_mermaid_signature(kind, before.splitlines())
1378
+ repaired = re.sub(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", f"%% logics-signature: {signature}", before, count=1, flags=re.MULTILINE)
1379
+ if repaired != before:
1380
+ changed_paths.add(path.relative_to(repo_root))
1381
+ if not dry_run:
1382
+ path.write_text(repaired, encoding="utf-8")
1383
+ return {
1384
+ "command": "repair",
1385
+ "kind": "mermaid",
1386
+ "refs": refs,
1387
+ "changed_files": sorted(path.as_posix() for path in changed_paths),
1388
+ "dry_run": dry_run,
1389
+ }
1390
+
1391
+
711
1392
  def _add_common_doc_args(parser: argparse.ArgumentParser, kind: str) -> None:
712
1393
  parser.add_argument("--from-version")
713
1394
  parser.add_argument("--understanding", default="90%")
@@ -738,9 +1419,9 @@ def _build_native_request_doc(repo_root: Path, planned_ref: str, title: str, arg
738
1419
  references = [
739
1420
  "`logics_manager/flow.py`",
740
1421
  "`logics_manager/assist.py`",
741
- "`python_tests/test_logics_manager_cli.py`",
1422
+ "`tests/python/test_logics_manager_cli.py`",
742
1423
  ]
743
- return "\n".join(
1424
+ content = "\n".join(
744
1425
  [
745
1426
  f"## {planned_ref} - {title}",
746
1427
  f"> From version: {from_version}",
@@ -787,6 +1468,7 @@ def _build_native_request_doc(repo_root: Path, planned_ref: str, title: str, arg
787
1468
  "",
788
1469
  ]
789
1470
  ).rstrip() + "\n"
1471
+ return _with_workflow_mermaid_overview("request", content)
790
1472
 
791
1473
 
792
1474
  def _build_native_backlog_doc(
@@ -809,7 +1491,7 @@ def _build_native_backlog_doc(
809
1491
  f"AC1: The backlog slice stays bounded for {title.lower()}.",
810
1492
  "AC2: The backlog slice is reviewable and promotable into a task.",
811
1493
  ]
812
- return "\n".join(
1494
+ content = "\n".join(
813
1495
  [
814
1496
  f"## {planned_ref} - {title}",
815
1497
  f"> From version: {from_version}",
@@ -837,6 +1519,7 @@ def _build_native_backlog_doc(
837
1519
  "# AC Traceability",
838
1520
  "- request-AC1 -> This backlog slice. Proof: bounded delivery slice.",
839
1521
  "- request-AC2 -> This backlog slice. Proof: promotable backlog item.",
1522
+ "- request-AC3 -> This backlog slice. Proof: delivery chain includes a task-ready backlog item.",
840
1523
  "",
841
1524
  "# Decision framing",
842
1525
  "- Product framing: Not needed",
@@ -863,6 +1546,7 @@ def _build_native_backlog_doc(
863
1546
  "",
864
1547
  ]
865
1548
  ).rstrip() + "\n"
1549
+ return _with_workflow_mermaid_overview("backlog", content)
866
1550
 
867
1551
 
868
1552
  def _build_native_task_doc(
@@ -884,7 +1568,7 @@ def _build_native_task_doc(
884
1568
  request_line = ", ".join(f"`{ref}`" for ref in request_refs) if request_refs else "(none yet)"
885
1569
  product_line = ", ".join(f"`{ref}`" for ref in product_refs) if product_refs else "(none yet)"
886
1570
  architecture_line = ", ".join(f"`{ref}`" for ref in architecture_refs) if architecture_refs else "(none yet)"
887
- return "\n".join(
1571
+ content = "\n".join(
888
1572
  [
889
1573
  f"## {planned_ref} - {title}",
890
1574
  f"> From version: {from_version}",
@@ -914,6 +1598,13 @@ def _build_native_task_doc(
914
1598
  "- [ ] Validation passes.",
915
1599
  "- [ ] Linked docs are synchronized.",
916
1600
  "",
1601
+ "# AC Traceability",
1602
+ "- request-AC1 -> This task. Proof: implementation delivers the bounded request need.",
1603
+ "- request-AC2 -> This task. Proof: implementation scope is limited to the linked delivery slice.",
1604
+ "- request-AC3 -> This task. Proof: implementation is executable from the promoted backlog item.",
1605
+ "- backlog-AC1 -> This task. Proof: task remains bounded to the linked backlog scope.",
1606
+ "- backlog-AC2 -> This task. Proof: task provides the executable implementation surface.",
1607
+ "",
917
1608
  "# Validation",
918
1609
  "- Run `python3 -m logics_manager lint --require-status`.",
919
1610
  "- Run the task-specific automated tests.",
@@ -934,6 +1625,7 @@ def _build_native_task_doc(
934
1625
  "",
935
1626
  ]
936
1627
  ).rstrip() + "\n"
1628
+ return _with_workflow_mermaid_overview("task", content)
937
1629
 
938
1630
 
939
1631
  def _extract_doc_title(path: Path) -> str:
@@ -1048,6 +1740,109 @@ def _append_doc_section_bullets(path: Path, heading: str, bullets: list[str], *,
1048
1740
  path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
1049
1741
 
1050
1742
 
1743
+ def _append_doc_section_bullets_changed(path: Path, heading: str, bullets: list[str], *, dry_run: bool) -> bool:
1744
+ if not bullets:
1745
+ return False
1746
+ before = path.read_text(encoding="utf-8") if path.is_file() else ""
1747
+ _append_doc_section_bullets(path, heading, bullets, dry_run=dry_run)
1748
+ if dry_run:
1749
+ return any(f"- {bullet}" not in before for bullet in bullets)
1750
+ return path.read_text(encoding="utf-8") != before
1751
+
1752
+
1753
+ def _remove_section_placeholder_bullets(path: Path, heading: str, placeholders: set[str], *, dry_run: bool) -> bool:
1754
+ lines = path.read_text(encoding="utf-8").splitlines()
1755
+ target = heading.strip().lower()
1756
+ in_section = False
1757
+ changed = False
1758
+ output: list[str] = []
1759
+ for line in lines:
1760
+ if line.startswith("# "):
1761
+ in_section = line[2:].strip().lower() == target
1762
+ output.append(line)
1763
+ continue
1764
+ if in_section and line.strip().lower() in placeholders:
1765
+ changed = True
1766
+ continue
1767
+ output.append(line)
1768
+ if changed and not dry_run:
1769
+ path.write_text("\n".join(output).rstrip() + "\n", encoding="utf-8")
1770
+ return changed
1771
+
1772
+
1773
+ def _replace_indicator_line(lines: list[str], label: str, value: str) -> list[str]:
1774
+ prefix = f"> {label}:"
1775
+ updated = False
1776
+ output: list[str] = []
1777
+ insert_at = 1
1778
+ for idx, line in enumerate(lines):
1779
+ if idx > 0 and line.startswith("> "):
1780
+ insert_at = idx + 1
1781
+ if line.startswith(prefix):
1782
+ output.append(f"{prefix} {value}")
1783
+ updated = True
1784
+ else:
1785
+ output.append(line)
1786
+ if not updated:
1787
+ output.insert(insert_at, f"{prefix} {value}")
1788
+ return output
1789
+
1790
+
1791
+ def _replace_or_append_prefixed_section_bullet(
1792
+ lines: list[str],
1793
+ heading: str,
1794
+ bullet_prefix: str,
1795
+ rendered_value: str,
1796
+ ) -> list[str]:
1797
+ heading_idx = None
1798
+ for idx, line in enumerate(lines):
1799
+ if line.startswith("# ") and line[2:].strip().lower() == heading.strip().lower():
1800
+ heading_idx = idx
1801
+ break
1802
+ rendered = f"- {bullet_prefix}: {rendered_value}"
1803
+ if heading_idx is None:
1804
+ return [*lines, "", f"# {heading}", rendered]
1805
+
1806
+ end_idx = heading_idx + 1
1807
+ while end_idx < len(lines) and not lines[end_idx].startswith("# "):
1808
+ end_idx += 1
1809
+
1810
+ output = list(lines)
1811
+ for idx in range(heading_idx + 1, end_idx):
1812
+ if output[idx].strip().startswith(f"- {bullet_prefix}:"):
1813
+ output[idx] = rendered
1814
+ return output
1815
+ output.insert(end_idx, rendered)
1816
+ return output
1817
+
1818
+
1819
+ def _update_product_delivery_links(
1820
+ product_path: Path,
1821
+ *,
1822
+ request_ref: str,
1823
+ backlog_ref: str,
1824
+ task_ref: str,
1825
+ dry_run: bool,
1826
+ ) -> None:
1827
+ if dry_run:
1828
+ return
1829
+ lines = product_path.read_text(encoding="utf-8").splitlines()
1830
+ lines = _replace_indicator_line(lines, "Related request", f"`{request_ref}`")
1831
+ lines = _replace_indicator_line(lines, "Related backlog", f"`{backlog_ref}`")
1832
+ lines = _replace_indicator_line(lines, "Related task", f"`{task_ref}`")
1833
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Product back-reference", f"`{backlog_ref}`")
1834
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Task back-reference", f"`{task_ref}`")
1835
+ product_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
1836
+
1837
+
1838
+ def _update_request_product_link(request_path: Path, product_ref: str, *, dry_run: bool) -> None:
1839
+ if dry_run:
1840
+ return
1841
+ lines = request_path.read_text(encoding="utf-8").splitlines()
1842
+ lines = _replace_or_append_prefixed_section_bullet(lines, "Companion docs", "Product brief(s)", f"`{product_ref}`")
1843
+ request_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
1844
+
1845
+
1051
1846
  def _build_native_product_brief(
1052
1847
  repo_root: Path,
1053
1848
  title: str,
@@ -1063,6 +1858,7 @@ def _build_native_product_brief(
1063
1858
  related_backlog = f"`{backlog_ref}`" if backlog_ref else "(none yet)"
1064
1859
  related_task = f"`{task_ref}`" if task_ref else "(none yet)"
1065
1860
  related_architecture = ", ".join(f"`{item}`" for item in architecture_refs) if architecture_refs else "(none yet)"
1861
+ signature_slug = _slugify(title) or "product-brief"
1066
1862
  content = "\n".join(
1067
1863
  [
1068
1864
  f"## {ref} - {title}",
@@ -1077,6 +1873,15 @@ def _build_native_product_brief(
1077
1873
  "# Overview",
1078
1874
  f"Logics should keep a single, predictable product surface for {title.lower()}.",
1079
1875
  "",
1876
+ "```mermaid",
1877
+ "%% logics-kind: product",
1878
+ f"%% logics-signature: product|{signature_slug}|generated",
1879
+ "flowchart TD",
1880
+ " Need[Product need] --> Scope[Scope and guardrails]",
1881
+ " Scope --> Decisions[Key decisions]",
1882
+ " Decisions --> Signals[Success signals]",
1883
+ "```",
1884
+ "",
1080
1885
  "# Goals",
1081
1886
  "- Keep the operator experience bounded and easy to reason about.",
1082
1887
  "- Preserve the CLI as the canonical workflow entrypoint.",
@@ -1174,8 +1979,7 @@ def _create_native_companion_docs(
1174
1979
  )
1175
1980
  adr_path = repo_root / "logics" / "architecture" / f"{adr_ref}.md"
1176
1981
  if not args.dry_run:
1177
- adr_path.parent.mkdir(parents=True, exist_ok=True)
1178
- adr_path.write_text(adr_content, encoding="utf-8")
1982
+ _write_new_doc(adr_path, adr_content)
1179
1983
  created_architecture_refs.append(adr_ref)
1180
1984
 
1181
1985
  if getattr(args, "auto_create_product_brief", False):
@@ -1189,8 +1993,7 @@ def _create_native_companion_docs(
1189
1993
  )
1190
1994
  product_path = repo_root / "logics" / "product" / f"{product_ref}.md"
1191
1995
  if not args.dry_run:
1192
- product_path.parent.mkdir(parents=True, exist_ok=True)
1193
- product_path.write_text(product_content, encoding="utf-8")
1996
+ _write_new_doc(product_path, product_content)
1194
1997
  created_product_refs.append(product_ref)
1195
1998
 
1196
1999
  return created_product_refs, created_architecture_refs
@@ -1307,7 +2110,7 @@ def _build_native_backlog_from_request(
1307
2110
  "",
1308
2111
  ]
1309
2112
  ).rstrip() + "\n"
1310
- return ref, content
2113
+ return ref, _with_workflow_mermaid_overview("backlog", content)
1311
2114
 
1312
2115
 
1313
2116
  def _build_native_task_from_backlog(
@@ -1380,7 +2183,7 @@ def _build_native_task_from_backlog(
1380
2183
  "",
1381
2184
  ]
1382
2185
  ).rstrip() + "\n"
1383
- return ref, content
2186
+ return ref, _with_workflow_mermaid_overview("task", content)
1384
2187
 
1385
2188
 
1386
2189
  def build_parser() -> argparse.ArgumentParser:
@@ -1420,6 +2223,65 @@ def build_parser() -> argparse.ArgumentParser:
1420
2223
  kind_parser.add_argument("--dry-run", action="store_true")
1421
2224
  kind_parser.set_defaults(func=cmd_companion)
1422
2225
 
2226
+ deliver_parser = sub.add_parser("deliver", help="Create a delivery chain from a product brief.")
2227
+ deliver_parser.add_argument("--from-product", required=True)
2228
+ deliver_parser.add_argument("--title")
2229
+ deliver_parser.add_argument("--finish", action="store_true")
2230
+ deliver_parser.add_argument("--format", choices=("text", "json"), default="text")
2231
+ deliver_parser.add_argument("--dry-run", action="store_true")
2232
+ deliver_parser.set_defaults(func=cmd_deliver)
2233
+
2234
+ validate_closeout_parser = sub.add_parser("validate-closeout", help="Preflight whether a task can be safely closed.")
2235
+ validate_closeout_parser.add_argument("source")
2236
+ validate_closeout_parser.add_argument("--format", choices=("text", "json"), default="text")
2237
+ validate_closeout_parser.set_defaults(func=cmd_validate_closeout)
2238
+
2239
+ repair_parser = sub.add_parser("repair", help="Apply deterministic closeout repairs.")
2240
+ repair_sub = repair_parser.add_subparsers(dest="repair_kind", required=True)
2241
+
2242
+ repair_gates = repair_sub.add_parser("gates", help="Check task and linked request gate checkboxes.")
2243
+ repair_gates.add_argument("source")
2244
+ repair_gates.add_argument("--verify-closeout")
2245
+ repair_gates.add_argument("--format", choices=("text", "json"), default="text")
2246
+ repair_gates.add_argument("--dry-run", action="store_true")
2247
+ repair_gates.set_defaults(func=cmd_repair_gates)
2248
+
2249
+ repair_ac = repair_sub.add_parser("ac-traceability", help="Add missing AC traceability entries.")
2250
+ repair_ac.add_argument("source")
2251
+ repair_ac.add_argument("--proof")
2252
+ repair_ac.add_argument("--proof-source")
2253
+ repair_ac.add_argument("--verify-closeout")
2254
+ repair_ac.add_argument("--format", choices=("text", "json"), default="text")
2255
+ repair_ac.add_argument("--dry-run", action="store_true")
2256
+ repair_ac.set_defaults(func=cmd_repair_ac_traceability)
2257
+
2258
+ repair_links = repair_sub.add_parser("links", help="Repair linked backlog/product references for a task.")
2259
+ repair_links.add_argument("source")
2260
+ repair_links.add_argument("--verify-closeout")
2261
+ repair_links.add_argument("--format", choices=("text", "json"), default="text")
2262
+ repair_links.add_argument("--dry-run", action="store_true")
2263
+ repair_links.set_defaults(func=cmd_repair_links)
2264
+
2265
+ repair_mermaid = repair_sub.add_parser("mermaid", help="Insert or refresh workflow Mermaid signatures.")
2266
+ repair_mermaid.add_argument("--refs", nargs="+", required=True)
2267
+ repair_mermaid.add_argument("--verify-closeout")
2268
+ repair_mermaid.add_argument("--format", choices=("text", "json"), default="text")
2269
+ repair_mermaid.add_argument("--dry-run", action="store_true")
2270
+ repair_mermaid.set_defaults(func=cmd_repair_mermaid)
2271
+
2272
+ closeout_parser = sub.add_parser("closeout", help="Append validation, repair deterministic gaps, finish, and optionally validate/index.")
2273
+ closeout_parser.add_argument("source")
2274
+ closeout_parser.add_argument("--validation", action="append", default=[])
2275
+ closeout_parser.add_argument("--validation-command")
2276
+ closeout_parser.add_argument("--validation-result", default="passed")
2277
+ closeout_parser.add_argument("--validation-note")
2278
+ closeout_parser.add_argument("--index", action="store_true")
2279
+ closeout_parser.add_argument("--lint", action="store_true")
2280
+ closeout_parser.add_argument("--audit", action="store_true")
2281
+ closeout_parser.add_argument("--format", choices=("text", "json"), default="text")
2282
+ closeout_parser.add_argument("--dry-run", action="store_true")
2283
+ closeout_parser.set_defaults(func=cmd_closeout)
2284
+
1423
2285
  promote_parser = sub.add_parser("promote", help="Promote between Logics stages.")
1424
2286
  promote_sub = promote_parser.add_subparsers(dest="promotion", required=True)
1425
2287
 
@@ -1482,16 +2344,22 @@ def cmd_new(args: argparse.Namespace) -> dict[str, object]:
1482
2344
  if doc_kind.kind == "request":
1483
2345
  content = _build_native_request_doc(repo_root, planned.ref, args.title, args)
1484
2346
  if not args.dry_run:
1485
- planned.path.parent.mkdir(parents=True, exist_ok=True)
1486
- planned.path.write_text(content, encoding="utf-8")
1487
- print(f"Wrote {planned.path}")
2347
+ _write_new_doc(planned.path, content)
2348
+ if args.format != "json":
2349
+ print(f"Wrote {planned.path}")
1488
2350
  else:
1489
- preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
1490
- print(f"[dry-run] would write: {planned.path}")
1491
- print(preview)
1492
- print(f"Created {doc_kind.kind}: {payload['path']}")
2351
+ if args.format != "json":
2352
+ preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
2353
+ print(f"[dry-run] would write: {planned.path}")
2354
+ print(preview)
2355
+ if args.format == "json":
2356
+ print_payload(payload, args.format)
2357
+ else:
2358
+ print(f"Created {doc_kind.kind}: {payload['path']}")
1493
2359
  return payload
1494
2360
  if doc_kind.kind == "backlog":
2361
+ if not args.dry_run:
2362
+ _ensure_new_doc_paths_available([planned.path])
1495
2363
  product_refs, architecture_refs = _create_native_companion_docs(
1496
2364
  repo_root,
1497
2365
  args.title,
@@ -1510,6 +2378,8 @@ def cmd_new(args: argparse.Namespace) -> dict[str, object]:
1510
2378
  architecture_refs=architecture_refs,
1511
2379
  )
1512
2380
  elif doc_kind.kind == "task":
2381
+ if not args.dry_run:
2382
+ _ensure_new_doc_paths_available([planned.path])
1513
2383
  product_refs, architecture_refs = _create_native_companion_docs(
1514
2384
  repo_root,
1515
2385
  args.title,
@@ -1532,15 +2402,19 @@ def cmd_new(args: argparse.Namespace) -> dict[str, object]:
1532
2402
  raise SystemExit(f"Unsupported doc kind `{doc_kind.kind}` for native creation.")
1533
2403
 
1534
2404
  if not args.dry_run:
1535
- planned.path.parent.mkdir(parents=True, exist_ok=True)
1536
- planned.path.write_text(content, encoding="utf-8")
1537
- print(f"Wrote {planned.path}")
2405
+ _write_new_doc(planned.path, content)
2406
+ if args.format != "json":
2407
+ print(f"Wrote {planned.path}")
1538
2408
  else:
1539
- preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
1540
- print(f"[dry-run] would write: {planned.path}")
1541
- print(preview)
2409
+ if args.format != "json":
2410
+ preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
2411
+ print(f"[dry-run] would write: {planned.path}")
2412
+ print(preview)
1542
2413
 
1543
- print(f"Created {doc_kind.kind}: {payload['path']}")
2414
+ if args.format == "json":
2415
+ print_payload(payload, args.format)
2416
+ else:
2417
+ print(f"Created {doc_kind.kind}: {payload['path']}")
1544
2418
  return payload
1545
2419
 
1546
2420
 
@@ -1582,13 +2456,14 @@ def cmd_companion(args: argparse.Namespace) -> dict[str, object]:
1582
2456
  raise SystemExit(f"Unsupported companion kind `{args.kind}`.")
1583
2457
 
1584
2458
  if not args.dry_run:
1585
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1586
- planned_path.write_text(content, encoding="utf-8")
1587
- print(f"Wrote {planned_path}")
2459
+ _write_new_doc(planned_path, content)
2460
+ if args.format != "json":
2461
+ print(f"Wrote {planned_path}")
1588
2462
  else:
1589
- preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
1590
- print(f"[dry-run] would write: {planned_path}")
1591
- print(preview)
2463
+ if args.format != "json":
2464
+ preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
2465
+ print(f"[dry-run] would write: {planned_path}")
2466
+ print(preview)
1592
2467
 
1593
2468
  payload = {
1594
2469
  "command": "companion",
@@ -1601,19 +2476,411 @@ def cmd_companion(args: argparse.Namespace) -> dict[str, object]:
1601
2476
  "dry_run": args.dry_run,
1602
2477
  }
1603
2478
  if args.format == "json":
1604
- print(json.dumps(payload, indent=2, sort_keys=True))
2479
+ print_payload(payload, args.format)
1605
2480
  else:
1606
2481
  print(f"Created companion doc: {payload['path']}")
1607
2482
  return payload
1608
2483
 
1609
2484
 
2485
+ def _deliver_builder_args(args: argparse.Namespace) -> argparse.Namespace:
2486
+ return argparse.Namespace(
2487
+ from_version=None,
2488
+ understanding="90%",
2489
+ confidence="85%",
2490
+ status="Ready",
2491
+ complexity="Medium",
2492
+ theme="Operator workflow",
2493
+ progress="0%",
2494
+ auto_create_product_brief=False,
2495
+ auto_create_adr=False,
2496
+ dry_run=args.dry_run,
2497
+ fixture=False,
2498
+ )
2499
+
2500
+
2501
+ def cmd_deliver(args: argparse.Namespace) -> dict[str, object]:
2502
+ repo_root = _find_repo_root(Path.cwd())
2503
+ product_path = _resolve_product_source(repo_root, args.from_product)
2504
+ product_ref = product_path.stem
2505
+ title = args.title or _extract_doc_title(product_path)
2506
+ build_args = _deliver_builder_args(args)
2507
+
2508
+ request_planned = _plan_doc(repo_root, DOC_KINDS["request"].directory, DOC_KINDS["request"].prefix, title, dry_run=args.dry_run)
2509
+ backlog_ref = _next_backlog_ref(repo_root, title)
2510
+ task_ref = _next_task_ref(repo_root, title)
2511
+ backlog_path = repo_root / DOC_KINDS["backlog"].directory / f"{backlog_ref}.md"
2512
+ task_path = repo_root / DOC_KINDS["task"].directory / f"{task_ref}.md"
2513
+
2514
+ if not args.dry_run:
2515
+ _ensure_new_doc_paths_available([request_planned.path, backlog_path, task_path])
2516
+
2517
+ request_content = _build_native_request_doc(repo_root, request_planned.ref, title, build_args)
2518
+ backlog_content = _build_native_backlog_doc(
2519
+ repo_root,
2520
+ backlog_ref,
2521
+ title,
2522
+ build_args,
2523
+ request_ref=request_planned.path.relative_to(repo_root).as_posix(),
2524
+ product_refs=[product_ref],
2525
+ architecture_refs=[],
2526
+ )
2527
+ task_content = _build_native_task_doc(
2528
+ repo_root,
2529
+ task_ref,
2530
+ title,
2531
+ build_args,
2532
+ backlog_ref=backlog_ref,
2533
+ request_refs=[request_planned.ref],
2534
+ product_refs=[product_ref],
2535
+ architecture_refs=[],
2536
+ )
2537
+
2538
+ if not args.dry_run:
2539
+ _write_new_doc(request_planned.path, request_content)
2540
+ _write_new_doc(backlog_path, backlog_content)
2541
+ _write_new_doc(task_path, task_content)
2542
+ _append_doc_section_bullets(request_planned.path, "Backlog", [f"`{backlog_ref}`"], dry_run=False)
2543
+ _append_doc_section_bullets(backlog_path, "Tasks", [f"`{task_ref}`"], dry_run=False)
2544
+ _remove_section_placeholder_bullets(request_planned.path, "Backlog", {"- none"}, dry_run=False)
2545
+ backlog_lines = backlog_path.read_text(encoding="utf-8").splitlines()
2546
+ backlog_lines = _replace_or_append_prefixed_section_bullet(backlog_lines, "Links", "Primary task(s)", f"`{task_ref}`")
2547
+ backlog_path.write_text("\n".join(backlog_lines).rstrip() + "\n", encoding="utf-8")
2548
+ _update_request_product_link(request_planned.path, product_ref, dry_run=False)
2549
+ _mark_section_checkboxes_done(request_planned.path, "Definition of Ready (DoR)", dry_run=False)
2550
+ _update_product_delivery_links(
2551
+ product_path,
2552
+ request_ref=request_planned.ref,
2553
+ backlog_ref=backlog_ref,
2554
+ task_ref=task_ref,
2555
+ dry_run=False,
2556
+ )
2557
+ repair_mermaid_payload(repo_root, [request_planned.ref, backlog_ref, task_ref], dry_run=False)
2558
+ if args.finish:
2559
+ _close_chain_for_kind(repo_root, task_path, DOC_KINDS["task"], dry_run=False, quiet=args.format == "json")
2560
+
2561
+ payload = {
2562
+ "command": "deliver",
2563
+ "from_product": product_path.relative_to(repo_root).as_posix(),
2564
+ "product_ref": product_ref,
2565
+ "created_request_ref": request_planned.ref,
2566
+ "created_request_path": request_planned.path.relative_to(repo_root).as_posix(),
2567
+ "created_backlog_ref": backlog_ref,
2568
+ "created_backlog_path": backlog_path.relative_to(repo_root).as_posix(),
2569
+ "created_task_ref": task_ref,
2570
+ "created_task_path": task_path.relative_to(repo_root).as_posix(),
2571
+ "finished": bool(args.finish and not args.dry_run),
2572
+ "dry_run": args.dry_run,
2573
+ }
2574
+
2575
+ if args.format == "json":
2576
+ print_payload(payload, args.format)
2577
+ elif args.dry_run:
2578
+ print(f"[dry-run] would create delivery chain from product: {product_path.relative_to(repo_root)}")
2579
+ print(f"- request: {payload['created_request_path']}")
2580
+ print(f"- backlog: {payload['created_backlog_path']}")
2581
+ print(f"- task: {payload['created_task_path']}")
2582
+ else:
2583
+ print(f"Created delivery chain from product: {product_path.relative_to(repo_root)}")
2584
+ print(f"- request: {payload['created_request_path']}")
2585
+ print(f"- backlog: {payload['created_backlog_path']}")
2586
+ print(f"- task: {payload['created_task_path']}")
2587
+ return payload
2588
+
2589
+
2590
+ def cmd_validate_closeout(args: argparse.Namespace) -> dict[str, object]:
2591
+ repo_root = _find_repo_root(Path.cwd())
2592
+ payload = validate_closeout_payload(repo_root, args.source)
2593
+ if args.format == "json":
2594
+ print_payload(payload, args.format)
2595
+ else:
2596
+ status = "OK" if payload["ok"] else "FAILED"
2597
+ print(f"Closeout preflight: {status} for {payload['source']}")
2598
+ if payload["issues"]:
2599
+ for issue in payload["issues"]:
2600
+ print(f"- {issue['code']}: {issue['message']} ({issue['path']})")
2601
+ if "repair_command" in issue:
2602
+ print(f" repair: {issue['repair_command']}")
2603
+ else:
2604
+ print("- no blocking closeout issues found")
2605
+ return payload
2606
+
2607
+
2608
+ def _print_repair_payload(payload: dict[str, object], output_format: str) -> None:
2609
+ if output_format == "json":
2610
+ print_payload(payload, output_format)
2611
+ return
2612
+ action = "would change" if payload.get("dry_run") else "changed"
2613
+ changed_files = payload.get("changed_files", [])
2614
+ print(f"Repair {payload['kind']}: {action} {len(changed_files)} file(s).")
2615
+ for rel_path in changed_files:
2616
+ print(f"- {rel_path}")
2617
+
2618
+
2619
+ REPAIR_VERIFY_CODES = {
2620
+ "gates": {"task_gate_unchecked", "task_missing_done_gate", "request_dor_unchecked"},
2621
+ "ac-traceability": {"ac_missing_item_traceability", "ac_missing_task_traceability"},
2622
+ "links": {"backlog_missing_task_link", "companion_link_missing"},
2623
+ "mermaid": {"mermaid_signature_stale"},
2624
+ }
2625
+
2626
+
2627
+ def _repair_verify_snapshot(repo_root: Path, source: str | None, dry_run: bool) -> dict[str, str]:
2628
+ if dry_run or not source:
2629
+ return {}
2630
+ preflight = validate_closeout_payload(repo_root, source)
2631
+ return _snapshot_existing_files(repo_root, list(preflight.get("related_paths", [])))
2632
+
2633
+
2634
+ def _finalize_repair_verify(repo_root: Path, payload: dict[str, object], source: str | None, snapshot: dict[str, str]) -> dict[str, object]:
2635
+ if not source or payload.get("dry_run"):
2636
+ return payload
2637
+ preflight = validate_closeout_payload(repo_root, source)
2638
+ payload["preflight"] = preflight
2639
+ relevant_codes = REPAIR_VERIFY_CODES.get(str(payload.get("kind")), set())
2640
+ remaining_relevant = [issue for issue in preflight.get("issues", []) if issue.get("code") in relevant_codes]
2641
+ payload["rolled_back"] = False
2642
+ if remaining_relevant and snapshot:
2643
+ payload["attempted_changed_files"] = payload.get("changed_files", [])
2644
+ _restore_file_snapshot(repo_root, snapshot)
2645
+ payload["changed_files"] = []
2646
+ payload["rolled_back"] = True
2647
+ payload["rollback_reason"] = "repair verification left relevant closeout issues"
2648
+ payload["remaining_relevant_issues"] = remaining_relevant
2649
+ return payload
2650
+
2651
+
2652
+ def cmd_repair_gates(args: argparse.Namespace) -> dict[str, object]:
2653
+ repo_root = _find_repo_root(Path.cwd())
2654
+ verify_source = args.verify_closeout or args.source
2655
+ snapshot = _repair_verify_snapshot(repo_root, verify_source, args.dry_run)
2656
+ payload = repair_gates_payload(repo_root, args.source, dry_run=args.dry_run)
2657
+ payload = _finalize_repair_verify(repo_root, payload, verify_source, snapshot)
2658
+ _print_repair_payload(payload, args.format)
2659
+ return payload
2660
+
2661
+
2662
+ def cmd_repair_ac_traceability(args: argparse.Namespace) -> dict[str, object]:
2663
+ repo_root = _find_repo_root(Path.cwd())
2664
+ snapshot = _repair_verify_snapshot(repo_root, args.verify_closeout, args.dry_run)
2665
+ payload = repair_ac_traceability_payload(repo_root, args.source, dry_run=args.dry_run, proof=args.proof, proof_source=args.proof_source)
2666
+ payload = _finalize_repair_verify(repo_root, payload, args.verify_closeout, snapshot)
2667
+ _print_repair_payload(payload, args.format)
2668
+ return payload
2669
+
2670
+
2671
+ def cmd_repair_links(args: argparse.Namespace) -> dict[str, object]:
2672
+ repo_root = _find_repo_root(Path.cwd())
2673
+ verify_source = args.verify_closeout or args.source
2674
+ snapshot = _repair_verify_snapshot(repo_root, verify_source, args.dry_run)
2675
+ payload = repair_links_payload(repo_root, args.source, dry_run=args.dry_run)
2676
+ payload = _finalize_repair_verify(repo_root, payload, verify_source, snapshot)
2677
+ _print_repair_payload(payload, args.format)
2678
+ return payload
2679
+
2680
+
2681
+ def cmd_repair_mermaid(args: argparse.Namespace) -> dict[str, object]:
2682
+ repo_root = _find_repo_root(Path.cwd())
2683
+ snapshot = _repair_verify_snapshot(repo_root, args.verify_closeout, args.dry_run)
2684
+ payload = repair_mermaid_payload(repo_root, args.refs, dry_run=args.dry_run)
2685
+ payload = _finalize_repair_verify(repo_root, payload, args.verify_closeout, snapshot)
2686
+ _print_repair_payload(payload, args.format)
2687
+ return payload
2688
+
2689
+
2690
+ def _closeout_refs(repo_root: Path, task_path: Path) -> list[str]:
2691
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
2692
+ refs = {task_path.stem}
2693
+ item_refs = set(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
2694
+ refs.update(item_refs)
2695
+ refs.update(_extract_refs(task_text, DOC_KINDS["request"].prefix))
2696
+ for item_ref in sorted(item_refs):
2697
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
2698
+ if item_path is not None:
2699
+ refs.update(_extract_refs(_strip_mermaid_blocks(item_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
2700
+ return sorted(refs)
2701
+
2702
+
2703
+ def _snapshot_existing_files(repo_root: Path, rel_paths: list[str]) -> dict[str, str]:
2704
+ snapshot: dict[str, str] = {}
2705
+ for rel_path in rel_paths:
2706
+ path = repo_root / rel_path
2707
+ if path.is_file():
2708
+ snapshot[rel_path] = path.read_text(encoding="utf-8")
2709
+ return snapshot
2710
+
2711
+
2712
+ def _restore_file_snapshot(repo_root: Path, snapshot: dict[str, str]) -> None:
2713
+ for rel_path, content in snapshot.items():
2714
+ path = repo_root / rel_path
2715
+ path.write_text(content, encoding="utf-8")
2716
+
2717
+
2718
+ def closeout_payload(
2719
+ repo_root: Path,
2720
+ source: str,
2721
+ *,
2722
+ validations: list[str],
2723
+ run_index: bool,
2724
+ run_lint: bool,
2725
+ run_audit: bool,
2726
+ dry_run: bool,
2727
+ validation_command: str | None = None,
2728
+ validation_result: str = "passed",
2729
+ validation_note: str | None = None,
2730
+ ) -> dict[str, object]:
2731
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
2732
+ task_ref = task_path.stem
2733
+ changed_files: set[str] = set()
2734
+ steps: list[dict[str, object]] = []
2735
+ initial_preflight = validate_closeout_payload(repo_root, task_ref)
2736
+ rollback_snapshot = {} if dry_run else _snapshot_existing_files(repo_root, list(initial_preflight.get("related_paths", [])))
2737
+
2738
+ if validation_command and validation_command.strip():
2739
+ validations = [*validations, _structured_validation_line(validation_command, validation_result, validation_note)]
2740
+
2741
+ for validation in validations:
2742
+ if validation and validation.strip():
2743
+ if _append_doc_section_bullets_changed(task_path, "Validation", [validation.strip()], dry_run=dry_run):
2744
+ changed_files.add(task_path.relative_to(repo_root).as_posix())
2745
+ steps.append({"kind": "validation", "text": validation.strip(), "dry_run": dry_run})
2746
+
2747
+ gate_payload = repair_gates_payload(repo_root, task_ref, dry_run=dry_run)
2748
+ link_payload = repair_links_payload(repo_root, task_ref, dry_run=dry_run)
2749
+ changed_files.update(gate_payload["changed_files"])
2750
+ changed_files.update(link_payload["changed_files"])
2751
+ steps.extend([gate_payload, link_payload])
2752
+
2753
+ request_refs = sorted(_extract_refs(_strip_mermaid_blocks(task_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
2754
+ item_refs = sorted(_extract_refs(_strip_mermaid_blocks(task_path.read_text(encoding="utf-8")), DOC_KINDS["backlog"].prefix))
2755
+ for item_ref in item_refs:
2756
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
2757
+ if item_path is not None:
2758
+ request_refs.extend(_extract_refs(_strip_mermaid_blocks(item_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
2759
+ for request_ref in sorted(set(request_refs)):
2760
+ ac_payload = repair_ac_traceability_payload(repo_root, request_ref, dry_run=dry_run)
2761
+ changed_files.update(ac_payload["changed_files"])
2762
+ steps.append(ac_payload)
2763
+
2764
+ mermaid_refs = _closeout_refs(repo_root, task_path)
2765
+ mermaid_payload = repair_mermaid_payload(repo_root, mermaid_refs, dry_run=dry_run)
2766
+ changed_files.update(mermaid_payload["changed_files"])
2767
+ steps.append(mermaid_payload)
2768
+
2769
+ preflight = validate_closeout_payload(repo_root, task_ref)
2770
+ if preflight["issues"]:
2771
+ rolled_back = False
2772
+ attempted_changed_files = sorted(changed_files)
2773
+ if rollback_snapshot:
2774
+ _restore_file_snapshot(repo_root, rollback_snapshot)
2775
+ changed_files.clear()
2776
+ rolled_back = True
2777
+ return {
2778
+ "command": "closeout",
2779
+ "ok": False,
2780
+ "source": task_path.relative_to(repo_root).as_posix(),
2781
+ "changed_files": sorted(changed_files),
2782
+ "attempted_changed_files": attempted_changed_files,
2783
+ "preflight": preflight,
2784
+ "rolled_back": rolled_back,
2785
+ "steps": steps,
2786
+ "dry_run": dry_run,
2787
+ }
2788
+
2789
+ finish_payload: dict[str, object] | None = None
2790
+ if not dry_run:
2791
+ _close_chain_for_kind(repo_root, task_path, DOC_KINDS["task"], dry_run=False, quiet=True)
2792
+ finish_issues = _verify_finished_task_chain(repo_root, task_path)
2793
+ if finish_issues:
2794
+ raise SystemExit("Finish verification failed:\n" + "\n".join(f"- {issue}" for issue in finish_issues))
2795
+ changed_files.add(task_path.relative_to(repo_root).as_posix())
2796
+ for ref in mermaid_refs:
2797
+ path, _kind = _resolve_any_workflow_source(repo_root, ref)
2798
+ changed_files.add(path.relative_to(repo_root).as_posix())
2799
+ finish_payload = {"kind": "finish", "ok": True}
2800
+ post_finish_mermaid = repair_mermaid_payload(repo_root, mermaid_refs, dry_run=False)
2801
+ changed_files.update(post_finish_mermaid["changed_files"])
2802
+ steps.append(post_finish_mermaid)
2803
+
2804
+ index_result: dict[str, object] | None = None
2805
+ if run_index:
2806
+ if dry_run:
2807
+ index_result = {"ok": True, "dry_run": True}
2808
+ else:
2809
+ index_result = index_payload(repo_root)
2810
+ changed_files.add(str(index_result["output_path"]))
2811
+
2812
+ lint_result: dict[str, object] | None = None
2813
+ if run_lint:
2814
+ lint_result = lint_payload(repo_root, require_status=True)
2815
+
2816
+ audit_result: dict[str, object] | None = None
2817
+ if run_audit:
2818
+ audit_result = audit_payload(repo_root, legacy_cutoff_version="1.1.0", group_by_doc=True)
2819
+
2820
+ ok = True
2821
+ if lint_result is not None and not lint_result.get("ok", False):
2822
+ ok = False
2823
+ if audit_result is not None and audit_result.get("issue_count", 0):
2824
+ ok = False
2825
+
2826
+ return {
2827
+ "command": "closeout",
2828
+ "ok": ok,
2829
+ "source": task_path.relative_to(repo_root).as_posix(),
2830
+ "changed_files": sorted(changed_files),
2831
+ "preflight": preflight,
2832
+ "finish": finish_payload,
2833
+ "index": index_result,
2834
+ "lint": lint_result,
2835
+ "audit": audit_result,
2836
+ "steps": steps,
2837
+ "rolled_back": False,
2838
+ "dry_run": dry_run,
2839
+ }
2840
+
2841
+
2842
+ def cmd_closeout(args: argparse.Namespace) -> dict[str, object]:
2843
+ repo_root = _find_repo_root(Path.cwd())
2844
+ payload = closeout_payload(
2845
+ repo_root,
2846
+ args.source,
2847
+ validations=args.validation or [],
2848
+ validation_command=args.validation_command,
2849
+ validation_result=args.validation_result,
2850
+ validation_note=args.validation_note,
2851
+ run_index=args.index,
2852
+ run_lint=args.lint,
2853
+ run_audit=args.audit,
2854
+ dry_run=args.dry_run,
2855
+ )
2856
+ if args.format == "json":
2857
+ print_payload(payload, args.format)
2858
+ else:
2859
+ status = "OK" if payload["ok"] else "FAILED"
2860
+ print(f"Closeout: {status} for {payload['source']}")
2861
+ print(f"- changed files: {len(payload['changed_files'])}")
2862
+ for rel_path in payload["changed_files"]:
2863
+ print(f" - {rel_path}")
2864
+ preflight = payload.get("preflight")
2865
+ if isinstance(preflight, dict) and preflight.get("issues"):
2866
+ print("- preflight issues:")
2867
+ for issue in preflight["issues"]:
2868
+ print(f" - {issue['code']}: {issue['message']} ({issue['path']})")
2869
+ if payload.get("lint") is not None:
2870
+ print(f"- lint ok: {payload['lint'].get('ok')}")
2871
+ if payload.get("audit") is not None:
2872
+ print(f"- audit issues: {payload['audit'].get('issue_count')}")
2873
+ return payload
2874
+
2875
+
1610
2876
  def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object]:
1611
2877
  repo_root = _find_repo_root(Path.cwd())
1612
- source_path = Path(args.source).resolve()
1613
- if not source_path.is_file():
1614
- raise SystemExit(f"Source not found: {source_path}")
2878
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], args.source)
1615
2879
  title = _extract_doc_title(source_path)
1616
2880
  ref, _ = _build_native_backlog_from_request(repo_root, source_path, title)
2881
+ planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
2882
+ if not args.dry_run:
2883
+ _ensure_new_doc_paths_available([planned_path])
1617
2884
  product_refs, architecture_refs = _create_native_companion_docs(
1618
2885
  repo_root,
1619
2886
  title,
@@ -1629,10 +2896,8 @@ def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object
1629
2896
  product_refs=product_refs,
1630
2897
  architecture_refs=architecture_refs,
1631
2898
  )
1632
- planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
1633
2899
  if not args.dry_run:
1634
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1635
- planned_path.write_text(content, encoding="utf-8")
2900
+ _write_new_doc(planned_path, content)
1636
2901
  _append_doc_section_bullets(source_path, "Backlog", [f"`{ref}`"], dry_run=False)
1637
2902
  payload = {
1638
2903
  "command": "promote",
@@ -1643,7 +2908,7 @@ def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object
1643
2908
  "dry_run": args.dry_run,
1644
2909
  }
1645
2910
  if args.format == "json":
1646
- print(json.dumps(payload, indent=2, sort_keys=True))
2911
+ print_payload(payload, args.format)
1647
2912
  else:
1648
2913
  print(f"Created backlog slice from request: {payload['created_path']}")
1649
2914
  return payload
@@ -1651,13 +2916,14 @@ def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object
1651
2916
 
1652
2917
  def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1653
2918
  repo_root = _find_repo_root(Path.cwd())
1654
- source_path = Path(args.source).resolve()
1655
- if not source_path.is_file():
1656
- raise SystemExit(f"Source not found: {source_path}")
2919
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["backlog"], args.source)
1657
2920
  title = _extract_doc_title(source_path)
1658
2921
  source_text = source_path.read_text(encoding="utf-8")
1659
2922
  request_refs = sorted(_extract_refs(_strip_mermaid_blocks(source_text), DOC_KINDS["request"].prefix))
1660
2923
  ref, _ = _build_native_task_from_backlog(repo_root, source_path, title)
2924
+ planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
2925
+ if not args.dry_run:
2926
+ _ensure_new_doc_paths_available([planned_path])
1661
2927
  product_refs, architecture_refs = _create_native_companion_docs(
1662
2928
  repo_root,
1663
2929
  title,
@@ -1674,10 +2940,8 @@ def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1674
2940
  product_refs=product_refs,
1675
2941
  architecture_refs=architecture_refs,
1676
2942
  )
1677
- planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
1678
2943
  if not args.dry_run:
1679
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1680
- planned_path.write_text(content, encoding="utf-8")
2944
+ _write_new_doc(planned_path, content)
1681
2945
  _append_doc_section_bullets(source_path, "Tasks", [f"`{ref}`"], dry_run=False)
1682
2946
  payload = {
1683
2947
  "command": "promote",
@@ -1688,7 +2952,7 @@ def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1688
2952
  "dry_run": args.dry_run,
1689
2953
  }
1690
2954
  if args.format == "json":
1691
- print(json.dumps(payload, indent=2, sort_keys=True))
2955
+ print_payload(payload, args.format)
1692
2956
  else:
1693
2957
  print(f"Created task from backlog: {payload['created_path']}")
1694
2958
  return payload
@@ -1696,9 +2960,7 @@ def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1696
2960
 
1697
2961
  def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1698
2962
  repo_root = _find_repo_root(Path.cwd())
1699
- source_path = Path(args.source).resolve()
1700
- if not source_path.is_file():
1701
- raise SystemExit(f"Source not found: {source_path}")
2963
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], args.source)
1702
2964
  titles = _split_titles([title for group in args.title for title in group])
1703
2965
  created_refs: list[str] = []
1704
2966
  for title in titles:
@@ -1707,6 +2969,9 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1707
2969
  source_path,
1708
2970
  title,
1709
2971
  )
2972
+ planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
2973
+ if not args.dry_run:
2974
+ _ensure_new_doc_paths_available([planned_path])
1710
2975
  product_refs, architecture_refs = _create_native_companion_docs(
1711
2976
  repo_root,
1712
2977
  title,
@@ -1722,10 +2987,8 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1722
2987
  product_refs=product_refs,
1723
2988
  architecture_refs=architecture_refs,
1724
2989
  )
1725
- planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
1726
2990
  if not args.dry_run:
1727
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1728
- planned_path.write_text(content, encoding="utf-8")
2991
+ _write_new_doc(planned_path, content)
1729
2992
  _append_doc_section_bullets(source_path, "Backlog", [f"`{ref}`"], dry_run=False)
1730
2993
  created_refs.append(ref)
1731
2994
  payload = {
@@ -1736,7 +2999,7 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1736
2999
  "dry_run": args.dry_run,
1737
3000
  }
1738
3001
  if args.format == "json":
1739
- print(json.dumps(payload, indent=2, sort_keys=True))
3002
+ print_payload(payload, args.format)
1740
3003
  else:
1741
3004
  print(f"Split request into {len(created_refs)} backlog item(s): {', '.join(created_refs)}")
1742
3005
  return payload
@@ -1744,15 +3007,16 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1744
3007
 
1745
3008
  def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1746
3009
  repo_root = _find_repo_root(Path.cwd())
1747
- source_path = Path(args.source).resolve()
1748
- if not source_path.is_file():
1749
- raise SystemExit(f"Source not found: {source_path}")
3010
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["backlog"], args.source)
1750
3011
  source_text = source_path.read_text(encoding="utf-8")
1751
3012
  request_refs = sorted(_extract_refs(_strip_mermaid_blocks(source_text), DOC_KINDS["request"].prefix))
1752
3013
  titles = _split_titles([title for group in args.title for title in group])
1753
3014
  created_refs: list[str] = []
1754
3015
  for title in titles:
1755
3016
  ref, _ = _build_native_task_from_backlog(repo_root, source_path, title)
3017
+ planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
3018
+ if not args.dry_run:
3019
+ _ensure_new_doc_paths_available([planned_path])
1756
3020
  product_refs, architecture_refs = _create_native_companion_docs(
1757
3021
  repo_root,
1758
3022
  title,
@@ -1769,10 +3033,8 @@ def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1769
3033
  product_refs=product_refs,
1770
3034
  architecture_refs=architecture_refs,
1771
3035
  )
1772
- planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
1773
3036
  if not args.dry_run:
1774
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1775
- planned_path.write_text(content, encoding="utf-8")
3037
+ _write_new_doc(planned_path, content)
1776
3038
  _append_doc_section_bullets(source_path, "Tasks", [f"`{ref}`"], dry_run=False)
1777
3039
  created_refs.append(ref)
1778
3040
  payload = {
@@ -1783,7 +3045,7 @@ def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1783
3045
  "dry_run": args.dry_run,
1784
3046
  }
1785
3047
  if args.format == "json":
1786
- print(json.dumps(payload, indent=2, sort_keys=True))
3048
+ print_payload(payload, args.format)
1787
3049
  else:
1788
3050
  print(f"Split backlog item into {len(created_refs)} task(s): {', '.join(created_refs)}")
1789
3051
  return payload
@@ -1792,14 +3054,9 @@ def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1792
3054
  def cmd_close(args: argparse.Namespace) -> dict[str, object]:
1793
3055
  repo_root = _find_repo_root(Path.cwd())
1794
3056
  kind = DOC_KINDS[args.kind]
1795
- source_path = Path(args.source).resolve()
1796
- if not source_path.is_file():
1797
- raise SystemExit(f"Source not found: {source_path}")
1798
- if not source_path.stem.startswith(f"{kind.prefix}_"):
1799
- raise SystemExit(f"Expected a `{kind.prefix}_...` file for kind `{kind.kind}`. Got: {source_path.name}")
3057
+ source_path = _resolve_workflow_source(repo_root, kind, args.source)
1800
3058
 
1801
- _close_chain_for_kind(repo_root, source_path, kind, dry_run=args.dry_run)
1802
- print(f"Closed {kind.kind}: {source_path.relative_to(repo_root)}")
3059
+ _close_chain_for_kind(repo_root, source_path, kind, dry_run=args.dry_run, quiet=args.format == "json")
1803
3060
 
1804
3061
  payload = {
1805
3062
  "command": "close",
@@ -1808,7 +3065,9 @@ def cmd_close(args: argparse.Namespace) -> dict[str, object]:
1808
3065
  "dry_run": args.dry_run,
1809
3066
  }
1810
3067
  if args.format == "json":
1811
- print(json.dumps(payload, indent=2, sort_keys=True))
3068
+ print_payload(payload, args.format)
3069
+ else:
3070
+ print(f"Closed {kind.kind}: {payload['source']}")
1812
3071
  return payload
1813
3072
 
1814
3073
 
@@ -1885,7 +3144,7 @@ def _record_finished_task_follow_up(repo_root: Path, task_path: Path, dry_run: b
1885
3144
  _append_section_bullets(task_path, "Report", report_bullets, dry_run)
1886
3145
 
1887
3146
 
1888
- def _maybe_close_request_chain(repo_root: Path, request_ref: str, dry_run: bool) -> None:
3147
+ def _maybe_close_request_chain(repo_root: Path, request_ref: str, dry_run: bool, *, quiet: bool = False) -> None:
1889
3148
  request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
1890
3149
  if request_path is None:
1891
3150
  return
@@ -1897,10 +3156,11 @@ def _maybe_close_request_chain(repo_root: Path, request_ref: str, dry_run: bool)
1897
3156
  if all(_is_doc_done(item_path, DOC_KINDS["backlog"]) for item_path in linked_items):
1898
3157
  if not _is_doc_done(request_path, DOC_KINDS["request"]):
1899
3158
  _close_doc(request_path, DOC_KINDS["request"], dry_run)
1900
- print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
3159
+ if not quiet:
3160
+ print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
1901
3161
 
1902
3162
 
1903
- def _close_chain_for_kind(repo_root: Path, source_path: Path, kind: DOC_KINDS, *, dry_run: bool) -> None:
3163
+ def _close_chain_for_kind(repo_root: Path, source_path: Path, kind: DOC_KINDS, *, dry_run: bool, quiet: bool = False) -> None:
1904
3164
  _close_doc(source_path, kind, dry_run)
1905
3165
 
1906
3166
  text = _strip_mermaid_blocks(source_path.read_text(encoding="utf-8"))
@@ -1919,40 +3179,37 @@ def _close_chain_for_kind(repo_root: Path, source_path: Path, kind: DOC_KINDS, *
1919
3179
  if linked_tasks and all(_is_doc_done(task_path, DOC_KINDS["task"]) for task_path in linked_tasks):
1920
3180
  if not _is_doc_done(item_path, DOC_KINDS["backlog"]):
1921
3181
  _close_doc(item_path, DOC_KINDS["backlog"], dry_run)
1922
- print(f"Auto-closed backlog item {item_ref} (all linked tasks are done).")
3182
+ if not quiet:
3183
+ print(f"Auto-closed backlog item {item_ref} (all linked tasks are done).")
1923
3184
 
1924
3185
  item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8"))
1925
3186
  for request_ref in sorted(_extract_refs(item_text, DOC_KINDS["request"].prefix)):
1926
3187
  if request_ref in processed_request_refs:
1927
3188
  continue
1928
3189
  processed_request_refs.add(request_ref)
1929
- _maybe_close_request_chain(repo_root, request_ref, dry_run)
3190
+ _maybe_close_request_chain(repo_root, request_ref, dry_run, quiet=quiet)
1930
3191
 
1931
3192
  if kind.kind == "backlog":
1932
3193
  for request_ref in sorted(_extract_refs(text, DOC_KINDS["request"].prefix)):
1933
3194
  if request_ref in processed_request_refs:
1934
3195
  continue
1935
3196
  processed_request_refs.add(request_ref)
1936
- _maybe_close_request_chain(repo_root, request_ref, dry_run)
3197
+ _maybe_close_request_chain(repo_root, request_ref, dry_run, quiet=quiet)
1937
3198
 
1938
3199
  if kind.kind == "request":
1939
- _maybe_close_request_chain(repo_root, source_path.stem, dry_run)
3200
+ _maybe_close_request_chain(repo_root, source_path.stem, dry_run, quiet=quiet)
1940
3201
 
1941
3202
 
1942
3203
  def cmd_finish_task(args: argparse.Namespace) -> dict[str, object]:
1943
3204
  repo_root = _find_repo_root(Path.cwd())
1944
- source_path = Path(args.source).resolve()
1945
- if not source_path.is_file():
1946
- raise SystemExit(f"Source not found: {source_path}")
1947
- if not source_path.stem.startswith(f"{DOC_KINDS['task'].prefix}_"):
1948
- raise SystemExit(f"Expected a `{DOC_KINDS['task'].prefix}_...` task file. Got: {source_path.name}")
3205
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], args.source)
1949
3206
 
1950
- _close_chain_for_kind(repo_root, source_path, DOC_KINDS["task"], dry_run=args.dry_run)
3207
+ _close_chain_for_kind(repo_root, source_path, DOC_KINDS["task"], dry_run=args.dry_run, quiet=args.format == "json")
1951
3208
 
1952
3209
  if args.dry_run:
1953
3210
  payload = {"command": "finish", "kind": "task", "source": source_path.relative_to(repo_root).as_posix(), "dry_run": True}
1954
3211
  if args.format == "json":
1955
- print(json.dumps(payload, indent=2, sort_keys=True))
3212
+ print_payload(payload, args.format)
1956
3213
  else:
1957
3214
  print("Dry run: skipped post-close verification.")
1958
3215
  return payload
@@ -1964,7 +3221,7 @@ def cmd_finish_task(args: argparse.Namespace) -> dict[str, object]:
1964
3221
 
1965
3222
  payload = {"command": "finish", "kind": "task", "source": source_path.relative_to(repo_root).as_posix(), "dry_run": False}
1966
3223
  if args.format == "json":
1967
- print(json.dumps(payload, indent=2, sort_keys=True))
3224
+ print_payload(payload, args.format)
1968
3225
  else:
1969
3226
  print(f"Finish verification: OK for {source_path.relative_to(repo_root)}")
1970
3227
  return payload
@@ -1989,6 +3246,21 @@ def main(argv: list[str]) -> int:
1989
3246
  if argv[0] == "companion" and len(argv) > 1 and argv[1] in {"product", "architecture"} and _help_requested(argv, 2):
1990
3247
  _print_help(_build_companion_kind_help(argv[1]))
1991
3248
  return 0
3249
+ if argv[0] == "deliver" and _help_requested(argv, 1):
3250
+ _print_help(_build_deliver_help())
3251
+ return 0
3252
+ if argv[0] == "validate-closeout" and _help_requested(argv, 1):
3253
+ _print_help(_build_validate_closeout_help())
3254
+ return 0
3255
+ if argv[0] == "repair" and _help_requested(argv, 1):
3256
+ _print_help(_build_repair_help())
3257
+ return 0
3258
+ if argv[0] == "repair" and len(argv) > 1 and argv[1] in {"gates", "ac-traceability", "links", "mermaid"} and _help_requested(argv, 2):
3259
+ _print_help(_build_repair_kind_help(argv[1]))
3260
+ return 0
3261
+ if argv[0] == "closeout" and _help_requested(argv, 1):
3262
+ _print_help(_build_closeout_help())
3263
+ return 0
1992
3264
  if argv[0] == "promote" and _help_requested(argv, 1):
1993
3265
  _print_help(_build_promote_help())
1994
3266
  return 0
@@ -2015,7 +3287,11 @@ def main(argv: list[str]) -> int:
2015
3287
  return 0
2016
3288
  parser = build_parser()
2017
3289
  args = parser.parse_args(argv)
2018
- if args.command not in {"new", "list", "companion", "promote", "split", "close", "finish"}:
3290
+ if args.command not in {"new", "list", "companion", "deliver", "validate-closeout", "repair", "closeout", "promote", "split", "close", "finish"}:
2019
3291
  raise SystemExit("Unsupported flow subcommand for the native CLI slice.")
2020
3292
  payload = args.func(args)
3293
+ if args.command == "validate-closeout" and isinstance(payload, dict) and not payload.get("ok", False):
3294
+ return 1
3295
+ if args.command == "closeout" and isinstance(payload, dict) and not payload.get("ok", False):
3296
+ return 1
2021
3297
  return 0 if isinstance(payload, dict) else 1