astrocode-workflow 0.1.58 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +243 -11
  2. package/dist/agents/prompts.d.ts +1 -0
  3. package/dist/agents/prompts.js +159 -0
  4. package/dist/agents/registry.js +11 -1
  5. package/dist/config/loader.js +34 -0
  6. package/dist/config/schema.d.ts +7 -1
  7. package/dist/config/schema.js +2 -0
  8. package/dist/hooks/continuation-enforcer.d.ts +9 -1
  9. package/dist/hooks/continuation-enforcer.js +2 -1
  10. package/dist/hooks/inject-provider.d.ts +9 -1
  11. package/dist/hooks/inject-provider.js +2 -1
  12. package/dist/hooks/tool-output-truncator.d.ts +9 -1
  13. package/dist/hooks/tool-output-truncator.js +2 -1
  14. package/dist/index.js +228 -45
  15. package/dist/state/adapters/index.d.ts +4 -2
  16. package/dist/state/adapters/index.js +23 -27
  17. package/dist/state/db.d.ts +6 -8
  18. package/dist/state/db.js +106 -45
  19. package/dist/tools/index.d.ts +13 -3
  20. package/dist/tools/index.js +14 -31
  21. package/dist/tools/init.d.ts +10 -1
  22. package/dist/tools/init.js +73 -18
  23. package/dist/tools/injects.js +90 -26
  24. package/dist/tools/spec.d.ts +0 -1
  25. package/dist/tools/spec.js +4 -1
  26. package/dist/tools/status.d.ts +1 -1
  27. package/dist/tools/status.js +70 -52
  28. package/dist/tools/workflow.js +2 -2
  29. package/dist/ui/inject.d.ts +16 -2
  30. package/dist/ui/inject.js +104 -33
  31. package/dist/workflow/directives.d.ts +2 -0
  32. package/dist/workflow/directives.js +34 -19
  33. package/dist/workflow/state-machine.d.ts +46 -3
  34. package/dist/workflow/state-machine.js +249 -92
  35. package/package.json +1 -1
  36. package/src/agents/prompts.ts +160 -0
  37. package/src/agents/registry.ts +16 -1
  38. package/src/config/loader.ts +39 -4
  39. package/src/config/schema.ts +3 -0
  40. package/src/hooks/continuation-enforcer.ts +9 -2
  41. package/src/hooks/inject-provider.ts +9 -2
  42. package/src/hooks/tool-output-truncator.ts +9 -2
  43. package/src/index.ts +260 -56
  44. package/src/state/adapters/index.ts +21 -26
  45. package/src/state/db.ts +114 -58
  46. package/src/tools/index.ts +29 -31
  47. package/src/tools/init.ts +91 -22
  48. package/src/tools/injects.ts +147 -53
  49. package/src/tools/spec.ts +6 -2
  50. package/src/tools/status.ts +71 -55
  51. package/src/tools/workflow.ts +3 -3
  52. package/src/ui/inject.ts +115 -41
  53. package/src/workflow/directives.ts +103 -75
  54. package/src/workflow/state-machine.ts +327 -109
package/src/state/db.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // src/state/db.ts
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { SCHEMA_SQL, SCHEMA_VERSION } from "./schema";
@@ -13,22 +14,34 @@ function ensureParentDir(filePath: string) {
13
14
  fs.mkdirSync(dir, { recursive: true });
14
15
  }
15
16
 
