aw-spec-cli 0.1.20 → 0.1.22

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 (62) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  2. package/assets/.appweaver/STACK-PROFILES.md +87 -8
  3. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
  4. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
  5. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +144 -52
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +78 -10
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +283 -25
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +202 -40
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +557 -57
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +101 -1
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
  22. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +772 -216
  23. package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
  24. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +708 -209
  25. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
  26. package/assets/.appweaver/scripts/python/aw-crstate.py +319 -0
  27. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  28. package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
  29. package/assets/.appweaver/scripts/python/aw-payload-parity.py +19 -0
  30. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +503 -0
  31. package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
  32. package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
  33. package/assets/.appweaver/scripts/python/aw-stack.py +257 -28
  34. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  35. package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
  36. package/assets/.appweaver/stack-profiles/angular.json +26 -0
  37. package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
  38. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +39 -0
  39. package/assets/.appweaver/stack-profiles/java-spring.json +20 -0
  40. package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
  41. package/assets/.appweaver/stack-profiles/node-express.json +27 -0
  42. package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
  43. package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
  44. package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
  45. package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
  46. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  47. package/assets/.appweaver/templates/ui-model-template.md +67 -0
  48. package/assets/.appweaver/templates/workflow-model-template.md +62 -0
  49. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  50. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  51. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  52. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  53. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  54. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  55. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  56. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  57. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +114 -0
  58. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  59. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  60. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  61. package/package.json +1 -1
  62. 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
@@ -1918,6 +1946,21 @@ function Get-LabeledVersion {
1918
1946
  }
1919
1947
  if ($vals.Count -gt 0) { return $vals[-1] } else { return $null }
1920
1948
  }
1949
+
1950
+ # The ratified NON-NEGOTIABLE principles. A thin wrapper: the scan lives in
1951
+ # python/aw-principles.py, which BOTH shells invoke, so the list a gate cites cannot
1952
+ # differ between them. Defaults to the two halves specify loads; --file overrides.
1953
+ function Invoke-PrinciplesList {
1954
+ param([string[]]$Rest)
1955
+ $repo = Get-RepoRoot
1956
+ $py = Get-Py
1957
+ if (-not $py) { Write-Error 'principles-list: python3 is required'; exit 1 }
1958
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-principles.py'
1959
+ if (-not (Test-Path $core)) { Write-Error "principles-list: core not found at $core"; exit 1 }
1960
+ & $py $core list --root $repo @Rest
1961
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
1962
+ }
1963
+
1921
1964
  function Invoke-ConstitutionVerify {
1922
1965
  param([string[]]$Rest)
1923
1966
  $file = ''; $template = ''
@@ -2304,7 +2347,12 @@ function Invoke-DeltaPlanScaffold {
2304
2347
  if ($tpl -and (Test-Path $tpl)) { Copy-Item $tpl $target } else { New-Item -ItemType File -Force -Path $target | Out-Null }
2305
2348
  $created = $true
2306
2349
  }
2307
- Set-HeaderStamp -Path $target -Name 'Wave' -Value (Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
2350
+ # The lane plan carries the change's wave so a reader of the plan alone can see where it
2351
+ # sits. It is COPIED from the delta-spec, never decided here — so when the spec has no
2352
+ # readable wave the template's `[N]` is left standing, which is what the merge and recall
2353
+ # fences read as "not ordered yet" and refuse on, with the command that stamps it.
2354
+ $scaffoldWave = Get-WaveOfReadable -FeatureDir $fd -CrId $crId -Repo $repo
2355
+ if ($scaffoldWave) { Set-HeaderStamp -Path $target -Name 'Wave' -Value $scaffoldWave }
2308
2356
  $overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
2309
2357
  $templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
2310
2358
  $tsOut = if ($templateSource) { $templateSource } else { '' }
@@ -2390,7 +2438,8 @@ function Invoke-TestRun {
2390
2438
  switch ($Rest[$i]) {
2391
2439
  { $_ -in '--feature-dir','-FeatureDir' } { $featureDir = $Rest[++$i] }
2392
2440
  { $_ -in '--scope','-Scope' } { $scope = $Rest[++$i] }
2393
- { $_ -in '--also','-Also' } { $also = $Rest[++$i] }
2441
+ # Repeatable, lists UNION — see the bash twin for why the caller passes two.
2442
+ { $_ -in '--also','-Also' } { $v = $Rest[++$i]; $also = if ($also) { "$also,$v" } else { $v } }
2394
2443
  }
2395
2444
  }
2396
2445
  $repo = Get-RepoRoot
@@ -2413,6 +2462,9 @@ function Invoke-TestRun {
2413
2462
 
2414
2463
  $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
2415
2464
  $matched = $false; $scoped = $false; $fenceGreen = $false; $filter = ''
2465
+ # The runner's exit code, kept because 126/127 is every shell's way of saying the
2466
+ # command itself could not be executed — a missing runner, not a failing suite.
2467
+ $rc = 0
2416
2468
  $hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2417
2469
  $hasCsproj = @(Get-ChildItem -LiteralPath $repo -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2418
2470
  $dotnetPresent = ($hasSln -or $hasCsproj)
@@ -2450,6 +2502,16 @@ function Invoke-TestRun {
2450
2502
  fence_green = $dOk
2451
2503
  cmd = $(if ($d -and $d.cmd) { [string]$d.cmd } else { '' })
2452
2504
  log_tail = $dLog
2505
+ # The failing lines of EVERY failing lane, budgeted per lane by run-phase —
2506
+ # what the fix loop reads, in place of a tail that may belong to a lane that
2507
+ # passed. Twin of the bash branch.
2508
+ failing_excerpt = $(if ($d -and $d.failing_excerpt) { [string]$d.failing_excerpt } else { '' })
2509
+ failing_lanes = @(if ($d -and $d.failing_lanes) { $d.failing_lanes } else { @() })
2510
+ # NOTHING ran a suite because no lane's test command is wired up — a different
2511
+ # state from a red suite, and the one a fix loop cannot help with.
2512
+ no_setup = [bool]($d -and $d.no_setup)
2513
+ setup_reason = $(if ($d -and $d.setup_reason) { [string]$d.setup_reason } else { '' })
2514
+ setup_missing = @(if ($d -and $d.setup_missing) { $d.setup_missing } else { @() })
2453
2515
  lanes_run = @(if ($d) { $d.lanes_run } else { @() })
2454
2516
  lanes_skipped = @(if ($d) { $d.lanes_skipped } else { @() })
2455
2517
  })
@@ -2468,16 +2530,16 @@ function Invoke-TestRun {
2468
2530
  } else {
2469
2531
  $filter = ($slices | ForEach-Object { "FullyQualifiedName~$_" }) -join '|'
2470
2532
  $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 }
2533
+ Push-Location $repo; try { $log = (dotnet test --nologo --filter $filter 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
2472
2534
  }
2473
2535
  } else {
2474
2536
  $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 }
2537
+ Push-Location $repo; try { $log = (dotnet test --nologo 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
2476
2538
  }
2477
2539
  } else { $ok = $false }
2478
2540
  } elseif ((Test-Path (Join-Path $repo 'package.json')) -and (Get-Command npm -ErrorAction SilentlyContinue)) {
2479
2541
  $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 }
2542
+ Push-Location $repo; try { $log = (npm test 2>&1 | Out-String); $rc = $LASTEXITCODE; $ok = ($rc -eq 0) } finally { Pop-Location }
2481
2543
  }
2482
2544
 
2483
2545
  # matched = at least one test was actually selected + run. Read from the LOG, never the
@@ -2491,7 +2553,35 @@ function Invoke-TestRun {
2491
2553
  if ($scoped) { $fenceGreen = ($ok -and $matched) } else { $fenceGreen = $ok }
2492
2554
 
2493
2555
  $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 })
