astrocode-workflow 0.1.53 → 0.1.56

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.
@@ -172,6 +172,12 @@ export declare const AstrocodeConfigSchema: z.ZodDefault<z.ZodObject<{
172
172
  idle_prompt_ms: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
173
173
  }, z.core.$strip>>>;
174
174
  }, z.core.$strip>>>;
175
+ inject: z.ZodOptional<z.ZodDefault<z.ZodObject<{
176
+ enabled: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
177
+ scope_allowlist: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
178
+ type_allowlist: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
179
+ max_per_turn: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
180
+ }, z.core.$strip>>>;
175
181
  }, z.core.$strip>>;
176
182
  export type AstrocodeConfig = z.infer<typeof AstrocodeConfigSchema>;
177
183
  export {};
@@ -161,6 +161,15 @@ const GitSchema = z.object({
161
161
  commit_message_template: z.string().default("astro: {{story_key}} {{title}}"),
162
162
  persist_diff_artifacts: z.boolean().default(true),
163
163
  }).partial().default({});
164
+ const InjectSchema = z
165
+ .object({
166
+ enabled: z.boolean().default(true),
167
+ scope_allowlist: z.array(z.string()).default(["repo", "global"]),
168
+ type_allowlist: z.array(z.string()).default(["note", "policy"]),
169
+ max_per_turn: z.number().int().positive().default(5),
170
+ })
171
+ .partial()
172
+ .default({});
164
173
  const UiSchema = z
