agentxchain 2.38.0 → 2.40.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 CHANGED
@@ -9,7 +9,9 @@ Legacy IDE-window coordination is still shipped as a compatibility mode for team
9
9
  ## Docs
10
10
 
11
11
  - [Quickstart](https://agentxchain.dev/docs/quickstart/)
12
+ - [Getting Started](https://agentxchain.dev/docs/getting-started/)
12
13
  - [CLI reference](https://agentxchain.dev/docs/cli/)
14
+ - [Templates](https://agentxchain.dev/docs/templates/)
13
15
  - [Export schema reference](https://agentxchain.dev/docs/export-schema/)
14
16
  - [Adapter reference](https://agentxchain.dev/docs/adapters/)
15
17
  - [Protocol spec (v6)](https://agentxchain.dev/docs/protocol/)
@@ -90,6 +92,15 @@ Built-in governed templates:
90
92
  - `web-app`: user flows, UI acceptance, browser support
91
93
  - `enterprise-app`: enterprise planning artifacts plus blueprint-backed `architect` and `security_reviewer` phases
92
94
 
95
+ Inspect the shipped template surfaces instead of inferring them from docs:
96
+
97
+ ```bash
98
+ agentxchain template list
99
+ agentxchain template list --phase-templates
100
+ ```
101
+
102
+ `template list` enumerates governed project templates. `template list --phase-templates` enumerates the reusable workflow-kit phase-template bundles you can reference from `workflow_kit.phases.<phase>.template`.
103
+
93
104
  `step` writes a turn-scoped bundle under `.agentxchain/dispatch/turns/<turn_id>/` and expects a staged result at `.agentxchain/staging/<turn_id>/turn-result.json`. Typical continuation:
94
105
 
95
106
  ```bash
@@ -411,6 +411,7 @@ templateCmd
411
411
  .command('list')
412
412
  .description('List available governed templates')
413
413
  .option('-j, --json', 'Output as JSON')
414
+ .option('--phase-templates', 'List workflow-kit phase templates instead of governed project templates')
414
415
  .action(templateListCommand);
415
416
 
416
417
  templateCmd
package/dashboard/app.js CHANGED
@@ -16,7 +16,7 @@ import { render as renderBlockers } from './components/blockers.js';
16
16
  import { render as renderArtifacts } from './components/artifacts.js';
17
17
 
18
18
  const VIEWS = {
19
- timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations'], render: renderTimeline },
19
+ timeline: { fetch: ['state', 'continuity', 'history', 'audit', 'annotations', 'connectors'], render: renderTimeline },
20
20
  ledger: { fetch: ['ledger'], render: renderLedger },
21
21
  hooks: { fetch: ['audit', 'annotations'], render: renderHooks },
22
22
  blocked: { fetch: ['state', 'audit', 'coordinatorState', 'coordinatorAudit'], render: renderBlocked },
@@ -41,6 +41,7 @@ const API_MAP = {
41
41
  coordinatorAudit: '/api/coordinator/hooks/audit',
42
42
  coordinatorBlockers: '/api/coordinator/blockers',
43
43
  workflowKitArtifacts: '/api/workflow-kit-artifacts',
44
+ connectors: '/api/connectors',
44
45
  };
45
46
 
46
47
  const viewState = {
@@ -82,7 +82,8 @@ function aggregateEvidence(turns) {
82
82
  allDecisions.push(dec);
83
83
  }
84
84
  }
85
- const files = turn.observed_artifact?.files_changed || turn.files_changed || [];
85
+ const observed = turn.observed_artifact?.files_changed;
86
+ const files = (Array.isArray(observed) && observed.length > 0) ? observed : (turn.files_changed || []);
86
87
  for (const f of files) {
87
88
  if (!allFiles.includes(f)) {
88
89
  allFiles.push(f);
@@ -177,7 +177,60 @@ function renderContinuityPanel(continuity) {
177
177
  return html;
178
178
  }
179
179
 
180
- export function render({ state, continuity, history, annotations, audit }) {
180
+ function connectorBadge(state) {
181
+ const colors = {
182
+ healthy: 'var(--green)',
183
+ failing: 'var(--red)',
184
+ active: 'var(--yellow)',
185
+ never_used: 'var(--text-dim)',
186
+ };
187
+ const color = colors[state] || 'var(--text-dim)';
188
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(state || 'unknown')}</span>`;
189
+ }
190
+
191
+ function renderConnectorHealthPanel(connectorsPayload) {
192
+ const connectors = Array.isArray(connectorsPayload?.connectors)
193
+ ? connectorsPayload.connectors
194
+ : [];
195
+ if (connectors.length === 0) return '';
196
+
197
+ let html = `<div class="section"><h3>Connector Health</h3><div class="turn-list">`;
198
+ for (const connector of connectors) {
199
+ html += `<div class="turn-card">
200
+ <div class="turn-header">
201
+ <span class="mono">${esc(connector.runtime_id)}</span>
202
+ ${connectorBadge(connector.state)}
203
+ </div>
204
+ <div class="turn-detail"><span class="detail-label">Type:</span> ${esc(connector.type || 'unknown')}</div>
205
+ <div class="turn-detail"><span class="detail-label">Target:</span> <span class="mono">${esc(connector.target || 'unknown')}</span></div>
206
+ <div class="turn-detail"><span class="detail-label">Reachable:</span> ${esc(connector.reachable || 'unknown')}</div>`;
207
+
208
+ if (Array.isArray(connector.active_turn_ids) && connector.active_turn_ids.length > 0) {
209
+ html += `<div class="turn-detail"><span class="detail-label">Active turns:</span> <span class="mono">${esc(connector.active_turn_ids.join(', '))}</span></div>`;
210
+ }
211
+
212
+ if (connector.last_success_at) {
213
+ html += `<div class="turn-detail"><span class="detail-label">Last success:</span> ${esc(connector.last_success_at)}</div>`;
214
+ }
215
+ if (connector.last_failure_at) {
216
+ html += `<div class="turn-detail"><span class="detail-label">Last failure:</span> ${esc(connector.last_failure_at)}</div>`;
217
+ }
218
+ if (connector.last_error) {
219
+ html += `<div class="turn-detail risks"><span class="detail-label">Last error:</span> ${esc(connector.last_error)}</div>`;
220
+ }
221
+ if (connector.attempts_made != null || connector.latency_ms != null) {
222
+ const attempts = connector.attempts_made != null ? connector.attempts_made : 'n/a';
223
+ const latency = connector.latency_ms != null ? `${connector.latency_ms}ms` : 'n/a';
224
+ html += `<div class="turn-detail"><span class="detail-label">Attempt telemetry:</span> attempts ${esc(attempts)} / latency ${esc(latency)}</div>`;
225
+ }
226
+
227
+ html += `</div>`;
228
+ }
229
+ html += `</div></div>`;
230
+ return html;
231
+ }
232
+
233
+ export function render({ state, continuity, history, annotations, audit, connectors }) {
181
234
  if (!state) {
182
235
  return `<div class="placeholder"><h2>No Run</h2><p>No governed run found. Start one with <code class="mono">agentxchain init --governed</code></p></div>`;
183
236
  }
@@ -198,6 +251,7 @@ export function render({ state, continuity, history, annotations, audit }) {
198
251
  </div>`;
199
252
 
200
253
  html += renderContinuityPanel(continuity);
254
+ html += renderConnectorHealthPanel(connectors);
201
255
 
202
256
  // Active turns
203
257
  if (activeTurns.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.38.0",
3
+ "version": "2.40.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "postflight:downstream": "bash scripts/release-downstream-truth.sh",
31
31
  "bump:release": "bash scripts/release-bump.sh",
32
32
  "sync:homebrew": "bash scripts/sync-homebrew.sh",
33
+ "verify:post-publish": "bash scripts/verify-post-publish.sh",
33
34
  "build:macos": "bun build bin/agentxchain.js --compile --target=bun-darwin-arm64 --outfile=dist/agentxchain-macos-arm64",
34
35
  "build:linux": "bun build bin/agentxchain.js --compile --target=bun-linux-x64 --outfile=dist/agentxchain-linux-x64",
35
36
  "publish:npm": "bash scripts/publish-npm.sh"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # Post-publish repo-mirror verification — the executable contract that
3
+ # ensures main is green after a release publish completes.
4
+ #
5
+ # Three-phase Homebrew lifecycle:
6
+ # Phase 1 (pre-publish): formula URL updated, SHA carried from previous version
7
+ # Phase 2 (post-publish): npm live, but repo mirror SHA is stale
8
+ # Phase 3 (post-sync): repo mirror SHA matches published tarball — main is green
9
+ #
10
+ # This script transitions from Phase 2 → Phase 3 and verifies the result.
11
+ #
12
+ # Usage: bash scripts/verify-post-publish.sh [--target-version <semver>]
13
+ # If --target-version is omitted, reads from package.json.
14
+ set -euo pipefail
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
+ CLI_DIR="${SCRIPT_DIR}/.."
18
+ cd "$CLI_DIR"
19
+
20
+ TARGET_VERSION=""
21
+
22
+ while [[ $# -gt 0 ]]; do
23
+ case "$1" in
24
+ --target-version)
25
+ if [[ -z "${2:-}" ]]; then
26
+ echo "Error: --target-version requires a semver argument" >&2
27
+ exit 1
28
+ fi
29
+ TARGET_VERSION="$2"
30
+ shift 2
31
+ ;;
32
+ *)
33
+ echo "Unknown argument: $1" >&2
34
+ exit 1
35
+ ;;
36
+ esac
37
+ done
38
+
39
+ if [[ -z "$TARGET_VERSION" ]]; then
40
+ TARGET_VERSION="$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")"
41
+ fi
42
+
43
+ echo "Post-Publish Verification: v${TARGET_VERSION}"
44
+ echo "============================================="
45
+ echo ""
46
+
47
+ # Step 1: Verify npm serves the version
48
+ echo "[1/4] Checking npm registry..."
49
+ NPM_VERSION="$(npm view "agentxchain@${TARGET_VERSION}" version 2>/dev/null || echo "")"
50
+ if [[ "$NPM_VERSION" != "$TARGET_VERSION" ]]; then
51
+ echo " FAIL: npm does not serve agentxchain@${TARGET_VERSION} (got: '${NPM_VERSION}')"
52
+ echo " This script must run AFTER npm publish completes."
53
+ exit 1
54
+ fi
55
+ echo " OK: npm serves v${TARGET_VERSION}"
56
+
57
+ # Step 2: Sync the repo mirror to the published tarball
58
+ echo "[2/4] Syncing repo mirror to published tarball..."
59
+ bash "${SCRIPT_DIR}/sync-homebrew.sh" --target-version "$TARGET_VERSION"
60
+ echo " OK: repo mirror synced"
61
+
62
+ # Step 3: Run the full test suite WITHOUT the preflight skip
63
+ echo "[3/4] Running full test suite (no preflight skip)..."
64
+ echo " This verifies the Homebrew mirror contract passes with the real SHA."
65
+ npm test
66
+ echo " OK: full test suite green"
67
+
68
+ # Step 4: Summary
69
+ echo ""
70
+ echo "============================================="
71
+ echo "Post-publish verification PASSED."
72
+ echo " - npm: agentxchain@${TARGET_VERSION} live"
73
+ echo " - repo mirror: SHA synced to published tarball"
74
+ echo " - test suite: green without preflight skip"
75
+ echo ""
76
+ echo "Main is now in Phase 3 (post-sync). Commit and push the mirror update."
@@ -6,7 +6,7 @@ import inquirer from 'inquirer';
6
6
  import { CONFIG_FILE, LOCK_FILE, STATE_FILE } from '../lib/config.js';
7
7
  import { generateVSCodeFiles } from '../lib/generate-vscode.js';
8
8
  import { loadGovernedTemplate, VALID_GOVERNED_TEMPLATE_IDS, buildSystemSpecContent } from '../lib/governed-templates.js';
9
- import { VALID_PROMPT_TRANSPORTS } from '../lib/normalized-config.js';
9
+ import { normalizeWorkflowKit, VALID_PROMPT_TRANSPORTS } from '../lib/normalized-config.js';
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const TEMPLATES_DIR = join(__dirname, '../templates');
@@ -628,6 +628,9 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
628
628
  const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
629
629
  const scaffoldConfig = buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitConfig);
630
630
  const { roles, runtimes, routing, gates, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
631
+ const scaffoldWorkflowKitConfig = effectiveWorkflowKitConfig
632
+ ? normalizeWorkflowKit(effectiveWorkflowKitConfig, Object.keys(routing))
633
+ : null;
631
634
  const initialPhase = Object.keys(routing)[0] || 'planning';
632
635
  const phaseGateStatus = Object.fromEntries(
633
636
  [...new Set(
@@ -709,7 +712,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
709
712
  const basePrompt = buildGovernedPrompt(roleId, role, {
710
713
  routing,
711
714
  gates,
712
- workflowKitConfig: effectiveWorkflowKitConfig,
715
+ workflowKitConfig: scaffoldWorkflowKitConfig,
713
716
  });
714
717
  const prompt = appendPromptOverride(basePrompt, template.prompt_overrides?.[roleId]);
715
718
  writeFileSync(join(dir, '.agentxchain', 'prompts', `${roleId}.md`), prompt);
@@ -736,7 +739,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
736
739
 
737
740
  // Workflow-kit custom artifacts — only scaffold files from explicit workflow_kit config
738
741
  // that are not already handled by the default scaffold above
739
- if (effectiveWorkflowKitConfig && effectiveWorkflowKitConfig.phases && typeof effectiveWorkflowKitConfig.phases === 'object') {
742
+ if (scaffoldWorkflowKitConfig && scaffoldWorkflowKitConfig.phases && typeof scaffoldWorkflowKitConfig.phases === 'object') {
740
743
  const defaultScaffoldPaths = new Set([
741
744
  '.planning/PM_SIGNOFF.md',
742
745
  '.planning/ROADMAP.md',
@@ -747,7 +750,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
747
750
  '.planning/RELEASE_NOTES.md',
748
751
  ]);
749
752
 
750
- for (const phaseConfig of Object.values(effectiveWorkflowKitConfig.phases)) {
753
+ for (const phaseConfig of Object.values(scaffoldWorkflowKitConfig.phases)) {
751
754
  if (!Array.isArray(phaseConfig.artifacts)) continue;
752
755
  for (const artifact of phaseConfig.artifacts) {
753
756
  if (!artifact.path || defaultScaffoldPaths.has(artifact.path)) continue;
@@ -782,7 +785,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
782
785
  }
783
786
  }
784
787
 
785
- return { config, state };
788
+ return { config, state, scaffoldWorkflowKitConfig };
786
789
  }
787
790
 
788
791
  async function initGoverned(opts) {
@@ -882,7 +885,7 @@ async function initGoverned(opts) {
882
885
  }
883
886
  }
884
887
 
885
- const { config } = scaffoldGoverned(dir, projectName, projectId, templateId, opts, workflowKitConfig);
888
+ const { config, scaffoldWorkflowKitConfig } = scaffoldGoverned(dir, projectName, projectId, templateId, opts, workflowKitConfig);
886
889
 
887
890
  console.log('');
888
891
  console.log(chalk.green(` ✓ Created governed project ${chalk.bold(targetLabel)}/`));
@@ -897,7 +900,7 @@ async function initGoverned(opts) {
897
900
  console.log(` ${chalk.dim('│')} ${chalk.dim('├──')} reviews/`);
898
901
  console.log(` ${chalk.dim('│')} ${chalk.dim('└──')} dispatch/`);
899
902
  console.log(` ${chalk.dim('├──')} .planning/`);
900
- const planningSummaryLines = buildPlanningSummaryLines(selectedTemplate, config.workflow_kit);
903
+ const planningSummaryLines = buildPlanningSummaryLines(selectedTemplate, scaffoldWorkflowKitConfig);
901
904
  for (const [index, line] of planningSummaryLines.entries()) {
902
905
  const branch = index === planningSummaryLines.length - 1 ? '└──' : '├──';
903
906
  console.log(` ${chalk.dim('│')} ${chalk.dim(branch)} ${line}`);
@@ -3,6 +3,8 @@ import { loadConfig, loadLock, loadProjectContext, loadProjectState, loadState }
3
3
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
4
4
  import { getActiveTurn, getActiveTurnCount, getActiveTurns } from '../lib/governed-state.js';
5
5
  import { getContinuityStatus } from '../lib/continuity-status.js';
6
+ import { getConnectorHealth } from '../lib/connector-health.js';
7
+ import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
6
8
 
7
9
  export async function statusCommand(opts) {
8
10
  const context = loadProjectContext();
@@ -76,6 +78,9 @@ function renderGovernedStatus(context, opts) {
76
78
  const { root, config, version } = context;
77
79
  const state = loadProjectState(root, config);
78
80
  const continuity = getContinuityStatus(root, state);
81
+ const connectorHealth = getConnectorHealth(root, config, state);
82
+
83
+ const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
79
84
 
80
85
  if (opts.json) {
81
86
  console.log(JSON.stringify({
@@ -85,6 +90,8 @@ function renderGovernedStatus(context, opts) {
85
90
  config,
86
91
  state,
87
92
  continuity,
93
+ connector_health: connectorHealth,
94
+ workflow_kit_artifacts: workflowKitArtifacts,
88
95
  }, null, 2));
89
96
  return;
90
97
  }
@@ -105,6 +112,7 @@ function renderGovernedStatus(context, opts) {
105
112
  console.log('');
106
113
 
107
114
  renderContinuityStatus(continuity, state);
115
+ renderConnectorHealthStatus(connectorHealth);
108
116
 
109
117
  const activeTurnCount = getActiveTurnCount(state);
110
118
  const activeTurns = getActiveTurns(state);
@@ -227,6 +235,8 @@ function renderGovernedStatus(context, opts) {
227
235
  }
228
236
  }
229
237
 
238
+ renderWorkflowKitArtifactsSection(workflowKitArtifacts);
239
+
230
240
  if (state?.budget_status) {
231
241
  console.log('');
232
242
  console.log(` ${chalk.dim('Budget:')} spent $${formatUsd(state.budget_status.spent_usd)} / remaining $${formatUsd(state.budget_status.remaining_usd)}`);
@@ -243,6 +253,48 @@ function renderGovernedStatus(context, opts) {
243
253
  console.log('');
244
254
  }
245
255
 
256
+ function renderConnectorHealthStatus(connectorHealth) {
257
+ const connectors = Array.isArray(connectorHealth?.connectors)
258
+ ? connectorHealth.connectors
259
+ : [];
260
+ if (connectors.length === 0) {
261
+ return;
262
+ }
263
+
264
+ console.log(` ${chalk.dim('Connectors:')}`);
265
+ for (const connector of connectors) {
266
+ const stateLabel = formatConnectorState(connector.state);
267
+ console.log(` ${stateLabel} ${chalk.bold(connector.runtime_id)} — ${connector.type} (${connector.target})`);
268
+
269
+ if (connector.active_turn_ids.length > 0) {
270
+ console.log(` ${chalk.dim('Active turns:')} ${connector.active_turn_ids.join(', ')}`);
271
+ }
272
+
273
+ if (connector.last_error) {
274
+ console.log(` ${chalk.dim('Last error:')} ${connector.last_error}`);
275
+ } else if (connector.last_success_at) {
276
+ console.log(` ${chalk.dim('Last success:')} ${connector.last_success_at}`);
277
+ } else if (connector.last_attempt_at) {
278
+ console.log(` ${chalk.dim('Last attempt:')} ${connector.last_attempt_at}`);
279
+ }
280
+ }
281
+ console.log('');
282
+ }
283
+
284
+ function formatConnectorState(state) {
285
+ switch (state) {
286
+ case 'healthy':
287
+ return chalk.green('● healthy');
288
+ case 'failing':
289
+ return chalk.red('✗ failing');
290
+ case 'active':
291
+ return chalk.yellow('● active');
292
+ case 'never_used':
293
+ default:
294
+ return chalk.dim('○ never_used');
295
+ }
296
+ }
297
+
246
298
  function renderContinuityStatus(continuity, state) {
247
299
  if (!continuity) return;
248
300
 
@@ -292,6 +344,25 @@ function renderContinuityStatus(continuity, state) {
292
344
  console.log('');
293
345
  }
294
346
 
347
+ function renderWorkflowKitArtifactsSection(wkData) {
348
+ if (!wkData || !wkData.artifacts || wkData.artifacts.length === 0) return;
349
+
350
+ const artifacts = wkData.artifacts;
351
+ console.log('');
352
+ console.log(` ${chalk.dim('Artifacts:')} (${wkData.phase})`);
353
+ for (const a of artifacts) {
354
+ const icon = a.exists ? chalk.green('✓') : (a.required ? chalk.red('✗') : chalk.yellow('○'));
355
+ const reqLabel = a.required ? '' : chalk.dim(' (optional)');
356
+ const ownerLabel = a.owned_by
357
+ ? chalk.dim(` [${a.owned_by}${a.owner_resolution === 'entry_role' ? '*' : ''}]`)
358
+ : '';
359
+ console.log(` ${icon} ${a.path}${ownerLabel}${reqLabel}`);
360
+ }
361
+ if (artifacts.some(a => a.owner_resolution === 'entry_role')) {
362
+ console.log(` ${chalk.dim('* = ownership inferred from entry_role')}`);
363
+ }
364
+ }
365
+
295
366
  function formatPhase(phase) {
296
367
  const colors = { discovery: chalk.blue, build: chalk.green, qa: chalk.yellow, deploy: chalk.magenta, blocked: chalk.red };
297
368
  return (colors[phase] || chalk.white)(phase);
@@ -1,7 +1,12 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadAllGovernedTemplates, VALID_GOVERNED_TEMPLATE_IDS } from '../lib/governed-templates.js';
3
+ import { listWorkflowKitPhaseTemplates } from '../lib/workflow-kit-phase-templates.js';
3
4
 
4
5
  export function templateListCommand(opts) {
6
+ if (opts.phaseTemplates) {
7
+ return listPhaseTemplates(opts);
8
+ }
9
+
5
10
  if (opts.json) {
6
11
  const templates = loadAllGovernedTemplates();
7
12
  const output = templates.map((t) => ({
@@ -34,4 +39,40 @@ export function templateListCommand(opts) {
34
39
  console.log('');
35
40
  }
36
41
  console.log(chalk.dim(` Usage: agentxchain template set <id>\n`));
42
+ console.log(chalk.dim(` Tip: use --phase-templates to list workflow-kit phase templates.\n`));
43
+ }
44
+
45
+ function listPhaseTemplates(opts) {
46
+ const templates = listWorkflowKitPhaseTemplates();
47
+
48
+ if (opts.json) {
49
+ const output = templates.map((t) => ({
50
+ id: t.id,
51
+ description: t.description,
52
+ artifacts: t.artifacts.map((a) => ({
53
+ path: a.path,
54
+ semantics: a.semantics || null,
55
+ semantics_config: a.semantics_config || null,
56
+ required: a.required,
57
+ })),
58
+ }));
59
+ console.log(JSON.stringify(output, null, 2));
60
+ return;
61
+ }
62
+
63
+ console.log(chalk.bold('\n Workflow-kit phase templates:\n'));
64
+ for (const t of templates) {
65
+ console.log(` ${chalk.cyan(t.id)} — ${t.description}`);
66
+ for (const a of t.artifacts) {
67
+ const req = a.required ? chalk.green('required') : chalk.dim('optional');
68
+ const sem = a.semantics ? chalk.yellow(a.semantics) : chalk.dim('none');
69
+ console.log(` ${a.path} [${req}] [semantics: ${sem}]`);
70
+ if (a.semantics === 'section_check' && a.semantics_config?.required_sections) {
71
+ console.log(` sections: ${a.semantics_config.required_sections.join(', ')}`);
72
+ }
73
+ }
74
+ console.log('');
75
+ }
76
+ console.log(chalk.dim(' Usage in agentxchain.json:'));
77
+ console.log(chalk.dim(' "workflow_kit": { "phases": { "<phase>": { "template": "<id>" } } }\n'));
37
78
  }
@@ -0,0 +1,319 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const HISTORY_PATH = '.agentxchain/history.jsonl';
5
+ const STAGING_DIR = '.agentxchain/staging';
6
+ const CONNECTOR_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
7
+
8
+ function safeReadJson(absPath) {
9
+ try {
10
+ if (!existsSync(absPath)) return null;
11
+ return JSON.parse(readFileSync(absPath, 'utf8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function safeReadJsonl(absPath) {
18
+ try {
19
+ if (!existsSync(absPath)) return [];
20
+ const raw = readFileSync(absPath, 'utf8').trim();
21
+ if (!raw) return [];
22
+ return raw.split('\n').filter(Boolean).map((line) => JSON.parse(line));
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ function toMillis(value) {
29
+ if (typeof value !== 'string' || value.length === 0) return null;
30
+ const ms = Date.parse(value);
31
+ return Number.isFinite(ms) ? ms : null;
32
+ }
33
+
34
+ function chooseLatest(left, right) {
35
+ if (!left) return right || null;
36
+ if (!right) return left;
37
+ const leftMs = toMillis(left.at);
38
+ const rightMs = toMillis(right.at);
39
+ if (leftMs === null) return right;
40
+ if (rightMs === null) return left;
41
+ return rightMs >= leftMs ? right : left;
42
+ }
43
+
44
+ function chooseLatestDefined(left, right) {
45
+ if (right == null) return left ?? null;
46
+ if (left == null) return right;
47
+ return right;
48
+ }
49
+
50
+ function formatCommand(command, args = []) {
51
+ if (Array.isArray(command)) {
52
+ return command.join(' ');
53
+ }
54
+ if (typeof command === 'string' && command.length > 0) {
55
+ return [command, ...(Array.isArray(args) ? args : [])].join(' ');
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function formatTarget(runtime) {
61
+ switch (runtime?.type) {
62
+ case 'api_proxy':
63
+ return [runtime.provider, runtime.model].filter(Boolean).join(' / ') || 'unknown target';
64
+ case 'remote_agent':
65
+ return runtime.url || 'unknown target';
66
+ case 'mcp':
67
+ return runtime.transport === 'streamable_http'
68
+ ? (runtime.url || 'unknown target')
69
+ : (formatCommand(runtime.command, runtime.args) || 'unknown target');
70
+ case 'local_cli':
71
+ return formatCommand(runtime.command, runtime.args) || 'unknown target';
72
+ default:
73
+ return 'unknown target';
74
+ }
75
+ }
76
+
77
+ function getLatestAttemptFromTrace(trace) {
78
+ if (!trace || typeof trace !== 'object' || Array.isArray(trace)) return null;
79
+ const attempts = Array.isArray(trace.attempts) ? trace.attempts : [];
80
+ if (attempts.length === 0) return null;
81
+ const last = attempts[attempts.length - 1];
82
+ if (!last || typeof last !== 'object') return null;
83
+ const startedAt = typeof last.started_at === 'string' ? last.started_at : null;
84
+ const completedAt = typeof last.completed_at === 'string' ? last.completed_at : startedAt;
85
+ let latencyMs = null;
86
+ const startedMs = toMillis(startedAt);
87
+ const completedMs = toMillis(completedAt);
88
+ if (startedMs !== null && completedMs !== null && completedMs >= startedMs) {
89
+ latencyMs = completedMs - startedMs;
90
+ }
91
+ return {
92
+ at: completedAt,
93
+ turn_id: trace.turn_id || null,
94
+ runtime_id: trace.runtime_id || null,
95
+ attempts_made: Number.isInteger(trace.attempts_made) ? trace.attempts_made : attempts.length,
96
+ final_outcome: trace.final_outcome || null,
97
+ latency_ms: latencyMs,
98
+ };
99
+ }
100
+
101
+ function buildConnectorEntry(runtimeId, runtime) {
102
+ return {
103
+ runtime_id: runtimeId,
104
+ type: runtime.type,
105
+ target: formatTarget(runtime),
106
+ state: 'never_used',
107
+ reachable: 'unknown',
108
+ active_turn_ids: [],
109
+ active_roles: [],
110
+ last_turn_id: null,
111
+ last_role: null,
112
+ last_phase: null,
113
+ last_attempt_at: null,
114
+ last_success_at: null,
115
+ last_failure_at: null,
116
+ latency_ms: null,
117
+ attempts_made: null,
118
+ last_error: null,
119
+ _latest_success: null,
120
+ _latest_failure: null,
121
+ _latest_attempt: null,
122
+ _latest_identity: null,
123
+ };
124
+ }
125
+
126
+ function applyIdentityTarget(entry, candidate) {
127
+ if (!candidate) return;
128
+ entry.last_turn_id = candidate.turn_id || entry.last_turn_id;
129
+ entry.last_role = candidate.role || entry.last_role;
130
+ entry.last_phase = candidate.phase || entry.last_phase;
131
+ }
132
+
133
+ function finalizeConnectorEntry(entry) {
134
+ entry.last_attempt_at = entry._latest_attempt?.at || null;
135
+ entry.latency_ms = entry._latest_attempt?.latency_ms ?? null;
136
+ entry.attempts_made = entry._latest_attempt?.attempts_made ?? null;
137
+ entry.last_success_at = entry._latest_success?.at || null;
138
+ entry.last_failure_at = entry._latest_failure?.at || null;
139
+ entry.last_error = entry._latest_failure?.error || null;
140
+
141
+ const hasActive = entry.active_turn_ids.length > 0;
142
+ const hasFailure = Boolean(entry._latest_failure);
143
+ const hasSuccess = Boolean(entry._latest_success);
144
+
145
+ if (hasActive && !hasFailure) {
146
+ entry.state = 'active';
147
+ } else {
148
+ const failureMs = toMillis(entry.last_failure_at);
149
+ const successMs = toMillis(entry.last_success_at);
150
+ if (failureMs !== null && (successMs === null || failureMs >= successMs)) {
151
+ entry.state = 'failing';
152
+ } else if (hasSuccess || entry._latest_attempt?.final_outcome === 'success') {
153
+ entry.state = 'healthy';
154
+ } else if (hasActive) {
155
+ entry.state = 'active';
156
+ } else {
157
+ entry.state = 'never_used';
158
+ }
159
+ }
160
+
161
+ if (entry.type === 'local_cli') {
162
+ entry.reachable = 'unknown';
163
+ } else if (entry._latest_attempt?.final_outcome === 'success' || entry.state === 'healthy') {
164
+ entry.reachable = 'yes';
165
+ } else if (entry._latest_failure) {
166
+ entry.reachable = 'no';
167
+ } else {
168
+ entry.reachable = 'unknown';
169
+ }
170
+
171
+ delete entry._latest_success;
172
+ delete entry._latest_failure;
173
+ delete entry._latest_attempt;
174
+ delete entry._latest_identity;
175
+ return entry;
176
+ }
177
+
178
+ export function getConnectorHealth(root, config, state) {
179
+ const runtimeEntries = Object.entries(config?.runtimes || {})
180
+ .filter(([, runtime]) => CONNECTOR_RUNTIME_TYPES.has(runtime?.type))
181
+ .sort(([left], [right]) => left.localeCompare(right, 'en'));
182
+
183
+ const connectors = runtimeEntries.map(([runtimeId, runtime]) => buildConnectorEntry(runtimeId, runtime));
184
+ const connectorMap = Object.fromEntries(connectors.map((entry) => [entry.runtime_id, entry]));
185
+
186
+ const turnRuntimeIndex = {};
187
+
188
+ for (const turn of Object.values(state?.active_turns || {})) {
189
+ if (!turn || typeof turn !== 'object') continue;
190
+ const entry = connectorMap[turn.runtime_id];
191
+ if (!entry) continue;
192
+ if (typeof turn.turn_id === 'string') {
193
+ turnRuntimeIndex[turn.turn_id] = turn.runtime_id;
194
+ entry.active_turn_ids.push(turn.turn_id);
195
+ }
196
+ if (typeof turn.assigned_role === 'string' && !entry.active_roles.includes(turn.assigned_role)) {
197
+ entry.active_roles.push(turn.assigned_role);
198
+ }
199
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, {
200
+ turn_id: turn.turn_id || null,
201
+ role: turn.assigned_role || null,
202
+ phase: state?.phase || null,
203
+ });
204
+ }
205
+
206
+ for (const entry of connectors) {
207
+ entry.active_turn_ids.sort((a, b) => a.localeCompare(b, 'en'));
208
+ entry.active_roles.sort((a, b) => a.localeCompare(b, 'en'));
209
+ }
210
+
211
+ const history = safeReadJsonl(join(root, HISTORY_PATH));
212
+ for (const item of history) {
213
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
214
+ const runtimeId = item.runtime_id;
215
+ const entry = connectorMap[runtimeId];
216
+ if (!entry) continue;
217
+
218
+ if (typeof item.turn_id === 'string') {
219
+ turnRuntimeIndex[item.turn_id] = runtimeId;
220
+ }
221
+
222
+ const acceptedAt = typeof item.accepted_at === 'string' ? item.accepted_at : null;
223
+ if (!acceptedAt) continue;
224
+
225
+ const successCandidate = {
226
+ at: acceptedAt,
227
+ turn_id: item.turn_id || null,
228
+ role: item.role || null,
229
+ phase: item.phase || null,
230
+ };
231
+ entry._latest_success = chooseLatest(entry._latest_success, successCandidate);
232
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, successCandidate);
233
+ }
234
+
235
+ const stagingRoot = join(root, STAGING_DIR);
236
+ if (existsSync(stagingRoot)) {
237
+ for (const child of readdirSync(stagingRoot, { withFileTypes: true })) {
238
+ if (!child.isDirectory()) continue;
239
+ const turnId = child.name;
240
+ const turnDir = join(stagingRoot, turnId);
241
+ const trace = safeReadJson(join(turnDir, 'retry-trace.json'));
242
+ const apiError = safeReadJson(join(turnDir, 'api-error.json'));
243
+ const runtimeId = trace?.runtime_id || turnRuntimeIndex[turnId] || null;
244
+ const entry = runtimeId ? connectorMap[runtimeId] : null;
245
+ if (!entry) continue;
246
+
247
+ const latestAttempt = getLatestAttemptFromTrace(trace);
248
+ if (latestAttempt) {
249
+ entry._latest_attempt = chooseLatest(entry._latest_attempt, latestAttempt);
250
+ const identity = {
251
+ turn_id: latestAttempt.turn_id || turnId,
252
+ role: null,
253
+ phase: null,
254
+ };
255
+ if (latestAttempt.final_outcome === 'success') {
256
+ entry._latest_success = chooseLatest(entry._latest_success, {
257
+ at: latestAttempt.at,
258
+ turn_id: latestAttempt.turn_id || turnId,
259
+ role: null,
260
+ phase: null,
261
+ });
262
+ } else if (latestAttempt.final_outcome === 'failure' || latestAttempt.final_outcome === 'aborted') {
263
+ entry._latest_failure = chooseLatest(entry._latest_failure, {
264
+ at: latestAttempt.at,
265
+ turn_id: latestAttempt.turn_id || turnId,
266
+ role: null,
267
+ phase: null,
268
+ error: apiError?.message || latestAttempt.final_outcome,
269
+ });
270
+ }
271
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, identity);
272
+ }
273
+
274
+ if (apiError && !latestAttempt) {
275
+ const stats = statSync(turnDir);
276
+ entry._latest_failure = chooseLatest(entry._latest_failure, {
277
+ at: stats.mtime.toISOString(),
278
+ turn_id: turnId,
279
+ role: null,
280
+ phase: null,
281
+ error: apiError.message || apiError.error_class || 'runtime_error',
282
+ });
283
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, {
284
+ turn_id: turnId,
285
+ role: null,
286
+ phase: null,
287
+ });
288
+ } else if (apiError && entry._latest_failure && entry._latest_failure.turn_id === turnId) {
289
+ entry._latest_failure.error = apiError.message || apiError.error_class || entry._latest_failure.error;
290
+ }
291
+ }
292
+ }
293
+
294
+ const blockedTurnId = state?.blocked_reason?.turn_id || null;
295
+ if (blockedTurnId && turnRuntimeIndex[blockedTurnId] && connectorMap[turnRuntimeIndex[blockedTurnId]]) {
296
+ const entry = connectorMap[turnRuntimeIndex[blockedTurnId]];
297
+ const detail = state?.blocked_reason?.recovery?.detail || state?.blocked_on || 'runtime_blocked';
298
+ const blockedAt = state?.blocked_reason?.blocked_at || null;
299
+ entry._latest_failure = chooseLatest(entry._latest_failure, {
300
+ at: blockedAt,
301
+ turn_id: blockedTurnId,
302
+ role: null,
303
+ phase: state?.phase || null,
304
+ error: detail,
305
+ });
306
+ entry._latest_identity = chooseLatestDefined(entry._latest_identity, {
307
+ turn_id: blockedTurnId,
308
+ role: null,
309
+ phase: state?.phase || null,
310
+ });
311
+ }
312
+
313
+ return {
314
+ connectors: connectors.map((entry) => {
315
+ applyIdentityTarget(entry, entry._latest_failure || entry._latest_success || entry._latest_identity);
316
+ return finalizeConnectorEntry(entry);
317
+ }),
318
+ };
319
+ }
@@ -19,6 +19,7 @@ import { FileWatcher } from './file-watcher.js';
19
19
  import { approvePendingDashboardGate } from './actions.js';
20
20
  import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
21
21
  import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
22
+ import { readConnectorHealthSnapshot } from './connectors.js';
22
23
 
23
24
  const MIME_TYPES = {
24
25
  '.html': 'text/html; charset=utf-8',
@@ -286,6 +287,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
286
287
  return;
287
288
  }
288
289
 
290
+ if (pathname === '/api/connectors') {
291
+ const result = readConnectorHealthSnapshot(workspacePath);
292
+ writeJson(res, result.status, result.body);
293
+ return;
294
+ }
295
+
289
296
  // API routes
290
297
  if (pathname.startsWith('/api/')) {
291
298
  const result = readResource(agentxchainDir, pathname);
@@ -0,0 +1,41 @@
1
+ import { loadProjectContext } from '../config.js';
2
+ import { readJsonFile } from './state-reader.js';
3
+ import { getConnectorHealth } from '../connector-health.js';
4
+ import { join } from 'path';
5
+
6
+ export function readConnectorHealthSnapshot(workspacePath) {
7
+ const context = loadProjectContext(workspacePath);
8
+ if (!context || context.config.protocol_mode !== 'governed') {
9
+ return {
10
+ ok: false,
11
+ status: 404,
12
+ body: {
13
+ ok: false,
14
+ code: 'config_missing',
15
+ error: 'Project config not found. Run `agentxchain init --governed` first.',
16
+ },
17
+ };
18
+ }
19
+
20
+ const state = readJsonFile(join(workspacePath, '.agentxchain'), 'state.json');
21
+ if (!state) {
22
+ return {
23
+ ok: false,
24
+ status: 404,
25
+ body: {
26
+ ok: false,
27
+ code: 'state_missing',
28
+ error: 'Run state not found. Run `agentxchain init --governed` first.',
29
+ },
30
+ };
31
+ }
32
+
33
+ return {
34
+ ok: true,
35
+ status: 200,
36
+ body: {
37
+ ok: true,
38
+ ...getConnectorHealth(workspacePath, context.config, state),
39
+ },
40
+ };
41
+ }
@@ -10,9 +10,9 @@
10
10
  * See: WORKFLOW_KIT_DASHBOARD_SPEC.md
11
11
  */
12
12
 
13
- import { existsSync } from 'fs';
14
13
  import { join } from 'path';
15
- import { loadConfig } from '../config.js';
14
+ import { loadConfig, loadProjectContext, loadProjectState } from '../config.js';
15
+ import { deriveWorkflowKitArtifacts } from '../workflow-kit-artifacts.js';
16
16
  import { readJsonFile } from './state-reader.js';
17
17
 
18
18
  /**
@@ -22,8 +22,10 @@ import { readJsonFile } from './state-reader.js';
22
22
  * @returns {{ ok: boolean, status: number, body: object }}
23
23
  */
24
24
  export function readWorkflowKitArtifacts(workspacePath) {
25
- const configResult = loadConfig(workspacePath);
26
- if (!configResult) {
25
+ const context = loadProjectContext(workspacePath);
26
+ const governedContext = context?.config?.protocol_mode === 'governed' ? context : null;
27
+ const legacyConfigResult = governedContext ? null : loadConfig(workspacePath);
28
+ if (!governedContext && !legacyConfigResult) {
27
29
  return {
28
30
  ok: false,
29
31
  status: 404,
@@ -35,8 +37,11 @@ export function readWorkflowKitArtifacts(workspacePath) {
35
37
  };
36
38
  }
37
39
 
38
- const agentxchainDir = join(workspacePath, '.agentxchain');
39
- const state = readJsonFile(agentxchainDir, 'state.json');
40
+ const root = governedContext?.root || legacyConfigResult.root;
41
+ const config = governedContext?.config || legacyConfigResult.config;
42
+ const state = governedContext
43
+ ? loadProjectState(root, config)
44
+ : readJsonFile(join(root, '.agentxchain'), 'state.json');
40
45
  if (!state) {
41
46
  return {
42
47
  ok: false,
@@ -49,7 +54,6 @@ export function readWorkflowKitArtifacts(workspacePath) {
49
54
  };
50
55
  }
51
56
 
52
- const config = configResult.config;
53
57
  const phase = state.phase || null;
54
58
 
55
59
  if (!config.workflow_kit) {
@@ -89,8 +93,8 @@ export function readWorkflowKitArtifacts(workspacePath) {
89
93
  };
90
94
  }
91
95
 
92
- const artifacts = Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
93
- if (artifacts.length === 0) {
96
+ const snapshot = deriveWorkflowKitArtifacts(root, config, state);
97
+ if (!snapshot) {
94
98
  return {
95
99
  ok: true,
96
100
  status: 200,
@@ -102,30 +106,13 @@ export function readWorkflowKitArtifacts(workspacePath) {
102
106
  };
103
107
  }
104
108
 
105
- const entryRole = config.routing?.[phase]?.entry_role || null;
106
-
107
- const result = artifacts
108
- .filter((a) => a && typeof a.path === 'string')
109
- .map((a) => {
110
- const hasExplicitOwner = typeof a.owned_by === 'string' && a.owned_by.length > 0;
111
- return {
112
- path: a.path,
113
- required: a.required !== false,
114
- semantics: a.semantics || null,
115
- owned_by: hasExplicitOwner ? a.owned_by : entryRole,
116
- owner_resolution: hasExplicitOwner ? 'explicit' : 'entry_role',
117
- exists: existsSync(join(workspacePath, a.path)),
118
- };
119
- })
120
- .sort((a, b) => a.path.localeCompare(b.path, 'en'));
121
-
122
109
  return {
123
110
  ok: true,
124
111
  status: 200,
125
112
  body: {
126
113
  ok: true,
127
114
  phase,
128
- artifacts: result,
115
+ artifacts: snapshot.artifacts,
129
116
  },
130
117
  };
131
118
  }
@@ -15,6 +15,12 @@
15
15
  import { validateHooksConfig } from './hook-runner.js';
16
16
  import { validateNotificationsConfig } from './notification-runner.js';
17
17
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
18
+ import {
19
+ buildDefaultWorkflowKitArtifactsForPhase,
20
+ expandWorkflowKitPhaseArtifacts,
21
+ isWorkflowKitPhaseTemplateId,
22
+ VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS,
23
+ } from './workflow-kit-phase-templates.js';
18
24
 
19
25
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
20
26
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
@@ -26,25 +32,6 @@ export { DEFAULT_PHASES };
26
32
  const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
27
33
  const VALID_SEMANTIC_IDS = ['pm_signoff', 'system_spec', 'implementation_notes', 'acceptance_matrix', 'ship_verdict', 'release_notes', 'section_check'];
28
34
 
29
- /**
30
- * Default artifact map for phases when workflow_kit is absent from config.
31
- * Only phases present in this map get default artifacts.
32
- */
33
- const DEFAULT_PHASE_ARTIFACTS = {
34
- planning: [
35
- { path: '.planning/PM_SIGNOFF.md', semantics: 'pm_signoff', required: true },
36
- { path: '.planning/SYSTEM_SPEC.md', semantics: 'system_spec', required: true },
37
- { path: '.planning/ROADMAP.md', semantics: null, required: true },
38
- ],
39
- implementation: [
40
- { path: '.planning/IMPLEMENTATION_NOTES.md', semantics: 'implementation_notes', required: true },
41
- ],
42
- qa: [
43
- { path: '.planning/acceptance-matrix.md', semantics: 'acceptance_matrix', required: true },
44
- { path: '.planning/ship-verdict.md', semantics: 'ship_verdict', required: true },
45
- { path: '.planning/RELEASE_NOTES.md', semantics: 'release_notes', required: true },
46
- ],
47
- };
48
35
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
49
36
  const VALID_API_PROXY_RETRY_CLASSES = [
50
37
  'rate_limited',
@@ -566,14 +553,52 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
566
553
  continue;
567
554
  }
568
555
 
569
- if (!Array.isArray(phaseConfig.artifacts)) {
556
+ let templateValid = true;
557
+ if (phaseConfig.template !== undefined) {
558
+ if (typeof phaseConfig.template !== 'string' || !phaseConfig.template.trim()) {
559
+ errors.push(`workflow_kit.phases.${phase}.template must be a non-empty string`);
560
+ templateValid = false;
561
+ } else if (!isWorkflowKitPhaseTemplateId(phaseConfig.template)) {
562
+ errors.push(
563
+ `workflow_kit.phases.${phase}.template "${phaseConfig.template}" is unknown; valid values: ${VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.join(', ')}`,
564
+ );
565
+ templateValid = false;
566
+ }
567
+ }
568
+
569
+ if (phaseConfig.artifacts !== undefined && !Array.isArray(phaseConfig.artifacts)) {
570
570
  errors.push(`workflow_kit.phases.${phase}.artifacts must be an array`);
571
571
  continue;
572
572
  }
573
573
 
574
+ if (Array.isArray(phaseConfig.artifacts)) {
575
+ const explicitSeenPaths = new Set();
576
+ for (const artifact of phaseConfig.artifacts) {
577
+ if (!artifact || typeof artifact !== 'object') {
578
+ continue;
579
+ }
580
+ if (typeof artifact.path !== 'string' || !artifact.path.trim()) {
581
+ continue;
582
+ }
583
+ if (explicitSeenPaths.has(artifact.path)) {
584
+ errors.push(`duplicate artifact path "${artifact.path}" in phase "${phase}"`);
585
+ continue;
586
+ }
587
+ explicitSeenPaths.add(artifact.path);
588
+ }
589
+ }
590
+
591
+ if (phaseConfig.template === undefined && phaseConfig.artifacts === undefined) {
592
+ errors.push(`workflow_kit.phases.${phase} must declare template, artifacts, or both`);
593
+ continue;
594
+ }
595
+
574
596
  const seenPaths = new Set();
575
- for (let i = 0; i < phaseConfig.artifacts.length; i++) {
576
- const artifact = phaseConfig.artifacts[i];
597
+ const expandedArtifacts = templateValid
598
+ ? expandWorkflowKitPhaseArtifacts(phaseConfig)
599
+ : Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
600
+ for (let i = 0; i < expandedArtifacts.length; i++) {
601
+ const artifact = expandedArtifacts[i];
577
602
  const prefix = `workflow_kit.phases.${phase}.artifacts[${i}]`;
578
603
 
579
604
  if (!artifact || typeof artifact !== 'object') {
@@ -845,7 +870,7 @@ export function getMaxConcurrentTurns(config, phase) {
845
870
 
846
871
  /**
847
872
  * Normalize workflow_kit config.
848
- * When absent, builds defaults from routing phases using DEFAULT_PHASE_ARTIFACTS.
873
+ * When absent, builds defaults from routing phases using the built-in phase templates.
849
874
  * When present, normalizes artifact entries.
850
875
  */
851
876
  export function normalizeWorkflowKit(raw, routingPhases) {
@@ -862,7 +887,7 @@ export function normalizeWorkflowKit(raw, routingPhases) {
862
887
  if (raw.phases) {
863
888
  for (const [phase, phaseConfig] of Object.entries(raw.phases)) {
864
889
  phases[phase] = {
865
- artifacts: (phaseConfig.artifacts || []).map(a => ({
890
+ artifacts: expandWorkflowKitPhaseArtifacts(phaseConfig).map(a => ({
866
891
  path: a.path,
867
892
  semantics: a.semantics || null,
868
893
  semantics_config: a.semantics_config || null,
@@ -879,9 +904,10 @@ export function normalizeWorkflowKit(raw, routingPhases) {
879
904
  function buildDefaultWorkflowKit(routingPhases) {
880
905
  const phases = {};
881
906
  for (const phase of routingPhases) {
882
- if (DEFAULT_PHASE_ARTIFACTS[phase]) {
907
+ const templateArtifacts = buildDefaultWorkflowKitArtifactsForPhase(phase);
908
+ if (templateArtifacts) {
883
909
  phases[phase] = {
884
- artifacts: DEFAULT_PHASE_ARTIFACTS[phase].map(a => ({ ...a, semantics_config: null })),
910
+ artifacts: templateArtifacts.map(a => ({ ...a, semantics_config: a.semantics_config || null })),
885
911
  };
886
912
  }
887
913
  }
@@ -0,0 +1,36 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function deriveWorkflowKitArtifacts(root, config, state) {
5
+ if (!config?.workflow_kit) return null;
6
+
7
+ const phase = state?.phase || null;
8
+ if (!phase) return null;
9
+
10
+ const phaseConfig = config.workflow_kit.phases?.[phase];
11
+ if (!phaseConfig) return null;
12
+
13
+ const artifacts = Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
14
+ if (artifacts.length === 0) return null;
15
+
16
+ const entryRole = config.routing?.[phase]?.entry_role || null;
17
+
18
+ return {
19
+ ok: true,
20
+ phase,
21
+ artifacts: artifacts
22
+ .filter((artifact) => artifact && typeof artifact.path === 'string')
23
+ .map((artifact) => {
24
+ const hasExplicitOwner = typeof artifact.owned_by === 'string' && artifact.owned_by.length > 0;
25
+ return {
26
+ path: artifact.path,
27
+ required: artifact.required !== false,
28
+ semantics: artifact.semantics || null,
29
+ owned_by: hasExplicitOwner ? artifact.owned_by : entryRole,
30
+ owner_resolution: hasExplicitOwner ? 'explicit' : 'entry_role',
31
+ exists: existsSync(join(root, artifact.path)),
32
+ };
33
+ })
34
+ .sort((a, b) => a.path.localeCompare(b.path, 'en')),
35
+ };
36
+ }
@@ -0,0 +1,128 @@
1
+ function cloneJsonCompatible(value) {
2
+ return value == null ? value : JSON.parse(JSON.stringify(value));
3
+ }
4
+
5
+ const WORKFLOW_KIT_PHASE_TEMPLATE_REGISTRY = Object.freeze({
6
+ 'planning-default': {
7
+ description: 'Core planning proof surface for governed repos.',
8
+ artifacts: [
9
+ { path: '.planning/PM_SIGNOFF.md', semantics: 'pm_signoff', required: true },
10
+ { path: '.planning/SYSTEM_SPEC.md', semantics: 'system_spec', required: true },
11
+ { path: '.planning/ROADMAP.md', semantics: null, required: true },
12
+ ],
13
+ },
14
+ 'implementation-default': {
15
+ description: 'Implementation proof surface for governed repos.',
16
+ artifacts: [
17
+ { path: '.planning/IMPLEMENTATION_NOTES.md', semantics: 'implementation_notes', required: true },
18
+ ],
19
+ },
20
+ 'qa-default': {
21
+ description: 'QA and ship-verdict proof surface for governed repos.',
22
+ artifacts: [
23
+ { path: '.planning/acceptance-matrix.md', semantics: 'acceptance_matrix', required: true },
24
+ { path: '.planning/ship-verdict.md', semantics: 'ship_verdict', required: true },
25
+ { path: '.planning/RELEASE_NOTES.md', semantics: 'release_notes', required: true },
26
+ ],
27
+ },
28
+ 'architecture-review': {
29
+ description: 'Structured architecture-review document with required sections.',
30
+ artifacts: [
31
+ {
32
+ path: '.planning/ARCHITECTURE.md',
33
+ semantics: 'section_check',
34
+ semantics_config: {
35
+ required_sections: ['## Context', '## Proposed Design', '## Trade-offs', '## Risks'],
36
+ },
37
+ required: true,
38
+ },
39
+ ],
40
+ },
41
+ 'security-review': {
42
+ description: 'Structured security-review document with required sections.',
43
+ artifacts: [
44
+ {
45
+ path: '.planning/SECURITY_REVIEW.md',
46
+ semantics: 'section_check',
47
+ semantics_config: {
48
+ required_sections: ['## Threat Model', '## Findings', '## Verdict'],
49
+ },
50
+ required: true,
51
+ },
52
+ ],
53
+ },
54
+ });
55
+
56
+ export const VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS = Object.freeze(
57
+ Object.keys(WORKFLOW_KIT_PHASE_TEMPLATE_REGISTRY),
58
+ );
59
+
60
+ const DEFAULT_WORKFLOW_KIT_PHASE_TEMPLATE_BY_PHASE = Object.freeze({
61
+ planning: 'planning-default',
62
+ implementation: 'implementation-default',
63
+ qa: 'qa-default',
64
+ });
65
+
66
+ export function listWorkflowKitPhaseTemplates() {
67
+ return VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.map((id) => loadWorkflowKitPhaseTemplate(id));
68
+ }
69
+
70
+ export function isWorkflowKitPhaseTemplateId(templateId) {
71
+ return VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.includes(templateId);
72
+ }
73
+
74
+ export function loadWorkflowKitPhaseTemplate(templateId) {
75
+ if (!isWorkflowKitPhaseTemplateId(templateId)) {
76
+ throw new Error(
77
+ `Unknown workflow-kit phase template "${templateId}". Valid templates: ${VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.join(', ')}`,
78
+ );
79
+ }
80
+
81
+ const template = WORKFLOW_KIT_PHASE_TEMPLATE_REGISTRY[templateId];
82
+ return {
83
+ id: templateId,
84
+ description: template.description,
85
+ artifacts: cloneJsonCompatible(template.artifacts),
86
+ };
87
+ }
88
+
89
+ export function expandWorkflowKitPhaseArtifacts(phaseConfig = {}) {
90
+ const templateArtifacts = phaseConfig.template
91
+ ? loadWorkflowKitPhaseTemplate(phaseConfig.template).artifacts
92
+ : [];
93
+ const explicitArtifacts = Array.isArray(phaseConfig.artifacts)
94
+ ? cloneJsonCompatible(phaseConfig.artifacts)
95
+ : [];
96
+ const mergedArtifacts = templateArtifacts.map((artifact) => cloneJsonCompatible(artifact));
97
+ const indexByPath = new Map(
98
+ mergedArtifacts
99
+ .filter((artifact) => artifact && typeof artifact.path === 'string')
100
+ .map((artifact, index) => [artifact.path, index]),
101
+ );
102
+
103
+ for (const artifact of explicitArtifacts) {
104
+ if (!artifact || typeof artifact.path !== 'string') {
105
+ mergedArtifacts.push(artifact);
106
+ continue;
107
+ }
108
+
109
+ const existingIndex = indexByPath.get(artifact.path);
110
+ if (existingIndex === undefined) {
111
+ indexByPath.set(artifact.path, mergedArtifacts.length);
112
+ mergedArtifacts.push(artifact);
113
+ continue;
114
+ }
115
+
116
+ mergedArtifacts[existingIndex] = {
117
+ ...mergedArtifacts[existingIndex],
118
+ ...artifact,
119
+ };
120
+ }
121
+
122
+ return mergedArtifacts;
123
+ }
124
+
125
+ export function buildDefaultWorkflowKitArtifactsForPhase(phase) {
126
+ const templateId = DEFAULT_WORKFLOW_KIT_PHASE_TEMPLATE_BY_PHASE[phase];
127
+ return templateId ? loadWorkflowKitPhaseTemplate(templateId).artifacts : null;
128
+ }
@@ -145,49 +145,33 @@
145
145
  "workflow_kit": {
146
146
  "phases": {
147
147
  "planning": {
148
- "artifacts": [
149
- { "path": ".planning/PM_SIGNOFF.md", "semantics": "pm_signoff", "required": true },
150
- { "path": ".planning/SYSTEM_SPEC.md", "semantics": "system_spec", "required": true },
151
- { "path": ".planning/ROADMAP.md", "semantics": null, "required": true }
152
- ]
148
+ "template": "planning-default"
153
149
  },
154
150
  "architecture": {
151
+ "template": "architecture-review",
155
152
  "artifacts": [
156
153
  {
157
154
  "path": ".planning/ARCHITECTURE.md",
158
- "semantics": "section_check",
159
155
  "owned_by": "architect",
160
- "semantics_config": {
161
- "required_sections": ["## Context", "## Proposed Design", "## Trade-offs", "## Risks"]
162
- },
163
156
  "required": true
164
157
  }
165
158
  ]
166
159
  },
167
160
  "implementation": {
168
- "artifacts": [
169
- { "path": ".planning/IMPLEMENTATION_NOTES.md", "semantics": "implementation_notes", "required": true }
170
- ]
161
+ "template": "implementation-default"
171
162
  },
172
163
  "security_review": {
164
+ "template": "security-review",
173
165
  "artifacts": [
174
166
  {
175
167
  "path": ".planning/SECURITY_REVIEW.md",
176
- "semantics": "section_check",
177
168
  "owned_by": "security_reviewer",
178
- "semantics_config": {
179
- "required_sections": ["## Threat Model", "## Findings", "## Verdict"]
180
- },
181
169
  "required": true
182
170
  }
183
171
  ]
184
172
  },
185
173
  "qa": {
186
- "artifacts": [
187
- { "path": ".planning/acceptance-matrix.md", "semantics": "acceptance_matrix", "required": true },
188
- { "path": ".planning/ship-verdict.md", "semantics": "ship_verdict", "required": true },
189
- { "path": ".planning/RELEASE_NOTES.md", "semantics": "release_notes", "required": true }
190
- ]
174
+ "template": "qa-default"
191
175
  }
192
176
  }
193
177
  }