bigpowers 2.76.3 → 2.77.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.76.3",
3
+ "version": "2.77.0",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # story: e76
3
+ set -euo pipefail
4
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/python-env.sh"
5
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(dirname "${BASH_SOURCE[0]}")/.."
6
+
7
+ mkdir -p "$ROOT/allure-results"
8
+
9
+ $PYTHON - "$ROOT" <<'PY'
10
+ import json
11
+ import sys
12
+ import xml.etree.ElementTree as ET
13
+ from pathlib import Path
14
+
15
+ root = Path(sys.argv[1])
16
+ out = root / "allure-results"
17
+
18
+ exec_status_file = root / "specs" / "execution-status.yaml"
19
+ release_plan_file = root / "specs" / "release-plan.yaml"
20
+ cycle_times_file = root / "specs" / "metrics" / "cycle-times.yaml"
21
+ bugs_registry_file = root / "specs" / "bugs" / "registry.yaml"
22
+
23
+ sys.path.insert(0, str(root / "scripts" / "lib"))
24
+ from simple_yaml import parse_simple_yaml
25
+
26
+ exec_status = parse_simple_yaml(exec_status_file.read_text()) if exec_status_file.exists() else {}
27
+ release_plan = parse_simple_yaml(release_plan_file.read_text()) if release_plan_file.exists() else {}
28
+ cycle_times = parse_simple_yaml(cycle_times_file.read_text()) if cycle_times_file.exists() else {"stories": []}
29
+ bugs_registry = parse_simple_yaml(bugs_registry_file.read_text()) if bugs_registry_file.exists() else {"bugs": []}
30
+
31
+ # Build cycle-times lookup
32
+ ct_lookup = {}
33
+ for ct in cycle_times.get("stories", []):
34
+ if isinstance(ct, dict):
35
+ ct_lookup[ct.get("id", "")] = ct
36
+
37
+ # Build JUnit XML
38
+ stories = exec_status.get("stories", {})
39
+ total_stories = len(stories)
40
+ incomplete = sum(1 for s in stories.values() if isinstance(s, dict) and s.get("status") != "done")
41
+
42
+ testsuite = ET.Element("testsuite", {
43
+ "name": "bigpowers-epic-progress",
44
+ "tests": str(total_stories),
45
+ "failures": str(incomplete),
46
+ "errors": "0",
47
+ "skipped": "0",
48
+ })
49
+
50
+ for story_id in sorted(stories.keys()):
51
+ story = stories[story_id]
52
+ if not isinstance(story, dict):
53
+ continue
54
+
55
+ epic_id = story.get("epic", "unknown")
56
+ title = story.get("title", story_id)
57
+ bcps = story.get("bcps", 0)
58
+ status = story.get("status", "backlog")
59
+ risk_max = story.get("risk_max", "none")
60
+ security_max = story.get("security_max", "none")
61
+
62
+ ct_data = ct_lookup.get(story_id, {})
63
+ cycle_minutes = ct_data.get("cycle_minutes", 0) if isinstance(ct_data, dict) else 0
64
+ bcp_per_hour = ct_data.get("bcp_per_hour", 0) if isinstance(ct_data, dict) else 0
65
+
66
+ time_seconds = cycle_minutes * 60.0 if cycle_minutes else 0.0
67
+
68
+ testcase = ET.SubElement(testsuite, "testcase", {
69
+ "classname": epic_id,
70
+ "name": f"{story_id}: {title}",
71
+ "time": str(round(time_seconds, 3)),
72
+ })
73
+
74
+ props = ET.SubElement(testcase, "properties")
75
+ ET.SubElement(props, "property", {"name": "risk", "value": risk_max})
76
+ ET.SubElement(props, "property", {"name": "security", "value": security_max})
77
+ ET.SubElement(props, "property", {"name": "bcps", "value": str(bcps)})
78
+ ET.SubElement(props, "property", {"name": "status", "value": status})
79
+ ET.SubElement(props, "property", {"name": "bcp_per_hour", "value": str(bcp_per_hour)})
80
+ ET.SubElement(props, "property", {"name": "lead_time_minutes", "value": str(cycle_minutes)})
81
+
82
+ if status != "done":
83
+ ET.SubElement(testcase, "failure", {
84
+ "message": f"Story {story_id} is {status} [risk={risk_max}, security={security_max}]",
85
+ "type": "StoryIncomplete"
86
+ })
87
+
88
+ tree = ET.ElementTree(testsuite)
89
+ ET.indent(tree, space=" ")
90
+ tree.write(str(out / "junit-results.xml"), encoding="utf-8", xml_declaration=True)
91
+
92
+ # Categories JSON
93
+ epics = exec_status.get("epics", {})
94
+ categories = []
95
+
96
+ for epic_id in sorted(epics.keys()):
97
+ epic = epics[epic_id]
98
+ if isinstance(epic, dict) and epic.get("status") != "done":
99
+ categories.append({
100
+ "name": f"Epic: {epic.get('title', epic_id)}",
101
+ "matchedStatuses": ["failed"],
102
+ "messageRegex": f".*{epic_id}:.*"
103
+ })
104
+
105
+ categories.append({
106
+ "name": "P0 Risk",
107
+ "matchedStatuses": ["failed"],
108
+ "messageRegex": ".*risk.*P0.*"
109
+ })
110
+ categories.append({
111
+ "name": "Security Review",
112
+ "matchedStatuses": ["failed"],
113
+ "messageRegex": ".*security.*(?:medium|high).*"
114
+ })
115
+
116
+ bug_list = bugs_registry.get("bugs", [])
117
+ bug_count = len(bug_list) if isinstance(bug_list, list) else 0
118
+ open_bugs = sum(1 for b in bug_list if isinstance(b, dict) and b.get("status") not in ("fixed", "closed", None))
119
+ if open_bugs > 0:
120
+ categories.append({
121
+ "name": "Open Bugs",
122
+ "matchedStatuses": ["failed"],
123
+ "messageRegex": ".*Bug.*"
124
+ })
125
+
126
+ (out / "categories.json").write_text(json.dumps(categories, indent=2))
127
+
128
+ # Executor JSON
129
+ rl = release_plan.get("release", {})
130
+ if not isinstance(rl, dict):
131
+ rl = {}
132
+ executor = {
133
+ "name": "bigpowers",
134
+ "type": "bigpowers",
135
+ "buildName": rl.get("version", "unknown"),
136
+ "buildOrder": len(exec_status.get("development_status", {})),
137
+ }
138
+ (out / "executor.json").write_text(json.dumps(executor, indent=2))
139
+
140
+ epic_count = len([e for e in epics.values() if isinstance(e, dict) and e.get("status") == "done"])
141
+ total_epics = len(epics)
142
+ print(f"generate-allure-report: {total_stories} stories, {epic_count}/{total_epics} epics done, {bug_count} bugs")
143
+ print(f" -> {out}/junit-results.xml")
144
+ print(f" -> {out}/categories.json")
145
+ print(f" -> {out}/executor.json")
146
+ PY
@@ -50,6 +50,7 @@ STORY_KEY="Story"
50
50
  # git-hours engine — partition + whole_range_hours (additivity oracle)
