aw-spec-cli 0.1.21 → 0.1.23

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.
Files changed (51) hide show
  1. package/assets/.appweaver/STACK-PROFILES.md +87 -8
  2. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
  3. package/assets/.appweaver/orchestrator/orchestrator.py +47 -3
  4. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +41 -8
  5. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +62 -2
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +18 -5
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +1 -1
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +4 -1
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +115 -27
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +5 -1
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +225 -27
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +4 -15
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1 -1
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +3 -1
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +104 -1
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +159 -0
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +153 -0
  21. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +203 -212
  22. package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
  23. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +191 -224
  24. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
  25. package/assets/.appweaver/scripts/python/aw-crstate.py +127 -2
  26. package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
  27. package/assets/.appweaver/scripts/python/aw-payload-parity.py +17 -1
  28. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +84 -4
  29. package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
  30. package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
  31. package/assets/.appweaver/scripts/python/aw-stack.py +6 -0
  32. package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
  33. package/assets/.appweaver/stack-profiles/angular.json +26 -0
  34. package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
  35. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +38 -0
  36. package/assets/.appweaver/stack-profiles/java-spring.json +19 -0
  37. package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
  38. package/assets/.appweaver/stack-profiles/node-express.json +26 -0
  39. package/assets/.appweaver/stack-profiles/python-fastapi.json +15 -0
  40. package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
  41. package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
  42. package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
  43. package/assets/.appweaver/templates/ui-model-template.md +67 -0
  44. package/assets/.appweaver/templates/workflow-model-template.md +62 -0
  45. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +3 -3
  46. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +5 -123
  47. package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +784 -0
  48. package/assets/foundations/java/Hexagonal/pack-foundation.sh +357 -0
  49. package/assets/foundations/java/Hexagonal/unpack-foundation.sh +399 -0
  50. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +44 -0
  51. package/package.json +1 -1
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ aw-plangate — the analyze-plan receipt, written and read in one place.
4
+
5
+ THE RECEIPT. `analyze-plan` records which bytes it checked against each other — spec.md,
6
+ the context's domain-model.md and every lane plan on disk — and the verdict it reached.
7
+ `implement` generates from the plans and deliberately does NOT read the spec, on the
8
+ strength of that receipt, so the pair of operations that write and read it is the hinge the
9
+ whole bootstrap path turns on.
10
+
11
+ WHY IT LIVES HERE. Both halves were embedded twice, as a bash heredoc and as a natively
12
+ re-written PowerShell function, and the rule they implement is a set of sha comparisons
13
+ with three deliberate asymmetries in it — an absent spec sha does not block, an absent
14
+ model sha does, and an unrecognised verdict records as `gaps`. That rule changed twice
15
+ (MF-19, then MF-20) and each time it had to be re-derived by hand on the PowerShell side
16
+ and kept in step by eye. One module both shells invoke removes the question: there is
17
+ nothing left to keep in step. Same reason aw-crstate.py owns "has this change merged".
18
+
19
+ NO BASELINE IS NOT "NOTHING MOVED". `spec_moved` / `model_moved` report "" where there is
20
+ nothing to compare against — no receipt, or a receipt that hashed nothing for that
21
+ document. "" is falsy in Python, in PowerShell and to the engine's require_true, so every
22
+ flag computed from them reads exactly as a false did; what it buys is that a reader can
23
+ tell "unchanged" from "unknown", which a bare false cannot express.
24
+
25
+ Usage:
26
+ aw-plangate.py stamp --feature-dir <p> --context-dir <p> --root <p>
27
+ [--spec-version <v>] [--mode <text>]
28
+ [--verdict conformant|routed-gaps|gaps] [--plan-gaps <n>]
29
+ [--routed <json list>]
30
+ -> {file, gated, spec_version, verdict, plan_gaps, routed_gaps, hashed[], count}
31
+ aw-plangate.py check --feature-dir <p> --context-dir <p> --root <p>
32
+ -> {file, tracked, gate_fresh, verdict, may_generate, plan_gaps, routed_gaps[],
33
+ routed_csv, spec_moved, model_moved, plans_changed[], plans_changed_csv,
34
+ gated, spec_version, reason}
35
+ """
36
+
37
+ import argparse
38
+ import datetime
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import sys
43
+
44
+ RECEIPT = ".aw-plan-gate.json"
45
+ VERDICTS = ("conformant", "routed-gaps", "gaps")
46
+
47
+ # Every document the gate READ. A lane that is not declared has no plan on disk and is
48
+ # simply absent from the map — the check treats a name it does not carry as not gated,
49
+ # never as changed, so declaring a second lane later does not read as tampering.
50
+ #
51
+ # THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
52
+ # resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
53
+ # that has not been through `plan` since the rename — every brownfield feature `adopt`
54
+ # produces — is GATED on those documents. Hashing only the new names would leave such a
55
+ # feature with an empty plan map: a receipt that looks tracked and fresh while the document
56
+ # implement actually generates from is unwatched.
57
+ PLAN_NAMES = ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md")
58
+
59
+ NOTE = ("Written by every master-mode analyze-plan run. It records the bytes that were "
60
+ "checked against each other and the verdict on them, not a version — the plans' "
61
+ "**Version**: headers are stamped in lockstep by close-delta and cannot answer "
62
+ "this. implement reads it through plan-gate-check: moved bytes or verdict `gaps` "
63
+ "refuse; `routed-gaps` lists what /appweaver specify still has to lift.")
64
+
65
+
66
+ def sha(path):
67
+ try:
68
+ with open(path, "rb") as fh:
69
+ return hashlib.sha256(fh.read()).hexdigest()
70
+ except Exception:
71
+ return ""
72
+
73
+
74
+ def stamp(fd, ctx, root, spec_version="", mode="master", verdict="conformant",
75
+ plan_gaps=0, routed=None):
76
+ plans = {}
77
+ for name in PLAN_NAMES:
78
+ h = sha(os.path.join(fd, name))
79
+ if h:
80
+ plans[name] = h
81
+ # An unrecognised verdict is recorded as `gaps`, the one that refuses.
82
+ verdict = (verdict or "").strip()
83
+ if verdict not in VERDICTS:
84
+ verdict = "gaps"
85
+ try:
86
+ plan_gaps = int(plan_gaps or 0)
87
+ except (TypeError, ValueError):
88
+ plan_gaps = 0
89
+ routed = [str(x) for x in (routed or [])]
90
+ rec = {
91
+ "gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
92
+ "gate_mode": mode or "master",
93
+ "spec_version": (spec_version or "").strip(),
94
+ "verdict": verdict,
95
+ "plan_gaps": plan_gaps,
96
+ "routed_gaps": routed,
97
+ "spec_sha": sha(os.path.join(fd, "spec.md")),
98
+ "model_sha": sha(os.path.join(ctx, "domain-model.md")),
99
+ "plans": plans,
100
+ "note": NOTE,
101
+ }
102
+ path = os.path.join(fd, RECEIPT)
103
+ with open(path, "w", encoding="utf-8") as fh:
104
+ fh.write(json.dumps(rec, indent=2) + "\n")
105
+ return {"file": os.path.relpath(path, root), "gated": rec["gated"],
106
+ "spec_version": rec["spec_version"], "verdict": verdict,
107
+ "plan_gaps": plan_gaps, "routed_gaps": len(routed),
108
+ "hashed": sorted(plans.keys()), "count": len(plans), "ok": True}
109
+
110
+
111
+ def check(fd, ctx, root):
112
+ path = os.path.join(fd, RECEIPT)
113
+ try:
114
+ rec = json.load(open(path, encoding="utf-8"))
115
+ except Exception:
116
+ rec = None
117
+ out = {"file": os.path.relpath(path, root), "tracked": bool(rec), "gate_fresh": False,
118
+ "verdict": "", "may_generate": True, "plan_gaps": 0, "routed_gaps": [],
119
+ "routed_csv": "", "spec_moved": "", "model_moved": "", "plans_changed": [],
120
+ "plans_changed_csv": "", "gated": "", "spec_version": "", "reason": "", "ok": True}
121
+ if not rec:
122
+ # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
123
+ # thing to report about a tree that has not run analyze-plan since this op existed.
124
+ out["reason"] = ("no .aw-plan-gate.json — this feature has not been through "
125
+ "analyze-plan in master mode since the gate began leaving a receipt")
126
+ return out
127
+
128
+ out["gated"] = rec.get("gated", "")
129
+ out["spec_version"] = rec.get("spec_version", "")
130
+ # A receipt from before the verdict existed was only ever written on a clean pass.
131
+ out["verdict"] = rec.get("verdict") or "conformant"
132
+ out["plan_gaps"] = rec.get("plan_gaps") or 0
133
+ out["routed_gaps"] = [str(x) for x in (rec.get("routed_gaps") or [])]
134
+ out["routed_csv"] = "; ".join(out["routed_gaps"])
135
+
136
+ # A receipt that hashed nothing for a document has no baseline for it either, so the
137
+ # question is unanswerable for that one document and says so.
138
+ spec_base = rec.get("spec_sha") or ""
139
+ out["spec_moved"] = (spec_base != sha(os.path.join(fd, "spec.md"))) if spec_base else ""
140
+ # A model recorded as absent that is still absent has not moved. A context that GAINED a
141
+ # domain model since the gate has moved: check 4 never ran against it — that stays a
142
+ # move, including for a receipt predating model_sha, because a model nothing has been
143
+ # checked against is the condition this field exists to catch. Only a receipt carrying
144
+ # no model_sha with no model on disk either leaves nothing to compare.
145
+ model_base = rec.get("model_sha")
146
+ model_now = sha(os.path.join(ctx, "domain-model.md"))
147
+ out["model_moved"] = ((model_base or "") != model_now
148
+ if (model_base is not None or model_now) else "")
149
+
150
+ changed = []
151
+ for name, was in sorted((rec.get("plans") or {}).items()):
152
+ if sha(os.path.join(fd, name)) != was:
153
+ changed.append(name)
154
+ out["plans_changed"] = changed
155
+ out["plans_changed_csv"] = ", ".join(changed)
156
+ out["gate_fresh"] = not (out["spec_moved"] or out["model_moved"] or changed)
157
+ # Moved bytes refuse whatever the verdict was; unmoved bytes refuse only on `gaps`.
158
+ out["may_generate"] = out["gate_fresh"] and out["verdict"] != "gaps"
159
+
160
+ if not out["gate_fresh"]:
161
+ bits = []
162
+ if out["spec_moved"]:
163
+ bits.append("spec.md has changed")
164
+ if out["model_moved"]:
165
+ bits.append("domain-model.md has changed")
166
+ if changed:
167
+ bits.append("%s changed (a re-derive by `plan main`, or a hand edit)"
168
+ % ", ".join(changed))
169
+ out["reason"] = "since analyze-plan checked them on %s: %s" % (out["gated"],
170
+ "; ".join(bits))
171
+ elif out["verdict"] == "gaps":
172
+ out["reason"] = ("analyze-plan on %s left %s gap(s) a plan rewrite closes, unrepaired"
173
+ % (out["gated"], out["plan_gaps"] or "some"))
174
+ elif out["verdict"] == "routed-gaps":
175
+ out["reason"] = ("the spec, the model and every gated plan are byte-identical to what "
176
+ "analyze-plan checked on %s; it left %d gap(s) only /appweaver specify "
177
+ "can close: %s" % (out["gated"], len(out["routed_gaps"]),
178
+ out["routed_csv"]))
179
+ else:
180
+ out["reason"] = ("the spec, the model and every gated plan are byte-identical to what "
181
+ "analyze-plan passed on %s" % out["gated"])
182
+ return out
183
+
184
+
185
+ def main():
186
+ ap = argparse.ArgumentParser(description="the analyze-plan receipt, written and read")
187
+ sub = ap.add_subparsers(dest="cmd", required=True)
188
+ for name in ("stamp", "check"):
189
+ p = sub.add_parser(name)
190
+ p.add_argument("--feature-dir", required=True)
191
+ p.add_argument("--context-dir", required=True)
192
+ p.add_argument("--root", required=True)
193
+ if name == "stamp":
194
+ p.add_argument("--spec-version", default="")
195
+ p.add_argument("--mode", default="master")
196
+ p.add_argument("--verdict", default="conformant")
197
+ p.add_argument("--plan-gaps", default="0")
198
+ p.add_argument("--routed", default="[]")
199
+ args = ap.parse_args()
200
+
201
+ if args.cmd == "stamp":
202
+ try:
203
+ routed = json.loads(args.routed or "[]")
204
+ except Exception:
205
+ routed = []
206
+ if not isinstance(routed, list):
207
+ routed = []
208
+ out = stamp(args.feature_dir, args.context_dir, args.root, args.spec_version,
209
+ args.mode, args.verdict, args.plan_gaps, routed)
210
+ else:
211
+ out = check(args.feature_dir, args.context_dir, args.root)
212
+ sys.stdout.write(json.dumps(out) + "\n")
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ aw-principles — enumerate the ratified NON-NEGOTIABLE principles.
4
+
5
+ WHY A SCRIPT AND NOT A JUDGEMENT. `specify` settles [NEEDS CLARIFICATION] markers by
6
+ asking, and a non-interactive run answers with the FIRST option offered — so an agent's
7
+ option ordering becomes project policy unattended. The gate that checks those answers
8
+ against the constitution has to name the principle it is invoking, and an agent asked to
9
+ both find the principle and judge the contradiction can invent the principle it then
10
+ objects to. Splitting it means the factual half is checkable: this module reads the
11
+ headings and nothing else, the agent judges only whether a settled answer departs from
12
+ one of THESE, and a citation outside the list is visibly wrong.
13
+
14
+ It also answers "are there any?" deterministically. A project whose constitution ratifies
15
+ no NON-NEGOTIABLE principle has nothing for the gate to check, and `count` is what lets
16
+ the workflow skip it rather than asking an agent to confirm an empty set.
17
+
18
+ WHAT COUNTS. A principle heading of the form `### <id>. <title> (NON-NEGOTIABLE)`, which
19
+ is what all three constitution templates emit. The id is whatever precedes the first
20
+ period — roman numerals in the domain and technical halves, `UX-`-prefixed in the UI half
21
+ — so a project that renumbers or re-prefixes its series needs no change here.
22
+
23
+ HTML comments are stripped first. The Sync Impact Report at the top of every constitution
24
+ quotes principle headings while recording what a ratification changed, and counting those
25
+ would report principles that are not in force.
26
+
27
+ Usage:
28
+ aw-principles.py list --root <p> [--file <p> ...]
29
+ -> {files[], missing[], principles[{file, id, title, line}], count, titles_csv, ok}
30
+
31
+ Default files are the two halves `specify` loads — constitution.domain.md and
32
+ constitution.ui.md. The technical half is NOT a default: specify never loads it (a
33
+ spec is WHAT, not HOW), so a gate there could cite a principle the authoring agent was
34
+ never shown. A caller that loads it passes --file.
35
+ """
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ import re
41
+ import sys
42
+
43
+ DEFAULT_FILES = (".appweaver/memory/constitution.domain.md",
44
+ ".appweaver/memory/constitution.ui.md")
45
+
46
+ # `### XXIII. Clinical Meaning is Bound, Never Free Text (NON-NEGOTIABLE)`
47
+ # `### UX-I. Accessibility Floor (NON-NEGOTIABLE)`
48
+ HEADING = re.compile(r"^###\s+([^.]+)\.\s+(.+?)\s*\(NON-NEGOTIABLE\)\s*$")
49
+ COMMENT = re.compile(r"<!--.*?-->", re.S)
50
+
51
+
52
+ def read(path):
53
+ try:
54
+ with open(path, encoding="utf-8") as fh:
55
+ return fh.read()
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ def principles_in(path, rel):
61
+ """[{file, id, title, line}] for one constitution half, in document order."""
62
+ text = read(path)
63
+ if text is None:
64
+ return None
65
+ # Blank the comments rather than deleting them, so reported line numbers still
66
+ # point at the heading in the file a reader opens.
67
+ text = COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), text)
68
+ out = []
69
+ for n, line in enumerate(text.split("\n"), 1):
70
+ m = HEADING.match(line)
71
+ if m:
72
+ out.append({"file": rel, "id": m.group(1).strip(),
73
+ "title": m.group(2).strip(), "line": n})
74
+ return out
75
+
76
+
77
+ def listing(root, files):
78
+ seen, missing, found = [], [], []
79
+ for rel in files:
80
+ path = rel if os.path.isabs(rel) else os.path.join(root, rel)
81
+ got = principles_in(path, rel)
82
+ if got is None:
83
+ missing.append(rel)
84
+ continue
85
+ seen.append(rel)
86
+ found.extend(got)
87
+ return {"files": seen, "missing": missing, "principles": found,
88
+ "count": len(found),
89
+ "titles_csv": "; ".join("%s (%s %s)" % (p["title"], os.path.basename(p["file"]),
90
+ p["id"]) for p in found),
91
+ "ok": True}
92
+
93
+
94
+ def main():
95
+ ap = argparse.ArgumentParser(description="enumerate ratified NON-NEGOTIABLE principles")
96
+ sub = ap.add_subparsers(dest="cmd", required=True)
97
+ p = sub.add_parser("list")
98
+ p.add_argument("--root", required=True)
99
+ p.add_argument("--file", action="append", default=[],
100
+ help="a constitution half to scan (repeatable; defaults to domain + ui)")
101
+ args = ap.parse_args()
102
+
103
+ out = listing(args.root, args.file or list(DEFAULT_FILES))
104
+ sys.stdout.write(json.dumps(out) + "\n")
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
@@ -917,6 +917,12 @@ def kind_block(repo, lanes, kind):
917
917
  kind + "_language": prof.get("language", ""),
