agentxchain 2.103.0 → 2.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/bin/agentxchain.js +16 -8
- package/dashboard/app.js +111 -7
- package/dashboard/components/blocked.js +95 -11
- package/dashboard/components/blockers.js +85 -86
- package/dashboard/components/coordinator-timeouts.js +13 -0
- package/dashboard/components/cross-repo.js +17 -12
- package/dashboard/components/gate.js +31 -11
- package/dashboard/components/initiative.js +173 -78
- package/dashboard/components/ledger.js +28 -0
- package/dashboard/components/live-status.js +39 -0
- package/dashboard/components/run-history.js +76 -1
- package/dashboard/components/timeline.js +5 -1
- package/dashboard/index.html +21 -0
- package/dashboard/live-observer.js +91 -0
- package/package.json +1 -1
- package/scripts/release-bump.sh +26 -3
- package/scripts/release-preflight.sh +82 -38
- package/src/commands/accept-turn.js +3 -3
- package/src/commands/decisions.js +98 -29
- package/src/commands/diff.js +27 -4
- package/src/commands/doctor.js +48 -16
- package/src/commands/generate.js +126 -1
- package/src/commands/history.js +21 -3
- package/src/commands/init.js +15 -97
- package/src/commands/multi.js +223 -54
- package/src/commands/phase.js +11 -13
- package/src/commands/reject-turn.js +1 -1
- package/src/commands/restart.js +28 -11
- package/src/commands/resume.js +6 -6
- package/src/commands/role.js +51 -14
- package/src/commands/run.js +5 -11
- package/src/commands/status.js +145 -13
- package/src/commands/step.js +36 -29
- package/src/lib/admission-control.js +14 -12
- package/src/lib/blocked-state.js +150 -0
- package/src/lib/conflict-actions.js +17 -0
- package/src/lib/context-section-parser.js +2 -0
- package/src/lib/continuity-status.js +1 -1
- package/src/lib/coordinator-blocker-presentation.js +127 -0
- package/src/lib/coordinator-event-narrative.js +43 -0
- package/src/lib/coordinator-gate-approval.js +98 -0
- package/src/lib/coordinator-gate-evaluation-presentation.js +57 -0
- package/src/lib/coordinator-next-actions.js +128 -0
- package/src/lib/coordinator-pending-gate-presentation.js +79 -0
- package/src/lib/coordinator-presentation-detail.js +11 -0
- package/src/lib/coordinator-repo-snapshots.js +53 -0
- package/src/lib/coordinator-repo-status-presentation.js +134 -0
- package/src/lib/dashboard/actions.js +105 -29
- package/src/lib/dashboard/bridge-server.js +7 -0
- package/src/lib/dashboard/coordinator-blockers.js +17 -0
- package/src/lib/dashboard/coordinator-repo-status.js +50 -0
- package/src/lib/dashboard/coordinator-timeout-status.js +34 -11
- package/src/lib/dashboard/state-reader.js +36 -1
- package/src/lib/dispatch-bundle.js +23 -0
- package/src/lib/export-diff.js +70 -38
- package/src/lib/export-verifier.js +3 -0
- package/src/lib/history-diff-summary.js +249 -0
- package/src/lib/manual-qa-fallback.js +18 -0
- package/src/lib/normalized-config.js +27 -22
- package/src/lib/planning-artifacts.js +131 -0
- package/src/lib/recent-event-summary.js +132 -0
- package/src/lib/repo-decisions.js +69 -28
- package/src/lib/report.js +353 -145
- package/src/lib/run-diff.js +4 -0
- package/src/lib/runtime-capabilities.js +222 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
function normalizeSingleLine(value) {
|
|
2
|
+
if (typeof value !== 'string') return null;
|
|
3
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
4
|
+
return normalized.length > 0 ? normalized : null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildRunOutcomeSummary(entry) {
|
|
8
|
+
const status = typeof entry?.status === 'string' ? entry.status : 'unknown';
|
|
9
|
+
const nextAction = normalizeSingleLine(
|
|
10
|
+
entry?.retrospective?.next_operator_action
|
|
11
|
+
|| entry?.retrospective?.follow_on_hint
|
|
12
|
+
|| null,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
if (status === 'blocked' && nextAction) {
|
|
16
|
+
return { label: 'operator', status, next_action: nextAction };
|
|
17
|
+
}
|
|
18
|
+
if (status === 'blocked') {
|
|
19
|
+
return { label: 'blocked', status, next_action: null };
|
|
20
|
+
}
|
|
21
|
+
if (status === 'completed' && nextAction) {
|
|
22
|
+
return { label: 'follow-on', status, next_action: nextAction };
|
|
23
|
+
}
|
|
24
|
+
if (status === 'completed') {
|
|
25
|
+
return { label: 'clean', status, next_action: null };
|
|
26
|
+
}
|
|
27
|
+
return { label: 'unknown', status, next_action: nextAction };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildRunDiffSummary(diff) {
|
|
31
|
+
if (!diff?.changed) {
|
|
32
|
+
return {
|
|
33
|
+
outcome: 'unchanged',
|
|
34
|
+
risk_level: 'none',
|
|
35
|
+
highlights: [],
|
|
36
|
+
changed_field_count: 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const regressionSignals = [];
|
|
41
|
+
const improvementSignals = [];
|
|
42
|
+
const changeSignals = [];
|
|
43
|
+
|
|
44
|
+
const statusChange = diff.scalar_changes?.status;
|
|
45
|
+
if (statusChange?.changed) {
|
|
46
|
+
const leftRank = getRunStatusRank(statusChange.left);
|
|
47
|
+
const rightRank = getRunStatusRank(statusChange.right);
|
|
48
|
+
if (rightRank < leftRank) {
|
|
49
|
+
regressionSignals.push(`status worsened to ${statusChange.right}`);
|
|
50
|
+
} else if (rightRank > leftRank) {
|
|
51
|
+
improvementSignals.push(`status improved to ${statusChange.right}`);
|
|
52
|
+
} else {
|
|
53
|
+
changeSignals.push(`status changed to ${statusChange.right}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const blockedReason = diff.scalar_changes?.blocked_reason;
|
|
58
|
+
if (blockedReason?.changed && blockedReason.right) {
|
|
59
|
+
regressionSignals.push(`blocked reason: ${blockedReason.right}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nextAction = diff.scalar_changes?.next_action;
|
|
63
|
+
if (nextAction?.changed) {
|
|
64
|
+
if (nextAction.right) {
|
|
65
|
+
changeSignals.push(`next action: ${nextAction.right}`);
|
|
66
|
+
} else {
|
|
67
|
+
improvementSignals.push('operator follow-up cleared');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const gateChange of diff.gate_changes || []) {
|
|
72
|
+
if (!gateChange.changed) continue;
|
|
73
|
+
if (isBlockingGateState(gateChange.right) && !isBlockingGateState(gateChange.left)) {
|
|
74
|
+
regressionSignals.push(`gate ${gateChange.gate_id} is ${gateChange.right}`);
|
|
75
|
+
} else if (isPassingGateState(gateChange.right) && isBlockingGateState(gateChange.left)) {
|
|
76
|
+
improvementSignals.push(`gate ${gateChange.gate_id} recovered to ${gateChange.right}`);
|
|
77
|
+
} else {
|
|
78
|
+
changeSignals.push(`gate ${gateChange.gate_id}: ${gateChange.left ?? '—'} -> ${gateChange.right ?? '—'}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const phases = diff.list_changes?.phases_completed;
|
|
83
|
+
if (phases?.changed) {
|
|
84
|
+
if (phases.added.length > 0) {
|
|
85
|
+
changeSignals.push(`phases added: ${phases.added.join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
if (phases.removed.length > 0) {
|
|
88
|
+
regressionSignals.push(`phases removed: ${phases.removed.join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const roles = diff.list_changes?.roles_used;
|
|
93
|
+
if (roles?.changed) {
|
|
94
|
+
if (roles.added.length > 0) {
|
|
95
|
+
changeSignals.push(`roles added: ${roles.added.join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
if (roles.removed.length > 0) {
|
|
98
|
+
changeSignals.push(`roles removed: ${roles.removed.join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cost = diff.numeric_changes?.total_cost_usd;
|
|
103
|
+
if (cost?.changed) {
|
|
104
|
+
changeSignals.push(`cost ${formatSignedNumber(cost.delta, 4, '$')}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const duration = diff.numeric_changes?.duration_ms;
|
|
108
|
+
if (duration?.changed) {
|
|
109
|
+
changeSignals.push(`duration ${formatDurationDelta(duration.delta)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let outcome = 'changed';
|
|
113
|
+
let riskLevel = 'low';
|
|
114
|
+
if (regressionSignals.length > 0 && improvementSignals.length > 0) {
|
|
115
|
+
outcome = 'mixed';
|
|
116
|
+
riskLevel = regressionSignals.some((signal) => signal.startsWith('status worsened') || signal.startsWith('gate '))
|
|
117
|
+
? 'high'
|
|
118
|
+
: 'medium';
|
|
119
|
+
} else if (regressionSignals.length > 0) {
|
|
120
|
+
outcome = 'regressed';
|
|
121
|
+
riskLevel = regressionSignals.some((signal) => signal.startsWith('status worsened') || signal.startsWith('gate '))
|
|
122
|
+
? 'high'
|
|
123
|
+
: 'medium';
|
|
124
|
+
} else if (improvementSignals.length > 0) {
|
|
125
|
+
outcome = 'improved';
|
|
126
|
+
riskLevel = changeSignals.length > 0 ? 'low' : 'none';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
outcome,
|
|
131
|
+
risk_level: riskLevel,
|
|
132
|
+
highlights: [...regressionSignals, ...improvementSignals, ...changeSignals].slice(0, 3),
|
|
133
|
+
changed_field_count: countChangedFields(diff),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildExportDiffSummary(diff) {
|
|
138
|
+
if (!diff?.changed) {
|
|
139
|
+
return {
|
|
140
|
+
outcome: 'unchanged',
|
|
141
|
+
risk_level: 'none',
|
|
142
|
+
highlights: [],
|
|
143
|
+
changed_field_count: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const regressions = Array.isArray(diff.regressions) ? diff.regressions : [];
|
|
148
|
+
const highlights = [];
|
|
149
|
+
|
|
150
|
+
if (regressions.length > 0) {
|
|
151
|
+
for (const reg of regressions.slice(0, 3)) {
|
|
152
|
+
highlights.push(`${reg.id}: ${reg.message}`);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
outcome: 'regressed',
|
|
156
|
+
risk_level: regressions.some((reg) => reg.severity === 'error') ? 'high' : 'medium',
|
|
157
|
+
highlights,
|
|
158
|
+
changed_field_count: countChangedFields(diff),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pushFirstChangeHighlights(diff, highlights);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
outcome: 'changed',
|
|
166
|
+
risk_level: 'low',
|
|
167
|
+
highlights: highlights.slice(0, 3),
|
|
168
|
+
changed_field_count: countChangedFields(diff),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pushFirstChangeHighlights(diff, highlights) {
|
|
173
|
+
const scalarChanges = Object.values(diff.scalar_changes || {}).filter((entry) => entry.changed);
|
|
174
|
+
for (const entry of scalarChanges) {
|
|
175
|
+
if (highlights.length >= 3) return;
|
|
176
|
+
highlights.push(`${entry.label}: ${entry.left ?? '—'} -> ${entry.right ?? '—'}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const numericChanges = Object.values(diff.numeric_changes || {}).filter((entry) => entry.changed);
|
|
180
|
+
for (const entry of numericChanges) {
|
|
181
|
+
if (highlights.length >= 3) return;
|
|
182
|
+
highlights.push(`${entry.label}: ${entry.left ?? '—'} -> ${entry.right ?? '—'}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const listChanges = Object.values(diff.list_changes || {}).filter((entry) => entry.changed);
|
|
186
|
+
for (const entry of listChanges) {
|
|
187
|
+
if (highlights.length >= 3) return;
|
|
188
|
+
if (entry.added?.length > 0) {
|
|
189
|
+
highlights.push(`${entry.label} added: ${entry.added.join(', ')}`);
|
|
190
|
+
} else if (entry.removed?.length > 0) {
|
|
191
|
+
highlights.push(`${entry.label} removed: ${entry.removed.join(', ')}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function countChangedFields(diff) {
|
|
197
|
+
const scalar = Object.values(diff.scalar_changes || {}).filter((entry) => entry.changed).length;
|
|
198
|
+
const numeric = Object.values(diff.numeric_changes || {}).filter((entry) => entry.changed).length;
|
|
199
|
+
const lists = Object.values(diff.list_changes || {}).filter((entry) => entry.changed).length;
|
|
200
|
+
const gates = Array.isArray(diff.gate_changes)
|
|
201
|
+
? diff.gate_changes.filter((entry) => entry.changed).length
|
|
202
|
+
: 0;
|
|
203
|
+
const repoStatuses = Array.isArray(diff.repo_status_changes)
|
|
204
|
+
? diff.repo_status_changes.filter((entry) => entry.changed).length
|
|
205
|
+
: 0;
|
|
206
|
+
const repoExports = Array.isArray(diff.repo_export_changes)
|
|
207
|
+
? diff.repo_export_changes.filter((entry) => entry.changed).length
|
|
208
|
+
: 0;
|
|
209
|
+
const eventTypes = Array.isArray(diff.event_type_changes)
|
|
210
|
+
? diff.event_type_changes.filter((entry) => entry.changed).length
|
|
211
|
+
: 0;
|
|
212
|
+
return scalar + numeric + lists + gates + repoStatuses + repoExports + eventTypes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getRunStatusRank(status) {
|
|
216
|
+
if (status === 'completed') return 3;
|
|
217
|
+
if (status === 'blocked') return 2;
|
|
218
|
+
if (status === 'failed') return 1;
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isBlockingGateState(value) {
|
|
223
|
+
return value === 'failed' || value === 'blocked' || value === 'pending';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isPassingGateState(value) {
|
|
227
|
+
return value === 'passed' || value === 'approved';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatSignedNumber(value, digits, prefix = '') {
|
|
231
|
+
if (typeof value !== 'number' || Number.isNaN(value) || value === 0) return 'unchanged';
|
|
232
|
+
const sign = value > 0 ? '+' : '';
|
|
233
|
+
return `${sign}${prefix}${Math.abs(value).toFixed(digits)}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function formatDurationDelta(ms) {
|
|
237
|
+
if (typeof ms !== 'number' || Number.isNaN(ms) || ms === 0) return 'unchanged';
|
|
238
|
+
const sign = ms > 0 ? '+' : '-';
|
|
239
|
+
const absolute = Math.abs(ms);
|
|
240
|
+
if (absolute < 1000) return `${sign}${absolute}ms`;
|
|
241
|
+
const secs = Math.floor(absolute / 1000);
|
|
242
|
+
if (secs < 60) return `${sign}${secs}s`;
|
|
243
|
+
const mins = Math.floor(secs / 60);
|
|
244
|
+
const remainSecs = secs % 60;
|
|
245
|
+
if (mins < 60) return `${sign}${mins}m ${remainSecs}s`;
|
|
246
|
+
const hrs = Math.floor(mins / 60);
|
|
247
|
+
const remainMins = mins % 60;
|
|
248
|
+
return `${sign}${hrs}h ${remainMins}m`;
|
|
249
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow recovery hint for the built-in manual QA fallback path.
|
|
3
|
+
*
|
|
4
|
+
* The check must use normalized config so operator-facing guidance stays
|
|
5
|
+
* consistent across commands and does not depend on raw config exceptions.
|
|
6
|
+
*/
|
|
7
|
+
export function shouldSuggestManualQaFallback({
|
|
8
|
+
roleId,
|
|
9
|
+
runtimeId,
|
|
10
|
+
classified,
|
|
11
|
+
config,
|
|
12
|
+
}) {
|
|
13
|
+
return classified?.error_class === 'missing_credentials'
|
|
14
|
+
&& roleId === 'qa'
|
|
15
|
+
&& runtimeId === 'api-qa'
|
|
16
|
+
&& config?.roles?.qa?.runtime_id === 'api-qa'
|
|
17
|
+
&& config?.runtimes?.['manual-qa']?.type === 'manual';
|
|
18
|
+
}
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
import { validateHooksConfig } from './hook-runner.js';
|
|
16
16
|
import { validateNotificationsConfig } from './notification-runner.js';
|
|
17
17
|
import { validatePolicies, normalizePolicies } from './policy-evaluator.js';
|
|
18
|
+
import {
|
|
19
|
+
canRoleParticipateInRequiredFileProduction,
|
|
20
|
+
canRoleSatisfyWorkflowArtifactOwnership,
|
|
21
|
+
getRoleRuntimeCapabilityContract,
|
|
22
|
+
} from './runtime-capabilities.js';
|
|
18
23
|
import { validateTimeoutsConfig } from './timeout-evaluator.js';
|
|
19
24
|
import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
|
|
20
25
|
import {
|
|
@@ -455,21 +460,12 @@ export function validateV4Config(data, projectRoot) {
|
|
|
455
460
|
// Cross-reference: review_only roles should not use authoritative runtimes
|
|
456
461
|
if (data.roles && data.runtimes) {
|
|
457
462
|
for (const [id, role] of Object.entries(data.roles)) {
|
|
458
|
-
if (role.write_authority === 'review_only' && role.runtime && data.runtimes[role.runtime]) {
|
|
459
|
-
const rt = data.runtimes[role.runtime];
|
|
460
|
-
if (rt.type === 'local_cli') {
|
|
461
|
-
errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
// api_proxy and remote_agent restriction: only review_only and proposed roles may bind.
|
|
465
|
-
// These adapters do not have a proven local workspace mutation path in v1.
|
|
466
463
|
if (role.runtime && data.runtimes[role.runtime]) {
|
|
467
464
|
const rt = data.runtimes[role.runtime];
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
) {
|
|
465
|
+
const contract = getRoleRuntimeCapabilityContract(id, role, rt);
|
|
466
|
+
if (contract.effective_write_path === 'invalid_review_only_binding') {
|
|
467
|
+
errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
|
|
468
|
+
} else if (contract.effective_write_path === 'invalid_authoritative_binding') {
|
|
473
469
|
errors.push(
|
|
474
470
|
`Role "${id}" has write_authority "${role.write_authority}" but uses ${rt.type} runtime "${role.runtime}" — ${rt.type} only supports review_only and proposed roles`
|
|
475
471
|
);
|
|
@@ -534,7 +530,7 @@ export function validateV4Config(data, projectRoot) {
|
|
|
534
530
|
|
|
535
531
|
// Workflow Kit (optional but validated if present)
|
|
536
532
|
if (data.workflow_kit !== undefined) {
|
|
537
|
-
const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing, data.roles);
|
|
533
|
+
const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing, data.roles, data.runtimes);
|
|
538
534
|
errors.push(...wkValidation.errors);
|
|
539
535
|
}
|
|
540
536
|
|
|
@@ -697,7 +693,7 @@ export function validateSchedulesConfig(schedules, roles) {
|
|
|
697
693
|
* Validate the workflow_kit config section.
|
|
698
694
|
* Returns { ok, errors, warnings }.
|
|
699
695
|
*/
|
|
700
|
-
export function validateWorkflowKitConfig(wk, routing, roles) {
|
|
696
|
+
export function validateWorkflowKitConfig(wk, routing, roles, runtimes = {}) {
|
|
701
697
|
const errors = [];
|
|
702
698
|
const warnings = [];
|
|
703
699
|
|
|
@@ -837,20 +833,25 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
|
|
|
837
833
|
} else if (
|
|
838
834
|
artifact.required !== false &&
|
|
839
835
|
roles && typeof roles === 'object' &&
|
|
840
|
-
roles[artifact.owned_by]
|
|
836
|
+
roles[artifact.owned_by]
|
|
841
837
|
) {
|
|
842
|
-
|
|
838
|
+
const ownerRole = roles[artifact.owned_by];
|
|
839
|
+
const ownerRuntimeKey = ownerRole.runtime_id || ownerRole.runtime;
|
|
840
|
+
const ownerRuntime = runtimes?.[ownerRuntimeKey];
|
|
843
841
|
const phaseRouting = routing?.[phase];
|
|
844
842
|
const phaseRoles = new Set([
|
|
845
843
|
...(phaseRouting?.allowed_next_roles || []),
|
|
846
844
|
...(phaseRouting?.entry_role ? [phaseRouting.entry_role] : []),
|
|
847
845
|
]);
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
846
|
+
const hasReachableProducer = [...phaseRoles].some((rid) => {
|
|
847
|
+
const phaseRole = roles[rid];
|
|
848
|
+
if (!phaseRole) return false;
|
|
849
|
+
const phaseRuntimeKey = phaseRole.runtime_id || phaseRole.runtime;
|
|
850
|
+
return canRoleParticipateInRequiredFileProduction(phaseRole, runtimes?.[phaseRuntimeKey]);
|
|
851
|
+
});
|
|
852
|
+
if (!canRoleSatisfyWorkflowArtifactOwnership(ownerRole, ownerRuntime) && !hasReachableProducer) {
|
|
852
853
|
warnings.push(
|
|
853
|
-
`${prefix} owned_by "${artifact.owned_by}"
|
|
854
|
+
`${prefix} owned_by "${artifact.owned_by}" has no reachable workflow ownership path in phase "${phase}", and no routed role can satisfy the required artifact`,
|
|
854
855
|
);
|
|
855
856
|
}
|
|
856
857
|
}
|
|
@@ -1053,6 +1054,9 @@ export function normalizeV4(raw) {
|
|
|
1053
1054
|
title: role.title,
|
|
1054
1055
|
mandate: role.mandate,
|
|
1055
1056
|
write_authority: role.write_authority,
|
|
1057
|
+
...(typeof role.decision_authority === 'number'
|
|
1058
|
+
? { decision_authority: role.decision_authority }
|
|
1059
|
+
: {}),
|
|
1056
1060
|
runtime_class: raw.runtimes?.[role.runtime]?.type || 'manual',
|
|
1057
1061
|
runtime_id: role.runtime,
|
|
1058
1062
|
};
|
|
@@ -1205,6 +1209,7 @@ export function normalizeWorkflowKit(raw, routingPhases) {
|
|
|
1205
1209
|
if (raw.phases) {
|
|
1206
1210
|
for (const [phase, phaseConfig] of Object.entries(raw.phases)) {
|
|
1207
1211
|
phases[phase] = {
|
|
1212
|
+
...(typeof phaseConfig?.template === 'string' ? { template: phaseConfig.template } : {}),
|
|
1208
1213
|
artifacts: expandWorkflowKitPhaseArtifacts(phaseConfig).map(a => ({
|
|
1209
1214
|
path: a.path,
|
|
1210
1215
|
semantics: a.semantics || null,
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { buildSystemSpecContent } from './governed-templates.js';
|
|
3
|
+
|
|
4
|
+
export const GOVERNED_BASELINE_PLANNING_PATHS = Object.freeze([
|
|
5
|
+
'.planning/PM_SIGNOFF.md',
|
|
6
|
+
'.planning/ROADMAP.md',
|
|
7
|
+
'.planning/SYSTEM_SPEC.md',
|
|
8
|
+
'.planning/IMPLEMENTATION_NOTES.md',
|
|
9
|
+
'.planning/acceptance-matrix.md',
|
|
10
|
+
'.planning/ship-verdict.md',
|
|
11
|
+
'.planning/RELEASE_NOTES.md',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const PHASE_DISPLAY_NAMES = Object.freeze({
|
|
15
|
+
qa: 'QA',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function formatPhaseDisplayName(phaseKey) {
|
|
19
|
+
if (PHASE_DISPLAY_NAMES[phaseKey]) {
|
|
20
|
+
return PHASE_DISPLAY_NAMES[phaseKey];
|
|
21
|
+
}
|
|
22
|
+
return phaseKey.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildRoadmapPhaseTable(routing, roles) {
|
|
26
|
+
const rows = Object.entries(routing).map(([phaseKey, phaseConfig]) => {
|
|
27
|
+
const phaseName = formatPhaseDisplayName(phaseKey);
|
|
28
|
+
const entryRole = phaseConfig.entry_role;
|
|
29
|
+
const role = roles[entryRole];
|
|
30
|
+
const goal = role?.mandate || phaseName;
|
|
31
|
+
const status = phaseKey === Object.keys(routing)[0] ? 'In progress' : 'Pending';
|
|
32
|
+
return `| ${phaseName} | ${goal} | ${status} |`;
|
|
33
|
+
});
|
|
34
|
+
return `| Phase | Goal | Status |\n|-------|------|--------|\n${rows.join('\n')}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function interpolateTemplateContent(contentTemplate, projectName) {
|
|
38
|
+
return contentTemplate.replaceAll('{{project_name}}', projectName);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function appendAcceptanceHints(baseMatrix, acceptanceHints) {
|
|
42
|
+
if (!Array.isArray(acceptanceHints) || acceptanceHints.length === 0) {
|
|
43
|
+
return baseMatrix;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hintLines = acceptanceHints.map((hint) => `- [ ] ${hint}`).join('\n');
|
|
47
|
+
return `${baseMatrix}\n\n## Template Guidance\n${hintLines}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function generateWorkflowKitPlaceholder(artifact, projectName) {
|
|
51
|
+
const filename = basename(artifact.path);
|
|
52
|
+
const title = filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
|
53
|
+
|
|
54
|
+
if (artifact.semantics === 'section_check' && artifact.semantics_config?.required_sections?.length) {
|
|
55
|
+
const sections = artifact.semantics_config.required_sections
|
|
56
|
+
.map((section) => `${section}\n\n(Content here.)\n`)
|
|
57
|
+
.join('\n');
|
|
58
|
+
return `# ${title} — ${projectName}\n\n${sections}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return `# ${title} — ${projectName}\n\n(Operator fills this in.)\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildGovernedPlanningArtifacts({ projectName, routing, roles, template, workflowKitConfig }) {
|
|
65
|
+
const artifacts = [
|
|
66
|
+
{
|
|
67
|
+
path: '.planning/PM_SIGNOFF.md',
|
|
68
|
+
source: 'core',
|
|
69
|
+
content: `# PM Signoff — ${projectName}\n\nApproved: NO\n\n> This scaffold starts blocked on purpose. Change this to \`Approved: YES\` only after a human reviews the planning artifacts and is ready to open the planning gate.\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
path: '.planning/ROADMAP.md',
|
|
73
|
+
source: 'core',
|
|
74
|
+
content: `# Roadmap — ${projectName}\n\n## Phases\n\n${buildRoadmapPhaseTable(routing, roles)}`,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
path: '.planning/SYSTEM_SPEC.md',
|
|
78
|
+
source: 'core',
|
|
79
|
+
content: buildSystemSpecContent(projectName, template?.system_spec_overlay),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
path: '.planning/IMPLEMENTATION_NOTES.md',
|
|
83
|
+
source: 'core',
|
|
84
|
+
content: `# Implementation Notes — ${projectName}\n\n## Changes\n\n(Dev fills this during implementation)\n\n## Verification\n\n(Dev fills this during implementation)\n\n## Unresolved Follow-ups\n\n(Dev lists any known gaps, tech debt, or follow-up items here.)\n`,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
path: '.planning/acceptance-matrix.md',
|
|
88
|
+
source: 'core',
|
|
89
|
+
content: appendAcceptanceHints(
|
|
90
|
+
`# Acceptance Matrix — ${projectName}\n\n| Req # | Requirement | Acceptance criteria | Test status | Last tested | Status |\n|-------|-------------|-------------------|-------------|-------------|--------|\n| (QA fills this from ROADMAP.md) | | | | | |\n`,
|
|
91
|
+
template?.acceptance_hints,
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
path: '.planning/ship-verdict.md',
|
|
96
|
+
source: 'core',
|
|
97
|
+
content: `# Ship Verdict — ${projectName}\n\n## Verdict: PENDING\n\n## QA Summary\n\n(QA writes the final ship/no-ship assessment here.)\n\n## Open Blockers\n\n(List any blocking issues.)\n\n## Conditions\n\n(List any conditions for shipping.)\n`,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
path: '.planning/RELEASE_NOTES.md',
|
|
101
|
+
source: 'core',
|
|
102
|
+
content: `# Release Notes — ${projectName}\n\n## User Impact\n\n(QA fills this during the QA phase)\n\n## Verification Summary\n\n(QA fills this during the QA phase)\n\n## Upgrade Notes\n\n(QA fills this during the QA phase)\n\n## Known Issues\n\n(QA fills this during the QA phase)\n`,
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const artifact of template?.planning_artifacts || []) {
|
|
107
|
+
artifacts.push({
|
|
108
|
+
path: `.planning/${artifact.filename}`,
|
|
109
|
+
source: 'template',
|
|
110
|
+
content: interpolateTemplateContent(artifact.content_template, projectName),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const seenPaths = new Set(GOVERNED_BASELINE_PLANNING_PATHS);
|
|
115
|
+
if (workflowKitConfig?.phases && typeof workflowKitConfig.phases === 'object') {
|
|
116
|
+
for (const phaseConfig of Object.values(workflowKitConfig.phases)) {
|
|
117
|
+
if (!Array.isArray(phaseConfig.artifacts)) continue;
|
|
118
|
+
for (const artifact of phaseConfig.artifacts) {
|
|
119
|
+
if (!artifact.path || seenPaths.has(artifact.path)) continue;
|
|
120
|
+
seenPaths.add(artifact.path);
|
|
121
|
+
artifacts.push({
|
|
122
|
+
path: artifact.path,
|
|
123
|
+
source: 'workflow_kit',
|
|
124
|
+
content: generateWorkflowKitPlaceholder(artifact, projectName),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return artifacts;
|
|
131
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readRunEvents } from './run-events.js';
|
|
2
|
+
|
|
3
|
+
export const RECENT_EVENT_WINDOW_MINUTES = 15;
|
|
4
|
+
export const RECENT_EVENT_WINDOW_MS = RECENT_EVENT_WINDOW_MINUTES * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
function isObject(value) {
|
|
7
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isValidTimestamp(timestamp) {
|
|
11
|
+
return typeof timestamp === 'string' && timestamp.trim().length > 0 && !Number.isNaN(new Date(timestamp).getTime());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function trimToNull(value) {
|
|
15
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function describeEvent(eventType, entry) {
|
|
19
|
+
const repoId = trimToNull(entry.repo_id);
|
|
20
|
+
const roleId = trimToNull(entry.role_id);
|
|
21
|
+
const gateId = trimToNull(entry.payload?.gate_id) || trimToNull(entry.gate);
|
|
22
|
+
const prefix = repoId ? `[${repoId}] ` : '';
|
|
23
|
+
|
|
24
|
+
switch (eventType) {
|
|
25
|
+
case 'turn_dispatched':
|
|
26
|
+
case 'turn_accepted':
|
|
27
|
+
case 'turn_rejected':
|
|
28
|
+
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
29
|
+
case 'phase_entered': {
|
|
30
|
+
const fromPhase = trimToNull(entry.payload?.from);
|
|
31
|
+
const toPhase = trimToNull(entry.payload?.to) || trimToNull(entry.phase);
|
|
32
|
+
if (fromPhase && toPhase) return `${prefix}${eventType} ${fromPhase} -> ${toPhase}`;
|
|
33
|
+
return `${prefix}${eventType}`;
|
|
34
|
+
}
|
|
35
|
+
case 'gate_pending':
|
|
36
|
+
case 'gate_approved':
|
|
37
|
+
case 'gate_failed':
|
|
38
|
+
return `${prefix}${eventType}${gateId ? ` (${gateId})` : ''}`;
|
|
39
|
+
case 'run_blocked':
|
|
40
|
+
case 'run_completed':
|
|
41
|
+
case 'run_started':
|
|
42
|
+
case 'escalation_raised':
|
|
43
|
+
case 'escalation_resolved':
|
|
44
|
+
case 'budget_exceeded_warn':
|
|
45
|
+
return `${prefix}${eventType}`;
|
|
46
|
+
default:
|
|
47
|
+
if (trimToNull(entry.summary)) return entry.summary.trim();
|
|
48
|
+
return `${prefix}${eventType || 'unknown_event'}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeRecentEventEntry(entry) {
|
|
53
|
+
if (!isObject(entry)) return null;
|
|
54
|
+
const turn = isObject(entry.turn) ? entry.turn : null;
|
|
55
|
+
const eventType = trimToNull(entry.event_type) || trimToNull(entry.type) || 'unknown_event';
|
|
56
|
+
const phase = trimToNull(entry.phase);
|
|
57
|
+
const status = trimToNull(entry.status);
|
|
58
|
+
const turnId = trimToNull(turn?.turn_id) || trimToNull(entry.turn_id) || null;
|
|
59
|
+
const roleId = trimToNull(turn?.role_id)
|
|
60
|
+
|| trimToNull(turn?.assigned_role)
|
|
61
|
+
|| trimToNull(entry.role_id)
|
|
62
|
+
|| trimToNull(entry.role)
|
|
63
|
+
|| null;
|
|
64
|
+
const repoId = trimToNull(entry.repo_id);
|
|
65
|
+
const timestamp = trimToNull(entry.timestamp);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
event_type: eventType,
|
|
69
|
+
timestamp,
|
|
70
|
+
phase,
|
|
71
|
+
status,
|
|
72
|
+
turn_id: turnId,
|
|
73
|
+
role_id: roleId,
|
|
74
|
+
repo_id: repoId,
|
|
75
|
+
summary: describeEvent(eventType, { ...entry, role_id: roleId, repo_id: repoId, turn }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildRecentEventSummary(entries, { now = Date.now(), windowMs = RECENT_EVENT_WINDOW_MS } = {}) {
|
|
80
|
+
const normalized = Array.isArray(entries)
|
|
81
|
+
? entries.map(normalizeRecentEventEntry).filter(Boolean)
|
|
82
|
+
: [];
|
|
83
|
+
|
|
84
|
+
if (normalized.length === 0) {
|
|
85
|
+
return {
|
|
86
|
+
window_minutes: Math.round(windowMs / 60000),
|
|
87
|
+
freshness: 'no_events',
|
|
88
|
+
recent_count: 0,
|
|
89
|
+
latest_event: null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const latestEvent = normalized[normalized.length - 1];
|
|
94
|
+
const recentCount = normalized.filter((event) => {
|
|
95
|
+
if (!isValidTimestamp(event.timestamp)) return false;
|
|
96
|
+
return (now - new Date(event.timestamp).getTime()) <= windowMs;
|
|
97
|
+
}).length;
|
|
98
|
+
|
|
99
|
+
let freshness = 'unknown';
|
|
100
|
+
if (isValidTimestamp(latestEvent.timestamp)) {
|
|
101
|
+
freshness = (now - new Date(latestEvent.timestamp).getTime()) <= windowMs ? 'recent' : 'quiet';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
window_minutes: Math.round(windowMs / 60000),
|
|
106
|
+
freshness,
|
|
107
|
+
recent_count: recentCount,
|
|
108
|
+
latest_event: latestEvent,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function readRecentRunEventSummary(root, opts = {}) {
|
|
113
|
+
const events = readRunEvents(root);
|
|
114
|
+
return buildRecentEventSummary(events, opts);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatRecentEventSummaryLine(summary, scopeLabel = null) {
|
|
118
|
+
const prefix = scopeLabel ? `${scopeLabel}: ` : '';
|
|
119
|
+
if (!summary || typeof summary !== 'object') return `${prefix}unknown`;
|
|
120
|
+
const countLabel = `${summary.recent_count || 0} in last ${summary.window_minutes || RECENT_EVENT_WINDOW_MINUTES}m`;
|
|
121
|
+
switch (summary.freshness) {
|
|
122
|
+
case 'recent':
|
|
123
|
+
return `${prefix}recent (${countLabel})`;
|
|
124
|
+
case 'quiet':
|
|
125
|
+
return `${prefix}quiet (${countLabel})`;
|
|
126
|
+
case 'unknown':
|
|
127
|
+
return `${prefix}unknown timing`;
|
|
128
|
+
case 'no_events':
|
|
129
|
+
default:
|
|
130
|
+
return `${prefix}none recorded`;
|
|
131
|
+
}
|
|
132
|
+
}
|