aw-spec-cli 0.1.7 → 0.1.9

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 (261) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +200 -0
  2. package/assets/.appweaver/ORCH-DIALECT.md +156 -0
  3. package/assets/.appweaver/RULE-PARTITION.md +175 -0
  4. package/assets/.appweaver/STACK-PROFILES.md +461 -0
  5. package/assets/.appweaver/ai-rules.md +180 -0
  6. package/assets/.appweaver/domain.json +1 -0
  7. package/assets/.appweaver/extensions/.registry +19 -0
  8. package/assets/.appweaver/extensions/agent-context/README.md +57 -0
  9. package/assets/.appweaver/extensions/agent-context/agent-context-config.yml +4 -0
  10. package/assets/.appweaver/extensions/agent-context/commands/aw-spec.agent-context.update.md +26 -0
  11. package/assets/.appweaver/extensions/agent-context/extension.yml +37 -0
  12. package/assets/.appweaver/extensions/agent-context/scripts/bash/update-agent-context.sh +201 -0
  13. package/assets/.appweaver/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +237 -0
  14. package/assets/.appweaver/extensions.yml +16 -0
  15. package/assets/.appweaver/feature.json +3 -0
  16. package/assets/.appweaver/init-options.json +9 -0
  17. package/assets/.appweaver/integration.json +15 -0
  18. package/assets/.appweaver/integrations/aw-spec.manifest.json +9 -0
  19. package/assets/.appweaver/interpreter/run-skill.py +1176 -0
  20. package/assets/.appweaver/memory/constitution.md +50 -0
  21. package/assets/.appweaver/orchestrator/.smoke-out/smoke.md +6 -0
  22. package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +94 -0
  23. package/assets/.appweaver/orchestrator/README.md +300 -0
  24. package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +74 -0
  25. package/assets/.appweaver/orchestrator/adapters/google_antigravity.py +74 -0
  26. package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +105 -0
  27. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +101 -0
  28. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +90 -0
  29. package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +126 -0
  30. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +123 -0
  31. package/assets/.appweaver/orchestrator/orchestrator.py +1721 -0
  32. package/assets/.appweaver/orchestrator/rev-templates/ai-rules.reference.md +40 -0
  33. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +101 -0
  34. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +245 -0
  35. package/assets/.appweaver/orchestrator/rev-templates/domain-constitution.skeleton.md +63 -0
  36. package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
  37. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +59 -0
  38. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
  39. package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +63 -0
  40. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +116 -0
  41. package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
  42. package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
  43. package/assets/.appweaver/orchestrator/rev-templates/ui-constitution.skeleton.md +125 -0
  44. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +123 -0
  45. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +294 -0
  46. package/assets/.appweaver/orchestrator/workflows/_smoke.orch.yaml +160 -0
  47. package/assets/.appweaver/orchestrator/workflows/aw-spec-add-component.orch.yaml +270 -0
  48. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +934 -0
  49. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +251 -0
  50. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +265 -0
  51. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +437 -0
  52. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +197 -0
  53. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +475 -0
  54. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
  55. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +727 -0
  56. package/assets/.appweaver/orchestrator/workflows/aw-spec-demo.orch.yaml +100 -0
  57. package/assets/.appweaver/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
  58. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
  59. package/assets/.appweaver/orchestrator/workflows/aw-spec-design-ui.orch.yaml +386 -0
  60. package/assets/.appweaver/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
  61. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +462 -0
  62. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +265 -0
  63. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +253 -0
  64. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +267 -0
  65. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +287 -0
  66. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +238 -0
  67. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1822 -0
  68. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
  69. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +194 -0
  70. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +83 -0
  71. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
  72. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +296 -0
  73. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +439 -0
  74. package/assets/.appweaver/scripts/bash/aw-inventory.sh +17 -0
  75. package/assets/.appweaver/scripts/bash/aw-parity.sh +13 -0
  76. package/assets/.appweaver/scripts/bash/aw-rev-constitution.sh +13 -0
  77. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +2626 -0
  78. package/assets/.appweaver/scripts/bash/aw-xref.sh +15 -0
  79. package/assets/.appweaver/scripts/bash/common.sh +533 -0
  80. package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +17 -0
  81. package/assets/.appweaver/scripts/powershell/aw-parity.ps1 +13 -0
  82. package/assets/.appweaver/scripts/powershell/aw-rev-constitution.ps1 +13 -0
  83. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +2232 -0
  84. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +15 -0
  85. package/assets/.appweaver/scripts/python/aw-component-catalog.py +121 -0
  86. package/assets/.appweaver/scripts/python/aw-inventory.py +1180 -0
  87. package/assets/.appweaver/scripts/python/aw-parity.py +380 -0
  88. package/assets/.appweaver/scripts/python/aw-rev-constitution.py +329 -0
  89. package/assets/.appweaver/scripts/python/aw-stack.py +1251 -0
  90. package/assets/.appweaver/scripts/python/aw-xref.py +1330 -0
  91. package/assets/.appweaver/scripts/python/validate_okf.py +352 -0
  92. package/assets/.appweaver/stack-profiles/angular.json +16 -0
  93. package/assets/.appweaver/stack-profiles/blazor-wasm.json +17 -0
  94. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +15 -0
  95. package/assets/.appweaver/stack-profiles/go-chi.json +14 -0
  96. package/assets/.appweaver/stack-profiles/java-spring.json +16 -0
  97. package/assets/.appweaver/stack-profiles/nextjs.json +16 -0
  98. package/assets/.appweaver/stack-profiles/node-express.json +15 -0
  99. package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
  100. package/assets/.appweaver/stack-profiles/react-vite.json +17 -0
  101. package/assets/.appweaver/stack-profiles/sveltekit.json +16 -0
  102. package/assets/.appweaver/stack-profiles/vue-vite.json +17 -0
  103. package/assets/.appweaver/stacks.json +3 -0
  104. package/assets/.appweaver/templates/constitution-domain-template.md +116 -0
  105. package/assets/.appweaver/templates/constitution-technical-template.md +108 -0
  106. package/assets/.appweaver/templates/constitution-ui-template.md +116 -0
  107. package/assets/.appweaver/templates/delta-design-template.md +69 -0
  108. package/assets/.appweaver/templates/delta-spec-template.md +63 -0
  109. package/assets/.appweaver/templates/design-template.md +383 -0
  110. package/assets/.appweaver/templates/okf-concept-template.md +114 -0
  111. package/assets/.appweaver/templates/parity-manifest-template.json +49 -0
  112. package/assets/.appweaver/templates/source-map-template.md +85 -0
  113. package/assets/.appweaver/templates/spec-template.md +309 -0
  114. package/assets/.appweaver/templates/ui-design-template.md +273 -0
  115. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.pack.txt +25248 -0
  116. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +5726 -0
  117. package/assets/foundations/dotnet/ModularMonolith/pack-foundation.ps1 +145 -0
  118. package/assets/foundations/dotnet/ModularMonolith/pack-foundation.sh +196 -0
  119. package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.ps1 +157 -0
  120. package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.sh +209 -0
  121. package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.ps1 +126 -0
  122. package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.sh +217 -0
  123. package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.ps1 +129 -0
  124. package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.sh +218 -0
  125. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.pack.txt +19318 -0
  126. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2833 -0
  127. package/assets/foundations/dotnet/VerticalSlice/pack-foundation.ps1 +145 -0
  128. package/assets/foundations/dotnet/VerticalSlice/pack-foundation.sh +196 -0
  129. package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.ps1 +157 -0
  130. package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.sh +209 -0
  131. package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.ps1 +126 -0
  132. package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.sh +217 -0
  133. package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.ps1 +129 -0
  134. package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.sh +218 -0
  135. package/assets/foundations/dotnet/components/Email/README.md +119 -0
  136. package/assets/foundations/dotnet/components/Email/ReferenceEndpoints/SendEmailEndpoint.cs +84 -0
  137. package/assets/foundations/dotnet/components/Email/integration.md +300 -0
  138. package/assets/foundations/dotnet/components/Jobs/README.md +113 -0
  139. package/assets/foundations/dotnet/components/Jobs/integration.md +259 -0
  140. package/assets/foundations/dotnet/components/QrGenerator/README.md +85 -0
  141. package/assets/foundations/dotnet/components/QrGenerator/ReferenceEndpoints/GenerateQrEndpoint.cs +57 -0
  142. package/assets/foundations/dotnet/components/QrGenerator/integration.md +230 -0
  143. package/assets/foundations/dotnet/components/README.md +74 -0
  144. package/assets/guardrails/README.md +222 -0
  145. package/assets/guardrails/domain/healthcare/ai-rules.domain.md +34 -0
  146. package/assets/guardrails/domain/healthcare/api-versioning.domain.md +17 -0
  147. package/assets/guardrails/domain/healthcare/auditing.domain.md +97 -0
  148. package/assets/guardrails/domain/healthcare/authorization.domain.md +26 -0
  149. package/assets/guardrails/domain/healthcare/clinical-terms.md +20 -0
  150. package/assets/guardrails/domain/healthcare/coding-standards.domain.md +26 -0
  151. package/assets/guardrails/domain/healthcare/consent.md +19 -0
  152. package/assets/guardrails/domain/healthcare/cqrs.domain.md +12 -0
  153. package/assets/guardrails/domain/healthcare/data-classification.domain.md +28 -0
  154. package/assets/guardrails/domain/healthcare/dependency-policy.domain.md +11 -0
  155. package/assets/guardrails/domain/healthcare/dicom.md +15 -0
  156. package/assets/guardrails/domain/healthcare/domain-constitution.md +119 -0
  157. package/assets/guardrails/domain/healthcare/domain-driven-design.domain.md +28 -0
  158. package/assets/guardrails/domain/healthcare/eventing.domain.md +18 -0
  159. package/assets/guardrails/domain/healthcare/fhir.md +36 -0
  160. package/assets/guardrails/domain/healthcare/folder-structure.domain.md +25 -0
  161. package/assets/guardrails/domain/healthcare/hipaa.md +34 -0
  162. package/assets/guardrails/domain/healthcare/hl7.md +15 -0
  163. package/assets/guardrails/domain/healthcare/ihe-conformance.md +31 -0
  164. package/assets/guardrails/domain/healthcare/integration-terms.md +21 -0
  165. package/assets/guardrails/domain/healthcare/persistence.domain.md +20 -0
  166. package/assets/guardrails/domain/healthcare/terminology.md +30 -0
  167. package/assets/guardrails/domain/healthcare/testing.domain.md +15 -0
  168. package/assets/guardrails/domain/healthcare/ux/accessibility-standards.md +36 -0
  169. package/assets/guardrails/domain/healthcare/ux/content-design.md +32 -0
  170. package/assets/guardrails/domain/healthcare/ux/interaction-states.md +44 -0
  171. package/assets/guardrails/domain/healthcare/ux/journey-mapping.md +40 -0
  172. package/assets/guardrails/domain/healthcare/vertical-slices.domain.md +18 -0
  173. package/assets/guardrails/domain/healthcare/zero-trust.domain.md +12 -0
  174. package/assets/guardrails/domain/pharma/ai-rules.domain.md +32 -0
  175. package/assets/guardrails/domain/pharma/api-versioning.domain.md +20 -0
  176. package/assets/guardrails/domain/pharma/atc.md +22 -0
  177. package/assets/guardrails/domain/pharma/auditing.domain.md +90 -0
  178. package/assets/guardrails/domain/pharma/authorization.domain.md +25 -0
  179. package/assets/guardrails/domain/pharma/coding-standards.domain.md +28 -0
  180. package/assets/guardrails/domain/pharma/cqrs.domain.md +16 -0
  181. package/assets/guardrails/domain/pharma/data-classification.domain.md +24 -0
  182. package/assets/guardrails/domain/pharma/data-provenance.md +21 -0
  183. package/assets/guardrails/domain/pharma/dependency-policy.domain.md +11 -0
  184. package/assets/guardrails/domain/pharma/domain-constitution.md +113 -0
  185. package/assets/guardrails/domain/pharma/domain-driven-design.domain.md +36 -0
  186. package/assets/guardrails/domain/pharma/domain-requirements.sample.md +103 -0
  187. package/assets/guardrails/domain/pharma/eventing.domain.md +18 -0
  188. package/assets/guardrails/domain/pharma/fhir.md +31 -0
  189. package/assets/guardrails/domain/pharma/folder-structure.domain.md +26 -0
  190. package/assets/guardrails/domain/pharma/hl7.md +17 -0
  191. package/assets/guardrails/domain/pharma/idmp.md +26 -0
  192. package/assets/guardrails/domain/pharma/integration-terms.md +21 -0
  193. package/assets/guardrails/domain/pharma/ndc.md +28 -0
  194. package/assets/guardrails/domain/pharma/persistence.domain.md +23 -0
  195. package/assets/guardrails/domain/pharma/pharma-terms.md +27 -0
  196. package/assets/guardrails/domain/pharma/product-lifecycle.domain.md +30 -0
  197. package/assets/guardrails/domain/pharma/regulatory-compliance.md +19 -0
  198. package/assets/guardrails/domain/pharma/terminology.md +32 -0
  199. package/assets/guardrails/domain/pharma/testing.domain.md +19 -0
  200. package/assets/guardrails/domain/pharma/vertical-slices.domain.md +17 -0
  201. package/assets/guardrails/domain/pharma/zero-trust.domain.md +12 -0
  202. package/assets/guardrails/projects/dotnet-default-project/domain/domain-requirements.md +58 -0
  203. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +113 -0
  204. package/assets/guardrails/projects/dotnet-default-project/frontend/ui-requirements.md +43 -0
  205. package/assets/guardrails/projects/dotnet-default-project/service/technical-requirements.md +40 -0
  206. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +263 -0
  207. package/assets/guardrails/requirement-spec.txt +27 -0
  208. package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +184 -0
  209. package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +44 -0
  210. package/assets/guardrails/technology/dotnet-webapi/auditing.md +96 -0
  211. package/assets/guardrails/technology/dotnet-webapi/authorization-matrix.md +14 -0
  212. package/assets/guardrails/technology/dotnet-webapi/authorization.md +39 -0
  213. package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +54 -0
  214. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +87 -0
  215. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +63 -0
  216. package/assets/guardrails/technology/dotnet-webapi/data-classification.md +38 -0
  217. package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +40 -0
  218. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +91 -0
  219. package/assets/guardrails/technology/dotnet-webapi/eventing.md +57 -0
  220. package/assets/guardrails/technology/dotnet-webapi/examples/api-versioning.md +75 -0
  221. package/assets/guardrails/technology/dotnet-webapi/examples/auditing.md +84 -0
  222. package/assets/guardrails/technology/dotnet-webapi/examples/authorization.md +68 -0
  223. package/assets/guardrails/technology/dotnet-webapi/examples/clean-architecture.md +37 -0
  224. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +97 -0
  225. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +141 -0
  226. package/assets/guardrails/technology/dotnet-webapi/examples/eventing.md +46 -0
  227. package/assets/guardrails/technology/dotnet-webapi/examples/persistence.md +207 -0
  228. package/assets/guardrails/technology/dotnet-webapi/examples/vertical-slices.md +151 -0
  229. package/assets/guardrails/technology/dotnet-webapi/examples/zero-trust.md +48 -0
  230. package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +61 -0
  231. package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +47 -0
  232. package/assets/guardrails/technology/dotnet-webapi/persistence.md +82 -0
  233. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +162 -0
  234. package/assets/guardrails/technology/dotnet-webapi/testing.md +55 -0
  235. package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +70 -0
  236. package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +48 -0
  237. package/assets/guardrails/technology/java-spring/README.md +62 -0
  238. package/assets/guardrails/technology/nextjs/accessibility-implementation.md +56 -0
  239. package/assets/guardrails/technology/nextjs/api-client.md +56 -0
  240. package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +174 -0
  241. package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +203 -0
  242. package/assets/guardrails/technology/nextjs/auth-session.md +68 -0
  243. package/assets/guardrails/technology/nextjs/client-security.md +69 -0
  244. package/assets/guardrails/technology/nextjs/component-architecture.md +60 -0
  245. package/assets/guardrails/technology/nextjs/component-conventions.md +58 -0
  246. package/assets/guardrails/technology/nextjs/design-system.md +60 -0
  247. package/assets/guardrails/technology/nextjs/error-handling.md +72 -0
  248. package/assets/guardrails/technology/nextjs/forms-and-validation.md +75 -0
  249. package/assets/guardrails/technology/nextjs/frontend-testing.md +94 -0
  250. package/assets/guardrails/technology/nextjs/i18n.md +57 -0
  251. package/assets/guardrails/technology/nextjs/observability.md +67 -0
  252. package/assets/guardrails/technology/nextjs/performance-budgets.md +66 -0
  253. package/assets/guardrails/technology/nextjs/routing-navigation.md +52 -0
  254. package/assets/guardrails/technology/nextjs/state-management.md +70 -0
  255. package/assets/guardrails/technology/nextjs/ui-constitution.md +166 -0
  256. package/dist/commands/init.js +2 -2
  257. package/dist/commands/init.js.map +1 -1
  258. package/dist/utils/copy-assets.js +8 -8
  259. package/dist/utils/copy-assets.js.map +1 -1
  260. package/package.json +1 -1
  261. package/assets/docs/pharma-constitution.my-ref.md +0 -86
