astrocode-workflow 0.1.55 → 0.1.57

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 {
@@ -1,2 +1,2 @@
1
1
  export declare const SCHEMA_VERSION = 2;
2
- export declare const SCHEMA_SQL = "\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS repo_state (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n spec_hash_before TEXT,\n spec_hash_after TEXT,\n last_run_id TEXT,\n last_story_key TEXT,\n last_event_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS epics (\n epic_key TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'active',\n priority INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_drafts (\n draft_id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_keyseq (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n next_story_num INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS stories (\n story_key TEXT PRIMARY KEY,\n epic_key TEXT,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'queued', -- queued|approved|in_progress|done|blocked|archived\n priority INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n locked_by_run_id TEXT,\n locked_at TEXT,\n in_progress INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (epic_key) REFERENCES epics(epic_key)\n);\n\nCREATE TABLE IF NOT EXISTS runs (\n run_id TEXT PRIMARY KEY,\n story_key TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'created', -- created|running|completed|failed|aborted\n pipeline_stages_json TEXT NOT NULL DEFAULT '[]',\n current_stage_key TEXT,\n created_at TEXT NOT NULL,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n error_text TEXT,\n FOREIGN KEY (story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS stage_runs (\n stage_run_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n stage_index INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|skipped\n created_at TEXT NOT NULL,\n subagent_type TEXT,\n subagent_session_id TEXT,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n baton_path TEXT,\n summary_md TEXT,\n output_json TEXT,\n error_text TEXT,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS artifacts (\n artifact_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL, -- plan|baton|evidence|diff|log|summary|commit|tool_output|snapshot\n path TEXT NOT NULL,\n sha256 TEXT,\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS tool_runs (\n tool_run_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n tool_name TEXT NOT NULL,\n args_json TEXT NOT NULL DEFAULT '{}',\n output_summary TEXT NOT NULL DEFAULT '',\n output_artifact_id TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS events (\n event_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL,\n body_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS injects (\n inject_id TEXT PRIMARY KEY,\n type TEXT NOT NULL DEFAULT 'note',\n title TEXT NOT NULL,\n body_md TEXT NOT NULL,\n tags_json TEXT NOT NULL DEFAULT '[]',\n scope TEXT NOT NULL DEFAULT 'repo', -- repo|run:<id>|story:<key>|global\n source TEXT NOT NULL DEFAULT 'user', -- user|tool|agent|import\n priority INTEGER NOT NULL DEFAULT 50,\n expires_at TEXT,\n sha256 TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS running_batches (\n batch_id TEXT PRIMARY KEY,\n run_id TEXT,\n session_id TEXT,\n status TEXT NOT NULL DEFAULT 'running', -- running|completed|failed|aborted\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_metrics (\n metric_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n name TEXT NOT NULL,\n value_num REAL,\n value_text TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS template_intents (\n intent_key TEXT PRIMARY KEY,\n body_md TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- vNext tables\n\nCREATE TABLE IF NOT EXISTS story_relations (\n parent_story_key TEXT NOT NULL,\n child_story_key TEXT NOT NULL,\n relation_type TEXT NOT NULL DEFAULT 'split',\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n PRIMARY KEY (parent_story_key, child_story_key),\n FOREIGN KEY (parent_story_key) REFERENCES stories(story_key),\n FOREIGN KEY (child_story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS continuations (\n continuation_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n run_id TEXT,\n directive_hash TEXT NOT NULL,\n kind TEXT NOT NULL, -- continue|stage|blocked|repair\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_continuations_session_created ON continuations(session_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_continuations_run_created ON continuations(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS context_snapshots (\n snapshot_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n summary_md TEXT NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_context_snapshots_run_created ON context_snapshots(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS agent_sessions (\n session_id TEXT PRIMARY KEY,\n parent_session_id TEXT,\n agent_name TEXT NOT NULL,\n run_id TEXT,\n stage_key TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- Indexes\n\nCREATE INDEX IF NOT EXISTS idx_stories_state ON stories(state);\nCREATE INDEX IF NOT EXISTS idx_runs_story ON runs(story_key);\nCREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);\nCREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id, stage_index);\nCREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_key, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);\n\n-- Stronger invariants (SQLite partial indexes)\n-- Only one run may be 'running' at a time (single-repo harness by default).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_running_run\n ON runs(status)\n WHERE status = 'running';\n\n-- Only one story may be in_progress=1 at a time (pairs with single running run).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_in_progress_story\n ON stories(in_progress)\n WHERE in_progress = 1;\n\n";
2
+ export declare const SCHEMA_SQL = "\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS repo_state (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n spec_hash_before TEXT,\n spec_hash_after TEXT,\n last_run_id TEXT,\n last_story_key TEXT,\n last_event_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS epics (\n epic_key TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'active',\n priority INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_drafts (\n draft_id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_keyseq (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n next_story_num INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS stories (\n story_key TEXT PRIMARY KEY,\n epic_key TEXT,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'queued', -- queued|approved|in_progress|done|blocked|archived\n priority INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n locked_by_run_id TEXT,\n locked_at TEXT,\n in_progress INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (epic_key) REFERENCES epics(epic_key)\n);\n\nCREATE TABLE IF NOT EXISTS runs (\n run_id TEXT PRIMARY KEY,\n story_key TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'created', -- created|running|completed|failed|aborted\n pipeline_stages_json TEXT NOT NULL DEFAULT '[]',\n current_stage_key TEXT,\n created_at TEXT NOT NULL,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n error_text TEXT,\n FOREIGN KEY (story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS stage_runs (\n stage_run_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n stage_index INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|skipped\n created_at TEXT NOT NULL,\n subagent_type TEXT,\n subagent_session_id TEXT,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n baton_path TEXT,\n summary_md TEXT,\n output_json TEXT,\n error_text TEXT,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS artifacts (\n artifact_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL, -- plan|baton|evidence|diff|log|summary|commit|tool_output|snapshot\n path TEXT NOT NULL,\n sha256 TEXT,\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS tool_runs (\n tool_run_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n tool_name TEXT NOT NULL,\n args_json TEXT NOT NULL DEFAULT '{}',\n output_summary TEXT NOT NULL DEFAULT '',\n output_artifact_id TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS events (\n event_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL,\n body_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS injects (\n inject_id TEXT PRIMARY KEY,\n type TEXT NOT NULL DEFAULT 'note',\n title TEXT NOT NULL,\n body_md TEXT NOT NULL,\n tags_json TEXT NOT NULL DEFAULT '[]',\n scope TEXT NOT NULL DEFAULT 'repo', -- repo|run:<id>|story:<key>|global\n source TEXT NOT NULL DEFAULT 'user', -- user|tool|agent|import\n priority INTEGER NOT NULL DEFAULT 50,\n expires_at TEXT,\n sha256 TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS running_batches (\n batch_id TEXT PRIMARY KEY,\n run_id TEXT,\n session_id TEXT,\n status TEXT NOT NULL DEFAULT 'running', -- running|completed|failed|aborted\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_metrics (\n metric_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n name TEXT NOT NULL,\n value_num REAL,\n value_text TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS template_intents (\n intent_key TEXT PRIMARY KEY,\n body_md TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- vNext tables\n\nCREATE TABLE IF NOT EXISTS story_relations (\n parent_story_key TEXT NOT NULL,\n child_story_key TEXT NOT NULL,\n relation_type TEXT NOT NULL DEFAULT 'split',\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n PRIMARY KEY (parent_story_key, child_story_key),\n FOREIGN KEY (parent_story_key) REFERENCES stories(story_key),\n FOREIGN KEY (child_story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS continuations (\n continuation_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n run_id TEXT,\n directive_hash TEXT NOT NULL,\n kind TEXT NOT NULL, -- continue|stage|blocked|repair\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_continuations_session_created ON continuations(session_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_continuations_run_created ON continuations(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS context_snapshots (\n snapshot_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n summary_md TEXT NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_context_snapshots_run_created ON context_snapshots(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS agent_sessions (\n session_id TEXT PRIMARY KEY,\n parent_session_id TEXT,\n agent_name TEXT NOT NULL,\n run_id TEXT,\n stage_key TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- Indexes\n\nCREATE INDEX IF NOT EXISTS idx_stories_state ON stories(state);\nCREATE INDEX IF NOT EXISTS idx_runs_story ON runs(story_key);\nCREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);\nCREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id, stage_index);\nCREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_key, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;\n\n-- Stronger invariants (SQLite partial indexes)\n-- Only one run may be 'running' at a time (single-repo harness by default).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_running_run\n ON runs(status)\n WHERE status = 'running';\n\n-- Only one story may be in_progress=1 at a time (pairs with single running run).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_in_progress_story\n ON stories(in_progress)\n WHERE in_progress = 1;\n\n";
@@ -233,6 +233,9 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_ke
233
233
  CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);
