aw-spec-cli 0.1.10 → 0.1.11
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/ORCH-DIALECT.md +17 -5
- package/assets/.appweaver/orchestrator/README.md +1 -1
- package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +2 -2
- package/assets/.appweaver/orchestrator/adapters/google_antigravity.py +28 -6
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +13 -1
- package/assets/.appweaver/orchestrator/orchestrator.py +126 -39
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +5 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +888 -141
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +826 -16
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +780 -12
- package/assets/.appweaver/stack-profiles/java-spring.json +3 -3
- package/assets/.appweaver/templates/spec-template.md +73 -62
- package/assets/.appweaver/templates/story-template.md +107 -0
- package/assets/guardrails/technology/java-spring/ai-rules.md +505 -0
- package/assets/guardrails/technology/java-spring/api-versioning.md +782 -0
- package/assets/guardrails/technology/java-spring/authorization.md +618 -0
- package/assets/guardrails/technology/java-spring/clean-architecture.md +960 -0
- package/assets/guardrails/technology/java-spring/coding-standards.md +821 -0
- package/assets/guardrails/technology/java-spring/dependency-policy.md +91 -0
- package/assets/guardrails/technology/java-spring/domain-driven-design.md +467 -0
- package/assets/guardrails/technology/java-spring/domain_abstraction.md +424 -0
- package/assets/guardrails/technology/java-spring/folder-structure.md +1537 -0
- package/assets/guardrails/technology/java-spring/global_exception_handler.md +247 -0
- package/assets/guardrails/technology/java-spring/logging.md +215 -0
- package/assets/guardrails/technology/java-spring/modular-monolith.md +77 -0
- package/assets/guardrails/technology/java-spring/persistence.md +316 -0
- package/assets/guardrails/technology/java-spring/problem_details.md +98 -0
- package/assets/guardrails/technology/java-spring/result_mapping.md +48 -0
- package/assets/guardrails/technology/java-spring/technology-constitution.md +168 -0
- package/assets/guardrails/technology/java-spring/testing.md +601 -0
- package/assets/guardrails/technology/java-spring/zero-trust.md +210 -0
- package/dist/index.js +0 -0
- package/dist/utils/config.d.ts +22 -0
- package/dist/utils/config.d.ts.map +1 -0
- package/dist/utils/config.js +76 -0
- package/dist/utils/config.js.map +1 -0
- package/dist/utils/copy-assets.d.ts.map +1 -1
- package/dist/utils/copy-assets.js +4 -11
- package/dist/utils/copy-assets.js.map +1 -1
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +11 -7
- package/dist/utils/paths.js.map +1 -1
- package/package.json +2 -1
- package/assets/guardrails/technology/java-spring/README.md +0 -62
- package/dist/commands/update.d.ts +0 -3
- package/dist/commands/update.d.ts.map +0 -1
- package/dist/commands/update.js +0 -142
- package/dist/commands/update.js.map +0 -1
- package/dist/utils/manifest.d.ts +0 -31
- package/dist/utils/manifest.d.ts.map +0 -1
- package/dist/utils/manifest.js +0 -91
- package/dist/utils/manifest.js.map +0 -1
- package/dist/utils/update-engine.d.ts +0 -52
- package/dist/utils/update-engine.d.ts.map +0 -1
- package/dist/utils/update-engine.js +0 -203
- package/dist/utils/update-engine.js.map +0 -1
|
@@ -453,7 +453,8 @@ function Invoke-CreateChecklist {
|
|
|
453
453
|
- [ ] Requirements are testable and unambiguous
|
|
454
454
|
- [ ] Success criteria are measurable
|
|
455
455
|
- [ ] Success criteria are technology-agnostic (no implementation details)
|
|
456
|
-
- [ ]
|
|
456
|
+
- [ ] Every requirement carries a stable id, and no retired id has been re-issued
|
|
457
|
+
- [ ] The newest Change History row names every requirement id this version added or changed
|
|
457
458
|
- [ ] Edge cases are identified
|
|
458
459
|
- [ ] Scope is clearly bounded
|
|
459
460
|
- [ ] Dependencies and assumptions identified
|
|
@@ -474,8 +475,8 @@ function Invoke-CreateChecklist {
|
|
|
474
475
|
|
|
475
476
|
## Feature Readiness
|
|
476
477
|
|
|
477
|
-
- [ ]
|
|
478
|
-
- [ ]
|
|
478
|
+
- [ ] Every functional requirement is stated in EARS form, which is its acceptance criterion
|
|
479
|
+
- [ ] The requirements cover the aggregate's primary flows, so a story can be sliced from them without inventing behaviour
|
|
479
480
|
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
|
480
481
|
- [ ] No implementation details leak into specification
|
|
481
482
|
|
|
@@ -775,6 +776,7 @@ $PyActiveVersion = @'
|
|
|
775
776
|
import json, os, re, datetime, sys
|
|
776
777
|
fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]; cr = os.environ.get("AW_CR", "")
|
|
777
778
|
force = os.environ.get("AW_FORCE", "")
|
|
779
|
+
boot = os.environ.get("AW_BOOT", "")
|
|
778
780
|
path = os.path.join(fd, "active_version.json")
|
|
779
781
|
feature = os.path.basename(fd.rstrip("/"))
|
|
780
782
|
def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -799,7 +801,23 @@ out = {"feature": d["feature"], "version": d["version"], "active_change": d.get(
|
|
|
799
801
|
if op == "read":
|
|
800
802
|
pass
|
|
801
803
|
elif op == "init":
|
|
802
|
-
|
|
804
|
+
# BOOTSTRAP IN PROGRESS, and ONLY when the caller says it is authoring a first draft
|
|
805
|
+
# (--bootstrap). A first run that dies after writing spec.md but before its ledger row
|
|
806
|
+
# leaves a document that looks, to the next FRESH invocation, exactly like a spec
|
|
807
|
+
# written before the ledger existed — `unstamped`, which passes. specify then took the
|
|
808
|
+
# EXTEND path and applied the same input a second time, on top of a v1.0 that was never
|
|
809
|
+
# finished, and bumped it to v1.1. Checkpoint resume was always fine; a fresh invocation
|
|
810
|
+
# was not, and nothing distinguished the two. This marker does. Only ledger-ids clears
|
|
811
|
+
# it, and only once the v1.0 row actually names ids read out of the finished document —
|
|
812
|
+
# so a bootstrap whose row never landed is re-run and REPAIRED rather than being frozen
|
|
813
|
+
# behind a v1.1 that can never go back and fix it.
|
|
814
|
+
#
|
|
815
|
+
# The flag is the CALLER's, not this primitive's: adopt inits the same file after
|
|
816
|
+
# reversing a spec out of existing code, and a marker raised there would tell the next
|
|
817
|
+
# specify that a finished reversal was a crashed first draft and to overwrite it.
|
|
818
|
+
if boot:
|
|
819
|
+
d["bootstrap_pending"] = True
|
|
820
|
+
save(d)
|
|
803
821
|
out["created"] = (not existed)
|
|
804
822
|
elif op == "open":
|
|
805
823
|
cur = d.get("active_change", "")
|
|
@@ -820,6 +838,7 @@ elif op == "close":
|
|
|
820
838
|
d["active_change"] = ""; save(d)
|
|
821
839
|
out["version"] = d["version"]; out["active_change"] = ""
|
|
822
840
|
out["previous"] = prev; out["closed_change"] = closed
|
|
841
|
+
out["bootstrap_pending"] = bool(d.get("bootstrap_pending"))
|
|
823
842
|
out["has_change"] = bool(out.get("active_change", "")) # clean branch signal for design
|
|
824
843
|
# What a close from THIS state would produce. One bump() authority, read by BOTH
|
|
825
844
|
# finalize-delta (to write the right number into the Change History rows it appends)
|
|
@@ -831,24 +850,748 @@ print(json.dumps(out))
|
|
|
831
850
|
'@
|
|
832
851
|
|
|
833
852
|
function Invoke-ActiveVersion {
|
|
834
|
-
param([string]$FeatureDir, [string]$Op, [string]$CrId = '', [string]$Force = '')
|
|
853
|
+
param([string]$FeatureDir, [string]$Op, [string]$CrId = '', [string]$Force = '', [string]$Boot = '')
|
|
835
854
|
$py = Get-Py; if (-not $py) { Write-Error "version-$Op`: python3 required"; exit 1 }
|
|
836
855
|
$env:AW_FD = $FeatureDir; $env:AW_OP = $Op; $env:AW_CR = $CrId; $env:AW_FORCE = $Force
|
|
856
|
+
$env:AW_BOOT = $Boot
|
|
837
857
|
$PyActiveVersion | & $py - # sets $LASTEXITCODE=3 when op=open is refused (guard)
|
|
838
858
|
}
|
|
839
859
|
|
|
840
|
-
function Invoke-VersionState { # op in read|init (feature-dir
|
|
860
|
+
function Invoke-VersionState { # op in read|init (feature-dir, plus --bootstrap on init)
|
|
841
861
|
param([string]$Op, [string[]]$Rest)
|
|
842
|
-
$fdArg = ''
|
|
843
|
-
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
862
|
+
$fdArg = ''; $boot = ''
|
|
863
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
864
|
+
if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$i] }
|
|
865
|
+
elseif ($Rest[$i] -in '--bootstrap','-Bootstrap') { $boot = '1' }
|
|
866
|
+
}
|
|
844
867
|
$repo = Get-RepoRoot
|
|
845
868
|
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
846
869
|
if (-not $fd) { Write-Error "version-$Op`: no feature set and no --feature-dir"; exit 1 }
|
|
847
|
-
Invoke-ActiveVersion -FeatureDir $fd -Op $Op
|
|
870
|
+
Invoke-ActiveVersion -FeatureDir $fd -Op $Op -Boot $boot
|
|
848
871
|
}
|
|
849
872
|
|
|
850
873
|
|
|
851
874
|
|
|
875
|
+
# ═════════════════════════ the spec version ledger ══════════════════════════
|
|
876
|
+
# spec.md's `## Change History` is the sync anchor between the spec and its story
|
|
877
|
+
# backlog: each row names the requirement ids that version touched, and each story
|
|
878
|
+
# records the version it last synced at, so "is this story stale" is one integer
|
|
879
|
+
# comparison rather than a diff. update-stories reads the rows; specify and
|
|
880
|
+
# update-spec append them.
|
|
881
|
+
#
|
|
882
|
+
# The ledger is hand-writable, which is the point — a spec can be edited by hand —
|
|
883
|
+
# and also its one weakness: a body edited with no row leaves every affected story
|
|
884
|
+
# quietly stale and nothing says so. active_version.json therefore carries a sha256
|
|
885
|
+
# of spec.md as of the last kit-authored write, and three states follow from
|
|
886
|
+
# comparing it against the document:
|
|
887
|
+
#
|
|
888
|
+
# hash matches clean — nothing has been hand-edited since
|
|
889
|
+
# hash differs, version stands a hand edit that skipped the ledger -> REFUSE
|
|
890
|
+
# hash differs, version advanced a hand edit that wrote its own row -> accept
|
|
891
|
+
#
|
|
892
|
+
# The middle case is the only silent failure the ledger has, which is why it is the
|
|
893
|
+
# one thing checked before any workflow writes.
|
|
894
|
+
$PySpecLedger = @'
|
|
895
|
+
import json, os, re, hashlib, datetime, sys
|
|
896
|
+
fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]
|
|
897
|
+
ids = os.environ.get("AW_IDS", "").strip(); summary = os.environ.get("AW_SUM", "").strip()
|
|
898
|
+
spec = os.path.join(fd, "spec.md"); avp = os.path.join(fd, "active_version.json")
|
|
899
|
+
feature = os.path.basename(fd.rstrip("/\\"))
|
|
900
|
+
|
|
901
|
+
def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
902
|
+
def vtuple(v):
|
|
903
|
+
m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
|
|
904
|
+
return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
|
|
905
|
+
def bump(v):
|
|
906
|
+
maj, mnr = vtuple(v or "v1.0")
|
|
907
|
+
return "v%d.%d" % (maj, mnr + 1)
|
|
908
|
+
|
|
909
|
+
out = {"feature": feature, "op": op, "ok": True, "spec_exists": os.path.isfile(spec)}
|
|
910
|
+
|
|
911
|
+
# A non-zero exit is reported by the orchestrator from STDERR alone, so anything that
|
|
912
|
+
# stops a run has to say why there as well as in the JSON — otherwise the operator sees
|
|
913
|
+
# "script failed (ledger-append):" and nothing else.
|
|
914
|
+
def bail(msg, code):
|
|
915
|
+
out.update(ok=False, error=msg)
|
|
916
|
+
print(json.dumps(out)); sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
917
|
+
|
|
918
|
+
if not out["spec_exists"]:
|
|
919
|
+
bail("no spec.md at %s" % fd, 1)
|
|
920
|
+
|
|
921
|
+
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
922
|
+
|
|
923
|
+
# The Story Coverage block is a projection of the backlog, not contract, and it is written
|
|
924
|
+
# by the story workflows rather than by whoever owns the spec. Cutting it out before
|
|
925
|
+
# hashing is what lets both be true at once: creating a story rewrites this document
|
|
926
|
+
# without that reading as an unledgered edit of the requirements.
|
|
927
|
+
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
928
|
+
def contract_of(t): return COV.sub("", t)
|
|
929
|
+
actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
|
|
930
|
+
av = {}
|
|
931
|
+
if os.path.isfile(avp):
|
|
932
|
+
try: av = json.load(open(avp, encoding="utf-8"))
|
|
933
|
+
except Exception: av = {}
|
|
934
|
+
recorded_hash = av.get("spec_sha256", "")
|
|
935
|
+
recorded_ver = av.get("version", "v1.0")
|
|
936
|
+
|
|
937
|
+
# The document's own header is what a hand edit bumps; active_version.json is what the
|
|
938
|
+
# kit last wrote. They agree except across a hand edit, and the disagreement is the signal.
|
|
939
|
+
m = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
|
|
940
|
+
doc_ver = m.group(1) if m else ""
|
|
941
|
+
|
|
942
|
+
# THE LEDGER TABLE, located once: its heading, its separator rule, and the data rows under
|
|
943
|
+
# it. Every op that touches a version row works from this, and `check` refuses a document
|
|
944
|
+
# that has none — because ledger-append discovers a missing table only AFTER the spec has
|
|
945
|
+
# been rewritten, and a rewritten spec with no row is exactly the state the next run's
|
|
946
|
+
# tripwire refuses. Finding it at entry turns a wedged feature into a message.
|
|
947
|
+
lines = text.split("\n")
|
|
948
|
+
hi = next((i for i, l in enumerate(lines)
|
|
949
|
+
if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
|
|
950
|
+
sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
|
|
951
|
+
len(lines)) if hi >= 0 else -1)
|
|
952
|
+
sep = (next((i for i in range(hi + 1, sec_end)
|
|
953
|
+
if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
|
|
954
|
+
rows = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
|
|
955
|
+
has_ledger = hi >= 0 and sep >= 0
|
|
956
|
+
|
|
957
|
+
def cell(s, limit=160):
|
|
958
|
+
"""One markdown table cell. A newline ENDS a row and a bare pipe STARTS a column, so
|
|
959
|
+
free text dropped into a cell silently rewrites the row's shape — a summary carrying
|
|
960
|
+
either one produced a version row whose 'Requirements changed' cell had moved or
|
|
961
|
+
vanished, which is a change no story can ever be reconciled against."""
|
|
962
|
+
s = re.sub(r'\s+', ' ', (s or "")).strip().replace("|", "\\|")
|
|
963
|
+
return (s[:limit - 1] + "\u2026") if len(s) > limit else s
|
|
964
|
+
|
|
965
|
+
# IS A DELTA ACTUALLY MEASURED AGAINST THIS MASTER. cr-setup scaffolds delta-spec.md AND
|
|
966
|
+
# delta-design.md for EVERY change it opens, so the file existing proves nothing. Since
|
|
967
|
+
# specify lost its delta branch nothing authors a delta-spec at all, and the only changes
|
|
968
|
+
# opened today are design-only ones carrying an untouched copy of the template — for which
|
|
969
|
+
# editing spec.md strands nothing. Authored means the bytes have moved off the template;
|
|
970
|
+
# with no template resolvable, a non-empty file counts.
|
|
971
|
+
cr = av.get("active_change", "")
|
|
972
|
+
dtpl = os.environ.get("AW_DTPL", "")
|
|
973
|
+
dspec = os.path.join(fd, "changes", cr, "delta-spec.md") if cr else ""
|
|
974
|
+
delta_authored = False
|
|
975
|
+
if dspec and os.path.isfile(dspec) and os.path.getsize(dspec) > 0:
|
|
976
|
+
try:
|
|
977
|
+
delta_authored = (not (dtpl and os.path.isfile(dtpl))
|
|
978
|
+
or open(dspec, "rb").read() != open(dtpl, "rb").read())
|
|
979
|
+
except Exception:
|
|
980
|
+
delta_authored = True
|
|
981
|
+
|
|
982
|
+
out.update(open_change=cr, delta_authored=delta_authored)
|
|
983
|
+
out.update(version=recorded_ver, doc_version=doc_ver, spec_sha256=actual,
|
|
984
|
+
recorded_sha256=recorded_hash, body_changed=(recorded_hash != actual),
|
|
985
|
+
has_ledger=has_ledger, ledger_rows=len(rows),
|
|
986
|
+
first_stamp=(not recorded_hash))
|
|
987
|
+
|
|
988
|
+
if op == "check":
|
|
989
|
+
# An AUTHORED delta-spec was computed against THIS master. Writing the master directly
|
|
990
|
+
# would leave that delta measured from a baseline that no longer exists, and nothing
|
|
991
|
+
# downstream re-checks it. The two families overlap only until the delta set is deleted,
|
|
992
|
+
# so this guard is temporary by design — but while both exist it is the one silent way
|
|
993
|
+
# they collide. Narrowed to an authored delta on purpose: gating on `active_change`
|
|
994
|
+
# alone refused every extend for the whole life of a DESIGN-only change, which strands
|
|
995
|
+
# nothing, and told the operator to finish a chain they may be retiring.
|
|
996
|
+
if cr and delta_authored:
|
|
997
|
+
out["state"] = "delta_open"
|
|
998
|
+
out.update(ok=False, active_change=cr,
|
|
999
|
+
error="change '%s' is open on this feature and its delta-spec has been "
|
|
1000
|
+
"authored, so a delta is measured against this master. Editing spec.md "
|
|
1001
|
+
"directly would leave that delta computed from a baseline that no longer "
|
|
1002
|
+
"exists. Either finish the change (finalize-delta -> analyze-slice -> "
|
|
1003
|
+
"close-delta), or abandon it: "
|
|
1004
|
+
"`.appweaver/scripts/bash/aw-spec-actions.sh cr-archive --cr-id %s` moves "
|
|
1005
|
+
"changes/%s/ into changes/archive/ and clears the flag. Abandoning also "
|
|
1006
|
+
"bumps the minor version, so the number moves even though nothing shipped."
|
|
1007
|
+
% (cr, cr, cr))
|
|
1008
|
+
elif not has_ledger:
|
|
1009
|
+
out["state"] = "no_ledger"
|
|
1010
|
+
out.update(ok=False,
|
|
1011
|
+
error="spec.md has no '## Change History' table. That table is where a "
|
|
1012
|
+
"story learns a requirement it cites has moved, so a spec without one "
|
|
1013
|
+
"cannot be kept in step with its backlog. Add the section with its "
|
|
1014
|
+
"header row and separator (see the spec template), then re-run.")
|
|
1015
|
+
elif not recorded_hash:
|
|
1016
|
+
out["state"] = "unstamped" # pre-ledger spec; stamp adopts it
|
|
1017
|
+
elif recorded_hash == actual:
|
|
1018
|
+
out["state"] = "clean"
|
|
1019
|
+
elif vtuple(doc_ver) > vtuple(recorded_ver):
|
|
1020
|
+
out["state"] = "ledgered_edit" # hand-edited AND a row was written
|
|
1021
|
+
elif vtuple(doc_ver) < vtuple(recorded_ver):
|
|
1022
|
+
out["state"] = "rollback"
|
|
1023
|
+
out.update(ok=False, error="spec.md reads %s but the kit last wrote %s — the "
|
|
1024
|
+
"document is behind its own ledger" % (doc_ver or "no version", recorded_ver))
|
|
1025
|
+
else:
|
|
1026
|
+
out["state"] = "unledgered_edit"
|
|
1027
|
+
out.update(ok=False, error="spec.md has changed since %s but no Change History row "
|
|
1028
|
+
"was added. Every story citing an edited requirement is now silently "
|
|
1029
|
+
"stale. Add a row naming the ids you changed, then re-run."
|
|
1030
|
+
% recorded_ver)
|
|
1031
|
+
elif op == "stamp":
|
|
1032
|
+
av.setdefault("feature", feature); av.setdefault("active_change", "")
|
|
1033
|
+
av["version"] = doc_ver or recorded_ver
|
|
1034
|
+
av["spec_sha256"] = actual; av["updated"] = now()
|
|
1035
|
+
open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
|
|
1036
|
+
out.update(version=av["version"], state="stamped")
|
|
1037
|
+
elif op == "append":
|
|
1038
|
+
if not ids:
|
|
1039
|
+
# A version that names no ids is a bump nothing can reconcile against: every
|
|
1040
|
+
# story keeps its old stamp and the gap compounds at the next real change.
|
|
1041
|
+
bail("ledger-append: --ids is required — a version row that names no requirement "
|
|
1042
|
+
"ids leaves update-stories nothing to act on", 2)
|
|
1043
|
+
# The next number comes from the highest of all three places a version is written —
|
|
1044
|
+
# what the kit last recorded, what the document's header claims, and the newest row
|
|
1045
|
+
# actually in the table. A hand edit routinely moves one and not the others, and
|
|
1046
|
+
# taking the maximum is what stops the ledger re-issuing a number it has already used.
|
|
1047
|
+
row_vers = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', text, re.M)
|
|
1048
|
+
new_ver = bump(max([recorded_ver, doc_ver] + row_vers, key=vtuple))
|
|
1049
|
+
today = datetime.date.today().isoformat()
|
|
1050
|
+
# Both free-text cells go through cell(): the summary is whatever the operator typed on
|
|
1051
|
+
# the command line, and one newline or one pipe in it destroys the row.
|
|
1052
|
+
row = "| %s | %s | %s | %s | — |" % (
|
|
1053
|
+
new_ver, today, cell(summary) or "Requirements updated", cell(ids))
|
|
1054
|
+
|
|
1055
|
+
# Bounded to the Change History section, not "the last table row in the file". Any
|
|
1056
|
+
# section below it — Story Coverage today — carries its own table, and an unbounded
|
|
1057
|
+
# search would file the version row into whichever one happened to come last.
|
|
1058
|
+
if not has_ledger:
|
|
1059
|
+
bail("spec.md has no '## Change History' table to append to — the ledger is "
|
|
1060
|
+
"where a story learns a requirement moved, so a spec without one cannot be "
|
|
1061
|
+
"kept in step with its backlog. spec.md HAS ALREADY BEEN WRITTEN by this run: "
|
|
1062
|
+
"add the section, then re-run to record what it changed", 2)
|
|
1063
|
+
last = rows[-1] if rows else sep
|
|
1064
|
+
# Duplicate version = two writers reached the same number, which is the collision the
|
|
1065
|
+
# integer buys for free. Refuse rather than append a second row nothing can order.
|
|
1066
|
+
if re.search(r'^\|\s*%s\s*\|' % re.escape(new_ver), text, re.M):
|
|
1067
|
+
bail("the ledger already carries a %s row — another write reached this version "
|
|
1068
|
+
"first; re-read spec.md and retry" % new_ver, 3)
|
|
1069
|
+
lines.insert(last + 1, row)
|
|
1070
|
+
|
|
1071
|
+
stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + new_ver,
|
|
1072
|
+
"\n".join(lines), count=1, flags=re.M)
|
|
1073
|
+
open(spec, "w", encoding="utf-8").write(stamped)
|
|
1074
|
+
|
|
1075
|
+
av.setdefault("feature", feature); av.setdefault("active_change", "")
|
|
1076
|
+
av["version"] = new_ver
|
|
1077
|
+
av["spec_sha256"] = hashlib.sha256(contract_of(stamped).encode("utf-8")).hexdigest()
|
|
1078
|
+
av["updated"] = now()
|
|
1079
|
+
open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
|
|
1080
|
+
out.update(version=new_ver, previous=recorded_ver, row=row, ids=ids,
|
|
1081
|
+
spec_sha256=av["spec_sha256"], state="appended")
|
|
1082
|
+
elif op == "rowids":
|
|
1083
|
+
# THE FIRST ROW, COMPUTED. Every other version row is derived from a before/after diff,
|
|
1084
|
+
# for the reason the header of this file gives: a row the model DECLARED can omit an id
|
|
1085
|
+
# it moved, and an omitted id is silent. The v1.0 row was the one exception — written by
|
|
1086
|
+
# the author from a template whose placeholder literally reads "FR-001…FR-00n", which
|
|
1087
|
+
# update-stories then has to be told not to trust. This op closes that gap: the caller
|
|
1088
|
+
# diffs the finished document against nothing and hands the ids in, exactly as the
|
|
1089
|
+
# extend path does, so the first row is observed like all the rest.
|
|
1090
|
+
if not ids:
|
|
1091
|
+
bail("ledger-ids: --ids is required", 2)
|
|
1092
|
+
if not has_ledger:
|
|
1093
|
+
bail("spec.md has no '## Change History' table to write the initial row into", 2)
|
|
1094
|
+
ver = doc_ver if re.match(r'^v\d+(\.\d+)?$', doc_ver or "") else (recorded_ver or "v1.0")
|
|
1095
|
+
today = datetime.date.today().isoformat()
|
|
1096
|
+
if rows:
|
|
1097
|
+
# Rewrite the newest row's cells in place. A cell split has to respect \| , which is
|
|
1098
|
+
# how cell() escapes a pipe the operator typed — splitting on every pipe would treat
|
|
1099
|
+
# an escaped one as a column break and shift every cell after it.
|
|
1100
|
+
parts = re.split(r'(?<!\\)\|', lines[rows[-1]])
|
|
1101
|
+
body = [c.strip() for c in parts[1:-1]] if len(parts) >= 3 else []
|
|
1102
|
+
while len(body) < 5: body.append("")
|
|
1103
|
+
if not re.match(r'^v\d+(\.\d+)?$', body[0]): body[0] = ver
|
|
1104
|
+
if not re.match(r'^\d{4}-\d{2}-\d{2}$', body[1]): body[1] = today
|
|
1105
|
+
if not body[2] or body[2].startswith("["): body[2] = "Initial specification"
|
|
1106
|
+
body[3] = cell(ids)
|
|
1107
|
+
if not body[4]: body[4] = "—"
|
|
1108
|
+
lines[rows[-1]] = "| " + " | ".join(body[:5]) + " |"
|
|
1109
|
+
else:
|
|
1110
|
+
lines.insert(sep + 1, "| %s | %s | Initial specification | %s | — |"
|
|
1111
|
+
% (ver, today, cell(ids)))
|
|
1112
|
+
stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + ver,
|
|
1113
|
+
"\n".join(lines), count=1, flags=re.M)
|
|
1114
|
+
open(spec, "w", encoding="utf-8").write(stamped)
|
|
1115
|
+
# The v1.0 row now names ids read out of the finished document, which is the moment the
|
|
1116
|
+
# bootstrap is complete. Clearing the marker here and NOWHERE else is what makes an
|
|
1117
|
+
# incomplete first run re-runnable: spec-stamp still re-baselines the hash on every path,
|
|
1118
|
+
# so gating on the hash would have frozen a placeholder row as clean forever.
|
|
1119
|
+
av.pop("bootstrap_pending", None)
|
|
1120
|
+
av.setdefault("feature", feature); av.setdefault("active_change", "")
|
|
1121
|
+
av["version"] = ver; av["updated"] = now()
|
|
1122
|
+
open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
|
|
1123
|
+
out.update(version=ver, ids=ids, row=lines[rows[-1]] if rows else lines[sep + 1],
|
|
1124
|
+
bootstrap_pending=False, state="rowids")
|
|
1125
|
+
print(json.dumps(out))
|
|
1126
|
+
'@
|
|
1127
|
+
|
|
1128
|
+
# What a run CHANGED, observed rather than declared. specify snapshots spec.md before
|
|
1129
|
+
# it authors and diffs after the converge loop has finished with it, so the Change
|
|
1130
|
+
# History row is computed from the document's own requirement ids. A row the model
|
|
1131
|
+
# declares can omit a requirement the converge loop rewrote on its way past — and an
|
|
1132
|
+
# omitted id is the silent case the whole ledger exists to prevent.
|
|
1133
|
+
$PySpecDiff = @'
|
|
1134
|
+
import json, os, re, hashlib, sys
|
|
1135
|
+
snap = os.environ.get("AW_SNAP", ""); spec = os.environ["AW_SPEC"]
|
|
1136
|
+
# ASKING FOR NO BEFORE-STATE IS NOT THE SAME AS LOSING ONE. An empty --snapshot is the
|
|
1137
|
+
# bootstrap path saying "there was nothing before this run", and every id is legitimately
|
|
1138
|
+
# added. A path that was given and is not there is the before-state having been LOST, and
|
|
1139
|
+
# answering that with "everything is new" is the loudest possible wrong answer: it marks
|
|
1140
|
+
# every story in the feature stale for a change that touched none of them.
|
|
1141
|
+
if snap and not os.path.isfile(snap):
|
|
1142
|
+
msg = ("spec-diff: the before-state at %s is gone, so this run cannot tell what it "
|
|
1143
|
+
"changed. spec.md HAS been written. Recover by hand: put the ids you changed "
|
|
1144
|
+
"into a new Change History row, then re-run spec-stamp to re-baseline."
|
|
1145
|
+
% snap)
|
|
1146
|
+
print(json.dumps({"ok": False, "error": msg, "had_snapshot": False, "snapshot": snap}))
|
|
1147
|
+
sys.stderr.write(msg + "\n"); sys.exit(2)
|
|
1148
|
+
# An id is keyed to the line that DEFINES it, never to a line that merely mentions it.
|
|
1149
|
+
# The four shapes are scored, best wins, earliest line breaks a tie:
|
|
1150
|
+
# 3 a leading table cell | DC-001 | … |
|
|
1151
|
+
# 3 a bold id opening a bullet - **FR-001**: THE SYSTEM SHALL …
|
|
1152
|
+
# 2 a bold id opening a line **FR-001** … (a definition missing its colon)
|
|
1153
|
+
# 1 a bold id anywhere … narrowing **FR-001**. (a cross-reference)
|
|
1154
|
+
# Scoring rather than "first bold id on the line, last line wins": a note that OPENS with
|
|
1155
|
+
# a cross-reference — "**FR-005** remains in force; **FR-006** narrows it" — re-keyed
|
|
1156
|
+
# FR-005 to that note, so a requirement restored character-for-character was reported as
|
|
1157
|
+
# MODIFIED. That is the worst possible misreading here, because the additive path exists
|
|
1158
|
+
# precisely to leave such a requirement alone, and its own instructions ask the author to
|
|
1159
|
+
# name the id in the new requirement's text.
|
|
1160
|
+
DEF_CELL = re.compile(r'^\s*\|\s*([A-Z]{2}-\d+)\s*\|')
|
|
1161
|
+
DEF_BULL = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*\s*[:\u2014\u2013-]')
|
|
1162
|
+
DEF_OPEN = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*')
|
|
1163
|
+
ANY_BOLD = re.compile(r'\*\*([A-Z]{2}-\d+)\*\*')
|
|
1164
|
+
# The Story Coverage block lists requirement ids in a table, so it reads exactly like the
|
|
1165
|
+
# Data Classification section to DEF_CELL. Left in, every story raised against FR-003 would
|
|
1166
|
+
# show up here as FR-003 having CHANGED — a version row, and every story citing it stale,
|
|
1167
|
+
# because somebody wrote a story.
|
|
1168
|
+
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
1169
|
+
# The ledger's own rows name ids in prose cells; they record history, they do not define a
|
|
1170
|
+
# requirement, and the table is stripped before the scan for the same reason as coverage.
|
|
1171
|
+
HIST = re.compile(r'^##\s+Change History\s*$', re.M)
|
|
1172
|
+
def is_def(l):
|
|
1173
|
+
return bool(DEF_CELL.match(l) or DEF_BULL.match(l) or DEF_OPEN.match(l))
|
|
1174
|
+
def scan(path):
|
|
1175
|
+
"""{id: (score, line_no, hash)} for every id the document mentions."""
|
|
1176
|
+
best = {}
|
|
1177
|
+
if not path or not os.path.isfile(path): return best
|
|
1178
|
+
text = COV.sub("", open(path, encoding="utf-8", errors="replace").read())
|
|
1179
|
+
hm = HIST.search(text)
|
|
1180
|
+
if hm: text = text[:hm.start()]
|
|
1181
|
+
lines = text.split("\n")
|
|
1182
|
+
for n, line in enumerate(lines):
|
|
1183
|
+
hits = []
|
|
1184
|
+
m = DEF_CELL.match(line)
|
|
1185
|
+
if m: hits.append((3, m.group(1)))
|
|
1186
|
+
m = DEF_BULL.match(line)
|
|
1187
|
+
if m: hits.append((3, m.group(1)))
|
|
1188
|
+
else:
|
|
1189
|
+
m = DEF_OPEN.match(line)
|
|
1190
|
+
if m: hits.append((2, m.group(1)))
|
|
1191
|
+
for rid in ANY_BOLD.findall(line):
|
|
1192
|
+
hits.append((1, rid))
|
|
1193
|
+
for score, rid in hits:
|
|
1194
|
+
if rid not in best or score > best[rid][0]:
|
|
1195
|
+
best[rid] = (score, n)
|
|
1196
|
+
# THE WHOLE REQUIREMENT, not the line its id sits on. A requirement that wraps carries
|
|
1197
|
+
# half its meaning on the continuation lines, and hashing only the id's own line made an
|
|
1198
|
+
# edit below the fold invisible: change_count 0, no ledger row appended, and spec-stamp
|
|
1199
|
+
# re-baselining the new body as clean — the ledger's own silent failure, for every story
|
|
1200
|
+
# citing that id. The block runs to the next definition, heading or blank line, and is
|
|
1201
|
+
# whitespace-normalised as ONE string, so a rewrap across lines is still not a change.
|
|
1202
|
+
out = {}
|
|
1203
|
+
for rid, (score, n) in best.items():
|
|
1204
|
+
block = [lines[n]]
|
|
1205
|
+
for m2 in range(n + 1, len(lines)):
|
|
1206
|
+
nxt = lines[m2]
|
|
1207
|
+
if not nxt.strip() or nxt.lstrip().startswith("#") or is_def(nxt): break
|
|
1208
|
+
block.append(nxt)
|
|
1209
|
+
h = hashlib.sha256(" ".join(" ".join(block).split()).encode()).hexdigest()[:16]
|
|
1210
|
+
out[rid] = (score, n, h)
|
|
1211
|
+
return out
|
|
1212
|
+
def ids(path):
|
|
1213
|
+
return {k: v[2] for k, v in scan(path).items()}
|
|
1214
|
+
# Ids the ledger has already spent. An id is PERMANENT: re-issuing one silently re-points
|
|
1215
|
+
# stories, designs and code that have already shipped. Read from the SNAPSHOT's history, so
|
|
1216
|
+
# it is what the ledger said before this run, and only meaningful when there is a snapshot.
|
|
1217
|
+
def spent(path):
|
|
1218
|
+
if not path or not os.path.isfile(path): return set()
|
|
1219
|
+
text = open(path, encoding="utf-8", errors="replace").read()
|
|
1220
|
+
hm = HIST.search(text)
|
|
1221
|
+
if not hm: return set()
|
|
1222
|
+
return set(re.findall(r'[A-Z]{2}-\d+', text[hm.end():]))
|
|
1223
|
+
before, after = ids(snap), ids(spec)
|
|
1224
|
+
added = sorted(k for k in after if k not in before)
|
|
1225
|
+
removed = sorted(k for k in before if k not in after)
|
|
1226
|
+
modified = sorted(k for k in after if k in before and before[k] != after[k])
|
|
1227
|
+
changed = sorted(set(added) | set(removed) | set(modified))
|
|
1228
|
+
reissued = sorted(set(added) & spent(snap))
|
|
1229
|
+
# THE STORY-FACING SUBSET. A story cites FR- and BR- ids and nothing else, so those are the
|
|
1230
|
+
# only classes the backlog can act on — the same scoping spec-coverage applies, for the same
|
|
1231
|
+
# reason. The scan is deliberately class-blind (CN-/DC-/IC-/UX-/SC- all move the contract and
|
|
1232
|
+
# all belong in the ledger row), but handing that whole set to update-stories as "the
|
|
1233
|
+
# requirements this run touched" asks it to raise a story for a success criterion.
|
|
1234
|
+
STORY_CLASS = ("FR-", "BR-")
|
|
1235
|
+
story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
|
|
1236
|
+
print(json.dumps({
|
|
1237
|
+
"ok": True, "had_snapshot": bool(snap and os.path.isfile(snap)),
|
|
1238
|
+
"added": added, "removed": removed, "modified": modified,
|
|
1239
|
+
"changed": changed, "changed_csv": ", ".join(changed),
|
|
1240
|
+
"change_count": len(changed),
|
|
1241
|
+
"added_csv": ", ".join(added),
|
|
1242
|
+
# removed ids orphan every story citing them (E1) — carried out so the caller can say so
|
|
1243
|
+
"removed_csv": ", ".join(removed),
|
|
1244
|
+
"modified_csv": ", ".join(modified),
|
|
1245
|
+
# What an existing story would feel. An ADDED requirement disturbs nobody: no story
|
|
1246
|
+
# cites an id that did not exist a moment ago. Modifying or removing one moves ground
|
|
1247
|
+
# somebody may already be standing on, which is the set worth asking about.
|
|
1248
|
+
"disturbing": sorted(set(modified) | set(removed)),
|
|
1249
|
+
"disturbing_csv": ", ".join(sorted(set(modified) | set(removed))),
|
|
1250
|
+
# An id this run introduced that the ledger has already spent. Detected, not blocked:
|
|
1251
|
+
# the document is written by the time we can see it, and refusing here would leave a
|
|
1252
|
+
# spec on disk that the next run's entry check would refuse too.
|
|
1253
|
+
"reissued": reissued, "reissued_csv": ", ".join(reissued),
|
|
1254
|
+
"reissued_any": bool(reissued),
|
|
1255
|
+
# changed, narrowed to the classes a story can cite — what update-stories works from.
|
|
1256
|
+
"story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
|
|
1257
|
+
"story_id_count": len(story_ids),
|
|
1258
|
+
}))
|
|
1259
|
+
'@
|
|
1260
|
+
|
|
1261
|
+
# Which requirements have live work against them. A read-only scan of the feature's
|
|
1262
|
+
# stories/ directory, reading only frontmatter — which is why §6.1 puts everything the
|
|
1263
|
+
# gates need there rather than in the body. Only OPEN stories count: a closed story is
|
|
1264
|
+
# history and a cancelled one was abandoned, and neither is disturbed by the contract
|
|
1265
|
+
# moving. in_flight is the narrower set — an open story that already has a stories/<ID>/
|
|
1266
|
+
# directory, so someone is working from a projection of the requirement as it reads today.
|
|
1267
|
+
$PyStoryRefs = @'
|
|
1268
|
+
import json, os, re
|
|
1269
|
+
fd = os.environ["AW_FD"]; want = os.environ.get("AW_IDS", "")
|
|
1270
|
+
sdir = os.path.join(fd, "stories")
|
|
1271
|
+
queried = [s.strip() for s in re.split(r'[,\s]+', want) if s.strip()]
|
|
1272
|
+
|
|
1273
|
+
def parse_fm(text):
|
|
1274
|
+
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
1275
|
+
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
1276
|
+
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
1277
|
+
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
1278
|
+
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
1279
|
+
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
1280
|
+
if not m: return {}
|
|
1281
|
+
fm, key, block = {}, None, set()
|
|
1282
|
+
for line in m.group(1).split("\n"):
|
|
1283
|
+
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
1284
|
+
if km:
|
|
1285
|
+
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
1286
|
+
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
1287
|
+
if im and (key in block or not fm[key]):
|
|
1288
|
+
block.add(key)
|
|
1289
|
+
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
1290
|
+
return fm
|
|
1291
|
+
|
|
1292
|
+
def frontmatter(path):
|
|
1293
|
+
try: return parse_fm(open(path, encoding="utf-8", errors="replace").read())
|
|
1294
|
+
except Exception: return {}
|
|
1295
|
+
|
|
1296
|
+
stories = []
|
|
1297
|
+
if os.path.isdir(sdir):
|
|
1298
|
+
for name in sorted(os.listdir(sdir)):
|
|
1299
|
+
if not name.endswith(".md"): continue
|
|
1300
|
+
path = os.path.join(sdir, name)
|
|
1301
|
+
if not os.path.isfile(path): continue
|
|
1302
|
+
fm = frontmatter(path)
|
|
1303
|
+
sid = fm.get("id") or name[:-3]
|
|
1304
|
+
# An unparseable story is reported as open rather than skipped: a story the scan
|
|
1305
|
+
# cannot read is exactly the one whose requirements must not move unannounced.
|
|
1306
|
+
status = (fm.get("status") or "open").strip().lower()
|
|
1307
|
+
cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
|
|
1308
|
+
stories.append({"id": sid, "status": status, "exercises": cites,
|
|
1309
|
+
"in_flight": os.path.isdir(os.path.join(sdir, sid))})
|
|
1310
|
+
|
|
1311
|
+
open_stories = [s for s in stories if s["status"] == "open"]
|
|
1312
|
+
covered = sorted({c for s in open_stories for c in s["exercises"]})
|
|
1313
|
+
|
|
1314
|
+
# ASKING ABOUT NOTHING IS NOT ASKING ABOUT EVERYTHING. `--ids ""` is a caller saying "this
|
|
1315
|
+
# run disturbed no requirement" — a pure addition — and the answer is an empty set. Only
|
|
1316
|
+
# the absence of the flag means "tell me about the whole backlog". Conflating the two made
|
|
1317
|
+
# an enhancement that touched nothing existing prompt about every requirement with a story.
|
|
1318
|
+
targets = queried if os.environ.get("AW_IDS_GIVEN") else covered
|
|
1319
|
+
|
|
1320
|
+
affected, aff_ids, aff_stories, inflight = [], [], [], []
|
|
1321
|
+
for rid in targets:
|
|
1322
|
+
hit = [s for s in open_stories if rid in s["exercises"]]
|
|
1323
|
+
if not hit: continue
|
|
1324
|
+
aff_ids.append(rid)
|
|
1325
|
+
names = [s["id"] for s in hit]
|
|
1326
|
+
flying = [s["id"] for s in hit if s["in_flight"]]
|
|
1327
|
+
aff_stories += names; inflight += flying
|
|
1328
|
+
affected.append({"id": rid, "stories": names, "in_flight": flying})
|
|
1329
|
+
|
|
1330
|
+
print(json.dumps({
|
|
1331
|
+
"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
|
|
1332
|
+
"story_count": len(stories), "open_count": len(open_stories),
|
|
1333
|
+
"queried": queried, "covered": covered, "covered_csv": ", ".join(covered),
|
|
1334
|
+
"affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
|
|
1335
|
+
"story_csv": ", ".join(sorted(set(aff_stories))),
|
|
1336
|
+
"in_flight_csv": ", ".join(sorted(set(inflight))),
|
|
1337
|
+
"any": bool(aff_ids), "any_in_flight": bool(inflight),
|
|
1338
|
+
}))
|
|
1339
|
+
'@
|
|
1340
|
+
|
|
1341
|
+
function Invoke-StoryRefs {
|
|
1342
|
+
param([string[]]$Rest)
|
|
1343
|
+
$fdArg = ''; $ids = ''; $idsGiven = ''
|
|
1344
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1345
|
+
switch ($Rest[$i]) {
|
|
1346
|
+
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
1347
|
+
'--ids' { $ids = $Rest[++$i]; $idsGiven = '1' }
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
$repo = Get-RepoRoot
|
|
1351
|
+
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1352
|
+
if (-not $fd) { Write-Error 'story-refs: no feature set and no --feature-dir'; exit 1 }
|
|
1353
|
+
$py = Get-Py; if (-not $py) { Write-Error 'story-refs: python3 required'; exit 1 }
|
|
1354
|
+
$env:AW_FD = $fd; $env:AW_IDS = $ids; $env:AW_IDS_GIVEN = $idsGiven
|
|
1355
|
+
$PyStoryRefs | & $py -
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
# Rewrite the spec's Story Coverage block from the stories/ directory: every FR- and BR-
|
|
1359
|
+
# in the document, and the stories that deliver it. The relation is stored ONCE, in each
|
|
1360
|
+
# story's `exercises:` frontmatter; this is that relation read the other way round, which
|
|
1361
|
+
# is why the block is regenerated wholesale rather than patched.
|
|
1362
|
+
#
|
|
1363
|
+
# Fenced out of the ledger on purpose — spec-check, spec-diff and ledger-append all cut
|
|
1364
|
+
# the block out before they look, so writing it is not a spec edit and a story raised
|
|
1365
|
+
# against FR-003 does not read as FR-003 having changed.
|
|
1366
|
+
$PySpecCoverage = @'
|
|
1367
|
+
import json, os, re, sys
|
|
1368
|
+
fd = os.environ["AW_FD"]
|
|
1369
|
+
spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
|
|
1370
|
+
BEGIN, END = "<!-- AW:COVERAGE:BEGIN -->", "<!-- AW:COVERAGE:END -->"
|
|
1371
|
+
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
1372
|
+
def parse_fm(text):
|
|
1373
|
+
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
1374
|
+
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
1375
|
+
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
1376
|
+
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
1377
|
+
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
1378
|
+
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
1379
|
+
if not m: return {}
|
|
1380
|
+
fm, key, block = {}, None, set()
|
|
1381
|
+
for line in m.group(1).split("\n"):
|
|
1382
|
+
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
1383
|
+
if km:
|
|
1384
|
+
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
1385
|
+
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
1386
|
+
if im and (key in block or not fm[key]):
|
|
1387
|
+
block.add(key)
|
|
1388
|
+
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
1389
|
+
return fm
|
|
1390
|
+
out = {"ok": True, "written": False}
|
|
1391
|
+
if not os.path.isfile(spec):
|
|
1392
|
+
out.update(ok=False, error="no spec.md at %s" % fd); print(json.dumps(out)); sys.exit(1)
|
|
1393
|
+
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
1394
|
+
if not COV.search(text):
|
|
1395
|
+
# A spec from before the block existed, or one whose markers were deleted. Not an
|
|
1396
|
+
# error: the coverage view is an aid, and refusing to run would block the change the
|
|
1397
|
+
# caller actually came to make.
|
|
1398
|
+
out.update(reason="no AW:COVERAGE block in spec.md"); print(json.dumps(out)); sys.exit(0)
|
|
1399
|
+
|
|
1400
|
+
body = COV.sub("", text)
|
|
1401
|
+
rids = []
|
|
1402
|
+
for line in body.split("\n"):
|
|
1403
|
+
m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
|
|
1404
|
+
if m and m.group(1) not in rids: rids.append(m.group(1))
|
|
1405
|
+
rids.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
1406
|
+
|
|
1407
|
+
stories = []
|
|
1408
|
+
if os.path.isdir(sdir):
|
|
1409
|
+
for name in sorted(os.listdir(sdir)):
|
|
1410
|
+
if not name.endswith(".md"): continue
|
|
1411
|
+
p = os.path.join(sdir, name)
|
|
1412
|
+
if not os.path.isfile(p): continue
|
|
1413
|
+
d = parse_fm(open(p, encoding="utf-8", errors="replace").read())
|
|
1414
|
+
sid = d.get("id") or name[:-3]
|
|
1415
|
+
stories.append({"id": sid, "status": (d.get("status") or "open").strip().lower(),
|
|
1416
|
+
"cites": re.findall(r'[A-Z]{2}-\d+', d.get("exercises", "")),
|
|
1417
|
+
"flying": os.path.isdir(os.path.join(sdir, sid))})
|
|
1418
|
+
|
|
1419
|
+
rows, uncovered, delivered = [], [], []
|
|
1420
|
+
for rid in rids:
|
|
1421
|
+
live = [s for s in stories if rid in s["cites"] and s["status"] == "open"]
|
|
1422
|
+
done = [s for s in stories if rid in s["cites"] and s["status"] != "open"]
|
|
1423
|
+
if not live and not done: uncovered.append(rid)
|
|
1424
|
+
# DELIVERED is "no OPEN stories", never "a story closed" — a follow-on can still carry
|
|
1425
|
+
# the remainder, and keying on a closed story would read a half-built requirement as done.
|
|
1426
|
+
elif not live: delivered.append(rid)
|
|
1427
|
+
rows.append("| %s | %s | %s |" % (
|
|
1428
|
+
rid,
|
|
1429
|
+
", ".join(s["id"] + (" (in flight)" if s["flying"] else "") for s in live) or "—",
|
|
1430
|
+
", ".join(s["id"] for s in done) or "—"))
|
|
1431
|
+
|
|
1432
|
+
table = ["| Requirement | Open stories | Closed / cancelled |",
|
|
1433
|
+
"|-------------|--------------|--------------------|"]
|
|
1434
|
+
table += rows or ["| _(no FR- or BR- requirements yet)_ | | |"]
|
|
1435
|
+
open(spec, "w", encoding="utf-8").write(COV.sub(BEGIN + "\n" + "\n".join(table) + "\n" + END, text, count=1))
|
|
1436
|
+
out.update(written=True, requirements=len(rids), stories=len(stories),
|
|
1437
|
+
uncovered=uncovered, uncovered_csv=", ".join(uncovered),
|
|
1438
|
+
delivered=delivered, delivered_csv=", ".join(delivered))
|
|
1439
|
+
print(json.dumps(out))
|
|
1440
|
+
'@
|
|
1441
|
+
|
|
1442
|
+
# Set subtraction with a purpose: of the requirements this run disturbed, which ones was
|
|
1443
|
+
# it NOT given permission to change. The approved list comes from a multi-select ask, so
|
|
1444
|
+
# the complement is what the re-author has to restore. Its own subcommand because the
|
|
1445
|
+
# orchestrator has no expressions — a workflow can bind a value, never compute one.
|
|
1446
|
+
$PySpecPreserve = @'
|
|
1447
|
+
import json, os, re
|
|
1448
|
+
def ids(s):
|
|
1449
|
+
seen, out = set(), []
|
|
1450
|
+
for t in re.findall(r'[A-Z]{2}-\d+', s or ""):
|
|
1451
|
+
if t not in seen: seen.add(t); out.append(t)
|
|
1452
|
+
return out
|
|
1453
|
+
mode = os.environ.get("AW_MODE", "complement")
|
|
1454
|
+
dist, ok = ids(os.environ.get("AW_DIST", "")), ids(os.environ.get("AW_OK", ""))
|
|
1455
|
+
if mode == "verify":
|
|
1456
|
+
# DID THE RESTORE ACTUALLY HOLD. The re-author is a model asked to put requirements back
|
|
1457
|
+
# to their earlier wording; nothing until now checked that it did. The diff taken after
|
|
1458
|
+
# it is the proof: a requirement the author refused to move must no longer appear in the
|
|
1459
|
+
# disturbing set. One that still does was amended WITHOUT approval — and the run would
|
|
1460
|
+
# otherwise report it, in the same breath, as having been left alone.
|
|
1461
|
+
pre = ids(os.environ.get("AW_PRE", ""))
|
|
1462
|
+
violated = [d for d in pre if d in dist]
|
|
1463
|
+
held = [d for d in pre if d not in dist]
|
|
1464
|
+
print(json.dumps({"ok": not violated, "mode": "verify", "preserved": pre,
|
|
1465
|
+
"held": held, "held_csv": ", ".join(held), "held_count": len(held),
|
|
1466
|
+
"violated": violated, "violated_csv": ", ".join(violated),
|
|
1467
|
+
"violated_count": len(violated), "restore_ok": not violated}))
|
|
1468
|
+
else:
|
|
1469
|
+
preserve = [d for d in dist if d not in ok]
|
|
1470
|
+
print(json.dumps({"ok": True, "mode": "complement", "disturbing": dist, "approved": ok,
|
|
1471
|
+
"preserve": preserve,
|
|
1472
|
+
"preserve_csv": ", ".join(preserve), "preserve_count": len(preserve),
|
|
1473
|
+
"approved_csv": ", ".join(ok)}))
|
|
1474
|
+
'@
|
|
1475
|
+
|
|
1476
|
+
function Invoke-SpecCoverage {
|
|
1477
|
+
param([string[]]$Rest)
|
|
1478
|
+
$fdArg = ''
|
|
1479
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--feature-dir') { $fdArg = $Rest[++$i] } }
|
|
1480
|
+
$repo = Get-RepoRoot
|
|
1481
|
+
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1482
|
+
if (-not $fd) { Write-Error 'spec-coverage: no feature set and no --feature-dir'; exit 1 }
|
|
1483
|
+
$py = Get-Py; if (-not $py) { Write-Error 'spec-coverage: python3 required'; exit 1 }
|
|
1484
|
+
$env:AW_FD = $fd
|
|
1485
|
+
$PySpecCoverage | & $py -
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function Invoke-SpecPreserve {
|
|
1489
|
+
param([string[]]$Rest)
|
|
1490
|
+
$dist = ''; $ok = ''; $pre = ''; $mode = 'complement'
|
|
1491
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1492
|
+
switch ($Rest[$i]) {
|
|
1493
|
+
'--disturbing' { $dist = $Rest[++$i] }
|
|
1494
|
+
'--approved' { $ok = $Rest[++$i] }
|
|
1495
|
+
'--preserved' { $pre = $Rest[++$i] }
|
|
1496
|
+
'--verify' { $mode = 'verify' }
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
$py = Get-Py; if (-not $py) { Write-Error 'spec-preserve: python3 required'; exit 1 }
|
|
1500
|
+
$env:AW_DIST = $dist; $env:AW_OK = $ok; $env:AW_PRE = $pre; $env:AW_MODE = $mode
|
|
1501
|
+
$PySpecPreserve | & $py -
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
# ────────────────────────────── spec-markers ───────────────────────────────
|
|
1505
|
+
# Are there any [NEEDS CLARIFICATION] markers to resolve. Deterministic and free, which is
|
|
1506
|
+
# the point: asking a model "are there any markers" costs a full round trip on every run to
|
|
1507
|
+
# hear "no" on most of them. The model is still what turns a marker into a question with
|
|
1508
|
+
# answerable options — this only decides whether to call it at all.
|
|
1509
|
+
$PySpecMarkers = @'
|
|
1510
|
+
import json, os, re
|
|
1511
|
+
spec = os.environ["AW_SPEC"]
|
|
1512
|
+
text = open(spec, encoding="utf-8", errors="replace").read() if os.path.isfile(spec) else ""
|
|
1513
|
+
found = [" ".join(f.split()) for f in re.findall(r'\[NEEDS CLARIFICATION:\s*(.*?)\]', text, re.S)]
|
|
1514
|
+
print(json.dumps({"ok": True, "spec_exists": bool(text), "markers": found,
|
|
1515
|
+
"marker_count": len(found), "any": bool(found)}))
|
|
1516
|
+
'@
|
|
1517
|
+
|
|
1518
|
+
function Invoke-SpecMarkers {
|
|
1519
|
+
param([string[]]$Rest)
|
|
1520
|
+
$fdArg = ''
|
|
1521
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--feature-dir') { $fdArg = $Rest[++$i] } }
|
|
1522
|
+
$repo = Get-RepoRoot
|
|
1523
|
+
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1524
|
+
if (-not $fd) { Write-Error 'spec-markers: no feature set and no --feature-dir'; exit 1 }
|
|
1525
|
+
$py = Get-Py; if (-not $py) { Write-Error 'spec-markers: python3 required'; exit 1 }
|
|
1526
|
+
$env:AW_SPEC = (Join-Path $fd 'spec.md')
|
|
1527
|
+
$PySpecMarkers | & $py -
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function Invoke-SpecLedger { # op in check|stamp|append|rowids
|
|
1531
|
+
param([string]$Op, [string[]]$Rest)
|
|
1532
|
+
$fdArg = ''; $ids = ''; $summary = ''
|
|
1533
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1534
|
+
switch ($Rest[$i]) {
|
|
1535
|
+
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
1536
|
+
'--ids' { $ids = $Rest[++$i] }
|
|
1537
|
+
'--summary' { $summary = $Rest[++$i] }
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
$repo = Get-RepoRoot
|
|
1541
|
+
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1542
|
+
if (-not $fd) { Write-Error "$Op`: no feature set and no --feature-dir"; exit 1 }
|
|
1543
|
+
$py = Get-Py; if (-not $py) { Write-Error "$Op`: python3 required"; exit 1 }
|
|
1544
|
+
# The delta-spec template, so `check` can tell an AUTHORED delta from the untouched copy
|
|
1545
|
+
# cr-setup scaffolds for every change it opens.
|
|
1546
|
+
$dtpl = @(Resolve-NamedTemplate -Name 'delta-spec-template' -RepoRoot $repo) | Select-Object -Last 1
|
|
1547
|
+
$env:AW_FD = $fd; $env:AW_OP = $Op; $env:AW_IDS = $ids; $env:AW_SUM = $summary
|
|
1548
|
+
$env:AW_DTPL = if ($dtpl) { [string]$dtpl } else { '' }
|
|
1549
|
+
$PySpecLedger | & $py -
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function Invoke-SpecSnapshot {
|
|
1553
|
+
param([string[]]$Rest)
|
|
1554
|
+
$fdArg = ''
|
|
1555
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) { if ($Rest[$i] -eq '--feature-dir') { $fdArg = $Rest[++$i] } }
|
|
1556
|
+
$repo = Get-RepoRoot
|
|
1557
|
+
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1558
|
+
if (-not $fd) { Write-Error 'spec-snapshot: no feature set and no --feature-dir'; exit 1 }
|
|
1559
|
+
$spec = Join-Path $fd 'spec.md'
|
|
1560
|
+
$snap = ''
|
|
1561
|
+
if (Test-Path $spec) {
|
|
1562
|
+
# BESIDE THE SPEC, not in the system temp directory. Only the PATH is replay-cached,
|
|
1563
|
+
# and the step is not re-run on resume — so a checkpointed run paused for a human
|
|
1564
|
+
# answer could outlive the temp file. A cleaned temp dir then left spec-diff with no
|
|
1565
|
+
# before-state at all: every id read as ADDED, nothing MODIFIED, so the E38 ask never
|
|
1566
|
+
# fired, the ledger row named every requirement in the document, and every open story
|
|
1567
|
+
# went stale for a change that touched none of them. Beside the spec it shares the
|
|
1568
|
+
# file's lifetime and travels with the repository.
|
|
1569
|
+
#
|
|
1570
|
+
# One snapshot per feature, overwritten each run: a run diffs against it TWICE, once
|
|
1571
|
+
# to see what changed and again after re-authoring a change made additively.
|
|
1572
|
+
$snap = Join-Path $fd '.aw-snapshot.md'
|
|
1573
|
+
Copy-Item $spec $snap -Force
|
|
1574
|
+
}
|
|
1575
|
+
Write-Json ([ordered]@{ snapshot = $snap; taken = [bool]$snap })
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function Invoke-SpecDiff {
|
|
1579
|
+
param([string[]]$Rest)
|
|
1580
|
+
$fdArg = ''; $snap = ''
|
|
1581
|
+
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
1582
|
+
switch ($Rest[$i]) {
|
|
1583
|
+
'--feature-dir' { $fdArg = $Rest[++$i] }
|
|
1584
|
+
'--snapshot' { $snap = $Rest[++$i] }
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
$repo = Get-RepoRoot
|
|
1588
|
+
$fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
|
|
1589
|
+
if (-not $fd) { Write-Error 'spec-diff: no feature set and no --feature-dir'; exit 1 }
|
|
1590
|
+
$py = Get-Py; if (-not $py) { Write-Error 'spec-diff: python3 required'; exit 1 }
|
|
1591
|
+
$env:AW_SNAP = $snap; $env:AW_SPEC = (Join-Path $fd 'spec.md')
|
|
1592
|
+
$PySpecDiff | & $py -
|
|
1593
|
+
}
|
|
1594
|
+
|
|
852
1595
|
# ─────────────────────────── feature-paths ─────────────────────────────────
|
|
853
1596
|
function Invoke-FeaturePaths {
|
|
854
1597
|
param([string[]]$Rest)
|
|
@@ -875,6 +1618,14 @@ function Invoke-FeaturePaths {
|
|
|
875
1618
|
$spec = Join-Path $fd 'spec.md'
|
|
876
1619
|
$design = Join-Path $fd 'design.md'
|
|
877
1620
|
$uiDesign = Join-Path $fd 'design.ui.md'
|
|
1621
|
+
# needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
|
|
1622
|
+
# leaves spec.md on disk with the marker still up, and branching on the file alone read
|
|
1623
|
+
# that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
|
|
1624
|
+
$bootstrapPending = $false
|
|
1625
|
+
$avFile = Join-Path $fd 'active_version.json'
|
|
1626
|
+
if (Test-Path $avFile) {
|
|
1627
|
+
try { $bootstrapPending = [bool](Get-Content $avFile -Raw | ConvertFrom-Json).bootstrap_pending } catch { $bootstrapPending = $false }
|
|
1628
|
+
}
|
|
878
1629
|
Write-Json ([ordered]@{
|
|
879
1630
|
feature = $feature
|
|
880
1631
|
feature_dir = $fd
|
|
@@ -884,6 +1635,8 @@ function Invoke-FeaturePaths {
|
|
|
884
1635
|
spec_exists = (Test-Path $spec)
|
|
885
1636
|
design_exists = (Test-Path $design)
|
|
886
1637
|
ui_design_exists = (Test-Path $uiDesign)
|
|
1638
|
+
bootstrap_pending = $bootstrapPending
|
|
1639
|
+
needs_bootstrap = ((-not (Test-Path $spec)) -or $bootstrapPending)
|
|
887
1640
|
})
|
|
888
1641
|
}
|
|
889
1642
|
|
|
@@ -925,10 +1678,15 @@ function Invoke-ChecklistRead {
|
|
|
925
1678
|
|
|
926
1679
|
function Invoke-ChecklistApply {
|
|
927
1680
|
param([string[]]$Rest)
|
|
928
|
-
$file = ''; $resultsRaw = '{}'
|
|
1681
|
+
$file = ''; $resultsRaw = '{}'; $allPassRaw = ''
|
|
929
1682
|
for ($i = 0; $i -lt $Rest.Count; $i++) {
|
|
930
|
-
switch ($Rest[$i]) { '--file' { $file = $Rest[++$i] } '--results' { $resultsRaw = $Rest[++$i] } }
|
|
1683
|
+
switch ($Rest[$i]) { '--file' { $file = $Rest[++$i] } '--results' { $resultsRaw = $Rest[++$i] } '--all-pass' { $allPassRaw = $Rest[++$i] } }
|
|
931
1684
|
}
|
|
1685
|
+
# The per-item map is the model's, and a model that judged the document to pass routinely
|
|
1686
|
+
# returns only the items it looked hard at — or none at all. Scoring that map alone then
|
|
1687
|
+
# reported "0/25 items passing" for a spec the same call had just passed, which is the one
|
|
1688
|
+
# quality signal this phase emits and the fastest way to teach people to ignore it.
|
|
1689
|
+
$allPass = @('true', '1', 'yes') -contains $allPassRaw.Trim().ToLower()
|
|
932
1690
|
if (-not $file) { Write-Error 'checklist-apply: --file required'; exit 1 }
|
|
933
1691
|
if (-not (Test-Path $file)) { Write-Json ([ordered]@{ exists = $false; before_checked = 0; after_checked = 0; total = 0; newly_passing = @(); regressions = @() }); return }
|
|
934
1692
|
$results = @{}
|
|
@@ -938,7 +1696,7 @@ function Invoke-ChecklistApply {
|
|
|
938
1696
|
$before = @($items | Where-Object { $_.checked }).Count
|
|
939
1697
|
$newly = @(); $regress = @(); $after = 0
|
|
940
1698
|
foreach ($it in $items) {
|
|
941
|
-
$desired = if ($results.ContainsKey($it.n)) { $results[$it.n] } else { $it.checked }
|
|
1699
|
+
$desired = if ($allPass) { $true } elseif ($results.ContainsKey($it.n)) { $results[$it.n] } else { $it.checked }
|
|
942
1700
|
if ($desired -ne $it.checked) { if ($desired) { $newly += $it.n } else { $regress += $it.n } }
|
|
943
1701
|
if ($desired) { $after++ }
|
|
944
1702
|
$marker = if ($desired) { 'x' } else { ' ' }
|
|
@@ -2203,6 +2961,16 @@ switch ($sub) {
|
|
|
2203
2961
|
'set-feature' { Invoke-SetFeature -Rest $rest }
|
|
2204
2962
|
'version-read' { Invoke-VersionState 'read' $rest }
|
|
2205
2963
|
'version-init' { Invoke-VersionState 'init' $rest }
|
|
2964
|
+
'spec-check' { Invoke-SpecLedger 'check' $rest }
|
|
2965
|
+
'spec-stamp' { Invoke-SpecLedger 'stamp' $rest }
|
|
2966
|
+
'ledger-append' { Invoke-SpecLedger 'append' $rest }
|
|
2967
|
+
'ledger-ids' { Invoke-SpecLedger 'rowids' $rest }
|
|
2968
|
+
'spec-snapshot' { Invoke-SpecSnapshot -Rest $rest }
|
|
2969
|
+
'spec-diff' { Invoke-SpecDiff -Rest $rest }
|
|
2970
|
+
'story-refs' { Invoke-StoryRefs -Rest $rest }
|
|
2971
|
+
'spec-coverage' { Invoke-SpecCoverage -Rest $rest }
|
|
2972
|
+
'spec-preserve' { Invoke-SpecPreserve -Rest $rest }
|
|
2973
|
+
'spec-markers' { Invoke-SpecMarkers -Rest $rest }
|
|
2206
2974
|
'feature-paths' { Invoke-FeaturePaths -Rest $rest }
|
|
2207
2975
|
'design-scaffold' { Invoke-DesignScaffold -Rest $rest }
|
|
2208
2976
|
'build-compile' { Invoke-BuildCompile -Rest $rest }
|