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,101 @@
1
+ import { clampLines, normalizeNewlines, stripCodeFences } from "../shared/text";
2
+ import { z } from "zod";
3
+ export const ASTRO_JSON_BEGIN = "<!-- ASTRO_JSON_BEGIN -->";
4
+ export const ASTRO_JSON_END = "<!-- ASTRO_JSON_END -->";
5
+ export const StageKeySchema = z.enum(["frame", "plan", "spec", "implement", "review", "verify", "close"]);
6
+ export const AstroJsonSchema = z.object({
7
+ schema_version: z.number().int().default(1),
8
+ run_id: z.string().optional(),
9
+ story_key: z.string().optional(),
10
+ stage_key: StageKeySchema,
11
+ status: z.enum(["ok", "blocked", "failed"]).default("ok"),
12
+ summary: z.string().default(""),
13
+ decisions: z.array(z.string()).default([]),
14
+ next_actions: z.array(z.string()).default([]),
15
+ files: z.array(z.object({
16
+ path: z.string(),
17
+ kind: z.string().default("file"),
18
+ notes: z.string().optional(),
19
+ })).default([]),
20
+ evidence: z.array(z.object({
21
+ path: z.string(),
22
+ kind: z.string().default("evidence"),
23
+ notes: z.string().optional(),
24
+ })).default([]),
25
+ new_stories: z.array(z.object({
26
+ title: z.string(),
27
+ body_md: z.string().optional(),
28
+ priority: z.number().int().optional(),
29
+ })).default([]),
30
+ questions: z.array(z.string()).default([]),
31
+ metrics: z.record(z.string(), z.union([z.number(), z.string()])).default({}),
32
+ });
33
+ export function parseStageOutputText(text) {
34
+ const norm = normalizeNewlines(text ?? "").trim();
35
+ const beginIdx = norm.indexOf(ASTRO_JSON_BEGIN);
36
+ const endIdx = norm.indexOf(ASTRO_JSON_END);
37
+ if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) {
38
+ return {
39
+ baton_md: norm,
40
+ astro_json: null,
41
+ astro_json_raw: null,
42
+ error: `Missing ASTRO JSON markers. Expected markers ${ASTRO_JSON_BEGIN} ... ${ASTRO_JSON_END}`,
43
+ };
44
+ }
45
+ const before = norm.slice(0, beginIdx).trim();
46
+ const jsonRaw = norm.slice(beginIdx + ASTRO_JSON_BEGIN.length, endIdx).trim();
47
+ const after = norm.slice(endIdx + ASTRO_JSON_END.length).trim();
48
+ const baton = [before, after].filter(Boolean).join("\n\n").trim();
49
+ try {
50
+ const cleaned = stripCodeFences(jsonRaw).trim();
51
+ const parsed = JSON.parse(cleaned);
52
+ const astroJson = AstroJsonSchema.parse(parsed);
53
+ return { baton_md: baton, astro_json: astroJson, astro_json_raw: cleaned, error: null };
54
+ }
55
+ catch (e) {
56
+ return {
57
+ baton_md: baton,
58
+ astro_json: null,
59
+ astro_json_raw: jsonRaw,
60
+ error: `Failed to parse ASTRO JSON: ${String(e)}`,
61
+ };
62
+ }
63
+ }
64
+ export function buildBatonSummary(opts) {
65
+ const { config, stage_key, astro_json, baton_md } = opts;
66
+ const maxLines = config.context_compaction.baton_summary_max_lines;
67
+ const lines = [];
68
+ lines.push(`# ${stage_key} — Summary`);
69
+ if (astro_json?.summary) {
70
+ lines.push("", astro_json.summary.trim());
71
+ }
72
+ else {
73
+ // Fallback: first non-empty paragraph
74
+ const paras = baton_md.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
75
+ if (paras[0])
76
+ lines.push("", paras[0]);
77
+ }
78
+ const addList = (title, items) => {
79
+ if (!items.length)
80
+ return;
81
+ lines.push("", `## ${title}`);
82
+ for (const it of items.slice(0, 12))
83
+ lines.push(`- ${it}`);
84
+ };
85
+ addList("Decisions", astro_json?.decisions ?? []);
86
+ addList("Next actions", astro_json?.next_actions ?? []);
87
+ const files = astro_json?.files ?? [];
88
+ if (files.length) {
89
+ lines.push("", "## Files");
90
+ for (const f of files.slice(0, 15))
91
+ lines.push(`- \`${f.path}\` (${f.kind})${f.notes ? ` — ${f.notes}` : ""}`);
92
+ }
93
+ const evidence = astro_json?.evidence ?? [];
94
+ if (evidence.length) {
95
+ lines.push("", "## Evidence");
96
+ for (const ev of evidence.slice(0, 15))
97
+ lines.push(`- \`${ev.path}\` (${ev.kind})${ev.notes ? ` — ${ev.notes}` : ""}`);
98
+ }
99
+ const out = lines.join("\n").trim();
100
+ return clampLines(out, maxLines);
101
+ }
@@ -0,0 +1,12 @@
1
+ import type { AstrocodeConfig } from "../config/schema";
2
+ import type { SqliteDb } from "../state/db";
3
+ import type { RunRow, StageRunRow, StoryRow } from "../state/types";
4
+ export declare function getRun(db: SqliteDb, runId: string): RunRow | null;
5
+ export declare function getStory(db: SqliteDb, storyKey: string): StoryRow | null;
6
+ export declare function listStageRuns(db: SqliteDb, runId: string): StageRunRow[];
7
+ export declare function buildContextSnapshot(opts: {
8
+ db: SqliteDb;
9
+ config: AstrocodeConfig;
10
+ run_id: string;
11
+ next_action?: string;
12
+ }): string;
@@ -0,0 +1,67 @@
1
+ import { clampLines } from "../shared/text";
2
+ export function getRun(db, runId) {
3
+ const row = db.prepare("SELECT * FROM runs WHERE run_id = ?").get(runId);
4
+ return row ?? null;
5
+ }
6
+ export function getStory(db, storyKey) {
7
+ const row = db.prepare("SELECT * FROM stories WHERE story_key = ?").get(storyKey);
8
+ return row ?? null;
9
+ }
10
+ export function listStageRuns(db, runId) {
11
+ return db
12
+ .prepare("SELECT * FROM stage_runs WHERE run_id = ? ORDER BY stage_index ASC")
13
+ .all(runId);
14
+ }
15
+ function statusIcon(status) {
16
+ switch (status) {
17
+ case "completed":
18
+ return "✅";
19
+ case "running":
20
+ return "🟦";
21
+ case "failed":
22
+ return "⛔";
23
+ case "skipped":
24
+ return "⏭️";
25
+ case "pending":
26
+ default:
27
+ return "⬜";
28
+ }
29
+ }
30
+ export function buildContextSnapshot(opts) {
31
+ const { db, config, run_id, next_action } = opts;
32
+ const run = getRun(db, run_id);
33
+ if (!run)
34
+ return `Run not found: ${run_id}`;
35
+ const story = getStory(db, run.story_key);
36
+ const stageRuns = listStageRuns(db, run_id);
37
+ const lines = [];
38
+ lines.push(`# Astrocode Context`);
39
+ lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}**`);
40
+ if (run.current_stage_key)
41
+ lines.push(`- Current stage: \`${run.current_stage_key}\``);
42
+ if (next_action)
43
+ lines.push(`- Next action: ${next_action}`);
44
+ if (story) {
45
+ lines.push(`- Story: \`${story.story_key}\` — ${story.title}`);
46
+ if (story.body_md?.trim()) {
47
+ const first = story.body_md.trim().split(/\n\n+/)[0]?.trim();
48
+ if (first)
49
+ lines.push(`- Story summary: ${first}`);
50
+ }
51
+ }
52
+ lines.push(``, `## Pipeline`);
53
+ for (const s of stageRuns) {
54
+ lines.push(`- ${statusIcon(s.status)} \`${s.stage_key}\` (${s.status})`);
55
+ }
56
+ const completed = stageRuns.filter((s) => s.status === "completed" && (s.summary_md ?? "").trim());
57
+ const lastSummaries = completed.slice(-config.context_compaction.snapshot_after_stage_count);
58
+ if (lastSummaries.length) {
59
+ lines.push(``, `## Recent stage summaries`);
60
+ for (const s of lastSummaries) {
61
+ lines.push(``, `### ${s.stage_key}`);
62
+ lines.push((s.summary_md ?? "").trim());
63
+ }
64
+ }
65
+ const out = lines.join("\n").trim();
66
+ return clampLines(out, config.context_compaction.snapshot_max_lines);
67
+ }
@@ -0,0 +1,37 @@
1
+ import type { AstrocodeConfig } from "../config/schema";
2
+ import type { StageKey } from "../state/types";
3
+ export type DirectiveKind = "continue" | "stage" | "blocked" | "repair";
4
+ export type BuiltDirective = {
5
+ kind: DirectiveKind;
6
+ title: string;
7
+ body: string;
8
+ hash: string;
9
+ };
10
+ export declare function directiveHash(body: string): string;
11
+ export declare function buildContinueDirective(opts: {
12
+ config: AstrocodeConfig;
13
+ run_id: string;
14
+ stage_key: string | null;
15
+ next_action: string;
16
+ context_snapshot_md: string;
17
+ }): BuiltDirective;
18
+ export declare function buildBlockedDirective(opts: {
19
+ run_id: string;
20
+ stage_key: string;
21
+ question: string;
22
+ context_snapshot_md: string;
23
+ }): BuiltDirective;
24
+ export declare function buildRepairDirective(opts: {
25
+ report_md: string;
26
+ }): BuiltDirective;
27
+ export declare function buildStageDirective(opts: {
28
+ config: AstrocodeConfig;
29
+ stage_key: StageKey;
30
+ run_id: string;
31
+ story_key: string;
32
+ story_title: string;
33
+ stage_agent_name: string;
34
+ stage_goal: string;
35
+ stage_constraints: string[];
36
+ context_snapshot_md: string;
37
+ }): BuiltDirective;
@@ -0,0 +1,111 @@
1
+ import { sha256Hex } from "../shared/hash";
2
+ import { clampChars, normalizeNewlines } from "../shared/text";
3
+ export function directiveHash(body) {
4
+ // Stable hash to dedupe: normalize newlines + trim
5
+ const norm = normalizeNewlines(body).trim();
6
+ return sha256Hex(norm);
7
+ }
8
+ export function buildContinueDirective(opts) {
9
+ const { config, run_id, stage_key, next_action, context_snapshot_md } = opts;
10
+ const body = clampChars(normalizeNewlines([
11
+ `[SYSTEM DIRECTIVE: ASTROCODE — CONTINUE]`,
12
+ ``,
13
+ `Run: \`${run_id}\`${stage_key ? ` Stage: \`${stage_key}\`` : ""}`,
14
+ ``,
15
+ `Next action: ${next_action}`,
16
+ ``,
17
+ `Rules:`,
18
+ `- Do not stop early. Keep going until the run is completed, failed, or blocked.`,
19
+ `- Prefer tools over prose.`,
20
+ `- If blocked, ask exactly ONE question and stop.`,
21
+ ``,
22
+ `Context snapshot:`,
23
+ context_snapshot_md.trim(),
24
+ ].join("\n")), config.context_compaction.inject_max_chars);
25
+ return {
26
+ kind: "continue",
27
+ title: "ASTROCODE — CONTINUE",
28
+ body,
29
+ hash: directiveHash(body),
30
+ };
31
+ }
32
+ export function buildBlockedDirective(opts) {
33
+ const { run_id, stage_key, question, context_snapshot_md } = opts;
34
+ const body = normalizeNewlines([
35
+ `[SYSTEM DIRECTIVE: ASTROCODE — BLOCKED]`,
36
+ ``,
37
+ `Run: \`${run_id}\` Stage: \`${stage_key}\``,
38
+ ``,
39
+ `You are blocked. Ask the user exactly ONE question (below), then stop.`,
40
+ ``,
41
+ `Question: ${question}`,
42
+ ``,
43
+ `Context snapshot:`,
44
+ context_snapshot_md.trim(),
45
+ ].join("\n")).trim();
46
+ return {
47
+ kind: "blocked",
48
+ title: "ASTROCODE — BLOCKED",
49
+ body,
50
+ hash: directiveHash(body),
51
+ };
52
+ }
53
+ export function buildRepairDirective(opts) {
54
+ const body = normalizeNewlines([
55
+ `[SYSTEM DIRECTIVE: ASTROCODE — REPAIR]`,
56
+ ``,
57
+ `Astrocode performed a repair pass. Summarize in <= 10 lines and continue.`,
58
+ ``,
59
+ `Repair report:`,
60
+ opts.report_md.trim(),
61
+ ].join("\n")).trim();
62
+ return {
63
+ kind: "repair",
64
+ title: "ASTROCODE — REPAIR",
65
+ body,
66
+ hash: directiveHash(body),
67
+ };
68
+ }
69
+ export function buildStageDirective(opts) {
70
+ const { config, stage_key, run_id, story_key, story_title, stage_agent_name, stage_goal, stage_constraints, context_snapshot_md } = opts;
71
+ const stageKeyUpper = stage_key.toUpperCase();
72
+ const constraintsBlock = stage_constraints.length
73
+ ? ["", "Constraints:", ...stage_constraints.map((c) => `- ${c}`)].join("\n")
74
+ : "";
75
+ const body = clampChars(normalizeNewlines([
76
+ `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_${stageKeyUpper}]`,
77
+ ``,
78
+ `You are: \`${stage_agent_name}\``,
79
+ `Run: \`${run_id}\``,
80
+ `Story: \`${story_key}\` — ${story_title}`,
81
+ ``,
82
+ `Stage goal: ${stage_goal}`,
83
+ constraintsBlock,
84
+ ``,
85
+ `Output contract (strict):`,
86
+ `1) Baton markdown (short, structured)`,
87
+ `2) ASTRO JSON between markers:`,
88
+ ` ${"```"}`,
89
+ ` ${"<!-- ASTRO_JSON_BEGIN -->"}`,
90
+ ` {...}`,
91
+ ` ${"<!-- ASTRO_JSON_END -->"}`,
92
+ ` ${"```"}`,
93
+ ``,
94
+ `ASTRO JSON requirements:`,
95
+ `- stage_key must be "${stage_key}"`,
96
+ `- status must be "ok" | "blocked" | "failed"`,
97
+ `- include summary + next_actions`,
98
+ `- include files/evidence paths when relevant`,
99
+ ``,
100
+ `If blocked: ask exactly ONE question and stop.`,
101
+ ``,
102
+ `Context snapshot:`,
103
+ context_snapshot_md.trim(),
104
+ ].join("\n")), config.context_compaction.inject_max_chars);
105
+ return {
106
+ kind: "stage",
107
+ title: `ASTROCODE — STAGE_${stageKeyUpper}`,
108
+ body,
109
+ hash: directiveHash(body),
110
+ };
111
+ }
@@ -0,0 +1,8 @@
1
+ import type { AstrocodeConfig } from "../config/schema";
2
+ import type { SqliteDb } from "../state/db";
3
+ export type RepairReport = {
4
+ actions: string[];
5
+ warnings: string[];
6
+ };
7
+ export declare function repairState(db: SqliteDb, config: AstrocodeConfig): RepairReport;
8
+ export declare function formatRepairReport(report: RepairReport): string;
@@ -0,0 +1,99 @@
1
+ import { nowISO } from "../shared/time";
2
+ import { newEventId, newStageRunId } from "../state/ids";
3
+ function push(report, line) {
4
+ report.actions.push(line);
5
+ }
6
+ function warn(report, line) {
7
+ report.warnings.push(line);
8
+ }
9
+ export function repairState(db, config) {
10
+ const report = { actions: [], warnings: [] };
11
+ const now = nowISO();
12
+ // 1) Multiple running runs -> abort extras (keep most recent started_at)
13
+ const running = db
14
+ .prepare("SELECT * FROM runs WHERE status='running' ORDER BY started_at DESC, created_at DESC")
15
+ .all();
16
+ if (running.length > 1) {
17
+ const keep = running[0];
18
+ for (const r of running.slice(1)) {
19
+ db.prepare("UPDATE runs SET status='aborted', error_text=?, completed_at=?, updated_at=? WHERE run_id=?").run("repair: multiple running runs", now, now, r.run_id);
20
+ // unlock story
21
+ db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, r.story_key);
22
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, 'run.aborted', ?, ?)").run(newEventId(), r.run_id, JSON.stringify({ reason: "repair: multiple running runs" }), now);
23
+ push(report, `Aborted extra running run ${r.run_id} (kept ${keep.run_id})`);
24
+ }
25
+ }
26
+ // 2) Fix story locks for the active run
27
+ const active = db.prepare("SELECT * FROM runs WHERE status='running' ORDER BY started_at DESC LIMIT 1").get();
28
+ if (active) {
29
+ const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(active.story_key);
30
+ if (!story) {
31
+ warn(report, `Active run ${active.run_id} references missing story ${active.story_key}`);
32
+ }
33
+ else {
34
+ if (story.locked_by_run_id !== active.run_id || story.in_progress !== 1 || story.state !== "in_progress") {
35
+ 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(active.run_id, now, now, story.story_key);
36
+ push(report, `Repaired story lock for ${story.story_key} to run ${active.run_id}`);
37
+ }
38
+ }
39
+ // 3) Ensure stage_runs exist for pipeline
40
+ const pipeline = JSON.parse(active.pipeline_stages_json ?? "[]");
41
+ const stageRuns = db
42
+ .prepare("SELECT * FROM stage_runs WHERE run_id=? ORDER BY stage_index ASC")
43
+ .all(active.run_id);
44
+ if (stageRuns.length < pipeline.length) {
45
+ const existingKeys = new Set(stageRuns.map((s) => s.stage_key));
46
+ const insert = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, updated_at) VALUES (?, ?, ?, ?, 'pending', ?)");
47
+ pipeline.forEach((key, idx) => {
48
+ if (!existingKeys.has(key)) {
49
+ insert.run(newStageRunId(), active.run_id, key, idx, now);
50
+ push(report, `Inserted missing stage_run ${key} for run ${active.run_id}`);
51
+ }
52
+ });
53
+ }
54
+ // 4) If current_stage_key missing, set to first incomplete
55
+ const refreshed = db
56
+ .prepare("SELECT * FROM stage_runs WHERE run_id=? ORDER BY stage_index ASC")
57
+ .all(active.run_id);
58
+ const cur = refreshed.find((s) => s.status !== "completed" && s.status !== "skipped");
59
+ if (cur && active.current_stage_key !== cur.stage_key) {
60
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(cur.stage_key, now, active.run_id);
61
+ push(report, `Set run.current_stage_key to ${cur.stage_key} for ${active.run_id}`);
62
+ }
63
+ }
64
+ // 5) Orphaned story locks (in_progress=1 but no running run)
65
+ const lockedStories = db
66
+ .prepare("SELECT * FROM stories WHERE in_progress=1")
67
+ .all();
68
+ for (const s of lockedStories) {
69
+ const run = s.locked_by_run_id
70
+ ? db.prepare("SELECT * FROM runs WHERE run_id=?").get(s.locked_by_run_id)
71
+ : undefined;
72
+ if (!run || run.status !== "running") {
73
+ db.prepare("UPDATE stories SET in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, s.story_key);
74
+ // Keep state conservative: if it was in_progress but no run, revert to approved.
75
+ db.prepare("UPDATE stories SET state='approved', updated_at=? WHERE story_key=? AND state='in_progress'").run(now, s.story_key);
76
+ push(report, `Cleared orphaned lock on story ${s.story_key}`);
77
+ }
78
+ }
79
+ if (!report.actions.length && !report.warnings.length) {
80
+ report.actions.push("No repairs needed.");
81
+ }
82
+ // Event marker
83
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, 'repair.completed', ?, ?)").run(newEventId(), JSON.stringify({ actions: report.actions, warnings: report.warnings, schema: config.db.schema_version_required }), now);
84
+ return report;
85
+ }
86
+ export function formatRepairReport(report) {
87
+ const lines = [];
88
+ lines.push("# Astrocode repair report");
89
+ lines.push("");
90
+ lines.push("## Actions");
91
+ for (const a of report.actions)
92
+ lines.push(`- ${a}`);
93
+ if (report.warnings.length) {
94
+ lines.push("", "## Warnings");
95
+ for (const w of report.warnings)
96
+ lines.push(`- ${w}`);
97
+ }
98
+ return lines.join("\n").trim();
99
+ }
@@ -0,0 +1,43 @@
1
+ import type { AstrocodeConfig } from "../config/schema";
2
+ import type { SqliteDb } from "../state/db";
3
+ import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
4
+ export type NextAction = {
5
+ kind: "idle";
6
+ reason: "no_approved_stories";
7
+ } | {
8
+ kind: "start_run";
9
+ story_key: string;
10
+ } | {
11
+ kind: "delegate_stage";
12
+ run_id: string;
13
+ stage_key: StageKey;
14
+ stage_run_id: string;
15
+ } | {
16
+ kind: "await_stage_completion";
17
+ run_id: string;
18
+ stage_key: StageKey;
19
+ stage_run_id: string;
20
+ } | {
21
+ kind: "complete_run";
22
+ run_id: string;
23
+ } | {
24
+ kind: "failed";
25
+ run_id: string;
26
+ stage_key: StageKey;
27
+ error_text: string;
28
+ };
29
+ export declare function getActiveRun(db: SqliteDb): RunRow | null;
30
+ export declare function getStory(db: SqliteDb, storyKey: string): StoryRow | null;
31
+ export declare function getStageRuns(db: SqliteDb, runId: string): StageRunRow[];
32
+ export declare function getCurrentStageRun(stageRuns: StageRunRow[]): StageRunRow | null;
33
+ export declare function decideNextAction(db: SqliteDb, config: AstrocodeConfig): NextAction;
34
+ export declare function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): {
35
+ run_id: string;
36
+ };
37
+ export declare function startStage(db: SqliteDb, runId: string, stageKey: StageKey, meta?: {
38
+ subagent_type?: string;
39
+ subagent_session_id?: string;
40
+ }): void;
41
+ export declare function completeRun(db: SqliteDb, runId: string): void;
42
+ export declare function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string): void;
43
+ export declare function abortRun(db: SqliteDb, runId: string, reason: string): void;
@@ -0,0 +1,127 @@
1
+ import { nowISO } from "../shared/time";
2
+ import { newEventId, newRunId, newStageRunId } from "../state/ids";
3
+ import { warn } from "../shared/log";
4
+ export function getActiveRun(db) {
5
+ const row = db
6
+ .prepare("SELECT * FROM runs WHERE status = 'running' ORDER BY started_at DESC, created_at DESC LIMIT 1")
7
+ .get();
8
+ return row ?? null;
9
+ }
10
+ export function getStory(db, storyKey) {
11
+ const row = db.prepare("SELECT * FROM stories WHERE story_key = ?").get(storyKey);
12
+ return row ?? null;
13
+ }
14
+ export function getStageRuns(db, runId) {
15
+ return db.prepare("SELECT * FROM stage_runs WHERE run_id = ? ORDER BY stage_index ASC").all(runId);
16
+ }
17
+ export function getCurrentStageRun(stageRuns) {
18
+ const cur = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
19
+ return cur ?? null;
20
+ }
21
+ export function decideNextAction(db, config) {
22
+ const activeRun = getActiveRun(db);
23
+ if (!activeRun) {
24
+ const story = db
25
+ .prepare("SELECT * FROM stories WHERE state = 'approved' AND (locked_by_run_id IS NULL) ORDER BY priority DESC, approved_at ASC, created_at ASC LIMIT 1")
26
+ .get();
27
+ if (!story)
28
+ return { kind: "idle", reason: "no_approved_stories" };
29
+ return { kind: "start_run", story_key: story.story_key };
30
+ }
31
+ const stageRuns = getStageRuns(db, activeRun.run_id);
32
+ const current = getCurrentStageRun(stageRuns);
33
+ if (!current) {
34
+ return { kind: "complete_run", run_id: activeRun.run_id };
35
+ }
36
+ if (current.status === "pending") {
37
+ return { kind: "delegate_stage", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
38
+ }
39
+ if (current.status === "running") {
40
+ return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
41
+ }
42
+ if (current.status === "failed") {
43
+ return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
44
+ }
45
+ // Should never happen: other statuses are handled above
46
+ warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
47
+ return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
48
+ }
49
+ export function createRunForStory(db, config, storyKey) {
50
+ const story = getStory(db, storyKey);
51
+ if (!story)
52
+ throw new Error(`Story not found: ${storyKey}`);
53
+ if (story.state !== "approved")
54
+ throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
55
+ const run_id = newRunId();
56
+ const now = nowISO();
57
+ const pipeline = config.workflow.pipeline;
58
+ // Lock story
59
+ db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?").run(run_id, now, now, storyKey);
60
+ db.prepare("INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)").run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
61
+ // Stage runs
62
+ const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, updated_at) VALUES (?, ?, ?, ?, 'pending', ?)");
63
+ pipeline.forEach((stageKey, idx) => {
64
+ insertStage.run(newStageRunId(), run_id, stageKey, idx, now);
65
+ });
66
+ // Event
67
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, 'run.started', ?, ?)").run(newEventId(), run_id, JSON.stringify({ story_key: storyKey, pipeline }), now);
68
+ db.prepare("UPDATE repo_state SET last_run_id=?, last_story_key=?, last_event_at=?, updated_at=? WHERE id=1").run(run_id, storyKey, now, now);
69
+ return { run_id };
70
+ }
71
+ export function startStage(db, runId, stageKey, meta) {
72
+ const now = nowISO();
73
+ // Ensure run is running
74
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
75
+ if (!run)
76
+ throw new Error(`Run not found: ${runId}`);
77
+ if (run.status !== "running")
78
+ throw new Error(`Run is not running: ${runId} (status=${run.status})`);
79
+ const stage = db
80
+ .prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?")
81
+ .get(runId, stageKey);
82
+ if (!stage)
83
+ throw new Error(`Stage run not found: ${runId}/${stageKey}`);
84
+ if (stage.status !== "pending")
85
+ throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
86
+ db.prepare("UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
87
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
88
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'stage.started', ?, ?)").run(newEventId(), runId, stageKey, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
89
+ }
90
+ export function completeRun(db, runId) {
91
+ const now = nowISO();
92
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
93
+ if (!run)
94
+ throw new Error(`Run not found: ${runId}`);
95
+ if (run.status !== "running")
96
+ throw new Error(`Run not running: ${runId} (status=${run.status})`);
97
+ // Ensure all stages completed/skipped
98
+ const stageRuns = getStageRuns(db, runId);
99
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
100
+ if (incomplete)
101
+ throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
102
+ db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
103
+ db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
104
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, 'run.completed', ?, ?)").run(newEventId(), runId, JSON.stringify({ story_key: run.story_key }), now);
105
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
106
+ }
107
+ export function failRun(db, runId, stageKey, errorText) {
108
+ const now = nowISO();
109
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
110
+ if (!run)
111
+ throw new Error(`Run not found: ${runId}`);
112
+ db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
113
+ db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
114
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, 'run.failed', ?, ?)").run(newEventId(), runId, stageKey, JSON.stringify({ error_text: errorText }), now);
115
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
116
+ }
117
+ export function abortRun(db, runId, reason) {
118
+ const now = nowISO();
119
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
120
+ if (!run)
121
+ throw new Error(`Run not found: ${runId}`);
122
+ db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
123
+ // Unlock story back to approved so it can be re-run.
124
+ db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
125
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, 'run.aborted', ?, ?)").run(newEventId(), runId, JSON.stringify({ reason }), now);
126
+ db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
127
+ }
@@ -0,0 +1,9 @@
1
+ import type { SqliteDb } from "../state/db";
2
+ export declare function nextStoryKey(db: SqliteDb): string;
3
+ export declare function insertStory(db: SqliteDb, opts: {
4
+ title: string;
5
+ body_md?: string;
6
+ priority?: number;
7
+ epic_key?: string | null;
8
+ state?: string;
9
+ }): string;
@@ -0,0 +1,13 @@
1
+ import { nowISO } from "../shared/time";
2
+ export function nextStoryKey(db) {
3
+ const row = db.prepare("SELECT next_story_num FROM story_keyseq WHERE id=1").get();
4
+ const n = row?.next_story_num ?? 1;
5
+ db.prepare("UPDATE story_keyseq SET next_story_num=? WHERE id=1").run(n + 1);
6
+ return `S-${String(n).padStart(4, "0")}`;
7
+ }
8
+ export function insertStory(db, opts) {
9
+ const now = nowISO();
10
+ const key = nextStoryKey(db);
11
+ db.prepare("INSERT INTO stories (story_key, epic_key, title, body_md, state, priority, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(key, opts.epic_key ?? null, opts.title, opts.body_md ?? "", opts.state ?? "queued", opts.priority ?? 0, now, now);
12
+ return key;
13
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "astrocode-workflow",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "typecheck": "tsc -p tsconfig.json --noEmit",
16
+ "clean": "rm -rf dist"
17
+ },
18
+ "dependencies": {
19
+ "@opencode-ai/plugin": "^1.1.19",
20
+ "@opencode-ai/sdk": "^1.1.19",
21
+ "jsonc-parser": "^3.2.0",
22
+ "zod": "4.1.8"
23
+ },
24
+ "optionalDependencies": {
25
+ "better-sqlite3": "^11.6.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/better-sqlite3": "^7.6.12",
29
+ "@types/node": "^20.12.12",
30
+ "typescript": "^5.6.3"
31
+ }
32
+ }