aw-spec-cli 0.1.21 → 0.1.22
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/STACK-PROFILES.md +87 -8
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +41 -8
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +62 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +18 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +115 -27
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +225 -27
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +100 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +190 -209
- package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +182 -224
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
- package/assets/.appweaver/scripts/python/aw-crstate.py +127 -2
- package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +17 -1
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +84 -4
- package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
- package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +6 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
- package/assets/.appweaver/stack-profiles/angular.json +26 -0
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +38 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +19 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
- package/assets/.appweaver/stack-profiles/node-express.json +26 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +15 -0
- package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
- package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
- package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
- package/assets/.appweaver/templates/ui-model-template.md +67 -0
- package/assets/.appweaver/templates/workflow-model-template.md +62 -0
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +44 -0
- package/package.json +1 -1
|
@@ -1100,9 +1100,12 @@ _cr_id_token() { # $1 = raw --cr-id value
|
|
|
1100
1100
|
}
|
|
1101
1101
|
|
|
1102
1102
|
# One change's wave, read from the documents that carry it: the delta-spec first, then
|
|
1103
|
-
# the model delta. Both are stamped together, so either answers
|
|
1104
|
-
#
|
|
1105
|
-
|
|
1103
|
+
# the model delta. Both are stamped together, so either answers. EMPTY when neither carries
|
|
1104
|
+
# a readable one — `**Wave**: [N]` is the unfilled template line, so "no wave" is a state
|
|
1105
|
+
# that happens, and a caller who WRITES the answer somewhere must not write a number nobody
|
|
1106
|
+
# derived. A phantom wave is worse than none: the merge and recall fences can see that a
|
|
1107
|
+
# wave is missing and say so, but a 1 that was invented downstream reads to them as fact.
|
|
1108
|
+
_wave_of_readable() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
|
|
1106
1109
|
local fd="$1" cr="$2" repo_root="$3" w=""
|
|
1107
1110
|
local dspec="$fd/changes/$cr/delta-spec.md"
|
|
1108
1111
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
@@ -1112,11 +1115,20 @@ _wave_of() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
|
|
|
1112
1115
|
w=$(sed -n -E 's/^\*\*Wave\*\*:[[:space:]]*([0-9]+).*$/\1/p' "$f" | head -1)
|
|
1113
1116
|
[ -n "$w" ] && break
|
|
1114
1117
|
done
|
|
1115
|
-
case "$w" in ''|*[!0-9]*) w=
|
|
1116
|
-
[ "$w"
|
|
1118
|
+
case "$w" in ''|*[!0-9]*) w="" ;; esac
|
|
1119
|
+
if [ -n "$w" ]; then
|
|
1120
|
+
[ "$w" -ge 1 ] 2>/dev/null || w=""
|
|
1121
|
+
fi
|
|
1117
1122
|
printf '%s' "$w"
|
|
1118
1123
|
}
|
|
1119
1124
|
|
|
1125
|
+
# The same reading with the floor applied, for the readers that must show a number — a
|
|
1126
|
+
# listing has a column to fill and 1 is the floor for a change opened before waves existed.
|
|
1127
|
+
_wave_of() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
|
|
1128
|
+
local w; w=$(_wave_of_readable "$1" "$2" "$3")
|
|
1129
|
+
printf '%s' "${w:-1}"
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1120
1132
|
# ─────────────────────────────── context-paths ─────────────────────────────
|
|
1121
1133
|
# Everything specify needs to know about the domain model of the context it is working
|
|
1122
1134
|
# in: where the master is, whether it exists yet, and EVERY open delta model beside it.
|
|
@@ -2564,6 +2576,25 @@ print(json.dumps({"ok": ok, "exists": True, "issues": issues,
|
|
|
2564
2576
|
PY
|
|
2565
2577
|
}
|
|
2566
2578
|
|
|
2579
|
+
# ─────────────────────────────── principles-list ───────────────────────────
|
|
2580
|
+
# The ratified NON-NEGOTIABLE principles. A thin wrapper: the scan lives in
|
|
2581
|
+
# python/aw-principles.py, which BOTH shells invoke, so the list a gate cites cannot
|
|
2582
|
+
# differ between them. Defaults to the two halves specify loads; --file overrides.
|
|
2583
|
+
cmd_principles_list() {
|
|
2584
|
+
local rest=()
|
|
2585
|
+
while [ $# -gt 0 ]; do
|
|
2586
|
+
case "$1" in
|
|
2587
|
+
--help|-h) echo "principles-list [--file <path>]... -> {files[], missing[], principles[], count, titles_csv}"; return 0 ;;
|
|
2588
|
+
*) rest+=("$1"); shift ;;
|
|
2589
|
+
esac
|
|
2590
|
+
done
|
|
2591
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
2592
|
+
local core="$SCRIPT_DIR/../python/aw-principles.py"
|
|
2593
|
+
if [ ! -f "$core" ]; then echo "principles-list: core not found at $core" >&2; return 1; fi
|
|
2594
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "principles-list: python3 is required" >&2; return 1; fi
|
|
2595
|
+
python3 "$core" list --root "$repo_root" "${rest[@]+"${rest[@]}"}"
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2567
2598
|
# ────────────────────────── agent-context-update ───────────────────────────
|
|
2568
2599
|
# Run the agent-context extension's updater (refreshes the managed AW-Spec block).
|
|
2569
2600
|
cmd_agent_context_update() {
|
|
@@ -2924,7 +2955,14 @@ cmd_delta_plan_scaffold() {
|
|
|
2924
2955
|
if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$target"; else touch "$target"; fi
|
|
2925
2956
|
created="true"
|
|
2926
2957
|
fi
|
|
2927
|
-
|
|
2958
|
+
# The lane plan carries the change's wave so a reader of the plan alone can see where it
|
|
2959
|
+
# sits. It is COPIED from the delta-spec, never decided here — so when the spec has no
|
|
2960
|
+
# readable wave the template's `[N]` is left standing, which is what the merge and recall
|
|
2961
|
+
# fences read as "not ordered yet" and refuse on, with the command that stamps it.
|
|
2962
|
+
local scaffold_wave; scaffold_wave=$(_wave_of_readable "$fd" "$cr_id" "$repo_root")
|
|
2963
|
+
if [ -n "$scaffold_wave" ]; then
|
|
2964
|
+
_stamp_header "$target" "Wave" "$scaffold_wave"
|
|
2965
|
+
fi
|
|
2928
2966
|
local override_active="false"
|
|
2929
2967
|
[ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
|
|
2930
2968
|
local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
|
|
@@ -2997,13 +3035,17 @@ cmd_test_run() {
|
|
|
2997
3035
|
# feature's aggregate name (feature-dir basename) plus any --also
|
|
2998
3036
|
# referrer names — the per-feature integrity scope. .NET only; npm/none
|
|
2999
3037
|
# ignore it and run the full suite.
|
|
3038
|
+
# --also is REPEATABLE and the lists UNION. A merge's blast radius has two halves that
|
|
3039
|
+
# are computed separately — the referrer closure and the features that co-own the
|
|
3040
|
+
# aggregates the change touches — and the dialect cannot concatenate two variables into
|
|
3041
|
+
# one argument, so the caller passes each list as its own --also.
|
|
3000
3042
|
local feature_dir="" scope="repo" also=""
|
|
3001
3043
|
while [ $# -gt 0 ]; do
|
|
3002
3044
|
case "$1" in
|
|
3003
3045
|
--feature-dir) feature_dir="$2"; shift 2 ;;
|
|
3004
3046
|
--scope) scope="$2"; shift 2 ;;
|
|
3005
|
-
--also) also="$2"; shift 2 ;;
|
|
3006
|
-
--help|-h) echo "test-run [--feature-dir <path>] [--scope slice|repo] [--also <csv>]"; return 0 ;;
|
|
3047
|
+
--also) also="${also:+$also,}$2"; shift 2 ;;
|
|
3048
|
+
--help|-h) echo "test-run [--feature-dir <path>] [--scope slice|repo] [--also <csv>]... (--also repeatable; lists union)"; return 0 ;;
|
|
3007
3049
|
*) echo "test-run: unknown arg '$1'" >&2; return 1 ;;
|
|
3008
3050
|
esac
|
|
3009
3051
|
done
|
|
@@ -3493,8 +3535,12 @@ required = max([d["wave"] for d in known], default=0) + 1
|
|
|
3493
3535
|
current = wave_of(cr)
|
|
3494
3536
|
ok = current is not None and current >= required
|
|
3495
3537
|
|
|
3538
|
+
# A change with NO readable wave is stamped too, not only one sitting too low. The merge
|
|
3539
|
+
# and recall fences refuse on an unordered change and send the operator here, so the one
|
|
3540
|
+
# command they are given has to be able to write the first wave as well as correct a wrong
|
|
3541
|
+
# one — a remedy that declines the case it was named for is no remedy.
|
|
3496
3542
|
stamped = []
|
|
3497
|
-
if stamp and current is
|
|
3543
|
+
if stamp and (current is None or current < required):
|
|
3498
3544
|
line = "**Wave**: %d" % required
|
|
3499
3545
|
pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
|
|
3500
3546
|
for p in present:
|
|
@@ -3505,13 +3551,16 @@ if stamp and current is not None and current < required:
|
|
|
3505
3551
|
if stamped:
|
|
3506
3552
|
current, ok = required, True
|
|
3507
3553
|
|
|
3508
|
-
if
|
|
3509
|
-
msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
|
|
3510
|
-
cr, required, current if current is not None else "no wave")
|
|
3511
|
-
elif stamped:
|
|
3554
|
+
if stamped and deps:
|
|
3512
3555
|
msg = "%s depends on %s, so it was raised to wave %d in %s" % (
|
|
3513
3556
|
cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
|
|
3514
3557
|
required, ", ".join(stamped))
|
|
3558
|
+
elif stamped:
|
|
3559
|
+
msg = "%s declares no predecessor, so it was stamped at wave %d — the floor — in %s" % (
|
|
3560
|
+
cr, required, ", ".join(stamped))
|
|
3561
|
+
elif not deps:
|
|
3562
|
+
msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
|
|
3563
|
+
cr, required, current if current is not None else "no wave")
|
|
3515
3564
|
elif ok:
|
|
3516
3565
|
msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
|
|
3517
3566
|
cr, current, required)
|
|
@@ -4434,7 +4483,7 @@ cmd_cr_merge_check() {
|
|
|
4434
4483
|
case "$1" in
|
|
4435
4484
|
--cr-id) cr_id="$2"; shift 2 ;;
|
|
4436
4485
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4437
|
-
--help|-h) echo "cr-merge-check --cr-id <id> [--feature-dir <path>] -> {rebase_needed, base_version, current_version, blockers[], blocker_count, wave, …}"; return 0 ;;
|
|
4486
|
+
--help|-h) echo "cr-merge-check --cr-id <id> [--feature-dir <path>] -> {rebase_needed, base_version, current_version, blockers[], blocker_count, wave, wave_ordering_ok, unordered[], …}"; return 0 ;;
|
|
4438
4487
|
*) echo "cr-merge-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4439
4488
|
esac
|
|
4440
4489
|
done
|
|
@@ -4462,14 +4511,8 @@ def norm(v):
|
|
|
4462
4511
|
if not v: return ""
|
|
4463
4512
|
return v if v.startswith("v") else "v" + v
|
|
4464
4513
|
|
|
4465
|
-
# THIS change's own spec half
|
|
4514
|
+
# THIS change's own spec half.
|
|
4466
4515
|
my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
|
|
4467
|
-
def wave_of(p, default=1):
|
|
4468
|
-
try: t = open(p, encoding="utf-8").read()
|
|
4469
|
-
except Exception: return default
|
|
4470
|
-
m = re.search(r"^\*\*Wave\*\*:[ \t]*(\d+)", t, re.M)
|
|
4471
|
-
return int(m.group(1)) if m else default
|
|
4472
|
-
wave = wave_of(my_spec)
|
|
4473
4516
|
base_version = norm(header(my_spec, "Base version"))
|
|
4474
4517
|
|
|
4475
4518
|
# The master's CURRENT version, from the ledger — the same authority version-read reports,
|
|
@@ -4486,52 +4529,65 @@ if not current:
|
|
|
4486
4529
|
# absence of evidence and is not treated as a move.
|
|
4487
4530
|
rebase_needed = bool(base_version and current and base_version != current)
|
|
4488
4531
|
|
|
4489
|
-
#
|
|
4490
|
-
#
|
|
4491
|
-
# a
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
"feature": feat})
|
|
4501
|
-
for f in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
4502
|
-
m = re.match(r"^domain-model\.(.+)\.delta\.md$", f)
|
|
4503
|
-
if not m: continue
|
|
4504
|
-
other = m.group(1)
|
|
4505
|
-
opens.setdefault(other, {"cr_id": other, "wave": wave_of(os.path.join(ctx, f)), "feature": ""})
|
|
4506
|
-
|
|
4507
|
-
blockers = [v for k, v in sorted(opens.items()) if k != cr and v["wave"] < wave]
|
|
4508
|
-
|
|
4509
|
-
# THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
|
|
4510
|
-
# bumps, so between the two the masters carry a change the number does not yet reflect — and
|
|
4511
|
-
# a second delta sharing the same base_version would compare equal and merge onto masters
|
|
4512
|
-
# that had already shifted under it. The proof is the Change History row finalize-delta
|
|
4513
|
-
# appends, carrying the id in its CR cell.
|
|
4532
|
+
# BOTH CONTEXT-WIDE QUESTIONS COME FROM aw-crstate, for every reader of them and for both
|
|
4533
|
+
# shells: which open changes this one is ordered against, and which of them have already
|
|
4534
|
+
# merged. Each was once a copy of the scan living in this payload and another in its
|
|
4535
|
+
# PowerShell twin, which is how the two came to answer the same question differently.
|
|
4536
|
+
#
|
|
4537
|
+
# PREDECESSORS. Every open change in the context, both halves — changes/<id>/ under each
|
|
4538
|
+
# feature is the authority for "open", and the pending model deltas are unioned in so a
|
|
4539
|
+
# model-only artifact of a half-built change is still seen. A change whose `**Wave**:` is
|
|
4540
|
+
# unreadable is reported as unordered rather than assumed to be wave 1, which is what let an
|
|
4541
|
+
# unstamped delta pose as a predecessor and let an unstamped SUBJECT skip this fence
|
|
4542
|
+
# entirely.
|
|
4514
4543
|
#
|
|
4515
|
-
#
|
|
4516
|
-
#
|
|
4517
|
-
#
|
|
4518
|
-
#
|
|
4519
|
-
#
|
|
4520
|
-
#
|
|
4521
|
-
|
|
4544
|
+
# MERGED-OPEN. The masters can move without the version moving: finalize-delta merges and
|
|
4545
|
+
# close-delta bumps, so between the two the masters carry a change the number does not yet
|
|
4546
|
+
# reflect, and a second delta sharing the same base_version would compare equal and merge
|
|
4547
|
+
# onto masters that had already shifted under it. The proof is the Change History row
|
|
4548
|
+
# finalize-delta appends, carrying the id in its CR cell — which replaced `cr in spec.md`,
|
|
4549
|
+
# a test that fired on an id quoted in prose and matched a prefix of a longer id. It looks
|
|
4550
|
+
# where a merge can actually show: this feature's spec AND the context model every feature
|
|
4551
|
+
# shares, so a SIBLING feature's merged-unclosed change is no longer invisible. A sibling
|
|
4552
|
+
# blocks only where it touched an aggregate this delta also names (`blocking`); a merge in
|
|
4553
|
+
# this feature blocks outright, because it moved this delta's own master.
|
|
4554
|
+
_m, _why = None, ""
|
|
4522
4555
|
try:
|
|
4523
4556
|
import importlib.util
|
|
4524
4557
|
_mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
|
|
4525
4558
|
_spec = importlib.util.spec_from_file_location("aw_crstate", _mod_path)
|
|
4526
|
-
|
|
4527
|
-
_spec.loader.exec_module(
|
|
4528
|
-
|
|
4559
|
+
_loaded = importlib.util.module_from_spec(_spec)
|
|
4560
|
+
_spec.loader.exec_module(_loaded)
|
|
4561
|
+
_m = _loaded
|
|
4529
4562
|
except Exception as _e:
|
|
4530
|
-
|
|
4531
|
-
|
|
4563
|
+
_why = str(_e)
|
|
4564
|
+
|
|
4565
|
+
order, merged_open, merged_text = None, None, ""
|
|
4566
|
+
if _m is not None:
|
|
4567
|
+
try:
|
|
4568
|
+
order = _m.wave_order(ctx, fd, cr, "lower", "context")
|
|
4569
|
+
merged_open = [r for r in _m.merged_open(ctx, fd, cr) if r["blocking"]]
|
|
4570
|
+
merged_text = _m.merged_open_text(merged_open)
|
|
4571
|
+
except Exception as _e:
|
|
4572
|
+
order, merged_open, _why = None, None, str(_e)
|
|
4573
|
+
|
|
4574
|
+
# Fail CLOSED on an unreadable helper: report the state as blocking rather than clear, and
|
|
4575
|
+
# say which check could not run rather than which condition was found.
|
|
4576
|
+
if order is None:
|
|
4577
|
+
order = {"wave": None, "wave_known": False, "related": [], "related_count": 0,
|
|
4578
|
+
"related_ids": "", "unknown": [], "unknown_count": 0, "unknown_ids": "",
|
|
4579
|
+
"unknown_blocks": True, "wave_ordering_ok": False,
|
|
4580
|
+
"wave_problem": "the wave reader could not be run (%s), so no open change in "
|
|
4581
|
+
"this context is ordered against %s at all" % (_why, cr)}
|
|
4582
|
+
if merged_open is None:
|
|
4583
|
+
merged_open = [{"cr_id": "(merged-open check failed: %s)" % _why, "blocking": True}]
|
|
4584
|
+
merged_text = merged_open[0]["cr_id"]
|
|
4585
|
+
blockers = order["related"]
|
|
4532
4586
|
|
|
4533
4587
|
print(json.dumps({
|
|
4534
|
-
"ok": True, "cr_id": cr,
|
|
4588
|
+
"ok": True, "cr_id": cr,
|
|
4589
|
+
"wave": order["wave"] if order["wave"] is not None else "",
|
|
4590
|
+
"wave_known": order["wave_known"],
|
|
4535
4591
|
"exists": os.path.isdir(os.path.join(fd, "changes", cr)),
|
|
4536
4592
|
# REBASE half — the masters moved because a change CLOSED and bumped the number.
|
|
4537
4593
|
"base_version": base_version, "current_version": current,
|
|
@@ -4540,14 +4596,20 @@ print(json.dumps({
|
|
|
4540
4596
|
# PREDECESSOR half. Lower wave = committed to land first.
|
|
4541
4597
|
"blockers": blockers, "blocker_count": len(blockers),
|
|
4542
4598
|
"has_blockers": len(blockers) > 0,
|
|
4543
|
-
"blocker_ids":
|
|
4599
|
+
"blocker_ids": order["related_ids"],
|
|
4600
|
+
# The changes this one cannot be ordered against at all, in either direction.
|
|
4601
|
+
"unordered": order["unknown"], "unordered_count": order["unknown_count"],
|
|
4602
|
+
"unordered_ids": order["unknown_ids"],
|
|
4603
|
+
"wave_ordering_ok": order["wave_ordering_ok"],
|
|
4604
|
+
"wave_problem": order["wave_problem"],
|
|
4544
4605
|
# The other way the masters move: a change MERGED and has not closed yet.
|
|
4545
4606
|
"merged_open": merged_open, "merged_open_count": len(merged_open),
|
|
4546
4607
|
"has_merged_open": len(merged_open) > 0,
|
|
4547
|
-
"merged_open_ids":
|
|
4548
|
-
# All
|
|
4549
|
-
# and a gate that must read
|
|
4550
|
-
"merge_ok": (not rebase_needed) and
|
|
4608
|
+
"merged_open_ids": merged_text,
|
|
4609
|
+
# All four refusals as ONE positive flag, because the engine takes no compound condition
|
|
4610
|
+
# and a gate that must read four booleans would need four passes over one question.
|
|
4611
|
+
"merge_ok": (not rebase_needed) and order["wave_ordering_ok"] and len(blockers) == 0
|
|
4612
|
+
and len(merged_open) == 0,
|
|
4551
4613
|
}))
|
|
4552
4614
|
PY
|
|
4553
4615
|
}
|
|
@@ -4572,7 +4634,7 @@ cmd_cr_recall_check() {
|
|
|
4572
4634
|
case "$1" in
|
|
4573
4635
|
--cr-id) cr_id="$2"; shift 2 ;;
|
|
4574
4636
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4575
|
-
--help|-h) echo "cr-recall-check --cr-id <id> [--feature-dir <path>] -> {merged, dependants[], dependant_count, wave, …}"; return 0 ;;
|
|
4637
|
+
--help|-h) echo "cr-recall-check --cr-id <id> [--feature-dir <path>] -> {merged, dependants[], dependant_count, wave, wave_ordering_ok, unordered[], …}"; return 0 ;;
|
|
4576
4638
|
*) echo "cr-recall-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4577
4639
|
esac
|
|
4578
4640
|
done
|
|
@@ -4588,21 +4650,13 @@ root = os.environ.get("AW_ROOT", ""); ctx = os.environ.get("AW_CTX", "")
|
|
|
4588
4650
|
changes = os.path.join(fd, "changes")
|
|
4589
4651
|
cr_dir = os.path.join(changes, cr)
|
|
4590
4652
|
|
|
4591
|
-
def wave_of(d):
|
|
4592
|
-
for name in ("delta-spec.md",):
|
|
4593
|
-
p = os.path.join(d, name)
|
|
4594
|
-
if os.path.isfile(p):
|
|
4595
|
-
m = re.search(r'^\*\*Wave\*\*:[ \t]*(\d+)', open(p, encoding="utf-8").read(), re.M)
|
|
4596
|
-
if m:
|
|
4597
|
-
return int(m.group(1))
|
|
4598
|
-
return 1
|
|
4599
|
-
|
|
4600
4653
|
# MERGED = a Change History row whose CR cell IS this change, in this feature's spec or in
|
|
4601
4654
|
# the context model every feature shares — aw-crstate owns that test for all three readers
|
|
4602
4655
|
# of it. The old test was `cr in line`, which fired on an id quoted in prose and matched a
|
|
4603
4656
|
# prefix of a longer id.
|
|
4604
4657
|
spec = os.path.join(fd, "spec.md")
|
|
4605
4658
|
merged, evidence, merged_doc = False, "", ""
|
|
4659
|
+
order, _why = None, ""
|
|
4606
4660
|
try:
|
|
4607
4661
|
import importlib.util
|
|
4608
4662
|
_mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
|
|
@@ -4611,29 +4665,39 @@ try:
|
|
|
4611
4665
|
_spec.loader.exec_module(_m)
|
|
4612
4666
|
evidence, merged_doc = _m.merged_in([spec, os.path.join(ctx, "domain-model.md")], cr)
|
|
4613
4667
|
merged = bool(evidence)
|
|
4614
|
-
|
|
4668
|
+
# DEPENDANTS, from the same reader the merge fence uses in the opposite direction. An
|
|
4669
|
+
# unreadable `**Wave**:` is not wave 1 here either: it used to mean `w > wave` was false
|
|
4670
|
+
# and the change was not counted, so recall withdrew the ground from under a dependant
|
|
4671
|
+
# it could not see. Waves have no ceiling, so any unordered change could sit above this
|
|
4672
|
+
# one and every one of them holds recall.
|
|
4673
|
+
order = _m.wave_order(ctx, fd, cr, "higher", "feature")
|
|
4674
|
+
except Exception as _e:
|
|
4615
4675
|
# Fail CLOSED: an unreadable helper must not report "never merged", which is the
|
|
4616
4676
|
# answer that lets close-delta archive a change nobody merged.
|
|
4617
|
-
merged, evidence = True, "(merged check unavailable — treated as merged)"
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
w = wave_of(d)
|
|
4627
|
-
if w > wave:
|
|
4628
|
-
dependants.append({"cr_id": name, "wave": w})
|
|
4677
|
+
merged, evidence, _why = True, "(merged check unavailable — treated as merged)", str(_e)
|
|
4678
|
+
|
|
4679
|
+
if order is None:
|
|
4680
|
+
order = {"wave": None, "wave_known": False, "related": [], "related_count": 0,
|
|
4681
|
+
"related_ids": "", "unknown": [], "unknown_count": 0, "unknown_ids": "",
|
|
4682
|
+
"unknown_blocks": True, "wave_ordering_ok": False,
|
|
4683
|
+
"wave_problem": "the wave reader could not be run (%s), so no open change in "
|
|
4684
|
+
"this feature is ordered against %s at all" % (_why, cr)}
|
|
4685
|
+
dependants = order["related"]
|
|
4629
4686
|
|
|
4630
4687
|
print(json.dumps({
|
|
4631
|
-
"ok": True, "cr_id": cr, "exists": os.path.isdir(cr_dir),
|
|
4688
|
+
"ok": True, "cr_id": cr, "exists": os.path.isdir(cr_dir),
|
|
4689
|
+
"wave": order["wave"] if order["wave"] is not None else "",
|
|
4690
|
+
"wave_known": order["wave_known"],
|
|
4632
4691
|
"merged": merged, "merge_evidence": evidence,
|
|
4633
4692
|
"dependants": dependants,
|
|
4634
|
-
"dependant_ids":
|
|
4693
|
+
"dependant_ids": order["related_ids"],
|
|
4635
4694
|
"dependant_count": len(dependants),
|
|
4636
4695
|
"has_dependants": len(dependants) > 0,
|
|
4696
|
+
# The changes this one cannot be ordered against at all, in either direction.
|
|
4697
|
+
"unordered": order["unknown"], "unordered_count": order["unknown_count"],
|
|
4698
|
+
"unordered_ids": order["unknown_ids"],
|
|
4699
|
+
"wave_ordering_ok": order["wave_ordering_ok"],
|
|
4700
|
+
"wave_problem": order["wave_problem"],
|
|
4637
4701
|
}))
|
|
4638
4702
|
PY
|
|
4639
4703
|
}
|
|
@@ -4641,6 +4705,12 @@ PY
|
|
|
4641
4705
|
# ──────────────────── plan-gate-stamp / plan-gate-check ────────────────────
|
|
4642
4706
|
# THE RECEIPT THAT analyze-plan PASSED, and over WHICH BYTES.
|
|
4643
4707
|
#
|
|
4708
|
+
# THE RULE ITSELF LIVES IN python/aw-plangate.py, which both shells invoke — these two are
|
|
4709
|
+
# thin wrappers that resolve the feature and hand over. It is a set of sha comparisons with
|
|
4710
|
+
# three deliberate asymmetries in it, and while it was embedded twice (a heredoc here, a
|
|
4711
|
+
# native function there) every change to it had to be re-derived by hand on the other side
|
|
4712
|
+
# and kept in step by eye. It changed twice that way. Now there is nothing to keep in step.
|
|
4713
|
+
#
|
|
4644
4714
|
# implement generates from the lane plans and deliberately does NOT read the spec — its
|
|
4645
4715
|
# own header says "analyze-plan already conformed the plans to it". That sentence was an
|
|
4646
4716
|
# assumption about the order the operator ran things in, not a fact anything checked: the
|
|
@@ -4719,75 +4789,23 @@ PY
|
|
|
4719
4789
|
}
|
|
4720
4790
|
|
|
4721
4791
|
cmd_plan_gate_stamp() {
|
|
4722
|
-
|
|
4792
|
+
case "${1:-}" in
|
|
4793
|
+
--help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] [--verdict conformant|routed-gaps|gaps] [--plan-gaps <n>] [--routed <json list>] -> {file, gated, verdict, hashed[], count}"; return 0 ;;
|
|
4794
|
+
esac
|
|
4795
|
+
local feature_dir_arg="" rest=()
|
|
4723
4796
|
while [ $# -gt 0 ]; do
|
|
4724
4797
|
case "$1" in
|
|
4725
4798
|
--feature-dir) feature_dir_arg="$2"; shift 2 ;;
|
|
4726
|
-
|
|
4727
|
-
--mode) mode="$2"; shift 2 ;;
|
|
4728
|
-
--verdict) verdict="$2"; shift 2 ;;
|
|
4729
|
-
--plan-gaps) plan_gaps="$2"; shift 2 ;;
|
|
4730
|
-
--routed) routed="$2"; shift 2 ;;
|
|
4731
|
-
--help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] [--verdict conformant|routed-gaps|gaps] [--plan-gaps <n>] [--routed <json list>] -> {file, gated, verdict, hashed[], count}"; return 0 ;;
|
|
4732
|
-
*) echo "plan-gate-stamp: unknown arg '$1'" >&2; return 1 ;;
|
|
4799
|
+
*) rest+=("$1"); shift ;;
|
|
4733
4800
|
esac
|
|
4734
4801
|
done
|
|
4735
4802
|
local repo_root; repo_root=$(get_repo_root)
|
|
4736
4803
|
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; }
|
|
4737
4804
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
def sha(p):
|
|
4743
|
-
try:
|
|
4744
|
-
with open(p, "rb") as f: return hashlib.sha256(f.read()).hexdigest()
|
|
4745
|
-
except Exception: return ""
|
|
4746
|
-
def rel(p): return os.path.relpath(p, root)
|
|
4747
|
-
# Every document the gate READ. A lane that is not declared has no plan on disk and is
|
|
4748
|
-
# simply absent from the map — the check treats a name it does not carry as not gated,
|
|
4749
|
-
# never as changed, so declaring a second lane later does not read as tampering.
|
|
4750
|
-
#
|
|
4751
|
-
# THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
|
|
4752
|
-
# resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
|
|
4753
|
-
# that has not been through `plan` since the rename — every brownfield feature `adopt`
|
|
4754
|
-
# produces — is GATED on those documents. Hashing only the new names would leave such a
|
|
4755
|
-
# feature with an empty plan map: a receipt that looks tracked and fresh while the document
|
|
4756
|
-
# implement actually generates from is unwatched.
|
|
4757
|
-
plans = {}
|
|
4758
|
-
for name in ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
|
|
4759
|
-
h = sha(os.path.join(fd, name))
|
|
4760
|
-
if h: plans[name] = h
|
|
4761
|
-
# An unrecognised verdict is recorded as `gaps`, the one that refuses.
|
|
4762
|
-
verdict = os.environ.get("AW_VERDICT", "").strip()
|
|
4763
|
-
if verdict not in ("conformant", "routed-gaps", "gaps"):
|
|
4764
|
-
verdict = "gaps"
|
|
4765
|
-
try: plan_gaps = int(os.environ.get("AW_PLAN_GAPS", "0") or 0)
|
|
4766
|
-
except ValueError: plan_gaps = 0
|
|
4767
|
-
try: routed = [str(x) for x in json.loads(os.environ.get("AW_ROUTED") or "[]")]
|
|
4768
|
-
except Exception: routed = []
|
|
4769
|
-
rec = {
|
|
4770
|
-
"gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
4771
|
-
"gate_mode": os.environ.get("AW_MODE", "master"),
|
|
4772
|
-
"spec_version": os.environ.get("AW_VER", "").strip(),
|
|
4773
|
-
"verdict": verdict,
|
|
4774
|
-
"plan_gaps": plan_gaps,
|
|
4775
|
-
"routed_gaps": routed,
|
|
4776
|
-
"spec_sha": sha(os.path.join(fd, "spec.md")),
|
|
4777
|
-
"model_sha": sha(os.path.join(ctx, "domain-model.md")),
|
|
4778
|
-
"plans": plans,
|
|
4779
|
-
"note": ("Written by every master-mode analyze-plan run. It records the bytes that were "
|
|
4780
|
-
"checked against each other and the verdict on them, not a version — the plans' "
|
|
4781
|
-
"**Version**: headers are stamped in lockstep by close-delta and cannot answer "
|
|
4782
|
-
"this. implement reads it through plan-gate-check: moved bytes or verdict `gaps` "
|
|
4783
|
-
"refuse; `routed-gaps` lists what /appweaver specify still has to lift."),
|
|
4784
|
-
}
|
|
4785
|
-
p = os.path.join(fd, ".aw-plan-gate.json")
|
|
4786
|
-
open(p, "w", encoding="utf-8").write(json.dumps(rec, indent=2) + "\n")
|
|
4787
|
-
print(json.dumps({"file": rel(p), "gated": rec["gated"], "spec_version": rec["spec_version"],
|
|
4788
|
-
"verdict": verdict, "plan_gaps": plan_gaps, "routed_gaps": len(routed),
|
|
4789
|
-
"hashed": sorted(plans.keys()), "count": len(plans), "ok": True}))
|
|
4790
|
-
PY
|
|
4805
|
+
local core="$SCRIPT_DIR/../python/aw-plangate.py"
|
|
4806
|
+
if [ ! -f "$core" ]; then echo "plan-gate-stamp: core not found at $core" >&2; return 1; fi
|
|
4807
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "plan-gate-stamp: python3 is required" >&2; return 1; fi
|
|
4808
|
+
python3 "$core" stamp --feature-dir "$fd" --context-dir "$ctx_dir" --root "$repo_root" "${rest[@]+"${rest[@]}"}"
|
|
4791
4809
|
}
|
|
4792
4810
|
|
|
4793
4811
|
cmd_plan_gate_check() {
|
|
@@ -4802,64 +4820,25 @@ cmd_plan_gate_check() {
|
|
|
4802
4820
|
local repo_root; repo_root=$(get_repo_root)
|
|
4803
4821
|
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; }
|
|
4804
4822
|
local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
"in master mode since the gate began leaving a receipt")
|
|
4825
|
-
else:
|
|
4826
|
-
out["gated"] = rec.get("gated", "")
|
|
4827
|
-
out["spec_version"] = rec.get("spec_version", "")
|
|
4828
|
-
# A receipt from before the verdict existed was only ever written on a clean pass.
|
|
4829
|
-
out["verdict"] = rec.get("verdict") or "conformant"
|
|
4830
|
-
out["plan_gaps"] = rec.get("plan_gaps") or 0
|
|
4831
|
-
out["routed_gaps"] = [str(x) for x in (rec.get("routed_gaps") or [])]
|
|
4832
|
-
out["routed_csv"] = "; ".join(out["routed_gaps"])
|
|
4833
|
-
out["spec_moved"] = bool(rec.get("spec_sha")) and rec["spec_sha"] != sha(os.path.join(fd, "spec.md"))
|
|
4834
|
-
# A model recorded as absent that is still absent has not moved. A context that
|
|
4835
|
-
# GAINED a domain model since the gate has moved: check 4 never ran against it.
|
|
4836
|
-
out["model_moved"] = rec.get("model_sha", "") != sha(os.path.join(ctx, "domain-model.md"))
|
|
4837
|
-
changed = []
|
|
4838
|
-
for name, was in sorted((rec.get("plans") or {}).items()):
|
|
4839
|
-
if sha(os.path.join(fd, name)) != was:
|
|
4840
|
-
changed.append(name)
|
|
4841
|
-
out["plans_changed"] = changed
|
|
4842
|
-
out["plans_changed_csv"] = ", ".join(changed)
|
|
4843
|
-
out["gate_fresh"] = not (out["spec_moved"] or out["model_moved"] or changed)
|
|
4844
|
-
# Moved bytes refuse whatever the verdict was; unmoved bytes refuse only on `gaps`.
|
|
4845
|
-
out["may_generate"] = out["gate_fresh"] and out["verdict"] != "gaps"
|
|
4846
|
-
if not out["gate_fresh"]:
|
|
4847
|
-
bits = []
|
|
4848
|
-
if out["spec_moved"]: bits.append("spec.md has changed")
|
|
4849
|
-
if out["model_moved"]: bits.append("domain-model.md has changed")
|
|
4850
|
-
if changed: bits.append("%s changed (a re-derive by `plan main`, or a hand edit)" % ", ".join(changed))
|
|
4851
|
-
out["reason"] = "since analyze-plan checked them on %s: %s" % (out["gated"], "; ".join(bits))
|
|
4852
|
-
elif out["verdict"] == "gaps":
|
|
4853
|
-
out["reason"] = ("analyze-plan on %s left %s gap(s) a plan rewrite closes, unrepaired"
|
|
4854
|
-
% (out["gated"], out["plan_gaps"] or "some"))
|
|
4855
|
-
elif out["verdict"] == "routed-gaps":
|
|
4856
|
-
out["reason"] = ("the spec, the model and every gated plan are byte-identical to what "
|
|
4857
|
-
"analyze-plan checked on %s; it left %d gap(s) only /appweaver specify "
|
|
4858
|
-
"can close: %s" % (out["gated"], len(out["routed_gaps"]), out["routed_csv"]))
|
|
4859
|
-
else:
|
|
4860
|
-
out["reason"] = "the spec, the model and every gated plan are byte-identical to what analyze-plan passed on %s" % out["gated"]
|
|
4861
|
-
print(json.dumps(out))
|
|
4862
|
-
PY
|
|
4823
|
+
local core="$SCRIPT_DIR/../python/aw-plangate.py"
|
|
4824
|
+
if [ ! -f "$core" ]; then echo "plan-gate-check: core not found at $core" >&2; return 1; fi
|
|
4825
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "plan-gate-check: python3 is required" >&2; return 1; fi
|
|
4826
|
+
python3 "$core" check --feature-dir "$fd" --context-dir "$ctx_dir" --root "$repo_root"
|
|
4827
|
+
}
|
|
4828
|
+
|
|
4829
|
+
# ─────────────────────────────── gap-route ─────────────────────────────────
|
|
4830
|
+
# Split a gate's findings by the command that CLOSES each one. The checks already tag
|
|
4831
|
+
# every finding with `fix`; this turns that tag into the routing a gate message prints,
|
|
4832
|
+
# so a plan defect is never sent to `implement` — which generates FROM the plan and would
|
|
4833
|
+
# reproduce it. Thin wrapper: the logic is in aw-gaps.py, called identically by both shells.
|
|
4834
|
+
cmd_gap_route() {
|
|
4835
|
+
case "${1:-}" in
|
|
4836
|
+
--help|-h) echo "gap-route --gaps '<json>'... [--cr <id>] -> {total, high_count, <route>_count, <route>_refs, <route>_text, summary, routes}"; return 0 ;;
|
|
4837
|
+
esac
|
|
4838
|
+
local core="$SCRIPT_DIR/../python/aw-gaps.py"
|
|
4839
|
+
if [ ! -f "$core" ]; then echo "gap-route: core not found at $core" >&2; return 1; fi
|
|
4840
|
+
if ! command -v python3 >/dev/null 2>&1; then echo "gap-route: python3 is required" >&2; return 1; fi
|
|
4841
|
+
python3 "$core" route "$@"
|
|
4863
4842
|
}
|
|
4864
4843
|
|
|
4865
4844
|
# ──────────────────────────────── plan-coverage ─────────────────────────────
|
|
@@ -4870,7 +4849,7 @@ PY
|
|
|
4870
4849
|
# (python/aw-plan-coverage.py), called identically by both shells.
|
|
4871
4850
|
cmd_plan_coverage() {
|
|
4872
4851
|
case "${1:-}" in
|
|
4873
|
-
--help|-h) echo "plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--list-only] -> {ok, total, names[], names_csv, elements[], gaps[], gap_names[], gap_count, gaps_text, message}"; return 0 ;;
|
|
4852
|
+
--help|-h) echo "plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--generated <json list>] [--list-only] -> {ok, outcome, total, names[], names_csv, elements[], gaps[], gap_names[], gap_count, gaps_text, message}"; return 0 ;;
|
|
4874
4853
|
esac
|
|
4875
4854
|
local repo_root; repo_root=$(get_repo_root)
|
|
4876
4855
|
local core="$SCRIPT_DIR/../python/aw-plan-coverage.py"
|
|
@@ -5039,6 +5018,7 @@ case "$sub" in
|
|
|
5039
5018
|
plan-gate-stamp) cmd_plan_gate_stamp "$@" ;;
|
|
5040
5019
|
plan-gate-check) cmd_plan_gate_check "$@" ;;
|
|
5041
5020
|
plan-coverage) cmd_plan_coverage "$@" ;;
|
|
5021
|
+
gap-route) cmd_gap_route "$@" ;;
|
|
5042
5022
|
checklist-read) cmd_checklist_read "$@" ;;
|
|
5043
5023
|
checklist-apply) cmd_checklist_apply "$@" ;;
|
|
5044
5024
|
detect-project) cmd_detect_project "$@" ;;
|
|
@@ -5054,6 +5034,7 @@ case "$sub" in
|
|
|
5054
5034
|
setup-constitution) cmd_setup_constitution "$@" ;;
|
|
5055
5035
|
compose-constitution) cmd_compose_constitution "$@" ;;
|
|
5056
5036
|
constitution-verify) cmd_constitution_verify "$@" ;;
|
|
5037
|
+
principles-list) cmd_principles_list "$@" ;;
|
|
5057
5038
|
agent-context-update) cmd_agent_context_update "$@" ;;
|
|
5058
5039
|
""|--help|-h)
|
|
5059
5040
|
sed -n '2,37p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' ;;
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
# aw-xref.sh list --aggregate Y [--root DIR] [--bundle knowledge]
|
|
10
10
|
# aw-xref.sh concept-upsert --aggregate X [--source-files a,b,c] [--description D] [--origin ...] [--root DIR]
|
|
11
11
|
# aw-xref.sh concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR]
|
|
12
|
-
# aw-xref.sh concept-paths --aggregate X | --feature-dir <dir> [--
|
|
12
|
+
# aw-xref.sh concept-paths --aggregate X | --feature-dir <dir> [--under <subtree>]...
|
|
13
|
+
# [--root DIR] [--bundle knowledge]
|
|
14
|
+
# (--under keeps only paths inside that subtree, so a caller
|
|
15
|
+
# generating ONE lane is not handed another lane's files)
|
|
13
16
|
# aw-xref.sh feature-aggregates --feature-dir <dir> [--root DIR]
|
|
14
17
|
# (the aggregate(s) a feature acts on: its spec's Acts-on line,
|
|
15
18
|
# then the boundary map, then the feature name itself)
|