bigpowers 2.65.1 → 2.67.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 (48) hide show
  1. package/.fix_regex.sh +16 -0
  2. package/.hermes-verify-sed-fix.py +53 -0
  3. package/.pi/package.json +2 -2
  4. package/.pi/prompts/build-epic.md +8 -0
  5. package/.pi/prompts/gate-trace.md +2 -1
  6. package/.pi/prompts/kickoff-branch.md +19 -0
  7. package/.pi/prompts/maintain-wiki.md +63 -0
  8. package/.pi/prompts/plan-tests.md +1 -1
  9. package/.pi/prompts/release-branch.md +17 -13
  10. package/.pi/prompts/verify-work.md +9 -5
  11. package/.pi/skills/build-epic/SKILL.md +8 -0
  12. package/.pi/skills/gate-trace/SKILL.md +2 -1
  13. package/.pi/skills/kickoff-branch/SKILL.md +19 -0
  14. package/.pi/skills/maintain-wiki/SKILL.md +65 -0
  15. package/.pi/skills/plan-tests/SKILL.md +1 -1
  16. package/.pi/skills/release-branch/SKILL.md +17 -13
  17. package/.pi/skills/verify-work/SKILL.md +9 -5
  18. package/CHANGELOG.md +20 -0
  19. package/README.md +1 -1
  20. package/SKILL-INDEX.md +1 -1
  21. package/_test_e28s01.sh +2 -0
  22. package/hermes-verify-sed-fix.py +30 -0
  23. package/package.json +1 -1
  24. package/scripts/build-skill-graph.sh +151 -183
  25. package/scripts/check-spec-drift.sh +121 -0
  26. package/scripts/check-spec-version-gap.sh +143 -0
  27. package/scripts/decompose-conventions.sh +147 -0
  28. package/scripts/generate-agent-guide.sh +89 -0
  29. package/scripts/hermes-verify-e39s02.sh +28 -0
  30. package/scripts/migrate-version.sh +721 -0
  31. package/scripts/okf-post-sync.sh +163 -0
  32. package/scripts/run-golden-suite.sh +235 -27
  33. package/scripts/validate-okf.sh +18 -0
  34. package/skills/build-epic/SKILL.md +8 -0
  35. package/skills/gate-trace/SKILL.md +2 -1
  36. package/skills/kickoff-branch/SKILL.md +19 -0
  37. package/skills/maintain-wiki/SKILL.md +66 -0
  38. package/skills/plan-tests/SKILL.md +1 -1
  39. package/skills/release-branch/SKILL.md +17 -13
  40. package/skills/verify-work/SKILL.md +9 -5
  41. package/skills-lock.json +11 -6
  42. package/tests/fixtures/v1.x-project/CLAUDE.md +4 -0
  43. package/tests/fixtures/v1.x-project/CONVENTIONS.md +4 -0
  44. package/tests/fixtures/v2.0.0-project/CLAUDE.md +4 -0
  45. package/tests/fixtures/v2.0.0-project/CONVENTIONS.md +3 -0
  46. package/tests/fixtures/v2.20-project/CLAUDE.md +4 -0
  47. package/tests/fixtures/v2.20-project/CONVENTIONS.md +4 -0
  48. package/tests/run-golden-migrate-version.sh +155 -0
@@ -1,226 +1,194 @@
1
1
  #!/usr/bin/env bash
2
2
  # story: e39s01
3
- # Consumes bigpowers-mcp build_skill_graph MCP tool output (graph.jsonl)
4
- # and emits specs/skill-graph.json + specs/skills-wiki/ OKF concepts.
5
- #
6
- # Supersedes standalone SKILL.md parsing approach (2026-07-04).
7
- # Graph must be built first: run MCP tool `build_skill_graph` or
8
- # `node -e "..."` to generate bigpowers-mcp/graph.jsonl.
9
-
3
+ # Consume e32 build_skill_graph MCP output (bigpowers-mcp/graph.jsonl) and emit:
4
+ # - specs/skill-graph.json — machine-readable dependency graph
5
+ # - specs/skills-wiki/ — OKF wiki with per-skill pages
10
6
  set -euo pipefail
