aw-spec-cli 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +18 -18
  2. package/assets/.appweaver/ORCH-DIALECT.md +1 -3
  3. package/assets/.appweaver/RULE-PARTITION.md +29 -13
  4. package/assets/.appweaver/STACK-PROFILES.md +10 -9
  5. package/assets/.appweaver/orchestrator/.state/smoke.state.json +32 -0
  6. package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +4 -1
  7. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +251 -0
  8. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +104 -35
  9. package/assets/.appweaver/orchestrator/orchestrator.py +5 -4
  10. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +94 -36
  11. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +1 -1
  12. package/assets/.appweaver/orchestrator/rev-templates/domain-model-template.skeleton.md +115 -0
  13. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +2 -9
  14. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +16 -40
  15. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +38 -17
  16. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +8 -8
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +10 -11
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +673 -0
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +535 -0
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +643 -0
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +78 -626
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +4 -4
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +7 -7
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +16 -14
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +2 -2
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +243 -144
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +882 -464
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +4 -4
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +32 -91
  30. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
  31. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +832 -477
  32. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +2 -2
  33. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-bounded-context.orch.yaml +132 -0
  34. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +38 -5
  35. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +3 -3
  36. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +271 -892
  37. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +1096 -2911
  38. package/assets/.appweaver/scripts/bash/aw-xref.sh +1 -2
  39. package/assets/.appweaver/scripts/bash/common.sh +47 -0
  40. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +936 -2766
  41. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +1 -2
  42. package/assets/.appweaver/scripts/python/aw-payload-parity.py +174 -0
  43. package/assets/.appweaver/scripts/python/aw-stack.py +6 -2
  44. package/assets/.appweaver/scripts/python/aw-xref.py +185 -292
  45. package/assets/.appweaver/templates/constitution-ui-template.md +1 -1
  46. package/assets/.appweaver/templates/delta-design-template.md +120 -30
  47. package/assets/.appweaver/templates/delta-domain-model-template.md +81 -0
  48. package/assets/.appweaver/templates/delta-spec-template.md +43 -17
  49. package/assets/.appweaver/templates/design-template.md +68 -98
  50. package/assets/.appweaver/templates/domain-model-template.md +130 -0
  51. package/assets/.appweaver/templates/okf-concept-template.md +2 -2
  52. package/assets/.appweaver/templates/source-map-template.md +3 -3
  53. package/assets/.appweaver/templates/spec-template.md +100 -122
  54. package/assets/.appweaver/templates/ui-delta-design-template.md +159 -0
  55. package/assets/.appweaver/templates/ui-design-template.md +46 -75
  56. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +7 -18
  57. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +34 -39
  58. package/assets/guardrails/requirement-spec.txt +2 -2
  59. package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
  60. package/package.json +1 -1
  61. package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +0 -105
  62. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +0 -251
  63. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +0 -749
  64. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +0 -253
  65. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +0 -540
  66. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +0 -597
  67. package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +0 -945
  68. package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +0 -1040
  69. package/assets/.appweaver/templates/story-template.md +0 -147
@@ -44,78 +44,11 @@
44
44
  # (the FILE signals for "does this lane exist"; resolve-tech carries the rule signal)
45
45
  # list-features [--preselect <name>] [--bundle <dir>] -> {features[], names[], count, select_options[], sentinel} (specs/ ∪ OKF aggregates)
46
46
  # set-feature --feature <name> -> {feature, feature_dir, spec_file, existed, created, spec_exists}
47
- # version-read [--feature-dir <path>] -> {feature, version, active_change, exists}
48
- # version-init [--feature-dir <path>] [--bootstrap]
49
- # -> {feature, version, active_change, created, bootstrap_pending}
50
- # (create active_version.json v1.0; --bootstrap marks a first draft in progress)
51
- # spec-check [--feature-dir <path>] -> {ok, state, version, doc_version, body_changed, spec_sha256, error}
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)
61
- # ledger-append [--feature-dir <path>] --ids <ids> [--summary <text>]
62
- # -> {ok, version, previous, row, spec_sha256}
63
- # (bump, append the Change History row, stamp the **Version** header and
64
- # re-hash — ONE call, so a row can never exist without its hash)
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)
91
- # -> {covered_csv, affected[], affected_csv, story_csv,
92
- # in_flight_csv, any, any_in_flight, story_count}
93
- # (which requirements have OPEN stories against them; no --ids = all of them.
94
- # Read-only frontmatter scan — the backlog's view of what is being built)
95
- # spec-coverage [--feature-dir <path>] -> {written, requirements, stories, uncovered_csv, delivered_csv}
96
- # (regenerate the spec's AW:COVERAGE block — every FR-/BR- and the stories
97
- # delivering it. Fenced out of the hash and the diff, so writing it is not
98
- # a spec edit. Called by specify, user-story and sync-stories alike)
99
- # spec-preserve --disturbing <csv> --approved <csv> -> {preserve_csv, preserve_count, approved_csv}
100
- # (the complement: which disturbed requirements were NOT approved for change)
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)
47
+ # version-read [--feature-dir <path>] -> {feature, version, active_change, exists, bootstrap_pending}
48
+ # version-init [--feature-dir <path>] [--bootstrap] -> {…, created, bootstrap_pending} (create active_version.json v1.0; --bootstrap marks a FIRST draft in progress)
49
+ # version-bootstrap-done [--feature-dir <path>] -> {…, cleared} (the ONLY clearer of the --bootstrap marker; call it when the first draft is finished)
50
+ # feature-paths -> {feature, feature_dir, spec_file, backend_plan, frontend_plan, *_exists,
51
+ # design_file/ui_design_file (aliases of the two plans), spec_exists, bootstrap_pending, needs_bootstrap}
119
52
  # checklist-read --file <path> -> {exists, total, checked, items[]}
120
53
  # checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
121
54
  # detect-project -> {scaffolded, markers[]}
@@ -133,8 +66,12 @@
133
66
  # -> {ok, exists, issues, placeholders, placeholder_check, version_body, version_report, version_match, bad_dates}
134
67
  # agent-context-update [--plan <path>] -> {ok, ran, message}
135
68
  #
136
- # design-scaffold --feature-dir <path> [--template <name>]
137
- # -> {feature_dir, design_doc, created, override_active, template, template_source}
69
+ # plan-scaffold --feature-dir <path> [--lane backend|frontend|service|ui] [--template <name>]
70
+ # (alias: design-scaffold) -> {feature_dir, plan_doc, design_doc, lane, created, migrated, override_active, template, template_source}
71
+ # (backend|frontend write backend-plan.md / frontend-plan.md and MIGRATE a
72
+ # legacy design.md / design.ui.md by renaming it; service|ui keep the legacy names)
73
+ # delta-plan-scaffold --cr-id <id> [--lane backend|frontend] [--feature-dir <path>] [--template <name>]
74
+ # -> {cr_id, cr_dir, lane, delta_plan, created, migrated, override_active, template}
138
75
  # (design-extract / design-reconcile / design-paths are RETIRED — the path-tagged
139
76
  # four-document model is gone. "Which files is this slice made of" is now an OKF
140
77
  # query: aw-xref concept-paths / concept-upsert --merge.)
@@ -142,8 +79,20 @@
142
79
  # test-run [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
143
80
  # run-migrate [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
144
81
  # deploy-dispatch -> {ok, configured, ran, cmd, log_tail}
145
- # cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>]
146
- # -> {cr_dir, delta_spec, delta_design, created}
82
+ # cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>] [--wave <n>]
83
+ # -> {cr_id, cr_dir, delta_spec, delta_model, wave, open_count, created}
84
+ # cr-list [--feature-dir <path>] [--preselect <cr-id|main|new>] [--include-main] [--include-new]
85
+ # -> {changes[], ids[], count, select_options[], matched_label, main_label,
86
+ # new_label, only_id, active_change}
87
+ # cr-paths --cr-id <id> [--feature-dir <path>]
88
+ # -> {cr_dir, delta_spec, delta_model, delta_backend_plan, delta_frontend_plan,
89
+ # wave, predecessor_files[], predecessor_specs[], predecessor_docs[],
90
+ # sibling_files[], domain_model}
91
+ # cr-overlap --cr-id <id> [--feature-dir <path>]
92
+ # -> {aggregates[], overlaps[], overlap_count, predecessor_overlaps,
93
+ # sibling_overlaps, successor_overlaps, overlap_report}
94
+ # cr-activate --cr-id <id> [--feature-dir <path>] -> {active_change, activated, open_changes[]}
95
+ # cr-wave --cr-id <id> --wave <n> [--feature-dir <path>] -> {cr_id, wave, stamped}
147
96
  # cr-archive --cr-id <id> [--feature-dir <path>]
148
97
  # -> {ok, archived_dir, version, previous}
149
98
  #
@@ -231,8 +180,7 @@ cmd_resolve_domain() {
231
180
  # stack_mismatch when it is not a stack this repo declares, because a map pinned to the
232
181
  # wrong corpus is how a Java project ends up being handed C# rules.
233
182
  cmd_resolve_tech() {
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.
183
+ local command_name="aw-spec-design"
236
184
  while [ $# -gt 0 ]; do
237
185
  case "$1" in
238
186
  --command) command_name="$2"; shift 2 ;;
@@ -560,7 +508,7 @@ cmd_create_checklist() {
560
508
  cat > "$checklist_file" <<EOF
561
509
  # Delta-Spec Quality Checklist: $feature_name — $cr_id
562
510
 
563
- **Purpose**: Validate the CHANGE's delta-spec before proceeding to design
511
+ **Purpose**: Validate the CHANGE's delta-spec before proceeding to plan
564
512
  **Created**: $today
565
513
  **Change**: [delta-spec.md](../delta-spec.md)
566
514
 
@@ -604,14 +552,14 @@ cmd_create_checklist() {
604
552
 
605
553
  - This checklist grades the DELTA DOCUMENT only. Accuracy against the master spec
606
554
  (id continuity, conflicts with existing requirements) is checked downstream by
607
- /appweaver analyze-delta-design.
608
- - Items marked incomplete require delta-spec updates before /appweaver design
555
+ /appweaver analyze-plan-delta.
556
+ - Items marked incomplete require delta-spec updates before /appweaver plan
609
557
  EOF
610
558
  else
611
559
  cat > "$checklist_file" <<EOF
612
560
  # Specification Quality Checklist: $feature_name
613
561
 
614
- **Purpose**: Validate specification completeness and quality before proceeding to design
562
+ **Purpose**: Validate specification completeness and quality before proceeding to plan
615
563
  **Created**: $today
616
564
  **Feature**: [spec.md](../spec.md)
617
565
 
@@ -628,7 +576,7 @@ EOF
628
576
  - [ ] Requirements are testable and unambiguous
629
577
  - [ ] Success criteria are measurable
630
578
  - [ ] Success criteria are technology-agnostic (no implementation details)
631
- - [ ] Every requirement carries a stable id
579
+ - [ ] All acceptance scenarios are defined
632
580
  - [ ] Edge cases are identified
633
581
  - [ ] Scope is clearly bounded
634
582
  - [ ] Dependencies and assumptions identified
@@ -649,14 +597,14 @@ EOF
649
597
 
650
598
  ## Feature Readiness
651
599
 
652
- - [ ] Every functional requirement is stated in EARS form, which is its acceptance criterion
653
- - [ ] The requirements cover the aggregate's primary flows, so a story can be sliced from them without inventing behaviour
600
+ - [ ] All functional requirements have clear acceptance criteria
601
+ - [ ] User scenarios cover primary flows
654
602
  - [ ] Feature meets measurable outcomes defined in Success Criteria
655
603
  - [ ] No implementation details leak into specification
656
604
 
657
605
  ## Notes
658
606
 
659
- - Items marked incomplete require spec updates before /appweaver design
607
+ - Items marked incomplete require spec updates before /appweaver plan
660
608
  EOF
661
609
  fi
662
610
 
@@ -876,6 +824,345 @@ cmd_lane_probe() {
876
824
  _aw_stack_py probe "${fwd[@]}"
877
825
  }
878
826
 
827
+ # ─────────────────────────────── list-contexts ─────────────────────────────
828
+ # Enumerate the BOUNDED CONTEXTS a user can pick. A bounded context is a directory
829
+ # under specs/ that holds features rather than being one — so the two are told apart
830
+ # by the only reliable evidence on disk: a directory carrying its OWN spec.md is a
831
+ # feature, one that does not is a context. No marker file, nothing to keep in sync.
832
+ #
833
+ # A pre-context repo has its features flat under specs/, and every one of them
834
+ # therefore reads as a feature, not a context — correct, and reported as
835
+ # `legacy_features` so the caller can say so out loud instead of showing an empty
836
+ # list and looking broken.
837
+ cmd_list_contexts() {
838
+ local preselect=""
839
+ while [ $# -gt 0 ]; do
840
+ case "$1" in
841
+ --preselect) preselect="$2"; shift 2 ;;
842
+ --help|-h) echo "list-contexts [--preselect <name>] -> {contexts[], names[], count, select_options[], sentinel, legacy_features[]}"; return 0 ;;
843
+ *) echo "list-contexts: unknown arg '$1'" >&2; return 1 ;;
844
+ esac
845
+ done
846
+ local repo_root; repo_root=$(get_repo_root)
847
+ local active_rel; active_rel=$(read_context_json_context_directory "$repo_root")
848
+ [ -n "$active_rel" ] && [ ! -d "$repo_root/$active_rel" ] && active_rel=""
849
+ AW_SPECS="$repo_root/specs" AW_PRESELECT="$preselect" AW_ACTIVE="$active_rel" python3 - <<'PY'
850
+ import json, os
851
+ base = os.environ["AW_SPECS"]
852
+ pre = os.environ.get("AW_PRESELECT", "").strip()
853
+ active_rel = os.environ.get("AW_ACTIVE", "").strip()
854
+ active = os.path.basename(active_rel.rstrip("/")) if active_rel else ""
855
+ sentinel = "+ Create a new bounded context"
856
+ contexts, names, legacy = [], [], []
857
+ seen = set()
858
+ if os.path.isdir(base):
859
+ for name in sorted(os.listdir(base)):
860
+ d = os.path.join(base, name)
861
+ if not os.path.isdir(d):
862
+ continue
863
+ # Its own spec.md => this IS a feature, from the pre-context flat layout.
864
+ if os.path.isfile(os.path.join(d, "spec.md")):
865
+ legacy.append(name)
866
+ continue
867
+ # EVERY subdirectory is a feature of this context, whether or not specify has
868
+ # run yet — select-feature creates the directory first and spec.md arrives
869
+ # later, so counting only specified ones would report a feature the user just
870
+ # created as absent, and disagree with list-features about what exists.
871
+ feats = sorted(n for n in os.listdir(d) if os.path.isdir(os.path.join(d, n)))
872
+ specified = [n for n in feats if os.path.isfile(os.path.join(d, n, "spec.md"))]
873
+ names.append(name); seen.add(name.lower())
874
+ contexts.append({
875
+ "name": name,
876
+ "dir": "specs/" + name,
877
+ "features": feats,
878
+ "feature_count": len(feats),
879
+ "specified_count": len(specified),
880
+ "has_domain_model": os.path.isfile(os.path.join(d, "domain-model.md")),
881
+ })
882
+ opts = list(names)
883
+ # Inject a not-yet-existing --preselect so preselect_from can create-or-select in
884
+ # one round trip, exactly as list-features does for the direct-argument fast path.
885
+ if pre and pre.lower() not in seen:
886
+ opts.append(pre)
887
+ opts.append(sentinel)
888
+ # scope_ok answers ONE question for select-feature: is it safe to resolve a feature
889
+ # directory right now? Two states are safe — a context is active (features nest inside
890
+ # it), or the repo has no contexts at all (the flat pre-context layout). The unsafe
891
+ # state is contexts existing with none selected: the feature would silently land at
892
+ # specs/<Feature>, outside every context, which is the quiet wrongness the lane model
893
+ # exists to prevent. The engine has no compound conditions, so the AND is computed here.
894
+ scope_ok = bool(active) or len(names) == 0
895
+ scope_message = "" if scope_ok else (
896
+ "%d bounded context(s) exist (%s) but none is selected. Run "
897
+ "/appweaver select-bounded-context first — without it a new feature is created at "
898
+ "specs/<Feature>, outside every context and with no domain model to share."
899
+ % (len(names), ", ".join(names)))
900
+ print(json.dumps({"contexts": contexts, "names": names, "count": len(names),
901
+ "select_options": opts, "sentinel": sentinel,
902
+ "legacy_features": legacy, "legacy_count": len(legacy),
903
+ "active": active, "active_dir": active_rel,
904
+ # The specs/ subtree features resolve in — the active context, or
905
+ # plain "specs" on a flat repo. Always populated, so a caller can
906
+ # name the location without branching on whether a context is set.
907
+ "feature_base": active_rel or "specs",
908
+ "scope_ok": scope_ok, "scope_message": scope_message}))
909
+ PY
910
+ }
911
+
912
+ # ─────────────────────────────── set-context ───────────────────────────────
913
+ # Create-or-select a bounded context under specs/ and persist it as the active one
914
+ # (.appweaver/context.json). Idempotent: selecting an existing context re-points the
915
+ # file; a new one is created. The context directory is created but domain-model.md is
916
+ # NOT — that document is authored by specify, which is the only writer of the model,
917
+ # and an empty placeholder would read as "the model exists" to every later gate.
918
+ cmd_set_context() {
919
+ local raw=""
920
+ while [ $# -gt 0 ]; do
921
+ case "$1" in
922
+ --context) raw="$2"; shift 2 ;;
923
+ --help|-h) echo "set-context --context <name> (create-or-select the bounded context under specs/)"; return 0 ;;
924
+ *) echo "set-context: unknown arg '$1'" >&2; return 1 ;;
925
+ esac
926
+ done
927
+ [ -n "$raw" ] || { echo "set-context: --context is required" >&2; return 1; }
928
+
929
+ # Same PascalCase normalization as set-feature: these are sibling identifiers in
930
+ # one path, so "order management" and "Order Management" cannot produce two dirs.
931
+ local cleaned; cleaned=$(echo "$raw" | sed -E 's/[^A-Za-z0-9]+/ /g; s/^ +| +$//g')
932
+ local context; context=$(_pascal "$cleaned")
933
+ if ! [[ "$context" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
934
+ echo "set-context: '$raw' is not a valid bounded context name (need a letter-led alphanumeric name, e.g. Sales)" >&2
935
+ return 1
936
+ fi
937
+
938
+ local repo_root; repo_root=$(get_repo_root)
939
+ local context_dir_rel="specs/$context"
940
+ local context_dir="$repo_root/$context_dir_rel"
941
+ # Refuse to shadow a flat feature: specs/Order/spec.md means Order is a feature,
942
+ # and turning it into a context would put specs/Order/<Feature>/ underneath a
943
+ # spec.md that already claims that directory.
944
+ if [ -f "$context_dir/spec.md" ]; then
945
+ echo "set-context: refusing — 'specs/$context' is a feature (it has its own spec.md), not a bounded context. Pick another name, or move that feature under a context first." >&2
946
+ return 1
947
+ fi
948
+ local existed=false; [ -d "$context_dir" ] && existed=true
949
+ mkdir -p "$context_dir"
950
+
951
+ mkdir -p "$repo_root/.appweaver"
952
+ local cj="$repo_root/.appweaver/context.json"
953
+ if command -v jq >/dev/null 2>&1; then
954
+ jq -cn --arg c "$context" --arg d "$context_dir_rel" '{context:$c,context_directory:$d}' > "$cj"
955
+ else
956
+ printf '{"context":"%s","context_directory":"%s"}\n' \
957
+ "$(json_escape "$context")" "$(json_escape "$context_dir_rel")" > "$cj"
958
+ fi
959
+
960
+ local created=true; [ "$existed" = true ] && created=false
961
+ local dm_exists=false; [ -f "$context_dir/domain-model.md" ] && dm_exists=true
962
+ printf '{"context":"%s","context_dir":"%s","domain_model":"%s","existed":%s,"created":%s,"domain_model_exists":%s,"written":true}\n' \
963
+ "$(json_escape "$context")" "$(json_escape "$context_dir_rel")" \
964
+ "$(json_escape "$context_dir_rel/domain-model.md")" "$existed" "$created" "$dm_exists"
965
+ }
966
+
967
+ # ══════════════════════ domain model — context-level helpers ════════════════
968
+ # The bounded context that owns a feature's domain model. specs/<Ctx>/<Feat> puts the
969
+ # model one level up at specs/<Ctx>/; a flat specs/<Feat> falls back to specs/ as the
970
+ # implicit single context, so a pre-context repo still resolves exactly one model
971
+ # rather than none.
972
+ _context_dir_for_feature() { # $1 = abs feature dir, $2 = repo_root; prints ABS context dir
973
+ local fd="$1" repo_root="$2"
974
+ local parent; parent=$(dirname "$fd")
975
+ # A feature directly under specs/ has no context of its own -> specs/ IS the context.
976
+ if [ "$parent" = "$repo_root/specs" ] || [ "$parent" = "$repo_root" ]; then
977
+ printf '%s' "$repo_root/specs"
978
+ else
979
+ printf '%s' "$parent"
980
+ fi
981
+ }
982
+
983
+ # Four random hex characters, so two changes opened in the same second cannot collide
984
+ # on one id. Falls back through the sources a minimal container might lack.
985
+ _rand_suffix() {
986
+ if [ -r /dev/urandom ]; then
987
+ LC_ALL=C tr -dc 'a-f0-9' < /dev/urandom 2>/dev/null | head -c 4 && return 0
988
+ fi
989
+ printf '%04x' $(( (RANDOM << 8) ^ RANDOM ^ $$ ))
990
+ }
991
+
992
+ # Read one top-level field out of a compact JSON object on stdin. Used to pluck a
993
+ # single value from a helper's output without a second parse in every caller.
994
+ _json_field() { # $1 = key
995
+ AW_KEY="$1" python3 -c "
996
+ import json,os,sys
997
+ try: d=json.load(sys.stdin)
998
+ except Exception: d={}
999
+ v=d.get(os.environ['AW_KEY'],'')
1000
+ print('' if v is None else v)" 2>/dev/null
1001
+ }
1002
+
1003
+ # Set or insert a '**<Name>**: <value>' header line. Scripts own ids, dates, versions
1004
+ # and the wave; the model never writes them, so every one of those lands through here.
1005
+ # Idempotent: an existing line is replaced in place, a missing one is inserted after
1006
+ # the title so the header block stays together.
1007
+ _stamp_header() { # $1 = file, $2 = header name, $3 = value
1008
+ [ -f "$1" ] || return 0
1009
+ AW_F="$1" AW_NAME="$2" AW_VAL="$3" python3 - <<'PY'
1010
+ import os, re
1011
+ p = os.environ["AW_F"]; name = os.environ["AW_NAME"]; val = os.environ["AW_VAL"]
1012
+ try: text = open(p, encoding="utf-8").read()
1013
+ except Exception: raise SystemExit(0)
1014
+ line = "**%s**: %s" % (name, val)
1015
+ pat = re.compile(r"^\*\*%s\*\*:.*$" % re.escape(name), re.M)
1016
+ if pat.search(text):
1017
+ text = pat.sub(lambda _m: line, text, count=1)
1018
+ else:
1019
+ lines = text.split("\n")
1020
+ at = 0
1021
+ for i, l in enumerate(lines):
1022
+ if l.startswith("# "):
1023
+ at = i + 1
1024
+ break
1025
+ lines.insert(at, "")
1026
+ lines.insert(at + 1, line)
1027
+ text = "\n".join(lines)
1028
+ open(p, "w", encoding="utf-8").write(text)
1029
+ PY
1030
+ }
1031
+
1032
+ # The highest wave among the delta models currently open in a context, or 1 when none
1033
+ # is. This is the DEFAULT for a new delta: parallel with whatever is already in flight,
1034
+ # never implicitly sequenced behind it.
1035
+ _max_open_wave() { # $1 = abs context dir
1036
+ AW_CTX="$1" python3 - <<'PY'
1037
+ import os, re, glob
1038
+ ctx = os.environ["AW_CTX"]
1039
+ best = 0
1040
+ for f in glob.glob(os.path.join(ctx, "domain-model.*.delta.md")):
1041
+ try: t = open(f, encoding="utf-8").read()
1042
+ except Exception: continue
1043
+ m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", t, re.M)
1044
+ if m: best = max(best, int(m.group(1)))
1045
+ print(best if best >= 1 else 1)
1046
+ PY
1047
+ }
1048
+
1049
+ # The change id out of whatever the caller passed. A user_select hands back the whole
1050
+ # option LABEL ("CR-… · wave 2 · Add discount tiers"), and a change id never contains
1051
+ # whitespace — so the first token is the id whether the caller passed a bare id or a
1052
+ # label the user picked off a menu.
1053
+ _cr_id_token() { # $1 = raw --cr-id value
1054
+ printf '%s' "${1%%[[:space:]]*}"
1055
+ }
1056
+
1057
+ # One change's wave, read from the documents that carry it: the delta-spec first, then
1058
+ # the model delta. Both are stamped together, so either answers; 1 is the floor for a
1059
+ # change opened before waves existed.
1060
+ _wave_of() { # $1 = abs feature dir, $2 = cr id, $3 = repo_root
1061
+ local fd="$1" cr="$2" repo_root="$3" w=""
1062
+ local dspec="$fd/changes/$cr/delta-spec.md"
1063
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
1064
+ local dmodel="$ctx_dir/domain-model.$cr.delta.md"
1065
+ for f in "$dspec" "$dmodel"; do
1066
+ [ -f "$f" ] || continue
1067
+ w=$(sed -n -E 's/^\*\*Wave\*\*:[[:space:]]*([0-9]+).*$/\1/p' "$f" | head -1)
1068
+ [ -n "$w" ] && break
1069
+ done
1070
+ case "$w" in ''|*[!0-9]*) w=1 ;; esac
1071
+ [ "$w" -ge 1 ] 2>/dev/null || w=1
1072
+ printf '%s' "$w"
1073
+ }
1074
+
1075
+ # ─────────────────────────────── context-paths ─────────────────────────────
1076
+ # Everything specify needs to know about the domain model of the context it is working
1077
+ # in: where the master is, whether it exists yet, and EVERY open delta model beside it.
1078
+ #
1079
+ # The pending list is the point. specify authors against the master PLUS what other open
1080
+ # deltas have already proposed, so an overlap is designed around at authoring time rather
1081
+ # than discovered at merge time. pending_files is a flat path list for build_context's
1082
+ # `context_from:`.
1083
+ cmd_context_paths() {
1084
+ local feature_dir_arg=""
1085
+ while [ $# -gt 0 ]; do
1086
+ case "$1" in
1087
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
1088
+ --help|-h) echo "context-paths [--feature-dir <path>] -> {context, context_dir, domain_model, domain_model_exists, pending[], pending_files[], max_wave, next_wave}"; return 0 ;;
1089
+ *) echo "context-paths: unknown arg '$1'" >&2; return 1 ;;
1090
+ esac
1091
+ done
1092
+ local repo_root; repo_root=$(get_repo_root)
1093
+ local fd="$feature_dir_arg"
1094
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
1095
+ [ -n "$fd" ] || { echo "context-paths: no feature set and no --feature-dir" >&2; return 1; }
1096
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
1097
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
1098
+ AW_CTX="$ctx_dir" AW_ROOT="$repo_root" python3 - <<'PY'
1099
+ import json, os, re, glob
1100
+ ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
1101
+ def rel(p): return os.path.relpath(p, root)
1102
+ name = os.path.basename(ctx.rstrip("/"))
1103
+ # specs/ itself is the implicit single context of a pre-context repo — it has a model
1104
+ # but no context NAME, and saying so is what keeps the flat layout honest.
1105
+ context = "" if rel(ctx) == "specs" else name
1106
+ master = os.path.join(ctx, "domain-model.md")
1107
+ pending, files = [], []
1108
+ for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
1109
+ base = os.path.basename(f)
1110
+ cr = base[len("domain-model."):-len(".delta.md")]
1111
+ wave = 1
1112
+ try:
1113
+ m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", open(f, encoding="utf-8").read(), re.M)
1114
+ if m: wave = int(m.group(1))
1115
+ except Exception: pass
1116
+ pending.append({"cr_id": cr, "file": rel(f), "wave": wave})
1117
+ files.append(rel(f))
1118
+ waves = [p["wave"] for p in pending]
1119
+ print(json.dumps({
1120
+ "context": context,
1121
+ "context_dir": rel(ctx),
1122
+ "domain_model": rel(master),
1123
+ "domain_model_exists": os.path.isfile(master),
1124
+ "pending": pending,
1125
+ "pending_files": files,
1126
+ "pending_count": len(pending),
1127
+ "max_wave": max(waves) if waves else 0,
1128
+ # What a new delta opened right now would be stamped with: alongside whatever is
1129
+ # already in flight, never implicitly behind it.
1130
+ "next_wave": max(waves) if waves else 1,
1131
+ }))
1132
+ PY
1133
+ }
1134
+
1135
+ # ─────────────────────────────── model-scaffold ────────────────────────────
1136
+ # Copy the domain-model template into the context on the BOOTSTRAP path. Idempotent and
1137
+ # non-destructive: an existing model is never overwritten, because after the first
1138
+ # specify the model is amended by delta and merge, never re-scaffolded.
1139
+ cmd_model_scaffold() {
1140
+ local feature_dir_arg=""
1141
+ while [ $# -gt 0 ]; do
1142
+ case "$1" in
1143
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
1144
+ --help|-h) echo "model-scaffold [--feature-dir <path>]"; return 0 ;;
1145
+ *) echo "model-scaffold: unknown arg '$1'" >&2; return 1 ;;
1146
+ esac
1147
+ done
1148
+ local repo_root; repo_root=$(get_repo_root)
1149
+ local fd="$feature_dir_arg"
1150
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
1151
+ [ -n "$fd" ] || { echo "model-scaffold: no feature set and no --feature-dir" >&2; return 1; }
1152
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
1153
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
1154
+ mkdir -p "$ctx_dir"
1155
+ local model="$ctx_dir/domain-model.md" tpl="" created=false
1156
+ if [ ! -f "$model" ]; then
1157
+ tpl=$(resolve_template "domain-model-template" "$repo_root") || true
1158
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$model"; else touch "$model"; fi
1159
+ created=true
1160
+ fi
1161
+ printf '{"context_dir":"%s","domain_model":"%s","template":"%s","created":%s,"exists":true}\n' \
1162
+ "$(json_escape "${ctx_dir#$repo_root/}")" "$(json_escape "${model#$repo_root/}")" \
1163
+ "$(json_escape "$tpl")" "$created"
1164
+ }
1165
+
879
1166
  # ─────────────────────────────── list-features ─────────────────────────────
880
1167
  # Enumerate the aggregates a user can pick as the active feature. Two sources,
881
1168
  # MERGED (deduped by feature name, case-insensitive):
