aw-spec-cli 0.1.23 → 0.1.25

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 (38) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +3 -3
  2. package/assets/.appweaver/RULE-PARTITION.md +41 -28
  3. package/assets/.appweaver/orchestrator/orchestrator.py +92 -14
  4. package/assets/.appweaver/orchestrator/rev-templates/ai-rules.reference.md +3 -3
  5. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +19 -12
  6. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +25 -17
  7. package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +2 -2
  8. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +10 -10
  9. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +28 -17
  10. package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +22 -24
  11. package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +1 -1
  12. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +29 -19
  13. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +27 -20
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +184 -37
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +18 -0
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +19 -0
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +18 -0
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +55 -29
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +650 -205
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +19 -0
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +5 -5
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +2 -2
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +13 -3
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +40 -1
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +2 -1
  26. package/assets/.appweaver/scripts/bash/aw-rev-constitution.sh +1 -1
  27. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +69 -7
  28. package/assets/.appweaver/scripts/powershell/aw-rev-constitution.ps1 +1 -1
  29. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +58 -2
  30. package/assets/.appweaver/scripts/python/aw-inventory.py +1809 -156
  31. package/assets/.appweaver/scripts/python/aw-rev-constitution.py +106 -145
  32. package/assets/.appweaver/scripts/python/aw-stack.py +45 -1
  33. package/assets/.appweaver/scripts/python/aw-xref.py +73 -2
  34. package/assets/.appweaver/scripts/python/validate_okf.py +10 -5
  35. package/assets/.appweaver/templates/okf-concept-template.md +10 -2
  36. package/assets/.appweaver/templates/proposed-inventory-template.md +100 -48
  37. package/assets/.appweaver/templates/source-map-template.md +14 -6
  38. package/package.json +1 -1
@@ -8,52 +8,50 @@ reverse-engineers that tier from what the code itself evidences, so forward
8
8
  development of the SAME codebase runs governed exactly like greenfield (Change
9
9
  Requests through the CR delta loop).
10
10
 
11
- CALLER (2026-07-29 onward): the standalone `/appweaver rev-constitution` workflow
12
- is RETIRED — do not expect that command to exist. The phase now lives in:
13
- * `/appweaver onboard` — up front, over the WHOLE repo, before any slice is
14
- adopted. It is the ONLY caller of this core (workflow `scripts:` ref, both
15
- shells). At onboard time nothing is adopted yet, so `list-adopted` legitimately
16
- returns slice_count 0 and draws its context from the OKF bundle instead.
17
- * `/appweaver adopt` — per slice, reconciling each provisional principle
18
- against that slice's real spec.md/design.md evidence. Adopt does NOT call this
19
- core; it reconciles in its own steps.
20
-
21
- This core survived the retirement unchanged because it is the MODEL-FREE half: it
22
- enumerates whatever slices ARE adopted and scaffolds the
23
- `guardrails/projects/brf-<domain>/` project directory; the calling workflow's authoring
24
- agents fill its rule files + the split constitution. Governance stance is AS-BUILT
25
- (the constitution reflects what the code does, not an aspiration — a separate
26
- modernization/migration workflow drains the debt later).
11
+ CALLER: `/appweaver onboard` is the only caller of this core (workflow `scripts:` ref,
12
+ both shells). The first context to onboard authors the project tier; every later one
13
+ amends it additively from its own code. The standalone
14
+ `/appweaver rev-constitution` workflow is retired, and adopt reconciles in its own
15
+ steps without calling this core.
16
+
17
+ This core is the MODEL-FREE half: it gathers the project framing the OKF bundle holds
18
+ and scaffolds the `guardrails/projects/brf-<domain>/` project directory; the calling
19
+ workflow's authoring agents fill its rule files + the split constitution. Governance
20
+ stance is AS-BUILT (the constitution reflects what the code does, not an aspiration).
27
21
 
