astrocode-workflow 0.2.0 → 0.3.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.
@@ -0,0 +1,681 @@
1
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import type { AstrocodeConfig } from "../config/schema";
3
+ import type { SqliteDb } from "../state/db";
4
+ import { withTx } from "../state/db";
5
+ import type { StageKey } from "../state/types";
6
+ import { buildContextSnapshot } from "../workflow/context";
7
+ import { decideNextAction, createRunForStory, startStage, completeRun, getActiveRun, EVENT_TYPES } from "../workflow/state-machine";
8
+ import { buildStageDirective, directiveHash } from "../workflow/directives";
9
+ import { injectChatPrompt } from "../ui/inject";
10
+ import { nowISO } from "../shared/time";
11
+ import { newEventId } from "../state/ids";
12
+ import { debug } from "../shared/log";
13
+ import { createToastManager } from "../ui/toasts";
14
+
15
+ // Agent name mapping for case-sensitive resolution
16
+ export const STAGE_TO_AGENT_MAP: Record<string, string> = {
17
+ frame: "Frame",
18
+ plan: "Plan",
19
+ spec: "Spec",
20
+ implement: "Implement",
21
+ review: "Review",
22
+ verify: "Verify",
23
+ close: "Close"
24
+ };
25
+
26
+ export function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string {
27
+ // Use configurable agent names from config, fallback to hardcoded map, then General
28
+ const agentNames = config.agents?.stage_agent_names;
29
+ let candidate: string;
30
+
31
+ if (agentNames && agentNames[stageKey]) {
32
+ candidate = agentNames[stageKey];
33
+ } else {
34
+ candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
35
+ }
36
+
37
+ // Validate that the agent actually exists in the registry
38
+ if (agents && !agents[candidate]) {
39
+ const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
40
+ if (warnings) {
41
+ warnings.push(warning);
42
+ } else {
43
+ console.warn(`[Astrocode] ${warning}`);
44
+ }
45
+ candidate = "General";
46
+ }
47
+
48
+ // Final guard: ensure General exists, fallback to built-in "general" if not
49
+ if (agents && !agents[candidate]) {
50
+ const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
51
+ if (warnings) {
52
+ warnings.push(finalWarning);
53
+ } else {
54
+ console.warn(`[Astrocode] ${finalWarning}`);
55
+ }
56
+ return "general"; // built-in, guaranteed by OpenCode
57
+ }
58
+
59
+ return candidate;
60
+ }
61
+
62
+ function stageGoal(stage: StageKey, cfg: AstrocodeConfig): string {
63
+ switch (stage) {
64
+ case "frame":
65
+ return "Define scope, constraints, and an unambiguous Definition of Done.";
66
+ case "plan":
67
+ return `Create 50-200 detailed implementation stories, each focused on a specific, implementable task. Break down every component into separate stories with clear acceptance criteria.`;
68
+ case "spec":
69
+ return "Produce minimal spec/contract: interfaces, invariants, acceptance checks.";
70
+ case "implement":
71
+ return "Implement the spec with minimal changes, referencing diffs and evidence as artifacts.";
72
+ case "review":
73
+ return "Review implementation for correctness, risks, and alignment with spec.";
74
+ case "verify":
75
+ return "Run verification commands and produce evidence artifacts.";
76
+ case "close":
77
+ return "Summarize outcome and confirm acceptance criteria, leaving clear breadcrumbs.";
78
+ }
79
+ }
80
+
81
+ function stageConstraints(stage: StageKey, cfg: AstrocodeConfig): string[] {
82
+ const common = [
83
+ "Do not narrate prompts.",
84
+ "Keep baton markdown short and structured.",
85
+ "If blocked: ask exactly ONE question and stop.",
86
+ ];
87
+
88
+ if (stage === "plan") {
89
+ common.push(`Create 50-200 stories; each story must be implementable in 2-8 hours with clear acceptance criteria.`);
90
+ }
91
+ if (stage === "verify" && cfg.workflow.evidence_required.verify) {
92
+ common.push("Evidence required: ASTRO JSON must include evidence[] paths.");
93
+ }
94
+ return common;
95
+ }
96
+
97
+ function agentNameForStage(stage: StageKey, cfg: AstrocodeConfig): string {
98
+ return cfg.agents.stage_agent_names[stage];
99
+ }
100
+
101
+ function buildDelegationPrompt(opts: {
102
+ stageDirective: string;
103
+ run_id: string;
104
+ stage_key: StageKey;
105
+ stage_agent_name: string;
106
+ }): string {
107
+ const { stageDirective, run_id, stage_key, stage_agent_name } = opts;
108
+ const stageUpper = stage_key.toUpperCase();
109
+
110
+ const prompt = [
111
+ `[SYSTEM DIRECTIVE: ASTROCODE — DELEGATE_STAGE_${stageUpper}]`,
112
+ ``,
113
+ `Do this now, in order:`,
114
+ `1) Call the **task** tool to delegate to subagent \`${stage_agent_name}\`.`,
115
+ ` - Pass this exact prompt text to the subagent (copy/paste):`,
116
+ ``,
117
+ stageDirective,
118
+ ``,
119
+ `2) When the subagent returns, immediately call **astro_stage_complete** with:`,
120
+ ` - run_id = "${run_id}"`,
121
+ ` - stage_key = "${stage_key}"`,
122
+ ` - output_text = (the FULL subagent response text)`,
123
+ ``,
124
+ `3) Then call **astro_workflow_proceed** again (mode=step).`,
125
+ ``,
126
+ `Important: do NOT do any stage work yourself in orchestrator mode.`,
127
+ ].join("\n").trim();
128
+
129
+ // Debug log the delegation prompt to troubleshoot agent output issues
130
+ debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
131
+
132
+ return prompt;
133
+ }
134
+
135
+ import { AgentConfig } from "@opencode-ai/sdk";
136
+
137
+ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb; agents?: Record<string, AgentConfig> }): ToolDefinition {
138
+ const { ctx, config, db, agents } = opts;
139
+ const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
140
+
141
+ return tool({
142
+ description:
143
+ "Deterministic harness: advances the DB-driven pipeline by one step (or loops bounded). Stops when LLM work is required (delegation/await).",
144
+ args: {
145
+ mode: tool.schema.enum(["step", "loop"]).default(config.workflow.default_mode),
146
+ max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
147
+ },
148
+ execute: async ({ mode, max_steps }) => {
149
+ const sessionId = (ctx as any).sessionID as string | undefined;
150
+ const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
151
+
152
+ const actions: string[] = [];
153
+ const warnings: string[] = [];
154
+ const startedAt = nowISO();
155
+
156
+ for (let i = 0; i < steps; i++) {
157
+ const next = decideNextAction(db, config);
158
+
159
+ if (next.kind === "idle") {
160
+ actions.push("idle: no approved stories");
161
+ break;
162
+ }
163
+
164
+ if (next.kind === "start_run") {
165
+ // NOTE: createRunForStory owns its own tx (state-machine.ts).
166
+ const { run_id } = createRunForStory(db, config, next.story_key);
167
+ actions.push(`started run ${run_id} for story ${next.story_key}`);
168
+
169
+ if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
170
+ await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
171
+ }
172
+
173
+ if (sessionId) {
174
+ await injectChatPrompt({
175
+ ctx,
176
+ sessionId,
177
+ agent: "Astro",
178
+ text: [
179
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_STARTED]`,
180
+ ``,
181
+ `Run started: \`${run_id}\``,
182
+ `Story: \`${next.story_key}\``,
183
+ ``,
184
+ `Next: call **astro_workflow_proceed** again to delegate the first stage.`,
185
+ ].join("\n"),
186
+ });
187
+ actions.push(`injected run started message for ${run_id}`);
188
+ }
189
+
190
+ if (mode === "step") break;
191
+ continue;
192
+ }
193
+
194
+ if (next.kind === "complete_run") {
195
+ // NOTE: completeRun owns its own tx (state-machine.ts).
196
+ completeRun(db, next.run_id);
197
+ actions.push(`completed run ${next.run_id}`);
198
+
199
+ if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
200
+ await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
201
+ }
202
+
203
+ // ✅ explicit injection on completeRun (requested)
204
+ if (sessionId) {
205
+ await injectChatPrompt({
206
+ ctx,
207
+ sessionId,
208
+ agent: "Astro",
209
+ text: [
210
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
211
+ ``,
212
+ `Run \`${next.run_id}\` completed.`,
213
+ ``,
214
+ `Next: call **astro_workflow_proceed** (mode=step) to start the next approved story (if any).`,
215
+ ].join("\n"),
216
+ });
217
+ actions.push(`injected run completed message for ${next.run_id}`);
218
+ }
219
+
220
+ if (mode === "step") break;
221
+ continue;
222
+ }
223
+
224
+ if (next.kind === "delegate_stage") {
225
+ const active = getActiveRun(db);
226
+ if (!active) throw new Error("Invariant: delegate_stage but no active run.");
227
+
228
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id) as any;
229
+ const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
230
+
231
+ let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
232
+
233
+ const agentExists = (name: string) => {
234
+ if (agents && agents[name]) return true;
235
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
236
+ if (knownStageAgents.includes(name)) return true;
237
+ return false;
238
+ };
239
+
240
+ if (!agentExists(agentName)) {
241
+ const originalAgent = agentName;
242
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
243
+ agentName = config.agents?.orchestrator_name || "Astro";
244
+ if (!agentExists(agentName)) {
245
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
246
+ agentName = "General";
247
+ if (!agentExists(agentName)) {
248
+ throw new Error(
249
+ `Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`
250
+ );
251
+ }
252
+ }
253
+ }
254
+
255
+ // NOTE: startStage owns its own tx (state-machine.ts).
256
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
257
+
258
+ actions.push(`stage started: ${next.stage_key}`);
259
+
260
+ if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
261
+ await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
262
+ }
263
+
264
+ // ✅ explicit injection on startStage (requested)
265
+ if (sessionId) {
266
+ await injectChatPrompt({
267
+ ctx,
268
+ sessionId,
269
+ agent: "Astro",
270
+ text: [
271
+ `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
272
+ ``,
273
+ `Run: \`${active.run_id}\``,
274
+ `Stage: \`${next.stage_key}\``,
275
+ `Delegated to: \`${agentName}\``,
276
+ ].join("\n"),
277
+ });
278
+ actions.push(`injected stage started message for ${next.stage_key}`);
279
+ }
280
+
281
+ const context = buildContextSnapshot({
282
+ db,
283
+ config,
284
+ run_id: active.run_id,
285
+ next_action: `delegate stage ${next.stage_key}`,
286
+ });
287
+
288
+ const stageDirective = buildStageDirective({
289
+ config,
290
+ stage_key: next.stage_key,
291
+ run_id: active.run_id,
292
+ story_key: run.story_key,
293
+ story_title: story?.title ?? "(missing)",
294
+ stage_agent_name: agentName,
295
+ stage_goal: stageGoal(next.stage_key, config),
296
+ stage_constraints: stageConstraints(next.stage_key, config),
297
+ context_snapshot_md: context,
298
+ }).body;
299
+
300
+ const delegatePrompt = buildDelegationPrompt({
301
+ stageDirective,
302
+ run_id: active.run_id,
303
+ stage_key: next.stage_key,
304
+ stage_agent_name: agentName,
305
+ });
306
+
307
+ // Best-effort: continuations table may not exist on older DBs.
308
+ try {
309
+ const h = directiveHash(delegatePrompt);
310
+ const now = nowISO();
311
+ if (sessionId) {
312
+ db.prepare(
313
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
314
+ ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
315
+ }
316
+ } catch (e) {
317
+ warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
318
+ }
319
+
320
+ // Visible injection so user can see state
321
+ if (sessionId) {
322
+ await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
323
+
324
+ const continueMessage = [
325
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
326
+ ``,
327
+ `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
328
+ ``,
329
+ `When \`${agentName}\` completes, call:`,
330
+ `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
331
+ ``,
332
+ `Then run **astro_workflow_proceed** again.`,
333
+ ].join("\n");
334
+
335
+ await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
336
+ }
337
+
338
+ actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
339
+
340
+ // Stop here; subagent needs to run.
341
+ break;
342
+ }
343
+
344
+ if (next.kind === "await_stage_completion") {
345
+ actions.push(`await stage completion: ${next.stage_key}`);
346
+
347
+ if (sessionId) {
348
+ const context = buildContextSnapshot({
349
+ db,
350
+ config,
351
+ run_id: next.run_id,
352
+ next_action: `complete stage ${next.stage_key}`,
353
+ });
354
+
355
+ const prompt = [
356
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
357
+ ``,
358
+ `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
359
+ `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
360
+ ``,
361
+ `Context snapshot:`,
362
+ context,
363
+ ].join("\n").trim();
364
+
365
+ try {
366
+ const h = directiveHash(prompt);
367
+ const now = nowISO();
368
+ db.prepare(
369
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)"
370
+ ).run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
371
+ } catch (e) {
372
+ warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
373
+ }
374
+
375
+ await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
376
+ }
377
+
378
+ break;
379
+ }
380
+
381
+ if (next.kind === "failed") {
382
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
383
+
384
+ if (sessionId) {
385
+ await injectChatPrompt({
386
+ ctx,
387
+ sessionId,
388
+ agent: "Astro",
389
+ text: [
390
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
391
+ ``,
392
+ `Run \`${next.run_id}\` failed at stage \`${next.stage_key}\`.`,
393
+ `Error: ${next.error_text}`,
394
+ ].join("\n"),
395
+ });
396
+ actions.push(`injected run failed message for ${next.run_id}`);
397
+ }
398
+
399
+ break;
400
+ }
401
+
402
+ actions.push(`unhandled next action: ${(next as any).kind}`);
403
+ break;
404
+ }
405
+
406
+ // Housekeeping event (best-effort)
407
+ try {
408
+ db.prepare(
409
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"
410
+ ).run(
411
+ newEventId(),
412
+ EVENT_TYPES.WORKFLOW_PROCEED,
413
+ JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }),
414
+ nowISO()
415
+ );
416
+ } catch (e) {
417
+ warnings.push(`workflow.proceed event insert failed (non-fatal): ${String(e)}`);
418
+ }
419
+
420
+ const active = getActiveRun(db);
421
+ const lines: string[] = [];
422
+ lines.push(`# astro_workflow_proceed`);
423
+ lines.push(`- mode: ${mode}`);
424
+ lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
425
+ if (active) lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
426
+ lines.push(``, `## Actions`);
427
+ for (const a of actions) lines.push(`- ${a}`);
428
+
429
+ if (warnings.length > 0) {
430
+ lines.push(``, `## Warnings`);
431
+ for (const w of warnings) lines.push(`⚠️ ${w}`);
432
+ }
433
+
434
+ return lines.join("\n").trim();
435
+ },
436
+ });
437
+ }
438
+
439
+ if (next.kind === "start_run") {
440
+ const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
441
+ actions.push(`started run ${run_id} for story ${next.story_key}`);
442
+
443
+ if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
444
+ await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
445
+ }
446
+
447
+ if (mode === "step") break;
448
+ continue;
449
+ }
450
+
451
+ if (next.kind === "complete_run") {
452
+ withTx(db, () => completeRun(db, next.run_id));
453
+ actions.push(`completed run ${next.run_id}`);
454
+
455
+ if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
456
+ await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
457
+ }
458
+
459
+ // Inject continuation directive for workflow resumption
460
+ if (sessionId) {
461
+ const continueDirective = [
462
+ `[SYSTEM DIRECTIVE: ASTROCODE — CONTINUE]`,
463
+ ``,
464
+ `Run ${next.run_id} completed successfully.`,
465
+ ``,
466
+ `The Clara Forms implementation run has finished all stages. The spec has been analyzed and decomposed into prioritized implementation stories.`,
467
+ ``,
468
+ `Next actions: Review the generated stories and approve the next one to continue development.`,
469
+ ].join("\n");
470
+
471
+ await injectChatPrompt({
472
+ ctx,
473
+ sessionId,
474
+ text: continueDirective,
475
+ agent: "Astro"
476
+ });
477
+
478
+ actions.push(`injected continuation directive for completed run ${next.run_id}`);
479
+ }
480
+
481
+ // Check for next approved story to start
482
+ const nextStory = db.prepare(
483
+ "SELECT story_key, title FROM stories WHERE state = 'approved' ORDER BY priority DESC, created_at ASC LIMIT 1"
484
+ ).get() as { story_key: string; title: string } | undefined;
485
+
486
+ if (nextStory && sessionId) {
487
+ const nextDirective = [
488
+ `[SYSTEM DIRECTIVE: ASTROCODE — START_NEXT_STORY]`,
489
+ ``,
490
+ `The previous run completed successfully. Start the next approved story.`,
491
+ ``,
492
+ `Next Story: ${nextStory.story_key} — ${nextStory.title}`,
493
+ ``,
494
+ `Action: Call astro_story_approve with story_key="${nextStory.story_key}" to start it, or select a different story.`,
495
+ ].join("\n");
496
+
497
+ await injectChatPrompt({
498
+ ctx,
499
+ sessionId,
500
+ text: nextDirective,
501
+ agent: "Astro"
502
+ });
503
+
504
+ actions.push(`injected directive to start next story ${nextStory.story_key}`);
505
+ }
506
+
507
+ if (mode === "step") break;
508
+ continue;
509
+ }
510
+
511
+ if (next.kind === "delegate_stage") {
512
+ const active = getActiveRun(db);
513
+ if (!active) throw new Error("Invariant: delegate_stage but no active run.");
514
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id) as any;
515
+ const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
516
+
517
+ // Mark stage started + set subagent_type to the stage agent.
518
+ let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
519
+
520
+ // Validate agent availability with fallback chain
521
+ const systemConfig = config as any;
522
+ // Check both the system config agent map (if present) OR the local agents map passed to the tool
523
+ const agentExists = (name: string) => {
524
+ // Check local agents map first (populated from src/index.ts)
525
+ if (agents && agents[name]) {
526
+ return true;
527
+ }
528
+ // Check system config agent map
529
+ if (systemConfig.agent && systemConfig.agent[name]) {
530
+ return true;
531
+ }
532
+ // For known stage agents, assume they exist (they are system-provided subagents)
533
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close"];
534
+ if (knownStageAgents.includes(name)) {
535
+ return true;
536
+ }
537
+ return false;
538
+ };
539
+
540
+ if (!agentExists(agentName)) {
541
+ const originalAgent = agentName;
542
+ console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
543
+ // First fallback: orchestrator
544
+ agentName = config.agents?.orchestrator_name || "Astro";
545
+ if (!agentExists(agentName)) {
546
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
547
+ // Final fallback: General (guaranteed to exist)
548
+ agentName = "General";
549
+ if (!agentExists(agentName)) {
550
+ throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
551
+ }
552
+ }
553
+ }
554
+
555
+ withTx(db, () => {
556
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
557
+
558
+ // Log delegation observability
559
+ if (config.debug?.telemetry?.enabled) {
560
+ // eslint-disable-next-line no-console
561
+ console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
562
+ }
563
+ });
564
+
565
+ if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
566
+ await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
567
+ }
568
+
569
+ const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: `delegate stage ${next.stage_key}` });
570
+
571
+ const stageDirective = buildStageDirective({
572
+ config,
573
+ stage_key: next.stage_key,
574
+ run_id: active.run_id,
575
+ story_key: run.story_key,
576
+ story_title: story?.title ?? "(missing)",
577
+ stage_agent_name: agentName,
578
+ stage_goal: stageGoal(next.stage_key, config),
579
+ stage_constraints: stageConstraints(next.stage_key, config),
580
+ context_snapshot_md: context,
581
+ }).body;
582
+
583
+ const delegatePrompt = buildDelegationPrompt({
584
+ stageDirective,
585
+ run_id: active.run_id,
586
+ stage_key: next.stage_key,
587
+ stage_agent_name: agentName,
588
+ });
589
+
590
+ // Record in continuations as a stage directive (dedupe by hash)
591
+ const h = directiveHash(delegatePrompt);
592
+ const now = nowISO();
593
+ if (sessionId) {
594
+ db.prepare(
595
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
596
+ ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
597
+ }
598
+
599
+ // Visible injection so user can see state
600
+ if (sessionId) {
601
+ await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
602
+
603
+ // Inject continuation guidance
604
+ const continueMessage = [
605
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
606
+ ``,
607
+ `Stage ${next.stage_key} delegated to ${agentName}.`,
608
+ ``,
609
+ `When ${agentName} completes, call:`,
610
+ `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
611
+ ``,
612
+ `This advances the workflow.`,
613
+ ].join("\n");
614
+
615
+ await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
616
+ }
617
+
618
+ actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
619
+
620
+ // Stop here; subagent needs to run.
621
+ break;
622
+ }
623
+
624
+ if (next.kind === "await_stage_completion") {
625
+ actions.push(`await stage completion: ${next.stage_key}`);
626
+ // Optionally nudge with a short directive
627
+ if (sessionId) {
628
+ const context = buildContextSnapshot({ db, config, run_id: next.run_id, next_action: `complete stage ${next.stage_key}` });
629
+ const prompt = [
630
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
631
+ ``,
632
+ `Run ${next.run_id} is waiting for stage ${next.stage_key} output.`,
633
+ `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
634
+ ``,
635
+ `Context snapshot:`,
636
+ context,
637
+ ].join("\n").trim();
638
+ const h = directiveHash(prompt);
639
+ const now = nowISO();
640
+ db.prepare(
641
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)"
642
+ ).run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
643
+
644
+ await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
645
+ }
646
+ break;
647
+ }
648
+
649
+ if (next.kind === "failed") {
650
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
651
+ break;
652
+ }
653
+
654
+ // safety
655
+ actions.push(`unhandled next action: ${(next as any).kind}`);
656
+ break;
657
+ }
658
+
659
+ // Housekeeping event
660
+ db.prepare(
661
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"
662
+ ).run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
663
+
664
+ const active = getActiveRun(db);
665
+ const lines: string[] = [];
666
+ lines.push(`# astro_workflow_proceed`);
667
+ lines.push(`- mode: ${mode}`);
668
+ lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
669
+ if (active) lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
670
+ lines.push(``, `## Actions`);
671
+ for (const a of actions) lines.push(`- ${a}`);
672
+
673
+ if (warnings.length > 0) {
674
+ lines.push(``, `## Warnings`);
675
+ for (const w of warnings) lines.push(`⚠️ ${w}`);
676
+ }
677
+
678
+ return lines.join("\n").trim();
679
+ },
680
+ });
681
+ }