@syntesseraai/opencode-feature-factory 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,11 +61,11 @@ These colors are intentionally unique to avoid collisions in OpenCode agent UIs
61
61
 
62
62
  The plugin exposes three MCP tools via the `feature-factory` agent:
63
63
 
64
- | Tool | Description |
65
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
66
- | `ff_pipeline` | Full multi-model pipeline: planning → build → review → documentation. Uses hardcoded per-role model defaults (see Model Routing below). |
67
- | `ff_mini_loop` | Lightweight build → review → documentation loop. **Does not hardcode model defaults** — all roles inherit the current session model when omitted. |
68
- | `ff_list_models` | Read-only discovery tool. Queries the OpenCode SDK to list all available providers, models, capability badges, connected status, and defaults. |
64
+ | Tool | Description |
65
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | `ff_pipeline` | Full multi-model pipeline: planning → build → CI → review → documentation. Uses hardcoded per-role model defaults (see Model Routing below). |
67
+ | `ff_mini_loop` | Lightweight build → CI → review → documentation loop. **Does not hardcode model defaults** — all roles inherit the current session model when omitted. CI validation runs `ff-ci.sh` if present (up to 3 attempts with auto-fix). |
68
+ | `ff_list_models` | Read-only discovery tool. Queries the OpenCode SDK to list all available providers, models, capability badges, connected status, and defaults. |
69
69
 
70
70
  ### Mini-Loop Model Inheritance
71
71
 
@@ -118,36 +118,37 @@ Both `ff_pipeline` and `ff_mini_loop` tools run asynchronously with real-time pr
118
118
  - **Immediate return**: Tools return instantly with a brief acknowledgment (e.g. `Pipeline started for: <summary>`), so the LLM can continue the conversation.
119
119
  - **Background orchestration**: The full pipeline or mini-loop runs in a detached `Promise`. All child session orchestration (fan-out, gates, loops) remains identical — it just executes after the tool returns.
120
120
  - **Progress updates via `promptAsync(noReply: true)`**: After each major phase completes, a structured notification is injected into the parent session as a visible chat message. These appear in the OpenCode TUI without triggering an LLM turn.