@@ -908,9 +1195,14 @@ cmd_list_features() {
908
1195
  if [ -d "$bundle" ] && [ -f "$invpy" ] && command -v python3 >/dev/null 2>&1; then
909
1196
  okf_json=$(python3 "$invpy" list-aggregates --bundle "$bundle" 2>/dev/null) || okf_json="{}"
910
1197
  fi
911
- AW_SPECS="$repo_root/specs" AW_PRESELECT="$preselect" AW_OKF="$okf_json" python3 - <<'PY'
1198
+ # Features are enumerated from the ACTIVE BOUNDED CONTEXT's directory when one is
1199
+ # set, and from specs/ itself when none is — so a context scopes the picker to its
1200
+ # own features and a pre-context repo lists exactly what it always did.
1201
+ local base_rel; base_rel=$(feature_base_rel "$repo_root")
1202
+ AW_SPECS="$repo_root/$base_rel" AW_BASE_REL="$base_rel" AW_PRESELECT="$preselect" AW_OKF="$okf_json" python3 - <<'PY'
912
1203
  import json, os, glob
913
1204
  base = os.environ["AW_SPECS"]
1205
+ base_rel = os.environ.get("AW_BASE_REL", "specs")
914
1206
  pre = os.environ.get("AW_PRESELECT", "").strip()
915
1207
  try:
916
1208
  okf = json.loads(os.environ.get("AW_OKF", "{}") or "{}")
@@ -919,11 +1211,28 @@ except Exception:
919
1211
  sentinel = "+ Create a new feature"
920
1212
  feats, names = [], []
921
1213
  seen = set() # case-insensitive dedup key across both sources
1214
+ def is_context(d):
1215
+ """A BOUNDED CONTEXT directory, not a feature: it holds features instead of
1216
+ being one. Only reachable with no context active — inside a context, a feature
1217
+ carries its own spec.md and never matches. Without this test a repo that has
1218
+ contexts but has not selected one lists them as pickable features, and picking
1219
+ one would scaffold a spec.md on top of a directory that already holds features."""
1220
+ if os.path.isfile(os.path.join(d, "spec.md")):
1221
+ return False
1222
+ if os.path.isfile(os.path.join(d, "domain-model.md")):
1223
+ return True
1224
+ try:
1225
+ return any(os.path.isfile(os.path.join(d, n, "spec.md")) for n in os.listdir(d))
1226
+ except OSError:
1227
+ return False
1228
+
922
1229
  if os.path.isdir(base):
923
1230
  for name in sorted(os.listdir(base)):
924
1231
  d = os.path.join(base, name)
925
1232
  if not os.path.isdir(d):
926
1233
  continue
1234
+ if is_context(d):
1235
+ continue
927
1236
  ver, active_change = "", ""
928
1237
  avf = os.path.join(d, "active_version.json")
929
1238
  if os.path.isfile(avf):
@@ -934,7 +1243,7 @@ if os.path.isdir(base):
934
1243
  names.append(name); seen.add(name.lower())
935
1244
  feats.append({
936
1245
  "name": name,
937
- "dir": "specs/" + name,
1246
+ "dir": base_rel + "/" + name,
938
1247
  "source": "spec",
939
1248
  "has_spec": os.path.isfile(os.path.join(d, "spec.md")),
940
1249
  # `design*.md`, NOT `design.*.md`: the second dot made this miss the single
@@ -953,7 +1262,7 @@ for agg in (okf.get("aggregates") or []):
953
1262
  names.append(nm); seen.add(nm.lower())
954
1263
  feats.append({
955
1264
  "name": nm,
956
- "dir": "specs/" + nm, # candidate slice (not yet created)
1265
+ "dir": base_rel + "/" + nm, # candidate slice (not yet created)
957
1266
  "source": "okf",
958
1267
  "has_spec": False,
959
1268
  "adopted": False,
@@ -998,17 +1307,32 @@ cmd_set_feature() {
998
1307
  fi
999
1308
 
1000
1309
  local repo_root; repo_root=$(get_repo_root)
1001
- local feature_dir_rel="specs/$feature"
1310
+ # The feature is created inside the ACTIVE BOUNDED CONTEXT when one is set, and
1311
+ # directly under specs/ when none is. feature.json stores the resulting path, so
1312
+ # every downstream caller reads a feature directory and never has to know which
1313
+ # layout produced it — the nesting is invisible past this point.
1314
+ local base_rel; base_rel=$(feature_base_rel "$repo_root")
1315
+ local feature_dir_rel="$base_rel/$feature"
1002
1316
  local feature_dir="$repo_root/$feature_dir_rel"
1317
+ # Refuse to scaffold a feature on top of a bounded context — the mirror of
1318
+ # set-context's refusal to shadow a feature. Reachable when a repo has contexts
1319
+ # but none is active, where the target resolves under a bare specs/.
1320
+ if [ ! -f "$feature_dir/spec.md" ] && { [ -f "$feature_dir/domain-model.md" ] || \
1321
+ { [ -d "$feature_dir" ] && [ -n "$(find "$feature_dir" -mindepth 2 -maxdepth 2 -name spec.md -print -quit 2>/dev/null)" ]; }; }; then
1322
+ echo "set-feature: refusing — '$feature_dir_rel' is a bounded context, not a feature. Run /appweaver select-bounded-context $feature first, then select-feature." >&2
1323
+ return 1
1324
+ fi
1003
1325
  local existed=false; [ -d "$feature_dir" ] && existed=true
1004
1326
  mkdir -p "$feature_dir"
1005
1327
  _persist_feature_json "$repo_root" "$feature_dir_rel"
1006
1328
 
1007
1329
  local created=true; [ "$existed" = true ] && created=false
1008
1330
  local spec_exists=false; [ -f "$feature_dir/spec.md" ] && spec_exists=true
1009
- printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","existed":%s,"created":%s,"spec_exists":%s}\n' \
1331
+ local context=""; [ "$base_rel" != "specs" ] && context="${base_rel#specs/}"
1332
+ printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","existed":%s,"created":%s,"spec_exists":%s,"context":"%s"}\n' \
1010
1333
  "$(json_escape "$feature")" "$(json_escape "$feature_dir_rel")" \
1011
- "$(json_escape "$feature_dir_rel/spec.md")" "$existed" "$created" "$spec_exists"
1334
+ "$(json_escape "$feature_dir_rel/spec.md")" "$existed" "$created" "$spec_exists" \
1335
+ "$(json_escape "$context")"
1012
1336
  }
1013
1337
 
1014
1338
  # ═══════════════════════════ active_version.json ════════════════════════════
@@ -1029,12 +1353,14 @@ _resolve_feature_dir() { # $1 = --feature-dir arg (may be empty), $2 = repo_ro
1029
1353
  printf '%s' "$fd"
1030
1354
  }
1031
1355
 
1032
- # Shared reader/mutator: op in {read,init,open,close}. Prints one JSON object.
1033
- # op=open enforces the single-active-change guard: opening a DIFFERENT change while
1034
- # one is already open fails (exit 3, ok:false) unless force ("1") is passed — this
1035
- # stops a second concurrent CR on the same aggregate from silently overwriting the
1036
- # first. (Concurrency is ACROSS features; changes to one feature are serialized.)
1037
- _active_version() { # $1 = abs feature_dir, $2 = op, $3 = cr_id (open only), $4 = force ("1" overrides open-guard), $5 = bootstrap ("1", init only)
1356
+ # Shared reader/mutator: op in {read,init,bootstrap-done,open,activate,close}. Prints one
1357
+ # JSON object.
1358
+ # MANY CHANGES MAY BE OPEN ON ONE FEATURE. open_changes[] is the record; active_change is
1359
+ # the most recently opened OR the one a later stage activated, and is kept correct for
1360
+ # every stage that still resolves "the" change through it. `force` is accepted and unused
1361
+ # on open — the guard it overrode is gone — and is kept so existing callers do not have to
1362
+ # change.
1363
+ _active_version() { # $1 = abs feature_dir, $2 = op, $3 = cr_id (open/close), $4 = force (accepted, inert), $5 = bootstrap ("1", init only)
1038
1364
  AW_FD="$1" AW_OP="$2" AW_CR="${3:-}" AW_FORCE="${4:-}" AW_BOOT="${5:-}" python3 - <<'PY'
1039
1365
  import json, os, re, datetime, sys
1040
1366
  fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]; cr = os.environ.get("AW_CR", "")
@@ -1059,7 +1385,14 @@ def bump(v):
1059
1385
 
1060
1386
  d, existed = load()
1061
1387
  d.setdefault("feature", feature); d.setdefault("version", "v1.0"); d.setdefault("active_change", "")
1388
+ # Migrate pre-parallel state forward on read: a file written when only one change could
1389
+ # be open carries active_change and no list. Seed the list from it rather than reporting
1390
+ # zero open changes on a feature that has one.
1391
+ if "open_changes" not in d:
1392
+ d["open_changes"] = [d["active_change"]] if d.get("active_change") else []
1062
1393
  out = {"feature": d["feature"], "version": d["version"], "active_change": d.get("active_change", ""),
1394
+ "open_changes": list(d.get("open_changes") or []),
1395
+ "open_count": len([c for c in (d.get("open_changes") or []) if c]),
1063
1396
  "exists": existed, "path": "active_version.json", "ok": True}
1064
1397
  if op == "read":
1065
1398
  pass
@@ -1067,13 +1400,10 @@ elif op == "init":
1067
1400
  # BOOTSTRAP IN PROGRESS, and ONLY when the caller says it is authoring a first draft
1068
1401
  # (--bootstrap). A first run that dies after writing spec.md but before its ledger row
1069
1402
  # leaves a document that looks, to the next FRESH invocation, exactly like a spec
1070
- # written before the ledger existed — `unstamped`, which passes. specify then took the
1071
- # EXTEND path and applied the same input a second time, on top of a v1.0 that was never
1072
- # finished, and bumped it to v1.1. Checkpoint resume was always fine; a fresh invocation
1073
- # was not, and nothing distinguished the two. This marker does. Only ledger-ids clears
1074
- # it, and only once the v1.0 row actually names ids read out of the finished document —
1075
- # so a bootstrap whose row never landed is re-run and REPAIRED rather than being frozen
1076
- # behind a v1.1 that can never go back and fix it.
1403
+ # written before the ledger existed — which passes. specify then took the EXTEND path
1404
+ # and applied the same input a second time, on top of a v1.0 that was never finished.
1405
+ # Checkpoint resume was always fine; a fresh invocation was not, and nothing
1406
+ # distinguished the two. This marker does.
1077
1407
  #
1078
1408
  # The flag is the CALLER's, not this primitive's: adopt inits the same file after
1079
1409
  # reversing a spec out of existing code, and a marker raised there would tell the next
@@ -1082,25 +1412,68 @@ elif op == "init":
1082
1412
  d["bootstrap_pending"] = True
1083
1413
  save(d)
1084
1414
  out["created"] = (not existed)
1415
+ elif op == "bootstrap-done":
1416
+ # THE ONLY THING THAT CLEARS THE MARKER, called by the phase that raised it once the
1417
+ # first draft is actually finished. It is a separate op, not a side effect of writing
1418
+ # the spec, because "the file exists" is exactly the signal that could not be trusted:
1419
+ # a crashed run leaves one too. Idempotent — clearing a marker that is already down is
1420
+ # the ordinary case on a re-run, and is not an error.
1421
+ was = bool(d.pop("bootstrap_pending", None))
1422
+ save(d)
1423
+ out["cleared"] = was
1085
1424
  elif op == "open":
1086
- cur = d.get("active_change", "")
1087
- if cur and cur != cr and force != "1": # a DIFFERENT change is already open -> refuse
1425
+ # MANY CHANGES MAY BE OPEN AT ONCE. Every specify call on an established feature
1426
+ # opens its own change, so the record is the open_changes LIST; the single-active
1427
+ # guard that used to refuse a second one is gone.
1428
+ #
1429
+ # active_change is still written, and is the MOST RECENTLY OPENED. Every stage
1430
+ # downstream of specify still resolves "the" change through that field, so keeping
1431
+ # it correct is what lets parallel AUTHORING land before those stages learn to take
1432
+ # an explicit --cr-id. Reading open_changes is how a caller sees the rest.
1433
+ opens = [c for c in (d.get("open_changes") or []) if c]
1434
+ if cr and cr not in opens:
1435
+ opens.append(cr)
1436
+ d["open_changes"] = opens
1437
+ d["active_change"] = cr or (opens[-1] if opens else "")
1438
+ save(d)
1439
+ out["active_change"] = d["active_change"]; out["opened"] = cr
1440
+ out["open_changes"] = opens; out["open_count"] = len(opens)
1441
+ if cr in (d.get("open_changes") or [])[:-1]: out["reopened"] = True
1442
+ elif op == "activate":
1443
+ # Point active_change at an already-open change. Nothing else moves: no version, no
1444
+ # membership, no dates. This is how a CHOSEN delta reaches the stages that still
1445
+ # resolve "the" change through active_change, without pretending it was just opened.
1446
+ opens = [c for c in (d.get("open_changes") or []) if c]
1447
+ if cr and cr not in opens:
1088
1448
  out["ok"] = False
1089
- out["error"] = ("change '%s' is already open on this feature; finish it "
1090
- "(finalize-delta -> analyze-slice -> close-delta) before opening "
1091
- "'%s', or pass --force" % (cur, cr))
1092
- out["conflict"] = cur; out["requested"] = cr; out["has_change"] = True
1093
- print(json.dumps(out)); sys.exit(3)
1094
- d["active_change"] = cr; save(d)
1095
- out["active_change"] = cr; out["opened"] = cr
1096
- if cur and cur == cr: out["reopened"] = True # idempotent re-open of the same CR
1449
+ out["error"] = ("'%s' is not an open change on this feature (open: %s)"
1450
+ % (cr, ", ".join(opens) or "none"))
1451
+ else:
1452
+ d["active_change"] = cr or d.get("active_change", "")
1453
+ save(d)
1454
+ out["active_change"] = d["active_change"]; out["activated"] = cr
1455
+ out["open_changes"] = opens; out["open_count"] = len(opens)
1097
1456
  elif op == "close":
1098
- prev = d["version"]; closed = d.get("active_change", "")
1099
- if closed: # a real change was open -> materialized -> bump
1457
+ # Closes ONE change by id (falling back to active_change when none is named, which
1458
+ # is the pre-parallel caller). The version bumps once per change materialized, and
1459
+ # active_change falls back to whatever is still open so the downstream stages keep
1460
+ # resolving a real change while others remain in flight.
1461
+ prev = d["version"]
1462
+ opens = [c for c in (d.get("open_changes") or []) if c]
1463
+ if not opens and d.get("active_change"):
1464
+ opens = [d["active_change"]] # state written before open_changes existed
1465
+ closed = cr or d.get("active_change", "")
1466
+ if closed and closed in opens:
1467
+ opens.remove(closed)
1100
1468
  d["version"] = bump(prev)
1101
- d["active_change"] = ""; save(d)
1102
- out["version"] = d["version"]; out["active_change"] = ""
1469
+ elif closed: # not tracked, but a real change was named
1470
+ d["version"] = bump(prev)
1471
+ d["open_changes"] = opens
1472
+ d["active_change"] = opens[-1] if opens else ""
1473
+ save(d)
1474
+ out["version"] = d["version"]; out["active_change"] = d["active_change"]
1103
1475
  out["previous"] = prev; out["closed_change"] = closed
1476
+ out["open_changes"] = opens; out["open_count"] = len(opens)
1104
1477
  out["bootstrap_pending"] = bool(d.get("bootstrap_pending"))
1105
1478
  out["has_change"] = bool(out.get("active_change", "")) # clean branch signal for design
1106
1479
  # What a close from THIS state would produce. One bump() authority, read by BOTH
@@ -1115,6 +1488,7 @@ PY
1115
1488
 
1116
1489
  cmd_version_read() { _version_cmd "read" "$@"; }
1117
1490
  cmd_version_init() { _version_cmd "init" "$@"; }
1491
+ cmd_version_bootstrap_done() { _version_cmd "bootstrap-done" "$@"; }
1118
1492
  _version_cmd() { # $1 = op (read|init), then --feature-dir arg
1119
1493
  local op="$1"; shift
1120
1494
  local fd_arg="" boot=""
@@ -1131,1841 +1505,6 @@ _version_cmd() { # $1 = op (read|init), then --feature-dir arg
1131
1505
  _active_version "$fd" "$op" "" "" "$boot"
1132
1506
  }
1133
1507
 
1134
- # ═════════════════════════════ the spec version ledger ══════════════════════
1135
- # spec.md's `## Change History` is the sync anchor between the spec and its story
1136
- # backlog: each row names the requirement ids that version touched, and each story
1137
- # records the version it last synced at, so "is this story stale" is one integer
1138
- # comparison rather than a diff. sync-stories reads the rows; specify and
1139
- # update-spec append them.
1140
- #
1141
- # The ledger is hand-writable, which is the point — a spec can be edited by hand —
1142
- # and also its one weakness: a body edited with no row leaves every affected story
1143
- # quietly stale and nothing says so. active_version.json therefore carries a sha256
1144
- # of spec.md as of the last kit-authored write, and three states follow from
1145
- # comparing it against the document:
1146
- #
1147
- # hash matches clean — nothing has been hand-edited since
1148
- # hash differs, version stands a hand edit that skipped the ledger -> REFUSE
1149
- # hash differs, version advanced a hand edit that wrote its own row -> accept
1150
- #
1151
- # The middle case is the only silent failure the ledger has, which is why it is the
1152
- # one thing checked before any workflow writes.
1153
- _spec_ledger() { # $1 = abs feature_dir, $2 = op, $3 = ids, $4 = summary, $5 = delta-spec template
1154
- AW_FD="$1" AW_OP="$2" AW_IDS="${3:-}" AW_SUM="${4:-}" AW_DTPL="${5:-}" python3 - <<'PY'
1155
- import json, os, re, hashlib, datetime, sys
1156
- fd = os.environ["AW_FD"]; op = os.environ["AW_OP"]
1157
- ids = os.environ.get("AW_IDS", "").strip(); summary = os.environ.get("AW_SUM", "").strip()
1158
- spec = os.path.join(fd, "spec.md"); avp = os.path.join(fd, "active_version.json")
1159
- feature = os.path.basename(fd.rstrip("/\\"))
1160
-
1161
- def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1162
- def vtuple(v):
1163
- m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
1164
- return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
1165
- def bump(v):
1166
- maj, mnr = vtuple(v or "v1.0")
1167
- return "v%d.%d" % (maj, mnr + 1)
1168
-
1169
- out = {"feature": feature, "op": op, "ok": True, "spec_exists": os.path.isfile(spec)}
1170
-
1171
- # A non-zero exit is reported by the orchestrator from STDERR alone, so anything that
1172
- # stops a run has to say why there as well as in the JSON — otherwise the operator sees
1173
- # "script failed (ledger-append):" and nothing else.
1174
- def bail(msg, code):
1175
- out.update(ok=False, error=msg)
1176
- print(json.dumps(out)); sys.stderr.write(msg + "\n"); sys.exit(code)
1177
-
1178
- if not out["spec_exists"]:
1179
- bail("no spec.md at %s" % fd, 1)
1180
-
1181
- text = open(spec, encoding="utf-8", errors="replace").read()
1182
-
1183
- # The Story Coverage block is a projection of the backlog, not contract, and it is written
1184
- # by the story workflows rather than by whoever owns the spec. Cutting it out before
1185
- # hashing is what lets both be true at once: creating a story rewrites this document
1186
- # without that reading as an unledgered edit of the requirements.
1187
- COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
1188
- def contract_of(t): return COV.sub("", t)
1189
- actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
1190
- av = {}
1191
- if os.path.isfile(avp):
1192
- try: av = json.load(open(avp, encoding="utf-8"))
1193
- except Exception: av = {}
1194
- recorded_hash = av.get("spec_sha256", "")
1195
- recorded_ver = av.get("version", "v1.0")
1196
-
1197
- # The document's own header is what a hand edit bumps; active_version.json is what the
1198
- # kit last wrote. They agree except across a hand edit, and the disagreement is the signal.
1199
- m = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
1200
- doc_ver = m.group(1) if m else ""
1201
-
1202
- # THE LEDGER TABLE, located once: its heading, its separator rule, and the data rows under
1203
- # it. Every op that touches a version row works from this, and `check` refuses a document
1204
- # that has none — because ledger-append discovers a missing table only AFTER the spec has
1205
- # been rewritten, and a rewritten spec with no row is exactly the state the next run's
1206
- # tripwire refuses. Finding it at entry turns a wedged feature into a message.
1207
- lines = text.split("\n")
1208
- hi = next((i for i, l in enumerate(lines)
1209
- if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
1210
- sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
1211
- len(lines)) if hi >= 0 else -1)
1212
- sep = (next((i for i in range(hi + 1, sec_end)
1213
- if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
1214
- rows = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
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 ""
1224
-
1225
- def cell(s, limit=160):
1226
- """One markdown table cell. A newline ENDS a row and a bare pipe STARTS a column, so
1227
- free text dropped into a cell silently rewrites the row's shape — a summary carrying
1228
- either one produced a version row whose 'Requirements changed' cell had moved or
1229
- vanished, which is a change no story can ever be reconciled against."""
1230
- s = re.sub(r'\s+', ' ', (s or "")).strip().replace("|", "\\|")
1231
- return (s[:limit - 1] + "\u2026") if len(s) > limit else s
1232
-
1233
- # IS A DELTA ACTUALLY MEASURED AGAINST THIS MASTER. cr-setup scaffolds delta-spec.md AND
1234
- # delta-design.md for EVERY change it opens, so the file existing proves nothing. Since
1235
- # specify lost its delta branch nothing authors a delta-spec at all, and the only changes
1236
- # opened today are design-only ones carrying an untouched copy of the template — for which
1237
- # editing spec.md strands nothing. Authored means the bytes have moved off the template;
1238
- # with no template resolvable, a non-empty file counts.
1239
- cr = av.get("active_change", "")
1240
- dtpl = os.environ.get("AW_DTPL", "")
1241
- dspec = os.path.join(fd, "changes", cr, "delta-spec.md") if cr else ""
1242
- delta_authored = False
1243
- if dspec and os.path.isfile(dspec) and os.path.getsize(dspec) > 0:
1244
- try:
1245
- delta_authored = (not (dtpl and os.path.isfile(dtpl))
1246
- or open(dspec, "rb").read() != open(dtpl, "rb").read())
1247
- except Exception:
1248
- delta_authored = True
1249
-
1250
- out.update(open_change=cr, delta_authored=delta_authored)
1251
- out.update(version=recorded_ver, doc_version=doc_ver, spec_sha256=actual,
1252
- recorded_sha256=recorded_hash, body_changed=(recorded_hash != actual),
1253
- has_ledger=has_ledger, ledger_rows=len(rows), newest_row=newest_row,
1254
- first_stamp=(not recorded_hash))
1255
-
1256
- if op == "check":
1257
- # An AUTHORED delta-spec was computed against THIS master. Writing the master directly
1258
- # would leave that delta measured from a baseline that no longer exists, and nothing
1259
- # downstream re-checks it. The two families overlap only until the delta set is deleted,
1260
- # so this guard is temporary by design — but while both exist it is the one silent way
1261
- # they collide. Narrowed to an authored delta on purpose: gating on `active_change`
1262
- # alone refused every extend for the whole life of a DESIGN-only change, which strands
1263
- # nothing, and told the operator to finish a chain they may be retiring.
1264
- if cr and delta_authored:
1265
- out["state"] = "delta_open"
1266
- out.update(ok=False, active_change=cr,
1267
- error="change '%s' is open on this feature and its delta-spec has been "
1268
- "authored, so a delta is measured against this master. Editing spec.md "
1269
- "directly would leave that delta computed from a baseline that no longer "
1270
- "exists. Either finish the change (finalize-delta -> analyze-slice -> "
1271
- "close-delta), or abandon it: "
1272
- "`.appweaver/scripts/bash/aw-spec-actions.sh cr-archive --cr-id %s` moves "
1273
- "changes/%s/ into changes/archive/ and clears the flag. Abandoning also "
1274
- "bumps the minor version, so the number moves even though nothing shipped."
1275
- % (cr, cr, cr))
1276
- elif not has_ledger:
1277
- out["state"] = "no_ledger"
1278
- out.update(ok=False,
1279
- error="spec.md has no '## Change History' table. That table is where a "
1280
- "story learns a requirement it cites has moved, so a spec without one "
1281
- "cannot be kept in step with its backlog. Add the section with its "
1282
- "header row and separator (see the spec template), then re-run.")
1283
- elif not recorded_hash:
1284
- out["state"] = "unstamped" # pre-ledger spec; stamp adopts it
1285
- elif recorded_hash == actual:
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
1298
- elif vtuple(doc_ver) > vtuple(recorded_ver):
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))
1309
- elif vtuple(doc_ver) < vtuple(recorded_ver):
1310
- out["state"] = "rollback"
1311
- out.update(ok=False, error="spec.md reads %s but the kit last wrote %s — the "
1312
- "document is behind its own ledger" % (doc_ver or "no version", recorded_ver))
1313
- else:
1314
- out["state"] = "unledgered_edit"
1315
- out.update(ok=False, error="spec.md has changed since %s but no Change History row "
1316
- "was added. Every story citing an edited requirement is now silently "
1317
- "stale. Add a row naming the ids you changed, then re-run."
1318
- % recorded_ver)
1319
- elif op == "stamp":
1320
- # THE VERSION IS THE HIGHEST OF THE THREE PLACES ONE IS WRITTEN, and the document's
1321
- # header is reconciled up to it. Taking the header alone let a hand edit that DID write
1322
- # its row — without bumping the header — leave the kit recording a version below the
1323
- # newest row; the next hand edit then read as a `rollback`, the document behind its own
1324
- # ledger, when nothing had rolled back. Rewriting the header is the authority
1325
- # ledger-append already exercises: the kit owns the number, the row is what it is
1326
- # computed from, and re-hashing after the rewrite is what keeps the baseline honest.
1327
- stamp_ver = max([recorded_ver or "v1.0", doc_ver or "v1.0"] +
1328
- ([newest_row] if newest_row else []), key=vtuple)
1329
- if doc_ver and vtuple(doc_ver) < vtuple(stamp_ver):
1330
- text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + stamp_ver,
1331
- text, count=1, flags=re.M)
1332
- open(spec, "w", encoding="utf-8").write(text)
1333
- actual = hashlib.sha256(contract_of(text).encode("utf-8")).hexdigest()
1334
- out.update(header_rewritten=True, doc_version=stamp_ver, spec_sha256=actual)
1335
- av.setdefault("feature", feature); av.setdefault("active_change", "")
1336
- av["version"] = stamp_ver
1337
- av["spec_sha256"] = actual; av["updated"] = now()
1338
- open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
1339
- out.update(version=av["version"], state="stamped")
1340
- elif op == "append":
1341
- if not ids:
1342
- # A version that names no ids is a bump nothing can reconcile against: every
1343
- # story keeps its old stamp and the gap compounds at the next real change.
1344
- bail("ledger-append: --ids is required — a version row that names no requirement "
1345
- "ids leaves sync-stories nothing to act on", 2)
1346
- # The next number comes from the highest of all three places a version is written —
1347
- # what the kit last recorded, what the document's header claims, and the newest row
1348
- # actually in the table. A hand edit routinely moves one and not the others, and
1349
- # taking the maximum is what stops the ledger re-issuing a number it has already used.
1350
- row_vers = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', text, re.M)
1351
- new_ver = bump(max([recorded_ver, doc_ver] + row_vers, key=vtuple))
1352
- today = datetime.date.today().isoformat()
1353
- # Both free-text cells go through cell(): the summary is whatever the operator typed on
1354
- # the command line, and one newline or one pipe in it destroys the row.
1355
- row = "| %s | %s | %s | %s | — |" % (
1356
- new_ver, today, cell(summary) or "Requirements updated", cell(ids))
1357
-
1358
- # Bounded to the Change History section, not "the last table row in the file". Any
1359
- # section below it — Story Coverage today — carries its own table, and an unbounded
1360
- # search would file the version row into whichever one happened to come last.
1361
- if not has_ledger:
1362
- bail("spec.md has no '## Change History' table to append to — the ledger is "
1363
- "where a story learns a requirement moved, so a spec without one cannot be "
1364
- "kept in step with its backlog. spec.md HAS ALREADY BEEN WRITTEN by this run: "
1365
- "add the section, then re-run to record what it changed", 2)
1366
- last = rows[-1] if rows else sep
1367
- # Duplicate version = two writers reached the same number, which is the collision the
1368
- # integer buys for free. Refuse rather than append a second row nothing can order.
1369
- if re.search(r'^\|\s*%s\s*\|' % re.escape(new_ver), text, re.M):
1370
- bail("the ledger already carries a %s row — another write reached this version "
1371
- "first; re-read spec.md and retry" % new_ver, 3)
1372
- lines.insert(last + 1, row)
1373
-
1374
- stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + new_ver,
1375
- "\n".join(lines), count=1, flags=re.M)
1376
- open(spec, "w", encoding="utf-8").write(stamped)
1377
-
1378
- av.setdefault("feature", feature); av.setdefault("active_change", "")
1379
- av["version"] = new_ver
1380
- av["spec_sha256"] = hashlib.sha256(contract_of(stamped).encode("utf-8")).hexdigest()
1381
- av["updated"] = now()
1382
- open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
1383
- out.update(version=new_ver, previous=recorded_ver, row=row, ids=ids,
1384
- spec_sha256=av["spec_sha256"], state="appended")
1385
- elif op == "rowids":
1386
- # THE FIRST ROW, COMPUTED. Every other version row is derived from a before/after diff,
1387
- # for the reason the header of this file gives: a row the model DECLARED can omit an id
1388
- # it moved, and an omitted id is silent. The v1.0 row was the one exception — written by
1389
- # the author from a template whose placeholder literally reads "FR-001…FR-00n", which
1390
- # sync-stories then has to be told not to trust. This op closes that gap: the caller
1391
- # diffs the finished document against nothing and hands the ids in, exactly as the
1392
- # extend path does, so the first row is observed like all the rest.
1393
- if not ids:
1394
- bail("ledger-ids: --ids is required", 2)
1395
- if not has_ledger:
1396
- bail("spec.md has no '## Change History' table to write the initial row into", 2)
1397
- ver = doc_ver if re.match(r'^v\d+(\.\d+)?$', doc_ver or "") else (recorded_ver or "v1.0")
1398
- today = datetime.date.today().isoformat()
1399
- if rows:
1400
- # Rewrite the newest row's cells in place. A cell split has to respect \| , which is
1401
- # how cell() escapes a pipe the operator typed — splitting on every pipe would treat
1402
- # an escaped one as a column break and shift every cell after it.
1403
- parts = re.split(r'(?<!\\)\|', lines[rows[-1]])
1404
- body = [c.strip() for c in parts[1:-1]] if len(parts) >= 3 else []
1405
- while len(body) < 5: body.append("")
1406
- if not re.match(r'^v\d+(\.\d+)?$', body[0]): body[0] = ver
1407
- if not re.match(r'^\d{4}-\d{2}-\d{2}$', body[1]): body[1] = today
1408
- if not body[2] or body[2].startswith("["): body[2] = "Initial specification"
1409
- body[3] = cell(ids)
1410
- if not body[4]: body[4] = "—"
1411
- lines[rows[-1]] = "| " + " | ".join(body[:5]) + " |"
1412
- else:
1413
- lines.insert(sep + 1, "| %s | %s | Initial specification | %s | — |"
1414
- % (ver, today, cell(ids)))
1415
- stamped = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + ver,
1416
- "\n".join(lines), count=1, flags=re.M)
1417
- open(spec, "w", encoding="utf-8").write(stamped)
1418
- # The v1.0 row now names ids read out of the finished document, which is the moment the
1419
- # bootstrap is complete. Clearing the marker here and NOWHERE else is what makes an
1420
- # incomplete first run re-runnable: spec-stamp still re-baselines the hash on every path,
1421
- # so gating on the hash would have frozen a placeholder row as clean forever.
1422
- av.pop("bootstrap_pending", None)
1423
- av.setdefault("feature", feature); av.setdefault("active_change", "")
1424
- av["version"] = ver; av["updated"] = now()
1425
- open(avp, "w", encoding="utf-8").write(json.dumps(av, indent=2) + "\n")
1426
- out.update(version=ver, ids=ids, row=lines[rows[-1]] if rows else lines[sep + 1],
1427
- bootstrap_pending=False, state="rowids")
1428
- print(json.dumps(out))
1429
- PY
1430
- }
1431
-
1432
- # ──────────────────────────────── story-refs ───────────────────────────────
1433
- # Which requirements have live work against them. A read-only scan of the feature's
1434
- # stories/ directory, reading only frontmatter — which is why §6.1 puts everything the
1435
- # gates need there rather than in the body.
1436
- #
1437
- # specify asks this twice, for two different purposes. Before authoring, with no --ids,
1438
- # to tell the author which requirements someone is currently building. After the diff,
1439
- # with the ids the run actually MODIFIED, to decide whether to ask before changing a
1440
- # contract under someone's feet.
1441
- #
1442
- # Only OPEN stories count. A closed story is history and a cancelled one was abandoned;
1443
- # neither is disturbed by the contract moving (E4).
1444
- #
1445
- # in_flight is the narrower set: an open story that already has a stories/<ID>/ directory,
1446
- # so someone has run story-plan and is working from a design of the requirement as it
1447
- # reads today. That is the same directory-existence predicate change-story-id uses for its
1448
- # legality boundary — one test, serving three workflows.
1449
- cmd_story_refs() {
1450
- local fd_arg="" ids="" ids_given=""
1451
- while [ $# -gt 0 ]; do
1452
- case "$1" in
1453
- --feature-dir) fd_arg="$2"; shift 2 ;;
1454
- --ids) ids="$2"; ids_given=1; shift 2 ;;
1455
- --help|-h) echo "story-refs [--feature-dir <path>] [--ids <FR-001, BR-002>]"; return 0 ;;
1456
- *) echo "story-refs: unknown arg '$1'" >&2; return 1 ;;
1457
- esac
1458
- done
1459
- local repo_root; repo_root=$(get_repo_root)
1460
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-refs: no feature set and no --feature-dir" >&2; return 1; }
1461
- AW_FD="$fd" AW_IDS="$ids" AW_IDS_GIVEN="$ids_given" python3 - <<'PY'
1462
- import json, os, re
1463
- fd = os.environ["AW_FD"]; want = os.environ.get("AW_IDS", "")
1464
- sdir = os.path.join(fd, "stories")
1465
- queried = [s.strip() for s in re.split(r'[,\s]+', want) if s.strip()]
1466
-
1467
- def parse_fm(text):
1468
- """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1469
- a YAML block list under an empty key — both are valid YAML and a hand edit produces
1470
- either, so a parser that read only the inline form saw a perfectly good story as citing
1471
- nothing, and a story citing nothing is invisible to every gate that exists to protect
1472
- it. Block items are folded into the inline shape so every caller stays regex-simple."""
1473
- m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
1474
- if not m: return {}
1475
- fm, key, block = {}, None, set()
1476
- for line in m.group(1).split("\n"):
1477
- km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
1478
- if km:
1479
- key = km.group(1); fm[key] = km.group(2).strip(); continue
1480
- im = re.match(r'^\s+-\s+(.*)$', line) if key else None
1481
- if im and (key in block or not fm[key]):
1482
- block.add(key)
1483
- fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1484
- return fm
1485
-
1486
- def frontmatter(path):
1487
- try: return parse_fm(open(path, encoding="utf-8", errors="replace").read())
1488
- except Exception: return {}
1489
-
1490
- stories = []
1491
- if os.path.isdir(sdir):
1492
- for name in sorted(os.listdir(sdir)):
1493
- if not name.endswith(".md"): continue
1494
- path = os.path.join(sdir, name)
1495
- if not os.path.isfile(path): continue
1496
- fm = frontmatter(path)
1497
- sid = fm.get("id") or name[:-3]
1498
- # An unparseable story is reported as open rather than skipped: a story the scan
1499
- # cannot read is exactly the one whose requirements must not move unannounced.
1500
- status = (fm.get("status") or "open").strip().lower()
1501
- cites = [c.strip() for c in re.findall(r'[A-Z]{2}-\d+', fm.get("exercises", ""))]
1502
- stories.append({"id": sid, "status": status, "exercises": cites,
1503
- "cites_csv": ", ".join(cites),
1504
- "title": (fm.get("title") or "").strip().strip("[]"),
1505
- "spec_version": (fm.get("spec_version") or "").strip(),
1506
- "type": (fm.get("type") or "functional").strip().lower(),
1507
- "follows": (fm.get("follows") or "").strip(),
1508
- "in_flight": os.path.isdir(os.path.join(sdir, sid))})
1509
-
1510
- open_stories = [s for s in stories if s["status"] == "open"]
1511
- covered = sorted({c for s in open_stories for c in s["exercises"]})
1512
-
1513
- # ASKING ABOUT NOTHING IS NOT ASKING ABOUT EVERYTHING. `--ids ""` is a caller saying "this
1514
- # run disturbed no requirement" — a pure addition — and the answer is an empty set. Only
1515
- # the absence of the flag means "tell me about the whole backlog". Conflating the two made
1516
- # an enhancement that touched nothing existing prompt about every requirement with a story.
1517
- targets = queried if os.environ.get("AW_IDS_GIVEN") else covered
1518
-
1519
- affected, aff_ids, aff_stories, inflight = [], [], [], []
1520
- for rid in targets:
1521
- hit = [s for s in open_stories if rid in s["exercises"]]
1522
- if not hit: continue
1523
- aff_ids.append(rid)
1524
- names = [s["id"] for s in hit]
1525
- flying = [s["id"] for s in hit if s["in_flight"]]
1526
- aff_stories += names; inflight += flying
1527
- affected.append({"id": rid, "stories": names, "in_flight": flying})
1528
-
1529
- # THE COMPLEMENT, NAMED. `affected` is "these ids have live work"; its complement is
1530
- # "these ids have nobody on them", and that is the set sync-stories raises new stories for.
1531
- # Derived here rather than in the caller because the orchestrator binds values and never
1532
- # computes them, and a subtraction done in a prompt is a subtraction nothing can check.
1533
- unlinked = [rid for rid in targets if rid not in aff_ids]
1534
-
1535
- # One line per open story, for a prompt that has to ask about them. A story id on its own
1536
- # is not something an author can answer about: what it cites and whether someone is already
1537
- # implementing it are the whole basis of the decision.
1538
- digest = "\n".join(
1539
- " %-16s %-9s %-11s cites %s%s" % (
1540
- s["id"], s["status"], "IN FLIGHT" if s["in_flight"] else "not started",
1541
- s["cites_csv"] or "nothing",
1542
- (" @ " + s["spec_version"]) if s["spec_version"] else "")
1543
- for s in open_stories) or " (no open stories)"
1544
-
1545
- print(json.dumps({
1546
- "ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
1547
- "story_count": len(stories), "open_count": len(open_stories),
1548
- "stories": stories, "digest": digest,
1549
- "queried": queried, "covered": covered, "covered_csv": ", ".join(covered),
1550
- "affected": affected, "affected_ids": aff_ids, "affected_csv": ", ".join(aff_ids),
1551
- "unlinked": unlinked, "unlinked_csv": ", ".join(unlinked),
1552
- "unlinked_count": len(unlinked), "any_unlinked": bool(unlinked),
1553
- "story_csv": ", ".join(sorted(set(aff_stories))),
1554
- "in_flight_csv": ", ".join(sorted(set(inflight))),
1555
- "any": bool(aff_ids), "any_in_flight": bool(inflight),
1556
- }))
1557
- PY
1558
- }
1559
-
1560
- # ────────────────────────────── spec-coverage ──────────────────────────────
1561
- # Rewrite the spec's Story Coverage block from the stories/ directory: every FR- and BR-
1562
- # in the document, and the stories that deliver it. The relation is stored ONCE, in each
1563
- # story's `exercises:` frontmatter; this is that relation read the other way round, which
1564
- # is why the block is regenerated wholesale rather than patched. Hand edits to it are
1565
- # discarded by design — the story is where delivery is declared.
1566
- #
1567
- # Every workflow that changes either side calls this: specify when the requirements move,
1568
- # user-story and sync-stories when the backlog does. It is deterministic and model-free,
1569
- # so calling it more often than necessary costs nothing and drift is not possible.
1570
- #
1571
- # Scoped to FR-/BR- — the ids a story cites. CN-/DC-/IC-/UX- are constraints the design
1572
- # traces, not slices of work, and listing them would show permanent gaps as coverage holes.
1573
- cmd_spec_coverage() {
1574
- local fd_arg=""
1575
- while [ $# -gt 0 ]; do
1576
- case "$1" in
1577
- --feature-dir) fd_arg="$2"; shift 2 ;;
1578
- --help|-h) echo "spec-coverage [--feature-dir <path>]"; return 0 ;;
1579
- *) echo "spec-coverage: unknown arg '$1'" >&2; return 1 ;;
1580
- esac
1581
- done
1582
- local repo_root; repo_root=$(get_repo_root)
1583
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-coverage: no feature set and no --feature-dir" >&2; return 1; }
1584
- AW_FD="$fd" python3 - <<'PY'
1585
- import json, os, re, sys
1586
- fd = os.environ["AW_FD"]
1587
- spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
1588
- BEGIN, END = "<!-- AW:COVERAGE:BEGIN -->", "<!-- AW:COVERAGE:END -->"
1589
- COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
1590
- def parse_fm(text):
1591
- """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1592
- a YAML block list under an empty key — both are valid YAML and a hand edit produces
1593
- either, so a parser that read only the inline form saw a perfectly good story as citing
1594
- nothing, and a story citing nothing is invisible to every gate that exists to protect
1595
- it. Block items are folded into the inline shape so every caller stays regex-simple."""
1596
- m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
1597
- if not m: return {}
1598
- fm, key, block = {}, None, set()
1599
- for line in m.group(1).split("\n"):
1600
- km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
1601
- if km:
1602
- key = km.group(1); fm[key] = km.group(2).strip(); continue
1603
- im = re.match(r'^\s+-\s+(.*)$', line) if key else None
1604
- if im and (key in block or not fm[key]):
1605
- block.add(key)
1606
- fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1607
- return fm
1608
- out = {"ok": True, "written": False}
1609
- if not os.path.isfile(spec):
1610
- out.update(ok=False, error="no spec.md at %s" % fd); print(json.dumps(out)); sys.exit(1)
1611
- text = open(spec, encoding="utf-8", errors="replace").read()
1612
- if not COV.search(text):
1613
- # A spec from before the block existed, or one whose markers were deleted. Not an
1614
- # error: the coverage view is an aid, and refusing to run would block the change the
1615
- # caller actually came to make.
1616
- out.update(reason="no AW:COVERAGE block in spec.md"); print(json.dumps(out)); sys.exit(0)
1617
-
1618
- body = COV.sub("", text)
1619
- rids = []
1620
- for line in body.split("\n"):
1621
- m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
1622
- if m and m.group(1) not in rids: rids.append(m.group(1))
1623
- rids.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1624
-
1625
- stories = []
1626
- if os.path.isdir(sdir):
1627
- for name in sorted(os.listdir(sdir)):
1628
- if not name.endswith(".md"): continue
1629
- p = os.path.join(sdir, name)
1630
- if not os.path.isfile(p): continue
1631
- d = parse_fm(open(p, encoding="utf-8", errors="replace").read())
1632
- sid = d.get("id") or name[:-3]
1633
- stories.append({"id": sid, "status": (d.get("status") or "open").strip().lower(),
1634
- "cites": re.findall(r'[A-Z]{2}-\d+', d.get("exercises", "")),
1635
- "flying": os.path.isdir(os.path.join(sdir, sid))})
1636
-
1637
- rows, uncovered, delivered = [], [], []
1638
- for rid in rids:
1639
- live = [s for s in stories if rid in s["cites"] and s["status"] == "open"]
1640
- done = [s for s in stories if rid in s["cites"] and s["status"] != "open"]
1641
- if not live and not done: uncovered.append(rid)
1642
- # DELIVERED is "no OPEN stories", never "a story closed" — a follow-on can still carry
1643
- # the remainder, and keying on a closed story would read a half-built requirement as done.
1644
- elif not live: delivered.append(rid)
1645
- rows.append("| %s | %s | %s |" % (
1646
- rid,
1647
- ", ".join(s["id"] + (" (in flight)" if s["flying"] else "") for s in live) or "—",
1648
- ", ".join(s["id"] for s in done) or "—"))
1649
-
1650
- table = ["| Requirement | Open stories | Closed / cancelled |",
1651
- "|-------------|--------------|--------------------|"]
1652
- table += rows or ["| _(no FR- or BR- requirements yet)_ | | |"]
1653
- open(spec, "w", encoding="utf-8").write(COV.sub(BEGIN + "\n" + "\n".join(table) + "\n" + END, text, count=1))
1654
- out.update(written=True, requirements=len(rids), stories=len(stories),
1655
- uncovered=uncovered, uncovered_csv=", ".join(uncovered),
1656
- delivered=delivered, delivered_csv=", ".join(delivered))
1657
- print(json.dumps(out))
1658
- PY
1659
- }
1660
-
1661
- # ─────────────────────────────── ledger-read ───────────────────────────────
1662
- # THE WORK SET: which requirements this sync has to account for. The ledger records what
1663
- # each version touched and every story records the version it last synced at, so the
1664
- # question "what has moved since the backlog was last in step" is answerable by reading
1665
- # rows — no diffing, no hashing, no model.
1666
- #
1667
- # The set is the union of the ids named by every row ABOVE THE LOWEST spec_version across
1668
- # the OPEN stories, not the ids of the newest row alone. Those are the same set whenever
1669
- # the backlog is one version behind, which is the ordinary case; they diverge only when a
1670
- # sync was skipped, and that is exactly the case worth getting right. Two specify runs
1671
- # without a sync between them leave the middle version named by no row anyone reads, and
1672
- # its requirements would get no story with nothing to say so — a silent gap, in the one
1673
- # mechanism built to make gaps loud.
1674
- #
1675
- # THE v1.0 CASE (E42). At v1.0 nothing has moved yet: there is no "since" to read from. So
1676
- # when no open story carries a readable spec version — an empty backlog, or stories written
1677
- # before the field existed — the work set is every FR-/BR- the DOCUMENT declares, and the
1678
- # per-id four-case table does the rest. A second run over the same spec then creates
1679
- # nothing, because by then every id has a story.
1680
- #
1681
- # Scoped to FR-/BR- throughout, the same scoping spec-coverage applies: those are the only
1682
- # classes a story's `exercises:` can name. CN-/DC-/IC-/UX- move the contract and belong in
1683
- # the ledger row, but they are constraints a story CARRIES, not slices of work it delivers.
1684
- cmd_ledger_read() {
1685
- local fd_arg=""
1686
- while [ $# -gt 0 ]; do
1687
- case "$1" in
1688
- --feature-dir) fd_arg="$2"; shift 2 ;;
1689
- --help|-h) echo "ledger-read [--feature-dir <path>]"; return 0 ;;
1690
- *) echo "ledger-read: unknown arg '$1'" >&2; return 1 ;;
1691
- esac
1692
- done
1693
- local repo_root; repo_root=$(get_repo_root)
1694
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "ledger-read: no feature set and no --feature-dir" >&2; return 1; }
1695
- AW_FD="$fd" python3 - <<'PY'
1696
- import json, os, re, sys
1697
- fd = os.environ["AW_FD"]
1698
- spec = os.path.join(fd, "spec.md"); sdir = os.path.join(fd, "stories")
1699
-
1700
- def bail(msg, code=1):
1701
- print(json.dumps({"ok": False, "error": msg}))
1702
- sys.stderr.write(msg + "\n"); sys.exit(code)
1703
-
1704
- if not os.path.isfile(spec): bail("no spec.md at %s" % fd)
1705
- text = open(spec, encoding="utf-8", errors="replace").read()
1706
-
1707
- COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
1708
-
1709
- def vtuple(v):
1710
- m = re.match(r'v?(\d+)(?:\.(\d+))?', (v or "").strip())
1711
- return (int(m.group(1)), int(m.group(2) or 0)) if m else (0, 0)
1712
-
1713
- def rids(s):
1714
- """FR-/BR- ids in order of appearance, deduped, then sorted by class and number so
1715
- two callers reading the same row always produce the same csv."""
1716
- seen, out = set(), []
1717
- for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
1718
- if t not in seen: seen.add(t); out.append(t)
1719
- return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1720
-
1721
- def parse_fm(text):
1722
- """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
1723
- a YAML block list under an empty key — both are valid YAML and a hand edit produces
1724
- either, so a parser that read only the inline form saw a perfectly good story as citing
1725
- nothing, and a story citing nothing is invisible to every gate that exists to protect
1726
- it. Block items are folded into the inline shape so every caller stays regex-simple."""
1727
- m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
1728
- if not m: return {}
1729
- fm, key, block = {}, None, set()
1730
- for line in m.group(1).split("\n"):
1731
- km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
1732
- if km:
1733
- key = km.group(1); fm[key] = km.group(2).strip(); continue
1734
- im = re.match(r'^\s+-\s+(.*)$', line) if key else None
1735
- if im and (key in block or not fm[key]):
1736
- block.add(key)
1737
- fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
1738
- return fm
1739
-
1740
- # THE LEDGER TABLE, located the same way _spec_ledger locates it — heading, separator rule,
1741
- # then the rows under it, bounded by the next `## `. Any other section carries its own
1742
- # table (Story Coverage does), and an unbounded search would read one of those as history.
1743
- lines = text.split("\n")
1744
- hi = next((i for i, l in enumerate(lines)
1745
- if re.match(r'^##\s+Change History\s*$', l.strip())), -1)
1746
- sec_end = (next((i for i in range(hi + 1, len(lines)) if lines[i].startswith("## ")),
1747
- len(lines)) if hi >= 0 else -1)
1748
- sep = (next((i for i in range(hi + 1, sec_end)
1749
- if re.match(r'^\|[\s\-:|]+\|$', lines[i].strip())), -1) if hi >= 0 else -1)
1750
- row_lines = [i for i in range(sep + 1, sec_end) if lines[i].lstrip().startswith("|")] if sep >= 0 else []
1751
- has_ledger = hi >= 0 and sep >= 0
1752
-
1753
- def cells(line):
1754
- parts = re.split(r'(?<!\\)\|', line)
1755
- return [c.strip().replace("\\|", "|") for c in parts[1:-1]] if len(parts) >= 3 else []
1756
-
1757
- rows = []
1758
- for i in row_lines:
1759
- c = cells(lines[i]); c += [""] * (5 - len(c))
1760
- # A row whose first cell is not a version is the template's own placeholder line, or a
1761
- # note somebody left in the table. It records no version, so it orders against nothing.
1762
- if not re.match(r'^v\d', c[0]): continue
1763
- rows.append({"version": c[0], "date": c[1], "change": c[2], "ids_cell": c[3],
1764
- "stories": c[4], "requirements": rids(c[3])})
1765
- rows.sort(key=lambda r: vtuple(r["version"]))
1766
-
1767
- # WHAT THE DOCUMENT STILL DECLARES. Read from the requirement's DEFINING line — the bolded
1768
- # id the template gives every requirement — and with the Change History cut away, so the
1769
- # ids named in a row's own cell are never mistaken for requirements the spec carries.
1770
- body = COV.sub("", text).split("\n## Change History")[0]
1771
- declared = []
1772
- for line in body.split("\n"):
1773
- m = re.search(r'\*\*((?:FR|BR)-\d+)\*\*', line)
1774
- if m and m.group(1) not in declared: declared.append(m.group(1))
1775
- declared.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1776
-
1777
- stories = []
1778
- if os.path.isdir(sdir):
1779
- for name in sorted(os.listdir(sdir)):
1780
- if not name.endswith(".md"): continue
1781
- p = os.path.join(sdir, name)
1782
- if not os.path.isfile(p): continue
1783
- fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
1784
- sid = fm.get("id") or name[:-3]
1785
- stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
1786
- "spec_version": (fm.get("spec_version") or "").strip(),
1787
- "follows": (fm.get("follows") or "").strip(),
1788
- "in_flight": os.path.isdir(os.path.join(sdir, sid)),
1789
- "cites": rids(fm.get("exercises", ""))})
1790
- open_stories = [s for s in stories if s["status"] == "open"]
1791
-
1792
- # THE FLOOR is the oldest version any open story is still sitting at, and everything above
1793
- # it is unsynced. A story whose spec_version is missing or unreadable counts as v0.0 rather
1794
- # than being skipped: it has demonstrably never synced, and putting the whole ledger above
1795
- # it is the same answer enumerating from the document would give.
1796
- floors = [vtuple(s["spec_version"]) for s in open_stories]
1797
- readable = [s for s in open_stories if re.match(r'^v?\d', s["spec_version"] or "")]
1798
- # A BACKLOG THAT IS ENTIRELY CLOSED IS NOT A BACKLOG THAT NEVER SYNCED. Reading the floor
1799
- # from open stories alone made the last story closing look exactly like a feature nobody has
1800
- # ever sliced: no floor took the enumerate-the-whole-document branch below, so one idle
1801
- # re-run after finishing a feature raised a fresh story for every requirement in the spec —
1802
- # each one reported as work the contract had moved on, when nothing had moved at all. A
1803
- # CLOSED story carries a stamp too, and it records which version its work was measured
1804
- # against. CANCELLED ones are excluded: abandoned work delivered nothing and settles nothing,
1805
- # so it establishes no floor and leaves its requirements to be sliced again.
1806
- closed_stories = [s for s in stories if s["status"] == "closed"]
1807
- floor_source = "open" if floors else ("closed" if closed_stories else "none")
1808
- floor = (min(floors) if floors
1809
- else (min(vtuple(s["spec_version"]) for s in closed_stories) if closed_stories
1810
- else None))
1811
-
1812
- latest = rows[-1] if rows else None
1813
- out = {"ok": True, "feature_dir": fd, "has_ledger": has_ledger,
1814
- "row_count": len(rows), "ledger_empty": len(rows) == 0,
1815
- "rows": rows,
1816
- "latest_version": latest["version"] if latest else "",
1817
- "latest_change": latest["change"] if latest else "",
1818
- "latest_ids_csv": ", ".join(latest["requirements"]) if latest else "",
1819
- "spec_ids_csv": ", ".join(declared), "spec_id_count": len(declared),
1820
- "story_count": len(stories), "open_count": len(open_stories),
1821
- "unversioned_open": len(open_stories) - len(readable)}
1822
-
1823
- if not has_ledger:
1824
- out.update(ok=False, error="spec.md has no '## Change History' table. That table is "
1825
- "where a story learns a requirement it cites has moved, so a spec without "
1826
- "one cannot be kept in step with its backlog.")
1827
- print(json.dumps(out)); sys.stderr.write(out["error"] + "\n"); sys.exit(2)
1828
-
1829
- if not rows:
1830
- # An empty ledger is not corruption — it is a spec that has never been versioned, which
1831
- # means specify has not finished with it. Reported, not raised: the caller warns and
1832
- # stops, because there is no "since" to sync from and inventing one would raise a
1833
- # backlog against a document nobody has committed to.
1834
- out.update(work_csv="", work_count=0, work_source="none", span_versions_csv="",
1835
- gap=False, removed_csv="",
1836
- work_note="the Change History table carries no version row")
1837
- print(json.dumps(out)); sys.exit(0)
1838
-
1839
- if floor is None:
1840
- # E42 — nothing has moved because nothing has synced. Enumerate from the document.
1841
- source, span = "spec", []
1842
- candidates = list(declared)
1843
- note = ("nothing has synced against this spec yet — no open story records a version and "
1844
- "nothing has closed against it — so there is no 'since' to read from: the work "
1845
- "set is every requirement the document declares, and the per-id rules then "
1846
- "create only what has no story")
1847
- else:
1848
- span = [r for r in rows if vtuple(r["version"]) > floor]
1849
- seen, candidates = set(), []
1850
- for r in span:
1851
- for rid in r["requirements"]:
1852
- if rid not in seen: seen.add(rid); candidates.append(rid)
1853
- candidates.sort(key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
1854
- source = "ledger"
1855
- note = ("%d version row(s) above the oldest %s story's v%d.%d: %s"
1856
- % (len(span), floor_source, floor[0], floor[1],
1857
- ", ".join(r["version"] for r in span) or "none"))
1858
-
1859
- # THE SPAN IS A PREFILTER; STALENESS IS PER ID. The floor is the oldest version any open
1860
- # story sits at, and a story whose requirements never move sits there forever — Order-001
1861
- # holding v1.0 keeps v1.1 and v1.2 in the span long after everything they named has been
1862
- # synced. Reading the span alone therefore re-offered the same requirements on every run,
1863
- # and each re-offer is a model call rewriting a story that was already in step.
1864
- #
1865
- # So the span says which ids are WORTH CHECKING and this says which have actually moved
1866
- # under someone: an id is stale when it has no open story at all, or when some open story
1867
- # citing it is stamped BELOW the newest version that touched it. An id whose every open
1868
- # story is at or above that version is in step, whatever the floor says.
1869
- moved_at = {}
1870
- for r in rows:
1871
- for rid in r["requirements"]:
1872
- v = vtuple(r["version"])
1873
- if v > moved_at.get(rid, (0, 0)): moved_at[rid] = v
1874
-
1875
- def carried_on(story_id, rid, at):
1876
- """Has a follow-on already taken this requirement off that story's hands, at or above
1877
- the version that moved it. The `follows` link is the whole record of that hand-off."""
1878
- return any(s["follows"] == story_id and rid in s["cites"]
1879
- and vtuple(s["spec_version"]) >= at for s in open_stories)
1880
-
1881
- work, in_step = [], []
1882
- for rid in candidates:
1883
- holders = [s for s in open_stories if rid in s["cites"]]
1884
- at = moved_at.get(rid, (0, 0))
1885
- behind = []
1886
- for s in holders:
1887
- if vtuple(s["spec_version"]) >= at: continue
1888
- # AN IN-FLIGHT STORY CANNOT BE BROUGHT UP TO DATE, BY DESIGN. It is never rewritten
1889
- # (§5.5), so its spec_version can never rise, so reading it as a staleness signal
1890
- # gives a signal that never clears — and a sync that mints a fresh follow-on for the
1891
- # same requirement on every run. What clears it is the follow-on itself: once one
1892
- # exists carrying this id at or above the version that moved it, the hand-off has
1893
- # happened and the original is history in progress, not unfinished business.
1894
- if s["in_flight"] and carried_on(s["id"], rid, at): continue
1895
- behind.append(s["id"])
1896
- # A CLOSED STORY STAMPED AT OR ABOVE THE VERSION THAT LAST MOVED THIS REQUIREMENT
1897
- # DELIVERED THE WORDING THE SPEC ASKS FOR TODAY, so the requirement is FINISHED rather
1898
- # than unsliced. Reading "no open story cites it" as work on its own could not tell those
1899
- # two apart, which is what let a fully delivered feature re-raise its entire backlog.
1900
- # A closed story stamped BELOW that version is the other case and stays work: the
1901
- # contract moved after it shipped, so what shipped is no longer what the spec asks for,
1902
- # and backlog-plan names the closed story it is re-opening against.
1903
- delivered = any(rid in s["cites"] and vtuple(s["spec_version"]) >= at
1904
- for s in closed_stories)
1905
- (in_step if ((holders and not behind) or (not holders and delivered))
1906
- else work).append(rid)
1907
-
1908
- # A row names what a version TOUCHED, and that includes ids it REMOVED. A removed
1909
- # requirement has no wording left to write a story from, so it leaves the work set — but it
1910
- # is reported, because every open story citing one is orphaned and that is a decision for a
1911
- # person, not a default (E1).
1912
- removed = [i for i in work if i not in declared]
1913
- work = [i for i in work if i in declared]
1914
-
1915
- out.update(work_csv=", ".join(work), work_count=len(work), work_source=source,
1916
- work_note=note, work_any=bool(work),
1917
- candidates_csv=", ".join(candidates), candidate_count=len(candidates),
1918
- in_step_csv=", ".join(in_step), in_step_count=len(in_step),
1919
- floor_source=floor_source,
1920
- span_versions_csv=", ".join(r["version"] for r in span),
1921
- span_count=len(span), gap=len(span) > 1,
1922
- removed_csv=", ".join(removed), removed_count=len(removed),
1923
- synced_floor=("v%d.%d" % floor) if floor else "")
1924
- print(json.dumps(out))
1925
- PY
1926
- }
1927
-
1928
- # ─────────────────────────────── slice-mode ────────────────────────────────
1929
- # Which slicing strategy the launch line asked for, and what is left of the input once that
1930
- # word is taken out of it. Its own op rather than a `condition:` in the workflow, because
1931
- # the engine compares a whole rendered value: `{{USER_INPUT}} == single-run` only matches an
1932
- # input carrying the strategy and no guidance at all, and `sync-stories single-run keep the
1933
- # payment rules together` — the form the launch line documents — would silently slice by
1934
- # default while looking as though it had been told not to.
1935
- #
1936
- # ONLY THE FIRST WORD IS A STRATEGY. Guidance is free prose, and "keep the single sign-on
1937
- # requirements in one story" is a slicing instruction with the token sitting in the middle
1938
- # of it. Reading the token anywhere in the string would turn that sentence into the opposite
1939
- # of what it asks, with nothing to say so.
1940
- cmd_slice_mode() {
1941
- local input=""
1942
- while [ $# -gt 0 ]; do
1943
- case "$1" in
1944
- --input) input="$2"; shift 2 ;;
1945
- --help|-h) echo "slice-mode [--input <text>] -> {strategy, guidance, note}"; return 0 ;;
1946
- *) echo "slice-mode: unknown arg '$1'" >&2; return 1 ;;
1947
- esac
1948
- done
1949
- AW_INPUT="$input" python3 - <<'PY'
1950
- import json, os, re
1951
- raw = (os.environ.get("AW_INPUT", "") or "").strip()
1952
-
1953
- # `single-run` is the spelling the launch line documents; the others are what people write
1954
- # when they mean it. `auto` is the default and is accepted explicitly so a launch line can
1955
- # say so — a strategy nobody can name is one nobody can turn off.
1956
- TOKENS = {"single-run": "single", "single-story": "single", "single": "single",
1957
- "one-story": "single", "auto": "auto", "per-slice": "auto", "default": "auto"}
1958
-
1959
- strategy, guidance = "auto", raw
1960
- m = re.match(r'^([A-Za-z][A-Za-z0-9_-]*)\s*[:,]?\s*(.*)$', raw, re.S)
1961
- if m and m.group(1).lower() in TOKENS:
1962
- strategy = TOKENS[m.group(1).lower()]
1963
- guidance = m.group(2).strip()
1964
-
1965
- if strategy == "single":
1966
- note = ("one story was asked for (single-run): the whole work set goes into a single "
1967
- "slice unless it is too heavy to be picked up as one piece of work, in which "
1968
- "case it is split along its dependencies and the split is reported")
1969
- else:
1970
- note = "the slicing is decided from the requirements themselves"
1971
-
1972
- print(json.dumps({"ok": True, "strategy": strategy, "guidance": guidance,
1973
- "single": strategy == "single", "note": note,
1974
- "raw": raw, "tokens": sorted(TOKENS)}))
1975
- PY
1976
- }
1977
-
1978
- # ─────────────────────────────── backlog-plan ──────────────────────────────
1979
- # The grouping is a judgment; everything that follows from it is arithmetic. This op does
1980
- # the arithmetic, in two modes over the same inputs so the two passes cannot disagree about
1981
- # what a group contains:
1982
- #
1983
- # scan which OPEN stories already stand in front of a group's requirements — the
1984
- # candidates the author is asked about, and the wording of that question.
1985
- # plan given the author's answer, the flat list of story files to write: one entry per
1986
- # file, each already carrying its id, its path, and the ids it must end up citing.
1987
- #
1988
- # THE FOUR CASES (§5.4) resolve per group, not per requirement, because a group IS the story:
1989
- #
1990
- # no open story cites anything in the group -> create
1991
- # an open story does, no stories/<ID>/ yet -> update in place (asked)
1992
- # an open story does, a story plan already exists -> mint a follow-on (asked); the
1993
- # original is never rewritten (§5.5)
1994
- # the author declines -> that story is left alone AND the ids
1995
- # it covers leave the group; whatever
1996
- # remains becomes a new story
1997
- #
1998
- # The last line is the one worth reading twice. Declining does not abandon the group — it
1999
- # withdraws the requirements that story is already carrying and creates a story for the
2000
- # rest. A group of three where one id has a story the author will not touch still delivers
2001
- # the other two.
2002
- #
2003
- # ONE GROUP, ONE ABSORBING STORY. Where the author approves two stories in the same group,
2004
- # the first by id takes the group's unclaimed requirements and the others keep exactly the
2005
- # ids they already cite. Two stories both claiming the same new requirement is not a state
2006
- # the completeness rule can read.
2007
- #
2008
- # IN FLIGHT IS TESTED PER STORY, NOT ONLY ON THE FIRST. An approved story that already has
2009
- # a story with a story plan is never rewritten wherever it sits in the group — it gets its own follow-on
2010
- # carrying what it covers, exactly as it would if it were the only story there.
2011
- #
2012
- # AND THE ORDER. Plan mode also resolves the dependency edges the grouping declared between
2013
- # slices, folds in the `depends_on:` an updated story already carries, breaks any cycle, and
2014
- # lays the result out in WAVES: wave N holds the stories whose every dependency sits below N,
2015
- # so one wave is a set with no ordering between its members — they may be picked up in
2016
- # parallel. The plan comes back sorted by wave, which is also the order the stories are
2017
- # written in, so meeting the cap costs the LAST wave rather than an arbitrary slice of the
2018
- # backlog. See the block above the wave computation for why `follows:` is not an edge.
2019
- cmd_backlog_plan() {
2020
- local fd_arg="" groups="" approved="" work="" version="" mode="scan" cap="" strategy="" ceiling=""
2021
- while [ $# -gt 0 ]; do
2022
- case "$1" in
2023
- --feature-dir) fd_arg="$2"; shift 2 ;;
2024
- --groups) groups="$2"; shift 2 ;;
2025
- --approved) approved="$2"; shift 2 ;;
2026
- --work) work="$2"; shift 2 ;;
2027
- --version) version="$2"; shift 2 ;;
2028
- --cap) cap="$2"; shift 2 ;;
2029
- --strategy) strategy="$2"; shift 2 ;;
2030
- --ceiling) ceiling="$2"; shift 2 ;;
2031
- --plan) mode="plan"; shift ;;
2032
- --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 ;;
2033
- *) echo "backlog-plan: unknown arg '$1'" >&2; return 1 ;;
2034
- esac
2035
- done
2036
- local repo_root; repo_root=$(get_repo_root)
2037
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "backlog-plan: no feature set and no --feature-dir" >&2; return 1; }
2038
- 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'
2039
- import json, os, re, sys
2040
- fd = os.environ["AW_FD"]; mode = os.environ.get("AW_MODE", "scan")
2041
- sdir = os.path.join(fd, "stories")
2042
- feature = os.path.basename(fd.rstrip("/\\"))
2043
-
2044
- def bail(msg, code=2):
2045
- print(json.dumps({"ok": False, "error": msg}))
2046
- sys.stderr.write(msg + "\n"); sys.exit(code)
2047
-
2048
- def rids(s):
2049
- seen, out = set(), []
2050
- for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
2051
- if t not in seen: seen.add(t); out.append(t)
2052
- return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2053
-
2054
- def sids(s):
2055
- """Story ids out of a csv, a JSON list or a YAML inline list. A story id is a feature
2056
- name and a number; a template placeholder (`[]`, `[STORY-ID]`) is neither, and must
2057
- never become a dependency edge nobody can resolve."""
2058
- seen, out = set(), []
2059
- for t in re.split(r'[,\s\[\]"\']+', s or ""):
2060
- t = t.strip()
2061
- if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
2062
- seen.add(t); out.append(t)
2063
- return out
2064
-
2065
- def wave_of(s):
2066
- """A story's declared wave. Absent — a story written before the field existed — reads
2067
- as 1, the conservative floor: nothing is KNOWN to have to land before it."""
2068
- m = re.match(r'^\s*(\d+)\s*$', (s or "").strip())
2069
- return int(m.group(1)) if m else 1
2070
-
2071
- def names(s):
2072
- """Story ids out of a csv or a JSON list. A multi-select answer arrives as the engine's
2073
- JSON rendering of a list, so both spellings have to read the same."""
2074
- seen, out = set(), []
2075
- for t in re.split(r'[,\s\[\]"\']+', s or ""):
2076
- t = t.strip()
2077
- if t and t not in seen: seen.add(t); out.append(t)
2078
- return out
2079
-
2080
- def parse_fm(text):
2081
- """Frontmatter as a flat dict. A value may be inline (`exercises: [FR-001, BR-002]`) or
2082
- a YAML block list under an empty key — both are valid YAML and a hand edit produces
2083
- either, so a parser that read only the inline form saw a perfectly good story as citing
2084
- nothing, and a story citing nothing is invisible to every gate that exists to protect
2085
- it. Block items are folded into the inline shape so every caller stays regex-simple."""
2086
- m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
2087
- if not m: return {}
2088
- fm, key, block = {}, None, set()
2089
- for line in m.group(1).split("\n"):
2090
- km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
2091
- if km:
2092
- key = km.group(1); fm[key] = km.group(2).strip(); continue
2093
- im = re.match(r'^\s+-\s+(.*)$', line) if key else None
2094
- if im and (key in block or not fm[key]):
2095
- block.add(key)
2096
- fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
2097
- return fm
2098
-
2099
- # ---- the backlog -----------------------------------------------------------------
2100
- stories, used_numbers = [], []
2101
- if os.path.isdir(sdir):
2102
- for name in sorted(os.listdir(sdir)):
2103
- if not name.endswith(".md"): continue
2104
- p = os.path.join(sdir, name)
2105
- if not os.path.isfile(p): continue
2106
- fm = parse_fm(open(p, encoding="utf-8", errors="replace").read())
2107
- sid = fm.get("id") or name[:-3]
2108
- # Numbers come from the FILENAME as well as the id, and from CLOSED and CANCELLED
2109
- # stories as well as open ones. An id is permanent once minted: re-issuing the
2110
- # number of a story that shipped re-points its execution artefacts at new work.
2111
- for cand in (sid, name[:-3]):
2112
- m = re.search(r'-(\d+)$', cand)
2113
- if m: used_numbers.append(int(m.group(1)))
2114
- stories.append({"id": sid, "status": (fm.get("status") or "open").strip().lower(),
2115
- "title": (fm.get("title") or "").strip().strip("[]"),
2116
- "spec_version": (fm.get("spec_version") or "").strip(),
2117
- "cites": rids(fm.get("exercises", "")),
2118
- "wave": wave_of(fm.get("wave", "")),
2119
- "deps": sids(fm.get("depends_on", "")),
2120
- "in_flight": os.path.isdir(os.path.join(sdir, sid)),
2121
- "path": p})
2122
- open_stories = [s for s in stories if s["status"] == "open"]
2123
- # Closed and cancelled stories hold no requirement — completeness is "no OPEN stories" —
2124
- # but they are the reason a create can look like duplicated work, so they are read in
2125
- # order to say so. See the create branch below.
2126
- done_stories = [s for s in stories if s["status"] != "open"]
2127
- by_id = {s["id"]: s for s in stories}
2128
- _next = [max(used_numbers) + 1 if used_numbers else 1]
2129
-
2130
- def mint():
2131
- n = _next[0]; _next[0] += 1
2132
- return "%s-%03d" % (feature, n)
2133
-
2134
- # ---- the groups ------------------------------------------------------------------
2135
- raw = (os.environ.get("AW_GROUPS") or "").strip()
2136
- try:
2137
- parsed = json.loads(raw) if raw else []
2138
- except Exception as e:
2139
- bail("backlog-plan: --groups is not valid JSON (%s)" % e)
2140
- if isinstance(parsed, dict): parsed = parsed.get("groups") or []
2141
- if not isinstance(parsed, list):
2142
- bail("backlog-plan: --groups must be a JSON list of groups")
2143
-
2144
- # Ids are read from the group's OWN requirements field, never scraped from its prose. A
2145
- # rationale that mentions FR-004 as the reason two others belong together would otherwise
2146
- # pull a third requirement into the story, and nothing downstream would question it.
2147
- work = rids(os.environ.get("AW_WORK", ""))
2148
- groups, dropped = [], []
2149
- for i, g in enumerate(parsed):
2150
- if not isinstance(g, dict): continue
2151
- src = g.get("requirements")
2152
- if isinstance(src, list): src = ", ".join(str(x) for x in src)
2153
- ids = rids(src if src is not None else g.get("requirements_csv", ""))
2154
- if work:
2155
- outside = [x for x in ids if x not in work]
2156
- dropped += outside
2157
- ids = [x for x in ids if x in work]
2158
- if not ids: continue
2159
- # `depends_on` names OTHER GROUPS by key, or open stories already in the backlog by
2160
- # id. Kept as raw tokens: a group key only becomes a story id once the plan has minted
2161
- # one, so resolving here would have to guess at a name that does not exist yet.
2162
- dep_src = g.get("depends_on")
2163
- if dep_src is None: dep_src = g.get("depends_on_csv")
2164
- if isinstance(dep_src, list): dep_src = ", ".join(str(x) for x in dep_src)
2165
- deps_raw = [x for x in re.split(r'[,\s\[\]"\']+', str(dep_src or "")) if x.strip()]
2166
- groups.append({"key": g.get("id") or ("g%d" % (i + 1)),
2167
- "title": (g.get("title") or "").strip(),
2168
- "rationale": (g.get("rationale") or "").strip(),
2169
- "complexity": (g.get("complexity") or "").strip().lower(),
2170
- "deps_raw": deps_raw,
2171
- "ids": ids, "ids_csv": ", ".join(ids)})
2172
-
2173
- # Every work-set id must land in exactly one group. One the model left out gets no story
2174
- # and nothing says so; one it repeated gets two, each claiming the same requirement.
2175
- placed = [x for g in groups for x in g["ids"]]
2176
- repeated = sorted({x for x in placed if placed.count(x) > 1})
2177
-
2178
- # A REPEATED ID IS RESOLVED HERE, not merely reported. Two groups both claiming one
2179
- # requirement become two stories both claiming it, and "does this requirement still have
2180
- # open stories" — the completeness rule — then answers for work that was never really
2181
- # duplicated. The grouping is a judgment; the arithmetic over it is not. So the id stays in
2182
- # the FIRST group that claimed it and leaves the rest, which is deterministic (the same
2183
- # grouping always yields the same slices) and loses no coverage, because the id still
2184
- # reaches exactly one story. repeated_notice reports what that touched.
2185
- if repeated:
2186
- seen_ids = set()
2187
- for g in groups:
2188
- keep_ids = [x for x in g["ids"] if x not in seen_ids]
2189
- seen_ids.update(keep_ids)
2190
- g["ids"], g["ids_csv"] = keep_ids, ", ".join(keep_ids)
2191
- groups = [g for g in groups if g["ids"]]
2192
- placed = [x for g in groups for x in g["ids"]]
2193
-
2194
- missed = [x for x in work if x not in placed]
2195
-
2196
- def candidates_of(g):
2197
- """Open stories standing in front of this group, each with what it already covers OF
2198
- THIS GROUP — the intersection, not its whole citation list, because that is what the
2199
- author is being asked to move."""
2200
- out = []
2201
- for s in open_stories:
2202
- shared = [x for x in g["ids"] if x in s["cites"]]
2203
- if not shared: continue
2204
- out.append({"story": s["id"], "title": s["title"], "in_flight": s["in_flight"],
2205
- "spec_version": s["spec_version"], "cites_csv": ", ".join(s["cites"]),
2206
- "shared_csv": ", ".join(shared), "shared": shared,
2207
- "group": g["key"], "group_title": g["title"], "group_ids_csv": g["ids_csv"],
2208
- "action": "followon" if s["in_flight"] else "update"})
2209
- return sorted(out, key=lambda c: c["story"])
2210
-
2211
- base = {"ok": True, "feature": feature, "feature_dir": fd, "stories_dir": sdir,
2212
- "group_count": len(groups), "groups": groups,
2213
- "work_csv": ", ".join(work),
2214
- "unplaced_csv": ", ".join(missed), "unplaced_count": len(missed),
2215
- "repeated_csv": ", ".join(repeated), "repeated_count": len(repeated),
2216
- "dropped_csv": ", ".join(sorted(set(dropped))),
2217
- "groups_digest": "\n".join(
2218
- " [%s] %s\n requirements: %s%s%s"
2219
- % (g["key"], g["title"] or "(untitled)", g["ids_csv"],
2220
- ("\n stands behind: " + ", ".join(g["deps_raw"])) if g["deps_raw"] else "",
2221
- ("\n " + g["rationale"]) if g["rationale"] else "")
2222
- for g in groups) or " (no groups)"}
2223
-
2224
- # THE STRATEGY IS THE AUTHOR'S; THE ARITHMETIC OVER IT IS NOT. `single` asked for one story
2225
- # covering the whole work set, and the grouping may still have returned several — because a
2226
- # slice nobody can pick up is not a story, and splitting it is the honest answer. So the
2227
- # split is REPORTED rather than refused or hidden: quietly handing six stories to someone
2228
- # who asked for one is the kind of silent disobedience that makes an option not worth having.
2229
- strategy = (os.environ.get("AW_STRATEGY") or "").strip().lower() or "auto"
2230
- try:
2231
- ceiling = int((os.environ.get("AW_CEILING") or "").strip() or 8)
2232
- except ValueError:
2233
- ceiling = 8
2234
- oversized = [g["key"] for g in groups if len(g["ids"]) > ceiling]
2235
- split_note, split_count = "", 0
2236
- if strategy == "single" and len(groups) > 1:
2237
- split_count = len(groups)
2238
- split_note = ("one story was asked for and the work set was too heavy for one: it is "
2239
- "split into %d, in dependency order — %s" % (len(groups), "; ".join(
2240
- "[%s] %s (%s)" % (g["key"], g["title"] or "untitled", g["ids_csv"])
2241
- for g in groups)))
2242
- elif strategy == "single" and len(groups) == 1:
2243
- split_note = ("one story was asked for and one covers the whole work set: %s"
2244
- % groups[0]["ids_csv"])
2245
- base.update(strategy=strategy, ceiling=ceiling, split_count=split_count,
2246
- split_note=split_note, oversized_csv=", ".join(oversized))
2247
-
2248
- if mode == "scan":
2249
- cands = [c for g in groups for c in candidates_of(g)]
2250
- lines = []
2251
- for c in cands:
2252
- lines.append(
2253
- " %s (%s)\n"
2254
- " group [%s] %s covers %s\n"
2255
- " this story already covers %s of it, and cites %s in total\n"
2256
- " %s"
2257
- % (c["story"], c["title"] or "untitled",
2258
- c["group"], c["group_title"] or "", c["group_ids_csv"],
2259
- c["shared_csv"], c["cites_csv"],
2260
- ("ALREADY IN FLIGHT (story plan exists) — picking it mints a FOLLOW-ON "
2261
- "carrying the change; this story is never rewritten"
2262
- if c["in_flight"] else
2263
- "not started (no story plan yet) — picking it rewrites this story to "
2264
- "cover the whole group")))
2265
- base.update(candidates=cands, candidate_count=len(cands),
2266
- candidate_ids=[c["story"] for c in cands],
2267
- candidate_csv=", ".join(c["story"] for c in cands),
2268
- any_candidates=bool(cands),
2269
- ask_digest="\n".join(lines) or " (no existing story covers any of these)")
2270
- print(json.dumps(base)); sys.exit(0)
2271
-
2272
- # ---- plan ------------------------------------------------------------------------
2273
- approved = set(names(os.environ.get("AW_OK", "")))
2274
- version = (os.environ.get("AW_VER") or "").strip()
2275
- plan, skipped, notes = [], [], []
2276
- for g in groups:
2277
- cands = candidates_of(g)
2278
- declined = [c for c in cands if c["story"] not in approved]
2279
- # A declined story keeps its requirements: the author said do not disturb it, and
2280
- # raising a second story over the same ids disturbs it just as thoroughly.
2281
- held = {x for c in declined for x in c["shared"]}
2282
- skipped += [c["story"] for c in declined]
2283
- remaining = [x for x in g["ids"] if x not in held]
2284
- taken = [c for c in cands if c["story"] in approved]
2285
-
2286
- if taken:
2287
- # ONE APPROVED STORY ABSORBS THE GROUP'S UNCLAIMED REQUIREMENTS — the first by id.
2288
- # Every OTHER approved story keeps exactly the ids it already cites, so those ids
2289
- # are RESERVED out of what the carrier gains: two open stories both claiming one
2290
- # requirement is not a state the completeness rule can read, and the run would have
2291
- # created that state itself rather than merely reporting it.
2292
- carrier = taken[0]; s = by_id[carrier["story"]]
2293
- reserved = {x for c in taken[1:] for x in c["shared"]}
2294
- gained = [x for x in remaining if x not in reserved]
2295
- if carrier["in_flight"]:
2296
- # §5.5 — the original stays open and unchanged, and closes having delivered
2297
- # what it was written to deliver. The follow-on carries the remainder.
2298
- carry = [x for x in g["ids"] if x in s["cites"] or x in gained]
2299
- carry = [x for x in carry if x not in held]
2300
- sid = mint()
2301
- plan.append({"action": "followon", "story": sid, "follows": carrier["story"],
2302
- "path": os.path.join(sdir, sid + ".md"),
2303
- "requirements_csv": ", ".join(carry), "existing_csv": "",
2304
- "prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
2305
- "prior_path": s["path"],
2306
- "group": g["key"], "group_title": g["title"],
2307
- "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2308
- "rationale": g["rationale"], "spec_version": version})
2309
- else:
2310
- keep = sorted(set(s["cites"]) | set(gained),
2311
- key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2312
- plan.append({"action": "update", "story": s["id"], "follows": "",
2313
- "path": s["path"],
2314
- "requirements_csv": ", ".join(keep),
2315
- "existing_csv": ", ".join(s["cites"]),
2316
- "prior_csv": ", ".join(s["cites"]), "prior_title": s["title"],
2317
- "prior_path": s["path"],
2318
- "group": g["key"], "group_title": g["title"],
2319
- "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2320
- "rationale": g["rationale"], "spec_version": version})
2321
- carrier_sid = plan[-1]["story"]
2322
- for c in taken[1:]:
2323
- s2 = by_id[c["story"]]
2324
- if c["in_flight"]:
2325
- # AN IN-FLIGHT STORY IS NEVER REWRITTEN, whatever position it holds in the
2326
- # group. Its story plan was derived from this body, and
2327
- # rewriting the body leaves the three disagreeing with nothing to say so —
2328
- # which is the whole reason §5.5 exists. Being second in a group is not a
2329
- # different situation from being first, so it gets the same answer: its own
2330
- # follow-on, carrying the ids it already covers of this group.
2331
- #
2332
- # That link is also what clears its staleness. ledger-read discounts an
2333
- # in-flight story once an open story whose `follows` names it cites the same
2334
- # id; skipping it instead would leave it stale forever and re-offered on
2335
- # every run.
2336
- carry2 = [x for x in c["shared"] if x not in held]
2337
- if not carry2:
2338
- notes.append("%s was approved and is in flight, but every requirement "
2339
- "it covers of group [%s] is held by a story left "
2340
- "untouched; nothing to carry" % (c["story"], g["key"]))
2341
- continue
2342
- sid2 = mint()
2343
- notes.append("%s was approved and is IN FLIGHT, so it is not rewritten; "
2344
- "%s carries %s forward from it, while %s takes the group's "
2345
- "unclaimed requirements"
2346
- % (c["story"], sid2, ", ".join(carry2), carrier_sid))
2347
- plan.append({"action": "followon", "story": sid2, "follows": c["story"],
2348
- "path": os.path.join(sdir, sid2 + ".md"),
2349
- "requirements_csv": ", ".join(carry2), "existing_csv": "",
2350
- "prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
2351
- "prior_path": s2["path"],
2352
- "group": g["key"], "group_title": g["title"],
2353
- "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2354
- "rationale": g["rationale"], "spec_version": version})
2355
- continue
2356
- notes.append("%s was approved but %s takes the group's unclaimed requirements; "
2357
- "%s is refreshed against the current spec without gaining ids"
2358
- % (c["story"], carrier_sid, c["story"]))
2359
- plan.append({"action": "update", "story": s2["id"], "follows": "",
2360
- "path": s2["path"],
2361
- "requirements_csv": ", ".join(s2["cites"]),
2362
- "existing_csv": ", ".join(s2["cites"]),
2363
- "prior_csv": ", ".join(s2["cites"]), "prior_title": s2["title"],
2364
- "prior_path": s2["path"],
2365
- "group": g["key"], "group_title": g["title"],
2366
- "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2367
- "rationale": g["rationale"], "spec_version": version})
2368
- elif remaining:
2369
- sid = mint()
2370
- # A REQUIREMENT WHOSE ONLY STORIES ARE CLOSED RE-ENTERS THE WORK SET, and that is
2371
- # right: a version row above the closed story's stamp means the contract moved
2372
- # AFTER it shipped, so what shipped is no longer what the spec asks for. It reads
2373
- # as duplicated work to anyone who remembers delivering it, so the run names the
2374
- # story it is re-opening against rather than leaving them to find it.
2375
- shipped = ["%s (%s)" % (d["id"], ", ".join(x for x in remaining if x in d["cites"]))
2376
- for d in done_stories if any(x in d["cites"] for x in remaining)]
2377
- if shipped:
2378
- notes.append("%s covers requirements already delivered by %s — the spec moved "
2379
- "after those closed, so this is the change, not a repeat"
2380
- % (sid, "; ".join(shipped)))
2381
- plan.append({"action": "create", "story": sid, "follows": "",
2382
- "path": os.path.join(sdir, sid + ".md"),
2383
- "requirements_csv": ", ".join(remaining), "existing_csv": "",
2384
- "prior_csv": "", "prior_title": "", "prior_path": "",
2385
- "group": g["key"], "group_title": g["title"],
2386
- "group_ids_csv": g["ids_csv"], "complexity": g["complexity"],
2387
- "rationale": g["rationale"], "spec_version": version})
2388
- else:
2389
- notes.append("group [%s] (%s) is entirely covered by stories left untouched; "
2390
- "nothing to write" % (g["key"], g["ids_csv"]))
2391
-
2392
- # ---- waves: the order these stories can actually be picked up in -------------------
2393
- # The grouping declared which slices stand behind which; turning that into an order is
2394
- # arithmetic, so it happens here and not in the prompt — the same grouping always yields
2395
- # the same waves.
2396
- #
2397
- # A WAVE IS AN ELIGIBILITY LEVEL, NOT A SCHEDULE. Wave N means every story this one depends
2398
- # on sits in a wave below N. Two stories in the SAME wave therefore have no dependency
2399
- # between them and may be picked up in parallel; it does not say they start together, and
2400
- # nothing waits for a whole wave to finish. That is also why the number is not a priority:
2401
- # wave 1 is "nothing blocks this", not "do this first".
2402
- #
2403
- # THE EDGE SET IS A UNION of what the grouping declared and what an updated story already
2404
- # carries in `depends_on:`. The sync sees one feature's spec; an edge someone added by hand
2405
- # may name work it has no view of, and dropping it would silently un-order real work. Both
2406
- # are claims about CODE ordering, and the union is exactly what the story ends up citing —
2407
- # so the wave and the frontmatter cannot disagree about what stands in front of what.
2408
- #
2409
- # `follows:` is NOT an edge. A follow-on carries the remainder of a story in flight, and the
2410
- # remainder is often buildable alongside it — §5.5 asks the author to say in Notes which
2411
- # half has to land first. Making it an implicit edge would put an ordering in the waves that
2412
- # no frontmatter field explains.
2413
- entries_of, by_story = {}, {}
2414
- for e in plan:
2415
- entries_of.setdefault(e["group"], []).append(e["story"]); by_story[e["story"]] = e
2416
- preds = {e["story"]: set() for e in plan}
2417
- external, unresolved = {}, [] # stories outside this run, at the wave they declare
2418
-
2419
- for g in groups:
2420
- tgt = entries_of.get(g["key"], [])
2421
- if not tgt: continue # the group wrote nothing; its edges order nothing
2422
- for tok in g["deps_raw"]:
2423
- tok = tok.strip()
2424
- if not tok or tok == g["key"]: continue
2425
- if tok in entries_of:
2426
- src = entries_of[tok]
2427
- elif tok in by_id and by_id[tok]["status"] == "open":
2428
- external[tok] = by_id[tok]["wave"]; src = [tok]
2429
- else:
2430
- unresolved.append("%s (named by group [%s])" % (tok, g["key"])); continue
2431
- for x in tgt: preds[x].update(s for s in src if s != x)
2432
-
2433
- for e in plan:
2434
- if e["action"] != "update": continue
2435
- for d in by_id.get(e["story"], {}).get("deps", []):
2436
- if d == e["story"] or d in preds[e["story"]]: continue
2437
- if d not in preds: external[d] = by_id[d]["wave"] if d in by_id else 1
2438
- preds[e["story"]].add(d)
2439
-
2440
- # A CYCLE IS REJECTED AT AUTHORING TIME (§6.1), and this is where it would be authored.
2441
- # Depth-first over a sorted node order, dropping the one edge that closes each cycle: only
2442
- # edges that actually close one are lost, and the choice is deterministic — the same
2443
- # grouping always breaks the same edge. Reported, because a dropped edge was a real claim
2444
- # about ordering and the cycle it sat in is a fact about the slicing, not a glitch.
2445
- cycles, state = [], {}
2446
- def walk(node):
2447
- state[node] = 1
2448
- for m in sorted(preds.get(node, ())):
2449
- if state.get(m) == 1:
2450
- preds[node].discard(m); cycles.append("%s -> %s" % (node, m)); continue
2451
- if state.get(m) != 2 and m in preds: walk(m)
2452
- state[node] = 2
2453
- for node in sorted(preds):
2454
- if state.get(node) != 2: walk(node)
2455
-
2456
- # Longest path from a root, relaxed to a fixed point. A story with no dependency is wave 1,
2457
- # which is what puts every independent story in the same wave.
2458
- wave = dict(external)
2459
- for _ in range(len(preds) + 1):
2460
- moved = False
2461
- for node in sorted(preds):
2462
- w = 1 + max([wave.get(m, 1) for m in preds[node]] or [0])
2463
- if wave.get(node) != w: wave[node] = w; moved = True
2464
- if not moved: break
2465
- for e in plan:
2466
- e["wave"] = wave.get(e["story"], 1)
2467
- e["depends_on_csv"] = ", ".join(sorted(preds.get(e["story"], ())))
2468
-
2469
- # Written in wave order, so a run that meets the cap writes the stories that can be STARTED
2470
- # rather than an arbitrary prefix of them. The story id is not the order — the wave is.
2471
- plan.sort(key=lambda e: (e["wave"], e["story"]))
2472
- for i, e in enumerate(plan): e["seq"] = i + 1
2473
-
2474
- waves = {}
2475
- for e in plan: waves.setdefault(e["wave"], []).append(e["story"])
2476
- wave_digest = "\n".join(" wave %d: %s" % (w, ", ".join(waves[w]))
2477
- for w in sorted(waves)) or " (nothing to write)"
2478
- if unresolved:
2479
- notes.append("the grouping named dependencies matching no slice and no open story, so "
2480
- "they set no order: %s" % "; ".join(sorted(set(unresolved))))
2481
- if cycles:
2482
- notes.append("slices were declared to depend on each other in a cycle; the edge closing "
2483
- "each one is dropped, because a backlog with no order at all is worse than "
2484
- "one missing an edge someone can re-add: %s" % ", ".join(cycles))
2485
-
2486
- # THE WRITE LOOP HAS A CEILING AND THE ENGINE DOES NOT SAY WHEN IT HITS IT — `over:` stops
2487
- # at max_iterations with no message, so plan entries past it are simply never written and
2488
- # the run reports success. The caller passes its own ceiling here so the overflow is named
2489
- # while the story ids still exist to name. Nothing is dropped from the plan: the ids are
2490
- # reported and the entries stay, so raising the cap and re-running writes them.
2491
- try:
2492
- cap = int((os.environ.get("AW_CAP") or "").strip() or 0)
2493
- except ValueError:
2494
- cap = 0
2495
- overflow = [e["story"] for e in plan[cap:]] if cap > 0 else []
2496
-
2497
- counts = {a: sum(1 for e in plan if e["action"] == a) for a in ("create", "update", "followon")}
2498
- base.update(plan=plan, plan_count=len(plan), any_plan=bool(plan),
2499
- cap=cap, overflow_csv=", ".join(overflow), overflow_count=len(overflow),
2500
- create_count=counts["create"], update_count=counts["update"],
2501
- followon_count=counts["followon"],
2502
- skipped_csv=", ".join(sorted(set(skipped))),
2503
- stories_csv=", ".join(e["story"] for e in plan),
2504
- wave_digest=wave_digest, wave_count=len(waves),
2505
- max_wave=(max(waves) if waves else 0),
2506
- cycle_csv=", ".join(cycles),
2507
- unresolved_deps_csv=", ".join(sorted(set(unresolved))),
2508
- notes=notes, notes_text="\n".join(" - " + n for n in notes),
2509
- plan_digest="\n".join(
2510
- " w%-3d %-9s %-16s %s%s%s"
2511
- % (e["wave"], e["action"], e["story"], e["requirements_csv"],
2512
- (" (follows %s)" % e["follows"]) if e["follows"] else "",
2513
- (" [after %s]" % e["depends_on_csv"]) if e["depends_on_csv"] else "")
2514
- for e in plan) or " (nothing to write)")
2515
- print(json.dumps(base))
2516
- PY
2517
- }
2518
-
2519
- # ─────────────────────────────── story-stamp ───────────────────────────────
2520
- # The story's frontmatter is the gate surface — `exercises:`, `spec_version:`, `status:`
2521
- # and `follows:` are what spec-coverage, check-stories, analyze-slice and close-story read,
2522
- # and none of them opens the body. So those fields are OBSERVED and written by the kit, not
2523
- # declared by the model that wrote the prose, for the same reason the Change History row is
2524
- # computed rather than announced: a model that drops one id from `exercises:` produces a
2525
- # story that reads perfectly and is invisible to every gate built to protect it. Silent,
2526
- # and in the one field where silence costs the most.
2527
- #
2528
- # Everything else in the block is left exactly as the author wrote it, and a key the file
2529
- # is missing is appended in template order rather than the block being rebuilt — a story
2530
- # carrying a work_item or a depends_on must not lose it to a stamp.
2531
- cmd_story_stamp() {
2532
- local fd_arg="" story="" ids="" version="" follows="" ids_given="" title="" deps="" wave=""
2533
- while [ $# -gt 0 ]; do
2534
- case "$1" in
2535
- --feature-dir) fd_arg="$2"; shift 2 ;;
2536
- --story) story="$2"; shift 2 ;;
2537
- --ids) ids="$2"; ids_given=1; shift 2 ;;
2538
- --version) version="$2"; shift 2 ;;
2539
- --follows) follows="$2"; shift 2 ;;
2540
- --title) title="$2"; shift 2 ;;
2541
- --depends-on) deps="$2"; shift 2 ;;
2542
- --wave) wave="$2"; shift 2 ;;
2543
- --help|-h) echo "story-stamp --story <id> [--ids <csv>] [--version <v>] [--follows <id>] [--title <t>] [--depends-on <csv>] [--wave <n>] [--feature-dir <p>]"; return 0 ;;
2544
- *) echo "story-stamp: unknown arg '$1'" >&2; return 1 ;;
2545
- esac
2546
- done
2547
- [ -n "$story" ] || { echo "story-stamp: --story is required" >&2; return 1; }
2548
- local repo_root; repo_root=$(get_repo_root)
2549
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-stamp: no feature set and no --feature-dir" >&2; return 1; }
2550
- AW_FD="$fd" AW_STORY="$story" AW_IDS="$ids" AW_IDS_GIVEN="$ids_given" \
2551
- AW_VER="$version" AW_FOLLOWS="$follows" AW_TITLE="$title" \
2552
- AW_DEPS="$deps" AW_WAVE="$wave" python3 - <<'PY'
2553
- import json, os, re, sys, datetime
2554
- fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
2555
- path = os.path.join(fd, "stories", story + ".md")
2556
-
2557
- def bail(msg, code=2):
2558
- print(json.dumps({"ok": False, "story": story, "path": path, "error": msg}))
2559
- sys.stderr.write(msg + "\n"); sys.exit(code)
2560
-
2561
- if not os.path.isfile(path):
2562
- bail("story-stamp: no story file at %s — the authoring step did not write it" % path)
2563
-
2564
- def rids(s):
2565
- seen, out = set(), []
2566
- for t in re.findall(r'\b(?:FR|BR)-\d+', s or ""):
2567
- if t not in seen: seen.add(t); out.append(t)
2568
- return sorted(out, key=lambda r: (r.split("-")[0], int(r.split("-")[1])))
2569
-
2570
- def sids(s):
2571
- """Story ids out of a csv or an inline list. A story id is a feature name and a number;
2572
- the template's placeholders are neither, and must not become a dependency edge."""
2573
- seen, out = set(), []
2574
- for t in re.split(r'[,\s\[\]"\']+', s or ""):
2575
- t = t.strip()
2576
- if re.match(r'^[A-Za-z][\w.]*-\d+$', t) and t not in seen:
2577
- seen.add(t); out.append(t)
2578
- return out
2579
-
2580
- text = open(path, encoding="utf-8", errors="replace").read()
2581
- m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
2582
- if m:
2583
- block, body = m.group(1), text[m.end():]
2584
- else:
2585
- # A model that returned the prose and no frontmatter leaves a story every gate skips.
2586
- # Synthesised rather than refused: the body is the expensive half and it is already on
2587
- # disk, and the fields below are exactly the ones this op is authoritative for.
2588
- block, body = "", text.lstrip("\n")
2589
-
2590
- # The block as an ordered list of (key, rendered line), so an unrecognised key and its
2591
- # continuation lines survive untouched.
2592
- lines = block.split("\n") if block else []
2593
- order, seen_key = [], None
2594
- for line in lines:
2595
- km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
2596
- if km:
2597
- seen_key = km.group(1); order.append([seen_key, [line]])
2598
- elif order and (line.startswith(" ") or line.startswith("\t")):
2599
- order[-1][1].append(line) # a block-list item under the key above
2600
- elif line.strip():
2601
- order.append([None, [line]])
2602
-
2603
- def put(key, value):
2604
- """Set a key to a single-line value, collapsing a block list into the inline form."""
2605
- line = ("%s: %s" % (key, value)) if value != "" else ("%s:" % key)
2606
- for entry in order:
2607
- if entry[0] == key:
2608
- entry[1] = [line]
2609
- return "set"
2610
- order.append([key, [line]])
2611
- return "added"
2612
-
2613
- TEMPLATE_ORDER = ["id", "type", "status", "title", "exercises", "spec_version",
2614
- "depends_on", "wave", "follows", "work_item", "created"]
2615
- def get(key):
2616
- for entry in order:
2617
- if entry[0] == key: return entry[1][0].split(":", 1)[1].strip()
2618
- return ""
2619
-
2620
- changed = {}
2621
- changed["id"] = put("id", story)
2622
-
2623
- if os.environ.get("AW_IDS_GIVEN"):
2624
- ids = rids(os.environ.get("AW_IDS", ""))
2625
- changed["exercises"] = put("exercises", "[" + ", ".join(ids) + "]")
2626
- else:
2627
- ids = rids(get("exercises"))
2628
-
2629
- ver = (os.environ.get("AW_VER") or "").strip()
2630
- if ver: changed["spec_version"] = put("spec_version", ver)
2631
-
2632
- fol = (os.environ.get("AW_FOLLOWS") or "").strip()
2633
- if fol: changed["follows"] = put("follows", fol)
2634
-
2635
- ttl = (os.environ.get("AW_TITLE") or "").strip()
2636
- if ttl and (not get("title") or get("title").startswith("[")):
2637
- changed["title"] = put("title", ttl)
2638
-
2639
- # Placeholders the template ships. Left alone once a real value is in them, so a story
2640
- # CLOSED between the plan and the write is not quietly re-opened by a stamp.
2641
- if not get("type") or get("type").startswith("["):
2642
- changed["type"] = put("type", "functional" if ids else "technical")
2643
- if not get("status") or get("status").startswith("["):
2644
- changed["status"] = put("status", "open")
2645
- # ORDERING IS A UNION, NEVER A REPLACEMENT. The edges passed in are what this run's slicing
2646
- # declared; the ones already in the file may name work outside this feature, which the sync
2647
- # cannot see. Dropping either loses a real claim about what has to land first, and a lost
2648
- # edge is silent — no later stage re-derives it.
2649
- deps = sorted(set(sids(get("depends_on")) + sids(os.environ.get("AW_DEPS", ""))) - {story})
2650
- if deps or not get("depends_on") or get("depends_on").startswith("["):
2651
- changed["depends_on"] = put("depends_on", "[" + ", ".join(deps) + "]")
2652
-
2653
- # THE WAVE IS AN ELIGIBILITY LEVEL, NOT A PRIORITY: every story this one depends on sits in
2654
- # a lower one, so stories sharing a wave have no ordering between them and may be picked up
2655
- # in parallel. Computed by backlog-plan from `depends_on`, and written here for the same
2656
- # reason `exercises:` is — a field the gates and the listings read is observed, not declared.
2657
- # A story stamped without one floors at 1: nothing is KNOWN to have to land before it.
2658
- wv = (os.environ.get("AW_WAVE") or "").strip()
2659
- if wv.isdigit():
2660
- changed["wave"] = put("wave", wv)
2661
- elif not get("wave").isdigit():
2662
- changed["wave"] = put("wave", "1")
2663
- if not get("created") or get("created").startswith("["):
2664
- changed["created"] = put("created", datetime.date.today().isoformat())
2665
- for k in ("title", "follows", "work_item"):
2666
- if not any(e[0] == k for e in order): put(k, "")
2667
-
2668
- # Rendered back in template order, with anything unrecognised kept after it in the order it
2669
- # was written. A story is read by people as well as by gates.
2670
- rank = {k: i for i, k in enumerate(TEMPLATE_ORDER)}
2671
- order.sort(key=lambda e: rank.get(e[0], len(TEMPLATE_ORDER)))
2672
- out_block = "\n".join(l for e in order for l in e[1])
2673
- open(path, "w", encoding="utf-8").write("---\n" + out_block + "\n---\n\n" + body.lstrip("\n"))
2674
-
2675
- print(json.dumps({"ok": True, "story": story, "path": path,
2676
- "exercises": ids, "exercises_csv": ", ".join(ids),
2677
- "spec_version": get("spec_version"), "status": get("status"),
2678
- "depends_on": get("depends_on"), "wave": get("wave"),
2679
- "follows": get("follows"), "title": get("title"),
2680
- "type": get("type"), "had_frontmatter": bool(m),
2681
- "fields": sorted(changed)}))
2682
- PY
2683
- }
2684
-
2685
- # ────────────────────────────── spec-preserve ──────────────────────────────
2686
- # Set subtraction with a purpose: of the requirements this run disturbed, which ones was
2687
- # it NOT given permission to change. The approved list comes from a multi-select ask, so
2688
- # the complement is what the re-author has to restore. Kept as its own subcommand because
2689
- # the orchestrator has no expressions — a workflow can bind a value, never compute one.
2690
- cmd_spec_preserve() {
2691
- local disturbing="" approved="" preserved="" mode="complement"
2692
- while [ $# -gt 0 ]; do
2693
- case "$1" in
2694
- --disturbing) disturbing="$2"; shift 2 ;;
2695
- --approved) approved="$2"; shift 2 ;;
2696
- --preserved) preserved="$2"; shift 2 ;;
2697
- --verify) mode="verify"; shift ;;
2698
- --help|-h) echo "spec-preserve --disturbing <csv> --approved <csv>"; echo "spec-preserve --verify --preserved <csv> --disturbing <csv>"; return 0 ;;
2699
- *) echo "spec-preserve: unknown arg '$1'" >&2; return 1 ;;
2700
- esac
2701
- done
2702
- AW_DIST="$disturbing" AW_OK="$approved" AW_PRE="$preserved" AW_MODE="$mode" python3 - <<'PY'
2703
- import json, os, re
2704
- def ids(s):
2705
- seen, out = set(), []
2706
- for t in re.findall(r'[A-Z]{2}-\d+', s or ""):
2707
- if t not in seen: seen.add(t); out.append(t)
2708
- return out
2709
- mode = os.environ.get("AW_MODE", "complement")
2710
- dist, ok = ids(os.environ.get("AW_DIST", "")), ids(os.environ.get("AW_OK", ""))
2711
- if mode == "verify":
2712
- # DID THE RESTORE ACTUALLY HOLD. The re-author is a model asked to put requirements back
2713
- # to their earlier wording; nothing until now checked that it did. The diff taken after
2714
- # it is the proof: a requirement the author refused to move must no longer appear in the
2715
- # disturbing set. One that still does was amended WITHOUT approval — and the run would
2716
- # otherwise report it, in the same breath, as having been left alone.
2717
- pre = ids(os.environ.get("AW_PRE", ""))
2718
- violated = [d for d in pre if d in dist]
2719
- held = [d for d in pre if d not in dist]
2720
- print(json.dumps({"ok": not violated, "mode": "verify", "preserved": pre,
2721
- "held": held, "held_csv": ", ".join(held), "held_count": len(held),
2722
- "violated": violated, "violated_csv": ", ".join(violated),
2723
- "violated_count": len(violated), "restore_ok": not violated}))
2724
- else:
2725
- preserve = [d for d in dist if d not in ok]
2726
- print(json.dumps({"ok": True, "mode": "complement", "disturbing": dist, "approved": ok,
2727
- "preserve": preserve,
2728
- "preserve_csv": ", ".join(preserve), "preserve_count": len(preserve),
2729
- "approved_csv": ", ".join(ok)}))
2730
- PY
2731
- }
2732
-
2733
- # ────────────────────────────── spec-markers ───────────────────────────────
2734
- # Are there any [NEEDS CLARIFICATION] markers to resolve. Deterministic and free, which is
2735
- # the point: asking a model "are there any markers" costs a full round trip on every run to
2736
- # hear "no" on most of them. The model is still what turns a marker into a question with
2737
- # answerable options — this only decides whether to call it at all.
2738
- cmd_spec_markers() {
2739
- local fd_arg=""
2740
- while [ $# -gt 0 ]; do
2741
- case "$1" in
2742
- --feature-dir) fd_arg="$2"; shift 2 ;;
2743
- --help|-h) echo "spec-markers [--feature-dir <path>]"; return 0 ;;
2744
- *) echo "spec-markers: unknown arg '$1'" >&2; return 1 ;;
2745
- esac
2746
- done
2747
- local repo_root; repo_root=$(get_repo_root)
2748
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-markers: no feature set and no --feature-dir" >&2; return 1; }
2749
- AW_SPEC="$fd/spec.md" python3 - <<'PY'
2750
- import json, os, re
2751
- spec = os.environ["AW_SPEC"]
2752
- text = open(spec, encoding="utf-8", errors="replace").read() if os.path.isfile(spec) else ""
2753
- found = [" ".join(f.split()) for f in re.findall(r'\[NEEDS CLARIFICATION:\s*(.*?)\]', text, re.S)]
2754
- print(json.dumps({"ok": True, "spec_exists": bool(text), "markers": found,
2755
- "marker_count": len(found), "any": bool(found)}))
2756
- PY
2757
- }
2758
-
2759
- # ───────────────────────── spec-snapshot / spec-diff ───────────────────────
2760
- # What a run CHANGED, observed rather than declared. specify snapshots spec.md before
2761
- # it authors and diffs after the converge loop has finished with it, so the Change
2762
- # History row is computed from the document's own requirement ids.
2763
- #
2764
- # Deriving the row this way is what makes it trustworthy. A row the model declares can
2765
- # omit a requirement the converge loop rewrote on its way past — and an omitted id is
2766
- # the silent case the whole ledger exists to prevent, since sync-stories would never
2767
- # learn that requirement moved. A row computed from the diff cannot omit one.
2768
- #
2769
- # Ids are read in both shapes the template uses: the bold form of the list sections
2770
- # (`- **FR-001**: …`) and the leading-cell form of the Data Classification table
2771
- # (`| DC-001 | … |`). The line is normalised on whitespace before hashing, so a rewrap
2772
- # is not a change.
2773
- cmd_spec_snapshot() {
2774
- local fd_arg=""
2775
- while [ $# -gt 0 ]; do
2776
- case "$1" in
2777
- --feature-dir) fd_arg="$2"; shift 2 ;;
2778
- --help|-h) echo "spec-snapshot [--feature-dir <path>]"; return 0 ;;
2779
- *) echo "spec-snapshot: unknown arg '$1'" >&2; return 1 ;;
2780
- esac
2781
- done
2782
- local repo_root; repo_root=$(get_repo_root)
2783
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-snapshot: no feature set and no --feature-dir" >&2; return 1; }
2784
- local spec="$fd/spec.md"
2785
- local snap=""
2786
- if [ -f "$spec" ]; then
2787
- # BESIDE THE SPEC, not in the system temp directory. Only the PATH is replay-cached,
2788
- # and the step is not re-run on resume — so a checkpointed run paused for a human
2789
- # answer could outlive the temp file. A cleaned temp dir then left spec-diff with no
2790
- # before-state at all: every id read as ADDED, nothing MODIFIED, so the E38 ask never
2791
- # fired, the ledger row named every requirement in the document, and every open story
2792
- # went stale for a change that touched none of them. Beside the spec it shares the
2793
- # file's lifetime and travels with the repository.
2794
- #
2795
- # One snapshot per feature, overwritten each run: a run diffs against it TWICE, once
2796
- # to see what changed and again after re-authoring a change made additively.
2797
- snap="$fd/.aw-snapshot.md"
2798
- cp "$spec" "$snap"
2799
- fi
2800
- printf '{"snapshot":"%s","taken":%s}\n' "$(json_escape "$snap")" "$([ -n "$snap" ] && echo true || echo false)"
2801
- }
2802
-
2803
- cmd_spec_diff() {
2804
- local fd_arg="" snap=""
2805
- while [ $# -gt 0 ]; do
2806
- case "$1" in
2807
- --feature-dir) fd_arg="$2"; shift 2 ;;
2808
- --snapshot) snap="$2"; shift 2 ;;
2809
- --help|-h) echo "spec-diff --snapshot <path> [--feature-dir <path>]"; return 0 ;;
2810
- *) echo "spec-diff: unknown arg '$1'" >&2; return 1 ;;
2811
- esac
2812
- done
2813
- local repo_root; repo_root=$(get_repo_root)
2814
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "spec-diff: no feature set and no --feature-dir" >&2; return 1; }
2815
- AW_SNAP="$snap" AW_SPEC="$fd/spec.md" python3 - <<'PY'
2816
- import json, os, re, hashlib, sys
2817
- snap = os.environ.get("AW_SNAP", ""); spec = os.environ["AW_SPEC"]
2818
- # ASKING FOR NO BEFORE-STATE IS NOT THE SAME AS LOSING ONE. An empty --snapshot is the
2819
- # bootstrap path saying "there was nothing before this run", and every id is legitimately
2820
- # added. A path that was given and is not there is the before-state having been LOST, and
2821
- # answering that with "everything is new" is the loudest possible wrong answer: it marks
2822
- # every story in the feature stale for a change that touched none of them.
2823
- if snap and not os.path.isfile(snap):
2824
- msg = ("spec-diff: the before-state at %s is gone, so this run cannot tell what it "
2825
- "changed. spec.md HAS been written. Recover by hand: put the ids you changed "
2826
- "into a new Change History row, then re-run spec-stamp to re-baseline."
2827
- % snap)
2828
- print(json.dumps({"ok": False, "error": msg, "had_snapshot": False, "snapshot": snap}))
2829
- sys.stderr.write(msg + "\n"); sys.exit(2)
2830
- # An id is keyed to the line that DEFINES it, never to a line that merely mentions it.
2831
- # The four shapes are scored, best wins, earliest line breaks a tie:
2832
- # 3 a leading table cell | DC-001 | … |
2833
- # 3 a bold id opening a bullet - **FR-001**: THE SYSTEM SHALL …
2834
- # 2 a bold id opening a line **FR-001** … (a definition missing its colon)
2835
- # 1 a bold id anywhere … narrowing **FR-001**. (a cross-reference)
2836
- # Scoring rather than "first bold id on the line, last line wins": a note that OPENS with
2837
- # a cross-reference — "**FR-005** remains in force; **FR-006** narrows it" — re-keyed
2838
- # FR-005 to that note, so a requirement restored character-for-character was reported as
2839
- # MODIFIED. That is the worst possible misreading here, because the additive path exists
2840
- # precisely to leave such a requirement alone, and its own instructions ask the author to
2841
- # name the id in the new requirement's text.
2842
- DEF_CELL = re.compile(r'^\s*\|\s*([A-Z]{2}-\d+)\s*\|')
2843
- DEF_BULL = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*\s*[:\u2014\u2013-]')
2844
- DEF_OPEN = re.compile(r'^\s*(?:[-*+]\s+)?\*\*([A-Z]{2}-\d+)\*\*')
2845
- ANY_BOLD = re.compile(r'\*\*([A-Z]{2}-\d+)\*\*')
2846
- # The Story Coverage block lists requirement ids in a table, so it reads exactly like the
2847
- # Data Classification section to DEF_CELL. Left in, every story raised against FR-003 would
2848
- # show up here as FR-003 having CHANGED — a version row, and every story citing it stale,
2849
- # because somebody wrote a story.
2850
- COV = re.compile(r'<!--\s*AW:COVERAGE:BEGIN\s*-->.*?<!--\s*AW:COVERAGE:END\s*-->', re.S)
2851
- # The ledger's own rows name ids in prose cells; they record history, they do not define a
2852
- # requirement, and the table is stripped before the scan for the same reason as coverage.
2853
- HIST = re.compile(r'^##\s+Change History\s*$', re.M)
2854
- def is_def(l):
2855
- return bool(DEF_CELL.match(l) or DEF_BULL.match(l) or DEF_OPEN.match(l))
2856
- def scan(path):
2857
- """{id: (score, line_no, hash)} for every id the document mentions."""
2858
- best = {}
2859
- if not path or not os.path.isfile(path): return best
2860
- text = COV.sub("", open(path, encoding="utf-8", errors="replace").read())
2861
- hm = HIST.search(text)
2862
- if hm: text = text[:hm.start()]
2863
- lines = text.split("\n")
2864
- for n, line in enumerate(lines):
2865
- hits = []
2866
- m = DEF_CELL.match(line)
2867
- if m: hits.append((3, m.group(1)))
2868
- m = DEF_BULL.match(line)
2869
- if m: hits.append((3, m.group(1)))
2870
- else:
2871
- m = DEF_OPEN.match(line)
2872
- if m: hits.append((2, m.group(1)))
2873
- for rid in ANY_BOLD.findall(line):
2874
- hits.append((1, rid))
2875
- for score, rid in hits:
2876
- if rid not in best or score > best[rid][0]:
2877
- best[rid] = (score, n)
2878
- # THE WHOLE REQUIREMENT, not the line its id sits on. A requirement that wraps carries
2879
- # half its meaning on the continuation lines, and hashing only the id's own line made an
2880
- # edit below the fold invisible: change_count 0, no ledger row appended, and spec-stamp
2881
- # re-baselining the new body as clean — the ledger's own silent failure, for every story
2882
- # citing that id. The block runs to the next definition, heading or blank line, and is
2883
- # whitespace-normalised as ONE string, so a rewrap across lines is still not a change.
2884
- out = {}
2885
- for rid, (score, n) in best.items():
2886
- block = [lines[n]]
2887
- for m2 in range(n + 1, len(lines)):
2888
- nxt = lines[m2]
2889
- if not nxt.strip() or nxt.lstrip().startswith("#") or is_def(nxt): break
2890
- block.append(nxt)
2891
- h = hashlib.sha256(" ".join(" ".join(block).split()).encode()).hexdigest()[:16]
2892
- out[rid] = (score, n, h)
2893
- return out
2894
- def ids(path):
2895
- return {k: v[2] for k, v in scan(path).items()}
2896
- # Ids the ledger has already spent. An id is PERMANENT: re-issuing one silently re-points
2897
- # stories, designs and code that have already shipped. Read from the SNAPSHOT's history, so
2898
- # it is what the ledger said before this run, and only meaningful when there is a snapshot.
2899
- def spent(path):
2900
- if not path or not os.path.isfile(path): return set()
2901
- text = open(path, encoding="utf-8", errors="replace").read()
2902
- hm = HIST.search(text)
2903
- if not hm: return set()
2904
- return set(re.findall(r'[A-Z]{2}-\d+', text[hm.end():]))
2905
- before, after = ids(snap), ids(spec)
2906
- added = sorted(k for k in after if k not in before)
2907
- removed = sorted(k for k in before if k not in after)
2908
- modified = sorted(k for k in after if k in before and before[k] != after[k])
2909
- changed = sorted(set(added) | set(removed) | set(modified))
2910
- reissued = sorted(set(added) & spent(snap))
2911
- # THE STORY-FACING SUBSET. A story cites FR- and BR- ids and nothing else, so those are the
2912
- # only classes the backlog can act on — the same scoping spec-coverage applies, for the same
2913
- # reason. The scan is deliberately class-blind (CN-/DC-/IC-/UX-/SC- all move the contract and
2914
- # all belong in the ledger row), but handing that whole set to sync-stories as "the
2915
- # requirements this run touched" asks it to raise a story for a success criterion.
2916
- STORY_CLASS = ("FR-", "BR-")
2917
- story_ids = [c for c in changed if c.startswith(STORY_CLASS)]
2918
- print(json.dumps({
2919
- "ok": True, "had_snapshot": bool(snap and os.path.isfile(snap)),
2920
- "added": added, "removed": removed, "modified": modified,
2921
- "changed": changed, "changed_csv": ", ".join(changed),
2922
- "change_count": len(changed),
2923
- "added_csv": ", ".join(added),
2924
- # removed ids orphan every story citing them (E1) — carried out so the caller can say so
2925
- "removed_csv": ", ".join(removed),
2926
- "modified_csv": ", ".join(modified),
2927
- # What an existing story would feel. An ADDED requirement disturbs nobody: no story
2928
- # cites an id that did not exist a moment ago. Modifying or removing one moves ground
2929
- # somebody may already be standing on, which is the set worth asking about.
2930
- "disturbing": sorted(set(modified) | set(removed)),
2931
- "disturbing_csv": ", ".join(sorted(set(modified) | set(removed))),
2932
- # An id this run introduced that the ledger has already spent. Detected, not blocked:
2933
- # the document is written by the time we can see it, and refusing here would leave a
2934
- # spec on disk that the next run's entry check would refuse too.
2935
- "reissued": reissued, "reissued_csv": ", ".join(reissued),
2936
- "reissued_any": bool(reissued),
2937
- # changed, narrowed to the classes a story can cite — what sync-stories works from.
2938
- "story_ids": story_ids, "story_ids_csv": ", ".join(story_ids),
2939
- "story_id_count": len(story_ids),
2940
- }))
2941
- PY
2942
- return 0
2943
- }
2944
-
2945
- cmd_spec_check() { _spec_ledger_cmd "check" "$@"; }
2946
- cmd_spec_stamp() { _spec_ledger_cmd "stamp" "$@"; }
2947
- cmd_ledger_append() { _spec_ledger_cmd "append" "$@"; }
2948
- cmd_ledger_ids() { _spec_ledger_cmd "rowids" "$@"; }
2949
- _spec_ledger_cmd() { # $1 = op, then --feature-dir / --ids / --summary
2950
- local op="$1"; shift
2951
- local fd_arg="" ids="" summary=""
2952
- while [ $# -gt 0 ]; do
2953
- case "$1" in
2954
- --feature-dir) fd_arg="$2"; shift 2 ;;
2955
- --ids) ids="$2"; shift 2 ;;
2956
- --summary) summary="$2"; shift 2 ;;
2957
- --help|-h) echo "$op [--feature-dir <path>] [--ids <FR-001, BR-002>] [--summary <text>]"; return 0 ;;
2958
- *) echo "$op: unknown arg '$1'" >&2; return 1 ;;
2959
- esac
2960
- done
2961
- local repo_root; repo_root=$(get_repo_root)
2962
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "$op: no feature set and no --feature-dir" >&2; return 1; }
2963
- # The delta-spec template, so `check` can tell an AUTHORED delta from the untouched copy
2964
- # cr-setup scaffolds for every change it opens.
2965
- local dtpl; dtpl=$(resolve_template "delta-spec-template" "$repo_root") || true
2966
- _spec_ledger "$fd" "$op" "$ids" "$summary" "$dtpl"
2967
- }
2968
-
2969
1508
 
