agentxchain 2.89.0 → 2.91.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/bin/agentxchain.js +2 -1
- package/dashboard/components/initiative.js +17 -0
- package/package.json +1 -1
- package/src/commands/dashboard.js +132 -4
- package/src/commands/stop.js +28 -0
- package/src/lib/coordinator-acceptance.js +1 -1
- package/src/lib/coordinator-barriers.js +2 -1
- package/src/lib/coordinator-config.js +58 -33
- package/src/lib/coordinator-recovery.js +1 -1
- package/src/lib/coordinator-state.js +3 -0
- package/src/lib/cross-repo-context.js +11 -10
- package/src/lib/report.js +47 -8
package/bin/agentxchain.js
CHANGED
|
@@ -199,7 +199,7 @@ program
|
|
|
199
199
|
|
|
200
200
|
program
|
|
201
201
|
.command('stop')
|
|
202
|
-
.description('Stop watch
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/stop.js
CHANGED
|
@@ -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 {
|
|
@@ -267,7 +267,7 @@ function evaluateBarrierEffects(workspacePath, state, config, repoId, workstream
|
|
|
267
267
|
snapshotChanged = true;
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
|
-
if (barrier.type === 'interface_alignment') {
|
|
270
|
+
if (barrier.type === 'interface_alignment' || barrier.type === 'named_decisions') {
|
|
271
271
|
const satisfiedRepos = getAlignedReposForBarrier(barrier, history);
|
|
272
272
|
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
273
273
|
barrier.satisfied_repos = satisfiedRepos;
|
|
@@ -41,7 +41,7 @@ export function getAcceptedReposForWorkstream(history, workstreamId, requiredRep
|
|
|
41
41
|
|
|
42
42
|
export function getAlignedReposForBarrier(barrier, history) {
|
|
43
43
|
const requiredRepos = Array.isArray(barrier.required_repos) ? barrier.required_repos : [];
|
|
44
|
-
const alignmentDecisionIds = barrier.alignment_decision_ids || {};
|
|
44
|
+
const alignmentDecisionIds = barrier.required_decision_ids_by_repo || barrier.alignment_decision_ids || {};
|
|
45
45
|
const { repoDecisionIds } = collectAcceptedDecisionIds(history, barrier.workstream_id, requiredRepos);
|
|
46
46
|
const alignedRepos = [];
|
|
47
47
|
|
|
@@ -105,6 +105,7 @@ export function computeBarrierStatus(barrier, history, config) {
|
|
|
105
105
|
return computeOrderedRepoSequenceStatus(barrier, history, config);
|
|
106
106
|
|
|
107
107
|
case 'interface_alignment':
|
|
108
|
+
case 'named_decisions':
|
|
108
109
|
return computeInterfaceAlignmentStatus(barrier, history);
|
|
109
110
|
|
|
110
111
|
case 'shared_human_gate':
|
|
@@ -11,6 +11,7 @@ const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
|
11
11
|
const VALID_BARRIER_TYPES = new Set([
|
|
12
12
|
'all_repos_accepted',
|
|
13
13
|
'interface_alignment',
|
|
14
|
+
'named_decisions',
|
|
14
15
|
'ordered_repo_sequence',
|
|
15
16
|
'shared_human_gate',
|
|
16
17
|
]);
|
|
@@ -151,34 +152,30 @@ function validateWorkstreams(raw, repoIds, errors) {
|
|
|
151
152
|
);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
validateDecisionRequirementBarrier(workstreamId, workstream, errors);
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
detectWorkstreamCycles(raw.workstreams, errors);
|
|
158
159
|
return workstreamIds;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const alignment = workstream.interface_alignment;
|
|
167
|
-
if (!alignment || typeof alignment !== 'object' || Array.isArray(alignment)) {
|
|
162
|
+
function validateDecisionIdsByRepo(workstreamId, workstream, errors, sectionName, errorPrefix) {
|
|
163
|
+
const section = workstream[sectionName];
|
|
164
|
+
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
|
168
165
|
pushError(
|
|
169
166
|
errors,
|
|
170
|
-
|
|
171
|
-
`workstream "${workstreamId}" with completion_barrier "
|
|
167
|
+
`${errorPrefix}_invalid`,
|
|
168
|
+
`workstream "${workstreamId}" with completion_barrier "${workstream.completion_barrier}" must declare ${sectionName}.decision_ids_by_repo`,
|
|
172
169
|
);
|
|
173
170
|
return;
|
|
174
171
|
}
|
|
175
172
|
|
|
176
|
-
const byRepo =
|
|
173
|
+
const byRepo = section.decision_ids_by_repo;
|
|
177
174
|
if (!byRepo || typeof byRepo !== 'object' || Array.isArray(byRepo)) {
|
|
178
175
|
pushError(
|
|
179
176
|
errors,
|
|
180
|
-
|
|
181
|
-
`workstream "${workstreamId}"
|
|
177
|
+
`${errorPrefix}_decisions_invalid`,
|
|
178
|
+
`workstream "${workstreamId}" ${sectionName}.decision_ids_by_repo must be an object`,
|
|
182
179
|
);
|
|
183
180
|
return;
|
|
184
181
|
}
|
|
@@ -190,8 +187,8 @@ function validateInterfaceAlignment(workstreamId, workstream, errors) {
|
|
|
190
187
|
if (!(repoId in byRepo)) {
|
|
191
188
|
pushError(
|
|
192
189
|
errors,
|
|
193
|
-
|
|
194
|
-
`workstream "${workstreamId}" must declare
|
|
190
|
+
`${errorPrefix}_repo_missing`,
|
|
191
|
+
`workstream "${workstreamId}" must declare ${sectionName}.decision_ids_by_repo["${repoId}"]`,
|
|
195
192
|
);
|
|
196
193
|
continue;
|
|
197
194
|
}
|
|
@@ -200,8 +197,8 @@ function validateInterfaceAlignment(workstreamId, workstream, errors) {
|
|
|
200
197
|
if (!Array.isArray(decisionIds) || decisionIds.length === 0) {
|
|
201
198
|
pushError(
|
|
202
199
|
errors,
|
|
203
|
-
|
|
204
|
-
`workstream "${workstreamId}"
|
|
200
|
+
`${errorPrefix}_repo_invalid`,
|
|
201
|
+
`workstream "${workstreamId}" ${sectionName}.decision_ids_by_repo["${repoId}"] must be a non-empty array`,
|
|
205
202
|
);
|
|
206
203
|
continue;
|
|
207
204
|
}
|
|
@@ -211,16 +208,16 @@ function validateInterfaceAlignment(workstreamId, workstream, errors) {
|
|
|
211
208
|
if (typeof decisionId !== 'string' || !/^DEC-\d+$/.test(decisionId)) {
|
|
212
209
|
pushError(
|
|
213
210
|
errors,
|
|
214
|
-
|
|
215
|
-
`workstream "${workstreamId}"
|
|
211
|
+
`${errorPrefix}_decision_invalid`,
|
|
212
|
+
`workstream "${workstreamId}" ${sectionName} decision "${decisionId}" for repo "${repoId}" must match DEC-NNN`,
|
|
216
213
|
);
|
|
217
214
|
continue;
|
|
218
215
|
}
|
|
219
216
|
if (seen.has(decisionId)) {
|
|
220
217
|
pushError(
|
|
221
218
|
errors,
|
|
222
|
-
|
|
223
|
-
`workstream "${workstreamId}"
|
|
219
|
+
`${errorPrefix}_decision_duplicate`,
|
|
220
|
+
`workstream "${workstreamId}" ${sectionName} decision "${decisionId}" is duplicated for repo "${repoId}"`,
|
|
224
221
|
);
|
|
225
222
|
continue;
|
|
226
223
|
}
|
|
@@ -232,13 +229,49 @@ function validateInterfaceAlignment(workstreamId, workstream, errors) {
|
|
|
232
229
|
if (!repoIdSet.has(repoId)) {
|
|
233
230
|
pushError(
|
|
234
231
|
errors,
|
|
235
|
-
|
|
236
|
-
`workstream "${workstreamId}"
|
|
232
|
+
`${errorPrefix}_repo_unknown`,
|
|
233
|
+
`workstream "${workstreamId}" ${sectionName} references undeclared repo "${repoId}"`,
|
|
237
234
|
);
|
|
238
235
|
}
|
|
239
236
|
}
|
|
240
237
|
}
|
|
241
238
|
|
|
239
|
+
function validateDecisionRequirementBarrier(workstreamId, workstream, errors) {
|
|
240
|
+
if (workstream.completion_barrier === 'interface_alignment') {
|
|
241
|
+
validateDecisionIdsByRepo(
|
|
242
|
+
workstreamId,
|
|
243
|
+
workstream,
|
|
244
|
+
errors,
|
|
245
|
+
'interface_alignment',
|
|
246
|
+
'workstream_interface_alignment',
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (workstream.completion_barrier === 'named_decisions') {
|
|
252
|
+
validateDecisionIdsByRepo(
|
|
253
|
+
workstreamId,
|
|
254
|
+
workstream,
|
|
255
|
+
errors,
|
|
256
|
+
'named_decisions',
|
|
257
|
+
'workstream_named_decisions',
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeDecisionIdsByRepo(section) {
|
|
263
|
+
return section?.decision_ids_by_repo
|
|
264
|
+
? {
|
|
265
|
+
decision_ids_by_repo: Object.fromEntries(
|
|
266
|
+
Object.entries(section.decision_ids_by_repo).map(([repoId, decisionIds]) => [
|
|
267
|
+
repoId,
|
|
268
|
+
Array.isArray(decisionIds) ? [...new Set(decisionIds)] : [],
|
|
269
|
+
]),
|
|
270
|
+
),
|
|
271
|
+
}
|
|
272
|
+
: null;
|
|
273
|
+
}
|
|
274
|
+
|
|
242
275
|
function detectWorkstreamCycles(workstreams, errors) {
|
|
243
276
|
const visiting = new Set();
|
|
244
277
|
const visited = new Set();
|
|
@@ -451,16 +484,8 @@ export function normalizeCoordinatorConfig(raw) {
|
|
|
451
484
|
entry_repo: workstream.entry_repo,
|
|
452
485
|
depends_on: Array.isArray(workstream.depends_on) ? [...new Set(workstream.depends_on)] : [],
|
|
453
486
|
completion_barrier: workstream.completion_barrier,
|
|
454
|
-
interface_alignment: workstream.interface_alignment
|
|
455
|
-
|
|
456
|
-
decision_ids_by_repo: Object.fromEntries(
|
|
457
|
-
Object.entries(workstream.interface_alignment.decision_ids_by_repo).map(([repoId, decisionIds]) => [
|
|
458
|
-
repoId,
|
|
459
|
-
Array.isArray(decisionIds) ? [...new Set(decisionIds)] : [],
|
|
460
|
-
]),
|
|
461
|
-
),
|
|
462
|
-
}
|
|
463
|
-
: null,
|
|
487
|
+
interface_alignment: normalizeDecisionIdsByRepo(workstream.interface_alignment),
|
|
488
|
+
named_decisions: normalizeDecisionIdsByRepo(workstream.named_decisions),
|
|
464
489
|
},
|
|
465
490
|
]),
|
|
466
491
|
),
|
|
@@ -366,7 +366,7 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
|
|
|
366
366
|
barriersChanged = true;
|
|
367
367
|
}
|
|
368
368
|
}
|
|
369
|
-
if (barrier.type === 'interface_alignment') {
|
|
369
|
+
if (barrier.type === 'interface_alignment' || barrier.type === 'named_decisions') {
|
|
370
370
|
const satisfiedRepos = getAlignedReposForBarrier(barrier, fullHistory);
|
|
371
371
|
if (JSON.stringify(barrier.satisfied_repos || []) !== JSON.stringify(satisfiedRepos)) {
|
|
372
372
|
barrier.satisfied_repos = satisfiedRepos;
|
|
@@ -112,6 +112,9 @@ function bootstrapBarriers(config) {
|
|
|
112
112
|
status: 'pending',
|
|
113
113
|
required_repos: [...workstream.repos],
|
|
114
114
|
satisfied_repos: [],
|
|
115
|
+
required_decision_ids_by_repo: workstream.named_decisions?.decision_ids_by_repo
|
|
116
|
+
|| workstream.interface_alignment?.decision_ids_by_repo
|
|
117
|
+
|| null,
|
|
115
118
|
alignment_decision_ids: workstream.interface_alignment?.decision_ids_by_repo || null,
|
|
116
119
|
created_at: new Date().toISOString(),
|
|
117
120
|
};
|
|
@@ -61,6 +61,7 @@ function collectActiveBarriers(barriers, workstreamIds, targetRepoId) {
|
|
|
61
61
|
type: barrier.type || 'unknown',
|
|
62
62
|
status: barrier.status,
|
|
63
63
|
notes: barrier.notes || null,
|
|
64
|
+
required_decision_ids_by_repo: barrier.required_decision_ids_by_repo || barrier.alignment_decision_ids || null,
|
|
64
65
|
alignment_decision_ids: barrier.alignment_decision_ids || null,
|
|
65
66
|
}));
|
|
66
67
|
}
|
|
@@ -78,13 +79,13 @@ function buildRequiredFollowups(workstreamId, dependencyIds, upstreamAcceptances
|
|
|
78
79
|
|
|
79
80
|
for (const barrier of activeBarriers) {
|
|
80
81
|
if (
|
|
81
|
-
barrier.type === 'interface_alignment'
|
|
82
|
-
&& barrier.
|
|
83
|
-
&& Array.isArray(barrier.
|
|
84
|
-
&& barrier.
|
|
82
|
+
(barrier.type === 'interface_alignment' || barrier.type === 'named_decisions')
|
|
83
|
+
&& barrier.required_decision_ids_by_repo
|
|
84
|
+
&& Array.isArray(barrier.required_decision_ids_by_repo[targetRepoId])
|
|
85
|
+
&& barrier.required_decision_ids_by_repo[targetRepoId].length > 0
|
|
85
86
|
) {
|
|
86
87
|
followups.push(
|
|
87
|
-
`Accept declared
|
|
88
|
+
`Accept declared decision requirements for ${targetRepoId}: ${barrier.required_decision_ids_by_repo[targetRepoId].join(', ')}.`,
|
|
88
89
|
);
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -146,12 +147,12 @@ function renderContextMarkdown(snapshot) {
|
|
|
146
147
|
for (const barrier of snapshot.active_barriers) {
|
|
147
148
|
let suffix = '';
|
|
148
149
|
if (
|
|
149
|
-
barrier.type === 'interface_alignment'
|
|
150
|
-
&& barrier.
|
|
151
|
-
&& Array.isArray(barrier.
|
|
152
|
-
&& barrier.
|
|
150
|
+
(barrier.type === 'interface_alignment' || barrier.type === 'named_decisions')
|
|
151
|
+
&& barrier.required_decision_ids_by_repo
|
|
152
|
+
&& Array.isArray(barrier.required_decision_ids_by_repo[snapshot.target_repo_id])
|
|
153
|
+
&& barrier.required_decision_ids_by_repo[snapshot.target_repo_id].length > 0
|
|
153
154
|
) {
|
|
154
|
-
suffix = ` Required decision IDs for ${snapshot.target_repo_id}: ${barrier.
|
|
155
|
+
suffix = ` Required decision IDs for ${snapshot.target_repo_id}: ${barrier.required_decision_ids_by_repo[snapshot.target_repo_id].join(', ')}.`;
|
|
155
156
|
}
|
|
156
157
|
lines.push(`- ${barrier.barrier_id}: ${barrier.type} (${barrier.status})${suffix}`);
|
|
157
158
|
}
|
package/src/lib/report.js
CHANGED
|
@@ -767,14 +767,28 @@ function extractBarrierSummary(artifact) {
|
|
|
767
767
|
return Object.entries(data)
|
|
768
768
|
.filter(([, b]) => b && typeof b === 'object' && !Array.isArray(b))
|
|
769
769
|
.sort(([a], [b]) => a.localeCompare(b, 'en'))
|
|
770
|
-
.map(([barrierId, b]) =>
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
770
|
+
.map(([barrierId, b]) => {
|
|
771
|
+
const entry = {
|
|
772
|
+
barrier_id: barrierId,
|
|
773
|
+
workstream_id: b.workstream_id || null,
|
|
774
|
+
type: b.type || 'unknown',
|
|
775
|
+
status: b.status || 'unknown',
|
|
776
|
+
required_repos: Array.isArray(b.required_repos) ? b.required_repos : [],
|
|
777
|
+
satisfied_repos: Array.isArray(b.satisfied_repos) ? b.satisfied_repos : [],
|
|
778
|
+
};
|
|
779
|
+
const decisionIds =
|
|
780
|
+
b.required_decision_ids_by_repo || b.alignment_decision_ids || null;
|
|
781
|
+
if (decisionIds && typeof decisionIds === 'object' && !Array.isArray(decisionIds)) {
|
|
782
|
+
entry.required_decision_ids_by_repo = decisionIds;
|
|
783
|
+
const satisfiedSet = new Set(entry.satisfied_repos);
|
|
784
|
+
const satisfiedByRepo = {};
|
|
785
|
+
for (const [repo, ids] of Object.entries(decisionIds)) {
|
|
786
|
+
satisfiedByRepo[repo] = satisfiedSet.has(repo) ? [...ids] : [];
|
|
787
|
+
}
|
|
788
|
+
entry.satisfied_decision_ids_by_repo = satisfiedByRepo;
|
|
789
|
+
}
|
|
790
|
+
return entry;
|
|
791
|
+
});
|
|
778
792
|
}
|
|
779
793
|
|
|
780
794
|
function summarizeBarrierTransition(entry) {
|
|
@@ -1442,6 +1456,17 @@ export function formatGovernanceReportText(report) {
|
|
|
1442
1456
|
const satisfied = b.satisfied_repos.length;
|
|
1443
1457
|
const required = b.required_repos.length;
|
|
1444
1458
|
lines.push(` - ${b.barrier_id}: ${b.status} (${b.type}, ${satisfied}/${required} repos satisfied, workstream ${b.workstream_id || 'unknown'})`);
|
|
1459
|
+
if (b.required_decision_ids_by_repo) {
|
|
1460
|
+
lines.push(' Decision requirements:');
|
|
1461
|
+
for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
|
|
1462
|
+
if (!Array.isArray(ids) || ids.length === 0) continue;
|
|
1463
|
+
const satisfiedIds = new Set(
|
|
1464
|
+
Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : []
|
|
1465
|
+
);
|
|
1466
|
+
const labels = ids.map((id) => `${id} (${satisfiedIds.has(id) ? 'satisfied' : 'pending'})`);
|
|
1467
|
+
lines.push(` ${repo}: ${labels.join(', ')}`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1445
1470
|
}
|
|
1446
1471
|
}
|
|
1447
1472
|
|
|
@@ -1917,6 +1942,20 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1917
1942
|
for (const b of barrier_summary) {
|
|
1918
1943
|
mdLines.push(`| \`${b.barrier_id}\` | \`${b.workstream_id || 'unknown'}\` | \`${b.type}\` | \`${b.status}\` | ${b.satisfied_repos.length}/${b.required_repos.length} repos |`);
|
|
1919
1944
|
}
|
|
1945
|
+
const barriersWithDecisions = barrier_summary.filter((b) => b.required_decision_ids_by_repo);
|
|
1946
|
+
if (barriersWithDecisions.length > 0) {
|
|
1947
|
+
mdLines.push('', '### Decision Requirements');
|
|
1948
|
+
for (const b of barriersWithDecisions) {
|
|
1949
|
+
mdLines.push('', `**\`${b.barrier_id}\`** decision requirements:`, '', '| Repo | Required | Satisfied |', '|------|----------|-----------|');
|
|
1950
|
+
for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
|
|
1951
|
+
if (!Array.isArray(ids) || ids.length === 0) continue;
|
|
1952
|
+
const satisfiedIds = Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : [];
|
|
1953
|
+
const reqStr = ids.map((id) => `\`${id}\``).join(', ');
|
|
1954
|
+
const satStr = satisfiedIds.length > 0 ? satisfiedIds.map((id) => `\`${id}\``).join(', ') : '—';
|
|
1955
|
+
mdLines.push(`| \`${repo}\` | ${reqStr} | ${satStr} |`);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1920
1959
|
}
|
|
1921
1960
|
|
|
1922
1961
|
if (barrier_ledger_timeline && barrier_ledger_timeline.length > 0) {
|