agentxchain 2.92.0 → 2.93.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.
@@ -113,6 +113,7 @@ import { intakeResolveCommand } from '../src/commands/intake-resolve.js';
113
113
  import { intakeStatusCommand } from '../src/commands/intake-status.js';
114
114
  import { demoCommand } from '../src/commands/demo.js';
115
115
  import { historyCommand } from '../src/commands/history.js';
116
+ import { decisionsCommand } from '../src/commands/decisions.js';
116
117
  import { diffCommand } from '../src/commands/diff.js';
117
118
  import { eventsCommand } from '../src/commands/events.js';
118
119
  import { connectorCheckCommand } from '../src/commands/connector.js';
@@ -157,7 +158,7 @@ program
157
158
  program
158
159
  .command('audit')
159
160
  .description('Render a governance audit directly from the current governed project or coordinator workspace')
160
- .option('--format <format>', 'Output format: text, json, or markdown', 'text')
161
+ .option('--format <format>', 'Output format: text, json, markdown, or html', 'text')
161
162
  .action(auditCommand);
162
163
 
163
164
  program
@@ -176,7 +177,7 @@ program
176
177
  .command('report')
177
178
  .description('Render a human-readable governance summary from an export artifact')
178
179
  .option('--input <path>', 'Export artifact path, or "-" for stdin', '-')
179
- .option('--format <format>', 'Output format: text, json, or markdown', 'text')
180
+ .option('--format <format>', 'Output format: text, json, markdown, or html', 'text')
180
181
  .action(reportCommand);
181
182
 
182
183
  program
@@ -333,6 +334,15 @@ program
333
334
  .option('-d, --dir <path>', 'Project directory')
334
335
  .action(historyCommand);
335
336
 
337
+ program
338
+ .command('decisions')
339
+ .description('Show repo-level decisions that persist across governed runs')
340
+ .option('-j, --json', 'Output as JSON')
341
+ .option('-a, --all', 'Include overridden decisions')
342
+ .option('-s, --show <id>', 'Show details for a specific decision (e.g. DEC-042)')
343
+ .option('-d, --dir <path>', 'Project directory')
344
+ .action(decisionsCommand);
345
+
336
346
  program
337
347
  .command('diff <left_run_id> <right_run_id>')
338
348
  .description('Compare two recorded governed runs from run-history')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.92.0",
3
+ "version": "2.93.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { buildCoordinatorExport, buildRunExport } from '../lib/export.js';
4
4
  import {
5
5
  buildGovernanceReport,
6
+ formatGovernanceReportHtml,
6
7
  formatGovernanceReportMarkdown,
7
8
  formatGovernanceReportText,
8
9
  } from '../lib/report.js';
@@ -43,6 +44,11 @@ function printAndExit(report, format, exitCode) {
43
44
  process.exit(exitCode);
44
45
  }
45
46
 