918
918
  kind + "_ecosystem": prof.get("ecosystem", ""),
919
919
  kind + "_title": prof.get("title", "") or lane.get("stack", ""),
920
+ # Names this stack's AMBIENT scope already defines — C#'s implicit usings, the
921
+ # browser and TS globals, java.lang, typing. A domain noun that matches one is
922
+ # not a naming mistake (the plan's names are the ubiquitous language) but it does
923
+ # have to be written qualified, or the compiler cannot tell the two apart.
924
+ kind + "_reserved_names": prof.get("reserved_names", []),
925
+ kind + "_reserved_csv": ", ".join(prof.get("reserved_names", [])),
920
926
  kind + "_root": root,
921
927
  kind + "_source_path": root,
922
928
  kind + "_root_exists": root_on_disk(repo, root),
@@ -749,6 +749,29 @@ def _referrer_closure(aggregate, bundle_abs):
749
749
  return sorted(seen)
750
750
 
751
751
 
752
+ def _has_code(name, bundle_abs):
753
+ """Does this aggregate have code on disk yet?
754
+
755
+ "Depends on me" and "has code that could break" are different questions, and the
756
+ closure alone answers only the first. Edges come from the PLAN — a feature that has
757
+ been planned and not implemented is a genuine dependant with nothing to re-test, and
758
+ `record` stubs a used sibling's concept when it has none, so its name enters the
759
+ closure either way. `source_files` is the difference: a stub is written with it empty
760
+ and implement's concept-upsert fills it from what it generated.
761
+ """
762
+ path = _resolve_concept(name, bundle_abs)
763
+ if not path:
764
+ return False
765
+ fm, _ = _read_fm(_read(path))
766
+ files = fm.get("source_files")
767
+ if isinstance(files, list):
768
+ return any(str(f).strip() for f in files)
769
+ # The front-matter reader returns a BLOCK list as a list and anything else as the raw
770
+ # scalar, so the `source_files: []` a stub carries arrives as the STRING "[]" — which
771
+ # is empty, and truthy to anyone who does not look inside it.
772
+ return bool(str(files or "").strip(" \t[]"))
773
+
774
+
752
775
  def _concept_title(path):
