@bridge_gpt/mcp-server 0.1.16 → 0.2.0

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.
Files changed (50) hide show
  1. package/README.md +333 -162
  2. package/build/agent-capabilities/cli.js +152 -0
  3. package/build/agent-capabilities/default-deps.js +45 -0
  4. package/build/agent-capabilities/probe-context.js +111 -0
  5. package/build/agent-capabilities/probes.js +278 -0
  6. package/build/agent-capabilities/reporter.js +50 -0
  7. package/build/agent-capabilities/runner.js +56 -0
  8. package/build/agent-capabilities/types.js +10 -0
  9. package/build/agent-launchers/claude.js +85 -0
  10. package/build/agent-launchers/index.js +17 -0
  11. package/build/agent-launchers/types.js +1 -0
  12. package/build/agents.generated.js +1 -1
  13. package/build/brainstorm-files.js +89 -0
  14. package/build/bridge-config.js +404 -0
  15. package/build/chain-orchestrator.js +1364 -0
  16. package/build/chain-utils.js +68 -0
  17. package/build/commands.generated.js +5 -3
  18. package/build/credential-materialization.js +128 -0
  19. package/build/credential-store.js +232 -0
  20. package/build/decision-page-schema.js +39 -6
  21. package/build/decision-page-template.js +54 -18
  22. package/build/doctor.js +18 -2
  23. package/build/fetch-stub.js +139 -0
  24. package/build/git-ignore-utils.js +63 -0
  25. package/build/index.js +1623 -546
  26. package/build/mcp-invoke.js +417 -0
  27. package/build/mcp-provisioning.js +249 -0
  28. package/build/mcp-registration-doctor.js +96 -0
  29. package/build/pipeline-orchestrator.js +66 -1
  30. package/build/pipeline-utils.js +33 -0
  31. package/build/pipelines.generated.js +165 -5
  32. package/build/schedule-run.js +951 -0
  33. package/build/schedule-store.js +132 -0
  34. package/build/scheduler-backends/at-fallback.js +144 -0
  35. package/build/scheduler-backends/escaping.js +113 -0
  36. package/build/scheduler-backends/index.js +72 -0
  37. package/build/scheduler-backends/launchd.js +216 -0
  38. package/build/scheduler-backends/systemd-user.js +237 -0
  39. package/build/scheduler-backends/task-scheduler.js +219 -0
  40. package/build/scheduler-backends/types.js +23 -0
  41. package/build/start-tickets-prereqs.js +90 -1
  42. package/build/start-tickets.js +222 -70
  43. package/build/third-party-mcp-targets.js +75 -0
  44. package/build/version.generated.js +1 -1
  45. package/package.json +8 -8
  46. package/pipelines/full-automation.json +49 -0
  47. package/pipelines/idea-to-ticket.json +71 -0
  48. package/pipelines/implement-ticket.json +28 -2
  49. package/smoke-test/SMOKE-TEST.md +511 -0
  50. package/smoke-test/smoke-test-mcp.md +23 -0