51
51
  true && source "$(dirname "${BASH_SOURCE[0]}")/lib/git-hours.sh"
52
52
  true && source "$(dirname "${BASH_SOURCE[0]}")/lib/record-cycle-time-lib.sh"
53
+ true && source "$(dirname "${BASH_SOURCE[0]}")/lib/python-env.sh"
53
54
 
54
55
  die() { record_cycle_die "$@"; }
55
56
  usage_cycle() { record_cycle_usage; }
@@ -256,6 +257,33 @@ cmd_append() {
256
257
  } >> "$file"
257
258
 
258
259
  echo "record-cycle-time: OKF bundle written for $story → $file (effort=$eff h, lead=$lead_min min, commits=$count)"
260
+
261
+ # Also update execution-status.yaml with cycle-time data
262
+ local exec_yaml="$ROOT/specs/execution-status.yaml"
263
+ if [ -f "$exec_yaml" ]; then
264
+ # Compute bcp_per_hour: bcps / effort_hours, handle zero effort
265
+ local bph
266
+ bph="$(awk -v b="$bcps" -v e="$eff" 'BEGIN{if(e>0)printf "%.1f", b/e; else print "0.0"}')"
267
+
268
+ $PYTHON -c "
269
+ import sys
270
+ sys.path.insert(0, '$ROOT/scripts/lib')
271
+ import yaml
272
+ try:
273
+ with open('$exec_yaml') as f:
274
+ data = yaml.safe_load(f) or {}
275
+ except: data = {}
276
+
277
+ stories = data.setdefault('stories', {})
278
+ entry = stories.setdefault('$story', {})
279
+ entry['effort_hours'] = float('$eff')
280
+ entry['lead_time_minutes'] = int('$lead_min')
281
+ entry['bcp_per_hour'] = float('$bph')
282
+
283
+ with open('$exec_yaml', 'w') as f:
284
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
285
+ " 2>/dev/null || echo "WARN: could not update execution-status.yaml with cycle-time data" >&2
286
+ fi
259
287
  }