28
22
  Two subcommands, each printing a single JSON object as the LAST stdout line (so
29
23
  the orchestrator's execute_script can bind it); human-readable summaries go to
30
24
  STDERR so they never corrupt the JSON contract:
31
25
 
32
- list-adopted [--root <dir>] [--specs <dir>] [--bundle <dir>]
33
- -> the union of adopted artifacts to reverse-engineer FROM. Returns
34
- {slices[], slice_count, reverse_count, spec_context[], design_context[],
35
- all_context[], project_context[], project_name, overview, platform,
36
- backlog, root}. *_context arrays are workspace-relative paths ready for a
37
- build_context `context_from:`. The technology (design) side carries the
38
- designs + OKF overview + platform note + migration backlog + the per-slice
39
- source-maps (the code-coordinate bridge) so the TECHNICAL half is derived from
40
- what the code evidences AND cites real code files/types, not the template —
41
- including the shared-infra gaps (platform Open Questions) and the as-built debt
42
- sink to route unmet template ideals to. Stays domain-agnostic by the author's
43
- prompt (cite code paths, ignore the source-map's domain spec-ids / entity names).
26
+ project-context [--root <dir>] [--bundle <dir>]
27
+ -> the OKF bundle's project framing. Returns
28
+ {project_context[], technical_context[], project_name, overview, platform,
29
+ root}. The *_context arrays are workspace-relative paths ready for a
30
+ build_context `context_from:`. project_context is the domain side (overview +
31
+ aggregate index + platform note + bundle index); technical_context is the
32
+ domain-free side (overview + platform note). Neither carries a feature's
33
+ spec, model, plan or source-map: onboard's single oracle is the code, and a
34
+ feature adopted before the tier is authored must not steer project-wide
35
+ rules. `list-adopted` is the older name for the same subcommand and still
36
+ works; it answers with this framing and no longer lists features.
44
37
 
45
38
  project-setup --name <domain> [--fallback <name>] [--root <dir>]
46
39
  -> slugify + `brf-` prefix the domain, create guardrails/projects/<slug>/ with its
47
- service/ frontend/ domain/ subfolders (guard: refuses to clobber a project that
48
- already holds .md files anywhere). Returns
49
- {domain_slug, pack_dir, domain_rule_bindings, domain_constitution_source,
50
- pack_present}. Both maps sit at the project root; every rule file, the
51
- domain charter included, sits in one of the three subfolders.
52
- pack_present is the STRING "true"/"false" (so verify_context
53
- require_value/condition can gate the anti-clobber guard).
40
+ service/ frontend/ domain/ subfolders when the project does not exist yet.
41
+ Without --name, an ACTIVE `brf-` project (.appweaver/domain.json) that already
42
+ holds rule files wins over the fallback name, so every later context amends the
43
+ project the first one created even if the bundle's project name has drifted.
44
+ Returns {domain_slug, pack_dir, domain_rule_bindings, domain_constitution_source,
45
+ pack_present, domain_present, service_present, frontend_present,
46
+ domain_files[], service_files[], frontend_files[]}. Both maps sit at the project
47
+ root; every rule file, the domain charter included, sits in one of the three
48
+ subfolders. The *_present flags are STRINGS "true"/"false" (so a workflow
49
+ condition can compare them): pack_present = the project holds any .md file,
50
+ and each tier flag = that subfolder does. The *_files arrays are the tier's
51
+ rule files, workspace-relative, ready for a build_context `context_from:`.
54
52
  `pack-setup` is the older name for the same subcommand and still works.
55
53
 
56
- Usage: aw-rev-constitution.py <list-adopted|project-setup> [options]
54
+ Usage: aw-rev-constitution.py <project-context|project-setup> [options]
57
55
  """
58
56
 
59
57
  import argparse
@@ -80,8 +78,7 @@ def _emit(obj):
80
78
 
81
79
 
82
80
  def _uniq(seq):
83
- """Order-preserving de-dup (context arrays are concatenated from overlapping
84
- sources — the OKF overview belongs to several of them)."""
81
+ """Order-preserving de-dup (a context array can name the same bundle file twice)."""
85
82
  seen, out = set(), []
86
83
  for x in seq:
87
84
  if x and x not in seen:
@@ -109,52 +106,12 @@ def _project_name(overview_abs):
109
106
  return b.group(1).strip() if b else ""
110
107
 
111
108
 
112
- # ─────────────────────────────── list-adopted ──────────────────────────────
113
- def cmd_list_adopted(args):
109
+ # ────────────────────────────── project-context ────────────────────────────
110
+ def cmd_project_context(args):
114
111
  root = _root(args.root)
115
- specs_dir = os.path.join(root, args.specs)
116
112
  bundle_dir = os.path.join(root, args.bundle)
117
113
 
118
- slices = []
119
- spec_context = []
120
- design_context = []
121
- source_maps = []
122
- reverse_count = 0
123
-
124
- if os.path.isdir(specs_dir):
125
- for name in sorted(os.listdir(specs_dir)):
126
- sdir = os.path.join(specs_dir, name)
127
- if not os.path.isdir(sdir):
128
- continue
129
- spec = os.path.join(sdir, "spec.md")
130
- design = os.path.join(sdir, "design.md")
131
- # A slice is adoptable-governance input only when BOTH docs exist.
132
- if not (os.path.isfile(spec) and os.path.isfile(design)):
133
- continue
134
- smap = os.path.join(sdir, "source-map.md")
135
- is_reverse = False
136
- try:
137
- with open(spec, "r", encoding="utf-8", errors="replace") as fh:
138
- head = fh.read(4096)
139
- if re.search(r"reverse-engineered", head, re.IGNORECASE):
140
- is_reverse = True
141
- except OSError:
142
- pass
143
- if is_reverse:
144
- reverse_count += 1
145
- slices.append({
146
- "name": name,
147
- "spec": _rel(spec, root),
148
- "design": _rel(design, root),
149
- "source_map": _rel(smap, root) if os.path.isfile(smap) else "",
150
- "origin": "reverse-engineered" if is_reverse else "unknown",
151
- })
152
- spec_context.append(_rel(spec, root))
153
- design_context.append(_rel(design, root))
154
- if os.path.isfile(smap):
155
- source_maps.append(_rel(smap, root))
156
-
157
- # The OKF architecture overview names the actual tech stack (technology half input).
114
+ # The OKF architecture overview names the actual tech stack.
158
115
  overview = ""
159
116
  overview_abs = ""
160
117
  for cand in ("architecture/overview.md", "architecture/architecture.md"):
@@ -164,26 +121,18 @@ def cmd_list_adopted(args):
164
121
  overview_abs = p
165
122
  break
166
123
 
167
- # TECHNICAL evidence beyond the overview, both domain-free: the shared-platform note
168
- # (cross-cutting persistence / tenancy / concurrency reality — incl. its Open Questions,
169
- # e.g. tenancy may be inactive) and the migration backlog (the as-built DEBT SINK the
170
- # technical author routes any unmet template ideal / stub to, instead of asserting it).
171
- # These feed the DESIGN side so the technical half is EVIDENCE-FIRST, not template-first.
124
+ # The shared-platform note: cross-cutting persistence / tenancy / concurrency as
125
+ # built, including its Open Questions (tenancy may be inactive). Domain-free, so it
126
+ # is technical evidence as well as project framing.
172
127
  platform_ref = ""
173
128
  p = os.path.join(bundle_dir, "architecture/platform.md")
174
129
  if os.path.isfile(p):
175
130
  platform_ref = _rel(p, root)
176
- backlog_ref = ""
177
- p = os.path.join(bundle_dir, "migration-backlog.md")
178
- if os.path.isfile(p):
179
- backlog_ref = _rel(p, root)
180
-
181
- # Project-level OKF framing — the PROJECT AS A WHOLE, not just one adopted slice:
182
- # the overview (mission + aggregate map + external refs + stack), the aggregate
183
- # index (bounded-context map), and the shared-platform note. These are what let the
184
- # DOMAIN author write a project charter instead of a single-aggregate spec. The
185
- # technical half deliberately does NOT get the aggregate index or specs (stays
186
- # domain-free); it DOES get platform.md + the backlog as technical evidence (below).
131
+
132
+ # The PROJECT AS A WHOLE: the overview (mission + aggregate map + external refs +
133
+ # stack), the aggregate index (bounded-context map), the shared-platform note and
134
+ # the bundle index. This is what lets the DOMAIN author write a project charter
135
+ # instead of a single-aggregate spec.
187
136
  project_context = [overview] if overview else []
188
137
  for cand in ("aggregates/index.md", "architecture/platform.md", "index.md"):
189
138
  p = os.path.join(bundle_dir, cand)
@@ -191,51 +140,28 @@ def cmd_list_adopted(args):
191
140
  project_context.append(_rel(p, root))
192
141
  project_context = _uniq(project_context)
193
142
 
143
+ # The technical half gets the stack and the platform note and nothing domain-bearing:
144
+ # no aggregate index, so it stays domain-agnostic.
145
+ technical_context = _uniq([overview, platform_ref])
146
+
194
147
  # The display name of the whole project the governance tier governs (from the OKF
195
148
  # overview), so the project + constitution are named + titled after the PROJECT — not the
196
149
  # outer tooling folder the CWD-basename fallback would pick.
197
150
  project_name = _project_name(overview_abs)
198
151
 
199
- # spec side = domain input (specs + source-maps + PROJECT framing); design side =
200
- # technology input (designs + OKF stack overview + shared-platform note + as-built
201
- # backlog + the per-slice source-maps — the code-coordinate bridge, so the TECHNICAL
202
- # half anchors each principle to a real code file/type, not just a design section;
203
- # kept domain-agnostic by the author's prompt discipline, not by starving it of the
204
- # code map — rich enough to DERIVE the principle set from evidence and route unmet
205
- # ideals to debt); all = the rule-set author's full picture.
206
- spec_side = _uniq(spec_context + source_maps + project_context)
207
- design_side = _uniq(
208
- design_context
209
- + source_maps
210
- + ([overview] if overview else [])
211
- + ([platform_ref] if platform_ref else [])
212
- + ([backlog_ref] if backlog_ref else [])
213
- )
214
- all_context = _uniq(spec_context + design_context + source_maps + project_context)
215
-
216
152
  result = {
217
- "slices": slices,
218
- "slice_count": len(slices),
219
- "reverse_count": reverse_count,
220
- "spec_context": spec_side,
221
- "design_context": design_side,
222
- "all_context": all_context,
223
153
  "project_context": project_context,
154
+ "technical_context": technical_context,
224
155
  "project_name": project_name,
225
156
  "overview": overview,
226
157
  "platform": platform_ref,
227
- "backlog": backlog_ref,
228
158
  "root": root,
229
159
  }
230
160
 
231
- names = ", ".join(s["name"] for s in slices) or "(none)"
232
161
  print(
233
- f"[govern-backward] adopted slices: {len(slices)} "
234
- f"({reverse_count} reverse-engineered) — {names}\n"
235
- f" project: {project_name or '(unknown — no OKF overview)'} "
236
- f"overview: {overview or '(none — no OKF bundle)'}\n"
237
- f" technical evidence: platform={platform_ref or '(none)'} "
238
- f"backlog={backlog_ref or '(none)'}",
162
+ f"[govern-backward] project: {project_name or '(unknown — no OKF overview)'} "
163
+ f"overview: {overview or '(none — no OKF bundle)'} "
164
+ f"platform: {platform_ref or '(none)'}",
239
165
  file=sys.stderr,
240
166
  )
241
167
  _emit(result)
@@ -250,9 +176,34 @@ def _slugify(name):
250
176
  return s
251
177
 
252
178
 
179
+ def _md_files(d, root):
180
+ """The .md files under one tier folder, workspace-relative and sorted."""
181
+ out = []
182
+ if os.path.isdir(d):
183
+ for dp, _, fs in os.walk(d):
184
+ out.extend(_rel(os.path.join(dp, f), root) for f in fs if f.endswith(".md"))
185
+ return sorted(out)
186
+
187
+
188
+ def _active_brf_project(root):
189
+ """The active project from .appweaver/domain.json when it is a brownfield (`brf-`)
190
+ project that already holds rule files, else "". A greenfield project the workspace
191
+ happens to be bound to is never taken over."""
192
+ try:
193
+ with open(os.path.join(root, ".appweaver", "domain.json"), encoding="utf-8") as fh:
194
+ name = (json.load(fh).get("domain") or "").strip()
195
+ except (OSError, ValueError, AttributeError):
196
+ return ""
197
+ if not name.startswith("brf-"):
198
+ return ""
199
+ d = os.path.join(root, "guardrails", "projects", name)
200
+ return name if _md_files(d, root) else ""
201
+
202
+
253
203
  def cmd_pack_setup(args):
254
204
  root = _root(args.root)
255
- raw = args.name if (args.name and args.name.strip()) else args.fallback
205
+ active = "" if (args.name and args.name.strip()) else _active_brf_project(root)
206
+ raw = args.name if (args.name and args.name.strip()) else (active or args.fallback)
256
207
  if not raw or not raw.strip():
257
208
  # Last-resort fallback: the component/repo directory name.
258
209
  raw = os.path.basename(root.rstrip(os.sep)) or "domain"
@@ -264,16 +215,17 @@ def cmd_pack_setup(args):
264
215
  domain_slug = "brf-" + slug
265
216
 
266
217
  pack_dir = os.path.join(root, "guardrails", "projects", domain_slug)
267
- # Anti-clobber guard: a project that already holds curated .md files is not ours
268
- # to overwrite (mirrors inventory's bundle_guard). pack_present drives the gate.
269
- # The walk covers the whole subtree, because a project's rule files live in its
270
- # service/ frontend/ domain/ subfolders and only the two maps sit at its root.
271
- present = False
272
- if os.path.isdir(pack_dir):
273
- present = any(f.endswith(".md") for _, _, fs in os.walk(pack_dir) for f in fs)
274
- if not present:
275
- for sub in ("service", "frontend", "domain"):
276
- os.makedirs(os.path.join(pack_dir, sub), exist_ok=True)
218
+ # A project that already holds .md files was authored by an earlier context: the
219
+ # calling workflow AMENDS it rather than authoring it again. The walk covers the
220
+ # whole subtree, because a project's rule files live in its service/ frontend/
221
+ # domain/ subfolders and only the two maps sit at its root. Each tier is reported
222
+ # on its own, so a lane declared after the first onboard is authored in full while
223
+ # the tiers that exist are amended.
224
+ tiers = {sub: _md_files(os.path.join(pack_dir, sub), root)
225
+ for sub in ("domain", "service", "frontend")}
226
+ present = bool(_md_files(pack_dir, root))
227
+ for sub in ("service", "frontend", "domain"):
228
+ os.makedirs(os.path.join(pack_dir, sub), exist_ok=True)
277
229
 
278
230
  result = {
279
231
  "domain_slug": domain_slug,
@@ -287,11 +239,17 @@ def cmd_pack_setup(args):
287
239
  os.path.join(pack_dir, "domain", "domain-constitution.md"), root
288
240
  ),
289
241
  "pack_present": "true" if present else "false",
242
+ "domain_present": "true" if tiers["domain"] else "false",
243
+ "service_present": "true" if tiers["service"] else "false",
244
+ "frontend_present": "true" if tiers["frontend"] else "false",
245
+ "domain_files": tiers["domain"],
246
+ "service_files": tiers["service"],
247
+ "frontend_files": tiers["frontend"],
290
248
  "root": root,
291
249
  }
292
250
  print(
293
251
  f"[govern-backward] project: guardrails/projects/{domain_slug}/ "
294
- f"({'EXISTS — guarded' if present else 'created'})",
252
+ f"({'EXISTS — ' + ', '.join(f'{k} {len(v)}' for k, v in tiers.items()) if present else 'created'})",
295
253
  file=sys.stderr,
296
254
  )
297
255
  _emit(result)
@@ -302,11 +260,14 @@ def main(argv):
302
260
  ap = argparse.ArgumentParser(prog="aw-rev-constitution.py", add_help=True)
303
261
  sub = ap.add_subparsers(dest="cmd")
304
262
 
305
- la = sub.add_parser("list-adopted", help="enumerate adopted slices to reverse-engineer from")
306
- la.add_argument("--root", default="")
307
- la.add_argument("--specs", default="specs")
308
- la.add_argument("--bundle", default="knowledge")
309
- la.set_defaults(func=cmd_list_adopted)
263
+ # `list-adopted` is the older name, kept so a caller written against it still runs.
264
+ # It also still accepts `--specs`, which nothing reads now.
265
+ for name in ("project-context", "list-adopted"):
266
+ pc = sub.add_parser(name, help="the OKF bundle's project framing for the governance authors")
267
+ pc.add_argument("--root", default="")
268
+ pc.add_argument("--bundle", default="knowledge")
269
+ pc.add_argument("--specs", default="specs", help=argparse.SUPPRESS)
270
+ pc.set_defaults(func=cmd_project_context)
310
271
 
311
272
  # `pack-setup` is the name this subcommand shipped under and is kept as an alias:
312
273
  # the folder it creates is a PROJECT, so the primary name says so, and a caller
@@ -9,7 +9,8 @@ powershell twin so the two stay in parity by construction rather than by review:
9
9
  detect match the repo's own files against those profiles — the BROWNFIELD
10
10
  candidate list, since an existing repo already answered the question
11
11
  declare upsert a lane into .appweaver/stacks.json — THE SOLE WRITER
12
- resolve read .appweaver/stacks.json — for every downstream reader
12
+ resolve read .appweaver/stacks.json — for every downstream reader; with
13
+ --feature-dir, narrowed to the lanes that feature has a surface in
13
14
  verify is the declaration USABLE for the phase about to run — the gate
14
15
  run-phase run a phase's command for EVERY declared lane, not just the first
15
16
  probe is there evidence of a lane of this kind — read-only, never fatal
@@ -964,8 +965,47 @@ def lane_options(repo, lanes):
964
965
  return out
965
966
 
966
967
 
968
+ def feature_lanes(repo, feature_dir):
969
+ """The lane kinds a FEATURE has a surface in, or None when it records none.
970
+
971
+ A repo declares its lanes once, in stacks.json; a feature inside it may still own no
972
+ part of one lane's surface — a job-only or shared-kernel slice in a repo that also has
973
+ a UI. adopt records that fact as `lanes` in the feature's active_version.json (written
974
+ by `feature-lanes`), and this reads it back. None — no feature named, no file, or no
975
+ record — means the feature follows every declared lane, which is the ordinary case.
976
+ """
977
+ if not feature_dir:
978
+ return None
979
+ fd = feature_dir if os.path.isabs(feature_dir) else os.path.join(repo, feature_dir)
980
+ try:
981
+ with open(os.path.join(fd, "active_version.json"), encoding="utf-8") as fh:
982
+ recorded = json.load(fh).get("lanes")
983
+ except (OSError, ValueError, AttributeError):
984
+ return None
985
+ if not isinstance(recorded, list) or not recorded:
986
+ return None
987
+ return [k for k in ("backend", "frontend") if k in recorded]
988
+
989
+
990
+ def apply_feature_lanes(payload, kinds):
991
+ """Narrow the per-kind `<kind>_declared` flags to the lanes a feature has a surface in.
992
+
993
+ Only the per-kind flags move. `declared`, `lanes` and `kinds` stay the REPO's answer, so
994
+ a gate that asks "is anything declared at all" still reads stacks.json, while every
995
+ step that plans, gates or builds one lane reads the flag and skips a lane this feature
996
+ does not own. `<kind>_feature_excluded` says which case a false flag is.
997
+ """
998
+ payload["feature_lanes"] = list(kinds or [])
999
+ for kind in ("backend", "frontend"):
1000
+ excluded = bool(kinds is not None and payload.get(kind + "_declared") and kind not in kinds)
1001
+ payload[kind + "_feature_excluded"] = excluded
1002
+ if excluded:
1003
+ payload[kind + "_declared"] = False
1004
+
1005
+
967
1006
  def cmd_resolve(args):
968
1007
  data = read_stacks(args.repo)
1008
+ fl = feature_lanes(args.repo, getattr(args, "feature_dir", ""))
969
1009
 
970
1010
  # Absence is a valid state for READING, never a failure: `resolve` reports what
971
1011
  # is there and lets the caller decide. It is `verify` that turns an absent or
@@ -982,6 +1022,7 @@ def cmd_resolve(args):
982
1022
  }
983
1023
  payload.update(kind_block(args.repo, [], "backend"))
984
1024
  payload.update(kind_block(args.repo, [], "frontend"))
1025
+ apply_feature_lanes(payload, fl)
985
1026
  emit(payload)
986
1027
  return
987
1028
 
@@ -1022,6 +1063,7 @@ def cmd_resolve(args):
1022
1063
  }
1023
1064
  payload.update(kind_block(args.repo, lanes, "backend"))
1024
1065
  payload.update(kind_block(args.repo, lanes, "frontend"))
1066
+ apply_feature_lanes(payload, fl)
1025
1067
  emit(payload)
1026
1068
 
1027
1069
 
@@ -1447,6 +1489,8 @@ def main():
1447
1489
  p = sub.add_parser("resolve", help="read stacks.json")
1448
1490
  p.add_argument("--kind", choices=["backend", "frontend"])
1449
1491
  p.add_argument("--id")
1492
+ p.add_argument("--feature-dir", dest="feature_dir", default="",
1493
+ help="report a lane this feature records no surface in as not declared")
1450
1494
  p.set_defaults(func=cmd_resolve)
1451
1495
 
1452
1496
  p = sub.add_parser("verify", help="is the declaration usable for the phase about to run")
@@ -451,7 +451,7 @@ def _read_fm(text):
451
451
 
452
452
  def _render_fm(fm):
453
453
  order = ["type", "title", "description", "origin", "bounded_context", "features",
454
- "source_files", "ui_source_files"]
454
+ "adopted_features", "source_files", "ui_source_files"]
455
455
  keys = [k for k in order if k in fm] + [k for k in fm if k not in order]
456
456
  out = ["---"]
457
457
  for k in keys:
@@ -1544,6 +1544,66 @@ def cmd_co_owners(args):
1544
1544
  return 0
1545
1545
 
1546
1546
 
1547
+ def _map_features(root):
1548
+ """(features, commit) from the confirmed boundary map — ([], "") where there is none."""
1549
+ try:
1550
+ with open(os.path.join(root, ".appweaver", "inventory", "feature-map.json"),
1551
+ encoding="utf-8") as fh:
1552
+ m = json.load(fh)
1553
+ except (OSError, ValueError):
1554
+ return [], ""
1555
+ return (m.get("features") or []), (m.get("commit") or "")
1556
+
1557
+
1558
+ def _operation_scope(root, feature, siblings):
1559
+ """The operations the boundary map gives this feature, and the ones it gives ONLY to a
1560
+ sibling feature over the same aggregate.
1561
+
1562
+ A feature is a capability scope the developer confirmed in the map, and two features
1563
+ over one aggregate share its code: every file of Encounter serves RecordEncounter and
1564
+ AmendEncounter alike. The operation list is what tells their scopes apart. A read both
1565
+ features call (GetEncounter) is in both lists, so it is this feature's too and never a
1566
+ sibling's alone."""
1567
+ mapped, commit = _map_features(root)
1568
+ by = {_key(f.get("feature")): [o for o in dict.fromkeys(f.get("operations") or []) if o]
1569
+ for f in mapped}
1570
+ mine = by.get(_key(feature), [])
1571
+ others = {}
1572
+ for s in siblings:
1573
+ only = [o for o in by.get(_key(s), []) if o not in mine]
1574
+ if only:
1575
+ others[s] = only
1576
+ return mine, others, commit
1577
+
1578
+
1579
+ def _operations_note(mine, others):
1580
+ """One prompt-ready line: this feature's operations and the ones that are not its own."""
1581
+ if not mine:
1582
+ return ("The boundary map records no operation list for this feature: scope its user "
1583
+ "stories by the feature itself, and treat an operation whose folder or name "
1584
+ "belongs to another feature as that feature's.")
1585
+ line = "This feature's operations (the confirmed boundary map): %s." % ", ".join(mine)
1586
+ if others:
1587
+ line += (" Operations that belong ONLY to another feature over the same aggregate — "
1588
+ "not this feature's: %s." % "; ".join(
1589
+ "%s → %s" % (s, ", ".join(ops)) for s, ops in others.items()))
1590
+ else:
1591
+ line += " No other feature over the same aggregate owns an operation of its own."
1592
+ return line
1593
+
1594
+
1595
+ def _commit_time(root, commit):
1596
+ """The ISO commit time of the inventory's baseline, or "" when git cannot resolve it."""
1597
+ if not commit:
1598
+ return ""
1599
+ try:
1600
+ r = subprocess.run(["git", "-C", root, "show", "-s", "--format=%cI", commit],
1601
+ capture_output=True, text=True, timeout=10)
1602
+ except (OSError, subprocess.SubprocessError):
1603
+ return ""
1604
+ return r.stdout.strip() if r.returncode == 0 else ""
1605
+
1606
+
1547
1607
  def cmd_feature_aggregates(args):
1548
1608
  """WHICH AGGREGATE(S) A FEATURE ACTS ON — the name every command that reads the
1549
1609
  bounded context's domain-model.md needs, because the model is organised by
@@ -1572,6 +1632,12 @@ def cmd_feature_aggregates(args):
1572
1632
  those aggregates — the 1:1 case, even where the spec also names an aggregate it
1573
1633
  merely touches — and otherwise every aggregate it names.
1574
1634
 
1635
+ It also carries what adopt needs to scope a reverse to the feature rather than to its
1636
+ aggregate: `operations` (this feature's, from the boundary map), `sibling_operations`
1637
+ (those only another feature over the same aggregate owns), `operations_note` (both as
1638
+ one prompt-ready line), and the inventory baseline the map was drawn at
1639
+ (`baseline_commit`, `baseline_time` — "" where git cannot resolve it).
1640
+
1575
1641
  STRICTLY READ-ONLY and never empty: source 3 always answers, so a caller never has
1576
1642
  to branch on a missing result."""
1577
1643
  root = _root(args.root)
@@ -1580,8 +1646,13 @@ def cmd_feature_aggregates(args):
1580
1646
  _emit({"error": "feature-dir-required", "root": root})
1581
1647
  return 2
1582
1648
  r = _resolve_feature(args.feature_dir, root)
1649
+ mine, others, commit = _operation_scope(root, r["feature"], r["shared_with"])
1650
+ r.update({"operations": mine, "operations_csv": ", ".join(mine),
1651
+ "sibling_operations": others,
1652
+ "operations_note": _operations_note(mine, others),
1653
+ "baseline_commit": commit, "baseline_time": _commit_time(root, commit)})
1583
1654
  print(f"[xref] feature-aggregates {r['feature']}: acts on {r['aggregates_csv']} "
1584
- f"(from {r['source']}); owns {r['owned_csv']}", file=sys.stderr)
1655
+ f"(from {r['source']}); owns {r['owned_csv']}; {len(mine)} operation(s)", file=sys.stderr)
1585
1656
  r["root"] = root