234
234
  CREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);
235
235
  CREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);
236
+ CREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);
237
+ CREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;
238
+ CREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;
236
239
 
237
240
  -- Stronger invariants (SQLite partial indexes)
238
241
  -- Only one run may be 'running' at a time (single-repo harness by default).
@@ -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;
@@ -1,6 +1,29 @@
1
1
  import { tool } from "@opencode-ai/plugin/tool";
2
+ import { withTx } from "../state/db";
2
3
  import { nowISO } from "../shared/time";
3
4
  import { sha256Hex } from "../shared/hash";
5
+ const VALID_INJECT_TYPES = ['note', 'policy', 'reminder', 'debug'];
6
+ function validateInjectType(type) {
7
+ if (!VALID_INJECT_TYPES.includes(type)) {
8
+ throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(', ')}`);
9
+ }
10
+ return type;
11
+ }
12
+ function validateTimestamp(timestamp) {
13
+ if (!timestamp)
14
+ return null;
15
+ // Check if it's a valid ISO 8601 timestamp with Z suffix
16
+ const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
17
+ if (!isoRegex.test(timestamp)) {
18
+ throw new Error(`Invalid timestamp format. Expected ISO 8601 UTC with Z suffix (e.g., "2026-01-23T13:05:19.000Z"), got: "${timestamp}"`);
19
+ }
20
+ // Additional validation: ensure it's parseable and represents a valid date
21
+ const parsed = new Date(timestamp);
22
+ if (isNaN(parsed.getTime())) {
23
+ throw new Error(`Invalid timestamp: "${timestamp}" does not represent a valid date`);
24
+ }
25
+ return timestamp;
26
+ }
4
27
  function newInjectId() {
5
28
  return `inj_${Date.now()}_${Math.random().toString(16).slice(2)}`;
6
29
  }
@@ -23,13 +46,33 @@ export function createAstroInjectPutTool(opts) {
23
46
  const id = inject_id ?? newInjectId();
24
47
  const now = nowISO();
25
48
  const sha = sha256Hex(body_md);
26
- const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id);
27
- if (existing) {
28
- db.prepare("UPDATE injects SET type=?, title=?, body_md=?, tags_json=?, scope=?, source=?, priority=?, expires_at=?, sha256=?, updated_at=? WHERE inject_id=?").run(type, title, body_md, tags_json, scope, source, priority, expires_at ?? null, sha, now, id);
29
- return `✅ Updated inject ${id}: ${title}`;
30
- }
31
- db.prepare("INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(id, type, title, body_md, tags_json, scope, source, priority, expires_at ?? null, sha, now, now);
32
- return `✅ Created inject ${id}: ${title}`;
49
+ // Validate inputs
50
+ const validatedType = validateInjectType(type);
51
+ const validatedExpiresAt = validateTimestamp(expires_at);
52
+ return withTx(db, () => {
53
+ const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id);
54
+ if (existing) {
55
+ // Use INSERT ... ON CONFLICT for atomic updates
56
+ db.prepare(`
57
+ INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at)
58
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59
+ ON CONFLICT(inject_id) DO UPDATE SET
60
+ type=excluded.type,
61
+ title=excluded.title,
62
+ body_md=excluded.body_md,
63
+ tags_json=excluded.tags_json,
64
+ scope=excluded.scope,
65
+ source=excluded.source,
66
+ priority=excluded.priority,
67
+ expires_at=excluded.expires_at,
68
+ sha256=excluded.sha256,
69
+ updated_at=excluded.updated_at
70
+ `).run(id, type, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
71
+ return `✅ Updated inject ${id}: ${title}`;
72
+ }
73
+ db.prepare("INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(id, validatedType, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
74
+ return `✅ Created inject ${id}: ${title}`;
75
+ });
33
76
  },
34
77
  });
35
78
  }
@@ -137,3 +180,82 @@ export function createAstroInjectEligibleTool(opts) {
137
180
  },
138
181
  });
139
182
  }
183
+ export function createAstroInjectDebugDueTool(opts) {
184
+ const { db } = opts;
185
+ return tool({
186
+ description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
187
+ args: {
188
+ scopes_json: tool.schema.string().default('["repo","global"]'),
189
+ types_json: tool.schema.string().default('["note","policy"]'),
190
+ },
191
+ execute: async ({ scopes_json, types_json }) => {
192
+ const now = nowISO();
193
+ const scopes = JSON.parse(scopes_json);
194
+ const types = JSON.parse(types_json);
195
+ // Get ALL injects to analyze filtering
196
+ const allInjects = db.prepare("SELECT * FROM injects").all();
197
+ let total = allInjects.length;
198
+ let selected = 0;
199
+ let skippedExpired = 0;
200
+ let skippedScope = 0;
201
+ let skippedType = 0;
202
+ const excludedReasons = [];
203
+ const selectedInjects = [];
204
+ for (const inject of allInjects) {
205
+ const reasons = [];
206
+ // Check expiration
207
+ if (inject.expires_at && inject.expires_at <= now) {
208
+ reasons.push("expired");
209
+ skippedExpired++;
210
+ }
211
+ // Check scope
212
+ if (!scopes.includes(inject.scope)) {
213
+ reasons.push("scope");
214
+ skippedScope++;
215
+ }
216
+ // Check type
217
+ if (!types.includes(inject.type)) {
218
+ reasons.push("type");
219
+ skippedType++;
220
+ }
221
+ if (reasons.length > 0) {
222
+ excludedReasons.push({
223
+ inject_id: inject.inject_id,
224
+ title: inject.title,
225
+ reasons: reasons,
226
+ scope: inject.scope,
227
+ type: inject.type,
228
+ expires_at: inject.expires_at,
229
+ });
230
+ }
231
+ else {
232
+ selected++;
233
+ selectedInjects.push({
234
+ inject_id: inject.inject_id,
235
+ title: inject.title,
236
+ scope: inject.scope,
237
+ type: inject.type,
238
+ expires_at: inject.expires_at,
239
+ });
240
+ }
241
+ }
242
+ return JSON.stringify({
243
+ now,
244
+ scopes_considered: scopes,
245
+ types_considered: types,
246
+ summary: {
247
+ total_injects: total,
248
+ selected_eligible: selected,
249
+ excluded_total: total - selected,
250
+ skipped_breakdown: {
251
+ expired: skippedExpired,
252
+ scope: skippedScope,
253
+ type: skippedType,
254
+ }
255
+ },
256
+ selected_injects: selectedInjects,
257
+ excluded_injects: excludedReasons,
258
+ }, null, 2);
259
+ },
260
+ });
261
+ }
@@ -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
@@ -193,15 +220,23 @@ export function createAstroWorkflowProceedTool(opts) {
193
220
  return false;
194
221
  };
195
222
  if (!agentExists(agentName)) {
223
+ const originalAgent = agentName;
196
224
  console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
197
- // Skip General fallback for stage agents to avoid malformed output
225
+ // First fallback: orchestrator
198
226
  agentName = config.agents?.orchestrator_name || "Astro";
199
227
  if (!agentExists(agentName)) {
200
- throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config)}, Orchestrator: ${agentName}`);
228
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
229
+ // Final fallback: General (guaranteed to exist)
230
+ agentName = "General";
231
+ if (!agentExists(agentName)) {
232
+ throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
233
+ }
201
234
  }