753
776
  fm, _ = _read_fm(_read(path))
754
777
  t = fm.get("title") if isinstance(fm.get("title"), str) else ""
@@ -995,15 +1018,30 @@ def cmd_list(args):
995
1018
  closure = []
996
1019
  for agg in aggregates:
997
1020
  closure.extend(c for c in _referrer_closure(agg, bundle_abs) if c not in closure)
1021
+ # THE CLOSURE ANSWERS TWO QUESTIONS AND A CALLER USUALLY WANTS ONE. Edges are read off
1022
+ # the PLAN, so a dependant can be fully planned and hold no code at all. A caller that
1023
+ # re-runs TESTS over the blast radius wants `built`: an unbuilt dependant has nothing
1024
+ # to run, and selecting it turns a fence into a failure over an empty test set. A
1025
+ # caller reporting what this change reaches wants the whole closure, with `unbuilt`
1026
+ # named so nobody reads its absence from a test run as "unaffected".
1027
+ built = [c for c in closure if _has_code(c, bundle_abs)]
1028
+ unbuilt = [c for c in closure if c not in built]
998
1029
  print(f"[xref] {', '.join(aggregates)}: {len(users)} downstream user(s) "
999
- f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}", file=sys.stderr)
1030
+ f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}"
1031
+ + (f"; {len(unbuilt)} planned but not implemented — {', '.join(unbuilt)}"
1032
+ if unbuilt else ""), file=sys.stderr)
1000
1033
  _emit({"aggregate": aggregate, "aggregates": aggregates,
1001
1034
  "concept": _rel(path, root) if path else "",
1002
1035
  "users": users, "count": len(users), "impact_report": out_rel, "root": root,
1003
1036
  "referrers": direct, "referrers_csv": ",".join(direct),
1004
1037
  "referrer_closure": closure, "referrer_closure_csv": ",".join(closure),
1005
1038
  "referrer_count": len(direct), "referrer_closure_count": len(closure),
1006
- "indirect_count": max(0, len(closure) - len(direct))})
1039
+ "indirect_count": max(0, len(closure) - len(direct)),
1040
+ # The closure split by whether the dependant has code yet.
1041
+ "built_closure": built, "built_closure_csv": ",".join(built),
1042
+ "built_count": len(built),
1043
+ "unbuilt_closure": unbuilt, "unbuilt_closure_csv": ",".join(unbuilt),
1044
+ "unbuilt_count": len(unbuilt)})
1007
1045
  return 0
