aw-spec-cli 0.1.3 → 0.1.5

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 (230) hide show
  1. package/README.md +291 -0
  2. package/assets/.agents/agent.json +28 -0
  3. package/assets/.agents/skills/aw-spec-orch/SKILL.md +56 -0
  4. package/assets/.agents/skills/aw-spec-orch-demo/SKILL.md +59 -0
  5. package/assets/.aw-spec/DESIGN-FORMAT.md +151 -0
  6. package/assets/.aw-spec/ORCH-DIALECT.md +156 -0
  7. package/assets/.aw-spec/RULE-PARTITION.md +126 -0
  8. package/assets/.aw-spec/extensions/agent-context/extension.yml +12 -9
  9. package/assets/.aw-spec/extensions.yml +12 -19
  10. package/assets/.aw-spec/integrations/aw-spec.manifest.json +1 -9
  11. package/assets/.aw-spec/interpreter/run-skill.py +1158 -0
  12. package/assets/.aw-spec/orchestrator/AGENT-PROFILE.md +94 -0
  13. package/assets/.aw-spec/orchestrator/README.md +300 -0
  14. package/assets/.aw-spec/orchestrator/adapters/claude_agent_sdk.py +74 -0
  15. package/assets/.aw-spec/orchestrator/adapters/google_antigravity.py +74 -0
  16. package/assets/.aw-spec/orchestrator/modules/delta-design.orch.yaml +105 -0
  17. package/assets/.aw-spec/orchestrator/modules/delta-spec.orch.yaml +79 -0
  18. package/assets/.aw-spec/orchestrator/modules/domain-select.orch.yaml +81 -0
  19. package/assets/.aw-spec/orchestrator/modules/tech-select.orch.yaml +75 -0
  20. package/assets/.aw-spec/orchestrator/orchestrator.py +1721 -0
  21. package/assets/.aw-spec/orchestrator/rev-templates/ai-rules.reference.md +40 -0
  22. package/assets/.aw-spec/orchestrator/rev-templates/comandrulemap.skeleton.md +59 -0
  23. package/assets/.aw-spec/orchestrator/rev-templates/commandtechrulemap.skeleton.md +97 -0
  24. package/assets/.aw-spec/orchestrator/rev-templates/domain-constitution.skeleton.md +61 -0
  25. package/assets/.aw-spec/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
  26. package/assets/.aw-spec/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
  27. package/assets/.aw-spec/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
  28. package/assets/.aw-spec/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
  29. package/assets/.aw-spec/orchestrator/workflows/_smoke.orch.yaml +160 -0
  30. package/assets/.aw-spec/orchestrator/workflows/aw-spec-add-component.orch.yaml +244 -0
  31. package/assets/.aw-spec/orchestrator/workflows/aw-spec-adopt.orch.yaml +775 -0
  32. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +248 -0
  33. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +261 -0
  34. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +425 -0
  35. package/assets/.aw-spec/orchestrator/workflows/aw-spec-build.orch.yaml +188 -0
  36. package/assets/.aw-spec/orchestrator/workflows/aw-spec-characterize.orch.yaml +474 -0
  37. package/assets/.aw-spec/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
  38. package/assets/.aw-spec/orchestrator/workflows/aw-spec-constitution.orch.yaml +284 -0
  39. package/assets/.aw-spec/orchestrator/workflows/aw-spec-demo.orch.yaml +103 -0
  40. package/assets/.aw-spec/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
  41. package/assets/.aw-spec/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
  42. package/assets/.aw-spec/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
  43. package/assets/.aw-spec/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +382 -0
  44. package/assets/.aw-spec/orchestrator/workflows/aw-spec-foundation.orch.yaml +246 -0
  45. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +231 -0
  46. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement.orch.yaml +258 -0
  47. package/assets/.aw-spec/orchestrator/workflows/aw-spec-inventory.orch.yaml +236 -0
  48. package/assets/.aw-spec/orchestrator/workflows/aw-spec-onboard.orch.yaml +949 -0
  49. package/assets/.aw-spec/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
  50. package/assets/.aw-spec/orchestrator/workflows/aw-spec-run.orch.yaml +177 -0
  51. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-domain.orch.yaml +79 -0
  52. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
  53. package/assets/.aw-spec/orchestrator/workflows/aw-spec-specify.orch.yaml +412 -0
  54. package/assets/.aw-spec/scripts/bash/aw-inventory.sh +14 -0
  55. package/assets/.aw-spec/scripts/bash/aw-parity.sh +13 -0
  56. package/assets/.aw-spec/scripts/bash/aw-rev-constitution.sh +13 -0
  57. package/assets/.aw-spec/scripts/bash/aw-spec-actions.sh +1899 -0
  58. package/assets/.aw-spec/scripts/bash/aw-xref.sh +15 -0
  59. package/assets/.aw-spec/scripts/bash/common.sh +11 -60
  60. package/assets/.aw-spec/scripts/powershell/aw-inventory.ps1 +14 -0
  61. package/assets/.aw-spec/scripts/powershell/aw-parity.ps1 +13 -0
  62. package/assets/.aw-spec/scripts/powershell/aw-rev-constitution.ps1 +13 -0
  63. package/assets/.aw-spec/scripts/powershell/aw-spec-actions.ps1 +1563 -0
  64. package/assets/.aw-spec/scripts/powershell/aw-xref.ps1 +15 -0
  65. package/assets/.aw-spec/scripts/python/aw-inventory.py +683 -0
  66. package/assets/.aw-spec/scripts/python/aw-parity.py +380 -0
  67. package/assets/.aw-spec/scripts/python/aw-rev-constitution.py +312 -0
  68. package/assets/.aw-spec/scripts/python/aw-xref.py +1330 -0
  69. package/assets/.aw-spec/scripts/python/validate_okf.py +349 -0
  70. package/assets/.aw-spec/templates/constitution-domain-template.md +75 -0
  71. package/assets/.aw-spec/templates/constitution-technical-template.md +70 -0
  72. package/assets/.aw-spec/templates/delta-design-template.md +69 -0
  73. package/assets/.aw-spec/templates/delta-spec-template.md +60 -0
  74. package/assets/.aw-spec/templates/design-template.md +383 -0
  75. package/assets/.aw-spec/templates/okf-concept-template.md +103 -0
  76. package/assets/.aw-spec/templates/parity-manifest-template.json +49 -0
  77. package/assets/.aw-spec/templates/source-map-template.md +85 -0
  78. package/assets/.aw-spec/templates/spec-template.md +192 -71
  79. package/assets/.claude/agent.json +27 -0
  80. package/assets/.claude/skills/aw-spec-orch/SKILL.md +55 -0
  81. package/assets/.claude/skills/aw-spec-orch-demo/SKILL.md +59 -0
  82. package/assets/.kilocode/agent.json +28 -0
  83. package/assets/.kilocode/workflows/aw-spec-orch-demo.md +59 -0
  84. package/assets/.kilocode/workflows/aw-spec-orch.md +55 -0
  85. package/assets/docs/architecture/clean-architecture.md +1 -1
  86. package/assets/docs/architecture/cqrs.md +1 -1
  87. package/assets/docs/architecture/eventing.md +1 -1
  88. package/assets/docs/architecture/folder-structure.md +1 -1
  89. package/assets/docs/architecture/modular-monolith.md +1 -1
  90. package/assets/docs/architecture/persistence.md +1 -1
  91. package/assets/docs/architecture/testing.md +1 -1
  92. package/assets/docs/architecture/vertical-slices.md +1 -1
  93. package/assets/docs/components/Email/README.md +1 -1
  94. package/assets/docs/components/Jobs/README.md +1 -1
  95. package/assets/docs/components/QrGenerator/README.md +1 -1
  96. package/assets/docs/components/README.md +1 -1
  97. package/assets/docs/domain/domain-driven-design.md +1 -1
  98. package/assets/docs/domainspecs/healthcare/api-versioning.domain.md +1 -1
  99. package/assets/docs/domainspecs/healthcare/auditing.domain.md +86 -18
  100. package/assets/docs/domainspecs/healthcare/authorization.domain.md +1 -1
  101. package/assets/docs/domainspecs/healthcare/clinical-terms.md +1 -1
  102. package/assets/docs/domainspecs/healthcare/coding-standards.domain.md +1 -1
  103. package/assets/docs/domainspecs/healthcare/comandrulemap.md +58 -124
  104. package/assets/docs/domainspecs/healthcare/commandtechrulemap.md +119 -0
  105. package/assets/docs/domainspecs/healthcare/consent.md +1 -1
  106. package/assets/docs/domainspecs/healthcare/cqrs.domain.md +1 -1
  107. package/assets/docs/domainspecs/healthcare/data-classification.domain.md +1 -1
  108. package/assets/docs/domainspecs/healthcare/dependency-policy.domain.md +1 -1
  109. package/assets/docs/domainspecs/healthcare/dicom.md +1 -1
  110. package/assets/docs/domainspecs/healthcare/domain-driven-design.domain.md +1 -1
  111. package/assets/docs/domainspecs/healthcare/eventing.domain.md +1 -1
  112. package/assets/docs/domainspecs/healthcare/fhir.md +1 -1
  113. package/assets/docs/domainspecs/healthcare/folder-structure.domain.md +1 -1
  114. package/assets/docs/domainspecs/healthcare/hipaa.md +1 -1
  115. package/assets/docs/domainspecs/healthcare/hl7.md +1 -1
  116. package/assets/docs/domainspecs/healthcare/integration-terms.md +1 -1
  117. package/assets/docs/domainspecs/healthcare/persistence.domain.md +1 -1
  118. package/assets/docs/domainspecs/healthcare/terminology.md +1 -1
  119. package/assets/docs/domainspecs/healthcare/testing.domain.md +1 -1
  120. package/assets/docs/domainspecs/healthcare/vertical-slices.domain.md +1 -1
  121. package/assets/docs/domainspecs/healthcare/zero-trust.domain.md +1 -1
  122. package/assets/docs/domainspecs/pharma/api-versioning.domain.md +1 -1
  123. package/assets/docs/domainspecs/pharma/atc.md +1 -1
  124. package/assets/docs/domainspecs/pharma/auditing.domain.md +80 -16
  125. package/assets/docs/domainspecs/pharma/authorization.domain.md +1 -1
  126. package/assets/docs/domainspecs/pharma/coding-standards.domain.md +1 -1
  127. package/assets/docs/domainspecs/pharma/comandrulemap.md +64 -142
  128. package/assets/docs/domainspecs/pharma/commandtechrulemap.md +119 -0
  129. package/assets/docs/domainspecs/pharma/cqrs.domain.md +1 -1
  130. package/assets/docs/domainspecs/pharma/data-classification.domain.md +1 -1
  131. package/assets/docs/domainspecs/pharma/data-provenance.md +1 -1
  132. package/assets/docs/domainspecs/pharma/dependency-policy.domain.md +1 -1
  133. package/assets/docs/domainspecs/pharma/domain-driven-design.domain.md +1 -1
  134. package/assets/docs/domainspecs/pharma/eventing.domain.md +1 -1
  135. package/assets/docs/domainspecs/pharma/fhir.md +1 -1
  136. package/assets/docs/domainspecs/pharma/folder-structure.domain.md +1 -1
  137. package/assets/docs/domainspecs/pharma/hl7.md +1 -1
  138. package/assets/docs/domainspecs/pharma/idmp.md +1 -1
  139. package/assets/docs/domainspecs/pharma/integration-terms.md +1 -1
  140. package/assets/docs/domainspecs/pharma/ndc.md +1 -1
  141. package/assets/docs/domainspecs/pharma/persistence.domain.md +1 -1
  142. package/assets/docs/domainspecs/pharma/pharma-terms.md +1 -1
  143. package/assets/docs/domainspecs/pharma/product-lifecycle.domain.md +1 -1
  144. package/assets/docs/domainspecs/pharma/regulatory-compliance.md +1 -1
  145. package/assets/docs/domainspecs/pharma/terminology.md +1 -1
  146. package/assets/docs/domainspecs/pharma/testing.domain.md +1 -1
  147. package/assets/docs/domainspecs/pharma/vertical-slices.domain.md +1 -1
  148. package/assets/docs/domainspecs/pharma/zero-trust.domain.md +1 -1
  149. package/assets/docs/governance/api-versioning.md +1 -1
  150. package/assets/docs/governance/coding-standards.md +1 -1
  151. package/assets/docs/governance/dependency-policy.md +1 -1
  152. package/assets/docs/requirement-spec.txt +13 -1
  153. package/assets/docs/security/auditing.md +54 -11
  154. package/assets/docs/security/authorization.md +1 -1
  155. package/assets/docs/security/data-classification.md +1 -1
  156. package/assets/docs/security/zero-trust.md +1 -1
  157. package/dist/commands/update.d.ts +3 -0
  158. package/dist/commands/update.d.ts.map +1 -0
  159. package/dist/commands/update.js +142 -0
  160. package/dist/commands/update.js.map +1 -0
  161. package/dist/utils/manifest.d.ts +31 -0
  162. package/dist/utils/manifest.d.ts.map +1 -0
  163. package/dist/utils/manifest.js +91 -0
  164. package/dist/utils/manifest.js.map +1 -0
  165. package/dist/utils/paths.js +2 -2
  166. package/dist/utils/paths.js.map +1 -1
  167. package/dist/utils/update-engine.d.ts +52 -0
  168. package/dist/utils/update-engine.d.ts.map +1 -0
  169. package/dist/utils/update-engine.js +203 -0
  170. package/dist/utils/update-engine.js.map +1 -0
  171. package/package.json +1 -1
  172. package/assets/.agents/skills/aw-spec-addcomponent/SKILL.md +0 -129
  173. package/assets/.agents/skills/aw-spec-agent-context-update/SKILL.md +0 -31
  174. package/assets/.agents/skills/aw-spec-analyze/SKILL.md +0 -296
  175. package/assets/.agents/skills/aw-spec-checklist/SKILL.md +0 -374
  176. package/assets/.agents/skills/aw-spec-clarify/SKILL.md +0 -320
  177. package/assets/.agents/skills/aw-spec-constitution/SKILL.md +0 -194
  178. package/assets/.agents/skills/aw-spec-fast-implement/SKILL.md +0 -268
  179. package/assets/.agents/skills/aw-spec-foundation/SKILL.md +0 -358
  180. package/assets/.agents/skills/aw-spec-implement/SKILL.md +0 -279
  181. package/assets/.agents/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
  182. package/assets/.agents/skills/aw-spec-plan/SKILL.md +0 -221
  183. package/assets/.agents/skills/aw-spec-select-domain/SKILL.md +0 -73
  184. package/assets/.agents/skills/aw-spec-specify/SKILL.md +0 -382
  185. package/assets/.agents/skills/aw-spec-tasks/SKILL.md +0 -259
  186. package/assets/.agents/skills/aw-spec-taskstoissues/SKILL.md +0 -107
  187. package/assets/.aw-spec/integrations/claude.manifest.json +0 -17
  188. package/assets/.aw-spec/scripts/bash/check-prerequisites.sh +0 -189
  189. package/assets/.aw-spec/scripts/bash/create-new-feature.sh +0 -299
  190. package/assets/.aw-spec/scripts/bash/setup-plan.sh +0 -84
  191. package/assets/.aw-spec/scripts/bash/setup-tasks.sh +0 -91
  192. package/assets/.aw-spec/templates/checklist-template.md +0 -40
  193. package/assets/.aw-spec/templates/constitution-template.md +0 -57
  194. package/assets/.aw-spec/templates/plan-template.md +0 -113
  195. package/assets/.aw-spec/templates/tasks-template.md +0 -252
  196. package/assets/.aw-spec/workflows/aw-spec/workflow.yml +0 -77
  197. package/assets/.aw-spec/workflows/workflow-registry.json +0 -13
  198. package/assets/.claude/skills/aw-spec-addcomponent/SKILL.md +0 -129
  199. package/assets/.claude/skills/aw-spec-agent-context-update/SKILL.md +0 -31
  200. package/assets/.claude/skills/aw-spec-analyze/SKILL.md +0 -296
  201. package/assets/.claude/skills/aw-spec-checklist/SKILL.md +0 -374
  202. package/assets/.claude/skills/aw-spec-clarify/SKILL.md +0 -320
  203. package/assets/.claude/skills/aw-spec-constitution/SKILL.md +0 -194
  204. package/assets/.claude/skills/aw-spec-fast-implement/SKILL.md +0 -268
  205. package/assets/.claude/skills/aw-spec-foundation/SKILL.md +0 -358
  206. package/assets/.claude/skills/aw-spec-implement/SKILL.md +0 -279
  207. package/assets/.claude/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
  208. package/assets/.claude/skills/aw-spec-plan/SKILL.md +0 -221
  209. package/assets/.claude/skills/aw-spec-select-domain/SKILL.md +0 -73
  210. package/assets/.claude/skills/aw-spec-specify/SKILL.md +0 -382
  211. package/assets/.claude/skills/aw-spec-tasks/SKILL.md +0 -259
  212. package/assets/.claude/skills/aw-spec-taskstoissues/SKILL.md +0 -107
  213. package/assets/.kilocode/rules/specify-rules.md +0 -4
  214. package/assets/.kilocode/workflows/aw-spec-addcomponent.md +0 -129
  215. package/assets/.kilocode/workflows/aw-spec-agent-context.update.md +0 -31
  216. package/assets/.kilocode/workflows/aw-spec-analyze.md +0 -296
  217. package/assets/.kilocode/workflows/aw-spec-checklist.md +0 -374
  218. package/assets/.kilocode/workflows/aw-spec-clarify.md +0 -320
  219. package/assets/.kilocode/workflows/aw-spec-constitution.md +0 -194
  220. package/assets/.kilocode/workflows/aw-spec-fast-implement.md +0 -263
  221. package/assets/.kilocode/workflows/aw-spec-foundation.md +0 -358
  222. package/assets/.kilocode/workflows/aw-spec-implement.md +0 -279
  223. package/assets/.kilocode/workflows/aw-spec-minimal-implementation.md +0 -204
  224. package/assets/.kilocode/workflows/aw-spec-plan.md +0 -221
  225. package/assets/.kilocode/workflows/aw-spec-select-domain.md +0 -73
  226. package/assets/.kilocode/workflows/aw-spec-specify.md +0 -382
  227. package/assets/.kilocode/workflows/aw-spec-tasks.md +0 -259
  228. package/assets/.kilocode/workflows/aw-spec-taskstoissues.md +0 -107
  229. package/assets/docs/command.txt +0 -295
  230. package/assets/docs/pharma-constitution.my-ref.md +0 -86
