aw-spec-cli 0.1.18 → 0.1.20
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.
- package/assets/.appweaver/DESIGN-FORMAT.md +63 -31
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +62 -14
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +10 -6
- package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +9 -1
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +21 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +23 -1
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +22 -13
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +6 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +399 -131
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +199 -38
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +159 -25
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +20 -9
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +183 -40
- package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +311 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +221 -40
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +217 -174
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +494 -109
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +83 -18
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +319 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +29 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +143 -34
- package/assets/.appweaver/scripts/bash/aw-inventory.sh +15 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +878 -51
- package/assets/.appweaver/scripts/bash/aw-xref.sh +3 -0
- package/assets/.appweaver/scripts/bash/common.sh +28 -1
- package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +15 -0
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +868 -51
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +3 -0
- package/assets/.appweaver/scripts/python/aw-inventory.py +1832 -65
- package/assets/.appweaver/scripts/python/aw-xref.py +500 -81
- package/assets/.appweaver/templates/delta-design-template.md +1 -1
- package/assets/.appweaver/templates/delta-domain-model-template.md +1 -1
- package/assets/.appweaver/templates/delta-spec-template.md +4 -3
- package/assets/.appweaver/templates/design-template.md +56 -40
- package/assets/.appweaver/templates/okf-concept-template.md +11 -1
- package/assets/.appweaver/templates/proposed-inventory-template.md +127 -0
- package/assets/.appweaver/templates/source-map-template.md +5 -3
- package/assets/.appweaver/templates/spec-template.md +24 -21
- package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
- package/assets/.appweaver/templates/ui-design-template.md +11 -7
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +4489 -361
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2274 -125
- package/assets/guardrails/domain/healthcare/auditing.domain.md +3 -3
- package/assets/guardrails/domain/pharma/auditing.domain.md +3 -3
- package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/eventing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
- package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
- package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
- package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
- package/assets/guardrails/technology/nextjs/design-system.md +1 -1
- package/assets/guardrails/technology/nextjs/error-handling.md +1 -1
- package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
- package/assets/guardrails/technology/nextjs/i18n.md +1 -1
- package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
- package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
- package/assets/guardrails/technology/nextjs/state-management.md +1 -1
- 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
|
-
|
|
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' }
|
|
@@ -654,6 +684,8 @@ function Invoke-LaneProbe {
|
|
|
654
684
|
# (case-insensitive) — (1) specs/ sub-folders (already scaffolded/adopted) and
|
|
655
685
|
# (2) the OKF bundle's Aggregate concepts (the brownfield feature map, resolved by
|
|
656
686
|
# the SAME shared python core the bash twin calls). Empty source 2 on greenfield.
|
|
687
|
+
# Both are scoped to the ACTIVE BOUNDED CONTEXT — source 1 by the directory listed,
|
|
688
|
+
# source 2 by the `bounded_context:` key onboard stamps on every concept it writes.
|
|
657
689
|
# ─────────────────────── context.json (bounded context) ───────────────────
|
|
658
690
|
# Twin of the bash read_context_json_context_directory / feature_base_rel pair. A
|
|
659
691
|
# bounded context is the container a feature lives in — specs/<Context>/<Feature>/ —
|
|
@@ -831,13 +863,31 @@ function Invoke-ListFeatures {
|
|
|
831
863
|
if (-not $bundle) { $bundle = 'knowledge' }
|
|
832
864
|
if (-not [System.IO.Path]::IsPathRooted($bundle)) { $bundle = Join-Path $repo $bundle }
|
|
833
865
|
|
|
866
|
+
# The OKF half is scoped to the active bounded context the same way. onboard runs
|
|
867
|
+
# once per context and stamps each concept it writes, so a bundle that has seen two
|
|
868
|
+
# runs holds two contexts' aggregates; unfiltered, standing inside one context would
|
|
869
|
+
# offer the other's features and scaffold them into the wrong tree. Empty when no
|
|
870
|
+
# context is active, which keeps the flat layout listing everything.
|
|
871
|
+
$activeCtx = ''
|
|
872
|
+
if ($baseRel -ne 'specs') { $activeCtx = ($baseRel -split '/')[-1] }
|
|
873
|
+
|
|
834
874
|
# Best-effort OKF aggregate list (brownfield feature map). Empty on greenfield.
|
|
835
875
|
$okfAggs = @()
|
|
836
876
|
$py = Get-Py; $invpy = Join-Path $PSScriptRoot '..' 'python' 'aw-inventory.py'
|
|
837
877
|
if ($py -and (Test-Path $bundle) -and (Test-Path $invpy)) {
|
|
838
878
|
try {
|
|
839
|
-
|
|
840
|
-
|
|
879
|
+
# --context only when one is active: Windows PowerShell drops an empty-string
|
|
880
|
+
# argument to a native command, which would leave --context with no value.
|
|
881
|
+
$ctxArgs = @(); if ($activeCtx) { $ctxArgs = @('--context', $activeCtx) }
|
|
882
|
+
$okfRaw = & $py $invpy list-aggregates --bundle $bundle --root $repo @ctxArgs 2>$null
|
|
883
|
+
if ($okfRaw) {
|
|
884
|
+
# The FEATURES the map puts on each concept, not the aggregates themselves —
|
|
885
|
+
# the same list the bash twin reads.
|
|
886
|
+
$okfJson = $okfRaw | ConvertFrom-Json
|
|
887
|
+
# An EMPTY `pickable` is an answer (nothing to offer); only its absence
|
|
888
|
+
# falls back to the raw aggregates.
|
|
889
|
+
$okfAggs = if ($okfJson.PSObject.Properties.Name -contains 'pickable') { @($okfJson.pickable) } else { @($okfJson.aggregates) }
|
|
890
|
+
}
|
|
841
891
|
} catch { $okfAggs = @() }
|
|
842
892
|
}
|
|
843
893
|
|
|
@@ -854,21 +904,24 @@ function Invoke-ListFeatures {
|
|
|
854
904
|
dir = "$baseRel/$($d.Name)"
|
|
855
905
|
source = 'spec'
|
|
856
906
|
has_spec = (Test-Path (Join-Path $d.FullName 'spec.md'))
|
|
857
|
-
#
|
|
858
|
-
#
|
|
859
|
-
# design.
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
#
|
|
863
|
-
#
|
|
864
|
-
|
|
865
|
-
|
|
907
|
+
# HOW MANY LANE PLANS THIS FEATURE HAS. Counted by NAME, not by pattern:
|
|
908
|
+
# the documents are backend-plan.md / frontend-plan.md, and the legacy pair
|
|
909
|
+
# design.md / design.ui.md that feature-paths still resolves under the same
|
|
910
|
+
# keys for a tree adopted before the rename. The old `design*.md` match
|
|
911
|
+
# counted neither of the current names, so this was structurally 0 on every
|
|
912
|
+
# feature the framework produces today — a field that could only ever be
|
|
913
|
+
# wrong. Informational only; no workflow reads it.
|
|
914
|
+
#
|
|
915
|
+
# -LiteralPath on the Join-Path result: the feature directory comes from the
|
|
916
|
+
# repo's own tree and `-Path` would read a `[` in it as a wildcard class.
|
|
917
|
+
plan_docs = @('backend-plan.md','frontend-plan.md','design.md','design.ui.md' |
|
|
918
|
+
Where-Object { Test-Path -LiteralPath (Join-Path $d.FullName $_) -PathType Leaf }).Count
|
|
866
919
|
version = $ver
|
|
867
920
|
active_change = $activeChange
|
|
868
921
|
}
|
|
869
922
|
}
|
|
870
923
|
}
|
|
871
|
-
# Merge OKF-discovered
|
|
924
|
+
# Merge the OKF-discovered FEATURES not already scaffolded under specs/.
|
|
872
925
|
foreach ($agg in $okfAggs) {
|
|
873
926
|
$nm = "$($agg.name)".Trim()
|
|
874
927
|
if (-not $nm -or $seen.ContainsKey($nm.ToLower())) { continue }
|
|
@@ -880,11 +933,15 @@ function Invoke-ListFeatures {
|
|
|
880
933
|
has_spec = $false
|
|
881
934
|
adopted = $false
|
|
882
935
|
concept_path = "$($agg.concept_path)"
|
|
883
|
-
|
|
936
|
+
plan_docs = 0
|
|
884
937
|
version = ''
|
|
885
938
|
active_change = ''
|
|
886
939
|
}
|
|
887
940
|
}
|
|
941
|
+
# `design_docs` is kept as a same-value ALIAS of plan_docs. The key is part of this
|
|
942
|
+
# op's published shape, so dropping it would break a reader outside the kit for no
|
|
943
|
+
# gain; what it reports is now the plan pair rather than a pattern that matched nothing.
|
|
944
|
+
foreach ($f in $features) { $f['design_docs'] = $f['plan_docs'] }
|
|
888
945
|
# select_options = merged names + (injected new --preselect) + sentinel.
|
|
889
946
|
$opts = @($names)
|
|
890
947
|
if ($preselect -and (-not $seen.ContainsKey($preselect.ToLower()))) { $opts += $preselect }
|
|
@@ -907,11 +964,11 @@ function Invoke-SetFeature {
|
|
|
907
964
|
}
|
|
908
965
|
if (-not $raw) { Write-Error 'set-feature: --feature is required'; exit 1 }
|
|
909
966
|
|
|
910
|
-
# Normalize to a PascalCase
|
|
967
|
+
# Normalize to a PascalCase identifier.
|
|
911
968
|
$cleaned = ($raw -replace '[^A-Za-z0-9]+', ' ').Trim()
|
|
912
969
|
$feature = ConvertTo-Pascal -In $cleaned
|
|
913
970
|
if ($feature -notmatch '^[A-Za-z][A-Za-z0-9]*$') {
|
|
914
|
-
Write-Error "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric
|
|
971
|
+
Write-Error "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric name, e.g. Order or PatientRegistration)"; exit 1
|
|
915
972
|
}
|
|
916
973
|
|
|
917
974
|
$repo = Get-RepoRoot
|
|
@@ -926,6 +983,33 @@ function Invoke-SetFeature {
|
|
|
926
983
|
if (Test-IsContextDir -Dir $featureDir) {
|
|
927
984
|
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
985
|
}
|
|
986
|
+
# AND THE SAME REFUSAL FOR A CONTEXT THAT IS MERELY EMPTY. The test above asks what a
|
|
987
|
+
# directory CONTAINS, so a context created by select-bounded-context and not yet
|
|
988
|
+
# populated — no domain-model.md, no feature inside it — passes it and is taken as a
|
|
989
|
+
# feature, which writes spec.md into it and orphans every feature meant to live there.
|
|
990
|
+
# Emptiness cannot be told from "a new feature directory" by looking at the directory,
|
|
991
|
+
# so the question asked here is the one list-contexts asks instead: is this repo USING
|
|
992
|
+
# bounded contexts, and is none of them active? In that state a target directly under
|
|
993
|
+
# specs/ is outside every context and cannot be the feature the caller meant — which is
|
|
994
|
+
# exactly the state `select-feature` refuses via scope_ok, restated at the point of the
|
|
995
|
+
# write so no other caller can walk past it.
|
|
996
|
+
if ($baseRel -eq 'specs' -and -not (Test-Path (Join-Path $featureDir 'spec.md'))) {
|
|
997
|
+
$specsRoot = Join-Path $repo 'specs'
|
|
998
|
+
$otherContexts = @()
|
|
999
|
+
if (Test-Path $specsRoot) {
|
|
1000
|
+
foreach ($d in (Get-ChildItem -LiteralPath $specsRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
1001
|
+
if ($d.Name -eq $feature) { continue }
|
|
1002
|
+
# A directory with its own spec.md is a FEATURE (the flat pre-context
|
|
1003
|
+
# layout); one without is a context. Only the latter means contexts are in
|
|
1004
|
+
# use here.
|
|
1005
|
+
if (-not (Test-Path (Join-Path $d.FullName 'spec.md'))) { $otherContexts += $d.Name }
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if ($otherContexts.Count -gt 0) {
|
|
1009
|
+
$names = ($otherContexts -join ', ')
|
|
1010
|
+
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
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
929
1013
|
$existed = Test-Path $featureDir
|
|
930
1014
|
New-Item -ItemType Directory -Force -Path $featureDir | Out-Null
|
|
931
1015
|
New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver') | Out-Null
|
|
@@ -1040,6 +1124,42 @@ elif op == "activate":
|
|
|
1040
1124
|
save(d)
|
|
1041
1125
|
out["active_change"] = d["active_change"]; out["activated"] = cr
|
|
1042
1126
|
out["open_changes"] = opens; out["open_count"] = len(opens)
|
|
1127
|
+
elif op == "recall":
|
|
1128
|
+
# WITHDRAWS one change: drop it from open_changes and move active_change off it,
|
|
1129
|
+
# WITHOUT bumping the version. That is the whole difference from `close`, and it is
|
|
1130
|
+
# the reason the two are separate ops rather than one with a flag. A version is what a
|
|
1131
|
+
# change SHIPPED as; a recalled change shipped nothing, so bumping would burn a number
|
|
1132
|
+
# on work that was never merged and leave the spine claiming a release that does not
|
|
1133
|
+
# exist. Everything else is identical — the same membership edit, the same fallback
|
|
1134
|
+
# for active_change — so a recall and a close leave the ledger in the same SHAPE and
|
|
1135
|
+
# only the number tells them apart.
|
|
1136
|
+
#
|
|
1137
|
+
# Refusing an untracked id is the point of this op, not a nicety: recall archives
|
|
1138
|
+
# documents, so acting on an id the ledger does not know would move a directory on the
|
|
1139
|
+
# strength of a typo. `close` cannot afford the same strictness (it is also the
|
|
1140
|
+
# migration path for state written before open_changes existed); recall can, because
|
|
1141
|
+
# every change it can reach was opened by specify.
|
|
1142
|
+
prev = d["version"]
|
|
1143
|
+
opens = [c for c in (d.get("open_changes") or []) if c]
|
|
1144
|
+
if not opens and d.get("active_change"):
|
|
1145
|
+
opens = [d["active_change"]] # state written before open_changes existed
|
|
1146
|
+
target = cr or d.get("active_change", "")
|
|
1147
|
+
if not target:
|
|
1148
|
+
out["ok"] = False
|
|
1149
|
+
out["error"] = "no change named and none is active on this feature"
|
|
1150
|
+
elif target not in opens:
|
|
1151
|
+
out["ok"] = False
|
|
1152
|
+
out["error"] = ("'%s' is not an open change on this feature (open: %s)"
|
|
1153
|
+
% (target, ", ".join(opens) or "none"))
|
|
1154
|
+
else:
|
|
1155
|
+
opens.remove(target)
|
|
1156
|
+
d["open_changes"] = opens
|
|
1157
|
+
d["active_change"] = opens[-1] if opens else ""
|
|
1158
|
+
save(d)
|
|
1159
|
+
out["recalled_change"] = target
|
|
1160
|
+
out["version"] = d["version"]; out["previous"] = prev
|
|
1161
|
+
out["active_change"] = d.get("active_change", "")
|
|
1162
|
+
out["open_changes"] = opens; out["open_count"] = len(opens)
|
|
1043
1163
|
elif op == "close":
|
|
1044
1164
|
# Closes ONE change by id (falling back to active_change when none is named, which
|
|
1045
1165
|
# is the pre-parallel caller). The version bumps once per change materialized, and
|
|
@@ -1250,7 +1370,7 @@ function Get-ProjectMarkers {
|
|
|
1250
1370
|
$topPrune = @('.appweaver', 'guardrails', 'foundations', 'specs', '.git')
|
|
1251
1371
|
$names = @('*.sln','*.csproj','package.json','pom.xml','build.gradle','build.gradle.kts','go.mod','Cargo.toml','pyproject.toml')
|
|
1252
1372
|
$out = @(
|
|
1253
|
-
Get-ChildItem -
|
|
1373
|
+
Get-ChildItem -LiteralPath $Start -Recurse -File -Include $names -ErrorAction SilentlyContinue |
|
|
1254
1374
|
ForEach-Object { $_.FullName.Substring($Repo.Length).TrimStart('\','/') -replace '\\','/' } |
|
|
1255
1375
|
Where-Object {
|
|
1256
1376
|
$seg = $_.Split('/')
|
|
@@ -1337,7 +1457,7 @@ function Get-FoundationDirs {
|
|
|
1337
1457
|
if (Test-Path $root) {
|
|
1338
1458
|
foreach ($s in (Get-ChildItem -Directory $root -ErrorAction SilentlyContinue)) {
|
|
1339
1459
|
foreach ($d in (Get-ChildItem -Directory $s.FullName -ErrorAction SilentlyContinue)) {
|
|
1340
|
-
if (-not (Get-ChildItem -
|
|
1460
|
+
if (-not (Get-ChildItem -LiteralPath $d.FullName -Filter '*.pack.txt' -File -ErrorAction SilentlyContinue)) { continue }
|
|
1341
1461
|
$out += [pscustomobject]@{ Name = $d.Name; Stack = $s.Name; Dir = $d.FullName; Rel = "foundations/$($s.Name)/$($d.Name)" }
|
|
1342
1462
|
}
|
|
1343
1463
|
}
|
|
@@ -1454,7 +1574,7 @@ function Invoke-FoundationStashDbContext {
|
|
|
1454
1574
|
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--out') { $out = $Rest[++$i] } }
|
|
1455
1575
|
$repo = Get-RepoRoot
|
|
1456
1576
|
if (-not [System.IO.Path]::IsPathRooted($out)) { $out = Join-Path $repo $out }
|
|
1457
|
-
$ctx = Get-ChildItem -
|
|
1577
|
+
$ctx = Get-ChildItem -LiteralPath (Join-Path $out 'src') -Recurse -Filter 'ApplicationDbContext.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
|
|
1458
1578
|
if (-not $ctx) { Write-Json ([ordered]@{ found = $false; dbcontext = ''; backup = '' }); return }
|
|
1459
1579
|
$bak = "$ctx.foundation.bak"
|
|
1460
1580
|
Copy-Item $ctx $bak
|
|
@@ -1923,15 +2043,15 @@ function Invoke-FindHostProject {
|
|
|
1923
2043
|
if (-not $svcRoot) { $svcRoot = 'src' }
|
|
1924
2044
|
$eco = Get-LaneField -Field 'backend_ecosystem'
|
|
1925
2045
|
$searched = $svcRoot.TrimEnd('/')
|
|
1926
|
-
$prog = Get-ChildItem -
|
|
2046
|
+
$prog = Get-ChildItem -LiteralPath (Join-Path $repo ($svcRoot.TrimEnd('/'))) -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
|
|
1927
2047
|
if (-not $prog) {
|
|
1928
|
-
$prog = Get-ChildItem -
|
|
2048
|
+
$prog = Get-ChildItem -LiteralPath $repo -Recurse -Filter 'Program.cs' -ErrorAction SilentlyContinue |
|
|
1929
2049
|
Where-Object { $_.FullName -notmatch '[/\\](\.git|guardrails|foundations|specs|\.appweaver|obj|bin)[/\\]' } |
|
|
1930
2050
|
Select-Object -First 1 -ExpandProperty FullName
|
|
1931
2051
|
}
|
|
1932
2052
|
if (-not $prog) { Write-Json ([ordered]@{ found = $false; program_cs = ''; project_dir = ''; csproj = ''; ecosystem = $eco; searched = $searched }); return }
|
|
1933
2053
|
$pdir = Split-Path $prog -Parent
|
|
1934
|
-
$csproj = Get-ChildItem -
|
|
2054
|
+
$csproj = Get-ChildItem -LiteralPath $pdir -Filter *.csproj -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
|
|
1935
2055
|
Write-Json ([ordered]@{ found = $true; program_cs = $prog; project_dir = $pdir; csproj = $(if ($csproj) { $csproj } else { '' }); ecosystem = $eco; searched = $searched })
|
|
1936
2056
|
}
|
|
1937
2057
|
|
|
@@ -2241,8 +2361,8 @@ function Invoke-BuildCompile {
|
|
|
2241
2361
|
|
|
2242
2362
|
# No declaration: the historic first-match-wins ladder, unchanged.
|
|
2243
2363
|
$tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
|
|
2244
|
-
$hasSln = @(Get-ChildItem -
|
|
2245
|
-
$hasCsproj = @(Get-ChildItem -
|
|
2364
|
+
$hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
|
|
2365
|
+
$hasCsproj = @(Get-ChildItem -LiteralPath (Join-Path $repo 'src') -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
|
|
2246
2366
|
if ($hasSln -or $hasCsproj) {
|
|
2247
2367
|
$tool = 'dotnet'
|
|
2248
2368
|
if (Get-Command dotnet -ErrorAction SilentlyContinue) {
|
|
@@ -2293,8 +2413,8 @@ function Invoke-TestRun {
|
|
|
2293
2413
|
|
|
2294
2414
|
$tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
|
|
2295
2415
|
$matched = $false; $scoped = $false; $fenceGreen = $false; $filter = ''
|
|
2296
|
-
$hasSln = @(Get-ChildItem -
|
|
2297
|
-
$hasCsproj = @(Get-ChildItem -
|
|
2416
|
+
$hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
|
|
2417
|
+
$hasCsproj = @(Get-ChildItem -LiteralPath $repo -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
|
|
2298
2418
|
$dotnetPresent = ($hasSln -or $hasCsproj)
|
|
2299
2419
|
|
|
2300
2420
|
# A DECLARED lane model answers this the same way it answers build-compile, and for
|
|
@@ -2409,8 +2529,8 @@ function Invoke-RunMigrate {
|
|
|
2409
2529
|
$svcDir = Join-Path $repo ($svcRoot.TrimEnd('/'))
|
|
2410
2530
|
|
|
2411
2531
|
$tool = 'none'; $ran = $false; $ok = $true; $cmd = ''; $log = ''
|
|
2412
|
-
$hasSln = @(Get-ChildItem -
|
|
2413
|
-
$hasCsproj = @(Get-ChildItem -
|
|
2532
|
+
$hasSln = @(Get-ChildItem -LiteralPath $repo -Filter *.sln -File -ErrorAction SilentlyContinue).Count -gt 0
|
|
2533
|
+
$hasCsproj = @(Get-ChildItem -LiteralPath $svcDir -Filter *.csproj -Recurse -ErrorAction SilentlyContinue).Count -gt 0
|
|
2414
2534
|
$efOk = $false
|
|
2415
2535
|
if (($hasSln -or $hasCsproj) -and (Get-Command dotnet -ErrorAction SilentlyContinue)) {
|
|
2416
2536
|
Push-Location $repo; try { dotnet ef --version *> $null; $efOk = ($LASTEXITCODE -eq 0) } catch { $efOk = $false } finally { Pop-Location }
|
|
@@ -2418,10 +2538,10 @@ function Invoke-RunMigrate {
|
|
|
2418
2538
|
if ($efOk) {
|
|
2419
2539
|
# Prefer *Service.csproj (API host with ApplicationDbContext); fall back to first
|
|
2420
2540
|
# non-AppHost/Web/ServiceDefaults/test project under the service lane's root.
|
|
2421
|
-
$project = Get-ChildItem -
|
|
2541
|
+
$project = Get-ChildItem -LiteralPath $svcDir -Filter '*.Service.csproj' -Recurse -ErrorAction SilentlyContinue |
|
|
2422
2542
|
Select-Object -First 1 -ExpandProperty FullName
|
|
2423
2543
|
if (-not $project) {
|
|
2424
|
-
$project = Get-ChildItem -
|
|
2544
|
+
$project = Get-ChildItem -LiteralPath $svcDir -Filter '*.csproj' -Recurse -ErrorAction SilentlyContinue |
|
|
2425
2545
|
Where-Object { $_.FullName -notmatch 'AppHost|ServiceDefaults|\.Web\.|Tests' } |
|
|
2426
2546
|
Select-Object -First 1 -ExpandProperty FullName
|
|
2427
2547
|
}
|
|
@@ -2495,6 +2615,23 @@ function Set-HeaderStamp {
|
|
|
2495
2615
|
Set-Content -Path $Path -Value $text -Encoding UTF8 -NoNewline
|
|
2496
2616
|
}
|
|
2497
2617
|
|
|
2618
|
+
# Read one '**Name**: value' header back out of a document — the counterpart of
|
|
2619
|
+
# Set-HeaderStamp, for the case where one document's header is the SOURCE of another's.
|
|
2620
|
+
# An UNFILLED template placeholder reads as absent: a header still carrying '[vX.Y]' or
|
|
2621
|
+
# '[DATE]' states nothing, and copying that bracket forward would spread the placeholder
|
|
2622
|
+
# instead of a value. Any trailing '<!-- … -->' the template keeps beside the value is
|
|
2623
|
+
# stripped, so the caller gets the value and not the explanation of it.
|
|
2624
|
+
# Twin of bash _read_header.
|
|
2625
|
+
function Get-MdHeader {
|
|
2626
|
+
param([string]$Path, [string]$Name)
|
|
2627
|
+
if (-not (Test-Path $Path)) { return '' }
|
|
2628
|
+
$m = [regex]::Match((Get-Content $Path -Raw), "(?m)^\*\*$([regex]::Escape($Name))\*\*:[ \t]*(.*)$")
|
|
2629
|
+
if (-not $m.Success) { return '' }
|
|
2630
|
+
$val = ([regex]::Replace($m.Groups[1].Value, '<!--.*', '')).Trim()
|
|
2631
|
+
if ($val.StartsWith('[') -and $val.EndsWith(']')) { return '' }
|
|
2632
|
+
return $val
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2498
2635
|
# The highest wave among the delta models open in a context, or 1 when none is. This is
|
|
2499
2636
|
# the DEFAULT for a new delta: parallel with whatever is in flight, never implicitly
|
|
2500
2637
|
# sequenced behind it.
|
|
@@ -2524,7 +2661,7 @@ function Get-WaveOf {
|
|
|
2524
2661
|
function Get-MaxOpenWave {
|
|
2525
2662
|
param([string]$ContextDir)
|
|
2526
2663
|
$best = 0
|
|
2527
|
-
foreach ($f in @(Get-ChildItem -
|
|
2664
|
+
foreach ($f in @(Get-ChildItem -LiteralPath $ContextDir -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue)) {
|
|
2528
2665
|
$t = Get-Content $f.FullName -Raw -ErrorAction SilentlyContinue
|
|
2529
2666
|
if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { $w = [int]$Matches[1]; if ($w -gt $best) { $best = $w } }
|
|
2530
2667
|
}
|
|
@@ -2552,7 +2689,7 @@ function Invoke-ContextPaths {
|
|
|
2552
2689
|
$context = if ($ctxRel -eq 'specs') { '' } else { Split-Path $ctxRel -Leaf }
|
|
2553
2690
|
$master = Join-Path $ctx 'domain-model.md'
|
|
2554
2691
|
$pending = @(); $files = @(); $waves = @()
|
|
2555
|
-
foreach ($f in @(Get-ChildItem -
|
|
2692
|
+
foreach ($f in @(Get-ChildItem -LiteralPath $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
2556
2693
|
$cr = $f.Name.Substring('domain-model.'.Length)
|
|
2557
2694
|
$cr = $cr.Substring(0, $cr.Length - '.delta.md'.Length)
|
|
2558
2695
|
$wave = 1
|
|
@@ -2659,7 +2796,7 @@ function Invoke-CrList {
|
|
|
2659
2796
|
$rows = @()
|
|
2660
2797
|
$changesDir = Join-Path $fd 'changes'
|
|
2661
2798
|
if (Test-Path $changesDir) {
|
|
2662
|
-
foreach ($d in @(Get-ChildItem -
|
|
2799
|
+
foreach ($d in @(Get-ChildItem -LiteralPath $changesDir -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
2663
2800
|
if ($d.Name -eq 'archive') { continue }
|
|
2664
2801
|
$dspec = Join-Path $d.FullName 'delta-spec.md'
|
|
2665
2802
|
$dmodel = Join-Path $ctx ("domain-model." + $d.Name + ".delta.md")
|
|
@@ -2718,6 +2855,10 @@ function Invoke-CrList {
|
|
|
2718
2855
|
}
|
|
2719
2856
|
Write-Json ([ordered]@{
|
|
2720
2857
|
changes = @($rows); ids = @($rows | ForEach-Object { $_.cr_id }); count = $rows.Count
|
|
2858
|
+
# The same ids as one readable string, for an operator message. A list renders as
|
|
2859
|
+
# its JSON in a rendered string, which is right for a payload and wrong for a
|
|
2860
|
+
# sentence.
|
|
2861
|
+
ids_csv = (@($rows | ForEach-Object { $_.cr_id }) -join ', ')
|
|
2721
2862
|
select_options = @($opts); matched_label = $matched
|
|
2722
2863
|
main_label = $(if ($incMain) { $mainLabel } else { '' })
|
|
2723
2864
|
new_label = $(if ($incNew) { $newLabel } else { '' })
|
|
@@ -2755,7 +2896,7 @@ function Invoke-CrPaths {
|
|
|
2755
2896
|
$master = Join-Path $ctx 'domain-model.md'
|
|
2756
2897
|
$wave = [int](Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
|
|
2757
2898
|
$pred = @(); $sib = @(); $predRows = @(); $sibRows = @(); $predSpecs = @()
|
|
2758
|
-
foreach ($f in @(Get-ChildItem -
|
|
2899
|
+
foreach ($f in @(Get-ChildItem -LiteralPath $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
2759
2900
|
$other = $f.Name.Substring('domain-model.'.Length)
|
|
2760
2901
|
$other = $other.Substring(0, $other.Length - '.delta.md'.Length)
|
|
2761
2902
|
if ($other -eq $crId) { continue }
|
|
@@ -2766,9 +2907,18 @@ function Invoke-CrPaths {
|
|
|
2766
2907
|
# A change's model delta sits at CONTEXT level and its delta-spec at FEATURE level,
|
|
2767
2908
|
# so the spec of another open change is found by walking the context's features
|
|
2768
2909
|
# rather than assuming it belongs to this one.
|
|
2910
|
+
#
|
|
2911
|
+
# The walk is EXPLICIT and every test is -LiteralPath, so no part of the repo's own
|
|
2912
|
+
# path is ever read as a pattern: -Path treats [ and ] as a wildcard character
|
|
2913
|
+
# class, which would silently return nothing for a context living under a directory
|
|
2914
|
+
# whose name contains either. Enumerating the feature directories and testing one
|
|
2915
|
+
# literal path each keeps this a provable twin of the bash glob on every platform.
|
|
2769
2916
|
$sRel = ''
|
|
2770
|
-
$sHit = @(Get-ChildItem -
|
|
2771
|
-
|
|
2917
|
+
$sHit = @(Get-ChildItem -LiteralPath $ctx -Directory -ErrorAction SilentlyContinue |
|
|
2918
|
+
ForEach-Object { Join-Path $_.FullName "changes/$other/delta-spec.md" } |
|
|
2919
|
+
Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
|
|
2920
|
+
Sort-Object)
|
|
2921
|
+
if ($sHit.Count -gt 0) { $sRel = ConvertTo-RepoRelative -Path $sHit[0] -RepoRoot $repo }
|
|
2772
2922
|
$row = [ordered]@{ cr_id = $other; file = $rel; wave = $w; delta_spec = $sRel }
|
|
2773
2923
|
if ($w -lt $wave) {
|
|
2774
2924
|
$pred += $rel; $predRows += $row
|
|
@@ -2794,6 +2944,17 @@ function Invoke-CrPaths {
|
|
|
2794
2944
|
predecessor_docs = @($pred + $predSpecs)
|
|
2795
2945
|
# Same or higher wave = nothing orders it against this one. Visible, never assumed.
|
|
2796
2946
|
sibling_files = @($sib); siblings = @($sibRows); sibling_count = $sib.Count
|
|
2947
|
+
# EVERYTHING A PLANNER SHOULD SEE, in one list, for the same one-var reason.
|
|
2948
|
+
# Siblings belong in it as much as predecessors do: a planner told "do not assume a
|
|
2949
|
+
# same-wave delta, and say so where one touches the same aggregate" cannot name an
|
|
2950
|
+
# overlap it was never shown, and the gate one stage later raises a finding for the
|
|
2951
|
+
# silence. Their MODEL deltas only — the aggregate a sibling touches is what makes
|
|
2952
|
+
# it an overlap, and their delta-specs would double the payload to say nothing more.
|
|
2953
|
+
related_docs = @($pred + $predSpecs + $sib)
|
|
2954
|
+
# Who is who, as text for a prompt. The file list alone cannot say which entries
|
|
2955
|
+
# are committed to landing first and which are merely open beside this one.
|
|
2956
|
+
predecessor_ids = (@($predRows | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
|
|
2957
|
+
sibling_ids = (@($sibRows | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
|
|
2797
2958
|
})
|
|
2798
2959
|
}
|
|
2799
2960
|
|
|
@@ -2842,7 +3003,7 @@ function Invoke-CrOverlap {
|
|
|
2842
3003
|
$wave = [int](Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
|
|
2843
3004
|
$mine = @(Get-DeltaAggregates -Text $mineTxt)
|
|
2844
3005
|
$rows = @(); $parts = @(); $npred = 0; $nsib = 0; $nsucc = 0
|
|
2845
|
-
foreach ($f in @(Get-ChildItem -
|
|
3006
|
+
foreach ($f in @(Get-ChildItem -LiteralPath $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
2846
3007
|
$other = $f.Name.Substring('domain-model.'.Length)
|
|
2847
3008
|
$other = $other.Substring(0, $other.Length - '.delta.md'.Length)
|
|
2848
3009
|
if ($other -eq $crId) { continue }
|
|
@@ -2863,6 +3024,9 @@ function Invoke-CrOverlap {
|
|
|
2863
3024
|
}
|
|
2864
3025
|
Write-Json ([ordered]@{
|
|
2865
3026
|
cr_id = $crId; wave = $wave; aggregates = @($mine)
|
|
3027
|
+
# The same names as one readable string, for a prompt. A list renders as its JSON
|
|
3028
|
+
# in a rendered string, which is right for a payload and wrong for a sentence.
|
|
3029
|
+
aggregates_csv = (@($mine) -join ', ')
|
|
2866
3030
|
overlaps = @($rows); overlap_count = $rows.Count
|
|
2867
3031
|
predecessor_overlaps = $npred; sibling_overlaps = $nsib
|
|
2868
3032
|
successor_overlaps = $nsucc
|
|
@@ -2890,7 +3054,26 @@ function Invoke-CrActivate {
|
|
|
2890
3054
|
if (-not $crId) { Write-Error 'cr-activate: --cr-id is required'; exit 1 }
|
|
2891
3055
|
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2892
3056
|
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
|
|
3057
|
+
$out = Invoke-ActiveVersion -FeatureDir $fd -Op 'activate' -CrId $crId
|
|
3058
|
+
$out
|
|
3059
|
+
# A REFUSAL IS AN ERROR, NOT A RESULT — so it leaves through the exit code, like every
|
|
3060
|
+
# other way this command can fail. When the id is not in the ledger nothing moves and
|
|
3061
|
+
# the JSON reports active_change: "", which is precisely what the calling step binds;
|
|
3062
|
+
# an empty active change then reads as "no change" in every condition downstream, and
|
|
3063
|
+
# the run continues against a target it never selected. Refusing here turns that into
|
|
3064
|
+
# the engine's own stop, carrying the message to the operator. Twin of bash cr-activate.
|
|
3065
|
+
#
|
|
3066
|
+
# THIS IS REACHABLE, not defensive: cr-list offers what is on DISK (changes/<id>/ is
|
|
3067
|
+
# the authority for "open") and reports `tracked` separately, so a change whose
|
|
3068
|
+
# directory survived a crashed run is still offered while the ledger has forgotten it.
|
|
3069
|
+
#
|
|
3070
|
+
# `ok` starts true and is flipped only by a refusal, so an EXPLICIT false is the test.
|
|
3071
|
+
$res = $null
|
|
3072
|
+
try { $res = ($out | ConvertFrom-Json) } catch { return }
|
|
3073
|
+
if ($null -ne $res -and $res.PSObject.Properties.Name -contains 'ok' -and -not $res.ok) {
|
|
3074
|
+
Write-Error "cr-activate: $($res.error)"
|
|
3075
|
+
exit 1
|
|
3076
|
+
}
|
|
2894
3077
|
}
|
|
2895
3078
|
|
|
2896
3079
|
# ─────────────────────────── cr-wave ───────────────────────────────────────
|
|
@@ -2906,6 +3089,9 @@ function Invoke-CrWave {
|
|
|
2906
3089
|
{ $_ -in '--wave','-Wave' } { $wave = $Rest[++$i] }
|
|
2907
3090
|
}
|
|
2908
3091
|
}
|
|
3092
|
+
# The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
|
|
3093
|
+
# first token. A bare id is unchanged by this.
|
|
3094
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
2909
3095
|
if (-not $crId) { Write-Error 'cr-wave: --cr-id is required'; exit 1 }
|
|
2910
3096
|
if ($wave -notmatch '^\d+$') { Write-Error 'cr-wave: --wave must be a positive integer'; exit 1 }
|
|
2911
3097
|
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
@@ -2932,6 +3118,9 @@ function Invoke-CrSetup {
|
|
|
2932
3118
|
{ $_ -in '--force','-Force' } { $force = '1' }
|
|
2933
3119
|
}
|
|
2934
3120
|
}
|
|
3121
|
+
# Before the generated default, so a menu LABEL passed as --cr-id becomes its id
|
|
3122
|
+
# rather than a directory name with spaces in it. Empty stays empty and is generated.
|
|
3123
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
2935
3124
|
# A random suffix, not the bare timestamp: two changes opened in the same second
|
|
2936
3125
|
# would otherwise collide on one id, and with parallel deltas that is reachable.
|
|
2937
3126
|
if (-not $crId) { $crId = 'CR-' + ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss')) + '-' + (Get-RandSuffix) }
|
|
@@ -2969,31 +3158,92 @@ function Invoke-CrSetup {
|
|
|
2969
3158
|
if ($t) { Copy-Item $t $dmodel } else { New-Item -ItemType File -Force -Path $dmodel | Out-Null }
|
|
2970
3159
|
$created = $true
|
|
2971
3160
|
}
|
|
2972
|
-
# Scripts stamp ids, dates and
|
|
3161
|
+
# Scripts stamp ids, dates and versions — the model never writes them, and the delta
|
|
3162
|
+
# quality checklists REQUIRE a complete header, so every placeholder the templates
|
|
3163
|
+
# carry has to be filled here or it ships as a literal '[DATE]' / '[vX.Y]'.
|
|
3164
|
+
#
|
|
3165
|
+
# BASE VERSION is the master version this delta is being authored AGAINST, read from
|
|
3166
|
+
# the ledger that was just updated — 'open' does not move the number, so what comes
|
|
3167
|
+
# back is the current one. It is what a rebase check compares against later: a master
|
|
3168
|
+
# that moved between the opening of a change and its merge is a change authored on a
|
|
3169
|
+
# document that no longer exists in that form.
|
|
3170
|
+
#
|
|
3171
|
+
# BASE CONTEXT VERSION is the same idea one level up, and its source is the master
|
|
3172
|
+
# model's own '**Context version**:' header rather than any ledger — there is no
|
|
3173
|
+
# context-level version file (⌛ that, and the close-delta bump that would move the
|
|
3174
|
+
# number, are still to build). While nothing bumps it, every delta reads the same
|
|
3175
|
+
# v1.0; the field is still correct, and it starts carrying real information the moment
|
|
3176
|
+
# something stamps the master.
|
|
3177
|
+
$createdOn = [DateTime]::UtcNow.ToString('yyyy-MM-dd')
|
|
3178
|
+
$baseVersion = ''
|
|
3179
|
+
try { $baseVersion = "$(($avOpen | ConvertFrom-Json).version)" } catch { $baseVersion = '' }
|
|
3180
|
+
if (-not $baseVersion) { $baseVersion = 'v1.0' }
|
|
3181
|
+
if (-not $baseVersion.StartsWith('v')) { $baseVersion = "v$baseVersion" }
|
|
3182
|
+
$baseCtx = Get-MdHeader -Path (Join-Path $ctx 'domain-model.md') -Name 'Context version'
|
|
3183
|
+
if (-not $baseCtx) { $baseCtx = 'v1.0' }
|
|
3184
|
+
# Both version fields are normalised to the same 'vX.Y' shape. base_version comes from
|
|
3185
|
+
# a machine ledger and baseCtx from a markdown header a human may have written as
|
|
3186
|
+
# '1.0', and a rebase check compares these as strings — two spellings of one version
|
|
3187
|
+
# would read as a move that never happened.
|
|
3188
|
+
if (-not $baseCtx.StartsWith('v')) { $baseCtx = "v$baseCtx" }
|
|
3189
|
+
|
|
2973
3190
|
Set-HeaderStamp -Path $dspec -Name 'Wave' -Value $wave
|
|
3191
|
+
Set-HeaderStamp -Path $dspec -Name 'Created' -Value $createdOn
|
|
3192
|
+
Set-HeaderStamp -Path $dspec -Name 'Base version' -Value $baseVersion
|
|
2974
3193
|
Set-HeaderStamp -Path $dmodel -Name 'Wave' -Value $wave
|
|
3194
|
+
Set-HeaderStamp -Path $dmodel -Name 'Created' -Value $createdOn
|
|
3195
|
+
Set-HeaderStamp -Path $dmodel -Name 'Base context version' -Value $baseCtx
|
|
2975
3196
|
|
|
2976
3197
|
Write-Json ([ordered]@{
|
|
2977
3198
|
cr_id = $crId; cr_dir = $crDir; delta_spec = $dspec
|
|
2978
3199
|
delta_model = $dmodel; context_dir = (ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo)
|
|
2979
3200
|
wave = [int]$wave; open_count = $openCount; title = $title; created = $created
|
|
3201
|
+
created_on = $createdOn; base_version = $baseVersion; base_context_version = $baseCtx
|
|
2980
3202
|
})
|
|
2981
3203
|
}
|
|
2982
3204
|
|
|
3205
|
+
# ARCHIVE, in either of the two ways a change leaves the open set.
|
|
3206
|
+
#
|
|
3207
|
+
# default CLOSED — it shipped. The directory is archived and the version bumps, which
|
|
3208
|
+
# is close-delta's whole job and the behaviour every existing caller gets.
|
|
3209
|
+
# --recall WITHDRAWN — it never shipped. The same archive, and NO version bump: a
|
|
3210
|
+
# version records what a change shipped as, and a recalled change shipped
|
|
3211
|
+
# nothing, so bumping would burn a number on work that was never merged.
|
|
3212
|
+
#
|
|
3213
|
+
# The two were fused until recall-delta needed the archive half on its own, which is why
|
|
3214
|
+
# withdrawing a change could not be "archive alone". Splitting them here rather than in
|
|
3215
|
+
# each caller keeps ONE place that knows how a change's documents are put away — the
|
|
3216
|
+
# directory, and the model delta that lives beside the master rather than inside it.
|
|
2983
3217
|
function Invoke-CrArchive {
|
|
2984
3218
|
param([string[]]$Rest)
|
|
2985
|
-
$crId = ''; $fdArg = ''
|
|
3219
|
+
$crId = ''; $fdArg = ''; $recall = $false; $reason = ''
|
|
2986
3220
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2987
3221
|
switch ($Rest[$i]) {
|
|
2988
3222
|
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
2989
3223
|
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3224
|
+
'--recall' { $recall = $true }
|
|
3225
|
+
'--reason' { $reason = $Rest[++$i] }
|
|
2990
3226
|
}
|
|
2991
3227
|
}
|
|
3228
|
+
# A caller that picked from a user_select menu hands over the whole LABEL
|
|
3229
|
+
# ("CR-… · wave 2 · title"), not the id. Every other cr-* op takes the first token;
|
|
3230
|
+
# this one did not, so a label arrived here as a directory name that does not exist
|
|
3231
|
+
# and the archive silently moved nothing.
|
|
3232
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
2992
3233
|
if (-not $crId) { Write-Error 'cr-archive: --cr-id required'; exit 1 }
|
|
2993
3234
|
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2994
3235
|
if (-not $fd) { Write-Error 'cr-archive: no feature set and no --feature-dir'; exit 1 }
|
|
3236
|
+
# THE LEDGER EDIT COMES FIRST, because it is the only step that can REFUSE. `recall`
|
|
3237
|
+
# will not withdraw a change active_version.json does not carry, and a refusal has to
|
|
3238
|
+
# be a no-op: archiving before asking would move the directory and only then report
|
|
3239
|
+
# that the change was never open. `close` cannot refuse, so its side effects are
|
|
3240
|
+
# unchanged by the ordering.
|
|
3241
|
+
$op = if ($recall) { 'recall' } else { 'close' }
|
|
3242
|
+
$av = Invoke-ActiveVersion -FeatureDir $fd -Op $op -CrId $crId | ConvertFrom-Json
|
|
3243
|
+
$ledgerOk = $(if ($null -ne $av.ok) { [bool]$av.ok } else { $true })
|
|
3244
|
+
|
|
2995
3245
|
$crDir = Join-Path $fd "changes/$crId"; $archRoot = Join-Path $fd 'changes/archive'; $archived = ''
|
|
2996
|
-
if (Test-Path $crDir) {
|
|
3246
|
+
if ($ledgerOk -and (Test-Path $crDir)) {
|
|
2997
3247
|
New-Item -ItemType Directory -Force -Path $archRoot | Out-Null
|
|
2998
3248
|
$dest = Join-Path $archRoot $crId
|
|
2999
3249
|
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
|
|
@@ -3005,15 +3255,537 @@ function Invoke-CrArchive {
|
|
|
3005
3255
|
# file behind would keep showing a landed change as in-flight to every other delta.
|
|
3006
3256
|
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3007
3257
|
$dmodel = Join-Path $ctx "domain-model.$crId.delta.md"
|
|
3008
|
-
|
|
3258
|
+
$archivedModel = ''
|
|
3259
|
+
if ($ledgerOk -and (Test-Path $dmodel)) {
|
|
3009
3260
|
$destDir = Join-Path $archRoot $crId
|
|
3010
3261
|
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
|
3011
|
-
|
|
3262
|
+
$archivedModel = Join-Path $destDir 'domain-model.delta.md'
|
|
3263
|
+
Move-Item -Force $dmodel $archivedModel
|
|
3264
|
+
}
|
|
3265
|
+
# A RECALLED change is marked as such where the evidence will be looked for — inside
|
|
3266
|
+
# its own archive — and the marker is written by the script, not by a model, because
|
|
3267
|
+
# everything on it is a fact the script already holds: the id, the date, and the
|
|
3268
|
+
# reason the caller passed.
|
|
3269
|
+
$marker = ''
|
|
3270
|
+
if ($recall -and $archived) {
|
|
3271
|
+
$marker = Join-Path $archived 'RECALLED.md'
|
|
3272
|
+
$when = [DateTime]::UtcNow.ToString('yyyy-MM-dd')
|
|
3273
|
+
$why = if ("$reason".Trim()) { "$reason".Trim() } else { '(not stated)' }
|
|
3274
|
+
$body = @(
|
|
3275
|
+
"# RECALLED — $crId", '',
|
|
3276
|
+
"**Recalled**: $when",
|
|
3277
|
+
"**Reason**: $why", '',
|
|
3278
|
+
'This change was WITHDRAWN before it was merged. Its delta documents are archived here',
|
|
3279
|
+
'unchanged; the feature''s spec.md and the context''s domain-model.md never carried it,',
|
|
3280
|
+
'and the version was not bumped — no release shipped this work.', '',
|
|
3281
|
+
'Any code /appweaver implement generated for this change is NOT reverted by a recall.',
|
|
3282
|
+
'Revert it with git if it should not stay.', ''
|
|
3283
|
+
) -join "`n"
|
|
3284
|
+
[System.IO.File]::WriteAllText($marker, $body)
|
|
3285
|
+
}
|
|
3286
|
+
$out = [ordered]@{
|
|
3287
|
+
# The ledger edit is what can refuse (recall will not touch a change the ledger
|
|
3288
|
+
# does not carry), so its verdict is this command's verdict.
|
|
3289
|
+
ok = $ledgerOk
|
|
3290
|
+
archived_dir = $archived
|
|
3291
|
+
archived_model = $archivedModel
|
|
3292
|
+
recalled = $recall
|
|
3293
|
+
recall_marker = $marker
|
|
3294
|
+
version = $av.version
|
|
3295
|
+
previous = $av.previous
|
|
3296
|
+
# Which of the two ways it left the open set. Exactly one is ever non-empty.
|
|
3297
|
+
closed_change = $(if ($av.PSObject.Properties['closed_change']) { $av.closed_change } else { '' })
|
|
3298
|
+
recalled_change = $(if ($av.PSObject.Properties['recalled_change']) { $av.recalled_change } else { '' })
|
|
3299
|
+
active_change = $av.active_change
|
|
3300
|
+
open_changes = @($av.open_changes)
|
|
3301
|
+
open_count = $av.open_count
|
|
3302
|
+
}
|
|
3303
|
+
if ($av.PSObject.Properties['error']) { $out['error'] = $av.error }
|
|
3304
|
+
Write-Json $out
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
# ──────────────────────────── context-version-bump ─────────────────────────
|
|
3308
|
+
# Move the BOUNDED CONTEXT's version, the way version-stamp moves a feature's.
|
|
3309
|
+
#
|
|
3310
|
+
# The model's header has always declared itself script-stamped — '**Context version**:
|
|
3311
|
+
# v1.0 <!-- script-stamped; close-delta bumps it when a closing delta touched the
|
|
3312
|
+
# model -->' — and specify tells the authoring model not to write it. Nothing wrote it,
|
|
3313
|
+
# so every context sat at the template's v1.0 for its whole life and the one instruction
|
|
3314
|
+
# the model was given about that field was the only thing keeping it accurate.
|
|
3315
|
+
#
|
|
3316
|
+
# WHY IT IS A CONTEXT-LEVEL NUMBER. A feature's version says what that feature shipped;
|
|
3317
|
+
# the model is shared by every feature in the context, so a change to it is a change to
|
|
3318
|
+
# all of them and no single feature's version can record it. The two spines move
|
|
3319
|
+
# independently and neither is derived from the other.
|
|
3320
|
+
#
|
|
3321
|
+
# ONLY WHEN THE MODEL ACTUALLY CHANGED. The caller passes --touched-model; a behaviour-only
|
|
3322
|
+
# change closes without moving this number, exactly as it leaves domain-model.md alone.
|
|
3323
|
+
# Refusing to bump is reported, not an error — most changes never touch the model.
|
|
3324
|
+
function Invoke-ContextVersionBump {
|
|
3325
|
+
param([string[]]$Rest)
|
|
3326
|
+
$fdArg = ''; $touched = $false; $crId = ''
|
|
3327
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3328
|
+
switch ($Rest[$i]) {
|
|
3329
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3330
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
3331
|
+
# Takes a VALUE rather than being a bare flag: a workflow cannot omit an
|
|
3332
|
+
# argument conditionally (an include's arg list is rendered whole), so the
|
|
3333
|
+
# caller always passes it and says true or false. Anything but 'true' is false.
|
|
3334
|
+
'--touched-model' { $touched = ("$($Rest[++$i])".Trim() -eq 'true') }
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
# The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
|
|
3338
|
+
# first token. Here the id only reaches the Change History row, so a label would have
|
|
3339
|
+
# been recorded verbatim rather than failing loudly — the quietest of the family's
|
|
3340
|
+
# inconsistencies, and the reason to close it rather than wait for a caller to hit it.
|
|
3341
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
3342
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3343
|
+
if (-not $fd) { Write-Error 'context-version-bump: no feature set and no --feature-dir'; exit 1 }
|
|
3344
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3345
|
+
$model = Join-Path $ctx 'domain-model.md'
|
|
3346
|
+
|
|
3347
|
+
$current = Get-MdHeader -Path $model -Name 'Context version'
|
|
3348
|
+
if (-not $current) { $current = 'v1.0' }
|
|
3349
|
+
if (-not $current.StartsWith('v')) { $current = "v$current" }
|
|
3350
|
+
|
|
3351
|
+
$bumped = $false; $next = $current; $reason = ''
|
|
3352
|
+
if (-not (Test-Path $model)) {
|
|
3353
|
+
$reason = 'this feature''s context has no domain-model.md, so there is no context version to move'
|
|
3354
|
+
} elseif (-not $touched) {
|
|
3355
|
+
$reason = 'the closing change carried no model delta, so the context is unchanged'
|
|
3356
|
+
} else {
|
|
3357
|
+
$m = [regex]::Match($current.Trim(), '^v?(\d+)(?:\.(\d+))?')
|
|
3358
|
+
$maj = if ($m.Success) { [int]$m.Groups[1].Value } else { 1 }
|
|
3359
|
+
$mnr = if ($m.Success -and $m.Groups[2].Success) { [int]$m.Groups[2].Value } else { 0 }
|
|
3360
|
+
$next = 'v{0}.{1}' -f $maj, ($mnr + 1)
|
|
3361
|
+
Set-HeaderStamp -Path $model -Name 'Context version' -Value $next
|
|
3362
|
+
$bumped = $true
|
|
3363
|
+
$reason = 'the closing change merged a model delta into this context'
|
|
3012
3364
|
}
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3365
|
+
Write-Json ([ordered]@{
|
|
3366
|
+
ok = $true; bumped = $bumped; previous = $current; version = $next
|
|
3367
|
+
model = (ConvertTo-RepoRelative -Path $model -RepoRoot $repo)
|
|
3368
|
+
model_exists = (Test-Path $model)
|
|
3369
|
+
context = (ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo)
|
|
3370
|
+
reason = $reason
|
|
3371
|
+
})
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
# ───────────────────────────── cr-recall-check ─────────────────────────────
|
|
3375
|
+
# The two mechanical refusals recall-delta needs, answered in one call from evidence on
|
|
3376
|
+
# disk rather than from anything a model read.
|
|
3377
|
+
#
|
|
3378
|
+
# MERGED. finalize-delta appends a Change History row carrying the CR id to spec.md, so
|
|
3379
|
+
# the id appearing in that file is the on-disk proof the merge ran. A merged change is
|
|
3380
|
+
# not recallable: the masters describe behaviour that includes it, and taking it back is
|
|
3381
|
+
# document surgery — the remedy is a NEW change that reverses it.
|
|
3382
|
+
#
|
|
3383
|
+
# DEPENDANTS. A wave is a dependency level: a higher wave is planned and built ON TOP of
|
|
3384
|
+
# the lower ones. Withdrawing a lower-wave change therefore strands every open change
|
|
3385
|
+
# above it. `depends_on` is stamped nowhere yet, so the wave is the only dependency signal
|
|
3386
|
+
# that actually exists, and it is the conservative one — same-wave changes are independent
|
|
3387
|
+
# by definition and are never counted here.
|
|
3388
|
+
# ───────────────────────────── cr-snapshot ─────────────────────────────────
|
|
3389
|
+
# Copy the two MASTERS into changes/<id>/pre-merge/ immediately before finalize-delta
|
|
3390
|
+
# rewrites them. The merge is the one act of the CR loop that edits a long-lived
|
|
3391
|
+
# hand-curated document in place, and until this existed the only record of what those
|
|
3392
|
+
# documents said beforehand was git — a different tool's answer to the question, and
|
|
3393
|
+
# unavailable at all if the merge and the commit are not interleaved.
|
|
3394
|
+
#
|
|
3395
|
+
# WHAT IT IS FOR. A recall after a merge has to put the masters back, and it may only do so
|
|
3396
|
+
# when nothing else landed in between — the pre-merge copy is the content to restore and
|
|
3397
|
+
# cr-merge-check's base_version comparison is what answers whether restoring is still safe.
|
|
3398
|
+
#
|
|
3399
|
+
# It is a COPY, not a move, and is overwritten on a re-run: finalize-delta may be attempted
|
|
3400
|
+
# more than once (a red downstream fence aborts it), and the snapshot that matters is the
|
|
3401
|
+
# one taken by the attempt that actually merged. The manifest records the version and date
|
|
3402
|
+
# so a reader can tell which. The snapshot travels WITH the change, so a closed change
|
|
3403
|
+
# carries the before-image of the masters it moved, in the archive, forever.
|
|
3404
|
+
# Twin of bash cr-snapshot.
|
|
3405
|
+
function Invoke-CrSnapshot {
|
|
3406
|
+
param([string[]]$Rest)
|
|
3407
|
+
$crId = ''; $fdArg = ''
|
|
3408
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3409
|
+
switch ($Rest[$i]) {
|
|
3410
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
3411
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
3415
|
+
if (-not $crId) { Write-Error 'cr-snapshot: --cr-id required'; exit 1 }
|
|
3416
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3417
|
+
if (-not $fd) { Write-Error 'cr-snapshot: no feature set and no --feature-dir'; exit 1 }
|
|
3418
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3419
|
+
|
|
3420
|
+
$snap = Join-Path $fd "changes/$crId/pre-merge"
|
|
3421
|
+
if (-not (Test-Path -LiteralPath $snap)) { New-Item -ItemType Directory -Path $snap -Force | Out-Null }
|
|
3422
|
+
$spec = Join-Path $fd 'spec.md'
|
|
3423
|
+
$model = Join-Path $ctx 'domain-model.md'
|
|
3424
|
+
# Flat names, not the source tree's shape: there are exactly two masters and they live
|
|
3425
|
+
# at different levels, so a nested copy would encode the layout of the day it was taken.
|
|
3426
|
+
[string[]]$copied = @()
|
|
3427
|
+
foreach ($pair in @(@($spec, 'spec.md'), @($model, 'domain-model.md'))) {
|
|
3428
|
+
if (Test-Path -LiteralPath $pair[0] -PathType Leaf) {
|
|
3429
|
+
Copy-Item -LiteralPath $pair[0] -Destination (Join-Path $snap $pair[1]) -Force
|
|
3430
|
+
$copied += $pair[1]
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
$version = ''
|
|
3435
|
+
$avPath = Join-Path $fd 'active_version.json'
|
|
3436
|
+
if (Test-Path -LiteralPath $avPath) {
|
|
3437
|
+
try { $version = "$((Get-Content $avPath -Raw | ConvertFrom-Json).version)" } catch { $version = '' }
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
$manifest = [ordered]@{
|
|
3441
|
+
cr_id = $crId
|
|
3442
|
+
taken = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
3443
|
+
version = $version
|
|
3444
|
+
context_version = (Get-MdHeader -Path $model -Name 'Context version')
|
|
3445
|
+
files = @($copied)
|
|
3446
|
+
sources = [ordered]@{
|
|
3447
|
+
'spec.md' = (ConvertTo-RepoRelative -Path $spec -RepoRoot $repo)
|
|
3448
|
+
'domain-model.md' = (ConvertTo-RepoRelative -Path $model -RepoRoot $repo)
|
|
3449
|
+
}
|
|
3450
|
+
# Stated in the file itself, because the restore is the reader's whole reason for
|
|
3451
|
+
# opening it and the condition on it is not obvious from the content.
|
|
3452
|
+
note = ("Masters as they stood immediately before finalize-delta merged $crId. " +
|
|
3453
|
+
"Restoring is only safe while no other change finalized afterwards — " +
|
|
3454
|
+
"cr-merge-check's base_version comparison is what answers that.")
|
|
3455
|
+
}
|
|
3456
|
+
$mPath = Join-Path $snap 'manifest.json'
|
|
3457
|
+
($manifest | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $mPath -Encoding utf8
|
|
3458
|
+
|
|
3459
|
+
Write-Json ([ordered]@{
|
|
3460
|
+
ok = $true; cr_id = $crId
|
|
3461
|
+
dir = (ConvertTo-RepoRelative -Path $snap -RepoRoot $repo)
|
|
3462
|
+
files = @($copied); count = @($copied).Count; version = $version
|
|
3463
|
+
manifest = (ConvertTo-RepoRelative -Path $mPath -RepoRoot $repo)
|
|
3464
|
+
})
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
# ───────────────────────────── cr-merge-check ──────────────────────────────
|
|
3468
|
+
# The two mechanical refusals finalize-delta needs, answered in one call from evidence on
|
|
3469
|
+
# disk — the merge-side twin of cr-recall-check below, shaped the same way so the two gates
|
|
3470
|
+
# of the CR loop read alike.
|
|
3471
|
+
#
|
|
3472
|
+
# REBASE. cr-setup stamps '**Base version**:' into the delta-spec when the change opens: the
|
|
3473
|
+
# master version the delta was authored AGAINST. If spec.md has moved since — because
|
|
3474
|
+
# another change finalized and closed in between — the delta's MODIFIED items name a BEFORE
|
|
3475
|
+
# the master no longer says, and merging them is last-writer-wins against work that already
|
|
3476
|
+
# landed. Both sides are normalised to 'vX.Y' by cr-setup, so this is an exact string
|
|
3477
|
+
# comparison and never a judgement. A delta with no stamped header reports an empty
|
|
3478
|
+
# base_version and does NOT block: an absent stamp is no evidence of a move.
|
|
3479
|
+
#
|
|
3480
|
+
# PREDECESSORS. A wave is a dependency level: a LOWER wave is committed to landing first,
|
|
3481
|
+
# and plan puts exactly those deltas in the planner's context as the base this one builds
|
|
3482
|
+
# on. Merging out of wave order folds in behaviour assuming a predecessor the masters do not
|
|
3483
|
+
# yet carry. depends_on is stamped nowhere, so the wave is the only dependency signal that
|
|
3484
|
+
# exists; same-wave changes are independent by definition and are never counted.
|
|
3485
|
+
#
|
|
3486
|
+
# The scan is CONTEXT-WIDE, matching cr-paths' definition of a predecessor, and reads both
|
|
3487
|
+
# halves — the changes/<id>/ directories AND the pending model deltas — because a
|
|
3488
|
+
# behaviour-only change has no model delta and would be invisible to a scan of those alone.
|
|
3489
|
+
# Twin of bash cr-merge-check.
|
|
3490
|
+
function Invoke-CrMergeCheck {
|
|
3491
|
+
param([string[]]$Rest)
|
|
3492
|
+
$crId = ''; $fdArg = ''
|
|
3493
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3494
|
+
switch ($Rest[$i]) {
|
|
3495
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
3496
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
3500
|
+
if (-not $crId) { Write-Error 'cr-merge-check: --cr-id required'; exit 1 }
|
|
3501
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3502
|
+
if (-not $fd) { Write-Error 'cr-merge-check: no feature set and no --feature-dir'; exit 1 }
|
|
3503
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3504
|
+
|
|
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
|
+
function Format-Ver([string]$v) {
|
|
3513
|
+
$v = "$v".Trim()
|
|
3514
|
+
if (-not $v) { return '' }
|
|
3515
|
+
if ($v.StartsWith('v')) { return $v } else { return "v$v" }
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
$mySpec = Join-Path $fd "changes/$crId/delta-spec.md"
|
|
3519
|
+
$wave = Get-WaveFile $mySpec
|
|
3520
|
+
$baseVersion = Format-Ver (Get-MdHeader -Path $mySpec -Name 'Base version')
|
|
3521
|
+
|
|
3522
|
+
# The master's CURRENT version, from the ledger — the same authority version-read
|
|
3523
|
+
# reports, so the two can never disagree about what the number is.
|
|
3524
|
+
$current = ''
|
|
3525
|
+
$avPath = Join-Path $fd 'active_version.json'
|
|
3526
|
+
if (Test-Path -LiteralPath $avPath) {
|
|
3527
|
+
try { $current = Format-Ver "$((Get-Content $avPath -Raw | ConvertFrom-Json).version)" } catch { $current = '' }
|
|
3528
|
+
}
|
|
3529
|
+
if (-not $current) { $current = Format-Ver (Get-MdHeader -Path (Join-Path $fd 'spec.md') -Name 'Version') }
|
|
3530
|
+
|
|
3531
|
+
# Only a STAMPED base that DISAGREES blocks. No stamp, or no readable current version,
|
|
3532
|
+
# is absence of evidence and is not treated as a move.
|
|
3533
|
+
$rebaseNeeded = [bool]($baseVersion -and $current -and ($baseVersion -ne $current))
|
|
3534
|
+
|
|
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 = '' }
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
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
|
+
})
|
|
3577
|
+
|
|
3578
|
+
Write-Json ([ordered]@{
|
|
3579
|
+
ok = $true; cr_id = $crId; wave = $wave
|
|
3580
|
+
exists = (Test-Path -LiteralPath (Join-Path $fd "changes/$crId"))
|
|
3581
|
+
# REBASE half — the masters moved because a change CLOSED and bumped the number.
|
|
3582
|
+
base_version = $baseVersion; current_version = $current
|
|
3583
|
+
rebase_needed = $rebaseNeeded
|
|
3584
|
+
base_stamped = [bool]$baseVersion
|
|
3585
|
+
blockers = @($blockers); blocker_count = @($blockers).Count
|
|
3586
|
+
has_blockers = (@($blockers).Count -gt 0)
|
|
3587
|
+
blocker_ids = (@($blockers | ForEach-Object { '{0} (wave {1})' -f $_.cr_id, $_.wave }) -join ', ')
|
|
3588
|
+
# The other way the masters move: a change MERGED and has not closed yet.
|
|
3589
|
+
merged_open = @($mergedOpen); merged_open_count = @($mergedOpen).Count
|
|
3590
|
+
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))
|
|
3595
|
+
})
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
function Invoke-CrRecallCheck {
|
|
3599
|
+
param([string[]]$Rest)
|
|
3600
|
+
$crId = ''; $fdArg = ''
|
|
3601
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3602
|
+
switch ($Rest[$i]) {
|
|
3603
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
3604
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
3608
|
+
if (-not $crId) { Write-Error 'cr-recall-check: --cr-id required'; exit 1 }
|
|
3609
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3610
|
+
if (-not $fd) { Write-Error 'cr-recall-check: no feature set and no --feature-dir'; exit 1 }
|
|
3611
|
+
$changes = Join-Path $fd 'changes'
|
|
3612
|
+
$crDir = Join-Path $changes $crId
|
|
3613
|
+
|
|
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
|
+
|
|
3623
|
+
$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
|
+
}
|
|
3629
|
+
}
|
|
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
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
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)
|
|
3651
|
+
})
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
# ──────────────────── plan-gate-stamp / plan-gate-check ────────────────────
|
|
3655
|
+
# THE RECEIPT THAT analyze-plan PASSED, and over WHICH BYTES.
|
|
3656
|
+
#
|
|
3657
|
+
# implement generates from the lane plans and deliberately does NOT read the spec — its
|
|
3658
|
+
# own header says "analyze-plan already conformed the plans to it". That sentence was an
|
|
3659
|
+
# assumption about the order the operator ran things in, not a fact anything checked: the
|
|
3660
|
+
# lane gates test that the plan FILE EXISTS and nothing more, so `plan` → edit spec.md →
|
|
3661
|
+
# `implement` generated from a plan that was never held against the spec it now contradicts.
|
|
3662
|
+
#
|
|
3663
|
+
# WHY NOT COMPARE THE PLANS' `**Version**:` HEADERS. They cannot answer this. version-stamp
|
|
3664
|
+
# moves spec.md, backend-plan.md and frontend-plan.md to the same number in LOCKSTEP at
|
|
3665
|
+
# close-delta, whether or not a plan was re-derived — close-delta says so itself ("a plan
|
|
3666
|
+
# header stamped here is stating the version the slice ships as, not that the plan was
|
|
3667
|
+
# re-derived for it"). Comparing them reads equal always, which is worse than not checking.
|
|
3668
|
+
#
|
|
3669
|
+
# WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
|
|
3670
|
+
# from that spec". The question implement depends on is stronger — "were these plans PROVEN
|
|
3671
|
+
# 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.
|
|
3675
|
+
#
|
|
3676
|
+
# The absence of a receipt is INFORMATION, not an error: it means this feature has never
|
|
3677
|
+
# 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.
|
|
3680
|
+
function Get-FileSha {
|
|
3681
|
+
# -LiteralPath throughout: -Path reads [ and ] as a wildcard character class, so a
|
|
3682
|
+
# repo living under a bracketed directory name would silently hash nothing.
|
|
3683
|
+
param([string]$Path)
|
|
3684
|
+
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
|
|
3685
|
+
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLower()
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
function Invoke-PlanGateStamp {
|
|
3689
|
+
param([string[]]$Rest)
|
|
3690
|
+
$fdArg = ''; $specVersion = ''; $mode = 'master'
|
|
3691
|
+
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
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3699
|
+
if (-not $fd) { Write-Error 'plan-gate-stamp: no feature set and no --feature-dir'; exit 1 }
|
|
3700
|
+
$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
|
+
})
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
function Invoke-PlanGateCheck {
|
|
3738
|
+
param([string[]]$Rest)
|
|
3739
|
+
$fdArg = ''
|
|
3740
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3741
|
+
switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } }
|
|
3742
|
+
}
|
|
3743
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3744
|
+
if (-not $fd) { Write-Error 'plan-gate-check: no feature set and no --feature-dir'; exit 1 }
|
|
3745
|
+
$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
|
|
3017
3789
|
}
|
|
3018
3790
|
|
|
3019
3791
|
# Stamp a version into the MASTER documents' `**Version**:` headers — the
|
|
@@ -3028,14 +3800,16 @@ function Invoke-CrArchive {
|
|
|
3028
3800
|
# Only the value moves; any trailing inline comment on the header line is kept.
|
|
3029
3801
|
function Invoke-VersionStamp {
|
|
3030
3802
|
param([string[]]$Rest)
|
|
3031
|
-
$ver = ''; $fdArg = ''
|
|
3803
|
+
$ver = ''; $fdArg = ''; $crId = ''
|
|
3032
3804
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3033
3805
|
switch ($Rest[$i]) {
|
|
3034
3806
|
{ $_ -in '--version','-Version' } { $ver = $Rest[++$i] }
|
|
3035
3807
|
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
3808
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
3036
3809
|
}
|
|
3037
3810
|
}
|
|
3038
3811
|
if (-not $ver) { Write-Error 'version-stamp: --version required'; exit 1 }
|
|
3812
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
3039
3813
|
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
3040
3814
|
if (-not $fd) { Write-Error 'version-stamp: no feature set and no --feature-dir'; exit 1 }
|
|
3041
3815
|
$ver = $ver.Trim()
|
|
@@ -3060,8 +3834,45 @@ function Invoke-VersionStamp {
|
|
|
3060
3834
|
[System.IO.File]::WriteAllText($p, $out)
|
|
3061
3835
|
$stamped += $name
|
|
3062
3836
|
}
|
|
3837
|
+
|
|
3838
|
+
# THE CHANGE HISTORY ROW, completed with the number the headers just took. The row is
|
|
3839
|
+
# found by its CR cell, not by position: rows are appended newest-last and a feature may
|
|
3840
|
+
# carry many, so "the last row" would be right only until two changes were open at once.
|
|
3841
|
+
# Only a cell still reading '(pending)' is written — a row already carrying a version
|
|
3842
|
+
# belongs to a change that closed, and re-stamping it would rewrite history.
|
|
3843
|
+
$rowFilled = $false; $rowWas = ''
|
|
3844
|
+
if ($crId) {
|
|
3845
|
+
$p = Join-Path $fd 'spec.md'
|
|
3846
|
+
if (Test-Path -LiteralPath $p) {
|
|
3847
|
+
$lines = [System.IO.File]::ReadAllLines($p)
|
|
3848
|
+
$changed = $false
|
|
3849
|
+
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
3850
|
+
if ($changed) { break }
|
|
3851
|
+
$line = $lines[$i]
|
|
3852
|
+
if ($line.Contains($crId) -and $line.TrimStart().StartsWith('|')) {
|
|
3853
|
+
$cells = $line -split '\|'
|
|
3854
|
+
if ($cells.Count -gt 2 -and
|
|
3855
|
+
@('(pending)','pending','—','-','') -contains $cells[1].Trim().ToLower()) {
|
|
3856
|
+
$rowWas = $cells[1].Trim()
|
|
3857
|
+
$cells[1] = " $ver "
|
|
3858
|
+
$lines[$i] = ($cells -join '|')
|
|
3859
|
+
$changed = $true
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
if ($changed) {
|
|
3864
|
+
[System.IO.File]::WriteAllLines($p, $lines)
|
|
3865
|
+
$rowFilled = $true
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3063
3870
|
Write-Json ([ordered]@{ ok = $true; version = $ver; stamped = $stamped; unchanged = $unchanged;
|
|
3064
|
-
missing = $missing; no_header = $noHeader; count = $stamped.Count
|
|
3871
|
+
missing = $missing; no_header = $noHeader; count = $stamped.Count
|
|
3872
|
+
# Whether the merge's pending row was completed. False with a
|
|
3873
|
+
# --cr-id is the ordinary outcome for a re-run (already filled)
|
|
3874
|
+
# and for a merge predating the pending convention.
|
|
3875
|
+
cr_id = $crId; history_row_filled = $rowFilled; history_row_was = $rowWas })
|
|
3065
3876
|
}
|
|
3066
3877
|
|
|
3067
3878
|
# ─────────────────────────────── main ──────────────────────────────────────
|
|
@@ -3107,9 +3918,15 @@ switch ($sub) {
|
|
|
3107
3918
|
'cr-activate' { Invoke-CrActivate -Rest $rest }
|
|
3108
3919
|
'cr-wave' { Invoke-CrWave -Rest $rest }
|
|
3109
3920
|
'cr-archive' { Invoke-CrArchive -Rest $rest }
|
|
3921
|
+
'cr-recall-check' { Invoke-CrRecallCheck -Rest $rest }
|
|
3922
|
+
'cr-merge-check' { Invoke-CrMergeCheck -Rest $rest }
|
|
3923
|
+
'cr-snapshot' { Invoke-CrSnapshot -Rest $rest }
|
|
3924
|
+
'context-version-bump' { Invoke-ContextVersionBump -Rest $rest }
|
|
3110
3925
|
'context-paths' { Invoke-ContextPaths -Rest $rest }
|
|
3111
3926
|
'model-scaffold' { Invoke-ModelScaffold -Rest $rest }
|
|
3112
3927
|
'version-stamp' { Invoke-VersionStamp -Rest $rest }
|
|
3928
|
+
'plan-gate-stamp' { Invoke-PlanGateStamp -Rest $rest }
|
|
3929
|
+
'plan-gate-check' { Invoke-PlanGateCheck -Rest $rest }
|
|
3113
3930
|
'checklist-read' { Invoke-ChecklistRead -Rest $rest }
|
|
3114
3931
|
'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
|
|
3115
3932
|
'detect-project' { Invoke-DetectProject -Rest $rest }
|