agentxchain 2.43.0 → 2.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Legacy IDE-window coordination is still shipped as a compatibility mode for team
24
24
  See governance before you scaffold a real repo:
25
25
 
26
26
  ```bash
27
- npx agentxchain demo
27
+ npx --yes -p agentxchain@latest -c "agentxchain demo"
28
28
  ```
29
29
 
30
30
  Requires Node.js 18.17+ or 20.5+ and `git`. The demo creates a temporary governed repo, runs a full PM -> Dev -> QA lifecycle through the real runner interface, shows gates/decisions/objections, and removes the temp workspace when finished. No API keys, config edits, or manual turn authoring required.
@@ -33,12 +33,13 @@ Requires Node.js 18.17+ or 20.5+ and `git`. The demo creates a temporary governe
33
33
 
34
34
  ```bash
35
35
  npm install -g agentxchain
36
+ agentxchain --version
36
37
  ```
37
38
 
38
- Or run without installing:
39
+ For a zero-install one-off command, use the package-bound form:
39
40
 
40
41
  ```bash
41
- npx agentxchain init --governed --dir my-agentxchain-project -y
42
+ npx --yes -p agentxchain@latest -c "agentxchain demo"
42
43
  ```
43
44
 
44
45
  ## Testing
@@ -62,7 +63,7 @@ Duplicate execution remains intentional for the current 36-file slice until a la
62
63
  ### Governed workflow
63
64
 
64
65
  ```bash
65
- npx agentxchain init --governed --dir my-agentxchain-project -y
66
+ agentxchain init --governed --dir my-agentxchain-project -y
66
67
  cd my-agentxchain-project
67
68
  git init
68
69
  git add -A
@@ -74,13 +75,13 @@ agentxchain step --role pm
74
75
  The default governed dev runtime is `claude --print --dangerously-skip-permissions` with stdin prompt delivery. The non-interactive governed path needs write access, so do not pretend bare `claude --print` is sufficient for unattended implementation turns. If your local coding agent uses a different launch contract, set it during scaffold creation:
75
76
 
76
77
  ```bash
77
- npx agentxchain init --governed --dir my-agentxchain-project --dev-command ./scripts/dev-agent.sh --dev-prompt-transport dispatch_bundle_only -y
78
+ agentxchain init --governed --dir my-agentxchain-project --dev-command ./scripts/dev-agent.sh --dev-prompt-transport dispatch_bundle_only -y
78
79
  ```
79
80
 
80
81
  If you want template-specific planning artifacts from day one:
81
82
 
82
83
  ```bash
83
- npx agentxchain init --governed --template api-service --dir my-agentxchain-project -y
84
+ agentxchain init --governed --template api-service --dir my-agentxchain-project -y
84
85
  ```
85
86
 
86
87
  Built-in governed templates:
@@ -119,8 +120,8 @@ Default governed scaffolding configures QA as `api_proxy` with `ANTHROPIC_API_KE
119
120
  For initiatives spanning multiple governed repos, use the coordinator to add cross-repo sequencing and shared gates:
120
121
 
121
122
  ```bash
122
- npx agentxchain init --governed --template api-service --dir repos/backend -y
123
- npx agentxchain init --governed --template web-app --dir repos/frontend -y
123
+ agentxchain init --governed --template api-service --dir repos/backend -y
124
+ agentxchain init --governed --template web-app --dir repos/frontend -y
124
125
  agentxchain multi init
125
126
  agentxchain multi step --json
126
127
  ```
@@ -105,6 +105,7 @@ import { intakeScanCommand } from '../src/commands/intake-scan.js';
105
105
  import { intakeResolveCommand } from '../src/commands/intake-resolve.js';
106
106
  import { intakeStatusCommand } from '../src/commands/intake-status.js';
107
107
  import { demoCommand } from '../src/commands/demo.js';
108
+ import { historyCommand } from '../src/commands/history.js';
108
109
 
109
110
  const __dirname = dirname(fileURLToPath(import.meta.url));
110
111
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -256,6 +257,15 @@ program
256
257
  .option('-v, --verbose', 'Show stack traces on failure')
257
258
  .action(demoCommand);
258
259
 
260
+ program
261
+ .command('history')
262
+ .description('Show cross-run history of governed runs in this project')
263
+ .option('-j, --json', 'Output as JSON')
264
+ .option('-l, --limit <n>', 'Number of recent runs to show (default: 20)')
265
+ .option('-s, --status <status>', 'Filter by status: completed, blocked, failed')
266
+ .option('-d, --dir <path>', 'Project directory')
267
+ .action(historyCommand);
268
+
259
269
  program
260
270
  .command('validate')
261
271
  .description('Validate project protocol artifacts')
package/dashboard/app.js CHANGED
@@ -14,6 +14,7 @@ import { render as renderInitiative } from './components/initiative.js';
14
14
  import { render as renderCrossRepo } from './components/cross-repo.js';
15
15
  import { render as renderBlockers } from './components/blockers.js';
16
16
  import { render as renderArtifacts } from './components/artifacts.js';
17
+ import { render as renderRunHistory } from './components/run-history.js';
17
18
 
