aw-spec-cli 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/assets/.agents/agent.json +28 -0
  2. package/assets/.agents/skills/aw-spec-orch/SKILL.md +56 -0
  3. package/assets/.agents/skills/aw-spec-orch-demo/SKILL.md +59 -0
  4. package/assets/.aw-spec/DESIGN-FORMAT.md +151 -0
  5. package/assets/.aw-spec/ORCH-DIALECT.md +156 -0
  6. package/assets/.aw-spec/RULE-PARTITION.md +126 -0
  7. package/assets/.aw-spec/extensions/agent-context/extension.yml +12 -9
  8. package/assets/.aw-spec/extensions.yml +12 -19
  9. package/assets/.aw-spec/integrations/aw-spec.manifest.json +1 -9
  10. package/assets/.aw-spec/interpreter/run-skill.py +1158 -0
  11. package/assets/.aw-spec/orchestrator/AGENT-PROFILE.md +94 -0
  12. package/assets/.aw-spec/orchestrator/README.md +300 -0
  13. package/assets/.aw-spec/orchestrator/adapters/claude_agent_sdk.py +74 -0
  14. package/assets/.aw-spec/orchestrator/adapters/google_antigravity.py +74 -0
  15. package/assets/.aw-spec/orchestrator/modules/delta-design.orch.yaml +105 -0
  16. package/assets/.aw-spec/orchestrator/modules/delta-spec.orch.yaml +79 -0
  17. package/assets/.aw-spec/orchestrator/modules/domain-select.orch.yaml +81 -0
  18. package/assets/.aw-spec/orchestrator/modules/tech-select.orch.yaml +75 -0
  19. package/assets/.aw-spec/orchestrator/orchestrator.py +1721 -0
  20. package/assets/.aw-spec/orchestrator/rev-templates/ai-rules.reference.md +40 -0
  21. package/assets/.aw-spec/orchestrator/rev-templates/comandrulemap.skeleton.md +59 -0
  22. package/assets/.aw-spec/orchestrator/rev-templates/commandtechrulemap.skeleton.md +97 -0
  23. package/assets/.aw-spec/orchestrator/rev-templates/domain-constitution.skeleton.md +61 -0
  24. package/assets/.aw-spec/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
  25. package/assets/.aw-spec/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
  26. package/assets/.aw-spec/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
  27. package/assets/.aw-spec/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
  28. package/assets/.aw-spec/orchestrator/workflows/_smoke.orch.yaml +160 -0
  29. package/assets/.aw-spec/orchestrator/workflows/aw-spec-add-component.orch.yaml +244 -0
  30. package/assets/.aw-spec/orchestrator/workflows/aw-spec-adopt.orch.yaml +775 -0
  31. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +248 -0
  32. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +261 -0
  33. package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +425 -0
  34. package/assets/.aw-spec/orchestrator/workflows/aw-spec-build.orch.yaml +188 -0
  35. package/assets/.aw-spec/orchestrator/workflows/aw-spec-characterize.orch.yaml +474 -0
  36. package/assets/.aw-spec/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
  37. package/assets/.aw-spec/orchestrator/workflows/aw-spec-constitution.orch.yaml +284 -0
  38. package/assets/.aw-spec/orchestrator/workflows/aw-spec-demo.orch.yaml +103 -0
  39. package/assets/.aw-spec/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
  40. package/assets/.aw-spec/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
  41. package/assets/.aw-spec/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
  42. package/assets/.aw-spec/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +382 -0
  43. package/assets/.aw-spec/orchestrator/workflows/aw-spec-foundation.orch.yaml +246 -0
  44. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +231 -0
  45. package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement.orch.yaml +258 -0
  46. package/assets/.aw-spec/orchestrator/workflows/aw-spec-inventory.orch.yaml +236 -0
  47. package/assets/.aw-spec/orchestrator/workflows/aw-spec-onboard.orch.yaml +949 -0
  48. package/assets/.aw-spec/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
  49. package/assets/.aw-spec/orchestrator/workflows/aw-spec-run.orch.yaml +177 -0
  50. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-domain.orch.yaml +79 -0
  51. package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
  52. package/assets/.aw-spec/orchestrator/workflows/aw-spec-specify.orch.yaml +412 -0
  53. package/assets/.aw-spec/scripts/bash/aw-inventory.sh +14 -0
  54. package/assets/.aw-spec/scripts/bash/aw-parity.sh +13 -0
  55. package/assets/.aw-spec/scripts/bash/aw-rev-constitution.sh +13 -0
  56. package/assets/.aw-spec/scripts/bash/aw-spec-actions.sh +1899 -0
  57. package/assets/.aw-spec/scripts/bash/aw-xref.sh +15 -0
  58. package/assets/.aw-spec/scripts/bash/common.sh +11 -60
  59. package/assets/.aw-spec/scripts/powershell/aw-inventory.ps1 +14 -0
  60. package/assets/.aw-spec/scripts/powershell/aw-parity.ps1 +13 -0
  61. package/assets/.aw-spec/scripts/powershell/aw-rev-constitution.ps1 +13 -0
  62. package/assets/.aw-spec/scripts/powershell/aw-spec-actions.ps1 +1563 -0
  63. package/assets/.aw-spec/scripts/powershell/aw-xref.ps1 +15 -0
  64. package/assets/.aw-spec/scripts/python/aw-inventory.py +683 -0
  65. package/assets/.aw-spec/scripts/python/aw-parity.py +380 -0
  66. package/assets/.aw-spec/scripts/python/aw-rev-constitution.py +312 -0
  67. package/assets/.aw-spec/scripts/python/aw-xref.py +1330 -0
  68. package/assets/.aw-spec/scripts/python/validate_okf.py +349 -0
  69. package/assets/.aw-spec/templates/constitution-domain-template.md +75 -0
  70. package/assets/.aw-spec/templates/constitution-technical-template.md +70 -0
  71. package/assets/.aw-spec/templates/delta-design-template.md +69 -0
  72. package/assets/.aw-spec/templates/delta-spec-template.md +60 -0
  73. package/assets/.aw-spec/templates/design-template.md +383 -0
  74. package/assets/.aw-spec/templates/okf-concept-template.md +103 -0
  75. package/assets/.aw-spec/templates/parity-manifest-template.json +49 -0
  76. package/assets/.aw-spec/templates/source-map-template.md +85 -0
  77. package/assets/.aw-spec/templates/spec-template.md +192 -71
  78. package/assets/.claude/agent.json +27 -0
  79. package/assets/.claude/skills/aw-spec-orch/SKILL.md +55 -0
  80. package/assets/.claude/skills/aw-spec-orch-demo/SKILL.md +59 -0
  81. package/assets/.kilocode/agent.json +28 -0
  82. package/assets/.kilocode/workflows/aw-spec-orch-demo.md +59 -0
  83. package/assets/.kilocode/workflows/aw-spec-orch.md +55 -0
  84. package/assets/docs/architecture/clean-architecture.md +1 -1
  85. package/assets/docs/architecture/cqrs.md +1 -1
  86. package/assets/docs/architecture/eventing.md +1 -1
  87. package/assets/docs/architecture/folder-structure.md +1 -1
  88. package/assets/docs/architecture/modular-monolith.md +1 -1
  89. package/assets/docs/architecture/persistence.md +1 -1
  90. package/assets/docs/architecture/testing.md +1 -1
  91. package/assets/docs/architecture/vertical-slices.md +1 -1
  92. package/assets/docs/components/Email/README.md +1 -1
  93. package/assets/docs/components/Jobs/README.md +1 -1
  94. package/assets/docs/components/QrGenerator/README.md +1 -1
  95. package/assets/docs/components/README.md +1 -1
  96. package/assets/docs/domain/domain-driven-design.md +1 -1
  97. package/assets/docs/domainspecs/healthcare/api-versioning.domain.md +1 -1
  98. package/assets/docs/domainspecs/healthcare/auditing.domain.md +86 -18
  99. package/assets/docs/domainspecs/healthcare/authorization.domain.md +1 -1
  100. package/assets/docs/domainspecs/healthcare/clinical-terms.md +1 -1
  101. package/assets/docs/domainspecs/healthcare/coding-standards.domain.md +1 -1
  102. package/assets/docs/domainspecs/healthcare/comandrulemap.md +58 -124
  103. package/assets/docs/domainspecs/healthcare/commandtechrulemap.md +119 -0
  104. package/assets/docs/domainspecs/healthcare/consent.md +1 -1
  105. package/assets/docs/domainspecs/healthcare/cqrs.domain.md +1 -1
  106. package/assets/docs/domainspecs/healthcare/data-classification.domain.md +1 -1
  107. package/assets/docs/domainspecs/healthcare/dependency-policy.domain.md +1 -1
  108. package/assets/docs/domainspecs/healthcare/dicom.md +1 -1
  109. package/assets/docs/domainspecs/healthcare/domain-driven-design.domain.md +1 -1
  110. package/assets/docs/domainspecs/healthcare/eventing.domain.md +1 -1
  111. package/assets/docs/domainspecs/healthcare/fhir.md +1 -1
  112. package/assets/docs/domainspecs/healthcare/folder-structure.domain.md +1 -1
  113. package/assets/docs/domainspecs/healthcare/hipaa.md +1 -1
  114. package/assets/docs/domainspecs/healthcare/hl7.md +1 -1
  115. package/assets/docs/domainspecs/healthcare/integration-terms.md +1 -1
  116. package/assets/docs/domainspecs/healthcare/persistence.domain.md +1 -1
  117. package/assets/docs/domainspecs/healthcare/terminology.md +1 -1
  118. package/assets/docs/domainspecs/healthcare/testing.domain.md +1 -1
  119. package/assets/docs/domainspecs/healthcare/vertical-slices.domain.md +1 -1
  120. package/assets/docs/domainspecs/healthcare/zero-trust.domain.md +1 -1
  121. package/assets/docs/domainspecs/pharma/api-versioning.domain.md +1 -1
  122. package/assets/docs/domainspecs/pharma/atc.md +1 -1
  123. package/assets/docs/domainspecs/pharma/auditing.domain.md +80 -16
  124. package/assets/docs/domainspecs/pharma/authorization.domain.md +1 -1
  125. package/assets/docs/domainspecs/pharma/coding-standards.domain.md +1 -1
  126. package/assets/docs/domainspecs/pharma/comandrulemap.md +64 -142
  127. package/assets/docs/domainspecs/pharma/commandtechrulemap.md +119 -0
  128. package/assets/docs/domainspecs/pharma/cqrs.domain.md +1 -1
  129. package/assets/docs/domainspecs/pharma/data-classification.domain.md +1 -1
  130. package/assets/docs/domainspecs/pharma/data-provenance.md +1 -1
  131. package/assets/docs/domainspecs/pharma/dependency-policy.domain.md +1 -1
  132. package/assets/docs/domainspecs/pharma/domain-driven-design.domain.md +1 -1
  133. package/assets/docs/domainspecs/pharma/eventing.domain.md +1 -1
  134. package/assets/docs/domainspecs/pharma/fhir.md +1 -1
  135. package/assets/docs/domainspecs/pharma/folder-structure.domain.md +1 -1
  136. package/assets/docs/domainspecs/pharma/hl7.md +1 -1
  137. package/assets/docs/domainspecs/pharma/idmp.md +1 -1
  138. package/assets/docs/domainspecs/pharma/integration-terms.md +1 -1
  139. package/assets/docs/domainspecs/pharma/ndc.md +1 -1
  140. package/assets/docs/domainspecs/pharma/persistence.domain.md +1 -1
  141. package/assets/docs/domainspecs/pharma/pharma-terms.md +1 -1
  142. package/assets/docs/domainspecs/pharma/product-lifecycle.domain.md +1 -1
  143. package/assets/docs/domainspecs/pharma/regulatory-compliance.md +1 -1
  144. package/assets/docs/domainspecs/pharma/terminology.md +1 -1
  145. package/assets/docs/domainspecs/pharma/testing.domain.md +1 -1
  146. package/assets/docs/domainspecs/pharma/vertical-slices.domain.md +1 -1
  147. package/assets/docs/domainspecs/pharma/zero-trust.domain.md +1 -1
  148. package/assets/docs/governance/api-versioning.md +1 -1
  149. package/assets/docs/governance/coding-standards.md +1 -1
  150. package/assets/docs/governance/dependency-policy.md +1 -1
  151. package/assets/docs/requirement-spec.txt +27 -0
  152. package/assets/docs/security/auditing.md +54 -11
  153. package/assets/docs/security/authorization.md +1 -1
  154. package/assets/docs/security/data-classification.md +1 -1
  155. package/assets/docs/security/zero-trust.md +1 -1
  156. package/dist/commands/init.d.ts.map +1 -1
  157. package/dist/commands/init.js +7 -54
  158. package/dist/commands/init.js.map +1 -1
  159. package/dist/index.js +0 -2
  160. package/dist/index.js.map +1 -1
  161. package/dist/utils/paths.js +2 -2
  162. package/dist/utils/paths.js.map +1 -1
  163. package/package.json +1 -1
  164. package/assets/.agents/skills/aw-spec-addcomponent/SKILL.md +0 -129
  165. package/assets/.agents/skills/aw-spec-agent-context-update/SKILL.md +0 -31
  166. package/assets/.agents/skills/aw-spec-analyze/SKILL.md +0 -296
  167. package/assets/.agents/skills/aw-spec-checklist/SKILL.md +0 -374
  168. package/assets/.agents/skills/aw-spec-clarify/SKILL.md +0 -320
  169. package/assets/.agents/skills/aw-spec-constitution/SKILL.md +0 -194
  170. package/assets/.agents/skills/aw-spec-fast-implement/SKILL.md +0 -268
  171. package/assets/.agents/skills/aw-spec-foundation/SKILL.md +0 -358
  172. package/assets/.agents/skills/aw-spec-implement/SKILL.md +0 -279
  173. package/assets/.agents/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
  174. package/assets/.agents/skills/aw-spec-plan/SKILL.md +0 -221
  175. package/assets/.agents/skills/aw-spec-select-domain/SKILL.md +0 -73
  176. package/assets/.agents/skills/aw-spec-specify/SKILL.md +0 -382
  177. package/assets/.agents/skills/aw-spec-tasks/SKILL.md +0 -259
  178. package/assets/.agents/skills/aw-spec-taskstoissues/SKILL.md +0 -107
  179. package/assets/.aw-spec/integrations/claude.manifest.json +0 -17
  180. package/assets/.aw-spec/scripts/bash/check-prerequisites.sh +0 -189
  181. package/assets/.aw-spec/scripts/bash/create-new-feature.sh +0 -299
  182. package/assets/.aw-spec/scripts/bash/setup-plan.sh +0 -84
  183. package/assets/.aw-spec/scripts/bash/setup-tasks.sh +0 -91
  184. package/assets/.aw-spec/templates/checklist-template.md +0 -40
  185. package/assets/.aw-spec/templates/constitution-template.md +0 -57
  186. package/assets/.aw-spec/templates/plan-template.md +0 -113
  187. package/assets/.aw-spec/templates/tasks-template.md +0 -252
  188. package/assets/.aw-spec/workflows/aw-spec/workflow.yml +0 -77
  189. package/assets/.aw-spec/workflows/workflow-registry.json +0 -13
  190. package/assets/.claude/skills/aw-spec-addcomponent/SKILL.md +0 -129
  191. package/assets/.claude/skills/aw-spec-agent-context-update/SKILL.md +0 -31
  192. package/assets/.claude/skills/aw-spec-analyze/SKILL.md +0 -296
  193. package/assets/.claude/skills/aw-spec-checklist/SKILL.md +0 -374
  194. package/assets/.claude/skills/aw-spec-clarify/SKILL.md +0 -320
  195. package/assets/.claude/skills/aw-spec-constitution/SKILL.md +0 -194
  196. package/assets/.claude/skills/aw-spec-fast-implement/SKILL.md +0 -268
  197. package/assets/.claude/skills/aw-spec-foundation/SKILL.md +0 -358
  198. package/assets/.claude/skills/aw-spec-implement/SKILL.md +0 -279
  199. package/assets/.claude/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
  200. package/assets/.claude/skills/aw-spec-plan/SKILL.md +0 -221
  201. package/assets/.claude/skills/aw-spec-select-domain/SKILL.md +0 -73
  202. package/assets/.claude/skills/aw-spec-specify/SKILL.md +0 -382
  203. package/assets/.claude/skills/aw-spec-tasks/SKILL.md +0 -259
  204. package/assets/.claude/skills/aw-spec-taskstoissues/SKILL.md +0 -107
  205. package/assets/.kilocode/rules/specify-rules.md +0 -4
  206. package/assets/.kilocode/workflows/aw-spec-addcomponent.md +0 -129
  207. package/assets/.kilocode/workflows/aw-spec-agent-context.update.md +0 -31
  208. package/assets/.kilocode/workflows/aw-spec-analyze.md +0 -296
  209. package/assets/.kilocode/workflows/aw-spec-checklist.md +0 -374
  210. package/assets/.kilocode/workflows/aw-spec-clarify.md +0 -320
  211. package/assets/.kilocode/workflows/aw-spec-constitution.md +0 -194
  212. package/assets/.kilocode/workflows/aw-spec-fast-implement.md +0 -263
  213. package/assets/.kilocode/workflows/aw-spec-foundation.md +0 -358
  214. package/assets/.kilocode/workflows/aw-spec-implement.md +0 -279
  215. package/assets/.kilocode/workflows/aw-spec-minimal-implementation.md +0 -204
  216. package/assets/.kilocode/workflows/aw-spec-plan.md +0 -221
  217. package/assets/.kilocode/workflows/aw-spec-select-domain.md +0 -73
  218. package/assets/.kilocode/workflows/aw-spec-specify.md +0 -382
  219. package/assets/.kilocode/workflows/aw-spec-tasks.md +0 -259
  220. package/assets/.kilocode/workflows/aw-spec-taskstoissues.md +0 -107
  221. package/assets/docs/UI.Visualization/PACKAGING.md +0 -191
  222. package/assets/docs/UI.Visualization/UI.Visualization.Reference.pack.txt +0 -11768
  223. package/assets/docs/UI.Visualization/UI.Visualization.pack.txt +0 -11214
  224. package/assets/docs/UI.Visualization/pack-foundation.ps1 +0 -165
  225. package/assets/docs/UI.Visualization/pack-foundation.sh +0 -206
  226. package/assets/docs/UI.Visualization/pack-reference-foundation.ps1 +0 -159
  227. package/assets/docs/UI.Visualization/pack-reference-foundation.sh +0 -205
  228. package/assets/docs/UI.Visualization/unpack-foundation.ps1 +0 -126
  229. package/assets/docs/UI.Visualization/unpack-foundation.sh +0 -217
  230. package/assets/docs/UI.Visualization/unpack-reference-foundation.ps1 +0 -129
  231. package/assets/docs/UI.Visualization/unpack-reference-foundation.sh +0 -218
  232. package/assets/docs/command.txt +0 -295
  233. package/assets/docs/domainspecs/UIVisualization/chart-bubble.md +0 -70
  234. package/assets/docs/domainspecs/UIVisualization/chart-dimred.md +0 -58
  235. package/assets/docs/domainspecs/UIVisualization/chart-heatmap.md +0 -62
  236. package/assets/docs/domainspecs/UIVisualization/chart-network.md +0 -69
  237. package/assets/docs/domainspecs/UIVisualization/chart-parallel.md +0 -75
  238. package/assets/docs/domainspecs/UIVisualization/chart-scurve.md +0 -66
  239. package/assets/docs/domainspecs/UIVisualization/chart-survival.md +0 -69
  240. package/assets/docs/domainspecs/UIVisualization/chart-trellis.md +0 -68
  241. package/assets/docs/domainspecs/UIVisualization/chart-violin.md +0 -61
  242. package/assets/docs/domainspecs/UIVisualization/chart-volcano.md +0 -63
  243. package/assets/docs/domainspecs/UIVisualization/charting-standards.domain.md +0 -80
  244. package/assets/docs/domainspecs/UIVisualization/clean-architecture.domain.md +0 -87
  245. package/assets/docs/domainspecs/UIVisualization/coding-standards.domain.md +0 -47
  246. package/assets/docs/domainspecs/UIVisualization/comandrulemap.md +0 -130
  247. package/assets/docs/domainspecs/UIVisualization/component-contracts.domain.md +0 -67
  248. package/assets/docs/domainspecs/UIVisualization/data-source.domain.md +0 -68
  249. package/assets/docs/domainspecs/UIVisualization/domain-requirements.md +0 -110
  250. package/assets/docs/domainspecs/UIVisualization/folder-structure.domain.md +0 -55
  251. package/assets/docs/domainspecs/UIVisualization/state-management.domain.md +0 -67
  252. package/assets/docs/domainspecs/UIVisualization/testing.domain.md +0 -52
  253. package/assets/docs/domainspecs/UIVisualization/ui-viz-terms.md +0 -39
  254. package/assets/docs/pharma-constitution.my-ref.md +0 -86
