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.
Files changed (66) hide show
  1. package/README.md +13 -7
  2. package/bin/agentxchain.js +16 -8
  3. package/dashboard/app.js +111 -7
  4. package/dashboard/components/blocked.js +95 -11
  5. package/dashboard/components/blockers.js +85 -86
  6. package/dashboard/components/coordinator-timeouts.js +13 -0
  7. package/dashboard/components/cross-repo.js +17 -12
  8. package/dashboard/components/gate.js +31 -11
  9. package/dashboard/components/initiative.js +173 -78
  10. package/dashboard/components/ledger.js +28 -0
  11. package/dashboard/components/live-status.js +39 -0
  12. package/dashboard/components/run-history.js +76 -1
  13. package/dashboard/components/timeline.js +5 -1
  14. package/dashboard/index.html +21 -0
  15. package/dashboard/live-observer.js +91 -0
  16. package/package.json +1 -1
  17. package/scripts/release-bump.sh +26 -3
  18. package/scripts/release-preflight.sh +82 -38
  19. package/src/commands/accept-turn.js +3 -3
  20. package/src/commands/decisions.js +98 -29
  21. package/src/commands/diff.js +27 -4
  22. package/src/commands/doctor.js +48 -16
  23. package/src/commands/generate.js +126 -1
  24. package/src/commands/history.js +21 -3
  25. package/src/commands/init.js +15 -97
  26. package/src/commands/multi.js +223 -54
  27. package/src/commands/phase.js +11 -13
  28. package/src/commands/reject-turn.js +1 -1
  29. package/src/commands/restart.js +28 -11
  30. package/src/commands/resume.js +6 -6
  31. package/src/commands/role.js +51 -14
  32. package/src/commands/run.js +5 -11
  33. package/src/commands/status.js +145 -13
  34. package/src/commands/step.js +36 -29
  35. package/src/lib/admission-control.js +14 -12
  36. package/src/lib/blocked-state.js +150 -0
  37. package/src/lib/conflict-actions.js +17 -0
  38. package/src/lib/context-section-parser.js +2 -0
  39. package/src/lib/continuity-status.js +1 -1
  40. package/src/lib/coordinator-blocker-presentation.js +127 -0
  41. package/src/lib/coordinator-event-narrative.js +43 -0
  42. package/src/lib/coordinator-gate-approval.js +98 -0
  43. package/src/lib/coordinator-gate-evaluation-presentation.js +57 -0
  44. package/src/lib/coordinator-next-actions.js +128 -0
  45. package/src/lib/coordinator-pending-gate-presentation.js +79 -0
  46. package/src/lib/coordinator-presentation-detail.js +11 -0
  47. package/src/lib/coordinator-repo-snapshots.js +53 -0
  48. package/src/lib/coordinator-repo-status-presentation.js +134 -0
  49. package/src/lib/dashboard/actions.js +105 -29
  50. package/src/lib/dashboard/bridge-server.js +7 -0
  51. package/src/lib/dashboard/coordinator-blockers.js +17 -0
  52. package/src/lib/dashboard/coordinator-repo-status.js +50 -0
  53. package/src/lib/dashboard/coordinator-timeout-status.js +34 -11
  54. package/src/lib/dashboard/state-reader.js +36 -1
  55. package/src/lib/dispatch-bundle.js +23 -0
  56. package/src/lib/export-diff.js +70 -38
  57. package/src/lib/export-verifier.js +3 -0
  58. package/src/lib/history-diff-summary.js +249 -0
  59. package/src/lib/manual-qa-fallback.js +18 -0
  60. package/src/lib/normalized-config.js +27 -22
  61. package/src/lib/planning-artifacts.js +131 -0
  62. package/src/lib/recent-event-summary.js +132 -0
  63. package/src/lib/repo-decisions.js +69 -28
  64. package/src/lib/report.js +353 -145
  65. package/src/lib/run-diff.js +4 -0
  66. 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
- if (
469
- (rt.type === 'api_proxy' || rt.type === 'remote_agent')
470
- && role.write_authority !== 'review_only'
471
- && role.write_authority !== 'proposed'
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]?.write_authority === 'review_only'
836
+ roles[artifact.owned_by]
841
837
  ) {
842
- // Check if any authoritative/proposed role exists in this phase's routing
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 hasWriter = [...phaseRoles].some(rid =>
849
- roles[rid]?.write_authority === 'authoritative' || roles[rid]?.write_authority === 'proposed',
850
- );
851
- if (!hasWriter) {
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}" is a review_only role in phase "${phase}" with no authoritative or proposed role — nobody can write this required artifact`,
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
+ }