aw-spec-cli 0.1.14 → 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/STACK-PROFILES.md +1 -1
- package/assets/.appweaver/orchestrator/modules/spec-state-gate.orch.yaml +87 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +5 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +10 -3
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +38 -11
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +2 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +672 -124
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +700 -72
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +531 -135
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +573 -145
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +78 -14
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +600 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +721 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +119 -32
- package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +1147 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +1121 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +2564 -38
- package/assets/.appweaver/scripts/bash/aw-xref.sh +2 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +2458 -22
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +2 -1
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +158 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +431 -48
- package/assets/.appweaver/templates/design-template.md +88 -54
- package/assets/.appweaver/templates/spec-template.md +12 -10
- package/assets/.appweaver/templates/story-template.md +83 -12
- package/assets/.appweaver/templates/ui-design-template.md +71 -39
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +19 -7
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +35 -26
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design-ui.orch.yaml +0 -386
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design.orch.yaml +0 -382
|
@@ -49,15 +49,45 @@
|
|
|
49
49
|
# -> {feature, version, active_change, created, bootstrap_pending}
|
|
50
50
|
# (create active_version.json v1.0; --bootstrap marks a first draft in progress)
|
|
51
51
|
# spec-check [--feature-dir <path>] -> {ok, state, version, doc_version, body_changed, spec_sha256, error}
|
|
52
|
-
# (the ledger tripwire: clean | unstamped | ledgered_edit |
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
52
|
+
# (the ledger tripwire: clean | unstamped | ledgered_edit | header_behind |
|
|
53
|
+
# unrowed_edit | unledgered_edit | rollback | no_ledger | delta_open.
|
|
54
|
+
# A recorded edit is one with a version ROW above the recorded version — the
|
|
55
|
+
# **Version** header is a claim, not evidence. ok:false on an edit that skipped
|
|
56
|
+
# the Change History row, which is the ledger's only silent case)
|
|
57
|
+
# spec-stamp [--feature-dir <path>] -> {ok, state, version, spec_sha256, header_rewritten}
|
|
58
|
+
# (record spec.md's hash as the new baseline; used after a bootstrap authors it.
|
|
59
|
+
# The version recorded is the highest of the recorded one, the document header
|
|
60
|
+
# and the newest ledger row, and a header behind that is rewritten up to it)
|
|
56
61
|
# ledger-append [--feature-dir <path>] --ids <ids> [--summary <text>]
|
|
57
62
|
# -> {ok, version, previous, row, spec_sha256}
|
|
58
63
|
# (bump, append the Change History row, stamp the **Version** header and
|
|
59
64
|
# re-hash — ONE call, so a row can never exist without its hash)
|
|
60
65
|
# story-refs [--feature-dir <path>] [--ids <FR-001, BR-002>]
|
|
66
|
+
# slice-mode [--input <text>] -> {strategy, guidance, single, note}
|
|
67
|
+
# (the strategy word off the front of the launch line, and the guidance
|
|
68
|
+
# left behind it — `single-run` asks for one story over the work set)
|
|
69
|
+
# backlog-plan --groups <json> --work <csv> [--strategy single|auto] [--ceiling <n>]
|
|
70
|
+
# [--plan --approved <ids> --version <v>]
|
|
71
|
+
# story-stamp --story <id> [--ids <csv>] [--version <v>] [--follows <id>]
|
|
72
|
+
# [--depends-on <csv>] [--wave <n>]
|
|
73
|
+
# ledger-read [--feature-dir <path>] -> {rows, work_csv, work_source, gap, ...}
|
|
74
|
+
# story-plan-check [--story <id>] [--input <text>] [--feature-dir <p>]
|
|
75
|
+
# -> {resolved, needs_pick, open_ids, story, story_open, status_msg,
|
|
76
|
+
# exercises_csv, constraints_csv, stale_csv, stale_note,
|
|
77
|
+
# be_doc_path/fe_doc_path, be_doc_exists/fe_doc_exists,
|
|
78
|
+
# be_next_pass/fe_next_pass, be_last_pass/fe_last_pass,
|
|
79
|
+
# be_pass_tmp/fe_pass_tmp,
|
|
80
|
+
# be_doc_spec_version/fe_doc_spec_version, be_doc_stale/fe_doc_stale,
|
|
81
|
+
# legacy_doc, legacy_doc_exists, if_tmp, adr_path, if_path}
|
|
82
|
+
# (resolve WHICH story a story-plan run works on + everything its gates read)
|
|
83
|
+
# story-plan-doc --story <id> --pass-file <p> [--lane backend|frontend] [--spec-version <v>]
|
|
84
|
+
# -> {ok, doc, lane, pass, created}
|
|
85
|
+
# (the only writer of stories/<ID>/story-<lane>-plan.md: scaffold on first use, append
|
|
86
|
+
# the pass under a script-written '## Design pass N' header, stamp frontmatter)
|
|
87
|
+
# log-append --log adr|interfaces --entries-file <p> [--status <s>]
|
|
88
|
+
# -> {ok, appended, ids_csv, unknown_ids_csv, bad_supersedes_csv}
|
|
89
|
+
# (append-only feature logs; the script assigns ADR-nnn/IF-nnn ids, dates and
|
|
90
|
+
# status — entries arrive as '## IF-NEW — <title>' blocks, 'none' is a no-op)
|
|
61
91
|
# -> {covered_csv, affected[], affected_csv, story_csv,
|
|
62
92
|
# in_flight_csv, any, any_in_flight, story_count}
|
|
63
93
|
# (which requirements have OPEN stories against them; no --ids = all of them.
|
|
@@ -65,10 +95,27 @@
|
|
|
65
95
|
# spec-coverage [--feature-dir <path>] -> {written, requirements, stories, uncovered_csv, delivered_csv}
|
|
66
96
|
# (regenerate the spec's AW:COVERAGE block — every FR-/BR- and the stories
|
|
67
97
|
# delivering it. Fenced out of the hash and the diff, so writing it is not
|
|
68
|
-
# a spec edit. Called by specify, user-story and
|
|
98
|
+
# a spec edit. Called by specify, user-story and sync-stories alike)
|
|
69
99
|
# spec-preserve --disturbing <csv> --approved <csv> -> {preserve_csv, preserve_count, approved_csv}
|
|
70
100
|
# (the complement: which disturbed requirements were NOT approved for change)
|
|
71
|
-
# feature-paths -> {feature, feature_dir, spec_file, design_file, spec_exists, design_exists
|
|
101
|
+
# feature-paths -> {feature, feature_dir, spec_file, design_file, spec_exists, design_exists,
|
|
102
|
+
# backend_plan_file, frontend_plan_file, backend_plan_exists, frontend_plan_exists}
|
|
103
|
+
# plan-check --doc <path> [--feature-dir <p>] -> {state, derived_version, current_version, changed_csv, changed_summary, body_changed}
|
|
104
|
+
# (the plan doc's derivation record vs the spec ledger: fresh | untracked |
|
|
105
|
+
# clean | behind | edited | edited_behind; behind's changed_csv is the update's work list)
|
|
106
|
+
# plan-stamp --doc <path> [--feature-dir <p>] [--ids <csv>] [--note <text>] [--no-row]
|
|
107
|
+
# -> {ok, version, row, warnings[]}
|
|
108
|
+
# (the ONLY writer of a plan doc's version identity: **Version** header := the
|
|
109
|
+
# spec version derived from, a Change History row unless --no-row, and the body
|
|
110
|
+
# hash into the doc's own .aw-plan-track.<doc>.json — refuses, writing no baseline, when the doc has
|
|
111
|
+
# no Change History section left)
|
|
112
|
+
# gate-result --gate <name> --ok <true|false> [--feature-dir <p>] [--spec-version <v>]
|
|
113
|
+
# [--story <id>] [--report <path>] [--out <path>] [--gap-count <n>]
|
|
114
|
+
# [--gap-refs <csv>] [--note <text>] [--field k=v]...
|
|
115
|
+
# -> {ok, result_file, status, gap_count}
|
|
116
|
+
# (the machine-readable verdict every analyze gate writes beside its markdown
|
|
117
|
+
# report, so CI branches on a value instead of prose. `ok` means the file was
|
|
118
|
+
# WRITTEN; the verdict is `status`. Fixed core keys + gate-specific --field)
|
|
72
119
|
# checklist-read --file <path> -> {exists, total, checked, items[]}
|
|
73
120
|
# checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
|
|
74
121
|
# detect-project -> {scaffolded, markers[]}
|
|
@@ -184,7 +231,8 @@ cmd_resolve_domain() {
|
|
|
184
231
|
# stack_mismatch when it is not a stack this repo declares, because a map pinned to the
|
|
185
232
|
# wrong corpus is how a Java project ends up being handed C# rules.
|
|
186
233
|
cmd_resolve_tech() {
|
|
187
|
-
local command_name="aw-spec-
|
|
234
|
+
local command_name="aw-spec-plan" # inert: every caller passes --command. Kept current
|
|
235
|
+
# so a hand-run defaults to a LIVE command, not a dead one.
|
|
188
236
|
while [ $# -gt 0 ]; do
|
|
189
237
|
case "$1" in
|
|
190
238
|
--command) command_name="$2"; shift 2 ;;
|
|
@@ -1087,7 +1135,7 @@ _version_cmd() { # $1 = op (read|init), then --feature-dir arg
|
|
|
1087
1135
|
# spec.md's `## Change History` is the sync anchor between the spec and its story
|
|
1088
1136
|
# backlog: each row names the requirement ids that version touched, and each story
|
|
1089
1137
|
# records the version it last synced at, so "is this story stale" is one integer
|
|
1090
|
-
# comparison rather than a diff.
|
|
1138
|
+
# comparison rather than a diff. sync-stories reads the rows; specify and
|
|
1091
1139
|
# update-spec append them.
|
|
1092
1140
|
#
|
|
1093
1141
|
# The ledger is hand-writable, which is the point — a spec can be edited by hand —
|
|
@@ -1165,6 +1213,14 @@ sep = (next((i for i in range(hi + 1, sec_end)
|
|
|
1165
1213
|
if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
|
|
1166
1214
|
rows = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
|
|
1167
1215
|
has_ledger = hi >= 0 and sep >= 0
|
|
1216
|
+
# THE VERSIONS THE TABLE ACTUALLY CARRIES, read from the rows located above rather than by
|
|
1217
|
+
# an unbounded scan, so a version-looking cell in another section's table cannot be counted
|
|
1218
|
+
# as history. This is the EVIDENCE that a change was recorded; the document's **Version**
|
|
1219
|
+
# header is only the CLAIM, and hand editing parts the two in both directions.
|
|
1220
|
+
row_versions = [m.group(1) for m in
|
|
1221
|
+
(re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', lines[i].lstrip()) for i in rows)
|
|
1222
|
+
if m]
|
|
1223
|
+
newest_row = max(row_versions, key=vtuple) if row_versions else ""
|
|
1168
1224
|
|
|
1169
1225
|
def cell(s, limit=160):
|
|
1170
1226
|
"""One markdown table cell. A newline ENDS a row and a bare pipe STARTS a column, so
|
|
@@ -1194,7 +1250,7 @@ if dspec and os.path.isfile(dspec) and os.path.getsize(dspec) > 0:
|
|
|
1194
1250
|
out.update(open_change=cr, delta_authored=delta_authored)
|
|
1195
1251
|
out.update(version=recorded_ver, doc_version=doc_ver, spec_sha256=actual,
|
|
1196
1252
|
recorded_sha256=recorded_hash, body_changed=(recorded_hash != actual),
|
|
1197
|
-
has_ledger=has_ledger, ledger_rows=len(rows),
|
|
1253
|
+
has_ledger=has_ledger, ledger_rows=len(rows), newest_row=newest_row,
|
|
1198
1254
|
first_stamp=(not recorded_hash))
|
|
1199
1255
|
|
|
1200
1256
|
if op == "check":
|
|
@@ -1228,21 +1284,66 @@ if op == "check":
|
|
|
1228
1284
|
out["state"] = "unstamped" # pre-ledger spec; stamp adopts it
|
|
1229
1285
|
elif recorded_hash == actual:
|
|
1230
1286
|
out["state"] = "clean"
|
|
1287
|
+
# THE ROW IS THE EVIDENCE; THE HEADER IS ONLY THE CLAIM. The body has moved since the
|
|
1288
|
+
# kit last stamped, and what makes that edit legible downstream is a version ROW naming
|
|
1289
|
+
# the ids it touched: sync-stories reads rows, story-plan's staleness check reads rows,
|
|
1290
|
+
# and neither ever opens the **Version** header. Taking the header as proof accepted the
|
|
1291
|
+
# commonest hand-edit mistake there is — bump the number, forget the row — and that one
|
|
1292
|
+
# passed every tripwire in the kit, raised no work anywhere, and was then buried for good
|
|
1293
|
+
# by the next run's stamp adopting its hash. So the test is a row ABOVE the version the
|
|
1294
|
+
# kit last recorded, and the header is only asked whether it kept up.
|
|
1295
|
+
elif newest_row and vtuple(newest_row) > vtuple(recorded_ver):
|
|
1296
|
+
out["state"] = ("ledgered_edit" if vtuple(doc_ver) >= vtuple(newest_row)
|
|
1297
|
+
else "header_behind") # recorded; stamp reconciles the header
|
|
1231
1298
|
elif vtuple(doc_ver) > vtuple(recorded_ver):
|
|
1232
|
-
|
|
1299
|
+
# The header claims a version the table never recorded. Refused for the same reason
|
|
1300
|
+
# an unrowed edit is: without the row nothing can say WHICH requirements moved, so
|
|
1301
|
+
# every story citing one is stale with nothing able to detect it.
|
|
1302
|
+
out["state"] = "unrowed_edit"
|
|
1303
|
+
out.update(ok=False, error="spec.md's header reads %s but the Change History carries "
|
|
1304
|
+
"no row above %s — the version was bumped and what it changed was never "
|
|
1305
|
+
"recorded. The row is what a story reads to learn that a requirement it "
|
|
1306
|
+
"cites has moved; the header is read by nobody. Add a row naming the ids "
|
|
1307
|
+
"you changed (keep the header at %s), then re-run."
|
|
1308
|
+
% (doc_ver or "no version", recorded_ver, doc_ver))
|
|
1233
1309
|
elif vtuple(doc_ver) < vtuple(recorded_ver):
|
|
1234
1310
|
out["state"] = "rollback"
|
|
1235
1311
|
out.update(ok=False, error="spec.md reads %s but the kit last wrote %s — the "
|
|
1236
1312
|
"document is behind its own ledger" % (doc_ver or "no version", recorded_ver))
|
|
1237
1313
|
else:
|
|
1238
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.
|
|
1239
1322
|
out.update(ok=False, error="spec.md has changed since %s but no Change History row "
|
|
1240
1323
|
"was added. Every story citing an edited requirement is now silently "
|
|
1241
|
-
"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."
|
|
1242
1328
|
% recorded_ver)
|
|
1243
1329
|
elif op == "stamp":
|
|
1330
|
+
# THE VERSION IS THE HIGHEST OF THE THREE PLACES ONE IS WRITTEN, and the document's
|
|
1331
|
+
# header is reconciled up to it. Taking the header alone let a hand edit that DID write
|
|
1332
|
+
# its row — without bumping the header — leave the kit recording a version below the
|
|
1333
|
+
# newest row; the next hand edit then read as a `rollback`, the document behind its own
|
|
1334
|
+
# ledger, when nothing had rolled back. Rewriting the header is the authority
|
|
1335
|
+
# ledger-append already exercises: the kit owns the number, the row is what it is
|
|
1336
|
+
# computed from, and re-hashing after the rewrite is what keeps the baseline honest.
|
|
1337
|
+
stamp_ver = max([recorded_ver or "v1.0", doc_ver or "v1.0"] +
|
|
1338
|
+
([newest_row] if newest_row else []), key=vtuple)
|
|
1339
|
+
if doc_ver and vtuple(doc_ver) < vtuple(stamp_ver):
|
|
1340
|
+
text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + stamp_ver,
|
|
1341
|
+
text, count=1, flags=re.M)
|
|
1342
|
+
open(spec, "w", encoding="utf-8").write(text)
|
|
1343
|
+
actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
|
|
1344
|
+
out.update(header_rewritten=True, doc_version=stamp_ver, spec_sha256=actual)
|
|
1244
1345
|
av.setdefault("feature", feature); av.setdefault("active_change", "")
|
|
1245
|
-
av["version"] =
|
|
1346
|
+
av["version"] = stamp_ver
|
|
1246
1347
|
av["spec_sha256"] = actual; av["updated"] = now()
|
|
1247
1348
|
open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
|
|
1248
1349
|
out.update(version=av["version"], state="stamped")
|
|
@@ -1251,7 +1352,7 @@ elif op == "append":
|
|
|
1251
1352
|
# A version that names no ids is a bump nothing can reconcile against: every
|
|
1252
1353
|
# story keeps its old stamp and the gap compounds at the next real change.
|
|
1253
1354
|
bail("ledger-append: --ids is required — a version row that names no requirement "
|
|
1254
|
-
"ids leaves
|
|
1355
|
+
"ids leaves sync-stories nothing to act on", 2)
|
|
1255
1356
|
# The next number comes from the highest of all three places a version is written —
|
|
1256
1357
|
# what the kit last recorded, what the document's header claims, and the newest row
|
|
1257
1358
|
# actually in the table. A hand edit routinely moves one and not the others, and
|
|
@@ -1296,7 +1397,7 @@ elif op == "rowids":
|
|
|
1296
1397
|
# for the reason the header of this file gives: a row the model DECLARED can omit an id
|
|
1297
1398
|
# it moved, and an omitted id is silent. The v1.0 row was the one exception — written by
|
|
1298
1399
|
# the author from a template whose placeholder literally reads "FR-001…FR-00n", which
|
|
1299
|
-
#
|
|
1400
|
+
# sync-stories then has to be told not to trust. This op closes that gap: the caller
|
|
1300
1401
|
# diffs the finished document against nothing and hands the ids in, exactly as the
|
|
1301
1402
|
# extend path does, so the first row is observed like all the rest.
|
|
1302
1403
|
if not ids:
|
|
@@ -1352,7 +1453,7 @@ PY
|
|
|
1352
1453
|
# neither is disturbed by the contract moving (E4).
|
|
1353
1454
|
#
|
|
1354
1455
|
# in_flight is the narrower set: an open story that already has a stories/<ID>/ directory,
|
|
1355
|
-
# so someone has run story-
|
|
1456
|
+
# so someone has run story-plan and is working from a design of the requirement as it
|
|
1356
1457
|
# reads today. That is the same directory-existence predicate change-story-id uses for its
|
|
1357
1458
|
# legality boundary — one test, serving three workflows.
|
|
1358
1459
|
cmd_story_refs() {
|
|
@@ -1392,9 +1493,24 @@ def parse_fm(text):
|
|
|
1392
1493
|
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
1393
1494
|
return fm
|
|
1394
1495
|
|
|
1395
|
-
def
|
|
1396
|
-
try: return
|
|
1397
|
-
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 []
|
|
1398
1514
|
|
|
1399
1515
|
stories = []
|
|
1400
1516
|
if os.path.isdir(sdir):
|
|
@@ -1402,27 +1518,44 @@ if os.path.isdir(sdir):
|
|
|
1402
1518
|
if not name.endswith(".md"): continue
|
|
1403
1519
|
path = os.path.join(sdir, name)
|
|
1404
1520
|
if not os.path.isfile(path): continue
|
|
1405
|
-
|
|
1521
|
+
text = read(path); fm = parse_fm(text)
|
|
1406
1522
|
sid = fm.get("id") or name[:-3]
|
|
1407
1523
|
# An unparseable story is reported as open rather than skipped: a story the scan
|
|
1408
1524
|
# cannot read is exactly the one whose requirements must not move unannounced.
|
|
1409
1525
|
status = (fm.get("status") or "open").strip().lower()
|
|
1410
1526
|
cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
|
|
1527
|
+
bounds = bound_ids(fm, text)
|
|
1411
1528
|
stories.append({"id": sid, "status": status, "exercises": cites,
|
|
1529
|
+
"cites_csv": ", ".join(cites),
|
|
1530
|
+
"constraints": bounds,
|
|
1531
|
+
"constraints_csv": ", ".join(bounds),
|
|
1532
|
+
"carried": sorted(set(cites) | set(bounds)),
|
|
1533
|
+
"title": (fm.get("title") or "").strip().strip("[]"),
|
|
1534
|
+
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
1535
|
+
"type": (fm.get("type") or "functional").strip().lower(),
|
|
1536
|
+
"follows": (fm.get("follows") or "").strip(),
|
|
1412
1537
|
"in_flight": os.path.isdir(os.path.join(sdir, sid))})
|
|
1413
1538
|
|
|
1414
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).
|
|
1415
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"]})
|
|
1416
1549
|
|
|
1417
1550
|
# ASKING ABOUT NOTHING IS NOT ASKING ABOUT EVERYTHING. `--ids ""` is a caller saying "this
|
|
1418
1551
|
# run disturbed no requirement" — a pure addition — and the answer is an empty set. Only
|
|
1419
1552
|
# the absence of the flag means "tell me about the whole backlog". Conflating the two made
|
|
1420
1553
|
# an enhancement that touched nothing existing prompt about every requirement with a story.
|
|
1421
|
-
targets = queried if os.environ.get("AW_IDS_GIVEN") else
|
|
1554
|
+
targets = queried if os.environ.get("AW_IDS_GIVEN") else carried
|
|
1422
1555
|
|
|
1423
1556
|
affected, aff_ids, aff_stories, inflight = [], [], [], []
|
|
1424
1557
|
for rid in targets:
|
|
1425
|
-
hit = [s for s in open_stories if rid in s["
|
|
1558
|
+
hit = [s for s in open_stories if rid in s["carried"]]
|
|
1426
1559
|
if not hit: continue
|
|
1427
1560
|
aff_ids.append(rid)
|
|
1428
1561
|
names = [s["id"] for s in hit]
|
|
@@ -1430,11 +1563,33 @@ for rid in targets:
|
|
|
1430
1563
|
aff_stories += names; inflight += flying
|
|
1431
1564
|
affected.append({"id": rid, "stories": names, "in_flight": flying})
|
|
1432
1565
|
|
|
1566
|
+
# THE COMPLEMENT, NAMED. `affected` is "these ids have live work"; its complement is
|
|
1567
|
+
# "these ids have nobody on them", and that is the set sync-stories raises new stories for.
|
|
1568
|
+
# Derived here rather than in the caller because the orchestrator binds values and never
|
|
1569
|
+
# computes them, and a subtraction done in a prompt is a subtraction nothing can check.
|
|
1570
|
+
unlinked = [rid for rid in targets if rid not in aff_ids]
|
|
1571
|
+
|
|
1572
|
+
# One line per open story, for a prompt that has to ask about them. A story id on its own
|
|
1573
|
+
# is not something an author can answer about: what it cites and whether someone is already
|
|
1574
|
+
# implementing it are the whole basis of the decision.
|
|
1575
|
+
digest = "\n".join(
|
|
1576
|
+
" %-16s %-9s %-11s cites %s%s%s" % (
|
|
1577
|
+
s["id"], s["status"], "IN FLIGHT" if s["in_flight"] else "not started",
|
|
1578
|
+
s["cites_csv"] or "nothing",
|
|
1579
|
+
(" bound by " + s["constraints_csv"]) if s["constraints_csv"] else "",
|
|
1580
|
+
(" @ " + s["spec_version"]) if s["spec_version"] else "")
|
|
1581
|
+
for s in open_stories) or " (no open stories)"
|
|
1582
|
+
|
|
1433
1583
|
print(json.dumps({
|
|
1434
1584
|
"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
|
|
1435
1585
|
"story_count": len(stories), "open_count": len(open_stories),
|
|
1586
|
+
"stories": stories, "digest": digest,
|
|
1436
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),
|
|
1437
1590
|
"affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
|
|
1591
|
+
"unlinked": unlinked, "unlinked_csv": ", ".join(unlinked),
|
|
1592
|
+
"unlinked_count": len(unlinked), "any_unlinked": bool(unlinked),
|
|
1438
1593
|
"story_csv": ", ".join(sorted(set(aff_stories))),
|
|
1439
1594
|
"in_flight_csv": ", ".join(sorted(set(inflight))),
|
|
1440
1595
|
"any": bool(aff_ids), "any_in_flight": bool(inflight),
|
|
@@ -1450,7 +1605,7 @@ PY
|
|
|
1450
1605
|
# discarded by design — the story is where delivery is declared.
|
|
1451
1606
|
#
|
|
1452
1607
|
# Every workflow that changes either side calls this: specify when the requirements move,
|
|
1453
|
-
# user-story and
|
|
1608
|
+
# user-story and sync-stories when the backlog does. It is deterministic and model-free,
|
|
1454
1609
|
# so calling it more often than necessary costs nothing and drift is not possible.
|
|
1455
1610
|
#
|
|
1456
1611
|
# Scoped to FR-/BR- — the ids a story cites. CN-/DC-/IC-/UX- are constraints the design
|
|
@@ -1543,6 +1698,1159 @@ print(json.dumps(out))
|
|
|
1543
1698
|
PY
|
|
1544
1699
|
}
|
|
1545
1700
|
|
|
1701
|
+
# ─────────────────────────────── ledger-read ───────────────────────────────
|
|
1702
|
+
# THE WORK SET: which requirements this sync has to account for. The ledger records what
|
|
1703
|
+
# each version touched and every story records the version it last synced at, so the
|
|
1704
|
+
# question "what has moved since the backlog was last in step" is answerable by reading
|
|
1705
|
+
# rows — no diffing, no hashing, no model.
|
|
1706
|
+
#
|
|
1707
|
+
# The set is the union of the ids named by every row ABOVE THE LOWEST spec_version across
|
|
1708
|
+
# the OPEN stories, not the ids of the newest row alone. Those are the same set whenever
|
|
1709
|
+
# the backlog is one version behind, which is the ordinary case; they diverge only when a
|
|
1710
|
+
# sync was skipped, and that is exactly the case worth getting right. Two specify runs
|
|
1711
|
+
# without a sync between them leave the middle version named by no row anyone reads, and
|
|
1712
|
+
# its requirements would get no story with nothing to say so — a silent gap, in the one
|
|
1713
|
+
# mechanism built to make gaps loud.
|
|
1714
|
+
#
|
|
1715
|
+
# THE v1.0 CASE (E42). At v1.0 nothing has moved yet: there is no "since" to read from. So
|
|
1716
|
+
# when no open story carries a readable spec version — an empty backlog, or stories written
|
|
1717
|
+
# before the field existed — the work set is every FR-/BR- the DOCUMENT declares, and the
|
|
1718
|
+
# per-id four-case table does the rest. A second run over the same spec then creates
|
|
1719
|
+
# nothing, because by then every id has a story.
|
|
1720
|
+
#
|
|
1721
|
+
# Scoped to FR-/BR- throughout, the same scoping spec-coverage applies: those are the only
|
|
1722
|
+
# classes a story's `exercises:` can name. CN-/DC-/IC-/UX- move the contract and belong in
|
|
1723
|
+
# the ledger row, but they are constraints a story CARRIES, not slices of work it delivers.
|
|
1724
|
+
cmd_ledger_read() {
|
|
1725
|
+
local fd_arg=""
|
|
1726
|
+
while [ $# -gt 0 ]; do
|
|
1727
|
+
case "$1" in
|
|
1728
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
1729
|
+
--help|-h) echo "ledger-read [--feature-dir <path>]"; return 0 ;;
|
|
1730
|
+
*) echo "ledger-read: unknown arg '$1'" >&2; return 1 ;;
|
|
1731
|
+
esac
|
|
1732
|
+
done
|
|
1733
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
1734
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "ledger-read: no feature set and no --feature-dir" >&2; return 1; }
|
|
1735
|
+
AW_FD="$fd" python3 - <<'PY'
|
|
1736
|
+
import json, os, re, sys
|
|
1737
|
+
fd = os.environ["AW_FD"]
|
|
1738
|
+
spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
|
|
1739
|
+
|
|
1740
|
+
def bail(msg, code=1):
|
|
1741
|
+
print(json.dumps({"ok": False, "error": msg}))
|
|
1742
|
+
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
1743
|
+
|
|
1744
|
+
if not os.path.isfile(spec): bail("no spec.md at %s" % fd)
|
|
1745
|
+
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
1746
|
+
|
|
1747
|
+
COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
|
|
1748
|
+
|
|
1749
|
+
def vtuple(v):
|
|
1750
|
+
m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
|
|
1751
|
+
return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
|
|
1752
|
+
|
|
1753
|
+
def rids(s):
|
|
1754
|
+
"""FR-/BR- ids in order of appearance, deduped, then sorted by class and number so
|
|
1755
|
+
two callers reading the same row always produce the same csv."""
|
|
1756
|
+
seen, out = set(), []
|
|
1757
|
+
for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
|
|
1758
|
+
if t not in seen: seen.add(t); out.append(t)
|
|
1759
|
+
return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
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
|
+
|
|
1773
|
+
def parse_fm(text):
|
|
1774
|
+
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
1775
|
+
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
1776
|
+
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
1777
|
+
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
1778
|
+
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
1779
|
+
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
1780
|
+
if not m: return {}
|
|
1781
|
+
fm, key, block = {}, None, set()
|
|
1782
|
+
for line in m.group(1).split("\n"):
|
|
1783
|
+
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
1784
|
+
if km:
|
|
1785
|
+
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
1786
|
+
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
1787
|
+
if im and (key in block or not fm[key]):
|
|
1788
|
+
block.add(key)
|
|
1789
|
+
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
1790
|
+
return fm
|
|
1791
|
+
|
|
1792
|
+
# THE LEDGER TABLE, located the same way _spec_ledger locates it — heading, separator rule,
|
|
1793
|
+
# then the rows under it, bounded by the next `## `. Any other section carries its own
|
|
1794
|
+
# table (Story Coverage does), and an unbounded search would read one of those as history.
|
|
1795
|
+
lines = text.split("\n")
|
|
1796
|
+
hi = next((i for i, l in enumerate(lines)
|
|
1797
|
+
if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
|
|
1798
|
+
sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
|
|
1799
|
+
len(lines)) if hi >= 0 else -1)
|
|
1800
|
+
sep = (next((i for i in range(hi + 1, sec_end)
|
|
1801
|
+
if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
|
|
1802
|
+
row_lines = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
|
|
1803
|
+
has_ledger = hi >= 0 and sep >= 0
|
|
1804
|
+
|
|
1805
|
+
def cells(line):
|
|
1806
|
+
parts = re.split(r'(?<!\\)\|', line)
|
|
1807
|
+
return [c.strip().replace("\\|", "|") for c in parts[1:-1]] if len(parts) >= 3 else []
|
|
1808
|
+
|
|
1809
|
+
rows = []
|
|
1810
|
+
for i in row_lines:
|
|
1811
|
+
c = cells(lines[i]); c += [""] * (5 - len(c))
|
|
1812
|
+
# A row whose first cell is not a version is the template's own placeholder line, or a
|
|
1813
|
+
# note somebody left in the table. It records no version, so it orders against nothing.
|
|
1814
|
+
if not re.match(r'^v\d', c[0]): continue
|
|
1815
|
+
rows.append({"version": c[0], "date": c[1], "change": c[2], "ids_cell": c[3],
|
|
1816
|
+
"stories": c[4], "requirements": rids(c[3]),
|
|
1817
|
+
"non_story": nids(c[3])})
|
|
1818
|
+
rows.sort(key=lambda r: vtuple(r["version"]))
|
|
1819
|
+
|
|
1820
|
+
# WHAT THE DOCUMENT STILL DECLARES. Read from the requirement's DEFINING line — the bolded
|
|
1821
|
+
# id the template gives every requirement — and with the Change History cut away, so the
|
|
1822
|
+
# ids named in a row's own cell are never mistaken for requirements the spec carries.
|
|
1823
|
+
body = COV.sub("", text).split("\n## Change History")[0]
|
|
1824
|
+
declared = []
|
|
1825
|
+
for line in body.split("\n"):
|
|
1826
|
+
m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
|
|
1827
|
+
if m and m.group(1) not in declared: declared.append(m.group(1))
|
|
1828
|
+
declared.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
1829
|
+
|
|
1830
|
+
stories = []
|
|
1831
|
+
if os.path.isdir(sdir):
|
|
1832
|
+
for name in sorted(os.listdir(sdir)):
|
|
1833
|
+
if not name.endswith(".md"): continue
|
|
1834
|
+
p = os.path.join(sdir, name)
|
|
1835
|
+
if not os.path.isfile(p): continue
|
|
1836
|
+
fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
|
|
1837
|
+
sid = fm.get("id") or name[:-3]
|
|
1838
|
+
stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
|
|
1839
|
+
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
1840
|
+
"follows": (fm.get("follows") or "").strip(),
|
|
1841
|
+
"in_flight": os.path.isdir(os.path.join(sdir, sid)),
|
|
1842
|
+
"cites": rids(fm.get("exercises", ""))})
|
|
1843
|
+
open_stories = [s for s in stories if s["status"] == "open"]
|
|
1844
|
+
|
|
1845
|
+
# THE FLOOR is the oldest version any open story is still sitting at, and everything above
|
|
1846
|
+
# it is unsynced. A story whose spec_version is missing or unreadable counts as v0.0 rather
|
|
1847
|
+
# than being skipped: it has demonstrably never synced, and putting the whole ledger above
|
|
1848
|
+
# it is the same answer enumerating from the document would give.
|
|
1849
|
+
floors = [vtuple(s["spec_version"]) for s in open_stories]
|
|
1850
|
+
readable = [s for s in open_stories if re.match(r'^v?\d', s["spec_version"] or "")]
|
|
1851
|
+
# A BACKLOG THAT IS ENTIRELY CLOSED IS NOT A BACKLOG THAT NEVER SYNCED. Reading the floor
|
|
1852
|
+
# from open stories alone made the last story closing look exactly like a feature nobody has
|
|
1853
|
+
# ever sliced: no floor took the enumerate-the-whole-document branch below, so one idle
|
|
1854
|
+
# re-run after finishing a feature raised a fresh story for every requirement in the spec —
|
|
1855
|
+
# each one reported as work the contract had moved on, when nothing had moved at all. A
|
|
1856
|
+
# CLOSED story carries a stamp too, and it records which version its work was measured
|
|
1857
|
+
# against. CANCELLED ones are excluded: abandoned work delivered nothing and settles nothing,
|
|
1858
|
+
# so it establishes no floor and leaves its requirements to be sliced again.
|
|
1859
|
+
closed_stories = [s for s in stories if s["status"] == "closed"]
|
|
1860
|
+
floor_source = "open" if floors else ("closed" if closed_stories else "none")
|
|
1861
|
+
floor = (min(floors) if floors
|
|
1862
|
+
else (min(vtuple(s["spec_version"]) for s in closed_stories) if closed_stories
|
|
1863
|
+
else None))
|
|
1864
|
+
|
|
1865
|
+
latest = rows[-1] if rows else None
|
|
1866
|
+
out = {"ok": True, "feature_dir": fd, "has_ledger": has_ledger,
|
|
1867
|
+
"row_count": len(rows), "ledger_empty": len(rows) == 0,
|
|
1868
|
+
"rows": rows,
|
|
1869
|
+
"latest_version": latest["version"] if latest else "",
|
|
1870
|
+
"latest_change": latest["change"] if latest else "",
|
|
1871
|
+
"latest_ids_csv": ", ".join(latest["requirements"]) if latest else "",
|
|
1872
|
+
"spec_ids_csv": ", ".join(declared), "spec_id_count": len(declared),
|
|
1873
|
+
"story_count": len(stories), "open_count": len(open_stories),
|
|
1874
|
+
"unversioned_open": len(open_stories) - len(readable)}
|
|
1875
|
+
|
|
1876
|
+
if not has_ledger:
|
|
1877
|
+
out.update(ok=False, error="spec.md has no '## Change History' table. That table is "
|
|
1878
|
+
"where a story learns a requirement it cites has moved, so a spec without "
|
|
1879
|
+
"one cannot be kept in step with its backlog.")
|
|
1880
|
+
print(json.dumps(out)); sys.stderr.write(out["error"] + "\n"); sys.exit(2)
|
|
1881
|
+
|
|
1882
|
+
if not rows:
|
|
1883
|
+
# An empty ledger is not corruption — it is a spec that has never been versioned, which
|
|
1884
|
+
# means specify has not finished with it. Reported, not raised: the caller warns and
|
|
1885
|
+
# stops, because there is no "since" to sync from and inventing one would raise a
|
|
1886
|
+
# backlog against a document nobody has committed to.
|
|
1887
|
+
out.update(work_csv="", work_count=0, work_source="none", span_versions_csv="",
|
|
1888
|
+
gap=False, removed_csv="",
|
|
1889
|
+
non_story_csv="", non_story_count=0, non_story_any=False,
|
|
1890
|
+
work_kind="none",
|
|
1891
|
+
work_note="the Change History table carries no version row")
|
|
1892
|
+
print(json.dumps(out)); sys.exit(0)
|
|
1893
|
+
|
|
1894
|
+
if floor is None:
|
|
1895
|
+
# E42 — nothing has moved because nothing has synced. Enumerate from the document.
|
|
1896
|
+
source, span = "spec", []
|
|
1897
|
+
candidates = list(declared)
|
|
1898
|
+
note = ("nothing has synced against this spec yet — no open story records a version and "
|
|
1899
|
+
"nothing has closed against it — so there is no 'since' to read from: the work "
|
|
1900
|
+
"set is every requirement the document declares, and the per-id rules then "
|
|
1901
|
+
"create only what has no story")
|
|
1902
|
+
else:
|
|
1903
|
+
span = [r for r in rows if vtuple(r["version"]) > floor]
|
|
1904
|
+
seen, candidates = set(), []
|
|
1905
|
+
for r in span:
|
|
1906
|
+
for rid in r["requirements"]:
|
|
1907
|
+
if rid not in seen: seen.add(rid); candidates.append(rid)
|
|
1908
|
+
candidates.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
1909
|
+
source = "ledger"
|
|
1910
|
+
note = ("%d version row(s) above the oldest %s story's v%d.%d: %s"
|
|
1911
|
+
% (len(span), floor_source, floor[0], floor[1],
|
|
1912
|
+
", ".join(r["version"] for r in span) or "none"))
|
|
1913
|
+
|
|
1914
|
+
# THE SPAN IS A PREFILTER; STALENESS IS PER ID. The floor is the oldest version any open
|
|
1915
|
+
# story sits at, and a story whose requirements never move sits there forever — Order-001
|
|
1916
|
+
# holding v1.0 keeps v1.1 and v1.2 in the span long after everything they named has been
|
|
1917
|
+
# synced. Reading the span alone therefore re-offered the same requirements on every run,
|
|
1918
|
+
# and each re-offer is a model call rewriting a story that was already in step.
|
|
1919
|
+
#
|
|
1920
|
+
# So the span says which ids are WORTH CHECKING and this says which have actually moved
|
|
1921
|
+
# under someone: an id is stale when it has no open story at all, or when some open story
|
|
1922
|
+
# citing it is stamped BELOW the newest version that touched it. An id whose every open
|
|
1923
|
+
# story is at or above that version is in step, whatever the floor says.
|
|
1924
|
+
moved_at = {}
|
|
1925
|
+
for r in rows:
|
|
1926
|
+
for rid in r["requirements"]:
|
|
1927
|
+
v = vtuple(r["version"])
|
|
1928
|
+
if v > moved_at.get(rid, (0, 0)): moved_at[rid] = v
|
|
1929
|
+
|
|
1930
|
+
def carried_on(story_id, rid, at):
|
|
1931
|
+
"""Has a follow-on already taken this requirement off that story's hands, at or above
|
|
1932
|
+
the version that moved it. The `follows` link is the whole record of that hand-off."""
|
|
1933
|
+
return any(s["follows"] == story_id and rid in s["cites"]
|
|
1934
|
+
and vtuple(s["spec_version"]) >= at for s in open_stories)
|
|
1935
|
+
|
|
1936
|
+
work, in_step = [], []
|
|
1937
|
+
for rid in candidates:
|
|
1938
|
+
holders = [s for s in open_stories if rid in s["cites"]]
|
|
1939
|
+
at = moved_at.get(rid, (0, 0))
|
|
1940
|
+
behind = []
|
|
1941
|
+
for s in holders:
|
|
1942
|
+
if vtuple(s["spec_version"]) >= at: continue
|
|
1943
|
+
# AN IN-FLIGHT STORY CANNOT BE BROUGHT UP TO DATE, BY DESIGN. It is never rewritten
|
|
1944
|
+
# (§5.5), so its spec_version can never rise, so reading it as a staleness signal
|
|
1945
|
+
# gives a signal that never clears — and a sync that mints a fresh follow-on for the
|
|
1946
|
+
# same requirement on every run. What clears it is the follow-on itself: once one
|
|
1947
|
+
# exists carrying this id at or above the version that moved it, the hand-off has
|
|
1948
|
+
# happened and the original is history in progress, not unfinished business.
|
|
1949
|
+
if s["in_flight"] and carried_on(s["id"], rid, at): continue
|
|
1950
|
+
behind.append(s["id"])
|
|
1951
|
+
# A CLOSED STORY STAMPED AT OR ABOVE THE VERSION THAT LAST MOVED THIS REQUIREMENT
|
|
1952
|
+
# DELIVERED THE WORDING THE SPEC ASKS FOR TODAY, so the requirement is FINISHED rather
|
|
1953
|
+
# than unsliced. Reading "no open story cites it" as work on its own could not tell those
|
|
1954
|
+
# two apart, which is what let a fully delivered feature re-raise its entire backlog.
|
|
1955
|
+
# A closed story stamped BELOW that version is the other case and stays work: the
|
|
1956
|
+
# contract moved after it shipped, so what shipped is no longer what the spec asks for,
|
|
1957
|
+
# and backlog-plan names the closed story it is re-opening against.
|
|
1958
|
+
delivered = any(rid in s["cites"] and vtuple(s["spec_version"]) >= at
|
|
1959
|
+
for s in closed_stories)
|
|
1960
|
+
(in_step if ((holders and not behind) or (not holders and delivered))
|
|
1961
|
+
else work).append(rid)
|
|
1962
|
+
|
|
1963
|
+
# A row names what a version TOUCHED, and that includes ids it REMOVED. A removed
|
|
1964
|
+
# requirement has no wording left to write a story from, so it leaves the work set — but it
|
|
1965
|
+
# is reported, because every open story citing one is orphaned and that is a decision for a
|
|
1966
|
+
# person, not a default (E1).
|
|
1967
|
+
removed = [i for i in work if i not in declared]
|
|
1968
|
+
work = [i for i in work if i in declared]
|
|
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
|
+
|
|
1985
|
+
out.update(work_csv=", ".join(work), work_count=len(work), work_source=source,
|
|
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,
|
|
1989
|
+
candidates_csv=", ".join(candidates), candidate_count=len(candidates),
|
|
1990
|
+
in_step_csv=", ".join(in_step), in_step_count=len(in_step),
|
|
1991
|
+
floor_source=floor_source,
|
|
1992
|
+
span_versions_csv=", ".join(r["version"] for r in span),
|
|
1993
|
+
span_count=len(span), gap=len(span) > 1,
|
|
1994
|
+
removed_csv=", ".join(removed), removed_count=len(removed),
|
|
1995
|
+
synced_floor=("v%d.%d" % floor) if floor else "")
|
|
1996
|
+
print(json.dumps(out))
|
|
1997
|
+
PY
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
# ─────────────────────────────── slice-mode ────────────────────────────────
|
|
2001
|
+
# Which slicing strategy the launch line asked for, and what is left of the input once that
|
|
2002
|
+
# word is taken out of it. Its own op rather than a `condition:` in the workflow, because
|
|
2003
|
+
# the engine compares a whole rendered value: `{{USER_INPUT}} == single-run` only matches an
|
|
2004
|
+
# input carrying the strategy and no guidance at all, and `sync-stories single-run keep the
|
|
2005
|
+
# payment rules together` — the form the launch line documents — would silently slice by
|
|
2006
|
+
# default while looking as though it had been told not to.
|
|
2007
|
+
#
|
|
2008
|
+
# ONLY THE FIRST WORD IS A STRATEGY. Guidance is free prose, and "keep the single sign-on
|
|
2009
|
+
# requirements in one story" is a slicing instruction with the token sitting in the middle
|
|
2010
|
+
# of it. Reading the token anywhere in the string would turn that sentence into the opposite
|
|
2011
|
+
# of what it asks, with nothing to say so.
|
|
2012
|
+
cmd_slice_mode() {
|
|
2013
|
+
local input=""
|
|
2014
|
+
while [ $# -gt 0 ]; do
|
|
2015
|
+
case "$1" in
|
|
2016
|
+
--input) input="$2"; shift 2 ;;
|
|
2017
|
+
--help|-h) echo "slice-mode [--input <text>] -> {strategy, guidance, note}"; return 0 ;;
|
|
2018
|
+
*) echo "slice-mode: unknown arg '$1'" >&2; return 1 ;;
|
|
2019
|
+
esac
|
|
2020
|
+
done
|
|
2021
|
+
AW_INPUT="$input" python3 - <<'PY'
|
|
2022
|
+
import json, os, re
|
|
2023
|
+
raw = (os.environ.get("AW_INPUT", "") or "").strip()
|
|
2024
|
+
|
|
2025
|
+
# `single-run` is the spelling the launch line documents; the others are what people write
|
|
2026
|
+
# when they mean it. `auto` is the default and is accepted explicitly so a launch line can
|
|
2027
|
+
# say so — a strategy nobody can name is one nobody can turn off.
|
|
2028
|
+
TOKENS = {"single-run": "single", "single-story": "single", "single": "single",
|
|
2029
|
+
"one-story": "single", "auto": "auto", "per-slice": "auto", "default": "auto"}
|
|
2030
|
+
|
|
2031
|
+
strategy, guidance = "auto", raw
|
|
2032
|
+
m = re.match(r'^([A-Za-z][A-Za-z0-9_-]*)\s*[:,]?\s*(.*)$', raw, re.S)
|
|
2033
|
+
if m and m.group(1).lower() in TOKENS:
|
|
2034
|
+
strategy = TOKENS[m.group(1).lower()]
|
|
2035
|
+
guidance = m.group(2).strip()
|
|
2036
|
+
|
|
2037
|
+
if strategy == "single":
|
|
2038
|
+
note = ("one story was asked for (single-run): the whole work set goes into a single "
|
|
2039
|
+
"slice unless it is too heavy to be picked up as one piece of work, in which "
|
|
2040
|
+
"case it is split along its dependencies and the split is reported")
|
|
2041
|
+
else:
|
|
2042
|
+
note = "the slicing is decided from the requirements themselves"
|
|
2043
|
+
|
|
2044
|
+
print(json.dumps({"ok": True, "strategy": strategy, "guidance": guidance,
|
|
2045
|
+
"single": strategy == "single", "note": note,
|
|
2046
|
+
"raw": raw, "tokens": sorted(TOKENS)}))
|
|
2047
|
+
PY
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
# ─────────────────────────────── backlog-plan ──────────────────────────────
|
|
2051
|
+
# The grouping is a judgment; everything that follows from it is arithmetic. This op does
|
|
2052
|
+
# the arithmetic, in two modes over the same inputs so the two passes cannot disagree about
|
|
2053
|
+
# what a group contains:
|
|
2054
|
+
#
|
|
2055
|
+
# scan which OPEN stories already stand in front of a group's requirements — the
|
|
2056
|
+
# candidates the author is asked about, and the wording of that question.
|
|
2057
|
+
# plan given the author's answer, the flat list of story files to write: one entry per
|
|
2058
|
+
# file, each already carrying its id, its path, and the ids it must end up citing.
|
|
2059
|
+
#
|
|
2060
|
+
# THE FOUR CASES (§5.4) resolve per group, not per requirement, because a group IS the story:
|
|
2061
|
+
#
|
|
2062
|
+
# no open story cites anything in the group -> create
|
|
2063
|
+
# an open story does, no stories/<ID>/ yet -> update in place (asked)
|
|
2064
|
+
# an open story does, a story plan already exists -> mint a follow-on (asked); the
|
|
2065
|
+
# original is never rewritten (§5.5)
|
|
2066
|
+
# the author declines -> that story is left alone AND the ids
|
|
2067
|
+
# it covers leave the group; whatever
|
|
2068
|
+
# remains becomes a new story
|
|
2069
|
+
#
|
|
2070
|
+
# The last line is the one worth reading twice. Declining does not abandon the group — it
|
|
2071
|
+
# withdraws the requirements that story is already carrying and creates a story for the
|
|
2072
|
+
# rest. A group of three where one id has a story the author will not touch still delivers
|
|
2073
|
+
# the other two.
|
|
2074
|
+
#
|
|
2075
|
+
# ONE GROUP, ONE ABSORBING STORY. Where the author approves two stories in the same group,
|
|
2076
|
+
# the first by id takes the group's unclaimed requirements and the others keep exactly the
|
|
2077
|
+
# ids they already cite. Two stories both claiming the same new requirement is not a state
|
|
2078
|
+
# the completeness rule can read.
|
|
2079
|
+
#
|
|
2080
|
+
# IN FLIGHT IS TESTED PER STORY, NOT ONLY ON THE FIRST. An approved story that already has
|
|
2081
|
+
# a story with a story plan is never rewritten wherever it sits in the group — it gets its own follow-on
|
|
2082
|
+
# carrying what it covers, exactly as it would if it were the only story there.
|
|
2083
|
+
#
|
|
2084
|
+
# AND THE ORDER. Plan mode also resolves the dependency edges the grouping declared between
|
|
2085
|
+
# slices, folds in the `depends_on:` an updated story already carries, breaks any cycle, and
|
|
2086
|
+
# lays the result out in WAVES: wave N holds the stories whose every dependency sits below N,
|
|
2087
|
+
# so one wave is a set with no ordering between its members — they may be picked up in
|
|
2088
|
+
# parallel. The plan comes back sorted by wave, which is also the order the stories are
|
|
2089
|
+
# written in, so meeting the cap costs the LAST wave rather than an arbitrary slice of the
|
|
2090
|
+
# backlog. See the block above the wave computation for why `follows:` is not an edge.
|
|
2091
|
+
cmd_backlog_plan() {
|
|
2092
|
+
local fd_arg="" groups="" approved="" work="" version="" mode="scan" cap="" strategy="" ceiling=""
|
|
2093
|
+
while [ $# -gt 0 ]; do
|
|
2094
|
+
case "$1" in
|
|
2095
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
2096
|
+
--groups) groups="$2"; shift 2 ;;
|
|
2097
|
+
--approved) approved="$2"; shift 2 ;;
|
|
2098
|
+
--work) work="$2"; shift 2 ;;
|
|
2099
|
+
--version) version="$2"; shift 2 ;;
|
|
2100
|
+
--cap) cap="$2"; shift 2 ;;
|
|
2101
|
+
--strategy) strategy="$2"; shift 2 ;;
|
|
2102
|
+
--ceiling) ceiling="$2"; shift 2 ;;
|
|
2103
|
+
--plan) mode="plan"; shift ;;
|
|
2104
|
+
--help|-h) echo "backlog-plan --groups <json> --work <csv> [--strategy single|auto] [--ceiling <n>] [--feature-dir <p>]"; echo "backlog-plan --plan --groups <json> --work <csv> --approved <story ids> --version <v> [--cap <n>] [--strategy single|auto] [--ceiling <n>]"; return 0 ;;
|
|
2105
|
+
*) echo "backlog-plan: unknown arg '$1'" >&2; return 1 ;;
|
|
2106
|
+
esac
|
|
2107
|
+
done
|
|
2108
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
2109
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "backlog-plan: no feature set and no --feature-dir" >&2; return 1; }
|
|
2110
|
+
AW_FD="$fd" AW_GROUPS="$groups" AW_OK="$approved" AW_WORK="$work" AW_VER="$version" AW_MODE="$mode" AW_CAP="$cap" AW_STRATEGY="$strategy" AW_CEILING="$ceiling" python3 - <<'PY'
|
|
2111
|
+
import json, os, re, sys
|
|
2112
|
+
fd = os.environ["AW_FD"]; mode = os.environ.get("AW_MODE", "scan")
|
|
2113
|
+
sdir = os.path.join(fd, "stories")
|
|
2114
|
+
feature = os.path.basename(fd.rstrip("/\\"))
|
|
2115
|
+
|
|
2116
|
+
def bail(msg, code=2):
|
|
2117
|
+
print(json.dumps({"ok": False, "error": msg}))
|
|
2118
|
+
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
2119
|
+
|
|
2120
|
+
def rids(s):
|
|
2121
|
+
seen, out = set(), []
|
|
2122
|
+
for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
|
|
2123
|
+
if t not in seen: seen.add(t); out.append(t)
|
|
2124
|
+
return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
2125
|
+
|
|
2126
|
+
def sids(s):
|
|
2127
|
+
"""Story ids out of a csv, a JSON list or a YAML inline list. A story id is a feature
|
|
2128
|
+
name and a number; a template placeholder (`[]`, `[STORY-ID]`) is neither, and must
|
|
2129
|
+
never become a dependency edge nobody can resolve."""
|
|
2130
|
+
seen, out = set(), []
|
|
2131
|
+
for t in re.split(r'[,\s\[\]"\']+', s or ""):
|
|
2132
|
+
t = t.strip()
|
|
2133
|
+
if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
|
|
2134
|
+
seen.add(t); out.append(t)
|
|
2135
|
+
return out
|
|
2136
|
+
|
|
2137
|
+
def wave_of(s):
|
|
2138
|
+
"""A story's declared wave. Absent — a story written before the field existed — reads
|
|
2139
|
+
as 1, the conservative floor: nothing is KNOWN to have to land before it."""
|
|
2140
|
+
m = re.match(r'^\s*(\d+)\s*$', (s or "").strip())
|
|
2141
|
+
return int(m.group(1)) if m else 1
|
|
2142
|
+
|
|
2143
|
+
def names(s):
|
|
2144
|
+
"""Story ids out of a csv or a JSON list. A multi-select answer arrives as the engine's
|
|
2145
|
+
JSON rendering of a list, so both spellings have to read the same."""
|
|
2146
|
+
seen, out = set(), []
|
|
2147
|
+
for t in re.split(r'[,\s\[\]"\']+', s or ""):
|
|
2148
|
+
t = t.strip()
|
|
2149
|
+
if t and t not in seen: seen.add(t); out.append(t)
|
|
2150
|
+
return out
|
|
2151
|
+
|
|
2152
|
+
def parse_fm(text):
|
|
2153
|
+
"""Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
|
|
2154
|
+
a YAML block list under an empty key — both are valid YAML and a hand edit produces
|
|
2155
|
+
either, so a parser that read only the inline form saw a perfectly good story as citing
|
|
2156
|
+
nothing, and a story citing nothing is invisible to every gate that exists to protect
|
|
2157
|
+
it. Block items are folded into the inline shape so every caller stays regex-simple."""
|
|
2158
|
+
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
2159
|
+
if not m: return {}
|
|
2160
|
+
fm, key, block = {}, None, set()
|
|
2161
|
+
for line in m.group(1).split("\n"):
|
|
2162
|
+
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
2163
|
+
if km:
|
|
2164
|
+
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
2165
|
+
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
2166
|
+
if im and (key in block or not fm[key]):
|
|
2167
|
+
block.add(key)
|
|
2168
|
+
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
2169
|
+
return fm
|
|
2170
|
+
|
|
2171
|
+
# ---- the backlog -----------------------------------------------------------------
|
|
2172
|
+
stories, used_numbers = [], []
|
|
2173
|
+
if os.path.isdir(sdir):
|
|
2174
|
+
for name in sorted(os.listdir(sdir)):
|
|
2175
|
+
if not name.endswith(".md"): continue
|
|
2176
|
+
p = os.path.join(sdir, name)
|
|
2177
|
+
if not os.path.isfile(p): continue
|
|
2178
|
+
fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
|
|
2179
|
+
sid = fm.get("id") or name[:-3]
|
|
2180
|
+
# Numbers come from the FILENAME as well as the id, and from CLOSED and CANCELLED
|
|
2181
|
+
# stories as well as open ones. An id is permanent once minted: re-issuing the
|
|
2182
|
+
# number of a story that shipped re-points its execution artefacts at new work.
|
|
2183
|
+
for cand in (sid, name[:-3]):
|
|
2184
|
+
m = re.search(r'-(\d+)$', cand)
|
|
2185
|
+
if m: used_numbers.append(int(m.group(1)))
|
|
2186
|
+
stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
|
|
2187
|
+
"title": (fm.get("title") or "").strip().strip("[]"),
|
|
2188
|
+
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
2189
|
+
"cites": rids(fm.get("exercises", "")),
|
|
2190
|
+
"wave": wave_of(fm.get("wave", "")),
|
|
2191
|
+
"deps": sids(fm.get("depends_on", "")),
|
|
2192
|
+
"in_flight": os.path.isdir(os.path.join(sdir, sid)),
|
|
2193
|
+
"path": p})
|
|
2194
|
+
open_stories = [s for s in stories if s["status"] == "open"]
|
|
2195
|
+
# Closed and cancelled stories hold no requirement — completeness is "no OPEN stories" —
|
|
2196
|
+
# but they are the reason a create can look like duplicated work, so they are read in
|
|
2197
|
+
# order to say so. See the create branch below.
|
|
2198
|
+
done_stories = [s for s in stories if s["status"] != "open"]
|
|
2199
|
+
by_id = {s["id"]: s for s in stories}
|
|
2200
|
+
_next = [max(used_numbers) + 1 if used_numbers else 1]
|
|
2201
|
+
|
|
2202
|
+
def mint():
|
|
2203
|
+
n = _next[0]; _next[0] += 1
|
|
2204
|
+
return "%s-%03d" % (feature, n)
|
|
2205
|
+
|
|
2206
|
+
# ---- the groups ------------------------------------------------------------------
|
|
2207
|
+
raw = (os.environ.get("AW_GROUPS") or "").strip()
|
|
2208
|
+
try:
|
|
2209
|
+
parsed = json.loads(raw) if raw else []
|
|
2210
|
+
except Exception as e:
|
|
2211
|
+
bail("backlog-plan: --groups is not valid JSON (%s)" % e)
|
|
2212
|
+
if isinstance(parsed, dict): parsed = parsed.get("groups") or []
|
|
2213
|
+
if not isinstance(parsed, list):
|
|
2214
|
+
bail("backlog-plan: --groups must be a JSON list of groups")
|
|
2215
|
+
|
|
2216
|
+
# Ids are read from the group's OWN requirements field, never scraped from its prose. A
|
|
2217
|
+
# rationale that mentions FR-004 as the reason two others belong together would otherwise
|
|
2218
|
+
# pull a third requirement into the story, and nothing downstream would question it.
|
|
2219
|
+
work = rids(os.environ.get("AW_WORK", ""))
|
|
2220
|
+
groups, dropped = [], []
|
|
2221
|
+
for i, g in enumerate(parsed):
|
|
2222
|
+
if not isinstance(g, dict): continue
|
|
2223
|
+
src = g.get("requirements")
|
|
2224
|
+
if isinstance(src, list): src = ", ".join(str(x) for x in src)
|
|
2225
|
+
ids = rids(src if src is not None else g.get("requirements_csv", ""))
|
|
2226
|
+
if work:
|
|
2227
|
+
outside = [x for x in ids if x not in work]
|
|
2228
|
+
dropped += outside
|
|
2229
|
+
ids = [x for x in ids if x in work]
|
|
2230
|
+
if not ids: continue
|
|
2231
|
+
# `depends_on` names OTHER GROUPS by key, or open stories already in the backlog by
|
|
2232
|
+
# id. Kept as raw tokens: a group key only becomes a story id once the plan has minted
|
|
2233
|
+
# one, so resolving here would have to guess at a name that does not exist yet.
|
|
2234
|
+
dep_src = g.get("depends_on")
|
|
2235
|
+
if dep_src is None: dep_src = g.get("depends_on_csv")
|
|
2236
|
+
if isinstance(dep_src, list): dep_src = ", ".join(str(x) for x in dep_src)
|
|
2237
|
+
deps_raw = [x for x in re.split(r'[,\s\[\]"\']+', str(dep_src or "")) if x.strip()]
|
|
2238
|
+
groups.append({"key": g.get("id") or ("g%d" % (i + 1)),
|
|
2239
|
+
"title": (g.get("title") or "").strip(),
|
|
2240
|
+
"rationale": (g.get("rationale") or "").strip(),
|
|
2241
|
+
"complexity": (g.get("complexity") or "").strip().lower(),
|
|
2242
|
+
"deps_raw": deps_raw,
|
|
2243
|
+
"ids": ids, "ids_csv": ", ".join(ids)})
|
|
2244
|
+
|
|
2245
|
+
# Every work-set id must land in exactly one group. One the model left out gets no story
|
|
2246
|
+
# and nothing says so; one it repeated gets two, each claiming the same requirement.
|
|
2247
|
+
placed = [x for g in groups for x in g["ids"]]
|
|
2248
|
+
repeated = sorted({x for x in placed if placed.count(x) > 1})
|
|
2249
|
+
|
|
2250
|
+
# A REPEATED ID IS RESOLVED HERE, not merely reported. Two groups both claiming one
|
|
2251
|
+
# requirement become two stories both claiming it, and "does this requirement still have
|
|
2252
|
+
# open stories" — the completeness rule — then answers for work that was never really
|
|
2253
|
+
# duplicated. The grouping is a judgment; the arithmetic over it is not. So the id stays in
|
|
2254
|
+
# the FIRST group that claimed it and leaves the rest, which is deterministic (the same
|
|
2255
|
+
# grouping always yields the same slices) and loses no coverage, because the id still
|
|
2256
|
+
# reaches exactly one story. repeated_notice reports what that touched.
|
|
2257
|
+
if repeated:
|
|
2258
|
+
seen_ids = set()
|
|
2259
|
+
for g in groups:
|
|
2260
|
+
keep_ids = [x for x in g["ids"] if x not in seen_ids]
|
|
2261
|
+
seen_ids.update(keep_ids)
|
|
2262
|
+
g["ids"], g["ids_csv"] = keep_ids, ", ".join(keep_ids)
|
|
2263
|
+
groups = [g for g in groups if g["ids"]]
|
|
2264
|
+
placed = [x for g in groups for x in g["ids"]]
|
|
2265
|
+
|
|
2266
|
+
missed = [x for x in work if x not in placed]
|
|
2267
|
+
|
|
2268
|
+
def candidates_of(g):
|
|
2269
|
+
"""Open stories standing in front of this group, each with what it already covers OF
|
|
2270
|
+
THIS GROUP — the intersection, not its whole citation list, because that is what the
|
|
2271
|
+
author is being asked to move."""
|
|
2272
|
+
out = []
|
|
2273
|
+
for s in open_stories:
|
|
2274
|
+
shared = [x for x in g["ids"] if x in s["cites"]]
|
|
2275
|
+
if not shared: continue
|
|
2276
|
+
out.append({"story": s["id"], "title": s["title"], "in_flight": s["in_flight"],
|
|
2277
|
+
"spec_version": s["spec_version"], "cites_csv": ", ".join(s["cites"]),
|
|
2278
|
+
"shared_csv": ", ".join(shared), "shared": shared,
|
|
2279
|
+
"group": g["key"], "group_title": g["title"], "group_ids_csv": g["ids_csv"],
|
|
2280
|
+
"action": "followon" if s["in_flight"] else "update"})
|
|
2281
|
+
return sorted(out, key=lambda c: c["story"])
|
|
2282
|
+
|
|
2283
|
+
base = {"ok": True, "feature": feature, "feature_dir": fd, "stories_dir": sdir,
|
|
2284
|
+
"group_count": len(groups), "groups": groups,
|
|
2285
|
+
"work_csv": ", ".join(work),
|
|
2286
|
+
"unplaced_csv": ", ".join(missed), "unplaced_count": len(missed),
|
|
2287
|
+
"repeated_csv": ", ".join(repeated), "repeated_count": len(repeated),
|
|
2288
|
+
"dropped_csv": ", ".join(sorted(set(dropped))),
|
|
2289
|
+
"groups_digest": "\n".join(
|
|
2290
|
+
" [%s] %s\n requirements: %s%s%s"
|
|
2291
|
+
% (g["key"], g["title"] or "(untitled)", g["ids_csv"],
|
|
2292
|
+
("\n stands behind: " + ", ".join(g["deps_raw"])) if g["deps_raw"] else "",
|
|
2293
|
+
("\n " + g["rationale"]) if g["rationale"] else "")
|
|
2294
|
+
for g in groups) or " (no groups)"}
|
|
2295
|
+
|
|
2296
|
+
# THE STRATEGY IS THE AUTHOR'S; THE ARITHMETIC OVER IT IS NOT. `single` asked for one story
|
|
2297
|
+
# covering the whole work set, and the grouping may still have returned several — because a
|
|
2298
|
+
# slice nobody can pick up is not a story, and splitting it is the honest answer. So the
|
|
2299
|
+
# split is REPORTED rather than refused or hidden: quietly handing six stories to someone
|
|
2300
|
+
# who asked for one is the kind of silent disobedience that makes an option not worth having.
|
|
2301
|
+
strategy = (os.environ.get("AW_STRATEGY") or "").strip().lower() or "auto"
|
|
2302
|
+
try:
|
|
2303
|
+
ceiling = int((os.environ.get("AW_CEILING") or "").strip() or 8)
|
|
2304
|
+
except ValueError:
|
|
2305
|
+
ceiling = 8
|
|
2306
|
+
oversized = [g["key"] for g in groups if len(g["ids"]) > ceiling]
|
|
2307
|
+
split_note, split_count = "", 0
|
|
2308
|
+
if strategy == "single" and len(groups) > 1:
|
|
2309
|
+
split_count = len(groups)
|
|
2310
|
+
split_note = ("one story was asked for and the work set was too heavy for one: it is "
|
|
2311
|
+
"split into %d, in dependency order — %s" % (len(groups), "; ".join(
|
|
2312
|
+
"[%s] %s (%s)" % (g["key"], g["title"] or "untitled", g["ids_csv"])
|
|
2313
|
+
for g in groups)))
|
|
2314
|
+
elif strategy == "single" and len(groups) == 1:
|
|
2315
|
+
split_note = ("one story was asked for and one covers the whole work set: %s"
|
|
2316
|
+
% groups[0]["ids_csv"])
|
|
2317
|
+
base.update(strategy=strategy, ceiling=ceiling, split_count=split_count,
|
|
2318
|
+
split_note=split_note, oversized_csv=", ".join(oversized))
|
|
2319
|
+
|
|
2320
|
+
if mode == "scan":
|
|
2321
|
+
cands = [c for g in groups for c in candidates_of(g)]
|
|
2322
|
+
lines = []
|
|
2323
|
+
for c in cands:
|
|
2324
|
+
lines.append(
|
|
2325
|
+
" %s (%s)\n"
|
|
2326
|
+
" group [%s] %s covers %s\n"
|
|
2327
|
+
" this story already covers %s of it, and cites %s in total\n"
|
|
2328
|
+
" %s"
|
|
2329
|
+
% (c["story"], c["title"] or "untitled",
|
|
2330
|
+
c["group"], c["group_title"] or "", c["group_ids_csv"],
|
|
2331
|
+
c["shared_csv"], c["cites_csv"],
|
|
2332
|
+
("ALREADY IN FLIGHT (story plan exists) — picking it mints a FOLLOW-ON "
|
|
2333
|
+
"carrying the change; this story is never rewritten"
|
|
2334
|
+
if c["in_flight"] else
|
|
2335
|
+
"not started (no story plan yet) — picking it rewrites this story to "
|
|
2336
|
+
"cover the whole group")))
|
|
2337
|
+
base.update(candidates=cands, candidate_count=len(cands),
|
|
2338
|
+
candidate_ids=[c["story"] for c in cands],
|
|
2339
|
+
candidate_csv=", ".join(c["story"] for c in cands),
|
|
2340
|
+
any_candidates=bool(cands),
|
|
2341
|
+
ask_digest="\n".join(lines) or " (no existing story covers any of these)")
|
|
2342
|
+
print(json.dumps(base)); sys.exit(0)
|
|
2343
|
+
|
|
2344
|
+
# ---- plan ------------------------------------------------------------------------
|
|
2345
|
+
approved = set(names(os.environ.get("AW_OK", "")))
|
|
2346
|
+
version = (os.environ.get("AW_VER") or "").strip()
|
|
2347
|
+
plan, skipped, notes = [], [], []
|
|
2348
|
+
for g in groups:
|
|
2349
|
+
cands = candidates_of(g)
|
|
2350
|
+
declined = [c for c in cands if c["story"] not in approved]
|
|
2351
|
+
# A declined story keeps its requirements: the author said do not disturb it, and
|
|
2352
|
+
# raising a second story over the same ids disturbs it just as thoroughly.
|
|
2353
|
+
held = {x for c in declined for x in c["shared"]}
|
|
2354
|
+
skipped += [c["story"] for c in declined]
|
|
2355
|
+
remaining = [x for x in g["ids"] if x not in held]
|
|
2356
|
+
taken = [c for c in cands if c["story"] in approved]
|
|
2357
|
+
|
|
2358
|
+
if taken:
|
|
2359
|
+
# ONE APPROVED STORY ABSORBS THE GROUP'S UNCLAIMED REQUIREMENTS — the first by id.
|
|
2360
|
+
# Every OTHER approved story keeps exactly the ids it already cites, so those ids
|
|
2361
|
+
# are RESERVED out of what the carrier gains: two open stories both claiming one
|
|
2362
|
+
# requirement is not a state the completeness rule can read, and the run would have
|
|
2363
|
+
# created that state itself rather than merely reporting it.
|
|
2364
|
+
carrier = taken[0]; s = by_id[carrier["story"]]
|
|
2365
|
+
reserved = {x for c in taken[1:] for x in c["shared"]}
|
|
2366
|
+
gained = [x for x in remaining if x not in reserved]
|
|
2367
|
+
if carrier["in_flight"]:
|
|
2368
|
+
# §5.5 — the original stays open and unchanged, and closes having delivered
|
|
2369
|
+
# what it was written to deliver. The follow-on carries the remainder.
|
|
2370
|
+
carry = [x for x in g["ids"] if x in s["cites"] or x in gained]
|
|
2371
|
+
carry = [x for x in carry if x not in held]
|
|
2372
|
+
sid = mint()
|
|
2373
|
+
plan.append({"action": "followon", "story": sid, "follows": carrier["story"],
|
|
2374
|
+
"path": os.path.join(sdir, sid + ".md"),
|
|
2375
|
+
"requirements_csv": ", ".join(carry), "existing_csv": "",
|
|
2376
|
+
"prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
|
|
2377
|
+
"prior_path": s["path"],
|
|
2378
|
+
"group": g["key"], "group_title": g["title"],
|
|
2379
|
+
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2380
|
+
"rationale": g["rationale"], "spec_version": version})
|
|
2381
|
+
else:
|
|
2382
|
+
keep = sorted(set(s["cites"]) | set(gained),
|
|
2383
|
+
key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
2384
|
+
plan.append({"action": "update", "story": s["id"], "follows": "",
|
|
2385
|
+
"path": s["path"],
|
|
2386
|
+
"requirements_csv": ", ".join(keep),
|
|
2387
|
+
"existing_csv": ", ".join(s["cites"]),
|
|
2388
|
+
"prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
|
|
2389
|
+
"prior_path": s["path"],
|
|
2390
|
+
"group": g["key"], "group_title": g["title"],
|
|
2391
|
+
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2392
|
+
"rationale": g["rationale"], "spec_version": version})
|
|
2393
|
+
carrier_sid = plan[-1]["story"]
|
|
2394
|
+
for c in taken[1:]:
|
|
2395
|
+
s2 = by_id[c["story"]]
|
|
2396
|
+
if c["in_flight"]:
|
|
2397
|
+
# AN IN-FLIGHT STORY IS NEVER REWRITTEN, whatever position it holds in the
|
|
2398
|
+
# group. Its story plan was derived from this body, and
|
|
2399
|
+
# rewriting the body leaves the three disagreeing with nothing to say so —
|
|
2400
|
+
# which is the whole reason §5.5 exists. Being second in a group is not a
|
|
2401
|
+
# different situation from being first, so it gets the same answer: its own
|
|
2402
|
+
# follow-on, carrying the ids it already covers of this group.
|
|
2403
|
+
#
|
|
2404
|
+
# That link is also what clears its staleness. ledger-read discounts an
|
|
2405
|
+
# in-flight story once an open story whose `follows` names it cites the same
|
|
2406
|
+
# id; skipping it instead would leave it stale forever and re-offered on
|
|
2407
|
+
# every run.
|
|
2408
|
+
carry2 = [x for x in c["shared"] if x not in held]
|
|
2409
|
+
if not carry2:
|
|
2410
|
+
notes.append("%s was approved and is in flight, but every requirement "
|
|
2411
|
+
"it covers of group [%s] is held by a story left "
|
|
2412
|
+
"untouched; nothing to carry" % (c["story"], g["key"]))
|
|
2413
|
+
continue
|
|
2414
|
+
sid2 = mint()
|
|
2415
|
+
notes.append("%s was approved and is IN FLIGHT, so it is not rewritten; "
|
|
2416
|
+
"%s carries %s forward from it, while %s takes the group's "
|
|
2417
|
+
"unclaimed requirements"
|
|
2418
|
+
% (c["story"], sid2, ", ".join(carry2), carrier_sid))
|
|
2419
|
+
plan.append({"action": "followon", "story": sid2, "follows": c["story"],
|
|
2420
|
+
"path": os.path.join(sdir, sid2 + ".md"),
|
|
2421
|
+
"requirements_csv": ", ".join(carry2), "existing_csv": "",
|
|
2422
|
+
"prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
|
|
2423
|
+
"prior_path": s2["path"],
|
|
2424
|
+
"group": g["key"], "group_title": g["title"],
|
|
2425
|
+
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2426
|
+
"rationale": g["rationale"], "spec_version": version})
|
|
2427
|
+
continue
|
|
2428
|
+
notes.append("%s was approved but %s takes the group's unclaimed requirements; "
|
|
2429
|
+
"%s is refreshed against the current spec without gaining ids"
|
|
2430
|
+
% (c["story"], carrier_sid, c["story"]))
|
|
2431
|
+
plan.append({"action": "update", "story": s2["id"], "follows": "",
|
|
2432
|
+
"path": s2["path"],
|
|
2433
|
+
"requirements_csv": ", ".join(s2["cites"]),
|
|
2434
|
+
"existing_csv": ", ".join(s2["cites"]),
|
|
2435
|
+
"prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
|
|
2436
|
+
"prior_path": s2["path"],
|
|
2437
|
+
"group": g["key"], "group_title": g["title"],
|
|
2438
|
+
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2439
|
+
"rationale": g["rationale"], "spec_version": version})
|
|
2440
|
+
elif remaining:
|
|
2441
|
+
sid = mint()
|
|
2442
|
+
# A REQUIREMENT WHOSE ONLY STORIES ARE CLOSED RE-ENTERS THE WORK SET, and that is
|
|
2443
|
+
# right: a version row above the closed story's stamp means the contract moved
|
|
2444
|
+
# AFTER it shipped, so what shipped is no longer what the spec asks for. It reads
|
|
2445
|
+
# as duplicated work to anyone who remembers delivering it, so the run names the
|
|
2446
|
+
# story it is re-opening against rather than leaving them to find it.
|
|
2447
|
+
shipped = ["%s (%s)" % (d["id"], ", ".join(x for x in remaining if x in d["cites"]))
|
|
2448
|
+
for d in done_stories if any(x in d["cites"] for x in remaining)]
|
|
2449
|
+
if shipped:
|
|
2450
|
+
notes.append("%s covers requirements already delivered by %s — the spec moved "
|
|
2451
|
+
"after those closed, so this is the change, not a repeat"
|
|
2452
|
+
% (sid, "; ".join(shipped)))
|
|
2453
|
+
plan.append({"action": "create", "story": sid, "follows": "",
|
|
2454
|
+
"path": os.path.join(sdir, sid + ".md"),
|
|
2455
|
+
"requirements_csv": ", ".join(remaining), "existing_csv": "",
|
|
2456
|
+
"prior_csv": "", "prior_title": "", "prior_path": "",
|
|
2457
|
+
"group": g["key"], "group_title": g["title"],
|
|
2458
|
+
"group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
|
|
2459
|
+
"rationale": g["rationale"], "spec_version": version})
|
|
2460
|
+
else:
|
|
2461
|
+
notes.append("group [%s] (%s) is entirely covered by stories left untouched; "
|
|
2462
|
+
"nothing to write" % (g["key"], g["ids_csv"]))
|
|
2463
|
+
|
|
2464
|
+
# ---- waves: the order these stories can actually be picked up in -------------------
|
|
2465
|
+
# The grouping declared which slices stand behind which; turning that into an order is
|
|
2466
|
+
# arithmetic, so it happens here and not in the prompt — the same grouping always yields
|
|
2467
|
+
# the same waves.
|
|
2468
|
+
#
|
|
2469
|
+
# A WAVE IS AN ELIGIBILITY LEVEL, NOT A SCHEDULE. Wave N means every story this one depends
|
|
2470
|
+
# on sits in a wave below N. Two stories in the SAME wave therefore have no dependency
|
|
2471
|
+
# between them and may be picked up in parallel; it does not say they start together, and
|
|
2472
|
+
# nothing waits for a whole wave to finish. That is also why the number is not a priority:
|
|
2473
|
+
# wave 1 is "nothing blocks this", not "do this first".
|
|
2474
|
+
#
|
|
2475
|
+
# THE EDGE SET IS A UNION of what the grouping declared and what an updated story already
|
|
2476
|
+
# carries in `depends_on:`. The sync sees one feature's spec; an edge someone added by hand
|
|
2477
|
+
# may name work it has no view of, and dropping it would silently un-order real work. Both
|
|
2478
|
+
# are claims about CODE ordering, and the union is exactly what the story ends up citing —
|
|
2479
|
+
# so the wave and the frontmatter cannot disagree about what stands in front of what.
|
|
2480
|
+
#
|
|
2481
|
+
# `follows:` is NOT an edge. A follow-on carries the remainder of a story in flight, and the
|
|
2482
|
+
# remainder is often buildable alongside it — §5.5 asks the author to say in Notes which
|
|
2483
|
+
# half has to land first. Making it an implicit edge would put an ordering in the waves that
|
|
2484
|
+
# no frontmatter field explains.
|
|
2485
|
+
entries_of, by_story = {}, {}
|
|
2486
|
+
for e in plan:
|
|
2487
|
+
entries_of.setdefault(e["group"], []).append(e["story"]); by_story[e["story"]] = e
|
|
2488
|
+
preds = {e["story"]: set() for e in plan}
|
|
2489
|
+
external, unresolved = {}, [] # stories outside this run, at the wave they declare
|
|
2490
|
+
|
|
2491
|
+
for g in groups:
|
|
2492
|
+
tgt = entries_of.get(g["key"], [])
|
|
2493
|
+
if not tgt: continue # the group wrote nothing; its edges order nothing
|
|
2494
|
+
for tok in g["deps_raw"]:
|
|
2495
|
+
tok = tok.strip()
|
|
2496
|
+
if not tok or tok == g["key"]: continue
|
|
2497
|
+
if tok in entries_of:
|
|
2498
|
+
src = entries_of[tok]
|
|
2499
|
+
elif tok in by_id and by_id[tok]["status"] == "open":
|
|
2500
|
+
external[tok] = by_id[tok]["wave"]; src = [tok]
|
|
2501
|
+
else:
|
|
2502
|
+
unresolved.append("%s (named by group [%s])" % (tok, g["key"])); continue
|
|
2503
|
+
for x in tgt: preds[x].update(s for s in src if s != x)
|
|
2504
|
+
|
|
2505
|
+
for e in plan:
|
|
2506
|
+
if e["action"] != "update": continue
|
|
2507
|
+
for d in by_id.get(e["story"], {}).get("deps", []):
|
|
2508
|
+
if d == e["story"] or d in preds[e["story"]]: continue
|
|
2509
|
+
if d not in preds: external[d] = by_id[d]["wave"] if d in by_id else 1
|
|
2510
|
+
preds[e["story"]].add(d)
|
|
2511
|
+
|
|
2512
|
+
# A CYCLE IS REJECTED AT AUTHORING TIME (§6.1), and this is where it would be authored.
|
|
2513
|
+
# Depth-first over a sorted node order, dropping the one edge that closes each cycle: only
|
|
2514
|
+
# edges that actually close one are lost, and the choice is deterministic — the same
|
|
2515
|
+
# grouping always breaks the same edge. Reported, because a dropped edge was a real claim
|
|
2516
|
+
# about ordering and the cycle it sat in is a fact about the slicing, not a glitch.
|
|
2517
|
+
cycles, state = [], {}
|
|
2518
|
+
def walk(node):
|
|
2519
|
+
state[node] = 1
|
|
2520
|
+
for m in sorted(preds.get(node, ())):
|
|
2521
|
+
if state.get(m) == 1:
|
|
2522
|
+
preds[node].discard(m); cycles.append("%s -> %s" % (node, m)); continue
|
|
2523
|
+
if state.get(m) != 2 and m in preds: walk(m)
|
|
2524
|
+
state[node] = 2
|
|
2525
|
+
for node in sorted(preds):
|
|
2526
|
+
if state.get(node) != 2: walk(node)
|
|
2527
|
+
|
|
2528
|
+
# Longest path from a root, relaxed to a fixed point. A story with no dependency is wave 1,
|
|
2529
|
+
# which is what puts every independent story in the same wave.
|
|
2530
|
+
wave = dict(external)
|
|
2531
|
+
for _ in range(len(preds) + 1):
|
|
2532
|
+
moved = False
|
|
2533
|
+
for node in sorted(preds):
|
|
2534
|
+
w = 1 + max([wave.get(m, 1) for m in preds[node]] or [0])
|
|
2535
|
+
if wave.get(node) != w: wave[node] = w; moved = True
|
|
2536
|
+
if not moved: break
|
|
2537
|
+
for e in plan:
|
|
2538
|
+
e["wave"] = wave.get(e["story"], 1)
|
|
2539
|
+
e["depends_on_csv"] = ", ".join(sorted(preds.get(e["story"], ())))
|
|
2540
|
+
|
|
2541
|
+
# Written in wave order, so a run that meets the cap writes the stories that can be STARTED
|
|
2542
|
+
# rather than an arbitrary prefix of them. The story id is not the order — the wave is.
|
|
2543
|
+
plan.sort(key=lambda e: (e["wave"], e["story"]))
|
|
2544
|
+
for i, e in enumerate(plan): e["seq"] = i + 1
|
|
2545
|
+
|
|
2546
|
+
waves = {}
|
|
2547
|
+
for e in plan: waves.setdefault(e["wave"], []).append(e["story"])
|
|
2548
|
+
wave_digest = "\n".join(" wave %d: %s" % (w, ", ".join(waves[w]))
|
|
2549
|
+
for w in sorted(waves)) or " (nothing to write)"
|
|
2550
|
+
if unresolved:
|
|
2551
|
+
notes.append("the grouping named dependencies matching no slice and no open story, so "
|
|
2552
|
+
"they set no order: %s" % "; ".join(sorted(set(unresolved))))
|
|
2553
|
+
if cycles:
|
|
2554
|
+
notes.append("slices were declared to depend on each other in a cycle; the edge closing "
|
|
2555
|
+
"each one is dropped, because a backlog with no order at all is worse than "
|
|
2556
|
+
"one missing an edge someone can re-add: %s" % ", ".join(cycles))
|
|
2557
|
+
|
|
2558
|
+
# THE WRITE LOOP HAS A CEILING AND THE ENGINE DOES NOT SAY WHEN IT HITS IT — `over:` stops
|
|
2559
|
+
# at max_iterations with no message, so plan entries past it are simply never written and
|
|
2560
|
+
# the run reports success. The caller passes its own ceiling here so the overflow is named
|
|
2561
|
+
# while the story ids still exist to name. Nothing is dropped from the plan: the ids are
|
|
2562
|
+
# reported and the entries stay, so raising the cap and re-running writes them.
|
|
2563
|
+
try:
|
|
2564
|
+
cap = int((os.environ.get("AW_CAP") or "").strip() or 0)
|
|
2565
|
+
except ValueError:
|
|
2566
|
+
cap = 0
|
|
2567
|
+
overflow = [e["story"] for e in plan[cap:]] if cap > 0 else []
|
|
2568
|
+
|
|
2569
|
+
counts = {a: sum(1 for e in plan if e["action"] == a) for a in ("create", "update", "followon")}
|
|
2570
|
+
base.update(plan=plan, plan_count=len(plan), any_plan=bool(plan),
|
|
2571
|
+
cap=cap, overflow_csv=", ".join(overflow), overflow_count=len(overflow),
|
|
2572
|
+
create_count=counts["create"], update_count=counts["update"],
|
|
2573
|
+
followon_count=counts["followon"],
|
|
2574
|
+
skipped_csv=", ".join(sorted(set(skipped))),
|
|
2575
|
+
stories_csv=", ".join(e["story"] for e in plan),
|
|
2576
|
+
wave_digest=wave_digest, wave_count=len(waves),
|
|
2577
|
+
max_wave=(max(waves) if waves else 0),
|
|
2578
|
+
cycle_csv=", ".join(cycles),
|
|
2579
|
+
unresolved_deps_csv=", ".join(sorted(set(unresolved))),
|
|
2580
|
+
notes=notes, notes_text="\n".join(" - " + n for n in notes),
|
|
2581
|
+
plan_digest="\n".join(
|
|
2582
|
+
" w%-3d %-9s %-16s %s%s%s"
|
|
2583
|
+
% (e["wave"], e["action"], e["story"], e["requirements_csv"],
|
|
2584
|
+
(" (follows %s)" % e["follows"]) if e["follows"] else "",
|
|
2585
|
+
(" [after %s]" % e["depends_on_csv"]) if e["depends_on_csv"] else "")
|
|
2586
|
+
for e in plan) or " (nothing to write)")
|
|
2587
|
+
print(json.dumps(base))
|
|
2588
|
+
PY
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
# ─────────────────────────────── story-stamp ───────────────────────────────
|
|
2592
|
+
# The story's frontmatter is the gate surface — `exercises:`, `spec_version:`, `status:`
|
|
2593
|
+
# and `follows:` are what spec-coverage, check-stories, analyze-slice and close-story read,
|
|
2594
|
+
# and none of them opens the body. So those fields are OBSERVED and written by the kit, not
|
|
2595
|
+
# declared by the model that wrote the prose, for the same reason the Change History row is
|
|
2596
|
+
# computed rather than announced: a model that drops one id from `exercises:` produces a
|
|
2597
|
+
# story that reads perfectly and is invisible to every gate built to protect it. Silent,
|
|
2598
|
+
# and in the one field where silence costs the most.
|
|
2599
|
+
#
|
|
2600
|
+
# Everything else in the block is left exactly as the author wrote it, and a key the file
|
|
2601
|
+
# is missing is appended in template order rather than the block being rebuilt — a story
|
|
2602
|
+
# carrying a work_item or a depends_on must not lose it to a stamp.
|
|
2603
|
+
cmd_story_stamp() {
|
|
2604
|
+
local fd_arg="" story="" ids="" version="" follows="" ids_given="" title="" deps="" wave=""
|
|
2605
|
+
local cons_given="" designed="" designed_given="" dropped="" dropped_given=""
|
|
2606
|
+
while [ $# -gt 0 ]; do
|
|
2607
|
+
case "$1" in
|
|
2608
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
2609
|
+
--story) story="$2"; shift 2 ;;
|
|
2610
|
+
--ids) ids="$2"; ids_given=1; shift 2 ;;
|
|
2611
|
+
--version) version="$2"; shift 2 ;;
|
|
2612
|
+
--follows) follows="$2"; shift 2 ;;
|
|
2613
|
+
--title) title="$2"; shift 2 ;;
|
|
2614
|
+
--depends-on) deps="$2"; shift 2 ;;
|
|
2615
|
+
--wave) wave="$2"; shift 2 ;;
|
|
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 ;;
|
|
2624
|
+
*) echo "story-stamp: unknown arg '$1'" >&2; return 1 ;;
|
|
2625
|
+
esac
|
|
2626
|
+
done
|
|
2627
|
+
[ -n "$story" ] || { echo "story-stamp: --story is required" >&2; return 1; }
|
|
2628
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
2629
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-stamp: no feature set and no --feature-dir" >&2; return 1; }
|
|
2630
|
+
AW_FD="$fd" AW_STORY="$story" AW_IDS="$ids" AW_IDS_GIVEN="$ids_given" \
|
|
2631
|
+
AW_VER="$version" AW_FOLLOWS="$follows" AW_TITLE="$title" \
|
|
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'
|
|
2635
|
+
import json, os, re, sys, datetime
|
|
2636
|
+
fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
|
|
2637
|
+
path = os.path.join(fd, "stories", story + ".md")
|
|
2638
|
+
|
|
2639
|
+
def bail(msg, code=2):
|
|
2640
|
+
print(json.dumps({"ok": False, "story": story, "path": path, "error": msg}))
|
|
2641
|
+
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
2642
|
+
|
|
2643
|
+
if not os.path.isfile(path):
|
|
2644
|
+
bail("story-stamp: no story file at %s — the authoring step did not write it" % path)
|
|
2645
|
+
|
|
2646
|
+
def rids(s):
|
|
2647
|
+
seen, out = set(), []
|
|
2648
|
+
for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
|
|
2649
|
+
if t not in seen: seen.add(t); out.append(t)
|
|
2650
|
+
return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
|
|
2651
|
+
|
|
2652
|
+
def sids(s):
|
|
2653
|
+
"""Story ids out of a csv or an inline list. A story id is a feature name and a number;
|
|
2654
|
+
the template's placeholders are neither, and must not become a dependency edge."""
|
|
2655
|
+
seen, out = set(), []
|
|
2656
|
+
for t in re.split(r'[,\s\[\]"\']+', s or ""):
|
|
2657
|
+
t = t.strip()
|
|
2658
|
+
if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
|
|
2659
|
+
seen.add(t); out.append(t)
|
|
2660
|
+
return out
|
|
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
|
+
|
|
2703
|
+
text = open(path, encoding="utf-8", errors="replace").read()
|
|
2704
|
+
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
2705
|
+
if m:
|
|
2706
|
+
block, body = m.group(1), text[m.end():]
|
|
2707
|
+
else:
|
|
2708
|
+
# A model that returned the prose and no frontmatter leaves a story every gate skips.
|
|
2709
|
+
# Synthesised rather than refused: the body is the expensive half and it is already on
|
|
2710
|
+
# disk, and the fields below are exactly the ones this op is authoritative for.
|
|
2711
|
+
block, body = "", text.lstrip("\n")
|
|
2712
|
+
|
|
2713
|
+
# The block as an ordered list of (key, rendered line), so an unrecognised key and its
|
|
2714
|
+
# continuation lines survive untouched.
|
|
2715
|
+
lines = block.split("\n") if block else []
|
|
2716
|
+
order, seen_key = [], None
|
|
2717
|
+
for line in lines:
|
|
2718
|
+
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
2719
|
+
if km:
|
|
2720
|
+
seen_key = km.group(1); order.append([seen_key, [line]])
|
|
2721
|
+
elif order and (line.startswith(" ") or line.startswith("\t")):
|
|
2722
|
+
order[-1][1].append(line) # a block-list item under the key above
|
|
2723
|
+
elif line.strip():
|
|
2724
|
+
order.append([None, [line]])
|
|
2725
|
+
|
|
2726
|
+
def put(key, value):
|
|
2727
|
+
"""Set a key to a single-line value, collapsing a block list into the inline form."""
|
|
2728
|
+
line = ("%s: %s" % (key, value)) if value != "" else ("%s:" % key)
|
|
2729
|
+
for entry in order:
|
|
2730
|
+
if entry[0] == key:
|
|
2731
|
+
entry[1] = [line]
|
|
2732
|
+
return "set"
|
|
2733
|
+
order.append([key, [line]])
|
|
2734
|
+
return "added"
|
|
2735
|
+
|
|
2736
|
+
TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "constraints",
|
|
2737
|
+
"spec_version", "depends_on", "wave", "follows",
|
|
2738
|
+
"designed_lanes", "dropped_lanes", "work_item", "created"]
|
|
2739
|
+
def get(key):
|
|
2740
|
+
for entry in order:
|
|
2741
|
+
if entry[0] == key: return entry[1][0].split(":", 1)[1].strip()
|
|
2742
|
+
return ""
|
|
2743
|
+
|
|
2744
|
+
changed = {}
|
|
2745
|
+
changed["id"] = put("id", story)
|
|
2746
|
+
|
|
2747
|
+
if os.environ.get("AW_IDS_GIVEN"):
|
|
2748
|
+
ids = rids(os.environ.get("AW_IDS", ""))
|
|
2749
|
+
changed["exercises"] = put("exercises", "[" + ", ".join(ids) + "]")
|
|
2750
|
+
else:
|
|
2751
|
+
ids = rids(get("exercises"))
|
|
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
|
+
|
|
2764
|
+
ver = (os.environ.get("AW_VER") or "").strip()
|
|
2765
|
+
if ver: changed["spec_version"] = put("spec_version", ver)
|
|
2766
|
+
|
|
2767
|
+
fol = (os.environ.get("AW_FOLLOWS") or "").strip()
|
|
2768
|
+
if fol: changed["follows"] = put("follows", fol)
|
|
2769
|
+
|
|
2770
|
+
ttl = (os.environ.get("AW_TITLE") or "").strip()
|
|
2771
|
+
if ttl and (not get("title") or get("title").startswith("[")):
|
|
2772
|
+
changed["title"] = put("title", ttl)
|
|
2773
|
+
|
|
2774
|
+
# Placeholders the template ships. Left alone once a real value is in them, so a story
|
|
2775
|
+
# CLOSED between the plan and the write is not quietly re-opened by a stamp.
|
|
2776
|
+
if not get("type") or get("type").startswith("["):
|
|
2777
|
+
changed["type"] = put("type", "functional" if ids else "technical")
|
|
2778
|
+
if not get("status") or get("status").startswith("["):
|
|
2779
|
+
changed["status"] = put("status", "open")
|
|
2780
|
+
# ORDERING IS A UNION, NEVER A REPLACEMENT. The edges passed in are what this run's slicing
|
|
2781
|
+
# declared; the ones already in the file may name work outside this feature, which the sync
|
|
2782
|
+
# cannot see. Dropping either loses a real claim about what has to land first, and a lost
|
|
2783
|
+
# edge is silent — no later stage re-derives it.
|
|
2784
|
+
deps = sorted(set(sids(get("depends_on")) + sids(os.environ.get("AW_DEPS", ""))) - {story})
|
|
2785
|
+
if deps or not get("depends_on") or get("depends_on").startswith("["):
|
|
2786
|
+
changed["depends_on"] = put("depends_on", "[" + ", ".join(deps) + "]")
|
|
2787
|
+
|
|
2788
|
+
# THE WAVE IS AN ELIGIBILITY LEVEL, NOT A PRIORITY: every story this one depends on sits in
|
|
2789
|
+
# a lower one, so stories sharing a wave have no ordering between them and may be picked up
|
|
2790
|
+
# in parallel. Computed by backlog-plan from `depends_on`, and written here for the same
|
|
2791
|
+
# reason `exercises:` is — a field the gates and the listings read is observed, not declared.
|
|
2792
|
+
# A story stamped without one floors at 1: nothing is KNOWN to have to land before it.
|
|
2793
|
+
wv = (os.environ.get("AW_WAVE") or "").strip()
|
|
2794
|
+
if wv.isdigit():
|
|
2795
|
+
changed["wave"] = put("wave", wv)
|
|
2796
|
+
elif not get("wave").isdigit():
|
|
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
|
+
|
|
2814
|
+
if not get("created") or get("created").startswith("["):
|
|
2815
|
+
changed["created"] = put("created", datetime.date.today().isoformat())
|
|
2816
|
+
for k in ("title", "follows", "work_item"):
|
|
2817
|
+
if not any(e[0] == k for e in order): put(k, "")
|
|
2818
|
+
|
|
2819
|
+
# Rendered back in template order, with anything unrecognised kept after it in the order it
|
|
2820
|
+
# was written. A story is read by people as well as by gates.
|
|
2821
|
+
rank = {k: i for i, k in enumerate(TEMPLATE_ORDER)}
|
|
2822
|
+
order.sort(key=lambda e: rank.get(e[0], len(TEMPLATE_ORDER)))
|
|
2823
|
+
out_block = "\n".join(l for e in order for l in e[1])
|
|
2824
|
+
open(path, "w", encoding="utf-8").write("---\n" + out_block + "\n---\n\n" + body.lstrip("\n"))
|
|
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
|
+
|
|
2838
|
+
print(json.dumps({"ok": True, "story": story, "path": path,
|
|
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"),
|
|
2846
|
+
"spec_version": get("spec_version"), "status": get("status"),
|
|
2847
|
+
"depends_on": get("depends_on"), "wave": get("wave"),
|
|
2848
|
+
"follows": get("follows"), "title": get("title"),
|
|
2849
|
+
"type": get("type"), "had_frontmatter": bool(m),
|
|
2850
|
+
"fields": sorted(changed)}))
|
|
2851
|
+
PY
|
|
2852
|
+
}
|
|
2853
|
+
|
|
1546
2854
|
# ────────────────────────────── spec-preserve ──────────────────────────────
|
|
1547
2855
|
# Set subtraction with a purpose: of the requirements this run disturbed, which ones was
|
|
1548
2856
|
# it NOT given permission to change. The approved list comes from a multi-select ask, so
|
|
@@ -1624,7 +2932,7 @@ PY
|
|
|
1624
2932
|
#
|
|
1625
2933
|
# Deriving the row this way is what makes it trustworthy. A row the model declares can
|
|
1626
2934
|
# omit a requirement the converge loop rewrote on its way past — and an omitted id is
|
|
1627
|
-
# the silent case the whole ledger exists to prevent, since
|
|
2935
|
+
# the silent case the whole ledger exists to prevent, since sync-stories would never
|
|
1628
2936
|
# learn that requirement moved. A row computed from the diff cannot omit one.
|
|
1629
2937
|
#
|
|
1630
2938
|
# Ids are read in both shapes the template uses: the bold form of the list sections
|
|
@@ -1754,6 +3062,60 @@ def scan(path):
|
|
|
1754
3062
|
return out
|
|
1755
3063
|
def ids(path):
|
|
1756
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
|
|
1757
3119
|
# Ids the ledger has already spent. An id is PERMANENT: re-issuing one silently re-points
|
|
1758
3120
|
# stories, designs and code that have already shipped. Read from the SNAPSHOT's history, so
|
|
1759
3121
|
# it is what the ledger said before this run, and only meaningful when there is a snapshot.
|
|
@@ -1772,10 +3134,11 @@ reissued = sorted(set(added) & spent(snap))
|
|
|
1772
3134
|
# THE STORY-FACING SUBSET. A story cites FR- and BR- ids and nothing else, so those are the
|
|
1773
3135
|
# only classes the backlog can act on — the same scoping spec-coverage applies, for the same
|
|
1774
3136
|
# reason. The scan is deliberately class-blind (CN-/DC-/IC-/UX-/SC- all move the contract and
|
|
1775
|
-
# all belong in the ledger row), but handing that whole set to
|
|
3137
|
+
# all belong in the ledger row), but handing that whole set to sync-stories as "the
|
|
1776
3138
|
# requirements this run touched" asks it to raise a story for a success criterion.
|
|
1777
3139
|
STORY_CLASS = ("FR-", "BR-")
|
|
1778
3140
|
story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
|
|
3141
|
+
warnings = unkeyed_warnings(spec, set(after))
|
|
1779
3142
|
print(json.dumps({
|
|
1780
3143
|
"ok": True, "had_snapshot": bool(snap and os.path.isfile(snap)),
|
|
1781
3144
|
"added": added, "removed": removed, "modified": modified,
|
|
@@ -1795,9 +3158,15 @@ print(json.dumps({
|
|
|
1795
3158
|
# spec on disk that the next run's entry check would refuse too.
|
|
1796
3159
|
"reissued": reissued, "reissued_csv": ", ".join(reissued),
|
|
1797
3160
|
"reissued_any": bool(reissued),
|
|
1798
|
-
# changed, narrowed to the classes a story can cite — what
|
|
3161
|
+
# changed, narrowed to the classes a story can cite — what sync-stories works from.
|
|
1799
3162
|
"story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
|
|
1800
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),
|
|
1801
3170
|
}))
|
|
1802
3171
|
PY
|
|
1803
3172
|
return 0
|
|
@@ -1857,6 +3226,7 @@ cmd_feature_paths() {
|
|
|
1857
3226
|
# design.ui.md (adopt today) enrolls the feature in the UI half of the delta loop
|
|
1858
3227
|
# by the act of writing the file.
|
|
1859
3228
|
local spec="$fd/spec.md" design="$fd/design.md" ui_design="$fd/design.ui.md"
|
|
3229
|
+
local backend_plan="$fd/backend-plan.md" frontend_plan="$fd/frontend-plan.md"
|
|
1860
3230
|
# needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
|
|
1861
3231
|
# leaves spec.md on disk with the marker still up, and branching on the file alone read
|
|
1862
3232
|
# that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
|
|
@@ -1865,12 +3235,15 @@ cmd_feature_paths() {
|
|
|
1865
3235
|
local spec_exists=false; [ -f "$spec" ] && spec_exists=true
|
|
1866
3236
|
local design_exists=false; [ -f "$design" ] && design_exists=true
|
|
1867
3237
|
local ui_design_exists=false; [ -f "$ui_design" ] && ui_design_exists=true
|
|
3238
|
+
local backend_plan_exists=false; [ -f "$backend_plan" ] && backend_plan_exists=true
|
|
3239
|
+
local frontend_plan_exists=false; [ -f "$frontend_plan" ] && frontend_plan_exists=true
|
|
1868
3240
|
local needs_bootstrap=false
|
|
1869
3241
|
[ "$spec_exists" = false ] && needs_bootstrap=true
|
|
1870
3242
|
[ "$bootstrap_pending" = true ] && needs_bootstrap=true
|
|
1871
|
-
printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","design_file":"%s","ui_design_file":"%s","spec_exists":%s,"design_exists":%s,"ui_design_exists":%s,"bootstrap_pending":%s,"needs_bootstrap":%s}\n' \
|
|
3243
|
+
printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","design_file":"%s","ui_design_file":"%s","backend_plan_file":"%s","frontend_plan_file":"%s","spec_exists":%s,"design_exists":%s,"ui_design_exists":%s,"backend_plan_exists":%s,"frontend_plan_exists":%s,"bootstrap_pending":%s,"needs_bootstrap":%s}\n' \
|
|
1872
3244
|
"$(json_escape "$feature")" "$(json_escape "$fd")" "$(json_escape "$spec")" "$(json_escape "$design")" \
|
|
1873
|
-
"$(json_escape "$ui_design")" "$
|
|
3245
|
+
"$(json_escape "$ui_design")" "$(json_escape "$backend_plan")" "$(json_escape "$frontend_plan")" \
|
|
3246
|
+
"$spec_exists" "$design_exists" "$ui_design_exists" "$backend_plan_exists" "$frontend_plan_exists" \
|
|
1874
3247
|
"$bootstrap_pending" "$needs_bootstrap"
|
|
1875
3248
|
}
|
|
1876
3249
|
|
|
@@ -1993,12 +3366,33 @@ _project_markers() {
|
|
|
1993
3366
|
| sed "s#^$repo_root/##" | LC_ALL=C sort | head -20
|
|
1994
3367
|
}
|
|
1995
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
|
+
|
|
1996
3386
|
cmd_detect_project() {
|
|
1997
|
-
local scope_root=""
|
|
3387
|
+
local scope_root="" ecosystem=""
|
|
1998
3388
|
while [ $# -gt 0 ]; do
|
|
1999
3389
|
case "$1" in
|
|
2000
3390
|
--root|--source-path) scope_root="$2"; shift 2 ;;
|
|
2001
|
-
|
|
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 ;;
|
|
2002
3396
|
*) echo "detect-project: unknown arg '$1'" >&2; return 1 ;;
|
|
2003
3397
|
esac
|
|
2004
3398
|
done
|
|
@@ -2018,7 +3412,18 @@ cmd_detect_project() {
|
|
|
2018
3412
|
fi
|
|
2019
3413
|
local scaffolded=false
|
|
2020
3414
|
[ ${#markers[@]} -gt 0 ] && scaffolded=true
|
|
2021
|
-
|
|
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"
|
|
2022
3427
|
}
|
|
2023
3428
|
|
|
2024
3429
|
|
|
@@ -2920,15 +4325,21 @@ cmd_design_scaffold() {
|
|
|
2920
4325
|
# Priority-1), so the defaults already yield the as-built structure for
|
|
2921
4326
|
# forward `design` and adopt alike, and this is rarely needed.
|
|
2922
4327
|
--template) template_arg="$2"; shift 2 ;;
|
|
2923
|
-
--help|-h) echo "design-scaffold --feature-dir <path> [--lane service|ui] [--template <name>]"; return 0 ;;
|
|
4328
|
+
--help|-h) echo "design-scaffold --feature-dir <path> [--lane service|ui|backend|frontend] [--template <name>]"; return 0 ;;
|
|
2924
4329
|
*) echo "design-scaffold: unknown arg '$1'" >&2; return 1 ;;
|
|
2925
4330
|
esac
|
|
2926
4331
|
done
|
|
2927
|
-
|
|
4332
|
+
# backend/frontend are the PLAN-phase pairs: the same templates, the plan document
|
|
4333
|
+
# names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the
|
|
4334
|
+
# plan name so a feature never carries both spellings of the same master.
|
|
4335
|
+
# service/ui stay byte-identical for their existing callers (adopt, the delta chain).
|
|
4336
|
+
local doc_name legacy_name=""
|
|
2928
4337
|
case "$lane_arg" in
|
|
2929
|
-
service)
|
|
2930
|
-
ui)
|
|
2931
|
-
|
|
4338
|
+
service) doc_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
|
|
4339
|
+
ui) doc_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
|
|
4340
|
+
backend) doc_name="backend-plan.md"; legacy_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
|
|
4341
|
+
frontend) doc_name="frontend-plan.md"; legacy_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
|
|
4342
|
+
*) echo "design-scaffold: unknown --lane '$lane_arg' (service|ui|backend|frontend)" >&2; return 1 ;;
|
|
2932
4343
|
esac
|
|
2933
4344
|
local repo_root; repo_root=$(get_repo_root)
|
|
2934
4345
|
local fd="$feature_dir_arg"
|
|
@@ -2937,7 +4348,10 @@ cmd_design_scaffold() {
|
|
|
2937
4348
|
[[ "$fd" != /* ]] && fd="$repo_root/$fd"
|
|
2938
4349
|
mkdir -p "$fd"
|
|
2939
4350
|
|
|
2940
|
-
local target="$fd/$doc_name" created="false" tpl
|
|
4351
|
+
local target="$fd/$doc_name" created="false" tpl migrated="false"
|
|
4352
|
+
if [ ! -f "$target" ] && [ -n "$legacy_name" ] && [ -f "$fd/$legacy_name" ]; then
|
|
4353
|
+
mv "$fd/$legacy_name" "$target"; migrated="true"
|
|
4354
|
+
fi
|
|
2941
4355
|
if [ -f "$target" ]; then
|
|
2942
4356
|
created="false"
|
|
2943
4357
|
else
|
|
@@ -2954,8 +4368,8 @@ cmd_design_scaffold() {
|
|
|
2954
4368
|
local override_active="false"
|
|
2955
4369
|
[ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
|
|
2956
4370
|
local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
|
|
2957
|
-
printf '{"feature_dir":"%s","design_doc":"%s","lane":"%s","created":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
|
|
2958
|
-
"$(json_escape "$fd")" "$(json_escape "$target")" "$(json_escape "$lane_arg")" "$created" "$override_active" \
|
|
4371
|
+
printf '{"feature_dir":"%s","design_doc":"%s","lane":"%s","created":%s,"migrated":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
|
|
4372
|
+
"$(json_escape "$fd")" "$(json_escape "$target")" "$(json_escape "$lane_arg")" "$created" "$migrated" "$override_active" \
|
|
2959
4373
|
"$(json_escape "$template_arg")" "$(json_escape "${template_source:-}")"
|
|
2960
4374
|
}
|
|
2961
4375
|
|
|
@@ -3137,6 +4551,189 @@ print(json.dumps({
|
|
|
3137
4551
|
"$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")"
|
|
3138
4552
|
}
|
|
3139
4553
|
|
|
4554
|
+
# ─────────────────────────────── test-diff ─────────────────────────────────
|
|
4555
|
+
# The TECHNICAL-STORY ORACLE. A technical story delivers no requirement, so there is no
|
|
4556
|
+
# FR- to trace it to and the existing test suite is the only thing that can say whether
|
|
4557
|
+
# it changed behaviour: a test that HAD to be edited means behaviour moved, and the work
|
|
4558
|
+
# was a functional story citing a requirement. This reports the suite's diff against a
|
|
4559
|
+
# baseline and lets the caller decide — it never judges.
|
|
4560
|
+
#
|
|
4561
|
+
# Read-only, always exits 0, and reports `checked` so an unreadable oracle (no git) is
|
|
4562
|
+
# distinguishable from a clean one. A gate that treats "could not look" as "nothing
|
|
4563
|
+
# found" is the exact false green the kit refuses everywhere else.
|
|
4564
|
+
cmd_test_diff() {
|
|
4565
|
+
local fd_arg="" base=""
|
|
4566
|
+
while [ $# -gt 0 ]; do
|
|
4567
|
+
case "$1" in
|
|
4568
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
4569
|
+
--base) base="$2"; shift 2 ;;
|
|
4570
|
+
--help|-h) echo "test-diff [--feature-dir <path>] [--base <git ref>]"; return 0 ;;
|
|
4571
|
+
*) echo "test-diff: unknown arg '$1'" >&2; return 1 ;;
|
|
4572
|
+
esac
|
|
4573
|
+
done
|
|
4574
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
4575
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || fd=""
|
|
4576
|
+
AW_ROOT="$repo_root" AW_FD="$fd" AW_BASE="$base" python3 - <<'PY'
|
|
4577
|
+
import json, os, re, subprocess
|
|
4578
|
+
root = os.environ.get("AW_ROOT") or "."
|
|
4579
|
+
fd = os.environ.get("AW_FD", "")
|
|
4580
|
+
want = (os.environ.get("AW_BASE") or "").strip()
|
|
4581
|
+
agg = os.path.basename(fd.rstrip("/\\"))
|
|
4582
|
+
|
|
4583
|
+
def git(*a):
|
|
4584
|
+
"""A git read that never raises. A working copy this command cannot interrogate is a
|
|
4585
|
+
FACT TO REPORT — the caller decides what an unreadable oracle means — never a crash
|
|
4586
|
+
that takes the gate down with it."""
|
|
4587
|
+
try:
|
|
4588
|
+
p = subprocess.run(["git", "-C", root] + list(a), capture_output=True, text=True)
|
|
4589
|
+
except Exception:
|
|
4590
|
+
return 1, ""
|
|
4591
|
+
return p.returncode, (p.stdout or "").strip()
|
|
4592
|
+
|
|
4593
|
+
DIR_RE = re.compile(r'(^|/)(tests?|specs?|__tests__|testing|test-suite)(/|$)', re.I)
|
|
4594
|
+
|
|
4595
|
+
# The kit's OWN trees, which hold documents and configuration and never tests. The spec
|
|
4596
|
+
# tree is derived rather than assumed: `specs/` matches the RSpec directory convention
|
|
4597
|
+
# exactly, so without this every requirement document — and this gate's own verdict file,
|
|
4598
|
+
# written before the diff is taken — counted as a modified test.
|
|
4599
|
+
SKIP = [".appweaver/"]
|
|
4600
|
+
if fd:
|
|
4601
|
+
_rel = os.path.relpath(fd, root).replace("\\", "/")
|
|
4602
|
+
SKIP.append((_rel.rsplit("/", 1)[0] if "/" in _rel else _rel).rstrip("/") + "/")
|
|
4603
|
+
|
|
4604
|
+
def is_test(path):
|
|
4605
|
+
"""A path is a TEST when a directory on it says so, or its own filename does — the
|
|
4606
|
+
four conventions every shipped stack uses between them: a tests/ directory, a
|
|
4607
|
+
Name.Tests.cs / NameTest.java suffix, a test_name.py / name_test.go affix, and a
|
|
4608
|
+
name.test.ts / name.spec.ts infix. Deliberately GENEROUS: over-calling a file a test
|
|
4609
|
+
costs a louder message, under-calling one costs a technical story that changed
|
|
4610
|
+
behaviour and passed its own oracle."""
|
|
4611
|
+
p = path.replace("\\", "/")
|
|
4612
|
+
if any(p.startswith(s) for s in SKIP):
|
|
4613
|
+
return False
|
|
4614
|
+
stem, ext = os.path.splitext(os.path.basename(p))
|
|
4615
|
+
# Prose is never a test, wherever it sits. A tests/ directory holds its own README
|
|
4616
|
+
# and a docs tree holds files named for testing; counting either would fail a
|
|
4617
|
+
# technical story over a paragraph.
|
|
4618
|
+
if ext.lower() in (".md", ".markdown", ".txt", ".rst", ".adoc"):
|
|
4619
|
+
return False
|
|
4620
|
+
if DIR_RE.search(os.path.dirname(p)):
|
|
4621
|
+
return True
|
|
4622
|
+
stem = stem.lower()
|
|
4623
|
+
if stem.startswith(("test", "spec")) or stem.endswith(("test", "tests", "spec", "specs")):
|
|
4624
|
+
return True
|
|
4625
|
+
return any(k in stem for k in (".test", ".spec", "_test", "_spec", "-test", "-spec"))
|
|
4626
|
+
|
|
4627
|
+
out = {"ok": True, "aggregate": agg, "git": False, "checked": False,
|
|
4628
|
+
"base": "", "base_ref": "", "base_kind": "none", "files_changed": 0,
|
|
4629
|
+
"tests_changed": [], "tests_changed_csv": "", "tests_changed_count": 0,
|
|
4630
|
+
"tests_deleted": [], "tests_deleted_csv": "",
|
|
4631
|
+
"tests_added": [], "tests_added_csv": "", "tests_added_count": 0,
|
|
4632
|
+
"slice_tests_changed": [], "slice_tests_changed_csv": "", "slice_tests_changed_count": 0,
|
|
4633
|
+
"any_test_changed": False, "clean": True, "oracle": "clean",
|
|
4634
|
+
"note": "", "digest": ""}
|
|
4635
|
+
|
|
4636
|
+
if git("rev-parse", "--is-inside-work-tree")[1] != "true":
|
|
4637
|
+
out["oracle"] = "unverified"
|
|
4638
|
+
out["note"] = ("Not a git working copy, so the test suite's history cannot be read. The "
|
|
4639
|
+
"unchanged-suite oracle is UNVERIFIED for this run — not passed.")
|
|
4640
|
+
out["digest"] = out["note"]
|
|
4641
|
+
print(json.dumps(out)); raise SystemExit(0)
|
|
4642
|
+
out["git"] = out["checked"] = True
|
|
4643
|
+
|
|
4644
|
+
# WHICH BASELINE. An explicit --base wins. Otherwise the fork point from the integration
|
|
4645
|
+
# branch, which is what a story's work sits on top of; falling back to HEAD, which sees
|
|
4646
|
+
# the working tree alone. The kind is reported because it changes what the answer MEANS:
|
|
4647
|
+
# a `head` baseline cannot see a test modified in a commit this branch already made.
|
|
4648
|
+
head = git("rev-parse", "HEAD")[1]
|
|
4649
|
+
if want and git("rev-parse", "--verify", want + "^{commit}")[0] == 0:
|
|
4650
|
+
out["base"], out["base_ref"], out["base_kind"] = git("rev-parse", want)[1], want, "given"
|
|
4651
|
+
if not out["base"]:
|
|
4652
|
+
cands = []
|
|
4653
|
+
rc, ref = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
4654
|
+
if rc == 0 and ref:
|
|
4655
|
+
cands.append(ref.split("refs/remotes/", 1)[-1])
|
|
4656
|
+
cands += ["origin/main", "origin/master", "main", "master"]
|
|
4657
|
+
seen = set()
|
|
4658
|
+
for c in cands:
|
|
4659
|
+
if c in seen:
|
|
4660
|
+
continue
|
|
4661
|
+
seen.add(c)
|
|
4662
|
+
if git("rev-parse", "--verify", c + "^{commit}")[0] != 0:
|
|
4663
|
+
continue
|
|
4664
|
+
rc, mb = git("merge-base", "HEAD", c)
|
|
4665
|
+
if rc != 0 or not mb:
|
|
4666
|
+
continue
|
|
4667
|
+
out["base"], out["base_ref"] = mb, c
|
|
4668
|
+
out["base_kind"] = "head" if mb == head else "merge-base"
|
|
4669
|
+
break
|
|
4670
|
+
if not out["base"]:
|
|
4671
|
+
out["base"], out["base_kind"] = head or "HEAD", "head"
|
|
4672
|
+
|
|
4673
|
+
rc, diff = git("diff", "--name-status", "--find-renames", out["base"])
|
|
4674
|
+
changed, added, deleted, files = [], [], [], 0
|
|
4675
|
+
for line in diff.splitlines():
|
|
4676
|
+
parts = line.split("\t")
|
|
4677
|
+
if len(parts) < 2:
|
|
4678
|
+
continue
|
|
4679
|
+
code, paths = parts[0].strip(), parts[1:]
|
|
4680
|
+
files += 1
|
|
4681
|
+
hits = [p for p in paths if is_test(p)]
|
|
4682
|
+
if not hits:
|
|
4683
|
+
continue
|
|
4684
|
+
p = hits[-1]
|
|
4685
|
+
if code.startswith("A") or code.startswith("C"):
|
|
4686
|
+
# A NEW test is not a changed one. A refactor that adds characterisation tests is
|
|
4687
|
+
# exactly the work a technical story exists to do.
|
|
4688
|
+
added.append(p)
|
|
4689
|
+
elif code.startswith("R"):
|
|
4690
|
+
# A pure rename moves a test without changing what it asserts; anything less than
|
|
4691
|
+
# a 100% match carries an edit inside it.
|
|
4692
|
+
(added if code == "R100" else changed).append(p)
|
|
4693
|
+
elif code.startswith("D"):
|
|
4694
|
+
deleted.append(p); changed.append(p)
|
|
4695
|
+
else:
|
|
4696
|
+
changed.append(p)
|
|
4697
|
+
|
|
4698
|
+
# Untracked files are ADDITIONS the diff cannot see — reported as such so a new test
|
|
4699
|
+
# written in the working tree is not mistaken for a missing one.
|
|
4700
|
+
for p in git("ls-files", "--others", "--exclude-standard")[1].splitlines():
|
|
4701
|
+
p = p.strip()
|
|
4702
|
+
if p and is_test(p) and p not in added:
|
|
4703
|
+
added.append(p); files += 1
|
|
4704
|
+
|
|
4705
|
+
changed = sorted(set(changed)); added = sorted(set(added)); deleted = sorted(set(deleted))
|
|
4706
|
+
low = agg.lower()
|
|
4707
|
+
slice_changed = [p for p in changed if low and low in p.lower()]
|
|
4708
|
+
out.update(files_changed=files,
|
|
4709
|
+
tests_changed=changed, tests_changed_csv=", ".join(changed),
|
|
4710
|
+
tests_changed_count=len(changed),
|
|
4711
|
+
tests_deleted=deleted, tests_deleted_csv=", ".join(deleted),
|
|
4712
|
+
tests_added=added, tests_added_csv=", ".join(added), tests_added_count=len(added),
|
|
4713
|
+
slice_tests_changed=slice_changed, slice_tests_changed_csv=", ".join(slice_changed),
|
|
4714
|
+
slice_tests_changed_count=len(slice_changed),
|
|
4715
|
+
any_test_changed=bool(changed), clean=not changed,
|
|
4716
|
+
# The DIFF's state in one word — clean | changed | unverified — so a caller can
|
|
4717
|
+
# branch on it without recombining a boolean with a "could I even look" flag.
|
|
4718
|
+
# It says what the suite did, never what that means for a story.
|
|
4719
|
+
oracle="changed" if changed else "clean")
|
|
4720
|
+
|
|
4721
|
+
kindnote = {"given": "the ref given on the command line",
|
|
4722
|
+
"merge-base": "the fork point from %s — every commit this branch added is in view" % out["base_ref"],
|
|
4723
|
+
"head": "HEAD — only uncommitted work is in view, so a test modified in a commit "
|
|
4724
|
+
"already made on this branch is NOT counted"}[out["base_kind"]]
|
|
4725
|
+
lines = ["Test-suite diff against %s (%s): %s" % (out["base"][:12], out["base_kind"], kindnote),
|
|
4726
|
+
" files changed: %d" % files,
|
|
4727
|
+
" tests MODIFIED or DELETED (%d): %s" % (len(changed), ", ".join(changed) or "none"),
|
|
4728
|
+
" tests ADDED (%d): %s" % (len(added), ", ".join(added) or "none")]
|
|
4729
|
+
if slice_changed:
|
|
4730
|
+
lines.append(" of those, on this aggregate (%s): %s" % (agg, ", ".join(slice_changed)))
|
|
4731
|
+
out["note"] = kindnote
|
|
4732
|
+
out["digest"] = "\n".join(lines)
|
|
4733
|
+
print(json.dumps(out))
|
|
4734
|
+
PY
|
|
4735
|
+
}
|
|
4736
|
+
|
|
3140
4737
|
cmd_run_migrate() {
|
|
3141
4738
|
while [ $# -gt 0 ]; do
|
|
3142
4739
|
case "$1" in
|
|
@@ -3374,6 +4971,923 @@ print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
|
|
|
3374
4971
|
PY
|
|
3375
4972
|
}
|
|
3376
4973
|
|
|
4974
|
+
|
|
4975
|
+
# ─────────────────────────── plan-check / plan-stamp ────────────────────────
|
|
4976
|
+
# The plan documents' derivation tracking. Each plan doc (backend-plan.md /
|
|
4977
|
+
# frontend-plan.md) derives from ONE spec version and carries its OWN track file
|
|
4978
|
+
# beside the spec — .aw-plan-track.backend-plan.json / .aw-plan-track.frontend-plan.json
|
|
4979
|
+
# — recording {spec_version, sha256}, so the two lanes' stamps never share a write.
|
|
4980
|
+
# plan-check reads the record back against the spec's Change History ledger;
|
|
4981
|
+
# plan-stamp is the only writer.
|
|
4982
|
+
# Both always exit 0 with an ok/state field — the workflow's verify_context owns
|
|
4983
|
+
# the stop (the lane-verify convention).
|
|
4984
|
+
cmd_plan_check() {
|
|
4985
|
+
local fd_arg="" doc=""
|
|
4986
|
+
while [ $# -gt 0 ]; do
|
|
4987
|
+
case "$1" in
|
|
4988
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
4989
|
+
--doc) doc="$2"; shift 2 ;;
|
|
4990
|
+
--help|-h) echo "plan-check --doc <path> [--feature-dir <path>]"; return 0 ;;
|
|
4991
|
+
*) echo "plan-check: unknown arg '$1'" >&2; return 1 ;;
|
|
4992
|
+
esac
|
|
4993
|
+
done
|
|
4994
|
+
[ -n "$doc" ] || { echo "plan-check: --doc required" >&2; return 1; }
|
|
4995
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
4996
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "plan-check: no feature set and no --feature-dir" >&2; return 1; }
|
|
4997
|
+
AW_DOC="$doc" AW_FD="$fd" python3 - <<'PY'
|
|
4998
|
+
import json, os, re, sys, hashlib
|
|
4999
|
+
doc = os.environ["AW_DOC"]; fd = os.environ["AW_FD"]
|
|
5000
|
+
spec = os.path.join(fd, "spec.md")
|
|
5001
|
+
# One track file per plan document (.aw-plan-track.backend-plan.json /
|
|
5002
|
+
# .aw-plan-track.frontend-plan.json) — parallel lane stamps never contend on a shared file.
|
|
5003
|
+
stem = os.path.splitext(os.path.basename(doc))[0]
|
|
5004
|
+
track_path = os.path.join(fd, ".aw-plan-track.%s.json" % stem)
|
|
5005
|
+
def vtuple(v):
|
|
5006
|
+
try: return tuple(int(x) for x in v.lstrip("vV").split("."))
|
|
5007
|
+
except Exception: return (0,)
|
|
5008
|
+
out = {"doc": doc, "doc_exists": os.path.isfile(doc), "state": "", "derived_version": "",
|
|
5009
|
+
"current_version": "", "changed_csv": "", "changed_summary": "",
|
|
5010
|
+
"body_changed": False, "body_sha256": ""}
|
|
5011
|
+
# Current version = the widest of the ledger's rows and the **Version** header — the
|
|
5012
|
+
# same widest-wins rule ledger-append uses, so a hand-moved header cannot hide a row.
|
|
5013
|
+
rows = []; cur = ""; idcol = -1
|
|
5014
|
+
if os.path.isfile(spec):
|
|
5015
|
+
text = open(spec, encoding="utf-8", errors="replace").read()
|
|
5016
|
+
m = re.search(r'^##\s+Change History\s*$', text, re.M)
|
|
5017
|
+
if m:
|
|
5018
|
+
for line in text[m.end():].splitlines():
|
|
5019
|
+
if idcol < 0 and line.lstrip().startswith("|") and "requirements changed" in line.lower():
|
|
5020
|
+
hdr = [c.strip().lower() for c in line.strip().strip("|").split("|")]
|
|
5021
|
+
idcol = next((i for i, c in enumerate(hdr) if "requirements changed" in c), -1)
|
|
5022
|
+
rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
|
|
5023
|
+
if rm: rows.append((rm.group(1), line))
|
|
5024
|
+
hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
|
|
5025
|
+
cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
|
|
5026
|
+
if cands: cur = max(cands, key=vtuple)
|
|
5027
|
+
out["current_version"] = cur
|
|
5028
|
+
if not out["doc_exists"]:
|
|
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()
|
|
5037
|
+
try: track = json.load(open(track_path, encoding="utf-8"))
|
|
5038
|
+
except Exception: track = {}
|
|
5039
|
+
entry = track.get(os.path.basename(doc))
|
|
5040
|
+
if not entry:
|
|
5041
|
+
# A document with no derivation record — a migrated legacy master, or a baseline
|
|
5042
|
+
# lost by hand. There is no stamp to diff against, so the caller reconciles the
|
|
5043
|
+
# WHOLE document once and plan-stamp baselines it.
|
|
5044
|
+
out["state"] = "untracked"; print(json.dumps(out)); sys.exit(0)
|
|
5045
|
+
derived = entry.get("spec_version", "")
|
|
5046
|
+
out["derived_version"] = derived
|
|
5047
|
+
out["body_changed"] = out["body_sha256"] != entry.get("sha256", "")
|
|
5048
|
+
moved = [r for r in rows if vtuple(r[0]) > vtuple(derived)]
|
|
5049
|
+
# Scrape ONLY the ledger's "Requirements changed" cell when it names one. The
|
|
5050
|
+
# Stories cell holds ids of the form <Aggregate>-nnn, which read as requirement ids
|
|
5051
|
+
# whenever the aggregate is spelled in caps (EDI-001, HR-002) — those would arrive
|
|
5052
|
+
# as requirements for the plan to conform, and there is no such requirement.
|
|
5053
|
+
def _scope(line):
|
|
5054
|
+
if idcol >= 0:
|
|
5055
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
5056
|
+
if idcol < len(cells): return cells[idcol]
|
|
5057
|
+
return line
|
|
5058
|
+
ids = sorted({i for _v, line in moved for i in re.findall(r'[A-Z]{2}-\d+', _scope(line))})
|
|
5059
|
+
out["changed_csv"] = ", ".join(ids)
|
|
5060
|
+
out["changed_summary"] = " // ".join(line.strip() for _v, line in moved)
|
|
5061
|
+
behind = bool(moved) or bool(cur and vtuple(cur) > vtuple(derived))
|
|
5062
|
+
if out["body_changed"] and behind: out["state"] = "edited_behind"
|
|
5063
|
+
elif out["body_changed"]: out["state"] = "edited"
|
|
5064
|
+
elif behind: out["state"] = "behind"
|
|
5065
|
+
else: out["state"] = "clean"
|
|
5066
|
+
print(json.dumps(out))
|
|
5067
|
+
PY
|
|
5068
|
+
}
|
|
5069
|
+
|
|
5070
|
+
cmd_plan_stamp() {
|
|
5071
|
+
local fd_arg="" doc="" ids="" note="" no_row="false"
|
|
5072
|
+
while [ $# -gt 0 ]; do
|
|
5073
|
+
case "$1" in
|
|
5074
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
5075
|
+
--doc) doc="$2"; shift 2 ;;
|
|
5076
|
+
--ids) ids="$2"; shift 2 ;;
|
|
5077
|
+
--note) note="$2"; shift 2 ;;
|
|
5078
|
+
--no-row) no_row="true"; shift ;;
|
|
5079
|
+
--help|-h) echo "plan-stamp --doc <path> [--feature-dir <path>] [--ids <csv>] [--note <text>] [--no-row]"; return 0 ;;
|
|
5080
|
+
*) echo "plan-stamp: unknown arg '$1'" >&2; return 1 ;;
|
|
5081
|
+
esac
|
|
5082
|
+
done
|
|
5083
|
+
[ -n "$doc" ] || { echo "plan-stamp: --doc required" >&2; return 1; }
|
|
5084
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
5085
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "plan-stamp: no feature set and no --feature-dir" >&2; return 1; }
|
|
5086
|
+
AW_DOC="$doc" AW_FD="$fd" AW_IDS="$ids" AW_NOTE="$note" AW_NOROW="$no_row" python3 - <<'PY'
|
|
5087
|
+
import json, os, re, sys, hashlib, datetime
|
|
5088
|
+
doc = os.environ["AW_DOC"]; fd = os.environ["AW_FD"]
|
|
5089
|
+
ids = os.environ.get("AW_IDS", "").strip(); note = os.environ.get("AW_NOTE", "").strip()
|
|
5090
|
+
no_row = os.environ.get("AW_NOROW", "false") == "true"
|
|
5091
|
+
spec = os.path.join(fd, "spec.md")
|
|
5092
|
+
# One track file per plan document (.aw-plan-track.backend-plan.json /
|
|
5093
|
+
# .aw-plan-track.frontend-plan.json) — parallel lane stamps never contend on a shared file.
|
|
5094
|
+
stem = os.path.splitext(os.path.basename(doc))[0]
|
|
5095
|
+
track_path = os.path.join(fd, ".aw-plan-track.%s.json" % stem)
|
|
5096
|
+
def vtuple(v):
|
|
5097
|
+
try: return tuple(int(x) for x in v.lstrip("vV").split("."))
|
|
5098
|
+
except Exception: return (0,)
|
|
5099
|
+
if not os.path.isfile(doc):
|
|
5100
|
+
print(json.dumps({"ok": False, "error": "plan document %s does not exist" % doc, "version": ""})); sys.exit(0)
|
|
5101
|
+
cur = ""
|
|
5102
|
+
if os.path.isfile(spec):
|
|
5103
|
+
st = open(spec, encoding="utf-8", errors="replace").read()
|
|
5104
|
+
m = re.search(r'^##\s+Change History\s*$', st, re.M)
|
|
5105
|
+
rows = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', st[m.end():] if m else "", re.M)
|
|
5106
|
+
hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', st, re.M)
|
|
5107
|
+
cands = rows + ([hv.group(1)] if hv else [])
|
|
5108
|
+
if cands: cur = max(cands, key=vtuple)
|
|
5109
|
+
text = open(doc, encoding="utf-8", errors="replace").read()
|
|
5110
|
+
# The ledger-survival fence: a plan whose update dropped the Change History section is
|
|
5111
|
+
# NOT baselined — a lost ledger silently baselined over could never be noticed again.
|
|
5112
|
+
hist = re.search(r'^##+\s*(?:\d+\.\s*)?Change History\s*$', text, re.M)
|
|
5113
|
+
if not hist:
|
|
5114
|
+
print(json.dumps({"ok": False, "version": cur,
|
|
5115
|
+
"error": "the plan document has no 'Change History' section — the write dropped the ledger; nothing was baselined"}))
|
|
5116
|
+
sys.exit(0)
|
|
5117
|
+
warnings = []
|
|
5118
|
+
if not re.search(r'^##+.*Assumptions', text, re.M):
|
|
5119
|
+
warnings.append("no 'Assumptions' section found")
|
|
5120
|
+
if not re.search(r'^##+.*(Workarounds|Deviations)', text, re.M):
|
|
5121
|
+
warnings.append("no 'Workarounds/Deviations' section found")
|
|
5122
|
+
row_txt = ""
|
|
5123
|
+
if not no_row:
|
|
5124
|
+
# Bounded to the Change History section; the row's cell count follows the section's
|
|
5125
|
+
# own table so the append fits the greenfield, UI and onboard-generated shapes alike.
|
|
5126
|
+
lines = text.split("\n")
|
|
5127
|
+
hline = text[:hist.start()].count("\n")
|
|
5128
|
+
end = len(lines)
|
|
5129
|
+
for i in range(hline + 1, len(lines)):
|
|
5130
|
+
if re.match(r'^##', lines[i]): end = i; break
|
|
5131
|
+
tbl = [i for i in range(hline + 1, end) if lines[i].lstrip().startswith("|")]
|
|
5132
|
+
ncells = 5
|
|
5133
|
+
if tbl: ncells = max(3, lines[tbl[0]].strip().strip("|").count("|") + 1)
|
|
5134
|
+
change = note or ("Conformed to spec %s" % (cur or "(unversioned)"))
|
|
5135
|
+
if ids: change = "%s — %s" % (change, ids)
|
|
5136
|
+
change = change.replace("|", "/").replace("\n", " ")
|
|
5137
|
+
cells = [cur or "v?", datetime.date.today().isoformat(), change] + ["—"] * (ncells - 3)
|
|
5138
|
+
row_txt = "| " + " | ".join(cells[:ncells]) + " |"
|
|
5139
|
+
lines.insert((tbl[-1] + 1) if tbl else end, row_txt)
|
|
5140
|
+
text = "\n".join(lines)
|
|
5141
|
+
if cur:
|
|
5142
|
+
text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + cur, text, count=1, flags=re.M)
|
|
5143
|
+
open(doc, "w", encoding="utf-8").write(text)
|
|
5144
|
+
try: track = json.load(open(track_path, encoding="utf-8"))
|
|
5145
|
+
except Exception: track = {}
|
|
5146
|
+
track[os.path.basename(doc)] = {
|
|
5147
|
+
"spec_version": cur,
|
|
5148
|
+
"sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
5149
|
+
"updated": datetime.datetime.now().isoformat(timespec="seconds")}
|
|
5150
|
+
open(track_path, "w", encoding="utf-8").write(json.dumps(track, indent=2) + "\n")
|
|
5151
|
+
print(json.dumps({"ok": True, "version": cur, "row": row_txt, "warnings": warnings}))
|
|
5152
|
+
PY
|
|
5153
|
+
}
|
|
5154
|
+
|
|
5155
|
+
|
|
5156
|
+
# ───────────────────────────────── gate-result ──────────────────────────────
|
|
5157
|
+
# The machine-readable verdict every analyze gate writes beside its markdown
|
|
5158
|
+
# report. CI cannot branch on prose, and a verdict is exactly the kind of dated,
|
|
5159
|
+
# derived record the model must never write — so the script writes it.
|
|
5160
|
+
#
|
|
5161
|
+
# Shared by design: analyze-design writes one per feature, and analyze-slice /
|
|
5162
|
+
# analyze-code will write one per story and per run. The CORE keys are fixed
|
|
5163
|
+
# (gate, status, feature, spec_version, story, gap_count, gap_refs, report,
|
|
5164
|
+
# generated_at) so a pipeline can read any gate's result with one expression;
|
|
5165
|
+
# everything gate-specific arrives through repeatable --field k=v and lands under
|
|
5166
|
+
# "fields", where it cannot destabilise that core.
|
|
5167
|
+
#
|
|
5168
|
+
# NOTE the returned `ok` means THE FILE WAS WRITTEN. The pass/fail verdict is
|
|
5169
|
+
# `status` — binding a workflow gate to `ok` would pass on every run.
|
|
5170
|
+
cmd_gate_result() {
|
|
5171
|
+
local fd_arg="" gate="" ok="" version="" story="" report="" out="" gaps="" refs="" note="" fields=""
|
|
5172
|
+
while [ $# -gt 0 ]; do
|
|
5173
|
+
case "$1" in
|
|
5174
|
+
--gate) gate="$2"; shift 2 ;;
|
|
5175
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
5176
|
+
--ok) ok="$2"; shift 2 ;;
|
|
5177
|
+
--spec-version) version="$2"; shift 2 ;;
|
|
5178
|
+
--story) story="$2"; shift 2 ;;
|
|
5179
|
+
--report) report="$2"; shift 2 ;;
|
|
5180
|
+
--out) out="$2"; shift 2 ;;
|
|
5181
|
+
--gap-count) gaps="$2"; shift 2 ;;
|
|
5182
|
+
--gap-refs) refs="$2"; shift 2 ;;
|
|
5183
|
+
--note) note="$2"; shift 2 ;;
|
|
5184
|
+
--field) fields="${fields}$2"$'\n'; shift 2 ;;
|
|
5185
|
+
--help|-h) echo "gate-result --gate <name> --ok <true|false> [--feature-dir <p>] [--spec-version <v>] [--story <id>] [--report <path>] [--out <path>] [--gap-count <n>] [--gap-refs <csv>] [--note <text>] [--field k=v]..."; return 0 ;;
|
|
5186
|
+
*) echo "gate-result: unknown arg '$1'" >&2; return 1 ;;
|
|
5187
|
+
esac
|
|
5188
|
+
done
|
|
5189
|
+
[ -n "$gate" ] || { echo "gate-result: --gate required" >&2; return 1; }
|
|
5190
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
5191
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "gate-result: no feature set and no --feature-dir" >&2; return 1; }
|
|
5192
|
+
AW_GATE="$gate" AW_FD="$fd" AW_OK="$ok" AW_VERSION="$version" AW_STORY="$story" \
|
|
5193
|
+
AW_REPORT="$report" AW_OUT="$out" AW_GAPS="$gaps" AW_REFS="$refs" AW_NOTE="$note" \
|
|
5194
|
+
AW_FIELDS="$fields" python3 - <<'PY'
|
|
5195
|
+
import json, os, sys, datetime
|
|
5196
|
+
gate = (os.environ.get("AW_GATE", "") or "").strip() or "gate"
|
|
5197
|
+
fd = os.environ.get("AW_FD", "")
|
|
5198
|
+
report = (os.environ.get("AW_REPORT", "") or "").strip()
|
|
5199
|
+
out = (os.environ.get("AW_OUT", "") or "").strip()
|
|
5200
|
+
# "pass" only on an explicit affirmative. An absent or unreadable verdict is a FAIL,
|
|
5201
|
+
# because a result that defaults green turns every broken run into a passing one.
|
|
5202
|
+
ok = (os.environ.get("AW_OK", "") or "").strip().lower() in ("true", "1", "yes", "pass", "ok")
|
|
5203
|
+
# Written BESIDE the markdown report and named after it, so a job holding either path
|
|
5204
|
+
# holds the other. --out overrides; with no report named at all it falls back to a
|
|
5205
|
+
# dotfile in the feature directory under the gate's own name.
|
|
5206
|
+
if not out:
|
|
5207
|
+
out = (os.path.splitext(report)[0] + ".json") if report else os.path.join(fd, ".aw-gate-%s.json" % gate)
|
|
5208
|
+
try: gap_count = int((os.environ.get("AW_GAPS", "") or "").strip() or 0)
|
|
5209
|
+
except ValueError: gap_count = 0
|
|
5210
|
+
refs = [r.strip() for r in (os.environ.get("AW_REFS", "") or "").replace(";", ",").split(",") if r.strip()]
|
|
5211
|
+
# A fail reporting zero findings reads as green to anything that sums the number
|
|
5212
|
+
# rather than reading the status, so a red verdict always carries at least one.
|
|
5213
|
+
if not ok and gap_count < 1: gap_count = max(1, len(refs))
|
|
5214
|
+
# --field k=v, repeatable, arriving newline-separated. Keeps the CORE schema below
|
|
5215
|
+
# fixed for every gate while each one carries its own detail underneath.
|
|
5216
|
+
fields = {}
|
|
5217
|
+
for line in (os.environ.get("AW_FIELDS", "") or "").splitlines():
|
|
5218
|
+
k, sep, v = line.partition("=")
|
|
5219
|
+
if sep and k.strip(): fields[k.strip()] = v.strip()
|
|
5220
|
+
res = {"gate": gate,
|
|
5221
|
+
"status": "pass" if ok else "fail",
|
|
5222
|
+
"feature": os.path.basename(fd.rstrip("/\\")),
|
|
5223
|
+
"feature_dir": fd,
|
|
5224
|
+
"spec_version": (os.environ.get("AW_VERSION", "") or "").strip(),
|
|
5225
|
+
"story": (os.environ.get("AW_STORY", "") or "").strip(),
|
|
5226
|
+
"gap_count": gap_count,
|
|
5227
|
+
"gap_refs": refs,
|
|
5228
|
+
"report": report,
|
|
5229
|
+
"note": (os.environ.get("AW_NOTE", "") or "").strip(),
|
|
5230
|
+
"fields": fields,
|
|
5231
|
+
"generated_at": datetime.datetime.now().isoformat(timespec="seconds")}
|
|
5232
|
+
d = os.path.dirname(out)
|
|
5233
|
+
if d and not os.path.isdir(d): os.makedirs(d, exist_ok=True)
|
|
5234
|
+
open(out, "w", encoding="utf-8").write(json.dumps(res, indent=2) + "\n")
|
|
5235
|
+
print(json.dumps({"ok": True, "result_file": out, "status": res["status"], "gap_count": gap_count}))
|
|
5236
|
+
PY
|
|
5237
|
+
}
|
|
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
|
+
|
|
5324
|
+
# ─────────────── story-plan-check / story-plan-doc / log-append ───────────────
|
|
5325
|
+
# The per-story execution design ("story design") — SHORT-LIVED scaffolding, ONE
|
|
5326
|
+
# DOCUMENT PER LANE beside the story: stories/<ID>/story-backend-plan.md and
|
|
5327
|
+
# stories/<ID>/story-frontend-plan.md. Every /appweaver story-plan run
|
|
5328
|
+
# APPENDS a dated "## Design pass N" section; the LATEST pass wins and nothing is
|
|
5329
|
+
# edited in place, so a manual edit to an earlier pass survives a re-run. The
|
|
5330
|
+
# document's identity is positional (its directory): no story id appears in its
|
|
5331
|
+
# text. Writing into stories/<ID>/ is what flips the story IN-FLIGHT for
|
|
5332
|
+
# sync-stories' follow-on rule — the same isdir test story-spec's projection used
|
|
5333
|
+
# to trip.
|
|
5334
|
+
#
|
|
5335
|
+
# story-plan-check resolves WHICH story (an explicit --story, an id found in the
|
|
5336
|
+
# free-text input, or the open backlog for an ask), and reports everything the
|
|
5337
|
+
# workflow gates on: status, cited ids, the Constraints section's bound ids, ledger
|
|
5338
|
+
# rows above the story's stamp (staleness), doc/pass state and the feature's
|
|
5339
|
+
# append-only log paths. Always exits 0 with ok/flag fields — the workflow's
|
|
5340
|
+
# verify_context owns every stop (the lane-verify convention).
|
|
5341
|
+
#
|
|
5342
|
+
# story-plan-doc is the ONLY writer of either lane's design: --lane picks the file,
|
|
5343
|
+
# and it scaffolds the shell (frontmatter + contract comment) on first use, appends
|
|
5344
|
+
# the authored pass under a SCRIPT-written header (the model writes no pass number,
|
|
5345
|
+
# date or version), stamps the frontmatter and removes the temp pass file. Pass
|
|
5346
|
+
# numbers are per lane. A pre-split story-plan.md is reported by story-plan-check as
|
|
5347
|
+
# legacy_doc and touched by nothing.
|
|
5348
|
+
#
|
|
5349
|
+
# log-append owns the feature's two append-only logs: interfaces.md (IF-nnn — the
|
|
5350
|
+
# FE/BE contract anchor; BE design appends status:proposed, story close verifies
|
|
5351
|
+
# against the as-built and flips to live) and adr.md (ADR-nnn — ratified decisions).
|
|
5352
|
+
# The script assigns ids, dates and status; entries are never edited afterwards,
|
|
5353
|
+
# only superseded by later entries naming `supersedes:`.
|
|
5354
|
+
cmd_story_plan_check() {
|
|
5355
|
+
local fd_arg="" input="" story=""
|
|
5356
|
+
while [ $# -gt 0 ]; do
|
|
5357
|
+
case "$1" in
|
|
5358
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
5359
|
+
--input) input="$2"; shift 2 ;;
|
|
5360
|
+
--story) story="$2"; shift 2 ;;
|
|
5361
|
+
--help|-h) echo "story-plan-check [--story <id>] [--input <free text>] [--feature-dir <path>]"; return 0 ;;
|
|
5362
|
+
*) echo "story-plan-check: unknown arg '$1'" >&2; return 1 ;;
|
|
5363
|
+
esac
|
|
5364
|
+
done
|
|
5365
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
5366
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-plan-check: no feature set and no --feature-dir" >&2; return 1; }
|
|
5367
|
+
AW_FD="$fd" AW_INPUT="$input" AW_STORY="$story" python3 - <<'PY'
|
|
5368
|
+
import json, os, re, sys
|
|
5369
|
+
fd = os.environ["AW_FD"]
|
|
5370
|
+
sdir = os.path.join(fd, "stories")
|
|
5371
|
+
spec = os.path.join(fd, "spec.md")
|
|
5372
|
+
|
|
5373
|
+
def parse_fm(text):
|
|
5374
|
+
"""Frontmatter as a flat dict — inline and block-list values both fold to the
|
|
5375
|
+
inline shape (same reader story-refs uses, for the same reason)."""
|
|
5376
|
+
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
|
|
5377
|
+
if not m: return {}
|
|
5378
|
+
fm, key, block = {}, None, set()
|
|
5379
|
+
for line in m.group(1).split("\n"):
|
|
5380
|
+
km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
|
|
5381
|
+
if km:
|
|
5382
|
+
key = km.group(1); fm[key] = km.group(2).strip(); continue
|
|
5383
|
+
im = re.match(r'^\s+-\s+(.*)$', line) if key else None
|
|
5384
|
+
if im and (key in block or not fm[key]):
|
|
5385
|
+
block.add(key)
|
|
5386
|
+
fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
|
|
5387
|
+
return fm
|
|
5388
|
+
|
|
5389
|
+
def vtuple(v):
|
|
5390
|
+
try: return tuple(int(x) for x in v.lstrip("vV").split("."))
|
|
5391
|
+
except Exception: return (0,)
|
|
5392
|
+
|
|
5393
|
+
# ---- the backlog ------------------------------------------------------------
|
|
5394
|
+
stories = []
|
|
5395
|
+
if os.path.isdir(sdir):
|
|
5396
|
+
for name in sorted(os.listdir(sdir)):
|
|
5397
|
+
# stories.md is the GENERATED index beside the stories, not a story
|
|
5398
|
+
if not name.endswith(".md") or name == "stories.md": continue
|
|
5399
|
+
p = os.path.join(sdir, name)
|
|
5400
|
+
if not os.path.isfile(p): continue
|
|
5401
|
+
text = open(p, encoding="utf-8", errors="replace").read()
|
|
5402
|
+
fm = parse_fm(text)
|
|
5403
|
+
sid = fm.get("id") or name[:-3]
|
|
5404
|
+
stories.append({"id": sid, "file_id": name[:-3],
|
|
5405
|
+
"status": (fm.get("status") or "open").strip().lower(),
|
|
5406
|
+
"type": (fm.get("type") or "functional").strip().lower(),
|
|
5407
|
+
"title": (fm.get("title") or "").strip().strip("[]"),
|
|
5408
|
+
"spec_version": (fm.get("spec_version") or "").strip(),
|
|
5409
|
+
"cites": re.findall(r'\b(?:FR|BR)-\d+', fm.get("exercises", "")),
|
|
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)
|
|
5422
|
+
open_stories = [s for s in stories if s["status"] == "open"]
|
|
5423
|
+
open_ids = [s["id"] for s in open_stories]
|
|
5424
|
+
digest = "\n".join(" %-16s %s%s" % (s["id"], s["title"] or "(untitled)",
|
|
5425
|
+
(" cites " + ", ".join(s["cites"])) if s["cites"] else "")
|
|
5426
|
+
for s in open_stories) or " (no open stories)"
|
|
5427
|
+
|
|
5428
|
+
# ---- which story ------------------------------------------------------------
|
|
5429
|
+
want = (os.environ.get("AW_STORY") or "").strip()
|
|
5430
|
+
free = os.environ.get("AW_INPUT") or ""
|
|
5431
|
+
target, reason = None, ""
|
|
5432
|
+
if want:
|
|
5433
|
+
hit = [s for s in stories if s["id"] == want or s["file_id"] == want]
|
|
5434
|
+
target = hit[0] if hit else None
|
|
5435
|
+
if not target: reason = "not-found"
|
|
5436
|
+
else:
|
|
5437
|
+
# every known story id appearing as a whole token in the free text; one match
|
|
5438
|
+
# resolves, several is a question, none is a question
|
|
5439
|
+
named = [s for s in stories
|
|
5440
|
+
if re.search(r'(?<![\w-])' + re.escape(s["id"]) + r'(?![\w-])', free)]
|
|
5441
|
+
if len(named) == 1: target = named[0]
|
|
5442
|
+
else: reason = "ambiguous" if named else "none-given"
|
|
5443
|
+
|
|
5444
|
+
out = {"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
|
|
5445
|
+
"open_count": len(open_stories), "any_open": bool(open_stories),
|
|
5446
|
+
"open_ids": open_ids, "open_ids_csv": ", ".join(open_ids), "digest": digest,
|
|
5447
|
+
"resolved": bool(target), "needs_pick": not target, "pick_reason": reason,
|
|
5448
|
+
"story": "", "story_path": "", "story_status": "", "story_open": False,
|
|
5449
|
+
"story_type": "", "is_technical": False,
|
|
5450
|
+
"status_msg": "", "story_title": "", "exercises_csv": "", "constraints_csv": "",
|
|
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,
|
|
5457
|
+
"story_dir": "", "if_tmp": "", "legacy_doc": "", "legacy_doc_exists": False,
|
|
5458
|
+
"be_doc_path": "", "be_doc_exists": False, "be_next_pass": 1, "be_pass_tmp": "",
|
|
5459
|
+
"be_last_pass": 0, "be_doc_spec_version": "", "be_doc_stale": False,
|
|
5460
|
+
"be_pass_plan_hash": "",
|
|
5461
|
+
"fe_doc_path": "", "fe_doc_exists": False, "fe_next_pass": 1, "fe_pass_tmp": "",
|
|
5462
|
+
"fe_last_pass": 0, "fe_doc_spec_version": "", "fe_doc_stale": False,
|
|
5463
|
+
"fe_pass_plan_hash": "",
|
|
5464
|
+
"adr_path": os.path.join(fd, "adr.md"), "adr_exists": os.path.isfile(os.path.join(fd, "adr.md")),
|
|
5465
|
+
"if_path": os.path.join(fd, "interfaces.md"), "if_exists": os.path.isfile(os.path.join(fd, "interfaces.md")),
|
|
5466
|
+
"spec_exists": os.path.isfile(spec)}
|
|
5467
|
+
|
|
5468
|
+
# ---- the ledger (widest-wins current version, same rule ledger-append uses) --
|
|
5469
|
+
rows, cur = [], ""
|
|
5470
|
+
if os.path.isfile(spec):
|
|
5471
|
+
stext = open(spec, encoding="utf-8", errors="replace").read()
|
|
5472
|
+
m = re.search(r'^##\s+Change History\s*$', stext, re.M)
|
|
5473
|
+
if m:
|
|
5474
|
+
for line in stext[m.end():].splitlines():
|
|
5475
|
+
rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
|
|
5476
|
+
if rm: rows.append((rm.group(1), line))
|
|
5477
|
+
hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', stext, re.M)
|
|
5478
|
+
cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
|
|
5479
|
+
if cands: cur = max(cands, key=vtuple)
|
|
5480
|
+
out["current_version"] = cur
|
|
5481
|
+
|
|
5482
|
+
if not target:
|
|
5483
|
+
print(json.dumps(out)); sys.exit(0)
|
|
5484
|
+
|
|
5485
|
+
# ---- the resolved story -----------------------------------------------------
|
|
5486
|
+
status = target["status"]
|
|
5487
|
+
out.update(story=target["id"], story_path=target["path"], story_status=status,
|
|
5488
|
+
story_open=(status == "open"), story_title=target["title"],
|
|
5489
|
+
story_type=target["type"], is_technical=(target["type"] == "technical"),
|
|
5490
|
+
exercises_csv=", ".join(target["cites"]),
|
|
5491
|
+
story_spec_version=target["spec_version"])
|
|
5492
|
+
if status != "open":
|
|
5493
|
+
verb = {"closed": "was closed — its plan is history. Remaining work needs its own story "
|
|
5494
|
+
"(sync-stories mints a follow-on when the spec moves)",
|
|
5495
|
+
"cancelled": "was cancelled — cancelled work is not planned; open a new story if the need returns",
|
|
5496
|
+
"draft": "is a draft proposal — run /appweaver sync-spec to merge it into the contract first; "
|
|
5497
|
+
"story-plan designs only against merged requirements"}
|
|
5498
|
+
out["status_msg"] = "Story %s %s." % (
|
|
5499
|
+
target["id"], verb.get(status, "has status '%s', not open" % status))
|
|
5500
|
+
|
|
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 []
|
|
5515
|
+
out["constraints_csv"] = ", ".join(cons)
|
|
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
|
+
|
|
5561
|
+
# staleness: ledger rows ABOVE the story's stamp naming an id the story carries —
|
|
5562
|
+
# exercises: AND the Constraints section, so a moved bound is announced too
|
|
5563
|
+
sv = target["spec_version"]
|
|
5564
|
+
if sv:
|
|
5565
|
+
carried = set(target["cites"]) | set(cons)
|
|
5566
|
+
moved = [r for r in rows if vtuple(r[0]) > vtuple(sv)]
|
|
5567
|
+
stale = sorted({i for _v, l in moved for i in re.findall(r'[A-Z]{2}-\d+', l) if i in carried})
|
|
5568
|
+
out["stale_csv"] = ", ".join(stale)
|
|
5569
|
+
if stale:
|
|
5570
|
+
out["stale_note"] = (
|
|
5571
|
+
"CAUTION: the spec moved above this story's stamp (%s -> %s) on ids this story "
|
|
5572
|
+
"carries: %s. Design against the CURRENT spec text, record the divergence under "
|
|
5573
|
+
"Risks, and consider /appweaver sync-stories to bring the backlog back in step."
|
|
5574
|
+
% (sv, cur or "?", ", ".join(stale)))
|
|
5575
|
+
|
|
5576
|
+
# doc / pass state + the temp paths the workflow writes through. ONE DOCUMENT PER
|
|
5577
|
+
# LANE: the service design and the UI design are authored by different steps, under
|
|
5578
|
+
# different rule sets, from different code, and are read by different implement
|
|
5579
|
+
# commands — folded into one file, every one of those becomes a section boundary the
|
|
5580
|
+
# next reader has to trust. Pass numbers are per file, so a run that designs only the
|
|
5581
|
+
# backend leaves the frontend's count exactly where it was.
|
|
5582
|
+
#
|
|
5583
|
+
# legacy_doc is the pre-split story-plan.md. It is REPORTED, never read and never
|
|
5584
|
+
# written: a design nobody consumes must not sit silently beside two that are.
|
|
5585
|
+
stdir = os.path.join(sdir, target["id"])
|
|
5586
|
+
legacy = os.path.join(stdir, "story-plan.md")
|
|
5587
|
+
out.update(story_dir=stdir, if_tmp=os.path.join(stdir, ".if-entries.tmp.md"),
|
|
5588
|
+
legacy_doc=legacy, legacy_doc_exists=os.path.isfile(legacy))
|
|
5589
|
+
for lane, stem in (("be", "backend"), ("fe", "frontend")):
|
|
5590
|
+
doc = os.path.join(stdir, "story-%s-plan.md" % stem)
|
|
5591
|
+
out[lane + "_doc_path"] = doc
|
|
5592
|
+
out[lane + "_doc_exists"] = os.path.isfile(doc)
|
|
5593
|
+
out[lane + "_pass_tmp"] = os.path.join(stdir, ".%s-pass.tmp.md" % stem)
|
|
5594
|
+
if out[lane + "_doc_exists"]:
|
|
5595
|
+
d = open(doc, encoding="utf-8", errors="replace").read()
|
|
5596
|
+
nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', d, re.M)]
|
|
5597
|
+
out[lane + "_next_pass"] = (max(nums) + 1) if nums else 1
|
|
5598
|
+
# What implement needs on top of the path: WHICH pass it builds from (the last
|
|
5599
|
+
# one — earlier passes are superseded history) and the spec version that pass was
|
|
5600
|
+
# designed against. A design behind the current spec is ANNOUNCED at implement,
|
|
5601
|
+
# never refused: the story is legitimately in flight, and the spec may well have
|
|
5602
|
+
# moved on ids this story does not carry. stale_csv is the sharper signal and it
|
|
5603
|
+
# is already computed above.
|
|
5604
|
+
out[lane + "_last_pass"] = max(nums) if nums else 0
|
|
5605
|
+
dv = (parse_fm(d).get("spec_version") or "").strip().strip('"')
|
|
5606
|
+
out[lane + "_doc_spec_version"] = dv
|
|
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 ""
|
|
5617
|
+
print(json.dumps(out))
|
|
5618
|
+
PY
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5621
|
+
cmd_story_plan_doc() {
|
|
5622
|
+
local fd_arg="" story="" pass_file="" spec_version="" lane="backend" plan_hash=""
|
|
5623
|
+
while [ $# -gt 0 ]; do
|
|
5624
|
+
case "$1" in
|
|
5625
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
5626
|
+
--story) story="$2"; shift 2 ;;
|
|
5627
|
+
--lane) lane="$2"; shift 2 ;;
|
|
5628
|
+
--pass-file) pass_file="$2"; shift 2 ;;
|
|
5629
|
+
--spec-version) spec_version="$2"; shift 2 ;;
|
|
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 ;;
|
|
5635
|
+
*) echo "story-plan-doc: unknown arg '$1'" >&2; return 1 ;;
|
|
5636
|
+
esac
|
|
5637
|
+
done
|
|
5638
|
+
[ -n "$story" ] || { echo "story-plan-doc: --story is required" >&2; return 1; }
|
|
5639
|
+
[ -n "$pass_file" ] || { echo "story-plan-doc: --pass-file is required" >&2; return 1; }
|
|
5640
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
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; }
|
|
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'
|
|
5644
|
+
import json, os, re, sys, datetime
|
|
5645
|
+
fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
|
|
5646
|
+
pf = os.environ["AW_PASS"]; ver = (os.environ.get("AW_VER") or "").strip()
|
|
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())
|
|
5649
|
+
sdir = os.path.join(fd, "stories", story)
|
|
5650
|
+
doc = os.path.join(sdir, "story-%s-plan.md" % lane)
|
|
5651
|
+
|
|
5652
|
+
def bail(msg, code=2):
|
|
5653
|
+
print(json.dumps({"ok": False, "doc": doc, "error": msg}))
|
|
5654
|
+
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
5655
|
+
|
|
5656
|
+
if lane not in ("backend", "frontend"):
|
|
5657
|
+
bail("story-plan-doc: --lane must be backend or frontend, not '%s'" % lane)
|
|
5658
|
+
if not os.path.isfile(os.path.join(fd, "stories", story + ".md")):
|
|
5659
|
+
bail("story-plan-doc: no story file for '%s' — the plan lives beside a story that exists" % story)
|
|
5660
|
+
if not os.path.isfile(pf):
|
|
5661
|
+
bail("story-plan-doc: pass file missing at %s — the authoring step wrote nothing" % pf)
|
|
5662
|
+
content = open(pf, encoding="utf-8", errors="replace").read().strip()
|
|
5663
|
+
if not content:
|
|
5664
|
+
bail("story-plan-doc: the authored pass is empty — nothing appended")
|
|
5665
|
+
# the pass header is SCRIPT-written; strip one the model wrote anyway rather than
|
|
5666
|
+
# letting the doc double-number
|
|
5667
|
+
content = re.sub(r'^##\s+Design pass\b[^\n]*\n+', '', content, count=1)
|
|
5668
|
+
|
|
5669
|
+
LANE_WORD = "SERVICE" if lane == "backend" else "UI"
|
|
5670
|
+
LANE_READER = "/appweaver implement" if lane == "backend" else "/appweaver implement-ui"
|
|
5671
|
+
os.makedirs(sdir, exist_ok=True)
|
|
5672
|
+
today = datetime.date.today().isoformat()
|
|
5673
|
+
created = not os.path.isfile(doc)
|
|
5674
|
+
if created:
|
|
5675
|
+
open(doc, "w", encoding="utf-8").write("""---
|
|
5676
|
+
created: %s
|
|
5677
|
+
updated: %s
|
|
5678
|
+
passes: 0
|
|
5679
|
+
spec_version: ""
|
|
5680
|
+
---
|
|
5681
|
+
|
|
5682
|
+
<!--
|
|
5683
|
+
STORY DESIGN (%s LANE) — the ephemeral execution design for ONE story, read by
|
|
5684
|
+
%s.
|
|
5685
|
+
|
|
5686
|
+
Identity is positional: the file lives in the story's directory and carries no
|
|
5687
|
+
story id in its text. Every /appweaver story-plan run APPENDS a "## Design pass N"
|
|
5688
|
+
section to THIS lane's file — nothing is edited in place, so a manual edit to an
|
|
5689
|
+
earlier pass survives a re-run. THE LATEST PASS WINS; earlier passes are the
|
|
5690
|
+
iteration history.
|
|
5691
|
+
|
|
5692
|
+
EDIT IT BEFORE YOU IMPLEMENT. This is the document a design is corrected in by
|
|
5693
|
+
hand: change the latest pass and implement builds what you changed. Re-running
|
|
5694
|
+
story-plan instead appends a fresh pass that supersedes it, so hand-edit to
|
|
5695
|
+
adjust, re-run to redesign.
|
|
5696
|
+
|
|
5697
|
+
It is scaffolding for confirming the implementation strategy — archived with the
|
|
5698
|
+
story at close, never maintained afterwards. Durable decisions do not live here:
|
|
5699
|
+
the latest pass PROPOSES ADRs (ratified into the feature's adr.md at close), and
|
|
5700
|
+
published-surface changes are appended to the feature's interfaces.md as proposed
|
|
5701
|
+
entries at design time. spec_version below is the spec the LATEST pass was
|
|
5702
|
+
designed against. The other lane's design, where the project has one, sits beside
|
|
5703
|
+
this file — the two are written apart on purpose and neither restates the other.
|
|
5704
|
+
-->
|
|
5705
|
+
""" % (today, today, LANE_WORD, LANE_READER))
|
|
5706
|
+
text = open(doc, encoding="utf-8", errors="replace").read()
|
|
5707
|
+
nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', text, re.M)]
|
|
5708
|
+
n = (max(nums) + 1) if nums else 1
|
|
5709
|
+
header = "## Design pass %d — %s%s%s" % (
|
|
5710
|
+
n, today, (" (spec %s)" % ver) if ver else "",
|
|
5711
|
+
(" (plan %s)" % phash) if phash else "")
|
|
5712
|
+
text = text.rstrip("\n") + "\n\n" + header + "\n\n" + content + "\n"
|
|
5713
|
+
# frontmatter stamps — first match only, which is the frontmatter by position
|
|
5714
|
+
text = re.sub(r'^(updated:).*$', r'\g<1> ' + today, text, count=1, flags=re.M)
|
|
5715
|
+
text = re.sub(r'^(passes:).*$', r'\g<1> %d' % n, text, count=1, flags=re.M)
|
|
5716
|
+
if ver:
|
|
5717
|
+
text = re.sub(r'^(spec_version:).*$', r'\g<1> %s' % ver, text, count=1, flags=re.M)
|
|
5718
|
+
open(doc, "w", encoding="utf-8").write(text)
|
|
5719
|
+
os.remove(pf)
|
|
5720
|
+
print(json.dumps({"ok": True, "doc": doc, "lane": lane, "pass": n, "created": created,
|
|
5721
|
+
"spec_version": ver, "plan_hash": phash, "story_dir": sdir}))
|
|
5722
|
+
PY
|
|
5723
|
+
}
|
|
5724
|
+
|
|
5725
|
+
cmd_log_append() {
|
|
5726
|
+
local fd_arg="" log="" entries_file="" status=""
|
|
5727
|
+
while [ $# -gt 0 ]; do
|
|
5728
|
+
case "$1" in
|
|
5729
|
+
--feature-dir) fd_arg="$2"; shift 2 ;;
|
|
5730
|
+
--log) log="$2"; shift 2 ;;
|
|
5731
|
+
--entries-file) entries_file="$2"; shift 2 ;;
|
|
5732
|
+
--status) status="$2"; shift 2 ;;
|
|
5733
|
+
--help|-h) echo "log-append --log adr|interfaces --entries-file <path> [--status <s>] [--feature-dir <path>]"; return 0 ;;
|
|
5734
|
+
*) echo "log-append: unknown arg '$1'" >&2; return 1 ;;
|
|
5735
|
+
esac
|
|
5736
|
+
done
|
|
5737
|
+
case "$log" in adr|interfaces) ;; *) echo "log-append: --log must be adr or interfaces" >&2; return 1 ;; esac
|
|
5738
|
+
[ -n "$entries_file" ] || { echo "log-append: --entries-file is required" >&2; return 1; }
|
|
5739
|
+
local repo_root; repo_root=$(get_repo_root)
|
|
5740
|
+
local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "log-append: no feature set and no --feature-dir" >&2; return 1; }
|
|
5741
|
+
AW_FD="$fd" AW_LOG="$log" AW_ENTRIES="$entries_file" AW_STATUS="$status" python3 - <<'PY'
|
|
5742
|
+
import json, os, re, sys, time, errno, datetime
|
|
5743
|
+
fd = os.environ["AW_FD"]; log = os.environ["AW_LOG"]
|
|
5744
|
+
ef = os.environ["AW_ENTRIES"]; status = (os.environ.get("AW_STATUS") or "").strip()
|
|
5745
|
+
fname, prefix, default_status, title = {
|
|
5746
|
+
"interfaces": ("interfaces.md", "IF", "proposed", "Interface Log"),
|
|
5747
|
+
"adr": ("adr.md", "ADR", "accepted", "Architecture Decision Log")}[log]
|
|
5748
|
+
status = status or default_status
|
|
5749
|
+
path = os.path.join(fd, fname)
|
|
5750
|
+
|
|
5751
|
+
def bail(msg, code=2):
|
|
5752
|
+
print(json.dumps({"ok": False, "log": path, "error": msg}))
|
|
5753
|
+
sys.stderr.write(msg + "\n"); sys.exit(code)
|
|
5754
|
+
|
|
5755
|
+
if not os.path.isfile(ef):
|
|
5756
|
+
bail("log-append: entries file missing at %s — the authoring step wrote nothing" % ef)
|
|
5757
|
+
raw = open(ef, encoding="utf-8", errors="replace").read().strip()
|
|
5758
|
+
if not raw or raw.lower().rstrip(".") == "none":
|
|
5759
|
+
os.remove(ef)
|
|
5760
|
+
print(json.dumps({"ok": True, "log": path, "appended": 0, "ids_csv": "",
|
|
5761
|
+
"unknown_ids_csv": "", "bad_supersedes_csv": "", "none": True}))
|
|
5762
|
+
sys.exit(0)
|
|
5763
|
+
|
|
5764
|
+
# every entry must arrive as '## <PREFIX>-NEW — <title>': the script is the only id
|
|
5765
|
+
# authority, so a model-numbered entry is refused rather than trusted
|
|
5766
|
+
blocks = [b.strip() for b in re.split(r'(?=^##\s)', raw, flags=re.M) if b.strip()]
|
|
5767
|
+
bad = [b.splitlines()[0] for b in blocks
|
|
5768
|
+
if not re.match(r'^##\s+%s-NEW\s+[—-]+\s+\S' % prefix, b)]
|
|
5769
|
+
if bad:
|
|
5770
|
+
bail("log-append: every entry must start '## %s-NEW — <title>' (the script assigns "
|
|
5771
|
+
"ids); got: %s" % (prefix, " | ".join(bad[:3])))
|
|
5772
|
+
|
|
5773
|
+
SCAFFOLD = {
|
|
5774
|
+
"interfaces": """<!--
|
|
5775
|
+
INTERFACE LOG — this feature's published-surface contract. APPEND-ONLY.
|
|
5776
|
+
|
|
5777
|
+
WHO WRITES: /appweaver plan publishes this feature's surface whenever it authors or
|
|
5778
|
+
conforms backend-plan.md; a BE story plan appends an entry only where its slice
|
|
5779
|
+
changes that surface. Both land as status: proposed, and story close verifies each
|
|
5780
|
+
against the as-built and flips it to live.
|
|
5781
|
+
WHO READS: /appweaver plan-ui and the FE story designs — this log is the BE-to-FE
|
|
5782
|
+
contract anchor, and the plan documents CITE these ids rather than restating them.
|
|
5783
|
+
The current surface per interface is the LATEST entry — one that names an earlier
|
|
5784
|
+
id in `supersedes:` replaces it. Never edit an existing entry; publish a
|
|
5785
|
+
superseding one. status: proposed -> live -> superseded | retired.
|
|
5786
|
+
`serves:` cites the FR-/BR- ids the interface exists for, so a spec change to
|
|
5787
|
+
those ids flags the contract for review.
|
|
5788
|
+
-->
|
|
5789
|
+
|
|
5790
|
+
# Interface Log
|
|
5791
|
+
""",
|
|
5792
|
+
"adr": """<!--
|
|
5793
|
+
ADR LOG — this feature's durable decisions. APPEND-ONLY.
|
|
5794
|
+
|
|
5795
|
+
A story plan PROPOSES ADRs in its latest design pass; they are ratified into this
|
|
5796
|
+
log at story close, or the architect appends directly. Never rewrite an entry —
|
|
5797
|
+
supersede it with a new one naming `supersedes:`. An ADR records ONLY what neither
|
|
5798
|
+
the code nor the rules can say: a deliberate choice among rule-compliant
|
|
5799
|
+
alternatives, a rejected alternative and why, or a trade-off with its revisit
|
|
5800
|
+
trigger. It never restates what code shows or a rule mandates.
|
|
5801
|
+
-->
|
|
5802
|
+
|
|
5803
|
+
# Architecture Decision Log
|
|
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)
|
|
5845
|
+
created = not os.path.isfile(path)
|
|
5846
|
+
if created:
|
|
5847
|
+
open(path, "w", encoding="utf-8").write(SCAFFOLD[log])
|
|
5848
|
+
text = open(path, encoding="utf-8", errors="replace").read()
|
|
5849
|
+
existing = set(re.findall(r'^##\s+(%s-\d+)' % prefix, text, re.M))
|
|
5850
|
+
nums = [int(i.split("-")[1]) for i in existing]
|
|
5851
|
+
nxt = (max(nums) + 1) if nums else 1
|
|
5852
|
+
today = datetime.date.today().isoformat()
|
|
5853
|
+
|
|
5854
|
+
spec = os.path.join(fd, "spec.md")
|
|
5855
|
+
known = set(re.findall(r'\b[A-Z]{2}-\d+\b',
|
|
5856
|
+
open(spec, encoding="utf-8", errors="replace").read())) \
|
|
5857
|
+
if os.path.isfile(spec) else set()
|
|
5858
|
+
|
|
5859
|
+
assigned, unknown, badsup, out_blocks = [], [], [], []
|
|
5860
|
+
for b in blocks:
|
|
5861
|
+
bid = "%s-%03d" % (prefix, nxt); nxt += 1
|
|
5862
|
+
lines = b.splitlines()
|
|
5863
|
+
lines[0] = lines[0].replace("%s-NEW" % prefix, bid, 1)
|
|
5864
|
+
body = "\n".join(lines[1:]).strip("\n")
|
|
5865
|
+
# status and date are script-stamped, whatever the model wrote
|
|
5866
|
+
body = re.sub(r'^status:.*\n?', '', body, flags=re.M)
|
|
5867
|
+
body = re.sub(r'^date:.*\n?', '', body, flags=re.M)
|
|
5868
|
+
body = ("status: %s\ndate: %s" % (status, today)) + ("\n" + body if body else "")
|
|
5869
|
+
for line in body.splitlines():
|
|
5870
|
+
if line.startswith("serves:"):
|
|
5871
|
+
unknown += [i for i in re.findall(r'\b[A-Z]{2}-\d+\b', line)
|
|
5872
|
+
if known and i not in known]
|
|
5873
|
+
if line.startswith("supersedes:"):
|
|
5874
|
+
badsup += [i for i in re.findall(r'\b%s-\d+\b' % prefix, line)
|
|
5875
|
+
if i not in existing]
|
|
5876
|
+
out_blocks.append(lines[0] + "\n\n" + body)
|
|
5877
|
+
assigned.append(bid)
|
|
5878
|
+
|
|
5879
|
+
text = text.rstrip("\n") + "\n\n" + "\n\n".join(out_blocks) + "\n"
|
|
5880
|
+
open(path, "w", encoding="utf-8").write(text)
|
|
5881
|
+
drop_lock(lock)
|
|
5882
|
+
os.remove(ef)
|
|
5883
|
+
print(json.dumps({"ok": True, "log": path, "appended": len(assigned),
|
|
5884
|
+
"ids_csv": ", ".join(assigned),
|
|
5885
|
+
"unknown_ids_csv": ", ".join(sorted(set(unknown))),
|
|
5886
|
+
"bad_supersedes_csv": ", ".join(sorted(set(badsup))),
|
|
5887
|
+
"none": False, "created_log": created}))
|
|
5888
|
+
PY
|
|
5889
|
+
}
|
|
5890
|
+
|
|
3377
5891
|
case "$sub" in
|
|
3378
5892
|
resolve-domain) cmd_resolve_domain "$@" ;;
|
|
3379
5893
|
resolve-tech) cmd_resolve_tech "$@" ;;
|
|
@@ -3399,13 +5913,25 @@ case "$sub" in
|
|
|
3399
5913
|
spec-snapshot) cmd_spec_snapshot "$@" ;;
|
|
3400
5914
|
spec-diff) cmd_spec_diff "$@" ;;
|
|
3401
5915
|
story-refs) cmd_story_refs "$@" ;;
|
|
5916
|
+
slice-mode) cmd_slice_mode "$@" ;;
|
|
5917
|
+
backlog-plan) cmd_backlog_plan "$@" ;;
|
|
5918
|
+
story-stamp) cmd_story_stamp "$@" ;;
|
|
5919
|
+
story-plan-check) cmd_story_plan_check "$@" ;;
|
|
5920
|
+
story-plan-doc) cmd_story_plan_doc "$@" ;;
|
|
5921
|
+
log-append) cmd_log_append "$@" ;;
|
|
5922
|
+
ledger-read) cmd_ledger_read "$@" ;;
|
|
3402
5923
|
spec-coverage) cmd_spec_coverage "$@" ;;
|
|
3403
5924
|
spec-preserve) cmd_spec_preserve "$@" ;;
|
|
3404
5925
|
spec-markers) cmd_spec_markers "$@" ;;
|
|
3405
5926
|
feature-paths) cmd_feature_paths "$@" ;;
|
|
3406
5927
|
design-scaffold) cmd_design_scaffold "$@" ;;
|
|
5928
|
+
plan-check) cmd_plan_check "$@" ;;
|
|
5929
|
+
plan-stamp) cmd_plan_stamp "$@" ;;
|
|
5930
|
+
gate-result) cmd_gate_result "$@" ;;
|
|
5931
|
+
gate-read) cmd_gate_read "$@" ;;
|
|
3407
5932
|
build-compile) cmd_build_compile "$@" ;;
|
|
3408
5933
|
test-run) cmd_test_run "$@" ;;
|
|
5934
|
+
test-diff) cmd_test_diff "$@" ;;
|
|
3409
5935
|
run-migrate) cmd_run_migrate "$@" ;;
|
|
3410
5936
|
deploy-dispatch) cmd_deploy_dispatch "$@" ;;
|
|
3411
5937
|
cr-setup) cmd_cr_setup "$@" ;;
|