@antoneeo/agentic-sdlc-skill 1.48.0 → 1.50.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 +57 -0
- package/README.md +56 -15
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/init.js +6 -6
- package/scripts/lib.js +19 -4
- package/scripts/postinstall.js +13 -1
- package/scripts/preuninstall.js +4 -7
- package/skills/agentic-sdlc-skill/ENFORCEMENT.md +44 -6
- package/skills/agentic-sdlc-skill/SKILL.md +6 -4
- package/skills/agentic-sdlc-skill/debugging.md +5 -5
- package/skills/agentic-sdlc-skill/docs_site/app.js +7 -1
- package/skills/agentic-sdlc-skill/docs_site/strings.js +9 -0
- package/skills/agentic-sdlc-skill/docs_site/style.css +2 -0
- package/skills/agentic-sdlc-skill/elicitation.md +2 -2
- package/skills/agentic-sdlc-skill/guides.md +8 -0
- package/skills/agentic-sdlc-skill/review.md +37 -1
- package/skills/agentic-sdlc-skill/scripts/docs_site.py +17 -3
- package/skills/agentic-sdlc-skill/scripts/knowledge.py +925 -33
- package/skills/agentic-sdlc-skill/scripts/sdlc_check.py +1 -0
- package/skills/agentic-sdlc-skill/scripts/sdlc_core.py +609 -29
- package/skills/agentic-sdlc-skill/templates.md +28 -4
|
@@ -100,6 +100,10 @@ CHRONOLOGY_EPOCH = "2026-10-18"
|
|
|
100
100
|
# filled '## Guide check'. The day AFTER the rule was written, so the analyses
|
|
101
101
|
# opened that day by parallel units (which never saw the template) stay quiet.
|
|
102
102
|
GUIDE_CHECK_EPOCH = "2026-10-24"
|
|
103
|
+
# Owner design approval (F-087): an L3 started on/after this date owes a request-
|
|
104
|
+
# record entry labelled `design approved` before implementation. The day AFTER the
|
|
105
|
+
# rule was written, like GUIDE_CHECK_EPOCH.
|
|
106
|
+
DESIGN_APPROVAL_EPOCH = "2026-10-31"
|
|
103
107
|
NEXT_MAX_CHARS = 240 # this repository's longest `next:` was 214 at the epoch
|
|
104
108
|
ID_SCAN_MAX_REFS = 300 # A3 is skipped above it (one advisory says so, T9)
|
|
105
109
|
ID_SCAN_REFS_PER_CALL = 50 # keeps one git command line far below 32k chars
|
|
@@ -265,6 +269,7 @@ REQUIRED_CAPABILITIES = frozenset({
|
|
|
265
269
|
OPTIONAL_CAPABILITIES = frozenset({
|
|
266
270
|
"architect_pass", # does the component already exist? (code overlay)
|
|
267
271
|
"interaction_contract", # actor-facing surface spec between use cases and solution (code overlay)
|
|
272
|
+
"preservation_constraints", # the Impact's P table, and validate's note on its absence (code overlay)
|
|
268
273
|
"taxonomy_pass", # do the categories/topics already exist? (knowledge overlay)
|
|
269
274
|
"comprehension_guides", # source_kind: code maps of complex components
|
|
270
275
|
"tdd", # test-first discipline
|
|
@@ -574,12 +579,20 @@ def stray_shadow(solutions_dir, doc_key):
|
|
|
574
579
|
None)
|
|
575
580
|
|
|
576
581
|
|
|
582
|
+
def skip_dir(name):
|
|
583
|
+
"""The one definition of a directory that holds no project files: VCS and
|
|
584
|
+
dependency/build directories, every dot-directory, and the docs root (by
|
|
585
|
+
name, at any depth). `iter_files` and the knowledge layer's inventory both
|
|
586
|
+
walk by it, so the two can never disagree about what the project is."""
|
|
587
|
+
return name in SKIP_DIRS or name == docs_dir() or name.startswith(".")
|
|
588
|
+
|
|
589
|
+
|
|
577
590
|
def iter_files(target):
|
|
578
591
|
if target.is_file():
|
|
579
592
|
yield target
|
|
580
593
|
return
|
|
581
594
|
for dirpath, dirnames, filenames in os.walk(target):
|
|
582
|
-
dirnames[:] = [d for d in dirnames if
|
|
595
|
+
dirnames[:] = [d for d in dirnames if not skip_dir(d)]
|
|
583
596
|
for name in filenames:
|
|
584
597
|
yield Path(dirpath) / name
|
|
585
598
|
|
|
@@ -1470,17 +1483,50 @@ def id_advisories(root):
|
|
|
1470
1483
|
return out
|
|
1471
1484
|
|
|
1472
1485
|
|
|
1473
|
-
def
|
|
1474
|
-
"""True when an L3 ANALYSIS
|
|
1475
|
-
|
|
1476
|
-
shipped. PLANNED is exempt -- the review is due at the END of Phase 3, so an
|
|
1477
|
-
analysis still being drafted is not late."""
|
|
1486
|
+
def _l3_in_work_since(meta, epoch):
|
|
1487
|
+
"""True when an L3 ANALYSIS is in implementation or done and began on/after
|
|
1488
|
+
`epoch` (an ISO date string)."""
|
|
1478
1489
|
if meta.get("level", "").upper() != "L3":
|
|
1479
1490
|
return False
|
|
1480
1491
|
if meta.get("status") not in ("IN_PROGRESS", "COMPLETED"):
|
|
1481
1492
|
return False
|
|
1482
1493
|
started = parse_iso((meta.get("start_date") or "").strip().strip("'\""))
|
|
1483
|
-
return started is not None and started >= parse_iso(
|
|
1494
|
+
return started is not None and started >= parse_iso(epoch)
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def design_review_due(meta):
|
|
1498
|
+
"""True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
|
|
1499
|
+
implementation has started or finished, and it began on/after the gate
|
|
1500
|
+
shipped. PLANNED is exempt -- the review is due at the END of Phase 3, so an
|
|
1501
|
+
analysis still being drafted is not late."""
|
|
1502
|
+
return _l3_in_work_since(meta, DESIGN_REVIEW_EPOCH)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def design_approval_due(meta):
|
|
1506
|
+
"""True when an L3 ANALYSIS owes the owner's design approval (review.md
|
|
1507
|
+
section When a review is due): in implementation or done, begun on/after
|
|
1508
|
+
DESIGN_APPROVAL_EPOCH. PLANNED is exempt -- the question comes after the PASS."""
|
|
1509
|
+
return _l3_in_work_since(meta, DESIGN_APPROVAL_EPOCH)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def requests_naming(root, analysis_name):
|
|
1513
|
+
"""The request records whose `analysis:` field names this ANALYSIS file."""
|
|
1514
|
+
req = ai_path(root, "audit", "requests")
|
|
1515
|
+
if not req.is_dir():
|
|
1516
|
+
return []
|
|
1517
|
+
return [r for r in sorted(req.glob("REQUEST_*.md"))
|
|
1518
|
+
if str(load_frontmatter(read_text(r).splitlines()).get("analysis") or "")
|
|
1519
|
+
.strip().strip("'\"`") == analysis_name]
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def design_approval_logged(root, analysis_name):
|
|
1523
|
+
"""True when a request record naming this ANALYSIS has an entry whose LABEL
|
|
1524
|
+
begins `design approved` (case-insensitive). Only the label is read, never
|
|
1525
|
+
the quoted words: a quotation is data, and a 'design approved?' inside a
|
|
1526
|
+
refusal approves nothing."""
|
|
1527
|
+
return any(label.lower().startswith("design approved")
|
|
1528
|
+
for r in requests_naming(root, analysis_name)
|
|
1529
|
+
for _, _, label, _ in request_entries(read_text(r))[0])
|
|
1484
1530
|
|
|
1485
1531
|
|
|
1486
1532
|
def review_logged(root, analysis_name):
|
|
@@ -1792,6 +1838,12 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1792
1838
|
advisories.append(f"{rel}: L3 in implementation with no design-review row in "
|
|
1793
1839
|
f"{review_log_rel()} -- the design was reviewed by nobody but its "
|
|
1794
1840
|
"author before code was written (review.md moment 1)")
|
|
1841
|
+
if (not hybrid and design_approval_due(meta)
|
|
1842
|
+
and not design_approval_logged(root, p.name)):
|
|
1843
|
+
advisories.append(f"{rel}: L3 in implementation with no design approval in its "
|
|
1844
|
+
"request record (no entry whose label begins 'design approved') -- the "
|
|
1845
|
+
"owner never said the design was what they meant before the "
|
|
1846
|
+
"work started (review.md, When a review is due)")
|
|
1795
1847
|
if ledger_due(meta) and not has_ledger_heading(text):
|
|
1796
1848
|
advisories.append(f"{rel}: L3 without '## Capability Ledger' -- the architect pass "
|
|
1797
1849
|
"left no record (architect.md); run it before the Impact")
|
|
@@ -1799,6 +1851,18 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1799
1851
|
advisories.append(f"{rel}: L3 without a filled '## Guide check' -- does a CURRENT "
|
|
1800
1852
|
"guide cover what this touches, and did understanding it force "
|
|
1801
1853
|
"a model of a complex component no guide covers (guides.md, section 1)?")
|
|
1854
|
+
# F-085 FS7: code lens only (R12: the lens, not merely the domain -- a kb
|
|
1855
|
+
# install validating a code-domain unit sees no change), open L3 only, a
|
|
1856
|
+
# note and never an error, so no `--strict` exit changes (P5).
|
|
1857
|
+
impact = section_body(text, IMPACT_SECTION)
|
|
1858
|
+
if (has_capability("preservation_constraints") and domain == "code"
|
|
1859
|
+
and str(level or "").upper() == "L3"
|
|
1860
|
+
and status in ("PLANNED", "IN_PROGRESS")
|
|
1861
|
+
and impact is not None and not has_preservation_record(impact)):
|
|
1862
|
+
advisories.append(f"{rel}: open L3 whose '## Impact' has neither the "
|
|
1863
|
+
f"'{PRESERVATION_HEADING}' table nor the line "
|
|
1864
|
+
f"'{NO_SYMBOL_LINE}' -- what existing code must keep "
|
|
1865
|
+
"doing is not extractable (templates.md)")
|
|
1802
1866
|
|
|
1803
1867
|
# Generated index aligned
|
|
1804
1868
|
hist = ai / "strategic" / "features_history.md"
|
|
@@ -2479,6 +2543,28 @@ def read_hook_payload():
|
|
|
2479
2543
|
return payload if isinstance(payload, dict) else None
|
|
2480
2544
|
|
|
2481
2545
|
|
|
2546
|
+
def hook_project(target, root=None, docs_name=None):
|
|
2547
|
+
"""The project root an edit hook judges, with its docs root name set, or None
|
|
2548
|
+
when two docs roots sit side by side. The hook runs in the session's current
|
|
2549
|
+
directory, which a `cd` moves (live probe 2026-10-27): the project AND its docs
|
|
2550
|
+
root name are the ones holding the edited file, never the ones around the
|
|
2551
|
+
working directory. An explicit root (--root) is kept as given."""
|
|
2552
|
+
start = Path(root) if root is not None else Path(target).resolve().parent
|
|
2553
|
+
try:
|
|
2554
|
+
found_root, name = resolve_docs_dir(argparse.Namespace(docs_dir=docs_name), start)
|
|
2555
|
+
except AmbiguousDocsRoot:
|
|
2556
|
+
return None
|
|
2557
|
+
set_docs_dir(name)
|
|
2558
|
+
if root is not None:
|
|
2559
|
+
return Path(root)
|
|
2560
|
+
if found_root is not None:
|
|
2561
|
+
return found_root
|
|
2562
|
+
found_root = find_project_root(start) # an explicit name: discovery did not run
|
|
2563
|
+
# No docs root above the edited file: the session's own project, as before the
|
|
2564
|
+
# hook resolved from the file -- the file's folder would unprotect a gated path.
|
|
2565
|
+
return found_root if ai_path(found_root).is_dir() else find_project_root()
|
|
2566
|
+
|
|
2567
|
+
|
|
2482
2568
|
def cmd_gate(args):
|
|
2483
2569
|
file_path = args.file or ""
|
|
2484
2570
|
if args.hook:
|
|
@@ -2487,9 +2573,12 @@ def cmd_gate(args):
|
|
|
2487
2573
|
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
2488
2574
|
except AttributeError:
|
|
2489
2575
|
return 0 # unparseable input: do not block
|
|
2490
|
-
if not file_path:
|
|
2576
|
+
if not isinstance(file_path, str) or not file_path:
|
|
2491
2577
|
return 0
|
|
2492
|
-
root = Path(args.root).resolve() if args.root else
|
|
2578
|
+
root = hook_project(file_path, Path(args.root).resolve() if args.root else None,
|
|
2579
|
+
getattr(args, "docs_dir", None))
|
|
2580
|
+
if root is None:
|
|
2581
|
+
return 0 # two docs roots side by side: cannot tell which governs
|
|
2493
2582
|
try:
|
|
2494
2583
|
rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
|
|
2495
2584
|
except ValueError:
|
|
@@ -2516,7 +2605,8 @@ def cmd_gate(args):
|
|
|
2516
2605
|
sys.stderr.write(
|
|
2517
2606
|
f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
|
|
2518
2607
|
"If your analysis already exists, set its frontmatter to 'status: IN_PROGRESS' "
|
|
2519
|
-
"(that flip is what opens the gate
|
|
2608
|
+
"(that flip is what opens the gate; at L3, only after the owner's design approval, "
|
|
2609
|
+
"review.md); otherwise write it first (Phase 3).\n")
|
|
2520
2610
|
return 2
|
|
2521
2611
|
|
|
2522
2612
|
|
|
@@ -2532,10 +2622,24 @@ VERDICT_EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
|
2532
2622
|
VERDICT_MAX_BYTES = 256 * 1024 * 1024 # a larger transcript: cannot afford to tell
|
|
2533
2623
|
VERDICT_LINE_MAX = 1024 * 1024 # assistant lines are small; skip the rest unparsed
|
|
2534
2624
|
# Markdown emphasis between the word and the colon ("**router**: no match") and
|
|
2535
|
-
# "Router verdict:" are the same declaration
|
|
2625
|
+
# "Router verdict:" are the same declaration.
|
|
2536
2626
|
_VERDICT_ROUTER_RE = re.compile(r"router(?:[\s*_`]+verdict)?[\s*_`]*:(.*)", re.IGNORECASE)
|
|
2627
|
+
# A declaration line may open with markdown: emphasis, quote, heading, bullet or a
|
|
2628
|
+
# numbered item ("## Level: L1", "1. Level: L1", "• router: no match").
|
|
2629
|
+
_LINE_OPEN = r"^[ \t>*_`#+•-]*(?:\d+[.)][ \t*_`]*)?"
|
|
2630
|
+
_LEVEL_WORD = r"(?:level|livello|niveau|nivel|stufe)\b[ \t*_`:·—–-]*(?:è[ \t]+)?"
|
|
2631
|
+
# ...and only on a line that opens with `router` or with a level (`L2`, `Spike`): a
|
|
2632
|
+
# verdict quoted inside a sentence is not one.
|
|
2633
|
+
_VERDICT_LINE_RE = re.compile(
|
|
2634
|
+
_LINE_OPEN + r"(?:router\b|" + _LEVEL_WORD + r"(?:(?:research[ \t]+)?spike|[A-Z][123])\b)", re.IGNORECASE)
|
|
2537
2635
|
_VERDICT_NONE_RE = re.compile(r"[\s`*_\"'(]*(no match|absent)", re.IGNORECASE)
|
|
2538
|
-
|
|
2636
|
+
# A level is DECLARED by a line that opens with the level word, in the form Rule
|
|
2637
|
+
# Zero prescribes -- level 1 alone, 2/3 with `router` on the same line -- so a level
|
|
2638
|
+
# quoted inside a sentence is not one. The letter is the lens's own (`L1`, `E1`):
|
|
2639
|
+
# groups are (letter, "1" or "", "2"/"3" or "").
|
|
2640
|
+
_DECLARATION_RE = re.compile(
|
|
2641
|
+
_LINE_OPEN + _LEVEL_WORD +
|
|
2642
|
+
r"([A-Z])(?:(1)\b|([23])\b(?=[^\n]*\brouter\b))", re.IGNORECASE | re.MULTILINE)
|
|
2539
2643
|
_GUIDE_NAME_RE = re.compile(r"GUIDE_[A-Za-z0-9_-]+\.md")
|
|
2540
2644
|
|
|
2541
2645
|
|
|
@@ -2545,7 +2649,7 @@ def transcript_facts(path):
|
|
|
2545
2649
|
|
|
2546
2650
|
Returns {"assistant": entries parsed, "sidechain": a subagent's own transcript
|
|
2547
2651
|
(Claude Code marks every entry of one `isSidechain`), "declared": a router verdict seen,
|
|
2548
|
-
"trivial": a `Level:
|
|
2652
|
+
"trivial": a level-1 declaration seen (`Level: L1`, `E1`), "named": guides the verdicts name,
|
|
2549
2653
|
"read": guide names that appeared in a non-edit tool's input}."""
|
|
2550
2654
|
facts = {"assistant": 0, "sidechain": False, "declared": False, "trivial": False,
|
|
2551
2655
|
"named": set(), "read": set()}
|
|
@@ -2574,10 +2678,11 @@ def transcript_facts(path):
|
|
|
2574
2678
|
continue
|
|
2575
2679
|
if block.get("type") == "text":
|
|
2576
2680
|
for line in str(block.get("text") or "").splitlines():
|
|
2577
|
-
|
|
2681
|
+
d = _DECLARATION_RE.match(line)
|
|
2682
|
+
if d and d.group(2):
|
|
2578
2683
|
facts["trivial"] = True
|
|
2579
2684
|
m = _VERDICT_ROUTER_RE.search(line)
|
|
2580
|
-
if m:
|
|
2685
|
+
if m and (d or _VERDICT_LINE_RE.match(line)):
|
|
2581
2686
|
facts["declared"] = True
|
|
2582
2687
|
if not _VERDICT_NONE_RE.match(m.group(1)):
|
|
2583
2688
|
facts["named"].update(_GUIDE_NAME_RE.findall(m.group(1)))
|
|
@@ -2590,15 +2695,19 @@ def transcript_facts(path):
|
|
|
2590
2695
|
return facts
|
|
2591
2696
|
|
|
2592
2697
|
|
|
2593
|
-
def verdict_reason(payload, root):
|
|
2594
|
-
"""The refusal line for this edit event, or None when the edit may proceed.
|
|
2698
|
+
def verdict_reason(payload, root=None, docs_name=None):
|
|
2699
|
+
"""The refusal line for this edit event, or None when the edit may proceed.
|
|
2700
|
+
Without a root, the project is the one holding the edited file (hook_project)."""
|
|
2595
2701
|
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
2596
2702
|
return None
|
|
2597
2703
|
tool_input = payload.get("tool_input") or {}
|
|
2598
2704
|
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
2599
2705
|
transcript = payload.get("transcript_path")
|
|
2600
|
-
if not target or not transcript:
|
|
2706
|
+
if not isinstance(target, str) or not target or not transcript:
|
|
2601
2707
|
return None
|
|
2708
|
+
root = hook_project(target, root, docs_name)
|
|
2709
|
+
if root is None:
|
|
2710
|
+
return None # two docs roots side by side: cannot tell which governs
|
|
2602
2711
|
try:
|
|
2603
2712
|
Path(target).resolve().relative_to(root)
|
|
2604
2713
|
except (ValueError, OSError):
|
|
@@ -2636,8 +2745,8 @@ def cmd_verdict(args):
|
|
|
2636
2745
|
payload = read_hook_payload()
|
|
2637
2746
|
if payload is None:
|
|
2638
2747
|
return 0
|
|
2639
|
-
root = Path(args.root).resolve() if args.root else
|
|
2640
|
-
reason = verdict_reason(payload, root)
|
|
2748
|
+
root = Path(args.root).resolve() if args.root else None
|
|
2749
|
+
reason = verdict_reason(payload, root, getattr(args, "docs_dir", None))
|
|
2641
2750
|
if reason:
|
|
2642
2751
|
print(json.dumps({"hookSpecificOutput": {
|
|
2643
2752
|
"hookEventName": "PreToolUse",
|
|
@@ -2648,6 +2757,456 @@ def cmd_verdict(args):
|
|
|
2648
2757
|
return 0
|
|
2649
2758
|
|
|
2650
2759
|
|
|
2760
|
+
# -------------------------------------------------------------- constraints
|
|
2761
|
+
# F-085: the constraint reader. It reads what an ANALYSIS already states in
|
|
2762
|
+
# extractable form -- id'd use cases, spec items and threats, and the rows of
|
|
2763
|
+
# the `### Preservation constraints` table -- and binds each to the files it
|
|
2764
|
+
# names. It names no feature and computes nothing: no count, rank or marker
|
|
2765
|
+
# reaches the output, and every view ends with CONSTRAINTS_SENTENCE naming what
|
|
2766
|
+
# it could not extract, so a short view never reads as a complete one.
|
|
2767
|
+
|
|
2768
|
+
CONSTRAINT_SECTIONS = (("## Use Cases",), ("## Functional Spec",), SECURITY_SECTION)
|
|
2769
|
+
INTERFACE_SECTION = ("## Interface Contract",)
|
|
2770
|
+
IMPACT_SECTION = ANALYSIS_SECTIONS[2]
|
|
2771
|
+
PRESERVATION_HEADING = "### Preservation constraints"
|
|
2772
|
+
NO_SYMBOL_LINE = "No existing symbol changed."
|
|
2773
|
+
CONSTRAINTS_SENTENCE = ("This view shows only the constraints written in extractable "
|
|
2774
|
+
"form and never replaces the ANALYSIS")
|
|
2775
|
+
CONSTRAINT_LINE_MAX = 300
|
|
2776
|
+
|
|
2777
|
+
_ITEM_RE = re.compile(r"^\s*([-*+]\s+|#{3,4}\s+|\|\s*)(?:\*\*|__)?(?:UC|FS|T)\d+\b")
|
|
2778
|
+
_P_ROW_RE = re.compile(r"^\|\s*(?:\*\*)?(P\d+)\b")
|
|
2779
|
+
_ITEM_ID_RE = re.compile(r"(UC|FS|T)\d+")
|
|
2780
|
+
_CITE_RE = re.compile(r"\b(UC|FS|T)(\d+)(?:\s*[–-]\s*(?:UC|FS|T)?(\d+))?\b")
|
|
2781
|
+
_BACKTICK_RE = re.compile(r"`([^`\n]+)`")
|
|
2782
|
+
_PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9_.\-/]+(?:::[A-Za-z0-9_.]+)?$")
|
|
2783
|
+
_PATH_EXT_RE = re.compile(r"[^./]\.[A-Za-z0-9]+$")
|
|
2784
|
+
_SENTENCE_END_RE = re.compile(r"[.!?](?=\s|$)")
|
|
2785
|
+
_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
|
2786
|
+
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
|
2787
|
+
|
|
2788
|
+
|
|
2789
|
+
def _norm_rel(path):
|
|
2790
|
+
"""Repository-relative, forward slashes, case-folded where the filesystem is."""
|
|
2791
|
+
p = path.replace("\\", "/")
|
|
2792
|
+
while p.startswith("./"):
|
|
2793
|
+
p = p[2:]
|
|
2794
|
+
return p.casefold() if os.name == "nt" else p
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
def _path_tokens(text, need_slash):
|
|
2798
|
+
"""The backticked file paths in `text`; `path::symbol` yields its path. An elided
|
|
2799
|
+
(`...`), globbed, placeholder or parent-relative path binds nothing; in prose
|
|
2800
|
+
(`need_slash`) a bare file name is a mention, not a binding."""
|
|
2801
|
+
out = set()
|
|
2802
|
+
for token in _BACKTICK_RE.findall(text or ""):
|
|
2803
|
+
t = token.strip()
|
|
2804
|
+
if "..." in t or "*" in t or t.startswith("/") or not _PATH_TOKEN_RE.match(t):
|
|
2805
|
+
continue
|
|
2806
|
+
path = t.split("::", 1)[0]
|
|
2807
|
+
while path.startswith("./"):
|
|
2808
|
+
path = path[2:]
|
|
2809
|
+
if ".." in path.split("/") or not _PATH_EXT_RE.search(path):
|
|
2810
|
+
continue
|
|
2811
|
+
if need_slash and "/" not in path:
|
|
2812
|
+
continue
|
|
2813
|
+
out.add(_norm_rel(path))
|
|
2814
|
+
return out
|
|
2815
|
+
|
|
2816
|
+
|
|
2817
|
+
def _first_sentence(text):
|
|
2818
|
+
"""Up to the first sentence end outside a backticked span (so `a.py` never ends
|
|
2819
|
+
one); a bold title such as `**FS1 Ids.**` ends nothing either."""
|
|
2820
|
+
masked = _BACKTICK_RE.sub(lambda m: "`" + "x" * len(m.group(1)) + "`", text)
|
|
2821
|
+
m = _SENTENCE_END_RE.search(masked)
|
|
2822
|
+
return text[:m.end()] if m else text
|
|
2823
|
+
|
|
2824
|
+
|
|
2825
|
+
def _cells(row):
|
|
2826
|
+
return [c.strip() for c in _CELL_SPLIT_RE.split(row.strip().strip("|"))]
|
|
2827
|
+
|
|
2828
|
+
|
|
2829
|
+
def _cited_ids(text):
|
|
2830
|
+
"""Ids cited in `text`, a range such as `FS3–FS7` expanded."""
|
|
2831
|
+
out = set()
|
|
2832
|
+
for prefix, start, end in _CITE_RE.findall(text or ""):
|
|
2833
|
+
a = int(start)
|
|
2834
|
+
b = int(end) if end else a
|
|
2835
|
+
if b < a or b - a > 50:
|
|
2836
|
+
b = a
|
|
2837
|
+
out.update(f"{prefix}{n}" for n in range(a, b + 1))
|
|
2838
|
+
return out
|
|
2839
|
+
|
|
2840
|
+
|
|
2841
|
+
def parse_constraints(text):
|
|
2842
|
+
"""Every constraint `text` states in extractable form, in document order: a dict
|
|
2843
|
+
with `id`, `kind` (`item` or `P`), `section` (its `## ` heading), `line`
|
|
2844
|
+
(1-based), `statement`, `bindings` (normalized paths), `evidence` and
|
|
2845
|
+
`released` (a P row's release reference, or None)."""
|
|
2846
|
+
lines = (text or "").splitlines()
|
|
2847
|
+
items, impact_rows = [], []
|
|
2848
|
+
section, extracted, impact, preservation = None, False, False, False
|
|
2849
|
+
for n, line in enumerate(lines):
|
|
2850
|
+
if line.startswith("## "):
|
|
2851
|
+
section = line.strip()
|
|
2852
|
+
extracted = any(line.startswith(a) for s in CONSTRAINT_SECTIONS for a in s)
|
|
2853
|
+
impact = any(line.startswith(a) for a in IMPACT_SECTION)
|
|
2854
|
+
preservation = False
|
|
2855
|
+
continue
|
|
2856
|
+
if impact:
|
|
2857
|
+
if line.startswith("### "):
|
|
2858
|
+
preservation = line.strip().lower() == PRESERVATION_HEADING.lower()
|
|
2859
|
+
continue
|
|
2860
|
+
if not line.lstrip().startswith("|"):
|
|
2861
|
+
continue
|
|
2862
|
+
cells = _cells(line)
|
|
2863
|
+
if preservation:
|
|
2864
|
+
m = _P_ROW_RE.match(line.strip())
|
|
2865
|
+
if m and len(cells) >= 5:
|
|
2866
|
+
must = cells[3]
|
|
2867
|
+
released = None
|
|
2868
|
+
if must.startswith("RELEASED (") and ")" in must:
|
|
2869
|
+
released = must[len("RELEASED ("):must.index(")")]
|
|
2870
|
+
items.append({"id": m.group(1), "kind": "P", "section": section,
|
|
2871
|
+
"line": n + 1, "statement": must,
|
|
2872
|
+
"bindings": _path_tokens(cells[1], False),
|
|
2873
|
+
"evidence": cells[4], "released": released})
|
|
2874
|
+
elif cells:
|
|
2875
|
+
paths = _path_tokens(cells[0], False)
|
|
2876
|
+
if paths:
|
|
2877
|
+
impact_rows.append((paths, _cited_ids(" | ".join(cells[1:]))))
|
|
2878
|
+
continue
|
|
2879
|
+
if not extracted:
|
|
2880
|
+
continue
|
|
2881
|
+
m = _ITEM_RE.match(line)
|
|
2882
|
+
if not m:
|
|
2883
|
+
continue
|
|
2884
|
+
marker = m.group(1).strip()
|
|
2885
|
+
body = []
|
|
2886
|
+
if not marker.startswith("|"):
|
|
2887
|
+
for nxt in lines[n + 1:]:
|
|
2888
|
+
if nxt.startswith("#") or _ITEM_RE.match(nxt):
|
|
2889
|
+
break
|
|
2890
|
+
if marker.startswith("#"):
|
|
2891
|
+
if nxt.strip():
|
|
2892
|
+
body.append(nxt.strip())
|
|
2893
|
+
elif nxt.strip() and nxt[:1].isspace():
|
|
2894
|
+
body.append(nxt.strip())
|
|
2895
|
+
else:
|
|
2896
|
+
break
|
|
2897
|
+
if marker.startswith("|"):
|
|
2898
|
+
statement = _first_sentence(" | ".join(_cells(line)))
|
|
2899
|
+
whole = line
|
|
2900
|
+
elif marker.startswith("#"):
|
|
2901
|
+
title = line.lstrip("#").strip()
|
|
2902
|
+
rest = " ".join(body)
|
|
2903
|
+
statement = title + (" — " + _first_sentence(rest) if rest else "")
|
|
2904
|
+
whole = " ".join([line] + body)
|
|
2905
|
+
else:
|
|
2906
|
+
joined = " ".join([line.strip()[len(marker):].strip()] + body)
|
|
2907
|
+
statement = _first_sentence(joined)
|
|
2908
|
+
whole = joined
|
|
2909
|
+
items.append({"id": _ITEM_ID_RE.search(line).group(0), "kind": "item",
|
|
2910
|
+
"section": section, "line": n + 1, "statement": statement,
|
|
2911
|
+
"bindings": _path_tokens(whole, True), "evidence": None,
|
|
2912
|
+
"released": None})
|
|
2913
|
+
for paths, ids in impact_rows:
|
|
2914
|
+
for c in items:
|
|
2915
|
+
if c["kind"] == "item" and c["id"] in ids:
|
|
2916
|
+
c["bindings"] |= paths
|
|
2917
|
+
return items
|
|
2918
|
+
|
|
2919
|
+
|
|
2920
|
+
def has_preservation_record(impact_body):
|
|
2921
|
+
"""True when an Impact body holds the preservation table or the no-symbol line."""
|
|
2922
|
+
return any(b.strip().lower() == PRESERVATION_HEADING.lower() or b.strip() == NO_SYMBOL_LINE
|
|
2923
|
+
for b in (impact_body or "").splitlines())
|
|
2924
|
+
|
|
2925
|
+
|
|
2926
|
+
def unread_sections(text, items=None):
|
|
2927
|
+
"""The `## ` headings the view could not extract, in document order: the
|
|
2928
|
+
Interface Contract (its items carry no id), an extracted section holding prose
|
|
2929
|
+
but no id'd item, and an Impact with neither the preservation table nor the
|
|
2930
|
+
no-symbol line."""
|
|
2931
|
+
items = parse_constraints(text) if items is None else items
|
|
2932
|
+
homes = {c["section"] for c in items if c["kind"] == "item"}
|
|
2933
|
+
lines = (text or "").splitlines()
|
|
2934
|
+
names = []
|
|
2935
|
+
for i, line in enumerate(lines):
|
|
2936
|
+
if not line.startswith("## "):
|
|
2937
|
+
continue
|
|
2938
|
+
body = []
|
|
2939
|
+
for nxt in lines[i + 1:]:
|
|
2940
|
+
if nxt.startswith("## "):
|
|
2941
|
+
break
|
|
2942
|
+
body.append(nxt)
|
|
2943
|
+
heading = line.strip()
|
|
2944
|
+
if any(line.startswith(a) for a in INTERFACE_SECTION):
|
|
2945
|
+
names.append(heading)
|
|
2946
|
+
elif any(line.startswith(a) for s in CONSTRAINT_SECTIONS for a in s):
|
|
2947
|
+
if any(b.strip() for b in body) and heading not in homes:
|
|
2948
|
+
names.append(heading)
|
|
2949
|
+
elif any(line.startswith(a) for a in IMPACT_SECTION):
|
|
2950
|
+
if not has_preservation_record("\n".join(body)):
|
|
2951
|
+
names.append(heading)
|
|
2952
|
+
return names
|
|
2953
|
+
|
|
2954
|
+
|
|
2955
|
+
def constraints_sentence(names):
|
|
2956
|
+
if not names:
|
|
2957
|
+
return CONSTRAINTS_SENTENCE + "."
|
|
2958
|
+
return CONSTRAINTS_SENTENCE + "; read in full: " + ", ".join(f"`{n}`" for n in names) + "."
|
|
2959
|
+
|
|
2960
|
+
|
|
2961
|
+
def _clip(line):
|
|
2962
|
+
line = _CONTROL_RE.sub("", line)
|
|
2963
|
+
return line if len(line) <= CONSTRAINT_LINE_MAX else line[:CONSTRAINT_LINE_MAX - 1] + "…"
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def constraint_lines(home, items, grouped):
|
|
2967
|
+
"""FS4's lines: `ID — statement — home:line`, a P row followed by its evidence;
|
|
2968
|
+
`grouped` puts each home heading before its first item."""
|
|
2969
|
+
out, current = [], None
|
|
2970
|
+
for c in items:
|
|
2971
|
+
if grouped and c["section"] != current:
|
|
2972
|
+
current = c["section"]
|
|
2973
|
+
out.append(_clip(current))
|
|
2974
|
+
out.append(_clip(f"{c['id']} — {c['statement']} — {home}:{c['line']}"))
|
|
2975
|
+
if c["kind"] == "P" and c["evidence"]:
|
|
2976
|
+
out.append(_clip(f" evidence: {c['evidence']}"))
|
|
2977
|
+
return out
|
|
2978
|
+
|
|
2979
|
+
|
|
2980
|
+
def unit_constraints(root, unit):
|
|
2981
|
+
"""[(path, meta, items, unread)] for the ANALYSIS files of `unit` -- an id such
|
|
2982
|
+
as F-001, or a file name with or without `.md`; [] when none matches."""
|
|
2983
|
+
key = str(unit or "").strip()
|
|
2984
|
+
name = key if key.endswith(".md") else key + ".md"
|
|
2985
|
+
out = []
|
|
2986
|
+
for p, meta, text in list_analyses(root):
|
|
2987
|
+
m = UNIT_ID_RE.search(str(meta.get("id") or ""))
|
|
2988
|
+
if p.name == name or (m and m.group(1) == key):
|
|
2989
|
+
items = parse_constraints(text)
|
|
2990
|
+
out.append((p, meta, items, unread_sections(text, items)))
|
|
2991
|
+
return out
|
|
2992
|
+
|
|
2993
|
+
|
|
2994
|
+
def project_relative(root, path):
|
|
2995
|
+
"""`path` relative to `root`, normalized as bindings are, or None outside it."""
|
|
2996
|
+
raw = str(path or "").replace("\\", "/")
|
|
2997
|
+
if not raw:
|
|
2998
|
+
return None
|
|
2999
|
+
p = Path(raw)
|
|
3000
|
+
if not p.is_absolute():
|
|
3001
|
+
p = Path.cwd() / p
|
|
3002
|
+
try:
|
|
3003
|
+
rel = p.resolve().relative_to(Path(root).resolve())
|
|
3004
|
+
except (ValueError, OSError):
|
|
3005
|
+
return None
|
|
3006
|
+
return _norm_rel(rel.as_posix())
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
def open_code_units(root):
|
|
3010
|
+
"""[(path, meta, text)] for the tree's open L3 units of the code domain. Open
|
|
3011
|
+
units come from `_open_units`, unchanged (P1)."""
|
|
3012
|
+
if not ai_path(root).is_dir():
|
|
3013
|
+
return []
|
|
3014
|
+
open_names = {n for names in _open_units(root).values() for n in names}
|
|
3015
|
+
if not open_names:
|
|
3016
|
+
return []
|
|
3017
|
+
default = project_default_domain(root)
|
|
3018
|
+
return [(p, meta, text) for p, meta, text in list_analyses(root)
|
|
3019
|
+
if p.name in open_names
|
|
3020
|
+
and str(meta.get("level", "")).upper() == "L3"
|
|
3021
|
+
and meta.get("status") in ("PLANNED", "IN_PROGRESS")
|
|
3022
|
+
and resolve_domain(meta, default)[0] == "code"]
|
|
3023
|
+
|
|
3024
|
+
|
|
3025
|
+
def file_constraints(root, path):
|
|
3026
|
+
"""[(path, meta, bound items, unread)] for each open L3 unit of the code domain
|
|
3027
|
+
with an unreleased constraint bound to `path`."""
|
|
3028
|
+
rel = project_relative(root, path)
|
|
3029
|
+
if rel is None:
|
|
3030
|
+
return []
|
|
3031
|
+
out = []
|
|
3032
|
+
for p, meta, text in open_code_units(root):
|
|
3033
|
+
items = parse_constraints(text)
|
|
3034
|
+
bound = [c for c in items if not c["released"] and rel in c["bindings"]]
|
|
3035
|
+
if bound:
|
|
3036
|
+
out.append((p, meta, bound, unread_sections(text, items)))
|
|
3037
|
+
return out
|
|
3038
|
+
|
|
3039
|
+
|
|
3040
|
+
# The recall hook (FS6). It informs, never decides: its JSON carries no
|
|
3041
|
+
# `permissionDecision`, so the user's permission flow is the one without it
|
|
3042
|
+
# (P6). A level is read with the verdict hook's `_DECLARATION_RE`; only the code
|
|
3043
|
+
# lens's `L` levels count here.
|
|
3044
|
+
CONSTRAINTS_HOOK_LINES = 15
|
|
3045
|
+
_WORK_TOOLS = VERDICT_EDIT_TOOLS + ("Read",)
|
|
3046
|
+
|
|
3047
|
+
|
|
3048
|
+
def transcript_work(path):
|
|
3049
|
+
"""What the session transcript shows about the work, or None when it cannot be
|
|
3050
|
+
read within the verdict hook's bounds: {"assistant": entries parsed,
|
|
3051
|
+
"sidechain": a subagent's transcript, "level": the last declared level ("1",
|
|
3052
|
+
"2", "3") or None, "touched": normalized absolute paths this session read or
|
|
3053
|
+
wrote}."""
|
|
3054
|
+
work = {"assistant": 0, "sidechain": False, "level": None, "touched": set()}
|
|
3055
|
+
try:
|
|
3056
|
+
p = Path(path)
|
|
3057
|
+
if not p.is_file() or p.stat().st_size > VERDICT_MAX_BYTES:
|
|
3058
|
+
return None
|
|
3059
|
+
with p.open("rb") as fh:
|
|
3060
|
+
for raw in fh:
|
|
3061
|
+
if len(raw) > VERDICT_LINE_MAX or b'"assistant"' not in raw:
|
|
3062
|
+
continue
|
|
3063
|
+
try:
|
|
3064
|
+
entry = json.loads(raw.decode("utf-8", errors="replace"))
|
|
3065
|
+
except ValueError:
|
|
3066
|
+
continue
|
|
3067
|
+
if not isinstance(entry, dict) or entry.get("type") != "assistant":
|
|
3068
|
+
continue
|
|
3069
|
+
content = (entry.get("message") or {}).get("content")
|
|
3070
|
+
if not isinstance(content, list):
|
|
3071
|
+
continue
|
|
3072
|
+
work["assistant"] += 1
|
|
3073
|
+
if entry.get("isSidechain") is True:
|
|
3074
|
+
work["sidechain"] = True
|
|
3075
|
+
for block in content:
|
|
3076
|
+
if not isinstance(block, dict):
|
|
3077
|
+
continue
|
|
3078
|
+
if block.get("type") == "text":
|
|
3079
|
+
for letter, one, other in _DECLARATION_RE.findall(
|
|
3080
|
+
str(block.get("text") or "")):
|
|
3081
|
+
if letter.upper() == "L":
|
|
3082
|
+
work["level"] = one or other
|
|
3083
|
+
elif block.get("type") == "tool_use" and block.get("name") in _WORK_TOOLS:
|
|
3084
|
+
inp = block.get("input") or {}
|
|
3085
|
+
target = inp.get("file_path") or inp.get("notebook_path")
|
|
3086
|
+
if isinstance(target, str) and target:
|
|
3087
|
+
try:
|
|
3088
|
+
work["touched"].add(_norm_rel(Path(target).resolve().as_posix()))
|
|
3089
|
+
except (OSError, ValueError):
|
|
3090
|
+
pass
|
|
3091
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
3092
|
+
return None
|
|
3093
|
+
return work
|
|
3094
|
+
|
|
3095
|
+
|
|
3096
|
+
def _hook_new_text(tool_input):
|
|
3097
|
+
"""The text an edit is about to write: Edit, Write, MultiEdit, NotebookEdit."""
|
|
3098
|
+
parts = [tool_input.get(k) for k in ("new_string", "content", "new_source")]
|
|
3099
|
+
for e in tool_input.get("edits") or []:
|
|
3100
|
+
if isinstance(e, dict):
|
|
3101
|
+
parts.append(e.get("new_string"))
|
|
3102
|
+
return "\n".join(x for x in parts if isinstance(x, str))
|
|
3103
|
+
|
|
3104
|
+
|
|
3105
|
+
def _unit_documents(root, p):
|
|
3106
|
+
"""Normalized absolute paths of a unit's ANALYSIS, request records and handoff:
|
|
3107
|
+
reading or writing any of them is working on the unit."""
|
|
3108
|
+
slug = p.stem[len("ANALYSIS_"):]
|
|
3109
|
+
docs = {p, ai_path(root, "audit", f"HANDOFF_{slug}.md")}
|
|
3110
|
+
for r in requests_naming(root, p.name):
|
|
3111
|
+
docs.add(r)
|
|
3112
|
+
docs.add(ai_path(root, "audit", f"HANDOFF_{r.stem[len('REQUEST_'):]}.md"))
|
|
3113
|
+
return {_norm_rel(d.resolve().as_posix()) for d in docs}
|
|
3114
|
+
|
|
3115
|
+
|
|
3116
|
+
def constraints_hook_context(payload, root=None, docs_name=None):
|
|
3117
|
+
"""The context to recall before this edit, or None (FS6): an edit tool, outside
|
|
3118
|
+
a subagent, on a file of a project whose open L3 code unit binds it (or the
|
|
3119
|
+
unit's own ANALYSIS, about to name a path a P row binds -- UC6), by a session
|
|
3120
|
+
that worked on that unit and last declared L3."""
|
|
3121
|
+
if payload.get("tool_name") not in VERDICT_EDIT_TOOLS or payload.get("agent_id"):
|
|
3122
|
+
return None
|
|
3123
|
+
tool_input = payload.get("tool_input") or {}
|
|
3124
|
+
target = tool_input.get("file_path") or tool_input.get("notebook_path")
|
|
3125
|
+
transcript = payload.get("transcript_path")
|
|
3126
|
+
if not isinstance(target, str) or not target or not transcript:
|
|
3127
|
+
return None
|
|
3128
|
+
root = hook_project(target, root, docs_name)
|
|
3129
|
+
if root is None or not ai_path(root, "solutions").is_dir():
|
|
3130
|
+
return None
|
|
3131
|
+
found = file_constraints(root, target)
|
|
3132
|
+
rel = project_relative(root, target)
|
|
3133
|
+
named = _path_tokens(_hook_new_text(tool_input), False)
|
|
3134
|
+
if named and rel and rel.startswith(_norm_rel(f"{docs_dir()}/solutions/ANALYSIS_")):
|
|
3135
|
+
for p, meta, text in open_code_units(root):
|
|
3136
|
+
if _norm_rel(f"{docs_dir()}/solutions/{p.name}") != rel:
|
|
3137
|
+
continue
|
|
3138
|
+
items = parse_constraints(text)
|
|
3139
|
+
bound = [c for c in items if c["kind"] == "P"
|
|
3140
|
+
and not c["released"] and c["bindings"] & named]
|
|
3141
|
+
if bound and not any(f[0] == p for f in found):
|
|
3142
|
+
found.append((p, meta, bound, unread_sections(text, items)))
|
|
3143
|
+
if not found:
|
|
3144
|
+
return None
|
|
3145
|
+
work = transcript_work(transcript)
|
|
3146
|
+
if not work or not work["assistant"] or work["sidechain"] or work["level"] != "3":
|
|
3147
|
+
return None
|
|
3148
|
+
# One budget for the whole recall, whatever the number of units; a P line and
|
|
3149
|
+
# its evidence line are kept together or left out together.
|
|
3150
|
+
out, budget = [], CONSTRAINTS_HOOK_LINES
|
|
3151
|
+
for p, meta, items, unread in found:
|
|
3152
|
+
if not (_unit_documents(root, p) & work["touched"]):
|
|
3153
|
+
continue
|
|
3154
|
+
home = f"solutions/{p.name}"
|
|
3155
|
+
unit = meta.get("id") or p.stem
|
|
3156
|
+
out.append(_clip(f"[sdlc constraints] {unit} — {home}"))
|
|
3157
|
+
shown = 0
|
|
3158
|
+
for c in items:
|
|
3159
|
+
chunk = constraint_lines(home, [c], False)
|
|
3160
|
+
if len(chunk) > budget:
|
|
3161
|
+
break
|
|
3162
|
+
out.extend(chunk)
|
|
3163
|
+
budget -= len(chunk)
|
|
3164
|
+
shown += 1
|
|
3165
|
+
if shown < len(items):
|
|
3166
|
+
out.append(_clip(f"More: sdlc_check.py constraints --unit {unit}"))
|
|
3167
|
+
out.append(_clip(constraints_sentence(unread)))
|
|
3168
|
+
return "\n".join(out) if out else None
|
|
3169
|
+
|
|
3170
|
+
|
|
3171
|
+
def cmd_constraints_hook(args):
|
|
3172
|
+
"""PreToolUse hook: print a context-only JSON or nothing. Always 0 -- exit 2
|
|
3173
|
+
would block the edit, and the wired command adds `; exit 0` for validators
|
|
3174
|
+
that cannot run this at all (T2)."""
|
|
3175
|
+
try:
|
|
3176
|
+
payload = read_hook_payload()
|
|
3177
|
+
if payload is None:
|
|
3178
|
+
return 0
|
|
3179
|
+
root = Path(args.root).resolve() if args.root else None
|
|
3180
|
+
context = constraints_hook_context(payload, root, getattr(args, "docs_dir", None))
|
|
3181
|
+
if context:
|
|
3182
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
3183
|
+
"hookEventName": "PreToolUse",
|
|
3184
|
+
"additionalContext": context}}, ensure_ascii=False))
|
|
3185
|
+
except Exception:
|
|
3186
|
+
pass
|
|
3187
|
+
return 0
|
|
3188
|
+
|
|
3189
|
+
|
|
3190
|
+
def cmd_constraints(root, args):
|
|
3191
|
+
if args.unit:
|
|
3192
|
+
found = unit_constraints(root, args.unit)
|
|
3193
|
+
if not found:
|
|
3194
|
+
print(f"[ERROR] unknown unit '{args.unit}': give an id such as F-001 "
|
|
3195
|
+
"or an ANALYSIS file name")
|
|
3196
|
+
return 1
|
|
3197
|
+
groups = [(p, meta, items, unread, True) for p, meta, items, unread in found]
|
|
3198
|
+
else:
|
|
3199
|
+
groups = [(p, meta, items, unread, False)
|
|
3200
|
+
for p, meta, items, unread in file_constraints(root, args.file)]
|
|
3201
|
+
for p, meta, items, unread, grouped in groups:
|
|
3202
|
+
home = f"solutions/{p.name}"
|
|
3203
|
+
print(_clip(f"{meta.get('id') or p.stem} — {home}"))
|
|
3204
|
+
for line in constraint_lines(home, items, grouped):
|
|
3205
|
+
print(line)
|
|
3206
|
+
print(_clip(constraints_sentence(unread)))
|
|
3207
|
+
return 0
|
|
3208
|
+
|
|
3209
|
+
|
|
2651
3210
|
# --------------------------------------------------------------------- plan
|
|
2652
3211
|
# Subagent Execution (Feature A). Zero-execution surface: this section and
|
|
2653
3212
|
# everything it calls MUST NOT spawn a process (no subprocess/os.system/eval/
|
|
@@ -2977,6 +3536,10 @@ def cmd_benefit(root):
|
|
|
2977
3536
|
rows, unparsed = parse_review_log(read_text(log))
|
|
2978
3537
|
not_due = [r for r in rows if _is_not_due(r["verdict"])]
|
|
2979
3538
|
rows = [r for r in rows if not _is_not_due(r["verdict"])]
|
|
3539
|
+
# F-085 FS10: a `post-merge` row is the owner's report of a defect that got
|
|
3540
|
+
# past every review -- not a review, so it stays out of every review figure.
|
|
3541
|
+
post_merge = [r for r in rows if r["tier"].lower().startswith("post-merge")]
|
|
3542
|
+
rows = [r for r in rows if not r["tier"].lower().startswith("post-merge")]
|
|
2980
3543
|
# `tier` carries the MOMENT in Standalone (design / closure) and the
|
|
2981
3544
|
# reviewer WEIGHT in Hybrid (deep / light / code / guide / vision). A
|
|
2982
3545
|
# Hybrid row is a real review whose moment this column does not state, so
|
|
@@ -3001,11 +3564,14 @@ def cmd_benefit(root):
|
|
|
3001
3564
|
unstated_code += n
|
|
3002
3565
|
classified = moments["design"] + moments["closure"]
|
|
3003
3566
|
print("rows parsed: %d rows unparsed: %d%s"
|
|
3004
|
-
% (len(rows) + len(not_due), len(unparsed),
|
|
3567
|
+
% (len(rows) + len(not_due) + len(post_merge), len(unparsed),
|
|
3005
3568
|
(" -> " + "; ".join("line %d: %s" % u for u in unparsed[:5]))
|
|
3006
3569
|
if unparsed else ""))
|
|
3007
3570
|
print("reviews ruled not due (logged, counted in no figure below): %d"
|
|
3008
3571
|
% len(not_due))
|
|
3572
|
+
if post_merge:
|
|
3573
|
+
print("defects reported after merge (tier post-merge, not reviews, "
|
|
3574
|
+
"counted in no figure below): %d" % len(post_merge))
|
|
3009
3575
|
print("reviews with a stated moment: %d moment not stated by `tier`: %d%s"
|
|
3010
3576
|
% (len(classified), sum(unstated.values()),
|
|
3011
3577
|
(" (" + ", ".join("%s x%d" % (t, n) for t, n
|
|
@@ -3498,6 +4064,16 @@ def main(argv=None):
|
|
|
3498
4064
|
"or an unread named guide (fail-open, always exit 0)")
|
|
3499
4065
|
vp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
|
|
3500
4066
|
|
|
4067
|
+
cp = sub.add_parser("constraints", parents=[common],
|
|
4068
|
+
help="print the constraints an ANALYSIS states in extractable "
|
|
4069
|
+
"form: a unit's view, or those bound to a file")
|
|
4070
|
+
cg = cp.add_mutually_exclusive_group(required=True)
|
|
4071
|
+
cg.add_argument("--unit", help="unit id (e.g. F-001) or ANALYSIS file name")
|
|
4072
|
+
cg.add_argument("--file", help="file path: constraints of the open L3 code units bound to it")
|
|
4073
|
+
cg.add_argument("--hook", action="store_true",
|
|
4074
|
+
help="PreToolUse hook: read the payload from stdin, print context only "
|
|
4075
|
+
"(fail-open, always exit 0)")
|
|
4076
|
+
|
|
3501
4077
|
sub.add_parser("benefit", parents=[common],
|
|
3502
4078
|
help="report what the review gates caught (and what the "
|
|
3503
4079
|
"doctrine costs to read) -- a report, never a gate: "
|
|
@@ -3521,6 +4097,16 @@ def main(argv=None):
|
|
|
3521
4097
|
|
|
3522
4098
|
args = ap.parse_args(argv)
|
|
3523
4099
|
|
|
4100
|
+
# F-085: the edit hooks resolve their root and docs root from the edited file
|
|
4101
|
+
# (the hook runs in whatever directory the session moved to), so they are
|
|
4102
|
+
# dispatched before the working-directory resolution below.
|
|
4103
|
+
if args.cmd == "constraints" and args.hook:
|
|
4104
|
+
return cmd_constraints_hook(args)
|
|
4105
|
+
if args.cmd == "verdict":
|
|
4106
|
+
return cmd_verdict(args)
|
|
4107
|
+
if args.cmd == "gate":
|
|
4108
|
+
return cmd_gate(args)
|
|
4109
|
+
|
|
3524
4110
|
# Resolve the documentation root ONCE, before any command runs, so every
|
|
3525
4111
|
# surface -- paths, messages, generated headers, the gate's exempt prefix --
|
|
3526
4112
|
# names the same thing.
|
|
@@ -3539,19 +4125,11 @@ def main(argv=None):
|
|
|
3539
4125
|
# and says so rather than exiting non-zero.
|
|
3540
4126
|
print(f"[note] {exc}")
|
|
3541
4127
|
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
4128
|
else:
|
|
3547
4129
|
print(f"[ERROR] {exc}")
|
|
3548
4130
|
return 1
|
|
3549
4131
|
set_docs_dir(name)
|
|
3550
4132
|
|
|
3551
|
-
if args.cmd == "gate":
|
|
3552
|
-
return cmd_gate(args)
|
|
3553
|
-
if args.cmd == "verdict":
|
|
3554
|
-
return cmd_verdict(args)
|
|
3555
4133
|
if args.cmd == "orient":
|
|
3556
4134
|
return cmd_orient(args)
|
|
3557
4135
|
if args.cmd == "remind":
|
|
@@ -3570,6 +4148,8 @@ def main(argv=None):
|
|
|
3570
4148
|
return cmd_mark(root, args.paths, args.note)
|
|
3571
4149
|
if args.cmd == "benefit":
|
|
3572
4150
|
return cmd_benefit(root)
|
|
4151
|
+
if args.cmd == "constraints":
|
|
4152
|
+
return cmd_constraints(root, args)
|
|
3573
4153
|
if args.cmd == "migrate":
|
|
3574
4154
|
return cmd_migrate(root, args)
|
|
3575
4155
|
if args.cmd == "plan":
|