astrocode-workflow 0.1.57 → 0.1.59

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 (49) 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 +12 -1
  7. package/dist/config/schema.js +14 -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 +11 -3
  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/spec.d.ts +0 -1
  24. package/dist/tools/spec.js +4 -1
  25. package/dist/tools/status.d.ts +1 -1
  26. package/dist/tools/status.js +70 -52
  27. package/dist/tools/workflow.js +6 -3
  28. package/dist/workflow/directives.d.ts +2 -0
  29. package/dist/workflow/directives.js +34 -19
  30. package/dist/workflow/state-machine.d.ts +27 -0
  31. package/dist/workflow/state-machine.js +167 -86
  32. package/package.json +1 -1
  33. package/src/agents/prompts.ts +160 -0
  34. package/src/agents/registry.ts +16 -1
  35. package/src/config/loader.ts +39 -4
  36. package/src/config/schema.ts +16 -0
  37. package/src/hooks/continuation-enforcer.ts +9 -2
  38. package/src/hooks/inject-provider.ts +18 -4
  39. package/src/hooks/tool-output-truncator.ts +9 -2
  40. package/src/index.ts +260 -56
  41. package/src/state/adapters/index.ts +21 -26
  42. package/src/state/db.ts +114 -58
  43. package/src/tools/index.ts +29 -31
  44. package/src/tools/init.ts +91 -22
  45. package/src/tools/spec.ts +6 -2
  46. package/src/tools/status.ts +71 -55
  47. package/src/tools/workflow.ts +7 -4
  48. package/src/workflow/directives.ts +103 -75
  49. package/src/workflow/state-machine.ts +229 -105
@@ -1,13 +1,25 @@
1
1
  import { sha256Hex } from "../shared/hash";
2
2
  import { clampChars, normalizeNewlines } from "../shared/text";
3
+ function getInjectMaxChars(config) {
4
+ // Deterministic fallback for older configs.
5
+ const v = config?.context_compaction?.inject_max_chars;
6
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 12000;
7
+ }
3
8
  export function directiveHash(body) {
4
9
  // Stable hash to dedupe: normalize newlines + trim
5
10
  const norm = normalizeNewlines(body).trim();
6
11
  return sha256Hex(norm);
7
12
  }
