aw-spec-cli 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +18 -18
  2. package/assets/.appweaver/ORCH-DIALECT.md +1 -3
  3. package/assets/.appweaver/RULE-PARTITION.md +29 -13
  4. package/assets/.appweaver/STACK-PROFILES.md +10 -9
  5. package/assets/.appweaver/orchestrator/.state/smoke.state.json +32 -0
  6. package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +4 -1
  7. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +251 -0
  8. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +104 -35
  9. package/assets/.appweaver/orchestrator/orchestrator.py +5 -4
  10. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +94 -36
  11. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +1 -1
  12. package/assets/.appweaver/orchestrator/rev-templates/domain-model-template.skeleton.md +115 -0
  13. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +2 -9
  14. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +16 -40
  15. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +38 -17
  16. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +8 -8
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +10 -11
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +673 -0
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +535 -0
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +643 -0
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +78 -626
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +4 -4
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +7 -7
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +16 -14
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +2 -2
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +243 -144
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +882 -464
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +4 -4
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +32 -91
  30. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
  31. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +832 -477
  32. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +2 -2
  33. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-bounded-context.orch.yaml +132 -0
  34. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +38 -5
  35. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +3 -3
  36. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +271 -892
  37. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +1096 -2911
  38. package/assets/.appweaver/scripts/bash/aw-xref.sh +1 -2
  39. package/assets/.appweaver/scripts/bash/common.sh +47 -0
  40. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +936 -2766
  41. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +1 -2
  42. package/assets/.appweaver/scripts/python/aw-payload-parity.py +174 -0
  43. package/assets/.appweaver/scripts/python/aw-stack.py +6 -2
  44. package/assets/.appweaver/scripts/python/aw-xref.py +185 -292
  45. package/assets/.appweaver/templates/constitution-ui-template.md +1 -1
  46. package/assets/.appweaver/templates/delta-design-template.md +120 -30
  47. package/assets/.appweaver/templates/delta-domain-model-template.md +81 -0
  48. package/assets/.appweaver/templates/delta-spec-template.md +43 -17
  49. package/assets/.appweaver/templates/design-template.md +68 -98
  50. package/assets/.appweaver/templates/domain-model-template.md +130 -0
  51. package/assets/.appweaver/templates/okf-concept-template.md +2 -2
  52. package/assets/.appweaver/templates/source-map-template.md +3 -3
  53. package/assets/.appweaver/templates/spec-template.md +100 -122
  54. package/assets/.appweaver/templates/ui-delta-design-template.md +159 -0
  55. package/assets/.appweaver/templates/ui-design-template.md +46 -75
  56. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +7 -18
  57. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +34 -39
  58. package/assets/guardrails/requirement-spec.txt +2 -2
  59. package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
  60. package/package.json +1 -1
  61. package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +0 -105
  62. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +0 -251
  63. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +0 -749
  64. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +0 -253
  65. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +0 -540
  66. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +0 -597
  67. package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +0 -945
  68. package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +0 -1040
  69. package/assets/.appweaver/templates/story-template.md +0 -147