18
19
  const VIEWS = {
19
20
  timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations', 'connectors'], render: renderTimeline },
@@ -25,6 +26,7 @@ const VIEWS = {
25
26
  'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
26
27
  blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
27
28
  artifacts: { fetch: ['workflowKitArtifacts'], render: renderArtifacts },
29
+ 'run-history': { fetch: ['runHistory'], render: renderRunHistory },
28
30
  };
29
31
 
30
32
  const API_MAP = {
@@ -42,6 +44,7 @@ const API_MAP = {
42
44
  coordinatorBlockers: '/api/coordinator/blockers',
43
45
  workflowKitArtifacts: '/api/workflow-kit-artifacts',
44
46
  connectors: '/api/connectors',
47
+ runHistory: '/api/run-history',
45
48
  };
46
49
 
47
50
  const viewState = {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Run History view — renders cross-run history from /api/run-history.
3
+ *
4
+ * Pure render function: takes data from the bridge server, returns HTML.
5
+ * No business logic — just table rendering with status-colored rows.
6
+ *
7
+ * See: RUN_HISTORY_TERMINAL_RECORDING_SPEC.md
8
+ */
9
+
10
+ function esc(str) {
11
+ if (!str) return '';
12
+ return String(str)
13
+ .replace(/&/g, '&amp;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&#39;');
18
+ }
19
+
20
+ function badge(label, color = 'var(--text-dim)') {
21
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
22
+ }
23
+
24
+ function statusBadge(status) {
25
+ switch (status) {
26
+ case 'completed':
27
+ return badge('completed', 'var(--green)');
28
+ case 'blocked':
29
+ return badge('blocked', 'var(--yellow)');
30
+ case 'failed':
31
+ return badge('failed', 'var(--red)');
32
+ default:
33
+ return badge(status || 'unknown', 'var(--text-dim)');
34
+ }
35
+ }
36
+
37
+ function formatDuration(ms) {
38
+ if (ms == null) return '—';
39
+ if (ms < 1000) return `${ms}ms`;
40
+ const seconds = Math.floor(ms / 1000);
41
+ if (seconds < 60) return `${seconds}s`;
42
+ const minutes = Math.floor(seconds / 60);
43
+ const remainingSeconds = seconds % 60;
44
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
45
+ const hours = Math.floor(minutes / 60);
46
+ const remainingMinutes = minutes % 60;
47
+ return `${hours}h ${remainingMinutes}m`;
48
+ }
49
+
50
+ function formatCost(usd) {
51
+ if (usd == null) return '—';
52
+ return `$${Number(usd).toFixed(2)}`;
53
+ }
54
+
55
+ function formatDate(iso) {
56
+ if (!iso) return '—';
57
+ try {
58
+ const d = new Date(iso);
59
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
60
+ + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
61
+ } catch {
62
+ return esc(iso);
63
+ }
64
+ }
65
+
66
+ function truncateId(id, len = 12) {
67
+ if (!id) return '—';
68
+ return id.length > len ? id.slice(0, len) + '…' : id;
69
+ }
70
+
71
+ function renderRow(entry, index) {
72
+ const rowClass = entry.status === 'blocked'
73
+ ? ' style="border-left:3px solid var(--yellow)"'
74
+ : entry.status === 'failed'
75
+ ? ' style="border-left:3px solid var(--red)"'
76
+ : '';
77
+
78
+ const phases = Array.isArray(entry.phases_completed) && entry.phases_completed.length > 0
79
+ ? entry.phases_completed.map(p => esc(p)).join(' → ')
80
+ : '—';
81
+
82
+ const blockedInfo = entry.status === 'blocked' && entry.blocked_reason
83
+ ? `<div class="blocked-hint" style="font-size:0.85em;color:var(--yellow);margin-top:2px">${esc(typeof entry.blocked_reason === 'string' ? entry.blocked_reason : entry.blocked_reason?.detail || entry.blocked_reason?.category || '')}</div>`
84
+ : '';
85
+
86
+ return `<tr${rowClass}>
87
+ <td style="color:var(--text-dim)">${index + 1}</td>
88
+ <td class="mono" title="${esc(entry.run_id)}">${esc(truncateId(entry.run_id))}</td>
89
+ <td>${statusBadge(entry.status)}${blockedInfo}</td>
90
+ <td>${phases}</td>
91
+ <td>${entry.total_turns ?? '—'}</td>
92
+ <td>${formatCost(entry.total_cost_usd)}</td>
93
+ <td>${formatDuration(entry.duration_ms)}</td>
94
+ <td>${formatDate(entry.recorded_at || entry.completed_at)}</td>
95
+ </tr>`;
96
+ }
97
+
98
+ export function render({ runHistory }) {
99
+ if (!runHistory) {
100
+ return `<div class="placeholder"><h2>Run History</h2><p>No run history data available. Complete a governed run to see cross-run history.</p></div>`;
101
+ }
102
+
103
+ if (!Array.isArray(runHistory) || runHistory.length === 0) {
104
+ return `<div class="placeholder"><h2>Run History</h2><p>No runs recorded yet. Run history is populated when governed runs reach a terminal state (completed or blocked).</p></div>`;
105
+ }
106
+
107
+ const total = runHistory.length;
108
+ const completed = runHistory.filter(e => e.status === 'completed').length;
109
+ const blocked = runHistory.filter(e => e.status === 'blocked').length;
110
+
111
+ let html = `<div class="run-history-view">`;
112
+
113
+ // Header summary
114
+ html += `<div class="run-header"><div class="run-meta">`;
115
+ html += `<span class="turn-count">${total} run${total !== 1 ? 's' : ''} recorded</span>`;
116
+ if (completed > 0) html += badge(`${completed} completed`, 'var(--green)');
117
+ if (blocked > 0) html += badge(`${blocked} blocked`, 'var(--yellow)');
118
+ html += `</div></div>`;
119
+
120
+ // Table
121
+ html += `<div class="section"><h3>Cross-Run History</h3>
122
+ <table class="data-table">
123
+ <thead>
124
+ <tr>
125
+ <th>#</th>
126
+ <th>Run ID</th>
127
+ <th>Status</th>
128
+ <th>Phases</th>
129
+ <th>Turns</th>
130
+ <th>Cost</th>
131
+ <th>Duration</th>
132
+ <th>Date</th>
133
+ </tr>
134
+ </thead>
135
+ <tbody>`;
136
+
137
+ runHistory.forEach((entry, index) => {
138
+ html += renderRow(entry, index);
139
+ });
140
+
141
+ html += `</tbody></table></div>`;
142
+ html += `</div>`;
143
+ return html;
144
+ }
@@ -383,6 +383,7 @@
383
383
  <a href="#gate">Gates</a>
384
384
  <a href="#blockers">Blockers</a>
385
385
  <a href="#artifacts">Artifacts</a>
386
+ <a href="#run-history">Run History</a>
386
387
  </nav>
387
388
  <main id="view-container">
388
389
  <div class="placeholder">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.43.0",
3
+ "version": "2.45.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * agentxchain history — cross-run operator observability.
3
+ *
4
+ * Shows a persistent history of governed runs in the current project.
5
+ */
6
+
7
+ import { resolve } from 'path';
8
+ import { existsSync, readFileSync } from 'fs';
9
+ import chalk from 'chalk';
10
+ import { queryRunHistory } from '../lib/run-history.js';
11
+
12
+ /**
13
+ * @param {object} opts - { json?: boolean, limit?: number, status?: string, dir?: string }
14
+ */
15
+ export async function historyCommand(opts) {
16
+ const root = findProjectRoot(opts.dir || process.cwd());
17
+ if (!root) {
18
+ console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
19
+ process.exit(1);
20
+ }
21
+
22
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
23
+ const entries = queryRunHistory(root, {
24
+ limit,
25
+ status: opts.status || undefined,
26
+ });
27
+
28
+ if (opts.json) {
29
+ console.log(JSON.stringify(entries, null, 2));
30
+ return;
31
+ }
32
+
33
+ if (entries.length === 0) {
34
+ console.log(chalk.dim('No run history found.'));
35
+ if (opts.status) {
36
+ console.log(chalk.dim(` (filtered by status: ${opts.status})`));
37
+ }
38
+ return;
39
+ }
40
+
41
+ // Table header
42
+ const header = [
43
+ pad('#', 4),
44
+ pad('Run ID', 14),
45
+ pad('Status', 11),
46
+ pad('Phases', 8),
47
+ pad('Turns', 6),
48
+ pad('Cost', 10),
49
+ pad('Duration', 10),
50
+ pad('Date', 20),
51
+ ].join(' ');
52
+
53
+ console.log(chalk.bold(header));
54
+ console.log(chalk.dim('─'.repeat(header.length)));
55
+
56
+ entries.forEach((entry, i) => {
57
+ const idx = String(i + 1);
58
+ const runId = (entry.run_id || '—').slice(0, 12);
59
+ const status = formatStatus(entry.status);
60
+ const phases = String(entry.phases_completed?.length || 0);
61
+ const turns = String(entry.total_turns || 0);
62
+ const cost = entry.total_cost_usd != null
63
+ ? `$${entry.total_cost_usd.toFixed(4)}`
64
+ : '—';
65
+ const duration = entry.duration_ms != null
66
+ ? formatDuration(entry.duration_ms)
67
+ : '—';
68
+ const date = entry.recorded_at
69
+ ? new Date(entry.recorded_at).toLocaleString()
70
+ : '—';
71
+
72
+ console.log([
73
+ pad(idx, 4),
74
+ pad(runId, 14),
75
+ pad(status, 11),
76
+ pad(phases, 8),
77
+ pad(turns, 6),
78
+ pad(cost, 10),
79
+ pad(duration, 10),
80
+ pad(date, 20),
81
+ ].join(' '));
82
+ });
83
+
84
+ console.log(chalk.dim(`\n${entries.length} run(s) shown`));
85
+ }
86
+
87
+ // ── Helpers ─────────────────────────────────────────────────────────────────
88
+
89
+ function findProjectRoot(startDir) {
90
+ let dir = resolve(startDir);
91
+ while (true) {
92
+ if (existsSync(resolve(dir, 'agentxchain.json'))) return dir;
93
+ const parent = resolve(dir, '..');
94
+ if (parent === dir) return null;
95
+ dir = parent;
96
+ }
97
+ }
98
+
99
+ function pad(str, width) {
100
+ return String(str).padEnd(width);
101
+ }
102
+
103
+ function formatStatus(status) {
104
+ if (status === 'completed') return chalk.green('completed');
105
+ if (status === 'blocked') return chalk.yellow('blocked');
106
+ if (status === 'failed') return chalk.red('failed');
107
+ return status || '—';
108
+ }
109
+
110
+ function formatDuration(ms) {
111
+ if (ms < 1000) return `${ms}ms`;
112
+ const secs = Math.floor(ms / 1000);
113
+ if (secs < 60) return `${secs}s`;
114
+ const mins = Math.floor(secs / 60);
115
+ const remainSecs = secs % 60;
116
+ if (mins < 60) return `${mins}m ${remainSecs}s`;
117
+ const hrs = Math.floor(mins / 60);
118
+ const remainMins = mins % 60;
119
+ return `${hrs}h ${remainMins}m`;
120
+ }
@@ -190,7 +190,8 @@ export async function restartCommand(opts) {
190
190
  }
191
191
 
192
192
  if (state.status === 'failed') {
193
- console.log(chalk.red('Run is in terminal state: failed.'));
193
+ console.log(chalk.red('Run uses reserved status: failed.'));
194
+ console.log(chalk.dim('Current governed writers do not emit run-level failed. Inspect state.json and recover manually.'));
194
195
  process.exit(1);
195
196
  }
196
197
 
@@ -23,7 +23,7 @@
23
23
  * All error returns include a `classified` ApiProxyError object with
24
24
  * error_class, recovery instructions, and retryable flag.
25
25
  *
26
- * Supported providers: "anthropic", "openai", "google"
26
+ * Supported providers: "anthropic", "openai", "google", "ollama"
27
27
  */
28
28
 
29
29
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
@@ -51,6 +51,7 @@ const PROVIDER_ENDPOINTS = {
51
51
  anthropic: 'https://api.anthropic.com/v1/messages',
52
52
  openai: 'https://api.openai.com/v1/chat/completions',
53
53
  google: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
54
+ ollama: 'http://localhost:11434/v1/chat/completions',
54
55
  };
55
56
 
56
57
  // Bundled cost rates per million tokens (USD).
@@ -161,6 +162,22 @@ const PROVIDER_ERROR_MAPS = {
161
162
  { provider_error_type: 'INTERNAL', http_status: 500, error_class: 'unknown_api_error', retryable: true },
162
163
  ],
163
164
  },
165
+ // Ollama uses OpenAI-compatible error structure
166
+ ollama: {
167
+ extractErrorType(body) {
168
+ return typeof body?.error?.type === 'string' ? body.error.type : null;
169
+ },
170
+ extractErrorCode(body) {
171
+ return typeof body?.error?.code === 'string' ? body.error.code : null;
172
+ },
173
+ mappings: [
174
+ { provider_error_code: 'invalid_api_key', http_status: 401, error_class: 'auth_failure', retryable: false },
175
+ { provider_error_code: 'model_not_found', http_status: 404, error_class: 'model_not_found', retryable: false },
176
+ { provider_error_type: 'invalid_request_error', http_status: 400, body_pattern: /context|token.*limit|too.many.tokens/i, error_class: 'context_overflow', retryable: false },
177
+ { provider_error_type: 'invalid_request_error', http_status: 400, error_class: 'invalid_request', retryable: false },
178
+ { provider_error_type: 'rate_limit_error', http_status: 429, error_class: 'rate_limited', retryable: true },
179
+ ],
180
+ },
164
181
  };
165
182
 
166
183
  // ── Error classification ──────────────────────────────────────────────────────
@@ -465,7 +482,7 @@ function usageFromTelemetry(provider, model, usage, config) {
465
482
  let inputTokens = 0;
466
483
  let outputTokens = 0;
467
484
 
468
- if (provider === 'openai') {
485
+ if (provider === 'openai' || provider === 'ollama') {
469
486
  inputTokens = Number.isFinite(usage.prompt_tokens) ? usage.prompt_tokens : 0;
470
487
  outputTokens = Number.isFinite(usage.completion_tokens) ? usage.completion_tokens : 0;
471
488
  } else if (provider === 'google') {
@@ -811,9 +828,10 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
811
828
  const provider = runtime.provider;
812
829
  const model = runtime.model;
813
830
  const authEnv = runtime.auth_env;
814
- const apiKey = process.env[authEnv];
831
+ const apiKey = authEnv ? process.env[authEnv] : null;
815
832
 
816
- if (!apiKey) {
833
+ // Auth is required for cloud providers, optional for local providers (ollama)
834
+ if (!apiKey && authEnv) {
817
835
  const classified = classifyError(
818
836
  'missing_credentials',
819
837
  `Environment variable "${authEnv}" is not set — required for api_proxy`,
@@ -1124,6 +1142,15 @@ function buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens) {
1124
1142
  };
1125
1143
  }
1126
1144
 
1145
+ function buildOllamaRequest(promptMd, contextMd, model, maxOutputTokens) {
1146
+ const openAiCompatible = buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens);
1147
+ return {
1148
+ ...openAiCompatible,
1149
+ max_tokens: maxOutputTokens,
1150
+ max_completion_tokens: undefined,
1151
+ };
1152
+ }
1153
+
1127
1154
  function buildGoogleHeaders(_apiKey) {
1128
1155
  // Google Gemini uses API key as a query parameter, not a header
1129
1156
  return {
@@ -1203,6 +1230,14 @@ function extractGoogleTurnResult(responseData) {
1203
1230
  return extraction;
1204
1231
  }
1205
1232
 
1233
+ function buildOllamaHeaders(apiKey) {
1234
+ const headers = { 'Content-Type': 'application/json' };
1235
+ if (apiKey) {
1236
+ headers['Authorization'] = `Bearer ${apiKey}`;
1237
+ }
1238
+ return headers;
1239
+ }
1240
+
1206
1241
  function buildProviderHeaders(provider, apiKey) {
1207
1242
  if (provider === 'openai') {
1208
1243
  return buildOpenAiHeaders(apiKey);
@@ -1210,6 +1245,9 @@ function buildProviderHeaders(provider, apiKey) {
1210
1245
  if (provider === 'google') {
1211
1246
  return buildGoogleHeaders(apiKey);
1212
1247
  }
1248
+ if (provider === 'ollama') {
1249
+ return buildOllamaHeaders(apiKey);
1250
+ }
1213
1251
  return buildAnthropicHeaders(apiKey);
1214
1252
  }
1215
1253
 
@@ -1217,6 +1255,9 @@ function buildProviderRequest(provider, promptMd, contextMd, model, maxOutputTok
1217
1255
  if (provider === 'openai') {
1218
1256
  return buildOpenAiRequest(promptMd, contextMd, model, maxOutputTokens);
1219
1257
  }
1258
+ if (provider === 'ollama') {
1259
+ return buildOllamaRequest(promptMd, contextMd, model, maxOutputTokens);
1260
+ }
1220
1261
  if (provider === 'google') {
1221
1262
  return buildGoogleRequest(promptMd, contextMd, model, maxOutputTokens);
1222
1263
  }
@@ -1304,7 +1345,7 @@ function extractOpenAiTurnResult(responseData) {
1304
1345
  }
1305
1346
 
1306
1347
  function extractTurnResult(responseData, provider = 'anthropic') {
1307
- if (provider === 'openai') {
1348
+ if (provider === 'openai' || provider === 'ollama') {
1308
1349
  return extractOpenAiTurnResult(responseData);
1309
1350
  }
1310
1351
  if (provider === 'google') {
@@ -1324,10 +1365,17 @@ export {
1324
1365
  extractTurnResult,
1325
1366
  buildAnthropicRequest,
1326
1367
  buildOpenAiRequest,
1368
+ buildOllamaRequest,
1327
1369
  buildGoogleRequest,
1370
+ buildOllamaHeaders,
1371
+ buildProviderHeaders,
1372
+ buildProviderRequest,
1328
1373
  classifyError,
1329
1374
  classifyHttpError,
1375
+ DEFAULT_RETRY_POLICY,
1330
1376
  BUNDLED_COST_RATES,
1331
1377
  BUNDLED_COST_RATES as COST_RATES, // backward compat alias
1332
1378
  getCostRates,
1379
+ PROVIDER_ENDPOINTS,
1380
+ RETRYABLE_ERROR_CLASSES,
1333
1381
  };
@@ -34,7 +34,16 @@ function deriveRecommendedContinuityAction(state) {
34
34
  };
35
35
  }
36
36
 
37
- if (!['blocked', 'completed', 'failed'].includes(state.status)) {
37
+ if (state.status === 'failed') {
38
+ return {
39
+ recommended_command: null,
40
+ recommended_reason: 'reserved_terminal_state',
41
+ recommended_detail: 'run-level failed is reserved and not emitted by current governed writers',
42
+ restart_recommended: false,
43
+ };
44
+ }
45
+
46
+ if (!['blocked', 'completed'].includes(state.status)) {
38
47
  return {
39
48
  recommended_command: 'agentxchain restart',
40
49
  recommended_reason: 'restart_available',
@@ -20,6 +20,7 @@ import { approvePendingDashboardGate } from './actions.js';
20
20
  import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
21
21
  import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
22
22
  import { readConnectorHealthSnapshot } from './connectors.js';
23
+ import { queryRunHistory } from '../run-history.js';
23
24
 
24
25
  const MIME_TYPES = {
25
26
  '.html': 'text/html; charset=utf-8',
@@ -293,6 +294,14 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
293
294
  return;
294
295
  }
295
296
 
297
+ if (pathname === '/api/run-history') {
298
+ const url = new URL(req.url, `http://${req.headers.host}`);
299
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
300
+ const entries = queryRunHistory(workspacePath, { limit });
301
+ writeJson(res, 200, entries);
302
+ return;
303
+ }
304
+
296
305
  // API routes
297
306
  if (pathname.startsWith('/api/')) {
298
307
  const result = readResource(agentxchainDir, pathname);
@@ -47,6 +47,7 @@ export const FILE_TO_RESOURCE = Object.fromEntries(
47
47
  Object.entries(RESOURCE_MAP).map(([resource, file]) => [normalizeRelativePath(file), resource])
48
48
  );
49
49
  FILE_TO_RESOURCE[normalizeRelativePath(SESSION_RECOVERY_FILE)] = '/api/continuity';
50
+ FILE_TO_RESOURCE[normalizeRelativePath('run-history.jsonl')] = '/api/run-history';
50
51
 
51
52
  export const WATCH_DIRECTORIES = [
52
53
  '',
package/src/lib/export.js CHANGED
@@ -29,6 +29,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
29
29
  '.agentxchain/hook-audit.jsonl',
30
30
  '.agentxchain/hook-annotations.jsonl',
31
31
  '.agentxchain/notification-audit.jsonl',
32
+ '.agentxchain/run-history.jsonl',
32
33
  '.agentxchain/dispatch',
33
34
  '.agentxchain/staging',
34
35
  '.agentxchain/transactions/accept',
@@ -50,6 +51,7 @@ export const RUN_RESTORE_ROOTS = [
50
51
  '.agentxchain/hook-audit.jsonl',
51
52
  '.agentxchain/hook-annotations.jsonl',
52
53
  '.agentxchain/notification-audit.jsonl',
54
+ '.agentxchain/run-history.jsonl',
53
55
  '.agentxchain/dispatch',
54
56
  '.agentxchain/staging',
55
57
  '.agentxchain/transactions/accept',
@@ -36,6 +36,7 @@ import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getRev
36
36
  import { runHooks } from './hook-runner.js';
37
37
  import { emitNotifications } from './notification-runner.js';
38
38
  import { writeSessionCheckpoint } from './session-checkpoint.js';
39
+ import { recordRunHistory } from './run-history.js';
39
40
 
40
41
  // ── Constants ────────────────────────────────────────────────────────────────
41
42
 
@@ -1029,6 +1030,12 @@ function blockRunForHookIssue(root, state, { phase, turnId, hookName, detail, er
1029
1030
  }),
1030
1031
  };
1031
1032
  writeState(root, blockedState);
1033
+
1034
+ // DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
1035
+ if (notificationConfig) {
1036
+ recordRunHistory(root, blockedState, notificationConfig, 'blocked');
1037
+ }
1038
+
1032
1039
  emitBlockedNotification(root, notificationConfig, blockedState, {
1033
1040
  category: typedReason,
1034
1041
  blockedOn: blockedState.blocked_on,
@@ -2094,6 +2101,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2094
2101
  });
2095
2102
 
2096
2103
  writeState(root, updatedState);
2104
+
2105
+ // DEC-RHTR-SPEC: Record conflict_loop blocked outcome in cross-run history (non-fatal)
2106
+ if (updatedState.status === 'blocked') {
2107
+ recordRunHistory(root, updatedState, config, 'blocked');
2108
+ }
2109
+
2097
2110
  return {
2098
2111
  ok: false,
2099
2112
  error: `Acceptance conflict detected for turn ${currentTurn.turn_id}`,
@@ -2464,6 +2477,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2464
2477
  }
2465
2478
 
2466
2479
  if (updatedState.status === 'blocked') {
2480
+ // DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
2481
+ // Covers needs_human, budget:exhausted, and any other non-hook blocked states
2482
+ recordRunHistory(root, updatedState, config, 'blocked');
2483
+
2467
2484
  emitBlockedNotification(root, config, updatedState, {
2468
2485
  category: updatedState.blocked_reason?.category || 'needs_human',
2469
2486
  blockedOn: updatedState.blocked_on,
@@ -2694,6 +2711,9 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
2694
2711
 
2695
2712
  writeState(root, updatedState);
2696
2713
 
2714
+ // DEC-RHTR-SPEC: Record retries-exhausted blocked outcome in cross-run history (non-fatal)
2715
+ recordRunHistory(root, updatedState, config, 'blocked');
2716
+
2697
2717
  emitBlockedNotification(root, config, updatedState, {
2698
2718
  category: 'retries_exhausted',
2699
2719
  blockedOn: updatedState.blocked_on,
@@ -2913,6 +2933,9 @@ export function approveRunCompletion(root, config) {
2913
2933
  // Session checkpoint — non-fatal
2914
2934
  writeSessionCheckpoint(root, updatedState, 'run_completed');
2915
2935
 
2936
+ // Run history — non-fatal
2937
+ recordRunHistory(root, updatedState, config, 'completed');
2938
+
2916
2939
  return {
2917
2940
  ok: true,
2918
2941
  state: attachLegacyCurrentTurnAlias(updatedState),
package/src/lib/intake.js CHANGED
@@ -23,7 +23,8 @@ const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
23
23
  const EVENT_ID_RE = /^evt_\d+_[0-9a-f]{4}$/;
24
24
  const INTENT_ID_RE = /^intent_\d+_[0-9a-f]{4}$/;
25
25
 
26
- // V3-S1 through S5 states
26
+ // V3-S1 through S5 states. `failed` remains read-tolerant for historical/manual
27
+ // intent files, but current first-party intake writers do not transition into it.
27
28
  const S1_STATES = new Set(['detected', 'triaged', 'approved', 'planned', 'executing', 'blocked', 'completed', 'failed', 'suppressed', 'rejected']);
28
29
  const TERMINAL_STATES = new Set(['suppressed', 'rejected', 'completed', 'failed']);
29
30
 
@@ -32,7 +33,7 @@ const VALID_TRANSITIONS = {
32
33
  triaged: ['approved', 'rejected'],
33
34
  approved: ['planned'],
34
35
  planned: ['executing'],
35
- executing: ['blocked', 'completed', 'failed'],
36
+ executing: ['blocked', 'completed'],
36
37
  blocked: ['approved'],
37
38
  };
38
39
 
@@ -864,23 +865,29 @@ function resolveRepoBackedIntent(root, intentPath, dirs, intent) {
864
865
  const now = nowISO();
865
866
  const previousStatus = intent.status;
866
867
 
867
- if (state.status === 'blocked' || state.status === 'failed') {
868
- const newStatus = state.status === 'blocked' ? 'blocked' : 'failed';
869
- intent.status = newStatus;
868
+ // Run-level 'failed' is reserved/unreached in current governed writers (DEC-RUN-STATUS-001).
869
+ // Fail closed if encountered operator must investigate manually.
870
+ if (state.status === 'failed') {
871
+ return {
872
+ ok: false,
873
+ error: 'governed run has reserved status "failed" which is not emitted by current governed writers. Manual inspection required. See DEC-RUN-STATUS-001.',
874
+ exitCode: 1,
875
+ };
876
+ }
877
+
878
+ if (state.status === 'blocked') {
879
+ intent.status = 'blocked';
870
880
  intent.run_blocked_on = state.blocked_on || null;
871
881
  intent.run_blocked_reason = state.blocked_reason?.category || null;
872
882
  intent.run_blocked_recovery = state.blocked_reason?.recovery?.recovery_action || null;
873
- if (newStatus === 'failed') {
874
- intent.run_failed_at = now;
875
- }
876
883
  intent.updated_at = now;
877
884
  intent.history.push({
878
885
  from: previousStatus,
879
- to: newStatus,
886
+ to: 'blocked',
880
887
  at: now,
881
- reason: `governed run ${intent.target_run} reached status ${state.status}`,
888
+ reason: `governed run ${intent.target_run} reached status blocked`,
882
889
  run_id: intent.target_run,
883
- run_status: state.status,
890
+ run_status: 'blocked',
884
891
  });
885
892
 
886
893
  safeWriteJson(intentPath, intent);
@@ -888,8 +895,8 @@ function resolveRepoBackedIntent(root, intentPath, dirs, intent) {
888
895
  ok: true,
889
896
  intent,
890
897
  previous_status: previousStatus,
891
- new_status: newStatus,
892
- run_outcome: state.status,
898
+ new_status: 'blocked',
899
+ run_outcome: 'blocked',
893
900
  no_change: false,
894
901
  exitCode: 0,
895
902
  };
@@ -1025,15 +1032,26 @@ function resolveCoordinatorBackedIntent(root, intentPath, dirs, intent) {
1025
1032
  };
1026
1033
  }
1027
1034
 
1028
- if (coordinatorState.status === 'failed' || coordinatorState.status === 'completed') {
1029
- intent.status = 'failed';
1030
- intent.run_failed_at = now;
1035
+ // Coordinator run-level 'failed' is reserved/unreached (DEC-RUN-STATUS-001). Fail closed.
1036
+ if (coordinatorState.status === 'failed') {
1037
+ return {
1038
+ ok: false,
1039
+ error: `coordinator run ${expectedSuperRunId} has reserved status "failed" which is not emitted by current governed writers. Manual inspection required. See DEC-RUN-STATUS-001.`,
1040
+ exitCode: 1,
1041
+ };
1042
+ }
1043
+
1044
+ if (coordinatorState.status === 'completed') {
1045
+ intent.status = 'blocked';
1046
+ intent.run_blocked_on = `coordinator:completed_without_workstream:${workstreamId}`;
1047
+ intent.run_blocked_reason = 'coordinator_completed_without_workstream';
1048
+ intent.run_blocked_recovery = `Coordinator run ${expectedSuperRunId} completed without satisfying workstream ${workstreamId}. Re-approve the intent and start a new run.`;
1031
1049
  intent.updated_at = now;
1032
1050
  intent.history.push({
1033
1051
  from: previousStatus,
1034
- to: 'failed',
1052
+ to: 'blocked',
1035
1053
  at: now,
1036
- reason: `coordinator run ${expectedSuperRunId} ended without satisfying workstream ${workstreamId}`,
1054
+ reason: `coordinator run ${expectedSuperRunId} completed without satisfying workstream ${workstreamId}`,
1037
1055
  super_run_id: expectedSuperRunId,
1038
1056
  run_status: coordinatorState.status,
1039
1057
  });
@@ -1043,7 +1061,7 @@ function resolveCoordinatorBackedIntent(root, intentPath, dirs, intent) {
1043
1061
  ok: true,
1044
1062
  intent,
1045
1063
  previous_status: previousStatus,
1046
- new_status: 'failed',
1064
+ new_status: 'blocked',
1047
1065
  run_outcome: coordinatorState.status,
1048
1066
  no_change: false,
1049
1067
  exitCode: 0,
@@ -24,7 +24,8 @@ import {
24
24
 
25
25
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
26
26
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
27
- const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai', 'google'];
27
+ export const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai', 'google', 'ollama'];
28
+ const AUTH_OPTIONAL_PROVIDERS = ['ollama'];
28
29
  export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
29
30
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
30
31
  const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
@@ -388,7 +389,9 @@ export function validateV4Config(data, projectRoot) {
388
389
  errors.push(`Runtime "${id}": api_proxy requires "model" (e.g. "claude-sonnet-4-6")`);
389
390
  }
390
391
  if (typeof rt.auth_env !== 'string' || !rt.auth_env.trim()) {
391
- errors.push(`Runtime "${id}": api_proxy requires "auth_env" (environment variable name for API key)`);
392
+ if (!AUTH_OPTIONAL_PROVIDERS.includes(rt.provider)) {
393
+ errors.push(`Runtime "${id}": api_proxy requires "auth_env" (environment variable name for API key)`);
394
+ }
392
395
  }
393
396
  if ('base_url' in rt) {
394
397
  if (typeof rt.base_url !== 'string' || !rt.base_url.trim()) {
@@ -40,6 +40,7 @@ const ORCHESTRATOR_STATE_FILES = [
40
40
  '.agentxchain/lock.json',
41
41
  '.agentxchain/hook-audit.jsonl',
42
42
  '.agentxchain/hook-annotations.jsonl',
43
+ '.agentxchain/run-history.jsonl',
43
44
  'TALK.md',
44
45
  ];
45
46
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Run History — cross-run operator observability.
3
+ *
4
+ * Append-only JSONL ledger persisting summary metadata from each governed run.
5
+ * Survives across runs (not reset by initializeGovernedRun).
6
+ *
7
+ * DEC-RH-SPEC: .planning/RUN_HISTORY_SPEC.md
8
+ */
9
+
10
+ import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
11
+ import { join, dirname } from 'path';
12
+
13
+ const RUN_HISTORY_PATH = '.agentxchain/run-history.jsonl';
14
+ const HISTORY_PATH = '.agentxchain/history.jsonl';
15
+ const LEDGER_PATH = '.agentxchain/decision-ledger.jsonl';
16
+ const SCHEMA_VERSION = '0.1';
17
+ const WRITABLE_TERMINAL_STATUSES = new Set(['completed', 'blocked']);
18
+
19
+ /**
20
+ * Record a run's summary into the persistent run-history ledger.
21
+ * Non-fatal: catches and returns { ok: false, error } on failure.
22
+ *
23
+ * @param {string} root - project root directory
24
+ * @param {object} state - final governed state
25
+ * @param {object} config - normalized config
26
+ * @param {'completed'|'blocked'} status - terminal status produced by current governed writers
27
+ * @returns {{ ok: boolean, error?: string }}
28
+ */
29
+ export function recordRunHistory(root, state, config, status) {
30
+ try {
31
+ if (!WRITABLE_TERMINAL_STATUSES.has(status)) {
32
+ return {
33
+ ok: false,
34
+ error: `Unsupported run-history terminal status: ${status}. Current governed writers emit completed or blocked only.`,
35
+ };
36
+ }
37
+
38
+ const filePath = join(root, RUN_HISTORY_PATH);
39
+ mkdirSync(dirname(filePath), { recursive: true });
40
+
41
+ const historyEntries = readJsonlSafe(root, HISTORY_PATH);
42
+ const ledgerEntries = readJsonlSafe(root, LEDGER_PATH);
43
+
44
+ // Extract unique phases and roles from turn history
45
+ const phasesCompleted = [...new Set(historyEntries.map(e => e.phase).filter(Boolean))];
46
+ const rolesUsed = [...new Set(historyEntries.map(e => e.role).filter(Boolean))];
47
+
48
+ // Derive connector and model from config
49
+ const firstRole = Object.values(config.roles || {})[0];
50
+ const connectorUsed = firstRole?.runtime_id || firstRole?.runtime || null;
51
+ const modelUsed = firstRole?.model || config.adapter?.model || null;
52
+
53
+ // Derive run start time from first history entry or state
54
+ const startedAt = historyEntries[0]?.accepted_at
55
+ || state?.created_at
56
+ || null;
57
+ const completedAt = state?.completed_at || null;
58
+ const durationMs = (startedAt && completedAt)
59
+ ? new Date(completedAt).getTime() - new Date(startedAt).getTime()
60
+ : null;
61
+
62
+ const record = {
63
+ schema_version: SCHEMA_VERSION,
64
+ run_id: state?.run_id || null,
65
+ project_id: config.project?.id || null,
66
+ project_name: config.project?.name || null,
67
+ template: config.template || null,
68
+ status,
69
+ started_at: startedAt,
70
+ completed_at: completedAt,
71
+ duration_ms: durationMs,
72
+ phases_completed: phasesCompleted,
73
+ total_turns: historyEntries.length,
74
+ roles_used: rolesUsed,
75
+ decisions_count: ledgerEntries.length,
76
+ total_cost_usd: state?.budget_status?.spent_usd ?? null,
77
+ budget_limit_usd: config.budget?.per_run_max_usd ?? null,
78
+ blocked_reason: status === 'blocked' ? (state?.blocked_reason?.detail || state?.blocked_on || null) : null,
79
+ gate_results: state?.phase_gate_status || {},
80
+ connector_used: connectorUsed,
81
+ model_used: modelUsed,
82
+ recorded_at: new Date().toISOString(),
83
+ };
84
+
85
+ appendFileSync(filePath, JSON.stringify(record) + '\n');
86
+ return { ok: true };
87
+ } catch (err) {
88
+ return { ok: false, error: err.message || String(err) };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Query the run-history ledger.
94
+ *
95
+ * @param {string} root - project root directory
96
+ * @param {object} [opts] - { limit?: number, status?: string }
97
+ * @returns {Array<object>} most-recent-first
98
+ */
99
+ export function queryRunHistory(root, opts = {}) {
100
+ const filePath = join(root, RUN_HISTORY_PATH);
101
+ if (!existsSync(filePath)) return [];
102
+
103
+ let content;
104
+ try {
105
+ content = readFileSync(filePath, 'utf8').trim();
106
+ } catch {
107
+ return [];
108
+ }
109
+ if (!content) return [];
110
+
111
+ let entries = content
112
+ .split('\n')
113
+ .filter(Boolean)
114
+ .map(line => {
115
+ try { return JSON.parse(line); } catch { return null; }
116
+ })
117
+ .filter(Boolean);
118
+
119
+ // Filter by status if requested
120
+ if (opts.status) {
121
+ entries = entries.filter(e => e.status === opts.status);
122
+ }
123
+
124
+ // Most recent first
125
+ entries.reverse();
126
+
127
+ // Limit
128
+ if (opts.limit && opts.limit > 0) {
129
+ entries = entries.slice(0, opts.limit);
130
+ }
131
+
132
+ return entries;
133
+ }
134
+
135
+ /**
136
+ * Get the path to the run-history file.
137
+ */
138
+ export function getRunHistoryPath(root) {
139
+ return join(root, RUN_HISTORY_PATH);
140
+ }
141
+
142
+ // ── Internal ────────────────────────────────────────────────────────────────
143
+
144
+ function readJsonlSafe(root, relPath) {
145
+ const filePath = join(root, relPath);
146
+ if (!existsSync(filePath)) return [];
147
+ try {
148
+ const content = readFileSync(filePath, 'utf8').trim();
149
+ if (!content) return [];
150
+ return content.split('\n').filter(Boolean).map(line => {
151
+ try { return JSON.parse(line); } catch { return null; }
152
+ }).filter(Boolean);
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
package/src/lib/schema.js CHANGED
@@ -31,6 +31,8 @@ export function validateStateSchema(data) {
31
31
 
32
32
  export function validateGovernedStateSchema(data) {
33
33
  const errors = [];
34
+ // Keep `failed` for compatibility. Current governed writers do not emit it,
35
+ // but validators and read-only surfaces still tolerate reserved/manual states.
34
36
  const VALID_RUN_STATUSES = ['idle', 'active', 'paused', 'blocked', 'completed', 'failed'];
35
37
  const isV1_1 = data?.schema_version === '1.1';
36
38
  const hasLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(data || {}, 'current_turn');