1586
1657
  _emit(r)
1587
1658
  return 0
@@ -14,7 +14,9 @@ ERRORS (bundle is non-conformant):
14
14
  - log.md date headings not in ISO 8601 YYYY-MM-DD form (§7)
15
15
  E4 log.md date block missing a required `Commit: \\`<sha>\\`` line, or
16
16
  the line is present but not a valid hex commit SHA. This is an
17
- ERROR because /speckit.okf.update depends on it to resume.
17
+ ERROR because the commit is what anchors each journal entry to the
18
+ code it describes (OKF §7) — an entry without one cannot be dated
19
+ against the history.
18
20
 
19
21
  WARNINGS (consumers must tolerate; reported for quality):
20
22
  W0 PyYAML not installed — using the lenient fallback parser.
@@ -23,11 +25,13 @@ WARNINGS (consumers must tolerate; reported for quality):
23
25
  W3 Directory containing concepts but no index.md.
24
26
  W4 Concept body is empty.
25
27
  W5 Possible secret-looking string in a file.
26
- W6 A `source_files` / `ui_source_files` entry does not exist on disk (may be pending
27
- reconciliation by /speckit.okf.update).
28
+ W6 A `source_files` / `ui_source_files` entry does not exist on disk (the code
29
+ moved or was removed since the concept was written; analyze-slice reports it
30
+ as stale_source_files, and nothing removes it for you — edit the concept).
28
31
  W7 Possible duplicate concept: another file shares the same
