aw-spec-cli 0.1.15 → 0.1.16

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.
@@ -1312,9 +1312,19 @@ if op == "check":
1312
1312
  "document is behind its own ledger" % (doc_ver or "no version", recorded_ver))
1313
1313
  else:
1314
1314
  out["state"] = "unledgered_edit"
1315
+ # BOTH ROUTES, BECAUSE NOT EVERY EDIT HAS AN ID TO NAME. Law 5 requires a version row
1316
+ # to name at least one id, so an edit to Aggregate Boundary, Experience Scope or any
1317
+ # other load-bearing prose has nothing legal to put in the cell — and being told to
1318
+ # "add a row naming the ids you changed" sent that person round a loop with no exit.
1319
+ # The protocol has always sanctioned re-baselining instead when the diff finds no id
1320
+ # (SPEC-EDIT-PROTOCOL.md, "The short version" step 4 and the PR checklist); it was
1321
+ # simply never named at the one place anybody reads it.
1315
1322
  out.update(ok=False, error="spec.md has changed since %s but no Change History row "
1316
1323
  "was added. Every story citing an edited requirement is now silently "
1317
- "stale. Add a row naming the ids you changed, then re-run."
1324
+ "stale. Add a row naming the ids you changed, then re-run — or, if this "
1325
+ "edit touched NO requirement (prose only: scope, boundary, wording that "
1326
+ "defines no id), re-run spec-stamp to re-baseline instead. Run spec-diff "
1327
+ "against the snapshot to see which of the two this is."
1318
1328
  % recorded_ver)
1319
1329
  elif op == "stamp":
1320
1330
  # THE VERSION IS THE HIGHEST OF THE THREE PLACES ONE IS WRITTEN, and the document's
@@ -1483,9 +1493,24 @@ def parse_fm(text):
1483
1493
  fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1484
1494
  return fm
1485
1495
 
1486
- def frontmatter(path):
1487
- try: return parse_fm(open(path, encoding="utf-8", errors="replace").read())
1488
- except Exception: return {}
1496
+ def read(path):
1497
+ try: return open(path, encoding="utf-8", errors="replace").read()
1498
+ except Exception: return ""
1499
+
1500
+ def bound_ids(fm, text):
1501
+ """The constraint ids this story is built to satisfy. Read from the `constraints:`
1502
+ FRONTMATTER field, which story-stamp lifts from the spec — the body's `## Constraints`
1503
+ section is model-authored prose, so an id the model left out of it would be invisible to
1504
+ exactly the scan that exists to notice a bound moving under someone.
1505
+
1506
+ ABSENT IS NOT EMPTY. A story written before the field existed has no key at all and its
1507
+ ids are still only in the body, so that case falls back to the section. An explicitly
1508
+ empty `constraints: []` is an answer — this story is bound by nothing — and is taken as
1509
+ one. The fallback goes when every live backlog has been re-stamped."""
1510
+ if "constraints" in fm:
1511
+ return sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', fm["constraints"])))
1512
+ bm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', text, re.M | re.S)
1513
+ return sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', bm.group(1)))) if bm else []
1489
1514
 
1490
1515
  stories = []
1491
1516
  if os.path.isdir(sdir):
@@ -1493,14 +1518,18 @@ if os.path.isdir(sdir):
1493
1518
  if not name.endswith(".md"): continue
1494
1519
  path = os.path.join(sdir, name)
1495
1520
  if not os.path.isfile(path): continue
1496
- fm = frontmatter(path)
1521
+ text = read(path); fm = parse_fm(text)
1497
1522
  sid = fm.get("id") or name[:-3]
1498
1523
  # An unparseable story is reported as open rather than skipped: a story the scan
1499
1524
  # cannot read is exactly the one whose requirements must not move unannounced.
1500
1525
  status = (fm.get("status") or "open").strip().lower()
1501
1526
  cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
1527
+ bounds = bound_ids(fm, text)
1502
1528
  stories.append({"id": sid, "status": status, "exercises": cites,
1503
1529
  "cites_csv": ", ".join(cites),
1530
+ "constraints": bounds,
1531
+ "constraints_csv": ", ".join(bounds),
1532
+ "carried": sorted(set(cites) | set(bounds)),
1504
1533
  "title": (fm.get("title") or "").strip().strip("[]"),
1505
1534
  "spec_version": (fm.get("spec_version") or "").strip(),
1506
1535
  "type": (fm.get("type") or "functional").strip().lower(),
@@ -1508,17 +1537,25 @@ if os.path.isdir(sdir):
1508
1537
  "in_flight": os.path.isdir(os.path.join(sdir, sid))})
1509
1538
 
1510
1539
  open_stories = [s for s in stories if s["status"] == "open"]
1540
+ # TWO SETS, DELIBERATELY DIFFERENT. `covered` is the DELIVERY relation — what open stories
1541
+ # are delivering — and analyze-slice subtracts it from a feature-scoped run as "these have
1542
+ # open stories" (law 39). A constraint is met, never delivered, so folding one in here would
1543
+ # drop a bound the code must still satisfy out of the feature gate's scope.
1544
+ # `carried` is the wider question specify has to ask: what would an open story FEEL if this
1545
+ # ran moved it. That one has to include the bounds, or amending a CN-/DC-/IC-/UX- disturbs
1546
+ # stories built against it and nothing anywhere says so (law 15).
1511
1547
  covered = sorted({c for s in open_stories for c in s["exercises"]})
1548
+ carried = sorted({c for s in open_stories for c in s["carried"]})
1512
1549
 
1513
1550
  # ASKING ABOUT NOTHING IS NOT ASKING ABOUT EVERYTHING. `--ids ""` is a caller saying "this
1514
1551
  # run disturbed no requirement" — a pure addition — and the answer is an empty set. Only
1515
1552
  # the absence of the flag means "tell me about the whole backlog". Conflating the two made
1516
1553
  # an enhancement that touched nothing existing prompt about every requirement with a story.
1517
- targets = queried if os.environ.get("AW_IDS_GIVEN") else covered
1554
+ targets = queried if os.environ.get("AW_IDS_GIVEN") else carried
1518
1555
 
1519
1556
  affected, aff_ids, aff_stories, inflight = [], [], [], []
1520
1557
  for rid in targets:
1521
- hit = [s for s in open_stories if rid in s["exercises"]]
1558
+ hit = [s for s in open_stories if rid in s["carried"]]
1522
1559
  if not hit: continue
1523
1560
  aff_ids.append(rid)
1524
1561
  names = [s["id"] for s in hit]
@@ -1536,9 +1573,10 @@ unlinked = [rid for rid in targets if rid not in aff_ids]
1536
1573
  # is not something an author can answer about: what it cites and whether someone is already
1537
1574
  # implementing it are the whole basis of the decision.