@@ -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] [--lane backend|frontend] [--merge]
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/file lists), PRESERVING
46
- its body — never clobbers the Cross-References other slices wrote. --lane picks
47
- WHICH list this run owns: backend -> `source_files`, frontend -> `ui_source_files`.
48
- Both keys are always present (an absent one claims "never implemented", which is
49
- not the same as empty). --merge UNIONs that lane's list instead of replacing it —
50
- what one story's slice, or a CR delta, must do so a second run does not drop the
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
@@ -97,12 +77,15 @@ Only the standard library is used. Usage: aw-xref.py <sub> [options]
97
77
  """
98
78
 
99
79
  import argparse
80
+ import contextlib
100
81
  import datetime
82
+ import errno
101
83
  import json
102
84
  import os
103
85
  import re
104
86
  import subprocess
105
87
  import sys
88
+ import time
106
89
 
107
90
  # ───────────────────────────── canonical shapes ────────────────────────────
108
91
  OVERVIEW_HEADING = "## Overview"
@@ -249,10 +232,98 @@ def _read(path):
249
232
  return ""
250
233
 
251
234
 
235
+ # ──────────────────────────── write serialisation ──────────────────────────
236
+ # EVERY CONCEPT WRITE IS A READ-MODIFY-WRITE: read the whole file, splice one section,
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
239
+ # ordinary case, and the second write drops the first's rows with nothing to say so. Git
240
+ # catches that across branches and never inside a single working tree.
241
+ #
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
244
+ # outer read-modify-write can hold it while the _write inside re-acquires.
245
+ _LOCK_TIMEOUT = 30.0
246
+ _HELD = {}
247
+
248
+
249
+ @contextlib.contextmanager
250
+ def _lock(path):
251
+ key = os.path.abspath(path)
252
+ if _HELD.get(key):
253
+ _HELD[key] += 1
254
+ try:
255
+ yield
256
+ finally:
257
+ _HELD[key] -= 1
258
+ return
259
+ parent = os.path.dirname(key)
260
+ if parent:
261
+ os.makedirs(parent, exist_ok=True)
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())
266
+ start = time.time()
267
+ while True:
268
+ try:
269
+ h = os.open(lp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
270
+ os.write(h, (token + "\n").encode("utf-8"))
271
+ os.close(h)
272
+ break
273
+ except OSError as exc:
274
+ if exc.errno != errno.EEXIST:
275
+ raise
276
+ try:
277
+ age = time.time() - os.path.getmtime(lp)
278
+ except OSError:
279
+ age = 0.0
280
+ # A lock older than the timeout belonged to a run that died holding it.
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.
284
+ if age > _LOCK_TIMEOUT:
285
+ try:
286
+ os.unlink(lp)
287
+ except OSError:
288
+ pass
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.
293
+ if time.time() - start > _LOCK_TIMEOUT:
294
+ raise RuntimeError(
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)))
298
+ time.sleep(0.05)
299
+ _HELD[key] = 1
300
+ try:
301
+ yield
302
+ finally:
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.
308
+ try:
309
+ with open(lp, encoding="utf-8") as fh:
310
+ mine = fh.read().strip() == token
311
+ except OSError:
312
+ mine = False
313
+ if mine:
314
+ try:
315
+ os.unlink(lp)
316
+ except OSError:
317
+ pass
318
+
319
+
252
320
  def _write(path, text):
253
- os.makedirs(os.path.dirname(path), exist_ok=True)
254
- with open(path, "w", encoding="utf-8") as fh:
255
- fh.write(text)
321
+ parent = os.path.dirname(path)
322
+ if parent:
323
+ os.makedirs(parent, exist_ok=True)
324
+ with _lock(path):
325
+ with open(path, "w", encoding="utf-8") as fh:
326
+ fh.write(text)
256
327
 
257
328
 
258
329
  def _today(arg_date):
@@ -498,19 +569,13 @@ def _resolve_concept(name, bundle_abs):
498
569
  return best
499
570
 
500
571
 
501
- def _skeleton_concept(name, origin, source_files, description, lane="backend"):
502
- """A new concept always carries BOTH lane keys — `source_files` for the service
503
- tree and `ui_source_files` for the UI one — with the run's files in the lane it
504
- generated and an empty list in the other. Whichever lane implements first, the
505
- other's key is there for it to fill; the template's rule is to leave it empty on a
506
- single-lane slice, never to delete it."""
572
+ def _skeleton_concept(name, origin, source_files, description):
507
573
  fm = {
508
574
  "type": "aggregate",
509
575
  "title": name,
510
576
  "description": description or f"{name} aggregate.",
511
577
  "origin": origin or "greenfield",
512
- "source_files": list(source_files or []) if lane == "backend" else [],
513
- "ui_source_files": list(source_files or []) if lane == "frontend" else [],
578
+ "source_files": list(source_files or []),
514
579
  }
515
580
  overview = f"[overview of the {name} aggregate]"
516
581
  body = (
@@ -679,15 +744,20 @@ def _git_head_short(root):
679
744
 
680
745
 
681
746
  def _write_xref_rows(path, rows):
682
- text = _read(path)
683
- body = _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, rows)
684
- _write(path, _replace_section(text, XREF_HEADING, body))
747
+ # The read is INSIDE the lock: splicing a section into a file another process is
748
+ # rewriting is what loses rows, and taking the lock only for the write would still
749
+ # splice into a copy that had gone stale between the read and the acquisition.
750
+ with _lock(path):
751
+ text = _read(path)
752
+ body = _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, rows)
753
+ _write(path, _replace_section(text, XREF_HEADING, body))
685
754
 
686
755
 
687
756
  def _write_deps_rows(path, rows):
688
- text = _read(path)
689
- body = _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, rows)
690
- _write(path, _replace_section(text, DEPS_HEADING, body))
757
+ with _lock(path):
758
+ text = _read(path)
759
+ body = _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, rows)
760
+ _write(path, _replace_section(text, DEPS_HEADING, body))
691
761
 
692
762
 
693
763
  def _all_concepts(bundle_abs):
@@ -730,22 +800,30 @@ def cmd_record(args):
730
800
  edges = _design_edges(_read(design_path), using, known)
731
801
 
732
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.
733
807
  pruned = 0
734
808
  for cpath in _all_concepts(bundle_abs):
735
- rows = _read_xref_rows(cpath)
736
- kept = [r for r in rows if _slug(r[0]) != using_slug]
737
- if len(kept) != len(rows):
738
- pruned += len(rows) - len(kept)
739
- _write_xref_rows(cpath, kept)
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)
740
815
 
741
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.
742
819
  wrote, stubbed = [], []
743
820
  for e in edges:
744
821
  ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed)
745
- rows = _read_xref_rows(ypath)
746
- rows = [r for r in rows if not (_slug(r[0]) == using_slug and (len(r) > 1 and r[1] == e["via"]))]
747
- rows.append([using, e["via"], e["kind"], origin, date])
748
- _write_xref_rows(ypath, rows)
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)
749
827
  wrote.append({"target": e["target"], "concept": _rel(ypath, root), "via": e["via"], "kind": e["kind"]})
750
828
 
751
829
  # Rewrite this slice's own Depends-On (forward prune index) — stub self if needed.
@@ -814,67 +892,53 @@ def cmd_concept_upsert(args):
814
892
  _emit({"error": "aggregate-required", "root": root})
815
893
  return 2
816
894
  source_files = _parse_source_files(args.source_files, root)
817
- # WHICH LANE'S FILE LIST THIS RUN OWNS. A feature has ONE concept across both lanes,
818
- # and the two trees live under different keys: `source_files` is the service lane's,
819
- # `ui_source_files` is the UI lane's. Writing UI files into the service key would
820
- # collapse the concept resolver's module_dir to the repo root and hand every later
821
- # service reader a tree of components — which is exactly what concept-coverage's
822
- # lane split exists to prevent.
823
- lane = (args.lane or "backend").strip().lower()
824
- if lane not in ("backend", "frontend"):
825
- print("[xref] concept-upsert: --lane must be backend or frontend, not %r" % lane, file=sys.stderr)
826
- _emit({"error": "bad-lane", "lane": lane, "root": root})
827
- return 2
828
- key = "source_files" if lane == "backend" else "ui_source_files"
829
895
 
830
896
  path = _resolve_concept(aggregate, bundle_abs)
831
897
  created = path is None
832
898
  if created:
833
899
  _ensure_bundle(bundle_abs)
834
900
  path = _canonical_concept_path(bundle_abs, aggregate)
835
- _write(path, _skeleton_concept(aggregate, origin, source_files, args.description or "", lane))
901
+ _write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""))
836
902
  _ensure_dir_index(path, aggregate)
837
903
  else:
838
904
  # Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
839
- # which holds the Cross-References other slices wrote.
840
- fm, body = _read_fm(_read(path))
841
- fm.setdefault("type", "aggregate")
842
- fm["title"] = fm.get("title") or aggregate
843
- if source_files:
844
- if args.merge:
845
- # INCREMENTAL mode: {{files}} is only what THIS run wrote — one story's
846
- # slice, or a delta's changed files — so UNION with what the lane already
847
- # records, never replace. Replacing would drop every file the feature's
848
- # earlier stories generated the moment a second story implements.
849
- existing = fm.get(key) if isinstance(fm.get(key), list) else []
850
- merged = list(existing)
851
- for s in source_files:
852
- if s not in merged:
853
- merged.append(s)
854
- fm[key] = merged
855
- else:
856
- fm[key] = source_files
857
- # Both keys always present: an absent one reads as "this lane was never
858
- # implemented" to every consumer, which is a different claim from "empty".
859
- for k in ("source_files", "ui_source_files"):
860
- if k not in fm:
861
- fm[k] = []
862
- if args.description:
863
- fm["description"] = args.description
864
- elif "description" not in fm:
865
- fm["description"] = f"{aggregate} aggregate."
866
- fm.setdefault("origin", origin)
867
- rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
868
- else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
869
- _write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
905
+ # which holds the Cross-References other slices wrote. Read and write under one
906
+ # lock: --merge unions this run's files with what is already recorded, so two
907
+ # parallel runs reading before either writes both union against the same
908
+ # starting list and the second write loses the first's files.
909
+ with _lock(path):
910
+ fm, body = _read_fm(_read(path))
911
+ fm.setdefault("type", "aggregate")
912
+ fm["title"] = fm.get("title") or aggregate
913
+ if source_files:
914
+ if args.merge:
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 []
918
+ merged = list(existing)
919
+ for s in source_files:
920
+ if s not in merged:
921
+ merged.append(s)
922
+ fm["source_files"] = merged
923
+ else:
924
+ fm["source_files"] = source_files
925
+ elif "source_files" not in fm:
926
+ fm["source_files"] = []
927
+ if args.description:
928
+ fm["description"] = args.description
929
+ elif "description" not in fm:
930
+ fm["description"] = f"{aggregate} aggregate."
931
+ fm.setdefault("origin", origin)
932
+ rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
933
+ else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
934
+ _write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
870
935
  _ensure_bundle(bundle_abs)
871
936
  _ensure_dir_index(path, aggregate)
872
937
 
873
938
  print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
874
- f"({len(source_files)} {lane} source file(s) into {key}) — {_rel(path, root)}",
875
- file=sys.stderr)
939
+ f"({len(source_files)} source file(s)) — {_rel(path, root)}", file=sys.stderr)
876
940
  _emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
877
- "lane": lane, "key": key, "source_file_count": len(source_files), "root": root})
941
+ "source_file_count": len(source_files), "root": root})
878
942
  return 0
879
943
 
880
944
 
@@ -911,7 +975,11 @@ def cmd_concept_overview(args):
911
975
  return 2
912
976
 
913
977
  body = f"{OVERVIEW_HEADING}\n\n{text}\n"
914
- _write(path, _replace_section(_read(path), OVERVIEW_HEADING, body, before=DEPS_HEADING))
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))
915
983
  print(f"[xref] concept-overview {aggregate}: written ({len(text)} chars) — {_rel(path, root)}",
916
984
  file=sys.stderr)
917
985
  _emit({"aggregate": aggregate, "concept": _rel(path, root), "written": "true", "root": root})
@@ -979,177 +1047,6 @@ def cmd_concept_paths(args):
979
1047
  return 0
980
1048
 
981
1049
 
982
- def cmd_concept_coverage(args):
983
- """CODE COVERAGE for one feature, read from its OKF concept — what code EXISTS
984
- today for the aggregate a story is about to design against, SPLIT BY LANE.
985
-
986
- concept-paths answers "which files is this slice made of" for the executable
987
- phases, as one flat backend list. A story design needs the same facts shaped
988
- differently: the service design reads `source_files`, the frontend design reads
989
- `ui_source_files`, and neither should be handed the other's tree — a UI pass
990
- told to read twelve C# files designs around them. It also needs the two things
991
- a path list drops: the aggregate's forward edges (Depends-On), and its REVERSE
992
- edges (Cross-References) — the blast radius a slice inherits the moment it
993
- changes a published surface.
994
-
995
- COVERAGE, HONESTLY. A greenfield concept holds no requirement→file index: code
996
- carries no FR ids. A feature that was ADOPTED does — the source map's `Spec ids`
997
- column — so the ids given in --ids come back split into ANCHORED (some artefact
998
- claims the id) and UNANCHORED. With no source map every id is unanchored, which
999
- is the true answer, and it is NEVER reported as uncovered: absent evidence is not
1000
- evidence of absence, and a design pass told "FR-003 has no code" when the index
1001
- simply does not exist would re-plan work that already ships.
1002
-
1003
- STRICTLY READ-ONLY, the same contract as concept-paths: a query never stubs a
1004
- concept, and a feature with no concept at all — greenfield, before the first
1005
- implement — is a legitimate EMPTY answer with `found: false`, not an error."""
1006
- root = _root(args.root)
1007
- bundle_abs = os.path.join(root, args.bundle)
1008
- aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
1009
- if not aggregate:
1010
- print("[xref] concept-coverage: --aggregate or --feature-dir required", file=sys.stderr)
1011
- _emit({"error": "aggregate-required", "root": root})
1012
- return 2
1013
-
1014
- path = _resolve_concept(aggregate, bundle_abs)
1015
- fm = {}
1016
- if path:
1017
- fm, _ = _read_fm(_read(path))
1018
-
1019
- def lane_files(key):
1020
- """One frontmatter key's paths, split into on-disk and declared-but-missing.
1021
- _read_fm gives a list for a block list and "" for an empty scalar; the
1022
- `# Layer` group headers are comments and are already skipped by it."""
1023
- raw = fm.get(key)
1024
- items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, str) and raw else [])
1025
- seen, existing, missing = set(), [], []
1026
- for s in items:
1027
- s = (s or "").strip().strip('"').strip("'").replace(os.sep, "/")
1028
- if not s or s in seen:
1029
- continue
1030
- seen.add(s)
1031
- target = s if os.path.isabs(s) else os.path.join(root, s)
1032
- (existing if os.path.isfile(target) else missing).append(s)
1033
- return existing, missing
1034
-
1035
- be, be_missing = lane_files("source_files")
1036
- fe, fe_missing = lane_files("ui_source_files")
1037
-
1038
- # ---- requirement anchors, where an index exists ---------------------------
1039
- ids = [i for i in re.split(r'[,\s]+', (args.ids or "").strip()) if i]
1040
- smap = ""
1041
- anchors = {}
1042
- if args.feature_dir:
1043
- cand = os.path.join(args.feature_dir, "source-map.md")
1044
- if os.path.isfile(cand):
1045
- smap = _rel(cand, root)
1046
- text = _read(cand)
1047
- lines = text.splitlines()
1048
- start = next((i for i, ln in enumerate(lines) if ln.startswith("## Artefact")), None)
1049
- if start is not None:
1050
- end = next((j for j in range(start + 1, len(lines))
1051
- if lines[j].startswith("## ")), len(lines))
1052
- for r in _table_data_rows("\n".join(lines[start:end])):
1053
- # | # | Legacy artefact | Kind | Spec ids | Design element | ... |
1054
- if len(r) < 4:
1055
- continue
1056
- artefact = r[1].strip().strip("`")
1057
- for rid in re.findall(r'\b[A-Z]{2}-\d+', r[3]):
1058
- anchors.setdefault(rid, [])
1059
- if artefact and artefact not in anchors[rid]:
1060
- anchors[rid].append(artefact)
1061
- anchored = [i for i in ids if anchors.get(i)]
1062
- unanchored = [i for i in ids if not anchors.get(i)]
1063
-
1064
- deps = [r[0].strip() for r in (_read_deps_rows(path) if path else [])
1065
- if r and _IDENT_RE.match((r[0] or "").strip())]
1066
- users = sorted(set(_xref_users(aggregate, bundle_abs))) if path else []
1067
-
1068
- # ---- the digest — the block a design pass reads --------------------------
1069
- cap = args.max_list if args.max_list and args.max_list > 0 else 60
1070
-
1071
- def block(label, files, missing, empty_note):
1072
- if not files:
1073
- return ["%s: %s" % (label, empty_note)]
1074
- out = ["%s: %d file(s) on disk" % (label, len(files))]
1075
- shown = 0
1076
- for group, items in _group_source_files(files):
1077
- if shown >= cap:
1078
- break
1079
- out.append(" %s" % group)
1080
- for f in items:
1081
- if shown >= cap:
1082
- break
1083
- out.append(" %s" % f)
1084
- shown += 1
1085
- if len(files) > shown:
1086
- out.append(" … and %d more (the full list is in the lane's csv)" % (len(files) - shown))
1087
- if missing:
1088
- out.append(" DRIFT — declared in the concept, not on disk (%d): %s"
1089
- % (len(missing), ", ".join(missing[:12])
1090
- + (" …" if len(missing) > 12 else "")))
1091
- return out
1092
-
1093
- lines = []
1094
- if not path:
1095
- lines.append("No OKF concept for %s — nothing has been implemented for this feature yet. "
1096
- "Every lane below is GREENFIELD: design from a clean slate." % aggregate)
1097
- lines.append("")
1098
- else:
1099
- lines.append("%s — code coverage from the OKF concept (%s, origin: %s)"
1100
- % (aggregate, _rel(path, root), (fm.get("origin") or "unknown")))
1101
- lines.append("")
1102
- lines += block("BACKEND", be, be_missing,
1103
- "no service code recorded for this aggregate — greenfield on this lane")
1104
- lines.append("")
1105
- lines += block("FRONTEND", fe, fe_missing,
1106
- "no UI code recorded for this aggregate — greenfield on this lane")
1107
- lines.append("")
1108
- lines.append("DEPENDS ON (this aggregate reaches into): %s" % (", ".join(deps) or "nothing"))
1109
- lines.append("USED BY (change this aggregate's surface and these break): %s"
1110
- % (", ".join(users) or "nothing recorded"))
1111
- if ids:
1112
- lines.append("")
1113
- if not smap:
1114
- lines.append("REQUIREMENT ANCHORS: none available — this feature has no source-map.md, "
1115
- "so no id can be traced to a file. Treat the files above as the whole of "
1116
- "what exists and read them; do NOT read an id's absence here as proof "
1117
- "that no code implements it.")
1118
- else:
1119
- lines.append("REQUIREMENT ANCHORS (%s):" % smap)
1120
- for i in anchored:
1121
- lines.append(" %-10s %s" % (i, ", ".join(anchors[i][:6])
1122
- + (" …" if len(anchors[i]) > 6 else "")))
1123
- if unanchored:
1124
- lines.append(" not anchored to any artefact: %s — the map claims no code for "
1125
- "them, so design them as new work unless the files above say otherwise."
1126
- % ", ".join(unanchored))
1127
- digest = "\n".join(lines)
1128
-
1129
- print("[xref] concept-coverage %s: backend %d, frontend %d, drift %d, used-by %d"
1130
- % (aggregate, len(be), len(fe), len(be_missing) + len(fe_missing), len(users)),
1131
- file=sys.stderr)
1132
- _emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
1133
- "found": bool(path), "origin": (fm.get("origin") or ""),
1134
- "backend_paths": be, "backend_csv": ",".join(be), "backend_count": len(be),
1135
- "frontend_paths": fe, "frontend_csv": ",".join(fe), "frontend_count": len(fe),
1136
- "has_backend_code": bool(be), "has_frontend_code": bool(fe),
1137
- "backend_missing": be_missing, "frontend_missing": fe_missing,
1138
- "missing_count": len(be_missing) + len(fe_missing),
1139
- # THE UNION, for a reader whose question spans both lanes. A gate that traces a
1140
- # requirement to code has to see the UI half of it: an FR- with a user-facing
1141
- # surface is realised in two trees, and `source_files` alone is the service one.
1142
- "all_paths": be + fe, "all_csv": ",".join(be + fe), "all_count": len(be) + len(fe),
1143
- "all_missing": be_missing + fe_missing,
1144
- "all_missing_csv": ", ".join(be_missing + fe_missing),
1145
- "depends_on": deps, "depends_on_csv": ", ".join(deps),
1146
- "used_by": users, "used_by_csv": ", ".join(users),
1147
- "source_map": smap, "anchored_csv": ", ".join(anchored),
1148
- "unanchored_csv": ", ".join(unanchored),
1149
- "digest": digest, "root": root})
1150
- return 0
1151
-
1152
-
1153
1050
  # ─────────────────────────────── validate ──────────────────────────────────
1154
1051
  def cmd_validate(args):
1155
1052
  """Cross-reference drift check (the safety net BEYOND reconcile-on-write). Pairs the
