aw-spec-cli 0.1.17 → 0.1.19

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 (53) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +45 -21
  2. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +62 -14
  3. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +6 -4
  4. package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +9 -1
  5. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +21 -0
  6. package/assets/.appweaver/orchestrator/orchestrator.py +23 -1
  7. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +18 -11
  8. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -3
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +298 -109
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +161 -34
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +125 -24
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +18 -7
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +182 -39
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +300 -35
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +190 -31
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +93 -8
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +34 -7
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +319 -0
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +125 -24
  20. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +846 -35
  21. package/assets/.appweaver/scripts/bash/common.sh +28 -1
  22. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +843 -46
  23. package/assets/.appweaver/scripts/python/aw-inventory.py +3 -2
  24. package/assets/.appweaver/templates/design-template.md +41 -30
  25. package/assets/.appweaver/templates/spec-template.md +7 -4
  26. package/assets/.appweaver/templates/ui-design-template.md +7 -3
  27. package/assets/guardrails/domain/healthcare/auditing.domain.md +3 -3
  28. package/assets/guardrails/domain/pharma/auditing.domain.md +3 -3
  29. package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
  30. package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
  31. package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
  32. package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
  33. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
  34. package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
  35. package/assets/guardrails/technology/dotnet-webapi/eventing.md +1 -1
  36. package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
  37. package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +1 -1
  38. package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
  39. package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
  40. package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
  41. package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
  42. package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
  43. package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
  44. package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
  45. package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
  46. package/assets/guardrails/technology/nextjs/design-system.md +1 -1
  47. package/assets/guardrails/technology/nextjs/error-handling.md +1 -1
  48. package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
  49. package/assets/guardrails/technology/nextjs/i18n.md +1 -1
  50. package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
  51. package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
  52. package/assets/guardrails/technology/nextjs/state-management.md +1 -1
  53. package/package.json +1 -1
@@ -20,15 +20,39 @@
20
20
  $ErrorActionPreference = 'Stop'
21
21
 
22
22
  # ─────────────────────────── shared helpers ────────────────────────────────
