aw-spec-cli 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +63 -31
  2. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +62 -14
  3. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +10 -6
  4. package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +9 -1
  5. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +21 -0
  6. package/assets/.appweaver/orchestrator/orchestrator.py +23 -1
  7. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +1 -1
  8. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +22 -13
  9. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +1 -1
  10. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +6 -4
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +399 -131
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +1 -1
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +199 -38
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +1 -1
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +159 -25
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +20 -9
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -1
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +183 -40
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +311 -36
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +221 -40
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +217 -174
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +494 -109
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +1 -1
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +83 -18
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +319 -0
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +1 -1
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +29 -23
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +143 -34
  30. package/assets/.appweaver/scripts/bash/aw-inventory.sh +15 -0
  31. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +878 -51
  32. package/assets/.appweaver/scripts/bash/aw-xref.sh +3 -0
  33. package/assets/.appweaver/scripts/bash/common.sh +28 -1
  34. package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +15 -0
  35. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +868 -51
  36. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +3 -0
  37. package/assets/.appweaver/scripts/python/aw-inventory.py +1832 -65
  38. package/assets/.appweaver/scripts/python/aw-xref.py +500 -81
  39. package/assets/.appweaver/templates/delta-design-template.md +1 -1
  40. package/assets/.appweaver/templates/delta-domain-model-template.md +1 -1
  41. package/assets/.appweaver/templates/delta-spec-template.md +4 -3
  42. package/assets/.appweaver/templates/design-template.md +56 -40
  43. package/assets/.appweaver/templates/okf-concept-template.md +11 -1
  44. package/assets/.appweaver/templates/proposed-inventory-template.md +127 -0
  45. package/assets/.appweaver/templates/source-map-template.md +5 -3
  46. package/assets/.appweaver/templates/spec-template.md +24 -21
  47. package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
  48. package/assets/.appweaver/templates/ui-design-template.md +11 -7
  49. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +4489 -361
  50. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2274 -125
  51. package/assets/guardrails/domain/healthcare/auditing.domain.md +3 -3
  52. package/assets/guardrails/domain/pharma/auditing.domain.md +3 -3
  53. package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
  54. package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
  55. package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
  56. package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
  57. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
  58. package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
  59. package/assets/guardrails/technology/dotnet-webapi/eventing.md +1 -1
  60. package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
  61. package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +1 -1
  62. package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
  63. package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
  64. package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
  65. package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
  66. package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
  67. package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
  68. package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
  69. package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
  70. package/assets/guardrails/technology/nextjs/design-system.md +1 -1
  71. package/assets/guardrails/technology/nextjs/error-handling.md +1 -1
  72. package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
  73. package/assets/guardrails/technology/nextjs/i18n.md +1 -1
  74. package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
  75. package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
  76. package/assets/guardrails/technology/nextjs/state-management.md +1 -1
  77. package/package.json +1 -1
@@ -42,7 +42,7 @@
42
42
  # Always exits 0 — the workflow's verify_context owns the stop)
43
43
  # lane-probe [--kind <k>] [--hint <text>] -> {declared, declared_profile, hint_profile, hint_terms[], signals[], any, evidence}
44
44
  # (the FILE signals for "does this lane exist"; resolve-tech carries the rule signal)
45
- # list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ OKF aggregates)
45
+ # list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ features the OKF concepts serve)
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, bootstrap_pending}
48
48
  # version-init [--feature-dir <path>] [--bootstrap] -> {…, created, bootstrap_pending} (create active_version.json v1.0; --bootstrap marks a FIRST draft in progress)
@@ -80,7 +80,8 @@
80
80
  # run-migrate [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
81
81
  # deploy-dispatch -> {ok, configured, ran, cmd, log_tail}
82
82
  # cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>] [--wave <n>]
83
- # -> {cr_id, cr_dir, delta_spec, delta_model, wave, open_count, created}
83
+ # -> {cr_id, cr_dir, delta_spec, delta_model, wave, open_count, created,
84
+ # created_on, base_version, base_context_version}
84
85
  # cr-list [--feature-dir <path>] [--preselect <cr-id|main|new>] [--include-main] [--include-new]
85
86
  # -> {changes[], ids[], count, select_options[], matched_label, main_label,
86
87
  # new_label, only_id, active_change}
@@ -93,8 +94,16 @@
93
94
  # sibling_overlaps, successor_overlaps, overlap_report}
94
95
  # cr-activate --cr-id <id> [--feature-dir <path>] -> {active_change, activated, open_changes[]}
95
96
  # cr-wave --cr-id <id> --wave <n> [--feature-dir <path>] -> {cr_id, wave, stamped}
96
- # cr-archive --cr-id <id> [--feature-dir <path>]
97
- # -> {ok, archived_dir, version, previous}
97
+ # cr-archive --cr-id <id> [--feature-dir <path>] [--recall] [--reason <text>]
98
+ # -> {ok, archived_dir, archived_model, recalled,
99
+ # recall_marker, version, previous,
100
+ # closed_change, recalled_change, open_changes[]}
101
+ # cr-recall-check --cr-id <id> [--feature-dir <path>]
102
+ # -> {merged, merge_evidence, wave, dependants[],
103
+ # dependant_ids, dependant_count, has_dependants}
104
+ # context-version-bump [--feature-dir <path>] [--cr-id <id>] [--touched-model true|false]
105
+ # -> {ok, bumped, previous, version, model,
106
+ # model_exists, context, reason}
98
107
  #
99
108
  # All subcommands accept --help.
100
109
 
@@ -795,7 +804,13 @@ cmd_lane_verify() {
795
804
  local -a fwd=()
796
805
  while [ $# -gt 0 ]; do
797
806
  case "$1" in
798
- --require) fwd+=("--require" "$2"); shift 2 ;;
807
+ # An EMPTY --require is "any lane will do" and is dropped rather than
808
+ # forwarded: the phase needs a declaration to work from, not a lane of a
809
+ # particular kind. A workflow that includes lane-gate cannot omit an
810
+ # argument conditionally — the include's `with` map is rendered whole —
811
+ # so an empty value is the only way it can say this, and argparse rejects
812
+ # one against --require's choices. Every other check stays armed.
813
+ --require) if [ -n "$2" ]; then fwd+=("--require" "$2"); fi; shift 2 ;;
799
814
  # --flag=value: --command carries a phase name that starts with '/'.
800
815
  --command) fwd+=("--command=$2"); shift 2 ;;
801
816
  --allow-missing-root) fwd+=("--allow-missing-root"); shift ;;
@@ -1029,6 +1044,25 @@ open(p, "w", encoding="utf-8").write(text)
1029
1044
  PY
1030
1045
  }
1031
1046
 
1047
+ # Read one `**Name**: value` header back out of a document — the counterpart of
1048
+ # _stamp_header, for the case where one document's header is the SOURCE of another's.
1049
+ # An UNFILLED template placeholder reads as absent: a header still carrying `[vX.Y]` or
1050
+ # `[DATE]` states nothing, and copying that bracket forward would spread the placeholder
1051
+ # instead of a value. Any trailing `<!-- … -->` the template keeps beside the value is
1052
+ # stripped, so the caller gets the value and not the explanation of it.
1053
+ _read_header() { # $1 = file, $2 = header name -> the value, empty when absent/unfilled
1054
+ [ -f "$1" ] || return 0
1055
+ AW_F="$1" AW_NAME="$2" python3 - <<'PY'
1056
+ import os, re
1057
+ try: text = open(os.environ["AW_F"], encoding="utf-8").read()
1058
+ except Exception: raise SystemExit(0)
1059
+ m = re.search(r"^\*\*%s\*\*:[ \t]*(.*)$" % re.escape(os.environ["AW_NAME"]), text, re.M)
1060
+ if not m: raise SystemExit(0)
1061
+ val = re.sub(r"<!--.*", "", m.group(1)).strip()
1062
+ print("" if (val.startswith("[") and val.endswith("]")) else val)
1063
+ PY
1064
+ }
1065
+
1032
1066
  # The highest wave among the delta models currently open in a context, or 1 when none
1033
1067
  # is. This is the DEFAULT for a new delta: parallel with whatever is already in flight,
1034
1068
  # never implicitly sequenced behind it.
