@syntesseraai/opencode-feature-factory 0.1.6 → 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.
@@ -34,7 +34,6 @@ Launch these agents **in parallel** using the Task tool:
34
34
 
35
35
  | Agent | Purpose | Focus |
36
36
  | --------------------- | ----------------------- | -------------------------------------- |
37
- | `ff-ci` | CI checks | Tests, build, lint, type-check |
38
37
  | `ff-review` | Code review | Quality, correctness, maintainability |
39
38
  | `ff-security` | Security audit | Vulnerabilities, auth, data protection |
40
39
  | `ff-acceptance` | Requirements validation | Acceptance criteria, completeness |
@@ -51,7 +50,6 @@ Launch these agents **in parallel** using the Task tool:
51
50
 
52
51
  ```
53
52
  Launch all agents simultaneously **in parallel**:
54
- - Task(ff-ci): "Run CI checks on the changes"
55
53
  - Task(ff-review): "Review the code changes for quality"
56
54
  - Task(ff-security): "Audit the changes for security issues"
57
55
  - Task(ff-acceptance): "Validate against acceptance criteria: <criteria>"
@@ -88,11 +86,6 @@ Output your validation results as structured JSON:
88
86
  "rationale": "Security vulnerability and failing tests must be fixed"
89
87
  },
90
88
  "agents": {
91
- "ci": {
92
- "status": "failed",
93
- "summary": "3 tests failing, build passed",
94
- "blocking": true
95
- },
96
89
  "review": {
97
90
  "status": "passed",
98
91
  "summary": "Code quality acceptable with minor suggestions",
@@ -124,13 +117,6 @@ Output your validation results as structured JSON:
124
117
  "line": 45,
125
118
  "description": "User input directly concatenated in SQL query",
126
119
  "fix": "Use parameterized queries"
127
- },
128
- {
129
- "source": "ff-ci",
130
- "severity": "high",
131
- "title": "Test failures",
132
- "description": "3 unit tests failing in UserService",
133
- "fix": "Fix the failing assertions"
134
120
  }
135
121
  ],
136
122
  "nonBlocking": [
@@ -164,14 +150,12 @@ Output your validation results as structured JSON:
164
150
 
165
151
  ### Automatic Approval (approved: true)
166
152
 
167
- - All CI checks pass (tests, build, lint, types)
168
153
  - No critical or high severity security issues
169
154
  - All acceptance criteria met
170
155
  - No blocking issues from any agent
171
156
 
172
157
  ### Request Changes (approved: false)
173
158
 
174
- - Any CI check fails
175
159
  - Any security vulnerability found
176
160
  - Acceptance criteria not met
177
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.6",
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
+ }
@@ -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 ({ worktree, directory }) => {
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
- return {
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';