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,2626 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # aw-spec-actions.sh — common action dispatcher for the aw-spec orchestrator.
4
+ #
5
+ # ONE shared script for every integration (.claude / .agents / .kilocode). The
6
+ # orchestrator workflows reach these subcommands through `execute_script` steps.
7
+ # Each subcommand performs deterministic, governance-safe work and prints a single
8
+ # JSON object on stdout. NONE of these actions author spec/code content — content
9
+ # generation is the LLM's job, in the workflow's agent_* steps.
10
+ #
11
+ # Usage:
12
+ # aw-spec-actions.sh <subcommand> [options]
13
+ #
14
+ # Subcommands:
15
+ # resolve-domain --command <name> -> {domain, rulemap_exists, candidates[]}
16
+ # resolve-tech --command <name> -> {domain, techrulemap_exists, candidates[]}
17
+ # scaffold --feature-dir <path> [--allow-existing]
18
+ # -> {feature_dir, spec_file, template, created, spec_created}
19
+ # resolve-hooks --phase <before_specify|after_specify>
20
+ # -> {phase, hooks[]}
21
+ # create-checklist --feature-dir <path> [--feature-name <name>]
22
+ # -> {checklist_file, created}
23
+ # list-domains -> {domains[]}
24
+ # set-domain --domain <name> -> {domain, map_path, map_exists, written}
25
+ # stack-list [--kind backend|frontend|all] [--preselect <text>] [--include-none] [--suggest]
26
+ # -> {profiles[], ids[], select_options[], count, preselect,
27
+ # detected_profile, detected_root, root_options[], none_sentinel}
28
+ # (--suggest also detects, so the menu opens on what the repo IS)
29
+ # stack-detect [--kind <k>] [--exclude-root <p>]… -> {matches[], backend, frontend, count}
30
+ # (matches the repo's OWN files against the profiles; writes nothing)
31
+ # lane-declare --profile <id|label|none|detected> [--kind <k>] [--id <lane>] [--source-path|--root <p>]
32
+ # [--stack <label>] [--origin declared|reverse-engineered] [--require-existing-root]
33
+ # [--build <c>] [--test <c>] [--run <c>] [--prototype-root <p>] [--prototype-run <c>] [--no-prototype]
34
+ # -> {declared, lane, lanes[], lane_count, source_path, root_exists, prototype, written}
35
+ # (SOLE WRITER of .appweaver/stacks.json; callers = select-stack, onboard)
36
+ # lane-resolve [--kind <k>] [--id <lane>] -> {declared, lanes[], found, lane, root, source_path, build, test, run,
37
+ # backend_*/frontend_* (declared, root, source_path, root_exists, build, test, run), prototype}
38
+ # (absence is a valid state: no stacks.json -> declared:false, exit 0)
39
+ # lane-verify [--require backend|frontend]… [--allow-missing-root] [--command <phase>]
40
+ # -> {ok, declared, issues[], issue_count, message, backend_*/frontend_*}
41
+ # (the GATE: is the declaration usable for the phase about to run.
42
+ # Always exits 0 — the workflow's verify_context owns the stop)
43
+ # lane-probe [--kind <k>] [--hint <text>] -> {declared, declared_profile, hint_profile, hint_terms[], signals[], any, evidence}
44
+ # (the FILE signals for "does this lane exist"; resolve-tech carries the rule signal)
45
+ # list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ OKF aggregates)
46
+ # set-feature --feature <name> -> {feature, feature_dir, spec_file, existed, created, spec_exists}
47
+ # version-read [--feature-dir <path>] -> {feature, version, active_change, exists}
48
+ # version-init [--feature-dir <path>] -> {feature, version, active_change, created} (create active_version.json v1.0)
49
+ # feature-paths -> {feature, feature_dir, spec_file, design_file, spec_exists, design_exists}
50
+ # checklist-read --file <path> -> {exists, total, checked, items[]}
51
+ # checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
52
+ # detect-project -> {scaffolded, markers[]}
53
+ # foundation-list [--args '<F> <A> [out]'] -> {foundations[], count, preselect_name, preselect_app, out_dir}
54
+ # foundation-locate --name <F> --app <A> [--out <d>]
55
+ # -> {ok, reason, name, app, out_dir, pack_file, unpacker, runner, sentinel, file_count, available[]}
56
+ # foundation-unpack --name <F> --app <A> --out <d> [--dry-run] [--force <yes|no>] [--reference]
57
+ # -> {ok, mode, written, has_collisions, stdout}
58
+ # foundation-verify --out <d> --sentinel <s> -> {clean, residue[]}
59
+ # list-components -> {components[]} (foundations/dotnet/components/* with integration.md)
60
+ # find-host-project -> {found, program_cs, project_dir, csproj}
61
+ # setup-constitution [--ui] -> {constitution_technical_file, constitution_domain_file, constitution_ui_file, technical_created, domain_created, ui_created}
62
+ # compose-constitution -> {composed_file, ok, domain_included, ui_included, numbering_ok, numbering_checked, issues, technical_last, domain_first}
63
+ # constitution-verify --file <path> [--template <name>]
64
+ # -> {ok, exists, issues, placeholders, placeholder_check, version_body, version_report, version_match, bad_dates}
65
+ # agent-context-update [--plan <path>] -> {ok, ran, message}
66
+ #
67
+ # design-scaffold --feature-dir <path> [--template <name>]
68
+ # -> {feature_dir, design_doc, created, override_active, template, template_source}
69
+ # (design-extract / design-reconcile / design-paths are RETIRED — the path-tagged
70
+ # four-document model is gone. "Which files is this slice made of" is now an OKF
71
+ # query: aw-xref concept-paths / concept-upsert --merge.)
72
+ # build-compile [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
73
+ # test-run [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
74
+ # run-migrate [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
75
+ # deploy-dispatch -> {ok, configured, ran, cmd, log_tail}
76
+ # cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>]
77
+ # -> {cr_dir, delta_spec, delta_design, created}
78
+ # cr-archive --cr-id <id> [--feature-dir <path>]
79
+ # -> {ok, archived_dir, version, previous}
80
+ #
81
+ # All subcommands accept --help.
82
+
83
+ set -e
84
+
85
+ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
86
+ # shellcheck source=common.sh
87
+ source "$SCRIPT_DIR/common.sh"
88
+
89
+ # ─────────────────────────── small JSON helpers ────────────────────────────
90
+ # Emit a JSON array of strings from remaining args.
91
+ json_str_array() {
92
+ local out="[" first=1 s
93
+ for s in "$@"; do
94
+ [ $first -eq 1 ] && first=0 || out+=","
95
+ out+="\"$(json_escape "$s")\""
96
+ done
97
+ out+="]"
98
+ printf '%s' "$out"
99
+ }
100
+
101
+ # ─────────────────────────────── resolve-domain ────────────────────────────
102
+ cmd_resolve_domain() {
103
+ local command_name="aw-spec-specify"
104
+ while [ $# -gt 0 ]; do
105
+ case "$1" in
106
+ --command) command_name="$2"; shift 2 ;;
107
+ --help|-h) echo "resolve-domain --command <name>"; return 0 ;;
108
+ *) echo "resolve-domain: unknown arg '$1'" >&2; return 1 ;;
109
+ esac
110
+ done
111
+
112
+ local repo_root; repo_root=$(get_repo_root)
113
+ local domain_json="$repo_root/.appweaver/domain.json"
114
+ local domain=""
115
+ if [ -f "$domain_json" ]; then
116
+ if command -v jq >/dev/null 2>&1; then
117
+ domain=$(jq -r '.domain // ""' "$domain_json" 2>/dev/null || echo "")
118
+ else
119
+ domain=$( { grep -E '"domain"[[:space:]]*:' "$domain_json" 2>/dev/null || true; } \
120
+ | head -n1 | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
121
+ fi
122
+ fi
123
+
124
+ local rulemap="$repo_root/guardrails/projects/$domain/domain-rule-bindings.md"
125
+ local rulemap_exists=false
126
+ [ -n "$domain" ] && [ -f "$rulemap" ] && rulemap_exists=true
127
+
128
+ # Collect bullet paths under "## /<command>" in the domain's domain-rule-bindings.md.
129
+ local -a candidates=()
130
+ if [ "$rulemap_exists" = true ]; then
131
+ # awk: capture "- path" bullets between the target "## <heading>" and the next "## "
132
+ while IFS= read -r line; do
133
+ [ -n "$line" ] && candidates+=("$line")
134
+ done < <(awk -v h="## /$command_name" '
135
+ $0==h {grab=1; next}
136
+ /^## / {grab=0}
137
+ grab && /^[[:space:]]*-[[:space:]]+/ {
138
+ sub(/^[[:space:]]*-[[:space:]]+/,""); sub(/[[:space:]]+$/,""); print
139
+ }
140
+ ' "$rulemap")
141
+ fi
142
+
143
+ printf '{"domain":"%s","rulemap_exists":%s,"candidates":%s}\n' \
144
+ "$(json_escape "$domain")" "$rulemap_exists" "$(json_str_array "${candidates[@]}")"
145
+ }
146
+
147
+ # ──────────────────────────────── resolve-tech ─────────────────────────────
148
+ # Technical rule-file mapping — parallel to resolve-domain, but reads
149
+ # guardrails/projects/<project>/tech-rule-bindings.md. The TECHNICAL rule files live in the
150
+ # shared per-stack corpora at guardrails/technology/<stack>/ (rules) and
151
+ # guardrails/technology/<stack>/examples/ (the worked sample for a rule file), and are
152
+ # configured per-command in this map, so they can be tuned per project WITHOUT editing the
153
+ # workflows and a second project can map the same corpus. The technical CONSTITUTION
154
+ # (constitution.technical.md) is NOT mapped — it is still referred to directly by
155
+ # each consumer, as-is.
156
+ #
157
+ # A bullet may name its stack with a PLACEHOLDER — {{stack}} / {{backend_stack}} /
158
+ # {{frontend_stack}} / {{backend_ecosystem}} / {{frontend_ecosystem}} — resolved from
159
+ # the lanes in .appweaver/stacks.json. That is what lets one map serve a Spring repo and
160
+ # a .NET repo without an edit. A bullet that instead spells a stack directory out is kept
161
+ # and USED (it may be a deliberate cross-stack reference) but reported through
162
+ # stack_mismatch when it is not a stack this repo declares, because a map pinned to the
163
+ # wrong corpus is how a Java project ends up being handed C# rules.
164
+ cmd_resolve_tech() {
165
+ local command_name="aw-spec-design"
166
+ while [ $# -gt 0 ]; do
167
+ case "$1" in
168
+ --command) command_name="$2"; shift 2 ;;
169
+ --help|-h) echo "resolve-tech --command <name>"; return 0 ;;
170
+ *) echo "resolve-tech: unknown arg '$1'" >&2; return 1 ;;
171
+ esac
172
+ done
173
+
174
+ local repo_root; repo_root=$(get_repo_root)
175
+ local domain_json="$repo_root/.appweaver/domain.json"
176
+ local domain=""
177
+ if [ -f "$domain_json" ]; then
178
+ if command -v jq >/dev/null 2>&1; then
179
+ domain=$(jq -r '.domain // ""' "$domain_json" 2>/dev/null || echo "")
180
+ else
181
+ domain=$( { grep -E '"domain"[[:space:]]*:' "$domain_json" 2>/dev/null || true; } \
182
+ | head -n1 | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
183
+ fi
184
+ fi
185
+
186
+ # The declared stacks, so one map can be written for any of them.
187
+ local be_prof fe_prof be_eco fe_eco
188
+ be_prof=$(_aw_lane_field backend profile); fe_prof=$(_aw_lane_field frontend profile)
189
+ be_eco=$(_aw_lane_field backend ecosystem); fe_eco=$(_aw_lane_field frontend ecosystem)
190
+
191
+ AW_REPO="$repo_root" AW_DOMAIN="$domain" AW_COMMAND="$command_name" \
192
+ AW_BE_PROFILE="$be_prof" AW_FE_PROFILE="$fe_prof" \
193
+ AW_BE_ECO="$be_eco" AW_FE_ECO="$fe_eco" python3 - <<'RESOLVE_TECH_PY'
194
+ import json, os, re
195
+
196
+ repo = os.environ["AW_REPO"]
197
+ domain = os.environ.get("AW_DOMAIN", "")
198
+ command = os.environ.get("AW_COMMAND", "")
199
+
200
+ techmap = os.path.join(repo, "guardrails", "projects", domain, "tech-rule-bindings.md")
201
+ exists = bool(domain) and os.path.isfile(techmap)
202
+
203
+ # WHAT A PLACEHOLDER IS FOR. A technical rule corpus is stack-partitioned —
204
+ # guardrails/technology/<stack>/ — so a bullet that spells the stack out is pinned to
205
+ # it, and the same map read from a Spring repo loads .NET rules into the standards the
206
+ # generator obeys. The placeholders let ONE map serve every stack a project declares:
207
+ # they resolve out of .appweaver/stacks.json, already the single answer to "what is
208
+ # this repo built in". Bullets without one are untouched, so every existing map reads
209
+ # exactly as it did.
210
+ subs = {
211
+ "backend_stack": os.environ.get("AW_BE_PROFILE", ""),
212
+ "frontend_stack": os.environ.get("AW_FE_PROFILE", ""),
213
+ "backend_ecosystem": os.environ.get("AW_BE_ECO", ""),
214
+ "frontend_ecosystem": os.environ.get("AW_FE_ECO", ""),
215
+ }
216
+ subs["stack"] = subs["backend_stack"] # the unqualified form means the service lane
217
+ declared = set(v for v in subs.values() if v)
218
+
219
+ HOLE = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")
220
+
221
+ def expand(line):
222
+ """(resolved path, reason) — reason is '' when the line resolved."""
223
+ for h in HOLE.findall(line):
224
+ if h not in subs:
225
+ return "", "unknown placeholder {{%s}}" % h
226
+ if not subs[h]:
227
+ # A frontend placeholder in a backend-only repo is not an error: the lane
228
+ # genuinely does not exist, so its rules genuinely do not apply.
229
+ return "", "no lane declared for {{%s}}" % h
230
+ return HOLE.sub(lambda m: subs[m.group(1)], line), ""
231
+
232
+ bullets = []
233
+ if exists:
234
+ text = open(techmap, encoding="utf-8").read().splitlines()
235
+ for heading in ("## /" + command, "## /" + command.replace("decl-", "")):
236
+ found, grab = [], False
237
+ for line in text:
238
+ if line.strip() == heading:
239
+ grab = True; continue
240
+ if line.startswith("## "):
241
+ grab = False; continue
242
+ if grab:
243
+ m = re.match(r"^\s*-\s+(.*?)\s*$", line)
244
+ if m:
245
+ found.append(m.group(1))
246
+ if found:
247
+ bullets = found
248
+ break
249
+
250
+ candidates, missing, skipped, mismatched = [], [], [], []
251
+ for b in bullets:
252
+ path, why = expand(b)
253
+ if why:
254
+ skipped.append({"bullet": b, "why": why}); continue
255
+ (candidates if os.path.isfile(os.path.join(repo, path)) else missing).append(path)
256
+ # A LITERAL stack directory that is not one this repo declares. Reported, never
257
+ # dropped: the file exists and the project chose it, so it may be a deliberate
258
+ # cross-stack reference — but it is also exactly how a Java repo ends up being
259
+ # handed C# rules, and that must not be invisible.
260
+ m = re.match(r"^guardrails/technology/([^/]+)/", path)
261
+ if m and declared and m.group(1) not in declared and "{{" not in b:
262
+ if m.group(1) not in mismatched:
263
+ mismatched.append(m.group(1))
264
+
265
+ print(json.dumps({
266
+ "domain": domain,
267
+ "techrulemap_exists": exists,
268
+ "candidates": candidates,
269
+ "missing": missing,
270
+ "skipped": skipped,
271
+ "declared_stacks": sorted(declared),
272
+ "stack_mismatch": bool(mismatched),
273
+ "mismatched_stacks": mismatched,
274
+ }))
275
+ RESOLVE_TECH_PY
276
+ }
277
+
278
+ # ─────────────────────────────── derive-names ──────────────────────────────
279
+ # Deterministic naming — NO LLM. Aggregate/Feature token wins; else kebab
280
+ # short-name (stop-word filtered) + General aggregate.
281
+ _pascal() { # kebab/space -> PascalCase
282
+ local in="$1" out="" w
283
+ in="${in//-/ }"
284
+ for w in $in; do
285
+ out+="$(tr '[:lower:]' '[:upper:]' <<< "${w:0:1}")${w:1}"
286
+ done
287
+ printf '%s' "$out"
288
+ }
289
+
290
+ _short_name() {
291
+ local description="$1"
292
+ local stop='^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$'
293
+ local clean; clean=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
294
+ local -a words=()
295
+ local word
296
+ for word in $clean; do
297
+ [ -z "$word" ] && continue
298
+ if ! echo "$word" | grep -qiE "$stop"; then
299
+ [ ${#word} -ge 3 ] && words+=("$word")
300
+ fi
301
+ done
302
+ local max=3 result="" count=0
303
+ [ ${#words[@]} -eq 4 ] && max=4
304
+ for word in "${words[@]}"; do
305
+ [ $count -ge $max ] && break
306
+ [ -n "$result" ] && result+="-"
307
+ result+="$word"; count=$((count+1))
308
+ done
309
+ [ -z "$result" ] && result="feature"
310
+ printf '%s' "$result"
311
+ }
312
+
313
+
314
+ # ───────────────────────────────── scaffold ────────────────────────────────
315
+ cmd_scaffold() {
316
+ local feature_dir_arg="" allow_existing=false
317
+ while [ $# -gt 0 ]; do
318
+ case "$1" in
319
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
320
+ --allow-existing) allow_existing=true; shift ;;
321
+ --help|-h) echo "scaffold --feature-dir <path> [--allow-existing]"; return 0 ;;
322
+ *) echo "scaffold: unknown arg '$1'" >&2; return 1 ;;
323
+ esac
324
+ done
325
+ [ -n "$feature_dir_arg" ] || { echo "scaffold: --feature-dir is required" >&2; return 1; }
326
+
327
+ local repo_root; repo_root=$(get_repo_root)
328
+ local feature_dir="$feature_dir_arg"
329
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
330
+ local spec_file="$feature_dir/spec.md"
331
+
332
+ local dir_existed=false; [ -d "$feature_dir" ] && dir_existed=true
333
+ if [ "$dir_existed" = true ] && [ "$allow_existing" != true ]; then
334
+ echo "scaffold: '$feature_dir' already exists (use --allow-existing)" >&2; return 1
335
+ fi
336
+ mkdir -p "$feature_dir"
337
+ local created=true; [ "$dir_existed" = true ] && created=false
338
+
339
+ local template="" spec_created=false
340
+ if [ ! -f "$spec_file" ]; then
341
+ template=$(resolve_template "spec-template" "$repo_root") || true
342
+ if [ -n "$template" ] && [ -f "$template" ]; then cp "$template" "$spec_file"; else touch "$spec_file"; fi
343
+ spec_created=true
344
+ fi
345
+ _persist_feature_json "$repo_root" "$feature_dir"
346
+
347
+ printf '{"feature_dir":"%s","spec_file":"%s","template":"%s","created":%s,"spec_created":%s}\n' \
348
+ "$(json_escape "$feature_dir")" "$(json_escape "$spec_file")" \
349
+ "$(json_escape "$template")" "$created" "$spec_created"
350
+ }
351
+
352
+ # ─────────────────────────────── resolve-hooks ─────────────────────────────
353
+ # Parse .appweaver/extensions.yml (no PyYAML dependency) and emit the executable
354
+ # hooks for a phase. Governance rules applied here (the agent only dispatches):
355
+ # - drop hooks with enabled: false (absent enabled => enabled)
356
+ # - drop hooks with a non-empty condition (leave to HookExecutor)
357
+ # - normalize command dots to hyphens for the slash form
358
+ cmd_resolve_hooks() {
359
+ local -a phases=()
360
+ while [ $# -gt 0 ]; do
361
+ case "$1" in
362
+ --phase) phases+=("$2"); shift 2 ;;
363
+ --help|-h) echo "resolve-hooks --phase <name> [--phase <name> ...] (multiple phases are merged + deduped by command)"; return 0 ;;
364
+ *) echo "resolve-hooks: unknown arg '$1'" >&2; return 1 ;;
365
+ esac
366
+ done
367
+ [ ${#phases[@]} -gt 0 ] || { echo "resolve-hooks: at least one --phase is required" >&2; return 1; }
368
+
369
+ local repo_root; repo_root=$(get_repo_root)
370
+ local ext="$repo_root/.appweaver/extensions.yml"
371
+ local phases_csv; phases_csv=$(IFS=,; echo "${phases[*]}")
372
+ if [ ! -f "$ext" ]; then
373
+ printf '{"phases":%s,"hooks":[]}\n' "$(json_str_array "${phases[@]}")"; return 0
374
+ fi
375
+
376
+ if command -v python3 >/dev/null 2>&1; then
377
+ AW_EXT="$ext" AW_PHASES="$phases_csv" python3 - <<'PY'
378
+ import json, os
379
+ path = os.environ["AW_EXT"]
380
+ phases = [p for p in os.environ["AW_PHASES"].split(",") if p]
381
+ lines = open(path, encoding="utf-8").read().splitlines()
382
+
383
+ def indent(s): return len(s) - len(s.lstrip(" "))
384
+
385
+ def parse_phase(phase):
386
+ # locate "hooks:" then "<phase>:" then its list items (targeted parser for the
387
+ # known extensions.yml shape; avoids a YAML dependency). List items ("- ") may
388
+ # sit at the SAME indent as their parent key.
389
+ i, n = 0, len(lines); hooks = []
390
+ while i < n:
391
+ if lines[i].rstrip() == "hooks:":
392
+ base = indent(lines[i]); i += 1
393
+ while i < n and (lines[i].strip() == "" or indent(lines[i]) > base):
394
+ st0 = lines[i].strip()
395
+ if st0.rstrip(":") == phase and st0.endswith(":") and not st0.startswith("- "):
396
+ ph_ind = indent(lines[i]); i += 1; cur = None
397
+ while i < n:
398
+ l = lines[i]
399
+ if l.strip() == "": i += 1; continue
400
+ ind = indent(l); st = l.strip()
401
+ if ind < ph_ind: break
402
+ if ind == ph_ind and st.endswith(":") and not st.startswith("- "): break
403
+ if st.startswith("- "):
404
+ if cur is not None: hooks.append(cur)
405
+ cur = {}; st = st[2:]
406
+ if cur is not None and ":" in st and not st.startswith("- "):
407
+ k, _, v = st.partition(":"); cur[k.strip()] = v.strip()
408
+ i += 1
409
+ if cur is not None: hooks.append(cur)
410
+ break
411
+ else: i += 1
412
+ break
413
+ i += 1
414
+ return hooks
415
+
416
+ def truthy_false(v): return str(v).lower() == "false"
417
+ def empty_cond(v): return v is None or str(v).strip().lower() in ("", "null", "~")
418
+
419
+ out, seen = [], set()
420
+ for phase in phases:
421
+ for h in parse_phase(phase):
422
+ if truthy_false(h.get("enabled", "true")): continue
423
+ if not empty_cond(h.get("condition")): continue
424
+ cmd = h.get("command", "")
425
+ if cmd in seen: continue # dedupe by command across phases
426
+ seen.add(cmd)
427
+ out.append({
428
+ "extension": h.get("extension", ""),
429
+ "command": cmd,
430
+ "slash": "/" + cmd.replace(".", "-") if cmd else "",
431
+ "optional": str(h.get("optional", "false")).lower() == "true",
432
+ "description": h.get("description", ""),
433
+ "prompt": h.get("prompt", ""),
434
+ })
435
+ print(json.dumps({"phases": phases, "hooks": out}))
436
+ PY
437
+ else
438
+ echo "resolve-hooks: python3 unavailable; skipping hook resolution" >&2
439
+ printf '{"phases":%s,"hooks":[]}\n' "$(json_str_array "${phases[@]}")"
440
+ fi
441
+ }
442
+
443
+ # ─────────────────────────────── create-checklist ──────────────────────────
444
+ cmd_create_checklist() {
445
+ local feature_dir_arg="" feature_name="" kind="master" change_dir_arg=""
446
+ while [ $# -gt 0 ]; do
447
+ case "$1" in
448
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
449
+ --feature-name) feature_name="$2"; shift 2 ;;
450
+ --kind) kind="$2"; shift 2 ;;
451
+ --change-dir) change_dir_arg="$2"; shift 2 ;;
452
+ --help|-h) echo "create-checklist --feature-dir <path> [--feature-name <name>] [--kind master|delta] [--change-dir <path>]"; return 0 ;;
453
+ *) echo "create-checklist: unknown arg '$1'" >&2; return 1 ;;
454
+ esac
455
+ done
456
+ [ -n "$feature_dir_arg" ] || { echo "create-checklist: --feature-dir is required" >&2; return 1; }
457
+ case "$kind" in
458
+ master|delta) ;;
459
+ *) echo "create-checklist: --kind must be 'master' or 'delta' (got '$kind')" >&2; return 1 ;;
460
+ esac
461
+
462
+ local repo_root; repo_root=$(get_repo_root)
463
+ local feature_dir="$feature_dir_arg"
464
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
465
+ [ -n "$feature_name" ] || feature_name="$(basename "$feature_dir")"
466
+
467
+ # WHERE the checklist lives and WHAT it grades are decided together here, and both
468
+ # are reported back, so a caller binds the pair from ONE call and can never point a
469
+ # checklist at the wrong document. master -> the feature's spec.md; delta -> the
470
+ # change's delta-spec.md (checklist sits beside it, inside changes/<cr-id>/).
471
+ local checklist_dir target_file cr_id=""
472
+ if [ "$kind" = delta ]; then
473
+ [ -n "$change_dir_arg" ] || { echo "create-checklist: --change-dir is required with --kind delta" >&2; return 1; }
474
+ local change_dir="$change_dir_arg"
475
+ [[ "$change_dir" != /* ]] && change_dir="$repo_root/$change_dir"
476
+ cr_id="$(basename "$change_dir")"
477
+ checklist_dir="$change_dir/checklists"
478
+ target_file="$change_dir/delta-spec.md"
479
+ else
480
+ checklist_dir="$feature_dir/checklists"
481
+ target_file="$feature_dir/spec.md"
482
+ fi
483
+ local checklist_file="$checklist_dir/requirements.md"
484
+ mkdir -p "$checklist_dir"
485
+
486
+ local created=true; [ -f "$checklist_file" ] && created=false
487
+ local today; today="$(date +%Y-%m-%d)"
488
+
489
+ if [ "$kind" = delta ]; then
490
+ cat > "$checklist_file" <<EOF
491
+ # Delta-Spec Quality Checklist: $feature_name — $cr_id
492
+
493
+ **Purpose**: Validate the CHANGE's delta-spec before proceeding to design
494
+ **Created**: $today
495
+ **Change**: [delta-spec.md](../delta-spec.md)
496
+
497
+ ## Content Quality
498
+
499
+ - [ ] No implementation mechanics (languages, frameworks, code/adapter design; interop obligations named as contracts are fine)
500
+ - [ ] Focused on user value and business needs
501
+ - [ ] Written for non-technical stakeholders
502
+ - [ ] Change summary states what is changing and why
503
+
504
+ ## Delta Completeness
505
+
506
+ - [ ] Every item sits under ADDED, MODIFIED or REMOVED (an empty section states None)
507
+ - [ ] MODIFIED items state both sides (was -> now)
508
+ - [ ] New behaviour is a user story (US-n) with priority, Given/When/Then acceptance, and an independent test
509
+ - [ ] New or changed system requirements keep EARS form
510
+ - [ ] Requirements are testable and unambiguous
511
+ - [ ] No [NEEDS CLARIFICATION] markers remain
512
+
513
+ ## Domain Lift (Constraints / Classification / Contracts)
514
+
515
+ - [ ] Constraint / NFR changes are captured as CN- items, or the section states None
516
+ - [ ] Data sensitivity or exposure changes are captured as DC- items, or the section states None
517
+ - [ ] External interoperability changes are captured as IC- items at contract level, or the section states None
518
+
519
+ ## Experience Lift (UI / UX standards)
520
+
521
+ - [ ] Experience-standard changes are captured as UX- items, or the section states None
522
+ - [ ] Where UX- deltas exist, each states a project-wide standard checkable on any surface — no screen, page, layout, navigation or component
523
+ - [ ] Where UX- deltas exist, a change to THIS aggregate's own interaction behaviour is an FR- delta, not a UX- one
524
+ - [ ] Where UX- deltas exist, a bound the service must meet stays a CN-, not a UX-
525
+
526
+ ## Merge Readiness
527
+
528
+ - [ ] Header is complete: aggregate, change type, base version
529
+ - [ ] Impact lists the affected ids (FR / BR / CN / DC / IC / UX / US / SC)
530
+ - [ ] Regression risk names the existing behaviour that must still hold
531
+ - [ ] Scoped to THIS change — the unchanged master is neither restated here nor edited
532
+
533
+ ## Notes
534
+
535
+ - This checklist grades the DELTA DOCUMENT only. Accuracy against the master spec
536
+ (id continuity, conflicts with existing requirements) is checked downstream by
537
+ /appweaver analyze-delta-design.
538
+ - Items marked incomplete require delta-spec updates before /appweaver design
539
+ EOF
540
+ else
541
+ cat > "$checklist_file" <<EOF
542
+ # Specification Quality Checklist: $feature_name
543
+
544
+ **Purpose**: Validate specification completeness and quality before proceeding to design
545
+ **Created**: $today
546
+ **Feature**: [spec.md](../spec.md)
547
+
548
+ ## Content Quality
549
+
550
+ - [ ] No implementation mechanics (languages, frameworks, code/adapter design; interop obligations named as contracts are fine)
551
+ - [ ] Focused on user value and business needs
552
+ - [ ] Written for non-technical stakeholders
553
+ - [ ] All mandatory sections completed
554
+
555
+ ## Requirement Completeness
556
+
557
+ - [ ] No [NEEDS CLARIFICATION] markers remain
558
+ - [ ] Requirements are testable and unambiguous
559
+ - [ ] Success criteria are measurable
560
+ - [ ] Success criteria are technology-agnostic (no implementation details)
561
+ - [ ] All acceptance scenarios are defined
562
+ - [ ] Edge cases are identified
563
+ - [ ] Scope is clearly bounded
564
+ - [ ] Dependencies and assumptions identified
565
+
566
+ ## Domain Lift (Constraints / Classification / Contracts)
567
+
568
+ - [ ] Domain-derived constraints are captured in Constraints & NFRs (CN-), or the section states None
569
+ - [ ] Data sensitivity and exposure rules are captured in Data Classification (DC-), or the section states None
570
+ - [ ] External interoperability obligations are captured as Integration Contracts (IC-) at contract level, or the section states None
571
+
572
+ ## Experience Lift (UI / UX standards)
573
+
574
+ - [ ] Experience Scope states the actors, channels and device / browser / assistive-technology floors, or states None
575
+ - [ ] Project-wide experience standards are captured as UX- items, or the section states None
576
+ - [ ] Where UX- items exist, each is checkable on any surface — no screen, page, layout, navigation or component appears
577
+ - [ ] Where UX- items exist, none of them is a per-feature interaction behaviour — those stay FR-
578
+ - [ ] Where UX- items exist, a bound the SERVICE must meet is a CN-, cross-referenced where the obligation binds both lanes
579
+
580
+ ## Feature Readiness
581
+
582
+ - [ ] All functional requirements have clear acceptance criteria
583
+ - [ ] User scenarios cover primary flows
584
+ - [ ] Feature meets measurable outcomes defined in Success Criteria
585
+ - [ ] No implementation details leak into specification
586
+
587
+ ## Notes
588
+
589
+ - Items marked incomplete require spec updates before /appweaver design
590
+ EOF
591
+ fi
592
+
593
+ printf '{"checklist_file":"%s","kind":"%s","target":"%s","created":%s}\n' \
594
+ "$(json_escape "$checklist_file")" "$(json_escape "$kind")" \
595
+ "$(json_escape "$target_file")" "$created"
596
+ }
597
+
598
+ # ─────────────────────────────── list-domains ──────────────────────────────
599
+ # The selectable projects: immediate subdirs of guardrails/projects/ that carry a rule
600
+ # map. The map is what makes a directory a project, exactly as a *.pack.txt is what makes
601
+ # a foundations/ directory a foundation — without that test any stray sibling would be
602
+ # offered as a governance target. EITHER map qualifies, because onboard writes the domain
603
+ # map several steps before the technical one and a half-built project must stay visible.
604
+ cmd_list_domains() {
605
+ while [ $# -gt 0 ]; do
606
+ case "$1" in
607
+ --help|-h) echo "list-domains"; return 0 ;;
608
+ *) echo "list-domains: unknown arg '$1'" >&2; return 1 ;;
609
+ esac
610
+ done
611
+ local repo_root; repo_root=$(get_repo_root)
612
+ local base="$repo_root/guardrails/projects"
613
+ local -a domains=()
614
+ if [ -d "$base" ]; then
615
+ local d
616
+ for d in "$base"/*/; do
617
+ [ -d "$d" ] || continue
618
+ [ -f "$d/tech-rule-bindings.md" ] || [ -f "$d/domain-rule-bindings.md" ] || continue
619
+ domains+=("$(basename "$d")")
620
+ done
621
+ fi
622
+ printf '{"domains":%s}\n' "$(json_str_array "${domains[@]}")"
623
+ }
624
+
625
+ # ─────────────────────────────── set-domain ────────────────────────────────
626
+ cmd_set_domain() {
627
+ local domain=""
628
+ while [ $# -gt 0 ]; do
629
+ case "$1" in
630
+ --domain) domain="$2"; shift 2 ;;
631
+ --help|-h) echo "set-domain --domain <name>"; return 0 ;;
632
+ *) echo "set-domain: unknown arg '$1'" >&2; return 1 ;;
633
+ esac
634
+ done
635
+ [ -n "$domain" ] || { echo "set-domain: --domain is required" >&2; return 1; }
636
+
637
+ local repo_root; repo_root=$(get_repo_root)
638
+ local map_rel="guardrails/projects/$domain/domain-rule-bindings.md"
639
+ local map_exists=false
640
+ [ -f "$repo_root/$map_rel" ] && map_exists=true
641
+
642
+ mkdir -p "$repo_root/.appweaver"
643
+ local dj="$repo_root/.appweaver/domain.json"
644
+ if command -v jq >/dev/null 2>&1; then
645
+ jq -cn --arg d "$domain" '{domain:$d}' > "$dj"
646
+ else
647
+ printf '{"domain":"%s"}\n' "$(json_escape "$domain")" > "$dj"
648
+ fi
649
+
650
+ printf '{"domain":"%s","map_path":"%s","map_exists":%s,"written":true}\n' \
651
+ "$(json_escape "$domain")" "$(json_escape "$map_rel")" "$map_exists"
652
+ }
653
+
654
+ # ────────── stack-list / lane-declare / lane-resolve / lane-probe ──────────
655
+ # The lane model. All four delegate to the shared python core so the bash and
656
+ # powershell twins cannot drift: the JSON contract has one implementation.
657
+ #
658
+ # lane-declare is the SOLE WRITER of .appweaver/stacks.json and has exactly one
659
+ # caller (aw-spec-select-stack). lane-resolve can never write — which is why
660
+ # these are two subcommands rather than one with a mode flag.
661
+ _aw_stack_py() {
662
+ local repo_root; repo_root=$(get_repo_root)
663
+ local core="$SCRIPT_DIR/../python/aw-stack.py"
664
+ if [ ! -f "$core" ]; then
665
+ echo "aw-stack: core not found at $core" >&2; return 1
666
+ fi
667
+ if ! command -v python3 >/dev/null 2>&1; then
668
+ echo "aw-stack: python3 is required for the lane model" >&2; return 1
669
+ fi
670
+ python3 "$core" --repo "$repo_root" "$@"
671
+ }
672
+
673
+ # ONE flattened field of a lane kind, empty when none is declared. The shells never
674
+ # parse stacks.json themselves — aw-stack is its one reader — so every command that needs
675
+ # to know something about a lane asks through here instead of assuming `src/` or `dotnet`.
676
+ # Field is the kind_block suffix: root, profile, ecosystem, language, title, build, test,
677
+ # run, migrate.
678
+ _aw_lane_field() {
679
+ local kind="$1" field="$2"
680
+ _aw_stack_py resolve --kind "$kind" 2>/dev/null | AW_KEY="${kind}_${field}" python3 -c '
681
+ import json, os, sys
682
+ try:
683
+ print(json.loads(sys.stdin.readline()).get(os.environ["AW_KEY"]) or "")
684
+ except Exception:
685
+ print("")
686
+ ' 2>/dev/null || echo ""
687
+ }
688
+
689
+ # The declared SOURCE PATH of a lane kind — the field asked for most often.
690
+ _aw_lane_root() { _aw_lane_field "$1" root; }
691
+
692
+ # Every ecosystem the declared lanes belong to, one per line, deduped and empty when
693
+ # nothing is declared. This is what keys the stack-partitioned corpora under
694
+ # foundations/<ecosystem>/ — a Java repo has no business being offered a .NET bundle,
695
+ # and the lane model already knows which it is.
696
+ _aw_ecosystems() {
697
+ _aw_stack_py resolve 2>/dev/null | python3 -c '
698
+ import json, sys
699
+ try:
700
+ d = json.loads(sys.stdin.readline())
701
+ except Exception:
702
+ raise SystemExit
703
+ seen = []
704
+ for k in ("backend_ecosystem", "frontend_ecosystem"):
705
+ v = d.get(k) or ""
706
+ if v and v not in seen:
707
+ seen.append(v)
708
+ print("\n".join(seen))
709
+ ' 2>/dev/null || true
710
+ }
711
+
712
+ cmd_stack_list() {
713
+ local -a fwd=()
714
+ while [ $# -gt 0 ]; do
715
+ case "$1" in
716
+ --kind) fwd+=("--kind" "$2"); shift 2 ;;
717
+ --preselect) fwd+=("--preselect" "$2"); shift 2 ;;
718
+ --include-none) fwd+=("--include-none"); shift ;;
719
+ --suggest) fwd+=("--suggest"); shift ;;
720
+ --help|-h) echo "stack-list [--kind backend|frontend|all] [--preselect <text>] [--include-none] [--suggest]"; return 0 ;;
721
+ *) echo "stack-list: unknown arg '$1'" >&2; return 1 ;;
722
+ esac
723
+ done
724
+ _aw_stack_py list "${fwd[@]}"
725
+ }
726
+
727
+ cmd_stack_detect() {
728
+ local -a fwd=()
729
+ while [ $# -gt 0 ]; do
730
+ case "$1" in
731
+ --kind) fwd+=("--kind" "$2"); shift 2 ;;
732
+ --exclude-root) fwd+=("--exclude-root" "$2"); shift 2 ;;
733
+ --help|-h) echo "stack-detect [--kind backend|frontend|all] [--exclude-root <path>]…"; return 0 ;;
734
+ *) echo "stack-detect: unknown arg '$1'" >&2; return 1 ;;
735
+ esac
736
+ done
737
+ _aw_stack_py detect "${fwd[@]}"
738
+ }
739
+
740
+ cmd_lane_declare() {
741
+ local -a fwd=()
742
+ while [ $# -gt 0 ]; do
743
+ case "$1" in
744
+ # --source-path is the same field as --root; both spellings reach the core.
745
+ --profile|--kind|--id|--root|--source-path|--stack|--origin|--build|--test|--run|--prototype-root|--prototype-run)
746
+ fwd+=("$1" "$2"); shift 2 ;;
747
+ --no-prototype) fwd+=("--no-prototype"); shift ;;
748
+ --require-existing-root) fwd+=("--require-existing-root"); shift ;;
749
+ --help|-h)
750
+ echo "lane-declare --profile <id|label|none|detected> [--kind backend|frontend] [--id <lane>] [--source-path|--root <path>] [--stack <label>] [--origin declared|reverse-engineered] [--build <cmd>] [--test <cmd>] [--run <cmd>] [--prototype-root <path>] [--prototype-run <cmd>] [--no-prototype] [--require-existing-root]"
751
+ return 0 ;;
752
+ *) echo "lane-declare: unknown arg '$1'" >&2; return 1 ;;
753
+ esac
754
+ done
755
+ _aw_stack_py declare "${fwd[@]}"
756
+ }
757
+
758
+ cmd_lane_resolve() {
759
+ local -a fwd=()
760
+ while [ $# -gt 0 ]; do
761
+ case "$1" in
762
+ --kind|--id) fwd+=("$1" "$2"); shift 2 ;;
763
+ --help|-h) echo "lane-resolve [--kind backend|frontend] [--id <lane>]"; return 0 ;;
764
+ *) echo "lane-resolve: unknown arg '$1'" >&2; return 1 ;;
765
+ esac
766
+ done
767
+ _aw_stack_py resolve "${fwd[@]}"
768
+ }
769
+
770
+ # The gate: is the declaration USABLE for the phase about to run. --require names a
771
+ # lane kind the phase cannot run without and is repeatable; every DECLARED lane is
772
+ # checked for a sound source path whether or not it was required, because a stack
773
+ # named against a directory that is not there is a mistake in every phase.
774
+ # --allow-missing-root is the greenfield state, where select-stack declares a source
775
+ # path before implement creates it.
776
+ cmd_lane_verify() {
777
+ local -a fwd=()
778
+ while [ $# -gt 0 ]; do
779
+ case "$1" in
780
+ --require) fwd+=("--require" "$2"); shift 2 ;;
781
+ # --flag=value: --command carries a phase name that starts with '/'.
782
+ --command) fwd+=("--command=$2"); shift 2 ;;
783
+ --allow-missing-root) fwd+=("--allow-missing-root"); shift ;;
784
+ --help|-h) echo "lane-verify [--require backend|frontend]… [--allow-missing-root] [--command <phase>]"; return 0 ;;
785
+ *) echo "lane-verify: unknown arg '$1'" >&2; return 1 ;;
786
+ esac
787
+ done
788
+ _aw_stack_py verify "${fwd[@]}"
789
+ }
790
+
791
+ # Evidence that a lane of this kind exists, from the FILE signals only: a lane in
792
+ # stacks.json, and a stack name appearing in free prose. The authoritative signal
793
+ # — whether the active project's technical map carries `## /aw-spec-<command>` for the
794
+ # lane's phases — is resolve-tech's, and callers OR the two together.
795
+ cmd_lane_probe() {
796
+ local -a fwd=()
797
+ while [ $# -gt 0 ]; do
798
+ case "$1" in
799
+ # --flag=value, not two tokens: --hint carries raw user prose, and a value
800
+ # that starts with '-' reads as another flag when passed separately.
801
+ --kind|--hint) fwd+=("$1=$2"); shift 2 ;;
802
+ --help|-h) echo "lane-probe [--kind backend|frontend] [--hint <text>]"; return 0 ;;
803
+ *) echo "lane-probe: unknown arg '$1'" >&2; return 1 ;;
804
+ esac
805
+ done
806
+ _aw_stack_py probe "${fwd[@]}"
807
+ }
808
+
809
+ # ─────────────────────────────── list-features ─────────────────────────────
810
+ # Enumerate the aggregates a user can pick as the active feature. Two sources,
811
+ # MERGED (deduped by feature name, case-insensitive):
812
+ # 1. specs/ sub-folders — features already scaffolded/adopted (greenfield or
813
+ # brownfield-post-adopt); annotated with spec + design docs + version.
814
+ # 2. the OKF bundle's Aggregate concepts — the BROWNFIELD FEATURE MAP onboard
815
+ # reverse-engineers (the discovered aggregates NOT yet under specs/). This is
816
+ # why select-feature can offer real code features in a brownfield repo before
817
+ # anything is adopted, instead of an empty specs/ list.
818
+ # On greenfield (no bundle) source 2 is empty → identical to the old specs-only
819
+ # behaviour. `select_options` = merged names + (an injected --preselect that is not
820
+ # yet a feature) + a "create new" sentinel, so select-feature can offer add-new and
821
+ # honour a direct-argument fast path with ONE list.
822
+ cmd_list_features() {
823
+ local preselect="" bundle=""
824
+ while [ $# -gt 0 ]; do
825
+ case "$1" in
826
+ --preselect) preselect="$2"; shift 2 ;;
827
+ --bundle) bundle="$2"; shift 2 ;;
828
+ --help|-h) echo "list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel}"; return 0 ;;
829
+ *) echo "list-features: unknown arg '$1'" >&2; return 1 ;;
830
+ esac
831
+ done
832
+ local repo_root; repo_root=$(get_repo_root)
833
+ [ -n "$bundle" ] || bundle="knowledge"
834
+ case "$bundle" in /*) ;; *) bundle="$repo_root/$bundle" ;; esac
835
+ # Best-effort OKF aggregate list from the shared python core (single source of
836
+ # truth for OKF parsing; the ps1 twin calls the SAME script). Empty on greenfield.
837
+ local okf_json="{}" invpy="$SCRIPT_DIR/../python/aw-inventory.py"
838
+ if [ -d "$bundle" ] && [ -f "$invpy" ] && command -v python3 >/dev/null 2>&1; then
839
+ okf_json=$(python3 "$invpy" list-aggregates --bundle "$bundle" 2>/dev/null) || okf_json="{}"
840
+ fi
841
+ AW_SPECS="$repo_root/specs" AW_PRESELECT="$preselect" AW_OKF="$okf_json" python3 - <<'PY'
842
+ import json, os, glob
843
+ base = os.environ["AW_SPECS"]
844
+ pre = os.environ.get("AW_PRESELECT", "").strip()
845
+ try:
846
+ okf = json.loads(os.environ.get("AW_OKF", "{}") or "{}")
847
+ except Exception:
848
+ okf = {}
849
+ sentinel = "+ Create a new feature"
850
+ feats, names = [], []
851
+ seen = set() # case-insensitive dedup key across both sources
852
+ if os.path.isdir(base):
853
+ for name in sorted(os.listdir(base)):
854
+ d = os.path.join(base, name)
855
+ if not os.path.isdir(d):
856
+ continue
857
+ ver, active_change = "", ""
858
+ avf = os.path.join(d, "active_version.json")
859
+ if os.path.isfile(avf):
860
+ try:
861
+ av = json.load(open(avf, encoding="utf-8")) or {}
862
+ ver = av.get("version", ""); active_change = av.get("active_change", "")
863
+ except Exception: pass
864
+ names.append(name); seen.add(name.lower())
865
+ feats.append({
866
+ "name": name,
867
+ "dir": "specs/" + name,
868
+ "source": "spec",
869
+ "has_spec": os.path.isfile(os.path.join(d, "spec.md")),
870
+ # `design*.md`, NOT `design.*.md`: the second dot made this miss the single
871
+ # `design.md` the framework actually produces (it only matched the retired
872
+ # design.domain.md / design.technical.md family). Informational count only.
873
+ "design_docs": len(glob.glob(os.path.join(d, "design*.md"))),
874
+ "version": ver,
875
+ "active_change": active_change,
876
+ })
877
+ # Merge OKF-discovered aggregates NOT already scaffolded under specs/ (a specs/
878
+ # folder is the adopted/created form of the same aggregate — it wins, no dup).
879
+ for agg in (okf.get("aggregates") or []):
880
+ nm = (agg.get("name") or "").strip()
881
+ if not nm or nm.lower() in seen:
882
+ continue
883
+ names.append(nm); seen.add(nm.lower())
884
+ feats.append({
885
+ "name": nm,
886
+ "dir": "specs/" + nm, # candidate slice (not yet created)
887
+ "source": "okf",
888
+ "has_spec": False,
889
+ "adopted": False,
890
+ "concept_path": agg.get("concept_path", ""),
891
+ "design_docs": 0,
892
+ "version": "",
893
+ "active_change": "",
894
+ })
895
+ opts = list(names)
896
+ # Inject a not-yet-existing --preselect so `preselect_from: USER_INPUT` can
897
+ # create-or-select it without an extra round trip (the direct-arg fast path).
898
+ if pre and pre.lower() not in seen:
899
+ opts.append(pre)
900
+ opts.append(sentinel)
901
+ print(json.dumps({"features": feats, "names": names, "count": len(names),
902
+ "select_options": opts, "sentinel": sentinel}))
903
+ PY
904
+ }
905
+
906
+ # ─────────────────────────────── set-feature ───────────────────────────────
907
+ # Create-or-select an aggregate under specs/ and persist it as the active feature
908
+ # (.appweaver/feature.json). feature = aggregate: the name is normalized to a
909
+ # PascalCase identifier (e.g. "order management" -> OrderManagement). Idempotent:
910
+ # selecting an existing feature just re-points feature.json; a new one is created.
911
+ cmd_set_feature() {
912
+ local raw=""
913
+ while [ $# -gt 0 ]; do
914
+ case "$1" in
915
+ --feature) raw="$2"; shift 2 ;;
916
+ --help|-h) echo "set-feature --feature <name> (create-or-select the aggregate under specs/)"; return 0 ;;
917
+ *) echo "set-feature: unknown arg '$1'" >&2; return 1 ;;
918
+ esac
919
+ done
920
+ [ -n "$raw" ] || { echo "set-feature: --feature is required" >&2; return 1; }
921
+
922
+ # Normalize to a PascalCase aggregate identifier (feature = aggregate).
923
+ local cleaned; cleaned=$(echo "$raw" | sed -E 's/[^A-Za-z0-9]+/ /g; s/^ +| +$//g')
924
+ local feature; feature=$(_pascal "$cleaned")
925
+ if ! [[ "$feature" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
926
+ echo "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric aggregate, e.g. Order)" >&2
927
+ return 1
928
+ fi
929
+
930
+ local repo_root; repo_root=$(get_repo_root)
931
+ local feature_dir_rel="specs/$feature"
932
+ local feature_dir="$repo_root/$feature_dir_rel"
933
+ local existed=false; [ -d "$feature_dir" ] && existed=true
934
+ mkdir -p "$feature_dir"
935
+ _persist_feature_json "$repo_root" "$feature_dir_rel"
936
+
937
+ local created=true; [ "$existed" = true ] && created=false
938
+ local spec_exists=false; [ -f "$feature_dir/spec.md" ] && spec_exists=true
939
+ printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","existed":%s,"created":%s,"spec_exists":%s}\n' \
940
+ "$(json_escape "$feature")" "$(json_escape "$feature_dir_rel")" \
941
+ "$(json_escape "$feature_dir_rel/spec.md")" "$existed" "$created" "$spec_exists"
942
+ }
943
+
944
+ # ═══════════════════════════ active_version.json ════════════════════════════
945
+ # PER-FEATURE state at <feature_dir>/active_version.json:
946
+ # {feature, version, active_change, updated}
947
+ # Distinct from the GLOBAL .appweaver/feature.json (which only SELECTS the active
948
+ # feature). This is the single source of truth for a feature's version number and
949
+ # its open change — the CR that spans cr-setup (opens) -> cr-archive (closes).
950
+ # "Every applied change bumps" => the close op bumps minor ONLY when a change is
951
+ # open (the initial bootstrap build stays v1.0; it is the baseline, not a change).
952
+ # The open/close OPS are reached solely through cr-setup / cr-archive; the former
953
+ # standalone `version-open` / `version-close` subcommands were unused and removed.
954
+ _resolve_feature_dir() { # $1 = --feature-dir arg (may be empty), $2 = repo_root; prints ABS dir or fails
955
+ local arg="$1" repo_root="$2" fd="$1"
956
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
957
+ [ -n "$fd" ] || return 1
958
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
959
+ printf '%s' "$fd"
960
+ }
961
+
962
+ # Shared reader/mutator: op in {read,init,open,close}. Prints one JSON object.
963
+ # op=open enforces the single-active-change guard: opening a DIFFERENT change while
964
+ # one is already open fails (exit 3, ok:false) unless force ("1") is passed — this
965
+ # stops a second concurrent CR on the same aggregate from silently overwriting the
966
+ # first. (Concurrency is ACROSS features; changes to one feature are serialized.)
967
+ _active_version() { # $1 = abs feature_dir, $2 = op, $3 = cr_id (open only), $4 = force ("1" overrides open-guard)
968
+ AW_FD="$1" AW_OP="$2" AW_CR="${3:-}" AW_FORCE="${4:-}" python3 - <<'PY'
969
+ import json, os, re, datetime, sys
970
+ fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]; cr = os.environ.get("AW_CR", "")
971
+ force = os.environ.get("AW_FORCE", "")
972
+ path = os.path.join(fd, "active_version.json")
973
+ feature = os.path.basename(fd.rstrip("/"))
974
+ def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
975
+ def load():
976
+ if os.path.isfile(path):
977
+ try: return json.load(open(path, encoding="utf-8")), True
978
+ except Exception: pass
979
+ return {"feature": feature, "version": "v1.0", "active_change": "", "updated": now()}, False
980
+ def save(d):
981
+ d["updated"] = now()
982
+ open(path, "w", encoding="utf-8").write(json.dumps(d, indent=2) + "\n")
983
+ def bump(v):
984
+ m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "v1.0").strip())
985
+ maj = int(m.group(1)) if m else 1
986
+ mnr = int(m.group(2)) if (m and m.group(2)) else 0
987
+ return "v%d.%d" % (maj, mnr + 1)
988
+
989
+ d, existed = load()
990
+ d.setdefault("feature", feature); d.setdefault("version", "v1.0"); d.setdefault("active_change", "")
991
+ out = {"feature": d["feature"], "version": d["version"], "active_change": d.get("active_change", ""),
992
+ "exists": existed, "path": "active_version.json", "ok": True}
993
+ if op == "read":
994
+ pass
995
+ elif op == "init":
996
+ if not existed: save(d)
997
+ out["created"] = (not existed)
998
+ elif op == "open":
999
+ cur = d.get("active_change", "")
1000
+ if cur and cur != cr and force != "1": # a DIFFERENT change is already open -> refuse
1001
+ out["ok"] = False
1002
+ out["error"] = ("change '%s' is already open on this feature; finish it "
1003
+ "(finalize-delta -> analyze-slice -> close-delta) before opening "
1004
+ "'%s', or pass --force" % (cur, cr))
1005
+ out["conflict"] = cur; out["requested"] = cr; out["has_change"] = True
1006
+ print(json.dumps(out)); sys.exit(3)
1007
+ d["active_change"] = cr; save(d)
1008
+ out["active_change"] = cr; out["opened"] = cr
1009
+ if cur and cur == cr: out["reopened"] = True # idempotent re-open of the same CR
1010
+ elif op == "close":
1011
+ prev = d["version"]; closed = d.get("active_change", "")
1012
+ if closed: # a real change was open -> materialized -> bump
1013
+ d["version"] = bump(prev)
1014
+ d["active_change"] = ""; save(d)
1015
+ out["version"] = d["version"]; out["active_change"] = ""
1016
+ out["previous"] = prev; out["closed_change"] = closed
1017
+ out["has_change"] = bool(out.get("active_change", "")) # clean branch signal for design
1018
+ # What a close from THIS state would produce. One bump() authority, read by BOTH
1019
+ # finalize-delta (to write the right number into the Change History rows it appends)
1020
+ # and close-delta (which actually performs the bump and stamps the two headers), so
1021
+ # the two stages can never disagree about the target. Equals `version` when no change
1022
+ # is open, mirroring close's "only bump if a change was really open" guard.
1023
+ out["next_version"] = bump(out["version"]) if out.get("active_change", "") else out["version"]
1024
+ print(json.dumps(out))
1025
+ PY
1026
+ }
1027
+
1028
+ cmd_version_read() { _version_cmd "read" "$@"; }
1029
+ cmd_version_init() { _version_cmd "init" "$@"; }
1030
+ _version_cmd() { # $1 = op (read|init), then --feature-dir arg
1031
+ local op="$1"; shift
1032
+ local fd_arg=""
1033
+ while [ $# -gt 0 ]; do
1034
+ case "$1" in
1035
+ --feature-dir) fd_arg="$2"; shift 2 ;;
1036
+ --help|-h) echo "version-$op [--feature-dir <path>]"; return 0 ;;
1037
+ *) echo "version-$op: unknown arg '$1'" >&2; return 1 ;;
1038
+ esac
1039
+ done
1040
+ local repo_root; repo_root=$(get_repo_root)
1041
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "version-$op: no feature set and no --feature-dir" >&2; return 1; }
1042
+ _active_version "$fd" "$op"
1043
+ }
1044
+
1045
+
1046
+
1047
+ # ─────────────────────────────── feature-paths ─────────────────────────────
1048
+ cmd_feature_paths() {
1049
+ while [ $# -gt 0 ]; do
1050
+ case "$1" in
1051
+ --help|-h) echo "feature-paths"; return 0 ;;
1052
+ *) echo "feature-paths: unknown arg '$1'" >&2; return 1 ;;
1053
+ esac
1054
+ done
1055
+ local repo_root; repo_root=$(get_repo_root)
1056
+ local fd; fd=$(read_feature_json_feature_directory "$repo_root")
1057
+ if [ -z "$fd" ]; then
1058
+ echo "feature-paths: no feature set (run /appweaver select-feature first)" >&2
1059
+ return 1
1060
+ fi
1061
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
1062
+ local feature; feature=$(basename "$fd") # the aggregate name (feature = specs/<Feature>)
1063
+ # The master documents of a feature: spec.md (WHAT/WHY) + design.md (the service
1064
+ # lane's HOW) + design.ui.md (the frontend lane's HOW, where the feature has a UI
1065
+ # half). plan.md / tasks.md belonged to the retired /aw-spec-plan + /aw-spec-tasks
1066
+ # phases and had zero consumers.
1067
+ #
1068
+ # ui_design_exists IS THE UI-HALF SIGNAL for the whole CR loop. Every delta stage
1069
+ # asks the same question — is there a UI master to keep in sync? — and the master's
1070
+ # own presence answers it without re-deriving the lane declaration and the slice's
1071
+ # UI file count at each stage. It is also self-correcting: whatever authors
1072
+ # design.ui.md (adopt today) enrolls the feature in the UI half of the delta loop
1073
+ # by the act of writing the file.
1074
+ local spec="$fd/spec.md" design="$fd/design.md" ui_design="$fd/design.ui.md"
1075
+ local spec_exists=false; [ -f "$spec" ] && spec_exists=true
1076
+ local design_exists=false; [ -f "$design" ] && design_exists=true
1077
+ local ui_design_exists=false; [ -f "$ui_design" ] && ui_design_exists=true
1078
+ printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","design_file":"%s","ui_design_file":"%s","spec_exists":%s,"design_exists":%s,"ui_design_exists":%s}\n' \
1079
+ "$(json_escape "$feature")" "$(json_escape "$fd")" "$(json_escape "$spec")" "$(json_escape "$design")" \
1080
+ "$(json_escape "$ui_design")" "$spec_exists" "$design_exists" "$ui_design_exists"
1081
+ }
1082
+
1083
+ # ────────────────────────── checklist-read / -apply ────────────────────────
1084
+ # Parse/toggle GitHub task-list checkboxes outside code fences. Python3 does the
1085
+ # parsing (robust, fence-aware); toggling changes ONLY the [ ]/[x] marker.
1086
+ cmd_checklist_read() {
1087
+ local file=""
1088
+ while [ $# -gt 0 ]; do
1089
+ case "$1" in
1090
+ --file) file="$2"; shift 2 ;;
1091
+ --help|-h) echo "checklist-read --file <path>"; return 0 ;;
1092
+ *) echo "checklist-read: unknown arg '$1'" >&2; return 1 ;;
1093
+ esac
1094
+ done
1095
+ [ -n "$file" ] || { echo "checklist-read: --file required" >&2; return 1; }
1096
+ AW_FILE="$file" python3 - <<'PY'
1097
+ import json, os
1098
+ f = os.environ["AW_FILE"]
1099
+ if not os.path.exists(f):
1100
+ print(json.dumps({"exists": False, "total": 0, "checked": 0, "items": []})); raise SystemExit
1101
+ items, n, fence = [], 0, False
1102
+ for line in open(f, encoding="utf-8").read().splitlines():
1103
+ s = line.strip()
1104
+ if s.startswith("```"): fence = not fence; continue
1105
+ if fence: continue
1106
+ st = line.lstrip()
1107
+ if st[:3] in ("- [",) and len(st) >= 5 and st[3] in " xX" and st[4] == "]":
1108
+ n += 1
1109
+ items.append({"n": n, "checked": st[3] in "xX", "text": st[5:].strip()})
1110
+ print(json.dumps({"exists": True, "total": len(items),
1111
+ "checked": sum(1 for i in items if i["checked"]), "items": items}))
1112
+ PY
1113
+ }
1114
+
1115
+ cmd_checklist_apply() {
1116
+ local file="" results="{}"
1117
+ while [ $# -gt 0 ]; do
1118
+ case "$1" in
1119
+ --file) file="$2"; shift 2 ;;
1120
+ --results) results="$2"; shift 2 ;;
1121
+ --help|-h) echo "checklist-apply --file <path> --results <json>"; return 0 ;;
1122
+ *) echo "checklist-apply: unknown arg '$1'" >&2; return 1 ;;
1123
+ esac
1124
+ done
1125
+ [ -n "$file" ] || { echo "checklist-apply: --file required" >&2; return 1; }
1126
+ AW_FILE="$file" AW_RESULTS="$results" python3 - <<'PY'
1127
+ import json, os, re
1128
+ f = os.environ["AW_FILE"]
1129
+ if not os.path.exists(f):
1130
+ print(json.dumps({"exists": False, "before_checked": 0, "after_checked": 0,
1131
+ "total": 0, "newly_passing": [], "regressions": []})); raise SystemExit
1132
+ try:
1133
+ results = {int(k): bool(v) for k, v in json.loads(os.environ["AW_RESULTS"] or "{}").items()}
1134
+ except Exception:
1135
+ results = {}
1136
+ lines = open(f, encoding="utf-8").read().splitlines(keepends=True)
1137
+ n, fence = 0, False
1138
+ before = after = 0
1139
+ newly, regress = [], []
1140
+ out = []
1141
+ for line in lines:
1142
+ s = line.strip()
1143
+ if s.startswith("```"):
1144
+ fence = not fence; out.append(line); continue
1145
+ m = re.match(r'^(\s*- \[)([ xX])(\].*)$', line.rstrip("\n")) if not fence else None
1146
+ if m:
1147
+ n += 1
1148
+ cur = m.group(2) in "xX"
1149
+ before += 1 if cur else 0
1150
+ desired = results.get(n, cur)
1151
+ if desired != cur:
1152
+ if desired: newly.append(n)
1153
+ else: regress.append(n)
1154
+ marker = "x" if desired else " "
1155
+ nl = "\n" if line.endswith("\n") else ""
1156
+ out.append(f"{m.group(1)}{marker}{m.group(3)}{nl}")
1157
+ after += 1 if desired else 0
1158
+ else:
1159
+ out.append(line)
1160
+ open(f, "w", encoding="utf-8").write("".join(out))
1161
+ print(json.dumps({"exists": True, "before_checked": before, "after_checked": after,
1162
+ "total": n, "newly_passing": newly, "regressions": regress}))
1163
+ PY
1164
+ }
1165
+
1166
+
1167
+
1168
+ # ─────────────────────────────── detect-project ────────────────────────────
1169
+ # Project Structure Gate: is there a scaffolded app (not just framework files)?
1170
+ #
1171
+ # EVERY ecosystem's manifest is searched to the same depth. A project is where its
1172
+ # manifest is, not where the repository root is: a Maven service copied into
1173
+ # backend/orderservice/ is as scaffolded as a .sln at the top. Searching *.sln
1174
+ # recursively while testing pom.xml and package.json only at the root reported
1175
+ # every non-.NET layout that nests its project as no-project-at-all, which reads
1176
+ # at the gate as "run /appweaver foundation first" — advice a stack with no
1177
+ # foundation bundle cannot take.
1178
+ #
1179
+ # --root scopes the search to one lane's subtree: the directory the calling phase
1180
+ # is about to write into, so the answer is about that lane's project rather than
1181
+ # about any project anywhere. It falls back to the whole repository when the
1182
+ # subtree holds no manifest of its own, which is the ordinary .NET layout — a
1183
+ # lane rooted at src/ under a solution file that sits beside it.
1184
+ _project_markers() {
1185
+ local repo_root="$1" start="$2"
1186
+ find "$start" \
1187
+ \( -path "$repo_root/.appweaver" -o -path "$repo_root/guardrails" \
1188
+ -o -path "$repo_root/foundations" -o -path "$repo_root/specs" \
1189
+ -o -path "$repo_root/.git" -o -name node_modules \) -prune -o \
1190
+ \( -name "*.sln" -o -name "*.csproj" -o -name "package.json" \
1191
+ -o -name "pom.xml" -o -name "build.gradle" -o -name "build.gradle.kts" \
1192
+ -o -name "go.mod" -o -name "Cargo.toml" -o -name "pyproject.toml" \) -print 2>/dev/null \
1193
+ | sed "s#^$repo_root/##" | LC_ALL=C sort | head -20
1194
+ }
1195
+
1196
+ cmd_detect_project() {
1197
+ local scope_root=""
1198
+ while [ $# -gt 0 ]; do
1199
+ case "$1" in
1200
+ --root|--source-path) scope_root="$2"; shift 2 ;;
1201
+ --help|-h) echo "detect-project [--root <dir>] -> {scaffolded, markers[], scope}"; return 0 ;;
1202
+ *) echo "detect-project: unknown arg '$1'" >&2; return 1 ;;
1203
+ esac
1204
+ done
1205
+ local repo_root; repo_root=$(get_repo_root)
1206
+ local -a markers=()
1207
+ local scope="repo" start="$repo_root" rel
1208
+ # A lane's source path arrives as the value declare stored, so it may carry a
1209
+ # trailing slash; an option label ("path — why") never reaches a script argument.
1210
+ rel="${scope_root#./}"; rel="${rel%/}"
1211
+ if [ -n "$rel" ] && [ -d "$repo_root/$rel" ]; then
1212
+ start="$repo_root/$rel"; scope="lane"
1213
+ fi
1214
+ while IFS= read -r m; do [ -n "$m" ] && markers+=("$m"); done < <(_project_markers "$repo_root" "$start")
1215
+ if [ ${#markers[@]} -eq 0 ] && [ "$scope" = "lane" ]; then
1216
+ scope="repo"
1217
+ while IFS= read -r m; do [ -n "$m" ] && markers+=("$m"); done < <(_project_markers "$repo_root" "$repo_root")
1218
+ fi
1219
+ local scaffolded=false
1220
+ [ ${#markers[@]} -gt 0 ] && scaffolded=true
1221
+ printf '{"scaffolded":%s,"markers":%s,"scope":"%s"}\n' "$scaffolded" "$(json_str_array "${markers[@]}")" "$scope"
1222
+ }
1223
+
1224
+
1225
+ # ───────────────────────── foundation resolution ───────────────────────────
1226
+ # Foundations live at foundations/<stack>/<Name>/, grouped by the technology stack
1227
+ # they scaffold. Names are unique across stacks, so callers pass a bare <Name> and
1228
+ # the lookup searches every stack.
1229
+
1230
+ # Echo "<stack>/<Name>" for each foundation directory, one per line. A stack also
1231
+ # holds siblings that are not foundations (components/), so carrying a *.pack.txt is
1232
+ # what makes a directory one — without that test they surface as bundle-less
1233
+ # foundations in every lookup.
1234
+ _foundation_dirs() {
1235
+ local repo_root="$1" s d
1236
+ [ -d "$repo_root/foundations" ] || return 0
1237
+ for s in "$repo_root/foundations"/*/; do
1238
+ [ -d "$s" ] || continue
1239
+ for d in "$s"*/; do
1240
+ [ -d "$d" ] || continue
1241
+ ls "$d"*.pack.txt >/dev/null 2>&1 && printf '%s/%s\n' "$(basename "$s")" "$(basename "$d")"
1242
+ done
1243
+ done
1244
+ }
1245
+
1246
+ # Echo the stack a foundation NAME belongs to; empty when the name is unknown.
1247
+ _foundation_stack() {
1248
+ local repo_root="$1" name="$2" rel
1249
+ while IFS= read -r rel; do
1250
+ [ "${rel##*/}" = "$name" ] && { printf '%s' "${rel%%/*}"; return 0; }
1251
+ done < <(_foundation_dirs "$repo_root")
1252
+ return 0
1253
+ }
1254
+
1255
+ # ──────────────────────────── foundation-list ──────────────────────────────
1256
+ # Enumerate available foundations (foundations/<stack>/<Name>/ that carry a real
1257
+ # <Name>.pack.txt) so the workflow can let the user PICK one — deterministic, no LLM. An optional
1258
+ # --args "<Foundation> <App> [out]" is parsed as a direct-argument fast path:
1259
+ # preselect_name is echoed back only when it names a real foundation; preselect_app
1260
+ # / out_dir carry any 2nd/3rd tokens through (empty/"." otherwise).
1261
+ cmd_foundation_list() {
1262
+ local raw=""
1263
+ while [ $# -gt 0 ]; do
1264
+ case "$1" in
1265
+ --args) raw="$2"; shift 2 ;;
1266
+ --help|-h) echo "foundation-list [--args '<Foundation> <App> [out]'] -> {foundations[], count, preselect_name, preselect_app, out_dir}"; return 0 ;;
1267
+ *) echo "foundation-list: unknown arg '$1'" >&2; return 1 ;;
1268
+ esac
1269
+ done
1270
+ local repo_root; repo_root=$(get_repo_root)
1271
+
1272
+ # WHICH foundations this repo may be offered. A foundation bundle is a whole
1273
+ # compiling project in one ecosystem's languages and build system, so unpacking
1274
+ # a .NET one into a Spring repo does not produce a scaffold — it produces a
1275
+ # second, foreign project beside the real one. The declared lanes already say
1276
+ # which ecosystems this repo is, so filter to those and let the gate say why
1277
+ # when that leaves nothing. Nothing declared (a bare greenfield repo, before
1278
+ # select-stack) filters nothing: there is no answer yet to filter on.
1279
+ local -a lane_ecos=()
1280
+ local e
1281
+ while IFS= read -r e; do [ -n "$e" ] && lane_ecos+=("$e"); done < <(_aw_ecosystems)
1282
+
1283
+ local -a foundations=() all=() eco_of=()
1284
+ local rel name eco keep
1285
+ while IFS= read -r rel; do
1286
+ name="${rel##*/}"; eco="${rel%%/*}"
1287
+ [ -f "$repo_root/foundations/$rel/$name.pack.txt" ] || continue # the primary pack (excludes Reference-only dirs)
1288
+ all+=("$name"); eco_of+=("$eco")
1289
+ keep=true
1290
+ if [ ${#lane_ecos[@]} -gt 0 ]; then
1291
+ keep=false
1292
+ for e in "${lane_ecos[@]}"; do [ "$e" = "$eco" ] && keep=true; done
1293
+ fi
1294
+ [ "$keep" = true ] && foundations+=("$name")
1295
+ done < <(_foundation_dirs "$repo_root")
1296
+ # Optional direct-argument fast path: "<FoundationName> <AppName> [OutputDir]".
1297
+ local pre_name="" pre_app="" out_dir="."
1298
+ if [ -n "$raw" ]; then
1299
+ # shellcheck disable=SC2086
1300
+ set -- $raw
1301
+ [ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] && out_dir="$3"
1302
+ fi
1303
+ # Only preselect a name that actually names an available foundation.
1304
+ local match="" f
1305
+ for f in "${foundations[@]}"; do [ "$f" = "$pre_name" ] && match="$pre_name"; done
1306
+ printf '{"foundations":%s,"count":%s,"all_foundations":%s,"all_count":%s,"ecosystems":%s,"lane_ecosystems":%s,"filtered":%s,"preselect_name":"%s","preselect_app":"%s","out_dir":"%s"}\n' \
1307
+ "$(json_str_array "${foundations[@]}")" "${#foundations[@]}" \
1308
+ "$(json_str_array "${all[@]}")" "${#all[@]}" \
1309
+ "$(json_str_array "${eco_of[@]}")" "$(json_str_array "${lane_ecos[@]}")" \
1310
+ "$([ ${#lane_ecos[@]} -gt 0 ] && echo true || echo false)" \
1311
+ "$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$out_dir")"
1312
+ }
1313
+
1314
+ # ─────────────────────────── foundation-locate ─────────────────────────────
1315
+ # Parse/validate args, locate the pack + unpacker, read the pack header (bounded).
1316
+ cmd_foundation_locate() {
1317
+ local name="" app="" out="." raw=""
1318
+ while [ $# -gt 0 ]; do
1319
+ case "$1" in
1320
+ --name) name="$2"; shift 2 ;;
1321
+ --app) app="$2"; shift 2 ;;
1322
+ --out) out="$2"; shift 2 ;;
1323
+ --args) raw="$2"; shift 2 ;;
1324
+ --help|-h) echo "foundation-locate (--args '<F> <A> [out]' | --name <F> --app <A> [--out <dir>])"; return 0 ;;
1325
+ *) echo "foundation-locate: unknown arg '$1'" >&2; return 1 ;;
1326
+ esac
1327
+ done
1328
+ # positional parse: "<FoundationName> <AppName> [OutputDir]"
1329
+ if [ -n "$raw" ]; then
1330
+ # shellcheck disable=SC2086
1331
+ set -- $raw
1332
+ [ -n "$1" ] && name="$1"; [ -n "$2" ] && app="$2"; [ -n "$3" ] && out="$3"
1333
+ fi
1334
+ local repo_root; repo_root=$(get_repo_root)
1335
+ # available = every foundation dir (_foundation_dirs already requires a *.pack.txt)
1336
+ local -a available=()
1337
+ local rel
1338
+ while IFS= read -r rel; do
1339
+ available+=("${rel##*/}")
1340
+ done < <(_foundation_dirs "$repo_root")
1341
+ _fnd_fail() { printf '{"ok":false,"reason":"%s","available":%s}\n' "$(json_escape "$1")" "$(json_str_array "${available[@]}")"; }
1342
+ if [ -z "$name" ] || [ -z "$app" ]; then
1343
+ _fnd_fail "Usage: /aw-spec-foundation <FoundationName> <AppName> [OutputDirectory]"; return 0
1344
+ fi
1345
+ local stack; stack=$(_foundation_stack "$repo_root" "$name")
1346
+ if [ -z "$stack" ]; then _fnd_fail "Foundation '$name' not found (expected foundations/<stack>/$name/)"; return 0; fi
1347
+ local rel="foundations/$stack/$name"
1348
+ local fdir="$repo_root/$rel"
1349
+ local pack="$fdir/$name.pack.txt"
1350
+ if [ ! -f "$pack" ]; then _fnd_fail "Foundation '$name' has no bundle yet (expected $pack). Build it via the pack scripts in $rel/."; return 0; fi
1351
+ # runner + unpacker
1352
+ local runner unpacker
1353
+ if command -v pwsh >/dev/null 2>&1 && [ -f "$fdir/unpack-foundation.ps1" ]; then
1354
+ runner="pwsh"; unpacker="$rel/unpack-foundation.ps1"
1355
+ elif [ -f "$fdir/unpack-foundation.sh" ]; then
1356
+ runner="sh"; unpacker="$rel/unpack-foundation.sh"
1357
+ else
1358
+ emit_fail "No unpacker found in $rel/ (unpack-foundation.ps1 or .sh)"; return 0
1359
+ fi
1360
+ # bounded header read (never load the pack body)
1361
+ local header sentinel file_count
1362
+ header=$(grep -m 30 '^#' "$pack" 2>/dev/null || true)
1363
+ sentinel=$(printf '%s\n' "$header" | grep -iE 'sentinel' | head -1 | sed -E 's/.*[sS]entinel[^A-Za-z0-9_.]*//; s/[^A-Za-z0-9_.].*$//')
1364
+ file_count=$(printf '%s\n' "$header" | grep -iE 'file' | grep -oE '[0-9]+' | head -1)
1365
+ [ -n "$file_count" ] || file_count="0"
1366
+
1367
+ printf '{"ok":true,"reason":"","name":"%s","stack":"%s","app":"%s","out_dir":"%s","pack_file":"%s","unpacker":"%s","runner":"%s","sentinel":"%s","file_count":%s,"available":%s}\n' \
1368
+ "$(json_escape "$name")" "$(json_escape "$stack")" "$(json_escape "$app")" "$(json_escape "$out")" "$(json_escape "$rel/$name.pack.txt")" \
1369
+ "$(json_escape "$unpacker")" "$runner" "$(json_escape "$sentinel")" "$file_count" "$(json_str_array "${available[@]}")"
1370
+ }
1371
+
1372
+ # ─────────────────────────── foundation-unpack ─────────────────────────────
1373
+ # Run the OS-selected unpacker for the foundation (or reference) pack.
1374
+ cmd_foundation_unpack() {
1375
+ local name="" app="" out="." dry=false force="no" reference=false
1376
+ while [ $# -gt 0 ]; do
1377
+ case "$1" in
1378
+ --name) name="$2"; shift 2 ;;
1379
+ --app) app="$2"; shift 2 ;;
1380
+ --out) out="$2"; shift 2 ;;
1381
+ --dry-run) dry=true; shift ;;
1382
+ --force) force="$2"; shift 2 ;;
1383
+ --reference) reference=true; shift ;;
1384
+ --help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
1385
+ *) echo "foundation-unpack: unknown arg '$1'" >&2; return 1 ;;
1386
+ esac
1387
+ done
1388
+ [ -n "$name" ] && [ -n "$app" ] || { echo "foundation-unpack: --name and --app required" >&2; return 1; }
1389
+ local repo_root; repo_root=$(get_repo_root)
1390
+ local stack; stack=$(_foundation_stack "$repo_root" "$name")
1391
+ local fdir="$repo_root/foundations/$stack/$name"
1392
+ local packbase="$name" script_stem="unpack-foundation"
1393
+ if [ "$reference" = true ]; then packbase="$name.Reference"; script_stem="unpack-reference-foundation"; fi
1394
+ local pack="$fdir/$packbase.pack.txt"
1395
+ if [ ! -f "$pack" ]; then printf '{"ok":false,"mode":"%s","written":0,"has_collisions":false,"stdout":"pack not found: %s"}\n' "$([ "$dry" = true ] && echo dry || echo real)" "$(json_escape "$packbase.pack.txt")"; return 0; fi
1396
+
1397
+ # pick runner
1398
+ local -a cmd=()
1399
+ if command -v pwsh >/dev/null 2>&1 && [ -f "$fdir/$script_stem.ps1" ]; then
1400
+ cmd=(pwsh -NoProfile -File "$fdir/$script_stem.ps1" -PackFile "$pack" -TargetAppName "$app" -OutDir "$out")
1401
+ [ "$dry" = true ] && cmd+=(-DryRun)
1402
+ [ "$force" = "yes" ] && cmd+=(-Force)
1403
+ [ "$reference" = true ] && [ "$force" != "yes" ] && cmd+=(-Force) # reference always forces
1404
+ elif [ -f "$fdir/$script_stem.sh" ]; then
1405
+ cmd=(bash "$fdir/$script_stem.sh" --pack-file "$pack" --target-app-name "$app" --out-dir "$out")
1406
+ [ "$dry" = true ] && cmd+=(--dry-run)
1407
+ { [ "$force" = "yes" ] || [ "$reference" = true ]; } && cmd+=(--force)
1408
+ else
1409
+ printf '{"ok":false,"mode":"real","written":0,"has_collisions":false,"stdout":"no unpacker script for %s"}\n' "$(json_escape "$name")"; return 0
1410
+ fi
1411
+
1412
+ local outp rc
1413
+ rc=0
1414
+ outp=$("${cmd[@]}" 2>&1) || rc=$? # set -e safe: a failing unpacker (dry-run collisions) must still yield JSON
1415
+ # unpackers say "Unpacked N file(s) to …" / "DryRun: N file(s) would be written …"
1416
+ local written; written=$(printf '%s' "$outp" | grep -oiE '[0-9]+ +file' | grep -oE '[0-9]+' | head -1)
1417
+ [ -n "$written" ] || written=0
1418
+ local has_coll=false
1419
+ if [ "$dry" = true ] && [ $rc -ne 0 ]; then has_coll=true; fi
1420
+ printf '{"ok":%s,"mode":"%s","written":%s,"has_collisions":%s,"stdout":"%s"}\n' \
1421
+ "$([ $rc -eq 0 ] && echo true || echo false)" "$([ "$dry" = true ] && echo dry || echo real)" \
1422
+ "$written" "$has_coll" "$(json_escape "$(printf '%s' "$outp" | tail -c 800)")"
1423
+ }
1424
+
1425
+ # ─────────────────────── foundation-stash-dbcontext ────────────────────────
1426
+ # Copy the current ApplicationDbContext.cs to a .foundation.bak sibling before a
1427
+ # reference unpack overwrites it (so the two can be merged afterward).
1428
+ cmd_foundation_stash_dbcontext() {
1429
+ local out="."
1430
+ while [ $# -gt 0 ]; do
1431
+ case "$1" in
1432
+ --out) out="$2"; shift 2 ;;
1433
+ --help|-h) echo "foundation-stash-dbcontext --out <dir>"; return 0 ;;
1434
+ *) echo "foundation-stash-dbcontext: unknown arg '$1'" >&2; return 1 ;;
1435
+ esac
1436
+ done
1437
+ local repo_root; repo_root=$(get_repo_root)
1438
+ [[ "$out" != /* ]] && out="$repo_root/$out"
1439
+ local ctx; ctx=$(find "$out/src" -name "ApplicationDbContext.cs" 2>/dev/null | head -1)
1440
+ if [ -z "$ctx" ]; then printf '{"found":false,"dbcontext":"","backup":""}\n'; return 0; fi
1441
+ local bak="$ctx.foundation.bak"
1442
+ cp "$ctx" "$bak"
1443
+ printf '{"found":true,"dbcontext":"%s","backup":"%s"}\n' "$(json_escape "$ctx")" "$(json_escape "$bak")"
1444
+ }
1445
+
1446
+ # ─────────────────────────── foundation-verify ─────────────────────────────
1447
+ # Scan the output tree for leftover sentinel tokens (should be none).
1448
+ cmd_foundation_verify() {
1449
+ local out="." sentinel=""
1450
+ while [ $# -gt 0 ]; do
1451
+ case "$1" in
1452
+ --out) out="$2"; shift 2 ;;
1453
+ --sentinel) sentinel="$2"; shift 2 ;;
1454
+ --help|-h) echo "foundation-verify --out <dir> --sentinel <s>"; return 0 ;;
1455
+ *) echo "foundation-verify: unknown arg '$1'" >&2; return 1 ;;
1456
+ esac
1457
+ done
1458
+ local repo_root; repo_root=$(get_repo_root)
1459
+ [[ "$out" != /* ]] && out="$repo_root/$out"
1460
+ local -a residue=()
1461
+ if [ -n "$sentinel" ] && [ -d "$out" ]; then
1462
+ # scan the generated tree only — never the bundle/framework dirs (the
1463
+ # .pack.txt legitimately contains the sentinel).
1464
+ while IFS= read -r m; do [ -n "$m" ] && residue+=("$m"); done < <(
1465
+ grep -rIl --exclude-dir=.git --exclude-dir=guardrails --exclude-dir=foundations --exclude-dir=.appweaver \
1466
+ --exclude-dir=specs --exclude-dir=node_modules -- "$sentinel" "$out" 2>/dev/null \
1467
+ | sed "s#^$repo_root/##" | head -50 )
1468
+ fi
1469
+ printf '{"clean":%s,"residue":%s}\n' "$([ ${#residue[@]} -eq 0 ] && echo true || echo false)" "$(json_str_array "${residue[@]}")"
1470
+ }
1471
+
1472
+ # ─────────────────────────────── setup-constitution ────────────────────────
1473
+ # Ensure the split constitution files exist (copy from templates if missing):
1474
+ # .appweaver/memory/constitution.technical.md (from constitution-technical-template)
1475
+ # .appweaver/memory/constitution.domain.md (from constitution-domain-template)
1476
+ # .appweaver/memory/constitution.ui.md (from constitution-ui-template) — ONLY with --ui
1477
+ # The composed .appweaver/memory/constitution.md is produced by `compose-constitution`
1478
+ # after the halves are authored. Each half is authored + verified independently.
1479
+ #
1480
+ # The UI half is opt-in because it is the only one whose ABSENCE is meaningful: a
1481
+ # backend-only project has no frontend lane and must look exactly as it did before
1482
+ # the lane model existed. The other two are always created and left as unauthored
1483
+ # templates when unused, which compose-constitution then skips.
1484
+ cmd_setup_constitution() {
1485
+ # --ui / --no-technical follow the LANES the project declared. A half is a lane's
1486
+ # standing governance, so a lane the project does not have gets no half — and the
1487
+ # file is not created either, because an empty template left in memory/ is
1488
+ # indistinguishable from a half somebody forgot to author.
1489
+ local want_ui=false want_tech=true
1490
+ while [ $# -gt 0 ]; do
1491
+ case "$1" in
1492
+ --ui) want_ui=true; shift ;;
1493
+ --no-technical) want_tech=false; shift ;;
1494
+ # An EMPTY argument is a flag slot the caller left unfilled. The engine
1495
+ # builds a step's `args:` list before it knows which branch it is in, so a
1496
+ # workflow that varies one flag has to choose between duplicating the whole
1497
+ # step and passing "" — and duplicating it is how this call came to recreate
1498
+ # the technical half that the earlier call had just been told to skip.
1499
+ "") shift ;;
1500
+ --help|-h) echo "setup-constitution [--ui] [--no-technical]"; return 0 ;;
1501
+ *) echo "setup-constitution: unknown arg '$1'" >&2; return 1 ;;
1502
+ esac
1503
+ done
1504
+ local repo_root; repo_root=$(get_repo_root)
1505
+ mkdir -p "$repo_root/.appweaver/memory"
1506
+ local tech="$repo_root/.appweaver/memory/constitution.technical.md"
1507
+ local dom="$repo_root/.appweaver/memory/constitution.domain.md"
1508
+ local ui="$repo_root/.appweaver/memory/constitution.ui.md"
1509
+ local tech_created=false dom_created=false ui_created=false ui_path=""
1510
+ if [ "$want_tech" != true ]; then tech=""; fi
1511
+ if [ -n "$tech" ] && [ ! -f "$tech" ]; then
1512
+ local tpl; tpl=$(resolve_template "constitution-technical-template" "$repo_root") || true
1513
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$tech"; else touch "$tech"; fi
1514
+ tech_created=true
1515
+ fi
1516
+ if [ ! -f "$dom" ]; then
1517
+ local tpl; tpl=$(resolve_template "constitution-domain-template" "$repo_root") || true
1518
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dom"; else touch "$dom"; fi
1519
+ dom_created=true
1520
+ fi
1521
+ if [ "$want_ui" = true ]; then
1522
+ ui_path="$ui"
1523
+ if [ ! -f "$ui" ]; then
1524
+ local tpl; tpl=$(resolve_template "constitution-ui-template" "$repo_root") || true
1525
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$ui"; else touch "$ui"; fi
1526
+ ui_created=true
1527
+ fi
1528
+ fi
1529
+ printf '{"constitution_technical_file":"%s","constitution_domain_file":"%s","constitution_ui_file":"%s","technical_created":%s,"domain_created":%s,"ui_created":%s}\n' \
1530
+ "$(json_escape "$tech")" "$(json_escape "$dom")" "$(json_escape "$ui_path")" \
1531
+ "$tech_created" "$dom_created" "$ui_created"
1532
+ }
1533
+
1534
+ # ─────────────────────────────── compose-constitution ──────────────────────
1535
+ # Deterministically concatenate the authored halves into the composed
1536
+ # .appweaver/memory/constitution.md (human/compat view; NOT loaded by the pipeline
1537
+ # phases — they load the halves directly). No model. If a half is absent or still
1538
+ # an unauthored template, it is skipped; a technical-only project's composed file
1539
+ # mirrors the technical half exactly.
1540
+ #
1541
+ # Order is technical → domain → ui. The domain half's principles CONTINUE the
1542
+ # technical base's roman numerals from wherever that base ended, so those two stay
1543
+ # adjacent; the UI half runs a parallel UX-* series and appends without interrupting
1544
+ # that run.
1545
+ #
1546
+ # NUMBERING CHECK. The boundary between the two roman-numeral halves is derived per
1547
+ # project — the technical half runs to whatever length its mapped technology base
1548
+ # defines — so a base of eighteen principles and a domain half that assumed it could
1549
+ # start at XVI would silently produce two Principle XVIs. Composition is the one place
1550
+ # both halves are read together, so it is where that is caught. A collision sets
1551
+ # numbering_ok=false; the file is still written, because a composed view that shows the
1552
+ # clash is more useful for fixing it than no file at all.
1553
+ #
1554
+ # UNFILLED-PLACEHOLDER TEST. "Still a raw template" is decided by intersecting the
1555
+ # half's [TOKEN]s with the TOKENS ITS OWN KIT TEMPLATE DEFINES — never by the shape of
1556
+ # the bracket. A constitution is free to write [HIPAA], [ISO_13485] or a [GDPR](url)
1557
+ # link; those are project content and match no template token, so they can no longer
1558
+ # make a whole ratified half vanish from the composed file.
1559
+ cmd_compose_constitution() {
1560
+ while [ $# -gt 0 ]; do
1561
+ case "$1" in
1562
+ --help|-h) echo "compose-constitution"; return 0 ;;
1563
+ *) echo "compose-constitution: unknown arg '$1'" >&2; return 1 ;;
1564
+ esac
1565
+ done
1566
+ local repo_root; repo_root=$(get_repo_root)
1567
+ local tpl_tech tpl_dom tpl_ui
1568
+ tpl_tech=$(resolve_template "constitution-technical-template" "$repo_root") || true
1569
+ tpl_dom=$(resolve_template "constitution-domain-template" "$repo_root") || true
1570
+ tpl_ui=$(resolve_template "constitution-ui-template" "$repo_root") || true
1571
+ AW_MEM="$repo_root/.appweaver/memory" \
1572
+ AW_TPL_TECH="$tpl_tech" AW_TPL_DOM="$tpl_dom" AW_TPL_UI="$tpl_ui" python3 - <<'PY'
1573
+ import json, os, re
1574
+
1575
+ mem = os.environ["AW_MEM"]
1576
+ tech = os.path.join(mem, "constitution.technical.md")
1577
+ dom = os.path.join(mem, "constitution.domain.md")
1578
+ ui = os.path.join(mem, "constitution.ui.md")
1579
+ out = os.path.join(mem, "constitution.md")
1580
+
1581
+ include_tech = os.path.exists(tech)
1582
+
1583
+ # A project that declared no SERVICE lane authors no technical half, and that is a
1584
+ # complete constitution rather than a broken one — a UI-only project's governance is
1585
+ # the UI half plus the domain addenda. What is not composable is a project with
1586
+ # nothing at all, which means the halves were never authored.
1587
+ if not (include_tech or os.path.exists(dom) or os.path.exists(ui)):
1588
+ print(json.dumps({"composed_file": out, "ok": False, "domain_included": False,
1589
+ "ui_included": False, "technical_included": False,
1590
+ "numbering_ok": True, "numbering_checked": False,
1591
+ "issues": ["no constitution half found — run /appweaver constitution"]}))
1592
+ raise SystemExit
1593
+
1594
+ TOKEN = re.compile(r"\[([A-Z][A-Z0-9_]*)\]")
1595
+
1596
+ def read(p):
1597
+ return open(p, encoding="utf-8").read()
1598
+
1599
+ def strip_comments(s):
1600
+ return re.sub(r"<!--.*?-->", "", s, flags=re.S)
1601
+
1602
+ def token_family(tokens):
1603
+ # The template's numbered slots are a series the author is told to extend, so
1604
+ # PRINCIPLE_6_NAME is as much an unfilled slot as PRINCIPLE_1_NAME even though the
1605
+ # template only shows five. Digit runs generalise; nothing else does, which is what
1606
+ # keeps a bracketed regulation code out of the family.
1607
+ return {re.compile(r"\A" + re.sub(r"\d+", r"\\d+", re.escape(t)) + r"\Z")
1608
+ for t in tokens if re.search(r"\d", t)}
1609
+
1610
+ def template_tokens(env):
1611
+ # The kit template's own token vocabulary. None = template unresolvable.
1612
+ p = os.environ.get(env, "")
1613
+ if not p or not os.path.exists(p):
1614
+ return None
1615
+ return set(TOKEN.findall(read(p)))
1616
+
1617
+ def unfilled(found, tokens):
1618
+ fam = token_family(tokens)
1619
+ return sorted({t for t in found
1620
+ if t in tokens or any(rx.match(t) for rx in fam)})
1621
+
1622
+ def authored(p, env):
1623
+ # An unused half stays a raw template. Comments are stripped first so a Sync
1624
+ # Impact Report that NAMES the placeholders it filled ("Removed: [PROJECT_NAME]")
1625
+ # cannot be mistaken for the placeholders themselves.
1626
+ if not (os.path.exists(p) and os.path.getsize(p) > 0):
1627
+ return False
1628
+ tokens = template_tokens(env)
1629
+ if tokens is None:
1630
+ return True # no template to compare against: never drop a half on a guess
1631
+ return not unfilled(set(TOKEN.findall(strip_comments(read(p)))), tokens)
1632
+
1633
+ include_domain = authored(dom, "AW_TPL_DOM")
1634
+ include_ui = authored(ui, "AW_TPL_UI")
1635
+
1636
+ with open(out, "w", encoding="utf-8") as fh:
1637
+ fh.write("<!-- COMPOSED — generated by `aw-spec-actions compose-constitution`. Do not edit here.\n")
1638
+ fh.write(" Source of truth: the constitution halves — technical + domain + ui (edit those).\n")
1639
+ fh.write(" Pipeline phases load the halves directly; this composed view is for humans/compat. -->\n\n")
1640
+ if include_tech:
1641
+ fh.write(read(tech))
1642
+ if include_domain:
1643
+ fh.write("\n\n---\n\n"); fh.write(read(dom))
1644
+ if include_ui:
1645
+ fh.write("\n\n---\n\n"); fh.write(read(ui))
1646
+
1647
+ # ── principle numbering ────────────────────────────────────────────────────
1648
+ VALUES = [(1000,"M"),(900,"CM"),(500,"D"),(400,"CD"),(100,"C"),(90,"XC"),
1649
+ (50,"L"),(40,"XL"),(10,"X"),(9,"IX"),(5,"V"),(4,"IV"),(1,"I")]
1650
+
1651
+ def to_int(s):
1652
+ total, i = 0, 0
1653
+ for val, sym in VALUES:
1654
+ while s[i:i+len(sym)] == sym:
1655
+ total += val; i += len(sym)
1656
+ return total if i == len(s) else None
1657
+
1658
+ def to_roman(n):
1659
+ out_ = ""
1660
+ for val, sym in VALUES:
1661
+ while n >= val:
1662
+ out_ += sym; n -= val
1663
+ return out_
1664
+
1665
+ # A principle heading opens with its numeral: "### VII — Name" or "### VII. Name".
1666
+ # UX-* is a PARALLEL series belonging to the frontend lane and is deliberately not
1667
+ # part of this run, so it is skipped rather than checked against it.
1668
+ HEAD = re.compile(r"^#{2,4}\s+(UX-)?([IVXLCDM]+)\s*[—–\-.:)]", re.M)
1669
+
1670
+ def principles(path):
1671
+ found = []
1672
+ if not os.path.exists(path):
1673
+ return found
1674
+ body = re.sub(r"<!--.*?-->", "", read(path), flags=re.S)
1675
+ for prefix, roman in HEAD.findall(body):
1676
+ if prefix: # UX- series — parallel, not this sequence
1677
+ continue
1678
+ n = to_int(roman)
1679
+ # Round-trip + a sane ceiling keep ordinary words made of roman letters
1680
+ # (and stray four-digit numerals) out of the sequence.
1681
+ if n and n <= 100 and to_roman(n) == roman:
1682
+ found.append((n, roman))
1683
+ return found
1684
+
1685
+ tech_p = principles(tech)
1686
+ dom_p = principles(dom) if include_domain else []
1687
+
1688
+ issues, numbering_ok = [], True
1689
+
1690
+ # The cross-half check can only run on halves whose principles it can actually read.
1691
+ # A half numbered some other way (1., 2. / P-01) parses to nothing, and silence there
1692
+ # would look identical to a clean result — so say so instead of reporting all-clear.
1693
+ NUMBERING_FORM = ("principles must be headed '### I — Title' or '### I. Title' "
1694
+ "(the UI half runs its own UX-I, UX-II, … series)")
1695
+ numbering_checked = True
1696
+ if not include_tech:
1697
+ # No service lane, so no technical half and no boundary to check: the domain
1698
+ # half opens the roman-numeral sequence at I instead of continuing one. Not an
1699
+ # issue, and not a check that silently passed — it is a check with no subject.
1700
+ numbering_checked = False
1701
+ elif not tech_p:
1702
+ numbering_ok = False
1703
+ numbering_checked = False
1704
+ issues.append("no roman-numeral principle headings found in the technical half, so "
1705
+ "numbering could not be verified — " + NUMBERING_FORM)
1706
+ if include_domain and not dom_p:
1707
+ numbering_ok = False
1708
+ numbering_checked = False
1709
+ issues.append("no roman-numeral principle headings found in the domain half, so "
1710
+ "numbering could not be verified — " + NUMBERING_FORM)
1711
+
1712
+ def dupes_within(items, label):
1713
+ global numbering_ok
1714
+ seen, dup = set(), []
1715
+ for n, roman in items:
1716
+ if n in seen and roman not in dup:
1717
+ dup.append(roman)
1718
+ seen.add(n)
1719
+ if dup:
1720
+ numbering_ok = False
1721
+ issues.append("%s half reuses principle number(s): %s" % (label, ", ".join(dup)))
1722
+
1723
+ dupes_within(tech_p, "technical")
1724
+ dupes_within(dom_p, "domain")
1725
+
1726
+ if tech_p and dom_p:
1727
+ overlap = sorted({n for n, _ in tech_p} & {n for n, _ in dom_p})
1728
+ if overlap:
1729
+ numbering_ok = False
1730
+ issues.append(
1731
+ "principle number(s) %s appear in BOTH the technical and domain halves — "
1732
+ "the domain half must resume from the number after the technical half's "
1733
+ "last principle (%s), not from a fixed value"
1734
+ % (", ".join(to_roman(n) for n in overlap), to_roman(max(n for n, _ in tech_p))))
1735
+ else:
1736
+ last_tech = max(n for n, _ in tech_p)
1737
+ first_dom = min(n for n, _ in dom_p)
1738
+ if first_dom != last_tech + 1:
1739
+ numbering_ok = False
1740
+ issues.append(
1741
+ "domain half starts at %s but the technical half ends at %s — numbering "
1742
+ "must run unbroken across the two halves (expected %s)"
1743
+ % (to_roman(first_dom), to_roman(last_tech), to_roman(last_tech + 1)))
1744
+
1745
+ print(json.dumps({"composed_file": out, "ok": numbering_ok,
1746
+ "technical_included": include_tech,
1747
+ "domain_included": include_domain, "ui_included": include_ui,
1748
+ "numbering_ok": numbering_ok, "numbering_checked": numbering_checked,
1749
+ "issues": issues,
1750
+ "technical_last": (to_roman(max(n for n, _ in tech_p)) if tech_p else None),
1751
+ "domain_first": (to_roman(min(n for n, _ in dom_p)) if dom_p else None)}))
1752
+ PY
1753
+ }
1754
+
1755
+ # ─────────────────────────── constitution-verify ───────────────────────────
1756
+ # Deterministic lint of a generated constitution (NO LLM): unfilled template
1757
+ # placeholders, body-vs-Sync-Impact-Report version agreement, and non-ISO dates.
1758
+ # Consumed by the constitution skill's bounded self-correction loop. ok=false ⇒ the
1759
+ # loop asks the model to patch.
1760
+ #
1761
+ # A placeholder is a token THE KIT TEMPLATE ACTUALLY DEFINES, resolved from the half's
1762
+ # own template (override/preset/extension/core, same order as setup-constitution) and
1763
+ # intersected with the file. Bracket SHAPE is not evidence: [HIPAA], [ISO_13485] and
1764
+ # [GDPR](url) are ordinary constitution content in a regulated domain and must never be
1765
+ # reported as unfilled slots. --template overrides the name derived from the filename.
1766
+ cmd_constitution_verify() {
1767
+ local file="" template=""
1768
+ while [ $# -gt 0 ]; do
1769
+ case "$1" in
1770
+ --file) file="$2"; shift 2 ;;
1771
+ --template) template="$2"; shift 2 ;;
1772
+ --help|-h) echo "constitution-verify --file <path> [--template <name>]"; return 0 ;;
1773
+ *) echo "constitution-verify: unknown arg '$1'" >&2; return 1 ;;
1774
+ esac
1775
+ done
1776
+ [ -n "$file" ] || { echo "constitution-verify: --file required" >&2; return 1; }
1777
+ if [ -z "$template" ]; then
1778
+ case "$(basename "$file")" in
1779
+ constitution.technical.md) template="constitution-technical-template" ;;
1780
+ constitution.domain.md) template="constitution-domain-template" ;;
1781
+ constitution.ui.md) template="constitution-ui-template" ;;
1782
+ esac
1783
+ fi
1784
+ local repo_root; repo_root=$(get_repo_root)
1785
+ local tpl_path=""
1786
+ [ -n "$template" ] && { tpl_path=$(resolve_template "$template" "$repo_root") || true; }
1787
+ AW_FILE="$file" AW_TPL="$tpl_path" python3 - <<'PY'
1788
+ import json, os, re
1789
+ f = os.environ["AW_FILE"]
1790
+ if not os.path.exists(f):
1791
+ print(json.dumps({"ok": False, "exists": False,
1792
+ "issues": ["constitution file not found"], "placeholders": [],
1793
+ "version_body": None, "version_report": None,
1794
+ "version_match": None, "bad_dates": []})); raise SystemExit
1795
+ text = open(f, encoding="utf-8").read()
1796
+ # Strip HTML comments (template guidance + the Sync Impact Report) so they do not
1797
+ # trip the body scan; keep the first block as the report for the version check.
1798
+ m = re.search(r"<!--.*?-->", text, re.S)
1799
+ report = m.group(0) if m else ""
1800
+ body = re.sub(r"<!--.*?-->", "", text, flags=re.S)
1801
+ # 1. Unfilled slots = the file's [TOKEN]s ∩ the template's own token vocabulary.
1802
+ TOKEN = re.compile(r"\[([A-Z][A-Z0-9_]*)\]")
1803
+ tpl = os.environ.get("AW_TPL", "")
1804
+ template_missing = not (tpl and os.path.exists(tpl))
1805
+ if template_missing:
1806
+ placeholders = []
1807
+ else:
1808
+ known = set(TOKEN.findall(open(tpl, encoding="utf-8").read()))
1809
+ # Numbered slots generalise — the author is told to extend the series, so an
1810
+ # unfilled PRINCIPLE_6_NAME counts even though the template only shows five.
1811
+ family = {re.compile(r"\A" + re.sub(r"\d+", r"\\d+", re.escape(t)) + r"\Z")
1812
+ for t in known if re.search(r"\d", t)}
1813
+ placeholders = sorted({t for t in TOKEN.findall(body)
1814
+ if t in known or any(rx.match(t) for rx in family)})
1815
+ # 2. Version: the document's own labelled version line vs the report's "new" version.
1816
+ # Anchored to lines that OPEN with the label, so an "OpenAPI version 3.1.0" inside a
1817
+ # principle cannot be mistaken for the constitution's version. The last such line
1818
+ # wins because the templates carry the version in a footer.
1819
+ VERSION_LINE = re.compile(r"^[\s>*_#-]*version\b", re.I)
1820
+ def labeled(s, last_on_line=False):
1821
+ vals = []
1822
+ for line in s.splitlines():
1823
+ if VERSION_LINE.match(line):
1824
+ sem = re.findall(r"\d+\.\d+\.\d+", line)
1825
+ if sem:
1826
+ vals.append(sem[-1] if last_on_line else sem[0])
1827
+ return vals[-1] if vals else None
1828
+ version_body = labeled(body)
1829
+ arrow = re.search(r"(\d+\.\d+\.\d+)\s*(?:->|→|=>)\s*(\d+\.\d+\.\d+)", report)
1830
+ # On the report line the LAST semver is the new version — which also survives an
1831
+ # "old → new" whose arrow was mangled by an encoding round-trip.
1832
+ version_report = arrow.group(2) if arrow else labeled(report, last_on_line=True)
1833
+ version_match = (version_body == version_report) if (version_body and version_report) else None
1834
+ # 3. Obviously non-ISO dates on ratification/amended/date lines (slash forms,
1835
+ # DD-MM-YYYY, US textual). ISO YYYY-MM-DD never matches these patterns.
1836
+ bad = []
1837
+ for line in body.splitlines():
1838
+ if re.search(r"(?i)ratif|amend|\bdate\b", line):
1839
+ bad += re.findall(r"\b\d{1,2}/\d{1,2}/\d{2,4}\b", line)
1840
+ bad += re.findall(r"\b\d{1,2}-\d{1,2}-\d{4}\b", line)
1841
+ bad += re.findall(r"\b(?:January|February|March|April|May|June|July|August|"
1842
+ r"September|October|November|December)\s+\d{1,2},?\s+\d{4}\b", line)
1843
+ bad = sorted(set(bad))
1844
+ issues = []
1845
+ if placeholders:
1846
+ issues.append("%d unfilled placeholder(s): %s" % (len(placeholders), ", ".join(placeholders)))
1847
+ if template_missing:
1848
+ # Reported, not failed: a missing kit template is an install problem, and blocking
1849
+ # the authoring loop over it would help nobody.
1850
+ issues.append("placeholder check skipped — no kit template resolved for this half")
1851
+ if version_match is False:
1852
+ issues.append("version mismatch: body %s vs Sync Impact Report %s" % (version_body, version_report))
1853
+ if bad:
1854
+ issues.append("non-ISO date(s) (use YYYY-MM-DD): %s" % ", ".join(bad))
1855
+ ok = (not placeholders) and (version_match is not False) and (not bad)
1856
+ print(json.dumps({"ok": ok, "exists": True, "issues": issues,
1857
+ "placeholders": placeholders, "placeholder_check": not template_missing,
1858
+ "version_body": version_body,
1859
+ "version_report": version_report, "version_match": version_match,
1860
+ "bad_dates": bad}))
1861
+ PY
1862
+ }
1863
+
1864
+ # ────────────────────────── agent-context-update ───────────────────────────
1865
+ # Run the agent-context extension's updater (refreshes the managed AW-Spec block).
1866
+ cmd_agent_context_update() {
1867
+ local plan=""
1868
+ while [ $# -gt 0 ]; do
1869
+ case "$1" in
1870
+ --plan) plan="$2"; shift 2 ;;
1871
+ --help|-h) echo "agent-context-update [--plan <path>]"; return 0 ;;
1872
+ *) echo "agent-context-update: unknown arg '$1'" >&2; return 1 ;;
1873
+ esac
1874
+ done
1875
+ local repo_root; repo_root=$(get_repo_root)
1876
+ local script="$repo_root/.appweaver/extensions/agent-context/scripts/bash/update-agent-context.sh"
1877
+ if [ ! -f "$script" ]; then
1878
+ printf '{"ok":true,"ran":false,"message":"agent-context extension not installed; nothing to do"}\n'; return 0
1879
+ fi
1880
+ local out rc
1881
+ if [ -n "$plan" ]; then out=$(bash "$script" "$plan" 2>&1); else out=$(bash "$script" 2>&1); fi
1882
+ rc=$?
1883
+ printf '{"ok":%s,"ran":true,"message":"%s"}\n' \
1884
+ "$([ $rc -eq 0 ] && echo true || echo false)" "$(json_escape "$(printf '%s' "$out" | tail -c 400)")"
1885
+ }
1886
+
1887
+
1888
+
1889
+ # ─────────────────────────────── list-components ───────────────────────────
1890
+ # Immediate subdirs of foundations/<ecosystem>/components/ that contain an integration.md
1891
+ # runbook, for the ecosystem the BACKEND lane declares. Components are wired into a host
1892
+ # project by editing its build files and its startup registration, so a component only
1893
+ # exists for the ecosystem whose build files and startup it knows how to edit — the
1894
+ # catalogue is per-ecosystem for the same reason the foundations are.
1895
+ cmd_list_components() {
1896
+ while [ $# -gt 0 ]; do
1897
+ case "$1" in
1898
+ --help|-h) echo "list-components -> {components[], ecosystem, dir}"; return 0 ;;
1899
+ *) echo "list-components: unknown arg '$1'" >&2; return 1 ;;
1900
+ esac
1901
+ done
1902
+ local repo_root; repo_root=$(get_repo_root)
1903
+ local eco; eco=$(_aw_lane_field backend ecosystem)
1904
+ # No backend lane yet: `dotnet` is the only ecosystem that ships components, so it is
1905
+ # what an undeclared repo would have been shown before the lane model and stays the
1906
+ # answer until select-stack says otherwise.
1907
+ [ -z "$eco" ] && eco="dotnet"
1908
+ local base="$repo_root/foundations/$eco/components"
1909
+ local -a comps=()
1910
+ if [ -d "$base" ]; then
1911
+ local d
1912
+ for d in "$base"/*/; do
1913
+ [ -d "$d" ] || continue
1914
+ [ -f "$d/integration.md" ] && comps+=("$(basename "$d")")
1915
+ done
1916
+ fi
1917
+ printf '{"components":%s,"ecosystem":"%s","dir":"foundations/%s/components"}\n' \
1918
+ "$(json_str_array "${comps[@]}")" "$(json_escape "$eco")" "$(json_escape "$eco")"
1919
+ }
1920
+
1921
+ # ─────────────────────────────── find-host-project ─────────────────────────
1922
+ # Locate the .NET host/startup project by finding Program.cs (never assume a name).
1923
+ #
1924
+ # DELIBERATELY .NET-ONLY, and it reports which stack it looked at so a caller can say
1925
+ # so. `Program.cs` is not a cross-stack concept: a Spring lane's entry point is an
1926
+ # @SpringBootApplication class, an Express lane's is whatever `main` in package.json
1927
+ # names, and neither has the .csproj this returns. Callers that need a LAUNCH COMMAND
1928
+ # ask the lane instead (its declared `run`); this exists for the two callers that need
1929
+ # the .NET host DIRECTORY — add-component, which edits its registration, and run, which
1930
+ # names it for information. found:false on a non-.NET lane is the correct answer, and
1931
+ # `ecosystem` is what lets the message say "this is a java lane" instead of "no project".
1932
+ cmd_find_host_project() {
1933
+ while [ $# -gt 0 ]; do
1934
+ case "$1" in
1935
+ --help|-h) echo "find-host-project"; return 0 ;;
1936
+ *) echo "find-host-project: unknown arg '$1'" >&2; return 1 ;;
1937
+ esac
1938
+ done
1939
+ local repo_root; repo_root=$(get_repo_root)
1940
+ local prog
1941
+ # WHERE the host lives. The service lane's declared source path when there is one,
1942
+ # `src/` when there is not — the same question run-migrate asks, and the same answer.
1943
+ # A repo whose service sits in `backend/` or `Legacy/App/` is searched where its code
1944
+ # actually is instead of being reported as having no host project at all.
1945
+ local svc_root; svc_root=$(_aw_lane_root backend)
1946
+ [ -z "$svc_root" ] && svc_root="src"
1947
+ local eco; eco=$(_aw_lane_field backend ecosystem)
1948
+ prog=$(find "$repo_root/${svc_root%/}" -name "Program.cs" 2>/dev/null | head -1)
1949
+ [ -z "$prog" ] && prog=$(find "$repo_root" \( -path "$repo_root/.appweaver" -o -path "$repo_root/guardrails" -o -path "$repo_root/foundations" -o -path "$repo_root/specs" -o -path "$repo_root/.git" -o -name obj -o -name bin \) -prune -o -name "Program.cs" -print 2>/dev/null | head -1)
1950
+ if [ -z "$prog" ]; then
1951
+ printf '{"found":false,"program_cs":"","project_dir":"","csproj":"","ecosystem":"%s","searched":"%s"}\n' \
1952
+ "$(json_escape "$eco")" "$(json_escape "${svc_root%/}")"
1953
+ return 0
1954
+ fi
1955
+ local pdir; pdir=$(dirname "$prog")
1956
+ local csproj; csproj=$(find "$pdir" -maxdepth 1 -name "*.csproj" 2>/dev/null | head -1)
1957
+ printf '{"found":true,"program_cs":"%s","project_dir":"%s","csproj":"%s","ecosystem":"%s","searched":"%s"}\n' \
1958
+ "$(json_escape "$prog")" "$(json_escape "$pdir")" "$(json_escape "$csproj")" \
1959
+ "$(json_escape "$eco")" "$(json_escape "${svc_root%/}")"
1960
+ }
1961
+
1962
+ # ─────────────────────────────── component-catalog ──────────────────────────
1963
+ # Deterministic parse of a component's integration.md "Implementation catalog
1964
+ # (selection list)" markdown table (NO LLM). Replaces the extract_catalog llm_query.
1965
+ # Returns {integrable, impl_keys[], implementations[{key,desc}]}.
1966
+ #
1967
+ # Delegates to the shared python core so the bash and powershell twins cannot drift:
1968
+ # the parse and the JSON contract have one implementation.
1969
+ cmd_component_catalog() {
1970
+ local file=""
1971
+ while [ $# -gt 0 ]; do
1972
+ case "$1" in
1973
+ --file) file="$2"; shift 2 ;;
1974
+ --help|-h) echo "component-catalog --file <integration.md>"; return 0 ;;
1975
+ *) echo "component-catalog: unknown arg '$1'" >&2; return 1 ;;
1976
+ esac
1977
+ done
1978
+ [ -n "$file" ] || { echo "component-catalog: --file required" >&2; return 1; }
1979
+ local repo_root; repo_root=$(get_repo_root)
1980
+ local core="$SCRIPT_DIR/../python/aw-component-catalog.py"
1981
+ if [ ! -f "$core" ]; then
1982
+ echo "component-catalog: core not found at $core" >&2; return 1
1983
+ fi
1984
+ if ! command -v python3 >/dev/null 2>&1; then
1985
+ echo "component-catalog: python3 is required to parse the catalog" >&2; return 1
1986
+ fi
1987
+ python3 "$core" --repo "$repo_root" --file "$file"
1988
+ }
1989
+
1990
+ # ─────────────────────────────── component-verify ───────────────────────────
1991
+ # Post-wiring verification (NO LLM decision): (1) `dotnet build` the host project
1992
+ # (graceful skip if the SDK is absent); (2) R0.2a dual-registration guard — when the
1993
+ # host has BOTH AddInfrastructure and AddAspireInfrastructure, assert `--marker`
1994
+ # appears in both. Returns {ok, ran, build_ok, errors[], dual_registration_ok,
1995
+ # fix_target, fix_reason, can_fix} for a bounded fix loop.
1996
+ cmd_component_verify() {
1997
+ local host_dir="" host_csproj="" marker=""
1998
+ while [ $# -gt 0 ]; do
1999
+ case "$1" in
2000
+ --host-dir) host_dir="$2"; shift 2 ;;
2001
+ --host-csproj) host_csproj="$2"; shift 2 ;;
2002
+ --marker) marker="$2"; shift 2 ;;
2003
+ --help|-h) echo "component-verify --host-dir <dir> [--host-csproj <path>] [--marker <token>]"; return 0 ;;
2004
+ *) echo "component-verify: unknown arg '$1'" >&2; return 1 ;;
2005
+ esac
2006
+ done
2007
+ local repo_root; repo_root=$(get_repo_root)
2008
+ AW_REPO="$repo_root" AW_HOST_DIR="$host_dir" AW_HOST_CSPROJ="$host_csproj" AW_MARKER="$marker" python3 - <<'PY'
2009
+ import json, os, re, shutil, subprocess
2010
+ repo = os.environ.get("AW_REPO", "")
2011
+ hostd = os.environ.get("AW_HOST_DIR", "")
2012
+ csproj = os.environ.get("AW_HOST_CSPROJ", "")
2013
+ marker = os.environ.get("AW_MARKER", "").strip()
2014
+ def relp(p):
2015
+ try: return os.path.relpath(p, repo).replace("\\", "/") if (repo and p) else p
2016
+ except Exception: return p
2017
+ # --- (1) build ---
2018
+ ran, build_ok, errors, primary = False, True, [], ""
2019
+ if shutil.which("dotnet"):
2020
+ target = csproj if csproj else hostd
2021
+ if target and os.path.exists(target):
2022
+ ran = True
2023
+ proc = subprocess.run(["dotnet", "build", target, "-nologo"],
2024
+ capture_output=True, text=True)
2025
+ seen = set()
2026
+ for line in (proc.stdout + "\n" + proc.stderr).splitlines():
2027
+ if re.search(r':\s*error\s', line):
2028
+ t = line.strip()
2029
+ if t not in seen:
2030
+ seen.add(t); errors.append(t)
2031
+ errors = errors[:20]
2032
+ build_ok = (proc.returncode == 0)
2033
+ if not build_ok and errors:
2034
+ m = re.match(r'^(.*?)\(\d+,\d+\)', errors[0])
2035
+ if m: primary = relp(m.group(1).strip())
2036
+ if not build_ok and not primary and csproj:
2037
+ primary = relp(csproj)
2038
+ # --- (2) R0.2a dual-registration guard ---
2039
+ def defining_files(method):
2040
+ root = hostd if (hostd and os.path.isdir(hostd)) else repo
2041
+ hits = []
2042
+ if not root: return hits
2043
+ pat = re.compile(r'\b' + re.escape(method) + r'\s*\(')
2044
+ for dp, dns, fns in os.walk(root):
2045
+ dns[:] = [d for d in dns if d not in ("bin", "obj", ".git")]
2046
+ for fn in fns:
2047
+ if fn.endswith(".cs"):
2048
+ fp = os.path.join(dp, fn)
2049
+ try: txt = open(fp, encoding="utf-8", errors="ignore").read()
2050
+ except Exception: continue
2051
+ if pat.search(txt): hits.append((fp, txt))
2052
+ return hits
2053
+ infra, aspire = defining_files("AddInfrastructure"), defining_files("AddAspireInfrastructure")
2054
+ dual_app = bool(infra) and bool(aspire)
2055
+ dual_ok, dual_note, dual_fix = True, "", ""
2056
+ def pick_fix(hits):
2057
+ non_prog = [fp for fp, _ in hits if os.path.basename(fp) != "Program.cs"]
2058
+ if non_prog: return relp(non_prog[0])
2059
+ return relp(hits[0][0]) if hits else ""
2060
+ if not dual_app:
2061
+ dual_note = "single registration path (R0.2) — dual-registration check N/A"
2062
+ elif not marker:
2063
+ dual_note = "both AddInfrastructure and AddAspireInfrastructure exist; no marker provided to verify the component is in both"
2064
+ else:
2065
+ in_infra = any(marker in txt for _, txt in infra)
2066
+ in_aspire = any(marker in txt for _, txt in aspire)
2067
+ dual_ok = in_infra and in_aspire
2068
+ if not dual_ok:
2069
+ missing = "AddInfrastructure" if not in_infra else "AddAspireInfrastructure"
2070
+ dual_fix = pick_fix(infra if not in_infra else aspire)
2071
+ dual_note = "marker '%s' missing from %s (R0.2a: register in BOTH paths)" % (marker, missing)
2072
+ else:
2073
+ dual_note = "marker '%s' present in both registration paths" % marker
2074
+ ok = build_ok and dual_ok
2075
+ fix_target, fix_reason = "", ""
2076
+ if not build_ok:
2077
+ fix_target = primary or (relp(csproj) if csproj else "")
2078
+ fix_reason = "build failed: " + " | ".join(errors[:5])
2079
+ elif not dual_ok:
2080
+ fix_target, fix_reason = dual_fix, dual_note
2081
+ print(json.dumps({"ok": ok, "ran": ran, "build_ok": build_ok, "errors": errors,
2082
+ "primary_error_file": primary, "dual_applicable": dual_app,
2083
+ "dual_registration_ok": dual_ok, "dual_note": dual_note,
2084
+ "dual_fix_file": dual_fix, "fix_target": fix_target,
2085
+ "fix_reason": fix_reason, "can_fix": bool(fix_target)}))
2086
+ PY
2087
+ }
2088
+
2089
+ # ─────────────────────────────────── main ──────────────────────────────────
2090
+ sub="${1:-}"; shift || true
2091
+ # ──────────────────────────────── design-scaffold ──────────────────────────
2092
+ # The spec-anchored Design phase: ONE high-level design doc per feature
2093
+ # (design.md) — a tech-agnostic HLD that `implement` GENERATES code from (not a
2094
+ # deterministic extract). See .appweaver/DESIGN-FORMAT.md + design-template.md.
2095
+ #
2096
+ # The former path-tagged helpers (design-extract / -reconcile / -paths) are GONE.
2097
+ # They belonged to a retired four-document model in which design.{domain,application,
2098
+ # api,infra}.md carried the source inside ```lang path=… fences. Nothing emits that
2099
+ # fence any more, and their glob (`design.*.md`) could not match a single `design.md`
2100
+ # even if something did — so all three had been returning nothing, silently, at exit 0.
2101
+ # The question they answered ("which files is this slice made of") is now answered by
2102
+ # the OKF concept: `aw-xref concept-paths` to read, `concept-upsert --merge` to write.
2103
+
2104
+ # Copy the single HLD design template into the feature dir as design.md (idempotent).
2105
+ cmd_design_scaffold() {
2106
+ local feature_dir_arg="" template_arg="" lane_arg="service"
2107
+ while [ $# -gt 0 ]; do
2108
+ case "$1" in
2109
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
2110
+ # --lane service|ui: WHOSE design this is. It selects a coherent
2111
+ # (template, document) PAIR rather than two independent flags — service
2112
+ # scaffolds design-template into design.md, ui scaffolds
2113
+ # ui-design-template into design.ui.md — so no caller can write the
2114
+ # service template under the UI lane's filename. Default `service`
2115
+ # leaves every existing caller byte-identical.
2116
+ --lane) lane_arg="$2"; shift 2 ;;
2117
+ # --template <name>: override the TEMPLATE half of that pair (the document
2118
+ # name still follows --lane). In a brownfield repo /appweaver onboard
2119
+ # GENERATES a code-derived override for both names (resolve_template
2120
+ # Priority-1), so the defaults already yield the as-built structure for
2121
+ # forward `design` and adopt alike, and this is rarely needed.
2122
+ --template) template_arg="$2"; shift 2 ;;
2123
+ --help|-h) echo "design-scaffold --feature-dir <path> [--lane service|ui] [--template <name>]"; return 0 ;;
2124
+ *) echo "design-scaffold: unknown arg '$1'" >&2; return 1 ;;
2125
+ esac
2126
+ done
2127
+ local doc_name
2128
+ case "$lane_arg" in
2129
+ service) doc_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
2130
+ ui) doc_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
2131
+ *) echo "design-scaffold: unknown --lane '$lane_arg' (service|ui)" >&2; return 1 ;;
2132
+ esac
2133
+ local repo_root; repo_root=$(get_repo_root)
2134
+ local fd="$feature_dir_arg"
2135
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
2136
+ [ -n "$fd" ] || { echo "design-scaffold: no feature set (run /appweaver select-feature first) and no --feature-dir" >&2; return 1; }
2137
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
2138
+ mkdir -p "$fd"
2139
+
2140
+ local target="$fd/$doc_name" created="false" tpl
2141
+ if [ -f "$target" ]; then
2142
+ created="false"
2143
+ else
2144
+ tpl=$(resolve_template "$template_arg" "$repo_root") || true
2145
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$target"; else touch "$target"; fi
2146
+ created="true"
2147
+ fi
2148
+ # override_active: is a PROJECT-level override installed for this template name?
2149
+ # Computed ALWAYS (independent of the idempotent scaffold skip above) so it is a
2150
+ # stable signal on both the bootstrap AND delta paths. In the brownfield-dev flow,
2151
+ # /appweaver onboard GENERATES the code-derived `design-template` override — so
2152
+ # override_active=true means the forward `design` phase should author with the
2153
+ # (code-derived) as-built structure, not the greenfield four-layer layout.
2154
+ local override_active="false"
2155
+ [ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
2156
+ local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
2157
+ printf '{"feature_dir":"%s","design_doc":"%s","lane":"%s","created":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
2158
+ "$(json_escape "$fd")" "$(json_escape "$target")" "$(json_escape "$lane_arg")" "$created" "$override_active" \
2159
+ "$(json_escape "$template_arg")" "$(json_escape "${template_source:-}")"
2160
+ }
2161
+
2162
+ # ─────────────────────── build / test / migrate / deploy ────────────────────
2163
+ # Detect the project's toolchain, run the phase command, and report structured
2164
+ # status. Tolerant: an unrecognized stack is a no-op pass (wire a hook instead).
2165
+ # The FIX is an LLM step in the workflow; these only run + report.
2166
+
2167
+ # Is a lane declared in .appweaver/stacks.json? The question every shell-out step asks
2168
+ # before it starts sniffing. Prints "true"/"" so a caller can test it directly.
2169
+ _aw_lanes_declared() {
2170
+ _aw_stack_py resolve 2>/dev/null | python3 -c '
2171
+ import json, sys
2172
+ try:
2173
+ print("true" if json.loads(sys.stdin.readline()).get("lanes") else "")
2174
+ except Exception:
2175
+ print("")
2176
+ ' 2>/dev/null || echo ""
2177
+ }
2178
+
2179
+ cmd_build_compile() {
2180
+ while [ $# -gt 0 ]; do
2181
+ case "$1" in
2182
+ --feature-dir) shift 2 ;;
2183
+ --help|-h) echo "build-compile [--feature-dir <path>]"; return 0 ;;
2184
+ *) echo "build-compile: unknown arg '$1'" >&2; return 1 ;;
2185
+ esac
2186
+ done
2187
+
2188
+ # A DECLARED lane model is the answer, and it is a better answer than the
2189
+ # ladder below in exactly the case that matters: a repo with two lanes. The
2190
+ # sniffer takes the first toolchain it finds and silently never builds the
2191
+ # second, which reports green for a frontend that was never compiled.
2192
+ if [ -n "$(_aw_lanes_declared)" ]; then
2193
+ _aw_stack_py run-phase --phase build
2194
+ return $?
2195
+ fi
2196
+
2197
+ # No declaration: the historic first-match-wins ladder, unchanged. A project
2198
+ # that never ran select-stack behaves exactly as it did before the lane model.
2199
+ local repo_root; repo_root=$(get_repo_root)
2200
+ local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
2201
+ if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root/src" -maxdepth 4 -name "*.csproj" 2>/dev/null | grep -q .; then
2202
+ tool="dotnet"
2203
+ if command -v dotnet >/dev/null 2>&1; then
2204
+ cmd="dotnet build --nologo"; ran=true
2205
+ if ( cd "$repo_root" && dotnet build --nologo ) >"$log" 2>&1; then ok=true; else ok=false; fi
2206
+ else ok=false; fi
2207
+ elif [ -f "$repo_root/package.json" ] && command -v npm >/dev/null 2>&1; then
2208
+ tool="npm"; cmd="npm run build --if-present"; ran=true
2209
+ if ( cd "$repo_root" && npm run build --if-present ) >"$log" 2>&1; then ok=true; else ok=false; fi
2210
+ else
2211
+ tool="none"; ran=false; ok=true
2212
+ fi
2213
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
2214
+ printf '{"ok":%s,"tool":"%s","ran":%s,"cmd":"%s","log_tail":"%s"}\n' \
2215
+ "$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$cmd")" "$(json_escape "$t")"
2216
+ }
2217
+
2218
+ cmd_test_run() {
2219
+ # --scope repo (default): run the whole suite — the historic behaviour, every
2220
+ # existing caller is unaffected.
2221
+ # --scope slice : run ONLY the tests whose FullyQualifiedName contains the
2222
+ # feature's aggregate name (feature-dir basename) plus any --also
2223
+ # referrer names — the per-feature integrity scope. .NET only; npm/none
2224
+ # ignore it and run the full suite.
2225
+ local feature_dir="" scope="repo" also=""
2226
+ while [ $# -gt 0 ]; do
2227
+ case "$1" in
2228
+ --feature-dir) feature_dir="$2"; shift 2 ;;
2229
+ --scope) scope="$2"; shift 2 ;;
2230
+ --also) also="$2"; shift 2 ;;
2231
+ --help|-h) echo "test-run [--feature-dir <path>] [--scope slice|repo] [--also <csv>]"; return 0 ;;
2232
+ *) echo "test-run: unknown arg '$1'" >&2; return 1 ;;
2233
+ esac
2234
+ done
2235
+ local repo_root; repo_root=$(get_repo_root)
2236
+
2237
+ # For scope=slice, build the token set = feature aggregate name + --also referrers,
2238
+ # each sanitised to an identifier (VSTest filter operators |&()!=~ are illegal) and
2239
+ # de-duplicated. A token that survives sanitising becomes a `FullyQualifiedName~<tok>`
2240
+ # clause; substring match is deliberate — it over-includes (safe for an integrity
2241
+ # fence) rather than risking an under-scoped miss.
2242
+ local slices="" name tok also_sp
2243
+ if [ "$scope" = "slice" ]; then
2244
+ also_sp=$(printf '%s' "$also" | tr ',' ' ')
2245
+ for name in "$(basename "${feature_dir%/}")" $also_sp; do
2246
+ tok=$(printf '%s' "$name" | tr -cd 'A-Za-z0-9._-')
2247
+ case "$tok" in [A-Za-z]*) ;; *) continue ;; esac # must start with a letter
2248
+ case " $slices " in *" $tok "*) continue ;; esac # de-dup
2249
+ slices="${slices:+$slices }$tok"
2250
+ done
2251
+ fi
2252
+
2253
+ local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
2254
+ local matched=false scoped=false fence_green=false filter="" t2
2255
+
2256
+ local dotnet_present=false
2257
+ if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root" -maxdepth 5 -name "*.csproj" 2>/dev/null | grep -q .; then
2258
+ dotnet_present=true
2259
+ fi
2260
+
2261
+ # A DECLARED lane model answers this the same way it answers build-compile, and for
2262
+ # the same reason: the ladder below recognises .NET and npm-at-the-root, and reports
2263
+ # `tool:"none" ran:false ok:true` for everything else. On a repo whose lane declares
2264
+ # `./mvnw test` that is a suite which never ran scoring the integrity fence GREEN —
2265
+ # the vacuous pass this command guards against everywhere else.
2266
+ #
2267
+ # scope=slice keeps the .NET path whenever a .NET toolchain is present, because
2268
+ # VSTest's --filter is the only per-test selector any shipped profile has. Elsewhere a
2269
+ # slice run delegates and widens to the lane's whole suite, which the fence tolerates
2270
+ # by design — it over-includes rather than risking an under-scoped miss — and reports
2271
+ # scoped=false so fence_green comes from `ok` rather than from the zero-match guard.
2272
+ local use_lanes=false
2273
+ if [ -n "$(_aw_lanes_declared)" ]; then
2274
+ if [ "$scope" != "slice" ] || [ "$dotnet_present" = false ]; then use_lanes=true; fi
2275
+ fi
2276
+ if [ "$use_lanes" = true ]; then
2277
+ rm -f "$log"
2278
+ _aw_stack_py run-phase --phase test | AW_SCOPE="$scope" AW_SLICES="$slices" python3 -c '
2279
+ import json, os, re, sys
2280
+ d = json.loads(sys.stdin.readline() or "{}")
2281
+ log, ok = d.get("log_tail", ""), bool(d.get("ok"))
2282
+ print(json.dumps({
2283
+ "ok": ok, "tool": d.get("tool", "lanes"), "ran": bool(d.get("ran")),
2284
+ "scope": os.environ.get("AW_SCOPE", "repo"), "slices": os.environ.get("AW_SLICES", ""),
2285
+ "scoped": False, "matched": bool(re.search(r"Total:\s*[1-9]", log)),
2286
+ "fence_green": ok, "cmd": d.get("cmd", ""), "log_tail": log,
2287
+ "lanes_run": d.get("lanes_run", []), "lanes_skipped": d.get("lanes_skipped", []),
2288
+ }))
2289
+ '
2290
+ return 0
2291
+ fi
2292
+
2293
+ if [ "$dotnet_present" = true ]; then
2294
+ tool="dotnet"
2295
+ if command -v dotnet >/dev/null 2>&1; then
2296
+ if [ "$scope" = "slice" ]; then
2297
+ if [ -z "$slices" ]; then
2298
+ # scope=slice but nothing valid to filter on — fail loudly, never
2299
+ # silently fall back to a repo-wide run.
2300
+ printf 'scope=slice but no valid filter tokens from --feature-dir/--also\n' >"$log"
2301
+ ok=false; ran=false; cmd="(no filter tokens)"
2302
+ else
2303
+ for t2 in $slices; do filter="${filter:+$filter|}FullyQualifiedName~$t2"; done
2304
+ scoped=true; cmd="dotnet test --nologo --filter \"$filter\""; ran=true
2305
+ if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else ok=false; fi
2306
+ fi
2307
+ else
2308
+ cmd="dotnet test --nologo"; ran=true
2309
+ if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else ok=false; fi
2310
+ fi
2311
+ else ok=false; fi
2312
+ elif [ -f "$repo_root/package.json" ] && command -v npm >/dev/null 2>&1; then
2313
+ tool="npm"; cmd="npm test"; ran=true
2314
+ if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else ok=false; fi
2315
+ else
2316
+ tool="none"; ran=false; ok=true
2317
+ fi
2318
+
2319
+ # matched = at least one test was actually selected + run. Read from the LOG, never
2320
+ # the exit code: a .NET --filter that selects ZERO tests still EXITS 0, so trusting
2321
+ # the exit code would report an empty scoped run as green (the vacuous-pass trap).
2322
+ if grep -Eq 'Total:[[:space:]]*[1-9]' "$log" 2>/dev/null; then matched=true; fi
2323
+
2324
+ # fence_green — a green that actually proves something ran.
2325
+ # scoped run → ok AND matched (guards the zero-match vacuous pass)
2326
+ # everything else (repo suite, npm, no test tool, the loud no-token fail) → ok,
2327
+ # preserving the historic pass-through for a project with no/zero tests.
2328
+ if [ "$scoped" = true ]; then
2329
+ if [ "$ok" = true ] && [ "$matched" = true ]; then fence_green=true; fi
2330
+ else
2331
+ fence_green=$ok
2332
+ fi
2333
+
2334
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
2335
+ printf '{"ok":%s,"tool":"%s","ran":%s,"scope":"%s","slices":"%s","scoped":%s,"matched":%s,"fence_green":%s,"cmd":"%s","log_tail":"%s"}\n' \
2336
+ "$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$scope")" "$(json_escape "$slices")" \
2337
+ "$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")"
2338
+ }
2339
+
2340
+ cmd_run_migrate() {
2341
+ while [ $# -gt 0 ]; do
2342
+ case "$1" in
2343
+ --feature-dir) shift 2 ;;
2344
+ --help|-h) echo "run-migrate [--feature-dir <path>]"; return 0 ;;
2345
+ *) echo "run-migrate: unknown arg '$1'" >&2; return 1 ;;
2346
+ esac
2347
+ done
2348
+ local repo_root; repo_root=$(get_repo_root)
2349
+
2350
+ # A DECLARED migration command wins outright. Schema migration is the phase stacks
2351
+ # answer most differently — Flyway, Liquibase, Alembic, Prisma, golang-migrate — and
2352
+ # no sniffer can pick between tools that happily coexist in one repo. When a lane
2353
+ # declares `migrate` in .appweaver/stacks.json, that IS the answer and it runs in the
2354
+ # lane's own working directory like every other phase command.
2355
+ if [ -n "$(_aw_lane_field backend migrate)$(_aw_lane_field frontend migrate)" ]; then
2356
+ _aw_stack_py run-phase --phase migrate | python3 -c '
2357
+ import json, sys
2358
+ d = json.loads(sys.stdin.readline() or "{}")
2359
+ print(json.dumps({"ok": bool(d.get("ok")), "tool": "lanes", "ran": bool(d.get("ran")),
2360
+ "cmd": d.get("cmd", ""), "log_tail": d.get("log_tail", ""),
2361
+ "lanes_run": d.get("lanes_run", [])}))
2362
+ '
2363
+ return 0
2364
+ fi
2365
+
2366
+ # WHERE to look for the migration project. The service lane's declared source
2367
+ # path when there is one, `src/` when there is not. Migrations belong to the
2368
+ # backend lane by definition, so a repo whose service code sits in `Legacy/App/`
2369
+ # or `backend/` gets searched where its code actually is instead of being told
2370
+ # it has no .NET project at all.
2371
+ local svc_root; svc_root=$(_aw_lane_root backend)
2372
+ [ -z "$svc_root" ] && svc_root="src"
2373
+ local svc_dir="$repo_root/${svc_root%/}"
2374
+
2375
+ local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
2376
+ local has_dotnet=false
2377
+ if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$svc_dir" -maxdepth 4 -name "*.csproj" 2>/dev/null | grep -q .; then has_dotnet=true; fi
2378
+ if [ "$has_dotnet" = true ] && command -v dotnet >/dev/null 2>&1 && ( cd "$repo_root" && dotnet ef --version >/dev/null 2>&1 ); then
2379
+ # Prefer *Service.csproj (API host with ApplicationDbContext); fall back to first
2380
+ # non-AppHost/Web/ServiceDefaults/test project under the service lane's root.
2381
+ local project=""
2382
+ project=$(find "$svc_dir" -maxdepth 3 -name '*.Service.csproj' 2>/dev/null | head -1)
2383
+ if [ -z "$project" ]; then
2384
+ project=$(find "$svc_dir" -maxdepth 3 -name '*.csproj' 2>/dev/null \
2385
+ | grep -vE 'AppHost|ServiceDefaults|\.Web\.|Tests' | head -1)
2386
+ fi
2387
+ if [ -n "$project" ]; then
2388
+ # Multi-context solutions need an explicit context (ApplicationDbContext is the app model).
2389
+ tool="dotnet-ef"
2390
+ cmd="dotnet ef database update --project $project --context ApplicationDbContext"
2391
+ ran=true
2392
+ if ( cd "$repo_root" && dotnet ef database update --project "$project" --context ApplicationDbContext ) >"$log" 2>&1; then
2393
+ ok=true
2394
+ else
2395
+ ok=false
2396
+ fi
2397
+ else
2398
+ tool="dotnet-ef"; cmd="dotnet ef database update"; ran=true
2399
+ if ( cd "$repo_root" && dotnet ef database update ) >"$log" 2>&1; then ok=true; else ok=false; fi
2400
+ fi
2401
+ else
2402
+ # No lane declares `migrate` and there is no dotnet-ef to fall back on, so there
2403
+ # is genuinely nothing to run. ok stays true: a repo with no migrations is a
2404
+ # normal state, not a failure — but `ran:false` says so rather than implying a
2405
+ # migration succeeded.
2406
+ tool="none"; ran=false; ok=true
2407
+ fi
2408
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
2409
+ printf '{"ok":%s,"tool":"%s","ran":%s,"cmd":"%s","log_tail":"%s"}\n' \
2410
+ "$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$cmd")" "$(json_escape "$t")"
2411
+ }
2412
+
2413
+ # Deploy is intentionally config-driven: reads .appweaver/deploy.json {"command": "..."}.
2414
+ cmd_deploy_dispatch() {
2415
+ while [ $# -gt 0 ]; do
2416
+ case "$1" in
2417
+ --help|-h) echo "deploy-dispatch"; return 0 ;;
2418
+ *) echo "deploy-dispatch: unknown arg '$1'" >&2; return 1 ;;
2419
+ esac
2420
+ done
2421
+ local repo_root; repo_root=$(get_repo_root)
2422
+ local cfg="$repo_root/.appweaver/deploy.json"
2423
+ local configured=false ran=false ok=true cmd="" log; log=$(mktemp)
2424
+ if [ -f "$cfg" ] && command -v python3 >/dev/null 2>&1; then
2425
+ cmd=$(AW_CFG="$cfg" python3 -c "import json,os;print(json.load(open(os.environ['AW_CFG'])).get('command',''))" 2>/dev/null || true)
2426
+ fi
2427
+ if [ -n "$cmd" ]; then
2428
+ configured=true; ran=true
2429
+ if ( cd "$repo_root" && bash -c "$cmd" ) >"$log" 2>&1; then ok=true; else ok=false; fi
2430
+ fi
2431
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
2432
+ printf '{"ok":%s,"configured":%s,"ran":%s,"cmd":"%s","log_tail":"%s"}\n' \
2433
+ "$ok" "$configured" "$ran" "$(json_escape "$cmd")" "$(json_escape "$t")"
2434
+ }
2435
+
2436
+ # ─────────────────────────────── cr-setup / cr-archive ──────────────────────
2437
+ # Change-request scaffolding: a delta lives under <feature>/changes/<cr-id>/,
2438
+ # archives to <feature>/changes/archive/<cr-id>/, and bumps the version on close.
2439
+ # Single-active-change guard: cr-setup refuses to open a 2nd concurrent change on
2440
+ # the same aggregate (design must close the open one first) unless --force.
2441
+ cmd_cr_setup() {
2442
+ local cr_id="" feature_dir_arg="" title="" force=""
2443
+ while [ $# -gt 0 ]; do
2444
+ case "$1" in
2445
+ --cr-id) cr_id="$2"; shift 2 ;;
2446
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
2447
+ --title) title="$2"; shift 2 ;;
2448
+ --force) force=1; shift ;;
2449
+ --help|-h) echo "cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>] [--force]"; return 0 ;;
2450
+ *) echo "cr-setup: unknown arg '$1'" >&2; return 1 ;;
2451
+ esac
2452
+ done
2453
+ [ -n "$cr_id" ] || cr_id="CR-$(date +%Y%m%d-%H%M%S)"
2454
+ local repo_root; repo_root=$(get_repo_root)
2455
+ local fd="$feature_dir_arg"
2456
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
2457
+ [ -n "$fd" ] || { echo "cr-setup: no feature set and no --feature-dir" >&2; return 1; }
2458
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
2459
+ # Single-active-change guard: open the change FIRST so a second concurrent CR on
2460
+ # the same aggregate is refused BEFORE any changes/<cr>/ dir is scaffolded (no
2461
+ # orphan left behind). Re-opening the same cr_id is a no-op. --force overrides.
2462
+ local av_open
2463
+ if ! av_open=$(_active_version "$fd" open "$cr_id" "$force"); then
2464
+ echo "cr-setup: refusing to open '$cr_id' — a change is already open on this feature; finish it (finalize-delta -> analyze-slice -> close-delta) first, or pass --force." >&2
2465
+ printf '%s\n' "$av_open"
2466
+ return 1
2467
+ fi
2468
+ local cr_dir="$fd/changes/$cr_id"
2469
+ mkdir -p "$cr_dir"
2470
+ local dspec="$cr_dir/delta-spec.md" ddesign="$cr_dir/delta-design.md" tpl created=false
2471
+ if [ ! -f "$dspec" ]; then
2472
+ tpl=$(resolve_template "delta-spec-template" "$repo_root") || true
2473
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dspec"; else touch "$dspec"; fi
2474
+ created=true
2475
+ fi
2476
+ if [ ! -f "$ddesign" ]; then
2477
+ tpl=$(resolve_template "delta-design-template" "$repo_root") || true
2478
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$ddesign"; else touch "$ddesign"; fi
2479
+ created=true
2480
+ fi
2481
+ printf '{"cr_id":"%s","cr_dir":"%s","delta_spec":"%s","delta_design":"%s","title":"%s","created":%s}\n' \
2482
+ "$(json_escape "$cr_id")" "$(json_escape "$cr_dir")" "$(json_escape "$dspec")" \
2483
+ "$(json_escape "$ddesign")" "$(json_escape "$title")" "$created"
2484
+ }
2485
+
2486
+ cmd_cr_archive() {
2487
+ local cr_id="" feature_dir_arg=""
2488
+ while [ $# -gt 0 ]; do
2489
+ case "$1" in
2490
+ --cr-id) cr_id="$2"; shift 2 ;;
2491
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
2492
+ --help|-h) echo "cr-archive --cr-id <id> [--feature-dir <path>]"; return 0 ;;
2493
+ *) echo "cr-archive: unknown arg '$1'" >&2; return 1 ;;
2494
+ esac
2495
+ done
2496
+ [ -n "$cr_id" ] || { echo "cr-archive: --cr-id required" >&2; return 1; }
2497
+ local repo_root; repo_root=$(get_repo_root)
2498
+ local fd="$feature_dir_arg"
2499
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
2500
+ [ -n "$fd" ] || { echo "cr-archive: no feature set and no --feature-dir" >&2; return 1; }
2501
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
2502
+ local cr_dir="$fd/changes/$cr_id" archive_root="$fd/changes/archive" archived=""
2503
+ if [ -d "$cr_dir" ]; then
2504
+ mkdir -p "$archive_root"
2505
+ rm -rf "$archive_root/$cr_id"
2506
+ mv "$cr_dir" "$archive_root/$cr_id"
2507
+ archived="$archive_root/$cr_id"
2508
+ fi
2509
+ # Close the change in active_version.json (single version source): bump minor +
2510
+ # clear active_change. (Supersedes the old bare <feature>/VERSION file.)
2511
+ local av; av=$(_active_version "$fd" close)
2512
+ AW_AV="$av" AW_ARCHIVED="$archived" python3 - <<'PY'
2513
+ import json, os
2514
+ try: av = json.loads(os.environ.get("AW_AV") or "{}")
2515
+ except Exception: av = {}
2516
+ print(json.dumps({"ok": True, "archived_dir": os.environ.get("AW_ARCHIVED", ""),
2517
+ "version": av.get("version", "v1.1"), "previous": av.get("previous", "v1.0"),
2518
+ "closed_change": av.get("closed_change", "")}))
2519
+ PY
2520
+ }
2521
+
2522
+ # Stamp a version into the MASTER documents' `**Version**:` headers — the
2523
+ # deterministic twin of cr-archive's active_version.json bump. close-delta calls
2524
+ # this with the version cr-archive just computed, so all THREE carriers of the
2525
+ # number (spec.md, design.md, active_version.json) move in ONE stage by ONE
2526
+ # mechanism. Before this, design.md's header was bumped by the MODEL at
2527
+ # finalize-delta — a stage early, and guessing the target since the number was
2528
+ # never passed into the merge prompt — while spec.md's header was bumped by
2529
+ # nobody at all and sat at v1.0 forever.
2530
+ # Idempotent: re-stamping the same version reports `unchanged`, writes nothing.
2531
+ # Only the value moves; any trailing inline comment on the header line is kept.
2532
+ cmd_version_stamp() {
2533
+ local version="" feature_dir_arg=""
2534
+ while [ $# -gt 0 ]; do
2535
+ case "$1" in
2536
+ --version) version="$2"; shift 2 ;;
2537
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
2538
+ --help|-h) echo "version-stamp --version <vX.Y> [--feature-dir <path>]"; return 0 ;;
2539
+ *) echo "version-stamp: unknown arg '$1'" >&2; return 1 ;;
2540
+ esac
2541
+ done
2542
+ [ -n "$version" ] || { echo "version-stamp: --version required" >&2; return 1; }
2543
+ local repo_root; repo_root=$(get_repo_root)
2544
+ local fd="$feature_dir_arg"
2545
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
2546
+ [ -n "$fd" ] || { echo "version-stamp: no feature set and no --feature-dir" >&2; return 1; }
2547
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
2548
+ AW_FD="$fd" AW_VER="$version" python3 - <<'PY'
2549
+ import json, os, re
2550
+ fd = os.environ["AW_FD"]; ver = os.environ["AW_VER"].strip()
2551
+ stamped, unchanged, missing, no_header = [], [], [], []
2552
+ # First `**Version**: <value>` line only — that is the header. The Change History
2553
+ # table's `| Version |` column head does not match this pattern.
2554
+ pat = re.compile(r'^(\*\*Version\*\*:[ \t]*)(\S+)(.*)$', re.M)
2555
+ # design.ui.md joins the spine where the feature has a UI half: the masters move in
2556
+ # LOCKSTEP, so a document left behind at the old number is the one lie the version
2557
+ # spine cannot tolerate. A feature without one reports it `missing` and nothing fails.
2558
+ for name in ("spec.md", "design.md", "design.ui.md"):
2559
+ p = os.path.join(fd, name)
2560
+ if not os.path.isfile(p):
2561
+ missing.append(name); continue
2562
+ src = open(p, encoding="utf-8").read()
2563
+ m = pat.search(src)
2564
+ if not m:
2565
+ no_header.append(name); continue
2566
+ if m.group(2) == ver:
2567
+ unchanged.append(name); continue
2568
+ open(p, "w", encoding="utf-8").write(
2569
+ src[:m.start()] + m.group(1) + ver + m.group(3) + src[m.end():])
2570
+ stamped.append(name)
2571
+ print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
2572
+ "unchanged": unchanged, "missing": missing,
2573
+ "no_header": no_header, "count": len(stamped)}))
2574
+ PY
2575
+ }
2576
+
2577
+ case "$sub" in
2578
+ resolve-domain) cmd_resolve_domain "$@" ;;
2579
+ resolve-tech) cmd_resolve_tech "$@" ;;
2580
+ scaffold) cmd_scaffold "$@" ;;
2581
+ resolve-hooks) cmd_resolve_hooks "$@" ;;
2582
+ create-checklist) cmd_create_checklist "$@" ;;
2583
+ list-domains) cmd_list_domains "$@" ;;
2584
+ set-domain) cmd_set_domain "$@" ;;
2585
+ stack-list) cmd_stack_list "$@" ;;
2586
+ stack-detect) cmd_stack_detect "$@" ;;
2587
+ lane-declare) cmd_lane_declare "$@" ;;
2588
+ lane-resolve) cmd_lane_resolve "$@" ;;
2589
+ lane-verify) cmd_lane_verify "$@" ;;
2590
+ lane-probe) cmd_lane_probe "$@" ;;
2591
+ list-features) cmd_list_features "$@" ;;
2592
+ set-feature) cmd_set_feature "$@" ;;
2593
+ version-read) cmd_version_read "$@" ;;
2594
+ version-init) cmd_version_init "$@" ;;
2595
+ feature-paths) cmd_feature_paths "$@" ;;
2596
+ design-scaffold) cmd_design_scaffold "$@" ;;
2597
+ build-compile) cmd_build_compile "$@" ;;
2598
+ test-run) cmd_test_run "$@" ;;
2599
+ run-migrate) cmd_run_migrate "$@" ;;
2600
+ deploy-dispatch) cmd_deploy_dispatch "$@" ;;
2601
+ cr-setup) cmd_cr_setup "$@" ;;
2602
+ cr-archive) cmd_cr_archive "$@" ;;
2603
+ version-stamp) cmd_version_stamp "$@" ;;
2604
+ checklist-read) cmd_checklist_read "$@" ;;
2605
+ checklist-apply) cmd_checklist_apply "$@" ;;
2606
+ detect-project) cmd_detect_project "$@" ;;
2607
+ foundation-list) cmd_foundation_list "$@" ;;
2608
+ foundation-locate) cmd_foundation_locate "$@" ;;
2609
+ foundation-unpack) cmd_foundation_unpack "$@" ;;
2610
+ foundation-verify) cmd_foundation_verify "$@" ;;
2611
+ foundation-stash-dbcontext) cmd_foundation_stash_dbcontext "$@" ;;
2612
+ list-components) cmd_list_components "$@" ;;
2613
+ component-catalog) cmd_component_catalog "$@" ;;
2614
+ component-verify) cmd_component_verify "$@" ;;
2615
+ find-host-project) cmd_find_host_project "$@" ;;
2616
+ setup-constitution) cmd_setup_constitution "$@" ;;
2617
+ compose-constitution) cmd_compose_constitution "$@" ;;
2618
+ constitution-verify) cmd_constitution_verify "$@" ;;
2619
+ agent-context-update) cmd_agent_context_update "$@" ;;
2620
+ ""|--help|-h)
2621
+ sed -n '2,37p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' ;;
2622
+ *)
2623
+ echo "Unknown subcommand: $sub" >&2
2624
+ echo "Run '$0 --help' for usage." >&2
2625
+ exit 1 ;;
2626
+ esac