@grifhinz/logics-manager 2.1.1 → 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.
- package/README.md +80 -5
- package/VERSION +1 -1
- package/logics_manager/assist.py +185 -21
- package/logics_manager/audit.py +72 -18
- package/logics_manager/cli.py +162 -61
- package/logics_manager/cli_output.py +18 -0
- package/logics_manager/flow.py +1257 -83
- package/logics_manager/index.py +3 -7
- package/logics_manager/insights.py +418 -0
- package/logics_manager/lint.py +21 -7
- package/logics_manager/mcp.py +385 -27
- package/logics_manager/path_utils.py +31 -0
- package/logics_manager/sync.py +24 -12
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/scripts/npm/logics-manager.mjs +13 -1
package/logics_manager/flow.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1486
|
-
|
|
1487
|
-
|
|
2332
|
+
_write_new_doc(planned.path, content)
|
|
2333
|
+
if args.format != "json":
|
|
2334
|
+
print(f"Wrote {planned.path}")
|
|
1488
2335
|
else:
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
|
1536
|
-
|
|
1537
|
-
|
|
2390
|
+
_write_new_doc(planned.path, content)
|
|
2391
|
+
if args.format != "json":
|
|
2392
|
+
print(f"Wrote {planned.path}")
|
|
1538
2393
|
else:
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
-
|
|
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
|
|
1586
|
-
|
|
1587
|
-
|
|
2444
|
+
_write_new_doc(planned_path, content)
|
|
2445
|
+
if args.format != "json":
|
|
2446
|
+
print(f"Wrote {planned_path}")
|
|
1588
2447
|
else:
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|