@grifhinz/logics-manager 2.1.2 → 2.2.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.
@@ -7,6 +7,11 @@ 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 .index import index_payload
13
+ from .lint import expected_workflow_mermaid_signature, lint_payload
14
+ from .path_utils import ensure_relative_to
10
15
  from .termstyle import colorize_help
11
16
 
12
17
 
@@ -202,6 +207,22 @@ def _build_help() -> str:
202
207
  " Create a companion doc from the integrated runtime.",
203
208
  " Flags: --title, --source-ref, --request-ref, --backlog-ref, --task-ref, --format {text,json}, --dry-run",
204
209
  "",
210
+ " deliver --from-product <source>",
211
+ " Create a linked request, backlog item, and task from a product brief.",
212
+ " Flags: --title, --finish, --format {text,json}, --dry-run",
213
+ "",
214
+ " validate-closeout <task>",
215
+ " Preflight whether a task can be safely closed.",
216
+ " Flags: --format {text,json}",
217
+ "",
218
+ " repair <gates|ac-traceability|links|mermaid>",
219
+ " Apply deterministic closeout repairs.",
220
+ " Flags: --format {text,json}, --dry-run",
221
+ "",
222
+ " closeout <task>",
223
+ " Append validation, repair deterministic gaps, finish, and optionally validate/index.",
224
+ " Flags: --validation, --index, --lint, --audit, --format {text,json}, --dry-run",
225
+ "",
205
226
  " promote request-to-backlog <source>",
206
227
  " Create a backlog slice from a request.",
207
228
  "",
@@ -226,6 +247,10 @@ def _build_help() -> str:
226
247
  "",
227
248
  "Examples:",
228
249
  ' logics-manager flow new request --title "My request"',
250
+ " logics-manager flow deliver --from-product prod_017_delivery_loop",
251
+ " logics-manager flow validate-closeout task_003_fix_docs",
252
+ " logics-manager flow repair gates task_003_fix_docs",
253
+ " logics-manager flow closeout task_003_fix_docs --validation \"pytest passed\" --index --lint --audit",
229
254
  " logics-manager flow promote request-to-backlog req_001_my_request",
230
255
  " logics-manager flow close task task_003_fix_docs --dry-run",
231
256
  ]
@@ -352,6 +377,122 @@ def _build_companion_kind_help(kind: str) -> str:
352
377
  )
353
378
 
354
379
 
380
+ def _build_deliver_help() -> str:
381
+ return "\n".join(
382
+ [
383
+ "Logics Flow Deliver",
384
+ "Create a delivery chain from a product brief.",
385
+ "",
386
+ "Usage:",
387
+ " logics-manager flow deliver --from-product <source> [args...]",
388
+ "",
389
+ "Flags:",
390
+ " --from-product <source>",
391
+ " --title",
392
+ " --finish",
393
+ " --format {text,json}",
394
+ " --dry-run",
395
+ "",
396
+ "Examples:",
397
+ " logics-manager flow deliver --from-product prod_017_logics_delivery_loop_ergonomics",
398
+ ' logics-manager flow deliver --from-product logics/product/prod_017_logics_delivery_loop_ergonomics.md --title "Implement flow deliver"',
399
+ ]
400
+ )
401
+
402
+
403
+ def _build_validate_closeout_help() -> str:
404
+ return "\n".join(
405
+ [
406
+ "Logics Flow Validate Closeout",
407
+ "Preflight whether a task can be safely closed.",
408
+ "",
409
+ "Usage:",
410
+ " logics-manager flow validate-closeout <task> [args...]",
411
+ "",
412
+ "Flags:",
413
+ " --format {text,json}",
414
+ "",
415
+ "Examples:",
416
+ " logics-manager flow validate-closeout task_164_implement_flow_deliver_from_product",
417
+ ]
418
+ )
419
+
420
+
421
+ def _build_repair_help() -> str:
422
+ return "\n".join(
423
+ [
424
+ "Logics Flow Repair",
425
+ "Apply deterministic closeout repairs.",
426
+ "",
427
+ "Usage:",
428
+ " logics-manager flow repair <gates|ac-traceability|links|mermaid> [args...]",
429
+ "",
430
+ "Commands:",
431
+ " gates <task>",
432
+ " Check task Plan/DoD and linked request DoR boxes.",
433
+ " ac-traceability <request>",
434
+ " Add missing request AC traceability entries to linked backlog/task docs.",
435
+ " links <task>",
436
+ " Ensure linked backlog/product docs reference the task chain.",
437
+ " mermaid --refs <refs...>",
438
+ " Insert or refresh workflow Mermaid signatures for selected docs.",
439
+ "",
440
+ "Flags:",
441
+ " --format {text,json}",
442
+ " --dry-run",
443
+ ]
444
+ )
445
+
446
+
447
+ def _build_repair_kind_help(kind: str) -> str:
448
+ examples = {
449
+ "gates": " logics-manager flow repair gates task_164_implement_flow_deliver_from_product",
450
+ "ac-traceability": " logics-manager flow repair ac-traceability req_199_implement_flow_deliver_from_product",
451
+ "links": " logics-manager flow repair links task_164_implement_flow_deliver_from_product",
452
+ "mermaid": " logics-manager flow repair mermaid --refs req_199 item_363 task_164",
453
+ }
454
+ usage = " logics-manager flow repair mermaid --refs <refs...> [args...]" if kind == "mermaid" else f" logics-manager flow repair {kind} <source> [args...]"
455
+ return "\n".join(
456
+ [
457
+ f"Logics Flow Repair {kind.title()}",
458
+ "Apply a deterministic closeout repair.",
459
+ "",
460
+ "Usage:",
461
+ usage,
462
+ "",
463
+ "Flags:",
464
+ " --format {text,json}",
465
+ " --dry-run",
466
+ "",
467
+ "Example:",
468
+ examples[kind],
469
+ ]
470
+ )
471
+
472
+
473
+ def _build_closeout_help() -> str:
474
+ return "\n".join(
475
+ [
476
+ "Logics Flow Closeout",
477
+ "Append validation, repair deterministic gaps, finish, and optionally validate/index.",
478
+ "",
479
+ "Usage:",
480
+ " logics-manager flow closeout <task> [args...]",
481
+ "",
482
+ "Flags:",
483
+ " --validation",
484
+ " --index",
485
+ " --lint",
486
+ " --audit",
487
+ " --format {text,json}",
488
+ " --dry-run",
489
+ "",
490
+ "Example:",
491
+ ' logics-manager flow closeout task_164 --validation "pytest passed" --index --lint --audit',
492
+ ]
493
+ )
494
+
495
+
355
496
  def _build_promote_help() -> str:
