bigpowers 2.51.0 → 2.52.0

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/.pi/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.51.0",
3
+ "version": "2.52.0",
4
4
  "description": "72 skills — 72 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "pi-package"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [2.52.0](https://github.com/danielvm-git/bigpowers/compare/v2.51.0...v2.52.0) (2026-07-03)
2
+
3
+
4
+ ### Features
5
+
6
+ * **e38:** deterministic spec-to-code coverage matrix builder ([5bc397c](https://github.com/danielvm-git/bigpowers/commit/5bc397c0eb31742fbb82136ffb935417ba07dc51))
7
+
1
8
  # [2.51.0](https://github.com/danielvm-git/bigpowers/compare/v2.50.0...v2.51.0) (2026-07-03)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.51.0",
3
+ "version": "2.52.0",
4
4
  "description": "72 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,612 @@
1
+ #!/usr/bin/env bash
2
+ # trace-stories.sh — deterministic spec-to-code coverage matrix builder
3
+ # Parses specs/release-plan.yaml + specs/execution-status.yaml, greps the
4
+ # codebase for story: eNNsNN tags, cross-references, and emits a coverage matrix.
5
+ #
6
+ # Oracle tiers (TEA-inspired):
7
+ # Tier 1: explicit story tag → confidence high
8
+ # Tier 2: file-name heuristic match → confidence medium
9
+ # Tier 3: epic capsule task reference → confidence low
10
+ #
11
+ # Usage:
12
+ # bash scripts/trace-stories.sh # full matrix, stdout
13
+ # bash scripts/trace-stories.sh --strict # exit non-zero on P0 uncovered
14
+ # bash scripts/trace-stories.sh --json # emit JSON to specs/traceability-matrix.json
15
+ # bash scripts/trace-stories.sh --help # this message
16
+ #
17
+ # Exit codes:
18
+ # 0 — matrix built successfully (or --help printed)
19
+ # 1 — input file missing or unparseable
20
+ # 2 — --strict mode: P0 story has 0% coverage
21
+
22
+ set -euo pipefail
23
+
24
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ MATRIX_JSON="$REPO_ROOT/specs/traceability-matrix.json"
26
+ TRACE_MD="$REPO_ROOT/specs/TRACEABILITY_LATEST.md"
27
+ RELEASE_PLAN="$REPO_ROOT/specs/release-plan.yaml"
28
+ EXEC_STATUS="$REPO_ROOT/specs/execution-status.yaml"
29
+ OKF_DIR="$REPO_ROOT/specs/codebase-wiki"
30
+
31
+ MODE=""
32
+ STRICT=0
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # CLI flags
36
+ # ---------------------------------------------------------------------------
37
+ while [[ $# -gt 0 ]]; do
38
+ case "$1" in
39
+ --help|-h)
40
+ cat <<'USAGE'
41
+ Usage: trace-stories.sh [flags]
42
+
43
+ Build a deterministic spec-to-code coverage matrix from story tags.
44
+
45
+ Flags:
46
+ --strict Exit non-zero if any P0 story has 0% code coverage.
47
+ --json Emit JSON matrix to specs/traceability-matrix.json.
48
+ --help Print this message and exit.
49
+
50
+ Exit codes:
51
+ 0 Matrix built successfully.
52
+ 1 Required input file missing or unparseable.
53
+ 2 --strict mode: one or more P0 stories have 0% code coverage.
54
+
55
+ Input files (required):
56
+ specs/release-plan.yaml Story inventory (id, title, wsjf, bcps).
57
+ specs/execution-status.yaml Story statuses (done, active, backlog).
58
+
59
+ Output files:
60
+ specs/traceability-matrix.json Machine-readable coverage matrix.
61
+ specs/TRACEABILITY_LATEST.md Human-readable report.
62
+ specs/codebase-wiki/ OKF-conformant derived bundle.
63
+ USAGE
64
+ exit 0
65
+ ;;
66
+ --strict)
67
+ STRICT=1
68
+ shift
69
+ ;;
70
+ --json)
71
+ MODE="json"
72
+ shift
73
+ ;;
74
+ *)
75
+ echo "trace-stories.sh: unknown flag: $1" >&2
76
+ echo "Try --help for usage." >&2
77
+ exit 1
78
+ ;;
79
+ esac
80
+ done
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Input existence checks
84
+ # ---------------------------------------------------------------------------
85
+ if [[ ! -f "$RELEASE_PLAN" ]]; then
86
+ echo "trace-stories.sh: release-plan.yaml: not found at $RELEASE_PLAN" >&2
87
+ exit 1
88
+ fi
89
+
90
+ if [[ ! -f "$EXEC_STATUS" ]]; then
91
+ echo "trace-stories.sh: execution-status.yaml: not found at $EXEC_STATUS" >&2
92
+ exit 1
93
+ fi
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Matrix builder (Python — no external deps beyond stdlib)
97
+ # ---------------------------------------------------------------------------
98
+ python3 - "$REPO_ROOT" "$MATRIX_JSON" "$TRACE_MD" "$OKF_DIR" "$STRICT" "$MODE" <<'PYEOF'
99
+ import json, os, re, sys, time
100
+ from pathlib import Path
101
+ from datetime import datetime, timezone
102
+
103
+ ROOT = Path(sys.argv[1])
104
+ MATRIX_JSON = Path(sys.argv[2])
105
+ TRACE_MD = Path(sys.argv[3])
106
+ OKF_DIR = Path(sys.argv[4])
107
+ STRICT = int(sys.argv[5])
108
+ MODE = sys.argv[6]
109
+
110
+ # -----------------------------------------------------------------------
111
+ # 1. Parse release-plan.yaml → story inventory
112
+ # -----------------------------------------------------------------------
113
+ def parse_simple_yaml(text: str) -> dict:
114
+ """Parse flat and one-level-nested YAML including lists of objects."""
115
+ root: dict = {}
116
+ # Each stack entry: (indent, container, parent_dict, key_in_parent)
117
+ stack: list = [(0, root, None, None)]
118
+ skip_until_indent = None # skip block scalar continuation lines (| and >)
119
+ for i, raw in enumerate(text.splitlines()):
120
+ stripped = raw.strip()
121
+ if not stripped or stripped.startswith("#"):
122
+ # Reset block-scalar skip on blank lines
123
+ continue
124
+ indent = len(raw) - len(raw.lstrip())
125
+
126
+ # Skip block scalar continuation (| and > in YAML)
127
+ if skip_until_indent is not None:
128
+ if indent > skip_until_indent:
129
+ continue
130
+ else:
131
+ skip_until_indent = None
132
+
133
+ # Pop stack until we're at the right nesting level
134
+ # Don't pop list containers at same indent — sibling items stay in same list
135
+ while len(stack) > 1:
136
+ if indent < stack[-1][0]:
137
+ stack.pop()
138
+ elif indent == stack[-1][0]:
139
+ # Same indent: pop dicts (moving to sibling key), keep lists (sibling items)
140
+ if isinstance(stack[-1][1], list) and stripped.startswith("- "):
141
+ break # stay in same list for next item
142
+ elif isinstance(stack[-1][1], dict) and stripped.startswith("- "):
143
+ # dict at same indent with upcoming list item — convert to list
144
+ entry_container = stack[-1][1]
145
+ entry_parent = stack[-1][2]
146
+ entry_key = stack[-1][3]
147
+ if entry_parent is not None and entry_key is not None:
148
+ if not isinstance(entry_parent.get(entry_key), list):
149
+ entry_parent[entry_key] = []
150
+ stack[-1] = (stack[-1][0], entry_parent[entry_key], entry_parent, entry_key)
151
+ break
152
+ stack.pop()
153
+ else:
154
+ stack.pop() # other same-indent transitions
155
+ else:
156
+ break # indent > stack indent — staying inside
157
+
158
+ container = stack[-1][1]
159
+ parent_dict = stack[-1][2]
160
+ key_in_parent = stack[-1][3]
161
+
162
+ # List item
163
+ if stripped.startswith("- "):
164
+ inner = stripped[2:]
165
+ # Convert dict to list if needed (preserve original indent from dict push)
166
+ if isinstance(container, dict) and parent_dict is not None and key_in_parent is not None:
167
+ if not isinstance(parent_dict.get(key_in_parent), list):
168
+ orig_indent = stack[-1][0]
169
+ parent_dict[key_in_parent] = []
170
+ container = parent_dict[key_in_parent]
171
+ stack[-1] = (orig_indent, container, parent_dict, key_in_parent)
172
+
173
+ if ":" in inner:
174
+ k, _, v = inner.partition(":")
175
+ k = k.strip()
176
+ v = v.strip()
177
+ # Detect block scalars in list items too
178
+ if v in ("|", ">", "|-", ">-", "|+", ">+"):
179
+ item = {k: v}
180
+ container.append(item)
181
+ skip_until_indent = indent
182
+ continue
183
+ item = {k: _yaml_scalar(v) if v else None}
184
+ container.append(item)
185
+ if v == "":
186
+ stack.append((indent, item, container, k))
187
+ else:
188
+ container.append(_yaml_scalar(inner))
189
+ continue
190
+
191
+ # Key: value
192
+ if ":" not in stripped:
193
+ continue
194
+ key, _, val = stripped.partition(":")
195
+ key = key.strip()
196
+ val = val.strip()
197
+
198
+ # Detect block scalars (|, >, |- etc.)
199
+ if val in ("|", ">", "|-", ">-", "|+", ">+"):
200
+ if isinstance(container, list) and container:
201
+ container[-1][key] = val # store the marker
202
+ elif isinstance(container, dict):
203
+ container[key] = val
204
+ skip_until_indent = indent
205
+ continue
206
+
207
+ if isinstance(container, list) and container:
208
+ # Inside a list item dict
209
+ container[-1][key] = _yaml_scalar(val) if val else None
210
+ if val == "":
211
+ nxt = {}
212
+ container[-1][key] = nxt
213
+ stack.append((indent, nxt, container[-1], key))
214
+ elif isinstance(container, dict):
215
+ if val == "":
216
+ # Store as dict first; will convert to list if next line is "-"
217
+ nxt = {}
218
+ container[key] = nxt
219
+ stack.append((indent, nxt, container, key))
220
+ else:
221
+ container[key] = _yaml_scalar(val)
222
+ return root
223
+
224
+
225
+ def _yaml_scalar(val: str):
226
+ """Convert a YAML scalar string to Python type."""
227
+ val = val.strip('"').strip("'")
228
+ if val in ("true", "false"):
229
+ return val == "true"
230
+ if val in ("null", "~", ""):
231
+ return None
232
+ try:
233
+ return int(val)
234
+ except ValueError:
235
+ try:
236
+ return float(val)
237
+ except ValueError:
238
+ return val
239
+
240
+ # Parse release plan for epics/stories
241
+ release_plan_path = ROOT / "specs" / "release-plan.yaml"
242
+ release_text = release_plan_path.read_text(encoding="utf-8")
243
+ release = parse_simple_yaml(release_text)
244
+
245
+ # Parse execution status
246
+ exec_path = ROOT / "specs" / "execution-status.yaml"
247
+ exec_text = exec_path.read_text(encoding="utf-8")
248
+ exec_status = parse_simple_yaml(exec_text)
249
+ dev_status = exec_status.get("development_status", {})
250
+
251
+ # -----------------------------------------------------------------------
252
+ # 2. Build story inventory from release-plan.yaml
253
+ # -----------------------------------------------------------------------
254
+ stories: dict[str, dict] = {}
255
+ epics = release.get("epics", [])
256
+ if not isinstance(epics, list):
257
+ epics = []
258
+
259
+ for epic in epics:
260
+ if not isinstance(epic, dict):
261
+ continue
262
+ eid = epic.get("id", "")
263
+ ebcp = epic.get("bcps", 0)
264
+ etitle = epic.get("title", "")
265
+ ewsjf = epic.get("wsjf", 0)
266
+
267
+ # Check for capsule_dir with stories
268
+ capsule_dir = epic.get("capsule_dir", "")
269
+ if capsule_dir:
270
+ capsule_path = ROOT / "specs" / capsule_dir / "epic.yaml"
271
+ if capsule_path.exists():
272
+ cap_text = capsule_path.read_text(encoding="utf-8")
273
+ cap = parse_simple_yaml(cap_text)
274
+ cap_stories = cap.get("stories", [])
275
+ if isinstance(cap_stories, list):
276
+ for s in cap_stories:
277
+ if isinstance(s, dict):
278
+ sid = s.get("id", "")
279
+ stories[sid] = {
280
+ "id": sid,
281
+ "title": s.get("title", ""),
282
+ "epic_id": eid,
283
+ "epic_title": etitle,
284
+ "bcp": s.get("bcp", 0),
285
+ "wsjf": float(ewsjf),
286
+ "description": s.get("description", "")
287
+ }
288
+
289
+ # Also check legacy flat epics (file: epics/eNN-*.yaml patterns)
290
+ file_key = epic.get("file", "")
291
+ if file_key and not capsule_dir:
292
+ legacy_path = ROOT / "specs" / file_key
293
+ if legacy_path.exists():
294
+ leg_text = legacy_path.read_text(encoding="utf-8")
295
+ leg = parse_simple_yaml(leg_text)
296
+ leg_stories = leg.get("stories", [])
297
+ if isinstance(leg_stories, list):
298
+ for s in leg_stories:
299
+ if isinstance(s, dict):
300
+ sid = s.get("id", "")
301
+ stories[sid] = {
302
+ "id": sid,
303
+ "title": s.get("title", ""),
304
+ "epic_id": eid,
305
+ "epic_title": etitle,
306
+ "bcp": s.get("bcp", 0),
307
+ "wsjf": float(ewsjf),
308
+ "description": s.get("description", "")
309
+ }
310
+
311
+ # -----------------------------------------------------------------------
312
+ # 3. Grep codebase for story: eNNsNN tags
313
+ # -----------------------------------------------------------------------
314
+ import subprocess
315
+ result = subprocess.run(
316
+ ["grep", "-rn", "--include=*.md", "--include=*.sh", "--include=*.py",
317
+ "--include=*.js", "--include=*.ts", "--include=*.yaml", "--include=*.yml",
318
+ "-E", r"story:\s*e\d{2}s\d{2}", str(ROOT)],
319
+ capture_output=True, text=True, cwd=str(ROOT)
320
+ )
321
+
322
+ tag_inventory: list[dict] = []
323
+ for line in result.stdout.splitlines():
324
+ m = re.match(r"^(.+?):(\d+):(.*story:\s*(e\d{2}s\d{2}).*)$", line)
325
+ if m:
326
+ fpath = m.group(1)
327
+ fline = int(m.group(2))
328
+ sid = m.group(4)
329
+ # Make path relative to repo root
330
+ try:
331
+ fpath = str(Path(fpath).relative_to(ROOT))
332
+ except ValueError:
333
+ pass
334
+ tag_inventory.append({"file": fpath, "line": fline, "story_id": sid})
335
+
336
+ # -----------------------------------------------------------------------
337
+ # 4. Resolve each story through oracle tiers
338
+ # -----------------------------------------------------------------------
339
+ # Tier 1: explicit story tag (already captured above)
340
+ # Tier 2: file-name heuristic match
341
+ # Tier 3: epic capsule task reference
342
+
343
+ # Collect all file basenames for heuristic matching
344
+ all_files = []
345
+ for root_dir, dirs, files in os.walk(str(ROOT)):
346
+ # Skip generated/artifacts
347
+ dirs[:] = [d for d in dirs if d not in (".git", "node_modules", ".cursor")]
348
+ for f in files:
349
+ all_files.append(str(Path(root_dir, f).relative_to(ROOT)))
350
+
351
+ def slugify(text: str) -> str:
352
+ """Convert title to a kebab-case slug."""
353
+ text = re.sub(r"[^a-zA-Z0-9\s-]", "", text.lower())
354
+ return re.sub(r"\s+", "-", text.strip())
355
+
356
+ def heuristic_match(story_title: str, file_path: str) -> bool:
357
+ """Check if file-path contains keywords from story title."""
358
+ slug = slugify(story_title)
359
+ words = slug.split("-")
360
+ fname = Path(file_path).stem.lower()
361
+ # Must match at least 2 significant keywords
362
+ sig_words = [w for w in words if len(w) > 2]
363
+ if len(sig_words) < 2:
364
+ sig_words = words
365
+ matches = sum(1 for w in sig_words if w in fname)
366
+ return matches >= min(2, len(sig_words))
367
+
368
+ def find_task_references(story_id: str) -> list[dict]:
369
+ """Find epic capsule task YAML files that reference this story's verify paths."""
370
+ refs = []
371
+ epics_dir = ROOT / "specs" / "epics"
372
+ if not epics_dir.exists():
373
+ return refs
374
+ for task_yaml in epics_dir.rglob("*tasks.yaml"):
375
+ try:
376
+ content = task_yaml.read_text(encoding="utf-8")
377
+ if f"story_id: {story_id}" in content or story_id in content:
378
+ rel = str(task_yaml.relative_to(ROOT))
379
+ refs.append({"file": rel, "type": "task_yaml"})
380
+ except Exception:
381
+ pass
382
+ return refs
383
+
384
+ # Build the coverage matrix
385
+ matrix_stories = []
386
+ tagged_sids = set(t["story_id"] for t in tag_inventory)
387
+ tag_index: dict[str, list] = {}
388
+ for t in tag_inventory:
389
+ tag_index.setdefault(t["story_id"], []).append(t)
390
+
391
+ dark_stories = []
392
+ orphan_tags = []
393
+ stale_tags = []
394
+
395
+ for sid, sinfo in sorted(stories.items()):
396
+ links = []
397
+ sid_status = dev_status.get(sid, "backlog")
398
+
399
+ # Tier 1: explicit tags
400
+ if sid in tag_index:
401
+ for t in tag_index[sid]:
402
+ links.append({"file": t["file"], "line": t["line"], "confidence": "high", "method": "explicit_tag"})
403
+
404
+ # Tier 2: file-name heuristic
405
+ for fpath in all_files:
406
+ if heuristic_match(sinfo["title"], fpath):
407
+ # Don't add if already covered by explicit tag
408
+ if not any(l["file"] == fpath for l in links):
409
+ links.append({"file": fpath, "line": 0, "confidence": "medium", "method": "file_heuristic"})
410
+
411
+ # Tier 3: task references
412
+ task_refs = find_task_references(sid)
413
+ for tr in task_refs:
414
+ existing_files = {l["file"] for l in links}
415
+ if tr["file"] not in existing_files:
416
+ links.append({"file": tr["file"], "line": 0, "confidence": "low", "method": "task_reference"})
417
+
418
+ matrix_stories.append({
419
+ "id": sid,
420
+ "title": sinfo["title"],
421
+ "epic_id": sinfo["epic_id"],
422
+ "epic_title": sinfo["epic_title"],
423
+ "bcp": sinfo["bcp"],
424
+ "wsjf": sinfo["wsjf"],
425
+ "status": sid_status,
426
+ "links": links,
427
+ "link_count": len(links)
428
+ })
429
+
430
+ # Classify findings
431
+ if len(links) == 0 and sid_status != "backlog":
432
+ dark_stories.append(sid)
433
+
434
+ # Check for orphan tags (tag in code, not in story inventory)
435
+ for sid in sorted(tagged_sids):
436
+ if sid not in stories:
437
+ orphan_tags.append(sid)
438
+
439
+ # Check for stale tags (story done, tag still in code)
440
+ for sid in sorted(tagged_sids):
441
+ if sid in stories and dev_status.get(sid) == "done":
442
+ stale_tags.append(sid)
443
+
444
+ # -----------------------------------------------------------------------
445
+ # 5. Emit matrix JSON
446
+ # -----------------------------------------------------------------------
447
+ matrix = {
448
+ "generated_at": datetime.now(timezone.utc).isoformat(),
449
+ "matrix_version": "1.0",
450
+ "stories": matrix_stories,
451
+ "summary": {
452
+ "total_stories": len(stories),
453
+ "tagged_stories": len(tagged_sids & set(stories.keys())),
454
+ "dark_stories": dark_stories,
455
+ "dark_count": len(dark_stories),
456
+ "orphan_tags": orphan_tags,
457
+ "orphan_count": len(orphan_tags),
458
+ "stale_tags": stale_tags,
459
+ "stale_count": len(stale_tags),
460
+ "oracle_stats": {
461
+ "high": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "high"),
462
+ "medium": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "medium"),
463
+ "low": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "low")
464
+ }
465
+ }
466
+ }
467
+
468
+ MATRIX_JSON.parent.mkdir(parents=True, exist_ok=True)
469
+ MATRIX_JSON.write_text(json.dumps(matrix, indent=2), encoding="utf-8")
470
+
471
+ # -----------------------------------------------------------------------
472
+ # 6. Emit TRACEABILITY_LATEST.md (human-readable report)
473
+ # -----------------------------------------------------------------------
474
+ lines = []
475
+ lines.append("# Traceability Matrix")
476
+ lines.append("")
477
+ lines.append(f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
478
+ lines.append(f"**Total stories:** {len(stories)}")
479
+ lines.append(f"**Tagged stories:** {len(tagged_sids & set(stories.keys()))}")
480
+ lines.append(f"**Dark stories:** {len(dark_stories)}")
481
+ lines.append(f"**Orphan tags:** {len(orphan_tags)}")
482
+ lines.append(f"**Stale tags:** {len(stale_tags)}")
483
+ lines.append("")
484
+ lines.append("## Oracle Stats")
485
+ lines.append("")
486
+ lines.append(f"- **High** (explicit tag): {matrix['summary']['oracle_stats']['high']}")
487
+ lines.append(f"- **Medium** (file heuristic): {matrix['summary']['oracle_stats']['medium']}")
488
+ lines.append(f"- **Low** (task reference): {matrix['summary']['oracle_stats']['low']}")
489
+ lines.append("")
490
+
491
+ # Story coverage table
492
+ lines.append("## Story Coverage")
493
+ lines.append("")
494
+ lines.append("| Story | Title | Epic | BCP | WSJF | Status | Links |")
495
+ lines.append("|-------|-------|------|-----|------|--------|-------|")
496
+ for s in matrix_stories:
497
+ lines.append(f"| {s['id']} | {s['title'][:60]} | {s['epic_id']} | {s['bcp']} | {s['wsjf']} | {s['status']} | {s['link_count']} |")
498
+ lines.append("")
499
+
500
+ # Findings
501
+ if dark_stories:
502
+ lines.append("## Dark Stories (no code links)")
503
+ lines.append("")
504
+ for ds in dark_stories:
505
+ sinfo = stories.get(ds, {})
506
+ lines.append(f"- **{ds}**: {sinfo.get('title', 'Unknown')} (status: {dev_status.get(ds, '?')})")
507
+ lines.append("")
508
+
509
+ if orphan_tags:
510
+ lines.append("## Orphan Tags (tag in code, no matching story)")
511
+ lines.append("")
512
+ for ot in orphan_tags:
513
+ lines.append(f"- `{ot}`")
514
+ lines.append("")
515
+
516
+ if stale_tags:
517
+ lines.append("## Stale Tags (story done, tag still in code)")
518
+ lines.append("")
519
+ for st in stale_tags:
520
+ lines.append(f"- `{st}`")
521
+ lines.append("")
522
+
523
+ TRACE_MD.parent.mkdir(parents=True, exist_ok=True)
524
+ TRACE_MD.write_text("\n".join(lines), encoding="utf-8")
525
+
526
+ # -----------------------------------------------------------------------
527
+ # 7. Emit OKF bundle (specs/codebase-wiki/)
528
+ # -----------------------------------------------------------------------
529
+ OKF_DIR.mkdir(parents=True, exist_ok=True)
530
+
531
+ # Generate index.md
532
+ idx_lines = []
533
+ idx_lines.append("---")
534
+ idx_lines.append("type: Index")
535
+ idx_lines.append(f"generated_at: {datetime.now(timezone.utc).isoformat()}")
536
+ idx_lines.append(f"total_concepts: {len(stories)}")
537
+ idx_lines.append("---")
538
+ idx_lines.append("")
539
+ idx_lines.append("# Codebase Wiki — Story Traceability")
540
+ idx_lines.append("")
541
+ idx_lines.append("Auto-generated OKF bundle from trace-stories.sh.")
542
+ idx_lines.append("")
543
+ idx_lines.append("| Story | Title | Confidence | Links |")
544
+ idx_lines.append("|-------|-------|------------|-------|")
545
+ for s in matrix_stories:
546
+ confs = {l["confidence"] for l in s["links"]}
547
+ max_conf = "high" if "high" in confs else ("medium" if "medium" in confs else ("low" if confs else "none"))
548
+ idx_lines.append(f"| [{s['id']}](./{s['id']}.md) | {s['title'][:60]} | {max_conf} | {s['link_count']} |")
549
+ idx_lines.append("")
550
+ (OKF_DIR / "index.md").write_text("\n".join(idx_lines), encoding="utf-8")
551
+
552
+ # Generate one concept per story
553
+ for s in matrix_stories:
554
+ concept = []
555
+ concept.append("---")
556
+ concept.append(f"type: Story")
557
+ concept.append(f"id: {s['id']}")
558
+ concept.append(f"epic: {s['epic_id']}")
559
+ concept.append(f"bcps: {s['bcp']}")
560
+ concept.append(f"wsjf: {s['wsjf']}")
561
+ concept.append(f"implementation_status: {s['status']}")
562
+ max_confs = set()
563
+ for l in s["links"]:
564
+ max_confs.add(l["confidence"])
565
+ conf = "high" if "high" in max_confs else ("medium" if "medium" in max_confs else ("low" if max_confs else "none"))
566
+ concept.append(f"coverage_status: {'covered' if s['link_count'] > 0 else 'dark'}")
567
+ concept.append(f"confidence: {conf}")
568
+ concept.append("links:")
569
+ for l in s["links"]:
570
+ concept.append(f" - file: {l['file']}")
571
+ concept.append(f" line: {l['line']}")
572
+ concept.append(f" confidence: {l['confidence']}")
573
+ concept.append(f" method: {l['method']}")
574
+ concept.append("---")
575
+ concept.append("")
576
+ concept.append(f"# {s['id']}: {s['title']}")
577
+ concept.append("")
578
+ concept.append(f"**Epic:** {s['epic_id']} — {s.get('epic_title', 'Unknown')}")
579
+ concept.append(f"**Status:** {s['status']}")
580
+ concept.append(f"**BCP:** {s['bcp']} | **WSJF:** {s['wsjf']}")
581
+ concept.append("")
582
+ if s["links"]:
583
+ concept.append("## Implemented In")
584
+ concept.append("")
585
+ for l in s["links"]:
586
+ concept.append(f"- `{l['file']}` (line {l['line']}, confidence: {l['confidence']}, method: {l['method']})")
587
+ else:
588
+ concept.append("*No code links found — this is a dark story.*")
589
+ concept.append("")
590
+ (OKF_DIR / f"{s['id']}.md").write_text("\n".join(concept), encoding="utf-8")
591
+
592
+ # -----------------------------------------------------------------------
593
+ # 8. Strict mode — exit non-zero on P0 uncovered
594
+ # -----------------------------------------------------------------------
595
+ if STRICT:
596
+ # P0 = top WSJF quartile among stories
597
+ if matrix_stories:
598
+ wsjf_sorted = sorted(set(s["wsjf"] for s in matrix_stories), reverse=True)
599
+ cutoff_idx = max(0, len(wsjf_sorted) // 4 - 1)
600
+ p0_threshold = wsjf_sorted[cutoff_idx] if cutoff_idx < len(wsjf_sorted) else 0
601
+
602
+ uncovered_p0 = []
603
+ for s in matrix_stories:
604
+ if s["wsjf"] >= p0_threshold and len(s["links"]) == 0:
605
+ uncovered_p0.append(s["id"])
606
+
607
+ if uncovered_p0:
608
+ print(f"trace-stories.sh: STRICT FAIL — P0 stories with 0% coverage: {', '.join(uncovered_p0)}", file=sys.stderr)
609
+ sys.exit(2)
610
+
611
+ sys.exit(0)
612
+ PYEOF