aw-spec-cli 0.1.32 → 0.1.34

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 (75) hide show
  1. package/README.md +76 -81
  2. package/assets/.appweaver/DESIGN-FORMAT.md +56 -17
  3. package/assets/.appweaver/STACK-PROFILES.md +95 -60
  4. package/assets/.appweaver/orchestrator/LAUNCHER.md +1 -1
  5. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +173 -32
  6. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +29 -1
  7. package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +7 -4
  8. package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +49 -60
  9. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +58 -0
  10. package/assets/.appweaver/orchestrator/orchestrator.py +26 -8
  11. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +20 -16
  12. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +30 -6
  13. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +9 -7
  14. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -5
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +341 -36
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +254 -43
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +248 -68
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +251 -20
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +316 -10
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +6 -47
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +62 -16
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +132 -9
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-fix-test-coverage.orch.yaml +965 -0
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +222 -169
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +28 -12
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +453 -116
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +7 -4
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +83 -6
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +45 -2
  30. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +324 -74
  31. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +275 -44
  32. package/assets/.appweaver/scripts/python/aw-foundation.py +16 -13
  33. package/assets/.appweaver/scripts/python/aw-gaps.py +11 -3
  34. package/assets/.appweaver/scripts/python/aw-model-guard.py +295 -0
  35. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +412 -3
  36. package/assets/.appweaver/scripts/python/aw-plangate.py +23 -16
  37. package/assets/.appweaver/scripts/python/aw-stack.py +1794 -265
  38. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +0 -2
  39. package/assets/.appweaver/stack-profiles/java-spring.json +0 -2
  40. package/assets/.appweaver/templates/api-plan-template.md +107 -0
  41. package/assets/.appweaver/templates/delta-api-plan-template.md +87 -0
  42. package/assets/.appweaver/templates/delta-design-template.md +25 -23
  43. package/assets/.appweaver/templates/delta-spec-template.md +3 -3
  44. package/assets/.appweaver/templates/design-template.md +20 -42
  45. package/assets/.appweaver/templates/domain-model-template.md +3 -0
  46. package/assets/.appweaver/templates/ui-delta-design-template.md +11 -8
  47. package/assets/.appweaver/templates/ui-design-template.md +4 -4
  48. package/assets/foundations/foundation.spec.template.txt +25 -18
  49. package/assets/guardrails/README.md +61 -25
  50. package/assets/guardrails/methodology/ux/accessibility-standards.md +38 -0
  51. package/assets/guardrails/methodology/ux/content-design.md +34 -0
  52. package/assets/guardrails/methodology/ux/interaction-states.md +45 -0
  53. package/assets/guardrails/methodology/ux/journey-mapping.md +41 -0
  54. package/assets/guardrails/project-specific/domain/domain-requirements.md +25 -38
  55. package/assets/guardrails/project-specific/domain-rule-bindings.md +33 -69
  56. package/assets/guardrails/project-specific/frontend/ui-requirements.md +2 -2
  57. package/assets/guardrails/project-specific/service/technical-requirements.md +2 -2
  58. package/assets/guardrails/project-specific/tech-rule-bindings.md +16 -13
  59. package/assets/guardrails/sample-healthcare/domain/domain-requirements.md +58 -0
  60. package/assets/guardrails/sample-healthcare/domain-rule-bindings.md +138 -0
  61. package/assets/guardrails/sample-healthcare/frontend/ui-requirements.md +43 -0
  62. package/assets/guardrails/sample-healthcare/service/technical-requirements.md +40 -0
  63. package/assets/guardrails/sample-healthcare/tech-rule-bindings.md +303 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +216 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/utils/config.js +1 -1
  68. package/dist/utils/config.js.map +1 -1
  69. package/dist/utils/foundation.d.ts +36 -0
  70. package/dist/utils/foundation.d.ts.map +1 -0
  71. package/dist/utils/foundation.js +109 -0
  72. package/dist/utils/foundation.js.map +1 -0
  73. package/dist/utils/paths.js +1 -1
  74. package/dist/utils/paths.js.map +1 -1
  75. package/package.json +1 -1
@@ -12,8 +12,10 @@
12
12
  # aw-spec-actions.sh <subcommand> [options]
13
13
  #
14
14
  # Subcommands:
15
- # resolve-domain --command <name> -> {domain, rulemap_exists, candidates[]}
16
- # resolve-tech --command <name> -> {domain, techrulemap_exists, candidates[]}
15
+ # resolve-domain --command <name> -> {domain, rulemap_exists, heading_found, candidates[], missing[]}
16
+ # resolve-tech --command <name> -> {domain, techrulemap_exists, heading_found, candidates[], missing[],
17
+ # missing_elsewhere[], absent_corpora[], absent_corpora_text, absent_files,
18
+ # skipped[], unknown_placeholders[], declared_stacks[], stack_mismatch, mismatched_stacks[]}
17
19
  # scaffold --feature-dir <path> [--allow-existing]
18
20
  # -> {feature_dir, spec_file, template, created, spec_created}
19
21
  # resolve-hooks --phase <before_specify|after_specify>
@@ -30,6 +32,9 @@
30
32
  # --profile builds root_options for the stack already chosen)
31
33
  # stack-detect [--kind <k>] [--exclude-root <p>]… -> {matches[], backend, frontend, count}
32
34
  # (matches the repo's OWN files against the profiles; writes nothing)
35
+ # stack-rules --kind <k> [--declared] -> {project, map, map_exists, kind, profiles[], note,
36
+ # declared, lane_profile, verdict, lane_coverage, ok, warning}
37
+ # (which stacks the active project's technical map has rule files for; read-only)
33
38
  # lane-declare --profile <id|label|none|detected> [--kind <k>] [--id <lane>] [--source-path|--root <p>]
34
39
  # [--stack <label>] [--origin declared|reverse-engineered] [--require-existing-root]
35
40
  # [--build <c>] [--test <c>] [--run <c>] [--prototype-root <p>] [--prototype-run <c>] [--no-prototype]
@@ -55,17 +60,32 @@
55
60
  # lane-probe [--kind <k>] [--hint <text>] -> {declared, declared_profile, hint_profile, hint_terms[], signals[], any, evidence}
56
61
  # (the FILE signals for "does this lane exist"; resolve-tech carries the rule signal)
57
62
  # lane-set (--id <lane>|--kind <k>) [--set KEY=VALUE]… [--unset KEY]… -> {ok, lane_id, set, removed, written}
58
- # (KEY: contract_run, contract, client; an empty VALUE changes nothing)
59
- # contract-export [--timeout <s>] -> {ok, ran, cmd, url, doc, doc_exists, operations, unnamed, already_running, reason, log_tail}
60
- # (start the service lane with contract_run, save the document at contract, stop it)
61
- # contract-draft --feature-dir <p> -> {ok, written, removed, path, plan, operations, ids[], ids_csv, unread_count, reason}
62
- # (the feature's api-draft.json, drafted by script from its backend plan's API section)
63
- # contract-combine -> {ok, doc, export, export_exists, drafts[], added, added_csv, served_csv, clashes_csv, status, reason}
64
- # (.appweaver/contracts/openapi.combined.json: the export plus every drafted operation it does not serve)
63
+ # (KEY: contract_write, contract_run, contract, client; an empty VALUE changes nothing)
64
+ # contract-export [--timeout <s>] -> {ok, way, cause, ran, cmd, url, doc, doc_exists, operations, unnamed, already_running, reason, log_tail}
65
+ # (the service lane's API document as built: its contract_write command, or contract_run + contract)
66
+ # contract-draft --feature-dir <p> [--cr <id>] -> {ok, written, removed, path, plan, operations, ids[], ids_csv, unread[], unread_count, dropped[], dropped_text, reason}
67
+ # (the feature's api-draft.json from its api-plan.md — its backend plan's API section before it has one;
68
+ # with --cr, the change's from its delta-api-plan.md; dropped = operations the replaced draft had)
69
+ # contract-combine -> {ok, doc, planned, changes_csv, removed_csv, unserved_csv, unplanned_csv, clashes_csv, export, status, reason}
70
+ # (.appweaver/contracts/openapi.combined.json: every planned operation with the open changes applied,
71
+ # plus the served operations no plan describes)
65
72
  # contract-requests --feature-dir <p> -> {count, open_count, open_csv, declined_csv, rebind_csv, backend_text, frontend_text, summary, requests[]}