165
174
  .object({
166
175
  toasts: ToastsSchema,
@@ -196,4 +205,5 @@ export const AstrocodeConfigSchema = z.object({
196
205
  permissions: PermissionsSchema,
197
206
  git: GitSchema,
198
207
  ui: UiSchema,
208
+ inject: InjectSchema,
199
209
  }).partial().default({});
@@ -0,0 +1,14 @@
1
+ import type { AstrocodeConfig } from "../config/schema";
2
+ import type { SqliteDb } from "../state/db";
3
+ type ChatMessageInput = {
4
+ sessionID: string;
5
+ agent: string;
6
+ };
7
+ export declare function createInjectProvider(opts: {
8
+ ctx: any;
9
+ config: AstrocodeConfig;
10
+ db: SqliteDb;
11
+ }): {
12
+ onChatMessage(input: ChatMessageInput): Promise<void>;
13
+ };
14
+ export {};
@@ -0,0 +1,112 @@
1
+ import { selectEligibleInjects } from "../tools/injects";
2
+ import { injectChatPrompt } from "../ui/inject";
3
+ import { nowISO } from "../shared/time";
4
+ export function createInjectProvider(opts) {
5
+ const { ctx, config, db } = opts;
6
+ // Cache to avoid re-injecting the same injects repeatedly
7
+ const injectedCache = new Map();
8
+ function shouldSkipInject(injectId, nowMs) {
9
+ const lastInjected = injectedCache.get(injectId);
10
+ if (!lastInjected)
11
+ return false;
12
+ // Skip if injected within the last 5 minutes (configurable?)
13
+ const cooldownMs = 5 * 60 * 1000;
14
+ return nowMs - lastInjected < cooldownMs;
15
+ }
16
+ function markInjected(injectId, nowMs) {
17
+ injectedCache.set(injectId, nowMs);
18
+ }
19
+ function getInjectionDiagnostics(nowIso, scopeAllowlist, typeAllowlist) {
20
+ // Get ALL injects to analyze filtering
21
+ const allInjects = db.prepare("SELECT * FROM injects").all();
22
+ let total = allInjects.length;
23
+ let selected = 0;
24
+ let skippedExpired = 0;
25
+ let skippedScope = 0;
26
+ let skippedType = 0;
27
+ let eligibleIds = [];
28
+ for (const inject of allInjects) {
29
+ // Check expiration
30
+ if (inject.expires_at && inject.expires_at <= nowIso) {
31
+ skippedExpired++;
32
+ continue;
33
+ }
34
+ // Check scope
35
+ if (!scopeAllowlist.includes(inject.scope)) {
36
+ skippedScope++;
37
+ continue;
38
+ }
39
+ // Check type
40
+ if (!typeAllowlist.includes(inject.type)) {
41
+ skippedType++;
42
+ continue;
43
+ }
44
+ // This inject is eligible
45
+ selected++;
46
+ eligibleIds.push(inject.inject_id);
47
+ }
48
+ return {
49
+ now: nowIso,
50
+ scopes_considered: scopeAllowlist,
51
+ types_considered: typeAllowlist,
52
+ total_injects: total,
53
+ selected_eligible: selected,
54
+ skipped: {
55
+ expired: skippedExpired,
56
+ scope: skippedScope,
57
+ type: skippedType,
58
+ },
59
+ eligible_ids: eligibleIds,
60
+ };
61
+ }
62
+ async function injectEligibleInjects(sessionId) {
63
+ const now = nowISO();
64
+ const nowMs = Date.now();
65
+ // Get allowlists from config or defaults
66
+ const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
67
+ const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
68
+ // Get diagnostic data
69
+ const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
70
+ const eligibleInjects = selectEligibleInjects(db, {
71
+ nowIso: now,
72
+ scopeAllowlist,
73
+ typeAllowlist,
74
+ limit: config.inject?.max_per_turn ?? 5,
75
+ });
76
+ let injected = 0;
77
+ let skippedDeduped = 0;
78
+ if (eligibleInjects.length === 0) {
79
+ // Log when no injects are eligible
80
+ console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
81
+ return;
82
+ }
83
+ // Inject each eligible inject, skipping recently injected ones
84
+ for (const inject of eligibleInjects) {
85
+ if (shouldSkipInject(inject.inject_id, nowMs)) {
86
+ skippedDeduped++;
87
+ continue;
88
+ }
89
+ // Format as injection message
90
+ const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
91
+ await injectChatPrompt({
92
+ ctx,
93
+ sessionId,
94
+ text: formattedText,
95
+ agent: "Astrocode"
96
+ });
97
+ injected++;
98
+ markInjected(inject.inject_id, nowMs);
99
+ }
100
+ // Log diagnostic summary
101
+ console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
102
+ }
103
+ // Public hook handlers
104
+ return {
105
+ async onChatMessage(input) {
106
+ if (!config.inject?.enabled)
107
+ return;
108
+ // Inject eligible injects before processing the user's message
109
+ await injectEligibleInjects(input.sessionID);
110
+ },
111
+ };
112
+ }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { getAstroPaths, ensureAstroDirs } from "./shared/paths";
5
5
  import { createAstroTools } from "./tools";
6
6
  import { createContinuationEnforcer } from "./hooks/continuation-enforcer";
7
7
  import { createToolOutputTruncatorHook } from "./hooks/tool-output-truncator";
8
+ import { createInjectProvider } from "./hooks/inject-provider";
8
9
  import { createToastManager } from "./ui/toasts";
9
10
  import { createAstroAgents } from "./agents/registry";
10
11
  const Astrocode = async (ctx) => {
@@ -21,6 +22,7 @@ const Astrocode = async (ctx) => {
21
22
  let configHandler = null;
22
23
  let continuation = null;
23
24
  let truncatorHook = null;
25
+ let injectProvider = null;
24
26
  let toasts = null;
25
27
  try {
26
28
  db = openSqlite(paths.dbPath, { busyTimeoutMs: pluginConfig.db.busy_timeout_ms });
@@ -31,6 +33,7 @@ const Astrocode = async (ctx) => {
31
33
  tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
32
34
  continuation = createContinuationEnforcer({ ctx, config: pluginConfig, db });
33
35
  truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, db });
36
+ injectProvider = createInjectProvider({ ctx, config: pluginConfig, db });
34
37
  toasts = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
35
38
  }
36
39
  catch (e) {
@@ -46,6 +49,7 @@ const Astrocode = async (ctx) => {
46
49
  tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
47
50
  continuation = null;
48
51
  truncatorHook = null;
52
+ injectProvider = null;
49
53
  toasts = null;
50
54
  }
51
55
  return {
@@ -80,6 +84,9 @@ const Astrocode = async (ctx) => {
80
84
  }
81
85
  },
82
86
  "chat.message": async (input, output) => {
87
+ if (injectProvider && !pluginConfig.disabled_hooks.includes("inject-provider")) {
88
+ await injectProvider.onChatMessage(input);
89
+ }
83
90
  if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
84
91
  await continuation.onChatMessage(input);
85
92
  }
@@ -6,7 +6,7 @@ import { createAstroRunGetTool, createAstroRunAbortTool } from "./run";
6
6
  import { createAstroWorkflowProceedTool } from "./workflow";
7
7
  import { createAstroStageStartTool, createAstroStageCompleteTool, createAstroStageFailTool, createAstroStageResetTool } from "./stage";
8
8
  import { createAstroArtifactPutTool, createAstroArtifactListTool, createAstroArtifactGetTool } from "./artifacts";
9
- import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool } from "./injects";
9
+ import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool, createAstroInjectEligibleTool, createAstroInjectDebugDueTool } from "./injects";
10
10
  import { createAstroRepairTool } from "./repair";
11
11
  export function createAstroTools(opts) {
12
12
  const { ctx, config, db, agents } = opts;
@@ -39,6 +39,8 @@ export function createAstroTools(opts) {
39
39
  tools.astro_inject_list = createAstroInjectListTool({ ctx, config, db });
40
40
  tools.astro_inject_search = createAstroInjectSearchTool({ ctx, config, db });
41
41
  tools.astro_inject_get = createAstroInjectGetTool({ ctx, config, db });
42
+ tools.astro_inject_eligible = createAstroInjectEligibleTool({ ctx, config, db });
43
+ tools.astro_inject_debug_due = createAstroInjectDebugDueTool({ ctx, config, db });
42
44
  tools.astro_repair = createAstroRepairTool({ ctx, config, db });
43
45
  }
44
46
  else {
@@ -21,3 +21,33 @@ export declare function createAstroInjectSearchTool(opts: {
21
21
  config: AstrocodeConfig;
22
22
  db: SqliteDb;
23
23
  }): ToolDefinition;
24
+ export type InjectRow = {
25
+ inject_id: string;
26
+ type: string;
27
+ title: string;
28
+ body_md: string;
29
+ tags_json: string;
30
+ scope: string;
31
+ source: string;
32
+ priority: number;
33
+ expires_at: string | null;
34
+ sha256: string;
35
+ created_at: string;
36
+ updated_at: string;
37
+ };
38
+ export declare function selectEligibleInjects(db: SqliteDb, opts: {
39
+ nowIso: string;
40
+ scopeAllowlist: string[];
41
+ typeAllowlist: string[];
42
+ limit?: number;
43
+ }): InjectRow[];
44
+ export declare function createAstroInjectEligibleTool(opts: {
45
+ ctx: any;
46
+ config: AstrocodeConfig;
47
+ db: SqliteDb;
48
+ }): ToolDefinition;
49
+ export declare function createAstroInjectDebugDueTool(opts: {
50
+ ctx: any;
51
+ config: AstrocodeConfig;
52
+ db: SqliteDb;
53
+ }): ToolDefinition;
@@ -97,3 +97,122 @@ export function createAstroInjectSearchTool(opts) {
97
97
  },
98
98
  });
99
99
  }
100
+ export function selectEligibleInjects(db, opts) {
101
+ const { nowIso, scopeAllowlist, typeAllowlist, limit = 50 } = opts;
102
+ // Build placeholders safely
103
+ const scopeQs = scopeAllowlist.map(() => "?").join(", ");
104
+ const typeQs = typeAllowlist.map(() => "?").join(", ");
105
+ const sql = `
106
+ SELECT *
107
+ FROM injects
108
+ WHERE (expires_at IS NULL OR expires_at > ?)
109
+ AND scope IN (${scopeQs})
110
+ AND type IN (${typeQs})
111
+ ORDER BY priority DESC, updated_at DESC
112
+ LIMIT ?
113
+ `;
114
+ const params = [nowIso, ...scopeAllowlist, ...typeAllowlist, limit];
115
+ return db.prepare(sql).all(...params);
116
+ }
117
+ export function createAstroInjectEligibleTool(opts) {
118
+ const { db } = opts;
119
+ return tool({
120
+ description: "Debug: show which injects are eligible right now for injection.",
121
+ args: {
122
+ scopes_json: tool.schema.string().default('["repo","global"]'),
123
+ types_json: tool.schema.string().default('["note","policy"]'),
124
+ limit: tool.schema.number().int().positive().default(50),
125
+ },
126
+ execute: async ({ scopes_json, types_json, limit }) => {
127
+ const now = nowISO();
128
+ const scopes = JSON.parse(scopes_json);
129
+ const types = JSON.parse(types_json);
130
+ const rows = selectEligibleInjects(db, {
131
+ nowIso: now,
132
+ scopeAllowlist: scopes,
133
+ typeAllowlist: types,
134
+ limit,
135
+ });
136
+ return JSON.stringify({ now, count: rows.length, rows }, null, 2);
137
+ },
138
+ });
139
+ }
140
+ export function createAstroInjectDebugDueTool(opts) {
141
+ const { db } = opts;
142
+ return tool({
143
+ description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
144
+ args: {
145
+ scopes_json: tool.schema.string().default('["repo","global"]'),
146
+ types_json: tool.schema.string().default('["note","policy"]'),
147
+ },
148
+ execute: async ({ scopes_json, types_json }) => {
149
+ const now = nowISO();
150
+ const scopes = JSON.parse(scopes_json);
151
+ const types = JSON.parse(types_json);
152
+ // Get ALL injects to analyze filtering
153
+ const allInjects = db.prepare("SELECT * FROM injects").all();
154
+ let total = allInjects.length;
155
+ let selected = 0;
156
+ let skippedExpired = 0;
157
+ let skippedScope = 0;
158
+ let skippedType = 0;
159
+ const excludedReasons = [];
160
+ const selectedInjects = [];
161
+ for (const inject of allInjects) {
162
+ const reasons = [];
163
+ // Check expiration
164
+ if (inject.expires_at && inject.expires_at <= now) {
165
+ reasons.push("expired");
166
+ skippedExpired++;
167
+ }
168
+ // Check scope
169
+ if (!scopes.includes(inject.scope)) {
170
+ reasons.push("scope");
171
+ skippedScope++;
172
+ }
173
+ // Check type
174
+ if (!types.includes(inject.type)) {
175
+ reasons.push("type");
176
+ skippedType++;
177
+ }
178
+ if (reasons.length > 0) {
179
+ excludedReasons.push({
180
+ inject_id: inject.inject_id,
181
+ title: inject.title,
182
+ reasons: reasons,
183
+ scope: inject.scope,
184
+ type: inject.type,
185
+ expires_at: inject.expires_at,
186
+ });
187
+ }
188
+ else {
189
+ selected++;
190
+ selectedInjects.push({
191
+ inject_id: inject.inject_id,
192
+ title: inject.title,
193
+ scope: inject.scope,
194
+ type: inject.type,
195
+ expires_at: inject.expires_at,
196
+ });
197
+ }
198
+ }
199
+ return JSON.stringify({
200
+ now,
201
+ scopes_considered: scopes,
202
+ types_considered: types,
203
+ summary: {
204
+ total_injects: total,
205
+ selected_eligible: selected,
206
+ excluded_total: total - selected,
207
+ skipped_breakdown: {
208
+ expired: skippedExpired,
209
+ scope: skippedScope,
210
+ type: skippedType,
211
+ }
212
+ },
213
+ selected_injects: selectedInjects,
214
+ excluded_injects: excludedReasons,
215
+ }, null, 2);
216
+ },
217
+ });
218
+ }
@@ -1,6 +1,9 @@
1
1
  import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
2
  import type { AstrocodeConfig } from "../config/schema";
3
3
  import type { SqliteDb } from "../state/db";
4
+ import type { StageKey } from "../state/types";
5
+ export declare const STAGE_TO_AGENT_MAP: Record<string, string>;
6
+ export declare function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string;
4
7
  import { AgentConfig } from "@opencode-ai/sdk";
5
8
  export declare function createAstroWorkflowProceedTool(opts: {
6
9
  ctx: any;
@@ -9,22 +9,48 @@ import { newEventId } from "../state/ids";
9
9
  import { debug } from "../shared/log";
10
10
  import { createToastManager } from "../ui/toasts";
11
11
  // Agent name mapping for case-sensitive resolution
12
- const STAGE_TO_AGENT_MAP = {
13
- frame: "frame",
14
- plan: "plan",
15
- spec: "spec",
16
- implement: "implement",
17
- review: "review",
18
- verify: "verify",
19
- close: "close"
12
+ export const STAGE_TO_AGENT_MAP = {
13
+ frame: "Frame",
14
+ plan: "Plan",
15
+ spec: "Spec",
16
+ implement: "Implement",
17
+ review: "Review",
18
+ verify: "Verify",
19
+ close: "Close"
20
20
  };
21
- function resolveAgentName(stageKey, config) {
21
+ export function resolveAgentName(stageKey, config, agents, warnings) {
22
22
  // Use configurable agent names from config, fallback to hardcoded map, then General
23
23
  const agentNames = config.agents?.stage_agent_names;
24
+ let candidate;
24
25
  if (agentNames && agentNames[stageKey]) {
25
- return agentNames[stageKey];
26
+ candidate = agentNames[stageKey];
26
27
  }
27
- return STAGE_TO_AGENT_MAP[stageKey] || "General";
28
+ else {
29
+ candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
30
+ }
31
+ // Validate that the agent actually exists in the registry
32
+ if (agents && !agents[candidate]) {
33
+ const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
34
+ if (warnings) {
35
+ warnings.push(warning);
36
+ }
37
+ else {
38
+ console.warn(`[Astrocode] ${warning}`);
39
+ }
40
+ candidate = "General";
41
+ }
42
+ // Final guard: ensure General exists, fallback to built-in "general" if not
43
+ if (agents && !agents[candidate]) {
44
+ const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
45
+ if (warnings) {
46
+ warnings.push(finalWarning);
47
+ }
48
+ else {
49
+ console.warn(`[Astrocode] ${finalWarning}`);
50
+ }
51
+ return "general"; // built-in, guaranteed by OpenCode
52
+ }
53
+ return candidate;
28
54
  }
29
55
  function stageGoal(stage, cfg) {
30
56
  switch (stage) {
@@ -99,6 +125,7 @@ export function createAstroWorkflowProceedTool(opts) {
99
125
  const sessionId = ctx.sessionID;
100
126
  const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
101
127
  const actions = [];
128
+ const warnings = [];
102
129
  const startedAt = nowISO();
103
130
  for (let i = 0; i < steps; i++) {
104
131
  const next = decideNextAction(db, config);
@@ -172,7 +199,7 @@ export function createAstroWorkflowProceedTool(opts) {
172
199
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
173
200
  const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
174
201
  // Mark stage started + set subagent_type to the stage agent.
175
- let agentName = resolveAgentName(next.stage_key, config);
202
+ let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
176
203
  // Validate agent availability with fallback chain
177
204
  const systemConfig = config;
178
205
  // Check both the system config agent map (if present) OR the local agents map passed to the tool
@@ -197,7 +224,7 @@ export function createAstroWorkflowProceedTool(opts) {
197
224
  // Skip General fallback for stage agents to avoid malformed output
198
225
  agentName = config.agents?.orchestrator_name || "Astro";
199
226
  if (!agentExists(agentName)) {
200
- throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config)}, Orchestrator: ${agentName}`);
227
+ throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config, agents, warnings)}, Orchestrator: ${agentName}`);
201
228
  }
202
229
  }
203
230
  withTx(db, () => {
@@ -291,6 +318,11 @@ export function createAstroWorkflowProceedTool(opts) {
291
318
  lines.push(``, `## Actions`);
292
319
  for (const a of actions)
293
320
  lines.push(`- ${a}`);
321
+ if (warnings.length > 0) {
322
+ lines.push(``, `## Warnings`);
323
+ for (const w of warnings)
324
+ lines.push(`⚠️ ${w}`);
325
+ }
294
326
  return lines.join("\n").trim();
295
327
  },
296
328
  });
@@ -4,6 +4,14 @@ import type { RunRow, StageRunRow, StoryRow } from "../state/types";
4
4
  export declare function getRun(db: SqliteDb, runId: string): RunRow | null;
5
5
  export declare function getStory(db: SqliteDb, storyKey: string): StoryRow | null;
6
6
  export declare function listStageRuns(db: SqliteDb, runId: string): StageRunRow[];
7
+ /**
8
+ * Check if a context snapshot is stale by comparing DB timestamps
9
+ */
10
+ export declare function isContextSnapshotStale(snapshotText: string, db: SqliteDb, maxAgeSeconds?: number): boolean;
11
+ /**
12
+ * Add staleness indicator to context snapshot if needed
13
+ */
14
+ export declare function addStalenessIndicator(snapshotText: string, db: SqliteDb, maxAgeSeconds?: number): string;
7
15
  export declare function buildContextSnapshot(opts: {
8
16
  db: SqliteDb;
9
17
  config: AstrocodeConfig;
@@ -27,6 +27,49 @@ function statusIcon(status) {
27
27
  return "⬜";
28
28
  }
29
29
  }
30
+ /**
31
+ * Check if a context snapshot is stale by comparing DB timestamps
32
+ */
33
+ export function isContextSnapshotStale(snapshotText, db, maxAgeSeconds = 300) {
34
+ // Extract run_id from snapshot
35
+ const runIdMatch = snapshotText.match(/Run: `([^`]+)`/);
36
+ if (!runIdMatch)
37
+ return true; // Can't validate without run_id
38
+ const runId = runIdMatch[1];
39
+ // Extract snapshot's claimed updated_at
40
+ const snapshotUpdatedMatch = snapshotText.match(/updated: ([^\)]+)\)/);
41
+ if (!snapshotUpdatedMatch)
42
+ return true; // Fallback to age-based check
43
+ try {
44
+ const snapshotUpdatedAt = snapshotUpdatedMatch[1];
45
+ const currentRun = db.prepare("SELECT updated_at FROM runs WHERE run_id = ?").get(runId);
46
+ if (!currentRun?.updated_at)
47
+ return true; // Run doesn't exist
48
+ // Compare timestamps - if DB is newer than snapshot claims, snapshot is stale
49
+ const snapshotTime = new Date(snapshotUpdatedAt).getTime();
50
+ const currentTime = new Date(currentRun.updated_at).getTime();
51
+ return currentTime > snapshotTime;
52
+ }
53
+ catch (error) {
54
+ // Fallback to age-based staleness if parsing fails
55
+ const timestampMatch = snapshotText.match(/generated: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
56
+ if (!timestampMatch)
57
+ return false;
58
+ const generatedAt = new Date(timestampMatch[1]);
59
+ const now = new Date();
60
+ const ageSeconds = (now.getTime() - generatedAt.getTime()) / 1000;
61
+ return ageSeconds > maxAgeSeconds;
62
+ }
63
+ }
64
+ /**
65
+ * Add staleness indicator to context snapshot if needed
66
+ */
67
+ export function addStalenessIndicator(snapshotText, db, maxAgeSeconds = 300) {
68
+ if (isContextSnapshotStale(snapshotText, db, maxAgeSeconds)) {
69
+ return snapshotText.replace(/# Astrocode Context \(generated: ([^\)]+)\)/, "# Astrocode Context (generated: $1) ⚠️ STALE - DB state has changed");
70
+ }
71
+ return snapshotText;
72
+ }
30
73
  export function buildContextSnapshot(opts) {
31
74
  const { db, config, run_id, next_action } = opts;
32
75
  const run = getRun(db, run_id);
@@ -35,8 +78,11 @@ export function buildContextSnapshot(opts) {
35
78
  const story = getStory(db, run.story_key);
36
79
  const stageRuns = listStageRuns(db, run_id);
37
80
  const lines = [];
38
- lines.push(`# Astrocode Context`);
39
- lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}**`);
81
+ // Add timestamps for staleness checking
82
+ const now = new Date();
83
+ const timestamp = now.toISOString();
84
+ lines.push(`# Astrocode Context (generated: ${timestamp})`);
85
+ lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}** (updated: ${run.updated_at})`);
40
86
  if (run.current_stage_key)
41
87
  lines.push(`- Current stage: \`${run.current_stage_key}\``);
42
88
  if (next_action)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.1.53",
3
+ "version": "0.1.56",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -194,6 +194,16 @@ const GitSchema = z.object({
194
194
  persist_diff_artifacts: z.boolean().default(true),
195
195
  }).partial().default({});
196
196
 
197
+ const InjectSchema = z
198
+ .object({
199
+ enabled: z.boolean().default(true),
200
+ scope_allowlist: z.array(z.string()).default(["repo", "global"]),
201
+ type_allowlist: z.array(z.string()).default(["note", "policy"]),
202
+ max_per_turn: z.number().int().positive().default(5),
203
+ })
204
+ .partial()
205
+ .default({});
206
+
197
207
  const UiSchema = z
198
208
  .object({
199
209
  toasts: ToastsSchema,
@@ -232,6 +242,7 @@ export const AstrocodeConfigSchema = z.object({
232
242
  permissions: PermissionsSchema,
233
243
  git: GitSchema,
234
244
  ui: UiSchema,
245
+ inject: InjectSchema,
235
246
  }).partial().default({});
236
247
 
237
248
  export type AstrocodeConfig = z.infer<typeof AstrocodeConfigSchema>;
@@ -0,0 +1,146 @@
1
+ import type { AstrocodeConfig } from "../config/schema";
2
+ import type { SqliteDb } from "../state/db";
3
+ import { selectEligibleInjects } from "../tools/injects";
4
+ import { injectChatPrompt } from "../ui/inject";
5
+ import { nowISO } from "../shared/time";
6
+
7
+ type ChatMessageInput = {
8
+ sessionID: string;
9
+ agent: string;
10
+ };
11
+
12
+ export function createInjectProvider(opts: {
13
+ ctx: any;
14
+ config: AstrocodeConfig;
15
+ db: SqliteDb;
16
+ }) {
17
+ const { ctx, config, db } = opts;
18
+
19
+ // Cache to avoid re-injecting the same injects repeatedly
20
+ const injectedCache = new Map<string, number>();
21
+
22
+ function shouldSkipInject(injectId: string, nowMs: number): boolean {
23
+ const lastInjected = injectedCache.get(injectId);
24
+ if (!lastInjected) return false;
25
+
26
+ // Skip if injected within the last 5 minutes (configurable?)
27
+ const cooldownMs = 5 * 60 * 1000;
28
+ return nowMs - lastInjected < cooldownMs;
29
+ }
30
+
31
+ function markInjected(injectId: string, nowMs: number) {
32
+ injectedCache.set(injectId, nowMs);
33
+ }
34
+
35
+ function getInjectionDiagnostics(nowIso: string, scopeAllowlist: string[], typeAllowlist: string[]): any {
36
+ // Get ALL injects to analyze filtering
37
+ const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
38
+
39
+ let total = allInjects.length;
40
+ let selected = 0;
41
+ let skippedExpired = 0;
42
+ let skippedScope = 0;
43
+ let skippedType = 0;
44
+ let eligibleIds: string[] = [];
45
+
46
+ for (const inject of allInjects) {
47
+ // Check expiration
48
+ if (inject.expires_at && inject.expires_at <= nowIso) {
49
+ skippedExpired++;
50
+ continue;
51
+ }
52
+
53
+ // Check scope
54
+ if (!scopeAllowlist.includes(inject.scope)) {
55
+ skippedScope++;
56
+ continue;
57
+ }
58
+
59
+ // Check type
60
+ if (!typeAllowlist.includes(inject.type)) {
61
+ skippedType++;
62
+ continue;
63
+ }
64
+
65
+ // This inject is eligible
66
+ selected++;
67
+ eligibleIds.push(inject.inject_id);
68
+ }
69
+
70
+ return {
71
+ now: nowIso,
72
+ scopes_considered: scopeAllowlist,
73
+ types_considered: typeAllowlist,
74
+ total_injects: total,
75
+ selected_eligible: selected,
76
+ skipped: {
77
+ expired: skippedExpired,
78
+ scope: skippedScope,
79
+ type: skippedType,
80
+ },
81
+ eligible_ids: eligibleIds,
82
+ };
83
+ }
84
+
85
+ async function injectEligibleInjects(sessionId: string) {
86
+ const now = nowISO();
87
+ const nowMs = Date.now();
88
+
89
+ // Get allowlists from config or defaults
90
+ const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
91
+ const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
92
+
93
+ // Get diagnostic data
94
+ const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
95
+
96
+ const eligibleInjects = selectEligibleInjects(db, {
97
+ nowIso: now,
98
+ scopeAllowlist,
99
+ typeAllowlist,
100
+ limit: config.inject?.max_per_turn ?? 5,
101
+ });
102
+
103
+ let injected = 0;
104
+ let skippedDeduped = 0;
105
+
106
+ if (eligibleInjects.length === 0) {
107
+ // Log when no injects are eligible
108
+ console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
109
+ return;
110
+ }
111
+
112
+ // Inject each eligible inject, skipping recently injected ones
113
+ for (const inject of eligibleInjects) {
114
+ if (shouldSkipInject(inject.inject_id, nowMs)) {
115
+ skippedDeduped++;
116
+ continue;
117
+ }
118
+
119
+ // Format as injection message
120
+ const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
121
+
122
+ await injectChatPrompt({
123
+ ctx,
124
+ sessionId,
125
+ text: formattedText,
126
+ agent: "Astrocode"
127
+ });
128
+
129
+ injected++;
130
+ markInjected(inject.inject_id, nowMs);
131
+ }
132
+
133
+ // Log diagnostic summary
134
+ console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
135
+ }
136
+
137
+ // Public hook handlers
138
+ return {
139
+ async onChatMessage(input: ChatMessageInput) {
140
+ if (!config.inject?.enabled) return;
141
+
142
+ // Inject eligible injects before processing the user's message
143
+ await injectEligibleInjects(input.sessionID);
144
+ },
145
+ };
146
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { getAstroPaths, ensureAstroDirs } from "./shared/paths";
6
6
  import { createAstroTools } from "./tools";
7
7
  import { createContinuationEnforcer } from "./hooks/continuation-enforcer";
8
8
  import { createToolOutputTruncatorHook } from "./hooks/tool-output-truncator";
9
+ import { createInjectProvider } from "./hooks/inject-provider";
9
10
  import { createToastManager } from "./ui/toasts";
10
11
  import { createAstroAgents } from "./agents/registry";
11
12
  import { info, warn } from "./shared/log";
@@ -25,10 +26,11 @@ const Astrocode: Plugin = async (ctx) => {
25
26
 
26
27
  let db: any = null;
27
28
  let tools: any = null;
28
- let configHandler: any = null;
29
- let continuation: any = null;
30
- let truncatorHook: any = null;
31
- let toasts: any = null;
29
+ let configHandler: any = null;
30
+ let continuation: any = null;
31
+ let truncatorHook: any = null;
32
+ let injectProvider: any = null;
33
+ let toasts: any = null;
32
34
 
33
35
  try {
34
36
 
@@ -41,6 +43,7 @@ const Astrocode: Plugin = async (ctx) => {
41
43
  tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
42
44
  continuation = createContinuationEnforcer({ ctx, config: pluginConfig, db });
43
45
  truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, db });
46
+ injectProvider = createInjectProvider({ ctx, config: pluginConfig, db });
44
47
  toasts = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
45
48
  } catch (e) {
46
49
  // Database initialization failed - setup limited mode
@@ -58,6 +61,7 @@ const Astrocode: Plugin = async (ctx) => {
58
61
  tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
59
62
  continuation = null;
60
63
  truncatorHook = null;
64
+ injectProvider = null;
61
65
  toasts = null;
62
66
  }
63
67
 
@@ -102,6 +106,9 @@ const Astrocode: Plugin = async (ctx) => {
102
106
  },
103
107
 
104
108
  "chat.message": async (input: any, output: any) => {
109
+ if (injectProvider && !pluginConfig.disabled_hooks.includes("inject-provider")) {
110
+ await injectProvider.onChatMessage(input);
111
+ }
105
112
  if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
106
113
  await continuation.onChatMessage(input);
107
114
  }
@@ -10,7 +10,7 @@ import { createAstroRunGetTool, createAstroRunAbortTool } from "./run";
10
10
  import { createAstroWorkflowProceedTool } from "./workflow";
11
11
  import { createAstroStageStartTool, createAstroStageCompleteTool, createAstroStageFailTool, createAstroStageResetTool } from "./stage";
12
12
  import { createAstroArtifactPutTool, createAstroArtifactListTool, createAstroArtifactGetTool } from "./artifacts";
13
- import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool } from "./injects";
13
+ import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool, createAstroInjectEligibleTool, createAstroInjectDebugDueTool } from "./injects";
14
14
  import { createAstroRepairTool } from "./repair";
15
15
 
16
16
  import { AgentConfig } from "@opencode-ai/sdk";
@@ -50,6 +50,8 @@ export function createAstroTools(opts: { ctx: any; config: AstrocodeConfig; db:
50
50
  tools.astro_inject_list = createAstroInjectListTool({ ctx, config, db });
51
51
  tools.astro_inject_search = createAstroInjectSearchTool({ ctx, config, db });
52
52
  tools.astro_inject_get = createAstroInjectGetTool({ ctx, config, db });
53
+ tools.astro_inject_eligible = createAstroInjectEligibleTool({ ctx, config, db });
54
+ tools.astro_inject_debug_due = createAstroInjectDebugDueTool({ ctx, config, db });
53
55
  tools.astro_repair = createAstroRepairTool({ ctx, config, db });
54
56
  } else {
55
57
  // Limited mode tools - provide helpful messages instead of failing
@@ -106,3 +106,159 @@ export function createAstroInjectSearchTool(opts: { ctx: any; config: AstrocodeC
106
106
  },
107
107
  });
108
108
  }
109
+
110
+ export type InjectRow = {
111
+ inject_id: string;
112
+ type: string;
113
+ title: string;
114
+ body_md: string;
115
+ tags_json: string;
116
+ scope: string;
117
+ source: string;
118
+ priority: number;
119
+ expires_at: string | null;
120
+ sha256: string;
121
+ created_at: string;
122
+ updated_at: string;
123
+ };
124
+
125
+ export function selectEligibleInjects(db: SqliteDb, opts: {
126
+ nowIso: string;
127
+ scopeAllowlist: string[];
128
+ typeAllowlist: string[];
129
+ limit?: number;
130
+ }): InjectRow[] {
131
+ const { nowIso, scopeAllowlist, typeAllowlist, limit = 50 } = opts;
132
+
133
+ // Build placeholders safely
134
+ const scopeQs = scopeAllowlist.map(() => "?").join(", ");
135
+ const typeQs = typeAllowlist.map(() => "?").join(", ");
136
+
137
+ const sql = `
138
+ SELECT *
139
+ FROM injects
140
+ WHERE (expires_at IS NULL OR expires_at > ?)
141
+ AND scope IN (${scopeQs})
142
+ AND type IN (${typeQs})
143
+ ORDER BY priority DESC, updated_at DESC
144
+ LIMIT ?
145
+ `;
146
+
147
+ const params = [nowIso, ...scopeAllowlist, ...typeAllowlist, limit];
148
+ return db.prepare(sql).all(...params) as InjectRow[];
149
+ }
150
+
151
+ export function createAstroInjectEligibleTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
152
+ const { db } = opts;
153
+
154
+ return tool({
155
+ description: "Debug: show which injects are eligible right now for injection.",
156
+ args: {
157
+ scopes_json: tool.schema.string().default('["repo","global"]'),
158
+ types_json: tool.schema.string().default('["note","policy"]'),
159
+ limit: tool.schema.number().int().positive().default(50),
160
+ },
161
+ execute: async ({ scopes_json, types_json, limit }) => {
162
+ const now = nowISO();
163
+ const scopes = JSON.parse(scopes_json) as string[];
164
+ const types = JSON.parse(types_json) as string[];
165
+
166
+ const rows = selectEligibleInjects(db, {
167
+ nowIso: now,
168
+ scopeAllowlist: scopes,
169
+ typeAllowlist: types,
170
+ limit,
171
+ });
172
+
173
+ return JSON.stringify({ now, count: rows.length, rows }, null, 2);
174
+ },
175
+ });
176
+ }
177
+
178
+ export function createAstroInjectDebugDueTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
179
+ const { db } = opts;
180
+
181
+ return tool({
182
+ description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
183
+ args: {
184
+ scopes_json: tool.schema.string().default('["repo","global"]'),
185
+ types_json: tool.schema.string().default('["note","policy"]'),
186
+ },
187
+ execute: async ({ scopes_json, types_json }) => {
188
+ const now = nowISO();
189
+ const scopes = JSON.parse(scopes_json) as string[];
190
+ const types = JSON.parse(types_json) as string[];
191
+
192
+ // Get ALL injects to analyze filtering
193
+ const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
194
+
195
+ let total = allInjects.length;
196
+ let selected = 0;
197
+ let skippedExpired = 0;
198
+ let skippedScope = 0;
199
+ let skippedType = 0;
200
+ const excludedReasons: any[] = [];
201
+ const selectedInjects: any[] = [];
202
+
203
+ for (const inject of allInjects) {
204
+ const reasons: string[] = [];
205
+
206
+ // Check expiration
207
+ if (inject.expires_at && inject.expires_at <= now) {
208
+ reasons.push("expired");
209
+ skippedExpired++;
210
+ }
211
+
212
+ // Check scope
213
+ if (!scopes.includes(inject.scope)) {
214
+ reasons.push("scope");
215
+ skippedScope++;
216
+ }
217
+
218
+ // Check type
219
+ if (!types.includes(inject.type)) {
220
+ reasons.push("type");
221
+ skippedType++;
222
+ }
223
+
224
+ if (reasons.length > 0) {
225
+ excludedReasons.push({
226
+ inject_id: inject.inject_id,
227
+ title: inject.title,
228
+ reasons: reasons,
229
+ scope: inject.scope,
230
+ type: inject.type,
231
+ expires_at: inject.expires_at,
232
+ });
233
+ } else {
234
+ selected++;
235
+ selectedInjects.push({
236
+ inject_id: inject.inject_id,
237
+ title: inject.title,
238
+ scope: inject.scope,
239
+ type: inject.type,
240
+ expires_at: inject.expires_at,
241
+ });
242
+ }
243
+ }
244
+
245
+ return JSON.stringify({
246
+ now,
247
+ scopes_considered: scopes,
248
+ types_considered: types,
249
+ summary: {
250
+ total_injects: total,
251
+ selected_eligible: selected,
252
+ excluded_total: total - selected,
253
+ skipped_breakdown: {
254
+ expired: skippedExpired,
255
+ scope: skippedScope,
256
+ type: skippedType,
257
+ }
258
+ },
259
+ selected_injects: selectedInjects,
260
+ excluded_injects: excludedReasons,
261
+ }, null, 2);
262
+ },
263
+ });
264
+ }
@@ -13,23 +13,50 @@ import { debug } from "../shared/log";
13
13
  import { createToastManager } from "../ui/toasts";
14
14
 
15
15
  // Agent name mapping for case-sensitive resolution
16
- const STAGE_TO_AGENT_MAP: Record<string, string> = {
17
- frame: "frame",
18
- plan: "plan",
19
- spec: "spec",
20
- implement: "implement",
21
- review: "review",
22
- verify: "verify",
23
- close: "close"
16
+ export const STAGE_TO_AGENT_MAP: Record<string, string> = {
17
+ frame: "Frame",
18
+ plan: "Plan",
19
+ spec: "Spec",
20
+ implement: "Implement",
21
+ review: "Review",
22
+ verify: "Verify",
23
+ close: "Close"
24
24
  };
25
25
 
26
- function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig): string {
26
+ export function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string {
27
27
  // Use configurable agent names from config, fallback to hardcoded map, then General
28
28
  const agentNames = config.agents?.stage_agent_names;
29
+ let candidate: string;
30
+
29
31
  if (agentNames && agentNames[stageKey]) {
30
- return agentNames[stageKey];
32
+ candidate = agentNames[stageKey];
33
+ } else {
34
+ candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
35
+ }
36
+
37
+ // Validate that the agent actually exists in the registry
38
+ if (agents && !agents[candidate]) {
39
+ const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
40
+ if (warnings) {
41
+ warnings.push(warning);
42
+ } else {
43
+ console.warn(`[Astrocode] ${warning}`);
44
+ }
45
+ candidate = "General";
46
+ }
47
+
48
+ // Final guard: ensure General exists, fallback to built-in "general" if not
49
+ if (agents && !agents[candidate]) {
50
+ const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
51
+ if (warnings) {
52
+ warnings.push(finalWarning);
53
+ } else {
54
+ console.warn(`[Astrocode] ${finalWarning}`);
55
+ }
56
+ return "general"; // built-in, guaranteed by OpenCode
31
57
  }
32
- return STAGE_TO_AGENT_MAP[stageKey] || "General";
58
+
59
+ return candidate;
33
60
  }
34
61
 
35
62
  function stageGoal(stage: StageKey, cfg: AstrocodeConfig): string {
@@ -123,6 +150,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
123
150
  const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
124
151
 
125
152
  const actions: string[] = [];
153
+ const warnings: string[] = [];
126
154
  const startedAt = nowISO();
127
155
 
128
156
  for (let i = 0; i < steps; i++) {
@@ -212,7 +240,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
212
240
  const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
213
241
 
214
242
  // Mark stage started + set subagent_type to the stage agent.
215
- let agentName = resolveAgentName(next.stage_key, config);
243
+ let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
216
244
 
217
245
  // Validate agent availability with fallback chain
218
246
  const systemConfig = config as any;
@@ -239,7 +267,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
239
267
  // Skip General fallback for stage agents to avoid malformed output
240
268
  agentName = config.agents?.orchestrator_name || "Astro";
241
269
  if (!agentExists(agentName)) {
242
- throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config)}, Orchestrator: ${agentName}`);
270
+ throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config, agents, warnings)}, Orchestrator: ${agentName}`);
243
271
  }
244
272
  }
245
273
 
@@ -355,6 +383,11 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
355
383
  lines.push(``, `## Actions`);
356
384
  for (const a of actions) lines.push(`- ${a}`);
357
385
 
386
+ if (warnings.length > 0) {
387
+ lines.push(``, `## Warnings`);
388
+ for (const w of warnings) lines.push(`⚠️ ${w}`);
389
+ }
390
+
358
391
  return lines.join("\n").trim();
359
392
  },
360
393
  });
@@ -35,6 +35,57 @@ function statusIcon(status: string): string {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Check if a context snapshot is stale by comparing DB timestamps
40
+ */
41
+ export function isContextSnapshotStale(snapshotText: string, db: SqliteDb, maxAgeSeconds: number = 300): boolean {
42
+ // Extract run_id from snapshot
43
+ const runIdMatch = snapshotText.match(/Run: `([^`]+)`/);
44
+ if (!runIdMatch) return true; // Can't validate without run_id
45
+
46
+ const runId = runIdMatch[1];
47
+
48
+ // Extract snapshot's claimed updated_at
49
+ const snapshotUpdatedMatch = snapshotText.match(/updated: ([^\)]+)\)/);
50
+ if (!snapshotUpdatedMatch) return true; // Fallback to age-based check
51
+
52
+ try {
53
+ const snapshotUpdatedAt = snapshotUpdatedMatch[1];
54
+ const currentRun = db.prepare("SELECT updated_at FROM runs WHERE run_id = ?").get(runId) as { updated_at?: string };
55
+
56
+ if (!currentRun?.updated_at) return true; // Run doesn't exist
57
+
58
+ // Compare timestamps - if DB is newer than snapshot claims, snapshot is stale
59
+ const snapshotTime = new Date(snapshotUpdatedAt).getTime();
60
+ const currentTime = new Date(currentRun.updated_at).getTime();
61
+
62
+ return currentTime > snapshotTime;
63
+ } catch (error) {
64
+ // Fallback to age-based staleness if parsing fails
65
+ const timestampMatch = snapshotText.match(/generated: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
66
+ if (!timestampMatch) return false;
67
+
68
+ const generatedAt = new Date(timestampMatch[1]);
69
+ const now = new Date();
70
+ const ageSeconds = (now.getTime() - generatedAt.getTime()) / 1000;
71
+
72
+ return ageSeconds > maxAgeSeconds;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Add staleness indicator to context snapshot if needed
78
+ */
79
+ export function addStalenessIndicator(snapshotText: string, db: SqliteDb, maxAgeSeconds: number = 300): string {
80
+ if (isContextSnapshotStale(snapshotText, db, maxAgeSeconds)) {
81
+ return snapshotText.replace(
82
+ /# Astrocode Context \(generated: ([^\)]+)\)/,
83
+ "# Astrocode Context (generated: $1) ⚠️ STALE - DB state has changed"
84
+ );
85
+ }
86
+ return snapshotText;
87
+ }
88
+
38
89
  export function buildContextSnapshot(opts: {
39
90
  db: SqliteDb;
40
91
  config: AstrocodeConfig;
@@ -52,8 +103,11 @@ export function buildContextSnapshot(opts: {
52
103
 
53
104
  const lines: string[] = [];
54
105
 
55
- lines.push(`# Astrocode Context`);
56
- lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}**`);
106
+ // Add timestamps for staleness checking
107
+ const now = new Date();
108
+ const timestamp = now.toISOString();
109
+ lines.push(`# Astrocode Context (generated: ${timestamp})`);
110
+ lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}** (updated: ${run.updated_at})`);
57
111
  if (run.current_stage_key) lines.push(`- Current stage: \`${run.current_stage_key}\``);
58
112
  if (next_action) lines.push(`- Next action: ${next_action}`);
59
113
 
@@ -2,6 +2,7 @@ import type { AstrocodeConfig } from "../config/schema";
2
2
  import { sha256Hex } from "../shared/hash";
3
3
  import { clampChars, normalizeNewlines } from "../shared/text";
4
4
  import type { StageKey } from "../state/types";
5
+ import { addStalenessIndicator } from "./context";
5
6
 
6
7
  export type DirectiveKind = "continue" | "stage" | "blocked" | "repair";
7
8
 
@@ -77,8 +78,8 @@ export function buildBlockedDirective(opts: {
77
78
  ``,
78
79
  `Question: ${question}`,
79
80
  ``,
80
- `Context snapshot:`,
81
- context_snapshot_md.trim(),
81
+ `Context snapshot:`,
82
+ context_snapshot_md.trim(),
82
83
  ].join("\n")
83
84
  ).trim();
84
85