astrocode-workflow 0.1.55 → 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.
@@ -16,24 +16,76 @@ export function createInjectProvider(opts) {
16
16
  function markInjected(injectId, nowMs) {
17
17
  injectedCache.set(injectId, nowMs);
18
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
+ }
19
62
  async function injectEligibleInjects(sessionId) {
20
63
  const now = nowISO();
21
64
  const nowMs = Date.now();
22
- // Get eligible injects - use allowlists from config or defaults
65
+ // Get allowlists from config or defaults
23
66
  const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
24
67
  const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
68
+ // Get diagnostic data
69
+ const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
25
70
  const eligibleInjects = selectEligibleInjects(db, {
26
71
  nowIso: now,
27
72
  scopeAllowlist,
28
73
  typeAllowlist,
29
74
  limit: config.inject?.max_per_turn ?? 5,
30
75
  });
31
- if (eligibleInjects.length === 0)
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}`);
32
81
  return;
82
+ }
33
83
  // Inject each eligible inject, skipping recently injected ones
34
84
  for (const inject of eligibleInjects) {
35
- if (shouldSkipInject(inject.inject_id, nowMs))
85
+ if (shouldSkipInject(inject.inject_id, nowMs)) {
86
+ skippedDeduped++;
36
87
  continue;
88
+ }
37
89
  // Format as injection message
38
90
  const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
39
91
  await injectChatPrompt({
@@ -42,8 +94,11 @@ export function createInjectProvider(opts) {
42
94
  text: formattedText,
43
95
  agent: "Astrocode"
44
96
  });
97
+ injected++;
45
98
  markInjected(inject.inject_id, nowMs);
46
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}}`);
47
102
  }
48
103
  // Public hook handlers
49
104
  return {
@@ -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 {
@@ -46,3 +46,8 @@ export declare function createAstroInjectEligibleTool(opts: {
46
46
  config: AstrocodeConfig;
47
47
  db: SqliteDb;
48
48
  }): ToolDefinition;
49
+ export declare function createAstroInjectDebugDueTool(opts: {
50
+ ctx: any;
51
+ config: AstrocodeConfig;
52
+ db: SqliteDb;
53
+ }): ToolDefinition;
@@ -137,3 +137,82 @@ export function createAstroInjectEligibleTool(opts) {
137
137
  },
138
138
  });
139
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,7 +9,7 @@ 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 = {
12
+ export const STAGE_TO_AGENT_MAP = {
13
13
  frame: "Frame",
14
14
  plan: "Plan",
15
15
  spec: "Spec",
@@ -18,13 +18,39 @@ const STAGE_TO_AGENT_MAP = {
18
18
  verify: "Verify",
19
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.55",
3
+ "version": "0.1.56",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -32,14 +32,67 @@ export function createInjectProvider(opts: {
32
32
  injectedCache.set(injectId, nowMs);
33
33
  }
34
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
+
35
85
  async function injectEligibleInjects(sessionId: string) {
36
86
  const now = nowISO();
37
87
  const nowMs = Date.now();
38
88
 
39
- // Get eligible injects - use allowlists from config or defaults
89
+ // Get allowlists from config or defaults
40
90
  const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
41
91
  const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
42
92
 
93
+ // Get diagnostic data
94
+ const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
95
+
43
96
  const eligibleInjects = selectEligibleInjects(db, {
44
97
  nowIso: now,
45
98
  scopeAllowlist,
@@ -47,11 +100,21 @@ export function createInjectProvider(opts: {
47
100
  limit: config.inject?.max_per_turn ?? 5,
48
101
  });
49
102
 
50
- if (eligibleInjects.length === 0) return;
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
+ }
51
111
 
52
112
  // Inject each eligible inject, skipping recently injected ones
53
113
  for (const inject of eligibleInjects) {
54
- if (shouldSkipInject(inject.inject_id, nowMs)) continue;
114
+ if (shouldSkipInject(inject.inject_id, nowMs)) {
115
+ skippedDeduped++;
116
+ continue;
117
+ }
55
118
 
56
119
  // Format as injection message
57
120
  const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
@@ -63,8 +126,12 @@ export function createInjectProvider(opts: {
63
126
  agent: "Astrocode"
64
127
  });
65
128
 
129
+ injected++;
66
130
  markInjected(inject.inject_id, nowMs);
67
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}}`);
68
135
  }
69
136
 
70
137
  // Public hook handlers
@@ -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
@@ -174,3 +174,91 @@ export function createAstroInjectEligibleTool(opts: { ctx: any; config: Astrocod
174
174
  },
175
175
  });
176
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,7 +13,7 @@ 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> = {
16
+ export const STAGE_TO_AGENT_MAP: Record<string, string> = {
17
17
  frame: "Frame",
18
18
  plan: "Plan",
19
19
  spec: "Spec",
@@ -23,13 +23,40 @@ const STAGE_TO_AGENT_MAP: Record<string, string> = {
23
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