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