@@ -0,0 +1,1899 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # aw-spec-actions.sh — common action dispatcher for declarative aw-spec skills.
4
+ #
5
+ # ONE shared script for every integration (.claude / .agents / .kilocode). The
6
+ # declarative skill.yaml files reference these subcommands via `execute_script`.
7
+ # Each subcommand performs deterministic, governance-safe work and prints a single
8
+ # JSON object on stdout. NONE of these actions author spec/code content — content
9
+ # generation is the LLM's job (the skill's single `llm_generate` step).
10
+ #
11
+ # Usage:
12
+ # aw-spec-actions.sh <subcommand> [options]
13
+ #
14
+ # Subcommands:
15
+ # resolve-domain --command <name> -> {domain, rulemap_exists, candidates[]}
16
+ # resolve-tech --command <name> -> {domain, techrulemap_exists, candidates[]}
17
+ # scaffold --feature-dir <path> [--allow-existing]
18
+ # -> {feature_dir, spec_file, template, created, spec_created}
19
+ # resolve-hooks --phase <before_specify|after_specify>
20
+ # -> {phase, hooks[]}
21
+ # create-checklist --feature-dir <path> [--feature-name <name>]
22
+ # -> {checklist_file, created}
23
+ # list-domains -> {domains[]}
24
+ # set-domain --domain <name> -> {domain, map_path, map_exists, written}
25
+ # list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ OKF aggregates)
26
+ # set-feature --feature <name> -> {feature, feature_dir, spec_file, existed, created, spec_exists}
27
+ # version-read [--feature-dir <path>] -> {feature, version, active_change, exists}
28
+ # version-init [--feature-dir <path>] -> {feature, version, active_change, created} (create active_version.json v1.0)
29
+ # feature-paths -> {feature, feature_dir, spec_file, design_file, spec_exists, design_exists}
30
+ # checklist-read --file <path> -> {exists, total, checked, items[]}
31
+ # checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
32
+ # detect-project -> {scaffolded, markers[]}
33
+ # foundation-list [--args '<F> <A> [out]'] -> {foundations[], count, preselect_name, preselect_app, out_dir}
34
+ # foundation-locate --name <F> --app <A> [--out <d>]
35
+ # -> {ok, reason, name, app, out_dir, pack_file, unpacker, runner, sentinel, file_count, available[]}
36
+ # foundation-unpack --name <F> --app <A> --out <d> [--dry-run] [--force <yes|no>] [--reference]
37
+ # -> {ok, mode, written, has_collisions, stdout}
38
+ # foundation-verify --out <d> --sentinel <s> -> {clean, residue[]}
39
+ # list-components -> {components[]} (docs/components/* with integration.md)
40
+ # find-host-project -> {found, program_cs, project_dir, csproj}
41
+ # setup-constitution -> {constitution_technical_file, constitution_domain_file, technical_created, domain_created}
42
+ # compose-constitution -> {composed_file, ok, issues}
43
+ # agent-context-update [--plan <path>] -> {ok, ran, message}
44
+ #
45
+ # design-scaffold --feature-dir <path> [--template <name>]
46
+ # -> {feature_dir, design_doc, created, override_active, template, template_source}
47
+ # (design-extract / design-reconcile / design-paths are RETIRED — the path-tagged
48
+ # four-document model is gone. "Which files is this slice made of" is now an OKF
49
+ # query: aw-xref concept-paths / concept-upsert --merge.)
50
+ # build-compile [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
51
+ # test-run [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
52
+ # run-migrate [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
53
+ # deploy-dispatch -> {ok, configured, ran, cmd, log_tail}
54
+ # cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>]
55
+ # -> {cr_dir, delta_spec, delta_design, created}
56
+ # cr-archive --cr-id <id> [--feature-dir <path>]
57
+ # -> {ok, archived_dir, version, previous}
58
+ #
59
+ # All subcommands accept --help.
60
+
61
+ set -e
62
+
63
+ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
64
+ # shellcheck source=common.sh
65
+ source "$SCRIPT_DIR/common.sh"
66
+
67
+ # ─────────────────────────── small JSON helpers ────────────────────────────
68
+ # Emit a JSON array of strings from remaining args.
69
+ json_str_array() {
70
+ local out="[" first=1 s
71
+ for s in "$@"; do
72
+ [ $first -eq 1 ] && first=0 || out+=","
73
+ out+="\"$(json_escape "$s")\""
74
+ done
75
+ out+="]"
76
+ printf '%s' "$out"
77
+ }
78
+
79
+ # ─────────────────────────────── resolve-domain ────────────────────────────
80
+ cmd_resolve_domain() {
81
+ local command_name="aw-spec-specify"
82
+ while [ $# -gt 0 ]; do
83
+ case "$1" in
84
+ --command) command_name="$2"; shift 2 ;;
85
+ --help|-h) echo "resolve-domain --command <name>"; return 0 ;;
86
+ *) echo "resolve-domain: unknown arg '$1'" >&2; return 1 ;;
87
+ esac
88
+ done
89
+
90
+ local repo_root; repo_root=$(get_repo_root)
91
+ local domain_json="$repo_root/.aw-spec/domain.json"
92
+ local domain=""
93
+ if [ -f "$domain_json" ]; then
94
+ if command -v jq >/dev/null 2>&1; then
95
+ domain=$(jq -r '.domain // ""' "$domain_json" 2>/dev/null || echo "")
96
+ else
97
+ domain=$( { grep -E '"domain"[[:space:]]*:' "$domain_json" 2>/dev/null || true; } \
98
+ | head -n1 | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
99
+ fi
100
+ fi
101
+
102
+ local rulemap="$repo_root/docs/domainspecs/$domain/comandrulemap.md"
103
+ local rulemap_exists=false
104
+ [ -n "$domain" ] && [ -f "$rulemap" ] && rulemap_exists=true
105
+
106
+ # Collect bullet paths under "## /<command>" in the domain's comandrulemap.md.
107
+ local -a candidates=()
108
+ if [ "$rulemap_exists" = true ]; then
109
+ # awk: capture "- path" bullets between the target "## <heading>" and the next "## "
110
+ while IFS= read -r line; do
111
+ [ -n "$line" ] && candidates+=("$line")
112
+ done < <(awk -v h="## /$command_name" '
113
+ $0==h {grab=1; next}
114
+ /^## / {grab=0}
115
+ grab && /^[[:space:]]*-[[:space:]]+/ {
116
+ sub(/^[[:space:]]*-[[:space:]]+/,""); sub(/[[:space:]]+$/,""); print
117
+ }
118
+ ' "$rulemap")
119
+ fi
120
+
121
+ printf '{"domain":"%s","rulemap_exists":%s,"candidates":%s}\n' \
122
+ "$(json_escape "$domain")" "$rulemap_exists" "$(json_str_array "${candidates[@]}")"
123
+ }
124
+
125
+ # ──────────────────────────────── resolve-tech ─────────────────────────────
126
+ # Technical rule-file mapping — parallel to resolve-domain, but reads
127
+ # docs/domainspecs/<domain>/commandtechrulemap.md. The TECHNICAL rule files (the
128
+ # docs/architecture · docs/security · docs/interop · docs/governance rule set that
129
+ # used to be hardcoded in each workflow's `standards:` block) are now configured
130
+ # per-command in this map, so they can be tuned per project WITHOUT editing the
131
+ # workflows. The technical CONSTITUTION (constitution.technical.md) is NOT mapped —
132
+ # it is still referred to directly by each consumer, as-is.
133
+ cmd_resolve_tech() {
134
+ local command_name="aw-spec-design"
135
+ while [ $# -gt 0 ]; do
136
+ case "$1" in
137
+ --command) command_name="$2"; shift 2 ;;
138
+ --help|-h) echo "resolve-tech --command <name>"; return 0 ;;
139
+ *) echo "resolve-tech: unknown arg '$1'" >&2; return 1 ;;
140
+ esac
141
+ done
142
+
143
+ local repo_root; repo_root=$(get_repo_root)
144
+ local domain_json="$repo_root/.aw-spec/domain.json"
145
+ local domain=""
146
+ if [ -f "$domain_json" ]; then
147
+ if command -v jq >/dev/null 2>&1; then
148
+ domain=$(jq -r '.domain // ""' "$domain_json" 2>/dev/null || echo "")
149
+ else
150
+ domain=$( { grep -E '"domain"[[:space:]]*:' "$domain_json" 2>/dev/null || true; } \
151
+ | head -n1 | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
152
+ fi
153
+ fi
154
+
155
+ local techmap="$repo_root/docs/domainspecs/$domain/commandtechrulemap.md"
156
+ local techmap_exists=false
157
+ [ -n "$domain" ] && [ -f "$techmap" ] && techmap_exists=true
158
+
159
+ # Collect bullet paths under "## /<command>" (try the exact command heading,
160
+ # then the decl-stripped name, mirroring resolve-domain's fallback).
161
+ local -a candidates=()
162
+ if [ "$techmap_exists" = true ]; then
163
+ local heading
164
+ for heading in "/$command_name" "/${command_name/decl-/}"; do
165
+ while IFS= read -r line; do
166
+ [ -n "$line" ] && candidates+=("$line")
167
+ done < <(awk -v h="## $heading" '
168
+ $0==h {grab=1; next}
169
+ /^## / {grab=0}
170
+ grab && /^[[:space:]]*-[[:space:]]+/ {
171
+ sub(/^[[:space:]]*-[[:space:]]+/,""); sub(/[[:space:]]+$/,""); print
172
+ }
173
+ ' "$techmap")
174
+ [ ${#candidates[@]} -gt 0 ] && break
175
+ done
176
+ fi
177
+
178
+ printf '{"domain":"%s","techrulemap_exists":%s,"candidates":%s}\n' \
179
+ "$(json_escape "$domain")" "$techmap_exists" "$(json_str_array "${candidates[@]}")"
180
+ }
181
+
182
+ # ─────────────────────────────── derive-names ──────────────────────────────
183
+ # Deterministic naming — NO LLM. Aggregate/Feature token wins; else kebab
184
+ # short-name (stop-word filtered) + General aggregate.
185
+ _pascal() { # kebab/space -> PascalCase
186
+ local in="$1" out="" w
187
+ in="${in//-/ }"
188
+ for w in $in; do
189
+ out+="$(tr '[:lower:]' '[:upper:]' <<< "${w:0:1}")${w:1}"
190
+ done
191
+ printf '%s' "$out"
192
+ }
193
+
194
+ _short_name() {
195
+ local description="$1"
196
+ local stop='^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$'
197
+ local clean; clean=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
198
+ local -a words=()
199
+ local word
200
+ for word in $clean; do
201
+ [ -z "$word" ] && continue
202
+ if ! echo "$word" | grep -qiE "$stop"; then
203
+ [ ${#word} -ge 3 ] && words+=("$word")
204
+ fi
205
+ done
206
+ local max=3 result="" count=0
207
+ [ ${#words[@]} -eq 4 ] && max=4
208
+ for word in "${words[@]}"; do
209
+ [ $count -ge $max ] && break
210
+ [ -n "$result" ] && result+="-"
211
+ result+="$word"; count=$((count+1))
212
+ done
213
+ [ -z "$result" ] && result="feature"
214
+ printf '%s' "$result"
215
+ }
216
+
217
+
218
+ # ───────────────────────────────── scaffold ────────────────────────────────
219
+ cmd_scaffold() {
220
+ local feature_dir_arg="" allow_existing=false
221
+ while [ $# -gt 0 ]; do
222
+ case "$1" in
223
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
224
+ --allow-existing) allow_existing=true; shift ;;
225
+ --help|-h) echo "scaffold --feature-dir <path> [--allow-existing]"; return 0 ;;
226
+ *) echo "scaffold: unknown arg '$1'" >&2; return 1 ;;
227
+ esac
228
+ done
229
+ [ -n "$feature_dir_arg" ] || { echo "scaffold: --feature-dir is required" >&2; return 1; }
230
+
231
+ local repo_root; repo_root=$(get_repo_root)
232
+ local feature_dir="$feature_dir_arg"
233
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
234
+ local spec_file="$feature_dir/spec.md"
235
+
236
+ local dir_existed=false; [ -d "$feature_dir" ] && dir_existed=true
237
+ if [ "$dir_existed" = true ] && [ "$allow_existing" != true ]; then
238
+ echo "scaffold: '$feature_dir' already exists (use --allow-existing)" >&2; return 1
239
+ fi
240
+ mkdir -p "$feature_dir"
241
+ local created=true; [ "$dir_existed" = true ] && created=false
242
+
243
+ local template="" spec_created=false
244
+ if [ ! -f "$spec_file" ]; then
245
+ template=$(resolve_template "spec-template" "$repo_root") || true
246
+ if [ -n "$template" ] && [ -f "$template" ]; then cp "$template" "$spec_file"; else touch "$spec_file"; fi
247
+ spec_created=true
248
+ fi
249
+ _persist_feature_json "$repo_root" "$feature_dir"
250
+
251
+ printf '{"feature_dir":"%s","spec_file":"%s","template":"%s","created":%s,"spec_created":%s}\n' \
252
+ "$(json_escape "$feature_dir")" "$(json_escape "$spec_file")" \
253
+ "$(json_escape "$template")" "$created" "$spec_created"
254
+ }
255
+
256
+ # ─────────────────────────────── resolve-hooks ─────────────────────────────
257
+ # Parse .aw-spec/extensions.yml (no PyYAML dependency) and emit the executable
258
+ # hooks for a phase. Governance rules applied here (the agent only dispatches):
259
+ # - drop hooks with enabled: false (absent enabled => enabled)
260
+ # - drop hooks with a non-empty condition (leave to HookExecutor)
261
+ # - normalize command dots to hyphens for the slash form
262
+ cmd_resolve_hooks() {
263
+ local -a phases=()
264
+ while [ $# -gt 0 ]; do
265
+ case "$1" in
266
+ --phase) phases+=("$2"); shift 2 ;;
267
+ --help|-h) echo "resolve-hooks --phase <name> [--phase <name> ...] (multiple phases are merged + deduped by command)"; return 0 ;;
268
+ *) echo "resolve-hooks: unknown arg '$1'" >&2; return 1 ;;
269
+ esac
270
+ done
271
+ [ ${#phases[@]} -gt 0 ] || { echo "resolve-hooks: at least one --phase is required" >&2; return 1; }
272
+
273
+ local repo_root; repo_root=$(get_repo_root)
274
+ local ext="$repo_root/.aw-spec/extensions.yml"
275
+ local phases_csv; phases_csv=$(IFS=,; echo "${phases[*]}")
276
+ if [ ! -f "$ext" ]; then
277
+ printf '{"phases":%s,"hooks":[]}\n' "$(json_str_array "${phases[@]}")"; return 0
278
+ fi
279
+
280
+ if command -v python3 >/dev/null 2>&1; then
281
+ AW_EXT="$ext" AW_PHASES="$phases_csv" python3 - <<'PY'
282
+ import json, os
283
+ path = os.environ["AW_EXT"]
284
+ phases = [p for p in os.environ["AW_PHASES"].split(",") if p]
285
+ lines = open(path, encoding="utf-8").read().splitlines()
286
+
287
+ def indent(s): return len(s) - len(s.lstrip(" "))
288
+
289
+ def parse_phase(phase):
290
+ # locate "hooks:" then "<phase>:" then its list items (targeted parser for the
291
+ # known extensions.yml shape; avoids a YAML dependency). List items ("- ") may
292
+ # sit at the SAME indent as their parent key.
293
+ i, n = 0, len(lines); hooks = []
294
+ while i < n:
295
+ if lines[i].rstrip() == "hooks:":
296
+ base = indent(lines[i]); i += 1
297
+ while i < n and (lines[i].strip() == "" or indent(lines[i]) > base):
298
+ st0 = lines[i].strip()
299
+ if st0.rstrip(":") == phase and st0.endswith(":") and not st0.startswith("- "):
300
+ ph_ind = indent(lines[i]); i += 1; cur = None
301
+ while i < n:
302
+ l = lines[i]
303
+ if l.strip() == "": i += 1; continue
304
+ ind = indent(l); st = l.strip()
305
+ if ind < ph_ind: break
306
+ if ind == ph_ind and st.endswith(":") and not st.startswith("- "): break
307
+ if st.startswith("- "):
308
+ if cur is not None: hooks.append(cur)
309
+ cur = {}; st = st[2:]
310
+ if cur is not None and ":" in st and not st.startswith("- "):
311
+ k, _, v = st.partition(":"); cur[k.strip()] = v.strip()
312
+ i += 1
313
+ if cur is not None: hooks.append(cur)
314
+ break
315
+ else: i += 1
316
+ break
317
+ i += 1
318
+ return hooks
319
+
320
+ def truthy_false(v): return str(v).lower() == "false"
321
+ def empty_cond(v): return v is None or str(v).strip().lower() in ("", "null", "~")
322
+
323
+ out, seen = [], set()
324
+ for phase in phases:
325
+ for h in parse_phase(phase):
326
+ if truthy_false(h.get("enabled", "true")): continue
327
+ if not empty_cond(h.get("condition")): continue
328
+ cmd = h.get("command", "")
329
+ if cmd in seen: continue # dedupe by command across phases
330
+ seen.add(cmd)
331
+ out.append({
332
+ "extension": h.get("extension", ""),
333
+ "command": cmd,
334
+ "slash": "/" + cmd.replace(".", "-") if cmd else "",
335
+ "optional": str(h.get("optional", "false")).lower() == "true",
336
+ "description": h.get("description", ""),
337
+ "prompt": h.get("prompt", ""),
338
+ })
339
+ print(json.dumps({"phases": phases, "hooks": out}))
340
+ PY
341
+ else
342
+ echo "resolve-hooks: python3 unavailable; skipping hook resolution" >&2
343
+ printf '{"phases":%s,"hooks":[]}\n' "$(json_str_array "${phases[@]}")"
344
+ fi
345
+ }
346
+
347
+ # ─────────────────────────────── create-checklist ──────────────────────────
348
+ cmd_create_checklist() {
349
+ local feature_dir_arg="" feature_name="" kind="master" change_dir_arg=""
350
+ while [ $# -gt 0 ]; do
351
+ case "$1" in
352
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
353
+ --feature-name) feature_name="$2"; shift 2 ;;
354
+ --kind) kind="$2"; shift 2 ;;
355
+ --change-dir) change_dir_arg="$2"; shift 2 ;;
356
+ --help|-h) echo "create-checklist --feature-dir <path> [--feature-name <name>] [--kind master|delta] [--change-dir <path>]"; return 0 ;;
357
+ *) echo "create-checklist: unknown arg '$1'" >&2; return 1 ;;
358
+ esac
359
+ done
360
+ [ -n "$feature_dir_arg" ] || { echo "create-checklist: --feature-dir is required" >&2; return 1; }
361
+ case "$kind" in
362
+ master|delta) ;;
363
+ *) echo "create-checklist: --kind must be 'master' or 'delta' (got '$kind')" >&2; return 1 ;;
364
+ esac
365
+
366
+ local repo_root; repo_root=$(get_repo_root)
367
+ local feature_dir="$feature_dir_arg"
368
+ [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
369
+ [ -n "$feature_name" ] || feature_name="$(basename "$feature_dir")"
370
+
371
+ # WHERE the checklist lives and WHAT it grades are decided together here, and both
372
+ # are reported back, so a caller binds the pair from ONE call and can never point a
373
+ # checklist at the wrong document. master -> the feature's spec.md; delta -> the
374
+ # change's delta-spec.md (checklist sits beside it, inside changes/<cr-id>/).
375
+ local checklist_dir target_file cr_id=""
376
+ if [ "$kind" = delta ]; then
377
+ [ -n "$change_dir_arg" ] || { echo "create-checklist: --change-dir is required with --kind delta" >&2; return 1; }
378
+ local change_dir="$change_dir_arg"
379
+ [[ "$change_dir" != /* ]] && change_dir="$repo_root/$change_dir"
380
+ cr_id="$(basename "$change_dir")"
381
+ checklist_dir="$change_dir/checklists"
382
+ target_file="$change_dir/delta-spec.md"
383
+ else
384
+ checklist_dir="$feature_dir/checklists"
385
+ target_file="$feature_dir/spec.md"
386
+ fi
387
+ local checklist_file="$checklist_dir/requirements.md"
388
+ mkdir -p "$checklist_dir"
389
+
390
+ local created=true; [ -f "$checklist_file" ] && created=false
391
+ local today; today="$(date +%Y-%m-%d)"
392
+
393
+ if [ "$kind" = delta ]; then
394
+ cat > "$checklist_file" <<EOF
395
+ # Delta-Spec Quality Checklist: $feature_name — $cr_id
396
+
397
+ **Purpose**: Validate the CHANGE's delta-spec before proceeding to design
398
+ **Created**: $today
399
+ **Change**: [delta-spec.md](../delta-spec.md)
400
+
401
+ ## Content Quality
402
+
403
+ - [ ] No implementation mechanics (languages, frameworks, code/adapter design; interop obligations named as contracts are fine)
404
+ - [ ] Focused on user value and business needs
405
+ - [ ] Written for non-technical stakeholders
406
+ - [ ] Change summary states what is changing and why
407
+
408
+ ## Delta Completeness
409
+
410
+ - [ ] Every item sits under ADDED, MODIFIED or REMOVED (an empty section states None)
411
+ - [ ] MODIFIED items state both sides (was -> now)
412
+ - [ ] New behaviour is a user story (US-n) with priority, Given/When/Then acceptance, and an independent test
413
+ - [ ] New or changed system requirements keep EARS form
414
+ - [ ] Requirements are testable and unambiguous
415
+ - [ ] No [NEEDS CLARIFICATION] markers remain
416
+
417
+ ## Domain Lift (Constraints / Classification / Contracts)
418
+
419
+ - [ ] Constraint / NFR changes are captured as CN- items, or the section states None
420
+ - [ ] Data sensitivity or exposure changes are captured as DC- items, or the section states None
421
+ - [ ] External interoperability changes are captured as IC- items at contract level, or the section states None
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 / 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
+ /aw-spec-orch analyze-delta-design.
435
+ - Items marked incomplete require delta-spec updates before /aw-spec-orch design
436
+ EOF
437
+ else
438
+ cat > "$checklist_file" <<EOF
439
+ # Specification Quality Checklist: $feature_name
440
+
441
+ **Purpose**: Validate specification completeness and quality before proceeding to design
442
+ **Created**: $today
443
+ **Feature**: [spec.md](../spec.md)
444
+
445
+ ## Content Quality
446
+
447
+ - [ ] No implementation mechanics (languages, frameworks, code/adapter design; interop obligations named as contracts are fine)
448
+ - [ ] Focused on user value and business needs
449
+ - [ ] Written for non-technical stakeholders
450
+ - [ ] All mandatory sections completed
451
+
452
+ ## Requirement Completeness
453
+
454
+ - [ ] No [NEEDS CLARIFICATION] markers remain
455
+ - [ ] Requirements are testable and unambiguous
456
+ - [ ] Success criteria are measurable
457
+ - [ ] Success criteria are technology-agnostic (no implementation details)
458
+ - [ ] All acceptance scenarios are defined
459
+ - [ ] Edge cases are identified
460
+ - [ ] Scope is clearly bounded
461
+ - [ ] Dependencies and assumptions identified
462
+
463
+ ## Domain Lift (Constraints / Classification / Contracts)
464
+
465
+ - [ ] Domain-derived constraints are captured in Constraints & NFRs (CN-), or the section states None
466
+ - [ ] Data sensitivity and exposure rules are captured in Data Classification (DC-), or the section states None
467
+ - [ ] External interoperability obligations are captured as Integration Contracts (IC-) at contract level, or the section states None
468
+
469
+ ## Feature Readiness
470
+
471
+ - [ ] All functional requirements have clear acceptance criteria
472
+ - [ ] User scenarios cover primary flows
473
+ - [ ] Feature meets measurable outcomes defined in Success Criteria
474
+ - [ ] No implementation details leak into specification
475
+
476
+ ## Notes
477
+
478
+ - Items marked incomplete require spec updates before /aw-spec-orch design
479
+ EOF
480
+ fi
481
+
482
+ printf '{"checklist_file":"%s","kind":"%s","target":"%s","created":%s}\n' \
483
+ "$(json_escape "$checklist_file")" "$(json_escape "$kind")" \
484
+ "$(json_escape "$target_file")" "$created"
485
+ }
486
+
487
+ # ─────────────────────────────── list-domains ──────────────────────────────
488
+ cmd_list_domains() {
489
+ while [ $# -gt 0 ]; do
490
+ case "$1" in
491
+ --help|-h) echo "list-domains"; return 0 ;;
492
+ *) echo "list-domains: unknown arg '$1'" >&2; return 1 ;;
493
+ esac
494
+ done
495
+ local repo_root; repo_root=$(get_repo_root)
496
+ local base="$repo_root/docs/domainspecs"
497
+ local -a domains=()
498
+ if [ -d "$base" ]; then
499
+ local d
500
+ for d in "$base"/*/; do
501
+ [ -d "$d" ] || continue
502
+ domains+=("$(basename "$d")")
503
+ done
504
+ fi
505
+ printf '{"domains":%s}\n' "$(json_str_array "${domains[@]}")"
506
+ }
507
+
508
+ # ─────────────────────────────── set-domain ────────────────────────────────
509
+ cmd_set_domain() {
510
+ local domain=""
511
+ while [ $# -gt 0 ]; do
512
+ case "$1" in
513
+ --domain) domain="$2"; shift 2 ;;
514
+ --help|-h) echo "set-domain --domain <name>"; return 0 ;;
515
+ *) echo "set-domain: unknown arg '$1'" >&2; return 1 ;;
516
+ esac
517
+ done
518
+ [ -n "$domain" ] || { echo "set-domain: --domain is required" >&2; return 1; }
519
+
520
+ local repo_root; repo_root=$(get_repo_root)
521
+ local map_rel="docs/domainspecs/$domain/comandrulemap.md"
522
+ local map_exists=false
523
+ [ -f "$repo_root/$map_rel" ] && map_exists=true
524
+
525
+ mkdir -p "$repo_root/.aw-spec"
526
+ local dj="$repo_root/.aw-spec/domain.json"
527
+ if command -v jq >/dev/null 2>&1; then
528
+ jq -cn --arg d "$domain" '{domain:$d}' > "$dj"
529
+ else
530
+ printf '{"domain":"%s"}\n' "$(json_escape "$domain")" > "$dj"
531
+ fi
532
+
533
+ printf '{"domain":"%s","map_path":"%s","map_exists":%s,"written":true}\n' \
534
+ "$(json_escape "$domain")" "$(json_escape "$map_rel")" "$map_exists"
535
+ }
536
+
537
+ # ─────────────────────────────── list-features ─────────────────────────────
538
+ # Enumerate the aggregates a user can pick as the active feature. Two sources,
539
+ # MERGED (deduped by feature name, case-insensitive):
540
+ # 1. specs/ sub-folders — features already scaffolded/adopted (greenfield or
541
+ # brownfield-post-adopt); annotated with spec + design docs + version.
542
+ # 2. the OKF bundle's Aggregate concepts — the BROWNFIELD FEATURE MAP onboard
543
+ # reverse-engineers (the discovered aggregates NOT yet under specs/). This is
544
+ # why select-feature can offer real code features in a brownfield repo before
545
+ # anything is adopted, instead of an empty specs/ list.
546
+ # On greenfield (no bundle) source 2 is empty → identical to the old specs-only
547
+ # behaviour. `select_options` = merged names + (an injected --preselect that is not
548
+ # yet a feature) + a "create new" sentinel, so select-feature can offer add-new and
549
+ # honour a direct-argument fast path with ONE list.
550
+ cmd_list_features() {
551
+ local preselect="" bundle=""
552
+ while [ $# -gt 0 ]; do
553
+ case "$1" in
554
+ --preselect) preselect="$2"; shift 2 ;;
555
+ --bundle) bundle="$2"; shift 2 ;;
556
+ --help|-h) echo "list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel}"; return 0 ;;
557
+ *) echo "list-features: unknown arg '$1'" >&2; return 1 ;;
558
+ esac
559
+ done
560
+ local repo_root; repo_root=$(get_repo_root)
561
+ [ -n "$bundle" ] || bundle="knowledge"
562
+ case "$bundle" in /*) ;; *) bundle="$repo_root/$bundle" ;; esac
563
+ # Best-effort OKF aggregate list from the shared python core (single source of
564
+ # truth for OKF parsing; the ps1 twin calls the SAME script). Empty on greenfield.
565
+ local okf_json="{}" invpy="$SCRIPT_DIR/../python/aw-inventory.py"
566
+ if [ -d "$bundle" ] && [ -f "$invpy" ] && command -v python3 >/dev/null 2>&1; then
567
+ okf_json=$(python3 "$invpy" list-aggregates --bundle "$bundle" 2>/dev/null) || okf_json="{}"
568
+ fi
569
+ AW_SPECS="$repo_root/specs" AW_PRESELECT="$preselect" AW_OKF="$okf_json" python3 - <<'PY'
570
+ import json, os, glob
571
+ base = os.environ["AW_SPECS"]
572
+ pre = os.environ.get("AW_PRESELECT", "").strip()
573
+ try:
574
+ okf = json.loads(os.environ.get("AW_OKF", "{}") or "{}")
575
+ except Exception:
576
+ okf = {}
577
+ sentinel = "+ Create a new feature"
578
+ feats, names = [], []
579
+ seen = set() # case-insensitive dedup key across both sources
580
+ if os.path.isdir(base):
581
+ for name in sorted(os.listdir(base)):
582
+ d = os.path.join(base, name)
583
+ if not os.path.isdir(d):
584
+ continue
585
+ ver, active_change = "", ""
586
+ avf = os.path.join(d, "active_version.json")
587
+ if os.path.isfile(avf):
588
+ try:
589
+ av = json.load(open(avf, encoding="utf-8")) or {}
590
+ ver = av.get("version", ""); active_change = av.get("active_change", "")
591
+ except Exception: pass
592
+ names.append(name); seen.add(name.lower())
593
+ feats.append({
594
+ "name": name,
595
+ "dir": "specs/" + name,
596
+ "source": "spec",
597
+ "has_spec": os.path.isfile(os.path.join(d, "spec.md")),
598
+ # `design*.md`, NOT `design.*.md`: the second dot made this miss the single
599
+ # `design.md` the framework actually produces (it only matched the retired
600
+ # design.domain.md / design.technical.md family). Informational count only.
601
+ "design_docs": len(glob.glob(os.path.join(d, "design*.md"))),
602
+ "version": ver,
603
+ "active_change": active_change,
604
+ })
605
+ # Merge OKF-discovered aggregates NOT already scaffolded under specs/ (a specs/
606
+ # folder is the adopted/created form of the same aggregate — it wins, no dup).
607
+ for agg in (okf.get("aggregates") or []):
608
+ nm = (agg.get("name") or "").strip()
609
+ if not nm or nm.lower() in seen:
610
+ continue
611
+ names.append(nm); seen.add(nm.lower())
612
+ feats.append({
613
+ "name": nm,
614
+ "dir": "specs/" + nm, # candidate slice (not yet created)
615
+ "source": "okf",
616
+ "has_spec": False,
617
+ "adopted": False,
618
+ "concept_path": agg.get("concept_path", ""),
619
+ "design_docs": 0,
620
+ "version": "",
621
+ "active_change": "",
622
+ })
623
+ opts = list(names)
624
+ # Inject a not-yet-existing --preselect so `preselect_from: USER_INPUT` can
625
+ # create-or-select it without an extra round trip (the direct-arg fast path).
626
+ if pre and pre.lower() not in seen:
627
+ opts.append(pre)
628
+ opts.append(sentinel)
629
+ print(json.dumps({"features": feats, "names": names, "count": len(names),
630
+ "select_options": opts, "sentinel": sentinel}))
631
+ PY
632
+ }
633
+
634
+ # ─────────────────────────────── set-feature ───────────────────────────────
635
+ # Create-or-select an aggregate under specs/ and persist it as the active feature
636
+ # (.aw-spec/feature.json). feature = aggregate: the name is normalized to a
637
+ # PascalCase identifier (e.g. "order management" -> OrderManagement). Idempotent:
638
+ # selecting an existing feature just re-points feature.json; a new one is created.
639
+ cmd_set_feature() {
640
+ local raw=""
641
+ while [ $# -gt 0 ]; do
642
+ case "$1" in
643
+ --feature) raw="$2"; shift 2 ;;
644
+ --help|-h) echo "set-feature --feature <name> (create-or-select the aggregate under specs/)"; return 0 ;;
645
+ *) echo "set-feature: unknown arg '$1'" >&2; return 1 ;;
646
+ esac
647
+ done
648
+ [ -n "$raw" ] || { echo "set-feature: --feature is required" >&2; return 1; }
649
+
650
+ # Normalize to a PascalCase aggregate identifier (feature = aggregate).
651
+ local cleaned; cleaned=$(echo "$raw" | sed -E 's/[^A-Za-z0-9]+/ /g; s/^ +| +$//g')
652
+ local feature; feature=$(_pascal "$cleaned")
653
+ if ! [[ "$feature" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
654
+ echo "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric aggregate, e.g. Order)" >&2
655
+ return 1
656
+ fi
657
+
658
+ local repo_root; repo_root=$(get_repo_root)
659
+ local feature_dir_rel="specs/$feature"
660
+ local feature_dir="$repo_root/$feature_dir_rel"
661
+ local existed=false; [ -d "$feature_dir" ] && existed=true
662
+ mkdir -p "$feature_dir"
663
+ _persist_feature_json "$repo_root" "$feature_dir_rel"
664
+
665
+ local created=true; [ "$existed" = true ] && created=false
666
+ local spec_exists=false; [ -f "$feature_dir/spec.md" ] && spec_exists=true
667
+ printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","existed":%s,"created":%s,"spec_exists":%s}\n' \
668
+ "$(json_escape "$feature")" "$(json_escape "$feature_dir_rel")" \
669
+ "$(json_escape "$feature_dir_rel/spec.md")" "$existed" "$created" "$spec_exists"
670
+ }
671
+
672
+ # ═══════════════════════════ active_version.json ════════════════════════════
673
+ # PER-FEATURE state at <feature_dir>/active_version.json:
674
+ # {feature, version, active_change, updated}
675
+ # Distinct from the GLOBAL .aw-spec/feature.json (which only SELECTS the active
676
+ # feature). This is the single source of truth for a feature's version number and
677
+ # its open change — the CR that spans cr-setup (opens) -> cr-archive (closes).
678
+ # "Every applied change bumps" => the close op bumps minor ONLY when a change is
679
+ # open (the initial bootstrap build stays v1.0; it is the baseline, not a change).
680
+ # The open/close OPS are reached solely through cr-setup / cr-archive; the former
681
+ # standalone `version-open` / `version-close` subcommands were unused and removed.
682
+ _resolve_feature_dir() { # $1 = --feature-dir arg (may be empty), $2 = repo_root; prints ABS dir or fails
683
+ local arg="$1" repo_root="$2" fd="$1"
684
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
685
+ [ -n "$fd" ] || return 1
686
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
687
+ printf '%s' "$fd"
688
+ }
689
+
690
+ # Shared reader/mutator: op in {read,init,open,close}. Prints one JSON object.
691
+ # op=open enforces the single-active-change guard: opening a DIFFERENT change while
692
+ # one is already open fails (exit 3, ok:false) unless force ("1") is passed — this
693
+ # stops a second concurrent CR on the same aggregate from silently overwriting the
694
+ # first. (Concurrency is ACROSS features; changes to one feature are serialized.)
695
+ _active_version() { # $1 = abs feature_dir, $2 = op, $3 = cr_id (open only), $4 = force ("1" overrides open-guard)
696
+ AW_FD="$1" AW_OP="$2" AW_CR="${3:-}" AW_FORCE="${4:-}" python3 - <<'PY'
697
+ import json, os, re, datetime, sys
698
+ fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]; cr = os.environ.get("AW_CR", "")
699
+ force = os.environ.get("AW_FORCE", "")
700
+ path = os.path.join(fd, "active_version.json")
701
+ feature = os.path.basename(fd.rstrip("/"))
702
+ def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
703
+ def load():
704
+ if os.path.isfile(path):
705
+ try: return json.load(open(path, encoding="utf-8")), True
706
+ except Exception: pass
707
+ return {"feature": feature, "version": "v1.0", "active_change": "", "updated": now()}, False
708
+ def save(d):
709
+ d["updated"] = now()
710
+ open(path, "w", encoding="utf-8").write(json.dumps(d, indent=2) + "\n")
711
+ def bump(v):
712
+ m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "v1.0").strip())
713
+ maj = int(m.group(1)) if m else 1
714
+ mnr = int(m.group(2)) if (m and m.group(2)) else 0
715
+ return "v%d.%d" % (maj, mnr + 1)
716
+
717
+ d, existed = load()
718
+ d.setdefault("feature", feature); d.setdefault("version", "v1.0"); d.setdefault("active_change", "")
719
+ out = {"feature": d["feature"], "version": d["version"], "active_change": d.get("active_change", ""),
720
+ "exists": existed, "path": "active_version.json", "ok": True}
721
+ if op == "read":
722
+ pass
723
+ elif op == "init":
724
+ if not existed: save(d)
725
+ out["created"] = (not existed)
726
+ elif op == "open":
727
+ cur = d.get("active_change", "")
728
+ if cur and cur != cr and force != "1": # a DIFFERENT change is already open -> refuse
729
+ out["ok"] = False
730
+ out["error"] = ("change '%s' is already open on this feature; finish it "
731
+ "(finalize-delta -> analyze-slice -> close-delta) before opening "
732
+ "'%s', or pass --force" % (cur, cr))
733
+ out["conflict"] = cur; out["requested"] = cr; out["has_change"] = True
734
+ print(json.dumps(out)); sys.exit(3)
735
+ d["active_change"] = cr; save(d)
736
+ out["active_change"] = cr; out["opened"] = cr
737
+ if cur and cur == cr: out["reopened"] = True # idempotent re-open of the same CR
738
+ elif op == "close":
739
+ prev = d["version"]; closed = d.get("active_change", "")
740
+ if closed: # a real change was open -> materialized -> bump
741
+ d["version"] = bump(prev)
742
+ d["active_change"] = ""; save(d)
743
+ out["version"] = d["version"]; out["active_change"] = ""
744
+ out["previous"] = prev; out["closed_change"] = closed
745
+ out["has_change"] = bool(out.get("active_change", "")) # clean branch signal for design
746
+ # What a close from THIS state would produce. One bump() authority, read by BOTH
747
+ # finalize-delta (to write the right number into the Change History rows it appends)
748
+ # and close-delta (which actually performs the bump and stamps the two headers), so
749
+ # the two stages can never disagree about the target. Equals `version` when no change
750
+ # is open, mirroring close's "only bump if a change was really open" guard.
751
+ out["next_version"] = bump(out["version"]) if out.get("active_change", "") else out["version"]
752
+ print(json.dumps(out))
753
+ PY
754
+ }
755
+
756
+ cmd_version_read() { _version_cmd "read" "$@"; }
757
+ cmd_version_init() { _version_cmd "init" "$@"; }
758
+ _version_cmd() { # $1 = op (read|init), then --feature-dir arg
759
+ local op="$1"; shift
760
+ local fd_arg=""
761
+ while [ $# -gt 0 ]; do
762
+ case "$1" in
763
+ --feature-dir) fd_arg="$2"; shift 2 ;;
764
+ --help|-h) echo "version-$op [--feature-dir <path>]"; return 0 ;;
765
+ *) echo "version-$op: unknown arg '$1'" >&2; return 1 ;;
766
+ esac
767
+ done
768
+ local repo_root; repo_root=$(get_repo_root)
769
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "version-$op: no feature set and no --feature-dir" >&2; return 1; }
770
+ _active_version "$fd" "$op"
771
+ }
772
+
773
+
774
+
775
+ # ─────────────────────────────── feature-paths ─────────────────────────────
776
+ cmd_feature_paths() {
777
+ while [ $# -gt 0 ]; do
778
+ case "$1" in
779
+ --help|-h) echo "feature-paths"; return 0 ;;
780
+ *) echo "feature-paths: unknown arg '$1'" >&2; return 1 ;;
781
+ esac
782
+ done
783
+ local repo_root; repo_root=$(get_repo_root)
784
+ local fd; fd=$(read_feature_json_feature_directory "$repo_root")
785
+ if [ -z "$fd" ]; then
786
+ echo "feature-paths: no feature set (run /aw-spec-orch select-feature first)" >&2
787
+ return 1
788
+ fi
789
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
790
+ local feature; feature=$(basename "$fd") # the aggregate name (feature = specs/<Feature>)
791
+ # The two-document model: spec.md + design.md ONLY. plan.md / tasks.md belonged to
792
+ # the retired /aw-spec-plan + /aw-spec-tasks phases and had zero consumers.
793
+ local spec="$fd/spec.md" design="$fd/design.md"
794
+ local spec_exists=false; [ -f "$spec" ] && spec_exists=true
795
+ local design_exists=false; [ -f "$design" ] && design_exists=true
796
+ printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","design_file":"%s","spec_exists":%s,"design_exists":%s}\n' \
797
+ "$(json_escape "$feature")" "$(json_escape "$fd")" "$(json_escape "$spec")" "$(json_escape "$design")" \
798
+ "$spec_exists" "$design_exists"
799
+ }
800
+
801
+ # ────────────────────────── checklist-read / -apply ────────────────────────
802
+ # Parse/toggle GitHub task-list checkboxes outside code fences. Python3 does the
803
+ # parsing (robust, fence-aware); toggling changes ONLY the [ ]/[x] marker.
804
+ cmd_checklist_read() {
805
+ local file=""
806
+ while [ $# -gt 0 ]; do
807
+ case "$1" in
808
+ --file) file="$2"; shift 2 ;;
809
+ --help|-h) echo "checklist-read --file <path>"; return 0 ;;
810
+ *) echo "checklist-read: unknown arg '$1'" >&2; return 1 ;;
811
+ esac
812
+ done
813
+ [ -n "$file" ] || { echo "checklist-read: --file required" >&2; return 1; }
814
+ AW_FILE="$file" python3 - <<'PY'
815
+ import json, os
816
+ f = os.environ["AW_FILE"]
817
+ if not os.path.exists(f):
818
+ print(json.dumps({"exists": False, "total": 0, "checked": 0, "items": []})); raise SystemExit
819
+ items, n, fence = [], 0, False
820
+ for line in open(f, encoding="utf-8").read().splitlines():
821
+ s = line.strip()
822
+ if s.startswith("```"): fence = not fence; continue
823
+ if fence: continue
824
+ st = line.lstrip()
825
+ if st[:3] in ("- [",) and len(st) >= 5 and st[3] in " xX" and st[4] == "]":
826
+ n += 1
827
+ items.append({"n": n, "checked": st[3] in "xX", "text": st[5:].strip()})
828
+ print(json.dumps({"exists": True, "total": len(items),
829
+ "checked": sum(1 for i in items if i["checked"]), "items": items}))
830
+ PY
831
+ }
832
+
833
+ cmd_checklist_apply() {
834
+ local file="" results="{}"
835
+ while [ $# -gt 0 ]; do
836
+ case "$1" in
837
+ --file) file="$2"; shift 2 ;;
838
+ --results) results="$2"; shift 2 ;;
839
+ --help|-h) echo "checklist-apply --file <path> --results <json>"; return 0 ;;
840
+ *) echo "checklist-apply: unknown arg '$1'" >&2; return 1 ;;
841
+ esac
842
+ done
843
+ [ -n "$file" ] || { echo "checklist-apply: --file required" >&2; return 1; }
844
+ AW_FILE="$file" AW_RESULTS="$results" python3 - <<'PY'
845
+ import json, os, re
846
+ f = os.environ["AW_FILE"]
847
+ if not os.path.exists(f):
848
+ print(json.dumps({"exists": False, "before_checked": 0, "after_checked": 0,
849
+ "total": 0, "newly_passing": [], "regressions": []})); raise SystemExit
850
+ try:
851
+ results = {int(k): bool(v) for k, v in json.loads(os.environ["AW_RESULTS"] or "{}").items()}
852
+ except Exception:
853
+ results = {}
854
+ lines = open(f, encoding="utf-8").read().splitlines(keepends=True)
855
+ n, fence = 0, False
856
+ before = after = 0
857
+ newly, regress = [], []
858
+ out = []
859
+ for line in lines:
860
+ s = line.strip()
861
+ if s.startswith("```"):
862
+ fence = not fence; out.append(line); continue
863
+ m = re.match(r'^(\s*- \[)([ xX])(\].*)$', line.rstrip("\n")) if not fence else None
864
+ if m:
865
+ n += 1
866
+ cur = m.group(2) in "xX"
867
+ before += 1 if cur else 0
868
+ desired = results.get(n, cur)
869
+ if desired != cur:
870
+ if desired: newly.append(n)
871
+ else: regress.append(n)
872
+ marker = "x" if desired else " "
873
+ nl = "\n" if line.endswith("\n") else ""
874
+ out.append(f"{m.group(1)}{marker}{m.group(3)}{nl}")
875
+ after += 1 if desired else 0
876
+ else:
877
+ out.append(line)
878
+ open(f, "w", encoding="utf-8").write("".join(out))
879
+ print(json.dumps({"exists": True, "before_checked": before, "after_checked": after,
880
+ "total": n, "newly_passing": newly, "regressions": regress}))
881
+ PY
882
+ }
883
+
884
+
885
+
886
+ # ─────────────────────────────── detect-project ────────────────────────────
887
+ # Project Structure Gate: is there a scaffolded app (not just framework files)?
888
+ cmd_detect_project() {
889
+ while [ $# -gt 0 ]; do
890
+ case "$1" in
891
+ --help|-h) echo "detect-project"; return 0 ;;
892
+ *) echo "detect-project: unknown arg '$1'" >&2; return 1 ;;
893
+ esac
894
+ done
895
+ local repo_root; repo_root=$(get_repo_root)
896
+ local -a markers=()
897
+ # .NET solution/project (ignore framework dirs)
898
+ while IFS= read -r m; do [ -n "$m" ] && markers+=("$m"); done < <(
899
+ find "$repo_root" \( -path "$repo_root/.aw-spec" -o -path "$repo_root/docs" -o -path "$repo_root/specs" -o -path "$repo_root/.git" -o -name node_modules \) -prune -o \
900
+ \( -name "*.sln" -o -name "*.csproj" \) -print 2>/dev/null | sed "s#^$repo_root/##" | head -20 )
901
+ # Other ecosystems at repo root
902
+ local f
903
+ for f in package.json pom.xml build.gradle build.gradle.kts go.mod Cargo.toml pyproject.toml; do
904
+ [ -f "$repo_root/$f" ] && markers+=("$f")
905
+ done
906
+ local scaffolded=false
907
+ [ ${#markers[@]} -gt 0 ] && scaffolded=true
908
+ printf '{"scaffolded":%s,"markers":%s}\n' "$scaffolded" "$(json_str_array "${markers[@]}")"
909
+ }
910
+
911
+
912
+ # ──────────────────────────── foundation-list ──────────────────────────────
913
+ # Enumerate available foundations (docs/<Name>/ that carry a real <Name>.pack.txt)
914
+ # so the workflow can let the user PICK one — deterministic, no LLM. An optional
915
+ # --args "<Foundation> <App> [out]" is parsed as a direct-argument fast path:
916
+ # preselect_name is echoed back only when it names a real foundation; preselect_app
917
+ # / out_dir carry any 2nd/3rd tokens through (empty/"." otherwise).
918
+ cmd_foundation_list() {
919
+ local raw=""
920
+ while [ $# -gt 0 ]; do
921
+ case "$1" in
922
+ --args) raw="$2"; shift 2 ;;
923
+ --help|-h) echo "foundation-list [--args '<Foundation> <App> [out]'] -> {foundations[], count, preselect_name, preselect_app, out_dir}"; return 0 ;;
924
+ *) echo "foundation-list: unknown arg '$1'" >&2; return 1 ;;
925
+ esac
926
+ done
927
+ local repo_root; repo_root=$(get_repo_root)
928
+ local -a foundations=()
929
+ if [ -d "$repo_root/docs" ]; then
930
+ local d name
931
+ for d in "$repo_root/docs"/*/; do
932
+ [ -d "$d" ] || continue
933
+ name="$(basename "$d")"
934
+ [ -f "$d$name.pack.txt" ] && foundations+=("$name") # the primary pack (excludes Reference-only dirs)
935
+ done
936
+ fi
937
+ # Optional direct-argument fast path: "<FoundationName> <AppName> [OutputDir]".
938
+ local pre_name="" pre_app="" out_dir="."
939
+ if [ -n "$raw" ]; then
940
+ # shellcheck disable=SC2086
941
+ set -- $raw
942
+ [ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] && out_dir="$3"
943
+ fi
944
+ # Only preselect a name that actually names an available foundation.
945
+ local match="" f
946
+ for f in "${foundations[@]}"; do [ "$f" = "$pre_name" ] && match="$pre_name"; done
947
+ printf '{"foundations":%s,"count":%s,"preselect_name":"%s","preselect_app":"%s","out_dir":"%s"}\n' \
948
+ "$(json_str_array "${foundations[@]}")" "${#foundations[@]}" \
949
+ "$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$out_dir")"
950
+ }
951
+
952
+ # ─────────────────────────── foundation-locate ─────────────────────────────
953
+ # Parse/validate args, locate the pack + unpacker, read the pack header (bounded).
954
+ cmd_foundation_locate() {
955
+ local name="" app="" out="." raw=""
956
+ while [ $# -gt 0 ]; do
957
+ case "$1" in
958
+ --name) name="$2"; shift 2 ;;
959
+ --app) app="$2"; shift 2 ;;
960
+ --out) out="$2"; shift 2 ;;
961
+ --args) raw="$2"; shift 2 ;;
962
+ --help|-h) echo "foundation-locate (--args '<F> <A> [out]' | --name <F> --app <A> [--out <dir>])"; return 0 ;;
963
+ *) echo "foundation-locate: unknown arg '$1'" >&2; return 1 ;;
964
+ esac
965
+ done
966
+ # positional parse: "<FoundationName> <AppName> [OutputDir]"
967
+ if [ -n "$raw" ]; then
968
+ # shellcheck disable=SC2086
969
+ set -- $raw
970
+ [ -n "$1" ] && name="$1"; [ -n "$2" ] && app="$2"; [ -n "$3" ] && out="$3"
971
+ fi
972
+ local repo_root; repo_root=$(get_repo_root)
973
+ # available = docs subdirs containing a *.pack.txt
974
+ local -a available=()
975
+ if [ -d "$repo_root/docs" ]; then
976
+ local d
977
+ for d in "$repo_root/docs"/*/; do
978
+ [ -d "$d" ] || continue
979
+ ls "$d"*.pack.txt >/dev/null 2>&1 && available+=("$(basename "$d")")
980
+ done
981
+ fi
982
+ _fnd_fail() { printf '{"ok":false,"reason":"%s","available":%s}\n' "$(json_escape "$1")" "$(json_str_array "${available[@]}")"; }
983
+ if [ -z "$name" ] || [ -z "$app" ]; then
984
+ _fnd_fail "Usage: /aw-spec-foundation <FoundationName> <AppName> [OutputDirectory]"; return 0
985
+ fi
986
+ local fdir="$repo_root/docs/$name"
987
+ if [ ! -d "$fdir" ]; then _fnd_fail "Foundation '$name' not found (expected docs/$name/)"; return 0; fi
988
+ local pack="$fdir/$name.pack.txt"
989
+ if [ ! -f "$pack" ]; then _fnd_fail "Foundation '$name' has no bundle yet (expected $pack). Build it via docs/$name/PACKAGING.md."; return 0; fi
990
+ # runner + unpacker
991
+ local runner unpacker
992
+ if command -v pwsh >/dev/null 2>&1 && [ -f "$fdir/unpack-foundation.ps1" ]; then
993
+ runner="pwsh"; unpacker="docs/$name/unpack-foundation.ps1"
994
+ elif [ -f "$fdir/unpack-foundation.sh" ]; then
995
+ runner="sh"; unpacker="docs/$name/unpack-foundation.sh"
996
+ else
997
+ emit_fail "No unpacker found in docs/$name/ (unpack-foundation.ps1 or .sh)"; return 0
998
+ fi
999
+ # bounded header read (never load the pack body)
1000
+ local header sentinel file_count
1001
+ header=$(grep -m 30 '^#' "$pack" 2>/dev/null || true)
1002
+ sentinel=$(printf '%s\n' "$header" | grep -iE 'sentinel' | head -1 | sed -E 's/.*[sS]entinel[^A-Za-z0-9_.]*//; s/[^A-Za-z0-9_.].*$//')
1003
+ file_count=$(printf '%s\n' "$header" | grep -iE 'file' | grep -oE '[0-9]+' | head -1)
1004
+ [ -n "$file_count" ] || file_count="0"
1005
+
1006
+ printf '{"ok":true,"reason":"","name":"%s","app":"%s","out_dir":"%s","pack_file":"%s","unpacker":"%s","runner":"%s","sentinel":"%s","file_count":%s,"available":%s}\n' \
1007
+ "$(json_escape "$name")" "$(json_escape "$app")" "$(json_escape "$out")" "$(json_escape "docs/$name/$name.pack.txt")" \
1008
+ "$(json_escape "$unpacker")" "$runner" "$(json_escape "$sentinel")" "$file_count" "$(json_str_array "${available[@]}")"
1009
+ }
1010
+
1011
+ # ─────────────────────────── foundation-unpack ─────────────────────────────
1012
+ # Run the OS-selected unpacker for the foundation (or reference) pack.
1013
+ cmd_foundation_unpack() {
1014
+ local name="" app="" out="." dry=false force="no" reference=false
1015
+ while [ $# -gt 0 ]; do
1016
+ case "$1" in
1017
+ --name) name="$2"; shift 2 ;;
1018
+ --app) app="$2"; shift 2 ;;
1019
+ --out) out="$2"; shift 2 ;;
1020
+ --dry-run) dry=true; shift ;;
1021
+ --force) force="$2"; shift 2 ;;
1022
+ --reference) reference=true; shift ;;
1023
+ --help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
1024
+ *) echo "foundation-unpack: unknown arg '$1'" >&2; return 1 ;;
1025
+ esac
1026
+ done
1027
+ [ -n "$name" ] && [ -n "$app" ] || { echo "foundation-unpack: --name and --app required" >&2; return 1; }
1028
+ local repo_root; repo_root=$(get_repo_root)
1029
+ local fdir="$repo_root/docs/$name"
1030
+ local packbase="$name" script_stem="unpack-foundation"
1031
+ if [ "$reference" = true ]; then packbase="$name.Reference"; script_stem="unpack-reference-foundation"; fi
1032
+ local pack="$fdir/$packbase.pack.txt"
1033
+ if [ ! -f "$pack" ]; then printf '{"ok":false,"mode":"%s","written":0,"has_collisions":false,"stdout":"pack not found: %s"}\n' "$([ "$dry" = true ] && echo dry || echo real)" "$(json_escape "$packbase.pack.txt")"; return 0; fi
1034
+
1035
+ # pick runner
1036
+ local -a cmd=()
1037
+ if command -v pwsh >/dev/null 2>&1 && [ -f "$fdir/$script_stem.ps1" ]; then
1038
+ cmd=(pwsh -NoProfile -File "$fdir/$script_stem.ps1" -PackFile "$pack" -TargetAppName "$app" -OutDir "$out")
1039
+ [ "$dry" = true ] && cmd+=(-DryRun)
1040
+ [ "$force" = "yes" ] && cmd+=(-Force)
1041
+ [ "$reference" = true ] && [ "$force" != "yes" ] && cmd+=(-Force) # reference always forces
1042
+ elif [ -f "$fdir/$script_stem.sh" ]; then
1043
+ cmd=(bash "$fdir/$script_stem.sh" --pack-file "$pack" --target-app-name "$app" --out-dir "$out")
1044
+ [ "$dry" = true ] && cmd+=(--dry-run)
1045
+ { [ "$force" = "yes" ] || [ "$reference" = true ]; } && cmd+=(--force)
1046
+ else
1047
+ printf '{"ok":false,"mode":"real","written":0,"has_collisions":false,"stdout":"no unpacker script for %s"}\n' "$(json_escape "$name")"; return 0
1048
+ fi
1049
+
1050
+ local outp rc
1051
+ rc=0
1052
+ outp=$("${cmd[@]}" 2>&1) || rc=$? # set -e safe: a failing unpacker (dry-run collisions) must still yield JSON
1053
+ # unpackers say "Unpacked N file(s) to …" / "DryRun: N file(s) would be written …"
1054
+ local written; written=$(printf '%s' "$outp" | grep -oiE '[0-9]+ +file' | grep -oE '[0-9]+' | head -1)
1055
+ [ -n "$written" ] || written=0
1056
+ local has_coll=false
1057
+ if [ "$dry" = true ] && [ $rc -ne 0 ]; then has_coll=true; fi
1058
+ printf '{"ok":%s,"mode":"%s","written":%s,"has_collisions":%s,"stdout":"%s"}\n' \
1059
+ "$([ $rc -eq 0 ] && echo true || echo false)" "$([ "$dry" = true ] && echo dry || echo real)" \
1060
+ "$written" "$has_coll" "$(json_escape "$(printf '%s' "$outp" | tail -c 800)")"
1061
+ }
1062
+
1063
+ # ─────────────────────── foundation-stash-dbcontext ────────────────────────
1064
+ # Copy the current ApplicationDbContext.cs to a .foundation.bak sibling before a
1065
+ # reference unpack overwrites it (so the two can be merged afterward).
1066
+ cmd_foundation_stash_dbcontext() {
1067
+ local out="."
1068
+ while [ $# -gt 0 ]; do
1069
+ case "$1" in
1070
+ --out) out="$2"; shift 2 ;;
1071
+ --help|-h) echo "foundation-stash-dbcontext --out <dir>"; return 0 ;;
1072
+ *) echo "foundation-stash-dbcontext: unknown arg '$1'" >&2; return 1 ;;
1073
+ esac
1074
+ done
1075
+ local repo_root; repo_root=$(get_repo_root)
1076
+ [[ "$out" != /* ]] && out="$repo_root/$out"
1077
+ local ctx; ctx=$(find "$out/src" -name "ApplicationDbContext.cs" 2>/dev/null | head -1)
1078
+ if [ -z "$ctx" ]; then printf '{"found":false,"dbcontext":"","backup":""}\n'; return 0; fi
1079
+ local bak="$ctx.foundation.bak"
1080
+ cp "$ctx" "$bak"
1081
+ printf '{"found":true,"dbcontext":"%s","backup":"%s"}\n' "$(json_escape "$ctx")" "$(json_escape "$bak")"
1082
+ }
1083
+
1084
+ # ─────────────────────────── foundation-verify ─────────────────────────────
1085
+ # Scan the output tree for leftover sentinel tokens (should be none).
1086
+ cmd_foundation_verify() {
1087
+ local out="." sentinel=""
1088
+ while [ $# -gt 0 ]; do
1089
+ case "$1" in
1090
+ --out) out="$2"; shift 2 ;;
1091
+ --sentinel) sentinel="$2"; shift 2 ;;
1092
+ --help|-h) echo "foundation-verify --out <dir> --sentinel <s>"; return 0 ;;
1093
+ *) echo "foundation-verify: unknown arg '$1'" >&2; return 1 ;;
1094
+ esac
1095
+ done
1096
+ local repo_root; repo_root=$(get_repo_root)
1097
+ [[ "$out" != /* ]] && out="$repo_root/$out"
1098
+ local -a residue=()
1099
+ if [ -n "$sentinel" ] && [ -d "$out" ]; then
1100
+ # scan the generated tree only — never the bundle/framework dirs (the
1101
+ # .pack.txt legitimately contains the sentinel).
1102
+ while IFS= read -r m; do [ -n "$m" ] && residue+=("$m"); done < <(
1103
+ grep -rIl --exclude-dir=.git --exclude-dir=docs --exclude-dir=.aw-spec \
1104
+ --exclude-dir=specs --exclude-dir=node_modules -- "$sentinel" "$out" 2>/dev/null \
1105
+ | sed "s#^$repo_root/##" | head -50 )
1106
+ fi
1107
+ printf '{"clean":%s,"residue":%s}\n' "$([ ${#residue[@]} -eq 0 ] && echo true || echo false)" "$(json_str_array "${residue[@]}")"
1108
+ }
1109
+
1110
+ # ─────────────────────────────── setup-constitution ────────────────────────
1111
+ # Ensure the split constitution files exist (copy from templates if missing):
1112
+ # .aw-spec/memory/constitution.technical.md (from constitution-technical-template)
1113
+ # .aw-spec/memory/constitution.domain.md (from constitution-domain-template)
1114
+ # The composed .aw-spec/memory/constitution.md is produced by `compose-constitution`
1115
+ # after both halves are authored. Each half is authored + verified independently.
1116
+ cmd_setup_constitution() {
1117
+ while [ $# -gt 0 ]; do
1118
+ case "$1" in
1119
+ --help|-h) echo "setup-constitution"; return 0 ;;
1120
+ *) echo "setup-constitution: unknown arg '$1'" >&2; return 1 ;;
1121
+ esac
1122
+ done
1123
+ local repo_root; repo_root=$(get_repo_root)
1124
+ mkdir -p "$repo_root/.aw-spec/memory"
1125
+ local tech="$repo_root/.aw-spec/memory/constitution.technical.md"
1126
+ local dom="$repo_root/.aw-spec/memory/constitution.domain.md"
1127
+ local tech_created=false dom_created=false
1128
+ if [ ! -f "$tech" ]; then
1129
+ local tpl; tpl=$(resolve_template "constitution-technical-template" "$repo_root") || true
1130
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$tech"; else touch "$tech"; fi
1131
+ tech_created=true
1132
+ fi
1133
+ if [ ! -f "$dom" ]; then
1134
+ local tpl; tpl=$(resolve_template "constitution-domain-template" "$repo_root") || true
1135
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dom"; else touch "$dom"; fi
1136
+ dom_created=true
1137
+ fi
1138
+ printf '{"constitution_technical_file":"%s","constitution_domain_file":"%s","technical_created":%s,"domain_created":%s}\n' \
1139
+ "$(json_escape "$tech")" "$(json_escape "$dom")" "$tech_created" "$dom_created"
1140
+ }
1141
+
1142
+ # ─────────────────────────────── compose-constitution ──────────────────────
1143
+ # Deterministically concatenate the technical + domain halves into the composed
1144
+ # .aw-spec/memory/constitution.md (human/compat view; NOT loaded by the pipeline
1145
+ # phases — they load the halves directly). No model. If the domain half is absent
1146
+ # or empty (technical-only project), the composed file mirrors the technical half.
1147
+ cmd_compose_constitution() {
1148
+ while [ $# -gt 0 ]; do
1149
+ case "$1" in
1150
+ --help|-h) echo "compose-constitution"; return 0 ;;
1151
+ *) echo "compose-constitution: unknown arg '$1'" >&2; return 1 ;;
1152
+ esac
1153
+ done
1154
+ local repo_root; repo_root=$(get_repo_root)
1155
+ local tech="$repo_root/.aw-spec/memory/constitution.technical.md"
1156
+ local dom="$repo_root/.aw-spec/memory/constitution.domain.md"
1157
+ local out="$repo_root/.aw-spec/memory/constitution.md"
1158
+ [ -f "$tech" ] || { printf '{"composed_file":"%s","ok":false,"domain_included":false,"issues":["technical half not found"]}\n' "$(json_escape "$out")"; return 0; }
1159
+ # Include the domain half only if it is authored — non-empty AND free of residual
1160
+ # [ALL_CAPS] template placeholders (a technical-only project leaves it as a raw template).
1161
+ local include_domain=false
1162
+ if [ -f "$dom" ] && [ -s "$dom" ] && ! grep -qE '\[[A-Z0-9]+(_[A-Z0-9]+)+\]' "$dom"; then
1163
+ include_domain=true
1164
+ fi
1165
+ {
1166
+ printf '<!-- COMPOSED — generated by `aw-spec-actions compose-constitution`. Do not edit here.\n'
1167
+ printf ' Source of truth: constitution.technical.md + constitution.domain.md (edit those).\n'
1168
+ printf ' Pipeline phases load the halves directly; this composed view is for humans/compat. -->\n\n'
1169
+ cat "$tech"
1170
+ if [ "$include_domain" = true ]; then
1171
+ printf '\n\n---\n\n'
1172
+ cat "$dom"
1173
+ fi
1174
+ } > "$out"
1175
+ printf '{"composed_file":"%s","ok":true,"domain_included":%s,"issues":[]}\n' "$(json_escape "$out")" "$include_domain"
1176
+ }
1177
+
1178
+ # ─────────────────────────── constitution-verify ───────────────────────────
1179
+ # Deterministic lint of a generated constitution (NO LLM): residual
1180
+ # [ALL_CAPS_WITH_UNDERSCORE] placeholder tokens, body-vs-Sync-Impact-Report version
1181
+ # agreement, and non-ISO dates. Consumed by the constitution skill's bounded
1182
+ # self-correction loop. ok=false ⇒ the loop asks the model to patch.
1183
+ cmd_constitution_verify() {
1184
+ local file=""
1185
+ while [ $# -gt 0 ]; do
1186
+ case "$1" in
1187
+ --file) file="$2"; shift 2 ;;
1188
+ --help|-h) echo "constitution-verify --file <path>"; return 0 ;;
1189
+ *) echo "constitution-verify: unknown arg '$1'" >&2; return 1 ;;
1190
+ esac
1191
+ done
1192
+ [ -n "$file" ] || { echo "constitution-verify: --file required" >&2; return 1; }
1193
+ AW_FILE="$file" python3 - <<'PY'
1194
+ import json, os, re
1195
+ f = os.environ["AW_FILE"]
1196
+ if not os.path.exists(f):
1197
+ print(json.dumps({"ok": False, "exists": False,
1198
+ "issues": ["constitution file not found"], "placeholders": [],
1199
+ "version_body": None, "version_report": None,
1200
+ "version_match": None, "bad_dates": []})); raise SystemExit
1201
+ text = open(f, encoding="utf-8").read()
1202
+ # Strip HTML comments (template guidance + the Sync Impact Report) so they do not
1203
+ # trip the body scan; keep the first block as the report for the version check.
1204
+ m = re.search(r"<!--.*?-->", text, re.S)
1205
+ report = m.group(0) if m else ""
1206
+ body = re.sub(r"<!--.*?-->", "", text, flags=re.S)
1207
+ # 1. Residual ALL_CAPS_WITH_UNDERSCORE placeholder tokens (the template convention).
1208
+ placeholders = sorted(set(re.findall(r"\[([A-Z0-9]+(?:_[A-Z0-9]+)+)\]", body)))
1209
+ # 2. Version: labeled body version vs the report's "new" version.
1210
+ def labeled(s):
1211
+ mm = re.search(r"(?im)version[^0-9\n]{0,20}v?(\d+\.\d+\.\d+)", s)
1212
+ return mm.group(1) if mm else None
1213
+ version_body = labeled(body)
1214
+ arrow = re.search(r"(\d+\.\d+\.\d+)\s*(?:->|→|=>)\s*(\d+\.\d+\.\d+)", report)
1215
+ version_report = arrow.group(2) if arrow else labeled(report)
1216
+ version_match = (version_body == version_report) if (version_body and version_report) else None
1217
+ # 3. Obviously non-ISO dates on ratification/amended/date lines (slash forms,
1218
+ # DD-MM-YYYY, US textual). ISO YYYY-MM-DD never matches these patterns.
1219
+ bad = []
1220
+ for line in body.splitlines():
1221
+ if re.search(r"(?i)ratif|amend|\bdate\b", line):
1222
+ bad += re.findall(r"\b\d{1,2}/\d{1,2}/\d{2,4}\b", line)
1223
+ bad += re.findall(r"\b\d{1,2}-\d{1,2}-\d{4}\b", line)
1224
+ bad += re.findall(r"\b(?:January|February|March|April|May|June|July|August|"
1225
+ r"September|October|November|December)\s+\d{1,2},?\s+\d{4}\b", line)
1226
+ bad = sorted(set(bad))
1227
+ issues = []
1228
+ if placeholders:
1229
+ issues.append("%d unfilled placeholder(s): %s" % (len(placeholders), ", ".join(placeholders)))
1230
+ if version_match is False:
1231
+ issues.append("version mismatch: body %s vs Sync Impact Report %s" % (version_body, version_report))
1232
+ if bad:
1233
+ issues.append("non-ISO date(s) (use YYYY-MM-DD): %s" % ", ".join(bad))
1234
+ ok = (not placeholders) and (version_match is not False) and (not bad)
1235
+ print(json.dumps({"ok": ok, "exists": True, "issues": issues,
1236
+ "placeholders": placeholders, "version_body": version_body,
1237
+ "version_report": version_report, "version_match": version_match,
1238
+ "bad_dates": bad}))
1239
+ PY
1240
+ }
1241
+
1242
+ # ────────────────────────── agent-context-update ───────────────────────────
1243
+ # Run the agent-context extension's updater (refreshes the managed AW-Spec block).
1244
+ cmd_agent_context_update() {
1245
+ local plan=""
1246
+ while [ $# -gt 0 ]; do
1247
+ case "$1" in
1248
+ --plan) plan="$2"; shift 2 ;;
1249
+ --help|-h) echo "agent-context-update [--plan <path>]"; return 0 ;;
1250
+ *) echo "agent-context-update: unknown arg '$1'" >&2; return 1 ;;
1251
+ esac
1252
+ done
1253
+ local repo_root; repo_root=$(get_repo_root)
1254
+ local script="$repo_root/.aw-spec/extensions/agent-context/scripts/bash/update-agent-context.sh"
1255
+ if [ ! -f "$script" ]; then
1256
+ printf '{"ok":true,"ran":false,"message":"agent-context extension not installed; nothing to do"}\n'; return 0
1257
+ fi
1258
+ local out rc
1259
+ if [ -n "$plan" ]; then out=$(bash "$script" "$plan" 2>&1); else out=$(bash "$script" 2>&1); fi
1260
+ rc=$?
1261
+ printf '{"ok":%s,"ran":true,"message":"%s"}\n' \
1262
+ "$([ $rc -eq 0 ] && echo true || echo false)" "$(json_escape "$(printf '%s' "$out" | tail -c 400)")"
1263
+ }
1264
+
1265
+
1266
+
1267
+ # ─────────────────────────────── list-components ───────────────────────────
1268
+ # Immediate subdirs of docs/components/ that contain an integration.md runbook.
1269
+ cmd_list_components() {
1270
+ while [ $# -gt 0 ]; do
1271
+ case "$1" in
1272
+ --help|-h) echo "list-components"; return 0 ;;
1273
+ *) echo "list-components: unknown arg '$1'" >&2; return 1 ;;
1274
+ esac
1275
+ done
1276
+ local repo_root; repo_root=$(get_repo_root)
1277
+ local base="$repo_root/docs/components"
1278
+ local -a comps=()
1279
+ if [ -d "$base" ]; then
1280
+ local d
1281
+ for d in "$base"/*/; do
1282
+ [ -d "$d" ] || continue
1283
+ [ -f "$d/integration.md" ] && comps+=("$(basename "$d")")
1284
+ done
1285
+ fi
1286
+ printf '{"components":%s}\n' "$(json_str_array "${comps[@]}")"
1287
+ }
1288
+
1289
+ # ─────────────────────────────── find-host-project ─────────────────────────
1290
+ # Locate the host/startup project by finding Program.cs (never assume a name).
1291
+ cmd_find_host_project() {
1292
+ while [ $# -gt 0 ]; do
1293
+ case "$1" in
1294
+ --help|-h) echo "find-host-project"; return 0 ;;
1295
+ *) echo "find-host-project: unknown arg '$1'" >&2; return 1 ;;
1296
+ esac
1297
+ done
1298
+ local repo_root; repo_root=$(get_repo_root)
1299
+ local prog
1300
+ # prefer under src/, ignore framework/build dirs
1301
+ prog=$(find "$repo_root/src" -name "Program.cs" 2>/dev/null | head -1)
1302
+ [ -z "$prog" ] && prog=$(find "$repo_root" \( -path "$repo_root/.aw-spec" -o -path "$repo_root/docs" -o -path "$repo_root/specs" -o -path "$repo_root/.git" -o -name obj -o -name bin \) -prune -o -name "Program.cs" -print 2>/dev/null | head -1)
1303
+ if [ -z "$prog" ]; then printf '{"found":false,"program_cs":"","project_dir":"","csproj":""}\n'; return 0; fi
1304
+ local pdir; pdir=$(dirname "$prog")
1305
+ local csproj; csproj=$(find "$pdir" -maxdepth 1 -name "*.csproj" 2>/dev/null | head -1)
1306
+ printf '{"found":true,"program_cs":"%s","project_dir":"%s","csproj":"%s"}\n' \
1307
+ "$(json_escape "$prog")" "$(json_escape "$pdir")" "$(json_escape "$csproj")"
1308
+ }
1309
+
1310
+ # ─────────────────────────────── component-catalog ──────────────────────────
1311
+ # Deterministic parse of a component's integration.md "Implementation catalog
1312
+ # (selection list)" markdown table (NO LLM). Replaces the extract_catalog llm_query.
1313
+ # Returns {integrable, impl_keys[], implementations[{key,desc}]}.
1314
+ cmd_component_catalog() {
1315
+ local file=""
1316
+ while [ $# -gt 0 ]; do
1317
+ case "$1" in
1318
+ --file) file="$2"; shift 2 ;;
1319
+ --help|-h) echo "component-catalog --file <integration.md>"; return 0 ;;
1320
+ *) echo "component-catalog: unknown arg '$1'" >&2; return 1 ;;
1321
+ esac
1322
+ done
1323
+ [ -n "$file" ] || { echo "component-catalog: --file required" >&2; return 1; }
1324
+ local repo_root; repo_root=$(get_repo_root)
1325
+ [[ "$file" != /* ]] && file="$repo_root/$file"
1326
+ AW_FILE="$file" python3 - <<'PY'
1327
+ import json, os, re
1328
+ f = os.environ["AW_FILE"]
1329
+ if not os.path.exists(f):
1330
+ print(json.dumps({"integrable": False, "impl_keys": [], "implementations": []})); raise SystemExit
1331
+ lines = open(f, encoding="utf-8").read().splitlines()
1332
+ start = None
1333
+ for i, ln in enumerate(lines):
1334
+ if re.match(r'^#{1,6}\s', ln) and re.search(r'(?i)implementation catalog|selection list', ln):
1335
+ start = i + 1; break
1336
+ rows = []
1337
+ if start is not None:
1338
+ for ln in lines[start:]:
1339
+ if re.match(r'^#{1,6}\s', ln): # next heading ends the section
1340
+ break
1341
+ s = ln.strip()
1342
+ if s.startswith("|"):
1343
+ rows.append(s)
1344
+ elif rows: # blank/non-table after the table => done
1345
+ break
1346
+ def cells(row):
1347
+ return [c.strip() for c in row.strip().strip("|").split("|")]
1348
+ impl_keys, implementations = [], []
1349
+ if len(rows) >= 2:
1350
+ header = cells(rows[0])
1351
+ kidx = 0
1352
+ for j, h in enumerate(header):
1353
+ if h.strip().lower() == "key":
1354
+ kidx = j; break
1355
+ for row in rows[2:]: # skip header + separator
1356
+ c = cells(row)
1357
+ if not c or all(re.fullmatch(r':?-{2,}:?', (x or '')) for x in c):
1358
+ continue
1359
+ if kidx >= len(c):
1360
+ continue
1361
+ key = c[kidx].strip("` ").strip()
1362
+ if not key:
1363
+ continue
1364
+ parts = []
1365
+ for j, val in enumerate(c):
1366
+ if j == kidx or not val:
1367
+ continue
1368
+ h = header[j] if j < len(header) else ""
1369
+ parts.append("%s: %s" % (h, val) if h else val)
1370
+ impl_keys.append(key)
1371
+ implementations.append({"key": key, "desc": " · ".join(parts)})
1372
+ print(json.dumps({"integrable": len(impl_keys) > 0,
1373
+ "impl_keys": impl_keys, "implementations": implementations}))
1374
+ PY
1375
+ }
1376
+
1377
+ # ─────────────────────────────── component-verify ───────────────────────────
1378
+ # Post-wiring verification (NO LLM decision): (1) `dotnet build` the host project
1379
+ # (graceful skip if the SDK is absent); (2) R0.2a dual-registration guard — when the
1380
+ # host has BOTH AddInfrastructure and AddAspireInfrastructure, assert `--marker`
1381
+ # appears in both. Returns {ok, ran, build_ok, errors[], dual_registration_ok,
1382
+ # fix_target, fix_reason, can_fix} for a bounded fix loop.
1383
+ cmd_component_verify() {
1384
+ local host_dir="" host_csproj="" marker=""
1385
+ while [ $# -gt 0 ]; do
1386
+ case "$1" in
1387
+ --host-dir) host_dir="$2"; shift 2 ;;
1388
+ --host-csproj) host_csproj="$2"; shift 2 ;;
1389
+ --marker) marker="$2"; shift 2 ;;
1390
+ --help|-h) echo "component-verify --host-dir <dir> [--host-csproj <path>] [--marker <token>]"; return 0 ;;
1391
+ *) echo "component-verify: unknown arg '$1'" >&2; return 1 ;;
1392
+ esac
1393
+ done
1394
+ local repo_root; repo_root=$(get_repo_root)
1395
+ AW_REPO="$repo_root" AW_HOST_DIR="$host_dir" AW_HOST_CSPROJ="$host_csproj" AW_MARKER="$marker" python3 - <<'PY'
1396
+ import json, os, re, shutil, subprocess
1397
+ repo = os.environ.get("AW_REPO", "")
1398
+ hostd = os.environ.get("AW_HOST_DIR", "")
1399
+ csproj = os.environ.get("AW_HOST_CSPROJ", "")
1400
+ marker = os.environ.get("AW_MARKER", "").strip()
1401
+ def relp(p):
1402
+ try: return os.path.relpath(p, repo).replace("\\", "/") if (repo and p) else p
1403
+ except Exception: return p
1404
+ # --- (1) build ---
1405
+ ran, build_ok, errors, primary = False, True, [], ""
1406
+ if shutil.which("dotnet"):
1407
+ target = csproj if csproj else hostd
1408
+ if target and os.path.exists(target):
1409
+ ran = True
1410
+ proc = subprocess.run(["dotnet", "build", target, "-nologo"],
1411
+ capture_output=True, text=True)
1412
+ seen = set()
1413
+ for line in (proc.stdout + "\n" + proc.stderr).splitlines():
1414
+ if re.search(r':\s*error\s', line):
1415
+ t = line.strip()
1416
+ if t not in seen:
1417
+ seen.add(t); errors.append(t)
1418
+ errors = errors[:20]
1419
+ build_ok = (proc.returncode == 0)
1420
+ if not build_ok and errors:
1421
+ m = re.match(r'^(.*?)\(\d+,\d+\)', errors[0])
1422
+ if m: primary = relp(m.group(1).strip())
1423
+ if not build_ok and not primary and csproj:
1424
+ primary = relp(csproj)
1425
+ # --- (2) R0.2a dual-registration guard ---
1426
+ def defining_files(method):
1427
+ root = hostd if (hostd and os.path.isdir(hostd)) else repo
1428
+ hits = []
1429
+ if not root: return hits
1430
+ pat = re.compile(r'\b' + re.escape(method) + r'\s*\(')
1431
+ for dp, dns, fns in os.walk(root):
1432
+ dns[:] = [d for d in dns if d not in ("bin", "obj", ".git")]
1433
+ for fn in fns:
1434
+ if fn.endswith(".cs"):
1435
+ fp = os.path.join(dp, fn)
1436
+ try: txt = open(fp, encoding="utf-8", errors="ignore").read()
1437
+ except Exception: continue
1438
+ if pat.search(txt): hits.append((fp, txt))
1439
+ return hits
1440
+ infra, aspire = defining_files("AddInfrastructure"), defining_files("AddAspireInfrastructure")
1441
+ dual_app = bool(infra) and bool(aspire)
1442
+ dual_ok, dual_note, dual_fix = True, "", ""
1443
+ def pick_fix(hits):
1444
+ non_prog = [fp for fp, _ in hits if os.path.basename(fp) != "Program.cs"]
1445
+ if non_prog: return relp(non_prog[0])
1446
+ return relp(hits[0][0]) if hits else ""
1447
+ if not dual_app:
1448
+ dual_note = "single registration path (R0.2) — dual-registration check N/A"
1449
+ elif not marker:
1450
+ dual_note = "both AddInfrastructure and AddAspireInfrastructure exist; no marker provided to verify the component is in both"
1451
+ else:
1452
+ in_infra = any(marker in txt for _, txt in infra)
1453
+ in_aspire = any(marker in txt for _, txt in aspire)
1454
+ dual_ok = in_infra and in_aspire
1455
+ if not dual_ok:
1456
+ missing = "AddInfrastructure" if not in_infra else "AddAspireInfrastructure"
1457
+ dual_fix = pick_fix(infra if not in_infra else aspire)
1458
+ dual_note = "marker '%s' missing from %s (R0.2a: register in BOTH paths)" % (marker, missing)
1459
+ else:
1460
+ dual_note = "marker '%s' present in both registration paths" % marker
1461
+ ok = build_ok and dual_ok
1462
+ fix_target, fix_reason = "", ""
1463
+ if not build_ok:
1464
+ fix_target = primary or (relp(csproj) if csproj else "")
1465
+ fix_reason = "build failed: " + " | ".join(errors[:5])
1466
+ elif not dual_ok:
1467
+ fix_target, fix_reason = dual_fix, dual_note
1468
+ print(json.dumps({"ok": ok, "ran": ran, "build_ok": build_ok, "errors": errors,
1469
+ "primary_error_file": primary, "dual_applicable": dual_app,
1470
+ "dual_registration_ok": dual_ok, "dual_note": dual_note,
1471
+ "dual_fix_file": dual_fix, "fix_target": fix_target,
1472
+ "fix_reason": fix_reason, "can_fix": bool(fix_target)}))
1473
+ PY
1474
+ }
1475
+
1476
+ # ─────────────────────────────────── main ──────────────────────────────────
1477
+ sub="${1:-}"; shift || true
1478
+ # ──────────────────────────────── design-scaffold ──────────────────────────
1479
+ # The spec-anchored Design phase: ONE high-level design doc per feature
1480
+ # (design.md) — a tech-agnostic HLD that `implement` GENERATES code from (not a
1481
+ # deterministic extract). See .aw-spec/DESIGN-FORMAT.md + design-template.md.
1482
+ #
1483
+ # The former path-tagged helpers (design-extract / -reconcile / -paths) are GONE.
1484
+ # They belonged to a retired four-document model in which design.{domain,application,
1485
+ # api,infra}.md carried the source inside ```lang path=… fences. Nothing emits that
1486
+ # fence any more, and their glob (`design.*.md`) could not match a single `design.md`
1487
+ # even if something did — so all three had been returning nothing, silently, at exit 0.
1488
+ # The question they answered ("which files is this slice made of") is now answered by
1489
+ # the OKF concept: `aw-xref concept-paths` to read, `concept-upsert --merge` to write.
1490
+
1491
+ # Copy the single HLD design template into the feature dir as design.md (idempotent).
1492
+ cmd_design_scaffold() {
1493
+ local feature_dir_arg="" template_arg="design-template"
1494
+ while [ $# -gt 0 ]; do
1495
+ case "$1" in
1496
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
1497
+ # --template <name>: which template to scaffold (default `design-template`).
1498
+ # In a brownfield repo, /aw-spec-orch onboard GENERATES a code-derived
1499
+ # `design-template` override (resolve_template Priority-1), so the default
1500
+ # already yields the as-built structure for both forward `design` and adopt
1501
+ # (no per-call --template needed). Resolved via resolve_template.
1502
+ --template) template_arg="$2"; shift 2 ;;
1503
+ --help|-h) echo "design-scaffold --feature-dir <path> [--template <name>]"; return 0 ;;
1504
+ *) echo "design-scaffold: unknown arg '$1'" >&2; return 1 ;;
1505
+ esac
1506
+ done
1507
+ local repo_root; repo_root=$(get_repo_root)
1508
+ local fd="$feature_dir_arg"
1509
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
1510
+ [ -n "$fd" ] || { echo "design-scaffold: no feature set (run /aw-spec-orch select-feature first) and no --feature-dir" >&2; return 1; }
1511
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
1512
+ mkdir -p "$fd"
1513
+
1514
+ local target="$fd/design.md" created="false" tpl
1515
+ if [ -f "$target" ]; then
1516
+ created="false"
1517
+ else
1518
+ tpl=$(resolve_template "$template_arg" "$repo_root") || true
1519
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$target"; else touch "$target"; fi
1520
+ created="true"
1521
+ fi
1522
+ # override_active: is a PROJECT-level override installed for this template name?
1523
+ # Computed ALWAYS (independent of the idempotent scaffold skip above) so it is a
1524
+ # stable signal on both the bootstrap AND delta paths. In the brownfield-dev flow,
1525
+ # /aw-spec-orch onboard GENERATES the code-derived `design-template` override — so
1526
+ # override_active=true means the forward `design` phase should author with the
1527
+ # (code-derived) as-built structure, not the greenfield four-layer layout.
1528
+ local override_active="false"
1529
+ [ -f "$repo_root/.aw-spec/templates/overrides/${template_arg}.md" ] && override_active="true"
1530
+ local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
1531
+ printf '{"feature_dir":"%s","design_doc":"%s","created":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
1532
+ "$(json_escape "$fd")" "$(json_escape "$target")" "$created" "$override_active" \
1533
+ "$(json_escape "$template_arg")" "$(json_escape "${template_source:-}")"
1534
+ }
1535
+
1536
+ # ─────────────────────── build / test / migrate / deploy ────────────────────
1537
+ # Detect the project's toolchain, run the phase command, and report structured
1538
+ # status. Tolerant: an unrecognized stack is a no-op pass (wire a hook instead).
1539
+ # The FIX is an LLM step in the workflow; these only run + report.
1540
+
1541
+ cmd_build_compile() {
1542
+ while [ $# -gt 0 ]; do
1543
+ case "$1" in
1544
+ --feature-dir) shift 2 ;;
1545
+ --help|-h) echo "build-compile [--feature-dir <path>]"; return 0 ;;
1546
+ *) echo "build-compile: unknown arg '$1'" >&2; return 1 ;;
1547
+ esac
1548
+ done
1549
+ local repo_root; repo_root=$(get_repo_root)
1550
+ local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
1551
+ if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root/src" -maxdepth 4 -name "*.csproj" 2>/dev/null | grep -q .; then
1552
+ tool="dotnet"
1553
+ if command -v dotnet >/dev/null 2>&1; then
1554
+ cmd="dotnet build --nologo"; ran=true
1555
+ if ( cd "$repo_root" && dotnet build --nologo ) >"$log" 2>&1; then ok=true; else ok=false; fi
1556
+ else ok=false; fi
1557
+ elif [ -f "$repo_root/package.json" ] && command -v npm >/dev/null 2>&1; then
1558
+ tool="npm"; cmd="npm run build --if-present"; ran=true
1559
+ if ( cd "$repo_root" && npm run build --if-present ) >"$log" 2>&1; then ok=true; else ok=false; fi
1560
+ else
1561
+ tool="none"; ran=false; ok=true
1562
+ fi
1563
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
1564
+ printf '{"ok":%s,"tool":"%s","ran":%s,"cmd":"%s","log_tail":"%s"}\n' \
1565
+ "$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$cmd")" "$(json_escape "$t")"
1566
+ }
1567
+
1568
+ cmd_test_run() {
1569
+ # --scope repo (default): run the whole suite — the historic behaviour, every
1570
+ # existing caller is unaffected.
1571
+ # --scope slice : run ONLY the tests whose FullyQualifiedName contains the
1572
+ # feature's aggregate name (feature-dir basename) plus any --also
1573
+ # referrer names — the per-feature integrity scope. .NET only; npm/none
1574
+ # ignore it and run the full suite.
1575
+ local feature_dir="" scope="repo" also=""
1576
+ while [ $# -gt 0 ]; do
1577
+ case "$1" in
1578
+ --feature-dir) feature_dir="$2"; shift 2 ;;
1579
+ --scope) scope="$2"; shift 2 ;;
1580
+ --also) also="$2"; shift 2 ;;
1581
+ --help|-h) echo "test-run [--feature-dir <path>] [--scope slice|repo] [--also <csv>]"; return 0 ;;
1582
+ *) echo "test-run: unknown arg '$1'" >&2; return 1 ;;
1583
+ esac
1584
+ done
1585
+ local repo_root; repo_root=$(get_repo_root)
1586
+
1587
+ # For scope=slice, build the token set = feature aggregate name + --also referrers,
1588
+ # each sanitised to an identifier (VSTest filter operators |&()!=~ are illegal) and
1589
+ # de-duplicated. A token that survives sanitising becomes a `FullyQualifiedName~<tok>`
1590
+ # clause; substring match is deliberate — it over-includes (safe for an integrity
1591
+ # fence) rather than risking an under-scoped miss.
1592
+ local slices="" name tok also_sp
1593
+ if [ "$scope" = "slice" ]; then
1594
+ also_sp=$(printf '%s' "$also" | tr ',' ' ')
1595
+ for name in "$(basename "${feature_dir%/}")" $also_sp; do
1596
+ tok=$(printf '%s' "$name" | tr -cd 'A-Za-z0-9._-')
1597
+ case "$tok" in [A-Za-z]*) ;; *) continue ;; esac # must start with a letter
1598
+ case " $slices " in *" $tok "*) continue ;; esac # de-dup
1599
+ slices="${slices:+$slices }$tok"
1600
+ done
1601
+ fi
1602
+
1603
+ local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
1604
+ local matched=false scoped=false fence_green=false filter="" t2
1605
+ if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root" -maxdepth 5 -name "*.csproj" 2>/dev/null | grep -q .; then
1606
+ tool="dotnet"
1607
+ if command -v dotnet >/dev/null 2>&1; then
1608
+ if [ "$scope" = "slice" ]; then
1609
+ if [ -z "$slices" ]; then
1610
+ # scope=slice but nothing valid to filter on — fail loudly, never
1611
+ # silently fall back to a repo-wide run.
1612
+ printf 'scope=slice but no valid filter tokens from --feature-dir/--also\n' >"$log"
1613
+ ok=false; ran=false; cmd="(no filter tokens)"
1614
+ else
1615
+ for t2 in $slices; do filter="${filter:+$filter|}FullyQualifiedName~$t2"; done
1616
+ scoped=true; cmd="dotnet test --nologo --filter \"$filter\""; ran=true
1617
+ if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else ok=false; fi
1618
+ fi
1619
+ else
1620
+ cmd="dotnet test --nologo"; ran=true
1621
+ if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else ok=false; fi
1622
+ fi
1623
+ else ok=false; fi
1624
+ elif [ -f "$repo_root/package.json" ] && command -v npm >/dev/null 2>&1; then
1625
+ tool="npm"; cmd="npm test"; ran=true
1626
+ if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else ok=false; fi
1627
+ else
1628
+ tool="none"; ran=false; ok=true
1629
+ fi
1630
+
1631
+ # matched = at least one test was actually selected + run. Read from the LOG, never
1632
+ # the exit code: a .NET --filter that selects ZERO tests still EXITS 0, so trusting
1633
+ # the exit code would report an empty scoped run as green (the vacuous-pass trap).
1634
+ if grep -Eq 'Total:[[:space:]]*[1-9]' "$log" 2>/dev/null; then matched=true; fi
1635
+
1636
+ # fence_green — a green that actually proves something ran.
1637
+ # scoped run → ok AND matched (guards the zero-match vacuous pass)
1638
+ # everything else (repo suite, npm, no test tool, the loud no-token fail) → ok,
1639
+ # preserving the historic pass-through for a project with no/zero tests.
1640
+ if [ "$scoped" = true ]; then
1641
+ if [ "$ok" = true ] && [ "$matched" = true ]; then fence_green=true; fi
1642
+ else
1643
+ fence_green=$ok
1644
+ fi
1645
+
1646
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
1647
+ printf '{"ok":%s,"tool":"%s","ran":%s,"scope":"%s","slices":"%s","scoped":%s,"matched":%s,"fence_green":%s,"cmd":"%s","log_tail":"%s"}\n' \
1648
+ "$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$scope")" "$(json_escape "$slices")" \
1649
+ "$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")"
1650
+ }
1651
+
1652
+ cmd_run_migrate() {
1653
+ while [ $# -gt 0 ]; do
1654
+ case "$1" in
1655
+ --feature-dir) shift 2 ;;
1656
+ --help|-h) echo "run-migrate [--feature-dir <path>]"; return 0 ;;
1657
+ *) echo "run-migrate: unknown arg '$1'" >&2; return 1 ;;
1658
+ esac
1659
+ done
1660
+ local repo_root; repo_root=$(get_repo_root)
1661
+ local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
1662
+ local has_dotnet=false
1663
+ if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root/src" -maxdepth 4 -name "*.csproj" 2>/dev/null | grep -q .; then has_dotnet=true; fi
1664
+ if [ "$has_dotnet" = true ] && command -v dotnet >/dev/null 2>&1 && ( cd "$repo_root" && dotnet ef --version >/dev/null 2>&1 ); then
1665
+ # Prefer *Service.csproj (API host with ApplicationDbContext); fall back to first
1666
+ # non-AppHost/Web/ServiceDefaults/test project under src/.
1667
+ local project=""
1668
+ project=$(find "$repo_root/src" -maxdepth 3 -name '*.Service.csproj' 2>/dev/null | head -1)
1669
+ if [ -z "$project" ]; then
1670
+ project=$(find "$repo_root/src" -maxdepth 3 -name '*.csproj' 2>/dev/null \
1671
+ | grep -vE 'AppHost|ServiceDefaults|\.Web\.|Tests' | head -1)
1672
+ fi
1673
+ if [ -n "$project" ]; then
1674
+ # Multi-context solutions need an explicit context (ApplicationDbContext is the app model).
1675
+ tool="dotnet-ef"
1676
+ cmd="dotnet ef database update --project $project --context ApplicationDbContext"
1677
+ ran=true
1678
+ if ( cd "$repo_root" && dotnet ef database update --project "$project" --context ApplicationDbContext ) >"$log" 2>&1; then
1679
+ ok=true
1680
+ else
1681
+ ok=false
1682
+ fi
1683
+ else
1684
+ tool="dotnet-ef"; cmd="dotnet ef database update"; ran=true
1685
+ if ( cd "$repo_root" && dotnet ef database update ) >"$log" 2>&1; then ok=true; else ok=false; fi
1686
+ fi
1687
+ else
1688
+ tool="none"; ran=false; ok=true # no .NET project / migration tool = nothing to run
1689
+ fi
1690
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
1691
+ printf '{"ok":%s,"tool":"%s","ran":%s,"cmd":"%s","log_tail":"%s"}\n' \
1692
+ "$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$cmd")" "$(json_escape "$t")"
1693
+ }
1694
+
1695
+ # Deploy is intentionally config-driven: reads .aw-spec/deploy.json {"command": "..."}.
1696
+ cmd_deploy_dispatch() {
1697
+ while [ $# -gt 0 ]; do
1698
+ case "$1" in
1699
+ --help|-h) echo "deploy-dispatch"; return 0 ;;
1700
+ *) echo "deploy-dispatch: unknown arg '$1'" >&2; return 1 ;;
1701
+ esac
1702
+ done
1703
+ local repo_root; repo_root=$(get_repo_root)
1704
+ local cfg="$repo_root/.aw-spec/deploy.json"
1705
+ local configured=false ran=false ok=true cmd="" log; log=$(mktemp)
1706
+ if [ -f "$cfg" ] && command -v python3 >/dev/null 2>&1; then
1707
+ cmd=$(AW_CFG="$cfg" python3 -c "import json,os;print(json.load(open(os.environ['AW_CFG'])).get('command',''))" 2>/dev/null || true)
1708
+ fi
1709
+ if [ -n "$cmd" ]; then
1710
+ configured=true; ran=true
1711
+ if ( cd "$repo_root" && bash -c "$cmd" ) >"$log" 2>&1; then ok=true; else ok=false; fi
1712
+ fi
1713
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
1714
+ printf '{"ok":%s,"configured":%s,"ran":%s,"cmd":"%s","log_tail":"%s"}\n' \
1715
+ "$ok" "$configured" "$ran" "$(json_escape "$cmd")" "$(json_escape "$t")"
1716
+ }
1717
+
1718
+ # ─────────────────────────────── cr-setup / cr-archive ──────────────────────
1719
+ # Change-request scaffolding: a delta lives under <feature>/changes/<cr-id>/,
1720
+ # archives to <feature>/changes/archive/<cr-id>/, and bumps the version on close.
1721
+ # Single-active-change guard: cr-setup refuses to open a 2nd concurrent change on
1722
+ # the same aggregate (design must close the open one first) unless --force.
1723
+ cmd_cr_setup() {
1724
+ local cr_id="" feature_dir_arg="" title="" force=""
1725
+ while [ $# -gt 0 ]; do
1726
+ case "$1" in
1727
+ --cr-id) cr_id="$2"; shift 2 ;;
1728
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
1729
+ --title) title="$2"; shift 2 ;;
1730
+ --force) force=1; shift ;;
1731
+ --help|-h) echo "cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>] [--force]"; return 0 ;;
1732
+ *) echo "cr-setup: unknown arg '$1'" >&2; return 1 ;;
1733
+ esac
1734
+ done
1735
+ [ -n "$cr_id" ] || cr_id="CR-$(date +%Y%m%d-%H%M%S)"
1736
+ local repo_root; repo_root=$(get_repo_root)
1737
+ local fd="$feature_dir_arg"
1738
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
1739
+ [ -n "$fd" ] || { echo "cr-setup: no feature set and no --feature-dir" >&2; return 1; }
1740
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
1741
+ # Single-active-change guard: open the change FIRST so a second concurrent CR on
1742
+ # the same aggregate is refused BEFORE any changes/<cr>/ dir is scaffolded (no
1743
+ # orphan left behind). Re-opening the same cr_id is a no-op. --force overrides.
1744
+ local av_open
1745
+ if ! av_open=$(_active_version "$fd" open "$cr_id" "$force"); then
1746
+ echo "cr-setup: refusing to open '$cr_id' — a change is already open on this feature; finish it (finalize-delta -> analyze-slice -> close-delta) first, or pass --force." >&2
1747
+ printf '%s\n' "$av_open"
1748
+ return 1
1749
+ fi
1750
+ local cr_dir="$fd/changes/$cr_id"
1751
+ mkdir -p "$cr_dir"
1752
+ local dspec="$cr_dir/delta-spec.md" ddesign="$cr_dir/delta-design.md" tpl created=false
1753
+ if [ ! -f "$dspec" ]; then
1754
+ tpl=$(resolve_template "delta-spec-template" "$repo_root") || true
1755
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dspec"; else touch "$dspec"; fi
1756
+ created=true
1757
+ fi
1758
+ if [ ! -f "$ddesign" ]; then
1759
+ tpl=$(resolve_template "delta-design-template" "$repo_root") || true
1760
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$ddesign"; else touch "$ddesign"; fi
1761
+ created=true
1762
+ fi
1763
+ printf '{"cr_id":"%s","cr_dir":"%s","delta_spec":"%s","delta_design":"%s","title":"%s","created":%s}\n' \
1764
+ "$(json_escape "$cr_id")" "$(json_escape "$cr_dir")" "$(json_escape "$dspec")" \
1765
+ "$(json_escape "$ddesign")" "$(json_escape "$title")" "$created"
1766
+ }
1767
+
1768
+ cmd_cr_archive() {
1769
+ local cr_id="" feature_dir_arg=""
1770
+ while [ $# -gt 0 ]; do
1771
+ case "$1" in
1772
+ --cr-id) cr_id="$2"; shift 2 ;;
1773
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
1774
+ --help|-h) echo "cr-archive --cr-id <id> [--feature-dir <path>]"; return 0 ;;
1775
+ *) echo "cr-archive: unknown arg '$1'" >&2; return 1 ;;
1776
+ esac
1777
+ done
1778
+ [ -n "$cr_id" ] || { echo "cr-archive: --cr-id required" >&2; return 1; }
1779
+ local repo_root; repo_root=$(get_repo_root)
1780
+ local fd="$feature_dir_arg"
1781
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
1782
+ [ -n "$fd" ] || { echo "cr-archive: no feature set and no --feature-dir" >&2; return 1; }
1783
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
1784
+ local cr_dir="$fd/changes/$cr_id" archive_root="$fd/changes/archive" archived=""
1785
+ if [ -d "$cr_dir" ]; then
1786
+ mkdir -p "$archive_root"
1787
+ rm -rf "$archive_root/$cr_id"
1788
+ mv "$cr_dir" "$archive_root/$cr_id"
1789
+ archived="$archive_root/$cr_id"
1790
+ fi
1791
+ # Close the change in active_version.json (single version source): bump minor +
1792
+ # clear active_change. (Supersedes the old bare <feature>/VERSION file.)
1793
+ local av; av=$(_active_version "$fd" close)
1794
+ AW_AV="$av" AW_ARCHIVED="$archived" python3 - <<'PY'
1795
+ import json, os
1796
+ try: av = json.loads(os.environ.get("AW_AV") or "{}")
1797
+ except Exception: av = {}
1798
+ print(json.dumps({"ok": True, "archived_dir": os.environ.get("AW_ARCHIVED", ""),
1799
+ "version": av.get("version", "v1.1"), "previous": av.get("previous", "v1.0"),
1800
+ "closed_change": av.get("closed_change", "")}))
1801
+ PY
1802
+ }
1803
+
1804
+ # Stamp a version into the MASTER documents' `**Version**:` headers — the
1805
+ # deterministic twin of cr-archive's active_version.json bump. close-delta calls
1806
+ # this with the version cr-archive just computed, so all THREE carriers of the
1807
+ # number (spec.md, design.md, active_version.json) move in ONE stage by ONE
1808
+ # mechanism. Before this, design.md's header was bumped by the MODEL at
1809
+ # finalize-delta — a stage early, and guessing the target since the number was
1810
+ # never passed into the merge prompt — while spec.md's header was bumped by
1811
+ # nobody at all and sat at v1.0 forever.
1812
+ # Idempotent: re-stamping the same version reports `unchanged`, writes nothing.
1813
+ # Only the value moves; any trailing inline comment on the header line is kept.
1814
+ cmd_version_stamp() {
1815
+ local version="" feature_dir_arg=""
1816
+ while [ $# -gt 0 ]; do
1817
+ case "$1" in
1818
+ --version) version="$2"; shift 2 ;;
1819
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
1820
+ --help|-h) echo "version-stamp --version <vX.Y> [--feature-dir <path>]"; return 0 ;;
1821
+ *) echo "version-stamp: unknown arg '$1'" >&2; return 1 ;;
1822
+ esac
1823
+ done
1824
+ [ -n "$version" ] || { echo "version-stamp: --version required" >&2; return 1; }
1825
+ local repo_root; repo_root=$(get_repo_root)
1826
+ local fd="$feature_dir_arg"
1827
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
1828
+ [ -n "$fd" ] || { echo "version-stamp: no feature set and no --feature-dir" >&2; return 1; }
1829
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
1830
+ AW_FD="$fd" AW_VER="$version" python3 - <<'PY'
1831
+ import json, os, re
1832
+ fd = os.environ["AW_FD"]; ver = os.environ["AW_VER"].strip()
1833
+ stamped, unchanged, missing, no_header = [], [], [], []
1834
+ # First `**Version**: <value>` line only — that is the header. The Change History
1835
+ # table's `| Version |` column head does not match this pattern.
1836
+ pat = re.compile(r'^(\*\*Version\*\*:[ \t]*)(\S+)(.*)$', re.M)
1837
+ for name in ("spec.md", "design.md"):
1838
+ p = os.path.join(fd, name)
1839
+ if not os.path.isfile(p):
1840
+ missing.append(name); continue
1841
+ src = open(p, encoding="utf-8").read()
1842
+ m = pat.search(src)
1843
+ if not m:
1844
+ no_header.append(name); continue
1845
+ if m.group(2) == ver:
1846
+ unchanged.append(name); continue
1847
+ open(p, "w", encoding="utf-8").write(
1848
+ src[:m.start()] + m.group(1) + ver + m.group(3) + src[m.end():])
1849
+ stamped.append(name)
1850
+ print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
1851
+ "unchanged": unchanged, "missing": missing,
1852
+ "no_header": no_header, "count": len(stamped)}))
1853
+ PY
1854
+ }
1855
+
1856
+ case "$sub" in
1857
+ resolve-domain) cmd_resolve_domain "$@" ;;
1858
+ resolve-tech) cmd_resolve_tech "$@" ;;
1859
+ scaffold) cmd_scaffold "$@" ;;
1860
+ resolve-hooks) cmd_resolve_hooks "$@" ;;
1861
+ create-checklist) cmd_create_checklist "$@" ;;
1862
+ list-domains) cmd_list_domains "$@" ;;
1863
+ set-domain) cmd_set_domain "$@" ;;
1864
+ list-features) cmd_list_features "$@" ;;
1865
+ set-feature) cmd_set_feature "$@" ;;
1866
+ version-read) cmd_version_read "$@" ;;
1867
+ version-init) cmd_version_init "$@" ;;
1868
+ feature-paths) cmd_feature_paths "$@" ;;
1869
+ design-scaffold) cmd_design_scaffold "$@" ;;
1870
+ build-compile) cmd_build_compile "$@" ;;
1871
+ test-run) cmd_test_run "$@" ;;
1872
+ run-migrate) cmd_run_migrate "$@" ;;
1873
+ deploy-dispatch) cmd_deploy_dispatch "$@" ;;
1874
+ cr-setup) cmd_cr_setup "$@" ;;
1875
+ cr-archive) cmd_cr_archive "$@" ;;
1876
+ version-stamp) cmd_version_stamp "$@" ;;
1877
+ checklist-read) cmd_checklist_read "$@" ;;
1878
+ checklist-apply) cmd_checklist_apply "$@" ;;
1879
+ detect-project) cmd_detect_project "$@" ;;
1880
+ foundation-list) cmd_foundation_list "$@" ;;
1881
+ foundation-locate) cmd_foundation_locate "$@" ;;
1882
+ foundation-unpack) cmd_foundation_unpack "$@" ;;
1883
+ foundation-verify) cmd_foundation_verify "$@" ;;
1884
+ foundation-stash-dbcontext) cmd_foundation_stash_dbcontext "$@" ;;
1885
+ list-components) cmd_list_components "$@" ;;
1886
+ component-catalog) cmd_component_catalog "$@" ;;
1887
+ component-verify) cmd_component_verify "$@" ;;
1888
+ find-host-project) cmd_find_host_project "$@" ;;
1889
+ setup-constitution) cmd_setup_constitution "$@" ;;
1890
+ compose-constitution) cmd_compose_constitution "$@" ;;
1891
+ constitution-verify) cmd_constitution_verify "$@" ;;
1892
+ agent-context-update) cmd_agent_context_update "$@" ;;
1893
+ ""|--help|-h)
1894
+ sed -n '2,34p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' ;;
1895
+ *)
1896
+ echo "Unknown subcommand: $sub" >&2
1897
+ echo "Run '$0 --help' for usage." >&2
1898
+ exit 1 ;;
1899
+ esac