29
32
  `type` + `title`.
30
- W8 Concept has unresolved `open_questions` (run /speckit.okf.clarify).
33
+ W8 Concept has unresolved `open_questions` (/appweaver adopt resolves the
34
+ ones its reverse answers; the rest are answered by hand).
31
35
 
32
36
  Usage: validate_okf.py <bundle_dir> [--config PATH] [--exclude GLOB]...
33
37
  [--repo-root PATH] [--json]
@@ -169,7 +173,8 @@ def check_open_questions(data: dict, rel: str):
169
173
  n = len(pending)
170
174
  warnings.append(
171
175
  f"W8 {rel}: {n} unresolved open question{'s' if n != 1 else ''} "
172
- f"— run /speckit.okf.clarify"
176
+ f"— /appweaver adopt of a feature acting on it resolves those its "
177
+ f"reverse answers; answer the rest by hand"
173
178
  )
174
179
 
175
180
 
@@ -5,14 +5,19 @@ description: [one line — what this aggregate is and the invariant/boundary it
5
5
  origin: greenfield
6
6
  bounded_context:
7
7
  features:
8
+ adopted_features:
8
9
  source_files:
9
10
  ui_source_files:
10
11
  ---
11
12
 
12
13
  <!--
13
14
  OKF CONCEPT — one aggregate's node in the shared knowledge bundle.
14
- Canonical home: knowledge/<slug>/<slug>.md (dir also holds an index.md; the bundle
15
- root holds index.md with okf_version + an optional log.md change journal).
15
+ Where it lives: implement and adopt create a new concept at knowledge/<slug>/<slug>.md
16
+ (the dir also holds an index.md); onboard writes a bounded context's concepts under
17
+ knowledge/aggregates/, beside the aggregates/index.md context map. Every reader finds a
18
+ concept by walking the bundle for its `type` + `title`, never by its folder, so a
19
+ concept is never moved to match either layout. The bundle root holds index.md with
20
+ okf_version + an optional log.md change journal.
16
21
 
17
22
  This single bundle is the OKF knowledge tier for BOTH pipelines:
18
23
  • greenfield — `implement` authors this concept (frontmatter + Overview + Depends-On)
@@ -23,6 +28,9 @@ ui_source_files:
23
28
  developer chose; it may act on several aggregates, and several features may act on
24
29
  one. `features:` lists every feature known to act on this aggregate — implement and
25
30
  adopt add their feature, and a feature is never removed from it by a later write.
31
+ `adopted_features:` lists the ones among them that `adopt` has deep-reversed from the
32
+ code; a feature in `features:` and not here is still only the discovery stub onboard
33
+ wrote. Empty on a greenfield concept, where nothing was reversed.
26
34
  `bounded_context:` names the context the aggregate belongs to.
27
35
 
28
36
  WHO WRITES WHAT (do not cross these lanes):