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.
Files changed (93) hide show
  1. package/.pi/package.json +2 -2
  2. package/.pi/prompts/audit-code.md +1 -1
  3. package/.pi/prompts/build-epic.md +10 -0
  4. package/.pi/prompts/craft-skill.md +2 -2
  5. package/.pi/prompts/elaborate-spec.md +1 -1
  6. package/.pi/prompts/evolve-skill.md +1 -1
  7. package/.pi/prompts/generate-allure-report.md +284 -0
  8. package/.pi/prompts/grill-me.md +1 -1
  9. package/.pi/prompts/guard-git.md +1 -1
  10. package/.pi/prompts/harden-vps.md +287 -0
  11. package/.pi/prompts/investigate-bug.md +2 -2
  12. package/.pi/prompts/model-domain.md +3 -3
  13. package/.pi/prompts/plan-refactor.md +2 -2
  14. package/.pi/prompts/plan-release.md +5 -10
  15. package/.pi/prompts/plan-work.md +14 -1
  16. package/.pi/prompts/using-bigpowers.md +1 -1
  17. package/.pi/prompts/write-document.md +2 -2
  18. package/.pi/skills/audit-code/SKILL.md +1 -1
  19. package/.pi/skills/build-epic/SKILL.md +10 -0
  20. package/.pi/skills/change-request/SKILL.md +1 -1
  21. package/.pi/skills/commit-message/SKILL.md +3 -3
  22. package/.pi/skills/compose-workflow/SKILL.md +1 -1
  23. package/.pi/skills/craft-skill/SKILL.md +4 -4
  24. package/.pi/skills/deepen-architecture/SKILL.md +13 -13
  25. package/.pi/skills/deploy/SKILL.md +4 -4
  26. package/.pi/skills/develop-tdd/SKILL.md +5 -5
  27. package/.pi/skills/elaborate-spec/SKILL.md +1 -1
  28. package/.pi/skills/evolve-skill/SKILL.md +2 -2
  29. package/.pi/skills/extract-design/SKILL.md +1 -1
  30. package/.pi/skills/generate-allure-report/SKILL.md +285 -0
  31. package/.pi/skills/grill-me/SKILL.md +2 -2
  32. package/.pi/skills/grill-with-docs/SKILL.md +1 -1
  33. package/.pi/skills/guard-git/SKILL.md +5 -5
  34. package/.pi/skills/harden-vps/SKILL.md +288 -0
  35. package/.pi/skills/investigate-bug/SKILL.md +2 -2
  36. package/.pi/skills/migrate-spec/SKILL.md +13 -13
  37. package/.pi/skills/model-domain/SKILL.md +5 -5
  38. package/.pi/skills/orchestrate-project/SKILL.md +2 -2
  39. package/.pi/skills/organize-workspace/SKILL.md +1 -1
  40. package/.pi/skills/plan-refactor/SKILL.md +2 -2
  41. package/.pi/skills/plan-release/SKILL.md +5 -10
  42. package/.pi/skills/plan-work/SKILL.md +15 -2
  43. package/.pi/skills/publish-package/SKILL.md +8 -8
  44. package/.pi/skills/release-branch/SKILL.md +2 -2
  45. package/.pi/skills/research-first/SKILL.md +1 -1
  46. package/.pi/skills/run-evals/SKILL.md +1 -1
  47. package/.pi/skills/security-review/SKILL.md +3 -3
  48. package/.pi/skills/seed-conventions/SKILL.md +3 -3
  49. package/.pi/skills/smoke-test/SKILL.md +5 -5
  50. package/.pi/skills/stocktake-skills/SKILL.md +1 -1
  51. package/.pi/skills/using-bigpowers/SKILL.md +1 -1
  52. package/.pi/skills/validate-contracts/SKILL.md +6 -6
  53. package/.pi/skills/verify-work/SKILL.md +1 -1
  54. package/.pi/skills/wire-ci/SKILL.md +8 -8
  55. package/.pi/skills/write-document/SKILL.md +3 -3
  56. package/CHANGELOG.md +14 -0
  57. package/README.md +1 -1
  58. package/SKILL-INDEX.md +1 -1
  59. package/allure-results/categories.json +23 -0
  60. package/allure-results/executor.json +6 -0
  61. package/allure-results/junit-results.xml +2743 -0
  62. package/package.json +4 -3
  63. package/scripts/adapters/pi.sh +4 -1
  64. package/scripts/check-skill-links.py +78 -0
  65. package/scripts/generate-allure-report.sh +146 -0
  66. package/scripts/generate-reference-tables.sh +2 -1
  67. package/scripts/lib/__pycache__/link_utils.cpython-312.pyc +0 -0
  68. package/scripts/lib/link_utils.py +49 -0
  69. package/scripts/lib/srp-engine.py +86 -1
  70. package/scripts/record-cycle-time.sh +28 -0
  71. package/scripts/sync-status-from-epics.sh +247 -24
  72. package/scripts/validate-skill-catalog.sh +1 -1
  73. package/skills/audit-code/SKILL.md +1 -1
  74. package/skills/build-epic/SKILL.md +10 -0
  75. package/skills/craft-skill/SKILL.md +2 -2
  76. package/skills/elaborate-spec/SKILL.md +1 -1
  77. package/skills/evolve-skill/REFERENCE.md +1 -1
  78. package/skills/generate-allure-report/REFERENCE.md +243 -0
  79. package/skills/generate-allure-report/SKILL.md +40 -0
  80. package/skills/grill-me/REFERENCE.md +1 -1
  81. package/skills/guard-git/SKILL.md +1 -1
  82. package/skills/harden-vps/REFERENCE.md +179 -0
  83. package/skills/harden-vps/SKILL.md +107 -0
  84. package/skills/investigate-bug/SKILL.md +2 -2
  85. package/skills/model-domain/CONTEXT-FORMAT.md +3 -3
  86. package/skills/plan-refactor/SKILL.md +2 -2
  87. package/skills/plan-release/SKILL.md +5 -10
  88. package/skills/plan-work/REFERENCE.md +13 -0
  89. package/skills/plan-work/SKILL.md +1 -1
  90. package/skills/using-bigpowers/SKILL.md +1 -1
  91. package/skills/write-document/REFERENCE.md +2 -2
  92. package/skills-lock.json +20 -10
  93. 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.76.3",
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",
@@ -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
- | \`$skill\` | **$model_cap** |"
39
+ | \`$skill_rel\` | **$model_cap** |"
39
40
  done
40
41
  BLOCK+="
41
42
 
@@ -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
- dispatch_to_adapter(skill_data, adapter, repo_root)
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; }