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,31 @@
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 { repairState, formatRepairReport } from "../workflow/repair";
6
+ import { putArtifact } from "../workflow/artifacts";
7
+ import { nowISO } from "../shared/time";
8
+
9
+ export function createAstroRepairTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
10
+ const { ctx, config, db } = opts;
11
+
12
+ return tool({
13
+ description: "Repair Astrocode invariants and recover from inconsistent DB state. Writes a repair report artifact.",
14
+ args: {
15
+ write_report_artifact: tool.schema.boolean().default(true),
16
+ },
17
+ execute: async ({ write_report_artifact }) => {
18
+ const repoRoot = ctx.directory as string;
19
+ const report = withTx(db, () => repairState(db, config));
20
+ const md = formatRepairReport(report);
21
+
22
+ if (write_report_artifact) {
23
+ const rel = `.astro/repair/repair_${nowISO().replace(/[:.]/g, "-")}.md`;
24
+ const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: md, meta: { kind: "repair" } });
25
+ return md + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
26
+ }
27
+
28
+ return md;
29
+ },
30
+ });
31
+ }
@@ -0,0 +1,62 @@
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 { abortRun, getActiveRun, getStageRuns, getStory } from "../workflow/state-machine";
5
+
6
+ export function createAstroRunGetTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
7
+ const { db } = opts;
8
+
9
+ return tool({
10
+ description: "Get run details (and stage run statuses). Defaults to active run if run_id omitted.",
11
+ args: {
12
+ run_id: tool.schema.string().optional(),
13
+ include_stage_summaries: tool.schema.boolean().default(false),
14
+ },
15
+ execute: async ({ run_id, include_stage_summaries }) => {
16
+ const active = getActiveRun(db);
17
+ const rid = run_id ?? active?.run_id;
18
+ if (!rid) return "No active run.";
19
+
20
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid) as any;
21
+ if (!run) throw new Error(`Run not found: ${rid}`);
22
+
23
+ const story = getStory(db, run.story_key);
24
+ const stages = getStageRuns(db, rid);
25
+
26
+ const lines: string[] = [];
27
+ lines.push(`# Run ${rid}`);
28
+ lines.push(`- Status: **${run.status}**`);
29
+ lines.push(`- Story: \`${run.story_key}\` — ${story?.title ?? "(missing)"}`);
30
+ lines.push(`- Current stage: \`${run.current_stage_key ?? "?"}\``);
31
+
32
+ lines.push("", "## Stages");
33
+ for (const s of stages) {
34
+ lines.push(`- \`${s.stage_key}\` (${s.status})`);
35
+ if (include_stage_summaries && s.summary_md) {
36
+ lines.push(` - summary: ${s.summary_md.split("\n")[0].slice(0, 120)}${s.summary_md.length > 120 ? "…" : ""}`);
37
+ }
38
+ }
39
+
40
+ return lines.join("\n").trim();
41
+ },
42
+ });
43
+ }
44
+
45
+ export function createAstroRunAbortTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
46
+ const { db } = opts;
47
+
48
+ return tool({
49
+ description: "Abort a run and unlock its story (returns story to approved). Defaults to active run if run_id omitted.",
50
+ args: {
51
+ run_id: tool.schema.string().optional(),
52
+ reason: tool.schema.string().default("aborted by user"),
53
+ },
54
+ execute: async ({ run_id, reason }) => {
55
+ const active = getActiveRun(db);
56
+ const rid = run_id ?? active?.run_id;
57
+ if (!rid) return "No active run to abort.";
58
+ abortRun(db, rid, reason);
59
+ return `šŸ›‘ Aborted run ${rid}. Reason: ${reason}`;
60
+ },
61
+ });
62
+ }
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
4
+ import type { AstrocodeConfig } from "../config/schema";
5
+ import type { SqliteDb } from "../state/db";
6
+ import { getAstroPaths, ensureAstroDirs } from "../shared/paths";
7
+ import { nowISO } from "../shared/time";
8
+ import { sha256Hex } from "../shared/hash";
9
+
10
+ export function createAstroSpecGetTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
11
+ const { ctx, config, db } = opts;
12
+
13
+ return tool({
14
+ description: "Get current project spec stored at .astro/spec.md",
15
+ args: {},
16
+ execute: async () => {
17
+ const repoRoot = ctx.directory as string;
18
+ const paths = getAstroPaths(repoRoot, config.db.path);
19
+ ensureAstroDirs(paths);
20
+
21
+ if (!fs.existsSync(paths.specPath)) return "No spec found at .astro/spec.md (run astro_init or astro_spec_set).";
22
+ const md = fs.readFileSync(paths.specPath, "utf-8");
23
+ return md;
24
+ },
25
+ });
26
+ }
27
+
28
+ export function createAstroSpecSetTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
29
+ const { ctx, config, db } = opts;
30
+
31
+ return tool({
32
+ description: "Set/replace the project spec at .astro/spec.md and record its hash in the DB.",
33
+ args: {
34
+ spec_md: tool.schema.string().min(1),
35
+ },
36
+ execute: async ({ spec_md }) => {
37
+ const repoRoot = ctx.directory as string;
38
+ const paths = getAstroPaths(repoRoot, config.db.path);
39
+ ensureAstroDirs(paths);
40
+
41
+ fs.writeFileSync(paths.specPath, spec_md);
42
+ const h = sha256Hex(spec_md);
43
+ const now = nowISO();
44
+
45
+ db.prepare("UPDATE repo_state SET spec_hash_after=?, updated_at=? WHERE id=1").run(h, now);
46
+
47
+ return `āœ… Spec updated (${path.relative(repoRoot, paths.specPath)}). sha256=${h}`;
48
+ },
49
+ });
50
+ }
@@ -0,0 +1,361 @@
1
+ import path from "node:path";
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import type { AstrocodeConfig } from "../config/schema";
4
+ import type { SqliteDb } from "../state/db";
5
+ import { withTx } from "../state/db";
6
+ import type { StageKey, StageRunRow } from "../state/types";
7
+ import { buildBatonSummary, parseStageOutputText } from "../workflow/baton";
8
+ import { buildContextSnapshot } from "../workflow/context";
9
+ import { putArtifact } from "../workflow/artifacts";
10
+ import { nowISO } from "../shared/time";
11
+ import { getAstroPaths, ensureAstroDirs, toPosix } from "../shared/paths";
12
+ import { failRun, getActiveRun, getStageRuns, startStage, completeRun } from "../workflow/state-machine";
13
+ import { newEventId } from "../state/ids";
14
+ import { insertStory } from "../workflow/story-helpers";
15
+
16
+ function nextStageKey(pipeline: StageKey[], current: StageKey): StageKey | null {
17
+ const i = pipeline.indexOf(current);
18
+ if (i === -1) return null;
19
+ return pipeline[i + 1] ?? null;
20
+ }
21
+
22
+ function ensureStageMatches(run: any, stage_key: StageKey) {
23
+ if (run.current_stage_key && run.current_stage_key !== stage_key) {
24
+ throw new Error(`Stage mismatch: run.current_stage_key=${run.current_stage_key} but got stage_key=${stage_key}`);
25
+ }
26
+ }
27
+
28
+ export function createAstroStageStartTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
29
+ const { config, db } = opts;
30
+
31
+ return tool({
32
+ description: "Start a stage for a run (sets stage_run.status=running). Usually called by astro_workflow_proceed.",
33
+ args: {
34
+ run_id: tool.schema.string().optional(),
35
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]).optional(),
36
+ subagent_type: tool.schema.string().optional(),
37
+ subagent_session_id: tool.schema.string().optional(),
38
+ },
39
+ execute: async ({ run_id, stage_key, subagent_type, subagent_session_id }) => {
40
+ const active = getActiveRun(db);
41
+ const rid = run_id ?? active?.run_id;
42
+ if (!rid) throw new Error("No active run and no run_id provided.");
43
+
44
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid) as any;
45
+ if (!run) throw new Error(`Run not found: ${rid}`);
46
+
47
+ const sk = (stage_key ?? run.current_stage_key) as StageKey;
48
+ if (!sk) throw new Error("No stage_key provided and run.current_stage_key is null.");
49
+
50
+ startStage(db, rid, sk, { subagent_type, subagent_session_id });
51
+
52
+ return `🟦 Started stage ${sk} for run ${rid}`;
53
+ },
54
+ });
55
+ }
56
+
57
+ export function createAstroStageCompleteTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
58
+ const { ctx, config, db } = opts;
59
+
60
+ return tool({
61
+ description:
62
+ "Complete a stage from stage-agent output text. Writes baton artifacts, updates stage_runs, advances pipeline, and can auto-queue split stories.",
63
+ args: {
64
+ run_id: tool.schema.string().optional(),
65
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]).optional(),
66
+
67
+ // Pass FULL stage-agent message text. This tool parses baton + ASTRO JSON markers.
68
+ output_text: tool.schema.string().min(1),
69
+
70
+ allow_new_stories: tool.schema.boolean().default(true),
71
+ relation_reason: tool.schema.string().default("split from stage output"),
72
+ },
73
+ execute: async ({ run_id, stage_key, output_text, allow_new_stories, relation_reason }) => {
74
+ const repoRoot = ctx.directory as string;
75
+ const paths = getAstroPaths(repoRoot, config.db.path);
76
+ ensureAstroDirs(paths);
77
+
78
+ const active = getActiveRun(db);
79
+ const rid = run_id ?? active?.run_id;
80
+ if (!rid) throw new Error("No active run and no run_id provided.");
81
+
82
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid) as any;
83
+ if (!run) throw new Error(`Run not found: ${rid}`);
84
+
85
+ const sk = (stage_key ?? run.current_stage_key) as StageKey;
86
+ if (!sk) throw new Error("No stage_key provided and run.current_stage_key is null.");
87
+
88
+ const stageRow = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(rid, sk) as StageRunRow | undefined;
89
+ if (!stageRow) throw new Error(`Stage run not found: ${rid}/${sk}`);
90
+ if (stageRow.status !== "running") {
91
+ throw new Error(`Stage ${sk} is not running (status=${stageRow.status}). Start it first.`);
92
+ }
93
+
94
+ const parsed = parseStageOutputText(output_text);
95
+ if (parsed.error || !parsed.astro_json) throw new Error(parsed.error ?? "ASTRO JSON missing");
96
+
97
+ if (parsed.astro_json.stage_key !== sk) {
98
+ throw new Error(`ASTRO JSON stage_key mismatch: expected ${sk}, got ${parsed.astro_json.stage_key}`);
99
+ }
100
+
101
+ // Evidence requirement
102
+ const evidenceRequired =
103
+ (sk === "verify" && config.workflow.evidence_required.verify) ||
104
+ (sk === "implement" && config.workflow.evidence_required.implement);
105
+
106
+ if (evidenceRequired && (parsed.astro_json.evidence ?? []).length === 0) {
107
+ throw new Error(`Evidence is required for stage ${sk} but ASTRO JSON evidence[] is empty.`);
108
+ }
109
+
110
+ const batonSummary = buildBatonSummary({ config, stage_key: sk, astro_json: parsed.astro_json, baton_md: parsed.baton_md });
111
+
112
+ const now = nowISO();
113
+ const pipeline = JSON.parse(run.pipeline_stages_json ?? "[]") as StageKey[];
114
+ const next = nextStageKey(pipeline, sk);
115
+
116
+ const stageDirRel = toPosix(path.join(".astro", "runs", rid, sk));
117
+ const batonRel = toPosix(path.join(stageDirRel, config.artifacts.baton_filename));
118
+ const summaryRel = toPosix(path.join(stageDirRel, config.artifacts.baton_summary_filename));
119
+ const jsonRel = toPosix(path.join(stageDirRel, config.artifacts.baton_json_filename));
120
+
121
+ const created: { artifact_id: string; path: string }[] = [];
122
+
123
+ const result = withTx(db, () => {
124
+ // Persist artifacts
125
+ if (config.artifacts.write_full_baton_md) {
126
+ const a = putArtifact({
127
+ repoRoot,
128
+ db,
129
+ run_id: rid,
130
+ stage_key: sk,
131
+ type: "baton",
132
+ rel_path: batonRel,
133
+ content: parsed.baton_md,
134
+ meta: { stage_key: sk },
135
+ });
136
+ created.push({ artifact_id: a.artifact_id, path: batonRel });
137
+ }
138
+
139
+ if (config.artifacts.write_baton_summary_md) {
140
+ const a = putArtifact({
141
+ repoRoot,
142
+ db,
143
+ run_id: rid,
144
+ stage_key: sk,
145
+ type: "summary",
146
+ rel_path: summaryRel,
147
+ content: batonSummary,
148
+ meta: { stage_key: sk },
149
+ });
150
+ created.push({ artifact_id: a.artifact_id, path: summaryRel });
151
+ }
152
+
153
+ if (config.artifacts.write_baton_output_json) {
154
+ const a = putArtifact({
155
+ repoRoot,
156
+ db,
157
+ run_id: rid,
158
+ stage_key: sk,
159
+ type: "baton",
160
+ rel_path: jsonRel,
161
+ content: JSON.stringify(parsed.astro_json, null, 2),
162
+ meta: { stage_key: sk, schema_version: parsed.astro_json.schema_version },
163
+ });
164
+ created.push({ artifact_id: a.artifact_id, path: jsonRel });
165
+ }
166
+
167
+ // Update stage_runs row
168
+ db.prepare(
169
+ "UPDATE stage_runs SET status=?, completed_at=?, updated_at=?, baton_path=?, summary_md=?, output_json=?, error_text=NULL WHERE stage_run_id=?"
170
+ ).run(
171
+ parsed.astro_json.status === "ok" ? "completed" : "failed",
172
+ now,
173
+ now,
174
+ batonRel,
175
+ batonSummary,
176
+ parsed.astro_json_raw ?? JSON.stringify(parsed.astro_json),
177
+ stageRow.stage_run_id
178
+ );
179
+
180
+ // stage event
181
+ db.prepare(
182
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
183
+ ).run(
184
+ newEventId(),
185
+ rid,
186
+ sk,
187
+ parsed.astro_json.status === "ok" ? "stage.completed" : parsed.astro_json.status === "blocked" ? "stage.blocked" : "stage.failed",
188
+ JSON.stringify({ artifacts: created, metrics: parsed.astro_json.metrics ?? {} }),
189
+ now
190
+ );
191
+
192
+ // Metrics
193
+ if (parsed.astro_json.metrics && typeof parsed.astro_json.metrics === "object") {
194
+ const ins = db.prepare(
195
+ "INSERT INTO workflow_metrics (metric_id, run_id, stage_key, name, value_num, value_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
196
+ );
197
+ for (const [k, v] of Object.entries(parsed.astro_json.metrics)) {
198
+ const metricId = `metric_${Date.now()}_${Math.random().toString(16).slice(2)}`;
199
+ if (typeof v === "number") ins.run(metricId, rid, sk, k, v, null, now);
200
+ else ins.run(metricId, rid, sk, k, null, String(v), now);
201
+ }
202
+ }
203
+
204
+ // New stories (split)
205
+ const newStoryKeys: string[] = [];
206
+ if (allow_new_stories && parsed.astro_json.new_stories?.length) {
207
+ for (const ns of parsed.astro_json.new_stories) {
208
+ const key = insertStory(db, { title: ns.title, body_md: ns.body_md ?? "", priority: ns.priority ?? 0, state: "queued" });
209
+ newStoryKeys.push(key);
210
+ db.prepare(
211
+ "INSERT OR IGNORE INTO story_relations (parent_story_key, child_story_key, relation_type, reason, created_at) VALUES (?, ?, 'split', ?, ?)"
212
+ ).run(run.story_key, key, relation_reason, now);
213
+ }
214
+ }
215
+
216
+ if (parsed.astro_json.status !== "ok") {
217
+ const err = parsed.astro_json.status === "blocked"
218
+ ? `blocked: ${(parsed.astro_json.questions?.[0] ?? "needs input")}`
219
+ : `failed: ${(parsed.astro_json.summary ?? "stage failed")}`;
220
+ // Mark run failed (also unlocks story)
221
+ failRun(db, rid, sk, err);
222
+ return { ok: false as const, next_stage: null as StageKey | null, new_stories: newStoryKeys, error: err };
223
+ }
224
+
225
+ // Advance run.current_stage_key
226
+ if (next) {
227
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(next, now, rid);
228
+ } else {
229
+ db.prepare("UPDATE runs SET current_stage_key=NULL, updated_at=? WHERE run_id=?").run(now, rid);
230
+ }
231
+
232
+ // If last stage, complete run
233
+ const stageRuns = getStageRuns(db, rid);
234
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
235
+ if (!incomplete) {
236
+ completeRun(db, rid);
237
+ return { ok: true as const, next_stage: null as StageKey | null, new_stories: newStoryKeys, completed_run: true };
238
+ }
239
+
240
+ return { ok: true as const, next_stage: next, new_stories: newStoryKeys, completed_run: false };
241
+ });
242
+
243
+ const context = buildContextSnapshot({ db, config, run_id: rid });
244
+
245
+ const lines: string[] = [];
246
+ if (result.ok) {
247
+ lines.push(`āœ… Stage ${sk} completed for run ${rid}.`);
248
+ } else {
249
+ lines.push(`ā›” Stage ${sk} ended with status ${parsed.astro_json.status} for run ${rid}.`);
250
+ lines.push(`Reason: ${result.error}`);
251
+ }
252
+
253
+ lines.push(``, `Artifacts:`);
254
+ for (const a of created) lines.push(`- ${a.path} (id=${a.artifact_id})`);
255
+
256
+ if (result.new_stories?.length) {
257
+ lines.push(``, `New stories queued: ${result.new_stories.map((k) => `\`${k}\``).join(", ")}`);
258
+ }
259
+
260
+ if (result.ok) {
261
+ if (result.completed_run) {
262
+ lines.push(``, `šŸŽ‰ Run completed.`);
263
+ } else if (result.next_stage) {
264
+ lines.push(``, `Next stage: \`${result.next_stage}\``);
265
+ }
266
+ }
267
+
268
+ lines.push(``, `Context snapshot (post-update):`);
269
+ lines.push(context);
270
+
271
+ return lines.join("\n").trim();
272
+ },
273
+ });
274
+ }
275
+
276
+ export function createAstroStageFailTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
277
+ const { db } = opts;
278
+
279
+ return tool({
280
+ description: "Manually fail a stage and mark run failed.",
281
+ args: {
282
+ run_id: tool.schema.string().optional(),
283
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]).optional(),
284
+ error_text: tool.schema.string().min(1),
285
+ },
286
+ execute: async ({ run_id, stage_key, error_text }) => {
287
+ const active = getActiveRun(db);
288
+ const rid = run_id ?? active?.run_id;
289
+ if (!rid) throw new Error("No active run and no run_id provided.");
290
+
291
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid) as any;
292
+ if (!run) throw new Error(`Run not found: ${rid}`);
293
+
294
+ const sk = (stage_key ?? run.current_stage_key) as StageKey;
295
+ if (!sk) throw new Error("No stage_key provided and run.current_stage_key is null.");
296
+
297
+ // Update stage_run row too
298
+ const now = nowISO();
299
+ const stageRow = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(rid, sk) as StageRunRow | undefined;
300
+ if (!stageRow) throw new Error(`Stage run not found: ${rid}/${sk}`);
301
+
302
+ withTx(db, () => {
303
+ db.prepare(
304
+ "UPDATE stage_runs SET status='failed', completed_at=?, updated_at=?, error_text=? WHERE stage_run_id=?"
305
+ ).run(now, now, error_text, stageRow.stage_run_id);
306
+
307
+ db.prepare(
308
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'stage.failed', ?, ?)"
309
+ ).run(newEventId(), rid, sk, JSON.stringify({ error_text }), now);
310
+
311
+ failRun(db, rid, sk, error_text);
312
+ });
313
+
314
+ return `ā›” Failed stage ${sk} and marked run ${rid} failed: ${error_text}`;
315
+ },
316
+ });
317
+ }
318
+
319
+ export function createAstroStageResetTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
320
+ const { db } = opts;
321
+
322
+ return tool({
323
+ description:
324
+ "Admin: reset a stage (and later stages) back to pending for a run. Re-opens run as running. Use carefully.",
325
+ args: {
326
+ run_id: tool.schema.string(),
327
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]),
328
+ note: tool.schema.string().default("reset by user"),
329
+ },
330
+ execute: async ({ run_id, stage_key, note }) => {
331
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(run_id) as any;
332
+ if (!run) throw new Error(`Run not found: ${run_id}`);
333
+
334
+ const now = nowISO();
335
+ const pipeline = JSON.parse(run.pipeline_stages_json ?? "[]") as StageKey[];
336
+ const idx = pipeline.indexOf(stage_key);
337
+ if (idx === -1) throw new Error(`Stage ${stage_key} not in pipeline for run ${run_id}`);
338
+
339
+ withTx(db, () => {
340
+ // Reset selected stage + later stages
341
+ db.prepare(
342
+ "UPDATE stage_runs SET status='pending', started_at=NULL, completed_at=NULL, baton_path=NULL, summary_md=NULL, output_json=NULL, error_text=NULL, subagent_session_id=NULL, updated_at=? WHERE run_id=? AND stage_index>=?"
343
+ ).run(now, run_id, idx);
344
+
345
+ // Re-open run
346
+ db.prepare("UPDATE runs SET status='running', error_text=NULL, completed_at=NULL, current_stage_key=?, updated_at=? WHERE run_id=?").run(stage_key, now, run_id);
347
+
348
+ // Re-lock story
349
+ db.prepare(
350
+ "UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=COALESCE(locked_at, ?), updated_at=? WHERE story_key=?"
351
+ ).run(run_id, now, now, run.story_key);
352
+
353
+ db.prepare(
354
+ "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'stage.reset', ?, ?)"
355
+ ).run(newEventId(), run_id, stage_key, JSON.stringify({ note }), now);
356
+ });
357
+
358
+ return `šŸ”„ Reset stage ${stage_key} (and later) for run ${run_id}.`;
359
+ },
360
+ });
361
+ }
@@ -0,0 +1,119 @@
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 { decideNextAction, getActiveRun, getStageRuns, getStory } from "../workflow/state-machine";
5
+
6
+ function statusIcon(status: string): string {
7
+ switch (status) {
8
+ case "running":
9
+ return "🟦";
10
+ case "completed":
11
+ return "āœ…";
12
+ case "failed":
13
+ return "ā›”";
14
+ case "aborted":
15
+ return "šŸ›‘";
16
+ case "created":
17
+ return "šŸ†•";
18
+ default:
19
+ return "⬜";
20
+ }
21
+ }
22
+
23
+ function stageIcon(status: string): string {
24
+ switch (status) {
25
+ case "completed":
26
+ return "āœ…";
27
+ case "running":
28
+ return "🟦";
29
+ case "failed":
30
+ return "ā›”";
31
+ case "skipped":
32
+ return "ā­ļø";
33
+ default:
34
+ return "⬜";
35
+ }
36
+ }
37
+
38
+ export function createAstroStatusTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
39
+ const { config, db } = opts;
40
+
41
+ return tool({
42
+ description: "Show a compact Astrocode status dashboard: active run/stage, pipeline, story board counts, and next action.",
43
+ args: {
44
+ include_board: tool.schema.boolean().default(true),
45
+ include_recent_events: tool.schema.boolean().default(false),
46
+ },
47
+ execute: async ({ include_board, include_recent_events }) => {
48
+ // Check if database is available
49
+ if (!db) {
50
+ return "šŸ”„ Astrocode Status\n\nāš ļø Limited Mode: Database not available\nAstrocode is running with reduced functionality";
51
+ }
52
+
53
+ const active = getActiveRun(db);
54
+
55
+ const lines: string[] = [];
56
+ lines.push(`# Astrocode Status`);
57
+
58
+ if (!active) {
59
+ lines.push(`- Active run: *(none)*`);
60
+ const next = decideNextAction(db, config);
61
+ if (next.kind === "idle") lines.push(`- Next: ${next.reason === "no_approved_stories" ? "No approved stories." : "Idle."}`);
62
+
63
+ if (include_board) {
64
+ const counts = db
65
+ .prepare("SELECT state, COUNT(*) as c FROM stories GROUP BY state ORDER BY state")
66
+ .all() as Array<{ state: string; c: number }>;
67
+ lines.push(``, `## Story board`);
68
+ for (const row of counts) lines.push(`- ${row.state}: ${row.c}`);
69
+ }
70
+
71
+ return lines.join("\n");
72
+ }
73
+
74
+ const story = getStory(db, active.story_key);
75
+ const stageRuns = getStageRuns(db, active.run_id);
76
+
77
+ lines.push(`- Active run: \`${active.run_id}\` ${statusIcon(active.status)} **${active.status}**`);
78
+ lines.push(`- Story: \`${active.story_key}\` — ${story?.title ?? "(missing)"} (${story?.state ?? "?"})`);
79
+ lines.push(`- Current stage: \`${active.current_stage_key ?? "?"}\``);
80
+
81
+ lines.push(``, `## Pipeline`);
82
+ for (const s of stageRuns) {
83
+ lines.push(`- ${stageIcon(s.status)} \`${s.stage_key}\` (${s.status})`);
84
+ }
85
+
86
+ const next = decideNextAction(db, config);
87
+ lines.push(``, `## Next`);
88
+ lines.push(`- ${next.kind}`);
89
+
90
+ if (next.kind === "await_stage_completion") {
91
+ lines.push(`- Awaiting output for stage \`${next.stage_key}\`. When you have it, call **astro_stage_complete** with the stage output text.`);
92
+ } else if (next.kind === "delegate_stage") {
93
+ lines.push(`- Need to run stage \`${next.stage_key}\`. Use **astro_workflow_proceed** (or delegate to the matching stage agent).`);
94
+ } else if (next.kind === "complete_run") {
95
+ lines.push(`- All stages done. Run can be completed via **astro_workflow_proceed**.`);
96
+ } else if (next.kind === "failed") {
97
+ lines.push(`- Run failed at \`${next.stage_key}\`: ${next.error_text}`);
98
+ }
99
+
100
+ if (include_board) {
101
+ const counts = db
102
+ .prepare("SELECT state, COUNT(*) as c FROM stories GROUP BY state ORDER BY state")
103
+ .all() as Array<{ state: string; c: number }>;
104
+ lines.push(``, `## Story board`);
105
+ for (const row of counts) lines.push(`- ${row.state}: ${row.c}`);
106
+ }
107
+
108
+ if (include_recent_events) {
109
+ const evs = db
110
+ .prepare("SELECT created_at, type, run_id, stage_key FROM events ORDER BY created_at DESC LIMIT 10")
111
+ .all() as Array<{ created_at: string; type: string; run_id: string | null; stage_key: string | null }>;
112
+ lines.push(``, `## Recent events`);
113
+ for (const e of evs) lines.push(`- ${e.created_at} — ${e.type}${e.run_id ? ` (run=${e.run_id})` : ""}${e.stage_key ? ` stage=${e.stage_key}` : ""}`);
114
+ }
115
+
116
+ return lines.join("\n");
117
+ },
118
+ });
119
+ }