@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.
@@ -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 d not in SKIP_DIRS and d != docs_dir() and not d.startswith(".")]
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 find_project_root()
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: format drift resolves to silence.
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
- _VERDICT_TRIVIAL_RE = re.compile(r"\blevel[\s*_`]*:[\s`*_]*[A-Z]1\b", re.IGNORECASE)
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: X1` seen, "named": guides the verdicts name,
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
- if _VERDICT_TRIVIAL_RE.search(line):
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 find_project_root()
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":