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
@@ -45,7 +45,29 @@
45
45
  # list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ OKF aggregates)
46
46
  # set-feature --feature <name> -> {feature, feature_dir, spec_file, existed, created, spec_exists}
47
47
  # version-read [--feature-dir <path>] -> {feature, version, active_change, exists}
48
- # version-init [--feature-dir <path>] -> {feature, version, active_change, created} (create active_version.json v1.0)
48
+ # version-init [--feature-dir <path>] [--bootstrap]
49
+ # -> {feature, version, active_change, created, bootstrap_pending}
50
+ # (create active_version.json v1.0; --bootstrap marks a first draft in progress)
51
+ # spec-check [--feature-dir <path>] -> {ok, state, version, doc_version, body_changed, spec_sha256, error}
52
+ # (the ledger tripwire: clean | unstamped | ledgered_edit | unledgered_edit | rollback.
53
+ # ok:false on an edit that skipped the Change History — the ledger's only silent case)
54
+ # spec-stamp [--feature-dir <path>] -> {ok, state, version, spec_sha256}
55
+ # (record spec.md's hash as the new baseline; used after a bootstrap authors it)
56
+ # ledger-append [--feature-dir <path>] --ids <ids> [--summary <text>]
57
+ # -> {ok, version, previous, row, spec_sha256}
58
+ # (bump, append the Change History row, stamp the **Version** header and
59
+ # re-hash — ONE call, so a row can never exist without its hash)
60
+ # story-refs [--feature-dir <path>] [--ids <FR-001, BR-002>]
61
+ # -> {covered_csv, affected[], affected_csv, story_csv,
62
+ # in_flight_csv, any, any_in_flight, story_count}
63
+ # (which requirements have OPEN stories against them; no --ids = all of them.
64
+ # Read-only frontmatter scan — the backlog's view of what is being built)
65
+ # spec-coverage [--feature-dir <path>] -> {written, requirements, stories, uncovered_csv, delivered_csv}
66
+ # (regenerate the spec's AW:COVERAGE block — every FR-/BR- and the stories
67
+ # delivering it. Fenced out of the hash and the diff, so writing it is not
68
+ # a spec edit. Called by specify, user-story and update-stories alike)
69
+ # spec-preserve --disturbing <csv> --approved <csv> -> {preserve_csv, preserve_count, approved_csv}
70
+ # (the complement: which disturbed requirements were NOT approved for change)
49
71
  # feature-paths -> {feature, feature_dir, spec_file, design_file, spec_exists, design_exists}
50
72
  # checklist-read --file <path> -> {exists, total, checked, items[]}
51
73
  # checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
@@ -558,7 +580,7 @@ EOF
558
580
  - [ ] Requirements are testable and unambiguous
559
581
  - [ ] Success criteria are measurable
560
582
  - [ ] Success criteria are technology-agnostic (no implementation details)
561
- - [ ] All acceptance scenarios are defined
583
+ - [ ] Every requirement carries a stable id
562
584
  - [ ] Edge cases are identified
563
585
  - [ ] Scope is clearly bounded
564
586
  - [ ] Dependencies and assumptions identified
@@ -579,8 +601,8 @@ EOF
579
601
 
580
602
  ## Feature Readiness
581
603
 
582
- - [ ] All functional requirements have clear acceptance criteria
583
- - [ ] User scenarios cover primary flows
604
+ - [ ] Every functional requirement is stated in EARS form, which is its acceptance criterion
605
+ - [ ] The requirements cover the aggregate's primary flows, so a story can be sliced from them without inventing behaviour
584
606
  - [ ] Feature meets measurable outcomes defined in Success Criteria
585
607
  - [ ] No implementation details leak into specification
586
608
 
@@ -964,11 +986,12 @@ _resolve_feature_dir() { # $1 = --feature-dir arg (may be empty), $2 = repo_ro
964
986
  # one is already open fails (exit 3, ok:false) unless force ("1") is passed — this
965
987
  # stops a second concurrent CR on the same aggregate from silently overwriting the
966
988
  # first. (Concurrency is ACROSS features; changes to one feature are serialized.)
967
- _active_version() { # $1 = abs feature_dir, $2 = op, $3 = cr_id (open only), $4 = force ("1" overrides open-guard)
968
- AW_FD="$1" AW_OP="$2" AW_CR="${3:-}" AW_FORCE="${4:-}" python3 - <<'PY'
989
+ _active_version() { # $1 = abs feature_dir, $2 = op, $3 = cr_id (open only), $4 = force ("1" overrides open-guard), $5 = bootstrap ("1", init only)
990
+ AW_FD="$1" AW_OP="$2" AW_CR="${3:-}" AW_FORCE="${4:-}" AW_BOOT="${5:-}" python3 - <<'PY'
969
991
  import json, os, re, datetime, sys
970
992
  fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]; cr = os.environ.get("AW_CR", "")
971
993
  force = os.environ.get("AW_FORCE", "")
994
+ boot = os.environ.get("AW_BOOT", "")
972
995
  path = os.path.join(fd, "active_version.json")
973
996
  feature = os.path.basename(fd.rstrip("/"))
974
997
  def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -993,7 +1016,23 @@ out = {"feature": d["feature"], "version": d["version"], "active_change": d.get(
993
1016
  if op == "read":
994
1017
  pass
995
1018
  elif op == "init":
996
- if not existed: save(d)
1019
+ # BOOTSTRAP IN PROGRESS, and ONLY when the caller says it is authoring a first draft
1020
+ # (--bootstrap). A first run that dies after writing spec.md but before its ledger row
1021
+ # leaves a document that looks, to the next FRESH invocation, exactly like a spec
1022
+ # written before the ledger existed — `unstamped`, which passes. specify then took the
1023
+ # EXTEND path and applied the same input a second time, on top of a v1.0 that was never
1024
+ # finished, and bumped it to v1.1. Checkpoint resume was always fine; a fresh invocation
1025
+ # was not, and nothing distinguished the two. This marker does. Only ledger-ids clears
1026
+ # it, and only once the v1.0 row actually names ids read out of the finished document —
1027
+ # so a bootstrap whose row never landed is re-run and REPAIRED rather than being frozen
1028
+ # behind a v1.1 that can never go back and fix it.
1029
+ #
1030
+ # The flag is the CALLER's, not this primitive's: adopt inits the same file after
1031
+ # reversing a spec out of existing code, and a marker raised there would tell the next
1032
+ # specify that a finished reversal was a crashed first draft and to overwrite it.
1033
+ if boot:
1034
+ d["bootstrap_pending"] = True
1035
+ save(d)
997
1036
  out["created"] = (not existed)
998
1037
  elif op == "open":
999
1038
  cur = d.get("active_change", "")
@@ -1014,6 +1053,7 @@ elif op == "close":
1014
1053
  d["active_change"] = ""; save(d)
1015
1054
  out["version"] = d["version"]; out["active_change"] = ""
1016
1055
  out["previous"] = prev; out["closed_change"] = closed
1056
+ out["bootstrap_pending"] = bool(d.get("bootstrap_pending"))
1017
1057
  out["has_change"] = bool(out.get("active_change", "")) # clean branch signal for design
1018
1058
  # What a close from THIS state would produce. One bump() authority, read by BOTH
1019
1059
  # finalize-delta (to write the right number into the Change History rows it appends)
@@ -1029,17 +1069,762 @@ cmd_version_read() { _version_cmd "read" "$@"; }
1029
1069
  cmd_version_init() { _version_cmd "init" "$@"; }
1030
1070
  _version_cmd() { # $1 = op (read|init), then --feature-dir arg
1031
1071
  local op="$1"; shift
1032
- local fd_arg=""
1072
+ local fd_arg="" boot=""
1033
1073
  while [ $# -gt 0 ]; do
1034
1074
  case "$1" in
1035
1075
  --feature-dir) fd_arg="$2"; shift 2 ;;
1036
- --help|-h) echo "version-$op [--feature-dir <path>]"; return 0 ;;
1076
+ --bootstrap) boot=1; shift ;; # init only: this caller is authoring a FIRST draft
1077
+ --help|-h) echo "version-$op [--feature-dir <path>] [--bootstrap]"; return 0 ;;
1037
1078
  *) echo "version-$op: unknown arg '$1'" >&2; return 1 ;;
1038
1079
  esac
1039
1080
  done
1040
1081
  local repo_root; repo_root=$(get_repo_root)
1041
1082
  local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "version-$op: no feature set and no --feature-dir" >&2; return 1; }
