aw-spec-cli 0.1.15 → 0.1.17
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 +18 -18
- package/assets/.appweaver/ORCH-DIALECT.md +1 -3
- package/assets/.appweaver/RULE-PARTITION.md +29 -13
- package/assets/.appweaver/STACK-PROFILES.md +10 -9
- package/assets/.appweaver/orchestrator/.state/smoke.state.json +32 -0
- package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +4 -1
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +251 -0
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +104 -35
- package/assets/.appweaver/orchestrator/orchestrator.py +5 -4
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +94 -36
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/domain-model-template.skeleton.md +115 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +2 -9
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +16 -40
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +38 -17
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +8 -8
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +10 -11
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +673 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +535 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +643 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +78 -626
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +7 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +16 -14
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +243 -144
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +882 -464
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +32 -91
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +832 -477
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-bounded-context.orch.yaml +132 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +38 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +271 -892
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +1096 -2911
- package/assets/.appweaver/scripts/bash/aw-xref.sh +1 -2
- package/assets/.appweaver/scripts/bash/common.sh +47 -0
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +936 -2766
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +1 -2
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +174 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +6 -2
- package/assets/.appweaver/scripts/python/aw-xref.py +185 -292
- package/assets/.appweaver/templates/constitution-ui-template.md +1 -1
- package/assets/.appweaver/templates/delta-design-template.md +120 -30
- package/assets/.appweaver/templates/delta-domain-model-template.md +81 -0
- package/assets/.appweaver/templates/delta-spec-template.md +43 -17
- package/assets/.appweaver/templates/design-template.md +68 -98
- package/assets/.appweaver/templates/domain-model-template.md +130 -0
- package/assets/.appweaver/templates/okf-concept-template.md +2 -2
- package/assets/.appweaver/templates/source-map-template.md +3 -3
- package/assets/.appweaver/templates/spec-template.md +100 -122
- package/assets/.appweaver/templates/ui-delta-design-template.md +159 -0
- package/assets/.appweaver/templates/ui-design-template.md +46 -75
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +7 -18
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +34 -39
- package/assets/guardrails/requirement-spec.txt +2 -2
- package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +0 -105
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +0 -251
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +0 -749
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +0 -253
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +0 -540
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +0 -597
- package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +0 -945
- package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +0 -1040
- package/assets/.appweaver/templates/story-template.md +0 -147
|
@@ -124,8 +124,7 @@ function Invoke-ResolveDomain {
|
|
|
124
124
|
# wrong corpus is how a Java project ends up being handed C# rules.
|
|
125
125
|
function Invoke-ResolveTech {
|
|
126
126
|
param([string[]]$Rest)
|
|
127
|
-
$commandName = 'aw-spec-
|
|
128
|
-
# so a hand-run defaults to a LIVE command, not a dead one.
|
|
127
|
+
$commandName = 'aw-spec-design'
|
|
129
128
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
130
129
|
switch ($Rest[$i]) {
|
|
131
130
|
'--command' { $commandName = $Rest[++$i] }
|
|
@@ -388,7 +387,7 @@ function Invoke-CreateChecklist {
|
|
|
388
387
|
$content = if ($kind -eq 'delta') { @"
|
|
389
388
|
# Delta-Spec Quality Checklist: $featureName — $crId
|
|
390
389
|
|
|
391
|
-
**Purpose**: Validate the CHANGE's delta-spec before proceeding to
|
|
390
|
+
**Purpose**: Validate the CHANGE's delta-spec before proceeding to plan
|
|
392
391
|
**Created**: $today
|
|
393
392
|
**Change**: [delta-spec.md](../delta-spec.md)
|
|
394
393
|
|
|
@@ -432,12 +431,12 @@ function Invoke-CreateChecklist {
|
|
|
432
431
|
|
|
433
432
|
- This checklist grades the DELTA DOCUMENT only. Accuracy against the master spec
|
|
434
433
|
(id continuity, conflicts with existing requirements) is checked downstream by
|
|
435
|
-
/appweaver analyze-delta
|
|
436
|
-
- Items marked incomplete require delta-spec updates before /appweaver
|
|
434
|
+
/appweaver analyze-plan-delta.
|
|
435
|
+
- Items marked incomplete require delta-spec updates before /appweaver plan
|
|
437
436
|
"@ } else { @"
|
|
438
437
|
# Specification Quality Checklist: $featureName
|
|
439
438
|
|
|
440
|
-
**Purpose**: Validate specification completeness and quality before proceeding to
|
|
439
|
+
**Purpose**: Validate specification completeness and quality before proceeding to plan
|
|
441
440
|
**Created**: $today
|
|
442
441
|
**Feature**: [spec.md](../spec.md)
|
|
443
442
|
|
|
@@ -454,8 +453,7 @@ function Invoke-CreateChecklist {
|
|
|
454
453
|
- [ ] Requirements are testable and unambiguous
|
|
455
454
|
- [ ] Success criteria are measurable
|
|
456
455
|
- [ ] Success criteria are technology-agnostic (no implementation details)
|
|
457
|
-
- [ ]
|
|
458
|
-
- [ ] The newest Change History row names every requirement id this version added or changed
|
|
456
|
+
- [ ] All acceptance scenarios are defined
|
|
459
457
|
- [ ] Edge cases are identified
|
|
460
458
|
- [ ] Scope is clearly bounded
|
|
461
459
|
- [ ] Dependencies and assumptions identified
|
|
@@ -476,14 +474,14 @@ function Invoke-CreateChecklist {
|
|
|
476
474
|
|
|
477
475
|
## Feature Readiness
|
|
478
476
|
|
|
479
|
-
- [ ]
|
|
480
|
-
- [ ]
|
|
477
|
+
- [ ] All functional requirements have clear acceptance criteria
|
|
478
|
+
- [ ] User scenarios cover primary flows
|
|
481
479
|
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
|
482
480
|
- [ ] No implementation details leak into specification
|
|
483
481
|
|
|
484
482
|
## Notes
|
|
485
483
|
|
|
486
|
-
- Items marked incomplete require spec updates before /appweaver
|
|
484
|
+
- Items marked incomplete require spec updates before /appweaver plan
|
|
487
485
|
"@ }
|
|
488
486
|
Set-Content -Path $checklistFile -Value $content -Encoding UTF8
|
|
489
487
|
Write-Json ([ordered]@{ checklist_file = $checklistFile; kind = $kind; target = $targetFile; created = $created })
|
|
@@ -656,6 +654,167 @@ function Invoke-LaneProbe {
|
|
|
656
654
|
# (case-insensitive) — (1) specs/ sub-folders (already scaffolded/adopted) and
|
|
657
655
|
# (2) the OKF bundle's Aggregate concepts (the brownfield feature map, resolved by
|
|
658
656
|
# the SAME shared python core the bash twin calls). Empty source 2 on greenfield.
|
|
657
|
+
# ─────────────────────── context.json (bounded context) ───────────────────
|
|
658
|
+
# Twin of the bash read_context_json_context_directory / feature_base_rel pair. A
|
|
659
|
+
# bounded context is the container a feature lives in — specs/<Context>/<Feature>/ —
|
|
660
|
+
# and it owns the one domain-model.md every spec inside it references.
|
|
661
|
+
#
|
|
662
|
+
# ABSENCE IS A VALID STATE: a repo that predates bounded contexts keeps its features
|
|
663
|
+
# flat under specs/, and every caller falls back to that layout when no context is
|
|
664
|
+
# active. Both shells resolve it in exactly one place so the two cannot diverge on
|
|
665
|
+
# where a feature lands.
|
|
666
|
+
function Get-ContextDirectory {
|
|
667
|
+
param([string]$Repo)
|
|
668
|
+
$cj = Join-Path $Repo '.appweaver/context.json'
|
|
669
|
+
if (-not (Test-Path $cj)) { return '' }
|
|
670
|
+
try {
|
|
671
|
+
$c = Get-Content $cj -Raw | ConvertFrom-Json
|
|
672
|
+
$v = "$($c.context_directory)"
|
|
673
|
+
if ($v) { return $v }
|
|
674
|
+
} catch { return '' }
|
|
675
|
+
return ''
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function Get-FeatureBaseRel {
|
|
679
|
+
param([string]$Repo)
|
|
680
|
+
$cd = Get-ContextDirectory -Repo $Repo
|
|
681
|
+
if ($cd -and (Test-Path (Join-Path $Repo $cd))) { return $cd }
|
|
682
|
+
return 'specs'
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
# A BOUNDED CONTEXT directory, not a feature: it holds features instead of being one.
|
|
686
|
+
# Only reachable with no context active — inside a context, a feature carries its own
|
|
687
|
+
# spec.md and never matches. Without this test a repo that has contexts but has not
|
|
688
|
+
# selected one lists them as pickable features, and picking one would scaffold a
|
|
689
|
+
# spec.md on top of a directory that already holds features. Twin of the bash
|
|
690
|
+
# is_context() / set-feature guard.
|
|
691
|
+
function Test-IsContextDir {
|
|
692
|
+
param([string]$Dir)
|
|
693
|
+
if (-not (Test-Path $Dir)) { return $false }
|
|
694
|
+
if (Test-Path (Join-Path $Dir 'spec.md')) { return $false }
|
|
695
|
+
if (Test-Path (Join-Path $Dir 'domain-model.md')) { return $true }
|
|
696
|
+
$kids = @(Get-ChildItem -Directory $Dir -ErrorAction SilentlyContinue |
|
|
697
|
+
Where-Object { Test-Path (Join-Path $_.FullName 'spec.md') })
|
|
698
|
+
return ($kids.Count -gt 0)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
# ─────────────────────────── list-contexts ─────────────────────────────────
|
|
702
|
+
# Twin of bash cmd_list_contexts, written natively (see ACCEPTED_BASH_ONLY in
|
|
703
|
+
# aw-payload-parity.py — this pair is dual-authored by decision and must be reviewed
|
|
704
|
+
# together). A directory under specs/ carrying its OWN spec.md is a feature; one that
|
|
705
|
+
# does not is a bounded context. Pre-context features are reported as legacy_features
|
|
706
|
+
# so an un-migrated repo says so rather than showing an empty list.
|
|
707
|
+
function Invoke-ListContexts {
|
|
708
|
+
param([string[]]$Rest)
|
|
709
|
+
$preselect = ''
|
|
710
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
711
|
+
if ($Rest[$i] -in '--preselect','-Preselect') { $preselect = $Rest[++$i] }
|
|
712
|
+
}
|
|
713
|
+
$repo = Get-RepoRoot
|
|
714
|
+
$base = Join-Path $repo 'specs'
|
|
715
|
+
$sentinel = '+ Create a new bounded context'
|
|
716
|
+
$activeRel = Get-ContextDirectory -Repo $repo
|
|
717
|
+
if ($activeRel -and -not (Test-Path (Join-Path $repo $activeRel))) { $activeRel = '' }
|
|
718
|
+
$active = ''
|
|
719
|
+
if ($activeRel) { $active = Split-Path $activeRel.TrimEnd('/') -Leaf }
|
|
720
|
+
$contexts = @(); $names = @(); $legacy = @(); $seen = @{}
|
|
721
|
+
if (Test-Path $base) {
|
|
722
|
+
foreach ($d in (Get-ChildItem -Directory $base -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
723
|
+
if (Test-Path (Join-Path $d.FullName 'spec.md')) { $legacy += $d.Name; continue }
|
|
724
|
+
# EVERY subdirectory is a feature of this context, whether or not specify
|
|
725
|
+
# has run yet — select-feature creates the directory first and spec.md
|
|
726
|
+
# arrives later, so counting only specified ones would report a feature the
|
|
727
|
+
# user just created as absent, and disagree with list-features.
|
|
728
|
+
$feats = @(Get-ChildItem -Directory $d.FullName -ErrorAction SilentlyContinue |
|
|
729
|
+
Sort-Object Name | ForEach-Object { $_.Name })
|
|
730
|
+
$specified = @($feats | Where-Object { Test-Path (Join-Path $d.FullName (Join-Path $_ 'spec.md')) })
|
|
731
|
+
$names += $d.Name; $seen[$d.Name.ToLower()] = $true
|
|
732
|
+
$contexts += [ordered]@{
|
|
733
|
+
name = $d.Name
|
|
734
|
+
dir = "specs/$($d.Name)"
|
|
735
|
+
features = @($feats)
|
|
736
|
+
feature_count = $feats.Count
|
|
737
|
+
specified_count = $specified.Count
|
|
738
|
+
has_domain_model = (Test-Path (Join-Path $d.FullName 'domain-model.md'))
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
$opts = @($names)
|
|
743
|
+
if ($preselect -and (-not $seen.ContainsKey($preselect.ToLower()))) { $opts += $preselect }
|
|
744
|
+
$opts += $sentinel
|
|
745
|
+
# scope_ok answers ONE question for select-feature: is it safe to resolve a feature
|
|
746
|
+
# directory right now? Safe when a context is active, or when the repo has no
|
|
747
|
+
# contexts at all (the flat pre-context layout). Unsafe when contexts exist and none
|
|
748
|
+
# is selected — the feature would silently land outside every one of them. The
|
|
749
|
+
# engine has no compound conditions, so the AND is computed here.
|
|
750
|
+
$scopeOk = [bool]$active -or ($names.Count -eq 0)
|
|
751
|
+
$scopeMessage = ''
|
|
752
|
+
if (-not $scopeOk) {
|
|
753
|
+
$scopeMessage = "$($names.Count) bounded context(s) exist ($($names -join ', ')) but none is selected. Run /appweaver select-bounded-context first — without it a new feature is created at specs/<Feature>, outside every context and with no domain model to share."
|
|
754
|
+
}
|
|
755
|
+
Write-Json ([ordered]@{
|
|
756
|
+
contexts = @($contexts)
|
|
757
|
+
names = @($names)
|
|
758
|
+
count = $names.Count
|
|
759
|
+
select_options = @($opts)
|
|
760
|
+
sentinel = $sentinel
|
|
761
|
+
legacy_features = @($legacy)
|
|
762
|
+
legacy_count = $legacy.Count
|
|
763
|
+
active = $active
|
|
764
|
+
active_dir = $activeRel
|
|
765
|
+
# The specs/ subtree features resolve in — the active context, or plain
|
|
766
|
+
# 'specs' on a flat repo. Always populated, so a caller can name the location
|
|
767
|
+
# without branching on whether a context is set.
|
|
768
|
+
feature_base = $(if ($activeRel) { $activeRel } else { 'specs' })
|
|
769
|
+
scope_ok = $scopeOk
|
|
770
|
+
scope_message = $scopeMessage
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
# ─────────────────────────── set-context ───────────────────────────────────
|
|
775
|
+
# Twin of bash cmd_set_context. Creates the context directory but NOT
|
|
776
|
+
# domain-model.md — specify is the only writer of the model, and an empty
|
|
777
|
+
# placeholder would read as "the model exists" to every later gate.
|
|
778
|
+
function Invoke-SetContext {
|
|
779
|
+
param([string[]]$Rest)
|
|
780
|
+
$raw = ''
|
|
781
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
782
|
+
if ($Rest[$i] -in '--context','-Context') { $raw = $Rest[++$i] }
|
|
783
|
+
}
|
|
784
|
+
if (-not $raw) { Write-Error 'set-context: --context is required'; exit 1 }
|
|
785
|
+
|
|
786
|
+
# Same PascalCase normalization as set-feature: siblings in one path, so
|
|
787
|
+
# 'order management' and 'Order Management' cannot produce two directories.
|
|
788
|
+
$cleaned = ($raw -replace '[^A-Za-z0-9]+', ' ').Trim()
|
|
789
|
+
$context = ConvertTo-Pascal -In $cleaned
|
|
790
|
+
if ($context -notmatch '^[A-Za-z][A-Za-z0-9]*$') {
|
|
791
|
+
Write-Error "set-context: '$raw' is not a valid bounded context name (need a letter-led alphanumeric name, e.g. Sales)"; exit 1
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
$repo = Get-RepoRoot
|
|
795
|
+
$contextDirRel = "specs/$context"
|
|
796
|
+
$contextDir = Join-Path $repo $contextDirRel
|
|
797
|
+
# Refuse to shadow a flat feature: its spec.md already claims that directory.
|
|
798
|
+
if (Test-Path (Join-Path $contextDir 'spec.md')) {
|
|
799
|
+
Write-Error "set-context: refusing — 'specs/$context' is a feature (it has its own spec.md), not a bounded context. Pick another name, or move that feature under a context first."; exit 1
|
|
800
|
+
}
|
|
801
|
+
$existed = Test-Path $contextDir
|
|
802
|
+
New-Item -ItemType Directory -Force -Path $contextDir | Out-Null
|
|
803
|
+
New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver') | Out-Null
|
|
804
|
+
[ordered]@{ context = $context; context_directory = $contextDirRel } | ConvertTo-Json -Compress |
|
|
805
|
+
Set-Content -Path (Join-Path $repo '.appweaver/context.json') -Encoding UTF8
|
|
806
|
+
|
|
807
|
+
Write-Json ([ordered]@{
|
|
808
|
+
context = $context
|
|
809
|
+
context_dir = $contextDirRel
|
|
810
|
+
domain_model = "$contextDirRel/domain-model.md"
|
|
811
|
+
existed = [bool]$existed
|
|
812
|
+
created = (-not $existed)
|
|
813
|
+
domain_model_exists = (Test-Path (Join-Path $contextDir 'domain-model.md'))
|
|
814
|
+
written = $true
|
|
815
|
+
})
|
|
816
|
+
}
|
|
817
|
+
|
|
659
818
|
function Invoke-ListFeatures {
|
|
660
819
|
param([string[]]$Rest)
|
|
661
820
|
$preselect = ''; $bundle = ''
|
|
@@ -664,7 +823,10 @@ function Invoke-ListFeatures {
|
|
|
664
823
|
elseif ($Rest[$i] -in '--bundle','-Bundle') { $bundle = $Rest[++$i] }
|
|
665
824
|
}
|
|
666
825
|
$repo = Get-RepoRoot
|
|
667
|
-
|
|
826
|
+
# Features come from the ACTIVE BOUNDED CONTEXT's directory when one is set, and
|
|
827
|
+
# from specs/ itself when none is.
|
|
828
|
+
$baseRel = Get-FeatureBaseRel -Repo $repo
|
|
829
|
+
$base = Join-Path $repo $baseRel
|
|
668
830
|
$sentinel = '+ Create a new feature'
|
|
669
831
|
if (-not $bundle) { $bundle = 'knowledge' }
|
|
670
832
|
if (-not [System.IO.Path]::IsPathRooted($bundle)) { $bundle = Join-Path $repo $bundle }
|
|
@@ -682,13 +844,14 @@ function Invoke-ListFeatures {
|
|
|
682
844
|
$features = @(); $names = @(); $seen = @{}
|
|
683
845
|
if (Test-Path $base) {
|
|
684
846
|
foreach ($d in (Get-ChildItem -Directory $base -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
847
|
+
if (Test-IsContextDir -Dir $d.FullName) { continue }
|
|
685
848
|
$ver = ''; $activeChange = ''
|
|
686
849
|
$avf = Join-Path $d.FullName 'active_version.json'
|
|
687
850
|
if (Test-Path $avf) { try { $av = Get-Content $avf -Raw | ConvertFrom-Json; $ver = "$($av.version)"; $activeChange = "$($av.active_change)" } catch { $ver = '' } }
|
|
688
851
|
$names += $d.Name; $seen[$d.Name.ToLower()] = $true
|
|
689
852
|
$features += [ordered]@{
|
|
690
853
|
name = $d.Name
|
|
691
|
-
dir = "
|
|
854
|
+
dir = "$baseRel/$($d.Name)"
|
|
692
855
|
source = 'spec'
|
|
693
856
|
has_spec = (Test-Path (Join-Path $d.FullName 'spec.md'))
|
|
694
857
|
# 'design*.md', NOT 'design.*.md': the second dot made this miss the single
|
|
@@ -712,7 +875,7 @@ function Invoke-ListFeatures {
|
|
|
712
875
|
$names += $nm; $seen[$nm.ToLower()] = $true
|
|
713
876
|
$features += [ordered]@{
|
|
714
877
|
name = $nm
|
|
715
|
-
dir = "
|
|
878
|
+
dir = "$baseRel/$nm"
|
|
716
879
|
source = 'okf'
|
|
717
880
|
has_spec = $false
|
|
718
881
|
adopted = $false
|
|
@@ -752,14 +915,25 @@ function Invoke-SetFeature {
|
|
|
752
915
|
}
|
|
753
916
|
|
|
754
917
|
$repo = Get-RepoRoot
|
|
755
|
-
|
|
918
|
+
# Created inside the ACTIVE BOUNDED CONTEXT when one is set, directly under specs/
|
|
919
|
+
# when none is. feature.json stores the resulting path, so the nesting is invisible
|
|
920
|
+
# to every downstream caller.
|
|
921
|
+
$baseRel = Get-FeatureBaseRel -Repo $repo
|
|
922
|
+
$featureDirRel = "$baseRel/$feature"
|
|
756
923
|
$featureDir = Join-Path $repo $featureDirRel
|
|
924
|
+
# Refuse to scaffold a feature on top of a bounded context — the mirror of
|
|
925
|
+
# set-context's refusal to shadow a feature.
|
|
926
|
+
if (Test-IsContextDir -Dir $featureDir) {
|
|
927
|
+
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
|
+
}
|
|
757
929
|
$existed = Test-Path $featureDir
|
|
758
930
|
New-Item -ItemType Directory -Force -Path $featureDir | Out-Null
|
|
759
931
|
New-Item -ItemType Directory -Force -Path (Join-Path $repo '.appweaver') | Out-Null
|
|
760
932
|
@{ feature_directory = $featureDirRel } | ConvertTo-Json -Compress |
|
|
761
933
|
Set-Content -Path (Join-Path $repo '.appweaver/feature.json') -Encoding UTF8
|
|
762
934
|
|
|
935
|
+
$context = ''
|
|
936
|
+
if ($baseRel -ne 'specs') { $context = $baseRel -replace '^specs/', '' }
|
|
763
937
|
Write-Json ([ordered]@{
|
|
764
938
|
feature = $feature
|
|
765
939
|
feature_dir = $featureDirRel
|
|
@@ -767,6 +941,7 @@ function Invoke-SetFeature {
|
|
|
767
941
|
existed = [bool]$existed
|
|
768
942
|
created = (-not $existed)
|
|
769
943
|
spec_exists = (Test-Path (Join-Path $featureDir 'spec.md'))
|
|
944
|
+
context = $context
|
|
770
945
|
})
|
|
771
946
|
}
|
|
772
947
|
|
|
@@ -797,7 +972,14 @@ def bump(v):
|
|
|
797
972
|
|
|
798
973
|
d, existed = load()
|
|
799
974
|
d.setdefault("feature", feature); d.setdefault("version", "v1.0"); d.setdefault("active_change", "")
|
|
975
|
+
# Migrate pre-parallel state forward on read: a file written when only one change could
|
|
976
|
+
# be open carries active_change and no list. Seed the list from it rather than reporting
|
|
977
|
+
# zero open changes on a feature that has one.
|
|
978
|
+
if "open_changes" not in d:
|
|
979
|
+
d["open_changes"] = [d["active_change"]] if d.get("active_change") else []
|
|
800
980
|
out = {"feature": d["feature"], "version": d["version"], "active_change": d.get("active_change", ""),
|
|
981
|
+
"open_changes": list(d.get("open_changes") or []),
|
|
982
|
+
"open_count": len([c for c in (d.get("open_changes") or []) if c]),
|
|
801
983
|
"exists": existed, "path": "active_version.json", "ok": True}
|
|
802
984
|
if op == "read":
|
|
803
985
|
pass
|
|
@@ -805,13 +987,10 @@ elif op == "init":
|
|
|
805
987
|
# BOOTSTRAP IN PROGRESS, and ONLY when the caller says it is authoring a first draft
|
|
806
988
|
# (--bootstrap). A first run that dies after writing spec.md but before its ledger row
|
|
807
989
|
# leaves a document that looks, to the next FRESH invocation, exactly like a spec
|
|
808
|
-
# written before the ledger existed —
|
|
809
|
-
#
|
|
810
|
-
#
|
|
811
|
-
#
|
|
812
|
-
# it, and only once the v1.0 row actually names ids read out of the finished document —
|
|
813
|
-
# so a bootstrap whose row never landed is re-run and REPAIRED rather than being frozen
|
|
814
|
-
# behind a v1.1 that can never go back and fix it.
|
|
990
|
+
# written before the ledger existed — which passes. specify then took the EXTEND path
|
|
991
|
+
# and applied the same input a second time, on top of a v1.0 that was never finished.
|
|
992
|
+
# Checkpoint resume was always fine; a fresh invocation was not, and nothing
|
|
993
|
+
# distinguished the two. This marker does.
|
|
815
994
|
#
|
|
816
995
|
# The flag is the CALLER's, not this primitive's: adopt inits the same file after
|
|
817
996
|
# reversing a spec out of existing code, and a marker raised there would tell the next
|
|
@@ -820,25 +999,68 @@ elif op == "init":
|
|
|
820
999
|
d["bootstrap_pending"] = True
|
|
821
1000
|
save(d)
|
|
822
1001
|
out["created"] = (not existed)
|
|
1002
|
+
elif op == "bootstrap-done":
|
|
1003
|
+
# THE ONLY THING THAT CLEARS THE MARKER, called by the phase that raised it once the
|
|
1004
|
+
# first draft is actually finished. It is a separate op, not a side effect of writing
|
|
1005
|
+
# the spec, because "the file exists" is exactly the signal that could not be trusted:
|
|
1006
|
+
# a crashed run leaves one too. Idempotent — clearing a marker that is already down is
|
|
1007
|
+
# the ordinary case on a re-run, and is not an error.
|
|
1008
|
+
was = bool(d.pop("bootstrap_pending", None))
|
|
1009
|
+
save(d)
|
|
1010
|
+
out["cleared"] = was
|
|
823
1011
|
elif op == "open":
|
|
824
|
-
|
|
825
|
-
|
|
1012
|
+
# MANY CHANGES MAY BE OPEN AT ONCE. Every specify call on an established feature
|
|
1013
|
+
# opens its own change, so the record is the open_changes LIST; the single-active
|
|
1014
|
+
# guard that used to refuse a second one is gone.
|
|
1015
|
+
#
|
|
1016
|
+
# active_change is still written, and is the MOST RECENTLY OPENED. Every stage
|
|
1017
|
+
# downstream of specify still resolves "the" change through that field, so keeping
|
|
1018
|
+
# it correct is what lets parallel AUTHORING land before those stages learn to take
|
|
1019
|
+
# an explicit --cr-id. Reading open_changes is how a caller sees the rest.
|
|
1020
|
+
opens = [c for c in (d.get("open_changes") or []) if c]
|
|
1021
|
+
if cr and cr not in opens:
|
|
1022
|
+
opens.append(cr)
|
|
1023
|
+
d["open_changes"] = opens
|
|
1024
|
+
d["active_change"] = cr or (opens[-1] if opens else "")
|
|
1025
|
+
save(d)
|
|
1026
|
+
out["active_change"] = d["active_change"]; out["opened"] = cr
|
|
1027
|
+
out["open_changes"] = opens; out["open_count"] = len(opens)
|
|
1028
|
+
if cr in (d.get("open_changes") or [])[:-1]: out["reopened"] = True
|
|
1029
|
+
elif op == "activate":
|
|
1030
|
+
# Point active_change at an already-open change. Nothing else moves: no version, no
|
|
1031
|
+
# membership, no dates. This is how a CHOSEN delta reaches the stages that still
|
|
1032
|
+
# resolve "the" change through active_change, without pretending it was just opened.
|
|
1033
|
+
opens = [c for c in (d.get("open_changes") or []) if c]
|
|
1034
|
+
if cr and cr not in opens:
|
|
826
1035
|
out["ok"] = False
|
|
827
|
-
out["error"] = ("
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
out["
|
|
834
|
-
if cur and cur == cr: out["reopened"] = True # idempotent re-open of the same CR
|
|
1036
|
+
out["error"] = ("'%s' is not an open change on this feature (open: %s)"
|
|
1037
|
+
% (cr, ", ".join(opens) or "none"))
|
|
1038
|
+
else:
|
|
1039
|
+
d["active_change"] = cr or d.get("active_change", "")
|
|
1040
|
+
save(d)
|
|
1041
|
+
out["active_change"] = d["active_change"]; out["activated"] = cr
|
|
1042
|
+
out["open_changes"] = opens; out["open_count"] = len(opens)
|
|
835
1043
|
elif op == "close":
|
|
836
|
-
|
|
837
|
-
|
|
1044
|
+
# Closes ONE change by id (falling back to active_change when none is named, which
|
|
1045
|
+
# is the pre-parallel caller). The version bumps once per change materialized, and
|
|
1046
|
+
# active_change falls back to whatever is still open so the downstream stages keep
|
|
1047
|
+
# resolving a real change while others remain in flight.
|
|
1048
|
+
prev = d["version"]
|
|
1049
|
+
opens = [c for c in (d.get("open_changes") or []) if c]
|
|
1050
|
+
if not opens and d.get("active_change"):
|
|
1051
|
+
opens = [d["active_change"]] # state written before open_changes existed
|
|
1052
|
+
closed = cr or d.get("active_change", "")
|
|
1053
|
+
if closed and closed in opens:
|
|
1054
|
+
opens.remove(closed)
|
|
1055
|
+
d["version"] = bump(prev)
|
|
1056
|
+
elif closed: # not tracked, but a real change was named
|
|
838
1057
|
d["version"] = bump(prev)
|
|
839
|
-
d["
|
|
840
|
-
|
|
1058
|
+
d["open_changes"] = opens
|
|
1059
|
+
d["active_change"] = opens[-1] if opens else ""
|
|
1060
|
+
save(d)
|
|
1061
|
+
out["version"] = d["version"]; out["active_change"] = d["active_change"]
|
|
841
1062
|
out["previous"] = prev; out["closed_change"] = closed
|
|
1063
|
+
out["open_changes"] = opens; out["open_count"] = len(opens)
|
|
842
1064
|
out["bootstrap_pending"] = bool(d.get("bootstrap_pending"))
|
|
843
1065
|
out["has_change"] = bool(out.get("active_change", "")) # clean branch signal for design
|
|
844
1066
|
# What a close from THIS state would produce. One bump() authority, read by BOTH
|
|
@@ -855,15 +1077,17 @@ function Invoke-ActiveVersion {
|
|
|
855
1077
|
$py = Get-Py; if (-not $py) { Write-Error "version-$Op`: python3 required"; exit 1 }
|
|
856
1078
|
$env:AW_FD = $FeatureDir; $env:AW_OP = $Op; $env:AW_CR = $CrId; $env:AW_FORCE = $Force
|
|
857
1079
|
$env:AW_BOOT = $Boot
|
|
858
|
-
|
|
1080
|
+
# CrId names the change on BOTH open and close: many may be open at once, so a close
|
|
1081
|
+
# has to say which one it is closing rather than assuming the only one.
|
|
1082
|
+
$PyActiveVersion | & $py -
|
|
859
1083
|
}
|
|
860
1084
|
|
|
861
|
-
function Invoke-VersionState { # op in read|init (feature-dir
|
|
1085
|
+
function Invoke-VersionState { # op in read|init (feature-dir only)
|
|
862
1086
|
param([string]$Op, [string[]]$Rest)
|
|
863
1087
|
$fdArg = ''; $boot = ''
|
|
864
1088
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
865
1089
|
if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$i] }
|
|
866
|
-
elseif ($Rest[$i] -
|
|
1090
|
+
elseif ($Rest[$i] -eq '--bootstrap') { $boot = '1' } # init only: this caller is authoring a FIRST draft
|
|
867
1091
|
}
|
|
868
1092
|
$repo = Get-RepoRoot
|
|
869
1093
|
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
@@ -873,1831 +1097,6 @@ function Invoke-VersionState { # op in read|init (feature-dir, plus --bootstra
|
|
|
873
1097
|
|
|
874
1098
|
|
|
875
1099
|
|
|
876
|
-
# ═════════════════════════ the spec version ledger ══════════════════════════
|
|
877
|
-
# spec.md's `## Change History` is the sync anchor between the spec and its story
|
|
878
|
-
# backlog: each row names the requirement ids that version touched, and each story
|
|
879
|
-
# records the version it last synced at, so "is this story stale" is one integer
|
|
880
|
-
# comparison rather than a diff. sync-stories reads the rows; specify and
|
|
881
|
-
# update-spec append them.
|
|
882
|
-
#
|
|
883
|
-
# The ledger is hand-writable, which is the point — a spec can be edited by hand —
|
|
884
|
-
# and also its one weakness: a body edited with no row leaves every affected story
|
|
885
|
-
# quietly stale and nothing says so. active_version.json therefore carries a sha256
|
|
886
|
-
# of spec.md as of the last kit-authored write, and three states follow from
|
|
887
|
-
# comparing it against the document:
|
|
888
|
-
#
|
|
889
|
-
# hash matches clean — nothing has been hand-edited since
|
|
890
|
-
# hash differs, version stands a hand edit that skipped the ledger -> REFUSE
|
|
891
|
-
# hash differs, version advanced a hand edit that wrote its own row -> accept
|
|
892
|
-
#
|
|
893
|
-
# The middle case is the only silent failure the ledger has, which is why it is the
|
|
894
|
-
# one thing checked before any workflow writes.
|
|
895
|
-
$PySpecLedger = @'
|
|
896
|
-
import json, os, re, hashlib, datetime, sys
|
|
897
|
-
fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]
|
|
898
|
-
ids = os.environ.get("AW_IDS", "").strip(); summary = os.environ.get("AW_SUM", "").strip()
|
|
899
|
-
spec = os.path.join(fd, "spec.md"); avp = os.path.join(fd, "active_version.json")
|
|
900
|
-
feature = os.path.basename(fd.rstrip("/\\"))
|
|
901
|
-
|
|
902
|
-
def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
903
|
-
def vtuple(v):
|
|
904
|
-
m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
|
|
905
|
-
return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
|
|
906
|
-
def bump(v):
|
|
907
|
-
maj, mnr = vtuple(v or "v1.0")
|
|
908
|
-
return "v%d.%d" % (maj, mnr + 1)
|
|
909
|
-
|
|
910
|
-
out = {"feature": feature, "op": op, "ok": True, "spec_exists": os.path.isfile(spec)}
|
|
911
|
-
|
|
912
|
-
# A non-zero exit is reported by the orchestrator from STDERR alone, so anything that
|
|
913
|
-
# stops a run has to say why there as well as in the JSON — otherwise the operator sees
|
|
914
|
-
# "script failed (ledger-append):" and nothing else.
|
|
915
|
-
def bail(msg, code):
|
|
916
|
-
out.update(ok=False, error=msg)
|
|
917
|
-
print(json.dumps(out)); sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
918
|
-
|
|
919
|
-
if not out["spec_exists"]:
|
|
920
|
-
bail("no spec.md at %s" % fd, 1)
|
|
921
|
-
|
|
922
|
-
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
923
|
-
|
|
924
|
-
# The Story Coverage block is a projection of the backlog, not contract, and it is written
|
|
925
|
-
# by the story workflows rather than by whoever owns the spec. Cutting it out before
|
|
926
|
-
# hashing is what lets both be true at once: creating a story rewrites this document
|
|
927
|
-
# without that reading as an unledgered edit of the requirements.
|
|
928
|
-
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
929
|
-
def contract_of(t): return COV.sub("", t)
|
|
930
|
-
actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
|
|
931
|
-
av = {}
|
|
932
|
-
if os.path.isfile(avp):
|
|
933
|
-
try: av = json.load(open(avp, encoding="utf-8"))
|
|
934
|
-
except Exception: av = {}
|
|
935
|
-
recorded_hash = av.get("spec_sha256", "")
|
|
936
|
-
recorded_ver = av.get("version", "v1.0")
|
|
937
|
-
|
|
938
|
-
# The document's own header is what a hand edit bumps; active_version.json is what the
|
|
939
|
-
# kit last wrote. They agree except across a hand edit, and the disagreement is the signal.
|
|
940
|
-
m = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
|
|
941
|
-
doc_ver = m.group(1) if m else ""
|
|
942
|
-
|
|
943
|
-
# THE LEDGER TABLE, located once: its heading, its separator rule, and the data rows under
|
|
944
|
-
# it. Every op that touches a version row works from this, and `check` refuses a document
|
|
945
|
-
# that has none — because ledger-append discovers a missing table only AFTER the spec has
|
|
946
|
-
# been rewritten, and a rewritten spec with no row is exactly the state the next run's
|
|
947
|
-
# tripwire refuses. Finding it at entry turns a wedged feature into a message.
|
|
948
|
-
lines = text.split("\n")
|
|
949
|
-
hi = next((i for i, l in enumerate(lines)
|
|
950
|
-
if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
|
|
951
|
-
sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
|
|
952
|
-
len(lines)) if hi >= 0 else -1)
|
|
953
|
-
sep = (next((i for i in range(hi + 1, sec_end)
|
|
954
|
-
if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
|
|
955
|
-
rows = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
|
|
956
|
-
has_ledger = hi >= 0 and sep >= 0
|
|
957
|
-
# THE VERSIONS THE TABLE ACTUALLY CARRIES, read from the rows located above rather than by
|
|
958
|
-
# an unbounded scan, so a version-looking cell in another section's table cannot be counted
|
|
959
|
-
# as history. This is the EVIDENCE that a change was recorded; the document's **Version**
|
|
960
|
-
# header is only the CLAIM, and hand editing parts the two in both directions.
|
|
961
|
-
row_versions = [m.group(1) for m in
|
|
962
|
-
(re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', lines[i].lstrip()) for i in rows)
|
|
963
|
-
if m]
|
|
964
|
-
newest_row = max(row_versions, key=vtuple) if row_versions else ""
|
|
965
|
-
|
|
966
|
-
def cell(s, limit=160):
|
|
967
|
-
"""One markdown table cell. A newline ENDS a row and a bare pipe STARTS a column, so
|
|
968
|
-
free text dropped into a cell silently rewrites the row's shape — a summary carrying
|
|
969
|
-
either one produced a version row whose 'Requirements changed' cell had moved or
|
|
970
|
-
vanished, which is a change no story can ever be reconciled against."""
|
|
971
|
-
s = re.sub(r'\s+', ' ', (s or "")).strip().replace("|", "\\|")
|
|
972
|
-
return (s[:limit - 1] + "\u2026") if len(s) > limit else s
|
|
973
|
-
|
|
974
|
-
# IS A DELTA ACTUALLY MEASURED AGAINST THIS MASTER. cr-setup scaffolds delta-spec.md AND
|
|
975
|
-
# delta-design.md for EVERY change it opens, so the file existing proves nothing. Since
|
|
976
|
-
# specify lost its delta branch nothing authors a delta-spec at all, and the only changes
|
|
977
|
-
# opened today are design-only ones carrying an untouched copy of the template — for which
|
|
978
|
-
# editing spec.md strands nothing. Authored means the bytes have moved off the template;
|
|
979
|
-
# with no template resolvable, a non-empty file counts.
|
|
980
|
-
cr = av.get("active_change", "")
|
|
981
|
-
dtpl = os.environ.get("AW_DTPL", "")
|
|
982
|
-
dspec = os.path.join(fd, "changes", cr, "delta-spec.md") if cr else ""
|
|
983
|
-
delta_authored = False
|
|
984
|
-
if dspec and os.path.isfile(dspec) and os.path.getsize(dspec) > 0:
|
|
985
|
-
try:
|
|
986
|
-
delta_authored = (not (dtpl and os.path.isfile(dtpl))
|
|
987
|
-
or open(dspec, "rb").read() != open(dtpl, "rb").read())
|
|
988
|
-
except Exception:
|
|
989
|
-
delta_authored = True
|
|
990
|
-
|
|
991
|
-
out.update(open_change=cr, delta_authored=delta_authored)
|
|
992
|
-
out.update(version=recorded_ver, doc_version=doc_ver, spec_sha256=actual,
|
|
993
|
-
recorded_sha256=recorded_hash, body_changed=(recorded_hash != actual),
|
|
994
|
-
has_ledger=has_ledger, ledger_rows=len(rows), newest_row=newest_row,
|
|
995
|
-
first_stamp=(not recorded_hash))
|
|
996
|
-
|
|
997
|
-
if op == "check":
|
|
998
|
-
# An AUTHORED delta-spec was computed against THIS master. Writing the master directly
|
|
999
|
-
# would leave that delta measured from a baseline that no longer exists, and nothing
|
|
1000
|
-
# downstream re-checks it. The two families overlap only until the delta set is deleted,
|
|
1001
|
-
# so this guard is temporary by design — but while both exist it is the one silent way
|
|
1002
|
-
# they collide. Narrowed to an authored delta on purpose: gating on `active_change`
|
|
1003
|
-
# alone refused every extend for the whole life of a DESIGN-only change, which strands
|
|
1004
|
-
# nothing, and told the operator to finish a chain they may be retiring.
|
|
1005
|
-
if cr and delta_authored:
|
|
1006
|
-
out["state"] = "delta_open"
|
|
1007
|
-
out.update(ok=False, active_change=cr,
|
|
1008
|
-
error="change '%s' is open on this feature and its delta-spec has been "
|
|
1009
|
-
"authored, so a delta is measured against this master. Editing spec.md "
|
|
1010
|
-
"directly would leave that delta computed from a baseline that no longer "
|
|
1011
|
-
"exists. Either finish the change (finalize-delta -> analyze-slice -> "
|
|
1012
|
-
"close-delta), or abandon it: "
|
|
1013
|
-
"`.appweaver/scripts/bash/aw-spec-actions.sh cr-archive --cr-id %s` moves "
|
|
1014
|
-
"changes/%s/ into changes/archive/ and clears the flag. Abandoning also "
|
|
1015
|
-
"bumps the minor version, so the number moves even though nothing shipped."
|
|
1016
|
-
% (cr, cr, cr))
|
|
1017
|
-
elif not has_ledger:
|
|
1018
|
-
out["state"] = "no_ledger"
|
|
1019
|
-
out.update(ok=False,
|
|
1020
|
-
error="spec.md has no '## Change History' table. That table is where a "
|
|
1021
|
-
"story learns a requirement it cites has moved, so a spec without one "
|
|
1022
|
-
"cannot be kept in step with its backlog. Add the section with its "
|
|
1023
|
-
"header row and separator (see the spec template), then re-run.")
|
|
1024
|
-
elif not recorded_hash:
|
|
1025
|
-
out["state"] = "unstamped" # pre-ledger spec; stamp adopts it
|
|
1026
|
-
elif recorded_hash == actual:
|
|
1027
|
-
out["state"] = "clean"
|
|
1028
|
-
# THE ROW IS THE EVIDENCE; THE HEADER IS ONLY THE CLAIM. The body has moved since the
|
|
1029
|
-
# kit last stamped, and what makes that edit legible downstream is a version ROW naming
|
|
1030
|
-
# the ids it touched: sync-stories reads rows, story-plan's staleness check reads rows,
|
|
1031
|
-
# and neither ever opens the **Version** header. Taking the header as proof accepted the
|
|
1032
|
-
# commonest hand-edit mistake there is — bump the number, forget the row — and that one
|
|
1033
|
-
# passed every tripwire in the kit, raised no work anywhere, and was then buried for good
|
|
1034
|
-
# by the next run's stamp adopting its hash. So the test is a row ABOVE the version the
|
|
1035
|
-
# kit last recorded, and the header is only asked whether it kept up.
|
|
1036
|
-
elif newest_row and vtuple(newest_row) > vtuple(recorded_ver):
|
|
1037
|
-
out["state"] = ("ledgered_edit" if vtuple(doc_ver) >= vtuple(newest_row)
|
|
1038
|
-
else "header_behind") # recorded; stamp reconciles the header
|
|
1039
|
-
elif vtuple(doc_ver) > vtuple(recorded_ver):
|
|
1040
|
-
# The header claims a version the table never recorded. Refused for the same reason
|
|
1041
|
-
# an unrowed edit is: without the row nothing can say WHICH requirements moved, so
|
|
1042
|
-
# every story citing one is stale with nothing able to detect it.
|
|
1043
|
-
out["state"] = "unrowed_edit"
|
|
1044
|
-
out.update(ok=False, error="spec.md's header reads %s but the Change History carries "
|
|
1045
|
-
"no row above %s — the version was bumped and what it changed was never "
|
|
1046
|
-
"recorded. The row is what a story reads to learn that a requirement it "
|
|
1047
|
-
"cites has moved; the header is read by nobody. Add a row naming the ids "
|
|
1048
|
-
"you changed (keep the header at %s), then re-run."
|
|
1049
|
-
% (doc_ver or "no version", recorded_ver, doc_ver))
|
|
1050
|
-
elif vtuple(doc_ver) < vtuple(recorded_ver):
|
|
1051
|
-
out["state"] = "rollback"
|
|
1052
|
-
out.update(ok=False, error="spec.md reads %s but the kit last wrote %s — the "
|
|
1053
|
-
"document is behind its own ledger" % (doc_ver or "no version", recorded_ver))
|
|
1054
|
-
else:
|
|
1055
|
-
out["state"] = "unledgered_edit"
|
|
1056
|
-
out.update(ok=False, error="spec.md has changed since %s but no Change History row "
|
|
1057
|
-
"was added. Every story citing an edited requirement is now silently "
|
|
1058
|
-
"stale. Add a row naming the ids you changed, then re-run."
|
|
1059
|
-
% recorded_ver)
|
|
1060
|
-
elif op == "stamp":
|
|
1061
|
-
# THE VERSION IS THE HIGHEST OF THE THREE PLACES ONE IS WRITTEN, and the document's
|
|
1062
|
-
# header is reconciled up to it. Taking the header alone let a hand edit that DID write
|
|
1063
|
-
# its row — without bumping the header — leave the kit recording a version below the
|
|
1064
|
-
# newest row; the next hand edit then read as a `rollback`, the document behind its own
|
|
1065
|
-
# ledger, when nothing had rolled back. Rewriting the header is the authority
|
|
1066
|
-
# ledger-append already exercises: the kit owns the number, the row is what it is
|
|
1067
|
-
# computed from, and re-hashing after the rewrite is what keeps the baseline honest.
|
|
1068
|
-
stamp_ver = max([recorded_ver or "v1.0", doc_ver or "v1.0"] +
|
|
1069
|
-
([newest_row] if newest_row else []), key=vtuple)
|
|
1070
|
-
if doc_ver and vtuple(doc_ver) < vtuple(stamp_ver):
|
|
1071
|
-
text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + stamp_ver,
|
|
1072
|
-
text, count=1, flags=re.M)
|
|
1073
|
-
open(spec, "w", encoding="utf-8").write(text)
|
|
1074
|
-
actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
|
|
1075
|
-
out.update(header_rewritten=True, doc_version=stamp_ver, spec_sha256=actual)
|
|
1076
|
-
av.setdefault("feature", feature); av.setdefault("active_change", "")
|
|
1077
|
-
av["version"] = stamp_ver
|
|
1078
|
-
av["spec_sha256"] = actual; av["updated"] = now()
|
|
1079
|
-
open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
|
|
1080
|
-
out.update(version=av["version"], state="stamped")
|
|
1081
|
-
elif op == "append":
|
|
1082
|
-
if not ids:
|
|
1083
|
-
# A version that names no ids is a bump nothing can reconcile against: every
|
|
1084
|
-
# story keeps its old stamp and the gap compounds at the next real change.
|
|
1085
|
-
bail("ledger-append: --ids is required — a version row that names no requirement "
|
|
1086
|
-
"ids leaves sync-stories nothing to act on", 2)
|
|
1087
|
-
# The next number comes from the highest of all three places a version is written —
|
|
1088
|
-
# what the kit last recorded, what the document's header claims, and the newest row
|
|
1089
|
-
# actually in the table. A hand edit routinely moves one and not the others, and
|
|
1090
|
-
# taking the maximum is what stops the ledger re-issuing a number it has already used.
|
|
1091
|
-
row_vers = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', text, re.M)
|
|
1092
|
-
new_ver = bump(max([recorded_ver, doc_ver] + row_vers, key=vtuple))
|
|
1093
|
-
today = datetime.date.today().isoformat()
|
|
1094
|
-
# Both free-text cells go through cell(): the summary is whatever the operator typed on
|
|
1095
|
-
# the command line, and one newline or one pipe in it destroys the row.
|
|
1096
|
-
row = "| %s | %s | %s | %s | — |" % (
|
|
1097
|
-
new_ver, today, cell(summary) or "Requirements updated", cell(ids))
|
|
1098
|
-
|
|
1099
|
-
# Bounded to the Change History section, not "the last table row in the file". Any
|
|
1100
|
-
# section below it — Story Coverage today — carries its own table, and an unbounded
|
|
1101
|
-
# search would file the version row into whichever one happened to come last.
|
|
1102
|
-
if not has_ledger:
|
|
1103
|
-
bail("spec.md has no '## Change History' table to append to — the ledger is "
|
|
1104
|
-
"where a story learns a requirement moved, so a spec without one cannot be "
|
|
1105
|
-
"kept in step with its backlog. spec.md HAS ALREADY BEEN WRITTEN by this run: "
|
|
1106
|
-
"add the section, then re-run to record what it changed", 2)
|
|
1107
|
-
last = rows[-1] if rows else sep
|
|
1108
|
-
# Duplicate version = two writers reached the same number, which is the collision the
|
|
1109
|
-
# integer buys for free. Refuse rather than append a second row nothing can order.
|
|
1110
|
-
if re.search(r'^\|\s*%s\s*\|' % re.escape(new_ver), text, re.M):
|
|
1111
|
-
bail("the ledger already carries a %s row — another write reached this version "
|
|
1112
|
-
"first; re-read spec.md and retry" % new_ver, 3)
|
|
1113
|
-
lines.insert(last + 1, row)
|
|
1114
|
-
|
|
1115
|
-
stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + new_ver,
|
|
1116
|
-
"\n".join(lines), count=1, flags=re.M)
|
|
1117
|
-
open(spec, "w", encoding="utf-8").write(stamped)
|
|
1118
|
-
|
|
1119
|
-
av.setdefault("feature", feature); av.setdefault("active_change", "")
|
|
1120
|
-
av["version"] = new_ver
|
|
1121
|
-
av["spec_sha256"] = hashlib.sha256(contract_of(stamped).encode("utf-8")).hexdigest()
|
|
1122
|
-
av["updated"] = now()
|
|
1123
|
-
open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
|
|
1124
|
-
out.update(version=new_ver, previous=recorded_ver, row=row, ids=ids,
|
|
1125
|
-
spec_sha256=av["spec_sha256"], state="appended")
|
|
1126
|
-
elif op == "rowids":
|
|
1127
|
-
# THE FIRST ROW, COMPUTED. Every other version row is derived from a before/after diff,
|
|
1128
|
-
# for the reason the header of this file gives: a row the model DECLARED can omit an id
|
|
1129
|
-
# it moved, and an omitted id is silent. The v1.0 row was the one exception — written by
|
|
1130
|
-
# the author from a template whose placeholder literally reads "FR-001…FR-00n", which
|
|
1131
|
-
# sync-stories then has to be told not to trust. This op closes that gap: the caller
|
|
1132
|
-
# diffs the finished document against nothing and hands the ids in, exactly as the
|
|
1133
|
-
# extend path does, so the first row is observed like all the rest.
|
|
1134
|
-
if not ids:
|
|
1135
|
-
bail("ledger-ids: --ids is required", 2)
|
|
1136
|
-
if not has_ledger:
|
|
1137
|
-
bail("spec.md has no '## Change History' table to write the initial row into", 2)
|
|
1138
|
-
ver = doc_ver if re.match(r'^v\d+(\.\d+)?$', doc_ver or "") else (recorded_ver or "v1.0")
|
|
1139
|
-
today = datetime.date.today().isoformat()
|
|
1140
|
-
if rows:
|
|
1141
|
-
# Rewrite the newest row's cells in place. A cell split has to respect \| , which is
|
|
1142
|
-
# how cell() escapes a pipe the operator typed — splitting on every pipe would treat
|
|
1143
|
-
# an escaped one as a column break and shift every cell after it.
|
|
1144
|
-
parts = re.split(r'(?<!\\)\|', lines[rows[-1]])
|
|
1145
|
-
body = [c.strip() for c in parts[1:-1]] if len(parts) >= 3 else []
|
|
1146
|
-
while len(body) < 5: body.append("")
|
|
1147
|
-
if not re.match(r'^v\d+(\.\d+)?$', body[0]): body[0] = ver
|
|
1148
|
-
if not re.match(r'^\d{4}-\d{2}-\d{2}$', body[1]): body[1] = today
|
|
1149
|
-
if not body[2] or body[2].startswith("["): body[2] = "Initial specification"
|
|
1150
|
-
body[3] = cell(ids)
|
|
1151
|
-
if not body[4]: body[4] = "—"
|
|
1152
|
-
lines[rows[-1]] = "| " + " | ".join(body[:5]) + " |"
|
|
1153
|
-
else:
|
|
1154
|
-
lines.insert(sep + 1, "| %s | %s | Initial specification | %s | — |"
|
|
1155
|
-
% (ver, today, cell(ids)))
|
|
1156
|
-
stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + ver,
|
|
1157
|
-
"\n".join(lines), count=1, flags=re.M)
|
|
1158
|
-
open(spec, "w", encoding="utf-8").write(stamped)
|
|
1159
|
-
# The v1.0 row now names ids read out of the finished document, which is the moment the
|
|
1160
|
-
# bootstrap is complete. Clearing the marker here and NOWHERE else is what makes an
|
|
1161
|
-
# incomplete first run re-runnable: spec-stamp still re-baselines the hash on every path,
|
|
1162
|
-
# so gating on the hash would have frozen a placeholder row as clean forever.
|
|
1163
|
-
av.pop("bootstrap_pending", None)
|
|
1164
|
-
av.setdefault("feature", feature); av.setdefault("active_change", "")
|
|
1165
|
-
av["version"] = ver; av["updated"] = now()
|
|
1166
|
-
open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
|
|
1167
|
-
out.update(version=ver, ids=ids, row=lines[rows[-1]] if rows else lines[sep + 1],
|
|
1168
|
-
bootstrap_pending=False, state="rowids")
|
|
1169
|
-
print(json.dumps(out))
|
|
1170
|
-
'@
|
|
1171
|
-
|
|
1172
|
-
# What a run CHANGED, observed rather than declared. specify snapshots spec.md before
|
|
1173
|
-
# it authors and diffs after the converge loop has finished with it, so the Change
|
|
1174
|
-
# History row is computed from the document's own requirement ids. A row the model
|
|
1175
|
-
# declares can omit a requirement the converge loop rewrote on its way past — and an
|
|
1176
|
-
# omitted id is the silent case the whole ledger exists to prevent.
|
|
1177
|
-
$PySpecDiff = @'
|
|
1178
|
-
import json, os, re, hashlib, sys
|
|
1179
|
-
snap = os.environ.get("AW_SNAP", ""); spec = os.environ["AW_SPEC"]
|
|
1180
|
-
# ASKING FOR NO BEFORE-STATE IS NOT THE SAME AS LOSING ONE. An empty --snapshot is the
|
|
1181
|
-
# bootstrap path saying "there was nothing before this run", and every id is legitimately
|
|
1182
|
-
# added. A path that was given and is not there is the before-state having been LOST, and
|
|
1183
|
-
# answering that with "everything is new" is the loudest possible wrong answer: it marks
|
|
1184
|
-
# every story in the feature stale for a change that touched none of them.
|
|
1185
|
-
if snap and not os.path.isfile(snap):
|
|
1186
|
-
msg = ("spec-diff: the before-state at %s is gone, so this run cannot tell what it "
|
|
1187
|
-
"changed. spec.md HAS been written. Recover by hand: put the ids you changed "
|
|
1188
|
-
"into a new Change History row, then re-run spec-stamp to re-baseline."
|
|
1189
|
-
% snap)
|
|
1190
|
-
print(json.dumps({"ok": False, "error": msg, "had_snapshot": False, "snapshot": snap}))
|
|
1191
|
-
sys.stderr.write(msg + "\n"); sys.exit(2)
|
|
1192
|
-
# An id is keyed to the line that DEFINES it, never to a line that merely mentions it.
|
|
1193
|
-
# The four shapes are scored, best wins, earliest line breaks a tie:
|
|
1194
|
-
# 3 a leading table cell | DC-001 | … |
|
|
1195
|
-
# 3 a bold id opening a bullet - **FR-001**: THE SYSTEM SHALL …
|
|
1196
|
-
# 2 a bold id opening a line **FR-001** … (a definition missing its colon)
|
|
1197
|
-
# 1 a bold id anywhere … narrowing **FR-001**. (a cross-reference)
|
|
1198
|
-
# Scoring rather than "first bold id on the line, last line wins": a note that OPENS with
|
|
1199
|
-
# a cross-reference — "**FR-005** remains in force; **FR-006** narrows it" — re-keyed
|
|
1200
|
-
# FR-005 to that note, so a requirement restored character-for-character was reported as
|
|
1201
|
-
# MODIFIED. That is the worst possible misreading here, because the additive path exists
|
|
1202
|
-
# precisely to leave such a requirement alone, and its own instructions ask the author to
|
|
1203
|
-
# name the id in the new requirement's text.
|
|
1204
|
-
DEF_CELL = re.compile(r'^\s*\|\s*([A-Z]{2}-\d+)\s*\|')
|
|
1205
|
-
DEF_BULL = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*\s*[:\u2014\u2013-]')
|
|
1206
|
-
DEF_OPEN = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*')
|
|
1207
|
-
ANY_BOLD = re.compile(r'\*\*([A-Z]{2}-\d+)\*\*')
|
|
1208
|
-
# The Story Coverage block lists requirement ids in a table, so it reads exactly like the
|
|
1209
|
-
# Data Classification section to DEF_CELL. Left in, every story raised against FR-003 would
|
|
1210
|
-
# show up here as FR-003 having CHANGED — a version row, and every story citing it stale,
|
|
1211
|
-
# because somebody wrote a story.
|
|
1212
|
-
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
1213
|
-
# The ledger's own rows name ids in prose cells; they record history, they do not define a
|
|
1214
|
-
# requirement, and the table is stripped before the scan for the same reason as coverage.
|
|
1215
|
-
HIST = re.compile(r'^##\s+Change History\s*$', re.M)
|
|
1216
|
-
def is_def(l):
|
|
1217
|
-
return bool(DEF_CELL.match(l) or DEF_BULL.match(l) or DEF_OPEN.match(l))
|
|
1218
|
-
def scan(path):
|
|
1219
|
-
"""{id: (score, line_no, hash)} for every id the document mentions."""
|
|
1220
|
-
best = {}
|
|
1221
|
-
if not path or not os.path.isfile(path): return best
|
|
1222
|
-
text = COV.sub("", open(path, encoding="utf-8", errors="replace").read())
|
|
1223
|
-
hm = HIST.search(text)
|
|
1224
|
-
if hm: text = text[:hm.start()]
|
|
1225
|
-
lines = text.split("\n")
|
|
1226
|
-
for n, line in enumerate(lines):
|
|
1227
|
-
hits = []
|
|
1228
|
-
m = DEF_CELL.match(line)
|
|
1229
|
-
if m: hits.append((3, m.group(1)))
|
|
1230
|
-
m = DEF_BULL.match(line)
|
|
1231
|
-
if m: hits.append((3, m.group(1)))
|
|
1232
|
-
else:
|
|
1233
|
-
m = DEF_OPEN.match(line)
|
|
1234
|
-
if m: hits.append((2, m.group(1)))
|
|
1235
|
-
for rid in ANY_BOLD.findall(line):
|
|
1236
|
-
hits.append((1, rid))
|
|
1237
|
-
for score, rid in hits:
|
|
1238
|
-
if rid not in best or score > best[rid][0]:
|
|
1239
|
-
best[rid] = (score, n)
|
|
1240
|
-
# THE WHOLE REQUIREMENT, not the line its id sits on. A requirement that wraps carries
|
|
1241
|
-
# half its meaning on the continuation lines, and hashing only the id's own line made an
|
|
1242
|
-
# edit below the fold invisible: change_count 0, no ledger row appended, and spec-stamp
|
|
1243
|
-
# re-baselining the new body as clean — the ledger's own silent failure, for every story
|
|
1244
|
-
# citing that id. The block runs to the next definition, heading or blank line, and is
|
|
1245
|
-
# whitespace-normalised as ONE string, so a rewrap across lines is still not a change.
|
|
1246
|
-
out = {}
|
|
1247
|
-
for rid, (score, n) in best.items():
|
|
1248
|
-
block = [lines[n]]
|
|
1249
|
-
for m2 in range(n + 1, len(lines)):
|
|
1250
|
-
nxt = lines[m2]
|
|
1251
|
-
if not nxt.strip() or nxt.lstrip().startswith("#") or is_def(nxt): break
|
|
1252
|
-
block.append(nxt)
|
|
1253
|
-
h = hashlib.sha256(" ".join(" ".join(block).split()).encode()).hexdigest()[:16]
|
|
1254
|
-
out[rid] = (score, n, h)
|
|
1255
|
-
return out
|
|
1256
|
-
def ids(path):
|
|
1257
|
-
return {k: v[2] for k, v in scan(path).items()}
|
|
1258
|
-
# Ids the ledger has already spent. An id is PERMANENT: re-issuing one silently re-points
|
|
1259
|
-
# stories, designs and code that have already shipped. Read from the SNAPSHOT's history, so
|
|
1260
|
-
# it is what the ledger said before this run, and only meaningful when there is a snapshot.
|
|
1261
|
-
def spent(path):
|
|
1262
|
-
if not path or not os.path.isfile(path): return set()
|
|
1263
|
-
text = open(path, encoding="utf-8", errors="replace").read()
|
|
1264
|
-
hm = HIST.search(text)
|
|
1265
|
-
if not hm: return set()
|
|
1266
|
-
return set(re.findall(r'[A-Z]{2}-\d+', text[hm.end():]))
|
|
1267
|
-
before, after = ids(snap), ids(spec)
|
|
1268
|
-
added = sorted(k for k in after if k not in before)
|
|
1269
|
-
removed = sorted(k for k in before if k not in after)
|
|
1270
|
-
modified = sorted(k for k in after if k in before and before[k] != after[k])
|
|
1271
|
-
changed = sorted(set(added) | set(removed) | set(modified))
|
|
1272
|
-
reissued = sorted(set(added) & spent(snap))
|
|
1273
|
-
# THE STORY-FACING SUBSET. A story cites FR- and BR- ids and nothing else, so those are the
|
|
1274
|
-
# only classes the backlog can act on — the same scoping spec-coverage applies, for the same
|
|
1275
|
-
# reason. The scan is deliberately class-blind (CN-/DC-/IC-/UX-/SC- all move the contract and
|
|
1276
|
-
# all belong in the ledger row), but handing that whole set to sync-stories as "the
|
|
1277
|
-
# requirements this run touched" asks it to raise a story for a success criterion.
|
|
1278
|
-
STORY_CLASS = ("FR-", "BR-")
|
|
1279
|
-
story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
|
|
1280
|
-
print(json.dumps({
|
|
1281
|
-
"ok": True, "had_snapshot": bool(snap and os.path.isfile(snap)),
|
|
1282
|
-
"added": added, "removed": removed, "modified": modified,
|
|
1283
|
-
"changed": changed, "changed_csv": ", ".join(changed),
|
|
1284
|
-
"change_count": len(changed),
|
|
1285
|
-
"added_csv": ", ".join(added),
|
|
1286
|
-
# removed ids orphan every story citing them (E1) — carried out so the caller can say so
|
|
1287
|
-
"removed_csv": ", ".join(removed),
|
|
1288
|
-
"modified_csv": ", ".join(modified),
|
|
1289
|
-
# What an existing story would feel. An ADDED requirement disturbs nobody: no story
|
|
1290
|
-
# cites an id that did not exist a moment ago. Modifying or removing one moves ground
|
|
1291
|
-
# somebody may already be standing on, which is the set worth asking about.
|
|
1292
|
-
"disturbing": sorted(set(modified) | set(removed)),
|
|
1293
|
-
"disturbing_csv": ", ".join(sorted(set(modified) | set(removed))),
|
|
1294
|
-
# An id this run introduced that the ledger has already spent. Detected, not blocked:
|
|
1295
|
-
# the document is written by the time we can see it, and refusing here would leave a
|
|
1296
|
-
# spec on disk that the next run's entry check would refuse too.
|
|
1297
|
-
"reissued": reissued, "reissued_csv": ", ".join(reissued),
|
|
1298
|
-
"reissued_any": bool(reissued),
|
|
1299
|
-
# changed, narrowed to the classes a story can cite — what sync-stories works from.
|
|
1300
|
-
"story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
|
|
1301
|
-
"story_id_count": len(story_ids),
|
|
1302
|
-
}))
|
|
1303
|
-
'@
|
|
1304
|
-
|
|
1305
|
-
# Which requirements have live work against them. A read-only scan of the feature's
|
|
1306
|
-
# stories/ directory, reading only frontmatter — which is why §6.1 puts everything the
|
|
1307
|
-
# gates need there rather than in the body. Only OPEN stories count: a closed story is
|
|
1308
|
-
# history and a cancelled one was abandoned, and neither is disturbed by the contract
|
|
1309
|
-
# moving. in_flight is the narrower set — an open story that already has a stories/<ID>/
|
|
1310
|
-
# directory, so someone is working from a projection of the requirement as it reads today.
|
|
1311
|
-
$PyStoryRefs = @'
|
|
1312
|
-
import json, os, re
|
|
1313
|
-
fd = os.environ["AW_FD"]; want = os.environ.get("AW_IDS", "")
|
|
1314
|
-
sdir = os.path.join(fd, "stories")
|
|
1315
|
-
queried = [s.strip() for s in re.split(r'[,\s]+', want) if s.strip()]
|
|
1316
|
-
|
|
1317
|
-
def parse_fm(text):
|
|
1318
|
-
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
1319
|
-
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
1320
|
-
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
1321
|
-
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
1322
|
-
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
1323
|
-
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
1324
|
-
if not m: return {}
|
|
1325
|
-
fm, key, block = {}, None, set()
|
|
1326
|
-
for line in m.group(1).split("\n"):
|
|
1327
|
-
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
1328
|
-
if km:
|
|
1329
|
-
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
1330
|
-
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
1331
|
-
if im and (key in block or not fm[key]):
|
|
1332
|
-
block.add(key)
|
|
1333
|
-
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
1334
|
-
return fm
|
|
1335
|
-
|
|
1336
|
-
def frontmatter(path):
|
|
1337
|
-
try: return parse_fm(open(path, encoding="utf-8", errors="replace").read())
|
|
1338
|
-
except Exception: return {}
|
|
1339
|
-
|
|
1340
|
-
stories = []
|
|
1341
|
-
if os.path.isdir(sdir):
|
|
1342
|
-
for name in sorted(os.listdir(sdir)):
|
|
1343
|
-
if not name.endswith(".md"): continue
|
|
1344
|
-
path = os.path.join(sdir, name)
|
|
1345
|
-
if not os.path.isfile(path): continue
|
|
1346
|
-
fm = frontmatter(path)
|
|
1347
|
-
sid = fm.get("id") or name[:-3]
|
|
1348
|
-
# An unparseable story is reported as open rather than skipped: a story the scan
|
|
1349
|
-
# cannot read is exactly the one whose requirements must not move unannounced.
|
|
1350
|
-
status = (fm.get("status") or "open").strip().lower()
|
|
1351
|
-
cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
|
|
1352
|
-
stories.append({"id": sid, "status": status, "exercises": cites,
|
|
1353
|
-
"cites_csv": ", ".join(cites),
|
|
1354
|
-
"title": (fm.get("title") or "").strip().strip("[]"),
|
|
1355
|
-
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
1356
|
-
"type": (fm.get("type") or "functional").strip().lower(),
|
|
1357
|
-
"follows": (fm.get("follows") or "").strip(),
|
|
1358
|
-
"in_flight": os.path.isdir(os.path.join(sdir, sid))})
|
|
1359
|
-
|
|
1360
|
-
open_stories = [s for s in stories if s["status"] == "open"]
|
|
1361
|
-
covered = sorted({c for s in open_stories for c in s["exercises"]})
|
|
1362
|
-
|
|
1363
|
-
# ASKING ABOUT NOTHING IS NOT ASKING ABOUT EVERYTHING. `--ids ""` is a caller saying "this
|
|
1364
|
-
# run disturbed no requirement" — a pure addition — and the answer is an empty set. Only
|
|
1365
|
-
# the absence of the flag means "tell me about the whole backlog". Conflating the two made
|
|
1366
|
-
# an enhancement that touched nothing existing prompt about every requirement with a story.
|
|
1367
|
-
targets = queried if os.environ.get("AW_IDS_GIVEN") else covered
|
|
1368
|
-
|
|
1369
|
-
affected, aff_ids, aff_stories, inflight = [], [], [], []
|
|
1370
|
-
for rid in targets:
|
|
1371
|
-
hit = [s for s in open_stories if rid in s["exercises"]]
|
|
1372
|
-
if not hit: continue
|
|
1373
|
-
aff_ids.append(rid)
|
|
1374
|
-
names = [s["id"] for s in hit]
|
|
1375
|
-
flying = [s["id"] for s in hit if s["in_flight"]]
|
|
1376
|
-
aff_stories += names; inflight += flying
|
|
1377
|
-
affected.append({"id": rid, "stories": names, "in_flight": flying})
|
|
1378
|
-
|
|
1379
|
-
# THE COMPLEMENT, NAMED. `affected` is "these ids have live work"; its complement is
|
|
1380
|
-
# "these ids have nobody on them", and that is the set sync-stories raises new stories for.
|
|
1381
|
-
# Derived here rather than in the caller because the orchestrator binds values and never
|
|
1382
|
-
# computes them, and a subtraction done in a prompt is a subtraction nothing can check.
|
|
1383
|
-
unlinked = [rid for rid in targets if rid not in aff_ids]
|
|
1384
|
-
|
|
1385
|
-
# One line per open story, for a prompt that has to ask about them. A story id on its own
|
|
1386
|
-
# is not something an author can answer about: what it cites and whether someone is already
|
|
1387
|
-
# implementing it are the whole basis of the decision.
|
|
1388
|
-
digest = "\n".join(
|
|
1389
|
-
" %-16s %-9s %-11s cites %s%s" % (
|
|
1390
|
-
s["id"], s["status"], "IN FLIGHT" if s["in_flight"] else "not started",
|
|
1391
|
-
s["cites_csv"] or "nothing",
|
|
1392
|
-
(" @ " + s["spec_version"]) if s["spec_version"] else "")
|
|
1393
|
-
for s in open_stories) or " (no open stories)"
|
|
1394
|
-
|
|
1395
|
-
print(json.dumps({
|
|
1396
|
-
"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
|
|
1397
|
-
"story_count": len(stories), "open_count": len(open_stories),
|
|
1398
|
-
"stories": stories, "digest": digest,
|
|
1399
|
-
"queried": queried, "covered": covered, "covered_csv": ", ".join(covered),
|
|
1400
|
-
"affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
|
|
1401
|
-
"unlinked": unlinked, "unlinked_csv": ", ".join(unlinked),
|
|
1402
|
-
"unlinked_count": len(unlinked), "any_unlinked": bool(unlinked),
|
|
1403
|
-
"story_csv": ", ".join(sorted(set(aff_stories))),
|
|
1404
|
-
"in_flight_csv": ", ".join(sorted(set(inflight))),
|
|
1405
|
-
"any": bool(aff_ids), "any_in_flight": bool(inflight),
|
|
1406
|
-
}))
|
|
1407
|
-
'@
|
|
1408
|
-
|
|
1409
|
-
function Invoke-StoryRefs {
|
|
1410
|
-
param([string[]]$Rest)
|
|
1411
|
-
$fdArg = ''; $ids = ''; $idsGiven = ''
|
|
1412
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1413
|
-
switch ($Rest[$i]) {
|
|
1414
|
-
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
1415
|
-
'--ids' { $ids = $Rest[++$i]; $idsGiven = '1' }
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
$repo = Get-RepoRoot
|
|
1419
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1420
|
-
if (-not $fd) { Write-Error 'story-refs: no feature set and no --feature-dir'; exit 1 }
|
|
1421
|
-
$py = Get-Py; if (-not $py) { Write-Error 'story-refs: python3 required'; exit 1 }
|
|
1422
|
-
$env:AW_FD = $fd; $env:AW_IDS = $ids; $env:AW_IDS_GIVEN = $idsGiven
|
|
1423
|
-
$PyStoryRefs | & $py -
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
# Rewrite the spec's Story Coverage block from the stories/ directory: every FR- and BR-
|
|
1427
|
-
# in the document, and the stories that deliver it. The relation is stored ONCE, in each
|
|
1428
|
-
# story's `exercises:` frontmatter; this is that relation read the other way round, which
|
|
1429
|
-
# is why the block is regenerated wholesale rather than patched.
|
|
1430
|
-
#
|
|
1431
|
-
# Fenced out of the ledger on purpose — spec-check, spec-diff and ledger-append all cut
|
|
1432
|
-
# the block out before they look, so writing it is not a spec edit and a story raised
|
|
1433
|
-
# against FR-003 does not read as FR-003 having changed.
|
|
1434
|
-
# ─────────────────────────────── ledger-read ───────────────────────────────
|
|
1435
|
-
# THE WORK SET: which requirements this sync has to account for. The ledger records what
|
|
1436
|
-
# each version touched and every story records the version it last synced at, so the
|
|
1437
|
-
# question "what has moved since the backlog was last in step" is answerable by reading
|
|
1438
|
-
# rows — no diffing, no hashing, no model.
|
|
1439
|
-
#
|
|
1440
|
-
# The set is the union of the ids named by every row ABOVE THE LOWEST spec_version across
|
|
1441
|
-
# the OPEN stories, not the ids of the newest row alone. Those are the same set whenever
|
|
1442
|
-
# the backlog is one version behind, which is the ordinary case; they diverge only when a
|
|
1443
|
-
# sync was skipped, and that is exactly the case worth getting right. Two specify runs
|
|
1444
|
-
# without a sync between them leave the middle version named by no row anyone reads, and
|
|
1445
|
-
# its requirements would get no story with nothing to say so — a silent gap, in the one
|
|
1446
|
-
# mechanism built to make gaps loud.
|
|
1447
|
-
#
|
|
1448
|
-
# THE v1.0 CASE (E42). At v1.0 nothing has moved yet: there is no "since" to read from. So
|
|
1449
|
-
# when no open story carries a readable spec version — an empty backlog, or stories written
|
|
1450
|
-
# before the field existed — the work set is every FR-/BR- the DOCUMENT declares, and the
|
|
1451
|
-
# per-id four-case table does the rest. A second run over the same spec then creates
|
|
1452
|
-
# nothing, because by then every id has a story.
|
|
1453
|
-
#
|
|
1454
|
-
# Scoped to FR-/BR- throughout, the same scoping spec-coverage applies: those are the only
|
|
1455
|
-
# classes a story's `exercises:` can name. CN-/DC-/IC-/UX- move the contract and belong in
|
|
1456
|
-
# the ledger row, but they are constraints a story CARRIES, not slices of work it delivers.
|
|
1457
|
-
$PyLedgerRead = @'
|
|
1458
|
-
import json, os, re, sys
|
|
1459
|
-
fd = os.environ["AW_FD"]
|
|
1460
|
-
spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
|
|
1461
|
-
|
|
1462
|
-
def bail(msg, code=1):
|
|
1463
|
-
print(json.dumps({"ok": False, "error": msg}))
|
|
1464
|
-
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
1465
|
-
|
|
1466
|
-
if not os.path.isfile(spec): bail("no spec.md at %s" % fd)
|
|
1467
|
-
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
1468
|
-
|
|
1469
|
-
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
1470
|
-
|
|
1471
|
-
def vtuple(v):
|
|
1472
|
-
m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
|
|
1473
|
-
return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
|
|
1474
|
-
|
|
1475
|
-
def rids(s):
|
|
1476
|
-
"""FR-/BR- ids in order of appearance, deduped, then sorted by class and number so
|
|
1477
|
-
two callers reading the same row always produce the same csv."""
|
|
1478
|
-
seen, out = set(), []
|
|
1479
|
-
for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
|
|
1480
|
-
if t not in seen: seen.add(t); out.append(t)
|
|
1481
|
-
return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
1482
|
-
|
|
1483
|
-
def parse_fm(text):
|
|
1484
|
-
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
1485
|
-
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
1486
|
-
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
1487
|
-
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
1488
|
-
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
1489
|
-
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
1490
|
-
if not m: return {}
|
|
1491
|
-
fm, key, block = {}, None, set()
|
|
1492
|
-
for line in m.group(1).split("\n"):
|
|
1493
|
-
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
1494
|
-
if km:
|
|
1495
|
-
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
1496
|
-
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
1497
|
-
if im and (key in block or not fm[key]):
|
|
1498
|
-
block.add(key)
|
|
1499
|
-
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
1500
|
-
return fm
|
|
1501
|
-
|
|
1502
|
-
# THE LEDGER TABLE, located the same way _spec_ledger locates it — heading, separator rule,
|
|
1503
|
-
# then the rows under it, bounded by the next `## `. Any other section carries its own
|
|
1504
|
-
# table (Story Coverage does), and an unbounded search would read one of those as history.
|
|
1505
|
-
lines = text.split("\n")
|
|
1506
|
-
hi = next((i for i, l in enumerate(lines)
|
|
1507
|
-
if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
|
|
1508
|
-
sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
|
|
1509
|
-
len(lines)) if hi >= 0 else -1)
|
|
1510
|
-
sep = (next((i for i in range(hi + 1, sec_end)
|
|
1511
|
-
if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
|
|
1512
|
-
row_lines = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
|
|
1513
|
-
has_ledger = hi >= 0 and sep >= 0
|
|
1514
|
-
|
|
1515
|
-
def cells(line):
|
|
1516
|
-
parts = re.split(r'(?<!\\)\|', line)
|
|
1517
|
-
return [c.strip().replace("\\|", "|") for c in parts[1:-1]] if len(parts) >= 3 else []
|
|
1518
|
-
|
|
1519
|
-
rows = []
|
|
1520
|
-
for i in row_lines:
|
|
1521
|
-
c = cells(lines[i]); c += [""] * (5 - len(c))
|
|
1522
|
-
# A row whose first cell is not a version is the template's own placeholder line, or a
|
|
1523
|
-
# note somebody left in the table. It records no version, so it orders against nothing.
|
|
1524
|
-
if not re.match(r'^v\d', c[0]): continue
|
|
1525
|
-
rows.append({"version": c[0], "date": c[1], "change": c[2], "ids_cell": c[3],
|
|
1526
|
-
"stories": c[4], "requirements": rids(c[3])})
|
|
1527
|
-
rows.sort(key=lambda r: vtuple(r["version"]))
|
|
1528
|
-
|
|
1529
|
-
# WHAT THE DOCUMENT STILL DECLARES. Read from the requirement's DEFINING line — the bolded
|
|
1530
|
-
# id the template gives every requirement — and with the Change History cut away, so the
|
|
1531
|
-
# ids named in a row's own cell are never mistaken for requirements the spec carries.
|
|
1532
|
-
body = COV.sub("", text).split("\n## Change History")[0]
|
|
1533
|
-
declared = []
|
|
1534
|
-
for line in body.split("\n"):
|
|
1535
|
-
m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
|
|
1536
|
-
if m and m.group(1) not in declared: declared.append(m.group(1))
|
|
1537
|
-
declared.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
1538
|
-
|
|
1539
|
-
stories = []
|
|
1540
|
-
if os.path.isdir(sdir):
|
|
1541
|
-
for name in sorted(os.listdir(sdir)):
|
|
1542
|
-
if not name.endswith(".md"): continue
|
|
1543
|
-
p = os.path.join(sdir, name)
|
|
1544
|
-
if not os.path.isfile(p): continue
|
|
1545
|
-
fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
|
|
1546
|
-
sid = fm.get("id") or name[:-3]
|
|
1547
|
-
stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
|
|
1548
|
-
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
1549
|
-
"follows": (fm.get("follows") or "").strip(),
|
|
1550
|
-
"in_flight": os.path.isdir(os.path.join(sdir, sid)),
|
|
1551
|
-
"cites": rids(fm.get("exercises", ""))})
|
|
1552
|
-
open_stories = [s for s in stories if s["status"] == "open"]
|
|
1553
|
-
|
|
1554
|
-
# THE FLOOR is the oldest version any open story is still sitting at, and everything above
|
|
1555
|
-
# it is unsynced. A story whose spec_version is missing or unreadable counts as v0.0 rather
|
|
1556
|
-
# than being skipped: it has demonstrably never synced, and putting the whole ledger above
|
|
1557
|
-
# it is the same answer enumerating from the document would give.
|
|
1558
|
-
floors = [vtuple(s["spec_version"]) for s in open_stories]
|
|
1559
|
-
readable = [s for s in open_stories if re.match(r'^v?\d', s["spec_version"] or "")]
|
|
1560
|
-
# A BACKLOG THAT IS ENTIRELY CLOSED IS NOT A BACKLOG THAT NEVER SYNCED. Reading the floor
|
|
1561
|
-
# from open stories alone made the last story closing look exactly like a feature nobody has
|
|
1562
|
-
# ever sliced: no floor took the enumerate-the-whole-document branch below, so one idle
|
|
1563
|
-
# re-run after finishing a feature raised a fresh story for every requirement in the spec —
|
|
1564
|
-
# each one reported as work the contract had moved on, when nothing had moved at all. A
|
|
1565
|
-
# CLOSED story carries a stamp too, and it records which version its work was measured
|
|
1566
|
-
# against. CANCELLED ones are excluded: abandoned work delivered nothing and settles nothing,
|
|
1567
|
-
# so it establishes no floor and leaves its requirements to be sliced again.
|
|
1568
|
-
closed_stories = [s for s in stories if s["status"] == "closed"]
|
|
1569
|
-
floor_source = "open" if floors else ("closed" if closed_stories else "none")
|
|
1570
|
-
floor = (min(floors) if floors
|
|
1571
|
-
else (min(vtuple(s["spec_version"]) for s in closed_stories) if closed_stories
|
|
1572
|
-
else None))
|
|
1573
|
-
|
|
1574
|
-
latest = rows[-1] if rows else None
|
|
1575
|
-
out = {"ok": True, "feature_dir": fd, "has_ledger": has_ledger,
|
|
1576
|
-
"row_count": len(rows), "ledger_empty": len(rows) == 0,
|
|
1577
|
-
"rows": rows,
|
|
1578
|
-
"latest_version": latest["version"] if latest else "",
|
|
1579
|
-
"latest_change": latest["change"] if latest else "",
|
|
1580
|
-
"latest_ids_csv": ", ".join(latest["requirements"]) if latest else "",
|
|
1581
|
-
"spec_ids_csv": ", ".join(declared), "spec_id_count": len(declared),
|
|
1582
|
-
"story_count": len(stories), "open_count": len(open_stories),
|
|
1583
|
-
"unversioned_open": len(open_stories) - len(readable)}
|
|
1584
|
-
|
|
1585
|
-
if not has_ledger:
|
|
1586
|
-
out.update(ok=False, error="spec.md has no '## Change History' table. That table is "
|
|
1587
|
-
"where a story learns a requirement it cites has moved, so a spec without "
|
|
1588
|
-
"one cannot be kept in step with its backlog.")
|
|
1589
|
-
print(json.dumps(out)); sys.stderr.write(out["error"] + "\n"); sys.exit(2)
|
|
1590
|
-
|
|
1591
|
-
if not rows:
|
|
1592
|
-
# An empty ledger is not corruption — it is a spec that has never been versioned, which
|
|
1593
|
-
# means specify has not finished with it. Reported, not raised: the caller warns and
|
|
1594
|
-
# stops, because there is no "since" to sync from and inventing one would raise a
|
|
1595
|
-
# backlog against a document nobody has committed to.
|
|
1596
|
-
out.update(work_csv="", work_count=0, work_source="none", span_versions_csv="",
|
|
1597
|
-
gap=False, removed_csv="",
|
|
1598
|
-
work_note="the Change History table carries no version row")
|
|
1599
|
-
print(json.dumps(out)); sys.exit(0)
|
|
1600
|
-
|
|
1601
|
-
if floor is None:
|
|
1602
|
-
# E42 — nothing has moved because nothing has synced. Enumerate from the document.
|
|
1603
|
-
source, span = "spec", []
|
|
1604
|
-
candidates = list(declared)
|
|
1605
|
-
note = ("nothing has synced against this spec yet — no open story records a version and "
|
|
1606
|
-
"nothing has closed against it — so there is no 'since' to read from: the work "
|
|
1607
|
-
"set is every requirement the document declares, and the per-id rules then "
|
|
1608
|
-
"create only what has no story")
|
|
1609
|
-
else:
|
|
1610
|
-
span = [r for r in rows if vtuple(r["version"]) > floor]
|
|
1611
|
-
seen, candidates = set(), []
|
|
1612
|
-
for r in span:
|
|
1613
|
-
for rid in r["requirements"]:
|
|
1614
|
-
if rid not in seen: seen.add(rid); candidates.append(rid)
|
|
1615
|
-
candidates.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
1616
|
-
source = "ledger"
|
|
1617
|
-
note = ("%d version row(s) above the oldest %s story's v%d.%d: %s"
|
|
1618
|
-
% (len(span), floor_source, floor[0], floor[1],
|
|
1619
|
-
", ".join(r["version"] for r in span) or "none"))
|
|
1620
|
-
|
|
1621
|
-
# THE SPAN IS A PREFILTER; STALENESS IS PER ID. The floor is the oldest version any open
|
|
1622
|
-
# story sits at, and a story whose requirements never move sits there forever — Order-001
|
|
1623
|
-
# holding v1.0 keeps v1.1 and v1.2 in the span long after everything they named has been
|
|
1624
|
-
# synced. Reading the span alone therefore re-offered the same requirements on every run,
|
|
1625
|
-
# and each re-offer is a model call rewriting a story that was already in step.
|
|
1626
|
-
#
|
|
1627
|
-
# So the span says which ids are WORTH CHECKING and this says which have actually moved
|
|
1628
|
-
# under someone: an id is stale when it has no open story at all, or when some open story
|
|
1629
|
-
# citing it is stamped BELOW the newest version that touched it. An id whose every open
|
|
1630
|
-
# story is at or above that version is in step, whatever the floor says.
|
|
1631
|
-
moved_at = {}
|
|
1632
|
-
for r in rows:
|
|
1633
|
-
for rid in r["requirements"]:
|
|
1634
|
-
v = vtuple(r["version"])
|
|
1635
|
-
if v > moved_at.get(rid, (0, 0)): moved_at[rid] = v
|
|
1636
|
-
|
|
1637
|
-
def carried_on(story_id, rid, at):
|
|
1638
|
-
"""Has a follow-on already taken this requirement off that story's hands, at or above
|
|
1639
|
-
the version that moved it. The `follows` link is the whole record of that hand-off."""
|
|
1640
|
-
return any(s["follows"] == story_id and rid in s["cites"]
|
|
1641
|
-
and vtuple(s["spec_version"]) >= at for s in open_stories)
|
|
1642
|
-
|
|
1643
|
-
work, in_step = [], []
|
|
1644
|
-
for rid in candidates:
|
|
1645
|
-
holders = [s for s in open_stories if rid in s["cites"]]
|
|
1646
|
-
at = moved_at.get(rid, (0, 0))
|
|
1647
|
-
behind = []
|
|
1648
|
-
for s in holders:
|
|
1649
|
-
if vtuple(s["spec_version"]) >= at: continue
|
|
1650
|
-
# AN IN-FLIGHT STORY CANNOT BE BROUGHT UP TO DATE, BY DESIGN. It is never rewritten
|
|
1651
|
-
# (§5.5), so its spec_version can never rise, so reading it as a staleness signal
|
|
1652
|
-
# gives a signal that never clears — and a sync that mints a fresh follow-on for the
|
|
1653
|
-
# same requirement on every run. What clears it is the follow-on itself: once one
|
|
1654
|
-
# exists carrying this id at or above the version that moved it, the hand-off has
|
|
1655
|
-
# happened and the original is history in progress, not unfinished business.
|
|
1656
|
-
if s["in_flight"] and carried_on(s["id"], rid, at): continue
|
|
1657
|
-
behind.append(s["id"])
|
|
1658
|
-
# A CLOSED STORY STAMPED AT OR ABOVE THE VERSION THAT LAST MOVED THIS REQUIREMENT
|
|
1659
|
-
# DELIVERED THE WORDING THE SPEC ASKS FOR TODAY, so the requirement is FINISHED rather
|
|
1660
|
-
# than unsliced. Reading "no open story cites it" as work on its own could not tell those
|
|
1661
|
-
# two apart, which is what let a fully delivered feature re-raise its entire backlog.
|
|
1662
|
-
# A closed story stamped BELOW that version is the other case and stays work: the
|
|
1663
|
-
# contract moved after it shipped, so what shipped is no longer what the spec asks for,
|
|
1664
|
-
# and backlog-plan names the closed story it is re-opening against.
|
|
1665
|
-
delivered = any(rid in s["cites"] and vtuple(s["spec_version"]) >= at
|
|
1666
|
-
for s in closed_stories)
|
|
1667
|
-
(in_step if ((holders and not behind) or (not holders and delivered))
|
|
1668
|
-
else work).append(rid)
|
|
1669
|
-
|
|
1670
|
-
# A row names what a version TOUCHED, and that includes ids it REMOVED. A removed
|
|
1671
|
-
# requirement has no wording left to write a story from, so it leaves the work set — but it
|
|
1672
|
-
# is reported, because every open story citing one is orphaned and that is a decision for a
|
|
1673
|
-
# person, not a default (E1).
|
|
1674
|
-
removed = [i for i in work if i not in declared]
|
|
1675
|
-
work = [i for i in work if i in declared]
|
|
1676
|
-
|
|
1677
|
-
out.update(work_csv=", ".join(work), work_count=len(work), work_source=source,
|
|
1678
|
-
work_note=note, work_any=bool(work),
|
|
1679
|
-
candidates_csv=", ".join(candidates), candidate_count=len(candidates),
|
|
1680
|
-
in_step_csv=", ".join(in_step), in_step_count=len(in_step),
|
|
1681
|
-
floor_source=floor_source,
|
|
1682
|
-
span_versions_csv=", ".join(r["version"] for r in span),
|
|
1683
|
-
span_count=len(span), gap=len(span) > 1,
|
|
1684
|
-
removed_csv=", ".join(removed), removed_count=len(removed),
|
|
1685
|
-
synced_floor=("v%d.%d" % floor) if floor else "")
|
|
1686
|
-
print(json.dumps(out))
|
|
1687
|
-
'@
|
|
1688
|
-
|
|
1689
|
-
function Invoke-LedgerRead {
|
|
1690
|
-
param([string[]]$Rest)
|
|
1691
|
-
$fdArg = ''
|
|
1692
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1693
|
-
switch ($Rest[$i]) { '--feature-dir' { $fdArg = $Rest[++$i] } }
|
|
1694
|
-
}
|
|
1695
|
-
$repo = Get-RepoRoot
|
|
1696
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1697
|
-
if (-not $fd) { Write-Error 'ledger-read: no feature set and no --feature-dir'; exit 1 }
|
|
1698
|
-
$py = Get-Py; if (-not $py) { Write-Error 'ledger-read: python3 required'; exit 1 }
|
|
1699
|
-
$env:AW_FD = $fd
|
|
1700
|
-
$PyLedgerRead | & $py -
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
# ─────────────────────────────── slice-mode ────────────────────────────────
|
|
1704
|
-
# Which slicing strategy the launch line asked for, and what is left of the input once that
|
|
1705
|
-
# word is taken out of it. Its own op rather than a `condition:` in the workflow, because
|
|
1706
|
-
# the engine compares a whole rendered value: `{{USER_INPUT}} == single-run` only matches an
|
|
1707
|
-
# input carrying the strategy and no guidance at all, and `sync-stories single-run keep the
|
|
1708
|
-
# payment rules together` — the form the launch line documents — would silently slice by
|
|
1709
|
-
# default while looking as though it had been told not to.
|
|
1710
|
-
#
|
|
1711
|
-
# ONLY THE FIRST WORD IS A STRATEGY. Guidance is free prose, and "keep the single sign-on
|
|
1712
|
-
# requirements in one story" is a slicing instruction with the token sitting in the middle
|
|
1713
|
-
# of it. Reading the token anywhere in the string would turn that sentence into the opposite
|
|
1714
|
-
# of what it asks, with nothing to say so.
|
|
1715
|
-
$PySliceMode = @'
|
|
1716
|
-
import json, os, re
|
|
1717
|
-
raw = (os.environ.get("AW_INPUT", "") or "").strip()
|
|
1718
|
-
|
|
1719
|
-
# `single-run` is the spelling the launch line documents; the others are what people write
|
|
1720
|
-
# when they mean it. `auto` is the default and is accepted explicitly so a launch line can
|
|
1721
|
-
# say so — a strategy nobody can name is one nobody can turn off.
|
|
1722
|
-
TOKENS = {"single-run": "single", "single-story": "single", "single": "single",
|
|
1723
|
-
"one-story": "single", "auto": "auto", "per-slice": "auto", "default": "auto"}
|
|
1724
|
-
|
|
1725
|
-
strategy, guidance = "auto", raw
|
|
1726
|
-
m = re.match(r'^([A-Za-z][A-Za-z0-9_-]*)\s*[:,]?\s*(.*)$', raw, re.S)
|
|
1727
|
-
if m and m.group(1).lower() in TOKENS:
|
|
1728
|
-
strategy = TOKENS[m.group(1).lower()]
|
|
1729
|
-
guidance = m.group(2).strip()
|
|
1730
|
-
|
|
1731
|
-
if strategy == "single":
|
|
1732
|
-
note = ("one story was asked for (single-run): the whole work set goes into a single "
|
|
1733
|
-
"slice unless it is too heavy to be picked up as one piece of work, in which "
|
|
1734
|
-
"case it is split along its dependencies and the split is reported")
|
|
1735
|
-
else:
|
|
1736
|
-
note = "the slicing is decided from the requirements themselves"
|
|
1737
|
-
|
|
1738
|
-
print(json.dumps({"ok": True, "strategy": strategy, "guidance": guidance,
|
|
1739
|
-
"single": strategy == "single", "note": note,
|
|
1740
|
-
"raw": raw, "tokens": sorted(TOKENS)}))
|
|
1741
|
-
'@
|
|
1742
|
-
|
|
1743
|
-
function Invoke-SliceMode {
|
|
1744
|
-
param([string[]]$Rest)
|
|
1745
|
-
$input_ = ''
|
|
1746
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1747
|
-
switch ($Rest[$i]) {
|
|
1748
|
-
'--input' { $input_ = $Rest[++$i] }
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
$py = Get-Py; if (-not $py) { Write-Error 'slice-mode: python3 required'; exit 1 }
|
|
1752
|
-
$env:AW_INPUT = $input_
|
|
1753
|
-
$PySliceMode | & $py -
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
# ─────────────────────────────── backlog-plan ──────────────────────────────
|
|
1757
|
-
# The grouping is a judgment; everything that follows from it is arithmetic. This op does
|
|
1758
|
-
# the arithmetic, in two modes over the same inputs so the two passes cannot disagree about
|
|
1759
|
-
# what a group contains:
|
|
1760
|
-
#
|
|
1761
|
-
# scan which OPEN stories already stand in front of a group's requirements — the
|
|
1762
|
-
# candidates the author is asked about, and the wording of that question.
|
|
1763
|
-
# plan given the author's answer, the flat list of story files to write: one entry per
|
|
1764
|
-
# file, each already carrying its id, its path, and the ids it must end up citing.
|
|
1765
|
-
#
|
|
1766
|
-
# THE FOUR CASES (§5.4) resolve per group, not per requirement, because a group IS the story:
|
|
1767
|
-
#
|
|
1768
|
-
# no open story cites anything in the group -> create
|
|
1769
|
-
# an open story does, no stories/<ID>/ yet -> update in place (asked)
|
|
1770
|
-
# an open story does, a story plan already exists -> mint a follow-on (asked); the
|
|
1771
|
-
# original is never rewritten (§5.5)
|
|
1772
|
-
# the author declines -> that story is left alone AND the ids
|
|
1773
|
-
# it covers leave the group; whatever
|
|
1774
|
-
# remains becomes a new story
|
|
1775
|
-
#
|
|
1776
|
-
# The last line is the one worth reading twice. Declining does not abandon the group — it
|
|
1777
|
-
# withdraws the requirements that story is already carrying and creates a story for the
|
|
1778
|
-
# rest. A group of three where one id has a story the author will not touch still delivers
|
|
1779
|
-
# the other two.
|
|
1780
|
-
#
|
|
1781
|
-
# ONE GROUP, ONE ABSORBING STORY. Where the author approves two stories in the same group,
|
|
1782
|
-
# the first by id takes the group's unclaimed requirements and the others are refreshed
|
|
1783
|
-
# against the current spec without gaining ids. Two stories both claiming the same new
|
|
1784
|
-
# requirement is not a state the completeness rule can read.
|
|
1785
|
-
#
|
|
1786
|
-
# AND THE ORDER. Plan mode also resolves the dependency edges the grouping declared between
|
|
1787
|
-
# slices, folds in the `depends_on:` an updated story already carries, breaks any cycle, and
|
|
1788
|
-
# lays the result out in WAVES: wave N holds the stories whose every dependency sits below N,
|
|
1789
|
-
# so one wave is a set with no ordering between its members — they may be picked up in
|
|
1790
|
-
# parallel. The plan comes back sorted by wave, which is also the order the stories are
|
|
1791
|
-
# written in, so meeting the cap costs the LAST wave rather than an arbitrary slice of the
|
|
1792
|
-
# backlog. See the block above the wave computation for why `follows:` is not an edge.
|
|
1793
|
-
$PyBacklogPlan = @'
|
|
1794
|
-
import json, os, re, sys
|
|
1795
|
-
fd = os.environ["AW_FD"]; mode = os.environ.get("AW_MODE", "scan")
|
|
1796
|
-
sdir = os.path.join(fd, "stories")
|
|
1797
|
-
feature = os.path.basename(fd.rstrip("/\\"))
|
|
1798
|
-
|
|
1799
|
-
def bail(msg, code=2):
|
|
1800
|
-
print(json.dumps({"ok": False, "error": msg}))
|
|
1801
|
-
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
1802
|
-
|
|
1803
|
-
def rids(s):
|
|
1804
|
-
seen, out = set(), []
|
|
1805
|
-
for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
|
|
1806
|
-
if t not in seen: seen.add(t); out.append(t)
|
|
1807
|
-
return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
1808
|
-
|
|
1809
|
-
def sids(s):
|
|
1810
|
-
"""Story ids out of a csv, a JSON list or a YAML inline list. A story id is a feature
|
|
1811
|
-
name and a number; a template placeholder (`[]`, `[STORY-ID]`) is neither, and must
|
|
1812
|
-
never become a dependency edge nobody can resolve."""
|
|
1813
|
-
seen, out = set(), []
|
|
1814
|
-
for t in re.split(r'[,\s\[\]"\']+', s or ""):
|
|
1815
|
-
t = t.strip()
|
|
1816
|
-
if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
|
|
1817
|
-
seen.add(t); out.append(t)
|
|
1818
|
-
return out
|
|
1819
|
-
|
|
1820
|
-
def wave_of(s):
|
|
1821
|
-
"""A story's declared wave. Absent — a story written before the field existed — reads
|
|
1822
|
-
as 1, the conservative floor: nothing is KNOWN to have to land before it."""
|
|
1823
|
-
m = re.match(r'^\s*(\d+)\s*$', (s or "").strip())
|
|
1824
|
-
return int(m.group(1)) if m else 1
|
|
1825
|
-
|
|
1826
|
-
def names(s):
|
|
1827
|
-
"""Story ids out of a csv or a JSON list. A multi-select answer arrives as the engine's
|
|
1828
|
-
JSON rendering of a list, so both spellings have to read the same."""
|
|
1829
|
-
seen, out = set(), []
|
|
1830
|
-
for t in re.split(r'[,\s\[\]"\']+', s or ""):
|
|
1831
|
-
t = t.strip()
|
|
1832
|
-
if t and t not in seen: seen.add(t); out.append(t)
|
|
1833
|
-
return out
|
|
1834
|
-
|
|
1835
|
-
def parse_fm(text):
|
|
1836
|
-
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
1837
|
-
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
1838
|
-
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
1839
|
-
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
1840
|
-
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
1841
|
-
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
1842
|
-
if not m: return {}
|
|
1843
|
-
fm, key, block = {}, None, set()
|
|
1844
|
-
for line in m.group(1).split("\n"):
|
|
1845
|
-
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
1846
|
-
if km:
|
|
1847
|
-
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
1848
|
-
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
1849
|
-
if im and (key in block or not fm[key]):
|
|
1850
|
-
block.add(key)
|
|
1851
|
-
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
1852
|
-
return fm
|
|
1853
|
-
|
|
1854
|
-
# ---- the backlog -----------------------------------------------------------------
|
|
1855
|
-
stories, used_numbers = [], []
|
|
1856
|
-
if os.path.isdir(sdir):
|
|
1857
|
-
for name in sorted(os.listdir(sdir)):
|
|
1858
|
-
if not name.endswith(".md"): continue
|
|
1859
|
-
p = os.path.join(sdir, name)
|
|
1860
|
-
if not os.path.isfile(p): continue
|
|
1861
|
-
fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
|
|
1862
|
-
sid = fm.get("id") or name[:-3]
|
|
1863
|
-
# Numbers come from the FILENAME as well as the id, and from CLOSED and CANCELLED
|
|
1864
|
-
# stories as well as open ones. An id is permanent once minted: re-issuing the
|
|
1865
|
-
# number of a story that shipped re-points its execution artefacts at new work.
|
|
1866
|
-
for cand in (sid, name[:-3]):
|
|
1867
|
-
m = re.search(r'-(\d+)$', cand)
|
|
1868
|
-
if m: used_numbers.append(int(m.group(1)))
|
|
1869
|
-
stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
|
|
1870
|
-
"title": (fm.get("title") or "").strip().strip("[]"),
|
|
1871
|
-
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
1872
|
-
"cites": rids(fm.get("exercises", "")),
|
|
1873
|
-
"wave": wave_of(fm.get("wave", "")),
|
|
1874
|
-
"deps": sids(fm.get("depends_on", "")),
|
|
1875
|
-
"in_flight": os.path.isdir(os.path.join(sdir, sid)),
|
|
1876
|
-
"path": p})
|
|
1877
|
-
open_stories = [s for s in stories if s["status"] == "open"]
|
|
1878
|
-
# Closed and cancelled stories hold no requirement — completeness is "no OPEN stories" —
|
|
1879
|
-
# but they are the reason a create can look like duplicated work, so they are read in
|
|
1880
|
-
# order to say so. See the create branch below.
|
|
1881
|
-
done_stories = [s for s in stories if s["status"] != "open"]
|
|
1882
|
-
by_id = {s["id"]: s for s in stories}
|
|
1883
|
-
_next = [max(used_numbers) + 1 if used_numbers else 1]
|
|
1884
|
-
|
|
1885
|
-
def mint():
|
|
1886
|
-
n = _next[0]; _next[0] += 1
|
|
1887
|
-
return "%s-%03d" % (feature, n)
|
|
1888
|
-
|
|
1889
|
-
# ---- the groups ------------------------------------------------------------------
|
|
1890
|
-
raw = (os.environ.get("AW_GROUPS") or "").strip()
|
|
1891
|
-
try:
|
|
1892
|
-
parsed = json.loads(raw) if raw else []
|
|
1893
|
-
except Exception as e:
|
|
1894
|
-
bail("backlog-plan: --groups is not valid JSON (%s)" % e)
|
|
1895
|
-
if isinstance(parsed, dict): parsed = parsed.get("groups") or []
|
|
1896
|
-
if not isinstance(parsed, list):
|
|
1897
|
-
bail("backlog-plan: --groups must be a JSON list of groups")
|
|
1898
|
-
|
|
1899
|
-
# Ids are read from the group's OWN requirements field, never scraped from its prose. A
|
|
1900
|
-
# rationale that mentions FR-004 as the reason two others belong together would otherwise
|
|
1901
|
-
# pull a third requirement into the story, and nothing downstream would question it.
|
|
1902
|
-
work = rids(os.environ.get("AW_WORK", ""))
|
|
1903
|
-
groups, dropped = [], []
|
|
1904
|
-
for i, g in enumerate(parsed):
|
|
1905
|
-
if not isinstance(g, dict): continue
|
|
1906
|
-
src = g.get("requirements")
|
|
1907
|
-
if isinstance(src, list): src = ", ".join(str(x) for x in src)
|
|
1908
|
-
ids = rids(src if src is not None else g.get("requirements_csv", ""))
|
|
1909
|
-
if work:
|
|
1910
|
-
outside = [x for x in ids if x not in work]
|
|
1911
|
-
dropped += outside
|
|
1912
|
-
ids = [x for x in ids if x in work]
|
|
1913
|
-
if not ids: continue
|
|
1914
|
-
# `depends_on` names OTHER GROUPS by key, or open stories already in the backlog by
|
|
1915
|
-
# id. Kept as raw tokens: a group key only becomes a story id once the plan has minted
|
|
1916
|
-
# one, so resolving here would have to guess at a name that does not exist yet.
|
|
1917
|
-
dep_src = g.get("depends_on")
|
|
1918
|
-
if dep_src is None: dep_src = g.get("depends_on_csv")
|
|
1919
|
-
if isinstance(dep_src, list): dep_src = ", ".join(str(x) for x in dep_src)
|
|
1920
|
-
deps_raw = [x for x in re.split(r'[,\s\[\]"\']+', str(dep_src or "")) if x.strip()]
|
|
1921
|
-
groups.append({"key": g.get("id") or ("g%d" % (i + 1)),
|
|
1922
|
-
"title": (g.get("title") or "").strip(),
|
|
1923
|
-
"rationale": (g.get("rationale") or "").strip(),
|
|
1924
|
-
"complexity": (g.get("complexity") or "").strip().lower(),
|
|
1925
|
-
"deps_raw": deps_raw,
|
|
1926
|
-
"ids": ids, "ids_csv": ", ".join(ids)})
|
|
1927
|
-
|
|
1928
|
-
# Every work-set id must land in exactly one group. One the model left out gets no story
|
|
1929
|
-
# and nothing says so; one it repeated gets two, each claiming the same requirement.
|
|
1930
|
-
placed = [x for g in groups for x in g["ids"]]
|
|
1931
|
-
repeated = sorted({x for x in placed if placed.count(x) > 1})
|
|
1932
|
-
|
|
1933
|
-
# A REPEATED ID IS RESOLVED HERE, not merely reported. Two groups both claiming one
|
|
1934
|
-
# requirement become two stories both claiming it, and "does this requirement still have
|
|
1935
|
-
# open stories" — the completeness rule — then answers for work that was never really
|
|
1936
|
-
# duplicated. The grouping is a judgment; the arithmetic over it is not. So the id stays in
|
|
1937
|
-
# the FIRST group that claimed it and leaves the rest, which is deterministic (the same
|
|
1938
|
-
# grouping always yields the same slices) and loses no coverage, because the id still
|
|
1939
|
-
# reaches exactly one story. repeated_notice reports what that touched.
|
|
1940
|
-
if repeated:
|
|
1941
|
-
seen_ids = set()
|
|
1942
|
-
for g in groups:
|
|
1943
|
-
keep_ids = [x for x in g["ids"] if x not in seen_ids]
|
|
1944
|
-
seen_ids.update(keep_ids)
|
|
1945
|
-
g["ids"], g["ids_csv"] = keep_ids, ", ".join(keep_ids)
|
|
1946
|
-
groups = [g for g in groups if g["ids"]]
|
|
1947
|
-
placed = [x for g in groups for x in g["ids"]]
|
|
1948
|
-
|
|
1949
|
-
missed = [x for x in work if x not in placed]
|
|
1950
|
-
|
|
1951
|
-
def candidates_of(g):
|
|
1952
|
-
"""Open stories standing in front of this group, each with what it already covers OF
|
|
1953
|
-
THIS GROUP — the intersection, not its whole citation list, because that is what the
|
|
1954
|
-
author is being asked to move."""
|
|
1955
|
-
out = []
|
|
1956
|
-
for s in open_stories:
|
|
1957
|
-
shared = [x for x in g["ids"] if x in s["cites"]]
|
|
1958
|
-
if not shared: continue
|
|
1959
|
-
out.append({"story": s["id"], "title": s["title"], "in_flight": s["in_flight"],
|
|
1960
|
-
"spec_version": s["spec_version"], "cites_csv": ", ".join(s["cites"]),
|
|
1961
|
-
"shared_csv": ", ".join(shared), "shared": shared,
|
|
1962
|
-
"group": g["key"], "group_title": g["title"], "group_ids_csv": g["ids_csv"],
|
|
1963
|
-
"action": "followon" if s["in_flight"] else "update"})
|
|
1964
|
-
return sorted(out, key=lambda c: c["story"])
|
|
1965
|
-
|
|
1966
|
-
base = {"ok": True, "feature": feature, "feature_dir": fd, "stories_dir": sdir,
|
|
1967
|
-
"group_count": len(groups), "groups": groups,
|
|
1968
|
-
"work_csv": ", ".join(work),
|
|
1969
|
-
"unplaced_csv": ", ".join(missed), "unplaced_count": len(missed),
|
|
1970
|
-
"repeated_csv": ", ".join(repeated), "repeated_count": len(repeated),
|
|
1971
|
-
"dropped_csv": ", ".join(sorted(set(dropped))),
|
|
1972
|
-
"groups_digest": "\n".join(
|
|
1973
|
-
" [%s] %s\n requirements: %s%s%s"
|
|
1974
|
-
% (g["key"], g["title"] or "(untitled)", g["ids_csv"],
|
|
1975
|
-
("\n stands behind: " + ", ".join(g["deps_raw"])) if g["deps_raw"] else "",
|
|
1976
|
-
("\n " + g["rationale"]) if g["rationale"] else "")
|
|
1977
|
-
for g in groups) or " (no groups)"}
|
|
1978
|
-
|
|
1979
|
-
# THE STRATEGY IS THE AUTHOR'S; THE ARITHMETIC OVER IT IS NOT. `single` asked for one story
|
|
1980
|
-
# covering the whole work set, and the grouping may still have returned several — because a
|
|
1981
|
-
# slice nobody can pick up is not a story, and splitting it is the honest answer. So the
|
|
1982
|
-
# split is REPORTED rather than refused or hidden: quietly handing six stories to someone
|
|
1983
|
-
# who asked for one is the kind of silent disobedience that makes an option not worth having.
|
|
1984
|
-
strategy = (os.environ.get("AW_STRATEGY") or "").strip().lower() or "auto"
|
|
1985
|
-
try:
|
|
1986
|
-
ceiling = int((os.environ.get("AW_CEILING") or "").strip() or 8)
|
|
1987
|
-
except ValueError:
|
|
1988
|
-
ceiling = 8
|
|
1989
|
-
oversized = [g["key"] for g in groups if len(g["ids"]) > ceiling]
|
|
1990
|
-
split_note, split_count = "", 0
|
|
1991
|
-
if strategy == "single" and len(groups) > 1:
|
|
1992
|
-
split_count = len(groups)
|
|
1993
|
-
split_note = ("one story was asked for and the work set was too heavy for one: it is "
|
|
1994
|
-
"split into %d, in dependency order — %s" % (len(groups), "; ".join(
|
|
1995
|
-
"[%s] %s (%s)" % (g["key"], g["title"] or "untitled", g["ids_csv"])
|
|
1996
|
-
for g in groups)))
|
|
1997
|
-
elif strategy == "single" and len(groups) == 1:
|
|
1998
|
-
split_note = ("one story was asked for and one covers the whole work set: %s"
|
|
1999
|
-
% groups[0]["ids_csv"])
|
|
2000
|
-
base.update(strategy=strategy, ceiling=ceiling, split_count=split_count,
|
|
2001
|
-
split_note=split_note, oversized_csv=", ".join(oversized))
|
|
2002
|
-
|
|
2003
|
-
if mode == "scan":
|
|
2004
|
-
cands = [c for g in groups for c in candidates_of(g)]
|
|
2005
|
-
lines = []
|
|
2006
|
-
for c in cands:
|
|
2007
|
-
lines.append(
|
|
2008
|
-
" %s (%s)\n"
|
|
2009
|
-
" group [%s] %s covers %s\n"
|
|
2010
|
-
" this story already covers %s of it, and cites %s in total\n"
|
|
2011
|
-
" %s"
|
|
2012
|
-
% (c["story"], c["title"] or "untitled",
|
|
2013
|
-
c["group"], c["group_title"] or "", c["group_ids_csv"],
|
|
2014
|
-
c["shared_csv"], c["cites_csv"],
|
|
2015
|
-
("ALREADY IN FLIGHT (story plan exists) — picking it mints a FOLLOW-ON "
|
|
2016
|
-
"carrying the change; this story is never rewritten"
|
|
2017
|
-
if c["in_flight"] else
|
|
2018
|
-
"not started (no story plan yet) — picking it rewrites this story to "
|
|
2019
|
-
"cover the whole group")))
|
|
2020
|
-
base.update(candidates=cands, candidate_count=len(cands),
|
|
2021
|
-
candidate_ids=[c["story"] for c in cands],
|
|
2022
|
-
candidate_csv=", ".join(c["story"] for c in cands),
|
|
2023
|
-
any_candidates=bool(cands),
|
|
2024
|
-
ask_digest="\n".join(lines) or " (no existing story covers any of these)")
|
|
2025
|
-
print(json.dumps(base)); sys.exit(0)
|
|
2026
|
-
|
|
2027
|
-
# ---- plan ------------------------------------------------------------------------
|
|
2028
|
-
approved = set(names(os.environ.get("AW_OK", "")))
|
|
2029
|
-
version = (os.environ.get("AW_VER") or "").strip()
|
|
2030
|
-
plan, skipped, notes = [], [], []
|
|
2031
|
-
for g in groups:
|
|
2032
|
-
cands = candidates_of(g)
|
|
2033
|
-
declined = [c for c in cands if c["story"] not in approved]
|
|
2034
|
-
# A declined story keeps its requirements: the author said do not disturb it, and
|
|
2035
|
-
# raising a second story over the same ids disturbs it just as thoroughly.
|
|
2036
|
-
held = {x for c in declined for x in c["shared"]}
|
|
2037
|
-
skipped += [c["story"] for c in declined]
|
|
2038
|
-
remaining = [x for x in g["ids"] if x not in held]
|
|
2039
|
-
taken = [c for c in cands if c["story"] in approved]
|
|
2040
|
-
|
|
2041
|
-
if taken:
|
|
2042
|
-
# ONE APPROVED STORY ABSORBS THE GROUP'S UNCLAIMED REQUIREMENTS — the first by id.
|
|
2043
|
-
# Every OTHER approved story keeps exactly the ids it already cites, so those ids
|
|
2044
|
-
# are RESERVED out of what the carrier gains: two open stories both claiming one
|
|
2045
|
-
# requirement is not a state the completeness rule can read, and the run would have
|
|
2046
|
-
# created that state itself rather than merely reporting it.
|
|
2047
|
-
carrier = taken[0]; s = by_id[carrier["story"]]
|
|
2048
|
-
reserved = {x for c in taken[1:] for x in c["shared"]}
|
|
2049
|
-
gained = [x for x in remaining if x not in reserved]
|
|
2050
|
-
if carrier["in_flight"]:
|
|
2051
|
-
# §5.5 — the original stays open and unchanged, and closes having delivered
|
|
2052
|
-
# what it was written to deliver. The follow-on carries the remainder.
|
|
2053
|
-
carry = [x for x in g["ids"] if x in s["cites"] or x in gained]
|
|
2054
|
-
carry = [x for x in carry if x not in held]
|
|
2055
|
-
sid = mint()
|
|
2056
|
-
plan.append({"action": "followon", "story": sid, "follows": carrier["story"],
|
|
2057
|
-
"path": os.path.join(sdir, sid + ".md"),
|
|
2058
|
-
"requirements_csv": ", ".join(carry), "existing_csv": "",
|
|
2059
|
-
"prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
|
|
2060
|
-
"prior_path": s["path"],
|
|
2061
|
-
"group": g["key"], "group_title": g["title"],
|
|
2062
|
-
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2063
|
-
"rationale": g["rationale"], "spec_version": version})
|
|
2064
|
-
else:
|
|
2065
|
-
keep = sorted(set(s["cites"]) | set(gained),
|
|
2066
|
-
key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
2067
|
-
plan.append({"action": "update", "story": s["id"], "follows": "",
|
|
2068
|
-
"path": s["path"],
|
|
2069
|
-
"requirements_csv": ", ".join(keep),
|
|
2070
|
-
"existing_csv": ", ".join(s["cites"]),
|
|
2071
|
-
"prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
|
|
2072
|
-
"prior_path": s["path"],
|
|
2073
|
-
"group": g["key"], "group_title": g["title"],
|
|
2074
|
-
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2075
|
-
"rationale": g["rationale"], "spec_version": version})
|
|
2076
|
-
carrier_sid = plan[-1]["story"]
|
|
2077
|
-
for c in taken[1:]:
|
|
2078
|
-
s2 = by_id[c["story"]]
|
|
2079
|
-
if c["in_flight"]:
|
|
2080
|
-
# AN IN-FLIGHT STORY IS NEVER REWRITTEN, whatever position it holds in the
|
|
2081
|
-
# group. Its story plan was derived from this body, and
|
|
2082
|
-
# rewriting the body leaves the three disagreeing with nothing to say so —
|
|
2083
|
-
# which is the whole reason §5.5 exists. Being second in a group is not a
|
|
2084
|
-
# different situation from being first, so it gets the same answer: its own
|
|
2085
|
-
# follow-on, carrying the ids it already covers of this group.
|
|
2086
|
-
#
|
|
2087
|
-
# That link is also what clears its staleness. ledger-read discounts an
|
|
2088
|
-
# in-flight story once an open story whose `follows` names it cites the same
|
|
2089
|
-
# id; skipping it instead would leave it stale forever and re-offered on
|
|
2090
|
-
# every run.
|
|
2091
|
-
carry2 = [x for x in c["shared"] if x not in held]
|
|
2092
|
-
if not carry2:
|
|
2093
|
-
notes.append("%s was approved and is in flight, but every requirement "
|
|
2094
|
-
"it covers of group [%s] is held by a story left "
|
|
2095
|
-
"untouched; nothing to carry" % (c["story"], g["key"]))
|
|
2096
|
-
continue
|
|
2097
|
-
sid2 = mint()
|
|
2098
|
-
notes.append("%s was approved and is IN FLIGHT, so it is not rewritten; "
|
|
2099
|
-
"%s carries %s forward from it, while %s takes the group's "
|
|
2100
|
-
"unclaimed requirements"
|
|
2101
|
-
% (c["story"], sid2, ", ".join(carry2), carrier_sid))
|
|
2102
|
-
plan.append({"action": "followon", "story": sid2, "follows": c["story"],
|
|
2103
|
-
"path": os.path.join(sdir, sid2 + ".md"),
|
|
2104
|
-
"requirements_csv": ", ".join(carry2), "existing_csv": "",
|
|
2105
|
-
"prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
|
|
2106
|
-
"prior_path": s2["path"],
|
|
2107
|
-
"group": g["key"], "group_title": g["title"],
|
|
2108
|
-
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2109
|
-
"rationale": g["rationale"], "spec_version": version})
|
|
2110
|
-
continue
|
|
2111
|
-
notes.append("%s was approved but %s takes the group's unclaimed requirements; "
|
|
2112
|
-
"%s is refreshed against the current spec without gaining ids"
|
|
2113
|
-
% (c["story"], carrier_sid, c["story"]))
|
|
2114
|
-
plan.append({"action": "update", "story": s2["id"], "follows": "",
|
|
2115
|
-
"path": s2["path"],
|
|
2116
|
-
"requirements_csv": ", ".join(s2["cites"]),
|
|
2117
|
-
"existing_csv": ", ".join(s2["cites"]),
|
|
2118
|
-
"prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
|
|
2119
|
-
"prior_path": s2["path"],
|
|
2120
|
-
"group": g["key"], "group_title": g["title"],
|
|
2121
|
-
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2122
|
-
"rationale": g["rationale"], "spec_version": version})
|
|
2123
|
-
elif remaining:
|
|
2124
|
-
sid = mint()
|
|
2125
|
-
# A REQUIREMENT WHOSE ONLY STORIES ARE CLOSED RE-ENTERS THE WORK SET, and that is
|
|
2126
|
-
# right: a version row above the closed story's stamp means the contract moved
|
|
2127
|
-
# AFTER it shipped, so what shipped is no longer what the spec asks for. It reads
|
|
2128
|
-
# as duplicated work to anyone who remembers delivering it, so the run names the
|
|
2129
|
-
# story it is re-opening against rather than leaving them to find it.
|
|
2130
|
-
shipped = ["%s (%s)" % (d["id"], ", ".join(x for x in remaining if x in d["cites"]))
|
|
2131
|
-
for d in done_stories if any(x in d["cites"] for x in remaining)]
|
|
2132
|
-
if shipped:
|
|
2133
|
-
notes.append("%s covers requirements already delivered by %s — the spec moved "
|
|
2134
|
-
"after those closed, so this is the change, not a repeat"
|
|
2135
|
-
% (sid, "; ".join(shipped)))
|
|
2136
|
-
plan.append({"action": "create", "story": sid, "follows": "",
|
|
2137
|
-
"path": os.path.join(sdir, sid + ".md"),
|
|
2138
|
-
"requirements_csv": ", ".join(remaining), "existing_csv": "",
|
|
2139
|
-
"prior_csv": "", "prior_title": "", "prior_path": "",
|
|
2140
|
-
"group": g["key"], "group_title": g["title"],
|
|
2141
|
-
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2142
|
-
"rationale": g["rationale"], "spec_version": version})
|
|
2143
|
-
else:
|
|
2144
|
-
notes.append("group [%s] (%s) is entirely covered by stories left untouched; "
|
|
2145
|
-
"nothing to write" % (g["key"], g["ids_csv"]))
|
|
2146
|
-
|
|
2147
|
-
# ---- waves: the order these stories can actually be picked up in -------------------
|
|
2148
|
-
# The grouping declared which slices stand behind which; turning that into an order is
|
|
2149
|
-
# arithmetic, so it happens here and not in the prompt — the same grouping always yields
|
|
2150
|
-
# the same waves.
|
|
2151
|
-
#
|
|
2152
|
-
# A WAVE IS AN ELIGIBILITY LEVEL, NOT A SCHEDULE. Wave N means every story this one depends
|
|
2153
|
-
# on sits in a wave below N. Two stories in the SAME wave therefore have no dependency
|
|
2154
|
-
# between them and may be picked up in parallel; it does not say they start together, and
|
|
2155
|
-
# nothing waits for a whole wave to finish. That is also why the number is not a priority:
|
|
2156
|
-
# wave 1 is "nothing blocks this", not "do this first".
|
|
2157
|
-
#
|
|
2158
|
-
# THE EDGE SET IS A UNION of what the grouping declared and what an updated story already
|
|
2159
|
-
# carries in `depends_on:`. The sync sees one feature's spec; an edge someone added by hand
|
|
2160
|
-
# may name work it has no view of, and dropping it would silently un-order real work. Both
|
|
2161
|
-
# are claims about CODE ordering, and the union is exactly what the story ends up citing —
|
|
2162
|
-
# so the wave and the frontmatter cannot disagree about what stands in front of what.
|
|
2163
|
-
#
|
|
2164
|
-
# `follows:` is NOT an edge. A follow-on carries the remainder of a story in flight, and the
|
|
2165
|
-
# remainder is often buildable alongside it — §5.5 asks the author to say in Notes which
|
|
2166
|
-
# half has to land first. Making it an implicit edge would put an ordering in the waves that
|
|
2167
|
-
# no frontmatter field explains.
|
|
2168
|
-
entries_of, by_story = {}, {}
|
|
2169
|
-
for e in plan:
|
|
2170
|
-
entries_of.setdefault(e["group"], []).append(e["story"]); by_story[e["story"]] = e
|
|
2171
|
-
preds = {e["story"]: set() for e in plan}
|
|
2172
|
-
external, unresolved = {}, [] # stories outside this run, at the wave they declare
|
|
2173
|
-
|
|
2174
|
-
for g in groups:
|
|
2175
|
-
tgt = entries_of.get(g["key"], [])
|
|
2176
|
-
if not tgt: continue # the group wrote nothing; its edges order nothing
|
|
2177
|
-
for tok in g["deps_raw"]:
|
|
2178
|
-
tok = tok.strip()
|
|
2179
|
-
if not tok or tok == g["key"]: continue
|
|
2180
|
-
if tok in entries_of:
|
|
2181
|
-
src = entries_of[tok]
|
|
2182
|
-
elif tok in by_id and by_id[tok]["status"] == "open":
|
|
2183
|
-
external[tok] = by_id[tok]["wave"]; src = [tok]
|
|
2184
|
-
else:
|
|
2185
|
-
unresolved.append("%s (named by group [%s])" % (tok, g["key"])); continue
|
|
2186
|
-
for x in tgt: preds[x].update(s for s in src if s != x)
|
|
2187
|
-
|
|
2188
|
-
for e in plan:
|
|
2189
|
-
if e["action"] != "update": continue
|
|
2190
|
-
for d in by_id.get(e["story"], {}).get("deps", []):
|
|
2191
|
-
if d == e["story"] or d in preds[e["story"]]: continue
|
|
2192
|
-
if d not in preds: external[d] = by_id[d]["wave"] if d in by_id else 1
|
|
2193
|
-
preds[e["story"]].add(d)
|
|
2194
|
-
|
|
2195
|
-
# A CYCLE IS REJECTED AT AUTHORING TIME (§6.1), and this is where it would be authored.
|
|
2196
|
-
# Depth-first over a sorted node order, dropping the one edge that closes each cycle: only
|
|
2197
|
-
# edges that actually close one are lost, and the choice is deterministic — the same
|
|
2198
|
-
# grouping always breaks the same edge. Reported, because a dropped edge was a real claim
|
|
2199
|
-
# about ordering and the cycle it sat in is a fact about the slicing, not a glitch.
|
|
2200
|
-
cycles, state = [], {}
|
|
2201
|
-
def walk(node):
|
|
2202
|
-
state[node] = 1
|
|
2203
|
-
for m in sorted(preds.get(node, ())):
|
|
2204
|
-
if state.get(m) == 1:
|
|
2205
|
-
preds[node].discard(m); cycles.append("%s -> %s" % (node, m)); continue
|
|
2206
|
-
if state.get(m) != 2 and m in preds: walk(m)
|
|
2207
|
-
state[node] = 2
|
|
2208
|
-
for node in sorted(preds):
|
|
2209
|
-
if state.get(node) != 2: walk(node)
|
|
2210
|
-
|
|
2211
|
-
# Longest path from a root, relaxed to a fixed point. A story with no dependency is wave 1,
|
|
2212
|
-
# which is what puts every independent story in the same wave.
|
|
2213
|
-
wave = dict(external)
|
|
2214
|
-
for _ in range(len(preds) + 1):
|
|
2215
|
-
moved = False
|
|
2216
|
-
for node in sorted(preds):
|
|
2217
|
-
w = 1 + max([wave.get(m, 1) for m in preds[node]] or [0])
|
|
2218
|
-
if wave.get(node) != w: wave[node] = w; moved = True
|
|
2219
|
-
if not moved: break
|
|
2220
|
-
for e in plan:
|
|
2221
|
-
e["wave"] = wave.get(e["story"], 1)
|
|
2222
|
-
e["depends_on_csv"] = ", ".join(sorted(preds.get(e["story"], ())))
|
|
2223
|
-
|
|
2224
|
-
# Written in wave order, so a run that meets the cap writes the stories that can be STARTED
|
|
2225
|
-
# rather than an arbitrary prefix of them. The story id is not the order — the wave is.
|
|
2226
|
-
plan.sort(key=lambda e: (e["wave"], e["story"]))
|
|
2227
|
-
for i, e in enumerate(plan): e["seq"] = i + 1
|
|
2228
|
-
|
|
2229
|
-
waves = {}
|
|
2230
|
-
for e in plan: waves.setdefault(e["wave"], []).append(e["story"])
|
|
2231
|
-
wave_digest = "\n".join(" wave %d: %s" % (w, ", ".join(waves[w]))
|
|
2232
|
-
for w in sorted(waves)) or " (nothing to write)"
|
|
2233
|
-
if unresolved:
|
|
2234
|
-
notes.append("the grouping named dependencies matching no slice and no open story, so "
|
|
2235
|
-
"they set no order: %s" % "; ".join(sorted(set(unresolved))))
|
|
2236
|
-
if cycles:
|
|
2237
|
-
notes.append("slices were declared to depend on each other in a cycle; the edge closing "
|
|
2238
|
-
"each one is dropped, because a backlog with no order at all is worse than "
|
|
2239
|
-
"one missing an edge someone can re-add: %s" % ", ".join(cycles))
|
|
2240
|
-
|
|
2241
|
-
# THE WRITE LOOP HAS A CEILING AND THE ENGINE DOES NOT SAY WHEN IT HITS IT — `over:` stops
|
|
2242
|
-
# at max_iterations with no message, so plan entries past it are simply never written and
|
|
2243
|
-
# the run reports success. The caller passes its own ceiling here so the overflow is named
|
|
2244
|
-
# while the story ids still exist to name. Nothing is dropped from the plan: the ids are
|
|
2245
|
-
# reported and the entries stay, so raising the cap and re-running writes them.
|
|
2246
|
-
try:
|
|
2247
|
-
cap = int((os.environ.get("AW_CAP") or "").strip() or 0)
|
|
2248
|
-
except ValueError:
|
|
2249
|
-
cap = 0
|
|
2250
|
-
overflow = [e["story"] for e in plan[cap:]] if cap > 0 else []
|
|
2251
|
-
|
|
2252
|
-
counts = {a: sum(1 for e in plan if e["action"] == a) for a in ("create", "update", "followon")}
|
|
2253
|
-
base.update(plan=plan, plan_count=len(plan), any_plan=bool(plan),
|
|
2254
|
-
cap=cap, overflow_csv=", ".join(overflow), overflow_count=len(overflow),
|
|
2255
|
-
create_count=counts["create"], update_count=counts["update"],
|
|
2256
|
-
followon_count=counts["followon"],
|
|
2257
|
-
skipped_csv=", ".join(sorted(set(skipped))),
|
|
2258
|
-
stories_csv=", ".join(e["story"] for e in plan),
|
|
2259
|
-
wave_digest=wave_digest, wave_count=len(waves),
|
|
2260
|
-
max_wave=(max(waves) if waves else 0),
|
|
2261
|
-
cycle_csv=", ".join(cycles),
|
|
2262
|
-
unresolved_deps_csv=", ".join(sorted(set(unresolved))),
|
|
2263
|
-
notes=notes, notes_text="\n".join(" - " + n for n in notes),
|
|
2264
|
-
plan_digest="\n".join(
|
|
2265
|
-
" w%-3d %-9s %-16s %s%s%s"
|
|
2266
|
-
% (e["wave"], e["action"], e["story"], e["requirements_csv"],
|
|
2267
|
-
(" (follows %s)" % e["follows"]) if e["follows"] else "",
|
|
2268
|
-
(" [after %s]" % e["depends_on_csv"]) if e["depends_on_csv"] else "")
|
|
2269
|
-
for e in plan) or " (nothing to write)")
|
|
2270
|
-
print(json.dumps(base))
|
|
2271
|
-
'@
|
|
2272
|
-
|
|
2273
|
-
function Invoke-BacklogPlan {
|
|
2274
|
-
param([string[]]$Rest)
|
|
2275
|
-
$fdArg = ''; $groups = ''; $approved = ''; $work = ''; $version = ''; $mode = 'scan'; $cap = ''
|
|
2276
|
-
$strategy = ''; $ceiling = ''
|
|
2277
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2278
|
-
switch ($Rest[$i]) {
|
|
2279
|
-
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
2280
|
-
'--groups' { $groups = $Rest[++$i] }
|
|
2281
|
-
'--approved' { $approved = $Rest[++$i] }
|
|
2282
|
-
'--work' { $work = $Rest[++$i] }
|
|
2283
|
-
'--version' { $version = $Rest[++$i] }
|
|
2284
|
-
'--cap' { $cap = $Rest[++$i] }
|
|
2285
|
-
'--strategy' { $strategy = $Rest[++$i] }
|
|
2286
|
-
'--ceiling' { $ceiling = $Rest[++$i] }
|
|
2287
|
-
'--plan' { $mode = 'plan' }
|
|
2288
|
-
}
|
|
2289
|
-
}
|
|
2290
|
-
$repo = Get-RepoRoot
|
|
2291
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2292
|
-
if (-not $fd) { Write-Error 'backlog-plan: no feature set and no --feature-dir'; exit 1 }
|
|
2293
|
-
$py = Get-Py; if (-not $py) { Write-Error 'backlog-plan: python3 required'; exit 1 }
|
|
2294
|
-
$env:AW_FD = $fd; $env:AW_GROUPS = $groups; $env:AW_OK = $approved
|
|
2295
|
-
$env:AW_WORK = $work; $env:AW_VER = $version; $env:AW_MODE = $mode; $env:AW_CAP = $cap
|
|
2296
|
-
$env:AW_STRATEGY = $strategy; $env:AW_CEILING = $ceiling
|
|
2297
|
-
$PyBacklogPlan | & $py -
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
# ─────────────────────────────── story-stamp ───────────────────────────────
|
|
2301
|
-
# The story's frontmatter is the gate surface — `exercises:`, `spec_version:`, `status:`
|
|
2302
|
-
# and `follows:` are what spec-coverage, check-stories, analyze-slice and close-story read,
|
|
2303
|
-
# and none of them opens the body. So those fields are OBSERVED and written by the kit, not
|
|
2304
|
-
# declared by the model that wrote the prose, for the same reason the Change History row is
|
|
2305
|
-
# computed rather than announced: a model that drops one id from `exercises:` produces a
|
|
2306
|
-
# story that reads perfectly and is invisible to every gate built to protect it. Silent,
|
|
2307
|
-
# and in the one field where silence costs the most.
|
|
2308
|
-
#
|
|
2309
|
-
# Everything else in the block is left exactly as the author wrote it, and a key the file
|
|
2310
|
-
# is missing is appended in template order rather than the block being rebuilt — a story
|
|
2311
|
-
# carrying a work_item or a depends_on must not lose it to a stamp.
|
|
2312
|
-
$PyStoryStamp = @'
|
|
2313
|
-
import json, os, re, sys, datetime
|
|
2314
|
-
fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
|
|
2315
|
-
path = os.path.join(fd, "stories", story + ".md")
|
|
2316
|
-
|
|
2317
|
-
def bail(msg, code=2):
|
|
2318
|
-
print(json.dumps({"ok": False, "story": story, "path": path, "error": msg}))
|
|
2319
|
-
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
2320
|
-
|
|
2321
|
-
if not os.path.isfile(path):
|
|
2322
|
-
bail("story-stamp: no story file at %s — the authoring step did not write it" % path)
|
|
2323
|
-
|
|
2324
|
-
def rids(s):
|
|
2325
|
-
seen, out = set(), []
|
|
2326
|
-
for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
|
|
2327
|
-
if t not in seen: seen.add(t); out.append(t)
|
|
2328
|
-
return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
2329
|
-
|
|
2330
|
-
def sids(s):
|
|
2331
|
-
"""Story ids out of a csv or an inline list. A story id is a feature name and a number;
|
|
2332
|
-
the template's placeholders are neither, and must not become a dependency edge."""
|
|
2333
|
-
seen, out = set(), []
|
|
2334
|
-
for t in re.split(r'[,\s\[\]"\']+', s or ""):
|
|
2335
|
-
t = t.strip()
|
|
2336
|
-
if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
|
|
2337
|
-
seen.add(t); out.append(t)
|
|
2338
|
-
return out
|
|
2339
|
-
|
|
2340
|
-
text = open(path, encoding="utf-8", errors="replace").read()
|
|
2341
|
-
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
2342
|
-
if m:
|
|
2343
|
-
block, body = m.group(1), text[m.end():]
|
|
2344
|
-
else:
|
|
2345
|
-
# A model that returned the prose and no frontmatter leaves a story every gate skips.
|
|
2346
|
-
# Synthesised rather than refused: the body is the expensive half and it is already on
|
|
2347
|
-
# disk, and the fields below are exactly the ones this op is authoritative for.
|
|
2348
|
-
block, body = "", text.lstrip("\n")
|
|
2349
|
-
|
|
2350
|
-
# The block as an ordered list of (key, rendered line), so an unrecognised key and its
|
|
2351
|
-
# continuation lines survive untouched.
|
|
2352
|
-
lines = block.split("\n") if block else []
|
|
2353
|
-
order, seen_key = [], None
|
|
2354
|
-
for line in lines:
|
|
2355
|
-
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
2356
|
-
if km:
|
|
2357
|
-
seen_key = km.group(1); order.append([seen_key, [line]])
|
|
2358
|
-
elif order and (line.startswith(" ") or line.startswith("\t")):
|
|
2359
|
-
order[-1][1].append(line) # a block-list item under the key above
|
|
2360
|
-
elif line.strip():
|
|
2361
|
-
order.append([None, [line]])
|
|
2362
|
-
|
|
2363
|
-
def put(key, value):
|
|
2364
|
-
"""Set a key to a single-line value, collapsing a block list into the inline form."""
|
|
2365
|
-
line = ("%s: %s" % (key, value)) if value != "" else ("%s:" % key)
|
|
2366
|
-
for entry in order:
|
|
2367
|
-
if entry[0] == key:
|
|
2368
|
-
entry[1] = [line]
|
|
2369
|
-
return "set"
|
|
2370
|
-
order.append([key, [line]])
|
|
2371
|
-
return "added"
|
|
2372
|
-
|
|
2373
|
-
TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "spec_version",
|
|
2374
|
-
"depends_on", "wave", "follows", "work_item", "created"]
|
|
2375
|
-
def get(key):
|
|
2376
|
-
for entry in order:
|
|
2377
|
-
if entry[0] == key: return entry[1][0].split(":", 1)[1].strip()
|
|
2378
|
-
return ""
|
|
2379
|
-
|
|
2380
|
-
changed = {}
|
|
2381
|
-
changed["id"] = put("id", story)
|
|
2382
|
-
|
|
2383
|
-
if os.environ.get("AW_IDS_GIVEN"):
|
|
2384
|
-
ids = rids(os.environ.get("AW_IDS", ""))
|
|
2385
|
-
changed["exercises"] = put("exercises", "[" + ", ".join(ids) + "]")
|
|
2386
|
-
else:
|
|
2387
|
-
ids = rids(get("exercises"))
|
|
2388
|
-
|
|
2389
|
-
ver = (os.environ.get("AW_VER") or "").strip()
|
|
2390
|
-
if ver: changed["spec_version"] = put("spec_version", ver)
|
|
2391
|
-
|
|
2392
|
-
fol = (os.environ.get("AW_FOLLOWS") or "").strip()
|
|
2393
|
-
if fol: changed["follows"] = put("follows", fol)
|
|
2394
|
-
|
|
2395
|
-
ttl = (os.environ.get("AW_TITLE") or "").strip()
|
|
2396
|
-
if ttl and (not get("title") or get("title").startswith("[")):
|
|
2397
|
-
changed["title"] = put("title", ttl)
|
|
2398
|
-
|
|
2399
|
-
# Placeholders the template ships. Left alone once a real value is in them, so a story
|
|
2400
|
-
# CLOSED between the plan and the write is not quietly re-opened by a stamp.
|
|
2401
|
-
if not get("type") or get("type").startswith("["):
|
|
2402
|
-
changed["type"] = put("type", "functional" if ids else "technical")
|
|
2403
|
-
if not get("status") or get("status").startswith("["):
|
|
2404
|
-
changed["status"] = put("status", "open")
|
|
2405
|
-
# ORDERING IS A UNION, NEVER A REPLACEMENT. The edges passed in are what this run's slicing
|
|
2406
|
-
# declared; the ones already in the file may name work outside this feature, which the sync
|
|
2407
|
-
# cannot see. Dropping either loses a real claim about what has to land first, and a lost
|
|
2408
|
-
# edge is silent — no later stage re-derives it.
|
|
2409
|
-
deps = sorted(set(sids(get("depends_on")) + sids(os.environ.get("AW_DEPS", ""))) - {story})
|
|
2410
|
-
if deps or not get("depends_on") or get("depends_on").startswith("["):
|
|
2411
|
-
changed["depends_on"] = put("depends_on", "[" + ", ".join(deps) + "]")
|
|
2412
|
-
|
|
2413
|
-
# THE WAVE IS AN ELIGIBILITY LEVEL, NOT A PRIORITY: every story this one depends on sits in
|
|
2414
|
-
# a lower one, so stories sharing a wave have no ordering between them and may be picked up
|
|
2415
|
-
# in parallel. Computed by backlog-plan from `depends_on`, and written here for the same
|
|
2416
|
-
# reason `exercises:` is — a field the gates and the listings read is observed, not declared.
|
|
2417
|
-
# A story stamped without one floors at 1: nothing is KNOWN to have to land before it.
|
|
2418
|
-
wv = (os.environ.get("AW_WAVE") or "").strip()
|
|
2419
|
-
if wv.isdigit():
|
|
2420
|
-
changed["wave"] = put("wave", wv)
|
|
2421
|
-
elif not get("wave").isdigit():
|
|
2422
|
-
changed["wave"] = put("wave", "1")
|
|
2423
|
-
if not get("created") or get("created").startswith("["):
|
|
2424
|
-
changed["created"] = put("created", datetime.date.today().isoformat())
|
|
2425
|
-
for k in ("title", "follows", "work_item"):
|
|
2426
|
-
if not any(e[0] == k for e in order): put(k, "")
|
|
2427
|
-
|
|
2428
|
-
# Rendered back in template order, with anything unrecognised kept after it in the order it
|
|
2429
|
-
# was written. A story is read by people as well as by gates.
|
|
2430
|
-
rank = {k: i for i, k in enumerate(TEMPLATE_ORDER)}
|
|
2431
|
-
order.sort(key=lambda e: rank.get(e[0], len(TEMPLATE_ORDER)))
|
|
2432
|
-
out_block = "\n".join(l for e in order for l in e[1])
|
|
2433
|
-
open(path, "w", encoding="utf-8").write("---\n" + out_block + "\n---\n\n" + body.lstrip("\n"))
|
|
2434
|
-
|
|
2435
|
-
print(json.dumps({"ok": True, "story": story, "path": path,
|
|
2436
|
-
"exercises": ids, "exercises_csv": ", ".join(ids),
|
|
2437
|
-
"spec_version": get("spec_version"), "status": get("status"),
|
|
2438
|
-
"depends_on": get("depends_on"), "wave": get("wave"),
|
|
2439
|
-
"follows": get("follows"), "title": get("title"),
|
|
2440
|
-
"type": get("type"), "had_frontmatter": bool(m),
|
|
2441
|
-
"fields": sorted(changed)}))
|
|
2442
|
-
'@
|
|
2443
|
-
|
|
2444
|
-
function Invoke-StoryStamp {
|
|
2445
|
-
param([string[]]$Rest)
|
|
2446
|
-
$fdArg = ''; $story = ''; $ids = ''; $version = ''; $follows = ''; $idsGiven = ''; $title = ''
|
|
2447
|
-
$deps = ''; $wave = ''
|
|
2448
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2449
|
-
switch ($Rest[$i]) {
|
|
2450
|
-
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
2451
|
-
'--story' { $story = $Rest[++$i] }
|
|
2452
|
-
'--ids' { $ids = $Rest[++$i]; $idsGiven = '1' }
|
|
2453
|
-
'--version' { $version = $Rest[++$i] }
|
|
2454
|
-
'--follows' { $follows = $Rest[++$i] }
|
|
2455
|
-
'--title' { $title = $Rest[++$i] }
|
|
2456
|
-
'--depends-on' { $deps = $Rest[++$i] }
|
|
2457
|
-
'--wave' { $wave = $Rest[++$i] }
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
if (-not $story) { Write-Error 'story-stamp: --story is required'; exit 1 }
|
|
2461
|
-
$repo = Get-RepoRoot
|
|
2462
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2463
|
-
if (-not $fd) { Write-Error 'story-stamp: no feature set and no --feature-dir'; exit 1 }
|
|
2464
|
-
$py = Get-Py; if (-not $py) { Write-Error 'story-stamp: python3 required'; exit 1 }
|
|
2465
|
-
$env:AW_FD = $fd; $env:AW_STORY = $story; $env:AW_IDS = $ids
|
|
2466
|
-
$env:AW_IDS_GIVEN = $idsGiven; $env:AW_VER = $version
|
|
2467
|
-
$env:AW_FOLLOWS = $follows; $env:AW_TITLE = $title
|
|
2468
|
-
$env:AW_DEPS = $deps; $env:AW_WAVE = $wave
|
|
2469
|
-
$PyStoryStamp | & $py -
|
|
2470
|
-
}
|
|
2471
|
-
|
|
2472
|
-
$PySpecCoverage = @'
|
|
2473
|
-
import json, os, re, sys
|
|
2474
|
-
fd = os.environ["AW_FD"]
|
|
2475
|
-
spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
|
|
2476
|
-
BEGIN, END = "<!-- AW:COVERAGE:BEGIN -->", "<!-- AW:COVERAGE:END -->"
|
|
2477
|
-
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
2478
|
-
def parse_fm(text):
|
|
2479
|
-
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
2480
|
-
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
2481
|
-
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
2482
|
-
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
2483
|
-
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
2484
|
-
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
2485
|
-
if not m: return {}
|
|
2486
|
-
fm, key, block = {}, None, set()
|
|
2487
|
-
for line in m.group(1).split("\n"):
|
|
2488
|
-
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
2489
|
-
if km:
|
|
2490
|
-
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
2491
|
-
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
2492
|
-
if im and (key in block or not fm[key]):
|
|
2493
|
-
block.add(key)
|
|
2494
|
-
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
2495
|
-
return fm
|
|
2496
|
-
out = {"ok": True, "written": False}
|
|
2497
|
-
if not os.path.isfile(spec):
|
|
2498
|
-
out.update(ok=False, error="no spec.md at %s" % fd); print(json.dumps(out)); sys.exit(1)
|
|
2499
|
-
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
2500
|
-
if not COV.search(text):
|
|
2501
|
-
# A spec from before the block existed, or one whose markers were deleted. Not an
|
|
2502
|
-
# error: the coverage view is an aid, and refusing to run would block the change the
|
|
2503
|
-
# caller actually came to make.
|
|
2504
|
-
out.update(reason="no AW:COVERAGE block in spec.md"); print(json.dumps(out)); sys.exit(0)
|
|
2505
|
-
|
|
2506
|
-
body = COV.sub("", text)
|
|
2507
|
-
rids = []
|
|
2508
|
-
for line in body.split("\n"):
|
|
2509
|
-
m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
|
|
2510
|
-
if m and m.group(1) not in rids: rids.append(m.group(1))
|
|
2511
|
-
rids.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
2512
|
-
|
|
2513
|
-
stories = []
|
|
2514
|
-
if os.path.isdir(sdir):
|
|
2515
|
-
for name in sorted(os.listdir(sdir)):
|
|
2516
|
-
if not name.endswith(".md"): continue
|
|
2517
|
-
p = os.path.join(sdir, name)
|
|
2518
|
-
if not os.path.isfile(p): continue
|
|
2519
|
-
d = parse_fm(open(p, encoding="utf-8", errors="replace").read())
|
|
2520
|
-
sid = d.get("id") or name[:-3]
|
|
2521
|
-
stories.append({"id": sid, "status": (d.get("status") or "open").strip().lower(),
|
|
2522
|
-
"cites": re.findall(r'[A-Z]{2}-\d+', d.get("exercises", "")),
|
|
2523
|
-
"flying": os.path.isdir(os.path.join(sdir, sid))})
|
|
2524
|
-
|
|
2525
|
-
rows, uncovered, delivered = [], [], []
|
|
2526
|
-
for rid in rids:
|
|
2527
|
-
live = [s for s in stories if rid in s["cites"] and s["status"] == "open"]
|
|
2528
|
-
done = [s for s in stories if rid in s["cites"] and s["status"] != "open"]
|
|
2529
|
-
if not live and not done: uncovered.append(rid)
|
|
2530
|
-
# DELIVERED is "no OPEN stories", never "a story closed" — a follow-on can still carry
|
|
2531
|
-
# the remainder, and keying on a closed story would read a half-built requirement as done.
|
|
2532
|
-
elif not live: delivered.append(rid)
|
|
2533
|
-
rows.append("| %s | %s | %s |" % (
|
|
2534
|
-
rid,
|
|
2535
|
-
", ".join(s["id"] + (" (in flight)" if s["flying"] else "") for s in live) or "—",
|
|
2536
|
-
", ".join(s["id"] for s in done) or "—"))
|
|
2537
|
-
|
|
2538
|
-
table = ["| Requirement | Open stories | Closed / cancelled |",
|
|
2539
|
-
"|-------------|--------------|--------------------|"]
|
|
2540
|
-
table += rows or ["| _(no FR- or BR- requirements yet)_ | | |"]
|
|
2541
|
-
open(spec, "w", encoding="utf-8").write(COV.sub(BEGIN + "\n" + "\n".join(table) + "\n" + END, text, count=1))
|
|
2542
|
-
out.update(written=True, requirements=len(rids), stories=len(stories),
|
|
2543
|
-
uncovered=uncovered, uncovered_csv=", ".join(uncovered),
|
|
2544
|
-
delivered=delivered, delivered_csv=", ".join(delivered))
|
|
2545
|
-
print(json.dumps(out))
|
|
2546
|
-
'@
|
|
2547
|
-
|
|
2548
|
-
# Set subtraction with a purpose: of the requirements this run disturbed, which ones was
|
|
2549
|
-
# it NOT given permission to change. The approved list comes from a multi-select ask, so
|
|
2550
|
-
# the complement is what the re-author has to restore. Its own subcommand because the
|
|
2551
|
-
# orchestrator has no expressions — a workflow can bind a value, never compute one.
|
|
2552
|
-
$PySpecPreserve = @'
|
|
2553
|
-
import json, os, re
|
|
2554
|
-
def ids(s):
|
|
2555
|
-
seen, out = set(), []
|
|
2556
|
-
for t in re.findall(r'[A-Z]{2}-\d+', s or ""):
|
|
2557
|
-
if t not in seen: seen.add(t); out.append(t)
|
|
2558
|
-
return out
|
|
2559
|
-
mode = os.environ.get("AW_MODE", "complement")
|
|
2560
|
-
dist, ok = ids(os.environ.get("AW_DIST", "")), ids(os.environ.get("AW_OK", ""))
|
|
2561
|
-
if mode == "verify":
|
|
2562
|
-
# DID THE RESTORE ACTUALLY HOLD. The re-author is a model asked to put requirements back
|
|
2563
|
-
# to their earlier wording; nothing until now checked that it did. The diff taken after
|
|
2564
|
-
# it is the proof: a requirement the author refused to move must no longer appear in the
|
|
2565
|
-
# disturbing set. One that still does was amended WITHOUT approval — and the run would
|
|
2566
|
-
# otherwise report it, in the same breath, as having been left alone.
|
|
2567
|
-
pre = ids(os.environ.get("AW_PRE", ""))
|
|
2568
|
-
violated = [d for d in pre if d in dist]
|
|
2569
|
-
held = [d for d in pre if d not in dist]
|
|
2570
|
-
print(json.dumps({"ok": not violated, "mode": "verify", "preserved": pre,
|
|
2571
|
-
"held": held, "held_csv": ", ".join(held), "held_count": len(held),
|
|
2572
|
-
"violated": violated, "violated_csv": ", ".join(violated),
|
|
2573
|
-
"violated_count": len(violated), "restore_ok": not violated}))
|
|
2574
|
-
else:
|
|
2575
|
-
preserve = [d for d in dist if d not in ok]
|
|
2576
|
-
print(json.dumps({"ok": True, "mode": "complement", "disturbing": dist, "approved": ok,
|
|
2577
|
-
"preserve": preserve,
|
|
2578
|
-
"preserve_csv": ", ".join(preserve), "preserve_count": len(preserve),
|
|
2579
|
-
"approved_csv": ", ".join(ok)}))
|
|
2580
|
-
'@
|
|
2581
|
-
|
|
2582
|
-
function Invoke-SpecCoverage {
|
|
2583
|
-
param([string[]]$Rest)
|
|
2584
|
-
$fdArg = ''
|
|
2585
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--feature-dir') { $fdArg = $Rest[++$i] } }
|
|
2586
|
-
$repo = Get-RepoRoot
|
|
2587
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2588
|
-
if (-not $fd) { Write-Error 'spec-coverage: no feature set and no --feature-dir'; exit 1 }
|
|
2589
|
-
$py = Get-Py; if (-not $py) { Write-Error 'spec-coverage: python3 required'; exit 1 }
|
|
2590
|
-
$env:AW_FD = $fd
|
|
2591
|
-
$PySpecCoverage | & $py -
|
|
2592
|
-
}
|
|
2593
|
-
|
|
2594
|
-
function Invoke-SpecPreserve {
|
|
2595
|
-
param([string[]]$Rest)
|
|
2596
|
-
$dist = ''; $ok = ''; $pre = ''; $mode = 'complement'
|
|
2597
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2598
|
-
switch ($Rest[$i]) {
|
|
2599
|
-
'--disturbing' { $dist = $Rest[++$i] }
|
|
2600
|
-
'--approved' { $ok = $Rest[++$i] }
|
|
2601
|
-
'--preserved' { $pre = $Rest[++$i] }
|
|
2602
|
-
'--verify' { $mode = 'verify' }
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
$py = Get-Py; if (-not $py) { Write-Error 'spec-preserve: python3 required'; exit 1 }
|
|
2606
|
-
$env:AW_DIST = $dist; $env:AW_OK = $ok; $env:AW_PRE = $pre; $env:AW_MODE = $mode
|
|
2607
|
-
$PySpecPreserve | & $py -
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
# ────────────────────────────── spec-markers ───────────────────────────────
|
|
2611
|
-
# Are there any [NEEDS CLARIFICATION] markers to resolve. Deterministic and free, which is
|
|
2612
|
-
# the point: asking a model "are there any markers" costs a full round trip on every run to
|
|
2613
|
-
# hear "no" on most of them. The model is still what turns a marker into a question with
|
|
2614
|
-
# answerable options — this only decides whether to call it at all.
|
|
2615
|
-
$PySpecMarkers = @'
|
|
2616
|
-
import json, os, re
|
|
2617
|
-
spec = os.environ["AW_SPEC"]
|
|
2618
|
-
text = open(spec, encoding="utf-8", errors="replace").read() if os.path.isfile(spec) else ""
|
|
2619
|
-
found = [" ".join(f.split()) for f in re.findall(r'\[NEEDS CLARIFICATION:\s*(.*?)\]', text, re.S)]
|
|
2620
|
-
print(json.dumps({"ok": True, "spec_exists": bool(text), "markers": found,
|
|
2621
|
-
"marker_count": len(found), "any": bool(found)}))
|
|
2622
|
-
'@
|
|
2623
|
-
|
|
2624
|
-
function Invoke-SpecMarkers {
|
|
2625
|
-
param([string[]]$Rest)
|
|
2626
|
-
$fdArg = ''
|
|
2627
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--feature-dir') { $fdArg = $Rest[++$i] } }
|
|
2628
|
-
$repo = Get-RepoRoot
|
|
2629
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2630
|
-
if (-not $fd) { Write-Error 'spec-markers: no feature set and no --feature-dir'; exit 1 }
|
|
2631
|
-
$py = Get-Py; if (-not $py) { Write-Error 'spec-markers: python3 required'; exit 1 }
|
|
2632
|
-
$env:AW_SPEC = (Join-Path $fd 'spec.md')
|
|
2633
|
-
$PySpecMarkers | & $py -
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
function Invoke-SpecLedger { # op in check|stamp|append|rowids
|
|
2637
|
-
param([string]$Op, [string[]]$Rest)
|
|
2638
|
-
$fdArg = ''; $ids = ''; $summary = ''
|
|
2639
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2640
|
-
switch ($Rest[$i]) {
|
|
2641
|
-
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
2642
|
-
'--ids' { $ids = $Rest[++$i] }
|
|
2643
|
-
'--summary' { $summary = $Rest[++$i] }
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
$repo = Get-RepoRoot
|
|
2647
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2648
|
-
if (-not $fd) { Write-Error "$Op`: no feature set and no --feature-dir"; exit 1 }
|
|
2649
|
-
$py = Get-Py; if (-not $py) { Write-Error "$Op`: python3 required"; exit 1 }
|
|
2650
|
-
# The delta-spec template, so `check` can tell an AUTHORED delta from the untouched copy
|
|
2651
|
-
# cr-setup scaffolds for every change it opens.
|
|
2652
|
-
$dtpl = @(Resolve-NamedTemplate -Name 'delta-spec-template' -RepoRoot $repo) | Select-Object -Last 1
|
|
2653
|
-
$env:AW_FD = $fd; $env:AW_OP = $Op; $env:AW_IDS = $ids; $env:AW_SUM = $summary
|
|
2654
|
-
$env:AW_DTPL = if ($dtpl) { [string]$dtpl } else { '' }
|
|
2655
|
-
$PySpecLedger | & $py -
|
|
2656
|
-
}
|
|
2657
|
-
|
|
2658
|
-
function Invoke-SpecSnapshot {
|
|
2659
|
-
param([string[]]$Rest)
|
|
2660
|
-
$fdArg = ''
|
|
2661
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--feature-dir') { $fdArg = $Rest[++$i] } }
|
|
2662
|
-
$repo = Get-RepoRoot
|
|
2663
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2664
|
-
if (-not $fd) { Write-Error 'spec-snapshot: no feature set and no --feature-dir'; exit 1 }
|
|
2665
|
-
$spec = Join-Path $fd 'spec.md'
|
|
2666
|
-
$snap = ''
|
|
2667
|
-
if (Test-Path $spec) {
|
|
2668
|
-
# BESIDE THE SPEC, not in the system temp directory. Only the PATH is replay-cached,
|
|
2669
|
-
# and the step is not re-run on resume — so a checkpointed run paused for a human
|
|
2670
|
-
# answer could outlive the temp file. A cleaned temp dir then left spec-diff with no
|
|
2671
|
-
# before-state at all: every id read as ADDED, nothing MODIFIED, so the E38 ask never
|
|
2672
|
-
# fired, the ledger row named every requirement in the document, and every open story
|
|
2673
|
-
# went stale for a change that touched none of them. Beside the spec it shares the
|
|
2674
|
-
# file's lifetime and travels with the repository.
|
|
2675
|
-
#
|
|
2676
|
-
# One snapshot per feature, overwritten each run: a run diffs against it TWICE, once
|
|
2677
|
-
# to see what changed and again after re-authoring a change made additively.
|
|
2678
|
-
$snap = Join-Path $fd '.aw-snapshot.md'
|
|
2679
|
-
Copy-Item $spec $snap -Force
|
|
2680
|
-
}
|
|
2681
|
-
Write-Json ([ordered]@{ snapshot = $snap; taken = [bool]$snap })
|
|
2682
|
-
}
|
|
2683
|
-
|
|
2684
|
-
function Invoke-SpecDiff {
|
|
2685
|
-
param([string[]]$Rest)
|
|
2686
|
-
$fdArg = ''; $snap = ''
|
|
2687
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2688
|
-
switch ($Rest[$i]) {
|
|
2689
|
-
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
2690
|
-
'--snapshot' { $snap = $Rest[++$i] }
|
|
2691
|
-
}
|
|
2692
|
-
}
|
|
2693
|
-
$repo = Get-RepoRoot
|
|
2694
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2695
|
-
if (-not $fd) { Write-Error 'spec-diff: no feature set and no --feature-dir'; exit 1 }
|
|
2696
|
-
$py = Get-Py; if (-not $py) { Write-Error 'spec-diff: python3 required'; exit 1 }
|
|
2697
|
-
$env:AW_SNAP = $snap; $env:AW_SPEC = (Join-Path $fd 'spec.md')
|
|
2698
|
-
$PySpecDiff | & $py -
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
1100
|
# ─────────────────────────── feature-paths ─────────────────────────────────
|
|
2702
1101
|
function Invoke-FeaturePaths {
|
|
2703
1102
|
param([string[]]$Rest)
|
|
@@ -2710,45 +1109,53 @@ function Invoke-FeaturePaths {
|
|
|
2710
1109
|
if (-not $fd) { Write-Error 'feature-paths: no feature set (run /appweaver select-feature first)'; exit 1 }
|
|
2711
1110
|
if (-not [System.IO.Path]::IsPathRooted($fd)) { $fd = Join-Path $repo $fd }
|
|
2712
1111
|
$feature = Split-Path $fd -Leaf # the aggregate name (feature = specs/<Feature>)
|
|
2713
|
-
# The master documents of a feature: spec.md (WHAT/WHY) +
|
|
2714
|
-
# lane's HOW)
|
|
2715
|
-
#
|
|
2716
|
-
#
|
|
1112
|
+
# The master documents of a feature: spec.md (WHAT/WHY) + one LANE PLAN per declared
|
|
1113
|
+
# lane (each lane's HOW) — backend-plan.md for the service lane, frontend-plan.md for
|
|
1114
|
+
# the frontend lane. `plan` writes them; nothing else does.
|
|
1115
|
+
#
|
|
1116
|
+
# LEGACY NAMES RESOLVE TOO. A feature authored before the lane split carries design.md
|
|
1117
|
+
# / design.ui.md, so each lane resolves to its plan document when that exists and to
|
|
1118
|
+
# the legacy name while it does not. Every reader therefore finds the document that is
|
|
1119
|
+
# actually on disk, and plan-scaffold renames it into the plan name on the next `plan`
|
|
1120
|
+
# run — which is what keeps the rename incremental instead of a flag day.
|
|
1121
|
+
# design_file / ui_design_file are kept as ALIASES of the same two resolved paths, so
|
|
1122
|
+
# a stage that has not been repointed yet reads the right document under its old key.
|
|
2717
1123
|
#
|
|
2718
1124
|
# ui_design_exists IS THE UI-HALF SIGNAL for the whole CR loop. Every delta stage
|
|
2719
1125
|
# asks the same question — is there a UI master to keep in sync? — and the master's
|
|
2720
1126
|
# own presence answers it without re-deriving the lane declaration and the slice's
|
|
2721
|
-
# UI file count at each stage. It is also self-correcting: whatever authors
|
|
2722
|
-
#
|
|
2723
|
-
#
|
|
1127
|
+
# UI file count at each stage. It is also self-correcting: whatever authors the
|
|
1128
|
+
# frontend plan enrolls the feature in the UI half of the delta loop by the act of
|
|
1129
|
+
# writing the file.
|
|
2724
1130
|
$spec = Join-Path $fd 'spec.md'
|
|
2725
|
-
$design = Join-Path $fd '
|
|
2726
|
-
$uiDesign = Join-Path $fd '
|
|
2727
|
-
$
|
|
2728
|
-
$
|
|
1131
|
+
$design = Join-Path $fd 'backend-plan.md'
|
|
1132
|
+
$uiDesign = Join-Path $fd 'frontend-plan.md'
|
|
1133
|
+
if (-not (Test-Path $design) -and (Test-Path (Join-Path $fd 'design.md'))) { $design = Join-Path $fd 'design.md' }
|
|
1134
|
+
if (-not (Test-Path $uiDesign) -and (Test-Path (Join-Path $fd 'design.ui.md'))) { $uiDesign = Join-Path $fd 'design.ui.md' }
|
|
2729
1135
|
# needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
|
|
2730
1136
|
# leaves spec.md on disk with the marker still up, and branching on the file alone read
|
|
2731
1137
|
# that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
|
|
2732
|
-
$bootstrapPending = $false
|
|
2733
1138
|
$avFile = Join-Path $fd 'active_version.json'
|
|
1139
|
+
$bootstrapPending = $false
|
|
2734
1140
|
if (Test-Path $avFile) {
|
|
2735
1141
|
try { $bootstrapPending = [bool](Get-Content $avFile -Raw | ConvertFrom-Json).bootstrap_pending } catch { $bootstrapPending = $false }
|
|
2736
1142
|
}
|
|
1143
|
+
$specExists = (Test-Path $spec)
|
|
2737
1144
|
Write-Json ([ordered]@{
|
|
2738
|
-
feature
|
|
2739
|
-
feature_dir
|
|
2740
|
-
spec_file
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
1145
|
+
feature = $feature
|
|
1146
|
+
feature_dir = $fd
|
|
1147
|
+
spec_file = $spec
|
|
1148
|
+
backend_plan = $design
|
|
1149
|
+
frontend_plan = $uiDesign
|
|
1150
|
+
backend_plan_exists = (Test-Path $design)
|
|
1151
|
+
frontend_plan_exists = (Test-Path $uiDesign)
|
|
1152
|
+
design_file = $design
|
|
1153
|
+
ui_design_file = $uiDesign
|
|
1154
|
+
spec_exists = $specExists
|
|
1155
|
+
design_exists = (Test-Path $design)
|
|
1156
|
+
ui_design_exists = (Test-Path $uiDesign)
|
|
2750
1157
|
bootstrap_pending = $bootstrapPending
|
|
2751
|
-
needs_bootstrap
|
|
1158
|
+
needs_bootstrap = ((-not $specExists) -or $bootstrapPending)
|
|
2752
1159
|
})
|
|
2753
1160
|
}
|
|
2754
1161
|
|
|
@@ -2794,15 +1201,15 @@ function Invoke-ChecklistApply {
|
|
|
2794
1201
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2795
1202
|
switch ($Rest[$i]) { '--file' { $file = $Rest[++$i] } '--results' { $resultsRaw = $Rest[++$i] } '--all-pass' { $allPassRaw = $Rest[++$i] } }
|
|
2796
1203
|
}
|
|
2797
|
-
# The per-item map is the model's, and a model that judged the document to pass routinely
|
|
2798
|
-
# returns only the items it looked hard at — or none at all. Scoring that map alone then
|
|
2799
|
-
# reported "0/25 items passing" for a spec the same call had just passed, which is the one
|
|
2800
|
-
# quality signal this phase emits and the fastest way to teach people to ignore it.
|
|
2801
|
-
$allPass = @('true', '1', 'yes') -contains $allPassRaw.Trim().ToLower()
|
|
2802
1204
|
if (-not $file) { Write-Error 'checklist-apply: --file required'; exit 1 }
|
|
2803
1205
|
if (-not (Test-Path $file)) { Write-Json ([ordered]@{ exists = $false; before_checked = 0; after_checked = 0; total = 0; newly_passing = @(); regressions = @() }); return }
|
|
2804
1206
|
$results = @{}
|
|
2805
1207
|
try { (ConvertFrom-Json $resultsRaw).PSObject.Properties | ForEach-Object { $results[[int]$_.Name] = [bool]$_.Value } } catch {}
|
|
1208
|
+
# The per-item map is the model's, and a model that judged the document to pass routinely
|
|
1209
|
+
# returns only the items it looked hard at — or none at all. Scoring that map alone then
|
|
1210
|
+
# reported "0/25 items passing" for a spec the same call had just passed, which is the one
|
|
1211
|
+
# quality signal this phase emits and the fastest way to teach people to ignore it.
|
|
1212
|
+
$allPass = $allPassRaw.Trim().ToLower() -in @('true', '1', 'yes')
|
|
2806
1213
|
$lines = Get-Content $file
|
|
2807
1214
|
$items = Get-CheckboxLines -Lines $lines
|
|
2808
1215
|
$before = @($items | Where-Object { $_.checked }).Count
|
|
@@ -2855,12 +1262,37 @@ function Get-ProjectMarkers {
|
|
|
2855
1262
|
return $out
|
|
2856
1263
|
}
|
|
2857
1264
|
|
|
1265
|
+
# Does a manifest belong to the ecosystem the caller asked about. The repo-scope fallback
|
|
1266
|
+
# below is deliberate — a .NET solution legitimately sits ABOVE the lane it scaffolds — but
|
|
1267
|
+
# it answers with whatever manifest it finds, so in a two-lane repo where only the frontend
|
|
1268
|
+
# is scaffolded the service lane's gate passes on the frontend's package.json. Knowing the
|
|
1269
|
+
# lane's ecosystem is what turns that hit back into a miss.
|
|
1270
|
+
function Get-MarkerEcosystem {
|
|
1271
|
+
param([string]$Marker)
|
|
1272
|
+
switch -Wildcard (($Marker -split '/')[-1]) {
|
|
1273
|
+
'*.sln' { return 'dotnet' }
|
|
1274
|
+
'*.csproj' { return 'dotnet' }
|
|
1275
|
+
'package.json' { return 'node' }
|
|
1276
|
+
'pom.xml' { return 'java' }
|
|
1277
|
+
'build.gradle' { return 'java' }
|
|
1278
|
+
'build.gradle.kts' { return 'java' }
|
|
1279
|
+
'go.mod' { return 'go' }
|
|
1280
|
+
'Cargo.toml' { return 'rust' }
|
|
1281
|
+
'pyproject.toml' { return 'python' }
|
|
1282
|
+
default { return '' }
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
2858
1286
|
function Invoke-DetectProject {
|
|
2859
1287
|
param([string[]]$Rest)
|
|
2860
|
-
$scopeRoot = ''
|
|
1288
|
+
$scopeRoot = ''; $ecosystem = ''
|
|
2861
1289
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2862
1290
|
switch ($Rest[$i]) {
|
|
2863
1291
|
{ $_ -in '--root','--source-path','-Root' } { $scopeRoot = $Rest[++$i] }
|
|
1292
|
+
# The lane's ecosystem, passed in by the caller that already resolved it.
|
|
1293
|
+
# NOT re-derived here: the caller binds it from stacks.json before this op
|
|
1294
|
+
# runs, and a second guess is a second thing to keep in step with that file.
|
|
1295
|
+
'--ecosystem' { $ecosystem = $Rest[++$i] }
|
|
2864
1296
|
}
|
|
2865
1297
|
}
|
|
2866
1298
|
$repo = Get-RepoRoot
|
|
@@ -2877,7 +1309,18 @@ function Invoke-DetectProject {
|
|
|
2877
1309
|
$scope = 'repo'
|
|
2878
1310
|
$markers = @(Get-ProjectMarkers -Repo $repo -Start $repo)
|
|
2879
1311
|
}
|
|
2880
|
-
|
|
1312
|
+
$scaffolded = ($markers.Count -gt 0)
|
|
1313
|
+
# UNASKED IS NOT UNMATCHED. With no --ecosystem the caller has told us nothing to
|
|
1314
|
+
# judge against, and answering false there would fail every existing call site.
|
|
1315
|
+
$match = $true
|
|
1316
|
+
if ($ecosystem -and $scaffolded) {
|
|
1317
|
+
$match = $false
|
|
1318
|
+
foreach ($m in $markers) {
|
|
1319
|
+
if ((Get-MarkerEcosystem -Marker $m) -eq $ecosystem) { $match = $true; break }
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
Write-Json ([ordered]@{ scaffolded = $scaffolded; markers = @($markers); scope = $scope
|
|
1323
|
+
ecosystem = $ecosystem; ecosystem_match = $match })
|
|
2881
1324
|
}
|
|
2882
1325
|
|
|
2883
1326
|
|
|
@@ -3659,17 +2102,17 @@ function Invoke-DesignScaffold {
|
|
|
3659
2102
|
# --lane. In a brownfield repo onboard GENERATES a code-derived override for both
|
|
3660
2103
|
# names, so the defaults already yield the as-built structure.
|
|
3661
2104
|
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--lane','-Lane' } { $lane = $Rest[++$i] } { $_ -in '--template','-Template' } { $tplName = $Rest[++$i] } } }
|
|
3662
|
-
# backend/frontend are the PLAN-phase pairs: the same templates, the plan document
|
|
3663
|
-
# names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the
|
|
3664
|
-
#
|
|
3665
|
-
#
|
|
2105
|
+
# backend / frontend are the PLAN-phase pairs: the same templates, the plan document
|
|
2106
|
+
# names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the plan
|
|
2107
|
+
# name, so a feature never carries two spellings of one master. service / ui stay
|
|
2108
|
+
# byte-identical for the callers that still write the legacy names.
|
|
3666
2109
|
$legacyName = ''
|
|
3667
2110
|
switch ($lane) {
|
|
3668
2111
|
'service' { $docName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
|
|
3669
2112
|
'ui' { $docName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
|
|
3670
2113
|
'backend' { $docName = 'backend-plan.md'; $legacyName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
|
|
3671
2114
|
'frontend' { $docName = 'frontend-plan.md'; $legacyName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
|
|
3672
|
-
default { Write-Error ("design-scaffold: unknown --lane '" + $lane + "' (service|ui
|
|
2115
|
+
default { Write-Error ("design-scaffold: unknown --lane '" + $lane + "' (backend|frontend|service|ui)"); exit 1 }
|
|
3673
2116
|
}
|
|
3674
2117
|
$repo = Get-RepoRoot
|
|
3675
2118
|
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
@@ -3677,6 +2120,9 @@ function Invoke-DesignScaffold {
|
|
|
3677
2120
|
New-Item -ItemType Directory -Force -Path $fd | Out-Null
|
|
3678
2121
|
$target = Join-Path $fd $docName
|
|
3679
2122
|
$migrated = $false
|
|
2123
|
+
# A feature that predates the lane split carries the legacy name. Rename it rather than
|
|
2124
|
+
# scaffolding an empty plan beside it: the content IS this lane's plan, and two files
|
|
2125
|
+
# would leave every reader guessing which one is current.
|
|
3680
2126
|
if ((-not (Test-Path $target)) -and $legacyName -and (Test-Path (Join-Path $fd $legacyName))) {
|
|
3681
2127
|
Move-Item (Join-Path $fd $legacyName) $target; $migrated = $true
|
|
3682
2128
|
}
|
|
@@ -3693,833 +2139,56 @@ function Invoke-DesignScaffold {
|
|
|
3693
2139
|
$overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
|
|
3694
2140
|
$templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
|
|
3695
2141
|
$tsOut = if ($templateSource) { $templateSource } else { '' }
|
|
3696
|
-
Write-Json ([ordered]@{ feature_dir = $fd; design_doc = $target; lane = $lane; created = $created; migrated = $migrated; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
|
|
3697
|
-
}
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
#
|
|
3701
|
-
#
|
|
3702
|
-
#
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
agg = os.path.basename(fd.rstrip("/\\"))
|
|
3709
|
-
|
|
3710
|
-
def git(*a):
|
|
3711
|
-
"""A git read that never raises. A working copy this command cannot interrogate is a
|
|
3712
|
-
FACT TO REPORT — the caller decides what an unreadable oracle means — never a crash
|
|
3713
|
-
that takes the gate down with it."""
|
|
3714
|
-
try:
|
|
3715
|
-
p = subprocess.run(["git", "-C", root] + list(a), capture_output=True, text=True)
|
|
3716
|
-
except Exception:
|
|
3717
|
-
return 1, ""
|
|
3718
|
-
return p.returncode, (p.stdout or "").strip()
|
|
3719
|
-
|
|
3720
|
-
DIR_RE = re.compile(r'(^|/)(tests?|specs?|__tests__|testing|test-suite)(/|$)', re.I)
|
|
3721
|
-
|
|
3722
|
-
# The kit's OWN trees, which hold documents and configuration and never tests. The spec
|
|
3723
|
-
# tree is derived rather than assumed: `specs/` matches the RSpec directory convention
|
|
3724
|
-
# exactly, so without this every requirement document — and this gate's own verdict file,
|
|
3725
|
-
# written before the diff is taken — counted as a modified test.
|
|
3726
|
-
SKIP = [".appweaver/"]
|
|
3727
|
-
if fd:
|
|
3728
|
-
_rel = os.path.relpath(fd, root).replace("\\", "/")
|
|
3729
|
-
SKIP.append((_rel.rsplit("/", 1)[0] if "/" in _rel else _rel).rstrip("/") + "/")
|
|
3730
|
-
|
|
3731
|
-
def is_test(path):
|
|
3732
|
-
"""A path is a TEST when a directory on it says so, or its own filename does — the
|
|
3733
|
-
four conventions every shipped stack uses between them: a tests/ directory, a
|
|
3734
|
-
Name.Tests.cs / NameTest.java suffix, a test_name.py / name_test.go affix, and a
|
|
3735
|
-
name.test.ts / name.spec.ts infix. Deliberately GENEROUS: over-calling a file a test
|
|
3736
|
-
costs a louder message, under-calling one costs a technical story that changed
|
|
3737
|
-
behaviour and passed its own oracle."""
|
|
3738
|
-
p = path.replace("\\", "/")
|
|
3739
|
-
if any(p.startswith(s) for s in SKIP):
|
|
3740
|
-
return False
|
|
3741
|
-
stem, ext = os.path.splitext(os.path.basename(p))
|
|
3742
|
-
# Prose is never a test, wherever it sits. A tests/ directory holds its own README
|
|
3743
|
-
# and a docs tree holds files named for testing; counting either would fail a
|
|
3744
|
-
# technical story over a paragraph.
|
|
3745
|
-
if ext.lower() in (".md", ".markdown", ".txt", ".rst", ".adoc"):
|
|
3746
|
-
return False
|
|
3747
|
-
if DIR_RE.search(os.path.dirname(p)):
|
|
3748
|
-
return True
|
|
3749
|
-
stem = stem.lower()
|
|
3750
|
-
if stem.startswith(("test", "spec")) or stem.endswith(("test", "tests", "spec", "specs")):
|
|
3751
|
-
return True
|
|
3752
|
-
return any(k in stem for k in (".test", ".spec", "_test", "_spec", "-test", "-spec"))
|
|
3753
|
-
|
|
3754
|
-
out = {"ok": True, "aggregate": agg, "git": False, "checked": False,
|
|
3755
|
-
"base": "", "base_ref": "", "base_kind": "none", "files_changed": 0,
|
|
3756
|
-
"tests_changed": [], "tests_changed_csv": "", "tests_changed_count": 0,
|
|
3757
|
-
"tests_deleted": [], "tests_deleted_csv": "",
|
|
3758
|
-
"tests_added": [], "tests_added_csv": "", "tests_added_count": 0,
|
|
3759
|
-
"slice_tests_changed": [], "slice_tests_changed_csv": "", "slice_tests_changed_count": 0,
|
|
3760
|
-
"any_test_changed": False, "clean": True, "oracle": "clean",
|
|
3761
|
-
"note": "", "digest": ""}
|
|
3762
|
-
|
|
3763
|
-
if git("rev-parse", "--is-inside-work-tree")[1] != "true":
|
|
3764
|
-
out["oracle"] = "unverified"
|
|
3765
|
-
out["note"] = ("Not a git working copy, so the test suite's history cannot be read. The "
|
|
3766
|
-
"unchanged-suite oracle is UNVERIFIED for this run — not passed.")
|
|
3767
|
-
out["digest"] = out["note"]
|
|
3768
|
-
print(json.dumps(out)); raise SystemExit(0)
|
|
3769
|
-
out["git"] = out["checked"] = True
|
|
3770
|
-
|
|
3771
|
-
# WHICH BASELINE. An explicit --base wins. Otherwise the fork point from the integration
|
|
3772
|
-
# branch, which is what a story's work sits on top of; falling back to HEAD, which sees
|
|
3773
|
-
# the working tree alone. The kind is reported because it changes what the answer MEANS:
|
|
3774
|
-
# a `head` baseline cannot see a test modified in a commit this branch already made.
|
|
3775
|
-
head = git("rev-parse", "HEAD")[1]
|
|
3776
|
-
if want and git("rev-parse", "--verify", want + "^{commit}")[0] == 0:
|
|
3777
|
-
out["base"], out["base_ref"], out["base_kind"] = git("rev-parse", want)[1], want, "given"
|
|
3778
|
-
if not out["base"]:
|
|
3779
|
-
cands = []
|
|
3780
|
-
rc, ref = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
3781
|
-
if rc == 0 and ref:
|
|
3782
|
-
cands.append(ref.split("refs/remotes/", 1)[-1])
|
|
3783
|
-
cands += ["origin/main", "origin/master", "main", "master"]
|
|
3784
|
-
seen = set()
|
|
3785
|
-
for c in cands:
|
|
3786
|
-
if c in seen:
|
|
3787
|
-
continue
|
|
3788
|
-
seen.add(c)
|
|
3789
|
-
if git("rev-parse", "--verify", c + "^{commit}")[0] != 0:
|
|
3790
|
-
continue
|
|
3791
|
-
rc, mb = git("merge-base", "HEAD", c)
|
|
3792
|
-
if rc != 0 or not mb:
|
|
3793
|
-
continue
|
|
3794
|
-
out["base"], out["base_ref"] = mb, c
|
|
3795
|
-
out["base_kind"] = "head" if mb == head else "merge-base"
|
|
3796
|
-
break
|
|
3797
|
-
if not out["base"]:
|
|
3798
|
-
out["base"], out["base_kind"] = head or "HEAD", "head"
|
|
3799
|
-
|
|
3800
|
-
rc, diff = git("diff", "--name-status", "--find-renames", out["base"])
|
|
3801
|
-
changed, added, deleted, files = [], [], [], 0
|
|
3802
|
-
for line in diff.splitlines():
|
|
3803
|
-
parts = line.split("\t")
|
|
3804
|
-
if len(parts) < 2:
|
|
3805
|
-
continue
|
|
3806
|
-
code, paths = parts[0].strip(), parts[1:]
|
|
3807
|
-
files += 1
|
|
3808
|
-
hits = [p for p in paths if is_test(p)]
|
|
3809
|
-
if not hits:
|
|
3810
|
-
continue
|
|
3811
|
-
p = hits[-1]
|
|
3812
|
-
if code.startswith("A") or code.startswith("C"):
|
|
3813
|
-
# A NEW test is not a changed one. A refactor that adds characterisation tests is
|
|
3814
|
-
# exactly the work a technical story exists to do.
|
|
3815
|
-
added.append(p)
|
|
3816
|
-
elif code.startswith("R"):
|
|
3817
|
-
# A pure rename moves a test without changing what it asserts; anything less than
|
|
3818
|
-
# a 100% match carries an edit inside it.
|
|
3819
|
-
(added if code == "R100" else changed).append(p)
|
|
3820
|
-
elif code.startswith("D"):
|
|
3821
|
-
deleted.append(p); changed.append(p)
|
|
3822
|
-
else:
|
|
3823
|
-
changed.append(p)
|
|
3824
|
-
|
|
3825
|
-
# Untracked files are ADDITIONS the diff cannot see — reported as such so a new test
|
|
3826
|
-
# written in the working tree is not mistaken for a missing one.
|
|
3827
|
-
for p in git("ls-files", "--others", "--exclude-standard")[1].splitlines():
|
|
3828
|
-
p = p.strip()
|
|
3829
|
-
if p and is_test(p) and p not in added:
|
|
3830
|
-
added.append(p); files += 1
|
|
3831
|
-
|
|
3832
|
-
changed = sorted(set(changed)); added = sorted(set(added)); deleted = sorted(set(deleted))
|
|
3833
|
-
low = agg.lower()
|
|
3834
|
-
slice_changed = [p for p in changed if low and low in p.lower()]
|
|
3835
|
-
out.update(files_changed=files,
|
|
3836
|
-
tests_changed=changed, tests_changed_csv=", ".join(changed),
|
|
3837
|
-
tests_changed_count=len(changed),
|
|
3838
|
-
tests_deleted=deleted, tests_deleted_csv=", ".join(deleted),
|
|
3839
|
-
tests_added=added, tests_added_csv=", ".join(added), tests_added_count=len(added),
|
|
3840
|
-
slice_tests_changed=slice_changed, slice_tests_changed_csv=", ".join(slice_changed),
|
|
3841
|
-
slice_tests_changed_count=len(slice_changed),
|
|
3842
|
-
any_test_changed=bool(changed), clean=not changed,
|
|
3843
|
-
# The DIFF's state in one word — clean | changed | unverified — so a caller can
|
|
3844
|
-
# branch on it without recombining a boolean with a "could I even look" flag.
|
|
3845
|
-
# It says what the suite did, never what that means for a story.
|
|
3846
|
-
oracle="changed" if changed else "clean")
|
|
3847
|
-
|
|
3848
|
-
kindnote = {"given": "the ref given on the command line",
|
|
3849
|
-
"merge-base": "the fork point from %s — every commit this branch added is in view" % out["base_ref"],
|
|
3850
|
-
"head": "HEAD — only uncommitted work is in view, so a test modified in a commit "
|
|
3851
|
-
"already made on this branch is NOT counted"}[out["base_kind"]]
|
|
3852
|
-
lines = ["Test-suite diff against %s (%s): %s" % (out["base"][:12], out["base_kind"], kindnote),
|
|
3853
|
-
" files changed: %d" % files,
|
|
3854
|
-
" tests MODIFIED or DELETED (%d): %s" % (len(changed), ", ".join(changed) or "none"),
|
|
3855
|
-
" tests ADDED (%d): %s" % (len(added), ", ".join(added) or "none")]
|
|
3856
|
-
if slice_changed:
|
|
3857
|
-
lines.append(" of those, on this aggregate (%s): %s" % (agg, ", ".join(slice_changed)))
|
|
3858
|
-
out["note"] = kindnote
|
|
3859
|
-
out["digest"] = "\n".join(lines)
|
|
3860
|
-
print(json.dumps(out))
|
|
3861
|
-
'@
|
|
3862
|
-
|
|
3863
|
-
function Invoke-TestDiff {
|
|
2142
|
+
Write-Json ([ordered]@{ feature_dir = $fd; design_doc = $target; plan_doc = $target; lane = $lane; created = $created; migrated = $migrated; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
# ─────────────────────────── delta-plan-scaffold ───────────────────────────
|
|
2146
|
+
# One change's LANE PLAN, inside the change directory beside its delta-spec:
|
|
2147
|
+
# changes/<cr-id>/delta-backend-plan.md (service lane)
|
|
2148
|
+
# changes/<cr-id>/delta-frontend-plan.md (frontend lane)
|
|
2149
|
+
# The plan phase calls this per DECLARED lane, so a backend-only project never grows an
|
|
2150
|
+
# orphan frontend delta plan. Not cr-setup's job: that runs in the spec phase, which knows
|
|
2151
|
+
# nothing about lanes. Same MIGRATION as the master — a change opened before the lane
|
|
2152
|
+
# split carries delta-design.md, renamed into the backend name rather than duplicated.
|
|
2153
|
+
function Invoke-DeltaPlanScaffold {
|
|
3864
2154
|
param([string[]]$Rest)
|
|
3865
|
-
$fdArg = ''; $
|
|
3866
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
$
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
3903
|
-
m = re.search(r'^##\s+Change History\s*$', text, re.M)
|
|
3904
|
-
if m:
|
|
3905
|
-
for line in text[m.end():].splitlines():
|
|
3906
|
-
if idcol < 0 and line.lstrip().startswith("|") and "requirements changed" in line.lower():
|
|
3907
|
-
hdr = [c.strip().lower() for c in line.strip().strip("|").split("|")]
|
|
3908
|
-
idcol = next((i for i, c in enumerate(hdr) if "requirements changed" in c), -1)
|
|
3909
|
-
rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
|
|
3910
|
-
if rm: rows.append((rm.group(1), line))
|
|
3911
|
-
hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
|
|
3912
|
-
cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
|
|
3913
|
-
if cands: cur = max(cands, key=vtuple)
|
|
3914
|
-
out["current_version"] = cur
|
|
3915
|
-
if not out["doc_exists"]:
|
|
3916
|
-
out["state"] = "fresh"; print(json.dumps(out)); sys.exit(0)
|
|
3917
|
-
try: track = json.load(open(track_path, encoding="utf-8"))
|
|
3918
|
-
except Exception: track = {}
|
|
3919
|
-
entry = track.get(os.path.basename(doc))
|
|
3920
|
-
if not entry:
|
|
3921
|
-
# A document with no derivation record — a migrated legacy master, or a baseline
|
|
3922
|
-
# lost by hand. There is no stamp to diff against, so the caller reconciles the
|
|
3923
|
-
# WHOLE document once and plan-stamp baselines it.
|
|
3924
|
-
out["state"] = "untracked"; print(json.dumps(out)); sys.exit(0)
|
|
3925
|
-
derived = entry.get("spec_version", "")
|
|
3926
|
-
out["derived_version"] = derived
|
|
3927
|
-
body = open(doc, encoding="utf-8", errors="replace").read()
|
|
3928
|
-
out["body_changed"] = hashlib.sha256(body.encode("utf-8")).hexdigest() != entry.get("sha256", "")
|
|
3929
|
-
moved = [r for r in rows if vtuple(r[0]) > vtuple(derived)]
|
|
3930
|
-
# Scrape ONLY the ledger's "Requirements changed" cell when it names one. The
|
|
3931
|
-
# Stories cell holds ids of the form <Aggregate>-nnn, which read as requirement ids
|
|
3932
|
-
# whenever the aggregate is spelled in caps (EDI-001, HR-002) — those would arrive
|
|
3933
|
-
# as requirements for the plan to conform, and there is no such requirement.
|
|
3934
|
-
def _scope(line):
|
|
3935
|
-
if idcol >= 0:
|
|
3936
|
-
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
3937
|
-
if idcol < len(cells): return cells[idcol]
|
|
3938
|
-
return line
|
|
3939
|
-
ids = sorted({i for _v, line in moved for i in re.findall(r'[A-Z]{2}-\d+', _scope(line))})
|
|
3940
|
-
out["changed_csv"] = ", ".join(ids)
|
|
3941
|
-
out["changed_summary"] = " // ".join(line.strip() for _v, line in moved)
|
|
3942
|
-
behind = bool(moved) or bool(cur and vtuple(cur) > vtuple(derived))
|
|
3943
|
-
if out["body_changed"] and behind: out["state"] = "edited_behind"
|
|
3944
|
-
elif out["body_changed"]: out["state"] = "edited"
|
|
3945
|
-
elif behind: out["state"] = "behind"
|
|
3946
|
-
else: out["state"] = "clean"
|
|
3947
|
-
print(json.dumps(out))
|
|
3948
|
-
'@
|
|
3949
|
-
|
|
3950
|
-
$PyPlanStamp = @'
|
|
3951
|
-
import json, os, re, sys, hashlib, datetime
|
|
3952
|
-
doc = os.environ["AW_DOC"]; fd = os.environ["AW_FD"]
|
|
3953
|
-
ids = os.environ.get("AW_IDS", "").strip(); note = os.environ.get("AW_NOTE", "").strip()
|
|
3954
|
-
no_row = os.environ.get("AW_NOROW", "false") == "true"
|
|
3955
|
-
spec = os.path.join(fd, "spec.md")
|
|
3956
|
-
# One track file per plan document (.aw-plan-track.backend-plan.json /
|
|
3957
|
-
# .aw-plan-track.frontend-plan.json) — parallel lane stamps never contend on a shared file.
|
|
3958
|
-
stem = os.path.splitext(os.path.basename(doc))[0]
|
|
3959
|
-
track_path = os.path.join(fd, ".aw-plan-track.%s.json" % stem)
|
|
3960
|
-
def vtuple(v):
|
|
3961
|
-
try: return tuple(int(x) for x in v.lstrip("vV").split("."))
|
|
3962
|
-
except Exception: return (0,)
|
|
3963
|
-
if not os.path.isfile(doc):
|
|
3964
|
-
print(json.dumps({"ok": False, "error": "plan document %s does not exist" % doc, "version": ""})); sys.exit(0)
|
|
3965
|
-
cur = ""
|
|
3966
|
-
if os.path.isfile(spec):
|
|
3967
|
-
st = open(spec, encoding="utf-8", errors="replace").read()
|
|
3968
|
-
m = re.search(r'^##\s+Change History\s*$', st, re.M)
|
|
3969
|
-
rows = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', st[m.end():] if m else "", re.M)
|
|
3970
|
-
hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', st, re.M)
|
|
3971
|
-
cands = rows + ([hv.group(1)] if hv else [])
|
|
3972
|
-
if cands: cur = max(cands, key=vtuple)
|
|
3973
|
-
text = open(doc, encoding="utf-8", errors="replace").read()
|
|
3974
|
-
# The ledger-survival fence: a plan whose update dropped the Change History section is
|
|
3975
|
-
# NOT baselined — a lost ledger silently baselined over could never be noticed again.
|
|
3976
|
-
hist = re.search(r'^##+\s*(?:\d+\.\s*)?Change History\s*$', text, re.M)
|
|
3977
|
-
if not hist:
|
|
3978
|
-
print(json.dumps({"ok": False, "version": cur,
|
|
3979
|
-
"error": "the plan document has no 'Change History' section — the write dropped the ledger; nothing was baselined"}))
|
|
3980
|
-
sys.exit(0)
|
|
3981
|
-
warnings = []
|
|
3982
|
-
if not re.search(r'^##+.*Assumptions', text, re.M):
|
|
3983
|
-
warnings.append("no 'Assumptions' section found")
|
|
3984
|
-
if not re.search(r'^##+.*(Workarounds|Deviations)', text, re.M):
|
|
3985
|
-
warnings.append("no 'Workarounds/Deviations' section found")
|
|
3986
|
-
row_txt = ""
|
|
3987
|
-
if not no_row:
|
|
3988
|
-
# Bounded to the Change History section; the row's cell count follows the section's
|
|
3989
|
-
# own table so the append fits the greenfield, UI and onboard-generated shapes alike.
|
|
3990
|
-
lines = text.split("\n")
|
|
3991
|
-
hline = text[:hist.start()].count("\n")
|
|
3992
|
-
end = len(lines)
|
|
3993
|
-
for i in range(hline + 1, len(lines)):
|
|
3994
|
-
if re.match(r'^##', lines[i]): end = i; break
|
|
3995
|
-
tbl = [i for i in range(hline + 1, end) if lines[i].lstrip().startswith("|")]
|
|
3996
|
-
ncells = 5
|
|
3997
|
-
if tbl: ncells = max(3, lines[tbl[0]].strip().strip("|").count("|") + 1)
|
|
3998
|
-
change = note or ("Conformed to spec %s" % (cur or "(unversioned)"))
|
|
3999
|
-
if ids: change = "%s — %s" % (change, ids)
|
|
4000
|
-
change = change.replace("|", "/").replace("\n", " ")
|
|
4001
|
-
cells = [cur or "v?", datetime.date.today().isoformat(), change] + ["—"] * (ncells - 3)
|
|
4002
|
-
row_txt = "| " + " | ".join(cells[:ncells]) + " |"
|
|
4003
|
-
lines.insert((tbl[-1] + 1) if tbl else end, row_txt)
|
|
4004
|
-
text = "\n".join(lines)
|
|
4005
|
-
if cur:
|
|
4006
|
-
text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + cur, text, count=1, flags=re.M)
|
|
4007
|
-
open(doc, "w", encoding="utf-8").write(text)
|
|
4008
|
-
try: track = json.load(open(track_path, encoding="utf-8"))
|
|
4009
|
-
except Exception: track = {}
|
|
4010
|
-
track[os.path.basename(doc)] = {
|
|
4011
|
-
"spec_version": cur,
|
|
4012
|
-
"sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
4013
|
-
"updated": datetime.datetime.now().isoformat(timespec="seconds")}
|
|
4014
|
-
open(track_path, "w", encoding="utf-8").write(json.dumps(track, indent=2) + "\n")
|
|
4015
|
-
print(json.dumps({"ok": True, "version": cur, "row": row_txt, "warnings": warnings}))
|
|
4016
|
-
'@
|
|
4017
|
-
|
|
4018
|
-
function Invoke-PlanCheck {
|
|
4019
|
-
param([string[]]$Rest)
|
|
4020
|
-
$fdArg = ''; $doc = ''
|
|
4021
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--doc','-Doc' } { $doc = $Rest[++$i] } } }
|
|
4022
|
-
if (-not $doc) { Write-Error 'plan-check: --doc required'; exit 1 }
|
|
4023
|
-
$py = Get-Py; if (-not $py) { Write-Error 'plan-check: python3 required'; exit 1 }
|
|
4024
|
-
$repo = Get-RepoRoot
|
|
4025
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4026
|
-
if (-not $fd) { Write-Error 'plan-check: no feature set and no --feature-dir'; exit 1 }
|
|
4027
|
-
$env:AW_DOC = $doc; $env:AW_FD = $fd
|
|
4028
|
-
$PyPlanCheck | & $py -
|
|
4029
|
-
Remove-Item Env:AW_DOC, Env:AW_FD -ErrorAction SilentlyContinue
|
|
4030
|
-
}
|
|
4031
|
-
|
|
4032
|
-
function Invoke-PlanStamp {
|
|
4033
|
-
param([string[]]$Rest)
|
|
4034
|
-
$fdArg = ''; $doc = ''; $ids = ''; $note = ''; $noRow = 'false'
|
|
4035
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--doc','-Doc' } { $doc = $Rest[++$i] } { $_ -in '--ids','-Ids' } { $ids = $Rest[++$i] } { $_ -in '--note','-Note' } { $note = $Rest[++$i] } { $_ -in '--no-row','-NoRow' } { $noRow = 'true' } } }
|
|
4036
|
-
if (-not $doc) { Write-Error 'plan-stamp: --doc required'; exit 1 }
|
|
4037
|
-
$py = Get-Py; if (-not $py) { Write-Error 'plan-stamp: python3 required'; exit 1 }
|
|
4038
|
-
$repo = Get-RepoRoot
|
|
4039
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4040
|
-
if (-not $fd) { Write-Error 'plan-stamp: no feature set and no --feature-dir'; exit 1 }
|
|
4041
|
-
$env:AW_DOC = $doc; $env:AW_FD = $fd; $env:AW_IDS = $ids; $env:AW_NOTE = $note; $env:AW_NOROW = $noRow
|
|
4042
|
-
$PyPlanStamp | & $py -
|
|
4043
|
-
Remove-Item Env:AW_DOC, Env:AW_FD, Env:AW_IDS, Env:AW_NOTE, Env:AW_NOROW -ErrorAction SilentlyContinue
|
|
4044
|
-
}
|
|
4045
|
-
|
|
4046
|
-
# ───────────────────────────────── gate-result ──────────────────────────────
|
|
4047
|
-
# The machine-readable verdict every analyze gate writes beside its markdown
|
|
4048
|
-
# report. CI cannot branch on prose, and a verdict is exactly the kind of dated,
|
|
4049
|
-
# derived record the model must never write — so the script writes it.
|
|
4050
|
-
#
|
|
4051
|
-
# Shared by design: analyze-design writes one per feature, and analyze-slice /
|
|
4052
|
-
# analyze-code will write one per story and per run. The CORE keys are fixed
|
|
4053
|
-
# (gate, status, feature, spec_version, story, gap_count, gap_refs, report,
|
|
4054
|
-
# generated_at) so a pipeline can read any gate's result with one expression;
|
|
4055
|
-
# everything gate-specific arrives through repeatable --field k=v and lands under
|
|
4056
|
-
# "fields", where it cannot destabilise that core.
|
|
4057
|
-
#
|
|
4058
|
-
# NOTE the returned `ok` means THE FILE WAS WRITTEN. The pass/fail verdict is
|
|
4059
|
-
# `status` — binding a workflow gate to `ok` would pass on every run.
|
|
4060
|
-
$PyGateResult = @'
|
|
4061
|
-
import json, os, sys, datetime
|
|
4062
|
-
gate = (os.environ.get("AW_GATE", "") or "").strip() or "gate"
|
|
4063
|
-
fd = os.environ.get("AW_FD", "")
|
|
4064
|
-
report = (os.environ.get("AW_REPORT", "") or "").strip()
|
|
4065
|
-
out = (os.environ.get("AW_OUT", "") or "").strip()
|
|
4066
|
-
# "pass" only on an explicit affirmative. An absent or unreadable verdict is a FAIL,
|
|
4067
|
-
# because a result that defaults green turns every broken run into a passing one.
|
|
4068
|
-
ok = (os.environ.get("AW_OK", "") or "").strip().lower() in ("true", "1", "yes", "pass", "ok")
|
|
4069
|
-
# Written BESIDE the markdown report and named after it, so a job holding either path
|
|
4070
|
-
# holds the other. --out overrides; with no report named at all it falls back to a
|
|
4071
|
-
# dotfile in the feature directory under the gate's own name.
|
|
4072
|
-
if not out:
|
|
4073
|
-
out = (os.path.splitext(report)[0] + ".json") if report else os.path.join(fd, ".aw-gate-%s.json" % gate)
|
|
4074
|
-
try: gap_count = int((os.environ.get("AW_GAPS", "") or "").strip() or 0)
|
|
4075
|
-
except ValueError: gap_count = 0
|
|
4076
|
-
refs = [r.strip() for r in (os.environ.get("AW_REFS", "") or "").replace(";", ",").split(",") if r.strip()]
|
|
4077
|
-
# A fail reporting zero findings reads as green to anything that sums the number
|
|
4078
|
-
# rather than reading the status, so a red verdict always carries at least one.
|
|
4079
|
-
if not ok and gap_count < 1: gap_count = max(1, len(refs))
|
|
4080
|
-
# --field k=v, repeatable, arriving newline-separated. Keeps the CORE schema below
|
|
4081
|
-
# fixed for every gate while each one carries its own detail underneath.
|
|
4082
|
-
fields = {}
|
|
4083
|
-
for line in (os.environ.get("AW_FIELDS", "") or "").splitlines():
|
|
4084
|
-
k, sep, v = line.partition("=")
|
|
4085
|
-
if sep and k.strip(): fields[k.strip()] = v.strip()
|
|
4086
|
-
res = {"gate": gate,
|
|
4087
|
-
"status": "pass" if ok else "fail",
|
|
4088
|
-
"feature": os.path.basename(fd.rstrip("/\\")),
|
|
4089
|
-
"feature_dir": fd,
|
|
4090
|
-
"spec_version": (os.environ.get("AW_VERSION", "") or "").strip(),
|
|
4091
|
-
"story": (os.environ.get("AW_STORY", "") or "").strip(),
|
|
4092
|
-
"gap_count": gap_count,
|
|
4093
|
-
"gap_refs": refs,
|
|
4094
|
-
"report": report,
|
|
4095
|
-
"note": (os.environ.get("AW_NOTE", "") or "").strip(),
|
|
4096
|
-
"fields": fields,
|
|
4097
|
-
"generated_at": datetime.datetime.now().isoformat(timespec="seconds")}
|
|
4098
|
-
d = os.path.dirname(out)
|
|
4099
|
-
if d and not os.path.isdir(d): os.makedirs(d, exist_ok=True)
|
|
4100
|
-
open(out, "w", encoding="utf-8").write(json.dumps(res, indent=2) + "\n")
|
|
4101
|
-
print(json.dumps({"ok": True, "result_file": out, "status": res["status"], "gap_count": gap_count}))
|
|
4102
|
-
'@
|
|
4103
|
-
|
|
4104
|
-
function Invoke-GateResult {
|
|
4105
|
-
param([string[]]$Rest)
|
|
4106
|
-
$fdArg = ''; $gate = ''; $ok = ''; $version = ''; $story = ''; $report = ''; $out = ''; $gaps = ''; $refs = ''; $note = ''; $fields = @()
|
|
4107
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--gate','-Gate' } { $gate = $Rest[++$i] } { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--ok','-Ok' } { $ok = $Rest[++$i] } { $_ -in '--spec-version','-SpecVersion' } { $version = $Rest[++$i] } { $_ -in '--story','-Story' } { $story = $Rest[++$i] } { $_ -in '--report','-Report' } { $report = $Rest[++$i] } { $_ -in '--out','-Out' } { $out = $Rest[++$i] } { $_ -in '--gap-count','-GapCount' } { $gaps = $Rest[++$i] } { $_ -in '--gap-refs','-GapRefs' } { $refs = $Rest[++$i] } { $_ -in '--note','-Note' } { $note = $Rest[++$i] } { $_ -in '--field','-Field' } { $fields += $Rest[++$i] } } }
|
|
4108
|
-
if (-not $gate) { Write-Error 'gate-result: --gate required'; exit 1 }
|
|
4109
|
-
$py = Get-Py; if (-not $py) { Write-Error 'gate-result: python3 required'; exit 1 }
|
|
4110
|
-
$repo = Get-RepoRoot
|
|
4111
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4112
|
-
if (-not $fd) { Write-Error 'gate-result: no feature set and no --feature-dir'; exit 1 }
|
|
4113
|
-
$env:AW_GATE = $gate; $env:AW_FD = $fd; $env:AW_OK = $ok; $env:AW_VERSION = $version; $env:AW_STORY = $story
|
|
4114
|
-
$env:AW_REPORT = $report; $env:AW_OUT = $out; $env:AW_GAPS = $gaps; $env:AW_REFS = $refs; $env:AW_NOTE = $note
|
|
4115
|
-
$env:AW_FIELDS = ($fields -join "`n")
|
|
4116
|
-
$PyGateResult | & $py -
|
|
4117
|
-
Remove-Item Env:AW_GATE, Env:AW_FD, Env:AW_OK, Env:AW_VERSION, Env:AW_STORY, Env:AW_REPORT, Env:AW_OUT, Env:AW_GAPS, Env:AW_REFS, Env:AW_NOTE, Env:AW_FIELDS -ErrorAction SilentlyContinue
|
|
4118
|
-
}
|
|
4119
|
-
|
|
4120
|
-
# ─────────────── story-plan-check / story-plan-doc / log-append ───────────────
|
|
4121
|
-
# The per-story execution design ("story design", one document per lane) + the
|
|
4122
|
-
# feature's append-only logs. Twins of the bash actions; the python payloads are
|
|
4123
|
-
# byte-identical by contract.
|
|
4124
|
-
$PyStoryPlanCheck = @'
|
|
4125
|
-
import json, os, re, sys
|
|
4126
|
-
fd = os.environ["AW_FD"]
|
|
4127
|
-
sdir = os.path.join(fd, "stories")
|
|
4128
|
-
spec = os.path.join(fd, "spec.md")
|
|
4129
|
-
|
|
4130
|
-
def parse_fm(text):
|
|
4131
|
-
"""Frontmatter as a flat dict — inline and block-list values both fold to the
|
|
4132
|
-
inline shape (same reader story-refs uses, for the same reason)."""
|
|
4133
|
-
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
4134
|
-
if not m: return {}
|
|
4135
|
-
fm, key, block = {}, None, set()
|
|
4136
|
-
for line in m.group(1).split("\n"):
|
|
4137
|
-
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
4138
|
-
if km:
|
|
4139
|
-
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
4140
|
-
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
4141
|
-
if im and (key in block or not fm[key]):
|
|
4142
|
-
block.add(key)
|
|
4143
|
-
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
4144
|
-
return fm
|
|
4145
|
-
|
|
4146
|
-
def vtuple(v):
|
|
4147
|
-
try: return tuple(int(x) for x in v.lstrip("vV").split("."))
|
|
4148
|
-
except Exception: return (0,)
|
|
4149
|
-
|
|
4150
|
-
# ---- the backlog ------------------------------------------------------------
|
|
4151
|
-
stories = []
|
|
4152
|
-
if os.path.isdir(sdir):
|
|
4153
|
-
for name in sorted(os.listdir(sdir)):
|
|
4154
|
-
# stories.md is the GENERATED index beside the stories, not a story
|
|
4155
|
-
if not name.endswith(".md") or name == "stories.md": continue
|
|
4156
|
-
p = os.path.join(sdir, name)
|
|
4157
|
-
if not os.path.isfile(p): continue
|
|
4158
|
-
text = open(p, encoding="utf-8", errors="replace").read()
|
|
4159
|
-
fm = parse_fm(text)
|
|
4160
|
-
sid = fm.get("id") or name[:-3]
|
|
4161
|
-
stories.append({"id": sid, "file_id": name[:-3],
|
|
4162
|
-
"status": (fm.get("status") or "open").strip().lower(),
|
|
4163
|
-
"type": (fm.get("type") or "functional").strip().lower(),
|
|
4164
|
-
"title": (fm.get("title") or "").strip().strip("[]"),
|
|
4165
|
-
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
4166
|
-
"cites": re.findall(r'\b(?:FR|BR)-\d+', fm.get("exercises", "")),
|
|
4167
|
-
"path": p, "text": text})
|
|
4168
|
-
open_stories = [s for s in stories if s["status"] == "open"]
|
|
4169
|
-
open_ids = [s["id"] for s in open_stories]
|
|
4170
|
-
digest = "\n".join(" %-16s %s%s" % (s["id"], s["title"] or "(untitled)",
|
|
4171
|
-
(" cites " + ", ".join(s["cites"])) if s["cites"] else "")
|
|
4172
|
-
for s in open_stories) or " (no open stories)"
|
|
4173
|
-
|
|
4174
|
-
# ---- which story ------------------------------------------------------------
|
|
4175
|
-
want = (os.environ.get("AW_STORY") or "").strip()
|
|
4176
|
-
free = os.environ.get("AW_INPUT") or ""
|
|
4177
|
-
target, reason = None, ""
|
|
4178
|
-
if want:
|
|
4179
|
-
hit = [s for s in stories if s["id"] == want or s["file_id"] == want]
|
|
4180
|
-
target = hit[0] if hit else None
|
|
4181
|
-
if not target: reason = "not-found"
|
|
4182
|
-
else:
|
|
4183
|
-
# every known story id appearing as a whole token in the free text; one match
|
|
4184
|
-
# resolves, several is a question, none is a question
|
|
4185
|
-
named = [s for s in stories
|
|
4186
|
-
if re.search(r'(?<![\w-])' + re.escape(s["id"]) + r'(?![\w-])', free)]
|
|
4187
|
-
if len(named) == 1: target = named[0]
|
|
4188
|
-
else: reason = "ambiguous" if named else "none-given"
|
|
4189
|
-
|
|
4190
|
-
out = {"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
|
|
4191
|
-
"open_count": len(open_stories), "any_open": bool(open_stories),
|
|
4192
|
-
"open_ids": open_ids, "open_ids_csv": ", ".join(open_ids), "digest": digest,
|
|
4193
|
-
"resolved": bool(target), "needs_pick": not target, "pick_reason": reason,
|
|
4194
|
-
"story": "", "story_path": "", "story_status": "", "story_open": False,
|
|
4195
|
-
"story_type": "", "is_technical": False,
|
|
4196
|
-
"status_msg": "", "story_title": "", "exercises_csv": "", "constraints_csv": "",
|
|
4197
|
-
"story_spec_version": "", "current_version": "", "stale_csv": "", "stale_note": "",
|
|
4198
|
-
"story_dir": "", "if_tmp": "", "legacy_doc": "", "legacy_doc_exists": False,
|
|
4199
|
-
"be_doc_path": "", "be_doc_exists": False, "be_next_pass": 1, "be_pass_tmp": "",
|
|
4200
|
-
"be_last_pass": 0, "be_doc_spec_version": "", "be_doc_stale": False,
|
|
4201
|
-
"fe_doc_path": "", "fe_doc_exists": False, "fe_next_pass": 1, "fe_pass_tmp": "",
|
|
4202
|
-
"fe_last_pass": 0, "fe_doc_spec_version": "", "fe_doc_stale": False,
|
|
4203
|
-
"adr_path": os.path.join(fd, "adr.md"), "adr_exists": os.path.isfile(os.path.join(fd, "adr.md")),
|
|
4204
|
-
"if_path": os.path.join(fd, "interfaces.md"), "if_exists": os.path.isfile(os.path.join(fd, "interfaces.md")),
|
|
4205
|
-
"spec_exists": os.path.isfile(spec)}
|
|
4206
|
-
|
|
4207
|
-
# ---- the ledger (widest-wins current version, same rule ledger-append uses) --
|
|
4208
|
-
rows, cur = [], ""
|
|
4209
|
-
if os.path.isfile(spec):
|
|
4210
|
-
stext = open(spec, encoding="utf-8", errors="replace").read()
|
|
4211
|
-
m = re.search(r'^##\s+Change History\s*$', stext, re.M)
|
|
4212
|
-
if m:
|
|
4213
|
-
for line in stext[m.end():].splitlines():
|
|
4214
|
-
rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
|
|
4215
|
-
if rm: rows.append((rm.group(1), line))
|
|
4216
|
-
hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', stext, re.M)
|
|
4217
|
-
cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
|
|
4218
|
-
if cands: cur = max(cands, key=vtuple)
|
|
4219
|
-
out["current_version"] = cur
|
|
4220
|
-
|
|
4221
|
-
if not target:
|
|
4222
|
-
print(json.dumps(out)); sys.exit(0)
|
|
4223
|
-
|
|
4224
|
-
# ---- the resolved story -----------------------------------------------------
|
|
4225
|
-
status = target["status"]
|
|
4226
|
-
out.update(story=target["id"], story_path=target["path"], story_status=status,
|
|
4227
|
-
story_open=(status == "open"), story_title=target["title"],
|
|
4228
|
-
story_type=target["type"], is_technical=(target["type"] == "technical"),
|
|
4229
|
-
exercises_csv=", ".join(target["cites"]),
|
|
4230
|
-
story_spec_version=target["spec_version"])
|
|
4231
|
-
if status != "open":
|
|
4232
|
-
verb = {"closed": "was closed — its plan is history. Remaining work needs its own story "
|
|
4233
|
-
"(sync-stories mints a follow-on when the spec moves)",
|
|
4234
|
-
"cancelled": "was cancelled — cancelled work is not planned; open a new story if the need returns",
|
|
4235
|
-
"draft": "is a draft proposal — run /appweaver sync-spec to merge it into the contract first; "
|
|
4236
|
-
"story-plan designs only against merged requirements"}
|
|
4237
|
-
out["status_msg"] = "Story %s %s." % (
|
|
4238
|
-
target["id"], verb.get(status, "has status '%s', not open" % status))
|
|
4239
|
-
|
|
4240
|
-
# bound constraint ids come from the story BODY's ## Constraints section (they are
|
|
4241
|
-
# barred from exercises: by design — met, not delivered)
|
|
4242
|
-
cm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', target["text"], re.M | re.S)
|
|
4243
|
-
cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', cm.group(1)))) if cm else []
|
|
4244
|
-
out["constraints_csv"] = ", ".join(cons)
|
|
4245
|
-
|
|
4246
|
-
# staleness: ledger rows ABOVE the story's stamp naming an id the story carries —
|
|
4247
|
-
# exercises: AND the Constraints section, so a moved bound is announced too
|
|
4248
|
-
sv = target["spec_version"]
|
|
4249
|
-
if sv:
|
|
4250
|
-
carried = set(target["cites"]) | set(cons)
|
|
4251
|
-
moved = [r for r in rows if vtuple(r[0]) > vtuple(sv)]
|
|
4252
|
-
stale = sorted({i for _v, l in moved for i in re.findall(r'[A-Z]{2}-\d+', l) if i in carried})
|
|
4253
|
-
out["stale_csv"] = ", ".join(stale)
|
|
4254
|
-
if stale:
|
|
4255
|
-
out["stale_note"] = (
|
|
4256
|
-
"CAUTION: the spec moved above this story's stamp (%s -> %s) on ids this story "
|
|
4257
|
-
"carries: %s. Design against the CURRENT spec text, record the divergence under "
|
|
4258
|
-
"Risks, and consider /appweaver sync-stories to bring the backlog back in step."
|
|
4259
|
-
% (sv, cur or "?", ", ".join(stale)))
|
|
4260
|
-
|
|
4261
|
-
# doc / pass state + the temp paths the workflow writes through. ONE DOCUMENT PER
|
|
4262
|
-
# LANE: the service design and the UI design are authored by different steps, under
|
|
4263
|
-
# different rule sets, from different code, and are read by different implement
|
|
4264
|
-
# commands — folded into one file, every one of those becomes a section boundary the
|
|
4265
|
-
# next reader has to trust. Pass numbers are per file, so a run that designs only the
|
|
4266
|
-
# backend leaves the frontend's count exactly where it was.
|
|
4267
|
-
#
|
|
4268
|
-
# legacy_doc is the pre-split story-plan.md. It is REPORTED, never read and never
|
|
4269
|
-
# written: a design nobody consumes must not sit silently beside two that are.
|
|
4270
|
-
stdir = os.path.join(sdir, target["id"])
|
|
4271
|
-
legacy = os.path.join(stdir, "story-plan.md")
|
|
4272
|
-
out.update(story_dir=stdir, if_tmp=os.path.join(stdir, ".if-entries.tmp.md"),
|
|
4273
|
-
legacy_doc=legacy, legacy_doc_exists=os.path.isfile(legacy))
|
|
4274
|
-
for lane, stem in (("be", "backend"), ("fe", "frontend")):
|
|
4275
|
-
doc = os.path.join(stdir, "story-%s-plan.md" % stem)
|
|
4276
|
-
out[lane + "_doc_path"] = doc
|
|
4277
|
-
out[lane + "_doc_exists"] = os.path.isfile(doc)
|
|
4278
|
-
out[lane + "_pass_tmp"] = os.path.join(stdir, ".%s-pass.tmp.md" % stem)
|
|
4279
|
-
if out[lane + "_doc_exists"]:
|
|
4280
|
-
d = open(doc, encoding="utf-8", errors="replace").read()
|
|
4281
|
-
nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', d, re.M)]
|
|
4282
|
-
out[lane + "_next_pass"] = (max(nums) + 1) if nums else 1
|
|
4283
|
-
# What implement needs on top of the path: WHICH pass it builds from (the last
|
|
4284
|
-
# one — earlier passes are superseded history) and the spec version that pass was
|
|
4285
|
-
# designed against. A design behind the current spec is ANNOUNCED at implement,
|
|
4286
|
-
# never refused: the story is legitimately in flight, and the spec may well have
|
|
4287
|
-
# moved on ids this story does not carry. stale_csv is the sharper signal and it
|
|
4288
|
-
# is already computed above.
|
|
4289
|
-
out[lane + "_last_pass"] = max(nums) if nums else 0
|
|
4290
|
-
dv = (parse_fm(d).get("spec_version") or "").strip().strip('"')
|
|
4291
|
-
out[lane + "_doc_spec_version"] = dv
|
|
4292
|
-
out[lane + "_doc_stale"] = bool(dv and cur and vtuple(dv) < vtuple(cur))
|
|
4293
|
-
print(json.dumps(out))
|
|
4294
|
-
'@
|
|
4295
|
-
|
|
4296
|
-
$PyStoryPlanDoc = @'
|
|
4297
|
-
import json, os, re, sys, datetime
|
|
4298
|
-
fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
|
|
4299
|
-
pf = os.environ["AW_PASS"]; ver = (os.environ.get("AW_VER") or "").strip()
|
|
4300
|
-
lane = (os.environ.get("AW_LANE") or "backend").strip().lower()
|
|
4301
|
-
sdir = os.path.join(fd, "stories", story)
|
|
4302
|
-
doc = os.path.join(sdir, "story-%s-plan.md" % lane)
|
|
4303
|
-
|
|
4304
|
-
def bail(msg, code=2):
|
|
4305
|
-
print(json.dumps({"ok": False, "doc": doc, "error": msg}))
|
|
4306
|
-
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
4307
|
-
|
|
4308
|
-
if lane not in ("backend", "frontend"):
|
|
4309
|
-
bail("story-plan-doc: --lane must be backend or frontend, not '%s'" % lane)
|
|
4310
|
-
if not os.path.isfile(os.path.join(fd, "stories", story + ".md")):
|
|
4311
|
-
bail("story-plan-doc: no story file for '%s' — the plan lives beside a story that exists" % story)
|
|
4312
|
-
if not os.path.isfile(pf):
|
|
4313
|
-
bail("story-plan-doc: pass file missing at %s — the authoring step wrote nothing" % pf)
|
|
4314
|
-
content = open(pf, encoding="utf-8", errors="replace").read().strip()
|
|
4315
|
-
if not content:
|
|
4316
|
-
bail("story-plan-doc: the authored pass is empty — nothing appended")
|
|
4317
|
-
# the pass header is SCRIPT-written; strip one the model wrote anyway rather than
|
|
4318
|
-
# letting the doc double-number
|
|
4319
|
-
content = re.sub(r'^##\s+Design pass\b[^\n]*\n+', '', content, count=1)
|
|
4320
|
-
|
|
4321
|
-
LANE_WORD = "SERVICE" if lane == "backend" else "UI"
|
|
4322
|
-
LANE_READER = "/appweaver implement" if lane == "backend" else "/appweaver implement-ui"
|
|
4323
|
-
os.makedirs(sdir, exist_ok=True)
|
|
4324
|
-
today = datetime.date.today().isoformat()
|
|
4325
|
-
created = not os.path.isfile(doc)
|
|
4326
|
-
if created:
|
|
4327
|
-
open(doc, "w", encoding="utf-8").write("""---
|
|
4328
|
-
created: %s
|
|
4329
|
-
updated: %s
|
|
4330
|
-
passes: 0
|
|
4331
|
-
spec_version: ""
|
|
4332
|
-
---
|
|
4333
|
-
|
|
4334
|
-
<!--
|
|
4335
|
-
STORY DESIGN (%s LANE) — the ephemeral execution design for ONE story, read by
|
|
4336
|
-
%s.
|
|
4337
|
-
|
|
4338
|
-
Identity is positional: the file lives in the story's directory and carries no
|
|
4339
|
-
story id in its text. Every /appweaver story-plan run APPENDS a "## Design pass N"
|
|
4340
|
-
section to THIS lane's file — nothing is edited in place, so a manual edit to an
|
|
4341
|
-
earlier pass survives a re-run. THE LATEST PASS WINS; earlier passes are the
|
|
4342
|
-
iteration history.
|
|
4343
|
-
|
|
4344
|
-
EDIT IT BEFORE YOU IMPLEMENT. This is the document a design is corrected in by
|
|
4345
|
-
hand: change the latest pass and implement builds what you changed. Re-running
|
|
4346
|
-
story-plan instead appends a fresh pass that supersedes it, so hand-edit to
|
|
4347
|
-
adjust, re-run to redesign.
|
|
4348
|
-
|
|
4349
|
-
It is scaffolding for confirming the implementation strategy — archived with the
|
|
4350
|
-
story at close, never maintained afterwards. Durable decisions do not live here:
|
|
4351
|
-
the latest pass PROPOSES ADRs (ratified into the feature's adr.md at close), and
|
|
4352
|
-
published-surface changes are appended to the feature's interfaces.md as proposed
|
|
4353
|
-
entries at design time. spec_version below is the spec the LATEST pass was
|
|
4354
|
-
designed against. The other lane's design, where the project has one, sits beside
|
|
4355
|
-
this file — the two are written apart on purpose and neither restates the other.
|
|
4356
|
-
-->
|
|
4357
|
-
""" % (today, today, LANE_WORD, LANE_READER))
|
|
4358
|
-
text = open(doc, encoding="utf-8", errors="replace").read()
|
|
4359
|
-
nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', text, re.M)]
|
|
4360
|
-
n = (max(nums) + 1) if nums else 1
|
|
4361
|
-
header = "## Design pass %d — %s%s" % (n, today, (" (spec %s)" % ver) if ver else "")
|
|
4362
|
-
text = text.rstrip("\n") + "\n\n" + header + "\n\n" + content + "\n"
|
|
4363
|
-
# frontmatter stamps — first match only, which is the frontmatter by position
|
|
4364
|
-
text = re.sub(r'^(updated:).*$', r'\g<1> ' + today, text, count=1, flags=re.M)
|
|
4365
|
-
text = re.sub(r'^(passes:).*$', r'\g<1> %d' % n, text, count=1, flags=re.M)
|
|
4366
|
-
if ver:
|
|
4367
|
-
text = re.sub(r'^(spec_version:).*$', r'\g<1> %s' % ver, text, count=1, flags=re.M)
|
|
4368
|
-
open(doc, "w", encoding="utf-8").write(text)
|
|
4369
|
-
os.remove(pf)
|
|
4370
|
-
print(json.dumps({"ok": True, "doc": doc, "lane": lane, "pass": n, "created": created,
|
|
4371
|
-
"spec_version": ver, "story_dir": sdir}))
|
|
4372
|
-
'@
|
|
4373
|
-
|
|
4374
|
-
$PyLogAppend = @'
|
|
4375
|
-
import json, os, re, sys, datetime
|
|
4376
|
-
fd = os.environ["AW_FD"]; log = os.environ["AW_LOG"]
|
|
4377
|
-
ef = os.environ["AW_ENTRIES"]; status = (os.environ.get("AW_STATUS") or "").strip()
|
|
4378
|
-
fname, prefix, default_status, title = {
|
|
4379
|
-
"interfaces": ("interfaces.md", "IF", "proposed", "Interface Log"),
|
|
4380
|
-
"adr": ("adr.md", "ADR", "accepted", "Architecture Decision Log")}[log]
|
|
4381
|
-
status = status or default_status
|
|
4382
|
-
path = os.path.join(fd, fname)
|
|
4383
|
-
|
|
4384
|
-
def bail(msg, code=2):
|
|
4385
|
-
print(json.dumps({"ok": False, "log": path, "error": msg}))
|
|
4386
|
-
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
4387
|
-
|
|
4388
|
-
if not os.path.isfile(ef):
|
|
4389
|
-
bail("log-append: entries file missing at %s — the authoring step wrote nothing" % ef)
|
|
4390
|
-
raw = open(ef, encoding="utf-8", errors="replace").read().strip()
|
|
4391
|
-
if not raw or raw.lower().rstrip(".") == "none":
|
|
4392
|
-
os.remove(ef)
|
|
4393
|
-
print(json.dumps({"ok": True, "log": path, "appended": 0, "ids_csv": "",
|
|
4394
|
-
"unknown_ids_csv": "", "bad_supersedes_csv": "", "none": True}))
|
|
4395
|
-
sys.exit(0)
|
|
4396
|
-
|
|
4397
|
-
# every entry must arrive as '## <PREFIX>-NEW — <title>': the script is the only id
|
|
4398
|
-
# authority, so a model-numbered entry is refused rather than trusted
|
|
4399
|
-
blocks = [b.strip() for b in re.split(r'(?=^##\s)', raw, flags=re.M) if b.strip()]
|
|
4400
|
-
bad = [b.splitlines()[0] for b in blocks
|
|
4401
|
-
if not re.match(r'^##\s+%s-NEW\s+[—-]+\s+\S' % prefix, b)]
|
|
4402
|
-
if bad:
|
|
4403
|
-
bail("log-append: every entry must start '## %s-NEW — <title>' (the script assigns "
|
|
4404
|
-
"ids); got: %s" % (prefix, " | ".join(bad[:3])))
|
|
4405
|
-
|
|
4406
|
-
SCAFFOLD = {
|
|
4407
|
-
"interfaces": """<!--
|
|
4408
|
-
INTERFACE LOG — this feature's published-surface contract. APPEND-ONLY.
|
|
4409
|
-
|
|
4410
|
-
WHO WRITES: /appweaver plan publishes this feature's surface whenever it authors or
|
|
4411
|
-
conforms backend-plan.md; a BE story plan appends an entry only where its slice
|
|
4412
|
-
changes that surface. Both land as status: proposed, and story close verifies each
|
|
4413
|
-
against the as-built and flips it to live.
|
|
4414
|
-
WHO READS: /appweaver plan-ui and the FE story designs — this log is the BE-to-FE
|
|
4415
|
-
contract anchor, and the plan documents CITE these ids rather than restating them.
|
|
4416
|
-
The current surface per interface is the LATEST entry — one that names an earlier
|
|
4417
|
-
id in `supersedes:` replaces it. Never edit an existing entry; publish a
|
|
4418
|
-
superseding one. status: proposed -> live -> superseded | retired.
|
|
4419
|
-
`serves:` cites the FR-/BR- ids the interface exists for, so a spec change to
|
|
4420
|
-
those ids flags the contract for review.
|
|
4421
|
-
-->
|
|
4422
|
-
|
|
4423
|
-
# Interface Log
|
|
4424
|
-
""",
|
|
4425
|
-
"adr": """<!--
|
|
4426
|
-
ADR LOG — this feature's durable decisions. APPEND-ONLY.
|
|
4427
|
-
|
|
4428
|
-
A story plan PROPOSES ADRs in its latest design pass; they are ratified into this
|
|
4429
|
-
log at story close, or the architect appends directly. Never rewrite an entry —
|
|
4430
|
-
supersede it with a new one naming `supersedes:`. An ADR records ONLY what neither
|
|
4431
|
-
the code nor the rules can say: a deliberate choice among rule-compliant
|
|
4432
|
-
alternatives, a rejected alternative and why, or a trade-off with its revisit
|
|
4433
|
-
trigger. It never restates what code shows or a rule mandates.
|
|
4434
|
-
-->
|
|
4435
|
-
|
|
4436
|
-
# Architecture Decision Log
|
|
4437
|
-
"""}
|
|
4438
|
-
created = not os.path.isfile(path)
|
|
4439
|
-
if created:
|
|
4440
|
-
open(path, "w", encoding="utf-8").write(SCAFFOLD[log])
|
|
4441
|
-
text = open(path, encoding="utf-8", errors="replace").read()
|
|
4442
|
-
existing = set(re.findall(r'^##\s+(%s-\d+)' % prefix, text, re.M))
|
|
4443
|
-
nums = [int(i.split("-")[1]) for i in existing]
|
|
4444
|
-
nxt = (max(nums) + 1) if nums else 1
|
|
4445
|
-
today = datetime.date.today().isoformat()
|
|
4446
|
-
|
|
4447
|
-
spec = os.path.join(fd, "spec.md")
|
|
4448
|
-
known = set(re.findall(r'\b[A-Z]{2}-\d+\b',
|
|
4449
|
-
open(spec, encoding="utf-8", errors="replace").read())) \
|
|
4450
|
-
if os.path.isfile(spec) else set()
|
|
4451
|
-
|
|
4452
|
-
assigned, unknown, badsup, out_blocks = [], [], [], []
|
|
4453
|
-
for b in blocks:
|
|
4454
|
-
bid = "%s-%03d" % (prefix, nxt); nxt += 1
|
|
4455
|
-
lines = b.splitlines()
|
|
4456
|
-
lines[0] = lines[0].replace("%s-NEW" % prefix, bid, 1)
|
|
4457
|
-
body = "\n".join(lines[1:]).strip("\n")
|
|
4458
|
-
# status and date are script-stamped, whatever the model wrote
|
|
4459
|
-
body = re.sub(r'^status:.*\n?', '', body, flags=re.M)
|
|
4460
|
-
body = re.sub(r'^date:.*\n?', '', body, flags=re.M)
|
|
4461
|
-
body = ("status: %s\ndate: %s" % (status, today)) + ("\n" + body if body else "")
|
|
4462
|
-
for line in body.splitlines():
|
|
4463
|
-
if line.startswith("serves:"):
|
|
4464
|
-
unknown += [i for i in re.findall(r'\b[A-Z]{2}-\d+\b', line)
|
|
4465
|
-
if known and i not in known]
|
|
4466
|
-
if line.startswith("supersedes:"):
|
|
4467
|
-
badsup += [i for i in re.findall(r'\b%s-\d+\b' % prefix, line)
|
|
4468
|
-
if i not in existing]
|
|
4469
|
-
out_blocks.append(lines[0] + "\n\n" + body)
|
|
4470
|
-
assigned.append(bid)
|
|
4471
|
-
|
|
4472
|
-
text = text.rstrip("\n") + "\n\n" + "\n\n".join(out_blocks) + "\n"
|
|
4473
|
-
open(path, "w", encoding="utf-8").write(text)
|
|
4474
|
-
os.remove(ef)
|
|
4475
|
-
print(json.dumps({"ok": True, "log": path, "appended": len(assigned),
|
|
4476
|
-
"ids_csv": ", ".join(assigned),
|
|
4477
|
-
"unknown_ids_csv": ", ".join(sorted(set(unknown))),
|
|
4478
|
-
"bad_supersedes_csv": ", ".join(sorted(set(badsup))),
|
|
4479
|
-
"none": False, "created_log": created}))
|
|
4480
|
-
'@
|
|
4481
|
-
|
|
4482
|
-
function Invoke-StoryPlanCheck {
|
|
4483
|
-
param([string[]]$Rest)
|
|
4484
|
-
$fdArg = ''; $inp = ''; $story = ''
|
|
4485
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--input','-InputText' } { $inp = $Rest[++$i] } { $_ -in '--story','-Story' } { $story = $Rest[++$i] } } }
|
|
4486
|
-
$py = Get-Py; if (-not $py) { Write-Error 'story-plan-check: python3 required'; exit 1 }
|
|
4487
|
-
$repo = Get-RepoRoot
|
|
4488
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4489
|
-
if (-not $fd) { Write-Error 'story-plan-check: no feature set and no --feature-dir'; exit 1 }
|
|
4490
|
-
$env:AW_FD = $fd; $env:AW_INPUT = $inp; $env:AW_STORY = $story
|
|
4491
|
-
$PyStoryPlanCheck | & $py -
|
|
4492
|
-
Remove-Item Env:AW_FD, Env:AW_INPUT, Env:AW_STORY -ErrorAction SilentlyContinue
|
|
4493
|
-
}
|
|
4494
|
-
|
|
4495
|
-
function Invoke-StoryPlanDoc {
|
|
4496
|
-
param([string[]]$Rest)
|
|
4497
|
-
$fdArg = ''; $story = ''; $passFile = ''; $specVersion = ''; $lane = 'backend'
|
|
4498
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--story','-Story' } { $story = $Rest[++$i] } { $_ -in '--lane','-Lane' } { $lane = $Rest[++$i] } { $_ -in '--pass-file','-PassFile' } { $passFile = $Rest[++$i] } { $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] } } }
|
|
4499
|
-
if (-not $story) { Write-Error 'story-plan-doc: --story is required'; exit 1 }
|
|
4500
|
-
if (-not $passFile) { Write-Error 'story-plan-doc: --pass-file is required'; exit 1 }
|
|
4501
|
-
$py = Get-Py; if (-not $py) { Write-Error 'story-plan-doc: python3 required'; exit 1 }
|
|
4502
|
-
$repo = Get-RepoRoot
|
|
4503
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4504
|
-
if (-not $fd) { Write-Error 'story-plan-doc: no feature set and no --feature-dir'; exit 1 }
|
|
4505
|
-
$env:AW_FD = $fd; $env:AW_STORY = $story; $env:AW_LANE = $lane; $env:AW_PASS = $passFile; $env:AW_VER = $specVersion
|
|
4506
|
-
$PyStoryPlanDoc | & $py -
|
|
4507
|
-
Remove-Item Env:AW_FD, Env:AW_STORY, Env:AW_LANE, Env:AW_PASS, Env:AW_VER -ErrorAction SilentlyContinue
|
|
4508
|
-
}
|
|
4509
|
-
|
|
4510
|
-
function Invoke-LogAppend {
|
|
4511
|
-
param([string[]]$Rest)
|
|
4512
|
-
$fdArg = ''; $log = ''; $entriesFile = ''; $status = ''
|
|
4513
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--log','-Log' } { $log = $Rest[++$i] } { $_ -in '--entries-file','-EntriesFile' } { $entriesFile = $Rest[++$i] } { $_ -in '--status','-Status' } { $status = $Rest[++$i] } } }
|
|
4514
|
-
if ($log -notin 'adr','interfaces') { Write-Error 'log-append: --log must be adr or interfaces'; exit 1 }
|
|
4515
|
-
if (-not $entriesFile) { Write-Error 'log-append: --entries-file is required'; exit 1 }
|
|
4516
|
-
$py = Get-Py; if (-not $py) { Write-Error 'log-append: python3 required'; exit 1 }
|
|
4517
|
-
$repo = Get-RepoRoot
|
|
4518
|
-
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4519
|
-
if (-not $fd) { Write-Error 'log-append: no feature set and no --feature-dir'; exit 1 }
|
|
4520
|
-
$env:AW_FD = $fd; $env:AW_LOG = $log; $env:AW_ENTRIES = $entriesFile; $env:AW_STATUS = $status
|
|
4521
|
-
$PyLogAppend | & $py -
|
|
4522
|
-
Remove-Item Env:AW_FD, Env:AW_LOG, Env:AW_ENTRIES, Env:AW_STATUS -ErrorAction SilentlyContinue
|
|
2155
|
+
$crId = ''; $fdArg = ''; $lane = 'backend'; $tplName = ''
|
|
2156
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2157
|
+
switch ($Rest[$i]) {
|
|
2158
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
2159
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
2160
|
+
{ $_ -in '--lane','-Lane' } { $lane = $Rest[++$i] }
|
|
2161
|
+
{ $_ -in '--template','-Template' } { $tplName = $Rest[++$i] }
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
2165
|
+
if (-not $crId) { Write-Error 'delta-plan-scaffold: --cr-id is required'; exit 1 }
|
|
2166
|
+
switch ($lane) {
|
|
2167
|
+
'backend' { $docName = 'delta-backend-plan.md'; $legacyName = 'delta-design.md'
|
|
2168
|
+
if (-not $tplName) { $tplName = 'delta-design-template' } }
|
|
2169
|
+
'frontend' { $docName = 'delta-frontend-plan.md'; $legacyName = 'delta-design.ui.md'
|
|
2170
|
+
if (-not $tplName) { $tplName = 'ui-delta-design-template' } }
|
|
2171
|
+
default { Write-Error ("delta-plan-scaffold: unknown --lane '" + $lane + "' (backend|frontend)"); exit 1 }
|
|
2172
|
+
}
|
|
2173
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2174
|
+
if (-not $fd) { Write-Error 'delta-plan-scaffold: no feature set and no --feature-dir'; exit 1 }
|
|
2175
|
+
$crDir = Join-Path $fd "changes/$crId"
|
|
2176
|
+
if (-not (Test-Path $crDir)) { Write-Error ("delta-plan-scaffold: no such open change '" + $crId + "' (looked in " + $crDir + ")"); exit 1 }
|
|
2177
|
+
$target = Join-Path $crDir $docName
|
|
2178
|
+
$created = $false; $migrated = $false
|
|
2179
|
+
if ((-not (Test-Path $target)) -and (Test-Path (Join-Path $crDir $legacyName))) {
|
|
2180
|
+
Move-Item (Join-Path $crDir $legacyName) $target; $migrated = $true
|
|
2181
|
+
}
|
|
2182
|
+
if (-not (Test-Path $target)) {
|
|
2183
|
+
$tpl = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
|
|
2184
|
+
if ($tpl -and (Test-Path $tpl)) { Copy-Item $tpl $target } else { New-Item -ItemType File -Force -Path $target | Out-Null }
|
|
2185
|
+
$created = $true
|
|
2186
|
+
}
|
|
2187
|
+
Set-HeaderStamp -Path $target -Name 'Wave' -Value (Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
|
|
2188
|
+
$overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
|
|
2189
|
+
$templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
|
|
2190
|
+
$tsOut = if ($templateSource) { $templateSource } else { '' }
|
|
2191
|
+
Write-Json ([ordered]@{ cr_id = $crId; cr_dir = $crDir; lane = $lane; delta_plan = $target; created = $created; migrated = $migrated; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
|
|
4523
2192
|
}
|
|
4524
2193
|
|
|
4525
2194
|
# Read one field out of the lane model. Twin of the bash inline python readers: the
|
|
@@ -4787,35 +2456,528 @@ function Invoke-DeployDispatch {
|
|
|
4787
2456
|
Write-Json ([ordered]@{ ok = $ok; configured = $configured; ran = $ran; cmd = $cmd; log_tail = $tail })
|
|
4788
2457
|
}
|
|
4789
2458
|
|
|
2459
|
+
# ────────────── domain model — context-level helpers (bash twins) ───────────
|
|
2460
|
+
# The bounded context that owns a feature's domain model. specs/<Ctx>/<Feat> puts the
|
|
2461
|
+
# model one level up; a flat specs/<Feat> falls back to specs/ as the implicit single
|
|
2462
|
+
# context, so a pre-context repo resolves exactly one model rather than none.
|
|
2463
|
+
function Get-ContextDirForFeature {
|
|
2464
|
+
param([string]$FeatureDir, [string]$Repo)
|
|
2465
|
+
$parent = Split-Path $FeatureDir -Parent
|
|
2466
|
+
$specs = Join-Path $Repo 'specs'
|
|
2467
|
+
if (($parent -eq $specs) -or ($parent -eq $Repo)) { return $specs }
|
|
2468
|
+
return $parent
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
# Four random hex characters, so two changes opened in the same second cannot collide.
|
|
2472
|
+
function Get-RandSuffix {
|
|
2473
|
+
return -join ((1..4) | ForEach-Object { '{0:x}' -f (Get-Random -Minimum 0 -Maximum 16) })
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
# Set or insert a '**<Name>**: <value>' header line. Scripts own ids, dates, versions and
|
|
2477
|
+
# the wave. Idempotent: an existing line is replaced in place, a missing one is inserted
|
|
2478
|
+
# after the title so the header block stays together. Twin of bash _stamp_header.
|
|
2479
|
+
function Set-HeaderStamp {
|
|
2480
|
+
param([string]$Path, [string]$Name, [string]$Value)
|
|
2481
|
+
if (-not (Test-Path $Path)) { return }
|
|
2482
|
+
$text = Get-Content $Path -Raw
|
|
2483
|
+
$line = "**$Name**: $Value"
|
|
2484
|
+
$pat = "(?m)^\*\*$([regex]::Escape($Name))\*\*:.*$"
|
|
2485
|
+
if ($text -match $pat) {
|
|
2486
|
+
$text = [regex]::Replace($text, $pat, { param($m) $line }, 1)
|
|
2487
|
+
} else {
|
|
2488
|
+
$lines = @($text -split "`n")
|
|
2489
|
+
$at = 0
|
|
2490
|
+
for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i].StartsWith('# ')) { $at = $i + 1; break } }
|
|
2491
|
+
$out = @(); $out += $lines[0..$at]; $out += ''; $out += $line
|
|
2492
|
+
if ($at + 1 -lt $lines.Count) { $out += $lines[($at + 1)..($lines.Count - 1)] }
|
|
2493
|
+
$text = ($out -join "`n")
|
|
2494
|
+
}
|
|
2495
|
+
Set-Content -Path $Path -Value $text -Encoding UTF8 -NoNewline
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
# The highest wave among the delta models open in a context, or 1 when none is. This is
|
|
2499
|
+
# the DEFAULT for a new delta: parallel with whatever is in flight, never implicitly
|
|
2500
|
+
# sequenced behind it.
|
|
2501
|
+
# The change id out of whatever the caller passed. A user_select hands back the whole
|
|
2502
|
+
# option LABEL ("CR-… · wave 2 · Add discount tiers"), and a change id never contains
|
|
2503
|
+
# whitespace — so the first token is the id either way. Twin of bash _cr_id_token.
|
|
2504
|
+
function Get-CrIdToken {
|
|
2505
|
+
param([string]$Raw)
|
|
2506
|
+
if (-not $Raw) { return '' }
|
|
2507
|
+
return ($Raw -split '\s+')[0]
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
# One change's wave, read from the documents that carry it: the delta-spec first, then the
|
|
2511
|
+
# model delta. Both are stamped together, so either answers; 1 is the floor for a change
|
|
2512
|
+
# opened before waves existed. Twin of bash _wave_of.
|
|
2513
|
+
function Get-WaveOf {
|
|
2514
|
+
param([string]$FeatureDir, [string]$CrId, [string]$Repo)
|
|
2515
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $FeatureDir -Repo $Repo
|
|
2516
|
+
foreach ($f in @((Join-Path $FeatureDir "changes/$CrId/delta-spec.md"), (Join-Path $ctx "domain-model.$CrId.delta.md"))) {
|
|
2517
|
+
if (-not (Test-Path $f)) { continue }
|
|
2518
|
+
$t = Get-Content $f -Raw -ErrorAction SilentlyContinue
|
|
2519
|
+
if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { return "$([int]$Matches[1])" }
|
|
2520
|
+
}
|
|
2521
|
+
return '1'
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function Get-MaxOpenWave {
|
|
2525
|
+
param([string]$ContextDir)
|
|
2526
|
+
$best = 0
|
|
2527
|
+
foreach ($f in @(Get-ChildItem -Path $ContextDir -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue)) {
|
|
2528
|
+
$t = Get-Content $f.FullName -Raw -ErrorAction SilentlyContinue
|
|
2529
|
+
if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { $w = [int]$Matches[1]; if ($w -gt $best) { $best = $w } }
|
|
2530
|
+
}
|
|
2531
|
+
if ($best -lt 1) { return 1 }
|
|
2532
|
+
return $best
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
# ─────────────────────────── context-paths ─────────────────────────────────
|
|
2536
|
+
# Everything specify needs about the domain model of the context it works in: where the
|
|
2537
|
+
# master is, whether it exists, and EVERY open delta model beside it. The pending list is
|
|
2538
|
+
# the point — specify authors against the master PLUS what other open deltas propose, so
|
|
2539
|
+
# an overlap is designed around rather than discovered at merge time.
|
|
2540
|
+
function Invoke-ContextPaths {
|
|
2541
|
+
param([string[]]$Rest)
|
|
2542
|
+
$fdArg = ''
|
|
2543
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2544
|
+
if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$i] }
|
|
2545
|
+
}
|
|
2546
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2547
|
+
if (-not $fd) { Write-Error 'context-paths: no feature set and no --feature-dir'; exit 1 }
|
|
2548
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
2549
|
+
$ctxRel = ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo
|
|
2550
|
+
# specs/ itself is the implicit single context of a pre-context repo — it has a model
|
|
2551
|
+
# but no context NAME, and saying so is what keeps the flat layout honest.
|
|
2552
|
+
$context = if ($ctxRel -eq 'specs') { '' } else { Split-Path $ctxRel -Leaf }
|
|
2553
|
+
$master = Join-Path $ctx 'domain-model.md'
|
|
2554
|
+
$pending = @(); $files = @(); $waves = @()
|
|
2555
|
+
foreach ($f in @(Get-ChildItem -Path $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
2556
|
+
$cr = $f.Name.Substring('domain-model.'.Length)
|
|
2557
|
+
$cr = $cr.Substring(0, $cr.Length - '.delta.md'.Length)
|
|
2558
|
+
$wave = 1
|
|
2559
|
+
$t = Get-Content $f.FullName -Raw -ErrorAction SilentlyContinue
|
|
2560
|
+
if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { $wave = [int]$Matches[1] }
|
|
2561
|
+
$rel = ConvertTo-RepoRelative -Path $f.FullName -RepoRoot $repo
|
|
2562
|
+
$pending += [ordered]@{ cr_id = $cr; file = $rel; wave = $wave }
|
|
2563
|
+
$files += $rel; $waves += $wave
|
|
2564
|
+
}
|
|
2565
|
+
$maxWave = if ($waves.Count) { ($waves | Measure-Object -Maximum).Maximum } else { 0 }
|
|
2566
|
+
Write-Json ([ordered]@{
|
|
2567
|
+
context = $context
|
|
2568
|
+
context_dir = $ctxRel
|
|
2569
|
+
domain_model = ConvertTo-RepoRelative -Path $master -RepoRoot $repo
|
|
2570
|
+
domain_model_exists = (Test-Path $master)
|
|
2571
|
+
pending = @($pending)
|
|
2572
|
+
pending_files = @($files)
|
|
2573
|
+
pending_count = $pending.Count
|
|
2574
|
+
max_wave = $maxWave
|
|
2575
|
+
# What a new delta opened right now would be stamped with: alongside whatever is
|
|
2576
|
+
# already in flight, never implicitly behind it.
|
|
2577
|
+
next_wave = $(if ($waves.Count) { $maxWave } else { 1 })
|
|
2578
|
+
})
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
# ─────────────────────────── model-scaffold ────────────────────────────────
|
|
2582
|
+
# Copy the domain-model template into the context on the BOOTSTRAP path. Idempotent and
|
|
2583
|
+
# non-destructive: after the first specify the model is amended by delta and merge, never
|
|
2584
|
+
# re-scaffolded.
|
|
2585
|
+
function Invoke-ModelScaffold {
|
|
2586
|
+
param([string[]]$Rest)
|
|
2587
|
+
$fdArg = ''
|
|
2588
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2589
|
+
if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$i] }
|
|
2590
|
+
}
|
|
2591
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2592
|
+
if (-not $fd) { Write-Error 'model-scaffold: no feature set and no --feature-dir'; exit 1 }
|
|
2593
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
2594
|
+
New-Item -ItemType Directory -Force -Path $ctx | Out-Null
|
|
2595
|
+
$model = Join-Path $ctx 'domain-model.md'; $tpl = ''; $created = $false
|
|
2596
|
+
if (-not (Test-Path $model)) {
|
|
2597
|
+
$tpl = Resolve-NamedTemplate -Name 'domain-model-template' -RepoRoot $repo
|
|
2598
|
+
if ($tpl) { Copy-Item $tpl $model } else { New-Item -ItemType File -Force -Path $model | Out-Null }
|
|
2599
|
+
$created = $true
|
|
2600
|
+
}
|
|
2601
|
+
Write-Json ([ordered]@{
|
|
2602
|
+
context_dir = ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo
|
|
2603
|
+
domain_model = ConvertTo-RepoRelative -Path $model -RepoRoot $repo
|
|
2604
|
+
template = "$tpl"
|
|
2605
|
+
created = $created
|
|
2606
|
+
exists = $true
|
|
2607
|
+
})
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
# ─────────────────────────── cr-list ───────────────────────────────────────
|
|
2611
|
+
# The changes currently OPEN on a feature, in wave order. This is what lets a delta stage
|
|
2612
|
+
# ASK which change it is working on instead of inferring one: many may be open at once,
|
|
2613
|
+
# and "the most recently opened" is a guess that is wrong as soon as two people work in
|
|
2614
|
+
# parallel. THE EVIDENCE IS THE DIRECTORY — changes/<cr-id>/ exists while the change is
|
|
2615
|
+
# open and moves to changes/archive/ when it closes; active_version.json is read alongside
|
|
2616
|
+
# and reported as `tracked`, so a disagreement is visible rather than silently resolved.
|
|
2617
|
+
#
|
|
2618
|
+
# TWO SYNTHETIC OPTIONS, off by default. A caller that offers more than "which open
|
|
2619
|
+
# change" asks for them by flag, and gets each one's exact label back so it can test the
|
|
2620
|
+
# selection against it (the engine compares rendered strings; there is no prefix match):
|
|
2621
|
+
# --include-main prepends MAIN — "not a change at all, the feature's own master".
|
|
2622
|
+
# `--preselect main` (or `master`) resolves to it.
|
|
2623
|
+
# --include-new appends NEW — "open a change that has no spec delta". `--preselect
|
|
2624
|
+
# new` resolves to it.
|
|
2625
|
+
# `count` and `has_changes` keep counting REAL open changes, so a caller that gates on
|
|
2626
|
+
# them is unaffected by either flag.
|
|
2627
|
+
function Invoke-CrList {
|
|
2628
|
+
param([string[]]$Rest)
|
|
2629
|
+
$fdArg = ''; $pre = ''; $incMain = $false; $incNew = $false
|
|
2630
|
+
$mainLabel = "main · the feature's own spec — re-plan the master (not a change)"
|
|
2631
|
+
$newLabel = 'new · open a plan-only change — a refactor or architectural decision, no spec delta'
|
|
2632
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2633
|
+
switch ($Rest[$i]) {
|
|
2634
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
2635
|
+
{ $_ -in '--preselect','-Preselect' } { $pre = $Rest[++$i] }
|
|
2636
|
+
{ $_ -in '--include-main','-IncludeMain' } { $incMain = $true }
|
|
2637
|
+
{ $_ -in '--include-new','-IncludeNew' } { $incNew = $true }
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
# The WHOLE argument, kept alongside the token. A cr id is matched on the token
|
|
2641
|
+
# because a selection label starts with one; the synthetic words are matched on the
|
|
2642
|
+
# whole string so that guidance prose like "new caching layer" — which plan passes
|
|
2643
|
+
# here verbatim — cannot silently resolve to the NEW option.
|
|
2644
|
+
$preRaw = "$pre".Trim().ToLower()
|
|
2645
|
+
$pre = Get-CrIdToken -Raw $pre
|
|
2646
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2647
|
+
if (-not $fd) { Write-Error 'cr-list: no feature set and no --feature-dir'; exit 1 }
|
|
2648
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
2649
|
+
$tracked = @(); $active = ''
|
|
2650
|
+
$avp = Join-Path $fd 'active_version.json'
|
|
2651
|
+
if (Test-Path $avp) {
|
|
2652
|
+
try {
|
|
2653
|
+
$av = Get-Content $avp -Raw | ConvertFrom-Json
|
|
2654
|
+
$active = "$($av.active_change)"
|
|
2655
|
+
$tracked = @($av.open_changes | Where-Object { $_ })
|
|
2656
|
+
if (-not $tracked.Count -and $active) { $tracked = @($active) }
|
|
2657
|
+
} catch { }
|
|
2658
|
+
}
|
|
2659
|
+
$rows = @()
|
|
2660
|
+
$changesDir = Join-Path $fd 'changes'
|
|
2661
|
+
if (Test-Path $changesDir) {
|
|
2662
|
+
foreach ($d in @(Get-ChildItem -Path $changesDir -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
2663
|
+
if ($d.Name -eq 'archive') { continue }
|
|
2664
|
+
$dspec = Join-Path $d.FullName 'delta-spec.md'
|
|
2665
|
+
$dmodel = Join-Path $ctx ("domain-model." + $d.Name + ".delta.md")
|
|
2666
|
+
$be = Join-Path $d.FullName 'delta-backend-plan.md'
|
|
2667
|
+
$fe = Join-Path $d.FullName 'delta-frontend-plan.md'
|
|
2668
|
+
$legacy = Join-Path $d.FullName 'delta-design.md'
|
|
2669
|
+
$wave = [int](Get-WaveOf -FeatureDir $fd -CrId $d.Name -Repo $repo)
|
|
2670
|
+
# "# Delta Spec — CR-…: Add discount tiers" -> the part after the id. An
|
|
2671
|
+
# unfilled template still carries the placeholder, reported as such rather
|
|
2672
|
+
# than offered to the user as a title.
|
|
2673
|
+
$title = ''
|
|
2674
|
+
if (Test-Path $dspec) {
|
|
2675
|
+
$first = (Get-Content $dspec -TotalCount 1 -ErrorAction SilentlyContinue)
|
|
2676
|
+
if ("$first" -match '^#\s+.*?:\s*(.+)$') { $t = $Matches[1].Trim(); if ($t -and -not $t.StartsWith('[')) { $title = $t } }
|
|
2677
|
+
}
|
|
2678
|
+
$rows += [ordered]@{
|
|
2679
|
+
cr_id = $d.Name; cr_dir = (ConvertTo-RepoRelative -Path $d.FullName -RepoRoot $repo); wave = $wave
|
|
2680
|
+
title = $title
|
|
2681
|
+
delta_spec = (ConvertTo-RepoRelative -Path $dspec -RepoRoot $repo); delta_spec_exists = (Test-Path $dspec)
|
|
2682
|
+
delta_model = (ConvertTo-RepoRelative -Path $dmodel -RepoRoot $repo); delta_model_exists = (Test-Path $dmodel)
|
|
2683
|
+
delta_backend_plan = (ConvertTo-RepoRelative -Path $be -RepoRoot $repo); delta_backend_plan_exists = (Test-Path $be)
|
|
2684
|
+
delta_frontend_plan = (ConvertTo-RepoRelative -Path $fe -RepoRoot $repo); delta_frontend_plan_exists = (Test-Path $fe)
|
|
2685
|
+
legacy_delta_design = (Test-Path $legacy)
|
|
2686
|
+
tracked = ($tracked -contains $d.Name)
|
|
2687
|
+
active = ($d.Name -eq $active)
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
$rows = @($rows | Sort-Object @{ Expression = { $_.wave } }, @{ Expression = { $_.cr_id } })
|
|
2692
|
+
$crOpts = @(); foreach ($r in $rows) {
|
|
2693
|
+
$bits = @($r.cr_id, ("wave " + $r.wave))
|
|
2694
|
+
if ($r.title) { $bits += $r.title } else { $bits += '(delta-spec not authored yet)' }
|
|
2695
|
+
$planned = @(); if ($r.delta_backend_plan_exists) { $planned += 'backend' }; if ($r.delta_frontend_plan_exists) { $planned += 'frontend' }
|
|
2696
|
+
if ($planned.Count) { $bits += ('planned: ' + ($planned -join '+')) }
|
|
2697
|
+
$crOpts += ($bits -join ' · ')
|
|
2698
|
+
}
|
|
2699
|
+
$matched = ''
|
|
2700
|
+
if ($pre) {
|
|
2701
|
+
for ($i = 0; $i -lt $rows.Count; $i++) { if ($rows[$i].cr_id -eq $pre) { $matched = $crOpts[$i]; break } }
|
|
2702
|
+
if (-not $matched) {
|
|
2703
|
+
$hits = @(); for ($i = 0; $i -lt $rows.Count; $i++) { if ($rows[$i].cr_id -like "*$pre*") { $hits += $crOpts[$i] } }
|
|
2704
|
+
if ($hits.Count -eq 1) { $matched = $hits[0] }
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
# The synthetic options bracket the real ones: MAIN first (it is the default reading
|
|
2708
|
+
# of "plan this feature"), NEW last (it CREATES something, so it sits away from the
|
|
2709
|
+
# choices that only select). Their words are also accepted as a --preselect, which is
|
|
2710
|
+
# how a bare `plan main` on the command line skips the prompt.
|
|
2711
|
+
$opts = @()
|
|
2712
|
+
if ($incMain) { $opts += $mainLabel }
|
|
2713
|
+
$opts += $crOpts
|
|
2714
|
+
if ($incNew) { $opts += $newLabel }
|
|
2715
|
+
if (-not $matched -and $preRaw) {
|
|
2716
|
+
if ($incMain -and $preRaw -in @('main','master')) { $matched = $mainLabel }
|
|
2717
|
+
elseif ($incNew -and $preRaw -eq 'new') { $matched = $newLabel }
|
|
2718
|
+
}
|
|
2719
|
+
Write-Json ([ordered]@{
|
|
2720
|
+
changes = @($rows); ids = @($rows | ForEach-Object { $_.cr_id }); count = $rows.Count
|
|
2721
|
+
select_options = @($opts); matched_label = $matched
|
|
2722
|
+
main_label = $(if ($incMain) { $mainLabel } else { '' })
|
|
2723
|
+
new_label = $(if ($incNew) { $newLabel } else { '' })
|
|
2724
|
+
active_change = $active; tracked = @($tracked)
|
|
2725
|
+
has_changes = ($rows.Count -gt 0)
|
|
2726
|
+
only_id = $(if ($rows.Count -eq 1) { $rows[0].cr_id } else { '' })
|
|
2727
|
+
})
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
# ─────────────────────────── cr-paths ──────────────────────────────────────
|
|
2731
|
+
# Everything one change needs to be PLANNED: its own documents, its wave, and the model
|
|
2732
|
+
# deltas that come BEFORE it. PREDECESSORS ARE THE LOWER WAVES — a wave is a dependency
|
|
2733
|
+
# level, so the deltas a plan may build on are exactly the open ones committed to landing
|
|
2734
|
+
# first. Same-wave deltas are reported as siblings: visible, so an overlap is designed
|
|
2735
|
+
# around, never assumed, because nothing orders them against each other.
|
|
2736
|
+
function Invoke-CrPaths {
|
|
2737
|
+
param([string[]]$Rest)
|
|
2738
|
+
$crId = ''; $fdArg = ''
|
|
2739
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2740
|
+
switch ($Rest[$i]) {
|
|
2741
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
2742
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
2746
|
+
if (-not $crId) { Write-Error 'cr-paths: --cr-id is required'; exit 1 }
|
|
2747
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2748
|
+
if (-not $fd) { Write-Error 'cr-paths: no feature set and no --feature-dir'; exit 1 }
|
|
2749
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
2750
|
+
$crDir = Join-Path $fd "changes/$crId"
|
|
2751
|
+
$dspec = Join-Path $crDir 'delta-spec.md'
|
|
2752
|
+
$dmodel = Join-Path $ctx "domain-model.$crId.delta.md"
|
|
2753
|
+
$be = Join-Path $crDir 'delta-backend-plan.md'
|
|
2754
|
+
$fe = Join-Path $crDir 'delta-frontend-plan.md'
|
|
2755
|
+
$master = Join-Path $ctx 'domain-model.md'
|
|
2756
|
+
$wave = [int](Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
|
|
2757
|
+
$pred = @(); $sib = @(); $predRows = @(); $sibRows = @(); $predSpecs = @()
|
|
2758
|
+
foreach ($f in @(Get-ChildItem -Path $ctx -Filter 'domain-model.*.delta.md' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
2759
|
+
$other = $f.Name.Substring('domain-model.'.Length)
|
|
2760
|
+
$other = $other.Substring(0, $other.Length - '.delta.md'.Length)
|
|
2761
|
+
if ($other -eq $crId) { continue }
|
|
2762
|
+
$w = 1
|
|
2763
|
+
$t = Get-Content $f.FullName -Raw -ErrorAction SilentlyContinue
|
|
2764
|
+
if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { $w = [int]$Matches[1] }
|
|
2765
|
+
$rel = ConvertTo-RepoRelative -Path $f.FullName -RepoRoot $repo
|
|
2766
|
+
# A change's model delta sits at CONTEXT level and its delta-spec at FEATURE level,
|
|
2767
|
+
# so the spec of another open change is found by walking the context's features
|
|
2768
|
+
# rather than assuming it belongs to this one.
|
|
2769
|
+
$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 }
|
|
2772
|
+
$row = [ordered]@{ cr_id = $other; file = $rel; wave = $w; delta_spec = $sRel }
|
|
2773
|
+
if ($w -lt $wave) {
|
|
2774
|
+
$pred += $rel; $predRows += $row
|
|
2775
|
+
if ($sRel) { $predSpecs += $sRel }
|
|
2776
|
+
} else { $sib += $rel; $sibRows += $row }
|
|
2777
|
+
}
|
|
2778
|
+
Write-Json ([ordered]@{
|
|
2779
|
+
cr_id = $crId; cr_dir = (ConvertTo-RepoRelative -Path $crDir -RepoRoot $repo); exists = (Test-Path $crDir)
|
|
2780
|
+
delta_spec = (ConvertTo-RepoRelative -Path $dspec -RepoRoot $repo); delta_spec_exists = (Test-Path $dspec)
|
|
2781
|
+
delta_model = (ConvertTo-RepoRelative -Path $dmodel -RepoRoot $repo); delta_model_exists = (Test-Path $dmodel)
|
|
2782
|
+
delta_backend_plan = (ConvertTo-RepoRelative -Path $be -RepoRoot $repo); delta_backend_plan_exists = (Test-Path $be)
|
|
2783
|
+
delta_frontend_plan = (ConvertTo-RepoRelative -Path $fe -RepoRoot $repo); delta_frontend_plan_exists = (Test-Path $fe)
|
|
2784
|
+
wave = $wave
|
|
2785
|
+
context_dir = (ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo)
|
|
2786
|
+
domain_model = (ConvertTo-RepoRelative -Path $master -RepoRoot $repo); domain_model_exists = (Test-Path $master)
|
|
2787
|
+
# Lower wave = committed to landing first, so this delta may plan on top of it.
|
|
2788
|
+
predecessor_files = @($pred); predecessors = @($predRows); predecessor_count = $pred.Count
|
|
2789
|
+
# Each predecessor's SPEC half. The model delta says what the domain becomes; the
|
|
2790
|
+
# delta-spec says what behaviour asked for it, and a gate that checks one without
|
|
2791
|
+
# the other cannot tell a realisation of a predecessor from an invention.
|
|
2792
|
+
predecessor_specs = @($predSpecs)
|
|
2793
|
+
# Both halves in one list, for a build_context `context_from:` (which takes one var).
|
|
2794
|
+
predecessor_docs = @($pred + $predSpecs)
|
|
2795
|
+
# Same or higher wave = nothing orders it against this one. Visible, never assumed.
|
|
2796
|
+
sibling_files = @($sib); siblings = @($sibRows); sibling_count = $sib.Count
|
|
2797
|
+
})
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
# ─────────────────────────── cr-overlap ────────────────────────────────────
|
|
2801
|
+
# Which OTHER open changes touch the same aggregates as this one — read off the
|
|
2802
|
+
# `## Aggregate: <Name>` headers of the pending model deltas, which is the merge unit
|
|
2803
|
+
# domain-model.md is organised by. Deterministic on purpose: two deltas collide when they
|
|
2804
|
+
# change the same aggregate, and that is a fact about two files' headings, so a regex
|
|
2805
|
+
# answers it exactly and the same way every run. Early warning, not enforcement — an
|
|
2806
|
+
# overlap is ordinary, and what matters is that the second change is PLANNED knowing about
|
|
2807
|
+
# the first. The relation follows the wave: a LOWER wave is a predecessor (a dependency to
|
|
2808
|
+
# declare), the SAME wave a sibling (a conflict to design around), a HIGHER wave a
|
|
2809
|
+
# successor (it plans on top of this change — information, not an obligation). Twin of
|
|
2810
|
+
# bash cmd_cr_overlap.
|
|
2811
|
+
#
|
|
2812
|
+
# The aggregate names one model delta declares, in the order it declares them. A
|
|
2813
|
+
# placeholder heading left by the template names no aggregate, so an unfilled model delta
|
|
2814
|
+
# reports none rather than colliding with every other change on "<Name>".
|
|
2815
|
+
function Get-DeltaAggregates {
|
|
2816
|
+
param([string]$Text)
|
|
2817
|
+
$out = @()
|
|
2818
|
+
if (-not $Text) { return $out }
|
|
2819
|
+
foreach ($m in [regex]::Matches($Text, '(?m)^##\s*Aggregate:\s*(.+?)\s*$')) {
|
|
2820
|
+
$name = $m.Groups[1].Value.Trim().Trim('`')
|
|
2821
|
+
if (-not $name -or $name.StartsWith('<') -or $name.StartsWith('[')) { continue }
|
|
2822
|
+
if ($out -notcontains $name) { $out += $name }
|
|
2823
|
+
}
|
|
2824
|
+
return $out
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function Invoke-CrOverlap {
|
|
2828
|
+
param([string[]]$Rest)
|
|
2829
|
+
$crId = ''; $fdArg = ''
|
|
2830
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2831
|
+
switch ($Rest[$i]) {
|
|
2832
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
2833
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
2837
|
+
if (-not $crId) { Write-Error 'cr-overlap: --cr-id is required'; exit 1 }
|
|
2838
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2839
|
+
if (-not $fd) { Write-Error 'cr-overlap: no feature set and no --feature-dir'; exit 1 }
|
|
2840
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
2841
|
+
$mineTxt = Get-Content (Join-Path $ctx "domain-model.$crId.delta.md") -Raw -ErrorAction SilentlyContinue
|
|
2842
|
+
$wave = [int](Get-WaveOf -FeatureDir $fd -CrId $crId -Repo $repo)
|
|
2843
|
+
$mine = @(Get-DeltaAggregates -Text $mineTxt)
|
|
2844
|
+
$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)) {
|
|
2846
|
+
$other = $f.Name.Substring('domain-model.'.Length)
|
|
2847
|
+
$other = $other.Substring(0, $other.Length - '.delta.md'.Length)
|
|
2848
|
+
if ($other -eq $crId) { continue }
|
|
2849
|
+
$t = Get-Content $f.FullName -Raw -ErrorAction SilentlyContinue
|
|
2850
|
+
$w = 1
|
|
2851
|
+
if ($t -match '(?m)^\*\*Wave\*\*:\s*(\d+)') { $w = [int]$Matches[1] }
|
|
2852
|
+
$shared = @(Get-DeltaAggregates -Text $t | Where-Object { $mine -contains $_ })
|
|
2853
|
+
if ($shared.Count -eq 0) { continue }
|
|
2854
|
+
if ($w -lt $wave) { $relation = 'predecessor'; $npred++ }
|
|
2855
|
+
elseif ($w -eq $wave) { $relation = 'sibling'; $nsib++ }
|
|
2856
|
+
else { $relation = 'successor'; $nsucc++ }
|
|
2857
|
+
$rows += [ordered]@{
|
|
2858
|
+
cr_id = $other; wave = $w; relation = $relation
|
|
2859
|
+
file = (ConvertTo-RepoRelative -Path $f.FullName -RepoRoot $repo)
|
|
2860
|
+
aggregates = @($shared)
|
|
2861
|
+
}
|
|
2862
|
+
$parts += ('{0} (wave {1}, {2}): {3}' -f $other, $w, $relation, ($shared -join ', '))
|
|
2863
|
+
}
|
|
2864
|
+
Write-Json ([ordered]@{
|
|
2865
|
+
cr_id = $crId; wave = $wave; aggregates = @($mine)
|
|
2866
|
+
overlaps = @($rows); overlap_count = $rows.Count
|
|
2867
|
+
predecessor_overlaps = $npred; sibling_overlaps = $nsib
|
|
2868
|
+
successor_overlaps = $nsucc
|
|
2869
|
+
# One line for a prompt: the model is told the fact, never asked to derive it.
|
|
2870
|
+
overlap_report = $(if ($parts.Count -gt 0) { $parts -join '; ' }
|
|
2871
|
+
else { 'none — no other open change touches these aggregates' })
|
|
2872
|
+
})
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
# ─────────────────────────── cr-activate ───────────────────────────────────
|
|
2876
|
+
# Point active_version.json.active_change at an OPEN change, changing nothing else — no
|
|
2877
|
+
# version bump, no wave restamp, no scaffold. Every delta stage after the plan phase still
|
|
2878
|
+
# resolves "the" change through active_change, so a CHOSEN delta has to reach them or they
|
|
2879
|
+
# would act on a different change than the one just planned.
|
|
2880
|
+
function Invoke-CrActivate {
|
|
2881
|
+
param([string[]]$Rest)
|
|
2882
|
+
$crId = ''; $fdArg = ''
|
|
2883
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2884
|
+
switch ($Rest[$i]) {
|
|
2885
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
2886
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
$crId = Get-CrIdToken -Raw $crId
|
|
2890
|
+
if (-not $crId) { Write-Error 'cr-activate: --cr-id is required'; exit 1 }
|
|
2891
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2892
|
+
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
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
# ─────────────────────────── cr-wave ───────────────────────────────────────
|
|
2897
|
+
# Change a delta's wave after the fact — the ONLY supported way to move one, because the
|
|
2898
|
+
# wave is stamped into two documents that must agree.
|
|
2899
|
+
function Invoke-CrWave {
|
|
2900
|
+
param([string[]]$Rest)
|
|
2901
|
+
$crId = ''; $fdArg = ''; $wave = ''
|
|
2902
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
2903
|
+
switch ($Rest[$i]) {
|
|
2904
|
+
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
2905
|
+
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
2906
|
+
{ $_ -in '--wave','-Wave' } { $wave = $Rest[++$i] }
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
if (-not $crId) { Write-Error 'cr-wave: --cr-id is required'; exit 1 }
|
|
2910
|
+
if ($wave -notmatch '^\d+$') { Write-Error 'cr-wave: --wave must be a positive integer'; exit 1 }
|
|
2911
|
+
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
2912
|
+
if (-not $fd) { Write-Error 'cr-wave: no feature set and no --feature-dir'; exit 1 }
|
|
2913
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
2914
|
+
$dspec = Join-Path $fd "changes/$crId/delta-spec.md"
|
|
2915
|
+
$dmodel = Join-Path $ctx "domain-model.$crId.delta.md"
|
|
2916
|
+
$stamped = 0
|
|
2917
|
+
if (Test-Path $dspec) { Set-HeaderStamp -Path $dspec -Name 'Wave' -Value $wave; $stamped++ }
|
|
2918
|
+
if (Test-Path $dmodel) { Set-HeaderStamp -Path $dmodel -Name 'Wave' -Value $wave; $stamped++ }
|
|
2919
|
+
if ($stamped -eq 0) { Write-Error "cr-wave: no delta documents found for '$crId'"; exit 1 }
|
|
2920
|
+
Write-Json ([ordered]@{ cr_id = $crId; wave = [int]$wave; stamped = $stamped; delta_spec = $dspec; delta_model = $dmodel })
|
|
2921
|
+
}
|
|
2922
|
+
|
|
4790
2923
|
function Invoke-CrSetup {
|
|
4791
2924
|
param([string[]]$Rest)
|
|
4792
|
-
$crId = ''; $fdArg = ''; $title = ''; $force = ''
|
|
2925
|
+
$crId = ''; $fdArg = ''; $title = ''; $force = ''; $wave = ''
|
|
4793
2926
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
4794
2927
|
switch ($Rest[$i]) {
|
|
4795
2928
|
{ $_ -in '--cr-id','-CrId' } { $crId = $Rest[++$i] }
|
|
4796
2929
|
{ $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] }
|
|
4797
2930
|
{ $_ -in '--title','-Title' } { $title = $Rest[++$i] }
|
|
2931
|
+
{ $_ -in '--wave','-Wave' } { $wave = $Rest[++$i] }
|
|
4798
2932
|
{ $_ -in '--force','-Force' } { $force = '1' }
|
|
4799
2933
|
}
|
|
4800
2934
|
}
|
|
4801
|
-
|
|
2935
|
+
# A random suffix, not the bare timestamp: two changes opened in the same second
|
|
2936
|
+
# would otherwise collide on one id, and with parallel deltas that is reachable.
|
|
2937
|
+
if (-not $crId) { $crId = 'CR-' + ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss')) + '-' + (Get-RandSuffix) }
|
|
4802
2938
|
$repo = Get-RepoRoot; $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
4803
2939
|
if (-not $fd) { Write-Error 'cr-setup: no feature set and no --feature-dir'; exit 1 }
|
|
4804
|
-
|
|
4805
|
-
#
|
|
4806
|
-
#
|
|
2940
|
+
|
|
2941
|
+
# The context that owns the domain model — one level up from a nested feature, specs/
|
|
2942
|
+
# itself for a flat one.
|
|
2943
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
2944
|
+
# Wave default = the highest wave already open, so a new delta is parallel with what
|
|
2945
|
+
# is in flight unless it says otherwise.
|
|
2946
|
+
if (-not $wave) { $wave = "$(Get-MaxOpenWave -ContextDir $ctx)" }
|
|
2947
|
+
if ($wave -notmatch '^\d+$') { Write-Error 'cr-setup: --wave must be a positive integer'; exit 1 }
|
|
2948
|
+
if ([int]$wave -lt 1) { $wave = '1' }
|
|
2949
|
+
|
|
2950
|
+
# MANY CHANGES MAY BE OPEN — the single-active guard is gone; open appends to the list.
|
|
4807
2951
|
$avOpen = Invoke-ActiveVersion -FeatureDir $fd -Op 'open' -CrId $crId -Force $force
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
exit 1
|
|
4812
|
-
}
|
|
2952
|
+
$openCount = 1
|
|
2953
|
+
try { $openCount = [int]((($avOpen | ConvertFrom-Json).open_count)) } catch { $openCount = 1 }
|
|
2954
|
+
|
|
4813
2955
|
$crDir = Join-Path $fd "changes/$crId"
|
|
4814
2956
|
New-Item -ItemType Directory -Force -Path $crDir | Out-Null
|
|
4815
|
-
|
|
2957
|
+
# The SPEC half. The change's LANE PLANS are not scaffolded here: cr-setup runs in the
|
|
2958
|
+
# spec phase, which knows nothing about which lanes are declared, and a plan document
|
|
2959
|
+
# created for an undeclared lane would sit empty for the rest of its life. `plan`
|
|
2960
|
+
# scaffolds them per declared lane (delta-plan-scaffold).
|
|
2961
|
+
$dspec = Join-Path $crDir 'delta-spec.md'; $created = $false
|
|
4816
2962
|
if (-not (Test-Path $dspec)) { $t = Resolve-NamedTemplate -Name 'delta-spec-template' -RepoRoot $repo; if ($t) { Copy-Item $t $dspec } else { New-Item -ItemType File -Force -Path $dspec | Out-Null }; $created = $true }
|
|
4817
|
-
|
|
4818
|
-
|
|
2963
|
+
# The model half, BESIDE the master model rather than inside the change directory, so
|
|
2964
|
+
# every other open delta can see it while planning.
|
|
2965
|
+
$dmodel = Join-Path $ctx "domain-model.$crId.delta.md"
|
|
2966
|
+
if (-not (Test-Path $dmodel)) {
|
|
2967
|
+
New-Item -ItemType Directory -Force -Path $ctx | Out-Null
|
|
2968
|
+
$t = Resolve-NamedTemplate -Name 'delta-domain-model-template' -RepoRoot $repo
|
|
2969
|
+
if ($t) { Copy-Item $t $dmodel } else { New-Item -ItemType File -Force -Path $dmodel | Out-Null }
|
|
2970
|
+
$created = $true
|
|
2971
|
+
}
|
|
2972
|
+
# Scripts stamp ids, dates and the wave — the model never writes them.
|
|
2973
|
+
Set-HeaderStamp -Path $dspec -Name 'Wave' -Value $wave
|
|
2974
|
+
Set-HeaderStamp -Path $dmodel -Name 'Wave' -Value $wave
|
|
2975
|
+
|
|
2976
|
+
Write-Json ([ordered]@{
|
|
2977
|
+
cr_id = $crId; cr_dir = $crDir; delta_spec = $dspec
|
|
2978
|
+
delta_model = $dmodel; context_dir = (ConvertTo-RepoRelative -Path $ctx -RepoRoot $repo)
|
|
2979
|
+
wave = [int]$wave; open_count = $openCount; title = $title; created = $created
|
|
2980
|
+
})
|
|
4819
2981
|
}
|
|
4820
2982
|
|
|
4821
2983
|
function Invoke-CrArchive {
|
|
@@ -4837,8 +2999,20 @@ function Invoke-CrArchive {
|
|
|
4837
2999
|
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
|
|
4838
3000
|
Move-Item $crDir $dest; $archived = $dest
|
|
4839
3001
|
}
|
|
4840
|
-
#
|
|
4841
|
-
|
|
3002
|
+
# The delta's model half lives beside the context's master model, not inside the
|
|
3003
|
+
# change directory, so archiving the directory does not carry it along. Move it into
|
|
3004
|
+
# the same archive: its changes are in domain-model.md by now, and leaving the pending
|
|
3005
|
+
# file behind would keep showing a landed change as in-flight to every other delta.
|
|
3006
|
+
$ctx = Get-ContextDirForFeature -FeatureDir $fd -Repo $repo
|
|
3007
|
+
$dmodel = Join-Path $ctx "domain-model.$crId.delta.md"
|
|
3008
|
+
if (Test-Path $dmodel) {
|
|
3009
|
+
$destDir = Join-Path $archRoot $crId
|
|
3010
|
+
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
|
3011
|
+
Move-Item -Force $dmodel (Join-Path $destDir 'domain-model.delta.md')
|
|
3012
|
+
}
|
|
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
|
|
4842
3016
|
Write-Json ([ordered]@{ ok = $true; archived_dir = $archived; version = $av.version; previous = $av.previous; closed_change = $av.closed_change })
|
|
4843
3017
|
}
|
|
4844
3018
|
|
|
@@ -4868,10 +3042,13 @@ function Invoke-VersionStamp {
|
|
|
4868
3042
|
[string[]]$stamped = @(); [string[]]$unchanged = @(); [string[]]$missing = @(); [string[]]$noHeader = @()
|
|
4869
3043
|
# First `**Version**: <value>` line only — that is the header. The Change History
|
|
4870
3044
|
# table's `| Version |` column head does not match this pattern.
|
|
4871
|
-
#
|
|
3045
|
+
# Each declared lane's plan joins the spine beside the spec: the masters move in
|
|
4872
3046
|
# LOCKSTEP, so a document left behind at the old number is the one lie the version
|
|
4873
|
-
# spine cannot tolerate. A feature
|
|
4874
|
-
|
|
3047
|
+
# spine cannot tolerate. A lane the feature does not have reports `missing` and nothing
|
|
3048
|
+
# fails, which is also how the legacy names are carried — a feature not yet migrated to
|
|
3049
|
+
# the plan names still gets stamped, and one that has been migrated reports the legacy
|
|
3050
|
+
# pair missing.
|
|
3051
|
+
foreach ($name in @('spec.md', 'backend-plan.md', 'frontend-plan.md', 'design.md', 'design.ui.md')) {
|
|
4875
3052
|
$p = Join-Path $fd $name
|
|
4876
3053
|
if (-not (Test-Path $p)) { $missing += $name; continue }
|
|
4877
3054
|
$src = Get-Content -Raw -Encoding UTF8 $p
|
|
@@ -4906,39 +3083,32 @@ switch ($sub) {
|
|
|
4906
3083
|
'lane-resolve' { Invoke-LaneResolve -Rest $rest }
|
|
4907
3084
|
'lane-verify' { Invoke-LaneVerify -Rest $rest }
|
|
4908
3085
|
'lane-probe' { Invoke-LaneProbe -Rest $rest }
|
|
3086
|
+
'list-contexts' { Invoke-ListContexts -Rest $rest }
|
|
3087
|
+
'set-context' { Invoke-SetContext -Rest $rest }
|
|
4909
3088
|
'list-features' { Invoke-ListFeatures -Rest $rest }
|
|
4910
3089
|
'set-feature' { Invoke-SetFeature -Rest $rest }
|
|
4911
3090
|
'version-read' { Invoke-VersionState 'read' $rest }
|
|
4912
3091
|
'version-init' { Invoke-VersionState 'init' $rest }
|
|
4913
|
-
'
|
|
4914
|
-
'spec-stamp' { Invoke-SpecLedger 'stamp' $rest }
|
|
4915
|
-
'ledger-append' { Invoke-SpecLedger 'append' $rest }
|
|
4916
|
-
'ledger-ids' { Invoke-SpecLedger 'rowids' $rest }
|
|
4917
|
-
'spec-snapshot' { Invoke-SpecSnapshot -Rest $rest }
|
|
4918
|
-
'spec-diff' { Invoke-SpecDiff -Rest $rest }
|
|
4919
|
-
'story-refs' { Invoke-StoryRefs -Rest $rest }
|
|
4920
|
-
'slice-mode' { Invoke-SliceMode -Rest $rest }
|
|
4921
|
-
'backlog-plan' { Invoke-BacklogPlan -Rest $rest }
|
|
4922
|
-
'story-stamp' { Invoke-StoryStamp -Rest $rest }
|
|
4923
|
-
'story-plan-check' { Invoke-StoryPlanCheck -Rest $rest }
|
|
4924
|
-
'story-plan-doc' { Invoke-StoryPlanDoc -Rest $rest }
|
|
4925
|
-
'log-append' { Invoke-LogAppend -Rest $rest }
|
|
4926
|
-
'ledger-read' { Invoke-LedgerRead -Rest $rest }
|
|
4927
|
-
'spec-coverage' { Invoke-SpecCoverage -Rest $rest }
|
|
4928
|
-
'spec-preserve' { Invoke-SpecPreserve -Rest $rest }
|
|
4929
|
-
'spec-markers' { Invoke-SpecMarkers -Rest $rest }
|
|
3092
|
+
'version-bootstrap-done' { Invoke-VersionState 'bootstrap-done' $rest }
|
|
4930
3093
|
'feature-paths' { Invoke-FeaturePaths -Rest $rest }
|
|
4931
3094
|
'design-scaffold' { Invoke-DesignScaffold -Rest $rest }
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
'
|
|
3095
|
+
# The plan phase's name for the same op. Both spellings reach one implementation:
|
|
3096
|
+
# --lane backend|frontend writes the plan documents, --lane service|ui the legacy names.
|
|
3097
|
+
'plan-scaffold' { Invoke-DesignScaffold -Rest $rest }
|
|
3098
|
+
'delta-plan-scaffold' { Invoke-DeltaPlanScaffold -Rest $rest }
|
|
4935
3099
|
'build-compile' { Invoke-BuildCompile -Rest $rest }
|
|
4936
3100
|
'test-run' { Invoke-TestRun -Rest $rest }
|
|
4937
|
-
'test-diff' { Invoke-TestDiff -Rest $rest }
|
|
4938
3101
|
'run-migrate' { Invoke-RunMigrate -Rest $rest }
|
|
4939
3102
|
'deploy-dispatch' { Invoke-DeployDispatch -Rest $rest }
|
|
4940
3103
|
'cr-setup' { Invoke-CrSetup -Rest $rest }
|
|
3104
|
+
'cr-list' { Invoke-CrList -Rest $rest }
|
|
3105
|
+
'cr-paths' { Invoke-CrPaths -Rest $rest }
|
|
3106
|
+
'cr-overlap' { Invoke-CrOverlap -Rest $rest }
|
|
3107
|
+
'cr-activate' { Invoke-CrActivate -Rest $rest }
|
|
3108
|
+
'cr-wave' { Invoke-CrWave -Rest $rest }
|
|
4941
3109
|
'cr-archive' { Invoke-CrArchive -Rest $rest }
|
|
3110
|
+
'context-paths' { Invoke-ContextPaths -Rest $rest }
|
|
3111
|
+
'model-scaffold' { Invoke-ModelScaffold -Rest $rest }
|
|
4942
3112
|
'version-stamp' { Invoke-VersionStamp -Rest $rest }
|
|
4943
3113
|
'checklist-read' { Invoke-ChecklistRead -Rest $rest }
|
|
4944
3114
|
'checklist-apply' { Invoke-ChecklistApply -Rest $rest }
|