aw-spec-cli 0.1.19 → 0.1.21

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 (75) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +17 -9
  2. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  3. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +26 -3
  4. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  5. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +1 -1
  6. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +4 -2
  7. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +1 -1
  8. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +1 -1
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +121 -42
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -7
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +145 -52
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +18 -10
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +306 -28
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +1 -1
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -1
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +1 -1
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +114 -30
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +385 -61
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +217 -174
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +407 -107
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +1 -1
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +49 -11
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +1 -1
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +1 -1
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +29 -23
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  30. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +19 -11
  31. package/assets/.appweaver/scripts/bash/aw-inventory.sh +15 -0
  32. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +683 -92
  33. package/assets/.appweaver/scripts/bash/aw-xref.sh +3 -0
  34. package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +15 -0
  35. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +625 -64
  36. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +3 -0
  37. package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
  38. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  39. package/assets/.appweaver/scripts/python/aw-inventory.py +1829 -63
  40. package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
  41. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
  42. package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
  43. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  44. package/assets/.appweaver/scripts/python/aw-xref.py +500 -81
  45. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
  46. package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
  47. package/assets/.appweaver/stack-profiles/node-express.json +1 -0
  48. package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
  49. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  50. package/assets/.appweaver/templates/delta-design-template.md +1 -1
  51. package/assets/.appweaver/templates/delta-domain-model-template.md +1 -1
  52. package/assets/.appweaver/templates/delta-spec-template.md +4 -3
  53. package/assets/.appweaver/templates/design-template.md +16 -11
  54. package/assets/.appweaver/templates/okf-concept-template.md +11 -1
  55. package/assets/.appweaver/templates/proposed-inventory-template.md +127 -0
  56. package/assets/.appweaver/templates/source-map-template.md +5 -3
  57. package/assets/.appweaver/templates/spec-template.md +17 -17
  58. package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
  59. package/assets/.appweaver/templates/ui-design-template.md +4 -4
  60. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +4489 -361
  61. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2274 -125
  62. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  63. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  64. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  65. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  66. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  67. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  68. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  69. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  70. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
  71. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  72. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  73. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  74. package/package.json +1 -1
  75. package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
@@ -14,8 +14,8 @@ Edges are DERIVED deterministically from the using aggregate's design.md — the
14
14
  domain/data-model section's "References (by id, not owned)" (+ `id<Other>` attribute
15
15
  types) for entity-ref edges, and the External Integrations table (located by heading
16
16
  keyword, so it works for both the forward and brownfield design layouts) for contract
17
- edges — resolved against the project's KNOWN aggregates (a specs/<Y>/ dir or an
18
- existing bundle concept). A
17
+ edges — resolved against the project's KNOWN aggregates (one a feature's spec says it
18
+ acts on, or an existing bundle concept). A
19
19
  target that is not a known aggregate (an external system, an owned child entity)
20
20
  is never written. Writers are greenfield `implement` and brownfield `adopt`; the
21
21
  reader is the CR loop (finalize-delta gates on the Cross-References blast radius).
@@ -224,6 +224,14 @@ def _slug(s):
224
224
  return re.sub(r"-{2,}", "-", s).strip("-")
225
225
 
226
226
 
227
+ def _key(s):
228
+ """A name with case and every separator dropped. The bundle holds one name in three
229
+ spellings — a PascalCase id (MedicinalProduct), an OKF kebab file name
230
+ (medicinal-product.md) and a human title (Medicinal Product) — and a slug only
231
+ matches the last two to each other, so a lookup by id finds neither."""
232
+ return re.sub(r"[^a-z0-9]", "", (s or "").lower())
233
+
234
+
227
235
  def _read(path):
228
236
  try:
229
237
  with open(path, encoding="utf-8", errors="replace") as fh:
@@ -332,12 +340,24 @@ def _today(arg_date):
332
340
  return datetime.date.today().isoformat()
333
341
 
334
342
 
335
- def _agg_from_feature_dir(fd):
336
- """The aggregate name = the feature dir's basename (feature = aggregate). The
337
- workflow passes {{feature_dir}} (absolute); feature-paths carries no name field."""
343
+ def _feature_from_dir(fd):
344
+ """The feature name = the feature directory's basename. The workflow passes
345
+ {{feature_dir}} (absolute); feature-paths carries no name field."""
338
346
  return os.path.basename(os.path.normpath(fd)) if fd else ""
339
347
 
340
348
 
349
+ def _agg_from_feature_dir(fd, root=None):
350
+ """The CONCEPT a feature directory's OKF operations belong to: the aggregate the
351
+ feature answers for, not the feature. The bundle is the project's knowledge and a
352
+ concept is an aggregate, so a feature that is one capability over a shared aggregate
353
+ (PatientRegistration over Patient) reads and writes Patient's concept and never gets
354
+ one of its own. A 1:1 feature resolves to its own name — what every caller keyed on
355
+ before features and aggregates could differ."""
356
+ if not fd:
357
+ return ""
358
+ return _resolve_feature(fd, root or os.getcwd())["primary"]
359
+
360
+
341
361
  def _parse_source_files(raw, root):
