aw-spec-cli 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.appweaver/DESIGN-FORMAT.md +45 -21
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +62 -14
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +6 -4
- package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +9 -1
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +21 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +23 -1
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +18 -11
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +298 -109
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +161 -34
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +125 -24
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +18 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +182 -39
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +300 -35
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +190 -31
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +93 -8
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +34 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +319 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +125 -24
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +846 -35
- package/assets/.appweaver/scripts/bash/common.sh +28 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +843 -46
- package/assets/.appweaver/scripts/python/aw-inventory.py +3 -2
- package/assets/.appweaver/templates/design-template.md +41 -30
- package/assets/.appweaver/templates/spec-template.md +7 -4
- package/assets/.appweaver/templates/ui-design-template.md +7 -3
- package/assets/guardrails/domain/healthcare/auditing.domain.md +3 -3
- package/assets/guardrails/domain/pharma/auditing.domain.md +3 -3
- package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/eventing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
- package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
- package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
- package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
- package/assets/guardrails/technology/nextjs/design-system.md +1 -1
- package/assets/guardrails/technology/nextjs/error-handling.md +1 -1
- package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
- package/assets/guardrails/technology/nextjs/i18n.md +1 -1
- package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
- package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
- package/assets/guardrails/technology/nextjs/state-management.md +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
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
|
|
@@ -1246,10 +1280,16 @@ if os.path.isdir(base):
|
|
|
1246
1280
|
"dir": base_rel + "/" + name,
|
|
1247
1281
|
"source": "spec",
|
|
1248
1282
|
"has_spec": os.path.isfile(os.path.join(d, "spec.md")),
|
|
1249
|
-
#
|
|
1250
|
-
#
|
|
1251
|
-
# design.
|
|
1252
|
-
|
|
1283
|
+
# HOW MANY LANE PLANS THIS FEATURE HAS. Counted by NAME, not by glob: the
|
|
1284
|
+
# documents are backend-plan.md / frontend-plan.md, and the legacy pair
|
|
1285
|
+
# design.md / design.ui.md that feature-paths still resolves under the same
|
|
1286
|
+
# keys for a tree adopted before the rename. The old `design*.md` glob counted
|
|
1287
|
+
# neither of the current names, so this was structurally 0 on every feature the
|
|
1288
|
+
# framework produces today — a field that could only ever be wrong.
|
|
1289
|
+
# Informational only; no workflow reads it.
|
|
1290
|
+
"plan_docs": sum(1 for n in ("backend-plan.md", "frontend-plan.md",
|
|
1291
|
+
"design.md", "design.ui.md")
|
|
1292
|
+
if os.path.isfile(os.path.join(d, n))),
|
|
1253
1293
|
"version": ver,
|
|
1254
1294
|
"active_change": active_change,
|
|
1255
1295
|
})
|
|
@@ -1267,10 +1307,15 @@ for agg in (okf.get("aggregates") or []):
|
|
|
1267
1307
|
"has_spec": False,
|
|
1268
1308
|
"adopted": False,
|
|
1269
1309
|
"concept_path": agg.get("concept_path", ""),
|
|
1270
|
-
"
|
|
1310
|
+
"plan_docs": 0,
|
|
1271
1311
|
"version": "",
|
|
1272
1312
|
"active_change": "",
|
|
1273
1313
|
})
|
|
1314
|
+
# `design_docs` is kept as a same-value ALIAS of plan_docs. The key is part of this op's
|
|
1315
|
+
# published shape, so dropping it would break a reader outside the kit for no gain; what
|
|
1316
|
+
# it reports is now the plan pair rather than a glob that matched nothing.
|
|
1317
|
+
for _f in feats:
|
|
1318
|
+
_f["design_docs"] = _f["plan_docs"]
|
|
1274
1319
|
opts = list(names)
|
|
1275
1320
|
# Inject a not-yet-existing --preselect so `preselect_from: USER_INPUT` can
|
|
1276
1321
|
# create-or-select it without an extra round trip (the direct-arg fast path).
|
|
@@ -1322,6 +1367,39 @@ cmd_set_feature() {
|
|
|
1322
1367
|
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
1368
|
return 1
|
|
1324
1369
|
fi
|
|
1370
|
+
# AND THE SAME REFUSAL FOR A CONTEXT THAT IS MERELY EMPTY. The test above asks what a
|
|
1371
|
+
# directory CONTAINS, so a context created by select-bounded-context and not yet
|
|
1372
|
+
# populated — no domain-model.md, no feature inside it — passes it and is taken as a
|
|
1373
|
+
# feature, which writes spec.md into it and orphans every feature meant to live there.
|
|
1374
|
+
# Emptiness cannot be told from "a new feature directory" by looking at the directory,
|
|
1375
|
+
# so the question asked here is the one list-contexts asks instead: is this repo USING
|
|
1376
|
+
# bounded contexts, and is none of them active? In that state a target directly under
|
|
1377
|
+
# specs/ is outside every context and cannot be the feature the caller meant — which is
|
|
1378
|
+
# exactly the state `select-feature` refuses via scope_ok, restated at the point of the
|
|
1379
|
+
# write so no other caller can walk past it.
|
|
1380
|
+
if [ "$base_rel" = "specs" ] && [ ! -f "$feature_dir/spec.md" ]; then
|
|
1381
|
+
local other_contexts
|
|
1382
|
+
other_contexts=$(AW_SPECS="$repo_root/specs" AW_TARGET="$feature" python3 - <<'PY'
|
|
1383
|
+
import os
|
|
1384
|
+
base = os.environ["AW_SPECS"]; target = os.environ["AW_TARGET"]
|
|
1385
|
+
names = []
|
|
1386
|
+
if os.path.isdir(base):
|
|
1387
|
+
for n in sorted(os.listdir(base)):
|
|
1388
|
+
d = os.path.join(base, n)
|
|
1389
|
+
if not os.path.isdir(d) or n == target:
|
|
1390
|
+
continue
|
|
1391
|
+
# A directory with its own spec.md is a FEATURE (the flat pre-context layout);
|
|
1392
|
+
# one without is a context. Only the latter means contexts are in use here.
|
|
1393
|
+
if not os.path.isfile(os.path.join(d, "spec.md")):
|
|
1394
|
+
names.append(n)
|
|
1395
|
+
print(", ".join(names))
|
|
1396
|
+
PY
|
|
1397
|
+
)
|
|
1398
|
+
if [ -n "$other_contexts" ]; then
|
|
1399
|
+
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
|
|
1400
|
+
return 1
|
|
1401
|
+
fi
|
|
1402
|
+
fi
|
|
1325
1403
|
local existed=false; [ -d "$feature_dir" ] && existed=true
|
|
1326
1404
|
mkdir -p "$feature_dir"
|
|
1327
1405
|
_persist_feature_json "$repo_root" "$feature_dir_rel"
|
|
@@ -1453,6 +1531,42 @@ elif op == "activate":
|
|
|
1453
1531
|
save(d)
|
|
1454
1532
|
out["active_change"] = d["active_change"]; out["activated"] = cr
|
|
1455
1533
|
out["open_changes"] = opens; out["open_count"] = len(opens)
|
|
1534
|
+
elif op == "recall":
|
|
1535
|
+
# WITHDRAWS one change: drop it from open_changes and move active_change off it,
|
|
1536
|
+
# WITHOUT bumping the version. That is the whole difference from `close`, and it is
|
|
1537
|
+
# the reason the two are separate ops rather than one with a flag. A version is what a
|
|
1538
|
+
# change SHIPPED as; a recalled change shipped nothing, so bumping would burn a number
|
|
1539
|
+
# on work that was never merged and leave the spine claiming a release that does not
|
|
1540
|
+
# exist. Everything else is identical — the same membership edit, the same fallback
|
|
1541
|
+
# for active_change — so a recall and a close leave the ledger in the same SHAPE and
|
|
1542
|
+
# only the number tells them apart.
|
|
1543
|
+
#
|
|
1544
|
+
# Refusing an untracked id is the point of this op, not a nicety: recall archives
|
|
1545
|
+
# documents, so acting on an id the ledger does not know would move a directory on the
|
|
1546
|
+
# strength of a typo. `close` cannot afford the same strictness (it is also the
|
|
1547
|
+
# migration path for state written before open_changes existed); recall can, because
|
|
1548
|
+
# every change it can reach was opened by specify.
|
|
1549
|
+
prev = d["version"]
|
|
1550
|
+
opens = [c for c in (d.get("open_changes") or []) if c]
|
|
1551
|
+
if not opens and d.get("active_change"):
|
|
1552
|
+
opens = [d["active_change"]] # state written before open_changes existed
|
|
1553
|
+
target = cr or d.get("active_change", "")
|
|
1554
|
+
if not target:
|
|
1555
|
+
out["ok"] = False
|
|
1556
|
+
out["error"] = "no change named and none is active on this feature"
|
|
1557
|
+
elif target not in opens:
|
|
1558
|
+
out["ok"] = False
|
|
1559
|
+
out["error"] = ("'%s' is not an open change on this feature (open: %s)"
|
|
1560
|
+
% (target, ", ".join(opens) or "none"))
|
|
1561
|
+
else:
|
|
1562
|
+
opens.remove(target)
|
|
1563
|
+
d["open_changes"] = opens
|
|
1564
|
+
d["active_change"] = opens[-1] if opens else ""
|
|
1565
|
+
save(d)
|
|
1566
|
+
out["recalled_change"] = target
|
|
1567
|
+
out["version"] = d["version"]; out["previous"] = prev
|
|
1568
|
+
out["active_change"] = d.get("active_change", "")
|
|
1569
|
+
out["open_changes"] = opens; out["open_count"] = len(opens)
|
|
1456
1570
|
elif op == "close":
|
|
1457
1571
|
# Closes ONE change by id (falling back to active_change when none is named, which
|
|
1458
1572
|
# is the pre-parallel caller). The version bumps once per change materialized, and
|
|
@@ -3056,6 +3170,9 @@ cmd_cr_setup() {
|
|
|
3056
3170
|
done
|
|
3057
3171
|
# A random suffix, not the bare timestamp: two changes opened in the same second
|
|
3058
3172
|
# would otherwise collide on one id, and with parallel deltas that is reachable.
|
|
3173
|
+
# Before the generated default, so a menu LABEL passed as --cr-id becomes its id
|
|
3174
|
+
# rather than a directory name with spaces in it. Empty stays empty and is generated.
|
|
3175
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3059
3176
|
[ -n "$cr_id" ] || cr_id="CR-$(date -u +%Y%m%d-%H%M%S)-$(_rand_suffix)"
|
|
3060
3177
|
local repo_root; repo_root=$(get_repo_root)
|
|
3061
3178
|
local fd="$feature_dir_arg"
|
|
@@ -3098,15 +3215,47 @@ cmd_cr_setup() {
|
|
|
3098
3215
|
if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dmodel"; else touch "$dmodel"; fi
|
|
3099
3216
|
created=true
|
|
3100
3217
|
fi
|
|
3101
|
-
# Scripts stamp ids, dates and
|
|
3218
|
+
# Scripts stamp ids, dates and versions — the model never writes them, and the delta
|
|
3219
|
+
# quality checklists REQUIRE a complete header, so every placeholder the templates
|
|
3220
|
+
# carry has to be filled here or it ships as a literal `[DATE]` / `[vX.Y]`.
|
|
3221
|
+
#
|
|
3222
|
+
# BASE VERSION is the master version this delta is being authored AGAINST, read from
|
|
3223
|
+
# the ledger that was just updated — `open` does not move the number, so what comes
|
|
3224
|
+
# back is the current one. It is what a rebase check compares against later: a master
|
|
3225
|
+
# that moved between the opening of a change and its merge is a change authored on a
|
|
3226
|
+
# document that no longer exists in that form.
|
|
3227
|
+
#
|
|
3228
|
+
# BASE CONTEXT VERSION is the same idea one level up, and its source is the master
|
|
3229
|
+
# model's own `**Context version**:` header rather than any ledger — there is no
|
|
3230
|
+
# context-level version file (⌛ that, and the close-delta bump that would move the
|
|
3231
|
+
# number, are still to build). While nothing bumps it, every delta reads the same
|
|
3232
|
+
# v1.0; the field is still correct, and it starts carrying real information the moment
|
|
3233
|
+
# something stamps the master.
|
|
3234
|
+
local created_on; created_on=$(date -u +%Y-%m-%d)
|
|
3235
|
+
local base_version; base_version=$(printf '%s' "$av_open" | _json_field version)
|
|
3236
|
+
[ -n "$base_version" ] || base_version="v1.0"
|
|
3237
|
+
case "$base_version" in v*) ;; *) base_version="v$base_version" ;; esac
|
|
3238
|
+
local base_ctx; base_ctx=$(_read_header "$ctx_dir/domain-model.md" "Context version")
|
|
3239
|
+
[ -n "$base_ctx" ] || base_ctx="v1.0"
|
|
3240
|
+
# Both version fields are normalised to the same `vX.Y` shape. base_version comes from
|
|
3241
|
+
# a machine ledger and base_ctx from a markdown header a human may have written as
|
|
3242
|
+
# "1.0", and a rebase check compares these as strings — two spellings of one version
|
|
3243
|
+
# would read as a move that never happened.
|
|
3244
|
+
case "$base_ctx" in v*) ;; *) base_ctx="v$base_ctx" ;; esac
|
|
3245
|
+
|
|
3102
3246
|
_stamp_header "$dspec" "Wave" "$wave"
|
|
3247
|
+
_stamp_header "$dspec" "Created" "$created_on"
|
|
3248
|
+
_stamp_header "$dspec" "Base version" "$base_version"
|
|
3103
3249
|
_stamp_header "$dmodel" "Wave" "$wave"
|
|
3250
|
+
_stamp_header "$dmodel" "Created" "$created_on"
|
|
3251
|
+
_stamp_header "$dmodel" "Base context version" "$base_ctx"
|
|
3104
3252
|
|
|
3105
3253
|
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' \
|
|
3254
|
+
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
3255
|
"$(json_escape "$cr_id")" "$(json_escape "$cr_dir")" "$(json_escape "$dspec")" \
|
|
3108
3256
|
"$(json_escape "$dmodel")" "$(json_escape "$ctx_rel")" \
|
|
3109
|
-
"$wave" "${open_count:-1}" "$(json_escape "$title")" "$created"
|
|
3257
|
+
"$wave" "${open_count:-1}" "$(json_escape "$title")" "$created" \
|
|
3258
|
+
"$(json_escape "$created_on")" "$(json_escape "$base_version")" "$(json_escape "$base_ctx")"
|
|
3110
3259
|
}
|
|
3111
3260
|
|
|
3112
3261
|
# ─────────────────────────────── cr-wave ────────────────────────────────────
|
|
@@ -3124,6 +3273,9 @@ cmd_cr_wave() {
|
|
|
3124
3273
|
*) echo "cr-wave: unknown arg '$1'" >&2; return 1 ;;
|
|
3125
3274
|
esac
|
|
3126
3275
|
done
|
|
3276
|
+
# The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
|
|
3277
|
+
# first token. A bare id is unchanged by this.
|
|
3278
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3127
3279
|
[ -n "$cr_id" ] || { echo "cr-wave: --cr-id is required" >&2; return 1; }
|
|
3128
3280
|
case "$wave" in ''|*[!0-9]*) echo "cr-wave: --wave must be a positive integer" >&2; return 1 ;; esac
|
|
3129
3281
|
local repo_root; repo_root=$(get_repo_root)
|
|
@@ -3274,6 +3426,9 @@ if not matched and pre_raw:
|
|
|
3274
3426
|
elif inc_new and pre_raw == "new": matched = NEW_LABEL
|
|
3275
3427
|
print(json.dumps({
|
|
3276
3428
|
"changes": rows, "ids": [r["cr_id"] for r in rows], "count": len(rows),
|
|
3429
|
+
# The same ids as one readable string, for an operator message. A list renders as its
|
|
3430
|
+
# JSON in a rendered string, which is right for a payload and wrong for a sentence.
|
|
3431
|
+
"ids_csv": ", ".join(r["cr_id"] for r in rows),
|
|
3277
3432
|
"select_options": opts, "matched_label": matched,
|
|
3278
3433
|
"main_label": MAIN_LABEL if inc_main else "",
|
|
3279
3434
|
"new_label": NEW_LABEL if inc_new else "",
|
|
@@ -3331,11 +3486,11 @@ wave = wave_of(dspec, wave_of(dmodel, 1))
|
|
|
3331
3486
|
# spec of another open change is found by walking the context's features rather than
|
|
3332
3487
|
# assuming it belongs to this one — two features of one context can both have changes open.
|
|
3333
3488
|
def spec_of(other):
|
|
3334
|
-
for c in sorted(glob.glob(os.path.join(ctx, "*", "changes", other, "delta-spec.md"))):
|
|
3489
|
+
for c in sorted(glob.glob(os.path.join(glob.escape(ctx), "*", "changes", glob.escape(other), "delta-spec.md"))):
|
|
3335
3490
|
return c
|
|
3336
3491
|
return ""
|
|
3337
3492
|
pred, sib, pred_rows, sib_rows, pred_specs = [], [], [], [], []
|
|
3338
|
-
for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
|
|
3493
|
+
for f in sorted(glob.glob(os.path.join(glob.escape(ctx), "domain-model.*.delta.md"))):
|
|
3339
3494
|
base = os.path.basename(f)
|
|
3340
3495
|
other = base[len("domain-model."):-len(".delta.md")]
|
|
3341
3496
|
if other == cr:
|
|
@@ -3367,6 +3522,17 @@ print(json.dumps({
|
|
|
3367
3522
|
"predecessor_docs": pred + pred_specs,
|
|
3368
3523
|
# Same or higher wave = nothing orders it against this one. Visible, never assumed.
|
|
3369
3524
|
"sibling_files": sib, "siblings": sib_rows, "sibling_count": len(sib),
|
|
3525
|
+
# EVERYTHING A PLANNER SHOULD SEE, in one list, for the same one-var reason. Siblings
|
|
3526
|
+
# belong in it as much as predecessors do: a planner told "do not assume a same-wave
|
|
3527
|
+
# delta, and say so where one touches the same aggregate" cannot name an overlap it
|
|
3528
|
+
# was never shown, and the gate one stage later raises a finding for the silence.
|
|
3529
|
+
# Their MODEL deltas only — the aggregate a sibling touches is what makes it an
|
|
3530
|
+
# overlap, and their delta-specs would double the payload to say nothing more about it.
|
|
3531
|
+
"related_docs": pred + pred_specs + sib,
|
|
3532
|
+
# Who is who, as text for a prompt. The file list alone cannot say which entries are
|
|
3533
|
+
# committed to landing first and which are merely open beside this one.
|
|
3534
|
+
"predecessor_ids": ", ".join("%s (wave %d)" % (r["cr_id"], r["wave"]) for r in pred_rows),
|
|
3535
|
+
"sibling_ids": ", ".join("%s (wave %d)" % (r["cr_id"], r["wave"]) for r in sib_rows),
|
|
3370
3536
|
}))
|
|
3371
3537
|
PY
|
|
3372
3538
|
}
|
|
@@ -3384,8 +3550,9 @@ PY
|
|
|
3384
3550
|
# EARLY WARNING, NOT ENFORCEMENT. An overlap is not an error: two changes to one
|
|
3385
3551
|
# aggregate are ordinary, and expand-then-contract is the way through. What matters is
|
|
3386
3552
|
# 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
|
|
3388
|
-
# base_version
|
|
3553
|
+
# merge time, which is why this runs at the gate while the HARD refusals stay at the merge:
|
|
3554
|
+
# cr-merge-check holds the base_version comparison, the merged-not-closed test and the
|
|
3555
|
+
# lower-wave predecessor test, and finalize-delta stops on each.
|
|
3389
3556
|
#
|
|
3390
3557
|
# The relation follows the wave: a LOWER wave is a predecessor (committed to land first —
|
|
3391
3558
|
# an overlap with it is a dependency to declare), the SAME wave is a sibling (nothing
|
|
@@ -3436,7 +3603,7 @@ wave = wave_of(read(os.path.join(fd, "changes", cr, "delta-spec.md")), wave_of(m
|
|
|
3436
3603
|
mine = aggregates_of(mine_txt)
|
|
3437
3604
|
mine_set = set(mine)
|
|
3438
3605
|
rows, parts, npred, nsib, nsucc = [], [], 0, 0, 0
|
|
3439
|
-
for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
|
|
3606
|
+
for f in sorted(glob.glob(os.path.join(glob.escape(ctx), "domain-model.*.delta.md"))):
|
|
3440
3607
|
other = os.path.basename(f)[len("domain-model."):-len(".delta.md")]
|
|
3441
3608
|
if other == cr:
|
|
3442
3609
|
continue
|
|
@@ -3453,6 +3620,9 @@ for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
|
|
|
3453
3620
|
parts.append("%s (wave %d, %s): %s" % (other, w, relation, ", ".join(shared)))
|
|
3454
3621
|
print(json.dumps({
|
|
3455
3622
|
"cr_id": cr, "wave": wave, "aggregates": mine,
|
|
3623
|
+
# The same names as one readable string, for a prompt. A list renders as its JSON in a
|
|
3624
|
+
# rendered string, which is right for a payload and wrong for a sentence.
|
|
3625
|
+
"aggregates_csv": ", ".join(mine),
|
|
3456
3626
|
"overlaps": rows, "overlap_count": len(rows),
|
|
3457
3627
|
"predecessor_overlaps": npred, "sibling_overlaps": nsib,
|
|
3458
3628
|
"successor_overlaps": nsucc,
|
|
@@ -3486,27 +3656,72 @@ cmd_cr_activate() {
|
|
|
3486
3656
|
[ -n "$cr_id" ] || { echo "cr-activate: --cr-id is required" >&2; return 1; }
|
|
3487
3657
|
local repo_root; repo_root=$(get_repo_root)
|
|
3488
3658
|
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"
|
|
3659
|
+
local out; out=$(_active_version "$fd" activate "$cr_id") || return 1
|
|
3660
|
+
printf '%s\n' "$out"
|
|
3661
|
+
# A REFUSAL IS AN ERROR, NOT A RESULT — so it leaves through the exit code, like
|
|
3662
|
+
# every other way this command can fail. When the id is not in the ledger nothing
|
|
3663
|
+
# moves and the JSON reports active_change: "", which is precisely what the calling
|
|
3664
|
+
# step binds; an empty active change then reads as "no change" in every condition
|
|
3665
|
+
# downstream, and the run continues against a target it never selected. Refusing
|
|
3666
|
+
# here turns that into the engine's own stop, carrying the message to the operator.
|
|
3667
|
+
#
|
|
3668
|
+
# THIS IS REACHABLE, not defensive: cr-list offers what is on DISK (changes/<id>/ is
|
|
3669
|
+
# the authority for "open") and reports `tracked` separately, so a change whose
|
|
3670
|
+
# directory survived a crashed run is still offered while the ledger has forgotten it.
|
|
3671
|
+
# `ok` starts True and is flipped only by a refusal, so an EXPLICIT False is the test.
|
|
3672
|
+
local ok; ok=$(printf '%s' "$out" | _json_field ok)
|
|
3673
|
+
if [ "$ok" = "False" ] || [ "$ok" = "false" ]; then
|
|
3674
|
+
printf 'cr-activate: %s\n' "$(printf '%s' "$out" | _json_field error)" >&2
|
|
3675
|
+
return 1
|
|
3676
|
+
fi
|
|
3490
3677
|
}
|
|
3491
3678
|
|
|
3679
|
+
# ARCHIVE, in either of the two ways a change leaves the open set.
|
|
3680
|
+
#
|
|
3681
|
+
# default CLOSED — it shipped. The directory is archived and the version bumps, which
|
|
3682
|
+
# is close-delta's whole job and the behaviour every existing caller gets.
|
|
3683
|
+
# --recall WITHDRAWN — it never shipped. The same archive, and NO version bump: a
|
|
3684
|
+
# version records what a change shipped as, and a recalled change shipped
|
|
3685
|
+
# nothing, so bumping would burn a number on work that was never merged.
|
|
3686
|
+
#
|
|
3687
|
+
# The two were fused until recall-delta needed the archive half on its own, which is why
|
|
3688
|
+
# withdrawing a change could not be "archive alone". Splitting them here rather than in
|
|
3689
|
+
# each caller keeps ONE place that knows how a change's documents are put away — the
|
|
3690
|
+
# directory, and the model delta that lives beside the master rather than inside it.
|
|
3492
3691
|
cmd_cr_archive() {
|
|
3493
|
-
local cr_id="" feature_dir_arg=""
|
|
3692
|
+
local cr_id="" feature_dir_arg="" recall="" reason=""
|
|
3494
3693
|
while [ $# -gt 0 ]; do
|
|
3495
3694
|
case "$1" in
|
|
3496
3695
|
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3497
3696
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3498
|
-
--
|
|
3697
|
+
--recall) recall="1"; shift ;;
|
|
3698
|
+
--reason) reason="$2"; shift 2 ;;
|
|
3699
|
+
--help|-h) echo "cr-archive --cr-id <id> [--feature-dir <path>] [--recall] [--reason <text>]"; return 0 ;;
|
|
3499
3700
|
*) echo "cr-archive: unknown arg '$1'" >&2; return 1 ;;
|
|
3500
3701
|
esac
|
|
3501
3702
|
done
|
|
3703
|
+
# A caller that picked from a user_select menu hands over the whole LABEL
|
|
3704
|
+
# ("CR-… · wave 2 · title"), not the id. Every other cr-* op takes the first token;
|
|
3705
|
+
# this one did not, so a label arrived here as a directory name that does not exist
|
|
3706
|
+
# and the archive silently moved nothing.
|
|
3707
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3502
3708
|
[ -n "$cr_id" ] || { echo "cr-archive: --cr-id required" >&2; return 1; }
|
|
3503
3709
|
local repo_root; repo_root=$(get_repo_root)
|
|
3504
3710
|
local fd="$feature_dir_arg"
|
|
3505
3711
|
[ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
|
|
3506
3712
|
[ -n "$fd" ] || { echo "cr-archive: no feature set and no --feature-dir" >&2; return 1; }
|
|
3507
3713
|
[[ "$fd" != /* ]] && fd="$repo_root/$fd"
|
|
3714
|
+
# THE LEDGER EDIT COMES FIRST, because it is the only step that can REFUSE. `recall`
|
|
3715
|
+
# will not withdraw a change active_version.json does not carry, and a refusal has to
|
|
3716
|
+
# be a no-op: archiving before asking would move the directory and only then report
|
|
3717
|
+
# that the change was never open. `close` cannot refuse, so its side effects are
|
|
3718
|
+
# unchanged by the ordering.
|
|
3719
|
+
local op="close"; [ -n "$recall" ] && op="recall"
|
|
3720
|
+
local av; av=$(_active_version "$fd" "$op" "$cr_id")
|
|
3721
|
+
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")
|
|
3722
|
+
|
|
3508
3723
|
local cr_dir="$fd/changes/$cr_id" archive_root="$fd/changes/archive" archived=""
|
|
3509
|
-
if [ -d "$cr_dir" ]; then
|
|
3724
|
+
if [ -n "$ledger_ok" ] && [ -d "$cr_dir" ]; then
|
|
3510
3725
|
mkdir -p "$archive_root"
|
|
3511
3726
|
rm -rf "$archive_root/$cr_id"
|
|
3512
3727
|
mv "$cr_dir" "$archive_root/$cr_id"
|
|
@@ -3519,21 +3734,565 @@ cmd_cr_archive() {
|
|
|
3519
3734
|
# in-flight to every other open delta.
|
|
3520
3735
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3521
3736
|
local dmodel="$ctx_dir/domain-model.$cr_id.delta.md" model_archived=""
|
|
3522
|
-
if [ -f "$dmodel" ]; then
|
|
3737
|
+
if [ -n "$ledger_ok" ] && [ -f "$dmodel" ]; then
|
|
3523
3738
|
mkdir -p "$archive_root/$cr_id"
|
|
3524
3739
|
mv -f "$dmodel" "$archive_root/$cr_id/domain-model.delta.md"
|
|
3525
3740
|
model_archived="$archive_root/$cr_id/domain-model.delta.md"
|
|
3526
3741
|
fi
|
|
3527
|
-
#
|
|
3528
|
-
#
|
|
3529
|
-
|
|
3530
|
-
|
|
3742
|
+
# A RECALLED change is marked as such where the evidence will be looked for — inside
|
|
3743
|
+
# its own archive — and the marker is written by the script, not by a model, because
|
|
3744
|
+
# everything on it is a fact the script already holds: the id, the date, and the
|
|
3745
|
+
# reason the caller passed.
|
|
3746
|
+
local marker=""
|
|
3747
|
+
if [ -n "$recall" ] && [ -n "$archived" ]; then
|
|
3748
|
+
marker="$archived/RECALLED.md"
|
|
3749
|
+
AW_MARK="$marker" AW_CR="$cr_id" AW_REASON="$reason" python3 - <<'PY'
|
|
3750
|
+
import datetime, os
|
|
3751
|
+
when = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
|
|
3752
|
+
cr, reason = os.environ["AW_CR"], os.environ.get("AW_REASON", "").strip()
|
|
3753
|
+
open(os.environ["AW_MARK"], "w", encoding="utf-8").write(
|
|
3754
|
+
"# RECALLED — %s\n\n"
|
|
3755
|
+
"**Recalled**: %s\n"
|
|
3756
|
+
"**Reason**: %s\n\n"
|
|
3757
|
+
"This change was WITHDRAWN before it was merged. Its delta documents are archived here\n"
|
|
3758
|
+
"unchanged; the feature's spec.md and the context's domain-model.md never carried it,\n"
|
|
3759
|
+
"and the version was not bumped — no release shipped this work.\n\n"
|
|
3760
|
+
"Any code /appweaver implement generated for this change is NOT reverted by a recall.\n"
|
|
3761
|
+
"Revert it with git if it should not stay.\n"
|
|
3762
|
+
% (cr, when, reason or "(not stated)"))
|
|
3763
|
+
PY
|
|
3764
|
+
fi
|
|
3765
|
+
AW_AV="$av" AW_ARCHIVED="$archived" AW_MODEL="$model_archived" AW_MARK="$marker" \
|
|
3766
|
+
AW_RECALL="$recall" AW_CR="$cr_id" python3 - <<'PY'
|
|
3531
3767
|
import json, os
|
|
3532
3768
|
try: av = json.loads(os.environ.get("AW_AV") or "{}")
|
|
3533
3769
|
except Exception: av = {}
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3770
|
+
recalled = bool(os.environ.get("AW_RECALL"))
|
|
3771
|
+
out = {
|
|
3772
|
+
# The ledger edit is what can refuse (recall will not touch a change the ledger does
|
|
3773
|
+
# not carry), so its verdict is this command's verdict.
|
|
3774
|
+
"ok": av.get("ok", True),
|
|
3775
|
+
"archived_dir": os.environ.get("AW_ARCHIVED", ""),
|
|
3776
|
+
"archived_model": os.environ.get("AW_MODEL", ""),
|
|
3777
|
+
"recalled": recalled,
|
|
3778
|
+
"recall_marker": os.environ.get("AW_MARK", ""),
|
|
3779
|
+
"version": av.get("version", "v1.1"),
|
|
3780
|
+
"previous": av.get("previous", "v1.0"),
|
|
3781
|
+
# Which of the two ways it left the open set. Exactly one is ever non-empty.
|
|
3782
|
+
"closed_change": av.get("closed_change", ""),
|
|
3783
|
+
"recalled_change": av.get("recalled_change", ""),
|
|
3784
|
+
"active_change": av.get("active_change", ""),
|
|
3785
|
+
"open_changes": av.get("open_changes", []),
|
|
3786
|
+
"open_count": av.get("open_count", 0),
|
|
3787
|
+
}
|
|
3788
|
+
if av.get("error"): out["error"] = av["error"]
|
|
3789
|
+
print(json.dumps(out))
|
|
3790
|
+
PY
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
# ──────────────────────────── context-version-bump ─────────────────────────
|
|
3794
|
+
# Move the BOUNDED CONTEXT's version, the way version-stamp moves a feature's.
|
|
3795
|
+
#
|
|
3796
|
+
# The model's header has always declared itself script-stamped — `**Context version**:
|
|
3797
|
+
# v1.0 <!-- script-stamped; close-delta bumps it when a closing delta touched the
|
|
3798
|
+
# model -->` — and specify tells the authoring model not to write it. Nothing wrote it,
|
|
3799
|
+
# so every context sat at the template's v1.0 for its whole life and the one instruction
|
|
3800
|
+
# the model was given about that field was the only thing keeping it accurate.
|
|
3801
|
+
#
|
|
3802
|
+
# WHY IT IS A CONTEXT-LEVEL NUMBER. A feature's version says what that feature shipped;
|
|
3803
|
+
# the model is shared by every feature in the context, so a change to it is a change to
|
|
3804
|
+
# all of them and no single feature's version can record it. The two spines move
|
|
3805
|
+
# independently and neither is derived from the other.
|
|
3806
|
+
#
|
|
3807
|
+
# ONLY WHEN THE MODEL ACTUALLY CHANGED. The caller passes --touched-model; a behaviour-only
|
|
3808
|
+
# change closes without moving this number, exactly as it leaves domain-model.md alone.
|
|
3809
|
+
# Refusing to bump is reported, not an error — most changes never touch the model.
|
|
3810
|
+
cmd_context_version_bump() {
|
|
3811
|
+
local feature_dir_arg="" touched="" cr_id=""
|
|
3812
|
+
while [ $# -gt 0 ]; do
|
|
3813
|
+
case "$1" in
|
|
3814
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3815
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3816
|
+
# Takes a VALUE rather than being a bare flag: a workflow cannot omit an
|
|
3817
|
+
# argument conditionally (an include's arg list is rendered whole), so the
|
|
3818
|
+
# caller always passes it and says true or false. Anything but `true` is false.
|
|
3819
|
+
--touched-model) [ "$2" = "true" ] && touched="1" || touched=""; shift 2 ;;
|
|
3820
|
+
--help|-h) echo "context-version-bump [--feature-dir <path>] [--cr-id <id>] [--touched-model true|false] -> {ok, bumped, previous, version, model, context}"; return 0 ;;
|
|
3821
|
+
*) echo "context-version-bump: unknown arg '$1'" >&2; return 1 ;;
|
|
3822
|
+
esac
|
|
3823
|
+
done
|
|
3824
|
+
# The whole LABEL arrives when a caller picked from a menu; every cr-* op takes its
|
|
3825
|
+
# first token. Here the id only reaches the Change History row, so a label would have
|
|
3826
|
+
# been recorded verbatim rather than failing loudly — the quietest of the family's
|
|
3827
|
+
# inconsistencies, and the reason to close it rather than wait for a caller to hit it.
|
|
3828
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3829
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
3830
|
+
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; }
|
|
3831
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3832
|
+
local model="$ctx_dir/domain-model.md"
|
|
3833
|
+
local ctx_rel="${ctx_dir#$repo_root/}"
|
|
3834
|
+
local model_rel="${model#$repo_root/}"
|
|
3835
|
+
|
|
3836
|
+
local current; current=$(_read_header "$model" "Context version")
|
|
3837
|
+
[ -n "$current" ] || current="v1.0"
|
|
3838
|
+
case "$current" in v*) ;; *) current="v$current" ;; esac
|
|
3839
|
+
|
|
3840
|
+
local bumped=false next="$current" reason=""
|
|
3841
|
+
if [ ! -f "$model" ]; then
|
|
3842
|
+
reason="this feature's context has no domain-model.md, so there is no context version to move"
|
|
3843
|
+
elif [ -z "$touched" ]; then
|
|
3844
|
+
reason="the closing change carried no model delta, so the context is unchanged"
|
|
3845
|
+
else
|
|
3846
|
+
next=$(AW_CUR="$current" python3 -c '
|
|
3847
|
+
import os, re
|
|
3848
|
+
m = re.match(r"v?(\d+)(?:\.(\d+))?", os.environ["AW_CUR"].strip())
|
|
3849
|
+
maj = int(m.group(1)) if m else 1
|
|
3850
|
+
mnr = int(m.group(2)) if (m and m.group(2)) else 0
|
|
3851
|
+
print("v%d.%d" % (maj, mnr + 1))')
|
|
3852
|
+
_stamp_header "$model" "Context version" "$next"
|
|
3853
|
+
bumped=true
|
|
3854
|
+
reason="the closing change merged a model delta into this context"
|
|
3855
|
+
fi
|
|
3856
|
+
printf '{"ok":true,"bumped":%s,"previous":"%s","version":"%s","model":"%s","model_exists":%s,"context":"%s","reason":"%s"}\n' \
|
|
3857
|
+
"$bumped" "$(json_escape "$current")" "$(json_escape "$next")" \
|
|
3858
|
+
"$(json_escape "$model_rel")" "$([ -f "$model" ] && echo true || echo false)" \
|
|
3859
|
+
"$(json_escape "$ctx_rel")" "$(json_escape "$reason")"
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
# ───────────────────────────── cr-snapshot ─────────────────────────────────
|
|
3863
|
+
# Copy the two MASTERS into changes/<id>/pre-merge/ immediately before finalize-delta
|
|
3864
|
+
# rewrites them. The merge is the one act of the CR loop that edits a long-lived
|
|
3865
|
+
# hand-curated document in place, and until this existed the only record of what those
|
|
3866
|
+
# documents said beforehand was git — which is a different tool's answer to the question,
|
|
3867
|
+
# and unavailable at all if the merge and the commit are not interleaved.
|
|
3868
|
+
#
|
|
3869
|
+
# WHAT IT IS FOR. A recall after a merge has to put the masters back, and it may only do so
|
|
3870
|
+
# when nothing else landed in between — the `pre-merge` copy is the content to restore and
|
|
3871
|
+
# cr-merge-check's base_version comparison is what answers whether restoring is still safe.
|
|
3872
|
+
# Taken together they are the difference between "un-merge" and "hope".
|
|
3873
|
+
#
|
|
3874
|
+
# IT IS A COPY, NOT A MOVE, and it is overwritten on a re-run: finalize-delta may be
|
|
3875
|
+
# attempted more than once (a red downstream fence aborts it), and the snapshot that matters
|
|
3876
|
+
# is the one taken by the attempt that actually merged. The manifest records the version and
|
|
3877
|
+
# the date so a reader can tell which.
|
|
3878
|
+
#
|
|
3879
|
+
# The snapshot travels WITH the change — changes/<id>/ is what cr-archive moves — so a
|
|
3880
|
+
# closed change carries the before-image of the masters it moved, in the archive, forever.
|
|
3881
|
+
cmd_cr_snapshot() {
|
|
3882
|
+
local cr_id="" feature_dir_arg=""
|
|
3883
|
+
while [ $# -gt 0 ]; do
|
|
3884
|
+
case "$1" in
|
|
3885
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3886
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3887
|
+
--help|-h) echo "cr-snapshot --cr-id <id> [--feature-dir <path>] -> {ok, dir, files[], count, version}"; return 0 ;;
|
|
3888
|
+
*) echo "cr-snapshot: unknown arg '$1'" >&2; return 1 ;;
|
|
3889
|
+
esac
|
|
3890
|
+
done
|
|
3891
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3892
|
+
[ -n "$cr_id" ] || { echo "cr-snapshot: --cr-id required" >&2; return 1; }
|
|
3893
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
3894
|
+
local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-snapshot: no feature set and no --feature-dir" >&2; return 1; }
|
|
3895
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3896
|
+
AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_CR="$cr_id" python3 - <<'PY'
|
|
3897
|
+
import json, os, re, shutil, datetime
|
|
3898
|
+
fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]; cr = os.environ["AW_CR"]
|
|
3899
|
+
def rel(p): return os.path.relpath(p, root)
|
|
3900
|
+
def header(path, name, default=""):
|
|
3901
|
+
try: t = open(path, encoding="utf-8").read()
|
|
3902
|
+
except Exception: return default
|
|
3903
|
+
m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), t, re.M)
|
|
3904
|
+
return m.group(1).strip() if m else default
|
|
3905
|
+
|
|
3906
|
+
snap = os.path.join(fd, "changes", cr, "pre-merge")
|
|
3907
|
+
os.makedirs(snap, exist_ok=True)
|
|
3908
|
+
spec = os.path.join(fd, "spec.md")
|
|
3909
|
+
model = os.path.join(ctx, "domain-model.md")
|
|
3910
|
+
copied = []
|
|
3911
|
+
# Flat names, not the source tree's shape: there are exactly two masters and they live at
|
|
3912
|
+
# different levels, so a nested copy would encode the layout of the day it was taken.
|
|
3913
|
+
for src, name in ((spec, "spec.md"), (model, "domain-model.md")):
|
|
3914
|
+
if os.path.isfile(src):
|
|
3915
|
+
shutil.copy2(src, os.path.join(snap, name))
|
|
3916
|
+
copied.append(name)
|
|
3917
|
+
|
|
3918
|
+
version = ""
|
|
3919
|
+
try:
|
|
3920
|
+
version = json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", "")
|
|
3921
|
+
except Exception:
|
|
3922
|
+
pass
|
|
3923
|
+
|
|
3924
|
+
manifest = {
|
|
3925
|
+
"cr_id": cr,
|
|
3926
|
+
"taken": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
3927
|
+
"version": version,
|
|
3928
|
+
"context_version": header(model, "Context version"),
|
|
3929
|
+
"files": copied,
|
|
3930
|
+
"sources": {"spec.md": rel(spec), "domain-model.md": rel(model)},
|
|
3931
|
+
# Stated in the file itself, because the restore is the reader's whole reason for
|
|
3932
|
+
# opening it and the condition on it is not obvious from the content.
|
|
3933
|
+
"note": ("Masters as they stood immediately before finalize-delta merged %s. "
|
|
3934
|
+
"Restoring is only safe while no other change finalized afterwards — "
|
|
3935
|
+
"cr-merge-check's base_version comparison is what answers that." % cr),
|
|
3936
|
+
}
|
|
3937
|
+
with open(os.path.join(snap, "manifest.json"), "w", encoding="utf-8") as f:
|
|
3938
|
+
json.dump(manifest, f, indent=2)
|
|
3939
|
+
f.write("\n")
|
|
3940
|
+
|
|
3941
|
+
print(json.dumps({"ok": True, "cr_id": cr, "dir": rel(snap), "files": copied,
|
|
3942
|
+
"count": len(copied), "version": version,
|
|
3943
|
+
"manifest": rel(os.path.join(snap, "manifest.json"))}))
|
|
3944
|
+
PY
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
# ───────────────────────────── cr-merge-check ──────────────────────────────
|
|
3948
|
+
# The two mechanical refusals finalize-delta needs, answered in one call from evidence on
|
|
3949
|
+
# disk — the merge-side twin of cr-recall-check below, and shaped the same way so the two
|
|
3950
|
+
# gates of the CR loop read alike.
|
|
3951
|
+
#
|
|
3952
|
+
# REBASE. `cr-setup` stamps `**Base version**:` into the delta-spec at the moment the change
|
|
3953
|
+
# opens: the master version the delta was authored AGAINST. If spec.md has moved since —
|
|
3954
|
+
# because another change finalized and closed in between — then the delta's MODIFIED items
|
|
3955
|
+
# name a BEFORE the master no longer says, and merging them is last-writer-wins against work
|
|
3956
|
+
# that already landed. Both sides are normalised to `vX.Y` by cr-setup, so this is an exact
|
|
3957
|
+
# string comparison and never a judgement. A delta with no stamped header (authored before
|
|
3958
|
+
# the field existed, or hand-made) reports `base_version: ""` and does NOT block — an absent
|
|
3959
|
+
# stamp is no evidence of a move, and refusing on it would strand every legacy change.
|
|
3960
|
+
#
|
|
3961
|
+
# PREDECESSORS. A wave is a dependency level: a LOWER wave is committed to landing first, and
|
|
3962
|
+
# `plan` puts exactly those deltas in the planner's context as the base this one builds on.
|
|
3963
|
+
# Merging out of wave order therefore folds in behaviour that assumes a predecessor the
|
|
3964
|
+
# masters do not yet carry. `depends_on` is stamped nowhere, so the wave is the only
|
|
3965
|
+
# dependency signal that exists; same-wave changes are independent by definition and are
|
|
3966
|
+
# never counted.
|
|
3967
|
+
#
|
|
3968
|
+
# THE SCAN IS CONTEXT-WIDE, matching cr-paths' definition of a predecessor: two features of
|
|
3969
|
+
# one bounded context share domain-model.md, so a lower-wave change on a sibling feature is
|
|
3970
|
+
# as much a predecessor as one on this feature. Both halves are read — the changes/<id>/
|
|
3971
|
+
# directories AND the pending model deltas — because a behaviour-only change has no model
|
|
3972
|
+
# delta and would be invisible to a scan of those alone.
|
|
3973
|
+
cmd_cr_merge_check() {
|
|
3974
|
+
local cr_id="" feature_dir_arg=""
|
|
3975
|
+
while [ $# -gt 0 ]; do
|
|
3976
|
+
case "$1" in
|
|
3977
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
3978
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3979
|
+
--help|-h) echo "cr-merge-check --cr-id <id> [--feature-dir <path>] -> {rebase_needed, base_version, current_version, blockers[], blocker_count, wave, …}"; return 0 ;;
|
|
3980
|
+
*) echo "cr-merge-check: unknown arg '$1'" >&2; return 1 ;;
|
|
3981
|
+
esac
|
|
3982
|
+
done
|
|
3983
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3984
|
+
[ -n "$cr_id" ] || { echo "cr-merge-check: --cr-id required" >&2; return 1; }
|
|
3985
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
3986
|
+
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; }
|
|
3987
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
3988
|
+
AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_CR="$cr_id" python3 - <<'PY'
|
|
3989
|
+
import json, os, re
|
|
3990
|
+
fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]; cr = os.environ["AW_CR"]
|
|
3991
|
+
def rel(p): return os.path.relpath(p, root)
|
|
3992
|
+
|
|
3993
|
+
def header(path, name, default=""):
|
|
3994
|
+
try: t = open(path, encoding="utf-8").read()
|
|
3995
|
+
except Exception: return default
|
|
3996
|
+
m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), t, re.M)
|
|
3997
|
+
if not m: return default
|
|
3998
|
+
v = m.group(1).strip()
|
|
3999
|
+
# An unfilled template placeholder is not a value. `[vX.Y]` means the stamp never ran.
|
|
4000
|
+
return "" if (v.startswith("[") and v.endswith("]")) else v
|
|
4001
|
+
|
|
4002
|
+
def norm(v):
|
|
4003
|
+
v = (v or "").strip()
|
|
4004
|
+
if not v: return ""
|
|
4005
|
+
return v if v.startswith("v") else "v" + v
|
|
4006
|
+
|
|
4007
|
+
# THIS change's own spec half and wave.
|
|
4008
|
+
my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
|
|
4009
|
+
def wave_of(p, default=1):
|
|
4010
|
+
try: t = open(p, encoding="utf-8").read()
|
|
4011
|
+
except Exception: return default
|
|
4012
|
+
m = re.search(r"^\*\*Wave\*\*:[ \t]*(\d+)", t, re.M)
|
|
4013
|
+
return int(m.group(1)) if m else default
|
|
4014
|
+
wave = wave_of(my_spec)
|
|
4015
|
+
base_version = norm(header(my_spec, "Base version"))
|
|
4016
|
+
|
|
4017
|
+
# The master's CURRENT version, from the ledger — the same authority version-read reports,
|
|
4018
|
+
# so the two can never disagree about what the number is.
|
|
4019
|
+
current = ""
|
|
4020
|
+
try:
|
|
4021
|
+
current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
|
|
4022
|
+
except Exception:
|
|
4023
|
+
current = ""
|
|
4024
|
+
if not current:
|
|
4025
|
+
current = norm(header(os.path.join(fd, "spec.md"), "Version"))
|
|
4026
|
+
|
|
4027
|
+
# Only a STAMPED base that DISAGREES blocks. No stamp, or no readable current version, is
|
|
4028
|
+
# absence of evidence and is not treated as a move.
|
|
4029
|
+
rebase_needed = bool(base_version and current and base_version != current)
|
|
4030
|
+
|
|
4031
|
+
# EVERY open change in the context, both halves. changes/<id>/ under each feature is the
|
|
4032
|
+
# authority for "open"; the pending model deltas are unioned in so a model-only artifact of
|
|
4033
|
+
# a half-built change is still seen.
|
|
4034
|
+
opens = {}
|
|
4035
|
+
for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
4036
|
+
ch = os.path.join(ctx, feat, "changes")
|
|
4037
|
+
if not os.path.isdir(ch): continue
|
|
4038
|
+
for name in sorted(os.listdir(ch)):
|
|
4039
|
+
d = os.path.join(ch, name)
|
|
4040
|
+
if name == "archive" or not os.path.isdir(d): continue
|
|
4041
|
+
opens.setdefault(name, {"cr_id": name, "wave": wave_of(os.path.join(d, "delta-spec.md")),
|
|
4042
|
+
"feature": feat})
|
|
4043
|
+
for f in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
4044
|
+
m = re.match(r"^domain-model\.(.+)\.delta\.md$", f)
|
|
4045
|
+
if not m: continue
|
|
4046
|
+
other = m.group(1)
|
|
4047
|
+
opens.setdefault(other, {"cr_id": other, "wave": wave_of(os.path.join(ctx, f)), "feature": ""})
|
|
4048
|
+
|
|
4049
|
+
blockers = [v for k, v in sorted(opens.items()) if k != cr and v["wave"] < wave]
|
|
4050
|
+
|
|
4051
|
+
# THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
|
|
4052
|
+
# bumps, so between the two spec.md carries a change the number does not yet reflect — and a
|
|
4053
|
+
# second delta sharing the same base_version would compare equal and merge onto masters that
|
|
4054
|
+
# had already shifted under it. An id appearing in spec.md is the on-disk proof that the
|
|
4055
|
+
# merge ran (it is the Change History row finalize-delta appends), which is the same
|
|
4056
|
+
# evidence cr-recall-check reads for the same question.
|
|
4057
|
+
spec_text = ""
|
|
4058
|
+
try:
|
|
4059
|
+
spec_text = open(os.path.join(fd, "spec.md"), encoding="utf-8").read()
|
|
4060
|
+
except Exception:
|
|
4061
|
+
pass
|
|
4062
|
+
merged_open = [v for k, v in sorted(opens.items()) if k != cr and k in spec_text]
|
|
4063
|
+
|
|
4064
|
+
print(json.dumps({
|
|
4065
|
+
"ok": True, "cr_id": cr, "wave": wave,
|
|
4066
|
+
"exists": os.path.isdir(os.path.join(fd, "changes", cr)),
|
|
4067
|
+
# REBASE half — the masters moved because a change CLOSED and bumped the number.
|
|
4068
|
+
"base_version": base_version, "current_version": current,
|
|
4069
|
+
"rebase_needed": rebase_needed,
|
|
4070
|
+
"base_stamped": bool(base_version),
|
|
4071
|
+
# PREDECESSOR half. Lower wave = committed to land first.
|
|
4072
|
+
"blockers": blockers, "blocker_count": len(blockers),
|
|
4073
|
+
"has_blockers": len(blockers) > 0,
|
|
4074
|
+
"blocker_ids": ", ".join("%s (wave %d)" % (b["cr_id"], b["wave"]) for b in blockers),
|
|
4075
|
+
# The other way the masters move: a change MERGED and has not closed yet.
|
|
4076
|
+
"merged_open": merged_open, "merged_open_count": len(merged_open),
|
|
4077
|
+
"has_merged_open": len(merged_open) > 0,
|
|
4078
|
+
"merged_open_ids": ", ".join(m["cr_id"] for m in merged_open),
|
|
4079
|
+
# All three refusals as ONE positive flag, because the engine takes no compound condition
|
|
4080
|
+
# and a gate that must read three booleans would need three passes over one question.
|
|
4081
|
+
"merge_ok": (not rebase_needed) and len(blockers) == 0 and len(merged_open) == 0,
|
|
4082
|
+
}))
|
|
4083
|
+
PY
|
|
4084
|
+
}
|
|
4085
|
+
|
|
4086
|
+
# ───────────────────────────── cr-recall-check ─────────────────────────────
|
|
4087
|
+
# The two mechanical refusals recall-delta needs, answered in one call from evidence on
|
|
4088
|
+
# disk rather than from anything a model read.
|
|
4089
|
+
#
|
|
4090
|
+
# MERGED. finalize-delta appends a Change History row carrying the CR id to spec.md, so
|
|
4091
|
+
# the id appearing in that file is the on-disk proof the merge ran. A merged change is
|
|
4092
|
+
# not recallable: the masters describe behaviour that includes it, and taking it back is
|
|
4093
|
+
# document surgery — the remedy is a NEW change that reverses it.
|
|
4094
|
+
#
|
|
4095
|
+
# DEPENDANTS. A wave is a dependency level: a higher wave is planned and built ON TOP of
|
|
4096
|
+
# the lower ones. Withdrawing a lower-wave change therefore strands every open change
|
|
4097
|
+
# above it. `depends_on` is stamped nowhere yet, so the wave is the only dependency signal
|
|
4098
|
+
# that actually exists, and it is the conservative one — same-wave changes are independent
|
|
4099
|
+
# by definition and are never counted here.
|
|
4100
|
+
cmd_cr_recall_check() {
|
|
4101
|
+
local cr_id="" feature_dir_arg=""
|
|
4102
|
+
while [ $# -gt 0 ]; do
|
|
4103
|
+
case "$1" in
|
|
4104
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
4105
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4106
|
+
--help|-h) echo "cr-recall-check --cr-id <id> [--feature-dir <path>] -> {merged, dependants[], dependant_count, wave, …}"; return 0 ;;
|
|
4107
|
+
*) echo "cr-recall-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4108
|
+
esac
|
|
4109
|
+
done
|
|
4110
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
4111
|
+
[ -n "$cr_id" ] || { echo "cr-recall-check: --cr-id required" >&2; return 1; }
|
|
4112
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
4113
|
+
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; }
|
|
4114
|
+
AW_FD="$fd" AW_CR="$cr_id" python3 - <<'PY'
|
|
4115
|
+
import json, os, re
|
|
4116
|
+
fd = os.environ["AW_FD"]; cr = os.environ["AW_CR"]
|
|
4117
|
+
changes = os.path.join(fd, "changes")
|
|
4118
|
+
cr_dir = os.path.join(changes, cr)
|
|
4119
|
+
|
|
4120
|
+
def wave_of(d):
|
|
4121
|
+
for name in ("delta-spec.md",):
|
|
4122
|
+
p = os.path.join(d, name)
|
|
4123
|
+
if os.path.isfile(p):
|
|
4124
|
+
m = re.search(r'^\*\*Wave\*\*:[ \t]*(\d+)', open(p, encoding="utf-8").read(), re.M)
|
|
4125
|
+
if m:
|
|
4126
|
+
return int(m.group(1))
|
|
4127
|
+
return 1
|
|
4128
|
+
|
|
4129
|
+
spec = os.path.join(fd, "spec.md")
|
|
4130
|
+
merged, evidence = False, ""
|
|
4131
|
+
if os.path.isfile(spec):
|
|
4132
|
+
for line in open(spec, encoding="utf-8").read().splitlines():
|
|
4133
|
+
if cr in line:
|
|
4134
|
+
merged, evidence = True, line.strip()
|
|
4135
|
+
break
|
|
4136
|
+
|
|
4137
|
+
wave = wave_of(cr_dir) if os.path.isdir(cr_dir) else 1
|
|
4138
|
+
dependants = []
|
|
4139
|
+
if os.path.isdir(changes):
|
|
4140
|
+
for name in sorted(os.listdir(changes)):
|
|
4141
|
+
d = os.path.join(changes, name)
|
|
4142
|
+
if name == cr or name == "archive" or not os.path.isdir(d):
|
|
4143
|
+
continue
|
|
4144
|
+
w = wave_of(d)
|
|
4145
|
+
if w > wave:
|
|
4146
|
+
dependants.append({"cr_id": name, "wave": w})
|
|
4147
|
+
|
|
4148
|
+
print(json.dumps({
|
|
4149
|
+
"ok": True, "cr_id": cr, "exists": os.path.isdir(cr_dir), "wave": wave,
|
|
4150
|
+
"merged": merged, "merge_evidence": evidence,
|
|
4151
|
+
"dependants": dependants,
|
|
4152
|
+
"dependant_ids": ", ".join("%s (wave %d)" % (x["cr_id"], x["wave"]) for x in dependants),
|
|
4153
|
+
"dependant_count": len(dependants),
|
|
4154
|
+
"has_dependants": len(dependants) > 0,
|
|
4155
|
+
}))
|
|
4156
|
+
PY
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
# ──────────────────── plan-gate-stamp / plan-gate-check ────────────────────
|
|
4160
|
+
# THE RECEIPT THAT analyze-plan PASSED, and over WHICH BYTES.
|
|
4161
|
+
#
|
|
4162
|
+
# implement generates from the lane plans and deliberately does NOT read the spec — its
|
|
4163
|
+
# own header says "analyze-plan already conformed the plans to it". That sentence was an
|
|
4164
|
+
# assumption about the order the operator ran things in, not a fact anything checked: the
|
|
4165
|
+
# lane gates test that the plan FILE EXISTS and nothing more, so `plan` → edit spec.md →
|
|
4166
|
+
# `implement` generated from a plan that was never held against the spec it now contradicts.
|
|
4167
|
+
#
|
|
4168
|
+
# WHY NOT COMPARE THE PLANS' `**Version**:` HEADERS. They cannot answer this. version-stamp
|
|
4169
|
+
# moves spec.md, backend-plan.md and frontend-plan.md to the same number in LOCKSTEP at
|
|
4170
|
+
# close-delta, whether or not a plan was re-derived — close-delta says so itself ("a plan
|
|
4171
|
+
# header stamped here is stating the version the slice ships as, not that the plan was
|
|
4172
|
+
# re-derived for it"). Comparing them reads equal always, which is worse than not checking.
|
|
4173
|
+
#
|
|
4174
|
+
# WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
|
|
4175
|
+
# from that spec". The question implement depends on is stronger — "were these plans PROVEN
|
|
4176
|
+
# against this spec" — and a plan authored from the current spec can still be wrong. So the
|
|
4177
|
+
# receipt is written where the proof happens, by analyze-plan, on green, and it hashes every
|
|
4178
|
+
# document the gate actually read: the spec, the context's domain model (check 4 reads it)
|
|
4179
|
+
# and each lane plan that was present.
|
|
4180
|
+
#
|
|
4181
|
+
# The absence of a receipt is INFORMATION, not an error: it means this feature has never
|
|
4182
|
+
# been through the gate, which every tree that predates this op is true of. It is reported
|
|
4183
|
+
# as untracked and blocks nothing. Only a receipt that EXISTS and no longer matches is a
|
|
4184
|
+
# refusal, because that tree has been gated once and the bytes have moved since.
|
|
4185
|
+
_plan_gate_file() { printf '%s/.aw-plan-gate.json' "$1"; }
|
|
4186
|
+
|
|
4187
|
+
cmd_plan_gate_stamp() {
|
|
4188
|
+
local feature_dir_arg="" spec_version="" mode="master"
|
|
4189
|
+
while [ $# -gt 0 ]; do
|
|
4190
|
+
case "$1" in
|
|
4191
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4192
|
+
--spec-version) spec_version="$2"; shift 2 ;;
|
|
4193
|
+
--mode) mode="$2"; shift 2 ;;
|
|
4194
|
+
--help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] -> {file, gated, hashed[], count}"; return 0 ;;
|
|
4195
|
+
*) echo "plan-gate-stamp: unknown arg '$1'" >&2; return 1 ;;
|
|
4196
|
+
esac
|
|
4197
|
+
done
|
|
4198
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
4199
|
+
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; }
|
|
4200
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4201
|
+
AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_VER="$spec_version" AW_MODE="$mode" python3 - <<'PY'
|
|
4202
|
+
import datetime, hashlib, json, os
|
|
4203
|
+
fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
|
|
4204
|
+
def sha(p):
|
|
4205
|
+
try:
|
|
4206
|
+
with open(p, "rb") as f: return hashlib.sha256(f.read()).hexdigest()
|
|
4207
|
+
except Exception: return ""
|
|
4208
|
+
def rel(p): return os.path.relpath(p, root)
|
|
4209
|
+
# Every document the gate READ. A lane that is not declared has no plan on disk and is
|
|
4210
|
+
# simply absent from the map — the check treats a name it does not carry as not gated,
|
|
4211
|
+
# never as changed, so declaring a second lane later does not read as tampering.
|
|
4212
|
+
#
|
|
4213
|
+
# THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
|
|
4214
|
+
# resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
|
|
4215
|
+
# that has not been through `plan` since the rename — every brownfield feature `adopt`
|
|
4216
|
+
# produces — is GATED on those documents. Hashing only the new names would leave such a
|
|
4217
|
+
# feature with an empty plan map: a receipt that looks tracked and fresh while the document
|
|
4218
|
+
# implement actually generates from is unwatched.
|
|
4219
|
+
plans = {}
|
|
4220
|
+
for name in ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
|
|
4221
|
+
h = sha(os.path.join(fd, name))
|
|
4222
|
+
if h: plans[name] = h
|
|
4223
|
+
rec = {
|
|
4224
|
+
"gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
4225
|
+
"gate_mode": os.environ.get("AW_MODE", "master"),
|
|
4226
|
+
"spec_version": os.environ.get("AW_VER", "").strip(),
|
|
4227
|
+
"spec_sha": sha(os.path.join(fd, "spec.md")),
|
|
4228
|
+
"model_sha": sha(os.path.join(ctx, "domain-model.md")),
|
|
4229
|
+
"plans": plans,
|
|
4230
|
+
"note": ("Written by analyze-plan when it PASSED. It records the bytes that were "
|
|
4231
|
+
"proven against each other, not a version — the plans' **Version**: headers "
|
|
4232
|
+
"are stamped in lockstep by close-delta and cannot answer this. implement "
|
|
4233
|
+
"reads it through plan-gate-check."),
|
|
4234
|
+
}
|
|
4235
|
+
p = os.path.join(fd, ".aw-plan-gate.json")
|
|
4236
|
+
open(p, "w", encoding="utf-8").write(json.dumps(rec, indent=2) + "\n")
|
|
4237
|
+
print(json.dumps({"file": rel(p), "gated": rec["gated"], "spec_version": rec["spec_version"],
|
|
4238
|
+
"hashed": sorted(plans.keys()), "count": len(plans), "ok": True}))
|
|
4239
|
+
PY
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
cmd_plan_gate_check() {
|
|
4243
|
+
local feature_dir_arg=""
|
|
4244
|
+
while [ $# -gt 0 ]; do
|
|
4245
|
+
case "$1" in
|
|
4246
|
+
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4247
|
+
--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 ;;
|
|
4248
|
+
*) echo "plan-gate-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4249
|
+
esac
|
|
4250
|
+
done
|
|
4251
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
4252
|
+
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; }
|
|
4253
|
+
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4254
|
+
AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" python3 - <<'PY'
|
|
4255
|
+
import hashlib, json, os
|
|
4256
|
+
fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
|
|
4257
|
+
def sha(p):
|
|
4258
|
+
try:
|
|
4259
|
+
with open(p, "rb") as f: return hashlib.sha256(f.read()).hexdigest()
|
|
4260
|
+
except Exception: return ""
|
|
4261
|
+
p = os.path.join(fd, ".aw-plan-gate.json")
|
|
4262
|
+
rec = None
|
|
4263
|
+
try: rec = json.load(open(p, encoding="utf-8"))
|
|
4264
|
+
except Exception: rec = None
|
|
4265
|
+
out = {"file": os.path.relpath(p, root), "tracked": bool(rec), "gate_fresh": False,
|
|
4266
|
+
"spec_moved": False, "model_moved": False, "plans_changed": [],
|
|
4267
|
+
"plans_changed_csv": "", "gated": "", "spec_version": "", "reason": "", "ok": True}
|
|
4268
|
+
if not rec:
|
|
4269
|
+
# Never gated. Not a failure and not a stale plan — an unknown, and the only honest
|
|
4270
|
+
# thing to report about a tree that has not run analyze-plan since this op existed.
|
|
4271
|
+
out["reason"] = ("no .aw-plan-gate.json — this feature has not passed analyze-plan "
|
|
4272
|
+
"since the gate began leaving a receipt")
|
|
4273
|
+
else:
|
|
4274
|
+
out["gated"] = rec.get("gated", "")
|
|
4275
|
+
out["spec_version"] = rec.get("spec_version", "")
|
|
4276
|
+
out["spec_moved"] = bool(rec.get("spec_sha")) and rec["spec_sha"] != sha(os.path.join(fd, "spec.md"))
|
|
4277
|
+
# A model recorded as absent that is still absent has not moved. A context that
|
|
4278
|
+
# GAINED a domain model since the gate has moved: check 4 never ran against it.
|
|
4279
|
+
out["model_moved"] = rec.get("model_sha", "") != sha(os.path.join(ctx, "domain-model.md"))
|
|
4280
|
+
changed = []
|
|
4281
|
+
for name, was in sorted((rec.get("plans") or {}).items()):
|
|
4282
|
+
if sha(os.path.join(fd, name)) != was:
|
|
4283
|
+
changed.append(name)
|
|
4284
|
+
out["plans_changed"] = changed
|
|
4285
|
+
out["plans_changed_csv"] = ", ".join(changed)
|
|
4286
|
+
out["gate_fresh"] = not (out["spec_moved"] or out["model_moved"] or changed)
|
|
4287
|
+
if out["gate_fresh"]:
|
|
4288
|
+
out["reason"] = "the spec, the model and every gated plan are byte-identical to what analyze-plan passed on %s" % out["gated"]
|
|
4289
|
+
else:
|
|
4290
|
+
bits = []
|
|
4291
|
+
if out["spec_moved"]: bits.append("spec.md has changed")
|
|
4292
|
+
if out["model_moved"]: bits.append("domain-model.md has changed")
|
|
4293
|
+
if changed: bits.append("%s changed (a re-derive by `plan main`, or a hand edit)" % ", ".join(changed))
|
|
4294
|
+
out["reason"] = "since analyze-plan passed on %s: %s" % (out["gated"], "; ".join(bits))
|
|
4295
|
+
print(json.dumps(out))
|
|
3537
4296
|
PY
|
|
3538
4297
|
}
|
|
3539
4298
|
|
|
@@ -3547,25 +4306,39 @@ PY
|
|
|
3547
4306
|
# nobody at all and sat at v1.0 forever.
|
|
3548
4307
|
# Idempotent: re-stamping the same version reports `unchanged`, writes nothing.
|
|
3549
4308
|
# Only the value moves; any trailing inline comment on the header line is kept.
|
|
4309
|
+
#
|
|
4310
|
+
# WITH --cr-id IT ALSO COMPLETES THAT CHANGE'S CHANGE HISTORY ROW. finalize-delta appends
|
|
4311
|
+
# the row when it MERGES, one stage before the number exists — so it writes `(pending)` in
|
|
4312
|
+
# the Version cell and this fills it in, at the same instant and by the same mechanism as
|
|
4313
|
+
# the headers. The alternative, which this replaces, was for the merge to write
|
|
4314
|
+
# version-read's `next_version`: a PREDICTION of what close-delta would bump to. A change
|
|
4315
|
+
# that merged and never closed — an abandoned run, a red analyze-slice, an un-merge — then
|
|
4316
|
+
# left spec.md permanently asserting a version that was never issued, in the document the
|
|
4317
|
+
# kit calls living truth. domain-model.md was always held to the rule this now applies to
|
|
4318
|
+
# spec.md: the row and its version land together, or the row carries a number nothing else
|
|
4319
|
+
# agrees with.
|
|
3550
4320
|
cmd_version_stamp() {
|
|
3551
|
-
local version="" feature_dir_arg=""
|
|
4321
|
+
local version="" feature_dir_arg="" cr_id=""
|
|
3552
4322
|
while [ $# -gt 0 ]; do
|
|
3553
4323
|
case "$1" in
|
|
3554
4324
|
--version) version="$2"; shift 2 ;;
|
|
3555
4325
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
3556
|
-
--
|
|
4326
|
+
--cr-id) cr_id="$2"; shift 2 ;;
|
|
4327
|
+
--help|-h) echo "version-stamp --version <vX.Y> [--feature-dir <path>] [--cr-id <id>]"; return 0 ;;
|
|
3557
4328
|
*) echo "version-stamp: unknown arg '$1'" >&2; return 1 ;;
|
|
3558
4329
|
esac
|
|
3559
4330
|
done
|
|
3560
4331
|
[ -n "$version" ] || { echo "version-stamp: --version required" >&2; return 1; }
|
|
4332
|
+
cr_id=$(_cr_id_token "$cr_id")
|
|
3561
4333
|
local repo_root; repo_root=$(get_repo_root)
|
|
3562
4334
|
local fd="$feature_dir_arg"
|
|
3563
4335
|
[ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
|
|
3564
4336
|
[ -n "$fd" ] || { echo "version-stamp: no feature set and no --feature-dir" >&2; return 1; }
|
|
3565
4337
|
[[ "$fd" != /* ]] && fd="$repo_root/$fd"
|
|
3566
|
-
AW_FD="$fd" AW_VER="$version" python3 - <<'PY'
|
|
4338
|
+
AW_FD="$fd" AW_VER="$version" AW_CR="$cr_id" python3 - <<'PY'
|
|
3567
4339
|
import json, os, re
|
|
3568
4340
|
fd = os.environ["AW_FD"]; ver = os.environ["AW_VER"].strip()
|
|
4341
|
+
cr = os.environ.get("AW_CR", "").strip()
|
|
3569
4342
|
stamped, unchanged, missing, no_header = [], [], [], []
|
|
3570
4343
|
# First `**Version**: <value>` line only — that is the header. The Change History
|
|
3571
4344
|
# table's `| Version |` column head does not match this pattern.
|
|
@@ -3589,9 +4362,41 @@ for name in ("spec.md", "backend-plan.md", "frontend-plan.md", "design.md", "des
|
|
|
3589
4362
|
open(p, "w", encoding="utf-8").write(
|
|
3590
4363
|
src[:m.start()] + m.group(1) + ver + m.group(3) + src[m.end():])
|
|
3591
4364
|
stamped.append(name)
|
|
4365
|
+
|
|
4366
|
+
# THE CHANGE HISTORY ROW, completed with the number the headers just took. The row is
|
|
4367
|
+
# found by its CR cell, not by position: rows are appended newest-last and a feature may
|
|
4368
|
+
# carry many, so "the last row" would be right only until two changes were open at once.
|
|
4369
|
+
# Only a cell still reading `(pending)` is written — a row already carrying a version
|
|
4370
|
+
# belongs to a change that closed, and re-stamping it would rewrite history.
|
|
4371
|
+
row_filled = False
|
|
4372
|
+
row_version = ""
|
|
4373
|
+
if cr:
|
|
4374
|
+
p = os.path.join(fd, "spec.md")
|
|
4375
|
+
if os.path.isfile(p):
|
|
4376
|
+
src = open(p, encoding="utf-8").read()
|
|
4377
|
+
out_lines, changed = [], False
|
|
4378
|
+
for line in src.splitlines(True):
|
|
4379
|
+
# A table row mentioning this CR whose FIRST cell is the pending marker.
|
|
4380
|
+
if (not changed) and cr in line and line.lstrip().startswith("|"):
|
|
4381
|
+
cells = line.split("|")
|
|
4382
|
+
if len(cells) > 2 and cells[1].strip().lower() in ("(pending)", "pending", "—", "-", ""):
|
|
4383
|
+
row_version = cells[1].strip()
|
|
4384
|
+
cells[1] = " %s " % ver
|
|
4385
|
+
line = "|".join(cells)
|
|
4386
|
+
changed = True
|
|
4387
|
+
out_lines.append(line)
|
|
4388
|
+
if changed:
|
|
4389
|
+
open(p, "w", encoding="utf-8").write("".join(out_lines))
|
|
4390
|
+
row_filled = True
|
|
4391
|
+
|
|
3592
4392
|
print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
|
|
3593
4393
|
"unchanged": unchanged, "missing": missing,
|
|
3594
|
-
"no_header": no_header, "count": len(stamped)
|
|
4394
|
+
"no_header": no_header, "count": len(stamped),
|
|
4395
|
+
# Whether the merge's pending row was completed. False with a --cr-id is
|
|
4396
|
+
# the ordinary outcome for a re-run (already filled) and for a change
|
|
4397
|
+
# whose merge predates the pending convention.
|
|
4398
|
+
"cr_id": cr, "history_row_filled": row_filled,
|
|
4399
|
+
"history_row_was": row_version}))
|
|
3595
4400
|
PY
|
|
3596
4401
|
}
|
|
3597
4402
|
|
|
@@ -3634,9 +4439,15 @@ case "$sub" in
|
|
|
3634
4439
|
cr-activate) cmd_cr_activate "$@" ;;
|
|
3635
4440
|
cr-wave) cmd_cr_wave "$@" ;;
|
|
3636
4441
|
cr-archive) cmd_cr_archive "$@" ;;
|
|
4442
|
+
cr-recall-check) cmd_cr_recall_check "$@" ;;
|
|
4443
|
+
cr-merge-check) cmd_cr_merge_check "$@" ;;
|
|
4444
|
+
cr-snapshot) cmd_cr_snapshot "$@" ;;
|
|
4445
|
+
context-version-bump) cmd_context_version_bump "$@" ;;
|
|
3637
4446
|
context-paths) cmd_context_paths "$@" ;;
|
|
3638
4447
|
model-scaffold) cmd_model_scaffold "$@" ;;
|
|
3639
4448
|
version-stamp) cmd_version_stamp "$@" ;;
|
|
4449
|
+
plan-gate-stamp) cmd_plan_gate_stamp "$@" ;;
|
|
4450
|
+
plan-gate-check) cmd_plan_gate_check "$@" ;;
|
|
3640
4451
|
checklist-read) cmd_checklist_read "$@" ;;
|
|
3641
4452
|
checklist-apply) cmd_checklist_apply "$@" ;;
|
|
3642
4453
|
detect-project) cmd_detect_project "$@" ;;
|