@syntesseraai/opencode-feature-factory 0.2.3 → 0.2.4

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,450 +0,0 @@
1
- import type { PluginInput, Hooks } from '@opencode-ai/plugin';
2
-
3
- const SERVICE_NAME = 'feature-factory';
4
- const IDLE_DEBOUNCE_MS = 500;
5
- const CI_TIMEOUT_MS = 300000; // 5 minutes
6
- const SESSION_TTL_MS = 3600000; // 1 hour
7
- const CLEANUP_INTERVAL_MS = 600000; // 10 minutes
8
-
9
- export const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
10
- // AWS Access Key IDs
11
- { pattern: /AKIA[0-9A-Z]{16}/g, replacement: '[REDACTED_AWS_KEY]' },
12
- // GitHub Personal Access Tokens (classic)
13
- { pattern: /ghp_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
14
- // GitHub Personal Access Tokens (fine-grained)
15
- { pattern: /github_pat_[A-Za-z0-9_]{22,}/g, replacement: '[REDACTED_GH_TOKEN]' },
16
- // GitHub OAuth tokens
17
- { pattern: /gho_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
18
- // GitHub App tokens (user-to-server and server-to-server)
19
- { pattern: /ghu_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
20
- { pattern: /ghs_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
21
- // GitLab Personal Access Tokens
22
- { pattern: /glpat-[A-Za-z0-9-]{20,}/g, replacement: '[REDACTED_GITLAB_TOKEN]' },
23
- // npm tokens
24
- { pattern: /npm_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_NPM_TOKEN]' },
25
- // Slack bot tokens
26
- { pattern: /xoxb-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
27
- // Slack user tokens
28
- { pattern: /xoxp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
29
- // Slack app tokens
30
- { pattern: /xapp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
31
- // Slack webhook URLs
32
- { pattern: /hooks\.slack\.com\/services\/[A-Z0-9/]+/g, replacement: '[REDACTED_SLACK_WEBHOOK]' },
33
- // Stripe live secret keys
34
- { pattern: /sk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
35
- // Stripe test secret keys
36
- { pattern: /sk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
37
- // Stripe live restricted keys
38
- { pattern: /rk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
39
- // Stripe test restricted keys
40
- { pattern: /rk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
41
- // Bearer tokens
42
- { pattern: /Bearer\s+[\w\-.]+/gi, replacement: 'Bearer [REDACTED]' },
43
- // API keys (api_key, api-key, apikey, apikeys, etc.)
44
- { pattern: /api[_-]?keys?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'api_key=[REDACTED]' },
45
- // Tokens (token, tokens) - require minimum 8 char value to reduce false positives
46
- { pattern: /tokens?[=:\s]+['"]?([A-Za-z0-9_-]{8,})['"]?/gi, replacement: 'token=[REDACTED]' },
47
- // Passwords
48
- { pattern: /passwords?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'password=[REDACTED]' },
49
- // Generic secrets
50
- { pattern: /secrets?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'secret=[REDACTED]' },
51
- // Base64-encoded long strings that look like secrets (40-500 chars to prevent ReDoS)
52
- { pattern: /[A-Za-z0-9+/]{40,500}={0,2}/g, replacement: '[REDACTED_BASE64]' },
53
- // Private keys (RSA, DSA, EC, OpenSSH, etc.)
54
- {
55
- pattern: /-----BEGIN[\s\w]+PRIVATE KEY-----[\s\S]*?-----END[\s\w]+PRIVATE KEY-----/g,
56
- replacement: '[REDACTED_PRIVATE_KEY]',
57
- },
58
- // Database connection strings with credentials (postgres, postgresql, mysql, mongodb, redis)
59
- // Password portion handles URL-encoded characters like %40 (for @) and %23 (for #)
60
- {
61
- pattern:
62
- /(postgres|postgresql|mysql|mongodb(\+srv)?|rediss?):\/\/[^\s/:]+:(?:[^@\s]|%[0-9A-Fa-f]{2})+@[^\s]+/gi,
63
- replacement: '[REDACTED_CONNECTION_STRING]',
64
- },
65
- // GCP API keys
66
- { pattern: /AIza[0-9A-Za-z_-]{35}/g, replacement: '[REDACTED_GCP_KEY]' },
67
- // GCP OAuth tokens
68
- { pattern: /ya29\.[0-9A-Za-z_-]+/g, replacement: '[REDACTED_GCP_TOKEN]' },
69
- ];
70
-
71
- /**
72
- * Sanitizes CI output by redacting common secret patterns before sending to the LLM.
73
- * This helps prevent accidental exposure of sensitive information in prompts.
74
- */
75
- export function sanitizeOutput(output: string): string {
76
- let sanitized = output;
77
- for (const { pattern, replacement } of SECRET_PATTERNS) {
78
- sanitized = sanitized.replace(pattern, replacement);
79
- }
80
- return sanitized;
81
- }
82
-
83
- /**
84
- * Truncates CI output to the last N lines to reduce prompt size and focus on relevant errors.
85
- * Adds a header indicating truncation if the output was longer than the limit.
86
- */
87
- export function truncateOutput(output: string, maxLines: number = 20): string {
88
- const lines = output.split('\n');
89
- if (lines.length <= maxLines) {
90
- return output;
91
- }
92
- const truncatedLines = lines.slice(-maxLines);
93
- const omittedCount = lines.length - maxLines;
94
- return `... (${omittedCount} lines omitted)\n${truncatedLines.join('\n')}`;
95
- }
96
-
97
- interface SessionState {
98
- lastRunAt: number;
99
- dirty: boolean;
100
- qualityGatePassed: boolean;
101
- idleDebounce: ReturnType<typeof setTimeout> | null;
102
- running: boolean;
103
- parentID?: string;
104
- isReadOnly?: boolean;
105
- lastAccess: number;
106
- }
107
-
108
- const sessions = new Map<string, SessionState>();
109
- let cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
110
-
111
- /**
112
- * Removes sessions that haven't been accessed within the TTL period.
113
- * Skips sessions that are currently running a quality gate check.
114
- */
115
- function cleanupStaleSessions(): void {
116
- const now = Date.now();
117
- for (const [sessionId, state] of sessions) {
118
- if (state.running) continue; // Don't evict active sessions
119
- if (now - state.lastAccess > SESSION_TTL_MS) {
120
- if (state.idleDebounce) {
121
- clearTimeout(state.idleDebounce);
122
- }
123
- sessions.delete(sessionId);
124
- }
125
- }
126
- }
127
-
128
- function startCleanupInterval(): void {
129
- if (cleanupIntervalId) return;
130
- cleanupIntervalId = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
131
- // Allow the process to exit even if the interval is running
132
- if (cleanupIntervalId.unref) {
133
- cleanupIntervalId.unref();
134
- }
135
- }
136
-
137
- function getSessionState(sessionId: string): SessionState {
138
- const existing = sessions.get(sessionId);
139
- if (existing) {
140
- existing.lastAccess = Date.now();
141
- return existing;
142
- }
143
-
144
- startCleanupInterval();
145
-
146
- const now = Date.now();
147
- const state: SessionState = {
148
- lastRunAt: 0,
149
- dirty: true,
150
- qualityGatePassed: false,
151
- idleDebounce: null,
152
- running: false,
153
- lastAccess: now,
154
- };
155
- sessions.set(sessionId, state);
156
- return state;
157
- }
158
-
159
- interface PermissionRule {
160
- permission: string;
161
- pattern: string;
162
- action: 'allow' | 'deny' | 'ask';
163
- }
164
-
165
- function isSessionReadOnly(permission?: PermissionRule[]): boolean {
166
- if (!permission) return false;
167
- return permission.some(
168
- (rule) => (rule.permission === 'edit' || rule.permission === 'bash') && rule.action === 'deny'
169
- );
170
- }
171
-
172
- type Client = PluginInput['client'];
173
-
174
- async function log(
175
- client: Client,
176
- level: 'debug' | 'info' | 'warn' | 'error',
177
- message: string,
178
- extra?: Record<string, unknown>
179
- ): Promise<void> {
180
- try {
181
- await client.app.log({
182
- body: {
183
- service: SERVICE_NAME,
184
- level,
185
- message,
186
- extra,
187
- },
188
- });
189
- } catch {
190
- return undefined;
191
- }
192
- }
193
-
194
- export async function createQualityGateHooks(input: PluginInput): Promise<Partial<Hooks>> {
195
- const { client, $, directory } = input;
196
-
197
- async function ciShExists(): Promise<boolean> {
198
- try {
199
- await $`test -f ${directory}/management/ci.sh`.quiet();
200
- return true;
201
- } catch {
202
- return false;
203
- }
204
- }
205
-
206
- async function _ensureSessionMetadata(sessionId: string, state: SessionState): Promise<void> {
207
- if (state.isReadOnly !== undefined) return;
208
-
209
- try {
210
- const response = await client.session.get({ path: { id: sessionId } });
211
- if (response.data) {
212
- state.parentID = response.data.parentID;
213
- const permission = (response.data as { permission?: PermissionRule[] }).permission;
214
- state.isReadOnly = isSessionReadOnly(permission);
215
- await log(client, 'debug', 'session.metadata-fetched', {
216
- sessionId,
217
- parentID: state.parentID,
218
- isReadOnly: state.isReadOnly,
219
- });
220
- }
221
- } catch {
222
- state.isReadOnly = false;
223
- await log(client, 'warn', 'session.metadata-fetch-failed', { sessionId });
224
- }
225
- }
226
-
227
- async function runQualityGate(sessionId: string): Promise<void> {
228
- const state = getSessionState(sessionId);
229
-
230
- if (!sessions.has(sessionId)) {
231
- await log(client, 'debug', 'quality-gate.skipped (session deleted)', { sessionId });
232
- return;
233
- }
234
-
235
- if (state.running) {
236
- await log(client, 'debug', 'quality-gate.skipped (already running)', { sessionId });
237
- return;
238
- }
239
-
240
- const now = Date.now();
241
- const cacheExpired = now - state.lastRunAt > 30 * 1000;
242
-
243
- if (state.qualityGatePassed && !state.dirty && !cacheExpired) {
244
- await log(client, 'debug', 'quality-gate.skipped (cached pass)', { sessionId });
245
- return;
246
- }
247
-
248
- const hasCiSh = await ciShExists();
249
-
250
- if (!hasCiSh) {
251
- await log(client, 'debug', 'quality-gate.skipped (no ci.sh)', { sessionId });
252
- return;
253
- }
254
-
255
- state.running = true;
256
-
257
- try {
258
- await log(client, 'info', 'quality-gate.started', { sessionId, directory });
259
-
260
- let ciOutput = '';
261
- let ciPassed = false;
262
- let timedOut = false;
263
-
264
- const ciPath = `${directory}/management/ci.sh`;
265
- // eslint-disable-next-line no-undef
266
- const proc = Bun.spawn(['bash', ciPath], {
267
- cwd: directory,
268
- stdout: 'pipe',
269
- stderr: 'pipe',
270
- });
271
-
272
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
273
- let forceKillTimeoutId: ReturnType<typeof setTimeout> | null = null;
274
- const timeoutPromise = new Promise<void>((resolve) => {
275
- timeoutId = setTimeout(() => {
276
- timedOut = true;
277
- // Graceful termination: SIGTERM first, then SIGKILL after grace period
278
- proc.kill('SIGTERM');
279
- forceKillTimeoutId = setTimeout(() => {
280
- // Force kill if still running after grace period
281
- try {
282
- proc.kill('SIGKILL');
283
- } catch {
284
- // Process already terminated
285
- }
286
- }, 5000);
287
- resolve();
288
- }, CI_TIMEOUT_MS);
289
- });
290
-
291
- // Race the process completion against the timeout
292
- await Promise.race([proc.exited, timeoutPromise]);
293
-
294
- // Clear timeouts if process completed before timeout
295
- if (timeoutId) {
296
- clearTimeout(timeoutId);
297
- }
298
- if (forceKillTimeoutId) {
299
- clearTimeout(forceKillTimeoutId);
300
- }
301
-
302
- const exitCode = proc.exitCode;
303
- const stdout = await new Response(proc.stdout).text();
304
- const stderr = await new Response(proc.stderr).text();
305
-
306
- if (timedOut) {
307
- await log(client, 'warn', 'quality-gate.timeout', {
308
- sessionId,
309
- timeoutMs: CI_TIMEOUT_MS,
310
- });
311
- ciOutput =
312
- `CI execution timed out after ${CI_TIMEOUT_MS / 1000} seconds\n\n${stdout}\n${stderr}`.trim();
313
- ciPassed = false;
314
- } else {
315
- ciOutput = stdout + (stderr ? `\n${stderr}` : '');
316
- ciPassed = exitCode === 0;
317
- }
318
-
319
- state.lastRunAt = Date.now();
320
- state.dirty = false;
321
- state.qualityGatePassed = ciPassed;
322
-
323
- if (!ciPassed) {
324
- // Sanitize secrets first, then truncate to last 20 lines to reduce prompt size
325
- const sanitizedOutput = truncateOutput(sanitizeOutput(ciOutput), 20);
326
- const instructions = `
327
-
328
- **Important:** Do not interrupt your current task. Add "Fix quality gate failures" to your todo list and continue with what you were doing. Address the quality gate issues after completing your current task.
329
-
330
- If the failure details are missing or truncated, run "management/ci.sh" to get the full output.`;
331
- const message = timedOut
332
- ? `⏱️ Quality gate timed out\n\nThe CI execution exceeded the ${CI_TIMEOUT_MS / 1000} second timeout. The build may be hanging or taking too long.\n\n\`\`\`\n${sanitizedOutput}\n\`\`\`${instructions}`
333
- : `❌ Quality gate failed\n\nThe CI checks did not pass. Please review the output below and fix the issues:\n\n\`\`\`\n${sanitizedOutput}\n\`\`\`${instructions}`;
334
- await client.session.prompt({
335
- path: { id: sessionId },
336
- body: {
337
- parts: [
338
- {
339
- type: 'text',
340
- text: message,
341
- },
342
- ],
343
- },
344
- });
345
- await log(
346
- client,
347
- 'debug',
348
- timedOut ? 'quality-gate.timeout-reported' : 'quality-gate.failed',
349
- { sessionId }
350
- );
351
- }
352
- } finally {
353
- state.running = false;
354
- }
355
- }
356
-
357
- return {
358
- event: async ({ event }) => {
359
- const eventProps = (event as { properties?: { sessionID?: string } }).properties;
360
- const sessionId = eventProps?.sessionID;
361
-
362
- if (event.type === 'session.created') {
363
- const sessionInfo = (
364
- event as {
365
- properties?: {
366
- info?: { id: string; parentID?: string; permission?: PermissionRule[] };
367
- };
368
- }
369
- ).properties?.info;
370
- const id = sessionInfo?.id;
371
- if (id) {
372
- startCleanupInterval();
373
- const isReadOnly = isSessionReadOnly(sessionInfo?.permission);
374
- sessions.set(id, {
375
- lastRunAt: 0,
376
- dirty: true,
377
- qualityGatePassed: false,
378
- idleDebounce: null,
379
- running: false,
380
- parentID: sessionInfo?.parentID,
381
- isReadOnly,
382
- lastAccess: Date.now(),
383
- });
384
- await log(client, 'debug', 'session.created', {
385
- sessionId: id,
386
- parentID: sessionInfo?.parentID,
387
- isReadOnly,
388
- });
389
- }
390
- }
391
-
392
- if (event.type === 'session.deleted' && sessionId) {
393
- const state = sessions.get(sessionId);
394
- if (state?.idleDebounce) {
395
- clearTimeout(state.idleDebounce);
396
- }
397
- sessions.delete(sessionId);
398
- await log(client, 'debug', 'session.deleted', { sessionId });
399
- }
400
-
401
- if (event.type === 'session.idle' && sessionId) {
402
- const state = getSessionState(sessionId);
403
-
404
- if (state.parentID) {
405
- await log(client, 'debug', 'quality-gate.skipped (sub-agent session)', {
406
- sessionId,
407
- parentID: state.parentID,
408
- });
409
- return;
410
- }
411
-
412
- if (state.isReadOnly) {
413
- await log(client, 'debug', 'quality-gate.skipped (read-only session)', { sessionId });
414
- return;
415
- }
416
-
417
- if (state.idleDebounce) {
418
- clearTimeout(state.idleDebounce);
419
- }
420
-
421
- state.idleDebounce = setTimeout(async () => {
422
- state.idleDebounce = null;
423
- if (sessions.has(sessionId)) {
424
- try {
425
- await runQualityGate(sessionId);
426
- } catch (err) {
427
- await log(client, 'error', 'quality-gate.unhandled-error', {
428
- sessionId,
429
- error: String(err),
430
- });
431
- }
432
- }
433
- }, IDLE_DEBOUNCE_MS);
434
- }
435
- },
436
-
437
- 'tool.execute.before': async (input) => {
438
- const sessionId = input.sessionID;
439
- if (!sessionId) return;
440
-
441
- const state = getSessionState(sessionId);
442
-
443
- if (input.tool === 'edit' || input.tool === 'write' || input.tool === 'patch') {
444
- state.dirty = true;
445
- state.qualityGatePassed = false;
446
- await log(client, 'debug', 'session.dirty', { sessionId, tool: input.tool });
447
- }
448
- },
449
- };
450
- }
package/src/types.ts DELETED
@@ -1,72 +0,0 @@
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';