11
7
 
12
8
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
9
  REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
14
- GRAPH_IN="$REPO_ROOT/bigpowers-mcp/graph.jsonl"
15
- GRAPH_OUT="$REPO_ROOT/specs/skill-graph.json"
16
- WIKI_DIR="$REPO_ROOT/specs/skills-wiki/skills"
10
+ GRAPH_JSONL="$REPO_ROOT/bigpowers-mcp/graph.jsonl"
11
+ SKILL_GRAPH_JSON="$REPO_ROOT/specs/skill-graph.json"
12
+ SKILLS_WIKI="$REPO_ROOT/specs/skills-wiki/skills"
17
13
 
18
- # --help
19
- if [[ "${1:-}" == "--help" ]]; then
14
+ show_help() {
20
15
  cat <<EOF
21
- Usage: build-skill-graph.sh
16
+ Usage: $(basename "$0") [OPTION]
22
17
 
23
- Reads bigpowers-mcp/graph.jsonl (generated by e32 build_skill_graph MCP tool)
24
- and produces:
25
- specs/skill-graph.json — nodes + typed edges (depends_on, gates, references,
26
- enforces, handoff_to)
27
- specs/skills-wiki/ — one OKF concept markdown file per skill
18
+ Consume bigpowers-mcp/graph.jsonl and emit:
19
+ - specs/skill-graph.json — full skill dependency graph
20
+ - specs/skills-wiki/skills/*.md — per-skill OKF wiki pages
28
21
 
29
22
  Options:
30
- --help Show this message
31
- --json Write only specs/skill-graph.json (skip OKF wiki)
32
- --wiki Write only specs/skills-wiki/ (skip JSON graph)
33
-
34
- Requires: bigpowers-mcp/graph.jsonl (run build_skill_graph MCP tool first)
23
+ --help Show this help and exit
24
+ --wiki Regenerate only the skills-wiki OKF bundle (skip graph.json)
35
25
  EOF
36
- exit 0
37
- fi
26
+ }
38
27
 
39
- MODE="${1:-both}"
40
- case "$MODE" in
41
- --json) DO_JSON=1; DO_WIKI=0 ;;
42
- --wiki) DO_JSON=0; DO_WIKI=1 ;;
43
- both) DO_JSON=1; DO_WIKI=1 ;;
44
- *) echo "Unknown option: $MODE" >&2; exit 1 ;;
45
- esac
46
-
47
- # Pre-flight: graph.jsonl must exist
48
- if [[ ! -f "$GRAPH_IN" ]]; then
49
- cat >&2 <<EOF
50
- ERROR: $GRAPH_IN not found.
51
-
52
- The skill graph has not been built yet. Run the bigpowers-mcp
53
- build_skill_graph tool to generate it:
54
-
55
- # Option A: via MCP inspector
56
- npx @modelcontextprotocol/inspector node bigpowers-mcp/build/index.js
57
- # Then call build_skill_graph
58
-
59
- # Option B: via Node.js directly
60
- node -e "
61
- const {discoverSkills,readSkillFile}=require('./bigpowers-mcp/build/lib/skill-parser.js');
62
- const {buildGraphFromSkills}=require('./bigpowers-mcp/build/lib/graph/builder.js');
63
- const {saveGraph}=require('./bigpowers-mcp/build/lib/graph/store.js');
64
- const skills=discoverSkills(process.cwd(),()=>'');
65
- const parsed=skills.map(s=>readSkillFile(process.cwd(),s.name));
66
- saveGraph('bigpowers-mcp/graph.jsonl', buildGraphFromSkills(parsed));
67
- "
68
- EOF
28
+ # --- Error handling ---
29
+ missing_graph() {
30
+ echo "[ERROR] MCP graph not found at: $GRAPH_JSONL" >&2
31
+ echo "[ERROR] Run 'cd bigpowers-mcp && npm start' or e32s02 MCP tool first" >&2
69
32
  exit 1
70
- fi
33
+ }
71
34
 
72
- # Parse graph.jsonl into nodes + edges
73
- echo "Reading $GRAPH_IN ..." >&2
35
+ if [ ! -f "$GRAPH_JSONL" ]; then
36
+ missing_graph
37
+ fi
74
38
 
75
- NODES_JSON="$REPO_ROOT/specs/.tmp-nodes.json"
76
- EDGES_JSON="$REPO_ROOT/specs/.tmp-edges.json"
39
+ # --- Read graph.jsonl into temporary JSON ---
40
+ TMP_NODES=$(mktemp)
41
+ TMP_EDGES=$(mktemp)
42
+ TMP_ALL=$(mktemp)
77
43
 
78
- python3 - "$GRAPH_IN" "$NODES_JSON" "$EDGES_JSON" <<'PYEOF'
44
+ python3 -c "
79
45
  import json, sys
80
- from pathlib import Path
81
46
 
82
- graph_in = Path(sys.argv[1])
83
- nodes_out = Path(sys.argv[2])
84
- edges_out = Path(sys.argv[3])
85
-
86
- nodes = []
47
+ nodes = {}
87
48
  edges = []
88
- entity_count = 0
89
- relation_count = 0
90
- parse_errors = 0
91
-
92
- with open(graph_in) as f:
93
- for lineno, line in enumerate(f, 1):
94
- line = line.strip()
95
- if not line:
96
- continue
97
- try:
98
- obj = json.loads(line)
99
- except json.JSONDecodeError as e:
100
- print(f"WARNING: {graph_in}:{lineno}: invalid JSON — {e}", file=sys.stderr)
101
- parse_errors += 1
102
- continue
103
-
104
- if obj.get("type") == "entity":
105
- nodes.append({
106
- "name": obj["name"],
107
- "entityType": obj.get("entityType", "Skill"),
108
- "observations": obj.get("observations", []),
49
+
50
+ with open('$GRAPH_JSONL') as f:
51
+ for line in f:
52
+ obj = json.loads(line)
53
+ if obj.get('type') == 'entity':
54
+ nodes[obj['name']] = {
55
+ 'id': obj['name'],
56
+ 'type': obj.get('entityType', 'Skill'),
57
+ 'observations': obj.get('observations', [])
58
+ }
59
+ elif obj.get('type') == 'relation':
60
+ edges.append({
61
+ 'source': obj.get('from', ''),
62
+ 'target': obj.get('to', ''),
63
+ 'relation': obj.get('relationType', 'references'),
64
+ 'provenance': obj.get('provenance', '')
109
65
  })
110
- entity_count += 1
111
- elif obj.get("type") == "relation":
66
+
67
+ # Emit node list
68
+ print(json.dumps(list(nodes.values()), indent=2))
69
+ " > "$TMP_NODES"
70
+
71
+ python3 -c "
72
+ import json
73
+ # re-read edges into a separate file
74
+ edges = []
75
+ with open('$GRAPH_JSONL') as f:
76
+ for line in f:
77
+ obj = json.loads(line)
78
+ if obj.get('type') == 'relation':
112
79
  edges.append({
113
- "from": obj["from"],
114
- "to": obj["to"],
115
- "relationType": obj.get("relationType", "unknown"),
80
+ 'source': obj.get('from', ''),
81
+ 'target': obj.get('to', ''),
82
+ 'relation': obj.get('relationType', 'references'),
83
+ 'provenance': obj.get('provenance', '')
116
84
  })
117
- relation_count += 1
118
- else:
119
- print(f"WARNING: {graph_in}:{lineno}: unknown type {obj.get('type')}", file=sys.stderr)
120
-
121
- with open(nodes_out, "w") as f:
122
- json.dump(nodes, f, indent=2)
123
- with open(edges_out, "w") as f:
124
- json.dump(edges, f, indent=2)
125
-
126
- print(f" Entities: {entity_count}, Relations: {relation_count}", file=sys.stderr)
127
- if parse_errors:
128
- print(f" Parse errors: {parse_errors}", file=sys.stderr)
129
- PYEOF
130
-
131
- # Emit specs/skill-graph.json
132
- if [[ "$DO_JSON" -eq 1 ]]; then
133
- echo "Writing $GRAPH_OUT ..." >&2
134
- python3 - "$NODES_JSON" "$EDGES_JSON" "$GRAPH_OUT" <<'PYEOF'
135
- import json, sys
85
+ print(json.dumps(edges, indent=2))
86
+ " > "$TMP_EDGES"
87
+
88
+ # --- Write skill-graph.json ---
89
+ python3 -c "
90
+ import json
136
91
 
137
- with open(sys.argv[1]) as f:
92
+ with open('$TMP_NODES') as f:
138
93
  nodes = json.load(f)
139
- with open(sys.argv[2]) as f:
94
+ with open('$TMP_EDGES') as f:
140
95
  edges = json.load(f)
141
96
 
142
- graph = {"nodes": nodes, "edges": edges}
143
- with open(sys.argv[3], "w") as f:
97
+ graph = {
98
+ 'meta': {
99
+ 'source': 'bigpowers-mcp/graph.jsonl',
100
+ 'generated_by': 'scripts/build-skill-graph.sh',
101
+ 'node_count': len(nodes),
102
+ 'edge_count': len(edges)
103
+ },
104
+ 'nodes': nodes,
105
+ 'edges': edges
106
+ }
107
+
108
+ with open('$SKILL_GRAPH_JSON', 'w') as f:
144
109
  json.dump(graph, f, indent=2)
145
110
 
146
- print(f" nodes: {len(nodes)}, edges: {len(edges)}", file=sys.stderr)
147
- PYEOF
148
- fi
111
+ print(f'OK: {len(nodes)} nodes, {len(edges)} edges written to specs/skill-graph.json')
112
+ "
149
113
 
150
- # Emit specs/skills-wiki/ OKF concepts
151
- if [[ "$DO_WIKI" -eq 1 ]]; then
152
- echo "Writing $WIKI_DIR/ ..." >&2
153
- rm -rf "$WIKI_DIR"
154
- mkdir -p "$WIKI_DIR"
114
+ # --- Emit specs/skills-wiki/ OKF bundle ---
115
+ WIKI_INDEX="$REPO_ROOT/specs/skills-wiki/index.md"
116
+ mkdir -p "$SKILLS_WIKI"
155
117
 
156
- python3 - "$NODES_JSON" "$WIKI_DIR" <<'PYEOF'
157
- import json, sys, re
158
- from pathlib import Path
118
+ # Generate index page
119
+ cat > "$WIKI_INDEX" << WEOF
120
+ # Skills Wiki — OKF Bundle
159
121
 
160
- nodes_file = Path(sys.argv[1])
161
- wiki_dir = Path(sys.argv[2])
122
+ ---
123
+ type: Index
124
+ title: Skills Wiki
125
+ context: benchmark
126
+ ---
127
+
128
+ **Generated by:** scripts/build-skill-graph.sh
129
+ **Source:** bigpowers-mcp/graph.jsonl
130
+
131
+ ## Skill Index
162
132
 
163
- with open(nodes_file) as f:
133
+ WEOF
134
+
135
+ # Generate one .md per skill
136
+ python3 -c "
137
+ import json, os
138
+
139
+ # Re-read nodes
140
+ with open('$TMP_NODES') as f:
164
141
  nodes = json.load(f)
165
142
 
166
- count = 0
167
- for node in nodes:
168
- name = node["name"]
169
- observations = node.get("observations", [])
170
-
171
- model = ""
172
- effort = ""
173
- description = ""
174
- for obs in observations:
175
- if obs.startswith("model: "):
176
- model = obs[7:]
177
- elif obs.startswith("effort: "):
178
- effort = obs[8:]
179
- elif obs.startswith("description: "):
180
- description = obs[13:]
181
-
182
- # Derive phase from name convention (simplified)
183
- phase = "utility"
184
- critical_path = [
185
- "survey-context", "elaborate-spec", "model-domain", "define-language",
186
- "design-interface", "plan-work", "plan-release", "slice-tasks",
187
- "kickoff-branch", "develop-tdd", "verify-work", "audit-code",
188
- "commit-message", "release-branch", "security-review",
189
- "assess-impact", "investigate-bug", "validate-fix",
190
- ]
191
- if name in critical_path:
192
- phase = "critical-path"
193
-
194
- safe_desc = description.replace('"', '\\"')
195
- md = f"""---
196
- type: Skill
197
- name: {name}
198
- phase: {phase}
199
- model: {model}
200
- effort: {effort}
201
- description: "{safe_desc}"
202
- ---
143
+ skills_dir = '$SKILLS_WIKI'
144
+ index_path = '$WIKI_INDEX'
203
145
 
204
- # {name}
146
+ # Sort by phase then name
147
+ nodes.sort(key=lambda n: (n.get('type', ''), n['id']))
205
148
 
206
- **Phase:** {phase}
207
- **Model:** {model}
208
- **Effort:** {effort}
149
+ with open(index_path, 'a') as idx:
150
+ for node in nodes:
151
+ name = node['id']
152
+ ntype = node.get('type', 'Skill')
153
+ obs = node.get('observations', [])
209
154
 
210
- {description}
155
+ slug = name # already kebab-case
211
156
 
212
- > Auto-generated by build-skill-graph.sh (e39s01) from bigpowers-mcp/graph.jsonl.
213
- > Source of truth: skills/{name}/SKILL.md
214
- """
215
- out_path = wiki_dir / f"{name}.md"
216
- out_path.write_text(md)
217
- count += 1
157
+ # Extract key fields from observations
158
+ model = ''
159
+ effort = ''
160
+ desc = ''
161
+ for o in obs:
162
+ if o.startswith('model:'):
163
+ model = o.replace('model:', '').strip()
164
+ elif o.startswith('effort:'):
165
+ effort = o.replace('effort:', '').strip()
166
+ elif o.startswith('description:'):
167
+ desc = o.replace('description:', '').strip()
218
168
 
219
- print(f" concepts written: {count}", file=sys.stderr)
220
- PYEOF
221
- fi
169
+ # Write skill page
170
+ page = f'''# {name}
171
+
172
+ **Type:** {ntype}
173
+ **Model:** {model or 'default'}
174
+ **Effort:** {effort or 'standard'}
175
+
176
+ {desc}
177
+
178
+ ---
179
+
180
+ *Auto-generated by scripts/build-skill-graph.sh*
181
+ '''
182
+ with open(os.path.join(skills_dir, f'{slug}.md'), 'w') as pf:
183
+ pf.write(page)
184
+
185
+ # Add to index
186
+ idx.write(f'- [{name}](skills/{slug}.md) — {desc[:80] if desc else \"\"}\\n')
187
+
188
+ print(f'OK: {len(nodes)} skill pages written to specs/skills-wiki/skills/')
189
+ "
222
190
 
223
191
  # Cleanup
224
- rm -f "$NODES_JSON" "$EDGES_JSON"
192
+ rm -f "$TMP_NODES" "$TMP_EDGES" "$TMP_ALL"
225
193
 
226
- echo "Done." >&2
194
+ echo "Done."
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env bash
2
+ # story: e39s03
3
+ # Compare spec-file mtimes against implementing source-file mtimes,
4
+ # mark suspect links, write to specs/drift-report.json
5
+ set -euo pipefail
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
9
+ TRACE_MATRIX="$REPO_ROOT/specs/traceability-matrix.json"
10
+ DRIFT_REPORT="$REPO_ROOT/specs/drift-report.json"
11
+
12
+ show_help() {
13
+ cat <<EOF
14
+ Usage: $(basename "$0") [OPTION]
15
+
16
+ Compare spec/story mtimes vs implementing file mtimes to detect drift.
17
+ Output: specs/drift-report.json with suspect and broken links.
18
+
19
+ Options:
20
+ --help Show this help and exit
21
+ EOF
22
+ }
23
+
24
+ if [ "$#" -eq 1 ] && [ "$1" = "--help" ]; then
25
+ show_help
26
+ exit 0
27
+ fi
28
+
29
+ if [ ! -f "$TRACE_MATRIX" ]; then
30
+ echo "[ERROR] Traceability matrix not found: $TRACE_MATRIX" >&2
31
+ echo "[ERROR] Run scripts/trace-stories.sh first" >&2
32
+ exit 1
33
+ fi
34
+
35
+ python3 -c "
36
+ import json, os, sys
37
+
38
+ with open('$TRACE_MATRIX') as f:
39
+ matrix = json.load(f)
40
+
41
+ drift = {
42
+ 'meta': {
43
+ 'generated_by': 'scripts/check-spec-drift.sh',
44
+ 'source': 'specs/traceability-matrix.json',
45
+ 'timestamp': __import__('datetime').datetime.now(__import__('datetime').timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
46
+ },
47
+ 'suspect_links': [],
48
+ 'broken_links': [],
49
+ 'summary': {'suspect': 0, 'broken': 0, 'ok': 0}
50
+ }
51
+
52
+ repo_root = '$REPO_ROOT'
53
+
54
+ stories = matrix.get('stories', [])
55
+ assert isinstance(stories, list), 'stories must be a list'
56
+
57
+ for entry in stories:
58
+ story_id = entry.get('id', '')
59
+ spec_path = entry.get('spec_path', '')
60
+ links = entry.get('links', [])
61
+
62
+ if not links:
63
+ drift['broken_links'].append({
64
+ 'story_id': story_id,
65
+ 'reason': 'no trace links',
66
+ 'severity': 'broken'
67
+ })
68
+ drift['summary']['broken'] += 1
69
+ continue
70
+
71
+ impl_files = [link['file'] for link in links if link.get('file')]
72
+
73
+ if not impl_files:
74
+ drift['broken_links'].append({
75
+ 'story_id': story_id,
76
+ 'reason': 'no implementing files in trace links',
77
+ 'severity': 'broken'
78
+ })
79
+ drift['summary']['broken'] += 1
80
+ continue
81
+
82
+ suspect_files = []
83
+
84
+ for impl_file in impl_files:
85
+ impl_full = os.path.join(repo_root, impl_file) if not os.path.isabs(impl_file) else impl_file
86
+ if os.path.exists(impl_full):
87
+ impl_mtime = os.path.getmtime(impl_full)
88
+ if spec_path and os.path.exists(spec_path):
89
+ spec_mtime = os.path.getmtime(spec_path)
90
+ if impl_mtime > spec_mtime:
91
+ suspect_files.append({
92
+ 'file': impl_file,
93
+ 'impl_mtime': impl_mtime,
94
+ 'spec_mtime': spec_mtime,
95
+ 'age_seconds': impl_mtime - spec_mtime
96
+ })
97
+ else:
98
+ drift['broken_links'].append({
99
+ 'story_id': story_id,
100
+ 'spec': spec_path,
101
+ 'file': impl_file,
102
+ 'reason': 'implementing file does not exist',
103
+ 'severity': 'broken'
104
+ })
105
+ drift['summary']['broken'] += 1
106
+
107
+ if suspect_files:
108
+ drift['suspect_links'].append({
109
+ 'story_id': story_id,
110
+ 'spec': spec_path,
111
+ 'files': suspect_files
112
+ })
113
+ drift['summary']['suspect'] += 1
114
+ else:
115
+ drift['summary']['ok'] += 1
116
+
117
+ with open('$DRIFT_REPORT', 'w') as f:
118
+ json.dump(drift, f, indent=2)
119
+
120
+ print(f'OK: {drift[\"summary\"][\"ok\"]} clean, {drift[\"summary\"][\"suspect\"]} suspect, {drift[\"summary\"][\"broken\"]} broken')
121
+ "
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # check-spec-version-gap.sh — detect spec format gap (e44s02)
3
+ set -euo pipefail
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ BP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
6
+
7
+ usage() {
8
+ cat <<'EOF'
9
+ Usage: check-spec-version-gap.sh [--help] [--project <dir>] [--json]
10
+ Exit codes: 0=no gap, 1=gap, 2=no specs, 3=blocked, 4=error
11
+ EOF
12
+ exit 0
13
+ }
14
+
15
+ PROJECT_DIR="."
16
+ JSON_STDOUT=false
17
+ SKIP_BLOCK=false
18
+ while [[ $# -gt 0 ]]; do
19
+ case "$1" in
20
+ --help|-h) usage ;;
21
+ --project) PROJECT_DIR="${2:?}"; shift 2 ;;
22
+ --json) JSON_STDOUT=true; shift ;;
23
+ --skip-block) SKIP_BLOCK=true; shift ;;
24
+ *) echo "unknown: $1" >&2; exit 4 ;;
25
+ esac
26
+ done
27
+
28
+ PROJECT_DIR="$(cd "$PROJECT_DIR" 2>/dev/null && pwd)" || { echo "bad dir: $PROJECT_DIR" >&2; exit 4; }
29
+ SPECS="$PROJECT_DIR/specs"
30
+
31
+ emit_json() {
32
+ if $JSON_STDOUT; then echo "$1"; else echo "$1" >&2; fi
33
+ }
34
+
35
+ get_installed_version() {
36
+ python3 -c "import json; d=json.load(open('$BP_ROOT/package.json')); print(d.get('version','unknown'))" 2>/dev/null || echo "unknown"
37
+ }
38
+
39
+ yaml_get() {
40
+ [[ ! -f "$1" ]] && { echo ""; return; }
41
+ python3 -c "
42
+ import sys, yaml
43
+ d = yaml.safe_load(open('$1')) or {}
44
+ parts = '$2'.split('.')
45
+ cur = d
46
+ for p in parts:
47
+ if isinstance(cur, dict): cur = cur.get(p)
48
+ else: print(''); sys.exit(0)
49
+ print('' if cur is None else cur)
50
+ " 2>/dev/null
51
+ }
52
+
53
+ version_lt() {
54
+ if [[ "$1" == "$2" ]]; then return 1; fi
55
+ local IFS=.; local a=($1) b=($2)
56
+ for i in "${!a[@]}"; do
57
+ local av="${a[$i]:-0}" bv="${b[$i]:-0}"
58
+ if (( av < bv )); then return 0; fi
59
+ if (( av > bv )); then return 1; fi
60
+ done
61
+ return 1
62
+ }
63
+
64
+ find_migrations() {
65
+ local ver="$1" reg="$BP_ROOT/specs/migrations/registry.okf.md"
66
+ [[ ! -f "$reg" ]] && { echo "[]"; return; }
67
+ python3 -c "
68
+ import json, yaml
69
+ def vt(v): return tuple(int(p) if p.isdigit() else 0 for p in v.lstrip('v').split('.')[:3])
70
+ dt = vt('$ver')
71
+ c = open('$reg').read()
72
+ parts = c.split('---')
73
+ fm = yaml.safe_load(parts[1]) if len(parts) >= 3 else {}
74
+ out = []
75
+ for m in fm.get('migrations', []):
76
+ if m.get('status') not in ('implemented','planned'): continue
77
+ st = vt(m.get('since_version','0'))
78
+ if dt < st:
79
+ out.append({'id': m['id'], 'since_version': m['since_version'], 'order': m.get('order',0), 'depends_on': m.get('depends_on',[]), 'actions_needed': m.get('actions_needed',[]), 'status': m.get('status','')})
80
+ print(json.dumps(out))
81
+ " 2>/dev/null
82
+ }
83
+
84
+ check_active_work() {
85
+ local branch; branch=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null || echo "")
86
+ if [[ -n "$branch" && "$branch" != "main" && "$branch" != "master" ]]; then echo "true"; return; fi
87
+ if [[ -d "$SPECS" ]] && git -C "$PROJECT_DIR" status --porcelain -- specs/ 2>/dev/null | grep -q .; then echo "true"; return; fi
88
+ local af; af=$(yaml_get "$SPECS/state.yaml" "active_flow")
89
+ if [[ -n "$af" && "$af" != "null" && "$af" != "None" ]]; then
90
+ case "$af" in build_epic|develop_tdd|fix_bug) echo "true"; return ;; esac
91
+ fi
92
+ echo "false"
93
+ }
94
+
95
+ # Main
96
+ [[ ! -d "$SPECS" ]] && { emit_json '{"gap":false,"reason":"NO_SPECS"}'; exit 2; }
97
+ IV=$(get_installed_version)
98
+ if [ "$SKIP_BLOCK" != "true" ] && [[ "$(check_active_work)" == "true" ]]; then
99
+ emit_json '{"gap":true,"blocked":true,"reason":"ACTIVE_WORK","installed_version":"'"$IV"'"}'
100
+ exit 3
101
+ fi
102
+
103
+ SV=$(yaml_get "$SPECS/state.yaml" "bigpowers_version")
104
+
105
+ if [[ -n "$SV" && "$SV" != "null" && "$SV" != "None" ]]; then
106
+ if [[ "$SV" == "$IV" ]]; then
107
+ emit_json '{"gap":false,"stamp":true,"detected_version":"'"$SV"'","installed_version":"'"$IV"'","detection_method":"stamp","confidence":"high"}'
108
+ exit 0
109
+ fi
110
+ APP=$(find_migrations "$SV")
111
+ CNT=$(echo "$APP" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)
112
+ emit_json "$(python3 -c "import json; a=json.loads('''$APP'''); print(json.dumps({'gap':True,'stamp':True,'detected_version':'$SV','detection_method':'stamp','installed_version':'$IV','applicable_migrations':len(a),'migration_ids':[m['id'] for m in a],'confidence':'high','active_work_blocked':False}))")"
113
+ exit 1
114
+ fi
115
+
116
+ # Fingerprint
117
+ DV=""; CONF="medium"; MARKERS=()
118
+ [[ -f "$SPECS/STATE.md" ]] && { DV="1.0.0"; CONF="high"; MARKERS+=("STATE.md"); }
119
+ [[ -f "$SPECS/state.yaml" ]] && { [[ -z "$DV" ]] && { DV="2.0.0"; MARKERS+=("state.yaml"); } || MARKERS+=("state.yaml (dual-era)"); }
120
+ for f in release-plan.yaml execution-status.yaml product/SCOPE_LATEST.yaml metrics/cycle-times.yaml; do
121
+ [[ -f "$SPECS/$f" ]] && { MARKERS+=("$f"); [[ -z "$DV" ]] && DV="2.0.0"; }
122
+ done
123
+ if [[ -f "$SPECS/state.yaml" ]]; then
124
+ ec=$(yaml_get "$SPECS/state.yaml" "epic_cycle.current_step")
125
+ if [[ -n "$ec" && "$ec" != "null" && "$ec" != "None" ]]; then
126
+ MARKERS+=("epic_cycle"); [[ "$DV" == "2.0.0" ]] && DV="2.20.0"
127
+ fi
128
+ fi
129
+ if ls "$SPECS"/epics/*/epic.yaml 2>/dev/null | head -1 | grep -q .; then
130
+ MARKERS+=("epic capsules"); [[ "$DV" == "2.0.0" ]] && DV="2.20.0"; [[ -z "$DV" ]] && DV="2.20.0"
131
+ fi
132
+ [[ -z "$DV" ]] && { DV="unknown"; CONF="low"; }
133
+
134
+ if [[ "$DV" != "unknown" ]] && ! version_lt "$DV" "$IV"; then
135
+ [[ "$DV" == "$IV" ]] && { emit_json '{"gap":false,"stamp":false,"detected_version":"'"$DV"'","installed_version":"'"$IV"'","detection_method":"fingerprint","confidence":"'"$CONF"'"}'; exit 0; }
136
+ fi
137
+
138
+ APP=$(find_migrations "$DV")
139
+ CNT=$(echo "$APP" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0)
140
+ MJ=$(printf '%s\n' "${MARKERS[@]}" | python3 -c "import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))" 2>/dev/null || echo "[]")
141
+
142
+ emit_json "$(python3 -c "import json; a=json.loads('''$APP'''); m=json.loads('''$MJ'''); print(json.dumps({'gap':True,'stamp':False,'detected_version':'$DV','detection_method':'fingerprint','installed_version':'$IV','applicable_migrations':len(a),'migration_ids':[x['id'] for x in a],'confidence':'$CONF','active_work_blocked':False,'matched_markers':m}))")"
143
+ exit 1