aw-spec-cli 0.1.8 → 0.1.10

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,2232 @@
1
+ #!/usr/bin/env pwsh
2
+ #
3
+ # aw-spec-actions.ps1 — common action dispatcher for the aw-spec orchestrator
4
+ # (Windows / PowerShell mirror of aw-spec-actions.sh).
5
+ #
6
+ # ONE shared script for every integration (.claude / .agents / .kilocode). The
7
+ # orchestrator workflows reach these subcommands through `execute_script` steps.
8
+ # Each subcommand performs deterministic, governance-safe work and prints a single
9
+ # JSON object on stdout. NONE of these actions author spec/code content — content
10
+ # generation is the LLM's job, in the workflow's agent_* steps.
11
+ #
12
+ # Usage: aw-spec-actions.ps1 <subcommand> [options]
13
+ # resolve-domain -Command <name>
14
+ # resolve-tech -Command <name>
15
+ # derive-names <feature description...>
16
+ # scaffold -FeatureDir <path> [-AllowExisting]
17
+ # resolve-hooks -Phase <before_specify|after_specify>
18
+ # create-checklist -FeatureDir <path> [-FeatureName <name>]
19
+
20
+ $ErrorActionPreference = 'Stop'
21
+
22
+ # ─────────────────────────── shared helpers ────────────────────────────────
23
+ function Get-RepoRoot {
24
+ $dir = (Get-Location).Path
25
+ while ($true) {
26
+ if (Test-Path (Join-Path $dir '.appweaver')) { return $dir }
27
+ $parent = Split-Path $dir -Parent
28
+ if ([string]::IsNullOrEmpty($parent) -or $parent -eq $dir) { break }
29
+ $dir = $parent
30
+ }
31
+ return (Resolve-Path (Join-Path $PSScriptRoot '..' '..' '..')).Path
32
+ }
33
+
34
+ function ConvertTo-RepoRelative {
35
+ param([string]$Path, [string]$RepoRoot)
36
+ $prefix = $RepoRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
37
+ if ($Path.StartsWith($prefix)) { $Path = $Path.Substring($prefix.Length) }
38
+ return ($Path -replace '\\', '/')
39
+ }
40
+
41
+ function Resolve-SpecTemplate {
42
+ param([string]$RepoRoot)
43
+ $base = Join-Path $RepoRoot '.appweaver/templates'
44
+ $name = 'spec-template.md'
45
+ $override = Join-Path $base "overrides/$name"
46
+ if (Test-Path $override) { return $override }
47
+ $presets = Join-Path $RepoRoot '.appweaver/presets'
48
+ if (Test-Path $presets) {
49
+ $hit = Get-ChildItem -Directory $presets -ErrorAction SilentlyContinue |
50
+ ForEach-Object { Join-Path $_.FullName "templates/$name" } |
51
+ Where-Object { Test-Path $_ } | Select-Object -First 1
52
+ if ($hit) { return $hit }
53
+ }
54
+ $exts = Join-Path $RepoRoot '.appweaver/extensions'
55
+ if (Test-Path $exts) {
56
+ $hit = Get-ChildItem -Directory $exts -ErrorAction SilentlyContinue |
57
+ Where-Object { -not $_.Name.StartsWith('.') } |
58
+ ForEach-Object { Join-Path $_.FullName "templates/$name" } |
59
+ Where-Object { Test-Path $_ } | Select-Object -First 1
60
+ if ($hit) { return $hit }
61
+ }
62
+ $core = Join-Path $base $name
63
+ if (Test-Path $core) { return $core }
64
+ return $null
65
+ }
66
+
67
+ function Write-Json { param($Obj) $Obj | ConvertTo-Json -Compress -Depth 6 }
68
+
69
+ # ─────────────────────────── resolve-domain ────────────────────────────────
70
+ function Invoke-ResolveDomain {
71
+ param([string[]]$Rest)
72
+ $commandName = 'aw-spec-specify'
73
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
74
+ switch ($Rest[$i]) {
75
+ '--command' { $commandName = $Rest[++$i] }
76
+ '-Command' { $commandName = $Rest[++$i] }
77
+ }
78
+ }
79
+ $repo = Get-RepoRoot
80
+ $domainJson = Join-Path $repo '.appweaver/domain.json'
81
+ $domain = ''
82
+ if (Test-Path $domainJson) {
83
+ try { $domain = (Get-Content $domainJson -Raw | ConvertFrom-Json).domain } catch { $domain = '' }
84
+ if (-not $domain) { $domain = '' }
85
+ }
86
+ $rulemap = Join-Path $repo "guardrails/projects/$domain/domain-rule-bindings.md"
87
+ $rulemapExists = ($domain -ne '') -and (Test-Path $rulemap)
88
+
89
+ $candidates = @()
90
+ if ($rulemapExists) {
91
+ $lines = Get-Content $rulemap
92
+ $heading = "## /$commandName"
93
+ $grab = $false; $found = @()
94
+ foreach ($line in $lines) {
95
+ if ($line -eq $heading) { $grab = $true; continue }
96
+ if ($line -match '^## ') { $grab = $false }
97
+ if ($grab -and $line -match '^\s*-\s+(.+?)\s*$') { $found += $Matches[1] }
98
+ }
99
+ if ($found.Count -gt 0) { $candidates = $found }
100
+ }
101
+
102
+ Write-Json ([ordered]@{
103
+ domain = $domain
104
+ rulemap_exists = $rulemapExists
105
+ candidates = @($candidates)
106
+ })
107
+ }
108
+
109
+ # ─────────────────────────── resolve-tech ───────────────────────────────────
110
+ # Technical rule-file mapping — parallel to resolve-domain, but reads
111
+ # guardrails/projects/<project>/tech-rule-bindings.md. The TECHNICAL rule files live in the
112
+ # shared per-stack corpora at guardrails/technology/<stack>/ (rules) and
113
+ # guardrails/technology/<stack>/examples/ (the worked sample for a rule file), and are
114
+ # configured per-command in this map, so they can be tuned per project WITHOUT editing the
115
+ # workflows and a second project can map the same corpus. The technical CONSTITUTION
116
+ # (constitution.technical.md) is NOT mapped — it is still referred to directly by
117
+ # each consumer, as-is.
118
+ # A bullet may name its stack with a PLACEHOLDER — {{stack}} / {{backend_stack}} /
119
+ # {{frontend_stack}} / {{backend_ecosystem}} / {{frontend_ecosystem}} — resolved from
120
+ # the lanes in .appweaver/stacks.json. That is what lets one map serve a Spring repo and
121
+ # a .NET repo without an edit. A bullet that instead spells a stack directory out is kept
122
+ # and USED (it may be a deliberate cross-stack reference) but reported through
123
+ # stack_mismatch when it is not a stack this repo declares, because a map pinned to the
124
+ # wrong corpus is how a Java project ends up being handed C# rules.
125
+ function Invoke-ResolveTech {
126
+ param([string[]]$Rest)
127
+ $commandName = 'aw-spec-design'
128
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
129
+ switch ($Rest[$i]) {
130
+ '--command' { $commandName = $Rest[++$i] }
131
+ '-Command' { $commandName = $Rest[++$i] }
132
+ }
133
+ }
134
+ $repo = Get-RepoRoot
135
+ $domainJson = Join-Path $repo '.appweaver/domain.json'
136
+ $domain = ''
137
+ if (Test-Path $domainJson) {
138
+ try { $domain = (Get-Content $domainJson -Raw | ConvertFrom-Json).domain } catch { $domain = '' }
139
+ if (-not $domain) { $domain = '' }
140
+ }
141
+ $techmap = Join-Path $repo "guardrails/projects/$domain/tech-rule-bindings.md"
142
+ $techmapExists = ($domain -ne '') -and (Test-Path $techmap)
143
+
144
+ # The declared stacks, so one map can be written for any of them.
145
+ $subs = [ordered]@{
146
+ 'backend_stack' = (Get-LaneField -Field 'backend_profile')
147
+ 'frontend_stack' = (Get-LaneField -Field 'frontend_profile')
148
+ 'backend_ecosystem' = (Get-LaneField -Field 'backend_ecosystem')
149
+ 'frontend_ecosystem' = (Get-LaneField -Field 'frontend_ecosystem')
150
+ }
151
+ $subs['stack'] = $subs['backend_stack'] # the unqualified form means the service lane
152
+ $declared = @($subs.Values | Where-Object { $_ } | Select-Object -Unique)
153
+
154
+ $bullets = @()
155
+ if ($techmapExists) {
156
+ $lines = Get-Content $techmap
157
+ foreach ($heading in @("## /$commandName", "## /$($commandName -replace 'decl-','')")) {
158
+ $grab = $false; $found = @()
159
+ foreach ($line in $lines) {
160
+ if ($line -eq $heading) { $grab = $true; continue }
161
+ if ($line -match '^## ') { $grab = $false }
162
+ if ($grab -and $line -match '^\s*-\s+(.+?)\s*$') { $found += $Matches[1] }
163
+ }
164
+ if ($found.Count -gt 0) { $bullets = $found; break }
165
+ }
166
+ }
167
+
168
+ $candidates = @(); $missing = @(); $skipped = @(); $mismatched = @()
169
+ foreach ($b in $bullets) {
170
+ $holes = @([regex]::Matches($b, '\{\{\s*([a-z_]+)\s*\}\}') | ForEach-Object { $_.Groups[1].Value })
171
+ $why = ''
172
+ foreach ($h in $holes) {
173
+ if (-not $subs.Contains($h)) { $why = "unknown placeholder {{$h}}"; break }
174
+ # A frontend placeholder in a backend-only repo is not an error: the lane
175
+ # genuinely does not exist, so its rules genuinely do not apply.
176
+ if (-not $subs[$h]) { $why = "no lane declared for {{$h}}"; break }
177
+ }
178
+ if ($why) { $skipped += [ordered]@{ bullet = $b; why = $why }; continue }
179
+ $path = [regex]::Replace($b, '\{\{\s*([a-z_]+)\s*\}\}', { param($m) $subs[$m.Groups[1].Value] })
180
+ if (Test-Path (Join-Path $repo $path)) { $candidates += $path } else { $missing += $path }
181
+ # A LITERAL stack directory that is not one this repo declares. Reported, never
182
+ # dropped: the file exists and the project chose it, so it may be a deliberate
183
+ # cross-stack reference — but it is also exactly how a Java repo ends up being
184
+ # handed C# rules, and that must not be invisible.
185
+ $m2 = [regex]::Match($path, '^guardrails/technology/([^/]+)/')
186
+ if ($m2.Success -and $declared.Count -gt 0 -and ($declared -notcontains $m2.Groups[1].Value) -and ($b -notmatch '\{\{')) {
187
+ if ($mismatched -notcontains $m2.Groups[1].Value) { $mismatched += $m2.Groups[1].Value }
188
+ }
189
+ }
190
+
191
+ Write-Json ([ordered]@{
192
+ domain = $domain
193
+ techrulemap_exists = $techmapExists
194
+ candidates = @($candidates)
195
+ missing = @($missing)
196
+ skipped = @($skipped)
197
+ declared_stacks = @($declared | Sort-Object)
198
+ stack_mismatch = ($mismatched.Count -gt 0)
199
+ mismatched_stacks = @($mismatched)
200
+ })
201
+ }
202
+
203
+ # ─────────────────────────── derive-names ──────────────────────────────────
204
+ function ConvertTo-Pascal {
205
+ param([string]$In)
206
+ ($In -replace '-', ' ').Split(' ') | Where-Object { $_ } |
207
+ ForEach-Object { $_.Substring(0,1).ToUpper() + $_.Substring(1) } | Join-String
208
+ }
209
+
210
+ function Get-ShortName {
211
+ param([string]$Description)
212
+ $stop = 'i','a','an','the','to','for','of','in','on','at','by','with','from','is','are','was','were',
213
+ 'be','been','being','have','has','had','do','does','did','will','would','should','could','can',
214
+ 'may','might','must','shall','this','that','these','those','my','your','our','their','want','need','add','get','set'
215
+ $clean = ($Description.ToLower() -replace '[^a-z0-9]', ' ')
216
+ $words = @()
217
+ foreach ($w in ($clean.Split(' ') | Where-Object { $_ })) {
218
+ if (($stop -notcontains $w) -and $w.Length -ge 3) { $words += $w }
219
+ }
220
+ $max = if ($words.Count -eq 4) { 4 } else { 3 }
221
+ $result = ($words | Select-Object -First $max) -join '-'
222
+ if (-not $result) { $result = 'feature' }
223
+ return $result
224
+ }
225
+
226
+
227
+ # ─────────────────────────── scaffold ──────────────────────────────────────
228
+ function Invoke-Scaffold {
229
+ param([string[]]$Rest)
230
+ $featureDirArg = ''; $allowExisting = $false
231
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
232
+ switch ($Rest[$i]) {
233
+ { $_ -in '--feature-dir','-FeatureDir' } { $featureDirArg = $Rest[++$i] }
234
+ { $_ -in '--allow-existing','-AllowExisting' } { $allowExisting = $true }
235
+ }
236
+ }
237
+ if (-not $featureDirArg) { Write-Error 'scaffold: --feature-dir is required'; exit 1 }
238
+
239
+ $repo = Get-RepoRoot
240
+ $featureDir = if ([System.IO.Path]::IsPathRooted($featureDirArg)) { $featureDirArg } else { Join-Path $repo $featureDirArg }
241
+ $specFile = Join-Path $featureDir 'spec.md'
242
+
243
+ $dirExisted = Test-Path $featureDir
244
+ if ($dirExisted -and -not $allowExisting) { Write-Error "scaffold: '$featureDir' already exists (use --allow-existing)"; exit 1 }
245
+ New-Item -ItemType Directory -Force -Path $featureDir | Out-Null
246
+ $created = -not $dirExisted
247
+
248
+ $template = $null; $specCreated = $false
249
+ if (-not (Test-Path $specFile)) {
250
+ $template = Resolve-SpecTemplate -RepoRoot $repo
251
+ if ($template -and (Test-Path $template)) { Copy-Item $template $specFile } else { New-Item -ItemType File -Force -Path $specFile | Out-Null }
252
+ $specCreated = $true
253
+ }
254
+
255
+ $featureJson = Join-Path $repo '.appweaver/feature.json'
256
+ New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver') | Out-Null
257
+ @{ feature_directory = (ConvertTo-RepoRelative -Path $featureDir -RepoRoot $repo) } |
258
+ ConvertTo-Json -Compress | Set-Content -Path $featureJson -Encoding UTF8
259
+
260
+ Write-Json ([ordered]@{
261
+ feature_dir = $featureDir
262
+ spec_file = $specFile
263
+ template = if ($template) { $template } else { '' }
264
+ created = $created
265
+ spec_created = $specCreated
266
+ })
267
+ }
268
+
269
+ # ─────────────────────────── resolve-hooks ─────────────────────────────────
270
+ # Native indentation parser for the known extensions.yml shape (no YAML module).
271
+ # Accepts multiple --phase; merges + dedupes by command.
272
+ function Get-HookIndent([string]$s) { return ($s.Length - $s.TrimStart(' ').Length) }
273
+
274
+ function Get-PhaseHooks {
275
+ param([string[]]$Lines, [string]$Phase)
276
+ $hooks = @(); $n = $Lines.Count; $i = 0
277
+ while ($i -lt $n) {
278
+ if ($Lines[$i].TrimEnd() -eq 'hooks:') {
279
+ $base = Get-HookIndent $Lines[$i]; $i++
280
+ while ($i -lt $n -and ($Lines[$i].Trim() -eq '' -or (Get-HookIndent $Lines[$i]) -gt $base)) {
281
+ $st0 = $Lines[$i].Trim()
282
+ if ($st0.TrimEnd(':') -eq $Phase -and $st0.EndsWith(':') -and -not $st0.StartsWith('- ')) {
283
+ $phInd = Get-HookIndent $Lines[$i]; $i++
284
+ $cur = $null
285
+ while ($i -lt $n) {
286
+ $l = $Lines[$i]
287
+ if ($l.Trim() -eq '') { $i++; continue }
288
+ $ind = Get-HookIndent $l; $st = $l.Trim()
289
+ if ($ind -lt $phInd) { break }
290
+ if ($ind -eq $phInd -and $st.EndsWith(':') -and -not $st.StartsWith('- ')) { break }
291
+ if ($st.StartsWith('- ')) {
292
+ if ($null -ne $cur) { $hooks += ,$cur }
293
+ $cur = @{}; $st = $st.Substring(2)
294
+ }
295
+ if ($null -ne $cur -and $st.Contains(':') -and -not $st.StartsWith('- ')) {
296
+ $k = $st.Substring(0, $st.IndexOf(':')).Trim()
297
+ $v = $st.Substring($st.IndexOf(':') + 1).Trim()
298
+ $cur[$k] = $v
299
+ }
300
+ $i++
301
+ }
302
+ if ($null -ne $cur) { $hooks += ,$cur }
303
+ break
304
+ } else { $i++ }
305
+ }
306
+ break
307
+ }
308
+ $i++
309
+ }
310
+ return ,$hooks
311
+ }
312
+
313
+ function Invoke-ResolveHooks {
314
+ param([string[]]$Rest)
315
+ $phases = @()
316
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
317
+ if ($Rest[$i] -in '--phase','-Phase') { $phases += $Rest[++$i] }
318
+ }
319
+ if ($phases.Count -eq 0) { Write-Error 'resolve-hooks: at least one --phase is required'; exit 1 }
320
+
321
+ $repo = Get-RepoRoot
322
+ $ext = Join-Path $repo '.appweaver/extensions.yml'
323
+ if (-not (Test-Path $ext)) { Write-Json ([ordered]@{ phases = @($phases); hooks = @() }); return }
324
+ $lines = Get-Content $ext
325
+
326
+ $out = @(); $seen = @{}
327
+ foreach ($phase in $phases) {
328
+ foreach ($h in (Get-PhaseHooks -Lines $lines -Phase $phase)) {
329
+ if ("$($h['enabled'])".ToLower() -eq 'false') { continue }
330
+ $cond = $h['condition']
331
+ if ($null -ne $cond -and "$cond".Trim().ToLower() -notin @('', 'null', '~')) { continue }
332
+ $cmd = [string]$h['command']
333
+ if ($seen.ContainsKey($cmd)) { continue }
334
+ $seen[$cmd] = $true
335
+ $out += [ordered]@{
336
+ extension = [string]$h['extension']
337
+ command = $cmd
338
+ slash = if ($cmd) { '/' + ($cmd -replace '\.', '-') } else { '' }
339
+ optional = ("$($h['optional'])".ToLower() -eq 'true')
340
+ description = [string]$h['description']
341
+ prompt = [string]$h['prompt']
342
+ }
343
+ }
344
+ }
345
+ Write-Json ([ordered]@{ phases = @($phases); hooks = @($out) })
346
+ }
347
+
348
+ # ─────────────────────────── create-checklist ──────────────────────────────
349
+ function Invoke-CreateChecklist {
350
+ param([string[]]$Rest)
351
+ $featureDirArg = ''; $featureName = ''; $kind = 'master'; $changeDirArg = ''
352
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
353
+ switch ($Rest[$i]) {
354
+ { $_ -in '--feature-dir','-FeatureDir' } { $featureDirArg = $Rest[++$i] }
355
+ { $_ -in '--feature-name','-FeatureName' } { $featureName = $Rest[++$i] }
356
+ { $_ -in '--kind','-Kind' } { $kind = $Rest[++$i] }
357
+ { $_ -in '--change-dir','-ChangeDir' } { $changeDirArg = $Rest[++$i] }
358
+ }
359
+ }
360
+ if (-not $featureDirArg) { Write-Error 'create-checklist: --feature-dir is required'; exit 1 }
361
+ if ($kind -notin 'master','delta') { Write-Error "create-checklist: --kind must be 'master' or 'delta' (got '$kind')"; exit 1 }
362
+
363
+ $repo = Get-RepoRoot
364
+ $featureDir = if ([System.IO.Path]::IsPathRooted($featureDirArg)) { $featureDirArg } else { Join-Path $repo $featureDirArg }
365
+ if (-not $featureName) { $featureName = Split-Path $featureDir -Leaf }
366
+
367
+ # WHERE the checklist lives and WHAT it grades are decided together here, and both
368
+ # are reported back, so a caller binds the pair from ONE call and can never point a
369
+ # checklist at the wrong document. master -> the feature's spec.md; delta -> the
370
+ # change's delta-spec.md (checklist sits beside it, inside changes/<cr-id>/).
371
+ $crId = ''
372
+ if ($kind -eq 'delta') {
373
+ if (-not $changeDirArg) { Write-Error 'create-checklist: --change-dir is required with --kind delta'; exit 1 }
374
+ $changeDir = if ([System.IO.Path]::IsPathRooted($changeDirArg)) { $changeDirArg } else { Join-Path $repo $changeDirArg }
375
+ $crId = Split-Path $changeDir -Leaf
376
+ $checklistDir = Join-Path $changeDir 'checklists'
377
+ $targetFile = Join-Path $changeDir 'delta-spec.md'
378
+ } else {
379
+ $checklistDir = Join-Path $featureDir 'checklists'
380
+ $targetFile = Join-Path $featureDir 'spec.md'
381
+ }
382
+ $checklistFile = Join-Path $checklistDir 'requirements.md'
383
+ New-Item -ItemType Directory -Force -Path $checklistDir | Out-Null
384
+ $created = -not (Test-Path $checklistFile)
385
+ $today = Get-Date -Format 'yyyy-MM-dd'
386
+
387
+ $content = if ($kind -eq 'delta') { @"
388
+ # Delta-Spec Quality Checklist: $featureName — $crId
389
+
390
+ **Purpose**: Validate the CHANGE's delta-spec before proceeding to design
391
+ **Created**: $today
392
+ **Change**: [delta-spec.md](../delta-spec.md)
393
+
394
+ ## Content Quality
395
+
396
+ - [ ] No implementation mechanics (languages, frameworks, code/adapter design; interop obligations named as contracts are fine)
397
+ - [ ] Focused on user value and business needs
398
+ - [ ] Written for non-technical stakeholders
399
+ - [ ] Change summary states what is changing and why
400
+
401
+ ## Delta Completeness
402
+
403
+ - [ ] Every item sits under ADDED, MODIFIED or REMOVED (an empty section states None)
404
+ - [ ] MODIFIED items state both sides (was -> now)
405
+ - [ ] New behaviour is a user story (US-n) with priority, Given/When/Then acceptance, and an independent test
406
+ - [ ] New or changed system requirements keep EARS form
407
+ - [ ] Requirements are testable and unambiguous
408
+ - [ ] No [NEEDS CLARIFICATION] markers remain
409
+
410
+ ## Domain Lift (Constraints / Classification / Contracts)
411
+
412
+ - [ ] Constraint / NFR changes are captured as CN- items, or the section states None
413
+ - [ ] Data sensitivity or exposure changes are captured as DC- items, or the section states None
414
+ - [ ] External interoperability changes are captured as IC- items at contract level, or the section states None
415
+
416
+ ## Experience Lift (UI / UX standards)
417
+
418
+ - [ ] Experience-standard changes are captured as UX- items, or the section states None
419
+ - [ ] Where UX- deltas exist, each states a project-wide standard checkable on any surface — no screen, page, layout, navigation or component
420
+ - [ ] Where UX- deltas exist, a change to THIS aggregate's own interaction behaviour is an FR- delta, not a UX- one
421
+ - [ ] Where UX- deltas exist, a bound the service must meet stays a CN-, not a UX-
422
+
423
+ ## Merge Readiness
424
+
425
+ - [ ] Header is complete: aggregate, change type, base version
426
+ - [ ] Impact lists the affected ids (FR / BR / CN / DC / IC / UX / US / SC)
427
+ - [ ] Regression risk names the existing behaviour that must still hold
428
+ - [ ] Scoped to THIS change — the unchanged master is neither restated here nor edited
429
+
430
+ ## Notes
431
+
432
+ - This checklist grades the DELTA DOCUMENT only. Accuracy against the master spec
433
+ (id continuity, conflicts with existing requirements) is checked downstream by
434
+ /appweaver analyze-delta-design.
435
+ - Items marked incomplete require delta-spec updates before /appweaver design
436
+ "@ } else { @"
437
+ # Specification Quality Checklist: $featureName
438
+
439
+ **Purpose**: Validate specification completeness and quality before proceeding to design
440
+ **Created**: $today
441
+ **Feature**: [spec.md](../spec.md)
442
+
443
+ ## Content Quality
444
+
445
+ - [ ] No implementation mechanics (languages, frameworks, code/adapter design; interop obligations named as contracts are fine)
446
+ - [ ] Focused on user value and business needs
447
+ - [ ] Written for non-technical stakeholders
448
+ - [ ] All mandatory sections completed
449
+
450
+ ## Requirement Completeness
451
+
452
+ - [ ] No [NEEDS CLARIFICATION] markers remain
453
+ - [ ] Requirements are testable and unambiguous
454
+ - [ ] Success criteria are measurable
455
+ - [ ] Success criteria are technology-agnostic (no implementation details)
456
+ - [ ] All acceptance scenarios are defined
457
+ - [ ] Edge cases are identified
458
+ - [ ] Scope is clearly bounded
459
+ - [ ] Dependencies and assumptions identified
460
+
461
+ ## Domain Lift (Constraints / Classification / Contracts)
462
+
463
+ - [ ] Domain-derived constraints are captured in Constraints & NFRs (CN-), or the section states None
464
+ - [ ] Data sensitivity and exposure rules are captured in Data Classification (DC-), or the section states None
465
+ - [ ] External interoperability obligations are captured as Integration Contracts (IC-) at contract level, or the section states None
466
+
467
+ ## Experience Lift (UI / UX standards)
468
+
469
+ - [ ] Experience Scope states the actors, channels and device / browser / assistive-technology floors, or states None
470
+ - [ ] Project-wide experience standards are captured as UX- items, or the section states None
471
+ - [ ] Where UX- items exist, each is checkable on any surface — no screen, page, layout, navigation or component appears
472
+ - [ ] Where UX- items exist, none of them is a per-feature interaction behaviour — those stay FR-
473
+ - [ ] Where UX- items exist, a bound the SERVICE must meet is a CN-, cross-referenced where the obligation binds both lanes
474
+
475
+ ## Feature Readiness
476
+
477
+ - [ ] All functional requirements have clear acceptance criteria
478
+ - [ ] User scenarios cover primary flows
479
+ - [ ] Feature meets measurable outcomes defined in Success Criteria
480
+ - [ ] No implementation details leak into specification
481
+
482
+ ## Notes
483
+
484
+ - Items marked incomplete require spec updates before /appweaver design
485
+ "@ }
486
+ Set-Content -Path $checklistFile -Value $content -Encoding UTF8
487
+ Write-Json ([ordered]@{ checklist_file = $checklistFile; kind = $kind; target = $targetFile; created = $created })
488
+ }
489
+
490
+ # ─────────────────────────── list-domains ──────────────────────────────────
491
+ # The selectable projects: immediate subdirs of guardrails/projects/ that carry a rule
492
+ # map. The map is what makes a directory a project, exactly as a *.pack.txt is what makes
493
+ # a foundations/ directory a foundation — without that test any stray sibling would be
494
+ # offered as a governance target. EITHER map qualifies, because onboard writes the domain
495
+ # map several steps before the technical one and a half-built project must stay visible.
496
+ function Invoke-ListDomains {
497
+ param([string[]]$Rest)
498
+ $repo = Get-RepoRoot
499
+ $base = Join-Path $repo 'guardrails/projects'
500
+ $domains = @()
501
+ if (Test-Path $base) {
502
+ $domains = @(Get-ChildItem -Directory $base -ErrorAction SilentlyContinue | Where-Object {
503
+ (Test-Path (Join-Path $_.FullName 'tech-rule-bindings.md')) -or
504
+ (Test-Path (Join-Path $_.FullName 'domain-rule-bindings.md'))
505
+ } | ForEach-Object { $_.Name })
506
+ }
507
+ Write-Json ([ordered]@{ domains = @($domains) })
508
+ }
509
+
510
+ # ─────────────────────────── set-domain ────────────────────────────────────
511
+ function Invoke-SetDomain {
512
+ param([string[]]$Rest)
513
+ $domain = ''
514
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
515
+ if ($Rest[$i] -in '--domain','-Domain') { $domain = $Rest[++$i] }
516
+ }
517
+ if (-not $domain) { Write-Error 'set-domain: --domain is required'; exit 1 }
518
+
519
+ $repo = Get-RepoRoot
520
+ $mapRel = "guardrails/projects/$domain/domain-rule-bindings.md"
521
+ $mapExists = Test-Path (Join-Path $repo $mapRel)
522
+
523
+ New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver') | Out-Null
524
+ @{ domain = $domain } | ConvertTo-Json -Compress |
525
+ Set-Content -Path (Join-Path $repo '.appweaver/domain.json') -Encoding UTF8
526
+
527
+ Write-Json ([ordered]@{
528
+ domain = $domain
529
+ map_path = $mapRel
530
+ map_exists = $mapExists
531
+ written = $true
532
+ })
533
+ }
534
+
535
+ # ────── stack-list / stack-detect / lane-declare / lane-resolve / lane-probe ──────
536
+ # The lane model. Twins of the bash cmd_stack_list / cmd_stack_detect /
537
+ # cmd_lane_declare / cmd_lane_resolve / cmd_lane_probe: both shells delegate to the
538
+ # SAME python core, so the JSON contract has one implementation and the pair cannot
539
+ # drift.
540
+ #
541
+ # lane-declare is the SOLE WRITER of .appweaver/stacks.json, with two callers:
542
+ # aw-spec-select-stack, where a person picks, and aw-spec-onboard, which declares
543
+ # what it reverse-engineered (those lanes carry origin: reverse-engineered, and
544
+ # onboard never overwrites a lane that is already declared). stack-detect proposes
545
+ # candidates and writes nothing. lane-resolve can never write — which is why these
546
+ # are separate subcommands rather than one with a mode flag.
547
+ function Invoke-AwStackCore {
548
+ param([string]$Op, [string[]]$Rest)
549
+ $py = Get-Py
550
+ if (-not $py) { Write-Error 'aw-stack: python3 is required for the lane model'; exit 1 }
551
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-stack.py'
552
+ if (-not (Test-Path $core)) { Write-Error "aw-stack: core not found at $core"; exit 1 }
553
+ $repo = Get-RepoRoot
554
+ & $py $core '--repo' $repo $Op @Rest
555
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
556
+ }
557
+
558
+ function Invoke-StackList {
559
+ param([string[]]$Rest)
560
+ $fwd = @()
561
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
562
+ switch ($Rest[$i]) {
563
+ '--kind' { $fwd += '--kind'; $fwd += $Rest[++$i] }
564
+ '--preselect' { $fwd += '--preselect'; $fwd += $Rest[++$i] }
565
+ '--include-none' { $fwd += '--include-none' }
566
+ '--suggest' { $fwd += '--suggest' }
567
+ default { Write-Error "stack-list: unknown arg '$($Rest[$i])'"; exit 1 }
568
+ }
569
+ }
570
+ Invoke-AwStackCore -Op 'list' -Rest $fwd
571
+ }
572
+
573
+ function Invoke-StackDetect {
574
+ param([string[]]$Rest)
575
+ $fwd = @()
576
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
577
+ switch ($Rest[$i]) {
578
+ '--kind' { $fwd += '--kind'; $fwd += $Rest[++$i] }
579
+ '--exclude-root' { $fwd += '--exclude-root'; $fwd += $Rest[++$i] }
580
+ default { Write-Error "stack-detect: unknown arg '$($Rest[$i])'"; exit 1 }
581
+ }
582
+ }
583
+ Invoke-AwStackCore -Op 'detect' -Rest $fwd
584
+ }
585
+
586
+ function Invoke-LaneDeclare {
587
+ param([string[]]$Rest)
588
+ $fwd = @()
589
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
590
+ switch ($Rest[$i]) {
591
+ # --source-path is the same field as --root; both spellings reach the core.
592
+ { $_ -in '--profile','--kind','--id','--root','--source-path','--stack','--origin','--build','--test','--run','--prototype-root','--prototype-run' } {
593
+ $fwd += $Rest[$i]; $fwd += $Rest[++$i]
594
+ }
595
+ '--no-prototype' { $fwd += '--no-prototype' }
596
+ '--require-existing-root' { $fwd += '--require-existing-root' }
597
+ default { Write-Error "lane-declare: unknown arg '$($Rest[$i])'"; exit 1 }
598
+ }
599
+ }
600
+ Invoke-AwStackCore -Op 'declare' -Rest $fwd
601
+ }
602
+
603
+ function Invoke-LaneResolve {
604
+ param([string[]]$Rest)
605
+ $fwd = @()
606
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
607
+ switch ($Rest[$i]) {
608
+ { $_ -in '--kind','--id' } { $fwd += $Rest[$i]; $fwd += $Rest[++$i] }
609
+ default { Write-Error "lane-resolve: unknown arg '$($Rest[$i])'"; exit 1 }
610
+ }
611
+ }
612
+ Invoke-AwStackCore -Op 'resolve' -Rest $fwd
613
+ }
614
+
615
+ # The gate: is the declaration USABLE for the phase about to run. Twin of bash
616
+ # cmd_lane_verify. --require names a lane kind the phase cannot run without;
617
+ # --allow-missing-root is the greenfield state, where a source path is declared
618
+ # before implement creates it.
619
+ function Invoke-LaneVerify {
620
+ param([string[]]$Rest)
621
+ $fwd = @()
622
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
623
+ switch ($Rest[$i]) {
624
+ '--require' { $fwd += '--require'; $fwd += $Rest[++$i] }
625
+ # --flag=value: --command carries a phase name that starts with '/'.
626
+ '--command' { $fwd += ('--command=' + $Rest[++$i]) }
627
+ '--allow-missing-root' { $fwd += '--allow-missing-root' }
628
+ default { Write-Error "lane-verify: unknown arg '$($Rest[$i])'"; exit 1 }
629
+ }
630
+ }
631
+ Invoke-AwStackCore -Op 'verify' -Rest $fwd
632
+ }
633
+
634
+ # Evidence that a lane of this kind exists, from the FILE signals only: a lane in
635
+ # stacks.json, and a stack name appearing in free prose. The authoritative signal
636
+ # — whether the active project's technical map carries `## /aw-spec-<command>` for the
637
+ # lane's phases — is resolve-tech's, and callers OR the two together.
638
+ function Invoke-LaneProbe {
639
+ param([string[]]$Rest)
640
+ $fwd = @()
641
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
642
+ switch ($Rest[$i]) {
643
+ # --flag=value, not two tokens: --hint carries raw user prose, and a value
644
+ # that starts with '-' reads as another flag when passed separately.
645
+ { $_ -in '--kind','--hint' } { $fwd += ($Rest[$i] + '=' + $Rest[++$i]) }
646
+ default { Write-Error "lane-probe: unknown arg '$($Rest[$i])'"; exit 1 }
647
+ }
648
+ }
649
+ Invoke-AwStackCore -Op 'probe' -Rest $fwd
650
+ }
651
+
652
+ # ─────────────────────────── list-features ─────────────────────────────────
653
+ # Twin of bash cmd_list_features: MERGE two feature sources, deduped by name
654
+ # (case-insensitive) — (1) specs/ sub-folders (already scaffolded/adopted) and
655
+ # (2) the OKF bundle's Aggregate concepts (the brownfield feature map, resolved by
656
+ # the SAME shared python core the bash twin calls). Empty source 2 on greenfield.
657
+ function Invoke-ListFeatures {
658
+ param([string[]]$Rest)
659
+ $preselect = ''; $bundle = ''
660
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
661
+ if ($Rest[$i] -in '--preselect','-Preselect') { $preselect = $Rest[++$i] }
662
+ elseif ($Rest[$i] -in '--bundle','-Bundle') { $bundle = $Rest[++$i] }
663
+ }
664
+ $repo = Get-RepoRoot
665
+ $base = Join-Path $repo 'specs'
666
+ $sentinel = '+ Create a new feature'
667
+ if (-not $bundle) { $bundle = 'knowledge' }
668
+ if (-not [System.IO.Path]::IsPathRooted($bundle)) { $bundle = Join-Path $repo $bundle }
669
+
670
+ # Best-effort OKF aggregate list (brownfield feature map). Empty on greenfield.
671
+ $okfAggs = @()
672
+ $py = Get-Py; $invpy = Join-Path $PSScriptRoot '..' 'python' 'aw-inventory.py'
673
+ if ($py -and (Test-Path $bundle) -and (Test-Path $invpy)) {
674
+ try {
675
+ $okfRaw = & $py $invpy list-aggregates --bundle $bundle 2>$null
676
+ if ($okfRaw) { $okfAggs = @((($okfRaw | ConvertFrom-Json).aggregates)) }
677
+ } catch { $okfAggs = @() }
678
+ }
679
+
680
+ $features = @(); $names = @(); $seen = @{}
681
+ if (Test-Path $base) {
682
+ foreach ($d in (Get-ChildItem -Directory $base -ErrorAction SilentlyContinue | Sort-Object Name)) {
683
+ $ver = ''; $activeChange = ''
684
+ $avf = Join-Path $d.FullName 'active_version.json'
685
+ if (Test-Path $avf) { try { $av = Get-Content $avf -Raw | ConvertFrom-Json; $ver = "$($av.version)"; $activeChange = "$($av.active_change)" } catch { $ver = '' } }
686
+ $names += $d.Name; $seen[$d.Name.ToLower()] = $true
687
+ $features += [ordered]@{
688
+ name = $d.Name
689
+ dir = "specs/$($d.Name)"
690
+ source = 'spec'
691
+ has_spec = (Test-Path (Join-Path $d.FullName 'spec.md'))
692
+ # 'design*.md', NOT 'design.*.md': the second dot made this miss the single
693
+ # `design.md` the framework actually produces (it only matched the retired
694
+ # design.domain.md / design.technical.md family). Informational count only.
695
+ # -like, not -Filter: -Filter delegates to the platform's filesystem matcher
696
+ # (which on Windows also honours 8.3 short names), so it is not guaranteed to
697
+ # agree with the bash twin's glob. -like is PowerShell's own engine — same
698
+ # result on every OS, which is what keeps the two scripts provably in parity.
699
+ design_docs = @(Get-ChildItem -Path $d.FullName -File -ErrorAction SilentlyContinue |
700
+ Where-Object { $_.Name -like 'design*.md' }).Count
701
+ version = $ver
702
+ active_change = $activeChange
703
+ }
704
+ }
705
+ }
706
+ # Merge OKF-discovered aggregates not already scaffolded under specs/.
707
+ foreach ($agg in $okfAggs) {
708
+ $nm = "$($agg.name)".Trim()
709
+ if (-not $nm -or $seen.ContainsKey($nm.ToLower())) { continue }
710
+ $names += $nm; $seen[$nm.ToLower()] = $true
711
+ $features += [ordered]@{
712
+ name = $nm
713
+ dir = "specs/$nm"
714
+ source = 'okf'
715
+ has_spec = $false
716
+ adopted = $false
717
+ concept_path = "$($agg.concept_path)"
718
+ design_docs = 0
719
+ version = ''
720
+ active_change = ''
721
+ }
722
+ }
723
+ # select_options = merged names + (injected new --preselect) + sentinel.
724
+ $opts = @($names)
725
+ if ($preselect -and (-not $seen.ContainsKey($preselect.ToLower()))) { $opts += $preselect }
726
+ $opts += $sentinel
727
+ Write-Json ([ordered]@{
728
+ features = @($features)
729
+ names = @($names)
730
+ count = $names.Count
731
+ select_options = @($opts)
732
+ sentinel = $sentinel
733
+ })
734
+ }
735
+
736
+ # ─────────────────────────── set-feature ───────────────────────────────────
737
+ function Invoke-SetFeature {
738
+ param([string[]]$Rest)
739
+ $raw = ''
740
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
741
+ if ($Rest[$i] -in '--feature','-Feature') { $raw = $Rest[++$i] }
742
+ }
743
+ if (-not $raw) { Write-Error 'set-feature: --feature is required'; exit 1 }
744
+
745
+ # Normalize to a PascalCase aggregate identifier (feature = aggregate).
746
+ $cleaned = ($raw -replace '[^A-Za-z0-9]+', ' ').Trim()
747
+ $feature = ConvertTo-Pascal -In $cleaned
748
+ if ($feature -notmatch '^[A-Za-z][A-Za-z0-9]*$') {
749
+ Write-Error "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric aggregate, e.g. Order)"; exit 1
750
+ }
751
+
752
+ $repo = Get-RepoRoot
753
+ $featureDirRel = "specs/$feature"
754
+ $featureDir = Join-Path $repo $featureDirRel
755
+ $existed = Test-Path $featureDir
756
+ New-Item -ItemType Directory -Force -Path $featureDir | Out-Null
757
+ New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver') | Out-Null
758
+ @{ feature_directory = $featureDirRel } | ConvertTo-Json -Compress |
759
+ Set-Content -Path (Join-Path $repo '.appweaver/feature.json') -Encoding UTF8
760
+
761
+ Write-Json ([ordered]@{
762
+ feature = $feature
763
+ feature_dir = $featureDirRel
764
+ spec_file = "$featureDirRel/spec.md"
765
+ existed = [bool]$existed
766
+ created = (-not $existed)
767
+ spec_exists = (Test-Path (Join-Path $featureDir 'spec.md'))
768
+ })
769
+ }
770
+
771
+ # ═══════════════════════════ active_version.json ════════════════════════════
772
+ # Per-feature version state. Shells out to the SAME python as the bash twin so the
773
+ # bump/timestamp logic is byte-identical across shells. (See bash _active_version.)
774
+ $PyActiveVersion = @'
775
+ import json, os, re, datetime, sys
776
+ fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]; cr = os.environ.get("AW_CR", "")
777
+ force = os.environ.get("AW_FORCE", "")
778
+ path = os.path.join(fd, "active_version.json")
779
+ feature = os.path.basename(fd.rstrip("/"))
780
+ def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
781
+ def load():
782
+ if os.path.isfile(path):
783
+ try: return json.load(open(path, encoding="utf-8")), True
784
+ except Exception: pass
785
+ return {"feature": feature, "version": "v1.0", "active_change": "", "updated": now()}, False
786
+ def save(d):
787
+ d["updated"] = now()
788
+ open(path, "w", encoding="utf-8").write(json.dumps(d, indent=2) + "\n")
789
+ def bump(v):
790
+ m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "v1.0").strip())
791
+ maj = int(m.group(1)) if m else 1
792
+ mnr = int(m.group(2)) if (m and m.group(2)) else 0
793
+ return "v%d.%d" % (maj, mnr + 1)
794
+
795
+ d, existed = load()
796
+ d.setdefault("feature", feature); d.setdefault("version", "v1.0"); d.setdefault("active_change", "")
797
+ out = {"feature": d["feature"], "version": d["version"], "active_change": d.get("active_change", ""),
798
+ "exists": existed, "path": "active_version.json", "ok": True}
799
+ if op == "read":
800
+ pass
801
+ elif op == "init":
802
+ if not existed: save(d)
803
+ out["created"] = (not existed)
804
+ elif op == "open":
805
+ cur = d.get("active_change", "")
806
+ if cur and cur != cr and force != "1": # a DIFFERENT change is already open -> refuse
807
+ out["ok"] = False
808
+ out["error"] = ("change '%s' is already open on this feature; finish it "
809
+ "(finalize-delta -> analyze-slice -> close-delta) before opening "
810
+ "'%s', or pass --force" % (cur, cr))
811
+ out["conflict"] = cur; out["requested"] = cr; out["has_change"] = True
812
+ print(json.dumps(out)); sys.exit(3)
813
+ d["active_change"] = cr; save(d)
814
+ out["active_change"] = cr; out["opened"] = cr
815
+ if cur and cur == cr: out["reopened"] = True # idempotent re-open of the same CR
816
+ elif op == "close":
817
+ prev = d["version"]; closed = d.get("active_change", "")
818
+ if closed: # a real change was open -> materialized -> bump
819
+ d["version"] = bump(prev)
820
+ d["active_change"] = ""; save(d)
821
+ out["version"] = d["version"]; out["active_change"] = ""
822
+ out["previous"] = prev; out["closed_change"] = closed
823
+ out["has_change"] = bool(out.get("active_change", "")) # clean branch signal for design
824
+ # What a close from THIS state would produce. One bump() authority, read by BOTH
825
+ # finalize-delta (to write the right number into the Change History rows it appends)
826
+ # and close-delta (which actually performs the bump and stamps the two headers), so
827
+ # the two stages can never disagree about the target. Equals `version` when no change
828
+ # is open, mirroring close's "only bump if a change was really open" guard.
829
+ out["next_version"] = bump(out["version"]) if out.get("active_change", "") else out["version"]
830
+ print(json.dumps(out))
831
+ '@
832
+
833
+ function Invoke-ActiveVersion {
834
+ param([string]$FeatureDir, [string]$Op, [string]$CrId = '', [string]$Force = '')
835
+ $py = Get-Py; if (-not $py) { Write-Error "version-$Op`: python3 required"; exit 1 }
836
+ $env:AW_FD = $FeatureDir; $env:AW_OP = $Op; $env:AW_CR = $CrId; $env:AW_FORCE = $Force
837
+ $PyActiveVersion | & $py - # sets $LASTEXITCODE=3 when op=open is refused (guard)
838
+ }
839
+
840
+ function Invoke-VersionState { # op in read|init (feature-dir only)
841
+ param([string]$Op, [string[]]$Rest)
842
+ $fdArg = ''
843
+ for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$i] } }
844
+ $repo = Get-RepoRoot
845
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
846
+ if (-not $fd) { Write-Error "version-$Op`: no feature set and no --feature-dir"; exit 1 }
847
+ Invoke-ActiveVersion -FeatureDir $fd -Op $Op
848
+ }
849
+
850
+
851
+
852
+ # ─────────────────────────── feature-paths ─────────────────────────────────
853
+ function Invoke-FeaturePaths {
854
+ param([string[]]$Rest)
855
+ $repo = Get-RepoRoot
856
+ $fj = Join-Path $repo '.appweaver/feature.json'
857
+ $fd = ''
858
+ if (Test-Path $fj) {
859
+ try { $fd = (Get-Content $fj -Raw | ConvertFrom-Json).feature_directory } catch { $fd = '' }
860
+ }
861
+ if (-not $fd) { Write-Error 'feature-paths: no feature set (run /appweaver select-feature first)'; exit 1 }
862
+ if (-not [System.IO.Path]::IsPathRooted($fd)) { $fd = Join-Path $repo $fd }
863
+ $feature = Split-Path $fd -Leaf # the aggregate name (feature = specs/<Feature>)
864
+ # The master documents of a feature: spec.md (WHAT/WHY) + design.md (the service
865
+ # lane's HOW) + design.ui.md (the frontend lane's HOW, where the feature has a UI
866
+ # half). plan.md / tasks.md belonged to the retired /aw-spec-plan + /aw-spec-tasks
867
+ # phases and had zero consumers.
868
+ #
869
+ # ui_design_exists IS THE UI-HALF SIGNAL for the whole CR loop. Every delta stage
870
+ # asks the same question — is there a UI master to keep in sync? — and the master's
871
+ # own presence answers it without re-deriving the lane declaration and the slice's
872
+ # UI file count at each stage. It is also self-correcting: whatever authors
873
+ # design.ui.md (adopt today) enrolls the feature in the UI half of the delta loop
874
+ # by the act of writing the file.
875
+ $spec = Join-Path $fd 'spec.md'
876
+ $design = Join-Path $fd 'design.md'
877
+ $uiDesign = Join-Path $fd 'design.ui.md'
878
+ Write-Json ([ordered]@{
879
+ feature = $feature
880
+ feature_dir = $fd
881
+ spec_file = $spec
882
+ design_file = $design
883
+ ui_design_file = $uiDesign
884
+ spec_exists = (Test-Path $spec)
885
+ design_exists = (Test-Path $design)
886
+ ui_design_exists = (Test-Path $uiDesign)
887
+ })
888
+ }
889
+
890
+ # ────────────────────────── checklist-read / -apply ────────────────────────
891
+ function Get-CheckboxLines {
892
+ param([string[]]$Lines)
893
+ $fence = $false; $n = 0; $items = @()
894
+ for ($i = 0; $i -lt $Lines.Count; $i++) {
895
+ $line = [string]$Lines[$i]
896
+ if ($line.Trim().StartsWith('```')) { $fence = -not $fence; continue }
897
+ if ($fence) { continue }
898
+ $m = [regex]::Match($line, '^\s*- \[([ xX])\]\s?(.*)$')
899
+ if ($m.Success) {
900
+ $n++
901
+ $items += [pscustomobject]@{
902
+ n = $n; index = $i
903
+ checked = ($m.Groups[1].Value -match '[xX]')
904
+ text = $m.Groups[2].Value.Trim()
905
+ }
906
+ }
907
+ }
908
+ return ,$items
909
+ }
910
+
911
+ function Invoke-ChecklistRead {
912
+ param([string[]]$Rest)
913
+ $file = ''
914
+ for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--file') { $file = $Rest[++$i] } }
915
+ if (-not $file) { Write-Error 'checklist-read: --file required'; exit 1 }
916
+ if (-not (Test-Path $file)) { Write-Json ([ordered]@{ exists = $false; total = 0; checked = 0; items = @() }); return }
917
+ $lines = Get-Content $file
918
+ $items = Get-CheckboxLines -Lines $lines
919
+ Write-Json ([ordered]@{
920
+ exists = $true; total = $items.Count
921
+ checked = @($items | Where-Object { $_.checked }).Count
922
+ items = @($items | ForEach-Object { [ordered]@{ n = $_.n; checked = $_.checked; text = $_.text } })
923
+ })
924
+ }
925
+
926
+ function Invoke-ChecklistApply {
927
+ param([string[]]$Rest)
928
+ $file = ''; $resultsRaw = '{}'
929
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
930
+ switch ($Rest[$i]) { '--file' { $file = $Rest[++$i] } '--results' { $resultsRaw = $Rest[++$i] } }
931
+ }
932
+ if (-not $file) { Write-Error 'checklist-apply: --file required'; exit 1 }
933
+ if (-not (Test-Path $file)) { Write-Json ([ordered]@{ exists = $false; before_checked = 0; after_checked = 0; total = 0; newly_passing = @(); regressions = @() }); return }
934
+ $results = @{}
935
+ try { (ConvertFrom-Json $resultsRaw).PSObject.Properties | ForEach-Object { $results[[int]$_.Name] = [bool]$_.Value } } catch {}
936
+ $lines = Get-Content $file
937
+ $items = Get-CheckboxLines -Lines $lines
938
+ $before = @($items | Where-Object { $_.checked }).Count
939
+ $newly = @(); $regress = @(); $after = 0
940
+ foreach ($it in $items) {
941
+ $desired = if ($results.ContainsKey($it.n)) { $results[$it.n] } else { $it.checked }
942
+ if ($desired -ne $it.checked) { if ($desired) { $newly += $it.n } else { $regress += $it.n } }
943
+ if ($desired) { $after++ }
944
+ $marker = if ($desired) { 'x' } else { ' ' }
945
+ $lines[$it.index] = $lines[$it.index] -replace '^(\s*- \[)[ xX](\].*)$', "`$1$marker`$2"
946
+ }
947
+ Set-Content -Path $file -Value $lines -Encoding UTF8
948
+ Write-Json ([ordered]@{ exists = $true; before_checked = $before; after_checked = $after; total = $items.Count; newly_passing = @($newly); regressions = @($regress) })
949
+ }
950
+
951
+
952
+
953
+ # ─────────────────────────────── detect-project ────────────────────────────
954
+ # Project Structure Gate: is there a scaffolded app (not just framework files)?
955
+ #
956
+ # EVERY ecosystem's manifest is searched to the same depth. A project is where its
957
+ # manifest is, not where the repository root is: a Maven service copied into
958
+ # backend/orderservice/ is as scaffolded as a .sln at the top. Searching *.sln
959
+ # recursively while testing pom.xml and package.json only at the root reported
960
+ # every non-.NET layout that nests its project as no-project-at-all, which reads
961
+ # at the gate as "run /appweaver foundation first" — advice a stack with no
962
+ # foundation bundle cannot take.
963
+ #
964
+ # --root scopes the search to one lane's subtree: the directory the calling phase
965
+ # is about to write into, so the answer is about that lane's project rather than
966
+ # about any project anywhere. It falls back to the whole repository when the
967
+ # subtree holds no manifest of its own, which is the ordinary .NET layout — a
968
+ # lane rooted at src/ under a solution file that sits beside it.
969
+ function Get-ProjectMarkers {
970
+ param([string]$Repo, [string]$Start)
971
+ # Anchored at the top level for the framework directories and at any depth for
972
+ # node_modules, matching the twin's -path/-name split.
973
+ $topPrune = @('.appweaver', 'guardrails', 'foundations', 'specs', '.git')
974
+ $names = @('*.sln','*.csproj','package.json','pom.xml','build.gradle','build.gradle.kts','go.mod','Cargo.toml','pyproject.toml')
975
+ $out = @(
976
+ Get-ChildItem -Path $Start -Recurse -File -Include $names -ErrorAction SilentlyContinue |
977
+ ForEach-Object { $_.FullName.Substring($Repo.Length).TrimStart('\','/') -replace '\\','/' } |
978
+ Where-Object {
979
+ $seg = $_.Split('/')
980
+ (-not ($topPrune -contains $seg[0])) -and (-not ($seg -contains 'node_modules'))
981
+ } |
982
+ Sort-Object -Unique -CaseSensitive | Select-Object -First 20
983
+ )
984
+ # Returned unwrapped so the caller's += appends the paths rather than the array.
985
+ return $out
986
+ }
987
+
988
+ function Invoke-DetectProject {
989
+ param([string[]]$Rest)
990
+ $scopeRoot = ''
991
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
992
+ switch ($Rest[$i]) {
993
+ { $_ -in '--root','--source-path','-Root' } { $scopeRoot = $Rest[++$i] }
994
+ }
995
+ }
996
+ $repo = Get-RepoRoot
997
+ $scope = 'repo'
998
+ $start = $repo
999
+ # A lane's source path arrives as the value declare stored, so it may carry a
1000
+ # trailing slash; an option label ("path — why") never reaches a script argument.
1001
+ $rel = ($scopeRoot -replace '\\','/').TrimStart('.','/').TrimEnd('/')
1002
+ if ($rel -and (Test-Path -PathType Container (Join-Path $repo $rel))) {
1003
+ $start = (Join-Path $repo $rel); $scope = 'lane'
1004
+ }
1005
+ $markers = @(Get-ProjectMarkers -Repo $repo -Start $start)
1006
+ if ($markers.Count -eq 0 -and $scope -eq 'lane') {
1007
+ $scope = 'repo'
1008
+ $markers = @(Get-ProjectMarkers -Repo $repo -Start $repo)
1009
+ }
1010
+ Write-Json ([ordered]@{ scaffolded = ($markers.Count -gt 0); markers = @($markers); scope = $scope })
1011
+ }
1012
+
1013
+
1014
+ # ───────────────────────── foundation resolution ───────────────────────────
1015
+ # Foundations live at foundations/<stack>/<Name>/, grouped by the technology stack
1016
+ # they scaffold. Names are unique across stacks, so callers pass a bare <Name> and
1017
+ # the lookup searches every stack. A stack also holds siblings that are not foundations
1018
+ # (components/), so carrying a *.pack.txt is what makes a directory one — without that
1019
+ # test they surface as bundle-less foundations in every lookup.
1020
+ function Get-FoundationDirs {
1021
+ param([string]$Repo)
1022
+ $root = Join-Path $Repo 'foundations'
1023
+ $out = @()
1024
+ if (Test-Path $root) {
1025
+ foreach ($s in (Get-ChildItem -Directory $root -ErrorAction SilentlyContinue)) {
1026
+ foreach ($d in (Get-ChildItem -Directory $s.FullName -ErrorAction SilentlyContinue)) {
1027
+ if (-not (Get-ChildItem -Path $d.FullName -Filter '*.pack.txt' -File -ErrorAction SilentlyContinue)) { continue }
1028
+ $out += [pscustomobject]@{ Name = $d.Name; Stack = $s.Name; Dir = $d.FullName; Rel = "foundations/$($s.Name)/$($d.Name)" }
1029
+ }
1030
+ }
1031
+ }
1032
+ # Returned unwrapped: call sites pipe this into Where-Object, and a ,$out wrapper
1033
+ # would hand the pipeline the array itself as one object instead of its elements.
1034
+ return $out
1035
+ }
1036
+
1037
+ # ──────────────────────────── foundation-list ──────────────────────────────
1038
+ # List available foundations (foundations/<stack>/<Name>/ with a real <Name>.pack.txt)
1039
+ # for the user to PICK — deterministic, no LLM. Optional --args fast path mirrors bash.
1040
+ function Invoke-FoundationList {
1041
+ param([string[]]$Rest)
1042
+ $raw = ''
1043
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { '--args' { $raw = $Rest[++$i] } } }
1044
+ $repo = Get-RepoRoot
1045
+
1046
+ # WHICH foundations this repo may be offered. A foundation bundle is a whole
1047
+ # compiling project in one ecosystem's languages and build system, so unpacking a
1048
+ # .NET one into a Spring repo does not produce a scaffold — it produces a second,
1049
+ # foreign project beside the real one. The declared lanes already say which
1050
+ # ecosystems this repo is, so filter to those and let the gate say why when that
1051
+ # leaves nothing. Nothing declared (a bare greenfield repo, before select-stack)
1052
+ # filters nothing: there is no answer yet to filter on.
1053
+ $laneEcos = @(Get-Ecosystems)
1054
+ $foundations = @(); $all = @(); $ecoOf = @()
1055
+ foreach ($f in @(Get-FoundationDirs $repo)) {
1056
+ if (-not (Test-Path (Join-Path $f.Dir "$($f.Name).pack.txt"))) { continue }
1057
+ $all += $f.Name; $ecoOf += $f.Stack
1058
+ if ($laneEcos.Count -eq 0 -or $laneEcos -contains $f.Stack) { $foundations += $f.Name }
1059
+ }
1060
+ $preName = ''; $preApp = ''; $outDir = '.'
1061
+ if ($raw) { $t = $raw -split '\s+' | Where-Object { $_ }; if ($t.Count -ge 1) { $preName = $t[0] }; if ($t.Count -ge 2) { $preApp = $t[1] }; if ($t.Count -ge 3) { $outDir = $t[2] } }
1062
+ $match = if ($foundations -contains $preName) { $preName } else { '' }
1063
+ Write-Json ([ordered]@{
1064
+ foundations = @($foundations); count = $foundations.Count
1065
+ all_foundations = @($all); all_count = $all.Count
1066
+ ecosystems = @($ecoOf); lane_ecosystems = @($laneEcos)
1067
+ filtered = ($laneEcos.Count -gt 0)
1068
+ preselect_name = $match; preselect_app = $preApp; out_dir = $outDir
1069
+ })
1070
+ }
1071
+
1072
+ function Invoke-FoundationLocate {
1073
+ param([string[]]$Rest)
1074
+ $name = ''; $app = ''; $out = '.'; $raw = ''
1075
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { '--name' { $name = $Rest[++$i] } '--app' { $app = $Rest[++$i] } '--out' { $out = $Rest[++$i] } '--args' { $raw = $Rest[++$i] } } }
1076
+ if ($raw) { $t = $raw -split '\s+' | Where-Object { $_ }; if ($t.Count -ge 1) { $name = $t[0] }; if ($t.Count -ge 2) { $app = $t[1] }; if ($t.Count -ge 3) { $out = $t[2] } }
1077
+ $repo = Get-RepoRoot
1078
+ $dirs = @(Get-FoundationDirs $repo)
1079
+ # available = every foundation dir (Get-FoundationDirs already requires a *.pack.txt)
1080
+ $available = @($dirs | ForEach-Object { $_.Name })
1081
+ function _fail($r) { Write-Json ([ordered]@{ ok = $false; reason = $r; available = @($available) }) }
1082
+ if (-not $name -or -not $app) { _fail 'Usage: /aw-spec-foundation <FoundationName> <AppName> [OutputDirectory]'; return }
1083
+ $found = $dirs | Where-Object { $_.Name -eq $name } | Select-Object -First 1
1084
+ if (-not $found) { _fail "Foundation '$name' not found (expected foundations/<stack>/$name/)"; return }
1085
+ $stack = $found.Stack; $rel = $found.Rel; $fdir = $found.Dir
1086
+ $pack = Join-Path $fdir "$name.pack.txt"
1087
+ if (-not (Test-Path $pack)) { _fail "Foundation '$name' has no bundle yet (expected $pack). Build it via the pack scripts in $rel/."; return }
1088
+ $runner = ''; $unpacker = ''
1089
+ if ((Get-Command pwsh -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $fdir 'unpack-foundation.ps1'))) { $runner = 'pwsh'; $unpacker = "$rel/unpack-foundation.ps1" }
1090
+ elseif (Test-Path (Join-Path $fdir 'unpack-foundation.sh')) { $runner = 'sh'; $unpacker = "$rel/unpack-foundation.sh" }
1091
+ else { _fail "No unpacker found in $rel/ (unpack-foundation.ps1 or .sh)"; return }
1092
+ $header = (Get-Content $pack -TotalCount 30 | Where-Object { $_ -like '#*' }) -join "`n"
1093
+ $sentinel = ''
1094
+ $sm = [regex]::Match($header, '(?i)sentinel[^A-Za-z0-9_.]*([A-Za-z0-9_.]+)')
1095
+ if ($sm.Success) { $sentinel = $sm.Groups[1].Value }
1096
+ $fc = 0; $fm = [regex]::Match($header, '(?i)file[^0-9]*([0-9]+)'); if ($fm.Success) { $fc = [int]$fm.Groups[1].Value }
1097
+ Write-Json ([ordered]@{
1098
+ ok = $true; reason = ''; name = $name; stack = $stack; app = $app; out_dir = $out
1099
+ pack_file = "$rel/$name.pack.txt"; unpacker = $unpacker; runner = $runner
1100
+ sentinel = $sentinel; file_count = $fc; available = @($available)
1101
+ })
1102
+ }
1103
+
1104
+ # ─────────────────────────── foundation-unpack ─────────────────────────────
1105
+ function Invoke-FoundationUnpack {
1106
+ param([string[]]$Rest)
1107
+ $name = ''; $app = ''; $out = '.'; $dry = $false; $force = 'no'; $reference = $false
1108
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
1109
+ switch ($Rest[$i]) { '--name' { $name = $Rest[++$i] } '--app' { $app = $Rest[++$i] } '--out' { $out = $Rest[++$i] } '--dry-run' { $dry = $true } '--force' { $force = $Rest[++$i] } '--reference' { $reference = $true } }
1110
+ }
1111
+ if (-not $name -or -not $app) { Write-Error 'foundation-unpack: --name and --app required'; exit 1 }
1112
+ $repo = Get-RepoRoot
1113
+ $found = @(Get-FoundationDirs $repo) | Where-Object { $_.Name -eq $name } | Select-Object -First 1
1114
+ $fdir = if ($found) { $found.Dir } else { Join-Path $repo "foundations/$name" }
1115
+ $packbase = if ($reference) { "$name.Reference" } else { $name }
1116
+ $stem = if ($reference) { 'unpack-reference-foundation' } else { 'unpack-foundation' }
1117
+ $pack = Join-Path $fdir "$packbase.pack.txt"
1118
+ if (-not (Test-Path $pack)) { Write-Json ([ordered]@{ ok = $false; mode = $(if ($dry) { 'dry' } else { 'real' }); written = 0; has_collisions = $false; stdout = "pack not found: $packbase.pack.txt" }); return }
1119
+ $cmd = $null; $cargs = @()
1120
+ if ((Get-Command pwsh -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $fdir "$stem.ps1"))) {
1121
+ $cmd = 'pwsh'; $cargs = @('-NoProfile','-File',(Join-Path $fdir "$stem.ps1"),'-PackFile',$pack,'-TargetAppName',$app,'-OutDir',$out)
1122
+ if ($dry) { $cargs += '-DryRun' }
1123
+ if ($force -eq 'yes' -or $reference) { $cargs += '-Force' }
1124
+ } elseif (Test-Path (Join-Path $fdir "$stem.sh")) {
1125
+ $cmd = 'bash'; $cargs = @((Join-Path $fdir "$stem.sh"),'--pack-file',$pack,'--target-app-name',$app,'--out-dir',$out)
1126
+ if ($dry) { $cargs += '--dry-run' }
1127
+ if ($force -eq 'yes' -or $reference) { $cargs += '--force' }
1128
+ } else { Write-Json ([ordered]@{ ok = $false; mode = 'real'; written = 0; has_collisions = $false; stdout = "no unpacker script for $name" }); return }
1129
+ $outp = & $cmd @cargs 2>&1 | Out-String
1130
+ $rc = $LASTEXITCODE
1131
+ # unpackers say "Unpacked N file(s) to …" / "DryRun: N file(s) would be written …"
1132
+ $written = 0; $wm = [regex]::Match($outp, '(?i)([0-9]+)\s+file'); if ($wm.Success) { $written = [int]$wm.Groups[1].Value }
1133
+ $hasColl = ($dry -and $rc -ne 0)
1134
+ Write-Json ([ordered]@{ ok = ($rc -eq 0); mode = $(if ($dry) { 'dry' } else { 'real' }); written = $written; has_collisions = $hasColl; stdout = ($outp.Substring([Math]::Max(0,$outp.Length-800))) })
1135
+ }
1136
+
1137
+ # ─────────────────────── foundation-stash-dbcontext ────────────────────────
1138
+ function Invoke-FoundationStashDbContext {
1139
+ param([string[]]$Rest)
1140
+ $out = '.'
1141
+ for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--out') { $out = $Rest[++$i] } }
1142
+ $repo = Get-RepoRoot
1143
+ if (-not [System.IO.Path]::IsPathRooted($out)) { $out = Join-Path $repo $out }
1144
+ $ctx = Get-ChildItem -Path (Join-Path $out 'src') -Recurse -Filter 'ApplicationDbContext.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1145
+ if (-not $ctx) { Write-Json ([ordered]@{ found = $false; dbcontext = ''; backup = '' }); return }
1146
+ $bak = "$ctx.foundation.bak"
1147
+ Copy-Item $ctx $bak
1148
+ Write-Json ([ordered]@{ found = $true; dbcontext = $ctx; backup = $bak })
1149
+ }
1150
+
1151
+ # ─────────────────────────── foundation-verify ─────────────────────────────
1152
+ function Invoke-FoundationVerify {
1153
+ param([string[]]$Rest)
1154
+ $out = '.'; $sentinel = ''
1155
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { '--out' { $out = $Rest[++$i] } '--sentinel' { $sentinel = $Rest[++$i] } } }
1156
+ $repo = Get-RepoRoot
1157
+ if (-not [System.IO.Path]::IsPathRooted($out)) { $out = Join-Path $repo $out }
1158
+ $residue = @()
1159
+ if ($sentinel -and (Test-Path $out)) {
1160
+ # scan the generated tree only — exclude bundle/framework dirs
1161
+ $residue = @(Get-ChildItem $out -Recurse -File -ErrorAction SilentlyContinue |
1162
+ Where-Object { $_.FullName -notmatch '[/\\](\.git|guardrails|foundations|\.appweaver|specs|node_modules)[/\\]' } |
1163
+ Where-Object { try { Select-String -Path $_.FullName -SimpleMatch $sentinel -Quiet -ErrorAction SilentlyContinue } catch { $false } } |
1164
+ Select-Object -First 50 | ForEach-Object { $_.FullName.Substring($repo.Length).TrimStart('\','/') -replace '\\','/' })
1165
+ }
1166
+ Write-Json ([ordered]@{ clean = ($residue.Count -eq 0); residue = @($residue) })
1167
+ }
1168
+
1169
+ # ─────────────────────────────── setup-constitution ────────────────────────
1170
+ # Ensure the split constitution files exist (copy from templates if missing):
1171
+ # .appweaver/memory/constitution.technical.md (from constitution-technical-template)
1172
+ # .appweaver/memory/constitution.domain.md (from constitution-domain-template)
1173
+ # .appweaver/memory/constitution.ui.md (from constitution-ui-template) — ONLY with --ui
1174
+ # The composed constitution.md is produced by compose-constitution after authoring.
1175
+ #
1176
+ # The UI half is opt-in because it is the only one whose ABSENCE is meaningful: a
1177
+ # backend-only project has no frontend lane and must look exactly as it did before
1178
+ # the lane model existed. The other two are always created and left as unauthored
1179
+ # templates when unused, which compose-constitution then skips.
1180
+ function Invoke-SetupConstitution {
1181
+ param([string[]]$Rest)
1182
+ # --ui / --no-technical follow the LANES the project declared. A half is a lane's
1183
+ # standing governance, so a lane the project does not have gets no half — and the
1184
+ # file is not created either, because an empty template left in memory/ is
1185
+ # indistinguishable from a half somebody forgot to author.
1186
+ $wantUi = $false; $wantTech = $true
1187
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
1188
+ switch ($Rest[$i]) {
1189
+ '--ui' { $wantUi = $true }
1190
+ '--no-technical' { $wantTech = $false }
1191
+ # An EMPTY argument is a flag slot the caller left unfilled. The engine builds
1192
+ # a step's `args:` list before it knows which branch it is in, so a workflow
1193
+ # that varies one flag has to choose between duplicating the whole step and
1194
+ # passing '' — and duplicating it is how this call came to recreate the
1195
+ # technical half that the earlier call had just been told to skip.
1196
+ '' { }
1197
+ default { Write-Error "setup-constitution: unknown arg '$($Rest[$i])'"; exit 1 }
1198
+ }
1199
+ }
1200
+ $repo = Get-RepoRoot
1201
+ New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver/memory') | Out-Null
1202
+ $tech = Join-Path $repo '.appweaver/memory/constitution.technical.md'
1203
+ $dom = Join-Path $repo '.appweaver/memory/constitution.domain.md'
1204
+ $ui = Join-Path $repo '.appweaver/memory/constitution.ui.md'
1205
+ $techCreated = $false; $domCreated = $false; $uiCreated = $false; $uiPath = ''
1206
+ # Full override/preset/extension/core resolution, matching the bash twin — and the
1207
+ # same resolution the placeholder check reads the template's token vocabulary from,
1208
+ # so a preset-supplied template is scored against itself rather than against core.
1209
+ if (-not $wantTech) { $tech = '' }
1210
+ if ($tech -and -not (Test-Path $tech)) {
1211
+ $tpl = Resolve-NamedTemplate -Name 'constitution-technical-template' -RepoRoot $repo
1212
+ if ($tpl) { Copy-Item $tpl $tech } else { New-Item -ItemType File -Force -Path $tech | Out-Null }
1213
+ $techCreated = $true
1214
+ }
1215
+ if (-not (Test-Path $dom)) {
1216
+ $tpl = Resolve-NamedTemplate -Name 'constitution-domain-template' -RepoRoot $repo
1217
+ if ($tpl) { Copy-Item $tpl $dom } else { New-Item -ItemType File -Force -Path $dom | Out-Null }
1218
+ $domCreated = $true
1219
+ }
1220
+ if ($wantUi) {
1221
+ $uiPath = $ui
1222
+ if (-not (Test-Path $ui)) {
1223
+ $tpl = Resolve-NamedTemplate -Name 'constitution-ui-template' -RepoRoot $repo
1224
+ if ($tpl) { Copy-Item $tpl $ui } else { New-Item -ItemType File -Force -Path $ui | Out-Null }
1225
+ $uiCreated = $true
1226
+ }
1227
+ }
1228
+ Write-Json ([ordered]@{ constitution_technical_file = $tech; constitution_domain_file = $dom; constitution_ui_file = $uiPath; technical_created = $techCreated; domain_created = $domCreated; ui_created = $uiCreated })
1229
+ }
1230
+
1231
+ # ─────────────────────────────── compose-constitution ──────────────────────
1232
+ # Deterministically concatenate the authored halves into the composed
1233
+ # constitution.md (human/compat view; NOT loaded by the pipeline phases). A half
1234
+ # that is absent or still an unauthored template is skipped, so a technical-only
1235
+ # project's composed file mirrors the technical half exactly.
1236
+ #
1237
+ # Order is technical → domain → ui. The domain half's principles CONTINUE the
1238
+ # technical base's roman numerals from wherever that base ended, so those two stay
1239
+ # adjacent; the UI half runs a parallel UX-* series and appends without interrupting
1240
+ # that run.
1241
+ #
1242
+ # NUMBERING CHECK. The boundary between the two roman-numeral halves is derived per
1243
+ # project — the technical half runs to whatever length its mapped technology base
1244
+ # defines — so a base of eighteen principles and a domain half that assumed it could
1245
+ # start at XVI would silently produce two Principle XVIs. Composition is the one place
1246
+ # both halves are read together, so it is where that is caught. A collision sets
1247
+ # numbering_ok=false; the file is still written, because a composed view that shows the
1248
+ # clash is more useful for fixing it than no file at all.
1249
+ $script:RomanValues = @(
1250
+ @(1000, 'M'), @(900, 'CM'), @(500, 'D'), @(400, 'CD'), @(100, 'C'), @(90, 'XC'),
1251
+ @(50, 'L'), @(40, 'XL'), @(10, 'X'), @(9, 'IX'), @(5, 'V'), @(4, 'IV'), @(1, 'I')
1252
+ )
1253
+
1254
+ function ConvertFrom-Roman {
1255
+ param([string]$Roman)
1256
+ $total = 0; $i = 0
1257
+ foreach ($pair in $script:RomanValues) {
1258
+ $val = $pair[0]; $sym = [string]$pair[1]
1259
+ while (($i + $sym.Length) -le $Roman.Length -and $Roman.Substring($i, $sym.Length) -ceq $sym) {
1260
+ $total += $val; $i += $sym.Length
1261
+ }
1262
+ }
1263
+ if ($i -ne $Roman.Length) { return $null }
1264
+ return $total
1265
+ }
1266
+
1267
+ function ConvertTo-Roman {
1268
+ param([int]$Value)
1269
+ $sb = ''
1270
+ foreach ($pair in $script:RomanValues) {
1271
+ $val = $pair[0]; $sym = [string]$pair[1]
1272
+ while ($Value -ge $val) { $sb += $sym; $Value -= $val }
1273
+ }
1274
+ return $sb
1275
+ }
1276
+
1277
+ # UNFILLED-PLACEHOLDER TEST. "Still a raw template" is decided by intersecting the
1278
+ # half's [TOKEN]s with the TOKENS ITS OWN KIT TEMPLATE DEFINES — never by the shape of
1279
+ # the bracket. A constitution is free to write [HIPAA], [ISO_13485] or a [GDPR](url)
1280
+ # link; those are project content and match no template token, so they can no longer
1281
+ # make a whole ratified half vanish from the composed file.
1282
+ function Get-TemplateTokens {
1283
+ param([string]$Name, [string]$RepoRoot)
1284
+ $tpl = Resolve-NamedTemplate -Name $Name -RepoRoot $RepoRoot
1285
+ if (-not $tpl) { return $null }
1286
+ $set = @{}
1287
+ foreach ($m in [regex]::Matches((Get-Content -Raw $tpl), '\[([A-Z][A-Z0-9_]*)\]')) {
1288
+ $set[$m.Groups[1].Value] = $true
1289
+ }
1290
+ return $set
1291
+ }
1292
+
1293
+ # The template's numbered slots are a series the author is told to extend, so
1294
+ # PRINCIPLE_6_NAME is as much an unfilled slot as PRINCIPLE_1_NAME even though the
1295
+ # template only shows five. Digit runs generalise; nothing else does, which is what keeps
1296
+ # a bracketed regulation code out of the family.
1297
+ function Test-TemplateToken {
1298
+ param([string]$Token, [hashtable]$Known)
1299
+ if ($Known.ContainsKey($Token)) { return $true }
1300
+ if ($Token -notmatch '\d') { return $false }
1301
+ foreach ($k in $Known.Keys) {
1302
+ if ($k -match '\d') {
1303
+ $rx = '\A' + ([regex]::Escape($k) -replace '\d+', '\d+') + '\Z'
1304
+ if ($Token -cmatch $rx) { return $true }
1305
+ }
1306
+ }
1307
+ return $false
1308
+ }
1309
+
1310
+ # Tokens in the file that its template also defines. Comments are stripped first so a
1311
+ # Sync Impact Report NAMING the placeholders it filled ("Removed: [PROJECT_NAME]")
1312
+ # cannot be mistaken for the placeholders themselves.
1313
+ function Get-UnfilledTokens {
1314
+ param([string]$Path, [hashtable]$Known)
1315
+ $found = @()
1316
+ if (-not (Test-Path $Path)) { return $found }
1317
+ $body = [regex]::Replace((Get-Content -Raw $Path), '(?s)<!--.*?-->', '')
1318
+ foreach ($m in [regex]::Matches($body, '\[([A-Z][A-Z0-9_]*)\]')) {
1319
+ $t = $m.Groups[1].Value
1320
+ if ($found -notcontains $t -and (Test-TemplateToken -Token $t -Known $Known)) { $found += $t }
1321
+ }
1322
+ return $found
1323
+ }
1324
+
1325
+ # A principle heading opens with its numeral: "### VII — Name" or "### VII. Name".
1326
+ # UX-* is a PARALLEL series belonging to the frontend lane and is deliberately not
1327
+ # part of this run, so it is skipped rather than checked against it.
1328
+ function Get-PrincipleNumbers {
1329
+ param([string]$Path)
1330
+ # Callers wrap the result in @(), which is what guarantees array-ness for the
1331
+ # empty and single-item cases; returning ,$found on top of that would nest.
1332
+ $found = @()
1333
+ if (-not (Test-Path $Path)) { return $found }
1334
+ $text = Get-Content -Raw $Path
1335
+ $body = [regex]::Replace($text, '<!--.*?-->', '', 'Singleline')
1336
+ foreach ($m in [regex]::Matches($body, '(?m)^#{2,4}[ \t]+(UX-)?([IVXLCDM]+)[ \t]*[—–\-.:)]')) {
1337
+ if ($m.Groups[1].Success) { continue } # UX- series — parallel, not this sequence
1338
+ $roman = $m.Groups[2].Value
1339
+ $n = ConvertFrom-Roman -Roman $roman
1340
+ # Round-trip + a sane ceiling keep ordinary words made of roman letters
1341
+ # (and stray four-digit numerals) out of the sequence.
1342
+ if ($null -ne $n -and $n -le 100 -and (ConvertTo-Roman -Value $n) -ceq $roman) {
1343
+ # An object, not a nested array: PowerShell unrolls arrays-of-arrays on
1344
+ # return and through the pipeline, which loses the pairing.
1345
+ $found += [PSCustomObject]@{ N = $n; Roman = $roman }
1346
+ }
1347
+ }
1348
+ return $found
1349
+ }
1350
+
1351
+ function Invoke-ComposeConstitution {
1352
+ param([string[]]$Rest)
1353
+ $repo = Get-RepoRoot
1354
+ $tech = Join-Path $repo '.appweaver/memory/constitution.technical.md'
1355
+ $dom = Join-Path $repo '.appweaver/memory/constitution.domain.md'
1356
+ $ui = Join-Path $repo '.appweaver/memory/constitution.ui.md'
1357
+ $out = Join-Path $repo '.appweaver/memory/constitution.md'
1358
+ # A project that declared no SERVICE lane authors no technical half, and that is a
1359
+ # complete constitution rather than a broken one — a UI-only project's governance is
1360
+ # the UI half plus the domain addenda. What is not composable is a project with
1361
+ # nothing at all, which means the halves were never authored.
1362
+ $includeTech = Test-Path $tech
1363
+ if (-not ($includeTech -or (Test-Path $dom) -or (Test-Path $ui))) {
1364
+ Write-Json ([ordered]@{ composed_file = $out; ok = $false; technical_included = $false; domain_included = $false; ui_included = $false; numbering_ok = $true; numbering_checked = $false; issues = @('no constitution half found — run /appweaver constitution') }); return
1365
+ }
1366
+ $header = @(
1367
+ '<!-- COMPOSED — generated by `aw-spec-actions compose-constitution`. Do not edit here.',
1368
+ ' Source of truth: the constitution halves — technical + domain + ui (edit those).',
1369
+ ' Pipeline phases load the halves directly; this composed view is for humans/compat. -->',
1370
+ ''
1371
+ ) -join "`n"
1372
+ $body = $header + "`n"
1373
+ if ($includeTech) { $body += (Get-Content -Raw $tech) }
1374
+ # Include a half only if authored — non-empty AND carrying none of its own kit
1375
+ # template's tokens (an unused half stays a raw template). A half whose template
1376
+ # cannot be resolved is included: never drop one on a guess.
1377
+ $tokDom = Get-TemplateTokens -Name 'constitution-domain-template' -RepoRoot $repo
1378
+ $tokUi = Get-TemplateTokens -Name 'constitution-ui-template' -RepoRoot $repo
1379
+ $includeDomain = $false
1380
+ if ((Test-Path $dom) -and ((Get-Item $dom).Length -gt 0)) {
1381
+ $includeDomain = ($null -eq $tokDom) -or (@(Get-UnfilledTokens -Path $dom -Known $tokDom).Count -eq 0)
1382
+ }
1383
+ $includeUi = $false
1384
+ if ((Test-Path $ui) -and ((Get-Item $ui).Length -gt 0)) {
1385
+ $includeUi = ($null -eq $tokUi) -or (@(Get-UnfilledTokens -Path $ui -Known $tokUi).Count -eq 0)
1386
+ }
1387
+ if ($includeDomain) {
1388
+ $body += "`n`n---`n`n" + (Get-Content -Raw $dom)
1389
+ }
1390
+ if ($includeUi) {
1391
+ $body += "`n`n---`n`n" + (Get-Content -Raw $ui)
1392
+ }
1393
+ Set-Content -Path $out -Value $body -NoNewline
1394
+
1395
+ # ── principle numbering ────────────────────────────────────────────────
1396
+ $techP = if ($includeTech) { @(Get-PrincipleNumbers -Path $tech) } else { @() }
1397
+ $domP = if ($includeDomain) { @(Get-PrincipleNumbers -Path $dom) } else { @() }
1398
+ $issues = @()
1399
+ $numberingOk = $true
1400
+
1401
+ # The cross-half check can only run on halves whose principles it can actually read.
1402
+ # A half numbered some other way (1., 2. / P-01) parses to nothing, and silence there
1403
+ # would look identical to a clean result — so say so instead of reporting all-clear.
1404
+ $numberingForm = "principles must be headed '### I — Title' or '### I. Title' (the UI half runs its own UX-I, UX-II, … series)"
1405
+ $numberingChecked = $true
1406
+ if (-not $includeTech) {
1407
+ # No service lane, so no technical half and no boundary to check: the domain
1408
+ # half opens the roman-numeral sequence at I instead of continuing one. Not an
1409
+ # issue, and not a check that silently passed — it is a check with no subject.
1410
+ $numberingChecked = $false
1411
+ } elseif ($techP.Count -eq 0) {
1412
+ $numberingOk = $false; $numberingChecked = $false
1413
+ $issues += ('no roman-numeral principle headings found in the technical half, so numbering could not be verified — ' + $numberingForm)
1414
+ }
1415
+ if ($includeDomain -and $domP.Count -eq 0) {
1416
+ $numberingOk = $false; $numberingChecked = $false
1417
+ $issues += ('no roman-numeral principle headings found in the domain half, so numbering could not be verified — ' + $numberingForm)
1418
+ }
1419
+
1420
+ foreach ($set in @(@{ Items = $techP; Label = 'technical' }, @{ Items = $domP; Label = 'domain' })) {
1421
+ $seen = @{}; $dup = @()
1422
+ foreach ($p in $set.Items) {
1423
+ if ($seen.ContainsKey($p.N) -and $dup -notcontains $p.Roman) { $dup += $p.Roman }
1424
+ $seen[$p.N] = $true
1425
+ }
1426
+ if ($dup.Count -gt 0) {
1427
+ $numberingOk = $false
1428
+ $issues += ('{0} half reuses principle number(s): {1}' -f $set.Label, ($dup -join ', '))
1429
+ }
1430
+ }
1431
+
1432
+ if ($techP.Count -gt 0 -and $domP.Count -gt 0) {
1433
+ $techNums = @($techP | ForEach-Object { $_.N })
1434
+ $domNums = @($domP | ForEach-Object { $_.N })
1435
+ $lastTech = ($techNums | Measure-Object -Maximum).Maximum
1436
+ $firstDom = ($domNums | Measure-Object -Minimum).Minimum
1437
+ $overlap = @($techNums | Where-Object { $domNums -contains $_ } | Sort-Object -Unique)
1438
+ if ($overlap.Count -gt 0) {
1439
+ $numberingOk = $false
1440
+ $issues += ('principle number(s) {0} appear in BOTH the technical and domain halves — the domain half must resume from the number after the technical half''s last principle ({1}), not from a fixed value' -f (($overlap | ForEach-Object { ConvertTo-Roman -Value $_ }) -join ', '), (ConvertTo-Roman -Value $lastTech))
1441
+ }
1442
+ elseif ($firstDom -ne ($lastTech + 1)) {
1443
+ $numberingOk = $false
1444
+ $issues += ('domain half starts at {0} but the technical half ends at {1} — numbering must run unbroken across the two halves (expected {2})' -f (ConvertTo-Roman -Value $firstDom), (ConvertTo-Roman -Value $lastTech), (ConvertTo-Roman -Value ($lastTech + 1)))
1445
+ }
1446
+ }
1447
+
1448
+ Write-Json ([ordered]@{
1449
+ composed_file = $out
1450
+ ok = $numberingOk
1451
+ domain_included = $includeDomain
1452
+ ui_included = $includeUi
1453
+ numbering_ok = $numberingOk
1454
+ numbering_checked = $numberingChecked
1455
+ issues = $issues
1456
+ technical_last = $(if ($techP.Count -gt 0) { ConvertTo-Roman -Value ((@($techP | ForEach-Object { $_.N }) | Measure-Object -Maximum).Maximum) } else { $null })
1457
+ domain_first = $(if ($domP.Count -gt 0) { ConvertTo-Roman -Value ((@($domP | ForEach-Object { $_.N }) | Measure-Object -Minimum).Minimum) } else { $null })
1458
+ })
1459
+ }
1460
+
1461
+ # ─────────────────────────── constitution-verify ───────────────────────────
1462
+ # Deterministic lint of a generated constitution (NO LLM): unfilled template
1463
+ # placeholders, body-vs-Sync-Impact-Report version agreement, and non-ISO dates.
1464
+ # Consumed by the constitution skill's bounded self-correction loop. ok=false => the
1465
+ # loop asks the model to patch.
1466
+ #
1467
+ # A placeholder is a token THE KIT TEMPLATE ACTUALLY DEFINES, resolved from the half's
1468
+ # own template and intersected with the file. Bracket SHAPE is not evidence: [HIPAA],
1469
+ # [ISO_13485] and [GDPR](url) are ordinary constitution content in a regulated domain
1470
+ # and must never be reported as unfilled slots. --template overrides the name derived
1471
+ # from the filename.
1472
+ #
1473
+ # The version is read from lines that OPEN with the label, last one wins, so an
1474
+ # "OpenAPI version 3.1.0" inside a principle cannot be mistaken for the document's
1475
+ # version. On a report line the LAST semver is the new version, which also survives an
1476
+ # "old -> new" whose arrow was mangled by an encoding round-trip.
1477
+ function Get-LabeledVersion {
1478
+ param([string]$Text, [switch]$LastOnLine)
1479
+ $vals = @()
1480
+ foreach ($line in ($Text -split "`n")) {
1481
+ if ($line -match '^[\s>*_#-]*version\b') {
1482
+ $sem = @([regex]::Matches($line, '\d+\.\d+\.\d+') | ForEach-Object { $_.Value })
1483
+ if ($sem.Count -gt 0) { $vals += $(if ($LastOnLine) { $sem[-1] } else { $sem[0] }) }
1484
+ }
1485
+ }
1486
+ if ($vals.Count -gt 0) { return $vals[-1] } else { return $null }
1487
+ }
1488
+ function Invoke-ConstitutionVerify {
1489
+ param([string[]]$Rest)
1490
+ $file = ''; $template = ''
1491
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
1492
+ if ($Rest[$i] -eq '--file') { $file = $Rest[++$i] }
1493
+ elseif ($Rest[$i] -eq '--template') { $template = $Rest[++$i] }
1494
+ }
1495
+ if (-not $file) { Write-Error 'constitution-verify: --file required'; exit 1 }
1496
+ if (-not (Test-Path $file)) {
1497
+ Write-Json ([ordered]@{ ok = $false; exists = $false; issues = @('constitution file not found');
1498
+ placeholders = @(); version_body = $null; version_report = $null; version_match = $null; bad_dates = @() })
1499
+ return
1500
+ }
1501
+ if (-not $template) {
1502
+ switch (Split-Path -Leaf $file) {
1503
+ 'constitution.technical.md' { $template = 'constitution-technical-template' }
1504
+ 'constitution.domain.md' { $template = 'constitution-domain-template' }
1505
+ 'constitution.ui.md' { $template = 'constitution-ui-template' }
1506
+ }
1507
+ }
1508
+ $known = $null
1509
+ if ($template) { $known = Get-TemplateTokens -Name $template -RepoRoot (Get-RepoRoot) }
1510
+ $text = Get-Content -Path $file -Raw
1511
+ # Strip HTML comments (template guidance + Sync Impact Report); keep the first as the report.
1512
+ $cm = [regex]::Match($text, '(?s)<!--.*?-->')
1513
+ $report = if ($cm.Success) { $cm.Value } else { '' }
1514
+ $body = [regex]::Replace($text, '(?s)<!--.*?-->', '')
1515
+ # 1. Unfilled slots = the file's [TOKEN]s intersected with the template's vocabulary.
1516
+ $templateMissing = ($null -eq $known)
1517
+ $placeholders = @()
1518
+ if (-not $templateMissing) {
1519
+ $placeholders = @([regex]::Matches($body, '\[([A-Z][A-Z0-9_]*)\]') |
1520
+ ForEach-Object { $_.Groups[1].Value } |
1521
+ Where-Object { Test-TemplateToken -Token $_ -Known $known } |
1522
+ Select-Object -Unique | Sort-Object)
1523
+ }
1524
+ # 2. Version: the document's own labelled version line vs the report's "new" version.
1525
+ $versionBody = Get-LabeledVersion $body
1526
+ $arrow = [regex]::Match($report, '(\d+\.\d+\.\d+)\s*(?:->|→|=>)\s*(\d+\.\d+\.\d+)')
1527
+ $versionReport = if ($arrow.Success) { $arrow.Groups[2].Value } else { Get-LabeledVersion $report -LastOnLine }
1528
+ $versionMatch = $null
1529
+ if ($versionBody -and $versionReport) { $versionMatch = ($versionBody -eq $versionReport) }
1530
+ # 3. Obviously non-ISO dates on ratification/amended/date lines (ISO never matches these).
1531
+ $bad = @()
1532
+ foreach ($line in ($body -split "`n")) {
1533
+ if ($line -match '(?i)ratif|amend|\bdate\b') {
1534
+ foreach ($mm in [regex]::Matches($line, '\b\d{1,2}/\d{1,2}/\d{2,4}\b')) { $bad += $mm.Value }
1535
+ foreach ($mm in [regex]::Matches($line, '\b\d{1,2}-\d{1,2}-\d{4}\b')) { $bad += $mm.Value }
1536
+ foreach ($mm in [regex]::Matches($line, '\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+\d{4}\b')) { $bad += $mm.Value }
1537
+ }
1538
+ }
1539
+ $bad = @($bad | Select-Object -Unique | Sort-Object)
1540
+ $issues = @()
1541
+ if ($placeholders.Count -gt 0) { $issues += ("{0} unfilled placeholder(s): {1}" -f $placeholders.Count, ($placeholders -join ', ')) }
1542
+ # Reported, not failed: a missing kit template is an install problem, and blocking
1543
+ # the authoring loop over it would help nobody.
1544
+ if ($templateMissing) { $issues += 'placeholder check skipped — no kit template resolved for this half' }
1545
+ if ($versionMatch -eq $false) { $issues += ("version mismatch: body {0} vs Sync Impact Report {1}" -f $versionBody, $versionReport) }
1546
+ if ($bad.Count -gt 0) { $issues += ("non-ISO date(s) (use YYYY-MM-DD): {0}" -f ($bad -join ', ')) }
1547
+ $ok = ($placeholders.Count -eq 0) -and ($versionMatch -ne $false) -and ($bad.Count -eq 0)
1548
+ Write-Json ([ordered]@{ ok = $ok; exists = $true; issues = @($issues);
1549
+ placeholders = @($placeholders); placeholder_check = (-not $templateMissing);
1550
+ version_body = $versionBody; version_report = $versionReport;
1551
+ version_match = $versionMatch; bad_dates = @($bad) })
1552
+ }
1553
+
1554
+ # ────────────────────────── agent-context-update ───────────────────────────
1555
+ function Invoke-AgentContextUpdate {
1556
+ param([string[]]$Rest)
1557
+ $plan = ''
1558
+ for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--plan') { $plan = $Rest[++$i] } }
1559
+ $repo = Get-RepoRoot
1560
+ $script = Join-Path $repo '.appweaver/extensions/agent-context/scripts/powershell/update-agent-context.ps1'
1561
+ if (-not (Test-Path $script)) {
1562
+ Write-Json ([ordered]@{ ok = $true; ran = $false; message = 'agent-context extension not installed; nothing to do' }); return
1563
+ }
1564
+ if ($plan) { $out = & pwsh -NoProfile -File $script $plan 2>&1 | Out-String }
1565
+ else { $out = & pwsh -NoProfile -File $script 2>&1 | Out-String }
1566
+ $rc = $LASTEXITCODE
1567
+ Write-Json ([ordered]@{ ok = ($rc -eq 0); ran = $true; message = ($out.Substring([Math]::Max(0,$out.Length-400))).Trim() })
1568
+ }
1569
+
1570
+
1571
+
1572
+ # ─────────────────────────────── list-components ───────────────────────────
1573
+ # Immediate subdirs of foundations/dotnet/components/ that contain an integration.md runbook.
1574
+ # Immediate subdirs of foundations/<ecosystem>/components/ that contain an integration.md
1575
+ # runbook, for the ecosystem the BACKEND lane declares. Components are wired into a host
1576
+ # project by editing its build files and its startup registration, so a component only
1577
+ # exists for the ecosystem whose build files and startup it knows how to edit — the
1578
+ # catalogue is per-ecosystem for the same reason the foundations are.
1579
+ function Invoke-ListComponents {
1580
+ param([string[]]$Rest)
1581
+ $repo = Get-RepoRoot
1582
+ $eco = Get-LaneField -Field 'backend_ecosystem'
1583
+ # No backend lane yet: 'dotnet' is the only ecosystem that ships components, so it is
1584
+ # what an undeclared repo would have been shown before the lane model and stays the
1585
+ # answer until select-stack says otherwise.
1586
+ if (-not $eco) { $eco = 'dotnet' }
1587
+ $base = Join-Path $repo "foundations/$eco/components"
1588
+ $comps = @()
1589
+ if (Test-Path $base) {
1590
+ foreach ($d in (Get-ChildItem -Directory $base -ErrorAction SilentlyContinue)) {
1591
+ if (Test-Path (Join-Path $d.FullName 'integration.md')) { $comps += $d.Name }
1592
+ }
1593
+ }
1594
+ Write-Json ([ordered]@{ components = @($comps); ecosystem = $eco; dir = "foundations/$eco/components" })
1595
+ }
1596
+
1597
+ # ─────────────────────────────── find-host-project ─────────────────────────
1598
+ # DELIBERATELY .NET-ONLY, and it reports which stack it looked at so a caller can say
1599
+ # so. 'Program.cs' is not a cross-stack concept: a Spring lane's entry point is an
1600
+ # @SpringBootApplication class, an Express lane's is whatever 'main' in package.json
1601
+ # names, and neither has the .csproj this returns. Callers that need a LAUNCH COMMAND
1602
+ # ask the lane instead (its declared 'run'); this exists for the two callers that need
1603
+ # the .NET host DIRECTORY. found:false on a non-.NET lane is the correct answer.
1604
+ function Invoke-FindHostProject {
1605
+ param([string[]]$Rest)
1606
+ $repo = Get-RepoRoot
1607
+ # WHERE the host lives. The service lane's declared source path when there is one,
1608
+ # 'src' when there is not — the same question run-migrate asks, and the same answer.
1609
+ $svcRoot = Get-LaneField -Field 'backend_root'
1610
+ if (-not $svcRoot) { $svcRoot = 'src' }
1611
+ $eco = Get-LaneField -Field 'backend_ecosystem'
1612
+ $searched = $svcRoot.TrimEnd('/')
1613
+ $prog = Get-ChildItem -Path (Join-Path $repo ($svcRoot.TrimEnd('/'))) -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1614
+ if (-not $prog) {
1615
+ $prog = Get-ChildItem -Path $repo -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue |
1616
+ Where-Object { $_.FullName -notmatch '[/\\](\.git|guardrails|foundations|specs|\.appweaver|obj|bin)[/\\]' } |
1617
+ Select-Object -First 1 -ExpandProperty FullName
1618
+ }
1619
+ if (-not $prog) { Write-Json ([ordered]@{ found = $false; program_cs = ''; project_dir = ''; csproj = ''; ecosystem = $eco; searched = $searched }); return }
1620
+ $pdir = Split-Path $prog -Parent
1621
+ $csproj = Get-ChildItem -Path $pdir -Filter *.csproj -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1622
+ Write-Json ([ordered]@{ found = $true; program_cs = $prog; project_dir = $pdir; csproj = $(if ($csproj) { $csproj } else { '' }); ecosystem = $eco; searched = $searched })
1623
+ }
1624
+
1625
+ # ─────────────────────────────── component-catalog ──────────────────────────
1626
+ # Deterministic parse of a component's integration.md "Implementation catalog"
1627
+ # markdown table (NO LLM). Returns {integrable, impl_keys[], implementations[{key,desc}]}.
1628
+ # Twin of the bash cmd_component_catalog: both shells delegate to the SAME python
1629
+ # core, so the parse and the JSON contract have one implementation and the pair
1630
+ # cannot drift.
1631
+ function Invoke-ComponentCatalog {
1632
+ param([string[]]$Rest)
1633
+ $file = ''
1634
+ for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--file') { $file = $Rest[++$i] } }
1635
+ if (-not $file) { Write-Error 'component-catalog: --file required'; exit 1 }
1636
+ $py = Get-Py
1637
+ if (-not $py) { Write-Error 'component-catalog: python3 is required to parse the catalog'; exit 1 }
1638
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-component-catalog.py'
1639
+ if (-not (Test-Path $core)) { Write-Error "component-catalog: core not found at $core"; exit 1 }
1640
+ & $py $core '--repo' (Get-RepoRoot) '--file' $file
1641
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
1642
+ }
1643
+
1644
+ # ─────────────────────────────── component-verify ───────────────────────────
1645
+ # Post-wiring verification (NO LLM decision): dotnet build (graceful skip if absent) +
1646
+ # R0.2a dual-registration guard. Returns {ok, ran, build_ok, errors[],
1647
+ # dual_registration_ok, fix_target, fix_reason, can_fix}.
1648
+ function Get-DefiningFiles {
1649
+ param([string]$Method, [string]$Root)
1650
+ $hits = @()
1651
+ if (-not $Root -or -not (Test-Path $Root)) { return , $hits }
1652
+ $pat = '\b' + [regex]::Escape($Method) + '\s*\('
1653
+ Get-ChildItem $Root -Recurse -File -Filter *.cs -ErrorAction SilentlyContinue |
1654
+ Where-Object { $_.FullName -notmatch '[/\\](bin|obj|\.git)[/\\]' } |
1655
+ ForEach-Object {
1656
+ $txt = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
1657
+ if ($txt -and ($txt -match $pat)) { $hits += [pscustomobject]@{ path = $_.FullName; text = $txt } }
1658
+ }
1659
+ return , $hits
1660
+ }
1661
+ function Invoke-ComponentVerify {
1662
+ param([string[]]$Rest)
1663
+ $hostDir = ''; $hostCsproj = ''; $marker = ''
1664
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
1665
+ switch ($Rest[$i]) { '--host-dir' { $hostDir = $Rest[++$i] } '--host-csproj' { $hostCsproj = $Rest[++$i] } '--marker' { $marker = $Rest[++$i] } }
1666
+ }
1667
+ $repo = Get-RepoRoot
1668
+ $marker = $marker.Trim()
1669
+ function RelP($p) { if ($p -and $repo) { try { return ([System.IO.Path]::GetRelativePath($repo, $p) -replace '\\', '/') } catch { return $p } } else { return $p } }
1670
+ # --- (1) build ---
1671
+ $ran = $false; $buildOk = $true; $errors = @(); $primary = ''
1672
+ if (Get-Command dotnet -ErrorAction SilentlyContinue) {
1673
+ $target = if ($hostCsproj) { $hostCsproj } else { $hostDir }
1674
+ if ($target -and (Test-Path $target)) {
1675
+ $ran = $true
1676
+ $out = (& dotnet build $target -nologo 2>&1 | Out-String)
1677
+ $code = $LASTEXITCODE
1678
+ foreach ($line in ($out -split "`n")) { if ($line -match ':\s*error\s') { $errors += $line.Trim() } }
1679
+ $errors = @($errors | Select-Object -Unique | Select-Object -First 20)
1680
+ $buildOk = ($code -eq 0)
1681
+ if (-not $buildOk -and $errors.Count -gt 0) {
1682
+ $m = [regex]::Match($errors[0], '^(.*?)\(\d+,\d+\)'); if ($m.Success) { $primary = RelP ($m.Groups[1].Value.Trim()) }
1683
+ }
1684
+ if (-not $buildOk -and -not $primary -and $hostCsproj) { $primary = RelP $hostCsproj }
1685
+ }
1686
+ }
1687
+ # --- (2) R0.2a dual-registration guard ---
1688
+ $searchRoot = if ($hostDir -and (Test-Path $hostDir)) { $hostDir } else { $repo }
1689
+ $infra = @(Get-DefiningFiles 'AddInfrastructure' $searchRoot)
1690
+ $aspire = @(Get-DefiningFiles 'AddAspireInfrastructure' $searchRoot)
1691
+ $dualApp = ($infra.Count -gt 0 -and $aspire.Count -gt 0)
1692
+ $dualOk = $true; $dualNote = ''; $dualFix = ''
1693
+ function Pick-Fix($hits) {
1694
+ $nonProg = @($hits | Where-Object { [System.IO.Path]::GetFileName($_.path) -ne 'Program.cs' })
1695
+ if ($nonProg.Count -gt 0) { return RelP $nonProg[0].path } elseif ($hits.Count -gt 0) { return RelP $hits[0].path } else { return '' }
1696
+ }
1697
+ if (-not $dualApp) { $dualNote = 'single registration path (R0.2) — dual-registration check N/A' }
1698
+ elseif (-not $marker) { $dualNote = 'both AddInfrastructure and AddAspireInfrastructure exist; no marker provided to verify the component is in both' }
1699
+ else {
1700
+ $inInfra = @($infra | Where-Object { $_.text.Contains($marker) }).Count -gt 0
1701
+ $inAspire = @($aspire | Where-Object { $_.text.Contains($marker) }).Count -gt 0
1702
+ $dualOk = ($inInfra -and $inAspire)
1703
+ if (-not $dualOk) {
1704
+ $missing = if (-not $inInfra) { 'AddInfrastructure' } else { 'AddAspireInfrastructure' }
1705
+ $dualFix = Pick-Fix ($(if (-not $inInfra) { $infra } else { $aspire }))
1706
+ $dualNote = "marker '$marker' missing from $missing (R0.2a: register in BOTH paths)"
1707
+ }
1708
+ else { $dualNote = "marker '$marker' present in both registration paths" }
1709
+ }
1710
+ $ok = ($buildOk -and $dualOk)
1711
+ $fixTarget = ''; $fixReason = ''
1712
+ if (-not $buildOk) { $fixTarget = if ($primary) { $primary } elseif ($hostCsproj) { RelP $hostCsproj } else { '' }; $fixReason = 'build failed: ' + ((@($errors) | Select-Object -First 5) -join ' | ') }
1713
+ elseif (-not $dualOk) { $fixTarget = $dualFix; $fixReason = $dualNote }
1714
+ Write-Json ([ordered]@{ ok = $ok; ran = $ran; build_ok = $buildOk; errors = @($errors); primary_error_file = $primary;
1715
+ dual_applicable = $dualApp; dual_registration_ok = $dualOk; dual_note = $dualNote; dual_fix_file = $dualFix;
1716
+ fix_target = $fixTarget; fix_reason = $fixReason; can_fix = [bool]$fixTarget })
1717
+ }
1718
+
1719
+ # ═════════════════ spec-anchored design pipeline (parity with bash) ═════════
1720
+ # Mirror of the design/build/analyze-slice/run/deploy/cr subcommands. The fence parsers
1721
+ # (extract/reconcile/paths) shell out to the SAME python3 as bash so the logic
1722
+ # is byte-identical; the rest is native. See .appweaver/DESIGN-FORMAT.md.
1723
+ # NOTE: authored for Windows parity; smoke-test under pwsh before relying on it.
1724
+
1725
+ function Get-Py {
1726
+ if (Get-Command python3 -ErrorAction SilentlyContinue) { return 'python3' }
1727
+ if (Get-Command python -ErrorAction SilentlyContinue) { return 'python' }
1728
+ return $null
1729
+ }
1730
+
1731
+ function Read-FeatureDir {
1732
+ param([string]$RepoRoot)
1733
+ $fj = Join-Path $RepoRoot '.appweaver/feature.json'
1734
+ if (-not (Test-Path $fj)) { return '' }
1735
+ try { return (Get-Content $fj -Raw | ConvertFrom-Json).feature_directory } catch { return '' }
1736
+ }
1737
+
1738
+ function Resolve-FeatureDir {
1739
+ param([string]$Arg, [string]$RepoRoot)
1740
+ $fd = $Arg
1741
+ if (-not $fd) { $fd = Read-FeatureDir -RepoRoot $RepoRoot }
1742
+ if (-not $fd) { return $null }
1743
+ if (-not [System.IO.Path]::IsPathRooted($fd)) { $fd = Join-Path $RepoRoot $fd }
1744
+ return $fd
1745
+ }
1746
+
1747
+ function Resolve-NamedTemplate {
1748
+ param([string]$Name, [string]$RepoRoot)
1749
+ $base = Join-Path $RepoRoot '.appweaver/templates'
1750
+ $file = "$Name.md"
1751
+ $override = Join-Path $base "overrides/$file"
1752
+ if (Test-Path $override) { return $override }
1753
+ $presets = Join-Path $RepoRoot '.appweaver/presets'
1754
+ if (Test-Path $presets) {
1755
+ $hit = Get-ChildItem -Directory $presets -ErrorAction SilentlyContinue |
1756
+ ForEach-Object { Join-Path $_.FullName "templates/$file" } |
1757
+ Where-Object { Test-Path $_ } | Select-Object -First 1
1758
+ if ($hit) { return $hit }
1759
+ }
1760
+ $exts = Join-Path $RepoRoot '.appweaver/extensions'
1761
+ if (Test-Path $exts) {
1762
+ $hit = Get-ChildItem -Directory $exts -ErrorAction SilentlyContinue |
1763
+ Where-Object { -not $_.Name.StartsWith('.') } |
1764
+ ForEach-Object { Join-Path $_.FullName "templates/$file" } |
1765
+ Where-Object { Test-Path $_ } | Select-Object -First 1
1766
+ if ($hit) { return $hit }
1767
+ }
1768
+ $core = Join-Path $base $file
1769
+ if (Test-Path $core) { return $core }
1770
+ return $null
1771
+ }
1772
+
1773
+ # The former path-tagged helpers (design-extract / -reconcile / -paths) are GONE, in
1774
+ # lockstep with the bash twin. They belonged to a retired four-document model in which
1775
+ # design.{domain,application,api,infra}.md carried the source inside ```lang path=…
1776
+ # fences. Nothing emits that fence any more, and their glob (`design.*.md`) could not
1777
+ # match a single `design.md` even if something did — so all three returned nothing,
1778
+ # silently, at exit 0. "Which files is this slice made of" is now an OKF query:
1779
+ # aw-xref concept-paths to read, concept-upsert --merge to write.
1780
+ function Invoke-DesignScaffold {
1781
+ param([string[]]$Rest)
1782
+ $fdArg = ''; $tplName = ''; $lane = 'service'
1783
+ # --lane service|ui: WHOSE design this is. It selects a coherent (template,
1784
+ # document) PAIR rather than two independent flags — service scaffolds
1785
+ # design-template into design.md, ui scaffolds ui-design-template into
1786
+ # design.ui.md — so no caller can write the service template under the UI lane's
1787
+ # filename. Default `service` leaves every existing caller byte-identical.
1788
+ # --template <name> overrides the TEMPLATE half only; the document name follows
1789
+ # --lane. In a brownfield repo onboard GENERATES a code-derived override for both
1790
+ # names, so the defaults already yield the as-built structure.
1791
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--lane','-Lane' } { $lane = $Rest[++$i] } { $_ -in '--template','-Template' } { $tplName = $Rest[++$i] } } }
1792
+ switch ($lane) {
1793
+ 'service' { $docName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
1794
+ 'ui' { $docName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
1795
+ default { Write-Error ("design-scaffold: unknown --lane '" + $lane + "' (service|ui)"); exit 1 }
1796
+ }
1797
+ $repo = Get-RepoRoot
1798
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
1799
+ if (-not $fd) { Write-Error 'design-scaffold: no feature set (run /appweaver select-feature first) and no --feature-dir'; exit 1 }
1800
+ New-Item -ItemType Directory -Force -Path $fd | Out-Null
1801
+ $target = Join-Path $fd $docName
1802
+ if (Test-Path $target) { $created = $false }
1803
+ else {
1804
+ $tpl = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
1805
+ if ($tpl -and (Test-Path $tpl)) { Copy-Item $tpl $target } else { New-Item -ItemType File -Force -Path $target | Out-Null }
1806
+ $created = $true
1807
+ }
1808
+ # override_active: is a PROJECT-level override installed for this template name?
1809
+ # Computed ALWAYS (a stable signal on bootstrap AND delta). onboard installs the
1810
+ # code-derived brownfield template as the `design-template` override, so
1811
+ # override_active=true means the forward design phase should author brownfield.
1812
+ $overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
1813
+ $templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
1814
+ $tsOut = if ($templateSource) { $templateSource } else { '' }
1815
+ Write-Json ([ordered]@{ feature_dir = $fd; design_doc = $target; lane = $lane; created = $created; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
1816
+ }
1817
+
1818
+ # Read one field out of the lane model. Twin of the bash inline python readers: the
1819
+ # shells never parse stacks.json themselves, so its schema has one reader.
1820
+ function Get-LaneField {
1821
+ param([string]$Field, [string]$Kind)
1822
+ $py = Get-Py
1823
+ if (-not $py) { return '' }
1824
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-stack.py'
1825
+ if (-not (Test-Path $core)) { return '' }
1826
+ $argsList = @('--repo', (Get-RepoRoot), 'resolve')
1827
+ if ($Kind) { $argsList += @('--kind', $Kind) }
1828
+ try {
1829
+ $line = (& $py $core @argsList 2>$null | Select-Object -First 1)
1830
+ if (-not $line) { return '' }
1831
+ $v = ($line | ConvertFrom-Json).$Field
1832
+ if ($null -eq $v) { return '' }
1833
+ return [string]$v
1834
+ } catch { return '' }
1835
+ }
1836
+
1837
+ # Every ecosystem the declared lanes belong to, deduped and empty when nothing is
1838
+ # declared. This keys the stack-partitioned corpora under foundations/<ecosystem>/ —
1839
+ # a Java repo has no business being offered a .NET bundle, and the lane model already
1840
+ # knows which it is. Twin of the bash _aw_ecosystems.
1841
+ function Get-Ecosystems {
1842
+ $out = @()
1843
+ foreach ($k in @('backend_ecosystem', 'frontend_ecosystem')) {
1844
+ $v = Get-LaneField -Field $k
1845
+ if ($v -and ($out -notcontains $v)) { $out += $v }
1846
+ }
1847
+ # NOT comma-wrapped. Every call site already writes @(Get-Ecosystems), and the
1848
+ # wrapper would make that a one-element array CONTAINING the array — which counts
1849
+ # as 1, contains no ecosystem name, and matches nothing.
1850
+ return $out
1851
+ }
1852
+
1853
+ function Invoke-BuildCompile {
1854
+ param([string[]]$Rest)
1855
+ $repo = Get-RepoRoot
1856
+
1857
+ # A DECLARED lane model is the answer, and it is a better answer than the ladder
1858
+ # below in exactly the case that matters: a repo with two lanes. The sniffer takes
1859
+ # the first toolchain it finds and silently never builds the second, which reports
1860
+ # green for a frontend that was never compiled.
1861
+ if ((Get-LaneField -Field 'lane_count') -and (Get-LaneField -Field 'lane_count') -ne '0') {
1862
+ Invoke-AwStackCore -Op 'run-phase' -Rest @('--phase', 'build')
1863
+ return
1864
+ }
1865
+
1866
+ # No declaration: the historic first-match-wins ladder, unchanged.
1867
+ $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
1868
+ $hasSln = @(Get-ChildItem -Path $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
1869
+ $hasCsproj = @(Get-ChildItem -Path (Join-Path $repo 'src') -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
1870
+ if ($hasSln -or $hasCsproj) {
1871
+ $tool = 'dotnet'
1872
+ if (Get-Command dotnet -ErrorAction SilentlyContinue) {
1873
+ $cmd = 'dotnet build --nologo'; $ran = $true
1874
+ Push-Location $repo; try { $log = (dotnet build --nologo 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
1875
+ } else { $ok = $false }
1876
+ } elseif ((Test-Path (Join-Path $repo 'package.json')) -and (Get-Command npm -ErrorAction SilentlyContinue)) {
1877
+ $tool = 'npm'; $cmd = 'npm run build --if-present'; $ran = $true
1878
+ Push-Location $repo; try { $log = (npm run build --if-present 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
1879
+ }
1880
+ $tail = if ($log.Length -gt 1400) { $log.Substring($log.Length - 1400) } else { $log }
1881
+ Write-Json ([ordered]@{ ok = $ok; tool = $tool; ran = $ran; cmd = $cmd; log_tail = $tail })
1882
+ }
1883
+
1884
+ function Invoke-TestRun {
1885
+ param([string[]]$Rest)
1886
+ # --scope repo (default): run the whole suite — the historic behaviour, every existing
1887
+ # caller is unaffected.
1888
+ # --scope slice : run ONLY the tests whose FullyQualifiedName contains the
1889
+ # feature's aggregate name (feature-dir basename) plus any --also referrer
1890
+ # names — the per-feature integrity scope. .NET only; npm/none ignore it
1891
+ # and run the full suite. (Kept byte-for-byte in step with the bash cmd_test_run.)
1892
+ $featureDir = ''; $scope = 'repo'; $also = ''
1893
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
1894
+ switch ($Rest[$i]) {
1895
+ { $_ -in '--feature-dir','-FeatureDir' } { $featureDir = $Rest[++$i] }
1896
+ { $_ -in '--scope','-Scope' } { $scope = $Rest[++$i] }
1897
+ { $_ -in '--also','-Also' } { $also = $Rest[++$i] }
1898
+ }
1899
+ }
1900
+ $repo = Get-RepoRoot
1901
+
1902
+ # For scope=slice, build the token set = feature aggregate name + --also referrers, each
1903
+ # sanitised to an identifier (VSTest filter operators |&()!=~ are illegal) and de-duped.
1904
+ # A surviving token becomes a `FullyQualifiedName~<tok>` clause; substring match is
1905
+ # deliberate — it over-includes (safe for an integrity fence) rather than under-scoping.
1906
+ $slices = @()
1907
+ if ($scope -eq 'slice') {
1908
+ $names = @()
1909
+ if ($featureDir) { $names += (Split-Path -Leaf ($featureDir.TrimEnd('/','\'))) }
1910
+ if ($also) { $names += ($also -split ',') }
1911
+ foreach ($name in $names) {
1912
+ $tok = ($name -replace '[^A-Za-z0-9._-]','')
1913
+ if ($tok -notmatch '^[A-Za-z]') { continue } # must start with a letter
1914
+ if ($slices -notcontains $tok) { $slices += $tok } # de-dup
1915
+ }
1916
+ }
1917
+
1918
+ $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
1919
+ $matched = $false; $scoped = $false; $fenceGreen = $false; $filter = ''
1920
+ $hasSln = @(Get-ChildItem -Path $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
1921
+ $hasCsproj = @(Get-ChildItem -Path $repo -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
1922
+ $dotnetPresent = ($hasSln -or $hasCsproj)
1923
+
1924
+ # A DECLARED lane model answers this the same way it answers build-compile, and for
1925
+ # the same reason: the ladder below recognises .NET and npm-at-the-root, and reports
1926
+ # `tool:"none" ran:false ok:true` for everything else. On a repo whose lane declares
1927
+ # `./mvnw test` that is a suite which never ran scoring the integrity fence GREEN.
1928
+ #
1929
+ # scope=slice keeps the .NET path whenever a .NET toolchain is present, because
1930
+ # VSTest's --filter is the only per-test selector any shipped profile has. Elsewhere a
1931
+ # slice run delegates and widens to the lane's whole suite, which the fence tolerates
1932
+ # by design, and reports scoped=false so fence_green comes from `ok`.
1933
+ # (Kept in step with the bash cmd_test_run.)
1934
+ $laneCount = Get-LaneField -Field 'lane_count'
1935
+ $useLanes = $false
1936
+ if ($laneCount -and $laneCount -ne '0') {
1937
+ if ($scope -ne 'slice' -or -not $dotnetPresent) { $useLanes = $true }
1938
+ }
1939
+ if ($useLanes) {
1940
+ $py = Get-Py
1941
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-stack.py'
1942
+ $line = (& $py $core '--repo' $repo 'run-phase' '--phase' 'test' | Select-Object -First 1)
1943
+ $d = if ($line) { $line | ConvertFrom-Json } else { $null }
1944
+ $dLog = if ($d -and $d.log_tail) { [string]$d.log_tail } else { '' }
1945
+ $dOk = [bool]($d -and $d.ok)
1946
+ Write-Json ([ordered]@{
1947
+ ok = $dOk
1948
+ tool = $(if ($d -and $d.tool) { [string]$d.tool } else { 'lanes' })
1949
+ ran = [bool]($d -and $d.ran)
1950
+ scope = $scope
1951
+ slices = ($slices -join ' ')
1952
+ scoped = $false
1953
+ matched = ($dLog -match 'Total:\s*[1-9]')
1954
+ fence_green = $dOk
1955
+ cmd = $(if ($d -and $d.cmd) { [string]$d.cmd } else { '' })
1956
+ log_tail = $dLog
1957
+ lanes_run = @(if ($d) { $d.lanes_run } else { @() })
1958
+ lanes_skipped = @(if ($d) { $d.lanes_skipped } else { @() })
1959
+ })
1960
+ return
1961
+ }
1962
+
1963
+ if ($dotnetPresent) {
1964
+ $tool = 'dotnet'
1965
+ if (Get-Command dotnet -ErrorAction SilentlyContinue) {
1966
+ if ($scope -eq 'slice') {
1967
+ if ($slices.Count -eq 0) {
1968
+ # scope=slice but nothing valid to filter on — fail loudly, never
1969
+ # silently fall back to a repo-wide run.
1970
+ $log = "scope=slice but no valid filter tokens from --feature-dir/--also`n"
1971
+ $ok = $false; $ran = $false; $cmd = '(no filter tokens)'
1972
+ } else {
1973
+ $filter = ($slices | ForEach-Object { "FullyQualifiedName~$_" }) -join '|'
1974
+ $scoped = $true; $cmd = "dotnet test --nologo --filter `"$filter`""; $ran = $true
1975
+ Push-Location $repo; try { $log = (dotnet test --nologo --filter $filter 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
1976
+ }
1977
+ } else {
1978
+ $cmd = 'dotnet test --nologo'; $ran = $true
1979
+ Push-Location $repo; try { $log = (dotnet test --nologo 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
1980
+ }
1981
+ } else { $ok = $false }
1982
+ } elseif ((Test-Path (Join-Path $repo 'package.json')) -and (Get-Command npm -ErrorAction SilentlyContinue)) {
1983
+ $tool = 'npm'; $cmd = 'npm test'; $ran = $true
1984
+ Push-Location $repo; try { $log = (npm test 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
1985
+ }
1986
+
1987
+ # matched = at least one test was actually selected + run. Read from the LOG, never the
1988
+ # exit code: a .NET --filter that selects ZERO tests still EXITS 0, so trusting the exit
1989
+ # code would report an empty scoped run as green (the vacuous-pass trap).
1990
+ if ($log -match 'Total:\s*[1-9]') { $matched = $true }
1991
+
1992
+ # fence_green — a green that actually proves something ran.
1993
+ # scoped run → ok AND matched (guards the zero-match vacuous pass)
1994
+ # everything else (repo suite, npm, no test tool, the loud no-token fail) → ok.
1995
+ if ($scoped) { $fenceGreen = ($ok -and $matched) } else { $fenceGreen = $ok }
1996
+
1997
+ $tail = if ($log.Length -gt 1400) { $log.Substring($log.Length - 1400) } else { $log }
1998
+ Write-Json ([ordered]@{ ok = $ok; tool = $tool; ran = $ran; scope = $scope; slices = ($slices -join ' '); scoped = $scoped; matched = $matched; fence_green = $fenceGreen; cmd = $cmd; log_tail = $tail })
1999
+ }
2000
+
2001
+ function Invoke-RunMigrate {
2002
+ param([string[]]$Rest)
2003
+ $repo = Get-RepoRoot
2004
+
2005
+ # A DECLARED migration command wins outright. Schema migration is the phase stacks
2006
+ # answer most differently — Flyway, Liquibase, Alembic, Prisma, golang-migrate — and
2007
+ # no sniffer can pick between tools that happily coexist in one repo. When a lane
2008
+ # declares 'migrate' in .appweaver/stacks.json, that IS the answer and it runs in the
2009
+ # lane's own working directory like every other phase command.
2010
+ if ((Get-LaneField -Field 'backend_migrate') -or (Get-LaneField -Field 'frontend_migrate')) {
2011
+ $py = Get-Py
2012
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-stack.py'
2013
+ $line = (& $py $core '--repo' $repo 'run-phase' '--phase' 'migrate' | Select-Object -First 1)
2014
+ $d = if ($line) { $line | ConvertFrom-Json } else { $null }
2015
+ Write-Json ([ordered]@{
2016
+ ok = [bool]($d -and $d.ok)
2017
+ tool = 'lanes'
2018
+ ran = [bool]($d -and $d.ran)
2019
+ cmd = $(if ($d) { [string]$d.cmd } else { '' })
2020
+ log_tail = $(if ($d) { [string]$d.log_tail } else { '' })
2021
+ lanes_run = @(if ($d) { $d.lanes_run } else { @() })
2022
+ })
2023
+ return
2024
+ }
2025
+
2026
+ # WHERE to look for the migration project. The service lane's declared source path
2027
+ # when there is one, 'src' when there is not. Migrations belong to the backend lane
2028
+ # by definition, so a repo whose service code sits in 'Legacy/App/' or 'backend/'
2029
+ # gets searched where its code actually is instead of being told it has no .NET
2030
+ # project at all.
2031
+ $svcRoot = Get-LaneField -Field 'backend_root'
2032
+ if (-not $svcRoot) { $svcRoot = 'src' }
2033
+ $svcDir = Join-Path $repo ($svcRoot.TrimEnd('/'))
2034
+
2035
+ $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
2036
+ $hasSln = @(Get-ChildItem -Path $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2037
+ $hasCsproj = @(Get-ChildItem -Path $svcDir -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2038
+ $efOk = $false
2039
+ if (($hasSln -or $hasCsproj) -and (Get-Command dotnet -ErrorAction SilentlyContinue)) {
2040
+ Push-Location $repo; try { dotnet ef --version *> $null; $efOk = ($LASTEXITCODE -eq 0) } catch { $efOk = $false } finally { Pop-Location }
2041
+ }
2042
+ if ($efOk) {
2043
+ # Prefer *Service.csproj (API host with ApplicationDbContext); fall back to first
2044
+ # non-AppHost/Web/ServiceDefaults/test project under the service lane's root.
2045
+ $project = Get-ChildItem -Path $svcDir -Filter '*.Service.csproj' -Recurse -ErrorAction SilentlyContinue |
2046
+ Select-Object -First 1 -ExpandProperty FullName
2047
+ if (-not $project) {
2048
+ $project = Get-ChildItem -Path $svcDir -Filter '*.csproj' -Recurse -ErrorAction SilentlyContinue |
2049
+ Where-Object { $_.FullName -notmatch 'AppHost|ServiceDefaults|\.Web\.|Tests' } |
2050
+ Select-Object -First 1 -ExpandProperty FullName
2051
+ }
2052
+ if ($project) {
2053
+ $tool = 'dotnet-ef'
2054
+ $cmd = "dotnet ef database update --project $project --context ApplicationDbContext"
2055
+ $ran = $true
2056
+ Push-Location $repo
2057
+ try {
2058
+ $log = (dotnet ef database update --project $project --context ApplicationDbContext 2>&1 | Out-String)
2059
+ $ok = ($LASTEXITCODE -eq 0)
2060
+ } finally { Pop-Location }
2061
+ } else {
2062
+ $tool = 'dotnet-ef'; $cmd = 'dotnet ef database update'; $ran = $true
2063
+ Push-Location $repo; try { $log = (dotnet ef database update 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
2064
+ }
2065
+ }
2066
+ $tail = if ($log.Length -gt 1400) { $log.Substring($log.Length - 1400) } else { $log }
2067
+ Write-Json ([ordered]@{ ok = $ok; tool = $tool; ran = $ran; cmd = $cmd; log_tail = $tail })
2068
+ }
2069
+
2070
+ function Invoke-DeployDispatch {
2071
+ param([string[]]$Rest)
2072
+ $repo = Get-RepoRoot; $cfg = Join-Path $repo '.appweaver/deploy.json'
2073
+ $configured = $false; $ran = $false; $ok = $true; $cmd = ''; $log = ''
2074
+ if (Test-Path $cfg) { try { $cmd = (Get-Content $cfg -Raw | ConvertFrom-Json).command } catch { $cmd = '' } }
2075
+ if ($cmd) {
2076
+ $configured = $true; $ran = $true
2077
+ Push-Location $repo; try { $log = (Invoke-Expression $cmd 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } catch { $ok = $false; $log = $_.ToString() } finally { Pop-Location }
2078
+ }
2079
+ $tail = if ($log.Length -gt 1400) { $log.Substring($log.Length - 1400) } else { $log }
2080
+ Write-Json ([ordered]@{ ok = $ok; configured = $configured; ran = $ran; cmd = $cmd; log_tail = $tail })
2081
+ }
2082
+
2083
+ function Invoke-CrSetup {
2084
+ param([string[]]$Rest)
2085
+ $crId = ''; $fdArg = ''; $title = ''; $force = ''
2086
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
2087
+ switch ($Rest[$i]) {
2088
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
2089
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
2090
+ { $_ -in '--title','-Title' } { $title = $Rest[++$i] }
2091
+ { $_ -in '--force','-Force' } { $force = '1' }
2092
+ }
2093
+ }
2094
+ if (-not $crId) { $crId = 'CR-' + (Get-Date -Format 'yyyyMMdd-HHmmss') }
2095
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2096
+ if (-not $fd) { Write-Error 'cr-setup: no feature set and no --feature-dir'; exit 1 }
2097
+ # Single-active-change guard: open the change FIRST so a second concurrent CR on the
2098
+ # same aggregate is refused BEFORE any changes/<cr>/ dir is scaffolded (no orphan
2099
+ # left behind). Re-opening the same cr_id is a no-op. --force overrides.
2100
+ $avOpen = Invoke-ActiveVersion -FeatureDir $fd -Op 'open' -CrId $crId -Force $force
2101
+ if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) {
2102
+ Write-Error "cr-setup: refusing to open '$crId' — a change is already open on this feature; finish it (finalize-delta -> analyze-slice -> close-delta) first, or pass --force."
2103
+ $avOpen
2104
+ exit 1
2105
+ }
2106
+ $crDir = Join-Path $fd "changes/$crId"
2107
+ New-Item -ItemType Directory -Force -Path $crDir | Out-Null
2108
+ $dspec = Join-Path $crDir 'delta-spec.md'; $ddesign = Join-Path $crDir 'delta-design.md'; $created = $false
2109
+ if (-not (Test-Path $dspec)) { $t = Resolve-NamedTemplate -Name 'delta-spec-template' -RepoRoot $repo; if ($t) { Copy-Item $t $dspec } else { New-Item -ItemType File -Force -Path $dspec | Out-Null }; $created = $true }
2110
+ if (-not (Test-Path $ddesign)) { $t = Resolve-NamedTemplate -Name 'delta-design-template' -RepoRoot $repo; if ($t) { Copy-Item $t $ddesign } else { New-Item -ItemType File -Force -Path $ddesign | Out-Null }; $created = $true }
2111
+ Write-Json ([ordered]@{ cr_id = $crId; cr_dir = $crDir; delta_spec = $dspec; delta_design = $ddesign; title = $title; created = $created })
2112
+ }
2113
+
2114
+ function Invoke-CrArchive {
2115
+ param([string[]]$Rest)
2116
+ $crId = ''; $fdArg = ''
2117
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
2118
+ switch ($Rest[$i]) {
2119
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
2120
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
2121
+ }
2122
+ }
2123
+ if (-not $crId) { Write-Error 'cr-archive: --cr-id required'; exit 1 }
2124
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2125
+ if (-not $fd) { Write-Error 'cr-archive: no feature set and no --feature-dir'; exit 1 }
2126
+ $crDir = Join-Path $fd "changes/$crId"; $archRoot = Join-Path $fd 'changes/archive'; $archived = ''
2127
+ if (Test-Path $crDir) {
2128
+ New-Item -ItemType Directory -Force -Path $archRoot | Out-Null
2129
+ $dest = Join-Path $archRoot $crId
2130
+ if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
2131
+ Move-Item $crDir $dest; $archived = $dest
2132
+ }
2133
+ # Close the change in active_version.json (single version source): bump + clear.
2134
+ $av = Invoke-ActiveVersion -FeatureDir $fd -Op 'close' | ConvertFrom-Json
2135
+ Write-Json ([ordered]@{ ok = $true; archived_dir = $archived; version = $av.version; previous = $av.previous; closed_change = $av.closed_change })
2136
+ }
2137
+
2138
+ # Stamp a version into the MASTER documents' `**Version**:` headers — the
2139
+ # deterministic twin of cr-archive's active_version.json bump. close-delta calls
2140
+ # this with the version cr-archive just computed, so all THREE carriers of the
2141
+ # number (spec.md, design.md, active_version.json) move in ONE stage by ONE
2142
+ # mechanism. Before this, design.md's header was bumped by the MODEL at
2143
+ # finalize-delta — a stage early, and guessing the target since the number was
2144
+ # never passed into the merge prompt — while spec.md's header was bumped by
2145
+ # nobody at all and sat at v1.0 forever.
2146
+ # Idempotent: re-stamping the same version reports `unchanged`, writes nothing.
2147
+ # Only the value moves; any trailing inline comment on the header line is kept.
2148
+ function Invoke-VersionStamp {
2149
+ param([string[]]$Rest)
2150
+ $ver = ''; $fdArg = ''
2151
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
2152
+ switch ($Rest[$i]) {
2153
+ { $_ -in '--version','-Version' } { $ver = $Rest[++$i] }
2154
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
2155
+ }
2156
+ }
2157
+ if (-not $ver) { Write-Error 'version-stamp: --version required'; exit 1 }
2158
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2159
+ if (-not $fd) { Write-Error 'version-stamp: no feature set and no --feature-dir'; exit 1 }
2160
+ $ver = $ver.Trim()
2161
+ [string[]]$stamped = @(); [string[]]$unchanged = @(); [string[]]$missing = @(); [string[]]$noHeader = @()
2162
+ # First `**Version**: <value>` line only — that is the header. The Change History
2163
+ # table's `| Version |` column head does not match this pattern.
2164
+ # design.ui.md joins the spine where the feature has a UI half: the masters move in
2165
+ # LOCKSTEP, so a document left behind at the old number is the one lie the version
2166
+ # spine cannot tolerate. A feature without one reports it `missing` and nothing fails.
2167
+ foreach ($name in @('spec.md', 'design.md', 'design.ui.md')) {
2168
+ $p = Join-Path $fd $name
2169
+ if (-not (Test-Path $p)) { $missing += $name; continue }
2170
+ $src = Get-Content -Raw -Encoding UTF8 $p
2171
+ $m = [regex]::Match($src, '(?m)^(\*\*Version\*\*:[ \t]*)(\S+)(.*)$')
2172
+ if (-not $m.Success) { $noHeader += $name; continue }
2173
+ if ($m.Groups[2].Value -eq $ver) { $unchanged += $name; continue }
2174
+ $out = $src.Substring(0, $m.Index) + $m.Groups[1].Value + $ver + $m.Groups[3].Value +
2175
+ $src.Substring($m.Index + $m.Length)
2176
+ [System.IO.File]::WriteAllText($p, $out)
2177
+ $stamped += $name
2178
+ }
2179
+ Write-Json ([ordered]@{ ok = $true; version = $ver; stamped = $stamped; unchanged = $unchanged;
2180
+ missing = $missing; no_header = $noHeader; count = $stamped.Count })
2181
+ }
2182
+
2183
+ # ─────────────────────────────── main ──────────────────────────────────────
2184
+ if ($args.Count -lt 1) { Write-Output 'Usage: aw-spec-actions.ps1 <subcommand> [options]'; exit 0 }
2185
+ $sub = $args[0]
2186
+ $rest = @(); if ($args.Count -gt 1) { $rest = $args[1..($args.Count - 1)] }
2187
+
2188
+ switch ($sub) {
2189
+ 'resolve-domain' { Invoke-ResolveDomain -Rest $rest }
2190
+ 'resolve-tech' { Invoke-ResolveTech -Rest $rest }
2191
+ 'scaffold' { Invoke-Scaffold -Rest $rest }
2192
+ 'resolve-hooks' { Invoke-ResolveHooks -Rest $rest }
2193
+ 'create-checklist' { Invoke-CreateChecklist -Rest $rest }
2194
+ 'list-domains' { Invoke-ListDomains -Rest $rest }
2195
+ 'set-domain' { Invoke-SetDomain -Rest $rest }
2196
+ 'stack-list' { Invoke-StackList -Rest $rest }
2197
+ 'stack-detect' { Invoke-StackDetect -Rest $rest }
2198
+ 'lane-declare' { Invoke-LaneDeclare -Rest $rest }
2199
+ 'lane-resolve' { Invoke-LaneResolve -Rest $rest }
2200
+ 'lane-verify' { Invoke-LaneVerify -Rest $rest }
2201
+ 'lane-probe' { Invoke-LaneProbe -Rest $rest }
2202
+ 'list-features' { Invoke-ListFeatures -Rest $rest }
2203
+ 'set-feature' { Invoke-SetFeature -Rest $rest }
2204
+ 'version-read' { Invoke-VersionState 'read' $rest }
2205
+ 'version-init' { Invoke-VersionState 'init' $rest }
2206
+ 'feature-paths' { Invoke-FeaturePaths -Rest $rest }
2207
+ 'design-scaffold' { Invoke-DesignScaffold -Rest $rest }
2208
+ 'build-compile' { Invoke-BuildCompile -Rest $rest }
2209
+ 'test-run' { Invoke-TestRun -Rest $rest }
2210
+ 'run-migrate' { Invoke-RunMigrate -Rest $rest }
2211
+ 'deploy-dispatch' { Invoke-DeployDispatch -Rest $rest }
2212
+ 'cr-setup' { Invoke-CrSetup -Rest $rest }
2213
+ 'cr-archive' { Invoke-CrArchive -Rest $rest }
2214
+ 'version-stamp' { Invoke-VersionStamp -Rest $rest }
2215
+ 'checklist-read' { Invoke-ChecklistRead -Rest $rest }
2216
+ 'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
2217
+ 'detect-project' { Invoke-DetectProject -Rest $rest }
2218
+ 'foundation-list' { Invoke-FoundationList -Rest $rest }
2219
+ 'foundation-locate' { Invoke-FoundationLocate -Rest $rest }
2220
+ 'foundation-unpack' { Invoke-FoundationUnpack -Rest $rest }
2221
+ 'foundation-verify' { Invoke-FoundationVerify -Rest $rest }
2222
+ 'foundation-stash-dbcontext' { Invoke-FoundationStashDbContext -Rest $rest }
2223
+ 'list-components' { Invoke-ListComponents -Rest $rest }
2224
+ 'component-catalog' { Invoke-ComponentCatalog -Rest $rest }
2225
+ 'component-verify' { Invoke-ComponentVerify -Rest $rest }
2226
+ 'find-host-project' { Invoke-FindHostProject -Rest $rest }
2227
+ 'setup-constitution' { Invoke-SetupConstitution -Rest $rest }
2228
+ 'compose-constitution' { Invoke-ComposeConstitution -Rest $rest }
2229
+ 'constitution-verify' { Invoke-ConstitutionVerify -Rest $rest }
2230
+ 'agent-context-update' { Invoke-AgentContextUpdate -Rest $rest }
2231
+ default { Write-Error "Unknown subcommand: $sub"; exit 1 }
2232
+ }