202
235
  }
203
236
  withTx(db, () => {
204
237
  startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
238
+ // Log delegation observability
239
+ console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
205
240
  });
206
241
  if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
207
242
  await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
@@ -291,6 +326,11 @@ export function createAstroWorkflowProceedTool(opts) {
291
326
  lines.push(``, `## Actions`);
292
327
  for (const a of actions)
293
328
  lines.push(`- ${a}`);
329
+ if (warnings.length > 0) {
330
+ lines.push(``, `## Warnings`);
331
+ for (const w of warnings)
332
+ lines.push(`⚠️ ${w}`);
333
+ }
294
334
  return lines.join("\n").trim();
295
335
  },
296
336
  });
@@ -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.57",
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
@@ -235,6 +235,9 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_ke
235
235
  CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);
236
236
  CREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);
237
237
  CREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);
238
+ CREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);
239
+ CREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;
240
+ CREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;
238
241
 
239
242
  -- Stronger invariants (SQLite partial indexes)
240
243
  -- Only one run may be 'running' at a time (single-repo harness by default).
@@ -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
@@ -1,9 +1,38 @@
1
1
  import { tool, 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 { withTx } from "../state/db";
4
5
  import { nowISO } from "../shared/time";
5
6
  import { sha256Hex } from "../shared/hash";
6
7
 
8
+ const VALID_INJECT_TYPES = ['note', 'policy', 'reminder', 'debug'] as const;
9
+ type InjectType = typeof VALID_INJECT_TYPES[number];
10
+
11
+ function validateInjectType(type: string): InjectType {
12
+ if (!VALID_INJECT_TYPES.includes(type as InjectType)) {
13
+ throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(', ')}`);
14
+ }
15
+ return type as InjectType;
16
+ }
17
+
18
+ function validateTimestamp(timestamp: string | null): string | null {
19
+ if (!timestamp) return null;
20
+
21
+ // Check if it's a valid ISO 8601 timestamp with Z suffix
22
+ const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
23
+ if (!isoRegex.test(timestamp)) {
24
+ throw new Error(`Invalid timestamp format. Expected ISO 8601 UTC with Z suffix (e.g., "2026-01-23T13:05:19.000Z"), got: "${timestamp}"`);
25
+ }
26
+
27
+ // Additional validation: ensure it's parseable and represents a valid date
28
+ const parsed = new Date(timestamp);
29
+ if (isNaN(parsed.getTime())) {
30
+ throw new Error(`Invalid timestamp: "${timestamp}" does not represent a valid date`);
31
+ }
32
+
33
+ return timestamp;
34
+ }
35
+
7
36
  function newInjectId(): string {
8
37
  return `inj_${Date.now()}_${Math.random().toString(16).slice(2)}`;
9
38
  }
@@ -29,20 +58,39 @@ export function createAstroInjectPutTool(opts: { ctx: any; config: AstrocodeConf
29
58
  const now = nowISO();
30
59
  const sha = sha256Hex(body_md);
31
60
 
32
- const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id) as any;
61
+ // Validate inputs
62
+ const validatedType = validateInjectType(type);
63
+ const validatedExpiresAt = validateTimestamp(expires_at);
33
64
 
34
- if (existing) {
35
- db.prepare(
36
- "UPDATE injects SET type=?, title=?, body_md=?, tags_json=?, scope=?, source=?, priority=?, expires_at=?, sha256=?, updated_at=? WHERE inject_id=?"
37
- ).run(type, title, body_md, tags_json, scope, source, priority, expires_at ?? null, sha, now, id);
38
- return `✅ Updated inject ${id}: ${title}`;
39
- }
65
+ return withTx(db, () => {
66
+ const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id) as any;
67
+
68
+ if (existing) {
69
+ // Use INSERT ... ON CONFLICT for atomic updates
70
+ db.prepare(`
71
+ INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at)
72
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(inject_id) DO UPDATE SET
74
+ type=excluded.type,
75
+ title=excluded.title,
76
+ body_md=excluded.body_md,
77
+ tags_json=excluded.tags_json,
78
+ scope=excluded.scope,
79
+ source=excluded.source,
80
+ priority=excluded.priority,
81
+ expires_at=excluded.expires_at,
82
+ sha256=excluded.sha256,
83
+ updated_at=excluded.updated_at
84
+ `).run(id, type, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
85
+ return `✅ Updated inject ${id}: ${title}`;
86
+ }
40
87
 
41
- db.prepare(
42
- "INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
43
- ).run(id, type, title, body_md, tags_json, scope, source, priority, expires_at ?? null, sha, now, now);
88
+ db.prepare(
89
+ "INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
90
+ ).run(id, validatedType, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
44
91
 
45
- return `✅ Created inject ${id}: ${title}`;
92
+ return `✅ Created inject ${id}: ${title}`;
93
+ });
46
94
  },
47
95
  });
48
96
  }
@@ -174,3 +222,91 @@ export function createAstroInjectEligibleTool(opts: { ctx: any; config: Astrocod
174
222
  },
175
223
  });
176
224
  }
225
+
226
+ export function createAstroInjectDebugDueTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
227
+ const { db } = opts;
228
+
229
+ return tool({
230
+ description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
231
+ args: {
232
+ scopes_json: tool.schema.string().default('["repo","global"]'),
233
+ types_json: tool.schema.string().default('["note","policy"]'),
234
+ },
235
+ execute: async ({ scopes_json, types_json }) => {
236
+ const now = nowISO();
237
+ const scopes = JSON.parse(scopes_json) as string[];
238
+ const types = JSON.parse(types_json) as string[];
239
+
240
+ // Get ALL injects to analyze filtering
241
+ const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
242
+
243
+ let total = allInjects.length;
244
+ let selected = 0;
245
+ let skippedExpired = 0;
246
+ let skippedScope = 0;
247
+ let skippedType = 0;
248
+ const excludedReasons: any[] = [];
249
+ const selectedInjects: any[] = [];
250
+
251
+ for (const inject of allInjects) {
252
+ const reasons: string[] = [];
253
+
254
+ // Check expiration
255
+ if (inject.expires_at && inject.expires_at <= now) {
256
+ reasons.push("expired");
257
+ skippedExpired++;
258
+ }
259
+
260
+ // Check scope
261
+ if (!scopes.includes(inject.scope)) {
262
+ reasons.push("scope");
263
+ skippedScope++;
264
+ }
265
+
266
+ // Check type
267
+ if (!types.includes(inject.type)) {
268
+ reasons.push("type");
269
+ skippedType++;
270
+ }
271
+
272
+ if (reasons.length > 0) {
273
+ excludedReasons.push({
274
+ inject_id: inject.inject_id,
275
+ title: inject.title,
276
+ reasons: reasons,
277
+ scope: inject.scope,
278
+ type: inject.type,
279
+ expires_at: inject.expires_at,
280
+ });
281
+ } else {
282
+ selected++;
283
+ selectedInjects.push({
284
+ inject_id: inject.inject_id,
285
+ title: inject.title,
286
+ scope: inject.scope,
287
+ type: inject.type,
288
+ expires_at: inject.expires_at,
289
+ });
290
+ }
291
+ }
292
+
293
+ return JSON.stringify({
294
+ now,
295
+ scopes_considered: scopes,
296
+ types_considered: types,
297
+ summary: {
298
+ total_injects: total,
299
+ selected_eligible: selected,
300
+ excluded_total: total - selected,
301
+ skipped_breakdown: {
302
+ expired: skippedExpired,
303
+ scope: skippedScope,
304
+ type: skippedType,
305
+ }
306
+ },
307
+ selected_injects: selectedInjects,
308
+ excluded_injects: excludedReasons,
309
+ }, null, 2);
310
+ },
311
+ });
312
+ }
@@ -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";
31
46
  }
32
- return STAGE_TO_AGENT_MAP[stageKey] || "General";
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
57
+ }
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;
@@ -234,17 +262,26 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
234
262
  return false;
235
263
  };
236
264
 
237
- if (!agentExists(agentName)) {
238
- console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
239
- // Skip General fallback for stage agents to avoid malformed output
240
- agentName = config.agents?.orchestrator_name || "Astro";
241
- if (!agentExists(agentName)) {
242
- throw new Error(`Critical: No agents available for delegation. Primary: ${resolveAgentName(next.stage_key, config)}, Orchestrator: ${agentName}`);
243
- }
244
- }
265
+ if (!agentExists(agentName)) {
266
+ const originalAgent = agentName;
267
+ console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
268
+ // First fallback: orchestrator
269
+ agentName = config.agents?.orchestrator_name || "Astro";
270
+ if (!agentExists(agentName)) {
271
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
272
+ // Final fallback: General (guaranteed to exist)
273
+ agentName = "General";
274
+ if (!agentExists(agentName)) {
275
+ throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
276
+ }
277
+ }
278
+ }
245
279
 
246
280
  withTx(db, () => {
247
281
  startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
282
+
283
+ // Log delegation observability
284
+ console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
248
285
  });
249
286
 
250
287
  if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
@@ -355,6 +392,11 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
355
392
  lines.push(``, `## Actions`);
356
393
  for (const a of actions) lines.push(`- ${a}`);
357
394
 
395
+ if (warnings.length > 0) {
396
+ lines.push(``, `## Warnings`);
397
+ for (const w of warnings) lines.push(`⚠️ ${w}`);
398
+ }
399
+
358
400
  return lines.join("\n").trim();
359
401
  },
360
402
  });
@@ -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