bigpowers 2.76.3 → 2.77.1
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/audit-code.md +1 -1
- package/.pi/prompts/build-epic.md +10 -0
- package/.pi/prompts/craft-skill.md +2 -2
- package/.pi/prompts/elaborate-spec.md +1 -1
- package/.pi/prompts/evolve-skill.md +1 -1
- package/.pi/prompts/generate-allure-report.md +284 -0
- package/.pi/prompts/grill-me.md +1 -1
- package/.pi/prompts/guard-git.md +1 -1
- package/.pi/prompts/harden-vps.md +287 -0
- package/.pi/prompts/investigate-bug.md +2 -2
- package/.pi/prompts/model-domain.md +3 -3
- package/.pi/prompts/plan-refactor.md +2 -2
- package/.pi/prompts/plan-release.md +5 -10
- package/.pi/prompts/plan-work.md +14 -1
- package/.pi/prompts/using-bigpowers.md +1 -1
- package/.pi/prompts/write-document.md +2 -2
- package/.pi/skills/audit-code/SKILL.md +1 -1
- package/.pi/skills/build-epic/SKILL.md +10 -0
- package/.pi/skills/change-request/SKILL.md +1 -1
- package/.pi/skills/commit-message/SKILL.md +3 -3
- package/.pi/skills/compose-workflow/SKILL.md +1 -1
- package/.pi/skills/craft-skill/SKILL.md +4 -4
- package/.pi/skills/deepen-architecture/SKILL.md +13 -13
- package/.pi/skills/deploy/SKILL.md +4 -4
- package/.pi/skills/develop-tdd/SKILL.md +5 -5
- package/.pi/skills/elaborate-spec/SKILL.md +1 -1
- package/.pi/skills/evolve-skill/SKILL.md +2 -2
- package/.pi/skills/extract-design/SKILL.md +1 -1
- package/.pi/skills/generate-allure-report/SKILL.md +285 -0
- package/.pi/skills/grill-me/SKILL.md +2 -2
- package/.pi/skills/grill-with-docs/SKILL.md +1 -1
- package/.pi/skills/guard-git/SKILL.md +5 -5
- package/.pi/skills/harden-vps/SKILL.md +288 -0
- package/.pi/skills/investigate-bug/SKILL.md +2 -2
- package/.pi/skills/migrate-spec/SKILL.md +13 -13
- package/.pi/skills/model-domain/SKILL.md +5 -5
- package/.pi/skills/orchestrate-project/SKILL.md +2 -2
- package/.pi/skills/organize-workspace/SKILL.md +1 -1
- package/.pi/skills/plan-refactor/SKILL.md +2 -2
- package/.pi/skills/plan-release/SKILL.md +5 -10
- package/.pi/skills/plan-work/SKILL.md +15 -2
- package/.pi/skills/publish-package/SKILL.md +8 -8
- package/.pi/skills/release-branch/SKILL.md +2 -2
- package/.pi/skills/research-first/SKILL.md +1 -1
- package/.pi/skills/run-evals/SKILL.md +1 -1
- package/.pi/skills/security-review/SKILL.md +3 -3
- package/.pi/skills/seed-conventions/SKILL.md +3 -3
- package/.pi/skills/smoke-test/SKILL.md +5 -5
- package/.pi/skills/stocktake-skills/SKILL.md +1 -1
- package/.pi/skills/using-bigpowers/SKILL.md +1 -1
- package/.pi/skills/validate-contracts/SKILL.md +6 -6
- package/.pi/skills/verify-work/SKILL.md +1 -1
- package/.pi/skills/wire-ci/SKILL.md +8 -8
- package/.pi/skills/write-document/SKILL.md +3 -3
- package/CHANGELOG.md +14 -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/package.json +4 -3
- package/scripts/adapters/pi.sh +4 -1
- package/scripts/check-skill-links.py +78 -0
- package/scripts/generate-allure-report.sh +146 -0
- package/scripts/generate-reference-tables.sh +2 -1
- package/scripts/lib/__pycache__/link_utils.cpython-312.pyc +0 -0
- package/scripts/lib/link_utils.py +49 -0
- package/scripts/lib/srp-engine.py +86 -1
- 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/audit-code/SKILL.md +1 -1
- package/skills/build-epic/SKILL.md +10 -0
- package/skills/craft-skill/SKILL.md +2 -2
- package/skills/elaborate-spec/SKILL.md +1 -1
- package/skills/evolve-skill/REFERENCE.md +1 -1
- package/skills/generate-allure-report/REFERENCE.md +243 -0
- package/skills/generate-allure-report/SKILL.md +40 -0
- package/skills/grill-me/REFERENCE.md +1 -1
- package/skills/guard-git/SKILL.md +1 -1
- package/skills/harden-vps/REFERENCE.md +179 -0
- package/skills/harden-vps/SKILL.md +107 -0
- package/skills/investigate-bug/SKILL.md +2 -2
- package/skills/model-domain/CONTEXT-FORMAT.md +3 -3
- package/skills/plan-refactor/SKILL.md +2 -2
- package/skills/plan-release/SKILL.md +5 -10
- package/skills/plan-work/REFERENCE.md +13 -0
- package/skills/plan-work/SKILL.md +1 -1
- package/skills/using-bigpowers/SKILL.md +1 -1
- package/skills/write-document/REFERENCE.md +2 -2
- package/skills-lock.json +20 -10
- package/tests/test_srp_engine.py +148 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bigpowers",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.77.1",
|
|
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": {
|
|
7
|
-
"compliance": "bash scripts/audit-compliance.sh specs/verifications/features && bash scripts/validate-doctrine.sh",
|
|
7
|
+
"compliance": "bash scripts/audit-compliance.sh specs/verifications/features && bash scripts/validate-doctrine.sh && python3 scripts/check-skill-links.py",
|
|
8
8
|
"doctrine": "bash scripts/validate-doctrine.sh",
|
|
9
9
|
"sync": "bash scripts/sync-skills.sh",
|
|
10
10
|
"validate-specs": "bash scripts/validate-specs-yaml.sh",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"dashboard:web": "node dashboard/bin/dashboard.js --web",
|
|
16
16
|
"site:dev": "cd website && npm run dev",
|
|
17
17
|
"site:build": "cd website && npm run build",
|
|
18
|
-
"site:preview": "cd website && npm run preview"
|
|
18
|
+
"site:preview": "cd website && npm run preview",
|
|
19
|
+
"check:skill-links": "python3 scripts/check-skill-links.py"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
21
22
|
"skills",
|
package/scripts/adapters/pi.sh
CHANGED
|
@@ -17,7 +17,7 @@ render_skill() {
|
|
|
17
17
|
echo "description: \"$IR_DESC_ESCAPED\""
|
|
18
18
|
echo "---"
|
|
19
19
|
echo ""
|
|
20
|
-
echo "$IR_BODY"
|
|
20
|
+
echo "${IR_BODY_SKILL:-$IR_BODY}"
|
|
21
21
|
} > "${PI_SKILLS}/$IR_NAME/SKILL.md"
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -45,6 +45,9 @@ if [[ ! -t 0 ]]; then
|
|
|
45
45
|
IR_MODEL=$(echo "$JSON_INPUT" | jq -r '.model')
|
|
46
46
|
IR_DESCRIPTION=$(echo "$JSON_INPUT" | jq -r '.description')
|
|
47
47
|
IR_BODY=$(echo "$JSON_INPUT" | jq -r '.body')
|
|
48
|
+
# Skill files get link-rewritten bodies (relative links repointed from
|
|
49
|
+
# .pi/skills/<name>/ to the shipped source tree); prompts keep the raw body.
|
|
50
|
+
IR_BODY_SKILL=$(echo "$JSON_INPUT" | jq -r '.body_pi_skill // .body')
|
|
48
51
|
IR_DESC_ESCAPED=$(echo "$IR_DESCRIPTION" | sed 's/\\/\\\\/g; s/\"/\\"/g')
|
|
49
52
|
render_skill
|
|
50
53
|
fi
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# check-skill-links.py — fail on machine-absolute paths or dangling relative
|
|
3
|
+
# links in skill docs. Scans skills/**/SKILL.md and .pi/skills/**/SKILL.md.
|
|
4
|
+
# Run: python3 scripts/check-skill-links.py (exit 1 with a report on failure)
|
|
5
|
+
#
|
|
6
|
+
# NOTE: .cursor/rules/ and .gemini/extensions/ are intentionally excluded.
|
|
7
|
+
# Those trees are auto-generated from SKILL.md sources via sync-skills.sh and
|
|
8
|
+
# are not published to npm, so dangling links there are a rendering gap, not a
|
|
9
|
+
# source defect. Enable once the sync pipeline gains link-rewriting support.
|
|
10
|
+
# See: https://github.com/danielvm-git/bigpowers/issues/78
|
|
11
|
+
import glob
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
# Import shared patterns — single source of truth so the rewriter (srp-engine)
|
|
16
|
+
# and the gate always agree on what constitutes a "link".
|
|
17
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
from lib.link_utils import LINK_RE, EXTERNAL_RE, MACHINE_PATH_RE, strip_code_spans # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_repo_root() -> str:
|
|
22
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
candidate = os.path.dirname(here) # scripts/ -> repo root
|
|
24
|
+
if os.path.isdir(os.path.join(candidate, "skills")):
|
|
25
|
+
return candidate
|
|
26
|
+
return os.getcwd()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_file_links(smd: str, root: str) -> list[str]:
|
|
30
|
+
"""Return a list of problem strings for a single SKILL.md file."""
|
|
31
|
+
rel = os.path.relpath(smd, root)
|
|
32
|
+
with open(smd, encoding="utf-8") as f:
|
|
33
|
+
text = f.read()
|
|
34
|
+
|
|
35
|
+
problems = []
|
|
36
|
+
|
|
37
|
+
# Machine-absolute path check runs on raw text (line-by-line for location).
|
|
38
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
39
|
+
if MACHINE_PATH_RE.search(line):
|
|
40
|
+
problems.append(f"{rel}:{i}: machine-absolute path: {line.strip()[:100]}")
|
|
41
|
+
|
|
42
|
+
# Link check runs on shadow (code spans blanked) to avoid false positives
|
|
43
|
+
# on example paths shown inside fenced blocks or inline code.
|
|
44
|
+
shadow = strip_code_spans(text)
|
|
45
|
+
for m in LINK_RE.finditer(shadow):
|
|
46
|
+
target = m.group(2).split("#", 1)[0] # strip fragment
|
|
47
|
+
if not target or EXTERNAL_RE.match(target) or target.startswith(("#", "/")):
|
|
48
|
+
continue
|
|
49
|
+
resolved = os.path.normpath(os.path.join(os.path.dirname(smd), target))
|
|
50
|
+
if not os.path.exists(resolved):
|
|
51
|
+
problems.append(f"{rel}: dangling link -> {target}")
|
|
52
|
+
|
|
53
|
+
return problems
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check(root: str) -> list[str]:
|
|
57
|
+
problems = []
|
|
58
|
+
for tree in ("skills", os.path.join(".pi", "skills")):
|
|
59
|
+
pattern = os.path.join(root, tree, "*", "SKILL.md")
|
|
60
|
+
for smd in sorted(glob.glob(pattern)):
|
|
61
|
+
problems.extend(check_file_links(smd, root))
|
|
62
|
+
return problems
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> int:
|
|
66
|
+
root = resolve_repo_root()
|
|
67
|
+
problems = check(root)
|
|
68
|
+
if problems:
|
|
69
|
+
print(f"check-skill-links: {len(problems)} problem(s):")
|
|
70
|
+
for p in problems:
|
|
71
|
+
print(f" {p}")
|
|
72
|
+
return 1
|
|
73
|
+
print("check-skill-links: OK — no machine paths or dangling links")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|
|
@@ -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
|
|
@@ -32,10 +32,11 @@ BLOCK="<!-- AUTO-GENERATED-CATALOG: begin — do not edit manually; run scripts/
|
|
|
32
32
|
for row in "${ROWS[@]}"; do
|
|
33
33
|
skill="${row%% *}"
|
|
34
34
|
model="${row##* }"
|
|
35
|
+
skill_rel="${skill#$REPO_ROOT/}"
|
|
35
36
|
# capitalise first letter for display
|
|
36
37
|
model_cap="$(echo "${model:0:1}" | tr '[:lower:]' '[:upper:]')${model:1}"
|
|
37
38
|
BLOCK+="
|
|
38
|
-
| \`$
|
|
39
|
+
| \`$skill_rel\` | **$model_cap** |"
|
|
39
40
|
done
|
|
40
41
|
BLOCK+="
|
|
41
42
|
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""link_utils.py — shared Markdown link patterns used by srp-engine and check-skill-links.
|
|
3
|
+
|
|
4
|
+
Single source of truth for what constitutes a "link" so the rewriter and the
|
|
5
|
+
regression gate always agree on the definition.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
# Matches standard Markdown links — with or without an optional title attribute.
|
|
10
|
+
# [text](target)
|
|
11
|
+
# [text](target "title")
|
|
12
|
+
# Group 1: link text, Group 2: raw target (may include URL fragment)
|
|
13
|
+
LINK_RE = re.compile(
|
|
14
|
+
r'\[([^\]]*)\]\(' # [text](
|
|
15
|
+
r'([^)\s"]+)' # target (no spaces, no quotes)
|
|
16
|
+
r'(?:\s+"[^"]*")?' # optional title attribute: "title text"
|
|
17
|
+
r'\s*\)' # closing )
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Matches scheme-prefixed (non-relative) URLs that should never be rewritten
|
|
21
|
+
# or resolved as local file paths.
|
|
22
|
+
EXTERNAL_RE = re.compile(r'(?i)^(https?|ftp|file|mailto):')
|
|
23
|
+
|
|
24
|
+
# Detects machine-absolute paths that must not appear in skill docs.
|
|
25
|
+
# Covers macOS (/Users/), Linux (/home/), and Windows (C:\Users, D:\Users …).
|
|
26
|
+
MACHINE_PATH_RE = re.compile(r'file:///|/Users/|/home/|[A-Z]:\\Users')
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Markdown code-block stripping
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# Fenced code block: ``` or ~~~ with optional language tag, any content, close fence.
|
|
33
|
+
_FENCED_BLOCK_RE = re.compile(r'(?m)^(`{3,}|~{3,})[^\n]*\n.*?\n\1\s*$', re.DOTALL)
|
|
34
|
+
# Inline code: `…` (single backtick, non-empty)
|
|
35
|
+
_INLINE_CODE_RE = re.compile(r'`[^`\n]+`')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def strip_code_spans(text: str) -> str:
|
|
39
|
+
"""Return *text* with fenced blocks and inline code replaced by blank space.
|
|
40
|
+
|
|
41
|
+
Preserves original character positions so that any offset-based logic
|
|
42
|
+
remains valid; only the link-matching content is neutralised.
|
|
43
|
+
"""
|
|
44
|
+
def _blank(m: re.Match) -> str:
|
|
45
|
+
return ' ' * len(m.group(0))
|
|
46
|
+
|
|
47
|
+
text = _FENCED_BLOCK_RE.sub(_blank, text)
|
|
48
|
+
text = _INLINE_CODE_RE.sub(_blank, text)
|
|
49
|
+
return text
|
|
@@ -7,6 +7,83 @@ import glob
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
10
|
+
# Ensure scripts/lib is on the path so link_utils resolves regardless of cwd.
|
|
11
|
+
_LIB_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
12
|
+
if _LIB_DIR not in sys.path:
|
|
13
|
+
sys.path.insert(0, _LIB_DIR)
|
|
14
|
+
|
|
15
|
+
from link_utils import LINK_RE, EXTERNAL_RE, strip_code_spans # noqa: E402
|
|
16
|
+
|
|
17
|
+
def rewrite_links_for_pi(body, name):
|
|
18
|
+
"""Repoint relative links so they resolve from .pi/skills/<name>/.
|
|
19
|
+
|
|
20
|
+
Sibling *.md content is inlined into the rendered SKILL.md body, but the
|
|
21
|
+
links to those files (and to repo-root docs) still point at paths relative
|
|
22
|
+
to the source dir, so they dangle in the rendered tree. Repoint them at
|
|
23
|
+
the shipped source tree (skills/, profiles/, repo-root files) so links
|
|
24
|
+
resolve in repo checkouts and npm installs alike. External, anchor, and
|
|
25
|
+
absolute links pass through untouched.
|
|
26
|
+
|
|
27
|
+
Code fences and inline code are skipped — links inside them are examples,
|
|
28
|
+
not navigation targets.
|
|
29
|
+
"""
|
|
30
|
+
out_dir = os.path.join(".pi", "skills", name)
|
|
31
|
+
# Build a "shadow" copy with code spans blanked out so LINK_RE never
|
|
32
|
+
# matches inside ``` blocks or inline `code`.
|
|
33
|
+
shadow = strip_code_spans(body)
|
|
34
|
+
|
|
35
|
+
def repl(m):
|
|
36
|
+
# If this match falls inside a code span the shadow char is a space;
|
|
37
|
+
# the real body char at that position is not a '[' — so LINK_RE won't
|
|
38
|
+
# match in the shadow at all. The sub() runs on the shadow but we
|
|
39
|
+
# return the replacement for the real body using the captured groups.
|
|
40
|
+
text, raw_target = m.group(1), m.group(2).strip()
|
|
41
|
+
if EXTERNAL_RE.match(raw_target) or raw_target.startswith(('#', '/')):
|
|
42
|
+
return m.group(0)
|
|
43
|
+
# Split fragment before normpath to avoid mangling '#section/sub'.
|
|
44
|
+
if '#' in raw_target:
|
|
45
|
+
path_part, fragment = raw_target.split('#', 1)
|
|
46
|
+
frag_suffix = '#' + fragment
|
|
47
|
+
else:
|
|
48
|
+
path_part, frag_suffix = raw_target, ''
|
|
49
|
+
resolved = os.path.normpath(os.path.join("skills", name, path_part))
|
|
50
|
+
if resolved.startswith(".."):
|
|
51
|
+
return m.group(0) # escapes repo root; leave untouched
|
|
52
|
+
new_target = os.path.relpath(resolved, out_dir).replace(os.sep, '/') + frag_suffix
|
|
53
|
+
return f"[{text}]({new_target})"
|
|
54
|
+
|
|
55
|
+
return LINK_RE.sub(repl, shadow).replace(shadow, body) if shadow == body else _apply_repl_on_real(body, shadow, name, out_dir)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _apply_repl_on_real(body, shadow, name, out_dir):
|
|
59
|
+
"""Apply link rewrites to *body* using *shadow* to skip code spans.
|
|
60
|
+
|
|
61
|
+
Iterates LINK_RE matches on *shadow* (which has code spans blanked) and
|
|
62
|
+
rebuilds body with only the matched positions replaced.
|
|
63
|
+
"""
|
|
64
|
+
result = []
|
|
65
|
+
last = 0
|
|
66
|
+
for m in LINK_RE.finditer(shadow):
|
|
67
|
+
result.append(body[last:m.start()])
|
|
68
|
+
text, raw_target = m.group(1), m.group(2).strip()
|
|
69
|
+
if EXTERNAL_RE.match(raw_target) or raw_target.startswith(('#', '/')):
|
|
70
|
+
result.append(body[m.start():m.end()])
|
|
71
|
+
else:
|
|
72
|
+
if '#' in raw_target:
|
|
73
|
+
path_part, fragment = raw_target.split('#', 1)
|
|
74
|
+
frag_suffix = '#' + fragment
|
|
75
|
+
else:
|
|
76
|
+
path_part, frag_suffix = raw_target, ''
|
|
77
|
+
resolved = os.path.normpath(os.path.join("skills", name, path_part))
|
|
78
|
+
if resolved.startswith(".."):
|
|
79
|
+
result.append(body[m.start():m.end()])
|
|
80
|
+
else:
|
|
81
|
+
new_target = os.path.relpath(resolved, out_dir).replace(os.sep, '/') + frag_suffix
|
|
82
|
+
result.append(f"[{text}]({new_target})")
|
|
83
|
+
last = m.end()
|
|
84
|
+
result.append(body[last:])
|
|
85
|
+
return ''.join(result)
|
|
86
|
+
|
|
10
87
|
def resolve_repo_root():
|
|
11
88
|
lib_dir = os.path.dirname(os.path.abspath(__file__))
|
|
12
89
|
# scripts/lib/srp-engine.py -> two levels up is REPO_ROOT
|
|
@@ -210,7 +287,11 @@ def main():
|
|
|
210
287
|
continue
|
|
211
288
|
|
|
212
289
|
for adapter in adapters:
|
|
213
|
-
|
|
290
|
+
data = skill_data
|
|
291
|
+
if adapter == "pi":
|
|
292
|
+
data = dict(skill_data)
|
|
293
|
+
data["body_pi_skill"] = rewrite_links_for_pi(skill_data['body'], skill_data['name'])
|
|
294
|
+
dispatch_to_adapter(data, adapter, repo_root)
|
|
214
295
|
|
|
215
296
|
if okf_mode:
|
|
216
297
|
render_okf_concept(skill_data, okf_wiki_skills)
|
|
@@ -235,6 +316,10 @@ def main():
|
|
|
235
316
|
|
|
236
317
|
skill_data = parse_skill(skill_md_path)
|
|
237
318
|
|
|
319
|
+
if target == "pi":
|
|
320
|
+
skill_data = dict(skill_data)
|
|
321
|
+
skill_data["body_pi_skill"] = rewrite_links_for_pi(skill_data['body'], skill_data['name'])
|
|
322
|
+
|
|
238
323
|
if dry_run or not target:
|
|
239
324
|
print(json.dumps(skill_data, indent=2))
|
|
240
325
|
return
|
|
@@ -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; }
|