bigpowers 2.76.2 → 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.
Files changed (39) hide show
  1. package/.pi/package.json +2 -2
  2. package/.pi/prompts/build-epic.md +10 -0
  3. package/.pi/prompts/commit-message.md +1 -0
  4. package/.pi/prompts/generate-allure-report.md +284 -0
  5. package/.pi/prompts/harden-vps.md +287 -0
  6. package/.pi/prompts/plan-release.md +3 -8
  7. package/.pi/prompts/plan-work.md +14 -1
  8. package/.pi/prompts/release-branch.md +3 -0
  9. package/.pi/skills/build-epic/SKILL.md +10 -0
  10. package/.pi/skills/commit-message/SKILL.md +1 -0
  11. package/.pi/skills/generate-allure-report/SKILL.md +285 -0
  12. package/.pi/skills/harden-vps/SKILL.md +288 -0
  13. package/.pi/skills/plan-release/SKILL.md +3 -8
  14. package/.pi/skills/plan-work/SKILL.md +14 -1
  15. package/.pi/skills/release-branch/SKILL.md +3 -0
  16. package/CHANGELOG.md +15 -0
  17. package/README.md +1 -1
  18. package/SKILL-INDEX.md +1 -1
  19. package/allure-results/categories.json +23 -0
  20. package/allure-results/executor.json +6 -0
  21. package/allure-results/junit-results.xml +2743 -0
  22. package/hooks/pre-tool-use.sh +5 -0
  23. package/package.json +1 -1
  24. package/scripts/generate-allure-report.sh +146 -0
  25. package/scripts/land-branch.sh +10 -0
  26. package/scripts/record-cycle-time.sh +28 -0
  27. package/scripts/sync-status-from-epics.sh +247 -24
  28. package/scripts/validate-skill-catalog.sh +1 -1
  29. package/skills/build-epic/SKILL.md +10 -0
  30. package/skills/commit-message/SKILL.md +1 -0
  31. package/skills/generate-allure-report/REFERENCE.md +243 -0
  32. package/skills/generate-allure-report/SKILL.md +40 -0
  33. package/skills/harden-vps/REFERENCE.md +179 -0
  34. package/skills/harden-vps/SKILL.md +107 -0
  35. package/skills/plan-release/SKILL.md +3 -8
  36. package/skills/plan-work/REFERENCE.md +13 -0
  37. package/skills/plan-work/SKILL.md +1 -1
  38. package/skills/release-branch/SKILL.md +3 -0
  39. package/skills-lock.json +15 -5
@@ -107,6 +107,11 @@ if [[ "$COMMAND" =~ git[[:space:]]+commit ]] || [[ "$COMMAND" =~ git[[:space:]]+
107
107
  if [ ${#SUBJECT} -gt 72 ]; then
108
108
  deny "BLOCKED: Commit subject line must be 72 characters or less."
109
109
  fi
110
+
111
+ # Block AI agent attribution (P1 — NEVER Co-authored-by:)
112
+ if echo "$MSG" | grep -qiE '^co[- ]authored[- ]by:' || echo "$MSG" | grep -qiE '\nco[- ]authored[- ]by:'; then
113
+ deny "BLOCKED: Commit must not include Co-authored-by: footer. All commits must appear as if authored solely by the human user (CONVENTIONS.md § Git Attribution)."
114
+ fi
110
115
  fi
111
116
 
112
117
  # Block direct commits to main unless land script is active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.76.2",
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
@@ -41,6 +41,11 @@ if [ ${#COMMIT_MSG} -gt 72 ]; then
41
41
  land_branch_deny "Commit subject line must be 72 characters or less"
42
42
  fi
43
43
 
44
+ # Block AI agent attribution (P1 — CONVENTIONS.md § Git Attribution)
45
+ if echo "$COMMIT_MSG" | grep -qiE '^co[- ]authored[- ]by:' || echo "$COMMIT_MSG" | grep -qiE '\nco[- ]authored[- ]by:'; then
46
+ land_branch_deny "Commit must not include Co-authored-by: footer. All commits must appear as if authored solely by the human user."
47
+ fi
48
+
44
49
  # Primary worktree only (.git is a directory, not a gitdir pointer file)
45
50
  if [ -f .git ]; then
46
51
  land_branch_deny "Run from the primary repository root, not a linked worktree (cd to main repo first)"
@@ -72,6 +77,11 @@ if ! git show-ref --verify --quiet "refs/heads/$FEATURE_BRANCH"; then
72
77
  land_branch_deny "Feature branch '$FEATURE_BRANCH' does not exist"
73
78
  fi
74
79
 
80
+ # Scan all commits in feature branch for Co-authored-by: footers
81
+ if git log "$DEFAULT_BRANCH..$FEATURE_BRANCH" --format="%B" 2>/dev/null | grep -qiE '^co[- ]authored[- ]by:'; then
82
+ land_branch_deny "Feature branch '$FEATURE_BRANCH' contains Co-authored-by: footer(s). Amend commits to remove all AI agent attribution before landing."
83
+ fi
84
+
75
85
  for protected in main master; do
76
86
  if [ "$FEATURE_BRANCH" = "$protected" ]; then
77
87
  land_branch_deny "Cannot land protected branch '$FEATURE_BRANCH'"
@@ -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:
@@ -39,6 +39,7 @@ description: Reviews working-tree changes, then drafts a Conventional Commits ti
39
39
  - [ ] **Scope** is a short noun in parentheses if it helps (e.g. `fix(api): …`).
40
40
  - [ ] Breaking changes are explicit (`!` and/or `BREAKING CHANGE:` in the body/footer).
41
41
  - [ ] Description is imperative, lowercase start after the prefix, no trailing period in the title line.
42
+ - [ ] **NO `Co-authored-by` or `Co-Authored-By` footers** — P1 rule (CONVENTIONS.md § Git Attribution). All commits must appear as if authored solely by the human user. The git hook and `land-branch.sh` both block these.
42
43
 
43
44
  ## When not to invent a bump
44
45