2970
1509
 
2971
1510
  # ─────────────────────────────── feature-paths ─────────────────────────────
@@ -2984,19 +1523,27 @@ cmd_feature_paths() {
2984
1523
  fi
2985
1524
  [[ "$fd" != /* ]] && fd="$repo_root/$fd"
2986
1525
  local feature; feature=$(basename "$fd") # the aggregate name (feature = specs/<Feature>)
2987
- # The master documents of a feature: spec.md (WHAT/WHY) + design.md (the service
2988
- # lane's HOW) + design.ui.md (the frontend lane's HOW, where the feature has a UI
2989
- # half). plan.md / tasks.md belonged to the retired /aw-spec-plan + /aw-spec-tasks
2990
- # phases and had zero consumers.
1526
+ # The master documents of a feature: spec.md (WHAT/WHY) + one LANE PLAN per declared
1527
+ # lane (each lane's HOW) — backend-plan.md for the service lane, frontend-plan.md for
1528
+ # the frontend lane. `plan` writes them; nothing else does.
1529
+ #
1530
+ # LEGACY NAMES RESOLVE TOO. A feature authored before the lane split carries design.md
1531
+ # / design.ui.md, so each lane resolves to its plan document when that exists and to
1532
+ # the legacy name while it does not. Every reader therefore finds the document that is
1533
+ # actually on disk, and plan-scaffold renames it into the plan name on the next `plan`
1534
+ # run — which is what keeps the rename incremental instead of a flag day.
1535
+ # design_file / ui_design_file are kept as ALIASES of the same two resolved paths, so
1536
+ # a stage that has not been repointed yet reads the right document under its old key.
2991
1537
  #
2992
1538
  # ui_design_exists IS THE UI-HALF SIGNAL for the whole CR loop. Every delta stage
2993
1539
  # asks the same question — is there a UI master to keep in sync? — and the master's
2994
1540
  # own presence answers it without re-deriving the lane declaration and the slice's
2995
- # UI file count at each stage. It is also self-correcting: whatever authors
2996
- # design.ui.md (adopt today) enrolls the feature in the UI half of the delta loop
2997
- # by the act of writing the file.
2998
- local spec="$fd/spec.md" design="$fd/design.md" ui_design="$fd/design.ui.md"
2999
- local backend_plan="$fd/backend-plan.md" frontend_plan="$fd/frontend-plan.md"
1541
+ # UI file count at each stage. It is also self-correcting: whatever authors the
1542
+ # frontend plan enrolls the feature in the UI half of the delta loop by the act of
1543
+ # writing the file.
1544
+ local spec="$fd/spec.md" design="$fd/backend-plan.md" ui_design="$fd/frontend-plan.md"
1545
+ [ ! -f "$design" ] && [ -f "$fd/design.md" ] && design="$fd/design.md"
1546
+ [ ! -f "$ui_design" ] && [ -f "$fd/design.ui.md" ] && ui_design="$fd/design.ui.md"
3000
1547
  # needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
3001
1548
  # leaves spec.md on disk with the marker still up, and branching on the file alone read
3002
1549
  # that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
@@ -3005,15 +1552,13 @@ cmd_feature_paths() {
3005
1552
  local spec_exists=false; [ -f "$spec" ] && spec_exists=true
3006
1553
  local design_exists=false; [ -f "$design" ] && design_exists=true
3007
1554
  local ui_design_exists=false; [ -f "$ui_design" ] && ui_design_exists=true
3008
- local backend_plan_exists=false; [ -f "$backend_plan" ] && backend_plan_exists=true
3009
- local frontend_plan_exists=false; [ -f "$frontend_plan" ] && frontend_plan_exists=true
3010
1555
  local needs_bootstrap=false
3011
1556
  [ "$spec_exists" = false ] && needs_bootstrap=true
3012
1557
  [ "$bootstrap_pending" = true ] && needs_bootstrap=true
3013
- 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' \
3014
- "$(json_escape "$feature")" "$(json_escape "$fd")" "$(json_escape "$spec")" "$(json_escape "$design")" \
3015
- "$(json_escape "$ui_design")" "$(json_escape "$backend_plan")" "$(json_escape "$frontend_plan")" \
3016
- "$spec_exists" "$design_exists" "$ui_design_exists" "$backend_plan_exists" "$frontend_plan_exists" \
1558
+ printf '{"feature":"%s","feature_dir":"%s","spec_file":"%s","backend_plan":"%s","frontend_plan":"%s","backend_plan_exists":%s,"frontend_plan_exists":%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' \
1559
+ "$(json_escape "$feature")" "$(json_escape "$fd")" "$(json_escape "$spec")" \
1560
+ "$(json_escape "$design")" "$(json_escape "$ui_design")" "$design_exists" "$ui_design_exists" \
1561
+ "$(json_escape "$design")" "$(json_escape "$ui_design")" "$spec_exists" "$design_exists" "$ui_design_exists" \
3017
1562
  "$bootstrap_pending" "$needs_bootstrap"
3018
1563
  }
3019
1564
 
@@ -3136,12 +1681,33 @@ _project_markers() {
3136
1681
  | sed "s#^$repo_root/##" | LC_ALL=C sort | head -20
3137
1682
  }
3138
1683
 
1684
+ # Does a manifest belong to the ecosystem the caller asked about. The repo-scope fallback
1685
+ # below is deliberate — a .NET solution legitimately sits ABOVE the lane it scaffolds — but
1686
+ # it answers with whatever manifest it finds, so in a two-lane repo where only the frontend
1687
+ # is scaffolded the service lane's gate passes on the frontend's package.json. Knowing the
1688
+ # lane's ecosystem is what turns that hit back into a miss.
1689
+ _marker_ecosystem() {
1690
+ case "${1##*/}" in
1691
+ *.sln|*.csproj) printf 'dotnet' ;;
1692
+ package.json) printf 'node' ;;
1693
+ pom.xml|build.gradle|build.gradle.kts) printf 'java' ;;
1694
+ go.mod) printf 'go' ;;
1695
+ Cargo.toml) printf 'rust' ;;
1696
+ pyproject.toml) printf 'python' ;;
1697
+ *) printf '' ;;
1698
+ esac
1699
+ }
1700
+
3139
1701
  cmd_detect_project() {
3140
- local scope_root=""
1702
+ local scope_root="" ecosystem=""
3141
1703
  while [ $# -gt 0 ]; do
3142
1704
  case "$1" in
3143
1705
  --root|--source-path) scope_root="$2"; shift 2 ;;
3144
- --help|-h) echo "detect-project [--root <dir>] -> {scaffolded, markers[], scope}"; return 0 ;;
1706
+ # The lane's ecosystem, passed in by the caller that already resolved it.
1707
+ # NOT re-derived here: the caller binds it from stacks.json before this op
1708
+ # runs, and a second guess is a second thing to keep in step with that file.
1709
+ --ecosystem) ecosystem="$2"; shift 2 ;;
1710
+ --help|-h) echo "detect-project [--root <dir>] [--ecosystem <name>] -> {scaffolded, markers[], scope, ecosystem_match}"; return 0 ;;
3145
1711
  *) echo "detect-project: unknown arg '$1'" >&2; return 1 ;;