@@ -1037,7 +1071,7 @@ _max_open_wave() { # $1 = abs context dir
1037
1071
  import os, re, glob
1038
1072
  ctx = os.environ["AW_CTX"]
1039
1073
  best = 0
1040
- for f in glob.glob(os.path.join(ctx, "domain-model.*.delta.md")):
1074
+ for f in glob.glob(os.path.join(glob.escape(ctx), "domain-model.*.delta.md")):
1041
1075
  try: t = open(f, encoding="utf-8").read()
1042
1076
  except Exception: continue
1043
1077
  m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", t, re.M)
@@ -1105,7 +1139,7 @@ name = os.path.basename(ctx.rstrip("/"))
1105
1139
  context = "" if rel(ctx) == "specs" else name
1106
1140
  master = os.path.join(ctx, "domain-model.md")
1107
1141
  pending, files = [], []
1108
- for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
1142
+ for f in sorted(glob.glob(os.path.join(glob.escape(ctx), "domain-model.*.delta.md"))):
1109
1143
  base = os.path.basename(f)
1110
1144
  cr = base[len("domain-model."):-len(".delta.md")]
1111
1145
  wave = 1
@@ -1172,6 +1206,10 @@ cmd_model_scaffold() {
1172
1206
  # reverse-engineers (the discovered aggregates NOT yet under specs/). This is
1173
1207
  # why select-feature can offer real code features in a brownfield repo before
1174
1208
  # anything is adopted, instead of an empty specs/ list.
1209
+ # BOTH sources are scoped to the ACTIVE BOUNDED CONTEXT: source 1 by enumerating the
1210
+ # context's own directory, source 2 by the `bounded_context:` key onboard stamps on
1211
+ # every concept it writes. onboard runs once per context, so an unfiltered bundle
1212
+ # offers a neighbouring context's features to a picker that would scaffold them here.
1175
1213
  # On greenfield (no bundle) source 2 is empty → identical to the old specs-only
1176
1214
  # behaviour. `select_options` = merged names + (an injected --preselect that is not
1177
1215
  # yet a feature) + a "create new" sentinel, so select-feature can offer add-new and
@@ -1189,16 +1227,24 @@ cmd_list_features() {
1189
1227
  local repo_root; repo_root=$(get_repo_root)
1190
1228
  [ -n "$bundle" ] || bundle="knowledge"
1191
1229
  case "$bundle" in /*) ;; *) bundle="$repo_root/$bundle" ;; esac
1192
- # Best-effort OKF aggregate list from the shared python core (single source of
1193
- # truth for OKF parsing; the ps1 twin calls the SAME script). Empty on greenfield.
1194
- local okf_json="{}" invpy="$SCRIPT_DIR/../python/aw-inventory.py"
1195
- if [ -d "$bundle" ] && [ -f "$invpy" ] && command -v python3 >/dev/null 2>&1; then
1196
- okf_json=$(python3 "$invpy" list-aggregates --bundle "$bundle" 2>/dev/null) || okf_json="{}"
1197
- fi
1198
1230
  # Features are enumerated from the ACTIVE BOUNDED CONTEXT's directory when one is
1199
1231
  # set, and from specs/ itself when none is — so a context scopes the picker to its
1200
1232
  # own features and a pre-context repo lists exactly what it always did.
1201
1233
  local base_rel; base_rel=$(feature_base_rel "$repo_root")
1234
+ # The OKF half is scoped the same way. onboard runs once per bounded context and
1235
+ # stamps each concept it writes, so a bundle that has seen two runs holds two
1236
+ # contexts' aggregates; unfiltered, standing inside one context would offer the
1237
+ # other's features and scaffold them into the wrong tree. Empty when no context is
1238
+ # active, which keeps the flat layout listing everything.
1239
+ local active_ctx=""
1240
+ [ "$base_rel" != "specs" ] && active_ctx="${base_rel##*/}"
1241
+ # Best-effort OKF aggregate list from the shared python core (single source of
1242
+ # truth for OKF parsing AND for the context filter; the ps1 twin calls the SAME
1243
+ # script with the same argument). Empty on greenfield.
1244
+ local okf_json="{}" invpy="$SCRIPT_DIR/../python/aw-inventory.py"
1245
+ if [ -d "$bundle" ] && [ -f "$invpy" ] && command -v python3 >/dev/null 2>&1; then
1246
+ okf_json=$(python3 "$invpy" list-aggregates --bundle "$bundle" --root "$repo_root" --context "$active_ctx" 2>/dev/null) || okf_json="{}"
1247
+ fi
1202
1248
  AW_SPECS="$repo_root/$base_rel" AW_BASE_REL="$base_rel" AW_PRESELECT="$preselect" AW_OKF="$okf_json" python3 - <<'PY'
1203
1249
  import json, os, glob
1204
1250
  base = os.environ["AW_SPECS"]
@@ -1246,16 +1292,26 @@ if os.path.isdir(base):
1246
1292
  "dir": base_rel + "/" + name,
1247
1293
  "source": "spec",
1248
1294
  "has_spec": os.path.isfile(os.path.join(d, "spec.md")),
1249
- # `design*.md`, NOT `design.*.md`: the second dot made this miss the single
1250
- # `design.md` the framework actually produces (it only matched the retired
1251
- # design.domain.md / design.technical.md family). Informational count only.
1252
- "design_docs": len(glob.glob(os.path.join(d, "design*.md"))),
1295
+ # HOW MANY LANE PLANS THIS FEATURE HAS. Counted by NAME, not by glob: the
1296
+ # documents are backend-plan.md / frontend-plan.md, and the legacy pair
1297
+ # design.md / design.ui.md that feature-paths still resolves under the same
1298
+ # keys for a tree adopted before the rename. The old `design*.md` glob counted
1299
+ # neither of the current names, so this was structurally 0 on every feature the
1300
+ # framework produces today — a field that could only ever be wrong.
1301
+ # Informational only; no workflow reads it.
1302
+ "plan_docs": sum(1 for n in ("backend-plan.md", "frontend-plan.md",
1303
+ "design.md", "design.ui.md")
1304
+ if os.path.isfile(os.path.join(d, n))),
1253
1305
  "version": ver,
1254
1306
  "active_change": active_change,
1255
1307
  })
1256
- # Merge OKF-discovered aggregates NOT already scaffolded under specs/ (a specs/
1257
- # folder is the adopted/created form of the same aggregate — it wins, no dup).
1258
- for agg in (okf.get("aggregates") or []):
1308
+ # Merge the OKF-discovered FEATURES not already scaffolded under specs/ (a specs/
1309
+ # folder is the adopted/created form of the same feature — it wins, no dup).
1310
+ # `pickable` is each aggregate concept offered as the features the map puts on it, so
1311
+ # an aggregate split into capabilities is never itself offered as a feature to pick.
1312
+ # An EMPTY `pickable` is an answer — nothing to offer, e.g. a greenfield bundle whose
1313
+ # features all have specs/ dirs — so only its absence falls back to the raw aggregates.
1314
+ for agg in (okf["pickable"] if "pickable" in okf else okf.get("aggregates") or []):
1259
1315
  nm = (agg.get("name") or "").strip()
1260
1316
  if not nm or nm.lower() in seen:
1261
1317
  continue
@@ -1267,10 +1323,15 @@ for agg in (okf.get("aggregates") or []):
1267
1323
  "has_spec": False,
1268
1324
  "adopted": False,
1269
1325
  "concept_path": agg.get("concept_path", ""),
1270
- "design_docs": 0,
1326
+ "plan_docs": 0,
1271
1327
  "version": "",
1272
1328
  "active_change": "",
1273
1329
  })
1330
+ # `design_docs` is kept as a same-value ALIAS of plan_docs. The key is part of this op's
1331
+ # published shape, so dropping it would break a reader outside the kit for no gain; what
1332
+ # it reports is now the plan pair rather than a glob that matched nothing.
1333
+ for _f in feats:
1334
+ _f["design_docs"] = _f["plan_docs"]
1274
1335
  opts = list(names)
1275
1336
  # Inject a not-yet-existing --preselect so `preselect_from: USER_INPUT` can
1276
1337
  # create-or-select it without an extra round trip (the direct-arg fast path).
@@ -1283,26 +1344,26 @@ PY
1283
1344
  }
1284
1345
 
1285
1346
  # ─────────────────────────────── set-feature ───────────────────────────────
1286
- # Create-or-select an aggregate under specs/ and persist it as the active feature
1287
- # (.appweaver/feature.json). feature = aggregate: the name is normalized to a
1288
- # PascalCase identifier (e.g. "order management" -> OrderManagement). Idempotent:
1347
+ # Create-or-select a feature under specs/ and persist it as the active feature
1348
+ # (.appweaver/feature.json). The name is the developer's capability scope, normalized to
1349
+ # a PascalCase identifier (e.g. "patient registration" -> PatientRegistration). Idempotent:
1289
1350
  # selecting an existing feature just re-points feature.json; a new one is created.
1290
1351
  cmd_set_feature() {
1291
1352
  local raw=""
1292
1353
  while [ $# -gt 0 ]; do
1293
1354
  case "$1" in
1294
1355
  --feature) raw="$2"; shift 2 ;;
1295
- --help|-h) echo "set-feature --feature <name> (create-or-select the aggregate under specs/)"; return 0 ;;
1356
+ --help|-h) echo "set-feature --feature <name> (create-or-select the feature under specs/)"; return 0 ;;
1296
1357
  *) echo "set-feature: unknown arg '$1'" >&2; return 1 ;;
1297
1358
  esac
1298
1359
  done
1299
1360
  [ -n "$raw" ] || { echo "set-feature: --feature is required" >&2; return 1; }
1300
1361
 
1301
- # Normalize to a PascalCase aggregate identifier (feature = aggregate).
1362
+ # Normalize to a PascalCase identifier.
1302
1363
  local cleaned; cleaned=$(echo "$raw" | sed -E 's/[^A-Za-z0-9]+/ /g; s/^ +| +$//g')
1303
1364
  local feature; feature=$(_pascal "$cleaned")