@@ -1210,10 +1107,11 @@ def cmd_validate(args):
1210
1107
  rev.setdefault(o["concept"], set()).add((o["using"], o["via"]))
1211
1108
  for crel, keys in rev.items():
1212
1109
  cpath = os.path.join(root, crel)
1213
- rows = _read_xref_rows(cpath)
1214
- keep = [r for r in rows if (r[0], r[1] if len(r) > 1 else "") not in keys]
1215
- pruned += len(rows) - len(keep)
1216
- _write_xref_rows(cpath, keep)
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)
1217
1115
 
1218
1116
  rev_n = sum(1 for o in orphans if o["direction"] == "reverse")
1219
1117
  fwd_n = len(orphans) - rev_n
@@ -1245,10 +1143,16 @@ def cmd_log(args):
1245
1143
  note = args.note or "OKF cross-references updated."
1246
1144
  logp = os.path.join(bundle_abs, "log.md")
1247
1145
  entry = f"## {date}\n\nCommit: `{sha}`\n\n- {note}\n"
1248
- if os.path.isfile(logp):
1249
- _write(logp, _read(logp).rstrip("\n") + "\n\n" + entry)
1250
- else:
1251
- _write(logp, "# Change Log\n\nOKF bundle change journal — commit-anchored (OKF §7).\n\n" + entry)
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)
1252
1156
  print(f"[xref] log: {date} @ {sha} — {note}", file=sys.stderr)