3146
1712
  esac
3147
1713
  done
@@ -3161,7 +1727,18 @@ cmd_detect_project() {
3161
1727
  fi
3162
1728
  local scaffolded=false
3163
1729
  [ ${#markers[@]} -gt 0 ] && scaffolded=true
3164
- printf '{"scaffolded":%s,"markers":%s,"scope":"%s"}\n' "$scaffolded" "$(json_str_array "${markers[@]}")" "$scope"
1730
+ # UNASKED IS NOT UNMATCHED. With no --ecosystem the caller has told us nothing to
1731
+ # judge against, and answering false there would fail every existing call site.
1732
+ local match=true m_eco
1733
+ if [ -n "$ecosystem" ] && [ "$scaffolded" = true ]; then
1734
+ match=false
1735
+ for m in "${markers[@]}"; do
1736
+ m_eco=$(_marker_ecosystem "$m")
1737
+ [ "$m_eco" = "$ecosystem" ] && { match=true; break; }
1738
+ done
1739
+ fi
1740
+ printf '{"scaffolded":%s,"markers":%s,"scope":"%s","ecosystem":"%s","ecosystem_match":%s}\n' \
1741
+ "$scaffolded" "$(json_str_array "${markers[@]}")" "$scope" "$ecosystem" "$match"
3165
1742
  }
3166
1743
 
3167
1744
 
@@ -4061,23 +2638,24 @@ cmd_design_scaffold() {
4061
2638
  # name still follows --lane). In a brownfield repo /appweaver onboard
4062
2639
  # GENERATES a code-derived override for both names (resolve_template
4063
2640
  # Priority-1), so the defaults already yield the as-built structure for
4064
- # forward `design` and adopt alike, and this is rarely needed.
2641
+ # forward `plan` and adopt alike, and this is rarely needed.
4065
2642
  --template) template_arg="$2"; shift 2 ;;
4066
- --help|-h) echo "design-scaffold --feature-dir <path> [--lane service|ui|backend|frontend] [--template <name>]"; return 0 ;;
2643
+ --help|-h) echo "plan-scaffold --feature-dir <path> [--lane backend|frontend|service|ui] [--template <name>]"; return 0 ;;
4067
2644
  *) echo "design-scaffold: unknown arg '$1'" >&2; return 1 ;;
