@antoneeo/mkt-agentic-sdlc-skill 0.26.0 → 0.27.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/CHANGELOG.md +12 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/mkt-agentic-sdlc/ENFORCEMENT.md +3 -3
- package/skills/mkt-agentic-sdlc/SKILL.md +1 -1
- package/skills/mkt-agentic-sdlc/docs_site/app.js +7 -1
- package/skills/mkt-agentic-sdlc/docs_site/strings.js +9 -0
- package/skills/mkt-agentic-sdlc/docs_site/style.css +2 -0
- package/skills/mkt-agentic-sdlc/guides.md +8 -0
- package/skills/mkt-agentic-sdlc/review.md +9 -1
- package/skills/mkt-agentic-sdlc/scripts/docs_site.py +17 -3
- package/skills/mkt-agentic-sdlc/scripts/knowledge.py +925 -33
- package/skills/mkt-agentic-sdlc/scripts/mkt_check.py +2 -1
- package/skills/mkt-agentic-sdlc/scripts/sdlc_core.py +562 -22
|
@@ -265,6 +265,7 @@ REQUIRED_CAPABILITIES = frozenset({
|
|
|
265
265
|
OPTIONAL_CAPABILITIES = frozenset({
|
|
266
266
|
"architect_pass", # does the component already exist? (code overlay)
|
|
267
267
|
"interaction_contract", # actor-facing surface spec between use cases and solution (code overlay)
|
|
268
|
+
"preservation_constraints", # the Impact's P table, and validate's note on its absence (code overlay)
|
|
268
269
|
"taxonomy_pass", # do the categories/topics already exist? (knowledge overlay)
|
|
269
270
|
"comprehension_guides", # source_kind: code maps of complex components
|
|
270
271
|
"tdd", # test-first discipline
|
|
@@ -574,12 +575,20 @@ def stray_shadow(solutions_dir, doc_key):
|
|
|
574
575
|
None)
|
|
575
576
|
|
|
576
577
|
|
|
578
|
+
def skip_dir(name):
|
|
579
|
+
"""The one definition of a directory that holds no project files: VCS and
|
|
580
|
+
dependency/build directories, every dot-directory, and the docs root (by
|
|
581
|
+
name, at any depth). `iter_files` and the knowledge layer's inventory both
|
|
582
|
+
walk by it, so the two can never disagree about what the project is."""
|
|
583
|
+
return name in SKIP_DIRS or name == docs_dir() or name.startswith(".")
|
|
584
|
+
|
|
585
|
+
|
|
577
586
|
def iter_files(target):
|
|
578
587
|
if target.is_file():
|
|
579
588
|
yield target
|
|
580
589
|
return
|
|
581
590
|
for dirpath, dirnames, filenames in os.walk(target):
|
|
582
|
-
dirnames[:] = [d for d in dirnames if
|
|
591
|
+
dirnames[:] = [d for d in dirnames if not skip_dir(d)]
|
|
583
592
|
for name in filenames:
|
|
584
593
|
yield Path(dirpath) / name
|
|
585
594
|
|
|
@@ -1799,6 +1808,18 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1799
1808
|
advisories.append(f"{rel}: L3 without a filled '## Guide check' -- does a CURRENT "
|
|
1800
1809
|
"guide cover what this touches, and did understanding it force "
|
|
1801
1810
|
"a model of a complex component no guide covers (guides.md, section 1)?")
|
|
1811
|
+
# F-085 FS7: code lens only (R12: the lens, not merely the domain -- a kb
|
|
1812
|
+
# install validating a code-domain unit sees no change), open L3 only, a
|
|
1813
|
+
# note and never an error, so no `--strict` exit changes (P5).
|
|
1814
|
+
impact = section_body(text, IMPACT_SECTION)
|
|
1815
|
+
if (has_capability("preservation_constraints") and domain == "code"
|
|
1816
|
+
and str(level or "").upper() == "L3"
|
|
1817
|
+
and status in ("PLANNED", "IN_PROGRESS")
|
|
1818
|
+
and impact is not None and not has_preservation_record(impact)):
|
|
1819
|
+
advisories.append(f"{rel}: open L3 whose '## Impact' has neither the "
|
|
1820
|
+
f"'{PRESERVATION_HEADING}' table nor the line "
|
|
1821
|
+
f"'{NO_SYMBOL_LINE}' -- what existing code must keep "
|
|
1822
|
+
"doing is not extractable (templates.md)")
|
|
1802
1823
|
|
|
1803
1824
|
# Generated index aligned
|
|
1804
1825
|
hist = ai / "strategic" / "features_history.md"
|
|
@@ -2479,6 +2500,28 @@ def read_hook_payload():
|
|
|
2479
2500
|
return payload if isinstance(payload, dict) else None
|
|
2480
2501
|
|
|
2481
2502
|
|
|
2503
|
+
def hook_project(target, root=None, docs_name=None):
|
|
2504
|
+
"""The project root an edit hook judges, with its docs root name set, or None
|
|
2505
|
+
when two docs roots sit side by side. The hook runs in the session's current
|
|
2506
|
+
directory, which a `cd` moves (live probe 2026-10-27): the project AND its docs
|
|
2507
|
+
root name are the ones holding the edited file, never the ones around the
|
|
2508
|
+
working directory. An explicit root (--root) is kept as given."""
|
|
2509
|
+
start = Path(root) if root is not None else Path(target).resolve().parent
|
|
2510
|
+
try:
|
|
2511
|
+
found_root, name = resolve_docs_dir(argparse.Namespace(docs_dir=docs_name), start)
|
|
2512
|
+
except AmbiguousDocsRoot:
|
|
2513
|
+
return None
|
|
2514
|
+
set_docs_dir(name)
|
|
2515
|
+
if root is not None:
|
|
2516
|
+
return Path(root)
|
|
2517
|
+
if found_root is not None:
|
|
2518
|
+
return found_root
|
|
2519
|
+
found_root = find_project_root(start) # an explicit name: discovery did not run
|
|
2520
|
+
# No docs root above the edited file: the session's own project, as before the
|
|
2521
|
+
# hook resolved from the file -- the file's folder would unprotect a gated path.
|
|
2522
|
+
return found_root if ai_path(found_root).is_dir() else find_project_root()
|
|
2523
|
+
|
|
2524
|
+
|
|
2482
2525
|
def cmd_gate(args):
|
|
2483
2526
|
file_path = args.file or ""
|
|
2484
2527
|
if args.hook:
|
|
@@ -2487,9 +2530,12 @@ def cmd_gate(args):
|
|
|
2487
2530
|
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
2488
2531
|
except AttributeError:
|
|
2489
2532
|
return 0 # unparseable input: do not block
|
|
2490
|
-
if not file_path:
|
|
2533
|
+
if not isinstance(file_path, str) or not file_path:
|
|
2491
2534
|
return 0
|
|
2492
|
-
root = Path(args.root).resolve() if args.root else
|
|
2535
|
+
root = hook_project(file_path, Path(args.root).resolve() if args.root else None,
|
|
2536
|
+
getattr(args, "docs_dir", None))
|
|
2537
|
+
if root is None:
|
|
2538
|
+
return 0 # two docs roots side by side: cannot tell which governs
|
|
2493
2539
|
try:
|
|
2494
2540
|
rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
|
|
2495
2541
|
except ValueError:
|
|
@@ -2532,10 +2578,24 @@ VERDICT_EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
|
2532
2578
|
VERDICT_MAX_BYTES = 256 * 1024 * 1024 # a larger transcript: cannot afford to tell
|
|
2533
2579
|
VERDICT_LINE_MAX = 1024 * 1024 # assistant lines are small; skip the rest unparsed
|
|
2534
2580
|
# Markdown emphasis between the word and the colon ("**router**: no match") and
|
|
2535
|
-
# "Router verdict:" are the same declaration
|
|
2581
|
+
# "Router verdict:" are the same declaration.
|
|
2536
2582
|
_VERDICT_ROUTER_RE = re.compile(r"router(?:[\s*_`]+verdict)?[\s*_`]*:(.*)", re.IGNORECASE)
|
|
2583
|
+
# A declaration line may open with markdown: emphasis, quote, heading, bullet or a
|
|
2584
|
+
# numbered item ("## Level: L1", "1. Level: L1", "• router: no match").
|
|
2585
|
+
_LINE_OPEN = r"^[ \t>*_`#+•-]*(?:\d+[.)][ \t*_`]*)?"
|
|
2586
|
+
_LEVEL_WORD = r"(?:level|livello|niveau|nivel|stufe)\b[ \t*_`:·—–-]*(?:è[ \t]+)?"
|
|
2587
|
+
# ...and only on a line that opens with `router` or with a level (`L2`, `Spike`): a
|
|
2588
|
+
# verdict quoted inside a sentence is not one.
|
|
2589
|
+
_VERDICT_LINE_RE = re.compile(
|
|
2590
|
+
_LINE_OPEN + r"(?:router\b|" + _LEVEL_WORD + r"(?:(?:research[ \t]+)?spike|[A-Z][123])\b)", re.IGNORECASE)
|
|
2537
2591
|
_VERDICT_NONE_RE = re.compile(r"[\s`*_\"'(]*(no match|absent)", re.IGNORECASE)
|
|
2538
|
-
|
|
2592
|
+
# A level is DECLARED by a line that opens with the level word, in the form Rule
|
|
2593
|
+
# Zero prescribes -- level 1 alone, 2/3 with `router` on the same line -- so a level
|
|
2594
|
+
# quoted inside a sentence is not one. The letter is the lens's own (`L1`, `E1`):
|
|
2595
|
+
# groups are (letter, "1" or "", "2"/"3" or "").
|
|
2596
|
+
_DECLARATION_RE = re.compile(
|
|
2597
|
+
_LINE_OPEN + _LEVEL_WORD +
|
|
2598
|
+
r"([A-Z])(?:(1)\b|([23])\b(?=[^\n]*\brouter\b))", re.IGNORECASE | re.MULTILINE)
|
|
2539
2599
|
_GUIDE_NAME_RE = re.compile(r"GUIDE_[A-Za-z0-9_-]+\.md")
|
|
2540
2600
|
|
|
2541
2601
|
|
|
@@ -2545,7 +2605,7 @@ def transcript_facts(path):
|
|
|
2545
2605
|
|
|
2546
2606
|
Returns {"assistant": entries parsed, "sidechain": a subagent's own transcript
|
|
2547
2607
|
(Claude Code marks every entry of one `isSidechain`), "declared": a router verdict seen,
|
|
2548
|
-
"trivial": a `Level:
|
|
2608
|
+
"trivial": a level-1 declaration seen (`Level: L1`, `E1`), "named": guides the verdicts name,
|
|
2549
2609
|
"read": guide names that appeared in a non-edit tool's input}."""
|
|
2550
2610
|
facts = {"assistant": 0, "sidechain": False, "declared": False, "trivial": False,
|
|
2551
2611
|
"named": set(), "read": set()}
|
|
@@ -2574,10 +2634,11 @@ def transcript_facts(path):
|
|
|
2574
2634
|
continue
|
|
2575
2635
|
if block.get("type") == "text":
|
|
2576
2636
|
for line in str(block.get("text") or "").splitlines():
|
|
2577
|
-
|
|
2637
|
+
d = _DECLARATION_RE.match(line)
|
|
2638
|
+
if d and d.group(2):
|
|
2578
2639
|
facts["trivial"] = True
|
|
2579
2640
|
m = _VERDICT_ROUTER_RE.search(line)
|
|
2580
|
-
if m:
|
|
2641
|
+
if m and (d or _VERDICT_LINE_RE.match(line)):
|
|
2581
2642
|
facts["declared"] = True
|
|
2582
2643
|
if not _VERDICT_NONE_RE.match(m.group(1)):
|
|
2583
2644
|
facts["named"].update(_GUIDE_NAME_RE.findall(m.group(1)))
|
|
@@ -2590,15 +2651,19 @@ def transcript_facts(path):
|
|
|
2590
2651
|
return facts
|
|
2591
2652
|
|
|
2592
2653
|
|
|
2593
|
-
def verdict_reason(payload, root):
|
|
2594
|
-
"""The refusal line for this edit event, or None when the edit may proceed.
|
|
2654
|
+
def verdict_reason(payload, root=None, docs_name=None):
|
|
2655
|
+
"""The refusal line for this edit event, or None when the edit may proceed.
|
|
2656
|
+
Without a root, the project is the one holding the edited file (hook_project)."""
|
|
2595
2657
|
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
2596
2658
|
return None
|
|
2597
2659
|
tool_input = payload.get("tool_input") or {}
|
|
2598
2660
|
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
2599
2661
|
transcript = payload.get("transcript_path")
|
|
2600
|
-
if not target or not transcript:
|
|
2662
|
+
if not isinstance(target, str) or not target or not transcript:
|
|
2601
2663
|
return None
|
|
2664
|
+
root = hook_project(target, root, docs_name)
|
|
2665
|
+
if root is None:
|
|
2666
|
+
return None # two docs roots side by side: cannot tell which governs
|
|
2602
2667
|
try:
|
|
2603
2668
|
Path(target).resolve().relative_to(root)
|
|
2604
2669
|
except (ValueError, OSError):
|
|
@@ -2636,8 +2701,8 @@ def cmd_verdict(args):
|
|
|
2636
2701
|
payload = read_hook_payload()
|
|
2637
2702
|
if payload is None:
|
|
2638
2703
|
return 0
|
|
2639
|
-
root = Path(args.root).resolve() if args.root else
|
|
2640
|
-
reason = verdict_reason(payload, root)
|
|
2704
|
+
root = Path(args.root).resolve() if args.root else None
|
|
2705
|
+
reason = verdict_reason(payload, root, getattr(args, "docs_dir", None))
|
|
2641
2706
|
if reason:
|
|
2642
2707
|
print(json.dumps({"hookSpecificOutput": {
|
|
2643
2708
|
"hookEventName": "PreToolUse",
|
|
@@ -2648,6 +2713,460 @@ def cmd_verdict(args):
|
|
|
2648
2713
|
return 0
|
|
2649
2714
|
|
|
2650
2715
|
|
|
2716
|
+
# -------------------------------------------------------------- constraints
|
|
2717
|
+
# F-085: the constraint reader. It reads what an ANALYSIS already states in
|
|
2718
|
+
# extractable form -- id'd use cases, spec items and threats, and the rows of
|
|
2719
|
+
# the `### Preservation constraints` table -- and binds each to the files it
|
|
2720
|
+
# names. It names no feature and computes nothing: no count, rank or marker
|
|
2721
|
+
# reaches the output, and every view ends with CONSTRAINTS_SENTENCE naming what
|
|
2722
|
+
# it could not extract, so a short view never reads as a complete one.
|
|
2723
|
+
|
|
2724
|
+
CONSTRAINT_SECTIONS = (("## Use Cases",), ("## Functional Spec",), SECURITY_SECTION)
|
|
2725
|
+
INTERFACE_SECTION = ("## Interface Contract",)
|
|
2726
|
+
IMPACT_SECTION = ANALYSIS_SECTIONS[2]
|
|
2727
|
+
PRESERVATION_HEADING = "### Preservation constraints"
|
|
2728
|
+
NO_SYMBOL_LINE = "No existing symbol changed."
|
|
2729
|
+
CONSTRAINTS_SENTENCE = ("This view shows only the constraints written in extractable "
|
|
2730
|
+
"form and never replaces the ANALYSIS")
|
|
2731
|
+
CONSTRAINT_LINE_MAX = 300
|
|
2732
|
+
|
|
2733
|
+
_ITEM_RE = re.compile(r"^\s*([-*+]\s+|#{3,4}\s+|\|\s*)(?:\*\*|__)?(?:UC|FS|T)\d+\b")
|
|
2734
|
+
_P_ROW_RE = re.compile(r"^\|\s*(?:\*\*)?(P\d+)\b")
|
|
2735
|
+
_ITEM_ID_RE = re.compile(r"(UC|FS|T)\d+")
|
|
2736
|
+
_CITE_RE = re.compile(r"\b(UC|FS|T)(\d+)(?:\s*[–-]\s*(?:UC|FS|T)?(\d+))?\b")
|
|
2737
|
+
_BACKTICK_RE = re.compile(r"`([^`\n]+)`")
|
|
2738
|
+
_PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9_.\-/]+(?:::[A-Za-z0-9_.]+)?$")
|
|
2739
|
+
_PATH_EXT_RE = re.compile(r"[^./]\.[A-Za-z0-9]+$")
|
|
2740
|
+
_SENTENCE_END_RE = re.compile(r"[.!?](?=\s|$)")
|
|
2741
|
+
_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
|
2742
|
+
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
def _norm_rel(path):
|
|
2746
|
+
"""Repository-relative, forward slashes, case-folded where the filesystem is."""
|
|
2747
|
+
p = path.replace("\\", "/")
|
|
2748
|
+
while p.startswith("./"):
|
|
2749
|
+
p = p[2:]
|
|
2750
|
+
return p.casefold() if os.name == "nt" else p
|
|
2751
|
+
|
|
2752
|
+
|
|
2753
|
+
def _path_tokens(text, need_slash):
|
|
2754
|
+
"""The backticked file paths in `text`; `path::symbol` yields its path. An elided
|
|
2755
|
+
(`...`), globbed, placeholder or parent-relative path binds nothing; in prose
|
|
2756
|
+
(`need_slash`) a bare file name is a mention, not a binding."""
|
|
2757
|
+
out = set()
|
|
2758
|
+
for token in _BACKTICK_RE.findall(text or ""):
|
|
2759
|
+
t = token.strip()
|
|
2760
|
+
if "..." in t or "*" in t or t.startswith("/") or not _PATH_TOKEN_RE.match(t):
|
|
2761
|
+
continue
|
|
2762
|
+
path = t.split("::", 1)[0]
|
|
2763
|
+
while path.startswith("./"):
|
|
2764
|
+
path = path[2:]
|
|
2765
|
+
if ".." in path.split("/") or not _PATH_EXT_RE.search(path):
|
|
2766
|
+
continue
|
|
2767
|
+
if need_slash and "/" not in path:
|
|
2768
|
+
continue
|
|
2769
|
+
out.add(_norm_rel(path))
|
|
2770
|
+
return out
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
def _first_sentence(text):
|
|
2774
|
+
"""Up to the first sentence end outside a backticked span (so `a.py` never ends
|
|
2775
|
+
one); a bold title such as `**FS1 Ids.**` ends nothing either."""
|
|
2776
|
+
masked = _BACKTICK_RE.sub(lambda m: "`" + "x" * len(m.group(1)) + "`", text)
|
|
2777
|
+
m = _SENTENCE_END_RE.search(masked)
|
|
2778
|
+
return text[:m.end()] if m else text
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
def _cells(row):
|
|
2782
|
+
return [c.strip() for c in _CELL_SPLIT_RE.split(row.strip().strip("|"))]
|
|
2783
|
+
|
|
2784
|
+
|
|
2785
|
+
def _cited_ids(text):
|
|
2786
|
+
"""Ids cited in `text`, a range such as `FS3–FS7` expanded."""
|
|
2787
|
+
out = set()
|
|
2788
|
+
for prefix, start, end in _CITE_RE.findall(text or ""):
|
|
2789
|
+
a = int(start)
|
|
2790
|
+
b = int(end) if end else a
|
|
2791
|
+
if b < a or b - a > 50:
|
|
2792
|
+
b = a
|
|
2793
|
+
out.update(f"{prefix}{n}" for n in range(a, b + 1))
|
|
2794
|
+
return out
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
def parse_constraints(text):
|
|
2798
|
+
"""Every constraint `text` states in extractable form, in document order: a dict
|
|
2799
|
+
with `id`, `kind` (`item` or `P`), `section` (its `## ` heading), `line`
|
|
2800
|
+
(1-based), `statement`, `bindings` (normalized paths), `evidence` and
|
|
2801
|
+
`released` (a P row's release reference, or None)."""
|
|
2802
|
+
lines = (text or "").splitlines()
|
|
2803
|
+
items, impact_rows = [], []
|
|
2804
|
+
section, extracted, impact, preservation = None, False, False, False
|
|
2805
|
+
for n, line in enumerate(lines):
|
|
2806
|
+
if line.startswith("## "):
|
|
2807
|
+
section = line.strip()
|
|
2808
|
+
extracted = any(line.startswith(a) for s in CONSTRAINT_SECTIONS for a in s)
|
|
2809
|
+
impact = any(line.startswith(a) for a in IMPACT_SECTION)
|
|
2810
|
+
preservation = False
|
|
2811
|
+
continue
|
|
2812
|
+
if impact:
|
|
2813
|
+
if line.startswith("### "):
|
|
2814
|
+
preservation = line.strip().lower() == PRESERVATION_HEADING.lower()
|
|
2815
|
+
continue
|
|
2816
|
+
if not line.lstrip().startswith("|"):
|
|
2817
|
+
continue
|
|
2818
|
+
cells = _cells(line)
|
|
2819
|
+
if preservation:
|
|
2820
|
+
m = _P_ROW_RE.match(line.strip())
|
|
2821
|
+
if m and len(cells) >= 5:
|
|
2822
|
+
must = cells[3]
|
|
2823
|
+
released = None
|
|
2824
|
+
if must.startswith("RELEASED (") and ")" in must:
|
|
2825
|
+
released = must[len("RELEASED ("):must.index(")")]
|
|
2826
|
+
items.append({"id": m.group(1), "kind": "P", "section": section,
|
|
2827
|
+
"line": n + 1, "statement": must,
|
|
2828
|
+
"bindings": _path_tokens(cells[1], False),
|
|
2829
|
+
"evidence": cells[4], "released": released})
|
|
2830
|
+
elif cells:
|
|
2831
|
+
paths = _path_tokens(cells[0], False)
|
|
2832
|
+
if paths:
|
|
2833
|
+
impact_rows.append((paths, _cited_ids(" | ".join(cells[1:]))))
|
|
2834
|
+
continue
|
|
2835
|
+
if not extracted:
|
|
2836
|
+
continue
|
|
2837
|
+
m = _ITEM_RE.match(line)
|
|
2838
|
+
if not m:
|
|
2839
|
+
continue
|
|
2840
|
+
marker = m.group(1).strip()
|
|
2841
|
+
body = []
|
|
2842
|
+
if not marker.startswith("|"):
|
|
2843
|
+
for nxt in lines[n + 1:]:
|
|
2844
|
+
if nxt.startswith("#") or _ITEM_RE.match(nxt):
|
|
2845
|
+
break
|
|
2846
|
+
if marker.startswith("#"):
|
|
2847
|
+
if nxt.strip():
|
|
2848
|
+
body.append(nxt.strip())
|
|
2849
|
+
elif nxt.strip() and nxt[:1].isspace():
|
|
2850
|
+
body.append(nxt.strip())
|
|
2851
|
+
else:
|
|
2852
|
+
break
|
|
2853
|
+
if marker.startswith("|"):
|
|
2854
|
+
statement = _first_sentence(" | ".join(_cells(line)))
|
|
2855
|
+
whole = line
|
|
2856
|
+
elif marker.startswith("#"):
|
|
2857
|
+
title = line.lstrip("#").strip()
|
|
2858
|
+
rest = " ".join(body)
|
|
2859
|
+
statement = title + (" — " + _first_sentence(rest) if rest else "")
|
|
2860
|
+
whole = " ".join([line] + body)
|
|
2861
|
+
else:
|
|
2862
|
+
joined = " ".join([line.strip()[len(marker):].strip()] + body)
|
|
2863
|
+
statement = _first_sentence(joined)
|
|
2864
|
+
whole = joined
|
|
2865
|
+
items.append({"id": _ITEM_ID_RE.search(line).group(0), "kind": "item",
|
|
2866
|
+
"section": section, "line": n + 1, "statement": statement,
|
|
2867
|
+
"bindings": _path_tokens(whole, True), "evidence": None,
|
|
2868
|
+
"released": None})
|
|
2869
|
+
for paths, ids in impact_rows:
|
|
2870
|
+
for c in items:
|
|
2871
|
+
if c["kind"] == "item" and c["id"] in ids:
|
|
2872
|
+
c["bindings"] |= paths
|
|
2873
|
+
return items
|
|
2874
|
+
|
|
2875
|
+
|
|
2876
|
+
def has_preservation_record(impact_body):
|
|
2877
|
+
"""True when an Impact body holds the preservation table or the no-symbol line."""
|
|
2878
|
+
return any(b.strip().lower() == PRESERVATION_HEADING.lower() or b.strip() == NO_SYMBOL_LINE
|
|
2879
|
+
for b in (impact_body or "").splitlines())
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
|
+
def unread_sections(text, items=None):
|
|
2883
|
+
"""The `## ` headings the view could not extract, in document order: the
|
|
2884
|
+
Interface Contract (its items carry no id), an extracted section holding prose
|
|
2885
|
+
but no id'd item, and an Impact with neither the preservation table nor the
|
|
2886
|
+
no-symbol line."""
|
|
2887
|
+
items = parse_constraints(text) if items is None else items
|
|
2888
|
+
homes = {c["section"] for c in items if c["kind"] == "item"}
|
|
2889
|
+
lines = (text or "").splitlines()
|
|
2890
|
+
names = []
|
|
2891
|
+
for i, line in enumerate(lines):
|
|
2892
|
+
if not line.startswith("## "):
|
|
2893
|
+
continue
|
|
2894
|
+
body = []
|
|
2895
|
+
for nxt in lines[i + 1:]:
|
|
2896
|
+
if nxt.startswith("## "):
|
|
2897
|
+
break
|
|
2898
|
+
body.append(nxt)
|
|
2899
|
+
heading = line.strip()
|
|
2900
|
+
if any(line.startswith(a) for a in INTERFACE_SECTION):
|
|
2901
|
+
names.append(heading)
|
|
2902
|
+
elif any(line.startswith(a) for s in CONSTRAINT_SECTIONS for a in s):
|
|
2903
|
+
if any(b.strip() for b in body) and heading not in homes:
|
|
2904
|
+
names.append(heading)
|
|
2905
|
+
elif any(line.startswith(a) for a in IMPACT_SECTION):
|
|
2906
|
+
if not has_preservation_record("\n".join(body)):
|
|
2907
|
+
names.append(heading)
|
|
2908
|
+
return names
|
|
2909
|
+
|
|
2910
|
+
|
|
2911
|
+
def constraints_sentence(names):
|
|
2912
|
+
if not names:
|
|
2913
|
+
return CONSTRAINTS_SENTENCE + "."
|
|
2914
|
+
return CONSTRAINTS_SENTENCE + "; read in full: " + ", ".join(f"`{n}`" for n in names) + "."
|
|
2915
|
+
|
|
2916
|
+
|
|
2917
|
+
def _clip(line):
|
|
2918
|
+
line = _CONTROL_RE.sub("", line)
|
|
2919
|
+
return line if len(line) <= CONSTRAINT_LINE_MAX else line[:CONSTRAINT_LINE_MAX - 1] + "…"
|
|
2920
|
+
|
|
2921
|
+
|
|
2922
|
+
def constraint_lines(home, items, grouped):
|
|
2923
|
+
"""FS4's lines: `ID — statement — home:line`, a P row followed by its evidence;
|
|
2924
|
+
`grouped` puts each home heading before its first item."""
|
|
2925
|
+
out, current = [], None
|
|
2926
|
+
for c in items:
|
|
2927
|
+
if grouped and c["section"] != current:
|
|
2928
|
+
current = c["section"]
|
|
2929
|
+
out.append(_clip(current))
|
|
2930
|
+
out.append(_clip(f"{c['id']} — {c['statement']} — {home}:{c['line']}"))
|
|
2931
|
+
if c["kind"] == "P" and c["evidence"]:
|
|
2932
|
+
out.append(_clip(f" evidence: {c['evidence']}"))
|
|
2933
|
+
return out
|
|
2934
|
+
|
|
2935
|
+
|
|
2936
|
+
def unit_constraints(root, unit):
|
|
2937
|
+
"""[(path, meta, items, unread)] for the ANALYSIS files of `unit` -- an id such
|
|
2938
|
+
as F-001, or a file name with or without `.md`; [] when none matches."""
|
|
2939
|
+
key = str(unit or "").strip()
|
|
2940
|
+
name = key if key.endswith(".md") else key + ".md"
|
|
2941
|
+
out = []
|
|
2942
|
+
for p, meta, text in list_analyses(root):
|
|
2943
|
+
m = UNIT_ID_RE.search(str(meta.get("id") or ""))
|
|
2944
|
+
if p.name == name or (m and m.group(1) == key):
|
|
2945
|
+
items = parse_constraints(text)
|
|
2946
|
+
out.append((p, meta, items, unread_sections(text, items)))
|
|
2947
|
+
return out
|
|
2948
|
+
|
|
2949
|
+
|
|
2950
|
+
def project_relative(root, path):
|
|
2951
|
+
"""`path` relative to `root`, normalized as bindings are, or None outside it."""
|
|
2952
|
+
raw = str(path or "").replace("\\", "/")
|
|
2953
|
+
if not raw:
|
|
2954
|
+
return None
|
|
2955
|
+
p = Path(raw)
|
|
2956
|
+
if not p.is_absolute():
|
|
2957
|
+
p = Path.cwd() / p
|
|
2958
|
+
try:
|
|
2959
|
+
rel = p.resolve().relative_to(Path(root).resolve())
|
|
2960
|
+
except (ValueError, OSError):
|
|
2961
|
+
return None
|
|
2962
|
+
return _norm_rel(rel.as_posix())
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
def open_code_units(root):
|
|
2966
|
+
"""[(path, meta, text)] for the tree's open L3 units of the code domain. Open
|
|
2967
|
+
units come from `_open_units`, unchanged (P1)."""
|
|
2968
|
+
if not ai_path(root).is_dir():
|
|
2969
|
+
return []
|
|
2970
|
+
open_names = {n for names in _open_units(root).values() for n in names}
|
|
2971
|
+
if not open_names:
|
|
2972
|
+
return []
|
|
2973
|
+
default = project_default_domain(root)
|
|
2974
|
+
return [(p, meta, text) for p, meta, text in list_analyses(root)
|
|
2975
|
+
if p.name in open_names
|
|
2976
|
+
and str(meta.get("level", "")).upper() == "L3"
|
|
2977
|
+
and meta.get("status") in ("PLANNED", "IN_PROGRESS")
|
|
2978
|
+
and resolve_domain(meta, default)[0] == "code"]
|
|
2979
|
+
|
|
2980
|
+
|
|
2981
|
+
def file_constraints(root, path):
|
|
2982
|
+
"""[(path, meta, bound items, unread)] for each open L3 unit of the code domain
|
|
2983
|
+
with an unreleased constraint bound to `path`."""
|
|
2984
|
+
rel = project_relative(root, path)
|
|
2985
|
+
if rel is None:
|
|
2986
|
+
return []
|
|
2987
|
+
out = []
|
|
2988
|
+
for p, meta, text in open_code_units(root):
|
|
2989
|
+
items = parse_constraints(text)
|
|
2990
|
+
bound = [c for c in items if not c["released"] and rel in c["bindings"]]
|
|
2991
|
+
if bound:
|
|
2992
|
+
out.append((p, meta, bound, unread_sections(text, items)))
|
|
2993
|
+
return out
|
|
2994
|
+
|
|
2995
|
+
|
|
2996
|
+
# The recall hook (FS6). It informs, never decides: its JSON carries no
|
|
2997
|
+
# `permissionDecision`, so the user's permission flow is the one without it
|
|
2998
|
+
# (P6). A level is read with the verdict hook's `_DECLARATION_RE`; only the code
|
|
2999
|
+
# lens's `L` levels count here.
|
|
3000
|
+
CONSTRAINTS_HOOK_LINES = 15
|
|
3001
|
+
_WORK_TOOLS = VERDICT_EDIT_TOOLS + ("Read",)
|
|
3002
|
+
|
|
3003
|
+
|
|
3004
|
+
def transcript_work(path):
|
|
3005
|
+
"""What the session transcript shows about the work, or None when it cannot be
|
|
3006
|
+
read within the verdict hook's bounds: {"assistant": entries parsed,
|
|
3007
|
+
"sidechain": a subagent's transcript, "level": the last declared level ("1",
|
|
3008
|
+
"2", "3") or None, "touched": normalized absolute paths this session read or
|
|
3009
|
+
wrote}."""
|
|
3010
|
+
work = {"assistant": 0, "sidechain": False, "level": None, "touched": set()}
|
|
3011
|
+
try:
|
|
3012
|
+
p = Path(path)
|
|
3013
|
+
if not p.is_file() or p.stat().st_size > VERDICT_MAX_BYTES:
|
|
3014
|
+
return None
|
|
3015
|
+
with p.open("rb") as fh:
|
|
3016
|
+
for raw in fh:
|
|
3017
|
+
if len(raw) > VERDICT_LINE_MAX or b'"assistant"' not in raw:
|
|
3018
|
+
continue
|
|
3019
|
+
try:
|
|
3020
|
+
entry = json.loads(raw.decode("utf-8", errors="replace"))
|
|
3021
|
+
except ValueError:
|
|
3022
|
+
continue
|
|
3023
|
+
if not isinstance(entry, dict) or entry.get("type") != "assistant":
|
|
3024
|
+
continue
|
|
3025
|
+
content = (entry.get("message") or {}).get("content")
|
|
3026
|
+
if not isinstance(content, list):
|
|
3027
|
+
continue
|
|
3028
|
+
work["assistant"] += 1
|
|
3029
|
+
if entry.get("isSidechain") is True:
|
|
3030
|
+
work["sidechain"] = True
|
|
3031
|
+
for block in content:
|
|
3032
|
+
if not isinstance(block, dict):
|
|
3033
|
+
continue
|
|
3034
|
+
if block.get("type") == "text":
|
|
3035
|
+
for letter, one, other in _DECLARATION_RE.findall(
|
|
3036
|
+
str(block.get("text") or "")):
|
|
3037
|
+
if letter.upper() == "L":
|
|
3038
|
+
work["level"] = one or other
|
|
3039
|
+
elif block.get("type") == "tool_use" and block.get("name") in _WORK_TOOLS:
|
|
3040
|
+
inp = block.get("input") or {}
|
|
3041
|
+
target = inp.get("file_path") or inp.get("notebook_path")
|
|
3042
|
+
if isinstance(target, str) and target:
|
|
3043
|
+
try:
|
|
3044
|
+
work["touched"].add(_norm_rel(Path(target).resolve().as_posix()))
|
|
3045
|
+
except (OSError, ValueError):
|
|
3046
|
+
pass
|
|
3047
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
3048
|
+
return None
|
|
3049
|
+
return work
|
|
3050
|
+
|
|
3051
|
+
|
|
3052
|
+
def _hook_new_text(tool_input):
|
|
3053
|
+
"""The text an edit is about to write: Edit, Write, MultiEdit, NotebookEdit."""
|
|
3054
|
+
parts = [tool_input.get(k) for k in ("new_string", "content", "new_source")]
|
|
3055
|
+
for e in tool_input.get("edits") or []:
|
|
3056
|
+
if isinstance(e, dict):
|
|
3057
|
+
parts.append(e.get("new_string"))
|
|
3058
|
+
return "\n".join(x for x in parts if isinstance(x, str))
|
|
3059
|
+
|
|
3060
|
+
|
|
3061
|
+
def _unit_documents(root, p):
|
|
3062
|
+
"""Normalized absolute paths of a unit's ANALYSIS, request records and handoff:
|
|
3063
|
+
reading or writing any of them is working on the unit."""
|
|
3064
|
+
slug = p.stem[len("ANALYSIS_"):]
|
|
3065
|
+
docs = {p, ai_path(root, "audit", f"HANDOFF_{slug}.md")}
|
|
3066
|
+
req = ai_path(root, "audit", "requests")
|
|
3067
|
+
if req.is_dir():
|
|
3068
|
+
for r in req.glob("REQUEST_*.md"):
|
|
3069
|
+
meta = load_frontmatter(read_text(r).splitlines())
|
|
3070
|
+
if str(meta.get("analysis") or "").strip().strip("'\"`") == p.name:
|
|
3071
|
+
docs.add(r)
|
|
3072
|
+
docs.add(ai_path(root, "audit", f"HANDOFF_{r.stem[len('REQUEST_'):]}.md"))
|
|
3073
|
+
return {_norm_rel(d.resolve().as_posix()) for d in docs}
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
def constraints_hook_context(payload, root=None, docs_name=None):
|
|
3077
|
+
"""The context to recall before this edit, or None (FS6): an edit tool, outside
|
|
3078
|
+
a subagent, on a file of a project whose open L3 code unit binds it (or the
|
|
3079
|
+
unit's own ANALYSIS, about to name a path a P row binds -- UC6), by a session
|
|
3080
|
+
that worked on that unit and last declared L3."""
|
|
3081
|
+
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
3082
|
+
return None
|
|
3083
|
+
tool_input = payload.get("tool_input") or {}
|
|
3084
|
+
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
3085
|
+
transcript = payload.get("transcript_path")
|
|
3086
|
+
if not isinstance(target, str) or not target or not transcript:
|
|
3087
|
+
return None
|
|
3088
|
+
root = hook_project(target, root, docs_name)
|
|
3089
|
+
if root is None or not ai_path(root, "solutions").is_dir():
|
|
3090
|
+
return None
|
|
3091
|
+
found = file_constraints(root, target)
|
|
3092
|
+
rel = project_relative(root, target)
|
|
3093
|
+
named = _path_tokens(_hook_new_text(tool_input), False)
|
|
3094
|
+
if named and rel and rel.startswith(_norm_rel(f"{docs_dir()}/solutions/ANALYSIS_")):
|
|
3095
|
+
for p, meta, text in open_code_units(root):
|
|
3096
|
+
if _norm_rel(f"{docs_dir()}/solutions/{p.name}") != rel:
|
|
3097
|
+
continue
|
|
3098
|
+
items = parse_constraints(text)
|
|
3099
|
+
bound = [c for c in items if c["kind"] == "P"
|
|
3100
|
+
and not c["released"] and c["bindings"] & named]
|
|
3101
|
+
if bound and not any(f[0] == p for f in found):
|
|
3102
|
+
found.append((p, meta, bound, unread_sections(text, items)))
|
|
3103
|
+
if not found:
|
|
3104
|
+
return None
|
|
3105
|
+
work = transcript_work(transcript)
|
|
3106
|
+
if not work or not work["assistant"] or work["sidechain"] or work["level"] != "3":
|
|
3107
|
+
return None
|
|
3108
|
+
# One budget for the whole recall, whatever the number of units; a P line and
|
|
3109
|
+
# its evidence line are kept together or left out together.
|
|
3110
|
+
out, budget = [], CONSTRAINTS_HOOK_LINES
|
|
3111
|
+
for p, meta, items, unread in found:
|
|
3112
|
+
if not (_unit_documents(root, p) & work["touched"]):
|
|
3113
|
+
continue
|
|
3114
|
+
home = f"solutions/{p.name}"
|
|
3115
|
+
unit = meta.get("id") or p.stem
|
|
3116
|
+
out.append(_clip(f"[sdlc constraints] {unit} — {home}"))
|
|
3117
|
+
shown = 0
|
|
3118
|
+
for c in items:
|
|
3119
|
+
chunk = constraint_lines(home, [c], False)
|
|
3120
|
+
if len(chunk) > budget:
|
|
3121
|
+
break
|
|
3122
|
+
out.extend(chunk)
|
|
3123
|
+
budget -= len(chunk)
|
|
3124
|
+
shown += 1
|
|
3125
|
+
if shown < len(items):
|
|
3126
|
+
out.append(_clip(f"More: sdlc_check.py constraints --unit {unit}"))
|
|
3127
|
+
out.append(_clip(constraints_sentence(unread)))
|
|
3128
|
+
return "\n".join(out) if out else None
|
|
3129
|
+
|
|
3130
|
+
|
|
3131
|
+
def cmd_constraints_hook(args):
|
|
3132
|
+
"""PreToolUse hook: print a context-only JSON or nothing. Always 0 -- exit 2
|
|
3133
|
+
would block the edit, and the wired command adds `; exit 0` for validators
|
|
3134
|
+
that cannot run this at all (T2)."""
|
|
3135
|
+
try:
|
|
3136
|
+
payload = read_hook_payload()
|
|
3137
|
+
if payload is None:
|
|
3138
|
+
return 0
|
|
3139
|
+
root = Path(args.root).resolve() if args.root else None
|
|
3140
|
+
context = constraints_hook_context(payload, root, getattr(args, "docs_dir", None))
|
|
3141
|
+
if context:
|
|
3142
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
3143
|
+
"hookEventName": "PreToolUse",
|
|
3144
|
+
"additionalContext": context}}, ensure_ascii=False))
|
|
3145
|
+
except Exception:
|
|
3146
|
+
pass
|
|
3147
|
+
return 0
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
def cmd_constraints(root, args):
|
|
3151
|
+
if args.unit:
|
|
3152
|
+
found = unit_constraints(root, args.unit)
|
|
3153
|
+
if not found:
|
|
3154
|
+
print(f"[ERROR] unknown unit '{args.unit}': give an id such as F-001 "
|
|
3155
|
+
"or an ANALYSIS file name")
|
|
3156
|
+
return 1
|
|
3157
|
+
groups = [(p, meta, items, unread, True) for p, meta, items, unread in found]
|
|
3158
|
+
else:
|
|
3159
|
+
groups = [(p, meta, items, unread, False)
|
|
3160
|
+
for p, meta, items, unread in file_constraints(root, args.file)]
|
|
3161
|
+
for p, meta, items, unread, grouped in groups:
|
|
3162
|
+
home = f"solutions/{p.name}"
|
|
3163
|
+
print(_clip(f"{meta.get('id') or p.stem} — {home}"))
|
|
3164
|
+
for line in constraint_lines(home, items, grouped):
|
|
3165
|
+
print(line)
|
|
3166
|
+
print(_clip(constraints_sentence(unread)))
|
|
3167
|
+
return 0
|
|
3168
|
+
|
|
3169
|
+
|
|
2651
3170
|
# --------------------------------------------------------------------- plan
|
|
2652
3171
|
# Subagent Execution (Feature A). Zero-execution surface: this section and
|
|
2653
3172
|
# everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
|
|
@@ -2977,6 +3496,10 @@ def cmd_benefit(root):
|
|
|
2977
3496
|
rows, unparsed = parse_review_log(read_text(log))
|
|
2978
3497
|
not_due = [r for r in rows if _is_not_due(r["verdict"])]
|
|
2979
3498
|
rows = [r for r in rows if not _is_not_due(r["verdict"])]
|
|
3499
|
+
# F-085 FS10: a `post-merge` row is the owner's report of a defect that got
|
|
3500
|
+
# past every review -- not a review, so it stays out of every review figure.
|
|
3501
|
+
post_merge = [r for r in rows if r["tier"].lower().startswith("post-merge")]
|
|
3502
|
+
rows = [r for r in rows if not r["tier"].lower().startswith("post-merge")]
|
|
2980
3503
|
# `tier` carries the MOMENT in Standalone (design / closure) and the
|
|
2981
3504
|
# reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
|
|
2982
3505
|
# Hybrid row is a real review whose moment this column does not state, so
|
|
@@ -3001,11 +3524,14 @@ def cmd_benefit(root):
|
|
|
3001
3524
|
unstated_code += n
|
|
3002
3525
|
classified = moments["design"] + moments["closure"]
|
|
3003
3526
|
print("rows parsed: %d rows unparsed: %d%s"
|
|
3004
|
-
% (len(rows) + len(not_due), len(unparsed),
|
|
3527
|
+
% (len(rows) + len(not_due) + len(post_merge), len(unparsed),
|
|
3005
3528
|
(" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
|
|
3006
3529
|
if unparsed else ""))
|
|
3007
3530
|
print("reviews ruled not due (logged, counted in no figure below): %d"
|
|
3008
3531
|
% len(not_due))
|
|
3532
|
+
if post_merge:
|
|
3533
|
+
print("defects reported after merge (tier post-merge, not reviews, "
|
|
3534
|
+
"counted in no figure below): %d" % len(post_merge))
|
|
3009
3535
|
print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
|
|
3010
3536
|
% (len(classified), sum(unstated.values()),
|
|
3011
3537
|
(" (" + ", ".join("%s x%d" % (t, n) for t, n
|
|
@@ -3498,6 +4024,16 @@ def main(argv=None):
|
|
|
3498
4024
|
"or an unread named guide (fail-open, always exit 0)")
|
|
3499
4025
|
vp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
3500
4026
|
|
|
4027
|
+
cp = sub.add_parser("constraints", parents=[common],
|
|
4028
|
+
help="print the constraints an ANALYSIS states in extractable "
|
|
4029
|
+
"form: a unit's view, or those bound to a file")
|
|
4030
|
+
cg = cp.add_mutually_exclusive_group(required=True)
|
|
4031
|
+
cg.add_argument("--unit", help="unit id (e.g. F-001) or ANALYSIS file name")
|
|
4032
|
+
cg.add_argument("--file", help="file path: constraints of the open L3 code units bound to it")
|
|
4033
|
+
cg.add_argument("--hook", action="store_true",
|
|
4034
|
+
help="PreToolUse hook: read the payload from stdin, print context only "
|
|
4035
|
+
"(fail-open, always exit 0)")
|
|
4036
|
+
|
|
3501
4037
|
sub.add_parser("benefit", parents=[common],
|
|
3502
4038
|
help="report what the review gates caught (and what the "
|
|
3503
4039
|
"doctrine costs to read) -- a report, never a gate: "
|
|
@@ -3521,6 +4057,16 @@ def main(argv=None):
|
|
|
3521
4057
|
|
|
3522
4058
|
args = ap.parse_args(argv)
|
|
3523
4059
|
|
|
4060
|
+
# F-085: the edit hooks resolve their root and docs root from the edited file
|
|
4061
|
+
# (the hook runs in whatever directory the session moved to), so they are
|
|
4062
|
+
# dispatched before the working-directory resolution below.
|
|
4063
|
+
if args.cmd == "constraints" and args.hook:
|
|
4064
|
+
return cmd_constraints_hook(args)
|
|
4065
|
+
if args.cmd == "verdict":
|
|
4066
|
+
return cmd_verdict(args)
|
|
4067
|
+
if args.cmd == "gate":
|
|
4068
|
+
return cmd_gate(args)
|
|
4069
|
+
|
|
3524
4070
|
# Resolve the documentation root ONCE, before any command runs, so every
|
|
3525
4071
|
# surface -- paths, messages, generated headers, the gate's exempt prefix --
|
|
3526
4072
|
# names the same thing.
|
|
@@ -3539,19 +4085,11 @@ def main(argv=None):
|
|
|
3539
4085
|
# and says so rather than exiting non-zero.
|
|
3540
4086
|
print(f"[note] {exc}")
|
|
3541
4087
|
discovered, name = None, DEFAULT_DOCS_DIR
|
|
3542
|
-
elif args.cmd == "verdict":
|
|
3543
|
-
# F-076: a PreToolUse check that cannot tell which root governs refuses
|
|
3544
|
-
# nothing and says nothing -- its stdout is read as the hook's decision.
|
|
3545
|
-
return 0
|
|
3546
4088
|
else:
|
|
3547
4089
|
print(f"[ERROR] {exc}")
|
|
3548
4090
|
return 1
|
|
3549
4091
|
set_docs_dir(name)
|
|
3550
4092
|
|
|
3551
|
-
if args.cmd == "gate":
|
|
3552
|
-
return cmd_gate(args)
|
|
3553
|
-
if args.cmd == "verdict":
|
|
3554
|
-
return cmd_verdict(args)
|
|
3555
4093
|
if args.cmd == "orient":
|
|
3556
4094
|
return cmd_orient(args)
|
|
3557
4095
|
if args.cmd == "remind":
|
|
@@ -3570,6 +4108,8 @@ def main(argv=None):
|
|
|
3570
4108
|
return cmd_mark(root, args.paths, args.note)
|
|
3571
4109
|
if args.cmd == "benefit":
|
|
3572
4110
|
return cmd_benefit(root)
|
|
4111
|
+
if args.cmd == "constraints":
|
|
4112
|
+
return cmd_constraints(root, args)
|
|
3573
4113
|
if args.cmd == "migrate":
|
|
3574
4114
|
return cmd_migrate(root, args)
|
|
3575
4115
|
if args.cmd == "plan":
|