1008
1046
 
1009
1047
 
@@ -1229,15 +1267,39 @@ def cmd_concept_paths(args):
1229
1267
  missing = [s for s in mapped if s not in existing]
1230
1268
  scope = "feature"
1231
1269
 
1270
+ # NARROWED TO ONE LANE'S SUBTREES. A concept spans every lane that realises the
1271
+ # aggregate — on a fullstack feature that is the service files AND the web files in
1272
+ # one list. A caller generating one lane must not be handed the other's: `implement`
1273
+ # tells each lane to read the code it is about to extend, and a backend pass shown the
1274
+ # UI's files is being invited to touch a subtree it is forbidden to write. Absent, the
1275
+ # whole list stands, which is what every existing caller wants.
1276
+ under = [u.strip().replace(os.sep, "/").lstrip("./") for u in (args.under or []) if u and u.strip()]
1277
+ under = [u if u.endswith("/") else u + "/" for u in under]
1278
+ scoped_out = 0
1279
+ if under:
1280
+ def inside(p):
1281
+ q = p.replace(os.sep, "/").lstrip("./")
1282
+ return any(q.startswith(u) for u in under)
1283
+ before = len(existing)
1284
+ existing = [p for p in existing if inside(p)]
1285
+ declared = [p for p in declared if inside(p)]
1286
+ missing = [p for p in missing if inside(p)]
1287
+ scoped_out = before - len(existing)
1288
+
1232
1289
  note = "" if path else " — no concept recorded yet"