2556
+ # The lines that NAME the failure, which a positional tail does not reliably contain:
2557
+ # a .NET run opens with its restore banner and a runner closes with a summary, so the
2558
+ # last 1400 characters can be entirely green while the failure sits above them.
2559
+ $excerpt = ''; $noSetup = $false; $setupReason = ''
2560
+ if (-not $fenceGreen) {
2561
+ $py = Get-Py
2562
+ if ($py) {
2563
+ $helper = Join-Path $PSScriptRoot '..' 'python' 'aw-testlog.py'
2564
+ if (Test-Path $helper) {
2565
+ $label = $tool; if ($cmd) { $label = "$tool · $cmd" }
2566
+ $label = ($label -replace '\s+', ' ')
2567
+ if ($label.Length -gt 80) { $label = $label.Substring(0, 80) }
2568
+ $tmp = [System.IO.Path]::GetTempFileName()
2569
+ try {
2570
+ Set-Content -LiteralPath $tmp -Value $log -NoNewline -Encoding utf8
2571
+ $line = (& $py $helper '--file' $tmp '--label' $label '--exit' "$rc" | Select-Object -First 1)
2572
+ if ($line) {
2573
+ $h = $line | ConvertFrom-Json
2574
+ $excerpt = [string]$h.excerpt
2575
+ # "The command never ran" — no test script, no runner, no test
2576
+ # project. Not a red suite; the callers gate on it.
2577
+ $setupReason = [string]$h.reason
2578
+ $noSetup = [bool]$h.no_setup
2579
+ }
2580
+ } catch { $excerpt = '' } finally { Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue }
2581
+ }
2582
+ }
2583
+ }
2584
+ 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
2585
  }
2496
2586
 