@@ -0,0 +1,1364 @@
1
+ /**
2
+ * Chain orchestrator — server-side sequential execution for the new
3
+ * ``run_full_automation`` / ``resume_full_automation`` MCP tools (BAPI-326).
4
+ *
5
+ * Design rules (mirrors pipeline-orchestrator.ts):
6
+ * - Never imports ``mcp_server/src/index.ts``. All runtime dependencies are
7
+ * injected through ``ChainOrchestratorDeps`` (which extends
8
+ * ``PipelineOrchestratorDeps`` with the chain-recipe registry).
9
+ * - Chain runs are persisted server-side via the Bridge API routes at
10
+ * ``/jira/chain-runs/runs``. Every state transition extends the idle TTL.
11
+ * - The chain drives existing child pipelines via ``runPipeline`` /
12
+ * ``resumePipeline``. Only compact per-stage metadata + heuristic
13
+ * (no-LLM) summaries are persisted in ``chain_runs.stages`` — never the
14
+ * full child pipeline ``results`` arrays.
15
+ * - Stage 3 (start-tickets) is special-cased: the orchestrator does NOT call
16
+ * ``runPipeline``; it emits an ``agent_task`` envelope naming the exact
17
+ * ``/start-tickets ...`` command for the host agent to invoke in-session.
18
+ * - Cross-stage variables (child_ticket_keys -> review fan-out ->
19
+ * reviewed_ticket_keys -> start-tickets) are resolved server-side. The
20
+ * idea-to-ticket stage emits a structured payload that keeps the Epic
21
+ * ``epic_parent_key`` separate from the implementable ``child_ticket_keys``,
22
+ * so the Epic parent is handed to NEITHER the review fan-out NOR
23
+ * start-tickets. Missing cross-stage variables produce a typed
24
+ * ``VALIDATION`` failure — never a placeholder value.
25
+ */
26
+ import { runPipeline, resumePipeline, peekPipelineRun } from "./pipeline-orchestrator.js";
27
+ import { toStringVariable, substituteChainValue } from "./chain-utils.js";
28
+ const CHAIN_NAME = "full-automation";
29
+ const START_TICKETS_PIPELINE = "start-tickets";
30
+ // Display titles for preambles, keyed by child pipeline name.
31
+ const STAGE_TITLES = {
32
+ "idea-to-ticket": "Idea to ticket",
33
+ "review-ticket": "Review ticket",
34
+ "start-tickets": "Start tickets",
35
+ };
36
+ export class ChainPersistenceError extends Error {
37
+ code;
38
+ constructor(code, message) {
39
+ super(message);
40
+ this.code = code;
41
+ }
42
+ }
43
+ export function createChainPersistenceClient(deps) {
44
+ const headers = {
45
+ "X-API-Key": deps.apiKey,
46
+ "Content-Type": "application/json",
47
+ };
48
+ const base = deps.baseUrl.replace(/\/+$/, "");
49
+ function urlFor(suffix, query) {
50
+ const u = new URL(`${base}/jira/chain-runs${suffix}`);
51
+ if (query) {
52
+ for (const [k, v] of Object.entries(query)) {
53
+ u.searchParams.set(k, v);
54
+ }
55
+ }
56
+ return u.toString();
57
+ }
58
+ async function readError(resp) {
59
+ const raw = await resp.text();
60
+ let codeFromBody = null;
61
+ let messageFromBody = null;
62
+ try {
63
+ const parsed = JSON.parse(raw);
64
+ const detail = parsed?.detail;
65
+ if (detail && typeof detail === "object") {
66
+ if (typeof detail.error_code === "string") {
67
+ codeFromBody = detail.error_code;
68
+ }
69
+ if (typeof detail.error === "string") {
70
+ messageFromBody = detail.error;
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // not JSON
76
+ }
77
+ const codeFromStatus = resp.status === 404
78
+ ? "NOT_FOUND"
79
+ : resp.status === 410
80
+ ? "EXPIRED"
81
+ : resp.status === 403
82
+ ? "REPO_MISMATCH"
83
+ : resp.status === 400 || resp.status === 422
84
+ ? "VALIDATION"
85
+ : "TOOL_ERROR";
86
+ return {
87
+ code: codeFromBody ?? codeFromStatus,
88
+ message: messageFromBody ?? (raw || `HTTP ${resp.status}`),
89
+ };
90
+ }
91
+ async function createRun(input) {
92
+ const resp = await fetch(urlFor("/runs"), {
93
+ method: "POST",
94
+ headers,
95
+ body: JSON.stringify({
96
+ repo_name: deps.repoName,
97
+ chain_name: input.chain_name,
98
+ args: input.args,
99
+ current_stage_index: input.current_stage_index,
100
+ stages: input.stages,
101
+ status: input.status,
102
+ ttl_seconds: input.ttl_seconds,
103
+ }),
104
+ });
105
+ if (!resp.ok) {
106
+ const err = await readError(resp);
107
+ throw new ChainPersistenceError(err.code, err.message);
108
+ }
109
+ return (await resp.json());
110
+ }
111
+ async function getRun(chainRunId) {
112
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`, {
113
+ repo_name: deps.repoName,
114
+ }), { headers: { "X-API-Key": deps.apiKey } });
115
+ if (!resp.ok) {
116
+ const err = await readError(resp);
117
+ throw new ChainPersistenceError(err.code, err.message);
118
+ }
119
+ return (await resp.json());
120
+ }
121
+ async function patchRun(chainRunId, body) {
122
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`), {
123
+ method: "PATCH",
124
+ headers,
125
+ body: JSON.stringify({ repo_name: deps.repoName, ...body }),
126
+ });
127
+ if (!resp.ok) {
128
+ const err = await readError(resp);
129
+ throw new ChainPersistenceError(err.code, err.message);
130
+ }
131
+ return (await resp.json());
132
+ }
133
+ async function listRuns(status) {
134
+ const query = { repo_name: deps.repoName };
135
+ if (status)
136
+ query.status = status;
137
+ const resp = await fetch(urlFor("/runs", query), {
138
+ headers: { "X-API-Key": deps.apiKey },
139
+ });
140
+ if (!resp.ok) {
141
+ const err = await readError(resp);
142
+ throw new ChainPersistenceError(err.code, err.message);
143
+ }
144
+ const body = (await resp.json());
145
+ return body.runs ?? [];
146
+ }
147
+ async function deleteRun(chainRunId) {
148
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`, {
149
+ repo_name: deps.repoName,
150
+ }), {
151
+ method: "DELETE",
152
+ headers: { "X-API-Key": deps.apiKey },
153
+ });
154
+ if (!resp.ok) {
155
+ const err = await readError(resp);
156
+ throw new ChainPersistenceError(err.code, err.message);
157
+ }
158
+ }
159
+ return { createRun, getRun, patchRun, listRuns, deleteRun };
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Pure helpers
163
+ // ---------------------------------------------------------------------------
164
+ function normalizeAutoApprove(value) {
165
+ if (value === true || value === "true")
166
+ return true;
167
+ return false;
168
+ }
169
+ const JIRA_KEY_EXACT = /^[A-Z][A-Z0-9]+-\d+$/;
170
+ /**
171
+ * Extract created Jira ticket keys from a STRUCTURED payload (an object/array
172
+ * holding `created_ticket_keys` / `ticket_key` / etc.). Collects values from the
173
+ * preferred key fields in a fixed preference order, validates each against
174
+ * `JIRA_KEY_EXACT`, and de-dupes in encounter order. There is deliberately NO
175
+ * free-text regex fallback: pass the parsed upload-step payload (via
176
+ * `extractCreatedKeysFromCompletedChild`), never a whole pipeline transcript, so
177
+ * keys merely *mentioned* by an earlier step (e.g. duplicate-detection search
178
+ * hits) can never be mistaken for this run's creations. A string / no-field
179
+ * input returns `[]`, which the caller treats as fail-closed.
180
+ *
181
+ * NOTE: This flat helper FLATTENS parent (`epic_key`) and `child_ticket_keys`
182
+ * into a single list. The full-automation chain fan-out must NOT use it — it
183
+ * would fold the Epic parent into the implementable list and hand the parent to
184
+ * review / start-tickets. The chain drives fan-out from the structured
185
+ * `extractCreatedTicketPayload` path instead; this helper remains only for
186
+ * non-chain compatibility callers that expect a flat single-ticket list.
187
+ */
188
+ export function extractCreatedTicketKeys(source) {
189
+ const preferredKeys = [
190
+ "created_ticket_keys",
191
+ "ticket_keys",
192
+ "child_ticket_keys",
193
+ "epic_key",
194
+ "ticket_key",
195
+ ];
196
+ const buckets = {};
197
+ for (const k of preferredKeys)
198
+ buckets[k] = [];
199
+ const visit = (value) => {
200
+ if (Array.isArray(value)) {
201
+ for (const item of value)
202
+ visit(item);
203
+ return;
204
+ }
205
+ if (value && typeof value === "object") {
206
+ const obj = value;
207
+ for (const key of preferredKeys) {
208
+ if (key in obj) {
209
+ const v = obj[key];
210
+ if (typeof v === "string") {
211
+ buckets[key].push(v);
212
+ }
213
+ else if (Array.isArray(v)) {
214
+ for (const elem of v) {
215
+ if (typeof elem === "string")
216
+ buckets[key].push(elem);
217
+ }
218
+ }
219
+ }
220
+ }
221
+ for (const v of Object.values(obj))
222
+ visit(v);
223
+ }
224
+ };
225
+ visit(source);
226
+ const ordered = [];
227
+ for (const key of preferredKeys) {
228
+ for (const candidate of buckets[key]) {
229
+ if (JIRA_KEY_EXACT.test(candidate))
230
+ ordered.push(candidate);
231
+ }
232
+ }
233
+ const seen = new Set();
234
+ const result = [];
235
+ for (const key of ordered) {
236
+ if (!seen.has(key)) {
237
+ seen.add(key);
238
+ result.push(key);
239
+ }
240
+ }
241
+ return result;
242
+ }
243
+ /**
244
+ * Pull a JSON object out of an agent's free-text result. Prefers the LAST fenced
245
+ * ```json … ``` block (the structured payload the upload step is told to emit as
246
+ * its final content); otherwise tries the last balanced `{ … }` slice that
247
+ * `JSON.parse`s. Returns the parsed value or `null`.
248
+ */
249
+ export function parseStructuredAgentResult(text) {
250
+ if (typeof text !== "string" || text.trim() === "")
251
+ return null;
252
+ // Prefer fenced ```json blocks; take the last one.
253
+ const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
254
+ for (let i = fences.length - 1; i >= 0; i--) {
255
+ const parsed = tryParseJson(fences[i][1]);
256
+ if (parsed !== undefined)
257
+ return parsed;
258
+ }
259
+ // Fallback: try each `{ … }` candidate. For each opening brace (last first),
260
+ // walk closing braces from the last back toward it, so a balanced object is
261
+ // found even when a stray `}` trails the valid JSON (using the loop-local
262
+ // `end`, not a single text-wide `lastIndexOf("}")`).
263
+ for (let start = text.lastIndexOf("{"); start >= 0; start = text.lastIndexOf("{", start - 1)) {
264
+ for (let end = text.lastIndexOf("}"); end > start; end = text.lastIndexOf("}", end - 1)) {
265
+ const parsed = tryParseJson(text.slice(start, end + 1));
266
+ if (parsed !== undefined)
267
+ return parsed;
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+ function tryParseJson(raw) {
273
+ try {
274
+ return JSON.parse(raw.trim());
275
+ }
276
+ catch {
277
+ return undefined;
278
+ }
279
+ }
280
+ function isValidJiraKey(value) {
281
+ return typeof value === "string" && JIRA_KEY_EXACT.test(value);
282
+ }
283
+ /**
284
+ * Normalize a PARSED, STRUCTURED upload payload into a {@link CreatedTicketPayload}.
285
+ * Accepts only a parsed JSON object — never a string, array, null, number, or
286
+ * free text — so Jira keys merely mentioned in prose can never become
287
+ * review/start-tickets targets. Two authoritative shapes are recognized:
288
+ *
289
+ * - Epic: ``{ epic_parent_key, child_ticket_keys }`` — ``epic_parent_key`` only
290
+ * (the legacy ``epic_key`` alias is rejected for a clean break); at least one
291
+ * valid child key required; the parent must not appear in the children.
292
+ * - Single-ticket: ``{ created_ticket_keys: [key] }`` — exactly one valid key,
293
+ * normalized into ``child_ticket_keys`` (with ``created_ticket_keys`` kept as
294
+ * a compatibility alias). A multi-key flat ``created_ticket_keys`` WITHOUT
295
+ * ``epic_parent_key`` is treated as malformed rather than guessing the first
296
+ * key is the parent.
297
+ *
298
+ * Returns ``{ ok: false, error }`` on any malformed shape so the caller can fail
299
+ * the chain with a clear validation message.
300
+ */
301
+ export function extractCreatedTicketPayload(source) {
302
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
303
+ return {
304
+ ok: false,
305
+ error: "Upload payload must be a structured JSON object; the chain never " +
306
+ "harvests Jira keys from free text, arrays, null, or numbers.",
307
+ };
308
+ }
309
+ const obj = source;
310
+ const hasEpicParent = "epic_parent_key" in obj;
311
+ const hasLegacyEpicKey = "epic_key" in obj;
312
+ const hasCreated = "created_ticket_keys" in obj;
313
+ // Epic path — driven exclusively by the explicit `epic_parent_key` field.
314
+ if (hasEpicParent) {
315
+ const parent = obj.epic_parent_key;
316
+ if (!isValidJiraKey(parent)) {
317
+ return {
318
+ ok: false,
319
+ error: `Epic payload "epic_parent_key" must be a valid Jira key; got ${JSON.stringify(parent)}.`,
320
+ };
321
+ }
322
+ const rawChildren = obj.child_ticket_keys;
323
+ if (!Array.isArray(rawChildren) || rawChildren.length === 0) {
324
+ return {
325
+ ok: false,
326
+ error: 'Epic payload requires "child_ticket_keys" with at least one ' +
327
+ "implementable child ticket key.",
328
+ };
329
+ }
330
+ const children = [];
331
+ for (const c of rawChildren) {
332
+ if (!isValidJiraKey(c)) {
333
+ return {
334
+ ok: false,
335
+ error: `Epic payload "child_ticket_keys" must contain only valid implementable Jira keys; got ${JSON.stringify(c)}.`,
336
+ };
337
+ }
338
+ if (c === parent) {
339
+ return {
340
+ ok: false,
341
+ error: `Epic parent ${parent} must not appear in "child_ticket_keys" (parent leakage).`,
342
+ };
343
+ }
344
+ children.push(c);
345
+ }
346
+ return { ok: true, payload: { epic_parent_key: parent, child_ticket_keys: children } };
347
+ }
348
+ // Reject the legacy `epic_key` alias explicitly: the chain accepts only
349
+ // `epic_parent_key` for the Epic parent (clean break, no alias recognition).
350
+ if (hasLegacyEpicKey) {
351
+ return {
352
+ ok: false,
353
+ error: 'Epic payload must use "epic_parent_key"; the legacy "epic_key" alias ' +
354
+ "is not accepted by the chain.",
355
+ };
356
+ }
357
+ // Single-ticket path — exactly one implementable key, normalized to children.
358
+ if (hasCreated) {
359
+ const raw = obj.created_ticket_keys;
360
+ if (!Array.isArray(raw) || raw.length !== 1 || !isValidJiraKey(raw[0])) {
361
+ return {
362
+ ok: false,
363
+ error: 'Single-ticket payload "created_ticket_keys" must contain exactly ' +
364
+ "one valid implementable Jira key. A multi-key list is malformed " +
365
+ '(the legacy "first key is the Epic parent" convention is not accepted).',
366
+ };
367
+ }
368
+ const key = raw[0];
369
+ return { ok: true, payload: { child_ticket_keys: [key], created_ticket_keys: [key] } };
370
+ }
371
+ return {
372
+ ok: false,
373
+ error: 'Upload payload has no recognized "epic_parent_key"/"child_ticket_keys" ' +
374
+ 'or single-ticket "created_ticket_keys" fields.',
375
+ };
376
+ }
377
+ /**
378
+ * Derive the structured {@link CreatedTicketPayload} created by a completed
379
+ * idea-to-ticket child run AUTHORITATIVELY from the upload step's structured
380
+ * output — never by scanning the whole transcript. The upload step
381
+ * (`upload-and-track.md`) is the LAST step of the idea-to-ticket pipeline and the
382
+ * chain never passes `skip_steps`, so it is the final entry of `results`. Its
383
+ * `result` is parsed (with `parseStructuredAgentResult` when it is a string) and
384
+ * passed to {@link extractCreatedTicketPayload}. Returns a validation error if
385
+ * the payload is absent or malformed, so dedup-search hits from earlier steps can
386
+ * never become targets.
387
+ */
388
+ export function extractCreatedTicketPayloadFromCompletedChild(childEnv) {
389
+ const results = childEnv.results;
390
+ if (!Array.isArray(results) || results.length === 0) {
391
+ return {
392
+ ok: false,
393
+ error: "idea-to-ticket child produced no results; cannot extract an upload payload.",
394
+ };
395
+ }
396
+ const uploadResult = results[results.length - 1]?.result;
397
+ const payload = typeof uploadResult === "string"
398
+ ? parseStructuredAgentResult(uploadResult)
399
+ : uploadResult;
400
+ return extractCreatedTicketPayload(payload);
401
+ }
402
+ /**
403
+ * Compatibility wrapper over {@link extractCreatedTicketPayloadFromCompletedChild}
404
+ * that returns ONLY the normalized implementable `child_ticket_keys` (never the
405
+ * Epic parent) on success, and `[]` on any structured-extraction failure — so
406
+ * legacy callers keep their flat-array contract without ever guessing
407
+ * parent/child roles.
408
+ */
409
+ export function extractCreatedKeysFromCompletedChild(childEnv) {
410
+ const extraction = extractCreatedTicketPayloadFromCompletedChild(childEnv);
411
+ if (!extraction.ok)
412
+ return [];
413
+ return extraction.payload.child_ticket_keys;
414
+ }
415
+ /**
416
+ * True when ``token`` appears in a failed child envelope's failure-text fields:
417
+ * the top-level ``error`` message and each step result's ``error`` / string
418
+ * ``result``. Deliberately does NOT scan echoed input, args, or other free-form
419
+ * fields, so a coincidental token elsewhere cannot misclassify the failure.
420
+ */
421
+ function failedEnvelopeMentions(env, token) {
422
+ if (env.status !== "failed")
423
+ return false;
424
+ if (typeof env.error === "string" && env.error.includes(token))
425
+ return true;
426
+ for (const r of env.results ?? []) {
427
+ if (typeof r.error === "string" && r.error.includes(token))
428
+ return true;
429
+ if (typeof r.result === "string" && r.result.includes(token))
430
+ return true;
431
+ }
432
+ return false;
433
+ }
434
+ /**
435
+ * Heuristic, no-LLM one-line stage summary. idea-to-ticket passes a structured
436
+ * {@link CreatedTicketPayload} so the summary distinguishes the Epic parent from
437
+ * implementable children; review / start-tickets pass a flat `string[]` of keys
438
+ * (wording preserved).
439
+ */
440
+ export function summarizeStageCompletion(pipelineName, keysOrPayload) {
441
+ if (pipelineName === "idea-to-ticket") {
442
+ if (!Array.isArray(keysOrPayload)) {
443
+ const payload = keysOrPayload;
444
+ const children = payload.child_ticket_keys.join(", ");
445
+ if (payload.epic_parent_key) {
446
+ return `Created Epic ${payload.epic_parent_key} and implementable children: ${children}`;
447
+ }
448
+ return `Created ticket: ${children}`;
449
+ }
450
+ // Backward-compatible flat-array form.
451
+ return `Created tickets: ${keysOrPayload.join(", ")}`;
452
+ }
453
+ const keys = Array.isArray(keysOrPayload)
454
+ ? keysOrPayload
455
+ : keysOrPayload.child_ticket_keys;
456
+ const joined = keys.join(", ");
457
+ if (pipelineName === "review-ticket") {
458
+ return `Reviewed tickets: ${joined}`;
459
+ }
460
+ if (pipelineName === START_TICKETS_PIPELINE) {
461
+ return `Spawned worktrees for ${keys.length} ticket(s): ${joined}`;
462
+ }
463
+ return `Completed ${pipelineName}: ${joined}`;
464
+ }
465
+ function stageTitle(pipelineName) {
466
+ return STAGE_TITLES[pipelineName] ?? pipelineName;
467
+ }
468
+ /** Build the markdown preamble for the active (or final) stage. */
469
+ export function buildPreamble(recipe, stageIndex, stages) {
470
+ const total = recipe.stages.length;
471
+ const lines = [];
472
+ if (stageIndex >= total) {
473
+ lines.push(`### Full automation complete (${total} of ${total} stages)`);
474
+ }
475
+ else {
476
+ const title = stageTitle(recipe.stages[stageIndex].pipeline_name);
477
+ lines.push(`### Stage ${stageIndex + 1} of ${total} — ${title}`);
478
+ }
479
+ const priorSummaries = [];
480
+ const upTo = Math.min(stageIndex, stages.length);
481
+ for (let i = 0; i < upTo; i++) {
482
+ const s = stages[i];
483
+ if (s && typeof s.summary === "string" && s.summary.trim() !== "") {
484
+ priorSummaries.push(`- ${s.summary}`);
485
+ }
486
+ }
487
+ if (priorSummaries.length > 0) {
488
+ lines.push("", "Completed so far:", ...priorSummaries);
489
+ }
490
+ return lines.join("\n");
491
+ }
492
+ const PRIOR_RESULT_MAX_CHARS = 2000;
493
+ /**
494
+ * Build a compact, read-only appendix surfacing the OUTPUTS of prior server-side
495
+ * `mcp_call` steps (e.g. `get_project_standards`) so the chain's driving agent
496
+ * can see context it would otherwise miss — the chain forwards only the
497
+ * agent_task `instruction`, unlike the standalone pipeline path whose envelope
498
+ * carries `results`. Skips agent_task results (the agent produced those) and
499
+ * failed steps; truncates each output. Returns "" when there is nothing to add,
500
+ * so the appendix is purely additive.
501
+ */
502
+ function buildPriorResultsAppendix(results) {
503
+ if (!Array.isArray(results))
504
+ return "";
505
+ const lines = [];
506
+ for (const r of results) {
507
+ if (r.type !== "mcp_call" || !r.ok)
508
+ continue;
509
+ const raw = typeof r.result === "string" ? r.result : stringifyResult(r.result);
510
+ if (raw.trim() === "")
511
+ continue;
512
+ const truncated = raw.length > PRIOR_RESULT_MAX_CHARS
513
+ ? `${raw.slice(0, PRIOR_RESULT_MAX_CHARS)}… [truncated]`
514
+ : raw;
515
+ lines.push(`- \`${r.tool ?? r.description}\`: ${truncated}`);
516
+ }
517
+ if (lines.length === 0)
518
+ return "";
519
+ return (`\n\n## Prior step outputs (read-only context)\n` +
520
+ `Server-side step outputs from earlier in this pipeline run that you cannot ` +
521
+ `fetch yourself:\n${lines.join("\n")}`);
522
+ }
523
+ function stringifyResult(value) {
524
+ if (value === undefined || value === null)
525
+ return "";
526
+ try {
527
+ return JSON.stringify(value);
528
+ }
529
+ catch {
530
+ return String(value);
531
+ }
532
+ }
533
+ // ---------------------------------------------------------------------------
534
+ // Envelope builders
535
+ // ---------------------------------------------------------------------------
536
+ function failedEnvelope(code, message, extras = {}) {
537
+ return {
538
+ status: "failed",
539
+ error_code: code,
540
+ error: message,
541
+ ...extras,
542
+ };
543
+ }
544
+ function buildNeedsAgentTaskEnvelope(args) {
545
+ const env = {
546
+ status: "needs_agent_task",
547
+ chain_run_id: args.chainRunId,
548
+ chain_stage: args.chainStage,
549
+ chain_step: args.chainStep,
550
+ chain_total: args.chainTotal,
551
+ preamble: args.preamble,
552
+ next_action: { kind: "agent_task", instruction: args.instruction },
553
+ };
554
+ if (args.pipelineRunId)
555
+ env.pipeline_run_id = args.pipelineRunId;
556
+ if (typeof args.pipelineStep === "number")
557
+ env.pipeline_step = args.pipelineStep;
558
+ if (typeof args.pipelineTotal === "number")
559
+ env.pipeline_total = args.pipelineTotal;
560
+ return env;
561
+ }
562
+ function buildCompletedEnvelope(recipe, row) {
563
+ const total = recipe.stages.length;
564
+ return {
565
+ status: "completed",
566
+ chain_run_id: row.chain_run_id,
567
+ chain_stage: CHAIN_NAME,
568
+ chain_step: total,
569
+ chain_total: total,
570
+ preamble: buildPreamble(recipe, total, row.stages),
571
+ next_action: { kind: "complete" },
572
+ };
573
+ }
574
+ /** Patch the chain + current stage as failed and return a VALIDATION envelope. */
575
+ async function failChainValidation(persistence, recipe, row, message) {
576
+ const idx = row.current_stage_index;
577
+ const stages = cloneStages(row.stages);
578
+ if (stages[idx])
579
+ stages[idx].status = "failed";
580
+ try {
581
+ await persistence.patchRun(row.chain_run_id, {
582
+ stages,
583
+ status: "failed",
584
+ });
585
+ }
586
+ catch {
587
+ // best-effort — the failure is what matters
588
+ }
589
+ return failedEnvelope("VALIDATION", message, {
590
+ chain_run_id: row.chain_run_id,
591
+ chain_stage: recipe.stages[idx]?.pipeline_name,
592
+ chain_step: idx + 1,
593
+ chain_total: recipe.stages.length,
594
+ });
595
+ }
596
+ function cloneStages(stages) {
597
+ return stages.map((s) => ({ ...s }));
598
+ }
599
+ /** True when ``value`` is a string whose trimmed content is non-empty. */
600
+ function isNonEmpty(value) {
601
+ return typeof value === "string" && value.trim() !== "";
602
+ }
603
+ const SLUG_STOP_WORDS = new Set([
604
+ "the", "a", "an", "of", "to", "and", "or", "for", "in", "on", "with",
605
+ ]);
606
+ /**
607
+ * Derive a kebab-case ``slug`` from an idea — the same shape the standalone
608
+ * idea-to-ticket command produces (lowercase, ~6-8 meaningful words, stop-words
609
+ * skipped, non-alphanumerics dropped, truncated to ~60 chars). Pure and
610
+ * deterministic so the chain can supply idea-to-ticket's required ``slug``
611
+ * variable without consulting the agent.
612
+ */
613
+ export function deriveSlug(idea) {
614
+ const words = idea
615
+ .toLowerCase()
616
+ .replace(/[^a-z0-9\s-]/g, " ")
617
+ .split(/[\s-]+/)
618
+ .filter((w) => w.length > 0 && !SLUG_STOP_WORDS.has(w));
619
+ let slug = words.slice(0, 8).join("-");
620
+ if (slug.length > 60) {
621
+ slug = slug.slice(0, 60).replace(/-+$/g, "");
622
+ }
623
+ return slug || "idea";
624
+ }
625
+ /**
626
+ * Derive idea-to-ticket's required ``run_id`` (the per-run artifact-directory
627
+ * id) from the already-unique ``chain_run_id``. Deterministic — no clock or RNG
628
+ * — so the value is stable across any re-derivation within a chain run.
629
+ */
630
+ export function deriveRunId(chainRunId) {
631
+ return isNonEmpty(chainRunId) ? `fa-${chainRunId}` : "fa-run";
632
+ }
633
+ /** Build the string variable map for a stage's child pipeline. */
634
+ function buildStageVariables(stage, args, extra = {}) {
635
+ const baseVars = {
636
+ idea: toStringVariable(args.idea),
637
+ auto_approve: toStringVariable(args.auto_approve),
638
+ scheduled_at: toStringVariable(args.scheduled_at),
639
+ max_children: toStringVariable(args.max_children),
640
+ allow_duplicate: toStringVariable(args.allow_duplicate),
641
+ agent: toStringVariable(args.agent),
642
+ ...extra,
643
+ };
644
+ const resolved = {};
645
+ for (const [k, template] of Object.entries(stage.variables ?? {})) {
646
+ resolved[k] = toStringVariable(substituteChainValue(template, baseVars));
647
+ }
648
+ return resolved;
649
+ }
650
+ /**
651
+ * Resolve a fan-out / cross-stage input list from prior stage outputs. This
652
+ * helper is intentionally KEY-SPECIFIC: it never searches alternate field names,
653
+ * so a recipe asking for `child_ticket_keys` will not silently fall back to
654
+ * `created_ticket_keys`.
655
+ */
656
+ function resolveCrossStageList(row, stageIndex, key) {
657
+ for (let i = stageIndex - 1; i >= 0; i--) {
658
+ const outputs = row.stages[i]?.outputs;
659
+ if (outputs && Array.isArray(outputs[key])) {
660
+ return outputs[key].filter((v) => typeof v === "string");
661
+ }
662
+ }
663
+ return null;
664
+ }
665
+ /**
666
+ * Scan stage outputs STRICTLY BEFORE ``stageIndex`` and return the first valid
667
+ * string ``epic_parent_key``. Non-string / empty values are ignored. Used as a
668
+ * second-layer guard so the Epic parent can be filtered out of the start-tickets
669
+ * handoff even if an upstream persisted stage output is malformed.
670
+ */
671
+ export function resolvePriorEpicParentKey(row, stageIndex) {
672
+ for (let i = 0; i < stageIndex; i++) {
673
+ const candidate = row.stages[i]?.outputs?.epic_parent_key;
674
+ if (typeof candidate === "string" && candidate.trim() !== "") {
675
+ return candidate;
676
+ }
677
+ }
678
+ return null;
679
+ }
680
+ /**
681
+ * Resolve the start-tickets stage's configured fan-out input and defensively
682
+ * filter out any key equal to the prior ``epic_parent_key``. Fails closed (a
683
+ * validation-oriented message) when the configured input is missing/empty, or
684
+ * when excluding the Epic parent leaves no implementable keys. This is the
685
+ * belt-and-suspenders second layer; the primary fix excludes the parent before
686
+ * review via the structured extractor.
687
+ */
688
+ export function resolveStartTicketKeys(row, stageIndex, fanOutInput) {
689
+ const configured = resolveCrossStageList(row, stageIndex, fanOutInput);
690
+ if (!configured || configured.length === 0) {
691
+ return {
692
+ ok: false,
693
+ error: `Missing cross-stage variable "${fanOutInput}" for start-tickets.`,
694
+ };
695
+ }
696
+ const epicParent = resolvePriorEpicParentKey(row, stageIndex);
697
+ const filtered = epicParent
698
+ ? configured.filter((k) => k !== epicParent)
699
+ : configured;
700
+ if (filtered.length === 0) {
701
+ return {
702
+ ok: false,
703
+ error: `No implementable tickets remain for start-tickets after excluding the ` +
704
+ `Epic parent ${epicParent}.`,
705
+ };
706
+ }
707
+ return { ok: true, keys: filtered };
708
+ }
709
+ // ---------------------------------------------------------------------------
710
+ // Child-pipeline envelope handling
711
+ // ---------------------------------------------------------------------------
712
+ /**
713
+ * Process a child pipeline envelope (from runPipeline/resumePipeline) for the
714
+ * current stage. Returns a StageOutcome:
715
+ * - pause: child needs an agent task — chain paused.
716
+ * - fail: child failed (or produced no usable cross-stage data).
717
+ * - advanced: child completed; chain row was patched to reflect progress.
718
+ *
719
+ * For single-child stages (idea-to-ticket), "advanced" finalizes the stage and
720
+ * bumps current_stage_index. For fan-out stages (review-ticket), "advanced"
721
+ * only records the completed child and increments current_child_index; the
722
+ * caller's loop decides when the whole stage is finished.
723
+ */
724
+ async function handleChildPipelineEnvelope(persistence, recipe, row, childEnv, opts) {
725
+ const idx = row.current_stage_index;
726
+ const stageRecipe = recipe.stages[idx];
727
+ const total = recipe.stages.length;
728
+ if (childEnv.status === "needs_agent_task") {
729
+ const stages = cloneStages(row.stages);
730
+ const stage = stages[idx];
731
+ stage.status = "paused";
732
+ stage.pipeline_run_id = childEnv.pipeline_run_id;
733
+ if (opts.fanOut) {
734
+ stage.current_child_index = opts.childIndex ?? stage.current_child_index ?? 0;
735
+ stage.child_runs = upsertChildRun(stage.child_runs, {
736
+ ticket_key: opts.ticketKey,
737
+ pipeline_run_id: childEnv.pipeline_run_id,
738
+ status: "paused",
739
+ });
740
+ }
741
+ let updated;
742
+ try {
743
+ updated = await persistence.patchRun(row.chain_run_id, {
744
+ stages,
745
+ status: "paused",
746
+ });
747
+ }
748
+ catch (err) {
749
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
750
+ }
751
+ const instruction = `${childEnv.instruction}` +
752
+ `${buildPriorResultsAppendix(childEnv.results)}\n\n` +
753
+ `When done, call \`resume_full_automation\` with \`chain_run_id\` ` +
754
+ `"${row.chain_run_id}" and \`agent_result\` set to the result described above.`;
755
+ return {
756
+ kind: "pause",
757
+ envelope: buildNeedsAgentTaskEnvelope({
758
+ chainRunId: updated.chain_run_id,
759
+ chainStage: stageRecipe.pipeline_name,
760
+ chainStep: idx + 1,
761
+ chainTotal: total,
762
+ preamble: buildPreamble(recipe, idx, updated.stages),
763
+ instruction,
764
+ pipelineRunId: childEnv.pipeline_run_id,
765
+ pipelineStep: childEnv.step_index,
766
+ pipelineTotal: childEnv.total_steps,
767
+ }),
768
+ };
769
+ }
770
+ if (childEnv.status === "failed") {
771
+ // Stage 0 vagueness or no-keys produced => VALIDATION. Anchor the sentinel
772
+ // check to the failure-text fields (the halt error + per-step error/result
773
+ // strings) rather than the whole stringified envelope, so an echoed input,
774
+ // agent_result, or arg that happens to contain the token can't misclassify.
775
+ if (idx === 0 && failedEnvelopeMentions(childEnv, "too_vague_to_ticket")) {
776
+ return {
777
+ kind: "fail",
778
+ envelope: await failChainValidation(persistence, recipe, row, "Idea was too vague to produce a ticket (too_vague_to_ticket)."),
779
+ };
780
+ }
781
+ const code = isChainErrorCode(childEnv.error_code)
782
+ ? childEnv.error_code
783
+ : "TOOL_ERROR";
784
+ const stages = cloneStages(row.stages);
785
+ if (stages[idx])
786
+ stages[idx].status = "failed";
787
+ try {
788
+ await persistence.patchRun(row.chain_run_id, { stages, status: "failed" });
789
+ }
790
+ catch {
791
+ // best-effort
792
+ }
793
+ return {
794
+ kind: "fail",
795
+ envelope: failedEnvelope(code, childEnv.error, {
796
+ chain_run_id: row.chain_run_id,
797
+ chain_stage: stageRecipe.pipeline_name,
798
+ chain_step: idx + 1,
799
+ chain_total: total,
800
+ }),
801
+ };
802
+ }
803
+ // childEnv.status === "completed"
804
+ if (opts.fanOut) {
805
+ const stages = cloneStages(row.stages);
806
+ const stage = stages[idx];
807
+ const childIndex = opts.childIndex ?? stage.current_child_index ?? 0;
808
+ stage.child_runs = upsertChildRun(stage.child_runs, {
809
+ ticket_key: opts.ticketKey,
810
+ pipeline_run_id: childEnv.pipeline_run_id,
811
+ status: "completed",
812
+ });
813
+ stage.current_child_index = childIndex + 1;
814
+ stage.pipeline_run_id = null;
815
+ stage.status = "running";
816
+ let updated;
817
+ try {
818
+ updated = await persistence.patchRun(row.chain_run_id, {
819
+ stages,
820
+ status: "running",
821
+ });
822
+ }
823
+ catch (err) {
824
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
825
+ }
826
+ return { kind: "advanced", row: updated };
827
+ }
828
+ // Single-child stage (idea-to-ticket): finalize. Derive the STRUCTURED payload
829
+ // ONLY from the upload step's structured output — never the whole transcript —
830
+ // so duplicate-detection search hits cannot become review/start-tickets
831
+ // targets. The Epic parent (`epic_parent_key`) is kept SEPARATE from the
832
+ // implementable `child_ticket_keys` so it is handed to NEITHER review NOR
833
+ // start-tickets. `created_ticket_keys` is persisted as an implementable-only
834
+ // compatibility alias (= children) and never carries the Epic parent.
835
+ const extraction = extractCreatedTicketPayloadFromCompletedChild(childEnv);
836
+ if (!extraction.ok) {
837
+ return {
838
+ kind: "fail",
839
+ envelope: await failChainValidation(persistence, recipe, row, extraction.error),
840
+ };
841
+ }
842
+ const payload = extraction.payload;
843
+ if (payload.child_ticket_keys.length === 0) {
844
+ return {
845
+ kind: "fail",
846
+ envelope: await failChainValidation(persistence, recipe, row, "idea-to-ticket produced no implementable child tickets; full automation cannot continue without implementable tickets."),
847
+ };
848
+ }
849
+ const stages = cloneStages(row.stages);
850
+ const stage = stages[idx];
851
+ stage.status = "completed";
852
+ stage.pipeline_run_id = null;
853
+ stage.outputs = {
854
+ child_ticket_keys: payload.child_ticket_keys,
855
+ created_ticket_keys: payload.child_ticket_keys,
856
+ ...(payload.epic_parent_key ? { epic_parent_key: payload.epic_parent_key } : {}),
857
+ };
858
+ stage.summary = summarizeStageCompletion(stageRecipe.pipeline_name, payload);
859
+ let updated;
860
+ try {
861
+ updated = await persistence.patchRun(row.chain_run_id, {
862
+ stages,
863
+ current_stage_index: idx + 1,
864
+ status: "running",
865
+ expected_status: "running",
866
+ expected_current_stage_index: idx,
867
+ });
868
+ }
869
+ catch (err) {
870
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
871
+ }
872
+ return { kind: "advanced", row: updated };
873
+ }
874
+ function upsertChildRun(existing, entry) {
875
+ const list = existing ? [...existing] : [];
876
+ const i = list.findIndex((c) => c.ticket_key !== undefined && c.ticket_key === entry.ticket_key);
877
+ if (i >= 0) {
878
+ list[i] = { ...list[i], ...entry };
879
+ }
880
+ else {
881
+ list.push(entry);
882
+ }
883
+ return list;
884
+ }
885
+ function isChainErrorCode(value) {
886
+ return (value === "VALIDATION" ||
887
+ value === "NOT_FOUND" ||
888
+ value === "EXPIRED" ||
889
+ value === "REPO_MISMATCH" ||
890
+ value === "TOOL_ERROR");
891
+ }
892
+ function persistenceFailEnvelope(err, row, recipe) {
893
+ const idx = row.current_stage_index;
894
+ if (err instanceof ChainPersistenceError) {
895
+ return failedEnvelope(err.code, err.message, {
896
+ chain_run_id: row.chain_run_id,
897
+ chain_stage: recipe.stages[idx]?.pipeline_name,
898
+ chain_step: idx + 1,
899
+ chain_total: recipe.stages.length,
900
+ });
901
+ }
902
+ return failedEnvelope("TOOL_ERROR", "An unexpected persistence error occurred.", {
903
+ chain_run_id: row.chain_run_id,
904
+ chain_stage: recipe.stages[idx]?.pipeline_name,
905
+ chain_step: idx + 1,
906
+ chain_total: recipe.stages.length,
907
+ });
908
+ }
909
+ // ---------------------------------------------------------------------------
910
+ // Stage starters
911
+ // ---------------------------------------------------------------------------
912
+ async function startOrContinueIdeaToTicketStage(deps, persistence, recipe, row, autoApprove) {
913
+ const idx = row.current_stage_index;
914
+ const stageRecipe = recipe.stages[idx];
915
+ const resolved = buildStageVariables(stageRecipe, row.args);
916
+ // idea-to-ticket declares [idea, slug, run_id, docs_dir, allow_duplicate,
917
+ // auto_approve_external, max_children]. `docs_dir` is auto-injected by the
918
+ // pipeline runner; the rest must be supplied here. `slug`/`run_id` are
919
+ // DERIVED (not chain inputs), so they cannot be recipe templates — inject
920
+ // them in code. We also backfill sane defaults so an omitted/empty chain
921
+ // input still satisfies the child pipeline's required-variable contract.
922
+ const variables = {
923
+ ...resolved,
924
+ slug: deriveSlug(toStringVariable(row.args.idea)),
925
+ run_id: deriveRunId(row.chain_run_id),
926
+ auto_approve_external: isNonEmpty(resolved.auto_approve_external)
927
+ ? resolved.auto_approve_external
928
+ : toStringVariable(row.args.auto_approve) || "false",
929
+ allow_duplicate: isNonEmpty(resolved.allow_duplicate)
930
+ ? resolved.allow_duplicate
931
+ : "false",
932
+ max_children: isNonEmpty(resolved.max_children)
933
+ ? resolved.max_children
934
+ : "10",
935
+ };
936
+ const stages = cloneStages(row.stages);
937
+ stages[idx].status = "running";
938
+ stages[idx].input_values = variables;
939
+ try {
940
+ row = await persistence.patchRun(row.chain_run_id, { stages, status: "running" });
941
+ }
942
+ catch (err) {
943
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
944
+ }
945
+ const childEnv = await runPipeline(deps, {
946
+ pipeline: stageRecipe.pipeline_name,
947
+ variables,
948
+ auto_approve: autoApprove,
949
+ ttl_seconds: numericArg(row.args.ttl_seconds),
950
+ });
951
+ return handleChildPipelineEnvelope(persistence, recipe, row, childEnv, {
952
+ fanOut: false,
953
+ });
954
+ }
955
+ async function startOrContinueReviewTicketStage(deps, persistence, recipe, row, autoApprove) {
956
+ const idx = row.current_stage_index;
957
+ const stageRecipe = recipe.stages[idx];
958
+ const fanOutInput = stageRecipe.fan_out_input ?? "created_ticket_keys";
959
+ const keys = resolveCrossStageList(row, idx, fanOutInput);
960
+ if (!keys || keys.length === 0) {
961
+ return {
962
+ kind: "fail",
963
+ envelope: await failChainValidation(persistence, recipe, row, `Missing cross-stage variable "${fanOutInput}" for review fan-out.`),
964
+ };
965
+ }
966
+ const childIndex = row.stages[idx].current_child_index ?? 0;
967
+ if (childIndex >= keys.length) {
968
+ // All children reviewed — finalize the stage.
969
+ const stages = cloneStages(row.stages);
970
+ const stage = stages[idx];
971
+ stage.status = "completed";
972
+ stage.pipeline_run_id = null;
973
+ stage.outputs = { reviewed_ticket_keys: keys };
974
+ stage.summary = summarizeStageCompletion(stageRecipe.pipeline_name, keys);
975
+ let updated;
976
+ try {
977
+ updated = await persistence.patchRun(row.chain_run_id, {
978
+ stages,
979
+ current_stage_index: idx + 1,
980
+ status: "running",
981
+ expected_status: "running",
982
+ expected_current_stage_index: idx,
983
+ });
984
+ }
985
+ catch (err) {
986
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
987
+ }
988
+ return { kind: "advanced", row: updated };
989
+ }
990
+ // Start the next pending child review.
991
+ const ticketKey = keys[childIndex];
992
+ const fanOutVariable = stageRecipe.fan_out_variable ?? "ticket_key";
993
+ const variables = buildStageVariables(stageRecipe, row.args, {
994
+ item: ticketKey,
995
+ [fanOutVariable]: ticketKey,
996
+ });
997
+ const stages = cloneStages(row.stages);
998
+ const stage = stages[idx];
999
+ stage.status = "running";
1000
+ stage.current_child_index = childIndex;
1001
+ stage.input_values = variables;
1002
+ try {
1003
+ row = await persistence.patchRun(row.chain_run_id, { stages, status: "running" });
1004
+ }
1005
+ catch (err) {
1006
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
1007
+ }
1008
+ const childEnv = await runPipeline(deps, {
1009
+ pipeline: stageRecipe.pipeline_name,
1010
+ variables,
1011
+ auto_approve: autoApprove,
1012
+ ttl_seconds: numericArg(row.args.ttl_seconds),
1013
+ });
1014
+ return handleChildPipelineEnvelope(persistence, recipe, row, childEnv, {
1015
+ fanOut: true,
1016
+ ticketKey,
1017
+ childIndex,
1018
+ });
1019
+ }
1020
+ async function startStartTicketsStage(persistence, recipe, row) {
1021
+ const idx = row.current_stage_index;
1022
+ const stageRecipe = recipe.stages[idx];
1023
+ const total = recipe.stages.length;
1024
+ const fanOutInput = stageRecipe.fan_out_input ?? "reviewed_ticket_keys";
1025
+ // Second-layer guard: filter the Epic parent out of the start keys even if an
1026
+ // upstream persisted stage output is malformed (the primary exclusion happens
1027
+ // before review via the structured extractor).
1028
+ const resolution = resolveStartTicketKeys(row, idx, fanOutInput);
1029
+ if (!resolution.ok) {
1030
+ return failChainValidation(persistence, recipe, row, resolution.error);
1031
+ }
1032
+ const keys = resolution.keys;
1033
+ // Hands-off chain runs (auto_approve=true, the default) spawn the
1034
+ // implementation agents auto-approved too, so they don't stall on approval
1035
+ // gates. A --require-approval run (auto_approve=false) spawns them interactive.
1036
+ const autoApproveFlag = row.args.auto_approve === true ? " --auto" : "";
1037
+ const command = `/start-tickets ${keys.join(" ")}${autoApproveFlag}`;
1038
+ const stages = cloneStages(row.stages);
1039
+ stages[idx].status = "paused";
1040
+ stages[idx].pipeline_run_id = null;
1041
+ stages[idx].input_values = { reviewed_ticket_keys: keys.join(" ") };
1042
+ let updated;
1043
+ try {
1044
+ updated = await persistence.patchRun(row.chain_run_id, {
1045
+ stages,
1046
+ status: "paused",
1047
+ });
1048
+ }
1049
+ catch (err) {
1050
+ return persistenceFailEnvelope(err, row, recipe);
1051
+ }
1052
+ const instruction = `Invoke the start-tickets command in this same session to spawn one ` +
1053
+ `worktree per reviewed ticket:\n\n${command}\n\n` +
1054
+ `When the worktrees have been spawned, call \`resume_full_automation\` with ` +
1055
+ `\`chain_run_id\` "${row.chain_run_id}" and \`agent_result\` set to a short ` +
1056
+ `summary of what start-tickets reported.`;
1057
+ return buildNeedsAgentTaskEnvelope({
1058
+ chainRunId: updated.chain_run_id,
1059
+ chainStage: START_TICKETS_PIPELINE,
1060
+ chainStep: idx + 1,
1061
+ chainTotal: total,
1062
+ preamble: buildPreamble(recipe, idx, updated.stages),
1063
+ instruction,
1064
+ });
1065
+ }
1066
+ function numericArg(value) {
1067
+ if (typeof value === "number" && Number.isFinite(value))
1068
+ return value;
1069
+ return undefined;
1070
+ }
1071
+ // ---------------------------------------------------------------------------
1072
+ // Chain driver
1073
+ // ---------------------------------------------------------------------------
1074
+ /**
1075
+ * Drive the chain forward by starting the current stage's next unit of work.
1076
+ * Called after a fresh create, or after a child completes and we advance.
1077
+ * Stops at: a child pipeline pause, the start-tickets seam, completion, a
1078
+ * typed validation failure, or a child pipeline failure.
1079
+ */
1080
+ async function continueChainExecution(deps, persistence, recipe, row, autoApprove) {
1081
+ // Bounded by total stages * (max children + 1); guard against runaway loops.
1082
+ let guard = 0;
1083
+ const guardMax = 10000;
1084
+ while (guard++ < guardMax) {
1085
+ const idx = row.current_stage_index;
1086
+ const total = recipe.stages.length;
1087
+ if (idx >= total) {
1088
+ try {
1089
+ row = await persistence.patchRun(row.chain_run_id, { status: "completed" });
1090
+ }
1091
+ catch (err) {
1092
+ return persistenceFailEnvelope(err, row, recipe);
1093
+ }
1094
+ return buildCompletedEnvelope(recipe, row);
1095
+ }
1096
+ const stageRecipe = recipe.stages[idx];
1097
+ let outcome = null;
1098
+ if (stageRecipe.pipeline_name === START_TICKETS_PIPELINE) {
1099
+ return startStartTicketsStage(persistence, recipe, row);
1100
+ }
1101
+ else if (stageRecipe.fan_out_input) {
1102
+ outcome = await startOrContinueReviewTicketStage(deps, persistence, recipe, row, autoApprove);
1103
+ }
1104
+ else {
1105
+ outcome = await startOrContinueIdeaToTicketStage(deps, persistence, recipe, row, autoApprove);
1106
+ }
1107
+ if (outcome.kind === "pause" || outcome.kind === "fail") {
1108
+ return outcome.envelope;
1109
+ }
1110
+ row = outcome.row;
1111
+ }
1112
+ return failedEnvelope("TOOL_ERROR", "Chain execution exceeded its step guard.", {
1113
+ chain_run_id: row.chain_run_id,
1114
+ chain_total: recipe.stages.length,
1115
+ });
1116
+ }
1117
+ // ---------------------------------------------------------------------------
1118
+ // Public entry points
1119
+ // ---------------------------------------------------------------------------
1120
+ export async function runFullAutomation(deps, input) {
1121
+ try {
1122
+ if (typeof input.idea !== "string" || input.idea.trim() === "") {
1123
+ return failedEnvelope("VALIDATION", "idea must be a non-empty string.");
1124
+ }
1125
+ const agent = input.agent ?? "claude";
1126
+ if (agent !== "claude") {
1127
+ return failedEnvelope("VALIDATION", `Unsupported agent "${String(input.agent)}". Only "claude" is supported.`);
1128
+ }
1129
+ const recipe = deps.chainRecipes[CHAIN_NAME];
1130
+ if (!recipe) {
1131
+ return failedEnvelope("VALIDATION", `Chain recipe "${CHAIN_NAME}" not found.`);
1132
+ }
1133
+ // Full automation is hands-off by default: when the caller omits
1134
+ // auto_approve, run without approval gates. The /full-automation command
1135
+ // sends an explicit boolean (false only for --require-approval); this
1136
+ // default governs direct run_full_automation callers.
1137
+ const autoApprove = input.auto_approve === undefined
1138
+ ? true
1139
+ : normalizeAutoApprove(input.auto_approve);
1140
+ const args = {
1141
+ idea: input.idea,
1142
+ auto_approve: autoApprove,
1143
+ scheduled_at: input.scheduled_at ?? "",
1144
+ max_children: input.max_children,
1145
+ allow_duplicate: input.allow_duplicate,
1146
+ agent,
1147
+ ttl_seconds: input.ttl_seconds,
1148
+ };
1149
+ const initialStages = recipe.stages.map((stage) => ({
1150
+ pipeline_name: stage.pipeline_name,
1151
+ status: "pending",
1152
+ }));
1153
+ const persistence = createChainPersistenceClient({
1154
+ baseUrl: deps.baseUrl,
1155
+ apiKey: deps.apiKey,
1156
+ repoName: deps.repoName,
1157
+ });
1158
+ let row;
1159
+ try {
1160
+ row = await persistence.createRun({
1161
+ chain_name: CHAIN_NAME,
1162
+ args,
1163
+ current_stage_index: 0,
1164
+ stages: initialStages,
1165
+ status: "running",
1166
+ ttl_seconds: input.ttl_seconds,
1167
+ });
1168
+ }
1169
+ catch (err) {
1170
+ if (err instanceof ChainPersistenceError) {
1171
+ return failedEnvelope(err.code, err.message);
1172
+ }
1173
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while creating the chain run.");
1174
+ }
1175
+ return continueChainExecution(deps, persistence, recipe, row, autoApprove);
1176
+ }
1177
+ catch (err) {
1178
+ // The public envelope is intentionally sanitized (no stack traces), but
1179
+ // log to stderr so smoke-testers / operators have a forensic trail (the
1180
+ // MCP server runs outside Sentry).
1181
+ console.error("[chain-orchestrator] unexpected error in runFullAutomation:", err);
1182
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while executing the full-automation chain.");
1183
+ }
1184
+ }
1185
+ export async function resumeFullAutomation(deps, input) {
1186
+ try {
1187
+ const recipe = deps.chainRecipes[CHAIN_NAME];
1188
+ if (!recipe) {
1189
+ return failedEnvelope("VALIDATION", `Chain recipe "${CHAIN_NAME}" not found.`, { chain_run_id: input.chain_run_id });
1190
+ }
1191
+ const persistence = createChainPersistenceClient({
1192
+ baseUrl: deps.baseUrl,
1193
+ apiKey: deps.apiKey,
1194
+ repoName: deps.repoName,
1195
+ });
1196
+ let row;
1197
+ try {
1198
+ row = await persistence.getRun(input.chain_run_id);
1199
+ }
1200
+ catch (err) {
1201
+ if (err instanceof ChainPersistenceError) {
1202
+ return failedEnvelope(err.code, err.message, {
1203
+ chain_run_id: input.chain_run_id,
1204
+ });
1205
+ }
1206
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while fetching the chain run.", { chain_run_id: input.chain_run_id });
1207
+ }
1208
+ if (row.status === "expired") {
1209
+ return failedEnvelope("EXPIRED", "Chain run has expired.", {
1210
+ chain_run_id: row.chain_run_id,
1211
+ chain_total: recipe.stages.length,
1212
+ });
1213
+ }
1214
+ const autoApprove = normalizeAutoApprove(row.args.auto_approve);
1215
+ const idx = row.current_stage_index;
1216
+ const stageRecipe = recipe.stages[idx];
1217
+ const total = recipe.stages.length;
1218
+ if (!stageRecipe) {
1219
+ return failedEnvelope("VALIDATION", `Chain run has no active stage at index ${idx}.`, { chain_run_id: row.chain_run_id, chain_total: total });
1220
+ }
1221
+ // Stage-3 start-tickets seam: complete the chain on a non-empty result.
1222
+ if (stageRecipe.pipeline_name === START_TICKETS_PIPELINE) {
1223
+ if (typeof input.agent_result !== "string" ||
1224
+ input.agent_result.trim() === "") {
1225
+ return failedEnvelope("VALIDATION", "agent_result must be a non-empty string to complete the start-tickets stage.", {
1226
+ chain_run_id: row.chain_run_id,
1227
+ chain_stage: START_TICKETS_PIPELINE,
1228
+ chain_step: idx + 1,
1229
+ chain_total: total,
1230
+ });
1231
+ }
1232
+ // Persist started_ticket_keys WITHOUT the Epic parent, using the same
1233
+ // defensive filter as startStartTicketsStage.
1234
+ const startResolution = resolveStartTicketKeys(row, idx, stageRecipe.fan_out_input ?? "reviewed_ticket_keys");
1235
+ const startedKeys = startResolution.ok ? startResolution.keys : [];
1236
+ const stages = cloneStages(row.stages);
1237
+ stages[idx].status = "completed";
1238
+ stages[idx].pipeline_run_id = null;
1239
+ stages[idx].outputs = { started_ticket_keys: startedKeys };
1240
+ stages[idx].summary = summarizeStageCompletion(START_TICKETS_PIPELINE, startedKeys);
1241
+ let updated;
1242
+ try {
1243
+ updated = await persistence.patchRun(row.chain_run_id, {
1244
+ stages,
1245
+ current_stage_index: idx + 1,
1246
+ status: "completed",
1247
+ expected_status: "paused",
1248
+ expected_current_stage_index: idx,
1249
+ });
1250
+ }
1251
+ catch (err) {
1252
+ return persistenceFailEnvelope(err, row, recipe);
1253
+ }
1254
+ return buildCompletedEnvelope(recipe, updated);
1255
+ }
1256
+ // Otherwise resume the active paused child pipeline.
1257
+ const activePipelineRunId = row.stages[idx]?.pipeline_run_id;
1258
+ if (!activePipelineRunId) {
1259
+ return failedEnvelope("VALIDATION", `No active child pipeline to resume for stage ${idx + 1}.`, {
1260
+ chain_run_id: row.chain_run_id,
1261
+ chain_stage: stageRecipe.pipeline_name,
1262
+ chain_step: idx + 1,
1263
+ chain_total: total,
1264
+ });
1265
+ }
1266
+ // Snapshot the inner pipeline run before touching anything. This lets us
1267
+ // resume the normal `paused` case, recover idempotently when the inner run
1268
+ // already reached a terminal state on a prior (failed-to-advance) resume,
1269
+ // and refuse cleanly when it is in an indeterminate state.
1270
+ const peek = await peekPipelineRun(deps, activePipelineRunId);
1271
+ if ("error_code" in peek) {
1272
+ return failedEnvelope(peek.error_code, peek.error, {
1273
+ chain_run_id: row.chain_run_id,
1274
+ chain_stage: stageRecipe.pipeline_name,
1275
+ chain_step: idx + 1,
1276
+ chain_total: total,
1277
+ });
1278
+ }
1279
+ if (peek.status !== "paused" &&
1280
+ peek.status !== "completed" &&
1281
+ peek.status !== "failed") {
1282
+ // running / expired — results are not known-final, so we can neither
1283
+ // resume (only paused runs resume) nor safely synthesize an advance.
1284
+ return {
1285
+ status: "failed",
1286
+ error_code: "VALIDATION",
1287
+ error: `Inner pipeline run is in status "${peek.status}" and cannot be ` +
1288
+ `safely resumed or recovered. Inspect pipeline_run_id ` +
1289
+ `${activePipelineRunId}.`,
1290
+ chain_run_id: row.chain_run_id,
1291
+ chain_stage: stageRecipe.pipeline_name,
1292
+ chain_step: idx + 1,
1293
+ chain_total: total,
1294
+ pipeline_run_id: activePipelineRunId,
1295
+ resumable: false,
1296
+ };
1297
+ }
1298
+ // Mirror the start path's invariant: the chain row must be "running" while
1299
+ // its inner pipeline is being advanced. The approval-gate pause left the
1300
+ // chain "paused"; flip it back BEFORE the completed-advance patch (which
1301
+ // asserts expected_status "running"). Idempotent (no expected_status) so a
1302
+ // recovery resume of an already-"running" chain does not conflict.
1303
+ try {
1304
+ row = await persistence.patchRun(row.chain_run_id, { status: "running" });
1305
+ }
1306
+ catch (err) {
1307
+ return persistenceFailEnvelope(err, row, recipe);
1308
+ }
1309
+ let childEnv;
1310
+ if (peek.status === "paused") {
1311
+ childEnv = await resumePipeline(deps, {
1312
+ pipeline_run_id: activePipelineRunId,
1313
+ agent_result: input.agent_result,
1314
+ });
1315
+ }
1316
+ else if (peek.status === "completed") {
1317
+ // Idempotent recovery: the inner pipeline already completed on a prior
1318
+ // resume; synthesize its completed envelope (no re-execution) so the
1319
+ // stage advances from the persisted results.
1320
+ childEnv = {
1321
+ status: "completed",
1322
+ pipeline_run_id: peek.pipeline_run_id,
1323
+ pipeline: peek.pipeline,
1324
+ total_steps: peek.total_steps,
1325
+ results: peek.results,
1326
+ };
1327
+ }
1328
+ else {
1329
+ // peek.status === "failed" — let handleChildPipelineEnvelope map it to a
1330
+ // stage failure from the persisted results. The pipeline run row does NOT
1331
+ // persist a top-level error_code (only `status: "failed"` + per-step
1332
+ // results), so the original code cannot be reconstructed here; surface the
1333
+ // failed step's recorded error text for diagnostics and default the code
1334
+ // to TOOL_ERROR.
1335
+ const failedStepError = peek.results.find((r) => !r.ok && typeof r.error === "string")?.error;
1336
+ childEnv = {
1337
+ status: "failed",
1338
+ error_code: "TOOL_ERROR",
1339
+ error: failedStepError
1340
+ ? `Inner pipeline run failed before the chain could advance: ${failedStepError}`
1341
+ : "Inner pipeline run failed before the chain could advance.",
1342
+ pipeline_run_id: peek.pipeline_run_id,
1343
+ pipeline: peek.pipeline,
1344
+ results: peek.results,
1345
+ };
1346
+ }
1347
+ const fanOut = !!stageRecipe.fan_out_input;
1348
+ const childIndex = row.stages[idx]?.current_child_index ?? 0;
1349
+ const ticketKey = fanOut
1350
+ ? (resolveCrossStageList(row, idx, stageRecipe.fan_out_input) ?? [])[childIndex]
1351
+ : undefined;
1352
+ const outcome = await handleChildPipelineEnvelope(persistence, recipe, row, childEnv, { fanOut, ticketKey, childIndex });
1353
+ if (outcome.kind === "pause" || outcome.kind === "fail") {
1354
+ return outcome.envelope;
1355
+ }
1356
+ return continueChainExecution(deps, persistence, recipe, outcome.row, autoApprove);
1357
+ }
1358
+ catch (err) {
1359
+ // Sanitized public envelope; log to stderr for a forensic trail (see
1360
+ // runFullAutomation's catch — the MCP server runs outside Sentry).
1361
+ console.error("[chain-orchestrator] unexpected error in resumeFullAutomation:", err);
1362
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while resuming the full-automation chain.", { chain_run_id: input.chain_run_id });
1363
+ }
1364
+ }