121
- - **Phase-by-phase visibility**: Users see updates for planning, building, each review iteration gate decision, each documentation iteration, and the final completion report.
122
- - **Error notifications**: If the background orchestration throws, a `<ff_pipeline_error>` or `<ff_mini_loop_error>` notification is sent with the last phase and error message.
121
+ - **Phase-by-phase visibility**: Users see updates for planning, building, CI validation (when `ff-ci.sh` present), each review iteration gate decision, each documentation iteration, and the final completion report.
122
+ - **Error notifications**: If the background orchestration throws, a `# Pipeline: Error` or `# Mini-Loop: Error` notification is sent with the last phase and error message.
123
123
  - **`context.metadata()` retained**: All existing metadata calls remain in place for future-proofing (when OpenCode's TUI renders tool metadata natively).
124
124
 
125
125
  ### Notification Format
126
126
 
127
- Pipeline updates use XML-style tags for structured parsing:
127
+ Pipeline notifications use plain-text markdown headers with phase START/END bracketing and per-iteration gate details:
128
128
 
129
129
  ```
130
- <ff_pipeline_update>
131
- Phase: Planning
130
+ # Pipeline: Reviewing — Iteration 2/10
131
+
132
132
  Status: APPROVED
133
- Duration: 45.2s
134
- Next Phase: Building
135
- </ff_pipeline_update>
133
+ Confidence: 97%
134
+ Unresolved Issues: 0
135
+ Duration: 45.3s
136
+ Feedback: N/A
136
137
  ```
137
138
 
138
- Mini-loop updates follow the same pattern:
139
+ Mini-loop notifications follow the same pattern:
139
140
 
140
141
  ```
141
- <ff_mini_loop_update>
142
- Phase: Implementation
143
- Status: APPROVED
144
- Confidence: 97%
145
- Iteration: 2/10
146
- Duration: 32.1s
147
- </ff_mini_loop_update>
142
+ # Mini-Loop: Building — Iteration 1/10
143
+
144
+ Status: REWORK
145
+ Confidence: 82%
146
+ Unresolved Issues: 2
147
+ Duration: 23.1s
148
+ Feedback: Fix type errors in handler.ts
148
149
  ```
149
150
 
150
- Final reports are wrapped in `<ff_pipeline_complete>` or `<ff_mini_loop_complete>` tags containing the full markdown report.
151
+ Final reports use `# Pipeline: Complete` or `# Mini-Loop: Complete` headers containing the full markdown report. Errors use `# Pipeline: Error` or `# Mini-Loop: Error`.
151
152
 
152
153
  ## Related Docs
153
154
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Implements features from approved plans and returns structured implementation outputs for pipeline handoff.
3
- mode: subagent
3
+ mode: primary
4
4
  color: '#22c55e'
5
5
  tools:
6
6
  read: true
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Documentation implementation specialist for pipeline documentation stage.
3
- mode: subagent
3
+ mode: primary
4
4
  color: '#f97316'
5
5
  tools:
6
6
  read: true
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Creates implementation plans and planning gates for pipeline and ad-hoc work. Uses result-based handoff instead of file artifacts.
3
- mode: subagent
3
+ mode: primary
4
4
  color: '#3b82f6'
5
5
  tools:
6
6
  read: true
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Unified validation agent for code and documentation. Performs acceptance, quality, security, and architecture review with context-driven scope.
3
- mode: subagent
3
+ mode: primary
4
4
  color: '#8b5cf6'
5
5
  tools:
6
6
  read: true
@@ -10,8 +10,9 @@
10
10
  */
11
11
  import { tool } from '@opencode-ai/plugin/tool';
12
12
  import { promptSession, notifyParent, evaluateMiniLoopImplGate, evaluateMiniLoopDocGate, parseModelString, } from '../workflow/orchestrator.js';
13
- import { miniBuildPrompt, miniReviewPrompt, documentPrompt, docReviewPrompt } from './prompts.js';
13
+ import { miniBuildPrompt, miniReviewPrompt, documentPrompt, docReviewPrompt, ciFixPrompt, } from './prompts.js';
14
14
  import { parseMiniReview, parseDocReview } from './parsers.js';
15
+ import { ciScriptExists, runCI } from '../workflow/ci-runner.js';
15
16
  // ---------------------------------------------------------------------------
16
17
  // Tool factory
17
18
  // ---------------------------------------------------------------------------
@@ -70,7 +71,7 @@ export function createMiniLoopTool(client) {
70
71
  report.push(`## ${phase}\n${msg}`);
71
72
  };
72
73
  // Notify helper bound to this session
73
- const notify = (message) => notifyParent(client, sessionId, agent, message);
74
+ const notify = (message, options) => notifyParent(client, sessionId, agent, message, options);
74
75
  // ===================================================================
75
76
  // PHASE 1: IMPLEMENTATION LOOP (build → review → gate, up to 10)
76
77
  // ===================================================================
@@ -79,6 +80,8 @@ export function createMiniLoopTool(client) {
79
80
  const implementationIterationDetails = [];
80
81
  let implGate = { decision: 'REWORK', feedback: requirements };
81
82
  let lastImplRaw = '';
83
+ // Phase start notification
84
+ await notify(`# Mini-Loop: Building started\n\nStarting implementation phase...\n`);
82
85
  for (let implIter = 0; implIter < 10 && implGate.decision === 'REWORK'; implIter++) {
83
86
  const iteration = implIter + 1;
84
87
  const buildTitle = `ff-mini-build-${iteration}`;
@@ -99,6 +102,73 @@ export function createMiniLoopTool(client) {
99
102
  const buildStartMs = Date.now();
100
103
  lastImplRaw = await promptSession(client, sessionId, miniBuildPrompt(buildInput, implIter > 0 ? implGate.feedback : undefined), { model: buildModel, agent: 'building', title: buildTitle });
101
104
  const buildEndMs = Date.now();
105
+ // ---------------------------------------------------------------
106
+ // CI validation (deterministic subprocess — not an LLM prompt)
107
+ // ---------------------------------------------------------------
108
+ const ciDir = process.cwd();
109
+ const hasCiScript = await ciScriptExists(ciDir);
110
+ if (!hasCiScript) {
111
+ await notify(`# Mini-Loop: CI script (ff-ci.sh) not found, skipping CI validation\n`);
112
+ }
113
+ else {
114
+ const maxCiAttempts = 3;
115
+ for (let ciAttempt = 1; ciAttempt <= maxCiAttempts; ciAttempt++) {
116
+ context.metadata({
117
+ title: `⏳ Running CI (attempt ${ciAttempt}/${maxCiAttempts})...`,
118
+ metadata: {
119
+ phase: 'implementation',
120
+ step: 'ci',
121
+ iteration,
122
+ ciAttempt,
123
+ maxCiAttempts,
124
+ },
125
+ });
126
+ await notify(`# Mini-Loop: CI validation — attempt ${ciAttempt}/${maxCiAttempts}\n`);
127
+ const ciResult = await runCI(ciDir);
128
+ if (ciResult.passed) {
129
+ await notify(`# Mini-Loop: CI passed (attempt ${ciAttempt}/${maxCiAttempts})\n`);
130
+ break;
131
+ }
132
+ // CI failed
133
+ await notify(`# Mini-Loop: CI failed (attempt ${ciAttempt}/${maxCiAttempts})\n\n\`\`\`\n${ciResult.output}\n\`\`\`\n`);
134
+ if (ciAttempt < maxCiAttempts) {
135
+ // Ask build model to fix the CI failures
136
+ context.metadata({
137
+ title: `⏳ CI rework (attempt ${ciAttempt}/${maxCiAttempts})...`,
138
+ metadata: {
139
+ phase: 'implementation',
140
+ step: 'ci',
141
+ iteration,
142
+ ciAttempt,
143
+ maxCiAttempts,
144
+ action: 'rework',
145
+ },
146
+ });
147
+ lastImplRaw = await promptSession(client, sessionId, ciFixPrompt(requirements, ciResult.output), {
148
+ model: buildModel,
149
+ agent: 'building',
150
+ title: `ff-mini-ci-fix-${iteration}-${ciAttempt}`,
151
+ });
152
+ }
153
+ else {
154
+ // Terminal CI failure — exhausted all attempts
155
+ addReport('CI Validation', `CI failed after ${maxCiAttempts} attempts.\n\n**Last output**:\n\`\`\`\n${ciResult.output}\n\`\`\`\n`);
156
+ context.metadata({
157
+ title: '❌ CI validation failed (attempts exhausted)',
158
+ metadata: {
159
+ phase: 'implementation',
160
+ step: 'ci',
161
+ iteration,
162
+ ciAttempt,
163
+ maxCiAttempts,
164
+ outcome: 'failed',
165
+ },
166
+ });
167
+ await notify(`# Mini-Loop: CI validation failed\n\nCI checks failed after ${maxCiAttempts} attempts. Aborting.\n`, { noReply: false });
168
+ return;
169
+ }
170
+ }
171
+ }
102
172
  context.metadata({
103
173
  title: `⏳ Reviewing (iteration ${iteration}/10)...`,
104
174
  metadata: {
@@ -165,10 +235,12 @@ export function createMiniLoopTool(client) {
165
235
  `- **Gate**: ${implGate.decision} (confidence: ${review.confidence}, change requested: ${review.changeRequested ? 'yes' : 'no'}, unresolved: ${review.unresolvedIssues})\n` +
166
236
  `- **Feedback**: ${feedback}`);
167
237
  // Notify each implementation iteration gate decision
168
- await notify(`<ff_mini_loop_update>\nPhase: Implementation\nStatus: ${implGate.decision}\nConfidence: ${review.confidence}%\nIteration: ${iteration}/10\nDuration: ${formatElapsed(implementationStartMs, Date.now())}\n</ff_mini_loop_update>`);
238
+ await notify(`# Mini-Loop: Building — Iteration ${iteration}/10\n\nStatus: ${implGate.decision}\nConfidence: ${review.confidence}%\nUnresolved Issues: ${review.unresolvedIssues}\nDuration: ${formatElapsed(implementationStartMs, Date.now())}\nFeedback: ${feedback}\n`);
169
239
  if (implGate.decision === 'ESCALATE') {
170
240
  const implementationEndMs = Date.now();
171
241
  addReport('Implementation', `${implementationIterationDetails.join('\n\n')}\n\n**Outcome**: ESCALATE: ${implGate.reason}\n**Phase time**: ${formatElapsed(implementationStartMs, implementationEndMs)}`);
242
+ // Phase end notification
243
+ await notify(`# Mini-Loop: Building ended\n\nOutcome: ESCALATE\nReason: ${implGate.reason}\nIterations: ${iteration}\nPhase time: ${formatElapsed(implementationStartMs, implementationEndMs)}\n`);
172
244
  context.metadata({
173
245
  title: '⚠️ Mini-loop finished with issues',
174
246
  metadata: {
@@ -178,7 +250,7 @@ export function createMiniLoopTool(client) {
178
250
  },
179
251
  });
180
252
  addReport('Complete', `Mini-loop finished with issues.\n**Total time**: ${formatElapsed(totalStartMs, Date.now())}`);
181
- await notify(`<ff_mini_loop_complete>\n${report.join('\n\n')}\n</ff_mini_loop_complete>`);
253
+ await notify(`# Mini-Loop: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
182
254
  return;
183
255
  }
184
256
  // REWORK continues the loop
@@ -187,6 +259,8 @@ export function createMiniLoopTool(client) {
187
259
  addReport('Implementation', `${implementationIterationDetails.join('\n\n')}\n\n**Outcome**: ${implGate.decision === 'APPROVED'
188
260
  ? 'APPROVED'
189
261
  : `REWORK exhausted (10 iterations). Last feedback: ${implGate.feedback}`}\n**Phase time**: ${formatElapsed(implementationStartMs, implementationEndMs)}`);
262
+ // Phase end notification
263
+ await notify(`# Mini-Loop: Building ended\n\nOutcome: ${implGate.decision}\nIterations: ${implGate.decision === 'APPROVED' ? 'converged' : '10 (exhausted)'}\nPhase time: ${formatElapsed(implementationStartMs, implementationEndMs)}\n`);
190
264
  if (implGate.decision !== 'APPROVED') {
191
265
  context.metadata({
192
266
  title: '⚠️ Mini-loop finished with issues',
@@ -197,7 +271,7 @@ export function createMiniLoopTool(client) {
197
271
  },
198
272
  });
199
273
  addReport('Complete', `Mini-loop finished with issues.\n**Total time**: ${formatElapsed(totalStartMs, Date.now())}`);
200
- await notify(`<ff_mini_loop_complete>\n${report.join('\n\n')}\n</ff_mini_loop_complete>`);
274
+ await notify(`# Mini-Loop: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
201
275
  return;
202
276
  }
203
277
  // ===================================================================
@@ -208,6 +282,8 @@ export function createMiniLoopTool(client) {
208
282
  const documentationIterationDetails = [];
209
283
  let docInput = lastImplRaw;
210
284
  let docGate = { decision: 'REWORK' };
285
+ // Phase start notification
286
+ await notify(`# Mini-Loop: Documentation started\n\nStarting documentation phase...\n`);
211
287
  for (let docIter = 0; docIter < 5 && docGate.decision === 'REWORK'; docIter++) {
212
288
  const iteration = docIter + 1;
213
289
  const writeTitle = `ff-mini-doc-write-${iteration}`;
@@ -295,7 +371,7 @@ export function createMiniLoopTool(client) {
295
371
  `- **Gate**: ${docGate.decision} (confidence: ${docReview.confidence}, unresolved: ${docReview.unresolvedIssues})\n` +
296
372
  `- **Feedback**: ${feedback}`);
297
373
  // Notify each documentation iteration gate decision
298
- await notify(`<ff_mini_loop_update>\nPhase: Documentation\nStatus: ${docGate.decision}\nConfidence: ${docReview.confidence}%\nIteration: ${iteration}/5\nDuration: ${formatElapsed(documentationStartMs, Date.now())}\n</ff_mini_loop_update>`);
374
+ await notify(`# Mini-Loop: Documentation — Iteration ${iteration}/5\n\nStatus: ${docGate.decision}\nConfidence: ${docReview.confidence}%\nUnresolved Issues: ${docReview.unresolvedIssues}\nDuration: ${formatElapsed(documentationStartMs, Date.now())}\nFeedback: ${feedback}\n`);
299
375
  if (docGate.decision === 'REWORK') {
300
376
  docInput = `${docInput}\n\nDocumentation review feedback:\n${docGate.feedback}`;
301
377
  }
@@ -306,6 +382,8 @@ export function createMiniLoopTool(client) {
306
382
  : docGate.decision === 'ESCALATE'
307
383
  ? `ESCALATE: ${docGate.reason}`
308
384
  : `REWORK exhausted (5 iterations). Last feedback: ${docGate.feedback}`}\n**Phase time**: ${formatElapsed(documentationStartMs, documentationEndMs)}`);
385
+ // Phase end notification
386
+ await notify(`# Mini-Loop: Documentation ended\n\nOutcome: ${docGate.decision}\nConfidence: ${docGate.decision === 'APPROVED' ? 'converged' : 'N/A'}\nPhase time: ${formatElapsed(documentationStartMs, documentationEndMs)}\n`);
309
387
  const totalEndMs = Date.now();
310
388
  const completedWithoutIssues = docGate.decision === 'APPROVED';
311
389
  context.metadata({
@@ -320,12 +398,12 @@ export function createMiniLoopTool(client) {
320
398
  });
321
399
  addReport('Complete', `${completedWithoutIssues ? 'Mini-loop finished successfully.' : 'Mini-loop finished with issues.'}\n**Total time**: ${formatElapsed(totalStartMs, totalEndMs)}`);
322
400
  // Send final completion report as notification
323
- await notify(`<ff_mini_loop_complete>\n${report.join('\n\n')}\n</ff_mini_loop_complete>`);
401
+ await notify(`# Mini-Loop: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
324
402
  }; // end asyncOrchestration
325
403
  // Launch orchestration in background — fire-and-forget
326
404
  void asyncOrchestration().catch(async (err) => {
327
405
  const message = err instanceof Error ? err.message : String(err);
328
- await notifyParent(client, sessionId, agent, `<ff_mini_loop_error>\nPhase: ${lastPhase}\nError: ${message}\nDuration: N/A\n</ff_mini_loop_error>`);
406
+ await notifyParent(client, sessionId, agent, `# Mini-Loop: Error\n\nPhase: ${lastPhase}\nError: ${message}\n`, { noReply: false });
329
407
  });
330
408
  // Return immediately with acknowledgment
331
409
  const summary = requirements.length > 120 ? requirements.slice(0, 120) + '...' : requirements;
@@ -12,7 +12,8 @@
12
12
  */
13
13
  import { tool } from '@opencode-ai/plugin/tool';
14
14
  import { fanOut, promptSession, notifyParent, evaluatePlanningGate, evaluateReviewGate, evaluateDocGate, PLANNING_MODELS, REVIEW_MODELS, ORCHESTRATOR_MODEL, BUILD_MODEL, DOC_MODEL, VALIDATE_MODEL, DOC_REVIEW_MODEL, parseModelString, parseNamedModels, } from '../workflow/orchestrator.js';
15
- import { planningPrompt, synthesisPrompt, breakdownPrompt, validateBatchPrompt, implementBatchPrompt, triagePrompt, reviewPrompt, reviewSynthesisPrompt, documentPrompt, docReviewPrompt, } from './prompts.js';
15
+ import { planningPrompt, synthesisPrompt, breakdownPrompt, validateBatchPrompt, implementBatchPrompt, triagePrompt, reviewPrompt, reviewSynthesisPrompt, documentPrompt, docReviewPrompt, ciFixPrompt, } from './prompts.js';
16
+ import { ciScriptExists, runCI } from '../workflow/ci-runner.js';
16
17
  import { parseConsensusPlan, parseReviewSynthesis, parseImplementationReport, parseDocReview, } from './parsers.js';
17
18
  // ---------------------------------------------------------------------------
18
19
  // Tool factory — needs the SDK client from plugin init
@@ -99,7 +100,7 @@ export function createPipelineTool(client) {
99
100
  report.push(`## ${phase}\n${msg}`);
100
101
  };
101
102
  // Notify helper bound to this session
102
- const notify = (message) => notifyParent(client, sessionId, agent, message);
103
+ const notify = (message, options) => notifyParent(client, sessionId, agent, message, options);
103
104
  // ===================================================================
104
105
  // PHASE 1: PLANNING (fan-out → synthesize → gate, loop up to 5)
105
106
  // ===================================================================
@@ -108,6 +109,8 @@ export function createPipelineTool(client) {
108
109
  const planningIterationDetails = [];
109
110
  let planningGate = { decision: 'REWORK', feedback: requirements };
110
111
  let finalPlan = '';
112
+ // Phase start notification
113
+ await notify(`# Pipeline: Planning started\n\nStarting planning phase with ${planModels.length} models...\n`);
111
114
  for (let planIter = 0; planIter < 5 && planningGate.decision === 'REWORK'; planIter++) {
112
115
  const iteration = planIter + 1;
113
116
  const synthesisTitle = `ff-plan-synthesis-${iteration}`;
@@ -197,9 +200,13 @@ export function createPipelineTool(client) {
197
200
  `- **Synthesis**: ${synthesisTitle} (${formatElapsed(synthesisStartMs, synthesisEndMs)})\n` +
198
201
  `- **Gate**: ${planningGate.decision} (score: ${consensus.consensusScore})\n` +
199
202
  `- **Feedback**: ${feedback}`);
203
+ // Notify each planning iteration gate decision
204
+ await notify(`# Pipeline: Planning — Iteration ${iteration}/5\n\nStatus: ${planningGate.decision}\nConsensus Score: ${consensus.consensusScore}%\nDuration: ${formatElapsed(planningStartMs, Date.now())}\nFeedback: ${feedback}\n`);
200
205
  if (planningGate.decision === 'BLOCKED') {
201
206
  const planningEndMs = Date.now();
202
207
  addReport('Planning', `${planningIterationDetails.join('\n\n')}\n\n**Outcome**: BLOCKED: ${planningGate.reason}\n**Phase time**: ${formatElapsed(planningStartMs, planningEndMs)}`);
208
+ // Phase end notification
209
+ await notify(`# Pipeline: Planning ended\n\nOutcome: BLOCKED\nReason: ${planningGate.reason}\nConsensus Score: ${consensus.consensusScore}%\nIterations: ${iteration}/5\nPhase time: ${formatElapsed(planningStartMs, planningEndMs)}\n`);
203
210
  context.metadata({
204
211
  title: '⚠️ Pipeline finished with issues',
205
212
  metadata: {
@@ -209,8 +216,7 @@ export function createPipelineTool(client) {
209
216
  },
210
217
  });
211
218
  addReport('Complete', `Pipeline finished with issues.\n**Total time**: ${formatElapsed(totalStartMs, Date.now())}`);
212
- await notify(`<ff_pipeline_update>\nPhase: Planning\nStatus: BLOCKED\nConsensus Score: ${consensus.consensusScore}%\nIterations: ${iteration}/5\nDuration: ${formatElapsed(planningStartMs, planningEndMs)}\n</ff_pipeline_update>`);
213
- await notify(`<ff_pipeline_complete>\n${report.join('\n\n')}\n</ff_pipeline_complete>`);
219
+ await notify(`# Pipeline: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
214
220
  return;
215
221
  }
216
222
  // REWORK continues the loop
@@ -219,8 +225,8 @@ export function createPipelineTool(client) {
219
225
  addReport('Planning', `${planningIterationDetails.join('\n\n')}\n\n**Outcome**: ${planningGate.decision === 'APPROVED'
220
226
  ? 'APPROVED'
221
227
  : `REWORK exhausted (5 iterations). Last feedback: ${planningGate.feedback}`}\n**Phase time**: ${formatElapsed(planningStartMs, planningEndMs)}`);
222
- // Notify planning phase complete
223
- await notify(`<ff_pipeline_update>\nPhase: Planning\nStatus: ${planningGate.decision}\nDuration: ${formatElapsed(planningStartMs, planningEndMs)}\nNext Phase: ${planningGate.decision === 'APPROVED' ? 'Building' : 'Finished'}\n</ff_pipeline_update>`);
228
+ // Phase end notification
229
+ await notify(`# Pipeline: Planning ended\n\nOutcome: ${planningGate.decision}\nDuration: ${formatElapsed(planningStartMs, planningEndMs)}\nNext Phase: ${planningGate.decision === 'APPROVED' ? 'Building' : 'Finished'}\n`);
224
230
  if (planningGate.decision !== 'APPROVED') {
225
231
  context.metadata({
226
232
  title: '⚠️ Pipeline finished with issues',
@@ -231,7 +237,7 @@ export function createPipelineTool(client) {
231
237
  },
232
238
  });
233
239
  addReport('Complete', `Pipeline finished with issues.\n**Total time**: ${formatElapsed(totalStartMs, Date.now())}`);
234
- await notify(`<ff_pipeline_complete>\n${report.join('\n\n')}\n</ff_pipeline_complete>`);
240
+ await notify(`# Pipeline: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
235
241
  return;
236
242
  }
237
243
  // ===================================================================
@@ -239,6 +245,8 @@ export function createPipelineTool(client) {
239
245
  // ===================================================================
240
246
  lastPhase = 'Building';
241
247
  const buildingStartMs = Date.now();
248
+ // Phase start notification
249
+ await notify(`# Pipeline: Building started\n\nStarting build phase (breakdown → validate → implement)...\n`);
242
250
  context.metadata({
243
251
  title: '⏳ Breaking down tasks...',
244
252
  metadata: {
@@ -288,6 +296,71 @@ export function createPipelineTool(client) {
288
296
  });
289
297
  const implementEndMs = Date.now();
290
298
  const implementation = parseImplementationReport(implRaw);
299
+ // ---------------------------------------------------------------
300
+ // CI validation (deterministic subprocess — not an LLM prompt)
301
+ // ---------------------------------------------------------------
302
+ const ciDir = process.cwd();
303
+ const hasCiScript = await ciScriptExists(ciDir);
304
+ if (!hasCiScript) {
305
+ await notify(`# Pipeline: CI script (ff-ci.sh) not found, skipping CI validation\n`);
306
+ }
307
+ else {
308
+ const maxCiAttempts = 3;
309
+ for (let ciAttempt = 1; ciAttempt <= maxCiAttempts; ciAttempt++) {
310
+ context.metadata({
311
+ title: `⏳ Running CI (attempt ${ciAttempt}/${maxCiAttempts})...`,
312
+ metadata: {
313
+ phase: 'building',
314
+ step: 'ci',
315
+ ciAttempt,
316
+ maxCiAttempts,
317
+ },
318
+ });
319
+ await notify(`# Pipeline: CI validation — attempt ${ciAttempt}/${maxCiAttempts}\n`);
320
+ const ciResult = await runCI(ciDir);
321
+ if (ciResult.passed) {
322
+ await notify(`# Pipeline: CI passed (attempt ${ciAttempt}/${maxCiAttempts})\n`);
323
+ break;
324
+ }
325
+ // CI failed
326
+ await notify(`# Pipeline: CI failed (attempt ${ciAttempt}/${maxCiAttempts})\n\n\`\`\`\n${ciResult.output}\n\`\`\`\n`);
327
+ if (ciAttempt < maxCiAttempts) {
328
+ // Ask build model to fix the CI failures
329
+ context.metadata({
330
+ title: `⏳ CI rework (attempt ${ciAttempt}/${maxCiAttempts})...`,
331
+ metadata: {
332
+ phase: 'building',
333
+ step: 'ci',
334
+ ciAttempt,
335
+ maxCiAttempts,
336
+ action: 'rework',
337
+ },
338
+ });
339
+ await promptSession(client, sessionId, ciFixPrompt(requirements, ciResult.output), {
340
+ model: buildModel,
341
+ agent: 'building',
342
+ title: `ff-pipeline-ci-fix-${ciAttempt}`,
343
+ });
344
+ }
345
+ else {
346
+ // Terminal CI failure — exhausted all attempts
347
+ addReport('CI Validation', `CI failed after ${maxCiAttempts} attempts.\n\n**Last output**:\n\`\`\`\n${ciResult.output}\n\`\`\`\n`);
348
+ context.metadata({
349
+ title: '❌ CI validation failed (attempts exhausted)',
350
+ metadata: {
351
+ phase: 'building',
352
+ step: 'ci',
353
+ ciAttempt,
354
+ maxCiAttempts,
355
+ outcome: 'failed',
356
+ },
357
+ });
358
+ addReport('Complete', `Pipeline finished with issues.\n**Total time**: ${formatElapsed(totalStartMs, Date.now())}`);
359
+ await notify(`# Pipeline: CI validation failed\n\nCI checks failed after ${maxCiAttempts} attempts. Aborting.\n`, { noReply: false });
360
+ return;
361
+ }
362
+ }
363
+ }
291
364
  const buildingEndMs = Date.now();
292
365
  addReport('Building', `### Execution\n` +
293
366
  `- **Breakdown**: ${breakdownTitle} (${formatElapsed(breakdownStartMs, breakdownEndMs)})\n` +
@@ -296,8 +369,8 @@ export function createPipelineTool(client) {
296
369
  `- **Tests passed**: ${implementation.testsPassed}\n` +
297
370
  `- **Open issues**: ${implementation.openIssues.length > 0 ? implementation.openIssues.join('; ') : 'none'}\n\n` +
298
371
  `**Phase time**: ${formatElapsed(buildingStartMs, buildingEndMs)}`);
299
- // Notify building phase complete
300
- await notify(`<ff_pipeline_update>\nPhase: Building\nStatus: COMPLETE\nFiles Changed: ${implementation.filesChanged.length}\nTests Passed: ${implementation.testsPassed}\nDuration: ${formatElapsed(buildingStartMs, buildingEndMs)}\nNext Phase: Reviewing\n</ff_pipeline_update>`);
372
+ // Phase end notification
373
+ await notify(`# Pipeline: Building ended\n\nOutcome: COMPLETE\nFiles Changed: ${implementation.filesChanged.length}\nTests Passed: ${implementation.testsPassed}\nOpen Issues: ${implementation.openIssues.length > 0 ? implementation.openIssues.join('; ') : 'none'}\nPhase time: ${formatElapsed(buildingStartMs, buildingEndMs)}\n`);
301
374
  // ===================================================================
302
375
  // PHASE 3: REVIEWING (triage → fan-out review → synthesize → gate, loop up to 10)
303
376
  // ===================================================================
@@ -306,6 +379,8 @@ export function createPipelineTool(client) {
306
379
  const reviewIterationDetails = [];
307
380
  let reviewInput = implRaw;
308
381
  let reviewGate = { decision: 'REWORK' };
382
+ // Phase start notification
383
+ await notify(`# Pipeline: Reviewing started\n\nStarting review phase with ${revModels.length} models...\n`);
309
384
  for (let revIter = 0; revIter < 10 && reviewGate.decision === 'REWORK'; revIter++) {
310
385
  const iteration = revIter + 1;
311
386
  const triageTitle = `ff-review-triage-${iteration}`;
@@ -435,10 +510,12 @@ export function createPipelineTool(client) {
435
510
  `${reworkLine}\n` +
436
511
  `- **Feedback**: ${feedback}`);
437
512
  // Notify each review iteration gate decision
438
- await notify(`<ff_pipeline_update>\nPhase: Reviewing\nStatus: ${reviewGate.decision}\nConfidence: ${synthesis.overallConfidence}%\nUnresolved Issues: ${synthesis.unresolvedIssues}\nIteration: ${iteration}/10\nDuration: ${formatElapsed(reviewStartMs, Date.now())}\n</ff_pipeline_update>`);
513
+ await notify(`# Pipeline: Reviewing — Iteration ${iteration}/10\n\nStatus: ${reviewGate.decision}\nConfidence: ${synthesis.overallConfidence}%\nUnresolved Issues: ${synthesis.unresolvedIssues}\nDuration: ${formatElapsed(reviewStartMs, Date.now())}\nFeedback: ${feedback}\n`);
439
514
  if (reviewGate.decision === 'ESCALATE') {
440
515
  const reviewEndMs = Date.now();
441
516
  addReport('Reviewing', `${reviewIterationDetails.join('\n\n')}\n\n**Outcome**: ESCALATE: ${reviewGate.reason}\n**Phase time**: ${formatElapsed(reviewStartMs, reviewEndMs)}`);
517
+ // Phase end notification
518
+ await notify(`# Pipeline: Reviewing ended\n\nOutcome: ESCALATE\nReason: ${reviewGate.reason}\nIterations: ${iteration}\nPhase time: ${formatElapsed(reviewStartMs, reviewEndMs)}\n`);
442
519
  context.metadata({
443
520
  title: '⚠️ Pipeline finished with issues',
444
521
  metadata: {
@@ -448,7 +525,7 @@ export function createPipelineTool(client) {
448
525
  },
449
526
  });
450
527
  addReport('Complete', `Pipeline finished with issues.\n**Total time**: ${formatElapsed(totalStartMs, Date.now())}`);
451
- await notify(`<ff_pipeline_complete>\n${report.join('\n\n')}\n</ff_pipeline_complete>`);
528
+ await notify(`# Pipeline: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
452
529
  return;
453
530
  }
454
531
  }
@@ -456,6 +533,8 @@ export function createPipelineTool(client) {
456
533
  addReport('Reviewing', `${reviewIterationDetails.join('\n\n')}\n\n**Outcome**: ${reviewGate.decision === 'APPROVED'
457
534
  ? 'APPROVED'
458
535
  : `REWORK exhausted (10 iterations). Last feedback: ${reviewGate.feedback}`}\n**Phase time**: ${formatElapsed(reviewStartMs, reviewEndMs)}`);
536
+ // Phase end notification
537
+ await notify(`# Pipeline: Reviewing ended\n\nOutcome: ${reviewGate.decision}\nDuration: ${formatElapsed(reviewStartMs, reviewEndMs)}\n`);
459
538
  if (reviewGate.decision !== 'APPROVED') {
460
539
  context.metadata({
461
540
  title: '⚠️ Pipeline finished with issues',
@@ -466,7 +545,7 @@ export function createPipelineTool(client) {
466
545
  },
467
546
  });
468
547
  addReport('Complete', `Pipeline finished with issues.\n**Total time**: ${formatElapsed(totalStartMs, Date.now())}`);
469
- await notify(`<ff_pipeline_complete>\n${report.join('\n\n')}\n</ff_pipeline_complete>`);
548
+ await notify(`# Pipeline: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
470
549
  return;
471
550
  }
472
551
  // ===================================================================
@@ -477,6 +556,8 @@ export function createPipelineTool(client) {
477
556
  const documentationIterationDetails = [];
478
557
  let docInput = `Implementation report:\n${implRaw}\n\nReview synthesis:\n${reviewInput}`;
479
558
  let docGate = { decision: 'REWORK' };
559
+ // Phase start notification
560
+ await notify(`# Pipeline: Documentation started\n\nStarting documentation phase...\n`);
480
561
  for (let docIter = 0; docIter < 5 && docGate.decision === 'REWORK'; docIter++) {
481
562
  const iteration = docIter + 1;
482
563
  const writeTitle = `ff-doc-write-${iteration}`;
@@ -567,7 +648,7 @@ export function createPipelineTool(client) {
567
648
  `- **Gate**: ${docGate.decision} (confidence: ${docReview.confidence}, unresolved: ${docReview.unresolvedIssues})\n` +
568
649
  `- **Feedback**: ${feedback}`);
569
650
  // Notify each documentation iteration gate decision
570
- await notify(`<ff_pipeline_update>\nPhase: Documentation\nStatus: ${docGate.decision}\nConfidence: ${docReview.confidence}%\nIteration: ${iteration}/5\nDuration: ${formatElapsed(documentationStartMs, Date.now())}\n</ff_pipeline_update>`);
651
+ await notify(`# Pipeline: Documentation — Iteration ${iteration}/5\n\nStatus: ${docGate.decision}\nConfidence: ${docReview.confidence}%\nUnresolved Issues: ${docReview.unresolvedIssues}\nDuration: ${formatElapsed(documentationStartMs, Date.now())}\nFeedback: ${feedback}\n`);
571
652
  if (docGate.decision === 'REWORK') {
572
653
  // Feed feedback into the next iteration
573
654
  docInput = `${docInput}\n\nDocumentation review feedback:\n${docGate.feedback}`;
@@ -579,6 +660,8 @@ export function createPipelineTool(client) {
579
660
  : docGate.decision === 'ESCALATE'
580
661
  ? `ESCALATE: ${docGate.reason}`
581
662
  : `REWORK exhausted (5 iterations). Last feedback: ${docGate.feedback}`}\n**Phase time**: ${formatElapsed(documentationStartMs, documentationEndMs)}`);
663
+ // Phase end notification
664
+ await notify(`# Pipeline: Documentation ended\n\nOutcome: ${docGate.decision}\nPhase time: ${formatElapsed(documentationStartMs, documentationEndMs)}\n`);
582
665
  // ===================================================================
583
666
  // FINAL REPORT
584
667
  // ===================================================================
@@ -596,12 +679,12 @@ export function createPipelineTool(client) {
596
679
  });
597
680
  addReport('Complete', `${completedWithoutIssues ? 'Pipeline finished successfully.' : 'Pipeline finished with issues.'}\n**Total time**: ${formatElapsed(totalStartMs, totalEndMs)}`);
598
681
  // Send final completion report as notification
599
- await notify(`<ff_pipeline_complete>\n${report.join('\n\n')}\n</ff_pipeline_complete>`);
682
+ await notify(`# Pipeline: Complete\n\n${report.join('\n\n')}\n`, { noReply: false });
600
683
  }; // end asyncOrchestration
601
684
  // Launch orchestration in background — fire-and-forget
602
685
  void asyncOrchestration().catch(async (err) => {
603
686
  const message = err instanceof Error ? err.message : String(err);
604
- await notifyParent(client, sessionId, agent, `<ff_pipeline_error>\nPhase: ${lastPhase}\nError: ${message}\nDuration: N/A\n</ff_pipeline_error>`);
687
+ await notifyParent(client, sessionId, agent, `# Pipeline: Error\n\nPhase: ${lastPhase}\nError: ${message}\n`, { noReply: false });
605
688
  });
606
689
  // Return immediately with acknowledgment
607
690
  const summary = requirements.length > 120 ? requirements.slice(0, 120) + '...' : requirements;
@@ -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
+ }
@@ -125,8 +125,12 @@ export declare function promptSession(client: Client, parentSessionId: string, p
125
125
  /**
126
126
  * Send a visible notification message to the parent session via `promptAsync`.
127
127
  *
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.
128
+ * By default uses `noReply: true` so the message appears in the TUI as a chat
129
+ * message without triggering an LLM turn. Pass `noReply: false` for terminal
130
+ * messages (completion reports, errors) so the LLM can validate the outcome.
131
+ *
132
+ * Errors are swallowed — notification delivery must never break the pipeline.
131
133
  */
132
- export declare function notifyParent(client: Client, sessionId: string, agent: string | undefined, message: string): Promise<void>;
134
+ export declare function notifyParent(client: Client, sessionId: string, agent: string | undefined, message: string, options?: {
135
+ noReply?: boolean;
136
+ }): Promise<void>;
@@ -94,14 +94,16 @@ export async function promptSession(client, parentSessionId, prompt, options) {
94
94
  /**
95
95
  * Send a visible notification message to the parent session via `promptAsync`.
96
96
  *
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.
97
+ * By default uses `noReply: true` so the message appears in the TUI as a chat
98
+ * message without triggering an LLM turn. Pass `noReply: false` for terminal
99
+ * messages (completion reports, errors) so the LLM can validate the outcome.
100
+ *
101
+ * Errors are swallowed — notification delivery must never break the pipeline.
100
102
  */
101
- export async function notifyParent(client, sessionId, agent, message) {
103
+ export async function notifyParent(client, sessionId, agent, message, options) {
102
104
  try {
103
105
  const body = {
104
- noReply: true,
106
+ noReply: options?.noReply ?? true,
105
107
  parts: [{ type: 'text', text: message }],
106
108
  };
107
109
  if (agent) {
@@ -6,4 +6,5 @@
6
6
  */
7
7
  export { fanOut, promptSession, extractText, notifyParent, type Client } 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';
@@ -6,4 +6,5 @@
6
6
  */
7
7
  export { fanOut, promptSession, extractText, notifyParent } 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';
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.0",
4
+ "version": "0.10.2",
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",