@antoneeo/mkt-agentic-sdlc-skill 0.26.0 → 0.28.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.
@@ -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 d not in SKIP_DIRS and d != docs_dir() and not d.startswith(".")]
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 design_review_due(meta):
1474
- """True when an L3 ANALYSIS owes a design-review row (review.md moment 1):
1475
- implementation has started or finished, and it began on/after the gate
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(DESIGN_REVIEW_EPOCH)
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 find_project_root()
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); otherwise write it first (Phase 3).\n")
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: format drift resolves to silence.
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
- _VERDICT_TRIVIAL_RE = re.compile(r"\blevel[\s*_`]*:[\s`*_]*[A-Z]1\b", re.IGNORECASE)
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: X1` seen, "named": guides the verdicts name,
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
- if _VERDICT_TRIVIAL_RE.search(line):
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 find_project_root()
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":