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.
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python3
2
+ """story: e38s04 — TEA-inspired heuristic blind-spot detector (Python engine).
3
+
4
+ Reads execution-status.yaml + traceability-matrix.json, runs 6 structural
5
+ quality checks, and emits specs/blind-spots.json.
6
+
7
+ Usage: called by scripts/check-blind-spots.sh with positional args:
8
+ python3 scripts/lib/blind-spots.py <repo_root> <blind_spots_json>
9
+ <exec_status_yaml> <matrix_json> <verifications_dir> <epics_dir>
10
+ """
11
+
12
+ import json, os, re, sys
13
+ from pathlib import Path
14
+ from datetime import datetime, timezone
15
+
16
+ ROOT = Path(sys.argv[1])
17
+ BLIND_SPOTS_JSON = Path(sys.argv[2])
18
+ EXEC_STATUS = Path(sys.argv[3])
19
+ MATRIX_JSON = Path(sys.argv[4])
20
+ VERIFICATIONS_DIR = Path(sys.argv[5])
21
+ EPICS_DIR = Path(sys.argv[6])
22
+
23
+ findings: list[dict] = []
24
+
25
+ def parse_exec_status(path: Path) -> dict[str, str]:
26
+ status: dict[str, str] = {}
27
+ if not path.exists():
28
+ return status
29
+ text = path.read_text(encoding="utf-8")
30
+ lines = text.splitlines()
31
+ in_dev_status = False
32
+ for line in lines:
33
+ stripped = line.strip()
34
+ if stripped.startswith("development_status:"):
35
+ in_dev_status = True
36
+ continue
37
+ if not in_dev_status:
38
+ if stripped and not stripped.startswith(" ") and not stripped.startswith("#"):
39
+ if ":" in stripped:
40
+ in_dev_status = False
41
+ continue
42
+ if ":" in stripped and not stripped.startswith("#"):
43
+ m = re.match(r'\s+(\S+):\s*"([^"]*)"', stripped)
44
+ if m:
45
+ status[m.group(1)] = m.group(2)
46
+ return status
47
+
48
+ dev_status = parse_exec_status(EXEC_STATUS)
49
+
50
+ matrix_data: dict | None = None
51
+ if MATRIX_JSON.exists():
52
+ matrix_data = json.loads(MATRIX_JSON.read_text(encoding="utf-8"))
53
+
54
+ tagged_files: dict[str, list[str]] = {}
55
+ file_story_counts: dict[str, set[str]] = {}
56
+ if matrix_data:
57
+ for s in matrix_data.get("stories", []):
58
+ sid = s["id"]
59
+ for link in s.get("links", []):
60
+ f = link["file"]
61
+ tagged_files.setdefault(sid, []).append(f)
62
+ file_story_counts.setdefault(f, set()).add(sid)
63
+
64
+ CODE_EXTENSIONS = {".sh", ".py", ".js", ".ts", ".jsx", ".tsx", ".md", ".yaml", ".yml", ".cjs", ".mjs", ".json"}
65
+ SKIP_DIRS = {".git", "node_modules", ".cursor", ".gemini", ".pi", "specs", "dist", "build", "__pycache__", ".bigpowers"}
66
+
67
+ def is_code_file(p: Path) -> bool:
68
+ return p.suffix in CODE_EXTENSIONS and not any(s in p.parts for s in SKIP_DIRS)
69
+
70
+ all_code_files: list[str] = []
71
+ for root, dirs, files in os.walk(str(ROOT)):
72
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
73
+ rel_root = str(Path(root).relative_to(ROOT))
74
+ for fname in files:
75
+ fpath = Path(root) / fname
76
+ if is_code_file(fpath):
77
+ all_code_files.append(str(fpath.relative_to(ROOT)))
78
+
79
+ TEST_PATTERNS = [
80
+ re.compile(r"(^|/)test(s)?/", re.IGNORECASE),
81
+ re.compile(r"(^|/)__tests__/", re.IGNORECASE),
82
+ re.compile(r"\.test\.", re.IGNORECASE),
83
+ re.compile(r"\.spec\.", re.IGNORECASE),
84
+ re.compile(r"_test\.", re.IGNORECASE),
85
+ re.compile(r"test_", re.IGNORECASE),
86
+ ]
87
+
88
+ def has_test_files(code_files: list[str]) -> bool:
89
+ for cf in code_files:
90
+ cf_path = Path(cf)
91
+ cf_dir = cf_path.parent
92
+ cf_stem = cf_path.stem
93
+ for tf in all_code_files:
94
+ tp = Path(tf)
95
+ if tp.parent == cf_dir:
96
+ for pat in TEST_PATTERNS:
97
+ if pat.search(tp.name) and cf_stem in tp.name:
98
+ return True
99
+ for tdn in ["tests", "test", "__tests__"]:
100
+ test_dir = cf_dir / tdn
101
+ if str(test_dir) in tf:
102
+ if cf_stem in tp.name:
103
+ return True
104
+ return False
105
+
106
+ # Check A: verify-gap
107
+ for sid, sstatus in dev_status.items():
108
+ if sstatus == "done":
109
+ verify_file = VERIFICATIONS_DIR / f"{sid}-verify.yaml"
110
+ if not verify_file.exists():
111
+ findings.append({
112
+ "check": "verify-gap",
113
+ "story_id": sid,
114
+ "severity": "HIGH",
115
+ "description": f"Story {sid} is marked done but no verification evidence exists at specs/verifications/{sid}-verify.yaml",
116
+ "remediation": f"Run verify-work for {sid} or create the verification evidence file"
117
+ })
118
+
119
+ # Check B: test-gap
120
+ for sid, files in tagged_files.items():
121
+ if dev_status.get(sid) in ("done", "active"):
122
+ if not has_test_files(files):
123
+ code_only = [f for f in files if Path(f).suffix in {".sh", ".py", ".js", ".ts", ".jsx", ".tsx"} and "SKILL.md" not in f]
124
+ if code_only:
125
+ findings.append({
126
+ "check": "test-gap",
127
+ "story_id": sid,
128
+ "severity": "MEDIUM",
129
+ "description": f"Story {sid} has {len(code_only)} tagged code file(s) with no matching test files",
130
+ "remediation": f"Add test coverage for the tagged files: {', '.join(code_only[:3])}"
131
+ })
132
+
133
+ # Check C: epic-orphan
134
+ if EPICS_DIR.exists():
135
+ for epic_dir in sorted(EPICS_DIR.iterdir()):
136
+ if not epic_dir.is_dir() or epic_dir.name == "archive":
137
+ continue
138
+ epic_yaml = epic_dir / "epic.yaml"
139
+ if not epic_yaml.exists():
140
+ continue
141
+ for task_file in sorted(epic_dir.glob("e*[0-9]-tasks.yaml")):
142
+ sid_match = re.match(r"(e\d+s\d+)", task_file.name)
143
+ if not sid_match:
144
+ continue
145
+ sid = sid_match.group(1)
146
+ sstatus = dev_status.get(sid, "backlog")
147
+ if sstatus in ("done", "active") and sid not in tagged_files:
148
+ findings.append({
149
+ "check": "epic-orphan",
150
+ "story_id": sid,
151
+ "severity": "LOW",
152
+ "description": f"Story {sid} has capsule tasks but no story tags found in any code file",
153
+ "remediation": f"Add `// story: {sid}` tags to implementing files"
154
+ })
155
+
156
+ # Check D: stale-tag
157
+ stale_from_matrix: set[str] = set()
158
+ if matrix_data:
159
+ stale_from_matrix = set(matrix_data.get("summary", {}).get("stale_tags", []))
160
+
161
+ for sid in stale_from_matrix:
162
+ findings.append({
163
+ "check": "stale-tag",
164
+ "story_id": sid,
165
+ "severity": "LOW",
166
+ "description": f"Story {sid} is marked done but still has story tags in code",
167
+ "remediation": f"Remove `// story: {sid}` tags from files or confirm the story should remain done"
168
+ })
169
+
170
+ # Check E: double-tag
171
+ for fpath, sids in file_story_counts.items():
172
+ if len(sids) > 1:
173
+ findings.append({
174
+ "check": "double-tag",
175
+ "file": fpath,
176
+ "severity": "MEDIUM",
177
+ "description": f"File '{fpath}' is tagged with multiple stories: {', '.join(sorted(sids))}",
178
+ "remediation": "Review whether this file genuinely implements multiple stories. If it's a shared utility, this is acceptable."
179
+ })
180
+
181
+ # Check F: bootstrap-testless
182
+ for sid in sorted(dev_status.keys()):
183
+ if dev_status[sid] in ("active",):
184
+ if sid in tagged_files:
185
+ code_files = tagged_files[sid]
186
+ code_only = [f for f in code_files if Path(f).suffix in {".sh", ".py", ".js", ".ts", ".jsx", ".tsx"} and "SKILL.md" not in f]
187
+ if code_only and not has_test_files(code_files):
188
+ findings.append({
189
+ "check": "bootstrap-testless",
190
+ "story_id": sid,
191
+ "severity": "HIGH",
192
+ "description": f"Active story {sid} has {len(code_only)} tagged code file(s) but no test files detected",
193
+ "remediation": f"Add test files for: {', '.join(code_only[:3])}"
194
+ })
195
+
196
+ # Deduplicate
197
+ seen: set[tuple] = set()
198
+ deduped: list[dict] = []
199
+ for f in findings:
200
+ key = (f["check"], f.get("story_id", f.get("file", "")))
201
+ if key not in seen:
202
+ seen.add(key)
203
+ deduped.append(f)
204
+ findings = deduped
205
+
206
+ # Emit blind-spots.json
207
+ high_count = sum(1 for f in findings if f["severity"] == "HIGH")
208
+ medium_count = sum(1 for f in findings if f["severity"] == "MEDIUM")
209
+ low_count = sum(1 for f in findings if f["severity"] == "LOW")
210
+
211
+ blind_spots = {
212
+ "generated_at": datetime.now(timezone.utc).isoformat(),
213
+ "version": "1.0",
214
+ "summary": {
215
+ "total_findings": len(findings),
216
+ "high": high_count,
217
+ "medium": medium_count,
218
+ "low": low_count
219
+ },
220
+ "findings": findings
221
+ }
222
+
223
+ BLIND_SPOTS_JSON.parent.mkdir(parents=True, exist_ok=True)
224
+ BLIND_SPOTS_JSON.write_text(json.dumps(blind_spots, indent=2), encoding="utf-8")
225
+
226
+ print(f"check-blind-spots.sh: {len(findings)} findings ({high_count} HIGH, {medium_count} MEDIUM, {low_count} LOW)")
227
+ for f in findings:
228
+ print(f" [{f['severity']}] {f['check']}: {f['description'][:100]}")
229
+
230
+ if high_count > 0:
231
+ print(f"\n⚠️ {high_count} HIGH-severity finding(s) — review specs/blind-spots.json")
232
+ sys.exit(1)
233
+
234
+ sys.exit(0)
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # story: e40s04
3
+ # git-hours.sh — deterministic, additive git-hours effort engine.
4
+ # Source this file in scripts that compute effort from commit history.
5
+ # Requires: IDLE_SECONDS, PAD_SECONDS, ROOT set before sourcing.
6
+ #
7
+ # Model (kimmobrunfeldt/git-hours):
8
+ # * commits sorted by author-date across the WHOLE range;
9
+ # * gap < IDLE -> real elapsed time, credited to later commit's story;
10
+ # * gap >= IDLE -> session boundary: credit PAD to later commit's story;
11
+ # * first commit never padded.
12
+ #
13
+ # partition() emits per-story TSV: story \t effort_hours \t commits \t first_epoch \t last_epoch
14
+ # whole_range_hours() emits a single float: whole-range effort (additivity oracle)
15
+
16
+ partition() {
17
+ local range="$1" key="$2"
18
+ git -C "$ROOT" log --no-merges "$range" \
19
+ --pretty=format:"%at%x1f%H%x1f%(trailers:key=${key},valueonly,separator=%x2C)" \
20
+ | sort -n \
21
+ | awk -F '\x1f' -v IDLE="$IDLE_SECONDS" -v PAD="$PAD_SECONDS" '
22
+ {
23
+ ts = $1 + 0; story = $3
24
+ gsub(/[[:space:]]/, "", story)
25
+ if (story == "") story = "unattributed"
26
+ count[story]++; seen[story] = 1
27
+ if (NR > 1) {
28
+ gap = ts - prev
29
+ if (gap < 0) gap = 0
30
+ if (gap < IDLE) eff[story] += gap
31
+ else eff[story] += PAD
32
+ }
33
+ if (!(story in first)) first[story] = ts
34
+ last[story] = ts; prev = ts
35
+ }
36
+ END {
37
+ for (s in seen)
38
+ printf "%s\t%.4f\t%d\t%d\t%d\n", s, eff[s] / 3600.0, count[s], first[s], last[s]
39
+ }' \
40
+ | sort
41
+ }
42
+
43
+ whole_range_hours() {
44
+ local range="$1"
45
+ git -C "$ROOT" log --no-merges "$range" --pretty=format:"%at" \
46
+ | sort -n \
47
+ | awk -v IDLE="$IDLE_SECONDS" -v PAD="$PAD_SECONDS" '
48
+ { ts=$1+0; if(NR>1){gap=ts-prev; if(gap<0)gap=0; if(gap<IDLE)e+=gap; else e+=PAD} prev=ts }
49
+ END { printf "%.4f\n", e/3600.0 }'
50
+ }
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env python3
2
+ """story: e38s01 — deterministic spec-to-code coverage matrix builder (Python engine).
3
+
4
+ Parses release-plan.yaml + execution-status.yaml, greps codebase for story tags,
5
+ builds oracle-tiered coverage matrix, emits JSON + markdown + OKF wiki.
6
+
7
+ Usage: called by scripts/trace-stories.sh with positional args:
8
+ python3 scripts/lib/trace-stories.py <repo_root> <matrix_json> <trace_md>
9
+ <okf_dir> <strict> <mode>
10
+
11
+ Oracle tiers (TEA-inspired):
12
+ Tier 1: explicit story tag → confidence high
13
+ Tier 2: file-name heuristic match → confidence medium
14
+ Tier 3: epic capsule task reference → confidence low
15
+ """
16
+
17
+ import json, os, re, subprocess, sys
18
+ from pathlib import Path
19
+ from datetime import datetime, timezone
20
+
21
+ ROOT = Path(sys.argv[1])
22
+ MATRIX_JSON = Path(sys.argv[2])
23
+ TRACE_MD = Path(sys.argv[3])
24
+ OKF_DIR = Path(sys.argv[4])
25
+ STRICT = int(sys.argv[5])
26
+ MODE = sys.argv[6]
27
+
28
+ # -----------------------------------------------------------------------
29
+ # 1. Parse release-plan.yaml → story inventory
30
+ # -----------------------------------------------------------------------
31
+ def parse_simple_yaml(text: str) -> dict:
32
+ """Parse flat and one-level-nested YAML including lists of objects."""
33
+ root: dict = {}
34
+ stack: list = [(0, root, None, None)]
35
+ skip_until_indent = None
36
+ for i, raw in enumerate(text.splitlines()):
37
+ stripped = raw.strip()
38
+ if not stripped or stripped.startswith("#"):
39
+ continue
40
+ indent = len(raw) - len(raw.lstrip())
41
+ if skip_until_indent is not None:
42
+ if indent > skip_until_indent:
43
+ continue
44
+ else:
45
+ skip_until_indent = None
46
+ while len(stack) > 1:
47
+ if indent < stack[-1][0]:
48
+ stack.pop()
49
+ elif indent == stack[-1][0]:
50
+ if isinstance(stack[-1][1], list) and stripped.startswith("- "):
51
+ break
52
+ elif isinstance(stack[-1][1], dict) and stripped.startswith("- "):
53
+ entry_container = stack[-1][1]
54
+ entry_parent = stack[-1][2]
55
+ entry_key = stack[-1][3]
56
+ if entry_parent is not None and entry_key is not None:
57
+ if not isinstance(entry_parent.get(entry_key), list):
58
+ entry_parent[entry_key] = []
59
+ stack[-1] = (stack[-1][0], entry_parent[entry_key], entry_parent, entry_key)
60
+ break
61
+ stack.pop()
62
+ else:
63
+ stack.pop()
64
+ else:
65
+ break
66
+ container = stack[-1][1]
67
+ parent_dict = stack[-1][2]
68
+ key_in_parent = stack[-1][3]
69
+ if stripped.startswith("- "):
70
+ inner = stripped[2:]
71
+ if isinstance(container, dict) and parent_dict is not None and key_in_parent is not None:
72
+ if not isinstance(parent_dict.get(key_in_parent), list):
73
+ orig_indent = stack[-1][0]
74
+ parent_dict[key_in_parent] = []
75
+ container = parent_dict[key_in_parent]
76
+ stack[-1] = (orig_indent, container, parent_dict, key_in_parent)
77
+ if ":" in inner:
78
+ k, _, v = inner.partition(":")
79
+ k = k.strip(); v = v.strip()
80
+ if v in ("|", ">", "|-", ">-", "|+", ">+"):
81
+ item = {k: v}; container.append(item)
82
+ skip_until_indent = indent; continue
83
+ item = {k: _yaml_scalar(v) if v else None}
84
+ container.append(item)
85
+ if v == "":
86
+ stack.append((indent, item, container, k))
87
+ else:
88
+ container.append(_yaml_scalar(inner))
89
+ continue
90
+ if ":" not in stripped:
91
+ continue
92
+ key, _, val = stripped.partition(":")
93
+ key = key.strip(); val = val.strip()
94
+ if val in ("|", ">", "|-", ">-", "|+", ">+"):
95
+ if isinstance(container, list) and container:
96
+ container[-1][key] = val
97
+ elif isinstance(container, dict):
98
+ container[key] = val
99
+ skip_until_indent = indent; continue
100
+ if isinstance(container, list) and container:
101
+ container[-1][key] = _yaml_scalar(val) if val else None
102
+ if val == "":
103
+ nxt = {}; container[-1][key] = nxt
104
+ stack.append((indent, nxt, container[-1], key))
105
+ elif isinstance(container, dict):
106
+ if val == "":
107
+ nxt = {}; container[key] = nxt
108
+ stack.append((indent, nxt, container, key))
109
+ else:
110
+ container[key] = _yaml_scalar(val)
111
+ return root
112
+
113
+ def _yaml_scalar(val: str):
114
+ val = val.strip('"').strip("'")
115
+ if val in ("true", "false"): return val == "true"
116
+ if val in ("null", "~", ""): return None
117
+ try: return int(val)
118
+ except ValueError:
119
+ try: return float(val)
120
+ except ValueError: return val
121
+
122
+ release_text = (ROOT / "specs" / "release-plan.yaml").read_text(encoding="utf-8")
123
+ release = parse_simple_yaml(release_text)
124
+ exec_text = (ROOT / "specs" / "execution-status.yaml").read_text(encoding="utf-8")
125
+ exec_status = parse_simple_yaml(exec_text)
126
+ dev_status = exec_status.get("development_status", {})
127
+
128
+ # -----------------------------------------------------------------------
129
+ # 2. Build story inventory
130
+ # -----------------------------------------------------------------------
131
+ stories: dict[str, dict] = {}
132
+ epics = release.get("epics", [])
133
+ if not isinstance(epics, list):
134
+ epics = []
135
+ for epic in epics:
136
+ if not isinstance(epic, dict):
137
+ continue
138
+ eid = epic.get("id", ""); ebcp = epic.get("bcps", 0)
139
+ etitle = epic.get("title", ""); ewsjf = epic.get("wsjf", 0)
140
+ capsule_dir = epic.get("capsule_dir", "")
141
+ if capsule_dir:
142
+ capsule_path = ROOT / "specs" / capsule_dir / "epic.yaml"
143
+ if capsule_path.exists():
144
+ cap = parse_simple_yaml(capsule_path.read_text(encoding="utf-8"))
145
+ for s in cap.get("stories", []) or []:
146
+ if isinstance(s, dict):
147
+ sid = s.get("id", "")
148
+ stories[sid] = {"id": sid, "title": s.get("title", ""),
149
+ "epic_id": eid, "epic_title": etitle,
150
+ "bcp": s.get("bcp", 0), "wsjf": float(ewsjf),
151
+ "description": s.get("description", "")}
152
+ file_key = epic.get("file", "")
153
+ if file_key and not capsule_dir:
154
+ legacy_path = ROOT / "specs" / file_key
155
+ if legacy_path.exists():
156
+ leg = parse_simple_yaml(legacy_path.read_text(encoding="utf-8"))
157
+ for s in leg.get("stories", []) or []:
158
+ if isinstance(s, dict):
159
+ sid = s.get("id", "")
160
+ stories[sid] = {"id": sid, "title": s.get("title", ""),
161
+ "epic_id": eid, "epic_title": etitle,
162
+ "bcp": s.get("bcp", 0), "wsjf": float(ewsjf),
163
+ "description": s.get("description", "")}
164
+
165
+ # -----------------------------------------------------------------------
166
+ # 3. Grep codebase for story tags
167
+ # -----------------------------------------------------------------------
168
+ result = subprocess.run(
169
+ ["grep", "-rn", "--include=*.md", "--include=*.sh", "--include=*.py",
170
+ "--include=*.js", "--include=*.ts", "--include=*.yaml", "--include=*.yml",
171
+ "-E", r"story:\s*e\d{2}s\d{2}", str(ROOT)],
172
+ capture_output=True, text=True, cwd=str(ROOT))
173
+
174
+ tag_inventory: list[dict] = []
175
+ tagged_sids: set[str] = set()
176
+ tag_index: dict[str, list] = {}
177
+ for line in result.stdout.splitlines():
178
+ m = re.match(r"^(.+?):(\d+):(.*story:\s*(e\d{2}s\d{2}).*)$", line)
179
+ if m:
180
+ fpath = m.group(1); fline = int(m.group(2)); sid = m.group(4)
181
+ try: fpath = str(Path(fpath).relative_to(ROOT))
182
+ except ValueError: pass
183
+ tag_inventory.append({"file": fpath, "line": fline, "story_id": sid})
184
+ tagged_sids.add(sid)
185
+ tag_index.setdefault(sid, []).append({"file": fpath, "line": fline})
186
+
187
+ # -----------------------------------------------------------------------
188
+ # 4. Oracle tiers
189
+ # -----------------------------------------------------------------------
190
+ all_files = []
191
+ for root_dir, dirs, files in os.walk(str(ROOT)):
192
+ dirs[:] = [d for d in dirs if d not in (".git", "node_modules", ".cursor")]
193
+ for f in files:
194
+ all_files.append(str(Path(root_dir, f).relative_to(ROOT)))
195
+
196
+ def slugify(text: str) -> str:
197
+ text = re.sub(r"[^a-zA-Z0-9\s-]", "", text.lower())
198
+ return re.sub(r"\s+", "-", text.strip())
199
+
200
+ def heuristic_match(story_title: str, file_path: str) -> bool:
201
+ slug = slugify(story_title)
202
+ words = slug.split("-")
203
+ fname = Path(file_path).stem.lower()
204
+ sig_words = [w for w in words if len(w) > 2]
205
+ if len(sig_words) < 2: sig_words = words
206
+ matches = sum(1 for w in sig_words if w in fname)
207
+ return matches >= min(2, len(sig_words))
208
+
209
+ def find_task_references(story_id: str) -> list[dict]:
210
+ refs = []
211
+ epics_dir = ROOT / "specs" / "epics"
212
+ if not epics_dir.exists(): return refs
213
+ for task_yaml in epics_dir.rglob("*tasks.yaml"):
214
+ try:
215
+ content = task_yaml.read_text(encoding="utf-8")
216
+ if f"story_id: {story_id}" in content or story_id in content:
217
+ refs.append({"file": str(task_yaml.relative_to(ROOT)), "type": "task_yaml"})
218
+ except Exception: pass
219
+ return refs
220
+
221
+ matrix_stories = []
222
+ dark_stories = []
223
+ for sid, sinfo in sorted(stories.items()):
224
+ links = []
225
+ sid_status = dev_status.get(sid, "backlog")
226
+ if sid in tag_index:
227
+ for t in tag_index[sid]:
228
+ links.append({"file": t["file"], "line": t["line"], "confidence": "high", "method": "explicit_tag"})
229
+ for fpath in all_files:
230
+ if heuristic_match(sinfo["title"], fpath):
231
+ if not any(l["file"] == fpath for l in links):
232
+ links.append({"file": fpath, "line": 0, "confidence": "medium", "method": "file_heuristic"})
233
+ for tr in find_task_references(sid):
234
+ existing = {l["file"] for l in links}
235
+ if tr["file"] not in existing:
236
+ links.append({"file": tr["file"], "line": 0, "confidence": "low", "method": "task_reference"})
237
+ matrix_stories.append({
238
+ "id": sid, "title": sinfo["title"], "epic_id": sinfo["epic_id"],
239
+ "epic_title": sinfo["epic_title"], "bcp": sinfo["bcp"], "wsjf": sinfo["wsjf"],
240
+ "status": sid_status, "links": links, "link_count": len(links)
241
+ })
242
+ if len(links) == 0 and sid_status != "backlog":
243
+ dark_stories.append(sid)
244
+
245
+ orphan_tags = [sid for sid in sorted(tagged_sids) if sid not in stories]
246
+ stale_tags = [sid for sid in sorted(tagged_sids) if sid in stories and dev_status.get(sid) == "done"]
247
+
248
+ # -----------------------------------------------------------------------
249
+ # 5. Emit matrix JSON
250
+ # -----------------------------------------------------------------------
251
+ matrix = {
252
+ "generated_at": datetime.now(timezone.utc).isoformat(),
253
+ "matrix_version": "1.0",
254
+ "stories": matrix_stories,
255
+ "summary": {
256
+ "total_stories": len(stories),
257
+ "tagged_stories": len(tagged_sids & set(stories.keys())),
258
+ "dark_stories": dark_stories, "dark_count": len(dark_stories),
259
+ "orphan_tags": orphan_tags, "orphan_count": len(orphan_tags),
260
+ "stale_tags": stale_tags, "stale_count": len(stale_tags),
261
+ "oracle_stats": {
262
+ "high": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "high"),
263
+ "medium": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "medium"),
264
+ "low": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "low")
265
+ }
266
+ }
267
+ }
268
+ MATRIX_JSON.parent.mkdir(parents=True, exist_ok=True)
269
+ MATRIX_JSON.write_text(json.dumps(matrix, indent=2), encoding="utf-8")
270
+
271
+ # -----------------------------------------------------------------------
272
+ # 6. Emit TRACEABILITY_LATEST.md
273
+ # -----------------------------------------------------------------------
274
+ lines = ["# Traceability Matrix", "",
275
+ f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
276
+ f"**Total stories:** {len(stories)}",
277
+ f"**Tagged stories:** {len(tagged_sids & set(stories.keys()))}",
278
+ f"**Dark stories:** {len(dark_stories)}",
279
+ f"**Orphan tags:** {len(orphan_tags)}",
280
+ f"**Stale tags:** {len(stale_tags)}",
281
+ "", "## Oracle Stats", "",
282
+ f"- **High** (explicit tag): {matrix['summary']['oracle_stats']['high']}",
283
+ f"- **Medium** (file heuristic): {matrix['summary']['oracle_stats']['medium']}",
284
+ f"- **Low** (task reference): {matrix['summary']['oracle_stats']['low']}",
285
+ "", "## Story Coverage", "",
286
+ "| Story | Title | Epic | BCP | WSJF | Status | Links |",
287
+ "|-------|-------|------|-----|------|--------|-------|"]
288
+ for s in matrix_stories:
289
+ lines.append(f"| {s['id']} | {s['title'][:60]} | {s['epic_id']} | {s['bcp']} | {s['wsjf']} | {s['status']} | {s['link_count']} |")
290
+ lines.append("")
291
+ if dark_stories:
292
+ lines.append("## Dark Stories (no code links)\n")
293
+ for ds in dark_stories:
294
+ si = stories.get(ds, {})
295
+ lines.append(f"- **{ds}**: {si.get('title', 'Unknown')} (status: {dev_status.get(ds, '?')})")
296
+ lines.append("")
297
+ if orphan_tags:
298
+ lines.append("## Orphan Tags (tag in code, no matching story)\n")
299
+ for ot in orphan_tags: lines.append(f"- `{ot}`")
300
+ lines.append("")
301
+ if stale_tags:
302
+ lines.append("## Stale Tags (story done, tag still in code)\n")
303
+ for st in stale_tags: lines.append(f"- `{st}`")
304
+ lines.append("")
305
+ TRACE_MD.parent.mkdir(parents=True, exist_ok=True)
306
+ TRACE_MD.write_text("\n".join(lines), encoding="utf-8")
307
+
308
+ # -----------------------------------------------------------------------
309
+ # 7. Emit OKF bundle (specs/codebase-wiki/)
310
+ # -----------------------------------------------------------------------
311
+ OKF_DIR.mkdir(parents=True, exist_ok=True)
312
+ idx_lines = ["---", "type: Index",
313
+ f"generated_at: {datetime.now(timezone.utc).isoformat()}",
314
+ f"total_concepts: {len(stories)}", "---", "",
315
+ "# Codebase Wiki — Story Traceability", "",
316
+ "Auto-generated OKF bundle from trace-stories.sh.", "",
317
+ "| Story | Title | Confidence | Links |",
318
+ "|-------|-------|------------|-------|"]
319
+ for s in matrix_stories:
320
+ confs = {l["confidence"] for l in s["links"]}
321
+ mc = "high" if "high" in confs else ("medium" if "medium" in confs else ("low" if confs else "none"))
322
+ idx_lines.append(f"| [{s['id']}](./{s['id']}.md) | {s['title'][:60]} | {mc} | {s['link_count']} |")
323
+ idx_lines.append("")
324
+ (OKF_DIR / "index.md").write_text("\n".join(idx_lines), encoding="utf-8")
325
+ for s in matrix_stories:
326
+ concept = ["---", f"type: Story", f"id: {s['id']}", f"epic: {s['epic_id']}",
327
+ f"bcps: {s['bcp']}", f"wsjf: {s['wsjf']}",
328
+ f"implementation_status: {s['status']}"]
329
+ max_confs = {l["confidence"] for l in s["links"]}
330
+ conf = "high" if "high" in max_confs else ("medium" if "medium" in max_confs else ("low" if max_confs else "none"))
331
+ concept.extend([f"coverage_status: {'covered' if s['link_count'] > 0 else 'dark'}",
332
+ f"confidence: {conf}", "links:"])
333
+ for l in s["links"]:
334
+ concept.extend([f" - file: {l['file']}", f" line: {l['line']}",
335
+ f" confidence: {l['confidence']}", f" method: {l['method']}"])
336
+ concept.extend(["---", "", f"# {s['id']}: {s['title']}", "",
337
+ f"**Epic:** {s['epic_id']} — {s.get('epic_title', 'Unknown')}",
338
+ f"**Status:** {s['status']}",
339
+ f"**BCP:** {s['bcp']} | **WSJF:** {s['wsjf']}", ""])
340
+ if s["links"]:
341
+ concept.append("## Implemented In\n")
342
+ for l in s["links"]:
343
+ concept.append(f"- `{l['file']}` (line {l['line']}, confidence: {l['confidence']}, method: {l['method']})")
344
+ else:
345
+ concept.append("*No code links found — this is a dark story.*")
346
+ concept.append("")
347
+ (OKF_DIR / f"{s['id']}.md").write_text("\n".join(concept), encoding="utf-8")
348
+
349
+ # -----------------------------------------------------------------------
350
+ # 8. Strict mode
351
+ # -----------------------------------------------------------------------
352
+ if STRICT:
353
+ if matrix_stories:
354
+ wsjf_sorted = sorted(set(s["wsjf"] for s in matrix_stories), reverse=True)
355
+ cutoff_idx = max(0, len(wsjf_sorted) // 4 - 1)
356
+ p0_threshold = wsjf_sorted[cutoff_idx] if cutoff_idx < len(wsjf_sorted) else 0
357
+ uncovered_p0 = [s["id"] for s in matrix_stories if s["wsjf"] >= p0_threshold and len(s["links"]) == 0]
358
+ if uncovered_p0:
359
+ print(f"trace-stories.sh: STRICT FAIL — P0 stories with 0% coverage: {', '.join(uncovered_p0)}", file=sys.stderr)
360
+ sys.exit(2)
361
+
362
+ sys.exit(0)