agentxchain 2.90.0 → 2.92.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.
@@ -199,7 +199,7 @@ program
199
199
 
200
200
  program
201
201
  .command('stop')
202
- .description('Stop watch daemon and Claude Code sessions; close Cursor/VS Code chats manually')
202
+ .description('Stop dashboard/watch daemons and Claude Code sessions; close Cursor/VS Code chats manually')
203
203
  .action(stopCommand);
204
204
 
205
205
  program
@@ -477,6 +477,7 @@ program
477
477
  .command('dashboard')
478
478
  .description('Open the read-only governance dashboard in your browser')
479
479
  .option('--port <port>', 'Server port', '3847')
480
+ .option('--daemon', 'Run the dashboard in background mode')
480
481
  .option('--no-open', 'Do not auto-open the browser')
481
482
  .action(dashboardCommand);
482
483
 
@@ -207,6 +207,23 @@ export function render({
207
207
  if (Array.isArray(barrier.satisfied_repos) && barrier.satisfied_repos.length > 0) {
208
208
  html += `<div class="turn-detail"><span class="detail-label">Satisfied Repos:</span> ${esc(barrier.satisfied_repos.join(', '))}</div>`;
209
209
  }
210
+ const decisionIds = barrier.required_decision_ids_by_repo || barrier.alignment_decision_ids || null;
211
+ if (decisionIds && typeof decisionIds === 'object' && !Array.isArray(decisionIds)) {
212
+ const satisfiedSet = new Set(Array.isArray(barrier.satisfied_repos) ? barrier.satisfied_repos : []);
213
+ html += `<div class="turn-detail"><span class="detail-label">Decision Requirements:</span></div>`;
214
+ html += `<div class="decision-req-list" style="margin-left:1.2em;margin-top:0.3em">`;
215
+ for (const [repo, ids] of Object.entries(decisionIds)) {
216
+ if (!Array.isArray(ids) || ids.length === 0) continue;
217
+ const repoSatisfied = satisfiedSet.has(repo);
218
+ const idBadges = ids.map((id) =>
219
+ repoSatisfied
220
+ ? badge(`${id} ✓`, 'var(--green)')
221
+ : badge(id, 'var(--text-dim)')
222
+ ).join(' ');
223
+ html += `<div style="margin-bottom:0.2em"><span class="mono" style="margin-right:0.5em">${esc(repo)}:</span>${idBadges}</div>`;
224
+ }
225
+ html += `</div>`;
226
+ }
210
227
  html += `</div>`;
211
228
  }
212
229
  html += `</div>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.90.0",
3
+ "version": "2.92.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,13 +5,17 @@
5
5
  * The dashboard remains mostly observational, but can approve pending gates.
6
6
  */
7
7
 
8
- import { existsSync } from 'fs';
8
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
9
+ import { spawn } from 'child_process';
9
10
  import { join, dirname } from 'path';
10
11
  import { fileURLToPath } from 'url';
11
12
  import { createBridgeServer } from '../lib/dashboard/bridge-server.js';
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const DEFAULT_PORT = 3847;
16
+ const DASHBOARD_PID_FILE = '.agentxchain-dashboard.pid';
17
+ const DASHBOARD_SESSION_FILE = '.agentxchain-dashboard.json';
18
+ const DASHBOARD_DAEMON_CHILD_ENV = 'AGENTXCHAIN_DASHBOARD_DAEMON_CHILD';
15
19
 
16
20
  export async function dashboardCommand(options) {
17
21
  const cwd = process.cwd();
@@ -29,12 +33,25 @@ export async function dashboardCommand(options) {
29
33
  process.exit(1);
30
34
  }
31
35
 
32
- const port = parseInt(options.port, 10) || DEFAULT_PORT;
36
+ if (options.daemon && process.env[DASHBOARD_DAEMON_CHILD_ENV] !== '1') {
37
+ await startDashboardDaemon({ cwd, port: parseDashboardPort(options.port) });
38
+ return;
39
+ }
40
+
41
+ cleanupDashboardFiles(cwd);
42
+
43
+ const port = parseDashboardPort(options.port);
33
44
  const bridge = createBridgeServer({ agentxchainDir, dashboardDir, port });
34
45
 
35
46
  try {
36
47
  const { port: actualPort } = await bridge.start();
37
48
  const url = `http://localhost:${actualPort}`;
49
+ writeDashboardSession(cwd, {
50
+ pid: process.pid,
51
+ port: actualPort,
52
+ url,
53
+ started_at: new Date().toISOString(),
54
+ });
38
55
 
39
56
  console.log(`Dashboard running at ${url}`);
40
57
  console.log('Press Ctrl+C to stop.\n');
@@ -51,16 +68,19 @@ export async function dashboardCommand(options) {
51
68
  }
52
69
  }
53
70
 
