astrocode-workflow 0.0.1

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 (133) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +85 -0
  3. package/dist/agents/commands.d.ts +9 -0
  4. package/dist/agents/commands.js +121 -0
  5. package/dist/agents/prompts.d.ts +2 -0
  6. package/dist/agents/prompts.js +27 -0
  7. package/dist/agents/registry.d.ts +6 -0
  8. package/dist/agents/registry.js +223 -0
  9. package/dist/agents/types.d.ts +14 -0
  10. package/dist/agents/types.js +8 -0
  11. package/dist/config/config-handler.d.ts +4 -0
  12. package/dist/config/config-handler.js +46 -0
  13. package/dist/config/defaults.d.ts +3 -0
  14. package/dist/config/defaults.js +3 -0
  15. package/dist/config/loader.d.ts +11 -0
  16. package/dist/config/loader.js +48 -0
  17. package/dist/config/schema.d.ts +176 -0
  18. package/dist/config/schema.js +198 -0
  19. package/dist/hooks/continuation-enforcer.d.ts +26 -0
  20. package/dist/hooks/continuation-enforcer.js +166 -0
  21. package/dist/hooks/tool-output-truncator.d.ts +17 -0
  22. package/dist/hooks/tool-output-truncator.js +56 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.js +108 -0
  25. package/dist/shared/deep-merge.d.ts +8 -0
  26. package/dist/shared/deep-merge.js +25 -0
  27. package/dist/shared/hash.d.ts +1 -0
  28. package/dist/shared/hash.js +4 -0
  29. package/dist/shared/log.d.ts +7 -0
  30. package/dist/shared/log.js +24 -0
  31. package/dist/shared/model-tuning.d.ts +9 -0
  32. package/dist/shared/model-tuning.js +28 -0
  33. package/dist/shared/paths.d.ts +19 -0
  34. package/dist/shared/paths.js +51 -0
  35. package/dist/shared/text.d.ts +4 -0
  36. package/dist/shared/text.js +19 -0
  37. package/dist/shared/time.d.ts +1 -0
  38. package/dist/shared/time.js +3 -0
  39. package/dist/state/adapters/index.d.ts +39 -0
  40. package/dist/state/adapters/index.js +119 -0
  41. package/dist/state/db.d.ts +17 -0
  42. package/dist/state/db.js +83 -0
  43. package/dist/state/ids.d.ts +8 -0
  44. package/dist/state/ids.js +25 -0
  45. package/dist/state/schema.d.ts +2 -0
  46. package/dist/state/schema.js +247 -0
  47. package/dist/state/types.d.ts +70 -0
  48. package/dist/state/types.js +1 -0
  49. package/dist/tools/artifacts.d.ts +18 -0
  50. package/dist/tools/artifacts.js +71 -0
  51. package/dist/tools/index.d.ts +8 -0
  52. package/dist/tools/index.js +100 -0
  53. package/dist/tools/init.d.ts +8 -0
  54. package/dist/tools/init.js +41 -0
  55. package/dist/tools/injects.d.ts +23 -0
  56. package/dist/tools/injects.js +99 -0
  57. package/dist/tools/repair.d.ts +8 -0
  58. package/dist/tools/repair.js +25 -0
  59. package/dist/tools/run.d.ts +13 -0
  60. package/dist/tools/run.js +54 -0
  61. package/dist/tools/spec.d.ts +13 -0
  62. package/dist/tools/spec.js +41 -0
  63. package/dist/tools/stage.d.ts +23 -0
  64. package/dist/tools/stage.js +284 -0
  65. package/dist/tools/status.d.ts +8 -0
  66. package/dist/tools/status.js +107 -0
  67. package/dist/tools/story.d.ts +23 -0
  68. package/dist/tools/story.js +85 -0
  69. package/dist/tools/workflow.d.ts +8 -0
  70. package/dist/tools/workflow.js +197 -0
  71. package/dist/ui/inject.d.ts +5 -0
  72. package/dist/ui/inject.js +9 -0
  73. package/dist/ui/toasts.d.ts +13 -0
  74. package/dist/ui/toasts.js +39 -0
  75. package/dist/workflow/artifacts.d.ts +24 -0
  76. package/dist/workflow/artifacts.js +45 -0
  77. package/dist/workflow/baton.d.ts +66 -0
  78. package/dist/workflow/baton.js +101 -0
  79. package/dist/workflow/context.d.ts +12 -0
  80. package/dist/workflow/context.js +67 -0
  81. package/dist/workflow/directives.d.ts +37 -0
  82. package/dist/workflow/directives.js +111 -0
  83. package/dist/workflow/repair.d.ts +8 -0
  84. package/dist/workflow/repair.js +99 -0
  85. package/dist/workflow/state-machine.d.ts +43 -0
  86. package/dist/workflow/state-machine.js +127 -0
  87. package/dist/workflow/story-helpers.d.ts +9 -0
  88. package/dist/workflow/story-helpers.js +13 -0
  89. package/package.json +32 -0
  90. package/src/agents/commands.ts +137 -0
  91. package/src/agents/prompts.ts +28 -0
  92. package/src/agents/registry.ts +310 -0
  93. package/src/agents/types.ts +31 -0
  94. package/src/config/config-handler.ts +48 -0
  95. package/src/config/defaults.ts +4 -0
  96. package/src/config/loader.ts +55 -0
  97. package/src/config/schema.ts +236 -0
  98. package/src/hooks/continuation-enforcer.ts +217 -0
  99. package/src/hooks/tool-output-truncator.ts +82 -0
  100. package/src/index.ts +131 -0
  101. package/src/shared/deep-merge.ts +28 -0
  102. package/src/shared/hash.ts +5 -0
  103. package/src/shared/log.ts +30 -0
  104. package/src/shared/model-tuning.ts +48 -0
  105. package/src/shared/paths.ts +70 -0
  106. package/src/shared/text.ts +20 -0
  107. package/src/shared/time.ts +3 -0
  108. package/src/shims.node.d.ts +20 -0
  109. package/src/state/adapters/index.ts +155 -0
  110. package/src/state/db.ts +105 -0
  111. package/src/state/ids.ts +33 -0
  112. package/src/state/schema.ts +249 -0
  113. package/src/state/types.ts +76 -0
  114. package/src/tools/artifacts.ts +83 -0
  115. package/src/tools/index.ts +111 -0
  116. package/src/tools/init.ts +50 -0
  117. package/src/tools/injects.ts +108 -0
  118. package/src/tools/repair.ts +31 -0
  119. package/src/tools/run.ts +62 -0
  120. package/src/tools/spec.ts +50 -0
  121. package/src/tools/stage.ts +361 -0
  122. package/src/tools/status.ts +119 -0
  123. package/src/tools/story.ts +106 -0
  124. package/src/tools/workflow.ts +241 -0
  125. package/src/ui/inject.ts +13 -0
  126. package/src/ui/toasts.ts +48 -0
  127. package/src/workflow/artifacts.ts +69 -0
  128. package/src/workflow/baton.ts +141 -0
  129. package/src/workflow/context.ts +86 -0
  130. package/src/workflow/directives.ts +170 -0
  131. package/src/workflow/repair.ts +138 -0
  132. package/src/workflow/state-machine.ts +194 -0
  133. package/src/workflow/story-helpers.ts +18 -0