1042
- _active_version "$fd" "$op"
1083
+ _active_version "$fd" "$op" "" "" "$boot"
1084
+ }
1085
+
1086
+ # ═════════════════════════════ the spec version ledger ══════════════════════
1087
+ # spec.md's `## Change History` is the sync anchor between the spec and its story
1088
+ # backlog: each row names the requirement ids that version touched, and each story
1089
+ # records the version it last synced at, so "is this story stale" is one integer
1090
+ # comparison rather than a diff. update-stories reads the rows; specify and
1091
+ # update-spec append them.
1092
+ #
1093
+ # The ledger is hand-writable, which is the point — a spec can be edited by hand —
1094
+ # and also its one weakness: a body edited with no row leaves every affected story
1095
+ # quietly stale and nothing says so. active_version.json therefore carries a sha256
1096
+ # of spec.md as of the last kit-authored write, and three states follow from
1097
+ # comparing it against the document:
1098
+ #
1099
+ # hash matches clean — nothing has been hand-edited since
1100
+ # hash differs, version stands a hand edit that skipped the ledger -> REFUSE
1101
+ # hash differs, version advanced a hand edit that wrote its own row -> accept
1102
+ #
1103
+ # The middle case is the only silent failure the ledger has, which is why it is the
1104
+ # one thing checked before any workflow writes.
1105
+ _spec_ledger() { # $1 = abs feature_dir, $2 = op, $3 = ids, $4 = summary, $5 = delta-spec template
1106
+ AW_FD="$1" AW_OP="$2" AW_IDS="${3:-}" AW_SUM="${4:-}" AW_DTPL="${5:-}" python3 - <<'PY'
1107
+ import json, os, re, hashlib, datetime, sys
1108
+ fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]
1109
+ ids = os.environ.get("AW_IDS", "").strip(); summary = os.environ.get("AW_SUM", "").strip()
1110
+ spec = os.path.join(fd, "spec.md"); avp = os.path.join(fd, "active_version.json")
1111
+ feature = os.path.basename(fd.rstrip("/\\"))
1112
+
1113
+ def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1114
+ def vtuple(v):
1115
+ m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
1116
+ return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
1117
+ def bump(v):
1118
+ maj, mnr = vtuple(v or "v1.0")
1119
+ return "v%d.%d" % (maj, mnr + 1)
1120
+
1121
+ out = {"feature": feature, "op": op, "ok": True, "spec_exists": os.path.isfile(spec)}
1122
+
1123
+ # A non-zero exit is reported by the orchestrator from STDERR alone, so anything that
1124
+ # stops a run has to say why there as well as in the JSON — otherwise the operator sees
1125
+ # "script failed (ledger-append):" and nothing else.
1126
+ def bail(msg, code):
1127
+ out.update(ok=False, error=msg)
1128
+ print(json.dumps(out)); sys.stderr.write(msg + "\n"); sys.exit(code)
1129
+
1130
+ if not out["spec_exists"]:
1131
+ bail("no spec.md at %s" % fd, 1)
1132
+
1133
+ text = open(spec, encoding="utf-8", errors="replace").read()
1134
+
1135
+ # The Story Coverage block is a projection of the backlog, not contract, and it is written
1136
+ # by the story workflows rather than by whoever owns the spec. Cutting it out before
1137
+ # hashing is what lets both be true at once: creating a story rewrites this document
1138
+ # without that reading as an unledgered edit of the requirements.
1139
+ COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
1140
+ def contract_of(t): return COV.sub("", t)
1141
+ actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
1142
+ av = {}
1143
+ if os.path.isfile(avp):
1144
+ try: av = json.load(open(avp, encoding="utf-8"))
1145
+ except Exception: av = {}
1146
+ recorded_hash = av.get("spec_sha256", "")
1147
+ recorded_ver = av.get("version", "v1.0")
1148
+
1149
+ # The document's own header is what a hand edit bumps; active_version.json is what the
1150
+ # kit last wrote. They agree except across a hand edit, and the disagreement is the signal.
1151
+ m = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
1152
+ doc_ver = m.group(1) if m else ""
1153
+
1154
+ # THE LEDGER TABLE, located once: its heading, its separator rule, and the data rows under
1155
+ # it. Every op that touches a version row works from this, and `check` refuses a document
1156
+ # that has none — because ledger-append discovers a missing table only AFTER the spec has
1157
+ # been rewritten, and a rewritten spec with no row is exactly the state the next run's
1158
+ # tripwire refuses. Finding it at entry turns a wedged feature into a message.
1159
+ lines = text.split("\n")
1160
+ hi = next((i for i, l in enumerate(lines)
1161
+ if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
1162
+ sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
1163
+ len(lines)) if hi >= 0 else -1)
1164
+ sep = (next((i for i in range(hi + 1, sec_end)
1165
+ if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
1166
+ rows = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
1167
+ has_ledger = hi >= 0 and sep >= 0
1168
+
1169
+ def cell(s, limit=160):
1170
+ """One markdown table cell. A newline ENDS a row and a bare pipe STARTS a column, so
1171
+ free text dropped into a cell silently rewrites the row's shape — a summary carrying
1172
+ either one produced a version row whose 'Requirements changed' cell had moved or
1173
+ vanished, which is a change no story can ever be reconciled against."""
1174
+ s = re.sub(r'\s+', ' ', (s or "")).strip().replace("|", "\\|")
1175
+ return (s[:limit - 1] + "\u2026") if len(s) > limit else s
1176
+
1177
+ # IS A DELTA ACTUALLY MEASURED AGAINST THIS MASTER. cr-setup scaffolds delta-spec.md AND
1178
+ # delta-design.md for EVERY change it opens, so the file existing proves nothing. Since
1179
+ # specify lost its delta branch nothing authors a delta-spec at all, and the only changes
1180
+ # opened today are design-only ones carrying an untouched copy of the template — for which
1181
+ # editing spec.md strands nothing. Authored means the bytes have moved off the template;
1182
+ # with no template resolvable, a non-empty file counts.
1183
+ cr = av.get("active_change", "")
1184
+ dtpl = os.environ.get("AW_DTPL", "")
1185
+ dspec = os.path.join(fd, "changes", cr, "delta-spec.md") if cr else ""
1186
+ delta_authored = False
1187
+ if dspec and os.path.isfile(dspec) and os.path.getsize(dspec) > 0:
1188
+ try:
1189
+ delta_authored = (not (dtpl and os.path.isfile(dtpl))
1190
+ or open(dspec, "rb").read() != open(dtpl, "rb").read())
1191
+ except Exception:
1192
+ delta_authored = True
1193
+
1194
+ out.update(open_change=cr, delta_authored=delta_authored)
1195
+ out.update(version=recorded_ver, doc_version=doc_ver, spec_sha256=actual,
1196
+ recorded_sha256=recorded_hash, body_changed=(recorded_hash != actual),
1197
+ has_ledger=has_ledger, ledger_rows=len(rows),
1198
+ first_stamp=(not recorded_hash))
1199
+
1200
+ if op == "check":
1201
+ # An AUTHORED delta-spec was computed against THIS master. Writing the master directly
1202
+ # would leave that delta measured from a baseline that no longer exists, and nothing
1203
+ # downstream re-checks it. The two families overlap only until the delta set is deleted,
1204
+ # so this guard is temporary by design — but while both exist it is the one silent way
1205
+ # they collide. Narrowed to an authored delta on purpose: gating on `active_change`
1206
+ # alone refused every extend for the whole life of a DESIGN-only change, which strands
1207
+ # nothing, and told the operator to finish a chain they may be retiring.
1208
+ if cr and delta_authored:
1209
+ out["state"] = "delta_open"
1210
+ out.update(ok=False, active_change=cr,
1211
+ error="change '%s' is open on this feature and its delta-spec has been "
1212
+ "authored, so a delta is measured against this master. Editing spec.md "
1213
+ "directly would leave that delta computed from a baseline that no longer "
1214
+ "exists. Either finish the change (finalize-delta -> analyze-slice -> "
1215
+ "close-delta), or abandon it: "
1216
+ "`.appweaver/scripts/bash/aw-spec-actions.sh cr-archive --cr-id %s` moves "
1217
+ "changes/%s/ into changes/archive/ and clears the flag. Abandoning also "
1218
+ "bumps the minor version, so the number moves even though nothing shipped."
1219
+ % (cr, cr, cr))
1220
+ elif not has_ledger:
1221
+ out["state"] = "no_ledger"
1222
+ out.update(ok=False,
1223
+ error="spec.md has no '## Change History' table. That table is where a "
1224
+ "story learns a requirement it cites has moved, so a spec without one "
1225
+ "cannot be kept in step with its backlog. Add the section with its "
1226
+ "header row and separator (see the spec template), then re-run.")
1227
+ elif not recorded_hash:
1228
+ out["state"] = "unstamped" # pre-ledger spec; stamp adopts it
1229
+ elif recorded_hash == actual:
1230
+ out["state"] = "clean"
1231
+ elif vtuple(doc_ver) > vtuple(recorded_ver):
1232
+ out["state"] = "ledgered_edit" # hand-edited AND a row was written
1233
+ elif vtuple(doc_ver) < vtuple(recorded_ver):
1234
+ out["state"] = "rollback"
1235
+ out.update(ok=False, error="spec.md reads %s but the kit last wrote %s — the "
1236
+ "document is behind its own ledger" % (doc_ver or "no version", recorded_ver))
1237
+ else:
1238
+ out["state"] = "unledgered_edit"
1239
+ out.update(ok=False, error="spec.md has changed since %s but no Change History row "
1240
+ "was added. Every story citing an edited requirement is now silently "
1241
+ "stale. Add a row naming the ids you changed, then re-run."
1242
+ % recorded_ver)
1243
+ elif op == "stamp":
1244
+ av.setdefault("feature", feature); av.setdefault("active_change", "")
1245
+ av["version"] = doc_ver or recorded_ver
1246
+ av["spec_sha256"] = actual; av["updated"] = now()
1247
+ open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
1248
+ out.update(version=av["version"], state="stamped")
1249
+ elif op == "append":
1250
+ if not ids:
1251
+ # A version that names no ids is a bump nothing can reconcile against: every
1252
+ # story keeps its old stamp and the gap compounds at the next real change.
1253
+ bail("ledger-append: --ids is required — a version row that names no requirement "
1254
+ "ids leaves update-stories nothing to act on", 2)
1255
+ # The next number comes from the highest of all three places a version is written —
1256
+ # what the kit last recorded, what the document's header claims, and the newest row
1257
+ # actually in the table. A hand edit routinely moves one and not the others, and
1258
+ # taking the maximum is what stops the ledger re-issuing a number it has already used.
1259
+ row_vers = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', text, re.M)
1260
+ new_ver = bump(max([recorded_ver, doc_ver] + row_vers, key=vtuple))
1261
+ today = datetime.date.today().isoformat()
1262
+ # Both free-text cells go through cell(): the summary is whatever the operator typed on
1263
+ # the command line, and one newline or one pipe in it destroys the row.
1264
+ row = "| %s | %s | %s | %s | — |" % (
1265
+ new_ver, today, cell(summary) or "Requirements updated", cell(ids))
1266
+
1267
+ # Bounded to the Change History section, not "the last table row in the file". Any
1268
+ # section below it — Story Coverage today — carries its own table, and an unbounded
1269
+ # search would file the version row into whichever one happened to come last.
1270
+ if not has_ledger:
1271
+ bail("spec.md has no '## Change History' table to append to — the ledger is "
1272
+ "where a story learns a requirement moved, so a spec without one cannot be "
1273
+ "kept in step with its backlog. spec.md HAS ALREADY BEEN WRITTEN by this run: "
1274
+ "add the section, then re-run to record what it changed", 2)
1275
+ last = rows[-1] if rows else sep
1276
+ # Duplicate version = two writers reached the same number, which is the collision the
1277
+ # integer buys for free. Refuse rather than append a second row nothing can order.
1278
+ if re.search(r'^\|\s*%s\s*\|' % re.escape(new_ver), text, re.M):
1279
+ bail("the ledger already carries a %s row — another write reached this version "
1280
+ "first; re-read spec.md and retry" % new_ver, 3)
1281
+ lines.insert(last + 1, row)
1282
+
1283
+ stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + new_ver,
1284
+ "\n".join(lines), count=1, flags=re.M)
1285
+ open(spec, "w", encoding="utf-8").write(stamped)
1286
+
1287
+ av.setdefault("feature", feature); av.setdefault("active_change", "")
1288
+ av["version"] = new_ver
1289
+ av["spec_sha256"] = hashlib.sha256(contract_of(stamped).encode("utf-8")).hexdigest()
1290
+ av["updated"] = now()
1291
+ open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
1292
+ out.update(version=new_ver, previous=recorded_ver, row=row, ids=ids,
1293
+ spec_sha256=av["spec_sha256"], state="appended")
1294
+ elif op == "rowids":
1295
+ # THE FIRST ROW, COMPUTED. Every other version row is derived from a before/after diff,
1296
+ # for the reason the header of this file gives: a row the model DECLARED can omit an id
1297
+ # it moved, and an omitted id is silent. The v1.0 row was the one exception — written by
1298
+ # the author from a template whose placeholder literally reads "FR-001…FR-00n", which
1299
+ # update-stories then has to be told not to trust. This op closes that gap: the caller
1300
+ # diffs the finished document against nothing and hands the ids in, exactly as the
1301
+ # extend path does, so the first row is observed like all the rest.
1302
+ if not ids:
1303
+ bail("ledger-ids: --ids is required", 2)
1304
+ if not has_ledger:
1305
+ bail("spec.md has no '## Change History' table to write the initial row into", 2)
1306
+ ver = doc_ver if re.match(r'^v\d+(\.\d+)?$', doc_ver or "") else (recorded_ver or "v1.0")
1307
+ today = datetime.date.today().isoformat()
1308
+ if rows:
1309
+ # Rewrite the newest row's cells in place. A cell split has to respect \| , which is
1310
+ # how cell() escapes a pipe the operator typed — splitting on every pipe would treat
1311
+ # an escaped one as a column break and shift every cell after it.
1312
+ parts = re.split(r'(?<!\\)\|', lines[rows[-1]])
1313
+ body = [c.strip() for c in parts[1:-1]] if len(parts) >= 3 else []
1314
+ while len(body) < 5: body.append("")
1315
+ if not re.match(r'^v\d+(\.\d+)?$', body[0]): body[0] = ver
1316
+ if not re.match(r'^\d{4}-\d{2}-\d{2}$', body[1]): body[1] = today
1317
+ if not body[2] or body[2].startswith("["): body[2] = "Initial specification"
1318
+ body[3] = cell(ids)
1319
+ if not body[4]: body[4] = "—"
1320
+ lines[rows[-1]] = "| " + " | ".join(body[:5]) + " |"
1321
+ else:
1322
+ lines.insert(sep + 1, "| %s | %s | Initial specification | %s | — |"
1323
+ % (ver, today, cell(ids)))
1324
+ stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + ver,
1325
+ "\n".join(lines), count=1, flags=re.M)
1326
+ open(spec, "w", encoding="utf-8").write(stamped)
1327
+ # The v1.0 row now names ids read out of the finished document, which is the moment the
1328
+ # bootstrap is complete. Clearing the marker here and NOWHERE else is what makes an
1329
+ # incomplete first run re-runnable: spec-stamp still re-baselines the hash on every path,
1330
+ # so gating on the hash would have frozen a placeholder row as clean forever.
1331
+ av.pop("bootstrap_pending", None)
1332
+ av.setdefault("feature", feature); av.setdefault("active_change", "")
1333
+ av["version"] = ver; av["updated"] = now()
1334
+ open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
1335
+ out.update(version=ver, ids=ids, row=lines[rows[-1]] if rows else lines[sep + 1],
1336
+ bootstrap_pending=False, state="rowids")
1337
+ print(json.dumps(out))
1338
+ PY
1339
+ }
1340
+
1341
+ # ──────────────────────────────── story-refs ───────────────────────────────
1342
+ # Which requirements have live work against them. A read-only scan of the feature's
1343
+ # stories/ directory, reading only frontmatter — which is why §6.1 puts everything the
1344
+ # gates need there rather than in the body.
1345
+ #
1346
+ # specify asks this twice, for two different purposes. Before authoring, with no --ids,
1347
+ # to tell the author which requirements someone is currently building. After the diff,
1348
+ # with the ids the run actually MODIFIED, to decide whether to ask before changing a
1349
+ # contract under someone's feet.
1350
+ #
1351
+ # Only OPEN stories count. A closed story is history and a cancelled one was abandoned;
1352
+ # neither is disturbed by the contract moving (E4).
1353
+ #
1354
+ # in_flight is the narrower set: an open story that already has a stories/<ID>/ directory,
1355
+ # so someone has run story-spec and is working from a projection of the requirement as it
1356
+ # reads today. That is the same directory-existence predicate change-story-id uses for its
1357
+ # legality boundary — one test, serving three workflows.
1358
+ cmd_story_refs() {
1359
+ local fd_arg="" ids="" ids_given=""
1360
+ while [ $# -gt 0 ]; do
1361
+ case "$1" in
1362
+ --feature-dir) fd_arg="$2"; shift 2 ;;
1363
+ --ids) ids="$2"; ids_given=1; shift 2 ;;
1364
+ --help|-h) echo "story-refs [--feature-dir <path>] [--ids <FR-001, BR-002>]"; return 0 ;;
1365
+ *) echo "story-refs: unknown arg '$1'" >&2; return 1 ;;
1366
+ esac
1367
+ done
1368
+ local repo_root; repo_root=$(get_repo_root)
1369
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-refs: no feature set and no --feature-dir" >&2; return 1; }
1370
+ AW_FD="$fd" AW_IDS="$ids" AW_IDS_GIVEN="$ids_given" python3 - <<'PY'
1371
+ import json, os, re
1372
+ fd = os.environ["AW_FD"]; want = os.environ.get("AW_IDS", "")
1373
+ sdir = os.path.join(fd, "stories")
1374
+ queried = [s.strip() for s in re.split(r'[,\s]+', want) if s.strip()]
1375
+
1376
+ def parse_fm(text):
1377
+ """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1378
+ a YAML block list under an empty key — both are valid YAML and a hand edit produces
1379
+ either, so a parser that read only the inline form saw a perfectly good story as citing
1380
+ nothing, and a story citing nothing is invisible to every gate that exists to protect
1381
+ it. Block items are folded into the inline shape so every caller stays regex-simple."""
1382
+ m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
1383
+ if not m: return {}
1384
+ fm, key, block = {}, None, set()
1385
+ for line in m.group(1).split("\n"):
1386
+ km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
1387
+ if km:
1388
+ key = km.group(1); fm[key] = km.group(2).strip(); continue
1389
+ im = re.match(r'^\s+-\s+(.*)$', line) if key else None
1390
+ if im and (key in block or not fm[key]):
1391
+ block.add(key)
1392
+ fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1393
+ return fm
1394
+
1395
+ def frontmatter(path):
1396
+ try: return parse_fm(open(path, encoding="utf-8", errors="replace").read())
1397
+ except Exception: return {}
1398
+
1399
+ stories = []
1400
+ if os.path.isdir(sdir):
1401
+ for name in sorted(os.listdir(sdir)):
1402
+ if not name.endswith(".md"): continue
1403
+ path = os.path.join(sdir, name)
1404
+ if not os.path.isfile(path): continue
1405
+ fm = frontmatter(path)
1406
+ sid = fm.get("id") or name[:-3]
1407
+ # An unparseable story is reported as open rather than skipped: a story the scan
1408
+ # cannot read is exactly the one whose requirements must not move unannounced.
1409
+ status = (fm.get("status") or "open").strip().lower()
1410
+ cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
1411
+ stories.append({"id": sid, "status": status, "exercises": cites,
1412
+ "in_flight": os.path.isdir(os.path.join(sdir, sid))})
1413
+
1414
+ open_stories = [s for s in stories if s["status"] == "open"]
1415
+ covered = sorted({c for s in open_stories for c in s["exercises"]})
1416
+
1417
+ # ASKING ABOUT NOTHING IS NOT ASKING ABOUT EVERYTHING. `--ids ""` is a caller saying "this
1418
+ # run disturbed no requirement" — a pure addition — and the answer is an empty set. Only
1419
+ # the absence of the flag means "tell me about the whole backlog". Conflating the two made
1420
+ # an enhancement that touched nothing existing prompt about every requirement with a story.
1421
+ targets = queried if os.environ.get("AW_IDS_GIVEN") else covered
1422
+
1423
+ affected, aff_ids, aff_stories, inflight = [], [], [], []
1424
+ for rid in targets:
1425
+ hit = [s for s in open_stories if rid in s["exercises"]]
1426
+ if not hit: continue
1427
+ aff_ids.append(rid)
1428
+ names = [s["id"] for s in hit]
1429
+ flying = [s["id"] for s in hit if s["in_flight"]]
1430
+ aff_stories += names; inflight += flying
1431
+ affected.append({"id": rid, "stories": names, "in_flight": flying})
1432
+
1433
+ print(json.dumps({
1434
+ "ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
1435
+ "story_count": len(stories), "open_count": len(open_stories),
1436
+ "queried": queried, "covered": covered, "covered_csv": ", ".join(covered),
1437
+ "affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
1438
+ "story_csv": ", ".join(sorted(set(aff_stories))),
1439
+ "in_flight_csv": ", ".join(sorted(set(inflight))),
1440
+ "any": bool(aff_ids), "any_in_flight": bool(inflight),
1441
+ }))
1442
+ PY
1443
+ }
1444
+
1445
+ # ────────────────────────────── spec-coverage ──────────────────────────────
1446
+ # Rewrite the spec's Story Coverage block from the stories/ directory: every FR- and BR-
1447
+ # in the document, and the stories that deliver it. The relation is stored ONCE, in each
1448
+ # story's `exercises:` frontmatter; this is that relation read the other way round, which
1449
+ # is why the block is regenerated wholesale rather than patched. Hand edits to it are
1450
+ # discarded by design — the story is where delivery is declared.
1451
+ #
1452
+ # Every workflow that changes either side calls this: specify when the requirements move,
1453
+ # user-story and update-stories when the backlog does. It is deterministic and model-free,
1454
+ # so calling it more often than necessary costs nothing and drift is not possible.
1455
+ #
1456
+ # Scoped to FR-/BR- — the ids a story cites. CN-/DC-/IC-/UX- are constraints the design
1457
+ # traces, not slices of work, and listing them would show permanent gaps as coverage holes.
1458
+ cmd_spec_coverage() {
1459
+ local fd_arg=""
1460
+ while [ $# -gt 0 ]; do
1461
+ case "$1" in
1462
+ --feature-dir) fd_arg="$2"; shift 2 ;;
1463
+ --help|-h) echo "spec-coverage [--feature-dir <path>]"; return 0 ;;
1464
+ *) echo "spec-coverage: unknown arg '$1'" >&2; return 1 ;;
1465
+ esac
1466
+ done
1467
+ local repo_root; repo_root=$(get_repo_root)
1468
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-coverage: no feature set and no --feature-dir" >&2; return 1; }
1469
+ AW_FD="$fd" python3 - <<'PY'
1470
+ import json, os, re, sys
1471
+ fd = os.environ["AW_FD"]
1472
+ spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
1473
+ BEGIN, END = "<!-- AW:COVERAGE:BEGIN -->", "<!-- AW:COVERAGE:END -->"
1474
+ COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
1475
+ def parse_fm(text):
1476
+ """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1477
+ a YAML block list under an empty key — both are valid YAML and a hand edit produces
1478
+ either, so a parser that read only the inline form saw a perfectly good story as citing
1479
+ nothing, and a story citing nothing is invisible to every gate that exists to protect
1480
+ it. Block items are folded into the inline shape so every caller stays regex-simple."""
1481
+ m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
1482
+ if not m: return {}
1483
+ fm, key, block = {}, None, set()
1484
+ for line in m.group(1).split("\n"):
1485
+ km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
1486
+ if km:
1487
+ key = km.group(1); fm[key] = km.group(2).strip(); continue
1488
+ im = re.match(r'^\s+-\s+(.*)$', line) if key else None
1489
+ if im and (key in block or not fm[key]):
1490
+ block.add(key)
1491
+ fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1492
+ return fm
1493
+ out = {"ok": True, "written": False}
1494
+ if not os.path.isfile(spec):
1495
+ out.update(ok=False, error="no spec.md at %s" % fd); print(json.dumps(out)); sys.exit(1)
1496
+ text = open(spec, encoding="utf-8", errors="replace").read()
1497
+ if not COV.search(text):
1498
+ # A spec from before the block existed, or one whose markers were deleted. Not an
1499
+ # error: the coverage view is an aid, and refusing to run would block the change the
1500
+ # caller actually came to make.
1501
+ out.update(reason="no AW:COVERAGE block in spec.md"); print(json.dumps(out)); sys.exit(0)
1502
+
1503
+ body = COV.sub("", text)
1504
+ rids = []
1505
+ for line in body.split("\n"):
1506
+ m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
1507
+ if m and m.group(1) not in rids: rids.append(m.group(1))
1508
+ rids.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1509
+
1510
+ stories = []
1511
+ if os.path.isdir(sdir):
1512
+ for name in sorted(os.listdir(sdir)):
1513
+ if not name.endswith(".md"): continue
1514
+ p = os.path.join(sdir, name)
1515
+ if not os.path.isfile(p): continue
1516
+ d = parse_fm(open(p, encoding="utf-8", errors="replace").read())
1517
+ sid = d.get("id") or name[:-3]
1518
+ stories.append({"id": sid, "status": (d.get("status") or "open").strip().lower(),
1519
+ "cites": re.findall(r'[A-Z]{2}-\d+', d.get("exercises", "")),
1520
+ "flying": os.path.isdir(os.path.join(sdir, sid))})
1521
+
1522
+ rows, uncovered, delivered = [], [], []
1523
+ for rid in rids:
1524
+ live = [s for s in stories if rid in s["cites"] and s["status"] == "open"]
1525
+ done = [s for s in stories if rid in s["cites"] and s["status"] != "open"]
1526
+ if not live and not done: uncovered.append(rid)
1527
+ # DELIVERED is "no OPEN stories", never "a story closed" — a follow-on can still carry
1528
+ # the remainder, and keying on a closed story would read a half-built requirement as done.
1529
+ elif not live: delivered.append(rid)
1530
+ rows.append("| %s | %s | %s |" % (
1531
+ rid,
1532
+ ", ".join(s["id"] + (" (in flight)" if s["flying"] else "") for s in live) or "—",
1533
+ ", ".join(s["id"] for s in done) or "—"))
1534
+
1535
+ table = ["| Requirement | Open stories | Closed / cancelled |",
1536
+ "|-------------|--------------|--------------------|"]
1537
+ table += rows or ["| _(no FR- or BR- requirements yet)_ | | |"]
1538
+ open(spec, "w", encoding="utf-8").write(COV.sub(BEGIN + "\n" + "\n".join(table) + "\n" + END, text, count=1))
1539
+ out.update(written=True, requirements=len(rids), stories=len(stories),
1540
+ uncovered=uncovered, uncovered_csv=", ".join(uncovered),
1541
+ delivered=delivered, delivered_csv=", ".join(delivered))
1542
+ print(json.dumps(out))
1543
+ PY
1544
+ }
1545
+
1546
+ # ────────────────────────────── spec-preserve ──────────────────────────────
1547
+ # Set subtraction with a purpose: of the requirements this run disturbed, which ones was
1548
+ # it NOT given permission to change. The approved list comes from a multi-select ask, so
1549
+ # the complement is what the re-author has to restore. Kept as its own subcommand because
1550
+ # the orchestrator has no expressions — a workflow can bind a value, never compute one.
1551
+ cmd_spec_preserve() {
1552
+ local disturbing="" approved="" preserved="" mode="complement"
1553
+ while [ $# -gt 0 ]; do
1554
+ case "$1" in
1555
+ --disturbing) disturbing="$2"; shift 2 ;;
1556
+ --approved) approved="$2"; shift 2 ;;
1557
+ --preserved) preserved="$2"; shift 2 ;;
1558
+ --verify) mode="verify"; shift ;;
1559
+ --help|-h) echo "spec-preserve --disturbing <csv> --approved <csv>"; echo "spec-preserve --verify --preserved <csv> --disturbing <csv>"; return 0 ;;
1560
+ *) echo "spec-preserve: unknown arg '$1'" >&2; return 1 ;;
1561
+ esac
1562
+ done
1563
+ AW_DIST="$disturbing" AW_OK="$approved" AW_PRE="$preserved" AW_MODE="$mode" python3 - <<'PY'
1564
+ import json, os, re
1565
+ def ids(s):
1566
+ seen, out = set(), []
1567
+ for t in re.findall(r'[A-Z]{2}-\d+', s or ""):
1568
+ if t not in seen: seen.add(t); out.append(t)
1569
+ return out
1570
+ mode = os.environ.get("AW_MODE", "complement")
1571
+ dist, ok = ids(os.environ.get("AW_DIST", "")), ids(os.environ.get("AW_OK", ""))
1572
+ if mode == "verify":
1573
+ # DID THE RESTORE ACTUALLY HOLD. The re-author is a model asked to put requirements back
1574
+ # to their earlier wording; nothing until now checked that it did. The diff taken after
1575
+ # it is the proof: a requirement the author refused to move must no longer appear in the
1576
+ # disturbing set. One that still does was amended WITHOUT approval — and the run would
1577
+ # otherwise report it, in the same breath, as having been left alone.
1578
+ pre = ids(os.environ.get("AW_PRE", ""))
1579
+ violated = [d for d in pre if d in dist]
1580
+ held = [d for d in pre if d not in dist]
1581
+ print(json.dumps({"ok": not violated, "mode": "verify", "preserved": pre,
1582
+ "held": held, "held_csv": ", ".join(held), "held_count": len(held),
1583
+ "violated": violated, "violated_csv": ", ".join(violated),
1584
+ "violated_count": len(violated), "restore_ok": not violated}))
1585
+ else:
1586
+ preserve = [d for d in dist if d not in ok]
1587
+ print(json.dumps({"ok": True, "mode": "complement", "disturbing": dist, "approved": ok,
1588
+ "preserve": preserve,
1589
+ "preserve_csv": ", ".join(preserve), "preserve_count": len(preserve),
1590
+ "approved_csv": ", ".join(ok)}))
1591
+ PY
1592
+ }
1593
+
1594
+ # ────────────────────────────── spec-markers ───────────────────────────────
1595
+ # Are there any [NEEDS CLARIFICATION] markers to resolve. Deterministic and free, which is
1596
+ # the point: asking a model "are there any markers" costs a full round trip on every run to
1597
+ # hear "no" on most of them. The model is still what turns a marker into a question with
1598
+ # answerable options — this only decides whether to call it at all.
1599
+ cmd_spec_markers() {
1600
+ local fd_arg=""
1601
+ while [ $# -gt 0 ]; do
1602
+ case "$1" in
1603
+ --feature-dir) fd_arg="$2"; shift 2 ;;
1604
+ --help|-h) echo "spec-markers [--feature-dir <path>]"; return 0 ;;
1605
+ *) echo "spec-markers: unknown arg '$1'" >&2; return 1 ;;
1606
+ esac
1607
+ done
1608
+ local repo_root; repo_root=$(get_repo_root)
1609
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-markers: no feature set and no --feature-dir" >&2; return 1; }
1610
+ AW_SPEC="$fd/spec.md" python3 - <<'PY'
1611
+ import json, os, re
1612
+ spec = os.environ["AW_SPEC"]
1613
+ text = open(spec, encoding="utf-8", errors="replace").read() if os.path.isfile(spec) else ""
1614
+ found = [" ".join(f.split()) for f in re.findall(r'\[NEEDS CLARIFICATION:\s*(.*?)\]', text, re.S)]
1615
+ print(json.dumps({"ok": True, "spec_exists": bool(text), "markers": found,
1616
+ "marker_count": len(found), "any": bool(found)}))
1617
+ PY
1618
+ }
1619
+
1620
+ # ───────────────────────── spec-snapshot / spec-diff ───────────────────────
1621
+ # What a run CHANGED, observed rather than declared. specify snapshots spec.md before
1622
+ # it authors and diffs after the converge loop has finished with it, so the Change
1623
+ # History row is computed from the document's own requirement ids.
1624
+ #
1625
+ # Deriving the row this way is what makes it trustworthy. A row the model declares can
1626
+ # omit a requirement the converge loop rewrote on its way past — and an omitted id is
1627
+ # the silent case the whole ledger exists to prevent, since update-stories would never
1628
+ # learn that requirement moved. A row computed from the diff cannot omit one.
1629
+ #
1630
+ # Ids are read in both shapes the template uses: the bold form of the list sections
1631
+ # (`- **FR-001**: …`) and the leading-cell form of the Data Classification table
1632
+ # (`| DC-001 | … |`). The line is normalised on whitespace before hashing, so a rewrap
1633
+ # is not a change.
1634
+ cmd_spec_snapshot() {
1635
+ local fd_arg=""
1636
+ while [ $# -gt 0 ]; do
1637
+ case "$1" in
1638
+ --feature-dir) fd_arg="$2"; shift 2 ;;
1639
+ --help|-h) echo "spec-snapshot [--feature-dir <path>]"; return 0 ;;
1640
+ *) echo "spec-snapshot: unknown arg '$1'" >&2; return 1 ;;
1641
+ esac
1642
+ done
1643
+ local repo_root; repo_root=$(get_repo_root)
1644
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-snapshot: no feature set and no --feature-dir" >&2; return 1; }
1645
+ local spec="$fd/spec.md"
1646
+ local snap=""
1647
+ if [ -f "$spec" ]; then
1648
+ # BESIDE THE SPEC, not in the system temp directory. Only the PATH is replay-cached,
1649
+ # and the step is not re-run on resume — so a checkpointed run paused for a human
1650
+ # answer could outlive the temp file. A cleaned temp dir then left spec-diff with no
1651
+ # before-state at all: every id read as ADDED, nothing MODIFIED, so the E38 ask never
1652
+ # fired, the ledger row named every requirement in the document, and every open story
1653
+ # went stale for a change that touched none of them. Beside the spec it shares the
1654
+ # file's lifetime and travels with the repository.
1655
+ #
1656
+ # One snapshot per feature, overwritten each run: a run diffs against it TWICE, once
1657
+ # to see what changed and again after re-authoring a change made additively.
1658
+ snap="$fd/.aw-snapshot.md"
1659
+ cp "$spec" "$snap"
1660
+ fi
1661
+ printf '{"snapshot":"%s","taken":%s}\n' "$(json_escape "$snap")" "$([ -n "$snap" ] && echo true || echo false)"
1662
+ }
1663
+
1664
+ cmd_spec_diff() {
1665
+ local fd_arg="" snap=""
1666
+ while [ $# -gt 0 ]; do
1667
+ case "$1" in
1668
+ --feature-dir) fd_arg="$2"; shift 2 ;;
1669
+ --snapshot) snap="$2"; shift 2 ;;
1670
+ --help|-h) echo "spec-diff --snapshot <path> [--feature-dir <path>]"; return 0 ;;
1671
+ *) echo "spec-diff: unknown arg '$1'" >&2; return 1 ;;
1672
+ esac
1673
+ done
1674
+ local repo_root; repo_root=$(get_repo_root)
1675
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-diff: no feature set and no --feature-dir" >&2; return 1; }
1676
+ AW_SNAP="$snap" AW_SPEC="$fd/spec.md" python3 - <<'PY'
1677
+ import json, os, re, hashlib, sys
1678
+ snap = os.environ.get("AW_SNAP", ""); spec = os.environ["AW_SPEC"]
1679
+ # ASKING FOR NO BEFORE-STATE IS NOT THE SAME AS LOSING ONE. An empty --snapshot is the
1680
+ # bootstrap path saying "there was nothing before this run", and every id is legitimately
1681
+ # added. A path that was given and is not there is the before-state having been LOST, and
1682
+ # answering that with "everything is new" is the loudest possible wrong answer: it marks
1683
+ # every story in the feature stale for a change that touched none of them.
1684
+ if snap and not os.path.isfile(snap):
1685
+ msg = ("spec-diff: the before-state at %s is gone, so this run cannot tell what it "
1686
+ "changed. spec.md HAS been written. Recover by hand: put the ids you changed "
1687
+ "into a new Change History row, then re-run spec-stamp to re-baseline."
1688
+ % snap)
1689
+ print(json.dumps({"ok": False, "error": msg, "had_snapshot": False, "snapshot": snap}))
1690
+ sys.stderr.write(msg + "\n"); sys.exit(2)
1691
+ # An id is keyed to the line that DEFINES it, never to a line that merely mentions it.
1692
+ # The four shapes are scored, best wins, earliest line breaks a tie:
1693
+ # 3 a leading table cell | DC-001 | … |
1694
+ # 3 a bold id opening a bullet - **FR-001**: THE SYSTEM SHALL …
1695
+ # 2 a bold id opening a line **FR-001** … (a definition missing its colon)
1696
+ # 1 a bold id anywhere … narrowing **FR-001**. (a cross-reference)
1697
+ # Scoring rather than "first bold id on the line, last line wins": a note that OPENS with
1698
+ # a cross-reference — "**FR-005** remains in force; **FR-006** narrows it" — re-keyed
1699
+ # FR-005 to that note, so a requirement restored character-for-character was reported as
1700
+ # MODIFIED. That is the worst possible misreading here, because the additive path exists
1701
+ # precisely to leave such a requirement alone, and its own instructions ask the author to
1702
+ # name the id in the new requirement's text.
1703
+ DEF_CELL = re.compile(r'^\s*\|\s*([A-Z]{2}-\d+)\s*\|')
1704
+ DEF_BULL = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*\s*[:\u2014\u2013-]')
1705
+ DEF_OPEN = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*')
1706
+ ANY_BOLD = re.compile(r'\*\*([A-Z]{2}-\d+)\*\*')
1707
+ # The Story Coverage block lists requirement ids in a table, so it reads exactly like the
1708
+ # Data Classification section to DEF_CELL. Left in, every story raised against FR-003 would
1709
+ # show up here as FR-003 having CHANGED — a version row, and every story citing it stale,
1710
+ # because somebody wrote a story.
1711
+ COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
1712
+ # The ledger's own rows name ids in prose cells; they record history, they do not define a
1713
+ # requirement, and the table is stripped before the scan for the same reason as coverage.
1714
+ HIST = re.compile(r'^##\s+Change History\s*$', re.M)
1715
+ def is_def(l):
1716
+ return bool(DEF_CELL.match(l) or DEF_BULL.match(l) or DEF_OPEN.match(l))
1717
+ def scan(path):
1718
+ """{id: (score, line_no, hash)} for every id the document mentions."""
1719
+ best = {}
1720
+ if not path or not os.path.isfile(path): return best
1721
+ text = COV.sub("", open(path, encoding="utf-8", errors="replace").read())
1722
+ hm = HIST.search(text)
1723
+ if hm: text = text[:hm.start()]
1724
+ lines = text.split("\n")
1725
+ for n, line in enumerate(lines):
1726
+ hits = []
1727
+ m = DEF_CELL.match(line)
1728
+ if m: hits.append((3, m.group(1)))
1729
+ m = DEF_BULL.match(line)
1730
+ if m: hits.append((3, m.group(1)))
1731
+ else:
1732
+ m = DEF_OPEN.match(line)
1733
+ if m: hits.append((2, m.group(1)))
1734
+ for rid in ANY_BOLD.findall(line):
1735
+ hits.append((1, rid))
1736
+ for score, rid in hits:
1737
+ if rid not in best or score > best[rid][0]:
1738
+ best[rid] = (score, n)
1739
+ # THE WHOLE REQUIREMENT, not the line its id sits on. A requirement that wraps carries
1740
+ # half its meaning on the continuation lines, and hashing only the id's own line made an
1741
+ # edit below the fold invisible: change_count 0, no ledger row appended, and spec-stamp
1742
+ # re-baselining the new body as clean — the ledger's own silent failure, for every story
1743
+ # citing that id. The block runs to the next definition, heading or blank line, and is
1744
+ # whitespace-normalised as ONE string, so a rewrap across lines is still not a change.
1745
+ out = {}
1746
+ for rid, (score, n) in best.items():
1747
+ block = [lines[n]]
1748
+ for m2 in range(n + 1, len(lines)):
1749
+ nxt = lines[m2]
1750
+ if not nxt.strip() or nxt.lstrip().startswith("#") or is_def(nxt): break
1751
+ block.append(nxt)
1752
+ h = hashlib.sha256(" ".join(" ".join(block).split()).encode()).hexdigest()[:16]
1753
+ out[rid] = (score, n, h)
1754
+ return out
1755
+ def ids(path):
1756
+ return {k: v[2] for k, v in scan(path).items()}
1757
+ # Ids the ledger has already spent. An id is PERMANENT: re-issuing one silently re-points
1758
+ # stories, designs and code that have already shipped. Read from the SNAPSHOT's history, so
1759
+ # it is what the ledger said before this run, and only meaningful when there is a snapshot.
1760
+ def spent(path):
1761
+ if not path or not os.path.isfile(path): return set()
1762
+ text = open(path, encoding="utf-8", errors="replace").read()
1763
+ hm = HIST.search(text)
1764
+ if not hm: return set()
1765
+ return set(re.findall(r'[A-Z]{2}-\d+', text[hm.end():]))
1766
+ before, after = ids(snap), ids(spec)
1767
+ added = sorted(k for k in after if k not in before)
1768
+ removed = sorted(k for k in before if k not in after)
1769
+ modified = sorted(k for k in after if k in before and before[k] != after[k])
1770
+ changed = sorted(set(added) | set(removed) | set(modified))
1771
+ reissued = sorted(set(added) & spent(snap))
1772
+ # THE STORY-FACING SUBSET. A story cites FR- and BR- ids and nothing else, so those are the
1773
+ # only classes the backlog can act on — the same scoping spec-coverage applies, for the same
1774
+ # reason. The scan is deliberately class-blind (CN-/DC-/IC-/UX-/SC- all move the contract and
1775
+ # all belong in the ledger row), but handing that whole set to update-stories as "the
1776
+ # requirements this run touched" asks it to raise a story for a success criterion.
1777
+ STORY_CLASS = ("FR-", "BR-")
1778
+ story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
1779
+ print(json.dumps({
1780
+ "ok": True, "had_snapshot": bool(snap and os.path.isfile(snap)),
1781
+ "added": added, "removed": removed, "modified": modified,
1782
+ "changed": changed, "changed_csv": ", ".join(changed),
1783
+ "change_count": len(changed),
1784
+ "added_csv": ", ".join(added),
1785
+ # removed ids orphan every story citing them (E1) — carried out so the caller can say so
1786
+ "removed_csv": ", ".join(removed),
1787
+ "modified_csv": ", ".join(modified),
1788
+ # What an existing story would feel. An ADDED requirement disturbs nobody: no story
1789
+ # cites an id that did not exist a moment ago. Modifying or removing one moves ground
1790
+ # somebody may already be standing on, which is the set worth asking about.
1791
+ "disturbing": sorted(set(modified) | set(removed)),
1792
+ "disturbing_csv": ", ".join(sorted(set(modified) | set(removed))),
1793
+ # An id this run introduced that the ledger has already spent. Detected, not blocked:
1794
+ # the document is written by the time we can see it, and refusing here would leave a
1795
+ # spec on disk that the next run's entry check would refuse too.
1796
+ "reissued": reissued, "reissued_csv": ", ".join(reissued),
1797
+ "reissued_any": bool(reissued),
1798
+ # changed, narrowed to the classes a story can cite — what update-stories works from.
1799
+ "story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
1800
+ "story_id_count": len(story_ids),
1801
+ }))
1802
+ PY
1803
+ return 0
1804
+ }
1805
+
1806
+ cmd_spec_check() { _spec_ledger_cmd "check" "$@"; }
1807
+ cmd_spec_stamp() { _spec_ledger_cmd "stamp" "$@"; }
1808
+ cmd_ledger_append() { _spec_ledger_cmd "append" "$@"; }
1809
+ cmd_ledger_ids() { _spec_ledger_cmd "rowids" "$@"; }
1810
+ _spec_ledger_cmd() { # $1 = op, then --feature-dir / --ids / --summary
1811
+ local op="$1"; shift
1812
+ local fd_arg="" ids="" summary=""
1813
+ while [ $# -gt 0 ]; do
1814
+ case "$1" in
1815
+ --feature-dir) fd_arg="$2"; shift 2 ;;
1816
+ --ids) ids="$2"; shift 2 ;;
1817
+ --summary) summary="$2"; shift 2 ;;
1818
+ --help|-h) echo "$op [--feature-dir <path>] [--ids <FR-001, BR-002>] [--summary <text>]"; return 0 ;;
1819
+ *) echo "$op: unknown arg '$1'" >&2; return 1 ;;
1820
+ esac
1821
+ done
1822
+ local repo_root; repo_root=$(get_repo_root)
1823
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "$op: no feature set and no --feature-dir" >&2; return 1; }
1824
+ # The delta-spec template, so `check` can tell an AUTHORED delta from the untouched copy
1825
+ # cr-setup scaffolds for every change it opens.
1826
+ local dtpl; dtpl=$(resolve_template "delta-spec-template" "$repo_root") || true
1827
+ _spec_ledger "$fd" "$op" "$ids" "$summary" "$dtpl"
1043
1828
  }