342
362
  """Accept the files a generate step owns, as EITHER a JSON array (how the engine
343
363
  renders a list var: `["a.cs","b.cs"]`) OR a comma-separated string. Relativise any
@@ -430,7 +450,8 @@ def _read_fm(text):
430
450
 
431
451
 
432
452
  def _render_fm(fm):
433
- order = ["type", "title", "description", "origin", "source_files", "ui_source_files"]
453
+ order = ["type", "title", "description", "origin", "bounded_context", "features",
454
+ "source_files", "ui_source_files"]
434
455
  keys = [k for k in order if k in fm] + [k for k in fm if k not in order]
435
456
  out = ["---"]
436
457
  for k in keys:
@@ -545,9 +566,10 @@ def _canonical_concept_path(bundle_abs, name):
545
566
 
546
567
 
547
568
  def _resolve_concept(name, bundle_abs):
548
- """Find name's concept (mirrors aw-inventory.py concept): match title/base slug,
549
- prefer a type containing 'aggregate'. Return abspath or None."""
550
- want = _slug(name)
569
+ """Find name's concept (mirrors aw-inventory.py concept): match title or file name
570
+ with case and separators ignored, prefer a type containing 'aggregate'. Return
571
+ abspath or None."""
572
+ want = _key(name)
551
573
  best, best_score = None, -1
552
574
  if not os.path.isdir(bundle_abs):
553
575
  return None
@@ -560,21 +582,22 @@ def _resolve_concept(name, bundle_abs):
560
582
  fm, _ = _read_fm(_read(path))
561
583
  title = fm.get("title") if isinstance(fm.get("title"), str) else ""
562
584
  base = os.path.splitext(fn)[0]
563
- if want not in (_slug(title), _slug(base)):
585
+ if want not in (_key(title), _key(base)):
564
586
  continue
565
587
  typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
566
- score = (2 if "aggregate" in typ.lower() else 0) + (1 if _slug(base) == want else 0)
588
+ score = (2 if "aggregate" in typ.lower() else 0) + (1 if _key(base) == want else 0)
567
589
  if score > best_score:
568
590
  best, best_score = path, score
569
591
  return best
570
592
 
571
593
 
572
- def _skeleton_concept(name, origin, source_files, description):
594
+ def _skeleton_concept(name, origin, source_files, description, features=None):
573
595
  fm = {
574
596
  "type": "aggregate",
575
597
  "title": name,
576
598
  "description": description or f"{name} aggregate.",
577
599
  "origin": origin or "greenfield",
600
+ "features": list(features or []),
578
601
  "source_files": list(source_files or []),
579
602
  }
580
603
  overview = f"[overview of the {name} aggregate]"
@@ -595,14 +618,15 @@ def _ensure_dir_index(concept_path, name):
595
618
  _write(idx, f"# {name}\n\n- [{name}](./{base}.md)\n") # NO frontmatter (OKF E3)
596
619
 
597
620
 
598
- def _get_or_stub_concept(name, bundle_abs, origin, stubbed):
599
- """Return the concept path for name, creating a stub (+ dir/root index) if missing."""
621
+ def _get_or_stub_concept(name, bundle_abs, origin, stubbed, features=None):
622
+ """Return the concept path for name, creating a stub (+ dir/root index) if missing.
623
+ A stub starts out listing the features already known to act on the aggregate."""
600
624
  path = _resolve_concept(name, bundle_abs)
601
625
  if path:
602
626
  return path
603
627
  _ensure_bundle(bundle_abs)
604
628
  path = _canonical_concept_path(bundle_abs, name)
605
- _write(path, _skeleton_concept(name, origin, [], ""))
629
+ _write(path, _skeleton_concept(name, origin, [], "", features))
606
630
  _ensure_dir_index(path, name)
607
631
  stubbed.append(name)
608
632
  return path
@@ -623,14 +647,14 @@ def _section(text, start_pat, end_pat):
623
647
 
624
648
 
625
649
  def _known_aggregates(root, specs, bundle_abs):
626
- """The project's aggregate registry: specs/<dir> names ∪ bundle concept titles/dirs.
627
- Returns {slug: display_name}."""
650
+ """The project's aggregate registry: the aggregates every feature acts on (its spec's
651
+ Acts-on line, the boundary map, or its name when neither says) ∪ bundle concept
652
+ titles. A specs/ directory name is a feature or a bounded context, never an
653
+ aggregate by itself. Returns {slug: display_name}."""
628
654
  known = {}
629
- specs_dir = os.path.join(root, specs)
630
- if os.path.isdir(specs_dir):
631
- for name in os.listdir(specs_dir):
632
- if os.path.isdir(os.path.join(specs_dir, name)):
633
- known.setdefault(_slug(name), name)
655
+ for v in _feature_index(root, specs).values():
656
+ for a in v["aggregates"]:
657
+ known.setdefault(_slug(a), a)
634
658
  if os.path.isdir(bundle_abs):
635
659
  for dirpath, dirnames, filenames in os.walk(bundle_abs):
636
660
  dirnames[:] = [d for d in dirnames if not d.startswith(".")]
@@ -772,17 +796,53 @@ def _all_concepts(bundle_abs):
772
796
  return out
773
797
 
774
798
 
799
+ def _plan_path(fd):
800
+ """A feature's backend plan, under its current name or the pre-lane-split one."""
801
+ for n in ("backend-plan.md", "design.md"):
802
+ p = os.path.join(fd, n)
803
+ if os.path.isfile(p):
804
+ return p
805
+ return ""
806
+
807
+
808
+ def _sibling_feature_dirs(root, specs, aggregate, exclude_fd):
809
+ """The OTHER feature directories that answer for `aggregate` and have a plan to
810
+ read — specs/<Feature>/ and specs/<Context>/<Feature>/ alike. Ownership is decided by
811
+ the same rule feature-aggregates applies, so a sibling is exactly what the resolver
812
+ says owns that aggregate."""
813
+ skip = ""
814
+ if exclude_fd:
815
+ skip = os.path.realpath(exclude_fd if os.path.isabs(exclude_fd)
816
+ else os.path.join(root, exclude_fd))
817
+ want = _key(aggregate)
818
+ return [d for d in _feature_dirs(root, specs)
819
+ if os.path.realpath(d) != skip and _plan_path(d)
820
+ and want in {_key(x) for x in _resolve_feature(d, root)["owned"]}]
821
+
822
+
775
823
  # ─────────────────────────────── commands ──────────────────────────────────
776
824
  def cmd_record(args):
777
825
  root = _root(args.root)