1538
1575
  digest = "\n".join(
1539
- " %-16s %-9s %-11s cites %s%s" % (
1576
+ " %-16s %-9s %-11s cites %s%s%s" % (
1540
1577
  s["id"], s["status"], "IN FLIGHT" if s["in_flight"] else "not started",
1541
1578
  s["cites_csv"] or "nothing",
1579
+ (" bound by " + s["constraints_csv"]) if s["constraints_csv"] else "",
1542
1580
  (" @ " + s["spec_version"]) if s["spec_version"] else "")
1543
1581
  for s in open_stories) or " (no open stories)"
1544
1582
 
@@ -1547,6 +1585,8 @@ print(json.dumps({
1547
1585
  "story_count": len(stories), "open_count": len(open_stories),
1548
1586
  "stories": stories, "digest": digest,
1549
1587
  "queried": queried, "covered": covered, "covered_csv": ", ".join(covered),
1588
+ "carried": carried, "carried_csv": ", ".join(carried),
1589
+ "carried_count": len(carried), "any_carried": bool(carried),
1550
1590
  "affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
1551
1591
  "unlinked": unlinked, "unlinked_csv": ", ".join(unlinked),
1552
1592
  "unlinked_count": len(unlinked), "any_unlinked": bool(unlinked),
@@ -1718,6 +1758,18 @@ def rids(s):
1718
1758
  if t not in seen: seen.add(t); out.append(t)
1719
1759
  return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1720
1760
 
1761
+ def nids(s):
1762
+ """The classes a row can name that a STORY cannot be raised for: the bounds a slice is
1763
+ built to satisfy and the outcomes the feature gate scores. Kept apart from rids()
1764
+ rather than widening it, because the work set drives story creation and a story for a
1765
+ success criterion is not a thing — but a row that moved one of these still has to be
1766
+ said out loud, and until now it produced an empty work set and a run that stopped with
1767
+ 'the backlog is already in step'."""
1768
+ seen, out = set(), []
1769
+ for t in re.findall(r'\b(?:CN|DC|IC|UX|SC)-\d+', s or ""):
1770
+ if t not in seen: seen.add(t); out.append(t)
1771
+ return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1772
+
1721
1773
  def parse_fm(text):
1722
1774
  """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1723
1775
  a YAML block list under an empty key — both are valid YAML and a hand edit produces
@@ -1761,7 +1813,8 @@ for i in row_lines:
1761
1813
  # note somebody left in the table. It records no version, so it orders against nothing.
1762
1814
  if not re.match(r'^v\d', c[0]): continue
1763
1815
  rows.append({"version": c[0], "date": c[1], "change": c[2], "ids_cell": c[3],
1764
- "stories": c[4], "requirements": rids(c[3])})
1816
+ "stories": c[4], "requirements": rids(c[3]),
1817
+ "non_story": nids(c[3])})
1765
1818
  rows.sort(key=lambda r: vtuple(r["version"]))
1766
1819
 
1767
1820
  # WHAT THE DOCUMENT STILL DECLARES. Read from the requirement's DEFINING line — the bolded
@@ -1833,6 +1886,8 @@ if not rows:
1833
1886
  # backlog against a document nobody has committed to.
1834
1887
  out.update(work_csv="", work_count=0, work_source="none", span_versions_csv="",
1835
1888
  gap=False, removed_csv="",
1889
+ non_story_csv="", non_story_count=0, non_story_any=False,
1890
+ work_kind="none",
1836
1891
  work_note="the Change History table carries no version row")
1837
1892
  print(json.dumps(out)); sys.exit(0)
1838
1893
 
@@ -1912,8 +1967,25 @@ for rid in candidates:
1912
1967
  removed = [i for i in work if i not in declared]
1913
1968
  work = [i for i in work if i in declared]
1914
1969
 
1970
+ # WHAT MOVED THAT NO STORY CAN BE RAISED FOR. Constraints and success criteria travel in
1971
+ # the same version rows as requirements and are read by the same downstream gates, but they
1972
+ # produce no work set — so a row naming only those left this run stopping at "the backlog is
1973
+ # already in step. Nothing was written." about the exact ids story-plan-check had just
1974
+ # reported as stale. Carried out separately so the caller can say so without the work gate
1975
+ # having to admit ids it must not slice.
1976
+ #
1977
+ # work_kind IS FOR THE LOG, NOT FOR THE BRANCH. A row naming FR-001 AND CN-001 is
1978
+ # `story` — the work set is real — and branching on the enum alone would print the
1979
+ # requirement path, sync the stories, and say nothing at all about the constraint. The
1980
+ # caller announces on non_story_csv being non-empty, whatever work_any says.
1981
+ non_story = sorted({i for r in span for i in r["non_story"]},
1982
+ key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1983
+ work_kind = ("story" if work else ("non_story" if non_story else "none"))
1984
+
1915
1985
  out.update(work_csv=", ".join(work), work_count=len(work), work_source=source,
1916
1986
  work_note=note, work_any=bool(work),
1987
+ non_story_csv=", ".join(non_story), non_story_count=len(non_story),
1988
+ non_story_any=bool(non_story), work_kind=work_kind,
1917
1989
  candidates_csv=", ".join(candidates), candidate_count=len(candidates),
1918
1990
  in_step_csv=", ".join(in_step), in_step_count=len(in_step),
1919
1991
  floor_source=floor_source,
@@ -2530,6 +2602,7 @@ PY
2530
2602
  # carrying a work_item or a depends_on must not lose it to a stamp.
2531
2603
  cmd_story_stamp() {
2532
2604
  local fd_arg="" story="" ids="" version="" follows="" ids_given="" title="" deps="" wave=""
2605
+ local cons_given="" designed="" designed_given="" dropped="" dropped_given=""
2533
2606
  while [ $# -gt 0 ]; do
2534
2607
  case "$1" in
2535
2608
  --feature-dir) fd_arg="$2"; shift 2 ;;
@@ -2540,7 +2613,14 @@ cmd_story_stamp() {
2540
2613
  --title) title="$2"; shift 2 ;;
2541
2614
  --depends-on) deps="$2"; shift 2 ;;
2542
2615
  --wave) wave="$2"; shift 2 ;;
2543
- --help|-h) echo "story-stamp --story <id> [--ids <csv>] [--version <v>] [--follows <id>] [--title <t>] [--depends-on <csv>] [--wave <n>] [--feature-dir <p>]"; return 0 ;;
2616
+ # A BARE FLAG, NOT A CSV: the ids are lifted from the spec this op already has
2617
+ # a --feature-dir for. Nothing upstream has to enumerate them, and the second
2618
+ # caller (story-plan) can write the lane keys without re-lifting constraints
2619
+ # from a spec that may have moved past this story's spec_version.
2620
+ --constraints) cons_given=1; shift ;;
2621
+ --designed-lanes) designed="$2"; designed_given=1; shift 2 ;;
2622
+ --dropped-lanes) dropped="$2"; dropped_given=1; shift 2 ;;
2623
+ --help|-h) echo "story-stamp --story <id> [--ids <csv>] [--version <v>] [--follows <id>] [--title <t>] [--depends-on <csv>] [--wave <n>] [--constraints] [--designed-lanes <csv>] [--dropped-lanes <csv>] [--feature-dir <p>]"; return 0 ;;
2544
2624
  *) echo "story-stamp: unknown arg '$1'" >&2; return 1 ;;
2545
2625
  esac
2546
2626
  done
@@ -2549,7 +2629,9 @@ cmd_story_stamp() {
2549
2629
  local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-stamp: no feature set and no --feature-dir" >&2; return 1; }
2550
2630
  AW_FD="$fd" AW_STORY="$story" AW_IDS="$ids" AW_IDS_GIVEN="$ids_given" \
2551
2631
  AW_VER="$version" AW_FOLLOWS="$follows" AW_TITLE="$title" \
2552
- AW_DEPS="$deps" AW_WAVE="$wave" python3 - <<'PY'
2632
+ AW_DEPS="$deps" AW_WAVE="$wave" AW_CONSTRAINTS_GIVEN="$cons_given" \
2633
+ AW_DESIGNED="$designed" AW_DESIGNED_GIVEN="$designed_given" \
2634
+ AW_DROPPED="$dropped" AW_DROPPED_GIVEN="$dropped_given" python3 - <<'PY'
2553
2635
  import json, os, re, sys, datetime
2554
2636
  fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
2555
2637
  path = os.path.join(fd, "stories", story + ".md")
@@ -2577,6 +2659,47 @@ def sids(s):
2577
2659
  seen.add(t); out.append(t)
2578
2660
  return out
2579
2661
 
2662
+ def cids(s):
2663
+ """Constraint ids, deduped and ordered by class then number so two readers of the same
2664
+ text always produce the same csv. SC- is NOT one of these: a success criterion is a
2665
+ feature-level outcome analyze-slice scores, not a bound a slice is built to satisfy,
2666
+ and putting one here would hand it to the generation prompt as a bound."""
2667
+ seen, out = set(), []
2668
+ for t in re.findall(r'\b(?:CN|DC|IC|UX)-\d+', s or ""):
2669
+ if t not in seen: seen.add(t); out.append(t)
2670
+ return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2671
+
2672
+ def lanes(s):
2673
+ """backend / frontend out of a csv or an inline list. Anything else is not a lane."""
2674
+ out = []
2675
+ for t in re.split(r'[,\s\[\]"\']+', (s or "").lower()):
2676
+ if t in ("backend", "frontend") and t not in out: out.append(t)
2677
+ return sorted(out)
2678
+
2679
+ COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
2680
+ # An id keyed to the line that DEFINES it, in the two shapes the spec template uses:
2681
+ # a leading table cell (Data Classification) and a bold id opening a bullet (everything
2682
+ # else). The same two shapes spec-diff scores, for the same reason — a cross-reference
2683
+ # inside another requirement's prose defines nothing.
2684
+ DEF_CELL = re.compile(r'^\s*\|\s*((?:CN|DC|IC|UX)-\d+)\s*\|')
2685
+ DEF_BULL = re.compile(r'^\s*(?:[-*+]\s+)?\*\*((?:CN|DC|IC|UX)-\d+)\*\*')
2686
+
2687
+ def spec_constraints():
2688
+ """Every constraint id the spec DEFINES. Spec constraints are declared aggregate-wide —
2689
+ "constraints that must ALWAYS hold for the aggregate, independent of any single
2690
+ operation" — so there is no per-requirement relation to infer and no judgment to make:
2691
+ the feature's whole constraint set binds every slice of it. Change History is cut away
2692
+ first (its rows NAME ids without defining them), and so is the coverage block."""
2693
+ sp = os.path.join(fd, "spec.md")
2694
+ if not os.path.isfile(sp): return []
2695
+ t = COV.sub("", open(sp, encoding="utf-8", errors="replace").read())
2696
+ t = re.split(r'^##\s+Change History\s*$', t, maxsplit=1, flags=re.M)[0]
2697
+ found = []
2698
+ for line in t.split("\n"):
2699
+ m = DEF_CELL.match(line) or DEF_BULL.match(line)
2700
+ if m and m.group(1) not in found: found.append(m.group(1))
2701
+ return sorted(found, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2702
+
2580
2703
  text = open(path, encoding="utf-8", errors="replace").read()
2581
2704
  m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
2582
2705
  if m:
@@ -2610,8 +2733,9 @@ def put(key, value):
2610
2733
  order.append([key, [line]])
2611
2734
  return "added"
2612
2735
 
2613
- TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "spec_version",
2614
- "depends_on", "wave", "follows", "work_item", "created"]
2736
+ TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "constraints",
2737
+ "spec_version", "depends_on", "wave", "follows",
2738
+ "designed_lanes", "dropped_lanes", "work_item", "created"]
2615
2739
  def get(key):
2616
2740
  for entry in order:
2617
2741
  if entry[0] == key: return entry[1][0].split(":", 1)[1].strip()
@@ -2626,6 +2750,17 @@ if os.environ.get("AW_IDS_GIVEN"):
2626
2750
  else:
2627
2751
  ids = rids(get("exercises"))
2628
2752
 
2753
+ # THE BOUNDS THIS SLICE MUST MEET, AS A FRONTMATTER FIELD THE KIT WRITES. Everything a gate
2754
+ # reads lives in frontmatter; the story's `## Constraints` section was the one exception,
2755
+ # and being model-authored it could omit an id with nothing able to notice — a missing bound
2756
+ # in a generation prompt that never opens the spec. Lifted here, so the gate surface is
2757
+ # complete by construction and the section keeps its per-slice prose.
2758
+ if os.environ.get("AW_CONSTRAINTS_GIVEN"):
2759
+ cons = spec_constraints()
2760
+ changed["constraints"] = put("constraints", "[" + ", ".join(cons) + "]")
2761
+ else:
2762
+ cons = cids(get("constraints"))
2763
+
2629
2764
  ver = (os.environ.get("AW_VER") or "").strip()
2630
2765
  if ver: changed["spec_version"] = put("spec_version", ver)
2631
2766
 
@@ -2660,6 +2795,22 @@ if wv.isdigit():
2660
2795
  changed["wave"] = put("wave", wv)
2661
2796
  elif not get("wave").isdigit():
2662
2797
  changed["wave"] = put("wave", "1")
2798
+
2799
+ # WHICH LANES WERE DESIGNED, AND WHICH WERE DROPPED — two fields, because one of them cannot
2800
+ # say the difference. analyze-design STOPS on a lane whose plan is behind; story-plan DROPS
2801
+ # it and designs the other, so one command later "this slice has no user-facing surface" and
2802
+ # "the UI lane was out of scope when this was designed" are the same state on disk, and a
2803
+ # UX--bearing requirement can ship backend-only with a green story-scoped gate.
2804
+ # Designed ACCUMULATES across passes (a lane that has a design keeps having one); dropped is
2805
+ # whatever has been dropped and not since designed, so the two never both claim a lane.
2806
+ if os.environ.get("AW_DESIGNED_GIVEN") or os.environ.get("AW_DROPPED_GIVEN"):
2807
+ designed = sorted(set(lanes(get("designed_lanes")))
2808
+ | set(lanes(os.environ.get("AW_DESIGNED", ""))))
2809
+ dropped = sorted((set(lanes(get("dropped_lanes")))
2810
+ | set(lanes(os.environ.get("AW_DROPPED", "")))) - set(designed))
2811
+ changed["designed_lanes"] = put("designed_lanes", "[" + ", ".join(designed) + "]")
2812
+ changed["dropped_lanes"] = put("dropped_lanes", "[" + ", ".join(dropped) + "]")
2813
+
2663
2814
  if not get("created") or get("created").startswith("["):
2664
2815
  changed["created"] = put("created", datetime.date.today().isoformat())
2665
2816
  for k in ("title", "follows", "work_item"):
@@ -2672,8 +2823,26 @@ order.sort(key=lambda e: rank.get(e[0], len(TEMPLATE_ORDER)))
2672
2823
  out_block = "\n".join(l for e in order for l in e[1])
2673
2824
  open(path, "w", encoding="utf-8").write("---\n" + out_block + "\n---\n\n" + body.lstrip("\n"))
2674
2825
 
2826
+ # THE STAMP OWNS THE ID SET; THE SECTION KEEPS ITS PROSE. `## Constraints` is not a list of
2827
+ # ids — each line says what THIS slice has to do about the bound it names, and that line is
2828
+ # the whole reason the section is worth reading. So the body is RECONCILED, never rewritten:
2829
+ # an id now in the frontmatter with no line in the section is reported for the caller to
2830
+ # announce, and a line naming an id the spec no longer defines is reported the same way.
2831
+ # Neither is silently written or silently deleted.
2832
+ bm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', body, re.M | re.S)
2833
+ body_ids = set(cids(bm.group(1))) if bm else set()
2834
+ unlined = [c for c in cons if c not in body_ids]
2835
+ orphan_lines = sorted(body_ids - set(cons),
2836
+ key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2837
+
2675
2838
  print(json.dumps({"ok": True, "story": story, "path": path,
2676
2839
  "exercises": ids, "exercises_csv": ", ".join(ids),
2840
+ "constraints": cons, "constraints_csv": ", ".join(cons),
2841
+ "unlined_csv": ", ".join(unlined), "unlined_any": bool(unlined),
2842
+ "orphan_lines_csv": ", ".join(orphan_lines),
2843
+ "constraints_reconciled": not (unlined or orphan_lines),
2844
+ "designed_lanes": get("designed_lanes"),
2845
+ "dropped_lanes": get("dropped_lanes"),
2677
2846
  "spec_version": get("spec_version"), "status": get("status"),
2678
2847
  "depends_on": get("depends_on"), "wave": get("wave"),
2679
2848
  "follows": get("follows"), "title": get("title"),
@@ -2893,6 +3062,60 @@ def scan(path):
2893
3062
  return out
2894
3063
  def ids(path):
2895
3064
  return {k: v[2] for k, v in scan(path).items()}
3065
+ # A DEFINITION THE SCAN CANNOT KEY IS A REQUIREMENT THAT CAN CHANGE WITH NO LEDGER RECORD OF
3066
+ # IT. The parser keys an id to the line that defines it and hashes the block under it; three
3067
+ # authoring forms fall outside that and each one is silent — the edit lands, change_count
3068
+ # stays 0, no row is appended, and spec-stamp re-baselines the new body as clean, for every
3069
+ # story citing that id. Reported rather than parsed around: widening the parser to accept
3070
+ # them would make the ledger's shapes whatever anyone happened to type, and the fix a person
3071
+ # wants here is to write the requirement in the form the template uses.
3072
+ LOOSE_DEF = re.compile(r'^\s*(?:[-*+]\s+)?([A-Z]{2}-\d+)\s*[:—–-]')
3073
+ def unkeyed_warnings(path, keyed):
3074
+ warn = []
3075
+ if not path or not os.path.isfile(path): return warn
3076
+ raw = COV.sub("", open(path, encoding="utf-8", errors="replace").read())
3077
+ hm = HIST.search(raw)
3078
+ above, below = (raw[:hm.start()], raw[hm.end():]) if hm else (raw, "")
3079
+ lines = above.split("\n")
3080
+ plain = []
3081
+ for line in lines:
3082
+ m = LOOSE_DEF.match(line)
3083
+ if m and m.group(1) not in keyed and m.group(1) not in plain: plain.append(m.group(1))
3084
+ if plain:
3085
+ warn.append("written without bold ids (`- %s: …` rather than `- **%s**: …`), so the "
3086
+ "diff cannot key them to a definition and an edit to one raises no "
3087
+ "ledger row: %s" % (plain[0], plain[0], ", ".join(sorted(plain))))
3088
+ if hm:
3089
+ low = []
3090
+ for line in below.split("\n"):
3091
+ m = DEF_CELL.match(line) or DEF_BULL.match(line) or LOOSE_DEF.match(line)
3092
+ if m and m.group(1) not in keyed and m.group(1) not in low: low.append(m.group(1))
3093
+ if low:
3094
+ warn.append("defined BELOW the '## Change History' table, where the scan stops "
3095
+ "— the ledger reads nothing under its own table, so these are "
3096
+ "invisible to every version comparison: %s" % ", ".join(sorted(low)))
3097
+ orphaned = []
3098
+ for n, line in enumerate(lines):
3099
+ m = DEF_CELL.match(line) or DEF_BULL.match(line) or DEF_OPEN.match(line)
3100
+ if not m: continue
3101
+ j = n + 1
3102
+ while (j < len(lines) and lines[j].strip()
3103
+ and not lines[j].lstrip().startswith("#") and not is_def(lines[j])):
3104
+ j += 1
3105
+ # j is the first line outside the hashed block. A BLANK there, followed by indented
3106
+ # prose that opens no new definition, is a continuation the hash cannot see.
3107
+ if j < len(lines) and not lines[j].strip():
3108
+ k = j + 1
3109
+ if (k < len(lines) and lines[k].strip()
3110
+ and (lines[k].startswith(" ") or lines[k].startswith("\t"))
3111
+ and not is_def(lines[k]) and not lines[k].lstrip().startswith("#")
3112
+ and m.group(1) not in orphaned):
3113
+ orphaned.append(m.group(1))
3114
+ if orphaned:
3115
+ warn.append("carry continuation text below a BLANK line — the hashed block ends at "
3116
+ "the blank, so anything under it can be rewritten with no diff: %s"
3117
+ % ", ".join(sorted(orphaned)))
3118
+ return warn
2896
3119
  # Ids the ledger has already spent. An id is PERMANENT: re-issuing one silently re-points
2897
3120
  # stories, designs and code that have already shipped. Read from the SNAPSHOT's history, so
2898
3121
  # it is what the ledger said before this run, and only meaningful when there is a snapshot.
@@ -2915,6 +3138,7 @@ reissued = sorted(set(added) & spent(snap))
2915
3138
  # requirements this run touched" asks it to raise a story for a success criterion.
2916
3139
  STORY_CLASS = ("FR-", "BR-")
2917
3140
  story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
3141
+ warnings = unkeyed_warnings(spec, set(after))
2918
3142
  print(json.dumps({
2919
3143
  "ok": True, "had_snapshot": bool(snap and os.path.isfile(snap)),
2920
3144
  "added": added, "removed": removed, "modified": modified,
@@ -2937,6 +3161,12 @@ print(json.dumps({
2937
3161
  # changed, narrowed to the classes a story can cite — what sync-stories works from.
2938
3162
  "story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
2939
3163
  "story_id_count": len(story_ids),
3164
+ # Ids the document states in a form the ledger cannot key to a definition. Not a
3165
+ # verdict on this run's changes — a standing property of the document, reported every
3166
+ # time because the requirement it describes can move silently until it is rewritten.
3167
+ "warnings": warnings, "warning_count": len(warnings),
3168
+ "any_warnings": bool(warnings),
3169
+ "warnings_note": " // ".join(warnings),
2940
3170
  }))
2941
3171
  PY
2942
3172
  return 0
@@ -3136,12 +3366,33 @@ _project_markers() {
3136
3366
  | sed "s#^$repo_root/##" | LC_ALL=C sort | head -20
3137
3367
  }
3138
3368
 
3369
+ # Does a manifest belong to the ecosystem the caller asked about. The repo-scope fallback
3370
+ # below is deliberate — a .NET solution legitimately sits ABOVE the lane it scaffolds — but
3371
+ # it answers with whatever manifest it finds, so in a two-lane repo where only the frontend
3372
+ # is scaffolded the service lane's gate passes on the frontend's package.json. Knowing the
3373
+ # lane's ecosystem is what turns that hit back into a miss.
3374
+ _marker_ecosystem() {
3375
+ case "${1##*/}" in
3376
+ *.sln|*.csproj) printf 'dotnet' ;;
3377
+ package.json) printf 'node' ;;
3378
+ pom.xml|build.gradle|build.gradle.kts) printf 'java' ;;
3379
+ go.mod) printf 'go' ;;
3380
+ Cargo.toml) printf 'rust' ;;
3381
+ pyproject.toml) printf 'python' ;;
3382
+ *) printf '' ;;
3383
+ esac
3384
+ }
3385
+
3139
3386
  cmd_detect_project() {
3140
- local scope_root=""
3387
+ local scope_root="" ecosystem=""
3141
3388
  while [ $# -gt 0 ]; do
3142
3389
  case "$1" in
3143
3390
  --root|--source-path) scope_root="$2"; shift 2 ;;
3144
- --help|-h) echo "detect-project [--root <dir>] -> {scaffolded, markers[], scope}"; return 0 ;;
3391
+ # The lane's ecosystem, passed in by the caller that already resolved it.
3392
+ # NOT re-derived here: lane-gate binds it from stacks.json before this op runs,
3393
+ # and a second guess is a second thing to keep in step with that file.
3394
+ --ecosystem) ecosystem="$2"; shift 2 ;;
3395
+ --help|-h) echo "detect-project [--root <dir>] [--ecosystem <name>] -> {scaffolded, markers[], scope, ecosystem_match}"; return 0 ;;
3145
3396
  *) echo "detect-project: unknown arg '$1'" >&2; return 1 ;;
3146
3397
  esac
3147
3398
  done
@@ -3161,7 +3412,18 @@ cmd_detect_project() {
3161
3412
  fi
3162
3413
  local scaffolded=false
3163
3414
  [ ${#markers[@]} -gt 0 ] && scaffolded=true
3164
- printf '{"scaffolded":%s,"markers":%s,"scope":"%s"}\n' "$scaffolded" "$(json_str_array "${markers[@]}")" "$scope"
3415
+ # UNASKED IS NOT UNMATCHED. With no --ecosystem the caller has told us nothing to
3416
+ # judge against, and answering false there would fail every existing call site.
3417
+ local match=true m_eco
3418
+ if [ -n "$ecosystem" ] && [ "$scaffolded" = true ]; then
3419
+ match=false
3420
+ for m in "${markers[@]}"; do
3421
+ m_eco=$(_marker_ecosystem "$m")
3422
+ [ "$m_eco" = "$ecosystem" ] && { match=true; break; }
3423
+ done
3424
+ fi
3425
+ printf '{"scaffolded":%s,"markers":%s,"scope":"%s","ecosystem":"%s","ecosystem_match":%s}\n' \
3426
+ "$scaffolded" "$(json_str_array "${markers[@]}")" "$scope" "$ecosystem" "$match"
3165
3427
  }
3166
3428
 
3167
3429
 
@@ -4744,7 +5006,8 @@ def vtuple(v):
4744
5006
  try: return tuple(int(x) for x in v.lstrip("vV").split("."))
4745
5007
  except Exception: return (0,)
4746
5008
  out = {"doc": doc, "doc_exists": os.path.isfile(doc), "state": "", "derived_version": "",
4747
- "current_version": "", "changed_csv": "", "changed_summary": "", "body_changed": False}
5009
+ "current_version": "", "changed_csv": "", "changed_summary": "",
5010
+ "body_changed": False, "body_sha256": ""}
4748
5011
  # Current version = the widest of the ledger's rows and the **Version** header — the
4749
5012
  # same widest-wins rule ledger-append uses, so a hand-moved header cannot hide a row.
4750
5013
  rows = []; cur = ""; idcol = -1
@@ -4764,6 +5027,13 @@ if os.path.isfile(spec):
4764
5027
  out["current_version"] = cur
4765
5028
  if not out["doc_exists"]:
4766
5029
  out["state"] = "fresh"; print(json.dumps(out)); sys.exit(0)
5030
+ # THE HASH ITSELF, not only the boolean it was reduced to. "Has this document moved since it
5031
+ # was stamped" answers the freshness question; WHICH shape it is now is what a gate verdict
5032
+ # has to record and a story pass has to name, or approved-then-changed-then-never-re-approved
5033
+ # has nothing to compare against. Taken before the untracked exit so a document with no
5034
+ # derivation record still reports what it is.
5035
+ body = open(doc, encoding="utf-8", errors="replace").read()
5036
+ out["body_sha256"] = hashlib.sha256(body.encode("utf-8")).hexdigest()
4767
5037
  try: track = json.load(open(track_path, encoding="utf-8"))
4768
5038
  except Exception: track = {}
4769
5039
  entry = track.get(os.path.basename(doc))
@@ -4774,8 +5044,7 @@ if not entry:
4774
5044
  out["state"] = "untracked"; print(json.dumps(out)); sys.exit(0)
4775
5045
  derived = entry.get("spec_version", "")
4776
5046
  out["derived_version"] = derived
4777
- body = open(doc, encoding="utf-8", errors="replace").read()
4778
- out["body_changed"] = hashlib.sha256(body.encode("utf-8")).hexdigest() != entry.get("sha256", "")
5047
+ out["body_changed"] = out["body_sha256"] != entry.get("sha256", "")
4779
5048
  moved = [r for r in rows if vtuple(r[0]) > vtuple(derived)]
4780
5049
  # Scrape ONLY the ledger's "Requirements changed" cell when it names one. The
4781
5050
  # Stories cell holds ids of the form <Aggregate>-nnn, which read as requirement ids
@@ -4967,6 +5236,91 @@ print(json.dumps({"ok": True, "result_file": out, "status": res["status"], "gap_
4967
5236
  PY
4968
5237
  }
4969
5238
 
5239
+ # ──────────────────────────────── gate-read ────────────────────────────────
5240
+ # THE OTHER HALF OF gate-result. Every gate has written a machine-readable verdict beside
5241
+ # its report for some time and nothing in the kit read one back, so "was this certified,
5242
+ # and against what" was a question only a human opening the JSON could answer — which is
5243
+ # why law 40 (approved, changed, never re-approved must not reach code) had no enforcement
5244
+ # anywhere: the record existed and had no reader.
5245
+ #
5246
+ # Always exits 0. A missing verdict is not an error here — it is the answer "never
5247
+ # certified", and the caller's verify_context owns the stop (the lane-verify convention).
5248
+ #
5249
+ # TWO ABSENCES THAT LOOK ALIKE AND ARE NOT. A gate that never ran leaves no file. A gate
5250
+ # that STARTED and aborted leaves the red gate_result_open writes before any analysis, whose
5251
+ # note says so. Both mean "nobody certified this"; neither means "certified and failed",
5252
+ # and routing them to 'go read the gaps' sends someone to a report with none in it. So
5253
+ # `certified` is reported apart from `status`, and `aborted` names the second case.
5254
+ cmd_gate_read() {
5255
+ local fd_arg="" gate="" report="" file=""
5256
+ while [ $# -gt 0 ]; do
5257
+ case "$1" in
5258
+ --feature-dir) fd_arg="$2"; shift 2 ;;
5259
+ --gate) gate="$2"; shift 2 ;;
5260
+ --report) report="$2"; shift 2 ;;
5261
+ --file) file="$2"; shift 2 ;;
5262
+ --help|-h) echo "gate-read --gate <name> [--report <path>] [--file <path>] [--feature-dir <p>]"; return 0 ;;
5263
+ *) echo "gate-read: unknown arg '$1'" >&2; return 1 ;;
5264
+ esac
5265
+ done
5266
+ [ -n "$gate" ] || { echo "gate-read: --gate required" >&2; return 1; }
5267
+ local repo_root; repo_root=$(get_repo_root)
5268
+ local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "gate-read: no feature set and no --feature-dir" >&2; return 1; }
5269
+ AW_GATE="$gate" AW_FD="$fd" AW_REPORT="$report" AW_FILE="$file" python3 - <<'PY'
5270
+ import json, os
5271
+ gate = (os.environ.get("AW_GATE", "") or "").strip()
5272
+ fd = os.environ.get("AW_FD", "")
5273
+ report = (os.environ.get("AW_REPORT", "") or "").strip()
5274
+ path = (os.environ.get("AW_FILE", "") or "").strip()
5275
+ # Resolved the same way gate-result names it, so the writer and the reader cannot drift:
5276
+ # beside the markdown report and named after it. The per-gate defaults are the report paths
5277
+ # those two workflows set; the dotfile is gate-result's own fallback.
5278
+ DEFAULT = {"analyze-design": "plan-analyze-report.json",
5279
+ "analyze-slice": "slice-analyze-report.json"}
5280
+ if not path:
5281
+ if report: path = os.path.splitext(report)[0] + ".json"
5282
+ elif gate in DEFAULT: path = os.path.join(fd, DEFAULT[gate])
5283
+ else: path = os.path.join(fd, ".aw-gate-%s.json" % gate)
5284
+
5285
+ out = {"ok": True, "gate": gate, "result_file": path, "exists": os.path.isfile(path),
5286
+ "status": "", "certified": False, "aborted": False, "note": "",
5287
+ "spec_version": "", "story": "", "report": "", "generated_at": "",
5288
+ "gap_count": 0, "gap_refs_csv": "",
5289
+ "backend_sha256": "", "frontend_sha256": "",
5290
+ "backend_in_scope": "", "frontend_in_scope": "", "fields": {}}
5291
+ res = {}
5292
+ if out["exists"]:
5293
+ try: res = json.load(open(path, encoding="utf-8"))
5294
+ except Exception: res = {}
5295
+ if not isinstance(res, dict) or not res:
5296
+ out["note"] = ("no readable verdict at %s — this gate has not certified anything here"
5297
+ % path)
5298
+ print(json.dumps(out)); raise SystemExit(0)
5299
+
5300
+ fields = res.get("fields") or {}
5301
+ if not isinstance(fields, dict): fields = {}
5302
+ out.update(status=(res.get("status") or "").strip(),
5303
+ note=(res.get("note") or "").strip(),
5304
+ spec_version=(res.get("spec_version") or "").strip(),
5305
+ story=(res.get("story") or "").strip(),
5306
+ report=(res.get("report") or "").strip(),
5307
+ generated_at=(res.get("generated_at") or "").strip(),
5308
+ gap_count=res.get("gap_count") or 0,
5309
+ gap_refs_csv=", ".join(res.get("gap_refs") or []),
5310
+ fields=fields,
5311
+ backend_sha256=(fields.get("backend_sha256") or "").strip(),
5312
+ frontend_sha256=(fields.get("frontend_sha256") or "").strip(),
5313
+ backend_in_scope=(fields.get("backend_in_scope") or "").strip(),
5314
+ frontend_in_scope=(fields.get("frontend_in_scope") or "").strip())
5315
+ # A verdict written before any analysis ran carries no spec version, because the workflow
5316
+ # has not read one yet. That, and not the note's wording, is what separates the aborted red
5317
+ # from a real one — the note is prose and prose gets edited.
5318
+ out["aborted"] = out["status"] != "pass" and not out["spec_version"]
5319
+ out["certified"] = out["status"] == "pass"
5320
+ print(json.dumps(out))
5321
+ PY
5322
+ }
5323
+
4970
5324
  # ─────────────── story-plan-check / story-plan-doc / log-append ───────────────
4971
5325
  # The per-story execution design ("story design") — SHORT-LIVED scaffolding, ONE
4972
5326
  # DOCUMENT PER LANE beside the story: stories/<ID>/story-backend-plan.md and
@@ -5053,7 +5407,18 @@ if os.path.isdir(sdir):
5053
5407
  "title": (fm.get("title") or "").strip().strip("[]"),
5054
5408
  "spec_version": (fm.get("spec_version") or "").strip(),
5055
5409
  "cites": re.findall(r'\b(?:FR|BR)-\d+', fm.get("exercises", "")),
5056
- "path": p, "text": text})
5410
+ "wave": (fm.get("wave") or "").strip(),
5411
+ "deps": [d for d in re.split(r'[,\s\[\]"\']+',
5412
+ fm.get("depends_on", "") or "")
5413
+ if re.match(r'^[A-Za-z][\w.]*-\d+$', d)],
5414
+ "fm": fm, "path": p, "text": text})
5415
+
5416
+ def lanes(s):
5417
+ """backend / frontend out of a csv or an inline list; anything else is not a lane."""
5418
+ out = []
5419
+ for tok in re.split(r'[,\s\[\]"\']+', (s or "").lower()):
5420
+ if tok in ("backend", "frontend") and tok not in out: out.append(tok)
5421
+ return sorted(out)
5057
5422
  open_stories = [s for s in stories if s["status"] == "open"]
5058
5423
  open_ids = [s["id"] for s in open_stories]
5059
5424
  digest = "\n".join(" %-16s %s%s" % (s["id"], s["title"] or "(untitled)",
@@ -5084,11 +5449,18 @@ out = {"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
5084
5449
  "story_type": "", "is_technical": False,
5085
5450
  "status_msg": "", "story_title": "", "exercises_csv": "", "constraints_csv": "",
5086
5451
  "story_spec_version": "", "current_version": "", "stale_csv": "", "stale_note": "",
5452
+ "story_wave": "", "depends_on_csv": "", "open_deps_csv": "",
5453
+ "unresolved_deps_csv": "", "deps_note": "", "deps_clear": True,
5454
+ "designed_lanes_csv": "", "dropped_lanes_csv": "", "lane_keys_present": False,
5455
+ "be_lane_designed": False, "fe_lane_designed": False,
5456
+ "be_lane_dropped": False, "fe_lane_dropped": False,
5087
5457
  "story_dir": "", "if_tmp": "", "legacy_doc": "", "legacy_doc_exists": False,
5088
5458
  "be_doc_path": "", "be_doc_exists": False, "be_next_pass": 1, "be_pass_tmp": "",
5089
5459
  "be_last_pass": 0, "be_doc_spec_version": "", "be_doc_stale": False,
5460
+ "be_pass_plan_hash": "",
5090
5461
  "fe_doc_path": "", "fe_doc_exists": False, "fe_next_pass": 1, "fe_pass_tmp": "",
5091
5462
  "fe_last_pass": 0, "fe_doc_spec_version": "", "fe_doc_stale": False,
5463
+ "fe_pass_plan_hash": "",
5092
5464
  "adr_path": os.path.join(fd, "adr.md"), "adr_exists": os.path.isfile(os.path.join(fd, "adr.md")),
5093
5465
  "if_path": os.path.join(fd, "interfaces.md"), "if_exists": os.path.isfile(os.path.join(fd, "interfaces.md")),
5094
5466
  "spec_exists": os.path.isfile(spec)}
@@ -5126,12 +5498,66 @@ if status != "open":
5126
5498
  out["status_msg"] = "Story %s %s." % (
5127
5499
  target["id"], verb.get(status, "has status '%s', not open" % status))
5128
5500
 
5129
- # bound constraint ids come from the story BODY's ## Constraints section (they are
5130
- # barred from exercises: by design — met, not delivered)
5131
- cm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', target["text"], re.M | re.S)
5132
- cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', cm.group(1)))) if cm else []
5501
+ # THE BOUNDS COME FROM FRONTMATTER, which story-stamp lifts from the spec. They are barred
5502
+ # from exercises: by design — a constraint is met, not delivered — and they were read out of
5503
+ # the BODY's ## Constraints section until that section turned out to be the one gate surface
5504
+ # the kit did not write: a model that omitted an id dropped a bound out of the generation
5505
+ # prompt with nothing able to contradict it.
5506
+ #
5507
+ # ABSENT IS NOT EMPTY. A story stamped before the field existed carries its ids only in the
5508
+ # body, so a missing key falls back to the section; an explicit `constraints: []` is an
5509
+ # answer and is taken as one. The fallback goes once every live backlog is re-stamped.
5510
+ if "constraints" in target["fm"]:
5511
+ cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', target["fm"]["constraints"])))
5512
+ else:
5513
+ cm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', target["text"], re.M | re.S)
5514
+ cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', cm.group(1)))) if cm else []
5133
5515
  out["constraints_csv"] = ", ".join(cons)
5134
5516
 
5517
+ # WHAT HAS TO LAND FIRST. sync-stories computes depends_on: and wave: and stamps both, and
5518
+ # until now nothing downstream read either — the ordering was recorded and never enforced.
5519
+ # Two different misses, kept apart: a dependency whose story is still OPEN here is work in
5520
+ # progress, and one with no story file in this feature is UNRESOLVED, which is the ordinary
5521
+ # shape of a cross-feature edge (Payment-004 from Order) and not the same as satisfied.
5522
+ #
5523
+ # The wave is surfaced and NOT gated. A story becomes startable when its own dependencies
5524
+ # land, not when the whole previous wave finishes, so warning on "an open story in a lower
5525
+ # wave" would serialise a wave that exists precisely to be unordered.
5526
+ # WHICH LANES THIS STORY WAS DESIGNED FOR, AND WHICH WERE DROPPED. story-plan stamps both,
5527
+ # because one of them cannot say the difference: a story with no UI design either has no
5528
+ # user-facing surface or had its UI lane dropped when it was designed, and those two states
5529
+ # are the same absence on disk. `lane_keys_present` is what keeps the distinction honest for
5530
+ # a story stamped before the fields existed — it has NEITHER key, and absent must never be
5531
+ # read as "no surface".
5532
+ des = lanes(target["fm"].get("designed_lanes", ""))
5533
+ drp = lanes(target["fm"].get("dropped_lanes", ""))
5534
+ out.update(designed_lanes_csv=", ".join(des), dropped_lanes_csv=", ".join(drp),
5535
+ lane_keys_present=("designed_lanes" in target["fm"]
5536
+ or "dropped_lanes" in target["fm"]),
5537
+ be_lane_designed=("backend" in des), fe_lane_designed=("frontend" in des),
5538
+ be_lane_dropped=("backend" in drp), fe_lane_dropped=("frontend" in drp))
5539
+
5540
+ deps = target["deps"]
5541
+ by_id = {s["id"]: s for s in stories}
5542
+ open_deps = [d for d in deps if d in by_id and by_id[d]["status"] == "open"]
5543
+ unresolved = [d for d in deps if d not in by_id]
5544
+ out.update(story_wave=target["wave"], depends_on_csv=", ".join(deps),
5545
+ open_deps_csv=", ".join(open_deps),
5546
+ unresolved_deps_csv=", ".join(unresolved),
5547
+ deps_clear=not (open_deps or unresolved))
5548
+ if open_deps or unresolved:
5549
+ parts = []
5550
+ if open_deps:
5551
+ parts.append("still open in this feature: %s" % ", ".join(open_deps))
5552
+ if unresolved:
5553
+ parts.append("no story file here — check the feature that owns them: %s"
5554
+ % ", ".join(unresolved))
5555
+ out["deps_note"] = (
5556
+ "ORDERING: %s declares depends_on: %s, and %s. This is a notice, not a refusal — "
5557
+ "the dependency is about CODE ordering, and calling into work that has not landed "
5558
+ "is where it starts to cost something."
5559
+ % (target["id"], ", ".join(deps), "; ".join(parts)))
5560
+
5135
5561
  # staleness: ledger rows ABOVE the story's stamp naming an id the story carries —
5136
5562
  # exercises: AND the Constraints section, so a moved bound is announced too
5137
5563
  sv = target["spec_version"]
@@ -5179,12 +5605,21 @@ for lane, stem in (("be", "backend"), ("fe", "frontend")):
5179
5605
  dv = (parse_fm(d).get("spec_version") or "").strip().strip('"')
5180
5606
  out[lane + "_doc_spec_version"] = dv
5181
5607
  out[lane + "_doc_stale"] = bool(dv and cur and vtuple(dv) < vtuple(cur))
5608
+ # THE FEATURE PLAN THIS PASS WAS DESIGNED AGAINST, as analyze-design certified it.
5609
+ # A story pass is written once and read forever, so comparing only the CURRENT plan
5610
+ # against the CURRENT verdict leaves the case law 40 is actually about: a pass
5611
+ # designed against plan H1 still generating after the plan was edited to H2 and
5612
+ # re-gated. The hash is script-written into the pass header by story-plan-doc; the
5613
+ # LAST pass is the one implement builds from, so it is the last one read here.
5614
+ hs = re.findall(r'^##\s+Design pass\s+\d+\b[^\n]*?\(plan\s+([0-9a-f]{8,64})\)',
5615
+ d, re.M)
5616
+ out[lane + "_pass_plan_hash"] = hs[-1] if hs else ""
5182
5617
  print(json.dumps(out))
5183
5618
  PY
5184
5619
  }
5185
5620
 
5186
5621
  cmd_story_plan_doc() {
5187
- local fd_arg="" story="" pass_file="" spec_version="" lane="backend"
5622
+ local fd_arg="" story="" pass_file="" spec_version="" lane="backend" plan_hash=""
5188
5623
  while [ $# -gt 0 ]; do
5189
5624
  case "$1" in
5190
5625
  --feature-dir) fd_arg="$2"; shift 2 ;;
@@ -5192,7 +5627,11 @@ cmd_story_plan_doc() {
5192
5627
  --lane) lane="$2"; shift 2 ;;
5193
5628
  --pass-file) pass_file="$2"; shift 2 ;;
5194
5629
  --spec-version) spec_version="$2"; shift 2 ;;
5195
- --help|-h) echo "story-plan-doc --story <id> --pass-file <path> [--lane backend|frontend] [--spec-version <v>] [--feature-dir <path>]"; return 0 ;;
5630
+ # The CERTIFIED feature-plan hash — the one analyze-design recorded in its
5631
+ # verdict, never a fresh one taken here. Stamped into the pass header so a pass
5632
+ # keeps saying which plan shape it was designed against long after the plan moved.
5633
+ --plan-hash) plan_hash="$2"; shift 2 ;;
5634
+ --help|-h) echo "story-plan-doc --story <id> --pass-file <path> [--lane backend|frontend] [--spec-version <v>] [--plan-hash <sha256>] [--feature-dir <path>]"; return 0 ;;
5196
5635
  *) echo "story-plan-doc: unknown arg '$1'" >&2; return 1 ;;
5197
5636
  esac
5198
5637
  done
@@ -5200,11 +5639,13 @@ cmd_story_plan_doc() {
5200
5639
  [ -n "$pass_file" ] || { echo "story-plan-doc: --pass-file is required" >&2; return 1; }
5201
5640
  local repo_root; repo_root=$(get_repo_root)
5202
5641
  local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-plan-doc: no feature set and no --feature-dir" >&2; return 1; }
5203
- AW_FD="$fd" AW_STORY="$story" AW_LANE="$lane" AW_PASS="$pass_file" AW_VER="$spec_version" python3 - <<'PY'
5642
+ AW_FD="$fd" AW_STORY="$story" AW_LANE="$lane" AW_PASS="$pass_file" AW_VER="$spec_version" \
5643
+ AW_PLAN_HASH="$plan_hash" python3 - <<'PY'
5204
5644
  import json, os, re, sys, datetime
5205
5645
  fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
5206
5646
  pf = os.environ["AW_PASS"]; ver = (os.environ.get("AW_VER") or "").strip()
5207
5647
  lane = (os.environ.get("AW_LANE") or "backend").strip().lower()
5648
+ phash = re.sub(r'[^0-9a-f]', '', (os.environ.get("AW_PLAN_HASH") or "").strip().lower())
5208
5649
  sdir = os.path.join(fd, "stories", story)
5209
5650
  doc = os.path.join(sdir, "story-%s-plan.md" % lane)
5210
5651
 
@@ -5265,7 +5706,9 @@ spec_version: ""
5265
5706
  text = open(doc, encoding="utf-8", errors="replace").read()
5266
5707
  nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', text, re.M)]
5267
5708
  n = (max(nums) + 1) if nums else 1
5268
- header = "## Design pass %d — %s%s" % (n, today, (" (spec %s)" % ver) if ver else "")
5709
+ header = "## Design pass %d — %s%s%s" % (
5710
+ n, today, (" (spec %s)" % ver) if ver else "",
5711
+ (" (plan %s)" % phash) if phash else "")
5269
5712
  text = text.rstrip("\n") + "\n\n" + header + "\n\n" + content + "\n"
5270
5713
  # frontmatter stamps — first match only, which is the frontmatter by position
5271
5714
  text = re.sub(r'^(updated:).*$', r'\g<1> ' + today, text, count=1, flags=re.M)
@@ -5275,7 +5718,7 @@ if ver:
5275
5718
  open(doc, "w", encoding="utf-8").write(text)
5276
5719
  os.remove(pf)
5277
5720
  print(json.dumps({"ok": True, "doc": doc, "lane": lane, "pass": n, "created": created,
5278
- "spec_version": ver, "story_dir": sdir}))
5721
+ "spec_version": ver, "plan_hash": phash, "story_dir": sdir}))
5279
5722
  PY
5280
5723
  }
5281
5724
 
@@ -5296,7 +5739,7 @@ cmd_log_append() {
5296
5739
  local repo_root; repo_root=$(get_repo_root)
5297
5740
  local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "log-append: no feature set and no --feature-dir" >&2; return 1; }
5298
5741
  AW_FD="$fd" AW_LOG="$log" AW_ENTRIES="$entries_file" AW_STATUS="$status" python3 - <<'PY'
5299
- import json, os, re, sys, datetime
5742
+ import json, os, re, sys, time, errno, datetime
5300
5743
  fd = os.environ["AW_FD"]; log = os.environ["AW_LOG"]
5301
5744
  ef = os.environ["AW_ENTRIES"]; status = (os.environ.get("AW_STATUS") or "").strip()
5302
5745
  fname, prefix, default_status, title = {
@@ -5359,6 +5802,46 @@ SCAFFOLD = {
5359
5802
 
5360
5803
  # Architecture Decision Log
5361
5804
  """}
5805
+ # THE ID ASSIGNMENT AND THE WRITE ARE ONE OPERATION. `nxt = max(existing) + 1` is computed
5806
+ # from a whole-file read and the file is then written back truncating — so two runs that
5807
+ # read before either wrote both compute the same next id, and the second write drops the
5808
+ # first's entry entirely. The wave model exists to have same-wave stories designed in
5809
+ # parallel, and this is the artefact §0.3 calls the cross-lane contract anchor, so the race
5810
+ # is not hypothetical: two story-plan runs, or plan and story-plan, is the ordinary case.
5811
+ # Git catches a lost entry across branches and never within one working tree.
5812
+ #
5813
+ # The lock is named for the FILE, so every writer of interfaces.md contends on one lock
5814
+ # whichever op or shell it came from — aw-xref.py names its own the same way for the same
5815
+ # reason. A lock older than the timeout belonged to a run that died holding it; breaking it
5816
+ # is right, because the alternative is a wedged feature nobody can append to again.
5817
+ LOCK_TIMEOUT = 30.0
5818
+ def take_lock(target):
5819
+ lp = target + ".aw-lock"
5820
+ start = time.time()
5821
+ while True:
5822
+ try:
5823
+ h = os.open(lp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
5824
+ os.write(h, ("%d\n" % os.getpid()).encode("utf-8")); os.close(h)
5825
+ return lp
5826
+ except OSError as e:
5827
+ if e.errno != errno.EEXIST: raise
5828
+ try: age = time.time() - os.path.getmtime(lp)
5829
+ except OSError: age = 0.0
5830
+ if age > LOCK_TIMEOUT:
5831
+ try: os.unlink(lp)
5832
+ except OSError: pass
5833
+ continue
5834
+ if time.time() - start > LOCK_TIMEOUT:
5835
+ bail("log-append: %s has been locked by another run for over %ds. If no "
5836
+ "other /appweaver command is running, delete %s and re-run."
5837
+ % (os.path.basename(target), int(LOCK_TIMEOUT), lp))
5838
+ time.sleep(0.05)
5839
+
5840
+ def drop_lock(lp):
5841
+ try: os.unlink(lp)
5842
+ except OSError: pass
5843
+
5844
+ lock = take_lock(path)
5362
5845
  created = not os.path.isfile(path)
5363
5846
  if created:
5364
5847
  open(path, "w", encoding="utf-8").write(SCAFFOLD[log])
@@ -5395,6 +5878,7 @@ for b in blocks:
5395
5878
 
5396
5879
  text = text.rstrip("\n") + "\n\n" + "\n\n".join(out_blocks) + "\n"
5397
5880
  open(path, "w", encoding="utf-8").write(text)
5881
+ drop_lock(lock)
5398
5882
  os.remove(ef)
5399
5883
  print(json.dumps({"ok": True, "log": path, "appended": len(assigned),
5400
5884
  "ids_csv": ", ".join(assigned),
@@ -5444,6 +5928,7 @@ case "$sub" in
5444
5928
  plan-check) cmd_plan_check "$@" ;;
5445
5929
  plan-stamp) cmd_plan_stamp "$@" ;;
5446
5930
  gate-result) cmd_gate_result "$@" ;;
5931
+ gate-read) cmd_gate_read "$@" ;;
5447
5932
  build-compile) cmd_build_compile "$@" ;;
5448
5933
  test-run) cmd_test_run "$@" ;;
5449
5934
  test-diff) cmd_test_diff "$@" ;;