1304
1365
  if ! [[ "$feature" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
1305
- echo "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric aggregate, e.g. Order)" >&2
1366
+ echo "set-feature: '$raw' is not a valid feature name (need a letter-led alphanumeric name, e.g. Order or PatientRegistration)" >&2
1306
1367
  return 1
1307
1368
  fi
1308
1369
 
@@ -1322,6 +1383,39 @@ cmd_set_feature() {
1322
1383
  echo "set-feature: refusing — '$feature_dir_rel' is a bounded context, not a feature. Run /appweaver select-bounded-context $feature first, then select-feature." >&2
1323
1384
  return 1
1324
1385
  fi
1386
+ # AND THE SAME REFUSAL FOR A CONTEXT THAT IS MERELY EMPTY. The test above asks what a
1387
+ # directory CONTAINS, so a context created by select-bounded-context and not yet
1388
+ # populated — no domain-model.md, no feature inside it — passes it and is taken as a
1389
+ # feature, which writes spec.md into it and orphans every feature meant to live there.
1390
+ # Emptiness cannot be told from "a new feature directory" by looking at the directory,
1391
+ # so the question asked here is the one list-contexts asks instead: is this repo USING
1392
+ # bounded contexts, and is none of them active? In that state a target directly under
1393
+ # specs/ is outside every context and cannot be the feature the caller meant — which is
1394
+ # exactly the state `select-feature` refuses via scope_ok, restated at the point of the
1395
+ # write so no other caller can walk past it.
1396
+ if [ "$base_rel" = "specs" ] && [ ! -f "$feature_dir/spec.md" ]; then
1397
+ local other_contexts
1398
+ other_contexts=$(AW_SPECS="$repo_root/specs" AW_TARGET="$feature" python3 - <<'PY'
1399
+ import os
1400
+ base = os.environ["AW_SPECS"]; target = os.environ["AW_TARGET"]
1401
+ names = []
1402
+ if os.path.isdir(base):
1403
+ for n in sorted(os.listdir(base)):
1404
+ d = os.path.join(base, n)
1405
+ if not os.path.isdir(d) or n == target:
1406
+ continue
1407
+ # A directory with its own spec.md is a FEATURE (the flat pre-context layout);
1408
+ # one without is a context. Only the latter means contexts are in use here.
1409
+ if not os.path.isfile(os.path.join(d, "spec.md")):
1410
+ names.append(n)
1411
+ print(", ".join(names))
1412
+ PY
1413
+ )
1414
+ if [ -n "$other_contexts" ]; then
1415
+ echo "set-feature: refusing — this repository uses bounded contexts ($other_contexts) and none is active, so '$feature_dir_rel' would be created outside every one of them. If $feature is itself a bounded context, run /appweaver select-bounded-context $feature. If it is a feature, run /appweaver select-bounded-context to choose the context that owns it first, then select-feature $feature." >&2
1416
+ return 1
1417
+ fi
1418
+ fi
1325
1419
  local existed=false; [ -d "$feature_dir" ] && existed=true
1326
1420
  mkdir -p "$feature_dir"
1327
1421
  _persist_feature_json "$repo_root" "$feature_dir_rel"
@@ -1453,6 +1547,42 @@ elif op == "activate":
1453
1547
  save(d)
1454
1548
  out["active_change"] = d["active_change"]; out["activated"] = cr
1455
1549
  out["open_changes"] = opens; out["open_count"] = len(opens)
1550
+ elif op == "recall":
1551
+ # WITHDRAWS one change: drop it from open_changes and move active_change off it,
1552
+ # WITHOUT bumping the version. That is the whole difference from `close`, and it is
1553
+ # the reason the two are separate ops rather than one with a flag. A version is what a
1554
+ # change SHIPPED as; a recalled change shipped nothing, so bumping would burn a number
1555
+ # on work that was never merged and leave the spine claiming a release that does not
1556
+ # exist. Everything else is identical — the same membership edit, the same fallback
1557
+ # for active_change — so a recall and a close leave the ledger in the same SHAPE and
1558
+ # only the number tells them apart.
1559
+ #
1560
+ # Refusing an untracked id is the point of this op, not a nicety: recall archives
1561
+ # documents, so acting on an id the ledger does not know would move a directory on the
1562
+ # strength of a typo. `close` cannot afford the same strictness (it is also the
1563
+ # migration path for state written before open_changes existed); recall can, because
1564
+ # every change it can reach was opened by specify.
1565
+ prev = d["version"]
1566
+ opens = [c for c in (d.get("open_changes") or []) if c]
1567
+ if not opens and d.get("active_change"):
1568
+ opens = [d["active_change"]] # state written before open_changes existed
1569
+ target = cr or d.get("active_change", "")
1570
+ if not target:
1571
+ out["ok"] = False
1572
+ out["error"] = "no change named and none is active on this feature"
1573
+ elif target not in opens:
1574
+ out["ok"] = False
1575
+ out["error"] = ("'%s' is not an open change on this feature (open: %s)"
1576
+ % (target, ", ".join(opens) or "none"))
1577
+ else:
1578
+ opens.remove(target)
1579
+ d["open_changes"] = opens
1580
+ d["active_change"] = opens[-1] if opens else ""
1581
+ save(d)
1582
+ out["recalled_change"] = target
1583
+ out["version"] = d["version"]; out["previous"] = prev
1584
+ out["active_change"] = d.get("active_change", "")
1585
+ out["open_changes"] = opens; out["open_count"] = len(opens)
1456
1586
  elif op == "close":
1457
1587
  # Closes ONE change by id (falling back to active_change when none is named, which
1458
1588
  # is the pre-parallel caller). The version bumps once per change materialized, and
@@ -3056,6 +3186,9 @@ cmd_cr_setup() {
3056
3186
  done
3057
3187
  # A random suffix, not the bare timestamp: two changes opened in the same second
3058
3188
  # would otherwise collide on one id, and with parallel deltas that is reachable.
3189
+ # Before the generated default, so a menu LABEL passed as --cr-id becomes its id
3190
+ # rather than a directory name with spaces in it. Empty stays empty and is generated.
3191
+ cr_id=$(_cr_id_token "$cr_id")
3059
3192
  [ -n "$cr_id" ] || cr_id="CR-$(date -u +%Y%m%d-%H%M%S)-$(_rand_suffix)"
3060
3193
  local repo_root; repo_root=$(get_repo_root)
3061
3194
  local fd="$feature_dir_arg"
@@ -3098,15 +3231,47 @@ cmd_cr_setup() {
3098
3231
  if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dmodel"; else touch "$dmodel"; fi
3099
3232
  created=true
3100
3233
  fi
3101
- # Scripts stamp ids, dates and the wave — the model never writes them.
3234
+ # Scripts stamp ids, dates and versions — the model never writes them, and the delta
3235
+ # quality checklists REQUIRE a complete header, so every placeholder the templates
3236
+ # carry has to be filled here or it ships as a literal `[DATE]` / `[vX.Y]`.
3237
+ #
3238
+ # BASE VERSION is the master version this delta is being authored AGAINST, read from
3239
+ # the ledger that was just updated — `open` does not move the number, so what comes
3240
+ # back is the current one. It is what a rebase check compares against later: a master
3241
+ # that moved between the opening of a change and its merge is a change authored on a
3242
+ # document that no longer exists in that form.
3243
+ #
3244
+ # BASE CONTEXT VERSION is the same idea one level up, and its source is the master
3245
+ # model's own `**Context version**:` header rather than any ledger — there is no
3246
+ # context-level version file (⌛ that, and the close-delta bump that would move the
3247
+ # number, are still to build). While nothing bumps it, every delta reads the same
3248
+ # v1.0; the field is still correct, and it starts carrying real information the moment
3249
+ # something stamps the master.
3250
+ local created_on; created_on=$(date -u +%Y-%m-%d)
3251
+ local base_version; base_version=$(printf '%s' "$av_open" | _json_field version)
3252
+ [ -n "$base_version" ] || base_version="v1.0"
3253
+ case "$base_version" in v*) ;; *) base_version="v$base_version" ;; esac
3254
+ local base_ctx; base_ctx=$(_read_header "$ctx_dir/domain-model.md" "Context version")
3255
+ [ -n "$base_ctx" ] || base_ctx="v1.0"
3256
+ # Both version fields are normalised to the same `vX.Y` shape. base_version comes from
3257
+ # a machine ledger and base_ctx from a markdown header a human may have written as
3258
+ # "1.0", and a rebase check compares these as strings — two spellings of one version
3259
+ # would read as a move that never happened.
3260
+ case "$base_ctx" in v*) ;; *) base_ctx="v$base_ctx" ;; esac
3261
+
3102
3262
  _stamp_header "$dspec" "Wave" "$wave"
3263
+ _stamp_header "$dspec" "Created" "$created_on"
3264
+ _stamp_header "$dspec" "Base version" "$base_version"
3103
3265
  _stamp_header "$dmodel" "Wave" "$wave"
3266
+ _stamp_header "$dmodel" "Created" "$created_on"
3267
+ _stamp_header "$dmodel" "Base context version" "$base_ctx"
3104
3268
 
3105
3269
  local open_count; open_count=$(printf '%s' "$av_open" | _json_field open_count)
3106
- printf '{"cr_id":"%s","cr_dir":"%s","delta_spec":"%s","delta_model":"%s","context_dir":"%s","wave":%s,"open_count":%s,"title":"%s","created":%s}\n' \
3270
+ printf '{"cr_id":"%s","cr_dir":"%s","delta_spec":"%s","delta_model":"%s","context_dir":"%s","wave":%s,"open_count":%s,"title":"%s","created":%s,"created_on":"%s","base_version":"%s","base_context_version":"%s"}\n' \
3107
3271
  "$(json_escape "$cr_id")" "$(json_escape "$cr_dir")" "$(json_escape "$dspec")" \
3108
3272
  "$(json_escape "$dmodel")" "$(json_escape "$ctx_rel")" \
3109
- "$wave" "${open_count:-1}" "$(json_escape "$title")" "$created"
3273
+ "$wave" "${open_count:-1}" "$(json_escape "$title")" "$created" \
3274
+ "$(json_escape "$created_on")" "$(json_escape "$base_version")" "$(json_escape "$base_ctx")"
3110
3275
  }
3111
3276
 
3112
3277
  # ─────────────────────────────── cr-wave ────────────────────────────────────
@@ -3124,6 +3289,9 @@ cmd_cr_wave() {
3124
3289
  *) echo "cr-wave: unknown arg '$1'" >&2; return 1 ;;
3125
3290
  esac
3126
3291
  done
3292
+ # The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
3293
+ # first token. A bare id is unchanged by this.
3294
+ cr_id=$(_cr_id_token "$cr_id")
3127
3295
  [ -n "$cr_id" ] || { echo "cr-wave: --cr-id is required" >&2; return 1; }
3128
3296
  case "$wave" in ''|*[!0-9]*) echo "cr-wave: --wave must be a positive integer" >&2; return 1 ;; esac
3129
3297
  local repo_root; repo_root=$(get_repo_root)
@@ -3274,6 +3442,9 @@ if not matched and pre_raw:
3274
3442
  elif inc_new and pre_raw == "new": matched = NEW_LABEL
