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.
@@ -1053,9 +1053,19 @@ if op == "check":
1053
1053
  "document is behind its own ledger" % (doc_ver or "no version", recorded_ver))
1054
1054
  else:
1055
1055
  out["state"] = "unledgered_edit"
1056
+ # BOTH ROUTES, BECAUSE NOT EVERY EDIT HAS AN ID TO NAME. Law 5 requires a version row
1057
+ # to name at least one id, so an edit to Aggregate Boundary, Experience Scope or any
1058
+ # other load-bearing prose has nothing legal to put in the cell — and being told to
1059
+ # "add a row naming the ids you changed" sent that person round a loop with no exit.
1060
+ # The protocol has always sanctioned re-baselining instead when the diff finds no id
1061
+ # (SPEC-EDIT-PROTOCOL.md, "The short version" step 4 and the PR checklist); it was
1062
+ # simply never named at the one place anybody reads it.
1056
1063
  out.update(ok=False, error="spec.md has changed since %s but no Change History row "
1057
1064
  "was added. Every story citing an edited requirement is now silently "
1058
- "stale. Add a row naming the ids you changed, then re-run."
1065
+ "stale. Add a row naming the ids you changed, then re-run — or, if this "
1066
+ "edit touched NO requirement (prose only: scope, boundary, wording that "
1067
+ "defines no id), re-run spec-stamp to re-baseline instead. Run spec-diff "
1068
+ "against the snapshot to see which of the two this is."
1059
1069
  % recorded_ver)
1060
1070
  elif op == "stamp":
1061
1071
  # THE VERSION IS THE HIGHEST OF THE THREE PLACES ONE IS WRITTEN, and the document's
@@ -1255,6 +1265,60 @@ def scan(path):
1255
1265
  return out
1256
1266
  def ids(path):
1257
1267
  return {k: v[2] for k, v in scan(path).items()}
1268
+ # A DEFINITION THE SCAN CANNOT KEY IS A REQUIREMENT THAT CAN CHANGE WITH NO LEDGER RECORD OF
1269
+ # IT. The parser keys an id to the line that defines it and hashes the block under it; three
1270
+ # authoring forms fall outside that and each one is silent — the edit lands, change_count
1271
+ # stays 0, no row is appended, and spec-stamp re-baselines the new body as clean, for every
1272
+ # story citing that id. Reported rather than parsed around: widening the parser to accept
1273
+ # them would make the ledger's shapes whatever anyone happened to type, and the fix a person
1274
+ # wants here is to write the requirement in the form the template uses.
1275
+ LOOSE_DEF = re.compile(r'^\s*(?:[-*+]\s+)?([A-Z]{2}-\d+)\s*[:—–-]')
1276
+ def unkeyed_warnings(path, keyed):
1277
+ warn = []
1278
+ if not path or not os.path.isfile(path): return warn
1279
+ raw = COV.sub("", open(path, encoding="utf-8", errors="replace").read())
1280
+ hm = HIST.search(raw)
1281
+ above, below = (raw[:hm.start()], raw[hm.end():]) if hm else (raw, "")
1282
+ lines = above.split("\n")
1283
+ plain = []
1284
+ for line in lines:
1285
+ m = LOOSE_DEF.match(line)
1286
+ if m and m.group(1) not in keyed and m.group(1) not in plain: plain.append(m.group(1))
1287
+ if plain:
1288
+ warn.append("written without bold ids (`- %s: …` rather than `- **%s**: …`), so the "
1289
+ "diff cannot key them to a definition and an edit to one raises no "
1290
+ "ledger row: %s" % (plain[0], plain[0], ", ".join(sorted(plain))))
1291
+ if hm:
1292
+ low = []
1293
+ for line in below.split("\n"):
1294
+ m = DEF_CELL.match(line) or DEF_BULL.match(line) or LOOSE_DEF.match(line)
1295
+ if m and m.group(1) not in keyed and m.group(1) not in low: low.append(m.group(1))
1296
+ if low:
1297
+ warn.append("defined BELOW the '## Change History' table, where the scan stops "
1298
+ "— the ledger reads nothing under its own table, so these are "
1299
+ "invisible to every version comparison: %s" % ", ".join(sorted(low)))
1300
+ orphaned = []
1301
+ for n, line in enumerate(lines):
1302
+ m = DEF_CELL.match(line) or DEF_BULL.match(line) or DEF_OPEN.match(line)
1303
+ if not m: continue
1304
+ j = n + 1
1305
+ while (j < len(lines) and lines[j].strip()
1306
+ and not lines[j].lstrip().startswith("#") and not is_def(lines[j])):
1307
+ j += 1
1308
+ # j is the first line outside the hashed block. A BLANK there, followed by indented
1309
+ # prose that opens no new definition, is a continuation the hash cannot see.
1310
+ if j < len(lines) and not lines[j].strip():
1311
+ k = j + 1
1312
+ if (k < len(lines) and lines[k].strip()
1313
+ and (lines[k].startswith(" ") or lines[k].startswith("\t"))
1314
+ and not is_def(lines[k]) and not lines[k].lstrip().startswith("#")
1315
+ and m.group(1) not in orphaned):
1316
+ orphaned.append(m.group(1))
1317
+ if orphaned:
1318
+ warn.append("carry continuation text below a BLANK line — the hashed block ends at "
1319
+ "the blank, so anything under it can be rewritten with no diff: %s"
1320
+ % ", ".join(sorted(orphaned)))
1321
+ return warn
1258
1322
  # Ids the ledger has already spent. An id is PERMANENT: re-issuing one silently re-points
1259
1323
  # stories, designs and code that have already shipped. Read from the SNAPSHOT's history, so
1260
1324
  # it is what the ledger said before this run, and only meaningful when there is a snapshot.
@@ -1277,6 +1341,7 @@ reissued = sorted(set(added) & spent(snap))
1277
1341
  # requirements this run touched" asks it to raise a story for a success criterion.
1278
1342
  STORY_CLASS = ("FR-", "BR-")
1279
1343
  story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