1044
1829
 
1045
1830
 
@@ -1072,12 +1857,21 @@ cmd_feature_paths() {
1072
1857
  # design.ui.md (adopt today) enrolls the feature in the UI half of the delta loop
1073
1858
  # by the act of writing the file.
1074
1859
  local spec="$fd/spec.md" design="$fd/design.md" ui_design="$fd/design.ui.md"
1860
+ # needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
1861
+ # leaves spec.md on disk with the marker still up, and branching on the file alone read
1862
+ # that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
1863
+ local av_file="$fd/active_version.json" bootstrap_pending=false
1864
+ grep -q '"bootstrap_pending"[[:space:]]*:[[:space:]]*true' "$av_file" 2>/dev/null && bootstrap_pending=true
1075
1865
  local spec_exists=false; [ -f "$spec" ] && spec_exists=true
1076
1866
  local design_exists=false; [ -f "$design" ] && design_exists=true
1077
1867
  local ui_design_exists=false; [ -f "$ui_design" ] && ui_design_exists=true
1078
- printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","design_file":"%s","ui_design_file":"%s","spec_exists":%s,"design_exists":%s,"ui_design_exists":%s}\n' \
1868
+ local needs_bootstrap=false
1869
+ [ "$spec_exists" = false ] && needs_bootstrap=true
1870
+ [ "$bootstrap_pending" = true ] && needs_bootstrap=true
1871
+ printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","design_file":"%s","ui_design_file":"%s","spec_exists":%s,"design_exists":%s,"ui_design_exists":%s,"bootstrap_pending":%s,"needs_bootstrap":%s}\n' \
1079
1872
  "$(json_escape "$feature")" "$(json_escape "$fd")" "$(json_escape "$spec")" "$(json_escape "$design")" \
1080
- "$(json_escape "$ui_design")" "$spec_exists" "$design_exists" "$ui_design_exists"
1873
+ "$(json_escape "$ui_design")" "$spec_exists" "$design_exists" "$ui_design_exists" \
1874
+ "$bootstrap_pending" "$needs_bootstrap"
1081
1875
  }
