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