1344
+ warnings = unkeyed_warnings(spec, set(after))
1280
1345
  print(json.dumps({
1281
1346
  "ok": True, "had_snapshot": bool(snap and os.path.isfile(snap)),
1282
1347
  "added": added, "removed": removed, "modified": modified,
@@ -1299,6 +1364,12 @@ print(json.dumps({
1299
1364
  # changed, narrowed to the classes a story can cite — what sync-stories works from.
1300
1365
  "story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
1301
1366
  "story_id_count": len(story_ids),
1367
+ # Ids the document states in a form the ledger cannot key to a definition. Not a
1368
+ # verdict on this run's changes — a standing property of the document, reported every
1369
+ # time because the requirement it describes can move silently until it is rewritten.
1370
+ "warnings": warnings, "warning_count": len(warnings),
1371
+ "any_warnings": bool(warnings),
1372
+ "warnings_note": " // ".join(warnings),
1302
1373
  }))
1303
1374
  '@
1304
1375
 
@@ -1333,9 +1404,24 @@ def parse_fm(text):
1333
1404
  fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1334
1405
  return fm
1335
1406
 
1336
- def frontmatter(path):
1337
- try: return parse_fm(open(path, encoding="utf-8", errors="replace").read())
1338
- except Exception: return {}
1407
+ def read(path):
1408
+ try: return open(path, encoding="utf-8", errors="replace").read()
1409
+ except Exception: return ""
1410
+
1411
+ def bound_ids(fm, text):
1412
+ """The constraint ids this story is built to satisfy. Read from the `constraints:`
1413
+ FRONTMATTER field, which story-stamp lifts from the spec — the body's `## Constraints`
1414
+ section is model-authored prose, so an id the model left out of it would be invisible to
1415
+ exactly the scan that exists to notice a bound moving under someone.
1416
+
1417
+ ABSENT IS NOT EMPTY. A story written before the field existed has no key at all and its
1418
+ ids are still only in the body, so that case falls back to the section. An explicitly
1419
+ empty `constraints: []` is an answer — this story is bound by nothing — and is taken as
1420
+ one. The fallback goes when every live backlog has been re-stamped."""
1421
+ if "constraints" in fm:
1422
+ return sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', fm["constraints"])))
1423
+ bm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', text, re.M | re.S)
1424
+ return sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', bm.group(1)))) if bm else []
1339
1425
 
1340
1426
  stories = []
1341
1427
  if os.path.isdir(sdir):
@@ -1343,14 +1429,18 @@ if os.path.isdir(sdir):
1343
1429
  if not name.endswith(".md"): continue
1344
1430
  path = os.path.join(sdir, name)
1345
1431
  if not os.path.isfile(path): continue
1346
- fm = frontmatter(path)
1432
+ text = read(path); fm = parse_fm(text)
1347
1433
  sid = fm.get("id") or name[:-3]
1348
1434
  # An unparseable story is reported as open rather than skipped: a story the scan
1349
1435
  # cannot read is exactly the one whose requirements must not move unannounced.
1350
1436
  status = (fm.get("status") or "open").strip().lower()
1351
1437
  cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
1438
+ bounds = bound_ids(fm, text)
1352
1439
  stories.append({"id": sid, "status": status, "exercises": cites,
1353
1440
  "cites_csv": ", ".join(cites),
1441
+ "constraints": bounds,
1442
+ "constraints_csv": ", ".join(bounds),
1443
+ "carried": sorted(set(cites) | set(bounds)),
1354
1444
  "title": (fm.get("title") or "").strip().strip("[]"),
1355
1445
  "spec_version": (fm.get("spec_version") or "").strip(),
1356
1446
  "type": (fm.get("type") or "functional").strip().lower(),
@@ -1358,17 +1448,25 @@ if os.path.isdir(sdir):
1358
1448
  "in_flight": os.path.isdir(os.path.join(sdir, sid))})
1359
1449
 
1360
1450
  open_stories = [s for s in stories if s["status"] == "open"]
1451
+ # TWO SETS, DELIBERATELY DIFFERENT. `covered` is the DELIVERY relation — what open stories
1452
+ # are delivering — and analyze-slice subtracts it from a feature-scoped run as "these have
1453
+ # open stories" (law 39). A constraint is met, never delivered, so folding one in here would
1454
+ # drop a bound the code must still satisfy out of the feature gate's scope.
1455
+ # `carried` is the wider question specify has to ask: what would an open story FEEL if this
1456
+ # ran moved it. That one has to include the bounds, or amending a CN-/DC-/IC-/UX- disturbs
1457
+ # stories built against it and nothing anywhere says so (law 15).
1361
1458
  covered = sorted({c for s in open_stories for c in s["exercises"]})
1459
+ carried = sorted({c for s in open_stories for c in s["carried"]})
1362
1460
 
1363
1461
  # ASKING ABOUT NOTHING IS NOT ASKING ABOUT EVERYTHING. `--ids ""` is a caller saying "this
1364
1462
  # run disturbed no requirement" — a pure addition — and the answer is an empty set. Only
1365
1463
  # the absence of the flag means "tell me about the whole backlog". Conflating the two made
1366
1464
  # an enhancement that touched nothing existing prompt about every requirement with a story.
1367
- targets = queried if os.environ.get("AW_IDS_GIVEN") else covered
1465
+ targets = queried if os.environ.get("AW_IDS_GIVEN") else carried
1368
1466
 
1369
1467
  affected, aff_ids, aff_stories, inflight = [], [], [], []
1370
1468
  for rid in targets:
1371
- hit = [s for s in open_stories if rid in s["exercises"]]
1469
+ hit = [s for s in open_stories if rid in s["carried"]]
1372
1470
  if not hit: continue
1373
1471
  aff_ids.append(rid)
1374
1472
  names = [s["id"] for s in hit]
@@ -1386,9 +1484,10 @@ unlinked = [rid for rid in targets if rid not in aff_ids]
1386
1484
  # is not something an author can answer about: what it cites and whether someone is already
1387
1485
  # implementing it are the whole basis of the decision.
1388
1486
  digest = "\n".join(
1389
- " %-16s %-9s %-11s cites %s%s" % (
1487
+ " %-16s %-9s %-11s cites %s%s%s" % (
1390
1488
  s["id"], s["status"], "IN FLIGHT" if s["in_flight"] else "not started",
1391
1489
  s["cites_csv"] or "nothing",
1490
+ (" bound by " + s["constraints_csv"]) if s["constraints_csv"] else "",
1392
1491
  (" @ " + s["spec_version"]) if s["spec_version"] else "")
1393
1492
  for s in open_stories) or " (no open stories)"
1394
1493
 
@@ -1397,6 +1496,8 @@ print(json.dumps({
1397
1496
  "story_count": len(stories), "open_count": len(open_stories),
1398
1497
  "stories": stories, "digest": digest,
1399
1498
  "queried": queried, "covered": covered, "covered_csv": ", ".join(covered),
1499
+ "carried": carried, "carried_csv": ", ".join(carried),
1500
+ "carried_count": len(carried), "any_carried": bool(carried),
1400
1501
  "affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
1401
1502
  "unlinked": unlinked, "unlinked_csv": ", ".join(unlinked),
1402
1503
  "unlinked_count": len(unlinked), "any_unlinked": bool(unlinked),
@@ -1480,6 +1581,18 @@ def rids(s):
1480
1581
  if t not in seen: seen.add(t); out.append(t)
1481
1582
  return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1482
1583
 
1584
+ def nids(s):
1585
+ """The classes a row can name that a STORY cannot be raised for: the bounds a slice is
1586
+ built to satisfy and the outcomes the feature gate scores. Kept apart from rids()
1587
+ rather than widening it, because the work set drives story creation and a story for a
1588
+ success criterion is not a thing — but a row that moved one of these still has to be
1589
+ said out loud, and until now it produced an empty work set and a run that stopped with
1590
+ 'the backlog is already in step'."""
1591
+ seen, out = set(), []
1592
+ for t in re.findall(r'\b(?:CN|DC|IC|UX|SC)-\d+', s or ""):
1593
+ if t not in seen: seen.add(t); out.append(t)
1594
+ return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1595
+
1483
1596
  def parse_fm(text):
1484
1597
  """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1485
1598
  a YAML block list under an empty key — both are valid YAML and a hand edit produces
@@ -1523,7 +1636,8 @@ for i in row_lines:
1523
1636
  # note somebody left in the table. It records no version, so it orders against nothing.
1524
1637
  if not re.match(r'^v\d', c[0]): continue
1525
1638
  rows.append({"version": c[0], "date": c[1], "change": c[2], "ids_cell": c[3],
1526
- "stories": c[4], "requirements": rids(c[3])})
1639
+ "stories": c[4], "requirements": rids(c[3]),
1640
+ "non_story": nids(c[3])})
1527
1641
  rows.sort(key=lambda r: vtuple(r["version"]))
1528
1642
 
1529
1643
  # WHAT THE DOCUMENT STILL DECLARES. Read from the requirement's DEFINING line — the bolded
@@ -1595,6 +1709,8 @@ if not rows:
1595
1709
  # backlog against a document nobody has committed to.
1596
1710
  out.update(work_csv="", work_count=0, work_source="none", span_versions_csv="",
1597
1711
  gap=False, removed_csv="",
1712
+ non_story_csv="", non_story_count=0, non_story_any=False,
1713
+ work_kind="none",
1598
1714
  work_note="the Change History table carries no version row")
1599
1715
  print(json.dumps(out)); sys.exit(0)
1600
1716
 
@@ -1674,8 +1790,25 @@ for rid in candidates:
1674
1790
  removed = [i for i in work if i not in declared]
1675
1791
  work = [i for i in work if i in declared]
1676
1792
 
1793
+ # WHAT MOVED THAT NO STORY CAN BE RAISED FOR. Constraints and success criteria travel in
1794
+ # the same version rows as requirements and are read by the same downstream gates, but they
1795
+ # produce no work set — so a row naming only those left this run stopping at "the backlog is
1796
+ # already in step. Nothing was written." about the exact ids story-plan-check had just
1797
+ # reported as stale. Carried out separately so the caller can say so without the work gate
1798
+ # having to admit ids it must not slice.
1799
+ #
1800
+ # work_kind IS FOR THE LOG, NOT FOR THE BRANCH. A row naming FR-001 AND CN-001 is
1801
+ # `story` — the work set is real — and branching on the enum alone would print the
1802
+ # requirement path, sync the stories, and say nothing at all about the constraint. The
1803
+ # caller announces on non_story_csv being non-empty, whatever work_any says.
1804
+ non_story = sorted({i for r in span for i in r["non_story"]},
1805
+ key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1806
+ work_kind = ("story" if work else ("non_story" if non_story else "none"))
1807
+
1677
1808
  out.update(work_csv=", ".join(work), work_count=len(work), work_source=source,
1678
1809
  work_note=note, work_any=bool(work),
1810
+ non_story_csv=", ".join(non_story), non_story_count=len(non_story),
1811
+ non_story_any=bool(non_story), work_kind=work_kind,
1679
1812
  candidates_csv=", ".join(candidates), candidate_count=len(candidates),
1680
1813
  in_step_csv=", ".join(in_step), in_step_count=len(in_step),
1681
1814
  floor_source=floor_source,
@@ -2337,6 +2470,47 @@ def sids(s):
2337
2470
  seen.add(t); out.append(t)
2338
2471
  return out
2339
2472
 
2473
+ def cids(s):
2474
+ """Constraint ids, deduped and ordered by class then number so two readers of the same
2475
+ text always produce the same csv. SC- is NOT one of these: a success criterion is a
2476
+ feature-level outcome analyze-slice scores, not a bound a slice is built to satisfy,
2477
+ and putting one here would hand it to the generation prompt as a bound."""
2478
+ seen, out = set(), []
2479
+ for t in re.findall(r'\b(?:CN|DC|IC|UX)-\d+', s or ""):
2480
+ if t not in seen: seen.add(t); out.append(t)
2481
+ return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2482
+
2483
+ def lanes(s):
2484
+ """backend / frontend out of a csv or an inline list. Anything else is not a lane."""
2485
+ out = []
2486
+ for t in re.split(r'[,\s\[\]"\']+', (s or "").lower()):
2487
+ if t in ("backend", "frontend") and t not in out: out.append(t)
2488
+ return sorted(out)
2489
+
2490
+ COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
2491
+ # An id keyed to the line that DEFINES it, in the two shapes the spec template uses:
2492
+ # a leading table cell (Data Classification) and a bold id opening a bullet (everything
2493
+ # else). The same two shapes spec-diff scores, for the same reason — a cross-reference
2494
+ # inside another requirement's prose defines nothing.
2495
+ DEF_CELL = re.compile(r'^\s*\|\s*((?:CN|DC|IC|UX)-\d+)\s*\|')
2496
+ DEF_BULL = re.compile(r'^\s*(?:[-*+]\s+)?\*\*((?:CN|DC|IC|UX)-\d+)\*\*')
2497
+
2498
+ def spec_constraints():
2499
+ """Every constraint id the spec DEFINES. Spec constraints are declared aggregate-wide —
2500
+ "constraints that must ALWAYS hold for the aggregate, independent of any single
2501
+ operation" — so there is no per-requirement relation to infer and no judgment to make:
2502
+ the feature's whole constraint set binds every slice of it. Change History is cut away
2503
+ first (its rows NAME ids without defining them), and so is the coverage block."""
2504
+ sp = os.path.join(fd, "spec.md")
2505
+ if not os.path.isfile(sp): return []
2506
+ t = COV.sub("", open(sp, encoding="utf-8", errors="replace").read())
2507
+ t = re.split(r'^##\s+Change History\s*$', t, maxsplit=1, flags=re.M)[0]
2508
+ found = []
2509
+ for line in t.split("\n"):
2510
+ m = DEF_CELL.match(line) or DEF_BULL.match(line)
2511
+ if m and m.group(1) not in found: found.append(m.group(1))
2512
+ return sorted(found, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2513
+
2340
2514
  text = open(path, encoding="utf-8", errors="replace").read()
2341
2515
  m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
2342
2516
  if m:
@@ -2370,8 +2544,9 @@ def put(key, value):
2370
2544
  order.append([key, [line]])
2371
2545
  return "added"
2372
2546
 
2373
- TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "spec_version",
2374
- "depends_on", "wave", "follows", "work_item", "created"]
2547
+ TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "constraints",
2548
+ "spec_version", "depends_on", "wave", "follows",
2549
+ "designed_lanes", "dropped_lanes", "work_item", "created"]
2375
2550
  def get(key):
2376
2551
  for entry in order:
2377
2552
  if entry[0] == key: return entry[1][0].split(":", 1)[1].strip()
@@ -2386,6 +2561,17 @@ if os.environ.get("AW_IDS_GIVEN"):
2386
2561
  else:
2387
2562
  ids = rids(get("exercises"))
2388
2563
 
2564
+ # THE BOUNDS THIS SLICE MUST MEET, AS A FRONTMATTER FIELD THE KIT WRITES. Everything a gate
2565
+ # reads lives in frontmatter; the story's `## Constraints` section was the one exception,
2566
+ # and being model-authored it could omit an id with nothing able to notice — a missing bound
2567
+ # in a generation prompt that never opens the spec. Lifted here, so the gate surface is
2568
+ # complete by construction and the section keeps its per-slice prose.
2569
+ if os.environ.get("AW_CONSTRAINTS_GIVEN"):
2570
+ cons = spec_constraints()
2571
+ changed["constraints"] = put("constraints", "[" + ", ".join(cons) + "]")
2572
+ else:
2573
+ cons = cids(get("constraints"))
2574
+
2389
2575
  ver = (os.environ.get("AW_VER") or "").strip()
2390
2576
  if ver: changed["spec_version"] = put("spec_version", ver)
2391
2577
 
@@ -2420,6 +2606,22 @@ if wv.isdigit():
2420
2606
  changed["wave"] = put("wave", wv)
2421
2607
  elif not get("wave").isdigit():
2422
2608
  changed["wave"] = put("wave", "1")
2609
+
2610
+ # WHICH LANES WERE DESIGNED, AND WHICH WERE DROPPED — two fields, because one of them cannot
2611
+ # say the difference. analyze-design STOPS on a lane whose plan is behind; story-plan DROPS
2612
+ # it and designs the other, so one command later "this slice has no user-facing surface" and
2613
+ # "the UI lane was out of scope when this was designed" are the same state on disk, and a
2614
+ # UX--bearing requirement can ship backend-only with a green story-scoped gate.
2615
+ # Designed ACCUMULATES across passes (a lane that has a design keeps having one); dropped is
2616
+ # whatever has been dropped and not since designed, so the two never both claim a lane.
2617
+ if os.environ.get("AW_DESIGNED_GIVEN") or os.environ.get("AW_DROPPED_GIVEN"):
2618
+ designed = sorted(set(lanes(get("designed_lanes")))
2619
+ | set(lanes(os.environ.get("AW_DESIGNED", ""))))
2620
+ dropped = sorted((set(lanes(get("dropped_lanes")))
2621
+ | set(lanes(os.environ.get("AW_DROPPED", "")))) - set(designed))
2622
+ changed["designed_lanes"] = put("designed_lanes", "[" + ", ".join(designed) + "]")
2623
+ changed["dropped_lanes"] = put("dropped_lanes", "[" + ", ".join(dropped) + "]")
2624
+
2423
2625
  if not get("created") or get("created").startswith("["):
2424
2626
  changed["created"] = put("created", datetime.date.today().isoformat())
2425
2627
  for k in ("title", "follows", "work_item"):
@@ -2432,8 +2634,26 @@ order.sort(key=lambda e: rank.get(e[0], len(TEMPLATE_ORDER)))
2432
2634
  out_block = "\n".join(l for e in order for l in e[1])
2433
2635
  open(path, "w", encoding="utf-8").write("---\n" + out_block + "\n---\n\n" + body.lstrip("\n"))
2434
2636
 
2637
+ # THE STAMP OWNS THE ID SET; THE SECTION KEEPS ITS PROSE. `## Constraints` is not a list of
2638
+ # ids — each line says what THIS slice has to do about the bound it names, and that line is
2639
+ # the whole reason the section is worth reading. So the body is RECONCILED, never rewritten:
2640
+ # an id now in the frontmatter with no line in the section is reported for the caller to
2641
+ # announce, and a line naming an id the spec no longer defines is reported the same way.
2642
+ # Neither is silently written or silently deleted.
2643
+ bm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', body, re.M | re.S)
2644
+ body_ids = set(cids(bm.group(1))) if bm else set()
2645
+ unlined = [c for c in cons if c not in body_ids]
2646
+ orphan_lines = sorted(body_ids - set(cons),
2647
+ key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2648
+
2435
2649
  print(json.dumps({"ok": True, "story": story, "path": path,
2436
2650
  "exercises": ids, "exercises_csv": ", ".join(ids),
2651
+ "constraints": cons, "constraints_csv": ", ".join(cons),
2652
+ "unlined_csv": ", ".join(unlined), "unlined_any": bool(unlined),
2653
+ "orphan_lines_csv": ", ".join(orphan_lines),
2654
+ "constraints_reconciled": not (unlined or orphan_lines),
2655
+ "designed_lanes": get("designed_lanes"),
2656
+ "dropped_lanes": get("dropped_lanes"),
2437
2657
  "spec_version": get("spec_version"), "status": get("status"),
2438
2658
  "depends_on": get("depends_on"), "wave": get("wave"),
2439
2659
  "follows": get("follows"), "title": get("title"),
@@ -2444,7 +2664,8 @@ print(json.dumps({"ok": True, "story": story, "path": path,
2444
2664
  function Invoke-StoryStamp {
2445
2665
  param([string[]]$Rest)
2446
2666
  $fdArg = ''; $story = ''; $ids = ''; $version = ''; $follows = ''; $idsGiven = ''; $title = ''
2447
- $deps = ''; $wave = ''
2667
+ $deps = ''; $wave = ''; $consGiven = ''
2668
+ $designed = ''; $designedGiven = ''; $dropped = ''; $droppedGiven = ''
2448
2669
  for ($i = 0; $i -lt $Rest.Count; $i++) {
2449
2670
  switch ($Rest[$i]) {
2450
2671
  '--feature-dir' { $fdArg = $Rest[++$i] }
@@ -2455,6 +2676,13 @@ function Invoke-StoryStamp {
2455
2676
  '--title' { $title = $Rest[++$i] }
2456
2677
  '--depends-on' { $deps = $Rest[++$i] }
2457
2678
  '--wave' { $wave = $Rest[++$i] }
2679
+ # A BARE FLAG, NOT A CSV: the ids are lifted from the spec this op already has
2680
+ # a --feature-dir for. Nothing upstream has to enumerate them, and the second
2681
+ # caller (story-plan) can write the lane keys without re-lifting constraints
2682
+ # from a spec that may have moved past this story's spec_version.
2683
+ '--constraints' { $consGiven = '1' }
2684
+ '--designed-lanes' { $designed = $Rest[++$i]; $designedGiven = '1' }
2685
+ '--dropped-lanes' { $dropped = $Rest[++$i]; $droppedGiven = '1' }
2458
2686
  }
2459
2687
  }
2460
2688
  if (-not $story) { Write-Error 'story-stamp: --story is required'; exit 1 }
@@ -2466,7 +2694,11 @@ function Invoke-StoryStamp {
2466
2694
  $env:AW_IDS_GIVEN = $idsGiven; $env:AW_VER = $version
2467
2695
  $env:AW_FOLLOWS = $follows; $env:AW_TITLE = $title
2468
2696
  $env:AW_DEPS = $deps; $env:AW_WAVE = $wave
2697
+ $env:AW_CONSTRAINTS_GIVEN = $consGiven
2698
+ $env:AW_DESIGNED = $designed; $env:AW_DESIGNED_GIVEN = $designedGiven
2699
+ $env:AW_DROPPED = $dropped; $env:AW_DROPPED_GIVEN = $droppedGiven
2469
2700
  $PyStoryStamp | & $py -
2701
+ Remove-Item Env:AW_CONSTRAINTS_GIVEN, Env:AW_DESIGNED, Env:AW_DESIGNED_GIVEN, Env:AW_DROPPED, Env:AW_DROPPED_GIVEN -ErrorAction SilentlyContinue
2470
2702
  }
2471
2703
 
2472
2704
  $PySpecCoverage = @'
@@ -2855,12 +3087,37 @@ function Get-ProjectMarkers {
2855
3087
  return $out
2856
3088
  }
2857
3089
 
3090
+ # Does a manifest belong to the ecosystem the caller asked about. The repo-scope fallback
3091
+ # below is deliberate — a .NET solution legitimately sits ABOVE the lane it scaffolds — but
3092
+ # it answers with whatever manifest it finds, so in a two-lane repo where only the frontend
3093
+ # is scaffolded the service lane's gate passes on the frontend's package.json. Knowing the
3094
+ # lane's ecosystem is what turns that hit back into a miss.
3095
+ function Get-MarkerEcosystem {
3096
+ param([string]$Marker)
3097
+ switch -Wildcard (Split-Path -Leaf $Marker) {
3098
+ '*.sln' { return 'dotnet' }
3099
+ '*.csproj' { return 'dotnet' }
3100
+ 'package.json' { return 'node' }
3101
+ 'pom.xml' { return 'java' }
3102
+ 'build.gradle' { return 'java' }
3103
+ 'build.gradle.kts' { return 'java' }
3104
+ 'go.mod' { return 'go' }
3105
+ 'Cargo.toml' { return 'rust' }
3106
+ 'pyproject.toml' { return 'python' }
3107
+ default { return '' }
3108
+ }
3109
+ }
3110
+
2858
3111
  function Invoke-DetectProject {
2859
3112
  param([string[]]$Rest)
2860
- $scopeRoot = ''
3113
+ $scopeRoot = ''; $ecosystem = ''
2861
3114
  for ($i = 0; $i -lt $Rest.Count; $i++) {
2862
3115
  switch ($Rest[$i]) {
2863
3116
  { $_ -in '--root','--source-path','-Root' } { $scopeRoot = $Rest[++$i] }
3117
+ # The lane's ecosystem, passed in by the caller that already resolved it.
3118
+ # NOT re-derived here: lane-gate binds it from stacks.json before this op runs,
3119
+ # and a second guess is a second thing to keep in step with that file.
3120
+ { $_ -in '--ecosystem','-Ecosystem' } { $ecosystem = $Rest[++$i] }
2864
3121
  }
2865
3122
  }
2866
3123
  $repo = Get-RepoRoot
@@ -2877,7 +3134,18 @@ function Invoke-DetectProject {
2877
3134
  $scope = 'repo'
2878
3135
  $markers = @(Get-ProjectMarkers -Repo $repo -Start $repo)
2879
3136
  }
2880
- Write-Json ([ordered]@{ scaffolded = ($markers.Count -gt 0); markers = @($markers); scope = $scope })
3137
+ $scaffolded = ($markers.Count -gt 0)
3138
+ # UNASKED IS NOT UNMATCHED. With no --ecosystem the caller has told us nothing to judge
3139
+ # against, and answering false there would fail every existing call site.
3140
+ $match = $true
3141
+ if ($ecosystem -and $scaffolded) {
3142
+ $match = $false
3143
+ foreach ($m in $markers) {
3144
+ if ((Get-MarkerEcosystem -Marker $m) -eq $ecosystem) { $match = $true; break }
3145
+ }
3146
+ }
3147
+ Write-Json ([ordered]@{ scaffolded = $scaffolded; markers = @($markers); scope = $scope
3148
+ ecosystem = $ecosystem; ecosystem_match = $match })
2881
3149
  }
2882
3150
 
2883
3151
 
@@ -3894,7 +4162,8 @@ def vtuple(v):
3894
4162
  try: return tuple(int(x) for x in v.lstrip("vV").split("."))
3895
4163
  except Exception: return (0,)
3896
4164
  out = {"doc": doc, "doc_exists": os.path.isfile(doc), "state": "", "derived_version": "",
3897
- "current_version": "", "changed_csv": "", "changed_summary": "", "body_changed": False}
4165
+ "current_version": "", "changed_csv": "", "changed_summary": "",
4166
+ "body_changed": False, "body_sha256": ""}
3898
4167
  # Current version = the widest of the ledger's rows and the **Version** header — the
3899
4168
  # same widest-wins rule ledger-append uses, so a hand-moved header cannot hide a row.
3900
4169
  rows = []; cur = ""; idcol = -1
@@ -3914,6 +4183,13 @@ if os.path.isfile(spec):
3914
4183
  out["current_version"] = cur
3915
4184
  if not out["doc_exists"]:
3916
4185
  out["state"] = "fresh"; print(json.dumps(out)); sys.exit(0)
4186
+ # THE HASH ITSELF, not only the boolean it was reduced to. "Has this document moved since it
4187
+ # was stamped" answers the freshness question; WHICH shape it is now is what a gate verdict
4188
+ # has to record and a story pass has to name, or approved-then-changed-then-never-re-approved
4189
+ # has nothing to compare against. Taken before the untracked exit so a document with no
4190
+ # derivation record still reports what it is.
4191
+ body = open(doc, encoding="utf-8", errors="replace").read()
4192
+ out["body_sha256"] = hashlib.sha256(body.encode("utf-8")).hexdigest()
3917
4193
  try: track = json.load(open(track_path, encoding="utf-8"))
3918
4194
  except Exception: track = {}
3919
4195
  entry = track.get(os.path.basename(doc))
@@ -3924,8 +4200,7 @@ if not entry:
3924
4200
  out["state"] = "untracked"; print(json.dumps(out)); sys.exit(0)
3925
4201
  derived = entry.get("spec_version", "")
3926
4202
  out["derived_version"] = derived
3927
- body = open(doc, encoding="utf-8", errors="replace").read()
3928
- out["body_changed"] = hashlib.sha256(body.encode("utf-8")).hexdigest() != entry.get("sha256", "")
4203
+ out["body_changed"] = out["body_sha256"] != entry.get("sha256", "")
3929
4204
  moved = [r for r in rows if vtuple(r[0]) > vtuple(derived)]
3930
4205
  # Scrape ONLY the ledger's "Requirements changed" cell when it names one. The
3931
4206
  # Stories cell holds ids of the form <Aggregate>-nnn, which read as requirement ids
@@ -4117,6 +4392,77 @@ function Invoke-GateResult {
4117
4392
  Remove-Item Env:AW_GATE, Env:AW_FD, Env:AW_OK, Env:AW_VERSION, Env:AW_STORY, Env:AW_REPORT, Env:AW_OUT, Env:AW_GAPS, Env:AW_REFS, Env:AW_NOTE, Env:AW_FIELDS -ErrorAction SilentlyContinue
4118
4393
  }
4119
4394
 
4395
+ # ──────────────────────────────── gate-read ────────────────────────────────
4396
+ # THE OTHER HALF OF gate-result. Twin of the bash action; the python payload is
4397
+ # byte-identical by contract.
4398
+ $PyGateRead = @'
4399
+ import json, os
4400
+ gate = (os.environ.get("AW_GATE", "") or "").strip()
4401
+ fd = os.environ.get("AW_FD", "")
4402
+ report = (os.environ.get("AW_REPORT", "") or "").strip()
4403
+ path = (os.environ.get("AW_FILE", "") or "").strip()
4404
+ # Resolved the same way gate-result names it, so the writer and the reader cannot drift:
4405
+ # beside the markdown report and named after it. The per-gate defaults are the report paths
4406
+ # those two workflows set; the dotfile is gate-result's own fallback.
4407
+ DEFAULT = {"analyze-design": "plan-analyze-report.json",
4408
+ "analyze-slice": "slice-analyze-report.json"}
4409
+ if not path:
4410
+ if report: path = os.path.splitext(report)[0] + ".json"
4411
+ elif gate in DEFAULT: path = os.path.join(fd, DEFAULT[gate])
4412
+ else: path = os.path.join(fd, ".aw-gate-%s.json" % gate)
4413
+
4414
+ out = {"ok": True, "gate": gate, "result_file": path, "exists": os.path.isfile(path),
4415
+ "status": "", "certified": False, "aborted": False, "note": "",
4416
+ "spec_version": "", "story": "", "report": "", "generated_at": "",
4417
+ "gap_count": 0, "gap_refs_csv": "",
4418
+ "backend_sha256": "", "frontend_sha256": "",
4419
+ "backend_in_scope": "", "frontend_in_scope": "", "fields": {}}
4420
+ res = {}
4421
+ if out["exists"]:
4422
+ try: res = json.load(open(path, encoding="utf-8"))
4423
+ except Exception: res = {}
4424
+ if not isinstance(res, dict) or not res:
4425
+ out["note"] = ("no readable verdict at %s — this gate has not certified anything here"
4426
+ % path)
4427
+ print(json.dumps(out)); raise SystemExit(0)
4428
+
4429
+ fields = res.get("fields") or {}
4430
+ if not isinstance(fields, dict): fields = {}
4431
+ out.update(status=(res.get("status") or "").strip(),
4432
+ note=(res.get("note") or "").strip(),
4433
+ spec_version=(res.get("spec_version") or "").strip(),
4434
+ story=(res.get("story") or "").strip(),
4435
+ report=(res.get("report") or "").strip(),
4436
+ generated_at=(res.get("generated_at") or "").strip(),
4437
+ gap_count=res.get("gap_count") or 0,
4438
+ gap_refs_csv=", ".join(res.get("gap_refs") or []),
4439
+ fields=fields,
4440
+ backend_sha256=(fields.get("backend_sha256") or "").strip(),
4441
+ frontend_sha256=(fields.get("frontend_sha256") or "").strip(),
4442
+ backend_in_scope=(fields.get("backend_in_scope") or "").strip(),
4443
+ frontend_in_scope=(fields.get("frontend_in_scope") or "").strip())
4444
+ # A verdict written before any analysis ran carries no spec version, because the workflow
4445
+ # has not read one yet. That, and not the note's wording, is what separates the aborted red
4446
+ # from a real one — the note is prose and prose gets edited.
4447
+ out["aborted"] = out["status"] != "pass" and not out["spec_version"]
4448
+ out["certified"] = out["status"] == "pass"
4449
+ print(json.dumps(out))
4450
+ '@
4451
+
4452
+ function Invoke-GateRead {
4453
+ param([string[]]$Rest)
4454
+ $fdArg = ''; $gate = ''; $report = ''; $file = ''
4455
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--gate','-Gate' } { $gate = $Rest[++$i] } { $_ -in '--report','-Report' } { $report = $Rest[++$i] } { $_ -in '--file','-File' } { $file = $Rest[++$i] } } }
4456
+ if (-not $gate) { Write-Error 'gate-read: --gate required'; exit 1 }
4457
+ $py = Get-Py; if (-not $py) { Write-Error 'gate-read: python3 required'; exit 1 }
4458
+ $repo = Get-RepoRoot
4459
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4460
+ if (-not $fd) { Write-Error 'gate-read: no feature set and no --feature-dir'; exit 1 }
4461
+ $env:AW_GATE = $gate; $env:AW_FD = $fd; $env:AW_REPORT = $report; $env:AW_FILE = $file
4462
+ $PyGateRead | & $py -
4463
+ Remove-Item Env:AW_GATE, Env:AW_FD, Env:AW_REPORT, Env:AW_FILE -ErrorAction SilentlyContinue
4464
+ }
4465
+
4120
4466
  # ─────────────── story-plan-check / story-plan-doc / log-append ───────────────
4121
4467
  # The per-story execution design ("story design", one document per lane) + the
4122
4468
  # feature's append-only logs. Twins of the bash actions; the python payloads are
@@ -4164,7 +4510,18 @@ if os.path.isdir(sdir):
4164
4510
  "title": (fm.get("title") or "").strip().strip("[]"),
4165
4511
  "spec_version": (fm.get("spec_version") or "").strip(),
4166
4512
  "cites": re.findall(r'\b(?:FR|BR)-\d+', fm.get("exercises", "")),
4167
- "path": p, "text": text})
4513
+ "wave": (fm.get("wave") or "").strip(),
4514
+ "deps": [d for d in re.split(r'[,\s\[\]"\']+',
4515
+ fm.get("depends_on", "") or "")
4516
+ if re.match(r'^[A-Za-z][\w.]*-\d+$', d)],
4517
+ "fm": fm, "path": p, "text": text})
4518
+
4519
+ def lanes(s):
4520
+ """backend / frontend out of a csv or an inline list; anything else is not a lane."""
4521
+ out = []
4522
+ for tok in re.split(r'[,\s\[\]"\']+', (s or "").lower()):
4523
+ if tok in ("backend", "frontend") and tok not in out: out.append(tok)
4524
+ return sorted(out)
4168
4525
  open_stories = [s for s in stories if s["status"] == "open"]
4169
4526
  open_ids = [s["id"] for s in open_stories]
4170
4527
  digest = "\n".join(" %-16s %s%s" % (s["id"], s["title"] or "(untitled)",
@@ -4195,11 +4552,18 @@ out = {"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
4195
4552
  "story_type": "", "is_technical": False,
4196
4553
  "status_msg": "", "story_title": "", "exercises_csv": "", "constraints_csv": "",
4197
4554
  "story_spec_version": "", "current_version": "", "stale_csv": "", "stale_note": "",
4555
+ "story_wave": "", "depends_on_csv": "", "open_deps_csv": "",
4556
+ "unresolved_deps_csv": "", "deps_note": "", "deps_clear": True,
4557
+ "designed_lanes_csv": "", "dropped_lanes_csv": "", "lane_keys_present": False,
4558
+ "be_lane_designed": False, "fe_lane_designed": False,
4559
+ "be_lane_dropped": False, "fe_lane_dropped": False,
4198
4560
  "story_dir": "", "if_tmp": "", "legacy_doc": "", "legacy_doc_exists": False,
4199
4561
  "be_doc_path": "", "be_doc_exists": False, "be_next_pass": 1, "be_pass_tmp": "",
4200
4562
  "be_last_pass": 0, "be_doc_spec_version": "", "be_doc_stale": False,
4563
+ "be_pass_plan_hash": "",
4201
4564
  "fe_doc_path": "", "fe_doc_exists": False, "fe_next_pass": 1, "fe_pass_tmp": "",
4202
4565
  "fe_last_pass": 0, "fe_doc_spec_version": "", "fe_doc_stale": False,
4566
+ "fe_pass_plan_hash": "",
4203
4567
  "adr_path": os.path.join(fd, "adr.md"), "adr_exists": os.path.isfile(os.path.join(fd, "adr.md")),
4204
4568
  "if_path": os.path.join(fd, "interfaces.md"), "if_exists": os.path.isfile(os.path.join(fd, "interfaces.md")),
4205
4569
  "spec_exists": os.path.isfile(spec)}
@@ -4237,12 +4601,66 @@ if status != "open":
4237
4601
  out["status_msg"] = "Story %s %s." % (
4238
4602
  target["id"], verb.get(status, "has status '%s', not open" % status))
4239
4603
 
4240
- # bound constraint ids come from the story BODY's ## Constraints section (they are
4241
- # barred from exercises: by design — met, not delivered)
4242
- cm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', target["text"], re.M | re.S)
4243
- cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', cm.group(1)))) if cm else []
4604
+ # THE BOUNDS COME FROM FRONTMATTER, which story-stamp lifts from the spec. They are barred
4605
+ # from exercises: by design — a constraint is met, not delivered — and they were read out of
4606
+ # the BODY's ## Constraints section until that section turned out to be the one gate surface
4607
+ # the kit did not write: a model that omitted an id dropped a bound out of the generation
4608
+ # prompt with nothing able to contradict it.
4609
+ #
4610
+ # ABSENT IS NOT EMPTY. A story stamped before the field existed carries its ids only in the
4611
+ # body, so a missing key falls back to the section; an explicit `constraints: []` is an
4612
+ # answer and is taken as one. The fallback goes once every live backlog is re-stamped.
4613
+ if "constraints" in target["fm"]:
4614
+ cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', target["fm"]["constraints"])))
4615
+ else:
4616
+ cm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', target["text"], re.M | re.S)
4617
+ cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', cm.group(1)))) if cm else []
4244
4618
  out["constraints_csv"] = ", ".join(cons)
4245
4619
 
4620
+ # WHAT HAS TO LAND FIRST. sync-stories computes depends_on: and wave: and stamps both, and
4621
+ # until now nothing downstream read either — the ordering was recorded and never enforced.
4622
+ # Two different misses, kept apart: a dependency whose story is still OPEN here is work in
4623
+ # progress, and one with no story file in this feature is UNRESOLVED, which is the ordinary
4624
+ # shape of a cross-feature edge (Payment-004 from Order) and not the same as satisfied.
4625
+ #
4626
+ # The wave is surfaced and NOT gated. A story becomes startable when its own dependencies
4627
+ # land, not when the whole previous wave finishes, so warning on "an open story in a lower
4628
+ # wave" would serialise a wave that exists precisely to be unordered.
4629
+ # WHICH LANES THIS STORY WAS DESIGNED FOR, AND WHICH WERE DROPPED. story-plan stamps both,
4630
+ # because one of them cannot say the difference: a story with no UI design either has no
4631
+ # user-facing surface or had its UI lane dropped when it was designed, and those two states
4632
+ # are the same absence on disk. `lane_keys_present` is what keeps the distinction honest for
4633
+ # a story stamped before the fields existed — it has NEITHER key, and absent must never be
4634
+ # read as "no surface".
4635
+ des = lanes(target["fm"].get("designed_lanes", ""))
4636
+ drp = lanes(target["fm"].get("dropped_lanes", ""))
4637
+ out.update(designed_lanes_csv=", ".join(des), dropped_lanes_csv=", ".join(drp),
4638
+ lane_keys_present=("designed_lanes" in target["fm"]
4639
+ or "dropped_lanes" in target["fm"]),
4640
+ be_lane_designed=("backend" in des), fe_lane_designed=("frontend" in des),
4641
+ be_lane_dropped=("backend" in drp), fe_lane_dropped=("frontend" in drp))
4642
+
4643
+ deps = target["deps"]
4644
+ by_id = {s["id"]: s for s in stories}
4645
+ open_deps = [d for d in deps if d in by_id and by_id[d]["status"] == "open"]
4646
+ unresolved = [d for d in deps if d not in by_id]
4647
+ out.update(story_wave=target["wave"], depends_on_csv=", ".join(deps),
4648
+ open_deps_csv=", ".join(open_deps),
4649
+ unresolved_deps_csv=", ".join(unresolved),
4650
+ deps_clear=not (open_deps or unresolved))
4651
+ if open_deps or unresolved:
4652
+ parts = []
4653
+ if open_deps:
4654
+ parts.append("still open in this feature: %s" % ", ".join(open_deps))
4655
+ if unresolved:
4656
+ parts.append("no story file here — check the feature that owns them: %s"
4657
+ % ", ".join(unresolved))
4658
+ out["deps_note"] = (
4659
+ "ORDERING: %s declares depends_on: %s, and %s. This is a notice, not a refusal — "
4660
+ "the dependency is about CODE ordering, and calling into work that has not landed "
4661
+ "is where it starts to cost something."
4662
+ % (target["id"], ", ".join(deps), "; ".join(parts)))
4663
+
4246
4664
  # staleness: ledger rows ABOVE the story's stamp naming an id the story carries —
4247
4665
  # exercises: AND the Constraints section, so a moved bound is announced too
4248
4666
  sv = target["spec_version"]
@@ -4290,6 +4708,15 @@ for lane, stem in (("be", "backend"), ("fe", "frontend")):
4290
4708
  dv = (parse_fm(d).get("spec_version") or "").strip().strip('"')
4291
4709
  out[lane + "_doc_spec_version"] = dv
4292
4710
  out[lane + "_doc_stale"] = bool(dv and cur and vtuple(dv) < vtuple(cur))
4711
+ # THE FEATURE PLAN THIS PASS WAS DESIGNED AGAINST, as analyze-design certified it.
4712
+ # A story pass is written once and read forever, so comparing only the CURRENT plan
4713
+ # against the CURRENT verdict leaves the case law 40 is actually about: a pass
4714
+ # designed against plan H1 still generating after the plan was edited to H2 and
4715
+ # re-gated. The hash is script-written into the pass header by story-plan-doc; the
4716
+ # LAST pass is the one implement builds from, so it is the last one read here.
4717
+ hs = re.findall(r'^##\s+Design pass\s+\d+\b[^\n]*?\(plan\s+([0-9a-f]{8,64})\)',
4718
+ d, re.M)
4719
+ out[lane + "_pass_plan_hash"] = hs[-1] if hs else ""
4293
4720
  print(json.dumps(out))
4294
4721
  '@
4295
4722
 
@@ -4298,6 +4725,7 @@ import json, os, re, sys, datetime
4298
4725
  fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
4299
4726
  pf = os.environ["AW_PASS"]; ver = (os.environ.get("AW_VER") or "").strip()
4300
4727
  lane = (os.environ.get("AW_LANE") or "backend").strip().lower()
4728
+ phash = re.sub(r'[^0-9a-f]', '', (os.environ.get("AW_PLAN_HASH") or "").strip().lower())
4301
4729
  sdir = os.path.join(fd, "stories", story)
4302
4730
  doc = os.path.join(sdir, "story-%s-plan.md" % lane)
4303
4731
 
@@ -4358,7 +4786,9 @@ spec_version: ""
4358
4786
  text = open(doc, encoding="utf-8", errors="replace").read()
4359
4787
  nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', text, re.M)]
4360
4788
  n = (max(nums) + 1) if nums else 1
4361
- header = "## Design pass %d — %s%s" % (n, today, (" (spec %s)" % ver) if ver else "")
4789
+ header = "## Design pass %d — %s%s%s" % (
4790
+ n, today, (" (spec %s)" % ver) if ver else "",
4791
+ (" (plan %s)" % phash) if phash else "")
4362
4792
  text = text.rstrip("\n") + "\n\n" + header + "\n\n" + content + "\n"
4363
4793
  # frontmatter stamps — first match only, which is the frontmatter by position
4364
4794
  text = re.sub(r'^(updated:).*$', r'\g<1> ' + today, text, count=1, flags=re.M)
@@ -4368,11 +4798,11 @@ if ver:
4368
4798
  open(doc, "w", encoding="utf-8").write(text)
4369
4799
  os.remove(pf)
4370
4800
  print(json.dumps({"ok": True, "doc": doc, "lane": lane, "pass": n, "created": created,
4371
- "spec_version": ver, "story_dir": sdir}))
4801
+ "spec_version": ver, "plan_hash": phash, "story_dir": sdir}))
4372
4802
  '@
4373
4803
 
4374
4804
  $PyLogAppend = @'
4375
- import json, os, re, sys, datetime
4805
+ import json, os, re, sys, time, errno, datetime
4376
4806
  fd = os.environ["AW_FD"]; log = os.environ["AW_LOG"]
4377
4807
  ef = os.environ["AW_ENTRIES"]; status = (os.environ.get("AW_STATUS") or "").strip()
4378
4808
  fname, prefix, default_status, title = {
@@ -4435,6 +4865,46 @@ SCAFFOLD = {
4435
4865
 
4436
4866
  # Architecture Decision Log
4437
4867
  """}
4868
+ # THE ID ASSIGNMENT AND THE WRITE ARE ONE OPERATION. `nxt = max(existing) + 1` is computed
4869
+ # from a whole-file read and the file is then written back truncating — so two runs that
4870
+ # read before either wrote both compute the same next id, and the second write drops the
4871
+ # first's entry entirely. The wave model exists to have same-wave stories designed in
4872
+ # parallel, and this is the artefact §0.3 calls the cross-lane contract anchor, so the race
4873
+ # is not hypothetical: two story-plan runs, or plan and story-plan, is the ordinary case.
4874
+ # Git catches a lost entry across branches and never within one working tree.
4875
+ #
4876
+ # The lock is named for the FILE, so every writer of interfaces.md contends on one lock
4877
+ # whichever op or shell it came from — aw-xref.py names its own the same way for the same
4878
+ # reason. A lock older than the timeout belonged to a run that died holding it; breaking it
4879
+ # is right, because the alternative is a wedged feature nobody can append to again.
4880
+ LOCK_TIMEOUT = 30.0
4881
+ def take_lock(target):
4882
+ lp = target + ".aw-lock"
4883
+ start = time.time()
4884
+ while True:
4885
+ try:
4886
+ h = os.open(lp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
4887
+ os.write(h, ("%d\n" % os.getpid()).encode("utf-8")); os.close(h)
4888
+ return lp
4889
+ except OSError as e:
4890
+ if e.errno != errno.EEXIST: raise
4891
+ try: age = time.time() - os.path.getmtime(lp)
4892
+ except OSError: age = 0.0
4893
+ if age > LOCK_TIMEOUT:
4894
+ try: os.unlink(lp)
4895
+ except OSError: pass
4896
+ continue
4897
+ if time.time() - start > LOCK_TIMEOUT:
4898
+ bail("log-append: %s has been locked by another run for over %ds. If no "
4899
+ "other /appweaver command is running, delete %s and re-run."
4900
+ % (os.path.basename(target), int(LOCK_TIMEOUT), lp))
4901
+ time.sleep(0.05)
4902
+
4903
+ def drop_lock(lp):
4904
+ try: os.unlink(lp)
4905
+ except OSError: pass
4906
+
4907
+ lock = take_lock(path)
4438
4908
  created = not os.path.isfile(path)
4439
4909
  if created:
4440
4910
  open(path, "w", encoding="utf-8").write(SCAFFOLD[log])
@@ -4471,6 +4941,7 @@ for b in blocks:
4471
4941
 
4472
4942
  text = text.rstrip("\n") + "\n\n" + "\n\n".join(out_blocks) + "\n"
4473
4943
  open(path, "w", encoding="utf-8").write(text)
4944
+ drop_lock(lock)
4474
4945
  os.remove(ef)
4475
4946
  print(json.dumps({"ok": True, "log": path, "appended": len(assigned),
4476
4947
  "ids_csv": ", ".join(assigned),
@@ -4494,8 +4965,11 @@ function Invoke-StoryPlanCheck {
4494
4965
 
4495
4966
  function Invoke-StoryPlanDoc {
4496
4967
  param([string[]]$Rest)
4497
- $fdArg = ''; $story = ''; $passFile = ''; $specVersion = ''; $lane = 'backend'
4498
- for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--story','-Story' } { $story = $Rest[++$i] } { $_ -in '--lane','-Lane' } { $lane = $Rest[++$i] } { $_ -in '--pass-file','-PassFile' } { $passFile = $Rest[++$i] } { $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] } } }
4968
+ $fdArg = ''; $story = ''; $passFile = ''; $specVersion = ''; $lane = 'backend'; $planHash = ''
4969
+ # --plan-hash is the CERTIFIED feature-plan hash — the one analyze-design recorded in
4970
+ # its verdict, never a fresh one taken here. Stamped into the pass header so a pass
4971
+ # keeps saying which plan shape it was designed against long after the plan moved.
4972
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--story','-Story' } { $story = $Rest[++$i] } { $_ -in '--lane','-Lane' } { $lane = $Rest[++$i] } { $_ -in '--pass-file','-PassFile' } { $passFile = $Rest[++$i] } { $_ -in '--spec-version','-SpecVersion' } { $specVersion = $Rest[++$i] } { $_ -in '--plan-hash','-PlanHash' } { $planHash = $Rest[++$i] } } }
4499
4973
  if (-not $story) { Write-Error 'story-plan-doc: --story is required'; exit 1 }
4500
4974
  if (-not $passFile) { Write-Error 'story-plan-doc: --pass-file is required'; exit 1 }
4501
4975
  $py = Get-Py; if (-not $py) { Write-Error 'story-plan-doc: python3 required'; exit 1 }
@@ -4503,8 +4977,9 @@ function Invoke-StoryPlanDoc {
4503
4977
  $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4504
4978
  if (-not $fd) { Write-Error 'story-plan-doc: no feature set and no --feature-dir'; exit 1 }
4505
4979
  $env:AW_FD = $fd; $env:AW_STORY = $story; $env:AW_LANE = $lane; $env:AW_PASS = $passFile; $env:AW_VER = $specVersion
4980
+ $env:AW_PLAN_HASH = $planHash
4506
4981
  $PyStoryPlanDoc | & $py -
4507
- Remove-Item Env:AW_FD, Env:AW_STORY, Env:AW_LANE, Env:AW_PASS, Env:AW_VER -ErrorAction SilentlyContinue
4982
+ Remove-Item Env:AW_FD, Env:AW_STORY, Env:AW_LANE, Env:AW_PASS, Env:AW_VER, Env:AW_PLAN_HASH -ErrorAction SilentlyContinue
4508
4983
  }
4509
4984
 
4510
4985
  function Invoke-LogAppend {
@@ -4932,6 +5407,7 @@ switch ($sub) {
4932
5407
  'plan-check' { Invoke-PlanCheck -Rest $rest }
4933
5408
  'plan-stamp' { Invoke-PlanStamp -Rest $rest }
4934
5409
  'gate-result' { Invoke-GateResult -Rest $rest }
5410
+ 'gate-read' { Invoke-GateRead -Rest $rest }
4935
5411
  'build-compile' { Invoke-BuildCompile -Rest $rest }
4936
5412
  'test-run' { Invoke-TestRun -Rest $rest }
4937
5413
  'test-diff' { Invoke-TestDiff -Rest $rest }