aw-spec-cli 0.1.21 → 0.1.23

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 (51) hide show
  1. package/assets/.appweaver/STACK-PROFILES.md +87 -8
  2. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
  3. package/assets/.appweaver/orchestrator/orchestrator.py +47 -3
  4. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +41 -8
  5. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +62 -2
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +18 -5
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +1 -1
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +4 -1
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +115 -27
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +5 -1
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +225 -27
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +4 -15
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1 -1
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +3 -1
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +104 -1
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +159 -0
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +153 -0
  21. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +203 -212
  22. package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
  23. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +191 -224
  24. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
  25. package/assets/.appweaver/scripts/python/aw-crstate.py +127 -2
  26. package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
  27. package/assets/.appweaver/scripts/python/aw-payload-parity.py +17 -1
  28. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +84 -4
  29. package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
  30. package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
  31. package/assets/.appweaver/scripts/python/aw-stack.py +6 -0
  32. package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
  33. package/assets/.appweaver/stack-profiles/angular.json +26 -0
  34. package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
  35. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +38 -0
  36. package/assets/.appweaver/stack-profiles/java-spring.json +19 -0
  37. package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
  38. package/assets/.appweaver/stack-profiles/node-express.json +26 -0
  39. package/assets/.appweaver/stack-profiles/python-fastapi.json +15 -0
  40. package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
  41. package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
  42. package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
  43. package/assets/.appweaver/templates/ui-model-template.md +67 -0
  44. package/assets/.appweaver/templates/workflow-model-template.md +62 -0
  45. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +3 -3
  46. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +5 -123
  47. package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +784 -0
  48. package/assets/foundations/java/Hexagonal/pack-foundation.sh +357 -0
  49. package/assets/foundations/java/Hexagonal/unpack-foundation.sh +399 -0
  50. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +44 -0
  51. package/package.json +1 -1
@@ -9,7 +9,10 @@
9
9
  # aw-xref.sh list --aggregate Y [--root DIR] [--bundle knowledge]
10
10
  # aw-xref.sh concept-upsert --aggregate X [--source-files a,b,c] [--description D] [--origin ...] [--root DIR]
11
11
  # aw-xref.sh concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR]
12
- # aw-xref.sh concept-paths --aggregate X | --feature-dir <dir> [--root DIR] [--bundle knowledge]
12
+ # aw-xref.sh concept-paths --aggregate X | --feature-dir <dir> [--under <subtree>]...
13
+ # [--root DIR] [--bundle knowledge]
14
+ # (--under keeps only paths inside that subtree, so a caller
15
+ # generating ONE lane is not handed another lane's files)
13
16
  # aw-xref.sh feature-aggregates --feature-dir <dir> [--root DIR]
14
17
  # (the aggregate(s) a feature acts on: its spec's Acts-on line,
15
18
  # then the boundary map, then the feature name itself)
