bigpowers 2.58.1 → 2.58.3

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