4068
2645
  esac
4069
2646
  done
4070
- # backend/frontend are the PLAN-phase pairs: the same templates, the plan document
4071
- # names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the
4072
- # plan name so a feature never carries both spellings of the same master.
4073
- # service/ui stay byte-identical for their existing callers (adopt, the delta chain).
2647
+ # backend / frontend are the PLAN-phase pairs: the same templates, the plan document
2648
+ # names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the plan
2649
+ # name, so a feature never carries two spellings of one master and the rename costs
2650
+ # no flag day. service / ui stay byte-identical for the callers that still write the
2651
+ # legacy names (adopt's reverse pass).
4074
2652
  local doc_name legacy_name=""
4075
2653
  case "$lane_arg" in
4076
- service) doc_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
4077
- ui) doc_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
4078
- backend) doc_name="backend-plan.md"; legacy_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
4079
- frontend) doc_name="frontend-plan.md"; legacy_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
4080
- *) echo "design-scaffold: unknown --lane '$lane_arg' (service|ui|backend|frontend)" >&2; return 1 ;;
2654
+ service) doc_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
2655
+ ui) doc_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
2656
+ backend) doc_name="backend-plan.md"; legacy_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
2657
+ frontend) doc_name="frontend-plan.md"; legacy_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
2658
+ *) echo "design-scaffold: unknown --lane '$lane_arg' (backend|frontend|service|ui)" >&2; return 1 ;;
4081
2659
  esac
4082
2660
  local repo_root; repo_root=$(get_repo_root)
4083
2661
  local fd="$feature_dir_arg"