47
+ if (format === 'html') {
48
+ console.log(formatGovernanceReportHtml(report));
49
+ process.exit(exitCode);
50
+ }
51
+
46
52
  if (format === 'text') {
47
53
  if (report.overall === 'error' || report.overall === 'fail') {
48
54
  console.log(chalk.red(formatGovernanceReportText(report)));
@@ -52,7 +58,7 @@ function printAndExit(report, format, exitCode) {
52
58
  process.exit(exitCode);
53
59
  }
54
60
 
55
- console.error(`Unsupported audit format "${format}". Use "text", "json", or "markdown".`);
61
+ console.error(`Unsupported audit format "${format}". Use "text", "json", "markdown", or "html".`);
56
62
  process.exit(2);
57
63
  }
58
64
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * agentxchain decisions — cross-run decision carryover surface.
3
+ *
4
+ * Shows repo-level decisions that persist across governed runs.
5
+ */
6
+
7
+ import { resolve } from 'path';
8
+ import { existsSync } from 'fs';
9
+ import chalk from 'chalk';
10
+ import { readRepoDecisions, getActiveRepoDecisions, getRepoDecisionById } from '../lib/repo-decisions.js';
11
+
12
+ /**
13
+ * @param {object} opts - { json?: boolean, all?: boolean, show?: string, dir?: string }
14
+ */
15
+ export async function decisionsCommand(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
+ // ── Show single decision ───────────────────────────────────────────────
23
+ if (opts.show) {
24
+ const dec = getRepoDecisionById(root, opts.show);
25
+ if (!dec) {
26
+ console.error(chalk.red(`Decision ${opts.show} not found in repo decisions.`));
27
+ process.exit(1);
28
+ }
29
+ if (opts.json) {
30
+ console.log(JSON.stringify(dec, null, 2));
31
+ return;
32
+ }
33
+ console.log(chalk.bold(`Decision ${dec.id}`));
34
+ console.log(` Category: ${dec.category}`);
35
+ console.log(` Statement: ${dec.statement}`);
36
+ console.log(` Rationale: ${dec.rationale}`);
37
+ console.log(` Status: ${formatStatus(dec.status)}`);
38
+ console.log(` Role: ${dec.role || '—'}`);
39
+ console.log(` Phase: ${dec.phase || '—'}`);
40
+ console.log(` Run: ${(dec.run_id || '—').slice(0, 16)}`);
41
+ console.log(` Turn: ${(dec.turn_id || '—').slice(0, 16)}`);
42
+ console.log(` Created: ${dec.created_at || '—'}`);
43
+ if (dec.overridden_by) {
44
+ console.log(` Overridden: ${chalk.yellow(dec.overridden_by)}`);
45
+ }
46
+ return;
47
+ }
48
+
49
+ // ── List decisions ─────────────────────────────────────────────────────
50
+ const decisions = opts.all ? readRepoDecisions(root) : getActiveRepoDecisions(root);
51
+
52
+ if (opts.json) {
53
+ console.log(JSON.stringify(decisions, null, 2));
54
+ return;
55
+ }
56
+
57
+ if (decisions.length === 0) {
58
+ console.log(chalk.dim('No repo-level decisions found.'));
59
+ if (!opts.all) {
60
+ console.log(chalk.dim('Use --all to include overridden decisions.'));
61
+ }
62
+ return;
63
+ }
64
+
65
+ const label = opts.all ? 'Repo Decisions (all)' : 'Active Repo Decisions';
66
+ console.log(chalk.bold(`${label}: ${decisions.length}`));
67
+ console.log('');
68
+
69
+ for (const dec of decisions) {
70
+ const status = formatStatus(dec.status);
71
+ const runShort = (dec.run_id || '').slice(0, 12);
72
+ const override = dec.overridden_by ? chalk.dim(` → ${dec.overridden_by}`) : '';
73
+ console.log(` ${chalk.cyan(dec.id)} ${status} ${chalk.dim(dec.category)} ${dec.statement}${override}`);
74
+ console.log(` ${chalk.dim(`by ${dec.role || '?'} in ${runShort || '?'}`)}`);
75
+ }
76
+ }
77
+
78
+ function formatStatus(status) {
79
+ if (status === 'active') return chalk.green('active');
80
+ if (status === 'overridden') return chalk.yellow('overridden');
81
+ return chalk.dim(status || '—');
82
+ }
83
+
84
+ function findProjectRoot(dir) {
85
+ let current = resolve(dir);
86
+ while (current !== '/') {
87
+ if (existsSync(resolve(current, 'agentxchain.json'))) return current;
88
+ if (existsSync(resolve(current, '.agentxchain'))) return current;
89
+ const parent = resolve(current, '..');
90
+ if (parent === current) break;
91
+ current = parent;
92
+ }
93
+ return null;
94
+ }
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { loadExportArtifact } from '../lib/export-verifier.js';
4
4
  import {
5
5
  buildGovernanceReport,
6
+ formatGovernanceReportHtml,
6
7
  formatGovernanceReportMarkdown,
7
8
  formatGovernanceReportText,
8
9
  } from '../lib/report.js';
@@ -18,6 +19,11 @@ function printAndExit(report, format, exitCode) {
18
19
  process.exit(exitCode);
19
20
  }
20
21
 
22
+ if (format === 'html') {
23
+ console.log(formatGovernanceReportHtml(report));
24
+ process.exit(exitCode);
25
+ }
26
+
21
27
  if (format === 'text') {
22
28
  if (report.overall === 'error') {
23
29
  console.log(chalk.red(formatGovernanceReportText(report)));
@@ -29,7 +35,7 @@ function printAndExit(report, format, exitCode) {
29
35
  process.exit(exitCode);
30
36
  }
31
37
 
32
- console.error(`Unsupported report format "${format}". Use "text", "json", or "markdown".`);
38
+ console.error(`Unsupported report format "${format}". Use "text", "json", "markdown", or "html".`);
33
39
  process.exit(1);
34
40
  }
35
41
 
@@ -105,6 +105,7 @@ function renderGovernedStatus(context, opts) {
105
105
  state,
106
106
  provenance: state?.provenance || null,
107
107
  inherited_context: state?.inherited_context || null,
108
+ repo_decisions: state?.repo_decisions || null,
108
109
  continuity,
109
110
  connector_health: connectorHealth,
110
111
  workflow_kit_artifacts: workflowKitArtifacts,
@@ -133,6 +134,9 @@ function renderGovernedStatus(context, opts) {
133
134
  if (state?.inherited_context?.parent_run_id) {
134
135
  console.log(` ${chalk.dim('Inherits:')} ${chalk.magenta(`parent ${state.inherited_context.parent_run_id} (${state.inherited_context.parent_status || 'unknown'})`)}`);
135
136
  }
137
+ if (state?.repo_decisions?.length > 0) {
138
+ console.log(` ${chalk.dim('Repo decisions:')} ${chalk.yellow(`${state.repo_decisions.length} active`)}`);
139
+ }
136
140
  if (state?.accepted_integration_ref) {
137
141
  console.log(` ${chalk.dim('Accepted:')} ${state.accepted_integration_ref}`);
138
142
  }
@@ -18,6 +18,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync
18
18
  import { join } from 'path';
19
19
  import { getActiveTurn, getActiveTurns } from './governed-state.js';
20
20
  import { renderInheritedContextMarkdown } from './run-context-inheritance.js';
21
+ import { renderRepoDecisionsMarkdown } from './repo-decisions.js';
21
22
  import {
22
23
  DISPATCH_INDEX_PATH,
23
24
  getDispatchAssignmentPath,
@@ -605,6 +606,14 @@ function renderContext(state, config, root, turn, role) {
605
606
  lines.push('');
606
607
  }
607
608
 
609
+ // Repo-level decisions that persist across runs
610
+ if (state.repo_decisions && state.repo_decisions.length > 0) {
611
+ const repoDecMd = renderRepoDecisionsMarkdown(state.repo_decisions);
612
+ if (repoDecMd) {
613
+ lines.push(repoDecMd);
614
+ }
615
+ }
616
+
608
617
  // Inherited context from parent run (when --inherit-context was used)
609
618
  if (state.inherited_context) {
610
619
  // First turn gets the full rendering; subsequent turns get compact
package/src/lib/export.js CHANGED
@@ -8,6 +8,7 @@ import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-co
8
8
  import { loadCoordinatorState } from './coordinator-state.js';
9
9
  import { normalizeRunProvenance } from './run-provenance.js';
10
10
  import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
11
+ import { readRepoDecisions } from './repo-decisions.js';
11
12
 
12
13
  const EXPORT_SCHEMA_VERSION = '0.3';
13
14
 
@@ -30,6 +31,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
30
31
  '.agentxchain/session.json',
31
32
  '.agentxchain/history.jsonl',
32
33
  '.agentxchain/decision-ledger.jsonl',
34
+ '.agentxchain/repo-decisions.jsonl',
33
35
  '.agentxchain/hook-audit.jsonl',
34
36
  '.agentxchain/hook-annotations.jsonl',
35
37
  '.agentxchain/notification-audit.jsonl',
@@ -208,6 +210,20 @@ function buildDashboardSessionSummary(root) {
208
210
  };
209
211
  }
210
212
 
213
+ export function buildRepoDecisionsSummary(root) {
214
+ const all = readRepoDecisions(root);
215
+ if (!all || all.length === 0) return null;
216
+ const active = all.filter(d => d.status === 'active');
217
+ const overridden = all.filter(d => d.status === 'overridden');
218
+ return {
219
+ total: all.length,
220
+ active_count: active.length,
221
+ overridden_count: overridden.length,
222
+ active: active.map(d => ({ id: d.id, category: d.category, statement: d.statement, role: d.role, run_id: d.run_id })),
223
+ overridden: overridden.map(d => ({ id: d.id, overridden_by: d.overridden_by, statement: d.statement })),
224
+ };
225
+ }
226
+
211
227
  export function buildDelegationSummary(files) {
212
228
  const historyData = files['.agentxchain/history.jsonl']?.data;
213
229
  if (!Array.isArray(historyData)) {
@@ -448,6 +464,7 @@ export function buildRunExport(startDir = process.cwd()) {
448
464
  coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
449
465
  dashboard_session: buildDashboardSessionSummary(root),
450
466
  delegation_summary: buildDelegationSummary(files),
467
+ repo_decisions: buildRepoDecisionsSummary(root),
451
468
  },
452
469
  workspace: buildRunWorkspaceMetadata(root),
453
470
  files,
@@ -44,6 +44,12 @@ import { emitRunEvent } from './run-events.js';
44
44
  import { writeSessionCheckpoint } from './session-checkpoint.js';
45
45
  import { recordRunHistory } from './run-history.js';
46
46
  import { buildDefaultRunProvenance } from './run-provenance.js';
47
+ import {
48
+ getActiveRepoDecisions,
49
+ appendRepoDecision,
50
+ overrideRepoDecision,
51
+ validateOverride,
52
+ } from './repo-decisions.js';
47
53
  import {
48
54
  replayVerificationMachineEvidence,
49
55
  summarizeVerificationReplay,
@@ -1908,6 +1914,7 @@ export function initializeGovernedRun(root, config, options = {}) {
1908
1914
  const runId = generateId('run');
1909
1915
  const now = new Date().toISOString();
1910
1916
  const provenance = buildDefaultRunProvenance(options.provenance);
1917
+ const repoDecisions = getActiveRepoDecisions(root);
1911
1918
  const updatedState = {
1912
1919
  ...state,
1913
1920
  run_id: runId,
@@ -1922,6 +1929,7 @@ export function initializeGovernedRun(root, config, options = {}) {
1922
1929
  },
1923
1930
  provenance,
1924
1931
  inherited_context: options.inherited_context || null,
1932
+ repo_decisions: repoDecisions.length > 0 ? repoDecisions : null,
1925
1933
  };
1926
1934
 
1927
1935
  writeState(root, updatedState);
@@ -2423,6 +2431,23 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2423
2431
  }
2424
2432
 
2425
2433
  const turnResult = validation.turnResult;
2434
+
2435
+ // Validate cross-run decision overrides against repo-decisions.jsonl
2436
+ if (turnResult.decisions && turnResult.decisions.length > 0) {
2437
+ for (const dec of turnResult.decisions) {
2438
+ if (dec.overrides) {
2439
+ const overrideCheck = validateOverride(root, dec);
2440
+ if (!overrideCheck.ok) {
2441
+ return {
2442
+ ok: false,
2443
+ error: `Override validation failed: ${overrideCheck.error}`,
2444
+ error_code: 'override_validation_failed',
2445
+ };
2446
+ }
2447
+ }
2448
+ }
2449
+ }
2450
+
2426
2451
  const stagingFile = join(root, resolvedStagingPath);
2427
2452
  const now = new Date().toISOString();
2428
2453
  const baseline = currentTurn.baseline || null;
@@ -3296,11 +3321,36 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3296
3321
  };
3297
3322
  writeAcceptanceJournal(root, journal);
3298
3323
 
3299
- // ── Commit order: history → ledger → talk → state → cleanup → journal ─
3324
+ // ── Commit order: history → ledger → repo-decisions → talk → state → cleanup → journal ─
3300
3325
  appendJsonl(root, HISTORY_PATH, historyEntry);
3301
3326
  for (const entry of ledgerEntries) {
3302
3327
  appendJsonl(root, LEDGER_PATH, entry);
3303
3328
  }
3329
+ // Persist repo-durable decisions and process overrides
3330
+ if (turnResult.decisions && turnResult.decisions.length > 0) {
3331
+ for (const dec of turnResult.decisions) {
3332
+ // Process override first (marks target as overridden in repo-decisions.jsonl)
3333
+ if (dec.overrides) {
3334
+ overrideRepoDecision(root, dec.overrides, dec.id);
3335
+ }
3336
+ // Write to repo-decisions.jsonl if repo-durable or overriding a repo decision
3337
+ if ((dec.durability || 'run') === 'repo' || dec.overrides) {
3338
+ appendRepoDecision(root, {
3339
+ id: dec.id,
3340
+ run_id: state.run_id,
3341
+ turn_id: turnResult.turn_id,
3342
+ role: turnResult.role,
3343
+ phase: state.phase,
3344
+ category: dec.category,
3345
+ statement: dec.statement,
3346
+ rationale: dec.rationale,
3347
+ status: 'active',
3348
+ overridden_by: null,
3349
+ created_at: now,
3350
+ });
3351
+ }
3352
+ }
3353
+ }
3304
3354
  appendTalk(root, talkSection);
3305
3355
  writeState(root, updatedState);
3306
3356
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Repo Decisions — cross-run decision carryover.
3
+ *
4
+ * Decisions with durability: "repo" persist in `.agentxchain/repo-decisions.jsonl`
5
+ * across governed runs. They act as binding constraints: agents in future runs
6
+ * must comply with active repo decisions or explicitly override them.
7
+ *
8
+ * DEC-SPEC: .planning/CROSS_RUN_DECISION_CARRYOVER_SPEC.md
9
+ */
10
+
11
+ import { readFileSync, appendFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
12
+ import { join, dirname } from 'path';
13
+
14
+ const REPO_DECISIONS_PATH = '.agentxchain/repo-decisions.jsonl';
15
+
16
+ // ── Read ────────────────────────────────────────────────────────────────────
17
+
18
+ export function readRepoDecisions(root) {
19
+ const filePath = join(root, REPO_DECISIONS_PATH);
20
+ if (!existsSync(filePath)) return [];
21
+ try {
22
+ const content = readFileSync(filePath, 'utf8').trim();
23
+ if (!content) return [];
24
+ return content.split('\n').filter(Boolean).map(line => {
25
+ try { return JSON.parse(line); } catch { return null; }
26
+ }).filter(Boolean);
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ export function getActiveRepoDecisions(root) {
33
+ return readRepoDecisions(root).filter(d => d.status === 'active');
34
+ }
35
+
36
+ export function getRepoDecisionById(root, decisionId) {
37
+ return readRepoDecisions(root).find(d => d.id === decisionId) || null;
38
+ }
39
+
40
+ // ── Write ───────────────────────────────────────────────────────────────────
41
+
42
+ export function appendRepoDecision(root, entry) {
43
+ const filePath = join(root, REPO_DECISIONS_PATH);
44
+ const dir = dirname(filePath);
45
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
46
+ appendFileSync(filePath, JSON.stringify(entry) + '\n');
47
+ }
48
+
49
+ export function overrideRepoDecision(root, targetId, overridingId) {
50
+ const all = readRepoDecisions(root);
51
+ const updated = all.map(d => {
52
+ if (d.id === targetId) {
53
+ return { ...d, status: 'overridden', overridden_by: overridingId };
54
+ }
55
+ return d;
56
+ });
57
+ const filePath = join(root, REPO_DECISIONS_PATH);
58
+ const dir = dirname(filePath);
59
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
60
+ writeFileSync(filePath, updated.map(d => JSON.stringify(d)).join('\n') + '\n');
61
+ }
62
+
63
+ // ── Validate Override ───────────────────────────────────────────────────────
64
+
65
+ export function validateOverride(root, decision) {
66
+ if (!decision.overrides) return { ok: true };
67
+ const targetId = decision.overrides;
68
+ const target = getRepoDecisionById(root, targetId);
69
+ if (!target) {
70
+ return { ok: false, error: `decisions: overrides references ${targetId} which does not exist in repo decisions.` };
71
+ }
72
+ if (target.status === 'overridden') {
73
+ return { ok: false, error: `decisions: ${targetId} is already overridden by ${target.overridden_by}.` };
74
+ }
75
+ if (target.status !== 'active') {
76
+ return { ok: false, error: `decisions: ${targetId} has status "${target.status}", only active repo decisions can be overridden.` };
77
+ }
78
+ return { ok: true };
79
+ }
80
+
81
+ // ── Render ──────────────────────────────────────────────────────────────────
82
+
83
+ export function renderRepoDecisionsMarkdown(activeDecisions) {
84
+ if (!activeDecisions || activeDecisions.length === 0) return '';
85
+ const lines = [
86
+ '## Active Repo Decisions',
87
+ '',
88
+ 'These decisions persist from prior governed runs. Comply or explicitly override with rationale.',
89
+ '',
90
+ ];
91
+ for (const d of activeDecisions) {
92
+ lines.push(`- **${d.id}** (${d.category}): ${d.statement}`);
93
+ }
94
+ lines.push('');
95
+ return lines.join('\n');
96
+ }
97
+
98
+ // ── Constants ───────────────────────────────────────────────────────────────
99
+
100
+ export { REPO_DECISIONS_PATH };
package/src/lib/report.js CHANGED
@@ -992,6 +992,7 @@ function buildRunSubject(artifact) {
992
992
  recovery_summary: recoverySummary,
993
993
  continuity,
994
994
  workflow_kit_artifacts: extractWorkflowKitArtifacts(artifact),
995
+ repo_decisions: artifact.summary?.repo_decisions || null,
995
996
  },
996
997
  artifacts: {
997
998
  history_entries: artifact.summary?.history_entries || 0,
@@ -1293,6 +1294,14 @@ export function formatGovernanceReportText(report) {
1293
1294
  }
1294
1295
  }
1295
1296
 
1297
+ if (run.repo_decisions?.active?.length > 0) {
1298
+ lines.push('', 'Repo Decisions:');
1299
+ lines.push(` Active: ${run.repo_decisions.active_count} Overridden: ${run.repo_decisions.overridden_count}`);
1300
+ for (const d of run.repo_decisions.active) {
1301
+ lines.push(` - ${d.id} (${d.category}): ${d.statement}`);
1302
+ }
1303
+ }
1304
+
1296
1305
  if (run.turns && run.turns.length > 0) {
1297
1306
  lines.push('', 'Turn Timeline:');
1298
1307
  for (let i = 0; i < run.turns.length; i++) {
@@ -1778,6 +1787,16 @@ export function formatGovernanceReportMarkdown(report) {
1778
1787
  }
1779
1788
  }
1780
1789
 
1790
+ if (run.repo_decisions?.active?.length > 0) {
1791
+ lines.push('', '## Repo Decisions', '');
1792
+ lines.push(`Active: ${run.repo_decisions.active_count} | Overridden: ${run.repo_decisions.overridden_count}`, '');
1793
+ lines.push('| ID | Category | Statement | Role | Run |', '|----|----------|-----------|------|-----|');
1794
+ for (const d of run.repo_decisions.active) {
1795
+ const stmt = (d.statement || '').replace(/\|/g, '\\|');
1796
+ lines.push(`| ${d.id} | ${d.category} | ${stmt} | ${d.role || '—'} | \`${(d.run_id || '').slice(0, 12)}\` |`);
1797
+ }
1798
+ }
1799
+
1781
1800
  if (run.turns && run.turns.length > 0) {
1782
1801
  lines.push('', '## Turn Timeline', '', '| # | Role | Phase | Summary | Files | Cost | Time |', '|---|------|-------|---------|-------|------|------|');
1783
1802
  for (let i = 0; i < run.turns.length; i++) {
@@ -2157,3 +2176,562 @@ export function formatGovernanceReportMarkdown(report) {
2157
2176
  }));
2158
2177
  return mdLines.join('\n');
2159
2178
  }
2179
+
2180
+ // --- HTML governance report formatter ---
2181
+
2182
+ function esc(str) {
2183
+ if (typeof str !== 'string') return '';
2184
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2185
+ }
2186
+
2187
+ function badge(status) {
2188
+ const colors = {
2189
+ pass: '#22c55e', running: '#3b82f6', completed: '#22c55e',
2190
+ failed: '#ef4444', error: '#ef4444', fail: '#ef4444',
2191
+ blocked: '#f59e0b', pending: '#a855f7', mixed: '#f59e0b',
2192
+ paused: '#6b7280', not_running: '#6b7280', stale: '#f59e0b',
2193
+ pid_only: '#f59e0b',
2194
+ };
2195
+ const color = colors[status] || '#6b7280';
2196
+ return `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.85em;font-weight:600;color:#fff;background:${color}">${esc(String(status))}</span>`;
2197
+ }
2198
+
2199
+ function htmlTable(headers, rows) {
2200
+ const lines = ['<table>', '<thead><tr>'];
2201
+ for (const h of headers) lines.push(`<th>${esc(h)}</th>`);
2202
+ lines.push('</tr></thead>', '<tbody>');
2203
+ for (const row of rows) {
2204
+ lines.push('<tr>');
2205
+ for (const cell of row) lines.push(`<td>${cell}</td>`);
2206
+ lines.push('</tr>');
2207
+ }
2208
+ lines.push('</tbody>', '</table>');
2209
+ return lines.join('');
2210
+ }
2211
+
2212
+ function htmlSection(title, content, level = 2) {
2213
+ const tag = `h${level}`;
2214
+ return `<${tag}>${esc(title)}</${tag}>\n${content}`;
2215
+ }
2216
+
2217
+ function htmlDl(pairs) {
2218
+ const lines = ['<dl>'];
2219
+ for (const [label, value] of pairs) {
2220
+ lines.push(`<dt>${esc(label)}</dt><dd>${value}</dd>`);
2221
+ }
2222
+ lines.push('</dl>');
2223
+ return lines.join('');
2224
+ }
2225
+
2226
+ const HTML_STYLES = `
2227
+ *{box-sizing:border-box;margin:0;padding:0}
2228
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.6;color:#1a1a2e;background:#f8fafc;padding:2rem;max-width:1100px;margin:0 auto}
2229
+ h1{font-size:1.6rem;margin-bottom:0.5rem;border-bottom:2px solid #e2e8f0;padding-bottom:0.5rem}
2230
+ h2{font-size:1.2rem;margin-top:2rem;margin-bottom:0.75rem;color:#334155}
2231
+ h3{font-size:1.05rem;margin-top:1.5rem;margin-bottom:0.5rem;color:#475569}
2232
+ h4{font-size:0.95rem;margin-top:1rem;margin-bottom:0.4rem;color:#64748b}
2233
+ dl{display:grid;grid-template-columns:max-content 1fr;gap:0.3rem 1rem;margin-bottom:1rem}
2234
+ dt{font-weight:600;color:#475569;white-space:nowrap}
2235
+ dd{color:#1e293b}
2236
+ table{width:100%;border-collapse:collapse;margin:0.75rem 0 1.5rem;font-size:0.9rem}
2237
+ th{background:#f1f5f9;font-weight:600;text-align:left;padding:0.5rem 0.75rem;border-bottom:2px solid #cbd5e1;color:#334155}
2238
+ td{padding:0.4rem 0.75rem;border-bottom:1px solid #e2e8f0}
2239
+ tr:hover td{background:#f8fafc}
2240
+ code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em;background:#f1f5f9;padding:1px 4px;border-radius:3px}
2241
+ .header{display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem}
2242
+ .header-brand{font-size:0.85rem;color:#64748b}
2243
+ .meta{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
2244
+ .section{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1rem}
2245
+ ul{margin:0.5rem 0;padding-left:1.5rem}
2246
+ li{margin-bottom:0.25rem}
2247
+ .warn{color:#d97706;font-weight:600}
2248
+ @media(prefers-color-scheme:dark){
2249
+ body{background:#0f172a;color:#e2e8f0}
2250
+ h1{border-bottom-color:#334155}
2251
+ h2,h3,h4{color:#94a3b8}
2252
+ dt{color:#94a3b8}dd{color:#e2e8f0}
2253
+ th{background:#1e293b;border-bottom-color:#475569;color:#cbd5e1}
2254
+ td{border-bottom-color:#334155}
2255
+ tr:hover td{background:#1e293b}
2256
+ code{background:#1e293b}
2257
+ .meta,.section{background:#1e293b;border-color:#334155}
2258
+ .header-brand{color:#94a3b8}
2259
+ }
2260
+ @media print{
2261
+ body{background:#fff;color:#000;padding:1rem}
2262
+ .meta,.section{border:1px solid #ccc}
2263
+ th{background:#eee}
2264
+ }
2265
+ `;
2266
+
2267
+ function wrapHtml(title, bodyContent) {
2268
+ return `<!DOCTYPE html>
2269
+ <html lang="en">
2270
+ <head>
2271
+ <meta charset="utf-8">
2272
+ <meta name="viewport" content="width=device-width,initial-scale=1">
2273
+ <title>${esc(title)}</title>
2274
+ <style>${HTML_STYLES}</style>
2275
+ </head>
2276
+ <body>
2277
+ <div class="header">
2278
+ <h1>AgentXchain Governance Report</h1>
2279
+ <span class="header-brand">agentxchain.dev</span>
2280
+ </div>
2281
+ ${bodyContent}
2282
+ </body>
2283
+ </html>`;
2284
+ }
2285
+
2286
+ function renderHtmlGovEventDetail(evt) {
2287
+ const parts = [];
2288
+ switch (evt.type) {
2289
+ case 'policy_escalation':
2290
+ for (const v of evt.violations || []) {
2291
+ parts.push(`<li>Violation: <code>${esc(v.policy_id || '?')}</code> / <code>${esc(v.rule || '?')}</code> — ${esc(v.message || 'n/a')}</li>`);
2292
+ }
2293
+ break;
2294
+ case 'conflict_detected':
2295
+ if (evt.conflicting_files?.length > 0) parts.push(`<li>Files: ${evt.conflicting_files.map((f) => `<code>${esc(f)}</code>`).join(', ')}</li>`);
2296
+ if (evt.overlap_ratio != null) parts.push(`<li>Overlap: ${(evt.overlap_ratio * 100).toFixed(0)}%</li>`);
2297
+ break;
2298
+ case 'operator_escalated':
2299
+ if (evt.reason) parts.push(`<li>Reason: ${esc(evt.reason)}</li>`);
2300
+ if (evt.blocked_on) parts.push(`<li>Blocked on: <code>${esc(evt.blocked_on)}</code></li>`);
2301
+ break;
2302
+ case 'escalation_resolved':
2303
+ if (evt.resolved_via) parts.push(`<li>Resolved via: <code>${esc(evt.resolved_via)}</code></li>`);
2304
+ break;
2305
+ }
2306
+ return parts.length ? `<ul>${parts.join('')}</ul>` : '';
2307
+ }
2308
+
2309
+ function renderRunHtml(report) {
2310
+ const { project, run, artifacts } = report.subject;
2311
+ const sections = [];
2312
+
2313
+ // Meta section
2314
+ const metaPairs = [
2315
+ ['Input', `<code>${esc(report.input)}</code>`],
2316
+ ['Export kind', `<code>${esc(report.export_kind)}</code>`],
2317
+ ['Verification', badge('pass')],
2318
+ ['Project', `${esc(project.name || 'unknown')} (<code>${esc(project.id || 'unknown')}</code>)`],
2319
+ ];
2320
+ if (project.goal) metaPairs.push(['Goal', esc(project.goal)]);
2321
+ metaPairs.push(
2322
+ ['Template', `<code>${esc(project.template)}</code>`],
2323
+ ['Protocol', `<code>${esc(project.protocol_mode || 'unknown')}</code> (schema <code>${esc(project.schema_version || 'unknown')}</code>)`],
2324
+ ['Run ID', `<code>${esc(run.run_id || 'none')}</code>`],
2325
+ ['Status', badge(run.status || 'unknown')],
2326
+ ['Phase', `<code>${esc(run.phase || 'unknown')}</code>`],
2327
+ ['Blocked on', `<code>${esc(summarizeBlockedState(run))}</code>`],
2328
+ ['Active turns', `${run.active_turn_count}${run.active_turn_ids.length ? ` (${run.active_turn_ids.map((id) => `<code>${esc(id)}</code>`).join(', ')})` : ''}`],
2329
+ ['Retained turns', `${run.retained_turn_count}${run.retained_turn_ids.length ? ` (${run.retained_turn_ids.map((id) => `<code>${esc(id)}</code>`).join(', ')})` : ''}`],
2330
+ ['Active roles', run.active_roles.length ? run.active_roles.map((r) => `<code>${esc(r)}</code>`).join(', ') : '<code>none</code>'],
2331
+ );
2332
+
2333
+ if (run.budget_status) {
2334
+ const warnTag = run.budget_status.warn_mode ? ' <span class="warn">[OVER BUDGET]</span>' : '';
2335
+ metaPairs.push(['Budget', `spent ${formatUsd(run.budget_status.spent_usd)}, remaining ${formatUsd(run.budget_status.remaining_usd)}${warnTag}`]);
2336
+ }
2337
+ if (run.created_at) metaPairs.push(['Started', `<code>${esc(run.created_at)}</code>`]);
2338
+ if (run.completed_at) metaPairs.push(['Completed', `<code>${esc(run.completed_at)}</code>`]);
2339
+ if (run.duration_seconds != null) metaPairs.push(['Duration', `<code>${run.duration_seconds}s</code>`]);
2340
+ if (summarizeRunProvenance(run.provenance)) metaPairs.push(['Provenance', `<code>${esc(summarizeRunProvenance(run.provenance))}</code>`]);
2341
+ if (run.inherited_context?.parent_run_id) metaPairs.push(['Inherited from', `<code>${esc(run.inherited_context.parent_run_id)}</code> (${esc(run.inherited_context.parent_status || 'unknown')})`]);
2342
+ if (run.dashboard_session) metaPairs.push(['Dashboard', `<code>${esc(formatDashboardSessionLine(run.dashboard_session))}</code>`]);
2343
+
2344
+ metaPairs.push(
2345
+ ['History entries', String(artifacts.history_entries)],
2346
+ ['Decision entries', String(artifacts.decision_entries)],
2347
+ ['Hook audit entries', String(artifacts.hook_audit_entries)],
2348
+ ['Notification entries', String(artifacts.notification_audit_entries)],
2349
+ ['Dispatch files', String(artifacts.dispatch_artifact_files)],
2350
+ ['Staging files', String(artifacts.staging_artifact_files)],
2351
+ ['Intake artifacts', artifacts.intake_present ? 'yes' : 'no'],
2352
+ ['Coordinator artifacts', artifacts.coordinator_present ? 'yes' : 'no'],
2353
+ );
2354
+
2355
+ sections.push(`<div class="meta">${htmlDl(metaPairs)}</div>`);
2356
+
2357
+ // Cost Summary
2358
+ if (run.cost_summary) {
2359
+ const cs = run.cost_summary;
2360
+ let costHtml = `<p><strong>Total:</strong> ${formatUsd(cs.total_usd)} across ${cs.turn_count} turn${cs.turn_count !== 1 ? 's' : ''} (${cs.costed_turn_count} with cost data)</p>`;
2361
+ if (cs.total_input_tokens != null || cs.total_output_tokens != null) {
2362
+ costHtml += `<p><strong>Tokens:</strong> ${formatTokenCount(cs.total_input_tokens)} input / ${formatTokenCount(cs.total_output_tokens)} output</p>`;
2363
+ }
2364
+ if (cs.by_role.length > 0) {
2365
+ costHtml += htmlTable(
2366
+ ['Role', 'Cost', 'Turns', 'Input Tokens', 'Output Tokens'],
2367
+ cs.by_role.map((r) => [esc(r.role), formatUsd(r.usd), String(r.turns), formatTokenCount(r.input_tokens), formatTokenCount(r.output_tokens)]),
2368
+ );
2369
+ }
2370
+ if (cs.by_phase.length > 0) {
2371
+ costHtml += htmlTable(
2372
+ ['Phase', 'Cost', 'Turns'],
2373
+ cs.by_phase.map((p) => [esc(p.phase), formatUsd(p.usd), String(p.turns)]),
2374
+ );
2375
+ }
2376
+ sections.push(`<div class="section">${htmlSection('Cost Summary', costHtml)}</div>`);
2377
+ }
2378
+
2379
+ // Delegation Summary
2380
+ if (run.delegation_summary?.delegation_chains?.length > 0) {
2381
+ const ds = run.delegation_summary;
2382
+ let delHtml = `<p>Total delegations issued: ${ds.total_delegations_issued}</p>`;
2383
+ const rows = [];
2384
+ for (const chain of ds.delegation_chains) {
2385
+ for (let i = 0; i < chain.delegations.length; i++) {
2386
+ const d = chain.delegations[i];
2387
+ rows.push([
2388
+ i === 0 ? esc(chain.parent_role) : '',
2389
+ i === 0 ? `<code>${esc(chain.parent_turn_id)}</code>` : '',
2390
+ i === 0 ? badge(chain.outcome) : '',
2391
+ i === 0 ? `<code>${esc(chain.review_turn_id || 'pending')}</code>` : '',
2392
+ `<code>${esc(d.delegation_id)}</code> &rarr; <code>${esc(d.to_role)}</code>`,
2393
+ `<code>${esc(d.child_turn_id || 'pending')}</code>`,
2394
+ badge(d.status),
2395
+ esc(d.charter),
2396
+ ]);
2397
+ }
2398
+ }
2399
+ delHtml += htmlTable(['Parent Role', 'Parent Turn', 'Outcome', 'Review Turn', 'Delegation', 'Child Turn', 'Status', 'Charter'], rows);
2400
+ sections.push(`<div class="section">${htmlSection('Delegation Summary', delHtml)}</div>`);
2401
+ }
2402
+
2403
+ // Repo Decisions
2404
+ if (run.repo_decisions?.active?.length > 0) {
2405
+ let rdHtml = `<p>Active: ${run.repo_decisions.active_count} | Overridden: ${run.repo_decisions.overridden_count}</p>`;
2406
+ rdHtml += htmlTable(
2407
+ ['ID', 'Category', 'Statement', 'Role', 'Run'],
2408
+ run.repo_decisions.active.map((d) => [esc(d.id), esc(d.category), esc(d.statement || ''), esc(d.role || '\u2014'), `<code>${esc((d.run_id || '').slice(0, 12))}</code>`]),
2409
+ );
2410
+ sections.push(`<div class="section">${htmlSection('Repo Decisions', rdHtml)}</div>`);
2411
+ }
2412
+
2413
+ // Turn Timeline
2414
+ if (run.turns && run.turns.length > 0) {
2415
+ const turnRows = run.turns.map((t, i) => {
2416
+ const cost = t.cost_usd != null ? formatUsd(t.cost_usd) : 'n/a';
2417
+ const phase = t.phase_transition ? `${esc(t.phase || '?')} &rarr; ${esc(t.phase_transition)}` : esc(t.phase || '?');
2418
+ const sibNote = Array.isArray(t.sibling_attributed_files) ? ` (${t.sibling_attributed_files.length} sibling)` : '';
2419
+ return [String(i + 1), esc(t.role), phase, esc(t.summary || '(no summary)'), `${t.files_changed_count}${sibNote}`, cost, esc(formatTurnTimelineTime(t))];
2420
+ });
2421
+ sections.push(`<div class="section">${htmlSection('Turn Timeline', htmlTable(['#', 'Role', 'Phase', 'Summary', 'Files', 'Cost', 'Time'], turnRows))}</div>`);
2422
+ }
2423
+
2424
+ // Decisions
2425
+ if (run.decisions && run.decisions.length > 0) {
2426
+ const decList = run.decisions.map((d) => `<li><strong>${esc(d.id)}</strong> (${esc(d.role || '?')}, ${esc(d.phase || '?')} phase): ${esc(d.statement)}</li>`).join('');
2427
+ sections.push(`<div class="section">${htmlSection('Decisions', `<ul>${decList}</ul>`)}</div>`);
2428
+ }
2429
+
2430
+ // Gate Outcomes
2431
+ if (run.gate_summary && run.gate_summary.length > 0) {
2432
+ const gateList = run.gate_summary.map((g) => `<li><code>${esc(g.gate_id)}</code>: ${badge(g.status)}</li>`).join('');
2433
+ sections.push(`<div class="section">${htmlSection('Gate Outcomes', `<ul>${gateList}</ul>`)}</div>`);
2434
+ }
2435
+
2436
+ // Gate Failures
2437
+ if (run.gate_failures && run.gate_failures.length > 0) {
2438
+ let gfHtml = '<ul>';
2439
+ for (const failure of run.gate_failures) {
2440
+ const request = failure.gate_type === 'run_completion' ? 'run completion' : `${esc(failure.from_phase || failure.phase || '?')} &rarr; ${esc(failure.to_phase || '?')}`;
2441
+ gfHtml += `<li><code>${esc(failure.gate_id || 'unknown')}</code> (${esc(failure.gate_type || 'unknown')}) at <code>${esc(failure.failed_at || 'n/a')}</code>: ${request}`;
2442
+ if (failure.reasons?.length) {
2443
+ gfHtml += '<ul>' + failure.reasons.map((r) => `<li>${esc(r)}</li>`).join('') + '</ul>';
2444
+ }
2445
+ gfHtml += '</li>';
2446
+ }
2447
+ gfHtml += '</ul>';
2448
+ sections.push(`<div class="section">${htmlSection('Gate Failures', gfHtml)}</div>`);
2449
+ }
2450
+
2451
+ // Approval Policy
2452
+ if (run.approval_policy_events && run.approval_policy_events.length > 0) {
2453
+ let apHtml = '<ul>';
2454
+ for (const evt of run.approval_policy_events) {
2455
+ const transition = evt.gate_type === 'run_completion' ? 'run completion' : `${esc(evt.from_phase || '?')} &rarr; ${esc(evt.to_phase || '?')}`;
2456
+ apHtml += `<li><strong>${esc(evt.action || 'unknown')}</strong> (${esc(evt.gate_type || 'unknown')}) ${transition} at <code>${esc(evt.timestamp || 'n/a')}</code>`;
2457
+ if (evt.reason) apHtml += `<br>${esc(evt.reason)}`;
2458
+ apHtml += '</li>';
2459
+ }
2460
+ apHtml += '</ul>';
2461
+ sections.push(`<div class="section">${htmlSection('Approval Policy', apHtml)}</div>`);
2462
+ }
2463
+
2464
+ // Governance Events
2465
+ if (run.governance_events && run.governance_events.length > 0) {
2466
+ let geHtml = '<ul>';
2467
+ for (const evt of run.governance_events) {
2468
+ geHtml += `<li><strong>${esc(evt.type)}</strong> (<code>${esc(evt.role || '?')}</code>, <code>${esc(evt.phase || '?')}</code> phase) at <code>${esc(evt.timestamp || 'n/a')}</code>${renderHtmlGovEventDetail(evt)}</li>`;
2469
+ }
2470
+ geHtml += '</ul>';
2471
+ sections.push(`<div class="section">${htmlSection('Governance Events', geHtml)}</div>`);
2472
+ }
2473
+
2474
+ // Timeout Events
2475
+ if (run.timeout_events && run.timeout_events.length > 0) {
2476
+ let teHtml = '<ul>';
2477
+ for (const evt of run.timeout_events) {
2478
+ const label = evt.type === 'timeout_warning' ? 'Warning' : evt.type === 'timeout_skip' ? 'Skip' : evt.type === 'timeout_skip_failed' ? 'Skip Failed' : 'Escalation';
2479
+ const elapsed = evt.elapsed_minutes != null ? `${evt.elapsed_minutes}m` : '?';
2480
+ const limit = evt.limit_minutes != null ? `${evt.limit_minutes}m` : '?';
2481
+ const exceeded = evt.exceeded_by_minutes != null ? ` (+${evt.exceeded_by_minutes}m)` : '';
2482
+ teHtml += `<li><strong>${label}</strong> (<code>${esc(evt.scope || '?')}</code> scope) \u2014 ${elapsed}/${limit}${exceeded}, action: <code>${esc(evt.action || 'n/a')}</code>, phase: <code>${esc(evt.phase || 'n/a')}</code> at <code>${esc(evt.timestamp || 'n/a')}</code></li>`;
2483
+ }
2484
+ teHtml += '</ul>';
2485
+ sections.push(`<div class="section">${htmlSection('Timeout Events', teHtml)}</div>`);
2486
+ }
2487
+
2488
+ // Intake Linkage
2489
+ if (run.intake_links && run.intake_links.length > 0) {
2490
+ const ilRows = run.intake_links.map((intake) => [
2491
+ `<code>${esc(intake.intent_id)}</code>`,
2492
+ badge(intake.status || 'unknown'),
2493
+ `<code>${esc(intake.event_id || 'n/a')}</code>`,
2494
+ `<code>${esc(intake.target_turn || 'n/a')}</code>`,
2495
+ `<code>${esc(intake.started_at || 'n/a')}</code>`,
2496
+ ]);
2497
+ sections.push(`<div class="section">${htmlSection('Intake Linkage', htmlTable(['Intent', 'Status', 'Event', 'Target Turn', 'Started'], ilRows))}</div>`);
2498
+ }
2499
+
2500
+ // Hook Activity
2501
+ if (run.hook_summary) {
2502
+ const eventList = Object.entries(run.hook_summary.events).sort(([a], [b]) => a.localeCompare(b, 'en')).map(([e, c]) => `${esc(e)}(${c})`).join(', ');
2503
+ const hookHtml = htmlDl([
2504
+ ['Total executions', String(run.hook_summary.total)],
2505
+ ['Blocked', String(run.hook_summary.blocked)],
2506
+ ...(eventList ? [['Events', eventList]] : []),
2507
+ ]);
2508
+ sections.push(`<div class="section">${htmlSection('Hook Activity', hookHtml)}</div>`);
2509
+ }
2510
+
2511
+ // Recovery
2512
+ if (run.recovery_summary) {
2513
+ const rs = run.recovery_summary;
2514
+ sections.push(`<div class="section">${htmlSection('Recovery', htmlDl([
2515
+ ['Category', `<code>${esc(rs.category || 'unknown')}</code>`],
2516
+ ['Typed reason', `<code>${esc(rs.typed_reason || 'unknown')}</code>`],
2517
+ ['Owner', `<code>${esc(rs.owner || 'unknown')}</code>`],
2518
+ ['Action', `<code>${esc(rs.recovery_action || 'n/a')}</code>`],
2519
+ ['Detail', esc(rs.detail || 'n/a')],
2520
+ ['Turn retained', rs.turn_retained == null ? 'n/a' : (rs.turn_retained ? 'yes' : 'no')],
2521
+ ]))}</div>`);
2522
+ }
2523
+
2524
+ // Continuity
2525
+ if (run.continuity) {
2526
+ const pairs = [
2527
+ ['Session', `<code>${esc(run.continuity.session_id || 'unknown')}</code>`],
2528
+ ['Checkpoint', `<code>${esc(run.continuity.checkpoint_reason || 'unknown')}</code> at <code>${esc(run.continuity.last_checkpoint_at || 'n/a')}</code>`],
2529
+ ['Last turn', `<code>${esc(run.continuity.last_turn_id || 'none')}</code>`],
2530
+ ['Last role', `<code>${esc(run.continuity.last_role || 'unknown')}</code>`],
2531
+ ['Last phase', `<code>${esc(run.continuity.last_phase || 'unknown')}</code>`],
2532
+ ];
2533
+ if (run.continuity.stale_checkpoint) {
2534
+ pairs.push(['Warning', `<span class="warn">checkpoint tracks run <code>${esc(run.continuity.run_id)}</code>, but export tracks <code>${esc(run.run_id)}</code></span>`]);
2535
+ }
2536
+ sections.push(`<div class="section">${htmlSection('Continuity', htmlDl(pairs))}</div>`);
2537
+ }
2538
+
2539
+ // Workflow Artifacts
2540
+ if (Array.isArray(run.workflow_kit_artifacts) && run.workflow_kit_artifacts.length > 0) {
2541
+ let waHtml = `<p>Phase: <code>${esc(run.phase || 'unknown')}</code></p>`;
2542
+ waHtml += htmlTable(
2543
+ ['Artifact', 'Required', 'Semantics', 'Owner', 'Resolution', 'Status'],
2544
+ run.workflow_kit_artifacts.map((art) => [
2545
+ `<code>${esc(art.path)}</code>`,
2546
+ art.required ? 'yes' : 'no',
2547
+ art.semantics ? `<code>${esc(art.semantics)}</code>` : 'none',
2548
+ art.owned_by ? `<code>${esc(art.owned_by)}</code>` : 'none',
2549
+ esc(art.owner_resolution),
2550
+ art.exists ? 'exists' : '<strong class="warn">missing</strong>',
2551
+ ]),
2552
+ );
2553
+ sections.push(`<div class="section">${htmlSection('Workflow Artifacts', waHtml)}</div>`);
2554
+ }
2555
+
2556
+ return wrapHtml('AgentXchain Governance Report', sections.join('\n'));
2557
+ }
2558
+
2559
+ function renderCoordinatorHtml(report) {
2560
+ const { coordinator, run, artifacts, repos, coordinator_timeline, barrier_summary, barrier_ledger_timeline, decision_digest, approval_policy_events, governance_events, timeout_events, recovery_report: coordRecoveryReport } = report.subject;
2561
+ const sections = [];
2562
+
2563
+ const metaPairs = [
2564
+ ['Input', `<code>${esc(report.input)}</code>`],
2565
+ ['Export kind', `<code>${esc(report.export_kind)}</code>`],
2566
+ ['Verification', badge('pass')],
2567
+ ['Workspace', `${esc(coordinator.project_name || 'unknown')} (<code>${esc(coordinator.project_id || 'unknown')}</code>)`],
2568
+ ['Schema', `<code>${esc(coordinator.schema_version || 'unknown')}</code>`],
2569
+ ['Super run', `<code>${esc(run.super_run_id || 'none')}</code>`],
2570
+ ['Status', badge(run.status || 'unknown')],
2571
+ ['Phase', `<code>${esc(run.phase || 'unknown')}</code>`],
2572
+ ['Blocked reason', `<code>${esc(run.blocked_reason || 'none')}</code>`],
2573
+ ];
2574
+
2575
+ if (run.run_id_mismatches?.length > 0) {
2576
+ metaPairs.push(['Run ID mismatches', `<strong class="warn">${run.run_id_mismatches.length}</strong>`]);
2577
+ }
2578
+
2579
+ metaPairs.push(
2580
+ ['Started', `<code>${esc(run.created_at || 'n/a')}</code>`],
2581
+ ['Repos', `${coordinator.repo_count} total, ${run.repo_ok_count} exported, ${run.repo_error_count} failed`],
2582
+ ['Workstreams', String(coordinator.workstream_count)],
2583
+ ['Barriers', String(run.barrier_count)],
2584
+ ['Repo statuses', formatStatusCounts(run.repo_status_counts)],
2585
+ ['History entries', String(artifacts.history_entries)],
2586
+ ['Decision entries', String(artifacts.decision_entries)],
2587
+ );
2588
+ if (run.completed_at) metaPairs.push(['Completed', `<code>${esc(run.completed_at)}</code>`]);
2589
+ if (run.duration_seconds != null) metaPairs.push(['Duration', `<code>${run.duration_seconds}s</code>`]);
2590
+ if (run.pending_gate) metaPairs.push(['Pending gate', `<code>${esc(run.pending_gate.gate)}</code> (<code>${esc(run.pending_gate.gate_type)}</code>)`]);
2591
+
2592
+ sections.push(`<div class="meta">${htmlDl(metaPairs)}</div>`);
2593
+
2594
+ // Next Actions
2595
+ if (run.next_actions?.length > 0) {
2596
+ const naHtml = '<ol>' + run.next_actions.map((a) => `<li><code>${esc(a.command)}</code>: ${esc(a.reason)}</li>`).join('') + '</ol>';
2597
+ sections.push(`<div class="section">${htmlSection('Next Actions', naHtml)}</div>`);
2598
+ }
2599
+
2600
+ // Coordinator Timeline
2601
+ if (coordinator_timeline?.length > 0) {
2602
+ const tlRows = coordinator_timeline.map((ev, i) => [String(i + 1), `<code>${esc(ev.type)}</code>`, `<code>${esc(ev.timestamp || 'n/a')}</code>`, esc(ev.summary)]);
2603
+ sections.push(`<div class="section">${htmlSection('Coordinator Timeline', htmlTable(['#', 'Type', 'Time', 'Summary'], tlRows))}</div>`);
2604
+ }
2605
+
2606
+ // Barrier Summary
2607
+ if (barrier_summary?.length > 0) {
2608
+ const bRows = barrier_summary.map((b) => [
2609
+ `<code>${esc(b.barrier_id)}</code>`,
2610
+ `<code>${esc(b.workstream_id || 'unknown')}</code>`,
2611
+ `<code>${esc(b.type)}</code>`,
2612
+ badge(b.status),
2613
+ `${b.satisfied_repos.length}/${b.required_repos.length} repos`,
2614
+ ]);
2615
+ sections.push(`<div class="section">${htmlSection('Barrier Summary', htmlTable(['Barrier', 'Workstream', 'Type', 'Status', 'Satisfied'], bRows))}</div>`);
2616
+ }
2617
+
2618
+ // Barrier Transitions
2619
+ if (barrier_ledger_timeline?.length > 0) {
2620
+ const btRows = barrier_ledger_timeline.map((t, i) => [String(i + 1), `<code>${esc(t.timestamp || 'n/a')}</code>`, `<code>${esc(t.barrier_id)}</code>`, `<code>${esc(t.previous_status)}</code>`, `<code>${esc(t.new_status)}</code>`, esc(t.summary)]);
2621
+ sections.push(`<div class="section">${htmlSection('Barrier Transitions', htmlTable(['#', 'Time', 'Barrier', 'From', 'To', 'Summary'], btRows))}</div>`);
2622
+ }
2623
+
2624
+ // Coordinator Decisions
2625
+ if (decision_digest?.length > 0) {
2626
+ const ddList = decision_digest.map((d) => `<li><strong>${esc(d.id)}</strong> (${esc(d.role || '?')}, ${esc(d.phase || '?')} phase): ${esc(d.statement)}</li>`).join('');
2627
+ sections.push(`<div class="section">${htmlSection('Coordinator Decisions', `<ul>${ddList}</ul>`)}</div>`);
2628
+ }
2629
+
2630
+ // Approval Policy
2631
+ if (approval_policy_events?.length > 0) {
2632
+ let apHtml = '<ul>';
2633
+ for (const evt of approval_policy_events) {
2634
+ const transition = evt.gate_type === 'run_completion' ? 'run completion' : `${esc(evt.from_phase || '?')} &rarr; ${esc(evt.to_phase || '?')}`;
2635
+ apHtml += `<li><strong>${esc(evt.action || 'unknown')}</strong> (${esc(evt.gate_type || 'unknown')}) ${transition} at <code>${esc(evt.timestamp || 'n/a')}</code>`;
2636
+ if (evt.reason) apHtml += `<br>${esc(evt.reason)}`;
2637
+ apHtml += '</li>';
2638
+ }
2639
+ apHtml += '</ul>';
2640
+ sections.push(`<div class="section">${htmlSection('Approval Policy', apHtml)}</div>`);
2641
+ }
2642
+
2643
+ // Governance Events
2644
+ if (governance_events?.length > 0) {
2645
+ let geHtml = '<ul>';
2646
+ for (const evt of governance_events) {
2647
+ geHtml += `<li><strong>${esc(evt.type)}</strong> (<code>${esc(evt.role || '?')}</code>, <code>${esc(evt.phase || '?')}</code> phase) at <code>${esc(evt.timestamp || 'n/a')}</code>${renderHtmlGovEventDetail(evt)}</li>`;
2648
+ }
2649
+ geHtml += '</ul>';
2650
+ sections.push(`<div class="section">${htmlSection('Governance Events', geHtml)}</div>`);
2651
+ }
2652
+
2653
+ // Timeout Events
2654
+ if (timeout_events?.length > 0) {
2655
+ let teHtml = '<ul>';
2656
+ for (const evt of timeout_events) {
2657
+ const label = evt.type === 'timeout_warning' ? 'Warning' : evt.type === 'timeout_skip' ? 'Skip' : 'Escalation';
2658
+ const elapsed = evt.elapsed_minutes != null ? `${evt.elapsed_minutes}m` : '?';
2659
+ const limit = evt.limit_minutes != null ? `${evt.limit_minutes}m` : '?';
2660
+ teHtml += `<li><strong>${label}</strong> (<code>${esc(evt.scope || '?')}</code>) \u2014 ${elapsed}/${limit}, action: <code>${esc(evt.action || 'n/a')}</code> at <code>${esc(evt.timestamp || 'n/a')}</code></li>`;
2661
+ }
2662
+ teHtml += '</ul>';
2663
+ sections.push(`<div class="section">${htmlSection('Timeout Events', teHtml)}</div>`);
2664
+ }
2665
+
2666
+ // Recovery Report
2667
+ if (coordRecoveryReport) {
2668
+ sections.push(`<div class="section">${htmlSection('Recovery Report', htmlDl([
2669
+ ['Trigger', esc(coordRecoveryReport.trigger || 'n/a')],
2670
+ ['Impact', esc(coordRecoveryReport.impact || 'n/a')],
2671
+ ['Mitigation', esc(coordRecoveryReport.mitigation || 'n/a')],
2672
+ ['Owner', esc(coordRecoveryReport.owner || 'n/a')],
2673
+ ['Exit Condition', esc(coordRecoveryReport.exit_condition || 'n/a')],
2674
+ ]))}</div>`);
2675
+ }
2676
+
2677
+ // Repo Details
2678
+ if (repos?.length > 0) {
2679
+ let repoHtml = '';
2680
+ for (const repo of repos) {
2681
+ if (!repo.ok) {
2682
+ repoHtml += `<h3>${esc(repo.repo_id)}</h3><p>Failed export: ${esc(repo.error || 'unknown error')}, path <code>${esc(repo.path || 'unknown')}</code></p>`;
2683
+ continue;
2684
+ }
2685
+ const repoPairs = [
2686
+ ['Status', badge(repo.status || 'unknown')],
2687
+ ['Run', `<code>${esc(repo.run_id || 'none')}</code>`],
2688
+ ['Phase', `<code>${esc(repo.phase || 'unknown')}</code>`],
2689
+ ['Path', `<code>${esc(repo.path || 'unknown')}</code>`],
2690
+ ];
2691
+ if (repo.blocked_on) repoPairs.push(['Blocked on', `<code>${esc(summarizeBlockedOn(repo.blocked_on))}</code>`]);
2692
+ repoHtml += `<h3>${esc(repo.repo_id)}</h3>${htmlDl(repoPairs)}`;
2693
+
2694
+ if (repo.turns?.length > 0) {
2695
+ const turnRows = repo.turns.map((t, i) => {
2696
+ const cost = t.cost_usd != null ? formatUsd(t.cost_usd) : 'n/a';
2697
+ const phase = t.phase_transition ? `${esc(t.phase || '?')} &rarr; ${esc(t.phase_transition)}` : esc(t.phase || '?');
2698
+ return [String(i + 1), esc(t.role), phase, esc(t.summary || '(no summary)'), String(t.files_changed_count), cost, esc(formatTurnTimelineTime(t))];
2699
+ });
2700
+ repoHtml += htmlSection('Turn Timeline', htmlTable(['#', 'Role', 'Phase', 'Summary', 'Files', 'Cost', 'Time'], turnRows), 4);
2701
+ }
2702
+ if (repo.decisions?.length > 0) {
2703
+ repoHtml += htmlSection('Decisions', '<ul>' + repo.decisions.map((d) => `<li><strong>${esc(d.id)}</strong> (${esc(d.role || '?')}, ${esc(d.phase || '?')} phase): ${esc(d.statement)}</li>`).join('') + '</ul>', 4);
2704
+ }
2705
+ if (repo.gate_summary?.length > 0) {
2706
+ repoHtml += htmlSection('Gate Outcomes', '<ul>' + repo.gate_summary.map((g) => `<li><code>${esc(g.gate_id)}</code>: ${badge(g.status)}</li>`).join('') + '</ul>', 4);
2707
+ }
2708
+ }
2709
+ sections.push(`<div class="section">${htmlSection('Repo Details', repoHtml)}</div>`);
2710
+ }
2711
+
2712
+ return wrapHtml('AgentXchain Governance Report — Coordinator', sections.join('\n'));
2713
+ }
2714
+
2715
+ export function formatGovernanceReportHtml(report) {
2716
+ if (report.overall === 'error') {
2717
+ return wrapHtml('AgentXchain Governance Report — Error', `
2718
+ <div class="meta">
2719
+ ${htmlDl([['Input', `<code>${esc(report.input)}</code>`], ['Status', badge('error')], ['Message', esc(report.message)]])}
2720
+ </div>`);
2721
+ }
2722
+
2723
+ if (report.overall === 'fail') {
2724
+ const errorList = (report.verification?.errors || []).map((e) => `<li>${esc(e)}</li>`).join('');
2725
+ return wrapHtml('AgentXchain Governance Report — Fail', `
2726
+ <div class="meta">
2727
+ ${htmlDl([['Input', `<code>${esc(report.input)}</code>`], ['Verification', badge('fail')], ['Message', esc(report.message)]])}
2728
+ </div>
2729
+ ${errorList ? `<div class="section"><h2>Verification Errors</h2><ul>${errorList}</ul></div>` : ''}`);
2730
+ }
2731
+
2732
+ if (report.subject?.kind === 'governed_run') {
2733
+ return renderRunHtml(report);
2734
+ }
2735
+
2736
+ return renderCoordinatorHtml(report);
2737
+ }
@@ -76,6 +76,16 @@
76
76
  "rationale": {
77
77
  "type": "string",
78
78
  "minLength": 1
79
+ },
80
+ "durability": {
81
+ "type": "string",
82
+ "enum": ["run", "repo"],
83
+ "description": "Decision persistence scope. 'run' (default) dies with the run; 'repo' persists across runs as a binding constraint."
84
+ },
85
+ "overrides": {
86
+ "type": "string",
87
+ "pattern": "^DEC-\\d+$",
88
+ "description": "ID of an active repo-durable decision this decision supersedes."
79
89
  }
80
90
  }
81
91
  }
@@ -325,6 +325,8 @@ function checkPlaceholder(errors, fieldPath, value) {
325
325
  }
326
326
  }
327
327
 
328
+ const VALID_DURABILITIES = ['run', 'repo'];
329
+
328
330
  function validateDecision(dec, index) {
329
331
  const errors = [];
330
332
  const prefix = `decisions[${index}]`;
@@ -344,6 +346,17 @@ function validateDecision(dec, index) {
344
346
  if (typeof dec.rationale !== 'string' || !dec.rationale.trim()) {
345
347
  errors.push(`${prefix}.rationale must be a non-empty string.`);
346
348
  }
349
+ if (dec.durability !== undefined && !VALID_DURABILITIES.includes(dec.durability)) {
350
+ errors.push(`${prefix}.durability must be one of: ${VALID_DURABILITIES.join(', ')}.`);
351
+ }
352
+ if (dec.overrides !== undefined) {
353
+ if (typeof dec.overrides !== 'string' || !/^DEC-\d+$/.test(dec.overrides)) {
354
+ errors.push(`${prefix}.overrides must match pattern DEC-NNN.`);
355
+ }
356
+ if (dec.overrides === dec.id) {
357
+ errors.push(`${prefix}.overrides cannot reference itself.`);
358
+ }
359
+ }
347
360
  return errors;
348
361
  }
349
362