1233
1290
  drift = f", {len(missing)} declared-but-missing" if missing else ""
1234
- print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{note}"
1291
+ narrowed = f", {scoped_out} outside {' '.join(under)}" if scoped_out else ""
1292
+ print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{narrowed}{note}"
1235
1293
  f" (scope: {scope})", file=sys.stderr)
1236
1294
  _emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
1237
1295
  "found": bool(path), "scope": scope, "paths": existing, "count": len(existing),
1238
1296
  "csv": ",".join(existing), "declared": declared,
1239
1297
  "declared_count": len(declared), "missing": missing,
1240
- "missing_count": len(missing), "root": root})
1298
+ "missing_count": len(missing), "under": under, "excluded_count": scoped_out,
1299
+ # One block per line, for a prompt that lists what the lane already has.
1300
+ "paths_text": "\n".join(" " + p for p in existing) if existing else
1301
+ " (none — nothing of this aggregate is on disk in this lane yet)",
1302
+ "root": root})
1241
1303
  return 0
1242
1304
 
1243
1305
 
@@ -1418,6 +1480,70 @@ def _resolve_feature(fd, root):
1418
1480
  "shared_with": shared_with, "shared_with_csv": ", ".join(shared_with)}
1419
1481
 
1420
1482
 
1483
+ def _crstate():
1484
+ """aw-crstate, loaded by path — it owns the readers for a change's own documents
1485
+ (`**Aggregates touched**`, the model delta beside the master or in its archive) and
1486
+ duplicating them here is how two answers to one question start to drift."""
1487
+ import importlib.util
1488
+ p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aw-crstate.py")
1489
+ spec = importlib.util.spec_from_file_location("aw_crstate", p)
1490
+ if not spec or not spec.loader:
1491
+ return None
1492
+ mod = importlib.util.module_from_spec(spec)
1493
+ try:
1494
+ spec.loader.exec_module(mod)
1495
+ except Exception:
1496
+ return None
1497
+ return mod
1498
+
1499
+
1500
+ def cmd_co_owners(args):
1501
+ """THE FEATURES THAT SHARE THIS CHANGE'S AGGREGATES — the half of a merge's blast
1502
+ radius that referrer edges cannot see.
1503
+
1504
+ `list` answers "who DEPENDS on this aggregate": features that reach it through a
1505
+ Cross-Reference edge, transitively. That is a real blast radius and it is not the whole
1506
+ one. A feature that CO-OWNS the aggregate holds no edge to it at all — it is not a user
1507
+ of `Encounter`, it is one of the capabilities Encounter is made of, and its code lives
1508
+ in the very files this merge rewrites. `Encounter.cs` is extended by every feature that
1509
+ claims Encounter, so a change to it can break a co-owner without touching one referrer.
1510
+
1511
+ SCOPED TO WHAT THE CHANGE TOUCHES, not to everything the feature owns. The delta's own
1512
+ `**Aggregates touched**` header is the authority: a feature owning Encounter and
1513
+ CareTeam whose change touches only Encounter must not drag CareTeam's co-owners through
1514
+ a regression fence they cannot have been affected by. Where that header cannot be read
1515
+ the feature's owned aggregates stand in — wider than needed, never narrower, and
1516
+ `source` says which answer this is so a caller can report it honestly.
1517
+ """
1518
+ root = _root(args.root)
1519
+ fd = args.feature_dir
1520
+ fd_abs = fd if os.path.isabs(fd) else os.path.join(root, fd)
1521
+ feature = _feature_from_dir(fd_abs)
1522
+ ctx = args.context_dir or os.path.dirname(os.path.normpath(fd_abs))
1523
+
1524
+ touched, source = [], "delta"
1525
+ cs = _crstate() if args.cr else None
1526
+ if cs:
1527
+ touched = sorted(cs.aggregates(cs.model_delta_of(ctx, args.cr)))
1528
+ if not touched:
1529
+ # No readable model delta: a spec-only change, or a header this cannot parse.
1530
+ touched = _resolve_feature(fd_abs, root)["owned"]
1531
+ source = "feature-owned"
1532
+
1533
+ own = {_key(a) for a in touched}
1534
+ features = sorted({v["feature"] for v in _feature_index(root).values()
1535
+ if _key(v["feature"]) != _key(feature)
1536
+ and own & {_key(a) for a in v["claims"]}}, key=str.lower)
1537
+
1538
+ print(f"[xref] co-owners {feature}: {len(features)} feature(s) share "
1539
+ f"{', '.join(touched) or '(no aggregate)'} (source: {source})", file=sys.stderr)
1540
+ _emit({"feature": feature, "cr_id": args.cr, "source": source,
1541
+ "aggregates": touched, "aggregates_csv": ", ".join(touched),
1542
+ "features": features, "features_csv": ", ".join(features),
1543
+ "count": len(features), "root": root})
1544
+ return 0
1545
+
1546
+
1421
1547
  def cmd_feature_aggregates(args):