@@ -4087,6 +2665,9 @@ cmd_design_scaffold() {
4087
2665
  mkdir -p "$fd"
4088
2666
 
4089
2667
  local target="$fd/$doc_name" created="false" tpl migrated="false"
2668
+ # A feature that predates the lane split carries the legacy name. Rename it rather
2669
+ # than scaffolding an empty plan beside it: the content IS this lane's plan, and two
2670
+ # files would leave every reader guessing which one is current.
4090
2671
  if [ ! -f "$target" ] && [ -n "$legacy_name" ] && [ -f "$fd/$legacy_name" ]; then
4091
2672
  mv "$fd/$legacy_name" "$target"; migrated="true"
4092
2673
  fi
@@ -4106,8 +2687,67 @@ cmd_design_scaffold() {
4106
2687
  local override_active="false"
4107
2688
  [ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
4108
2689
  local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
4109
- printf '{"feature_dir":"%s","design_doc":"%s","lane":"%s","created":%s,"migrated":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
4110
- "$(json_escape "$fd")" "$(json_escape "$target")" "$(json_escape "$lane_arg")" "$created" "$migrated" "$override_active" \
2690
+ printf '{"feature_dir":"%s","design_doc":"%s","plan_doc":"%s","lane":"%s","created":%s,"migrated":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
2691
+ "$(json_escape "$fd")" "$(json_escape "$target")" "$(json_escape "$target")" "$(json_escape "$lane_arg")" \
2692
+ "$created" "$migrated" "$override_active" \
2693
+ "$(json_escape "$template_arg")" "$(json_escape "${template_source:-}")"
2694
+ }
2695
+
2696
+ # ─────────────────────────────── delta-plan-scaffold ───────────────────────
2697
+ # One change's LANE PLAN, inside the change directory beside its delta-spec:
2698
+ # changes/<cr-id>/delta-backend-plan.md (service lane)
2699
+ # changes/<cr-id>/delta-frontend-plan.md (frontend lane)
2700
+ # The plan phase calls this per DECLARED lane, so a backend-only project never grows an
2701
+ # orphan frontend delta plan and a frontend-only one is planned without a service half.
2702
+ # It is deliberately NOT cr-setup's job: cr-setup runs in the spec phase, which knows
2703
+ # nothing about lanes, and a document scaffolded there would exist for a lane that was
2704
+ # never declared.
2705
+ #
2706
+ # Same MIGRATION as the master: a change opened before the lane split carries
2707
+ # delta-design.md, which is renamed into the backend name rather than duplicated.
2708
+ cmd_delta_plan_scaffold() {
2709
+ local cr_id="" feature_dir_arg="" lane_arg="backend" template_arg=""
2710
+ while [ $# -gt 0 ]; do
2711
+ case "$1" in
2712
+ --cr-id) cr_id="$2"; shift 2 ;;
2713
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
2714
+ --lane) lane_arg="$2"; shift 2 ;;
2715
+ --template) template_arg="$2"; shift 2 ;;
2716
+ --help|-h) echo "delta-plan-scaffold --cr-id <id> [--lane backend|frontend] [--feature-dir <path>] [--template <name>]"; return 0 ;;
2717
+ *) echo "delta-plan-scaffold: unknown arg '$1'" >&2; return 1 ;;
2718
+ esac
2719
+ done
2720
+ cr_id=$(_cr_id_token "$cr_id")
2721
+ [ -n "$cr_id" ] || { echo "delta-plan-scaffold: --cr-id is required" >&2; return 1; }
2722
+ local doc_name legacy_name=""
2723
+ case "$lane_arg" in
2724
+ backend) doc_name="delta-backend-plan.md"; legacy_name="delta-design.md"
2725
+ [ -n "$template_arg" ] || template_arg="delta-design-template" ;;
2726
+ frontend) doc_name="delta-frontend-plan.md"; legacy_name="delta-design.ui.md"
2727
+ [ -n "$template_arg" ] || template_arg="ui-delta-design-template" ;;
2728
+ *) echo "delta-plan-scaffold: unknown --lane '$lane_arg' (backend|frontend)" >&2; return 1 ;;
2729
+ esac
2730
+ local repo_root; repo_root=$(get_repo_root)
2731
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "delta-plan-scaffold: no feature set and no --feature-dir" >&2; return 1; }
2732
+ local cr_dir="$fd/changes/$cr_id"
2733
+ [ -d "$cr_dir" ] || { echo "delta-plan-scaffold: no such open change '$cr_id' (looked in $cr_dir)" >&2; return 1; }
2734
+
2735
+ local target="$cr_dir/$doc_name" created="false" migrated="false" tpl
2736
+ if [ ! -f "$target" ] && [ -f "$cr_dir/$legacy_name" ]; then
2737
+ mv "$cr_dir/$legacy_name" "$target"; migrated="true"
2738
+ fi
2739
+ if [ ! -f "$target" ]; then
2740
+ tpl=$(resolve_template "$template_arg" "$repo_root") || true
2741
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$target"; else touch "$target"; fi
2742
+ created="true"
2743
+ fi
2744
+ _stamp_header "$target" "Wave" "$(_wave_of "$fd" "$cr_id" "$repo_root")"
2745
+ local override_active="false"
2746
+ [ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
2747
+ local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
2748
+ printf '{"cr_id":"%s","cr_dir":"%s","lane":"%s","delta_plan":"%s","created":%s,"migrated":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
2749
+ "$(json_escape "$cr_id")" "$(json_escape "$cr_dir")" "$(json_escape "$lane_arg")" "$(json_escape "$target")" \
2750
+ "$created" "$migrated" "$override_active" \
4111
2751
  "$(json_escape "$template_arg")" "$(json_escape "${template_source:-}")"
4112
2752
  }
4113
2753
 
@@ -4289,189 +2929,6 @@ print(json.dumps({
4289
2929
  "$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")"
4290
2930
  }
4291
2931
 
4292
- # ─────────────────────────────── test-diff ─────────────────────────────────
4293
- # The TECHNICAL-STORY ORACLE. A technical story delivers no requirement, so there is no
4294
- # FR- to trace it to and the existing test suite is the only thing that can say whether
4295
- # it changed behaviour: a test that HAD to be edited means behaviour moved, and the work
4296
- # was a functional story citing a requirement. This reports the suite's diff against a
4297
- # baseline and lets the caller decide — it never judges.
4298
- #
4299
- # Read-only, always exits 0, and reports `checked` so an unreadable oracle (no git) is
4300
- # distinguishable from a clean one. A gate that treats "could not look" as "nothing
4301
- # found" is the exact false green the kit refuses everywhere else.
4302
- cmd_test_diff() {
4303
- local fd_arg="" base=""
4304
- while [ $# -gt 0 ]; do
4305
- case "$1" in
4306
- --feature-dir) fd_arg="$2"; shift 2 ;;
4307
- --base) base="$2"; shift 2 ;;
4308
- --help|-h) echo "test-diff [--feature-dir <path>] [--base <git ref>]"; return 0 ;;
4309
- *) echo "test-diff: unknown arg '$1'" >&2; return 1 ;;
4310
- esac
4311
- done
4312
- local repo_root; repo_root=$(get_repo_root)
4313
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || fd=""
4314
- AW_ROOT="$repo_root" AW_FD="$fd" AW_BASE="$base" python3 - <<'PY'
4315
- import json, os, re, subprocess
4316
- root = os.environ.get("AW_ROOT") or "."
4317
- fd = os.environ.get("AW_FD", "")
4318
- want = (os.environ.get("AW_BASE") or "").strip()
4319
- agg = os.path.basename(fd.rstrip("/\\"))
4320
-
4321
- def git(*a):
4322
- """A git read that never raises. A working copy this command cannot interrogate is a
4323
- FACT TO REPORT — the caller decides what an unreadable oracle means — never a crash
4324
- that takes the gate down with it."""
4325
- try:
4326
- p = subprocess.run(["git", "-C", root] + list(a), capture_output=True, text=True)
4327
- except Exception:
4328
- return 1, ""
4329
- return p.returncode, (p.stdout or "").strip()
4330
-
4331
- DIR_RE = re.compile(r'(^|/)(tests?|specs?|__tests__|testing|test-suite)(/|$)', re.I)
4332
-
4333
- # The kit's OWN trees, which hold documents and configuration and never tests. The spec
4334
- # tree is derived rather than assumed: `specs/` matches the RSpec directory convention
4335
- # exactly, so without this every requirement document — and this gate's own verdict file,
4336
- # written before the diff is taken — counted as a modified test.
4337
- SKIP = [".appweaver/"]
4338
- if fd:
4339
- _rel = os.path.relpath(fd, root).replace("\\", "/")
4340
- SKIP.append((_rel.rsplit("/", 1)[0] if "/" in _rel else _rel).rstrip("/") + "/")
4341
-
4342
- def is_test(path):
4343
- """A path is a TEST when a directory on it says so, or its own filename does — the
4344
- four conventions every shipped stack uses between them: a tests/ directory, a
4345
- Name.Tests.cs / NameTest.java suffix, a test_name.py / name_test.go affix, and a
4346
- name.test.ts / name.spec.ts infix. Deliberately GENEROUS: over-calling a file a test
4347
- costs a louder message, under-calling one costs a technical story that changed
4348
- behaviour and passed its own oracle."""
4349
- p = path.replace("\\", "/")
4350
- if any(p.startswith(s) for s in SKIP):
4351
- return False
4352
- stem, ext = os.path.splitext(os.path.basename(p))
4353
- # Prose is never a test, wherever it sits. A tests/ directory holds its own README
4354
- # and a docs tree holds files named for testing; counting either would fail a
4355
- # technical story over a paragraph.
4356
- if ext.lower() in (".md", ".markdown", ".txt", ".rst", ".adoc"):
4357
- return False
4358
- if DIR_RE.search(os.path.dirname(p)):
4359
- return True
4360
- stem = stem.lower()
4361
- if stem.startswith(("test", "spec")) or stem.endswith(("test", "tests", "spec", "specs")):
4362
- return True
4363
- return any(k in stem for k in (".test", ".spec", "_test", "_spec", "-test", "-spec"))
4364
-
4365
- out = {"ok": True, "aggregate": agg, "git": False, "checked": False,
4366
- "base": "", "base_ref": "", "base_kind": "none", "files_changed": 0,
4367
- "tests_changed": [], "tests_changed_csv": "", "tests_changed_count": 0,
4368
- "tests_deleted": [], "tests_deleted_csv": "",
4369
- "tests_added": [], "tests_added_csv": "", "tests_added_count": 0,
4370
- "slice_tests_changed": [], "slice_tests_changed_csv": "", "slice_tests_changed_count": 0,
4371
- "any_test_changed": False, "clean": True, "oracle": "clean",
4372
- "note": "", "digest": ""}
4373
-
4374
- if git("rev-parse", "--is-inside-work-tree")[1] != "true":
4375
- out["oracle"] = "unverified"
4376
- out["note"] = ("Not a git working copy, so the test suite's history cannot be read. The "
4377
- "unchanged-suite oracle is UNVERIFIED for this run — not passed.")
4378
- out["digest"] = out["note"]
4379
- print(json.dumps(out)); raise SystemExit(0)
4380
- out["git"] = out["checked"] = True
4381
-
4382
- # WHICH BASELINE. An explicit --base wins. Otherwise the fork point from the integration
4383
- # branch, which is what a story's work sits on top of; falling back to HEAD, which sees
4384
- # the working tree alone. The kind is reported because it changes what the answer MEANS:
4385
- # a `head` baseline cannot see a test modified in a commit this branch already made.
4386
- head = git("rev-parse", "HEAD")[1]
4387
- if want and git("rev-parse", "--verify", want + "^{commit}")[0] == 0:
4388
- out["base"], out["base_ref"], out["base_kind"] = git("rev-parse", want)[1], want, "given"
4389
- if not out["base"]:
4390
- cands = []
4391
- rc, ref = git("symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
4392
- if rc == 0 and ref:
4393
- cands.append(ref.split("refs/remotes/", 1)[-1])
4394
- cands += ["origin/main", "origin/master", "main", "master"]
4395
- seen = set()
4396
- for c in cands:
4397
- if c in seen:
4398
- continue
4399
- seen.add(c)
4400
- if git("rev-parse", "--verify", c + "^{commit}")[0] != 0:
4401
- continue
4402
- rc, mb = git("merge-base", "HEAD", c)
4403
- if rc != 0 or not mb:
4404
- continue
4405
- out["base"], out["base_ref"] = mb, c
4406
- out["base_kind"] = "head" if mb == head else "merge-base"
4407
- break
4408
- if not out["base"]:
4409
- out["base"], out["base_kind"] = head or "HEAD", "head"
4410
-
4411
- rc, diff = git("diff", "--name-status", "--find-renames", out["base"])
4412
- changed, added, deleted, files = [], [], [], 0
4413
- for line in diff.splitlines():
4414
- parts = line.split("\t")
4415
- if len(parts) < 2:
4416
- continue
4417
- code, paths = parts[0].strip(), parts[1:]
4418
- files += 1
4419
- hits = [p for p in paths if is_test(p)]
4420
- if not hits:
4421
- continue
4422
- p = hits[-1]
4423
- if code.startswith("A") or code.startswith("C"):
4424
- # A NEW test is not a changed one. A refactor that adds characterisation tests is
4425
- # exactly the work a technical story exists to do.
4426
- added.append(p)
4427
- elif code.startswith("R"):
4428
- # A pure rename moves a test without changing what it asserts; anything less than
4429
- # a 100% match carries an edit inside it.
4430
- (added if code == "R100" else changed).append(p)
4431
- elif code.startswith("D"):
4432
- deleted.append(p); changed.append(p)
4433
- else:
4434
- changed.append(p)
4435
-
4436
- # Untracked files are ADDITIONS the diff cannot see — reported as such so a new test
4437
- # written in the working tree is not mistaken for a missing one.
4438
- for p in git("ls-files", "--others", "--exclude-standard")[1].splitlines():
4439
- p = p.strip()
4440
- if p and is_test(p) and p not in added:
4441
- added.append(p); files += 1
4442
-
4443
- changed = sorted(set(changed)); added = sorted(set(added)); deleted = sorted(set(deleted))
4444
- low = agg.lower()
4445
- slice_changed = [p for p in changed if low and low in p.lower()]
4446
- out.update(files_changed=files,
4447
- tests_changed=changed, tests_changed_csv=", ".join(changed),
4448
- tests_changed_count=len(changed),
4449
- tests_deleted=deleted, tests_deleted_csv=", ".join(deleted),
4450
- tests_added=added, tests_added_csv=", ".join(added), tests_added_count=len(added),
4451
- slice_tests_changed=slice_changed, slice_tests_changed_csv=", ".join(slice_changed),
4452
- slice_tests_changed_count=len(slice_changed),
4453
- any_test_changed=bool(changed), clean=not changed,
4454
- # The DIFF's state in one word — clean | changed | unverified — so a caller can
4455
- # branch on it without recombining a boolean with a "could I even look" flag.
4456
- # It says what the suite did, never what that means for a story.
4457
- oracle="changed" if changed else "clean")
4458
-
4459
- kindnote = {"given": "the ref given on the command line",
4460
- "merge-base": "the fork point from %s — every commit this branch added is in view" % out["base_ref"],
4461
- "head": "HEAD — only uncommitted work is in view, so a test modified in a commit "
4462
- "already made on this branch is NOT counted"}[out["base_kind"]]
4463
- lines = ["Test-suite diff against %s (%s): %s" % (out["base"][:12], out["base_kind"], kindnote),
4464
- " files changed: %d" % files,
4465
- " tests MODIFIED or DELETED (%d): %s" % (len(changed), ", ".join(changed) or "none"),
4466
- " tests ADDED (%d): %s" % (len(added), ", ".join(added) or "none")]
4467
- if slice_changed:
4468
- lines.append(" of those, on this aggregate (%s): %s" % (agg, ", ".join(slice_changed)))
4469
- out["note"] = kindnote
4470
- out["digest"] = "\n".join(lines)
4471
- print(json.dumps(out))
4472
- PY
4473
- }
4474
-
4475
2932
  cmd_run_migrate() {
4476
2933
  while [ $# -gt 0 ]; do
4477
2934
  case "$1" in
@@ -4569,53 +3026,467 @@ cmd_deploy_dispatch() {
4569
3026
  }
4570
3027
 
4571
3028
  # ─────────────────────────────── cr-setup / cr-archive ──────────────────────
4572
- # Change-request scaffolding: a delta lives under <feature>/changes/<cr-id>/,
4573
- # archives to <feature>/changes/archive/<cr-id>/, and bumps the version on close.
4574
- # Single-active-change guard: cr-setup refuses to open a 2nd concurrent change on
4575
- # the same aggregate (design must close the open one first) unless --force.
3029
+ # Change-request scaffolding. A delta is TWO-PART:
3030
+ # • feature level — <feature>/changes/<cr-id>/delta-spec.md
3031
+ # • context level — <context>/domain-model.<cr-id>.delta.md
3032
+ # The model half sits BESIDE the master model, not inside the change directory, so
3033
+ # every other open delta can see it while planning and design around it instead of
3034
+ # discovering the overlap at merge time. Both halves are created here, together.
3035
+ # The change's LANE PLANS (delta-backend-plan.md / delta-frontend-plan.md) are NOT:
3036
+ # they are scaffolded per declared lane by delta-plan-scaffold, in the plan phase.
3037
+ #
3038
+ # MANY CHANGES MAY BE OPEN. Every specify call on an established feature opens its
3039
+ # own; the single-active guard is gone. WAVE is what orders them: deltas sharing a
3040
+ # wave are independent and may be developed in parallel, a wave-N+1 delta is
3041
+ # implemented after every wave-N delta lands. The default is the wave of the changes
3042
+ # already open (parallel by default) — a delta that genuinely builds on one of them
3043
+ # is raised explicitly with --wave, or later with cr-wave.
4576
3044
  cmd_cr_setup() {
4577
- local cr_id="" feature_dir_arg="" title="" force=""
3045
+ local cr_id="" feature_dir_arg="" title="" force="" wave=""
4578
3046
  while [ $# -gt 0 ]; do
4579
3047
  case "$1" in
4580
3048
  --cr-id) cr_id="$2"; shift 2 ;;
4581
3049
  --feature-dir) feature_dir_arg="$2"; shift 2 ;;
4582
3050
  --title) title="$2"; shift 2 ;;
3051
+ --wave) wave="$2"; shift 2 ;;
4583
3052
  --force) force=1; shift ;;
4584
- --help|-h) echo "cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>] [--force]"; return 0 ;;
3053
+ --help|-h) echo "cr-setup [--cr-id <id>] [--feature-dir <path>] [--title <t>] [--wave <n>]"; return 0 ;;
4585
3054
  *) echo "cr-setup: unknown arg '$1'" >&2; return 1 ;;
4586
3055
  esac
4587
3056
  done
4588
- [ -n "$cr_id" ] || cr_id="CR-$(date +%Y%m%d-%H%M%S)"
3057
+ # A random suffix, not the bare timestamp: two changes opened in the same second
3058
+ # would otherwise collide on one id, and with parallel deltas that is reachable.
3059
+ [ -n "$cr_id" ] || cr_id="CR-$(date -u +%Y%m%d-%H%M%S)-$(_rand_suffix)"
4589
3060
  local repo_root; repo_root=$(get_repo_root)
4590
3061
  local fd="$feature_dir_arg"