54
- // Keep running until interrupted
71
+ let shuttingDown = false;
55
72
  const shutdown = async () => {
73
+ if (shuttingDown) return;
74
+ shuttingDown = true;
56
75
  console.log('\nShutting down dashboard...');
76
+ cleanupDashboardFiles(cwd);
57
77
  await bridge.stop();
58
78
  process.exit(0);
59
79
  };
60
80
  process.on('SIGINT', shutdown);
61
81
  process.on('SIGTERM', shutdown);
62
-
63
82
  } catch (err) {
83
+ cleanupDashboardFiles(cwd);
64
84
  if (err.code === 'EADDRINUSE') {
65
85
  console.error(`Error: Port ${port} is already in use. Try --port <number>.`);
66
86
  process.exit(1);
@@ -68,3 +88,111 @@ export async function dashboardCommand(options) {
68
88
  throw err;
69
89
  }
70
90
  }
91
+
92
+ export function getDashboardPid(root) {
93
+ const pidPath = join(root, DASHBOARD_PID_FILE);
94
+ if (!existsSync(pidPath)) return null;
95
+ try {
96
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
97
+ if (!Number.isFinite(pid)) return null;
98
+ process.kill(pid, 0);
99
+ return pid;
100
+ } catch (err) {
101
+ if (err?.code === 'ESRCH') {
102
+ cleanupDashboardFiles(root);
103
+ return null;
104
+ }
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export function getDashboardSession(root) {
110
+ const sessionPath = join(root, DASHBOARD_SESSION_FILE);
111
+ if (!existsSync(sessionPath)) return null;
112
+ try {
113
+ return JSON.parse(readFileSync(sessionPath, 'utf8'));
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ export function cleanupDashboardFiles(root) {
120
+ const paths = [
121
+ join(root, DASHBOARD_PID_FILE),
122
+ join(root, DASHBOARD_SESSION_FILE),
123
+ ];
124
+ for (const path of paths) {
125
+ if (!existsSync(path)) continue;
126
+ try { unlinkSync(path); } catch {}
127
+ }
128
+ }
129
+
130
+ function parseDashboardPort(value) {
131
+ const parsed = parseInt(value, 10);
132
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT;
133
+ }
134
+
135
+ function writeDashboardSession(root, session) {
136
+ writeFileSync(join(root, DASHBOARD_PID_FILE), `${session.pid}\n`);
137
+ writeFileSync(join(root, DASHBOARD_SESSION_FILE), `${JSON.stringify(session, null, 2)}\n`);
138
+ }
139
+
140
+ async function startDashboardDaemon({ cwd, port }) {
141
+ const existingPid = getDashboardPid(cwd);
142
+ if (existingPid) {
143
+ const existingSession = getDashboardSession(cwd);
144
+ const existingUrl = existingSession?.url || `http://localhost:${existingSession?.port || port}`;
145
+ console.error(`Error: Dashboard already running at ${existingUrl} (PID: ${existingPid}). Stop it first with "agentxchain stop".`);
146
+ process.exit(1);
147
+ }
148
+
149
+ cleanupDashboardFiles(cwd);
150
+
151
+ const cliBin = join(__dirname, '..', '..', 'bin', 'agentxchain.js');
152
+ const child = spawn(process.execPath, [cliBin, 'dashboard', '--port', String(port), '--no-open'], {
153
+ cwd,
154
+ env: {
155
+ ...process.env,
156
+ [DASHBOARD_DAEMON_CHILD_ENV]: '1',
157
+ },
158
+ detached: true,
159
+ stdio: 'ignore',
160
+ });
161
+ child.unref();
162
+
163
+ try {
164
+ const session = await waitForDashboardSession(cwd, child.pid);
165
+ console.log(`Dashboard started in daemon mode at ${session.url}`);
166
+ console.log(`PID: ${session.pid}`);
167
+ } catch (err) {
168
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
169
+ cleanupDashboardFiles(cwd);
170
+ console.error(`Error: ${err.message}`);
171
+ process.exit(1);
172
+ }
173
+ }
174
+
175
+ function waitForDashboardSession(root, expectedPid, timeoutMs = 8000) {
176
+ return new Promise((resolve, reject) => {
177
+ const startedAt = Date.now();
178
+
179
+ const poll = () => {
180
+ const pid = getDashboardPid(root);
181
+ const session = getDashboardSession(root);
182
+
183
+ if (pid === expectedPid && session?.pid === expectedPid && typeof session?.url === 'string') {
184
+ resolve(session);
185
+ return;
186
+ }
187
+
188
+ if (Date.now() - startedAt >= timeoutMs) {
189
+ reject(new Error('Dashboard daemon did not report a live session within 8s.'));
190
+ return;
191
+ }
192
+
193
+ setTimeout(poll, 100);
194
+ };
195
+
196
+ poll();
197
+ });
198
+ }
@@ -5,6 +5,7 @@ import chalk from 'chalk';
5
5
  import { loadConfig, loadLock, findProjectRoot } from '../lib/config.js';
6
6
  import { validateProject } from '../lib/validation.js';
7
7
  import { getWatchPid } from './watch.js';
8
+ import { getDashboardPid, getDashboardSession } from './dashboard.js';
8
9
  import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
9
10
  import { readDaemonState, evaluateDaemonStatus } from '../lib/run-schedule.js';
10
11
  import { getGovernedVersionSurface, formatGovernedVersionLabel } from '../lib/protocol-version.js';
@@ -219,6 +220,21 @@ function governedDoctor(root, rawConfig, opts) {
219
220
  }
220
221
  }
221
222
 
223
+ // 9. Dashboard session health (unconditional — dashboard is a general operator surface)
224
+ {
225
+ const dashPid = getDashboardPid(root);
226
+ const dashSession = getDashboardSession(root);
227
+ if (dashPid && dashSession) {
228
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'pass', detail: `Dashboard running at ${dashSession.url} (PID: ${dashPid})` });
229
+ } else if (dashPid && !dashSession) {
230
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'warn', detail: `Dashboard PID ${dashPid} alive but session file missing` });
231
+ } else if (!dashPid && dashSession) {
232
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'warn', detail: `Stale dashboard session files (PID ${dashSession.pid || '?'} not running)` });
233
+ } else {
234
+ checks.push({ id: 'dashboard_session', name: 'Dashboard session', level: 'info', detail: 'No dashboard session' });
235
+ }
236
+ }
237
+
222
238
  // Compute summary
