aw-spec-cli 0.1.20 → 0.1.21

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 (41) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  2. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
  3. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  4. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
  5. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +107 -48
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +17 -9
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +265 -20
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +89 -15
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +354 -52
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +1 -1
  15. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +651 -76
  16. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +600 -59
  17. package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
  18. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  19. package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
  20. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
  21. package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
  22. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  23. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
  24. package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
  25. package/assets/.appweaver/stack-profiles/node-express.json +1 -0
  26. package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
  27. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  28. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  29. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  30. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  31. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  32. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  33. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  34. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  35. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  36. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
  37. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  38. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  39. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  40. package/package.json +1 -1
  41. package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
@@ -586,6 +586,7 @@ function Invoke-StackList {
586
586
  switch ($Rest[$i]) {
587
587
  '--kind' { $fwd += '--kind'; $fwd += $Rest[++$i] }
588
588
  '--preselect' { $fwd += '--preselect'; $fwd += $Rest[++$i] }
589
+ '--profile' { $fwd += '--profile'; $fwd += $Rest[++$i] }
589
590
  '--include-none' { $fwd += '--include-none' }
590
591
  '--suggest' { $fwd += '--suggest' }
591
592
  default { Write-Error "stack-list: unknown arg '$($Rest[$i])'"; exit 1 }
@@ -1469,36 +1470,37 @@ function Get-FoundationDirs {
1469
1470
 
1470
1471
  # ──────────────────────────── foundation-list ──────────────────────────────
1471
1472
  # List available foundations (foundations/<stack>/<Name>/ with a real <Name>.pack.txt)
1472
- # for the user to PICK — deterministic, no LLM. Optional --args fast path mirrors bash.
1473
+ # for the user to PICK — deterministic, no LLM. Optional --args fast path mirrors bash,
1474
+ # including a third token coming back as `rejected_out` rather than as a destination:
1475
+ # see the bash twin for why a scaffold has no caller-chosen output directory.
1473
1476
  function Invoke-FoundationList {
1474
1477
  param([string[]]$Rest)
1475
- $raw = ''
1476
- for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { '--args' { $raw = $Rest[++$i] } } }
1478
+ $raw = ''; $ecoFilter = ''
1479
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { '--args' { $raw = $Rest[++$i] } '--ecosystem' { $ecoFilter = $Rest[++$i] } } }
1477
1480
  $repo = Get-RepoRoot
1478
1481
 
1479
1482
  # WHICH foundations this repo may be offered. A foundation bundle is a whole
1480
1483
  # compiling project in one ecosystem's languages and build system, so unpacking a
1481
1484
  # .NET one into a Spring repo does not produce a scaffold — it produces a second,
1482
- # foreign project beside the real one. The declared lanes already say which
1483
- # ecosystems this repo is, so filter to those and let the gate say why when that
1484
- # leaves nothing. Nothing declared (a bare greenfield repo, before select-stack)
1485
- # filters nothing: there is no answer yet to filter on.
1486
- $laneEcos = @(Get-Ecosystems)
1485
+ # foreign project beside the real one. --ecosystem narrows to the ONE lane being
1486
+ # scaffolded; without it the filter is every declared lane's ecosystem, and nothing
1487
+ # at all on a bare repo.
1488
+ $laneEcos = if ($ecoFilter) { @($ecoFilter) } else { @(Get-Ecosystems) }
1487
1489
  $foundations = @(); $all = @(); $ecoOf = @()
1488
1490
  foreach ($f in @(Get-FoundationDirs $repo)) {
1489
1491
  if (-not (Test-Path (Join-Path $f.Dir "$($f.Name).pack.txt"))) { continue }
1490
1492
  $all += $f.Name; $ecoOf += $f.Stack
1491
1493
  if ($laneEcos.Count -eq 0 -or $laneEcos -contains $f.Stack) { $foundations += $f.Name }
1492
1494
  }
1493
- $preName = ''; $preApp = ''; $outDir = '.'
1494
- if ($raw) { $t = $raw -split '\s+' | Where-Object { $_ }; if ($t.Count -ge 1) { $preName = $t[0] }; if ($t.Count -ge 2) { $preApp = $t[1] }; if ($t.Count -ge 3) { $outDir = $t[2] } }
1495
+ $preName = ''; $preApp = ''; $rejectedOut = ''
1496
+ if ($raw) { $t = $raw -split '\s+' | Where-Object { $_ }; if ($t.Count -ge 1) { $preName = $t[0] }; if ($t.Count -ge 2) { $preApp = $t[1] }; if ($t.Count -ge 3) { $rejectedOut = $t[2] } }
1495
1497
  $match = if ($foundations -contains $preName) { $preName } else { '' }
1496
1498
  Write-Json ([ordered]@{
1497
1499
  foundations = @($foundations); count = $foundations.Count
1498
1500
  all_foundations = @($all); all_count = $all.Count
1499
1501
  ecosystems = @($ecoOf); lane_ecosystems = @($laneEcos)
1500
1502
  filtered = ($laneEcos.Count -gt 0)
1501
- preselect_name = $match; preselect_app = $preApp; out_dir = $outDir
1503
+ preselect_name = $match; preselect_app = $preApp; rejected_out = $rejectedOut
1502
1504
  })
1503
1505
  }
1504
1506
 
@@ -1535,11 +1537,14 @@ function Invoke-FoundationLocate {
1535
1537
  }
1536
1538
 
1537
1539
  # ─────────────────────────── foundation-unpack ─────────────────────────────
1540
+ # --out is the LANE'S SOURCE PATH and --test-path its other subtree; the pack's own test
1541
+ # tree is lifted onto the latter by aw-foundation.py. Twin of the bash cmd_foundation_unpack
1542
+ # — see it for why a scaffold goes into a lane rather than into the repo root.
1538
1543
  function Invoke-FoundationUnpack {
1539
1544
  param([string[]]$Rest)
1540
- $name = ''; $app = ''; $out = '.'; $dry = $false; $force = 'no'; $reference = $false
1545
+ $name = ''; $app = ''; $out = '.'; $dry = $false; $force = 'no'; $reference = $false; $testPath = ''
1541
1546
  for ($i = 0; $i -lt $Rest.Count; $i++) {
1542
- switch ($Rest[$i]) { '--name' { $name = $Rest[++$i] } '--app' { $app = $Rest[++$i] } '--out' { $out = $Rest[++$i] } '--dry-run' { $dry = $true } '--force' { $force = $Rest[++$i] } '--reference' { $reference = $true } }
1547
+ switch ($Rest[$i]) { '--name' { $name = $Rest[++$i] } '--app' { $app = $Rest[++$i] } '--out' { $out = $Rest[++$i] } '--test-path' { $testPath = $Rest[++$i] } '--dry-run' { $dry = $true } '--force' { $force = $Rest[++$i] } '--reference' { $reference = $true } }
1543
1548
  }
1544
1549
  if (-not $name -or -not $app) { Write-Error 'foundation-unpack: --name and --app required'; exit 1 }
1545
1550
  $repo = Get-RepoRoot
@@ -1564,7 +1569,28 @@ function Invoke-FoundationUnpack {
1564
1569
  # unpackers say "Unpacked N file(s) to …" / "DryRun: N file(s) would be written …"
1565
1570
  $written = 0; $wm = [regex]::Match($outp, '(?i)([0-9]+)\s+file'); if ($wm.Success) { $written = [int]$wm.Groups[1].Value }
1566
1571
  $hasColl = ($dry -and $rc -ne 0)
1567
- Write-Json ([ordered]@{ ok = ($rc -eq 0); mode = $(if ($dry) { 'dry' } else { 'real' }); written = $written; has_collisions = $hasColl; stdout = ($outp.Substring([Math]::Max(0,$outp.Length-800))) })
1572
+
1573
+ # Put the pack's test tree on the lane's test path. Only after a REAL unpack that
1574
+ # worked: a dry run has written nothing to lift, and a failed one leaves a tree
1575
+ # nobody should be rearranging.
1576
+ $testsMoved = 0; $testsMsg = ''
1577
+ if (-not $dry -and $rc -eq 0 -and $testPath) {
1578
+ $awFnd = Join-Path $PSScriptRoot '..' 'python' 'aw-foundation.py'
1579
+ $py = Get-Py
1580
+ if ($py -and (Test-Path $awFnd)) {
1581
+ $rargs = @($awFnd, '--repo', $repo, 'redirect-tests', '--out', $out, '--test-path', $testPath)
1582
+ if ($force -eq 'yes' -or $reference) { $rargs += '--force' }
1583
+ $rout = (& $py @rargs 2>$null | Select-Object -Last 1)
1584
+ if ($rout) {
1585
+ try {
1586
+ $rj = $rout | ConvertFrom-Json
1587
+ $testsMoved = [int]$rj.moved; $testsMsg = [string]$rj.message
1588
+ } catch { $testsMoved = 0; $testsMsg = '' }
1589
+ }
1590
+ }
1591
+ }
1592
+
1593
+ Write-Json ([ordered]@{ ok = ($rc -eq 0); mode = $(if ($dry) { 'dry' } else { 'real' }); written = $written; has_collisions = $hasColl; tests_moved = $testsMoved; tests_message = $testsMsg; stdout = ($outp.Substring([Math]::Max(0,$outp.Length-800))) })
1568
1594
  }
1569
1595
 
1570
1596
  # ─────────────────────── foundation-stash-dbcontext ────────────────────────
@@ -1574,7 +1600,9 @@ function Invoke-FoundationStashDbContext {
1574
1600
  for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--out') { $out = $Rest[++$i] } }
1575
1601
  $repo = Get-RepoRoot
1576
1602
  if (-not [System.IO.Path]::IsPathRooted($out)) { $out = Join-Path $repo $out }
1577
- $ctx = Get-ChildItem -LiteralPath (Join-Path $out 'src') -Recurse -Filter 'ApplicationDbContext.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1603
+ # Searched from the unpack root itself, which IS the lane's source path — a `src/`
1604
+ # segment underneath it is the old repo-root layout and is not there any more.
1605
+ $ctx = Get-ChildItem -LiteralPath $out -Recurse -Filter 'ApplicationDbContext.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1578
1606
  if (-not $ctx) { Write-Json ([ordered]@{ found = $false; dbcontext = ''; backup = '' }); return }
1579
1607
  $bak = "$ctx.foundation.bak"
1580
1608
  Copy-Item $ctx $bak
@@ -2413,6 +2441,9 @@ function Invoke-TestRun {
2413
2441
 
2414
2442
  $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
2415
2443
  $matched = $false; $scoped = $false; $fenceGreen = $false; $filter = ''
2444
+ # The runner's exit code, kept because 126/127 is every shell's way of saying the
2445
+ # command itself could not be executed — a missing runner, not a failing suite.
2446
+ $rc = 0
2416
2447
  $hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2417
2448
  $hasCsproj = @(Get-ChildItem -LiteralPath $repo -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2418
2449
  $dotnetPresent = ($hasSln -or $hasCsproj)
@@ -2450,6 +2481,16 @@ function Invoke-TestRun {
2450
2481
  fence_green = $dOk
2451
2482
  cmd = $(if ($d -and $d.cmd) { [string]$d.cmd } else { '' })
2452
2483
  log_tail = $dLog
2484
+ # The failing lines of EVERY failing lane, budgeted per lane by run-phase —
2485
+ # what the fix loop reads, in place of a tail that may belong to a lane that
2486
+ # passed. Twin of the bash branch.
2487
+ failing_excerpt = $(if ($d -and $d.failing_excerpt) { [string]$d.failing_excerpt } else { '' })
2488
+ failing_lanes = @(if ($d -and $d.failing_lanes) { $d.failing_lanes } else { @() })
2489
+ # NOTHING ran a suite because no lane's test command is wired up — a different
2490
+ # state from a red suite, and the one a fix loop cannot help with.
2491
+ no_setup = [bool]($d -and $d.no_setup)
2492
+ setup_reason = $(if ($d -and $d.setup_reason) { [string]$d.setup_reason } else { '' })
2493
+ setup_missing = @(if ($d -and $d.setup_missing) { $d.setup_missing } else { @() })
2453
2494
  lanes_run = @(if ($d) { $d.lanes_run } else { @() })
2454
2495
  lanes_skipped = @(if ($d) { $d.lanes_skipped } else { @() })
2455
2496
  })
@@ -2468,16 +2509,16 @@ function Invoke-TestRun {
2468
2509
  } else {
2469
2510
  $filter = ($slices | ForEach-Object { "FullyQualifiedName~$_" }) -join '|'
2470
2511
  $scoped = $true; $cmd = "dotnet test --nologo --filter `"$filter`""; $ran = $true
2471
- Push-Location $repo; try { $log = (dotnet test --nologo --filter $filter 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
2512
+ Push-Location $repo; try { $log = (dotnet test --nologo --filter $filter 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
2472
2513
  }
2473
2514
  } else {
2474
2515
  $cmd = 'dotnet test --nologo'; $ran = $true
2475
- Push-Location $repo; try { $log = (dotnet test --nologo 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
2516
+ Push-Location $repo; try { $log = (dotnet test --nologo 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
2476
2517
  }
2477
2518
  } else { $ok = $false }
2478
2519
  } elseif ((Test-Path (Join-Path $repo 'package.json')) -and (Get-Command npm -ErrorAction SilentlyContinue)) {
2479
2520
  $tool = 'npm'; $cmd = 'npm test'; $ran = $true
2480
- Push-Location $repo; try { $log = (npm test 2>&1 | Out-String); $ok = ($LASTEXITCODE -eq 0) } finally { Pop-Location }
2521
+ Push-Location $repo; try { $log = (npm test 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
2481
2522
  }
2482
2523
 
2483
2524
  # matched = at least one test was actually selected + run. Read from the LOG, never the
@@ -2491,7 +2532,35 @@ function Invoke-TestRun {
2491
2532
  if ($scoped) { $fenceGreen = ($ok -and $matched) } else { $fenceGreen = $ok }
2492
2533
 
2493
2534
  $tail = if ($log.Length -gt 1400) { $log.Substring($log.Length - 1400) } else { $log }
2494
- Write-Json ([ordered]@{ ok = $ok; tool = $tool; ran = $ran; scope = $scope; slices = ($slices -join ' '); scoped = $scoped; matched = $matched; fence_green = $fenceGreen; cmd = $cmd; log_tail = $tail })
2535
+ # The lines that NAME the failure, which a positional tail does not reliably contain:
2536
+ # a .NET run opens with its restore banner and a runner closes with a summary, so the
2537
+ # last 1400 characters can be entirely green while the failure sits above them.
2538
+ $excerpt = ''; $noSetup = $false; $setupReason = ''
2539
+ if (-not $fenceGreen) {
2540
+ $py = Get-Py
2541
+ if ($py) {
2542
+ $helper = Join-Path $PSScriptRoot '..' 'python' 'aw-testlog.py'
2543
+ if (Test-Path $helper) {
2544
+ $label = $tool; if ($cmd) { $label = "$tool · $cmd" }
2545
+ $label = ($label -replace '\s+', ' ')
2546
+ if ($label.Length -gt 80) { $label = $label.Substring(0, 80) }
2547
+ $tmp = [System.IO.Path]::GetTempFileName()
2548
+ try {
2549
+ Set-Content -LiteralPath $tmp -Value $log -NoNewline -Encoding utf8
2550
+ $line = (& $py $helper '--file' $tmp '--label' $label '--exit' "$rc" | Select-Object -First 1)
2551
+ if ($line) {
2552
+ $h = $line | ConvertFrom-Json
2553
+ $excerpt = [string]$h.excerpt
2554
+ # "The command never ran" — no test script, no runner, no test
2555
+ # project. Not a red suite; the callers gate on it.
2556
+ $setupReason = [string]$h.reason
2557
+ $noSetup = [bool]$h.no_setup
2558
+ }
2559
+ } catch { $excerpt = '' } finally { Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue }
2560
+ }
2561
+ }
2562
+ }
2563
+ Write-Json ([ordered]@{ ok = $ok; tool = $tool; ran = $ran; scope = $scope; slices = ($slices -join ' '); scoped = $scoped; matched = $matched; fence_green = $fenceGreen; cmd = $cmd; log_tail = $tail; failing_excerpt = $excerpt; failing_lanes = @(); no_setup = $noSetup; setup_reason = $setupReason; setup_missing = @() })
2495
2564
  }
2496
2565
 
2497
2566
  function Invoke-RunMigrate {
@@ -2960,26 +3029,41 @@ function Invoke-CrPaths {
2960
3029
 
2961
3030
  # ─────────────────────────── cr-overlap ────────────────────────────────────
2962
3031
  # Which OTHER open changes touch the same aggregates as this one — read off the
2963
- # `## Aggregate: <Name>` headers of the pending model deltas, which is the merge unit
2964
- # domain-model.md is organised by. Deterministic on purpose: two deltas collide when they
2965
- # change the same aggregate, and that is a fact about two files' headings, so a regex
2966
- # answers it exactly and the same way every run. Early warning, not enforcement — an
3032
+ # `**Aggregates touched**:` header of each pending model delta. That header names the
3033
+ # `## Aggregate: <Name>` sections of domain-model.md the delta merges into; the delta itself
3034
+ # carries no such headings, the master does. Deterministic on purpose: two deltas collide
3035
+ # when they change the same aggregate, and that is a fact about two files' headers, so a
3036
+ # regex answers it exactly and the same way every run. Early warning, not enforcement — an
2967
3037
  # overlap is ordinary, and what matters is that the second change is PLANNED knowing about
2968
3038
  # the first. The relation follows the wave: a LOWER wave is a predecessor (a dependency to
2969
3039
  # declare), the SAME wave a sibling (a conflict to design around), a HIGHER wave a
2970
3040
  # successor (it plans on top of this change — information, not an obligation). Twin of
2971
3041
  # bash cmd_cr_overlap.
2972
3042
  #
2973
- # The aggregate names one model delta declares, in the order it declares them. A
2974
- # placeholder heading left by the template names no aggregate, so an unfilled model delta
2975
- # reports none rather than colliding with every other change on "<Name>".
3043
+ # The aggregate names one model delta declares, in the order its `**Aggregates touched**:`
3044
+ # header lists them — the same list finalize-delta opens master sections for. Backticked
3045
+ # names are the template's form and win when present, so prose around them adds nothing; a
3046
+ # value with no backticks is split on , ; / & "and" and each piece's leading name is taken.
3047
+ # The template placeholder (`[Root]`) and "None — behaviour only" name no aggregate, so an
3048
+ # unfilled or model-free delta reports none rather than colliding with every other change.
2976
3049
  function Get-DeltaAggregates {
2977
3050
  param([string]$Text)
2978
3051
  $out = @()
2979
3052
  if (-not $Text) { return $out }
2980
- foreach ($m in [regex]::Matches($Text, '(?m)^##\s*Aggregate:\s*(.+?)\s*$')) {
2981
- $name = $m.Groups[1].Value.Trim().Trim('`')
2982
- if (-not $name -or $name.StartsWith('<') -or $name.StartsWith('[')) { continue }
3053
+ $m = [regex]::Match($Text, '(?m)^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$')
3054
+ if (-not $m.Success) { return $out }
3055
+ $value = ([regex]::Replace($m.Groups[1].Value, '<!--.*?(?:-->|$)', '')).Trim()
3056
+ $names = @([regex]::Matches($value, '`([^`]+)`') | ForEach-Object { $_.Groups[1].Value })
3057
+ if ($names.Count -eq 0) {
3058
+ $names = @(foreach ($piece in [regex]::Split($value, '[,;/&]|\band\b')) {
3059
+ $pm = [regex]::Match($piece.Trim(), '^[A-Za-z][A-Za-z0-9_]*')
3060
+ if ($pm.Success) { $pm.Value }
3061
+ })
3062
+ }
3063
+ foreach ($name in $names) {
3064
+ $name = $name.Trim()
3065
+ if (-not $name -or $name.StartsWith('<') -or $name.StartsWith('[') -or
3066
+ [regex]::IsMatch($name, '^none\b', 'IgnoreCase')) { continue }
2983
3067
  if ($out -notcontains $name) { $out += $name }
2984
3068
  }
2985
3069
  return $out
@@ -3106,6 +3190,337 @@ function Invoke-CrWave {
3106
3190
  Write-Json ([ordered]@{ cr_id = $crId; wave = [int]$wave; stamped = $stamped; delta_spec = $dspec; delta_model = $dmodel })
3107
3191
  }
3108
3192
 
3193
+ $PyCrWaveDerive = @'
3194
+ import glob, json, os, re
3195
+
3196
+ cr = os.environ["AW_CR"]
3197
+ fd = os.environ["AW_FD"]
3198
+ ctx = os.environ["AW_CTX"]
3199
+ stamp = os.environ.get("AW_STAMP", "") == "true"
3200
+
3201
+ CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
3202
+ WAVE_RX = re.compile(r"^\*\*Wave\*\*:\s*(\d+)", re.M)
3203
+
3204
+
3205
+ def read(path):
3206
+ try:
3207
+ return open(path, encoding="utf-8").read()
3208
+ except Exception:
3209
+ return ""
3210
+
3211
+
3212
+ def wave_of(cr_id):
3213
+ """A change states its wave in the header of its own delta documents."""
3214
+ for p in (os.path.join(ctx, "domain-model.%s.delta.md" % cr_id),
3215
+ os.path.join(fd, "changes", cr_id, "delta-spec.md")):
3216
+ m = WAVE_RX.search(read(p))
3217
+ if m:
3218
+ return int(m.group(1))
3219
+ for p in glob.glob(os.path.join(glob.escape(os.path.join(fd, "changes", cr_id)), "delta-*.md")):
3220
+ m = WAVE_RX.search(read(p))
3221
+ if m:
3222
+ return int(m.group(1))
3223
+ return None
3224
+
3225
+
3226
+ def depends_ids(text):
3227
+ """The CR ids named under `## Depends on`, up to the next heading of that level."""
3228
+ m = re.search(r"^##\s+Depends on\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
3229
+ if not m:
3230
+ return []
3231
+ body = re.sub(r"<!--.*?-->", " ", m.group(1), flags=re.S)
3232
+ return [i for i in CR_RX.findall(body) if i != cr]
3233
+
3234
+
3235
+ change_dir = os.path.join(fd, "changes", cr)
3236
+ docs = [os.path.join(ctx, "domain-model.%s.delta.md" % cr),
3237
+ os.path.join(change_dir, "delta-spec.md")]
3238
+ docs += sorted(glob.glob(os.path.join(glob.escape(change_dir), "delta-*plan*.md")))
3239
+ present = [p for p in docs if os.path.isfile(p)]
3240
+
3241
+ deps, seen = [], set()
3242
+ for p in present:
3243
+ for i in depends_ids(read(p)):
3244
+ if i not in seen:
3245
+ seen.add(i)
3246
+ deps.append(i)
3247
+
3248
+ known, unknown = [], []
3249
+ for d in deps:
3250
+ w = wave_of(d)
3251
+ (known if w is not None else unknown).append({"cr_id": d, "wave": w})
3252
+ required = max([d["wave"] for d in known], default=0) + 1
3253
+ current = wave_of(cr)
3254
+ ok = current is not None and current >= required
3255
+
3256
+ stamped = []
3257
+ if stamp and current is not None and current < required:
3258
+ line = "**Wave**: %d" % required
3259
+ pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
3260
+ for p in present:
3261
+ text = read(p)
3262
+ if pat.search(text):
3263
+ open(p, "w", encoding="utf-8").write(pat.sub(lambda _m: line, text, count=1))
3264
+ stamped.append(os.path.basename(p))
3265
+ if stamped:
3266
+ current, ok = required, True
3267
+
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:
3272
+ msg = "%s depends on %s, so it was raised to wave %d in %s" % (
3273
+ cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
3274
+ required, ", ".join(stamped))
3275
+ elif ok:
3276
+ msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
3277
+ cr, current, required)
3278
+ else:
3279
+ msg = ("%s is at wave %s but depends on %s, so it must sit at wave %d — a predecessor "
3280
+ "at the same or a higher wave is not ordered behind it at all" % (
3281
+ cr, current, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
3282
+ required))
3283
+ if unknown:
3284
+ msg += ". Named but not found open: %s" % ", ".join(u["cr_id"] for u in unknown)
3285
+
3286
+ print(json.dumps({
3287
+ "cr_id": cr, "wave": current, "required_wave": required, "ok": ok,
3288
+ "depends_on": known, "unknown": unknown, "depends_count": len(deps),
3289
+ "stamped": stamped, "documents": [os.path.basename(p) for p in present],
3290
+ "message": msg,
3291
+ }))
3292
+ '@
3293
+
3294
+ # THE WAVE A CHANGE'S OWN DOCUMENTS IMPLY, from their `## Depends on` sections. Twin of the
3295
+ # bash cmd_cr_wave_derive, running the SAME python payload — see the bash comment for why
3296
+ # the declaration, not cr-setup's default, is the ordering authority.
3297
+ function Invoke-CrWaveDerive {
3298
+ param([string[]]$Rest)
3299
+ $crId = ''; $fdArg = ''; $stamp = $false
3300
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3301
+ switch ($Rest[$i]) {
3302
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3303
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3304
+ { $_ -in '--stamp','-Stamp' } { $stamp = $true }
3305
+ }
3306
+ }
3307
+ $crId = Get-CrIdToken -Raw $crId
3308
+ if (-not $crId) { Write-Error 'cr-wave-derive: --cr-id is required'; exit 1 }
3309
+ $py = Get-Py
3310
+ if (-not $py) { Write-Error 'cr-wave-derive: python3 is required'; exit 1 }
3311
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3312
+ if (-not $fd) { Write-Error 'cr-wave-derive: no feature set and no --feature-dir'; exit 1 }
3313
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3314
+ $env:AW_CR = $crId; $env:AW_FD = $fd; $env:AW_CTX = $ctx
3315
+ $env:AW_STAMP = $(if ($stamp) { 'true' } else { 'false' })
3316
+ try { $PyCrWaveDerive | & $py - } finally {
3317
+ Remove-Item Env:AW_CR, Env:AW_FD, Env:AW_CTX, Env:AW_STAMP -ErrorAction SilentlyContinue
3318
+ }
3319
+ }
3320
+
3321
+ $PyCrRebase = @'
3322
+ import glob, json, os, re
3323
+
3324
+ cr = os.environ["AW_CR"]
3325
+ fd = os.environ["AW_FD"]
3326
+ ctx = os.environ["AW_CTX"]
3327
+ stamp = os.environ.get("AW_STAMP", "") == "true"
3328
+
3329
+ CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
3330
+ TICK_RX = re.compile(r"`([^`]+)`")
3331
+
3332
+
3333
+ def read(path):
3334
+ try:
3335
+ return open(path, encoding="utf-8").read()
3336
+ except Exception:
3337
+ return ""
3338
+
3339
+
3340
+ def header(text, name):
3341
+ m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), text, re.M)
3342
+ if not m:
3343
+ return ""
3344
+ v = m.group(1).strip()
3345
+ return "" if (v.startswith("[") and v.endswith("]")) else v
3346
+
3347
+
3348
+ def norm(v):
3349
+ v = (v or "").strip()
3350
+ return "" if not v else (v if v.startswith("v") else "v" + v)
3351
+
3352
+
3353
+ def as_tuple(v):
3354
+ parts = re.findall(r"\d+", v or "")
3355
+ return tuple(int(p) for p in parts) if parts else None
3356
+
3357
+
3358
+ def aggregates(text):
3359
+ """The `**Aggregates touched**:` header — the list finalize-delta opens sections for."""
3360
+ m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
3361
+ if not m:
3362
+ return set()
3363
+ value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
3364
+ names = TICK_RX.findall(value)
3365
+ if not names:
3366
+ names = [p.strip() for p in re.split(r"[,;/&]|\band\b", value) if p.strip()]
3367
+ names = [re.match(r"[A-Za-z_][A-Za-z0-9_]*", n).group(0)
3368
+ for n in names if re.match(r"[A-Za-z_][A-Za-z0-9_]*", n)]
3369
+ return {n for n in names if not n.startswith(("<", "[")) and not re.match(r"(?i)none\b", n)}
3370
+
3371
+
3372
+ def landed_since(text, base):
3373
+ """CR ids in Change History rows whose version is ABOVE `base` — what moved the master
3374
+ since this delta was stamped. A row with no readable version counts, because a row this
3375
+ cannot place is one it cannot prove is older."""
3376
+ ids, base_t = [], as_tuple(base)
3377
+ for line in text.split("\n"):
3378
+ if not line.strip().startswith("|"):
3379
+ continue
3380
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
3381
+ if len(cells) < 2 or set("".join(cells)) <= set("-: "):
3382
+ continue
3383
+ row_t = as_tuple(cells[0]) if re.match(r"^v?\d", cells[0]) else None
3384
+ if row_t is None:
3385
+ continue
3386
+ if base_t is not None and row_t <= base_t:
3387
+ continue
3388
+ for i in CR_RX.findall(line):
3389
+ if i != cr and i not in ids:
3390
+ ids.append(i)
3391
+ return ids
3392
+
3393
+
3394
+ def model_delta_of(cr_id):
3395
+ """A change's model half: beside the master while open, in its archive once closed."""
3396
+ p = os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)
3397
+ if os.path.isfile(p):
3398
+ return read(p)
3399
+ for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
3400
+ a = os.path.join(ctx, feat, "changes", "archive", cr_id, "domain-model.delta.md")
3401
+ if os.path.isfile(a):
3402
+ return read(a)
3403
+ return ""
3404
+
3405
+
3406
+ my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
3407
+ my_model = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
3408
+ spec_master = os.path.join(fd, "spec.md")
3409
+ model_master = os.path.join(ctx, "domain-model.md")
3410
+
3411
+ spec_text, model_text = read(my_spec), read(my_model)
3412
+ base_version = norm(header(spec_text, "Base version"))
3413
+ base_ctx = norm(header(model_text, "Base context version"))
3414
+
3415
+ current = ""
3416
+ try:
3417
+ current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
3418
+ except Exception:
3419
+ current = ""
3420
+ if not current:
3421
+ current = norm(header(read(spec_master), "Version"))
3422
+ current_ctx = norm(header(read(model_master), "Version"))
3423
+
3424
+ moved = bool(base_version and current and base_version != current)
3425
+ ctx_moved = bool(base_ctx and current_ctx and base_ctx != current_ctx)
3426
+
3427
+ # WHAT LANDED IN BETWEEN, and whether any of it touched what this delta touches. A delta
3428
+ # whose aggregates nobody else moved still applies to the master it was written against;
3429
+ # one whose aggregates a landed change rewrote does not, and no stamp can make it.
3430
+ mine = aggregates(model_text)
3431
+ intervening = landed_since(read(spec_master), base_version) if moved else []
3432
+ if ctx_moved:
3433
+ for i in landed_since(read(model_master), base_ctx):
3434
+ if i not in intervening:
3435
+ intervening.append(i)
3436
+
3437
+ rows, conflicts = [], []
3438
+ for i in intervening:
3439
+ theirs = aggregates(model_delta_of(i))
3440
+ shared = sorted(mine & theirs) if (mine and theirs) else []
3441
+ # Unknown aggregates fail CLOSED: a change this cannot read is one it cannot clear.
3442
+ unknown = not theirs
3443
+ rows.append({"cr_id": i, "aggregates": sorted(theirs), "shared": shared, "unknown": unknown})
3444
+ if shared or unknown:
3445
+ conflicts.append({"cr_id": i, "shared": shared, "unknown": unknown})
3446
+
3447
+ clean = (moved or ctx_moved) and not conflicts
3448
+ stamped = []
3449
+ if stamp and clean:
3450
+ if moved and os.path.isfile(my_spec):
3451
+ open(my_spec, "w", encoding="utf-8").write(
3452
+ re.sub(r"^\*\*Base version\*\*:.*$", "**Base version**: %s" % current,
3453
+ spec_text, count=1, flags=re.M))
3454
+ stamped.append({"file": os.path.basename(my_spec), "header": "Base version",
3455
+ "from": base_version, "to": current})
3456
+ if ctx_moved and os.path.isfile(my_model):
3457
+ open(my_model, "w", encoding="utf-8").write(
3458
+ re.sub(r"^\*\*Base context version\*\*:.*$", "**Base context version**: %s" % current_ctx,
3459
+ model_text, count=1, flags=re.M))
3460
+ stamped.append({"file": os.path.basename(my_model), "header": "Base context version",
3461
+ "from": base_ctx, "to": current_ctx})
3462
+ if stamped:
3463
+ moved = ctx_moved = False
3464
+
3465
+ ok = not (moved or ctx_moved)
3466
+ if not (base_version or base_ctx):
3467
+ msg = "%s carries no base stamp, so there is nothing to compare — treated as not moved" % cr
3468
+ elif ok and not stamped:
3469
+ msg = "%s is stamped against the current masters (%s, context %s)" % (cr, current or "—", current_ctx or "—")
3470
+ elif stamped:
3471
+ msg = "%s was re-stamped: %s. Nothing that landed in between (%s) touched its aggregates (%s)" % (
3472
+ cr, "; ".join("%s %s → %s" % (s["header"], s["from"], s["to"]) for s in stamped),
3473
+ ", ".join(r["cr_id"] for r in rows) or "no recorded change", ", ".join(sorted(mine)) or "none named")
3474
+ else:
3475
+ parts = []
3476
+ for c in conflicts:
3477
+ parts.append("%s (%s)" % (c["cr_id"], "aggregates unreadable" if c["unknown"]
3478
+ else "also touched " + ", ".join(c["shared"])))
3479
+ msg = ("%s was authored against %s%s but the masters are at %s%s, and what landed in "
3480
+ "between overlaps it: %s" % (
3481
+ cr, base_version or "—", "" if not base_ctx else " (context %s)" % base_ctx,
3482
+ current or "—", "" if not current_ctx else " (context %s)" % current_ctx,
3483
+ "; ".join(parts)))
3484
+
3485
+ print(json.dumps({
3486
+ "cr_id": cr, "ok": ok, "clean": clean, "moved": moved, "context_moved": ctx_moved,
3487
+ "base_version": base_version, "current_version": current,
3488
+ "base_context_version": base_ctx, "current_context_version": current_ctx,
3489
+ "aggregates": sorted(mine), "landed_since": rows,
3490
+ "conflicts": conflicts, "conflict_count": len(conflicts),
3491
+ "conflict_ids": ", ".join(c["cr_id"] for c in conflicts),
3492
+ "stamped": stamped, "message": msg,
3493
+ }))
3494
+ '@
3495
+
3496
+
3497
+ # RE-STAMP a change's base versions when nothing that landed in between touched its
3498
+ # aggregates. Twin of the bash cmd_cr_rebase, running the SAME python payload — see the
3499
+ # bash comment for what "applies cleanly" means and why an unreadable delta fails closed.
3500
+ function Invoke-CrRebase {
3501
+ param([string[]]$Rest)
3502
+ $crId = ''; $fdArg = ''; $stamp = $false
3503
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3504
+ switch ($Rest[$i]) {
3505
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3506
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3507
+ { $_ -in '--stamp','-Stamp' } { $stamp = $true }
3508
+ }
3509
+ }
3510
+ $crId = Get-CrIdToken -Raw $crId
3511
+ if (-not $crId) { Write-Error 'cr-rebase: --cr-id is required'; exit 1 }
3512
+ $py = Get-Py
3513
+ if (-not $py) { Write-Error 'cr-rebase: python3 is required'; exit 1 }
3514
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3515
+ if (-not $fd) { Write-Error 'cr-rebase: no feature set and no --feature-dir'; exit 1 }
3516
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3517
+ $env:AW_CR = $crId; $env:AW_FD = $fd; $env:AW_CTX = $ctx
3518
+ $env:AW_STAMP = $(if ($stamp) { 'true' } else { 'false' })
3519
+ try { $PyCrRebase | & $py - } finally {
3520
+ Remove-Item Env:AW_CR, Env:AW_FD, Env:AW_CTX, Env:AW_STAMP -ErrorAction SilentlyContinue
3521
+ }
3522
+ }
3523
+
3109
3524
  function Invoke-CrSetup {
3110
3525
  param([string[]]$Rest)
3111
3526
  $crId = ''; $fdArg = ''; $title = ''; $force = ''; $wave = ''
@@ -3568,12 +3983,24 @@ function Invoke-CrMergeCheck {
3568
3983
  # masters that had already shifted under it. An id appearing in spec.md is the on-disk
3569
3984
  # proof that the merge ran (the Change History row finalize-delta appends), the same
3570
3985
  # evidence cr-recall-check reads for the same question.
3571
- $specText = ''
3572
- $specPath = Join-Path $fd 'spec.md'
3573
- if (Test-Path -LiteralPath $specPath) { $specText = (Get-Content -LiteralPath $specPath -Raw) }
3574
- $mergedOpen = @(foreach ($k in ($opens.Keys | Sort-Object)) {
3575
- if ($k -ne $crId -and $specText.Contains($k)) { $opens[$k] }
3576
- })
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
3993
+ $crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
3994
+ if ($pyMerged -and (Test-Path $crstate)) {
3995
+ 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) }
3998
+ } catch {
3999
+ $mergedOpen = @([pscustomobject]@{ cr_id = '(merged-open check failed)'; blocking = $true })
4000
+ }
4001
+ } else {
4002
+ $mergedOpen = @([pscustomobject]@{ cr_id = '(merged-open check unavailable)'; blocking = $true })
4003
+ }
3577
4004
 
3578
4005
  Write-Json ([ordered]@{
3579
4006
  ok = $true; cr_id = $crId; wave = $wave
@@ -3607,6 +4034,7 @@ function Invoke-CrRecallCheck {
3607
4034
  $crId = Get-CrIdToken -Raw $crId
3608
4035
  if (-not $crId) { Write-Error 'cr-recall-check: --cr-id required'; exit 1 }
3609
4036
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4037
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3610
4038
  if (-not $fd) { Write-Error 'cr-recall-check: no feature set and no --feature-dir'; exit 1 }
3611
4039
  $changes = Join-Path $fd 'changes'
3612
4040
  $crDir = Join-Path $changes $crId
@@ -3620,12 +4048,22 @@ function Invoke-CrRecallCheck {
3620
4048
  return 1
3621
4049
  }
3622
4050
 
4051
+ # MERGED = a Change History row whose CR cell IS this change, in this feature's spec or
4052
+ # in the shared context model — aw-crstate owns that test for all three readers of it.
4053
+ # Fails CLOSED: an unreadable helper reports merged, never "never merged", which is the
4054
+ # answer that would let close-delta archive a change nobody merged.
3623
4055
  $spec = Join-Path $fd 'spec.md'
3624
- $merged = $false; $evidence = ''
3625
- if (Test-Path $spec) {
3626
- foreach ($line in (Get-Content $spec)) {
3627
- if ("$line".Contains($crId)) { $merged = $true; $evidence = "$line".Trim(); break }
3628
- }
4056
+ $merged = $true; $evidence = '(merged check unavailable — treated as merged)'
4057
+ $pyMerged = Get-Py
4058
+ $crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
4059
+ if ($pyMerged -and (Test-Path $crstate)) {
4060
+ try {
4061
+ $line = (& $pyMerged $crstate 'merged' '--cr' $crId '--doc' $spec '--doc' (Join-Path $ctx 'domain-model.md') | Select-Object -First 1)
4062
+ if ($line) {
4063
+ $h = $line | ConvertFrom-Json
4064
+ $merged = [bool]$h.merged; $evidence = [string]$h.evidence
4065
+ }
4066
+ } catch { }
3629
4067
  }
3630
4068
 
3631
4069
  $wave = if (Test-Path $crDir) { Get-WaveOf $crDir } else { 1 }
@@ -3669,14 +4107,65 @@ function Invoke-CrRecallCheck {
3669
4107
  # WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
3670
4108
  # from that spec". The question implement depends on is stronger — "were these plans PROVEN
3671
4109
  # against this spec" — and a plan authored from the current spec can still be wrong. So the
3672
- # receipt is written where the proof happens, by analyze-plan, on green, and it hashes every
3673
- # document the gate actually read: the spec, the context's domain model (check 4 reads it)
3674
- # and each lane plan that was present.
4110
+ # receipt is written where the proof happens, by analyze-plan, and it hashes every document
4111
+ # the gate actually read: the spec, the context's domain model (check 4 reads it) and each
4112
+ # lane plan that was present.
4113
+ #
4114
+ # THE RECEIPT CARRIES THE VERDICT, and every master-mode run writes one. A receipt written
4115
+ # only on a clean pass is never written on a feature whose spec misses a domain constraint —
4116
+ # a LIFT-FAILURE analyze-plan may not close — and then the staleness check has nothing to
4117
+ # compare. The verdict splits the gaps by who can close them (plan-gaps):
4118
+ # conformant no gaps
4119
+ # routed-gaps only gaps /appweaver specify must close — implement warns and generates
4120
+ # gaps at least one gap a plan rewrite closes — implement refuses
3675
4121
  #
3676
4122
  # The absence of a receipt is INFORMATION, not an error: it means this feature has never
3677
4123
  # been through the gate, which every tree that predates this op is true of. It is reported
3678
- # as untracked and blocks nothing. Only a receipt that EXISTS and no longer matches is a
3679
- # refusal, because that tree has been gated once and the bytes have moved since.
4124
+ # as untracked and blocks nothing. A receipt that EXISTS refuses when the bytes have moved
4125
+ # since, or when its verdict is `gaps`. A receipt written before the verdict existed was
4126
+ # written on a clean pass, and reads as `conformant`. Twins of the bash ops.
4127
+
4128
+ # Which analyze-plan gaps a plan rewrite can close, and which only /appweaver specify can.
4129
+ # A gap routes to specify when the analyze pass tagged it `route: specify` (a constraint the
4130
+ # spec never lifted, structure domain-model.md does not define, behaviour the spec does not
4131
+ # describe), or when its text opens with LIFT-FAILURE whatever the tag says. Anything else —
4132
+ # an untagged gap included — is a plan gap. Unreadable input is one plan gap: a verdict that
4133
+ # could not be read must not pass.
4134
+ function Invoke-PlanGaps {
4135
+ param([string[]]$Rest)
4136
+ $raw = ''
4137
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
4138
+ switch ($Rest[$i]) { { $_ -in '--gaps','-Gaps' } { $raw = $Rest[++$i] } }
4139
+ }
4140
+ $gaps = @(); $readable = $true
4141
+ if ("$raw".Trim()) {
4142
+ try {
4143
+ $parsed = ConvertFrom-Json -InputObject $raw -NoEnumerate -ErrorAction Stop
4144
+ if ($parsed -is [array]) { $gaps = @($parsed) } else { $readable = $false }
4145
+ } catch { $readable = $false }
4146
+ }
4147
+ $plan = 0; $routed = @()
4148
+ foreach ($g in $gaps) {
4149
+ if ($g -is [System.Management.Automation.PSCustomObject]) {
4150
+ $text = "$($g.gap)".Trim(); $route = "$($g.route)".Trim().ToLower(); $ref = "$($g.spec_ref)".Trim()
4151
+ } else {
4152
+ $text = "$g".Trim(); $route = ''; $ref = ''
4153
+ }
4154
+ if ($text.ToUpper().StartsWith('LIFT-FAILURE') -or $route -eq 'specify') {
4155
+ $routed += $(if ($ref -and $ref -ne '—') { '{0}: {1}' -f $ref, $text } else { $text })
4156
+ } else {
4157
+ $plan++
4158
+ }
4159
+ }
4160
+ if (-not $readable) { $plan++ }
4161
+ $verdict = if ($plan -gt 0) { 'gaps' } elseif ($routed.Count -gt 0) { 'routed-gaps' } else { 'conformant' }
4162
+ Write-Json ([ordered]@{
4163
+ ok = $true; readable = $readable; verdict = $verdict; plan_ok = ($plan -eq 0)
4164
+ plan_gaps = $plan; routed_gaps = $routed.Count; routed = @($routed)
4165
+ routed_csv = ($routed -join '; ')
4166
+ })
4167
+ }
4168
+
3680
4169
  function Get-FileSha {
3681
4170
  # -LiteralPath throughout: -Path reads [ and ] as a wildcard character class, so a
3682
4171
  # repo living under a bracketed directory name would silently hash nothing.
@@ -3687,14 +4176,27 @@ function Get-FileSha {
3687
4176
 
3688
4177
  function Invoke-PlanGateStamp {
3689
4178
  param([string[]]$Rest)
3690
- $fdArg = ''; $specVersion = ''; $mode = 'master'
4179
+ $fdArg = ''; $specVersion = ''; $mode = 'master'; $verdict = 'conformant'; $planGaps = '0'; $routedRaw = '[]'
3691
4180
  for ($i = 0; $i -lt $Rest.Count; $i++) {
3692
4181
  switch ($Rest[$i]) {
3693
4182
  { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3694
4183
  { $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] }
3695
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] }
3696
4188
  }
3697
4189
  }
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 = @() }
3698
4200
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3699
4201
  if (-not $fd) { Write-Error 'plan-gate-stamp: no feature set and no --feature-dir'; exit 1 }
3700
4202
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
@@ -3717,19 +4219,24 @@ function Invoke-PlanGateStamp {
3717
4219
  gated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
3718
4220
  gate_mode = $mode
3719
4221
  spec_version = $specVersion.Trim()
4222
+ verdict = $verdict
4223
+ plan_gaps = $planGapCount
4224
+ routed_gaps = @($routed)
3720
4225
  spec_sha = (Get-FileSha -Path (Join-Path $fd 'spec.md'))
3721
4226
  model_sha = (Get-FileSha -Path (Join-Path $ctx 'domain-model.md'))
3722
4227
  plans = $plans
3723
- note = ('Written by analyze-plan when it PASSED. It records the bytes that were ' +
3724
- 'proven against each other, not a version — the plans'' **Version**: headers ' +
3725
- 'are stamped in lockstep by close-delta and cannot answer this. implement ' +
3726
- 'reads it through plan-gate-check.')
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.')
3727
4233
  }
3728
4234
  $p = Join-Path $fd '.aw-plan-gate.json'
3729
- ($rec | ConvertTo-Json -Depth 6) + "`n" | Set-Content -LiteralPath $p -NoNewline -Encoding utf8
4235
+ (ConvertTo-Json -InputObject $rec -Depth 6) + "`n" | Set-Content -LiteralPath $p -NoNewline -Encoding utf8
3730
4236
  Write-Json ([ordered]@{
3731
4237
  file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); gated = $rec.gated
3732
- spec_version = $rec.spec_version; hashed = @($plans.Keys | Sort-Object)
4238
+ spec_version = $rec.spec_version; verdict = $verdict; plan_gaps = $planGapCount
4239
+ routed_gaps = $routed.Count; hashed = @($plans.Keys | Sort-Object)
3733
4240
  count = $plans.Count; ok = $true
3734
4241
  })
3735
4242
  }
@@ -3750,17 +4257,27 @@ function Invoke-PlanGateCheck {
3750
4257
  }
3751
4258
  $out = [ordered]@{
3752
4259
  file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); tracked = [bool]$rec
3753
- gate_fresh = $false; spec_moved = $false; model_moved = $false
4260
+ gate_fresh = $false; verdict = ''; may_generate = $true; plan_gaps = 0
4261
+ routed_gaps = @(); routed_csv = ''; spec_moved = $false; model_moved = $false
3754
4262
  plans_changed = @(); plans_changed_csv = ''; gated = ''; spec_version = ''
3755
4263
  reason = ''; ok = $true
3756
4264
  }
3757
4265
  if (-not $rec) {
3758
4266
  # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
3759
4267
  # thing to report about a tree that has not run analyze-plan since this op existed.
3760
- $out.reason = 'no .aw-plan-gate.json — this feature has not passed analyze-plan since the gate began leaving a receipt'
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'
3761
4269
  } else {
3762
- $out.gated = [string]$rec.gated
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 })
3763
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 '; ')
3764
4281
  $specSha = [string]$rec.spec_sha
