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