778
826
  bundle_abs = os.path.join(root, args.bundle)
779
827
  date = _today(args.date)
780
- using = args.using or _agg_from_feature_dir(args.feature_dir)
781
- if not using:
828
+ # The concept a slice's edges belong to is the AGGREGATE it answers for, never the
829
+ # feature. A feature that answers for several aggregates records under each of them,
830
+ # and a feature that shares an aggregate records beside every other feature that
831
+ # answers for it — which is why each aggregate's edge set is the union over all of
832
+ # them rather than this feature's alone. An explicit --using names the aggregate itself.
833
+ one_to_one, own_keys = True, set()
834
+ if args.using:
835
+ usings = [args.using]
836
+ elif args.feature_dir:
837
+ r = _resolve_feature(args.feature_dir, root)
838
+ usings, one_to_one = list(r["owned"]), r["one_to_one"] == "true"
839
+ own_keys = {_key(a) for a in r["owned"]}
840
+ else:
841
+ usings = []
842
+ if not usings:
782
843
  print("[xref] record: --using or --feature-dir required", file=sys.stderr)
783
844
  _emit({"error": "using-required", "root": root})
784
845
  return 2
785
- using_slug = _slug(using)
786
846
 
787
847
  design_arg = args.design or (os.path.join(args.feature_dir, "design.md") if args.feature_dir else "")
788
848
  if not design_arg:
@@ -795,9 +855,61 @@ def cmd_record(args):
795
855
  if origin == "auto":
796
856
  origin = _detect_origin(design_path)
797
857
 
798
- known = _known_aggregates(root, args.specs, bundle_abs)
858
+ known_all = _known_aggregates(root, args.specs, bundle_abs)
859
+ feature = _feature_from_dir(args.feature_dir) if args.feature_dir else ""
860
+ results = [_record_one(root, bundle_abs, args, u, design_path, feature, own_keys,
861
+ one_to_one, known_all, origin, date) for u in usings]
862
+
863
+ edges, wrote, stubbed, owners = [], [], [], []
864
+ for res in results:
865
+ edges.extend(dict(e, using=res["using"]) for e in res["edges"])
866
+ wrote.extend(res["wrote"])
867
+ stubbed.extend(s for s in res["stubbed"] if s not in stubbed)
868
+ owners.extend(o for o in res["owners"] if o not in owners)
869
+ pruned = sum(res["pruned"] for res in results)
870
+ print(f"[xref] recorded {', '.join(usings)} → {len(edges)} edge(s); "
871
+ f"stubbed {len(stubbed)}, pruned {pruned} stale row(s)", file=sys.stderr)
872
+ _emit({"using": usings[0], "usings": usings, "origin": origin, "edges": edges,
873
+ "edge_count": len(edges), "wrote": wrote, "stubbed": stubbed, "pruned": pruned,
874
+ "owners": owners, "one_to_one": "true" if one_to_one else "false",
875
+ "concept": results[0]["concept"], "root": root})
876
+ return 0
877
+
878
+
879
+ def _record_one(root, bundle_abs, args, using, design_path, feature, own_keys,
880
+ one_to_one, known_all, origin, date):
881
+ """Reconcile ONE aggregate's edges: this feature's plan plus, where the aggregate is
882
+ shared, every other feature's plan that answers for it."""
883
+ using_slug = _slug(using)
884
+ known = dict(known_all)
799
885
  known.pop(using_slug, None)
800
- edges = _design_edges(_read(design_path), using, known)
886
+
887
+ # An aggregate a feature itself answers for is never a dependency of another one it
888
+ # answers for: the reference between them is that one feature's code, not a boundary
889
+ # the model draws. Recorded, it would make a feature over Order + Inventory put each
890
+ # aggregate on the other's blast radius — a cycle no wave ordering can break.
891
+ def plan_edges(text, exclude):
892
+ return [e for e in _design_edges(text, using, known) if _key(e["target"]) not in exclude]
893
+
894
+ edges = plan_edges(_read(design_path), own_keys)
895
+
896
+ # A SHARED AGGREGATE'S EDGES ARE EVERY FEATURE'S. The delete phase below strips every
897
+ # row recorded under this aggregate and the add phase writes back only what it is
898
+ # handed, so recording one feature from its own plan would erase the edges the others
899
+ # recorded. Each other feature's CURRENT plan is read instead and the rows written are
900
+ # the union, which keeps reconcile-on-write self-healing for all of them. A feature
901
+ # that alone answers for the aggregate has no one to union with.
902
+ owners = [feature or using]
903
+ if not one_to_one:
904
+ seen_e = {(e["target"], e["via"], e["kind"]) for e in edges}
905
+ for sd in _sibling_feature_dirs(root, args.specs, using, args.feature_dir):
906
+ sr = _resolve_feature(sd, root)
907
+ owners.append(sr["feature"])
908
+ for e in plan_edges(_read(_plan_path(sd)), {_key(a) for a in sr["owned"]}):
909
+ k3 = (e["target"], e["via"], e["kind"])
910
+ if k3 not in seen_e:
911
+ seen_e.add(k3)
912
+ edges.append(e)
801
913
 
802
914
  # RECONCILE-ON-WRITE, delete phase: strip every row this slice authored, anywhere.
803
915
  # The ROWS are what this rewrites, so the lock has to span reading them and writing
@@ -818,7 +930,8 @@ def cmd_record(args):
818
930
  # same lock, spanning the read and the write.
819
931
  wrote, stubbed = [], []
820
932
  for e in edges:
821
- ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed)
933
+ ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed,
934
+ _features_on(root, e["target"], specs=args.specs))
822
935
  with _lock(ypath):
823
936
  rows = _read_xref_rows(ypath)
824
937
  rows = [r for r in rows if not (_slug(r[0]) == using_slug and (len(r) > 1 and r[1] == e["via"]))]
