astrocode-workflow 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/index.js +6 -0
  2. package/dist/shared/metrics.d.ts +66 -0
  3. package/dist/shared/metrics.js +112 -0
  4. package/dist/src/agents/commands.d.ts +9 -0
  5. package/dist/src/agents/commands.js +121 -0
  6. package/dist/src/agents/prompts.d.ts +3 -0
  7. package/dist/src/agents/prompts.js +232 -0
  8. package/dist/src/agents/registry.d.ts +6 -0
  9. package/dist/src/agents/registry.js +242 -0
  10. package/dist/src/agents/types.d.ts +14 -0
  11. package/dist/src/agents/types.js +8 -0
  12. package/dist/src/config/config-handler.d.ts +4 -0
  13. package/dist/src/config/config-handler.js +46 -0
  14. package/dist/src/config/defaults.d.ts +3 -0
  15. package/dist/src/config/defaults.js +3 -0
  16. package/dist/src/config/loader.d.ts +11 -0
  17. package/dist/src/config/loader.js +82 -0
  18. package/dist/src/config/schema.d.ts +194 -0
  19. package/dist/src/config/schema.js +223 -0
  20. package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
  21. package/dist/src/hooks/continuation-enforcer.js +190 -0
  22. package/dist/src/hooks/inject-provider.d.ts +22 -0
  23. package/dist/src/hooks/inject-provider.js +120 -0
  24. package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
  25. package/dist/src/hooks/tool-output-truncator.js +57 -0
  26. package/dist/src/index.d.ts +3 -0
  27. package/dist/src/index.js +308 -0
  28. package/dist/src/shared/deep-merge.d.ts +8 -0
  29. package/dist/src/shared/deep-merge.js +25 -0
  30. package/dist/src/shared/hash.d.ts +1 -0
  31. package/dist/src/shared/hash.js +4 -0
  32. package/dist/src/shared/log.d.ts +7 -0
  33. package/dist/src/shared/log.js +24 -0
  34. package/dist/src/shared/metrics.d.ts +66 -0
  35. package/dist/src/shared/metrics.js +112 -0
  36. package/dist/src/shared/model-tuning.d.ts +9 -0
  37. package/dist/src/shared/model-tuning.js +28 -0
  38. package/dist/src/shared/paths.d.ts +19 -0
  39. package/dist/src/shared/paths.js +64 -0
  40. package/dist/src/shared/text.d.ts +4 -0
  41. package/dist/src/shared/text.js +19 -0
  42. package/dist/src/shared/time.d.ts +1 -0
  43. package/dist/src/shared/time.js +3 -0
  44. package/dist/src/state/adapters/index.d.ts +41 -0
  45. package/dist/src/state/adapters/index.js +115 -0
  46. package/dist/src/state/db.d.ts +16 -0
  47. package/dist/src/state/db.js +225 -0
  48. package/dist/src/state/ids.d.ts +8 -0
  49. package/dist/src/state/ids.js +25 -0
  50. package/dist/src/state/repo-lock.d.ts +3 -0
  51. package/dist/src/state/repo-lock.js +29 -0
  52. package/dist/src/state/schema.d.ts +2 -0
  53. package/dist/src/state/schema.js +251 -0
  54. package/dist/src/state/types.d.ts +71 -0
  55. package/dist/src/state/types.js +1 -0
  56. package/dist/src/tools/artifacts.d.ts +18 -0
  57. package/dist/src/tools/artifacts.js +71 -0
  58. package/dist/src/tools/health.d.ts +8 -0
  59. package/dist/src/tools/health.js +119 -0
  60. package/dist/src/tools/index.d.ts +20 -0
  61. package/dist/src/tools/index.js +94 -0
  62. package/dist/src/tools/init.d.ts +17 -0
  63. package/dist/src/tools/init.js +96 -0
  64. package/dist/src/tools/injects.d.ts +53 -0
  65. package/dist/src/tools/injects.js +325 -0
  66. package/dist/src/tools/metrics.d.ts +7 -0
  67. package/dist/src/tools/metrics.js +61 -0
  68. package/dist/src/tools/repair.d.ts +8 -0
  69. package/dist/src/tools/repair.js +25 -0
  70. package/dist/src/tools/reset.d.ts +8 -0
  71. package/dist/src/tools/reset.js +92 -0
  72. package/dist/src/tools/run.d.ts +13 -0
  73. package/dist/src/tools/run.js +54 -0
  74. package/dist/src/tools/spec.d.ts +12 -0
  75. package/dist/src/tools/spec.js +44 -0
  76. package/dist/src/tools/stage.d.ts +23 -0
  77. package/dist/src/tools/stage.js +371 -0
  78. package/dist/src/tools/status.d.ts +8 -0
  79. package/dist/src/tools/status.js +125 -0
  80. package/dist/src/tools/story.d.ts +23 -0
  81. package/dist/src/tools/story.js +85 -0
  82. package/dist/src/tools/workflow.d.ts +13 -0
  83. package/dist/src/tools/workflow.js +355 -0
  84. package/dist/src/ui/inject.d.ts +12 -0
  85. package/dist/src/ui/inject.js +107 -0
  86. package/dist/src/ui/toasts.d.ts +13 -0
  87. package/dist/src/ui/toasts.js +39 -0
  88. package/dist/src/workflow/artifacts.d.ts +24 -0
  89. package/dist/src/workflow/artifacts.js +45 -0
  90. package/dist/src/workflow/baton.d.ts +72 -0
  91. package/dist/src/workflow/baton.js +166 -0
  92. package/dist/src/workflow/context.d.ts +20 -0
  93. package/dist/src/workflow/context.js +113 -0
  94. package/dist/src/workflow/directives.d.ts +39 -0
  95. package/dist/src/workflow/directives.js +137 -0
  96. package/dist/src/workflow/repair.d.ts +8 -0
  97. package/dist/src/workflow/repair.js +99 -0
  98. package/dist/src/workflow/state-machine.d.ts +86 -0
  99. package/dist/src/workflow/state-machine.js +216 -0
  100. package/dist/src/workflow/story-helpers.d.ts +9 -0
  101. package/dist/src/workflow/story-helpers.js +13 -0
  102. package/dist/state/db.d.ts +1 -0
  103. package/dist/state/db.js +9 -0
  104. package/dist/state/repo-lock.d.ts +3 -0
  105. package/dist/state/repo-lock.js +29 -0
  106. package/dist/test/integration/db-transactions.test.d.ts +1 -0
  107. package/dist/test/integration/db-transactions.test.js +126 -0
  108. package/dist/test/integration/injection-metrics.test.d.ts +1 -0
  109. package/dist/test/integration/injection-metrics.test.js +129 -0
  110. package/dist/tools/health.d.ts +8 -0
  111. package/dist/tools/health.js +119 -0
  112. package/dist/tools/index.js +9 -0
  113. package/dist/tools/metrics.d.ts +7 -0
  114. package/dist/tools/metrics.js +61 -0
  115. package/dist/tools/reset.d.ts +8 -0
  116. package/dist/tools/reset.js +92 -0
  117. package/dist/tools/workflow.js +210 -215
  118. package/dist/ui/inject.d.ts +6 -0
  119. package/dist/ui/inject.js +86 -67
  120. package/dist/workflow/state-machine.d.ts +32 -32
  121. package/dist/workflow/state-machine.js +85 -170
  122. package/package.json +6 -3
  123. package/src/index.ts +8 -0
  124. package/src/shared/metrics.ts +148 -0
  125. package/src/state/db.ts +10 -1
  126. package/src/state/repo-lock.ts +158 -0
  127. package/src/tools/health.ts +128 -0
  128. package/src/tools/index.ts +12 -3
  129. package/src/tools/init.ts +26 -14
  130. package/src/tools/metrics.ts +71 -0
  131. package/src/tools/repair.ts +21 -8
  132. package/src/tools/reset.ts +100 -0
  133. package/src/tools/stage.ts +12 -0
  134. package/src/tools/status.ts +17 -3
  135. package/src/tools/story.ts +41 -15
  136. package/src/tools/workflow.ts +123 -121
  137. package/src/ui/inject.ts +113 -79
  138. package/src/workflow/state-machine.ts +123 -227
  139. package/src/tools/workflow.ts.backup +0 -681
