@syntesseraai/opencode-feature-factory 0.2.13 → 0.2.15

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.
@@ -0,0 +1,117 @@
1
+ import { tool } from '@opencode-ai/plugin/tool';
2
+ import { listActiveAgents, findAgentFiles, findAgentFilesById, findAllAgentFiles, readAgentContextById, } from './agent-context.js';
3
+ /**
4
+ * Create agent management tools for the plugin
5
+ */
6
+ export function createAgentManagementTools(input) {
7
+ const { $ } = input;
8
+ return {
9
+ tool: {
10
+ 'ff-agents-current': tool({
11
+ description: 'List all currently active Feature Factory agents with their UUIDs and status',
12
+ args: {
13
+ sessionID: tool.schema.string().optional().describe('Filter by specific session ID'),
14
+ agent: tool.schema
15
+ .string()
16
+ .optional()
17
+ .describe('Filter by agent type (e.g., planning, research)'),
18
+ },
19
+ execute: async (args) => {
20
+ try {
21
+ const agents = await listActiveAgents(input, args.sessionID, args.agent);
22
+ if (agents.length === 0) {
23
+ return JSON.stringify({
24
+ count: 0,
25
+ message: 'No active agents found',
26
+ agents: [],
27
+ }, null, 2);
28
+ }
29
+ return JSON.stringify({
30
+ count: agents.length,
31
+ agents: agents.map((a) => ({
32
+ id: a.id,
33
+ agent: a.agent,
34
+ title: a.title,
35
+ folder: a.folder,
36
+ status: a.status,
37
+ started: a.started,
38
+ session: a.session,
39
+ parent: a.parent || null,
40
+ delegated_to: a.delegated_to || [],
41
+ })),
42
+ }, null, 2);
43
+ }
44
+ catch (error) {
45
+ return JSON.stringify({
46
+ error: `Failed to list agents: ${error}`,
47
+ }, null, 2);
48
+ }
49
+ },
50
+ }),
51
+ 'ff-agents-clear': tool({
52
+ description: 'Clear agent context files. Can clear all, or filter by session, agent type, or specific UUID',
53
+ args: {
54
+ sessionID: tool.schema
55
+ .string()
56
+ .optional()
57
+ .describe('Clear only agents for specific session'),
58
+ agent: tool.schema.string().optional().describe('Clear only specific agent type'),
59
+ id: tool.schema.string().optional().describe('Clear specific agent by UUID'),
60
+ },
61
+ execute: async (args) => {
62
+ try {
63
+ let files = [];
64
+ if (args.id) {
65
+ files = await findAgentFilesById(input, args.id);
66
+ }
67
+ else if (args.agent && args.sessionID) {
68
+ files = await findAgentFiles(input, args.agent, args.sessionID);
69
+ }
70
+ else if (args.sessionID) {
71
+ files = await findAgentFiles(input, undefined, args.sessionID);
72
+ }
73
+ else if (args.agent) {
74
+ files = await findAgentFiles(input, args.agent);
75
+ }
76
+ else {
77
+ files = await findAllAgentFiles(input);
78
+ }
79
+ if (files.length === 0) {
80
+ return 'No agent context files found to clear.';
81
+ }
82
+ for (const file of files) {
83
+ try {
84
+ await $ `rm ${file}`.quiet();
85
+ }
86
+ catch {
87
+ // Continue even if one file fails
88
+ }
89
+ }
90
+ return `Cleared ${files.length} agent context file(s)`;
91
+ }
92
+ catch (error) {
93
+ return `Error clearing agents: ${error}`;
94
+ }
95
+ },
96
+ }),
97
+ 'ff-agents-show': tool({
98
+ description: 'Show detailed context for a specific agent by UUID',
99
+ args: {
100
+ id: tool.schema.string().describe('Agent UUID to show details for'),
101
+ },
102
+ execute: async (args) => {
103
+ try {
104
+ const agentContext = await readAgentContextById(input, args.id);
105
+ if (!agentContext) {
106
+ return `Agent with ID "${args.id}" not found`;
107
+ }
108
+ return JSON.stringify(agentContext, null, 2);
109
+ }
110
+ catch (error) {
111
+ return `Error reading agent: ${error}`;
112
+ }
113
+ },
114
+ }),
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,9 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ type BunShell = any;
3
+ /**
4
+ * Initialize the Feature Factory directory structure.
5
+ * Creates .feature-factory/ and .feature-factory/agents/ directories.
6
+ * Migrates management/ci.sh to .feature-factory/ci.sh if it exists.
7
+ */
8
+ export declare function initializeFeatureFactory(input: PluginInput, $: BunShell): Promise<void>;
9
+ export {};
@@ -0,0 +1,151 @@
1
+ const SERVICE_NAME = 'feature-factory';
2
+ /**
3
+ * Log a message using the OpenCode client's structured logging.
4
+ * Silently fails if logging is unavailable.
5
+ */
6
+ async function log(client, level, message, extra) {
7
+ try {
8
+ await client.app.log({
9
+ body: {
10
+ service: SERVICE_NAME,
11
+ level,
12
+ message,
13
+ extra,
14
+ },
15
+ });
16
+ }
17
+ catch {
18
+ // Logging failure should not affect plugin operation
19
+ }
20
+ }
21
+ /**
22
+ * Execute a shell command with a timeout to prevent hangs
23
+ */
24
+ async function executeWithTimeout(operation, timeoutMs = 5000, operationName = 'shell command') {
25
+ const timeoutPromise = new Promise((_, reject) => {
26
+ setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs);
27
+ });
28
+ try {
29
+ return await Promise.race([operation(), timeoutPromise]);
30
+ }
31
+ catch (error) {
32
+ // Return null on timeout or error
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * Validate that a directory path is safe to use
38
+ */
39
+ function isValidDirectory(directory) {
40
+ if (!directory || typeof directory !== 'string')
41
+ return false;
42
+ if (directory === '' || directory === '/')
43
+ return false;
44
+ // Check for common invalid patterns
45
+ if (directory.includes('\0') || directory.includes('..'))
46
+ return false;
47
+ return true;
48
+ }
49
+ /**
50
+ * Initialize the Feature Factory directory structure.
51
+ * Creates .feature-factory/ and .feature-factory/agents/ directories.
52
+ * Migrates management/ci.sh to .feature-factory/ci.sh if it exists.
53
+ */
54
+ export async function initializeFeatureFactory(input, $) {
55
+ const { directory, client } = input;
56
+ // Validate directory before attempting any operations
57
+ if (!isValidDirectory(directory)) {
58
+ await log(client, 'debug', 'feature-factory-init-skipped-invalid-directory', {
59
+ directory: String(directory),
60
+ });
61
+ return;
62
+ }
63
+ const featureFactoryDir = `${directory}/.feature-factory`;
64
+ const agentsDir = `${featureFactoryDir}/agents`;
65
+ // Create directories if they don't exist (with timeout protection)
66
+ try {
67
+ const result = await executeWithTimeout(async () => await $ `mkdir -p ${agentsDir}`.quiet(), 3000, 'mkdir -p');
68
+ if (result === null) {
69
+ await log(client, 'warn', 'feature-factory-directory-creation-timeout', {
70
+ agentsDir,
71
+ });
72
+ return; // Exit early if we can't create directories
73
+ }
74
+ await log(client, 'debug', 'feature-factory-directories-created', {
75
+ featureFactoryDir,
76
+ agentsDir,
77
+ });
78
+ }
79
+ catch (error) {
80
+ await log(client, 'warn', 'feature-factory-directory-creation-failed', {
81
+ error: String(error),
82
+ });
83
+ // Continue even if directory creation fails - it might already exist
84
+ }
85
+ // Check for CI script migration (with timeout protection)
86
+ const oldCiPath = `${directory}/management/ci.sh`;
87
+ const newCiPath = `${featureFactoryDir}/ci.sh`;
88
+ try {
89
+ // Check if old CI exists (with timeout)
90
+ const oldExists = await executeWithTimeout(async () => {
91
+ await $ `test -f ${oldCiPath}`.quiet();
92
+ return true;
93
+ }, 2000, 'test -f old-ci');
94
+ if (oldExists !== true) {
95
+ // Old doesn't exist, nothing to migrate
96
+ await log(client, 'debug', 'ci-sh-no-migration-needed', {
97
+ oldPath: oldCiPath,
98
+ });
99
+ return;
100
+ }
101
+ // Old exists, check if new already exists (with timeout)
102
+ const newExists = await executeWithTimeout(async () => {
103
+ await $ `test -f ${newCiPath}`.quiet();
104
+ return true;
105
+ }, 2000, 'test -f new-ci');
106
+ if (newExists === true) {
107
+ // Both exist - migration already done or user has both
108
+ await log(client, 'debug', 'ci-sh-both-locations-exist', {
109
+ oldPath: oldCiPath,
110
+ newPath: newCiPath,
111
+ });
112
+ return;
113
+ }
114
+ // Old exists, new doesn't - migrate (with timeout)
115
+ const copied = await executeWithTimeout(async () => {
116
+ await $ `cp ${oldCiPath} ${newCiPath}`.quiet();
117
+ return true;
118
+ }, 3000, 'cp ci.sh');
119
+ if (copied === true) {
120
+ const removed = await executeWithTimeout(async () => {
121
+ await $ `rm ${oldCiPath}`.quiet();
122
+ return true;
123
+ }, 2000, 'rm old-ci');
124
+ if (removed === true) {
125
+ await log(client, 'info', 'ci-sh-migrated', {
126
+ from: oldCiPath,
127
+ to: newCiPath,
128
+ });
129
+ }
130
+ else {
131
+ await log(client, 'warn', 'ci-sh-migration-partial', {
132
+ from: oldCiPath,
133
+ to: newCiPath,
134
+ reason: 'copied but failed to remove old file',
135
+ });
136
+ }
137
+ }
138
+ else {
139
+ await log(client, 'error', 'ci-sh-migration-failed', {
140
+ from: oldCiPath,
141
+ to: newCiPath,
142
+ reason: 'copy operation timed out or failed',
143
+ });
144
+ }
145
+ }
146
+ catch (error) {
147
+ await log(client, 'error', 'ci-sh-migration-error', {
148
+ error: String(error),
149
+ });
150
+ }
151
+ }
package/dist/index.d.ts CHANGED
@@ -6,11 +6,16 @@ import type { Plugin } from '@opencode-ai/plugin';
6
6
  *
7
7
  * Behavior:
8
8
  * - Runs quality gate on session.idle (when agent finishes working)
9
- * - Executes management/ci.sh directly (no LLM involvement)
9
+ * - Executes .feature-factory/ci.sh directly (no LLM involvement)
10
10
  * - On fast feedback: "Quality gate is running, please stand-by for results ..."
11
11
  * - On success: "Quality gate passed"
12
12
  * - On failure: passes full CI output to LLM for fix instructions
13
- * - If management/ci.sh does not exist, quality gate does not run
13
+ * - If .feature-factory/ci.sh does not exist, quality gate does not run
14
+ *
15
+ * Feature Factory Context System:
16
+ * - Creates .feature-factory/ directory structure on startup
17
+ * - Migrates management/ci.sh to .feature-factory/ci.sh if present
18
+ * - Provides tools for agent context tracking (ff-agents-current, ff-agents-clear, ff-agents-show)
14
19
  */
15
20
  export declare const StopQualityGatePlugin: Plugin;
16
21
  export default StopQualityGatePlugin;
package/dist/index.js CHANGED
@@ -1,5 +1,22 @@
1
1
  import { createQualityGateHooks } from './stop-quality-gate.js';
2
+ import { initializeFeatureFactory } from './feature-factory-setup.js';
3
+ import { createAgentManagementTools } from './agent-management-tools.js';
2
4
  const SERVICE_NAME = 'feature-factory';
5
+ const PLUGIN_INIT_TIMEOUT_MS = 10000; // 10 seconds max for plugin initialization
6
+ /**
7
+ * Wrap an async operation with a timeout to prevent hangs
8
+ */
9
+ async function withTimeout(operation, timeoutMs, _context) {
10
+ const timeoutPromise = new Promise((_, reject) => {
11
+ setTimeout(() => reject(new Error(`timeout`)), timeoutMs);
12
+ });
13
+ try {
14
+ return await Promise.race([operation(), timeoutPromise]);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
3
20
  /**
4
21
  * Log a message using the OpenCode client's structured logging.
5
22
  * Silently fails if logging is unavailable.
@@ -36,31 +53,69 @@ function resolveRootDir(input) {
36
53
  *
37
54
  * Behavior:
38
55
  * - Runs quality gate on session.idle (when agent finishes working)
39
- * - Executes management/ci.sh directly (no LLM involvement)
56
+ * - Executes .feature-factory/ci.sh directly (no LLM involvement)
40
57
  * - On fast feedback: "Quality gate is running, please stand-by for results ..."
41
58
  * - On success: "Quality gate passed"
42
59
  * - On failure: passes full CI output to LLM for fix instructions
43
- * - If management/ci.sh does not exist, quality gate does not run
60
+ * - If .feature-factory/ci.sh does not exist, quality gate does not run
61
+ *
62
+ * Feature Factory Context System:
63
+ * - Creates .feature-factory/ directory structure on startup
64
+ * - Migrates management/ci.sh to .feature-factory/ci.sh if present
65
+ * - Provides tools for agent context tracking (ff-agents-current, ff-agents-clear, ff-agents-show)
44
66
  */
45
67
  export const StopQualityGatePlugin = async (input) => {
46
- const { worktree, directory, client } = input;
68
+ const { worktree, directory, client, $ } = input;
47
69
  const rootDir = resolveRootDir({ worktree, directory });
48
- // Skip quality gate if no valid directory (e.g., global config with no project)
70
+ // Skip if no valid directory (e.g., global config with no project)
49
71
  if (!rootDir || rootDir === '' || rootDir === '/') {
50
72
  return {};
51
73
  }
52
- // Create quality gate hooks
74
+ // Initialize Feature Factory directory structure (with timeout protection)
75
+ try {
76
+ const initResult = await withTimeout(async () => await initializeFeatureFactory(input, $), PLUGIN_INIT_TIMEOUT_MS, 'feature-factory-init');
77
+ if (initResult === null) {
78
+ await log(client, 'warn', 'feature-factory-init-timeout', {
79
+ timeoutMs: PLUGIN_INIT_TIMEOUT_MS,
80
+ });
81
+ }
82
+ }
83
+ catch (error) {
84
+ await log(client, 'error', 'feature-factory-init-error', {
85
+ error: String(error),
86
+ });
87
+ }
88
+ // Create quality gate hooks (with timeout protection)
53
89
  let qualityGateHooks = {};
54
90
  try {
55
- qualityGateHooks = await createQualityGateHooks(input);
91
+ const hooksResult = await withTimeout(async () => await createQualityGateHooks(input), PLUGIN_INIT_TIMEOUT_MS, 'quality-gate-init');
92
+ if (hooksResult !== null) {
93
+ qualityGateHooks = hooksResult;
94
+ }
95
+ else {
96
+ await log(client, 'warn', 'quality-gate-init-timeout', {
97
+ timeoutMs: PLUGIN_INIT_TIMEOUT_MS,
98
+ });
99
+ }
56
100
  }
57
101
  catch (error) {
58
102
  await log(client, 'error', 'quality-gate.init-error', {
59
103
  error: String(error),
60
104
  });
61
105
  }
106
+ // Create agent management tools (synchronous, minimal timeout for safety)
107
+ let agentManagementHooks = {};
108
+ try {
109
+ agentManagementHooks = createAgentManagementTools(input);
110
+ }
111
+ catch (error) {
112
+ await log(client, 'error', 'agent-management.init-error', {
113
+ error: String(error),
114
+ });
115
+ }
62
116
  return {
63
117
  ...qualityGateHooks,
118
+ ...agentManagementHooks,
64
119
  };
65
120
  };
66
121
  // Default export for OpenCode plugin discovery
@@ -5,7 +5,7 @@
5
5
  * - extractErrorLines: extracts error-like lines from command output
6
6
  * - tailLines: returns last N lines with truncation notice
7
7
  */
8
- import { extractErrorLines, tailLines } from './output.js';
8
+ import { extractErrorLines, tailLines } from './output';
9
9
  describe('extractErrorLines', () => {
10
10
  it('should return empty array for empty input', () => {
11
11
  expect(extractErrorLines('', 10)).toEqual([]);
@@ -6,7 +6,7 @@
6
6
  * - hasConfiguredCommands: checks if explicit commands are configured
7
7
  * - DEFAULT_QUALITY_GATE: verify default values
8
8
  */
9
- import { mergeQualityGateConfig, hasConfiguredCommands, DEFAULT_QUALITY_GATE, } from './quality-gate-config.js';
9
+ import { mergeQualityGateConfig, hasConfiguredCommands, DEFAULT_QUALITY_GATE, } from './quality-gate-config';
10
10
  describe('DEFAULT_QUALITY_GATE', () => {
11
11
  it('should have correct default values', () => {
12
12
  expect(DEFAULT_QUALITY_GATE.steps).toEqual(['lint', 'build', 'test']);
@@ -154,12 +154,30 @@ async function log(client, level, message, extra) {
154
154
  return undefined;
155
155
  }
156
156
  }
157
+ /**
158
+ * Execute a shell command with a timeout to prevent hangs
159
+ */
160
+ async function executeWithTimeout(operation, timeoutMs = 5000, _operationName = 'shell command') {
161
+ const timeoutPromise = new Promise((_, reject) => {
162
+ setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
163
+ });
164
+ try {
165
+ return await Promise.race([operation(), timeoutPromise]);
166
+ }
167
+ catch {
168
+ // Return null on timeout or error
169
+ return null;
170
+ }
171
+ }
157
172
  export async function createQualityGateHooks(input) {
158
173
  const { client, $, directory } = input;
159
174
  async function ciShExists() {
160
175
  try {
161
- await $ `test -f ${directory}/management/ci.sh`.quiet();
162
- return true;
176
+ const result = await executeWithTimeout(async () => {
177
+ await $ `test -f ${directory}/.feature-factory/ci.sh`.quiet();
178
+ return true;
179
+ }, 2000, 'test -f ci.sh');
180
+ return result === true;
163
181
  }
164
182
  catch {
165
183
  return false;
@@ -213,7 +231,7 @@ export async function createQualityGateHooks(input) {
213
231
  let ciOutput = '';
214
232
  let ciPassed = false;
215
233
  let timedOut = false;
216
- const ciPath = `${directory}/management/ci.sh`;
234
+ const ciPath = `${directory}/.feature-factory/ci.sh`;
217
235
  // eslint-disable-next-line no-undef
218
236
  const proc = Bun.spawn(['bash', ciPath], {
219
237
  cwd: directory,
@@ -274,7 +292,7 @@ export async function createQualityGateHooks(input) {
274
292
 
275
293
  **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.
276
294
 
277
- If the failure details are missing or truncated, run "management/ci.sh" to get the full output.`;
295
+ If the failure details are missing or truncated, run ".feature-factory/ci.sh" to get the full output.`;
278
296
  const message = timedOut
279
297
  ? `⏱️ 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}`
280
298
  : `❌ 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}`;
@@ -5,7 +5,7 @@
5
5
  * - sanitizeOutput: redacts secrets from CI output
6
6
  * - isSessionReadOnly: determines if session has write permissions
7
7
  */
8
- import { sanitizeOutput, truncateOutput } from './stop-quality-gate.js';
8
+ import { sanitizeOutput, truncateOutput } from './stop-quality-gate';
9
9
  function isSessionReadOnly(permission) {
10
10
  if (!permission)
11
11
  return false;
package/dist/uuid.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * UUID v4 generator
3
+ * Generates random UUIDs for agent instance tracking
4
+ */
5
+ /**
6
+ * Generate a random UUID v4
7
+ * Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
8
+ */
9
+ export declare function generateUUID(): string;
10
+ /**
11
+ * Validate if a string is a valid UUID v4
12
+ */
13
+ export declare function isValidUUID(str: string): boolean;
package/dist/uuid.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * UUID v4 generator
3
+ * Generates random UUIDs for agent instance tracking
4
+ */
5
+ /**
6
+ * Generate a random UUID v4
7
+ * Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
8
+ */
9
+ export function generateUUID() {
10
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
11
+ const r = (Math.random() * 16) | 0;
12
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
13
+ return v.toString(16);
14
+ });
15
+ }
16
+ /**
17
+ * Validate if a string is a valid UUID v4
18
+ */
19
+ export function isValidUUID(str) {
20
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
21
+ return uuidRegex.test(str);
22
+ }
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.2.13",
4
+ "version": "0.2.15",
5
5
  "type": "module",
6
6
  "description": "OpenCode plugin for Feature Factory agents - provides sub-agents and skills for validation, review, security, and architecture assessment",
7
7
  "license": "MIT",