@@ -827,40 +940,49 @@ def cmd_record(args):
827
940
  wrote.append({"target": e["target"], "concept": _rel(ypath, root), "via": e["via"], "kind": e["kind"]})
828
941
 
829
942
  # Rewrite this slice's own Depends-On (forward prune index) — stub self if needed.
830
- xpath = _get_or_stub_concept(using, bundle_abs, origin, stubbed)
943
+ xpath = _get_or_stub_concept(using, bundle_abs, origin, stubbed,
944
+ _features_on(root, using, specs=args.specs))
831
945
  _write_deps_rows(xpath, [[e["target"], e["via"], e["kind"]] for e in edges])
832
-
833
- print(f"[xref] recorded {using} → {len(edges)} edge(s); "
834
- f"stubbed {len(stubbed)}, pruned {pruned} stale row(s)", file=sys.stderr)
835
- _emit({"using": using, "origin": origin, "edges": edges, "edge_count": len(edges),
836
- "wrote": wrote, "stubbed": stubbed, "pruned": pruned,
837
- "concept": _rel(xpath, root), "root": root})
838
- return 0
946
+ return {"using": using, "edges": edges, "wrote": wrote, "stubbed": stubbed,
947
+ "pruned": pruned, "owners": owners, "concept": _rel(xpath, root)}
839
948
 
840
949
 
841
950
  def cmd_list(args):
842
951
  root = _root(args.root)
843
952
  bundle_abs = os.path.join(root, args.bundle)
844
- aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
845
- if not aggregate:
953
+ # A feature's blast radius is that of EVERY aggregate it answers for: a change to a
954
+ # feature over Order and Inventory can ripple into whatever uses either of them.
955
+ if args.aggregate:
956
+ aggregates = [args.aggregate]
957
+ elif args.feature_dir:
958
+ aggregates = list(_resolve_feature(args.feature_dir, root)["owned"])
959
+ else:
960
+ aggregates = []
961
+ if not aggregates:
846
962
  print("[xref] list: --aggregate or --feature-dir required", file=sys.stderr)
847
963
  _emit({"error": "aggregate-required", "root": root})
848
964
  return 2
965
+ aggregate = aggregates[0]
849
966
  path = _resolve_concept(aggregate, bundle_abs)
850
- users = []
851
- if path:
852
- for r in _read_xref_rows(path):
853
- users.append({
967
+ users, seen_u = [], set()
968
+ for agg in aggregates:
969
+ apath = _resolve_concept(agg, bundle_abs)
970
+ for r in (_read_xref_rows(apath) if apath else []):
971
+ u = {
854
972
  "using": r[0],
855
973
  "via": r[1] if len(r) > 1 else "",
856
974
  "kind": r[2] if len(r) > 2 else "",
857
975
  "origin": r[3] if len(r) > 3 else "",
858
976
  "recorded": r[4] if len(r) > 4 else "",
859
- })
977
+ }
978
+ k3 = (u["using"], u["via"], u["kind"])
979
+ if k3 not in seen_u:
980
+ seen_u.add(k3)
981
+ users.append(u)
860
982
  out_rel = ""
861
983
  if args.out:
862
984
  out_path = args.out if os.path.isabs(args.out) else os.path.join(root, args.out)
863
- _write(out_path, _render_impact_md(aggregate, users))
985
+ _write(out_path, _render_impact_md(", ".join(aggregates), users))
864
986
  out_rel = _rel(out_path, root)
865
987
  # Direct referrers (identifier-clean, de-duped) + the transitive closure — the
866
988
  # integrity blast radius a caller runs tests over. count/users stay as-is for
@@ -870,10 +992,13 @@ def cmd_list(args):
870
992
  name = (u["using"] or "").strip()
871
993
  if _IDENT_RE.match(name) and name not in seen_d:
872
994
  seen_d.add(name); direct.append(name)
873
- closure = _referrer_closure(aggregate, bundle_abs)
874
- print(f"[xref] {aggregate}: {len(users)} downstream user(s) "
995
+ closure = []
996
+ for agg in aggregates:
997
+ closure.extend(c for c in _referrer_closure(agg, bundle_abs) if c not in closure)
998
+ print(f"[xref] {', '.join(aggregates)}: {len(users)} downstream user(s) "
875
999
  f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}", file=sys.stderr)
876
- _emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
1000
+ _emit({"aggregate": aggregate, "aggregates": aggregates,
1001
+ "concept": _rel(path, root) if path else "",
877
1002
  "users": users, "count": len(users), "impact_report": out_rel, "root": root,
878
1003
  "referrers": direct, "referrers_csv": ",".join(direct),
879
1004
  "referrer_closure": closure, "referrer_closure_csv": ",".join(closure),
@@ -886,19 +1011,29 @@ def cmd_concept_upsert(args):
886
1011
  root = _root(args.root)
887
1012
  bundle_abs = os.path.join(root, args.bundle)
888
1013
  origin = args.origin or "greenfield"
889
- aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
1014
+ aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir, root)
890
1015
  if not aggregate:
891
1016
  print("[xref] concept-upsert: --aggregate or --feature-dir required", file=sys.stderr)
892
1017
  _emit({"error": "aggregate-required", "root": root})
893
1018
  return 2
894
1019
  source_files = _parse_source_files(args.source_files, root)
1020
+ # THE CONCEPT KNOWS ITS FEATURES. A concept is an aggregate, and several features may
1021
+ # act on one — so each concept lists, under `features:`, every feature known to act on
1022
+ # it. The files land on ONE concept (the aggregate this feature answers for first, so
1023
+ # a file has one home); the feature is linked on every aggregate it acts on.
1024
+ feature, acted = "", []
1025
+ if args.feature_dir and not args.aggregate:
1026
+ fr = _resolve_feature(args.feature_dir, root)
1027
+ feature, acted = fr["feature"], fr["aggregates"]
895
1028
 
896
1029
  path = _resolve_concept(aggregate, bundle_abs)
897
1030
  created = path is None
898
1031
  if created:
899
1032
  _ensure_bundle(bundle_abs)
900
1033
  path = _canonical_concept_path(bundle_abs, aggregate)
901
- _write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""))
1034
+ _write(path, _skeleton_concept(aggregate, origin, source_files, args.description or "",
1035
+ _merge_features(_features_on(root, aggregate),
1036
+ [feature])))
902
1037
  _ensure_dir_index(path, aggregate)