2497
2587
  function Invoke-RunMigrate {
@@ -2645,16 +2735,33 @@ function Get-CrIdToken {
2645
2735
  }
2646
2736
 
2647
2737
  # One change's wave, read from the documents that carry it: the delta-spec first, then the
2648
- # model delta. Both are stamped together, so either answers; 1 is the floor for a change
2649
- # opened before waves existed. Twin of bash _wave_of.
2650
- function Get-WaveOf {
2738
+ # model delta. Both are stamped together, so either answers. EMPTY when neither carries a
2739
+ # readable one — `**Wave**: [N]` is the unfilled template line, so "no wave" is a state that
2740
+ # happens, and a caller who WRITES the answer somewhere must not write a number nobody
2741
+ # derived. A phantom wave is worse than none: the merge and recall fences can see that a
2742
+ # wave is missing and say so, but a 1 invented downstream reads to them as fact.
2743
+ # Twin of bash _wave_of_readable.
2744
+ function Get-WaveOfReadable {
2651
2745
  param([string]$FeatureDir, [string]$CrId, [string]$Repo)
2652
2746
  $ctx = Get-ContextDirForFeature -FeatureDir $FeatureDir -Repo $Repo
2653
2747
  foreach ($f in @((Join-Path $FeatureDir "changes/$CrId/delta-spec.md"), (Join-Path $ctx "domain-model.$CrId.delta.md"))) {
2654
2748
  if (-not (Test-Path $f)) { continue }
2655
2749
  $t = Get-Content $f -Raw -ErrorAction SilentlyContinue
2656
- if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { return "$([int]$Matches[1])" }
2750
+ if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') {
2751
+ $w = [int]$Matches[1]
2752
+ if ($w -ge 1) { return "$w" }
2753
+ }
2657
2754
  }
2755
+ return ''
2756
+ }
2757
+
2758
+ # The same reading with the floor applied, for the readers that must show a number — a
2759
+ # listing has a column to fill and 1 is the floor for a change opened before waves existed.
2760
+ # Twin of bash _wave_of.
2761
+ function Get-WaveOf {
2762
+ param([string]$FeatureDir, [string]$CrId, [string]$Repo)
2763
+ $w = Get-WaveOfReadable -FeatureDir $FeatureDir -CrId $CrId -Repo $Repo
2764
+ if ($w) { return $w }
2658
2765
  return '1'
2659
2766
  }
2660
2767
 
@@ -2960,26 +3067,41 @@ function Invoke-CrPaths {
2960
3067
 
2961
3068
  # ─────────────────────────── cr-overlap ────────────────────────────────────
2962
3069
  # 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
3070
+ # `**Aggregates touched**:` header of each pending model delta. That header names the
3071
+ # `## Aggregate: <Name>` sections of domain-model.md the delta merges into; the delta itself
3072
+ # carries no such headings, the master does. Deterministic on purpose: two deltas collide
3073
+ # when they change the same aggregate, and that is a fact about two files' headers, so a
3074
+ # regex answers it exactly and the same way every run. Early warning, not enforcement — an
2967
3075
  # overlap is ordinary, and what matters is that the second change is PLANNED knowing about
2968
3076
  # the first. The relation follows the wave: a LOWER wave is a predecessor (a dependency to
2969
3077
  # declare), the SAME wave a sibling (a conflict to design around), a HIGHER wave a
2970
3078
  # successor (it plans on top of this change — information, not an obligation). Twin of
2971
3079
  # bash cmd_cr_overlap.
2972
3080
  #
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>".
3081
+ # The aggregate names one model delta declares, in the order its `**Aggregates touched**:`
3082
+ # header lists them — the same list finalize-delta opens master sections for. Backticked
3083
+ # names are the template's form and win when present, so prose around them adds nothing; a
3084
+ # value with no backticks is split on , ; / & "and" and each piece's leading name is taken.
3085
+ # The template placeholder (`[Root]`) and "None — behaviour only" name no aggregate, so an
3086
+ # unfilled or model-free delta reports none rather than colliding with every other change.
2976
3087
  function Get-DeltaAggregates {
2977
3088
  param([string]$Text)
2978
3089
  $out = @()
2979
3090
  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 }
3091
+ $m = [regex]::Match($Text, '(?m)^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$')
3092
+ if (-not $m.Success) { return $out }
3093
+ $value = ([regex]::Replace($m.Groups[1].Value, '<!--.*?(?:-->|$)', '')).Trim()
3094
+ $names = @([regex]::Matches($value, '`([^`]+)`') | ForEach-Object { $_.Groups[1].Value })
3095
+ if ($names.Count -eq 0) {
3096
+ $names = @(foreach ($piece in [regex]::Split($value, '[,;/&]|\band\b')) {
3097
+ $pm = [regex]::Match($piece.Trim(), '^[A-Za-z][A-Za-z0-9_]*')
3098
+ if ($pm.Success) { $pm.Value }
3099
+ })
3100
+ }
3101
+ foreach ($name in $names) {
3102
+ $name = $name.Trim()
3103
+ if (-not $name -or $name.StartsWith('<') -or $name.StartsWith('[') -or
3104
+ [regex]::IsMatch($name, '^none\b', 'IgnoreCase')) { continue }
2983
3105
  if ($out -notcontains $name) { $out += $name }
2984
3106
  }
2985
3107
  return $out
@@ -3106,6 +3228,344 @@ function Invoke-CrWave {
3106
3228
  Write-Json ([ordered]@{ cr_id = $crId; wave = [int]$wave; stamped = $stamped; delta_spec = $dspec; delta_model = $dmodel })
3107
3229
  }
3108
3230
 
3231
+ $PyCrWaveDerive = @'
3232
+ import glob, json, os, re
3233
+
3234
+ cr = os.environ["AW_CR"]
3235
+ fd = os.environ["AW_FD"]
3236
+ ctx = os.environ["AW_CTX"]
3237
+ stamp = os.environ.get("AW_STAMP", "") == "true"
3238
+
3239
+ CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
3240
+ WAVE_RX = re.compile(r"^\*\*Wave\*\*:\s*(\d+)", re.M)
3241
+
3242
+
3243
+ def read(path):
3244
+ try:
3245
+ return open(path, encoding="utf-8").read()
3246
+ except Exception:
3247
+ return ""
3248
+
3249
+
3250
+ def wave_of(cr_id):
3251
+ """A change states its wave in the header of its own delta documents."""
3252
+ for p in (os.path.join(ctx, "domain-model.%s.delta.md" % cr_id),
3253
+ os.path.join(fd, "changes", cr_id, "delta-spec.md")):
3254
+ m = WAVE_RX.search(read(p))
3255
+ if m:
3256
+ return int(m.group(1))
3257
+ for p in glob.glob(os.path.join(glob.escape(os.path.join(fd, "changes", cr_id)), "delta-*.md")):
3258
+ m = WAVE_RX.search(read(p))
3259
+ if m:
3260
+ return int(m.group(1))
3261
+ return None
3262
+
3263
+
3264
+ def depends_ids(text):
3265
+ """The CR ids named under `## Depends on`, up to the next heading of that level."""
3266
+ m = re.search(r"^##\s+Depends on\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
3267
+ if not m:
3268
+ return []
3269
+ body = re.sub(r"<!--.*?-->", " ", m.group(1), flags=re.S)
3270
+ return [i for i in CR_RX.findall(body) if i != cr]
3271
+
3272
+
3273
+ change_dir = os.path.join(fd, "changes", cr)
3274
+ docs = [os.path.join(ctx, "domain-model.%s.delta.md" % cr),
3275
+ os.path.join(change_dir, "delta-spec.md")]
3276
+ docs += sorted(glob.glob(os.path.join(glob.escape(change_dir), "delta-*plan*.md")))
3277
+ present = [p for p in docs if os.path.isfile(p)]
3278
+
3279
+ deps, seen = [], set()
3280
+ for p in present:
3281
+ for i in depends_ids(read(p)):
3282
+ if i not in seen:
3283
+ seen.add(i)
3284
+ deps.append(i)
3285
+
3286
+ known, unknown = [], []
3287
+ for d in deps:
3288
+ w = wave_of(d)
3289
+ (known if w is not None else unknown).append({"cr_id": d, "wave": w})
3290
+ required = max([d["wave"] for d in known], default=0) + 1
3291
+ current = wave_of(cr)
3292
+ ok = current is not None and current >= required
3293
+
3294
+ # A change with NO readable wave is stamped too, not only one sitting too low. The merge
3295
+ # and recall fences refuse on an unordered change and send the operator here, so the one
3296
+ # command they are given has to be able to write the first wave as well as correct a wrong
3297
+ # one — a remedy that declines the case it was named for is no remedy.
3298
+ stamped = []
3299
+ if stamp and (current is None or current < required):
3300
+ line = "**Wave**: %d" % required
3301
+ pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
3302
+ for p in present:
3303
+ text = read(p)
3304
+ if pat.search(text):
3305
+ open(p, "w", encoding="utf-8").write(pat.sub(lambda _m: line, text, count=1))
3306
+ stamped.append(os.path.basename(p))
3307
+ if stamped:
3308
+ current, ok = required, True
3309
+
3310
+ if stamped and deps:
3311
+ msg = "%s depends on %s, so it was raised to wave %d in %s" % (
3312
+ cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
3313
+ required, ", ".join(stamped))
3314
+ elif stamped:
3315
+ msg = "%s declares no predecessor, so it was stamped at wave %d — the floor — in %s" % (
3316
+ cr, required, ", ".join(stamped))
3317
+ elif not deps:
3318
+ msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
3319
+ cr, required, current if current is not None else "no wave")
3320
+ elif ok:
3321
+ msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
3322
+ cr, current, required)
3323
+ else:
3324
+ msg = ("%s is at wave %s but depends on %s, so it must sit at wave %d — a predecessor "
3325
+ "at the same or a higher wave is not ordered behind it at all" % (
3326
+ cr, current, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
3327
+ required))
3328
+ if unknown:
3329
+ msg += ". Named but not found open: %s" % ", ".join(u["cr_id"] for u in unknown)
3330
+
3331
+ print(json.dumps({
3332
+ "cr_id": cr, "wave": current, "required_wave": required, "ok": ok,
3333
+ "depends_on": known, "unknown": unknown, "depends_count": len(deps),
3334
+ "stamped": stamped, "documents": [os.path.basename(p) for p in present],
3335
+ "message": msg,
3336
+ }))
3337
+ '@
3338
+
3339
+ # THE WAVE A CHANGE'S OWN DOCUMENTS IMPLY, from their `## Depends on` sections. Twin of the
3340
+ # bash cmd_cr_wave_derive, running the SAME python payload — see the bash comment for why
3341
+ # the declaration, not cr-setup's default, is the ordering authority.
3342
+ function Invoke-CrWaveDerive {
3343
+ param([string[]]$Rest)
3344
+ $crId = ''; $fdArg = ''; $stamp = $false
3345
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3346
+ switch ($Rest[$i]) {
3347
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3348
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3349
+ { $_ -in '--stamp','-Stamp' } { $stamp = $true }
3350
+ }
3351
+ }
3352
+ $crId = Get-CrIdToken -Raw $crId
3353
+ if (-not $crId) { Write-Error 'cr-wave-derive: --cr-id is required'; exit 1 }
3354
+ $py = Get-Py
3355
+ if (-not $py) { Write-Error 'cr-wave-derive: python3 is required'; exit 1 }
3356
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3357
+ if (-not $fd) { Write-Error 'cr-wave-derive: no feature set and no --feature-dir'; exit 1 }
3358
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3359
+ $env:AW_CR = $crId; $env:AW_FD = $fd; $env:AW_CTX = $ctx
3360
+ $env:AW_STAMP = $(if ($stamp) { 'true' } else { 'false' })
3361
+ try { $PyCrWaveDerive | & $py - } finally {
3362
+ Remove-Item Env:AW_CR, Env:AW_FD, Env:AW_CTX, Env:AW_STAMP -ErrorAction SilentlyContinue
3363
+ }
3364
+ }
3365
+
3366
+ $PyCrRebase = @'
3367
+ import glob, json, os, re
3368
+
3369
+ cr = os.environ["AW_CR"]
3370
+ fd = os.environ["AW_FD"]
3371
+ ctx = os.environ["AW_CTX"]
3372
+ stamp = os.environ.get("AW_STAMP", "") == "true"
3373
+
3374
+ CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
3375
+ TICK_RX = re.compile(r"`([^`]+)`")
3376
+
3377
+
3378
+ def read(path):
3379
+ try:
3380
+ return open(path, encoding="utf-8").read()
3381
+ except Exception:
3382
+ return ""
3383
+
3384
+
3385
+ def header(text, name):
3386
+ m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), text, re.M)
3387
+ if not m:
3388
+ return ""
3389
+ v = m.group(1).strip()
3390
+ return "" if (v.startswith("[") and v.endswith("]")) else v
3391
+
3392
+
3393
+ def norm(v):
3394
+ v = (v or "").strip()
3395
+ return "" if not v else (v if v.startswith("v") else "v" + v)
3396
+
3397
+
3398
+ def as_tuple(v):
3399
+ parts = re.findall(r"\d+", v or "")
3400
+ return tuple(int(p) for p in parts) if parts else None
3401
+
3402
+
3403
+ def aggregates(text):
3404
+ """The `**Aggregates touched**:` header — the list finalize-delta opens sections for."""
3405
+ m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
3406
+ if not m:
3407
+ return set()
3408
+ value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
3409
+ names = TICK_RX.findall(value)
3410
+ if not names:
3411
+ names = [p.strip() for p in re.split(r"[,;/&]|\band\b", value) if p.strip()]
3412
+ names = [re.match(r"[A-Za-z_][A-Za-z0-9_]*", n).group(0)
3413
+ for n in names if re.match(r"[A-Za-z_][A-Za-z0-9_]*", n)]
3414
+ return {n for n in names if not n.startswith(("<", "[")) and not re.match(r"(?i)none\b", n)}
3415
+
3416
+
3417
+ def landed_since(text, base):
3418
+ """CR ids in Change History rows whose version is ABOVE `base` — what moved the master
3419
+ since this delta was stamped. A row with no readable version counts, because a row this
3420
+ cannot place is one it cannot prove is older."""
3421
+ ids, base_t = [], as_tuple(base)
3422
+ for line in text.split("\n"):
3423
+ if not line.strip().startswith("|"):
3424
+ continue
3425
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
3426
+ if len(cells) < 2 or set("".join(cells)) <= set("-: "):
3427
+ continue
3428
+ row_t = as_tuple(cells[0]) if re.match(r"^v?\d", cells[0]) else None
3429
+ if row_t is None:
3430
+ continue
3431
+ if base_t is not None and row_t <= base_t:
3432
+ continue
3433
+ for i in CR_RX.findall(line):
3434
+ if i != cr and i not in ids:
3435
+ ids.append(i)
3436
+ return ids
3437
+
3438
+
3439
+ def model_delta_of(cr_id):
3440
+ """A change's model half: beside the master while open, in its archive once closed."""
3441
+ p = os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)
3442
+ if os.path.isfile(p):
3443
+ return read(p)
3444
+ for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
3445
+ a = os.path.join(ctx, feat, "changes", "archive", cr_id, "domain-model.delta.md")
3446
+ if os.path.isfile(a):
3447
+ return read(a)
3448
+ return ""
3449
+
3450
+
3451
+ my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
3452
+ my_model = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
3453
+ spec_master = os.path.join(fd, "spec.md")
3454
+ model_master = os.path.join(ctx, "domain-model.md")
3455
+
3456
+ spec_text, model_text = read(my_spec), read(my_model)
3457
+ base_version = norm(header(spec_text, "Base version"))
3458
+ base_ctx = norm(header(model_text, "Base context version"))
3459
+
3460
+ current = ""
3461
+ try:
3462
+ current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
3463
+ except Exception:
3464
+ current = ""
3465
+ if not current:
3466
+ current = norm(header(read(spec_master), "Version"))
3467
+ current_ctx = norm(header(read(model_master), "Version"))
3468
+
3469
+ moved = bool(base_version and current and base_version != current)
3470
+ ctx_moved = bool(base_ctx and current_ctx and base_ctx != current_ctx)
3471
+
3472
+ # WHAT LANDED IN BETWEEN, and whether any of it touched what this delta touches. A delta
3473
+ # whose aggregates nobody else moved still applies to the master it was written against;
3474
+ # one whose aggregates a landed change rewrote does not, and no stamp can make it.
3475
+ mine = aggregates(model_text)
3476
+ intervening = landed_since(read(spec_master), base_version) if moved else []
3477
+ if ctx_moved:
3478
+ for i in landed_since(read(model_master), base_ctx):
3479
+ if i not in intervening:
3480
+ intervening.append(i)
3481
+
3482
+ rows, conflicts = [], []
3483
+ for i in intervening:
3484
+ theirs = aggregates(model_delta_of(i))
3485
+ shared = sorted(mine & theirs) if (mine and theirs) else []
3486
+ # Unknown aggregates fail CLOSED: a change this cannot read is one it cannot clear.
3487
+ unknown = not theirs
3488
+ rows.append({"cr_id": i, "aggregates": sorted(theirs), "shared": shared, "unknown": unknown})
3489
+ if shared or unknown:
3490
+ conflicts.append({"cr_id": i, "shared": shared, "unknown": unknown})
3491
+
3492
+ clean = (moved or ctx_moved) and not conflicts
3493
+ stamped = []
3494
+ if stamp and clean:
3495
+ if moved and os.path.isfile(my_spec):
3496
+ open(my_spec, "w", encoding="utf-8").write(
3497
+ re.sub(r"^\*\*Base version\*\*:.*$", "**Base version**: %s" % current,
3498
+ spec_text, count=1, flags=re.M))
3499
+ stamped.append({"file": os.path.basename(my_spec), "header": "Base version",
3500
+ "from": base_version, "to": current})
3501
+ if ctx_moved and os.path.isfile(my_model):
3502
+ open(my_model, "w", encoding="utf-8").write(
3503
+ re.sub(r"^\*\*Base context version\*\*:.*$", "**Base context version**: %s" % current_ctx,
3504
+ model_text, count=1, flags=re.M))
3505
+ stamped.append({"file": os.path.basename(my_model), "header": "Base context version",
3506
+ "from": base_ctx, "to": current_ctx})
3507
+ if stamped:
3508
+ moved = ctx_moved = False
3509
+
3510
+ ok = not (moved or ctx_moved)
3511
+ if not (base_version or base_ctx):
3512
+ msg = "%s carries no base stamp, so there is nothing to compare — treated as not moved" % cr
3513
+ elif ok and not stamped:
3514
+ msg = "%s is stamped against the current masters (%s, context %s)" % (cr, current or "—", current_ctx or "—")
3515
+ elif stamped:
3516
+ msg = "%s was re-stamped: %s. Nothing that landed in between (%s) touched its aggregates (%s)" % (
3517
+ cr, "; ".join("%s %s → %s" % (s["header"], s["from"], s["to"]) for s in stamped),
3518
+ ", ".join(r["cr_id"] for r in rows) or "no recorded change", ", ".join(sorted(mine)) or "none named")
3519
+ else:
3520
+ parts = []
3521
+ for c in conflicts:
3522
+ parts.append("%s (%s)" % (c["cr_id"], "aggregates unreadable" if c["unknown"]
3523
+ else "also touched " + ", ".join(c["shared"])))
3524
+ msg = ("%s was authored against %s%s but the masters are at %s%s, and what landed in "
3525
+ "between overlaps it: %s" % (
3526
+ cr, base_version or "—", "" if not base_ctx else " (context %s)" % base_ctx,
3527
+ current or "—", "" if not current_ctx else " (context %s)" % current_ctx,
3528
+ "; ".join(parts)))
3529
+
3530
+ print(json.dumps({
3531
+ "cr_id": cr, "ok": ok, "clean": clean, "moved": moved, "context_moved": ctx_moved,
3532
+ "base_version": base_version, "current_version": current,
3533
+ "base_context_version": base_ctx, "current_context_version": current_ctx,
3534
+ "aggregates": sorted(mine), "landed_since": rows,
3535
+ "conflicts": conflicts, "conflict_count": len(conflicts),
3536
+ "conflict_ids": ", ".join(c["cr_id"] for c in conflicts),
3537
+ "stamped": stamped, "message": msg,
3538
+ }))
3539
+ '@
3540
+
3541
+
3542
+ # RE-STAMP a change's base versions when nothing that landed in between touched its
3543
+ # aggregates. Twin of the bash cmd_cr_rebase, running the SAME python payload — see the
3544
+ # bash comment for what "applies cleanly" means and why an unreadable delta fails closed.
3545
+ function Invoke-CrRebase {
3546
+ param([string[]]$Rest)
3547
+ $crId = ''; $fdArg = ''; $stamp = $false
3548
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3549
+ switch ($Rest[$i]) {
3550
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3551
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3552
+ { $_ -in '--stamp','-Stamp' } { $stamp = $true }
3553
+ }
3554
+ }
3555
+ $crId = Get-CrIdToken -Raw $crId
3556
+ if (-not $crId) { Write-Error 'cr-rebase: --cr-id is required'; exit 1 }
3557
+ $py = Get-Py
3558
+ if (-not $py) { Write-Error 'cr-rebase: python3 is required'; exit 1 }
3559
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3560
+ if (-not $fd) { Write-Error 'cr-rebase: no feature set and no --feature-dir'; exit 1 }
3561
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3562
+ $env:AW_CR = $crId; $env:AW_FD = $fd; $env:AW_CTX = $ctx
3563
+ $env:AW_STAMP = $(if ($stamp) { 'true' } else { 'false' })
3564
+ try { $PyCrRebase | & $py - } finally {
3565
+ Remove-Item Env:AW_CR, Env:AW_FD, Env:AW_CTX, Env:AW_STAMP -ErrorAction SilentlyContinue
3566
+ }
3567
+ }
3568
+
3109
3569
  function Invoke-CrSetup {
3110
3570
  param([string[]]$Rest)
3111
3571
  $crId = ''; $fdArg = ''; $title = ''; $force = ''; $wave = ''
@@ -3502,13 +3962,6 @@ function Invoke-CrMergeCheck {
3502
3962
  if (-not $fd) { Write-Error 'cr-merge-check: no feature set and no --feature-dir'; exit 1 }
3503
3963
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3504
3964
 
3505
- function Get-WaveFile([string]$path) {
3506
- if (Test-Path -LiteralPath $path) {
3507
- $m = [regex]::Match((Get-Content -LiteralPath $path -Raw), '(?m)^\*\*Wave\*\*:[ \t]*(\d+)')
3508
- if ($m.Success) { return [int]$m.Groups[1].Value }
3509
- }
3510
- return 1
3511
- }
3512
3965
  function Format-Ver([string]$v) {
3513
3966
  $v = "$v".Trim()
3514
3967
  if (-not $v) { return '' }
@@ -3516,7 +3969,6 @@ function Invoke-CrMergeCheck {
3516
3969
  }
3517
3970
 
3518
3971
  $mySpec = Join-Path $fd "changes/$crId/delta-spec.md"
3519
- $wave = Get-WaveFile $mySpec
3520
3972
  $baseVersion = Format-Ver (Get-MdHeader -Path $mySpec -Name 'Base version')
3521
3973
 
3522
3974
  # The master's CURRENT version, from the ledger — the same authority version-read
@@ -3532,51 +3984,59 @@ function Invoke-CrMergeCheck {
3532
3984
  # is absence of evidence and is not treated as a move.
3533
3985
  $rebaseNeeded = [bool]($baseVersion -and $current -and ($baseVersion -ne $current))
3534
3986
 
3535
- # EVERY open change in the context, both halves. changes/<id>/ under each feature is the
3536
- # authority for "open"; the pending model deltas are unioned in so a model-only artifact
3537
- # of a half-built change is still seen.
3538
- $opens = [ordered]@{}
3539
- if (Test-Path -LiteralPath $ctx) {
3540
- foreach ($feat in (Get-ChildItem -LiteralPath $ctx -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
3541
- $ch = Join-Path $feat.FullName 'changes'
3542
- if (-not (Test-Path -LiteralPath $ch)) { continue }
3543
- foreach ($d in (Get-ChildItem -LiteralPath $ch -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
3544
- if ($d.Name -eq 'archive') { continue }
3545
- if (-not $opens.Contains($d.Name)) {
3546
- $opens[$d.Name] = [ordered]@{ cr_id = $d.Name
3547
- wave = (Get-WaveFile (Join-Path $d.FullName 'delta-spec.md'))
3548
- feature = $feat.Name }
3549
- }
3550
- }
3551
- }
3552
- foreach ($f in (Get-ChildItem -LiteralPath $ctx -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
3553
- $m = [regex]::Match($f.Name, '^domain-model\.(.+)\.delta\.md$')
3554
- if (-not $m.Success) { continue }
3555
- $other = $m.Groups[1].Value
3556
- if (-not $opens.Contains($other)) {
3557
- $opens[$other] = [ordered]@{ cr_id = $other; wave = (Get-WaveFile $f.FullName); feature = '' }
3987
+ # BOTH CONTEXT-WIDE QUESTIONS COME FROM aw-crstate, the same module the bash payload
3988
+ # loads, so the two shells cannot answer them differently — which they did while each
3989
+ # carried its own copy of the scan.
3990
+ #
3991
+ # PREDECESSORS. Every open change in the context, both halves: changes/<id>/ under each
3992
+ # feature is the authority for "open", and the pending model deltas are unioned in so a
3993
+ # model-only artifact of a half-built change is still seen. A change whose `**Wave**:`
3994
+ # is unreadable is reported as unordered rather than assumed to be wave 1.
3995
+ #
3996
+ # MERGED-OPEN. The masters can move without the version moving: finalize-delta merges
3997
+ # and close-delta bumps, so between the two spec.md carries a change the number does not
3998
+ # yet reflect, and a second delta sharing the same base_version would compare equal and
3999
+ # merge onto masters that had already shifted under it. A change is MERGED when its id
4000
+ # is the CR cell of a Change History row — in this feature's spec or in the context
4001
+ # model every feature shares, so a SIBLING feature's merged-unclosed change is no longer
4002
+ # invisible. A sibling blocks only where it touched an aggregate this delta also names.
4003
+ #
4004
+ # Fails CLOSED: an unreadable helper reports blocking and nothing ordered.
4005
+ $py = Get-Py
4006
+ $crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
4007
+ # `answered` is what says the reader ran, not the shape of what it returned: NO blocking
4008
+ # merged-open change is an empty array, which is exactly what a failed read looks like.
4009
+ $order = $null; $mergedOpen = @(); $mergedText = ''; $answered = $false
4010
+ $why = 'python or aw-crstate.py not found'
4011
+ if ($py -and (Test-Path $crstate)) {
4012
+ try {
4013
+ $line = (& $py $crstate 'wave-order' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx '--direction' 'lower' '--scope' 'context' | Select-Object -First 1)
4014
+ if ($line) { $order = ($line | ConvertFrom-Json) }
4015
+ $line = (& $py $crstate 'merged-open' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx | Select-Object -First 1)
4016
+ if ($line) {
4017
+ $parsed = ($line | ConvertFrom-Json)
4018
+ $mergedOpen = @($parsed.blocking); $mergedText = "$($parsed.blocking_text)"; $answered = $true
3558
4019
  }
4020
+ } catch {
4021
+ $order = $null; $answered = $false; $why = "$_"
3559
4022
  }
3560
4023
  }
3561
- $blockers = @(foreach ($k in ($opens.Keys | Sort-Object)) {
3562
- if ($k -ne $crId -and $opens[$k].wave -lt $wave) { $opens[$k] }
3563
- })
3564
-
3565
- # THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
3566
- # bumps, so between the two spec.md carries a change the number does not yet reflect —
3567
- # and a second delta sharing the same base_version would compare equal and merge onto
3568
- # masters that had already shifted under it. An id appearing in spec.md is the on-disk
3569
- # proof that the merge ran (the Change History row finalize-delta appends), the same
3570
- # 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
- })
4024
+ if (-not $order) {
4025
+ $order = [pscustomobject]@{ wave = $null; wave_known = $false; related = @(); related_count = 0
4026
+ related_ids = ''; unknown = @(); unknown_count = 0; unknown_ids = ''
4027
+ unknown_blocks = $true; wave_ordering_ok = $false
4028
+ wave_problem = "the wave reader could not be run ($why), so no open change in this context is ordered against $crId at all" }
4029
+ }
4030
+ if (-not $answered) {
4031
+ $mergedOpen = @([pscustomobject]@{ cr_id = "(merged-open check failed: $why)"; blocking = $true })
4032
+ $mergedText = $mergedOpen[0].cr_id
4033
+ }
4034
+ $blockers = @($order.related)
3577
4035
 
3578
4036
  Write-Json ([ordered]@{
3579
- ok = $true; cr_id = $crId; wave = $wave
4037
+ ok = $true; cr_id = $crId
4038
+ wave = $(if ($null -ne $order.wave) { $order.wave } else { '' })
4039
+ wave_known = [bool]$order.wave_known
3580
4040
  exists = (Test-Path -LiteralPath (Join-Path $fd "changes/$crId"))
3581
4041
  # REBASE half — the masters moved because a change CLOSED and bumped the number.
3582
4042
  base_version = $baseVersion; current_version = $current
@@ -3584,14 +4044,19 @@ function Invoke-CrMergeCheck {
3584
4044
  base_stamped = [bool]$baseVersion
3585
4045
  blockers = @($blockers); blocker_count = @($blockers).Count
3586
4046
  has_blockers = (@($blockers).Count -gt 0)
3587
- blocker_ids = (@($blockers | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
4047
+ blocker_ids = $order.related_ids
4048
+ # The changes this one cannot be ordered against at all, in either direction.
4049
+ unordered = @($order.unknown); unordered_count = $order.unknown_count
4050
+ unordered_ids = $order.unknown_ids
4051
+ wave_ordering_ok = [bool]$order.wave_ordering_ok
4052
+ wave_problem = $order.wave_problem
3588
4053
  # The other way the masters move: a change MERGED and has not closed yet.
3589
4054
  merged_open = @($mergedOpen); merged_open_count = @($mergedOpen).Count
3590
4055
  has_merged_open = (@($mergedOpen).Count -gt 0)
3591
- merged_open_ids = (@($mergedOpen | ForEach-Object { $_.cr_id }) -join ', ')
3592
- # All three refusals as ONE positive flag, because the engine takes no compound
3593
- # condition and a gate reading three booleans would need three passes over one question.
3594
- merge_ok = ((-not $rebaseNeeded) -and (@($blockers).Count -eq 0) -and (@($mergedOpen).Count -eq 0))
4056
+ merged_open_ids = $mergedText
4057
+ # All four refusals as ONE positive flag, because the engine takes no compound
4058
+ # condition and a gate reading four booleans would need four passes over one question.
4059
+ merge_ok = ((-not $rebaseNeeded) -and [bool]$order.wave_ordering_ok -and (@($blockers).Count -eq 0) -and (@($mergedOpen).Count -eq 0))
3595
4060
  })
3596
4061
  }
3597
4062
 
@@ -3607,47 +4072,62 @@ function Invoke-CrRecallCheck {
3607
4072
  $crId = Get-CrIdToken -Raw $crId
3608
4073
  if (-not $crId) { Write-Error 'cr-recall-check: --cr-id required'; exit 1 }
3609
4074
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4075
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3610
4076
  if (-not $fd) { Write-Error 'cr-recall-check: no feature set and no --feature-dir'; exit 1 }
3611
4077
  $changes = Join-Path $fd 'changes'
3612
4078
  $crDir = Join-Path $changes $crId
3613
4079
 
3614
- function Get-WaveOf([string]$dir) {
3615
- $p = Join-Path $dir 'delta-spec.md'
3616
- if (Test-Path $p) {
3617
- $m = [regex]::Match((Get-Content $p -Raw), '(?m)^\*\*Wave\*\*:[ \t]*(\d+)')
3618
- if ($m.Success) { return [int]$m.Groups[1].Value }
3619
- }
3620
- return 1
3621
- }
3622
-
4080
+ # MERGED = a Change History row whose CR cell IS this change, in this feature's spec or
4081
+ # in the shared context model — aw-crstate owns that test for all three readers of it.
4082
+ # Fails CLOSED: an unreadable helper reports merged, never "never merged", which is the
4083
+ # answer that would let close-delta archive a change nobody merged.
4084
+ #
4085
+ # DEPENDANTS come from the same module the merge fence reads in the opposite direction.
4086
+ # An unreadable `**Wave**:` is not wave 1 here either: it used to mean the change was
4087
+ # not counted as higher, so recall withdrew the ground from under a dependant it could
4088
+ # not see. Waves have no ceiling, so any unordered change could sit above this one.
3623
4089
  $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
- }
4090
+ $merged = $true; $evidence = '(merged check unavailable — treated as merged)'
4091
+ $py = Get-Py
4092
+ $crstate = Join-Path $PSScriptRoot '..' 'python' 'aw-crstate.py'
4093
+ $order = $null; $why = 'python or aw-crstate.py not found'
4094
+ if ($py -and (Test-Path $crstate)) {
4095
+ try {
4096
+ $line = (& $py $crstate 'merged' '--cr' $crId '--doc' $spec '--doc' (Join-Path $ctx 'domain-model.md') | Select-Object -First 1)
4097
+ if ($line) {
4098
+ $h = $line | ConvertFrom-Json
4099
+ $merged = [bool]$h.merged; $evidence = [string]$h.evidence
4100
+ }
4101
+ $line = (& $py $crstate 'wave-order' '--cr' $crId '--feature-dir' $fd '--context-dir' $ctx '--direction' 'higher' '--scope' 'feature' | Select-Object -First 1)
4102
+ if ($line) { $order = ($line | ConvertFrom-Json) }
4103
+ } catch { $order = $null; $why = "$_" }
3629
4104
  }
3630
-
3631
- $wave = if (Test-Path $crDir) { Get-WaveOf $crDir } else { 1 }
3632
- $dependants = @()
3633
- if (Test-Path $changes) {
3634
- foreach ($d in (Get-ChildItem -LiteralPath $changes -Directory | Sort-Object Name)) {
3635
- if ($d.Name -eq $crId -or $d.Name -eq 'archive') { continue }
3636
- $w = Get-WaveOf $d.FullName
3637
- if ($w -gt $wave) { $dependants += [ordered]@{ cr_id = $d.Name; wave = $w } }
3638
- }
4105
+ if (-not $order) {
4106
+ $order = [pscustomobject]@{ wave = $null; wave_known = $false; related = @(); related_count = 0
4107
+ related_ids = ''; unknown = @(); unknown_count = 0; unknown_ids = ''
4108
+ unknown_blocks = $true; wave_ordering_ok = $false
4109
+ wave_problem = "the wave reader could not be run ($why), so no open change in this feature is ordered against $crId at all" }
3639
4110
  }
4111
+ $dependants = @($order.related)
4112
+
3640
4113
  Write-Json ([ordered]@{
3641
- ok = $true
3642
- cr_id = $crId
3643
- exists = (Test-Path $crDir)
3644
- wave = $wave
3645
- merged = $merged
3646
- merge_evidence = $evidence
3647
- dependants = @($dependants)
3648
- dependant_ids = (@($dependants | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
3649
- dependant_count = @($dependants).Count
3650
- has_dependants = (@($dependants).Count -gt 0)
4114
+ ok = $true
4115
+ cr_id = $crId
4116
+ exists = (Test-Path $crDir)
4117
+ wave = $(if ($null -ne $order.wave) { $order.wave } else { '' })
4118
+ wave_known = [bool]$order.wave_known
4119
+ merged = $merged
4120
+ merge_evidence = $evidence
4121
+ dependants = @($dependants)
4122
+ dependant_ids = $order.related_ids
4123
+ dependant_count = @($dependants).Count
4124
+ has_dependants = (@($dependants).Count -gt 0)
4125
+ # The changes this one cannot be ordered against at all, in either direction.
4126
+ unordered = @($order.unknown)
4127
+ unordered_count = $order.unknown_count
4128
+ unordered_ids = $order.unknown_ids
4129
+ wave_ordering_ok = [bool]$order.wave_ordering_ok
4130
+ wave_problem = $order.wave_problem
3651
4131
  })
3652
4132
  }
3653
4133
 
@@ -3669,14 +4149,65 @@ function Invoke-CrRecallCheck {
3669
4149
  # WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
3670
4150
  # from that spec". The question implement depends on is stronger — "were these plans PROVEN
3671
4151
  # 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.
4152
+ # receipt is written where the proof happens, by analyze-plan, and it hashes every document
4153
+ # the gate actually read: the spec, the context's domain model (check 4 reads it) and each
4154
+ # lane plan that was present.
4155
+ #
4156
+ # THE RECEIPT CARRIES THE VERDICT, and every master-mode run writes one. A receipt written
4157
+ # only on a clean pass is never written on a feature whose spec misses a domain constraint —
4158
+ # a LIFT-FAILURE analyze-plan may not close — and then the staleness check has nothing to
4159
+ # compare. The verdict splits the gaps by who can close them (plan-gaps):
4160
+ # conformant no gaps
4161
+ # routed-gaps only gaps /appweaver specify must close — implement warns and generates
4162
+ # gaps at least one gap a plan rewrite closes — implement refuses
3675
4163
  #
3676
4164
  # The absence of a receipt is INFORMATION, not an error: it means this feature has never
3677
4165
  # 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.
4166
+ # as untracked and blocks nothing. A receipt that EXISTS refuses when the bytes have moved
4167
+ # since, or when its verdict is `gaps`. A receipt written before the verdict existed was
4168
+ # written on a clean pass, and reads as `conformant`. Twins of the bash ops.
4169
+
4170
+ # Which analyze-plan gaps a plan rewrite can close, and which only /appweaver specify can.
4171
+ # A gap routes to specify when the analyze pass tagged it `route: specify` (a constraint the
4172
+ # spec never lifted, structure domain-model.md does not define, behaviour the spec does not
4173
+ # describe), or when its text opens with LIFT-FAILURE whatever the tag says. Anything else —
4174
+ # an untagged gap included — is a plan gap. Unreadable input is one plan gap: a verdict that
4175
+ # could not be read must not pass.
4176
+ function Invoke-PlanGaps {
4177
+ param([string[]]$Rest)
4178
+ $raw = ''
4179
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
4180
+ switch ($Rest[$i]) { { $_ -in '--gaps','-Gaps' } { $raw = $Rest[++$i] } }
4181
+ }
4182
+ $gaps = @(); $readable = $true
4183
+ if ("$raw".Trim()) {
4184
+ try {
4185
+ $parsed = ConvertFrom-Json -InputObject $raw -NoEnumerate -ErrorAction Stop
4186
+ if ($parsed -is [array]) { $gaps = @($parsed) } else { $readable = $false }
4187
+ } catch { $readable = $false }
4188
+ }
4189
+ $plan = 0; $routed = @()
4190
+ foreach ($g in $gaps) {
4191
+ if ($g -is [System.Management.Automation.PSCustomObject]) {
4192
+ $text = "$($g.gap)".Trim(); $route = "$($g.route)".Trim().ToLower(); $ref = "$($g.spec_ref)".Trim()
4193
+ } else {
4194
+ $text = "$g".Trim(); $route = ''; $ref = ''
4195
+ }
4196
+ if ($text.ToUpper().StartsWith('LIFT-FAILURE') -or $route -eq 'specify') {
4197
+ $routed += $(if ($ref -and $ref -ne '—') { '{0}: {1}' -f $ref, $text } else { $text })
4198
+ } else {
4199
+ $plan++
4200
+ }
4201
+ }
4202
+ if (-not $readable) { $plan++ }
4203
+ $verdict = if ($plan -gt 0) { 'gaps' } elseif ($routed.Count -gt 0) { 'routed-gaps' } else { 'conformant' }
4204
+ Write-Json ([ordered]@{
4205
+ ok = $true; readable = $readable; verdict = $verdict; plan_ok = ($plan -eq 0)
4206
+ plan_gaps = $plan; routed_gaps = $routed.Count; routed = @($routed)
4207
+ routed_csv = ($routed -join '; ')
4208
+ })
4209
+ }
4210
+
3680
4211
  function Get-FileSha {
3681
4212
  # -LiteralPath throughout: -Path reads [ and ] as a wildcard character class, so a
3682
4213
  # repo living under a bracketed directory name would silently hash nothing.
@@ -3685,53 +4216,26 @@ function Get-FileSha {
3685
4216
  return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLower()
3686
4217
  }
3687
4218
 
4219
+ # THE RULE ITSELF LIVES IN python/aw-plangate.py, which both shells invoke. It was a set
4220
+ # of sha comparisons written twice — a heredoc in bash, this function natively — and every
4221
+ # change to it had to be re-derived here by hand and kept in step by eye. It changed twice
4222
+ # that way. These two are now thin wrappers: resolve the feature, hand over.
3688
4223
  function Invoke-PlanGateStamp {
3689
4224
  param([string[]]$Rest)
3690
- $fdArg = ''; $specVersion = ''; $mode = 'master'
4225
+ $fdArg = ''; $pass = @()
3691
4226
  for ($i = 0; $i -lt $Rest.Count; $i++) {
3692
- switch ($Rest[$i]) {
3693
- { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3694
- { $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] }
3695
- { $_ -in '--mode','-Mode' } { $mode = $Rest[++$i] }
3696
- }
4227
+ if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$i] }
4228
+ else { $pass += $Rest[$i] }
3697
4229
  }
3698
4230
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3699
4231
  if (-not $fd) { Write-Error 'plan-gate-stamp: no feature set and no --feature-dir'; exit 1 }
3700
4232
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3701
- # Every document the gate READ. A lane that is not declared has no plan on disk and is
3702
- # simply absent from the map — the check treats a name it does not carry as not gated,
3703
- # never as changed, so declaring a second lane later does not read as tampering.
3704
- #
3705
- # THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
3706
- # resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
3707
- # that has not been through `plan` since the rename — every brownfield feature `adopt`
3708
- # produces — is GATED on those documents. Hashing only the new names would leave such a
3709
- # feature with an empty plan map: a receipt that looks tracked and fresh while the document
3710
- # implement actually generates from is unwatched.
3711
- $plans = [ordered]@{}
3712
- foreach ($name in @('backend-plan.md','frontend-plan.md','design.md','design.ui.md')) {
3713
- $h = Get-FileSha -Path (Join-Path $fd $name)
3714
- if ($h) { $plans[$name] = $h }
3715
- }
3716
- $rec = [ordered]@{
3717
- gated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
3718
- gate_mode = $mode
3719
- spec_version = $specVersion.Trim()
3720
- spec_sha = (Get-FileSha -Path (Join-Path $fd 'spec.md'))
3721
- model_sha = (Get-FileSha -Path (Join-Path $ctx 'domain-model.md'))
3722
- 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.')
3727
- }
3728
- $p = Join-Path $fd '.aw-plan-gate.json'
3729
- ($rec | ConvertTo-Json -Depth 6) + "`n" | Set-Content -LiteralPath $p -NoNewline -Encoding utf8
3730
- Write-Json ([ordered]@{
3731
- file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); gated = $rec.gated
3732
- spec_version = $rec.spec_version; hashed = @($plans.Keys | Sort-Object)
3733
- count = $plans.Count; ok = $true
3734
- })
4233
+ $py = Get-Py
4234
+ if (-not $py) { Write-Error 'plan-gate-stamp: python3 is required'; exit 1 }
4235
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-plangate.py'
4236
+ if (-not (Test-Path $core)) { Write-Error "plan-gate-stamp: core not found at $core"; exit 1 }
4237
+ & $py $core stamp --feature-dir $fd --context-dir $ctx --root $repo @pass
4238
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
3735
4239
  }
3736
4240
 
3737
4241
  function Invoke-PlanGateCheck {
@@ -3743,49 +4247,38 @@ function Invoke-PlanGateCheck {
3743
4247
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3744
4248
  if (-not $fd) { Write-Error 'plan-gate-check: no feature set and no --feature-dir'; exit 1 }
3745
4249
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3746
- $p = Join-Path $fd '.aw-plan-gate.json'
3747
- $rec = $null
3748
- if (Test-Path -LiteralPath $p -PathType Leaf) {
3749
- try { $rec = Get-Content -LiteralPath $p -Raw | ConvertFrom-Json } catch { $rec = $null }
3750
- }
3751
- $out = [ordered]@{
3752
- file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); tracked = [bool]$rec
3753
- gate_fresh = $false; spec_moved = $false; model_moved = $false
3754
- plans_changed = @(); plans_changed_csv = ''; gated = ''; spec_version = ''
3755
- reason = ''; ok = $true
3756
- }
3757
- if (-not $rec) {
3758
- # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
3759
- # 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'
3761
- } else {
3762
- $out.gated = [string]$rec.gated
3763
- $out.spec_version = [string]$rec.spec_version
3764
- $specSha = [string]$rec.spec_sha
3765
- $out.spec_moved = ($specSha -and $specSha -ne (Get-FileSha -Path (Join-Path $fd 'spec.md')))
3766
- # A model recorded as absent that is still absent has not moved. A context that
3767
- # GAINED a domain model since the gate has moved: check 4 never ran against it.
3768
- $out.model_moved = ([string]$rec.model_sha -ne (Get-FileSha -Path (Join-Path $ctx 'domain-model.md')))
3769
- $changed = @()
3770
- if ($rec.plans) {
3771
- foreach ($name in @($rec.plans.PSObject.Properties.Name | Sort-Object)) {
3772
- if ((Get-FileSha -Path (Join-Path $fd $name)) -ne [string]$rec.plans.$name) { $changed += $name }
3773
- }
3774
- }
3775
- $out.plans_changed = @($changed)
3776
- $out.plans_changed_csv = ($changed -join ', ')
3777
- $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 {
3781
- $bits = @()
3782
- if ($out.spec_moved) { $bits += 'spec.md has changed' }
3783
- if ($out.model_moved) { $bits += 'domain-model.md has changed' }
3784
- 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 '; ')
3786
- }
3787
- }
3788
- Write-Json $out
4250
+ $py = Get-Py
4251
+ if (-not $py) { Write-Error 'plan-gate-check: python3 is required'; exit 1 }
4252
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-plangate.py'
4253
+ if (-not (Test-Path $core)) { Write-Error "plan-gate-check: core not found at $core"; exit 1 }
4254
+ & $py $core check --feature-dir $fd --context-dir $ctx --root $repo
4255
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4256
+ }
4257
+
4258
+ # Which of a lane plan's elements the generated code realises. One implementation
4259
+ # (python/aw-plan-coverage.py), called identically by both shells — see the bash twin.
4260
+ # Split a gate's findings by the command that closes each one. Twin of the bash
4261
+ # cmd_gap_route, calling the SAME aw-gaps.py — see the bash comment for why a plan
4262
+ # defect must never be routed to `implement`.
4263
+ function Invoke-GapRoute {
4264
+ param([string[]]$Rest)
4265
+ $py = Get-Py
4266
+ if (-not $py) { Write-Error 'gap-route: python3 is required'; exit 1 }
4267
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-gaps.py'
4268
+ if (-not (Test-Path $core)) { Write-Error "gap-route: core not found at $core"; exit 1 }
4269
+ & $py $core route @Rest
4270
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
4271
+ }
4272
+
4273
+ function Invoke-PlanCoverage {
4274
+ param([string[]]$Rest)
4275
+ $py = Get-Py
4276
+ if (-not $py) { Write-Error 'plan-coverage: python3 is required'; exit 1 }
4277
+ $core = Join-Path $PSScriptRoot '..' 'python' 'aw-plan-coverage.py'
4278
+ if (-not (Test-Path $core)) { Write-Error "plan-coverage: core not found at $core"; exit 1 }
4279
+ $repo = Get-RepoRoot
4280
+ & $py $core '--repo' $repo @Rest
4281
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
3789
4282
  }
3790
4283
 
3791
4284
  # Stamp a version into the MASTER documents' `**Version**:` headers — the
@@ -3917,6 +4410,8 @@ switch ($sub) {
3917
4410
  'cr-overlap' { Invoke-CrOverlap -Rest $rest }
3918
4411
  'cr-activate' { Invoke-CrActivate -Rest $rest }
3919
4412
  'cr-wave' { Invoke-CrWave -Rest $rest }
4413
+ 'cr-wave-derive' { Invoke-CrWaveDerive -Rest $rest }
4414
+ 'cr-rebase' { Invoke-CrRebase -Rest $rest }
3920
4415
  'cr-archive' { Invoke-CrArchive -Rest $rest }
3921
4416
  'cr-recall-check' { Invoke-CrRecallCheck -Rest $rest }
3922
4417
  'cr-merge-check' { Invoke-CrMergeCheck -Rest $rest }
@@ -3925,8 +4420,11 @@ switch ($sub) {
3925
4420
  'context-paths' { Invoke-ContextPaths -Rest $rest }
3926
4421
  'model-scaffold' { Invoke-ModelScaffold -Rest $rest }
3927
4422
  'version-stamp' { Invoke-VersionStamp -Rest $rest }
4423
+ 'plan-gaps' { Invoke-PlanGaps -Rest $rest }
3928
4424
  'plan-gate-stamp' { Invoke-PlanGateStamp -Rest $rest }
3929
4425
  'plan-gate-check' { Invoke-PlanGateCheck -Rest $rest }
4426
+ 'plan-coverage' { Invoke-PlanCoverage -Rest $rest }
4427
+ 'gap-route' { Invoke-GapRoute -Rest $rest }
3930
4428
  'checklist-read' { Invoke-ChecklistRead -Rest $rest }
3931
4429
  'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
3932
4430
  'detect-project' { Invoke-DetectProject -Rest $rest }
@@ -3942,6 +4440,7 @@ switch ($sub) {
3942
4440
  'setup-constitution' { Invoke-SetupConstitution -Rest $rest }
3943
4441
  'compose-constitution' { Invoke-ComposeConstitution -Rest $rest }
3944
4442
  'constitution-verify' { Invoke-ConstitutionVerify -Rest $rest }
4443
+ 'principles-list' { Invoke-PrinciplesList -Rest $rest }
3945
4444
  'agent-context-update' { Invoke-AgentContextUpdate -Rest $rest }
3946
4445
  default { Write-Error "Unknown subcommand: $sub"; exit 1 }
3947
4446
  }