@@ -0,0 +1,683 @@
1
+ #!/usr/bin/env python3
2
+ """aw-inventory.py — brownfield repository inventory for the aw-spec pipeline.
3
+
4
+ The deterministic, model-free substrate for the Brownfield-Pipeline B0
5
+ (Inventory) phase (see platform/AppWeaver.Component.Spec/Brownfield-Pipeline.my-ref.md).
6
+ It scans an existing ("brownfield") codebase and emits a factual JSON substrate
7
+ that the /aw-spec-orch inventory workflow's enrichment agent turns into an
8
+ aggregate-organized OKF v0.1 knowledge bundle. The agent uses this so it never
9
+ has to guess the repo layout.
10
+
11
+ Ported from Reference/okf-inventory.sh (which was ~90% embedded python already)
12
+ into a single cross-platform python core, with two brownfield additions:
13
+
14
+ * aggregate_signals — candidate DDD aggregate roots derived from domain /
15
+ entity / model / route file clustering, ranked by churn + evidence. These
16
+ are SIGNALS, not decisions: the workflow's agent draws the real aggregate
17
+ boundaries. This is the "inventory following aggregate root" the pipeline
18
+ needs — one aggregate ≈ one candidate specs/<Feature>/ vertical slice.
19
+ * git history scoped to the scan subtree (not the whole monorepo), so the
20
+ inventory reflects only the code being adopted.
21
+
22
+ Subcommands, all printing a single JSON object as the LAST stdout line (so the
23
+ orchestrator's execute_script can bind it); human-readable summaries go to STDERR
24
+ so they never corrupt the JSON contract:
25
+
26
+ scan [--root <dir>] [--out <path>] [--bundle <dir>] [--config <yml>]
27
+ -> writes the full substrate JSON to --out; prints a compact summary
28
+ object {inventory_json, file_count, head, branch, aggregate_count,
29
+ bundle_dir, bundle_present, bundle_md_count}
30
+ concept --bundle <dir> --aggregate <name>
31
+ -> resolves ONE aggregate's OKF concept + source_files (the code that
32
+ IS the slice) — the index adopt reverse-engineers a feature from
33
+ list-aggregates --bundle <dir>
34
+ -> enumerates the bundle's Aggregate concepts (the brownfield feature
35
+ map) → {aggregates[{name,title,slug,concept_path,...}], count} — the
36
+ selectable feature list select-feature offers in a brownfield repo
37
+ validate --bundle <dir> [--repo-root <dir>]
38
+ -> runs the OKF v0.1 conformance checker (validate_okf.py, beside
39
+ this file) and re-emits {concepts, errors[], warnings[], result,
40
+ conformant}
41
+
42
+ Usage: aw-inventory.py <scan|concept|list-aggregates|validate> [options]
43
+ """
44
+
45
+ import argparse
46
+ import json
47
+ import os
48
+ import re
49
+ import subprocess
50
+ import sys
51
+
52
+ HERE = os.path.dirname(os.path.abspath(__file__))
53
+
54
+ # Default excludes, aligned with okf-inventory.sh / okf-config.template.yml.
55
+ DEFAULT_EXCLUDE = re.compile(
56
+ r"^(node_modules|\.git|dist|build|vendor|\.specify|\.aw-spec|specs|knowledge)/"
57
+ r"|(^|/)[^/]*\.min\.js$"
58
+ )
59
+
60
+ CAP = int(os.environ.get("OKF_INVENTORY_CAP", "150"))
61
+ HIST_COMMITS = int(os.environ.get("OKF_HISTORY_COMMITS", "2000"))
62
+ HIST_RECENT = int(os.environ.get("OKF_HISTORY_RECENT", "20"))
63
+ CHURN_TOP = int(os.environ.get("OKF_CHURN_TOP", "30"))
64
+
65
+ # --- category regexes (mirror okf-inventory.sh) ----------------------------
66
+ MANIFESTS_RE = re.compile(
67
+ r"(^|/)(package\.json|pyproject\.toml|setup\.py|requirements[^/]*\.txt|"
68
+ r"go\.mod|Cargo\.toml|pom\.xml|build\.gradle(\.kts)?|Gemfile|composer\.json|"
69
+ r"[^/]*\.csproj)$"
70
+ )
71
+ ENTRYPOINTS_RE = re.compile(
72
+ r"(^|/)(main|app|index|server|cli|manage|__main__|Program)\.(py|js|ts|go|rs|rb|java|cs)$"
73
+ )
74
+ API_DEFS_RE = re.compile(
75
+ r"\.(proto|avsc|graphql|gql)$|(^|/)(openapi|swagger)[^/]*\.(ya?ml|json)$"
76
+ )
77
+ ROUTES_RE = re.compile(
78
+ r"(rout|controller|endpoint|handler|view|resource)s?[^/]*\.(py|js|ts|go|rb|java|cs|php)$",
79
+ re.IGNORECASE,
80
+ )
81
+ DB_FILES_RE = re.compile(
82
+ r"(^|/)(migrations?|alembic|db/migrate)/|"
83
+ r"(model|schema|entit|aggregat)[^/]*\.(py|js|ts|go|rb|java|cs|sql|prisma)$",
84
+ re.IGNORECASE,
85
+ )
86
+ OPS_FILES_RE = re.compile(
87
+ r"(^|/)(Dockerfile[^/]*|docker-compose[^/]*\.ya?ml|Makefile|Procfile|[^/]*\.tf|"
88
+ r"helm/.*|k8s/.*\.ya?ml|\.github/workflows/.*\.ya?ml|\.gitlab-ci\.yml|"
89
+ r"Jenkinsfile|cloudbuild\.ya?ml)$"
90
+ )
91
+ DOCS_RE = re.compile(
92
+ r"(^|/)(README[^/]*|CONTRIBUTING[^/]*|CHANGELOG[^/]*|ARCHITECTURE[^/]*|docs?/.*)"
93
+ r"\.(md|rst|txt)$"
94
+ )
95
+ CONFIGS_RE = re.compile(
96
+ r"(^|/)(config|settings|conf|appsettings)[^/]*\.(py|js|ts|ya?ml|json|toml|ini|env\.example)$",
97
+ re.IGNORECASE,
98
+ )
99
+ ADR_RE = re.compile(r"(^|/)(adr|adrs|decisions?|rfcs?)/|(^|/)(ADR|RFC)[-_0-9]", re.IGNORECASE)
100
+
101
+ # Directory / filename hints that a file participates in a domain aggregate.
102
+ DOMAIN_DIR_RE = re.compile(r"(^|/)(domain|aggregates?|entit(y|ies)|models?)(/|$)", re.IGNORECASE)
103
+ # Layer folders inside a vertical slice. The PARENT of any of these is an
104
+ # aggregate-module candidate (e.g. .../MedicinalProducts/Domain/... -> the
105
+ # aggregate module is MedicinalProducts). This mirrors the aw-spec
106
+ # MinimalMonolith vertical-slice layout and is the strongest, lowest-noise
107
+ # aggregate signal — far better than clustering on file basenames.
108
+ LAYER_DIR_RE = re.compile(
109
+ r"^(domain|features?|application|use[-_]?cases?|handlers?|persistence|"
110
+ r"infrastructure|infra|data|repositor(y|ies)|api|endpoints?|controllers?|"
111
+ r"aggregates?|entit(y|ies)|models?|dtos?|contracts?)$",
112
+ re.IGNORECASE,
113
+ )
114
+ # Which bucket a layer folder maps to, for grouping a candidate's files.
115
+ LAYER_KIND = {
116
+ "domain": "domain", "aggregate": "domain", "aggregates": "domain",
117
+ "entity": "domain", "entities": "domain", "model": "domain", "models": "domain",
118
+ "feature": "feature", "features": "feature", "application": "feature",
119
+ "usecase": "feature", "usecases": "feature", "use-case": "feature",
120
+ "use-cases": "feature", "use_case": "feature", "use_cases": "feature",
121
+ "handler": "feature", "handlers": "feature",
122
+ "persistence": "infra", "infrastructure": "infra", "infra": "infra",
123
+ "data": "infra", "repository": "infra", "repositories": "infra",
124
+ "api": "api", "endpoint": "api", "endpoints": "api",
125
+ "controller": "api", "controllers": "api",
126
+ "dto": "api", "dtos": "api", "contract": "api", "contracts": "api",
127
+ }
128
+ # Type-role suffixes stripped to recover the underlying aggregate/entity noun
129
+ # (used only for the secondary within-aggregate "entities" hint, and for the
130
+ # flat-repo fallback when there are no layer folders).
131
+ ROLE_SUFFIX_RE = re.compile(
132
+ r"(Aggregate(Root)?|Root|Entity|Model|Repository|Repo|Controller|Service|"
133
+ r"Handler|Endpoint|Resource|Dto|DTO|Mapper|Schema|Table|Command|Query|"
134
+ r"Factory|Spec|Specification|View)$"
135
+ )
136
+ # Verb prefixes on CQRS command/use-case names, stripped so RegisterMedicinal…
137
+ # resolves toward its aggregate rather than becoming its own candidate.
138
+ VERB_PREFIX_RE = re.compile(
139
+ r"^(Register|Create|Add|Update|Modify|Change|Delete|Remove|Get|List|Find|"
140
+ r"Fetch|Set|Assign|Approve|Reject|Cancel|Submit|Publish|Archive|Restore|"
141
+ r"Enable|Disable|Activate|Deactivate|Send|Generate)"
142
+ )
143
+ CODE_EXT_RE = re.compile(r"\.(py|js|ts|go|rs|rb|java|cs|php|kt|scala|sql|prisma)$", re.IGNORECASE)
144
+
145
+
146
+ def singularize(name):
147
+ """Light singularization for a module dir name (MedicinalProducts ->
148
+ MedicinalProduct). Signal-grade only — never grammar-perfect."""
149
+ if len(name) > 4 and name.endswith("s") and not name.lower().endswith(("ss", "us", "is", "as")):
150
+ return name[:-1]
151
+ return name
152
+
153
+
154
+ # --- glob-based config excludes (best-effort, mirrors okf-inventory.sh) -----
155
+ def load_config_excludes(config_path):
156
+ if not config_path or not os.path.isfile(config_path):
157
+ return []
158
+ try:
159
+ with open(config_path, encoding="utf-8") as f:
160
+ lines = f.readlines()
161
+ except OSError:
162
+ return []
163
+ in_exclude = False
164
+ globs = []
165
+ for line in lines:
166
+ stripped = line.strip()
167
+ if re.match(r"^exclude\s*:\s*$", stripped):
168
+ in_exclude = True
169
+ continue
170
+ if in_exclude:
171
+ m = re.match(r"^-\s*[\"']?([^\"'#]+)[\"']?\s*(#.*)?$", stripped)
172
+ if m:
173
+ globs.append(m.group(1).strip())
174
+ continue
175
+ if stripped and not stripped.startswith("#"):
176
+ in_exclude = False
177
+ out = []
178
+ for g in globs:
179
+ parts, i = [], 0
180
+ while i < len(g):
181
+ if g[i:i + 2] == "**":
182
+ parts.append(".*"); i += 2
183
+ elif g[i] == "*":
184
+ parts.append("[^/]*"); i += 1
185
+ else:
186
+ parts.append(re.escape(g[i])); i += 1
187
+ out.append(re.compile("^" + "".join(parts) + "$"))
188
+ return out
189
+
190
+
191
+ def git(root, *args):
192
+ """Run a git command in `root`; return stdout (stripped) or '' on failure."""
193
+ try:
194
+ p = subprocess.run(["git", "-C", root, *args], capture_output=True, text=True)
195
+ return p.stdout.strip() if p.returncode == 0 else ""
196
+ except Exception:
197
+ return ""
198
+
199
+
200
+ def is_excluded(path, config_excludes):
201
+ if DEFAULT_EXCLUDE.search(path):
202
+ return True
203
+ return any(rx.match(path) for rx in config_excludes)
204
+
205
+
206
+ def list_files(root, config_excludes):
207
+ """Repo-relative-to-`root` file list, honoring .gitignore via git ls-files."""
208
+ if git(root, "rev-parse", "--git-dir"):
209
+ raw = git(root, "ls-files")
210
+ files = raw.splitlines() if raw else []
211
+ else:
212
+ files = []
213
+ for dirpath, dirnames, filenames in os.walk(root):
214
+ dirnames[:] = [d for d in dirnames if d != ".git"]
215
+ for fn in filenames:
216
+ rel = os.path.relpath(os.path.join(dirpath, fn), root)
217
+ files.append(rel.replace(os.sep, "/"))
218
+ return [f for f in files if f.strip() and not is_excluded(f, config_excludes)]
219
+
220
+
221
+ def churn(root, config_excludes):
222
+ """Commit-count per file over the last HIST_COMMITS commits, scoped to the
223
+ scan subtree (paths made relative to `root` via --show-prefix)."""
224
+ if not git(root, "rev-parse", "--git-dir"):
225
+ return [], 0
226
+ prefix = git(root, "rev-parse", "--show-prefix") # root's path under toplevel
227
+ raw = git(root, "log", "--no-merges", "-n", str(HIST_COMMITS),
228
+ "--pretty=format:", "--name-only", "--", ".")
229
+ counts = {}
230
+ for line in raw.splitlines():
231
+ line = line.strip()
232
+ if not line:
233
+ continue
234
+ rel = line[len(prefix):] if prefix and line.startswith(prefix) else line
235
+ if prefix and not line.startswith(prefix):
236
+ continue # outside the scan subtree
237
+ if is_excluded(rel, config_excludes):
238
+ continue
239
+ counts[rel] = counts.get(rel, 0) + 1
240
+ ranked = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:CHURN_TOP]
241
+ scanned = git(root, "rev-list", "--no-merges", "--count", "-n", str(HIST_COMMITS), "HEAD")
242
+ return [{"file": f, "commits": c} for f, c in ranked], int(scanned or 0)
243
+
244
+
245
+ def recent_commits(root):
246
+ if not git(root, "rev-parse", "--git-dir"):
247
+ return []
248
+ raw = git(root, "log", "--no-merges", "-n", str(HIST_RECENT),
249
+ "--pretty=format:%h%x09%cI%x09%s", "--", ".")
250
+ out = []
251
+ for line in raw.splitlines():
252
+ parts = line.split("\t")
253
+ if len(parts) >= 3:
254
+ out.append({"sha": parts[0], "date": parts[1], "subject": "\t".join(parts[2:])})
255
+ return out
256
+
257
+
258
+ def matching(files, rx):
259
+ return [f for f in files if rx.search(f)]
260
+
261
+
262
+ def _entity_noun(path):
263
+ """Recover the domain noun from a file basename (role suffixes + a CQRS verb
264
+ prefix stripped): RegisterMedicinalProductCommandHandler -> MedicinalProduct.
265
+ Signal-grade; returns None for non-nouny names."""
266
+ base = os.path.splitext(os.path.basename(path))[0]
267
+ base = re.split(r"[._-]", base)[0]
268
+ prev = None
269
+ while prev != base:
270
+ prev = base
271
+ base = ROLE_SUFFIX_RE.sub("", base)
272
+ base = VERB_PREFIX_RE.sub("", base)
273
+ return base if base and base[:1].isalpha() and len(base) > 2 else None
274
+
275
+
276
+ def aggregate_signals(files, churn_map):
277
+ """Derive CANDIDATE aggregate roots — one per vertical slice, the base unit
278
+ the /aw-spec-orch inventory workflow turns into a specs/<Feature>/ slice.
279
+
280
+ PRIMARY signal (low-noise): a MODULE DIRECTORY that owns layer folders
281
+ (Domain / Features / Persistence / Api …). Its parent segment, singularized,
282
+ is the aggregate (…/MedicinalProducts/Domain/… -> MedicinalProduct); every
283
+ file under the module is grouped by layer. FALLBACK (flat repos with no
284
+ layer folders): cluster domain/route/api files on the basename noun.
285
+
286
+ Signals only — the workflow's agent draws the real boundaries. Empty for a
287
+ repo with no recognizable domain structure."""
288
+ modules = {} # module_dir -> candidate
289
+
290
+ def module_candidate(module_dir, name):
291
+ return modules.setdefault(module_dir, {
292
+ "name": name, "module_dir": module_dir,
293
+ "domain_files": [], "feature_files": [], "infra_files": [],
294
+ "api_files": [], "other_files": [], "entities": set(), "churn": 0,
295
+ })
296
+
297
+ # PRIMARY: walk each file's path for a layer folder; its parent is the module.
298
+ module_files = {} # module_dir -> [(file, kind)]
299
+ for f in files:
300
+ parts = f.split("/")
301
+ for i in range(1, len(parts) - 1): # a layer folder needs a parent + a child
302
+ if LAYER_DIR_RE.match(parts[i]):
303
+ module_dir = "/".join(parts[:i])
304
+ kind = LAYER_KIND.get(parts[i].lower(), "other")
305
+ module_files.setdefault(module_dir, []).append((f, kind))
306
+ break
307
+
308
+ for module_dir, entries in module_files.items():
309
+ name = singularize(os.path.basename(module_dir)) or os.path.basename(module_dir)
310
+ c = module_candidate(module_dir, name)
311
+ for f, kind in entries:
312
+ c[kind + "_files"].append(f)
313
+ c["churn"] += churn_map.get(f, 0)
314
+ if kind == "domain":
315
+ n = _entity_noun(f)
316
+ if n:
317
+ c["entities"].add(n)
318
+
319
+ sig = []
320
+ for c in modules.values():
321
+ layers = ("domain_files", "feature_files", "infra_files", "api_files", "other_files")
322
+ # Drop pure-doc/config false positives (e.g. docs/domain/): a real
323
+ # aggregate module owns at least one source file.
324
+ if not any(CODE_EXT_RE.search(f) for k in layers for f in c[k]):
325
+ continue
326
+ for k in layers:
327
+ c[k] = sorted(set(c[k]))[:CAP]
328
+ c["entities"] = sorted(c["entities"])[:CAP]
329
+ c["file_count"] = sum(len(c[k]) for k in layers)
330
+ c["evidence_count"] = c["file_count"]
331
+ sig.append(c)
332
+
333
+ # FALLBACK: only if the module walk found nothing (flat repo, no layer dirs).
334
+ if not sig:
335
+ domain = matching(files, DB_FILES_RE) + [f for f in files if DOMAIN_DIR_RE.search(f)]
336
+ flat = {}
337
+ for f in domain + matching(files, ROUTES_RE) + matching(files, API_DEFS_RE):
338
+ noun = _entity_noun(f)
339
+ if not noun:
340
+ continue
341
+ c = flat.setdefault(noun.lower(), {
342
+ "name": noun, "module_dir": "", "domain_files": [], "feature_files": [],
343
+ "infra_files": [], "api_files": [], "other_files": [], "entities": [], "churn": 0})
344
+ c["other_files"].append(f)
345
+ c["churn"] += churn_map.get(f, 0)
346
+ for c in flat.values():
347
+ c["other_files"] = sorted(set(c["other_files"]))[:CAP]
348
+ c["file_count"] = len(c["other_files"])
349
+ c["evidence_count"] = c["file_count"]
350
+ if c["file_count"] >= 2:
351
+ sig.append(c)
352
+
353
+ sig.sort(key=lambda c: (c["evidence_count"], c["churn"]), reverse=True)
354
+ return sig
355
+
356
+
357
+ def bundle_state(bundle_dir):
358
+ """How many concept .md files a candidate bundle already holds (the OKF
359
+ generate guard: a non-empty bundle means /aw-spec-orch inventory would
360
+ clobber curation — the workflow should stop unless --force)."""
361
+ if not os.path.isdir(bundle_dir):
362
+ return False, 0
363
+ count = 0
364
+ for dirpath, dirnames, filenames in os.walk(bundle_dir):
365
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
366
+ count += sum(1 for f in filenames if f.endswith(".md") and f not in ("index.md", "log.md"))
367
+ return count > 0, count
368
+
369
+
370
+ def cmd_scan(args):
371
+ root = os.path.abspath(args.root or os.getcwd())
372
+ config_excludes = load_config_excludes(args.config)
373
+ out = os.path.abspath(args.out) if args.out else os.path.join(root, ".aw-spec", "inventory", "inventory.json")
374
+ bundle_dir = os.path.abspath(args.bundle) if args.bundle else os.path.join(root, "knowledge")
375
+
376
+ files = list_files(root, config_excludes)
377
+ churn_list, commits_scanned = churn(root, config_excludes)
378
+ churn_map = {c["file"]: c["commits"] for c in churn_list}
379
+
380
+ def hist(s):
381
+ h = {}
382
+ for f in files:
383
+ _, dot, ext = f.rpartition(".")
384
+ if dot and ext:
385
+ h[ext] = h.get(ext, 0) + 1
386
+ return h
387
+
388
+ lang_hist = dict(sorted(hist(files).items(), key=lambda kv: kv[1], reverse=True)[:15])
389
+ topdirs = {}
390
+ for f in files:
391
+ if "/" in f:
392
+ top = f.split("/", 1)[0]
393
+ topdirs[top] = topdirs.get(top, 0) + 1
394
+ top_level_dirs = dict(sorted(topdirs.items(), key=lambda kv: kv[1], reverse=True)[:20])
395
+
396
+ def category(rx):
397
+ hits = matching(files, rx)
398
+ return {"items": hits[:CAP], "truncated": len(hits) > CAP}
399
+
400
+ cats = {
401
+ "dependency_manifests": category(MANIFESTS_RE),
402
+ "entrypoints": category(ENTRYPOINTS_RE),
403
+ "api_definitions": category(API_DEFS_RE),
404
+ "route_like_files": category(ROUTES_RE),
405
+ "data_layer_files": category(DB_FILES_RE),
406
+ "ops_files": category(OPS_FILES_RE),
407
+ "docs": category(DOCS_RE),
408
+ "config_files": category(CONFIGS_RE),
409
+ "adr_docs": category(ADR_RE),
410
+ }
411
+ signals = aggregate_signals(files, churn_map)
412
+ present, md_count = bundle_state(bundle_dir)
413
+
414
+ inv = {
415
+ "root": root,
416
+ "git": {
417
+ "remote": git(root, "remote", "get-url", "origin"),
418
+ "branch": git(root, "symbolic-ref", "--short", "HEAD") or "main",
419
+ "head": git(root, "rev-parse", "--short", "HEAD"),
420
+ "history": {
421
+ "commits_scanned": commits_scanned,
422
+ "churn": churn_list,
423
+ "recent_commits": recent_commits(root),
424
+ "churn_truncated": len(churn_list) >= CHURN_TOP > 0,
425
+ },
426
+ },
427
+ "file_count": len(files),
428
+ "cap": CAP,
429
+ "language_histogram": lang_hist,
430
+ "top_level_dirs": top_level_dirs,
431
+ **{k: v["items"] for k, v in cats.items()},
432
+ "aggregate_signals": signals,
433
+ "truncated": {k: True for k, v in cats.items() if v["truncated"]},
434
+ }
435
+ os.makedirs(os.path.dirname(out), exist_ok=True)
436
+ with open(out, "w", encoding="utf-8") as f:
437
+ json.dump(inv, f, indent=2)
438
+
439
+ # Human summary -> STDERR (must not pollute the JSON contract on stdout).
440
+ print(f"Inventory written to {out}", file=sys.stderr)
441
+ print(f" files: {inv['file_count']} head: {inv['git']['head'] or '-'} "
442
+ f"branch: {inv['git']['branch']}", file=sys.stderr)
443
+ h = inv["git"]["history"]
444
+ print(f" git history: {h['commits_scanned']} commits scanned, "
445
+ f"{len(h['churn'])} hot files, {len(h['recent_commits'])} recent subjects",
446
+ file=sys.stderr)
447
+ print(f" candidate aggregates ({len(signals)}): "
448
+ f"{', '.join(s['name'] for s in signals[:12])}"
449
+ f"{' …' if len(signals) > 12 else ''}", file=sys.stderr)
450
+ for k in cats:
451
+ flag = " (truncated)" if k in inv["truncated"] else ""
452
+ print(f" {k}: {len(inv[k])}{flag}", file=sys.stderr)
453
+ if present:
454
+ print(f" NOTE: bundle {bundle_dir} already holds {md_count} concept file(s)",
455
+ file=sys.stderr)
456
+
457
+ # Machine contract -> LAST stdout line (bound by the orchestrator).
458
+ print(json.dumps({
459
+ "inventory_json": out,
460
+ "root": root,
461
+ "file_count": inv["file_count"],
462
+ "head": inv["git"]["head"],
463
+ "branch": inv["git"]["branch"],
464
+ "aggregate_count": len(signals),
465
+ "bundle_dir": bundle_dir,
466
+ "bundle_present": "true" if present else "false",
467
+ "bundle_md_count": md_count,
468
+ }))
469
+ return 0
470
+
471
+
472
+ def _slug(s):
473
+ return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", (s or "").lower())).strip("-")
474
+
475
+
476
+ def _pascal(s):
477
+ """kebab/space/anything -> PascalCase, byte-compatible with the bash/ps
478
+ _pascal + set-feature normalizer (uppercase each word's first char, keep the
479
+ rest as-is so an existing PascalCase token like MedicinalProduct is preserved)."""
480
+ words = re.split(r"[^A-Za-z0-9]+", s or "")
481
+ return "".join(w[:1].upper() + w[1:] for w in words if w)
482
+
483
+
484
+ def _read_frontmatter(path):
485
+ """Return the concept's frontmatter dict (title/type/source_files), best-effort
486
+ without a YAML dependency. Only the fields adopt needs."""
487
+ try:
488
+ with open(path, encoding="utf-8") as f:
489
+ text = f.read()
490
+ except OSError:
491
+ return {}
492
+ if not text.startswith("---"):
493
+ return {}
494
+ lines = text.splitlines()
495
+ end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
496
+ if end is None:
497
+ return {}
498
+ data, key = {}, None
499
+ for ln in lines[1:end]:
500
+ m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", ln)
501
+ if m:
502
+ key = m.group(1)
503
+ val = m.group(2).strip()
504
+ data[key] = val.strip("'\"") if val else []
505
+ elif key and re.match(r"^\s*-\s+", ln): # a list item under the last key
506
+ if not isinstance(data.get(key), list):
507
+ data[key] = []
508
+ data[key].append(re.sub(r"^\s*-\s+", "", ln).strip().strip("'\""))
509
+ return data
510
+
511
+
512
+ def cmd_concept(args):
513
+ """Resolve the OKF bundle concept for an aggregate → its path + source_files
514
+ (the code that IS the slice). The index adopt reverse-engineers spec.md +
515
+ design.md FROM. Matches on title/filename slug; prefers an Aggregate type."""
516
+ bundle = os.path.abspath(args.bundle)
517
+ want = _slug(args.aggregate)
518
+ best = None
519
+ if os.path.isdir(bundle):
520
+ for dirpath, dirnames, filenames in os.walk(bundle):
521
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
522
+ for fn in filenames:
523
+ if not fn.endswith(".md") or fn in ("index.md", "log.md"):
524
+ continue
525
+ path = os.path.join(dirpath, fn)
526
+ fm = _read_frontmatter(path)
527
+ title = fm.get("title") if isinstance(fm.get("title"), str) else ""
528
+ base = os.path.splitext(fn)[0]
529
+ is_match = want in (_slug(title), _slug(base)) or (
530
+ want and title and want.replace("-", "") in _slug(title).replace("-", ""))
531
+ if not is_match:
532
+ continue
533
+ typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
534
+ sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
535
+ score = (2 if "aggregate" in typ.lower() else 0) + (1 if _slug(base) == want else 0)
536
+ cand = {"concept_path": os.path.relpath(path, os.getcwd()),
537
+ "title": title or base, "type": typ, "source_files": sf, "score": score}
538
+ if best is None or cand["score"] > best["score"]:
539
+ best = cand
540
+ if not best:
541
+ print(json.dumps({"exists": "false", "aggregate": args.aggregate, "concept_path": "",
542
+ "source_files": [], "count": 0, "module_dir": ""}))
543
+ return 0
544
+ sf = best["source_files"]
545
+ # Common directory prefix of the source files = the aggregate's module dir.
546
+ module_dir = ""
547
+ if sf:
548
+ common = os.path.commonpath([s.replace("\\", "/") for s in sf]) if len(sf) > 1 else os.path.dirname(sf[0])
549
+ module_dir = common
550
+ print(f"Concept for {args.aggregate}: {best['concept_path']} "
551
+ f"({len(sf)} source file(s), module {module_dir or '-'})", file=sys.stderr)
552
+ print(json.dumps({
553
+ "exists": "true", "aggregate": args.aggregate, "concept_path": best["concept_path"],
554
+ "title": best["title"], "type": best["type"],
555
+ "source_files": sf, "count": len(sf), "module_dir": module_dir,
556
+ }))
557
+ return 0
558
+
559
+
560
+ def cmd_list_aggregates(args):
561
+ """Enumerate the OKF bundle's Aggregate concepts — the brownfield FEATURE MAP.
562
+ This is the selectable feature list `select-feature` offers in a brownfield
563
+ repo (the discovered aggregates), as opposed to only the specs/ sub-folders.
564
+
565
+ Each aggregate → its candidate specs/<Feature> slice name (PascalCase, the id
566
+ set-feature would create), its title/concept_path, and source_file_count. The
567
+ caller merges these with the specs/ list, deduping by name (case-insensitive).
568
+ Empty {aggregates:[], count:0} on a greenfield repo (no bundle) — a no-op there.
569
+ """
570
+ bundle = os.path.abspath(args.bundle)
571
+ present = os.path.isdir(bundle)
572
+ by_name = {}
573
+ if present:
574
+ for dirpath, dirnames, filenames in os.walk(bundle):
575
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
576
+ for fn in sorted(filenames):
577
+ if not fn.endswith(".md") or fn in ("index.md", "log.md"):
578
+ continue
579
+ path = os.path.join(dirpath, fn)
580
+ fm = _read_frontmatter(path)
581
+ typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
582
+ if "aggregate" not in typ.lower():
583
+ continue
584
+ title = fm.get("title") if isinstance(fm.get("title"), str) else ""
585
+ base = os.path.splitext(fn)[0]
586
+ name = _pascal(title or base)
587
+ if not name:
588
+ continue
589
+ sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
590
+ cand = {
591
+ "name": name,
592
+ "title": title or base,
593
+ "slug": _slug(name),
594
+ "concept_path": os.path.relpath(path, os.getcwd()),
595
+ "type": typ,
596
+ "source_file_count": len(sf),
597
+ }
598
+ # Dedup by the case-insensitive feature name; a filename-slug match
599
+ # to the wanted id, or a richer source set, wins the tie.
600
+ key = name.lower()
601
+ prev = by_name.get(key)
602
+ if prev is None or (
603
+ (_slug(base) == cand["slug"]) or cand["source_file_count"] > prev["source_file_count"]):
604
+ by_name[key] = cand
605
+ aggs = sorted(by_name.values(), key=lambda a: a["name"].lower())
606
+ print(f"OKF aggregates in {args.bundle}: {len(aggs)} "
607
+ f"({', '.join(a['name'] for a in aggs) or '-'})", file=sys.stderr)
608
+ print(json.dumps({
609
+ "aggregates": aggs, "count": len(aggs),
610
+ "bundle": args.bundle, "bundle_present": "true" if present else "false",
611
+ }))
612
+ return 0
613
+
614
+
615
+ def cmd_validate(args):
616
+ validator = os.path.join(HERE, "validate_okf.py")
617
+ if not os.path.isfile(validator):
618
+ print(json.dumps({"result": "ERROR", "conformant": "false",
619
+ "errors": [f"validator not found: {validator}"], "warnings": [],
620
+ "concepts": 0}))
621
+ return 1
622
+ if not os.path.isdir(args.bundle):
623
+ print(json.dumps({"result": "ERROR", "conformant": "false",
624
+ "errors": [f"bundle dir not found: {args.bundle}"],
625
+ "warnings": [], "concepts": 0}))
626
+ return 1
627
+ cmd = [sys.executable, validator, args.bundle, "--json"]
628
+ if args.repo_root:
629
+ cmd += ["--repo-root", args.repo_root]
630
+ proc = subprocess.run(cmd, capture_output=True, text=True)
631
+ try:
632
+ # validate_okf.py --json emits one pretty-printed (multi-line) object.
633
+ raw = proc.stdout.strip()
634
+ data = json.loads(raw[raw.index("{"):])
635
+ except Exception:
636
+ print(json.dumps({"result": "ERROR", "conformant": "false",
637
+ "errors": [f"validator emitted no JSON: {proc.stdout[:300]} "
638
+ f"{proc.stderr[:300]}"], "warnings": [], "concepts": 0}))
639
+ return 1
640
+ # Mirror validate_okf's human report to stderr for the operator.
641
+ print(f"OKF validation of {args.bundle}: {data.get('result')} "
642
+ f"({data.get('concepts')} concepts, {len(data.get('errors', []))} errors, "
643
+ f"{len(data.get('warnings', []))} warnings)", file=sys.stderr)
644
+ for e in data.get("errors", []):
645
+ print(f" ERROR {e}", file=sys.stderr)
646
+ for w in data.get("warnings", []):
647
+ print(f" WARNING {w}", file=sys.stderr)
648
+ data["conformant"] = "false" if data.get("errors") else "true"
649
+ print(json.dumps(data))
650
+ return 1 if data.get("errors") else 0
651
+
652
+
653
+ def main():
654
+ ap = argparse.ArgumentParser(description="Brownfield inventory scanner + OKF validator bridge.")
655
+ sub = ap.add_subparsers(dest="cmd", required=True)
656
+
657
+ ps = sub.add_parser("scan", help="Scan a brownfield repo; emit the JSON inventory substrate.")
658
+ ps.add_argument("--root", help="Codebase to inventory (default: CWD; git history scoped to it).")
659
+ ps.add_argument("--out", help="Substrate JSON path (default: <root>/.aw-spec/inventory/inventory.json).")
660
+ ps.add_argument("--bundle", help="OKF bundle dir to check for existing curation (default: <root>/knowledge).")
661
+ ps.add_argument("--config", help="okf-config.yml whose exclude: list is honored.")
662
+ ps.set_defaults(func=cmd_scan)
663
+
664
+ pc = sub.add_parser("concept", help="Resolve the OKF bundle concept + source_files for an aggregate.")
665
+ pc.add_argument("--bundle", required=True, help="OKF bundle dir to search.")
666
+ pc.add_argument("--aggregate", required=True, help="Aggregate name (e.g. MedicinalProduct).")
667
+ pc.set_defaults(func=cmd_concept)
668
+
669
+ pl = sub.add_parser("list-aggregates", help="List the OKF bundle's Aggregate concepts (the brownfield feature map).")
670
+ pl.add_argument("--bundle", required=True, help="OKF bundle dir to enumerate.")
671
+ pl.set_defaults(func=cmd_list_aggregates)
672
+
673
+ pv = sub.add_parser("validate", help="Run the OKF v0.1 conformance checker on a bundle.")
674
+ pv.add_argument("--bundle", required=True, help="OKF bundle dir to validate.")
675
+ pv.add_argument("--repo-root", help="Repo root for resolving source_files (default: nearest .git).")
676
+ pv.set_defaults(func=cmd_validate)
677
+
678
+ args = ap.parse_args()
679
+ return args.func(args)
680
+
681
+
682
+ if __name__ == "__main__":
683
+ sys.exit(main())