260
288
 
261
289
  [ $# -ge 1 ] || { usage_cycle; }
@@ -12,6 +12,32 @@ $PYTHON - "$EPICS" "$OUT" <<'PY'
12
12
  import re
13
13
  import sys
14
14
  from pathlib import Path
15
+ import yaml
16
+
17
+
18
+ def _yaml_str(s: str) -> str:
19
+ """Return a YAML-safe quoted string, using single quotes when needed."""
20
+ if not s:
21
+ return "''"
22
+ if any(c in s for c in ":{}[]&*?|>-;!%@`,\"\n\t#'"):
23
+ escaped = s.replace("'", "''")
24
+ return f"'{escaped}'"
25
+ if s.startswith(" ") or s.endswith(" ") or s[0] in "0123456789":
26
+ escaped = s.replace("'", "''")
27
+ return f"'{escaped}'"
28
+ return s
29
+
30
+
31
+ def _yaml_val(v) -> str:
32
+ """Return a YAML-safe scalar representation."""
33
+ if v is None:
34
+ return "null"
35
+ if isinstance(v, bool):
36
+ return "true" if v else "false"
37
+ if isinstance(v, (int, float)):
38
+ return str(v)
39
+ return _yaml_str(str(v))
40
+
15
41
 
16
42
  epics_dir = Path(sys.argv[1])
17
43
  out = Path(sys.argv[2])
@@ -23,34 +49,231 @@ if existing_path.exists():
23
49
  for m in re.finditer(r"^ ([a-z0-9._-]+):\s*(\S+)", existing, re.M):
24
50
  keys[m.group(1)] = m.group(2)
25
51
 
26
- for epic_file in sorted(epics_dir.glob("e*.yaml")):
27
- text = epic_file.read_text(encoding="utf-8")
28
- em = re.search(r"^id:\s*(e\d+)", text, re.M)
29
- if em:
30
- eid = em.group(1)
31
- keys.setdefault(eid, "backlog")
32
- for sm in re.finditer(r"^\s+- id:\s*(e\d+s\d+)", text, re.M):
33
- keys.setdefault(sm.group(1), "backlog")
34
-
35
- for folder in sorted(epics_dir.glob("e*/")):
36
- epic_yaml = folder / "epic.yaml"
37
- if epic_yaml.exists():
38
- text = epic_yaml.read_text(encoding="utf-8")
39
- em = re.search(r"^id:\s*(e\d+)", text, re.M)
40
- if em:
41
- keys.setdefault(em.group(1), "backlog")
42
- # Parse inline story IDs from capsule epic.yaml (same pattern as flat files)
43
- for sm in re.finditer(r"^\s+- id:\s*(e\d+s\d+)", text, re.M):
44
- keys.setdefault(sm.group(1), "backlog")
45
- for story in (folder / "stories").glob("e*s*.md"):
46
- m = re.match(r"(e\d+s\d+)", story.name)
47
- if m:
48
- keys.setdefault(m.group(1), "backlog")
52
+ # --- Parse release-plan.yaml for wsjf/tier per epic ---
53
+ rp_epics: dict[str, dict[str, object]] = {}
54
+ rp_path = epics_dir.parent / "release-plan.yaml"
55
+ if rp_path.exists():
56
+ try:
57
+ rp = yaml.safe_load(rp_path.read_text(encoding="utf-8"))
58
+ for ep in (rp.get("epics") or []):
59
+ eid = ep.get("id")
60
+ if not eid:
61
+ continue
62
+ entry: dict[str, object] = {}
63
+ if "wsjf" in ep:
64
+ entry["wsjf"] = ep["wsjf"]
65
+ if "tier" in ep:
66
+ entry["tier"] = ep["tier"]
67
+ rp_epics[eid] = entry
68
+ except Exception:
69
+ pass
70
+
71
+ # --- Collect all epic sources (capsule dirs + flat YAML) ---
72
+ epic_sources: list[tuple[str, Path]] = []
73
+ seen_eids: set[str] = set()
74
+
75
+ # Search roots: epics_dir itself, plus immediate subdirectories (e.g., archive/)
76
+ search_roots = [epics_dir] if epics_dir.is_dir() else []
77
+ for sub in sorted(epics_dir.iterdir()) if epics_dir.is_dir() else []:
78
+ if sub.is_dir():
79
+ search_roots.append(sub)
80
+
81
+ for root in search_roots:
82
+ for capsule in sorted(root.glob("e*/")):
83
+ ey = capsule / "epic.yaml"
84
+ if ey.exists():
85
+ try:
86
+ ed = yaml.safe_load(ey.read_text(encoding="utf-8"))
87
+ eid = ed.get("id")
88
+ if eid and eid not in seen_eids:
89
+ epic_sources.append((eid, ey))
90
+ seen_eids.add(eid)
91
+ except Exception:
92
+ pass
93
+
94
+ for epic_file in sorted(root.glob("e*.yaml")):
95
+ if re.search(r"e\d+s\d+", epic_file.stem):
96
+ continue
97
+ try:
98
+ ed = yaml.safe_load(epic_file.read_text(encoding="utf-8"))
99
+ eid = ed.get("id")
100
+ if eid and eid not in seen_eids:
101
+ epic_sources.append((eid, epic_file))
102
+ seen_eids.add(eid)
103
+ except Exception:
104
+ pass
105
+
106
+ # --- Build enriched epic + story data ---
107
+ epics_output: dict[str, dict[str, object]] = {}
108
+ stories_output: dict[str, dict[str, object]] = {}
109
+
110
+ risk_rank = {"P0": 4, "P1": 3, "P2": 2, "P3": 1}
111
+ sec_rank = {"high": 4, "medium": 3, "low": 2, "none": 1}
112
+
113
+ for eid, epic_path in sorted(epic_sources):
114
+ try:
115
+ ed = yaml.safe_load(epic_path.read_text(encoding="utf-8"))
116
+ except Exception:
117
+ continue
118
+ keys.setdefault(eid, "backlog")
119
+
120
+ title = ed.get("title", "")
121
+ total_bcps = ed.get("total_bcps") or ed.get("bcps")
122
+
123
+ epics_output[eid] = {
124
+ "status": keys[eid],
125
+ "title": title,
126
+ "wsjf": rp_epics.get(eid, {}).get("wsjf"),
127
+ "total_bcps": total_bcps,
128
+ }
129
+ if "tier" in rp_epics.get(eid, {}):
130
+ epics_output[eid]["tier"] = rp_epics[eid]["tier"]
131
+
132
+ capsule_dir = epic_path.parent
133
+ stories = ed.get("stories") or []
49
134
 
135
+ for story in stories:
136
+ if not isinstance(story, dict):
137
+ continue
138
+ sid = story.get("id")
139
+ if not sid or not re.match(r"^e\d+s\d+$", str(sid)):
140
+ continue
141
+ keys.setdefault(sid, "backlog")
142
+
143
+ story_bcps = story.get("bcp") or story.get("bcps")
144
+ stories_output[sid] = {
145
+ "status": keys[sid],
146
+ "title": story.get("title", ""),
147
+ "bcps": story_bcps,
148
+ "epic": eid,
149
+ "tasks_total": 0,
150
+ "tasks_passing": 0,
151
+ "tasks_failing": 0,
152
+ "risk_max": None,
153
+ "security_max": None,
154
+ }
155
+
156
+ # Look for -tasks.yaml at capsule root
157
+ tasks_file = None
158
+ for pattern in [f"{sid}-tasks.yaml", f"{sid}*.yaml"]:
159
+ for tf in sorted(capsule_dir.glob(pattern)):
160
+ try:
161
+ tf_data = yaml.safe_load(tf.read_text(encoding="utf-8"))
162
+ if isinstance(tf_data, dict) and "tasks" in tf_data:
163
+ tasks_file = tf
164
+ break
165
+ except Exception:
166
+ pass
167
+ if tasks_file:
168
+ break
169
+
170
+ if tasks_file:
171
+ try:
172
+ tf_data = yaml.safe_load(tasks_file.read_text(encoding="utf-8"))
173
+ tasks = tf_data.get("tasks") or []
174
+
175
+ stories_output[sid]["tasks_total"] = len(tasks)
176
+ passing = sum(
177
+ 1 for t in tasks
178
+ if isinstance(t, dict) and t.get("status") in ("done", "passing")
179
+ )
180
+ failing = sum(
181
+ 1 for t in tasks
182
+ if isinstance(t, dict) and t.get("status") == "failing"
183
+ )
184
+ stories_output[sid]["tasks_passing"] = passing
185
+ stories_output[sid]["tasks_failing"] = failing
186
+
187
+ # Risk max: check individual tasks + story-level
188
+ max_risk_val = None
189
+ max_risk_rank = 0
190
+ for t in tasks:
191
+ tr = t.get("risk") if isinstance(t, dict) else None
192
+ if tr in risk_rank and risk_rank[tr] > max_risk_rank:
193
+ max_risk_rank = risk_rank[tr]
194
+ max_risk_val = tr
195
+ sr = tf_data.get("risk")
196
+ if sr in risk_rank and risk_rank[sr] > max_risk_rank:
197
+ max_risk_val = sr
198
+ stories_output[sid]["risk_max"] = max_risk_val
199
+
200
+ # Security max: check individual tasks + story-level
201
+ max_sec_val = None
202
+ max_sec_rank = 0
203
+ for t in tasks:
204
+ ts = t.get("security") if isinstance(t, dict) else None
205
+ if ts in sec_rank and sec_rank[ts] > max_sec_rank:
206
+ max_sec_rank = sec_rank[ts]
207
+ max_sec_val = ts
208
+ ss = tf_data.get("security")
209
+ if ss in sec_rank and sec_rank[ss] > max_sec_rank:
210
+ max_sec_val = ss
211
+ stories_output[sid]["security_max"] = max_sec_val
212
+
213
+ except Exception:
214
+ pass
215
+
216
+ # Stories/ subdirectory (future-proofing)
217
+ stories_dir = capsule_dir / "stories"
218
+ if stories_dir.is_dir():
219
+ for story_file in sorted(stories_dir.glob("e*s*.md")):
220
+ m = re.match(r"(e\d+s\d+)", story_file.stem)
221
+ if m:
222
+ sid = m.group(1)
223
+ keys.setdefault(sid, "backlog")
224
+ if sid not in stories_output:
225
+ stories_output[sid] = {
226
+ "status": keys[sid],
227
+ "title": "",
228
+ "bcps": None,
229
+ "epic": eid,
230
+ "tasks_total": 0,
231
+ "tasks_passing": 0,
232
+ "tasks_failing": 0,
233
+ "risk_max": None,
234
+ "security_max": None,
235
+ }
236
+
237
+ # --- Build output ---
50
238
  lines = ["development_status:"]
51
239
  for k in sorted(keys.keys()):
52
240
  lines.append(f" {k}: {keys[k]}")
53
241
  lines.append("")
242
+
243
+ # --- Epics section ---
244
+ lines.append("epics:")
245
+ for eid in sorted(epics_output.keys()):
246
+ lines.append(f" {eid}:")
247
+ ep = epics_output[eid]
248
+ lines.append(f" status: {_yaml_val(ep['status'])}")
249
+ lines.append(f" title: {_yaml_str(ep.get('title', ''))}")
250
+ if ep.get("wsjf") is not None:
251
+ lines.append(f" wsjf: {ep['wsjf']}")
252
+ if ep.get("tier") is not None:
253
+ lines.append(f" tier: {ep['tier']}")
254
+ if ep.get("total_bcps") is not None:
255
+ lines.append(f" total_bcps: {ep['total_bcps']}")
256
+ lines.append("")
257
+
258
+ # --- Stories section ---
259
+ lines.append("stories:")
260
+ for sid in sorted(stories_output.keys()):
261
+ lines.append(f" {sid}:")
262
+ st = stories_output[sid]
263
+ lines.append(f" status: {_yaml_val(st['status'])}")
264
+ lines.append(f" title: {_yaml_str(st.get('title', ''))}")
265
+ if st.get("bcps") is not None:
266
+ lines.append(f" bcps: {st['bcps']}")
267
+ lines.append(f" epic: {_yaml_val(st['epic'])}")
268
+ lines.append(f" tasks_total: {st['tasks_total']}")
269
+ lines.append(f" tasks_passing: {st['tasks_passing']}")
270
+ lines.append(f" tasks_failing: {st['tasks_failing']}")
271
+ if st.get("risk_max") is not None:
272
+ lines.append(f" risk_max: {_yaml_val(st['risk_max'])}")
273
+ if st.get("security_max") is not None:
274
+ lines.append(f" security_max: {_yaml_val(st['security_max'])}")
275
+ lines.append("")
276
+
54
277
  out.write_text("\n".join(lines), encoding="utf-8")
55
- print(f"sync-status-from-epics: wrote {out} ({len(keys)} keys)")
278
+ print(f"sync-status-from-epics: wrote {out} ({len(keys)} development keys, {len(epics_output)} epics, {len(stories_output)} stories)")
56
279
  PY
@@ -18,7 +18,7 @@ VIOLATIONS=0
18
18
  WARNINGS=0
19
19
 
20
20
  # Documented naming exceptions (craft-skill REFERENCE.md)
21
- VERB_NOUN_EXCEPTIONS="grill-me grill-with-docs context7-mcp using-bigpowers deploy"
21
+ VERB_NOUN_EXCEPTIONS="grill-me grill-with-docs context7-mcp using-bigpowers deploy generate-allure-report"
22
22
 
23
23
  catalog_usage() {
24
24
  echo "Usage: bash scripts/validate-skill-catalog.sh [--archive] [--strict] [--skill <name>]"
@@ -35,6 +35,7 @@ Orchestrates the **build** flow for a single epic: survey → plan tasks → kic
35
35
  ## Process
36
36
 
37
37
  1. Read `specs/state.yaml`, `specs/execution-status.yaml`, `specs/release-plan.yaml`, active `specs/epics/eNN-slug/epic.yaml`.
38
+ - **On story start (step 1):** Write `started_at` ISO 8601 timestamp to `execution-status.yaml` under the story's key in the `stories:` section. The `bp-timing.sh` script already tracks skill-level timings — this is story-level.
38
39
  2. **Step 0 — Threat Model:** Run `security-review` against the epic's scope (read from the epic capsule). Output `specs/security/epics/<epic-id>/THREAT_MODEL.md` with surface area, vulnerability categories, risk level, and mitigation guidance.
39
40
  3. **Assess Impact (Step 2):** Before writing tasks, run `assess-impact --lightweight` on the proposed change. If the risk score exceeds 7, gate — require a `grill-me` session. Write the impact report to `specs/IMPACT-<epic>-<story>.md`. For net-new code with no existing dependents, skip.
40
41
  4. **BCP Tracking (Step 2):** After `plan-work` completes, read the `bcps:` count (Business Complexity Points story size) from the epic capsule and carry it into `state.yaml` as `epic_cycle.story_bcps = N`.
@@ -43,6 +44,15 @@ Orchestrates the **build** flow for a single epic: survey → plan tasks → kic
43
44
  6. Run **only the current step** (resume mode) unless user asked for full auto-run.
44
45
  7. After step verify passes, increment `epic_cycle.step` in `state.yaml` (or `bash scripts/bp-yaml-set.sh` if available).
45
46
  8. On story complete, set `execution-status.yaml` story key to `done`; run `bash scripts/sync-status-from-epics.sh`.
47
+ - **On story complete (step 8):** Write `completed_at` ISO 8601 timestamp to `execution-status.yaml` under the same story key. Format:
48
+
49
+ ```yaml
50
+ stories:
51
+ e01s01:
52
+ status: done
53
+ started_at: "2026-07-12T16:00:00-03:00"
54
+ completed_at: "2026-07-12T18:45:00-03:00"
55
+ ```
46
56
  9. **Traceability refresh:** Before step 8 (release-branch), run `bash scripts/trace-stories.sh` to regenerate `specs/traceability-matrix.json`, `specs/TRACEABILITY_LATEST.md`, and the `specs/codebase-wiki/` OKF bundle. Surface dark/orphan/stale findings for the just-built epic in your verify summary. If `scripts/trace-stories.sh` is missing or fails, note "trace skipped" and continue — trace failure must be visible, not silent (blocking is gate-trace's job, e38s06).
47
57
 
48
58
  9b. **OKF wiki refresh (e39s08):** Before step 8, run maintain-wiki INGEST to refresh the OKF wiki bundle: