aw-spec-cli 0.1.32 → 0.1.34

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 (75) hide show
  1. package/README.md +76 -81
  2. package/assets/.appweaver/DESIGN-FORMAT.md +56 -17
  3. package/assets/.appweaver/STACK-PROFILES.md +95 -60
  4. package/assets/.appweaver/orchestrator/LAUNCHER.md +1 -1
  5. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +173 -32
  6. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +29 -1
  7. package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +7 -4
  8. package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +49 -60
  9. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +58 -0
  10. package/assets/.appweaver/orchestrator/orchestrator.py +26 -8
  11. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +20 -16
  12. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +30 -6
  13. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +9 -7
  14. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -5
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +341 -36
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +254 -43
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +248 -68
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +251 -20
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +316 -10
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +6 -47
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +62 -16
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +132 -9
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-fix-test-coverage.orch.yaml +965 -0
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +222 -169
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +28 -12
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +453 -116
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +7 -4
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +83 -6
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +45 -2
  30. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +324 -74
  31. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +275 -44
  32. package/assets/.appweaver/scripts/python/aw-foundation.py +16 -13
  33. package/assets/.appweaver/scripts/python/aw-gaps.py +11 -3
  34. package/assets/.appweaver/scripts/python/aw-model-guard.py +295 -0
  35. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +412 -3
  36. package/assets/.appweaver/scripts/python/aw-plangate.py +23 -16
  37. package/assets/.appweaver/scripts/python/aw-stack.py +1794 -265
  38. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +0 -2
  39. package/assets/.appweaver/stack-profiles/java-spring.json +0 -2
  40. package/assets/.appweaver/templates/api-plan-template.md +107 -0
  41. package/assets/.appweaver/templates/delta-api-plan-template.md +87 -0
  42. package/assets/.appweaver/templates/delta-design-template.md +25 -23
  43. package/assets/.appweaver/templates/delta-spec-template.md +3 -3
  44. package/assets/.appweaver/templates/design-template.md +20 -42
  45. package/assets/.appweaver/templates/domain-model-template.md +3 -0
  46. package/assets/.appweaver/templates/ui-delta-design-template.md +11 -8
  47. package/assets/.appweaver/templates/ui-design-template.md +4 -4
  48. package/assets/foundations/foundation.spec.template.txt +25 -18
  49. package/assets/guardrails/README.md +61 -25
  50. package/assets/guardrails/methodology/ux/accessibility-standards.md +38 -0
  51. package/assets/guardrails/methodology/ux/content-design.md +34 -0
  52. package/assets/guardrails/methodology/ux/interaction-states.md +45 -0
  53. package/assets/guardrails/methodology/ux/journey-mapping.md +41 -0
  54. package/assets/guardrails/project-specific/domain/domain-requirements.md +25 -38
  55. package/assets/guardrails/project-specific/domain-rule-bindings.md +33 -69
  56. package/assets/guardrails/project-specific/frontend/ui-requirements.md +2 -2
  57. package/assets/guardrails/project-specific/service/technical-requirements.md +2 -2
  58. package/assets/guardrails/project-specific/tech-rule-bindings.md +16 -13
  59. package/assets/guardrails/sample-healthcare/domain/domain-requirements.md +58 -0
  60. package/assets/guardrails/sample-healthcare/domain-rule-bindings.md +138 -0
  61. package/assets/guardrails/sample-healthcare/frontend/ui-requirements.md +43 -0
  62. package/assets/guardrails/sample-healthcare/service/technical-requirements.md +40 -0
  63. package/assets/guardrails/sample-healthcare/tech-rule-bindings.md +303 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +216 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/utils/config.js +1 -1
  68. package/dist/utils/config.js.map +1 -1
  69. package/dist/utils/foundation.d.ts +36 -0
  70. package/dist/utils/foundation.d.ts.map +1 -0
  71. package/dist/utils/foundation.js +109 -0
  72. package/dist/utils/foundation.js.map +1 -0
  73. package/dist/utils/paths.js +1 -1
  74. package/dist/utils/paths.js.map +1 -1
  75. package/package.json +1 -1
@@ -110,23 +110,32 @@ function Invoke-ResolveDomain {
110
110
  $rulemap = Join-Path $repo "guardrails/$domain/domain-rule-bindings.md"
111
111
  $rulemapExists = ($domain -ne '') -and (Test-Path $rulemap)
112
112
 
113
- $candidates = @()
113
+ # A mapped file that is not on disk goes to `missing`, never to `candidates`, so a
114
+ # caller can say so instead of loading nothing in its place without a word.
115
+ # heading_found tells a heading that is deliberately empty (a `_None_` line) apart
116
+ # from one that is absent or misspelled — both give no candidates.
117
+ $candidates = @(); $missing = @(); $headingFound = $false
114
118
  if ($rulemapExists) {
115
119
  $lines = Get-Content $rulemap
116
120
  $heading = "## /$commandName"
117
121
  $grab = $false; $found = @()
118
122
  foreach ($line in $lines) {
119
- if ($line -eq $heading) { $grab = $true; continue }
123
+ if ($line -eq $heading) { $grab = $true; $headingFound = $true; continue }
120
124
  if ($line -match '^## ') { $grab = $false }
121
- if ($grab -and $line -match '^\s*-\s+(.+?)\s*$') { $found += $Matches[1] }
125
+ # A path written as code (- `path`) is read without its backticks.
126
+ if ($grab -and $line -match '^\s*-\s+(.+?)\s*$') { $found += ($Matches[1] -replace '^`([^`]+)`$', '$1') }
127
+ }
128
+ foreach ($f in $found) {
129
+ if (Test-Path -LiteralPath (Join-Path $repo $f) -PathType Leaf) { $candidates += $f } else { $missing += $f }
122
130
  }
123
- if ($found.Count -gt 0) { $candidates = $found }
124
131
  }
125
132
 
126
133
  Write-Json ([ordered]@{
127
134
  domain = $domain
128
135
  rulemap_exists = $rulemapExists
136
+ heading_found = $headingFound
129
137
  candidates = @($candidates)
138
+ missing = @($missing)
130
139
  })
131
140
  }
132
141
 
@@ -175,21 +184,27 @@ function Invoke-ResolveTech {
175
184
  $subs['stack'] = $subs['backend_stack'] # the unqualified form means the service lane
176
185
  $declared = @($subs.Values | Where-Object { $_ } | Select-Object -Unique)
177
186
 
178
- $bullets = @()
187
+ # Whether the command's heading is in the map at all. A heading with no bullets (a
188
+ # `_None_` line) is a deliberate choice; an absent or misspelled one loads the same
189
+ # nothing, and only this tells the caller which of the two it is.
190
+ $bullets = @(); $headingFound = $false
179
191
  if ($techmapExists) {
180
192
  $lines = Get-Content $techmap
181
193
  foreach ($heading in @("## /$commandName", "## /$($commandName -replace 'decl-','')")) {
182
194
  $grab = $false; $found = @()
183
195
  foreach ($line in $lines) {
184
- if ($line -eq $heading) { $grab = $true; continue }
196
+ if ($line -eq $heading) { $grab = $true; $headingFound = $true; continue }
185
197
  if ($line -match '^## ') { $grab = $false }
186
- if ($grab -and $line -match '^\s*-\s+(.+?)\s*$') { $found += $Matches[1] }
198
+ # A path written as code (- `path`) is read without its backticks.
199
+ if ($grab -and $line -match '^\s*-\s+(.+?)\s*$') { $found += ($Matches[1] -replace '^`([^`]+)`$', '$1') }
187
200
  }
188
201
  if ($found.Count -gt 0) { $bullets = $found; break }
189
202
  }
190
203
  }
191
204
 
192
- $candidates = @(); $missing = @(); $skipped = @(); $mismatched = @()
205
+ # A placeholder the kit does not know is a typo in the map, unlike one for a lane
206
+ # this repo does not declare, which is skipped because its rules do not apply.
207
+ $candidates = @(); $missing = @(); $skipped = @(); $mismatched = @(); $unknown = @()
193
208
  foreach ($b in $bullets) {
194
209
  $holes = @([regex]::Matches($b, '\{\{\s*([a-z_]+)\s*\}\}') | ForEach-Object { $_.Groups[1].Value })
195
210
  $why = ''
@@ -199,7 +214,11 @@ function Invoke-ResolveTech {
199
214
  # genuinely does not exist, so its rules genuinely do not apply.
200
215
  if (-not $subs[$h]) { $why = "no lane declared for {{$h}}"; break }
201
216
  }
202
- if ($why) { $skipped += [ordered]@{ bullet = $b; why = $why }; continue }
217
+ if ($why) {
218
+ $skipped += [ordered]@{ bullet = $b; why = $why }
219
+ if ($why -like 'unknown placeholder*') { $unknown += $b }
220
+ continue
221
+ }
203
222
  $path = [regex]::Replace($b, '\{\{\s*([a-z_]+)\s*\}\}', { param($m) $subs[$m.Groups[1].Value] })
204
223
  if (Test-Path (Join-Path $repo $path)) { $candidates += $path } else { $missing += $path }
205
224
  # A LITERAL stack directory that is not one this repo declares. Reported, never
@@ -212,13 +231,30 @@ function Invoke-ResolveTech {
212
231
  }
213
232
  }
214
233
 
234
+ # A STACK WITH NO RULE FOLDER AT ALL — reported as the folder, see the bash twin. A file
235
+ # missing from a folder that does exist stays listed in missing_elsewhere.
236
+ $absent = @(); $absentFiles = 0; $elsewhere = @()
237
+ foreach ($p in $missing) {
238
+ $m3 = [regex]::Match($p, '^(guardrails/technology/[^/]+/)')
239
+ if ($m3.Success -and -not (Test-Path -LiteralPath (Join-Path $repo $m3.Groups[1].Value) -PathType Container)) {
240
+ if ($absent -notcontains $m3.Groups[1].Value) { $absent += $m3.Groups[1].Value }
241
+ $absentFiles++
242
+ } else { $elsewhere += $p }
243
+ }
244
+
215
245
  Write-Json ([ordered]@{
216
- domain = $domain
217
- techrulemap_exists = $techmapExists
218
- candidates = @($candidates)
219
- missing = @($missing)
220
- skipped = @($skipped)
221
- declared_stacks = @($declared | Sort-Object)
246
+ domain = $domain
247
+ techrulemap_exists = $techmapExists
248
+ heading_found = $headingFound
249
+ candidates = @($candidates)
250
+ missing = @($missing)
251
+ missing_elsewhere = @($elsewhere)
252
+ absent_corpora = @($absent)
253
+ absent_corpora_text = ($absent -join ' and ')
254
+ absent_files = $absentFiles
255
+ skipped = @($skipped)
256
+ unknown_placeholders = @($unknown)
257
+ declared_stacks = @($declared | Sort-Object)
222
258
  stack_mismatch = ($mismatched.Count -gt 0)
223
259
  mismatched_stacks = @($mismatched)
224
260
  })
@@ -579,6 +615,16 @@ function Invoke-AwStackCore {
579
615
  if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
580
616
  }
581
617
 
618
+ # One aw-stack call whose JSON the caller reads instead of printing; $null when it fails.
619
+ function Get-AwStackJson {
620
+ param([string]$Op, [string[]]$Rest)
621
+ $py = Get-Py
622
+ if (-not $py) { return $null }
623
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-stack.py'
624
+ try { return ((& $py $core '--repo' (Get-RepoRoot) $Op @Rest) | Out-String | ConvertFrom-Json) }
625
+ catch { return $null }
626
+ }
627
+
582
628
  function Invoke-StackList {
583
629
  param([string[]]$Rest)
584
630
  $fwd = @()
@@ -608,6 +654,20 @@ function Invoke-StackDetect {
608
654
  Invoke-AwStackCore -Op 'detect' -Rest $fwd
609
655
  }
610
656
 
657
+ # Which stacks the active project's technical map has rule files for — see the bash twin.
658
+ function Invoke-StackRules {
659
+ param([string[]]$Rest)
660
+ $fwd = @()
661
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
662
+ switch ($Rest[$i]) {
663
+ '--kind' { $fwd += '--kind'; $fwd += $Rest[++$i] }
664
+ '--declared' { $fwd += '--declared' }
665
+ default { Write-Error "stack-rules: unknown arg '$($Rest[$i])'"; exit 1 }
666
+ }
667
+ }
668
+ Invoke-AwStackCore -Op 'rules' -Rest $fwd
669
+ }
670
+
611
671
  function Invoke-LaneDeclare {
612
672
  param([string[]]$Rest)
613
673
  $fwd = @()
@@ -640,8 +700,8 @@ function Invoke-LaneResolve {
640
700
  Invoke-AwStackCore -Op 'resolve' -Rest $fwd
641
701
  }
642
702
 
643
- # Change the API-contract keys (contract_run, contract, client) on a lane already
644
- # declared. Twin of bash cmd_lane_set.
703
+ # Change the API-contract keys (contract_write, contract_run, contract, client) on a lane
704
+ # already declared. Twin of bash cmd_lane_set.
645
705
  function Invoke-LaneSet {
646
706
  param([string[]]$Rest)
647
707
  $fwd = @()
@@ -654,8 +714,9 @@ function Invoke-LaneSet {
654
714
  Invoke-AwStackCore -Op 'set' -Rest $fwd
655
715
  }
656
716
 
657
- # ─────────────── contract-export / -draft / -combine / -requests / -client / -check ───────────────
658
- # Twins of the bash cmd_contract_export / _draft / _combine / _requests / _client / _check.
717
+ # ─────── contract-export / -draft / -combine / -requests / -client / -check / -verify / -merge, api-split ───────
718
+ # Twins of the bash cmd_contract_export / _draft / _combine / _requests / _client / _check /
719
+ # _verify / _merge and cmd_api_split.
659
720
  function Invoke-ContractExport {
660
721
  param([string[]]$Rest)
661
722
  $fwd = @()
@@ -668,12 +729,19 @@ function Invoke-ContractExport {
668
729
  Invoke-AwStackCore -Op 'contract-export' -Rest $fwd
669
730
  }
670
731
 
732
+ # Each value is forwarded only when it is not empty — PowerShell 5.1 drops an empty
733
+ # argument, and the flag would then take the next one.
671
734
  function Invoke-ContractDraft {
672
735
  param([string[]]$Rest)
673
736
  $fwd = @()
674
737
  for ($i = 0; $i -lt $Rest.Count; $i++) {
675
738
  switch ($Rest[$i]) {
676
- '--feature-dir' { $v = $Rest[++$i]; if ($v) { $fwd += '--feature-dir'; $fwd += $v } }
739
+ { $_ -in '--feature-dir','--cr' } {
740
+ $flag = $Rest[$i]
741
+ if ($i + 1 -lt $Rest.Count -and -not "$($Rest[$i + 1])".StartsWith('--')) {
742
+ $v = $Rest[++$i]; if ($v) { $fwd += $flag; $fwd += $v }
743
+ }
744
+ }
677
745
  default { Write-Error "contract-draft: unknown arg '$($Rest[$i])'"; exit 1 }
678
746
  }
679
747
  }
@@ -744,6 +812,67 @@ function Invoke-ContractImpact {
744
812
  Invoke-AwStackCore -Op 'contract-impact' -Rest $fwd
745
813
  }
746
814
 
815
+ # cmd_contract_verify: does the built service serve what its plan names; with --check, only
816
+ # whether the plan names anything to check. Each value is forwarded only when it is not
817
+ # empty, as above.
818
+ function Invoke-ContractVerify {
819
+ param([string[]]$Rest)
820
+ $fwd = @()
821
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
822
+ switch ($Rest[$i]) {
823
+ { $_ -in '--feature-dir','--cr','--doc' } {
824
+ $flag = $Rest[$i]
825
+ if ($i + 1 -lt $Rest.Count -and -not "$($Rest[$i + 1])".StartsWith('--')) {
826
+ $v = $Rest[++$i]; if ($v) { $fwd += $flag; $fwd += $v }
827
+ }
828
+ }
829
+ '--check' { $fwd += '--check' }
830
+ default { Write-Error "contract-verify: unknown arg '$($Rest[$i])'"; exit 1 }
831
+ }
832
+ }
833
+ Invoke-AwStackCore -Op 'contract-verify' -Rest $fwd
834
+ }
835
+
836
+ # cmd_contract_merge: a change's delta API plan, merged into its feature's API plan; with
837
+ # --check, only whether it is still to merge (close-delta).
838
+ function Invoke-ContractMerge {
839
+ param([string[]]$Rest)
840
+ $fwd = @()
841
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
842
+ switch ($Rest[$i]) {
843
+ { $_ -in '--feature-dir','--cr' } {
844
+ $flag = $Rest[$i]
845
+ if ($i + 1 -lt $Rest.Count -and -not "$($Rest[$i + 1])".StartsWith('--')) {
846
+ $v = $Rest[++$i]; if ($v) { $fwd += $flag; $fwd += $v }
847
+ }
848
+ }
849
+ '--check' { $fwd += '--check' }
850
+ default { Write-Error "contract-merge: unknown arg '$($Rest[$i])'"; exit 1 }
851
+ }
852
+ }
853
+ Invoke-AwStackCore -Op 'contract-merge' -Rest $fwd
854
+ }
855
+
856
+ # cmd_api_split: a feature's endpoints and requests table, moved out of its backend plan
857
+ # into an API plan; --check only reports whether one is still to move.
858
+ function Invoke-ApiSplit {
859
+ param([string[]]$Rest)
860
+ $fwd = @()
861
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
862
+ switch ($Rest[$i]) {
863
+ { $_ -in '--feature-dir','--cr' } {
864
+ $flag = $Rest[$i]
865
+ if ($i + 1 -lt $Rest.Count -and -not "$($Rest[$i + 1])".StartsWith('--')) {
866
+ $v = $Rest[++$i]; if ($v) { $fwd += $flag; $fwd += $v }
867
+ }
868
+ }
869
+ '--check' { $fwd += '--check' }
870
+ default { Write-Error "api-split: unknown arg '$($Rest[$i])'"; exit 1 }
871
+ }
872
+ }
873
+ Invoke-AwStackCore -Op 'api-split' -Rest $fwd
874
+ }
875
+
747
876
  # The gate: is the declaration USABLE for the phase about to run. Twin of bash
748
877
  # cmd_lane_verify. --require names a lane kind the phase cannot run without;
749
878
  # --allow-missing-root is the greenfield state, where a lane root folder is declared
@@ -1493,6 +1622,10 @@ function Invoke-FeaturePaths {
1493
1622
  $uiDesign = Join-Path $fd 'frontend-plan.md'
1494
1623
  if (-not (Test-Path $design) -and (Test-Path (Join-Path $fd 'design.md'))) { $design = Join-Path $fd 'design.md' }
1495
1624
  if (-not (Test-Path $uiDesign) -and (Test-Path (Join-Path $fd 'design.ui.md'))) { $uiDesign = Join-Path $fd 'design.ui.md' }
1625
+ # The API plan beside the backend plan: the service's published endpoints, a master that
1626
+ # finalize-delta merges each change's API into. A feature planned before it existed has
1627
+ # none, and keeps its endpoints in its backend plan until `api-split` moves them.
1628
+ $apiPlan = Join-Path $fd 'api-plan.md'
1496
1629
  # needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
1497
1630
  # leaves spec.md on disk with the marker still up, and branching on the file alone read
1498
1631
  # that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
@@ -1511,6 +1644,8 @@ function Invoke-FeaturePaths {
1511
1644
  frontend_plan = $uiDesign
1512
1645
  backend_plan_exists = (Test-Path $design)
1513
1646
  frontend_plan_exists = (Test-Path $uiDesign)
1647
+ api_plan = $apiPlan
1648
+ api_plan_exists = (Test-Path $apiPlan)
1514
1649
  design_file = $design
1515
1650
  ui_design_file = $uiDesign
1516
1651
  spec_exists = $specExists
@@ -2602,14 +2737,17 @@ function Invoke-DesignScaffold {
2602
2737
  # backend / frontend are the PLAN-phase pairs: the same templates, the plan document
2603
2738
  # names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the plan
2604
2739
  # name, so a feature never carries two spellings of one master. service / ui stay
2605
- # byte-identical for the callers that still write the legacy names.
2740
+ # byte-identical for the callers that still write the legacy names. api is the backend
2741
+ # lane's API plan, beside its plan — one template for every project, since its endpoint
2742
+ # block form is fixed.
2606
2743
  $legacyName = ''
2607
2744
  switch ($lane) {
2608
2745
  'service' { $docName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
2609
2746
  'ui' { $docName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
2610
2747
  'backend' { $docName = 'backend-plan.md'; $legacyName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
2611
2748
  'frontend' { $docName = 'frontend-plan.md'; $legacyName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
2612
- default { Write-Error ("design-scaffold: unknown --lane '" + $lane + "' (backend|frontend|service|ui)"); exit 1 }
2749
+ 'api' { $docName = 'api-plan.md'; if (-not $tplName) { $tplName = 'api-plan-template' } }
2750
+ default { Write-Error ("design-scaffold: unknown --lane '" + $lane + "' (backend|frontend|api|service|ui)"); exit 1 }
2613
2751
  }
2614
2752
  $repo = Get-RepoRoot
2615
2753
  $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
@@ -2623,7 +2761,15 @@ function Invoke-DesignScaffold {
2623
2761
  if ((-not (Test-Path $target)) -and $legacyName -and (Test-Path (Join-Path $fd $legacyName))) {
2624
2762
  Move-Item (Join-Path $fd $legacyName) $target; $migrated = $true
2625
2763
  }
2626
- if (Test-Path $target) { $created = $false }
2764
+ # An API plan is never scaffolded beside a backend plan that still carries the API: the
2765
+ # empty template would be read in its place and the draft would lose every endpoint.
2766
+ # `api-split` moves them first.
2767
+ $blocked = $false
2768
+ if ($lane -eq 'api' -and -not (Test-Path $target)) {
2769
+ $chk = Get-AwStackJson -Op 'api-split' -Rest @('--feature-dir', $fd, '--check')
2770
+ $blocked = [bool]($chk -and $chk.needed)
2771
+ }
2772
+ if ((Test-Path $target) -or $blocked) { $created = $false }
2627
2773
  else {
2628
2774
  $tpl = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
2629
2775
  if ($tpl -and (Test-Path $tpl)) { Copy-Item $tpl $target } else { New-Item -ItemType File -Force -Path $target | Out-Null }
@@ -2636,7 +2782,7 @@ function Invoke-DesignScaffold {
2636
2782
  $overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
2637
2783
  $templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
2638
2784
  $tsOut = if ($templateSource) { $templateSource } else { '' }
2639
- Write-Json ([ordered]@{ feature_dir = $fd; design_doc = $target; plan_doc = $target; lane = $lane; created = $created; migrated = $migrated; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
2785
+ Write-Json ([ordered]@{ feature_dir = $fd; design_doc = $target; plan_doc = $target; lane = $lane; created = $created; migrated = $migrated; blocked = $blocked; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
2640
2786
  }
2641
2787
 
2642
2788
  # ─────────────────────────── delta-plan-scaffold ───────────────────────────
@@ -2665,7 +2811,9 @@ function Invoke-DeltaPlanScaffold {
2665
2811
  if (-not $tplName) { $tplName = 'delta-design-template' } }
2666
2812
  'frontend' { $docName = 'delta-frontend-plan.md'; $legacyName = 'delta-design.ui.md'
2667
2813
  if (-not $tplName) { $tplName = 'ui-delta-design-template' } }
2668
- default { Write-Error ("delta-plan-scaffold: unknown --lane '" + $lane + "' (backend|frontend)"); exit 1 }
2814
+ 'api' { $docName = 'delta-api-plan.md'; $legacyName = ''
2815
+ if (-not $tplName) { $tplName = 'delta-api-plan-template' } }
2816
+ default { Write-Error ("delta-plan-scaffold: unknown --lane '" + $lane + "' (backend|frontend|api)"); exit 1 }
2669
2817
  }
2670
2818
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2671
2819
  if (-not $fd) { Write-Error 'delta-plan-scaffold: no feature set and no --feature-dir'; exit 1 }
@@ -2673,10 +2821,17 @@ function Invoke-DeltaPlanScaffold {
2673
2821
  if (-not (Test-Path $crDir)) { Write-Error ("delta-plan-scaffold: no such open change '" + $crId + "' (looked in " + $crDir + ")"); exit 1 }
2674
2822
  $target = Join-Path $crDir $docName
2675
2823
  $created = $false; $migrated = $false
2676
- if ((-not (Test-Path $target)) -and (Test-Path (Join-Path $crDir $legacyName))) {
2824
+ if ((-not (Test-Path $target)) -and $legacyName -and (Test-Path (Join-Path $crDir $legacyName))) {
2677
2825
  Move-Item (Join-Path $crDir $legacyName) $target; $migrated = $true
2678
2826
  }
2679
- if (-not (Test-Path $target)) {
2827
+ # A change planned before the delta API plan existed keeps its API in its delta backend
2828
+ # plan, which is read in its place; an empty delta API plan beside it would hide it.
2829
+ $blocked = $false
2830
+ if ($lane -eq 'api' -and -not (Test-Path $target)) {
2831
+ $chk = Get-AwStackJson -Op 'api-split' -Rest @('--feature-dir', $fd, '--cr', $crId)
2832
+ $blocked = [bool]($chk -and $chk.needed)
2833
+ }
2834
+ if ((-not (Test-Path $target)) -and -not $blocked) {
2680
2835
  $tpl = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
2681
2836
  if ($tpl -and (Test-Path $tpl)) { Copy-Item $tpl $target } else { New-Item -ItemType File -Force -Path $target | Out-Null }
2682
2837
  $created = $true
@@ -2686,11 +2841,11 @@ function Invoke-DeltaPlanScaffold {
2686
2841
  # readable wave the template's `[N]` is left standing, which is what the merge and recall
2687
2842
  # fences read as "not ordered yet" and refuse on, with the command that stamps it.
2688
2843
  $scaffoldWave = Get-WaveOfReadable -FeatureDir $fd -CrId $crId -Repo $repo
2689
- if ($scaffoldWave) { Set-HeaderStamp -Path $target -Name 'Wave' -Value $scaffoldWave }
2844
+ if ($scaffoldWave -and (Test-Path $target)) { Set-HeaderStamp -Path $target -Name 'Wave' -Value $scaffoldWave }
2690
2845
  $overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
2691
2846
  $templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
2692
2847
  $tsOut = if ($templateSource) { $templateSource } else { '' }
2693
- Write-Json ([ordered]@{ cr_id = $crId; cr_dir = $crDir; lane = $lane; delta_plan = $target; created = $created; migrated = $migrated; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
2848
+ Write-Json ([ordered]@{ cr_id = $crId; cr_dir = $crDir; lane = $lane; delta_plan = $target; created = $created; migrated = $migrated; blocked = $blocked; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
2694
2849
  }
2695
2850
 
2696
2851
  # Read one field out of the lane model. Twin of the bash inline python readers: the
@@ -3204,6 +3359,29 @@ function Invoke-ModelScaffold {
3204
3359
  })
3205
3360
  }
3206
3361
 
3362
+ # ──────────────────── model-guard-snapshot / model-guard-check ─────────────────────
3363
+ # Bracket a whole-file rewrite of the context's domain-model.md (specify bootstrap, adopt):
3364
+ # the snapshot copies the model aside, the check compares the rewrite with the copy and, on
3365
+ # a loss, restores the model and keeps the rewrite as domain-model.rejected.md. THE RULE
3366
+ # LIVES IN python/aw-model-guard.py, which both shells invoke — see the bash twin. Both
3367
+ # exit 0 with `ok` in the JSON; the workflow's gate owns the stop.
3368
+ function Invoke-ModelGuard {
3369
+ param([string]$Op, [string[]]$Rest)
3370
+ $model = ''
3371
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3372
+ if ($Rest[$i] -in '--model','-Model') { $model = $Rest[++$i] }
3373
+ }
3374
+ if (-not $model) { Write-Error "model-guard-${Op}: --model required"; exit 1 }
3375
+ $repo = Get-RepoRoot
3376
+ if (-not [System.IO.Path]::IsPathRooted($model)) { $model = Join-Path $repo $model }
3377
+ $py = Get-Py
3378
+ if (-not $py) { Write-Error "model-guard-${Op}: python3 is required"; exit 1 }
3379
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-model-guard.py'
3380
+ if (-not (Test-Path -LiteralPath $core)) { Write-Error "model-guard-${Op}: core not found at $core"; exit 1 }
3381
+ & $py $core $Op --model $model --root $repo
3382
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
3383
+ }
3384
+
3207
3385
  # ─────────────────────────── cr-list ───────────────────────────────────────
3208
3386
  # The changes currently OPEN on a feature, in wave order. This is what lets a delta stage
3209
3387
  # ASK which change it is working on instead of inferring one: many may be open at once,
@@ -3404,6 +3582,7 @@ function Invoke-CrPaths {
3404
3582
  $dmodel = Join-Path $ctx "domain-model.$crId.delta.md"
3405
3583
  $be = Join-Path $crDir 'delta-backend-plan.md'
3406
3584
  $fe = Join-Path $crDir 'delta-frontend-plan.md'
3585
+ $api = Join-Path $crDir 'delta-api-plan.md'
3407
3586
  $master = Join-Path $ctx 'domain-model.md'
3408
3587
  $wave = [int](Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
3409
3588
  $recorded = @(Get-ChangeLanes -Path $dspec)
@@ -3445,6 +3624,8 @@ function Invoke-CrPaths {
3445
3624
  delta_model = (ConvertTo-RepoRelative -Path $dmodel -RepoRoot $repo); delta_model_exists = (Test-Path $dmodel)
3446
3625
  delta_backend_plan = (ConvertTo-RepoRelative -Path $be -RepoRoot $repo); delta_backend_plan_exists = (Test-Path $be)
3447
3626
  delta_frontend_plan = (ConvertTo-RepoRelative -Path $fe -RepoRoot $repo); delta_frontend_plan_exists = (Test-Path $fe)
3627
+ # The change's API delta — merged into the feature's api-plan.md by finalize-delta.
3628
+ delta_api_plan = (ConvertTo-RepoRelative -Path $api -RepoRoot $repo); delta_api_plan_exists = (Test-Path $api)
3448
3629
  wave = $wave
3449
3630
  # The lanes this change touches, from its delta-spec's **Lanes** line — every lane
3450
3631
  # when it records none (lanes_recorded false).
@@ -4348,13 +4529,26 @@ function Invoke-CrSnapshot {
4348
4529
  if (-not (Test-Path -LiteralPath $snap)) { New-Item -ItemType Directory -Path $snap -Force | Out-Null }
4349
4530
  $spec = Join-Path $fd 'spec.md'
4350
4531
  $model = Join-Path $ctx 'domain-model.md'
4351
- # Flat names, not the source tree's shape: there are exactly two masters and they live
4352
- # at different levels, so a nested copy would encode the layout of the day it was taken.
4532
+ # Every document finalize-delta may rewrite: the two masters it merges into, the API plan
4533
+ # it merges the change's API into, the backend plan the first such merge moves the
4534
+ # endpoints out of, and the API draft it redraws.
4535
+ $backend = Join-Path $fd 'backend-plan.md'
4536
+ if (-not (Test-Path -LiteralPath $backend) -and (Test-Path -LiteralPath (Join-Path $fd 'design.md'))) {
4537
+ $backend = Join-Path $fd 'design.md'
4538
+ }
4539
+ $docs = @(@($spec, 'spec.md'), @($model, 'domain-model.md'),
4540
+ @((Join-Path $fd 'api-plan.md'), 'api-plan.md'),
4541
+ @($backend, (Split-Path -Leaf $backend)),
4542
+ @((Join-Path $fd 'api-draft.json'), 'api-draft.json'))
4543
+ # Flat names, not the source tree's shape: the documents live at different levels, so a
4544
+ # nested copy would encode the layout of the day it was taken.
4353
4545
  [string[]]$copied = @()
4354
- foreach ($pair in @(@($spec, 'spec.md'), @($model, 'domain-model.md'))) {
4546
+ $sources = [ordered]@{}
4547
+ foreach ($pair in $docs) {
4355
4548
  if (Test-Path -LiteralPath $pair[0] -PathType Leaf) {
4356
4549
  Copy-Item -LiteralPath $pair[0] -Destination (Join-Path $snap $pair[1]) -Force
4357
4550
  $copied += $pair[1]
4551
+ $sources[$pair[1]] = (ConvertTo-RepoRelative -Path $pair[0] -RepoRoot $repo)
4358
4552
  }
4359
4553
  }
4360
4554
 
@@ -4370,10 +4564,7 @@ function Invoke-CrSnapshot {
4370
4564
  version = $version
4371
4565
  context_version = (Get-MdHeader -Path $model -Name 'Context version')
4372
4566
  files = @($copied)
4373
- sources = [ordered]@{
4374
- 'spec.md' = (ConvertTo-RepoRelative -Path $spec -RepoRoot $repo)
4375
- 'domain-model.md' = (ConvertTo-RepoRelative -Path $model -RepoRoot $repo)
4376
- }
4567
+ sources = $sources
4377
4568
  # Stated in the file itself, because the restore is the reader's whole reason for
4378
4569
  # opening it and the condition on it is not obvious from the content.
4379
4570
  note = ("Masters as they stood immediately before finalize-delta merged $crId. " +
@@ -4772,6 +4963,31 @@ function Invoke-PlanCoverage {
4772
4963
  if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4773
4964
  }
4774
4965
 
4966
+ # Which of a lane plan's requirement ids no test of the slice names. Same core as
4967
+ # plan-coverage (python/aw-plan-coverage.py --citations), called identically by both
4968
+ # shells — see the bash twin.
4969
+ function Invoke-TestCitations {
4970
+ param([string[]]$Rest)
4971
+ $py = Get-Py
4972
+ if (-not $py) { Write-Error 'test-citations: python3 is required'; exit 1 }
4973
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-plan-coverage.py'
4974
+ if (-not (Test-Path $core)) { Write-Error "test-citations: core not found at $core"; exit 1 }
4975
+ # --flag=value: a lane with no test path or a feature with no model passes an empty
4976
+ # value, and PowerShell 5.1 drops an empty argument.
4977
+ $fwd = @()
4978
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
4979
+ $a = "$($Rest[$i])"
4980
+ if ($a -in '--plan','--kind','--mode','--paths','--source-path','--test-path','--spec','--model','--known-gaps') {
4981
+ $v = ''
4982
+ if ($i + 1 -lt $Rest.Count -and -not "$($Rest[$i + 1])".StartsWith('--')) { $v = $Rest[++$i] }
4983
+ $fwd += ($a + '=' + $v)
4984
+ } else { $fwd += $a }
4985
+ }
4986
+ $repo = Get-RepoRoot
4987
+ & $py $core '--repo' $repo '--citations' @fwd
4988
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4989
+ }
4990
+
4775
4991
  # Stamp a version into the MASTER documents' `**Version**:` headers — the
4776
4992
  # deterministic twin of cr-archive's active_version.json bump. close-delta calls
4777
4993
  # this with the version cr-archive just computed, so all THREE carriers of the
@@ -4805,8 +5021,9 @@ function Invoke-VersionStamp {
4805
5021
  # spine cannot tolerate. A lane the feature does not have reports `missing` and nothing
4806
5022
  # fails, which is also how the legacy names are carried — a feature not yet migrated to
4807
5023
  # the plan names still gets stamped, and one that has been migrated reports the legacy
4808
- # pair missing.
4809
- foreach ($name in @('spec.md', 'backend-plan.md', 'frontend-plan.md', 'design.md', 'design.ui.md')) {
5024
+ # pair missing. The API plan is a master too, and moves with them.
5025
+ foreach ($name in @('spec.md', 'backend-plan.md', 'frontend-plan.md', 'design.md', 'design.ui.md',
5026
+ 'api-plan.md')) {
4810
5027
  $p = Join-Path $fd $name
4811
5028
  if (-not (Test-Path $p)) { $missing += $name; continue }
4812
5029
  $src = Get-Content -Raw -Encoding UTF8 $p
@@ -4823,11 +5040,14 @@ function Invoke-VersionStamp {
4823
5040
  # found by its CR cell, not by position: rows are appended newest-last and a feature may
4824
5041
  # carry many, so "the last row" would be right only until two changes were open at once.
4825
5042
  # Only a cell still reading '(pending)' is written — a row already carrying a version
4826
- # belongs to a change that closed, and re-stamping it would rewrite history.
5043
+ # belongs to a change that closed, and re-stamping it would rewrite history. The API
5044
+ # plan's row, which finalize-delta's API merge wrote, is completed the same way.
4827
5045
  $rowFilled = $false; $rowWas = ''
5046
+ [string[]]$rowsFilled = @()
4828
5047
  if ($crId) {
4829
- $p = Join-Path $fd 'spec.md'
4830
- if (Test-Path -LiteralPath $p) {
5048
+ foreach ($name in @('spec.md', 'api-plan.md')) {
5049
+ $p = Join-Path $fd $name
5050
+ if (-not (Test-Path -LiteralPath $p)) { continue }
4831
5051
  $lines = [System.IO.File]::ReadAllLines($p)
4832
5052
  $changed = $false
4833
5053
  for ($i = 0; $i -lt $lines.Count; $i++) {
@@ -4837,7 +5057,7 @@ function Invoke-VersionStamp {
4837
5057
  $cells = $line -split '\|'
4838
5058
  if ($cells.Count -gt 2 -and
4839
5059
  @('(pending)','pending','—','-','') -contains $cells[1].Trim().ToLower()) {
4840
- $rowWas = $cells[1].Trim()
5060
+ if ($name -eq 'spec.md') { $rowWas = $cells[1].Trim() }
4841
5061
  $cells[1] = " $ver "
4842
5062
  $lines[$i] = ($cells -join '|')
4843
5063
  $changed = $true
@@ -4846,7 +5066,8 @@ function Invoke-VersionStamp {
4846
5066
  }
4847
5067
  if ($changed) {
4848
5068
  [System.IO.File]::WriteAllLines($p, $lines)
4849
- $rowFilled = $true
5069
+ $rowsFilled += $name
5070
+ if ($name -eq 'spec.md') { $rowFilled = $true }
4850
5071
  }
4851
5072
  }
4852
5073
  }
@@ -4856,7 +5077,10 @@ function Invoke-VersionStamp {
4856
5077
  # Whether the merge's pending row was completed. False with a
4857
5078
  # --cr-id is the ordinary outcome for a re-run (already filled)
4858
5079
  # and for a merge predating the pending convention.
4859
- cr_id = $crId; history_row_filled = $rowFilled; history_row_was = $rowWas })
5080
+ cr_id = $crId; history_row_filled = $rowFilled; history_row_was = $rowWas
5081
+ # Every document whose pending row was completed: spec.md, and the
5082
+ # API plan when the change merged an API delta into it.
5083
+ history_rows_filled = @($rowsFilled) })
4860
5084
  }
4861
5085
 
4862
5086
  # ─────────────────────────────── main ──────────────────────────────────────
@@ -4874,6 +5098,7 @@ switch ($sub) {
4874
5098
  'set-domain' { Invoke-SetDomain -Rest $rest }
4875
5099
  'stack-list' { Invoke-StackList -Rest $rest }
4876
5100
  'stack-detect' { Invoke-StackDetect -Rest $rest }
5101
+ 'stack-rules' { Invoke-StackRules -Rest $rest }
4877
5102
  'lane-declare' { Invoke-LaneDeclare -Rest $rest }
4878
5103
  'lane-resolve' { Invoke-LaneResolve -Rest $rest }
4879
5104
  'lane-verify' { Invoke-LaneVerify -Rest $rest }
@@ -4887,6 +5112,9 @@ switch ($sub) {
4887
5112
  'contract-client' { Invoke-ContractClient -Rest $rest }
4888
5113
  'contract-check' { Invoke-ContractCheck -Rest $rest }
4889
5114
  'contract-impact' { Invoke-ContractImpact -Rest $rest }
5115
+ 'contract-verify' { Invoke-ContractVerify -Rest $rest }
5116
+ 'contract-merge' { Invoke-ContractMerge -Rest $rest }
5117
+ 'api-split' { Invoke-ApiSplit -Rest $rest }
4890
5118
  'list-contexts' { Invoke-ListContexts -Rest $rest }
4891
5119
  'set-context' { Invoke-SetContext -Rest $rest }
4892
5120
  'list-features' { Invoke-ListFeatures -Rest $rest }
@@ -4923,11 +5151,14 @@ switch ($sub) {
4923
5151
  'context-version-bump' { Invoke-ContextVersionBump -Rest $rest }
4924
5152
  'context-paths' { Invoke-ContextPaths -Rest $rest }
4925
5153
  'model-scaffold' { Invoke-ModelScaffold -Rest $rest }
5154
+ 'model-guard-snapshot' { Invoke-ModelGuard -Op 'snapshot' -Rest $rest }
5155
+ 'model-guard-check' { Invoke-ModelGuard -Op 'check' -Rest $rest }
4926
5156
  'version-stamp' { Invoke-VersionStamp -Rest $rest }
4927
5157
  'plan-gaps' { Invoke-PlanGaps -Rest $rest }
4928
5158
  'plan-gate-stamp' { Invoke-PlanGateStamp -Rest $rest }
4929
5159
  'plan-gate-check' { Invoke-PlanGateCheck -Rest $rest }
4930
5160
  'plan-coverage' { Invoke-PlanCoverage -Rest $rest }
5161
+ 'test-citations' { Invoke-TestCitations -Rest $rest }
4931
5162
  'gap-route' { Invoke-GapRoute -Rest $rest }
4932
5163
  'checklist-read' { Invoke-ChecklistRead -Rest $rest }
4933
5164
  'checklist-apply' { Invoke-ChecklistApply -Rest $rest }