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.
- package/.pi/package.json +2 -2
- package/.pi/prompts/build-epic.md +10 -0
- package/.pi/prompts/commit-message.md +1 -0
- package/.pi/prompts/generate-allure-report.md +284 -0
- package/.pi/prompts/harden-vps.md +287 -0
- package/.pi/prompts/plan-release.md +3 -8
- package/.pi/prompts/plan-work.md +14 -1
- package/.pi/prompts/release-branch.md +3 -0
- package/.pi/skills/build-epic/SKILL.md +10 -0
- package/.pi/skills/commit-message/SKILL.md +1 -0
- package/.pi/skills/generate-allure-report/SKILL.md +285 -0
- package/.pi/skills/harden-vps/SKILL.md +288 -0
- package/.pi/skills/plan-release/SKILL.md +3 -8
- package/.pi/skills/plan-work/SKILL.md +14 -1
- package/.pi/skills/release-branch/SKILL.md +3 -0
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/SKILL-INDEX.md +1 -1
- package/allure-results/categories.json +23 -0
- package/allure-results/executor.json +6 -0
- package/allure-results/junit-results.xml +2743 -0
- package/hooks/pre-tool-use.sh +5 -0
- package/package.json +1 -1
- package/scripts/generate-allure-report.sh +146 -0
- package/scripts/land-branch.sh +10 -0
- package/scripts/record-cycle-time.sh +28 -0
- package/scripts/sync-status-from-epics.sh +247 -24
- package/scripts/validate-skill-catalog.sh +1 -1
- package/skills/build-epic/SKILL.md +10 -0
- package/skills/commit-message/SKILL.md +1 -0
- package/skills/generate-allure-report/REFERENCE.md +243 -0
- package/skills/generate-allure-report/SKILL.md +40 -0
- package/skills/harden-vps/REFERENCE.md +179 -0
- package/skills/harden-vps/SKILL.md +107 -0
- package/skills/plan-release/SKILL.md +3 -8
- package/skills/plan-work/REFERENCE.md +13 -0
- package/skills/plan-work/SKILL.md +1 -1
- package/skills/release-branch/SKILL.md +3 -0
- package/skills-lock.json +15 -5
package/hooks/pre-tool-use.sh
CHANGED
|
@@ -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
|
@@ -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
|
package/scripts/land-branch.sh
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|