@@ -1230,6 +1230,14 @@ function Invoke-FeaturePaths {
1230
1230
  if (-not $fd) { Write-Error 'feature-paths: no feature set (run /appweaver select-feature first)'; exit 1 }
1231
1231
  if (-not [System.IO.Path]::IsPathRooted($fd)) { $fd = Join-Path $repo $fd }
1232
1232
  $feature = Split-Path $fd -Leaf # the aggregate name (feature = specs/<Feature>)
1233
+ # feature_slug is the URL-safe form of that name, and it is what the UI stages use
1234
+ # as a route segment and a directory name (app/<slug>/page.tsx). It is derived here
1235
+ # rather than per workflow so every reader gets the same segment for one feature.
1236
+ # CamelCase splits on the case boundary before the non-alphanumeric squeeze, so
1237
+ # OrderManagement and "Order Management" both reach order-management.
1238
+ $featureSlug = $feature -creplace '([a-z0-9])([A-Z])', '$1-$2'
1239
+ $featureSlug = $featureSlug -creplace '([A-Z]+)([A-Z][a-z])', '$1-$2'
1240
+ $featureSlug = ($featureSlug.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-')
1233
1241
  # The master documents of a feature: spec.md (WHAT/WHY) + one LANE PLAN per declared
1234
1242
  # lane (each lane's HOW) — backend-plan.md for the service lane, frontend-plan.md for
1235
1243
  # the frontend lane. `plan` writes them; nothing else does.
@@ -1264,6 +1272,7 @@ function Invoke-FeaturePaths {
1264
1272
  $specExists = (Test-Path $spec)
1265
1273
  Write-Json ([ordered]@{
1266
1274
  feature = $feature
1275
+ feature_slug = $featureSlug
1267
1276
  feature_dir = $fd
1268
1277
  spec_file = $spec
1269
1278
  backend_plan = $design
@@ -1946,6 +1955,21 @@ function Get-LabeledVersion {
1946
1955
  }
1947
1956
  if ($vals.Count -gt 0) { return $vals[-1] } else { return $null }
1948
1957
  }
1958
+
1959
+ # The ratified NON-NEGOTIABLE principles. A thin wrapper: the scan lives in
1960
+ # python/aw-principles.py, which BOTH shells invoke, so the list a gate cites cannot
1961
+ # differ between them. Defaults to the two halves specify loads; --file overrides.
1962
+ function Invoke-PrinciplesList {
1963
+ param([string[]]$Rest)
1964
+ $repo = Get-RepoRoot
1965
+ $py = Get-Py
1966
+ if (-not $py) { Write-Error 'principles-list: python3 is required'; exit 1 }
1967
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-principles.py'
1968
+ if (-not (Test-Path $core)) { Write-Error "principles-list: core not found at $core"; exit 1 }
1969
+ & $py $core list --root $repo @Rest
1970
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
1971
+ }
1972
+
1949
1973
  function Invoke-ConstitutionVerify {
1950
1974
  param([string[]]$Rest)
1951
1975
  $file = ''; $template = ''
@@ -2332,7 +2356,12 @@ function Invoke-DeltaPlanScaffold {
2332
2356
  if ($tpl -and (Test-Path $tpl)) { Copy-Item $tpl $target } else { New-Item -ItemType File -Force -Path $target | Out-Null }
2333
2357
  $created = $true
2334
2358
  }
2335
- Set-HeaderStamp -Path $target -Name 'Wave' -Value (Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
2359
+ # The lane plan carries the change's wave so a reader of the plan alone can see where it
2360
+ # sits. It is COPIED from the delta-spec, never decided here — so when the spec has no
2361
+ # readable wave the template's `[N]` is left standing, which is what the merge and recall
2362
+ # fences read as "not ordered yet" and refuse on, with the command that stamps it.
2363
+ $scaffoldWave = Get-WaveOfReadable -FeatureDir $fd -CrId $crId -Repo $repo
2364
+ if ($scaffoldWave) { Set-HeaderStamp -Path $target -Name 'Wave' -Value $scaffoldWave }
2336
2365
  $overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
2337
2366
  $templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
2338
2367
  $tsOut = if ($templateSource) { $templateSource } else { '' }
@@ -2418,7 +2447,8 @@ function Invoke-TestRun {
2418
2447
  switch ($Rest[$i]) {
2419
2448
  { $_ -in '--feature-dir','-FeatureDir' } { $featureDir = $Rest[++$i] }
2420
2449
  { $_ -in '--scope','-Scope' } { $scope = $Rest[++$i] }
2421
- { $_ -in '--also','-Also' } { $also = $Rest[++$i] }
2450
+ # Repeatable, lists UNION — see the bash twin for why the caller passes two.
2451
+ { $_ -in '--also','-Also' } { $v = $Rest[++$i]; $also = if ($also) { "$also,$v" } else { $v } }
2422
2452
  }
2423
2453
  }
2424
2454
  $repo = Get-RepoRoot
@@ -2714,16 +2744,33 @@ function Get-CrIdToken {
2714
2744
  }
2715
2745
 
2716
2746
  # One change's wave, read from the documents that carry it: the delta-spec first, then the
2717
- # model delta. Both are stamped together, so either answers; 1 is the floor for a change
2718
- # opened before waves existed. Twin of bash _wave_of.
2719
- function Get-WaveOf {
2747
+ # model delta. Both are stamped together, so either answers. EMPTY when neither carries a
2748
+ # readable one — `**Wave**: [N]` is the unfilled template line, so "no wave" is a state that
2749
+ # happens, and a caller who WRITES the answer somewhere must not write a number nobody
2750
+ # derived. A phantom wave is worse than none: the merge and recall fences can see that a
2751
+ # wave is missing and say so, but a 1 invented downstream reads to them as fact.
2752
+ # Twin of bash _wave_of_readable.
2753
+ function Get-WaveOfReadable {
2720
2754
  param([string]$FeatureDir, [string]$CrId, [string]$Repo)
2721
2755
  $ctx = Get-ContextDirForFeature -FeatureDir $FeatureDir -Repo $Repo
2722
2756
  foreach ($f in @((Join-Path $FeatureDir "changes/$CrId/delta-spec.md"), (Join-Path $ctx "domain-model.$CrId.delta.md"))) {
2723
2757
  if (-not (Test-Path $f)) { continue }
2724
2758
  $t = Get-Content $f -Raw -ErrorAction SilentlyContinue
2725
- if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { return "$([int]$Matches[1])" }
2759
+ if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') {
2760
+ $w = [int]$Matches[1]
2761
+ if ($w -ge 1) { return "$w" }
2762
+ }
2726
2763
  }
2764
+ return ''
2765
+ }
2766
+
2767
+ # The same reading with the floor applied, for the readers that must show a number — a
2768
+ # listing has a column to fill and 1 is the floor for a change opened before waves existed.
2769
+ # Twin of bash _wave_of.
2770
+ function Get-WaveOf {
2771
+ param([string]$FeatureDir, [string]$CrId, [string]$Repo)
2772
+ $w = Get-WaveOfReadable -FeatureDir $FeatureDir -CrId $CrId -Repo $Repo
2773
+ if ($w) { return $w }
2727
2774
  return '1'
2728
2775
  }
2729
2776
 
@@ -3253,8 +3300,12 @@ required = max([d["wave"] for d in known], default=0) + 1
3253
3300
  current = wave_of(cr)
3254
3301
  ok = current is not None and current >= required
3255
3302
 
3303
+ # A change with NO readable wave is stamped too, not only one sitting too low. The merge
3304
+ # and recall fences refuse on an unordered change and send the operator here, so the one
3305
+ # command they are given has to be able to write the first wave as well as correct a wrong
3306
+ # one — a remedy that declines the case it was named for is no remedy.
3256
3307
  stamped = []
3257
- if stamp and current is not None and current < required:
3308
+ if stamp and (current is None or current < required):
3258
3309
  line = "**Wave**: %d" % required
3259
3310
  pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
3260
3311
  for p in present:
@@ -3265,13 +3316,16 @@ if stamp and current is not None and current < required:
3265
3316
  if stamped:
3266
3317
  current, ok = required, True
3267
3318
 
3268
- if not deps:
3269
- msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
3270
- cr, required, current if current is not None else "no wave")
3271
- elif stamped:
3319
+ if stamped and deps:
3272
3320
  msg = "%s depends on %s, so it was raised to wave %d in %s" % (
3273
3321
  cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
3274
3322
  required, ", ".join(stamped))
3323
+ elif stamped:
3324
+ msg = "%s declares no predecessor, so it was stamped at wave %d — the floor — in %s" % (
3325
+ cr, required, ", ".join(stamped))
3326
+ elif not deps:
3327
+ msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
3328
+ cr, required, current if current is not None else "no wave")
3275
3329
  elif ok:
3276
3330
  msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
3277
3331
  cr, current, required)
@@ -3917,13 +3971,6 @@ function Invoke-CrMergeCheck {
3917
3971
  if (-not $fd) { Write-Error 'cr-merge-check: no feature set and no --feature-dir'; exit 1 }
3918
3972
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3919
3973
 
3920
- function Get-WaveFile([string]$path) {
3921
- if (Test-Path -LiteralPath $path) {
3922
- $m = [regex]::Match((Get-Content -LiteralPath $path -Raw), '(?m)^\*\*Wave\*\*:[ \t]*(\d+)')
3923
- if ($m.Success) { return [int]$m.Groups[1].Value }
3924
- }
3925
- return 1
3926
- }
3927
3974
  function Format-Ver([string]$v) {
3928
3975
  $v = "$v".Trim()
3929
3976
  if (-not $v) { return '' }
@@ -3931,7 +3978,6 @@ function Invoke-CrMergeCheck {
3931
3978
  }
3932
3979
 
3933
3980
  $mySpec = Join-Path $fd "changes/$crId/delta-spec.md"
3934
- $wave = Get-WaveFile $mySpec
3935
3981
  $baseVersion = Format-Ver (Get-MdHeader -Path $mySpec -Name 'Base version')
3936
3982
 
3937
3983
  # The master's CURRENT version, from the ledger — the same authority version-read
@@ -3947,63 +3993,59 @@ function Invoke-CrMergeCheck {
3947
3993
  # is absence of evidence and is not treated as a move.
3948
3994
  $rebaseNeeded = [bool]($baseVersion -and $current -and ($baseVersion -ne $current))
3949
3995
 
3950
- # EVERY open change in the context, both halves. changes/<id>/ under each feature is the
3951
- # authority for "open"; the pending model deltas are unioned in so a model-only artifact
3952
- # of a half-built change is still seen.
3953
- $opens = [ordered]@{}
3954
- if (Test-Path -LiteralPath $ctx) {
3955
- foreach ($feat in (Get-ChildItem -LiteralPath $ctx -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
3956
- $ch = Join-Path $feat.FullName 'changes'
3957
- if (-not (Test-Path -LiteralPath $ch)) { continue }
3958
- foreach ($d in (Get-ChildItem -LiteralPath $ch -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
3959
- if ($d.Name -eq 'archive') { continue }
3960
- if (-not $opens.Contains($d.Name)) {
3961
- $opens[$d.Name] = [ordered]@{ cr_id = $d.Name
3962
- wave = (Get-WaveFile (Join-Path $d.FullName 'delta-spec.md'))
3963
- feature = $feat.Name }
3964
- }
3965
- }
3966
- }
3967
- foreach ($f in (Get-ChildItem -LiteralPath $ctx -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
3968
- $m = [regex]::Match($f.Name, '^domain-model\.(.+)\.delta\.md$')
3969
- if (-not $m.Success) { continue }
3970
- $other = $m.Groups[1].Value
3971
- if (-not $opens.Contains($other)) {
3972
- $opens[$other] = [ordered]@{ cr_id = $other; wave = (Get-WaveFile $f.FullName); feature = '' }
3973
- }
3974
- }
3975
- }
3976
- $blockers = @(foreach ($k in ($opens.Keys | Sort-Object)) {
3977
- if ($k -ne $crId -and $opens[$k].wave -lt $wave) { $opens[$k] }
3978
- })
3979
-
3980
- # THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
3981
- # bumps, so between the two spec.md carries a change the number does not yet reflect —
3982
- # and a second delta sharing the same base_version would compare equal and merge onto
3983
- # masters that had already shifted under it. An id appearing in spec.md is the on-disk
3984
- # proof that the merge ran (the Change History row finalize-delta appends), the same
3985
- # evidence cr-recall-check reads for the same question.
3986
- # aw-crstate owns the test, for both shells and all three readers of it: a change is
3987
- # MERGED when its id is the CR cell of a Change History row — in this feature's spec or
3988
- # in the context model every feature shares, so a SIBLING feature's merged-unclosed
3989
- # change is no longer invisible. A sibling blocks only where it touched an aggregate
3990
- # this delta also names. Fails CLOSED: an unreadable helper reports blocking.
3991
- $mergedOpen = @()
3992
- $pyMerged = Get-Py
3996
+ # BOTH CONTEXT-WIDE QUESTIONS COME FROM aw-crstate, the same module the bash payload
3997
+ # loads, so the two shells cannot answer them differently — which they did while each
3998
+ # carried its own copy of the scan.
3999
+ #
4000
+ # PREDECESSORS. Every open change in the context, both halves: changes/<id>/ under each
4001
+ # feature is the authority for "open", and the pending model deltas are unioned in so a
4002
+ # model-only artifact of a half-built change is still seen. A change whose `**Wave**:`
4003
+ # is unreadable is reported as unordered rather than assumed to be wave 1.
4004
+ #
4005
+ # MERGED-OPEN. The masters can move without the version moving: finalize-delta merges
4006
+ # and close-delta bumps, so between the two spec.md carries a change the number does not
4007
+ # yet reflect, and a second delta sharing the same base_version would compare equal and
4008
+ # merge onto masters that had already shifted under it. A change is MERGED when its id
4009
+ # is the CR cell of a Change History row — in this feature's spec or in the context
4010
+ # model every feature shares, so a SIBLING feature's merged-unclosed change is no longer
4011
+ # invisible. A sibling blocks only where it touched an aggregate this delta also names.
4012
+ #
4013
+ # Fails CLOSED: an unreadable helper reports blocking and nothing ordered.
4014
+ $py = Get-Py
3993
4015
  $crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
3994
- if ($pyMerged -and (Test-Path $crstate)) {
4016
+ # `answered` is what says the reader ran, not the shape of what it returned: NO blocking
4017
+ # merged-open change is an empty array, which is exactly what a failed read looks like.
4018
+ $order = $null; $mergedOpen = @(); $mergedText = ''; $answered = $false
4019
+ $why = 'python or aw-crstate.py not found'
4020
+ if ($py -and (Test-Path $crstate)) {
3995
4021
  try {
3996
- $line = (& $pyMerged $crstate 'merged-open' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx | Select-Object -First 1)
3997
- if ($line) { $mergedOpen = @(($line | ConvertFrom-Json).blocking) }
4022
+ $line = (& $py $crstate 'wave-order' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx '--direction' 'lower' '--scope' 'context' | Select-Object -First 1)
4023
+ if ($line) { $order = ($line | ConvertFrom-Json) }
4024
+ $line = (& $py $crstate 'merged-open' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx | Select-Object -First 1)
4025
+ if ($line) {
4026
+ $parsed = ($line | ConvertFrom-Json)
4027
+ $mergedOpen = @($parsed.blocking); $mergedText = "$($parsed.blocking_text)"; $answered = $true
4028
+ }
3998
4029
  } catch {
3999
- $mergedOpen = @([pscustomobject]@{ cr_id = '(merged-open check failed)'; blocking = $true })
4030
+ $order = $null; $answered = $false; $why = "$_"
4000
4031
  }
4001
- } else {
4002
- $mergedOpen = @([pscustomobject]@{ cr_id = '(merged-open check unavailable)'; blocking = $true })
4003
4032
  }
4033
+ if (-not $order) {
4034
+ $order = [pscustomobject]@{ wave = $null; wave_known = $false; related = @(); related_count = 0
4035
+ related_ids = ''; unknown = @(); unknown_count = 0; unknown_ids = ''
4036
+ unknown_blocks = $true; wave_ordering_ok = $false
4037
+ wave_problem = "the wave reader could not be run ($why), so no open change in this context is ordered against $crId at all" }
4038
+ }
4039
+ if (-not $answered) {
4040
+ $mergedOpen = @([pscustomobject]@{ cr_id = "(merged-open check failed: $why)"; blocking = $true })
4041
+ $mergedText = $mergedOpen[0].cr_id
4042
+ }
4043
+ $blockers = @($order.related)
4004
4044
 
4005
4045
  Write-Json ([ordered]@{
4006
- ok = $true; cr_id = $crId; wave = $wave
4046
+ ok = $true; cr_id = $crId
4047
+ wave = $(if ($null -ne $order.wave) { $order.wave } else { '' })
4048
+ wave_known = [bool]$order.wave_known
4007
4049
  exists = (Test-Path -LiteralPath (Join-Path $fd "changes/$crId"))
4008
4050
  # REBASE half — the masters moved because a change CLOSED and bumped the number.
4009
4051
  base_version = $baseVersion; current_version = $current
@@ -4011,14 +4053,19 @@ function Invoke-CrMergeCheck {
4011
4053
  base_stamped = [bool]$baseVersion
4012
4054
  blockers = @($blockers); blocker_count = @($blockers).Count
4013
4055
  has_blockers = (@($blockers).Count -gt 0)
4014
- blocker_ids = (@($blockers | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
4056
+ blocker_ids = $order.related_ids
4057
+ # The changes this one cannot be ordered against at all, in either direction.
4058
+ unordered = @($order.unknown); unordered_count = $order.unknown_count
4059
+ unordered_ids = $order.unknown_ids
4060
+ wave_ordering_ok = [bool]$order.wave_ordering_ok
4061
+ wave_problem = $order.wave_problem
4015
4062
  # The other way the masters move: a change MERGED and has not closed yet.
4016
4063
  merged_open = @($mergedOpen); merged_open_count = @($mergedOpen).Count
4017
4064
  has_merged_open = (@($mergedOpen).Count -gt 0)
4018
- merged_open_ids = (@($mergedOpen | ForEach-Object { $_.cr_id }) -join ', ')
4019
- # All three refusals as ONE positive flag, because the engine takes no compound
4020
- # condition and a gate reading three booleans would need three passes over one question.
4021
- merge_ok = ((-not $rebaseNeeded) -and (@($blockers).Count -eq 0) -and (@($mergedOpen).Count -eq 0))
4065
+ merged_open_ids = $mergedText
4066
+ # All four refusals as ONE positive flag, because the engine takes no compound
4067
+ # condition and a gate reading four booleans would need four passes over one question.
4068
+ merge_ok = ((-not $rebaseNeeded) -and [bool]$order.wave_ordering_ok -and (@($blockers).Count -eq 0) -and (@($mergedOpen).Count -eq 0))
4022
4069
  })
4023
4070
  }
4024
4071
 
@@ -4039,53 +4086,57 @@ function Invoke-CrRecallCheck {
4039
4086
  $changes = Join-Path $fd 'changes'
4040
4087
  $crDir = Join-Path $changes $crId
4041
4088
 
4042
- function Get-WaveOf([string]$dir) {
4043
- $p = Join-Path $dir 'delta-spec.md'
4044
- if (Test-Path $p) {
4045
- $m = [regex]::Match((Get-Content $p -Raw), '(?m)^\*\*Wave\*\*:[ \t]*(\d+)')
4046
- if ($m.Success) { return [int]$m.Groups[1].Value }
4047
- }
4048
- return 1
4049
- }
4050
-
4051
4089
  # MERGED = a Change History row whose CR cell IS this change, in this feature's spec or
4052
4090
  # in the shared context model — aw-crstate owns that test for all three readers of it.
4053
4091
  # Fails CLOSED: an unreadable helper reports merged, never "never merged", which is the
4054
4092
  # answer that would let close-delta archive a change nobody merged.
4093
+ #
4094
+ # DEPENDANTS come from the same module the merge fence reads in the opposite direction.
4095
+ # An unreadable `**Wave**:` is not wave 1 here either: it used to mean the change was
4096
+ # not counted as higher, so recall withdrew the ground from under a dependant it could
4097
+ # not see. Waves have no ceiling, so any unordered change could sit above this one.
4055
4098
  $spec = Join-Path $fd 'spec.md'
4056
4099
  $merged = $true; $evidence = '(merged check unavailable — treated as merged)'
4057
- $pyMerged = Get-Py
4100
+ $py = Get-Py
4058
4101
  $crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
4059
- if ($pyMerged -and (Test-Path $crstate)) {
4102
+ $order = $null; $why = 'python or aw-crstate.py not found'
4103
+ if ($py -and (Test-Path $crstate)) {
4060
4104
  try {
4061
- $line = (& $pyMerged $crstate 'merged' '--cr' $crId '--doc' $spec '--doc' (Join-Path $ctx 'domain-model.md') | Select-Object -First 1)
4105
+ $line = (& $py $crstate 'merged' '--cr' $crId '--doc' $spec '--doc' (Join-Path $ctx 'domain-model.md') | Select-Object -First 1)
4062
4106
  if ($line) {
4063
4107
  $h = $line | ConvertFrom-Json
4064
4108
  $merged = [bool]$h.merged; $evidence = [string]$h.evidence
4065
4109
  }
4066
- } catch { }
4110
+ $line = (& $py $crstate 'wave-order' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx '--direction' 'higher' '--scope' 'feature' | Select-Object -First 1)
4111
+ if ($line) { $order = ($line | ConvertFrom-Json) }
4112
+ } catch { $order = $null; $why = "$_" }
4067
4113
  }
4068
-
4069
- $wave = if (Test-Path $crDir) { Get-WaveOf $crDir } else { 1 }
4070
- $dependants = @()
4071
- if (Test-Path $changes) {
4072
- foreach ($d in (Get-ChildItem -LiteralPath $changes -Directory | Sort-Object Name)) {
4073
- if ($d.Name -eq $crId -or $d.Name -eq 'archive') { continue }
4074
- $w = Get-WaveOf $d.FullName
4075
- if ($w -gt $wave) { $dependants += [ordered]@{ cr_id = $d.Name; wave = $w } }
4076
- }
4114
+ if (-not $order) {
4115
+ $order = [pscustomobject]@{ wave = $null; wave_known = $false; related = @(); related_count = 0
4116
+ related_ids = ''; unknown = @(); unknown_count = 0; unknown_ids = ''
4117
+ unknown_blocks = $true; wave_ordering_ok = $false
4118
+ wave_problem = "the wave reader could not be run ($why), so no open change in this feature is ordered against $crId at all" }
4077
4119
  }
4120
+ $dependants = @($order.related)
4121
+
4078
4122
  Write-Json ([ordered]@{
4079
- ok = $true
4080
- cr_id = $crId
4081
- exists = (Test-Path $crDir)
4082
- wave = $wave
4083
- merged = $merged
4084
- merge_evidence = $evidence
4085
- dependants = @($dependants)
4086
- dependant_ids = (@($dependants | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
4087
- dependant_count = @($dependants).Count
4088
- has_dependants = (@($dependants).Count -gt 0)
4123
+ ok = $true
4124
+ cr_id = $crId
4125
+ exists = (Test-Path $crDir)
4126
+ wave = $(if ($null -ne $order.wave) { $order.wave } else { '' })
4127
+ wave_known = [bool]$order.wave_known
4128
+ merged = $merged
4129
+ merge_evidence = $evidence
4130
+ dependants = @($dependants)
4131
+ dependant_ids = $order.related_ids
4132
+ dependant_count = @($dependants).Count
4133
+ has_dependants = (@($dependants).Count -gt 0)
4134
+ # The changes this one cannot be ordered against at all, in either direction.
4135
+ unordered = @($order.unknown)
4136
+ unordered_count = $order.unknown_count
4137
+ unordered_ids = $order.unknown_ids
4138
+ wave_ordering_ok = [bool]$order.wave_ordering_ok
4139
+ wave_problem = $order.wave_problem
4089
4140
  })
4090
4141
  }
4091
4142
 
@@ -4174,71 +4225,26 @@ function Get-FileSha {
4174
4225
  return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLower()
4175
4226
  }
4176
4227
 
4228
+ # THE RULE ITSELF LIVES IN python/aw-plangate.py, which both shells invoke. It was a set
4229
+ # of sha comparisons written twice — a heredoc in bash, this function natively — and every
4230
+ # change to it had to be re-derived here by hand and kept in step by eye. It changed twice
4231
+ # that way. These two are now thin wrappers: resolve the feature, hand over.
4177
4232
  function Invoke-PlanGateStamp {
4178
4233
  param([string[]]$Rest)
4179
- $fdArg = ''; $specVersion = ''; $mode = 'master'; $verdict = 'conformant'; $planGaps = '0'; $routedRaw = '[]'
4234
+ $fdArg = ''; $pass = @()
4180
4235
  for ($i = 0; $i -lt $Rest.Count; $i++) {
4181
- switch ($Rest[$i]) {
4182
- { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
4183
- { $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] }
4184
- { $_ -in '--mode','-Mode' } { $mode = $Rest[++$i] }
4185
- { $_ -in '--verdict','-Verdict' } { $verdict = $Rest[++$i] }
4186
- { $_ -in '--plan-gaps','-PlanGaps' } { $planGaps = $Rest[++$i] }
4187
- { $_ -in '--routed','-Routed' } { $routedRaw = $Rest[++$i] }
4188
- }
4236
+ if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$i] }
4237
+ else { $pass += $Rest[$i] }
4189
4238
  }
4190
- # An unrecognised verdict is recorded as `gaps`, the one that refuses.
4191
- $verdict = "$verdict".Trim()
4192
- if ($verdict -notin @('conformant','routed-gaps','gaps')) { $verdict = 'gaps' }
4193
- $planGapCount = 0
4194
- if (-not [int]::TryParse("$planGaps", [ref]$planGapCount)) { $planGapCount = 0 }
4195
- $routed = @()
4196
- try {
4197
- $parsedRouted = ConvertFrom-Json -InputObject $(if ("$routedRaw".Trim()) { $routedRaw } else { '[]' }) -NoEnumerate -ErrorAction Stop
4198
- $routed = @($parsedRouted | ForEach-Object { "$_" })
4199
- } catch { $routed = @() }
4200
4239
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4201
4240
  if (-not $fd) { Write-Error 'plan-gate-stamp: no feature set and no --feature-dir'; exit 1 }
4202
4241
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
4203
- # Every document the gate READ. A lane that is not declared has no plan on disk and is
4204
- # simply absent from the map — the check treats a name it does not carry as not gated,
4205
- # never as changed, so declaring a second lane later does not read as tampering.
4206
- #
4207
- # THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
4208
- # resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
4209
- # that has not been through `plan` since the rename — every brownfield feature `adopt`
4210
- # produces — is GATED on those documents. Hashing only the new names would leave such a
4211
- # feature with an empty plan map: a receipt that looks tracked and fresh while the document
4212
- # implement actually generates from is unwatched.
4213
- $plans = [ordered]@{}
4214
- foreach ($name in @('backend-plan.md','frontend-plan.md','design.md','design.ui.md')) {
4215
- $h = Get-FileSha -Path (Join-Path $fd $name)
4216
- if ($h) { $plans[$name] = $h }
4217
- }
4218
- $rec = [ordered]@{
4219
- gated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
4220
- gate_mode = $mode
4221
- spec_version = $specVersion.Trim()
4222
- verdict = $verdict
4223
- plan_gaps = $planGapCount
4224
- routed_gaps = @($routed)
4225
- spec_sha = (Get-FileSha -Path (Join-Path $fd 'spec.md'))
4226
- model_sha = (Get-FileSha -Path (Join-Path $ctx 'domain-model.md'))
4227
- plans = $plans
4228
- note = ('Written by every master-mode analyze-plan run. It records the bytes that were ' +
4229
- 'checked against each other and the verdict on them, not a version — the plans'' ' +
4230
- '**Version**: headers are stamped in lockstep by close-delta and cannot answer ' +
4231
- 'this. implement reads it through plan-gate-check: moved bytes or verdict `gaps` ' +
4232
- 'refuse; `routed-gaps` lists what /appweaver specify still has to lift.')
4233
- }
4234
- $p = Join-Path $fd '.aw-plan-gate.json'
4235
- (ConvertTo-Json -InputObject $rec -Depth 6) + "`n" | Set-Content -LiteralPath $p -NoNewline -Encoding utf8
4236
- Write-Json ([ordered]@{
4237
- file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); gated = $rec.gated
4238
- spec_version = $rec.spec_version; verdict = $verdict; plan_gaps = $planGapCount
4239
- routed_gaps = $routed.Count; hashed = @($plans.Keys | Sort-Object)
4240
- count = $plans.Count; ok = $true
4241
- })
4242
+ $py = Get-Py
4243
+ if (-not $py) { Write-Error 'plan-gate-stamp: python3 is required'; exit 1 }
4244
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-plangate.py'
4245
+ if (-not (Test-Path $core)) { Write-Error "plan-gate-stamp: core not found at $core"; exit 1 }
4246
+ & $py $core stamp --feature-dir $fd --context-dir $ctx --root $repo @pass
4247
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4242
4248
  }
4243
4249
 
4244
4250
  function Invoke-PlanGateCheck {
@@ -4250,70 +4256,29 @@ function Invoke-PlanGateCheck {
4250
4256
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4251
4257
  if (-not $fd) { Write-Error 'plan-gate-check: no feature set and no --feature-dir'; exit 1 }
4252
4258
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
4253
- $p = Join-Path $fd '.aw-plan-gate.json'
4254
- $rec = $null
4255
- if (Test-Path -LiteralPath $p -PathType Leaf) {
4256
- try { $rec = Get-Content -LiteralPath $p -Raw | ConvertFrom-Json } catch { $rec = $null }
4257
- }
4258
- $out = [ordered]@{
4259
- file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); tracked = [bool]$rec
4260
- gate_fresh = $false; verdict = ''; may_generate = $true; plan_gaps = 0
4261
- routed_gaps = @(); routed_csv = ''; spec_moved = $false; model_moved = $false
4262
- plans_changed = @(); plans_changed_csv = ''; gated = ''; spec_version = ''
4263
- reason = ''; ok = $true
4264
- }
4265
- if (-not $rec) {
4266
- # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
4267
- # thing to report about a tree that has not run analyze-plan since this op existed.
4268
- $out.reason = 'no .aw-plan-gate.json — this feature has not been through analyze-plan in master mode since the gate began leaving a receipt'
4269
- } else {
4270
- # ConvertFrom-Json turns an ISO timestamp into a DateTime; render it back in the
4271
- # form the receipt was written in, as the bash twin reports it.
4272
- $out.gated = $(if ($rec.gated -is [datetime]) { $rec.gated.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { [string]$rec.gated })
4273
- $out.spec_version = [string]$rec.spec_version
4274
- # A receipt from before the verdict existed was only ever written on a clean pass.
4275
- $out.verdict = $(if ("$($rec.verdict)") { [string]$rec.verdict } else { 'conformant' })
4276
- $gapCount = 0
4277
- if ($null -ne $rec.plan_gaps) { [void][int]::TryParse("$($rec.plan_gaps)", [ref]$gapCount) }
4278
- $out.plan_gaps = $gapCount
4279
- $out.routed_gaps = @(@($rec.routed_gaps) | Where-Object { $null -ne $_ } | ForEach-Object { "$_" })
4280
- $out.routed_csv = ($out.routed_gaps -join '; ')
4281
- $specSha = [string]$rec.spec_sha
4282
- $out.spec_moved = ($specSha -and $specSha -ne (Get-FileSha -Path (Join-Path $fd 'spec.md')))
4283
- # A model recorded as absent that is still absent has not moved. A context that
4284
- # GAINED a domain model since the gate has moved: check 4 never ran against it.
4285
- $out.model_moved = ([string]$rec.model_sha -ne (Get-FileSha -Path (Join-Path $ctx 'domain-model.md')))
4286
- $changed = @()
4287
- if ($rec.plans) {
4288
- foreach ($name in @($rec.plans.PSObject.Properties.Name | Sort-Object)) {
4289
- if ((Get-FileSha -Path (Join-Path $fd $name)) -ne [string]$rec.plans.$name) { $changed += $name }
4290
- }
4291
- }
4292
- $out.plans_changed = @($changed)
4293
- $out.plans_changed_csv = ($changed -join ', ')
4294
- $out.gate_fresh = (-not ($out.spec_moved -or $out.model_moved -or $changed.Count -gt 0))
4295
- # Moved bytes refuse whatever the verdict was; unmoved bytes refuse only on `gaps`.
4296
- $out.may_generate = ($out.gate_fresh -and $out.verdict -ne 'gaps')
4297
- if (-not $out.gate_fresh) {
4298
- $bits = @()
4299
- if ($out.spec_moved) { $bits += 'spec.md has changed' }
4300
- if ($out.model_moved) { $bits += 'domain-model.md has changed' }
4301
- if ($changed.Count -gt 0) { $bits += ('{0} changed (a re-derive by `plan main`, or a hand edit)' -f ($changed -join ', ')) }
4302
- $out.reason = 'since analyze-plan checked them on {0}: {1}' -f $out.gated, ($bits -join '; ')
4303
- } elseif ($out.verdict -eq 'gaps') {
4304
- $n = $(if ($out.plan_gaps) { $out.plan_gaps } else { 'some' })
4305
- $out.reason = 'analyze-plan on {0} left {1} gap(s) a plan rewrite closes, unrepaired' -f $out.gated, $n
4306
- } elseif ($out.verdict -eq 'routed-gaps') {
4307
- $out.reason = 'the spec, the model and every gated plan are byte-identical to what analyze-plan checked on {0}; it left {1} gap(s) only /appweaver specify can close: {2}' -f $out.gated, $out.routed_gaps.Count, $out.routed_csv
4308
- } else {
4309
- $out.reason = 'the spec, the model and every gated plan are byte-identical to what analyze-plan passed on {0}' -f $out.gated
4310
- }
4311
- }
4312
- Write-Json $out
4259
+ $py = Get-Py
4260
+ if (-not $py) { Write-Error 'plan-gate-check: python3 is required'; exit 1 }
4261
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-plangate.py'
4262
+ if (-not (Test-Path $core)) { Write-Error "plan-gate-check: core not found at $core"; exit 1 }
4263
+ & $py $core check --feature-dir $fd --context-dir $ctx --root $repo
4264
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4313
4265
  }
4314
4266
 
4315
4267
  # Which of a lane plan's elements the generated code realises. One implementation
4316
4268
  # (python/aw-plan-coverage.py), called identically by both shells — see the bash twin.
4269
+ # Split a gate's findings by the command that closes each one. Twin of the bash
4270
+ # cmd_gap_route, calling the SAME aw-gaps.py — see the bash comment for why a plan
4271
+ # defect must never be routed to `implement`.
4272
+ function Invoke-GapRoute {
4273
+ param([string[]]$Rest)
4274
+ $py = Get-Py
4275
+ if (-not $py) { Write-Error 'gap-route: python3 is required'; exit 1 }
4276
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-gaps.py'
4277
+ if (-not (Test-Path $core)) { Write-Error "gap-route: core not found at $core"; exit 1 }
4278
+ & $py $core route @Rest
4279
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4280
+ }
4281
+
4317
4282
  function Invoke-PlanCoverage {
4318
4283
  param([string[]]$Rest)
4319
4284
  $py = Get-Py
@@ -4468,6 +4433,7 @@ switch ($sub) {
4468
4433
  'plan-gate-stamp' { Invoke-PlanGateStamp -Rest $rest }
4469
4434
  'plan-gate-check' { Invoke-PlanGateCheck -Rest $rest }
4470
4435
  'plan-coverage' { Invoke-PlanCoverage -Rest $rest }
4436
+ 'gap-route' { Invoke-GapRoute -Rest $rest }
4471
4437
  'checklist-read' { Invoke-ChecklistRead -Rest $rest }
4472
4438
  'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
4473
4439
  'detect-project' { Invoke-DetectProject -Rest $rest }
@@ -4483,6 +4449,7 @@ switch ($sub) {
4483
4449
  'setup-constitution' { Invoke-SetupConstitution -Rest $rest }
4484
4450
  'compose-constitution' { Invoke-ComposeConstitution -Rest $rest }
4485
4451
  'constitution-verify' { Invoke-ConstitutionVerify -Rest $rest }
4452
+ 'principles-list' { Invoke-PrinciplesList -Rest $rest }
4486
4453
  'agent-context-update' { Invoke-AgentContextUpdate -Rest $rest }
4487
4454
  default { Write-Error "Unknown subcommand: $sub"; exit 1 }
4488
4455
  }
@@ -8,7 +8,10 @@
8
8
  # aw-xref.ps1 list --aggregate Y [--root DIR] [--bundle knowledge]
9
9
  # aw-xref.ps1 concept-upsert --aggregate X [--source-files a,b,c] [--description D] [--origin ...] [--root DIR]
10
10
  # aw-xref.ps1 concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR]
11
- # aw-xref.ps1 concept-paths --aggregate X | --feature-dir <dir> [--root DIR] [--bundle knowledge]
11
+ # aw-xref.ps1 concept-paths --aggregate X | --feature-dir <dir> [--under <subtree>]...
12
+ # [--root DIR] [--bundle knowledge]
13
+ # (--under keeps only paths inside that subtree, so a caller
14
+ # generating ONE lane is not handed another lane's files)
12
15
  # aw-xref.ps1 feature-aggregates --feature-dir <dir> [--root DIR]
13
16
  # (the aggregate(s) a feature acts on: its spec's Acts-on line,
14
17
  # then the boundary map, then the feature name itself)