4591
3062
  [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
4592
3063
  [ -n "$fd" ] || { echo "cr-setup: no feature set and no --feature-dir" >&2; return 1; }
4593
3064
  [[ "$fd" != /* ]] && fd="$repo_root/$fd"
4594
- # Single-active-change guard: open the change FIRST so a second concurrent CR on
4595
- # the same aggregate is refused BEFORE any changes/<cr>/ dir is scaffolded (no
4596
- # orphan left behind). Re-opening the same cr_id is a no-op. --force overrides.
4597
- local av_open
4598
- if ! av_open=$(_active_version "$fd" open "$cr_id" "$force"); then
4599
- echo "cr-setup: refusing to open '$cr_id' — a change is already open on this feature; finish it (finalize-delta -> analyze-slice -> close-delta) first, or pass --force." >&2
4600
- printf '%s\n' "$av_open"
4601
- return 1
3065
+
3066
+ # The context that owns the domain model. A feature at specs/<Ctx>/<Feat> has its
3067
+ # model one level up; a flat specs/<Feat> falls back to specs/ as the implicit
3068
+ # single context, so a pre-context repo still gets exactly one model.
3069
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3070
+ local ctx_rel="${ctx_dir#$repo_root/}"
3071
+
3072
+ # Wave default = the highest wave among the changes already open, so a new delta is
3073
+ # parallel with them unless it says otherwise.
3074
+ if [ -z "$wave" ]; then
3075
+ wave=$(_max_open_wave "$ctx_dir")
4602
3076
  fi
3077
+ case "$wave" in ''|*[!0-9]*) echo "cr-setup: --wave must be a positive integer" >&2; return 1 ;; esac
3078
+ [ "$wave" -ge 1 ] 2>/dev/null || wave=1
3079
+
3080
+ local av_open; av_open=$(_active_version "$fd" open "$cr_id" "$force")
4603
3081
  local cr_dir="$fd/changes/$cr_id"
4604
3082
  mkdir -p "$cr_dir"
4605
- local dspec="$cr_dir/delta-spec.md" ddesign="$cr_dir/delta-design.md" tpl created=false
3083
+ # The SPEC half. The change's LANE PLANS are not scaffolded here: cr-setup runs in the
3084
+ # spec phase, which knows nothing about which lanes are declared, and a plan document
3085
+ # created for an undeclared lane would sit empty in the change directory for the rest
3086
+ # of its life. `plan` scaffolds them per declared lane (delta-plan-scaffold).
3087
+ local dspec="$cr_dir/delta-spec.md" tpl created=false
4606
3088
  if [ ! -f "$dspec" ]; then
4607
3089
  tpl=$(resolve_template "delta-spec-template" "$repo_root") || true
4608
3090
  if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dspec"; else touch "$dspec"; fi
4609
3091
  created=true
4610
3092
  fi
4611
- if [ ! -f "$ddesign" ]; then
4612
- tpl=$(resolve_template "delta-design-template" "$repo_root") || true
4613
- if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$ddesign"; else touch "$ddesign"; fi
3093
+ # The model half, beside the master model.
3094
+ local dmodel="$ctx_dir/domain-model.$cr_id.delta.md"
3095
+ if [ ! -f "$dmodel" ]; then
3096
+ mkdir -p "$ctx_dir"
3097
+ tpl=$(resolve_template "delta-domain-model-template" "$repo_root") || true
3098
+ if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$dmodel"; else touch "$dmodel"; fi
4614
3099
  created=true
4615
3100
  fi
4616
- printf '{"cr_id":"%s","cr_dir":"%s","delta_spec":"%s","delta_design":"%s","title":"%s","created":%s}\n' \
3101
+ # Scripts stamp ids, dates and the wave — the model never writes them.
3102
+ _stamp_header "$dspec" "Wave" "$wave"
3103
+ _stamp_header "$dmodel" "Wave" "$wave"
3104
+
3105
+ local open_count; open_count=$(printf '%s' "$av_open" | _json_field open_count)
3106
+ printf '{"cr_id":"%s","cr_dir":"%s","delta_spec":"%s","delta_model":"%s","context_dir":"%s","wave":%s,"open_count":%s,"title":"%s","created":%s}\n' \
4617
3107
  "$(json_escape "$cr_id")" "$(json_escape "$cr_dir")" "$(json_escape "$dspec")" \
4618
- "$(json_escape "$ddesign")" "$(json_escape "$title")" "$created"
3108
+ "$(json_escape "$dmodel")" "$(json_escape "$ctx_rel")" \
3109
+ "$wave" "${open_count:-1}" "$(json_escape "$title")" "$created"
3110
+ }
3111
+
3112
+ # ─────────────────────────────── cr-wave ────────────────────────────────────
3113
+ # Change a delta's wave after the fact — the ONLY supported way to move one, because
3114
+ # the wave is stamped into two documents that must agree. Raise a delta's wave when
3115
+ # it turns out to build on another open delta; lower it when that dependency goes away.
3116
+ cmd_cr_wave() {
3117
+ local cr_id="" feature_dir_arg="" wave=""
3118
+ while [ $# -gt 0 ]; do
3119
+ case "$1" in
3120
+ --cr-id) cr_id="$2"; shift 2 ;;
3121
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3122
+ --wave) wave="$2"; shift 2 ;;
3123
+ --help|-h) echo "cr-wave --cr-id <id> --wave <n> [--feature-dir <path>]"; return 0 ;;
3124
+ *) echo "cr-wave: unknown arg '$1'" >&2; return 1 ;;
3125
+ esac
3126
+ done
3127
+ [ -n "$cr_id" ] || { echo "cr-wave: --cr-id is required" >&2; return 1; }
3128
+ case "$wave" in ''|*[!0-9]*) echo "cr-wave: --wave must be a positive integer" >&2; return 1 ;; esac
3129
+ local repo_root; repo_root=$(get_repo_root)
3130
+ local fd="$feature_dir_arg"
3131
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
3132
+ [ -n "$fd" ] || { echo "cr-wave: no feature set and no --feature-dir" >&2; return 1; }
3133
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
3134
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3135
+ local dspec="$fd/changes/$cr_id/delta-spec.md" dmodel="$ctx_dir/domain-model.$cr_id.delta.md"
3136
+ local stamped=0
3137
+ [ -f "$dspec" ] && { _stamp_header "$dspec" "Wave" "$wave"; stamped=$((stamped+1)); }
3138
+ [ -f "$dmodel" ] && { _stamp_header "$dmodel" "Wave" "$wave"; stamped=$((stamped+1)); }
3139
+ if [ "$stamped" -eq 0 ]; then
3140
+ echo "cr-wave: no delta documents found for '$cr_id'" >&2; return 1
3141
+ fi
3142
+ printf '{"cr_id":"%s","wave":%s,"stamped":%s,"delta_spec":"%s","delta_model":"%s"}\n' \
3143
+ "$(json_escape "$cr_id")" "$wave" "$stamped" \
3144
+ "$(json_escape "$dspec")" "$(json_escape "$dmodel")"
3145
+ }
3146
+
3147
+ # ─────────────────────────────── cr-list ───────────────────────────────────
3148
+ # The changes currently OPEN on a feature, in wave order. This is what lets a delta
3149
+ # stage ASK which change it is working on instead of inferring one: many may be open at
3150
+ # once, and "the most recently opened" is a guess that is wrong as soon as two people
3151
+ # are working in parallel.
3152
+ #
3153
+ # THE EVIDENCE IS THE DIRECTORY. changes/<cr-id>/ exists while the change is open and
3154
+ # moves to changes/archive/<cr-id>/ when it closes, so the tree already answers "what is
3155
+ # open" without trusting a ledger that a crashed run could have left stale.
3156
+ # active_version.json is read alongside it and reported as `tracked`, so a disagreement
3157
+ # between the two is visible rather than silently resolved.
3158
+ #
3159
+ # select_options carries a LABEL per change (id · wave · title). `matched_label` resolves
3160
+ # a --preselect (a cr id, or any unique fragment of one) to the whole label, so a caller
3161
+ # can pass a change id straight through to user_select's preselect_from and skip the
3162
+ # prompt — the engine matches a preselection against the option text exactly.
3163
+ #
3164
+ # TWO SYNTHETIC OPTIONS, off by default. A caller that offers more than "which open
3165
+ # change" asks for them by flag, and gets each one's exact label back so it can test the
3166
+ # selection against it (the engine compares rendered strings; there is no prefix match):
3167
+ # --include-main prepends MAIN — "not a change at all, the feature's own master".
3168
+ # `--preselect main` (or `master`) resolves to it.
3169
+ # --include-new appends NEW — "open a change that has no spec delta". `--preselect
3170
+ # new` resolves to it.
3171
+ # `count` and `has_changes` keep counting REAL open changes, so a caller that gates on
3172
+ # them is unaffected by either flag.
3173
+ cmd_cr_list() {
3174
+ local feature_dir_arg="" preselect="" inc_main="" inc_new=""
3175
+ while [ $# -gt 0 ]; do
3176
+ case "$1" in
3177
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3178
+ --preselect) preselect="$2"; shift 2 ;;
3179
+ --include-main) inc_main="1"; shift ;;
3180
+ --include-new) inc_new="1"; shift ;;
3181
+ --help|-h) echo "cr-list [--feature-dir <path>] [--preselect <cr-id|main|new>] [--include-main] [--include-new] -> {changes[], ids[], count, select_options[], matched_label, main_label, new_label, active_change}"; return 0 ;;
3182
+ *) echo "cr-list: unknown arg '$1'" >&2; return 1 ;;
3183
+ esac
3184
+ done
3185
+ local repo_root; repo_root=$(get_repo_root)
3186
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-list: no feature set and no --feature-dir" >&2; return 1; }
3187
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3188
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_PRE="$(_cr_id_token "$preselect")" \
3189
+ AW_PRE_RAW="$preselect" AW_INC_MAIN="$inc_main" AW_INC_NEW="$inc_new" python3 - <<'PY'
3190
+ import json, os, re
3191
+ fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
3192
+ pre = os.environ.get("AW_PRE", "").strip()
3193
+ # The WHOLE argument, not its first token. A cr id is matched on the token because a
3194
+ # selection label starts with one; the synthetic words are matched on the whole string
3195
+ # so that guidance prose like "new caching layer" — which plan passes here verbatim —
3196
+ # cannot silently resolve to the NEW option.
3197
+ pre_raw = os.environ.get("AW_PRE_RAW", "").strip().lower()
3198
+ inc_main = bool(os.environ.get("AW_INC_MAIN"))
3199
+ inc_new = bool(os.environ.get("AW_INC_NEW"))
3200
+ MAIN_LABEL = "main · the feature's own spec — re-plan the master (not a change)"
3201
+ NEW_LABEL = "new · open a plan-only change — a refactor or architectural decision, no spec delta"
3202
+ def rel(p): return os.path.relpath(p, root)
3203
+ def header(path, name, default=""):
3204
+ try: t = open(path, encoding="utf-8").read()
3205
+ except Exception: return default
3206
+ m = re.search(r"^\*\*%s\*\*:\s*(.+?)\s*$" % re.escape(name), t, re.M)
3207
+ return m.group(1).strip() if m else default
3208
+ def title_of(path):
3209
+ # "# Delta Spec — CR-…: Add discount tiers" -> the part after the id. An unfilled
3210
+ # template still carries the placeholder, which is reported as such rather than
3211
+ # offered to the user as a title.
3212
+ try: first = open(path, encoding="utf-8").readline().strip()
3213
+ except Exception: return ""
3214
+ m = re.match(r"^#\s+.*?:\s*(.+)$", first)
3215
+ t = m.group(1).strip() if m else ""
3216
+ return "" if (not t or t.startswith("[")) else t
3217
+ tracked = []
3218
+ avp = os.path.join(fd, "active_version.json")
3219
+ active = ""
3220
+ try:
3221
+ av = json.load(open(avp, encoding="utf-8"))
3222
+ tracked = [c for c in (av.get("open_changes") or []) if c] or ([av["active_change"]] if av.get("active_change") else [])
3223
+ active = av.get("active_change", "")
3224
+ except Exception: pass
3225
+ changes_dir = os.path.join(fd, "changes")
3226
+ rows = []
3227
+ if os.path.isdir(changes_dir):
3228
+ for name in sorted(os.listdir(changes_dir)):
3229
+ d = os.path.join(changes_dir, name)
3230
+ if name == "archive" or not os.path.isdir(d):
3231
+ continue
3232
+ dspec = os.path.join(d, "delta-spec.md")
3233
+ dmodel = os.path.join(ctx, "domain-model.%s.delta.md" % name)
3234
+ be = os.path.join(d, "delta-backend-plan.md")
3235
+ fe = os.path.join(d, "delta-frontend-plan.md")
3236
+ legacy = os.path.join(d, "delta-design.md")
3237
+ wave = header(dspec, "Wave") or header(dmodel, "Wave") or "1"
3238
+ try: wave = int(re.sub(r"\D", "", wave) or 1)
3239
+ except Exception: wave = 1
3240
+ rows.append({
3241
+ "cr_id": name, "cr_dir": rel(d), "wave": wave,
3242
+ "title": title_of(dspec),
3243
+ "delta_spec": rel(dspec), "delta_spec_exists": os.path.isfile(dspec),
3244
+ "delta_model": rel(dmodel), "delta_model_exists": os.path.isfile(dmodel),
3245
+ "delta_backend_plan": rel(be), "delta_backend_plan_exists": os.path.isfile(be),
3246
+ "delta_frontend_plan": rel(fe), "delta_frontend_plan_exists": os.path.isfile(fe),
3247
+ # A change opened before the lane split; plan-scaffold renames it into the
3248
+ # backend plan on the next planning run.
3249
+ "legacy_delta_design": os.path.isfile(legacy),
3250
+ "tracked": name in tracked,
3251
+ "active": name == active,
3252
+ })
3253
+ rows.sort(key=lambda r: (r["wave"], r["cr_id"]))
3254
+ def label(r):
3255
+ bits = [r["cr_id"], "wave %d" % r["wave"]]
3256
+ bits.append(r["title"] or "(delta-spec not authored yet)")
3257
+ planned = [k for k, v in (("backend", r["delta_backend_plan_exists"]), ("frontend", r["delta_frontend_plan_exists"])) if v]
3258
+ if planned: bits.append("planned: " + "+".join(planned))
3259
+ return " · ".join(bits)
3260
+ cr_opts = [label(r) for r in rows]
3261
+ matched = ""
3262
+ if pre:
3263
+ exact = [l for l, r in zip(cr_opts, rows) if r["cr_id"] == pre]
3264
+ part = [l for l, r in zip(cr_opts, rows) if pre in r["cr_id"]]
3265
+ if exact: matched = exact[0]
3266
+ elif len(part) == 1: matched = part[0]
3267
+ # The synthetic options bracket the real ones: MAIN first (it is the default reading of
3268
+ # "plan this feature"), NEW last (it CREATES something, so it sits away from the choices
3269
+ # that only select). Their words are also accepted as a --preselect, which is how a bare
3270
+ # `plan main` on the command line skips the prompt.
3271
+ opts = ([MAIN_LABEL] if inc_main else []) + cr_opts + ([NEW_LABEL] if inc_new else [])
3272
+ if not matched and pre_raw:
3273
+ if inc_main and pre_raw in ("main", "master"): matched = MAIN_LABEL
3274
+ elif inc_new and pre_raw == "new": matched = NEW_LABEL
3275
+ print(json.dumps({
3276
+ "changes": rows, "ids": [r["cr_id"] for r in rows], "count": len(rows),
3277
+ "select_options": opts, "matched_label": matched,
3278
+ "main_label": MAIN_LABEL if inc_main else "",
3279
+ "new_label": NEW_LABEL if inc_new else "",
3280
+ "active_change": active, "tracked": tracked,
3281
+ "has_changes": len(rows) > 0,
3282
+ # The lone open change, so a caller can take it without a prompt when there is
3283
+ # nothing to choose between.
3284
+ "only_id": rows[0]["cr_id"] if len(rows) == 1 else "",
3285
+ }))
3286
+ PY
3287
+ }
3288
+
3289
+ # ─────────────────────────────── cr-paths ──────────────────────────────────
3290
+ # Everything one change needs to be PLANNED: its own documents, its wave, and the model
3291
+ # deltas that come BEFORE it.
3292
+ #
3293
+ # PREDECESSORS ARE THE LOWER WAVES. A wave is a dependency level — same wave means
3294
+ # independent and developable in parallel, a higher wave means "after those have landed".
3295
+ # So the deltas a plan may build on are exactly the open ones in a LOWER wave: they are
3296
+ # the ones committed to landing first. Same-wave deltas are reported separately as
3297
+ # siblings: visible, so an overlap is designed around, but never assumed, because nothing
3298
+ # orders them against each other.
3299
+ cmd_cr_paths() {
3300
+ local cr_id="" feature_dir_arg=""
3301
+ while [ $# -gt 0 ]; do
3302
+ case "$1" in
3303
+ --cr-id) cr_id="$2"; shift 2 ;;
3304
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3305
+ --help|-h) echo "cr-paths --cr-id <id> [--feature-dir <path>] -> {cr_id, cr_dir, delta_spec, delta_model, delta_backend_plan, delta_frontend_plan, wave, predecessor_files[], predecessor_specs[], predecessor_docs[], sibling_files[], domain_model}"; return 0 ;;
3306
+ *) echo "cr-paths: unknown arg '$1'" >&2; return 1 ;;
3307
+ esac
3308
+ done
3309
+ cr_id=$(_cr_id_token "$cr_id")
3310
+ [ -n "$cr_id" ] || { echo "cr-paths: --cr-id is required" >&2; return 1; }
3311
+ local repo_root; repo_root=$(get_repo_root)
3312
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-paths: no feature set and no --feature-dir" >&2; return 1; }
3313
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3314
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_CR="$cr_id" python3 - <<'PY'
3315
+ import json, os, re, glob
3316
+ fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]; cr = os.environ["AW_CR"]
3317
+ def rel(p): return os.path.relpath(p, root)
3318
+ def wave_of(path, default=1):
3319
+ try: t = open(path, encoding="utf-8").read()
3320
+ except Exception: return default
3321
+ m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", t, re.M)
3322
+ return int(m.group(1)) if m else default
3323
+ cr_dir = os.path.join(fd, "changes", cr)
3324
+ dspec = os.path.join(cr_dir, "delta-spec.md")
3325
+ dmodel = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
3326
+ be = os.path.join(cr_dir, "delta-backend-plan.md")
3327
+ fe = os.path.join(cr_dir, "delta-frontend-plan.md")
3328
+ master = os.path.join(ctx, "domain-model.md")
3329
+ wave = wave_of(dspec, wave_of(dmodel, 1))
3330
+ # A change's model delta sits at CONTEXT level and its delta-spec at FEATURE level, so the
3331
+ # spec of another open change is found by walking the context's features rather than
3332
+ # assuming it belongs to this one — two features of one context can both have changes open.
3333
+ def spec_of(other):
3334
+ for c in sorted(glob.glob(os.path.join(ctx, "*", "changes", other, "delta-spec.md"))):
3335
+ return c
3336
+ return ""
3337
+ pred, sib, pred_rows, sib_rows, pred_specs = [], [], [], [], []
3338
+ for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
3339
+ base = os.path.basename(f)
3340
+ other = base[len("domain-model."):-len(".delta.md")]
3341
+ if other == cr:
3342
+ continue
3343
+ w = wave_of(f)
3344
+ s = spec_of(other)
3345
+ row = {"cr_id": other, "file": rel(f), "wave": w, "delta_spec": rel(s) if s else ""}
3346
+ if w < wave:
3347
+ pred.append(rel(f)); pred_rows.append(row)
3348
+ if s: pred_specs.append(rel(s))
3349
+ else:
3350
+ sib.append(rel(f)); sib_rows.append(row)
3351
+ print(json.dumps({
3352
+ "cr_id": cr, "cr_dir": rel(cr_dir), "exists": os.path.isdir(cr_dir),
3353
+ "delta_spec": rel(dspec), "delta_spec_exists": os.path.isfile(dspec),
3354
+ "delta_model": rel(dmodel), "delta_model_exists": os.path.isfile(dmodel),
3355
+ "delta_backend_plan": rel(be), "delta_backend_plan_exists": os.path.isfile(be),
3356
+ "delta_frontend_plan": rel(fe), "delta_frontend_plan_exists": os.path.isfile(fe),
3357
+ "wave": wave,
3358
+ "context_dir": rel(ctx),
3359
+ "domain_model": rel(master), "domain_model_exists": os.path.isfile(master),
3360
+ # Lower wave = committed to landing first, so this delta may plan on top of it.
3361
+ "predecessor_files": pred, "predecessors": pred_rows, "predecessor_count": len(pred),
3362
+ # Each predecessor's SPEC half. The model delta says what the domain becomes; the
3363
+ # delta-spec says what behaviour asked for it, and a gate that checks one without the
3364
+ # other cannot tell a realisation of a predecessor from an invention.
3365
+ "predecessor_specs": pred_specs,
3366
+ # Both halves in one list, for a build_context `context_from:` (which takes one var).
3367
+ "predecessor_docs": pred + pred_specs,
3368
+ # Same or higher wave = nothing orders it against this one. Visible, never assumed.
3369
+ "sibling_files": sib, "siblings": sib_rows, "sibling_count": len(sib),
3370
+ }))
3371
+ PY
3372
+ }
3373
+
3374
+ # ─────────────────────────────── cr-overlap ────────────────────────────────
3375
+ # Which OTHER open changes touch the same aggregates as this one — read off the
3376
+ # `## Aggregate: <Name>` headers of the pending model deltas, which is the merge unit
3377
+ # domain-model.md is organised by.
3378
+ #
3379
+ # DETERMINISTIC ON PURPOSE. Two deltas collide when they change the same aggregate, and
3380
+ # that is a fact about two files' headings — a regex answers it exactly, at no cost, and
3381
+ # the same way every run. Handing the question to a model instead would make the one
3382
+ # check that can be exact the one check that can hallucinate.
3383
+ #
3384
+ # EARLY WARNING, NOT ENFORCEMENT. An overlap is not an error: two changes to one
3385
+ # aggregate are ordinary, and expand-then-contract is the way through. What matters is
3386
+ # that the second one is PLANNED knowing about the first instead of discovering it at
3387
+ # merge time, which is why this runs at the gate and finalize-delta still holds the
3388
+ # base_version check.
3389
+ #
3390
+ # The relation follows the wave: a LOWER wave is a predecessor (committed to land first —
3391
+ # an overlap with it is a dependency to declare), the SAME wave is a sibling (nothing
3392
+ # orders the two — an overlap with it is a conflict to design around), a HIGHER wave is a
3393
+ # successor (it lands after this change and plans on top of it — an overlap with it is
3394
+ # information, not an obligation). cr-paths splits at the same line but only two ways,
3395
+ # because for CONTEXT the question is only "may I build on this"; here the three-way
3396
+ # answer is the advice itself.
3397
+ cmd_cr_overlap() {
3398
+ local cr_id="" feature_dir_arg=""
3399
+ while [ $# -gt 0 ]; do
3400
+ case "$1" in
3401
+ --cr-id) cr_id="$2"; shift 2 ;;
3402
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3403
+ --help|-h) echo "cr-overlap --cr-id <id> [--feature-dir <path>] -> {cr_id, wave, aggregates[], overlaps[], overlap_count, predecessor_overlaps, sibling_overlaps, successor_overlaps, overlap_report}"; return 0 ;;
3404
+ *) echo "cr-overlap: unknown arg '$1'" >&2; return 1 ;;
3405
+ esac
3406
+ done
3407
+ cr_id=$(_cr_id_token "$cr_id")
3408
+ [ -n "$cr_id" ] || { echo "cr-overlap: --cr-id is required" >&2; return 1; }
3409
+ local repo_root; repo_root=$(get_repo_root)
3410
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-overlap: no feature set and no --feature-dir" >&2; return 1; }
3411
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3412
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_CR="$cr_id" python3 - <<'PY'
3413
+ import json, os, re, glob
3414
+ fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]; cr = os.environ["AW_CR"]
3415
+ def rel(p): return os.path.relpath(p, root)
3416
+ def read(p):
3417
+ try: return open(p, encoding="utf-8").read()
3418
+ except Exception: return ""
3419
+ def wave_of(text, default=1):
3420
+ m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", text, re.M)
3421
+ return int(m.group(1)) if m else default
3422
+ # The merge unit. A placeholder heading left by the template names no aggregate, so an
3423
+ # unfilled model delta reports none rather than colliding with every other change on
3424
+ # "<Name>".
3425
+ def aggregates_of(text):
3426
+ out = []
3427
+ for m in re.finditer(r"^##\s*Aggregate:\s*(.+?)\s*$", text, re.M):
3428
+ name = m.group(1).strip().strip("`")
3429
+ if not name or name.startswith("<") or name.startswith("["):
3430
+ continue
3431
+ if name not in out:
3432
+ out.append(name)
3433
+ return out
3434
+ mine_txt = read(os.path.join(ctx, "domain-model.%s.delta.md" % cr))
3435
+ wave = wave_of(read(os.path.join(fd, "changes", cr, "delta-spec.md")), wave_of(mine_txt, 1))
3436
+ mine = aggregates_of(mine_txt)
3437
+ mine_set = set(mine)
3438
+ rows, parts, npred, nsib, nsucc = [], [], 0, 0, 0
3439
+ for f in sorted(glob.glob(os.path.join(ctx, "domain-model.*.delta.md"))):
3440
+ other = os.path.basename(f)[len("domain-model."):-len(".delta.md")]
3441
+ if other == cr:
3442
+ continue
3443
+ t = read(f)
3444
+ w = wave_of(t)
3445
+ shared = [a for a in aggregates_of(t) if a in mine_set]
3446
+ if not shared:
3447
+ continue
3448
+ if w < wave: relation = "predecessor"; npred += 1
3449
+ elif w == wave: relation = "sibling"; nsib += 1
3450
+ else: relation = "successor"; nsucc += 1
3451
+ rows.append({"cr_id": other, "wave": w, "relation": relation,
3452
+ "file": rel(f), "aggregates": shared})
3453
+ parts.append("%s (wave %d, %s): %s" % (other, w, relation, ", ".join(shared)))
3454
+ print(json.dumps({
3455
+ "cr_id": cr, "wave": wave, "aggregates": mine,
3456
+ "overlaps": rows, "overlap_count": len(rows),
3457
+ "predecessor_overlaps": npred, "sibling_overlaps": nsib,
3458
+ "successor_overlaps": nsucc,
3459
+ # One line for a prompt: the model is told the fact, never asked to derive it.
3460
+ "overlap_report": ("; ".join(parts) if parts
3461
+ else "none — no other open change touches these aggregates"),
3462
+ }))
3463
+ PY
3464
+ }
3465
+
3466
+ # ─────────────────────────────── cr-activate ───────────────────────────────
3467
+ # Point active_version.json.active_change at an OPEN change, changing nothing else — no
3468
+ # version bump, no wave restamp, no scaffold.
3469
+ #
3470
+ # WHY IT EXISTS. Every delta stage after the plan phase still resolves "the" change
3471
+ # through active_change. Once the plan phase lets a user CHOOSE which open delta to plan,
3472
+ # that choice has to reach those stages or they would act on a different change than the
3473
+ # one just planned. Activating the chosen change is what keeps the chain pointed at one
3474
+ # delta while --cr-id is threaded through the rest of it.
3475
+ cmd_cr_activate() {
3476
+ local cr_id="" feature_dir_arg=""
3477
+ while [ $# -gt 0 ]; do
3478
+ case "$1" in
3479
+ --cr-id) cr_id="$2"; shift 2 ;;
3480
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3481
+ --help|-h) echo "cr-activate --cr-id <id> [--feature-dir <path>]"; return 0 ;;
3482
+ *) echo "cr-activate: unknown arg '$1'" >&2; return 1 ;;
3483
+ esac
3484
+ done
3485
+ cr_id=$(_cr_id_token "$cr_id")
3486
+ [ -n "$cr_id" ] || { echo "cr-activate: --cr-id is required" >&2; return 1; }
3487
+ local repo_root; repo_root=$(get_repo_root)
3488
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-activate: no feature set and no --feature-dir" >&2; return 1; }
3489
+ _active_version "$fd" activate "$cr_id"
4619
3490
  }
4620
3491
 
4621
3492
  cmd_cr_archive() {
@@ -4641,9 +3512,21 @@ cmd_cr_archive() {
4641
3512
  mv "$cr_dir" "$archive_root/$cr_id"
4642
3513
  archived="$archive_root/$cr_id"
4643
3514
  fi
4644
- # Close the change in active_version.json (single version source): bump minor +
4645
- # clear active_change. (Supersedes the old bare <feature>/VERSION file.)
4646
- local av; av=$(_active_version "$fd" close)
3515
+ # The delta's model half lives beside the context's master model, not inside the
3516
+ # change directory, so archiving the directory does not carry it along. It is moved
3517
+ # into the same archive: a merged delta's model changes are in domain-model.md by
3518
+ # now, and leaving the pending file behind would keep showing a landed change as
3519
+ # in-flight to every other open delta.
3520
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3521
+ local dmodel="$ctx_dir/domain-model.$cr_id.delta.md" model_archived=""
3522
+ if [ -f "$dmodel" ]; then
3523
+ mkdir -p "$archive_root/$cr_id"
3524
+ mv -f "$dmodel" "$archive_root/$cr_id/domain-model.delta.md"
3525
+ model_archived="$archive_root/$cr_id/domain-model.delta.md"
3526
+ fi
3527
+ # Close THIS change in active_version.json (single version source): bump minor +
3528
+ # drop it from open_changes. Other changes on the feature stay open.
3529
+ local av; av=$(_active_version "$fd" close "$cr_id")
4647
3530
  AW_AV="$av" AW_ARCHIVED="$archived" python3 - <<'PY'
4648
3531
  import json, os
4649
3532
  try: av = json.loads(os.environ.get("AW_AV") or "{}")
@@ -4687,10 +3570,13 @@ stamped, unchanged, missing, no_header = [], [], [], []
4687
3570
  # First `**Version**: <value>` line only — that is the header. The Change History
4688
3571
  # table's `| Version |` column head does not match this pattern.
4689
3572
  pat = re.compile(r'^(\*\*Version\*\*:[ \t]*)(\S+)(.*)$', re.M)
4690
- # design.ui.md joins the spine where the feature has a UI half: the masters move in
3573
+ # Each declared lane's plan joins the spine beside the spec: the masters move in
4691
3574
  # LOCKSTEP, so a document left behind at the old number is the one lie the version
4692
- # spine cannot tolerate. A feature without one reports it `missing` and nothing fails.
4693
- for name in ("spec.md", "design.md", "design.ui.md"):
3575
+ # spine cannot tolerate. A lane the feature does not have reports `missing` and nothing
3576
+ # fails, which is also how the legacy names are carried — a feature not yet migrated to
3577
+ # the plan names still gets stamped, and one that has been migrated reports the legacy
3578
+ # pair missing.
3579
+ for name in ("spec.md", "backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
4694
3580
  p = os.path.join(fd, name)
4695
3581
  if not os.path.isfile(p):
4696
3582
  missing.append(name); continue
@@ -4709,701 +3595,6 @@ print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
4709
3595
  PY
4710
3596
  }
4711
3597
 
4712
-
4713
- # ─────────────────────────── plan-check / plan-stamp ────────────────────────
4714
- # The plan documents' derivation tracking. Each plan doc (backend-plan.md /
4715
- # frontend-plan.md) derives from ONE spec version and carries its OWN track file
4716
- # beside the spec — .aw-plan-track.backend-plan.json / .aw-plan-track.frontend-plan.json
4717
- # — recording {spec_version, sha256}, so the two lanes' stamps never share a write.
4718
- # plan-check reads the record back against the spec's Change History ledger;
4719
- # plan-stamp is the only writer.
4720
- # Both always exit 0 with an ok/state field — the workflow's verify_context owns
4721
- # the stop (the lane-verify convention).
4722
- cmd_plan_check() {
4723
- local fd_arg="" doc=""
4724
- while [ $# -gt 0 ]; do
4725
- case "$1" in
4726
- --feature-dir) fd_arg="$2"; shift 2 ;;
4727
- --doc) doc="$2"; shift 2 ;;
4728
- --help|-h) echo "plan-check --doc <path> [--feature-dir <path>]"; return 0 ;;
4729
- *) echo "plan-check: unknown arg '$1'" >&2; return 1 ;;
4730
- esac
4731
- done
4732
- [ -n "$doc" ] || { echo "plan-check: --doc required" >&2; return 1; }
4733
- local repo_root; repo_root=$(get_repo_root)
4734
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "plan-check: no feature set and no --feature-dir" >&2; return 1; }
4735
- AW_DOC="$doc" AW_FD="$fd" python3 - <<'PY'
4736
- import json, os, re, sys, hashlib
4737
- doc = os.environ["AW_DOC"]; fd = os.environ["AW_FD"]
4738
- spec = os.path.join(fd, "spec.md")
4739
- # One track file per plan document (.aw-plan-track.backend-plan.json /
4740
- # .aw-plan-track.frontend-plan.json) — parallel lane stamps never contend on a shared file.
4741
- stem = os.path.splitext(os.path.basename(doc))[0]
4742
- track_path = os.path.join(fd, ".aw-plan-track.%s.json" % stem)
4743
- def vtuple(v):
4744
- try: return tuple(int(x) for x in v.lstrip("vV").split("."))
4745
- except Exception: return (0,)
4746
- out = {"doc": doc, "doc_exists": os.path.isfile(doc), "state": "", "derived_version": "",
4747
- "current_version": "", "changed_csv": "", "changed_summary": "", "body_changed": False}
4748
- # Current version = the widest of the ledger's rows and the **Version** header — the
4749
- # same widest-wins rule ledger-append uses, so a hand-moved header cannot hide a row.
4750
- rows = []; cur = ""; idcol = -1
4751
- if os.path.isfile(spec):
4752
- text = open(spec, encoding="utf-8", errors="replace").read()
4753
- m = re.search(r'^##\s+Change History\s*$', text, re.M)
4754
- if m:
4755
- for line in text[m.end():].splitlines():
4756
- if idcol < 0 and line.lstrip().startswith("|") and "requirements changed" in line.lower():
4757
- hdr = [c.strip().lower() for c in line.strip().strip("|").split("|")]
4758
- idcol = next((i for i, c in enumerate(hdr) if "requirements changed" in c), -1)
4759
- rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
4760
- if rm: rows.append((rm.group(1), line))
4761
- hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', text, re.M)
4762
- cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
4763
- if cands: cur = max(cands, key=vtuple)
4764
- out["current_version"] = cur
4765
- if not out["doc_exists"]:
4766
- out["state"] = "fresh"; print(json.dumps(out)); sys.exit(0)
4767
- try: track = json.load(open(track_path, encoding="utf-8"))
4768
- except Exception: track = {}
4769
- entry = track.get(os.path.basename(doc))
4770
- if not entry:
4771
- # A document with no derivation record — a migrated legacy master, or a baseline
4772
- # lost by hand. There is no stamp to diff against, so the caller reconciles the
4773
- # WHOLE document once and plan-stamp baselines it.
4774
- out["state"] = "untracked"; print(json.dumps(out)); sys.exit(0)
4775
- derived = entry.get("spec_version", "")
4776
- out["derived_version"] = derived
4777
- body = open(doc, encoding="utf-8", errors="replace").read()
4778
- out["body_changed"] = hashlib.sha256(body.encode("utf-8")).hexdigest() != entry.get("sha256", "")
4779
- moved = [r for r in rows if vtuple(r[0]) > vtuple(derived)]
4780
- # Scrape ONLY the ledger's "Requirements changed" cell when it names one. The
4781
- # Stories cell holds ids of the form <Aggregate>-nnn, which read as requirement ids
4782
- # whenever the aggregate is spelled in caps (EDI-001, HR-002) — those would arrive
4783
- # as requirements for the plan to conform, and there is no such requirement.
4784
- def _scope(line):
4785
- if idcol >= 0:
4786
- cells = [c.strip() for c in line.strip().strip("|").split("|")]
4787
- if idcol < len(cells): return cells[idcol]
4788
- return line
4789
- ids = sorted({i for _v, line in moved for i in re.findall(r'[A-Z]{2}-\d+', _scope(line))})
4790
- out["changed_csv"] = ", ".join(ids)
4791
- out["changed_summary"] = " // ".join(line.strip() for _v, line in moved)
4792
- behind = bool(moved) or bool(cur and vtuple(cur) > vtuple(derived))
4793
- if out["body_changed"] and behind: out["state"] = "edited_behind"
4794
- elif out["body_changed"]: out["state"] = "edited"
4795
- elif behind: out["state"] = "behind"
4796
- else: out["state"] = "clean"
4797
- print(json.dumps(out))
4798
- PY
4799
- }
4800
-
4801
- cmd_plan_stamp() {
4802
- local fd_arg="" doc="" ids="" note="" no_row="false"
4803
- while [ $# -gt 0 ]; do
4804
- case "$1" in
4805
- --feature-dir) fd_arg="$2"; shift 2 ;;
4806
- --doc) doc="$2"; shift 2 ;;
4807
- --ids) ids="$2"; shift 2 ;;
4808
- --note) note="$2"; shift 2 ;;
4809
- --no-row) no_row="true"; shift ;;
4810
- --help|-h) echo "plan-stamp --doc <path> [--feature-dir <path>] [--ids <csv>] [--note <text>] [--no-row]"; return 0 ;;
4811
- *) echo "plan-stamp: unknown arg '$1'" >&2; return 1 ;;
4812
- esac
4813
- done
4814
- [ -n "$doc" ] || { echo "plan-stamp: --doc required" >&2; return 1; }
4815
- local repo_root; repo_root=$(get_repo_root)
4816
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "plan-stamp: no feature set and no --feature-dir" >&2; return 1; }
4817
- AW_DOC="$doc" AW_FD="$fd" AW_IDS="$ids" AW_NOTE="$note" AW_NOROW="$no_row" python3 - <<'PY'
4818
- import json, os, re, sys, hashlib, datetime
4819
- doc = os.environ["AW_DOC"]; fd = os.environ["AW_FD"]
4820
- ids = os.environ.get("AW_IDS", "").strip(); note = os.environ.get("AW_NOTE", "").strip()
4821
- no_row = os.environ.get("AW_NOROW", "false") == "true"
4822
- spec = os.path.join(fd, "spec.md")
4823
- # One track file per plan document (.aw-plan-track.backend-plan.json /
4824
- # .aw-plan-track.frontend-plan.json) — parallel lane stamps never contend on a shared file.
4825
- stem = os.path.splitext(os.path.basename(doc))[0]
4826
- track_path = os.path.join(fd, ".aw-plan-track.%s.json" % stem)
4827
- def vtuple(v):
4828
- try: return tuple(int(x) for x in v.lstrip("vV").split("."))
4829
- except Exception: return (0,)
4830
- if not os.path.isfile(doc):
4831
- print(json.dumps({"ok": False, "error": "plan document %s does not exist" % doc, "version": ""})); sys.exit(0)
4832
- cur = ""
4833
- if os.path.isfile(spec):
4834
- st = open(spec, encoding="utf-8", errors="replace").read()
4835
- m = re.search(r'^##\s+Change History\s*$', st, re.M)
4836
- rows = re.findall(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', st[m.end():] if m else "", re.M)
4837
- hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', st, re.M)
4838
- cands = rows + ([hv.group(1)] if hv else [])
4839
- if cands: cur = max(cands, key=vtuple)
4840
- text = open(doc, encoding="utf-8", errors="replace").read()
4841
- # The ledger-survival fence: a plan whose update dropped the Change History section is
4842
- # NOT baselined — a lost ledger silently baselined over could never be noticed again.
4843
- hist = re.search(r'^##+\s*(?:\d+\.\s*)?Change History\s*$', text, re.M)
4844
- if not hist:
4845
- print(json.dumps({"ok": False, "version": cur,
4846
- "error": "the plan document has no 'Change History' section — the write dropped the ledger; nothing was baselined"}))
4847
- sys.exit(0)
4848
- warnings = []
4849
- if not re.search(r'^##+.*Assumptions', text, re.M):
4850
- warnings.append("no 'Assumptions' section found")
4851
- if not re.search(r'^##+.*(Workarounds|Deviations)', text, re.M):
4852
- warnings.append("no 'Workarounds/Deviations' section found")
4853
- row_txt = ""
4854
- if not no_row:
4855
- # Bounded to the Change History section; the row's cell count follows the section's
4856
- # own table so the append fits the greenfield, UI and onboard-generated shapes alike.
4857
- lines = text.split("\n")
4858
- hline = text[:hist.start()].count("\n")
4859
- end = len(lines)
4860
- for i in range(hline + 1, len(lines)):
4861
- if re.match(r'^##', lines[i]): end = i; break
4862
- tbl = [i for i in range(hline + 1, end) if lines[i].lstrip().startswith("|")]
4863
- ncells = 5
4864
- if tbl: ncells = max(3, lines[tbl[0]].strip().strip("|").count("|") + 1)
4865
- change = note or ("Conformed to spec %s" % (cur or "(unversioned)"))
4866
- if ids: change = "%s — %s" % (change, ids)
4867
- change = change.replace("|", "/").replace("\n", " ")
4868
- cells = [cur or "v?", datetime.date.today().isoformat(), change] + ["—"] * (ncells - 3)
4869
- row_txt = "| " + " | ".join(cells[:ncells]) + " |"
4870
- lines.insert((tbl[-1] + 1) if tbl else end, row_txt)
4871
- text = "\n".join(lines)
4872
- if cur:
4873
- text = re.sub(r'^(\*\*Version\*\*:\s*)v[\d.]+', r'\g<1>' + cur, text, count=1, flags=re.M)
4874
- open(doc, "w", encoding="utf-8").write(text)
4875
- try: track = json.load(open(track_path, encoding="utf-8"))
4876
- except Exception: track = {}
4877
- track[os.path.basename(doc)] = {
4878
- "spec_version": cur,
4879
- "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
4880
- "updated": datetime.datetime.now().isoformat(timespec="seconds")}
4881
- open(track_path, "w", encoding="utf-8").write(json.dumps(track, indent=2) + "\n")
4882
- print(json.dumps({"ok": True, "version": cur, "row": row_txt, "warnings": warnings}))
4883
- PY
4884
- }
4885
-
4886
-
4887
- # ───────────────────────────────── gate-result ──────────────────────────────
4888
- # The machine-readable verdict every analyze gate writes beside its markdown
4889
- # report. CI cannot branch on prose, and a verdict is exactly the kind of dated,
4890
- # derived record the model must never write — so the script writes it.
4891
- #
4892
- # Shared by design: analyze-design writes one per feature, and analyze-slice /
4893
- # analyze-code will write one per story and per run. The CORE keys are fixed
4894
- # (gate, status, feature, spec_version, story, gap_count, gap_refs, report,
4895
- # generated_at) so a pipeline can read any gate's result with one expression;
4896
- # everything gate-specific arrives through repeatable --field k=v and lands under
4897
- # "fields", where it cannot destabilise that core.
4898
- #
4899
- # NOTE the returned `ok` means THE FILE WAS WRITTEN. The pass/fail verdict is
4900
- # `status` — binding a workflow gate to `ok` would pass on every run.
4901
- cmd_gate_result() {
4902
- local fd_arg="" gate="" ok="" version="" story="" report="" out="" gaps="" refs="" note="" fields=""
4903
- while [ $# -gt 0 ]; do
4904
- case "$1" in
4905
- --gate) gate="$2"; shift 2 ;;
4906
- --feature-dir) fd_arg="$2"; shift 2 ;;
4907
- --ok) ok="$2"; shift 2 ;;
4908
- --spec-version) version="$2"; shift 2 ;;
4909
- --story) story="$2"; shift 2 ;;
4910
- --report) report="$2"; shift 2 ;;
4911
- --out) out="$2"; shift 2 ;;
4912
- --gap-count) gaps="$2"; shift 2 ;;
4913
- --gap-refs) refs="$2"; shift 2 ;;
4914
- --note) note="$2"; shift 2 ;;
4915
- --field) fields="${fields}$2"$'\n'; shift 2 ;;
4916
- --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 ;;
4917
- *) echo "gate-result: unknown arg '$1'" >&2; return 1 ;;
4918
- esac
4919
- done
4920
- [ -n "$gate" ] || { echo "gate-result: --gate required" >&2; return 1; }
4921
- local repo_root; repo_root=$(get_repo_root)
4922
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "gate-result: no feature set and no --feature-dir" >&2; return 1; }
4923
- AW_GATE="$gate" AW_FD="$fd" AW_OK="$ok" AW_VERSION="$version" AW_STORY="$story" \
4924
- AW_REPORT="$report" AW_OUT="$out" AW_GAPS="$gaps" AW_REFS="$refs" AW_NOTE="$note" \
4925
- AW_FIELDS="$fields" python3 - <<'PY'
4926
- import json, os, sys, datetime
4927
- gate = (os.environ.get("AW_GATE", "") or "").strip() or "gate"
4928
- fd = os.environ.get("AW_FD", "")
4929
- report = (os.environ.get("AW_REPORT", "") or "").strip()
4930
- out = (os.environ.get("AW_OUT", "") or "").strip()
4931
- # "pass" only on an explicit affirmative. An absent or unreadable verdict is a FAIL,
4932
- # because a result that defaults green turns every broken run into a passing one.
4933
- ok = (os.environ.get("AW_OK", "") or "").strip().lower() in ("true", "1", "yes", "pass", "ok")
4934
- # Written BESIDE the markdown report and named after it, so a job holding either path
4935
- # holds the other. --out overrides; with no report named at all it falls back to a
4936
- # dotfile in the feature directory under the gate's own name.
4937
- if not out:
4938
- out = (os.path.splitext(report)[0] + ".json") if report else os.path.join(fd, ".aw-gate-%s.json" % gate)
4939
- try: gap_count = int((os.environ.get("AW_GAPS", "") or "").strip() or 0)
4940
- except ValueError: gap_count = 0
4941
- refs = [r.strip() for r in (os.environ.get("AW_REFS", "") or "").replace(";", ",").split(",") if r.strip()]
4942
- # A fail reporting zero findings reads as green to anything that sums the number
4943
- # rather than reading the status, so a red verdict always carries at least one.
4944
- if not ok and gap_count < 1: gap_count = max(1, len(refs))
4945
- # --field k=v, repeatable, arriving newline-separated. Keeps the CORE schema below
4946
- # fixed for every gate while each one carries its own detail underneath.
4947
- fields = {}
4948
- for line in (os.environ.get("AW_FIELDS", "") or "").splitlines():
4949
- k, sep, v = line.partition("=")
4950
- if sep and k.strip(): fields[k.strip()] = v.strip()
4951
- res = {"gate": gate,
4952
- "status": "pass" if ok else "fail",
4953
- "feature": os.path.basename(fd.rstrip("/\\")),
4954
- "feature_dir": fd,
4955
- "spec_version": (os.environ.get("AW_VERSION", "") or "").strip(),
4956
- "story": (os.environ.get("AW_STORY", "") or "").strip(),
4957
- "gap_count": gap_count,
4958
- "gap_refs": refs,
4959
- "report": report,
4960
- "note": (os.environ.get("AW_NOTE", "") or "").strip(),
4961
- "fields": fields,
4962
- "generated_at": datetime.datetime.now().isoformat(timespec="seconds")}
4963
- d = os.path.dirname(out)
4964
- if d and not os.path.isdir(d): os.makedirs(d, exist_ok=True)
4965
- open(out, "w", encoding="utf-8").write(json.dumps(res, indent=2) + "\n")
4966
- print(json.dumps({"ok": True, "result_file": out, "status": res["status"], "gap_count": gap_count}))
4967
- PY
4968
- }
4969
-
4970
- # ─────────────── story-plan-check / story-plan-doc / log-append ───────────────
4971
- # The per-story execution design ("story design") — SHORT-LIVED scaffolding, ONE
4972
- # DOCUMENT PER LANE beside the story: stories/<ID>/story-backend-plan.md and
4973
- # stories/<ID>/story-frontend-plan.md. Every /appweaver story-plan run
4974
- # APPENDS a dated "## Design pass N" section; the LATEST pass wins and nothing is
4975
- # edited in place, so a manual edit to an earlier pass survives a re-run. The
4976
- # document's identity is positional (its directory): no story id appears in its
4977
- # text. Writing into stories/<ID>/ is what flips the story IN-FLIGHT for
4978
- # sync-stories' follow-on rule — the same isdir test story-spec's projection used
4979
- # to trip.
4980
- #
4981
- # story-plan-check resolves WHICH story (an explicit --story, an id found in the
4982
- # free-text input, or the open backlog for an ask), and reports everything the
4983
- # workflow gates on: status, cited ids, the Constraints section's bound ids, ledger
4984
- # rows above the story's stamp (staleness), doc/pass state and the feature's
4985
- # append-only log paths. Always exits 0 with ok/flag fields — the workflow's
4986
- # verify_context owns every stop (the lane-verify convention).
4987
- #
4988
- # story-plan-doc is the ONLY writer of either lane's design: --lane picks the file,
4989
- # and it scaffolds the shell (frontmatter + contract comment) on first use, appends
4990
- # the authored pass under a SCRIPT-written header (the model writes no pass number,
4991
- # date or version), stamps the frontmatter and removes the temp pass file. Pass
4992
- # numbers are per lane. A pre-split story-plan.md is reported by story-plan-check as
4993
- # legacy_doc and touched by nothing.
4994
- #
4995
- # log-append owns the feature's two append-only logs: interfaces.md (IF-nnn — the
4996
- # FE/BE contract anchor; BE design appends status:proposed, story close verifies
4997
- # against the as-built and flips to live) and adr.md (ADR-nnn — ratified decisions).
4998
- # The script assigns ids, dates and status; entries are never edited afterwards,
4999
- # only superseded by later entries naming `supersedes:`.
5000
- cmd_story_plan_check() {
5001
- local fd_arg="" input="" story=""
5002
- while [ $# -gt 0 ]; do
5003
- case "$1" in
5004
- --feature-dir) fd_arg="$2"; shift 2 ;;
5005
- --input) input="$2"; shift 2 ;;
5006
- --story) story="$2"; shift 2 ;;
5007
- --help|-h) echo "story-plan-check [--story <id>] [--input <free text>] [--feature-dir <path>]"; return 0 ;;
5008
- *) echo "story-plan-check: unknown arg '$1'" >&2; return 1 ;;
5009
- esac
5010
- done
5011
- local repo_root; repo_root=$(get_repo_root)
5012
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-plan-check: no feature set and no --feature-dir" >&2; return 1; }
5013
- AW_FD="$fd" AW_INPUT="$input" AW_STORY="$story" python3 - <<'PY'
5014
- import json, os, re, sys
5015
- fd = os.environ["AW_FD"]
5016
- sdir = os.path.join(fd, "stories")
5017
- spec = os.path.join(fd, "spec.md")
5018
-
5019
- def parse_fm(text):
5020
- """Frontmatter as a flat dict — inline and block-list values both fold to the
5021
- inline shape (same reader story-refs uses, for the same reason)."""
5022
- m = re.match(r'^---\s*\n(.*?)\n---\s*\n', text, re.S)
5023
- if not m: return {}
5024
- fm, key, block = {}, None, set()
5025
- for line in m.group(1).split("\n"):
5026
- km = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
5027
- if km:
5028
- key = km.group(1); fm[key] = km.group(2).strip(); continue
5029
- im = re.match(r'^\s+-\s+(.*)$', line) if key else None
5030
- if im and (key in block or not fm[key]):
5031
- block.add(key)
5032
- fm[key] = (fm[key] + ", " + im.group(1).strip()) if fm[key] else im.group(1).strip()
5033
- return fm
5034
-
5035
- def vtuple(v):
5036
- try: return tuple(int(x) for x in v.lstrip("vV").split("."))
5037
- except Exception: return (0,)
5038
-
5039
- # ---- the backlog ------------------------------------------------------------
5040
- stories = []
5041
- if os.path.isdir(sdir):
5042
- for name in sorted(os.listdir(sdir)):
5043
- # stories.md is the GENERATED index beside the stories, not a story
5044
- if not name.endswith(".md") or name == "stories.md": continue
5045
- p = os.path.join(sdir, name)
5046
- if not os.path.isfile(p): continue
5047
- text = open(p, encoding="utf-8", errors="replace").read()
5048
- fm = parse_fm(text)
5049
- sid = fm.get("id") or name[:-3]
5050
- stories.append({"id": sid, "file_id": name[:-3],
5051
- "status": (fm.get("status") or "open").strip().lower(),
5052
- "type": (fm.get("type") or "functional").strip().lower(),
5053
- "title": (fm.get("title") or "").strip().strip("[]"),
5054
- "spec_version": (fm.get("spec_version") or "").strip(),
5055
- "cites": re.findall(r'\b(?:FR|BR)-\d+', fm.get("exercises", "")),
5056
- "path": p, "text": text})
5057
- open_stories = [s for s in stories if s["status"] == "open"]
5058
- open_ids = [s["id"] for s in open_stories]
5059
- digest = "\n".join(" %-16s %s%s" % (s["id"], s["title"] or "(untitled)",
5060
- (" cites " + ", ".join(s["cites"])) if s["cites"] else "")
5061
- for s in open_stories) or " (no open stories)"
5062
-
5063
- # ---- which story ------------------------------------------------------------
5064
- want = (os.environ.get("AW_STORY") or "").strip()
5065
- free = os.environ.get("AW_INPUT") or ""
5066
- target, reason = None, ""
5067
- if want:
5068
- hit = [s for s in stories if s["id"] == want or s["file_id"] == want]
5069
- target = hit[0] if hit else None
5070
- if not target: reason = "not-found"
5071
- else:
5072
- # every known story id appearing as a whole token in the free text; one match
5073
- # resolves, several is a question, none is a question
5074
- named = [s for s in stories
5075
- if re.search(r'(?<![\w-])' + re.escape(s["id"]) + r'(?![\w-])', free)]
5076
- if len(named) == 1: target = named[0]
5077
- else: reason = "ambiguous" if named else "none-given"
5078
-
5079
- out = {"ok": True, "stories_dir": sdir, "stories_exist": os.path.isdir(sdir),
5080
- "open_count": len(open_stories), "any_open": bool(open_stories),
5081
- "open_ids": open_ids, "open_ids_csv": ", ".join(open_ids), "digest": digest,
5082
- "resolved": bool(target), "needs_pick": not target, "pick_reason": reason,
5083
- "story": "", "story_path": "", "story_status": "", "story_open": False,
5084
- "story_type": "", "is_technical": False,
5085
- "status_msg": "", "story_title": "", "exercises_csv": "", "constraints_csv": "",
5086
- "story_spec_version": "", "current_version": "", "stale_csv": "", "stale_note": "",
5087
- "story_dir": "", "if_tmp": "", "legacy_doc": "", "legacy_doc_exists": False,
5088
- "be_doc_path": "", "be_doc_exists": False, "be_next_pass": 1, "be_pass_tmp": "",
5089
- "be_last_pass": 0, "be_doc_spec_version": "", "be_doc_stale": False,
5090
- "fe_doc_path": "", "fe_doc_exists": False, "fe_next_pass": 1, "fe_pass_tmp": "",
5091
- "fe_last_pass": 0, "fe_doc_spec_version": "", "fe_doc_stale": False,
5092
- "adr_path": os.path.join(fd, "adr.md"), "adr_exists": os.path.isfile(os.path.join(fd, "adr.md")),
5093
- "if_path": os.path.join(fd, "interfaces.md"), "if_exists": os.path.isfile(os.path.join(fd, "interfaces.md")),
5094
- "spec_exists": os.path.isfile(spec)}
5095
-
5096
- # ---- the ledger (widest-wins current version, same rule ledger-append uses) --
5097
- rows, cur = [], ""
5098
- if os.path.isfile(spec):
5099
- stext = open(spec, encoding="utf-8", errors="replace").read()
5100
- m = re.search(r'^##\s+Change History\s*$', stext, re.M)
5101
- if m:
5102
- for line in stext[m.end():].splitlines():
5103
- rm = re.match(r'^\|\s*(v\d+(?:\.\d+)?)\s*\|', line)
5104
- if rm: rows.append((rm.group(1), line))
5105
- hv = re.search(r'^\*\*Version\*\*:\s*(v[\d.]+)', stext, re.M)
5106
- cands = [r[0] for r in rows] + ([hv.group(1)] if hv else [])
5107
- if cands: cur = max(cands, key=vtuple)
5108
- out["current_version"] = cur
5109
-
5110
- if not target:
5111
- print(json.dumps(out)); sys.exit(0)
5112
-
5113
- # ---- the resolved story -----------------------------------------------------
5114
- status = target["status"]
5115
- out.update(story=target["id"], story_path=target["path"], story_status=status,
5116
- story_open=(status == "open"), story_title=target["title"],
5117
- story_type=target["type"], is_technical=(target["type"] == "technical"),
5118
- exercises_csv=", ".join(target["cites"]),
5119
- story_spec_version=target["spec_version"])
5120
- if status != "open":
5121
- verb = {"closed": "was closed — its plan is history. Remaining work needs its own story "
5122
- "(sync-stories mints a follow-on when the spec moves)",
5123
- "cancelled": "was cancelled — cancelled work is not planned; open a new story if the need returns",
5124
- "draft": "is a draft proposal — run /appweaver sync-spec to merge it into the contract first; "
5125
- "story-plan designs only against merged requirements"}
5126
- out["status_msg"] = "Story %s %s." % (
5127
- target["id"], verb.get(status, "has status '%s', not open" % status))
5128
-
5129
- # bound constraint ids come from the story BODY's ## Constraints section (they are
5130
- # barred from exercises: by design — met, not delivered)
5131
- cm = re.search(r'^##\s+Constraints\s*$(.*?)(?=^##\s|\Z)', target["text"], re.M | re.S)
5132
- cons = sorted(set(re.findall(r'\b(?:CN|DC|IC|UX)-\d+', cm.group(1)))) if cm else []
5133
- out["constraints_csv"] = ", ".join(cons)
5134
-
5135
- # staleness: ledger rows ABOVE the story's stamp naming an id the story carries —
5136
- # exercises: AND the Constraints section, so a moved bound is announced too
5137
- sv = target["spec_version"]
5138
- if sv:
5139
- carried = set(target["cites"]) | set(cons)
5140
- moved = [r for r in rows if vtuple(r[0]) > vtuple(sv)]
5141
- stale = sorted({i for _v, l in moved for i in re.findall(r'[A-Z]{2}-\d+', l) if i in carried})
5142
- out["stale_csv"] = ", ".join(stale)
5143
- if stale:
5144
- out["stale_note"] = (
5145
- "CAUTION: the spec moved above this story's stamp (%s -> %s) on ids this story "
5146
- "carries: %s. Design against the CURRENT spec text, record the divergence under "
5147
- "Risks, and consider /appweaver sync-stories to bring the backlog back in step."
5148
- % (sv, cur or "?", ", ".join(stale)))
5149
-
5150
- # doc / pass state + the temp paths the workflow writes through. ONE DOCUMENT PER
5151
- # LANE: the service design and the UI design are authored by different steps, under
5152
- # different rule sets, from different code, and are read by different implement
5153
- # commands — folded into one file, every one of those becomes a section boundary the
5154
- # next reader has to trust. Pass numbers are per file, so a run that designs only the
5155
- # backend leaves the frontend's count exactly where it was.
5156
- #
5157
- # legacy_doc is the pre-split story-plan.md. It is REPORTED, never read and never
5158
- # written: a design nobody consumes must not sit silently beside two that are.
5159
- stdir = os.path.join(sdir, target["id"])
5160
- legacy = os.path.join(stdir, "story-plan.md")
5161
- out.update(story_dir=stdir, if_tmp=os.path.join(stdir, ".if-entries.tmp.md"),
5162
- legacy_doc=legacy, legacy_doc_exists=os.path.isfile(legacy))
5163
- for lane, stem in (("be", "backend"), ("fe", "frontend")):
5164
- doc = os.path.join(stdir, "story-%s-plan.md" % stem)
5165
- out[lane + "_doc_path"] = doc
5166
- out[lane + "_doc_exists"] = os.path.isfile(doc)
5167
- out[lane + "_pass_tmp"] = os.path.join(stdir, ".%s-pass.tmp.md" % stem)
5168
- if out[lane + "_doc_exists"]:
5169
- d = open(doc, encoding="utf-8", errors="replace").read()
5170
- nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', d, re.M)]
5171
- out[lane + "_next_pass"] = (max(nums) + 1) if nums else 1
5172
- # What implement needs on top of the path: WHICH pass it builds from (the last
5173
- # one — earlier passes are superseded history) and the spec version that pass was
5174
- # designed against. A design behind the current spec is ANNOUNCED at implement,
5175
- # never refused: the story is legitimately in flight, and the spec may well have
5176
- # moved on ids this story does not carry. stale_csv is the sharper signal and it
5177
- # is already computed above.
5178
- out[lane + "_last_pass"] = max(nums) if nums else 0
5179
- dv = (parse_fm(d).get("spec_version") or "").strip().strip('"')
5180
- out[lane + "_doc_spec_version"] = dv
5181
- out[lane + "_doc_stale"] = bool(dv and cur and vtuple(dv) < vtuple(cur))
5182
- print(json.dumps(out))
5183
- PY
5184
- }
5185
-
5186
- cmd_story_plan_doc() {
5187
- local fd_arg="" story="" pass_file="" spec_version="" lane="backend"
5188
- while [ $# -gt 0 ]; do
5189
- case "$1" in
5190
- --feature-dir) fd_arg="$2"; shift 2 ;;
5191
- --story) story="$2"; shift 2 ;;
5192
- --lane) lane="$2"; shift 2 ;;
5193
- --pass-file) pass_file="$2"; shift 2 ;;
5194
- --spec-version) spec_version="$2"; shift 2 ;;
5195
- --help|-h) echo "story-plan-doc --story <id> --pass-file <path> [--lane backend|frontend] [--spec-version <v>] [--feature-dir <path>]"; return 0 ;;
5196
- *) echo "story-plan-doc: unknown arg '$1'" >&2; return 1 ;;
5197
- esac
5198
- done
5199
- [ -n "$story" ] || { echo "story-plan-doc: --story is required" >&2; return 1; }
5200
- [ -n "$pass_file" ] || { echo "story-plan-doc: --pass-file is required" >&2; return 1; }
5201
- local repo_root; repo_root=$(get_repo_root)
5202
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "story-plan-doc: no feature set and no --feature-dir" >&2; return 1; }
5203
- AW_FD="$fd" AW_STORY="$story" AW_LANE="$lane" AW_PASS="$pass_file" AW_VER="$spec_version" python3 - <<'PY'
5204
- import json, os, re, sys, datetime
5205
- fd = os.environ["AW_FD"]; story = os.environ["AW_STORY"].strip()
5206
- pf = os.environ["AW_PASS"]; ver = (os.environ.get("AW_VER") or "").strip()
5207
- lane = (os.environ.get("AW_LANE") or "backend").strip().lower()
5208
- sdir = os.path.join(fd, "stories", story)
5209
- doc = os.path.join(sdir, "story-%s-plan.md" % lane)
5210
-
5211
- def bail(msg, code=2):
5212
- print(json.dumps({"ok": False, "doc": doc, "error": msg}))
5213
- sys.stderr.write(msg + "\n"); sys.exit(code)
5214
-
5215
- if lane not in ("backend", "frontend"):
5216
- bail("story-plan-doc: --lane must be backend or frontend, not '%s'" % lane)
5217
- if not os.path.isfile(os.path.join(fd, "stories", story + ".md")):
5218
- bail("story-plan-doc: no story file for '%s' — the plan lives beside a story that exists" % story)
5219
- if not os.path.isfile(pf):
5220
- bail("story-plan-doc: pass file missing at %s — the authoring step wrote nothing" % pf)
5221
- content = open(pf, encoding="utf-8", errors="replace").read().strip()
5222
- if not content:
5223
- bail("story-plan-doc: the authored pass is empty — nothing appended")
5224
- # the pass header is SCRIPT-written; strip one the model wrote anyway rather than
5225
- # letting the doc double-number
5226
- content = re.sub(r'^##\s+Design pass\b[^\n]*\n+', '', content, count=1)
5227
-
5228
- LANE_WORD = "SERVICE" if lane == "backend" else "UI"
5229
- LANE_READER = "/appweaver implement" if lane == "backend" else "/appweaver implement-ui"
5230
- os.makedirs(sdir, exist_ok=True)
5231
- today = datetime.date.today().isoformat()
5232
- created = not os.path.isfile(doc)
5233
- if created:
5234
- open(doc, "w", encoding="utf-8").write("""---
5235
- created: %s
5236
- updated: %s
5237
- passes: 0
5238
- spec_version: ""
5239
- ---
5240
-
5241
- <!--
5242
- STORY DESIGN (%s LANE) — the ephemeral execution design for ONE story, read by
5243
- %s.
5244
-
5245
- Identity is positional: the file lives in the story's directory and carries no
5246
- story id in its text. Every /appweaver story-plan run APPENDS a "## Design pass N"
5247
- section to THIS lane's file — nothing is edited in place, so a manual edit to an
5248
- earlier pass survives a re-run. THE LATEST PASS WINS; earlier passes are the
5249
- iteration history.
5250
-
5251
- EDIT IT BEFORE YOU IMPLEMENT. This is the document a design is corrected in by
5252
- hand: change the latest pass and implement builds what you changed. Re-running
5253
- story-plan instead appends a fresh pass that supersedes it, so hand-edit to
5254
- adjust, re-run to redesign.
5255
-
5256
- It is scaffolding for confirming the implementation strategy — archived with the
5257
- story at close, never maintained afterwards. Durable decisions do not live here:
5258
- the latest pass PROPOSES ADRs (ratified into the feature's adr.md at close), and
5259
- published-surface changes are appended to the feature's interfaces.md as proposed
5260
- entries at design time. spec_version below is the spec the LATEST pass was
5261
- designed against. The other lane's design, where the project has one, sits beside
5262
- this file — the two are written apart on purpose and neither restates the other.
5263
- -->
5264
- """ % (today, today, LANE_WORD, LANE_READER))
5265
- text = open(doc, encoding="utf-8", errors="replace").read()
5266
- nums = [int(n) for n in re.findall(r'^##\s+Design pass\s+(\d+)', text, re.M)]
5267
- n = (max(nums) + 1) if nums else 1
5268
- header = "## Design pass %d — %s%s" % (n, today, (" (spec %s)" % ver) if ver else "")
5269
- text = text.rstrip("\n") + "\n\n" + header + "\n\n" + content + "\n"
5270
- # frontmatter stamps — first match only, which is the frontmatter by position
5271
- text = re.sub(r'^(updated:).*$', r'\g<1> ' + today, text, count=1, flags=re.M)
5272
- text = re.sub(r'^(passes:).*$', r'\g<1> %d' % n, text, count=1, flags=re.M)
5273
- if ver:
5274
- text = re.sub(r'^(spec_version:).*$', r'\g<1> %s' % ver, text, count=1, flags=re.M)
5275
- open(doc, "w", encoding="utf-8").write(text)
5276
- os.remove(pf)
5277
- print(json.dumps({"ok": True, "doc": doc, "lane": lane, "pass": n, "created": created,
5278
- "spec_version": ver, "story_dir": sdir}))
5279
- PY
5280
- }
5281
-
5282
- cmd_log_append() {
5283
- local fd_arg="" log="" entries_file="" status=""
5284
- while [ $# -gt 0 ]; do
5285
- case "$1" in
5286
- --feature-dir) fd_arg="$2"; shift 2 ;;
5287
- --log) log="$2"; shift 2 ;;
5288
- --entries-file) entries_file="$2"; shift 2 ;;
5289
- --status) status="$2"; shift 2 ;;
5290
- --help|-h) echo "log-append --log adr|interfaces --entries-file <path> [--status <s>] [--feature-dir <path>]"; return 0 ;;
5291
- *) echo "log-append: unknown arg '$1'" >&2; return 1 ;;
5292
- esac
5293
- done
5294
- case "$log" in adr|interfaces) ;; *) echo "log-append: --log must be adr or interfaces" >&2; return 1 ;; esac
5295
- [ -n "$entries_file" ] || { echo "log-append: --entries-file is required" >&2; return 1; }
5296
- local repo_root; repo_root=$(get_repo_root)
5297
- local fd; fd=$(_resolve_feature_dir "$fd_arg" "$repo_root") || { echo "log-append: no feature set and no --feature-dir" >&2; return 1; }
5298
- AW_FD="$fd" AW_LOG="$log" AW_ENTRIES="$entries_file" AW_STATUS="$status" python3 - <<'PY'
5299
- import json, os, re, sys, datetime
5300
- fd = os.environ["AW_FD"]; log = os.environ["AW_LOG"]
5301
- ef = os.environ["AW_ENTRIES"]; status = (os.environ.get("AW_STATUS") or "").strip()
5302
- fname, prefix, default_status, title = {
5303
- "interfaces": ("interfaces.md", "IF", "proposed", "Interface Log"),
5304
- "adr": ("adr.md", "ADR", "accepted", "Architecture Decision Log")}[log]
5305
- status = status or default_status
5306
- path = os.path.join(fd, fname)
5307
-
5308
- def bail(msg, code=2):
5309
- print(json.dumps({"ok": False, "log": path, "error": msg}))
5310
- sys.stderr.write(msg + "\n"); sys.exit(code)
5311
-
5312
- if not os.path.isfile(ef):
5313
- bail("log-append: entries file missing at %s — the authoring step wrote nothing" % ef)
5314
- raw = open(ef, encoding="utf-8", errors="replace").read().strip()
5315
- if not raw or raw.lower().rstrip(".") == "none":
5316
- os.remove(ef)
5317
- print(json.dumps({"ok": True, "log": path, "appended": 0, "ids_csv": "",
5318
- "unknown_ids_csv": "", "bad_supersedes_csv": "", "none": True}))
5319
- sys.exit(0)
5320
-
5321
- # every entry must arrive as '## <PREFIX>-NEW — <title>': the script is the only id
5322
- # authority, so a model-numbered entry is refused rather than trusted
5323
- blocks = [b.strip() for b in re.split(r'(?=^##\s)', raw, flags=re.M) if b.strip()]
5324
- bad = [b.splitlines()[0] for b in blocks
5325
- if not re.match(r'^##\s+%s-NEW\s+[—-]+\s+\S' % prefix, b)]
5326
- if bad:
5327
- bail("log-append: every entry must start '## %s-NEW — <title>' (the script assigns "
5328
- "ids); got: %s" % (prefix, " | ".join(bad[:3])))
5329
-
5330
- SCAFFOLD = {
5331
- "interfaces": """<!--
5332
- INTERFACE LOG — this feature's published-surface contract. APPEND-ONLY.
5333
-
5334
- WHO WRITES: /appweaver plan publishes this feature's surface whenever it authors or
5335
- conforms backend-plan.md; a BE story plan appends an entry only where its slice
5336
- changes that surface. Both land as status: proposed, and story close verifies each
5337
- against the as-built and flips it to live.
5338
- WHO READS: /appweaver plan-ui and the FE story designs — this log is the BE-to-FE
5339
- contract anchor, and the plan documents CITE these ids rather than restating them.
5340
- The current surface per interface is the LATEST entry — one that names an earlier
5341
- id in `supersedes:` replaces it. Never edit an existing entry; publish a
5342
- superseding one. status: proposed -> live -> superseded | retired.
5343
- `serves:` cites the FR-/BR- ids the interface exists for, so a spec change to
5344
- those ids flags the contract for review.
5345
- -->
5346
-
5347
- # Interface Log
5348
- """,
5349
- "adr": """<!--
5350
- ADR LOG — this feature's durable decisions. APPEND-ONLY.
5351
-
5352
- A story plan PROPOSES ADRs in its latest design pass; they are ratified into this
5353
- log at story close, or the architect appends directly. Never rewrite an entry —
5354
- supersede it with a new one naming `supersedes:`. An ADR records ONLY what neither
5355
- the code nor the rules can say: a deliberate choice among rule-compliant
5356
- alternatives, a rejected alternative and why, or a trade-off with its revisit
5357
- trigger. It never restates what code shows or a rule mandates.
5358
- -->
5359
-
5360
- # Architecture Decision Log
5361
- """}
5362
- created = not os.path.isfile(path)
5363
- if created:
5364
- open(path, "w", encoding="utf-8").write(SCAFFOLD[log])
5365
- text = open(path, encoding="utf-8", errors="replace").read()
5366
- existing = set(re.findall(r'^##\s+(%s-\d+)' % prefix, text, re.M))
5367
- nums = [int(i.split("-")[1]) for i in existing]
5368
- nxt = (max(nums) + 1) if nums else 1
5369
- today = datetime.date.today().isoformat()
5370
-
5371
- spec = os.path.join(fd, "spec.md")
5372
- known = set(re.findall(r'\b[A-Z]{2}-\d+\b',
5373
- open(spec, encoding="utf-8", errors="replace").read())) \
5374
- if os.path.isfile(spec) else set()
5375
-
5376
- assigned, unknown, badsup, out_blocks = [], [], [], []
5377
- for b in blocks:
5378
- bid = "%s-%03d" % (prefix, nxt); nxt += 1
5379
- lines = b.splitlines()
5380
- lines[0] = lines[0].replace("%s-NEW" % prefix, bid, 1)
5381
- body = "\n".join(lines[1:]).strip("\n")
5382
- # status and date are script-stamped, whatever the model wrote
5383
- body = re.sub(r'^status:.*\n?', '', body, flags=re.M)
5384
- body = re.sub(r'^date:.*\n?', '', body, flags=re.M)
5385
- body = ("status: %s\ndate: %s" % (status, today)) + ("\n" + body if body else "")
5386
- for line in body.splitlines():
5387
- if line.startswith("serves:"):
5388
- unknown += [i for i in re.findall(r'\b[A-Z]{2}-\d+\b', line)
5389
- if known and i not in known]
5390
- if line.startswith("supersedes:"):
5391
- badsup += [i for i in re.findall(r'\b%s-\d+\b' % prefix, line)
5392
- if i not in existing]
5393
- out_blocks.append(lines[0] + "\n\n" + body)
5394
- assigned.append(bid)
5395
-
5396
- text = text.rstrip("\n") + "\n\n" + "\n\n".join(out_blocks) + "\n"
5397
- open(path, "w", encoding="utf-8").write(text)
5398
- os.remove(ef)
5399
- print(json.dumps({"ok": True, "log": path, "appended": len(assigned),
5400
- "ids_csv": ", ".join(assigned),
5401
- "unknown_ids_csv": ", ".join(sorted(set(unknown))),
5402
- "bad_supersedes_csv": ", ".join(sorted(set(badsup))),
5403
- "none": False, "created_log": created}))
5404
- PY
5405
- }
5406
-
5407
3598
  case "$sub" in
5408
3599
  resolve-domain) cmd_resolve_domain "$@" ;;
5409
3600
  resolve-tech) cmd_resolve_tech "$@" ;;
@@ -5418,39 +3609,33 @@ case "$sub" in
5418
3609
  lane-resolve) cmd_lane_resolve "$@" ;;
5419
3610
  lane-verify) cmd_lane_verify "$@" ;;
5420
3611
  lane-probe) cmd_lane_probe "$@" ;;
3612
+ list-contexts) cmd_list_contexts "$@" ;;
3613
+ set-context) cmd_set_context "$@" ;;
5421
3614
  list-features) cmd_list_features "$@" ;;
5422
3615
  set-feature) cmd_set_feature "$@" ;;
5423
3616
  version-read) cmd_version_read "$@" ;;
5424
3617
  version-init) cmd_version_init "$@" ;;
5425
- spec-check) cmd_spec_check "$@" ;;
5426
- spec-stamp) cmd_spec_stamp "$@" ;;
5427
- ledger-append) cmd_ledger_append "$@" ;;
5428
- ledger-ids) cmd_ledger_ids "$@" ;;
5429
- spec-snapshot) cmd_spec_snapshot "$@" ;;
5430
- spec-diff) cmd_spec_diff "$@" ;;
5431
- story-refs) cmd_story_refs "$@" ;;
5432
- slice-mode) cmd_slice_mode "$@" ;;
5433
- backlog-plan) cmd_backlog_plan "$@" ;;
5434
- story-stamp) cmd_story_stamp "$@" ;;
5435
- story-plan-check) cmd_story_plan_check "$@" ;;
5436
- story-plan-doc) cmd_story_plan_doc "$@" ;;
5437
- log-append) cmd_log_append "$@" ;;
5438
- ledger-read) cmd_ledger_read "$@" ;;
5439
- spec-coverage) cmd_spec_coverage "$@" ;;
5440
- spec-preserve) cmd_spec_preserve "$@" ;;
5441
- spec-markers) cmd_spec_markers "$@" ;;
3618
+ version-bootstrap-done) cmd_version_bootstrap_done "$@" ;;
5442
3619
  feature-paths) cmd_feature_paths "$@" ;;
5443
3620
  design-scaffold) cmd_design_scaffold "$@" ;;
5444
- plan-check) cmd_plan_check "$@" ;;
5445
- plan-stamp) cmd_plan_stamp "$@" ;;
5446
- gate-result) cmd_gate_result "$@" ;;
3621
+ # The plan phase's name for the same op. Both spellings reach one implementation:
3622
+ # --lane backend|frontend writes the plan documents, --lane service|ui the legacy
3623
+ # names the brownfield reverse pass still authors.
3624
+ plan-scaffold) cmd_design_scaffold "$@" ;;
3625
+ delta-plan-scaffold) cmd_delta_plan_scaffold "$@" ;;
5447
3626
  build-compile) cmd_build_compile "$@" ;;
5448
3627
  test-run) cmd_test_run "$@" ;;
5449
- test-diff) cmd_test_diff "$@" ;;
5450
3628
  run-migrate) cmd_run_migrate "$@" ;;
5451
3629
  deploy-dispatch) cmd_deploy_dispatch "$@" ;;
5452
3630
  cr-setup) cmd_cr_setup "$@" ;;
3631
+ cr-list) cmd_cr_list "$@" ;;
3632
+ cr-paths) cmd_cr_paths "$@" ;;
3633
+ cr-overlap) cmd_cr_overlap "$@" ;;
3634
+ cr-activate) cmd_cr_activate "$@" ;;
3635
+ cr-wave) cmd_cr_wave "$@" ;;
5453
3636
  cr-archive) cmd_cr_archive "$@" ;;
3637
+ context-paths) cmd_context_paths "$@" ;;
3638
+ model-scaffold) cmd_model_scaffold "$@" ;;
5454
3639
  version-stamp) cmd_version_stamp "$@" ;;
5455
3640
  checklist-read) cmd_checklist_read "$@" ;;
5456
3641
  checklist-apply) cmd_checklist_apply "$@" ;;