aw-spec-cli 0.1.8 → 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 (257) 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/package.json +1 -1
  257. package/assets/docs/pharma-constitution.my-ref.md +0 -86
@@ -0,0 +1,1251 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ aw-stack — the lane model's deterministic core.
4
+
5
+ Backs seven aw-spec-actions subcommands, called identically by the bash and the
6
+ powershell twin so the two stay in parity by construction rather than by review:
7
+
8
+ list enumerate .appweaver/stack-profiles/ — the candidate list
9
+ detect match the repo's own files against those profiles — the BROWNFIELD
10
+ candidate list, since an existing repo already answered the question
11
+ declare upsert a lane into .appweaver/stacks.json — THE SOLE WRITER
12
+ resolve read .appweaver/stacks.json — for every downstream reader
13
+ verify is the declaration USABLE for the phase about to run — the gate
14
+ run-phase run a phase's command for EVERY declared lane, not just the first
15
+ probe is there evidence of a lane of this kind — read-only, never fatal
16
+
17
+ `declare` is the sole writer and has exactly one caller — `select-stack`, where a
18
+ person picks — so the rule is a grep rather than a convention. The `origin` key
19
+ stays optional in the schema for a future caller that proposes a lane without a
20
+ person in the loop; a reader that branches on it keeps working either way.
21
+
22
+ A person editing stacks.json in a text editor is the other way a lane arrives,
23
+ and it is supported: the kit ships the file holding `{"lanes": []}` for exactly
24
+ that. It is the one path that does not pass the writer's checks, which is why
25
+ `verify` re-checks the roots-disjoint invariant rather than trusting that
26
+ `declare` already did. `resolve` can never write, which is why these are two
27
+ subcommands and not one with a mode flag.
28
+
29
+ A lane's `root` IS its source path — the subtree the lane owns and the only place
30
+ its implement phase may write. The two words name one field, and `--source-path`
31
+ is accepted wherever `--root` is.
32
+
33
+ WHERE THE COMMANDS RUN is a separate question, because for most stacks the answer
34
+ is a different directory: `dotnet build` wants the solution file at the top while
35
+ the lane owns `src/`, and `npm run build` wants the package.json at the top while
36
+ the lane owns `web/`. `run_from` carries that answer — "repo" (the default, and
37
+ the shape of every lane written before the key existed) or "root". A stack whose
38
+ manifest lives INSIDE its subtree needs "root" and cannot work without it: a Maven
39
+ wrapper at backend/<app>/mvnw is unreachable from the repository root.
40
+
41
+ WHERE THE PATH IS CHECKED AGAINST THE DISK. `declare` records a path; it does not
42
+ require the path to exist, because in a greenfield project it does not exist yet —
43
+ the whole point of declaring `src/` on day one is that `implement` will create it.
44
+ `verify` is the check, and it runs where the path has to be real: before implement,
45
+ build, run, and before onboard reverse-engineers a codebase out of it. That split
46
+ is what lets one file serve a project that has no code yet and a project that is
47
+ nothing but code.
48
+
49
+ Stdlib only. Emits one line of compact JSON on stdout.
50
+ """
51
+
52
+ import argparse
53
+ import fnmatch
54
+ import glob
55
+ import json
56
+ import os
57
+ import re
58
+ import sys
59
+
60
+ STACKS_REL = ".appweaver/stacks.json"
61
+ PROFILES_REL = ".appweaver/stack-profiles"
62
+
63
+ # A lane id names the ROLE in the project; the profile names the technology.
64
+ # Keeping them separate is what lets a project re-stack a lane without renaming
65
+ # the paths, rules and constitution half that hang off the lane id.
66
+ DEFAULT_LANE_ID = {"backend": "service", "frontend": "web"}
67
+
68
+ # "(none)" is a real answer for EITHER kind: a backend-only project declines the UI
69
+ # lane, and a UI-only project declines the service lane. Both are projects the kit
70
+ # has to be able to govern, and the sentinel reads as the answer to the question
71
+ # that was actually asked. The leading "(none)" token is what `declare` matches on,
72
+ # so the trailing prose is free to differ by kind.
73
+ NONE_SENTINEL = "(none) — no lane of this kind"
74
+ NONE_SENTINEL_BY_KIND = {
75
+ "backend": "(none) — no service lane, UI-only project",
76
+ "frontend": "(none) — no UI lane, backend-only project",
77
+ }
78
+
79
+
80
+ def none_sentinel(kind):
81
+ return NONE_SENTINEL_BY_KIND.get(kind or "", NONE_SENTINEL)
82
+
83
+
84
+ def emit(obj):
85
+ sys.stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")
86
+
87
+
88
+ def fail(msg):
89
+ sys.stderr.write(msg + "\n")
90
+ sys.exit(1)
91
+
92
+
93
+ # ─────────────────────────────── profiles ──────────────────────────────────
94
+
95
+ def load_profiles(repo, kind=None):
96
+ base = os.path.join(repo, PROFILES_REL)
97
+ out = []
98
+ for path in sorted(glob.glob(os.path.join(base, "*.json"))):
99
+ try:
100
+ with open(path, "r", encoding="utf-8") as fh:
101
+ prof = json.load(fh)
102
+ except (ValueError, OSError):
103
+ continue # a malformed profile is skipped, never fatal
104
+ if not prof.get("id") or not prof.get("kind"):
105
+ continue
106
+ if kind and kind != "all" and prof.get("kind") != kind:
107
+ continue
108
+ out.append(prof)
109
+ return out
110
+
111
+
112
+ def option_label(prof):
113
+ lang = prof.get("language", "")
114
+ suffix = " (%s)" % lang if lang else ""
115
+ return "%s — %s%s" % (prof["id"], prof.get("title", prof["id"]), suffix)
116
+
117
+
118
+ def profile_id_from_label(label):
119
+ """Options are '<id> — <title> (<lang>)'; the id is the leading token.
120
+
121
+ Accepts a bare id too, so --profile works with a direct argument and with a
122
+ selection relayed back from the launcher.
123
+ """
124
+ return (label or "").strip().split()[0] if (label or "").strip() else ""
125
+
126
+
127
+ def resolve_preselect(text, profiles, include_none, kind=None):
128
+ """Match a direct argument against THIS kind's profiles only.
129
+
130
+ select-stack asks twice, so one argument would be ambiguous — except that a
131
+ profile id belongs to exactly one kind. Each list matches the token it owns
132
+ and ignores the rest, which makes `select-stack dotnet-webapi react-vite`
133
+ answer both selects and `select-stack react-vite` answer only the UI one.
134
+ """
135
+ tokens = [t.strip().lower() for t in (text or "").replace(",", " ").split()]
136
+ if not tokens:
137
+ return ""
138
+ for prof in profiles:
139
+ if prof["id"].lower() in tokens:
140
+ return option_label(prof)
141
+ if include_none and ("none" in tokens or "(none)" in tokens):
142
+ return none_sentinel(kind)
143
+ return ""
144
+
145
+
146
+ # ─────────────────────────────── detection ─────────────────────────────────
147
+ # Match a repository against the installed profiles' own `detect` patterns. This
148
+ # is the brownfield source of lane candidates: greenfield asks an operator to pick
149
+ # from `stack-list`, and an existing repo already answered the question in its
150
+ # files. DETECTION PROPOSES — it writes nothing and decides nothing; `declare` is
151
+ # still the only writer, and a person still confirms through select-stack.
152
+ #
153
+ # Patterns are matched on BASENAME, anywhere in the tree, not as root-anchored
154
+ # globs: a profile that ships `detect: ["web/package.json", "web/vite.config.*"]`
155
+ # must still recognise the same stack in `client/` or `apps/portal/`, because a
156
+ # brownfield repo did not choose its layout from our profile. The directory where
157
+ # the patterns actually met IS the detected root, which is why the root comes out
158
+ # of detection rather than out of the profile.
159
+ DETECT_PRUNE = {"node_modules", ".git", "dist", "build", "out", "bin", "obj",
160
+ "vendor", ".venv", "venv", "__pycache__", ".appweaver", "specs", "knowledge"}
161
+
162
+
163
+ _REPO_FILES_CACHE = {}
164
+
165
+
166
+ def _repo_files(repo, cap=20000):
167
+ """Every file in the repo, pruned of the directories detection never looks in.
168
+
169
+ Memoised per (repo, cap) for the life of the process. Detection is asked the same
170
+ question several times in one run — once per kind, plus once more to work out which
171
+ subtrees another kind already owns — and the answer cannot change underneath a single
172
+ CLI invocation, which is the only scope this cache spans. On a large brownfield tree
173
+ the walk is the expensive part of `stack-list --suggest`.
174
+
175
+ Callers only read the result, so they share one list.
176
+ """
177
+ key = (repo, cap)
178
+ if key in _REPO_FILES_CACHE:
179
+ return _REPO_FILES_CACHE[key]
180
+ out = []
181
+ for dirpath, dirnames, filenames in os.walk(repo):
182
+ dirnames[:] = [d for d in dirnames if d not in DETECT_PRUNE and not d.startswith(".")]
183
+ rel = os.path.relpath(dirpath, repo).replace("\\", "/")
184
+ rel = "" if rel == "." else rel
185
+ for f in filenames:
186
+ out.append((rel + "/" + f) if rel else f)
187
+ if len(out) >= cap:
188
+ _REPO_FILES_CACHE[key] = out
189
+ return out
190
+ _REPO_FILES_CACHE[key] = out
191
+ return out
192
+
193
+
194
+ def _glob_rx(pattern):
195
+ """Translate a detect glob to a regex over a '/'-relative path.
196
+
197
+ `**` spans directory separators and `*` does not, which is the distinction
198
+ `src/**/*.csproj` depends on and fnmatch does not make.
199
+ """
200
+ out, i = [], 0
201
+ while i < len(pattern):
202
+ c = pattern[i]
203
+ if pattern.startswith("**/", i):
204
+ out.append("(?:[^/]+/)*"); i += 3
205
+ elif pattern.startswith("**", i):
206
+ out.append(".*"); i += 2
207
+ elif c == "*":
208
+ out.append("[^/]*"); i += 1
209
+ elif c == "?":
210
+ out.append("[^/]"); i += 1
211
+ else:
212
+ out.append(re.escape(c)); i += 1
213
+ return re.compile(r"\A" + "".join(out) + r"\Z")
214
+
215
+
216
+ def _ambiguous_basenames(repo):
217
+ """Detect-pattern filenames that profiles of MORE THAN ONE kind claim.
218
+
219
+ `package.json` marks a Node service as readily as a Vite UI, and `*.csproj` a Web API
220
+ as readily as a Blazor client. Such a name is real evidence where the profile's own
221
+ layout put it, but it cannot by itself carry a profile into a directory the profile
222
+ never named — that is how a .NET backend acquires a phantom Blazor UI lane. Derived
223
+ from the installed profiles rather than listed here, so a new profile that shares a
224
+ manifest is accounted for by being installed.
225
+ """
226
+ owners = {}
227
+ for prof in load_profiles(repo):
228
+ for pat in (prof.get("detect") or []):
229
+ owners.setdefault(os.path.basename(pat), set()).add(prof.get("kind", ""))
230
+ return {b for b, kinds in owners.items() if len(kinds) > 1}
231
+
232
+
233
+ def detect_profiles(repo, kind=None, exclude_roots=()):
234
+ """Rank the installed profiles against what this repo actually contains.
235
+
236
+ `detect` is an ANY-of list: `java-spring` names both `pom.xml` and
237
+ `build.gradle` because a project has one or the other, and requiring both
238
+ would make it undetectable. So one pattern hitting is a match, and the COUNT
239
+ of patterns that hit is the score.
240
+
241
+ Matching is tried twice. ANCHORED — the pattern against the repo-relative
242
+ path, which is how the patterns are written. Then RELOCATED — the same
243
+ pattern allowed any directory prefix, because a profile that ships
244
+ `web/package.json` must still recognise the same stack in `client/` or
245
+ `apps/portal/`: a brownfield repo did not pick its layout from our profiles.
246
+ An anchored hit outranks a relocated one.
247
+
248
+ `root` is reported separately from the match, because in these profiles the
249
+ two are independent — `go-chi` detects on `go.mod` at the repo root and
250
+ declares a root of `internal/`. The reported root is the shallowest directory
251
+ the evidence actually sits in, falling back to the profile's own. Callers with
252
+ better information override it; `onboard` does exactly that for the frontend
253
+ lane, from the scan's own candidate roots."""
254
+ files = _repo_files(repo)
255
+ ambiguous = _ambiguous_basenames(repo)
256
+ out = []
257
+ for prof in load_profiles(repo, kind):
258
+ patterns = prof.get("detect") or []
259
+ if not patterns:
260
+ continue
261
+ # A subtree another lane already owns cannot be evidence for this one.
262
+ # Without this a backend profile relocates onto the frontend's own
263
+ # `package.json` and claims the UI's root — the ownership rule violated by
264
+ # the very step that is supposed to establish it.
265
+ scoped = [f for f in files
266
+ if not any(norm_root(os.path.dirname(f) or ".").startswith(x)
267
+ or (f + "/").startswith(x)
268
+ for x in exclude_roots if x)]
269
+ files_for_prof = scoped if prof.get("kind") != "frontend" else files
270
+ evidence, matched, anchored_hits = [], 0, 0
271
+ prof_root = norm_root(prof.get("root") or "")
272
+ for pat in patterns:
273
+ rx = _glob_rx(pat)
274
+ rx_any = _glob_rx("**/" + pat)
275
+ hits = [f for f in files_for_prof if rx.match(f)]
276
+ if hits:
277
+ anchored_hits += 1
278
+ else:
279
+ hits = [f for f in files_for_prof if rx_any.match(f)]
280
+ if not hits and prof_root and pat.startswith(prof_root) \
281
+ and os.path.basename(pat) not in ambiguous:
282
+ # RELOCATED, with the profile's own root segment substituted rather than
283
+ # prefixed. Prefixing alone only ever finds the profile's own folder name
284
+ # deeper in the tree — `apps/portal/web/angular.json` — so a UI in
285
+ # `client/` or at the repo root, which is what most repositories that did
286
+ # not start from these profiles actually look like, went undetected.
287
+ # Restricted to names only one kind claims, so substitution widens where a
288
+ # stack may live without widening what counts as evidence for it.
289
+ hits = [f for f in files_for_prof
290
+ if _glob_rx("**/" + pat[len(prof_root):]).match(f)]
291
+ if not hits:
292
+ continue
293
+ matched += 1
294
+ evidence.extend(hits[:10])
295
+ if not matched:
296
+ continue # no evidence at all is not a stack
297
+ dirs = sorted({os.path.dirname(f) for f in evidence}, key=lambda d: (d.count("/"), len(d)))
298
+ # WHERE THE SOURCE LIVES, which is not always where the evidence sits. A manifest
299
+ # at the repo root — `App.sln`, a workspace `package.json` — marks the repository,
300
+ # not the subtree: the code it describes is under the profile's own root. So repo-
301
+ # root evidence defers to that root when the directory is really there, and only
302
+ # falls back to the repo root itself when it is not, which is the case for the
303
+ # single-package layouts (`create-next-app`, a UI checked out at the top level)
304
+ # whose files genuinely are at the top.
305
+ prof_default = norm_root(prof.get("root") or "")
306
+ if not dirs:
307
+ root = prof_default
308
+ elif dirs[0]:
309
+ root = norm_root(dirs[0])
310
+ elif prof_default and os.path.isdir(os.path.join(repo, prof_default)):
311
+ root = prof_default
312
+ else:
313
+ root = REPO_ROOT
314
+ out.append({
315
+ "id": prof["id"],
316
+ "kind": prof.get("kind", ""),
317
+ "title": prof.get("title", prof["id"]),
318
+ "language": prof.get("language", ""),
319
+ "root": root,
320
+ "profile_root": norm_root(prof.get("root") or ""),
321
+ "build": prof.get("build", ""),
322
+ "test": prof.get("test", ""),
323
+ "run": prof.get("run", ""),
324
+ "patterns": len(patterns),
325
+ "patterns_matched": matched,
326
+ "anchored": anchored_hits > 0,
327
+ "evidence": sorted(set(evidence))[:20],
328
+ })
329
+ # Strongest first: an anchored match, then more patterns hit, then a shallower root.
330
+ out.sort(key=lambda m: (m["anchored"], m["patterns_matched"], -m["root"].count("/")),
331
+ reverse=True)
332
+ return out
333
+
334
+
335
+ def cmd_detect(args):
336
+ excl = [norm_root(r) for r in (args.exclude_root or []) if r]
337
+ matches = detect_profiles(args.repo, args.kind, excl)
338
+ best = {}
339
+ for m in matches:
340
+ best.setdefault(m["kind"], m)
341
+ emit({
342
+ "matches": matches,
343
+ "count": len(matches),
344
+ "kinds": sorted(best),
345
+ "backend": best.get("backend", {}),
346
+ "backend_profile": best.get("backend", {}).get("id", ""),
347
+ "backend_root": best.get("backend", {}).get("root", ""),
348
+ "frontend": best.get("frontend", {}),
349
+ "frontend_profile": best.get("frontend", {}).get("id", ""),
350
+ "frontend_root": best.get("frontend", {}).get("root", ""),
351
+ })
352
+
353
+
354
+ def suggestion_exclusions(repo, kind):
355
+ """Subtrees that belong to a DIFFERENT lane, and so cannot be evidence for this one.
356
+
357
+ `detect_profiles` already refuses to score a profile on files another lane owns;
358
+ without a caller supplying that set the rule never engaged on the path select-stack
359
+ actually uses, and the ownership rule was violated by the very step that establishes
360
+ it. A repo with an Angular UI in `web/` and a Spring service in `backend/svc/` offered
361
+ `node-express` rooted at `web/` as its BACKEND: `web/package.json` is anchored evidence
362
+ for the UI and merely relocated evidence for a Node service, and a shallower root broke
363
+ the tie the wrong way.
364
+
365
+ Two sources, both about a subtree that is already spoken for:
366
+ - a lane of another kind already declared in stacks.json, and
367
+ - a profile of another kind whose evidence is ANCHORED — the strength the ranking
368
+ itself treats as decisive.
369
+
370
+ The repo root is never excluded. It is the one directory a fullstack repo legitimately
371
+ shares — a root `package.json` next to a root `go.mod` — and excluding it would remove
372
+ every file from consideration rather than narrow the field.
373
+ """
374
+ out = set()
375
+ data = read_stacks(repo) or {}
376
+ for lane in (data.get("lanes") or []):
377
+ if lane.get("kind") and lane.get("kind") != kind:
378
+ r = norm_root(lane.get("root"))
379
+ if r and r != REPO_ROOT:
380
+ out.add(r)
381
+ for other in ("frontend", "backend"):
382
+ if other == kind:
383
+ continue
384
+ others = detect_profiles(repo, other)
385
+ for i, m in enumerate(others):
386
+ # The strongest match owns its root even when it had to relocate to find it:
387
+ # a Vite UI in `client/` is no less the owner of `client/` for the profile
388
+ # having shipped `web/`. Beyond the strongest, only anchored evidence claims a
389
+ # subtree, so a weak relocation cannot fence off a directory it merely touched.
390
+ if i and not m.get("anchored"):
391
+ continue
392
+ r = norm_root(m.get("root"))
393
+ if r and r != REPO_ROOT:
394
+ out.add(r)
395
+ return sorted(out)
396
+
397
+
398
+ def cmd_list(args):
399
+ """The menu for one kind, and — with --suggest — the repo's own answer to it.
400
+
401
+ Folding detection into the listing is deliberate. select-stack asks two
402
+ questions and each needs the same two things: the candidates, and what this
403
+ repository looks like. Two subcommands would walk the file tree twice and add
404
+ two steps to a workflow whose whole value is being short. One call returns the
405
+ options, the profile the files point at, and the directory the evidence sits
406
+ in — which is the source path the operator is about to be asked to confirm.
407
+
408
+ HOW A DETECTED STACK IS PROPOSED, and why it is not preselected. Detection
409
+ moves its match to the FRONT of the option list; it never fills `preselect`.
410
+ The two are answered at different strengths: `preselect` is the direct-argument
411
+ fast path and the engine treats it as the answer, ahead of a headless
412
+ `--select`, so filling it from a scan would let this repo's files silently
413
+ overrule an operator who named a stack on the command line. Leading the list
414
+ is the right strength for a proposal — it is what an interactive menu opens on
415
+ and what `--non-interactive` takes, and both are still choices anyone can
416
+ override. `(none)` stays last wherever it is offered.
417
+ """
418
+ profiles = load_profiles(args.repo, args.kind)
419
+ sentinel = none_sentinel(args.kind)
420
+
421
+ matches = detect_profiles(args.repo, args.kind,
422
+ suggestion_exclusions(args.repo, args.kind)) \
423
+ if args.suggest else []
424
+ detected = matches[0] if matches else {}
425
+ suggested_profile = detected.get("id", "")
426
+ suggested_root = detected.get("root") or detected.get("profile_root") or ""
427
+ evidence = ", ".join(detected.get("evidence", [])[:4])
428
+
429
+ ordered = sorted(profiles, key=lambda p: p["id"] != suggested_profile) \
430
+ if suggested_profile else profiles
431
+ options = [option_label(p) for p in ordered]
432
+ if args.include_none:
433
+ options.append(sentinel)
434
+
435
+ preselect = resolve_preselect(args.preselect, profiles, args.include_none, args.kind)
436
+ preselect_source = "argument" if preselect else ""
437
+
438
+ # Candidate SOURCE PATHS for the follow-up question, best first: where the
439
+ # evidence actually sits, then each profile's own default. Labelled with why
440
+ # each is offered, because the operator picking from this list is choosing the
441
+ # subtree a generator will be allowed to write into. Free text is always
442
+ # accepted — a repo that matches no profile still has a source path.
443
+ root_options, seen = [], set()
444
+
445
+ def offer(root, why):
446
+ r = norm_root(root)
447
+ if not r or r in seen:
448
+ return
449
+ seen.add(r)
450
+ root_options.append(ROOT_LABEL_SEP.join([r, why]))
451
+
452
+ if suggested_root:
453
+ offer(suggested_root, "detected here (%s)" % (suggested_profile or "evidence"))
454
+ for m in matches[1:]:
455
+ offer(m.get("root") or m.get("profile_root"), "detected here (%s)" % m["id"])
456
+ for p in profiles:
457
+ offer(p.get("root"), "%s default" % p["id"])
458
+
459
+ emit({
460
+ "kind": args.kind or "all",
461
+ "profiles": profiles,
462
+ "ids": [p["id"] for p in profiles],
463
+ "select_options": options,
464
+ "count": len(profiles),
465
+ "preselect": preselect,
466
+ "preselect_source": preselect_source,
467
+ "detected_profile": suggested_profile,
468
+ "detected_root": suggested_root,
469
+ "detected_evidence": evidence,
470
+ "detected_count": len(matches),
471
+ "root_options": root_options,
472
+ "none_sentinel": sentinel,
473
+ })
474
+
475
+
476
+ # ──────────────────────────────── stacks.json ──────────────────────────────
477
+
478
+ def read_stacks(repo):
479
+ path = os.path.join(repo, STACKS_REL)
480
+ if not os.path.isfile(path):
481
+ return None
482
+ try:
483
+ with open(path, "r", encoding="utf-8") as fh:
484
+ return json.load(fh)
485
+ except (ValueError, OSError):
486
+ return None
487
+
488
+
489
+ # A source-path option reads "<path> — <why it is offered>". The separator is two
490
+ # spaces and an em dash, which cannot occur in a path, so one field carries both the
491
+ # value and its justification and `declare` still receives a path.
492
+ ROOT_LABEL_SEP = " — "
493
+
494
+
495
+ def strip_root_label(value):
496
+ return (value or "").split(ROOT_LABEL_SEP, 1)[0].strip()
497
+
498
+
499
+ # The repository root as a source path. A single-module project — a Maven, Cargo, Go
500
+ # or Node repo whose manifest sits at the top — has no subdirectory to name, and the
501
+ # empty string cannot carry it: `verify` reads an empty root as "no source path
502
+ # declared". "./" is the one spelling that survives being normalised twice, which is
503
+ # what makes it usable as a STORED value rather than only as typed input.
504
+ REPO_ROOT = "./"
505
+
506
+
507
+ def norm_root(root):
508
+ """Canonicalise a source path, IDEMPOTENTLY.
509
+
510
+ Idempotence is a correctness requirement, not tidiness: `declare` normalises what it
511
+ writes and `verify` normalises what it reads, so a value that changes on the second
512
+ pass fails the lane the writer had just accepted. `.` normalising to `./` and `./`
513
+ then normalising to `""` did exactly that — every repo-root lane declared clean and
514
+ was rejected by the next gate that looked at it.
515
+ """
516
+ raw = strip_root_label(root).replace("\\", "/").strip()
517
+ r = raw
518
+ while r.startswith("./"):
519
+ r = r[2:]
520
+ if r == ".":
521
+ r = ""
522
+ r = r.lstrip("/") # leading separators only — a '.output/' root keeps its dot
523
+ if not r:
524
+ # An empty result means one of two different things: nothing was supplied (no
525
+ # root), or the repository root itself was. Only the second gets a value.
526
+ return REPO_ROOT if raw else ""
527
+ if not r.endswith("/"):
528
+ r += "/"
529
+ return r
530
+
531
+
532
+ def root_on_disk(repo, root):
533
+ """Does the lane's source path exist as a directory?
534
+
535
+ Reported everywhere a root is reported and enforced only by `verify`. A false
536
+ here is an ordinary state before any code is written and a hard error once a
537
+ phase needs to read or write the subtree.
538
+ """
539
+ r = norm_root(root)
540
+ return bool(r) and os.path.isdir(os.path.join(repo, r.rstrip("/")))
541
+
542
+
543
+ def lane_workdir(repo, lane):
544
+ """The directory a lane's commands run in.
545
+
546
+ `run_from: "root"` runs them in the lane's own source path; anything else — including
547
+ the key being ABSENT, which is the shape of every lane written before this key
548
+ existed — runs them at the repository root. That default is not laziness: a .NET
549
+ solution file, a go.mod or a package.json at the top is what `dotnet build`,
550
+ `go build ./...` and `npm run build` need to find, and those profiles declare a `root`
551
+ that is their SOURCE directory rather than their build directory. The two coincide for
552
+ a stack whose manifest lives inside the subtree — a Maven wrapper at
553
+ backend/orderservice/mvnw cannot be reached from the repo root at all.
554
+
555
+ Falls back to the repo root when the declared root is not on disk, so the phase
556
+ reports the command's own failure instead of a chdir error.
557
+ """
558
+ if (lane.get("run_from") or "repo") == "root":
559
+ r = norm_root(lane.get("root"))
560
+ cand = os.path.join(repo, r.rstrip("/")) if r else repo
561
+ if os.path.isdir(cand):
562
+ return cand
563
+ return repo
564
+
565
+
566
+ def lane_command(lane, phase):
567
+ """The command for one phase, with `{{root}}` standing for the lane's root.
568
+
569
+ A profile command sometimes has to NAME the source path — `dotnet run --project
570
+ src/`, `python -m compileall app`. Written literally, that is the profile's
571
+ default root frozen into the command, and it stays frozen when the lane declares
572
+ a different one: a service at `Services/Api/` runs the project at `src/`, or
573
+ fails looking for it. The placeholder lets the profile say "the root this lane
574
+ declares" and be right on every layout.
575
+
576
+ Expanded at RUN time, not at declare time, so editing `root` in stacks.json by
577
+ hand keeps the command consistent with it. A command with no placeholder is
578
+ returned untouched, so every literal command already on disk is unaffected.
579
+ """
580
+ cmd = (lane.get(phase) or "").strip()
581
+ if "{{root}}" not in cmd:
582
+ return cmd
583
+ r = norm_root(lane.get("root"))
584
+ # `./` is the repo root: a bare `./` reads as noise in a command line, and the
585
+ # only sensible substitution for "the root, which is here" is the cwd itself.
586
+ sub = "." if (r == REPO_ROOT or not r) else r.rstrip("/")
587
+ return cmd.replace("{{root}}", sub)
588
+
589
+
590
+ def roots_overlap(a, b):
591
+ """Roots own their subtree, so containment either way is an overlap.
592
+
593
+ Compared as '/'-terminated strings, which keeps 'src/' clear of 'src-gen/'
594
+ while still catching 'src/' against 'src/api/'.
595
+ """
596
+ a, b = norm_root(a), norm_root(b)
597
+ if not a or not b:
598
+ return False
599
+ # The repository root contains every other subtree, and prefix comparison cannot see
600
+ # that — "./" is not a prefix of "web/". Without this a lane rooted at the top and a
601
+ # lane rooted inside it both pass the writer's check and then own the same files.
602
+ if a == REPO_ROOT or b == REPO_ROOT:
603
+ return True
604
+ return a == b or a.startswith(b) or b.startswith(a)
605
+
606
+
607
+ def cmd_declare(args):
608
+ repo = args.repo
609
+ data = read_stacks(repo) or {"lanes": []}
610
+ lanes = data.get("lanes") or []
611
+
612
+ # "none" is a declaration too: it says this project has no lane of this kind.
613
+ # Routing it through the writer keeps re-runs correct — a UI lane declared in
614
+ # an earlier run is removed here rather than stranded.
615
+ profile_id = profile_id_from_label(args.profile)
616
+ if profile_id.lower() in ("none", "(none)"):
617
+ if not args.kind:
618
+ fail("lane-declare: --kind is required with --profile none")
619
+ removed = [l for l in lanes if l.get("kind") == args.kind]
620
+ lanes = [l for l in lanes if l.get("kind") != args.kind]
621
+ data["lanes"] = lanes
622
+ if args.kind == "frontend":
623
+ data.pop("prototype", None)
624
+ write_stacks(repo, data)
625
+ # Same key set as the declare branch below: callers bind these outputs
626
+ # without knowing which branch ran.
627
+ emit({
628
+ "declared": False,
629
+ "lane": {},
630
+ "lane_id": "",
631
+ "kind": args.kind,
632
+ "profile": "",
633
+ "root": "",
634
+ "source_path": "",
635
+ "root_exists": False,
636
+ "replaced": False,
637
+ "removed": [l.get("id") for l in removed],
638
+ "lanes": lanes,
639
+ "lane_count": len(lanes),
640
+ "prototype": data.get("prototype", {}),
641
+ "path": STACKS_REL,
642
+ "written": True,
643
+ })
644
+ return
645
+
646
+ # `detected` is a profile-shaped hole for a stack the kit ships no profile for.
647
+ # Brownfield needs it and greenfield never does: the eleven installed profiles
648
+ # describe how a NEW project would be started, while a legacy WebForms, JSP,
649
+ # ERB or Razor-MVC lane matches none of them and is no less real for it.
650
+ # Refusing to declare those would leave exactly the repos onboard exists for
651
+ # unable to record a lane. It is deliberately a distinct sentinel rather than a
652
+ # relaxation of the unknown-profile check, so a mistyped profile id still fails
653
+ # loudly instead of silently becoming a lane.
654
+ prof = None
655
+ if profile_id.lower() == "detected":
656
+ if not args.kind:
657
+ fail("lane-declare: --kind is required with --profile detected")
658
+ if not args.stack:
659
+ fail("lane-declare: --stack <label> is required with --profile detected "
660
+ "(what the code was found to be, e.g. 'asp.net-webforms')")
661
+ if not args.root:
662
+ fail("lane-declare: --root is required with --profile detected "
663
+ "(no profile to take a default root from)")
664
+ else:
665
+ profiles = {p["id"]: p for p in load_profiles(repo)}
666
+ prof = profiles.get(profile_id)
667
+ if prof is None:
668
+ fail("lane-declare: unknown stack profile '%s'. Known: %s"
669
+ % (profile_id, ", ".join(sorted(profiles)) or "(none installed)"))
670
+
671
+ kind = args.kind or (prof or {}).get("kind")
672
+ lane_id = args.id or DEFAULT_LANE_ID.get(kind, kind)
673
+ root = norm_root(args.root or (prof or {}).get("root") or "")
674
+ if not root:
675
+ fail("lane-declare: lane '%s' has no source path. A lane without a root owns "
676
+ "nothing, so nothing can be built, run or written into it. Pass "
677
+ "--source-path <dir> (the profile declares no default)." % lane_id)
678
+
679
+ # Greenfield declares a path that does not exist yet, so existence is checked
680
+ # only where a caller says the subtree must already be there — onboard, which
681
+ # is about to read the code out of it.
682
+ exists = root_on_disk(repo, root)
683
+ if args.require_existing_root and not exists:
684
+ fail("lane-declare: source path '%s' for lane '%s' does not exist under %s. "
685
+ "Point the lane at the directory that actually holds this lane's source, "
686
+ "or re-run /appweaver select-stack to correct it."
687
+ % (root, lane_id, repo))
688
+
689
+ lane = {
690
+ "id": lane_id,
691
+ "kind": kind,
692
+ "profile": prof["id"] if prof else "detected",
693
+ "root": root,
694
+ "build": args.build or (prof or {}).get("build", ""),
695
+ "test": args.test or (prof or {}).get("test", ""),
696
+ "run": args.run or (prof or {}).get("run", ""),
697
+ }
698
+ # SCHEMA MIGRATION, the one phase most stacks answer differently. A profile that
699
+ # declares `migrate` gets it recorded like any other phase command; the shipped
700
+ # profiles declare none, because there is no single right answer (Flyway, Liquibase,
701
+ # Alembic, Prisma, golang-migrate all coexist) and inventing one would be a guess
702
+ # dressed as a default. Absent, `run-migrate` keeps its .NET `dotnet ef` path and
703
+ # otherwise reports honestly that nothing ran — an empty key here changes nothing.
704
+ migrate = args.migrate or (prof or {}).get("migrate", "")
705
+ if migrate:
706
+ lane["migrate"] = migrate
707
+ # WHERE this lane's commands run — see lane_workdir. Recorded only when it is not
708
+ # the default, so a lane reads the same as it always did unless its stack needs the
709
+ # other answer.
710
+ run_from = getattr(args, "run_from", "") or (prof or {}).get("run_from", "")
711
+ if run_from == "root":
712
+ lane["run_from"] = "root"
713
+ # What the code was found to be, when no shipped profile names it.
714
+ if args.stack:
715
+ lane["stack"] = args.stack
716
+ # How this lane came to be declared. Absent means a person picked it — the
717
+ # greenfield path, and the historical shape of every lane written before now.
718
+ if args.origin and args.origin != "declared":
719
+ lane["origin"] = args.origin
720
+
721
+ # root IS ownership: two generators must never be able to write one file.
722
+ for other in lanes:
723
+ if other.get("id") == lane_id:
724
+ continue
725
+ if roots_overlap(root, other.get("root")):
726
+ fail("lane-declare: root '%s' for lane '%s' overlaps lane '%s' at '%s'. "
727
+ "Lane roots own their subtree and must be disjoint."
728
+ % (root, lane_id, other.get("id"), other.get("root")))
729
+
730
+ replaced = False
731
+ for i, other in enumerate(lanes):
732
+ if other.get("id") == lane_id:
733
+ lanes[i] = lane
734
+ replaced = True
735
+ break
736
+ if not replaced:
737
+ lanes.append(lane)
738
+
739
+ data["lanes"] = lanes
740
+
741
+ # The prototype block is NOT a lane: a root and a run command so an operator
742
+ # can open it, and nothing else. It is never built, tested, gated or deployed.
743
+ if kind == "frontend" and not args.no_prototype:
744
+ proto_run = args.prototype_run or (prof or {}).get("prototype_run", "")
745
+ if proto_run:
746
+ data["prototype"] = {
747
+ "root": norm_root(args.prototype_root or "prototype/"),
748
+ "run": proto_run,
749
+ }
750
+
751
+ write_stacks(repo, data)
752
+ emit({
753
+ "declared": True,
754
+ "lane": lane,
755
+ "lane_id": lane_id,
756
+ "kind": kind,
757
+ "profile": lane["profile"],
758
+ "stack": lane.get("stack", ""),
759
+ "origin": lane.get("origin", "declared"),
760
+ "root": root,
761
+ "source_path": root,
762
+ "root_exists": exists,
763
+ "replaced": replaced,
764
+ "removed": [],
765
+ "lanes": lanes,
766
+ "lane_count": len(lanes),
767
+ "prototype": data.get("prototype", {}),
768
+ "path": STACKS_REL,
769
+ "written": True,
770
+ })
771
+
772
+
773
+ def write_stacks(repo, data):
774
+ os.makedirs(os.path.join(repo, ".appweaver"), exist_ok=True)
775
+ path = os.path.join(repo, STACKS_REL)
776
+ with open(path, "w", encoding="utf-8") as fh:
777
+ json.dump(data, fh, indent=2)
778
+ fh.write("\n")
779
+
780
+
781
+ def profile_of(repo, lane):
782
+ """The stack profile a lane names, or {} when it names none the kit ships."""
783
+ if not lane:
784
+ return {}
785
+ pid = lane.get("profile", "")
786
+ if not pid or pid in ("detected", "none"):
787
+ return {}
788
+ return next((p for p in load_profiles(repo) if p.get("id") == pid), {})
789
+
790
+
791
+ def kind_block(repo, lanes, kind):
792
+ """Every field a caller needs about ONE kind, flattened and prefixed.
793
+
794
+ A workflow that wants the service source path binds `$.backend_source_path`
795
+ and is done — no `--kind` argument, no second call, and no branch on which
796
+ lane came back. Both kinds are always present in the payload, so the two-lane
797
+ steps (a gate that needs backend, a report that names both) resolve one script
798
+ call rather than two. Empty strings where the lane is not declared: the engine
799
+ compares variables as text, and an absent lane must read as absent.
800
+ """
801
+ lane = next((l for l in lanes if l.get("kind") == kind), {})
802
+ root = lane.get("root", "")
803
+ prof = profile_of(repo, lane)
804
+ return {
805
+ kind + "_declared": bool(lane),
806
+ kind + "_lane_id": lane.get("id", ""),
807
+ kind + "_profile": lane.get("profile", ""),
808
+ kind + "_stack": lane.get("stack", ""),
809
+ kind + "_origin": lane.get("origin", "declared") if lane else "",
810
+ # WHAT the stack IS, as opposed to where it lives. The three identity fields
811
+ # come from the profile at read time rather than from the lane record, so a
812
+ # lane declared before they existed answers them too — no re-declaration.
813
+ # A `detected` lane has no profile to read: its own `stack` label is the
814
+ # only name it has, and language/ecosystem stay empty rather than guessed.
815
+ kind + "_language": prof.get("language", ""),
816
+ kind + "_ecosystem": prof.get("ecosystem", ""),
817
+ kind + "_title": prof.get("title", "") or lane.get("stack", ""),
818
+ kind + "_root": root,
819
+ kind + "_source_path": root,
820
+ kind + "_root_exists": root_on_disk(repo, root),
821
+ kind + "_build": lane_command(lane, "build"),
822
+ kind + "_test": lane_command(lane, "test"),
823
+ kind + "_run": lane_command(lane, "run"),
824
+ kind + "_migrate": lane_command(lane, "migrate") or prof.get("migrate", ""),
825
+ }
826
+
827
+
828
+ def cmd_resolve(args):
829
+ data = read_stacks(args.repo)
830
+
831
+ # Absence is a valid state for READING, never a failure: `resolve` reports what
832
+ # is there and lets the caller decide. It is `verify` that turns an absent or
833
+ # unusable declaration into a stop, and only in the phases that need one.
834
+ if data is None:
835
+ payload = {
836
+ "declared": False, "exists": False, "lanes": [], "lane_count": 0,
837
+ "found": False, "lane": {}, "kinds": [], "prototype": {},
838
+ "lane_id": "", "root": "", "source_path": "", "root_exists": False,
839
+ "build": "", "test": "", "run": "", "migrate": "",
840
+ "language": "", "ecosystem": "",
841
+ "path": STACKS_REL,
842
+ }
843
+ payload.update(kind_block(args.repo, [], "backend"))
844
+ payload.update(kind_block(args.repo, [], "frontend"))
845
+ emit(payload)
846
+ return
847
+
848
+ lanes = data.get("lanes") or []
849
+ match = {}
850
+ if args.id:
851
+ match = next((l for l in lanes if l.get("id") == args.id), {})
852
+ elif args.kind:
853
+ match = next((l for l in lanes if l.get("kind") == args.kind), {})
854
+
855
+ # `declared` is about LANES and `exists` about the file — they part company on
856
+ # the shipped-but-unfilled seed, which is on disk and declares nothing.
857
+ payload = {
858
+ "declared": bool(lanes),
859
+ "exists": True,
860
+ "lanes": lanes,
861
+ "lane_count": len(lanes),
862
+ "kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
863
+ "found": bool(match),
864
+ "lane": match,
865
+ "lane_id": match.get("id", ""),
866
+ "root": match.get("root", ""),
867
+ "source_path": match.get("root", ""),
868
+ "root_exists": root_on_disk(args.repo, match.get("root", "")),
869
+ "build": lane_command(match, "build"),
870
+ "test": lane_command(match, "test"),
871
+ "run": lane_command(match, "run"),
872
+ "migrate": lane_command(match, "migrate") or profile_of(args.repo, match).get("migrate", ""),
873
+ "language": profile_of(args.repo, match).get("language", ""),
874
+ "ecosystem": profile_of(args.repo, match).get("ecosystem", ""),
875
+ "prototype": data.get("prototype", {}),
876
+ "path": STACKS_REL,
877
+ }
878
+ payload.update(kind_block(args.repo, lanes, "backend"))
879
+ payload.update(kind_block(args.repo, lanes, "frontend"))
880
+ emit(payload)
881
+
882
+
883
+ # ──────────────────────────────── verify ───────────────────────────────────
884
+ # The gate. `resolve` reports; this decides whether the declaration is USABLE for
885
+ # the phase about to run, and it is the only place a lane model becomes fatal.
886
+ #
887
+ # TWO CLASSES OF FAILURE, and the distinction is the whole design:
888
+ #
889
+ # MISSING — the phase needs a lane of this kind and none is declared. Reported
890
+ # only for the kinds a caller asks for with --require, because "no frontend
891
+ # lane" is the correct and common state and must never fail a backend build.
892
+ #
893
+ # BROKEN — a lane IS declared and its source path is empty or is not a directory.
894
+ # Checked for EVERY declared lane whether or not the caller asked about it. A
895
+ # stack named against a path that is not there is a mistake in every phase, and
896
+ # letting it through means discovering it as an empty build or a generator
897
+ # writing into a directory it just created by accident.
898
+ #
899
+ # --allow-missing-root turns the on-disk half off. That is the greenfield state:
900
+ # select-stack declares `src/` before any code exists, and the path becomes real
901
+ # when implement writes into it. Every phase that READS the subtree — implement,
902
+ # build, run, onboard — leaves the check on.
903
+
904
+ KIND_PHASES = {
905
+ "backend": "implement / implement-delta / build / run",
906
+ "frontend": "ui-implement / ui-implement-delta",
907
+ }
908
+
909
+
910
+ def cmd_verify(args):
911
+ repo = args.repo
912
+ data = read_stacks(repo)
913
+ lanes = (data or {}).get("lanes") or []
914
+ required = [k for k in (args.require or []) if k in ("backend", "frontend")]
915
+ issues = []
916
+
917
+ # An empty file and an absent one are the SAME state. The kit ships
918
+ # stacks.json holding `{"lanes": []}` so a project can fill it in by hand, so
919
+ # the file being there says nothing about whether a lane was declared — only
920
+ # its contents do. Both cases report one issue naming which of the two it is.
921
+ if not lanes:
922
+ issues.append(
923
+ "no lane is declared: %s %s. A project with neither a service nor a UI "
924
+ "lane has nothing to build, govern or generate. Run "
925
+ "`/appweaver select-stack` to declare each lane's stack and source "
926
+ "path, or fill the file in by hand (the schema is in "
927
+ ".appweaver/STACK-PROFILES.md)."
928
+ % (STACKS_REL, "does not exist" if data is None else "declares no lanes"))
929
+ else:
930
+ for kind in required:
931
+ if not any(l.get("kind") == kind for l in lanes):
932
+ issues.append(
933
+ "no %s lane is declared in %s, and %s needs one. Run "
934
+ "`/appweaver select-stack` and choose a %s stack."
935
+ % (kind, STACKS_REL, args.command or KIND_PHASES.get(kind, "this phase"), kind))
936
+ # BROKEN is checked across every declared lane, not just the required ones.
937
+ for lane in lanes:
938
+ kind, lane_id = lane.get("kind", "?"), lane.get("id", "?")
939
+ root = lane.get("root", "")
940
+ if not root:
941
+ issues.append(
942
+ "lane '%s' (%s, stack %s) declares no source path. A lane owns a "
943
+ "subtree; without one nothing can be built, run or written into it. "
944
+ "Re-run `/appweaver select-stack` and give it a source path."
945
+ % (lane_id, kind, lane.get("profile", "?")))
946
+ elif not args.allow_missing_root and not root_on_disk(repo, root):
947
+ issues.append(
948
+ "lane '%s' (%s, stack %s) declares source path '%s', which is not a "
949
+ "directory in this repository. Re-run `/appweaver select-stack` and "
950
+ "point the lane at the directory that holds its source."
951
+ % (lane_id, kind, lane.get("profile", "?"), root))
952
+ # `declare` refuses an overlap, so one can only arrive here through a
953
+ # hand-edited file — a supported way to fill this in, and the one path that
954
+ # does not pass the writer. The invariant is worth a second check anywhere:
955
+ # two lanes sharing a subtree means two generators can write one file.
956
+ for i, first in enumerate(lanes):
957
+ for second in lanes[i + 1:]:
958
+ if roots_overlap(first.get("root"), second.get("root")):
959
+ issues.append(
960
+ "lanes '%s' and '%s' claim overlapping source paths '%s' and '%s'. "
961
+ "Lane roots own their subtree and must be disjoint, or two "
962
+ "generators can write the same file. Give each lane a source path "
963
+ "outside the other's."
964
+ % (first.get("id", "?"), second.get("id", "?"),
965
+ first.get("root", ""), second.get("root", "")))
966
+
967
+ payload = {
968
+ "ok": not issues,
969
+ "declared": bool(lanes),
970
+ "required": required,
971
+ "lanes": lanes,
972
+ "lane_count": len(lanes),
973
+ "kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
974
+ "root_check": not args.allow_missing_root,
975
+ "issues": issues,
976
+ "issue_count": len(issues),
977
+ # No lanes always carries an issue now, so a clean run always has lanes to name.
978
+ "message": " ".join(issues) if issues else
979
+ ("lane model ok: " + ", ".join(
980
+ "%s (%s) at %s" % (l.get("id", "?"), l.get("profile", "?"), l.get("root", "?"))
981
+ for l in lanes)),
982
+ "path": STACKS_REL,
983
+ }
984
+ payload.update(kind_block(repo, lanes, "backend"))
985
+ payload.update(kind_block(repo, lanes, "frontend"))
986
+ emit(payload)
987
+ # Exit 0 even when the check fails: the workflow gate owns the stop, so the
988
+ # message and every flattened field still reach it. A non-zero exit here would
989
+ # make `on_error: continue` the only way to read the reason a run was refused.
990
+
991
+
992
+ # ────────────────────────────── run-phase ──────────────────────────────────
993
+ # Execute a phase's project-supplied command for EVERY declared lane.
994
+ #
995
+ # This is the payoff of declaring a source path. Without a declaration, `build`
996
+ # sniffs: it looks for a .sln, then for a package.json, and takes the first hit.
997
+ # On a single-lane repo that is right. On a two-lane repo it is wrong in the worst
998
+ # way available — it builds the backend, reports green, and never mentions that the
999
+ # frontend was not compiled at all. Iterating the declared lanes is what turns "one
1000
+ # build" from a guess into a fact, and a lane whose command fails fails the phase.
1001
+ #
1002
+ # The engine stays stack-ignorant: these are strings out of stacks.json, run in the
1003
+ # repo root, and nothing here knows what a compiler is. A lane with no command for
1004
+ # the phase is skipped rather than failed — a profile is allowed to have no test
1005
+ # command, and a lane the project never wired up should not fail a build.
1006
+
1007
+ LOG_TAIL = 1400
1008
+
1009
+
1010
+ def cmd_run_phase(args):
1011
+ import subprocess
1012
+
1013
+ repo = args.repo
1014
+ data = read_stacks(repo)
1015
+ declared = (data or {}).get("lanes") or []
1016
+ lanes = declared
1017
+ if args.kind:
1018
+ lanes = [l for l in lanes if l.get("kind") == args.kind]
1019
+
1020
+ results, tails, cmds = [], [], []
1021
+ ok, ran = True, False
1022
+ for lane in lanes:
1023
+ cmd = lane_command(lane, args.phase)
1024
+ lane_id, root = lane.get("id", "?"), lane.get("root", "")
1025
+ if not cmd:
1026
+ results.append({"lane": lane_id, "root": root, "cmd": "",
1027
+ "ran": False, "ok": True, "skipped": "no %s command" % args.phase})
1028
+ continue
1029
+ # The source path has to be there before a command is run against it.
1030
+ # Reported per lane rather than raised, so a two-lane run still reports
1031
+ # what the other lane did.
1032
+ if not root_on_disk(repo, root):
1033
+ ok = False
1034
+ results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": False, "ok": False,
1035
+ "error": "source path '%s' is not a directory" % root})
1036
+ tails.append("[%s] source path '%s' is not a directory" % (lane_id, root))
1037
+ continue
1038
+ ran = True
1039
+ cmds.append(cmd)
1040
+ workdir = lane_workdir(repo, lane)
1041
+ proc = subprocess.run(cmd, shell=True, cwd=workdir, stdout=subprocess.PIPE,
1042
+ stderr=subprocess.STDOUT)
1043
+ out = proc.stdout.decode("utf-8", "replace")
1044
+ lane_ok = proc.returncode == 0
1045
+ ok = ok and lane_ok
1046
+ results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": True,
1047
+ "ok": lane_ok, "exit_code": proc.returncode,
1048
+ "cwd": os.path.relpath(workdir, repo).replace("\\", "/")})
1049
+ if not lane_ok or args.phase == "run":
1050
+ tails.append("[%s] %s" % (lane_id, out[-LOG_TAIL:]))
1051
+
1052
+ emit({
1053
+ "ok": ok,
1054
+ # Every lane the file declares, not the kind-filtered slice this phase ran.
1055
+ "declared": bool(declared),
1056
+ "phase": args.phase,
1057
+ "tool": "lanes",
1058
+ "ran": ran,
1059
+ "lane_count": len(lanes),
1060
+ "lanes_run": [r["lane"] for r in results if r["ran"]],
1061
+ "lanes_skipped": [r["lane"] for r in results if not r["ran"]],
1062
+ "results": results,
1063
+ "cmd": " && ".join(cmds),
1064
+ "log_tail": "\n".join(tails)[-LOG_TAIL:],
1065
+ })
1066
+
1067
+
1068
+ # ──────────────────────────────── probe ────────────────────────────────────
1069
+
1070
+ def profile_terms(prof):
1071
+ """The words that unambiguously NAME this stack: its id and the id's parts.
1072
+
1073
+ Title and language are deliberately excluded. "TypeScript", "Next" and "Web"
1074
+ all occur in ordinary prose about a backend, and the two errors are not
1075
+ symmetric: a false positive authors a governed file the project never asked
1076
+ for, while a false negative only produces a warning that names the fix.
1077
+ """
1078
+ pid = (prof.get("id") or "").lower()
1079
+ terms = {pid} if pid else set()
1080
+ terms.update(p for p in re.split(r"[-_]+", pid) if len(p) >= 3)
1081
+ return terms
1082
+
1083
+
1084
+ def hint_tokens(text):
1085
+ """Tokenise free prose into stack-name candidates.
1086
+
1087
+ Keeps '-' and '.' inside a token so "react-vite" and "next.js" survive
1088
+ whole, and adds a punctuation-stripped form of each so "next.js" also
1089
+ reaches the profile id "nextjs".
1090
+ """
1091
+ out = set()
1092
+ for raw in re.split(r"[^A-Za-z0-9+#._-]+", (text or "").lower()):
1093
+ tok = raw.strip("._-+")
1094
+ if not tok:
1095
+ continue
1096
+ out.add(tok)
1097
+ out.add(re.sub(r"[._]", "", tok))
1098
+ return out
1099
+
1100
+
1101
+ def cmd_probe(args):
1102
+ """Evidence that a lane of this kind exists, from the two FILE signals.
1103
+
1104
+ This answers only what stacks.json and the shipped profiles can answer. The
1105
+ authoritative signal — whether the active pack maps `## /aw-spec-<command>`
1106
+ for the lane's phases — belongs to `resolve-tech`, and callers OR it in.
1107
+ Re-parsing the rule map here would be a third implementation of a contract
1108
+ that already has two, so this deliberately stops short of it.
1109
+
1110
+ Read-only and never fatal: an absent stacks.json, an absent profile
1111
+ directory and an empty hint are all ordinary answers, not errors.
1112
+ """
1113
+ lanes = (read_stacks(args.repo) or {}).get("lanes") or []
1114
+ lane = next((l for l in lanes if l.get("kind") == args.kind), {})
1115
+
1116
+ tokens = hint_tokens(args.hint)
1117
+ hint_profile, hint_terms = "", []
1118
+ for prof in load_profiles(args.repo, args.kind):
1119
+ hit = sorted(profile_terms(prof) & tokens)
1120
+ if hit:
1121
+ hint_profile, hint_terms = prof["id"], hit
1122
+ break
1123
+
1124
+ signals = []
1125
+ if lane:
1126
+ signals.append("declaration")
1127
+ if hint_profile:
1128
+ signals.append("hint")
1129
+
1130
+ parts = []
1131
+ if lane:
1132
+ parts.append("lane '%s' (%s) declared in %s"
1133
+ % (lane.get("id", ""), lane.get("profile", ""), STACKS_REL))
1134
+ if hint_profile:
1135
+ parts.append("the request names %s (matched on %s)"
1136
+ % (hint_profile, ", ".join(hint_terms)))
1137
+
1138
+ emit({
1139
+ "kind": args.kind,
1140
+ "declared": bool(lane),
1141
+ "lane": lane,
1142
+ "declared_lane_id": lane.get("id", ""),
1143
+ "declared_profile": lane.get("profile", ""),
1144
+ "declared_root": lane.get("root", ""),
1145
+ "hint_profile": hint_profile,
1146
+ "hint_terms": hint_terms,
1147
+ "signals": signals,
1148
+ "any": bool(signals),
1149
+ "evidence": "; ".join(parts),
1150
+ })
1151
+
1152
+
1153
+ def main():
1154
+ ap = argparse.ArgumentParser(prog="aw-stack", description=__doc__)
1155
+ ap.add_argument("--repo", default=".", help="repository root (callers pass their own resolver's answer)")
1156
+ sub = ap.add_subparsers(dest="cmd")
1157
+
1158
+ p = sub.add_parser("list", help="enumerate the installed stack profiles")
1159
+ p.add_argument("--kind", choices=["backend", "frontend", "all"], default="all")
1160
+ p.add_argument("--include-none", action="store_true",
1161
+ help="append the '(none)' sentinel — for an optional lane")
1162
+ p.add_argument("--preselect", default="",
1163
+ help="direct-argument text; matched against this kind's profile ids")
1164
+ p.add_argument("--suggest", action="store_true",
1165
+ help="also run detection over this repo, so the menu opens on the "
1166
+ "stack the files point at and root_options leads with the "
1167
+ "directory the evidence sits in")
1168
+ p.set_defaults(func=cmd_list)
1169
+
1170
+ p = sub.add_parser("detect", help="match the repo's files against the installed profiles")
1171
+ p.add_argument("--kind", choices=["backend", "frontend", "all"], default="all")
1172
+ p.add_argument("--exclude-root", dest="exclude_root", action="append", default=[],
1173
+ help="a subtree another lane owns; repeatable. Backend candidates find no "
1174
+ "evidence there, so a service profile cannot relocate onto the UI's files")
1175
+ p.set_defaults(func=cmd_detect)
1176
+
1177
+ p = sub.add_parser("declare", help="upsert a lane into stacks.json (sole writer)")
1178
+ p.add_argument("--profile", required=True,
1179
+ help="profile id, a select_options label, 'none', or 'detected' "
1180
+ "(a stack the kit ships no profile for — needs --stack and --root)")
1181
+ p.add_argument("--kind", choices=["backend", "frontend"])
1182
+ p.add_argument("--id", help="lane id (default: service for backend, web for frontend)")
1183
+ p.add_argument("--root", "--source-path", dest="root",
1184
+ help="the lane's SOURCE PATH — the subtree it owns, overriding the "
1185
+ "profile's default root. The two spellings are one field")
1186
+ p.add_argument("--run-from", dest="run_from", choices=["repo", "root"],
1187
+ help="where this lane's build/test/run commands execute: 'repo' (default) "
1188
+ "at the repository root, which is what a top-level solution file, "
1189
+ "go.mod or package.json needs; 'root' inside the lane's own source "
1190
+ "path, which is the only way to reach a manifest nested in the "
1191
+ "subtree (a Maven wrapper at backend/<app>/mvnw). Defaults to the "
1192
+ "profile's own setting")
1193
+ p.add_argument("--require-existing-root", dest="require_existing_root", action="store_true",
1194
+ help="refuse a source path that is not a directory. For a caller about "
1195
+ "to READ the subtree (onboard); greenfield declares a path that "
1196
+ "implement has not created yet, and leaves this off")
1197
+ p.add_argument("--stack", default="",
1198
+ help="what the code was found to be, when no shipped profile names it")
1199
+ p.add_argument("--origin", default="declared", choices=["declared", "reverse-engineered"],
1200
+ help="how the lane came to be declared; recorded only when not 'declared'")
1201
+ p.add_argument("--build")
1202
+ p.add_argument("--test")
1203
+ p.add_argument("--run")
1204
+ p.add_argument("--migrate", default="",
1205
+ help="schema-migration command for this lane (overrides the profile's)")
1206
+ p.add_argument("--prototype-root", dest="prototype_root")
1207
+ p.add_argument("--prototype-run", dest="prototype_run")
1208
+ p.add_argument("--no-prototype", dest="no_prototype", action="store_true",
1209
+ help="do not write the prototype block. Brownfield: the prototype phase "
1210
+ "does not run on adopted features, and the profile's default would "
1211
+ "point at a directory the repo does not have")
1212
+ p.set_defaults(func=cmd_declare)
1213
+
1214
+ p = sub.add_parser("resolve", help="read stacks.json")
1215
+ p.add_argument("--kind", choices=["backend", "frontend"])
1216
+ p.add_argument("--id")
1217
+ p.set_defaults(func=cmd_resolve)
1218
+
1219
+ p = sub.add_parser("verify", help="is the declaration usable for the phase about to run")
1220
+ p.add_argument("--require", action="append", default=[], choices=["backend", "frontend"],
1221
+ help="a lane kind this phase cannot run without; repeatable. Omit to "
1222
+ "check only that whatever IS declared is sound")
1223
+ p.add_argument("--allow-missing-root", dest="allow_missing_root", action="store_true",
1224
+ help="do not require the source paths to exist on disk yet — the "
1225
+ "greenfield state, where select-stack declares a path implement "
1226
+ "will create")
1227
+ p.add_argument("--command", default="",
1228
+ help="the phase being gated, quoted back in the failure message")
1229
+ p.set_defaults(func=cmd_verify)
1230
+
1231
+ p = sub.add_parser("run-phase", help="run a phase's command for every declared lane")
1232
+ p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate"],
1233
+ help="which project-supplied command to run")
1234
+ p.add_argument("--kind", choices=["backend", "frontend"],
1235
+ help="restrict to one lane kind (default: every declared lane)")
1236
+ p.set_defaults(func=cmd_run_phase)
1237
+
1238
+ p = sub.add_parser("probe", help="evidence of a lane of this kind (file signals only)")
1239
+ p.add_argument("--kind", choices=["backend", "frontend"], default="frontend")
1240
+ p.add_argument("--hint", default="", help="free prose to scan for a stack name")
1241
+ p.set_defaults(func=cmd_probe)
1242
+
1243
+ args = ap.parse_args()
1244
+ if not getattr(args, "func", None):
1245
+ ap.print_help()
1246
+ sys.exit(0)
1247
+ args.func(args)
1248
+
1249
+
1250
+ if __name__ == "__main__":
1251
+ main()