17
+ function tableExists(db: SqliteDb, tableName: string): boolean {
18
+ try {
19
+ const row = db
20
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
21
+ .get(tableName) as { name?: string } | undefined;
22
+ return row?.name === tableName;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
16
28
  export function openSqlite(dbPath: string, opts?: { busyTimeoutMs?: number }): SqliteDb {
17
29
  ensureParentDir(dbPath);
18
30
  const adapter = createDatabaseAdapter();
19
- const db = adapter.open(dbPath, opts);
20
- return db;
31
+ if (!adapter.isAvailable()) {
32
+ throw new Error("No SQLite driver available (adapter unavailable).");
33
+ }
34
+ return adapter.open(dbPath, opts);
21
35
  }
22
36
 
23
- export function configurePragmas(
24
- db: SqliteDb,
25
- pragmas: {
26
- journal_mode?: "WAL" | "DELETE";
27
- synchronous?: "NORMAL" | "FULL" | "OFF";
28
- foreign_keys?: boolean;
29
- temp_store?: "DEFAULT" | "MEMORY" | "FILE";
37
+ export function configurePragmas(db: SqliteDb, pragmas: Record<string, any>) {
38
+ if (!pragmas || typeof pragmas !== "object") return;
39
+
40
+ const known = new Set(["journal_mode", "synchronous", "foreign_keys", "temp_store"]);
41
+ for (const k of Object.keys(pragmas)) {
42
+ if (!known.has(k)) warn(`[Astrocode] Unknown pragma ignored: ${k}`);
30
43
  }
31
- ) {
44
+
32
45
  if (pragmas.journal_mode) db.pragma(`journal_mode = ${pragmas.journal_mode}`);
33
46
  if (pragmas.synchronous) db.pragma(`synchronous = ${pragmas.synchronous}`);
34
47
  if (typeof pragmas.foreign_keys === "boolean") db.pragma(`foreign_keys = ${pragmas.foreign_keys ? "ON" : "OFF"}`);
@@ -36,10 +49,12 @@ export function configurePragmas(
36
49
  }
37
50
 
38
51
  /** BEGIN IMMEDIATE transaction helper. */
39
- export function withTx<T>(db: SqliteDb, fn: () => T): T {
52
+ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean }): T {
40
53
  const adapter = createDatabaseAdapter();
41
- if (!adapter.isAvailable()) {
42
- // No database available, just run the function
54
+ const available = adapter.isAvailable();
55
+
56
+ if (!available) {
57
+ if (opts?.require) throw new Error("Database adapter unavailable; transaction required.");
43
58
  return fn();
44
59
  }
45
60
 
@@ -58,61 +73,102 @@ export function withTx<T>(db: SqliteDb, fn: () => T): T {
58
73
  }
59
74
  }
60
75
 
61
- export function ensureSchema(db: SqliteDb, opts?: { allowAutoMigrate?: boolean; failOnDowngrade?: boolean }) {
62
- const adapter = createDatabaseAdapter();
63
- if (!adapter.isAvailable()) {
64
- // Silent skip for mock adapter
65
- return;
76
+ export function getSchemaVersion(db: SqliteDb): number {
77
+ try {
78
+ const row = db
79
+ .prepare("SELECT schema_version FROM repo_state WHERE id = 1")
80
+ .get() as { schema_version?: number } | undefined;
81
+ return row?.schema_version ?? 0;
82
+ } catch (e) {
83
+ const msg = e instanceof Error ? e.message : String(e);
84
+ if (msg.includes("no such table")) return 0;
85
+ throw new Error(`Failed to read schema version: ${msg}`);
66
86
  }
87
+ }
67
88
 
89
+ function migrateStageRunsCreatedAt(db: SqliteDb) {
90
+ // v2 requires stage_runs.created_at to be meaningful; never write ''.
91
+ // This migration is best-effort and does not assume constraint rewrite support.
68
92
  try {
69
- db.exec(SCHEMA_SQL);
93
+ const cols = db.prepare("PRAGMA table_info(stage_runs)").all() as Array<{ name: string }>;
94
+ const hasCreatedAt = cols.some((c) => c.name === "created_at");
95
+ if (hasCreatedAt) return;
70
96
 
71
- // Migrations for existing databases
72
- // Add created_at to stage_runs if missing (introduced in schema version 2)
73
- try {
74
- const columns = db.prepare("PRAGMA table_info(stage_runs)").all() as { name: string }[];
75
- const hasCreatedAt = columns.some(col => col.name === 'created_at');
76
- if (!hasCreatedAt) {
77
- db.exec("ALTER TABLE stage_runs ADD COLUMN created_at TEXT NOT NULL DEFAULT ''");
78
- info("[Astrocode] Added created_at column to stage_runs table");
79
- }
80
- } catch (e) {
81
- // Column might already exist or table doesn't exist, ignore
82
- }
97
+ // Add nullable column first (avoid poison defaults).
98
+ db.exec("ALTER TABLE stage_runs ADD COLUMN created_at TEXT");
83
99
 
84
- const row = db.prepare("SELECT schema_version FROM repo_state WHERE id = 1").get() as { schema_version?: number } | undefined;
100
+ const now = nowISO();
85
101
 
86
- if (!row) {
87
- const now = nowISO();
88
- db.prepare(
89
- "INSERT INTO repo_state (id, schema_version, created_at, updated_at) VALUES (1, ?, ?, ?)"
90
- ).run(SCHEMA_VERSION, now, now);
91
- // Initialize story key seq
92
- db.prepare("INSERT OR IGNORE INTO story_keyseq (id, next_story_num) VALUES (1, 1)").run();
93
- return;
94
- }
102
+ // Backfill using best available timestamp fields, falling back to now.
103
+ db.prepare(`
104
+ UPDATE stage_runs
105
+ SET created_at =
106
+ COALESCE(
107
+ NULLIF(created_at, ''),
108
+ NULLIF(updated_at, ''),
109
+ NULLIF(started_at, ''),
110
+ ?
111
+ )
112
+ WHERE created_at IS NULL OR created_at = ''
113
+ `).run(now);
95
114
 
96
- const currentVersion = row.schema_version ?? 0;
115
+ info("[Astrocode] Added created_at column to stage_runs table (backfilled)");
116
+ } catch (e) {
117
+ // Ignore: table may not exist yet, or migration already applied.
118
+ }
119
+ }
97
120
 
98
- if (currentVersion === SCHEMA_VERSION) return;
121
+ export function ensureSchema(db: SqliteDb, opts?: { allowAutoMigrate?: boolean; silent?: boolean }) {
122
+ try {
123
+ return withTx(
124
+ db,
125
+ () => {
126
+ db.exec(SCHEMA_SQL);
99
127
 
100
- if (currentVersion > SCHEMA_VERSION && (opts?.failOnDowngrade ?? true)) {
101
- throw new Error(
102
- `Astrocode DB schema_version ${currentVersion} is newer than this plugin (${SCHEMA_VERSION}). Refusing to downgrade.`
103
- );
104
- }
128
+ // Deterministic assertions: if these fail, bootstrap is broken.
129
+ if (!tableExists(db, "repo_state")) throw new Error("Schema missing required table repo_state after SCHEMA_SQL");
130
+ if (!tableExists(db, "story_keyseq")) throw new Error("Schema missing required table story_keyseq after SCHEMA_SQL");
105
131
 
106
- if (currentVersion < SCHEMA_VERSION) {
107
- if (!(opts?.allowAutoMigrate ?? true)) {
108
- throw new Error(
109
- `Astrocode DB schema_version ${currentVersion} is older than required (${SCHEMA_VERSION}). Auto-migrate disabled.`
110
- );
111
- }
112
- // Additive schema: SCHEMA_SQL already created new tables/indexes if missing.
113
- db.prepare("UPDATE repo_state SET schema_version = ?, updated_at = ? WHERE id = 1").run(SCHEMA_VERSION, nowISO());
114
- }
132
+ // Ensure required singleton rows exist.
133
+ db.prepare("INSERT OR IGNORE INTO story_keyseq (id, next_story_num) VALUES (1, 1)").run();
134
+
135
+ // Migrations for existing DBs.
136
+ migrateStageRunsCreatedAt(db);
137
+
138
+ const row = db
139
+ .prepare("SELECT schema_version FROM repo_state WHERE id = 1")
140
+ .get() as { schema_version?: number } | undefined;
141
+
142
+ if (!row) {
143
+ const now = nowISO();
144
+ db.prepare("INSERT INTO repo_state (id, schema_version, created_at, updated_at) VALUES (1, ?, ?, ?)").run(
145
+ SCHEMA_VERSION,
146
+ now,
147
+ now
148
+ );
149
+ return;
150
+ }
151
+
152
+ const currentVersion = row.schema_version ?? 0;
153
+
154
+ if (currentVersion === SCHEMA_VERSION) return;
155
+
156
+ if (currentVersion > SCHEMA_VERSION) {
157
+ // Newer schema - no action (policy enforcement elsewhere).
158
+ return;
159
+ }
160
+
161
+ if (currentVersion < SCHEMA_VERSION) {
162
+ if (opts?.allowAutoMigrate ?? true) {
163
+ db.prepare("UPDATE repo_state SET schema_version = ?, updated_at = ? WHERE id = 1").run(SCHEMA_VERSION, nowISO());
164
+ }
165
+ }
166
+ },
167
+ { require: true }
168
+ );
115
169
  } catch (e) {
116
- // Schema operations might fail on mock adapter, silently ignore
170
+ if (opts?.silent) return;
171
+ const msg = e instanceof Error ? e.message : String(e);
172
+ throw new Error(`Schema initialization failed: ${msg}`);
117
173
  }
118
174
  }
@@ -15,22 +15,40 @@ import { createAstroRepairTool } from "./repair";
15
15
 
16
16
  import { AgentConfig } from "@opencode-ai/sdk";
17
17
 
18
- export function createAstroTools(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb; agents?: Record<string, AgentConfig> }): Record<string, ToolDefinition> {
19
- const { ctx, config, db, agents } = opts;
20
- const hasDatabase = !!db;
18
+ type RuntimeState = {
19
+ db: SqliteDb | null;
20
+ limitedMode: boolean;
21
+ limitedModeReason: null | { code: "db_init_failed"|"schema_too_old"|"schema_downgrade"|"schema_migration_failed"; details: any };
22
+ };
23
+
24
+ type CreateAstroToolsOptions = {
25
+ ctx: any;
26
+ config: AstrocodeConfig;
27
+ agents?: Record<string, AgentConfig>;
28
+ runtime: RuntimeState;
29
+ };
30
+
31
+ export function createAstroTools(opts: CreateAstroToolsOptions): Record<string, ToolDefinition> {
32
+ const { ctx, config, agents, runtime } = opts;
33
+ const { db } = runtime;
34
+ const hasDatabase = db !== null; // Source of truth: DB availability
21
35
 
22
36
  const tools: Record<string, ToolDefinition> = {};
23
37
 
24
- // Always available tools
25
- tools.astro_status = createAstroStatusTool({ ctx, config, db });
38
+ // Always available tools (work without database - guaranteed DB-independent)
39
+ tools.astro_status = createAstroStatusTool({ ctx, config });
40
+ tools.astro_spec_get = createAstroSpecGetTool({ ctx, config });
26
41
 
27
- // Always available tools (work without database)
28
- tools.astro_status = createAstroStatusTool({ ctx, config, db });
29
- tools.astro_spec_get = createAstroSpecGetTool({ ctx, config, db });
42
+ // Recovery tool - available even in limited mode to allow DB initialization
43
+ tools.astro_init = createAstroInitTool({ ctx, config, runtime });
30
44
 
31
45
  // Database-dependent tools
32
46
  if (hasDatabase) {
33
- tools.astro_init = createAstroInitTool({ ctx, config, db });
47
+ // Ensure agents are available for workflow tools that require them
48
+ if (!agents) {
49
+ throw new Error("astro_workflow_proceed requires agents to be provided in normal mode.");
50
+ }
51
+
34
52
  tools.astro_story_queue = createAstroStoryQueueTool({ ctx, config, db });
35
53
  tools.astro_story_approve = createAstroStoryApproveTool({ ctx, config, db });
36
54
  tools.astro_story_board = createAstroStoryBoardTool({ ctx, config, db });
@@ -53,28 +71,6 @@ export function createAstroTools(opts: { ctx: any; config: AstrocodeConfig; db:
53
71
  tools.astro_inject_eligible = createAstroInjectEligibleTool({ ctx, config, db });
54
72
  tools.astro_inject_debug_due = createAstroInjectDebugDueTool({ ctx, config, db });
55
73
  tools.astro_repair = createAstroRepairTool({ ctx, config, db });
56
- } else {
57
- // Limited mode tools - provide helpful messages instead of failing
58
- tools.astro_init = {
59
- description: "Initialize Astrocode (requires database - currently unavailable)",
60
- args: {},
61
- execute: async () => "❌ Database not available. Astrocode is running in limited mode."
62
- };
63
- tools.astro_story_queue = {
64
- description: "Queue a story (requires database - currently unavailable)",
65
- args: {},
66
- execute: async () => "❌ Database not available. Astrocode is running in limited mode."
67
- };
68
- tools.astro_spec_set = {
69
- description: "Set project spec (requires database for hash tracking - currently unavailable)",
70
- args: {},
71
- execute: async () => "❌ Database not available. Astrocode is running in limited mode."
72
- };
73
- tools.astro_workflow_proceed = {
74
- description: "Advance workflow (requires database - currently unavailable)",
75
- args: {},
76
- execute: async () => "❌ Database not available. Astrocode is running in limited mode."
77
- };
78
74
  }
79
75
 
80
76
  // Create aliases for backward compatibility
@@ -101,6 +97,8 @@ export function createAstroTools(opts: { ctx: any; config: AstrocodeConfig; db:
101
97
  ["_astro_inject_list", "astro_inject_list"],
102
98
  ["_astro_inject_search", "astro_inject_search"],
103
99
  ["_astro_inject_get", "astro_inject_get"],
100
+ ["_astro_inject_eligible", "astro_inject_eligible"],
101
+ ["_astro_inject_debug_due", "astro_inject_debug_due"],
104
102
  ["_astro_repair", "astro_repair"],
105
103
  ];
106
104
 
package/src/tools/init.ts CHANGED
@@ -3,13 +3,22 @@ import path from "node:path";
3
3
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
4
4
  import type { AstrocodeConfig } from "../config/schema";
5
5
  import type { SqliteDb } from "../state/db";
6
- import { ensureSchema } from "../state/db";
6
+ import { ensureSchema, openSqlite, configurePragmas } from "../state/db";
7
7
  import { getAstroPaths, ensureAstroDirs } from "../shared/paths";
8
8
  import { nowISO } from "../shared/time";
9
9
  import { sha256Hex } from "../shared/hash";
10
10
 
11
- export function createAstroInitTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
12
- const { ctx, config, db } = opts;
11
+ type RuntimeState = {
12
+ db: SqliteDb | null;
13
+ limitedMode: boolean;
14
+ limitedModeReason: null | {
15
+ code: "db_init_failed" | "schema_too_old" | "schema_downgrade" | "schema_migration_failed";
16
+ details: any;
17
+ };
18
+ };
19
+
20
+ export function createAstroInitTool(opts: { ctx: any; config: AstrocodeConfig; runtime: RuntimeState }): ToolDefinition {
21
+ const { ctx, config, runtime } = opts;
13
22
 
14
23
  return tool({
15
24
  description:
@@ -23,28 +32,88 @@ export function createAstroInitTool(opts: { ctx: any; config: AstrocodeConfig; d
23
32
  const paths = getAstroPaths(repoRoot, config.db.path);
24
33
  ensureAstroDirs(paths);
25
34
 
26
- ensureSchema(db, { allowAutoMigrate: config.db.allow_auto_migrate, failOnDowngrade: config.db.fail_on_downgrade });
35
+ const hadDbAlready = !!runtime.db;
36
+ let db: SqliteDb | null = runtime.db;
37
+ let publishedToRuntime = false;
38
+
39
+ try {
40
+ if (!db) {
41
+ try {
42
+ db = openSqlite(paths.dbPath, { busyTimeoutMs: config.db.busy_timeout_ms });
43
+ configurePragmas(db, config.db.pragmas);
44
+ } catch (e) {
45
+ const msg = e instanceof Error ? e.message : String(e);
46
+ throw new Error(
47
+ `❌ Failed to open database at ${paths.dbPath}: ${msg}. Install a SQLite driver (better-sqlite3/bun:sqlite) and check file permissions.`
48
+ );
49
+ }
50
+ }
51
+
52
+ // Source-of-truth for schema + repo_state invariants.
53
+ ensureSchema(db, { allowAutoMigrate: config.db.allow_auto_migrate });
54
+
55
+ // Postcondition: repo_state must exist after ensureSchema.
56
+ try {
57
+ db.prepare("SELECT schema_version FROM repo_state WHERE id = 1").get();
58
+ } catch (e) {
59
+ const msg = e instanceof Error ? e.message : String(e);
60
+ throw new Error(`❌ Schema initialization incomplete: repo_state missing/unreadable after ensureSchema (${msg})`);
61
+ }
62
+
63
+ if (ensure_spec) {
64
+ if (!fs.existsSync(paths.specPath)) {
65
+ fs.writeFileSync(paths.specPath, spec_placeholder);
66
+ }
27
67
 
28
- if (ensure_spec) {
29
- if (!fs.existsSync(paths.specPath)) {
30
- fs.writeFileSync(paths.specPath, spec_placeholder);
31
- const specHash = sha256Hex(spec_placeholder);
68
+ const content = fs.readFileSync(paths.specPath, "utf8");
69
+ const specHash = sha256Hex(content);
32
70
  const now = nowISO();
33
- db.prepare("UPDATE repo_state SET spec_hash_after=?, updated_at=? WHERE id=1").run(specHash, now);
71
+
72
+ // Update hash; fail with a clear error if schema is mismatched.
73
+ try {
74
+ db.prepare(
75
+ `
76
+ UPDATE repo_state
77
+ SET spec_hash_after = ?, updated_at = ?
78
+ WHERE id = 1
79
+ `
80
+ ).run(specHash, now);
81
+ } catch (e) {
82
+ const msg = e instanceof Error ? e.message : String(e);
83
+ throw new Error(`❌ Failed to update repo_state spec hash (schema mismatch?): ${msg}`);
84
+ }
85
+ }
86
+
87
+ // Best-effort: if we recovered DB in-process, publish it so the harness can use it immediately.
88
+ // (If your harness reads runtime at creation-time only, this still helps future tool calls.)
89
+ if (!hadDbAlready && db) {
90
+ runtime.db = db;
91
+ runtime.limitedMode = false;
92
+ runtime.limitedModeReason = null;
93
+ publishedToRuntime = true;
34
94
  }
35
- }
36
95
 
37
- const stat = db.prepare("SELECT schema_version, created_at, updated_at FROM repo_state WHERE id=1").get() as any;
38
-
39
- return [
40
- `✅ Astrocode initialized.`,
41
- ``,
42
- `- Repo: ${repoRoot}`,
43
- `- DB: ${path.relative(repoRoot, paths.dbPath)} (schema_version=${stat?.schema_version ?? "?"})`,
44
- `- Spec: ${path.relative(repoRoot, paths.specPath)}`,
45
- ``,
46
- `Next: queue a story (astro_story_queue) and approve it (astro_story_approve), or run /astro-status.`,
47
- ].join("\n");
96
+ const stat = db.prepare("SELECT schema_version, created_at, updated_at FROM repo_state WHERE id=1").get() as any;
97
+
98
+ return [
99
+ `✅ Astrocode initialized.`,
100
+ ``,
101
+ `- Repo: ${repoRoot}`,
102
+ `- DB: ${path.relative(repoRoot, paths.dbPath)} (schema_version=${stat?.schema_version ?? "?"})`,
103
+ `- Spec: ${path.relative(repoRoot, paths.specPath)}`,
104
+ ``,
105
+ publishedToRuntime
106
+ ? `Next: run /astro-status. (DB recovered in-process.)`
107
+ : `Next: restart the agent/runtime if Astrocode is still in Limited Mode, then run /astro-status.`,
108
+ ].join("\n");
109
+ } finally {
110
+ // Only close if this tool opened it AND we did not publish it for ongoing use.
111
+ if (!hadDbAlready && !publishedToRuntime && db && typeof db.close === "function") {
112
+ try {
113
+ db.close();
114
+ } catch {}
115
+ }
116
+ }
48
117
  },
49
118
  });
50
- }
119
+ }