903
1038
  else:
904
1039
  # Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
@@ -929,19 +1064,61 @@ def cmd_concept_upsert(args):
929
1064
  elif "description" not in fm:
930
1065
  fm["description"] = f"{aggregate} aggregate."
931
1066
  fm.setdefault("origin", origin)
932
- rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
933
- else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
934
- _write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
1067
+ if feature:
1068
+ fm["features"] = _merge_features(fm.get("features"), [feature])
1069
+ _write(path, _rebuild_concept(fm, body))
935
1070
  _ensure_bundle(bundle_abs)
936
1071
  _ensure_dir_index(path, aggregate)
937
1072
 
1073
+ # Every OTHER aggregate this feature acts on lists it too, where that aggregate has a
1074
+ # concept already. One with no concept yet picks the feature up when it is created:
1075
+ # creation seeds the list from every feature known to act on the aggregate.
1076
+ linked = [aggregate] if feature else []
1077
+ for a in acted:
1078
+ apath = _resolve_concept(a, bundle_abs) if _key(a) != _key(aggregate) else None
1079
+ if apath:
1080
+ _link_feature(apath, feature)
1081
+ linked.append(a)
1082
+
938
1083
  print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
939
- f"({len(source_files)} source file(s)) — {_rel(path, root)}", file=sys.stderr)
1084
+ f"({len(source_files)} source file(s)) — {_rel(path, root)}"
1085
+ + (f"; {feature} listed on {', '.join(linked)}" if feature else ""), file=sys.stderr)
940
1086
  _emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
941
- "source_file_count": len(source_files), "root": root})
1087
+ "source_file_count": len(source_files), "feature": feature,
1088
+ "features_linked": linked, "root": root})
942
1089
  return 0
943
1090
 
944
1091
 
1092
+ def _merge_features(existing, names):
1093
+ """`existing` plus each of `names` not already listed, compared case- and
1094
+ punctuation-insensitively. Never drops an entry: a concept's feature list only grows,
1095
+ the same as adopt extends a concept additively."""
1096
+ out = [x for x in (existing if isinstance(existing, list) else []) if x]
1097
+ keys = {_key(x) for x in out}
1098
+ for n in names:
1099
+ if n and _key(n) not in keys:
1100
+ keys.add(_key(n))
1101
+ out.append(n)
1102
+ return out
1103
+
1104
+
1105
+ def _rebuild_concept(fm, body):
1106
+ rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
1107
+ else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
1108
+ return rebuilt.rstrip("\n") + "\n" # _read_fm's split drops the final newline
1109
+
1110
+
1111
+ def _link_feature(path, feature):
1112
+ """Add `feature` to a concept's `features:` list, under the concept's lock, touching
1113
+ nothing else in the file."""
1114
+ with _lock(path):
1115
+ fm, body = _read_fm(_read(path))
1116
+ merged = _merge_features(fm.get("features"), [feature])
1117
+ if merged != fm.get("features"):
1118
+ fm["features"] = merged
1119
+ _write(path, _rebuild_concept(fm, body))
1120
+
1121
+
945
1122
  def cmd_concept_overview(args):
946
1123
  """Splice model-authored PROSE into the concept's `## Overview` — the one section a
947
1124
  deterministic script cannot write. Section-scoped on purpose: the model never owns
@@ -949,7 +1126,7 @@ def cmd_concept_overview(args):
949
1126
  Runs AFTER concept-upsert (which owns creation + frontmatter)."""
950
1127
  root = _root(args.root)
951
1128
  bundle_abs = os.path.join(root, args.bundle)
952
- aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
1129
+ aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir, root)
953
1130
  if not aggregate:
954
1131
  print("[xref] concept-overview: --aggregate or --feature-dir required", file=sys.stderr)
955
1132
  _emit({"error": "aggregate-required", "root": root})
