@ikieaneh/opencode-kit 0.6.0 → 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.0",
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,64 +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;
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
+ }
243
+ }
244
+ } catch (err) {
245
+ log('warn', `Failed to parse contract for rule_overrides: ${err.message}`);
246
+ }
247
+ }
212
248
 
213
- const firstUser = output.messages.find(m => m.info.role === 'user');
214
- if (!firstUser || !firstUser.parts.length) return;
249
+ const firstUser = output.messages.find(m => m.info.role === 'user');
250
+ if (!firstUser || !firstUser.parts.length) return;
215
251
 
216
- // Guard: skip if already injected
217
- 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;
218
254
 
219
- const ref = firstUser.parts[0];
220
- firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
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
+ }
221
260
  }
222
261
  };
223
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.0",
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",
@@ -41,6 +41,10 @@
41
41
  "type": "git",
42
42
  "url": "git+https://github.com/RizkiRachman/opencode-kit.git"
43
43
  },
44
+ "scripts": {
45
+ "lint": "eslint src/ test/",
46
+ "format": "prettier --check src/ test/"
47
+ },
44
48
  "bugs": {
45
49
  "url": "https://github.com/RizkiRachman/opencode-kit/issues"
46
50
  },
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/cli.js CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * opencode-kit CLI — version and help
3
+ * opencode-kit CLI — version, help, and project commands
4
4
  * Usage: npx opencode-kit --version
5
5
  * npx opencode-kit --help
6
+ * npx opencode-kit doctor
7
+ * npx opencode-kit status
8
+ * npx opencode-kit analytics
6
9
  */
7
10
  import { fileURLToPath } from 'url';
8
11
  import path from 'path';
9
12
  import fs from 'fs';
13
+ import { spawnSync } from 'child_process';
10
14
 
11
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
16
  const pkgPath = path.resolve(__dirname, '../package.json');
@@ -27,10 +31,13 @@ Usage:
27
31
  npx opencode-kit [command]
28
32
 
29
33
  Commands:
30
- init [--force] Scaffold orchestration framework into project
31
- update [--dry-run] Pull latest templates from GitHub
32
- --version, -v Print version
33
- --help, -h Print this help
34
+ init [--force] Scaffold orchestration framework into project
35
+ update [--dry-run] Pull latest templates from GitHub
36
+ doctor Run project health checks
37
+ status Show project status
38
+ analytics Show project analytics
39
+ --version, -v Print version
40
+ --help, -h Print this help
34
41
 
35
42
  Plugin mode:
36
43
  Add "opencode-kit" to opencode.json plugin array (FIRST position).
