aw-spec-cli 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """aw-payload-parity.py — prove the two shells run the SAME python.
3
+
4
+ Most of aw-spec-actions is one python program embedded twice: as a `<<'PY'` heredoc in
5
+ aw-spec-actions.sh and as a `$PyName = @'…'@` here-string in aw-spec-actions.ps1. The
6
+ contract is that each pair is BYTE-IDENTICAL, and nothing checked it — so a fix applied to
7
+ one shell and forgotten in the other is a gate that behaves differently on Windows, in
8
+ silence. Three of the ops with no twin at all are on the forward chain (resolve-hooks, with
9
+ 17 call sites across the nine workflows, and checklist-read / checklist-apply, which produce
10
+ analyze-design's and analyze-slice's LIFT-FAILURE findings), and every one of them was read
11
+ in bash when the workflows were reviewed.
12
+
13
+ WHAT IT CHECKS
14
+ 1. Every bash payload has a byte-identical PowerShell twin.
15
+ 2. Every PowerShell payload has a byte-identical bash twin.
16
+ Payloads with no twin are listed, not counted against a hard-coded number: the split is
17
+ whatever the files say today, and pinning it would need editing on every legitimate change.
18
+
19
+ DUAL-AUTHORED BY DECISION. The ops in ACCEPTED_BASH_ONLY are bash-only on purpose (their
20
+ PowerShell path is written natively rather than shared) and are reported separately, so the
21
+ exit code reacts only to a NEW divergence. Moving one out of that list means it gained a
22
+ shared payload; adding one means the decision was taken to dual-author it — and either way
23
+ the reviewer has to say so in the same commit.
24
+
25
+ exit 0 every payload is paired, or unpaired only by accepted decision
26
+ exit 1 a new unpaired payload, or a pair that has drifted apart
27
+
28
+ Usage: aw-payload-parity.py [--root DIR] [--verbose]
29
+ """
30
+
31
+ import argparse
32
+ import hashlib
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ SH_REL = ".appweaver/scripts/bash/aw-spec-actions.sh"
38
+ PS_REL = ".appweaver/scripts/powershell/aw-spec-actions.ps1"
39
+
40
+ # Bash payloads with no shared PowerShell twin BY DECISION, not by oversight. Each is
41
+ # implemented natively on the PowerShell side and the two must be reviewed in pairs.
42
+ ACCEPTED_BASH_ONLY = {
43
+ "cmd_resolve_hooks",
44
+ "cmd_list_features",
45
+ "cmd_checklist_read",
46
+ "cmd_checklist_apply",
47
+ "cmd_compose_constitution",
48
+ "cmd_constitution_verify",
49
+ "cmd_component_verify",
50
+ "cmd_cr_archive",
51
+ "cmd_version_stamp",
52
+ }
53
+
54
+
55
+ def bash_payloads(text):
56
+ """[(owning function, payload, first line, last line)] in file order."""
57
+ out, owner, lines, i = [], "(top level)", text.split("\n"), 0
58
+ while i < len(lines):
59
+ m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\(\)\s*\{", lines[i])
60
+ if m:
61
+ owner = m.group(1)
62
+ if re.search(r"<<'PY'\s*$", lines[i]):
63
+ j, body = i + 1, []
64
+ while j < len(lines) and lines[j] != "PY":
65
+ body.append(lines[j])
66
+ j += 1
67
+ out.append((owner, "\n".join(body), i + 2, j))
68
+ i = j
69
+ i += 1
70
+ return out
71
+
72
+
73
+ def ps_payloads(text):
74
+ """[(variable name, payload, first line, last line)] in file order."""
75
+ out, lines, i = [], text.split("\n"), 0
76
+ while i < len(lines):
77
+ m = re.match(r"^\$(Py[A-Za-z0-9_]*)\s*=\s*@'\s*$", lines[i])
78
+ if m:
79
+ j, body = i + 1, []
80
+ while j < len(lines) and lines[j] != "'@":
81
+ body.append(lines[j])
82
+ j += 1
83
+ out.append((m.group(1), "\n".join(body), i + 2, j))
84
+ i = j
85
+ i += 1
86
+ return out
87
+
88
+
89
+ def digest(s):
90
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
91
+
92
+
93
+ def main():
94
+ ap = argparse.ArgumentParser(description="check bash/PowerShell python-payload parity")
95
+ ap.add_argument("--root", default="", help="kit root (default: this script's kit)")
96
+ ap.add_argument("--verbose", action="store_true", help="list every matched pair too")
97
+ args = ap.parse_args()
98
+
99
+ root = args.root or os.path.normpath(
100
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".."))
101
+ sh_path, ps_path = os.path.join(root, SH_REL), os.path.join(root, PS_REL)
102
+ for p in (sh_path, ps_path):
103
+ if not os.path.isfile(p):
104
+ print("aw-payload-parity: not found: %s" % p, file=sys.stderr)
105
+ return 2
106
+
107
+ sh = bash_payloads(open(sh_path, encoding="utf-8").read())
108
+ ps = ps_payloads(open(ps_path, encoding="utf-8").read())
109
+
110
+ ps_by_hash = {}
111
+ for name, body, a, b in ps:
112
+ ps_by_hash.setdefault(digest(body), []).append((name, a, b))
113
+ sh_by_hash = {}
114
+ for owner, body, a, b in sh:
115
+ sh_by_hash.setdefault(digest(body), []).append((owner, a, b))
116
+
117
+ paired, accepted, unpaired_sh, unpaired_ps = [], [], [], []
118
+ for owner, body, a, b in sh:
119
+ twin = ps_by_hash.get(digest(body))
120
+ if twin:
121
+ paired.append((owner, twin[0][0]))
122
+ elif owner in ACCEPTED_BASH_ONLY:
123
+ accepted.append((owner, a, b))
124
+ else:
125
+ unpaired_sh.append((owner, a, b))
126
+ for name, body, a, b in ps:
127
+ if not sh_by_hash.get(digest(body)):
128
+ unpaired_ps.append((name, a, b))
129
+
130
+ print("payload parity: %d bash, %d powershell, %d byte-identical pair(s)"
131
+ % (len(sh), len(ps), len(paired)))
132
+ if args.verbose:
133
+ for owner, name in paired:
134
+ print(" ok %-34s == $%s" % (owner, name))
135
+ if accepted:
136
+ print("\ndual-authored by decision (%d) — PowerShell implements these natively, so a "
137
+ "change to one must be mirrored by hand:" % len(accepted))
138
+ for owner, a, b in accepted:
139
+ print(" -- %-34s %s:%d-%d" % (owner, os.path.basename(sh_path), a, b))
140
+
141
+ failed = bool(unpaired_sh or unpaired_ps)
142
+ if unpaired_sh:
143
+ print("\nFAIL — bash payloads with no byte-identical PowerShell twin:")
144
+ for owner, a, b in unpaired_sh:
145
+ print(" !! %-34s %s:%d-%d" % (owner, os.path.basename(sh_path), a, b))
146
+ if unpaired_ps:
147
+ print("\nFAIL — PowerShell payloads with no byte-identical bash twin:")
148
+ for name, a, b in unpaired_ps:
149
+ print(" !! $%-33s %s:%d-%d" % (name, os.path.basename(ps_path), a, b))
150
+ if failed:
151
+ print("\nA payload changed in one shell and not the other, or a new op shipped to one "
152
+ "shell only. Copy the payload verbatim into its twin — they are byte-identical "
153
+ "by contract — or, if the op is deliberately dual-authored, add its function to "
154
+ "ACCEPTED_BASH_ONLY in this file and say why in the commit.")
155
+ return 1 if failed else 0
156
+
157
+
158
+ sys.exit(main())
@@ -97,12 +97,15 @@ Only the standard library is used. Usage: aw-xref.py <sub> [options]
97
97
  """
98
98
 
99
99
  import argparse
100
+ import contextlib
100
101
  import datetime
102
+ import errno
101
103
  import json
102
104
  import os
103
105
  import re
104
106
  import subprocess
105
107
  import sys
108
+ import time
106
109
 
107
110
  # ───────────────────────────── canonical shapes ────────────────────────────
108
111
  OVERVIEW_HEADING = "## Overview"
@@ -120,6 +123,17 @@ XREF_COMMENT = (
120
123
  "Each row written by the using slice's implement/adopt (reconcile-on-write). "
121
124
  "Reviewed at CR time. Do not hand-edit. -->"
122
125
  )
126
+ ANCHOR_HEADING = "## Requirement Anchors"
127
+ ANCHOR_HEADER = ["Requirement", "Slice", "Lane", "Artefacts", "Recorded"]
128
+ ANCHOR_COMMENT = (
129
+ "<!-- WHICH CODE REALISES WHICH REQUIREMENT — written by implement / implement-ui as "
130
+ "each story generates, from the ids that story exercises and the files that run wrote. "
131
+ "The greenfield counterpart of a brownfield source-map.md: without it every id on a "
132
+ "greenfield feature comes back unanchored, and the trace pass has to read every file to "
133
+ "find out what implements what. Reconcile-on-write, keyed by slice + lane, so "
134
+ "re-implementing a story replaces its rows rather than doubling them. Do not "
135
+ "hand-edit. -->"
136
+ )
123
137
  RESERVED = {"index.md", "log.md"}
124
138
 
125
139
  # A Cross-References "Using slice" cell that is safe to hand to a VSTest --filter (and a
@@ -249,10 +263,82 @@ def _read(path):
249
263
  return ""
250
264
 
251
265
 
266
+ # ──────────────────────────── write serialisation ──────────────────────────
267
+ # EVERY CONCEPT WRITE IS A READ-MODIFY-WRITE: read the whole file, splice one section,
268
+ # write it back truncating. None of them was serialised, and the wave model exists so that
269
+ # stories sharing a wave are implemented in parallel — so two implements, or implement and
270
+ # implement-ui (which share a feature's ONE concept), reading before either writes is the
271
+ # ordinary case, and the second write drops the first's rows with nothing to say so. Git
272
+ # catches that across branches and never inside a single working tree.
273
+ #
274
+ # The lock is named for the FILE — <path>.aw-lock, the same convention aw-spec-actions'
275
+ # log-append uses for interfaces.md and adr.md — so every writer of a file contends on one
276
+ # lock whichever process, op or shell it came from. Re-entrant within a process, so an
277
+ # outer read-modify-write can hold it while the _write inside re-acquires.
278
+ _LOCK_TIMEOUT = 30.0
279
+ _HELD = {}
280
+
281
+
282
+ @contextlib.contextmanager
283
+ def _lock(path):
284
+ key = os.path.abspath(path)
285
+ if _HELD.get(key):
286
+ _HELD[key] += 1
287
+ try:
288
+ yield
289
+ finally:
290
+ _HELD[key] -= 1
291
+ return
292
+ parent = os.path.dirname(key)
293
+ if parent:
294
+ os.makedirs(parent, exist_ok=True)
295
+ lp = key + ".aw-lock"
296
+ start = time.time()
297
+ while True:
298
+ try:
299
+ h = os.open(lp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
300
+ os.write(h, ("%d\n" % os.getpid()).encode("utf-8"))
301
+ os.close(h)
302
+ break
303
+ except OSError as exc:
304
+ if exc.errno != errno.EEXIST:
305
+ raise
306
+ try:
307
+ age = time.time() - os.path.getmtime(lp)
308
+ except OSError:
309
+ age = 0.0
310
+ # A lock older than the timeout belonged to a run that died holding it.
311
+ # Breaking it is right: the alternative is a concept nobody can write again.
312
+ if age > _LOCK_TIMEOUT:
313
+ try:
314
+ os.unlink(lp)
315
+ except OSError:
316
+ pass
317
+ continue
318
+ if time.time() - start > _LOCK_TIMEOUT:
319
+ raise RuntimeError(
320
+ "aw-xref: %s has been locked by another run for over %ds. If no other "
321
+ "/appweaver command is running, delete %s and re-run."
322
+ % (os.path.basename(key), int(_LOCK_TIMEOUT), lp))
323
+ time.sleep(0.05)
324
+ _HELD[key] = 1
325
+ try:
326
+ yield
327
+ finally:
328
+ _HELD.pop(key, None)
329
+ try:
330
+ os.unlink(lp)
331
+ except OSError:
332
+ pass
333
+
334
+
252
335
  def _write(path, text):
253
- os.makedirs(os.path.dirname(path), exist_ok=True)
254
- with open(path, "w", encoding="utf-8") as fh:
255
- fh.write(text)
336
+ parent = os.path.dirname(path)
337
+ if parent:
338
+ os.makedirs(parent, exist_ok=True)
339
+ with _lock(path):
340
+ with open(path, "w", encoding="utf-8") as fh:
341
+ fh.write(text)
256
342
 
257
343
 
258
344
  def _today(arg_date):
@@ -679,15 +765,41 @@ def _git_head_short(root):
679
765
 
680
766
 
681
767
  def _write_xref_rows(path, rows):
682
- text = _read(path)
683
- body = _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, rows)
684
- _write(path, _replace_section(text, XREF_HEADING, body))
768
+ # The read is INSIDE the lock: splicing a section into a file another process is
769
+ # rewriting is what loses rows, and taking the lock only for the write would still
770
+ # splice into a copy that had gone stale between the read and the acquisition.
771
+ with _lock(path):
772
+ text = _read(path)
773
+ body = _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, rows)
774
+ _write(path, _replace_section(text, XREF_HEADING, body))
685
775
 
686
776
 
687
777
  def _write_deps_rows(path, rows):
688
- text = _read(path)
689
- body = _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, rows)
690
- _write(path, _replace_section(text, DEPS_HEADING, body))
778
+ with _lock(path):
779
+ text = _read(path)
780
+ body = _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, rows)
781
+ _write(path, _replace_section(text, DEPS_HEADING, body))
782
+
783
+
784
+ def _read_anchor_rows(path):
785
+ return _table_data_rows(_section_text(_read(path), ANCHOR_HEADING))
786
+
787
+
788
+ def _write_anchor_rows(path, slice_id, lane, ids, artefacts, recorded):
789
+ """Requirement → artefact rows for ONE slice, reconciled on write. Every row this
790
+ slice+lane wrote before is dropped and its current ones re-added, so a story that
791
+ re-implements against a changed design replaces its claims instead of accumulating
792
+ them — the same rule the Cross-References rows follow, for the same reason."""
793
+ with _lock(path):
794
+ keep = [r for r in _read_anchor_rows(path)
795
+ if not (len(r) >= 3 and r[1].strip() == slice_id and r[2].strip() == lane)]
796
+ # Ordered by requirement so a person reading the section can find an id, and two
797
+ # runs writing the same rows produce the same file.
798
+ fresh = [[rid, slice_id, lane, artefacts, recorded] for rid in ids]
799
+ rows = sorted(keep + fresh, key=lambda r: (r[0], r[1] if len(r) > 1 else ""))
800
+ body = _render_section(ANCHOR_HEADING, ANCHOR_COMMENT, ANCHOR_HEADER, rows)
801
+ _write(path, _replace_section(_read(path), ANCHOR_HEADING, body))
802
+ return len(fresh)
691
803
 
692
804
 
693
805
  def _all_concepts(bundle_abs):
@@ -836,45 +948,67 @@ def cmd_concept_upsert(args):
836
948
  _ensure_dir_index(path, aggregate)
837
949
  else:
838
950
  # Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
839
- # which holds the Cross-References other slices wrote.
840
- fm, body = _read_fm(_read(path))
841
- fm.setdefault("type", "aggregate")
842
- fm["title"] = fm.get("title") or aggregate
843
- if source_files:
844
- if args.merge:
845
- # INCREMENTAL mode: {{files}} is only what THIS run wrote — one story's
846
- # slice, or a delta's changed files — so UNION with what the lane already
847
- # records, never replace. Replacing would drop every file the feature's
848
- # earlier stories generated the moment a second story implements.
849
- existing = fm.get(key) if isinstance(fm.get(key), list) else []
850
- merged = list(existing)
851
- for s in source_files:
852
- if s not in merged:
853
- merged.append(s)
854
- fm[key] = merged
855
- else:
856
- fm[key] = source_files
857
- # Both keys always present: an absent one reads as "this lane was never
858
- # implemented" to every consumer, which is a different claim from "empty".
859
- for k in ("source_files", "ui_source_files"):
860
- if k not in fm:
861
- fm[k] = []
862
- if args.description:
863
- fm["description"] = args.description
864
- elif "description" not in fm:
865
- fm["description"] = f"{aggregate} aggregate."
866
- fm.setdefault("origin", origin)
867
- rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
868
- else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
869
- _write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
951
+ # which holds the Cross-References other slices wrote. Read and write under one
952
+ # lock: --merge unions this run's files with what is already recorded, so two
953
+ # parallel stories reading before either writes both union against the same
954
+ # starting list and the second write loses the first's files.
955
+ with _lock(path):
956
+ fm, body = _read_fm(_read(path))
957
+ fm.setdefault("type", "aggregate")
958
+ fm["title"] = fm.get("title") or aggregate
959
+ if source_files:
960
+ if args.merge:
961
+ # INCREMENTAL mode: {{files}} is only what THIS run wrote — one story's
962
+ # slice, or a delta's changed files — so UNION with what the lane already
963
+ # records, never replace. Replacing would drop every file the feature's
964
+ # earlier stories generated the moment a second story implements.
965
+ existing = fm.get(key) if isinstance(fm.get(key), list) else []
966
+ merged = list(existing)
967
+ for s in source_files:
968
+ if s not in merged:
969
+ merged.append(s)
970
+ fm[key] = merged
971
+ else:
972
+ fm[key] = source_files
973
+ # Both keys always present: an absent one reads as "this lane was never
974
+ # implemented" to every consumer, which is a different claim from "empty".
975
+ for k in ("source_files", "ui_source_files"):
976
+ if k not in fm:
977
+ fm[k] = []
978
+ if args.description:
979
+ fm["description"] = args.description
980
+ elif "description" not in fm:
981
+ fm["description"] = f"{aggregate} aggregate."
982
+ fm.setdefault("origin", origin)
983
+ rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
984
+ else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
985
+ _write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
870
986
  _ensure_bundle(bundle_abs)
871
987
  _ensure_dir_index(path, aggregate)
872
988
 
989
+ # WHICH REQUIREMENT THIS SLICE'S FILES REALISE. The two halves have always been in this
990
+ # call and were thrown away: the ids the story exercises, and the files the run just
991
+ # generated. Persisted here so a greenfield feature has the anchor index only a
992
+ # brownfield source-map.md carried — without it every id comes back unanchored and the
993
+ # trace pass has to read the whole tree to find out what implements what.
994
+ anchor_ids = sorted({i for i in re.findall(r'\b[A-Z]{2}-\d+', args.ids or "")})
995
+ anchors_written = 0
996
+ if anchor_ids:
997
+ slice_id = (args.slice_id or "").strip() or "(unnamed slice)"
998
+ # The cell is table content: a path holding a pipe would silently split it.
999
+ cell = ", ".join(s.replace("|", r"\|") for s in source_files) or "(no files recorded)"
1000
+ anchors_written = _write_anchor_rows(path, slice_id, lane, anchor_ids, cell,
1001
+ _today(args.date))
1002
+
873
1003
  print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
874
- f"({len(source_files)} {lane} source file(s) into {key}) — {_rel(path, root)}",
1004
+ f"({len(source_files)} {lane} source file(s) into {key}"
1005
+ + (f", {anchors_written} requirement anchor(s)" if anchors_written else "")
1006
+ + f") — {_rel(path, root)}",
875
1007
  file=sys.stderr)
876
1008
  _emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
877
- "lane": lane, "key": key, "source_file_count": len(source_files), "root": root})
1009
+ "lane": lane, "key": key, "source_file_count": len(source_files),
1010
+ "anchor_ids": anchor_ids, "anchor_ids_csv": ", ".join(anchor_ids),
1011
+ "anchors_written": anchors_written, "root": root})
878
1012
  return 0
879
1013
 
880
1014
 
@@ -1039,6 +1173,24 @@ def cmd_concept_coverage(args):
1039
1173
  ids = [i for i in re.split(r'[,\s]+', (args.ids or "").strip()) if i]
1040
1174
  smap = ""
1041
1175
  anchors = {}
1176
+ # TWO SOURCES, ONE INDEX. A brownfield feature's anchors come from adopt's
1177
+ # source-map.md; a greenfield feature's come from the concept's own Requirement Anchors
1178
+ # rows, which implement / implement-ui write as each story generates. Reading only the
1179
+ # first meant every id on a greenfield feature came back "not anchored to any artefact"
1180
+ # — advice to design it as new work, on a requirement that already had code.
1181
+ if path:
1182
+ for r in _read_anchor_rows(path):
1183
+ if len(r) < 4:
1184
+ continue
1185
+ rid = r[0].strip()
1186
+ if not re.match(r'^[A-Z]{2}-\d+$', rid):
1187
+ continue
1188
+ for art in (a.strip().replace(r"\|", "|") for a in r[3].split(",")):
1189
+ if art and art != "(no files recorded)":
1190
+ anchors.setdefault(rid, [])
1191
+ if art not in anchors[rid]:
1192
+ anchors[rid].append(art)
1193
+ concept_anchored = bool(anchors)
1042
1194
  if args.feature_dir:
1043
1195
  cand = os.path.join(args.feature_dir, "source-map.md")
1044
1196
  if os.path.isfile(cand):
@@ -1110,13 +1262,15 @@ def cmd_concept_coverage(args):
1110
1262
  % (", ".join(users) or "nothing recorded"))
1111
1263
  if ids:
1112
1264
  lines.append("")
1113
- if not smap:
1114
- lines.append("REQUIREMENT ANCHORS: none available — this feature has no source-map.md, "
1115
- "so no id can be traced to a file. Treat the files above as the whole of "
1116
- "what exists and read them; do NOT read an id's absence here as proof "
1117
- "that no code implements it.")
1265
+ if not smap and not concept_anchored:
1266
+ lines.append("REQUIREMENT ANCHORS: none available — this feature has no source-map.md "
1267
+ "and no slice has recorded one in the concept, so no id can be traced to "
1268
+ "a file. Treat the files above as the whole of what exists and read them; "
1269
+ "do NOT read an id's absence here as proof that no code implements it.")
1118
1270
  else:
1119
- lines.append("REQUIREMENT ANCHORS (%s):" % smap)
1271
+ src = " + ".join(filter(None, [smap, "the concept's Requirement Anchors"
1272
+ if concept_anchored else ""]))
1273
+ lines.append("REQUIREMENT ANCHORS (%s):" % src)
1120
1274
  for i in anchored:
1121
1275
  lines.append(" %-10s %s" % (i, ", ".join(anchors[i][:6])
1122
1276
  + (" …" if len(anchors[i]) > 6 else "")))
@@ -1490,6 +1644,9 @@ def main(argv):
1490
1644
  c.add_argument("--source-files", default="")
1491
1645
  c.add_argument("--merge", action="store_true", help="union the lane's file list with existing (one story's slice, a CR delta) instead of replacing")
1492
1646
  c.add_argument("--lane", default="backend", help="backend -> source_files, frontend -> ui_source_files")
1647
+ c.add_argument("--ids", default="", help="requirement ids this slice realises; records a Requirement Anchors row per id against the files just written")
1648
+ c.add_argument("--slice", dest="slice_id", default="", help="the story id the anchor rows are keyed by (with --ids)")
1649
+ c.add_argument("--date", default="")
1493
1650
  c.add_argument("--title", default="")
1494
1651
  c.add_argument("--description", default="")
1495
1652
  c.add_argument("--origin", default="greenfield")
@@ -4,10 +4,13 @@ type: functional
4
4
  status: open
5
5
  title: [one line — the outcome this story delivers]
6
6
  exercises: []
7
+ constraints: []
7
8
  spec_version: [vN.N]
8
9
  depends_on: []
9
10
  wave: 1
10
11
  follows:
12
+ designed_lanes: []
13
+ dropped_lanes: []
11
14
  work_item:
12
15
  created: [DATE]
13
16
  ---
@@ -23,7 +26,8 @@ created: [DATE]
23
26
 
24
27
  A story is a slice of work over a spec that is already written. It is NEVER a proposal
25
28
  to change the spec — nothing here merges back into spec.md. When a story genuinely
26
- needs the contract to change, update-spec folds it back through specify's
29
+ needs the contract to change, sync-spec (⌛ not built yet — the hand route is
30
+ .appweaver/SPEC-EDIT-PROTOCOL.md) folds it back through specify's
27
31
  checklist -> clarify -> validate loop, and the spec's Change History gains a row.
28
32
 
29
33
  ONE STORY, ONE FEATURE. A story whose design touches a second feature is failed by
@@ -33,7 +37,7 @@ created: [DATE]
33
37
  directory scan rather than a markdown parse.
34
38
 
35
39
  id Minted by the kit: the feature name plus the next free number in
36
- stories/. user-story refuses to write over an existing file, so a
40
+ stories/. The writer refuses to overwrite an existing file, so a
37
41
  collision is an add/add git conflict, never a silent overwrite.
38
42
  change-story-id corrects it while it is still legal — see below.
39
43
 
@@ -57,9 +61,19 @@ created: [DATE]
57
61
  id. A requirement is DELIVERED when it has NO OPEN STORIES — not when a
58
62
  particular story closed, because a follow-on may carry the remainder.
59
63
 
64
+ constraints The spec-level bounds this slice must satisfy: the CN-/DC-/IC-/UX- ids,
65
+ lifted from spec.md by the kit and never typed by hand. Spec constraints
66
+ are declared aggregate-wide, so the feature's WHOLE set binds every slice
67
+ of it and there is nothing to choose. This field is the gate surface —
68
+ implement, implement-ui and analyze-slice read it — and the "## Constraints"
69
+ section below is where each id gets the line saying what THIS slice does
70
+ about it. Stamped by sync-stories; a story written before this field
71
+ existed has no key at all, and its readers fall back to the section.
72
+
60
73
  spec_version The spec version this story last synced at. A row above this version
61
- naming an id in exercises means the story is stale; check-stories
62
- reports it and sync-stories acts on it. Restamped on every sync.
74
+ naming an id in exercises OR constraints means the story is stale;
75
+ check-stories reports it and sync-stories acts on it. Restamped on
76
+ every sync.
63
77
 
64
78
  depends_on Story ids that must land first, e.g. [Payment-004]. About CODE ordering,
65
79
  not spec: implement warns on an open dependency, which is where calling
@@ -83,6 +97,16 @@ created: [DATE]
83
97
  its own dependencies are done, whatever else shares its wave. A story
84
98
  raised by hand floors at 1 — nothing is KNOWN to come first.
85
99
 
100
+ designed_lanes Which lanes story-plan actually designed, e.g. [backend, frontend].
101
+ dropped_lanes Which lanes it DROPPED, and why the pair exists: a story with no UI
102
+ design either has no user-facing surface or had its UI lane dropped when
103
+ it was designed — an undeclared frontend lane, or a frontend-plan.md
104
+ missing or behind the spec — and one field cannot say which. Written by
105
+ story-plan (never by sync-stories: stories are not designed there);
106
+ designed accumulates across passes, dropped holds what has been dropped
107
+ and not since designed. Both absent on a story that predates them, which
108
+ implement-ui reports as exactly that rather than guessing.
109
+
86
110
  follows On a follow-on only: the story id this one carries the remainder of.
87
111
  When the spec moves under a story that already has a story plan,
88
112
  sync-stories leaves that story untouched and mints a follow-on
@@ -126,10 +150,17 @@ As a [role], I want [capability], so that [outcome].
126
150
  a standard the product does not hold, and they are the half most often lost: they
127
151
  reach no UI phase except through the story.
128
152
 
153
+ THE IDS ARE STAMPED FOR YOU into `constraints:` above — the kit lifts every one the
154
+ spec declares, because spec constraints bind the aggregate rather than a single
155
+ operation. This section is where each gets its LINE. Write one per id saying what
156
+ this slice has to do about it, and where one genuinely does not apply, say so in its
157
+ line rather than dropping it: an omission used to be indistinguishable from a bound
158
+ nobody noticed, which is how a slice gets built without one.
159
+
129
160
  These are NOT in `exercises:`. That field is FR-/BR- only, because it is the
130
161
  delivery relation — what this story DELIVERS — and a constraint is not delivered,
131
162
  it is met. story-plan carries these ids into its design pass; analyze-slice
132
- traces it. Write "None" for a class that does not touch this story. -->
163
+ traces it. -->
133
164
 
134
165
  - **[UX-00n]** — [what this slice must satisfy of it]
135
166
 
@@ -144,4 +175,4 @@ As a [role], I want [capability], so that [outcome].
144
175
  ## Notes
145
176
 
146
177
  <!-- Optional: sequencing, risks, what is deliberately out of this slice. Anything that
147
- belongs to the CONTRACT belongs in spec.md via update-spec, not here. -->
178
+ belongs to the CONTRACT belongs in spec.md via sync-spec, not here. -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aw-spec-cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "AppWeaver Spec-Driven Development CLI - scaffolds projects with vertical slices, DDD, CQRS, and agent skills",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",