1082
1876
 
1083
1877
  # ────────────────────────── checklist-read / -apply ────────────────────────
@@ -1113,17 +1907,18 @@ PY
1113
1907
  }
1114
1908
 
1115
1909
  cmd_checklist_apply() {
1116
- local file="" results="{}"
1910
+ local file="" results="{}" all_pass=""
1117
1911
  while [ $# -gt 0 ]; do
1118
1912
  case "$1" in
1119
1913
  --file) file="$2"; shift 2 ;;
1120
1914
  --results) results="$2"; shift 2 ;;
1121
- --help|-h) echo "checklist-apply --file <path> --results <json>"; return 0 ;;
1915
+ --all-pass) all_pass="$2"; shift 2 ;;
1916
+ --help|-h) echo "checklist-apply --file <path> --results <json> [--all-pass true|false]"; return 0 ;;
1122
1917
  *) echo "checklist-apply: unknown arg '$1'" >&2; return 1 ;;
1123
1918
  esac
1124
1919
  done
1125
1920
  [ -n "$file" ] || { echo "checklist-apply: --file required" >&2; return 1; }
1126
- AW_FILE="$file" AW_RESULTS="$results" python3 - <<'PY'
1921
+ AW_FILE="$file" AW_RESULTS="$results" AW_ALLPASS="$all_pass" python3 - <<'PY'
1127
1922
  import json, os, re