@@ -45,3 +52,38 @@ Docs: ${pkg.homepage}
45
52
  `);
46
53
  process.exit(0);
47
54
  }
55
+
56
+ /**
57
+ * Walk up from startDir to find a directory containing .opencode/
58
+ */
59
+ function findProjectRoot(startDir) {
60
+ let dir = startDir;
61
+ while (dir !== path.dirname(dir)) {
62
+ if (fs.existsSync(path.join(dir, '.opencode'))) {
63
+ return dir;
64
+ }
65
+ dir = path.dirname(dir);
66
+ }
67
+ return null;
68
+ }
69
+
70
+ const commands = {
71
+ doctor: '.opencode/src/doctor.sh',
72
+ status: '.opencode/src/status.sh',
73
+ analytics: 'src/analytics.sh',
74
+ };
75
+
76
+ const command = args[0];
77
+
78
+ if (commands[command]) {
79
+ const projectRoot = findProjectRoot(path.resolve(__dirname, '..'));
80
+
81
+ if (!projectRoot) {
82
+ console.error('Not in an opencode-kit project');
83
+ process.exit(1);
84
+ }
85
+
86
+ const scriptPath = path.resolve(projectRoot, commands[command]);
87
+ const result = spawnSync('bash', [scriptPath], { stdio: 'inherit', cwd: projectRoot });
88
+ process.exit(result.status);
89
+ }
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/doctor.sh CHANGED
@@ -42,6 +42,32 @@ else
42
42
  fi
43
43
  fi
44
44
 
45
+ # === 1b. Contract schema validation ===
46
+ SCHEMA_FILE=".opencode/templates/contract.schema.json"
47
+ if [ -f "$CONTRACT_FILE" ] && [ -f "$SCHEMA_FILE" ] && [ -n "$PYTHON_CMD" ]; then
48
+ if $PYTHON_CMD -c "
49
+ import json, sys
50
+ with open('$CONTRACT_FILE') as f: contract = json.load(f)
51
+ with open('$SCHEMA_FILE') as f: schema = json.load(f)
52
+ # Basic required-field validation
53
+ errors = []
54
+ if not contract.get('state'): errors.append('Missing state')
55
+ if not contract.get('requirements', {}).get('goal'): errors.append('Missing requirements.goal')
56
+ if contract.get('state') not in ['INIT','PLAN','PLAN_SCORED','EXECUTE','EXECUTE_SCORED','REVIEW','REVIEW_SCORED','COMPLETE','BLOCKED']:
57
+ errors.append('Invalid state')
58
+ if errors:
59
+ print('; '.join(errors))
60
+ sys.exit(1)
61
+ else:
62
+ print('OK')
63
+ " 2>/dev/null; then
64
+ echo -e " ✅ Contract schema valid"
65
+ else
66
+ echo -e " ⚠️ Contract schema issues found (non-blocking)"
67
+ ISSUES=$((ISSUES + 1))
68
+ fi
69
+ fi
70
+
45
71
  # === 2. Rules check ===
46
72
  echo -e "${CYAN}[RULES]${NC} Checking rules.json..."
47
73
  if [ ! -f "$RULES_FILE" ]; then
@@ -54,7 +80,7 @@ else
54
80
  fi
55
81
  fi
56
82
 
57
- # === 3. MCP checks ===
83
+ # === 3a. MCP CLI checks ===
58
84
  echo -e "${CYAN}[MCP]${NC} Checking required MCPs..."
59
85
  if [ -f "$RULES_FILE" ] && [ -n "$PYTHON_CMD" ]; then
60
86
  $PYTHON_CMD -c "
@@ -78,6 +104,16 @@ for name, cfg in mcps.items():
78
104
  " 2>/dev/null || ISSUES=$((ISSUES + 1))
79
105
  fi
80
106
 
107
+ # === 3b. MCP connectivity ===
108
+ echo -e "${CYAN}[MCP_CONNECT]${NC} Testing MCP connectivity..."
109
+ if command -v lean-ctx &>/dev/null; then
110
+ if lean-ctx ctx_knowledge status &>/dev/null 2>&1; then
111
+ echo -e " ✅ lean-ctx MCP: responding"
112
+ else
113
+ echo -e " ⚠️ lean-ctx CLI found but MCP not responding (expected in CI)"
114
+ fi
115
+ fi
116
+
81
117
  # === 4. Git branch ===
82
118
  echo -e "${CYAN}[GIT]${NC} Checking branch..."
83
119
  BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
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
 
@@ -168,6 +168,13 @@ if [ "$PLUGIN_MODE" = false ]; then
168
168
  done
169
169
  fi
170
170
 
171
+ # --- Copy git hooks ---
172
+ if [ -d "$KIT_DIR/.githooks" ]; then
173
+ cp -r "$KIT_DIR/.githooks" .githooks
174
+ chmod +x .githooks/pre-commit .githooks/commit-msg
175
+ echo " ✅ .githooks/ (pre-commit, commit-msg)"
176
+ fi
177
+
171
178
  # --- Git ignore .opencode/src (scripts are project-specific) ---
172
179
  if [ -f ".gitignore" ]; then
173
180
  if ! grep -q ".opencode/src" .gitignore 2>/dev/null; then
@@ -175,6 +182,12 @@ if [ -f ".gitignore" ]; then
175
182
  fi
176
183
  fi
177
184
 
185
+ # --- Configure git hooks ---
186
+ if [ -d ".githooks" ]; then
187
+ git config core.hooksPath .githooks
188
+ echo " ✅ Git hooks configured (.githooks/)"
189
+ fi
190
+
178
191
  # --- Verify ---
179
192
  echo ""
180
193
  echo "[opencode-kit] Running verification..."