356
497
  return "\n".join(
357
498
  [
@@ -587,7 +728,8 @@ def _find_repo_root(start: Path) -> Path:
587
728
 
588
729
  def _plan_doc(repo_root: Path, directory: str, prefix: str, title: str, dry_run: bool = False) -> PlannedDoc:
589
730
  target_dir = repo_root / directory
590
- target_dir.mkdir(parents=True, exist_ok=True)
731
+ if not dry_run:
732
+ target_dir.mkdir(parents=True, exist_ok=True)
591
733
  slug = _slugify(title)
592
734
  highest = -1
593
735
  pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)_.*\.md$")
@@ -600,6 +742,22 @@ def _plan_doc(repo_root: Path, directory: str, prefix: str, title: str, dry_run:
600
742
  return PlannedDoc(ref=ref, path=path)
601
743
 
602
744
 
745
+ def _ensure_new_doc_paths_available(paths: list[Path]) -> None:
746
+ collisions = [path for path in paths if path.exists()]
747
+ if collisions:
748
+ rendered = ", ".join(path.as_posix() for path in collisions)
749
+ raise SystemExit(f"Ref collision while creating Logics doc(s): {rendered}. Re-run the command to allocate a fresh id.")
750
+
751
+
752
+ def _write_new_doc(path: Path, content: str) -> None:
753
+ path.parent.mkdir(parents=True, exist_ok=True)
754
+ try:
755
+ with path.open("x", encoding="utf-8") as handle:
756
+ handle.write(content)
757
+ except FileExistsError as exc:
758
+ raise SystemExit(f"Ref collision while creating Logics doc: {path.as_posix()}. Re-run the command to allocate a fresh id.") from exc
759
+
760
+
603
761
  def _extract_refs(text: str, prefix: str) -> list[str]:
604
762
  pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
605
763
  return sorted({match.group(0) for match in pattern.finditer(text)})
@@ -609,11 +767,107 @@ def _strip_mermaid_blocks(text: str) -> str:
609
767
  return re.sub(r"```mermaid\s*\n.*?\n```", "", text, flags=re.DOTALL)
610
768
 
611
769
 
770
+ def _workflow_mermaid_block(kind: str, signature: str) -> list[str]:
771
+ if kind == "request":
772
+ body = [
773
+ "flowchart TD",
774
+ " Need[Request need] --> Backlog[Backlog slice]",
775
+ " Backlog --> Task[Delivery task]",
776
+ ]
777
+ elif kind == "backlog":
778
+ body = [
779
+ "flowchart TD",
780
+ " Request[Request source] --> Scope[Backlog scope]",
781
+ " Scope --> Task[Delivery task]",
782
+ ]
783
+ else:
784
+ body = [
785
+ "flowchart TD",
786
+ " Backlog[Backlog item] --> Build[Implementation]",
787
+ " Build --> Validate[Validation]",
788
+ " Validate --> Close[Finish workflow]",
789
+ ]
790
+ return [
791
+ "```mermaid",
792
+ f"%% logics-kind: {kind}",
793
+ f"%% logics-signature: {signature}",
794
+ *body,
795
+ "```",
796
+ ]
797
+
798
+
799
+ def _with_workflow_mermaid_overview(kind: str, content: str) -> str:
800
+ lines = content.rstrip().splitlines()
801
+ signature = expected_workflow_mermaid_signature(kind, lines)
802
+ if not signature:
803
+ return content
804
+ block = _workflow_mermaid_block(kind, signature)
805
+ heading = {"request": "Context", "backlog": "Scope", "task": "Backlog"}[kind]
806
+ insert_at = len(lines)
807
+ for idx, line in enumerate(lines):
808
+ if line.startswith("# ") and line[2:].strip().lower() == heading.lower():
809
+ insert_at = idx + 1
810
+ while insert_at < len(lines) and not lines[insert_at].startswith("# "):
811
+ insert_at += 1
812
+ break
813
+ updated = [*lines[:insert_at], "", *block, "", *lines[insert_at:]]
814
+ return "\n".join(updated).rstrip() + "\n"
815
+
816
+
612
817
  def _resolve_doc_path(repo_root: Path, kind: DocKind, ref: str) -> Path | None:
613
818
  path = repo_root / kind.directory / f"{ref}.md"
614
819
  return path if path.is_file() else None
615
820
 
616
821
 
822
+ def _resolve_workflow_source(repo_root: Path, kind: DocKind, source: str) -> Path:
823
+ raw = Path(source)
824
+ if raw.is_absolute():
825
+ candidate = raw.resolve()
826
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
827
+ elif any(part == ".." for part in raw.parts):
828
+ raise SystemExit(f"Unsupported source `{source}`. Use a {kind.prefix}_... ref or repo-relative Logics path.")
829
+ elif len(raw.parts) == 1 and raw.suffix != ".md":
830
+ path = _resolve_doc_path(repo_root, kind, source)
831
+ if path is None:
832
+ raise SystemExit(f"Source not found: {source}")
833
+ return path
834
+ else:
835
+ candidate = (repo_root / raw).resolve()
836
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
837
+ expected_dir = Path(kind.directory)
838
+ if candidate.parent != (repo_root / kind.directory).resolve():
839
+ raise SystemExit(f"Expected source under `{kind.directory}`. Got: `{rel_path.as_posix()}`.")
840
+ if not candidate.is_file():
841
+ raise SystemExit(f"Source not found: {rel_path.as_posix()}")
842
+ if not candidate.stem.startswith(f"{kind.prefix}_"):
843
+ raise SystemExit(f"Expected a `{kind.prefix}_...` file for kind `{kind.kind}`. Got: {candidate.name}")
844
+ if rel_path.parent != expected_dir:
845
+ raise SystemExit(f"Expected source under `{kind.directory}`. Got: `{rel_path.as_posix()}`.")
846
+ return candidate
847
+
848
+
849
+ def _resolve_product_source(repo_root: Path, source: str) -> Path:
850
+ raw = Path(source)
851
+ if raw.is_absolute():
852
+ candidate = raw.resolve()
853
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
854
+ elif any(part == ".." for part in raw.parts):
855
+ raise SystemExit("Unsupported product source. Use a prod_... ref or repo-relative product path.")
856
+ elif len(raw.parts) == 1 and raw.suffix != ".md":
857
+ candidate = repo_root / "logics" / "product" / f"{source}.md"
858
+ rel_path = candidate.relative_to(repo_root)
859
+ else:
860
+ candidate = (repo_root / raw).resolve()
861
+ rel_path = ensure_relative_to(candidate, repo_root, label="source")
862
+ if candidate.parent != (repo_root / "logics" / "product").resolve():
863
+ raise SystemExit(f"Expected product source under `logics/product`. Got: `{rel_path.as_posix()}`.")
864
+ if not candidate.is_file():
865
+ raise SystemExit(f"Product source not found: {rel_path.as_posix()}")
866
+ if not candidate.stem.startswith("prod_"):
867
+ raise SystemExit(f"Expected a `prod_...` product brief. Got: {candidate.name}")
868
+ return candidate
869
+
870
+
617
871
  def _append_section_bullets(path: Path, heading: str, bullets: list[str], dry_run: bool) -> None:
618
872
  if dry_run:
619
873
  return
@@ -708,6 +962,437 @@ def _is_doc_done(path: Path, kind: DocKind) -> bool:
708
962
  return False
709
963
 
710
964
 
965
+ def _section_text(text: str, heading: str) -> str:
966
+ return "\n".join(_section_lines(text.splitlines(), heading)).strip()
967
+
968
+
969
+ def _section_has_unchecked_checkbox(text: str, heading: str) -> bool:
970
+ return any("- [ ]" in line for line in _section_lines(text.splitlines(), heading))
971
+
972
+
973
+ def _section_has_checked_checkbox(text: str, heading: str) -> bool:
974
+ return any("- [x]" in line.lower() for line in _section_lines(text.splitlines(), heading))
975
+
976
+
977
+ def _has_validation_evidence(text: str) -> bool:
978
+ for line in _section_lines(text.splitlines(), "Validation"):
979
+ stripped = line.strip()
980
+ if not stripped.startswith("- "):
981
+ continue
982
+ value = stripped[2:].strip().lower()
983
+ if not value or value.startswith("run `") or value.startswith("run the "):
984
+ continue
985
+ if any(marker in value for marker in ("pass", "ok", "validated", "verification", "regression")):
986
+ return True
987
+ return False
988
+
989
+
990
+ def _request_ac_ids(text: str) -> list[str]:
991
+ ids: list[str] = []
992
+ for line in _section_lines(text.splitlines(), "Acceptance criteria"):
993
+ match = re.search(r"\bAC(\d+)\s*:", line, flags=re.IGNORECASE)
994
+ if match:
995
+ ids.append(f"AC{int(match.group(1))}")
996
+ return ids
997
+
998
+
999
+ def _has_ac_proof(text: str, ac_id: str) -> bool:
1000
+ upper = text.upper()
1001
+ return ac_id.upper() in upper and "proof:" in text.lower()
1002
+
1003
+
1004
+ def _first_product_path(repo_root: Path, product_ref: str) -> Path | None:
1005
+ path = repo_root / "logics" / "product" / f"{product_ref}.md"
1006
+ return path if path.is_file() else None
1007
+
1008
+
1009
+ def _mermaid_closeout_issue(path: Path, kind: str) -> str | None:
1010
+ text = path.read_text(encoding="utf-8")
1011
+ match = re.search(r"```mermaid\s*\n(.*?)\n```", text, flags=re.DOTALL)
1012
+ if match is None:
1013
+ return "missing Mermaid overview block"
1014
+ signature_match = re.search(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", match.group(1), flags=re.MULTILINE)
1015
+ expected = expected_workflow_mermaid_signature(kind, text.splitlines())
1016
+ if signature_match is None:
1017
+ return "missing Mermaid context signature comment"
1018
+ if expected and signature_match.group(1).strip() != expected:
1019
+ return f"stale Mermaid signature, expected `{expected}`"
1020
+ return None
1021
+
1022
+
1023
+ def _closeout_issue(path: Path, code: str, message: str, repair_command: str | None = None) -> dict[str, str]:
1024
+ issue = {
1025
+ "path": path.as_posix(),
1026
+ "code": code,
1027
+ "message": message,
1028
+ }
1029
+ if repair_command:
1030
+ issue["repair_command"] = repair_command
1031
+ return issue
1032
+
1033
+
1034
+ def validate_closeout_payload(repo_root: Path, source: str) -> dict[str, object]:
1035
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
1036
+ task_ref = task_path.stem
1037
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
1038
+ raw_task_text = task_path.read_text(encoding="utf-8")
1039
+ issues: list[dict[str, str]] = []
1040
+ related_paths = [task_path.relative_to(repo_root).as_posix()]
1041
+
1042
+ for heading in ("Plan", "Definition of Done (DoD)"):
1043
+ if _section_has_unchecked_checkbox(task_text, heading):
1044
+ issues.append(
1045
+ _closeout_issue(
1046
+ task_path.relative_to(repo_root),
1047
+ "task_gate_unchecked",
1048
+ f"`# {heading}` contains unchecked items",
1049
+ f"python3 -m logics_manager flow repair gates {task_ref}",
1050
+ )
1051
+ )
1052
+ if not _section_has_checked_checkbox(task_text, "Definition of Done (DoD)"):
1053
+ issues.append(
1054
+ _closeout_issue(
1055
+ task_path.relative_to(repo_root),
1056
+ "task_missing_done_gate",
1057
+ "`# Definition of Done (DoD)` has no checked completion evidence",
1058
+ f"python3 -m logics_manager flow repair gates {task_ref}",
1059
+ )
1060
+ )
1061
+ if not _has_validation_evidence(task_text):
1062
+ issues.append(
1063
+ _closeout_issue(
1064
+ task_path.relative_to(repo_root),
1065
+ "validation_evidence_missing",
1066
+ "`# Validation` has no concrete passing validation evidence",
1067
+ f"python3 -m logics_manager flow closeout {task_ref} --validation \"... passed\"",
1068
+ )
1069
+ )
1070
+
1071
+ mermaid_issue = _mermaid_closeout_issue(task_path, "task")
1072
+ if mermaid_issue:
1073
+ issues.append(
1074
+ _closeout_issue(
1075
+ task_path.relative_to(repo_root),
1076
+ "mermaid_signature_stale",
1077
+ mermaid_issue,
1078
+ f"python3 -m logics_manager flow repair mermaid --refs {task_ref}",
1079
+ )
1080
+ )
1081
+
1082
+ item_refs = sorted(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
1083
+ if not item_refs:
1084
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "task_missing_backlog", "task has no linked backlog item reference"))
1085
+
1086
+ request_refs: set[str] = set(_extract_refs(task_text, DOC_KINDS["request"].prefix))
1087
+ item_paths: list[Path] = []
1088
+ for item_ref in item_refs:
1089
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
1090
+ if item_path is None:
1091
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "task_missing_backlog_target", f"task references missing backlog item `{item_ref}`"))
1092
+ continue
1093
+ item_paths.append(item_path)
1094
+ related_paths.append(item_path.relative_to(repo_root).as_posix())
1095
+ item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8"))
1096
+ request_refs.update(_extract_refs(item_text, DOC_KINDS["request"].prefix))
1097
+ if task_ref not in item_text:
1098
+ issues.append(
1099
+ _closeout_issue(
1100
+ item_path.relative_to(repo_root),
1101
+ "backlog_missing_task_link",
1102
+ f"backlog item does not link task `{task_ref}`",
1103
+ f"python3 -m logics_manager flow repair links {task_ref}",
1104
+ )
1105
+ )
1106
+ mermaid_issue = _mermaid_closeout_issue(item_path, "backlog")
1107
+ if mermaid_issue:
1108
+ issues.append(
1109
+ _closeout_issue(
1110
+ item_path.relative_to(repo_root),
1111
+ "mermaid_signature_stale",
1112
+ mermaid_issue,
1113
+ f"python3 -m logics_manager flow repair mermaid --refs {item_ref}",
1114
+ )
1115
+ )
1116
+
1117
+ for request_ref in sorted(request_refs):
1118
+ request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
1119
+ if request_path is None:
1120
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "missing_request_target", f"linked request `{request_ref}` is missing"))
1121
+ continue
1122
+ related_paths.append(request_path.relative_to(repo_root).as_posix())
1123
+ request_text = _strip_mermaid_blocks(request_path.read_text(encoding="utf-8"))
1124
+ if _section_has_unchecked_checkbox(request_text, "Definition of Ready (DoR)"):
1125
+ issues.append(
1126
+ _closeout_issue(
1127
+ request_path.relative_to(repo_root),
1128
+ "request_dor_unchecked",
1129
+ "`# Definition of Ready (DoR)` contains unchecked items",
1130
+ f"python3 -m logics_manager flow repair gates {task_ref}",
1131
+ )
1132
+ )
1133
+ mermaid_issue = _mermaid_closeout_issue(request_path, "request")
1134
+ if mermaid_issue:
1135
+ issues.append(
1136
+ _closeout_issue(
1137
+ request_path.relative_to(repo_root),
1138
+ "mermaid_signature_stale",
1139
+ mermaid_issue,
1140
+ f"python3 -m logics_manager flow repair mermaid --refs {request_ref}",
1141
+ )
1142
+ )
1143
+ for ac_id in _request_ac_ids(request_text):
1144
+ if item_paths and not any(_has_ac_proof(path.read_text(encoding="utf-8"), ac_id) for path in item_paths):
1145
+ issues.append(
1146
+ _closeout_issue(
1147
+ request_path.relative_to(repo_root),
1148
+ "ac_missing_item_traceability",
1149
+ f"`{ac_id}` missing backlog-level proof",
1150
+ f"python3 -m logics_manager flow repair ac-traceability {request_ref}",
1151
+ )
1152
+ )
1153
+ if not _has_ac_proof(raw_task_text, ac_id):
1154
+ issues.append(
1155
+ _closeout_issue(
1156
+ request_path.relative_to(repo_root),
1157
+ "ac_missing_task_traceability",
1158
+ f"`{ac_id}` missing task-level proof",
1159
+ f"python3 -m logics_manager flow repair ac-traceability {request_ref}",
1160
+ )
1161
+ )
1162
+
1163
+ product_refs = sorted(_extract_refs(raw_task_text, "prod"))
1164
+ for product_ref in product_refs:
1165
+ product_path = _first_product_path(repo_root, product_ref)
1166
+ if product_path is None:
1167
+ issues.append(_closeout_issue(task_path.relative_to(repo_root), "missing_product_target", f"linked product brief `{product_ref}` is missing"))
1168
+ continue
1169
+ related_paths.append(product_path.relative_to(repo_root).as_posix())
1170
+ product_text = product_path.read_text(encoding="utf-8")
1171
+ if task_ref not in product_text:
1172
+ issues.append(
1173
+ _closeout_issue(
1174
+ product_path.relative_to(repo_root),
1175
+ "companion_link_missing",
1176
+ f"product brief does not link task `{task_ref}`",
1177
+ f"python3 -m logics_manager flow repair links {task_ref}",
1178
+ )
1179
+ )
1180
+
1181
+ unique_issues = []
1182
+ seen_issue_keys: set[tuple[str, str, str]] = set()
1183
+ for issue in issues:
1184
+ key = (issue["path"], issue["code"], issue["message"])
1185
+ if key in seen_issue_keys:
1186
+ continue
1187
+ seen_issue_keys.add(key)
1188
+ unique_issues.append(issue)
1189
+
1190
+ return {
1191
+ "command": "validate-closeout",
1192
+ "ok": not unique_issues,
1193
+ "source": task_path.relative_to(repo_root).as_posix(),
1194
+ "task_ref": task_ref,
1195
+ "issue_count": len(unique_issues),
1196
+ "issues": unique_issues,
1197
+ "related_paths": sorted(set(related_paths)),
1198
+ }
1199
+
1200
+
1201
+ def _changed_rel(repo_root: Path, changed_paths: set[Path], path: Path, before: str | None) -> None:
1202
+ if before is not None and path.read_text(encoding="utf-8") != before:
1203
+ changed_paths.add(path.relative_to(repo_root))
1204
+
1205
+
1206
+ def repair_gates_payload(repo_root: Path, source: str, *, dry_run: bool) -> dict[str, object]:
1207
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
1208
+ changed_paths: set[Path] = set()
1209
+ planned_paths: set[Path] = set()
1210
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
1211
+ item_refs = sorted(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
1212
+ request_refs: set[str] = set(_extract_refs(task_text, DOC_KINDS["request"].prefix))
1213
+
1214
+ before = task_path.read_text(encoding="utf-8")
1215
+ if _section_has_unchecked_checkbox(before, "Plan") or _section_has_unchecked_checkbox(before, "Definition of Done (DoD)"):
1216
+ planned_paths.add(task_path.relative_to(repo_root))
1217
+ _mark_section_checkboxes_done(task_path, "Plan", dry_run)
1218
+ _mark_section_checkboxes_done(task_path, "Definition of Done (DoD)", dry_run)
1219
+ if not dry_run:
1220
+ _changed_rel(repo_root, changed_paths, task_path, before)
1221
+
1222
+ for item_ref in item_refs:
1223
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
1224
+ if item_path is None:
1225
+ continue
1226
+ request_refs.update(_extract_refs(_strip_mermaid_blocks(item_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
1227
+
1228
+ for request_ref in sorted(request_refs):
1229
+ request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
1230
+ if request_path is None:
1231
+ continue
1232
+ before = request_path.read_text(encoding="utf-8")
1233
+ if _section_has_unchecked_checkbox(before, "Definition of Ready (DoR)"):
1234
+ planned_paths.add(request_path.relative_to(repo_root))
1235
+ _mark_section_checkboxes_done(request_path, "Definition of Ready (DoR)", dry_run)
1236
+ if not dry_run:
1237
+ _changed_rel(repo_root, changed_paths, request_path, before)
1238
+
1239
+ return {
1240
+ "command": "repair",
1241
+ "kind": "gates",
1242
+ "source": task_path.relative_to(repo_root).as_posix(),
1243
+ "changed_files": sorted(path.as_posix() for path in (planned_paths if dry_run else changed_paths)),
1244
+ "dry_run": dry_run,
1245
+ }
1246
+
1247
+
1248
+ def _request_ac_entries(request_path: Path) -> list[tuple[str, str]]:
1249
+ entries: list[tuple[str, str]] = []
1250
+ for line in _section_lines(request_path.read_text(encoding="utf-8").splitlines(), "Acceptance criteria"):
1251
+ match = re.search(r"\bAC(\d+)\s*:\s*(.+)", line, flags=re.IGNORECASE)
1252
+ if match:
1253
+ entries.append((f"AC{int(match.group(1))}", match.group(2).strip()))
1254
+ return entries
1255
+
1256
+
1257
+ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: bool) -> dict[str, object]:
1258
+ request_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], source)
1259
+ request_ref = request_path.stem
1260
+ ac_entries = _request_ac_entries(request_path)
1261
+ changed_paths: set[Path] = set()
1262
+ linked_items = _collect_docs_linking_ref(repo_root, DOC_KINDS["backlog"], request_ref)
1263
+ linked_task_paths = {
1264
+ path
1265
+ for path in _collect_docs_linking_ref(repo_root, DOC_KINDS["task"], request_ref)
1266
+ }
1267
+
1268
+ for item_path in linked_items:
1269
+ item_before = item_path.read_text(encoding="utf-8")
1270
+ item_missing = [
1271
+ f"request-{ac_id} -> This backlog slice. Proof: {text}"
1272
+ for ac_id, text in ac_entries
1273
+ if not _has_ac_proof(item_before, ac_id)
1274
+ ]
1275
+ if _append_doc_section_bullets_changed(item_path, "AC Traceability", item_missing, dry_run=dry_run):
1276
+ changed_paths.add(item_path.relative_to(repo_root))
1277
+
1278
+ item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8") if not dry_run else item_before)
1279
+ for task_ref in sorted(_extract_refs(item_text, DOC_KINDS["task"].prefix)):
1280
+ task_path = _resolve_doc_path(repo_root, DOC_KINDS["task"], task_ref)
1281
+ if task_path is None:
1282
+ continue
1283
+ linked_task_paths.add(task_path)
1284
+
1285
+ for task_path in sorted(linked_task_paths):
1286
+ task_before = task_path.read_text(encoding="utf-8")
1287
+ task_missing = [
1288
+ f"request-{ac_id} -> This task. Proof: {text}"
1289
+ for ac_id, text in ac_entries
1290
+ if not _has_ac_proof(task_before, ac_id)
1291
+ ]
1292
+ if _append_doc_section_bullets_changed(task_path, "AC Traceability", task_missing, dry_run=dry_run):
1293
+ changed_paths.add(task_path.relative_to(repo_root))
1294
+
1295
+ return {
1296
+ "command": "repair",
1297
+ "kind": "ac-traceability",
1298
+ "source": request_path.relative_to(repo_root).as_posix(),
1299
+ "changed_files": sorted(path.as_posix() for path in changed_paths),
1300
+ "dry_run": dry_run,
1301
+ }
1302
+
1303
+
1304
+ def repair_links_payload(repo_root: Path, source: str, *, dry_run: bool) -> dict[str, object]:
1305
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
1306
+ task_ref = task_path.stem
1307
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
1308
+ item_refs = sorted(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
1309
+ request_refs = sorted(_extract_refs(task_text, DOC_KINDS["request"].prefix))
1310
+ product_refs = sorted(_extract_refs(task_path.read_text(encoding="utf-8"), "prod"))
1311
+ changed_paths: set[Path] = set()
1312
+
1313
+ for item_ref in item_refs:
1314
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
1315
+ if item_path is None:
1316
+ continue
1317
+ if _append_doc_section_bullets_changed(item_path, "Tasks", [f"`{task_ref}`"], dry_run=dry_run):
1318
+ changed_paths.add(item_path.relative_to(repo_root))
1319
+ before = item_path.read_text(encoding="utf-8")
1320
+ lines = before.splitlines()
1321
+ lines = _replace_or_append_prefixed_section_bullet(lines, "Links", "Primary task(s)", f"`{task_ref}`")
1322
+ if request_refs:
1323
+ lines = _replace_or_append_prefixed_section_bullet(lines, "Links", "Request", f"`{request_refs[0]}`")
1324
+ after = "\n".join(lines).rstrip() + "\n"
1325
+ if after != before:
1326
+ changed_paths.add(item_path.relative_to(repo_root))
1327
+ if not dry_run:
1328
+ item_path.write_text(after, encoding="utf-8")
1329
+
1330
+ for product_ref in product_refs:
1331
+ product_path = _first_product_path(repo_root, product_ref)
1332
+ if product_path is None:
1333
+ continue
1334
+ before = product_path.read_text(encoding="utf-8")
1335
+ backlog_ref = item_refs[0] if item_refs else None
1336
+ request_ref = request_refs[0] if request_refs else None
1337
+ lines = before.splitlines()
1338
+ if request_ref:
1339
+ lines = _replace_indicator_line(lines, "Related request", f"`{request_ref}`")
1340
+ if backlog_ref:
1341
+ lines = _replace_indicator_line(lines, "Related backlog", f"`{backlog_ref}`")
1342
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Product back-reference", f"`{backlog_ref}`")
1343
+ lines = _replace_indicator_line(lines, "Related task", f"`{task_ref}`")
1344
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Task back-reference", f"`{task_ref}`")
1345
+ after = "\n".join(lines).rstrip() + "\n"
1346
+ if after != before:
1347
+ changed_paths.add(product_path.relative_to(repo_root))
1348
+ if not dry_run:
1349
+ product_path.write_text(after, encoding="utf-8")
1350
+
1351
+ return {
1352
+ "command": "repair",
1353
+ "kind": "links",
1354
+ "source": task_path.relative_to(repo_root).as_posix(),
1355
+ "changed_files": sorted(path.as_posix() for path in changed_paths),
1356
+ "dry_run": dry_run,
1357
+ }
1358
+
1359
+
1360
+ def _resolve_any_workflow_source(repo_root: Path, source: str) -> tuple[Path, str]:
1361
+ for kind in ("request", "backlog", "task"):
1362
+ try:
1363
+ return _resolve_workflow_source(repo_root, DOC_KINDS[kind], source), kind
1364
+ except SystemExit:
1365
+ continue
1366
+ raise SystemExit(f"Workflow source not found: {source}")
1367
+
1368
+
1369
+ def repair_mermaid_payload(repo_root: Path, refs: list[str], *, dry_run: bool) -> dict[str, object]:
1370
+ changed_paths: set[Path] = set()
1371
+ for ref in refs:
1372
+ path, kind = _resolve_any_workflow_source(repo_root, ref)
1373
+ before = path.read_text(encoding="utf-8")
1374
+ if "```mermaid" not in before:
1375
+ repaired = _with_workflow_mermaid_overview(kind, before)
1376
+ if repaired != before:
1377
+ changed_paths.add(path.relative_to(repo_root))
1378
+ if not dry_run:
1379
+ path.write_text(repaired, encoding="utf-8")
1380
+ else:
1381
+ signature = expected_workflow_mermaid_signature(kind, before.splitlines())
1382
+ repaired = re.sub(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", f"%% logics-signature: {signature}", before, count=1, flags=re.MULTILINE)
1383
+ if repaired != before:
1384
+ changed_paths.add(path.relative_to(repo_root))
1385
+ if not dry_run:
1386
+ path.write_text(repaired, encoding="utf-8")
1387
+ return {
1388
+ "command": "repair",
1389
+ "kind": "mermaid",
1390
+ "refs": refs,
1391
+ "changed_files": sorted(path.as_posix() for path in changed_paths),
1392
+ "dry_run": dry_run,
1393
+ }
1394
+
1395
+
711
1396
  def _add_common_doc_args(parser: argparse.ArgumentParser, kind: str) -> None:
712
1397
  parser.add_argument("--from-version")
713
1398
  parser.add_argument("--understanding", default="90%")
@@ -740,7 +1425,7 @@ def _build_native_request_doc(repo_root: Path, planned_ref: str, title: str, arg
740
1425
  "`logics_manager/assist.py`",
741
1426
  "`python_tests/test_logics_manager_cli.py`",
742
1427
  ]
743
- return "\n".join(
1428
+ content = "\n".join(
744
1429
  [
745
1430
  f"## {planned_ref} - {title}",
746
1431
  f"> From version: {from_version}",
@@ -787,6 +1472,7 @@ def _build_native_request_doc(repo_root: Path, planned_ref: str, title: str, arg
787
1472
  "",
788
1473
  ]
789
1474
  ).rstrip() + "\n"
1475
+ return _with_workflow_mermaid_overview("request", content)
790
1476
 
791
1477
 
792
1478
  def _build_native_backlog_doc(
@@ -809,7 +1495,7 @@ def _build_native_backlog_doc(
809
1495
  f"AC1: The backlog slice stays bounded for {title.lower()}.",
810
1496
  "AC2: The backlog slice is reviewable and promotable into a task.",
811
1497
  ]
812
- return "\n".join(
1498
+ content = "\n".join(
813
1499
  [
814
1500
  f"## {planned_ref} - {title}",
815
1501
  f"> From version: {from_version}",
@@ -837,6 +1523,7 @@ def _build_native_backlog_doc(
837
1523
  "# AC Traceability",
838
1524
  "- request-AC1 -> This backlog slice. Proof: bounded delivery slice.",
839
1525
  "- request-AC2 -> This backlog slice. Proof: promotable backlog item.",
1526
+ "- request-AC3 -> This backlog slice. Proof: delivery chain includes a task-ready backlog item.",
840
1527
  "",
841
1528
  "# Decision framing",
842
1529
  "- Product framing: Not needed",
@@ -863,6 +1550,7 @@ def _build_native_backlog_doc(
863
1550
  "",
864
1551
  ]
865
1552
  ).rstrip() + "\n"
1553
+ return _with_workflow_mermaid_overview("backlog", content)
866
1554
 
867
1555
 
868
1556
  def _build_native_task_doc(
@@ -884,7 +1572,7 @@ def _build_native_task_doc(
884
1572
  request_line = ", ".join(f"`{ref}`" for ref in request_refs) if request_refs else "(none yet)"
885
1573
  product_line = ", ".join(f"`{ref}`" for ref in product_refs) if product_refs else "(none yet)"
886
1574
  architecture_line = ", ".join(f"`{ref}`" for ref in architecture_refs) if architecture_refs else "(none yet)"
887
- return "\n".join(
1575
+ content = "\n".join(
888
1576
  [
889
1577
  f"## {planned_ref} - {title}",
890
1578
  f"> From version: {from_version}",
@@ -914,6 +1602,13 @@ def _build_native_task_doc(
914
1602
  "- [ ] Validation passes.",
915
1603
  "- [ ] Linked docs are synchronized.",
916
1604
  "",
1605
+ "# AC Traceability",
1606
+ "- request-AC1 -> This task. Proof: implementation delivers the bounded request need.",
1607
+ "- request-AC2 -> This task. Proof: implementation scope is limited to the linked delivery slice.",
1608
+ "- request-AC3 -> This task. Proof: implementation is executable from the promoted backlog item.",
1609
+ "- backlog-AC1 -> This task. Proof: task remains bounded to the linked backlog scope.",
1610
+ "- backlog-AC2 -> This task. Proof: task provides the executable implementation surface.",
1611
+ "",
917
1612
  "# Validation",
918
1613
  "- Run `python3 -m logics_manager lint --require-status`.",
919
1614
  "- Run the task-specific automated tests.",
@@ -934,6 +1629,7 @@ def _build_native_task_doc(
934
1629
  "",
935
1630
  ]
936
1631
  ).rstrip() + "\n"
1632
+ return _with_workflow_mermaid_overview("task", content)
937
1633
 
938
1634
 
939
1635
  def _extract_doc_title(path: Path) -> str:
@@ -1048,6 +1744,109 @@ def _append_doc_section_bullets(path: Path, heading: str, bullets: list[str], *,
1048
1744
  path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
1049
1745
 
1050
1746
 
1747
+ def _append_doc_section_bullets_changed(path: Path, heading: str, bullets: list[str], *, dry_run: bool) -> bool:
1748
+ if not bullets:
1749
+ return False
1750
+ before = path.read_text(encoding="utf-8") if path.is_file() else ""
1751
+ _append_doc_section_bullets(path, heading, bullets, dry_run=dry_run)
1752
+ if dry_run:
1753
+ return any(f"- {bullet}" not in before for bullet in bullets)
1754
+ return path.read_text(encoding="utf-8") != before
1755
+
1756
+
1757
+ def _remove_section_placeholder_bullets(path: Path, heading: str, placeholders: set[str], *, dry_run: bool) -> bool:
1758
+ lines = path.read_text(encoding="utf-8").splitlines()
1759
+ target = heading.strip().lower()
1760
+ in_section = False
1761
+ changed = False
1762
+ output: list[str] = []
1763
+ for line in lines:
1764
+ if line.startswith("# "):
1765
+ in_section = line[2:].strip().lower() == target
1766
+ output.append(line)
1767
+ continue
1768
+ if in_section and line.strip().lower() in placeholders:
1769
+ changed = True
1770
+ continue
1771
+ output.append(line)
1772
+ if changed and not dry_run:
1773
+ path.write_text("\n".join(output).rstrip() + "\n", encoding="utf-8")
1774
+ return changed
1775
+
1776
+
1777
+ def _replace_indicator_line(lines: list[str], label: str, value: str) -> list[str]:
1778
+ prefix = f"> {label}:"
1779
+ updated = False
1780
+ output: list[str] = []
1781
+ insert_at = 1
1782
+ for idx, line in enumerate(lines):
1783
+ if idx > 0 and line.startswith("> "):
1784
+ insert_at = idx + 1
1785
+ if line.startswith(prefix):
1786
+ output.append(f"{prefix} {value}")
1787
+ updated = True
1788
+ else:
1789
+ output.append(line)
1790
+ if not updated:
1791
+ output.insert(insert_at, f"{prefix} {value}")
1792
+ return output
1793
+
1794
+
1795
+ def _replace_or_append_prefixed_section_bullet(
1796
+ lines: list[str],
1797
+ heading: str,
1798
+ bullet_prefix: str,
1799
+ rendered_value: str,
1800
+ ) -> list[str]:
1801
+ heading_idx = None
1802
+ for idx, line in enumerate(lines):
1803
+ if line.startswith("# ") and line[2:].strip().lower() == heading.strip().lower():
1804
+ heading_idx = idx
1805
+ break
1806
+ rendered = f"- {bullet_prefix}: {rendered_value}"
1807
+ if heading_idx is None:
1808
+ return [*lines, "", f"# {heading}", rendered]
1809
+
1810
+ end_idx = heading_idx + 1
1811
+ while end_idx < len(lines) and not lines[end_idx].startswith("# "):
1812
+ end_idx += 1
1813
+
1814
+ output = list(lines)
1815
+ for idx in range(heading_idx + 1, end_idx):
1816
+ if output[idx].strip().startswith(f"- {bullet_prefix}:"):
1817
+ output[idx] = rendered
1818
+ return output
1819
+ output.insert(end_idx, rendered)
1820
+ return output
1821
+
1822
+
1823
+ def _update_product_delivery_links(
1824
+ product_path: Path,
1825
+ *,
1826
+ request_ref: str,
1827
+ backlog_ref: str,
1828
+ task_ref: str,
1829
+ dry_run: bool,
1830
+ ) -> None:
1831
+ if dry_run:
1832
+ return
1833
+ lines = product_path.read_text(encoding="utf-8").splitlines()
1834
+ lines = _replace_indicator_line(lines, "Related request", f"`{request_ref}`")
1835
+ lines = _replace_indicator_line(lines, "Related backlog", f"`{backlog_ref}`")
1836
+ lines = _replace_indicator_line(lines, "Related task", f"`{task_ref}`")
1837
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Product back-reference", f"`{backlog_ref}`")
1838
+ lines = _replace_or_append_prefixed_section_bullet(lines, "References", "Task back-reference", f"`{task_ref}`")
1839
+ product_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
1840
+
1841
+
1842
+ def _update_request_product_link(request_path: Path, product_ref: str, *, dry_run: bool) -> None:
1843
+ if dry_run:
1844
+ return
1845
+ lines = request_path.read_text(encoding="utf-8").splitlines()
1846
+ lines = _replace_or_append_prefixed_section_bullet(lines, "Companion docs", "Product brief(s)", f"`{product_ref}`")
1847
+ request_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
1848
+
1849
+
1051
1850
  def _build_native_product_brief(
1052
1851
  repo_root: Path,
1053
1852
  title: str,
@@ -1174,8 +1973,7 @@ def _create_native_companion_docs(
1174
1973
  )
1175
1974
  adr_path = repo_root / "logics" / "architecture" / f"{adr_ref}.md"
1176
1975
  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")
1976
+ _write_new_doc(adr_path, adr_content)
1179
1977
  created_architecture_refs.append(adr_ref)
1180
1978
 
1181
1979
  if getattr(args, "auto_create_product_brief", False):
@@ -1189,8 +1987,7 @@ def _create_native_companion_docs(
1189
1987
  )
1190
1988
  product_path = repo_root / "logics" / "product" / f"{product_ref}.md"
1191
1989
  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")
1990
+ _write_new_doc(product_path, product_content)
1194
1991
  created_product_refs.append(product_ref)
1195
1992
 
1196
1993
  return created_product_refs, created_architecture_refs
@@ -1307,7 +2104,7 @@ def _build_native_backlog_from_request(
1307
2104
  "",
1308
2105
  ]
1309
2106
  ).rstrip() + "\n"
1310
- return ref, content
2107
+ return ref, _with_workflow_mermaid_overview("backlog", content)
1311
2108
 
1312
2109
 
1313
2110
  def _build_native_task_from_backlog(
@@ -1380,7 +2177,7 @@ def _build_native_task_from_backlog(
1380
2177
  "",
1381
2178
  ]
1382
2179
  ).rstrip() + "\n"
1383
- return ref, content
2180
+ return ref, _with_workflow_mermaid_overview("task", content)
1384
2181
 
1385
2182
 
1386
2183
  def build_parser() -> argparse.ArgumentParser:
@@ -1420,6 +2217,56 @@ def build_parser() -> argparse.ArgumentParser:
1420
2217
  kind_parser.add_argument("--dry-run", action="store_true")
1421
2218
  kind_parser.set_defaults(func=cmd_companion)
1422
2219
 
2220
+ deliver_parser = sub.add_parser("deliver", help="Create a delivery chain from a product brief.")
2221
+ deliver_parser.add_argument("--from-product", required=True)
2222
+ deliver_parser.add_argument("--title")
2223
+ deliver_parser.add_argument("--finish", action="store_true")
2224
+ deliver_parser.add_argument("--format", choices=("text", "json"), default="text")
2225
+ deliver_parser.add_argument("--dry-run", action="store_true")
2226
+ deliver_parser.set_defaults(func=cmd_deliver)
2227
+
2228
+ validate_closeout_parser = sub.add_parser("validate-closeout", help="Preflight whether a task can be safely closed.")
2229
+ validate_closeout_parser.add_argument("source")
2230
+ validate_closeout_parser.add_argument("--format", choices=("text", "json"), default="text")
2231
+ validate_closeout_parser.set_defaults(func=cmd_validate_closeout)
2232
+
2233
+ repair_parser = sub.add_parser("repair", help="Apply deterministic closeout repairs.")
2234
+ repair_sub = repair_parser.add_subparsers(dest="repair_kind", required=True)
2235
+
2236
+ repair_gates = repair_sub.add_parser("gates", help="Check task and linked request gate checkboxes.")
2237
+ repair_gates.add_argument("source")
2238
+ repair_gates.add_argument("--format", choices=("text", "json"), default="text")
2239
+ repair_gates.add_argument("--dry-run", action="store_true")
2240
+ repair_gates.set_defaults(func=cmd_repair_gates)
2241
+
2242
+ repair_ac = repair_sub.add_parser("ac-traceability", help="Add missing AC traceability entries.")
2243
+ repair_ac.add_argument("source")
2244
+ repair_ac.add_argument("--format", choices=("text", "json"), default="text")
2245
+ repair_ac.add_argument("--dry-run", action="store_true")
2246
+ repair_ac.set_defaults(func=cmd_repair_ac_traceability)
2247
+
2248
+ repair_links = repair_sub.add_parser("links", help="Repair linked backlog/product references for a task.")
2249
+ repair_links.add_argument("source")
2250
+ repair_links.add_argument("--format", choices=("text", "json"), default="text")
2251
+ repair_links.add_argument("--dry-run", action="store_true")
2252
+ repair_links.set_defaults(func=cmd_repair_links)
2253
+
2254
+ repair_mermaid = repair_sub.add_parser("mermaid", help="Insert or refresh workflow Mermaid signatures.")
2255
+ repair_mermaid.add_argument("--refs", nargs="+", required=True)
2256
+ repair_mermaid.add_argument("--format", choices=("text", "json"), default="text")
2257
+ repair_mermaid.add_argument("--dry-run", action="store_true")
2258
+ repair_mermaid.set_defaults(func=cmd_repair_mermaid)
2259
+
2260
+ closeout_parser = sub.add_parser("closeout", help="Append validation, repair deterministic gaps, finish, and optionally validate/index.")
2261
+ closeout_parser.add_argument("source")
2262
+ closeout_parser.add_argument("--validation", action="append", default=[])
2263
+ closeout_parser.add_argument("--index", action="store_true")
2264
+ closeout_parser.add_argument("--lint", action="store_true")
2265
+ closeout_parser.add_argument("--audit", action="store_true")
2266
+ closeout_parser.add_argument("--format", choices=("text", "json"), default="text")
2267
+ closeout_parser.add_argument("--dry-run", action="store_true")
2268
+ closeout_parser.set_defaults(func=cmd_closeout)
2269
+
1423
2270
  promote_parser = sub.add_parser("promote", help="Promote between Logics stages.")
1424
2271
  promote_sub = promote_parser.add_subparsers(dest="promotion", required=True)
1425
2272
 
@@ -1482,16 +2329,22 @@ def cmd_new(args: argparse.Namespace) -> dict[str, object]:
1482
2329
  if doc_kind.kind == "request":
1483
2330
  content = _build_native_request_doc(repo_root, planned.ref, args.title, args)
1484
2331
  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}")
2332
+ _write_new_doc(planned.path, content)
2333
+ if args.format != "json":
2334
+ print(f"Wrote {planned.path}")
1488
2335
  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']}")
2336
+ if args.format != "json":
2337
+ preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
2338
+ print(f"[dry-run] would write: {planned.path}")
2339
+ print(preview)
2340
+ if args.format == "json":
2341
+ print_payload(payload, args.format)
2342
+ else:
2343
+ print(f"Created {doc_kind.kind}: {payload['path']}")
1493
2344
  return payload
1494
2345
  if doc_kind.kind == "backlog":
2346
+ if not args.dry_run:
2347
+ _ensure_new_doc_paths_available([planned.path])
1495
2348
  product_refs, architecture_refs = _create_native_companion_docs(
1496
2349
  repo_root,
1497
2350
  args.title,
@@ -1510,6 +2363,8 @@ def cmd_new(args: argparse.Namespace) -> dict[str, object]:
1510
2363
  architecture_refs=architecture_refs,
1511
2364
  )
1512
2365
  elif doc_kind.kind == "task":
2366
+ if not args.dry_run:
2367
+ _ensure_new_doc_paths_available([planned.path])
1513
2368
  product_refs, architecture_refs = _create_native_companion_docs(
1514
2369
  repo_root,
1515
2370
  args.title,
@@ -1532,15 +2387,19 @@ def cmd_new(args: argparse.Namespace) -> dict[str, object]:
1532
2387
  raise SystemExit(f"Unsupported doc kind `{doc_kind.kind}` for native creation.")
1533
2388
 
1534
2389
  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}")
2390
+ _write_new_doc(planned.path, content)
2391
+ if args.format != "json":
2392
+ print(f"Wrote {planned.path}")
1538
2393
  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)
2394
+ if args.format != "json":
2395
+ preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
2396
+ print(f"[dry-run] would write: {planned.path}")
2397
+ print(preview)
1542
2398
 
1543
- print(f"Created {doc_kind.kind}: {payload['path']}")
2399
+ if args.format == "json":
2400
+ print_payload(payload, args.format)
2401
+ else:
2402
+ print(f"Created {doc_kind.kind}: {payload['path']}")
1544
2403
  return payload
1545
2404
 
1546
2405
 
@@ -1582,13 +2441,14 @@ def cmd_companion(args: argparse.Namespace) -> dict[str, object]:
1582
2441
  raise SystemExit(f"Unsupported companion kind `{args.kind}`.")
1583
2442
 
1584
2443
  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}")
2444
+ _write_new_doc(planned_path, content)
2445
+ if args.format != "json":
2446
+ print(f"Wrote {planned_path}")
1588
2447
  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)
2448
+ if args.format != "json":
2449
+ preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
2450
+ print(f"[dry-run] would write: {planned_path}")
2451
+ print(preview)
1592
2452
 
1593
2453
  payload = {
1594
2454
  "command": "companion",
@@ -1601,19 +2461,324 @@ def cmd_companion(args: argparse.Namespace) -> dict[str, object]:
1601
2461
  "dry_run": args.dry_run,
1602
2462
  }
1603
2463
  if args.format == "json":
1604
- print(json.dumps(payload, indent=2, sort_keys=True))
2464
+ print_payload(payload, args.format)
1605
2465
  else:
1606
2466
  print(f"Created companion doc: {payload['path']}")
1607
2467
  return payload
1608
2468
 
1609
2469
 
2470
+ def _deliver_builder_args(args: argparse.Namespace) -> argparse.Namespace:
2471
+ return argparse.Namespace(
2472
+ from_version=None,
2473
+ understanding="90%",
2474
+ confidence="85%",
2475
+ status="Ready",
2476
+ complexity="Medium",
2477
+ theme="Operator workflow",
2478
+ progress="0%",
2479
+ auto_create_product_brief=False,
2480
+ auto_create_adr=False,
2481
+ dry_run=args.dry_run,
2482
+ fixture=False,
2483
+ )
2484
+
2485
+
2486
+ def cmd_deliver(args: argparse.Namespace) -> dict[str, object]:
2487
+ repo_root = _find_repo_root(Path.cwd())
2488
+ product_path = _resolve_product_source(repo_root, args.from_product)
2489
+ product_ref = product_path.stem
2490
+ title = args.title or _extract_doc_title(product_path)
2491
+ build_args = _deliver_builder_args(args)
2492
+
2493
+ request_planned = _plan_doc(repo_root, DOC_KINDS["request"].directory, DOC_KINDS["request"].prefix, title, dry_run=args.dry_run)
2494
+ backlog_ref = _next_backlog_ref(repo_root, title)
2495
+ task_ref = _next_task_ref(repo_root, title)
2496
+ backlog_path = repo_root / DOC_KINDS["backlog"].directory / f"{backlog_ref}.md"
2497
+ task_path = repo_root / DOC_KINDS["task"].directory / f"{task_ref}.md"
2498
+
2499
+ if not args.dry_run:
2500
+ _ensure_new_doc_paths_available([request_planned.path, backlog_path, task_path])
2501
+
2502
+ request_content = _build_native_request_doc(repo_root, request_planned.ref, title, build_args)
2503
+ backlog_content = _build_native_backlog_doc(
2504
+ repo_root,
2505
+ backlog_ref,
2506
+ title,
2507
+ build_args,
2508
+ request_ref=request_planned.path.relative_to(repo_root).as_posix(),
2509
+ product_refs=[product_ref],
2510
+ architecture_refs=[],
2511
+ )
2512
+ task_content = _build_native_task_doc(
2513
+ repo_root,
2514
+ task_ref,
2515
+ title,
2516
+ build_args,
2517
+ backlog_ref=backlog_ref,
2518
+ request_refs=[request_planned.ref],
2519
+ product_refs=[product_ref],
2520
+ architecture_refs=[],
2521
+ )
2522
+
2523
+ if not args.dry_run:
2524
+ _write_new_doc(request_planned.path, request_content)
2525
+ _write_new_doc(backlog_path, backlog_content)
2526
+ _write_new_doc(task_path, task_content)
2527
+ _append_doc_section_bullets(request_planned.path, "Backlog", [f"`{backlog_ref}`"], dry_run=False)
2528
+ _append_doc_section_bullets(backlog_path, "Tasks", [f"`{task_ref}`"], dry_run=False)
2529
+ _remove_section_placeholder_bullets(request_planned.path, "Backlog", {"- none"}, dry_run=False)
2530
+ backlog_lines = backlog_path.read_text(encoding="utf-8").splitlines()
2531
+ backlog_lines = _replace_or_append_prefixed_section_bullet(backlog_lines, "Links", "Primary task(s)", f"`{task_ref}`")
2532
+ backlog_path.write_text("\n".join(backlog_lines).rstrip() + "\n", encoding="utf-8")
2533
+ _update_request_product_link(request_planned.path, product_ref, dry_run=False)
2534
+ _mark_section_checkboxes_done(request_planned.path, "Definition of Ready (DoR)", dry_run=False)
2535
+ _update_product_delivery_links(
2536
+ product_path,
2537
+ request_ref=request_planned.ref,
2538
+ backlog_ref=backlog_ref,
2539
+ task_ref=task_ref,
2540
+ dry_run=False,
2541
+ )
2542
+ repair_mermaid_payload(repo_root, [request_planned.ref, backlog_ref, task_ref], dry_run=False)
2543
+ if args.finish:
2544
+ _close_chain_for_kind(repo_root, task_path, DOC_KINDS["task"], dry_run=False, quiet=args.format == "json")
2545
+
2546
+ payload = {
2547
+ "command": "deliver",
2548
+ "from_product": product_path.relative_to(repo_root).as_posix(),
2549
+ "product_ref": product_ref,
2550
+ "created_request_ref": request_planned.ref,
2551
+ "created_request_path": request_planned.path.relative_to(repo_root).as_posix(),
2552
+ "created_backlog_ref": backlog_ref,
2553
+ "created_backlog_path": backlog_path.relative_to(repo_root).as_posix(),
2554
+ "created_task_ref": task_ref,
2555
+ "created_task_path": task_path.relative_to(repo_root).as_posix(),
2556
+ "finished": bool(args.finish and not args.dry_run),
2557
+ "dry_run": args.dry_run,
2558
+ }
2559
+
2560
+ if args.format == "json":
2561
+ print_payload(payload, args.format)
2562
+ elif args.dry_run:
2563
+ print(f"[dry-run] would create delivery chain from product: {product_path.relative_to(repo_root)}")
2564
+ print(f"- request: {payload['created_request_path']}")
2565
+ print(f"- backlog: {payload['created_backlog_path']}")
2566
+ print(f"- task: {payload['created_task_path']}")
2567
+ else:
2568
+ print(f"Created delivery chain from product: {product_path.relative_to(repo_root)}")
2569
+ print(f"- request: {payload['created_request_path']}")
2570
+ print(f"- backlog: {payload['created_backlog_path']}")
2571
+ print(f"- task: {payload['created_task_path']}")
2572
+ return payload
2573
+
2574
+
2575
+ def cmd_validate_closeout(args: argparse.Namespace) -> dict[str, object]:
2576
+ repo_root = _find_repo_root(Path.cwd())
2577
+ payload = validate_closeout_payload(repo_root, args.source)
2578
+ if args.format == "json":
2579
+ print_payload(payload, args.format)
2580
+ else:
2581
+ status = "OK" if payload["ok"] else "FAILED"
2582
+ print(f"Closeout preflight: {status} for {payload['source']}")
2583
+ if payload["issues"]:
2584
+ for issue in payload["issues"]:
2585
+ print(f"- {issue['code']}: {issue['message']} ({issue['path']})")
2586
+ if "repair_command" in issue:
2587
+ print(f" repair: {issue['repair_command']}")
2588
+ else:
2589
+ print("- no blocking closeout issues found")
2590
+ return payload
2591
+
2592
+
2593
+ def _print_repair_payload(payload: dict[str, object], output_format: str) -> None:
2594
+ if output_format == "json":
2595
+ print_payload(payload, output_format)
2596
+ return
2597
+ action = "would change" if payload.get("dry_run") else "changed"
2598
+ changed_files = payload.get("changed_files", [])
2599
+ print(f"Repair {payload['kind']}: {action} {len(changed_files)} file(s).")
2600
+ for rel_path in changed_files:
2601
+ print(f"- {rel_path}")
2602
+
2603
+
2604
+ def cmd_repair_gates(args: argparse.Namespace) -> dict[str, object]:
2605
+ repo_root = _find_repo_root(Path.cwd())
2606
+ payload = repair_gates_payload(repo_root, args.source, dry_run=args.dry_run)
2607
+ _print_repair_payload(payload, args.format)
2608
+ return payload
2609
+
2610
+
2611
+ def cmd_repair_ac_traceability(args: argparse.Namespace) -> dict[str, object]:
2612
+ repo_root = _find_repo_root(Path.cwd())
2613
+ payload = repair_ac_traceability_payload(repo_root, args.source, dry_run=args.dry_run)
2614
+ _print_repair_payload(payload, args.format)
2615
+ return payload
2616
+
2617
+
2618
+ def cmd_repair_links(args: argparse.Namespace) -> dict[str, object]:
2619
+ repo_root = _find_repo_root(Path.cwd())
2620
+ payload = repair_links_payload(repo_root, args.source, dry_run=args.dry_run)
2621
+ _print_repair_payload(payload, args.format)
2622
+ return payload
2623
+
2624
+
2625
+ def cmd_repair_mermaid(args: argparse.Namespace) -> dict[str, object]:
2626
+ repo_root = _find_repo_root(Path.cwd())
2627
+ payload = repair_mermaid_payload(repo_root, args.refs, dry_run=args.dry_run)
2628
+ _print_repair_payload(payload, args.format)
2629
+ return payload
2630
+
2631
+
2632
+ def _closeout_refs(repo_root: Path, task_path: Path) -> list[str]:
2633
+ task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
2634
+ refs = {task_path.stem}
2635
+ item_refs = set(_extract_refs(task_text, DOC_KINDS["backlog"].prefix))
2636
+ refs.update(item_refs)
2637
+ refs.update(_extract_refs(task_text, DOC_KINDS["request"].prefix))
2638
+ for item_ref in sorted(item_refs):
2639
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
2640
+ if item_path is not None:
2641
+ refs.update(_extract_refs(_strip_mermaid_blocks(item_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
2642
+ return sorted(refs)
2643
+
2644
+
2645
+ def closeout_payload(repo_root: Path, source: str, *, validations: list[str], run_index: bool, run_lint: bool, run_audit: bool, dry_run: bool) -> dict[str, object]:
2646
+ task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
2647
+ task_ref = task_path.stem
2648
+ changed_files: set[str] = set()
2649
+ steps: list[dict[str, object]] = []
2650
+
2651
+ for validation in validations:
2652
+ if validation and validation.strip():
2653
+ if _append_doc_section_bullets_changed(task_path, "Validation", [validation.strip()], dry_run=dry_run):
2654
+ changed_files.add(task_path.relative_to(repo_root).as_posix())
2655
+ steps.append({"kind": "validation", "text": validation.strip(), "dry_run": dry_run})
2656
+
2657
+ gate_payload = repair_gates_payload(repo_root, task_ref, dry_run=dry_run)
2658
+ link_payload = repair_links_payload(repo_root, task_ref, dry_run=dry_run)
2659
+ changed_files.update(gate_payload["changed_files"])
2660
+ changed_files.update(link_payload["changed_files"])
2661
+ steps.extend([gate_payload, link_payload])
2662
+
2663
+ request_refs = sorted(_extract_refs(_strip_mermaid_blocks(task_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
2664
+ item_refs = sorted(_extract_refs(_strip_mermaid_blocks(task_path.read_text(encoding="utf-8")), DOC_KINDS["backlog"].prefix))
2665
+ for item_ref in item_refs:
2666
+ item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
2667
+ if item_path is not None:
2668
+ request_refs.extend(_extract_refs(_strip_mermaid_blocks(item_path.read_text(encoding="utf-8")), DOC_KINDS["request"].prefix))
2669
+ for request_ref in sorted(set(request_refs)):
2670
+ ac_payload = repair_ac_traceability_payload(repo_root, request_ref, dry_run=dry_run)
2671
+ changed_files.update(ac_payload["changed_files"])
2672
+ steps.append(ac_payload)
2673
+
2674
+ mermaid_refs = _closeout_refs(repo_root, task_path)
2675
+ mermaid_payload = repair_mermaid_payload(repo_root, mermaid_refs, dry_run=dry_run)
2676
+ changed_files.update(mermaid_payload["changed_files"])
2677
+ steps.append(mermaid_payload)
2678
+
2679
+ preflight = validate_closeout_payload(repo_root, task_ref)
2680
+ if preflight["issues"]:
2681
+ return {
2682
+ "command": "closeout",
2683
+ "ok": False,
2684
+ "source": task_path.relative_to(repo_root).as_posix(),
2685
+ "changed_files": sorted(changed_files),
2686
+ "preflight": preflight,
2687
+ "steps": steps,
2688
+ "dry_run": dry_run,
2689
+ }
2690
+
2691
+ finish_payload: dict[str, object] | None = None
2692
+ if not dry_run:
2693
+ _close_chain_for_kind(repo_root, task_path, DOC_KINDS["task"], dry_run=False, quiet=True)
2694
+ finish_issues = _verify_finished_task_chain(repo_root, task_path)
2695
+ if finish_issues:
2696
+ raise SystemExit("Finish verification failed:\n" + "\n".join(f"- {issue}" for issue in finish_issues))
2697
+ changed_files.add(task_path.relative_to(repo_root).as_posix())
2698
+ for ref in mermaid_refs:
2699
+ path, _kind = _resolve_any_workflow_source(repo_root, ref)
2700
+ changed_files.add(path.relative_to(repo_root).as_posix())
2701
+ finish_payload = {"kind": "finish", "ok": True}
2702
+ post_finish_mermaid = repair_mermaid_payload(repo_root, mermaid_refs, dry_run=False)
2703
+ changed_files.update(post_finish_mermaid["changed_files"])
2704
+ steps.append(post_finish_mermaid)
2705
+
2706
+ index_result: dict[str, object] | None = None
2707
+ if run_index:
2708
+ if dry_run:
2709
+ index_result = {"ok": True, "dry_run": True}
2710
+ else:
2711
+ index_result = index_payload(repo_root)
2712
+ changed_files.add(str(index_result["output_path"]))
2713
+
2714
+ lint_result: dict[str, object] | None = None
2715
+ if run_lint:
2716
+ lint_result = lint_payload(repo_root, require_status=True)
2717
+
2718
+ audit_result: dict[str, object] | None = None
2719
+ if run_audit:
2720
+ audit_result = audit_payload(repo_root, legacy_cutoff_version="1.1.0", group_by_doc=True)
2721
+
2722
+ ok = True
2723
+ if lint_result is not None and not lint_result.get("ok", False):
2724
+ ok = False
2725
+ if audit_result is not None and audit_result.get("issue_count", 0):
2726
+ ok = False
2727
+
2728
+ return {
2729
+ "command": "closeout",
2730
+ "ok": ok,
2731
+ "source": task_path.relative_to(repo_root).as_posix(),
2732
+ "changed_files": sorted(changed_files),
2733
+ "preflight": preflight,
2734
+ "finish": finish_payload,
2735
+ "index": index_result,
2736
+ "lint": lint_result,
2737
+ "audit": audit_result,
2738
+ "steps": steps,
2739
+ "dry_run": dry_run,
2740
+ }
2741
+
2742
+
2743
+ def cmd_closeout(args: argparse.Namespace) -> dict[str, object]:
2744
+ repo_root = _find_repo_root(Path.cwd())
2745
+ payload = closeout_payload(
2746
+ repo_root,
2747
+ args.source,
2748
+ validations=args.validation or [],
2749
+ run_index=args.index,
2750
+ run_lint=args.lint,
2751
+ run_audit=args.audit,
2752
+ dry_run=args.dry_run,
2753
+ )
2754
+ if args.format == "json":
2755
+ print_payload(payload, args.format)
2756
+ else:
2757
+ status = "OK" if payload["ok"] else "FAILED"
2758
+ print(f"Closeout: {status} for {payload['source']}")
2759
+ print(f"- changed files: {len(payload['changed_files'])}")
2760
+ for rel_path in payload["changed_files"]:
2761
+ print(f" - {rel_path}")
2762
+ preflight = payload.get("preflight")
2763
+ if isinstance(preflight, dict) and preflight.get("issues"):
2764
+ print("- preflight issues:")
2765
+ for issue in preflight["issues"]:
2766
+ print(f" - {issue['code']}: {issue['message']} ({issue['path']})")
2767
+ if payload.get("lint") is not None:
2768
+ print(f"- lint ok: {payload['lint'].get('ok')}")
2769
+ if payload.get("audit") is not None:
2770
+ print(f"- audit issues: {payload['audit'].get('issue_count')}")
2771
+ return payload
2772
+
2773
+
1610
2774
  def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object]:
1611
2775
  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}")
2776
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], args.source)
1615
2777
  title = _extract_doc_title(source_path)
1616
2778
  ref, _ = _build_native_backlog_from_request(repo_root, source_path, title)
2779
+ planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
2780
+ if not args.dry_run:
2781
+ _ensure_new_doc_paths_available([planned_path])
1617
2782
  product_refs, architecture_refs = _create_native_companion_docs(
1618
2783
  repo_root,
1619
2784
  title,
@@ -1629,10 +2794,8 @@ def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object
1629
2794
  product_refs=product_refs,
1630
2795
  architecture_refs=architecture_refs,
1631
2796
  )
1632
- planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
1633
2797
  if not args.dry_run:
1634
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1635
- planned_path.write_text(content, encoding="utf-8")
2798
+ _write_new_doc(planned_path, content)
1636
2799
  _append_doc_section_bullets(source_path, "Backlog", [f"`{ref}`"], dry_run=False)
1637
2800
  payload = {
1638
2801
  "command": "promote",
@@ -1643,7 +2806,7 @@ def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object
1643
2806
  "dry_run": args.dry_run,
1644
2807
  }
1645
2808
  if args.format == "json":
1646
- print(json.dumps(payload, indent=2, sort_keys=True))
2809
+ print_payload(payload, args.format)
1647
2810
  else:
1648
2811
  print(f"Created backlog slice from request: {payload['created_path']}")
1649
2812
  return payload
@@ -1651,13 +2814,14 @@ def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object
1651
2814
 
1652
2815
  def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1653
2816
  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}")
2817
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["backlog"], args.source)
1657
2818
  title = _extract_doc_title(source_path)
1658
2819
  source_text = source_path.read_text(encoding="utf-8")
1659
2820
  request_refs = sorted(_extract_refs(_strip_mermaid_blocks(source_text), DOC_KINDS["request"].prefix))
1660
2821
  ref, _ = _build_native_task_from_backlog(repo_root, source_path, title)
2822
+ planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
2823
+ if not args.dry_run:
2824
+ _ensure_new_doc_paths_available([planned_path])
1661
2825
  product_refs, architecture_refs = _create_native_companion_docs(
1662
2826
  repo_root,
1663
2827
  title,
@@ -1674,10 +2838,8 @@ def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1674
2838
  product_refs=product_refs,
1675
2839
  architecture_refs=architecture_refs,
1676
2840
  )
1677
- planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
1678
2841
  if not args.dry_run:
1679
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1680
- planned_path.write_text(content, encoding="utf-8")
2842
+ _write_new_doc(planned_path, content)
1681
2843
  _append_doc_section_bullets(source_path, "Tasks", [f"`{ref}`"], dry_run=False)
1682
2844
  payload = {
1683
2845
  "command": "promote",
@@ -1688,7 +2850,7 @@ def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1688
2850
  "dry_run": args.dry_run,
1689
2851
  }
1690
2852
  if args.format == "json":
1691
- print(json.dumps(payload, indent=2, sort_keys=True))
2853
+ print_payload(payload, args.format)
1692
2854
  else:
1693
2855
  print(f"Created task from backlog: {payload['created_path']}")
1694
2856
  return payload
@@ -1696,9 +2858,7 @@ def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
1696
2858
 
1697
2859
  def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1698
2860
  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}")
2861
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], args.source)
1702
2862
  titles = _split_titles([title for group in args.title for title in group])
1703
2863
  created_refs: list[str] = []
1704
2864
  for title in titles:
@@ -1707,6 +2867,9 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1707
2867
  source_path,
1708
2868
  title,
1709
2869
  )
2870
+ planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
2871
+ if not args.dry_run:
2872
+ _ensure_new_doc_paths_available([planned_path])
1710
2873
  product_refs, architecture_refs = _create_native_companion_docs(
1711
2874
  repo_root,
1712
2875
  title,
@@ -1722,10 +2885,8 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1722
2885
  product_refs=product_refs,
1723
2886
  architecture_refs=architecture_refs,
1724
2887
  )
1725
- planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
1726
2888
  if not args.dry_run:
1727
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1728
- planned_path.write_text(content, encoding="utf-8")
2889
+ _write_new_doc(planned_path, content)
1729
2890
  _append_doc_section_bullets(source_path, "Backlog", [f"`{ref}`"], dry_run=False)
1730
2891
  created_refs.append(ref)
1731
2892
  payload = {
@@ -1736,7 +2897,7 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1736
2897
  "dry_run": args.dry_run,
1737
2898
  }
1738
2899
  if args.format == "json":
1739
- print(json.dumps(payload, indent=2, sort_keys=True))
2900
+ print_payload(payload, args.format)
1740
2901
  else:
1741
2902
  print(f"Split request into {len(created_refs)} backlog item(s): {', '.join(created_refs)}")
1742
2903
  return payload
@@ -1744,15 +2905,16 @@ def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
1744
2905
 
1745
2906
  def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1746
2907
  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}")
2908
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["backlog"], args.source)
1750
2909
  source_text = source_path.read_text(encoding="utf-8")
1751
2910
  request_refs = sorted(_extract_refs(_strip_mermaid_blocks(source_text), DOC_KINDS["request"].prefix))
1752
2911
  titles = _split_titles([title for group in args.title for title in group])
1753
2912
  created_refs: list[str] = []
1754
2913
  for title in titles:
1755
2914
  ref, _ = _build_native_task_from_backlog(repo_root, source_path, title)
2915
+ planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
2916
+ if not args.dry_run:
2917
+ _ensure_new_doc_paths_available([planned_path])
1756
2918
  product_refs, architecture_refs = _create_native_companion_docs(
1757
2919
  repo_root,
1758
2920
  title,
@@ -1769,10 +2931,8 @@ def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1769
2931
  product_refs=product_refs,
1770
2932
  architecture_refs=architecture_refs,
1771
2933
  )
1772
- planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
1773
2934
  if not args.dry_run:
1774
- planned_path.parent.mkdir(parents=True, exist_ok=True)
1775
- planned_path.write_text(content, encoding="utf-8")
2935
+ _write_new_doc(planned_path, content)
1776
2936
  _append_doc_section_bullets(source_path, "Tasks", [f"`{ref}`"], dry_run=False)
1777
2937
  created_refs.append(ref)
1778
2938
  payload = {
@@ -1783,7 +2943,7 @@ def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1783
2943
  "dry_run": args.dry_run,
1784
2944
  }
1785
2945
  if args.format == "json":
1786
- print(json.dumps(payload, indent=2, sort_keys=True))
2946
+ print_payload(payload, args.format)
1787
2947
  else:
1788
2948
  print(f"Split backlog item into {len(created_refs)} task(s): {', '.join(created_refs)}")
1789
2949
  return payload
@@ -1792,14 +2952,9 @@ def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
1792
2952
  def cmd_close(args: argparse.Namespace) -> dict[str, object]:
1793
2953
  repo_root = _find_repo_root(Path.cwd())
1794
2954
  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}")
2955
+ source_path = _resolve_workflow_source(repo_root, kind, args.source)
1800
2956
 
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)}")
2957
+ _close_chain_for_kind(repo_root, source_path, kind, dry_run=args.dry_run, quiet=args.format == "json")
1803
2958
 
1804
2959
  payload = {
1805
2960
  "command": "close",
@@ -1808,7 +2963,9 @@ def cmd_close(args: argparse.Namespace) -> dict[str, object]:
1808
2963
  "dry_run": args.dry_run,
1809
2964
  }
1810
2965
  if args.format == "json":
1811
- print(json.dumps(payload, indent=2, sort_keys=True))
2966
+ print_payload(payload, args.format)
2967
+ else:
2968
+ print(f"Closed {kind.kind}: {payload['source']}")
1812
2969
  return payload
1813
2970
 
1814
2971
 
@@ -1885,7 +3042,7 @@ def _record_finished_task_follow_up(repo_root: Path, task_path: Path, dry_run: b
1885
3042
  _append_section_bullets(task_path, "Report", report_bullets, dry_run)
1886
3043
 
1887
3044
 
1888
- def _maybe_close_request_chain(repo_root: Path, request_ref: str, dry_run: bool) -> None:
3045
+ def _maybe_close_request_chain(repo_root: Path, request_ref: str, dry_run: bool, *, quiet: bool = False) -> None:
1889
3046
  request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
1890
3047
  if request_path is None:
1891
3048
  return
@@ -1897,10 +3054,11 @@ def _maybe_close_request_chain(repo_root: Path, request_ref: str, dry_run: bool)
1897
3054
  if all(_is_doc_done(item_path, DOC_KINDS["backlog"]) for item_path in linked_items):
1898
3055
  if not _is_doc_done(request_path, DOC_KINDS["request"]):
1899
3056
  _close_doc(request_path, DOC_KINDS["request"], dry_run)
1900
- print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
3057
+ if not quiet:
3058
+ print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
1901
3059
 
1902
3060
 
1903
- def _close_chain_for_kind(repo_root: Path, source_path: Path, kind: DOC_KINDS, *, dry_run: bool) -> None:
3061
+ def _close_chain_for_kind(repo_root: Path, source_path: Path, kind: DOC_KINDS, *, dry_run: bool, quiet: bool = False) -> None:
1904
3062
  _close_doc(source_path, kind, dry_run)
1905
3063
 
1906
3064
  text = _strip_mermaid_blocks(source_path.read_text(encoding="utf-8"))
@@ -1919,40 +3077,37 @@ def _close_chain_for_kind(repo_root: Path, source_path: Path, kind: DOC_KINDS, *
1919
3077
  if linked_tasks and all(_is_doc_done(task_path, DOC_KINDS["task"]) for task_path in linked_tasks):
1920
3078
  if not _is_doc_done(item_path, DOC_KINDS["backlog"]):
1921
3079
  _close_doc(item_path, DOC_KINDS["backlog"], dry_run)
1922
- print(f"Auto-closed backlog item {item_ref} (all linked tasks are done).")
3080
+ if not quiet:
3081
+ print(f"Auto-closed backlog item {item_ref} (all linked tasks are done).")
1923
3082
 
1924
3083
  item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8"))
1925
3084
  for request_ref in sorted(_extract_refs(item_text, DOC_KINDS["request"].prefix)):
1926
3085
  if request_ref in processed_request_refs:
1927
3086
  continue
1928
3087
  processed_request_refs.add(request_ref)
1929
- _maybe_close_request_chain(repo_root, request_ref, dry_run)
3088
+ _maybe_close_request_chain(repo_root, request_ref, dry_run, quiet=quiet)
1930
3089
 
1931
3090
  if kind.kind == "backlog":
1932
3091
  for request_ref in sorted(_extract_refs(text, DOC_KINDS["request"].prefix)):
1933
3092
  if request_ref in processed_request_refs:
1934
3093
  continue
1935
3094
  processed_request_refs.add(request_ref)
1936
- _maybe_close_request_chain(repo_root, request_ref, dry_run)
3095
+ _maybe_close_request_chain(repo_root, request_ref, dry_run, quiet=quiet)
1937
3096
 
1938
3097
  if kind.kind == "request":
1939
- _maybe_close_request_chain(repo_root, source_path.stem, dry_run)
3098
+ _maybe_close_request_chain(repo_root, source_path.stem, dry_run, quiet=quiet)
1940
3099
 
1941
3100
 
1942
3101
  def cmd_finish_task(args: argparse.Namespace) -> dict[str, object]:
1943
3102
  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}")
3103
+ source_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], args.source)
1949
3104
 
1950
- _close_chain_for_kind(repo_root, source_path, DOC_KINDS["task"], dry_run=args.dry_run)
3105
+ _close_chain_for_kind(repo_root, source_path, DOC_KINDS["task"], dry_run=args.dry_run, quiet=args.format == "json")
1951
3106
 
1952
3107
  if args.dry_run:
1953
3108
  payload = {"command": "finish", "kind": "task", "source": source_path.relative_to(repo_root).as_posix(), "dry_run": True}
1954
3109
  if args.format == "json":
1955
- print(json.dumps(payload, indent=2, sort_keys=True))
3110
+ print_payload(payload, args.format)
1956
3111
  else:
1957
3112
  print("Dry run: skipped post-close verification.")
1958
3113
  return payload
@@ -1964,7 +3119,7 @@ def cmd_finish_task(args: argparse.Namespace) -> dict[str, object]:
1964
3119
 
1965
3120
  payload = {"command": "finish", "kind": "task", "source": source_path.relative_to(repo_root).as_posix(), "dry_run": False}
1966
3121
  if args.format == "json":
1967
- print(json.dumps(payload, indent=2, sort_keys=True))
3122
+ print_payload(payload, args.format)
1968
3123
  else:
1969
3124
  print(f"Finish verification: OK for {source_path.relative_to(repo_root)}")
1970
3125
  return payload
@@ -1989,6 +3144,21 @@ def main(argv: list[str]) -> int:
1989
3144
  if argv[0] == "companion" and len(argv) > 1 and argv[1] in {"product", "architecture"} and _help_requested(argv, 2):
1990
3145
  _print_help(_build_companion_kind_help(argv[1]))
1991
3146
  return 0
3147
+ if argv[0] == "deliver" and _help_requested(argv, 1):
3148
+ _print_help(_build_deliver_help())
3149
+ return 0
3150
+ if argv[0] == "validate-closeout" and _help_requested(argv, 1):
3151
+ _print_help(_build_validate_closeout_help())
3152
+ return 0
3153
+ if argv[0] == "repair" and _help_requested(argv, 1):
3154
+ _print_help(_build_repair_help())
3155
+ return 0
3156
+ if argv[0] == "repair" and len(argv) > 1 and argv[1] in {"gates", "ac-traceability", "links", "mermaid"} and _help_requested(argv, 2):
3157
+ _print_help(_build_repair_kind_help(argv[1]))
3158
+ return 0
3159
+ if argv[0] == "closeout" and _help_requested(argv, 1):
3160
+ _print_help(_build_closeout_help())
3161
+ return 0
1992
3162
  if argv[0] == "promote" and _help_requested(argv, 1):
1993
3163
  _print_help(_build_promote_help())
1994
3164
  return 0
@@ -2015,7 +3185,11 @@ def main(argv: list[str]) -> int:
2015
3185
  return 0
2016
3186
  parser = build_parser()
2017
3187
  args = parser.parse_args(argv)
2018
- if args.command not in {"new", "list", "companion", "promote", "split", "close", "finish"}:
3188
+ if args.command not in {"new", "list", "companion", "deliver", "validate-closeout", "repair", "closeout", "promote", "split", "close", "finish"}:
2019
3189
  raise SystemExit("Unsupported flow subcommand for the native CLI slice.")
2020
3190
  payload = args.func(args)
3191
+ if args.command == "validate-closeout" and isinstance(payload, dict) and not payload.get("ok", False):
3192
+ return 1
3193
+ if args.command == "closeout" and isinstance(payload, dict) and not payload.get("ok", False):
3194
+ return 1
2021
3195
  return 0 if isinstance(payload, dict) else 1