@@ -0,0 +1,13 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import type { AstrocodeConfig } from "../config/schema";
3
+ import type { SqliteDb } from "../state/db";
4
+ export declare function createAstroRunGetTool(opts: {
5
+ ctx: any;
6
+ config: AstrocodeConfig;
7
+ db: SqliteDb;
8
+ }): ToolDefinition;
9
+ export declare function createAstroRunAbortTool(opts: {
10
+ ctx: any;
11
+ config: AstrocodeConfig;
12
+ db: SqliteDb;
13
+ }): ToolDefinition;
@@ -0,0 +1,54 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+ import { abortRun, getActiveRun, getStageRuns, getStory } from "../workflow/state-machine";
3
+ export function createAstroRunGetTool(opts) {
4
+ const { db } = opts;
5
+ return tool({
6
+ description: "Get run details (and stage run statuses). Defaults to active run if run_id omitted.",
7
+ args: {
8
+ run_id: tool.schema.string().optional(),
9
+ include_stage_summaries: tool.schema.boolean().default(false),
10
+ },
11
+ execute: async ({ run_id, include_stage_summaries }) => {
12
+ const active = getActiveRun(db);
13
+ const rid = run_id ?? active?.run_id;
14
+ if (!rid)
15
+ return "No active run.";
16
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid);
17
+ if (!run)
18
+ throw new Error(`Run not found: ${rid}`);
19
+ const story = getStory(db, run.story_key);
20
+ const stages = getStageRuns(db, rid);
21
+ const lines = [];
22
+ lines.push(`# Run ${rid}`);
23
+ lines.push(`- Status: **${run.status}**`);
24
+ lines.push(`- Story: \`${run.story_key}\` — ${story?.title ?? "(missing)"}`);
25
+ lines.push(`- Current stage: \`${run.current_stage_key ?? "?"}\``);
26
+ lines.push("", "## Stages");
27
+ for (const s of stages) {
28
+ lines.push(`- \`${s.stage_key}\` (${s.status})`);
29
+ if (include_stage_summaries && s.summary_md) {
30
+ lines.push(` - summary: ${s.summary_md.split("\n")[0].slice(0, 120)}${s.summary_md.length > 120 ? "…" : ""}`);
31
+ }
32
+ }
33
+ return lines.join("\n").trim();
34
+ },
35
+ });
36
+ }
37
+ export function createAstroRunAbortTool(opts) {
38
+ const { db } = opts;
39
+ return tool({
40
+ description: "Abort a run and unlock its story (returns story to approved). Defaults to active run if run_id omitted.",
41
+ args: {
42
+ run_id: tool.schema.string().optional(),
43
+ reason: tool.schema.string().default("aborted by user"),
44
+ },
45
+ execute: async ({ run_id, reason }) => {
46
+ const active = getActiveRun(db);
47
+ const rid = run_id ?? active?.run_id;
48
+ if (!rid)
49
+ return "No active run to abort.";
50
+ abortRun(db, rid, reason);
51
+ return `🛑 Aborted run ${rid}. Reason: ${reason}`;
52
+ },
53
+ });
54
+ }
@@ -0,0 +1,12 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import type { AstrocodeConfig } from "../config/schema";
3
+ import type { SqliteDb } from "../state/db";
4
+ export declare function createAstroSpecGetTool(opts: {
5
+ ctx: any;
6
+ config: AstrocodeConfig;
7
+ }): ToolDefinition;
8
+ export declare function createAstroSpecSetTool(opts: {
9
+ ctx: any;
10
+ config: AstrocodeConfig;
11
+ db: SqliteDb;
12
+ }): ToolDefinition;
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { tool } from "@opencode-ai/plugin/tool";
4
+ import { getAstroPaths, ensureAstroDirs } from "../shared/paths";
5
+ import { nowISO } from "../shared/time";
6
+ import { sha256Hex } from "../shared/hash";
7
+ export function createAstroSpecGetTool(opts) {
8
+ const { ctx, config } = opts;
9
+ return tool({
10
+ description: "Get current project spec stored at .astro/spec.md",
11
+ args: {},
12
+ execute: async () => {
13
+ const repoRoot = ctx.directory;
14
+ const paths = getAstroPaths(repoRoot, config.db.path);
15
+ ensureAstroDirs(paths);
16
+ if (!fs.existsSync(paths.specPath))
17
+ return "No spec found at .astro/spec.md (run astro_init or astro_spec_set).";
18
+ const md = fs.readFileSync(paths.specPath, "utf-8");
19
+ return md;
20
+ },
21
+ });
22
+ }
23
+ export function createAstroSpecSetTool(opts) {
24
+ const { ctx, config, db } = opts;
25
+ return tool({
26
+ description: "Set/replace the project spec at .astro/spec.md and record its hash in the DB.",
27
+ args: {
28
+ spec_md: tool.schema.string().min(1),
29
+ },
30
+ execute: async ({ spec_md }) => {
31
+ if (!db) {
32
+ return "❌ Database not available. Cannot track spec hash. Astrocode is running in limited mode.";
33
+ }
34
+ const repoRoot = ctx.directory;
35
+ const paths = getAstroPaths(repoRoot, config.db.path);
36
+ ensureAstroDirs(paths);
37
+ fs.writeFileSync(paths.specPath, spec_md);
38
+ const h = sha256Hex(spec_md);
39
+ const now = nowISO();
40
+ db.prepare("UPDATE repo_state SET spec_hash_after=?, updated_at=? WHERE id=1").run(h, now);
41
+ return `✅ Spec updated (${path.relative(repoRoot, paths.specPath)}). sha256=${h}`;
42
+ },
43
+ });
44
+ }
@@ -0,0 +1,23 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import type { AstrocodeConfig } from "../config/schema";
3
+ import type { SqliteDb } from "../state/db";
4
+ export declare function createAstroStageStartTool(opts: {
5
+ ctx: any;
6
+ config: AstrocodeConfig;
7
+ db: SqliteDb;
8
+ }): ToolDefinition;
9
+ export declare function createAstroStageCompleteTool(opts: {
10
+ ctx: any;
11
+ config: AstrocodeConfig;
12
+ db: SqliteDb;
13
+ }): ToolDefinition;
14
+ export declare function createAstroStageFailTool(opts: {
15
+ ctx: any;
16
+ config: AstrocodeConfig;
17
+ db: SqliteDb;
18
+ }): ToolDefinition;
19
+ export declare function createAstroStageResetTool(opts: {
20
+ ctx: any;
21
+ config: AstrocodeConfig;
22
+ db: SqliteDb;
23
+ }): ToolDefinition;
@@ -0,0 +1,371 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { tool } from "@opencode-ai/plugin/tool";
4
+ import { withTx } from "../state/db";
5
+ import { buildBatonSummary, parseStageOutputText } from "../workflow/baton";
6
+ import { buildContextSnapshot } from "../workflow/context";
7
+ import { putArtifact } from "../workflow/artifacts";
8
+ import { nowISO } from "../shared/time";
9
+ import { getAstroPaths, ensureAstroDirs, toPosix } from "../shared/paths";
10
+ import { failRun, getActiveRun, getStageRuns, startStage, completeRun } from "../workflow/state-machine";
11
+ import { newEventId, newId } from "../state/ids";
12
+ import { insertStory } from "../workflow/story-helpers";
13
+ function nextStageKey(pipeline, current) {
14
+ const i = pipeline.indexOf(current);
15
+ if (i === -1)
16
+ return null;
17
+ return pipeline[i + 1] ?? null;
18
+ }
19
+ function ensureStageMatches(run, stage_key) {
20
+ if (run.current_stage_key && run.current_stage_key !== stage_key) {
21
+ throw new Error(`Stage mismatch: run.current_stage_key=${run.current_stage_key} but got stage_key=${stage_key}`);
22
+ }
23
+ }
24
+ function splitTasksIntoStories(db, tasks, run, now, newStoryKeys, relationReason) {
25
+ for (const task of tasks) {
26
+ const complexity = task.complexity ?? 5;
27
+ const subtasks = task.subtasks ?? [];
28
+ if (subtasks.length > 0) {
29
+ // Split into subtasks
30
+ for (const subtask of subtasks) {
31
+ const key = insertStory(db, {
32
+ title: `${task.title}: ${subtask}`,
33
+ body_md: task.description ?? "",
34
+ priority: Math.max(1, 10 - complexity),
35
+ state: "queued",
36
+ epic_key: run.story_key
37
+ });
38
+ newStoryKeys.push(key);
39
+ db.prepare("INSERT INTO story_relations (parent_story_key, child_story_key, relation_type, reason, created_at) VALUES (?, ?, ?, ?, ?)").run(run.story_key, key, "split", relationReason, now);
40
+ }
41
+ }
42
+ else if (complexity > 6) {
43
+ // Split complex tasks
44
+ const key = insertStory(db, {
45
+ title: task.title,
46
+ body_md: task.description ?? "",
47
+ priority: Math.max(1, 10 - complexity),
48
+ state: "queued",
49
+ epic_key: run.story_key
50
+ });
51
+ newStoryKeys.push(key);
52
+ db.prepare("INSERT INTO story_relations (parent_story_key, child_story_key, relation_type, reason, created_at) VALUES (?, ?, ?, ?, ?)").run(run.story_key, key, "split", relationReason, now);
53
+ }
54
+ }
55
+ }
56
+ export function createAstroStageStartTool(opts) {
57
+ const { config, db } = opts;
58
+ return tool({
59
+ description: "Start a stage for a run (sets stage_run.status=running). Usually called by astro_workflow_proceed.",
60
+ args: {
61
+ run_id: tool.schema.string().optional(),
62
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]).optional(),
63
+ subagent_type: tool.schema.string().optional(),
64
+ subagent_session_id: tool.schema.string().optional(),
65
+ },
66
+ execute: async ({ run_id, stage_key, subagent_type, subagent_session_id }) => {
67
+ const active = getActiveRun(db);
68
+ const rid = run_id ?? active?.run_id;
69
+ if (!rid)
70
+ throw new Error("No active run and no run_id provided.");
71
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid);
72
+ if (!run)
73
+ throw new Error(`Run not found: ${rid}`);
74
+ const sk = (stage_key ?? run.current_stage_key);
75
+ if (!sk)
76
+ throw new Error("No stage_key provided and run.current_stage_key is null.");
77
+ startStage(db, rid, sk, { subagent_type, subagent_session_id });
78
+ return `🟦 Started stage ${sk} for run ${rid}`;
79
+ },
80
+ });
81
+ }
82
+ export function createAstroStageCompleteTool(opts) {
83
+ const { ctx, config, db } = opts;
84
+ return tool({
85
+ description: "Complete a stage from stage-agent output text. Writes baton artifacts, updates stage_runs, advances pipeline, and can auto-queue split stories.",
86
+ args: {
87
+ run_id: tool.schema.string().optional(),
88
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]).optional(),
89
+ // Pass FULL stage-agent message text. This tool parses baton + ASTRO JSON markers.
90
+ output_text: tool.schema.string().min(1),
91
+ allow_new_stories: tool.schema.boolean().default(true),
92
+ relation_reason: tool.schema.string().default("split from stage output"),
93
+ },
94
+ execute: async ({ run_id, stage_key, output_text, allow_new_stories, relation_reason }) => {
95
+ const repoRoot = ctx.directory;
96
+ const paths = getAstroPaths(repoRoot, config.db.path);
97
+ ensureAstroDirs(paths);
98
+ const active = getActiveRun(db);
99
+ const rid = run_id ?? active?.run_id;
100
+ if (!rid)
101
+ return "❌ No active run found. Start a run first.";
102
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid);
103
+ if (!run)
104
+ return `❌ Run not found: ${rid}`;
105
+ const sk = (stage_key ?? run.current_stage_key);
106
+ if (!sk)
107
+ return "❌ No stage_key provided and run has no current stage.";
108
+ const stageRow = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(rid, sk);
109
+ if (!stageRow)
110
+ return `❌ Stage run not found: ${rid}/${sk}`;
111
+ if (stageRow.status !== "running") {
112
+ return `❌ Stage ${sk} is not running (status=${stageRow.status}). Start it first with astro_stage_start.`;
113
+ }
114
+ const parsed = parseStageOutputText(output_text);
115
+ if (parsed.error || !parsed.astro_json) {
116
+ return `❌ JSON Parse Error: ${parsed.error ?? "ASTRO JSON missing"}.
117
+
118
+ Expected format:
119
+ 1) Narrative summary (optional)
120
+ 2) Valid JSON between markers:
121
+ <!-- ASTRO_JSON_BEGIN -->
122
+ {
123
+ "schema_version": 1,
124
+ "stage_key": "${sk}",
125
+ "status": "ok",
126
+ "summary": "Brief summary",
127
+ "decisions": [],
128
+ "next_actions": [],
129
+ "tasks": [{"title": "Task name", "complexity": 3}],
130
+ "files": [{"path": "file.js", "kind": "file"}],
131
+ "evidence": [{"path": "test.js", "kind": "evidence"}],
132
+ "new_stories": [{"title": "Story title", "body_md": "Description"}],
133
+ "questions": [],
134
+ "metrics": {}
135
+ }
136
+ <!-- ASTRO_JSON_END -->
137
+
138
+ Ensure JSON has required fields (stage_key, status) and valid syntax.`;
139
+ }
140
+ // Override stage_key to match the expected stage (agents sometimes get this wrong)
141
+ parsed.astro_json.stage_key = sk;
142
+ // Context validation (warnings, not errors)
143
+ if (parsed.astro_json.run_id && parsed.astro_json.run_id !== rid) {
144
+ console.warn(`[Astrocode] ⚠️ Run ID mismatch in baton: expected "${rid}", got "${parsed.astro_json.run_id}". Proceeding anyway.`);
145
+ }
146
+ if (parsed.astro_json.story_key && parsed.astro_json.story_key !== run.story_key) {
147
+ console.warn(`[Astrocode] ⚠️ Story key mismatch in baton: expected "${run.story_key}", got "${parsed.astro_json.story_key}". Proceeding anyway.`);
148
+ }
149
+ // Evidence requirement
150
+ const evidenceRequired = (sk === "verify" && config.workflow.evidence_required.verify) ||
151
+ (sk === "implement" && config.workflow.evidence_required.implement);
152
+ if (evidenceRequired && (parsed.astro_json.evidence ?? []).length === 0) {
153
+ return `❌ Evidence is required for stage ${sk} but ASTRO JSON evidence[] is empty. Please provide evidence files.`;
154
+ }
155
+ const batonSummary = buildBatonSummary({ config, stage_key: sk, astro_json: parsed.astro_json, baton_md: parsed.baton_md });
156
+ const now = nowISO();
157
+ const pipeline = JSON.parse(run.pipeline_stages_json ?? "[]");
158
+ let next = nextStageKey(pipeline, sk);
159
+ const stageDirRel = toPosix(path.join(".astro", "runs", rid, sk));
160
+ const batonRel = toPosix(path.join(stageDirRel, config.artifacts.baton_filename));
161
+ const summaryRel = toPosix(path.join(stageDirRel, config.artifacts.baton_summary_filename));
162
+ const jsonRel = toPosix(path.join(stageDirRel, config.artifacts.baton_json_filename));
163
+ const created = [];
164
+ const result = withTx(db, () => {
165
+ // Persist artifacts
166
+ if (config.artifacts.write_full_baton_md) {
167
+ const a = putArtifact({
168
+ repoRoot,
169
+ db,
170
+ run_id: rid,
171
+ stage_key: sk,
172
+ type: "baton",
173
+ rel_path: batonRel,
174
+ content: parsed.baton_md,
175
+ meta: { stage_key: sk },
176
+ });
177
+ created.push({ artifact_id: a.artifact_id, path: batonRel });
178
+ }
179
+ if (config.artifacts.write_baton_summary_md) {
180
+ const a = putArtifact({
181
+ repoRoot,
182
+ db,
183
+ run_id: rid,
184
+ stage_key: sk,
185
+ type: "summary",
186
+ rel_path: summaryRel,
187
+ content: batonSummary,
188
+ meta: { stage_key: sk },
189
+ });
190
+ created.push({ artifact_id: a.artifact_id, path: summaryRel });
191
+ }
192
+ if (config.artifacts.write_baton_output_json) {
193
+ const a = putArtifact({
194
+ repoRoot,
195
+ db,
196
+ run_id: rid,
197
+ stage_key: sk,
198
+ type: "baton",
199
+ rel_path: jsonRel,
200
+ content: JSON.stringify(parsed.astro_json, null, 2),
201
+ meta: { stage_key: sk, schema_version: parsed.astro_json.schema_version },
202
+ });
203
+ created.push({ artifact_id: a.artifact_id, path: jsonRel });
204
+ }
205
+ // Update stage_runs row
206
+ db.prepare("UPDATE stage_runs SET status=?, completed_at=?, updated_at=?, baton_path=?, summary_md=?, output_json=?, error_text=NULL WHERE stage_run_id=?").run(parsed.astro_json.status === "ok" ? "completed" : "failed", now, now, batonRel, batonSummary, parsed.astro_json_raw ?? JSON.stringify(parsed.astro_json), stageRow.stage_run_id);
207
+ // stage event
208
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), rid, sk, parsed.astro_json.status === "ok" ? "stage.completed" : parsed.astro_json.status === "blocked" ? "stage.blocked" : "stage.failed", JSON.stringify({ artifacts: created, metrics: parsed.astro_json.metrics ?? {} }), now);
209
+ // Metrics
210
+ if (parsed.astro_json.metrics && typeof parsed.astro_json.metrics === "object") {
211
+ const ins = db.prepare("INSERT INTO workflow_metrics (metric_id, run_id, stage_key, name, value_num, value_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
212
+ for (const [k, v] of Object.entries(parsed.astro_json.metrics)) {
213
+ const metricId = `metric_${Date.now()}_${Math.random().toString(16).slice(2)}`;
214
+ if (typeof v === "number")
215
+ ins.run(metricId, rid, sk, k, v, null, now);
216
+ else
217
+ ins.run(metricId, rid, sk, k, null, String(v), now);
218
+ }
219
+ }
220
+ // New stories (split)
221
+ const newStoryKeys = [];
222
+ if (allow_new_stories && parsed.astro_json.new_stories?.length) {
223
+ for (const ns of parsed.astro_json.new_stories) {
224
+ const key = insertStory(db, { title: ns.title, body_md: ns.body_md ?? "", priority: ns.priority ?? 0, state: "queued" });
225
+ newStoryKeys.push(key);
226
+ db.prepare("INSERT INTO story_relations (parent_story_key, child_story_key, relation_type, reason, created_at) VALUES (?, ?, ?, ?, ?)").run(run.story_key, key, "split", relation_reason, now);
227
+ }
228
+ }
229
+ // Automatic story splitting for complex tasks
230
+ if (allow_new_stories && parsed.astro_json.tasks?.length) {
231
+ splitTasksIntoStories(db, parsed.astro_json.tasks, run, now, newStoryKeys, "split from stage output");
232
+ }
233
+ // Skip spec stage if spec already exists
234
+ if (sk === "plan" && next === "spec") {
235
+ const specPath = path.join(repoRoot, ".astro", "spec.md");
236
+ if (fs.existsSync(specPath) && fs.statSync(specPath).size > 100) {
237
+ // Skip spec
238
+ const specIndex = pipeline.indexOf("spec");
239
+ db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
240
+ .run(newId("stage"), rid, "spec", specIndex, "skipped", now, now);
241
+ next = "implement";
242
+ }
243
+ }
244
+ // Split stories during implementation if tasks are identified
245
+ if (sk === "implement" && allow_new_stories && parsed.astro_json.tasks?.length) {
246
+ splitTasksIntoStories(db, parsed.astro_json.tasks, run, now, newStoryKeys, "split from implement");
247
+ }
248
+ // Validate blocked status includes questions
249
+ if (parsed.astro_json.status === "blocked") {
250
+ if (!parsed.astro_json.questions || parsed.astro_json.questions.length === 0) {
251
+ return { ok: false, next_stage: null, new_stories: newStoryKeys, error: "Blocked status requires questions[] array to be non-empty. Add clarifying questions for user input." };
252
+ }
253
+ }
254
+ if (parsed.astro_json.status !== "ok") {
255
+ const err = parsed.astro_json.status === "blocked"
256
+ ? `blocked: ${(parsed.astro_json.questions?.[0] ?? "needs input")}`
257
+ : `failed: ${(parsed.astro_json.summary ?? "stage failed")}`;
258
+ // Mark run failed (also unlocks story)
259
+ failRun(db, rid, sk, err);
260
+ return { ok: false, next_stage: null, new_stories: newStoryKeys, error: err };
261
+ }
262
+ // Advance run.current_stage_key
263
+ if (next) {
264
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(next, now, rid);
265
+ }
266
+ else {
267
+ db.prepare("UPDATE runs SET current_stage_key=NULL, updated_at=? WHERE run_id=?").run(now, rid);
268
+ }
269
+ // If last stage, complete run
270
+ const stageRuns = getStageRuns(db, rid);
271
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
272
+ if (!incomplete) {
273
+ completeRun(db, rid);
274
+ return { ok: true, next_stage: null, new_stories: newStoryKeys, completed_run: true };
275
+ }
276
+ return { ok: true, next_stage: next, new_stories: newStoryKeys, completed_run: false };
277
+ });
278
+ const context = buildContextSnapshot({ db, config, run_id: rid });
279
+ const lines = [];
280
+ if (result.ok) {
281
+ lines.push(`✅ Stage ${sk} completed for run ${rid}.`);
282
+ }
283
+ else {
284
+ lines.push(`⛔ Stage ${sk} ended with status ${parsed.astro_json.status} for run ${rid}.`);
285
+ lines.push(`Reason: ${result.error}`);
286
+ }
287
+ lines.push(``, `Artifacts:`);
288
+ for (const a of created)
289
+ lines.push(`- ${a.path} (id=${a.artifact_id})`);
290
+ if (result.new_stories?.length) {
291
+ lines.push(``, `New stories queued: ${result.new_stories.map((k) => `\`${k}\``).join(", ")}`);
292
+ }
293
+ if (result.ok) {
294
+ if (result.completed_run) {
295
+ lines.push(``, `🎉 Run completed.`);
296
+ }
297
+ else if (result.next_stage) {
298
+ lines.push(``, `Next stage: \`${result.next_stage}\``);
299
+ }
300
+ }
301
+ lines.push(``, `Context snapshot (post-update):`);
302
+ lines.push(context);
303
+ return lines.join("\n").trim();
304
+ },
305
+ });
306
+ }
307
+ export function createAstroStageFailTool(opts) {
308
+ const { db } = opts;
309
+ return tool({
310
+ description: "Manually fail a stage and mark run failed.",
311
+ args: {
312
+ run_id: tool.schema.string().optional(),
313
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]).optional(),
314
+ error_text: tool.schema.string().min(1),
315
+ },
316
+ execute: async ({ run_id, stage_key, error_text }) => {
317
+ const active = getActiveRun(db);
318
+ const rid = run_id ?? active?.run_id;
319
+ if (!rid)
320
+ throw new Error("No active run and no run_id provided.");
321
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(rid);
322
+ if (!run)
323
+ throw new Error(`Run not found: ${rid}`);
324
+ const sk = (stage_key ?? run.current_stage_key);
325
+ if (!sk)
326
+ throw new Error("No stage_key provided and run.current_stage_key is null.");
327
+ // Update stage_run row too
328
+ const now = nowISO();
329
+ const stageRow = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(rid, sk);
330
+ if (!stageRow)
331
+ throw new Error(`Stage run not found: ${rid}/${sk}`);
332
+ withTx(db, () => {
333
+ db.prepare("UPDATE stage_runs SET status='failed', completed_at=?, updated_at=?, error_text=? WHERE stage_run_id=?").run(now, now, error_text, stageRow.stage_run_id);
334
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'stage.failed', ?, ?)").run(newEventId(), rid, sk, JSON.stringify({ error_text }), now);
335
+ failRun(db, rid, sk, error_text);
336
+ });
337
+ return `⛔ Failed stage ${sk} and marked run ${rid} failed: ${error_text}`;
338
+ },
339
+ });
340
+ }
341
+ export function createAstroStageResetTool(opts) {
342
+ const { db } = opts;
343
+ return tool({
344
+ description: "Admin: reset a stage (and later stages) back to pending for a run. Re-opens run as running. Use carefully.",
345
+ args: {
346
+ run_id: tool.schema.string(),
347
+ stage_key: tool.schema.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]),
348
+ note: tool.schema.string().default("reset by user"),
349
+ },
350
+ execute: async ({ run_id, stage_key, note }) => {
351
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(run_id);
352
+ if (!run)
353
+ throw new Error(`Run not found: ${run_id}`);
354
+ const now = nowISO();
355
+ const pipeline = JSON.parse(run.pipeline_stages_json ?? "[]");
356
+ const idx = pipeline.indexOf(stage_key);
357
+ if (idx === -1)
358
+ throw new Error(`Stage ${stage_key} not in pipeline for run ${run_id}`);
359
+ withTx(db, () => {
360
+ // Reset selected stage + later stages
361
+ db.prepare("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>=?").run(now, run_id, idx);
362
+ // Re-open run
363
+ 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);
364
+ // Re-lock story
365
+ db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=COALESCE(locked_at, ?), updated_at=? WHERE story_key=?").run(run_id, now, now, run.story_key);
366
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'stage.reset', ?, ?)").run(newEventId(), run_id, stage_key, JSON.stringify({ note }), now);
367
+ });
368
+ return `🔄 Reset stage ${stage_key} (and later) for run ${run_id}.`;
369
+ },
370
+ });
371
+ }
@@ -0,0 +1,8 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import type { AstrocodeConfig } from "../config/schema";
3
+ import type { SqliteDb } from "../state/db";
4
+ export declare function createAstroStatusTool(opts: {
5
+ ctx: any;
6
+ config: AstrocodeConfig;
7
+ db?: SqliteDb | null;
8
+ }): ToolDefinition;
@@ -0,0 +1,125 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+ import { decideNextAction, getActiveRun, getStageRuns, getStory } from "../workflow/state-machine";
3
+ function statusIcon(status) {
4
+ switch (status) {
5
+ case "running":
6
+ return "🟦";
7
+ case "completed":
8
+ return "✅";
9
+ case "failed":
10
+ return "⛔";
11
+ case "aborted":
12
+ return "🛑";
13
+ case "created":
14
+ return "🆕";
15
+ default:
16
+ return "⬜";
17
+ }
18
+ }
19
+ function stageIcon(status) {
20
+ switch (status) {
21
+ case "completed":
22
+ return "✅";
23
+ case "running":
24
+ return "🟦";
25
+ case "failed":
26
+ return "⛔";
27
+ case "skipped":
28
+ return "⏭️";
29
+ default:
30
+ return "⬜";
31
+ }
32
+ }
33
+ export function createAstroStatusTool(opts) {
34
+ const { config, db } = opts;
35
+ return tool({
36
+ description: "Show a compact Astrocode status dashboard: active run/stage, pipeline, story board counts, and next action.",
37
+ args: {
38
+ include_board: tool.schema.boolean().default(true),
39
+ include_recent_events: tool.schema.boolean().default(false),
40
+ },
41
+ execute: async ({ include_board, include_recent_events }) => {
42
+ if (!db) {
43
+ return [
44
+ `⚠️ Astrocode not initialized.`,
45
+ ``,
46
+ `- Reason: Database not available`,
47
+ ``,
48
+ `Next: run **astro_init**, then restart the agent/runtime, then run /astro-status.`,
49
+ ].join("\n");
50
+ }
51
+ try {
52
+ const active = getActiveRun(db);
53
+ const lines = [];
54
+ lines.push(`# Astrocode Status`);
55
+ if (!active) {
56
+ lines.push(`- Active run: *(none)*`);
57
+ const next = decideNextAction(db, config);
58
+ if (next.kind === "idle")
59
+ lines.push(`- Next: ${next.reason === "no_approved_stories" ? "No approved stories." : "Idle."}`);
60
+ if (include_board) {
61
+ const counts = db.prepare("SELECT state, COUNT(*) as c FROM stories GROUP BY state ORDER BY state").all();
62
+ lines.push(``, `## Story board`);
63
+ for (const row of counts)
64
+ lines.push(`- ${row.state}: ${row.c}`);
65
+ }
66
+ return lines.join("\n");
67
+ }
68
+ const story = getStory(db, active.story_key);
69
+ const stageRuns = getStageRuns(db, active.run_id);
70
+ lines.push(`- Active run: \`${active.run_id}\` ${statusIcon(active.status)} **${active.status}**`);
71
+ lines.push(`- Story: \`${active.story_key}\` — ${story?.title ?? "(missing)"} (${story?.state ?? "?"})`);
72
+ lines.push(`- Current stage: \`${active.current_stage_key ?? "?"}\``);
73
+ lines.push(``, `## Pipeline`);
74
+ for (const s of stageRuns)
75
+ lines.push(`- ${stageIcon(s.status)} \`${s.stage_key}\` (${s.status})`);
76
+ const next = decideNextAction(db, config);
77
+ lines.push(``, `## Next`, `- ${next.kind}`);
78
+ if (next.kind === "await_stage_completion") {
79
+ lines.push(`- Awaiting output for stage \`${next.stage_key}\`. When you have it, call **astro_stage_complete** with the stage output text.`);
80
+ }
81
+ else if (next.kind === "delegate_stage") {
82
+ lines.push(`- Need to run stage \`${next.stage_key}\`. Use **astro_workflow_proceed** (or delegate to the matching stage agent).`);
83
+ }
84
+ else if (next.kind === "complete_run") {
85
+ lines.push(`- All stages done. Run can be completed via **astro_workflow_proceed**.`);
86
+ }
87
+ else if (next.kind === "failed") {
88
+ lines.push(`- Run failed at \`${next.stage_key}\`: ${next.error_text}`);
89
+ }
90
+ if (include_board) {
91
+ const counts = db.prepare("SELECT state, COUNT(*) as c FROM stories GROUP BY state ORDER BY state").all();
92
+ lines.push(``, `## Story board`);
93
+ for (const row of counts)
94
+ lines.push(`- ${row.state}: ${row.c}`);
95
+ }
96
+ if (include_recent_events) {
97
+ const evs = db.prepare("SELECT created_at, type, run_id, stage_key FROM events ORDER BY created_at DESC LIMIT 10").all();
98
+ lines.push(``, `## Recent events`);
99
+ for (const e of evs)
100
+ lines.push(`- ${e.created_at} — ${e.type}${e.run_id ? ` (run=${e.run_id})` : ""}${e.stage_key ? ` stage=${e.stage_key}` : ""}`);
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+ catch (e) {
105
+ const msg = e instanceof Error ? e.message : String(e);
106
+ if (msg.includes("no such table") || msg.includes("no such column")) {
107
+ return [
108
+ `⚠️ Astrocode not initialized.`,
109
+ ``,
110
+ `- Reason: Database present but schema is not initialized or is incompatible`,
111
+ `- Error: ${msg}`,
112
+ ``,
113
+ `Next: run **astro_init**, then restart the agent/runtime, then run /astro-status.`,
114
+ ].join("\n");
115
+ }
116
+ return [
117
+ `# Astrocode Status`,
118
+ ``,
119
+ `⛔ Database error.`,
120
+ `Error: ${msg}`,
121
+ ].join("\n");
122
+ }
123
+ },
124
+ });
125
+ }