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.
Files changed (58) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +17 -5
  2. package/assets/.appweaver/orchestrator/README.md +1 -1
  3. package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +2 -2
  4. package/assets/.appweaver/orchestrator/adapters/google_antigravity.py +28 -6
  5. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +13 -1
  6. package/assets/.appweaver/orchestrator/orchestrator.py +126 -39
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +5 -0
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -0
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1 -0
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +888 -141
  11. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +826 -16
  12. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +780 -12
  13. package/assets/.appweaver/stack-profiles/java-spring.json +3 -3
  14. package/assets/.appweaver/templates/spec-template.md +73 -62
  15. package/assets/.appweaver/templates/story-template.md +107 -0
  16. package/assets/guardrails/technology/java-spring/ai-rules.md +505 -0
  17. package/assets/guardrails/technology/java-spring/api-versioning.md +782 -0
  18. package/assets/guardrails/technology/java-spring/authorization.md +618 -0
  19. package/assets/guardrails/technology/java-spring/clean-architecture.md +960 -0
  20. package/assets/guardrails/technology/java-spring/coding-standards.md +821 -0
  21. package/assets/guardrails/technology/java-spring/dependency-policy.md +91 -0
  22. package/assets/guardrails/technology/java-spring/domain-driven-design.md +467 -0
  23. package/assets/guardrails/technology/java-spring/domain_abstraction.md +424 -0
  24. package/assets/guardrails/technology/java-spring/folder-structure.md +1537 -0
  25. package/assets/guardrails/technology/java-spring/global_exception_handler.md +247 -0
  26. package/assets/guardrails/technology/java-spring/logging.md +215 -0
  27. package/assets/guardrails/technology/java-spring/modular-monolith.md +77 -0
  28. package/assets/guardrails/technology/java-spring/persistence.md +316 -0
  29. package/assets/guardrails/technology/java-spring/problem_details.md +98 -0
  30. package/assets/guardrails/technology/java-spring/result_mapping.md +48 -0
  31. package/assets/guardrails/technology/java-spring/technology-constitution.md +168 -0
  32. package/assets/guardrails/technology/java-spring/testing.md +601 -0
  33. package/assets/guardrails/technology/java-spring/zero-trust.md +210 -0
  34. package/dist/index.js +0 -0
  35. package/dist/utils/config.d.ts +22 -0
  36. package/dist/utils/config.d.ts.map +1 -0
  37. package/dist/utils/config.js +76 -0
  38. package/dist/utils/config.js.map +1 -0
  39. package/dist/utils/copy-assets.d.ts.map +1 -1
  40. package/dist/utils/copy-assets.js +4 -11
  41. package/dist/utils/copy-assets.js.map +1 -1
  42. package/dist/utils/paths.d.ts.map +1 -1
  43. package/dist/utils/paths.js +11 -7
  44. package/dist/utils/paths.js.map +1 -1
  45. package/package.json +2 -1
  46. package/assets/guardrails/technology/java-spring/README.md +0 -62
  47. package/dist/commands/update.d.ts +0 -3
  48. package/dist/commands/update.d.ts.map +0 -1
  49. package/dist/commands/update.js +0 -142
  50. package/dist/commands/update.js.map +0 -1
  51. package/dist/utils/manifest.d.ts +0 -31
  52. package/dist/utils/manifest.d.ts.map +0 -1
  53. package/dist/utils/manifest.js +0 -91
  54. package/dist/utils/manifest.js.map +0 -1
  55. package/dist/utils/update-engine.d.ts +0 -52
  56. package/dist/utils/update-engine.d.ts.map +0 -1
  57. package/dist/utils/update-engine.js +0 -203
  58. 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
- - [ ] All acceptance scenarios are defined
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
- - [ ] All functional requirements have clear acceptance criteria
478
- - [ ] User scenarios cover primary flows
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
- if not existed: save(d)
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 only)
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++) { if ($Rest[$i] -in '--feature-dir','-FeatureDir') { $fdArg = $Rest[++$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 }