13
+ function finalizeBody(body, maxChars) {
14
+ // Normalize first, clamp second, trim last => hash/body match exactly.
15
+ const norm = normalizeNewlines(body);
16
+ const clamped = clampChars(norm, maxChars);
17
+ return clamped.trim();
18
+ }
8
19
  export function buildContinueDirective(opts) {
9
20
  const { config, run_id, stage_key, next_action, context_snapshot_md } = opts;
10
- const body = clampChars(normalizeNewlines([
21
+ const maxChars = getInjectMaxChars(config);
22
+ const body = finalizeBody([
11
23
  `[SYSTEM DIRECTIVE: ASTROCODE — CONTINUE]`,
12
24
  ``,
13
25
  `This directive is injected by the Astro agent to continue the workflow.`,
@@ -22,8 +34,8 @@ export function buildContinueDirective(opts) {
22
34
  `- If blocked, ask exactly ONE question and stop.`,
23
35
  ``,
24
36
  `Context snapshot:`,
25
- context_snapshot_md.trim(),
26
- ].join("\n")), config.context_compaction.inject_max_chars);
37
+ (context_snapshot_md ?? "").trim(),
38
+ ].join("\n"), maxChars);
27
39
  return {
28
40
  kind: "continue",
29
41
  title: "ASTROCODE — CONTINUE",
@@ -32,8 +44,9 @@ export function buildContinueDirective(opts) {
32
44
  };
33
45
  }
34
46
  export function buildBlockedDirective(opts) {
35
- const { run_id, stage_key, question, context_snapshot_md } = opts;
36
- const body = normalizeNewlines([
47
+ const { config, run_id, stage_key, question, context_snapshot_md } = opts;
48
+ const maxChars = getInjectMaxChars(config);
49
+ const body = finalizeBody([
37
50
  `[SYSTEM DIRECTIVE: ASTROCODE — BLOCKED]`,
38
51
  ``,
39
52
  `This directive is injected by the Astro agent indicating the workflow is blocked.`,
@@ -45,8 +58,8 @@ export function buildBlockedDirective(opts) {
45
58
  `Question: ${question}`,
46
59
  ``,
47
60
  `Context snapshot:`,
48
- context_snapshot_md.trim(),
49
- ].join("\n")).trim();
61
+ (context_snapshot_md ?? "").trim(),
62
+ ].join("\n"), maxChars);
50
63
  return {
51
64
  kind: "blocked",
52
65
  title: "ASTROCODE — BLOCKED",
@@ -55,7 +68,8 @@ export function buildBlockedDirective(opts) {
55
68
  };
56
69
  }
57
70
  export function buildRepairDirective(opts) {
58
- const body = normalizeNewlines([
71
+ const maxChars = getInjectMaxChars(opts.config);
72
+ const body = finalizeBody([
59
73
  `[SYSTEM DIRECTIVE: ASTROCODE — REPAIR]`,
60
74
  ``,
61
75
  `This directive is injected by the Astro agent after performing a repair pass.`,
@@ -63,8 +77,8 @@ export function buildRepairDirective(opts) {
63
77
  `Astrocode performed a repair pass. Summarize in <= 10 lines and continue.`,
64
78
  ``,
65
79
  `Repair report:`,
66
- opts.report_md.trim(),
67
- ].join("\n")).trim();
80
+ (opts.report_md ?? "").trim(),
81
+ ].join("\n"), maxChars);
68
82
  return {
69
83
  kind: "repair",
70
84
  title: "ASTROCODE — REPAIR",
@@ -73,12 +87,13 @@ export function buildRepairDirective(opts) {
73
87
  };
74
88
  }
75
89
  export function buildStageDirective(opts) {
76
- const { config, stage_key, run_id, story_key, story_title, stage_agent_name, stage_goal, stage_constraints, context_snapshot_md } = opts;
77
- const stageKeyUpper = stage_key.toUpperCase();
78
- const constraintsBlock = stage_constraints.length
90
+ const { config, stage_key, run_id, story_key, story_title, stage_agent_name, stage_goal, stage_constraints, context_snapshot_md, } = opts;
91
+ const maxChars = getInjectMaxChars(config);
92
+ const stageKeyUpper = String(stage_key).toUpperCase();
93
+ const constraintsBlock = Array.isArray(stage_constraints) && stage_constraints.length
79
94
  ? ["", "Constraints:", ...stage_constraints.map((c) => `- ${c}`)].join("\n")
80
95
  : "";
81
- const body = clampChars(normalizeNewlines([
96
+ const body = finalizeBody([
82
97
  `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_${stageKeyUpper}]`,
83
98
  ``,
84
99
  `This directive is injected by the Astro agent to delegate the stage task.`,
@@ -93,14 +108,14 @@ export function buildStageDirective(opts) {
93
108
  `Output contract (strict):`,
94
109
  `1) Baton markdown (short, structured)`,
95
110
  `2) ASTRO JSON between markers:`,
96
- ` ${"<!-- ASTRO_JSON_BEGIN -->"}`,
111
+ ` <!-- ASTRO_JSON_BEGIN -->`,
97
112
  ` {`,
98
113
  ` "schema_version": 1,`,
99
114
  ` "stage_key": "${stage_key}",`,
100
115
  ` "status": "ok",`,
101
- ` ...`,
116
+ ` "...": "..."`,
102
117
  ` }`,
103
- ` ${"<!-- ASTRO_JSON_END -->"}`,
118
+ ` <!-- ASTRO_JSON_END -->`,
104
119
  ``,
105
120
  `ASTRO JSON requirements:`,
106
121
  `- stage_key must be "${stage_key}"`,
@@ -111,8 +126,8 @@ export function buildStageDirective(opts) {
111
126
  `If blocked: ask exactly ONE question and stop.`,
112
127
  ``,
113
128
  `Context snapshot:`,
114
- context_snapshot_md.trim(),
115
- ].join("\n")), config.context_compaction.inject_max_chars);
129
+ (context_snapshot_md ?? "").trim(),
130
+ ].join("\n"), maxChars);
116
131
  return {
117
132
  kind: "stage",
118
133
  title: `ASTROCODE — STAGE_${stageKeyUpper}`,
@@ -1,6 +1,33 @@
1
1
  import type { AstrocodeConfig } from "../config/schema";
2
2
  import type { SqliteDb } from "../state/db";
3
3
  import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
4
+ export declare const EVENT_TYPES: {
5
+ readonly RUN_STARTED: "run.started";
6
+ readonly RUN_COMPLETED: "run.completed";
7
+ readonly RUN_FAILED: "run.failed";
8
+ readonly RUN_ABORTED: "run.aborted";
9
+ readonly RUN_GENESIS_PLANNING_ATTACHED: "run.genesis_planning_attached";
10
+ readonly STAGE_STARTED: "stage.started";
11
+ readonly WORKFLOW_PROCEED: "workflow.proceed";
12
+ };
13
+ /**
14
+ * PLANNING-FIRST REDESIGN
15
+ * ----------------------
16
+ * Old behavior: mutate an approved story into a planning/decomposition instruction.
17
+ * New behavior: NEVER mutate story title/body.
18
+ *
19
+ * Planning-first is a run-scoped directive stored in `injects` (scope = run:<run_id>).
20
+ *
21
+ * Deterministic trigger (config-driven):
22
+ * - config.workflow.genesis_planning:
23
+ * - "off" => never attach directive
24
+ * - "first_story_only"=> attach only when story_key === "S-0001"
25
+ * - "always" => attach for every run
26
+ *
27
+ * Contract: DB is already initialized before workflow is used:
28
+ * - schema tables exist
29
+ * - repo_state singleton row (id=1) exists
30
+ */
4
31
  export type NextAction = {
5
32
  kind: "idle";
6
33
  reason: "no_approved_stories";
@@ -1,6 +1,17 @@
1
+ import { withTx } from "../state/db";
1
2
  import { nowISO } from "../shared/time";
2
3
  import { newEventId, newRunId, newStageRunId } from "../state/ids";
3
4
  import { warn } from "../shared/log";
5
+ import { sha256Hex } from "../shared/hash";
6
+ export const EVENT_TYPES = {
7
+ RUN_STARTED: "run.started",
8
+ RUN_COMPLETED: "run.completed",
9
+ RUN_FAILED: "run.failed",
10
+ RUN_ABORTED: "run.aborted",
11
+ RUN_GENESIS_PLANNING_ATTACHED: "run.genesis_planning_attached",
12
+ STAGE_STARTED: "stage.started",
13
+ WORKFLOW_PROCEED: "workflow.proceed",
14
+ };
4
15
  export function getActiveRun(db) {
5
16
  const row = db
6
17
  .prepare("SELECT * FROM runs WHERE status = 'running' ORDER BY started_at DESC, created_at DESC LIMIT 1")
@@ -30,9 +41,8 @@ export function decideNextAction(db, config) {
30
41
  }
31
42
  const stageRuns = getStageRuns(db, activeRun.run_id);
32
43
  const current = getCurrentStageRun(stageRuns);
33
- if (!current) {
44
+ if (!current)
34
45
  return { kind: "complete_run", run_id: activeRun.run_id };
35
- }
36
46
  if (current.status === "pending") {
37
47
  return { kind: "delegate_stage", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
38
48
  }
@@ -42,103 +52,174 @@ export function decideNextAction(db, config) {
42
52
  if (current.status === "failed") {
43
53
  return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
44
54
  }
45
- // Should never happen: other statuses are handled above
46
55
  warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
47
56
  return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
48
57
  }
49
- function isInitialStory(db, storyKey) {
50
- // Check if this story has no parent relations (is top-level)
51
- const relations = db.prepare("SELECT COUNT(*) as count FROM story_relations WHERE child_story_key=?").get(storyKey);
52
- return relations.count === 0;
58
+ function getPipelineFromConfig(config) {
59
+ const pipeline = config?.workflow?.pipeline;
60
+ if (!Array.isArray(pipeline) || pipeline.length === 0) {
61
+ throw new Error("Invalid config: workflow.pipeline must be a non-empty array of stage keys.");
62
+ }
63
+ return pipeline;
53
64
  }
54
- export function createRunForStory(db, config, storyKey) {
55
- const story = getStory(db, storyKey);
56
- if (!story)
57
- throw new Error(`Story not found: ${storyKey}`);
58
- if (story.state !== "approved")
59
- throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
60
- const run_id = newRunId();
65
+ function getGenesisPlanningMode(config) {
66
+ const raw = config?.workflow?.genesis_planning;
67
+ if (raw === "off" || raw === "first_story_only" || raw === "always")
68
+ return raw;
69
+ if (raw != null)
70
+ warn(`Invalid workflow.genesis_planning config: ${String(raw)}. Using default "first_story_only".`);
71
+ return "first_story_only";
72
+ }
73
+ function shouldAttachPlanningDirective(config, story) {
74
+ const mode = getGenesisPlanningMode(config);
75
+ if (mode === "off")
76
+ return false;
77
+ if (mode === "always")
78
+ return true;
79
+ return story.story_key === "S-0001";
80
+ }
81
+ function attachRunPlanningDirective(db, runId, story, pipeline) {
61
82
  const now = nowISO();
62
- const pipeline = config.workflow.pipeline;
63
- // Convert to genesis planning story if needed
64
- const isGenesisCandidate = storyKey === 'S-0001' || isInitialStory(db, storyKey) ||
65
- (story.body_md && story.body_md.length > 100 &&
66
- (story.title.toLowerCase().includes('implement') || story.body_md.toLowerCase().includes('implement')));
67
- // Skip conversion if there are already many stories (spec already decomposed)
68
- const existingStoriesCount = db.prepare("SELECT COUNT(*) as count FROM stories").get();
69
- const alreadyDecomposed = existingStoriesCount.count > 10; // Arbitrary threshold
70
- if (isGenesisCandidate && !alreadyDecomposed) {
71
- const planningTitle = `Plan and decompose: ${story.title}`;
72
- const planningBody = `Analyze the requirements and break down "${story.title}" into 50-200 detailed, granular implementation stories. Each story should be focused on a specific, implementable task with clear acceptance criteria.\n\nOriginal request: ${story.body_md || ''}`;
73
- db.prepare("UPDATE stories SET title=?, body_md=? WHERE story_key=?").run(planningTitle, planningBody, storyKey);
83
+ const injectId = `inj_${runId}_genesis_plan`;
84
+ const body = [
85
+ `# Genesis planning directive`,
86
+ ``,
87
+ `This run is configured to perform a planning/decomposition pass before implementation.`,
88
+ `Do not edit the origin story title/body. Create additional stories instead.`,
89
+ ``,
90
+ `## Required output`,
91
+ `- Produce 50–200 granular implementation stories with clear acceptance criteria.`,
92
+ `- Each story: single focused change, explicit done conditions, dependencies listed.`,
93
+ ``,
94
+ `## Context`,
95
+ `- Origin story: ${story.story_key} — ${story.title ?? ""}`,
96
+ `- Pipeline: ${pipeline.join(" → ")}`,
97
+ ``,
98
+ ].join("\n");
99
+ let hash = null;
100
+ try {
101
+ hash = sha256Hex(body);
102
+ }
103
+ catch {
104
+ // Hash is optional; directive must never be blocked by hashing.
105
+ hash = null;
74
106
  }
75
- // Lock story
76
- 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);
77
- 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);
78
- // Stage runs
79
- const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, updated_at) VALUES (?, ?, ?, ?, 'pending', ?)");
80
- pipeline.forEach((stageKey, idx) => {
81
- insertStage.run(newStageRunId(), run_id, stageKey, idx, now);
107
+ try {
108
+ // Do not clobber user edits. If it exists, we leave it.
109
+ db.prepare(`
110
+ INSERT OR IGNORE INTO injects (
111
+ inject_id, type, title, body_md, tags_json, scope, source, priority,
112
+ expires_at, sha256, created_at, updated_at
113
+ ) VALUES (
114
+ ?, 'note', ?, ?, '["genesis","planning","decompose"]', ?, 'tool', 100,
115
+ NULL, ?, ?, ?
116
+ )
117
+ `).run(injectId, "Genesis planning: decompose into stories", body, `run:${runId}`, hash, now, now);
118
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_GENESIS_PLANNING_ATTACHED, JSON.stringify({ story_key: story.story_key, inject_id: injectId }), now);
119
+ }
120
+ catch (e) {
121
+ // Helpful, never required for correctness.
122
+ warn("Failed to attach genesis planning inject", { run_id: runId, story_key: story.story_key, err: e });
123
+ }
124
+ }
125
+ function updateRepoStateLastEvent(db, now, fields) {
126
+ // Contract: repo_state row exists. If not, fail deterministically (don’t “bootstrap” here).
127
+ const res = db
128
+ .prepare(`
129
+ UPDATE repo_state
130
+ SET last_run_id = COALESCE(?, last_run_id),
131
+ last_story_key = COALESCE(?, last_story_key),
132
+ last_event_at = ?,
133
+ updated_at = ?
134
+ WHERE id = 1
135
+ `)
136
+ .run(fields.last_run_id ?? null, fields.last_story_key ?? null, now, now);
137
+ if (!res || res.changes === 0) {
138
+ throw new Error("repo_state missing (id=1). Database not initialized; run init must be performed before workflow.");
139
+ }
140
+ }
141
+ export function createRunForStory(db, config, storyKey) {
142
+ return withTx(db, () => {
143
+ const story = getStory(db, storyKey);
144
+ if (!story)
145
+ throw new Error(`Story not found: ${storyKey}`);
146
+ if (story.state !== "approved")
147
+ throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
148
+ const run_id = newRunId();
149
+ const now = nowISO();
150
+ const pipeline = getPipelineFromConfig(config);
151
+ 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);
152
+ 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);
153
+ const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
154
+ pipeline.forEach((stageKey, idx) => {
155
+ insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
156
+ });
157
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
158
+ if (shouldAttachPlanningDirective(config, story)) {
159
+ attachRunPlanningDirective(db, run_id, story, pipeline);
160
+ }
161
+ updateRepoStateLastEvent(db, now, { last_run_id: run_id, last_story_key: storyKey });
162
+ return { run_id };
82
163
  });
83
- // Event
84
- 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);
85
- 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);
86
- return { run_id };
87
164
  }
88
165
  export function startStage(db, runId, stageKey, meta) {
89
- const now = nowISO();
90
- // Ensure run is running
91
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
92
- if (!run)
93
- throw new Error(`Run not found: ${runId}`);
94
- if (run.status !== "running")
95
- throw new Error(`Run is not running: ${runId} (status=${run.status})`);
96
- const stage = db
97
- .prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?")
98
- .get(runId, stageKey);
99
- if (!stage)
100
- throw new Error(`Stage run not found: ${runId}/${stageKey}`);
101
- if (stage.status !== "pending")
102
- throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
103
- 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);
104
- db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
105
- 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);
166
+ return withTx(db, () => {
167
+ const now = nowISO();
168
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
169
+ if (!run)
170
+ throw new Error(`Run not found: ${runId}`);
171
+ if (run.status !== "running")
172
+ throw new Error(`Run is not running: ${runId} (status=${run.status})`);
173
+ const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
174
+ if (!stage)
175
+ throw new Error(`Stage run not found: ${runId}/${stageKey}`);
176
+ if (stage.status !== "pending")
177
+ throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
178
+ 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);
179
+ db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
180
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
181
+ updateRepoStateLastEvent(db, now, {});
182
+ });
106
183
  }
107
184
  export function completeRun(db, runId) {
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
- if (run.status !== "running")
113
- throw new Error(`Run not running: ${runId} (status=${run.status})`);
114
- // Ensure all stages completed/skipped
115
- const stageRuns = getStageRuns(db, runId);
116
- const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
117
- if (incomplete)
118
- throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
119
- db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
120
- 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);
121
- 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);
122
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
185
+ return withTx(db, () => {
186
+ const now = nowISO();
187
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
188
+ if (!run)
189
+ throw new Error(`Run not found: ${runId}`);
190
+ if (run.status !== "running")
191
+ throw new Error(`Run not running: ${runId} (status=${run.status})`);
192
+ const stageRuns = getStageRuns(db, runId);
193
+ const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
194
+ if (incomplete)
195
+ throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
196
+ db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
197
+ 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);
198
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
199
+ updateRepoStateLastEvent(db, now, {});
200
+ });
123
201
  }
124
202
  export function failRun(db, runId, stageKey, errorText) {
125
- const now = nowISO();
126
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
127
- if (!run)
128
- throw new Error(`Run not found: ${runId}`);
129
- db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
130
- 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);
131
- 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);
132
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
203
+ return withTx(db, () => {
204
+ const now = nowISO();
205
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
206
+ if (!run)
207
+ throw new Error(`Run not found: ${runId}`);
208
+ db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
209
+ 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);
210
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
211
+ updateRepoStateLastEvent(db, now, {});
212
+ });
133
213
  }
134
214
  export function abortRun(db, runId, reason) {
135
- const now = nowISO();
136
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
137
- if (!run)
138
- throw new Error(`Run not found: ${runId}`);
139
- db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
140
- // Unlock story back to approved so it can be re-run.
141
- 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);
142
- 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);
143
- db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
215
+ return withTx(db, () => {
216
+ const now = nowISO();
217
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
218
+ if (!run)
219
+ throw new Error(`Run not found: ${runId}`);
220
+ db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
221
+ 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);
222
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
223
+ updateRepoStateLastEvent(db, now, {});
224
+ });
144
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -72,3 +72,163 @@ I analyzed the requirements and identified key components for implementation.
72
72
 
