@syntesseraai/opencode-feature-factory 0.10.1 → 0.10.3

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.
@@ -21,4 +21,5 @@ export declare function reviewSynthesisPrompt(reviews: Array<{
21
21
  export declare function documentPrompt(input: string): string;
22
22
  export declare function docReviewPrompt(docUpdate: string): string;
23
23
  export declare function miniBuildPrompt(requirements: string, reworkFeedback?: string): string;
24
+ export declare function ciFixPrompt(requirements: string, ciOutput: string): string;
24
25
  export declare function miniReviewPrompt(implementationReport: string): string;
@@ -169,6 +169,22 @@ Requirements:
169
169
  3. Run lint/typecheck/tests only for impacted scope.
170
170
  4. Return a concise implementation report with changed files, tests run, and known open issues.`;
171
171
  }
172
+ export function ciFixPrompt(requirements, ciOutput) {
173
+ return `The CI checks (ff-ci.sh) failed after your implementation. Fix the issues identified below.
174
+
175
+ Original requirements:
176
+ ${requirements}
177
+
178
+ CI failure output:
179
+ \`\`\`
180
+ ${ciOutput}
181
+ \`\`\`
182
+
183
+ Requirements:
184
+ 1. Fix all failing checks (lint, typecheck, build, tests).
185
+ 2. Do not remove or skip tests — fix the underlying issues.
186
+ 3. Return a concise implementation report with changed files and what was fixed.`;
187
+ }
172
188
  export function miniReviewPrompt(implementationReport) {
173
189
  return `Review the latest mini-loop implementation output below.
174
190
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * CI runner utility for workflow tools.
3
+ *
4
+ * Executes `ff-ci.sh` in a subprocess and returns a structured result.
5
+ * Reuses sanitizeOutput / truncateOutput from stop-quality-gate.ts for
6
+ * consistent secret redaction and output trimming.
7
+ */
8
+ export interface CIResult {
9
+ passed: boolean;
10
+ output: string;
11
+ timedOut: boolean;
12
+ }
13
+ /**
14
+ * Check whether `ff-ci.sh` exists at the given directory root.
15
+ * Never throws — returns `false` on any error.
16
+ */
17
+ export declare function ciScriptExists(directory: string): Promise<boolean>;
18
+ /**
19
+ * Execute `ff-ci.sh` from the given directory and return a structured result.
20
+ * Never throws — returns a failure result on any error.
21
+ */
22
+ export declare function runCI(directory: string): Promise<CIResult>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * CI runner utility for workflow tools.
3
+ *
4
+ * Executes `ff-ci.sh` in a subprocess and returns a structured result.
5
+ * Reuses sanitizeOutput / truncateOutput from stop-quality-gate.ts for
6
+ * consistent secret redaction and output trimming.
7
+ */
8
+ import { sanitizeOutput, truncateOutput } from '../stop-quality-gate.js';
9
+ const CI_TIMEOUT_MS = 300_000; // 5 minutes
10
+ /**
11
+ * Check whether `ff-ci.sh` exists at the given directory root.
12
+ * Never throws — returns `false` on any error.
13
+ */
14
+ export async function ciScriptExists(directory) {
15
+ try {
16
+ const ciPath = `${directory}/ff-ci.sh`;
17
+ // eslint-disable-next-line no-undef
18
+ const proc = Bun.spawn(['test', '-f', ciPath], {
19
+ cwd: directory,
20
+ stdout: 'pipe',
21
+ stderr: 'pipe',
22
+ });
23
+ await proc.exited;
24
+ return proc.exitCode === 0;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Execute `ff-ci.sh` from the given directory and return a structured result.
32
+ * Never throws — returns a failure result on any error.
33
+ */
34
+ export async function runCI(directory) {
35
+ try {
36
+ const ciPath = `${directory}/ff-ci.sh`;
37
+ // eslint-disable-next-line no-undef
38
+ const proc = Bun.spawn(['bash', ciPath], {
39
+ cwd: directory,
40
+ stdout: 'pipe',
41
+ stderr: 'pipe',
42
+ });
43
+ let timedOut = false;
44
+ let timeoutId = null;
45
+ let forceKillTimeoutId = null;
46
+ const timeoutPromise = new Promise((resolve) => {
47
+ timeoutId = setTimeout(() => {
48
+ timedOut = true;
49
+ proc.kill('SIGTERM');
50
+ forceKillTimeoutId = setTimeout(() => {
51
+ try {
52
+ proc.kill('SIGKILL');
53
+ }
54
+ catch {
55
+ // Process already terminated
56
+ }
57
+ }, 5000);
58
+ resolve();
59
+ }, CI_TIMEOUT_MS);
60
+ });
61
+ await Promise.race([proc.exited, timeoutPromise]);
62
+ if (timeoutId)
63
+ clearTimeout(timeoutId);
64
+ if (forceKillTimeoutId)
65
+ clearTimeout(forceKillTimeoutId);
66
+ const stdout = await new Response(proc.stdout).text();
67
+ const stderr = await new Response(proc.stderr).text();
68
+ let output;
69
+ let passed;
70
+ if (timedOut) {
71
+ output =
72
+ `CI execution timed out after ${CI_TIMEOUT_MS / 1000} seconds\n\n${stdout}\n${stderr}`.trim();
73
+ passed = false;
74
+ }
75
+ else {
76
+ output = stdout + (stderr ? `\n${stderr}` : '');
77
+ passed = proc.exitCode === 0;
78
+ }
79
+ // Sanitize secrets and truncate for prompt safety
80
+ output = truncateOutput(sanitizeOutput(output));
81
+ return { passed, output, timedOut };
82
+ }
83
+ catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ return {
86
+ passed: false,
87
+ output: `CI runner error: ${message}`,
88
+ timedOut: false,
89
+ };
90
+ }
91
+ }
@@ -18,6 +18,9 @@ import type { NamedModel, ModelId } from './types.js';
18
18
  export type Client = {
19
19
  session: {
20
20
  create(options: {
21
+ query?: {
22
+ directory?: string;
23
+ };
21
24
  body?: {
22
25
  parentID?: string;
23
26
  title?: string;
@@ -28,6 +31,9 @@ export type Client = {
28
31
  };
29
32
  }>;
30
33
  prompt(options: {
34
+ query?: {
35
+ directory?: string;
36
+ };
31
37
  path: {
32
38
  id: string;
33
39
  };
@@ -53,6 +59,9 @@ export type Client = {
53
59
  };
54
60
  }>;
55
61
  promptAsync(options: {
62
+ query?: {
63
+ directory?: string;
64
+ };
56
65
  path: {
57
66
  id: string;
58
67
  };
@@ -100,6 +109,14 @@ export declare function extractText(parts: Array<{
100
109
  text?: string;
101
110
  [k: string]: unknown;
102
111
  }>): string;
112
+ export interface SessionContext {
113
+ sessionId: string;
114
+ directory?: string;
115
+ }
116
+ export declare function createRunParentSession(client: Client, callerSessionId: string, options?: {
117
+ title?: string;
118
+ directory?: string;
119
+ }): Promise<SessionContext>;
103
120
  export interface FanOutResult {
104
121
  tag: string;
105
122
  raw: string;
@@ -111,13 +128,13 @@ export interface FanOutResult {
111
128
  * Each model's work runs in its own child session so none of the
112
129
  * intermediate outputs pollute the parent context window.
113
130
  */
114
- export declare function fanOut(client: Client, parentSessionId: string, models: readonly NamedModel[], buildPrompt: (tag: string) => string, agent?: string): Promise<FanOutResult[]>;
131
+ export declare function fanOut(client: Client, context: SessionContext, models: readonly NamedModel[], buildPrompt: (tag: string) => string, agent?: string): Promise<FanOutResult[]>;
115
132
  /**
116
133
  * Prompt in an isolated child session and return the raw text response.
117
134
  *
118
135
  * This keeps the sub-step off the parent's context window.
119
136
  */
120
- export declare function promptSession(client: Client, parentSessionId: string, prompt: string, options?: {
137
+ export declare function promptSession(client: Client, context: SessionContext, prompt: string, options?: {
121
138
  model?: ModelId;
122
139
  agent?: string;
123
140
  title?: string;
@@ -125,8 +142,12 @@ export declare function promptSession(client: Client, parentSessionId: string, p
125
142
  /**
126
143
  * Send a visible notification message to the parent session via `promptAsync`.
127
144
  *
128
- * Uses `noReply: true` so the message appears in the TUI as a chat message
129
- * without triggering an LLM turn. Errors are swallowed notification
130
- * delivery must never break the pipeline.
145
+ * By default uses `noReply: true` so the message appears in the TUI as a chat
146
+ * message without triggering an LLM turn. Pass `noReply: false` for terminal
147
+ * messages (completion reports, errors) so the LLM can validate the outcome.
148
+ *
149
+ * Errors are swallowed — notification delivery must never break the pipeline.
131
150
  */
132
- export declare function notifyParent(client: Client, sessionId: string, agent: string | undefined, message: string): Promise<void>;
151
+ export declare function notifyParent(client: Client, context: SessionContext, agent: string | undefined, message: string, options?: {
152
+ noReply?: boolean;
153
+ }): Promise<void>;
@@ -19,6 +19,29 @@ export function extractText(parts) {
19
19
  .map((p) => p.text)
20
20
  .join('\n');
21
21
  }
22
+ function withDirectory(directory) {
23
+ if (!directory) {
24
+ return undefined;
25
+ }
26
+ return { directory };
27
+ }
28
+ export async function createRunParentSession(client, callerSessionId, options) {
29
+ const session = await client.session.create({
30
+ query: withDirectory(options?.directory),
31
+ body: {
32
+ parentID: callerSessionId,
33
+ title: options?.title,
34
+ },
35
+ });
36
+ const runParentId = session.data?.id;
37
+ if (!runParentId) {
38
+ throw new Error('Failed to create run parent session');
39
+ }
40
+ return {
41
+ sessionId: runParentId,
42
+ directory: options?.directory,
43
+ };
44
+ }
22
45
  // ---------------------------------------------------------------------------
23
46
  // Isolated session helper
24
47
  // ---------------------------------------------------------------------------
@@ -28,11 +51,12 @@ export function extractText(parts) {
28
51
  * The child session is parented to `parentSessionId` so it appears in the
29
52
  * session tree but its messages do **not** pollute the parent context window.
30
53
  */
31
- async function promptInChildSession(client, parentSessionId, prompt, options) {
54
+ async function promptInChildSession(client, context, prompt, options) {
32
55
  // 1. Create an isolated child session
33
56
  const session = await client.session.create({
57
+ query: withDirectory(context.directory),
34
58
  body: {
35
- parentID: parentSessionId,
59
+ parentID: context.sessionId,
36
60
  title: options?.title,
37
61
  },
38
62
  });
@@ -53,6 +77,7 @@ async function promptInChildSession(client, parentSessionId, prompt, options) {
53
77
  body.agent = options.agent;
54
78
  }
55
79
  const response = await client.session.prompt({
80
+ query: withDirectory(context.directory),
56
81
  path: { id: childId },
57
82
  body,
58
83
  });
@@ -66,9 +91,9 @@ async function promptInChildSession(client, parentSessionId, prompt, options) {
66
91
  * Each model's work runs in its own child session so none of the
67
92
  * intermediate outputs pollute the parent context window.
68
93
  */
69
- export async function fanOut(client, parentSessionId, models, buildPrompt, agent) {
94
+ export async function fanOut(client, context, models, buildPrompt, agent) {
70
95
  const results = await Promise.all(models.map(async (nm) => {
71
- const raw = await promptInChildSession(client, parentSessionId, buildPrompt(nm.tag), {
96
+ const raw = await promptInChildSession(client, context, buildPrompt(nm.tag), {
72
97
  model: nm.model,
73
98
  agent,
74
99
  title: `ff-fanout-${nm.tag}`,
@@ -85,8 +110,8 @@ export async function fanOut(client, parentSessionId, models, buildPrompt, agent
85
110
  *
86
111
  * This keeps the sub-step off the parent's context window.
87
112
  */
88
- export async function promptSession(client, parentSessionId, prompt, options) {
89
- return promptInChildSession(client, parentSessionId, prompt, options);
113
+ export async function promptSession(client, context, prompt, options) {
114
+ return promptInChildSession(client, context, prompt, options);
90
115
  }
91
116
  // ---------------------------------------------------------------------------
92
117
  // Parent session notification helper
@@ -94,21 +119,24 @@ export async function promptSession(client, parentSessionId, prompt, options) {
94
119
  /**
95
120
  * Send a visible notification message to the parent session via `promptAsync`.
96
121
  *
97
- * Uses `noReply: true` so the message appears in the TUI as a chat message
98
- * without triggering an LLM turn. Errors are swallowed notification
99
- * delivery must never break the pipeline.
122
+ * By default uses `noReply: true` so the message appears in the TUI as a chat
123
+ * message without triggering an LLM turn. Pass `noReply: false` for terminal
124
+ * messages (completion reports, errors) so the LLM can validate the outcome.
125
+ *
126
+ * Errors are swallowed — notification delivery must never break the pipeline.
100
127
  */
101
- export async function notifyParent(client, sessionId, agent, message) {
128
+ export async function notifyParent(client, context, agent, message, options) {
102
129
  try {
103
130
  const body = {
104
- noReply: true,
131
+ noReply: options?.noReply ?? true,
105
132
  parts: [{ type: 'text', text: message }],
106
133
  };
107
134
  if (agent) {
108
135
  body.agent = agent;
109
136
  }
110
137
  await client.session.promptAsync({
111
- path: { id: sessionId },
138
+ query: withDirectory(context.directory),
139
+ path: { id: context.sessionId },
112
140
  body,
113
141
  });
114
142
  }
@@ -4,6 +4,7 @@
4
4
  * Re-exports the fan-out, gate, and type modules as a single convenient
5
5
  * entry-point for tool implementations.
6
6
  */
7
- export { fanOut, promptSession, extractText, notifyParent, type Client } from './fan-out.js';
7
+ export { fanOut, promptSession, extractText, notifyParent, createRunParentSession, type Client, type SessionContext, } from './fan-out.js';
8
8
  export { evaluatePlanningGate, evaluateReviewGate, evaluateDocGate, evaluateMiniLoopImplGate, evaluateMiniLoopDocGate, } from './gate-evaluator.js';
9
+ export { runCI, ciScriptExists } from './ci-runner.js';
9
10
  export * from './types.js';
@@ -4,6 +4,7 @@
4
4
  * Re-exports the fan-out, gate, and type modules as a single convenient
5
5
  * entry-point for tool implementations.
6
6
  */
7
- export { fanOut, promptSession, extractText, notifyParent } from './fan-out.js';
7
+ export { fanOut, promptSession, extractText, notifyParent, createRunParentSession, } from './fan-out.js';
8
8
  export { evaluatePlanningGate, evaluateReviewGate, evaluateDocGate, evaluateMiniLoopImplGate, evaluateMiniLoopDocGate, } from './gate-evaluator.js';
9
+ export { runCI, ciScriptExists } from './ci-runner.js';
9
10
  export * from './types.js';
@@ -0,0 +1,13 @@
1
+ export interface WorktreeOptions {
2
+ enabled?: boolean;
3
+ parentDirectory?: string;
4
+ }
5
+ export interface RunDirectoryContext {
6
+ runId: string;
7
+ runDirectory: string;
8
+ worktreeEnabled: boolean;
9
+ worktreePath?: string;
10
+ repoRoot?: string;
11
+ }
12
+ export declare function resolveRunDirectory(toolTag: 'pipeline' | 'mini-loop', worktree: WorktreeOptions | undefined, cwd?: string): Promise<RunDirectoryContext>;
13
+ export declare function cleanupWorktree(ctx: RunDirectoryContext): Promise<void>;
@@ -0,0 +1,77 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, isAbsolute, join, resolve } from 'node:path';
4
+ const UNSAFE_WORKTREE_PARENT_DIRS = new Set(['/', '/etc']);
5
+ function assertSafeParentDirectory(parentDirectory) {
6
+ const normalized = resolve(parentDirectory);
7
+ if (UNSAFE_WORKTREE_PARENT_DIRS.has(normalized)) {
8
+ throw new Error(`Unsafe worktree_parent_dir: ${parentDirectory}. Choose a dedicated writable path.`);
9
+ }
10
+ }
11
+ function resolveParentDirectory(cwd, parentDirectory) {
12
+ // Trust model: worktree_parent_dir is caller-controlled and can point anywhere
13
+ // on the local filesystem. We allow flexible absolute/relative paths but reject
14
+ // obviously unsafe system locations where accidental cleanup would be dangerous.
15
+ if (!parentDirectory || parentDirectory.trim().length === 0) {
16
+ return join(cwd, '.feature-factory', 'worktrees');
17
+ }
18
+ const resolvedParent = isAbsolute(parentDirectory)
19
+ ? parentDirectory
20
+ : resolve(cwd, parentDirectory);
21
+ assertSafeParentDirectory(resolvedParent);
22
+ return resolvedParent;
23
+ }
24
+ async function runGit(args, cwd) {
25
+ // eslint-disable-next-line no-undef
26
+ const proc = Bun.spawn(['git', ...args], {
27
+ cwd,
28
+ stdout: 'pipe',
29
+ stderr: 'pipe',
30
+ });
31
+ const [stdout, stderr] = await Promise.all([
32
+ new Response(proc.stdout).text(),
33
+ new Response(proc.stderr).text(),
34
+ proc.exited,
35
+ ]);
36
+ if (proc.exitCode !== 0) {
37
+ const detail = stderr.trim() || stdout.trim() || `git ${args.join(' ')} failed`;
38
+ throw new Error(detail);
39
+ }
40
+ return stdout.trim();
41
+ }
42
+ export async function resolveRunDirectory(toolTag, worktree, cwd = process.cwd()) {
43
+ const runId = `${toolTag}-${Date.now()}-${randomUUID().slice(0, 8)}`;
44
+ if (!worktree?.enabled) {
45
+ return {
46
+ runId,
47
+ runDirectory: cwd,
48
+ worktreeEnabled: false,
49
+ };
50
+ }
51
+ const repoRoot = await runGit(['rev-parse', '--show-toplevel'], cwd);
52
+ const head = await runGit(['rev-parse', '--verify', 'HEAD'], repoRoot);
53
+ const parentDirectory = resolveParentDirectory(repoRoot, worktree.parentDirectory);
54
+ await mkdir(parentDirectory, { recursive: true });
55
+ const repoName = basename(repoRoot);
56
+ const worktreePath = join(parentDirectory, `${repoName}-${runId}`);
57
+ await runGit(['worktree', 'add', '--detach', worktreePath, head], repoRoot);
58
+ return {
59
+ runId,
60
+ runDirectory: worktreePath,
61
+ worktreeEnabled: true,
62
+ worktreePath,
63
+ repoRoot,
64
+ };
65
+ }
66
+ export async function cleanupWorktree(ctx) {
67
+ if (!ctx.worktreeEnabled || !ctx.worktreePath) {
68
+ return;
69
+ }
70
+ const gitCwd = ctx.repoRoot ?? ctx.runDirectory;
71
+ try {
72
+ await runGit(['worktree', 'remove', '--force', ctx.worktreePath], gitCwd);
73
+ }
74
+ catch (error) {
75
+ console.warn('[feature-factory] Failed to cleanup run worktree:', error);
76
+ }
77
+ }
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.10.1",
4
+ "version": "0.10.3",
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",