bigpowers 2.56.0 → 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 +1 -1
- package/CHANGELOG.md +14 -0
- package/CONVENTIONS.md +2 -0
- package/package.json +1 -1
- package/scripts/audit-compliance.sh +89 -7
- package/scripts/lib/blind-spot-checks.py +213 -0
- package/scripts/lib/emit-okf-metrics.sh +97 -0
- package/scripts/lib/trace-matrix.py +523 -0
- package/scripts/sync-skills.sh +1 -0
package/.pi/package.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## [2.56.1](https://github.com/danielvm-git/bigpowers/compare/v2.56.0...v2.56.1) (2026-07-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* add story tags for P0 stories blocking CI traceability gate ([d0ca097](https://github.com/danielvm-git/bigpowers/commit/d0ca0970857841fd90e4e54b812b4023b02f6f18))
|
|
14
|
+
|
|
1
15
|
# [2.56.0](https://github.com/danielvm-git/bigpowers/compare/v2.55.0...v2.56.0) (2026-07-03)
|
|
2
16
|
|
|
3
17
|
|
package/CONVENTIONS.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/scripts/sync-skills.sh
CHANGED