@ikieaneh/opencode-kit 0.6.1 → 0.6.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-kit",
3
3
  "description": "Standardized orchestration framework — contract-based, rules-enforced, zero-touch agent workflow",
4
- "version": "0.6.1",
4
+ "version": "0.6.2",
5
5
  "author": {
6
6
  "name": "Rizki Rachman",
7
7
  "url": "https://github.com/RizkiRachman"
@@ -73,33 +73,38 @@ const resolveConfigPath = (projectDir, relPath) => {
73
73
 
74
74
  // --- Auto-init contract.json if missing ---
75
75
  const ensureContract = (projectDir) => {
76
- const homeDir = os.homedir();
77
- const globalDir = path.join(homeDir, '.config/opencode-kit');
78
- const contractPath = path.join(projectDir, '.opencode', 'orchestration', 'contract.json');
79
-
80
- // Already exists — nothing to do
81
- if (fs.existsSync(contractPath)) return contractPath;
82
-
83
- // Check global config first
84
- const globalContract = path.join(globalDir, 'orchestration', 'contract.json');
85
- if (fs.existsSync(globalContract)) {
86
- fs.mkdirSync(path.dirname(contractPath), { recursive: true });
87
- fs.copyFileSync(globalContract, contractPath);
88
- log('info', `Auto-initialized contract from global config: ${contractPath}`);
89
- return contractPath;
90
- }
76
+ try {
77
+ const homeDir = os.homedir();
78
+ const globalDir = path.join(homeDir, '.config/opencode-kit');
79
+ const contractPath = path.join(projectDir, '.opencode', 'orchestration', 'contract.json');
80
+
81
+ // Already exists — nothing to do
82
+ if (fs.existsSync(contractPath)) return contractPath;
83
+
84
+ // Check global config first
85
+ const globalContract = path.join(globalDir, 'orchestration', 'contract.json');
86
+ if (fs.existsSync(globalContract)) {
87
+ fs.mkdirSync(path.dirname(contractPath), { recursive: true });
88
+ fs.copyFileSync(globalContract, contractPath);
89
+ log('info', `Auto-initialized contract from global config: ${contractPath}`);
90
+ return contractPath;
91
+ }
91
92
 
92
- // Scaffold from plugin template
93
- const templatePath = path.join(TEMPLATES_DIR, 'contract.json');
94
- if (fs.existsSync(templatePath)) {
95
- fs.mkdirSync(path.dirname(contractPath), { recursive: true });
96
- fs.copyFileSync(templatePath, contractPath);
97
- log('info', `Auto-initialized contract from plugin template: ${contractPath}`);
98
- return contractPath;
99
- }
93
+ // Scaffold from plugin template
94
+ const templatePath = path.join(TEMPLATES_DIR, 'contract.json');
95
+ if (fs.existsSync(templatePath)) {
96
+ fs.mkdirSync(path.dirname(contractPath), { recursive: true });
97
+ fs.copyFileSync(templatePath, contractPath);
98
+ log('info', `Auto-initialized contract from plugin template: ${contractPath}`);
99
+ return contractPath;
100
+ }
100
101
 
101
- log('warn', 'Could not auto-initialize contract — no template found');
102
- return null;
102
+ log('warn', 'Could not auto-initialize contract — no template found');
103
+ return null;
104
+ } catch (err) {
105
+ log('error', `Failed to auto-init contract: ${err.message}`);
106
+ return null;
107
+ }
103
108
  };
104
109
 
105
110
  // --- Load bootstrap content (cached) ---
@@ -160,86 +165,98 @@ export const OpencodeKitPlugin = async ({ client, directory }) => {
160
165
  ensureContract(projectDir);
161
166
 
162
167
  // Ensure global config directory exists
163
- fs.mkdirSync(path.join(globalConfigDir, 'orchestration'), { recursive: true });
164
- fs.mkdirSync(path.join(globalConfigDir, 'rules'), { recursive: true });
168
+ try {
169
+ fs.mkdirSync(path.join(globalConfigDir, 'orchestration'), { recursive: true });
170
+ fs.mkdirSync(path.join(globalConfigDir, 'rules'), { recursive: true });
171
+ } catch (err) {
172
+ log('warn', `Failed to create global config dirs: ${err.message}`);
173
+ }
165
174
 
166
175
  return {
167
176
  // Skill resolution order (first match wins):
168
177
  // 1. .opencode/skills/<name>/ (user project — highest priority)
169
178
  // 2. plugin skills/<name>/ (opencode-kit defaults — fallback)
170
179
  config: async (config) => {
171
- config.skills = config.skills || {};
172
- config.skills.paths = config.skills.paths || [];
173
-
174
- // Detect if other plugins might conflict with opencode-kit's system prompt
175
- if (config.plugins && Array.isArray(config.plugins)) {
176
- const kitIndex = config.plugins.findIndex(p =>
177
- typeof p === 'string' && p.includes('opencode-kit')
178
- );
179
- if (kitIndex > 0) {
180
- const firstPlugin = config.plugins[0];
181
- log('warn', `Plugin ordering conflict: opencode-kit should be FIRST, but found '${firstPlugin}' at position 0 and opencode-kit at position ${kitIndex}`);
180
+ try {
181
+ config.skills = config.skills || {};
182
+ config.skills.paths = config.skills.paths || [];
183
+
184
+ // Detect if other plugins might conflict with opencode-kit's system prompt
185
+ if (config.plugins && Array.isArray(config.plugins)) {
186
+ const kitIndex = config.plugins.findIndex(p =>
187
+ typeof p === 'string' && p.includes('opencode-kit')
188
+ );
189
+ if (kitIndex > 0) {
190
+ const firstPlugin = config.plugins[0];
191
+ log('warn', `Plugin ordering conflict: opencode-kit should be FIRST, but found '${firstPlugin}' at position 0 and opencode-kit at position ${kitIndex}`);
192
+ }
182
193
  }
183
- }
184
194
 
185
- // Register user project skills FIRST (higher priority)
186
- const userSkillsDir = path.join(projectDir, '.opencode/skills');
187
- if (fs.existsSync(userSkillsDir) && !config.skills.paths.includes(userSkillsDir)) {
188
- config.skills.paths.push(userSkillsDir);
189
- log('info', `Registered user skills: ${userSkillsDir}`);
190
- }
195
+ // Register user project skills FIRST (higher priority)
196
+ const userSkillsDir = path.join(projectDir, '.opencode/skills');
197
+ if (fs.existsSync(userSkillsDir) && !config.skills.paths.includes(userSkillsDir)) {
198
+ config.skills.paths.push(userSkillsDir);
199
+ log('info', `Registered user skills: ${userSkillsDir}`);
200
+ }
191
201
 
192
- // Register plugin skills SECOND (fallback)
193
- if (!config.skills.paths.includes(SKILLS_DIR)) {
194
- config.skills.paths.push(SKILLS_DIR);
195
- log('info', `Registered plugin skills: ${SKILLS_DIR}`);
196
- }
202
+ // Register plugin skills SECOND (fallback)
203
+ if (!config.skills.paths.includes(SKILLS_DIR)) {
204
+ config.skills.paths.push(SKILLS_DIR);
205
+ log('info', `Registered plugin skills: ${SKILLS_DIR}`);
206
+ }
197
207
 
198
- // Register global config skills path
199
- if (!config.skills.paths.includes(globalConfigDir)) {
200
- if (fs.existsSync(globalConfigDir)) {
201
- config.skills.paths.push(globalConfigDir);
208
+ // Register global config skills path
209
+ if (!config.skills.paths.includes(globalConfigDir)) {
210
+ if (fs.existsSync(globalConfigDir)) {
211
+ config.skills.paths.push(globalConfigDir);
212
+ }
202
213
  }
203
- }
204
214
 
205
- // Provide default contract key hint for agents
206
- config.contractKey = contractKey;
215
+ // Provide default contract key hint for agents
216
+ config.contractKey = contractKey;
217
+ } catch (err) {
218
+ log('error', `config hook failed: ${err.message}`);
219
+ }
207
220
  },
208
221
 
209
222
  'experimental.chat.messages.transform': async (_input, output) => {
210
- const bootstrap = getBootstrapContent();
211
- if (!bootstrap || !output.messages.length) return;
212
-
213
- // Check contract for rule_overrides and inject them into bootstrap
214
- const contractPath = path.join(projectDir, '.opencode', 'orchestration', 'contract.json');
215
- let finalBootstrap = bootstrap;
216
- if (fs.existsSync(contractPath)) {
217
- try {
218
- const contractRaw = fs.readFileSync(contractPath, 'utf8');
219
- const contract = JSON.parse(contractRaw);
220
- if (contract.validation && contract.validation.rule_overrides) {
221
- const overrides = contract.validation.rule_overrides;
222
- const overrideKeys = Object.keys(overrides);
223
- if (overrideKeys.length > 0) {
224
- const overrideText = overrideKeys
225
- .map(id => ` - ${id}: action → ${overrides[id]}`)
226
- .join('\n');
227
- finalBootstrap = bootstrap + `\n## Rule Overrides (from contract)\n\nThe following rule severities have been overridden:\n${overrideText}\n`;
223
+ try {
224
+ const bootstrap = getBootstrapContent();
225
+ if (!bootstrap || !output.messages.length) return;
226
+
227
+ // Check contract for rule_overrides and inject them into bootstrap
228
+ const contractPath = path.join(projectDir, '.opencode', 'orchestration', 'contract.json');
229
+ let finalBootstrap = bootstrap;
230
+ if (fs.existsSync(contractPath)) {
231
+ try {
232
+ const contractRaw = fs.readFileSync(contractPath, 'utf8');
233
+ const contract = JSON.parse(contractRaw);
234
+ if (contract.validation && contract.validation.rule_overrides) {
235
+ const overrides = contract.validation.rule_overrides;
236
+ const overrideKeys = Object.keys(overrides);
237
+ if (overrideKeys.length > 0) {
238
+ const overrideText = overrideKeys
239
+ .map(id => ` - ${id}: action → ${overrides[id]}`)
240
+ .join('\n');
241
+ finalBootstrap = bootstrap + `\n## Rule Overrides (from contract)\n\nThe following rule severities have been overridden:\n${overrideText}\n`;
242
+ }
228
243
  }
244
+ } catch (err) {
245
+ log('warn', `Failed to parse contract for rule_overrides: ${err.message}`);
229
246
  }
230
- } catch (err) {
231
- log('warn', `Failed to parse contract for rule_overrides: ${err.message}`);
232
247
  }
233
- }
234
248
 
235
- const firstUser = output.messages.find(m => m.info.role === 'user');
236
- if (!firstUser || !firstUser.parts.length) return;
249
+ const firstUser = output.messages.find(m => m.info.role === 'user');
250
+ if (!firstUser || !firstUser.parts.length) return;
237
251
 
238
- // Guard: skip if already injected
239
- if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('opencode-kit'))) return;
252
+ // Guard: skip if already injected
253
+ if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('opencode-kit'))) return;
240
254
 
241
- const ref = firstUser.parts[0];
242
- firstUser.parts.unshift({ ...ref, type: 'text', text: finalBootstrap });
255
+ const ref = firstUser.parts[0];
256
+ firstUser.parts.unshift({ ...ref, type: 'text', text: finalBootstrap });
257
+ } catch (err) {
258
+ log('error', `messages.transform hook failed: ${err.message}`);
259
+ }
243
260
  }
244
261
  };
245
262
  };
@@ -66,7 +66,7 @@ Create `opencode.json`:
66
66
  "steps": 30,
67
67
  "tools": { "postgres_*": false, "memory_*": false, "graphify_*": false }
68
68
  },
69
- "leaner": {
69
+ "learner": {
70
70
  "model": "your-model",
71
71
  "skills": ["verification-before-completion", "qa-expert"]
72
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikieaneh/opencode-kit",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Standardized OpenCode orchestration framework — contract-based, rules-enforced, zero-touch agent workflow. Install as plugin.",
5
5
  "license": "MIT",
6
6
  "author": "RizkiRachman",
package/src/adr.sh CHANGED
@@ -59,24 +59,31 @@ if [ -z "$TITLE" ]; then
59
59
  fi
60
60
 
61
61
  # --- Compute next ADR ID ---
62
- NEXT_ID=$($PYTHON_CMD -c "
63
- import json
64
- with open('$CONTRACT_FILE') as f: d = json.load(f)
62
+ NEXT_ID=$(CONTRACT_FILE="$CONTRACT_FILE" $PYTHON_CMD -c "
63
+ import json, os
64
+ with open(os.environ['CONTRACT_FILE']) as f: d = json.load(f)
65
65
  log = d.get('decisions', {}).get('adr_log', [])
66
66
  if not log:
67
67
  print('ADR-001')
68
68
  else:
69
- last = max(int(entry.get('id','ADR-000').replace('ADR-','')) for entry in log)
69
+ last = 0
70
+ for entry in log:
71
+ try:
72
+ num = int(entry.get('id','ADR-000').replace('ADR-',''))
73
+ if num > last: last = num
74
+ except ValueError:
75
+ continue
70
76
  print(f'ADR-{last+1:03d}')
71
77
  ")
72
78
 
73
79
  # --- Check for duplicate title ---
74
- DUP=$($PYTHON_CMD -c "
75
- import json
76
- with open('$CONTRACT_FILE') as f: d = json.load(f)
80
+ DUP=$(CONTRACT_FILE="$CONTRACT_FILE" TITLE="$TITLE" $PYTHON_CMD -c "
81
+ import json, os
82
+ with open(os.environ['CONTRACT_FILE']) as f: d = json.load(f)
77
83
  log = d.get('decisions', {}).get('adr_log', [])
84
+ search_title = os.environ.get('TITLE', '').lower().strip()
78
85
  for entry in log:
79
- if entry.get('title','').lower().strip() == '$TITLE'.lower().strip():
86
+ if entry.get('title','').lower().strip() == search_title:
80
87
  print(entry.get('id',''))
81
88
  break
82
89
  " 2>/dev/null)
@@ -86,50 +93,37 @@ if [ -n "$DUP" ]; then
86
93
  exit 0
87
94
  fi
88
95
 
89
- # --- Build ADR entry via temp JSON file (avoids shell injection) ---
90
- # Write ADR fields to temp file to avoid shell interpolation into Python
91
- ADR_DATA=$(mktemp /tmp/opencode-adr-data-XXXXX.json)
92
- cat > "$ADR_DATA" << ADRDATA
93
- {
94
- "title": "$TITLE",
95
- "context": "$CONTEXT",
96
- "decision": "$DECISION",
97
- "alternatives": "$ALTERNATIVES",
98
- "consequences": "$CONSEQUENCES"
99
- }
100
- ADRDATA
101
-
102
- $PYTHON_CMD -c "
103
- import json
96
+ # --- Build ADR entry via Python (avoids shell injection) ---
97
+ ENTRY_FILE=$(mktemp /tmp/opencode-adr-entry-XXXXX.json)
98
+ trap 'rm -f "$ENTRY_FILE"' EXIT INT TERM
104
99
 
105
- with open('$ADR_DATA') as f:
106
- data = json.load(f)
100
+ TITLE="$TITLE" CONTEXT="$CONTEXT" DECISION="$DECISION" ALTERNATIVES="$ALTERNATIVES" CONSEQUENCES="$CONSEQUENCES" NEXT_ID="$NEXT_ID" ENTRY_FILE="$ENTRY_FILE" $PYTHON_CMD -c "
101
+ import json, os
102
+ from datetime import date
107
103
 
108
104
  entry = {
109
- 'id': '$NEXT_ID',
110
- 'date': '$(date +%Y-%m-%d)',
111
- 'title': data['title'],
112
- 'context': data['context'],
113
- 'decision': data['decision'],
114
- 'alternatives': data['alternatives'],
115
- 'consequences': data['consequences']
105
+ 'id': os.environ['NEXT_ID'],
106
+ 'date': date.today().isoformat(),
107
+ 'title': os.environ.get('TITLE', ''),
108
+ 'context': os.environ.get('CONTEXT', ''),
109
+ 'decision': os.environ.get('DECISION', ''),
110
+ 'alternatives': os.environ.get('ALTERNATIVES', ''),
111
+ 'consequences': os.environ.get('CONSEQUENCES', '')
116
112
  }
117
113
 
118
- with open('/tmp/opencode-adr-entry.json', 'w') as f:
114
+ with open(os.environ['ENTRY_FILE'], 'w') as f:
119
115
  json.dump(entry, f, indent=2)
120
116
  print('Entry written')
121
117
  "
122
118
 
123
- rm -f "$ADR_DATA"
124
-
125
119
  # --- Inject into contract.json ---
126
- $PYTHON_CMD -c "
127
- import json
120
+ CONTRACT_FILE="$CONTRACT_FILE" ENTRY_FILE="$ENTRY_FILE" $PYTHON_CMD -c "
121
+ import json, os
128
122
 
129
- with open('$CONTRACT_FILE') as f:
123
+ with open(os.environ['CONTRACT_FILE']) as f:
130
124
  contract = json.load(f)
131
125
 
132
- with open('/tmp/opencode-adr-entry.json') as f:
126
+ with open(os.environ['ENTRY_FILE']) as f:
133
127
  entry = json.load(f)
134
128
 
135
129
  if 'decisions' not in contract:
@@ -139,12 +133,14 @@ if 'adr_log' not in contract['decisions']:
139
133
 
140
134
  contract['decisions']['adr_log'].append(entry)
141
135
 
142
- with open('$CONTRACT_FILE', 'w') as f:
136
+ with open(os.environ['CONTRACT_FILE'], 'w') as f:
143
137
  json.dump(contract, f, indent=2)
144
138
 
145
139
  print(json.dumps(entry, indent=2))
146
140
  "
147
141
 
142
+ rm -f "$ENTRY_FILE"
143
+
148
144
  echo ""
149
145
  echo -e "${GREEN}[opencode-kit] ✅ ADR recorded: $NEXT_ID${NC}"
150
146
  echo " Title: $TITLE"
package/src/diff.sh CHANGED
@@ -42,13 +42,25 @@ if [ -z "$CONTRACT_A" ] && [ -z "$CONTRACT_B" ]; then
42
42
  fi
43
43
 
44
44
  # Show state diff
45
+ # Write contract data to temp files for safe Python consumption
46
+ CONTRACT_A_FILE=$(mktemp /tmp/opencode-diff-a-XXXXXX.json 2>/dev/null || mktemp /tmp/opencode-diff-a-XXXXXX)
47
+ CONTRACT_B_FILE=$(mktemp /tmp/opencode-diff-b-XXXXXX.json 2>/dev/null || mktemp /tmp/opencode-diff-b-XXXXXX)
48
+ trap 'rm -f "$CONTRACT_A_FILE" "$CONTRACT_B_FILE"' EXIT INT TERM
49
+
50
+ echo "$CONTRACT_A" > "$CONTRACT_A_FILE"
51
+ echo "$CONTRACT_B" > "$CONTRACT_B_FILE"
52
+
45
53
  if [ -n "$PYTHON_CMD" ]; then
46
54
  $PYTHON_CMD -c "
47
55
  import json, sys
48
56
 
49
- def get_state(c):
57
+ def get_state(filepath):
50
58
  try:
51
- d = json.loads(c)
59
+ with open(filepath) as f:
60
+ content = f.read().strip()
61
+ if not content:
62
+ return None
63
+ d = json.loads(content)
52
64
  return {
53
65
  'state': d.get('state', '?'),
54
66
  'goal': (d.get('requirements', {}) or {}).get('goal', '?'),
@@ -60,12 +72,12 @@ def get_state(c):
60
72
  except:
61
73
  return None
62
74
 
63
- a = get_state('''$CONTRACT_A''') if '''$CONTRACT_A''' else None
64
- b = get_state('''$CONTRACT_B''') if '''$CONTRACT_B''' else None
75
+ a = get_state('$CONTRACT_A_FILE')
76
+ b = get_state('$CONTRACT_B_FILE')
65
77
 
66
78
  if a and b:
67
79
  print(f' Field $BRANCH_A $BRANCH_B')
68
- print(f' {"-"*50}')
80
+ print(f' {\"-\"*50}')
69
81
  for field in ['state', 'goal', 'score', 'version']:
70
82
  va = str(a.get(field, '?'))[:20]
71
83
  vb = str(b.get(field, '?'))[:20]
@@ -80,6 +92,8 @@ elif b and not a:
80
92
  print(f' Contract exists in $BRANCH_B but NOT in $BRANCH_A')
81
93
  print(f' State: {b.get(\"state\",\"?\")}')
82
94
  " 2>/dev/null || echo -e "${YELLOW}Could not parse contract JSON${NC}"
95
+
96
+ rm -f "$CONTRACT_A_FILE" "$CONTRACT_B_FILE"
83
97
  fi
84
98
 
85
99
  # Raw git diff
package/src/init.sh CHANGED
@@ -83,8 +83,8 @@ if [ -d ".opencode" ]; then
83
83
  echo " ✅ Backed up to $BACKUP"
84
84
  else
85
85
  echo ""
86
- echo -e "${YELLOW}⚠️ .opencode/ already exists. Use --force to overwrite (backup + clean scaffold).${NC}"
87
- echo " Missing files will be added. Existing files will NOT be overwritten."
86
+ echo -e "${YELLOW}⚠️ .opencode/ already exists. Use --force to re-scaffold (backup + clean).${NC}"
87
+ echo " Skipping existing .opencode/ preserved."
88
88
  fi
89
89
  fi
90
90
 
package/src/telemetry.sh CHANGED
@@ -32,33 +32,49 @@ case "$MODE" in
32
32
  --phases)
33
33
  if [ -f "$TELEMETRY_DIR/phases.jsonl" ]; then
34
34
  echo "Phase transitions:"
35
- cat "$TELEMETRY_DIR/phases.jsonl" | while IFS= read -r line; do
36
- [ -z "$line" ] && continue
37
- FROM=$(echo "$line" | $PYTHON_CMD -c "import sys,json; d=json.load(sys.stdin); print(d.get('from','?'))" 2>/dev/null)
38
- TO=$(echo "$line" | $PYTHON_CMD -c "import sys,json; d=json.load(sys.stdin); print(d.get('to','?'))" 2>/dev/null)
39
- MS=$(echo "$line" | $PYTHON_CMD -c "import sys,json; d=json.load(sys.stdin); print(d.get('elapsed_ms',0))" 2>/dev/null)
40
- printf " %-20s → %-20s %5.1fs\n" "$FROM" "$TO" "$($PYTHON_CMD -c "print($MS/1000)" 2>/dev/null || echo "0.0")"
41
- done
35
+ $PYTHON_CMD -c "
36
+ import sys, json
37
+ for line in sys.stdin:
38
+ line = line.strip()
39
+ if not line:
40
+ continue
41
+ d = json.loads(line)
42
+ from_ = d.get('from', '?')
43
+ to_ = d.get('to', '?')
44
+ ms = d.get('elapsed_ms', 0)
45
+ print(f' {from_:20s} → {to_:20s} {ms/1000:5.1f}s')
46
+ " < "$TELEMETRY_DIR/phases.jsonl" 2>/dev/null || echo -e "${YELLOW}Could not parse phase data${NC}"
42
47
  else
43
48
  echo -e "${YELLOW}No phase data yet.${NC}"
44
49
  fi
45
50
  ;;
46
51
  --summary|*)
47
52
  if [ -f "$TELEMETRY_DIR/summary.json" ]; then
48
- TOTAL_S=$($PYTHON_CMD -c "import json; d=json.load(open('$TELEMETRY_DIR/summary.json')); print(d.get('total_elapsed_s',0))" 2>/dev/null || echo "0")
49
- PHASES=$($PYTHON_CMD -c "import json; d=json.load(open('$TELEMETRY_DIR/summary.json')); print(len(d.get('phases_completed',[])))" 2>/dev/null || echo "0")
53
+ read -r TOTAL_S PHASES < <(TELEMETRY_DIR="$TELEMETRY_DIR" $PYTHON_CMD -c "
54
+ import json, os
55
+ d = json.load(open(os.environ['TELEMETRY_DIR'] + '/summary.json'))
56
+ print(d.get('total_elapsed_s', 0))
57
+ print(len(d.get('phases_completed', [])))
58
+ " 2>/dev/null || echo -e "0\n0")
50
59
  echo " Total elapsed: ${TOTAL_S}s"
51
60
  echo " Phases completed: $PHASES"
52
61
  echo ""
53
62
  echo " Latest phases:"
54
- tail -5 "$TELEMETRY_DIR/phases.jsonl" 2>/dev/null | while IFS= read -r line; do
55
- [ -z "$line" ] && continue
56
- TS=$(echo "$line" | $PYTHON_CMD -c "import sys,json; d=json.load(sys.stdin); print(d.get('ts','?'))" 2>/dev/null | cut -dT -f2 | cut -d. -f1)
57
- FROM=$(echo "$line" | $PYTHON_CMD -c "import sys,json; d=json.load(sys.stdin); print(d.get('from','?'))" 2>/dev/null)
58
- TO=$(echo "$line" | $PYTHON_CMD -c "import sys,json; d=json.load(sys.stdin); print(d.get('to','?'))" 2>/dev/null)
59
- MS=$(echo "$line" | $PYTHON_CMD -c "import sys,json; d=json.load(sys.stdin); print(d.get('elapsed_ms',0))" 2>/dev/null)
60
- echo " $TS $FROM $TO ($((MS/1000))s)"
61
- done
63
+ tail -5 "$TELEMETRY_DIR/phases.jsonl" 2>/dev/null | $PYTHON_CMD -c "
64
+ import sys, json
65
+ for line in sys.stdin:
66
+ line = line.strip()
67
+ if not line:
68
+ continue
69
+ d = json.loads(line)
70
+ ts = d.get('ts', '?')
71
+ if ts != '?':
72
+ ts = ts.split('T')[1].split('.')[0] if 'T' in ts else ts
73
+ from_ = d.get('from', '?')
74
+ to_ = d.get('to', '?')
75
+ ms = d.get('elapsed_ms', 0)
76
+ print(f' {ts} {from_} → {to_} ({ms//1000}s)')
77
+ " 2>/dev/null || echo -e "${YELLOW}Could not parse phase data${NC}"
62
78
  else
63
79
  echo -e "${YELLOW}No telemetry data yet. Run a phase first.${NC}"
64
80
  echo " Phases are recorded automatically by postflight.sh"
package/src/update.sh CHANGED
@@ -28,6 +28,12 @@ while [ $# -gt 0 ]; do
28
28
  esac
29
29
  done
30
30
 
31
+ # --- Verify Python availability ---
32
+ if [ -z "${PYTHON_CMD:-}" ]; then
33
+ echo -e "${RED}❌ PYTHON_CMD is not set. Python is required for update operations.${NC}"
34
+ exit 1
35
+ fi
36
+
31
37
  echo -e "${CYAN}[opencode-kit] 🔄 Update check${NC}"
32
38
  echo " Current dir: $PWD"
33
39
  echo " Source: $REPO_URL (branch: $VERSION)"
@@ -42,6 +48,7 @@ fi
42
48
 
43
49
  # --- Clone latest to temp ---
44
50
  TEMP_DIR=$(mktemp -d /tmp/opencode-kit-XXXXX)
51
+ trap 'rm -rf "$TEMP_DIR"' EXIT INT TERM
45
52
  echo " Cloning latest version to $TEMP_DIR..."
46
53
 
47
54
  if ! git clone --depth 1 --branch "$VERSION" "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then
@@ -62,9 +69,9 @@ print(d.get('contract_version', 'unknown'))
62
69
  " 2>/dev/null || echo "unknown")
63
70
  fi
64
71
 
65
- LATEST_VERSION=$($PYTHON_CMD -c "
66
- import json
67
- with open('$TEMP_DIR/templates/contract.json') as f:
72
+ LATEST_VERSION=$(TEMP_DIR="$TEMP_DIR" $PYTHON_CMD -c "
73
+ import os, json
74
+ with open(os.environ['TEMP_DIR'] + '/templates/contract.json') as f:
68
75
  d=json.load(f)
69
76
  print(d.get('contract_version', 'unknown'))
70
77
  " 2>/dev/null || echo "unknown")
@@ -82,8 +89,8 @@ fi
82
89
  # --- Backup contract state ---
83
90
  echo " Backing up contract state..."
84
91
  STATE_BACKUP=$(mktemp /tmp/opencode-contract-state-XXXXX.json)
85
- $PYTHON_CMD -c "
86
- import json
92
+ STATE_BACKUP="$STATE_BACKUP" $PYTHON_CMD -c "
93
+ import os, json
87
94
  with open('.opencode/orchestration/contract.json') as f:
88
95
  d = json.load(f)
89
96
  # Extract only the state fields to preserve
@@ -98,7 +105,7 @@ state = {
98
105
  'score': d.get('score', {}),
99
106
  'outputs': d.get('outputs', {})
100
107
  }
101
- with open('$STATE_BACKUP', 'w') as f:
108
+ with open(os.environ['STATE_BACKUP'], 'w') as f:
102
109
  json.dump(state, f, indent=2)
103
110
  " 2>/dev/null || echo " ⚠️ Could not backup contract state"
104
111
  echo " ✅ State backed up"
@@ -151,18 +158,18 @@ update_file "$TEMP_DIR/templates/superpowers-contract.json" ".opencode/templates
151
158
 
152
159
  # --- Restore contract state ---
153
160
  if [ "$DRY_RUN" = false ] && [ -f "$STATE_BACKUP" ]; then
154
- $PYTHON_CMD -c "
155
- import json
161
+ STATE_BACKUP="$STATE_BACKUP" LATEST_VERSION="$LATEST_VERSION" $PYTHON_CMD -c "
162
+ import os, json
156
163
  with open('.opencode/orchestration/contract.json') as f:
157
164
  contract = json.load(f)
158
- with open('$STATE_BACKUP') as f:
165
+ with open(os.environ['STATE_BACKUP']) as f:
159
166
  state = json.load(f)
160
167
  # Merge preserved state back into new contract
161
168
  for key, val in state.items():
162
169
  if val: # only overwrite if backup has data
163
170
  contract[key] = val
164
171
  # Update contract_version to latest
165
- contract['contract_version'] = '$LATEST_VERSION'
172
+ contract['contract_version'] = os.environ['LATEST_VERSION']
166
173
  with open('.opencode/orchestration/contract.json', 'w') as f:
167
174
  json.dump(contract, f, indent=2)
168
175
  " 2>/dev/null && echo " ✅ Contract state restored" || echo " ⚠️ Contract state restore failed"