23
+ #
24
+ # A `[` or `]` ANYWHERE IN THE ROOT REFUSES THE RUN, in both shells. Those two characters
25
+ # are a wildcard character class to PowerShell's `-Path` family and to a shell/Python
26
+ # glob, so under such a root a path test answers about a DIFFERENT path — `…/a[b]/x` is
27
+ # looked up as `…/ab/x` — and an enumeration quietly returns nothing. The kit resolves
28
+ # hundreds of paths against this root, and the failure mode is not an error but a wrong
29
+ # answer: a gate that finds no delta reports a clean tree, and a merge that finds no
30
+ # section merges nothing and says it succeeded.
31
+ #
32
+ # The per-call fixes are in (-LiteralPath on every enumeration here; glob.escape on every
33
+ # pattern's directory in the bash twin), and they cover every site the kit resolves through
34
+ # a pattern. What they cannot cover is the ~150 plain existence tests, and a half-guarded
35
+ # root is worse than a refused one because it looks like it works. So the root itself is
36
+ # the gate, checked once, naming the path.
37
+ function Assert-UnbracketedRoot {
38
+ param([string]$Root)
39
+ if ($Root -match '[\[\]]') {
40
+ Write-Error ("aw-spec: this repository's path contains '[' or ']' — $Root`n" +
41
+ "aw-spec: those characters are a wildcard character class to the path APIs this kit uses (PowerShell -Path, shell and Python globs), so file lookups would silently resolve to a different path or to nothing at all, and a gate that finds nothing reports success. Move or rename the checkout so no directory in its path contains a square bracket, then re-run.")
42
+ exit 1
43
+ }
44
+ return $Root
45
+ }
46
+
23
47
  function Get-RepoRoot {
24
48
  $dir = (Get-Location).Path
25
49
  while ($true) {
26
- if (Test-Path (Join-Path $dir '.appweaver')) { return $dir }
50
+ if (Test-Path -LiteralPath (Join-Path $dir '.appweaver')) { return (Assert-UnbracketedRoot $dir) }
27
51
  $parent = Split-Path $dir -Parent
28
52
  if ([string]::IsNullOrEmpty($parent) -or $parent -eq $dir) { break }
29
53
  $dir = $parent
30
54
  }
31
- return (Resolve-Path (Join-Path $PSScriptRoot '..' '..' '..')).Path
55
+ return (Assert-UnbracketedRoot (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..' '..' '..')).Path)
32
56
  }
33
57
 
34
58
  function ConvertTo-RepoRelative {
@@ -621,7 +645,13 @@ function Invoke-LaneVerify {
621
645
  $fwd = @()
622
646
  for ($i = 0; $i -lt $Rest.Count; $i++) {
623
647
  switch ($Rest[$i]) {
624
- '--require' { $fwd += '--require'; $fwd += $Rest[++$i] }
648
+ # An EMPTY --require is "any lane will do" and is dropped rather than
649
+ # forwarded: the phase needs a declaration to work from, not a lane of a
650
+ # particular kind. A workflow that includes lane-gate cannot omit an
651
+ # argument conditionally — the include's `with` map is rendered whole —
652
+ # so an empty value is the only way it can say this, and argparse rejects
653
+ # one against --require's choices. Every other check stays armed.
654
+ '--require' { $v = $Rest[++$i]; if ("$v".Trim()) { $fwd += '--require'; $fwd += $v } }
625
655
  # --flag=value: --command carries a phase name that starts with '/'.
626
656
  '--command' { $fwd += ('--command=' + $Rest[++$i]) }
627
657
  '--allow-missing-root' { $fwd += '--allow-missing-root' }
@@ -854,15 +884,18 @@ function Invoke-ListFeatures {
854
884
  dir = "$baseRel/$($d.Name)"
855
885
  source = 'spec'
856
886
  has_spec = (Test-Path (Join-Path $d.FullName 'spec.md'))
857
- # 'design*.md', NOT 'design.*.md': the second dot made this miss the single
858
- # `design.md` the framework actually produces (it only matched the retired
859
- # design.domain.md / design.technical.md family). Informational count only.
860
- # -like, not -Filter: -Filter delegates to the platform's filesystem matcher
861
- # (which on Windows also honours 8.3 short names), so it is not guaranteed to
862
- # agree with the bash twin's glob. -like is PowerShell's own engine — same
863
- # result on every OS, which is what keeps the two scripts provably in parity.
864
- design_docs = @(Get-ChildItem -Path $d.FullName -File -ErrorAction SilentlyContinue |
865
- Where-Object { $_.Name -like 'design*.md' }).Count
887
+ # HOW MANY LANE PLANS THIS FEATURE HAS. Counted by NAME, not by pattern:
888
+ # the documents are backend-plan.md / frontend-plan.md, and the legacy pair
889
+ # design.md / design.ui.md that feature-paths still resolves under the same
890
+ # keys for a tree adopted before the rename. The old `design*.md` match
891
+ # counted neither of the current names, so this was structurally 0 on every
892
+ # feature the framework produces today — a field that could only ever be
893
+ # wrong. Informational only; no workflow reads it.
894
+ #
895
+ # -LiteralPath on the Join-Path result: the feature directory comes from the
896
+ # repo's own tree and `-Path` would read a `[` in it as a wildcard class.
897
+ plan_docs = @('backend-plan.md','frontend-plan.md','design.md','design.ui.md' |
898
+ Where-Object { Test-Path -LiteralPath (Join-Path $d.FullName $_) -PathType Leaf }).Count
866
899
  version = $ver
867
900
  active_change = $activeChange
868
901
  }
@@ -880,11 +913,15 @@ function Invoke-ListFeatures {
880
913
  has_spec = $false
881
914
  adopted = $false
882
915
  concept_path = "$($agg.concept_path)"
883
- design_docs = 0
916
+ plan_docs = 0
884
917
  version = ''
885
918
  active_change = ''
886
919
  }
887
920
  }
921
+ # `design_docs` is kept as a same-value ALIAS of plan_docs. The key is part of this
922
+ # op's published shape, so dropping it would break a reader outside the kit for no
923
+ # gain; what it reports is now the plan pair rather than a pattern that matched nothing.
924
+ foreach ($f in $features) { $f['design_docs'] = $f['plan_docs'] }
888
925
  # select_options = merged names + (injected new --preselect) + sentinel.
889
926
  $opts = @($names)
890
927
  if ($preselect -and (-not $seen.ContainsKey($preselect.ToLower()))) { $opts += $preselect }
@@ -926,6 +963,33 @@ function Invoke-SetFeature {
926
963
  if (Test-IsContextDir -Dir $featureDir) {
927
964
  Write-Error "set-feature: refusing — '$featureDirRel' is a bounded context, not a feature. Run /appweaver select-bounded-context $feature first, then select-feature."; exit 1
928
965
  }
966
+ # AND THE SAME REFUSAL FOR A CONTEXT THAT IS MERELY EMPTY. The test above asks what a
967
+ # directory CONTAINS, so a context created by select-bounded-context and not yet
968
+ # populated — no domain-model.md, no feature inside it — passes it and is taken as a
969
+ # feature, which writes spec.md into it and orphans every feature meant to live there.
970
+ # Emptiness cannot be told from "a new feature directory" by looking at the directory,
971
+ # so the question asked here is the one list-contexts asks instead: is this repo USING
972
+ # bounded contexts, and is none of them active? In that state a target directly under
973
+ # specs/ is outside every context and cannot be the feature the caller meant — which is
974
+ # exactly the state `select-feature` refuses via scope_ok, restated at the point of the
975
+ # write so no other caller can walk past it.
976
+ if ($baseRel -eq 'specs' -and -not (Test-Path (Join-Path $featureDir 'spec.md'))) {
977
+ $specsRoot = Join-Path $repo 'specs'
978
+ $otherContexts = @()
979
+ if (Test-Path $specsRoot) {
980
+ foreach ($d in (Get-ChildItem -LiteralPath $specsRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
981
+ if ($d.Name -eq $feature) { continue }
982
+ # A directory with its own spec.md is a FEATURE (the flat pre-context
983
+ # layout); one without is a context. Only the latter means contexts are in
984
+ # use here.
985
+ if (-not (Test-Path (Join-Path $d.FullName 'spec.md'))) { $otherContexts += $d.Name }
986
+ }
987
+ }
988
+ if ($otherContexts.Count -gt 0) {
989
+ $names = ($otherContexts -join ', ')
990
+ Write-Error "set-feature: refusing — this repository uses bounded contexts ($names) and none is active, so '$featureDirRel' would be created outside every one of them. If $feature is itself a bounded context, run /appweaver select-bounded-context $feature. If it is a feature, run /appweaver select-bounded-context to choose the context that owns it first, then select-feature $feature."; exit 1
991
+ }
992
+ }
929
993
  $existed = Test-Path $featureDir
930
994
  New-Item -ItemType Directory -Force -Path $featureDir | Out-Null
931
995
  New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver') | Out-Null
@@ -1040,6 +1104,42 @@ elif op == "activate":
1040
1104
  save(d)
1041
1105
  out["active_change"] = d["active_change"]; out["activated"] = cr
1042
1106
  out["open_changes"] = opens; out["open_count"] = len(opens)
1107
+ elif op == "recall":
1108
+ # WITHDRAWS one change: drop it from open_changes and move active_change off it,
1109
+ # WITHOUT bumping the version. That is the whole difference from `close`, and it is
1110
+ # the reason the two are separate ops rather than one with a flag. A version is what a
1111
+ # change SHIPPED as; a recalled change shipped nothing, so bumping would burn a number
1112
+ # on work that was never merged and leave the spine claiming a release that does not
1113
+ # exist. Everything else is identical — the same membership edit, the same fallback
1114
+ # for active_change — so a recall and a close leave the ledger in the same SHAPE and
1115
+ # only the number tells them apart.
1116
+ #
1117
+ # Refusing an untracked id is the point of this op, not a nicety: recall archives
1118
+ # documents, so acting on an id the ledger does not know would move a directory on the
1119
+ # strength of a typo. `close` cannot afford the same strictness (it is also the
1120
+ # migration path for state written before open_changes existed); recall can, because
1121
+ # every change it can reach was opened by specify.
1122
+ prev = d["version"]
1123
+ opens = [c for c in (d.get("open_changes") or []) if c]
1124
+ if not opens and d.get("active_change"):
1125
+ opens = [d["active_change"]] # state written before open_changes existed
1126
+ target = cr or d.get("active_change", "")
1127
+ if not target:
1128
+ out["ok"] = False
1129
+ out["error"] = "no change named and none is active on this feature"
1130
+ elif target not in opens:
1131
+ out["ok"] = False
1132
+ out["error"] = ("'%s' is not an open change on this feature (open: %s)"
1133
+ % (target, ", ".join(opens) or "none"))
1134
+ else:
1135
+ opens.remove(target)
1136
+ d["open_changes"] = opens
1137
+ d["active_change"] = opens[-1] if opens else ""
1138
+ save(d)
1139
+ out["recalled_change"] = target
1140
+ out["version"] = d["version"]; out["previous"] = prev
1141
+ out["active_change"] = d.get("active_change", "")
1142
+ out["open_changes"] = opens; out["open_count"] = len(opens)
1043
1143
  elif op == "close":
1044
1144
  # Closes ONE change by id (falling back to active_change when none is named, which
1045
1145
  # is the pre-parallel caller). The version bumps once per change materialized, and
@@ -1250,7 +1350,7 @@ function Get-ProjectMarkers {
1250
1350
  $topPrune = @('.appweaver', 'guardrails', 'foundations', 'specs', '.git')
1251
1351
  $names = @('*.sln','*.csproj','package.json','pom.xml','build.gradle','build.gradle.kts','go.mod','Cargo.toml','pyproject.toml')
1252
1352
  $out = @(
1253
- Get-ChildItem -Path $Start -Recurse -File -Include $names -ErrorAction SilentlyContinue |
1353
+ Get-ChildItem -LiteralPath $Start -Recurse -File -Include $names -ErrorAction SilentlyContinue |
1254
1354
  ForEach-Object { $_.FullName.Substring($Repo.Length).TrimStart('\','/') -replace '\\','/' } |
1255
1355
  Where-Object {
1256
1356
  $seg = $_.Split('/')
@@ -1337,7 +1437,7 @@ function Get-FoundationDirs {
1337
1437
  if (Test-Path $root) {
1338
1438
  foreach ($s in (Get-ChildItem -Directory $root -ErrorAction SilentlyContinue)) {
1339
1439
  foreach ($d in (Get-ChildItem -Directory $s.FullName -ErrorAction SilentlyContinue)) {
1340
- if (-not (Get-ChildItem -Path $d.FullName -Filter '*.pack.txt' -File -ErrorAction SilentlyContinue)) { continue }
1440
+ if (-not (Get-ChildItem -LiteralPath $d.FullName -Filter '*.pack.txt' -File -ErrorAction SilentlyContinue)) { continue }
1341
1441
  $out += [pscustomobject]@{ Name = $d.Name; Stack = $s.Name; Dir = $d.FullName; Rel = "foundations/$($s.Name)/$($d.Name)" }
1342
1442
  }
1343
1443
  }
@@ -1454,7 +1554,7 @@ function Invoke-FoundationStashDbContext {
1454
1554
  for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--out') { $out = $Rest[++$i] } }
1455
1555
  $repo = Get-RepoRoot
1456
1556
  if (-not [System.IO.Path]::IsPathRooted($out)) { $out = Join-Path $repo $out }
1457
- $ctx = Get-ChildItem -Path (Join-Path $out 'src') -Recurse -Filter 'ApplicationDbContext.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1557
+ $ctx = Get-ChildItem -LiteralPath (Join-Path $out 'src') -Recurse -Filter 'ApplicationDbContext.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1458
1558
  if (-not $ctx) { Write-Json ([ordered]@{ found = $false; dbcontext = ''; backup = '' }); return }
1459
1559
  $bak = "$ctx.foundation.bak"
1460
1560
  Copy-Item $ctx $bak
@@ -1923,15 +2023,15 @@ function Invoke-FindHostProject {
1923
2023
  if (-not $svcRoot) { $svcRoot = 'src' }
1924
2024
  $eco = Get-LaneField -Field 'backend_ecosystem'
1925
2025
  $searched = $svcRoot.TrimEnd('/')
1926
- $prog = Get-ChildItem -Path (Join-Path $repo ($svcRoot.TrimEnd('/'))) -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
2026
+ $prog = Get-ChildItem -LiteralPath (Join-Path $repo ($svcRoot.TrimEnd('/'))) -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1927
2027
  if (-not $prog) {
1928
- $prog = Get-ChildItem -Path $repo -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue |
2028
+ $prog = Get-ChildItem -LiteralPath $repo -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue |
1929
2029
  Where-Object { $_.FullName -notmatch '[/\\](\.git|guardrails|foundations|specs|\.appweaver|obj|bin)[/\\]' } |
1930
2030
  Select-Object -First 1 -ExpandProperty FullName
1931
2031
  }
1932
2032
  if (-not $prog) { Write-Json ([ordered]@{ found = $false; program_cs = ''; project_dir = ''; csproj = ''; ecosystem = $eco; searched = $searched }); return }
1933
2033
  $pdir = Split-Path $prog -Parent
1934
- $csproj = Get-ChildItem -Path $pdir -Filter *.csproj -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
2034
+ $csproj = Get-ChildItem -LiteralPath $pdir -Filter *.csproj -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
1935
2035
  Write-Json ([ordered]@{ found = $true; program_cs = $prog; project_dir = $pdir; csproj = $(if ($csproj) { $csproj } else { '' }); ecosystem = $eco; searched = $searched })
1936
2036
  }
1937
2037
 
@@ -2241,8 +2341,8 @@ function Invoke-BuildCompile {
2241
2341
 
2242
2342
  # No declaration: the historic first-match-wins ladder, unchanged.
2243
2343
  $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
2244
- $hasSln = @(Get-ChildItem -Path $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2245
- $hasCsproj = @(Get-ChildItem -Path (Join-Path $repo 'src') -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2344
+ $hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2345
+ $hasCsproj = @(Get-ChildItem -LiteralPath (Join-Path $repo 'src') -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2246
2346
  if ($hasSln -or $hasCsproj) {
2247
2347
  $tool = 'dotnet'
2248
2348
  if (Get-Command dotnet -ErrorAction SilentlyContinue) {
@@ -2293,8 +2393,8 @@ function Invoke-TestRun {
2293
2393
 
2294
2394
  $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
2295
2395
  $matched = $false; $scoped = $false; $fenceGreen = $false; $filter = ''
2296
- $hasSln = @(Get-ChildItem -Path $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2297
- $hasCsproj = @(Get-ChildItem -Path $repo -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2396
+ $hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2397
+ $hasCsproj = @(Get-ChildItem -LiteralPath $repo -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2298
2398
  $dotnetPresent = ($hasSln -or $hasCsproj)
2299
2399
 
2300
2400
  # A DECLARED lane model answers this the same way it answers build-compile, and for
@@ -2409,8 +2509,8 @@ function Invoke-RunMigrate {
2409
2509
  $svcDir = Join-Path $repo ($svcRoot.TrimEnd('/'))
2410
2510
 
2411
2511
  $tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
2412
- $hasSln = @(Get-ChildItem -Path $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2413
- $hasCsproj = @(Get-ChildItem -Path $svcDir -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2512
+ $hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
2513
+ $hasCsproj = @(Get-ChildItem -LiteralPath $svcDir -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
2414
2514
  $efOk = $false
2415
2515
  if (($hasSln -or $hasCsproj) -and (Get-Command dotnet -ErrorAction SilentlyContinue)) {
2416
2516
  Push-Location $repo; try { dotnet ef --version *> $null; $efOk = ($LASTEXITCODE -eq 0) } catch { $efOk = $false } finally { Pop-Location }
@@ -2418,10 +2518,10 @@ function Invoke-RunMigrate {
2418
2518
  if ($efOk) {
2419
2519
  # Prefer *Service.csproj (API host with ApplicationDbContext); fall back to first
2420
2520
  # non-AppHost/Web/ServiceDefaults/test project under the service lane's root.
2421
- $project = Get-ChildItem -Path $svcDir -Filter '*.Service.csproj' -Recurse -ErrorAction SilentlyContinue |
2521
+ $project = Get-ChildItem -LiteralPath $svcDir -Filter '*.Service.csproj' -Recurse -ErrorAction SilentlyContinue |
2422
2522
  Select-Object -First 1 -ExpandProperty FullName
2423
2523
  if (-not $project) {
2424
- $project = Get-ChildItem -Path $svcDir -Filter '*.csproj' -Recurse -ErrorAction SilentlyContinue |
2524
+ $project = Get-ChildItem -LiteralPath $svcDir -Filter '*.csproj' -Recurse -ErrorAction SilentlyContinue |
2425
2525
  Where-Object { $_.FullName -notmatch 'AppHost|ServiceDefaults|\.Web\.|Tests' } |
2426
2526
  Select-Object -First 1 -ExpandProperty FullName
2427
2527
  }
@@ -2495,6 +2595,23 @@ function Set-HeaderStamp {
2495
2595
  Set-Content -Path $Path -Value $text -Encoding UTF8 -NoNewline
2496
2596
  }
2497
2597
 
2598
+ # Read one '**Name**: value' header back out of a document — the counterpart of
2599
+ # Set-HeaderStamp, for the case where one document's header is the SOURCE of another's.
2600
+ # An UNFILLED template placeholder reads as absent: a header still carrying '[vX.Y]' or
2601
+ # '[DATE]' states nothing, and copying that bracket forward would spread the placeholder
2602
+ # instead of a value. Any trailing '<!-- … -->' the template keeps beside the value is
2603
+ # stripped, so the caller gets the value and not the explanation of it.
2604
+ # Twin of bash _read_header.
2605
+ function Get-MdHeader {
2606
+ param([string]$Path, [string]$Name)
2607
+ if (-not (Test-Path $Path)) { return '' }
2608
+ $m = [regex]::Match((Get-Content $Path -Raw), "(?m)^\*\*$([regex]::Escape($Name))\*\*:[ \t]*(.*)$")
2609
+ if (-not $m.Success) { return '' }
2610
+ $val = ([regex]::Replace($m.Groups[1].Value, '<!--.*', '')).Trim()
2611
+ if ($val.StartsWith('[') -and $val.EndsWith(']')) { return '' }
2612
+ return $val
2613
+ }
2614
+
2498
2615
  # The highest wave among the delta models open in a context, or 1 when none is. This is
2499
2616
  # the DEFAULT for a new delta: parallel with whatever is in flight, never implicitly
2500
2617
  # sequenced behind it.
@@ -2524,7 +2641,7 @@ function Get-WaveOf {
2524
2641
  function Get-MaxOpenWave {
2525
2642
  param([string]$ContextDir)
2526
2643
  $best = 0
2527
- foreach ($f in @(Get-ChildItem -Path $ContextDir -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue)) {
2644
+ foreach ($f in @(Get-ChildItem -LiteralPath $ContextDir -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue)) {
2528
2645
  $t = Get-Content $f.FullName -Raw -ErrorAction SilentlyContinue
2529
2646
  if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { $w = [int]$Matches[1]; if ($w -gt $best) { $best = $w } }
2530
2647
  }
@@ -2552,7 +2669,7 @@ function Invoke-ContextPaths {
2552
2669
  $context = if ($ctxRel -eq 'specs') { '' } else { Split-Path $ctxRel -Leaf }
2553
2670
  $master = Join-Path $ctx 'domain-model.md'
2554
2671
  $pending = @(); $files = @(); $waves = @()
2555
- foreach ($f in @(Get-ChildItem -Path $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
2672
+ foreach ($f in @(Get-ChildItem -LiteralPath $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
2556
2673
  $cr = $f.Name.Substring('domain-model.'.Length)
2557
2674
  $cr = $cr.Substring(0, $cr.Length - '.delta.md'.Length)
2558
2675
  $wave = 1
@@ -2659,7 +2776,7 @@ function Invoke-CrList {
2659
2776
  $rows = @()
2660
2777
  $changesDir = Join-Path $fd 'changes'
2661
2778
  if (Test-Path $changesDir) {
2662
- foreach ($d in @(Get-ChildItem -Path $changesDir -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
2779
+ foreach ($d in @(Get-ChildItem -LiteralPath $changesDir -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
2663
2780
  if ($d.Name -eq 'archive') { continue }
2664
2781
  $dspec = Join-Path $d.FullName 'delta-spec.md'
2665
2782
  $dmodel = Join-Path $ctx ("domain-model." + $d.Name + ".delta.md")
@@ -2718,6 +2835,10 @@ function Invoke-CrList {
2718
2835
  }
2719
2836
  Write-Json ([ordered]@{
2720
2837
  changes = @($rows); ids = @($rows | ForEach-Object { $_.cr_id }); count = $rows.Count
2838
+ # The same ids as one readable string, for an operator message. A list renders as
2839
+ # its JSON in a rendered string, which is right for a payload and wrong for a
2840
+ # sentence.
2841
+ ids_csv = (@($rows | ForEach-Object { $_.cr_id }) -join ', ')
2721
2842
  select_options = @($opts); matched_label = $matched
2722
2843
  main_label = $(if ($incMain) { $mainLabel } else { '' })
2723
2844
  new_label = $(if ($incNew) { $newLabel } else { '' })
@@ -2755,7 +2876,7 @@ function Invoke-CrPaths {
2755
2876
  $master = Join-Path $ctx 'domain-model.md'
2756
2877
  $wave = [int](Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
2757
2878
  $pred = @(); $sib = @(); $predRows = @(); $sibRows = @(); $predSpecs = @()
2758
- foreach ($f in @(Get-ChildItem -Path $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
2879
+ foreach ($f in @(Get-ChildItem -LiteralPath $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
2759
2880
  $other = $f.Name.Substring('domain-model.'.Length)
2760
2881
  $other = $other.Substring(0, $other.Length - '.delta.md'.Length)
2761
2882
  if ($other -eq $crId) { continue }
@@ -2766,9 +2887,18 @@ function Invoke-CrPaths {
2766
2887
  # A change's model delta sits at CONTEXT level and its delta-spec at FEATURE level,
2767
2888
  # so the spec of another open change is found by walking the context's features
2768
2889
  # rather than assuming it belongs to this one.
2890
+ #
2891
+ # The walk is EXPLICIT and every test is -LiteralPath, so no part of the repo's own
2892
+ # path is ever read as a pattern: -Path treats [ and ] as a wildcard character
2893
+ # class, which would silently return nothing for a context living under a directory
2894
+ # whose name contains either. Enumerating the feature directories and testing one
2895
+ # literal path each keeps this a provable twin of the bash glob on every platform.
2769
2896
  $sRel = ''
2770
- $sHit = @(Get-ChildItem -Path (Join-Path $ctx ('*/changes/{0}/delta-spec.md' -f $other)) -File -ErrorAction SilentlyContinue | Sort-Object FullName)
2771
- if ($sHit.Count -gt 0) { $sRel = ConvertTo-RepoRelative -Path $sHit[0].FullName -RepoRoot $repo }
2897
+ $sHit = @(Get-ChildItem -LiteralPath $ctx -Directory -ErrorAction SilentlyContinue |
2898
+ ForEach-Object { Join-Path $_.FullName "changes/$other/delta-spec.md" } |
2899
+ Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
2900
+ Sort-Object)
2901
+ if ($sHit.Count -gt 0) { $sRel = ConvertTo-RepoRelative -Path $sHit[0] -RepoRoot $repo }
2772
2902
  $row = [ordered]@{ cr_id = $other; file = $rel; wave = $w; delta_spec = $sRel }
2773
2903
  if ($w -lt $wave) {
2774
2904
  $pred += $rel; $predRows += $row
@@ -2794,6 +2924,17 @@ function Invoke-CrPaths {
2794
2924
  predecessor_docs = @($pred + $predSpecs)
2795
2925
  # Same or higher wave = nothing orders it against this one. Visible, never assumed.
2796
2926
  sibling_files = @($sib); siblings = @($sibRows); sibling_count = $sib.Count
2927
+ # EVERYTHING A PLANNER SHOULD SEE, in one list, for the same one-var reason.
2928
+ # Siblings belong in it as much as predecessors do: a planner told "do not assume a
2929
+ # same-wave delta, and say so where one touches the same aggregate" cannot name an
2930
+ # overlap it was never shown, and the gate one stage later raises a finding for the
2931
+ # silence. Their MODEL deltas only — the aggregate a sibling touches is what makes
2932
+ # it an overlap, and their delta-specs would double the payload to say nothing more.
2933
+ related_docs = @($pred + $predSpecs + $sib)
2934
+ # Who is who, as text for a prompt. The file list alone cannot say which entries
2935
+ # are committed to landing first and which are merely open beside this one.
2936
+ predecessor_ids = (@($predRows | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
2937
+ sibling_ids = (@($sibRows | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
2797
2938
  })
2798
2939
  }
2799
2940
 
@@ -2842,7 +2983,7 @@ function Invoke-CrOverlap {
2842
2983
  $wave = [int](Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
2843
2984
  $mine = @(Get-DeltaAggregates -Text $mineTxt)
2844
2985
  $rows = @(); $parts = @(); $npred = 0; $nsib = 0; $nsucc = 0
2845
- foreach ($f in @(Get-ChildItem -Path $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
2986
+ foreach ($f in @(Get-ChildItem -LiteralPath $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
2846
2987
  $other = $f.Name.Substring('domain-model.'.Length)
2847
2988
  $other = $other.Substring(0, $other.Length - '.delta.md'.Length)
2848
2989
  if ($other -eq $crId) { continue }
@@ -2863,6 +3004,9 @@ function Invoke-CrOverlap {
2863
3004
  }
2864
3005
  Write-Json ([ordered]@{
2865
3006
  cr_id = $crId; wave = $wave; aggregates = @($mine)
3007
+ # The same names as one readable string, for a prompt. A list renders as its JSON
3008
+ # in a rendered string, which is right for a payload and wrong for a sentence.
3009
+ aggregates_csv = (@($mine) -join ', ')
2866
3010
  overlaps = @($rows); overlap_count = $rows.Count
2867
3011
  predecessor_overlaps = $npred; sibling_overlaps = $nsib
2868
3012
  successor_overlaps = $nsucc
@@ -2890,7 +3034,26 @@ function Invoke-CrActivate {
2890
3034
  if (-not $crId) { Write-Error 'cr-activate: --cr-id is required'; exit 1 }
2891
3035
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2892
3036
  if (-not $fd) { Write-Error 'cr-activate: no feature set and no --feature-dir'; exit 1 }
2893
- Invoke-ActiveVersion -FeatureDir $fd -Op 'activate' -CrId $crId
3037
+ $out = Invoke-ActiveVersion -FeatureDir $fd -Op 'activate' -CrId $crId
3038
+ $out
3039
+ # A REFUSAL IS AN ERROR, NOT A RESULT — so it leaves through the exit code, like every
3040
+ # other way this command can fail. When the id is not in the ledger nothing moves and
3041
+ # the JSON reports active_change: "", which is precisely what the calling step binds;
3042
+ # an empty active change then reads as "no change" in every condition downstream, and
3043
+ # the run continues against a target it never selected. Refusing here turns that into
3044
+ # the engine's own stop, carrying the message to the operator. Twin of bash cr-activate.
3045
+ #
3046
+ # THIS IS REACHABLE, not defensive: cr-list offers what is on DISK (changes/<id>/ is
3047
+ # the authority for "open") and reports `tracked` separately, so a change whose
3048
+ # directory survived a crashed run is still offered while the ledger has forgotten it.
3049
+ #
3050
+ # `ok` starts true and is flipped only by a refusal, so an EXPLICIT false is the test.
3051
+ $res = $null
3052
+ try { $res = ($out | ConvertFrom-Json) } catch { return }
3053
+ if ($null -ne $res -and $res.PSObject.Properties.Name -contains 'ok' -and -not $res.ok) {
3054
+ Write-Error "cr-activate: $($res.error)"
3055
+ exit 1
3056
+ }
2894
3057
  }
2895
3058
 
2896
3059
  # ─────────────────────────── cr-wave ───────────────────────────────────────
@@ -2906,6 +3069,9 @@ function Invoke-CrWave {
2906
3069
  { $_ -in '--wave','-Wave' } { $wave = $Rest[++$i] }
2907
3070
  }
2908
3071
  }
3072
+ # The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
3073
+ # first token. A bare id is unchanged by this.
3074
+ $crId = Get-CrIdToken -Raw $crId
2909
3075
  if (-not $crId) { Write-Error 'cr-wave: --cr-id is required'; exit 1 }
2910
3076
  if ($wave -notmatch '^\d+$') { Write-Error 'cr-wave: --wave must be a positive integer'; exit 1 }
2911
3077
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
@@ -2932,6 +3098,9 @@ function Invoke-CrSetup {
2932
3098
  { $_ -in '--force','-Force' } { $force = '1' }
2933
3099
  }
2934
3100
  }
3101
+ # Before the generated default, so a menu LABEL passed as --cr-id becomes its id
3102
+ # rather than a directory name with spaces in it. Empty stays empty and is generated.
3103
+ $crId = Get-CrIdToken -Raw $crId
2935
3104
  # A random suffix, not the bare timestamp: two changes opened in the same second
2936
3105
  # would otherwise collide on one id, and with parallel deltas that is reachable.
2937
3106
  if (-not $crId) { $crId = 'CR-' + ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss')) + '-' + (Get-RandSuffix) }
@@ -2969,31 +3138,92 @@ function Invoke-CrSetup {
2969
3138
  if ($t) { Copy-Item $t $dmodel } else { New-Item -ItemType File -Force -Path $dmodel | Out-Null }
2970
3139
  $created = $true
2971
3140
  }
2972
- # Scripts stamp ids, dates and the wave — the model never writes them.
3141
+ # Scripts stamp ids, dates and versions — the model never writes them, and the delta
3142
+ # quality checklists REQUIRE a complete header, so every placeholder the templates
3143
+ # carry has to be filled here or it ships as a literal '[DATE]' / '[vX.Y]'.
3144
+ #
3145
+ # BASE VERSION is the master version this delta is being authored AGAINST, read from
3146
+ # the ledger that was just updated — 'open' does not move the number, so what comes
3147
+ # back is the current one. It is what a rebase check compares against later: a master
3148
+ # that moved between the opening of a change and its merge is a change authored on a
3149
+ # document that no longer exists in that form.
3150
+ #
3151
+ # BASE CONTEXT VERSION is the same idea one level up, and its source is the master
3152
+ # model's own '**Context version**:' header rather than any ledger — there is no
3153
+ # context-level version file (⌛ that, and the close-delta bump that would move the
3154
+ # number, are still to build). While nothing bumps it, every delta reads the same
3155
+ # v1.0; the field is still correct, and it starts carrying real information the moment
3156
+ # something stamps the master.
3157
+ $createdOn = [DateTime]::UtcNow.ToString('yyyy-MM-dd')
3158
+ $baseVersion = ''
3159
+ try { $baseVersion = "$(($avOpen | ConvertFrom-Json).version)" } catch { $baseVersion = '' }
3160
+ if (-not $baseVersion) { $baseVersion = 'v1.0' }
3161
+ if (-not $baseVersion.StartsWith('v')) { $baseVersion = "v$baseVersion" }
3162
+ $baseCtx = Get-MdHeader -Path (Join-Path $ctx 'domain-model.md') -Name 'Context version'
3163
+ if (-not $baseCtx) { $baseCtx = 'v1.0' }
3164
+ # Both version fields are normalised to the same 'vX.Y' shape. base_version comes from
3165
+ # a machine ledger and baseCtx from a markdown header a human may have written as
3166
+ # '1.0', and a rebase check compares these as strings — two spellings of one version
3167
+ # would read as a move that never happened.
3168
+ if (-not $baseCtx.StartsWith('v')) { $baseCtx = "v$baseCtx" }
3169
+
2973
3170
  Set-HeaderStamp -Path $dspec -Name 'Wave' -Value $wave
3171
+ Set-HeaderStamp -Path $dspec -Name 'Created' -Value $createdOn
3172
+ Set-HeaderStamp -Path $dspec -Name 'Base version' -Value $baseVersion
2974
3173
  Set-HeaderStamp -Path $dmodel -Name 'Wave' -Value $wave
3174
+ Set-HeaderStamp -Path $dmodel -Name 'Created' -Value $createdOn
3175
+ Set-HeaderStamp -Path $dmodel -Name 'Base context version' -Value $baseCtx
2975
3176
 
2976
3177
  Write-Json ([ordered]@{
2977
3178
  cr_id = $crId; cr_dir = $crDir; delta_spec = $dspec
2978
3179
  delta_model = $dmodel; context_dir = (ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo)
2979
3180
  wave = [int]$wave; open_count = $openCount; title = $title; created = $created
3181
+ created_on = $createdOn; base_version = $baseVersion; base_context_version = $baseCtx
2980
3182
  })
2981
3183
  }
2982
3184
 
3185
+ # ARCHIVE, in either of the two ways a change leaves the open set.
3186
+ #
3187
+ # default CLOSED — it shipped. The directory is archived and the version bumps, which
3188
+ # is close-delta's whole job and the behaviour every existing caller gets.
3189
+ # --recall WITHDRAWN — it never shipped. The same archive, and NO version bump: a
3190
+ # version records what a change shipped as, and a recalled change shipped
3191
+ # nothing, so bumping would burn a number on work that was never merged.
3192
+ #
3193
+ # The two were fused until recall-delta needed the archive half on its own, which is why
3194
+ # withdrawing a change could not be "archive alone". Splitting them here rather than in
3195
+ # each caller keeps ONE place that knows how a change's documents are put away — the
3196
+ # directory, and the model delta that lives beside the master rather than inside it.
2983
3197
  function Invoke-CrArchive {
2984
3198
  param([string[]]$Rest)
2985
- $crId = ''; $fdArg = ''
3199
+ $crId = ''; $fdArg = ''; $recall = $false; $reason = ''
2986
3200
  for ($i = 0; $i -lt $Rest.Count; $i++) {
2987
3201
  switch ($Rest[$i]) {
2988
3202
  { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
2989
3203
  { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3204
+ '--recall' { $recall = $true }
3205
+ '--reason' { $reason = $Rest[++$i] }
2990
3206
  }
2991
3207
  }
3208
+ # A caller that picked from a user_select menu hands over the whole LABEL
3209
+ # ("CR-… · wave 2 · title"), not the id. Every other cr-* op takes the first token;
3210
+ # this one did not, so a label arrived here as a directory name that does not exist
3211
+ # and the archive silently moved nothing.
3212
+ $crId = Get-CrIdToken -Raw $crId
2992
3213
  if (-not $crId) { Write-Error 'cr-archive: --cr-id required'; exit 1 }
2993
3214
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2994
3215
  if (-not $fd) { Write-Error 'cr-archive: no feature set and no --feature-dir'; exit 1 }
3216
+ # THE LEDGER EDIT COMES FIRST, because it is the only step that can REFUSE. `recall`
3217
+ # will not withdraw a change active_version.json does not carry, and a refusal has to
3218
+ # be a no-op: archiving before asking would move the directory and only then report
3219
+ # that the change was never open. `close` cannot refuse, so its side effects are
3220
+ # unchanged by the ordering.
3221
+ $op = if ($recall) { 'recall' } else { 'close' }
3222
+ $av = Invoke-ActiveVersion -FeatureDir $fd -Op $op -CrId $crId | ConvertFrom-Json
3223
+ $ledgerOk = $(if ($null -ne $av.ok) { [bool]$av.ok } else { $true })
3224
+
2995
3225
  $crDir = Join-Path $fd "changes/$crId"; $archRoot = Join-Path $fd 'changes/archive'; $archived = ''
2996
- if (Test-Path $crDir) {
3226
+ if ($ledgerOk -and (Test-Path $crDir)) {
2997
3227
  New-Item -ItemType Directory -Force -Path $archRoot | Out-Null
2998
3228
  $dest = Join-Path $archRoot $crId
2999
3229
  if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
@@ -3005,15 +3235,537 @@ function Invoke-CrArchive {
3005
3235
  # file behind would keep showing a landed change as in-flight to every other delta.
3006
3236
  $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3007
3237
  $dmodel = Join-Path $ctx "domain-model.$crId.delta.md"
3008
- if (Test-Path $dmodel) {
3238
+ $archivedModel = ''
3239
+ if ($ledgerOk -and (Test-Path $dmodel)) {
3009
3240
  $destDir = Join-Path $archRoot $crId
3010
3241
  New-Item -ItemType Directory -Force -Path $destDir | Out-Null
3011
- Move-Item -Force $dmodel (Join-Path $destDir 'domain-model.delta.md')
3242
+ $archivedModel = Join-Path $destDir 'domain-model.delta.md'
3243
+ Move-Item -Force $dmodel $archivedModel
3244
+ }
3245
+ # A RECALLED change is marked as such where the evidence will be looked for — inside
3246
+ # its own archive — and the marker is written by the script, not by a model, because
3247
+ # everything on it is a fact the script already holds: the id, the date, and the
3248
+ # reason the caller passed.
3249
+ $marker = ''
3250
+ if ($recall -and $archived) {
3251
+ $marker = Join-Path $archived 'RECALLED.md'
3252
+ $when = [DateTime]::UtcNow.ToString('yyyy-MM-dd')
3253
+ $why = if ("$reason".Trim()) { "$reason".Trim() } else { '(not stated)' }
3254
+ $body = @(
3255
+ "# RECALLED — $crId", '',
3256
+ "**Recalled**: $when",
3257
+ "**Reason**: $why", '',
3258
+ 'This change was WITHDRAWN before it was merged. Its delta documents are archived here',
3259
+ 'unchanged; the feature''s spec.md and the context''s domain-model.md never carried it,',
3260
+ 'and the version was not bumped — no release shipped this work.', '',
3261
+ 'Any code /appweaver implement generated for this change is NOT reverted by a recall.',
3262
+ 'Revert it with git if it should not stay.', ''
3263
+ ) -join "`n"
3264
+ [System.IO.File]::WriteAllText($marker, $body)
3265
+ }
3266
+ $out = [ordered]@{
3267
+ # The ledger edit is what can refuse (recall will not touch a change the ledger
3268
+ # does not carry), so its verdict is this command's verdict.
3269
+ ok = $ledgerOk
3270
+ archived_dir = $archived
3271
+ archived_model = $archivedModel
3272
+ recalled = $recall
3273
+ recall_marker = $marker
3274
+ version = $av.version
3275
+ previous = $av.previous
3276
+ # Which of the two ways it left the open set. Exactly one is ever non-empty.
3277
+ closed_change = $(if ($av.PSObject.Properties['closed_change']) { $av.closed_change } else { '' })
3278
+ recalled_change = $(if ($av.PSObject.Properties['recalled_change']) { $av.recalled_change } else { '' })
3279
+ active_change = $av.active_change
3280
+ open_changes = @($av.open_changes)
3281
+ open_count = $av.open_count
3282
+ }
3283
+ if ($av.PSObject.Properties['error']) { $out['error'] = $av.error }
3284
+ Write-Json $out
3285
+ }
3286
+
3287
+ # ──────────────────────────── context-version-bump ─────────────────────────
3288
+ # Move the BOUNDED CONTEXT's version, the way version-stamp moves a feature's.
3289
+ #
3290
+ # The model's header has always declared itself script-stamped — '**Context version**:
3291
+ # v1.0 <!-- script-stamped; close-delta bumps it when a closing delta touched the
3292
+ # model -->' — and specify tells the authoring model not to write it. Nothing wrote it,
3293
+ # so every context sat at the template's v1.0 for its whole life and the one instruction
3294
+ # the model was given about that field was the only thing keeping it accurate.
3295
+ #
3296
+ # WHY IT IS A CONTEXT-LEVEL NUMBER. A feature's version says what that feature shipped;
3297
+ # the model is shared by every feature in the context, so a change to it is a change to
3298
+ # all of them and no single feature's version can record it. The two spines move
3299
+ # independently and neither is derived from the other.
3300
+ #
3301
+ # ONLY WHEN THE MODEL ACTUALLY CHANGED. The caller passes --touched-model; a behaviour-only
3302
+ # change closes without moving this number, exactly as it leaves domain-model.md alone.
3303
+ # Refusing to bump is reported, not an error — most changes never touch the model.
3304
+ function Invoke-ContextVersionBump {
3305
+ param([string[]]$Rest)
3306
+ $fdArg = ''; $touched = $false; $crId = ''
3307
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3308
+ switch ($Rest[$i]) {
3309
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3310
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3311
+ # Takes a VALUE rather than being a bare flag: a workflow cannot omit an
3312
+ # argument conditionally (an include's arg list is rendered whole), so the
3313
+ # caller always passes it and says true or false. Anything but 'true' is false.
3314
+ '--touched-model' { $touched = ("$($Rest[++$i])".Trim() -eq 'true') }
3315
+ }
3012
3316
  }
3013
- # Close THIS change in active_version.json (single version source): bump minor + drop
3014
- # it from open_changes. Other changes on the feature stay open.
3015
- $av = Invoke-ActiveVersion -FeatureDir $fd -Op 'close' -CrId $crId | ConvertFrom-Json
3016
- Write-Json ([ordered]@{ ok = $true; archived_dir = $archived; version = $av.version; previous = $av.previous; closed_change = $av.closed_change })
3317
+ # The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
3318
+ # first token. Here the id only reaches the Change History row, so a label would have
3319
+ # been recorded verbatim rather than failing loudly — the quietest of the family's
3320
+ # inconsistencies, and the reason to close it rather than wait for a caller to hit it.
3321
+ $crId = Get-CrIdToken -Raw $crId
3322
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3323
+ if (-not $fd) { Write-Error 'context-version-bump: no feature set and no --feature-dir'; exit 1 }
3324
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3325
+ $model = Join-Path $ctx 'domain-model.md'
3326
+
3327
+ $current = Get-MdHeader -Path $model -Name 'Context version'
3328
+ if (-not $current) { $current = 'v1.0' }
3329
+ if (-not $current.StartsWith('v')) { $current = "v$current" }
3330
+
3331
+ $bumped = $false; $next = $current; $reason = ''
3332
+ if (-not (Test-Path $model)) {
3333
+ $reason = 'this feature''s context has no domain-model.md, so there is no context version to move'
3334
+ } elseif (-not $touched) {
3335
+ $reason = 'the closing change carried no model delta, so the context is unchanged'
3336
+ } else {
3337
+ $m = [regex]::Match($current.Trim(), '^v?(\d+)(?:\.(\d+))?')
3338
+ $maj = if ($m.Success) { [int]$m.Groups[1].Value } else { 1 }
3339
+ $mnr = if ($m.Success -and $m.Groups[2].Success) { [int]$m.Groups[2].Value } else { 0 }
3340
+ $next = 'v{0}.{1}' -f $maj, ($mnr + 1)
3341
+ Set-HeaderStamp -Path $model -Name 'Context version' -Value $next
3342
+ $bumped = $true
3343
+ $reason = 'the closing change merged a model delta into this context'
3344
+ }
3345
+ Write-Json ([ordered]@{
3346
+ ok = $true; bumped = $bumped; previous = $current; version = $next
3347
+ model = (ConvertTo-RepoRelative -Path $model -RepoRoot $repo)
3348
+ model_exists = (Test-Path $model)
3349
+ context = (ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo)
3350
+ reason = $reason
3351
+ })
3352
+ }
3353
+
3354
+ # ───────────────────────────── cr-recall-check ─────────────────────────────
3355
+ # The two mechanical refusals recall-delta needs, answered in one call from evidence on
3356
+ # disk rather than from anything a model read.
3357
+ #
3358
+ # MERGED. finalize-delta appends a Change History row carrying the CR id to spec.md, so
3359
+ # the id appearing in that file is the on-disk proof the merge ran. A merged change is
3360
+ # not recallable: the masters describe behaviour that includes it, and taking it back is
3361
+ # document surgery — the remedy is a NEW change that reverses it.
3362
+ #
3363
+ # DEPENDANTS. A wave is a dependency level: a higher wave is planned and built ON TOP of
3364
+ # the lower ones. Withdrawing a lower-wave change therefore strands every open change
3365
+ # above it. `depends_on` is stamped nowhere yet, so the wave is the only dependency signal
3366
+ # that actually exists, and it is the conservative one — same-wave changes are independent
3367
+ # by definition and are never counted here.
3368
+ # ───────────────────────────── cr-snapshot ─────────────────────────────────
3369
+ # Copy the two MASTERS into changes/<id>/pre-merge/ immediately before finalize-delta
3370
+ # rewrites them. The merge is the one act of the CR loop that edits a long-lived
3371
+ # hand-curated document in place, and until this existed the only record of what those
3372
+ # documents said beforehand was git — a different tool's answer to the question, and
3373
+ # unavailable at all if the merge and the commit are not interleaved.
3374
+ #
3375
+ # WHAT IT IS FOR. A recall after a merge has to put the masters back, and it may only do so
3376
+ # when nothing else landed in between — the pre-merge copy is the content to restore and
3377
+ # cr-merge-check's base_version comparison is what answers whether restoring is still safe.
3378
+ #
3379
+ # It is a COPY, not a move, and is overwritten on a re-run: finalize-delta may be attempted
3380
+ # more than once (a red downstream fence aborts it), and the snapshot that matters is the
3381
+ # one taken by the attempt that actually merged. The manifest records the version and date
3382
+ # so a reader can tell which. The snapshot travels WITH the change, so a closed change
3383
+ # carries the before-image of the masters it moved, in the archive, forever.
3384
+ # Twin of bash cr-snapshot.
3385
+ function Invoke-CrSnapshot {
3386
+ param([string[]]$Rest)
3387
+ $crId = ''; $fdArg = ''
3388
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3389
+ switch ($Rest[$i]) {
3390
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3391
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3392
+ }
3393
+ }
3394
+ $crId = Get-CrIdToken -Raw $crId
3395
+ if (-not $crId) { Write-Error 'cr-snapshot: --cr-id required'; exit 1 }
3396
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3397
+ if (-not $fd) { Write-Error 'cr-snapshot: no feature set and no --feature-dir'; exit 1 }
3398
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3399
+
3400
+ $snap = Join-Path $fd "changes/$crId/pre-merge"
3401
+ if (-not (Test-Path -LiteralPath $snap)) { New-Item -ItemType Directory -Path $snap -Force | Out-Null }
3402
+ $spec = Join-Path $fd 'spec.md'
3403
+ $model = Join-Path $ctx 'domain-model.md'
3404
+ # Flat names, not the source tree's shape: there are exactly two masters and they live
3405
+ # at different levels, so a nested copy would encode the layout of the day it was taken.
3406
+ [string[]]$copied = @()
3407
+ foreach ($pair in @(@($spec, 'spec.md'), @($model, 'domain-model.md'))) {
3408
+ if (Test-Path -LiteralPath $pair[0] -PathType Leaf) {
3409
+ Copy-Item -LiteralPath $pair[0] -Destination (Join-Path $snap $pair[1]) -Force
3410
+ $copied += $pair[1]
3411
+ }
3412
+ }
3413
+
3414
+ $version = ''
3415
+ $avPath = Join-Path $fd 'active_version.json'
3416
+ if (Test-Path -LiteralPath $avPath) {
3417
+ try { $version = "$((Get-Content $avPath -Raw | ConvertFrom-Json).version)" } catch { $version = '' }
3418
+ }
3419
+
3420
+ $manifest = [ordered]@{
3421
+ cr_id = $crId
3422
+ taken = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
3423
+ version = $version
3424
+ context_version = (Get-MdHeader -Path $model -Name 'Context version')
3425
+ files = @($copied)
3426
+ sources = [ordered]@{
3427
+ 'spec.md' = (ConvertTo-RepoRelative -Path $spec -RepoRoot $repo)
3428
+ 'domain-model.md' = (ConvertTo-RepoRelative -Path $model -RepoRoot $repo)
3429
+ }
3430
+ # Stated in the file itself, because the restore is the reader's whole reason for
3431
+ # opening it and the condition on it is not obvious from the content.
3432
+ note = ("Masters as they stood immediately before finalize-delta merged $crId. " +
3433
+ "Restoring is only safe while no other change finalized afterwards — " +
3434
+ "cr-merge-check's base_version comparison is what answers that.")
3435
+ }
3436
+ $mPath = Join-Path $snap 'manifest.json'
3437
+ ($manifest | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $mPath -Encoding utf8
3438
+
3439
+ Write-Json ([ordered]@{
3440
+ ok = $true; cr_id = $crId
3441
+ dir = (ConvertTo-RepoRelative -Path $snap -RepoRoot $repo)
3442
+ files = @($copied); count = @($copied).Count; version = $version
3443
+ manifest = (ConvertTo-RepoRelative -Path $mPath -RepoRoot $repo)
3444
+ })
3445
+ }
3446
+
3447
+ # ───────────────────────────── cr-merge-check ──────────────────────────────
3448
+ # The two mechanical refusals finalize-delta needs, answered in one call from evidence on
3449
+ # disk — the merge-side twin of cr-recall-check below, shaped the same way so the two gates
3450
+ # of the CR loop read alike.
3451
+ #
3452
+ # REBASE. cr-setup stamps '**Base version**:' into the delta-spec when the change opens: the
3453
+ # master version the delta was authored AGAINST. If spec.md has moved since — because
3454
+ # another change finalized and closed in between — the delta's MODIFIED items name a BEFORE
3455
+ # the master no longer says, and merging them is last-writer-wins against work that already
3456
+ # landed. Both sides are normalised to 'vX.Y' by cr-setup, so this is an exact string
3457
+ # comparison and never a judgement. A delta with no stamped header reports an empty
3458
+ # base_version and does NOT block: an absent stamp is no evidence of a move.
3459
+ #
3460
+ # PREDECESSORS. A wave is a dependency level: a LOWER wave is committed to landing first,
3461
+ # and plan puts exactly those deltas in the planner's context as the base this one builds
3462
+ # on. Merging out of wave order folds in behaviour assuming a predecessor the masters do not
3463
+ # yet carry. depends_on is stamped nowhere, so the wave is the only dependency signal that
3464
+ # exists; same-wave changes are independent by definition and are never counted.
3465
+ #
3466
+ # The scan is CONTEXT-WIDE, matching cr-paths' definition of a predecessor, and reads both
3467
+ # halves — the changes/<id>/ directories AND the pending model deltas — because a
3468
+ # behaviour-only change has no model delta and would be invisible to a scan of those alone.
3469
+ # Twin of bash cr-merge-check.
3470
+ function Invoke-CrMergeCheck {
3471
+ param([string[]]$Rest)
3472
+ $crId = ''; $fdArg = ''
3473
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3474
+ switch ($Rest[$i]) {
3475
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3476
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3477
+ }
3478
+ }
3479
+ $crId = Get-CrIdToken -Raw $crId
3480
+ if (-not $crId) { Write-Error 'cr-merge-check: --cr-id required'; exit 1 }
3481
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3482
+ if (-not $fd) { Write-Error 'cr-merge-check: no feature set and no --feature-dir'; exit 1 }
3483
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3484
+
3485
+ function Get-WaveFile([string]$path) {
3486
+ if (Test-Path -LiteralPath $path) {
3487
+ $m = [regex]::Match((Get-Content -LiteralPath $path -Raw), '(?m)^\*\*Wave\*\*:[ \t]*(\d+)')
3488
+ if ($m.Success) { return [int]$m.Groups[1].Value }
3489
+ }
3490
+ return 1
3491
+ }
3492
+ function Format-Ver([string]$v) {
3493
+ $v = "$v".Trim()
3494
+ if (-not $v) { return '' }
3495
+ if ($v.StartsWith('v')) { return $v } else { return "v$v" }
3496
+ }
3497
+
3498
+ $mySpec = Join-Path $fd "changes/$crId/delta-spec.md"
3499
+ $wave = Get-WaveFile $mySpec
3500
+ $baseVersion = Format-Ver (Get-MdHeader -Path $mySpec -Name 'Base version')
3501
+
3502
+ # The master's CURRENT version, from the ledger — the same authority version-read
3503
+ # reports, so the two can never disagree about what the number is.
3504
+ $current = ''
3505
+ $avPath = Join-Path $fd 'active_version.json'
3506
+ if (Test-Path -LiteralPath $avPath) {
3507
+ try { $current = Format-Ver "$((Get-Content $avPath -Raw | ConvertFrom-Json).version)" } catch { $current = '' }
3508
+ }
3509
+ if (-not $current) { $current = Format-Ver (Get-MdHeader -Path (Join-Path $fd 'spec.md') -Name 'Version') }
3510
+
3511
+ # Only a STAMPED base that DISAGREES blocks. No stamp, or no readable current version,
3512
+ # is absence of evidence and is not treated as a move.
3513
+ $rebaseNeeded = [bool]($baseVersion -and $current -and ($baseVersion -ne $current))
3514
+
3515
+ # EVERY open change in the context, both halves. changes/<id>/ under each feature is the
3516
+ # authority for "open"; the pending model deltas are unioned in so a model-only artifact
3517
+ # of a half-built change is still seen.
3518
+ $opens = [ordered]@{}
3519
+ if (Test-Path -LiteralPath $ctx) {
3520
+ foreach ($feat in (Get-ChildItem -LiteralPath $ctx -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
3521
+ $ch = Join-Path $feat.FullName 'changes'
3522
+ if (-not (Test-Path -LiteralPath $ch)) { continue }
3523
+ foreach ($d in (Get-ChildItem -LiteralPath $ch -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
3524
+ if ($d.Name -eq 'archive') { continue }
3525
+ if (-not $opens.Contains($d.Name)) {
3526
+ $opens[$d.Name] = [ordered]@{ cr_id = $d.Name
3527
+ wave = (Get-WaveFile (Join-Path $d.FullName 'delta-spec.md'))
3528
+ feature = $feat.Name }
3529
+ }
3530
+ }
3531
+ }
3532
+ foreach ($f in (Get-ChildItem -LiteralPath $ctx -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
3533
+ $m = [regex]::Match($f.Name, '^domain-model\.(.+)\.delta\.md$')
3534
+ if (-not $m.Success) { continue }
3535
+ $other = $m.Groups[1].Value
3536
+ if (-not $opens.Contains($other)) {
3537
+ $opens[$other] = [ordered]@{ cr_id = $other; wave = (Get-WaveFile $f.FullName); feature = '' }
3538
+ }
3539
+ }
3540
+ }
3541
+ $blockers = @(foreach ($k in ($opens.Keys | Sort-Object)) {
3542
+ if ($k -ne $crId -and $opens[$k].wave -lt $wave) { $opens[$k] }
3543
+ })
3544
+
3545
+ # THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
3546
+ # bumps, so between the two spec.md carries a change the number does not yet reflect —
3547
+ # and a second delta sharing the same base_version would compare equal and merge onto
3548
+ # masters that had already shifted under it. An id appearing in spec.md is the on-disk
3549
+ # proof that the merge ran (the Change History row finalize-delta appends), the same
3550
+ # evidence cr-recall-check reads for the same question.
3551
+ $specText = ''
3552
+ $specPath = Join-Path $fd 'spec.md'
3553
+ if (Test-Path -LiteralPath $specPath) { $specText = (Get-Content -LiteralPath $specPath -Raw) }
3554
+ $mergedOpen = @(foreach ($k in ($opens.Keys | Sort-Object)) {
3555
+ if ($k -ne $crId -and $specText.Contains($k)) { $opens[$k] }
3556
+ })
3557
+
3558
+ Write-Json ([ordered]@{
3559
+ ok = $true; cr_id = $crId; wave = $wave
3560
+ exists = (Test-Path -LiteralPath (Join-Path $fd "changes/$crId"))
3561
+ # REBASE half — the masters moved because a change CLOSED and bumped the number.
3562
+ base_version = $baseVersion; current_version = $current
3563
+ rebase_needed = $rebaseNeeded
3564
+ base_stamped = [bool]$baseVersion
3565
+ blockers = @($blockers); blocker_count = @($blockers).Count
3566
+ has_blockers = (@($blockers).Count -gt 0)
3567
+ blocker_ids = (@($blockers | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
3568
+ # The other way the masters move: a change MERGED and has not closed yet.
3569
+ merged_open = @($mergedOpen); merged_open_count = @($mergedOpen).Count
3570
+ has_merged_open = (@($mergedOpen).Count -gt 0)
3571
+ merged_open_ids = (@($mergedOpen | ForEach-Object { $_.cr_id }) -join ', ')
3572
+ # All three refusals as ONE positive flag, because the engine takes no compound
3573
+ # condition and a gate reading three booleans would need three passes over one question.
3574
+ merge_ok = ((-not $rebaseNeeded) -and (@($blockers).Count -eq 0) -and (@($mergedOpen).Count -eq 0))
3575
+ })
3576
+ }
3577
+
3578
+ function Invoke-CrRecallCheck {
3579
+ param([string[]]$Rest)
3580
+ $crId = ''; $fdArg = ''
3581
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3582
+ switch ($Rest[$i]) {
3583
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3584
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3585
+ }
3586
+ }
3587
+ $crId = Get-CrIdToken -Raw $crId
3588
+ if (-not $crId) { Write-Error 'cr-recall-check: --cr-id required'; exit 1 }
3589
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3590
+ if (-not $fd) { Write-Error 'cr-recall-check: no feature set and no --feature-dir'; exit 1 }
3591
+ $changes = Join-Path $fd 'changes'
3592
+ $crDir = Join-Path $changes $crId
3593
+
3594
+ function Get-WaveOf([string]$dir) {
3595
+ $p = Join-Path $dir 'delta-spec.md'
3596
+ if (Test-Path $p) {
3597
+ $m = [regex]::Match((Get-Content $p -Raw), '(?m)^\*\*Wave\*\*:[ \t]*(\d+)')
3598
+ if ($m.Success) { return [int]$m.Groups[1].Value }
3599
+ }
3600
+ return 1
3601
+ }
3602
+
3603
+ $spec = Join-Path $fd 'spec.md'
3604
+ $merged = $false; $evidence = ''
3605
+ if (Test-Path $spec) {
3606
+ foreach ($line in (Get-Content $spec)) {
3607
+ if ("$line".Contains($crId)) { $merged = $true; $evidence = "$line".Trim(); break }
3608
+ }
3609
+ }
3610
+
3611
+ $wave = if (Test-Path $crDir) { Get-WaveOf $crDir } else { 1 }
3612
+ $dependants = @()
3613
+ if (Test-Path $changes) {
3614
+ foreach ($d in (Get-ChildItem -LiteralPath $changes -Directory | Sort-Object Name)) {
3615
+ if ($d.Name -eq $crId -or $d.Name -eq 'archive') { continue }
3616
+ $w = Get-WaveOf $d.FullName
3617
+ if ($w -gt $wave) { $dependants += [ordered]@{ cr_id = $d.Name; wave = $w } }
3618
+ }
3619
+ }
3620
+ Write-Json ([ordered]@{
3621
+ ok = $true
3622
+ cr_id = $crId
3623
+ exists = (Test-Path $crDir)
3624
+ wave = $wave
3625
+ merged = $merged
3626
+ merge_evidence = $evidence
3627
+ dependants = @($dependants)
3628
+ dependant_ids = (@($dependants | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
3629
+ dependant_count = @($dependants).Count
3630
+ has_dependants = (@($dependants).Count -gt 0)
3631
+ })
3632
+ }
3633
+
3634
+ # ──────────────────── plan-gate-stamp / plan-gate-check ────────────────────
3635
+ # THE RECEIPT THAT analyze-plan PASSED, and over WHICH BYTES.
3636
+ #
3637
+ # implement generates from the lane plans and deliberately does NOT read the spec — its
3638
+ # own header says "analyze-plan already conformed the plans to it". That sentence was an
3639
+ # assumption about the order the operator ran things in, not a fact anything checked: the
3640
+ # lane gates test that the plan FILE EXISTS and nothing more, so `plan` → edit spec.md →
3641
+ # `implement` generated from a plan that was never held against the spec it now contradicts.
3642
+ #
3643
+ # WHY NOT COMPARE THE PLANS' `**Version**:` HEADERS. They cannot answer this. version-stamp
3644
+ # moves spec.md, backend-plan.md and frontend-plan.md to the same number in LOCKSTEP at
3645
+ # close-delta, whether or not a plan was re-derived — close-delta says so itself ("a plan
3646
+ # header stamped here is stating the version the slice ships as, not that the plan was
3647
+ # re-derived for it"). Comparing them reads equal always, which is worse than not checking.
3648
+ #
3649
+ # WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
3650
+ # from that spec". The question implement depends on is stronger — "were these plans PROVEN
3651
+ # against this spec" — and a plan authored from the current spec can still be wrong. So the
3652
+ # receipt is written where the proof happens, by analyze-plan, on green, and it hashes every
3653
+ # document the gate actually read: the spec, the context's domain model (check 4 reads it)
3654
+ # and each lane plan that was present.
3655
+ #
3656
+ # The absence of a receipt is INFORMATION, not an error: it means this feature has never
3657
+ # been through the gate, which every tree that predates this op is true of. It is reported
3658
+ # as untracked and blocks nothing. Only a receipt that EXISTS and no longer matches is a
3659
+ # refusal, because that tree has been gated once and the bytes have moved since.
3660
+ function Get-FileSha {
3661
+ # -LiteralPath throughout: -Path reads [ and ] as a wildcard character class, so a
3662
+ # repo living under a bracketed directory name would silently hash nothing.
3663
+ param([string]$Path)
3664
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
3665
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLower()
3666
+ }
3667
+
3668
+ function Invoke-PlanGateStamp {
3669
+ param([string[]]$Rest)
3670
+ $fdArg = ''; $specVersion = ''; $mode = 'master'
3671
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3672
+ switch ($Rest[$i]) {
3673
+ { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3674
+ { $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] }
3675
+ { $_ -in '--mode','-Mode' } { $mode = $Rest[++$i] }
3676
+ }
3677
+ }
3678
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3679
+ if (-not $fd) { Write-Error 'plan-gate-stamp: no feature set and no --feature-dir'; exit 1 }
3680
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3681
+ # Every document the gate READ. A lane that is not declared has no plan on disk and is
3682
+ # simply absent from the map — the check treats a name it does not carry as not gated,
3683
+ # never as changed, so declaring a second lane later does not read as tampering.
3684
+ #
3685
+ # THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
3686
+ # resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
3687
+ # that has not been through `plan` since the rename — every brownfield feature `adopt`
3688
+ # produces — is GATED on those documents. Hashing only the new names would leave such a
3689
+ # feature with an empty plan map: a receipt that looks tracked and fresh while the document
3690
+ # implement actually generates from is unwatched.
3691
+ $plans = [ordered]@{}
3692
+ foreach ($name in @('backend-plan.md','frontend-plan.md','design.md','design.ui.md')) {
3693
+ $h = Get-FileSha -Path (Join-Path $fd $name)
3694
+ if ($h) { $plans[$name] = $h }
3695
+ }
3696
+ $rec = [ordered]@{
3697
+ gated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
3698
+ gate_mode = $mode
3699
+ spec_version = $specVersion.Trim()
3700
+ spec_sha = (Get-FileSha -Path (Join-Path $fd 'spec.md'))
3701
+ model_sha = (Get-FileSha -Path (Join-Path $ctx 'domain-model.md'))
3702
+ plans = $plans
3703
+ note = ('Written by analyze-plan when it PASSED. It records the bytes that were ' +
3704
+ 'proven against each other, not a version — the plans'' **Version**: headers ' +
3705
+ 'are stamped in lockstep by close-delta and cannot answer this. implement ' +
3706
+ 'reads it through plan-gate-check.')
3707
+ }
3708
+ $p = Join-Path $fd '.aw-plan-gate.json'
3709
+ ($rec | ConvertTo-Json -Depth 6) + "`n" | Set-Content -LiteralPath $p -NoNewline -Encoding utf8
3710
+ Write-Json ([ordered]@{
3711
+ file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); gated = $rec.gated
3712
+ spec_version = $rec.spec_version; hashed = @($plans.Keys | Sort-Object)
3713
+ count = $plans.Count; ok = $true
3714
+ })
3715
+ }
3716
+
3717
+ function Invoke-PlanGateCheck {
3718
+ param([string[]]$Rest)
3719
+ $fdArg = ''
3720
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
3721
+ switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } }
3722
+ }
3723
+ $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3724
+ if (-not $fd) { Write-Error 'plan-gate-check: no feature set and no --feature-dir'; exit 1 }
3725
+ $ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
3726
+ $p = Join-Path $fd '.aw-plan-gate.json'
3727
+ $rec = $null
3728
+ if (Test-Path -LiteralPath $p -PathType Leaf) {
3729
+ try { $rec = Get-Content -LiteralPath $p -Raw | ConvertFrom-Json } catch { $rec = $null }
3730
+ }
3731
+ $out = [ordered]@{
3732
+ file = (ConvertTo-RepoRelative -Path $p -RepoRoot $repo); tracked = [bool]$rec
3733
+ gate_fresh = $false; spec_moved = $false; model_moved = $false
3734
+ plans_changed = @(); plans_changed_csv = ''; gated = ''; spec_version = ''
3735
+ reason = ''; ok = $true
3736
+ }
3737
+ if (-not $rec) {
3738
+ # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
3739
+ # thing to report about a tree that has not run analyze-plan since this op existed.
3740
+ $out.reason = 'no .aw-plan-gate.json — this feature has not passed analyze-plan since the gate began leaving a receipt'
3741
+ } else {
3742
+ $out.gated = [string]$rec.gated
3743
+ $out.spec_version = [string]$rec.spec_version
3744
+ $specSha = [string]$rec.spec_sha
3745
+ $out.spec_moved = ($specSha -and $specSha -ne (Get-FileSha -Path (Join-Path $fd 'spec.md')))
3746
+ # A model recorded as absent that is still absent has not moved. A context that
3747
+ # GAINED a domain model since the gate has moved: check 4 never ran against it.
3748
+ $out.model_moved = ([string]$rec.model_sha -ne (Get-FileSha -Path (Join-Path $ctx 'domain-model.md')))
3749
+ $changed = @()
3750
+ if ($rec.plans) {
3751
+ foreach ($name in @($rec.plans.PSObject.Properties.Name | Sort-Object)) {
3752
+ if ((Get-FileSha -Path (Join-Path $fd $name)) -ne [string]$rec.plans.$name) { $changed += $name }
3753
+ }
3754
+ }
3755
+ $out.plans_changed = @($changed)
3756
+ $out.plans_changed_csv = ($changed -join ', ')
3757
+ $out.gate_fresh = (-not ($out.spec_moved -or $out.model_moved -or $changed.Count -gt 0))
3758
+ if ($out.gate_fresh) {
3759
+ $out.reason = 'the spec, the model and every gated plan are byte-identical to what analyze-plan passed on {0}' -f $out.gated
3760
+ } else {
3761
+ $bits = @()
3762
+ if ($out.spec_moved) { $bits += 'spec.md has changed' }
3763
+ if ($out.model_moved) { $bits += 'domain-model.md has changed' }
3764
+ if ($changed.Count -gt 0) { $bits += ('{0} changed (a re-derive by `plan main`, or a hand edit)' -f ($changed -join ', ')) }
3765
+ $out.reason = 'since analyze-plan passed on {0}: {1}' -f $out.gated, ($bits -join '; ')
3766
+ }
3767
+ }
3768
+ Write-Json $out
3017
3769
  }
3018
3770
 
3019
3771
  # Stamp a version into the MASTER documents' `**Version**:` headers — the
@@ -3028,14 +3780,16 @@ function Invoke-CrArchive {
3028
3780
  # Only the value moves; any trailing inline comment on the header line is kept.
3029
3781
  function Invoke-VersionStamp {
3030
3782
  param([string[]]$Rest)
3031
- $ver = ''; $fdArg = ''
3783
+ $ver = ''; $fdArg = ''; $crId = ''
3032
3784
  for ($i = 0; $i -lt $Rest.Count; $i++) {
3033
3785
  switch ($Rest[$i]) {
3034
3786
  { $_ -in '--version','-Version' } { $ver = $Rest[++$i] }
3035
3787
  { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
3788
+ { $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
3036
3789
  }
3037
3790
  }
3038
3791
  if (-not $ver) { Write-Error 'version-stamp: --version required'; exit 1 }
3792
+ $crId = Get-CrIdToken -Raw $crId
3039
3793
  $repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3040
3794
  if (-not $fd) { Write-Error 'version-stamp: no feature set and no --feature-dir'; exit 1 }
3041
3795
  $ver = $ver.Trim()
@@ -3060,8 +3814,45 @@ function Invoke-VersionStamp {
3060
3814
  [System.IO.File]::WriteAllText($p, $out)
3061
3815
  $stamped += $name
3062
3816
  }
3817
+
3818
+ # THE CHANGE HISTORY ROW, completed with the number the headers just took. The row is
3819
+ # found by its CR cell, not by position: rows are appended newest-last and a feature may
3820
+ # carry many, so "the last row" would be right only until two changes were open at once.
3821
+ # Only a cell still reading '(pending)' is written — a row already carrying a version
3822
+ # belongs to a change that closed, and re-stamping it would rewrite history.
3823
+ $rowFilled = $false; $rowWas = ''
3824
+ if ($crId) {
3825
+ $p = Join-Path $fd 'spec.md'
3826
+ if (Test-Path -LiteralPath $p) {
3827
+ $lines = [System.IO.File]::ReadAllLines($p)
3828
+ $changed = $false
3829
+ for ($i = 0; $i -lt $lines.Count; $i++) {
3830
+ if ($changed) { break }
3831
+ $line = $lines[$i]
3832
+ if ($line.Contains($crId) -and $line.TrimStart().StartsWith('|')) {
3833
+ $cells = $line -split '\|'
3834
+ if ($cells.Count -gt 2 -and
3835
+ @('(pending)','pending','—','-','') -contains $cells[1].Trim().ToLower()) {
3836
+ $rowWas = $cells[1].Trim()
3837
+ $cells[1] = " $ver "
3838
+ $lines[$i] = ($cells -join '|')
3839
+ $changed = $true
3840
+ }
3841
+ }
3842
+ }
3843
+ if ($changed) {
3844
+ [System.IO.File]::WriteAllLines($p, $lines)
3845
+ $rowFilled = $true
3846
+ }
3847
+ }
3848
+ }
3849
+
3063
3850
  Write-Json ([ordered]@{ ok = $true; version = $ver; stamped = $stamped; unchanged = $unchanged;
3064
- missing = $missing; no_header = $noHeader; count = $stamped.Count })
3851
+ missing = $missing; no_header = $noHeader; count = $stamped.Count
3852
+ # Whether the merge's pending row was completed. False with a
3853
+ # --cr-id is the ordinary outcome for a re-run (already filled)
3854
+ # and for a merge predating the pending convention.
3855
+ cr_id = $crId; history_row_filled = $rowFilled; history_row_was = $rowWas })
3065
3856
  }
3066
3857
 
3067
3858
  # ─────────────────────────────── main ──────────────────────────────────────
@@ -3107,9 +3898,15 @@ switch ($sub) {
3107
3898
  'cr-activate' { Invoke-CrActivate -Rest $rest }
3108
3899
  'cr-wave' { Invoke-CrWave -Rest $rest }
3109
3900
  'cr-archive' { Invoke-CrArchive -Rest $rest }
3901
+ 'cr-recall-check' { Invoke-CrRecallCheck -Rest $rest }
3902
+ 'cr-merge-check' { Invoke-CrMergeCheck -Rest $rest }
3903
+ 'cr-snapshot' { Invoke-CrSnapshot -Rest $rest }
3904
+ 'context-version-bump' { Invoke-ContextVersionBump -Rest $rest }
3110
3905
  'context-paths' { Invoke-ContextPaths -Rest $rest }
3111
3906
  'model-scaffold' { Invoke-ModelScaffold -Rest $rest }
3112
3907
  'version-stamp' { Invoke-VersionStamp -Rest $rest }
3908
+ 'plan-gate-stamp' { Invoke-PlanGateStamp -Rest $rest }
3909
+ 'plan-gate-check' { Invoke-PlanGateCheck -Rest $rest }
3113
3910
  'checklist-read' { Invoke-ChecklistRead -Rest $rest }
3114
3911
  'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
3115
3912
  'detect-project' { Invoke-DetectProject -Rest $rest }