aw-spec-cli 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/assets/.appweaver/STACK-PROFILES.md +1 -1
  2. package/assets/.appweaver/orchestrator/orchestrator.py +5 -0
  3. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +10 -3
  4. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +38 -11
  5. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +2 -1
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +607 -123
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +625 -66
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +407 -134
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +452 -143
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +78 -14
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +597 -0
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +688 -0
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +3 -3
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +23 -23
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +945 -0
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +1040 -0
  17. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +2069 -28
  18. package/assets/.appweaver/scripts/bash/aw-xref.sh +2 -1
  19. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +1973 -13
  20. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +2 -1
  21. package/assets/.appweaver/scripts/python/aw-xref.py +246 -20
  22. package/assets/.appweaver/templates/design-template.md +88 -54
  23. package/assets/.appweaver/templates/spec-template.md +12 -10
  24. package/assets/.appweaver/templates/story-template.md +48 -8
  25. package/assets/.appweaver/templates/ui-design-template.md +71 -39
  26. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +19 -7
  27. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +35 -26
  28. package/package.json +1 -1
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-design-ui.orch.yaml +0 -386
  30. package/assets/.appweaver/orchestrator/workflows/aw-spec-design.orch.yaml +0 -382
@@ -124,7 +124,8 @@ function Invoke-ResolveDomain {
124
124
  # wrong corpus is how a Java project ends up being handed C# rules.
125
125
  function Invoke-ResolveTech {
126
126
  param([string[]]$Rest)
127
- $commandName = 'aw-spec-design'
127
+ $commandName = 'aw-spec-plan' # inert: every caller passes --command. Kept current
128
+ # so a hand-run defaults to a LIVE command, not a dead one.
128
129
  for ($i = 0; $i -lt $Rest.Count; $i++) {
129
130
  switch ($Rest[$i]) {
130
131
  '--command' { $commandName = $Rest[++$i] }
@@ -876,7 +877,7 @@ function Invoke-VersionState { # op in read|init (feature-dir, plus --bootstra
876
877
  # spec.md's `## Change History` is the sync anchor between the spec and its story
877
878
  # backlog: each row names the requirement ids that version touched, and each story
878
879
  # records the version it last synced at, so "is this story stale" is one integer
879
- # comparison rather than a diff. update-stories reads the rows; specify and
880
+ # comparison rather than a diff. sync-stories reads the rows; specify and
880
881
  # update-spec append them.
881
882
  #
882
883
  # The ledger is hand-writable, which is the point — a spec can be edited by hand —
@@ -953,6 +954,14 @@ sep = (next((i for i in range(hi + 1, sec_end)
953
954
  if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
954
955
  rows = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
955
956
  has_ledger = hi >= 0 and sep >= 0
957
+ # THE VERSIONS THE TABLE ACTUALLY CARRIES, read from the rows located above rather than by
958
+ # an unbounded scan, so a version-looking cell in another section's table cannot be counted
959
+ # as history. This is the EVIDENCE that a change was recorded; the document's **Version**
960
+ # header is only the CLAIM, and hand editing parts the two in both directions.
961
+ row_versions = [m.group(1) for m in
962
+ (re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', lines[i].lstrip()) for i in rows)
963
+ if m]
964
+ newest_row = max(row_versions, key=vtuple) if row_versions else ""
956
965
 
957
966
  def cell(s, limit=160):
958
967
  """One markdown table cell. A newline ENDS a row and a bare pipe STARTS a column, so
@@ -982,7 +991,7 @@ if dspec and os.path.isfile(dspec) and os.path.getsize(dspec) > 0:
982
991
  out.update(open_change=cr, delta_authored=delta_authored)
983
992
  out.update(version=recorded_ver, doc_version=doc_ver, spec_sha256=actual,
984
993
  recorded_sha256=recorded_hash, body_changed=(recorded_hash != actual),
985
- has_ledger=has_ledger, ledger_rows=len(rows),
994
+ has_ledger=has_ledger, ledger_rows=len(rows), newest_row=newest_row,
986
995
  first_stamp=(not recorded_hash))
987
996
 
988
997
  if op == "check":
@@ -1016,8 +1025,28 @@ if op == "check":
1016
1025
  out["state"] = "unstamped" # pre-ledger spec; stamp adopts it
1017
1026
  elif recorded_hash == actual:
1018
1027
  out["state"] = "clean"
1028
+ # THE ROW IS THE EVIDENCE; THE HEADER IS ONLY THE CLAIM. The body has moved since the
1029
+ # kit last stamped, and what makes that edit legible downstream is a version ROW naming
1030
+ # the ids it touched: sync-stories reads rows, story-plan's staleness check reads rows,
1031
+ # and neither ever opens the **Version** header. Taking the header as proof accepted the
1032
+ # commonest hand-edit mistake there is — bump the number, forget the row — and that one
1033
+ # passed every tripwire in the kit, raised no work anywhere, and was then buried for good
1034
+ # by the next run's stamp adopting its hash. So the test is a row ABOVE the version the
1035
+ # kit last recorded, and the header is only asked whether it kept up.
1036
+ elif newest_row and vtuple(newest_row) > vtuple(recorded_ver):
1037
+ out["state"] = ("ledgered_edit" if vtuple(doc_ver) >= vtuple(newest_row)
1038
+ else "header_behind") # recorded; stamp reconciles the header
1019
1039
  elif vtuple(doc_ver) > vtuple(recorded_ver):
1020
- out["state"] = "ledgered_edit" # hand-edited AND a row was written
1040
+ # The header claims a version the table never recorded. Refused for the same reason
1041
+ # an unrowed edit is: without the row nothing can say WHICH requirements moved, so
1042
+ # every story citing one is stale with nothing able to detect it.
1043
+ out["state"] = "unrowed_edit"
1044
+ out.update(ok=False, error="spec.md's header reads %s but the Change History carries "
1045
+ "no row above %s — the version was bumped and what it changed was never "
1046
+ "recorded. The row is what a story reads to learn that a requirement it "
1047
+ "cites has moved; the header is read by nobody. Add a row naming the ids "
1048
+ "you changed (keep the header at %s), then re-run."
1049
+ % (doc_ver or "no version", recorded_ver, doc_ver))
1021
1050
  elif vtuple(doc_ver) < vtuple(recorded_ver):
1022
1051
  out["state"] = "rollback"
1023
1052
  out.update(ok=False, error="spec.md reads %s but the kit last wrote %s — the "
@@ -1029,8 +1058,23 @@ if op == "check":
1029
1058
  "stale. Add a row naming the ids you changed, then re-run."
1030
1059
  % recorded_ver)
1031
1060
  elif op == "stamp":
1061
+ # THE VERSION IS THE HIGHEST OF THE THREE PLACES ONE IS WRITTEN, and the document's
1062
+ # header is reconciled up to it. Taking the header alone let a hand edit that DID write
1063
+ # its row — without bumping the header — leave the kit recording a version below the
1064
+ # newest row; the next hand edit then read as a `rollback`, the document behind its own
1065
+ # ledger, when nothing had rolled back. Rewriting the header is the authority
1066
+ # ledger-append already exercises: the kit owns the number, the row is what it is
1067
+ # computed from, and re-hashing after the rewrite is what keeps the baseline honest.
1068
+ stamp_ver = max([recorded_ver or "v1.0", doc_ver or "v1.0"] +
1069
+ ([newest_row] if newest_row else []), key=vtuple)
1070
+ if doc_ver and vtuple(doc_ver) < vtuple(stamp_ver):
1071
+ text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + stamp_ver,
1072
+ text, count=1, flags=re.M)
1073
+ open(spec, "w", encoding="utf-8").write(text)
1074
+ actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
1075
+ out.update(header_rewritten=True, doc_version=stamp_ver, spec_sha256=actual)
1032
1076
  av.setdefault("feature", feature); av.setdefault("active_change", "")
1033
- av["version"] = doc_ver or recorded_ver
1077
+ av["version"] = stamp_ver
1034
1078
  av["spec_sha256"] = actual; av["updated"] = now()
1035
1079
  open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
1036
1080
  out.update(version=av["version"], state="stamped")
@@ -1039,7 +1083,7 @@ elif op == "append":
1039
1083
  # A version that names no ids is a bump nothing can reconcile against: every
1040
1084
  # story keeps its old stamp and the gap compounds at the next real change.
1041
1085
  bail("ledger-append: --ids is required — a version row that names no requirement "
1042
- "ids leaves update-stories nothing to act on", 2)
1086
+ "ids leaves sync-stories nothing to act on", 2)
1043
1087
  # The next number comes from the highest of all three places a version is written —
1044
1088
  # what the kit last recorded, what the document's header claims, and the newest row
1045
1089
  # actually in the table. A hand edit routinely moves one and not the others, and
@@ -1084,7 +1128,7 @@ elif op == "rowids":
1084
1128
  # for the reason the header of this file gives: a row the model DECLARED can omit an id
1085
1129
  # it moved, and an omitted id is silent. The v1.0 row was the one exception — written by
1086
1130
  # the author from a template whose placeholder literally reads "FR-001…FR-00n", which
1087
- # update-stories then has to be told not to trust. This op closes that gap: the caller
1131
+ # sync-stories then has to be told not to trust. This op closes that gap: the caller
1088
1132
  # diffs the finished document against nothing and hands the ids in, exactly as the
1089
1133
  # extend path does, so the first row is observed like all the rest.
1090
1134
  if not ids:
@@ -1229,7 +1273,7 @@ reissued = sorted(set(added) & spent(snap))
1229
1273
  # THE STORY-FACING SUBSET. A story cites FR- and BR- ids and nothing else, so those are the
1230
1274
  # only classes the backlog can act on — the same scoping spec-coverage applies, for the same
1231
1275
  # reason. The scan is deliberately class-blind (CN-/DC-/IC-/UX-/SC- all move the contract and
1232
- # all belong in the ledger row), but handing that whole set to update-stories as "the
1276
+ # all belong in the ledger row), but handing that whole set to sync-stories as "the
1233
1277
  # requirements this run touched" asks it to raise a story for a success criterion.
1234
1278
  STORY_CLASS = ("FR-", "BR-")
1235
1279
  story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
@@ -1252,7 +1296,7 @@ print(json.dumps({
1252
1296
  # spec on disk that the next run's entry check would refuse too.
1253
1297
  "reissued": reissued, "reissued_csv": ", ".join(reissued),
1254
1298
  "reissued_any": bool(reissued),
1255
- # changed, narrowed to the classes a story can cite — what update-stories works from.
1299
+ # changed, narrowed to the classes a story can cite — what sync-stories works from.
1256
1300
  "story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
1257
1301
  "story_id_count": len(story_ids),
1258
1302
  }))
@@ -1306,6 +1350,11 @@ if os.path.isdir(sdir):
1306
1350
  status = (fm.get("status") or "open").strip().lower()
1307
1351
  cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
1308
1352
  stories.append({"id": sid, "status": status, "exercises": cites,
1353
+ "cites_csv": ", ".join(cites),
1354
+ "title": (fm.get("title") or "").strip().strip("[]"),
1355
+ "spec_version": (fm.get("spec_version") or "").strip(),
1356
+ "type": (fm.get("type") or "functional").strip().lower(),
1357
+ "follows": (fm.get("follows") or "").strip(),
1309
1358
  "in_flight": os.path.isdir(os.path.join(sdir, sid))})
1310
1359
 
1311
1360
  open_stories = [s for s in stories if s["status"] == "open"]
@@ -1327,11 +1376,30 @@ for rid in targets:
1327
1376
  aff_stories += names; inflight += flying
1328
1377
  affected.append({"id": rid, "stories": names, "in_flight": flying})
1329
1378
 
1379
+ # THE COMPLEMENT, NAMED. `affected` is "these ids have live work"; its complement is
1380
+ # "these ids have nobody on them", and that is the set sync-stories raises new stories for.
1381
+ # Derived here rather than in the caller because the orchestrator binds values and never
1382
+ # computes them, and a subtraction done in a prompt is a subtraction nothing can check.
1383
+ unlinked = [rid for rid in targets if rid not in aff_ids]
1384
+
1385
+ # One line per open story, for a prompt that has to ask about them. A story id on its own
1386
+ # is not something an author can answer about: what it cites and whether someone is already
1387
+ # implementing it are the whole basis of the decision.
1388
+ digest = "\n".join(
1389
+ " %-16s %-9s %-11s cites %s%s" % (
1390
+ s["id"], s["status"], "IN FLIGHT" if s["in_flight"] else "not started",
1391
+ s["cites_csv"] or "nothing",
1392
+ (" @ " + s["spec_version"]) if s["spec_version"] else "")
1393
+ for s in open_stories) or " (no open stories)"
1394
+
1330
1395
  print(json.dumps({
1331
1396
  "ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
1332
1397
  "story_count": len(stories), "open_count": len(open_stories),
1398
+ "stories": stories, "digest": digest,
1333
1399
  "queried": queried, "covered": covered, "covered_csv": ", ".join(covered),
1334
1400
  "affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
1401
+ "unlinked": unlinked, "unlinked_csv": ", ".join(unlinked),
1402
+ "unlinked_count": len(unlinked), "any_unlinked": bool(unlinked),
1335
1403
  "story_csv": ", ".join(sorted(set(aff_stories))),
1336
1404
  "in_flight_csv": ", ".join(sorted(set(inflight))),
1337
1405
  "any": bool(aff_ids), "any_in_flight": bool(inflight),
@@ -1363,6 +1431,1044 @@ function Invoke-StoryRefs {
1363
1431
  # Fenced out of the ledger on purpose — spec-check, spec-diff and ledger-append all cut
1364
1432
  # the block out before they look, so writing it is not a spec edit and a story raised
1365
1433
  # against FR-003 does not read as FR-003 having changed.
1434
+ # ─────────────────────────────── ledger-read ───────────────────────────────
1435
+ # THE WORK SET: which requirements this sync has to account for. The ledger records what
1436
+ # each version touched and every story records the version it last synced at, so the
1437
+ # question "what has moved since the backlog was last in step" is answerable by reading
1438
+ # rows — no diffing, no hashing, no model.
1439
+ #
1440
+ # The set is the union of the ids named by every row ABOVE THE LOWEST spec_version across
1441
+ # the OPEN stories, not the ids of the newest row alone. Those are the same set whenever
1442
+ # the backlog is one version behind, which is the ordinary case; they diverge only when a
1443
+ # sync was skipped, and that is exactly the case worth getting right. Two specify runs
1444
+ # without a sync between them leave the middle version named by no row anyone reads, and
1445
+ # its requirements would get no story with nothing to say so — a silent gap, in the one
1446
+ # mechanism built to make gaps loud.
1447
+ #
1448
+ # THE v1.0 CASE (E42). At v1.0 nothing has moved yet: there is no "since" to read from. So
1449
+ # when no open story carries a readable spec version — an empty backlog, or stories written
1450
+ # before the field existed — the work set is every FR-/BR- the DOCUMENT declares, and the
1451
+ # per-id four-case table does the rest. A second run over the same spec then creates
1452
+ # nothing, because by then every id has a story.
1453
+ #
1454
+ # Scoped to FR-/BR- throughout, the same scoping spec-coverage applies: those are the only
1455
+ # classes a story's `exercises:` can name. CN-/DC-/IC-/UX- move the contract and belong in
1456
+ # the ledger row, but they are constraints a story CARRIES, not slices of work it delivers.
1457
+ $PyLedgerRead = @'
1458
+ import json, os, re, sys
1459
+ fd = os.environ["AW_FD"]
1460
+ spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
1461
+
1462
+ def bail(msg, code=1):
1463
+ print(json.dumps({"ok": False, "error": msg}))
1464
+ sys.stderr.write(msg + "\n"); sys.exit(code)
1465
+
1466
+ if not os.path.isfile(spec): bail("no spec.md at %s" % fd)
1467
+ text = open(spec, encoding="utf-8", errors="replace").read()
1468
+
1469
+ COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
1470
+
1471
+ def vtuple(v):
1472
+ m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
1473
+ return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
1474
+
1475
+ def rids(s):
1476
+ """FR-/BR- ids in order of appearance, deduped, then sorted by class and number so
1477
+ two callers reading the same row always produce the same csv."""
1478
+ seen, out = set(), []
1479
+ for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
1480
+ if t not in seen: seen.add(t); out.append(t)
1481
+ return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1482
+
1483
+ def parse_fm(text):
1484
+ """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1485
+ a YAML block list under an empty key — both are valid YAML and a hand edit produces
1486
+ either, so a parser that read only the inline form saw a perfectly good story as citing
1487
+ nothing, and a story citing nothing is invisible to every gate that exists to protect
1488
+ it. Block items are folded into the inline shape so every caller stays regex-simple."""
1489
+ m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
1490
+ if not m: return {}
1491
+ fm, key, block = {}, None, set()
1492
+ for line in m.group(1).split("\n"):
1493
+ km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
1494
+ if km:
1495
+ key = km.group(1); fm[key] = km.group(2).strip(); continue
1496
+ im = re.match(r'^\s+-\s+(.*)$', line) if key else None
1497
+ if im and (key in block or not fm[key]):
1498
+ block.add(key)
1499
+ fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1500
+ return fm
1501
+
1502
+ # THE LEDGER TABLE, located the same way _spec_ledger locates it — heading, separator rule,
1503
+ # then the rows under it, bounded by the next `## `. Any other section carries its own
1504
+ # table (Story Coverage does), and an unbounded search would read one of those as history.
1505
+ lines = text.split("\n")
1506
+ hi = next((i for i, l in enumerate(lines)
1507
+ if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
1508
+ sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
1509
+ len(lines)) if hi >= 0 else -1)
1510
+ sep = (next((i for i in range(hi + 1, sec_end)
1511
+ if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
1512
+ row_lines = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
1513
+ has_ledger = hi >= 0 and sep >= 0
1514
+
1515
+ def cells(line):
1516
+ parts = re.split(r'(?<!\\)\|', line)
1517
+ return [c.strip().replace("\\|", "|") for c in parts[1:-1]] if len(parts) >= 3 else []
1518
+
1519
+ rows = []
1520
+ for i in row_lines:
1521
+ c = cells(lines[i]); c += [""] * (5 - len(c))
1522
+ # A row whose first cell is not a version is the template's own placeholder line, or a
1523
+ # note somebody left in the table. It records no version, so it orders against nothing.
1524
+ if not re.match(r'^v\d', c[0]): continue
1525
+ rows.append({"version": c[0], "date": c[1], "change": c[2], "ids_cell": c[3],
1526
+ "stories": c[4], "requirements": rids(c[3])})
1527
+ rows.sort(key=lambda r: vtuple(r["version"]))
1528
+
1529
+ # WHAT THE DOCUMENT STILL DECLARES. Read from the requirement's DEFINING line — the bolded
1530
+ # id the template gives every requirement — and with the Change History cut away, so the
1531
+ # ids named in a row's own cell are never mistaken for requirements the spec carries.
1532
+ body = COV.sub("", text).split("\n## Change History")[0]
1533
+ declared = []
1534
+ for line in body.split("\n"):
1535
+ m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
1536
+ if m and m.group(1) not in declared: declared.append(m.group(1))
1537
+ declared.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1538
+
1539
+ stories = []
1540
+ if os.path.isdir(sdir):
1541
+ for name in sorted(os.listdir(sdir)):
1542
+ if not name.endswith(".md"): continue
1543
+ p = os.path.join(sdir, name)
1544
+ if not os.path.isfile(p): continue
1545
+ fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
1546
+ sid = fm.get("id") or name[:-3]
1547
+ stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
1548
+ "spec_version": (fm.get("spec_version") or "").strip(),
1549
+ "follows": (fm.get("follows") or "").strip(),
1550
+ "in_flight": os.path.isdir(os.path.join(sdir, sid)),
1551
+ "cites": rids(fm.get("exercises", ""))})
1552
+ open_stories = [s for s in stories if s["status"] == "open"]
1553
+
1554
+ # THE FLOOR is the oldest version any open story is still sitting at, and everything above
1555
+ # it is unsynced. A story whose spec_version is missing or unreadable counts as v0.0 rather
1556
+ # than being skipped: it has demonstrably never synced, and putting the whole ledger above
1557
+ # it is the same answer enumerating from the document would give.
1558
+ floors = [vtuple(s["spec_version"]) for s in open_stories]
1559
+ readable = [s for s in open_stories if re.match(r'^v?\d', s["spec_version"] or "")]
1560
+ # A BACKLOG THAT IS ENTIRELY CLOSED IS NOT A BACKLOG THAT NEVER SYNCED. Reading the floor
1561
+ # from open stories alone made the last story closing look exactly like a feature nobody has
1562
+ # ever sliced: no floor took the enumerate-the-whole-document branch below, so one idle
1563
+ # re-run after finishing a feature raised a fresh story for every requirement in the spec —
1564
+ # each one reported as work the contract had moved on, when nothing had moved at all. A
1565
+ # CLOSED story carries a stamp too, and it records which version its work was measured
1566
+ # against. CANCELLED ones are excluded: abandoned work delivered nothing and settles nothing,
1567
+ # so it establishes no floor and leaves its requirements to be sliced again.
1568
+ closed_stories = [s for s in stories if s["status"] == "closed"]
1569
+ floor_source = "open" if floors else ("closed" if closed_stories else "none")
1570
+ floor = (min(floors) if floors
1571
+ else (min(vtuple(s["spec_version"]) for s in closed_stories) if closed_stories
1572
+ else None))
1573
+
1574
+ latest = rows[-1] if rows else None
1575
+ out = {"ok": True, "feature_dir": fd, "has_ledger": has_ledger,
1576
+ "row_count": len(rows), "ledger_empty": len(rows) == 0,
1577
+ "rows": rows,
1578
+ "latest_version": latest["version"] if latest else "",
1579
+ "latest_change": latest["change"] if latest else "",
1580
+ "latest_ids_csv": ", ".join(latest["requirements"]) if latest else "",
1581
+ "spec_ids_csv": ", ".join(declared), "spec_id_count": len(declared),
1582
+ "story_count": len(stories), "open_count": len(open_stories),
1583
+ "unversioned_open": len(open_stories) - len(readable)}
1584
+
1585
+ if not has_ledger:
1586
+ out.update(ok=False, error="spec.md has no '## Change History' table. That table is "
1587
+ "where a story learns a requirement it cites has moved, so a spec without "
1588
+ "one cannot be kept in step with its backlog.")
1589
+ print(json.dumps(out)); sys.stderr.write(out["error"] + "\n"); sys.exit(2)
1590
+
1591
+ if not rows:
1592
+ # An empty ledger is not corruption — it is a spec that has never been versioned, which
1593
+ # means specify has not finished with it. Reported, not raised: the caller warns and
1594
+ # stops, because there is no "since" to sync from and inventing one would raise a
1595
+ # backlog against a document nobody has committed to.
1596
+ out.update(work_csv="", work_count=0, work_source="none", span_versions_csv="",
1597
+ gap=False, removed_csv="",
1598
+ work_note="the Change History table carries no version row")
1599
+ print(json.dumps(out)); sys.exit(0)
1600
+
1601
+ if floor is None:
1602
+ # E42 — nothing has moved because nothing has synced. Enumerate from the document.
1603
+ source, span = "spec", []
1604
+ candidates = list(declared)
1605
+ note = ("nothing has synced against this spec yet — no open story records a version and "
1606
+ "nothing has closed against it — so there is no 'since' to read from: the work "
1607
+ "set is every requirement the document declares, and the per-id rules then "
1608
+ "create only what has no story")
1609
+ else:
1610
+ span = [r for r in rows if vtuple(r["version"]) > floor]
1611
+ seen, candidates = set(), []
1612
+ for r in span:
1613
+ for rid in r["requirements"]:
1614
+ if rid not in seen: seen.add(rid); candidates.append(rid)
1615
+ candidates.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1616
+ source = "ledger"
1617
+ note = ("%d version row(s) above the oldest %s story's v%d.%d: %s"
1618
+ % (len(span), floor_source, floor[0], floor[1],
1619
+ ", ".join(r["version"] for r in span) or "none"))
1620
+
1621
+ # THE SPAN IS A PREFILTER; STALENESS IS PER ID. The floor is the oldest version any open
1622
+ # story sits at, and a story whose requirements never move sits there forever — Order-001
1623
+ # holding v1.0 keeps v1.1 and v1.2 in the span long after everything they named has been
1624
+ # synced. Reading the span alone therefore re-offered the same requirements on every run,
1625
+ # and each re-offer is a model call rewriting a story that was already in step.
1626
+ #
1627
+ # So the span says which ids are WORTH CHECKING and this says which have actually moved
1628
+ # under someone: an id is stale when it has no open story at all, or when some open story
1629
+ # citing it is stamped BELOW the newest version that touched it. An id whose every open
1630
+ # story is at or above that version is in step, whatever the floor says.
1631
+ moved_at = {}
1632
+ for r in rows:
1633
+ for rid in r["requirements"]:
1634
+ v = vtuple(r["version"])
1635
+ if v > moved_at.get(rid, (0, 0)): moved_at[rid] = v
1636
+
1637
+ def carried_on(story_id, rid, at):
1638
+ """Has a follow-on already taken this requirement off that story's hands, at or above
1639
+ the version that moved it. The `follows` link is the whole record of that hand-off."""
1640
+ return any(s["follows"] == story_id and rid in s["cites"]
1641
+ and vtuple(s["spec_version"]) >= at for s in open_stories)
1642
+
1643
+ work, in_step = [], []
1644
+ for rid in candidates:
1645
+ holders = [s for s in open_stories if rid in s["cites"]]
1646
+ at = moved_at.get(rid, (0, 0))
1647
+ behind = []
1648
+ for s in holders:
1649
+ if vtuple(s["spec_version"]) >= at: continue
1650
+ # AN IN-FLIGHT STORY CANNOT BE BROUGHT UP TO DATE, BY DESIGN. It is never rewritten
1651
+ # (§5.5), so its spec_version can never rise, so reading it as a staleness signal
1652
+ # gives a signal that never clears — and a sync that mints a fresh follow-on for the
1653
+ # same requirement on every run. What clears it is the follow-on itself: once one
1654
+ # exists carrying this id at or above the version that moved it, the hand-off has
1655
+ # happened and the original is history in progress, not unfinished business.
1656
+ if s["in_flight"] and carried_on(s["id"], rid, at): continue
1657
+ behind.append(s["id"])
1658
+ # A CLOSED STORY STAMPED AT OR ABOVE THE VERSION THAT LAST MOVED THIS REQUIREMENT
1659
+ # DELIVERED THE WORDING THE SPEC ASKS FOR TODAY, so the requirement is FINISHED rather
1660
+ # than unsliced. Reading "no open story cites it" as work on its own could not tell those
1661
+ # two apart, which is what let a fully delivered feature re-raise its entire backlog.
1662
+ # A closed story stamped BELOW that version is the other case and stays work: the
1663
+ # contract moved after it shipped, so what shipped is no longer what the spec asks for,
1664
+ # and backlog-plan names the closed story it is re-opening against.
1665
+ delivered = any(rid in s["cites"] and vtuple(s["spec_version"]) >= at
1666
+ for s in closed_stories)
1667
+ (in_step if ((holders and not behind) or (not holders and delivered))
1668
+ else work).append(rid)
1669
+
1670
+ # A row names what a version TOUCHED, and that includes ids it REMOVED. A removed
1671
+ # requirement has no wording left to write a story from, so it leaves the work set — but it
1672
+ # is reported, because every open story citing one is orphaned and that is a decision for a
1673
+ # person, not a default (E1).
1674
+ removed = [i for i in work if i not in declared]
1675
+ work = [i for i in work if i in declared]
1676
+
1677
+ out.update(work_csv=", ".join(work), work_count=len(work), work_source=source,
1678
+ work_note=note, work_any=bool(work),
1679
+ candidates_csv=", ".join(candidates), candidate_count=len(candidates),
1680
+ in_step_csv=", ".join(in_step), in_step_count=len(in_step),
1681
+ floor_source=floor_source,
1682
+ span_versions_csv=", ".join(r["version"] for r in span),
1683
+ span_count=len(span), gap=len(span) > 1,
1684
+ removed_csv=", ".join(removed), removed_count=len(removed),
1685
+ synced_floor=("v%d.%d" % floor) if floor else "")
1686
+ print(json.dumps(out))
1687
+ '@
1688
+
1689
+ function Invoke-LedgerRead {
1690
+ param([string[]]$Rest)
1691
+ $fdArg = ''
1692
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
1693
+ switch ($Rest[$i]) { '--feature-dir' { $fdArg = $Rest[++$i] } }
1694
+ }
1695
+ $repo = Get-RepoRoot
1696
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
1697
+ if (-not $fd) { Write-Error 'ledger-read: no feature set and no --feature-dir'; exit 1 }
1698
+ $py = Get-Py; if (-not $py) { Write-Error 'ledger-read: python3 required'; exit 1 }
1699
+ $env:AW_FD = $fd
1700
+ $PyLedgerRead | & $py -
1701
+ }
1702
+
1703
+ # ─────────────────────────────── slice-mode ────────────────────────────────
1704
+ # Which slicing strategy the launch line asked for, and what is left of the input once that
1705
+ # word is taken out of it. Its own op rather than a `condition:` in the workflow, because
1706
+ # the engine compares a whole rendered value: `{{USER_INPUT}} == single-run` only matches an
1707
+ # input carrying the strategy and no guidance at all, and `sync-stories single-run keep the
1708
+ # payment rules together` — the form the launch line documents — would silently slice by
1709
+ # default while looking as though it had been told not to.
1710
+ #
1711
+ # ONLY THE FIRST WORD IS A STRATEGY. Guidance is free prose, and "keep the single sign-on
1712
+ # requirements in one story" is a slicing instruction with the token sitting in the middle
1713
+ # of it. Reading the token anywhere in the string would turn that sentence into the opposite
1714
+ # of what it asks, with nothing to say so.
1715
+ $PySliceMode = @'
1716
+ import json, os, re
1717
+ raw = (os.environ.get("AW_INPUT", "") or "").strip()
1718
+
1719
+ # `single-run` is the spelling the launch line documents; the others are what people write
1720
+ # when they mean it. `auto` is the default and is accepted explicitly so a launch line can
1721
+ # say so — a strategy nobody can name is one nobody can turn off.
1722
+ TOKENS = {"single-run": "single", "single-story": "single", "single": "single",
1723
+ "one-story": "single", "auto": "auto", "per-slice": "auto", "default": "auto"}
1724
+
1725
+ strategy, guidance = "auto", raw
1726
+ m = re.match(r'^([A-Za-z][A-Za-z0-9_-]*)\s*[:,]?\s*(.*)$', raw, re.S)
1727
+ if m and m.group(1).lower() in TOKENS:
1728
+ strategy = TOKENS[m.group(1).lower()]
1729
+ guidance = m.group(2).strip()
1730
+
1731
+ if strategy == "single":
1732
+ note = ("one story was asked for (single-run): the whole work set goes into a single "
1733
+ "slice unless it is too heavy to be picked up as one piece of work, in which "
1734
+ "case it is split along its dependencies and the split is reported")
1735
+ else:
1736
+ note = "the slicing is decided from the requirements themselves"
1737
+
1738
+ print(json.dumps({"ok": True, "strategy": strategy, "guidance": guidance,
1739
+ "single": strategy == "single", "note": note,
1740
+ "raw": raw, "tokens": sorted(TOKENS)}))
1741
+ '@
1742
+
1743
+ function Invoke-SliceMode {
1744
+ param([string[]]$Rest)
1745
+ $input_ = ''
1746
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
1747
+ switch ($Rest[$i]) {
1748
+ '--input' { $input_ = $Rest[++$i] }
1749
+ }
1750
+ }
1751
+ $py = Get-Py; if (-not $py) { Write-Error 'slice-mode: python3 required'; exit 1 }
1752
+ $env:AW_INPUT = $input_
1753
+ $PySliceMode | & $py -
1754
+ }
1755
+
1756
+ # ─────────────────────────────── backlog-plan ──────────────────────────────
1757
+ # The grouping is a judgment; everything that follows from it is arithmetic. This op does
1758
+ # the arithmetic, in two modes over the same inputs so the two passes cannot disagree about
1759
+ # what a group contains:
1760
+ #
1761
+ # scan which OPEN stories already stand in front of a group's requirements — the
1762
+ # candidates the author is asked about, and the wording of that question.
1763
+ # plan given the author's answer, the flat list of story files to write: one entry per
1764
+ # file, each already carrying its id, its path, and the ids it must end up citing.
1765
+ #
1766
+ # THE FOUR CASES (§5.4) resolve per group, not per requirement, because a group IS the story:
1767
+ #
1768
+ # no open story cites anything in the group -> create
1769
+ # an open story does, no stories/<ID>/ yet -> update in place (asked)
1770
+ # an open story does, a story plan already exists -> mint a follow-on (asked); the
1771
+ # original is never rewritten (§5.5)
1772
+ # the author declines -> that story is left alone AND the ids
1773
+ # it covers leave the group; whatever
1774
+ # remains becomes a new story
1775
+ #
1776
+ # The last line is the one worth reading twice. Declining does not abandon the group — it
1777
+ # withdraws the requirements that story is already carrying and creates a story for the
1778
+ # rest. A group of three where one id has a story the author will not touch still delivers
1779
+ # the other two.
1780
+ #
1781
+ # ONE GROUP, ONE ABSORBING STORY. Where the author approves two stories in the same group,
1782
+ # the first by id takes the group's unclaimed requirements and the others are refreshed
1783
+ # against the current spec without gaining ids. Two stories both claiming the same new
1784
+ # requirement is not a state the completeness rule can read.
1785
+ #
1786
+ # AND THE ORDER. Plan mode also resolves the dependency edges the grouping declared between
1787
+ # slices, folds in the `depends_on:` an updated story already carries, breaks any cycle, and
1788
+ # lays the result out in WAVES: wave N holds the stories whose every dependency sits below N,
1789
+ # so one wave is a set with no ordering between its members — they may be picked up in
1790
+ # parallel. The plan comes back sorted by wave, which is also the order the stories are
1791
+ # written in, so meeting the cap costs the LAST wave rather than an arbitrary slice of the
1792
+ # backlog. See the block above the wave computation for why `follows:` is not an edge.
1793
+ $PyBacklogPlan = @'
1794
+ import json, os, re, sys
1795
+ fd = os.environ["AW_FD"]; mode = os.environ.get("AW_MODE", "scan")
1796
+ sdir = os.path.join(fd, "stories")
1797
+ feature = os.path.basename(fd.rstrip("/\\"))
1798
+
1799
+ def bail(msg, code=2):
1800
+ print(json.dumps({"ok": False, "error": msg}))
1801
+ sys.stderr.write(msg + "\n"); sys.exit(code)
1802
+
1803
+ def rids(s):
1804
+ seen, out = set(), []
1805
+ for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
1806
+ if t not in seen: seen.add(t); out.append(t)
1807
+ return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1808
+
1809
+ def sids(s):
1810
+ """Story ids out of a csv, a JSON list or a YAML inline list. A story id is a feature
1811
+ name and a number; a template placeholder (`[]`, `[STORY-ID]`) is neither, and must
1812
+ never become a dependency edge nobody can resolve."""
1813
+ seen, out = set(), []
1814
+ for t in re.split(r'[,\s\[\]"\']+', s or ""):
1815
+ t = t.strip()
1816
+ if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
1817
+ seen.add(t); out.append(t)
1818
+ return out
1819
+
1820
+ def wave_of(s):
1821
+ """A story's declared wave. Absent — a story written before the field existed — reads
1822
+ as 1, the conservative floor: nothing is KNOWN to have to land before it."""
1823
+ m = re.match(r'^\s*(\d+)\s*$', (s or "").strip())
1824
+ return int(m.group(1)) if m else 1
1825
+
1826
+ def names(s):
1827
+ """Story ids out of a csv or a JSON list. A multi-select answer arrives as the engine's
1828
+ JSON rendering of a list, so both spellings have to read the same."""
1829
+ seen, out = set(), []
1830
+ for t in re.split(r'[,\s\[\]"\']+', s or ""):
1831
+ t = t.strip()
1832
+ if t and t not in seen: seen.add(t); out.append(t)
1833
+ return out
1834
+
1835
+ def parse_fm(text):
1836
+ """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1837
+ a YAML block list under an empty key — both are valid YAML and a hand edit produces
1838
+ either, so a parser that read only the inline form saw a perfectly good story as citing
1839
+ nothing, and a story citing nothing is invisible to every gate that exists to protect
1840
+ it. Block items are folded into the inline shape so every caller stays regex-simple."""
1841
+ m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
1842
+ if not m: return {}
1843
+ fm, key, block = {}, None, set()
1844
+ for line in m.group(1).split("\n"):
1845
+ km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
1846
+ if km:
1847
+ key = km.group(1); fm[key] = km.group(2).strip(); continue
1848
+ im = re.match(r'^\s+-\s+(.*)$', line) if key else None
1849
+ if im and (key in block or not fm[key]):
1850
+ block.add(key)
1851
+ fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1852
+ return fm
1853
+
1854
+ # ---- the backlog -----------------------------------------------------------------
1855
+ stories, used_numbers = [], []
1856
+ if os.path.isdir(sdir):
1857
+ for name in sorted(os.listdir(sdir)):
1858
+ if not name.endswith(".md"): continue
1859
+ p = os.path.join(sdir, name)
1860
+ if not os.path.isfile(p): continue
1861
+ fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
1862
+ sid = fm.get("id") or name[:-3]
1863
+ # Numbers come from the FILENAME as well as the id, and from CLOSED and CANCELLED
1864
+ # stories as well as open ones. An id is permanent once minted: re-issuing the
1865
+ # number of a story that shipped re-points its execution artefacts at new work.
1866
+ for cand in (sid, name[:-3]):
1867
+ m = re.search(r'-(\d+)$', cand)
1868
+ if m: used_numbers.append(int(m.group(1)))
1869
+ stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
1870
+ "title": (fm.get("title") or "").strip().strip("[]"),
1871
+ "spec_version": (fm.get("spec_version") or "").strip(),
1872
+ "cites": rids(fm.get("exercises", "")),
1873
+ "wave": wave_of(fm.get("wave", "")),
1874
+ "deps": sids(fm.get("depends_on", "")),
1875
+ "in_flight": os.path.isdir(os.path.join(sdir, sid)),
1876
+ "path": p})
1877
+ open_stories = [s for s in stories if s["status"] == "open"]
1878
+ # Closed and cancelled stories hold no requirement — completeness is "no OPEN stories" —
1879
+ # but they are the reason a create can look like duplicated work, so they are read in
1880
+ # order to say so. See the create branch below.
1881
+ done_stories = [s for s in stories if s["status"] != "open"]
1882
+ by_id = {s["id"]: s for s in stories}
1883
+ _next = [max(used_numbers) + 1 if used_numbers else 1]
1884
+
1885
+ def mint():
1886
+ n = _next[0]; _next[0] += 1
1887
+ return "%s-%03d" % (feature, n)
1888
+
1889
+ # ---- the groups ------------------------------------------------------------------
1890
+ raw = (os.environ.get("AW_GROUPS") or "").strip()
1891
+ try:
1892
+ parsed = json.loads(raw) if raw else []
1893
+ except Exception as e:
1894
+ bail("backlog-plan: --groups is not valid JSON (%s)" % e)
1895
+ if isinstance(parsed, dict): parsed = parsed.get("groups") or []
1896
+ if not isinstance(parsed, list):
1897
+ bail("backlog-plan: --groups must be a JSON list of groups")
1898
+
1899
+ # Ids are read from the group's OWN requirements field, never scraped from its prose. A
1900
+ # rationale that mentions FR-004 as the reason two others belong together would otherwise
1901
+ # pull a third requirement into the story, and nothing downstream would question it.
1902
+ work = rids(os.environ.get("AW_WORK", ""))
1903
+ groups, dropped = [], []
1904
+ for i, g in enumerate(parsed):
1905
+ if not isinstance(g, dict): continue
1906
+ src = g.get("requirements")
1907
+ if isinstance(src, list): src = ", ".join(str(x) for x in src)
1908
+ ids = rids(src if src is not None else g.get("requirements_csv", ""))
1909
+ if work:
1910
+ outside = [x for x in ids if x not in work]
1911
+ dropped += outside
1912
+ ids = [x for x in ids if x in work]
1913
+ if not ids: continue
1914
+ # `depends_on` names OTHER GROUPS by key, or open stories already in the backlog by
1915
+ # id. Kept as raw tokens: a group key only becomes a story id once the plan has minted
1916
+ # one, so resolving here would have to guess at a name that does not exist yet.
1917
+ dep_src = g.get("depends_on")
1918
+ if dep_src is None: dep_src = g.get("depends_on_csv")
1919
+ if isinstance(dep_src, list): dep_src = ", ".join(str(x) for x in dep_src)
1920
+ deps_raw = [x for x in re.split(r'[,\s\[\]"\']+', str(dep_src or "")) if x.strip()]
1921
+ groups.append({"key": g.get("id") or ("g%d" % (i + 1)),
1922
+ "title": (g.get("title") or "").strip(),
1923
+ "rationale": (g.get("rationale") or "").strip(),
1924
+ "complexity": (g.get("complexity") or "").strip().lower(),
1925
+ "deps_raw": deps_raw,
1926
+ "ids": ids, "ids_csv": ", ".join(ids)})
1927
+
1928
+ # Every work-set id must land in exactly one group. One the model left out gets no story
1929
+ # and nothing says so; one it repeated gets two, each claiming the same requirement.
1930
+ placed = [x for g in groups for x in g["ids"]]
1931
+ repeated = sorted({x for x in placed if placed.count(x) > 1})
1932
+
1933
+ # A REPEATED ID IS RESOLVED HERE, not merely reported. Two groups both claiming one
1934
+ # requirement become two stories both claiming it, and "does this requirement still have
1935
+ # open stories" — the completeness rule — then answers for work that was never really
1936
+ # duplicated. The grouping is a judgment; the arithmetic over it is not. So the id stays in
1937
+ # the FIRST group that claimed it and leaves the rest, which is deterministic (the same
1938
+ # grouping always yields the same slices) and loses no coverage, because the id still
1939
+ # reaches exactly one story. repeated_notice reports what that touched.
1940
+ if repeated:
1941
+ seen_ids = set()
1942
+ for g in groups:
1943
+ keep_ids = [x for x in g["ids"] if x not in seen_ids]
1944
+ seen_ids.update(keep_ids)
1945
+ g["ids"], g["ids_csv"] = keep_ids, ", ".join(keep_ids)
1946
+ groups = [g for g in groups if g["ids"]]
1947
+ placed = [x for g in groups for x in g["ids"]]
1948
+
1949
+ missed = [x for x in work if x not in placed]
1950
+
1951
+ def candidates_of(g):
1952
+ """Open stories standing in front of this group, each with what it already covers OF
1953
+ THIS GROUP — the intersection, not its whole citation list, because that is what the
1954
+ author is being asked to move."""
1955
+ out = []
1956
+ for s in open_stories:
1957
+ shared = [x for x in g["ids"] if x in s["cites"]]
1958
+ if not shared: continue
1959
+ out.append({"story": s["id"], "title": s["title"], "in_flight": s["in_flight"],
1960
+ "spec_version": s["spec_version"], "cites_csv": ", ".join(s["cites"]),
1961
+ "shared_csv": ", ".join(shared), "shared": shared,
1962
+ "group": g["key"], "group_title": g["title"], "group_ids_csv": g["ids_csv"],
1963
+ "action": "followon" if s["in_flight"] else "update"})
1964
+ return sorted(out, key=lambda c: c["story"])
1965
+
1966
+ base = {"ok": True, "feature": feature, "feature_dir": fd, "stories_dir": sdir,
1967
+ "group_count": len(groups), "groups": groups,
1968
+ "work_csv": ", ".join(work),
1969
+ "unplaced_csv": ", ".join(missed), "unplaced_count": len(missed),
1970
+ "repeated_csv": ", ".join(repeated), "repeated_count": len(repeated),
1971
+ "dropped_csv": ", ".join(sorted(set(dropped))),
1972
+ "groups_digest": "\n".join(
1973
+ " [%s] %s\n requirements: %s%s%s"
1974
+ % (g["key"], g["title"] or "(untitled)", g["ids_csv"],
1975
+ ("\n stands behind: " + ", ".join(g["deps_raw"])) if g["deps_raw"] else "",
1976
+ ("\n " + g["rationale"]) if g["rationale"] else "")
1977
+ for g in groups) or " (no groups)"}
1978
+
1979
+ # THE STRATEGY IS THE AUTHOR'S; THE ARITHMETIC OVER IT IS NOT. `single` asked for one story
1980
+ # covering the whole work set, and the grouping may still have returned several — because a
1981
+ # slice nobody can pick up is not a story, and splitting it is the honest answer. So the
1982
+ # split is REPORTED rather than refused or hidden: quietly handing six stories to someone
1983
+ # who asked for one is the kind of silent disobedience that makes an option not worth having.
1984
+ strategy = (os.environ.get("AW_STRATEGY") or "").strip().lower() or "auto"
1985
+ try:
1986
+ ceiling = int((os.environ.get("AW_CEILING") or "").strip() or 8)
1987
+ except ValueError:
1988
+ ceiling = 8
1989
+ oversized = [g["key"] for g in groups if len(g["ids"]) > ceiling]
1990
+ split_note, split_count = "", 0
1991
+ if strategy == "single" and len(groups) > 1:
1992
+ split_count = len(groups)
1993
+ split_note = ("one story was asked for and the work set was too heavy for one: it is "
1994
+ "split into %d, in dependency order — %s" % (len(groups), "; ".join(
1995
+ "[%s] %s (%s)" % (g["key"], g["title"] or "untitled", g["ids_csv"])
1996
+ for g in groups)))
1997
+ elif strategy == "single" and len(groups) == 1:
1998
+ split_note = ("one story was asked for and one covers the whole work set: %s"
1999
+ % groups[0]["ids_csv"])
2000
+ base.update(strategy=strategy, ceiling=ceiling, split_count=split_count,
2001
+ split_note=split_note, oversized_csv=", ".join(oversized))
2002
+
2003
+ if mode == "scan":
2004
+ cands = [c for g in groups for c in candidates_of(g)]
2005
+ lines = []
2006
+ for c in cands:
2007
+ lines.append(
2008
+ " %s (%s)\n"
2009
+ " group [%s] %s covers %s\n"
2010
+ " this story already covers %s of it, and cites %s in total\n"
2011
+ " %s"
2012
+ % (c["story"], c["title"] or "untitled",
2013
+ c["group"], c["group_title"] or "", c["group_ids_csv"],
2014
+ c["shared_csv"], c["cites_csv"],
2015
+ ("ALREADY IN FLIGHT (story plan exists) — picking it mints a FOLLOW-ON "
2016
+ "carrying the change; this story is never rewritten"
2017
+ if c["in_flight"] else
2018
+ "not started (no story plan yet) — picking it rewrites this story to "
2019
+ "cover the whole group")))
2020
+ base.update(candidates=cands, candidate_count=len(cands),
2021
+ candidate_ids=[c["story"] for c in cands],
2022
+ candidate_csv=", ".join(c["story"] for c in cands),
2023
+ any_candidates=bool(cands),
2024
+ ask_digest="\n".join(lines) or " (no existing story covers any of these)")
2025
+ print(json.dumps(base)); sys.exit(0)
2026
+
2027
+ # ---- plan ------------------------------------------------------------------------
2028
+ approved = set(names(os.environ.get("AW_OK", "")))
2029
+ version = (os.environ.get("AW_VER") or "").strip()
2030
+ plan, skipped, notes = [], [], []
2031
+ for g in groups:
2032
+ cands = candidates_of(g)
2033
+ declined = [c for c in cands if c["story"] not in approved]
2034
+ # A declined story keeps its requirements: the author said do not disturb it, and
2035
+ # raising a second story over the same ids disturbs it just as thoroughly.
2036
+ held = {x for c in declined for x in c["shared"]}
2037
+ skipped += [c["story"] for c in declined]
2038
+ remaining = [x for x in g["ids"] if x not in held]
2039
+ taken = [c for c in cands if c["story"] in approved]
2040
+
2041
+ if taken:
2042
+ # ONE APPROVED STORY ABSORBS THE GROUP'S UNCLAIMED REQUIREMENTS — the first by id.
2043
+ # Every OTHER approved story keeps exactly the ids it already cites, so those ids
2044
+ # are RESERVED out of what the carrier gains: two open stories both claiming one
2045
+ # requirement is not a state the completeness rule can read, and the run would have
2046
+ # created that state itself rather than merely reporting it.
2047
+ carrier = taken[0]; s = by_id[carrier["story"]]
2048
+ reserved = {x for c in taken[1:] for x in c["shared"]}
2049
+ gained = [x for x in remaining if x not in reserved]
2050
+ if carrier["in_flight"]:
2051
+ # §5.5 — the original stays open and unchanged, and closes having delivered
2052
+ # what it was written to deliver. The follow-on carries the remainder.
2053
+ carry = [x for x in g["ids"] if x in s["cites"] or x in gained]
2054
+ carry = [x for x in carry if x not in held]
2055
+ sid = mint()
2056
+ plan.append({"action": "followon", "story": sid, "follows": carrier["story"],
2057
+ "path": os.path.join(sdir, sid + ".md"),
2058
+ "requirements_csv": ", ".join(carry), "existing_csv": "",
2059
+ "prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
2060
+ "prior_path": s["path"],
2061
+ "group": g["key"], "group_title": g["title"],
2062
+ "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2063
+ "rationale": g["rationale"], "spec_version": version})
2064
+ else:
2065
+ keep = sorted(set(s["cites"]) | set(gained),
2066
+ key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2067
+ plan.append({"action": "update", "story": s["id"], "follows": "",
2068
+ "path": s["path"],
2069
+ "requirements_csv": ", ".join(keep),
2070
+ "existing_csv": ", ".join(s["cites"]),
2071
+ "prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
2072
+ "prior_path": s["path"],
2073
+ "group": g["key"], "group_title": g["title"],
2074
+ "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2075
+ "rationale": g["rationale"], "spec_version": version})
2076
+ carrier_sid = plan[-1]["story"]
2077
+ for c in taken[1:]:
2078
+ s2 = by_id[c["story"]]
2079
+ if c["in_flight"]:
2080
+ # AN IN-FLIGHT STORY IS NEVER REWRITTEN, whatever position it holds in the
2081
+ # group. Its story plan was derived from this body, and
2082
+ # rewriting the body leaves the three disagreeing with nothing to say so —
2083
+ # which is the whole reason §5.5 exists. Being second in a group is not a
2084
+ # different situation from being first, so it gets the same answer: its own
2085
+ # follow-on, carrying the ids it already covers of this group.
2086
+ #
2087
+ # That link is also what clears its staleness. ledger-read discounts an
2088
+ # in-flight story once an open story whose `follows` names it cites the same
2089
+ # id; skipping it instead would leave it stale forever and re-offered on
2090
+ # every run.
2091
+ carry2 = [x for x in c["shared"] if x not in held]
2092
+ if not carry2:
2093
+ notes.append("%s was approved and is in flight, but every requirement "
2094
+ "it covers of group [%s] is held by a story left "
2095
+ "untouched; nothing to carry" % (c["story"], g["key"]))
2096
+ continue
2097
+ sid2 = mint()
2098
+ notes.append("%s was approved and is IN FLIGHT, so it is not rewritten; "
2099
+ "%s carries %s forward from it, while %s takes the group's "
2100
+ "unclaimed requirements"
2101
+ % (c["story"], sid2, ", ".join(carry2), carrier_sid))
2102
+ plan.append({"action": "followon", "story": sid2, "follows": c["story"],
2103
+ "path": os.path.join(sdir, sid2 + ".md"),
2104
+ "requirements_csv": ", ".join(carry2), "existing_csv": "",
2105
+ "prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
2106
+ "prior_path": s2["path"],
2107
+ "group": g["key"], "group_title": g["title"],
2108
+ "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2109
+ "rationale": g["rationale"], "spec_version": version})
2110
+ continue
2111
+ notes.append("%s was approved but %s takes the group's unclaimed requirements; "
2112
+ "%s is refreshed against the current spec without gaining ids"
2113
+ % (c["story"], carrier_sid, c["story"]))
2114
+ plan.append({"action": "update", "story": s2["id"], "follows": "",
2115
+ "path": s2["path"],
2116
+ "requirements_csv": ", ".join(s2["cites"]),
2117
+ "existing_csv": ", ".join(s2["cites"]),
2118
+ "prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
2119
+ "prior_path": s2["path"],
2120
+ "group": g["key"], "group_title": g["title"],
2121
+ "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2122
+ "rationale": g["rationale"], "spec_version": version})
2123
+ elif remaining:
2124
+ sid = mint()
2125
+ # A REQUIREMENT WHOSE ONLY STORIES ARE CLOSED RE-ENTERS THE WORK SET, and that is
2126
+ # right: a version row above the closed story's stamp means the contract moved
2127
+ # AFTER it shipped, so what shipped is no longer what the spec asks for. It reads
2128
+ # as duplicated work to anyone who remembers delivering it, so the run names the
2129
+ # story it is re-opening against rather than leaving them to find it.
2130
+ shipped = ["%s (%s)" % (d["id"], ", ".join(x for x in remaining if x in d["cites"]))
2131
+ for d in done_stories if any(x in d["cites"] for x in remaining)]
2132
+ if shipped:
2133
+ notes.append("%s covers requirements already delivered by %s — the spec moved "
2134
+ "after those closed, so this is the change, not a repeat"
2135
+ % (sid, "; ".join(shipped)))
2136
+ plan.append({"action": "create", "story": sid, "follows": "",
2137
+ "path": os.path.join(sdir, sid + ".md"),
2138
+ "requirements_csv": ", ".join(remaining), "existing_csv": "",
2139
+ "prior_csv": "", "prior_title": "", "prior_path": "",
2140
+ "group": g["key"], "group_title": g["title"],
2141
+ "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2142
+ "rationale": g["rationale"], "spec_version": version})
2143
+ else:
2144
+ notes.append("group [%s] (%s) is entirely covered by stories left untouched; "
2145
+ "nothing to write" % (g["key"], g["ids_csv"]))
2146
+
2147
+ # ---- waves: the order these stories can actually be picked up in -------------------
2148
+ # The grouping declared which slices stand behind which; turning that into an order is
2149
+ # arithmetic, so it happens here and not in the prompt — the same grouping always yields
2150
+ # the same waves.
2151
+ #
2152
+ # A WAVE IS AN ELIGIBILITY LEVEL, NOT A SCHEDULE. Wave N means every story this one depends
2153
+ # on sits in a wave below N. Two stories in the SAME wave therefore have no dependency
2154
+ # between them and may be picked up in parallel; it does not say they start together, and
2155
+ # nothing waits for a whole wave to finish. That is also why the number is not a priority:
2156
+ # wave 1 is "nothing blocks this", not "do this first".
2157
+ #
2158
+ # THE EDGE SET IS A UNION of what the grouping declared and what an updated story already
2159
+ # carries in `depends_on:`. The sync sees one feature's spec; an edge someone added by hand
2160
+ # may name work it has no view of, and dropping it would silently un-order real work. Both
2161
+ # are claims about CODE ordering, and the union is exactly what the story ends up citing —
2162
+ # so the wave and the frontmatter cannot disagree about what stands in front of what.
2163
+ #
2164
+ # `follows:` is NOT an edge. A follow-on carries the remainder of a story in flight, and the
2165
+ # remainder is often buildable alongside it — §5.5 asks the author to say in Notes which
2166
+ # half has to land first. Making it an implicit edge would put an ordering in the waves that
2167
+ # no frontmatter field explains.
2168
+ entries_of, by_story = {}, {}
2169
+ for e in plan:
2170
+ entries_of.setdefault(e["group"], []).append(e["story"]); by_story[e["story"]] = e
2171
+ preds = {e["story"]: set() for e in plan}
2172
+ external, unresolved = {}, [] # stories outside this run, at the wave they declare
2173
+
2174
+ for g in groups:
2175
+ tgt = entries_of.get(g["key"], [])
2176
+ if not tgt: continue # the group wrote nothing; its edges order nothing
2177
+ for tok in g["deps_raw"]:
2178
+ tok = tok.strip()
2179
+ if not tok or tok == g["key"]: continue
2180
+ if tok in entries_of:
2181
+ src = entries_of[tok]
2182
+ elif tok in by_id and by_id[tok]["status"] == "open":
2183
+ external[tok] = by_id[tok]["wave"]; src = [tok]
2184
+ else:
2185
+ unresolved.append("%s (named by group [%s])" % (tok, g["key"])); continue
2186
+ for x in tgt: preds[x].update(s for s in src if s != x)
2187
+
2188
+ for e in plan:
2189
+ if e["action"] != "update": continue
2190
+ for d in by_id.get(e["story"], {}).get("deps", []):
2191
+ if d == e["story"] or d in preds[e["story"]]: continue
2192
+ if d not in preds: external[d] = by_id[d]["wave"] if d in by_id else 1
2193
+ preds[e["story"]].add(d)
2194
+
2195
+ # A CYCLE IS REJECTED AT AUTHORING TIME (§6.1), and this is where it would be authored.
2196
+ # Depth-first over a sorted node order, dropping the one edge that closes each cycle: only
2197
+ # edges that actually close one are lost, and the choice is deterministic — the same
2198
+ # grouping always breaks the same edge. Reported, because a dropped edge was a real claim
2199
+ # about ordering and the cycle it sat in is a fact about the slicing, not a glitch.
2200
+ cycles, state = [], {}
2201
+ def walk(node):
2202
+ state[node] = 1
2203
+ for m in sorted(preds.get(node, ())):
2204
+ if state.get(m) == 1:
2205
+ preds[node].discard(m); cycles.append("%s -> %s" % (node, m)); continue
2206
+ if state.get(m) != 2 and m in preds: walk(m)
2207
+ state[node] = 2
2208
+ for node in sorted(preds):
2209
+ if state.get(node) != 2: walk(node)
2210
+
2211
+ # Longest path from a root, relaxed to a fixed point. A story with no dependency is wave 1,
2212
+ # which is what puts every independent story in the same wave.
2213
+ wave = dict(external)
2214
+ for _ in range(len(preds) + 1):
2215
+ moved = False
2216
+ for node in sorted(preds):
2217
+ w = 1 + max([wave.get(m, 1) for m in preds[node]] or [0])
2218
+ if wave.get(node) != w: wave[node] = w; moved = True
2219
+ if not moved: break
2220
+ for e in plan:
2221
+ e["wave"] = wave.get(e["story"], 1)
2222
+ e["depends_on_csv"] = ", ".join(sorted(preds.get(e["story"], ())))
2223
+
2224
+ # Written in wave order, so a run that meets the cap writes the stories that can be STARTED
2225
+ # rather than an arbitrary prefix of them. The story id is not the order — the wave is.
2226
+ plan.sort(key=lambda e: (e["wave"], e["story"]))
2227
+ for i, e in enumerate(plan): e["seq"] = i + 1
2228
+
2229
+ waves = {}
2230
+ for e in plan: waves.setdefault(e["wave"], []).append(e["story"])
2231
+ wave_digest = "\n".join(" wave %d: %s" % (w, ", ".join(waves[w]))
2232
+ for w in sorted(waves)) or " (nothing to write)"
2233
+ if unresolved:
2234
+ notes.append("the grouping named dependencies matching no slice and no open story, so "
2235
+ "they set no order: %s" % "; ".join(sorted(set(unresolved))))
2236
+ if cycles:
2237
+ notes.append("slices were declared to depend on each other in a cycle; the edge closing "
2238
+ "each one is dropped, because a backlog with no order at all is worse than "
2239
+ "one missing an edge someone can re-add: %s" % ", ".join(cycles))
2240
+
2241
+ # THE WRITE LOOP HAS A CEILING AND THE ENGINE DOES NOT SAY WHEN IT HITS IT — `over:` stops
2242
+ # at max_iterations with no message, so plan entries past it are simply never written and
2243
+ # the run reports success. The caller passes its own ceiling here so the overflow is named
2244
+ # while the story ids still exist to name. Nothing is dropped from the plan: the ids are
2245
+ # reported and the entries stay, so raising the cap and re-running writes them.
2246
+ try:
2247
+ cap = int((os.environ.get("AW_CAP") or "").strip() or 0)
2248
+ except ValueError:
2249
+ cap = 0
2250
+ overflow = [e["story"] for e in plan[cap:]] if cap > 0 else []
2251
+
2252
+ counts = {a: sum(1 for e in plan if e["action"] == a) for a in ("create", "update", "followon")}
2253
+ base.update(plan=plan, plan_count=len(plan), any_plan=bool(plan),
2254
+ cap=cap, overflow_csv=", ".join(overflow), overflow_count=len(overflow),
2255
+ create_count=counts["create"], update_count=counts["update"],
2256
+ followon_count=counts["followon"],
2257
+ skipped_csv=", ".join(sorted(set(skipped))),
2258
+ stories_csv=", ".join(e["story"] for e in plan),
2259
+ wave_digest=wave_digest, wave_count=len(waves),
2260
+ max_wave=(max(waves) if waves else 0),
2261
+ cycle_csv=", ".join(cycles),
2262
+ unresolved_deps_csv=", ".join(sorted(set(unresolved))),
2263
+ notes=notes, notes_text="\n".join(" - " + n for n in notes),
2264
+ plan_digest="\n".join(
2265
+ " w%-3d %-9s %-16s %s%s%s"
2266
+ % (e["wave"], e["action"], e["story"], e["requirements_csv"],
2267
+ (" (follows %s)" % e["follows"]) if e["follows"] else "",
2268
+ (" [after %s]" % e["depends_on_csv"]) if e["depends_on_csv"] else "")
2269
+ for e in plan) or " (nothing to write)")
2270
+ print(json.dumps(base))
2271
+ '@
2272
+
2273
+ function Invoke-BacklogPlan {
2274
+ param([string[]]$Rest)
2275
+ $fdArg = ''; $groups = ''; $approved = ''; $work = ''; $version = ''; $mode = 'scan'; $cap = ''
2276
+ $strategy = ''; $ceiling = ''
2277
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
2278
+ switch ($Rest[$i]) {
2279
+ '--feature-dir' { $fdArg = $Rest[++$i] }
2280
+ '--groups' { $groups = $Rest[++$i] }
2281
+ '--approved' { $approved = $Rest[++$i] }
2282
+ '--work' { $work = $Rest[++$i] }
2283
+ '--version' { $version = $Rest[++$i] }
2284
+ '--cap' { $cap = $Rest[++$i] }
2285
+ '--strategy' { $strategy = $Rest[++$i] }
2286
+ '--ceiling' { $ceiling = $Rest[++$i] }
2287
+ '--plan' { $mode = 'plan' }
2288
+ }
2289
+ }
2290
+ $repo = Get-RepoRoot
2291
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2292
+ if (-not $fd) { Write-Error 'backlog-plan: no feature set and no --feature-dir'; exit 1 }
2293
+ $py = Get-Py; if (-not $py) { Write-Error 'backlog-plan: python3 required'; exit 1 }
2294
+ $env:AW_FD = $fd; $env:AW_GROUPS = $groups; $env:AW_OK = $approved
2295
+ $env:AW_WORK = $work; $env:AW_VER = $version; $env:AW_MODE = $mode; $env:AW_CAP = $cap
2296
+ $env:AW_STRATEGY = $strategy; $env:AW_CEILING = $ceiling
2297
+ $PyBacklogPlan | & $py -
2298
+ }
2299
+
2300
+ # ─────────────────────────────── story-stamp ───────────────────────────────
2301
+ # The story's frontmatter is the gate surface — `exercises:`, `spec_version:`, `status:`
2302
+ # and `follows:` are what spec-coverage, check-stories, analyze-slice and close-story read,
2303
+ # and none of them opens the body. So those fields are OBSERVED and written by the kit, not
2304
+ # declared by the model that wrote the prose, for the same reason the Change History row is
2305
+ # computed rather than announced: a model that drops one id from `exercises:` produces a
2306
+ # story that reads perfectly and is invisible to every gate built to protect it. Silent,
2307
+ # and in the one field where silence costs the most.
2308
+ #
2309
+ # Everything else in the block is left exactly as the author wrote it, and a key the file
2310
+ # is missing is appended in template order rather than the block being rebuilt — a story
2311
+ # carrying a work_item or a depends_on must not lose it to a stamp.
2312
+ $PyStoryStamp = @'
2313
+ import json, os, re, sys, datetime
2314
+ fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
2315
+ path = os.path.join(fd, "stories", story + ".md")
2316
+
2317
+ def bail(msg, code=2):
2318
+ print(json.dumps({"ok": False, "story": story, "path": path, "error": msg}))
2319
+ sys.stderr.write(msg + "\n"); sys.exit(code)
2320
+
2321
+ if not os.path.isfile(path):
2322
+ bail("story-stamp: no story file at %s — the authoring step did not write it" % path)
2323
+
2324
+ def rids(s):
2325
+ seen, out = set(), []
2326
+ for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
2327
+ if t not in seen: seen.add(t); out.append(t)
2328
+ return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2329
+
2330
+ def sids(s):
2331
+ """Story ids out of a csv or an inline list. A story id is a feature name and a number;
2332
+ the template's placeholders are neither, and must not become a dependency edge."""
2333
+ seen, out = set(), []
2334
+ for t in re.split(r'[,\s\[\]"\']+', s or ""):
2335
+ t = t.strip()
2336
+ if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
2337
+ seen.add(t); out.append(t)
2338
+ return out
2339
+
2340
+ text = open(path, encoding="utf-8", errors="replace").read()
2341
+ m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
2342
+ if m:
2343
+ block, body = m.group(1), text[m.end():]
2344
+ else:
2345
+ # A model that returned the prose and no frontmatter leaves a story every gate skips.
2346
+ # Synthesised rather than refused: the body is the expensive half and it is already on
2347
+ # disk, and the fields below are exactly the ones this op is authoritative for.
2348
+ block, body = "", text.lstrip("\n")
2349
+
2350
+ # The block as an ordered list of (key, rendered line), so an unrecognised key and its
2351
+ # continuation lines survive untouched.
2352
+ lines = block.split("\n") if block else []
2353
+ order, seen_key = [], None
2354
+ for line in lines:
2355
+ km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
2356
+ if km:
2357
+ seen_key = km.group(1); order.append([seen_key, [line]])
2358
+ elif order and (line.startswith(" ") or line.startswith("\t")):
2359
+ order[-1][1].append(line) # a block-list item under the key above
2360
+ elif line.strip():
2361
+ order.append([None, [line]])
2362
+
2363
+ def put(key, value):
2364
+ """Set a key to a single-line value, collapsing a block list into the inline form."""
2365
+ line = ("%s: %s" % (key, value)) if value != "" else ("%s:" % key)
2366
+ for entry in order:
2367
+ if entry[0] == key:
2368
+ entry[1] = [line]
2369
+ return "set"
2370
+ order.append([key, [line]])
2371
+ return "added"
2372
+
2373
+ TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "spec_version",
2374
+ "depends_on", "wave", "follows", "work_item", "created"]
2375
+ def get(key):
2376
+ for entry in order:
2377
+ if entry[0] == key: return entry[1][0].split(":", 1)[1].strip()
2378
+ return ""
2379
+
2380
+ changed = {}
2381
+ changed["id"] = put("id", story)
2382
+
2383
+ if os.environ.get("AW_IDS_GIVEN"):
2384
+ ids = rids(os.environ.get("AW_IDS", ""))
2385
+ changed["exercises"] = put("exercises", "[" + ", ".join(ids) + "]")
2386
+ else:
2387
+ ids = rids(get("exercises"))
2388
+
2389
+ ver = (os.environ.get("AW_VER") or "").strip()
2390
+ if ver: changed["spec_version"] = put("spec_version", ver)
2391
+
2392
+ fol = (os.environ.get("AW_FOLLOWS") or "").strip()
2393
+ if fol: changed["follows"] = put("follows", fol)
2394
+
2395
+ ttl = (os.environ.get("AW_TITLE") or "").strip()
2396
+ if ttl and (not get("title") or get("title").startswith("[")):
2397
+ changed["title"] = put("title", ttl)
2398
+
2399
+ # Placeholders the template ships. Left alone once a real value is in them, so a story
2400
+ # CLOSED between the plan and the write is not quietly re-opened by a stamp.
2401
+ if not get("type") or get("type").startswith("["):
2402
+ changed["type"] = put("type", "functional" if ids else "technical")
2403
+ if not get("status") or get("status").startswith("["):
2404
+ changed["status"] = put("status", "open")
2405
+ # ORDERING IS A UNION, NEVER A REPLACEMENT. The edges passed in are what this run's slicing
2406
+ # declared; the ones already in the file may name work outside this feature, which the sync
2407
+ # cannot see. Dropping either loses a real claim about what has to land first, and a lost
2408
+ # edge is silent — no later stage re-derives it.
2409
+ deps = sorted(set(sids(get("depends_on")) + sids(os.environ.get("AW_DEPS", ""))) - {story})
2410
+ if deps or not get("depends_on") or get("depends_on").startswith("["):
2411
+ changed["depends_on"] = put("depends_on", "[" + ", ".join(deps) + "]")
2412
+
2413
+ # THE WAVE IS AN ELIGIBILITY LEVEL, NOT A PRIORITY: every story this one depends on sits in
2414
+ # a lower one, so stories sharing a wave have no ordering between them and may be picked up
2415
+ # in parallel. Computed by backlog-plan from `depends_on`, and written here for the same
2416
+ # reason `exercises:` is — a field the gates and the listings read is observed, not declared.
2417
+ # A story stamped without one floors at 1: nothing is KNOWN to have to land before it.
2418
+ wv = (os.environ.get("AW_WAVE") or "").strip()
2419
+ if wv.isdigit():
2420
+ changed["wave"] = put("wave", wv)
2421
+ elif not get("wave").isdigit():
2422
+ changed["wave"] = put("wave", "1")
2423
+ if not get("created") or get("created").startswith("["):
2424
+ changed["created"] = put("created", datetime.date.today().isoformat())
2425
+ for k in ("title", "follows", "work_item"):
2426
+ if not any(e[0] == k for e in order): put(k, "")
2427
+
2428
+ # Rendered back in template order, with anything unrecognised kept after it in the order it
2429
+ # was written. A story is read by people as well as by gates.
2430
+ rank = {k: i for i, k in enumerate(TEMPLATE_ORDER)}
2431
+ order.sort(key=lambda e: rank.get(e[0], len(TEMPLATE_ORDER)))
2432
+ out_block = "\n".join(l for e in order for l in e[1])
2433
+ open(path, "w", encoding="utf-8").write("---\n" + out_block + "\n---\n\n" + body.lstrip("\n"))
2434
+
2435
+ print(json.dumps({"ok": True, "story": story, "path": path,
2436
+ "exercises": ids, "exercises_csv": ", ".join(ids),
2437
+ "spec_version": get("spec_version"), "status": get("status"),
2438
+ "depends_on": get("depends_on"), "wave": get("wave"),
2439
+ "follows": get("follows"), "title": get("title"),
2440
+ "type": get("type"), "had_frontmatter": bool(m),
2441
+ "fields": sorted(changed)}))
2442
+ '@
2443
+
2444
+ function Invoke-StoryStamp {
2445
+ param([string[]]$Rest)
2446
+ $fdArg = ''; $story = ''; $ids = ''; $version = ''; $follows = ''; $idsGiven = ''; $title = ''
2447
+ $deps = ''; $wave = ''
2448
+ for ($i = 0; $i -lt $Rest.Count; $i++) {
2449
+ switch ($Rest[$i]) {
2450
+ '--feature-dir' { $fdArg = $Rest[++$i] }
2451
+ '--story' { $story = $Rest[++$i] }
2452
+ '--ids' { $ids = $Rest[++$i]; $idsGiven = '1' }
2453
+ '--version' { $version = $Rest[++$i] }
2454
+ '--follows' { $follows = $Rest[++$i] }
2455
+ '--title' { $title = $Rest[++$i] }
2456
+ '--depends-on' { $deps = $Rest[++$i] }
2457
+ '--wave' { $wave = $Rest[++$i] }
2458
+ }
2459
+ }
2460
+ if (-not $story) { Write-Error 'story-stamp: --story is required'; exit 1 }
2461
+ $repo = Get-RepoRoot
2462
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2463
+ if (-not $fd) { Write-Error 'story-stamp: no feature set and no --feature-dir'; exit 1 }
2464
+ $py = Get-Py; if (-not $py) { Write-Error 'story-stamp: python3 required'; exit 1 }
2465
+ $env:AW_FD = $fd; $env:AW_STORY = $story; $env:AW_IDS = $ids
2466
+ $env:AW_IDS_GIVEN = $idsGiven; $env:AW_VER = $version
2467
+ $env:AW_FOLLOWS = $follows; $env:AW_TITLE = $title
2468
+ $env:AW_DEPS = $deps; $env:AW_WAVE = $wave
2469
+ $PyStoryStamp | & $py -
2470
+ }
2471
+
1366
2472
  $PySpecCoverage = @'
1367
2473
  import json, os, re, sys
1368
2474
  fd = os.environ["AW_FD"]
@@ -1618,6 +2724,8 @@ function Invoke-FeaturePaths {
1618
2724
  $spec = Join-Path $fd 'spec.md'
1619
2725
  $design = Join-Path $fd 'design.md'
1620
2726
  $uiDesign = Join-Path $fd 'design.ui.md'
2727
+ $backendPlan = Join-Path $fd 'backend-plan.md'
2728
+ $frontendPlan = Join-Path $fd 'frontend-plan.md'
1621
2729
  # needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
1622
2730
  # leaves spec.md on disk with the marker still up, and branching on the file alone read
1623
2731
  # that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
@@ -1635,6 +2743,10 @@ function Invoke-FeaturePaths {
1635
2743
  spec_exists = (Test-Path $spec)
1636
2744
  design_exists = (Test-Path $design)
1637
2745
  ui_design_exists = (Test-Path $uiDesign)
2746
+ backend_plan_file = $backendPlan
2747
+ frontend_plan_file = $frontendPlan
2748
+ backend_plan_exists = (Test-Path $backendPlan)
2749
+ frontend_plan_exists = (Test-Path $frontendPlan)
1638
2750
  bootstrap_pending = $bootstrapPending
1639
2751
  needs_bootstrap = ((-not (Test-Path $spec)) -or $bootstrapPending)
1640
2752
  })
@@ -2547,16 +3659,27 @@ function Invoke-DesignScaffold {
2547
3659
  # --lane. In a brownfield repo onboard GENERATES a code-derived override for both
2548
3660
  # names, so the defaults already yield the as-built structure.
2549
3661
  for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--lane','-Lane' } { $lane = $Rest[++$i] } { $_ -in '--template','-Template' } { $tplName = $Rest[++$i] } } }
3662
+ # backend/frontend are the PLAN-phase pairs: the same templates, the plan document
3663
+ # names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the
3664
+ # plan name so a feature never carries both spellings of the same master.
3665
+ # service/ui stay byte-identical for their existing callers (adopt, the delta chain).
3666
+ $legacyName = ''
2550
3667
  switch ($lane) {
2551
- 'service' { $docName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
2552
- 'ui' { $docName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
2553
- default { Write-Error ("design-scaffold: unknown --lane '" + $lane + "' (service|ui)"); exit 1 }
3668
+ 'service' { $docName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
3669
+ 'ui' { $docName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
3670
+ 'backend' { $docName = 'backend-plan.md'; $legacyName = 'design.md'; if (-not $tplName) { $tplName = 'design-template' } }
3671
+ 'frontend' { $docName = 'frontend-plan.md'; $legacyName = 'design.ui.md'; if (-not $tplName) { $tplName = 'ui-design-template' } }
3672
+ default { Write-Error ("design-scaffold: unknown --lane '" + $lane + "' (service|ui|backend|frontend)"); exit 1 }
2554
3673
  }
2555
3674
  $repo = Get-RepoRoot
2556
3675
  $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
2557
3676
  if (-not $fd) { Write-Error 'design-scaffold: no feature set (run /appweaver select-feature first) and no --feature-dir'; exit 1 }
2558
3677
  New-Item -ItemType Directory -Force -Path $fd | Out-Null
2559
3678
  $target = Join-Path $fd $docName
3679
+ $migrated = $false
3680
+ if ((-not (Test-Path $target)) -and $legacyName -and (Test-Path (Join-Path $fd $legacyName))) {
3681
+ Move-Item (Join-Path $fd $legacyName) $target; $migrated = $true
3682
+ }
2560
3683
  if (Test-Path $target) { $created = $false }
2561
3684
  else {
2562
3685
  $tpl = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
@@ -2570,7 +3693,833 @@ function Invoke-DesignScaffold {
2570
3693
  $overrideActive = Test-Path (Join-Path $repo (".appweaver/templates/overrides/" + $tplName + ".md"))
2571
3694
  $templateSource = Resolve-NamedTemplate -Name $tplName -RepoRoot $repo
2572
3695
  $tsOut = if ($templateSource) { $templateSource } else { '' }
2573
- Write-Json ([ordered]@{ feature_dir = $fd; design_doc = $target; lane = $lane; created = $created; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
3696
+ Write-Json ([ordered]@{ feature_dir = $fd; design_doc = $target; lane = $lane; created = $created; migrated = $migrated; override_active = $overrideActive; template = $tplName; template_source = $tsOut })
3697
+ }
3698
+
3699
+
3700
+ # ─────────────────────────────── test-diff ─────────────────────────────────
3701
+ # The TECHNICAL-STORY ORACLE — twin of the bash action; the python payload is
3702
+ # byte-identical by contract.
3703
+ $PyTestDiff = @'
3704
+ import json, os, re, subprocess
3705
+ root = os.environ.get("AW_ROOT") or "."
3706
+ fd = os.environ.get("AW_FD", "")
3707
+ want = (os.environ.get("AW_BASE") or "").strip()
3708
+ agg = os.path.basename(fd.rstrip("/\\"))
3709
+
3710
+ def git(*a):
3711
+ """A git read that never raises. A working copy this command cannot interrogate is a
3712
+ FACT TO REPORT — the caller decides what an unreadable oracle means — never a crash
3713
+ that takes the gate down with it."""
3714
+ try:
3715
+ p = subprocess.run(["git", "-C", root] + list(a), capture_output=True, text=True)
3716
+ except Exception:
3717
+ return 1, ""
3718
+ return p.returncode, (p.stdout or "").strip()
3719
+
3720
+ DIR_RE = re.compile(r'(^|/)(tests?|specs?|__tests__|testing|test-suite)(/|$)', re.I)
3721
+
3722
+ # The kit's OWN trees, which hold documents and configuration and never tests. The spec
3723
+ # tree is derived rather than assumed: `specs/` matches the RSpec directory convention
3724
+ # exactly, so without this every requirement document — and this gate's own verdict file,
3725
+ # written before the diff is taken — counted as a modified test.
3726
+ SKIP = [".appweaver/"]
3727
+ if fd:
3728
+ _rel = os.path.relpath(fd, root).replace("\\", "/")
3729
+ SKIP.append((_rel.rsplit("/", 1)[0] if "/" in _rel else _rel).rstrip("/") + "/")
3730
+
3731
+ def is_test(path):
3732
+ """A path is a TEST when a directory on it says so, or its own filename does — the
3733
+ four conventions every shipped stack uses between them: a tests/ directory, a
3734
+ Name.Tests.cs / NameTest.java suffix, a test_name.py / name_test.go affix, and a
3735
+ name.test.ts / name.spec.ts infix. Deliberately GENEROUS: over-calling a file a test
3736
+ costs a louder message, under-calling one costs a technical story that changed
3737
+ behaviour and passed its own oracle."""
3738
+ p = path.replace("\\", "/")
3739
+ if any(p.startswith(s) for s in SKIP):
3740
+ return False
3741
+ stem, ext = os.path.splitext(os.path.basename(p))
3742
+ # Prose is never a test, wherever it sits. A tests/ directory holds its own README
3743
+ # and a docs tree holds files named for testing; counting either would fail a
3744
+ # technical story over a paragraph.
3745
+ if ext.lower() in (".md", ".markdown", ".txt", ".rst", ".adoc"):
3746
+ return False
3747
+ if DIR_RE.search(os.path.dirname(p)):
3748
+ return True
3749
+ stem = stem.lower()
3750
+ if stem.startswith(("test", "spec")) or stem.endswith(("test", "tests", "spec", "specs")):
3751
+ return True
3752
+ return any(k in stem for k in (".test", ".spec", "_test", "_spec", "-test", "-spec"))
3753
+
3754
+ out = {"ok": True, "aggregate": agg, "git": False, "checked": False,
3755
+ "base": "", "base_ref": "", "base_kind": "none", "files_changed": 0,
3756
+ "tests_changed": [], "tests_changed_csv": "", "tests_changed_count": 0,
3757
+ "tests_deleted": [], "tests_deleted_csv": "",
3758
+ "tests_added": [], "tests_added_csv": "", "tests_added_count": 0,
3759
+ "slice_tests_changed": [], "slice_tests_changed_csv": "", "slice_tests_changed_count": 0,
3760
+ "any_test_changed": False, "clean": True, "oracle": "clean",
3761
+ "note": "", "digest": ""}
3762
+
3763
+ if git("rev-parse", "--is-inside-work-tree")[1] != "true":
3764
+ out["oracle"] = "unverified"
3765
+ out["note"] = ("Not a git working copy, so the test suite's history cannot be read. The "
3766
+ "unchanged-suite oracle is UNVERIFIED for this run — not passed.")
3767
+ out["digest"] = out["note"]
3768
+ print(json.dumps(out)); raise SystemExit(0)
3769
+ out["git"] = out["checked"] = True
3770
+
3771
+ # WHICH BASELINE. An explicit --base wins. Otherwise the fork point from the integration
3772
+ # branch, which is what a story's work sits on top of; falling back to HEAD, which sees
3773
+ # the working tree alone. The kind is reported because it changes what the answer MEANS:
3774
+ # a `head` baseline cannot see a test modified in a commit this branch already made.
3775
+ head = git("rev-parse", "HEAD")[1]
3776
+ if want and git("rev-parse", "--verify", want + "^{commit}")[0] == 0:
3777
+ out["base"], out["base_ref"], out["base_kind"] = git("rev-parse", want)[1], want, "given"
3778
+ if not out["base"]:
3779
+ cands = []
3780
+ rc, ref = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
3781
+ if rc == 0 and ref:
3782
+ cands.append(ref.split("refs/remotes/", 1)[-1])
3783
+ cands += ["origin/main", "origin/master", "main", "master"]
3784
+ seen = set()
3785
+ for c in cands:
3786
+ if c in seen:
3787
+ continue
3788
+ seen.add(c)
3789
+ if git("rev-parse", "--verify", c + "^{commit}")[0] != 0:
3790
+ continue
3791
+ rc, mb = git("merge-base", "HEAD", c)
3792
+ if rc != 0 or not mb:
3793
+ continue
3794
+ out["base"], out["base_ref"] = mb, c
3795
+ out["base_kind"] = "head" if mb == head else "merge-base"
3796
+ break
3797
+ if not out["base"]:
3798
+ out["base"], out["base_kind"] = head or "HEAD", "head"
3799
+
3800
+ rc, diff = git("diff", "--name-status", "--find-renames", out["base"])
3801
+ changed, added, deleted, files = [], [], [], 0
3802
+ for line in diff.splitlines():
3803
+ parts = line.split("\t")
3804
+ if len(parts) < 2:
3805
+ continue
3806
+ code, paths = parts[0].strip(), parts[1:]
3807
+ files += 1
3808
+ hits = [p for p in paths if is_test(p)]
3809
+ if not hits:
3810
+ continue
3811
+ p = hits[-1]
3812
+ if code.startswith("A") or code.startswith("C"):
3813
+ # A NEW test is not a changed one. A refactor that adds characterisation tests is
3814
+ # exactly the work a technical story exists to do.
3815
+ added.append(p)
3816
+ elif code.startswith("R"):
3817
+ # A pure rename moves a test without changing what it asserts; anything less than
3818
+ # a 100% match carries an edit inside it.
3819
+ (added if code == "R100" else changed).append(p)
3820
+ elif code.startswith("D"):
3821
+ deleted.append(p); changed.append(p)
3822
+ else:
3823
+ changed.append(p)
3824
+
3825
+ # Untracked files are ADDITIONS the diff cannot see — reported as such so a new test
3826
+ # written in the working tree is not mistaken for a missing one.
3827
+ for p in git("ls-files", "--others", "--exclude-standard")[1].splitlines():
3828
+ p = p.strip()
3829
+ if p and is_test(p) and p not in added:
3830
+ added.append(p); files += 1
3831
+
3832
+ changed = sorted(set(changed)); added = sorted(set(added)); deleted = sorted(set(deleted))
3833
+ low = agg.lower()
3834
+ slice_changed = [p for p in changed if low and low in p.lower()]
3835
+ out.update(files_changed=files,
3836
+ tests_changed=changed, tests_changed_csv=", ".join(changed),
3837
+ tests_changed_count=len(changed),
3838
+ tests_deleted=deleted, tests_deleted_csv=", ".join(deleted),
3839
+ tests_added=added, tests_added_csv=", ".join(added), tests_added_count=len(added),
3840
+ slice_tests_changed=slice_changed, slice_tests_changed_csv=", ".join(slice_changed),
3841
+ slice_tests_changed_count=len(slice_changed),
3842
+ any_test_changed=bool(changed), clean=not changed,
3843
+ # The DIFF's state in one word — clean | changed | unverified — so a caller can
3844
+ # branch on it without recombining a boolean with a "could I even look" flag.
3845
+ # It says what the suite did, never what that means for a story.
3846
+ oracle="changed" if changed else "clean")
3847
+
3848
+ kindnote = {"given": "the ref given on the command line",
3849
+ "merge-base": "the fork point from %s — every commit this branch added is in view" % out["base_ref"],
3850
+ "head": "HEAD — only uncommitted work is in view, so a test modified in a commit "
3851
+ "already made on this branch is NOT counted"}[out["base_kind"]]
3852
+ lines = ["Test-suite diff against %s (%s): %s" % (out["base"][:12], out["base_kind"], kindnote),
3853
+ " files changed: %d" % files,
3854
+ " tests MODIFIED or DELETED (%d): %s" % (len(changed), ", ".join(changed) or "none"),
3855
+ " tests ADDED (%d): %s" % (len(added), ", ".join(added) or "none")]
3856
+ if slice_changed:
3857
+ lines.append(" of those, on this aggregate (%s): %s" % (agg, ", ".join(slice_changed)))
3858
+ out["note"] = kindnote
3859
+ out["digest"] = "\n".join(lines)
3860
+ print(json.dumps(out))
3861
+ '@
3862
+
3863
+ function Invoke-TestDiff {
3864
+ param([string[]]$Rest)
3865
+ $fdArg = ''; $base = ''
3866
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--base','-Base' } { $base = $Rest[++$i] } } }
3867
+ $py = Get-Py; if (-not $py) { Write-Error 'test-diff: python3 required'; exit 1 }
3868
+ $repo = Get-RepoRoot
3869
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
3870
+ if (-not $fd) { $fd = '' }
3871
+ $env:AW_ROOT = $repo; $env:AW_FD = $fd; $env:AW_BASE = $base
3872
+ $PyTestDiff | & $py -
3873
+ Remove-Item Env:AW_ROOT, Env:AW_FD, Env:AW_BASE -ErrorAction SilentlyContinue
3874
+ }
3875
+
3876
+ # ─────────────────────────── plan-check / plan-stamp ────────────────────────
3877
+ # The plan documents' derivation tracking — twin of the bash implementation; the
3878
+ # python payloads are IDENTICAL, so the two shells cannot drift. Each plan doc
3879
+ # derives from ONE spec version and carries its OWN track file beside the spec
3880
+ # (.aw-plan-track.backend-plan.json / .aw-plan-track.frontend-plan.json) recording
3881
+ # {spec_version, sha256}, so the two lanes' stamps never share a write. plan-check
3882
+ # reads the record back against the spec's Change History ledger; plan-stamp is the
3883
+ # only writer. Both always exit 0 with an ok/state field — the workflow's
3884
+ # verify_context owns the stop.
3885
+ $PyPlanCheck = @'
3886
+ import json, os, re, sys, hashlib
3887
+ doc = os.environ["AW_DOC"]; fd = os.environ["AW_FD"]
3888
+ spec = os.path.join(fd, "spec.md")
3889
+ # One track file per plan document (.aw-plan-track.backend-plan.json /
3890
+ # .aw-plan-track.frontend-plan.json) — parallel lane stamps never contend on a shared file.
3891
+ stem = os.path.splitext(os.path.basename(doc))[0]
3892
+ track_path = os.path.join(fd, ".aw-plan-track.%s.json" % stem)
3893
+ def vtuple(v):
3894
+ try: return tuple(int(x) for x in v.lstrip("vV").split("."))
3895
+ except Exception: return (0,)
3896
+ out = {"doc": doc, "doc_exists": os.path.isfile(doc), "state": "", "derived_version": "",
3897
+ "current_version": "", "changed_csv": "", "changed_summary": "", "body_changed": False}
3898
+ # Current version = the widest of the ledger's rows and the **Version** header — the
3899
+ # same widest-wins rule ledger-append uses, so a hand-moved header cannot hide a row.
3900
+ rows = []; cur = ""; idcol = -1
3901
+ if os.path.isfile(spec):
3902
+ text = open(spec, encoding="utf-8", errors="replace").read()
3903
+ m = re.search(r'^##\s+Change History\s*$', text, re.M)
3904
+ if m:
3905
+ for line in text[m.end():].splitlines():
3906
+ if idcol < 0 and line.lstrip().startswith("|") and "requirements changed" in line.lower():
3907
+ hdr = [c.strip().lower() for c in line.strip().strip("|").split("|")]
3908
+ idcol = next((i for i, c in enumerate(hdr) if "requirements changed" in c), -1)
3909
+ rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
3910
+ if rm: rows.append((rm.group(1), line))
3911
+ hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
3912
+ cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
3913
+ if cands: cur = max(cands, key=vtuple)
3914
+ out["current_version"] = cur
3915
+ if not out["doc_exists"]:
3916
+ out["state"] = "fresh"; print(json.dumps(out)); sys.exit(0)
3917
+ try: track = json.load(open(track_path, encoding="utf-8"))
3918
+ except Exception: track = {}
3919
+ entry = track.get(os.path.basename(doc))
3920
+ if not entry:
3921
+ # A document with no derivation record — a migrated legacy master, or a baseline
3922
+ # lost by hand. There is no stamp to diff against, so the caller reconciles the
3923
+ # WHOLE document once and plan-stamp baselines it.
3924
+ out["state"] = "untracked"; print(json.dumps(out)); sys.exit(0)
3925
+ derived = entry.get("spec_version", "")
3926
+ 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", "")
3929
+ moved = [r for r in rows if vtuple(r[0]) > vtuple(derived)]
3930
+ # Scrape ONLY the ledger's "Requirements changed" cell when it names one. The
3931
+ # Stories cell holds ids of the form <Aggregate>-nnn, which read as requirement ids
3932
+ # whenever the aggregate is spelled in caps (EDI-001, HR-002) — those would arrive
3933
+ # as requirements for the plan to conform, and there is no such requirement.
3934
+ def _scope(line):
3935
+ if idcol >= 0:
3936
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
3937
+ if idcol < len(cells): return cells[idcol]
3938
+ return line
3939
+ ids = sorted({i for _v, line in moved for i in re.findall(r'[A-Z]{2}-\d+', _scope(line))})
3940
+ out["changed_csv"] = ", ".join(ids)
3941
+ out["changed_summary"] = " // ".join(line.strip() for _v, line in moved)
3942
+ behind = bool(moved) or bool(cur and vtuple(cur) > vtuple(derived))
3943
+ if out["body_changed"] and behind: out["state"] = "edited_behind"
3944
+ elif out["body_changed"]: out["state"] = "edited"
3945
+ elif behind: out["state"] = "behind"
3946
+ else: out["state"] = "clean"
3947
+ print(json.dumps(out))
3948
+ '@
3949
+
3950
+ $PyPlanStamp = @'
3951
+ import json, os, re, sys, hashlib, datetime
3952
+ doc = os.environ["AW_DOC"]; fd = os.environ["AW_FD"]
3953
+ ids = os.environ.get("AW_IDS", "").strip(); note = os.environ.get("AW_NOTE", "").strip()
3954
+ no_row = os.environ.get("AW_NOROW", "false") == "true"
3955
+ spec = os.path.join(fd, "spec.md")
3956
+ # One track file per plan document (.aw-plan-track.backend-plan.json /
3957
+ # .aw-plan-track.frontend-plan.json) — parallel lane stamps never contend on a shared file.
3958
+ stem = os.path.splitext(os.path.basename(doc))[0]
3959
+ track_path = os.path.join(fd, ".aw-plan-track.%s.json" % stem)
3960
+ def vtuple(v):
3961
+ try: return tuple(int(x) for x in v.lstrip("vV").split("."))
3962
+ except Exception: return (0,)
3963
+ if not os.path.isfile(doc):
3964
+ print(json.dumps({"ok": False, "error": "plan document %s does not exist" % doc, "version": ""})); sys.exit(0)
3965
+ cur = ""
3966
+ if os.path.isfile(spec):
3967
+ st = open(spec, encoding="utf-8", errors="replace").read()
3968
+ m = re.search(r'^##\s+Change History\s*$', st, re.M)
3969
+ rows = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', st[m.end():] if m else "", re.M)
3970
+ hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', st, re.M)
3971
+ cands = rows + ([hv.group(1)] if hv else [])
3972
+ if cands: cur = max(cands, key=vtuple)
3973
+ text = open(doc, encoding="utf-8", errors="replace").read()
3974
+ # The ledger-survival fence: a plan whose update dropped the Change History section is
3975
+ # NOT baselined — a lost ledger silently baselined over could never be noticed again.
3976
+ hist = re.search(r'^##+\s*(?:\d+\.\s*)?Change History\s*$', text, re.M)
3977
+ if not hist:
3978
+ print(json.dumps({"ok": False, "version": cur,
3979
+ "error": "the plan document has no 'Change History' section — the write dropped the ledger; nothing was baselined"}))
3980
+ sys.exit(0)
3981
+ warnings = []
3982
+ if not re.search(r'^##+.*Assumptions', text, re.M):
3983
+ warnings.append("no 'Assumptions' section found")
3984
+ if not re.search(r'^##+.*(Workarounds|Deviations)', text, re.M):
3985
+ warnings.append("no 'Workarounds/Deviations' section found")
3986
+ row_txt = ""
3987
+ if not no_row:
3988
+ # Bounded to the Change History section; the row's cell count follows the section's
3989
+ # own table so the append fits the greenfield, UI and onboard-generated shapes alike.
3990
+ lines = text.split("\n")
3991
+ hline = text[:hist.start()].count("\n")
3992
+ end = len(lines)
3993
+ for i in range(hline + 1, len(lines)):
3994
+ if re.match(r'^##', lines[i]): end = i; break
3995
+ tbl = [i for i in range(hline + 1, end) if lines[i].lstrip().startswith("|")]
3996
+ ncells = 5
3997
+ if tbl: ncells = max(3, lines[tbl[0]].strip().strip("|").count("|") + 1)
3998
+ change = note or ("Conformed to spec %s" % (cur or "(unversioned)"))
3999
+ if ids: change = "%s — %s" % (change, ids)
4000
+ change = change.replace("|", "/").replace("\n", " ")
4001
+ cells = [cur or "v?", datetime.date.today().isoformat(), change] + ["—"] * (ncells - 3)
4002
+ row_txt = "| " + " | ".join(cells[:ncells]) + " |"
4003
+ lines.insert((tbl[-1] + 1) if tbl else end, row_txt)
4004
+ text = "\n".join(lines)
4005
+ if cur:
4006
+ text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + cur, text, count=1, flags=re.M)
4007
+ open(doc, "w", encoding="utf-8").write(text)
4008
+ try: track = json.load(open(track_path, encoding="utf-8"))
4009
+ except Exception: track = {}
4010
+ track[os.path.basename(doc)] = {
4011
+ "spec_version": cur,
4012
+ "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
4013
+ "updated": datetime.datetime.now().isoformat(timespec="seconds")}
4014
+ open(track_path, "w", encoding="utf-8").write(json.dumps(track, indent=2) + "\n")
4015
+ print(json.dumps({"ok": True, "version": cur, "row": row_txt, "warnings": warnings}))
4016
+ '@
4017
+
4018
+ function Invoke-PlanCheck {
4019
+ param([string[]]$Rest)
4020
+ $fdArg = ''; $doc = ''
4021
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--doc','-Doc' } { $doc = $Rest[++$i] } } }
4022
+ if (-not $doc) { Write-Error 'plan-check: --doc required'; exit 1 }
4023
+ $py = Get-Py; if (-not $py) { Write-Error 'plan-check: python3 required'; exit 1 }
4024
+ $repo = Get-RepoRoot
4025
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4026
+ if (-not $fd) { Write-Error 'plan-check: no feature set and no --feature-dir'; exit 1 }
4027
+ $env:AW_DOC = $doc; $env:AW_FD = $fd
4028
+ $PyPlanCheck | & $py -
4029
+ Remove-Item Env:AW_DOC, Env:AW_FD -ErrorAction SilentlyContinue
4030
+ }
4031
+
4032
+ function Invoke-PlanStamp {
4033
+ param([string[]]$Rest)
4034
+ $fdArg = ''; $doc = ''; $ids = ''; $note = ''; $noRow = 'false'
4035
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--doc','-Doc' } { $doc = $Rest[++$i] } { $_ -in '--ids','-Ids' } { $ids = $Rest[++$i] } { $_ -in '--note','-Note' } { $note = $Rest[++$i] } { $_ -in '--no-row','-NoRow' } { $noRow = 'true' } } }
4036
+ if (-not $doc) { Write-Error 'plan-stamp: --doc required'; exit 1 }
4037
+ $py = Get-Py; if (-not $py) { Write-Error 'plan-stamp: python3 required'; exit 1 }
4038
+ $repo = Get-RepoRoot
4039
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4040
+ if (-not $fd) { Write-Error 'plan-stamp: no feature set and no --feature-dir'; exit 1 }
4041
+ $env:AW_DOC = $doc; $env:AW_FD = $fd; $env:AW_IDS = $ids; $env:AW_NOTE = $note; $env:AW_NOROW = $noRow
4042
+ $PyPlanStamp | & $py -
4043
+ Remove-Item Env:AW_DOC, Env:AW_FD, Env:AW_IDS, Env:AW_NOTE, Env:AW_NOROW -ErrorAction SilentlyContinue
4044
+ }
4045
+
4046
+ # ───────────────────────────────── gate-result ──────────────────────────────
4047
+ # The machine-readable verdict every analyze gate writes beside its markdown
4048
+ # report. CI cannot branch on prose, and a verdict is exactly the kind of dated,
4049
+ # derived record the model must never write — so the script writes it.
4050
+ #
4051
+ # Shared by design: analyze-design writes one per feature, and analyze-slice /
4052
+ # analyze-code will write one per story and per run. The CORE keys are fixed
4053
+ # (gate, status, feature, spec_version, story, gap_count, gap_refs, report,
4054
+ # generated_at) so a pipeline can read any gate's result with one expression;
4055
+ # everything gate-specific arrives through repeatable --field k=v and lands under
4056
+ # "fields", where it cannot destabilise that core.
4057
+ #
4058
+ # NOTE the returned `ok` means THE FILE WAS WRITTEN. The pass/fail verdict is
4059
+ # `status` — binding a workflow gate to `ok` would pass on every run.
4060
+ $PyGateResult = @'
4061
+ import json, os, sys, datetime
4062
+ gate = (os.environ.get("AW_GATE", "") or "").strip() or "gate"
4063
+ fd = os.environ.get("AW_FD", "")
4064
+ report = (os.environ.get("AW_REPORT", "") or "").strip()
4065
+ out = (os.environ.get("AW_OUT", "") or "").strip()
4066
+ # "pass" only on an explicit affirmative. An absent or unreadable verdict is a FAIL,
4067
+ # because a result that defaults green turns every broken run into a passing one.
4068
+ ok = (os.environ.get("AW_OK", "") or "").strip().lower() in ("true", "1", "yes", "pass", "ok")
4069
+ # Written BESIDE the markdown report and named after it, so a job holding either path
4070
+ # holds the other. --out overrides; with no report named at all it falls back to a
4071
+ # dotfile in the feature directory under the gate's own name.
4072
+ if not out:
4073
+ out = (os.path.splitext(report)[0] + ".json") if report else os.path.join(fd, ".aw-gate-%s.json" % gate)
4074
+ try: gap_count = int((os.environ.get("AW_GAPS", "") or "").strip() or 0)
4075
+ except ValueError: gap_count = 0
4076
+ refs = [r.strip() for r in (os.environ.get("AW_REFS", "") or "").replace(";", ",").split(",") if r.strip()]
4077
+ # A fail reporting zero findings reads as green to anything that sums the number
4078
+ # rather than reading the status, so a red verdict always carries at least one.
4079
+ if not ok and gap_count < 1: gap_count = max(1, len(refs))
4080
+ # --field k=v, repeatable, arriving newline-separated. Keeps the CORE schema below
4081
+ # fixed for every gate while each one carries its own detail underneath.
4082
+ fields = {}
4083
+ for line in (os.environ.get("AW_FIELDS", "") or "").splitlines():
4084
+ k, sep, v = line.partition("=")
4085
+ if sep and k.strip(): fields[k.strip()] = v.strip()
4086
+ res = {"gate": gate,
4087
+ "status": "pass" if ok else "fail",
4088
+ "feature": os.path.basename(fd.rstrip("/\\")),
4089
+ "feature_dir": fd,
4090
+ "spec_version": (os.environ.get("AW_VERSION", "") or "").strip(),
4091
+ "story": (os.environ.get("AW_STORY", "") or "").strip(),
4092
+ "gap_count": gap_count,
4093
+ "gap_refs": refs,
4094
+ "report": report,
4095
+ "note": (os.environ.get("AW_NOTE", "") or "").strip(),
4096
+ "fields": fields,
4097
+ "generated_at": datetime.datetime.now().isoformat(timespec="seconds")}
4098
+ d = os.path.dirname(out)
4099
+ if d and not os.path.isdir(d): os.makedirs(d, exist_ok=True)
4100
+ open(out, "w", encoding="utf-8").write(json.dumps(res, indent=2) + "\n")
4101
+ print(json.dumps({"ok": True, "result_file": out, "status": res["status"], "gap_count": gap_count}))
4102
+ '@
4103
+
4104
+ function Invoke-GateResult {
4105
+ param([string[]]$Rest)
4106
+ $fdArg = ''; $gate = ''; $ok = ''; $version = ''; $story = ''; $report = ''; $out = ''; $gaps = ''; $refs = ''; $note = ''; $fields = @()
4107
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--gate','-Gate' } { $gate = $Rest[++$i] } { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--ok','-Ok' } { $ok = $Rest[++$i] } { $_ -in '--spec-version','-SpecVersion' } { $version = $Rest[++$i] } { $_ -in '--story','-Story' } { $story = $Rest[++$i] } { $_ -in '--report','-Report' } { $report = $Rest[++$i] } { $_ -in '--out','-Out' } { $out = $Rest[++$i] } { $_ -in '--gap-count','-GapCount' } { $gaps = $Rest[++$i] } { $_ -in '--gap-refs','-GapRefs' } { $refs = $Rest[++$i] } { $_ -in '--note','-Note' } { $note = $Rest[++$i] } { $_ -in '--field','-Field' } { $fields += $Rest[++$i] } } }
4108
+ if (-not $gate) { Write-Error 'gate-result: --gate required'; exit 1 }
4109
+ $py = Get-Py; if (-not $py) { Write-Error 'gate-result: python3 required'; exit 1 }
4110
+ $repo = Get-RepoRoot
4111
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4112
+ if (-not $fd) { Write-Error 'gate-result: no feature set and no --feature-dir'; exit 1 }
4113
+ $env:AW_GATE = $gate; $env:AW_FD = $fd; $env:AW_OK = $ok; $env:AW_VERSION = $version; $env:AW_STORY = $story
4114
+ $env:AW_REPORT = $report; $env:AW_OUT = $out; $env:AW_GAPS = $gaps; $env:AW_REFS = $refs; $env:AW_NOTE = $note
4115
+ $env:AW_FIELDS = ($fields -join "`n")
4116
+ $PyGateResult | & $py -
4117
+ 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
+ }
4119
+
4120
+ # ─────────────── story-plan-check / story-plan-doc / log-append ───────────────
4121
+ # The per-story execution design ("story design", one document per lane) + the
4122
+ # feature's append-only logs. Twins of the bash actions; the python payloads are
4123
+ # byte-identical by contract.
4124
+ $PyStoryPlanCheck = @'
4125
+ import json, os, re, sys
4126
+ fd = os.environ["AW_FD"]
4127
+ sdir = os.path.join(fd, "stories")
4128
+ spec = os.path.join(fd, "spec.md")
4129
+
4130
+ def parse_fm(text):
4131
+ """Frontmatter as a flat dict — inline and block-list values both fold to the
4132
+ inline shape (same reader story-refs uses, for the same reason)."""
4133
+ m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
4134
+ if not m: return {}
4135
+ fm, key, block = {}, None, set()
4136
+ for line in m.group(1).split("\n"):
4137
+ km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
4138
+ if km:
4139
+ key = km.group(1); fm[key] = km.group(2).strip(); continue
4140
+ im = re.match(r'^\s+-\s+(.*)$', line) if key else None
4141
+ if im and (key in block or not fm[key]):
4142
+ block.add(key)
4143
+ fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
4144
+ return fm
4145
+
4146
+ def vtuple(v):
4147
+ try: return tuple(int(x) for x in v.lstrip("vV").split("."))
4148
+ except Exception: return (0,)
4149
+
4150
+ # ---- the backlog ------------------------------------------------------------
4151
+ stories = []
4152
+ if os.path.isdir(sdir):
4153
+ for name in sorted(os.listdir(sdir)):
4154
+ # stories.md is the GENERATED index beside the stories, not a story
4155
+ if not name.endswith(".md") or name == "stories.md": continue
4156
+ p = os.path.join(sdir, name)
4157
+ if not os.path.isfile(p): continue
4158
+ text = open(p, encoding="utf-8", errors="replace").read()
4159
+ fm = parse_fm(text)
4160
+ sid = fm.get("id") or name[:-3]
4161
+ stories.append({"id": sid, "file_id": name[:-3],
4162
+ "status": (fm.get("status") or "open").strip().lower(),
4163
+ "type": (fm.get("type") or "functional").strip().lower(),
4164
+ "title": (fm.get("title") or "").strip().strip("[]"),
4165
+ "spec_version": (fm.get("spec_version") or "").strip(),
4166
+ "cites": re.findall(r'\b(?:FR|BR)-\d+', fm.get("exercises", "")),
4167
+ "path": p, "text": text})
4168
+ open_stories = [s for s in stories if s["status"] == "open"]
4169
+ open_ids = [s["id"] for s in open_stories]
4170
+ digest = "\n".join(" %-16s %s%s" % (s["id"], s["title"] or "(untitled)",
4171
+ (" cites " + ", ".join(s["cites"])) if s["cites"] else "")
4172
+ for s in open_stories) or " (no open stories)"
4173
+
4174
+ # ---- which story ------------------------------------------------------------
4175
+ want = (os.environ.get("AW_STORY") or "").strip()
4176
+ free = os.environ.get("AW_INPUT") or ""
4177
+ target, reason = None, ""
4178
+ if want:
4179
+ hit = [s for s in stories if s["id"] == want or s["file_id"] == want]
4180
+ target = hit[0] if hit else None
4181
+ if not target: reason = "not-found"
4182
+ else:
4183
+ # every known story id appearing as a whole token in the free text; one match
4184
+ # resolves, several is a question, none is a question
4185
+ named = [s for s in stories
4186
+ if re.search(r'(?<![\w-])' + re.escape(s["id"]) + r'(?![\w-])', free)]
4187
+ if len(named) == 1: target = named[0]
4188
+ else: reason = "ambiguous" if named else "none-given"
4189
+
4190
+ out = {"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
4191
+ "open_count": len(open_stories), "any_open": bool(open_stories),
4192
+ "open_ids": open_ids, "open_ids_csv": ", ".join(open_ids), "digest": digest,
4193
+ "resolved": bool(target), "needs_pick": not target, "pick_reason": reason,
4194
+ "story": "", "story_path": "", "story_status": "", "story_open": False,
4195
+ "story_type": "", "is_technical": False,
4196
+ "status_msg": "", "story_title": "", "exercises_csv": "", "constraints_csv": "",
4197
+ "story_spec_version": "", "current_version": "", "stale_csv": "", "stale_note": "",
4198
+ "story_dir": "", "if_tmp": "", "legacy_doc": "", "legacy_doc_exists": False,
4199
+ "be_doc_path": "", "be_doc_exists": False, "be_next_pass": 1, "be_pass_tmp": "",
4200
+ "be_last_pass": 0, "be_doc_spec_version": "", "be_doc_stale": False,
4201
+ "fe_doc_path": "", "fe_doc_exists": False, "fe_next_pass": 1, "fe_pass_tmp": "",
4202
+ "fe_last_pass": 0, "fe_doc_spec_version": "", "fe_doc_stale": False,
4203
+ "adr_path": os.path.join(fd, "adr.md"), "adr_exists": os.path.isfile(os.path.join(fd, "adr.md")),
4204
+ "if_path": os.path.join(fd, "interfaces.md"), "if_exists": os.path.isfile(os.path.join(fd, "interfaces.md")),
4205
+ "spec_exists": os.path.isfile(spec)}
4206
+
4207
+ # ---- the ledger (widest-wins current version, same rule ledger-append uses) --
4208
+ rows, cur = [], ""
4209
+ if os.path.isfile(spec):
4210
+ stext = open(spec, encoding="utf-8", errors="replace").read()
4211
+ m = re.search(r'^##\s+Change History\s*$', stext, re.M)
4212
+ if m:
4213
+ for line in stext[m.end():].splitlines():
4214
+ rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
4215
+ if rm: rows.append((rm.group(1), line))
4216
+ hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', stext, re.M)
4217
+ cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
4218
+ if cands: cur = max(cands, key=vtuple)
4219
+ out["current_version"] = cur
4220
+
4221
+ if not target:
4222
+ print(json.dumps(out)); sys.exit(0)
4223
+
4224
+ # ---- the resolved story -----------------------------------------------------
4225
+ status = target["status"]
4226
+ out.update(story=target["id"], story_path=target["path"], story_status=status,
4227
+ story_open=(status == "open"), story_title=target["title"],
4228
+ story_type=target["type"], is_technical=(target["type"] == "technical"),
4229
+ exercises_csv=", ".join(target["cites"]),
4230
+ story_spec_version=target["spec_version"])
4231
+ if status != "open":
4232
+ verb = {"closed": "was closed — its plan is history. Remaining work needs its own story "
4233
+ "(sync-stories mints a follow-on when the spec moves)",
4234
+ "cancelled": "was cancelled — cancelled work is not planned; open a new story if the need returns",
4235
+ "draft": "is a draft proposal — run /appweaver sync-spec to merge it into the contract first; "
4236
+ "story-plan designs only against merged requirements"}
4237
+ out["status_msg"] = "Story %s %s." % (
4238
+ target["id"], verb.get(status, "has status '%s', not open" % status))
4239
+
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 []
4244
+ out["constraints_csv"] = ", ".join(cons)
4245
+
4246
+ # staleness: ledger rows ABOVE the story's stamp naming an id the story carries —
4247
+ # exercises: AND the Constraints section, so a moved bound is announced too
4248
+ sv = target["spec_version"]
4249
+ if sv:
4250
+ carried = set(target["cites"]) | set(cons)
4251
+ moved = [r for r in rows if vtuple(r[0]) > vtuple(sv)]
4252
+ stale = sorted({i for _v, l in moved for i in re.findall(r'[A-Z]{2}-\d+', l) if i in carried})
4253
+ out["stale_csv"] = ", ".join(stale)
4254
+ if stale:
4255
+ out["stale_note"] = (
4256
+ "CAUTION: the spec moved above this story's stamp (%s -> %s) on ids this story "
4257
+ "carries: %s. Design against the CURRENT spec text, record the divergence under "
4258
+ "Risks, and consider /appweaver sync-stories to bring the backlog back in step."
4259
+ % (sv, cur or "?", ", ".join(stale)))
4260
+
4261
+ # doc / pass state + the temp paths the workflow writes through. ONE DOCUMENT PER
4262
+ # LANE: the service design and the UI design are authored by different steps, under
4263
+ # different rule sets, from different code, and are read by different implement
4264
+ # commands — folded into one file, every one of those becomes a section boundary the
4265
+ # next reader has to trust. Pass numbers are per file, so a run that designs only the
4266
+ # backend leaves the frontend's count exactly where it was.
4267
+ #
4268
+ # legacy_doc is the pre-split story-plan.md. It is REPORTED, never read and never
4269
+ # written: a design nobody consumes must not sit silently beside two that are.
4270
+ stdir = os.path.join(sdir, target["id"])
4271
+ legacy = os.path.join(stdir, "story-plan.md")
4272
+ out.update(story_dir=stdir, if_tmp=os.path.join(stdir, ".if-entries.tmp.md"),
4273
+ legacy_doc=legacy, legacy_doc_exists=os.path.isfile(legacy))
4274
+ for lane, stem in (("be", "backend"), ("fe", "frontend")):
4275
+ doc = os.path.join(stdir, "story-%s-plan.md" % stem)
4276
+ out[lane + "_doc_path"] = doc
4277
+ out[lane + "_doc_exists"] = os.path.isfile(doc)
4278
+ out[lane + "_pass_tmp"] = os.path.join(stdir, ".%s-pass.tmp.md" % stem)
4279
+ if out[lane + "_doc_exists"]:
4280
+ d = open(doc, encoding="utf-8", errors="replace").read()
4281
+ nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', d, re.M)]
4282
+ out[lane + "_next_pass"] = (max(nums) + 1) if nums else 1
4283
+ # What implement needs on top of the path: WHICH pass it builds from (the last
4284
+ # one — earlier passes are superseded history) and the spec version that pass was
4285
+ # designed against. A design behind the current spec is ANNOUNCED at implement,
4286
+ # never refused: the story is legitimately in flight, and the spec may well have
4287
+ # moved on ids this story does not carry. stale_csv is the sharper signal and it
4288
+ # is already computed above.
4289
+ out[lane + "_last_pass"] = max(nums) if nums else 0
4290
+ dv = (parse_fm(d).get("spec_version") or "").strip().strip('"')
4291
+ out[lane + "_doc_spec_version"] = dv
4292
+ out[lane + "_doc_stale"] = bool(dv and cur and vtuple(dv) < vtuple(cur))
4293
+ print(json.dumps(out))
4294
+ '@
4295
+
4296
+ $PyStoryPlanDoc = @'
4297
+ import json, os, re, sys, datetime
4298
+ fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
4299
+ pf = os.environ["AW_PASS"]; ver = (os.environ.get("AW_VER") or "").strip()
4300
+ lane = (os.environ.get("AW_LANE") or "backend").strip().lower()
4301
+ sdir = os.path.join(fd, "stories", story)
4302
+ doc = os.path.join(sdir, "story-%s-plan.md" % lane)
4303
+
4304
+ def bail(msg, code=2):
4305
+ print(json.dumps({"ok": False, "doc": doc, "error": msg}))
4306
+ sys.stderr.write(msg + "\n"); sys.exit(code)
4307
+
4308
+ if lane not in ("backend", "frontend"):
4309
+ bail("story-plan-doc: --lane must be backend or frontend, not '%s'" % lane)
4310
+ if not os.path.isfile(os.path.join(fd, "stories", story + ".md")):
4311
+ bail("story-plan-doc: no story file for '%s' — the plan lives beside a story that exists" % story)
4312
+ if not os.path.isfile(pf):
4313
+ bail("story-plan-doc: pass file missing at %s — the authoring step wrote nothing" % pf)
4314
+ content = open(pf, encoding="utf-8", errors="replace").read().strip()
4315
+ if not content:
4316
+ bail("story-plan-doc: the authored pass is empty — nothing appended")
4317
+ # the pass header is SCRIPT-written; strip one the model wrote anyway rather than
4318
+ # letting the doc double-number
4319
+ content = re.sub(r'^##\s+Design pass\b[^\n]*\n+', '', content, count=1)
4320
+
4321
+ LANE_WORD = "SERVICE" if lane == "backend" else "UI"
4322
+ LANE_READER = "/appweaver implement" if lane == "backend" else "/appweaver implement-ui"
4323
+ os.makedirs(sdir, exist_ok=True)
4324
+ today = datetime.date.today().isoformat()
4325
+ created = not os.path.isfile(doc)
4326
+ if created:
4327
+ open(doc, "w", encoding="utf-8").write("""---
4328
+ created: %s
4329
+ updated: %s
4330
+ passes: 0
4331
+ spec_version: ""
4332
+ ---
4333
+
4334
+ <!--
4335
+ STORY DESIGN (%s LANE) — the ephemeral execution design for ONE story, read by
4336
+ %s.
4337
+
4338
+ Identity is positional: the file lives in the story's directory and carries no
4339
+ story id in its text. Every /appweaver story-plan run APPENDS a "## Design pass N"
4340
+ section to THIS lane's file — nothing is edited in place, so a manual edit to an
4341
+ earlier pass survives a re-run. THE LATEST PASS WINS; earlier passes are the
4342
+ iteration history.
4343
+
4344
+ EDIT IT BEFORE YOU IMPLEMENT. This is the document a design is corrected in by
4345
+ hand: change the latest pass and implement builds what you changed. Re-running
4346
+ story-plan instead appends a fresh pass that supersedes it, so hand-edit to
4347
+ adjust, re-run to redesign.
4348
+
4349
+ It is scaffolding for confirming the implementation strategy — archived with the
4350
+ story at close, never maintained afterwards. Durable decisions do not live here:
4351
+ the latest pass PROPOSES ADRs (ratified into the feature's adr.md at close), and
4352
+ published-surface changes are appended to the feature's interfaces.md as proposed
4353
+ entries at design time. spec_version below is the spec the LATEST pass was
4354
+ designed against. The other lane's design, where the project has one, sits beside
4355
+ this file — the two are written apart on purpose and neither restates the other.
4356
+ -->
4357
+ """ % (today, today, LANE_WORD, LANE_READER))
4358
+ text = open(doc, encoding="utf-8", errors="replace").read()
4359
+ nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', text, re.M)]
4360
+ n = (max(nums) + 1) if nums else 1
4361
+ header = "## Design pass %d — %s%s" % (n, today, (" (spec %s)" % ver) if ver else "")
4362
+ text = text.rstrip("\n") + "\n\n" + header + "\n\n" + content + "\n"
4363
+ # frontmatter stamps — first match only, which is the frontmatter by position
4364
+ text = re.sub(r'^(updated:).*$', r'\g<1> ' + today, text, count=1, flags=re.M)
4365
+ text = re.sub(r'^(passes:).*$', r'\g<1> %d' % n, text, count=1, flags=re.M)
4366
+ if ver:
4367
+ text = re.sub(r'^(spec_version:).*$', r'\g<1> %s' % ver, text, count=1, flags=re.M)
4368
+ open(doc, "w", encoding="utf-8").write(text)
4369
+ os.remove(pf)
4370
+ print(json.dumps({"ok": True, "doc": doc, "lane": lane, "pass": n, "created": created,
4371
+ "spec_version": ver, "story_dir": sdir}))
4372
+ '@
4373
+
4374
+ $PyLogAppend = @'
4375
+ import json, os, re, sys, datetime
4376
+ fd = os.environ["AW_FD"]; log = os.environ["AW_LOG"]
4377
+ ef = os.environ["AW_ENTRIES"]; status = (os.environ.get("AW_STATUS") or "").strip()
4378
+ fname, prefix, default_status, title = {
4379
+ "interfaces": ("interfaces.md", "IF", "proposed", "Interface Log"),
4380
+ "adr": ("adr.md", "ADR", "accepted", "Architecture Decision Log")}[log]
4381
+ status = status or default_status
4382
+ path = os.path.join(fd, fname)
4383
+
4384
+ def bail(msg, code=2):
4385
+ print(json.dumps({"ok": False, "log": path, "error": msg}))
4386
+ sys.stderr.write(msg + "\n"); sys.exit(code)
4387
+
4388
+ if not os.path.isfile(ef):
4389
+ bail("log-append: entries file missing at %s — the authoring step wrote nothing" % ef)
4390
+ raw = open(ef, encoding="utf-8", errors="replace").read().strip()
4391
+ if not raw or raw.lower().rstrip(".") == "none":
4392
+ os.remove(ef)
4393
+ print(json.dumps({"ok": True, "log": path, "appended": 0, "ids_csv": "",
4394
+ "unknown_ids_csv": "", "bad_supersedes_csv": "", "none": True}))
4395
+ sys.exit(0)
4396
+
4397
+ # every entry must arrive as '## <PREFIX>-NEW — <title>': the script is the only id
4398
+ # authority, so a model-numbered entry is refused rather than trusted
4399
+ blocks = [b.strip() for b in re.split(r'(?=^##\s)', raw, flags=re.M) if b.strip()]
4400
+ bad = [b.splitlines()[0] for b in blocks
4401
+ if not re.match(r'^##\s+%s-NEW\s+[—-]+\s+\S' % prefix, b)]
4402
+ if bad:
4403
+ bail("log-append: every entry must start '## %s-NEW — <title>' (the script assigns "
4404
+ "ids); got: %s" % (prefix, " | ".join(bad[:3])))
4405
+
4406
+ SCAFFOLD = {
4407
+ "interfaces": """<!--
4408
+ INTERFACE LOG — this feature's published-surface contract. APPEND-ONLY.
4409
+
4410
+ WHO WRITES: /appweaver plan publishes this feature's surface whenever it authors or
4411
+ conforms backend-plan.md; a BE story plan appends an entry only where its slice
4412
+ changes that surface. Both land as status: proposed, and story close verifies each
4413
+ against the as-built and flips it to live.
4414
+ WHO READS: /appweaver plan-ui and the FE story designs — this log is the BE-to-FE
4415
+ contract anchor, and the plan documents CITE these ids rather than restating them.
4416
+ The current surface per interface is the LATEST entry — one that names an earlier
4417
+ id in `supersedes:` replaces it. Never edit an existing entry; publish a
4418
+ superseding one. status: proposed -> live -> superseded | retired.
4419
+ `serves:` cites the FR-/BR- ids the interface exists for, so a spec change to
4420
+ those ids flags the contract for review.
4421
+ -->
4422
+
4423
+ # Interface Log
4424
+ """,
4425
+ "adr": """<!--
4426
+ ADR LOG — this feature's durable decisions. APPEND-ONLY.
4427
+
4428
+ A story plan PROPOSES ADRs in its latest design pass; they are ratified into this
4429
+ log at story close, or the architect appends directly. Never rewrite an entry —
4430
+ supersede it with a new one naming `supersedes:`. An ADR records ONLY what neither
4431
+ the code nor the rules can say: a deliberate choice among rule-compliant
4432
+ alternatives, a rejected alternative and why, or a trade-off with its revisit
4433
+ trigger. It never restates what code shows or a rule mandates.
4434
+ -->
4435
+
4436
+ # Architecture Decision Log
4437
+ """}
4438
+ created = not os.path.isfile(path)
4439
+ if created:
4440
+ open(path, "w", encoding="utf-8").write(SCAFFOLD[log])
4441
+ text = open(path, encoding="utf-8", errors="replace").read()
4442
+ existing = set(re.findall(r'^##\s+(%s-\d+)' % prefix, text, re.M))
4443
+ nums = [int(i.split("-")[1]) for i in existing]
4444
+ nxt = (max(nums) + 1) if nums else 1
4445
+ today = datetime.date.today().isoformat()
4446
+
4447
+ spec = os.path.join(fd, "spec.md")
4448
+ known = set(re.findall(r'\b[A-Z]{2}-\d+\b',
4449
+ open(spec, encoding="utf-8", errors="replace").read())) \
4450
+ if os.path.isfile(spec) else set()
4451
+
4452
+ assigned, unknown, badsup, out_blocks = [], [], [], []
4453
+ for b in blocks:
4454
+ bid = "%s-%03d" % (prefix, nxt); nxt += 1
4455
+ lines = b.splitlines()
4456
+ lines[0] = lines[0].replace("%s-NEW" % prefix, bid, 1)
4457
+ body = "\n".join(lines[1:]).strip("\n")
4458
+ # status and date are script-stamped, whatever the model wrote
4459
+ body = re.sub(r'^status:.*\n?', '', body, flags=re.M)
4460
+ body = re.sub(r'^date:.*\n?', '', body, flags=re.M)
4461
+ body = ("status: %s\ndate: %s" % (status, today)) + ("\n" + body if body else "")
4462
+ for line in body.splitlines():
4463
+ if line.startswith("serves:"):
4464
+ unknown += [i for i in re.findall(r'\b[A-Z]{2}-\d+\b', line)
4465
+ if known and i not in known]
4466
+ if line.startswith("supersedes:"):
4467
+ badsup += [i for i in re.findall(r'\b%s-\d+\b' % prefix, line)
4468
+ if i not in existing]
4469
+ out_blocks.append(lines[0] + "\n\n" + body)
4470
+ assigned.append(bid)
4471
+
4472
+ text = text.rstrip("\n") + "\n\n" + "\n\n".join(out_blocks) + "\n"
4473
+ open(path, "w", encoding="utf-8").write(text)
4474
+ os.remove(ef)
4475
+ print(json.dumps({"ok": True, "log": path, "appended": len(assigned),
4476
+ "ids_csv": ", ".join(assigned),
4477
+ "unknown_ids_csv": ", ".join(sorted(set(unknown))),
4478
+ "bad_supersedes_csv": ", ".join(sorted(set(badsup))),
4479
+ "none": False, "created_log": created}))
4480
+ '@
4481
+
4482
+ function Invoke-StoryPlanCheck {
4483
+ param([string[]]$Rest)
4484
+ $fdArg = ''; $inp = ''; $story = ''
4485
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--input','-InputText' } { $inp = $Rest[++$i] } { $_ -in '--story','-Story' } { $story = $Rest[++$i] } } }
4486
+ $py = Get-Py; if (-not $py) { Write-Error 'story-plan-check: python3 required'; exit 1 }
4487
+ $repo = Get-RepoRoot
4488
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4489
+ if (-not $fd) { Write-Error 'story-plan-check: no feature set and no --feature-dir'; exit 1 }
4490
+ $env:AW_FD = $fd; $env:AW_INPUT = $inp; $env:AW_STORY = $story
4491
+ $PyStoryPlanCheck | & $py -
4492
+ Remove-Item Env:AW_FD, Env:AW_INPUT, Env:AW_STORY -ErrorAction SilentlyContinue
4493
+ }
4494
+
4495
+ function Invoke-StoryPlanDoc {
4496
+ 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] } } }
4499
+ if (-not $story) { Write-Error 'story-plan-doc: --story is required'; exit 1 }
4500
+ if (-not $passFile) { Write-Error 'story-plan-doc: --pass-file is required'; exit 1 }
4501
+ $py = Get-Py; if (-not $py) { Write-Error 'story-plan-doc: python3 required'; exit 1 }
4502
+ $repo = Get-RepoRoot
4503
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4504
+ if (-not $fd) { Write-Error 'story-plan-doc: no feature set and no --feature-dir'; exit 1 }
4505
+ $env:AW_FD = $fd; $env:AW_STORY = $story; $env:AW_LANE = $lane; $env:AW_PASS = $passFile; $env:AW_VER = $specVersion
4506
+ $PyStoryPlanDoc | & $py -
4507
+ Remove-Item Env:AW_FD, Env:AW_STORY, Env:AW_LANE, Env:AW_PASS, Env:AW_VER -ErrorAction SilentlyContinue
4508
+ }
4509
+
4510
+ function Invoke-LogAppend {
4511
+ param([string[]]$Rest)
4512
+ $fdArg = ''; $log = ''; $entriesFile = ''; $status = ''
4513
+ for ($i = 0; $i -lt $Rest.Count; $i++) { switch ($Rest[$i]) { { $_ -in '--feature-dir','-FeatureDir' } { $fdArg = $Rest[++$i] } { $_ -in '--log','-Log' } { $log = $Rest[++$i] } { $_ -in '--entries-file','-EntriesFile' } { $entriesFile = $Rest[++$i] } { $_ -in '--status','-Status' } { $status = $Rest[++$i] } } }
4514
+ if ($log -notin 'adr','interfaces') { Write-Error 'log-append: --log must be adr or interfaces'; exit 1 }
4515
+ if (-not $entriesFile) { Write-Error 'log-append: --entries-file is required'; exit 1 }
4516
+ $py = Get-Py; if (-not $py) { Write-Error 'log-append: python3 required'; exit 1 }
4517
+ $repo = Get-RepoRoot
4518
+ $fd = Resolve-FeatureDir -Arg $fdArg -RepoRoot $repo
4519
+ if (-not $fd) { Write-Error 'log-append: no feature set and no --feature-dir'; exit 1 }
4520
+ $env:AW_FD = $fd; $env:AW_LOG = $log; $env:AW_ENTRIES = $entriesFile; $env:AW_STATUS = $status
4521
+ $PyLogAppend | & $py -
4522
+ Remove-Item Env:AW_FD, Env:AW_LOG, Env:AW_ENTRIES, Env:AW_STATUS -ErrorAction SilentlyContinue
2574
4523
  }
2575
4524
 
2576
4525
  # Read one field out of the lane model. Twin of the bash inline python readers: the
@@ -2968,13 +4917,24 @@ switch ($sub) {
2968
4917
  'spec-snapshot' { Invoke-SpecSnapshot -Rest $rest }
2969
4918
  'spec-diff' { Invoke-SpecDiff -Rest $rest }
2970
4919
  'story-refs' { Invoke-StoryRefs -Rest $rest }
4920
+ 'slice-mode' { Invoke-SliceMode -Rest $rest }
4921
+ 'backlog-plan' { Invoke-BacklogPlan -Rest $rest }
4922
+ 'story-stamp' { Invoke-StoryStamp -Rest $rest }
4923
+ 'story-plan-check' { Invoke-StoryPlanCheck -Rest $rest }
4924
+ 'story-plan-doc' { Invoke-StoryPlanDoc -Rest $rest }
4925
+ 'log-append' { Invoke-LogAppend -Rest $rest }
4926
+ 'ledger-read' { Invoke-LedgerRead -Rest $rest }
2971
4927
  'spec-coverage' { Invoke-SpecCoverage -Rest $rest }
2972
4928
  'spec-preserve' { Invoke-SpecPreserve -Rest $rest }
2973
4929
  'spec-markers' { Invoke-SpecMarkers -Rest $rest }
2974
4930
  'feature-paths' { Invoke-FeaturePaths -Rest $rest }
2975
4931
  'design-scaffold' { Invoke-DesignScaffold -Rest $rest }
4932
+ 'plan-check' { Invoke-PlanCheck -Rest $rest }
4933
+ 'plan-stamp' { Invoke-PlanStamp -Rest $rest }
4934
+ 'gate-result' { Invoke-GateResult -Rest $rest }
2976
4935
  'build-compile' { Invoke-BuildCompile -Rest $rest }
2977
4936
  'test-run' { Invoke-TestRun -Rest $rest }
4937
+ 'test-diff' { Invoke-TestDiff -Rest $rest }
2978
4938
  'run-migrate' { Invoke-RunMigrate -Rest $rest }
2979
4939
  'deploy-dispatch' { Invoke-DeployDispatch -Rest $rest }
2980
4940
  'cr-setup' { Invoke-CrSetup -Rest $rest }