@@ -1011,7 +1188,7 @@ def cmd_concept_paths(args):
1011
1188
  than swallowed."""
1012
1189
  root = _root(args.root)
1013
1190
  bundle_abs = os.path.join(root, args.bundle)
1014
- aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
1191
+ aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir, root)
1015
1192
  if not aggregate:
1016
1193
  print("[xref] concept-paths: --aggregate or --feature-dir required", file=sys.stderr)
1017
1194
  _emit({"error": "aggregate-required", "root": root})
@@ -1035,18 +1212,255 @@ def cmd_concept_paths(args):
1035
1212
  target = s if os.path.isabs(s) else os.path.join(root, s)
1036
1213
  (existing if os.path.isfile(target) else missing).append(s)
1037
1214
 
1215
+ # A feature that is one capability over a shared aggregate is made of ITS files, not
1216
+ # the aggregate's: the concept lists every capability's code, and a fix loop handed a
1217
+ # sibling's files will edit them. The source-map adopt writes is the per-feature
1218
+ # list. Without one — a capability adopt has not reached yet — the aggregate's list
1219
+ # stands: over-inclusive, but never missing a file this feature does own.
1220
+ scope = "aggregate"
1221
+ if args.feature_dir and not args.aggregate:
1222
+ r = _resolve_feature(args.feature_dir, root)
1223
+ if r["one_to_one"] == "false":
1224
+ mapped = _source_map_files(os.path.join(r["feature_dir"], "source-map.md"))
1225
+ if mapped:
1226
+ declared = mapped
1227
+ existing = [s for s in mapped
1228
+ if os.path.isfile(s if os.path.isabs(s) else os.path.join(root, s))]
1229
+ missing = [s for s in mapped if s not in existing]
1230
+ scope = "feature"
1231
+
1038
1232
  note = "" if path else " — no concept recorded yet"
1039
1233
  drift = f", {len(missing)} declared-but-missing" if missing else ""
1040
- print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{note}",
1041
- file=sys.stderr)
1234
+ print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{note}"
1235
+ f" (scope: {scope})", file=sys.stderr)
1042
1236
  _emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
1043
- "found": bool(path), "paths": existing, "count": len(existing),
1237
+ "found": bool(path), "scope": scope, "paths": existing, "count": len(existing),
1044
1238
  "csv": ",".join(existing), "declared": declared,
1045
1239
  "declared_count": len(declared), "missing": missing,
1046
1240
  "missing_count": len(missing), "root": root})
1047
1241
  return 0
1048
1242
 
1049
1243
 
1244
+ # A source-map's Artefact → spec/design table row: `| SM-nnn | `<artefact>` | …`.
1245
+ _SM_ROW_RE = re.compile(r"^\|\s*SM-\d+\s*\|\s*`([^`]+)`\s*\|", re.M)
1246
+
1247
+
1248
+ def _source_map_files(path):
1249
+ """The legacy FILES a feature's source-map maps — the per-feature file list. Only
1250
+ the Artefact → spec/design table counts, and only rows naming a path: tables,
1251
+ endpoints and jobs share that table and are not files. The Unmapped / open section
1252
+ is exactly what the feature does NOT own, so it is never read."""
1253
+ out = []
1254
+ for p in _SM_ROW_RE.findall(_read(path) or ""):
1255
+ p = p.strip().replace(os.sep, "/")
1256
+ if ("/" in p or re.search(r"\.[A-Za-z0-9]{1,6}$", p)) and p not in out:
1257
+ out.append(p)
1258
+ return out
1259
+
1260
+
1261
+ # ──────────────────── feature-aggregates (read-only) ───────────────────────
1262
+ # The spec template's Domain Model Reference line. Only what precedes the "— see …"
1263
+ # pointer names aggregates; the pointer itself names the model file.
1264
+ _ACTS_ON_RE = re.compile(r"^\s*[-*]?\s*\*\*Acts on aggregate\(s\)\*\*\s*:\s*(.*)$", re.I | re.M)
1265
+ _AGG_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
1266
+
1267
+
1268
+ def _acts_on_from_spec(spec_path):
1269
+ """The aggregate names a spec's `**Acts on aggregate(s)**:` line gives, in order.
1270
+ Backticked names are read first; a line written without backticks falls back to
1271
+ comma-separated bare names. A bracketed template token such as `[Root]` is not a
1272
+ name — a scaffolded spec that nobody has filled yet answers nothing here."""
1273
+ m = _ACTS_ON_RE.search(_read(spec_path) or "")
1274
+ if not m:
1275
+ return []
1276
+ head = re.split(r"\s+[—–]\s+see\b|\s+-\s+see\b", m.group(1), maxsplit=1)[0]
1277
+ names = re.findall(r"`([^`]+)`", head) or re.split(r"\s*[,;]\s*|\s+and\s+", head)
1278
+ out = []
1279
+ for n in names:
1280
+ n = (n or "").strip().strip("*").strip()
1281
+ if n and "[" not in n and _AGG_NAME_RE.match(n) and n not in out:
1282
+ out.append(n)
1283
+ return out
1284
+
1285
+
1286
+ def _aggregates_from_map(root, feature):
1287
+ """The aggregate(s) the confirmed boundary map assigns to a feature. Matched with
1288
+ case and punctuation ignored, because the map carries the name as the operator
1289
+ typed it ("care plan") and the directory carries it normalised (CarePlan)."""
1290
+ try:
1291
+ with open(os.path.join(root, ".appweaver", "inventory", "feature-map.json"),
1292
+ encoding="utf-8") as fh:
1293
+ m = json.load(fh)
1294
+ except (OSError, ValueError):
1295
+ return []
1296
+ key = lambda s: re.sub(r"[^a-z0-9]", "", (s or "").lower())
1297
+ for f in m.get("features", []) or []:
1298
+ if key(f.get("feature")) == key(feature):
1299
+ aggs = f.get("aggregates") or ([f.get("aggregate")] if f.get("aggregate") else [])
1300
+ return [a for a in dict.fromkeys(aggs) if a]
1301
+ return []
1302
+
1303
+
1304
+ def _raw_aggregates(fd_abs, root, feature):
1305
+ """(aggregates, source) for one feature directory — the spec's Acts-on line, then
1306
+ the boundary map, then the feature name. No ownership is decided here."""
1307
+ aggs, source = _acts_on_from_spec(os.path.join(fd_abs, "spec.md")), "spec"
1308
+ if not aggs:
1309
+ aggs, source = _aggregates_from_map(root, feature), "feature-map"
1310
+ if not aggs:
1311
+ aggs, source = [feature], "feature-name"
1312
+ return aggs, source
1313
+
1314
+
1315
+ def _home(feature, aggs):
1316
+ """The one aggregate a feature is ABOUT, when it has one: the aggregate it is named
1317
+ after, or the only aggregate it acts on. Every other aggregate a feature with a home
1318
+ names is one it merely touches. A feature over several aggregates named after none
1319
+ of them has no home — it is a capability across all of them."""
1320
+ mine = [a for a in aggs if _key(a) == _key(feature)]
1321
+ return mine[:1] or (list(aggs[:1]) if len(aggs) == 1 else [])
1322
+
1323
+
1324
+ def _pascal_name(s):
1325
+ return "".join(w[:1].upper() + w[1:] for w in re.split(r"[^A-Za-z0-9]+", s or "") if w)
1326
+
1327
+
1328
+ def _feature_dirs(root, specs="specs"):
1329
+ """Every feature directory in the specs tree: specs/<Feature>/ in a flat layout and
1330
+ specs/<Context>/<Feature>/ inside a bounded context. A top-level directory with no
1331
+ spec.md of its own that holds a domain-model.md or sub-directories is a context, and
1332
+ every child of a context is a feature, specified or not."""
1333
+ base = os.path.join(root, specs)
1334
+ out = []
1335
+ if not os.path.isdir(base):
1336
+ return out
1337
+ for a in sorted(os.listdir(base)):
1338
+ d = os.path.join(base, a)
1339
+ if a.startswith(".") or not os.path.isdir(d):
1340
+ continue
1341
+ subs = [b for b in sorted(os.listdir(d))
1342
+ if not b.startswith(".") and os.path.isdir(os.path.join(d, b))]
1343
+ if not os.path.isfile(os.path.join(d, "spec.md")) and (
1344
+ os.path.isfile(os.path.join(d, "domain-model.md")) or subs):
1345
+ out.extend(os.path.join(d, b) for b in subs)
1346
+ else:
1347
+ out.append(d)
1348
+ return out
1349
+
1350
+
1351
+ _INDEX_CACHE = {}
1352
+
1353
+
1354
+ def _feature_index(root, specs="specs"):
1355
+ """Every feature the project defines — the specs tree ∪ the confirmed boundary map —
1356
+ with the aggregates it acts on and the ones it CLAIMS (answers for). A feature with a
1357
+ home claims only its home; a capability claims every aggregate it acts on. Keyed by
1358
+ the normalised feature name. Read once per process: nothing here writes the tree."""
1359
+ ck = (os.path.realpath(root), specs)
1360
+ if ck in _INDEX_CACHE:
1361
+ return _INDEX_CACHE[ck]
1362
+ idx = {}
1363
+ for d in _feature_dirs(root, specs):
1364
+ name = _feature_from_dir(d)
1365
+ aggs, _ = _raw_aggregates(d, root, name)
1366
+ idx[_key(name)] = {"feature": name, "dir": d, "aggregates": aggs,
1367
+ "claims": _home(name, aggs) or aggs}
1368
+ try:
1369
+ with open(os.path.join(root, ".appweaver", "inventory", "feature-map.json"),
1370
+ encoding="utf-8") as fh:
1371
+ mapped = json.load(fh).get("features") or []
1372
+ except (OSError, ValueError):
1373
+ mapped = []
1374
+ for f in mapped:
1375
+ name = _pascal_name(f.get("feature", ""))
1376
+ if not name or _key(name) in idx:
1377
+ continue
1378
+ aggs = [a for a in dict.fromkeys(f.get("aggregates") or [f.get("aggregate")]) if a]
1379
+ if aggs:
1380
+ idx[_key(name)] = {"feature": name, "dir": "", "aggregates": aggs,
1381
+ "claims": _home(name, aggs) or aggs}
1382
+ _INDEX_CACHE[ck] = idx
1383
+ return idx
1384
+
1385
+
1386
+ def _features_on(root, aggregate, field="aggregates", specs="specs"):
1387
+ """The features that act on (field="aggregates") or answer for (field="claims") an
1388
+ aggregate, in name order."""
1389
+ k = _key(aggregate)
1390
+ return sorted((v["feature"] for v in _feature_index(root, specs).values()
1391
+ if k in {_key(a) for a in v[field]}), key=str.lower)
1392
+
1393
+
1394
+ def _resolve_feature(fd, root):
1395
+ """What feature-aggregates reports, as a dict — shared with every concept operation
1396
+ that is handed a feature directory, so all of them agree on which concept it is."""
1397
+ feature = _feature_from_dir(fd)
1398
+ fd_abs = fd if os.path.isabs(fd) else os.path.join(root, fd)
1399
+ aggs, source = _raw_aggregates(fd_abs, root, feature)
1400
+ # OWNERSHIP IS DECIDED BY WHO ELSE ANSWERS FOR THE AGGREGATE, NEVER BY THE NAME. A
1401
+ # feature answers outright for its home only while no other feature claims that
1402
+ # aggregate too; the moment a second one does, both are capabilities over a shared
1403
+ # aggregate. A name that matches the aggregate is the developer's naming choice,
1404
+ # not a claim that no other feature will ever act on it.
1405
+ home = _home(feature, aggs)
1406
+ owned = home or aggs
1407
+ own_keys = {_key(a) for a in owned}
1408
+ shared_with = [v["feature"] for v in _feature_index(root).values()
1409
+ if _key(v["feature"]) != _key(feature)
1410
+ and own_keys & {_key(a) for a in v["claims"]}]
1411
+ shared_with = sorted(dict.fromkeys(shared_with), key=str.lower)
1412
+ one_to_one = bool(home) and not shared_with
1413
+ fmt = lambda xs: ", ".join(f"`## Aggregate: {a}`" for a in xs)
1414
+ return {"feature": feature, "feature_dir": fd_abs, "source": source,
1415
+ "aggregates": aggs, "aggregates_csv": ", ".join(aggs), "sections": fmt(aggs),
1416
+ "owned": owned, "owned_csv": ", ".join(owned), "owned_sections": fmt(owned),
1417
+ "primary": owned[0], "one_to_one": "true" if one_to_one else "false",
1418
+ "shared_with": shared_with, "shared_with_csv": ", ".join(shared_with)}
1419
+
1420
+
1421
+ def cmd_feature_aggregates(args):
1422
+ """WHICH AGGREGATE(S) A FEATURE ACTS ON — the name every command that reads the
1423
+ bounded context's domain-model.md needs, because the model is organised by
1424
+ aggregate (`## Aggregate: <Name>`) and the specs tree by feature.
1425
+
1426
+ For most features the two names are the same, and every caller used the feature
1427
+ name as the section heading. That stops being true the moment one aggregate
1428
+ carries two capabilities — PatientRegistration and PatientMerge over Patient —
1429
+ and a lookup by feature name then finds no section. The failure is silent in the
1430
+ worst place: a conformance gate scoring "the BR-nnn under this feature's section"
1431
+ finds none and passes.
1432
+
1433
+ Three sources, most authoritative first:
1434
+ 1. spec — the spec's own `**Acts on aggregate(s)**:` line. The document-level
1435
+ answer once a spec exists, and the one a change updates.
1436
+ 2. map — the confirmed boundary map (.appweaver/inventory/feature-map.json),
1437
+ which /appweaver inventory writes and the operator edits. Covers a
1438
+ brownfield feature before its spec exists, which is exactly when
1439
+ adopt needs to know.
1440
+ 3. feature — the feature name itself: the 1:1 default, and the only answer a
1441
+ greenfield feature with no spec yet can have. Every caller behaves
1442
+ exactly as it did before this resolver existed.
1443
+
1444
+ Two answers come back. `aggregates` is everything the feature acts on. `owned` is
1445
+ the part of the model it answers for: its own section when it is itself one of
1446
+ those aggregates — the 1:1 case, even where the spec also names an aggregate it
1447
+ merely touches — and otherwise every aggregate it names.
1448
+
1449
+ STRICTLY READ-ONLY and never empty: source 3 always answers, so a caller never has
1450
+ to branch on a missing result."""
1451
+ root = _root(args.root)
1452
+ if not _feature_from_dir(args.feature_dir):
1453
+ print("[xref] feature-aggregates: --feature-dir required", file=sys.stderr)
1454
+ _emit({"error": "feature-dir-required", "root": root})
1455
+ return 2
1456
+ r = _resolve_feature(args.feature_dir, root)
1457
+ print(f"[xref] feature-aggregates {r['feature']}: acts on {r['aggregates_csv']} "
1458
+ f"(from {r['source']}); owns {r['owned_csv']}", file=sys.stderr)
1459
+ r["root"] = root
1460
+ _emit(r)
1461
+ return 0
1462
+
1463
+
1050
1464
  # ─────────────────────────────── validate ──────────────────────────────────
1051
1465
  def cmd_validate(args):
1052
1466
  """Cross-reference drift check (the safety net BEYOND reconcile-on-write). Pairs the