3765
4282
  $out.spec_moved = ($specSha -and $specSha -ne (Get-FileSha -Path (Join-Path $fd 'spec.md')))
3766
4283
  # A model recorded as absent that is still absent has not moved. A context that
@@ -3775,19 +4292,39 @@ function Invoke-PlanGateCheck {
3775
4292
  $out.plans_changed = @($changed)
3776
4293
  $out.plans_changed_csv = ($changed -join ', ')
3777
4294
  $out.gate_fresh = (-not ($out.spec_moved -or $out.model_moved -or $changed.Count -gt 0))
3778
- if ($out.gate_fresh) {
3779
- $out.reason = 'the spec, the model and every gated plan are byte-identical to what analyze-plan passed on {0}' -f $out.gated
3780
- } else {
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) {
3781
4298
  $bits = @()
3782
4299
  if ($out.spec_moved) { $bits += 'spec.md has changed' }
3783
4300
  if ($out.model_moved) { $bits += 'domain-model.md has changed' }
3784
4301
  if ($changed.Count -gt 0) { $bits += ('{0} changed (a re-derive by `plan main`, or a hand edit)' -f ($changed -join ', ')) }
3785
- $out.reason = 'since analyze-plan passed on {0}: {1}' -f $out.gated, ($bits -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
3786
4310
  }
3787
4311
  }
3788
4312
  Write-Json $out
3789
4313
  }