1253
1157
  _emit({"logged": "true", "commit": sha, "date": date, "note": note,
1254
1158
  "log": _rel(logp, root), "root": root})
@@ -1488,8 +1392,7 @@ def main(argv):
1488
1392
  c.add_argument("--aggregate", default="")
1489
1393
  c.add_argument("--feature-dir", dest="feature_dir", default="")
1490
1394
  c.add_argument("--source-files", default="")
1491
- c.add_argument("--merge", action="store_true", help="union the lane's file list with existing (one story's slice, a CR delta) instead of replacing")
1492
- c.add_argument("--lane", default="backend", help="backend -> source_files, frontend -> ui_source_files")
1395
+ c.add_argument("--merge", action="store_true", help="union source_files with existing (CR delta) instead of replacing")
1493
1396
  c.add_argument("--title", default="")
1494
1397
  c.add_argument("--description", default="")
1495
1398
  c.add_argument("--origin", default="greenfield")
@@ -1513,16 +1416,6 @@ def main(argv):
1513
1416
  cp.add_argument("--bundle", default="knowledge")
1514
1417
  cp.set_defaults(func=cmd_concept_paths)
1515
1418
 
1516
- cc = sub.add_parser("concept-coverage", help="read-only: lane-split code coverage for one feature (story design)")
1517
- cc.add_argument("--aggregate", default="")
1518
- cc.add_argument("--feature-dir", dest="feature_dir", default="")
1519
- cc.add_argument("--ids", default="", help="requirement ids to report anchors for (csv)")
1520
- cc.add_argument("--max-list", dest="max_list", type=int, default=60,
1521
- help="cap on files listed per lane in the digest (the csv stays complete)")
1522
- cc.add_argument("--root", default="")
1523
- cc.add_argument("--bundle", default="knowledge")
1524
- cc.set_defaults(func=cmd_concept_coverage)
1525
-
1526
1419
  vv = sub.add_parser("validate", help="report cross-reference drift (orphan rows)")
1527
1420
  vv.add_argument("--prune", action="store_true", help="remove stale reverse orphans (safe self-heal)")
1528
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 ui-implement may write; it must match
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