@@ -1196,28 +1610,28 @@ def cmd_tree(args):
1196
1610
  "xrefs": [r[0] for r in xref_rows if len(r) >= 1]
1197
1611
  }
1198
1612
 
1199
- # 2. Check specs/ directory for features
1200
- if os.path.isdir(specs_abs):
1201
- for entry in os.listdir(specs_abs):
1202
- entry_path = os.path.join(specs_abs, entry)
1203
- if os.path.isdir(entry_path) and not entry.startswith("."):
1204
- slug = _slug(entry)
1205
- if slug not in aggregates:
1206
- design_p = os.path.join(entry_path, "design.md")
1207
- spec_p = os.path.join(entry_path, "spec.md")
1208
- if os.path.exists(design_p) or os.path.exists(spec_p):
1209
- dep_targets = []
1210
- if os.path.exists(design_p):
1211
- known_map = {s: s for s in aggregates}
1212
- edges = _design_edges(_read(design_p), entry, known_map)
1213
- dep_targets = [e["target"] for e in edges]
1214
- aggregates[slug] = {
1215
- "name": entry,
1216
- "slug": slug,
1217
- "concept_path": _rel(spec_p if os.path.exists(spec_p) else entry_path, root),
1218
- "deps": list(set(dep_targets)),
1219
- "xrefs": []
1220
- }
1613
+ # 2. Aggregates the specs tree knows that have no concept yet. A specs/ directory is a
1614
+ # feature or a bounded context, never an aggregate: each feature contributes the
1615
+ # aggregates it answers for, with the edges its plan declares.
1616
+ for fd in _feature_dirs(root, args.specs):
1617
+ spec_p, plan_p = os.path.join(fd, "spec.md"), _plan_path(fd)
1618
+ if not plan_p and not os.path.isfile(spec_p):
1619
+ continue
1620
+ for agg in _resolve_feature(fd, root)["owned"]:
1621
+ slug = _slug(agg)
1622
+ if slug in aggregates:
1623
+ continue
1624
+ dep_targets = []
1625
+ if plan_p:
1626
+ known_map = {s: s for s in aggregates}
1627
+ dep_targets = [e["target"] for e in _design_edges(_read(plan_p), agg, known_map)]
1628
+ aggregates[slug] = {
1629
+ "name": agg,
1630
+ "slug": slug,
1631
+ "concept_path": _rel(spec_p if os.path.isfile(spec_p) else fd, root),
1632
+ "deps": list(set(dep_targets)),
1633
+ "xrefs": []
1634
+ }
1221
1635
 
1222
1636
  # 3. Fallback scan src/ directory if no aggregates in bundle/specs
1223
1637
  if not aggregates:
@@ -1416,6 +1830,11 @@ def main(argv):
1416
1830
  cp.add_argument("--bundle", default="knowledge")
1417
1831
  cp.set_defaults(func=cmd_concept_paths)
1418
1832
 
1833
+ 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
+ fa.add_argument("--feature-dir", dest="feature_dir", default="")
1835
+ fa.add_argument("--root", default="")
1836
+ fa.set_defaults(func=cmd_feature_aggregates)
1837
+
1419
1838
  vv = sub.add_parser("validate", help="report cross-reference drift (orphan rows)")
1420
1839
  vv.add_argument("--prune", action="store_true", help="remove stale reverse orphans (safe self-heal)")
1421
1840
  vv.add_argument("--root", default="")