223
239
  const failCount = checks.filter(c => c.level === 'fail').length;
224
240
  const warnCount = checks.filter(c => c.level === 'warn').length;
@@ -253,7 +269,9 @@ function governedDoctor(root, rawConfig, opts) {
253
269
  ? chalk.green('PASS')
254
270
  : c.level === 'warn'
255
271
  ? chalk.yellow('WARN')
256
- : chalk.red('FAIL');
272
+ : c.level === 'info'
273
+ ? chalk.dim('INFO')
274
+ : chalk.red('FAIL');
257
275
  console.log(` ${badge} ${c.name.padEnd(24)} ${chalk.dim(c.detail)}`);
258
276
  }
259
277
 
@@ -9,6 +9,7 @@ import { getConnectorHealth } from '../lib/connector-health.js';
9
9
  import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
10
10
  import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
11
11
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
12
+ import { getDashboardPid, getDashboardSession } from './dashboard.js';
12
13
 
13
14
  export async function statusCommand(opts) {
14
15
  const context = loadProjectContext();
@@ -87,6 +88,14 @@ function renderGovernedStatus(context, opts) {
87
88
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
88
89
 
89
90
  if (opts.json) {
91
+ const dashPid = getDashboardPid(root);
92
+ const dashSession = getDashboardSession(root);
93
+ const dashboardSessionObj = dashPid
94
+ ? { status: 'running', pid: dashPid, url: dashSession?.url || null, started_at: dashSession?.started_at || null }
95
+ : dashSession
96
+ ? { status: 'stale', pid: dashSession.pid || null, url: dashSession.url || null, started_at: dashSession.started_at || null }
97
+ : { status: 'not_running', pid: null, url: null, started_at: null };
98
+
90
99
  console.log(JSON.stringify({
91
100
  version,
92
101
  protocol_mode: config.protocol_mode,
@@ -99,6 +108,7 @@ function renderGovernedStatus(context, opts) {
99
108
  continuity,
100
109
  connector_health: connectorHealth,
101
110
  workflow_kit_artifacts: workflowKitArtifacts,
111
+ dashboard_session: dashboardSessionObj,
102
112
  }, null, 2));
103
113
  return;
104
114
  }
@@ -3,9 +3,12 @@ import { join } from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { loadConfig } from '../lib/config.js';
5
5
  import { getWatchPid } from './watch.js';
6
+ import { cleanupDashboardFiles, getDashboardPid, getDashboardSession } from './dashboard.js';
6
7
 
7
8
  const SESSION_FILE = '.agentxchain-session.json';
8
9
  const WATCH_PID_FILE = '.agentxchain-watch.pid';
10
+ const DASHBOARD_PID_FILE = '.agentxchain-dashboard.pid';
11
+ const DASHBOARD_SESSION_FILE = '.agentxchain-dashboard.json';
9
12
 
10
13
  export async function stopCommand() {
11
14
  const result = loadConfig();
@@ -14,7 +17,12 @@ export async function stopCommand() {
14
17
  const { root } = result;
15
18
  const sessionPath = join(root, SESSION_FILE);
16
19
  const watchPidPath = join(root, WATCH_PID_FILE);
20
+ const dashboardPidPath = join(root, DASHBOARD_PID_FILE);
21
+ const dashboardSessionPath = join(root, DASHBOARD_SESSION_FILE);
17
22
  const watchPid = getWatchPid(root);
23
+ const hadDashboardArtifacts = existsSync(dashboardPidPath) || existsSync(dashboardSessionPath);
24
+ const dashboardPid = getDashboardPid(root);
25
+ const dashboardSession = getDashboardSession(root);
18
26
  let didStopAnything = false;
19
27
 
20
28
  if (watchPid) {
@@ -41,6 +49,26 @@ export async function stopCommand() {
41
49
  } catch {}
42
50
  }
43
51
 
52
+ if (dashboardPid) {
53
+ try {
54
+ process.kill(dashboardPid, 'SIGTERM');
55
+ cleanupDashboardFiles(root);
56
+ didStopAnything = true;
57
+ console.log('');
58
+ console.log(chalk.green(` ✓ Stopped dashboard process (PID: ${dashboardPid})${dashboardSession?.url ? ` at ${dashboardSession.url}` : ''}`));
59
+ console.log('');
60
+ } catch (err) {
61
+ if (err.code === 'ESRCH') {
62
+ cleanupDashboardFiles(root);
63
+ } else {
64
+ console.log(chalk.red(` ✗ Could not stop dashboard process (PID: ${dashboardPid}): ${err.message}`));
65
+ }
66
+ }
67
+ } else if (hadDashboardArtifacts) {
68
+ cleanupDashboardFiles(root);
69
+ console.log(chalk.dim(' Removed stale dashboard session files.'));
70
+ }
71
+
44
72
  if (existsSync(sessionPath)) {
45
73
  let session;
46
74
  try {
@@ -115,7 +115,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
115
115
  child = spawn(command, args, {
116
116
  cwd: runtime.cwd ? join(root, runtime.cwd) : root,
117
117
  stdio: ['pipe', 'pipe', 'pipe'],
118
- env: { ...process.env },
118
+ env: { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id },
119
119
  });
120
120
  } catch (err) {
121
121
  resolve({ ok: false, error: `Failed to spawn "${command}": ${err.message}`, logs });
package/src/lib/export.js CHANGED
@@ -7,6 +7,7 @@ import { loadProjectContext, loadProjectState } from './config.js';
7
7
  import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
8
8
  import { loadCoordinatorState } from './coordinator-state.js';
9
9
  import { normalizeRunProvenance } from './run-provenance.js';
10
+ import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
10
11
 
11
12
  const EXPORT_SCHEMA_VERSION = '0.3';
12
13
 
@@ -23,6 +24,8 @@ const COORDINATOR_INCLUDED_ROOTS = [
23
24
  export const RUN_EXPORT_INCLUDED_ROOTS = [
24
25
  'agentxchain.json',
25
26
  'TALK.md',
27
+ '.agentxchain-dashboard.pid',
28
+ '.agentxchain-dashboard.json',
26
29
  '.agentxchain/state.json',
27
30
  '.agentxchain/session.json',
28
31
  '.agentxchain/history.jsonl',
@@ -166,6 +169,45 @@ function countDirectoryFiles(files, prefix) {
166
169
  return Object.keys(files).filter((path) => path.startsWith(`${prefix}/`)).length;
167
170
  }
168
171
 
172
+ function buildDashboardSessionSummary(root) {
173
+ const dashPid = getDashboardPid(root);
174
+ const dashSession = getDashboardSession(root);
175
+
176
+ if (dashPid && dashSession) {
177
+ return {
178
+ status: 'running',
179
+ pid: dashPid,
180
+ url: dashSession.url || null,
181
+ started_at: dashSession.started_at || null,
182
+ };
183
+ }
184
+
185
+ if (dashPid && !dashSession) {
186
+ return {
187
+ status: 'pid_only',
188
+ pid: dashPid,
189
+ url: null,
190
+ started_at: null,
191
+ };
192
+ }
193
+
194
+ if (!dashPid && dashSession) {
195
+ return {
196
+ status: 'stale',
197
+ pid: dashSession.pid || null,
198
+ url: dashSession.url || null,
199
+ started_at: dashSession.started_at || null,
200
+ };
201
+ }
202
+
203
+ return {
204
+ status: 'not_running',
205
+ pid: null,
206
+ url: null,
207
+ started_at: null,
208
+ };
209
+ }
210
+
169
211
  export function buildDelegationSummary(files) {
170
212
  const historyData = files['.agentxchain/history.jsonl']?.data;
171
213
  if (!Array.isArray(historyData)) {
@@ -404,6 +446,7 @@ export function buildRunExport(startDir = process.cwd()) {
404
446
  staging_artifact_files: countDirectoryFiles(files, '.agentxchain/staging'),
405
447
  intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
406
448
  coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
449
+ dashboard_session: buildDashboardSessionSummary(root),
407
450
  delegation_summary: buildDelegationSummary(files),
408
451
  },
409
452
  workspace: buildRunWorkspaceMetadata(root),
@@ -799,6 +799,61 @@ function readJsonlEntries(root, relPath) {
799
799
  .filter(Boolean);
800
800
  }
801
801
 
802
+ function collectPendingConcurrentSiblingDeclarations(root, state, currentTurn, historyEntries = []) {
803
+ const concurrentIds = new Set(
804
+ Array.isArray(currentTurn?.concurrent_with)
805
+ ? currentTurn.concurrent_with.filter((id) => typeof id === 'string' && id.length > 0)
806
+ : [],
807
+ );
808
+ const activeTurns = getActiveTurns(state);
809
+ for (const turn of Object.values(activeTurns)) {
810
+ if (
811
+ turn?.turn_id !== currentTurn?.turn_id
812
+ && Array.isArray(turn?.concurrent_with)
813
+ && turn.concurrent_with.includes(currentTurn?.turn_id)
814
+ ) {
815
+ concurrentIds.add(turn.turn_id);
816
+ }
817
+ }
818
+ if (concurrentIds.size === 0) {
819
+ return [];
820
+ }
821
+
822
+ const acceptedIds = new Set(
823
+ (Array.isArray(historyEntries) ? historyEntries : [])
824
+ .map((entry) => entry?.turn_id)
825
+ .filter((turnId) => typeof turnId === 'string' && turnId.length > 0),
826
+ );
827
+ const declarations = [];
828
+
829
+ for (const siblingTurnId of concurrentIds) {
830
+ if (siblingTurnId === currentTurn?.turn_id || acceptedIds.has(siblingTurnId) || !activeTurns[siblingTurnId]) {
831
+ continue;
832
+ }
833
+
834
+ const stagedSibling = loadHookStagedTurn(root, getTurnStagingResultPath(siblingTurnId));
835
+ const siblingResult = stagedSibling.turnResult;
836
+ if (!siblingResult || siblingResult.turn_id !== siblingTurnId) {
837
+ continue;
838
+ }
839
+
840
+ const siblingFiles = [...new Set(
841
+ (Array.isArray(siblingResult.files_changed) ? siblingResult.files_changed : [])
842
+ .filter((filePath) => typeof filePath === 'string' && filePath.length > 0),
843
+ )];
844
+ if (siblingFiles.length === 0) {
845
+ continue;
846
+ }
847
+
848
+ declarations.push({
849
+ turn_id: siblingTurnId,
850
+ files_changed: siblingFiles,
851
+ });
852
+ }
853
+
854
+ return declarations;
855
+ }
856
+
802
857
  function getObservedFiles(entry) {
803
858
  if (Array.isArray(entry?.observed_artifact?.files_changed)) {
804
859
  return entry.observed_artifact.files_changed;
@@ -2373,7 +2428,17 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2373
2428
  const baseline = currentTurn.baseline || null;
2374
2429
  const rawObservation = observeChanges(root, baseline);
2375
2430
  const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2376
- const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries);
2431
+ const pendingConcurrentSiblingDeclarations = collectPendingConcurrentSiblingDeclarations(
2432
+ root,
2433
+ state,
2434
+ currentTurn,
2435
+ historyEntries,
2436
+ );
2437
+ const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries, {
2438
+ currentDeclaredFiles: turnResult.files_changed || [],
2439
+ concurrentSiblingIds: pendingConcurrentSiblingDeclarations.map((entry) => entry.turn_id),
2440
+ pendingConcurrentSiblingDeclarations,
2441
+ });
2377
2442
  const role = config.roles?.[turnResult.role];
2378
2443
  const runtimeId = turnResult.runtime_id;
2379
2444
  const runtime = config.runtimes?.[runtimeId];
@@ -2381,11 +2446,28 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2381
2446
  materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline);
2382
2447
  materializeDerivedProposalArtifact(root, turnResult, state, runtimeType);
2383
2448
  const writeAuthority = role?.write_authority || 'review_only';
2449
+
2450
+ // When concurrent siblings exist but have not yet been accepted, the
2451
+ // observation includes their file changes too. The attribution system
2452
+ // (attributeObservedChangesToTurn) only removes sibling files for
2453
+ // *later*-accepted turns. For the *first*-accepted concurrent turn,
2454
+ // undeclared files are expected noise from concurrency — downgrade to
2455
+ // warnings so the governance contract is not broken by turn-acceptance
2456
+ // ordering.
2457
+ const concurrentIds = new Set(
2458
+ Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
2459
+ );
2460
+ const acceptedTurnIds = new Set(historyEntries.map(h => h.turn_id));
2461
+ const hasUnacceptedConcurrentSiblings = [...concurrentIds].some(id => !acceptedTurnIds.has(id));
2462
+
2384
2463
  const diffComparison = compareDeclaredVsObserved(
2385
2464
  turnResult.files_changed || [],
2386
2465
  observation.files_changed,
2387
2466
  writeAuthority,
2388
- { observation_available: observation.observation_available },
2467
+ {
2468
+ observation_available: observation.observation_available,
2469
+ has_unaccepted_concurrent_siblings: hasUnacceptedConcurrentSiblings,
2470
+ },
2389
2471
  );
2390
2472
  if (diffComparison.errors.length > 0) {
2391
2473
  return {
@@ -175,7 +175,7 @@ export function observeChanges(root, baseline) {
175
175
  };
176
176
  }
177
177
 
178
- export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = []) {
178
+ export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = [], options = {}) {
179
179
  const observedFiles = Array.isArray(observation?.files_changed) ? observation.files_changed : [];
180
180
  if (observedFiles.length === 0) {
181
181
  return observation;
@@ -184,6 +184,11 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
184
184
  const concurrentIds = new Set(
185
185
  Array.isArray(currentTurn?.concurrent_with) ? currentTurn.concurrent_with : [],
186
186
  );
187
+ for (const siblingId of Array.isArray(options.concurrentSiblingIds) ? options.concurrentSiblingIds : []) {
188
+ if (typeof siblingId === 'string' && siblingId.length > 0) {
189
+ concurrentIds.add(siblingId);
190
+ }
191
+ }
187
192
  if (concurrentIds.size === 0) {
188
193
  return observation;
189
194
  }
@@ -195,11 +200,17 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
195
200
  .filter((entry) => (
196
201
  Number.isInteger(entry?.accepted_sequence)
197
202
  && entry.accepted_sequence > assignedSequence
198
- && concurrentIds.has(entry.turn_id)
203
+ && (
204
+ concurrentIds.has(entry.turn_id)
205
+ || (Array.isArray(entry?.concurrent_with) && entry.concurrent_with.includes(currentTurn?.turn_id))
206
+ )
199
207
  ))
200
208
  .sort((left, right) => left.accepted_sequence - right.accepted_sequence);
201
209
 
202
- if (acceptedConcurrentSiblings.length === 0) {
210
+ const pendingConcurrentSiblingDeclarations = Array.isArray(options.pendingConcurrentSiblingDeclarations)
211
+ ? options.pendingConcurrentSiblingDeclarations
212
+ : [];
213
+ if (acceptedConcurrentSiblings.length === 0 && pendingConcurrentSiblingDeclarations.length === 0) {
203
214
  return observation;
204
215
  }
205
216
 
@@ -221,7 +232,20 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
221
232
  }
222
233
  }
223
234
 
224
- if (siblingMarkersByFile.size === 0) {
235
+ const currentDeclaredFiles = new Set(
236
+ Array.isArray(options.currentDeclaredFiles) ? options.currentDeclaredFiles : [],
237
+ );
238
+ const pendingConcurrentSiblingFiles = new Set();
239
+ for (const declaration of pendingConcurrentSiblingDeclarations) {
240
+ const siblingFiles = Array.isArray(declaration?.files_changed) ? declaration.files_changed : [];
241
+ for (const filePath of siblingFiles) {
242
+ if (typeof filePath === 'string' && filePath.length > 0 && !currentDeclaredFiles.has(filePath)) {
243
+ pendingConcurrentSiblingFiles.add(filePath);
244
+ }
245
+ }
246
+ }
247
+
248
+ if (siblingMarkersByFile.size === 0 && pendingConcurrentSiblingFiles.size === 0) {
225
249
  return observation;
226
250
  }
227
251
 
@@ -239,6 +263,10 @@ export function attributeObservedChangesToTurn(observation, currentTurn, history
239
263
  attributedToConcurrentSiblings.push(filePath);
240
264
  continue;
241
265
  }
266
+ if (pendingConcurrentSiblingFiles.has(filePath)) {
267
+ attributedToConcurrentSiblings.push(filePath);
268
+ continue;
269
+ }
242
270
  nextFiles.push(filePath);
243
271
  if (typeof currentMarker === 'string') {
244
272
  nextMarkers[filePath] = currentMarker;
@@ -503,7 +531,13 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority, op
503
531
 
504
532
  if (writeAuthority === 'authoritative') {
505
533
  if (undeclared.length > 0) {
506
- errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
534
+ if (options.has_unaccepted_concurrent_siblings) {
535
+ // Concurrent siblings may have written these files; downgrade to warning.
536
+ // The attribution system will handle later-accepted siblings correctly.
537
+ warnings.push(`Undeclared file changes detected (likely from concurrent sibling turns): ${undeclared.join(', ')}`);
538
+ } else {
539
+ errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
540
+ }
507
541
  }
508
542
  if (phantom.length > 0) {
509
543
  warnings.push(`Declared files not observed in actual diff: ${phantom.join(', ')}`);
package/src/lib/report.js CHANGED
@@ -5,6 +5,7 @@ import { normalizeRunProvenance, summarizeRunProvenance } from './run-provenance
5
5
  export const GOVERNANCE_REPORT_VERSION = '0.1';
6
6
 
7
7
  const VALID_DELEGATION_OUTCOMES = new Set(['completed', 'failed', 'mixed', 'pending']);
8
+ const VALID_DASHBOARD_SESSION_STATUSES = new Set(['running', 'pid_only', 'stale', 'not_running']);
8
9
 
9
10
  function normalizeDelegationSummary(summary) {
10
11
  if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return null;
@@ -58,6 +59,37 @@ function extractDelegationSummary(artifact) {
58
59
  return normalizeDelegationSummary(buildDelegationSummary(artifact.files || {}));
59
60
  }
60
61
 
62
+ function normalizeDashboardSessionSummary(summary) {
63
+ if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return null;
64
+ if (!VALID_DASHBOARD_SESSION_STATUSES.has(summary.status)) return null;
65
+ return {
66
+ status: summary.status,
67
+ pid: Number.isInteger(summary.pid) ? summary.pid : null,
68
+ url: typeof summary.url === 'string' && summary.url.length > 0 ? summary.url : null,
69
+ started_at: typeof summary.started_at === 'string' && summary.started_at.length > 0 ? summary.started_at : null,
70
+ };
71
+ }
72
+
73
+ function extractDashboardSessionSummary(artifact) {
74
+ return normalizeDashboardSessionSummary(artifact.summary?.dashboard_session);
75
+ }
76
+
77
+ function formatDashboardSessionLine(session) {
78
+ if (!session) return null;
79
+ switch (session.status) {
80
+ case 'running':
81
+ return `running at ${session.url || 'unknown url'} (PID: ${session.pid || '?'})`;
82
+ case 'pid_only':
83
+ return `pid_only (PID: ${session.pid || '?'}, session metadata missing)`;
84
+ case 'stale':
85
+ return `stale session files${session.pid ? ` (PID: ${session.pid})` : ''}${session.url ? ` at ${session.url}` : ''}`;
86
+ case 'not_running':
87
+ return 'not_running';
88
+ default:
89
+ return null;
90
+ }
91
+ }
92
+
61
93
  function yesNo(value) {
62
94
  return value ? 'yes' : 'no';
63
95
  }
@@ -767,14 +799,28 @@ function extractBarrierSummary(artifact) {
767
799
  return Object.entries(data)
768
800
  .filter(([, b]) => b && typeof b === 'object' && !Array.isArray(b))
769
801
  .sort(([a], [b]) => a.localeCompare(b, 'en'))
770
- .map(([barrierId, b]) => ({
771
- barrier_id: barrierId,
772
- workstream_id: b.workstream_id || null,
773
- type: b.type || 'unknown',
774
- status: b.status || 'unknown',
775
- required_repos: Array.isArray(b.required_repos) ? b.required_repos : [],
776
- satisfied_repos: Array.isArray(b.satisfied_repos) ? b.satisfied_repos : [],
777
- }));
802
+ .map(([barrierId, b]) => {
803
+ const entry = {
804
+ barrier_id: barrierId,
805
+ workstream_id: b.workstream_id || null,
806
+ type: b.type || 'unknown',
807
+ status: b.status || 'unknown',
808
+ required_repos: Array.isArray(b.required_repos) ? b.required_repos : [],
809
+ satisfied_repos: Array.isArray(b.satisfied_repos) ? b.satisfied_repos : [],
810
+ };
811
+ const decisionIds =
812
+ b.required_decision_ids_by_repo || b.alignment_decision_ids || null;
813
+ if (decisionIds && typeof decisionIds === 'object' && !Array.isArray(decisionIds)) {
814
+ entry.required_decision_ids_by_repo = decisionIds;
815
+ const satisfiedSet = new Set(entry.satisfied_repos);
816
+ const satisfiedByRepo = {};
817
+ for (const [repo, ids] of Object.entries(decisionIds)) {
818
+ satisfiedByRepo[repo] = satisfiedSet.has(repo) ? [...ids] : [];
819
+ }
820
+ entry.satisfied_decision_ids_by_repo = satisfiedByRepo;
821
+ }
822
+ return entry;
823
+ });
778
824
  }
779
825
 
780
826
  function summarizeBarrierTransition(entry) {
@@ -902,6 +948,7 @@ function buildRunSubject(artifact) {
902
948
  const continuity = extractContinuityMetadata(artifact);
903
949
  const governanceEvents = extractGovernanceEventDigest(artifact);
904
950
  const delegationSummary = extractDelegationSummary(artifact);
951
+ const dashboardSession = extractDashboardSessionSummary(artifact);
905
952
 
906
953
  return {
907
954
  kind: 'governed_run',
@@ -928,6 +975,7 @@ function buildRunSubject(artifact) {
928
975
  active_roles: activeRoles,
929
976
  budget_status: normalizeBudgetStatus(artifact.state?.budget_status),
930
977
  cost_summary: computeCostSummary(turns),
978
+ dashboard_session: dashboardSession,
931
979
  created_at: timing.created_at,
932
980
  completed_at: timing.completed_at,
933
981
  duration_seconds: timing.duration_seconds,
@@ -1195,6 +1243,9 @@ export function formatGovernanceReportText(report) {
1195
1243
  if (run.inherited_context?.parent_run_id) {
1196
1244
  lines.push(`Inherited from: ${run.inherited_context.parent_run_id} (${run.inherited_context.parent_status || 'unknown'})`);
1197
1245
  }
1246
+ if (run.dashboard_session) {
1247
+ lines.push(`Dashboard session: ${formatDashboardSessionLine(run.dashboard_session)}`);
1248
+ }
1198
1249
 
1199
1250
  lines.push(
1200
1251
  `History entries: ${artifacts.history_entries}`,
@@ -1442,6 +1493,17 @@ export function formatGovernanceReportText(report) {
1442
1493
  const satisfied = b.satisfied_repos.length;
1443
1494
  const required = b.required_repos.length;
1444
1495
  lines.push(` - ${b.barrier_id}: ${b.status} (${b.type}, ${satisfied}/${required} repos satisfied, workstream ${b.workstream_id || 'unknown'})`);
1496
+ if (b.required_decision_ids_by_repo) {
1497
+ lines.push(' Decision requirements:');
1498
+ for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
1499
+ if (!Array.isArray(ids) || ids.length === 0) continue;
1500
+ const satisfiedIds = new Set(
1501
+ Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : []
1502
+ );
1503
+ const labels = ids.map((id) => `${id} (${satisfiedIds.has(id) ? 'satisfied' : 'pending'})`);
1504
+ lines.push(` ${repo}: ${labels.join(', ')}`);
1505
+ }
1506
+ }
1445
1507
  }
1446
1508
  }
1447
1509
 
@@ -1663,6 +1725,9 @@ export function formatGovernanceReportMarkdown(report) {
1663
1725
  if (run.inherited_context?.parent_run_id) {
1664
1726
  lines.push(`- Inherited from: \`${run.inherited_context.parent_run_id}\` (${run.inherited_context.parent_status || 'unknown'})`);
1665
1727
  }
1728
+ if (run.dashboard_session) {
1729
+ lines.push(`- Dashboard session: \`${formatDashboardSessionLine(run.dashboard_session)}\``);
1730
+ }
1666
1731
 
1667
1732
  lines.push(
1668
1733
  `- History entries: ${artifacts.history_entries}`,
@@ -1917,6 +1982,20 @@ export function formatGovernanceReportMarkdown(report) {
1917
1982
  for (const b of barrier_summary) {
1918
1983
  mdLines.push(`| \`${b.barrier_id}\` | \`${b.workstream_id || 'unknown'}\` | \`${b.type}\` | \`${b.status}\` | ${b.satisfied_repos.length}/${b.required_repos.length} repos |`);
1919
1984
  }
1985
+ const barriersWithDecisions = barrier_summary.filter((b) => b.required_decision_ids_by_repo);
1986
+ if (barriersWithDecisions.length > 0) {
1987
+ mdLines.push('', '### Decision Requirements');
1988
+ for (const b of barriersWithDecisions) {
1989
+ mdLines.push('', `**\`${b.barrier_id}\`** decision requirements:`, '', '| Repo | Required | Satisfied |', '|------|----------|-----------|');
1990
+ for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
1991
+ if (!Array.isArray(ids) || ids.length === 0) continue;
1992
+ const satisfiedIds = Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : [];
1993
+ const reqStr = ids.map((id) => `\`${id}\``).join(', ');
1994
+ const satStr = satisfiedIds.length > 0 ? satisfiedIds.map((id) => `\`${id}\``).join(', ') : '—';
1995
+ mdLines.push(`| \`${repo}\` | ${reqStr} | ${satStr} |`);
1996
+ }
1997
+ }
1998
+ }
1920
1999
  }
1921
2000
 
1922
2001
  if (barrier_ledger_timeline && barrier_ledger_timeline.length > 0) {
@@ -217,7 +217,16 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
217
217
 
218
218
  // If selectRole returns a role we already tried (or assigned), try
219
219
  // other eligible roles from the routing before giving up.
220
+ // Exception: when delegation queue is driving resolution, do not fill
221
+ // extra slots with non-delegation roles via the fallback — those roles
222
+ // would execute without delegation context and corrupt the lifecycle.
220
223
  if (triedRoles.has(roleId)) {
224
+ const hasPendingDelegations = Array.isArray(state?.delegation_queue) &&
225
+ state.delegation_queue.some(d => d.status === 'pending' || d.status === 'active');
226
+ const hasPendingReview = !!state?.pending_delegation_review;
227
+ if (hasPendingDelegations || hasPendingReview) {
228
+ break;
229
+ }
221
230
  const phase = state.phase;
222
231
  const allowed = config?.routing?.[phase]?.allowed_next_roles || [];
223
232
  const alternateFound = allowed.some((alt) => {
@@ -248,6 +257,13 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
248
257
  turnsToDispatch.push({ turn: assignResult.turn, state: assignResult.state });
249
258
  emit({ type: 'turn_assigned', turn: assignResult.turn, role: roleId, state: assignResult.state });
250
259
 
260
+ // Delegation review is a coordination checkpoint — do not fill additional
261
+ // slots alongside it. The review must execute alone so it can assess all
262
+ // delegation results before the run continues.
263
+ if (assignResult.turn.delegation_review) {
264
+ break;
265
+ }
266
+
251
267
  // Reload state after assignment to get accurate active count
252
268
  state = loadState(root, config);
253
269
  activeCount = getActiveTurnCount(state);