66
- # (the frontend plan's RQ-n requests and the backend plan's answers)
73
+ # (the frontend plan's RQ-n requests and the API plan's answers)
67
74
  # contract-client [--doc <d>] -> run-phase result for the frontend lane's `client` command ({{contract}} = --doc)
68
- # contract-check --plan <p> [--doc <d>] -> {ok, cited, ids[], missing[], drafted[], uncited, doc_exists, reason}
75
+ # contract-check --plan <p> [--doc <d>] -> {ok, cited, ids[], missing[], removed_csv, unserved_csv, unplanned_csv, uncited, doc_exists, reason}
76
+ # contract-verify --feature-dir <p> [--cr <id>] [--doc <d>] [--check]
77
+ # -> {ok, checked, planned, missing[], moved[], renamed[], still_served[], fields[], unplanned[], findings[], issues_text, warning, note}
78
+ # (the as-built document vs the operations the feature's plan — or one change — names;
79
+ # --check reads no document -> {ok, check, mode, cr, planned, removed, changes_applied[], needed, note}: is there anything to check)
80
+ # contract-merge --feature-dir <p> --cr <id> [--check]
81
+ # -> {ok, merged, written, split, path, delta, added[], changed[], removed[], from_table[], block_kept[],
82
+ # id_added[], not_found[], requests[], history_row, draft, draft_operations, dropped_text, warning, reason}
83
+ # (finalize-delta: the change's delta-api-plan.md merged into the feature's api-plan.md, by endpoint block;
84
+ # --check writes nothing -> {ok, check, cr, path, delta, has_delta, merged, needed, reason}, close-delta's guard)
85
+ # api-split --feature-dir <p> [--check] [--cr <id>]
86
+ # -> {ok, split, needed, path, backend_plan, endpoints, requests_moved, draft_operations, dropped_text, reason}
87
+ # (a feature planned before api-plan.md: its endpoint blocks and requests table moved out of its backend plan;
88
+ # --check only reports `needed`, and --cr checks a change's delta backend plan the same way)
69
89
  # contract-impact --delta-backend <p> --frontend-plan <p> [--delta-frontend <p>]
70
90
  # -> {changes[], affected[], addressed[], unaddressed[], unaddressed_csv, warn}
71
91
  # (operations a change's service delta changes or removes that the frontend binds to,
@@ -79,14 +99,18 @@
79
99
  # version-bootstrap-done [--feature-dir <path>] -> {…, cleared} (the ONLY clearer of the --bootstrap marker; call it when the first draft is finished)
80
100
  # feature-lanes [--feature-dir <path>] [--set backend|frontend|backend,frontend] [--clear] -> {…, lanes[]}
81
101
  # (the declared lanes this feature has a surface in; empty = every declared lane)
82
- # feature-paths -> {feature, feature_slug, feature_dir, spec_file, backend_plan, frontend_plan, *_exists,
102
+ # feature-paths -> {feature, feature_slug, feature_dir, spec_file, backend_plan, frontend_plan, api_plan, *_exists,
83
103
  # design_file/ui_design_file (aliases of the two plans), spec_exists, bootstrap_pending, needs_bootstrap}
84
104
  # checklist-read --file <path> -> {exists, total, checked, items[]}
85
105
  # checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
86
- # plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>]
87
- # [--test-path <p>] [--reported <json list>]... [--list-only]
106
+ # plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--api-plan <path>]
107
+ # [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--list-only]
88
108
  # -> {ok, total, names[], names_csv, elements[], gaps[], gap_count, gaps_text}
89
109
  # (which plan elements the lane's code names and the model reported done)
110
+ # test-citations --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--paths <json list>]...
111
+ # [--source-path <p>] [--test-path <p>] [--spec <p>] [--model <p>] [--known-gaps <json>]...
112
+ # -> {ok, all_cited, clean, required[], uncited[], uncited_csv, findings[], warning}
113
+ # (which of the plan's FR-/BR- ids no test of the slice names)
90
114
  # detect-project -> {scaffolded, markers[]}
91
115
  # foundation-list [--kind <k>] [--profile <id>] [--ecosystem <e>] [--lane-id <id>] [--args '<F> <A>']
92
116
  # -> {options[], entries[], count, foundations[], specs[], preselect_option, preselect_app, rejected_out}
@@ -119,12 +143,14 @@
119
143
  # -> {ok, exists, issues, placeholders, placeholder_check, version_body, version_report, version_match, bad_dates}
120
144
  # agent-context-update [--plan <path>] -> {ok, ran, message}
121
145
  #
122
- # plan-scaffold --feature-dir <path> [--lane backend|frontend|service|ui] [--template <name>]
123
- # (alias: design-scaffold) -> {feature_dir, plan_doc, design_doc, lane, created, migrated, override_active, template, template_source}
146
+ # plan-scaffold --feature-dir <path> [--lane backend|frontend|api|service|ui] [--template <name>]
147
+ # (alias: design-scaffold) -> {feature_dir, plan_doc, design_doc, lane, created, migrated, blocked, override_active, template, template_source}
148
+ # (--lane api: api-plan.md; blocked while the backend plan still carries the API — run api-split)
124
149
  # (backend|frontend write backend-plan.md / frontend-plan.md and MIGRATE a
125
150
  # legacy design.md / design.ui.md by renaming it; service|ui keep the legacy names)
126
- # delta-plan-scaffold --cr-id <id> [--lane backend|frontend] [--feature-dir <path>] [--template <name>]
127
- # -> {cr_id, cr_dir, lane, delta_plan, created, migrated, override_active, template}
151
+ # delta-plan-scaffold --cr-id <id> [--lane backend|frontend|api] [--feature-dir <path>] [--template <name>]
152
+ # -> {cr_id, cr_dir, lane, delta_plan, created, migrated, blocked, override_active, template}
153
+ # (--lane api: delta-api-plan.md; blocked while the delta backend plan still carries the change's API)
128
154
  # (design-extract / design-reconcile / design-paths are RETIRED — the path-tagged
129
155
  # four-document model is gone. "Which files is this slice made of" is now an OKF
130
156
  # query: aw-xref concept-paths / concept-upsert --merge.)
@@ -151,7 +177,7 @@
151
177
  # (--lane lists only the changes touching one of those lanes; none = every open change)
152
178
  # cr-paths --cr-id <id> [--feature-dir <path>]
153
179
  # -> {cr_dir, delta_spec, delta_model, delta_backend_plan, delta_frontend_plan,
154
- # wave, lanes[], lanes_csv, lanes_recorded, touches_backend,
180
+ # delta_api_plan, wave, lanes[], lanes_csv, lanes_recorded, touches_backend,
155
181
  # touches_frontend, predecessor_files[], predecessor_specs[],
156
182
  # predecessor_docs[], sibling_files[], domain_model}
157
183
  # cr-overlap --cr-id <id> [--feature-dir <path>]
@@ -164,7 +190,7 @@
164
190
  # lanes[], lanes_csv, backend, frontend, source, stamped}
165
191
  # (the lanes a change touches, on its delta-spec's **Lanes** line; no line = every declared lane)
166
192
  # slice-gate-stamp --cr-id <id> --lane backend|frontend [--feature-dir <path>] [--tests-green true|false]
167
- # [--tests-ran true|false] [--requirements-ok true|false] [--findings <json list>]
193
+ # [--tests-ran true|false] [--requirements-ok true|false] [--findings <json list>]...
168
194
  # -> {file, lane, cr_id, verdict, tests_green, tests_ran,
169
195
  # requirements_ok, findings, high_findings, gated}
