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