73
73
  If blocked, set status="blocked" and add ONE question to questions array. Do not deviate from this format.
74
74
  `;
75
+
76
+ export const QA_AGENT_PROMPT = `🌍 Global Engineering Review Prompt (LOCKED)
77
+
78
+ Use this prompt when reviewing any codebase.
79
+
80
+ 1️⃣ How every file review starts (MANDATORY)
81
+
82
+ Before discussing details, always answer:
83
+
84
+ Simple question this file answers
85
+
86
+ What decision, contract, or responsibility does this file define?
87
+
88
+ Things you have at the end of running this code
89
+
90
+ What objects, capabilities, state, guarantees, or invariants now exist?
91
+
92
+ If you can't answer these clearly, the file is already suspect.
93
+
94
+ 2️⃣ Canonical RULE set (GLOBAL ENGINEERING INVARIANTS)
95
+
96
+ These are engineering physics laws.
97
+ Every serious bug maps to one of these.
98
+ No ad-hoc rules are allowed.
99
+
100
+ enum RULE {
101
+ CAPABILITY_GUARANTEE =
102
+ "Expose a capability only when you can guarantee it will execute safely under current runtime conditions.",
103
+
104
+ RECOVERY_STRICTER =
105
+ "Recovery/degraded paths must be simpler and more conservative than the normal path, and must not introduce new failure modes.",
106
+
107
+ SOURCE_OF_TRUTH =
108
+ "For any piece of state, define exactly one authoritative source and explicit precedence rules for any mirrors, caches, or derivations.",
109
+
110
+ LIFECYCLE_DETERMINISM =
111
+ "Initialization and lifecycle must be deterministic: single construction, stable ordering, controlled side effects, and repeatable outcomes.",
112
+
113
+ SECURITY_BOUNDARIES =
114
+ "Security, authorization, and trust boundaries must be explicit, enforced, and never inferred implicitly."
115
+ }
116
+
117
+ If an issue does not violate one of these rules, it is not a P0/P1 blocker.
118
+
119
+ 3️⃣ Severity model (WHAT "P" MEANS)
120
+
121
+ Severity is about trust, not annoyance.
122
+
123
+ P0 — Trust break
124
+
125
+ Unsafe execution
126
+
127
+ Corrupted or ambiguous state
128
+
129
+ Non-deterministic lifecycle
130
+
131
+ Broken auditability / resumability
132
+
133
+ Security boundary violations
134
+
135
+ P1 — Reliability break
136
+
137
+ Runtime crashes after successful boot
138
+
139
+ Capabilities exposed but unusable
140
+
141
+ Degraded mode that lies or half-works
142
+
143
+ Recovery paths that add fragility
144
+
145
+ P2 — Quality / polish
146
+
147
+ Readability, ergonomics, maintainability
148
+
149
+ No RULE violated
150
+
151
+ 4️⃣ Mandatory P0 / P1 Blocker Format (STRICT)
152
+
153
+ Every P0 / P1 must be written exactly like this:
154
+
155
+ P{0|1} — <short human title>
156
+
157
+ Rule: RULE.<ONE_ENUM_VALUE>
158
+
159
+ Description:
160
+ A human-readable explanation of how this specific code violates the rule in context.
161
+ This is situational and concrete — not a rule.
162
+
163
+ What:
164
+ The exact defect or unsafe behavior.
165
+
166
+ Where:
167
+ Precise file + function + construct / lines.
168
+
169
+ Proposed fix:
170
+ The smallest possible change that restores the rule.
171
+ (Code snippets if helpful.)
172
+
173
+ Why:
174
+ How this fix restores the invariant and what class of failures it prevents.
175
+
176
+ 5️⃣ Recovery / Degraded Mode Lens (AUTO-APPLIED)
177
+
178
+ Whenever code introduces:
179
+
180
+ limited mode
181
+
182
+ fallback
183
+
184
+ catch-based recovery
185
+
186
+ partial initialization
187
+
188
+ Automatically evaluate against:
189
+
190
+ RULE.RECOVERY_STRICTER
191
+
192
+ RULE.CAPABILITY_GUARANTEE
193
+
194
+ RULE.LIFECYCLE_DETERMINISM
195
+
196
+ If recovery adds logic, validation, or ambiguity → it is a blocker.
197
+
198
+ Recovery must:
199
+
200
+ reduce capability surface
201
+
202
+ fail earlier, not later
203
+
204
+ be simpler than the normal path
205
+
206
+ provide a clear path back to normal
207
+
208
+ 6️⃣ How to ask for the next file (TEACHING MODE)
209
+
210
+ Before asking for the next file, always explain:
211
+
212
+ What this next file likely does (human-readable)
213
+
214
+ "This file takes X, registers it with Y, then enforces Z..."
215
+
216
+ Why this file matters next
217
+
218
+ Which RULE it is likely to uphold or violate, and why reviewing it now reduces risk.
219
+
220
+ 7️⃣ What this frame teaches over time
221
+
222
+ After repeated use, you stop seeing "random bugs" and start seeing patterns:
223
+
224
+ CAPABILITY_GUARANTEE violations (exposed but unsafe APIs)
225
+
226
+ RECOVERY_STRICTER violations (clever fallbacks that explode)
227
+
228
+ SOURCE_OF_TRUTH drift (DB vs disk vs memory)
229
+
230
+ LIFECYCLE_DETERMINISM failures (double init, racey wiring)
231
+
232
+ SECURITY_BOUNDARIES leaks (implicit trust)
233
+
234
+ At that point, reviews become portable skills, not project-specific knowledge.`;