170
196
  # (one lane's analyze-slice-delta result, as changes/<id>/.aw-slice-gate.<lane>.json;
@@ -179,6 +205,11 @@
179
205
  # context-version-bump [--feature-dir <path>] [--cr-id <id>] [--touched-model true|false]
180
206
  # -> {ok, bumped, previous, version, model,
181
207
  # model_exists, context, reason}
208
+ # model-guard-snapshot --model <path> -> {ok, model, before, exists, aggregates[], rules, cross_rules, guarded}
209
+ # model-guard-check --model <path> -> {ok, checked, skipped, model, rejected, restored, losses[],
210
+ # loss_count, losses_text, reason}
211
+ # (bracket a whole-file rewrite of domain-model.md; on a loss the check
212
+ # restores the model and keeps the rewrite as domain-model.rejected.md)
182
213
  #
183
214
  # All subcommands accept --help.
184
215
 
@@ -228,22 +259,34 @@ cmd_resolve_domain() {
228
259
  [ -n "$domain" ] && [ -f "$rulemap" ] && rulemap_exists=true
229
260
 
230
261
  # Collect bullet paths under "## /<command>" in the domain's domain-rule-bindings.md.
231
- local -a candidates=()
262
+ # A mapped file that is not on disk goes to `missing`, never to `candidates`, so a
263
+ # caller can say so instead of loading nothing in its place without a word.
264
+ # heading_found tells a heading that is deliberately empty (a `_None_` line) apart
265
+ # from one that is absent or misspelled — both give no candidates.
266
+ local -a candidates=() missing=()
267
+ local heading_found=false
232
268
  if [ "$rulemap_exists" = true ]; then
233
- # awk: capture "- path" bullets between the target "## <heading>" and the next "## "
269
+ grep -qxF "## /$command_name" "$rulemap" && heading_found=true
270
+ # awk: capture "- path" bullets between the target "## <heading>" and the next "## ".
271
+ # A path written as code (- `path`) is read without its backticks.
234
272
  while IFS= read -r line; do
235
- [ -n "$line" ] && candidates+=("$line")
273
+ [ -n "$line" ] || continue
274
+ if [ -f "$repo_root/$line" ]; then candidates+=("$line"); else missing+=("$line"); fi
236
275
  done < <(awk -v h="## /$command_name" '
237
276
  $0==h {grab=1; next}
238
277
  /^## / {grab=0}
239
278
  grab && /^[[:space:]]*-[[:space:]]+/ {
240
- sub(/^[[:space:]]*-[[:space:]]+/,""); sub(/[[:space:]]+$/,""); print
279
+ sub(/^[[:space:]]*-[[:space:]]+/,""); sub(/[[:space:]]+$/,"")
280
+ if ($0 ~ /^`[^`]+`$/) $0 = substr($0, 2, length($0) - 2)
281
+ print
241
282
  }
242
283
  ' "$rulemap")
243
284
  fi
244
285
 
245
- printf '{"domain":"%s","rulemap_exists":%s,"candidates":%s}\n' \
246
- "$(json_escape "$domain")" "$rulemap_exists" "$(json_str_array "${candidates[@]}")"
286
+ printf '{"domain":"%s","rulemap_exists":%s,"heading_found":%s,"candidates":%s,"missing":%s}\n' \
287
+ "$(json_escape "$domain")" "$rulemap_exists" "$heading_found" \
288
+ "$(json_str_array "${candidates[@]+"${candidates[@]}"}")" \
289
+ "$(json_str_array "${missing[@]+"${missing[@]}"}")"
247
290
  }
248
291
 
249
292
  # ──────────────────────────────── resolve-tech ─────────────────────────────
@@ -332,28 +375,39 @@ def expand(line):
332
375
  return HOLE.sub(lambda m: subs[m.group(1)], line), ""
333
376
 
334
377
  bullets = []
378
+ # Whether the command's heading is in the map at all. A heading with no bullets (a
379
+ # `_None_` line) is a deliberate choice; an absent or misspelled one loads the same
380
+ # nothing, and only this tells the caller which of the two it is.
381
+ heading_found = False
335
382
  if exists:
336
383
  text = open(techmap, encoding="utf-8").read().splitlines()
337
384
  for heading in ("## /" + command, "## /" + command.replace("decl-", "")):
338
385
  found, grab = [], False
339
386
  for line in text:
340
387
  if line.strip() == heading:
341
- grab = True; continue
388
+ grab = True; heading_found = True; continue
342
389
  if line.startswith("## "):
343
390
  grab = False; continue
344
391
  if grab:
345
392
  m = re.match(r"^\s*-\s+(.*?)\s*$", line)
346
393
  if m:
347
- found.append(m.group(1))
394
+ # A path written as code (- `path`) is read without its backticks.
395
+ found.append(re.sub(r"^`([^`]+)`$", r"\1", m.group(1)))
348
396
  if found:
349
397
  bullets = found
350
398
  break
351
399
 
352
400
  candidates, missing, skipped, mismatched = [], [], [], []
401
+ # A placeholder the kit does not know is a typo in the map, unlike one for a lane
402
+ # this repo does not declare, which is skipped because its rules do not apply.
403
+ unknown = []
353
404
  for b in bullets:
354
405
  path, why = expand(b)
355
406
  if why:
356
- skipped.append({"bullet": b, "why": why}); continue
407
+ skipped.append({"bullet": b, "why": why})
408
+ if why.startswith("unknown placeholder"):
409
+ unknown.append(b)
410
+ continue
357
411
  (candidates if os.path.isfile(os.path.join(repo, path)) else missing).append(path)
358
412
  # A LITERAL stack directory that is not one this repo declares. Reported, never
359
413
  # dropped: the file exists and the project chose it, so it may be a deliberate
@@ -364,12 +418,32 @@ for b in bullets:
364
418
  if m.group(1) not in mismatched:
365
419
  mismatched.append(m.group(1))
366
420
 
421
+ # A STACK WITH NO RULE FOLDER AT ALL. Every file the map names under it is missing, and
422
+ # that is a supported setup rather than a typo (STACK-PROFILES.md §7), so those files are
423
+ # reported as the folder they belong to. A file missing from a folder that DOES exist is
424
+ # a gap to fix, and stays listed one by one in missing_elsewhere. `missing` keeps both.
425
+ absent, absent_files, elsewhere = [], 0, []
426
+ for p in missing:
427
+ m = re.match(r"^(guardrails/technology/[^/]+/)", p)
428
+ if m and not os.path.isdir(os.path.join(repo, m.group(1))):
429
+ if m.group(1) not in absent:
430
+ absent.append(m.group(1))
431
+ absent_files += 1
432
+ else:
433
+ elsewhere.append(p)
434
+
367
435
  print(json.dumps({
368
436
  "domain": domain,
369
437
  "techrulemap_exists": exists,
438
+ "heading_found": heading_found,
370
439
  "candidates": candidates,
371
440
  "missing": missing,
441
+ "missing_elsewhere": elsewhere,
442
+ "absent_corpora": absent,
443
+ "absent_corpora_text": " and ".join(absent),
444
+ "absent_files": absent_files,
372
445
  "skipped": skipped,
446
+ "unknown_placeholders": unknown,
373
447
  "declared_stacks": sorted(declared),
374
448
  "stack_mismatch": bool(mismatched),
375
449
  "mismatched_stacks": mismatched,
@@ -842,6 +916,22 @@ cmd_stack_detect() {
842
916
  _aw_stack_py detect "${fwd[@]}"
843
917
  }
844
918
 
919
+ # Which stacks the active project's technical map has rule files for (aw-stack.py rules).
920
+ # A stack without them is a supported setup; select-stack reports it in the menu and
921
+ # warns for a declared lane, so the operator learns it where the stack is chosen.
922
+ cmd_stack_rules() {
923
+ local -a fwd=()
924
+ while [ $# -gt 0 ]; do
925
+ case "$1" in
926
+ --kind) fwd+=("--kind" "$2"); shift 2 ;;
927
+ --declared) fwd+=("--declared"); shift ;;
928
+ --help|-h) echo "stack-rules --kind backend|frontend [--declared] -> {project, map, map_exists, kind, profiles[], note, declared, lane_profile, verdict, lane_coverage, ok, warning}"; return 0 ;;
929
+ *) echo "stack-rules: unknown arg '$1'" >&2; return 1 ;;
930
+ esac
931
+ done
932
+ _aw_stack_py rules "${fwd[@]}"
933
+ }
934
+
845
935
  cmd_lane_declare() {
846
936
  local -a fwd=()
847
937
  while [ $# -gt 0 ]; do
@@ -875,45 +965,47 @@ cmd_lane_resolve() {
875
965
  _aw_stack_py resolve "${fwd[@]}"
876
966
  }
877
967
 
878
- # Change the API-contract keys (contract_run, contract, client) on a lane already
879
- # declared, leaving the rest of it alone. `--set KEY=` with an empty value changes
880
- # nothing; `--unset KEY` removes one. Callers: foundation (a pack's lane settings) and
881
- # implement (the client command a developer supplies).
968
+ # Change the API-contract keys (contract_write, contract_run, contract, client) on a lane
969
+ # already declared, leaving the rest of it alone. `--set KEY=` with an empty value changes
970
+ # nothing; `--unset KEY` removes one. Callers: foundation (a pack's or a spec's lane
971
+ # settings) and implement (the client command a developer supplies).
882
972
  cmd_lane_set() {
883
973
  local -a fwd=()
884
974
  while [ $# -gt 0 ]; do
885
975
  case "$1" in
886
976
  --id|--kind|--set|--unset) fwd+=("$1" "$2"); shift 2 ;;
887
- --help|-h) echo "lane-set (--id <lane>|--kind backend|frontend) [--set KEY=VALUE]… [--unset KEY]… (KEY: contract_run, contract, client)"; return 0 ;;
977
+ --help|-h) echo "lane-set (--id <lane>|--kind backend|frontend) [--set KEY=VALUE]… [--unset KEY]… (KEY: contract_write, contract_run, contract, client)"; return 0 ;;
888
978
  *) echo "lane-set: unknown arg '$1'" >&2; return 1 ;;
889
979
  esac
890
980
  done
891
981
  _aw_stack_py set "${fwd[@]}"
892
982
  }
893
983
 
894
- # ─────────────── contract-export / -draft / -combine / -requests / -client / -check ───────────────
895
- # The API contract between the lanes: export the service lane's API document from the
896
- # running service, draft a feature's document from its backend plan, combine the two for
897
- # the frontend, read the request ledger between the plans, generate the frontend lane's
898
- # client, and check the operation ids a frontend plan cites. All delegate to aw-stack.py.
984
+ # ─────── contract-export / -draft / -combine / -requests / -client / -check / -verify / -merge, api-split ───────
985
+ # The API contract between the lanes: the API plans' drafts are the contract, combined for
986
+ # the frontend with the open changes applied, and each change's delta API plan is merged
987
+ # into its feature's API plan when the change is finalized; the request ledger runs between
988
+ # the plans; the frontend lane's client is generated from the combined document, and the
989
+ # operation ids its plan cites are checked against it. The service lane's document as
990
+ # built is written by its own way and checked against the plan. All delegate to aw-stack.py.
899
991
  cmd_contract_export() {
900
992
  local -a fwd=()
901
993
  while [ $# -gt 0 ]; do
902
994
  case "$1" in
903
995
  --timeout) fwd+=("$1" "$2"); shift 2 ;;
904
- --help|-h) echo "contract-export [--timeout <seconds>] -> {ok, ran, cmd, url, doc, doc_exists, operations, unnamed, already_running, reason, log_tail}"; return 0 ;;
996
+ --help|-h) echo "contract-export [--timeout <seconds>] -> {ok, way, cause, ran, cmd, url, doc, doc_exists, operations, unnamed, already_running, reason, log_tail}"; return 0 ;;
905
997
  *) echo "contract-export: unknown arg '$1'" >&2; return 1 ;;
906
998
  esac
907
999
  done
908
- _aw_stack_py contract-export "${fwd[@]}"
1000
+ _aw_stack_py contract-export "${fwd[@]+"${fwd[@]}"}"
909
1001
  }
910
1002
 
911
1003
  cmd_contract_draft() {
912
1004
  local -a fwd=()
913
1005
  while [ $# -gt 0 ]; do
914
1006
  case "$1" in
915
- --feature-dir) if [ -n "$2" ]; then fwd+=("$1" "$2"); fi; shift 2 ;;
916
- --help|-h) echo "contract-draft --feature-dir <path> -> {ok, written, removed, path, plan, operations, ids, ids_csv, unread_count, reason}"; return 0 ;;
1007
+ --feature-dir|--cr) if [ -n "$2" ]; then fwd+=("$1" "$2"); fi; shift 2 ;;
1008
+ --help|-h) echo "contract-draft --feature-dir <path> [--cr <id>] -> {ok, written, removed, path, plan, operations, ids, ids_csv, unread, unread_count, dropped, dropped_text, reason}"; return 0 ;;
917
1009
  *) echo "contract-draft: unknown arg '$1'" >&2; return 1 ;;
918
1010
  esac
919
1011
  done
@@ -923,7 +1015,7 @@ cmd_contract_draft() {
923
1015
  cmd_contract_combine() {
924
1016
  while [ $# -gt 0 ]; do
925
1017
  case "$1" in
926
- --help|-h) echo "contract-combine -> {ok, doc, export, export_exists, drafts, added, added_csv, served_csv, clashes_csv, status, reason}"; return 0 ;;
1018
+ --help|-h) echo "contract-combine -> {ok, doc, planned, changes_csv, removed_csv, unserved_csv, unplanned_csv, clashes_csv, export, export_exists, status, reason}"; return 0 ;;
927
1019
  *) echo "contract-combine: unknown arg '$1'" >&2; return 1 ;;
928
1020
  esac
929
1021
  done
@@ -962,13 +1054,58 @@ cmd_contract_check() {
962
1054
  while [ $# -gt 0 ]; do
963
1055
  case "$1" in
964
1056
  --plan|--doc) fwd+=("$1" "$2"); shift 2 ;;
965
- --help|-h) echo "contract-check --plan <frontend plan> [--doc <api document>] -> {ok, cited, ids, missing, uncited, doc_exists, reason}"; return 0 ;;
1057
+ --help|-h) echo "contract-check --plan <frontend plan> [--doc <api document>] -> {ok, cited, ids, missing, removed_csv, unserved_csv, unplanned_csv, uncited, doc_exists, reason}"; return 0 ;;
966
1058
  *) echo "contract-check: unknown arg '$1'" >&2; return 1 ;;
967
1059
  esac
968
1060
  done
969
1061
  _aw_stack_py contract-check "${fwd[@]}"
970
1062
  }
971
1063
 
1064
+ # Does the built service serve what its plan names — the feature's operations, or with --cr
1065
+ # one change's; with --check, only whether the plan names anything to check. Each value is
1066
+ # forwarded only when it is not empty.
1067
+ cmd_contract_verify() {
1068
+ local -a fwd=()
1069
+ while [ $# -gt 0 ]; do
1070
+ case "$1" in
1071
+ --feature-dir|--cr|--doc) if [ -n "$2" ]; then fwd+=("$1" "$2"); fi; shift 2 ;;
1072
+ --check) fwd+=("$1"); shift ;;
1073
+ --help|-h) echo "contract-verify --feature-dir <path> [--cr <id>] [--doc <as-built document>] [--check] -> {ok, checked, planned, missing, moved, renamed, still_served, fields, unplanned, findings, issues_text, warning, note}; --check reads no document -> {ok, check, mode, cr, planned, removed, changes_applied, needed, note}"; return 0 ;;
1074
+ *) echo "contract-verify: unknown arg '$1'" >&2; return 1 ;;
1075
+ esac
1076
+ done
1077
+ _aw_stack_py contract-verify "${fwd[@]+"${fwd[@]}"}"
1078
+ }
1079
+
1080
+ # A change's delta API plan, merged into its feature's API plan (finalize-delta); with
1081
+ # --check, only whether it is still to merge (close-delta).
1082
+ cmd_contract_merge() {
1083
+ local -a fwd=()
1084
+ while [ $# -gt 0 ]; do
1085
+ case "$1" in
1086
+ --feature-dir|--cr) if [ -n "$2" ]; then fwd+=("$1" "$2"); fi; shift 2 ;;
1087
+ --check) fwd+=("$1"); shift ;;
1088
+ --help|-h) echo "contract-merge --feature-dir <path> --cr <id> [--check] -> {ok, merged, written, split, path, delta, added, changed, removed, from_table, block_kept, id_added, not_found, requests, history_row, draft, draft_operations, dropped_text, warning, reason}; --check writes nothing -> {ok, check, cr, path, delta, has_delta, merged, needed, reason}"; return 0 ;;
1089
+ *) echo "contract-merge: unknown arg '$1'" >&2; return 1 ;;
1090
+ esac
1091
+ done
1092
+ _aw_stack_py contract-merge "${fwd[@]+"${fwd[@]}"}"
1093
+ }
1094
+
1095
+ # A feature's endpoints and requests table, moved out of its backend plan into an API plan.
1096
+ cmd_api_split() {
1097
+ local -a fwd=()
1098
+ while [ $# -gt 0 ]; do
1099
+ case "$1" in
1100
+ --feature-dir|--cr) if [ -n "$2" ]; then fwd+=("$1" "$2"); fi; shift 2 ;;
1101
+ --check) fwd+=("$1"); shift ;;
1102
+ --help|-h) echo "api-split --feature-dir <path> [--check] [--cr <id>] -> {ok, split, needed, path, backend_plan, endpoints, requests_moved, draft_operations, dropped_text, reason}"; return 0 ;;
1103
+ *) echo "api-split: unknown arg '$1'" >&2; return 1 ;;
1104
+ esac
1105
+ done
1106
+ _aw_stack_py api-split "${fwd[@]+"${fwd[@]}"}"
1107
+ }
1108
+
972
1109
  # What a change's service delta changes or removes that the frontend master plan binds to,
973
1110
  # and whether the change's frontend delta plan carries it. Each path is forwarded only when
974
1111
  # it is not empty.
@@ -1434,6 +1571,39 @@ cmd_model_scaffold() {
1434
1571
  "$(json_escape "$tpl")" "$created"
1435
1572
  }
1436
1573
 
1574
+ # ──────────────────── model-guard-snapshot / model-guard-check ─────────────────────
1575
+ # specify (bootstrap) and adopt write the context's WHOLE domain-model.md, and from a
1576
+ # context's second feature on that file holds the aggregates, BR-/XBR- rules and ledger rows
1577
+ # other features rely on. These two bracket that step: the snapshot copies the model aside,
1578
+ # the check compares the rewrite with the copy and, on a loss, puts the model back and keeps
1579
+ # the rewrite as domain-model.rejected.md. THE RULE LIVES IN python/aw-model-guard.py, which
1580
+ # both shells invoke — these are thin wrappers that resolve the path and hand over. Both
1581
+ # exit 0 with `ok` in the JSON; the workflow's gate owns the stop.
1582
+ cmd_model_guard() {
1583
+ local op="$1"; shift
1584
+ local model=""
1585
+ while [ $# -gt 0 ]; do
1586
+ case "$1" in
1587
+ --model) model="$2"; shift 2 ;;
1588
+ --help|-h)
1589
+ if [ "$op" = "snapshot" ]; then
1590
+ echo "model-guard-snapshot --model <path> -> {ok, model, before, exists, aggregates[], rules, cross_rules, guarded}"
1591
+ else
1592
+ echo "model-guard-check --model <path> -> {ok, checked, skipped, model, rejected, restored, losses[], loss_count, losses_text, reason}"
1593
+ fi
1594
+ return 0 ;;
1595
+ *) echo "model-guard-$op: unknown arg '$1'" >&2; return 1 ;;
1596
+ esac
1597
+ done
1598
+ [ -n "$model" ] || { echo "model-guard-$op: --model required" >&2; return 1; }
1599
+ local repo_root; repo_root=$(get_repo_root)
1600
+ [[ "$model" != /* ]] && model="$repo_root/$model"
1601
+ local core="$SCRIPT_DIR/../python/aw-model-guard.py"
1602
+ if [ ! -f "$core" ]; then echo "model-guard-$op: core not found at $core" >&2; return 1; fi
1603
+ if ! command -v python3 >/dev/null 2>&1; then echo "model-guard-$op: python3 is required" >&2; return 1; fi
1604
+ python3 "$core" "$op" --model "$model" --root "$repo_root"
1605
+ }
1606
+
1437
1607
  # ─────────────────────────────── list-features ─────────────────────────────
1438
1608
  # Enumerate the aggregates a user can pick as the active feature. Two sources,
1439
1609
  # MERGED (deduped by feature name, case-insensitive):
@@ -2015,6 +2185,11 @@ cmd_feature_paths() {
2015
2185
  local spec="$fd/spec.md" design="$fd/backend-plan.md" ui_design="$fd/frontend-plan.md"
2016
2186
  [ ! -f "$design" ] && [ -f "$fd/design.md" ] && design="$fd/design.md"
2017
2187
  [ ! -f "$ui_design" ] && [ -f "$fd/design.ui.md" ] && ui_design="$fd/design.ui.md"
2188
+ # The API plan beside the backend plan: the service's published endpoints, a master that
2189
+ # finalize-delta merges each change's API into. A feature planned before it existed has
2190
+ # none, and keeps its endpoints in its backend plan until `api-split` moves them.
2191
+ local api_plan="$fd/api-plan.md" api_plan_exists=false
2192
+ [ -f "$api_plan" ] && api_plan_exists=true
2018
2193
  # needs_bootstrap, not spec_exists, is what picks specify's branch. A crashed first run
2019
2194
  # leaves spec.md on disk with the marker still up, and branching on the file alone read
2020
2195
  # that as a spec to EXTEND — re-applying the same input over an unfinished v1.0.
@@ -2026,9 +2201,10 @@ cmd_feature_paths() {
2026
2201
  local needs_bootstrap=false
2027
2202
  [ "$spec_exists" = false ] && needs_bootstrap=true
2028
2203
  [ "$bootstrap_pending" = true ] && needs_bootstrap=true
2029
- printf '{"feature":"%s","feature_slug":"%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' \
2204
+ printf '{"feature":"%s","feature_slug":"%s","feature_dir":"%s","spec_file":"%s","backend_plan":"%s","frontend_plan":"%s","backend_plan_exists":%s,"frontend_plan_exists":%s,"api_plan":"%s","api_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' \
2030
2205
  "$(json_escape "$feature")" "$(json_escape "$feature_slug")" "$(json_escape "$fd")" "$(json_escape "$spec")" \
2031
2206
  "$(json_escape "$design")" "$(json_escape "$ui_design")" "$design_exists" "$ui_design_exists" \
2207
+ "$(json_escape "$api_plan")" "$api_plan_exists" \
2032
2208
  "$(json_escape "$design")" "$(json_escape "$ui_design")" "$spec_exists" "$design_exists" "$ui_design_exists" \
2033
2209
  "$bootstrap_pending" "$needs_bootstrap"
2034
2210
  }
@@ -3251,7 +3427,7 @@ cmd_design_scaffold() {
3251
3427
  # Priority-1), so the defaults already yield the as-built structure for
3252
3428
  # forward `plan` and adopt alike, and this is rarely needed.
3253
3429
  --template) template_arg="$2"; shift 2 ;;
3254
- --help|-h) echo "plan-scaffold --feature-dir <path> [--lane backend|frontend|service|ui] [--template <name>]"; return 0 ;;
3430
+ --help|-h) echo "plan-scaffold --feature-dir <path> [--lane backend|frontend|api|service|ui] [--template <name>]"; return 0 ;;
3255
3431
  *) echo "design-scaffold: unknown arg '$1'" >&2; return 1 ;;
3256
3432
  esac
3257
3433
  done
@@ -3259,14 +3435,16 @@ cmd_design_scaffold() {
3259
3435
  # names, and a MIGRATION — a legacy design.md / design.ui.md is RENAMED into the plan
3260
3436
  # name, so a feature never carries two spellings of one master and the rename costs
3261
3437
  # no flag day. service / ui stay byte-identical for the callers that still write the
3262
- # legacy names (adopt's reverse pass).
3438
+ # legacy names (adopt's reverse pass). api is the backend lane's API plan, beside its
3439
+ # plan — one template for every project, since its endpoint block form is fixed.
3263
3440
  local doc_name legacy_name=""
3264
3441
  case "$lane_arg" in
3265
3442
  service) doc_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
3266
3443
  ui) doc_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
3267
3444
  backend) doc_name="backend-plan.md"; legacy_name="design.md"; [ -n "$template_arg" ] || template_arg="design-template" ;;
3268
3445
  frontend) doc_name="frontend-plan.md"; legacy_name="design.ui.md"; [ -n "$template_arg" ] || template_arg="ui-design-template" ;;
3269
- *) echo "design-scaffold: unknown --lane '$lane_arg' (backend|frontend|service|ui)" >&2; return 1 ;;
3446
+ api) doc_name="api-plan.md"; [ -n "$template_arg" ] || template_arg="api-plan-template" ;;
3447
+ *) echo "design-scaffold: unknown --lane '$lane_arg' (backend|frontend|api|service|ui)" >&2; return 1 ;;
3270
3448
  esac
3271
3449
  local repo_root; repo_root=$(get_repo_root)
3272
3450
  local fd="$feature_dir_arg"
@@ -3282,7 +3460,17 @@ cmd_design_scaffold() {
3282
3460
  if [ ! -f "$target" ] && [ -n "$legacy_name" ] && [ -f "$fd/$legacy_name" ]; then
3283
3461
  mv "$fd/$legacy_name" "$target"; migrated="true"
3284
3462
  fi
3285
- if [ -f "$target" ]; then
3463
+ # An API plan is never scaffolded beside a backend plan that still carries the API: the
3464
+ # empty template would be read in its place and the draft would lose every endpoint.
3465
+ # `api-split` moves them first.
3466
+ local blocked="false"
3467
+ if [ "$lane_arg" = "api" ] && [ ! -f "$target" ]; then
3468
+ blocked=$(_aw_stack_py api-split --feature-dir "$fd" --check 2>/dev/null \
3469
+ | python3 -c 'import json,sys
3470
+ try: print("true" if json.load(sys.stdin).get("needed") else "false")
3471
+ except Exception: print("false")' 2>/dev/null) || blocked="false"
3472
+ fi
3473
+ if [ -f "$target" ] || [ "$blocked" = "true" ]; then
3286
3474
  created="false"
3287
3475
  else
3288
3476
  tpl=$(resolve_template "$template_arg" "$repo_root") || true
@@ -3298,9 +3486,9 @@ cmd_design_scaffold() {
3298
3486
  local override_active="false"
3299
3487
  [ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
3300
3488
  local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
3301
- 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' \
3489
+ printf '{"feature_dir":"%s","design_doc":"%s","plan_doc":"%s","lane":"%s","created":%s,"migrated":%s,"blocked":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
3302
3490
  "$(json_escape "$fd")" "$(json_escape "$target")" "$(json_escape "$target")" "$(json_escape "$lane_arg")" \
3303
- "$created" "$migrated" "$override_active" \
3491
+ "$created" "$migrated" "$blocked" "$override_active" \
3304
3492
  "$(json_escape "$template_arg")" "$(json_escape "${template_source:-}")"
3305
3493
  }
3306
3494
 
@@ -3324,7 +3512,7 @@ cmd_delta_plan_scaffold() {
3324
3512
  --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3325
3513
  --lane) lane_arg="$2"; shift 2 ;;
3326
3514
  --template) template_arg="$2"; shift 2 ;;
3327
- --help|-h) echo "delta-plan-scaffold --cr-id <id> [--lane backend|frontend] [--feature-dir <path>] [--template <name>]"; return 0 ;;
3515
+ --help|-h) echo "delta-plan-scaffold --cr-id <id> [--lane backend|frontend|api] [--feature-dir <path>] [--template <name>]"; return 0 ;;
3328
3516
  *) echo "delta-plan-scaffold: unknown arg '$1'" >&2; return 1 ;;
3329
3517
  esac
3330
3518
  done
@@ -3336,7 +3524,9 @@ cmd_delta_plan_scaffold() {
3336
3524
  [ -n "$template_arg" ] || template_arg="delta-design-template" ;;
3337
3525
  frontend) doc_name="delta-frontend-plan.md"; legacy_name="delta-design.ui.md"
3338
3526
  [ -n "$template_arg" ] || template_arg="ui-delta-design-template" ;;
3339
- *) echo "delta-plan-scaffold: unknown --lane '$lane_arg' (backend|frontend)" >&2; return 1 ;;
3527
+ api) doc_name="delta-api-plan.md"
3528
+ [ -n "$template_arg" ] || template_arg="delta-api-plan-template" ;;
3529
+ *) echo "delta-plan-scaffold: unknown --lane '$lane_arg' (backend|frontend|api)" >&2; return 1 ;;
3340
3530
  esac
3341
3531
  local repo_root; repo_root=$(get_repo_root)
3342
3532
  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; }
@@ -3344,10 +3534,19 @@ cmd_delta_plan_scaffold() {
3344
3534
  [ -d "$cr_dir" ] || { echo "delta-plan-scaffold: no such open change '$cr_id' (looked in $cr_dir)" >&2; return 1; }
3345
3535
 
3346
3536
  local target="$cr_dir/$doc_name" created="false" migrated="false" tpl
3347
- if [ ! -f "$target" ] && [ -f "$cr_dir/$legacy_name" ]; then
3537
+ if [ ! -f "$target" ] && [ -n "$legacy_name" ] && [ -f "$cr_dir/$legacy_name" ]; then
3348
3538
  mv "$cr_dir/$legacy_name" "$target"; migrated="true"
3349
3539
  fi
3350
- if [ ! -f "$target" ]; then
3540
+ # A change planned before the delta API plan existed keeps its API in its delta backend
3541
+ # plan, which is read in its place; an empty delta API plan beside it would hide it.
3542
+ local blocked="false"
3543
+ if [ "$lane_arg" = "api" ] && [ ! -f "$target" ]; then
3544
+ blocked=$(_aw_stack_py api-split --feature-dir "$fd" --cr "$cr_id" 2>/dev/null \
3545
+ | python3 -c 'import json,sys
3546
+ try: print("true" if json.load(sys.stdin).get("needed") else "false")
3547
+ except Exception: print("false")' 2>/dev/null) || blocked="false"
3548
+ fi
3549
+ if [ ! -f "$target" ] && [ "$blocked" != "true" ]; then
3351
3550
  tpl=$(resolve_template "$template_arg" "$repo_root") || true
3352
3551
  if [ -n "$tpl" ] && [ -f "$tpl" ]; then cp "$tpl" "$target"; else touch "$target"; fi
3353
3552
  created="true"
@@ -3357,15 +3556,15 @@ cmd_delta_plan_scaffold() {
3357
3556
  # readable wave the template's `[N]` is left standing, which is what the merge and recall
3358
3557
  # fences read as "not ordered yet" and refuse on, with the command that stamps it.
3359
3558
  local scaffold_wave; scaffold_wave=$(_wave_of_readable "$fd" "$cr_id" "$repo_root")
3360
- if [ -n "$scaffold_wave" ]; then
3559
+ if [ -n "$scaffold_wave" ] && [ -f "$target" ]; then
3361
3560
  _stamp_header "$target" "Wave" "$scaffold_wave"
3362
3561
  fi
3363
3562
  local override_active="false"
3364
3563
  [ -f "$repo_root/.appweaver/templates/overrides/${template_arg}.md" ] && override_active="true"
3365
3564
  local template_source; template_source=$(resolve_template "$template_arg" "$repo_root") || true
3366
- 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' \
3565
+ printf '{"cr_id":"%s","cr_dir":"%s","lane":"%s","delta_plan":"%s","created":%s,"migrated":%s,"blocked":%s,"override_active":%s,"template":"%s","template_source":"%s"}\n' \
3367
3566
  "$(json_escape "$cr_id")" "$(json_escape "$cr_dir")" "$(json_escape "$lane_arg")" "$(json_escape "$target")" \
3368
- "$created" "$migrated" "$override_active" \
3567
+ "$created" "$migrated" "$blocked" "$override_active" \
3369
3568
  "$(json_escape "$template_arg")" "$(json_escape "${template_source:-}")"
3370
3569
  }
3371
3570
 
@@ -3899,7 +4098,7 @@ cmd_slice_gate_stamp() {
3899
4098
  --feature-dir) fd_arg="$2"; shift 2 ;;
3900
4099
  # --flag=value: the findings are a JSON list, and any value may be empty.
3901
4100
  --lane|--tests-green|--tests-ran|--requirements-ok|--findings) fwd+=("$1=$2"); shift 2 ;;
3902
- --help|-h) echo "slice-gate-stamp --cr-id <id> --lane backend|frontend [--feature-dir <path>] [--tests-green true|false] [--tests-ran true|false] [--requirements-ok true|false] [--findings <json list>] -> {file, lane, cr_id, verdict, tests_green, tests_ran, requirements_ok, findings, high_findings, gated}"; return 0 ;;
4101
+ --help|-h) echo "slice-gate-stamp --cr-id <id> --lane backend|frontend [--feature-dir <path>] [--tests-green true|false] [--tests-ran true|false] [--requirements-ok true|false] [--findings <json list>]... -> {file, lane, cr_id, verdict, tests_green, tests_ran, requirements_ok, findings, high_findings, gated}"; return 0 ;;
3903
4102
  *) echo "slice-gate-stamp: unknown arg '$1'" >&2; return 1 ;;
3904
4103
  esac
3905
4104
  done
@@ -4465,7 +4664,7 @@ cmd_cr_paths() {
4465
4664
  case "$1" in
4466
4665
  --cr-id) cr_id="$2"; shift 2 ;;
4467
4666
  --feature-dir) feature_dir_arg="$2"; shift 2 ;;
4468
- --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, lanes[], lanes_csv, lanes_recorded, touches_backend, touches_frontend, predecessor_files[], predecessor_specs[], predecessor_docs[], sibling_files[], domain_model}"; return 0 ;;
4667
+ --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, delta_api_plan, wave, lanes[], lanes_csv, lanes_recorded, touches_backend, touches_frontend, predecessor_files[], predecessor_specs[], predecessor_docs[], sibling_files[], domain_model}"; return 0 ;;
4469
4668
  *) echo "cr-paths: unknown arg '$1'" >&2; return 1 ;;
4470
4669
  esac
4471
4670
  done
@@ -4489,6 +4688,7 @@ dspec = os.path.join(cr_dir, "delta-spec.md")
4489
4688
  dmodel = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
4490
4689
  be = os.path.join(cr_dir, "delta-backend-plan.md")
4491
4690
  fe = os.path.join(cr_dir, "delta-frontend-plan.md")
4691
+ api = os.path.join(cr_dir, "delta-api-plan.md")
4492
4692
  master = os.path.join(ctx, "domain-model.md")
4493
4693
  wave = wave_of(dspec, wave_of(dmodel, 1))
4494
4694
  # The lanes a change touches: read_change_lanes in python/aw-stack.py, the one reader of a
@@ -4531,6 +4731,8 @@ print(json.dumps({
4531
4731
  "delta_model": rel(dmodel), "delta_model_exists": os.path.isfile(dmodel),
4532
4732
  "delta_backend_plan": rel(be), "delta_backend_plan_exists": os.path.isfile(be),
4533
4733
  "delta_frontend_plan": rel(fe), "delta_frontend_plan_exists": os.path.isfile(fe),
4734
+ # The change's API delta — merged into the feature's api-plan.md by finalize-delta.
4735
+ "delta_api_plan": rel(api), "delta_api_plan_exists": os.path.isfile(api),
4534
4736
  "wave": wave,
4535
4737
  # The lanes this change touches, from its delta-spec's **Lanes** line — every lane when
4536
4738
  # it records none (lanes_recorded false).
@@ -4947,10 +5149,19 @@ snap = os.path.join(fd, "changes", cr, "pre-merge")
4947
5149
  os.makedirs(snap, exist_ok=True)
4948
5150
  spec = os.path.join(fd, "spec.md")
4949
5151
  model = os.path.join(ctx, "domain-model.md")
5152
+ # Every document finalize-delta may rewrite: the two masters it merges into, the API plan it
5153
+ # merges the change's API into, the backend plan the first such merge moves the endpoints out
5154
+ # of, and the API draft it redraws.
5155
+ backend = next((os.path.join(fd, n) for n in ("backend-plan.md", "design.md")
5156
+ if os.path.isfile(os.path.join(fd, n))), os.path.join(fd, "backend-plan.md"))
5157
+ docs = ((spec, "spec.md"), (model, "domain-model.md"),
5158
+ (os.path.join(fd, "api-plan.md"), "api-plan.md"),
5159
+ (backend, os.path.basename(backend)),
5160
+ (os.path.join(fd, "api-draft.json"), "api-draft.json"))
4950
5161
  copied = []
4951
- # Flat names, not the source tree's shape: there are exactly two masters and they live at
4952
- # different levels, so a nested copy would encode the layout of the day it was taken.
4953
- for src, name in ((spec, "spec.md"), (model, "domain-model.md")):
5162
+ # Flat names, not the source tree's shape: the documents live at different levels, so a
5163
+ # nested copy would encode the layout of the day it was taken.
5164
+ for src, name in docs:
4954
5165
  if os.path.isfile(src):
4955
5166
  shutil.copy2(src, os.path.join(snap, name))
4956
5167
  copied.append(name)
@@ -4967,7 +5178,7 @@ manifest = {
4967
5178
  "version": version,
4968
5179
  "context_version": header(model, "Context version"),
4969
5180
  "files": copied,
4970
- "sources": {"spec.md": rel(spec), "domain-model.md": rel(model)},
5181
+ "sources": {name: rel(src) for src, name in docs if name in copied},
4971
5182
  # Stated in the file itself, because the restore is the reader's whole reason for
4972
5183
  # opening it and the condition on it is not obvious from the content.
4973
5184
  "note": ("Masters as they stood immediately before finalize-delta merged %s. "
@@ -5411,7 +5622,7 @@ cmd_gap_route() {
5411
5622
  # (python/aw-plan-coverage.py), called identically by both shells.
5412
5623
  cmd_plan_coverage() {
5413
5624
  case "${1:-}" in
5414
- --help|-h) echo "plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--generated <json list>] [--list-only] -> {ok, outcome, total, names[], names_csv, elements[], gaps[], gap_names[], gap_count, gaps_text, message}"; return 0 ;;
5625
+ --help|-h) echo "plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--api-plan <path>] [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--generated <json list>] [--list-only] -> {ok, outcome, total, names[], names_csv, elements[], gaps[], gap_names[], gap_count, gaps_text, message}"; return 0 ;;
5415
5626
  esac
5416
5627
  local repo_root; repo_root=$(get_repo_root)
5417
5628
  local core="$SCRIPT_DIR/../python/aw-plan-coverage.py"
@@ -5424,6 +5635,27 @@ cmd_plan_coverage() {
5424
5635
  python3 "$core" --repo "$repo_root" "$@"
5425
5636
  }
5426
5637
 
5638
+ # ──────────────────────────────── test-citations ────────────────────────────
5639
+ # Which of a lane plan's requirement ids no test of the slice names. A requirement whose
5640
+ # test was never written leaves the suite green, so analyze-slice and analyze-slice-delta
5641
+ # read the plan's FR- and BR- ids and look for each one in the slice's test files. Same
5642
+ # core as plan-coverage (python/aw-plan-coverage.py --citations), called identically by
5643
+ # both shells.
5644
+ cmd_test_citations() {
5645
+ case "${1:-}" in
5646
+ --help|-h) echo "test-citations --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--paths <json list>]... [--source-path <p>] [--test-path <p>] [--spec <spec.md>] [--model <domain-model.md>] [--known-gaps <json list>]... -> {ok, all_cited, clean, outcome, checked, readable, advisory, required[], uncited[], uncited_csv, uncited_text, other_uncited[], stale[], findings[], tests_scanned, scoped, warning, message}"; return 0 ;;
5647
+ esac
5648
+ local repo_root; repo_root=$(get_repo_root)
5649
+ local core="$SCRIPT_DIR/../python/aw-plan-coverage.py"
5650
+ if [ ! -f "$core" ]; then
5651
+ echo "test-citations: core not found at $core" >&2; return 1
5652
+ fi
5653
+ if ! command -v python3 >/dev/null 2>&1; then
5654
+ echo "test-citations: python3 is required" >&2; return 1
5655
+ fi
5656
+ python3 "$core" --repo "$repo_root" --citations "$@"
5657
+ }
5658
+
5427
5659
  # Stamp a version into the MASTER documents' `**Version**:` headers — the
5428
5660
  # deterministic twin of cr-archive's active_version.json bump. close-delta calls
5429
5661
  # this with the version cr-archive just computed, so all THREE carriers of the
@@ -5476,8 +5708,9 @@ pat = re.compile(r'^(\*\*Version\*\*:[ \t]*)(\S+)(.*)$', re.M)
5476
5708
  # spine cannot tolerate. A lane the feature does not have reports `missing` and nothing
5477
5709
  # fails, which is also how the legacy names are carried — a feature not yet migrated to
5478
5710
  # the plan names still gets stamped, and one that has been migrated reports the legacy
5479
- # pair missing.
5480
- for name in ("spec.md", "backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
5711
+ # pair missing. The API plan is a master too, and moves with them.
5712
+ for name in ("spec.md", "backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md",
5713
+ "api-plan.md"):
5481
5714
  p = os.path.join(fd, name)
5482
5715
  if not os.path.isfile(p):
5483
5716
  missing.append(name); continue
@@ -5495,12 +5728,16 @@ for name in ("spec.md", "backend-plan.md", "frontend-plan.md", "design.md", "des
5495
5728
  # found by its CR cell, not by position: rows are appended newest-last and a feature may
5496
5729
  # carry many, so "the last row" would be right only until two changes were open at once.
5497
5730
  # Only a cell still reading `(pending)` is written — a row already carrying a version
5498
- # belongs to a change that closed, and re-stamping it would rewrite history.
5731
+ # belongs to a change that closed, and re-stamping it would rewrite history. The API plan's
5732
+ # row, which finalize-delta's API merge wrote, is completed the same way.
5499
5733
  row_filled = False
5500
5734
  row_version = ""
5735
+ rows_filled = []
5501
5736
  if cr:
5502
- p = os.path.join(fd, "spec.md")
5503
- if os.path.isfile(p):
5737
+ for name in ("spec.md", "api-plan.md"):
5738
+ p = os.path.join(fd, name)
5739
+ if not os.path.isfile(p):
5740
+ continue
5504
5741
  src = open(p, encoding="utf-8").read()
5505
5742
  out_lines, changed = [], False
5506
5743
  for line in src.splitlines(True):
@@ -5508,14 +5745,17 @@ if cr:
5508
5745
  if (not changed) and cr in line and line.lstrip().startswith("|"):
5509
5746
  cells = line.split("|")
5510
5747
  if len(cells) > 2 and cells[1].strip().lower() in ("(pending)", "pending", "—", "-", ""):
5511
- row_version = cells[1].strip()
5748
+ if name == "spec.md":
5749
+ row_version = cells[1].strip()
5512
5750
  cells[1] = " %s " % ver
5513
5751
  line = "|".join(cells)
5514
5752
  changed = True
5515
5753
  out_lines.append(line)
5516
5754
  if changed:
5517
5755
  open(p, "w", encoding="utf-8").write("".join(out_lines))
5518
- row_filled = True
5756
+ rows_filled.append(name)
5757
+ if name == "spec.md":
5758
+ row_filled = True
5519
5759
 
5520
5760
  print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
5521
5761
  "unchanged": unchanged, "missing": missing,
@@ -5524,7 +5764,10 @@ print(json.dumps({"ok": True, "version": ver, "stamped": stamped,
5524
5764
  # the ordinary outcome for a re-run (already filled) and for a change
5525
5765
  # whose merge predates the pending convention.
5526
5766
  "cr_id": cr, "history_row_filled": row_filled,
5527
- "history_row_was": row_version}))
5767
+ "history_row_was": row_version,
5768
+ # Every document whose pending row was completed: spec.md, and the API
5769
+ # plan when the change merged an API delta into it.
5770
+ "history_rows_filled": rows_filled}))
5528
5771
  PY
5529
5772
  }
5530
5773
 
@@ -5538,6 +5781,7 @@ case "$sub" in
5538
5781
  set-domain) cmd_set_domain "$@" ;;
5539
5782
  stack-list) cmd_stack_list "$@" ;;
5540
5783
  stack-detect) cmd_stack_detect "$@" ;;
5784
+ stack-rules) cmd_stack_rules "$@" ;;
5541
5785
  lane-declare) cmd_lane_declare "$@" ;;
5542
5786
  lane-resolve) cmd_lane_resolve "$@" ;;
5543
5787
  lane-verify) cmd_lane_verify "$@" ;;
@@ -5551,6 +5795,9 @@ case "$sub" in
5551
5795
  contract-client) cmd_contract_client "$@" ;;
5552
5796
  contract-check) cmd_contract_check "$@" ;;
5553
5797
  contract-impact) cmd_contract_impact "$@" ;;
5798
+ contract-verify) cmd_contract_verify "$@" ;;
5799
+ contract-merge) cmd_contract_merge "$@" ;;
5800
+ api-split) cmd_api_split "$@" ;;
5554
5801
  list-contexts) cmd_list_contexts "$@" ;;
5555
5802
  set-context) cmd_set_context "$@" ;;
5556
5803
  list-features) cmd_list_features "$@" ;;
@@ -5588,11 +5835,14 @@ case "$sub" in
5588
5835
  context-version-bump) cmd_context_version_bump "$@" ;;
5589
5836
  context-paths) cmd_context_paths "$@" ;;
5590
5837
  model-scaffold) cmd_model_scaffold "$@" ;;
5838
+ model-guard-snapshot) cmd_model_guard snapshot "$@" ;;
5839
+ model-guard-check) cmd_model_guard check "$@" ;;
5591
5840
  version-stamp) cmd_version_stamp "$@" ;;
5592
5841
  plan-gaps) cmd_plan_gaps "$@" ;;
5593
5842
  plan-gate-stamp) cmd_plan_gate_stamp "$@" ;;
5594
5843
  plan-gate-check) cmd_plan_gate_check "$@" ;;
5595
5844
  plan-coverage) cmd_plan_coverage "$@" ;;
5845
+ test-citations) cmd_test_citations "$@" ;;
5596
5846
  gap-route) cmd_gap_route "$@" ;;
5597
5847
  checklist-read) cmd_checklist_read "$@" ;;
5598
5848
  checklist-apply) cmd_checklist_apply "$@" ;;