3275
3443
  print(json.dumps({
3276
3444
  "changes": rows, "ids": [r["cr_id"] for r in rows], "count": len(rows),
3445
+ # The same ids as one readable string, for an operator message. A list renders as its
3446
+ # JSON in a rendered string, which is right for a payload and wrong for a sentence.
3447
+ "ids_csv": ", ".join(r["cr_id"] for r in rows),
3277
3448
  "select_options": opts, "matched_label": matched,
3278
3449
  "main_label": MAIN_LABEL if inc_main else "",
3279
3450
  "new_label": NEW_LABEL if inc_new else "",
@@ -3331,11 +3502,11 @@ wave = wave_of(dspec, wave_of(dmodel, 1))
3331
3502
  # spec of another open change is found by walking the context's features rather than
3332
3503
  # assuming it belongs to this one — two features of one context can both have changes open.
3333
3504
  def spec_of(other):
3334
- for c in sorted(glob.glob(os.path.join(ctx, "*", "changes", other, "delta-spec.md"))):
3505
+ for c in sorted(glob.glob(os.path.join(glob.escape(ctx), "*", "changes", glob.escape(other), "delta-spec.md"))):
3335
3506
  return c
3336
3507
  return ""
3337
3508
  pred, sib, pred_rows, sib_rows, pred_specs = [], [], [], [], []
3338
- for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
3509
+ for f in sorted(glob.glob(os.path.join(glob.escape(ctx), "domain-model.*.delta.md"))):
3339
3510
  base = os.path.basename(f)
3340
3511
  other = base[len("domain-model."):-len(".delta.md")]
3341
3512
  if other == cr:
@@ -3367,6 +3538,17 @@ print(json.dumps({
3367
3538
  "predecessor_docs": pred + pred_specs,
3368
3539
  # Same or higher wave = nothing orders it against this one. Visible, never assumed.
3369
3540
  "sibling_files": sib, "siblings": sib_rows, "sibling_count": len(sib),
3541
+ # EVERYTHING A PLANNER SHOULD SEE, in one list, for the same one-var reason. Siblings
3542
+ # belong in it as much as predecessors do: a planner told "do not assume a same-wave
3543
+ # delta, and say so where one touches the same aggregate" cannot name an overlap it
3544
+ # was never shown, and the gate one stage later raises a finding for the silence.
3545
+ # Their MODEL deltas only — the aggregate a sibling touches is what makes it an
3546
+ # overlap, and their delta-specs would double the payload to say nothing more about it.
3547
+ "related_docs": pred + pred_specs + sib,
3548
+ # Who is who, as text for a prompt. The file list alone cannot say which entries are
3549
+ # committed to landing first and which are merely open beside this one.
3550
+ "predecessor_ids": ", ".join("%s (wave %d)" % (r["cr_id"], r["wave"]) for r in pred_rows),
3551
+ "sibling_ids": ", ".join("%s (wave %d)" % (r["cr_id"], r["wave"]) for r in sib_rows),
3370
3552
  }))
3371
3553
  PY
3372
3554
  }
@@ -3384,8 +3566,9 @@ PY
3384
3566
  # EARLY WARNING, NOT ENFORCEMENT. An overlap is not an error: two changes to one
3385
3567
  # aggregate are ordinary, and expand-then-contract is the way through. What matters is
3386
3568
  # that the second one is PLANNED knowing about the first instead of discovering it at
3387
- # merge time, which is why this runs at the gate and finalize-delta still holds the
3388
- # base_version check.
3569
+ # merge time, which is why this runs at the gate while the HARD refusals stay at the merge:
3570
+ # cr-merge-check holds the base_version comparison, the merged-not-closed test and the
3571
+ # lower-wave predecessor test, and finalize-delta stops on each.
3389
3572
  #
3390
3573
  # The relation follows the wave: a LOWER wave is a predecessor (committed to land first —
3391
3574
  # an overlap with it is a dependency to declare), the SAME wave is a sibling (nothing
@@ -3436,7 +3619,7 @@ wave = wave_of(read(os.path.join(fd, "changes", cr, "delta-spec.md")), wave_of(m
3436
3619
  mine = aggregates_of(mine_txt)
3437
3620
  mine_set = set(mine)
3438
3621
  rows, parts, npred, nsib, nsucc = [], [], 0, 0, 0
3439
- for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
3622
+ for f in sorted(glob.glob(os.path.join(glob.escape(ctx), "domain-model.*.delta.md"))):
3440
3623
  other = os.path.basename(f)[len("domain-model."):-len(".delta.md")]
3441
3624
  if other == cr:
3442
3625
  continue
@@ -3453,6 +3636,9 @@ for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
3453
3636
  parts.append("%s (wave %d, %s): %s" % (other, w, relation, ", ".join(shared)))
3454
3637
  print(json.dumps({
3455
3638
  "cr_id": cr, "wave": wave, "aggregates": mine,
3639
+ # The same names as one readable string, for a prompt. A list renders as its JSON in a
3640
+ # rendered string, which is right for a payload and wrong for a sentence.
3641
+ "aggregates_csv": ", ".join(mine),
3456
3642
  "overlaps": rows, "overlap_count": len(rows),
3457
3643
  "predecessor_overlaps": npred, "sibling_overlaps": nsib,
3458
3644
  "successor_overlaps": nsucc,
@@ -3486,27 +3672,72 @@ cmd_cr_activate() {
3486
3672
  [ -n "$cr_id" ] || { echo "cr-activate: --cr-id is required" >&2; return 1; }
3487
3673
  local repo_root; repo_root=$(get_repo_root)
3488
3674
  local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-activate: no feature set and no --feature-dir" >&2; return 1; }
3489
- _active_version "$fd" activate "$cr_id"
3675
+ local out; out=$(_active_version "$fd" activate "$cr_id") || return 1
3676
+ printf '%s\n' "$out"
3677
+ # A REFUSAL IS AN ERROR, NOT A RESULT — so it leaves through the exit code, like
3678
+ # every other way this command can fail. When the id is not in the ledger nothing
3679
+ # moves and the JSON reports active_change: "", which is precisely what the calling
3680
+ # step binds; an empty active change then reads as "no change" in every condition
3681
+ # downstream, and the run continues against a target it never selected. Refusing
3682
+ # here turns that into the engine's own stop, carrying the message to the operator.
3683
+ #
3684
+ # THIS IS REACHABLE, not defensive: cr-list offers what is on DISK (changes/<id>/ is
3685
+ # the authority for "open") and reports `tracked` separately, so a change whose
3686
+ # directory survived a crashed run is still offered while the ledger has forgotten it.
3687
+ # `ok` starts True and is flipped only by a refusal, so an EXPLICIT False is the test.
3688
+ local ok; ok=$(printf '%s' "$out" | _json_field ok)
3689
+ if [ "$ok" = "False" ] || [ "$ok" = "false" ]; then
3690
+ printf 'cr-activate: %s\n' "$(printf '%s' "$out" | _json_field error)" >&2
3691
+ return 1
3692
+ fi
3490
3693
  }
3491
3694
 
3695
+ # ARCHIVE, in either of the two ways a change leaves the open set.
3696
+ #
3697
+ # default CLOSED — it shipped. The directory is archived and the version bumps, which
3698
+ # is close-delta's whole job and the behaviour every existing caller gets.
3699
+ # --recall WITHDRAWN — it never shipped. The same archive, and NO version bump: a
3700
+ # version records what a change shipped as, and a recalled change shipped
3701
+ # nothing, so bumping would burn a number on work that was never merged.
3702
+ #
3703
+ # The two were fused until recall-delta needed the archive half on its own, which is why
3704
+ # withdrawing a change could not be "archive alone". Splitting them here rather than in
3705
+ # each caller keeps ONE place that knows how a change's documents are put away — the
3706
+ # directory, and the model delta that lives beside the master rather than inside it.
3492
3707
  cmd_cr_archive() {
3493
- local cr_id="" feature_dir_arg=""
3708
+ local cr_id="" feature_dir_arg="" recall="" reason=""
3494
3709
  while [ $# -gt 0 ]; do
3495
3710
  case "$1" in
3496
3711
  --cr-id) cr_id="$2"; shift 2 ;;
3497
3712
  --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3498
- --help|-h) echo "cr-archive --cr-id <id> [--feature-dir <path>]"; return 0 ;;
3713
+ --recall) recall="1"; shift ;;
3714
+ --reason) reason="$2"; shift 2 ;;
3715
+ --help|-h) echo "cr-archive --cr-id <id> [--feature-dir <path>] [--recall] [--reason <text>]"; return 0 ;;
3499
3716
  *) echo "cr-archive: unknown arg '$1'" >&2; return 1 ;;
3500
3717
  esac
3501
3718
  done
3719
+ # A caller that picked from a user_select menu hands over the whole LABEL
3720
+ # ("CR-… · wave 2 · title"), not the id. Every other cr-* op takes the first token;
3721
+ # this one did not, so a label arrived here as a directory name that does not exist
3722
+ # and the archive silently moved nothing.
3723
+ cr_id=$(_cr_id_token "$cr_id")
3502
3724
  [ -n "$cr_id" ] || { echo "cr-archive: --cr-id required" >&2; return 1; }
3503
3725
  local repo_root; repo_root=$(get_repo_root)
3504
3726
  local fd="$feature_dir_arg"
3505
3727
  [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
3506
3728
  [ -n "$fd" ] || { echo "cr-archive: no feature set and no --feature-dir" >&2; return 1; }
3507
3729
  [[ "$fd" != /* ]] && fd="$repo_root/$fd"
3730
+ # THE LEDGER EDIT COMES FIRST, because it is the only step that can REFUSE. `recall`
3731
+ # will not withdraw a change active_version.json does not carry, and a refusal has to
3732
+ # be a no-op: archiving before asking would move the directory and only then report
3733
+ # that the change was never open. `close` cannot refuse, so its side effects are
3734
+ # unchanged by the ordering.
3735
+ local op="close"; [ -n "$recall" ] && op="recall"
3736
+ local av; av=$(_active_version "$fd" "$op" "$cr_id")
3737
+ local ledger_ok; ledger_ok=$(printf '%s' "$av" | python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("ok", True) else "")' 2>/dev/null || echo "1")
3738
+
3508
3739
  local cr_dir="$fd/changes/$cr_id" archive_root="$fd/changes/archive" archived=""
3509
- if [ -d "$cr_dir" ]; then
3740
+ if [ -n "$ledger_ok" ] && [ -d "$cr_dir" ]; then
3510
3741
  mkdir -p "$archive_root"
3511
3742
  rm -rf "$archive_root/$cr_id"
3512
3743
  mv "$cr_dir" "$archive_root/$cr_id"
@@ -3519,21 +3750,565 @@ cmd_cr_archive() {
3519
3750
  # in-flight to every other open delta.
3520
3751
  local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3521
3752
  local dmodel="$ctx_dir/domain-model.$cr_id.delta.md" model_archived=""
3522
- if [ -f "$dmodel" ]; then
3753
+ if [ -n "$ledger_ok" ] && [ -f "$dmodel" ]; then
3523
3754
  mkdir -p "$archive_root/$cr_id"
3524
3755
  mv -f "$dmodel" "$archive_root/$cr_id/domain-model.delta.md"
3525
3756
  model_archived="$archive_root/$cr_id/domain-model.delta.md"
3526
3757
  fi
3527
- # Close THIS change in active_version.json (single version source): bump minor +
3528
- # drop it from open_changes. Other changes on the feature stay open.
3529
- local av; av=$(_active_version "$fd" close "$cr_id")
3530
- AW_AV="$av" AW_ARCHIVED="$archived" python3 - <<'PY'
3758
+ # A RECALLED change is marked as such where the evidence will be looked for — inside
3759
+ # its own archive — and the marker is written by the script, not by a model, because
3760
+ # everything on it is a fact the script already holds: the id, the date, and the
3761
+ # reason the caller passed.
3762
+ local marker=""
3763
+ if [ -n "$recall" ] && [ -n "$archived" ]; then
3764
+ marker="$archived/RECALLED.md"
3765
+ AW_MARK="$marker" AW_CR="$cr_id" AW_REASON="$reason" python3 - <<'PY'
3766
+ import datetime, os
3767
+ when = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
3768
+ cr, reason = os.environ["AW_CR"], os.environ.get("AW_REASON", "").strip()
3769
+ open(os.environ["AW_MARK"], "w", encoding="utf-8").write(
3770
+ "# RECALLED — %s\n\n"
3771
+ "**Recalled**: %s\n"
3772
+ "**Reason**: %s\n\n"
3773
+ "This change was WITHDRAWN before it was merged. Its delta documents are archived here\n"
3774
+ "unchanged; the feature's spec.md and the context's domain-model.md never carried it,\n"
3775
+ "and the version was not bumped — no release shipped this work.\n\n"
3776
+ "Any code /appweaver implement generated for this change is NOT reverted by a recall.\n"
3777
+ "Revert it with git if it should not stay.\n"
3778
+ % (cr, when, reason or "(not stated)"))
3779
+ PY
3780
+ fi
3781
+ AW_AV="$av" AW_ARCHIVED="$archived" AW_MODEL="$model_archived" AW_MARK="$marker" \
3782
+ AW_RECALL="$recall" AW_CR="$cr_id" python3 - <<'PY'
3531
3783
  import json, os
3532
3784
  try: av = json.loads(os.environ.get("AW_AV") or "{}")
3533
3785
  except Exception: av = {}
3534
- print(json.dumps({"ok": True, "archived_dir": os.environ.get("AW_ARCHIVED", ""),
3535
- "version": av.get("version", "v1.1"), "previous": av.get("previous", "v1.0"),
3536
- "closed_change": av.get("closed_change", "")}))
3786
+ recalled = bool(os.environ.get("AW_RECALL"))
3787
+ out = {
3788
+ # The ledger edit is what can refuse (recall will not touch a change the ledger does
3789
+ # not carry), so its verdict is this command's verdict.
3790
+ "ok": av.get("ok", True),
3791
+ "archived_dir": os.environ.get("AW_ARCHIVED", ""),
3792
+ "archived_model": os.environ.get("AW_MODEL", ""),
3793
+ "recalled": recalled,
3794
+ "recall_marker": os.environ.get("AW_MARK", ""),
3795
+ "version": av.get("version", "v1.1"),
3796
+ "previous": av.get("previous", "v1.0"),
3797
+ # Which of the two ways it left the open set. Exactly one is ever non-empty.
3798
+ "closed_change": av.get("closed_change", ""),
3799
+ "recalled_change": av.get("recalled_change", ""),
3800
+ "active_change": av.get("active_change", ""),
3801
+ "open_changes": av.get("open_changes", []),
3802
+ "open_count": av.get("open_count", 0),
3803
+ }
3804
+ if av.get("error"): out["error"] = av["error"]
3805
+ print(json.dumps(out))
3806
+ PY
3807
+ }
3808
+
3809
+ # ──────────────────────────── context-version-bump ─────────────────────────
3810
+ # Move the BOUNDED CONTEXT's version, the way version-stamp moves a feature's.
3811
+ #
3812
+ # The model's header has always declared itself script-stamped — `**Context version**:
3813
+ # v1.0 <!-- script-stamped; close-delta bumps it when a closing delta touched the
3814
+ # model -->` — and specify tells the authoring model not to write it. Nothing wrote it,
3815
+ # so every context sat at the template's v1.0 for its whole life and the one instruction
3816
+ # the model was given about that field was the only thing keeping it accurate.
3817
+ #
3818
+ # WHY IT IS A CONTEXT-LEVEL NUMBER. A feature's version says what that feature shipped;
3819
+ # the model is shared by every feature in the context, so a change to it is a change to
3820
+ # all of them and no single feature's version can record it. The two spines move
3821
+ # independently and neither is derived from the other.
3822
+ #
3823
+ # ONLY WHEN THE MODEL ACTUALLY CHANGED. The caller passes --touched-model; a behaviour-only
3824
+ # change closes without moving this number, exactly as it leaves domain-model.md alone.
3825
+ # Refusing to bump is reported, not an error — most changes never touch the model.
3826
+ cmd_context_version_bump() {
3827
+ local feature_dir_arg="" touched="" cr_id=""
3828
+ while [ $# -gt 0 ]; do
3829
+ case "$1" in
3830
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3831
+ --cr-id) cr_id="$2"; shift 2 ;;
3832
+ # Takes a VALUE rather than being a bare flag: a workflow cannot omit an
3833
+ # argument conditionally (an include's arg list is rendered whole), so the
3834
+ # caller always passes it and says true or false. Anything but `true` is false.
3835
+ --touched-model) [ "$2" = "true" ] && touched="1" || touched=""; shift 2 ;;
3836
+ --help|-h) echo "context-version-bump [--feature-dir <path>] [--cr-id <id>] [--touched-model true|false] -> {ok, bumped, previous, version, model, context}"; return 0 ;;
3837
+ *) echo "context-version-bump: unknown arg '$1'" >&2; return 1 ;;
3838
+ esac
3839
+ done
3840
+ # The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
3841
+ # first token. Here the id only reaches the Change History row, so a label would have
3842
+ # been recorded verbatim rather than failing loudly — the quietest of the family's
3843
+ # inconsistencies, and the reason to close it rather than wait for a caller to hit it.
3844
+ cr_id=$(_cr_id_token "$cr_id")
3845
+ local repo_root; repo_root=$(get_repo_root)
3846
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "context-version-bump: no feature set and no --feature-dir" >&2; return 1; }
3847
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3848
+ local model="$ctx_dir/domain-model.md"
3849
+ local ctx_rel="${ctx_dir#$repo_root/}"
3850
+ local model_rel="${model#$repo_root/}"
3851
+
3852
+ local current; current=$(_read_header "$model" "Context version")
3853
+ [ -n "$current" ] || current="v1.0"
3854
+ case "$current" in v*) ;; *) current="v$current" ;; esac
3855
+
3856
+ local bumped=false next="$current" reason=""
3857
+ if [ ! -f "$model" ]; then
3858
+ reason="this feature's context has no domain-model.md, so there is no context version to move"
3859
+ elif [ -z "$touched" ]; then
3860
+ reason="the closing change carried no model delta, so the context is unchanged"
3861
+ else
3862
+ next=$(AW_CUR="$current" python3 -c '
3863
+ import os, re
3864
+ m = re.match(r"v?(\d+)(?:\.(\d+))?", os.environ["AW_CUR"].strip())
3865
+ maj = int(m.group(1)) if m else 1
3866
+ mnr = int(m.group(2)) if (m and m.group(2)) else 0
3867
+ print("v%d.%d" % (maj, mnr + 1))')
3868
+ _stamp_header "$model" "Context version" "$next"
3869
+ bumped=true
3870
+ reason="the closing change merged a model delta into this context"
3871
+ fi
3872
+ printf '{"ok":true,"bumped":%s,"previous":"%s","version":"%s","model":"%s","model_exists":%s,"context":"%s","reason":"%s"}\n' \
3873
+ "$bumped" "$(json_escape "$current")" "$(json_escape "$next")" \
3874
+ "$(json_escape "$model_rel")" "$([ -f "$model" ] && echo true || echo false)" \
3875
+ "$(json_escape "$ctx_rel")" "$(json_escape "$reason")"
3876
+ }
3877
+
3878
+ # ───────────────────────────── cr-snapshot ─────────────────────────────────
3879
+ # Copy the two MASTERS into changes/<id>/pre-merge/ immediately before finalize-delta
3880
+ # rewrites them. The merge is the one act of the CR loop that edits a long-lived
3881
+ # hand-curated document in place, and until this existed the only record of what those
3882
+ # documents said beforehand was git — which is a different tool's answer to the question,
3883
+ # and unavailable at all if the merge and the commit are not interleaved.
3884
+ #
3885
+ # WHAT IT IS FOR. A recall after a merge has to put the masters back, and it may only do so
3886
+ # when nothing else landed in between — the `pre-merge` copy is the content to restore and
3887
+ # cr-merge-check's base_version comparison is what answers whether restoring is still safe.
3888
+ # Taken together they are the difference between "un-merge" and "hope".
3889
+ #
3890
+ # IT IS A COPY, NOT A MOVE, and it is overwritten on a re-run: finalize-delta may be
3891
+ # attempted more than once (a red downstream fence aborts it), and the snapshot that matters
3892
+ # is the one taken by the attempt that actually merged. The manifest records the version and
3893
+ # the date so a reader can tell which.
3894
+ #
3895
+ # The snapshot travels WITH the change — changes/<id>/ is what cr-archive moves — so a
3896
+ # closed change carries the before-image of the masters it moved, in the archive, forever.
3897
+ cmd_cr_snapshot() {
3898
+ local cr_id="" feature_dir_arg=""
3899
+ while [ $# -gt 0 ]; do
3900
+ case "$1" in
3901
+ --cr-id) cr_id="$2"; shift 2 ;;
3902
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3903
+ --help|-h) echo "cr-snapshot --cr-id <id> [--feature-dir <path>] -> {ok, dir, files[], count, version}"; return 0 ;;
3904
+ *) echo "cr-snapshot: unknown arg '$1'" >&2; return 1 ;;
3905
+ esac
3906
+ done
3907
+ cr_id=$(_cr_id_token "$cr_id")
3908
+ [ -n "$cr_id" ] || { echo "cr-snapshot: --cr-id required" >&2; return 1; }
3909
+ local repo_root; repo_root=$(get_repo_root)
3910
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-snapshot: no feature set and no --feature-dir" >&2; return 1; }
3911
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3912
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_CR="$cr_id" python3 - <<'PY'
3913
+ import json, os, re, shutil, datetime
3914
+ fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]; cr = os.environ["AW_CR"]
3915
+ def rel(p): return os.path.relpath(p, root)
3916
+ def header(path, name, default=""):
3917
+ try: t = open(path, encoding="utf-8").read()
3918
+ except Exception: return default
3919
+ m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), t, re.M)
3920
+ return m.group(1).strip() if m else default
3921
+
3922
+ snap = os.path.join(fd, "changes", cr, "pre-merge")
3923
+ os.makedirs(snap, exist_ok=True)
3924
+ spec = os.path.join(fd, "spec.md")
3925
+ model = os.path.join(ctx, "domain-model.md")
3926
+ copied = []
3927
+ # Flat names, not the source tree's shape: there are exactly two masters and they live at
3928
+ # different levels, so a nested copy would encode the layout of the day it was taken.
3929
+ for src, name in ((spec, "spec.md"), (model, "domain-model.md")):
3930
+ if os.path.isfile(src):
3931
+ shutil.copy2(src, os.path.join(snap, name))
3932
+ copied.append(name)
3933
+
3934
+ version = ""
3935
+ try:
3936
+ version = json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", "")
3937
+ except Exception:
3938
+ pass
3939
+
3940
+ manifest = {
3941
+ "cr_id": cr,
3942
+ "taken": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
3943
+ "version": version,
3944
+ "context_version": header(model, "Context version"),
3945
+ "files": copied,
3946
+ "sources": {"spec.md": rel(spec), "domain-model.md": rel(model)},
3947
+ # Stated in the file itself, because the restore is the reader's whole reason for
3948
+ # opening it and the condition on it is not obvious from the content.
3949
+ "note": ("Masters as they stood immediately before finalize-delta merged %s. "
3950
+ "Restoring is only safe while no other change finalized afterwards — "
3951
+ "cr-merge-check's base_version comparison is what answers that." % cr),
3952
+ }
3953
+ with open(os.path.join(snap, "manifest.json"), "w", encoding="utf-8") as f:
3954
+ json.dump(manifest, f, indent=2)
3955
+ f.write("\n")
3956
+
3957
+ print(json.dumps({"ok": True, "cr_id": cr, "dir": rel(snap), "files": copied,
3958
+ "count": len(copied), "version": version,
3959
+ "manifest": rel(os.path.join(snap, "manifest.json"))}))
3960
+ PY
3961
+ }
3962
+
3963
+ # ───────────────────────────── cr-merge-check ──────────────────────────────
3964
+ # The two mechanical refusals finalize-delta needs, answered in one call from evidence on
3965
+ # disk — the merge-side twin of cr-recall-check below, and shaped the same way so the two
3966
+ # gates of the CR loop read alike.
3967
+ #
3968
+ # REBASE. `cr-setup` stamps `**Base version**:` into the delta-spec at the moment the change
3969
+ # opens: the master version the delta was authored AGAINST. If spec.md has moved since —
3970
+ # because another change finalized and closed in between — then the delta's MODIFIED items
3971
+ # name a BEFORE the master no longer says, and merging them is last-writer-wins against work
3972
+ # that already landed. Both sides are normalised to `vX.Y` by cr-setup, so this is an exact
3973
+ # string comparison and never a judgement. A delta with no stamped header (authored before
3974
+ # the field existed, or hand-made) reports `base_version: ""` and does NOT block — an absent
3975
+ # stamp is no evidence of a move, and refusing on it would strand every legacy change.
3976
+ #
3977
+ # PREDECESSORS. A wave is a dependency level: a LOWER wave is committed to landing first, and
3978
+ # `plan` puts exactly those deltas in the planner's context as the base this one builds on.
3979
+ # Merging out of wave order therefore folds in behaviour that assumes a predecessor the
3980
+ # masters do not yet carry. `depends_on` is stamped nowhere, so the wave is the only
3981
+ # dependency signal that exists; same-wave changes are independent by definition and are
3982
+ # never counted.
3983
+ #
3984
+ # THE SCAN IS CONTEXT-WIDE, matching cr-paths' definition of a predecessor: two features of
3985
+ # one bounded context share domain-model.md, so a lower-wave change on a sibling feature is
3986
+ # as much a predecessor as one on this feature. Both halves are read — the changes/<id>/
3987
+ # directories AND the pending model deltas — because a behaviour-only change has no model
3988
+ # delta and would be invisible to a scan of those alone.
3989
+ cmd_cr_merge_check() {
3990
+ local cr_id="" feature_dir_arg=""
3991
+ while [ $# -gt 0 ]; do
3992
+ case "$1" in
3993
+ --cr-id) cr_id="$2"; shift 2 ;;
3994
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3995
+ --help|-h) echo "cr-merge-check --cr-id <id> [--feature-dir <path>] -> {rebase_needed, base_version, current_version, blockers[], blocker_count, wave, …}"; return 0 ;;
3996
+ *) echo "cr-merge-check: unknown arg '$1'" >&2; return 1 ;;
3997
+ esac
3998
+ done
3999
+ cr_id=$(_cr_id_token "$cr_id")
4000
+ [ -n "$cr_id" ] || { echo "cr-merge-check: --cr-id required" >&2; return 1; }
4001
+ local repo_root; repo_root=$(get_repo_root)
4002
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-merge-check: no feature set and no --feature-dir" >&2; return 1; }
4003
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
4004
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_CR="$cr_id" python3 - <<'PY'
4005
+ import json, os, re
4006
+ fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]; cr = os.environ["AW_CR"]
4007
+ def rel(p): return os.path.relpath(p, root)
4008
+
4009
+ def header(path, name, default=""):
4010
+ try: t = open(path, encoding="utf-8").read()
4011
+ except Exception: return default
4012
+ m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), t, re.M)
4013
+ if not m: return default
4014
+ v = m.group(1).strip()
4015
+ # An unfilled template placeholder is not a value. `[vX.Y]` means the stamp never ran.
4016
+ return "" if (v.startswith("[") and v.endswith("]")) else v
4017
+
4018
+ def norm(v):
4019
+ v = (v or "").strip()
4020
+ if not v: return ""
4021
+ return v if v.startswith("v") else "v" + v
4022
+
4023
+ # THIS change's own spec half and wave.
4024
+ my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
4025
+ def wave_of(p, default=1):
4026
+ try: t = open(p, encoding="utf-8").read()
4027
+ except Exception: return default
4028
+ m = re.search(r"^\*\*Wave\*\*:[ \t]*(\d+)", t, re.M)
4029
+ return int(m.group(1)) if m else default
4030
+ wave = wave_of(my_spec)
4031
+ base_version = norm(header(my_spec, "Base version"))
4032
+
4033
+ # The master's CURRENT version, from the ledger — the same authority version-read reports,
4034
+ # so the two can never disagree about what the number is.
4035
+ current = ""
4036
+ try:
4037
+ current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
4038
+ except Exception:
4039
+ current = ""
4040
+ if not current:
4041
+ current = norm(header(os.path.join(fd, "spec.md"), "Version"))
4042
+
4043
+ # Only a STAMPED base that DISAGREES blocks. No stamp, or no readable current version, is
4044
+ # absence of evidence and is not treated as a move.
4045
+ rebase_needed = bool(base_version and current and base_version != current)
4046
+
4047
+ # EVERY open change in the context, both halves. changes/<id>/ under each feature is the
4048
+ # authority for "open"; the pending model deltas are unioned in so a model-only artifact of
4049
+ # a half-built change is still seen.
4050
+ opens = {}
4051
+ for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
4052
+ ch = os.path.join(ctx, feat, "changes")
4053
+ if not os.path.isdir(ch): continue
4054
+ for name in sorted(os.listdir(ch)):
4055
+ d = os.path.join(ch, name)
4056
+ if name == "archive" or not os.path.isdir(d): continue
4057
+ opens.setdefault(name, {"cr_id": name, "wave": wave_of(os.path.join(d, "delta-spec.md")),
4058
+ "feature": feat})
4059
+ for f in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
4060
+ m = re.match(r"^domain-model\.(.+)\.delta\.md$", f)
4061
+ if not m: continue
4062
+ other = m.group(1)
4063
+ opens.setdefault(other, {"cr_id": other, "wave": wave_of(os.path.join(ctx, f)), "feature": ""})
4064
+
4065
+ blockers = [v for k, v in sorted(opens.items()) if k != cr and v["wave"] < wave]
4066
+
4067
+ # THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
4068
+ # bumps, so between the two spec.md carries a change the number does not yet reflect — and a
4069
+ # second delta sharing the same base_version would compare equal and merge onto masters that
4070
+ # had already shifted under it. An id appearing in spec.md is the on-disk proof that the
4071
+ # merge ran (it is the Change History row finalize-delta appends), which is the same
4072
+ # evidence cr-recall-check reads for the same question.
4073
+ spec_text = ""
4074
+ try:
4075
+ spec_text = open(os.path.join(fd, "spec.md"), encoding="utf-8").read()
4076
+ except Exception:
4077
+ pass
4078
+ merged_open = [v for k, v in sorted(opens.items()) if k != cr and k in spec_text]
4079
+
4080
+ print(json.dumps({
4081
+ "ok": True, "cr_id": cr, "wave": wave,
4082
+ "exists": os.path.isdir(os.path.join(fd, "changes", cr)),
4083
+ # REBASE half — the masters moved because a change CLOSED and bumped the number.
4084
+ "base_version": base_version, "current_version": current,
4085
+ "rebase_needed": rebase_needed,
4086
+ "base_stamped": bool(base_version),
4087
+ # PREDECESSOR half. Lower wave = committed to land first.
4088
+ "blockers": blockers, "blocker_count": len(blockers),
4089
+ "has_blockers": len(blockers) > 0,
4090
+ "blocker_ids": ", ".join("%s (wave %d)" % (b["cr_id"], b["wave"]) for b in blockers),
4091
+ # The other way the masters move: a change MERGED and has not closed yet.
4092
+ "merged_open": merged_open, "merged_open_count": len(merged_open),
4093
+ "has_merged_open": len(merged_open) > 0,
4094
+ "merged_open_ids": ", ".join(m["cr_id"] for m in merged_open),
4095
+ # All three refusals as ONE positive flag, because the engine takes no compound condition
4096
+ # and a gate that must read three booleans would need three passes over one question.
4097
+ "merge_ok": (not rebase_needed) and len(blockers) == 0 and len(merged_open) == 0,
4098
+ }))
4099
+ PY
4100
+ }
4101
+
4102
+ # ───────────────────────────── cr-recall-check ─────────────────────────────
4103
+ # The two mechanical refusals recall-delta needs, answered in one call from evidence on
4104
+ # disk rather than from anything a model read.
4105
+ #
4106
+ # MERGED. finalize-delta appends a Change History row carrying the CR id to spec.md, so
4107
+ # the id appearing in that file is the on-disk proof the merge ran. A merged change is
4108
+ # not recallable: the masters describe behaviour that includes it, and taking it back is
4109
+ # document surgery — the remedy is a NEW change that reverses it.
4110
+ #
4111
+ # DEPENDANTS. A wave is a dependency level: a higher wave is planned and built ON TOP of
4112
+ # the lower ones. Withdrawing a lower-wave change therefore strands every open change
4113
+ # above it. `depends_on` is stamped nowhere yet, so the wave is the only dependency signal
4114
+ # that actually exists, and it is the conservative one — same-wave changes are independent
4115
+ # by definition and are never counted here.
4116
+ cmd_cr_recall_check() {
4117
+ local cr_id="" feature_dir_arg=""
4118
+ while [ $# -gt 0 ]; do
4119
+ case "$1" in
4120
+ --cr-id) cr_id="$2"; shift 2 ;;
4121
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
4122
+ --help|-h) echo "cr-recall-check --cr-id <id> [--feature-dir <path>] -> {merged, dependants[], dependant_count, wave, …}"; return 0 ;;
4123
+ *) echo "cr-recall-check: unknown arg '$1'" >&2; return 1 ;;
4124
+ esac
4125
+ done
4126
+ cr_id=$(_cr_id_token "$cr_id")
4127
+ [ -n "$cr_id" ] || { echo "cr-recall-check: --cr-id required" >&2; return 1; }
4128
+ local repo_root; repo_root=$(get_repo_root)
4129
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-recall-check: no feature set and no --feature-dir" >&2; return 1; }
4130
+ AW_FD="$fd" AW_CR="$cr_id" python3 - <<'PY'
4131
+ import json, os, re
4132
+ fd = os.environ["AW_FD"]; cr = os.environ["AW_CR"]
4133
+ changes = os.path.join(fd, "changes")
4134
+ cr_dir = os.path.join(changes, cr)
4135
+
4136
+ def wave_of(d):
4137
+ for name in ("delta-spec.md",):
4138
+ p = os.path.join(d, name)
4139
+ if os.path.isfile(p):
4140
+ m = re.search(r'^\*\*Wave\*\*:[ \t]*(\d+)', open(p, encoding="utf-8").read(), re.M)
4141
+ if m:
4142
+ return int(m.group(1))
4143
+ return 1
4144
+
4145
+ spec = os.path.join(fd, "spec.md")
4146
+ merged, evidence = False, ""
4147
+ if os.path.isfile(spec):
4148
+ for line in open(spec, encoding="utf-8").read().splitlines():
4149
+ if cr in line:
4150
+ merged, evidence = True, line.strip()
4151
+ break
4152
+
4153
+ wave = wave_of(cr_dir) if os.path.isdir(cr_dir) else 1
4154
+ dependants = []
4155
+ if os.path.isdir(changes):
4156
+ for name in sorted(os.listdir(changes)):
4157
+ d = os.path.join(changes, name)
4158
+ if name == cr or name == "archive" or not os.path.isdir(d):
4159
+ continue
4160
+ w = wave_of(d)
4161
+ if w > wave:
4162
+ dependants.append({"cr_id": name, "wave": w})
4163
+
4164
+ print(json.dumps({
4165
+ "ok": True, "cr_id": cr, "exists": os.path.isdir(cr_dir), "wave": wave,
4166
+ "merged": merged, "merge_evidence": evidence,
4167
+ "dependants": dependants,
4168
+ "dependant_ids": ", ".join("%s (wave %d)" % (x["cr_id"], x["wave"]) for x in dependants),
4169
+ "dependant_count": len(dependants),
4170
+ "has_dependants": len(dependants) > 0,
4171
+ }))
4172
+ PY
4173
+ }
4174
+
4175
+ # ──────────────────── plan-gate-stamp / plan-gate-check ────────────────────
4176
+ # THE RECEIPT THAT analyze-plan PASSED, and over WHICH BYTES.
4177
+ #
4178
+ # implement generates from the lane plans and deliberately does NOT read the spec — its
4179
+ # own header says "analyze-plan already conformed the plans to it". That sentence was an
4180
+ # assumption about the order the operator ran things in, not a fact anything checked: the
4181
+ # lane gates test that the plan FILE EXISTS and nothing more, so `plan` → edit spec.md →
4182
+ # `implement` generated from a plan that was never held against the spec it now contradicts.
4183
+ #
4184
+ # WHY NOT COMPARE THE PLANS' `**Version**:` HEADERS. They cannot answer this. version-stamp
4185
+ # moves spec.md, backend-plan.md and frontend-plan.md to the same number in LOCKSTEP at
4186
+ # close-delta, whether or not a plan was re-derived — close-delta says so itself ("a plan
4187
+ # header stamped here is stating the version the slice ships as, not that the plan was
4188
+ # re-derived for it"). Comparing them reads equal always, which is worse than not checking.
4189
+ #
4190
+ # WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
4191
+ # from that spec". The question implement depends on is stronger — "were these plans PROVEN
4192
+ # against this spec" — and a plan authored from the current spec can still be wrong. So the
4193
+ # receipt is written where the proof happens, by analyze-plan, on green, and it hashes every
4194
+ # document the gate actually read: the spec, the context's domain model (check 4 reads it)
4195
+ # and each lane plan that was present.
4196
+ #
4197
+ # The absence of a receipt is INFORMATION, not an error: it means this feature has never
4198
+ # been through the gate, which every tree that predates this op is true of. It is reported
4199
+ # as untracked and blocks nothing. Only a receipt that EXISTS and no longer matches is a
4200
+ # refusal, because that tree has been gated once and the bytes have moved since.
4201
+ _plan_gate_file() { printf '%s/.aw-plan-gate.json' "$1"; }
4202
+
4203
+ cmd_plan_gate_stamp() {
4204
+ local feature_dir_arg="" spec_version="" mode="master"
4205
+ while [ $# -gt 0 ]; do
4206
+ case "$1" in
4207
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
4208
+ --spec-version) spec_version="$2"; shift 2 ;;
4209
+ --mode) mode="$2"; shift 2 ;;
4210
+ --help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] -> {file, gated, hashed[], count}"; return 0 ;;
4211
+ *) echo "plan-gate-stamp: unknown arg '$1'" >&2; return 1 ;;
4212
+ esac
4213
+ done
4214
+ local repo_root; repo_root=$(get_repo_root)
4215
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "plan-gate-stamp: no feature set and no --feature-dir" >&2; return 1; }
4216
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
4217
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_VER="$spec_version" AW_MODE="$mode" python3 - <<'PY'
4218
+ import datetime, hashlib, json, os
4219
+ fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
4220
+ def sha(p):
4221
+ try:
4222
+ with open(p, "rb") as f: return hashlib.sha256(f.read()).hexdigest()
4223
+ except Exception: return ""
4224
+ def rel(p): return os.path.relpath(p, root)
4225
+ # Every document the gate READ. A lane that is not declared has no plan on disk and is
4226
+ # simply absent from the map — the check treats a name it does not carry as not gated,
4227
+ # never as changed, so declaring a second lane later does not read as tampering.
4228
+ #
4229
+ # THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
4230
+ # resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
4231
+ # that has not been through `plan` since the rename — every brownfield feature `adopt`
4232
+ # produces — is GATED on those documents. Hashing only the new names would leave such a
4233
+ # feature with an empty plan map: a receipt that looks tracked and fresh while the document
4234
+ # implement actually generates from is unwatched.
4235
+ plans = {}
4236
+ for name in ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
4237
+ h = sha(os.path.join(fd, name))
4238
+ if h: plans[name] = h
4239
+ rec = {
4240
+ "gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
4241
+ "gate_mode": os.environ.get("AW_MODE", "master"),
4242
+ "spec_version": os.environ.get("AW_VER", "").strip(),
4243
+ "spec_sha": sha(os.path.join(fd, "spec.md")),
4244
+ "model_sha": sha(os.path.join(ctx, "domain-model.md")),
4245
+ "plans": plans,
4246
+ "note": ("Written by analyze-plan when it PASSED. It records the bytes that were "
4247
+ "proven against each other, not a version — the plans' **Version**: headers "
4248
+ "are stamped in lockstep by close-delta and cannot answer this. implement "
4249
+ "reads it through plan-gate-check."),
4250
+ }
4251
+ p = os.path.join(fd, ".aw-plan-gate.json")
4252
+ open(p, "w", encoding="utf-8").write(json.dumps(rec, indent=2) + "\n")
4253
+ print(json.dumps({"file": rel(p), "gated": rec["gated"], "spec_version": rec["spec_version"],
4254
+ "hashed": sorted(plans.keys()), "count": len(plans), "ok": True}))
4255
+ PY
4256
+ }
4257
+
4258
+ cmd_plan_gate_check() {
4259
+ local feature_dir_arg=""
4260
+ while [ $# -gt 0 ]; do
4261
+ case "$1" in
4262
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
4263
+ --help|-h) echo "plan-gate-check [--feature-dir <path>] -> {tracked, gate_fresh, spec_moved, model_moved, plans_changed[], plans_changed_csv, gated, reason}"; return 0 ;;
4264
+ *) echo "plan-gate-check: unknown arg '$1'" >&2; return 1 ;;
4265
+ esac
4266
+ done
4267
+ local repo_root; repo_root=$(get_repo_root)
4268
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "plan-gate-check: no feature set and no --feature-dir" >&2; return 1; }
4269
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
4270
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" python3 - <<'PY'
4271
+ import hashlib, json, os
4272
+ fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
4273
+ def sha(p):
4274
+ try:
4275
+ with open(p, "rb") as f: return hashlib.sha256(f.read()).hexdigest()
4276
+ except Exception: return ""
4277
+ p = os.path.join(fd, ".aw-plan-gate.json")
4278
+ rec = None
4279
+ try: rec = json.load(open(p, encoding="utf-8"))
4280
+ except Exception: rec = None
4281
+ out = {"file": os.path.relpath(p, root), "tracked": bool(rec), "gate_fresh": False,
4282
+ "spec_moved": False, "model_moved": False, "plans_changed": [],
4283
+ "plans_changed_csv": "", "gated": "", "spec_version": "", "reason": "", "ok": True}
4284
+ if not rec:
4285
+ # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
4286
+ # thing to report about a tree that has not run analyze-plan since this op existed.
4287
+ out["reason"] = ("no .aw-plan-gate.json — this feature has not passed analyze-plan "
4288
+ "since the gate began leaving a receipt")
4289
+ else:
4290
+ out["gated"] = rec.get("gated", "")
4291
+ out["spec_version"] = rec.get("spec_version", "")
4292
+ out["spec_moved"] = bool(rec.get("spec_sha")) and rec["spec_sha"] != sha(os.path.join(fd, "spec.md"))
4293
+ # A model recorded as absent that is still absent has not moved. A context that
4294
+ # GAINED a domain model since the gate has moved: check 4 never ran against it.
4295
+ out["model_moved"] = rec.get("model_sha", "") != sha(os.path.join(ctx, "domain-model.md"))
4296
+ changed = []
4297
+ for name, was in sorted((rec.get("plans") or {}).items()):
4298
+ if sha(os.path.join(fd, name)) != was:
4299
+ changed.append(name)
4300
+ out["plans_changed"] = changed
4301
+ out["plans_changed_csv"] = ", ".join(changed)
4302
+ out["gate_fresh"] = not (out["spec_moved"] or out["model_moved"] or changed)
4303
+ if out["gate_fresh"]:
4304
+ out["reason"] = "the spec, the model and every gated plan are byte-identical to what analyze-plan passed on %s" % out["gated"]
4305
+ else:
4306
+ bits = []
4307
+ if out["spec_moved"]: bits.append("spec.md has changed")
4308
+ if out["model_moved"]: bits.append("domain-model.md has changed")
4309
+ if changed: bits.append("%s changed (a re-derive by `plan main`, or a hand edit)" % ", ".join(changed))
4310
+ out["reason"] = "since analyze-plan passed on %s: %s" % (out["gated"], "; ".join(bits))
4311
+ print(json.dumps(out))
3537
4312
  PY
3538
4313
  }
3539
4314
 
@@ -3547,25 +4322,39 @@ PY
3547
4322
  # nobody at all and sat at v1.0 forever.
3548
4323
  # Idempotent: re-stamping the same version reports `unchanged`, writes nothing.
3549
4324
  # Only the value moves; any trailing inline comment on the header line is kept.
4325
+ #
4326
+ # WITH --cr-id IT ALSO COMPLETES THAT CHANGE'S CHANGE HISTORY ROW. finalize-delta appends
4327
+ # the row when it MERGES, one stage before the number exists — so it writes `(pending)` in
4328
+ # the Version cell and this fills it in, at the same instant and by the same mechanism as
4329
+ # the headers. The alternative, which this replaces, was for the merge to write
4330
+ # version-read's `next_version`: a PREDICTION of what close-delta would bump to. A change
4331
+ # that merged and never closed — an abandoned run, a red analyze-slice, an un-merge — then
4332
+ # left spec.md permanently asserting a version that was never issued, in the document the
4333
+ # kit calls living truth. domain-model.md was always held to the rule this now applies to
4334
+ # spec.md: the row and its version land together, or the row carries a number nothing else
4335
+ # agrees with.
3550
4336
  cmd_version_stamp() {
3551
- local version="" feature_dir_arg=""
4337
+ local version="" feature_dir_arg="" cr_id=""
3552
4338
  while [ $# -gt 0 ]; do
3553
4339
  case "$1" in
3554
4340
  --version) version="$2"; shift 2 ;;
3555
4341
  --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3556
- --help|-h) echo "version-stamp --version <vX.Y> [--feature-dir <path>]"; return 0 ;;
4342
+ --cr-id) cr_id="$2"; shift 2 ;;
4343
+ --help|-h) echo "version-stamp --version <vX.Y> [--feature-dir <path>] [--cr-id <id>]"; return 0 ;;
3557
4344
  *) echo "version-stamp: unknown arg '$1'" >&2; return 1 ;;
3558
4345
  esac
3559
4346
  done
3560
4347
  [ -n "$version" ] || { echo "version-stamp: --version required" >&2; return 1; }
4348
+ cr_id=$(_cr_id_token "$cr_id")
3561
4349
  local repo_root; repo_root=$(get_repo_root)
3562
4350
  local fd="$feature_dir_arg"
3563
4351
  [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
3564
4352
  [ -n "$fd" ] || { echo "version-stamp: no feature set and no --feature-dir" >&2; return 1; }
3565
4353
  [[ "$fd" != /* ]] && fd="$repo_root/$fd"
3566
- AW_FD="$fd" AW_VER="$version" python3 - <<'PY'
4354
+ AW_FD="$fd" AW_VER="$version" AW_CR="$cr_id" python3 - <<'PY'
3567
4355
  import json, os, re
3568
4356
  fd = os.environ["AW_FD"]; ver = os.environ["AW_VER"].strip()
4357
+ cr = os.environ.get("AW_CR", "").strip()
3569
4358
  stamped, unchanged, missing, no_header = [], [], [], []
3570
4359
  # First `**Version**: <value>` line only — that is the header. The Change History
3571
4360
  # table's `| Version |` column head does not match this pattern.
@@ -3589,9 +4378,41 @@ for name in ("spec.md", "backend-plan.md", "frontend-plan.md", "design.md", "des
3589
4378
  open(p, "w", encoding="utf-8").write(
3590
4379
  src[:m.start()] + m.group(1) + ver + m.group(3) + src[m.end():])
3591
4380
  stamped.append(name)
4381
+
4382
+ # THE CHANGE HISTORY ROW, completed with the number the headers just took. The row is
4383
+ # found by its CR cell, not by position: rows are appended newest-last and a feature may
4384
+ # carry many, so "the last row" would be right only until two changes were open at once.
4385
+ # Only a cell still reading `(pending)` is written — a row already carrying a version
4386
+ # belongs to a change that closed, and re-stamping it would rewrite history.
4387
+ row_filled = False
4388
+ row_version = ""
4389
+ if cr:
4390
+ p = os.path.join(fd, "spec.md")
4391
+ if os.path.isfile(p):
4392
+ src = open(p, encoding="utf-8").read()
4393
+ out_lines, changed = [], False
4394
+ for line in src.splitlines(True):
4395
+ # A table row mentioning this CR whose FIRST cell is the pending marker.
4396
+ if (not changed) and cr in line and line.lstrip().startswith("|"):
4397
+ cells = line.split("|")
4398
+ if len(cells) > 2 and cells[1].strip().lower() in ("(pending)", "pending", "—", "-", ""):
4399
+ row_version = cells[1].strip()
4400
+ cells[1] = " %s " % ver
4401
+ line = "|".join(cells)
4402
+ changed = True
4403
+ out_lines.append(line)
4404
+ if changed:
4405
+ open(p, "w", encoding="utf-8").write("".join(out_lines))
4406
+ row_filled = True
4407
+
3592
4408
  print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
3593
4409
  "unchanged": unchanged, "missing": missing,
3594
- "no_header": no_header, "count": len(stamped)}))
4410
+ "no_header": no_header, "count": len(stamped),
4411
+ # Whether the merge's pending row was completed. False with a --cr-id is
4412
+ # the ordinary outcome for a re-run (already filled) and for a change
4413
+ # whose merge predates the pending convention.
4414
+ "cr_id": cr, "history_row_filled": row_filled,
4415
+ "history_row_was": row_version}))
3595
4416
  PY
3596
4417
  }
3597
4418
 
@@ -3634,9 +4455,15 @@ case "$sub" in
3634
4455
  cr-activate) cmd_cr_activate "$@" ;;
3635
4456
  cr-wave) cmd_cr_wave "$@" ;;
3636
4457
  cr-archive) cmd_cr_archive "$@" ;;
4458
+ cr-recall-check) cmd_cr_recall_check "$@" ;;
4459
+ cr-merge-check) cmd_cr_merge_check "$@" ;;
4460
+ cr-snapshot) cmd_cr_snapshot "$@" ;;
4461
+ context-version-bump) cmd_context_version_bump "$@" ;;
3637
4462
  context-paths) cmd_context_paths "$@" ;;
3638
4463
  model-scaffold) cmd_model_scaffold "$@" ;;
3639
4464
  version-stamp) cmd_version_stamp "$@" ;;
4465
+ plan-gate-stamp) cmd_plan_gate_stamp "$@" ;;
4466
+ plan-gate-check) cmd_plan_gate_check "$@" ;;
3640
4467
  checklist-read) cmd_checklist_read "$@" ;;
3641
4468
  checklist-apply) cmd_checklist_apply "$@" ;;
3642
4469
  detect-project) cmd_detect_project "$@" ;;