3790
4314
 
4315
+ # Which of a lane plan's elements the generated code realises. One implementation
4316
+ # (python/aw-plan-coverage.py), called identically by both shells — see the bash twin.
4317
+ function Invoke-PlanCoverage {
4318
+ param([string[]]$Rest)
4319
+ $py = Get-Py
4320
+ if (-not $py) { Write-Error 'plan-coverage: python3 is required'; exit 1 }
4321
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-plan-coverage.py'
4322
+ if (-not (Test-Path $core)) { Write-Error "plan-coverage: core not found at $core"; exit 1 }
4323
+ $repo = Get-RepoRoot
4324
+ & $py $core '--repo' $repo @Rest
4325
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4326
+ }
4327
+
3791
4328
  # Stamp a version into the MASTER documents' `**Version**:` headers — the
3792
4329
  # deterministic twin of cr-archive's active_version.json bump. close-delta calls
3793
4330
  # this with the version cr-archive just computed, so all THREE carriers of the
@@ -3917,6 +4454,8 @@ switch ($sub) {
3917
4454
  'cr-overlap' { Invoke-CrOverlap -Rest $rest }
3918
4455
  'cr-activate' { Invoke-CrActivate -Rest $rest }
3919
4456
  'cr-wave' { Invoke-CrWave -Rest $rest }
4457
+ 'cr-wave-derive' { Invoke-CrWaveDerive -Rest $rest }
4458
+ 'cr-rebase' { Invoke-CrRebase -Rest $rest }
3920
4459
  'cr-archive' { Invoke-CrArchive -Rest $rest }
3921
4460
  'cr-recall-check' { Invoke-CrRecallCheck -Rest $rest }
3922
4461
  'cr-merge-check' { Invoke-CrMergeCheck -Rest $rest }
@@ -3925,8 +4464,10 @@ switch ($sub) {
3925
4464
  'context-paths' { Invoke-ContextPaths -Rest $rest }
3926
4465
  'model-scaffold' { Invoke-ModelScaffold -Rest $rest }
3927
4466
  'version-stamp' { Invoke-VersionStamp -Rest $rest }
4467
+ 'plan-gaps' { Invoke-PlanGaps -Rest $rest }
3928
4468
  'plan-gate-stamp' { Invoke-PlanGateStamp -Rest $rest }
3929
4469
  'plan-gate-check' { Invoke-PlanGateCheck -Rest $rest }
4470
+ 'plan-coverage' { Invoke-PlanCoverage -Rest $rest }
3930
4471
  'checklist-read' { Invoke-ChecklistRead -Rest $rest }
3931
4472
  'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
3932
4473
  'detect-project' { Invoke-DetectProject -Rest $rest }