@syntesseraai/opencode-feature-factory 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/agents/ff-validate.md +3 -45
- package/package.json +2 -2
- package/src/discovery.ts +244 -0
- package/src/index.ts +75 -4
- package/src/output.ts +55 -0
- package/src/quality-gate-config.ts +108 -0
- package/src/stop-quality-gate.ts +394 -0
- package/src/types.ts +72 -0
|
@@ -26,41 +26,14 @@ You are a validation orchestrator for Feature Factory. Your role is to run compr
|
|
|
26
26
|
4. **Prioritize Issues** - Rank findings by severity and impact
|
|
27
27
|
5. **Generate Report** - Produce comprehensive validation report
|
|
28
28
|
|
|
29
|
-
## Validation Pipeline
|
|
30
|
-
|
|
31
|
-
Run all validation agents **in parallel** for maximum efficiency:
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
┌──────────────────────────────────────────────────────────────────────┐
|
|
35
|
-
│ ff-validate agent │
|
|
36
|
-
├──────────────────────────────────────────────────────────────────────┤
|
|
37
|
-
│ │
|
|
38
|
-
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────────┐ │
|
|
39
|
-
│ │ ff-ci │ │ff-review │ │ff-security │ │ ff-acceptance │ │
|
|
40
|
-
│ └────┬────┘ └────┬─────┘ └─────┬──────┘ └────────┬─────────┘ │
|
|
41
|
-
│ │ │ │ │ │
|
|
42
|
-
│ │ │ │ │ │
|
|
43
|
-
│ ┌────┴────┐ │ │ ┌───────┴───────┐ │
|
|
44
|
-
│ │ tests │ │ │ │ff-well-archit │ │
|
|
45
|
-
│ │ build │ │ │ └───────┬───────┘ │
|
|
46
|
-
│ │ lint │ │ │ │ │
|
|
47
|
-
│ │ types │ │ │ │ │
|
|
48
|
-
│ └────┬────┘ │ │ │ │
|
|
49
|
-
│ │ │ │ │ │
|
|
50
|
-
│ ▼ ▼ ▼ ▼ │
|
|
51
|
-
│ ┌─────────────────────────────────────────────────────────────┐ │
|
|
52
|
-
│ │ Aggregate & Report │ │
|
|
53
|
-
│ └─────────────────────────────────────────────────────────────┘ │
|
|
54
|
-
└──────────────────────────────────────────────────────────────────────┘
|
|
55
|
-
```
|
|
56
|
-
|
|
57
29
|
## Sub-Agents to Run
|
|
58
30
|
|
|
31
|
+
Run all validation agents **in parallel** for maximum efficiency.
|
|
32
|
+
|
|
59
33
|
Launch these agents **in parallel** using the Task tool:
|
|
60
34
|
|
|
61
35
|
| Agent | Purpose | Focus |
|
|
62
36
|
| --------------------- | ----------------------- | -------------------------------------- |
|
|
63
|
-
| `ff-ci` | CI checks | Tests, build, lint, type-check |
|
|
64
37
|
| `ff-review` | Code review | Quality, correctness, maintainability |
|
|
65
38
|
| `ff-security` | Security audit | Vulnerabilities, auth, data protection |
|
|
66
39
|
| `ff-acceptance` | Requirements validation | Acceptance criteria, completeness |
|
|
@@ -76,8 +49,7 @@ Launch these agents **in parallel** using the Task tool:
|
|
|
76
49
|
2. **Launch Sub-Agents in Parallel**
|
|
77
50
|
|
|
78
51
|
```
|
|
79
|
-
Launch simultaneously
|
|
80
|
-
- Task(ff-ci): "Run CI checks on the changes"
|
|
52
|
+
Launch all agents simultaneously **in parallel**:
|
|
81
53
|
- Task(ff-review): "Review the code changes for quality"
|
|
82
54
|
- Task(ff-security): "Audit the changes for security issues"
|
|
83
55
|
- Task(ff-acceptance): "Validate against acceptance criteria: <criteria>"
|
|
@@ -114,11 +86,6 @@ Output your validation results as structured JSON:
|
|
|
114
86
|
"rationale": "Security vulnerability and failing tests must be fixed"
|
|
115
87
|
},
|
|
116
88
|
"agents": {
|
|
117
|
-
"ci": {
|
|
118
|
-
"status": "failed",
|
|
119
|
-
"summary": "3 tests failing, build passed",
|
|
120
|
-
"blocking": true
|
|
121
|
-
},
|
|
122
89
|
"review": {
|
|
123
90
|
"status": "passed",
|
|
124
91
|
"summary": "Code quality acceptable with minor suggestions",
|
|
@@ -150,13 +117,6 @@ Output your validation results as structured JSON:
|
|
|
150
117
|
"line": 45,
|
|
151
118
|
"description": "User input directly concatenated in SQL query",
|
|
152
119
|
"fix": "Use parameterized queries"
|
|
153
|
-
},
|
|
154
|
-
{
|
|
155
|
-
"source": "ff-ci",
|
|
156
|
-
"severity": "high",
|
|
157
|
-
"title": "Test failures",
|
|
158
|
-
"description": "3 unit tests failing in UserService",
|
|
159
|
-
"fix": "Fix the failing assertions"
|
|
160
120
|
}
|
|
161
121
|
],
|
|
162
122
|
"nonBlocking": [
|
|
@@ -190,14 +150,12 @@ Output your validation results as structured JSON:
|
|
|
190
150
|
|
|
191
151
|
### Automatic Approval (approved: true)
|
|
192
152
|
|
|
193
|
-
- All CI checks pass (tests, build, lint, types)
|
|
194
153
|
- No critical or high severity security issues
|
|
195
154
|
- All acceptance criteria met
|
|
196
155
|
- No blocking issues from any agent
|
|
197
156
|
|
|
198
157
|
### Request Changes (approved: false)
|
|
199
158
|
|
|
200
|
-
- Any CI check fails
|
|
201
159
|
- Any security vulnerability found
|
|
202
160
|
- Acceptance criteria not met
|
|
203
161
|
- Critical architectural concerns
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@syntesseraai/opencode-feature-factory",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.7",
|
|
5
5
|
"description": "OpenCode plugin for Feature Factory agents - provides planning, implementation, review, testing, and validation agents",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -31,4 +31,4 @@
|
|
|
31
31
|
"@types/node": "^22.0.0",
|
|
32
32
|
"typescript": "^5.0.0"
|
|
33
33
|
}
|
|
34
|
-
}
|
|
34
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { CommandStep, PackageManager, QualityGateConfig } from './types';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_QUALITY_GATE,
|
|
4
|
+
fileExists,
|
|
5
|
+
hasConfiguredCommands,
|
|
6
|
+
readJsonFile,
|
|
7
|
+
} from './quality-gate-config';
|
|
8
|
+
|
|
9
|
+
// Shell type - uses `any` to be compatible with OpenCode's BunShell
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
type BunShell = any;
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Package Manager Detection
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect the package manager based on lockfile presence.
|
|
19
|
+
* Priority: pnpm > bun > yarn > npm
|
|
20
|
+
*/
|
|
21
|
+
async function detectPackageManager(
|
|
22
|
+
$: BunShell,
|
|
23
|
+
directory: string,
|
|
24
|
+
override?: string
|
|
25
|
+
): Promise<PackageManager> {
|
|
26
|
+
if (override && override !== 'auto') {
|
|
27
|
+
return override as PackageManager;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Priority order: pnpm > bun > yarn > npm
|
|
31
|
+
if (await fileExists($, `${directory}/pnpm-lock.yaml`)) return 'pnpm';
|
|
32
|
+
if (await fileExists($, `${directory}/bun.lockb`)) return 'bun';
|
|
33
|
+
if (await fileExists($, `${directory}/bun.lock`)) return 'bun';
|
|
34
|
+
if (await fileExists($, `${directory}/yarn.lock`)) return 'yarn';
|
|
35
|
+
if (await fileExists($, `${directory}/package-lock.json`)) return 'npm';
|
|
36
|
+
|
|
37
|
+
return 'npm'; // fallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the run command for a given package manager and script name
|
|
42
|
+
*/
|
|
43
|
+
function buildRunCommand(pm: PackageManager, script: string): string {
|
|
44
|
+
switch (pm) {
|
|
45
|
+
case 'pnpm':
|
|
46
|
+
return `pnpm -s run ${script}`;
|
|
47
|
+
case 'bun':
|
|
48
|
+
return `bun run ${script}`;
|
|
49
|
+
case 'yarn':
|
|
50
|
+
return `yarn -s ${script}`;
|
|
51
|
+
case 'npm':
|
|
52
|
+
return `npm run -s ${script}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Node.js Discovery
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Discover Node.js lint/build/test commands from package.json scripts
|
|
62
|
+
*/
|
|
63
|
+
async function discoverNodeCommands(
|
|
64
|
+
$: BunShell,
|
|
65
|
+
directory: string,
|
|
66
|
+
pm: PackageManager
|
|
67
|
+
): Promise<CommandStep[]> {
|
|
68
|
+
const pkgJson = await readJsonFile($, `${directory}/package.json`);
|
|
69
|
+
if (!pkgJson?.scripts) return [];
|
|
70
|
+
|
|
71
|
+
const scripts = pkgJson.scripts as Record<string, string>;
|
|
72
|
+
const steps: CommandStep[] = [];
|
|
73
|
+
|
|
74
|
+
// Lint: prefer lint:ci, then lint
|
|
75
|
+
const lintScript = scripts['lint:ci'] ? 'lint:ci' : scripts['lint'] ? 'lint' : null;
|
|
76
|
+
if (lintScript) {
|
|
77
|
+
steps.push({ step: 'lint', cmd: buildRunCommand(pm, lintScript) });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build: prefer build:ci, then build
|
|
81
|
+
const buildScript = scripts['build:ci'] ? 'build:ci' : scripts['build'] ? 'build' : null;
|
|
82
|
+
if (buildScript) {
|
|
83
|
+
steps.push({ step: 'build', cmd: buildRunCommand(pm, buildScript) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Test: prefer test:ci, then test
|
|
87
|
+
const testScript = scripts['test:ci'] ? 'test:ci' : scripts['test'] ? 'test' : null;
|
|
88
|
+
if (testScript) {
|
|
89
|
+
steps.push({ step: 'test', cmd: buildRunCommand(pm, testScript) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return steps;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Rust Discovery
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Discover Rust commands from Cargo.toml presence
|
|
101
|
+
*/
|
|
102
|
+
async function discoverRustCommands(
|
|
103
|
+
$: BunShell,
|
|
104
|
+
directory: string,
|
|
105
|
+
includeClippy: boolean
|
|
106
|
+
): Promise<CommandStep[]> {
|
|
107
|
+
if (!(await fileExists($, `${directory}/Cargo.toml`))) return [];
|
|
108
|
+
|
|
109
|
+
const steps: CommandStep[] = [{ step: 'lint (fmt)', cmd: 'cargo fmt --check' }];
|
|
110
|
+
|
|
111
|
+
if (includeClippy) {
|
|
112
|
+
steps.push({
|
|
113
|
+
step: 'lint (clippy)',
|
|
114
|
+
cmd: 'cargo clippy --all-targets --all-features',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
steps.push({ step: 'build', cmd: 'cargo build' });
|
|
119
|
+
steps.push({ step: 'test', cmd: 'cargo test' });
|
|
120
|
+
|
|
121
|
+
return steps;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Go Discovery
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Discover Go commands from go.mod presence
|
|
130
|
+
*/
|
|
131
|
+
async function discoverGoCommands($: BunShell, directory: string): Promise<CommandStep[]> {
|
|
132
|
+
if (!(await fileExists($, `${directory}/go.mod`))) return [];
|
|
133
|
+
|
|
134
|
+
return [{ step: 'test', cmd: 'go test ./...' }];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Python Discovery
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Discover Python test commands with strong signal detection
|
|
143
|
+
*/
|
|
144
|
+
async function discoverPythonCommands($: BunShell, directory: string): Promise<CommandStep[]> {
|
|
145
|
+
// Only add pytest if we have strong signal
|
|
146
|
+
const hasPytestIni = await fileExists($, `${directory}/pytest.ini`);
|
|
147
|
+
const hasPyproject = await fileExists($, `${directory}/pyproject.toml`);
|
|
148
|
+
|
|
149
|
+
if (!hasPytestIni && !hasPyproject) return [];
|
|
150
|
+
|
|
151
|
+
// pytest.ini is strong signal
|
|
152
|
+
if (hasPytestIni) {
|
|
153
|
+
return [{ step: 'test', cmd: 'pytest' }];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check if pyproject.toml mentions pytest
|
|
157
|
+
if (hasPyproject) {
|
|
158
|
+
try {
|
|
159
|
+
const result = await $`cat ${directory}/pyproject.toml`.quiet();
|
|
160
|
+
const content = result.text();
|
|
161
|
+
if (content.includes('pytest')) {
|
|
162
|
+
return [{ step: 'test', cmd: 'pytest' }];
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore read errors - return empty array if file can't be read
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Main Resolution Logic
|
|
174
|
+
// ============================================================================
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the command plan based on configuration and discovery.
|
|
178
|
+
*
|
|
179
|
+
* Resolution order:
|
|
180
|
+
* 1. Configured commands (qualityGate.lint/build/test) - highest priority
|
|
181
|
+
* 2. management/ci.sh (Feature Factory convention) - only if no configured commands
|
|
182
|
+
* 3. Conventional discovery (Node/Rust/Go/Python) - only if no ci.sh
|
|
183
|
+
*
|
|
184
|
+
* @returns Array of command steps to execute, or empty if nothing found
|
|
185
|
+
*/
|
|
186
|
+
export async function resolveCommands(args: {
|
|
187
|
+
$: BunShell;
|
|
188
|
+
directory: string;
|
|
189
|
+
config: QualityGateConfig;
|
|
190
|
+
}): Promise<CommandStep[]> {
|
|
191
|
+
const { $, directory, config } = args;
|
|
192
|
+
const mergedConfig = { ...DEFAULT_QUALITY_GATE, ...config };
|
|
193
|
+
|
|
194
|
+
// 1. Configured commands take priority (do NOT run ci.sh if these exist)
|
|
195
|
+
if (hasConfiguredCommands(config)) {
|
|
196
|
+
const steps: CommandStep[] = [];
|
|
197
|
+
const order = mergedConfig.steps;
|
|
198
|
+
|
|
199
|
+
for (const stepName of order) {
|
|
200
|
+
const cmd = config[stepName];
|
|
201
|
+
if (cmd) {
|
|
202
|
+
steps.push({ step: stepName, cmd });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return steps;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 2. Feature Factory CI script (only if no configured commands)
|
|
210
|
+
if (mergedConfig.useCiSh !== 'never') {
|
|
211
|
+
const ciShPath = `${directory}/management/ci.sh`;
|
|
212
|
+
if (await fileExists($, ciShPath)) {
|
|
213
|
+
return [{ step: 'ci', cmd: `bash ${ciShPath}` }];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 3. Conventional discovery (only if no ci.sh)
|
|
218
|
+
const pm = await detectPackageManager($, directory, mergedConfig.packageManager);
|
|
219
|
+
|
|
220
|
+
// Try Node first (most common)
|
|
221
|
+
if (await fileExists($, `${directory}/package.json`)) {
|
|
222
|
+
const nodeSteps = await discoverNodeCommands($, directory, pm);
|
|
223
|
+
if (nodeSteps.length > 0) return nodeSteps;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Rust
|
|
227
|
+
const rustSteps = await discoverRustCommands(
|
|
228
|
+
$,
|
|
229
|
+
directory,
|
|
230
|
+
mergedConfig.include?.rustClippy ?? true
|
|
231
|
+
);
|
|
232
|
+
if (rustSteps.length > 0) return rustSteps;
|
|
233
|
+
|
|
234
|
+
// Go
|
|
235
|
+
const goSteps = await discoverGoCommands($, directory);
|
|
236
|
+
if (goSteps.length > 0) return goSteps;
|
|
237
|
+
|
|
238
|
+
// Python
|
|
239
|
+
const pythonSteps = await discoverPythonCommands($, directory);
|
|
240
|
+
if (pythonSteps.length > 0) return pythonSteps;
|
|
241
|
+
|
|
242
|
+
// No commands discovered
|
|
243
|
+
return [];
|
|
244
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { Plugin } from '@opencode-ai/plugin';
|
|
1
|
+
import type { Plugin, Hooks } from '@opencode-ai/plugin';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { mkdir, access, writeFile, readFile } from 'node:fs/promises';
|
|
5
5
|
import { constants as fsConstants } from 'node:fs';
|
|
6
|
+
import { createQualityGateHooks } from './stop-quality-gate';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* List of agent templates to sync to projects.
|
|
@@ -89,10 +90,45 @@ async function ensureAgentsInstalled(
|
|
|
89
90
|
return { installed, updated };
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Merge multiple hook objects into a single hooks object.
|
|
95
|
+
* For event handlers, chains them to run sequentially.
|
|
96
|
+
*/
|
|
97
|
+
function mergeHooks(...hookSets: Partial<Hooks>[]): Hooks {
|
|
98
|
+
const merged: Hooks = {};
|
|
99
|
+
|
|
100
|
+
for (const hooks of hookSets) {
|
|
101
|
+
for (const [key, handler] of Object.entries(hooks)) {
|
|
102
|
+
if (!handler) continue;
|
|
103
|
+
|
|
104
|
+
const existingHandler = merged[key as keyof Hooks];
|
|
105
|
+
if (existingHandler) {
|
|
106
|
+
// Chain handlers - run existing first, then new
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
108
|
+
merged[key as keyof Hooks] = (async (...args: any[]) => {
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
110
|
+
await (existingHandler as (...args: any[]) => Promise<void>)(...args);
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
112
|
+
await (handler as (...args: any[]) => Promise<void>)(...args);
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
114
|
+
}) as any;
|
|
115
|
+
} else {
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
merged[key as keyof Hooks] = handler as any;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return merged;
|
|
123
|
+
}
|
|
124
|
+
|
|
92
125
|
/**
|
|
93
126
|
* Feature Factory OpenCode Plugin
|
|
94
127
|
*
|
|
95
|
-
* This plugin automatically syncs Feature Factory agent templates to projects
|
|
128
|
+
* This plugin automatically syncs Feature Factory agent templates to projects
|
|
129
|
+
* and runs quality gate checks when the agent becomes idle.
|
|
130
|
+
*
|
|
131
|
+
* ## Agent Templates
|
|
96
132
|
*
|
|
97
133
|
* Agents installed:
|
|
98
134
|
* - ff-plan (primary): Creates detailed implementation plans
|
|
@@ -107,12 +143,26 @@ async function ensureAgentsInstalled(
|
|
|
107
143
|
* - ff-ci (subagent): CI checks (tests, build, lint, type-check)
|
|
108
144
|
* - ff-validate (subagent): Orchestrates all validation agents in parallel
|
|
109
145
|
*
|
|
146
|
+
* ## Quality Gate (StopQualityGate)
|
|
147
|
+
*
|
|
148
|
+
* Runs quality checks when the agent becomes idle after editing files.
|
|
149
|
+
*
|
|
150
|
+
* Resolution order for commands:
|
|
151
|
+
* 1. opencode.json / .opencode/opencode.json `qualityGate` config (authoritative)
|
|
152
|
+
* 2. management/ci.sh (Feature Factory convention, replaces all steps)
|
|
153
|
+
* 3. Conventional discovery (Node/Rust/Go/Python)
|
|
154
|
+
*
|
|
110
155
|
* Behavior:
|
|
111
156
|
* - On plugin init (every OpenCode startup), syncs agents to .opencode/agent/
|
|
112
157
|
* - Always overwrites existing files with bundled templates (user changes are not preserved)
|
|
113
158
|
* - Also syncs on installation.updated event
|
|
159
|
+
* - Runs quality gate on session.idle (when agent finishes working)
|
|
160
|
+
* - Marks session dirty when edit/write/patch tools are used
|
|
161
|
+
* - On failure: prompts agent with failure output and instructions to fix
|
|
162
|
+
* - On success: logs "Quality gate passed."
|
|
114
163
|
*/
|
|
115
|
-
export const FeatureFactoryPlugin: Plugin = async (
|
|
164
|
+
export const FeatureFactoryPlugin: Plugin = async (input) => {
|
|
165
|
+
const { worktree, directory } = input;
|
|
116
166
|
const rootDir = resolveRootDir({ worktree, directory });
|
|
117
167
|
|
|
118
168
|
// Skip if no valid directory (e.g., global config with no project)
|
|
@@ -127,7 +177,16 @@ export const FeatureFactoryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
127
177
|
// Silent autosync shouldn't crash OpenCode startup
|
|
128
178
|
}
|
|
129
179
|
|
|
130
|
-
|
|
180
|
+
// Create quality gate hooks
|
|
181
|
+
let qualityGateHooks: Partial<Hooks> = {};
|
|
182
|
+
try {
|
|
183
|
+
qualityGateHooks = await createQualityGateHooks(input);
|
|
184
|
+
} catch {
|
|
185
|
+
// Silent failure - quality gate shouldn't crash OpenCode startup
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Agent sync hooks
|
|
189
|
+
const agentSyncHooks: Partial<Hooks> = {
|
|
131
190
|
// Also sync on installation updates (plugin updates)
|
|
132
191
|
event: async ({ event }) => {
|
|
133
192
|
if (event.type === 'installation.updated') {
|
|
@@ -139,7 +198,19 @@ export const FeatureFactoryPlugin: Plugin = async ({ worktree, directory }) => {
|
|
|
139
198
|
}
|
|
140
199
|
},
|
|
141
200
|
};
|
|
201
|
+
|
|
202
|
+
// Merge all hooks together
|
|
203
|
+
return mergeHooks(agentSyncHooks, qualityGateHooks);
|
|
142
204
|
};
|
|
143
205
|
|
|
144
206
|
// Default export for OpenCode plugin discovery
|
|
145
207
|
export default FeatureFactoryPlugin;
|
|
208
|
+
|
|
209
|
+
// Export types for consumers who want to use them
|
|
210
|
+
export type {
|
|
211
|
+
QualityGateConfig,
|
|
212
|
+
CommandStep,
|
|
213
|
+
StepResult,
|
|
214
|
+
SessionState,
|
|
215
|
+
PackageManager,
|
|
216
|
+
} from './types';
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex pattern for detecting error-like lines in command output
|
|
3
|
+
*/
|
|
4
|
+
const ERROR_PATTERNS =
|
|
5
|
+
/(error|failed|failure|panic|assert|exception|traceback|TypeError|ReferenceError|SyntaxError|FAILED|FAIL|ERR!)/i;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract lines that likely contain error information from command output.
|
|
9
|
+
* Includes 1-2 lines of context after each error line.
|
|
10
|
+
*
|
|
11
|
+
* @param output - The command output to search
|
|
12
|
+
* @param maxLines - Maximum number of error lines to extract
|
|
13
|
+
* @returns Array of error-like lines (up to maxLines)
|
|
14
|
+
*/
|
|
15
|
+
export function extractErrorLines(output: string, maxLines: number): string[] {
|
|
16
|
+
const lines = output.split('\n');
|
|
17
|
+
const errorLines: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < lines.length && errorLines.length < maxLines; i++) {
|
|
20
|
+
const line = lines[i];
|
|
21
|
+
if (line && ERROR_PATTERNS.test(line)) {
|
|
22
|
+
errorLines.push(line);
|
|
23
|
+
// Include 1-2 lines of context after the error
|
|
24
|
+
const nextLine = lines[i + 1];
|
|
25
|
+
const nextNextLine = lines[i + 2];
|
|
26
|
+
if (nextLine !== undefined) {
|
|
27
|
+
errorLines.push(nextLine);
|
|
28
|
+
}
|
|
29
|
+
if (nextNextLine !== undefined) {
|
|
30
|
+
errorLines.push(nextNextLine);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return errorLines.slice(0, maxLines);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return the last N lines of output, with a truncation notice if needed.
|
|
40
|
+
*
|
|
41
|
+
* @param output - The full command output
|
|
42
|
+
* @param maxLines - Maximum number of lines to return
|
|
43
|
+
* @returns Truncated output with notice, or full output if short enough
|
|
44
|
+
*/
|
|
45
|
+
export function tailLines(output: string, maxLines: number): string {
|
|
46
|
+
const lines = output.split('\n');
|
|
47
|
+
if (lines.length <= maxLines) {
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const truncatedCount = lines.length - maxLines;
|
|
52
|
+
const tail = lines.slice(-maxLines).join('\n');
|
|
53
|
+
|
|
54
|
+
return `... (${truncatedCount} lines truncated)\n${tail}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { QualityGateConfig } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default configuration values for StopQualityGate
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_QUALITY_GATE: Required<
|
|
7
|
+
Omit<QualityGateConfig, 'lint' | 'build' | 'test' | 'cwd'>
|
|
8
|
+
> = {
|
|
9
|
+
steps: ['lint', 'build', 'test'],
|
|
10
|
+
useCiSh: 'auto',
|
|
11
|
+
packageManager: 'auto',
|
|
12
|
+
cacheSeconds: 30,
|
|
13
|
+
maxOutputLines: 160,
|
|
14
|
+
maxErrorLines: 60,
|
|
15
|
+
include: {
|
|
16
|
+
rustClippy: true,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Shell type - uses `any` to be compatible with OpenCode's BunShell
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
type BunShell = any;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a JSON file safely, returning null if not found or invalid
|
|
26
|
+
*/
|
|
27
|
+
export async function readJsonFile(
|
|
28
|
+
$: BunShell,
|
|
29
|
+
path: string
|
|
30
|
+
): Promise<Record<string, unknown> | null> {
|
|
31
|
+
try {
|
|
32
|
+
const result = await $`cat ${path}`.quiet();
|
|
33
|
+
return JSON.parse(result.text()) as Record<string, unknown>;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a file exists
|
|
41
|
+
*/
|
|
42
|
+
export async function fileExists($: BunShell, path: string): Promise<boolean> {
|
|
43
|
+
try {
|
|
44
|
+
await $`test -f ${path}`.quiet();
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Merge two qualityGate config objects.
|
|
53
|
+
*
|
|
54
|
+
* Precedence: `.opencode/opencode.json` overrides `opencode.json` (root).
|
|
55
|
+
* Deep-merges nested objects like `include`.
|
|
56
|
+
*/
|
|
57
|
+
export function mergeQualityGateConfig(
|
|
58
|
+
root: QualityGateConfig | undefined,
|
|
59
|
+
dotOpencode: QualityGateConfig | undefined
|
|
60
|
+
): QualityGateConfig {
|
|
61
|
+
const base = root ?? {};
|
|
62
|
+
const override = dotOpencode ?? {};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
...base,
|
|
66
|
+
...override,
|
|
67
|
+
// Deep merge the `include` object
|
|
68
|
+
include: {
|
|
69
|
+
...(base.include ?? {}),
|
|
70
|
+
...(override.include ?? {}),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load and merge qualityGate config from both config file locations.
|
|
77
|
+
*
|
|
78
|
+
* Reads from:
|
|
79
|
+
* - `${directory}/opencode.json`
|
|
80
|
+
* - `${directory}/.opencode/opencode.json`
|
|
81
|
+
*
|
|
82
|
+
* Merges ONLY the `qualityGate` key. `.opencode/opencode.json` takes precedence.
|
|
83
|
+
*/
|
|
84
|
+
export async function loadQualityGateConfig(
|
|
85
|
+
$: BunShell,
|
|
86
|
+
directory: string
|
|
87
|
+
): Promise<QualityGateConfig> {
|
|
88
|
+
const rootConfigPath = `${directory}/opencode.json`;
|
|
89
|
+
const dotOpencodeConfigPath = `${directory}/.opencode/opencode.json`;
|
|
90
|
+
|
|
91
|
+
const [rootJson, dotOpencodeJson] = await Promise.all([
|
|
92
|
+
readJsonFile($, rootConfigPath),
|
|
93
|
+
readJsonFile($, dotOpencodeConfigPath),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const rootQualityGate = rootJson?.qualityGate as QualityGateConfig | undefined;
|
|
97
|
+
const dotOpencodeQualityGate = dotOpencodeJson?.qualityGate as QualityGateConfig | undefined;
|
|
98
|
+
|
|
99
|
+
return mergeQualityGateConfig(rootQualityGate, dotOpencodeQualityGate);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if any explicit commands are configured (lint/build/test).
|
|
104
|
+
* If so, these take priority over ci.sh and discovery.
|
|
105
|
+
*/
|
|
106
|
+
export function hasConfiguredCommands(config: QualityGateConfig): boolean {
|
|
107
|
+
return !!(config.lint || config.build || config.test);
|
|
108
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type { PluginInput, Hooks } from '@opencode-ai/plugin';
|
|
2
|
+
import type { QualityGateConfig, SessionState, StepResult } from './types';
|
|
3
|
+
import { DEFAULT_QUALITY_GATE, loadQualityGateConfig } from './quality-gate-config';
|
|
4
|
+
import { extractErrorLines, tailLines } from './output';
|
|
5
|
+
import { resolveCommands } from './discovery';
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Constants
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
const SERVICE_NAME = 'feature-factory';
|
|
12
|
+
|
|
13
|
+
/** Debounce delay before running quality gate after session.idle (ms) */
|
|
14
|
+
const IDLE_DEBOUNCE_MS = 500;
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Session State Management
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
interface ExtendedSessionState extends SessionState {
|
|
21
|
+
/** Whether quality gate has passed since last edit */
|
|
22
|
+
qualityGatePassed: boolean;
|
|
23
|
+
/** Debounce timer for session.idle */
|
|
24
|
+
idleDebounce: ReturnType<typeof setTimeout> | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sessions = new Map<string, ExtendedSessionState>();
|
|
28
|
+
|
|
29
|
+
function getSessionState(sessionId: string): ExtendedSessionState {
|
|
30
|
+
const existing = sessions.get(sessionId);
|
|
31
|
+
if (existing) return existing;
|
|
32
|
+
|
|
33
|
+
const state: ExtendedSessionState = {
|
|
34
|
+
lastRunAt: 0,
|
|
35
|
+
lastResults: [],
|
|
36
|
+
dirty: true,
|
|
37
|
+
qualityGatePassed: false,
|
|
38
|
+
idleDebounce: null,
|
|
39
|
+
};
|
|
40
|
+
sessions.set(sessionId, state);
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Prompt Builders
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the prompt shown when a quality gate step fails
|
|
50
|
+
*/
|
|
51
|
+
function buildFailurePrompt(params: { config: QualityGateConfig; failed: StepResult }): string {
|
|
52
|
+
const merged = { ...DEFAULT_QUALITY_GATE, ...params.config };
|
|
53
|
+
const errorLines = extractErrorLines(params.failed.output, merged.maxErrorLines);
|
|
54
|
+
const tailOutput = tailLines(params.failed.output, merged.maxOutputLines);
|
|
55
|
+
|
|
56
|
+
let prompt = `## Quality gate failed: ${params.failed.step}
|
|
57
|
+
|
|
58
|
+
The quality gate check has failed. Please fix the issues before completing your work.
|
|
59
|
+
|
|
60
|
+
**Command:**
|
|
61
|
+
\`\`\`
|
|
62
|
+
${params.failed.cmd}
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
**Exit code:** ${params.failed.exitCode}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
if (errorLines.length > 0) {
|
|
69
|
+
prompt += `
|
|
70
|
+
**Key errors:**
|
|
71
|
+
\`\`\`
|
|
72
|
+
${errorLines.join('\n')}
|
|
73
|
+
\`\`\`
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
prompt += `
|
|
78
|
+
**Output (tail):**
|
|
79
|
+
\`\`\`
|
|
80
|
+
${tailOutput}
|
|
81
|
+
\`\`\`
|
|
82
|
+
|
|
83
|
+
## Next steps:
|
|
84
|
+
1. Fix the issues reported above.
|
|
85
|
+
2. Re-run: \`${params.failed.cmd}\`
|
|
86
|
+
3. Complete your work.
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
return prompt;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the prompt shown when no quality gate commands could be found
|
|
94
|
+
*/
|
|
95
|
+
function buildNoCommandsPrompt(params: { directory: string }): string {
|
|
96
|
+
return `## Quality gate could not run
|
|
97
|
+
|
|
98
|
+
No quality commands were found for this project.
|
|
99
|
+
|
|
100
|
+
**Checked locations:**
|
|
101
|
+
- \`${params.directory}/opencode.json\` (qualityGate.lint/build/test)
|
|
102
|
+
- \`${params.directory}/.opencode/opencode.json\` (qualityGate.lint/build/test)
|
|
103
|
+
- \`${params.directory}/management/ci.sh\` (Feature Factory CI script)
|
|
104
|
+
- \`${params.directory}/package.json\` (npm scripts: lint, build, test)
|
|
105
|
+
- \`${params.directory}/Cargo.toml\` (Rust project)
|
|
106
|
+
- \`${params.directory}/go.mod\` (Go project)
|
|
107
|
+
- \`${params.directory}/pyproject.toml\` or \`pytest.ini\` (Python project)
|
|
108
|
+
|
|
109
|
+
**To configure quality gates, either:**
|
|
110
|
+
1. Add \`qualityGate\` config to \`opencode.json\` or \`.opencode/opencode.json\`:
|
|
111
|
+
\`\`\`json
|
|
112
|
+
{
|
|
113
|
+
"qualityGate": {
|
|
114
|
+
"lint": "npm run lint",
|
|
115
|
+
"build": "npm run build",
|
|
116
|
+
"test": "npm run test"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
2. Or create a Feature Factory CI script at \`management/ci.sh\``;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Command Execution
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
// Shell type from PluginInput
|
|
129
|
+
type BunShell = PluginInput['$'];
|
|
130
|
+
type Client = PluginInput['client'];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run a single command and capture its output
|
|
134
|
+
*/
|
|
135
|
+
async function runCommand(
|
|
136
|
+
$: BunShell,
|
|
137
|
+
cmd: string,
|
|
138
|
+
cwd: string
|
|
139
|
+
): Promise<{ exitCode: number; output: string }> {
|
|
140
|
+
try {
|
|
141
|
+
const result = await $`bash -c ${cmd}`.cwd(cwd).quiet().nothrow();
|
|
142
|
+
const stdout = result.text() || '';
|
|
143
|
+
const stderr = result.stderr?.toString?.() || '';
|
|
144
|
+
return {
|
|
145
|
+
exitCode: result.exitCode,
|
|
146
|
+
output: stdout + stderr,
|
|
147
|
+
};
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
const error = err as { exitCode?: number; message?: string };
|
|
150
|
+
return {
|
|
151
|
+
exitCode: error.exitCode ?? 1,
|
|
152
|
+
output: error.message || String(err),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run all command steps sequentially, stopping on first failure
|
|
159
|
+
*/
|
|
160
|
+
async function runAllCommands(
|
|
161
|
+
$: BunShell,
|
|
162
|
+
steps: { step: string; cmd: string }[],
|
|
163
|
+
cwd: string
|
|
164
|
+
): Promise<StepResult[]> {
|
|
165
|
+
const results: StepResult[] = [];
|
|
166
|
+
|
|
167
|
+
for (const { step, cmd } of steps) {
|
|
168
|
+
const { exitCode, output } = await runCommand($, cmd, cwd);
|
|
169
|
+
results.push({ step, cmd, exitCode, output });
|
|
170
|
+
|
|
171
|
+
// Stop on first failure
|
|
172
|
+
if (exitCode !== 0) break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// Logging Helper
|
|
180
|
+
// ============================================================================
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Log a message using the OpenCode client API
|
|
184
|
+
*/
|
|
185
|
+
async function log(
|
|
186
|
+
client: Client,
|
|
187
|
+
level: 'debug' | 'info' | 'warn' | 'error',
|
|
188
|
+
message: string,
|
|
189
|
+
extra?: Record<string, unknown>
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
try {
|
|
192
|
+
await client.app.log({
|
|
193
|
+
body: {
|
|
194
|
+
service: SERVICE_NAME,
|
|
195
|
+
level,
|
|
196
|
+
message,
|
|
197
|
+
extra,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
// Silently ignore logging errors to avoid breaking the plugin
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// Quality Gate Hooks Factory
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Create quality gate hooks for the plugin.
|
|
212
|
+
*
|
|
213
|
+
* Resolution order for commands:
|
|
214
|
+
* 1. opencode.json / .opencode/opencode.json `qualityGate` config (authoritative)
|
|
215
|
+
* 2. management/ci.sh (Feature Factory convention, replaces all steps)
|
|
216
|
+
* 3. Conventional discovery (Node/Rust/Go/Python)
|
|
217
|
+
*
|
|
218
|
+
* On failure: prompts agent with failure output and instructions to fix.
|
|
219
|
+
* On success: logs "Quality gate passed."
|
|
220
|
+
* If no commands found: logs warning about missing configuration.
|
|
221
|
+
*/
|
|
222
|
+
export async function createQualityGateHooks(input: PluginInput): Promise<Partial<Hooks>> {
|
|
223
|
+
const { client, $, directory } = input;
|
|
224
|
+
|
|
225
|
+
// Load and merge config from both config file locations
|
|
226
|
+
const qualityGate = await loadQualityGateConfig($, directory);
|
|
227
|
+
|
|
228
|
+
const cacheSeconds = qualityGate.cacheSeconds ?? DEFAULT_QUALITY_GATE.cacheSeconds;
|
|
229
|
+
const cwd = qualityGate.cwd ? `${directory}/${qualityGate.cwd}` : directory;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Run the quality gate checks for a session
|
|
233
|
+
*/
|
|
234
|
+
async function runQualityGate(sessionId: string): Promise<void> {
|
|
235
|
+
const state = getSessionState(sessionId);
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const cacheExpired = now - state.lastRunAt > cacheSeconds * 1000;
|
|
238
|
+
|
|
239
|
+
// Skip if already passed and not dirty
|
|
240
|
+
if (state.qualityGatePassed && !state.dirty && !cacheExpired) {
|
|
241
|
+
await log(client, 'debug', 'quality-gate.skipped (cached pass)', { sessionId });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Log start
|
|
246
|
+
await log(client, 'info', 'quality-gate.started', {
|
|
247
|
+
sessionId,
|
|
248
|
+
directory,
|
|
249
|
+
cwd,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Resolve commands
|
|
253
|
+
const plan = await resolveCommands({ $, directory, config: qualityGate });
|
|
254
|
+
|
|
255
|
+
// No commands found - log warning but don't block
|
|
256
|
+
if (plan.length === 0) {
|
|
257
|
+
await log(client, 'warn', 'quality-gate.no_commands', {
|
|
258
|
+
sessionId,
|
|
259
|
+
directory,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Only prompt once per session about missing config
|
|
263
|
+
if (!state.qualityGatePassed) {
|
|
264
|
+
await client.session.prompt({
|
|
265
|
+
path: { id: sessionId },
|
|
266
|
+
body: {
|
|
267
|
+
parts: [{ type: 'text', text: buildNoCommandsPrompt({ directory }) }],
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Run commands if dirty, cache expired, or no cached results
|
|
275
|
+
let results: StepResult[] = state.lastResults;
|
|
276
|
+
|
|
277
|
+
if (state.dirty || cacheExpired || results.length === 0) {
|
|
278
|
+
const startTime = Date.now();
|
|
279
|
+
results = await runAllCommands($, plan, cwd);
|
|
280
|
+
const durationMs = Date.now() - startTime;
|
|
281
|
+
|
|
282
|
+
state.lastResults = results;
|
|
283
|
+
state.lastRunAt = now;
|
|
284
|
+
state.dirty = false;
|
|
285
|
+
|
|
286
|
+
// Log execution details
|
|
287
|
+
await log(client, 'debug', 'quality-gate.executed', {
|
|
288
|
+
sessionId,
|
|
289
|
+
plan: plan.map((p) => p.step),
|
|
290
|
+
durationMs,
|
|
291
|
+
results: results.map((r) => ({
|
|
292
|
+
step: r.step,
|
|
293
|
+
exitCode: r.exitCode,
|
|
294
|
+
})),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check for failures
|
|
299
|
+
const failed = results.find((r) => r.exitCode !== 0);
|
|
300
|
+
|
|
301
|
+
if (failed) {
|
|
302
|
+
state.qualityGatePassed = false;
|
|
303
|
+
|
|
304
|
+
// Log failure
|
|
305
|
+
await log(client, 'error', 'quality-gate.failed', {
|
|
306
|
+
sessionId,
|
|
307
|
+
step: failed.step,
|
|
308
|
+
cmd: failed.cmd,
|
|
309
|
+
exitCode: failed.exitCode,
|
|
310
|
+
outputTail: tailLines(failed.output, 50),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Prompt agent with failure details to continue working
|
|
314
|
+
await client.session.prompt({
|
|
315
|
+
path: { id: sessionId },
|
|
316
|
+
body: {
|
|
317
|
+
parts: [
|
|
318
|
+
{
|
|
319
|
+
type: 'text',
|
|
320
|
+
text: buildFailurePrompt({ config: qualityGate, failed }),
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// All passed
|
|
329
|
+
state.qualityGatePassed = true;
|
|
330
|
+
|
|
331
|
+
await log(client, 'info', 'quality-gate.passed', {
|
|
332
|
+
sessionId,
|
|
333
|
+
steps: results.map((r) => r.step),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
// Handle session lifecycle and idle events
|
|
339
|
+
event: async ({ event }) => {
|
|
340
|
+
// Event properties contain sessionID
|
|
341
|
+
const eventProps = (event as { properties?: { sessionID?: string } }).properties;
|
|
342
|
+
const sessionId = eventProps?.sessionID;
|
|
343
|
+
|
|
344
|
+
if (event.type === 'session.created' && sessionId) {
|
|
345
|
+
sessions.set(sessionId, {
|
|
346
|
+
lastRunAt: 0,
|
|
347
|
+
lastResults: [],
|
|
348
|
+
dirty: true,
|
|
349
|
+
qualityGatePassed: false,
|
|
350
|
+
idleDebounce: null,
|
|
351
|
+
});
|
|
352
|
+
await log(client, 'debug', 'session.created', { sessionId });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (event.type === 'session.deleted' && sessionId) {
|
|
356
|
+
const state = sessions.get(sessionId);
|
|
357
|
+
if (state?.idleDebounce) {
|
|
358
|
+
clearTimeout(state.idleDebounce);
|
|
359
|
+
}
|
|
360
|
+
sessions.delete(sessionId);
|
|
361
|
+
await log(client, 'debug', 'session.deleted', { sessionId });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Run quality gate when session becomes idle (agent stopped)
|
|
365
|
+
if (event.type === 'session.idle' && sessionId) {
|
|
366
|
+
const state = getSessionState(sessionId);
|
|
367
|
+
|
|
368
|
+
// Debounce to avoid running multiple times
|
|
369
|
+
if (state.idleDebounce) {
|
|
370
|
+
clearTimeout(state.idleDebounce);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
state.idleDebounce = setTimeout(async () => {
|
|
374
|
+
state.idleDebounce = null;
|
|
375
|
+
await runQualityGate(sessionId);
|
|
376
|
+
}, IDLE_DEBOUNCE_MS);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
// Mark session dirty when files are edited
|
|
381
|
+
'tool.execute.before': async (input) => {
|
|
382
|
+
const sessionId = input.sessionID;
|
|
383
|
+
if (!sessionId) return;
|
|
384
|
+
|
|
385
|
+
const state = getSessionState(sessionId);
|
|
386
|
+
|
|
387
|
+
if (input.tool === 'edit' || input.tool === 'write' || input.tool === 'patch') {
|
|
388
|
+
state.dirty = true;
|
|
389
|
+
state.qualityGatePassed = false;
|
|
390
|
+
await log(client, 'debug', 'session.dirty', { sessionId, tool: input.tool });
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the StopQualityGate plugin.
|
|
3
|
+
* Read from `qualityGate` in opencode.json or .opencode/opencode.json
|
|
4
|
+
*/
|
|
5
|
+
export interface QualityGateConfig {
|
|
6
|
+
/** Custom lint command (e.g., "pnpm -s lint") */
|
|
7
|
+
lint?: string;
|
|
8
|
+
/** Custom build command (e.g., "pnpm -s build") */
|
|
9
|
+
build?: string;
|
|
10
|
+
/** Custom test command (e.g., "pnpm -s test") */
|
|
11
|
+
test?: string;
|
|
12
|
+
/** Working directory relative to repo root (default: ".") */
|
|
13
|
+
cwd?: string;
|
|
14
|
+
/** Order of steps to run (default: ["lint", "build", "test"]) */
|
|
15
|
+
steps?: ('lint' | 'build' | 'test')[];
|
|
16
|
+
/** Whether to use management/ci.sh: "auto" | "always" | "never" (default: "auto") */
|
|
17
|
+
useCiSh?: 'auto' | 'always' | 'never';
|
|
18
|
+
/** Package manager override: "auto" | "pnpm" | "bun" | "yarn" | "npm" (default: "auto") */
|
|
19
|
+
packageManager?: 'auto' | 'pnpm' | 'bun' | 'yarn' | 'npm';
|
|
20
|
+
/** Cache duration in seconds before re-running checks (default: 30) */
|
|
21
|
+
cacheSeconds?: number;
|
|
22
|
+
/** Max lines of output tail to include in failure prompt (default: 160) */
|
|
23
|
+
maxOutputLines?: number;
|
|
24
|
+
/** Max error lines to extract and show (default: 60) */
|
|
25
|
+
maxErrorLines?: number;
|
|
26
|
+
/** Feature flags for discovery */
|
|
27
|
+
include?: {
|
|
28
|
+
/** Include cargo clippy in Rust discovery (default: true) */
|
|
29
|
+
rustClippy?: boolean;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A single command step to execute
|
|
35
|
+
*/
|
|
36
|
+
export interface CommandStep {
|
|
37
|
+
/** Name of the step (e.g., "lint", "build", "test", "ci") */
|
|
38
|
+
step: string;
|
|
39
|
+
/** The shell command to run */
|
|
40
|
+
cmd: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Result of executing a command step
|
|
45
|
+
*/
|
|
46
|
+
export interface StepResult {
|
|
47
|
+
/** Name of the step */
|
|
48
|
+
step: string;
|
|
49
|
+
/** The command that was run */
|
|
50
|
+
cmd: string;
|
|
51
|
+
/** Exit code (0 = success) */
|
|
52
|
+
exitCode: number;
|
|
53
|
+
/** Combined stdout + stderr output */
|
|
54
|
+
output: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Per-session state for caching and dirty tracking
|
|
59
|
+
*/
|
|
60
|
+
export interface SessionState {
|
|
61
|
+
/** Timestamp of last quality gate run */
|
|
62
|
+
lastRunAt: number;
|
|
63
|
+
/** Results from last run */
|
|
64
|
+
lastResults: StepResult[];
|
|
65
|
+
/** Whether files have been edited since last run */
|
|
66
|
+
dirty: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Package manager type for Node projects
|
|
71
|
+
*/
|
|
72
|
+
export type PackageManager = 'pnpm' | 'bun' | 'yarn' | 'npm';
|