@@ -0,0 +1,1330 @@
1
+ #!/usr/bin/env python3
2
+ """aw-xref.py — deterministic cross-reference (OKF dependency) substrate.
3
+
4
+ Maintains the cross-slice dependency edges INSIDE the OKF knowledge bundle, so a
5
+ change to one aggregate can see every other aggregate that uses it (its blast
6
+ radius) without a central graph. Two mirrored tables live in each concept:
7
+
8
+ • Depends-On (FORWARD) in the USING aggregate's concept — its own deps + the
9
+ prune index.
10
+ • Cross-References (REVERSE) in the USED aggregate's concept — its blast radius,
11
+ reviewed at CR time.
12
+
13
+ Edges are DERIVED deterministically from the using aggregate's design.md — the §1
14
+ domain/data-model section's "References (by id, not owned)" (+ `id<Other>` attribute
15
+ types) for entity-ref edges, and the External Integrations table (located by heading
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
19
+ target that is not a known aggregate (an external system, an owned child entity)
20
+ is never written. Writers are greenfield `implement` and brownfield `adopt`; the
21
+ reader is the CR loop (finalize-delta gates on the Cross-References blast radius).
22
+
23
+ RECONCILE-ON-WRITE: every `record` first strips ALL rows authored by the using
24
+ slice from every concept, then re-adds its current edges — so a dropped
25
+ dependency self-heals (no stale reverse edge). Rows are keyed by the using slice.
26
+
27
+ Subcommands, each printing a single JSON object as the LAST stdout line (human
28
+ summary to STDERR so it never corrupts the JSON contract):
29
+
30
+ deps --design <design.md> --aggregate X [--root DIR] [--specs specs] [--bundle knowledge]
31
+ -> the internal edges X declares. {aggregate, edges[], edge_count, known[], root}
32
+
33
+ record --using X --design <design.md> [--origin greenfield|reverse-engineered]
34
+ [--date YYYY-MM-DD] [--root DIR] [--specs specs] [--bundle knowledge]
35
+ -> reconcile X's edges across the bundle (stub used concepts if missing, prune
36
+ stale rows, upsert current rows, rewrite X's Depends-On).
37
+ {using, origin, edges[], edge_count, wrote[], stubbed[], pruned, concept, root}
38
+
39
+ list --aggregate Y [--root DIR] [--bundle knowledge]
40
+ -> Y's blast radius (who uses Y). {aggregate, concept, users[], count, root}
41
+
42
+ concept-upsert --aggregate X [--source-files a,b,c] [--merge] [--title T] [--description D]
43
+ [--origin greenfield|reverse-engineered] [--root DIR] [--bundle knowledge]
44
+ -> create/update X's OWN concept (frontmatter type/title/source_files), PRESERVING
45
+ its body — never clobbers the Cross-References other slices wrote. --merge UNIONs
46
+ source_files (CR deltas) instead of replacing. source_files is written as a FLAT
47
+ list, path-grouped by the code's OWN folders via YAML comment headers (adaptive —
48
+ see _group_source_files), so the listing mirrors the real architecture.
49
+ {aggregate, concept, created, source_file_count, root}
50
+
51
+ concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR] [--bundle knowledge]
52
+ -> splice model-authored prose into X's `## Overview` ONLY (the one section a
53
+ deterministic script cannot write). Empty/placeholder text is a no-op.
54
+ Run AFTER concept-upsert. {aggregate, concept, written, root}
55
+
56
+ concept-paths --aggregate X [--root DIR] [--bundle knowledge]
57
+ -> READ-ONLY: the slice's source files, from its concept's `source_files`. The
58
+ answer the executable phases (build/run/analyze-slice) need for "which files is
59
+ this slice made of", replacing the retired `design-paths` fence scan. `paths` is
60
+ filtered to what EXISTS on disk (safe to feed build_context); `missing` reports
61
+ declared-but-absent files (real index drift). Never stubs a concept; no concept
62
+ is an empty answer, not an error.
63
+ {aggregate, concept, found, paths[], count, csv, declared[], declared_count,
64
+ missing[], missing_count, root}
65
+
66
+ validate [--prune] [--root DIR] [--bundle knowledge]
67
+ -> drift check beyond reconcile-on-write. reverse-orphan = a Cross-References row with
68
+ no backing Depends-On (stale; --prune removes it); forward-orphan = a Depends-On
69
+ row with no matching Cross-References (reported; fix by re-record).
70
+ {orphans[], orphan_count, reverse_orphans, forward_orphans, pruned, ok, root}
71
+
72
+ log [--note TEXT] [--date YYYY-MM-DD] [--root DIR] [--bundle knowledge]
73
+ -> append a commit-anchored entry to the bundle log.md (OKF §7 freshness journal).
74
+ No-op when there is no bundle or no git commit. {logged, commit, date, log, root}
75
+
76
+ Only the standard library is used. Usage: aw-xref.py <sub> [options]
77
+ """
78
+
79
+ import argparse
80
+ import datetime
81
+ import json
82
+ import os
83
+ import re
84
+ import subprocess
85
+ import sys
86
+
87
+ # ───────────────────────────── canonical shapes ────────────────────────────
88
+ OVERVIEW_HEADING = "## Overview"
89
+ DEPS_HEADING = "## Depends-On"
90
+ XREF_HEADING = "## Cross-References"
91
+ DEPS_HEADER = ["Target", "Via", "Kind"]
92
+ XREF_HEADER = ["Using slice", "Uses (via)", "Kind", "Origin", "Recorded"]
93
+ DEPS_COMMENT = (
94
+ "<!-- FORWARD edges — sibling aggregates this one references, derived from "
95
+ "design.md §1 (the model section) + its External integrations table. Prune index; "
96
+ "regenerated every implement/adopt. Do not hand-edit. -->"
97
+ )
98
+ XREF_COMMENT = (
99
+ "<!-- REVERSE edges — sibling aggregates that USE this one (its blast radius). "
100
+ "Each row written by the using slice's implement/adopt (reconcile-on-write). "
101
+ "Reviewed at CR time. Do not hand-edit. -->"
102
+ )
103
+ RESERVED = {"index.md", "log.md"}
104
+
105
+ # A Cross-References "Using slice" cell that is safe to hand to a VSTest --filter (and a
106
+ # real aggregate name, not the "_(none observed…)_" placeholder or a malformed cell).
107
+ _IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
108
+
109
+ # ─────────────────── source_files architecture grouping ────────────────────
110
+ # `source_files` stays a FLAT YAML list — validate_okf (E2/W6), the aw-inventory
111
+ # concept resolver and the source-map fidelity check all key on that shape, and
112
+ # both lenient frontmatter parsers in the toolchain only understand scalars +
113
+ # flat block lists. So the grouping is emitted as YAML comment headers, which
114
+ # PyYAML and both lenient parsers skip: readable grouping, zero schema change, no
115
+ # duplicated path list. Regenerated on every write (comments are lost on read and
116
+ # re-derived deterministically), so it is idempotent.
117
+ #
118
+ # ADAPTIVE, PATH-DERIVED grouping. Each header is DERIVED FROM THE CODE'S OWN
119
+ # FOLDERS, so the listing mirrors whatever architecture the code actually uses —
120
+ # the layer folders of a vertical-slice module (Domain / Features / Persistence
121
+ # …), the tier projects of an n-tier / layered app (DataAccess / BusinessLogic /
122
+ # Web …), or a legacy layout as-is. Nothing is forced into a fixed
123
+ # Domain/Application/UI taxonomy the code never had (this replaced the greenfield
124
+ # LAYER_RULES). That keeps the knowledge bundle honest for BOTH pipelines —
125
+ # brownfield especially, where the CODE, not a template, is the source of truth.
126
+ #
127
+ # HOW (see _group_source_files): take the longest common directory prefix across
128
+ # the concept's own NON-test files; each file's group is the FIRST path segment
129
+ # AFTER that shared prefix — i.e. what actually VARIES across the files, which is
130
+ # the architectural axis (the layer folder in a vertical slice, the tier project
131
+ # in an n-tier app). Test files are a cross-cutting concern → one `tests` bucket;
132
+ # repo-root files (no shared structure to derive a group from) → `(root)`.
133
+
134
+ # A source path is a test if it lives under a test root OR is a *Tests / *.spec /
135
+ # *.test file — held out of the common-prefix so it never distorts the axis.
136
+ _TEST_RE = re.compile(
137
+ r"(^|/)tests?/|(^|/)[^/]*Tests?\.(cs|ts|tsx|js|py)$|\.(spec|test)\.[jt]sx?$",
138
+ re.IGNORECASE,
139
+ )
140
+ _TESTS_GROUP = "tests"
141
+ _ROOT_GROUP = "(root)"
142
+
143
+
144
+ def _is_test(path):
145
+ return bool(_TEST_RE.search(path or ""))
146
+
147
+
148
+ def _dir_parts(path):
149
+ """Directory segments of a repo-relative path ([] for a repo-root file)."""
150
+ d = os.path.dirname(path or "")
151
+ return [p for p in d.split("/") if p] if d else []
152
+
153
+
154
+ def _common_prefix(parts_list):
155
+ """Longest shared leading run of path segments across every entry ([] if none)."""
156
+ if not parts_list:
157
+ return []
158
+ common = list(parts_list[0])
159
+ for parts in parts_list[1:]:
160
+ i = 0
161
+ while i < len(common) and i < len(parts) and common[i] == parts[i]:
162
+ i += 1
163
+ del common[i:]
164
+ if not common:
165
+ break
166
+ return common
167
+
168
+
169
+ def _group_source_files(files):
170
+ """[(group, [path, …]), …] with each group DERIVED from the file's own path
171
+ (see the ADAPTIVE grouping note). Files are sorted for a stable, diff-friendly
172
+ listing; groups appear in first-seen order over that sorted list, with the
173
+ cross-cutting `tests` and `(root)` buckets pinned last. Empty groups never
174
+ appear."""
175
+ files = sorted(files)
176
+ prod = [f for f in files if not _is_test(f)]
177
+ # Common prefix over files that actually sit in a directory — a repo-root file
178
+ # (parts == []) is a cross-cutting outlier and must not collapse the shared
179
+ # prefix the real module files derive their groups from.
180
+ prefix = _common_prefix([p for p in (_dir_parts(f) for f in prod) if p])
181
+ n = len(prefix)
182
+
183
+ order, buckets = [], {}
184
+ for f in files:
185
+ if _is_test(f):
186
+ g = _TESTS_GROUP
187
+ else:
188
+ parts = _dir_parts(f)
189
+ if not parts:
190
+ g = _ROOT_GROUP # repo-root file, no shared axis
191
+ else:
192
+ rest = parts[n:]
193
+ # first segment past the shared prefix = the architectural axis;
194
+ # a file sitting directly in the shared folder → that folder's leaf
195
+ g = rest[0] if rest else (prefix[-1] if prefix else _ROOT_GROUP)
196
+ if g not in buckets:
197
+ buckets[g] = []
198
+ order.append(g)
199
+ buckets[g].append(f)
200
+
201
+ tail = [g for g in (_TESTS_GROUP, _ROOT_GROUP) if g in buckets]
202
+ head = [g for g in order if g not in tail]
203
+ return [(g, buckets[g]) for g in head + tail]
204
+
205
+
206
+ # ───────────────────────────── small helpers ───────────────────────────────
207
+ def _root(arg):
208
+ return os.path.abspath(arg) if arg else os.getcwd()
209
+
210
+
211
+ def _rel(path, root):
212
+ return os.path.relpath(path, root).replace(os.sep, "/")
213
+
214
+
215
+ def _emit(obj):
216
+ print(json.dumps(obj))
217
+
218
+
219
+ def _slug(s):
220
+ s = re.sub(r"[^a-z0-9]+", "-", (s or "").lower())
221
+ return re.sub(r"-{2,}", "-", s).strip("-")
222
+
223
+
224
+ def _read(path):
225
+ try:
226
+ with open(path, encoding="utf-8", errors="replace") as fh:
227
+ return fh.read()
228
+ except OSError:
229
+ return ""
230
+
231
+
232
+ def _write(path, text):
233
+ os.makedirs(os.path.dirname(path), exist_ok=True)
234
+ with open(path, "w", encoding="utf-8") as fh:
235
+ fh.write(text)
236
+
237
+
238
+ def _today(arg_date):
239
+ if arg_date and arg_date.strip():
240
+ return arg_date.strip()
241
+ return datetime.date.today().isoformat()
242
+
243
+
244
+ def _agg_from_feature_dir(fd):
245
+ """The aggregate name = the feature dir's basename (feature = aggregate). The
246
+ workflow passes {{feature_dir}} (absolute); feature-paths carries no name field."""
247
+ return os.path.basename(os.path.normpath(fd)) if fd else ""
248
+
249
+
250
+ def _parse_source_files(raw, root):
251
+ """Accept the files a generate step owns, as EITHER a JSON array (how the engine
252
+ renders a list var: `["a.cs","b.cs"]`) OR a comma-separated string. Relativise any
253
+ absolute path to root and normalise to POSIX, so source_files stays repo-relative
254
+ (what validate_okf and the concept resolver expect)."""
255
+ raw = (raw or "").strip()
256
+ if not raw:
257
+ return []
258
+ items = []
259
+ if raw.startswith("["):
260
+ try:
261
+ data = json.loads(raw)
262
+ if isinstance(data, list):
263
+ for it in data:
264
+ if isinstance(it, str):
265
+ items.append(it)
266
+ elif isinstance(it, dict):
267
+ items.append(it.get("path") or it.get("file") or "")
268
+ except json.JSONDecodeError:
269
+ items = [s for s in raw.strip("[]").split(",")]
270
+ else:
271
+ items = raw.split(",")
272
+ out, seen = [], set()
273
+ for s in items:
274
+ s = (s or "").strip().strip('"').strip("'")
275
+ if not s:
276
+ continue
277
+ if os.path.isabs(s):
278
+ s = os.path.relpath(s, root)
279
+ s = s.replace(os.sep, "/")
280
+ if s not in seen:
281
+ seen.add(s)
282
+ out.append(s)
283
+ return out
284
+
285
+
286
+ def _detect_origin(design_path):
287
+ """`--origin auto`: read the slice's provenance from its design.md head — adopt
288
+ stamps `origin: reverse-engineered` (a CR on a brownfield slice keeps that), else
289
+ greenfield. Lets finalize-delta refresh edges without knowing the pipeline."""
290
+ head = _read(design_path)[:2048]
291
+ m = re.search(r"origin:\s*(reverse-engineered|migrated|greenfield)", head, re.IGNORECASE)
292
+ if m:
293
+ return m.group(1).lower()
294
+ if re.search(r"reverse-engineered", head, re.IGNORECASE):
295
+ return "reverse-engineered"
296
+ return "greenfield"
297
+
298
+
299
+ def _render_impact_md(aggregate, users):
300
+ """A human blast-radius report (cr writes it to changes/<cr>/impact.md for review)."""
301
+ lines = [
302
+ f"# Cross-Reference Impact — {aggregate}",
303
+ "",
304
+ f"Downstream slices that USE **{aggregate}** — its blast radius. A change to "
305
+ f"{aggregate} may ripple into these; review or re-verify each before finalizing.",
306
+ "",
307
+ "| Using slice | Uses (via) | Kind | Origin | Recorded |",
308
+ "| --- | --- | --- | --- | --- |",
309
+ ]
310
+ for u in users:
311
+ lines.append(f"| {u['using']} | {u['via']} | {u['kind']} | {u['origin']} | {u['recorded']} |")
312
+ if not users:
313
+ lines.append("| _(none — no slice references this aggregate)_ | | | | |")
314
+ return "\n".join(lines) + "\n"
315
+
316
+
317
+ # ─────────────────────────── frontmatter I/O ───────────────────────────────
318
+ def _read_fm(text):
319
+ """Return (frontmatter dict, body). Scalars are strings; block lists are lists.
320
+ Mirrors aw-inventory.py's lenient parser so the two agree without a YAML dep."""
321
+ if not text.startswith("---"):
322
+ return {}, text
323
+ lines = text.splitlines()
324
+ end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
325
+ if end is None:
326
+ return {}, text
327
+ fm, key = {}, None
328
+ for ln in lines[1:end]:
329
+ m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", ln)
330
+ if m:
331
+ key = m.group(1)
332
+ val = m.group(2).strip()
333
+ fm[key] = val.strip("'\"") if val else []
334
+ elif key and re.match(r"^\s*-\s+", ln):
335
+ if not isinstance(fm.get(key), list):
336
+ fm[key] = []
337
+ fm[key].append(re.sub(r"^\s*-\s+", "", ln).strip().strip("'\""))
338
+ return fm, "\n".join(lines[end + 1:])
339
+
340
+
341
+ def _render_fm(fm):
342
+ order = ["type", "title", "description", "origin", "source_files", "ui_source_files"]
343
+ keys = [k for k in order if k in fm] + [k for k in fm if k not in order]
344
+ out = ["---"]
345
+ for k in keys:
346
+ v = fm[k]
347
+ if isinstance(v, list):
348
+ out.append(f"{k}:")
349
+ if k in ("source_files", "ui_source_files") and v:
350
+ # flat list, path-grouped by the code's own folders (see _group_source_files)
351
+ for layer, items in _group_source_files(v):
352
+ out.append(f" # {layer}")
353
+ out.extend(f" - {it}" for it in items)
354
+ else:
355
+ for it in v:
356
+ out.append(f" - {it}")
357
+ else:
358
+ out.append(f"{k}: {v}")
359
+ out.append("---")
360
+ return "\n".join(out)
361
+
362
+
363
+ # ─────────────────────── markdown section / table I/O ───────────────────────
364
+ def _render_table(header, rows):
365
+ out = ["| " + " | ".join(header) + " |",
366
+ "| " + " | ".join(["---"] * len(header)) + " |"]
367
+ for r in rows:
368
+ cells = (list(r) + [""] * len(header))[:len(header)]
369
+ out.append("| " + " | ".join(cells) + " |")
370
+ return "\n".join(out)
371
+
372
+
373
+ def _render_section(heading, comment, header, rows):
374
+ return f"{heading}\n\n{comment}\n\n{_render_table(header, rows)}\n"
375
+
376
+
377
+ def _section_text(text, heading):
378
+ """The lines from `heading` up to (not including) the next `## ` heading."""
379
+ lines = text.splitlines()
380
+ start = next((i for i, ln in enumerate(lines) if ln.strip() == heading), None)
381
+ if start is None:
382
+ return ""
383
+ end = len(lines)
384
+ for j in range(start + 1, len(lines)):
385
+ if lines[j].startswith("## "):
386
+ end = j
387
+ break
388
+ return "\n".join(lines[start:end])
389
+
390
+
391
+ def _table_data_rows(section_text):
392
+ """Data rows (list of trimmed cell-lists) under a section: skips the header row,
393
+ the |---| separator, HTML comments (incl. commented example rows), and blanks."""
394
+ rows, in_comment, header_seen = [], False, False
395
+ for ln in section_text.splitlines():
396
+ s = ln.strip()
397
+ if in_comment:
398
+ if "-->" in s:
399
+ in_comment = False
400
+ continue
401
+ if s.startswith("<!--"):
402
+ if "-->" not in s:
403
+ in_comment = True
404
+ continue
405
+ if not s.startswith("|"):
406
+ continue
407
+ cells = [c.strip() for c in s.strip("|").split("|")]
408
+ joined = "".join(cells)
409
+ if not header_seen:
410
+ header_seen = True # first pipe row = header
411
+ continue
412
+ if joined == "" or set(joined) <= set("-: "):
413
+ continue # separator or empty
414
+ rows.append(cells)
415
+ return rows
416
+
417
+
418
+ def _replace_section(text, heading, new_body, before=None):
419
+ """Replace the `heading` section (heading→next `## `) with new_body. If the heading
420
+ is absent, insert it before the `before` heading when given, else append. Only the
421
+ machine sections are ever passed here, so human prose elsewhere is preserved."""
422
+ lines = text.splitlines()
423
+ start = next((i for i, ln in enumerate(lines) if ln.strip() == heading), None)
424
+ new_lines = new_body.rstrip("\n").splitlines()
425
+ if start is None:
426
+ anchor = (next((i for i, ln in enumerate(lines) if ln.strip() == before), None)
427
+ if before else None)
428
+ if anchor is not None:
429
+ merged = lines[:anchor] + new_lines + [""] + lines[anchor:]
430
+ return "\n".join(merged).rstrip("\n") + "\n"
431
+ base = text.rstrip("\n")
432
+ return (base + "\n\n" + "\n".join(new_lines) + "\n") if base else ("\n".join(new_lines) + "\n")
433
+ end = len(lines)
434
+ for j in range(start + 1, len(lines)):
435
+ if lines[j].startswith("## "):
436
+ end = j
437
+ break
438
+ merged = lines[:start] + new_lines + [""] + lines[end:]
439
+ return "\n".join(merged).rstrip("\n") + "\n"
440
+
441
+
442
+ # ───────────────────────── bundle / concept resolution ──────────────────────
443
+ def _ensure_bundle(bundle_abs):
444
+ os.makedirs(bundle_abs, exist_ok=True)
445
+ idx = os.path.join(bundle_abs, "index.md")
446
+ if not os.path.isfile(idx):
447
+ _write(idx, "---\nokf_version: 0.1\n---\n\n# Knowledge Bundle\n\n"
448
+ "OKF v0.1 knowledge bundle — one concept per aggregate.\n")
449
+
450
+
451
+ def _canonical_concept_path(bundle_abs, name):
452
+ slug = _slug(name)
453
+ return os.path.join(bundle_abs, slug, slug + ".md")
454
+
455
+
456
+ def _resolve_concept(name, bundle_abs):
457
+ """Find name's concept (mirrors aw-inventory.py concept): match title/base slug,
458
+ prefer a type containing 'aggregate'. Return abspath or None."""
459
+ want = _slug(name)
460
+ best, best_score = None, -1
461
+ if not os.path.isdir(bundle_abs):
462
+ return None
463
+ for dirpath, dirnames, filenames in os.walk(bundle_abs):
464
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
465
+ for fn in filenames:
466
+ if not fn.endswith(".md") or fn in RESERVED:
467
+ continue
468
+ path = os.path.join(dirpath, fn)
469
+ fm, _ = _read_fm(_read(path))
470
+ title = fm.get("title") if isinstance(fm.get("title"), str) else ""
471
+ base = os.path.splitext(fn)[0]
472
+ if want not in (_slug(title), _slug(base)):
473
+ continue
474
+ typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
475
+ score = (2 if "aggregate" in typ.lower() else 0) + (1 if _slug(base) == want else 0)
476
+ if score > best_score:
477
+ best, best_score = path, score
478
+ return best
479
+
480
+
481
+ def _skeleton_concept(name, origin, source_files, description):
482
+ fm = {
483
+ "type": "aggregate",
484
+ "title": name,
485
+ "description": description or f"{name} aggregate.",
486
+ "origin": origin or "greenfield",
487
+ "source_files": list(source_files or []),
488
+ }
489
+ overview = f"[overview of the {name} aggregate]"
490
+ body = (
491
+ f"# {name}\n\n"
492
+ f"## Overview\n\n{overview}\n\n"
493
+ + _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, []) + "\n"
494
+ + _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, [])
495
+ )
496
+ return _render_fm(fm) + "\n\n" + body.rstrip("\n") + "\n"
497
+
498
+
499
+ def _ensure_dir_index(concept_path, name):
500
+ d = os.path.dirname(concept_path)
501
+ idx = os.path.join(d, "index.md")
502
+ if not os.path.isfile(idx):
503
+ base = os.path.splitext(os.path.basename(concept_path))[0]
504
+ _write(idx, f"# {name}\n\n- [{name}](./{base}.md)\n") # NO frontmatter (OKF E3)
505
+
506
+
507
+ def _get_or_stub_concept(name, bundle_abs, origin, stubbed):
508
+ """Return the concept path for name, creating a stub (+ dir/root index) if missing."""
509
+ path = _resolve_concept(name, bundle_abs)
510
+ if path:
511
+ return path
512
+ _ensure_bundle(bundle_abs)
513
+ path = _canonical_concept_path(bundle_abs, name)
514
+ _write(path, _skeleton_concept(name, origin, [], ""))
515
+ _ensure_dir_index(path, name)
516
+ stubbed.append(name)
517
+ return path
518
+
519
+
520
+ # ──────────────────────────── design.md parsing ────────────────────────────
521
+ def _section(text, start_pat, end_pat):
522
+ lines, out, started = text.splitlines(), [], False
523
+ for ln in lines:
524
+ if not started:
525
+ if re.match(start_pat, ln):
526
+ started, _ = True, out.append(ln)
527
+ continue
528
+ if re.match(end_pat, ln):
529
+ break
530
+ out.append(ln)
531
+ return "\n".join(out)
532
+
533
+
534
+ def _known_aggregates(root, specs, bundle_abs):
535
+ """The project's aggregate registry: specs/<dir> names ∪ bundle concept titles/dirs.
536
+ Returns {slug: display_name}."""
537
+ known = {}
538
+ specs_dir = os.path.join(root, specs)
539
+ if os.path.isdir(specs_dir):
540
+ for name in os.listdir(specs_dir):
541
+ if os.path.isdir(os.path.join(specs_dir, name)):
542
+ known.setdefault(_slug(name), name)
543
+ if os.path.isdir(bundle_abs):
544
+ for dirpath, dirnames, filenames in os.walk(bundle_abs):
545
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
546
+ for fn in filenames:
547
+ if not fn.endswith(".md") or fn in RESERVED:
548
+ continue
549
+ fm, _ = _read_fm(_read(os.path.join(dirpath, fn)))
550
+ title = fm.get("title") if isinstance(fm.get("title"), str) else ""
551
+ if title:
552
+ known.setdefault(_slug(title), title)
553
+ return known
554
+
555
+
556
+ def _design_edges(text, aggregate, known):
557
+ """Internal edges the aggregate declares. `known` = {slug: name}; self excluded."""
558
+ self_slug = _slug(aggregate)
559
+ edges, seen = [], set()
560
+
561
+ def add(target_name, via, kind):
562
+ tslug = _slug(target_name)
563
+ key = (tslug, via, kind)
564
+ if tslug == self_slug or tslug not in known or key in seen:
565
+ return
566
+ seen.add(key)
567
+ edges.append({"target": known[tslug], "via": via, "kind": kind})
568
+
569
+ # §1 (the MODEL section) — entity-ref edges: `<Name>Id` and id<Name> attribute
570
+ # types. Both the forward and brownfield design templates keep the domain/data
571
+ # model at "## 1." (a neutral, architecture-agnostic anchor), so this scan is
572
+ # structure-independent.
573
+ sec1 = _section(text, r"^##\s+1\.", r"^##\s+\d")
574
+ ref_names = set(re.findall(r"`([A-Z][A-Za-z0-9]*)Id`", sec1))
575
+ ref_names |= set(re.findall(r"id<\s*([A-Z][A-Za-z0-9]*)\s*>", sec1))
576
+ for name in sorted(ref_names):
577
+ add(name, name + "Id", "entity-ref")
578
+
579
+ # External integrations — contract edges: External-system cell names a known
580
+ # aggregate, keyed by the IC- ids in the Serves column. Located by HEADING
581
+ # KEYWORD (any `###` heading containing "integration"), not a fixed section
582
+ # number, so it matches both the forward design's "### 4.2 External integrations"
583
+ # and a brownfield design's code-derived "### 2.x External Integrations …".
584
+ sec42 = _section(text, r"(?i)^###\s+.*integration", r"^(###\s|##\s)")
585
+ for row in _table_data_rows(sec42):
586
+ if len(row) < 2:
587
+ continue
588
+ external = row[1]
589
+ serves = row[-1]
590
+ ics = re.findall(r"IC-\d+", serves)
591
+ for tslug, name in known.items():
592
+ if tslug == self_slug:
593
+ continue
594
+ if re.search(r"\b" + re.escape(name) + r"\b", external, re.IGNORECASE):
595
+ for ic in (ics or ["contract"]):
596
+ add(name, ic, "contract")
597
+ return edges
598
+
599
+
600
+ # ───────────────────────────── xref row I/O ────────────────────────────────
601
+ def _read_xref_rows(path):
602
+ return _table_data_rows(_section_text(_read(path), XREF_HEADING))
603
+
604
+
605
+ def _read_deps_rows(path):
606
+ return _table_data_rows(_section_text(_read(path), DEPS_HEADING))
607
+
608
+
609
+ # ─────────────── reverse-edge closure — an aggregate's referrers ────────────
610
+ def _xref_users(aggregate, bundle_abs):
611
+ """Direct 'who refers to me' — the first column (Using slice) of aggregate's
612
+ Cross-References rows, kept only when it is an identifier, so the placeholder
613
+ row and any malformed cell are dropped and the names are filter-safe."""
614
+ path = _resolve_concept(aggregate, bundle_abs)
615
+ if not path:
616
+ return []
617
+ return [r[0].strip() for r in _read_xref_rows(path)
618
+ if r and _IDENT_RE.match((r[0] or "").strip())]
619
+
620
+
621
+ def _referrer_closure(aggregate, bundle_abs):
622
+ """Transitive 'who refers to me' blast radius: BFS over the reverse
623
+ (Cross-References) edges. self is excluded, cycles terminate via the seen set,
624
+ result sorted for a stable filter. Resolves each node's concept on demand —
625
+ O(nodes) walks, fine for a one-shot CLI even at hundreds of aggregates."""
626
+ seen, frontier = set(), [aggregate]
627
+ while frontier:
628
+ current = frontier.pop()
629
+ for user in _xref_users(current, bundle_abs):
630
+ if user in seen or user == aggregate:
631
+ continue
632
+ seen.add(user)
633
+ frontier.append(user)
634
+ return sorted(seen)
635
+
636
+
637
+ def _concept_title(path):
638
+ fm, _ = _read_fm(_read(path))
639
+ t = fm.get("title") if isinstance(fm.get("title"), str) else ""
640
+ return t or os.path.splitext(os.path.basename(path))[0]
641
+
642
+
643
+ def _git_head_short(root):
644
+ """Short HEAD sha for the OKF log.md commit anchor (OKF §7 / validate_okf E4);
645
+ empty when not a git repo or no commit yet (then log is skipped, never written broken)."""
646
+ try:
647
+ r = subprocess.run(["git", "-C", root, "rev-parse", "--short", "HEAD"],
648
+ capture_output=True, text=True, timeout=5)
649
+ sha = r.stdout.strip()
650
+ return sha if r.returncode == 0 and re.fullmatch(r"[0-9a-fA-F]{7,40}", sha) else ""
651
+ except Exception:
652
+ return ""
653
+
654
+
655
+ def _write_xref_rows(path, rows):
656
+ text = _read(path)
657
+ body = _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, rows)
658
+ _write(path, _replace_section(text, XREF_HEADING, body))
659
+
660
+
661
+ def _write_deps_rows(path, rows):
662
+ text = _read(path)
663
+ body = _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, rows)
664
+ _write(path, _replace_section(text, DEPS_HEADING, body))
665
+
666
+
667
+ def _all_concepts(bundle_abs):
668
+ out = []
669
+ if not os.path.isdir(bundle_abs):
670
+ return out
671
+ for dirpath, dirnames, filenames in os.walk(bundle_abs):
672
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
673
+ for fn in filenames:
674
+ if fn.endswith(".md") and fn not in RESERVED:
675
+ out.append(os.path.join(dirpath, fn))
676
+ return out
677
+
678
+
679
+ # ─────────────────────────────── commands ──────────────────────────────────
680
+ def cmd_record(args):
681
+ root = _root(args.root)
682
+ bundle_abs = os.path.join(root, args.bundle)
683
+ date = _today(args.date)
684
+ using = args.using or _agg_from_feature_dir(args.feature_dir)
685
+ if not using:
686
+ print("[xref] record: --using or --feature-dir required", file=sys.stderr)
687
+ _emit({"error": "using-required", "root": root})
688
+ return 2
689
+ using_slug = _slug(using)
690
+
691
+ design_arg = args.design or (os.path.join(args.feature_dir, "design.md") if args.feature_dir else "")
692
+ if not design_arg:
693
+ print("[xref] record: --design or --feature-dir required", file=sys.stderr)
694
+ _emit({"error": "design-required", "root": root})
695
+ return 2
696
+ design_path = design_arg if os.path.isabs(design_arg) else os.path.join(root, design_arg)
697
+
698
+ origin = args.origin or "greenfield"
699
+ if origin == "auto":
700
+ origin = _detect_origin(design_path)
701
+
702
+ known = _known_aggregates(root, args.specs, bundle_abs)
703
+ known.pop(using_slug, None)
704
+ edges = _design_edges(_read(design_path), using, known)
705
+
706
+ # RECONCILE-ON-WRITE, delete phase: strip every row this slice authored, anywhere.
707
+ pruned = 0
708
+ for cpath in _all_concepts(bundle_abs):
709
+ rows = _read_xref_rows(cpath)
710
+ kept = [r for r in rows if _slug(r[0]) != using_slug]
711
+ if len(kept) != len(rows):
712
+ pruned += len(rows) - len(kept)
713
+ _write_xref_rows(cpath, kept)
714
+
715
+ # Add phase: upsert this slice's current rows into each used concept (stub if missing).
716
+ wrote, stubbed = [], []
717
+ for e in edges:
718
+ ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed)
719
+ rows = _read_xref_rows(ypath)
720
+ rows = [r for r in rows if not (_slug(r[0]) == using_slug and (len(r) > 1 and r[1] == e["via"]))]
721
+ rows.append([using, e["via"], e["kind"], origin, date])
722
+ _write_xref_rows(ypath, rows)
723
+ wrote.append({"target": e["target"], "concept": _rel(ypath, root), "via": e["via"], "kind": e["kind"]})
724
+
725
+ # Rewrite this slice's own Depends-On (forward prune index) — stub self if needed.
726
+ xpath = _get_or_stub_concept(using, bundle_abs, origin, stubbed)
727
+ _write_deps_rows(xpath, [[e["target"], e["via"], e["kind"]] for e in edges])
728
+
729
+ print(f"[xref] recorded {using} → {len(edges)} edge(s); "
730
+ f"stubbed {len(stubbed)}, pruned {pruned} stale row(s)", file=sys.stderr)
731
+ _emit({"using": using, "origin": origin, "edges": edges, "edge_count": len(edges),
732
+ "wrote": wrote, "stubbed": stubbed, "pruned": pruned,
733
+ "concept": _rel(xpath, root), "root": root})
734
+ return 0
735
+
736
+
737
+ def cmd_list(args):
738
+ root = _root(args.root)
739
+ bundle_abs = os.path.join(root, args.bundle)
740
+ aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
741
+ if not aggregate:
742
+ print("[xref] list: --aggregate or --feature-dir required", file=sys.stderr)
743
+ _emit({"error": "aggregate-required", "root": root})
744
+ return 2
745
+ path = _resolve_concept(aggregate, bundle_abs)
746
+ users = []
747
+ if path:
748
+ for r in _read_xref_rows(path):
749
+ users.append({
750
+ "using": r[0],
751
+ "via": r[1] if len(r) > 1 else "",
752
+ "kind": r[2] if len(r) > 2 else "",
753
+ "origin": r[3] if len(r) > 3 else "",
754
+ "recorded": r[4] if len(r) > 4 else "",
755
+ })
756
+ out_rel = ""
757
+ if args.out:
758
+ out_path = args.out if os.path.isabs(args.out) else os.path.join(root, args.out)
759
+ _write(out_path, _render_impact_md(aggregate, users))
760
+ out_rel = _rel(out_path, root)
761
+ # Direct referrers (identifier-clean, de-duped) + the transitive closure — the
762
+ # integrity blast radius a caller runs tests over. count/users stay as-is for
763
+ # backward-compatible readers; the referrer_* fields are the clean, filter-ready set.
764
+ seen_d, direct = set(), []
765
+ for u in users:
766
+ name = (u["using"] or "").strip()
767
+ if _IDENT_RE.match(name) and name not in seen_d:
768
+ seen_d.add(name); direct.append(name)
769
+ closure = _referrer_closure(aggregate, bundle_abs)
770
+ print(f"[xref] {aggregate}: {len(users)} downstream user(s) "
771
+ f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}", file=sys.stderr)
772
+ _emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
773
+ "users": users, "count": len(users), "impact_report": out_rel, "root": root,
774
+ "referrers": direct, "referrers_csv": ",".join(direct),
775
+ "referrer_closure": closure, "referrer_closure_csv": ",".join(closure),
776
+ "referrer_count": len(direct), "referrer_closure_count": len(closure),
777
+ "indirect_count": max(0, len(closure) - len(direct))})
778
+ return 0
779
+
780
+
781
+ def cmd_concept_upsert(args):
782
+ root = _root(args.root)
783
+ bundle_abs = os.path.join(root, args.bundle)
784
+ origin = args.origin or "greenfield"
785
+ aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
786
+ if not aggregate:
787
+ print("[xref] concept-upsert: --aggregate or --feature-dir required", file=sys.stderr)
788
+ _emit({"error": "aggregate-required", "root": root})
789
+ return 2
790
+ source_files = _parse_source_files(args.source_files, root)
791
+
792
+ path = _resolve_concept(aggregate, bundle_abs)
793
+ created = path is None
794
+ if created:
795
+ _ensure_bundle(bundle_abs)
796
+ path = _canonical_concept_path(bundle_abs, aggregate)
797
+ _write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""))
798
+ _ensure_dir_index(path, aggregate)
799
+ else:
800
+ # Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
801
+ # which holds the Cross-References other slices wrote.
802
+ fm, body = _read_fm(_read(path))
803
+ fm.setdefault("type", "aggregate")
804
+ fm["title"] = fm.get("title") or aggregate
805
+ if source_files:
806
+ if args.merge:
807
+ # CR mode: {{files}} is only the delta's changed files — UNION with the
808
+ # slice's existing source_files, never replace (that would drop the rest).
809
+ existing = fm.get("source_files") if isinstance(fm.get("source_files"), list) else []
810
+ merged = list(existing)
811
+ for s in source_files:
812
+ if s not in merged:
813
+ merged.append(s)
814
+ fm["source_files"] = merged
815
+ else:
816
+ fm["source_files"] = source_files
817
+ elif "source_files" not in fm:
818
+ fm["source_files"] = []
819
+ if args.description:
820
+ fm["description"] = args.description
821
+ elif "description" not in fm:
822
+ fm["description"] = f"{aggregate} aggregate."
823
+ fm.setdefault("origin", origin)
824
+ rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
825
+ else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
826
+ _write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
827
+ _ensure_bundle(bundle_abs)
828
+ _ensure_dir_index(path, aggregate)
829
+
830
+ print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
831
+ f"({len(source_files)} source file(s)) — {_rel(path, root)}", file=sys.stderr)
832
+ _emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
833
+ "source_file_count": len(source_files), "root": root})
834
+ return 0
835
+
836
+
837
+ def cmd_concept_overview(args):
838
+ """Splice model-authored PROSE into the concept's `## Overview` — the one section a
839
+ deterministic script cannot write. Section-scoped on purpose: the model never owns
840
+ the whole file, so the Cross-References rows other slices wrote can never be lost.
841
+ Runs AFTER concept-upsert (which owns creation + frontmatter)."""
842
+ root = _root(args.root)
843
+ bundle_abs = os.path.join(root, args.bundle)
844
+ aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
845
+ if not aggregate:
846
+ print("[xref] concept-overview: --aggregate or --feature-dir required", file=sys.stderr)
847
+ _emit({"error": "aggregate-required", "root": root})
848
+ return 2
849
+
850
+ text = _read(args.text_file) if args.text_file else (args.text or "")
851
+ # Strip anything the model may have wrapped the prose in: a repeated heading,
852
+ # code fences, and the placeholder itself (an empty/placeholder answer = no-op).
853
+ text = re.sub(r"^\s*#{1,6}\s*Overview\s*$", "", text, flags=re.MULTILINE)
854
+ text = re.sub(r"^\s*```.*$", "", text, flags=re.MULTILINE).strip()
855
+ if not text or re.fullmatch(r"\[overview of the .*\]", text):
856
+ print(f"[xref] concept-overview {aggregate}: empty/placeholder text — left unchanged",
857
+ file=sys.stderr)
858
+ _emit({"aggregate": aggregate, "written": "false", "reason": "empty-or-placeholder",
859
+ "root": root})
860
+ return 0
861
+
862
+ path = _resolve_concept(aggregate, bundle_abs)
863
+ if path is None:
864
+ print(f"[xref] concept-overview: no concept for '{aggregate}' — run concept-upsert first",
865
+ file=sys.stderr)
866
+ _emit({"error": "concept-missing", "aggregate": aggregate, "root": root})
867
+ return 2
868
+
869
+ body = f"{OVERVIEW_HEADING}\n\n{text}\n"
870
+ _write(path, _replace_section(_read(path), OVERVIEW_HEADING, body, before=DEPS_HEADING))
871
+ print(f"[xref] concept-overview {aggregate}: written ({len(text)} chars) — {_rel(path, root)}",
872
+ file=sys.stderr)
873
+ _emit({"aggregate": aggregate, "concept": _rel(path, root), "written": "true", "root": root})
874
+ return 0
875
+
876
+
877
+ # ──────────────────────── concept-paths (read-only) ────────────────────────
878
+ def cmd_concept_paths(args):
879
+ """The slice's source files, read from its OKF concept — the REPLACEMENT for the
880
+ retired `design-paths` fence scan.
881
+
882
+ The executable phases (build / run / analyze-slice) all need the same answer: which
883
+ files is this slice made of, so a fix loop can be handed them. That answer used to be
884
+ parsed out of ```lang path=… fences in a four-document design — a convention the
885
+ tech-agnostic single design.md no longer carries (and whose glob never matched
886
+ design.md anyway). The concept's `source_files` is the maintained answer now:
887
+ implement / implement-delta / adopt write it, analyze-slice merges fix-loop files in.
888
+
889
+ STRICTLY READ-ONLY. Unlike record / concept-upsert this never stubs a missing concept:
890
+ a query must not mutate the bundle. No concept — greenfield before the first implement,
891
+ or no bundle at all — is a legitimate EMPTY answer, not an error. It exits 0 with
892
+ paths=[] so every caller soft-continues exactly as it did when the fence scan found
893
+ nothing, which is what keeps this greenfield-safe.
894
+
895
+ `paths` is filtered to files that exist on disk. It feeds build_context, which drops
896
+ missing entries silently anyway, so filtering here makes the reported count honest.
897
+ Declared-but-absent files are reported separately as `missing` — that is real index
898
+ drift (a file deleted or moved without re-running the owning slice), surfaced rather
899
+ than swallowed."""
900
+ root = _root(args.root)
901
+ bundle_abs = os.path.join(root, args.bundle)
902
+ aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
903
+ if not aggregate:
904
+ print("[xref] concept-paths: --aggregate or --feature-dir required", file=sys.stderr)
905
+ _emit({"error": "aggregate-required", "root": root})
906
+ return 2
907
+
908
+ path = _resolve_concept(aggregate, bundle_abs)
909
+ declared, existing, missing = [], [], []
910
+ if path:
911
+ fm, _ = _read_fm(_read(path))
912
+ raw = fm.get("source_files")
913
+ # _read_fm gives a list for a block list, "" for an empty scalar; the `# Layer`
914
+ # group headers _render_fm writes are comments and are already skipped by it.
915
+ items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, str) and raw else [])
916
+ seen = set()
917
+ for s in items:
918
+ s = (s or "").strip().strip('"').strip("'").replace(os.sep, "/")
919
+ if not s or s in seen:
920
+ continue
921
+ seen.add(s)
922
+ declared.append(s)
923
+ target = s if os.path.isabs(s) else os.path.join(root, s)
924
+ (existing if os.path.isfile(target) else missing).append(s)
925
+
926
+ note = "" if path else " — no concept recorded yet"
927
+ drift = f", {len(missing)} declared-but-missing" if missing else ""
928
+ print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{note}",
929
+ file=sys.stderr)
930
+ _emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
931
+ "found": bool(path), "paths": existing, "count": len(existing),
932
+ "csv": ",".join(existing), "declared": declared,
933
+ "declared_count": len(declared), "missing": missing,
934
+ "missing_count": len(missing), "root": root})
935
+ return 0
936
+
937
+
938
+ # ─────────────────────────────── validate ──────────────────────────────────
939
+ def cmd_validate(args):
940
+ """Cross-reference drift check (the safety net BEYOND reconcile-on-write). Pairs the
941
+ forward index (Depends-On) against the reverse index (Cross-References):
942
+ • reverse-orphan — a Cross-References row (Y says "X uses me") with NO backing
943
+ Depends-On in X (X's concept gone, or X no longer declares the dep). Stale; safe
944
+ to prune (this is the user-facing orphan).
945
+ • forward-orphan — a Depends-On row (X → Y) with NO matching Cross-References in Y
946
+ (a missing reverse edge). Reported only; the fix is `record --using X` (re-write
947
+ both), never deleting X's real declaration."""
948
+ root = _root(args.root)
949
+ bundle_abs = os.path.join(root, args.bundle)
950
+ info = {}
951
+ for cpath in _all_concepts(bundle_abs):
952
+ title = _concept_title(cpath)
953
+ info[cpath] = {
954
+ "title": title,
955
+ "slug": _slug(title),
956
+ "deps": [(d[0], d[1] if len(d) > 1 else "") for d in _read_deps_rows(cpath)],
957
+ "xrefs": [(x[0], x[1] if len(x) > 1 else "") for x in _read_xref_rows(cpath)],
958
+ }
959
+ slug_to_path = {}
960
+ for p, i in info.items():
961
+ slug_to_path.setdefault(i["slug"], p)
962
+
963
+ orphans = []
964
+ for ypath, yi in info.items():
965
+ for (using, via) in yi["xrefs"]:
966
+ xp = slug_to_path.get(_slug(using))
967
+ if xp is None:
968
+ orphans.append({"direction": "reverse", "concept": _rel(ypath, root),
969
+ "aggregate": yi["title"], "using": using, "via": via,
970
+ "reason": "using-slice has no concept"})
971
+ elif not any(_slug(t) == yi["slug"] and v == via for (t, v) in info[xp]["deps"]):
972
+ orphans.append({"direction": "reverse", "concept": _rel(ypath, root),
973
+ "aggregate": yi["title"], "using": using, "via": via,
974
+ "reason": "using-slice no longer declares this dependency"})
975
+ for xpath, xi in info.items():
976
+ for (target, via) in xi["deps"]:
977
+ yp = slug_to_path.get(_slug(target))
978
+ if yp is None:
979
+ orphans.append({"direction": "forward", "concept": _rel(xpath, root),
980
+ "aggregate": xi["title"], "target": target, "via": via,
981
+ "reason": "depends-on target has no concept"})
982
+ elif not any(_slug(u) == xi["slug"] and v == via for (u, v) in info[yp]["xrefs"]):
983
+ orphans.append({"direction": "forward", "concept": _rel(xpath, root),
984
+ "aggregate": xi["title"], "target": target, "via": via,
985
+ "reason": "used slice missing the reverse cross-reference"})
986
+
987
+ pruned = 0
988
+ if args.prune:
989
+ # Only reverse-orphans are safe to auto-remove (a stale reverse row). Forward-
990
+ # orphans mean a MISSING reverse edge — deleting X's real Depends-On would lose a
991
+ # true dependency; those are fixed by re-running record, not by prune.
992
+ rev = {}
993
+ for o in orphans:
994
+ if o["direction"] == "reverse":
995
+ rev.setdefault(o["concept"], set()).add((o["using"], o["via"]))
996
+ for crel, keys in rev.items():
997
+ cpath = os.path.join(root, crel)
998
+ rows = _read_xref_rows(cpath)
999
+ keep = [r for r in rows if (r[0], r[1] if len(r) > 1 else "") not in keys]
1000
+ pruned += len(rows) - len(keep)
1001
+ _write_xref_rows(cpath, keep)
1002
+
1003
+ rev_n = sum(1 for o in orphans if o["direction"] == "reverse")
1004
+ fwd_n = len(orphans) - rev_n
1005
+ print(f"[xref] validate: {len(orphans)} orphan(s) — {rev_n} reverse, {fwd_n} forward"
1006
+ + (f"; pruned {pruned} reverse" if args.prune else ""), file=sys.stderr)
1007
+ _emit({"orphans": orphans, "orphan_count": len(orphans), "reverse_orphans": rev_n,
1008
+ "forward_orphans": fwd_n, "pruned": pruned,
1009
+ "ok": "true" if not orphans else "false", "root": root})
1010
+ return 0
1011
+
1012
+
1013
+ # ─────────────────────────────────── log ───────────────────────────────────
1014
+ def cmd_log(args):
1015
+ """Append a commit-anchored entry to the bundle's log.md (OKF §7 change journal /
1016
+ validate_okf E4 freshness). No-op when there is no bundle or no git commit to anchor —
1017
+ never writes a block without a valid sha (that would make the bundle non-conformant)."""
1018
+ root = _root(args.root)
1019
+ bundle_abs = os.path.join(root, args.bundle)
1020
+ if not os.path.isdir(bundle_abs):
1021
+ print("[xref] log: no OKF bundle — skipped", file=sys.stderr)
1022
+ _emit({"logged": "false", "reason": "no-bundle", "root": root})
1023
+ return 0
1024
+ sha = _git_head_short(root)
1025
+ if not sha:
1026
+ print("[xref] log: no git commit to anchor — skipped", file=sys.stderr)
1027
+ _emit({"logged": "false", "reason": "no-commit", "root": root})
1028
+ return 0
1029
+ date = _today(args.date)
1030
+ note = args.note or "OKF cross-references updated."
1031
+ logp = os.path.join(bundle_abs, "log.md")
1032
+ entry = f"## {date}\n\nCommit: `{sha}`\n\n- {note}\n"
1033
+ if os.path.isfile(logp):
1034
+ _write(logp, _read(logp).rstrip("\n") + "\n\n" + entry)
1035
+ else:
1036
+ _write(logp, "# Change Log\n\nOKF bundle change journal — commit-anchored (OKF §7).\n\n" + entry)
1037
+ print(f"[xref] log: {date} @ {sha} — {note}", file=sys.stderr)
1038
+ _emit({"logged": "true", "commit": sha, "date": date, "note": note,
1039
+ "log": _rel(logp, root), "root": root})
1040
+ return 0
1041
+
1042
+
1043
+ # ─────────────────────────────────── tree ───────────────────────────────────
1044
+ def cmd_tree(args):
1045
+ """Build the global dependency hierarchy tree and topological migration wave plan
1046
+ from OKF concept bundles (knowledge/) and specs/."""
1047
+ root = _root(args.root)
1048
+ bundle_abs = os.path.join(root, args.bundle)
1049
+ specs_abs = os.path.join(root, args.specs)
1050
+
1051
+ concepts = _all_concepts(bundle_abs)
1052
+ aggregates = {}
1053
+
1054
+ # 1. Parse knowledge bundle concept files
1055
+ for cp in concepts:
1056
+ agg_name = os.path.splitext(os.path.basename(cp))[0]
1057
+ agg_slug = _slug(agg_name)
1058
+ if agg_slug in RESERVED:
1059
+ continue
1060
+ title = _concept_title(cp)
1061
+ deps_rows = _read_deps_rows(cp)
1062
+ xref_rows = _read_xref_rows(cp)
1063
+
1064
+ dep_targets = []
1065
+ for r in deps_rows:
1066
+ if len(r) >= 1 and r[0].strip():
1067
+ t = r[0].strip()
1068
+ t_slug = _slug(t)
1069
+ if t_slug and t_slug != agg_slug:
1070
+ dep_targets.append(t)
1071
+
1072
+ aggregates[agg_slug] = {
1073
+ "name": title or agg_name,
1074
+ "slug": agg_slug,
1075
+ "concept_path": _rel(cp, root),
1076
+ "deps": list(set(dep_targets)),
1077
+ "xrefs": [r[0] for r in xref_rows if len(r) >= 1]
1078
+ }
1079
+
1080
+ # 2. Check specs/ directory for features
1081
+ if os.path.isdir(specs_abs):
1082
+ for entry in os.listdir(specs_abs):
1083
+ entry_path = os.path.join(specs_abs, entry)
1084
+ if os.path.isdir(entry_path) and not entry.startswith("."):
1085
+ slug = _slug(entry)
1086
+ if slug not in aggregates:
1087
+ design_p = os.path.join(entry_path, "design.md")
1088
+ spec_p = os.path.join(entry_path, "spec.md")
1089
+ if os.path.exists(design_p) or os.path.exists(spec_p):
1090
+ dep_targets = []
1091
+ if os.path.exists(design_p):
1092
+ known_map = {s: s for s in aggregates}
1093
+ edges = _design_edges(_read(design_p), entry, known_map)
1094
+ dep_targets = [e["target"] for e in edges]
1095
+ aggregates[slug] = {
1096
+ "name": entry,
1097
+ "slug": slug,
1098
+ "concept_path": _rel(spec_p if os.path.exists(spec_p) else entry_path, root),
1099
+ "deps": list(set(dep_targets)),
1100
+ "xrefs": []
1101
+ }
1102
+
1103
+ # 3. Fallback scan src/ directory if no aggregates in bundle/specs
1104
+ if not aggregates:
1105
+ src_abs = os.path.join(root, "src")
1106
+ if os.path.isdir(src_abs):
1107
+ for entry in os.listdir(src_abs):
1108
+ ep = os.path.join(src_abs, entry)
1109
+ if os.path.isdir(ep) and not entry.startswith("."):
1110
+ slug = _slug(entry)
1111
+ aggregates[slug] = {
1112
+ "name": entry,
1113
+ "slug": slug,
1114
+ "concept_path": _rel(ep, root),
1115
+ "deps": [],
1116
+ "xrefs": []
1117
+ }
1118
+
1119
+ # 4. Build Dependency Graph
1120
+ all_slugs = set(aggregates.keys())
1121
+ deps_graph = {}
1122
+ for slug, info in aggregates.items():
1123
+ internal_deps = sorted(list(set(_slug(d) for d in info["deps"] if _slug(d) in all_slugs and _slug(d) != slug)))
1124
+ deps_graph[slug] = internal_deps
1125
+
1126
+ # 5. Topological Sort into Migration Waves
1127
+ placed = set()
1128
+ waves = []
1129
+ has_cycles = False
1130
+ cycles = []
1131
+ remaining = set(all_slugs)
1132
+
1133
+ while remaining:
1134
+ next_wave_nodes = []
1135
+ for slug in sorted(list(remaining)):
1136
+ reqs = deps_graph[slug]
1137
+ if all(req in placed for req in reqs):
1138
+ next_wave_nodes.append(slug)
1139
+
1140
+ if not next_wave_nodes:
1141
+ has_cycles = True
1142
+ cycle_nodes = list(remaining)
1143
+ cycles.append(cycle_nodes)
1144
+ waves.append({
1145
+ "wave_id": len(waves),
1146
+ "name": f"Wave {len(waves)} (Cycle Remediation Required)",
1147
+ "aggregates": [aggregates[s]["name"] for s in cycle_nodes],
1148
+ "slugs": cycle_nodes,
1149
+ "is_cycle": True
1150
+ })
1151
+ break
1152
+
1153
+ wave_id = len(waves)
1154
+ wave_name = "Wave 0 (Base / Leaf Aggregates)" if wave_id == 0 else f"Wave {wave_id}"
1155
+ waves.append({
1156
+ "wave_id": wave_id,
1157
+ "name": wave_name,
1158
+ "aggregates": [aggregates[s]["name"] for s in next_wave_nodes],
1159
+ "slugs": next_wave_nodes,
1160
+ "is_cycle": False
1161
+ })
1162
+
1163
+ placed.update(next_wave_nodes)
1164
+ remaining.difference_update(next_wave_nodes)
1165
+
1166
+ # 6. Format and Write Output Reports
1167
+ out_md_path = os.path.join(root, args.out) if args.out else os.path.join(root, "specs", "migration-tree.md")
1168
+ out_json_path = os.path.join(root, args.out_json) if getattr(args, "out_json", None) else os.path.join(root, "specs", "migration-sequence.json")
1169
+
1170
+ os.makedirs(os.path.dirname(out_md_path), exist_ok=True)
1171
+ os.makedirs(os.path.dirname(out_json_path), exist_ok=True)
1172
+
1173
+ md_lines = [
1174
+ "# Aggregates Dependency Hierarchy Tree & Migration Wave Plan",
1175
+ "",
1176
+ f"*Generated by `aw-xref.py tree` on {datetime.date.today().isoformat()}*",
1177
+ "",
1178
+ "## Topological Migration Waves",
1179
+ "",
1180
+ "| Wave | Aggregate / Feature | Dependencies (Internal) | Status / Seam Strategy |",
1181
+ "|---|---|---|---|",
1182
+ ]
1183
+
1184
+ for w in waves:
1185
+ for slug in w["slugs"]:
1186
+ info = aggregates[slug]
1187
+ deps_str = ", ".join(deps_graph[slug]) if deps_graph[slug] else "*(None - Leaf)*"
1188
+ seam_strat = "Cycle Remediation Required" if w.get("is_cycle") else ("Pure Service / Minimal Seam" if w["wave_id"] == 0 else f"Wave {w['wave_id']} Strangler Seam")
1189
+ md_lines.append(f"| Wave {w['wave_id']} | `{info['name']}` | {deps_str} | {seam_strat} |")
1190
+
1191
+ if has_cycles:
1192
+ md_lines.extend([
1193
+ "",
1194
+ "## ⚠️ Circular Dependency Warning",
1195
+ "",
1196
+ "The following aggregates contain circular dependencies and require seam refactoring before migration:",
1197
+ ""
1198
+ ])
1199
+ for c in cycles:
1200
+ md_lines.append(f"- `{' ↔ '.join([aggregates[s]['name'] for s in c])}`")
1201
+
1202
+ md_lines.extend([
1203
+ "",
1204
+ "## Dependency Hierarchy (DAG)",
1205
+ "",
1206
+ "```mermaid",
1207
+ "flowchart TD"
1208
+ ])
1209
+
1210
+ for slug, info in aggregates.items():
1211
+ md_lines.append(f' {slug}["{info["name"]}"]')
1212
+ for dep_slug in deps_graph[slug]:
1213
+ md_lines.append(f' {dep_slug} --> {slug}')
1214
+
1215
+ md_lines.extend([
1216
+ "```",
1217
+ ""
1218
+ ])
1219
+
1220
+ _write(out_md_path, "\n".join(md_lines) + "\n")
1221
+
1222
+ sequence_data = {
1223
+ "generated_at": datetime.date.today().isoformat(),
1224
+ "aggregate_count": len(aggregates),
1225
+ "wave_count": len(waves),
1226
+ "has_cycles": has_cycles,
1227
+ "cycles": cycles,
1228
+ "waves": waves,
1229
+ "aggregates": aggregates
1230
+ }
1231
+ _write(out_json_path, json.dumps(sequence_data, indent=2) + "\n")
1232
+
1233
+ print(f"[xref] Generated Dependency Tree: {len(aggregates)} aggregate(s) sorted into {len(waves)} wave(s). Report: {_rel(out_md_path, root)}", file=sys.stderr)
1234
+
1235
+ _emit({
1236
+ "aggregate_count": len(aggregates),
1237
+ "wave_count": len(waves),
1238
+ "has_cycles": has_cycles,
1239
+ "cycles": cycles,
1240
+ "tree_file": _rel(out_md_path, root),
1241
+ "sequence_file": _rel(out_json_path, root),
1242
+ "waves": waves,
1243
+ "root": root
1244
+ })
1245
+ return 0
1246
+
1247
+
1248
+ # ─────────────────────────────── argparse ──────────────────────────────────
1249
+ def main(argv):
1250
+ ap = argparse.ArgumentParser(prog="aw-xref.py", add_help=True)
1251
+ sub = ap.add_subparsers(dest="cmd")
1252
+
1253
+ r = sub.add_parser("record", help="reconcile a using slice's edges across the bundle")
1254
+ r.add_argument("--using", default="")
1255
+ r.add_argument("--feature-dir", dest="feature_dir", default="")
1256
+ r.add_argument("--design", default="")
1257
+ r.add_argument("--origin", default="greenfield", help="greenfield | reverse-engineered | migrated | auto (detect from design.md)")
1258
+ r.add_argument("--date", default="")
1259
+ r.add_argument("--root", default="")
1260
+ r.add_argument("--specs", default="specs")
1261
+ r.add_argument("--bundle", default="knowledge")
1262
+ r.set_defaults(func=cmd_record)
1263
+
1264
+ l = sub.add_parser("list", help="an aggregate's blast radius (who uses it)")
1265
+ l.add_argument("--aggregate", default="")
1266
+ l.add_argument("--feature-dir", dest="feature_dir", default="")
1267
+ l.add_argument("--out", default="", help="also write a human blast-radius report to this path")
1268
+ l.add_argument("--root", default="")
1269
+ l.add_argument("--bundle", default="knowledge")
1270
+ l.set_defaults(func=cmd_list)
1271
+
1272
+ c = sub.add_parser("concept-upsert", help="create/update an aggregate's own concept")
1273
+ c.add_argument("--aggregate", default="")
1274
+ c.add_argument("--feature-dir", dest="feature_dir", default="")
1275
+ c.add_argument("--source-files", default="")
1276
+ c.add_argument("--merge", action="store_true", help="union source_files with existing (CR delta) instead of replacing")
1277
+ c.add_argument("--title", default="")
1278
+ c.add_argument("--description", default="")
1279
+ c.add_argument("--origin", default="greenfield")
1280
+ c.add_argument("--root", default="")
1281
+ c.add_argument("--bundle", default="knowledge")
1282
+ c.set_defaults(func=cmd_concept_upsert)
1283
+
1284
+ co = sub.add_parser("concept-overview", help="splice model-authored prose into a concept's Overview")
1285
+ co.add_argument("--aggregate", default="")
1286
+ co.add_argument("--feature-dir", dest="feature_dir", default="")
1287
+ co.add_argument("--text", default="", help="the overview prose (2-4 sentences)")
1288
+ co.add_argument("--text-file", dest="text_file", default="", help="read the prose from this file instead")
1289
+ co.add_argument("--root", default="")
1290
+ co.add_argument("--bundle", default="knowledge")
1291
+ co.set_defaults(func=cmd_concept_overview)
1292
+
1293
+ cp = sub.add_parser("concept-paths", help="read-only: the slice's source_files (replaces design-paths)")
1294
+ cp.add_argument("--aggregate", default="")
1295
+ cp.add_argument("--feature-dir", dest="feature_dir", default="")
1296
+ cp.add_argument("--root", default="")
1297
+ cp.add_argument("--bundle", default="knowledge")
1298
+ cp.set_defaults(func=cmd_concept_paths)
1299
+
1300
+ vv = sub.add_parser("validate", help="report cross-reference drift (orphan rows)")
1301
+ vv.add_argument("--prune", action="store_true", help="remove stale reverse orphans (safe self-heal)")
1302
+ vv.add_argument("--root", default="")
1303
+ vv.add_argument("--bundle", default="knowledge")
1304
+ vv.set_defaults(func=cmd_validate)
1305
+
1306
+ lg = sub.add_parser("log", help="append a commit-anchored entry to the bundle log.md")
1307
+ lg.add_argument("--note", default="")
1308
+ lg.add_argument("--date", default="")
1309
+ lg.add_argument("--root", default="")
1310
+ lg.add_argument("--bundle", default="knowledge")
1311
+ lg.set_defaults(func=cmd_log)
1312
+
1313
+ tr = sub.add_parser("tree", help="build dependency hierarchy tree & topological migration waves")
1314
+ tr.add_argument("--out", default="", help="output markdown file (default: specs/migration-tree.md)")
1315
+ tr.add_argument("--out-json", dest="out_json", default="", help="output json file (default: specs/migration-sequence.json)")
1316
+ tr.add_argument("--root", default="")
1317
+ tr.add_argument("--specs", default="specs")
1318
+ tr.add_argument("--bundle", default="knowledge")
1319
+ tr.set_defaults(func=cmd_tree)
1320
+
1321
+ args = ap.parse_args(argv)
1322
+ if not getattr(args, "func", None):
1323
+ ap.print_help(sys.stderr)
1324
+ return 2
1325
+ return args.func(args)
1326
+
1327
+
1328
+ if __name__ == "__main__":
1329
+ sys.exit(main(sys.argv[1:]))
1330
+