1128
1923
  f = os.environ["AW_FILE"]
1129
1924
  if not os.path.exists(f):
@@ -1133,6 +1928,11 @@ try:
1133
1928
  results = {int(k): bool(v) for k, v in json.loads(os.environ["AW_RESULTS"] or "{}").items()}
1134
1929
  except Exception:
1135
1930
  results = {}
1931
+ # The per-item map is the model's, and a model that judged the document to pass routinely
1932
+ # returns only the items it looked hard at — or none at all. Scoring that map alone then
1933
+ # reported "0/25 items passing" for a spec the same call had just passed, which is the one
1934
+ # quality signal this phase emits and the fastest way to teach people to ignore it.
1935
+ all_pass = os.environ.get("AW_ALLPASS", "").strip().lower() in ("true", "1", "yes")
1136
1936
  lines = open(f, encoding="utf-8").read().splitlines(keepends=True)
1137
1937
  n, fence = 0, False
1138
1938
  before = after = 0
@@ -1147,7 +1947,7 @@ for line in lines:
1147
1947
  n += 1
1148
1948
  cur = m.group(2) in "xX"
1149
1949
  before += 1 if cur else 0
1150
- desired = results.get(n, cur)
1950
+ desired = True if all_pass else results.get(n, cur)
1151
1951
  if desired != cur:
1152
1952
  if desired: newly.append(n)
1153
1953
  else: regress.append(n)
@@ -2592,6 +3392,16 @@ case "$sub" in
2592
3392
  set-feature) cmd_set_feature "$@" ;;
2593
3393
  version-read) cmd_version_read "$@" ;;
2594
3394
  version-init) cmd_version_init "$@" ;;
3395
+ spec-check) cmd_spec_check "$@" ;;
3396
+ spec-stamp) cmd_spec_stamp "$@" ;;
3397
+ ledger-append) cmd_ledger_append "$@" ;;
3398
+ ledger-ids) cmd_ledger_ids "$@" ;;
3399
+ spec-snapshot) cmd_spec_snapshot "$@" ;;
3400
+ spec-diff) cmd_spec_diff "$@" ;;
3401
+ story-refs) cmd_story_refs "$@" ;;
3402
+ spec-coverage) cmd_spec_coverage "$@" ;;
3403
+ spec-preserve) cmd_spec_preserve "$@" ;;
3404
+ spec-markers) cmd_spec_markers "$@" ;;
2595
3405
  feature-paths) cmd_feature_paths "$@" ;;
2596
3406
  design-scaffold) cmd_design_scaffold "$@" ;;
2597
3407
  build-compile) cmd_build_compile "$@" ;;