@@ -0,0 +1,106 @@
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 { nowISO } from "../shared/time";
6
+ import type { StoryState } from "../state/types";
7
+
8
+ import { insertStory } from "../workflow/story-helpers";
9
+
10
+
11
+ export function createAstroStoryQueueTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
12
+ const { db } = opts;
13
+
14
+ return tool({
15
+ description: "Create a queued story (ticket) in Astrocode. Returns story_key.",
16
+ args: {
17
+ title: tool.schema.string().min(1),
18
+ body_md: tool.schema.string().default(""),
19
+ epic_key: tool.schema.string().optional(),
20
+ priority: tool.schema.number().int().default(0),
21
+ },
22
+ execute: async ({ title, body_md, epic_key, priority }) => {
23
+ const now = nowISO();
24
+ const story_key = withTx(db, () => {
25
+ return insertStory(db, { title, body_md, epic_key: epic_key ?? null, priority: priority ?? 0, state: 'queued' });
26
+ });
27
+
28
+ return `✅ Queued story ${story_key}: ${title}`;
29
+ },
30
+ });
31
+ }
32
+
33
+ export function createAstroStoryApproveTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
34
+ const { db } = opts;
35
+
36
+ return tool({
37
+ description: "Approve a story so it becomes eligible to run.",
38
+ args: {
39
+ story_key: tool.schema.string().min(1),
40
+ },
41
+ execute: async ({ story_key }) => {
42
+ const now = nowISO();
43
+ const row = db.prepare("SELECT story_key, state, title FROM stories WHERE story_key=?").get(story_key) as any;
44
+ if (!row) throw new Error(`Story not found: ${story_key}`);
45
+
46
+ if (row.state === "approved") return `ℹ️ Story ${story_key} already approved.`;
47
+
48
+ db.prepare("UPDATE stories SET state='approved', approved_at=?, updated_at=? WHERE story_key=?").run(now, now, story_key);
49
+ return `✅ Approved story ${story_key}: ${row.title}`;
50
+ },
51
+ });
52
+ }
53
+
54
+ export function createAstroStoryBoardTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
55
+ const { db } = opts;
56
+
57
+ return tool({
58
+ description: "Show stories grouped by state (a compact board).",
59
+ args: {
60
+ limit_per_state: tool.schema.number().int().positive().default(20),
61
+ },
62
+ execute: async ({ limit_per_state }) => {
63
+ const states: StoryState[] = ["queued", "approved", "in_progress", "blocked", "done", "archived"];
64
+ const lines: string[] = [];
65
+ lines.push("# Story board");
66
+
67
+ for (const st of states) {
68
+ const rows = db
69
+ .prepare(
70
+ "SELECT story_key, title, priority, created_at FROM stories WHERE state=? ORDER BY priority DESC, created_at ASC LIMIT ?"
71
+ )
72
+ .all(st, limit_per_state) as Array<any>;
73
+ lines.push("", `## ${st} (${rows.length})`);
74
+ for (const r of rows) lines.push(`- \`${r.story_key}\` (p=${r.priority}) — ${r.title}`);
75
+ }
76
+
77
+ return lines.join("\n").trim();
78
+ },
79
+ });
80
+ }
81
+
82
+ export function createAstroStorySetStateTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
83
+ const { db } = opts;
84
+
85
+ return tool({
86
+ description:
87
+ "Admin: set story state manually (queued|approved|in_progress|done|blocked|archived). Use carefully.",
88
+ args: {
89
+ story_key: tool.schema.string().min(1),
90
+ state: tool.schema.enum(["queued", "approved", "in_progress", "done", "blocked", "archived"]),
91
+ note: tool.schema.string().default(""),
92
+ },
93
+ execute: async ({ story_key, state, note }) => {
94
+ const now = nowISO();
95
+ const row = db.prepare("SELECT story_key, title, state FROM stories WHERE story_key=?").get(story_key) as any;
96
+ if (!row) throw new Error(`Story not found: ${story_key}`);
97
+
98
+ db.prepare("UPDATE stories SET state=?, updated_at=? WHERE story_key=?").run(state, now, story_key);
99
+ db.prepare(
100
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, 'story.state_set', ?, ?)"
101
+ ).run(`evt_${Date.now()}_${Math.random().toString(16).slice(2)}`, JSON.stringify({ story_key, from: row.state, to: state, note }), now);
102
+
103
+ return `✅ Story ${story_key} state: ${row.state} → ${state}${note ? ` (${note})` : ""}`;
104
+ },
105
+ });
106
+ }
@@ -0,0 +1,241 @@
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 } 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 { createToastManager } from "../ui/toasts";
13
+
14
+ function stageGoal(stage: StageKey, cfg: AstrocodeConfig): string {
15
+ switch (stage) {
16
+ case "frame":
17
+ return "Define scope, constraints, and an unambiguous Definition of Done.";
18
+ case "plan":
19
+ return `Produce a bounded task plan (<= ${cfg.workflow.plan_max_tasks} tasks) tied to files and tests.`;
20
+ case "spec":
21
+ return "Produce minimal spec/contract: interfaces, invariants, acceptance checks.";
22
+ case "implement":
23
+ return "Implement the spec with minimal changes, referencing diffs and evidence as artifacts.";
24
+ case "review":
25
+ return "Review implementation for correctness, risks, and alignment with spec.";
26
+ case "verify":
27
+ return "Run verification commands and produce evidence artifacts.";
28
+ case "close":
29
+ return "Summarize outcome and confirm acceptance criteria, leaving clear breadcrumbs.";
30
+ }
31
+ }
32
+
33
+ function stageConstraints(stage: StageKey, cfg: AstrocodeConfig): string[] {
34
+ const common = [
35
+ "Do not narrate prompts.",
36
+ "Keep baton markdown short and structured.",
37
+ "If blocked: ask exactly ONE question and stop.",
38
+ ];
39
+
40
+ if (stage === "plan") {
41
+ common.push(`Hard limit: <= ${cfg.workflow.plan_max_tasks} tasks and <= ${cfg.workflow.plan_max_lines} lines of plan output.`);
42
+ }
43
+ if (stage === "verify" && cfg.workflow.evidence_required.verify) {
44
+ common.push("Evidence required: ASTRO JSON must include evidence[] paths.");
45
+ }
46
+ return common;
47
+ }
48
+
49
+ function agentNameForStage(stage: StageKey, cfg: AstrocodeConfig): string {
50
+ return cfg.agents.stage_agent_names[stage];
51
+ }
52
+
53
+ function buildDelegationPrompt(opts: {
54
+ stageDirective: string;
55
+ run_id: string;
56
+ stage_key: StageKey;
57
+ stage_agent_name: string;
58
+ }): string {
59
+ const { stageDirective, run_id, stage_key, stage_agent_name } = opts;
60
+ const stageUpper = stage_key.toUpperCase();
61
+
62
+ return [
63
+ `[SYSTEM DIRECTIVE: ASTROCODE — DELEGATE_STAGE_${stageUpper}]`,
64
+ ``,
65
+ `Do this now, in order:`,
66
+ `1) Call the **task** tool to delegate to subagent \`${stage_agent_name}\`.`,
67
+ ` - Pass this exact prompt text to the subagent (copy/paste):`,
68
+ ``,
69
+ stageDirective,
70
+ ``,
71
+ `2) When the subagent returns, immediately call **astro_stage_complete** with:`,
72
+ ` - run_id = "${run_id}"`,
73
+ ` - stage_key = "${stage_key}"`,
74
+ ` - output_text = (the FULL subagent response text)`,
75
+ ``,
76
+ `3) Then call **astro_workflow_proceed** again (mode=step).`,
77
+ ``,
78
+ `Important: do NOT do any stage work yourself in orchestrator mode.`,
79
+ ].join("\n").trim();
80
+ }
81
+
82
+ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
83
+ const { ctx, config, db } = opts;
84
+ const toasts = createToastManager({ ctx, throttleMs: config.ui.toasts.throttle_ms });
85
+
86
+ return tool({
87
+ description:
88
+ "Deterministic harness: advances the DB-driven pipeline by one step (or loops bounded). Stops when LLM work is required (delegation/await).",
89
+ args: {
90
+ mode: tool.schema.enum(["step", "loop"]).default(config.workflow.default_mode),
91
+ max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
92
+ },
93
+ execute: async ({ mode, max_steps }) => {
94
+ const sessionId = (ctx as any).sessionID as string | undefined;
95
+ const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
96
+
97
+ const actions: string[] = [];
98
+ const startedAt = nowISO();
99
+
100
+ for (let i = 0; i < steps; i++) {
101
+ const next = decideNextAction(db, config);
102
+
103
+ if (next.kind === "idle") {
104
+ actions.push("idle: no approved stories");
105
+ break;
106
+ }
107
+
108
+ if (next.kind === "start_run") {
109
+ const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
110
+ actions.push(`started run ${run_id} for story ${next.story_key}`);
111
+
112
+ if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
113
+ await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
114
+ }
115
+
116
+ if (mode === "step") break;
117
+ continue;
118
+ }
119
+
120
+ if (next.kind === "complete_run") {
121
+ withTx(db, () => completeRun(db, next.run_id));
122
+ actions.push(`completed run ${next.run_id}`);
123
+
124
+ if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
125
+ await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
126
+ }
127
+
128
+ if (mode === "step") break;
129
+ continue;
130
+ }
131
+
132
+ if (next.kind === "delegate_stage") {
133
+ const active = getActiveRun(db);
134
+ if (!active) throw new Error("Invariant: delegate_stage but no active run.");
135
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id) as any;
136
+ const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
137
+
138
+ // Mark stage started + set subagent_type to the stage agent.
139
+ const agentName = agentNameForStage(next.stage_key, config);
140
+ withTx(db, () => {
141
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
142
+ });
143
+
144
+ if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
145
+ await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
146
+ }
147
+
148
+ const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: `delegate stage ${next.stage_key}` });
149
+
150
+ const stageDirective = buildStageDirective({
151
+ config,
152
+ stage_key: next.stage_key,
153
+ run_id: active.run_id,
154
+ story_key: run.story_key,
155
+ story_title: story?.title ?? "(missing)",
156
+ stage_agent_name: agentName,
157
+ stage_goal: stageGoal(next.stage_key, config),
158
+ stage_constraints: stageConstraints(next.stage_key, config),
159
+ context_snapshot_md: context,
160
+ }).body;
161
+
162
+ const delegatePrompt = buildDelegationPrompt({
163
+ stageDirective,
164
+ run_id: active.run_id,
165
+ stage_key: next.stage_key,
166
+ stage_agent_name: agentName,
167
+ });
168
+
169
+ // Record in continuations as a stage directive (dedupe by hash)
170
+ const h = directiveHash(delegatePrompt);
171
+ const now = nowISO();
172
+ if (sessionId) {
173
+ db.prepare(
174
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
175
+ ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
176
+ }
177
+
178
+ // Visible injection so user can see state
179
+ if (sessionId) {
180
+ await injectChatPrompt({ ctx, sessionId, text: delegatePrompt });
181
+ }
182
+
183
+ actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
184
+
185
+ // Stop here; subagent needs to run.
186
+ break;
187
+ }
188
+
189
+ if (next.kind === "await_stage_completion") {
190
+ actions.push(`await stage completion: ${next.stage_key}`);
191
+ // Optionally nudge with a short directive
192
+ if (sessionId) {
193
+ const context = buildContextSnapshot({ db, config, run_id: next.run_id, next_action: `complete stage ${next.stage_key}` });
194
+ const prompt = [
195
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
196
+ ``,
197
+ `Run ${next.run_id} is waiting for stage ${next.stage_key} output.`,
198
+ `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
199
+ ``,
200
+ `Context snapshot:`,
201
+ context,
202
+ ].join("\n").trim();
203
+ const h = directiveHash(prompt);
204
+ const now = nowISO();
205
+ db.prepare(
206
+ "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)"
207
+ ).run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
208
+
209
+ await injectChatPrompt({ ctx, sessionId, text: prompt });
210
+ }
211
+ break;
212
+ }
213
+
214
+ if (next.kind === "failed") {
215
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
216
+ break;
217
+ }
218
+
219
+ // safety
220
+ actions.push(`unhandled next action: ${(next as any).kind}`);
221
+ break;
222
+ }
223
+
224
+ // Housekeeping event
225
+ db.prepare(
226
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, 'workflow.proceed', ?, ?)"
227
+ ).run(newEventId(), JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
228
+
229
+ const active = getActiveRun(db);
230
+ const lines: string[] = [];
231
+ lines.push(`# astro_workflow_proceed`);
232
+ lines.push(`- mode: ${mode}`);
233
+ lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
234
+ if (active) lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
235
+ lines.push(``, `## Actions`);
236
+ for (const a of actions) lines.push(`- ${a}`);
237
+
238
+ return lines.join("\n").trim();
239
+ },
240
+ });
241
+ }
@@ -0,0 +1,13 @@
1
+ export async function injectChatPrompt(opts: {
2
+ ctx: any;
3
+ sessionId: string;
4
+ text: string;
5
+ }) {
6
+ const { ctx, sessionId, text } = opts;
7
+ await ctx.client.session.prompt({
8
+ path: { id: sessionId },
9
+ body: {
10
+ parts: [{ type: "text", text }],
11
+ },
12
+ });
13
+ }
@@ -0,0 +1,48 @@
1
+ export type ToastVariant = "info" | "success" | "warning" | "error";
2
+
3
+ export type ToastOptions = {
4
+ title: string;
5
+ message: string;
6
+ variant?: ToastVariant;
7
+ durationMs?: number;
8
+ };
9
+
10
+ export function createToastManager(opts: { ctx: any; throttleMs: number }) {
11
+ const { ctx, throttleMs } = opts;
12
+ let lastAt = 0;
13
+
14
+ async function show(toast: ToastOptions): Promise<void> {
15
+ const now = Date.now();
16
+ if (now - lastAt < throttleMs) return;
17
+ lastAt = now;
18
+
19
+ const tui = (ctx as any)?.client?.tui;
20
+ if (tui?.showToast) {
21
+ try {
22
+ await tui.showToast({
23
+ title: toast.title,
24
+ message: toast.message,
25
+ variant: toast.variant ?? "info",
26
+ durationMs: toast.durationMs ?? 2500,
27
+ });
28
+ return;
29
+ } catch {
30
+ // fall through
31
+ }
32
+ }
33
+
34
+ // Fallback: visible chat prompt
35
+ try {
36
+ const sessionId = (ctx as any)?.sessionID;
37
+ if (!sessionId) return;
38
+ await (ctx as any).client.session.prompt({
39
+ path: { id: sessionId },
40
+ body: { parts: [{ type: "text", text: `[ASTRO TOAST] ${toast.title}: ${toast.message}` }] },
41
+ });
42
+ } catch {
43
+ // ignore
44
+ }
45
+ }
46
+
47
+ return { show };
48
+ }
@@ -0,0 +1,69 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { SqliteDb } from "../state/db";
4
+ import { newArtifactId } from "../state/ids";
5
+ import { nowISO } from "../shared/time";
6
+ import { sha256Hex } from "../shared/hash";
7
+ import { assertInsideAstro, ensureDir, toPosix } from "../shared/paths";
8
+
9
+ export type ArtifactType =
10
+ | "baton"
11
+ | "summary"
12
+ | "evidence"
13
+ | "diff"
14
+ | "log"
15
+ | "commit"
16
+ | "tool_output"
17
+ | "snapshot"
18
+ | "spec";
19
+
20
+ export type PutArtifactOpts = {
21
+ repoRoot: string;
22
+ db: SqliteDb;
23
+ run_id?: string | null;
24
+ stage_key?: string | null;
25
+ type: ArtifactType | string;
26
+ rel_path: string; // relative to repo root
27
+ content: string | Buffer;
28
+ meta?: Record<string, unknown>;
29
+ };
30
+
31
+ export function writeFileSafe(repoRoot: string, relPath: string, content: string | Buffer) {
32
+ const abs = path.join(repoRoot, relPath);
33
+ assertInsideAstro(repoRoot, abs);
34
+ ensureDir(path.dirname(abs));
35
+ fs.writeFileSync(abs, content);
36
+ }
37
+
38
+ export function putArtifact(opts: PutArtifactOpts): { artifact_id: string; sha256: string; abs_path: string } {
39
+ const { repoRoot, db, run_id = null, stage_key = null, type, rel_path, content } = opts;
40
+
41
+ const artifact_id = newArtifactId();
42
+ const abs_path = path.join(repoRoot, rel_path);
43
+ writeFileSafe(repoRoot, rel_path, content);
44
+
45
+ const sha256 = sha256Hex(content instanceof Buffer ? content : Buffer.from(content, "utf-8"));
46
+
47
+ const created_at = nowISO();
48
+ const meta_json = JSON.stringify(opts.meta ?? {});
49
+ db.prepare(
50
+ "INSERT INTO artifacts (artifact_id, run_id, stage_key, type, path, sha256, meta_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
51
+ ).run(artifact_id, run_id, stage_key, type, toPosix(rel_path), sha256, meta_json, created_at);
52
+
53
+ return { artifact_id, sha256, abs_path };
54
+ }
55
+
56
+ export function listArtifacts(db: SqliteDb, filters?: { run_id?: string; stage_key?: string; type?: string }): any[] {
57
+ const where: string[] = [];
58
+ const params: any[] = [];
59
+ if (filters?.run_id) { where.push("run_id = ?"); params.push(filters.run_id); }
60
+ if (filters?.stage_key) { where.push("stage_key = ?"); params.push(filters.stage_key); }
61
+ if (filters?.type) { where.push("type = ?"); params.push(filters.type); }
62
+ const sql = `SELECT * FROM artifacts ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY created_at DESC LIMIT 200`;
63
+ return db.prepare(sql).all(...params);
64
+ }
65
+
66
+ export function getArtifact(db: SqliteDb, artifact_id: string): any | null {
67
+ const row = db.prepare("SELECT * FROM artifacts WHERE artifact_id = ?").get(artifact_id);
68
+ return (row as any) ?? null;
69
+ }
@@ -0,0 +1,141 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+ import type { AstrocodeConfig } from "../config/schema";
3
+ import { clampLines, normalizeNewlines, stripCodeFences } from "../shared/text";
4
+ import { z } from "zod";
5
+
6
+ export const ASTRO_JSON_BEGIN = "<!-- ASTRO_JSON_BEGIN -->";
7
+ export const ASTRO_JSON_END = "<!-- ASTRO_JSON_END -->";
8
+
9
+ export const StageKeySchema = z.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]);
10
+
11
+ export const AstroJsonSchema = z.object({
12
+ schema_version: z.number().int().default(1),
13
+
14
+ run_id: z.string().optional(),
15
+ story_key: z.string().optional(),
16
+
17
+ stage_key: StageKeySchema,
18
+ status: z.enum(["ok", "blocked", "failed"]).default("ok"),
19
+
20
+ summary: z.string().default(""),
21
+ decisions: z.array(z.string()).default([]),
22
+ next_actions: z.array(z.string()).default([]),
23
+
24
+ files: z.array(
25
+ z.object({
26
+ path: z.string(),
27
+ kind: z.string().default("file"),
28
+ notes: z.string().optional(),
29
+ })
30
+ ).default([]),
31
+
32
+ evidence: z.array(
33
+ z.object({
34
+ path: z.string(),
35
+ kind: z.string().default("evidence"),
36
+ notes: z.string().optional(),
37
+ })
38
+ ).default([]),
39
+
40
+ new_stories: z.array(
41
+ z.object({
42
+ title: z.string(),
43
+ body_md: z.string().optional(),
44
+ priority: z.number().int().optional(),
45
+ })
46
+ ).default([]),
47
+
48
+ questions: z.array(z.string()).default([]),
49
+
50
+ metrics: z.record(z.string(), z.union([z.number(), z.string()])).default({}),
51
+ });
52
+
53
+ export type AstroJson = z.infer<typeof AstroJsonSchema>;
54
+
55
+ export type ParsedStageOutput = {
56
+ baton_md: string;
57
+ astro_json: AstroJson | null;
58
+ astro_json_raw: string | null;
59
+ error: string | null;
60
+ };
61
+
62
+ export function parseStageOutputText(text: string): ParsedStageOutput {
63
+ const norm = normalizeNewlines(text ?? "").trim();
64
+
65
+ const beginIdx = norm.indexOf(ASTRO_JSON_BEGIN);
66
+ const endIdx = norm.indexOf(ASTRO_JSON_END);
67
+
68
+ if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) {
69
+ return {
70
+ baton_md: norm,
71
+ astro_json: null,
72
+ astro_json_raw: null,
73
+ error: `Missing ASTRO JSON markers. Expected markers ${ASTRO_JSON_BEGIN} ... ${ASTRO_JSON_END}`,
74
+ };
75
+ }
76
+
77
+ const before = norm.slice(0, beginIdx).trim();
78
+ const jsonRaw = norm.slice(beginIdx + ASTRO_JSON_BEGIN.length, endIdx).trim();
79
+ const after = norm.slice(endIdx + ASTRO_JSON_END.length).trim();
80
+
81
+ const baton = [before, after].filter(Boolean).join("\n\n").trim();
82
+
83
+ try {
84
+ const cleaned = stripCodeFences(jsonRaw).trim();
85
+ const parsed = JSON.parse(cleaned) as unknown;
86
+ const astroJson = AstroJsonSchema.parse(parsed);
87
+ return { baton_md: baton, astro_json: astroJson, astro_json_raw: cleaned, error: null };
88
+ } catch (e) {
89
+ return {
90
+ baton_md: baton,
91
+ astro_json: null,
92
+ astro_json_raw: jsonRaw,
93
+ error: `Failed to parse ASTRO JSON: ${String(e)}`,
94
+ };
95
+ }
96
+ }
97
+
98
+ export function buildBatonSummary(opts: {
99
+ config: AstrocodeConfig;
100
+ stage_key: string;
101
+ astro_json: AstroJson | null;
102
+ baton_md: string;
103
+ }): string {
104
+ const { config, stage_key, astro_json, baton_md } = opts;
105
+ const maxLines = config.context_compaction.baton_summary_max_lines;
106
+
107
+ const lines: string[] = [];
108
+ lines.push(`# ${stage_key} — Summary`);
109
+
110
+ if (astro_json?.summary) {
111
+ lines.push("", astro_json.summary.trim());
112
+ } else {
113
+ // Fallback: first non-empty paragraph
114
+ const paras = baton_md.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
115
+ if (paras[0]) lines.push("", paras[0]);
116
+ }
117
+
118
+ const addList = (title: string, items: string[]) => {
119
+ if (!items.length) return;
120
+ lines.push("", `## ${title}`);
121
+ for (const it of items.slice(0, 12)) lines.push(`- ${it}`);
122
+ };
123
+
124
+ addList("Decisions", astro_json?.decisions ?? []);
125
+ addList("Next actions", astro_json?.next_actions ?? []);
126
+
127
+ const files = astro_json?.files ?? [];
128
+ if (files.length) {
129
+ lines.push("", "## Files");
130
+ for (const f of files.slice(0, 15)) lines.push(`- \`${f.path}\` (${f.kind})${f.notes ? ` — ${f.notes}` : ""}`);
131
+ }
132
+
133
+ const evidence = astro_json?.evidence ?? [];
134
+ if (evidence.length) {
135
+ lines.push("", "## Evidence");
136
+ for (const ev of evidence.slice(0, 15)) lines.push(`- \`${ev.path}\` (${ev.kind})${ev.notes ? ` — ${ev.notes}` : ""}`);
137
+ }
138
+
139
+ const out = lines.join("\n").trim();
140
+ return clampLines(out, maxLines);
141
+ }