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.
Files changed (39) hide show
  1. package/assets/.appweaver/STACK-PROFILES.md +87 -8
  2. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
  3. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +41 -8
  4. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +62 -2
  5. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +18 -5
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +115 -27
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +225 -27
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +100 -0
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
  14. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +190 -209
  15. package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
  16. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +182 -224
  17. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
  18. package/assets/.appweaver/scripts/python/aw-crstate.py +127 -2
  19. package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
  20. package/assets/.appweaver/scripts/python/aw-payload-parity.py +17 -1
  21. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +84 -4
  22. package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
  23. package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
  24. package/assets/.appweaver/scripts/python/aw-stack.py +6 -0
  25. package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
  26. package/assets/.appweaver/stack-profiles/angular.json +26 -0
  27. package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
  28. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +38 -0
  29. package/assets/.appweaver/stack-profiles/java-spring.json +19 -0
  30. package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
  31. package/assets/.appweaver/stack-profiles/node-express.json +26 -0
  32. package/assets/.appweaver/stack-profiles/python-fastapi.json +15 -0
  33. package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
  34. package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
  35. package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
  36. package/assets/.appweaver/templates/ui-model-template.md +67 -0
  37. package/assets/.appweaver/templates/workflow-model-template.md +62 -0
  38. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +44 -0
  39. 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; 1 is the floor for a
1104
- # change opened before waves existed.
1105
- _wave_of() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
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=1 ;; esac
1116
- [ "$w" -ge 1 ] 2>/dev/null || w=1
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
- _stamp_header "$target" "Wave" "$(_wave_of "$fd" "$cr_id" "$repo_root")"
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 not None and current < required:
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 not deps:
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 and wave.
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
- # EVERY open change in the context, both halves. changes/<id>/ under each feature is the
4490
- # authority for "open"; the pending model deltas are unioned in so a model-only artifact of
4491
- # a half-built change is still seen.
4492
- opens = {}
4493
- for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
4494
- ch = os.path.join(ctx, feat, "changes")
4495
- if not os.path.isdir(ch): continue
4496
- for name in sorted(os.listdir(ch)):
4497
- d = os.path.join(ch, name)
4498
- if name == "archive" or not os.path.isdir(d): continue
4499
- opens.setdefault(name, {"cr_id": name, "wave": wave_of(os.path.join(d, "delta-spec.md")),
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
- # aw-crstate owns that test, for every reader of it. It replaces `cr in spec.md` — which
4516
- # fired on an id quoted in prose and matched a prefix of a longer id — and it looks where a
4517
- # merge can actually show: this feature's spec AND the context model every feature shares,
4518
- # so a SIBLING feature's merged-unclosed change is no longer invisible. A sibling blocks
4519
- # only where it touched an aggregate this delta also names (`blocking`); a merge in this
4520
- # feature blocks outright, because it moved this delta's own master.
4521
- merged_open = []
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
- _m = importlib.util.module_from_spec(_spec)
4527
- _spec.loader.exec_module(_m)
4528
- merged_open = [r for r in _m.merged_open(ctx, fd, cr) if r["blocking"]]
4559
+ _loaded = importlib.util.module_from_spec(_spec)
4560
+ _spec.loader.exec_module(_loaded)
4561
+ _m = _loaded
4529
4562
  except Exception as _e:
4530
- # Fail CLOSED on an unreadable helper: report the state as blocking rather than clear.
4531
- merged_open = [{"cr_id": "(merged-open check failed: %s)" % _e, "blocking": True}]
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, "wave": wave,
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": ", ".join("%s (wave %d)" % (b["cr_id"], b["wave"]) for b in blockers),
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": ", ".join(m["cr_id"] for m in merged_open),
4548
- # All three refusals as ONE positive flag, because the engine takes no compound condition
4549
- # and a gate that must read three booleans would need three passes over one question.
4550
- "merge_ok": (not rebase_needed) and len(blockers) == 0 and len(merged_open) == 0,
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
- except Exception:
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
- wave = wave_of(cr_dir) if os.path.isdir(cr_dir) else 1
4620
- dependants = []
4621
- if os.path.isdir(changes):
4622
- for name in sorted(os.listdir(changes)):
4623
- d = os.path.join(changes, name)
4624
- if name == cr or name == "archive" or not os.path.isdir(d):
4625
- continue
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), "wave": wave,
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": ", ".join("%s (wave %d)" % (x["cr_id"], x["wave"]) for x in dependants),
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
- local feature_dir_arg="" spec_version="" mode="master" verdict="conformant" plan_gaps="0" routed="[]"
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
- --spec-version) spec_version="$2"; shift 2 ;;
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
- AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_VER="$spec_version" AW_MODE="$mode" \
4739
- AW_VERDICT="$verdict" AW_PLAN_GAPS="$plan_gaps" AW_ROUTED="$routed" python3 - <<'PY'
4740
- import datetime, hashlib, json, os
4741
- fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
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
- AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" python3 - <<'PY'
4806
- import hashlib, json, os
4807
- fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
4808
- def sha(p):
4809
- try:
4810
- with open(p, "rb") as f: return hashlib.sha256(f.read()).hexdigest()
4811
- except Exception: return ""
4812
- p = os.path.join(fd, ".aw-plan-gate.json")
4813
- rec = None
4814
- try: rec = json.load(open(p, encoding="utf-8"))
4815
- except Exception: rec = None
4816
- out = {"file": os.path.relpath(p, root), "tracked": bool(rec), "gate_fresh": False,
4817
- "verdict": "", "may_generate": True, "plan_gaps": 0, "routed_gaps": [], "routed_csv": "",
4818
- "spec_moved": False, "model_moved": False, "plans_changed": [],
4819
- "plans_changed_csv": "", "gated": "", "spec_version": "", "reason": "", "ok": True}
4820
- if not rec:
4821
- # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
4822
- # thing to report about a tree that has not run analyze-plan since this op existed.
4823
- out["reason"] = ("no .aw-plan-gate.json — this feature has not been through analyze-plan "
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> [--root DIR] [--bundle knowledge]
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)