bigpowers 2.56.1 → 2.57.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.56.1",
3
+ "version": "2.57.0",
4
4
  "description": "73 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.57.0](https://github.com/danielvm-git/bigpowers/compare/v2.56.1...v2.57.0) (2026-07-03)
2
+
3
+
4
+ ### Features
5
+
6
+ * **compliance:** add waiver subsystem to audit-compliance.sh ([985b67d](https://github.com/danielvm-git/bigpowers/commit/985b67dcaf8df61108d372a0572ffced99f5af29))
7
+
1
8
  ## [2.56.1](https://github.com/danielvm-git/bigpowers/compare/v2.56.0...v2.56.1) (2026-07-03)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.56.1",
3
+ "version": "2.57.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": {
@@ -67,6 +67,43 @@ fi
67
67
  # --- Global Stats ---
68
68
  TOTAL_GLOBAL_PASS=0
69
69
  TOTAL_GLOBAL_FAIL=0
70
+ TOTAL_GLOBAL_WAIVED=0
71
+ TOTAL_GLOBAL_EXPIRED=0
72
+
73
+ # --- Waiver Subsystem ---
74
+ # Loads waivers.yaml and provides is_waived() for bash 3.2+ compatibility.
75
+ # Waived steps are excluded from the denominator; expired waivers re-enter as FAILs.
76
+ WAIVER_FILE="specs/verifications/waivers.yaml"
77
+
78
+ is_waived() {
79
+ local step_sanitized="$1"
80
+ local waiver_file="${WAIVER_FILE:-specs/verifications/waivers.yaml}"
81
+
82
+ if [[ ! -f "$waiver_file" ]]; then
83
+ return 1 # No waiver file → not waived
84
+ fi
85
+
86
+ # Check if step has any waiver entry
87
+ if ! grep -q "step_sanitized: $step_sanitized" "$waiver_file" 2>/dev/null; then
88
+ return 1 # Not found → not waived
89
+ fi
90
+
91
+ # Extract review_date for this step (parses the YAML block)
92
+ local review_date
93
+ review_date=$(awk -v slug="$step_sanitized" '
94
+ $0 ~ "step_sanitized: " slug { found=1; next }
95
+ found && /review_date:/ { gsub(/[^0-9-]/, "", $0); print; exit }
96
+ ' "$waiver_file")
97
+
98
+ # Check expiry: if review_date < today, waiver is expired
99
+ local today
100
+ today=$(date +%Y-%m-%d)
101
+ if [[ -n "$review_date" && "$review_date" < "$today" ]]; then
102
+ return 2 # Expired waiver → counts as FAIL
103
+ fi
104
+
105
+ return 0 # Valid waiver → excluded from denominator
106
+ }
70
107
 
71
108
  # --- Judging Logic ---
72
109
 
@@ -150,7 +187,7 @@ process_step() {
150
187
  local scenario_name="$3"
151
188
  local report_file="$4"
152
189
  echo " [STEP] $step"
153
-
190
+
154
191
  if [[ "$DRY_RUN" == "true" ]]; then
155
192
  return 0
156
193
  fi
@@ -167,12 +204,44 @@ process_step() {
167
204
 
168
205
  if [[ "$JUDGE" == "gemini" ]]; then
169
206
  judge_with_gemini "$step" "$feature_name" "$scenario_name" "$evidence" "$report_file"
207
+ # Gemini path: rely on judge return code (no waiver check for LLM path)
170
208
  else
171
- judge_binary "$step" "$exit_code" "$evidence" "$report_file"
209
+ # Binary path: check waivers before counting as FAIL
210
+ if [[ $exit_code -eq 0 ]]; then
211
+ echo " Result: PASS"
212
+ echo "- [x] $step (PASS)" >> "$report_file"
213
+ return 0
214
+ fi
215
+
216
+ # Step failed — check if it has a valid waiver
217
+ is_waived "$sanitized_step"
218
+ local waiver_code=$?
219
+
220
+ if [[ $waiver_code -eq 0 ]]; then
221
+ # Valid waiver — exclude from denominator
222
+ echo " Result: WAIVED (documented exception)"
223
+ echo "- [~] $step (WAIVED)" >> "$report_file"
224
+ ((TOTAL_GLOBAL_WAIVED++))
225
+ return 3 # Special code: waived (not PASS, not FAIL)
226
+ elif [[ $waiver_code -eq 2 ]]; then
227
+ # Expired waiver — re-enter denominator as FAIL
228
+ echo " Result: FAIL (EXPIRED WAIVER)"
229
+ echo "- [ ] $step (FAIL) - EXPIRED WAIVER: review_date has passed" >> "$report_file"
230
+ ((TOTAL_GLOBAL_EXPIRED++))
231
+ ((TOTAL_GLOBAL_FAIL++))
232
+ return 1
233
+ else
234
+ # No waiver — genuine FAIL
235
+ echo " Result: FAIL"
236
+ echo "- [ ] $step (FAIL) - $evidence" >> "$report_file"
237
+ ((TOTAL_GLOBAL_FAIL++))
238
+ return 1
239
+ fi
172
240
  fi
173
241
  else
174
242
  echo " Result: FAIL (Missing evidence: $step_script)"
175
243
  echo "- [ ] $step (FAIL) - No verification script found at $step_script" >> "$report_file"
244
+ ((TOTAL_GLOBAL_FAIL++))
176
245
  return 1
177
246
  fi
178
247
  }
@@ -214,12 +283,17 @@ run_audit_file() {
214
283
  echo "### Scenario: $CURRENT_SCENARIO" >> "$REPORT_FILE"
215
284
  elif [[ "$line" =~ ^(Given|When|Then|And|But)\ ]]; then
216
285
  if [[ "$IN_SCENARIO" == "true" ]]; then
217
- if process_step "$line" "$CURRENT_FEATURE" "$CURRENT_SCENARIO" "$REPORT_FILE"; then
286
+ local step_result
287
+ process_step "$line" "$CURRENT_FEATURE" "$CURRENT_SCENARIO" "$REPORT_FILE"
288
+ step_result=$?
289
+ if [[ $step_result -eq 3 ]]; then
290
+ # Waived — excluded from both pass and fail counts
291
+ :
292
+ elif [[ $step_result -eq 0 ]]; then
218
293
  ((TOTAL_PASS++))
219
294
  ((TOTAL_GLOBAL_PASS++))
220
295
  else
221
296
  ((TOTAL_FAIL++))
222
- ((TOTAL_GLOBAL_FAIL++))
223
297
  fi
224
298
  fi
225
299
  fi
@@ -247,15 +321,23 @@ done
247
321
 
248
322
  echo "============================================================"
249
323
  echo "Global Audit Summary:"
250
- echo " TOTAL PASS: $TOTAL_GLOBAL_PASS"
251
- echo " TOTAL FAIL: $TOTAL_GLOBAL_FAIL"
252
324
  TOTAL_GLOBAL_ALL=$((TOTAL_GLOBAL_PASS + TOTAL_GLOBAL_FAIL))
325
+ echo " TOTAL PASS: $TOTAL_GLOBAL_PASS"
326
+ echo " TOTAL FAIL: $TOTAL_GLOBAL_FAIL"
327
+ echo " TOTAL WAIVED: $TOTAL_GLOBAL_WAIVED (excluded from denominator)"
328
+ if [[ $TOTAL_GLOBAL_EXPIRED -gt 0 ]]; then
329
+ echo " EXPIRED WAIVERS: $TOTAL_GLOBAL_EXPIRED (counted as FAIL)"
330
+ fi
253
331
  if [[ $TOTAL_GLOBAL_ALL -gt 0 ]]; then
254
332
  SCORE=$(awk "BEGIN { printf \"%d\", $TOTAL_GLOBAL_PASS * 100 / $TOTAL_GLOBAL_ALL }")
255
333
  else
256
334
  SCORE=0
257
335
  fi
258
- echo " SCORE: ${SCORE}% (threshold 94%)"
336
+ if [[ $TOTAL_GLOBAL_WAIVED -gt 0 ]]; then
337
+ echo " SCORE: ${SCORE}% (of ${TOTAL_GLOBAL_ALL} unwaived checks, threshold 94%)"
338
+ else
339
+ echo " SCORE: ${SCORE}% (threshold 94%)"
340
+ fi
259
341
  if [[ $SCORE -ge 94 ]]; then
260
342
  echo " GATE: PASS"
261
343
  exit 0
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env python3
2
+ # story: e38s04
3
+ # blind-spot-checks.py — TEA-inspired heuristic blind-spot detector (Python engine).
4
+ # Called by scripts/check-blind-spots.sh. Not meant to be run standalone without args.
5
+
6
+ import json, os, re, sys
7
+ from pathlib import Path
8
+ from datetime import datetime, timezone
9
+
10
+ ROOT = Path(sys.argv[1])
11
+ BLIND_SPOTS_JSON = Path(sys.argv[2])
12
+ EXEC_STATUS = Path(sys.argv[3])
13
+ MATRIX_JSON = Path(sys.argv[4])
14
+ VERIFICATIONS_DIR = Path(sys.argv[5])
15
+ EPICS_DIR = Path(sys.argv[6])
16
+
17
+ findings: list[dict] = []
18
+
19
+ # Parse execution-status.yaml → flat dict of story_id → status
20
+ def parse_exec_status(path: Path) -> dict[str, str]:
21
+ status: dict[str, str] = {}
22
+ if not path.exists():
23
+ return status
24
+ text = path.read_text(encoding="utf-8")
25
+ lines = text.splitlines()
26
+ in_dev_status = False
27
+ for line in lines:
28
+ stripped = line.strip()
29
+ if stripped.startswith("development_status:"):
30
+ in_dev_status = True
31
+ continue
32
+ if not in_dev_status:
33
+ if stripped and not stripped.startswith(" ") and not stripped.startswith("#"):
34
+ if ":" in stripped:
35
+ in_dev_status = False
36
+ continue
37
+ if ":" in stripped and not stripped.startswith("#"):
38
+ m = re.match(r'\s+(\S+):\s*"([^"]*)"', stripped)
39
+ if m:
40
+ status[m.group(1)] = m.group(2)
41
+ return status
42
+
43
+ dev_status = parse_exec_status(EXEC_STATUS)
44
+
45
+ # Read traceability matrix (if available)
46
+ matrix_data: dict | None = None
47
+ if MATRIX_JSON.exists():
48
+ matrix_data = json.loads(MATRIX_JSON.read_text(encoding="utf-8"))
49
+
50
+ # Build indexes from matrix
51
+ tagged_files: dict[str, list[str]] = {}
52
+ file_story_counts: dict[str, set[str]] = {}
53
+ if matrix_data:
54
+ for s in matrix_data.get("stories", []):
55
+ sid = s["id"]
56
+ for link in s.get("links", []):
57
+ f = link["file"]
58
+ tagged_files.setdefault(sid, []).append(f)
59
+ file_story_counts.setdefault(f, set()).add(sid)
60
+
61
+ # Enumerate all tracked code files
62
+ CODE_EXTENSIONS = {".sh", ".py", ".js", ".ts", ".jsx", ".tsx", ".md", ".yaml", ".yml", ".cjs", ".mjs", ".json"}
63
+ SKIP_DIRS = {".git", "node_modules", ".cursor", ".gemini", ".pi", "specs", "dist", "build", "__pycache__", ".bigpowers"}
64
+
65
+ def is_code_file(p: Path) -> bool:
66
+ return p.suffix in CODE_EXTENSIONS and not any(s in p.parts for s in SKIP_DIRS)
67
+
68
+ all_code_files: list[str] = []
69
+ for root, dirs, files in os.walk(str(ROOT)):
70
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
71
+ rel_root = str(Path(root).relative_to(ROOT))
72
+ for fname in files:
73
+ fpath = Path(root) / fname
74
+ if is_code_file(fpath):
75
+ all_code_files.append(str(fpath.relative_to(ROOT)))
76
+
77
+ # Helper: find test files for a given code file
78
+ TEST_PATTERNS = [
79
+ re.compile(r"(^|/)test(s)?/", re.IGNORECASE),
80
+ re.compile(r"(^|/)__tests__/", re.IGNORECASE),
81
+ re.compile(r"\.test\.", re.IGNORECASE),
82
+ re.compile(r"\.spec\.", re.IGNORECASE),
83
+ re.compile(r"_test\.", re.IGNORECASE),
84
+ re.compile(r"test_", re.IGNORECASE),
85
+ ]
86
+
87
+ def has_test_files(code_files: list[str]) -> bool:
88
+ for cf in code_files:
89
+ cf_path = Path(cf)
90
+ cf_dir = cf_path.parent
91
+ cf_stem = cf_path.stem
92
+ for tf in all_code_files:
93
+ tp = Path(tf)
94
+ if tp.parent == cf_dir:
95
+ for pat in TEST_PATTERNS:
96
+ if pat.search(tp.name) and cf_stem in tp.name:
97
+ return True
98
+ for tdn in ["tests", "test", "__tests__"]:
99
+ test_dir = cf_dir / tdn
100
+ if str(test_dir) in tf:
101
+ if cf_stem in tp.name:
102
+ return True
103
+ return False
104
+
105
+ # Check A: verify-gap
106
+ for sid, sstatus in dev_status.items():
107
+ if sstatus == "done":
108
+ verify_file = VERIFICATIONS_DIR / f"{sid}-verify.yaml"
109
+ if not verify_file.exists():
110
+ findings.append({
111
+ "check": "verify-gap", "story_id": sid, "severity": "HIGH",
112
+ "description": f"Story {sid} is marked done but no verification evidence at specs/verifications/{sid}-verify.yaml",
113
+ "remediation": f"Run verify-work for {sid} or create the verification evidence file"
114
+ })
115
+
116
+ # Check B: test-gap
117
+ for sid, files in tagged_files.items():
118
+ if dev_status.get(sid) in ("done", "active"):
119
+ if not has_test_files(files):
120
+ code_only = [f for f in files if Path(f).suffix in {".sh", ".py", ".js", ".ts", ".jsx", ".tsx"} and "SKILL.md" not in f]
121
+ if code_only:
122
+ findings.append({
123
+ "check": "test-gap", "story_id": sid, "severity": "MEDIUM",
124
+ "description": f"Story {sid} has {len(code_only)} tagged code file(s) with no matching test files",
125
+ "remediation": f"Add test coverage for: {', '.join(code_only[:3])}"
126
+ })
127
+
128
+ # Check C: epic-orphan
129
+ if EPICS_DIR.exists():
130
+ for epic_dir in sorted(EPICS_DIR.iterdir()):
131
+ if not epic_dir.is_dir() or epic_dir.name == "archive":
132
+ continue
133
+ epic_yaml = epic_dir / "epic.yaml"
134
+ if not epic_yaml.exists():
135
+ continue
136
+ for task_file in sorted(epic_dir.glob("e*[0-9]-tasks.yaml")):
137
+ sid_match = re.match(r"(e\d+s\d+)", task_file.name)
138
+ if not sid_match:
139
+ continue
140
+ sid = sid_match.group(1)
141
+ sstatus = dev_status.get(sid, "backlog")
142
+ if sstatus in ("done", "active") and sid not in tagged_files:
143
+ findings.append({
144
+ "check": "epic-orphan", "story_id": sid, "severity": "LOW",
145
+ "description": f"Story {sid} has capsule tasks but no story tags found in any code file",
146
+ "remediation": f"Add `// story: {sid}` tags to implementing files"
147
+ })
148
+
149
+ # Check D: stale-tag
150
+ stale_from_matrix: set[str] = set()
151
+ if matrix_data:
152
+ stale_from_matrix = set(matrix_data.get("summary", {}).get("stale_tags", []))
153
+ for sid in stale_from_matrix:
154
+ findings.append({
155
+ "check": "stale-tag", "story_id": sid, "severity": "LOW",
156
+ "description": f"Story {sid} is marked done but still has story tags in code",
157
+ "remediation": f"Remove `// story: {sid}` tags or confirm the story should remain done"
158
+ })
159
+
160
+ # Check E: double-tag
161
+ for fpath, sids in file_story_counts.items():
162
+ if len(sids) > 1:
163
+ findings.append({
164
+ "check": "double-tag", "file": fpath, "severity": "MEDIUM",
165
+ "description": f"File '{fpath}' is tagged with multiple stories: {', '.join(sorted(sids))}",
166
+ "remediation": "Review whether this file genuinely implements multiple stories."
167
+ })
168
+
169
+ # Check F: bootstrap-testless
170
+ active_and_recent = [sid for sid in sorted(dev_status.keys()) if dev_status[sid] == "active"]
171
+ for sid in active_and_recent:
172
+ if sid in tagged_files:
173
+ code_files = tagged_files[sid]
174
+ 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]
175
+ if code_only and not has_test_files(code_files):
176
+ findings.append({
177
+ "check": "bootstrap-testless", "story_id": sid, "severity": "HIGH",
178
+ "description": f"Active story {sid} has {len(code_only)} tagged code file(s) but no test files detected",
179
+ "remediation": f"Add test files for: {', '.join(code_only[:3])}"
180
+ })
181
+
182
+ # De-duplicate findings
183
+ seen: set[tuple] = set()
184
+ deduped: list[dict] = []
185
+ for f in findings:
186
+ key = (f["check"], f.get("story_id", f.get("file", "")))
187
+ if key not in seen:
188
+ seen.add(key)
189
+ deduped.append(f)
190
+ findings = deduped
191
+
192
+ # Emit blind-spots.json
193
+ high_count = sum(1 for f in findings if f["severity"] == "HIGH")
194
+ medium_count = sum(1 for f in findings if f["severity"] == "MEDIUM")
195
+ low_count = sum(1 for f in findings if f["severity"] == "LOW")
196
+
197
+ blind_spots = {
198
+ "generated_at": datetime.now(timezone.utc).isoformat(),
199
+ "version": "1.0",
200
+ "summary": {"total_findings": len(findings), "high": high_count, "medium": medium_count, "low": low_count},
201
+ "findings": findings
202
+ }
203
+
204
+ BLIND_SPOTS_JSON.parent.mkdir(parents=True, exist_ok=True)
205
+ BLIND_SPOTS_JSON.write_text(json.dumps(blind_spots, indent=2), encoding="utf-8")
206
+
207
+ print(f"check-blind-spots.sh: {len(findings)} findings ({high_count} HIGH, {medium_count} MEDIUM, {low_count} LOW)")
208
+ for f in findings:
209
+ print(f" [{f['severity']}] {f['check']}: {f['description'][:100]}")
210
+ if high_count > 0:
211
+ print(f"\n⚠️ {high_count} HIGH-severity finding(s) — review specs/blind-spots.json")
212
+ sys.exit(1)
213
+ sys.exit(0)
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # story: e40s06
3
+ # emit-okf-metrics.sh — shared OKF story-metrics bundle emitter (e40 provenance gate).
4
+ # Sourced by scripts/record-cycle-time.sh; emits YAML frontmatter + markdown body.
5
+ # Not meant to be run standalone.
6
+
7
+ # Usage: emit_okf_metrics <story> <bcps> <effort_hours> <lead_min> <commit_range> <generated_at>
8
+ # <agent_cost> <agent_tokens_total> <agent_cache_hit>
9
+ # <agent_compression> <agent_tool_calls> <agent_api_requests>
10
+ # <agent_duration_ms> <agent_model> <agent_tier>
11
+ # <agent_downgraded> <agent_skills>
12
+ # <agent_tokens_in> <agent_tokens_out>
13
+ # <agent_tokens_cache_read> <agent_tokens_cache_write>
14
+ # [--file <path>]
15
+ emit_okf_metrics() {
16
+ local story="$1" bcps="$2" eff="$3" lead_min="$4" commit_range="$5" generated_at="$6"
17
+ local agent_cost="${7:-null}" agent_tokens_total="${8:-null}" agent_cache_hit="${9:-null}"
18
+ local agent_compression="${10:-null}" agent_tool_calls="${11:-null}" agent_api_requests="${12:-null}"
19
+ local agent_duration_ms="${13:-null}" agent_model="${14:-null}" agent_tier="${15:-null}"
20
+ local agent_downgraded="${16:-null}" agent_skills="${17:-[]}"
21
+ local agent_tokens_in="${18:-null}" agent_tokens_out="${19:-null}"
22
+ local agent_tokens_cache_read="${20:-null}" agent_tokens_cache_write="${21:-null}"
23
+
24
+ local file
25
+ file="$(git rev-parse --show-toplevel 2>/dev/null)/specs/metrics/cycle-times.yaml"
26
+ while [ $# -gt 0 ]; do
27
+ case "$1" in --file) file="$2"; shift 2 ;; *) shift ;; esac
28
+ done
29
+
30
+ {
31
+ printf -- '---\n'
32
+ printf -- 'okf_kind: story-metrics\n'
33
+ printf -- 'okf_version: "0.1"\n'
34
+ printf -- 'id: %s\n' "$story"
35
+ printf -- 'epic: %s\n' "${story%%s*}"
36
+ printf -- 'bcps: %s\n' "$bcps"
37
+ printf -- 'commit_range: "%s"\n' "$commit_range"
38
+ printf -- 'source: measured\n'
39
+ printf -- 'generated_at: %s\n' "$generated_at"
40
+ printf -- 'generator: scripts/record-cycle-time.sh\n'
41
+ printf -- '\n'
42
+ printf -- '# DORA — the four keys (market standard). ⌀ median aggregate.\n'
43
+ printf -- 'dora:\n'
44
+ printf -- ' lead_time_for_changes_min: %s # ⌀ first commit → merge\n' "$lead_min"
45
+ printf -- ' deployment_frequency: null # %% populated by e40s05\n'
46
+ printf -- ' change_failure: null # %% populated by e40s05\n'
47
+ printf -- ' time_to_restore_min: null # ⌀ populated by e40s05\n'
48
+ printf -- '\n'
49
+ printf -- '# Agent code-generation telemetry (GSD-2 style). Σ additive.\n'
50
+ printf -- 'agent:\n'
51
+ printf -- ' cost_usd: %s # Σ\n' "$agent_cost"
52
+ printf -- ' tokens:\n'
53
+ printf -- ' input: %s\n' "$agent_tokens_in"
54
+ printf -- ' output: %s\n' "$agent_tokens_out"
55
+ printf -- ' cache_read: %s\n' "$agent_tokens_cache_read"
56
+ printf -- ' cache_write: %s\n' "$agent_tokens_cache_write"
57
+ printf -- ' total: %s\n' "$agent_tokens_total"
58
+ printf -- ' cache_hit_rate_pct: %s # %%\n' "$agent_cache_hit"
59
+ printf -- ' compression_savings_pct: %s # %%\n' "$agent_compression"
60
+ printf -- ' tool_calls: %s # Σ\n' "$agent_tool_calls"
61
+ printf -- ' api_requests: %s # Σ\n' "$agent_api_requests"
62
+ printf -- ' agent_duration_ms: %s # Σ\n' "$agent_duration_ms"
63
+ printf -- ' model: %s # •\n' "$agent_model"
64
+ printf -- ' tier: %s # •\n' "$agent_tier"
65
+ printf -- ' model_downgraded: %s # •\n' "$agent_downgraded"
66
+ printf -- ' skills: %s # Σ\n' "$agent_skills"
67
+ printf -- '\n'
68
+ printf -- '# Effort (git-derived, idle-stripped, ADDITIVE).\n'
69
+ printf -- 'effort:\n'
70
+ printf -- ' effort_hours: %s # Σ idle-stripped\n' "$eff"
71
+ printf -- ' idle_threshold_min: 120 # •\n'
72
+ printf -- '\n'
73
+ printf -- '# Quality gate. ⌀ median / • static.\n'
74
+ printf -- 'quality:\n'
75
+ printf -- ' audit_score_pct: null # ⌀\n'
76
+ printf -- ' coverage_pct: null # ⌀\n'
77
+ printf -- ' tests_passed: null # •\n'
78
+ printf -- ' verify_status: null # pass|waived\n'
79
+ printf -- ' rework_count: null # Σ\n'
80
+ printf -- '\n'
81
+ printf -- '# Flow (worktree telemetry).\n'
82
+ printf -- 'flow:\n'
83
+ printf -- ' merge_duration_ms: null # ⌀\n'
84
+ printf -- ' merge_conflicts: null # Σ\n'
85
+ printf -- ' worktree_orphaned: null # •\n'
86
+ printf -- '---\n'
87
+ printf -- '\n'
88
+ printf -- '# Story metrics — %s\n' "$story"
89
+ printf -- '\n'
90
+ printf -- 'Effort derived from git commit history via the git-hours model\n'
91
+ printf -- '(src: kimmobrunfeldt/git-hours, 120-min idle threshold, 120-min\n'
92
+ printf -- 'first-commit pad). Lead time is calendar latency (first commit → merge).\n'
93
+ printf -- '\n'
94
+ printf -- 'Aggregation tags: Σ additive (sum → total) · ⌀ median/p95 (never sum)\n'
95
+ printf -- '· %% rate (ratio over window) · • static (identity).\n'
96
+ } >> "$file"
97
+ }
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env python3
2
+ # story: e38s01
3
+ # trace-matrix.py — deterministic spec-to-code coverage matrix builder (Python engine).
4
+ # Called by scripts/trace-stories.sh. Not meant to be run standalone without args.
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
+ import json, os, re, sys, time
12
+ from pathlib import Path
13
+ from datetime import datetime, timezone
14
+
15
+ ROOT = Path(sys.argv[1])
16
+ MATRIX_JSON = Path(sys.argv[2])
17
+ TRACE_MD = Path(sys.argv[3])
18
+ OKF_DIR = Path(sys.argv[4])
19
+ STRICT = int(sys.argv[5])
20
+ MODE = sys.argv[6]
21
+
22
+ # -----------------------------------------------------------------------
23
+ # 1. Parse release-plan.yaml → story inventory
24
+ # -----------------------------------------------------------------------
25
+ def parse_simple_yaml(text: str) -> dict:
26
+ """Parse flat and one-level-nested YAML including lists of objects."""
27
+ root: dict = {}
28
+ # Each stack entry: (indent, container, parent_dict, key_in_parent)
29
+ stack: list = [(0, root, None, None)]
30
+ skip_until_indent = None # skip block scalar continuation lines (| and >)
31
+ for i, raw in enumerate(text.splitlines()):
32
+ stripped = raw.strip()
33
+ if not stripped or stripped.startswith("#"):
34
+ # Reset block-scalar skip on blank lines
35
+ continue
36
+ indent = len(raw) - len(raw.lstrip())
37
+
38
+ # Skip block scalar continuation (| and > in YAML)
39
+ if skip_until_indent is not None:
40
+ if indent > skip_until_indent:
41
+ continue
42
+ else:
43
+ skip_until_indent = None
44
+
45
+ # Pop stack until we're at the right nesting level
46
+ # Don't pop list containers at same indent — sibling items stay in same list
47
+ while len(stack) > 1:
48
+ if indent < stack[-1][0]:
49
+ stack.pop()
50
+ elif indent == stack[-1][0]:
51
+ # Same indent: pop dicts (moving to sibling key), keep lists (sibling items)
52
+ if isinstance(stack[-1][1], list) and stripped.startswith("- "):
53
+ break # stay in same list for next item
54
+ elif isinstance(stack[-1][1], dict) and stripped.startswith("- "):
55
+ # dict at same indent with upcoming list item — convert to list
56
+ entry_container = stack[-1][1]
57
+ entry_parent = stack[-1][2]
58
+ entry_key = stack[-1][3]
59
+ if entry_parent is not None and entry_key is not None:
60
+ if not isinstance(entry_parent.get(entry_key), list):
61
+ entry_parent[entry_key] = []
62
+ stack[-1] = (stack[-1][0], entry_parent[entry_key], entry_parent, entry_key)
63
+ break
64
+ stack.pop()
65
+ else:
66
+ stack.pop() # other same-indent transitions
67
+ else:
68
+ break # indent > stack indent — staying inside
69
+
70
+ container = stack[-1][1]
71
+ parent_dict = stack[-1][2]
72
+ key_in_parent = stack[-1][3]
73
+
74
+ # List item
75
+ if stripped.startswith("- "):
76
+ inner = stripped[2:]
77
+ # Convert dict to list if needed (preserve original indent from dict push)
78
+ if isinstance(container, dict) and parent_dict is not None and key_in_parent is not None:
79
+ if not isinstance(parent_dict.get(key_in_parent), list):
80
+ orig_indent = stack[-1][0]
81
+ parent_dict[key_in_parent] = []
82
+ container = parent_dict[key_in_parent]
83
+ stack[-1] = (orig_indent, container, parent_dict, key_in_parent)
84
+
85
+ if ":" in inner:
86
+ k, _, v = inner.partition(":")
87
+ k = k.strip()
88
+ v = v.strip()
89
+ # Detect block scalars in list items too
90
+ if v in ("|", ">", "|-", ">-", "|+", ">+"):
91
+ item = {k: v}
92
+ container.append(item)
93
+ skip_until_indent = indent
94
+ continue
95
+ item = {k: _yaml_scalar(v) if v else None}
96
+ container.append(item)
97
+ if v == "":
98
+ stack.append((indent, item, container, k))
99
+ else:
100
+ container.append(_yaml_scalar(inner))
101
+ continue
102
+
103
+ # Key: value
104
+ if ":" not in stripped:
105
+ continue
106
+ key, _, val = stripped.partition(":")
107
+ key = key.strip()
108
+ val = val.strip()
109
+
110
+ # Detect block scalars (|, >, |- etc.)
111
+ if val in ("|", ">", "|-", ">-", "|+", ">+"):
112
+ if isinstance(container, list) and container:
113
+ container[-1][key] = val # store the marker
114
+ elif isinstance(container, dict):
115
+ container[key] = val
116
+ skip_until_indent = indent
117
+ continue
118
+
119
+ if isinstance(container, list) and container:
120
+ # Inside a list item dict
121
+ container[-1][key] = _yaml_scalar(val) if val else None
122
+ if val == "":
123
+ nxt = {}
124
+ container[-1][key] = nxt
125
+ stack.append((indent, nxt, container[-1], key))
126
+ elif isinstance(container, dict):
127
+ if val == "":
128
+ # Store as dict first; will convert to list if next line is "-"
129
+ nxt = {}
130
+ container[key] = nxt
131
+ stack.append((indent, nxt, container, key))
132
+ else:
133
+ container[key] = _yaml_scalar(val)
134
+ return root
135
+
136
+
137
+ def _yaml_scalar(val: str):
138
+ """Convert a YAML scalar string to Python type."""
139
+ val = val.strip('"').strip("'")
140
+ if val in ("true", "false"):
141
+ return val == "true"
142
+ if val in ("null", "~", ""):
143
+ return None
144
+ try:
145
+ return int(val)
146
+ except ValueError:
147
+ try:
148
+ return float(val)
149
+ except ValueError:
150
+ return val
151
+
152
+ # Parse release plan for epics/stories
153
+ release_plan_path = ROOT / "specs" / "release-plan.yaml"
154
+ release_text = release_plan_path.read_text(encoding="utf-8")
155
+ release = parse_simple_yaml(release_text)
156
+
157
+ # Parse execution status
158
+ exec_path = ROOT / "specs" / "execution-status.yaml"
159
+ exec_text = exec_path.read_text(encoding="utf-8")
160
+ exec_status = parse_simple_yaml(exec_text)
161
+ dev_status = exec_status.get("development_status", {})
162
+
163
+ # -----------------------------------------------------------------------
164
+ # 2. Build story inventory from release-plan.yaml
165
+ # -----------------------------------------------------------------------
166
+ stories: dict[str, dict] = {}
167
+ epics = release.get("epics", [])
168
+ if not isinstance(epics, list):
169
+ epics = []
170
+
171
+ for epic in epics:
172
+ if not isinstance(epic, dict):
173
+ continue
174
+ eid = epic.get("id", "")
175
+ ebcp = epic.get("bcps", 0)
176
+ etitle = epic.get("title", "")
177
+ ewsjf = epic.get("wsjf", 0)
178
+
179
+ # Check for capsule_dir with stories
180
+ capsule_dir = epic.get("capsule_dir", "")
181
+ if capsule_dir:
182
+ capsule_path = ROOT / "specs" / capsule_dir / "epic.yaml"
183
+ if capsule_path.exists():
184
+ cap_text = capsule_path.read_text(encoding="utf-8")
185
+ cap = parse_simple_yaml(cap_text)
186
+ cap_stories = cap.get("stories", [])
187
+ if isinstance(cap_stories, list):
188
+ for s in cap_stories:
189
+ if isinstance(s, dict):
190
+ sid = s.get("id", "")
191
+ stories[sid] = {
192
+ "id": sid,
193
+ "title": s.get("title", ""),
194
+ "epic_id": eid,
195
+ "epic_title": etitle,
196
+ "bcp": s.get("bcp", 0),
197
+ "wsjf": float(ewsjf),
198
+ "description": s.get("description", "")
199
+ }
200
+
201
+ # Also check legacy flat epics (file: epics/eNN-*.yaml patterns)
202
+ file_key = epic.get("file", "")
203
+ if file_key and not capsule_dir:
204
+ legacy_path = ROOT / "specs" / file_key
205
+ if legacy_path.exists():
206
+ leg_text = legacy_path.read_text(encoding="utf-8")
207
+ leg = parse_simple_yaml(leg_text)
208
+ leg_stories = leg.get("stories", [])
209
+ if isinstance(leg_stories, list):
210
+ for s in leg_stories:
211
+ if isinstance(s, dict):
212
+ sid = s.get("id", "")
213
+ stories[sid] = {
214
+ "id": sid,
215
+ "title": s.get("title", ""),
216
+ "epic_id": eid,
217
+ "epic_title": etitle,
218
+ "bcp": s.get("bcp", 0),
219
+ "wsjf": float(ewsjf),
220
+ "description": s.get("description", "")
221
+ }
222
+
223
+ # -----------------------------------------------------------------------
224
+ # 3. Grep codebase for story: eNNsNN tags
225
+ # -----------------------------------------------------------------------
226
+ import subprocess
227
+ result = subprocess.run(
228
+ ["grep", "-rn", "--include=*.md", "--include=*.sh", "--include=*.py",
229
+ "--include=*.js", "--include=*.ts", "--include=*.yaml", "--include=*.yml",
230
+ "-E", r"story:\s*e\d{2}s\d{2}", str(ROOT)],
231
+ capture_output=True, text=True, cwd=str(ROOT)
232
+ )
233
+
234
+ tag_inventory: list[dict] = []
235
+ for line in result.stdout.splitlines():
236
+ m = re.match(r"^(.+?):(\d+):(.*story:\s*(e\d{2}s\d{2}).*)$", line)
237
+ if m:
238
+ fpath = m.group(1)
239
+ fline = int(m.group(2))
240
+ sid = m.group(4)
241
+ # Make path relative to repo root
242
+ try:
243
+ fpath = str(Path(fpath).relative_to(ROOT))
244
+ except ValueError:
245
+ pass
246
+ tag_inventory.append({"file": fpath, "line": fline, "story_id": sid})
247
+
248
+ # -----------------------------------------------------------------------
249
+ # 4. Resolve each story through oracle tiers
250
+ # -----------------------------------------------------------------------
251
+ # Tier 1: explicit story tag (already captured above)
252
+ # Tier 2: file-name heuristic match
253
+ # Tier 3: epic capsule task reference
254
+
255
+ # Collect all file basenames for heuristic matching
256
+ all_files = []
257
+ for root_dir, dirs, files in os.walk(str(ROOT)):
258
+ # Skip generated/artifacts
259
+ dirs[:] = [d for d in dirs if d not in (".git", "node_modules", ".cursor")]
260
+ for f in files:
261
+ all_files.append(str(Path(root_dir, f).relative_to(ROOT)))
262
+
263
+ def slugify(text: str) -> str:
264
+ """Convert title to a kebab-case slug."""
265
+ text = re.sub(r"[^a-zA-Z0-9\s-]", "", text.lower())
266
+ return re.sub(r"\s+", "-", text.strip())
267
+
268
+ def heuristic_match(story_title: str, file_path: str) -> bool:
269
+ """Check if file-path contains keywords from story title."""
270
+ slug = slugify(story_title)
271
+ words = slug.split("-")
272
+ fname = Path(file_path).stem.lower()
273
+ # Must match at least 2 significant keywords
274
+ sig_words = [w for w in words if len(w) > 2]
275
+ if len(sig_words) < 2:
276
+ sig_words = words
277
+ matches = sum(1 for w in sig_words if w in fname)
278
+ return matches >= min(2, len(sig_words))
279
+
280
+ def find_task_references(story_id: str) -> list[dict]:
281
+ """Find epic capsule task YAML files that reference this story's verify paths."""
282
+ refs = []
283
+ epics_dir = ROOT / "specs" / "epics"
284
+ if not epics_dir.exists():
285
+ return refs
286
+ for task_yaml in epics_dir.rglob("*tasks.yaml"):
287
+ try:
288
+ content = task_yaml.read_text(encoding="utf-8")
289
+ if f"story_id: {story_id}" in content or story_id in content:
290
+ rel = str(task_yaml.relative_to(ROOT))
291
+ refs.append({"file": rel, "type": "task_yaml"})
292
+ except Exception:
293
+ pass
294
+ return refs
295
+
296
+ # Build the coverage matrix
297
+ matrix_stories = []
298
+ tagged_sids = set(t["story_id"] for t in tag_inventory)
299
+ tag_index: dict[str, list] = {}
300
+ for t in tag_inventory:
301
+ tag_index.setdefault(t["story_id"], []).append(t)
302
+
303
+ dark_stories = []
304
+ orphan_tags = []
305
+ stale_tags = []
306
+
307
+ for sid, sinfo in sorted(stories.items()):
308
+ links = []
309
+ sid_status = dev_status.get(sid, "backlog")
310
+
311
+ # Tier 1: explicit tags
312
+ if sid in tag_index:
313
+ for t in tag_index[sid]:
314
+ links.append({"file": t["file"], "line": t["line"], "confidence": "high", "method": "explicit_tag"})
315
+
316
+ # Tier 2: file-name heuristic
317
+ for fpath in all_files:
318
+ if heuristic_match(sinfo["title"], fpath):
319
+ # Don't add if already covered by explicit tag
320
+ if not any(l["file"] == fpath for l in links):
321
+ links.append({"file": fpath, "line": 0, "confidence": "medium", "method": "file_heuristic"})
322
+
323
+ # Tier 3: task references
324
+ task_refs = find_task_references(sid)
325
+ for tr in task_refs:
326
+ existing_files = {l["file"] for l in links}
327
+ if tr["file"] not in existing_files:
328
+ links.append({"file": tr["file"], "line": 0, "confidence": "low", "method": "task_reference"})
329
+
330
+ matrix_stories.append({
331
+ "id": sid,
332
+ "title": sinfo["title"],
333
+ "epic_id": sinfo["epic_id"],
334
+ "epic_title": sinfo["epic_title"],
335
+ "bcp": sinfo["bcp"],
336
+ "wsjf": sinfo["wsjf"],
337
+ "status": sid_status,
338
+ "links": links,
339
+ "link_count": len(links)
340
+ })
341
+
342
+ # Classify findings
343
+ if len(links) == 0 and sid_status != "backlog":
344
+ dark_stories.append(sid)
345
+
346
+ # Check for orphan tags (tag in code, not in story inventory)
347
+ for sid in sorted(tagged_sids):
348
+ if sid not in stories:
349
+ orphan_tags.append(sid)
350
+
351
+ # Check for stale tags (story done, tag still in code)
352
+ for sid in sorted(tagged_sids):
353
+ if sid in stories and dev_status.get(sid) == "done":
354
+ stale_tags.append(sid)
355
+
356
+ # -----------------------------------------------------------------------
357
+ # 5. Emit matrix JSON
358
+ # -----------------------------------------------------------------------
359
+ matrix = {
360
+ "generated_at": datetime.now(timezone.utc).isoformat(),
361
+ "matrix_version": "1.0",
362
+ "stories": matrix_stories,
363
+ "summary": {
364
+ "total_stories": len(stories),
365
+ "tagged_stories": len(tagged_sids & set(stories.keys())),
366
+ "dark_stories": dark_stories,
367
+ "dark_count": len(dark_stories),
368
+ "orphan_tags": orphan_tags,
369
+ "orphan_count": len(orphan_tags),
370
+ "stale_tags": stale_tags,
371
+ "stale_count": len(stale_tags),
372
+ "oracle_stats": {
373
+ "high": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "high"),
374
+ "medium": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "medium"),
375
+ "low": sum(1 for s in matrix_stories for l in s["links"] if l["confidence"] == "low")
376
+ }
377
+ }
378
+ }
379
+
380
+ MATRIX_JSON.parent.mkdir(parents=True, exist_ok=True)
381
+ MATRIX_JSON.write_text(json.dumps(matrix, indent=2), encoding="utf-8")
382
+
383
+ # -----------------------------------------------------------------------
384
+ # 6. Emit TRACEABILITY_LATEST.md (human-readable report)
385
+ # -----------------------------------------------------------------------
386
+ lines = []
387
+ lines.append("# Traceability Matrix")
388
+ lines.append("")
389
+ lines.append(f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
390
+ lines.append(f"**Total stories:** {len(stories)}")
391
+ lines.append(f"**Tagged stories:** {len(tagged_sids & set(stories.keys()))}")
392
+ lines.append(f"**Dark stories:** {len(dark_stories)}")
393
+ lines.append(f"**Orphan tags:** {len(orphan_tags)}")
394
+ lines.append(f"**Stale tags:** {len(stale_tags)}")
395
+ lines.append("")
396
+ lines.append("## Oracle Stats")
397
+ lines.append("")
398
+ lines.append(f"- **High** (explicit tag): {matrix['summary']['oracle_stats']['high']}")
399
+ lines.append(f"- **Medium** (file heuristic): {matrix['summary']['oracle_stats']['medium']}")
400
+ lines.append(f"- **Low** (task reference): {matrix['summary']['oracle_stats']['low']}")
401
+ lines.append("")
402
+
403
+ # Story coverage table
404
+ lines.append("## Story Coverage")
405
+ lines.append("")
406
+ lines.append("| Story | Title | Epic | BCP | WSJF | Status | Links |")
407
+ lines.append("|-------|-------|------|-----|------|--------|-------|")
408
+ for s in matrix_stories:
409
+ lines.append(f"| {s['id']} | {s['title'][:60]} | {s['epic_id']} | {s['bcp']} | {s['wsjf']} | {s['status']} | {s['link_count']} |")
410
+ lines.append("")
411
+
412
+ # Findings
413
+ if dark_stories:
414
+ lines.append("## Dark Stories (no code links)")
415
+ lines.append("")
416
+ for ds in dark_stories:
417
+ sinfo = stories.get(ds, {})
418
+ lines.append(f"- **{ds}**: {sinfo.get('title', 'Unknown')} (status: {dev_status.get(ds, '?')})")
419
+ lines.append("")
420
+
421
+ if orphan_tags:
422
+ lines.append("## Orphan Tags (tag in code, no matching story)")
423
+ lines.append("")
424
+ for ot in orphan_tags:
425
+ lines.append(f"- `{ot}`")
426
+ lines.append("")
427
+
428
+ if stale_tags:
429
+ lines.append("## Stale Tags (story done, tag still in code)")
430
+ lines.append("")
431
+ for st in stale_tags:
432
+ lines.append(f"- `{st}`")
433
+ lines.append("")
434
+
435
+ TRACE_MD.parent.mkdir(parents=True, exist_ok=True)
436
+ TRACE_MD.write_text("\n".join(lines), encoding="utf-8")
437
+
438
+ # -----------------------------------------------------------------------
439
+ # 7. Emit OKF bundle (specs/codebase-wiki/)
440
+ # -----------------------------------------------------------------------
441
+ OKF_DIR.mkdir(parents=True, exist_ok=True)
442
+
443
+ # Generate index.md
444
+ idx_lines = []
445
+ idx_lines.append("---")
446
+ idx_lines.append("type: Index")
447
+ idx_lines.append(f"generated_at: {datetime.now(timezone.utc).isoformat()}")
448
+ idx_lines.append(f"total_concepts: {len(stories)}")
449
+ idx_lines.append("---")
450
+ idx_lines.append("")
451
+ idx_lines.append("# Codebase Wiki — Story Traceability")
452
+ idx_lines.append("")
453
+ idx_lines.append("Auto-generated OKF bundle from trace-stories.sh.")
454
+ idx_lines.append("")
455
+ idx_lines.append("| Story | Title | Confidence | Links |")
456
+ idx_lines.append("|-------|-------|------------|-------|")
457
+ for s in matrix_stories:
458
+ confs = {l["confidence"] for l in s["links"]}
459
+ max_conf = "high" if "high" in confs else ("medium" if "medium" in confs else ("low" if confs else "none"))
460
+ idx_lines.append(f"| [{s['id']}](./{s['id']}.md) | {s['title'][:60]} | {max_conf} | {s['link_count']} |")
461
+ idx_lines.append("")
462
+ (OKF_DIR / "index.md").write_text("\n".join(idx_lines), encoding="utf-8")
463
+
464
+ # Generate one concept per story
465
+ for s in matrix_stories:
466
+ concept = []
467
+ concept.append("---")
468
+ concept.append(f"type: Story")
469
+ concept.append(f"id: {s['id']}")
470
+ concept.append(f"epic: {s['epic_id']}")
471
+ concept.append(f"bcps: {s['bcp']}")
472
+ concept.append(f"wsjf: {s['wsjf']}")
473
+ concept.append(f"implementation_status: {s['status']}")
474
+ max_confs = set()
475
+ for l in s["links"]:
476
+ max_confs.add(l["confidence"])
477
+ conf = "high" if "high" in max_confs else ("medium" if "medium" in max_confs else ("low" if max_confs else "none"))
478
+ concept.append(f"coverage_status: {'covered' if s['link_count'] > 0 else 'dark'}")
479
+ concept.append(f"confidence: {conf}")
480
+ concept.append("links:")
481
+ for l in s["links"]:
482
+ concept.append(f" - file: {l['file']}")
483
+ concept.append(f" line: {l['line']}")
484
+ concept.append(f" confidence: {l['confidence']}")
485
+ concept.append(f" method: {l['method']}")
486
+ concept.append("---")
487
+ concept.append("")
488
+ concept.append(f"# {s['id']}: {s['title']}")
489
+ concept.append("")
490
+ concept.append(f"**Epic:** {s['epic_id']} — {s.get('epic_title', 'Unknown')}")
491
+ concept.append(f"**Status:** {s['status']}")
492
+ concept.append(f"**BCP:** {s['bcp']} | **WSJF:** {s['wsjf']}")
493
+ concept.append("")
494
+ if s["links"]:
495
+ concept.append("## Implemented In")
496
+ concept.append("")
497
+ for l in s["links"]:
498
+ concept.append(f"- `{l['file']}` (line {l['line']}, confidence: {l['confidence']}, method: {l['method']})")
499
+ else:
500
+ concept.append("*No code links found — this is a dark story.*")
501
+ concept.append("")
502
+ (OKF_DIR / f"{s['id']}.md").write_text("\n".join(concept), encoding="utf-8")
503
+
504
+ # -----------------------------------------------------------------------
505
+ # 8. Strict mode — exit non-zero on P0 uncovered
506
+ # -----------------------------------------------------------------------
507
+ if STRICT:
508
+ # P0 = top WSJF quartile among stories
509
+ if matrix_stories:
510
+ wsjf_sorted = sorted(set(s["wsjf"] for s in matrix_stories), reverse=True)
511
+ cutoff_idx = max(0, len(wsjf_sorted) // 4 - 1)
512
+ p0_threshold = wsjf_sorted[cutoff_idx] if cutoff_idx < len(wsjf_sorted) else 0
513
+
514
+ uncovered_p0 = []
515
+ for s in matrix_stories:
516
+ if s["wsjf"] >= p0_threshold and len(s["links"]) == 0:
517
+ uncovered_p0.append(s["id"])
518
+
519
+ if uncovered_p0:
520
+ print(f"trace-stories.sh: STRICT FAIL — P0 stories with 0% coverage: {', '.join(uncovered_p0)}", file=sys.stderr)
521
+ sys.exit(2)
522
+
523
+ sys.exit(0)