1422
1548
  """WHICH AGGREGATE(S) A FEATURE ACTS ON — the name every command that reads the
1423
1549
  bounded context's domain-model.md needs, because the model is organised by
@@ -1826,10 +1952,21 @@ def main(argv):
1826
1952
  cp = sub.add_parser("concept-paths", help="read-only: the slice's source_files (replaces design-paths)")
1827
1953
  cp.add_argument("--aggregate", default="")
1828
1954
  cp.add_argument("--feature-dir", dest="feature_dir", default="")
1955
+ cp.add_argument("--under", action="append", default=[],
1956
+ help="keep only paths inside this subtree; repeatable. Omitted, every "
1957
+ "path the concept records is returned.")
1829
1958
  cp.add_argument("--root", default="")
1830
1959
  cp.add_argument("--bundle", default="knowledge")
1831
1960
  cp.set_defaults(func=cmd_concept_paths)
1832
1961
 
1962
+ cw = sub.add_parser("co-owners", help="read-only: the features that SHARE this change's aggregates (no referrer edge, same files)")
1963
+ cw.add_argument("--cr", default="", help="the change id; its **Aggregates touched** scopes the answer")
1964
+ cw.add_argument("--feature-dir", dest="feature_dir", default="")
1965
+ cw.add_argument("--context-dir", dest="context_dir", default="",
1966
+ help="the bounded context directory; defaults to the feature dir's parent")
1967
+ cw.add_argument("--root", default="")
1968
+ cw.set_defaults(func=cmd_co_owners)
1969
+
1833
1970
  fa = sub.add_parser("feature-aggregates", help="read-only: the aggregate(s) a feature acts on — spec, then the boundary map, then the feature name")
1834
1971
  fa.add_argument("--feature-dir", dest="feature_dir", default="")
1835
1972
  fa.add_argument("--root", default="")
@@ -12,5 +12,31 @@
12
12
  "prototype_run": "npm start --prefix prototype",
13
13
  "detect": [
14
14
  "web/angular.json"
15
+ ],
16
+ "reserved_names": [
17
+ "Array",
18
+ "Date",
19
+ "Document",
20
+ "Element",
21
+ "Error",
22
+ "Event",
23
+ "Headers",
24
+ "History",
25
+ "Image",
26
+ "Location",
27
+ "Map",
28
+ "Node",
29
+ "Notification",
30
+ "Object",
31
+ "Option",
32
+ "Range",
33
+ "Record",
34
+ "Request",
35
+ "Response",
36
+ "Selection",
37
+ "Set",
38
+ "Storage",
39
+ "Text",
40
+ "Worker"
15
41
  ]
16
42
  }
@@ -13,5 +13,43 @@
13
13
  "detect": [
14
14
  "web/**/*.csproj",
15
15
  "web/**/wwwroot/index.html"
16
+ ],
17
+ "reserved_names": [
18
+ "Activity",
19
+ "Array",
20
+ "Attribute",
21
+ "Buffer",
22
+ "Comparer",
23
+ "Convert",
24
+ "Delegate",
25
+ "Dictionary",
26
+ "Directory",
27
+ "Endpoint",
28
+ "Environment",
29
+ "Exception",
30
+ "File",
31
+ "Guid",
32
+ "Index",
33
+ "List",
34
+ "Lock",
35
+ "Math",
36
+ "Object",
37
+ "Path",
38
+ "Progress",
39
+ "Queue",
40
+ "Random",
41
+ "Range",
42
+ "Results",
43
+ "Route",
44
+ "Stack",
45
+ "Stream",
46
+ "String",
47
+ "Task",
48
+ "Thread",
49
+ "Timer",
50
+ "Tuple",
51
+ "Type",
52
+ "Uri",
53
+ "Version"
16
54
  ]
17
55
  }
@@ -12,5 +12,43 @@
12
12
  "detect": [
13
13
  "*.sln",
14
14
  "src/**/*.csproj"
15
+ ],
16
+ "reserved_names": [
17
+ "Activity",
18
+ "Array",
19
+ "Attribute",
20
+ "Buffer",
21
+ "Comparer",
22
+ "Convert",
23
+ "Delegate",
24
+ "Dictionary",
25
+ "Directory",
26
+ "Endpoint",
27
+ "Environment",
28
+ "Exception",
29
+ "File",
30
+ "Guid",
31
+ "Index",
32
+ "List",
33
+ "Lock",
34
+ "Math",
35
+ "Object",
36
+ "Path",
37
+ "Progress",
38
+ "Queue",
39
+ "Random",
40
+ "Range",
41
+ "Results",
42
+ "Route",
43
+ "Stack",
44
+ "Stream",
45
+ "String",
46
+ "Task",
47
+ "Thread",
48
+ "Timer",
49
+ "Tuple",
50
+ "Type",
51
+ "Uri",
52
+ "Version"
15
53
  ]
16
54
  }