aw-spec-cli 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.appweaver/DESIGN-FORMAT.md +18 -18
- package/assets/.appweaver/ORCH-DIALECT.md +1 -3
- package/assets/.appweaver/RULE-PARTITION.md +29 -13
- package/assets/.appweaver/STACK-PROFILES.md +10 -9
- package/assets/.appweaver/orchestrator/.state/smoke.state.json +32 -0
- package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +4 -1
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +251 -0
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +104 -35
- package/assets/.appweaver/orchestrator/orchestrator.py +5 -4
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +94 -36
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/domain-model-template.skeleton.md +115 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +2 -9
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +16 -40
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +38 -17
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +8 -8
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +10 -11
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +673 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +535 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +643 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +84 -701
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +7 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +16 -14
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +243 -144
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +877 -578
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +32 -91
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +836 -514
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-bounded-context.orch.yaml +132 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +38 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +271 -979
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +1085 -3385
- package/assets/.appweaver/scripts/bash/aw-xref.sh +1 -2
- package/assets/.appweaver/scripts/bash/common.sh +47 -0
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +914 -3220
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +1 -2
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +19 -3
- package/assets/.appweaver/scripts/python/aw-stack.py +6 -2
- package/assets/.appweaver/scripts/python/aw-xref.py +86 -350
- package/assets/.appweaver/templates/constitution-ui-template.md +1 -1
- package/assets/.appweaver/templates/delta-design-template.md +120 -30
- package/assets/.appweaver/templates/delta-domain-model-template.md +81 -0
- package/assets/.appweaver/templates/delta-spec-template.md +43 -17
- package/assets/.appweaver/templates/design-template.md +68 -98
- package/assets/.appweaver/templates/domain-model-template.md +130 -0
- package/assets/.appweaver/templates/okf-concept-template.md +2 -2
- package/assets/.appweaver/templates/source-map-template.md +3 -3
- package/assets/.appweaver/templates/spec-template.md +100 -122
- package/assets/.appweaver/templates/ui-delta-design-template.md +159 -0
- package/assets/.appweaver/templates/ui-design-template.md +46 -75
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +7 -18
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +34 -39
- package/assets/guardrails/requirement-spec.txt +2 -2
- package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +0 -105
- package/assets/.appweaver/orchestrator/modules/spec-state-gate.orch.yaml +0 -87
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +0 -251
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +0 -813
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +0 -253
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +0 -663
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +0 -600
- package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +0 -1147
- package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +0 -1121
- package/assets/.appweaver/templates/story-template.md +0 -178
|
@@ -39,19 +39,14 @@ summary to STDERR so it never corrupts the JSON contract):
|
|
|
39
39
|
list --aggregate Y [--root DIR] [--bundle knowledge]
|
|
40
40
|
-> Y's blast radius (who uses Y). {aggregate, concept, users[], count, root}
|
|
41
41
|
|
|
42
|
-
concept-upsert --aggregate X [--source-files a,b,c] [--
|
|
43
|
-
[--title T] [--description D]
|
|
42
|
+
concept-upsert --aggregate X [--source-files a,b,c] [--merge] [--title T] [--description D]
|
|
44
43
|
[--origin greenfield|reverse-engineered] [--root DIR] [--bundle knowledge]
|
|
45
|
-
-> create/update X's OWN concept (frontmatter type/title/
|
|
46
|
-
its body — never clobbers the Cross-References other slices wrote. --
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
first's files. Each list is written FLAT, path-grouped by the code's OWN folders
|
|
52
|
-
via YAML comment headers (adaptive — see _group_source_files), so the listing
|
|
53
|
-
mirrors the real architecture.
|
|
54
|
-
{aggregate, concept, created, lane, key, source_file_count, root}
|
|
44
|
+
-> create/update X's OWN concept (frontmatter type/title/source_files), PRESERVING
|
|
45
|
+
its body — never clobbers the Cross-References other slices wrote. --merge UNIONs
|
|
46
|
+
source_files (CR deltas) instead of replacing. source_files is written as a FLAT
|
|
47
|
+
list, path-grouped by the code's OWN folders via YAML comment headers (adaptive —
|
|
48
|
+
see _group_source_files), so the listing mirrors the real architecture.
|
|
49
|
+
{aggregate, concept, created, source_file_count, root}
|
|
55
50
|
|
|
56
51
|
concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR] [--bundle knowledge]
|
|
57
52
|
-> splice model-authored prose into X's `## Overview` ONLY (the one section a
|
|
@@ -68,21 +63,6 @@ summary to STDERR so it never corrupts the JSON contract):
|
|
|
68
63
|
{aggregate, concept, found, paths[], count, csv, declared[], declared_count,
|
|
69
64
|
missing[], missing_count, root}
|
|
70
65
|
|
|
71
|
-
concept-coverage --aggregate X | --feature-dir <dir> [--ids CSV] [--max-list N] [--root DIR] [--bundle knowledge]
|
|
72
|
-
-> READ-ONLY: what CODE EXISTS for one feature, split by LANE — `source_files`
|
|
73
|
-
(service) and `ui_source_files` (UI) as separate lists, plus the concept's
|
|
74
|
-
forward edges (Depends-On) and reverse edges (Cross-References, the blast
|
|
75
|
-
radius). The answer a STORY DESIGN needs, where concept-paths' one flat
|
|
76
|
-
backend list would hand a UI pass the service tree. `--ids` splits the
|
|
77
|
-
story's requirements into anchored/unanchored against the feature's
|
|
78
|
-
source-map.md where one exists; with no map every id is unanchored, which
|
|
79
|
-
is reported as "no index", never as "no code". `digest` is the rendered
|
|
80
|
-
block a design pass reads. Never stubs a concept.
|
|
81
|
-
{aggregate, concept, found, origin, backend_paths[]/_csv/_count,
|
|
82
|
-
frontend_paths[]/_csv/_count, has_backend_code, has_frontend_code,
|
|
83
|
-
backend_missing[], frontend_missing[], missing_count, depends_on[]/_csv,
|
|
84
|
-
used_by[]/_csv, source_map, anchored_csv, unanchored_csv, digest, root}
|
|
85
|
-
|
|
86
66
|
validate [--prune] [--root DIR] [--bundle knowledge]
|
|
87
67
|
-> drift check beyond reconcile-on-write. reverse-orphan = a Cross-References row with
|
|
88
68
|
no backing Depends-On (stale; --prune removes it); forward-orphan = a Depends-On
|
|
@@ -123,17 +103,6 @@ XREF_COMMENT = (
|
|
|
123
103
|
"Each row written by the using slice's implement/adopt (reconcile-on-write). "
|
|
124
104
|
"Reviewed at CR time. Do not hand-edit. -->"
|
|
125
105
|
)
|
|
126
|
-
ANCHOR_HEADING = "## Requirement Anchors"
|
|
127
|
-
ANCHOR_HEADER = ["Requirement", "Slice", "Lane", "Artefacts", "Recorded"]
|
|
128
|
-
ANCHOR_COMMENT = (
|
|
129
|
-
"<!-- WHICH CODE REALISES WHICH REQUIREMENT — written by implement / implement-ui as "
|
|
130
|
-
"each story generates, from the ids that story exercises and the files that run wrote. "
|
|
131
|
-
"The greenfield counterpart of a brownfield source-map.md: without it every id on a "
|
|
132
|
-
"greenfield feature comes back unanchored, and the trace pass has to read every file to "
|
|
133
|
-
"find out what implements what. Reconcile-on-write, keyed by slice + lane, so "
|
|
134
|
-
"re-implementing a story replaces its rows rather than doubling them. Do not "
|
|
135
|
-
"hand-edit. -->"
|
|
136
|
-
)
|
|
137
106
|
RESERVED = {"index.md", "log.md"}
|
|
138
107
|
|
|
139
108
|
# A Cross-References "Using slice" cell that is safe to hand to a VSTest --filter (and a
|
|
@@ -265,15 +234,13 @@ def _read(path):
|
|
|
265
234
|
|
|
266
235
|
# ──────────────────────────── write serialisation ──────────────────────────
|
|
267
236
|
# EVERY CONCEPT WRITE IS A READ-MODIFY-WRITE: read the whole file, splice one section,
|
|
268
|
-
# write it back truncating. None of them was serialised, and
|
|
269
|
-
#
|
|
270
|
-
# implement-ui (which share a feature's ONE concept), reading before either writes is the
|
|
237
|
+
# write it back truncating. None of them was serialised, and implement / implement-ui
|
|
238
|
+
# share a feature's ONE concept — so two runs reading before either writes is the
|
|
271
239
|
# ordinary case, and the second write drops the first's rows with nothing to say so. Git
|
|
272
240
|
# catches that across branches and never inside a single working tree.
|
|
273
241
|
#
|
|
274
|
-
# The lock is named for the FILE — <path>.aw-lock
|
|
275
|
-
#
|
|
276
|
-
# lock whichever process, op or shell it came from. Re-entrant within a process, so an
|
|
242
|
+
# The lock is named for the FILE — <path>.aw-lock — so every writer of a file contends on
|
|
243
|
+
# one lock whichever process, op or shell it came from. Re-entrant within a process, so an
|
|
277
244
|
# outer read-modify-write can hold it while the _write inside re-acquires.
|
|
278
245
|
_LOCK_TIMEOUT = 30.0
|
|
279
246
|
_HELD = {}
|
|
@@ -293,11 +260,14 @@ def _lock(path):
|
|
|
293
260
|
if parent:
|
|
294
261
|
os.makedirs(parent, exist_ok=True)
|
|
295
262
|
lp = key + ".aw-lock"
|
|
263
|
+
# The token identifies THIS acquisition, not just this process: a pid alone can be
|
|
264
|
+
# reused, and the release below refuses to remove a lock it does not own.
|
|
265
|
+
token = "%d %s" % (os.getpid(), os.urandom(8).hex())
|
|
296
266
|
start = time.time()
|
|
297
267
|
while True:
|
|
298
268
|
try:
|
|
299
269
|
h = os.open(lp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
300
|
-
os.write(h, ("
|
|
270
|
+
os.write(h, (token + "\n").encode("utf-8"))
|
|
301
271
|
os.close(h)
|
|
302
272
|
break
|
|
303
273
|
except OSError as exc:
|
|
@@ -309,27 +279,42 @@ def _lock(path):
|
|
|
309
279
|
age = 0.0
|
|
310
280
|
# A lock older than the timeout belonged to a run that died holding it.
|
|
311
281
|
# Breaking it is right: the alternative is a concept nobody can write again.
|
|
282
|
+
# Two waiters may break the same lock at once; only one of them then wins the
|
|
283
|
+
# O_EXCL create, so a break never yields two holders.
|
|
312
284
|
if age > _LOCK_TIMEOUT:
|
|
313
285
|
try:
|
|
314
286
|
os.unlink(lp)
|
|
315
287
|
except OSError:
|
|
316
288
|
pass
|
|
317
289
|
continue
|
|
290
|
+
# Reached only when the lock keeps being RE-taken by other runs — a lock held
|
|
291
|
+
# continuously past the timeout is broken above instead. Nothing to clean up
|
|
292
|
+
# by hand, so this says what it is: too much contention to make progress.
|
|
318
293
|
if time.time() - start > _LOCK_TIMEOUT:
|
|
319
294
|
raise RuntimeError(
|
|
320
|
-
"aw-xref:
|
|
321
|
-
"/appweaver
|
|
322
|
-
% (os.path.basename(key), int(_LOCK_TIMEOUT)
|
|
295
|
+
"aw-xref: could not get a write lock on %s within %ds — other "
|
|
296
|
+
"/appweaver runs are writing it continuously. Re-run when they finish."
|
|
297
|
+
% (os.path.basename(key), int(_LOCK_TIMEOUT)))
|
|
323
298
|
time.sleep(0.05)
|
|
324
299
|
_HELD[key] = 1
|
|
325
300
|
try:
|
|
326
301
|
yield
|
|
327
302
|
finally:
|
|
328
303
|
_HELD.pop(key, None)
|
|
304
|
+
# ONLY remove a lock this acquisition still owns. A run slow enough to have its
|
|
305
|
+
# lock broken above would otherwise delete the lock of whoever took it next, and
|
|
306
|
+
# a third writer would walk straight in while that one was mid-write — the very
|
|
307
|
+
# thing the lock is here to stop.
|
|
329
308
|
try:
|
|
330
|
-
|
|
309
|
+
with open(lp, encoding="utf-8") as fh:
|
|
310
|
+
mine = fh.read().strip() == token
|
|
331
311
|
except OSError:
|
|
332
|
-
|
|
312
|
+
mine = False
|
|
313
|
+
if mine:
|
|
314
|
+
try:
|
|
315
|
+
os.unlink(lp)
|
|
316
|
+
except OSError:
|
|
317
|
+
pass
|
|
333
318
|
|
|
334
319
|
|
|
335
320
|
def _write(path, text):
|
|
@@ -584,19 +569,13 @@ def _resolve_concept(name, bundle_abs):
|
|
|
584
569
|
return best
|
|
585
570
|
|
|
586
571
|
|
|
587
|
-
def _skeleton_concept(name, origin, source_files, description
|
|
588
|
-
"""A new concept always carries BOTH lane keys — `source_files` for the service
|
|
589
|
-
tree and `ui_source_files` for the UI one — with the run's files in the lane it
|
|
590
|
-
generated and an empty list in the other. Whichever lane implements first, the
|
|
591
|
-
other's key is there for it to fill; the template's rule is to leave it empty on a
|
|
592
|
-
single-lane slice, never to delete it."""
|
|
572
|
+
def _skeleton_concept(name, origin, source_files, description):
|
|
593
573
|
fm = {
|
|
594
574
|
"type": "aggregate",
|
|
595
575
|
"title": name,
|
|
596
576
|
"description": description or f"{name} aggregate.",
|
|
597
577
|
"origin": origin or "greenfield",
|
|
598
|
-
"source_files": list(source_files or [])
|
|
599
|
-
"ui_source_files": list(source_files or []) if lane == "frontend" else [],
|
|
578
|
+
"source_files": list(source_files or []),
|
|
600
579
|
}
|
|
601
580
|
overview = f"[overview of the {name} aggregate]"
|
|
602
581
|
body = (
|
|
@@ -781,27 +760,6 @@ def _write_deps_rows(path, rows):
|
|
|
781
760
|
_write(path, _replace_section(text, DEPS_HEADING, body))
|
|
782
761
|
|
|
783
762
|
|
|
784
|
-
def _read_anchor_rows(path):
|
|
785
|
-
return _table_data_rows(_section_text(_read(path), ANCHOR_HEADING))
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
def _write_anchor_rows(path, slice_id, lane, ids, artefacts, recorded):
|
|
789
|
-
"""Requirement → artefact rows for ONE slice, reconciled on write. Every row this
|
|
790
|
-
slice+lane wrote before is dropped and its current ones re-added, so a story that
|
|
791
|
-
re-implements against a changed design replaces its claims instead of accumulating
|
|
792
|
-
them — the same rule the Cross-References rows follow, for the same reason."""
|
|
793
|
-
with _lock(path):
|
|
794
|
-
keep = [r for r in _read_anchor_rows(path)
|
|
795
|
-
if not (len(r) >= 3 and r[1].strip() == slice_id and r[2].strip() == lane)]
|
|
796
|
-
# Ordered by requirement so a person reading the section can find an id, and two
|
|
797
|
-
# runs writing the same rows produce the same file.
|
|
798
|
-
fresh = [[rid, slice_id, lane, artefacts, recorded] for rid in ids]
|
|
799
|
-
rows = sorted(keep + fresh, key=lambda r: (r[0], r[1] if len(r) > 1 else ""))
|
|
800
|
-
body = _render_section(ANCHOR_HEADING, ANCHOR_COMMENT, ANCHOR_HEADER, rows)
|
|
801
|
-
_write(path, _replace_section(_read(path), ANCHOR_HEADING, body))
|
|
802
|
-
return len(fresh)
|
|
803
|
-
|
|
804
|
-
|
|
805
763
|
def _all_concepts(bundle_abs):
|
|
806
764
|
out = []
|
|
807
765
|
if not os.path.isdir(bundle_abs):
|
|
@@ -842,22 +800,30 @@ def cmd_record(args):
|
|
|
842
800
|
edges = _design_edges(_read(design_path), using, known)
|
|
843
801
|
|
|
844
802
|
# RECONCILE-ON-WRITE, delete phase: strip every row this slice authored, anywhere.
|
|
803
|
+
# The ROWS are what this rewrites, so the lock has to span reading them and writing
|
|
804
|
+
# them back. _write_xref_rows locks its own splice, but the row list it is handed was
|
|
805
|
+
# computed from a read that happened before — two slices recording at once each
|
|
806
|
+
# filtered the same starting list and the second write dropped the first's row.
|
|
845
807
|
pruned = 0
|
|
846
808
|
for cpath in _all_concepts(bundle_abs):
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
809
|
+
with _lock(cpath):
|
|
810
|
+
rows = _read_xref_rows(cpath)
|
|
811
|
+
kept = [r for r in rows if _slug(r[0]) != using_slug]
|
|
812
|
+
if len(kept) != len(rows):
|
|
813
|
+
pruned += len(rows) - len(kept)
|
|
814
|
+
_write_xref_rows(cpath, kept)
|
|
852
815
|
|
|
853
816
|
# Add phase: upsert this slice's current rows into each used concept (stub if missing).
|
|
817
|
+
# A concept is written by EVERY slice that references it, so this is the contended one:
|
|
818
|
+
# same lock, spanning the read and the write.
|
|
854
819
|
wrote, stubbed = [], []
|
|
855
820
|
for e in edges:
|
|
856
821
|
ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed)
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
822
|
+
with _lock(ypath):
|
|
823
|
+
rows = _read_xref_rows(ypath)
|
|
824
|
+
rows = [r for r in rows if not (_slug(r[0]) == using_slug and (len(r) > 1 and r[1] == e["via"]))]
|
|
825
|
+
rows.append([using, e["via"], e["kind"], origin, date])
|
|
826
|
+
_write_xref_rows(ypath, rows)
|
|
861
827
|
wrote.append({"target": e["target"], "concept": _rel(ypath, root), "via": e["via"], "kind": e["kind"]})
|
|
862
828
|
|
|
863
829
|
# Rewrite this slice's own Depends-On (forward prune index) — stub self if needed.
|
|
@@ -926,31 +892,19 @@ def cmd_concept_upsert(args):
|
|
|
926
892
|
_emit({"error": "aggregate-required", "root": root})
|
|
927
893
|
return 2
|
|
928
894
|
source_files = _parse_source_files(args.source_files, root)
|
|
929
|
-
# WHICH LANE'S FILE LIST THIS RUN OWNS. A feature has ONE concept across both lanes,
|
|
930
|
-
# and the two trees live under different keys: `source_files` is the service lane's,
|
|
931
|
-
# `ui_source_files` is the UI lane's. Writing UI files into the service key would
|
|
932
|
-
# collapse the concept resolver's module_dir to the repo root and hand every later
|
|
933
|
-
# service reader a tree of components — which is exactly what concept-coverage's
|
|
934
|
-
# lane split exists to prevent.
|
|
935
|
-
lane = (args.lane or "backend").strip().lower()
|
|
936
|
-
if lane not in ("backend", "frontend"):
|
|
937
|
-
print("[xref] concept-upsert: --lane must be backend or frontend, not %r" % lane, file=sys.stderr)
|
|
938
|
-
_emit({"error": "bad-lane", "lane": lane, "root": root})
|
|
939
|
-
return 2
|
|
940
|
-
key = "source_files" if lane == "backend" else "ui_source_files"
|
|
941
895
|
|
|
942
896
|
path = _resolve_concept(aggregate, bundle_abs)
|
|
943
897
|
created = path is None
|
|
944
898
|
if created:
|
|
945
899
|
_ensure_bundle(bundle_abs)
|
|
946
900
|
path = _canonical_concept_path(bundle_abs, aggregate)
|
|
947
|
-
_write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""
|
|
901
|
+
_write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""))
|
|
948
902
|
_ensure_dir_index(path, aggregate)
|
|
949
903
|
else:
|
|
950
904
|
# Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
|
|
951
905
|
# which holds the Cross-References other slices wrote. Read and write under one
|
|
952
906
|
# lock: --merge unions this run's files with what is already recorded, so two
|
|
953
|
-
# parallel
|
|
907
|
+
# parallel runs reading before either writes both union against the same
|
|
954
908
|
# starting list and the second write loses the first's files.
|
|
955
909
|
with _lock(path):
|
|
956
910
|
fm, body = _read_fm(_read(path))
|
|
@@ -958,23 +912,18 @@ def cmd_concept_upsert(args):
|
|
|
958
912
|
fm["title"] = fm.get("title") or aggregate
|
|
959
913
|
if source_files:
|
|
960
914
|
if args.merge:
|
|
961
|
-
#
|
|
962
|
-
# slice
|
|
963
|
-
|
|
964
|
-
# earlier stories generated the moment a second story implements.
|
|
965
|
-
existing = fm.get(key) if isinstance(fm.get(key), list) else []
|
|
915
|
+
# CR mode: {{files}} is only the delta's changed files — UNION with the
|
|
916
|
+
# slice's existing source_files, never replace (that would drop the rest).
|
|
917
|
+
existing = fm.get("source_files") if isinstance(fm.get("source_files"), list) else []
|
|
966
918
|
merged = list(existing)
|
|
967
919
|
for s in source_files:
|
|
968
920
|
if s not in merged:
|
|
969
921
|
merged.append(s)
|
|
970
|
-
fm[
|
|
922
|
+
fm["source_files"] = merged
|
|
971
923
|
else:
|
|
972
|
-
fm[
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
for k in ("source_files", "ui_source_files"):
|
|
976
|
-
if k not in fm:
|
|
977
|
-
fm[k] = []
|
|
924
|
+
fm["source_files"] = source_files
|
|
925
|
+
elif "source_files" not in fm:
|
|
926
|
+
fm["source_files"] = []
|
|
978
927
|
if args.description:
|
|
979
928
|
fm["description"] = args.description
|
|
980
929
|
elif "description" not in fm:
|
|
@@ -986,29 +935,10 @@ def cmd_concept_upsert(args):
|
|
|
986
935
|
_ensure_bundle(bundle_abs)
|
|
987
936
|
_ensure_dir_index(path, aggregate)
|
|
988
937
|
|
|
989
|
-
# WHICH REQUIREMENT THIS SLICE'S FILES REALISE. The two halves have always been in this
|
|
990
|
-
# call and were thrown away: the ids the story exercises, and the files the run just
|
|
991
|
-
# generated. Persisted here so a greenfield feature has the anchor index only a
|
|
992
|
-
# brownfield source-map.md carried — without it every id comes back unanchored and the
|
|
993
|
-
# trace pass has to read the whole tree to find out what implements what.
|
|
994
|
-
anchor_ids = sorted({i for i in re.findall(r'\b[A-Z]{2}-\d+', args.ids or "")})
|
|
995
|
-
anchors_written = 0
|
|
996
|
-
if anchor_ids:
|
|
997
|
-
slice_id = (args.slice_id or "").strip() or "(unnamed slice)"
|
|
998
|
-
# The cell is table content: a path holding a pipe would silently split it.
|
|
999
|
-
cell = ", ".join(s.replace("|", r"\|") for s in source_files) or "(no files recorded)"
|
|
1000
|
-
anchors_written = _write_anchor_rows(path, slice_id, lane, anchor_ids, cell,
|
|
1001
|
-
_today(args.date))
|
|
1002
|
-
|
|
1003
938
|
print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
|
|
1004
|
-
f"({len(source_files)}
|
|
1005
|
-
+ (f", {anchors_written} requirement anchor(s)" if anchors_written else "")
|
|
1006
|
-
+ f") — {_rel(path, root)}",
|
|
1007
|
-
file=sys.stderr)
|
|
939
|
+
f"({len(source_files)} source file(s)) — {_rel(path, root)}", file=sys.stderr)
|
|
1008
940
|
_emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
|
|
1009
|
-
"
|
|
1010
|
-
"anchor_ids": anchor_ids, "anchor_ids_csv": ", ".join(anchor_ids),
|
|
1011
|
-
"anchors_written": anchors_written, "root": root})
|
|
941
|
+
"source_file_count": len(source_files), "root": root})
|
|
1012
942
|
return 0
|
|
1013
943
|
|
|
1014
944
|
|
|
@@ -1045,7 +975,11 @@ def cmd_concept_overview(args):
|
|
|
1045
975
|
return 2
|
|
1046
976
|
|
|
1047
977
|
body = f"{OVERVIEW_HEADING}\n\n{text}\n"
|
|
1048
|
-
|
|
978
|
+
# Splicing one section into the concept is a read-modify-write like the two table
|
|
979
|
+
# writers': the read has to happen under the same lock as the write, or the Overview
|
|
980
|
+
# goes back onto a copy that another slice has already added its rows to.
|
|
981
|
+
with _lock(path):
|
|
982
|
+
_write(path, _replace_section(_read(path), OVERVIEW_HEADING, body, before=DEPS_HEADING))
|
|
1049
983
|
print(f"[xref] concept-overview {aggregate}: written ({len(text)} chars) — {_rel(path, root)}",
|
|
1050
984
|
file=sys.stderr)
|
|
1051
985
|
_emit({"aggregate": aggregate, "concept": _rel(path, root), "written": "true", "root": root})
|
|
@@ -1113,197 +1047,6 @@ def cmd_concept_paths(args):
|
|
|
1113
1047
|
return 0
|
|
1114
1048
|
|
|
1115
1049
|
|
|
1116
|
-
def cmd_concept_coverage(args):
|
|
1117
|
-
"""CODE COVERAGE for one feature, read from its OKF concept — what code EXISTS
|
|
1118
|
-
today for the aggregate a story is about to design against, SPLIT BY LANE.
|
|
1119
|
-
|
|
1120
|
-
concept-paths answers "which files is this slice made of" for the executable
|
|
1121
|
-
phases, as one flat backend list. A story design needs the same facts shaped
|
|
1122
|
-
differently: the service design reads `source_files`, the frontend design reads
|
|
1123
|
-
`ui_source_files`, and neither should be handed the other's tree — a UI pass
|
|
1124
|
-
told to read twelve C# files designs around them. It also needs the two things
|
|
1125
|
-
a path list drops: the aggregate's forward edges (Depends-On), and its REVERSE
|
|
1126
|
-
edges (Cross-References) — the blast radius a slice inherits the moment it
|
|
1127
|
-
changes a published surface.
|
|
1128
|
-
|
|
1129
|
-
COVERAGE, HONESTLY. A greenfield concept holds no requirement→file index: code
|
|
1130
|
-
carries no FR ids. A feature that was ADOPTED does — the source map's `Spec ids`
|
|
1131
|
-
column — so the ids given in --ids come back split into ANCHORED (some artefact
|
|
1132
|
-
claims the id) and UNANCHORED. With no source map every id is unanchored, which
|
|
1133
|
-
is the true answer, and it is NEVER reported as uncovered: absent evidence is not
|
|
1134
|
-
evidence of absence, and a design pass told "FR-003 has no code" when the index
|
|
1135
|
-
simply does not exist would re-plan work that already ships.
|
|
1136
|
-
|
|
1137
|
-
STRICTLY READ-ONLY, the same contract as concept-paths: a query never stubs a
|
|
1138
|
-
concept, and a feature with no concept at all — greenfield, before the first
|
|
1139
|
-
implement — is a legitimate EMPTY answer with `found: false`, not an error."""
|
|
1140
|
-
root = _root(args.root)
|
|
1141
|
-
bundle_abs = os.path.join(root, args.bundle)
|
|
1142
|
-
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
1143
|
-
if not aggregate:
|
|
1144
|
-
print("[xref] concept-coverage: --aggregate or --feature-dir required", file=sys.stderr)
|
|
1145
|
-
_emit({"error": "aggregate-required", "root": root})
|
|
1146
|
-
return 2
|
|
1147
|
-
|
|
1148
|
-
path = _resolve_concept(aggregate, bundle_abs)
|
|
1149
|
-
fm = {}
|
|
1150
|
-
if path:
|
|
1151
|
-
fm, _ = _read_fm(_read(path))
|
|
1152
|
-
|
|
1153
|
-
def lane_files(key):
|
|
1154
|
-
"""One frontmatter key's paths, split into on-disk and declared-but-missing.
|
|
1155
|
-
_read_fm gives a list for a block list and "" for an empty scalar; the
|
|
1156
|
-
`# Layer` group headers are comments and are already skipped by it."""
|
|
1157
|
-
raw = fm.get(key)
|
|
1158
|
-
items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, str) and raw else [])
|
|
1159
|
-
seen, existing, missing = set(), [], []
|
|
1160
|
-
for s in items:
|
|
1161
|
-
s = (s or "").strip().strip('"').strip("'").replace(os.sep, "/")
|
|
1162
|
-
if not s or s in seen:
|
|
1163
|
-
continue
|
|
1164
|
-
seen.add(s)
|
|
1165
|
-
target = s if os.path.isabs(s) else os.path.join(root, s)
|
|
1166
|
-
(existing if os.path.isfile(target) else missing).append(s)
|
|
1167
|
-
return existing, missing
|
|
1168
|
-
|
|
1169
|
-
be, be_missing = lane_files("source_files")
|
|
1170
|
-
fe, fe_missing = lane_files("ui_source_files")
|
|
1171
|
-
|
|
1172
|
-
# ---- requirement anchors, where an index exists ---------------------------
|
|
1173
|
-
ids = [i for i in re.split(r'[,\s]+', (args.ids or "").strip()) if i]
|
|
1174
|
-
smap = ""
|
|
1175
|
-
anchors = {}
|
|
1176
|
-
# TWO SOURCES, ONE INDEX. A brownfield feature's anchors come from adopt's
|
|
1177
|
-
# source-map.md; a greenfield feature's come from the concept's own Requirement Anchors
|
|
1178
|
-
# rows, which implement / implement-ui write as each story generates. Reading only the
|
|
1179
|
-
# first meant every id on a greenfield feature came back "not anchored to any artefact"
|
|
1180
|
-
# — advice to design it as new work, on a requirement that already had code.
|
|
1181
|
-
if path:
|
|
1182
|
-
for r in _read_anchor_rows(path):
|
|
1183
|
-
if len(r) < 4:
|
|
1184
|
-
continue
|
|
1185
|
-
rid = r[0].strip()
|
|
1186
|
-
if not re.match(r'^[A-Z]{2}-\d+$', rid):
|
|
1187
|
-
continue
|
|
1188
|
-
for art in (a.strip().replace(r"\|", "|") for a in r[3].split(",")):
|
|
1189
|
-
if art and art != "(no files recorded)":
|
|
1190
|
-
anchors.setdefault(rid, [])
|
|
1191
|
-
if art not in anchors[rid]:
|
|
1192
|
-
anchors[rid].append(art)
|
|
1193
|
-
concept_anchored = bool(anchors)
|
|
1194
|
-
if args.feature_dir:
|
|
1195
|
-
cand = os.path.join(args.feature_dir, "source-map.md")
|
|
1196
|
-
if os.path.isfile(cand):
|
|
1197
|
-
smap = _rel(cand, root)
|
|
1198
|
-
text = _read(cand)
|
|
1199
|
-
lines = text.splitlines()
|
|
1200
|
-
start = next((i for i, ln in enumerate(lines) if ln.startswith("## Artefact")), None)
|
|
1201
|
-
if start is not None:
|
|
1202
|
-
end = next((j for j in range(start + 1, len(lines))
|
|
1203
|
-
if lines[j].startswith("## ")), len(lines))
|
|
1204
|
-
for r in _table_data_rows("\n".join(lines[start:end])):
|
|
1205
|
-
# | # | Legacy artefact | Kind | Spec ids | Design element | ... |
|
|
1206
|
-
if len(r) < 4:
|
|
1207
|
-
continue
|
|
1208
|
-
artefact = r[1].strip().strip("`")
|
|
1209
|
-
for rid in re.findall(r'\b[A-Z]{2}-\d+', r[3]):
|
|
1210
|
-
anchors.setdefault(rid, [])
|
|
1211
|
-
if artefact and artefact not in anchors[rid]:
|
|
1212
|
-
anchors[rid].append(artefact)
|
|
1213
|
-
anchored = [i for i in ids if anchors.get(i)]
|
|
1214
|
-
unanchored = [i for i in ids if not anchors.get(i)]
|
|
1215
|
-
|
|
1216
|
-
deps = [r[0].strip() for r in (_read_deps_rows(path) if path else [])
|
|
1217
|
-
if r and _IDENT_RE.match((r[0] or "").strip())]
|
|
1218
|
-
users = sorted(set(_xref_users(aggregate, bundle_abs))) if path else []
|
|
1219
|
-
|
|
1220
|
-
# ---- the digest — the block a design pass reads --------------------------
|
|
1221
|
-
cap = args.max_list if args.max_list and args.max_list > 0 else 60
|
|
1222
|
-
|
|
1223
|
-
def block(label, files, missing, empty_note):
|
|
1224
|
-
if not files:
|
|
1225
|
-
return ["%s: %s" % (label, empty_note)]
|
|
1226
|
-
out = ["%s: %d file(s) on disk" % (label, len(files))]
|
|
1227
|
-
shown = 0
|
|
1228
|
-
for group, items in _group_source_files(files):
|
|
1229
|
-
if shown >= cap:
|
|
1230
|
-
break
|
|
1231
|
-
out.append(" %s" % group)
|
|
1232
|
-
for f in items:
|
|
1233
|
-
if shown >= cap:
|
|
1234
|
-
break
|
|
1235
|
-
out.append(" %s" % f)
|
|
1236
|
-
shown += 1
|
|
1237
|
-
if len(files) > shown:
|
|
1238
|
-
out.append(" … and %d more (the full list is in the lane's csv)" % (len(files) - shown))
|
|
1239
|
-
if missing:
|
|
1240
|
-
out.append(" DRIFT — declared in the concept, not on disk (%d): %s"
|
|
1241
|
-
% (len(missing), ", ".join(missing[:12])
|
|
1242
|
-
+ (" …" if len(missing) > 12 else "")))
|
|
1243
|
-
return out
|
|
1244
|
-
|
|
1245
|
-
lines = []
|
|
1246
|
-
if not path:
|
|
1247
|
-
lines.append("No OKF concept for %s — nothing has been implemented for this feature yet. "
|
|
1248
|
-
"Every lane below is GREENFIELD: design from a clean slate." % aggregate)
|
|
1249
|
-
lines.append("")
|
|
1250
|
-
else:
|
|
1251
|
-
lines.append("%s — code coverage from the OKF concept (%s, origin: %s)"
|
|
1252
|
-
% (aggregate, _rel(path, root), (fm.get("origin") or "unknown")))
|
|
1253
|
-
lines.append("")
|
|
1254
|
-
lines += block("BACKEND", be, be_missing,
|
|
1255
|
-
"no service code recorded for this aggregate — greenfield on this lane")
|
|
1256
|
-
lines.append("")
|
|
1257
|
-
lines += block("FRONTEND", fe, fe_missing,
|
|
1258
|
-
"no UI code recorded for this aggregate — greenfield on this lane")
|
|
1259
|
-
lines.append("")
|
|
1260
|
-
lines.append("DEPENDS ON (this aggregate reaches into): %s" % (", ".join(deps) or "nothing"))
|
|
1261
|
-
lines.append("USED BY (change this aggregate's surface and these break): %s"
|
|
1262
|
-
% (", ".join(users) or "nothing recorded"))
|
|
1263
|
-
if ids:
|
|
1264
|
-
lines.append("")
|
|
1265
|
-
if not smap and not concept_anchored:
|
|
1266
|
-
lines.append("REQUIREMENT ANCHORS: none available — this feature has no source-map.md "
|
|
1267
|
-
"and no slice has recorded one in the concept, so no id can be traced to "
|
|
1268
|
-
"a file. Treat the files above as the whole of what exists and read them; "
|
|
1269
|
-
"do NOT read an id's absence here as proof that no code implements it.")
|
|
1270
|
-
else:
|
|
1271
|
-
src = " + ".join(filter(None, [smap, "the concept's Requirement Anchors"
|
|
1272
|
-
if concept_anchored else ""]))
|
|
1273
|
-
lines.append("REQUIREMENT ANCHORS (%s):" % src)
|
|
1274
|
-
for i in anchored:
|
|
1275
|
-
lines.append(" %-10s %s" % (i, ", ".join(anchors[i][:6])
|
|
1276
|
-
+ (" …" if len(anchors[i]) > 6 else "")))
|
|
1277
|
-
if unanchored:
|
|
1278
|
-
lines.append(" not anchored to any artefact: %s — the map claims no code for "
|
|
1279
|
-
"them, so design them as new work unless the files above say otherwise."
|
|
1280
|
-
% ", ".join(unanchored))
|
|
1281
|
-
digest = "\n".join(lines)
|
|
1282
|
-
|
|
1283
|
-
print("[xref] concept-coverage %s: backend %d, frontend %d, drift %d, used-by %d"
|
|
1284
|
-
% (aggregate, len(be), len(fe), len(be_missing) + len(fe_missing), len(users)),
|
|
1285
|
-
file=sys.stderr)
|
|
1286
|
-
_emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
|
|
1287
|
-
"found": bool(path), "origin": (fm.get("origin") or ""),
|
|
1288
|
-
"backend_paths": be, "backend_csv": ",".join(be), "backend_count": len(be),
|
|
1289
|
-
"frontend_paths": fe, "frontend_csv": ",".join(fe), "frontend_count": len(fe),
|
|
1290
|
-
"has_backend_code": bool(be), "has_frontend_code": bool(fe),
|
|
1291
|
-
"backend_missing": be_missing, "frontend_missing": fe_missing,
|
|
1292
|
-
"missing_count": len(be_missing) + len(fe_missing),
|
|
1293
|
-
# THE UNION, for a reader whose question spans both lanes. A gate that traces a
|
|
1294
|
-
# requirement to code has to see the UI half of it: an FR- with a user-facing
|
|
1295
|
-
# surface is realised in two trees, and `source_files` alone is the service one.
|
|
1296
|
-
"all_paths": be + fe, "all_csv": ",".join(be + fe), "all_count": len(be) + len(fe),
|
|
1297
|
-
"all_missing": be_missing + fe_missing,
|
|
1298
|
-
"all_missing_csv": ", ".join(be_missing + fe_missing),
|
|
1299
|
-
"depends_on": deps, "depends_on_csv": ", ".join(deps),
|
|
1300
|
-
"used_by": users, "used_by_csv": ", ".join(users),
|
|
1301
|
-
"source_map": smap, "anchored_csv": ", ".join(anchored),
|
|
1302
|
-
"unanchored_csv": ", ".join(unanchored),
|
|
1303
|
-
"digest": digest, "root": root})
|
|
1304
|
-
return 0
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
1050
|
# ─────────────────────────────── validate ──────────────────────────────────
|
|
1308
1051
|
def cmd_validate(args):
|
|
1309
1052
|
"""Cross-reference drift check (the safety net BEYOND reconcile-on-write). Pairs the
|
|
@@ -1364,10 +1107,11 @@ def cmd_validate(args):
|
|
|
1364
1107
|
rev.setdefault(o["concept"], set()).add((o["using"], o["via"]))
|
|
1365
1108
|
for crel, keys in rev.items():
|
|
1366
1109
|
cpath = os.path.join(root, crel)
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1110
|
+
with _lock(cpath):
|
|
1111
|
+
rows = _read_xref_rows(cpath)
|
|
1112
|
+
keep = [r for r in rows if (r[0], r[1] if len(r) > 1 else "") not in keys]
|
|
1113
|
+
pruned += len(rows) - len(keep)
|
|
1114
|
+
_write_xref_rows(cpath, keep)
|
|
1371
1115
|
|
|
1372
1116
|
rev_n = sum(1 for o in orphans if o["direction"] == "reverse")
|
|
1373
1117
|
fwd_n = len(orphans) - rev_n
|
|
@@ -1399,10 +1143,16 @@ def cmd_log(args):
|
|
|
1399
1143
|
note = args.note or "OKF cross-references updated."
|
|
1400
1144
|
logp = os.path.join(bundle_abs, "log.md")
|
|
1401
1145
|
entry = f"## {date}\n\nCommit: `{sha}`\n\n- {note}\n"
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1146
|
+
# log.md is the bundle's ONE journal, appended by every slice that touches the
|
|
1147
|
+
# knowledge layer, which makes it the most contended file here — and an append is a
|
|
1148
|
+
# read-modify-write like any other. Unserialised, ten concurrent appends landed two.
|
|
1149
|
+
# The existence test belongs inside the lock too: two runs that both saw "no file"
|
|
1150
|
+
# would otherwise each write a fresh header and the first entry would be gone.
|
|
1151
|
+
with _lock(logp):
|
|
1152
|
+
if os.path.isfile(logp):
|
|
1153
|
+
_write(logp, _read(logp).rstrip("\n") + "\n\n" + entry)
|
|
1154
|
+
else:
|
|
1155
|
+
_write(logp, "# Change Log\n\nOKF bundle change journal — commit-anchored (OKF §7).\n\n" + entry)
|
|
1406
1156
|
print(f"[xref] log: {date} @ {sha} — {note}", file=sys.stderr)
|
|
1407
1157
|
_emit({"logged": "true", "commit": sha, "date": date, "note": note,
|
|
1408
1158
|
"log": _rel(logp, root), "root": root})
|
|
@@ -1642,11 +1392,7 @@ def main(argv):
|
|
|
1642
1392
|
c.add_argument("--aggregate", default="")
|
|
1643
1393
|
c.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1644
1394
|
c.add_argument("--source-files", default="")
|
|
1645
|
-
c.add_argument("--merge", action="store_true", help="union
|
|
1646
|
-
c.add_argument("--lane", default="backend", help="backend -> source_files, frontend -> ui_source_files")
|
|
1647
|
-
c.add_argument("--ids", default="", help="requirement ids this slice realises; records a Requirement Anchors row per id against the files just written")
|
|
1648
|
-
c.add_argument("--slice", dest="slice_id", default="", help="the story id the anchor rows are keyed by (with --ids)")
|
|
1649
|
-
c.add_argument("--date", default="")
|
|
1395
|
+
c.add_argument("--merge", action="store_true", help="union source_files with existing (CR delta) instead of replacing")
|
|
1650
1396
|
c.add_argument("--title", default="")
|
|
1651
1397
|
c.add_argument("--description", default="")
|
|
1652
1398
|
c.add_argument("--origin", default="greenfield")
|
|
@@ -1670,16 +1416,6 @@ def main(argv):
|
|
|
1670
1416
|
cp.add_argument("--bundle", default="knowledge")
|
|
1671
1417
|
cp.set_defaults(func=cmd_concept_paths)
|
|
1672
1418
|
|
|
1673
|
-
cc = sub.add_parser("concept-coverage", help="read-only: lane-split code coverage for one feature (story design)")
|
|
1674
|
-
cc.add_argument("--aggregate", default="")
|
|
1675
|
-
cc.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1676
|
-
cc.add_argument("--ids", default="", help="requirement ids to report anchors for (csv)")
|
|
1677
|
-
cc.add_argument("--max-list", dest="max_list", type=int, default=60,
|
|
1678
|
-
help="cap on files listed per lane in the digest (the csv stays complete)")
|
|
1679
|
-
cc.add_argument("--root", default="")
|
|
1680
|
-
cc.add_argument("--bundle", default="knowledge")
|
|
1681
|
-
cc.set_defaults(func=cmd_concept_coverage)
|
|
1682
|
-
|
|
1683
1419
|
vv = sub.add_parser("validate", help="report cross-reference drift (orphan rows)")
|
|
1684
1420
|
vv.add_argument("--prune", action="store_true", help="remove stale reverse orphans (safe self-heal)")
|
|
1685
1421
|
vv.add_argument("--root", default="")
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
[UI_LANE_DECLARATION]
|
|
57
57
|
<!-- Form: one line naming the lane, its stack and the root directory it owns — all three
|
|
58
58
|
taken from the mapped ui-constitution.md, which declares them, and never invented
|
|
59
|
-
here. The root is the subtree
|
|
59
|
+
here. The root is the subtree implement's frontend lane may write; it must match
|
|
60
60
|
.appweaver/stacks.json where that file exists, and a disagreement is a STACK-MISMATCH:
|
|
61
61
|
failure, not a silent rewrite. -->
|
|
62
62
|
|