agentxchain 2.25.1 → 2.25.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.25.1",
3
+ "version": "2.25.2",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -454,7 +454,21 @@ function formatInitTarget(dir) {
454
454
  return dir;
455
455
  }
456
456
 
457
- export function scaffoldGoverned(dir, projectName, projectId, templateId = 'generic', runtimeOptions = {}) {
457
+ function generateWorkflowKitPlaceholder(artifact, projectName) {
458
+ const filename = basename(artifact.path);
459
+ const title = filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
460
+
461
+ if (artifact.semantics === 'section_check' && artifact.semantics_config?.required_sections?.length) {
462
+ const sections = artifact.semantics_config.required_sections
463
+ .map(s => `${s}\n\n(Content here.)\n`)
464
+ .join('\n');
465
+ return `# ${title} — ${projectName}\n\n${sections}`;
466
+ }
467
+
468
+ return `# ${title} — ${projectName}\n\n(Operator fills this in.)\n`;
469
+ }
470
+
471
+ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'generic', runtimeOptions = {}, workflowKitConfig = null) {
458
472
  const template = loadGovernedTemplate(templateId);
459
473
  const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
460
474
  const runtimes = {
@@ -561,6 +575,37 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
561
575
  );
562
576
  }
563
577
 
578
+ // Workflow-kit custom artifacts — only scaffold files from explicit workflow_kit config
579
+ // that are not already handled by the default scaffold above
580
+ if (workflowKitConfig && workflowKitConfig.phases && typeof workflowKitConfig.phases === 'object') {
581
+ const defaultScaffoldPaths = new Set([
582
+ '.planning/PM_SIGNOFF.md',
583
+ '.planning/ROADMAP.md',
584
+ '.planning/SYSTEM_SPEC.md',
585
+ '.planning/IMPLEMENTATION_NOTES.md',
586
+ '.planning/acceptance-matrix.md',
587
+ '.planning/ship-verdict.md',
588
+ '.planning/RELEASE_NOTES.md',
589
+ ]);
590
+
591
+ for (const phaseConfig of Object.values(workflowKitConfig.phases)) {
592
+ if (!Array.isArray(phaseConfig.artifacts)) continue;
593
+ for (const artifact of phaseConfig.artifacts) {
594
+ if (!artifact.path || defaultScaffoldPaths.has(artifact.path)) continue;
595
+ const absPath = join(dir, artifact.path);
596
+ if (existsSync(absPath)) continue;
597
+
598
+ // Ensure parent directory exists
599
+ const parentDir = dirname(absPath);
600
+ if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
601
+
602
+ // Generate placeholder content based on semantics type
603
+ const content = generateWorkflowKitPlaceholder(artifact, projectName);
604
+ writeFileSync(absPath, content);
605
+ }
606
+ }
607
+ }
608
+
564
609
  // TALK.md
565
610
  writeFileSync(join(dir, 'TALK.md'), `# ${projectName} — Team Talk File\n\nCanonical human-readable handoff log for all agents.\n\n---\n\n`);
566
611
 
@@ -662,7 +707,21 @@ async function initGoverned(opts) {
662
707
 
663
708
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
664
709
 
665
- scaffoldGoverned(dir, projectName, projectId, templateId, opts);
710
+ // If reinitializing a project that has explicit workflow_kit config, preserve it for scaffold
711
+ let workflowKitConfig = null;
712
+ const existingConfigPath = join(dir, CONFIG_FILE);
713
+ if (existsSync(existingConfigPath)) {
714
+ try {
715
+ const existing = JSON.parse(readFileSync(existingConfigPath, 'utf8'));
716
+ if (existing.workflow_kit && typeof existing.workflow_kit === 'object' && Object.keys(existing.workflow_kit).length > 0) {
717
+ workflowKitConfig = existing.workflow_kit;
718
+ }
719
+ } catch {
720
+ // Ignore parse errors — scaffold will overwrite the config anyway
721
+ }
722
+ }
723
+
724
+ scaffoldGoverned(dir, projectName, projectId, templateId, opts, workflowKitConfig);
666
725
 
667
726
  console.log('');
668
727
  console.log(chalk.green(` ✓ Created governed project ${chalk.bold(targetLabel)}/`));
@@ -19,7 +19,109 @@
19
19
 
20
20
  import { existsSync } from 'fs';
21
21
  import { join } from 'path';
22
- import { evaluateWorkflowGateSemantics } from './workflow-gate-semantics.js';
22
+ import {
23
+ evaluateArtifactSemantics,
24
+ evaluateWorkflowGateSemantics,
25
+ getSemanticIdForPath,
26
+ } from './workflow-gate-semantics.js';
27
+
28
+ function getWorkflowArtifactsForPhase(config, phase) {
29
+ const artifacts = config?.workflow_kit?.phases?.[phase]?.artifacts;
30
+ return Array.isArray(artifacts) ? artifacts : [];
31
+ }
32
+
33
+ function buildEffectiveGateArtifacts(config, gateDef, phase) {
34
+ const byPath = new Map();
35
+
36
+ if (Array.isArray(gateDef?.requires_files)) {
37
+ for (const filePath of gateDef.requires_files) {
38
+ byPath.set(filePath, {
39
+ path: filePath,
40
+ required: true,
41
+ useLegacySemantics: true,
42
+ semanticChecks: [],
43
+ });
44
+ }
45
+ }
46
+
47
+ for (const artifact of getWorkflowArtifactsForPhase(config, phase)) {
48
+ if (!artifact?.path) {
49
+ continue;
50
+ }
51
+
52
+ const existing = byPath.get(artifact.path) || {
53
+ path: artifact.path,
54
+ required: false,
55
+ useLegacySemantics: false,
56
+ semanticChecks: [],
57
+ };
58
+
59
+ existing.required = existing.required || artifact.required !== false;
60
+
61
+ if (artifact.semantics) {
62
+ const legacySemanticId = existing.useLegacySemantics ? getSemanticIdForPath(artifact.path) : null;
63
+ if (artifact.semantics !== legacySemanticId) {
64
+ existing.semanticChecks.push({
65
+ semantics: artifact.semantics,
66
+ semantics_config: artifact.semantics_config || null,
67
+ });
68
+ }
69
+ }
70
+
71
+ byPath.set(artifact.path, existing);
72
+ }
73
+
74
+ return [...byPath.values()];
75
+ }
76
+
77
+ function addMissingFile(result, filePath) {
78
+ if (!result.missing_files.includes(filePath)) {
79
+ result.missing_files.push(filePath);
80
+ }
81
+ }
82
+
83
+ function prefixSemanticReason(filePath, reason) {
84
+ if (!reason || reason.includes(filePath)) {
85
+ return reason;
86
+ }
87
+ return `${filePath}: ${reason}`;
88
+ }
89
+
90
+ function evaluateGateArtifacts({ root, config, gateDef, phase, result }) {
91
+ const failures = [];
92
+ const artifacts = buildEffectiveGateArtifacts(config, gateDef, phase);
93
+
94
+ for (const artifact of artifacts) {
95
+ const absPath = join(root, artifact.path);
96
+ if (!existsSync(absPath)) {
97
+ if (artifact.required) {
98
+ addMissingFile(result, artifact.path);
99
+ failures.push(`Required file missing: ${artifact.path}`);
100
+ }
101
+ continue;
102
+ }
103
+
104
+ if (artifact.useLegacySemantics) {
105
+ const semanticCheck = evaluateWorkflowGateSemantics(root, artifact.path);
106
+ if (semanticCheck && !semanticCheck.ok) {
107
+ failures.push(semanticCheck.reason);
108
+ }
109
+ }
110
+
111
+ for (const semantic of artifact.semanticChecks) {
112
+ const semanticCheck = evaluateArtifactSemantics(root, {
113
+ path: artifact.path,
114
+ semantics: semantic.semantics,
115
+ semantics_config: semantic.semantics_config,
116
+ });
117
+ if (semanticCheck && !semanticCheck.ok) {
118
+ failures.push(prefixSemanticReason(artifact.path, semanticCheck.reason));
119
+ }
120
+ }
121
+ }
122
+
123
+ return failures;
124
+ }
23
125
 
24
126
  /**
25
127
  * Evaluate whether the current phase exit gate is satisfied.
@@ -118,20 +220,13 @@ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
118
220
  const failures = [];
119
221
 
120
222
  // Predicate: requires_files
121
- if (gateDef.requires_files && Array.isArray(gateDef.requires_files)) {
122
- for (const filePath of gateDef.requires_files) {
123
- if (!existsSync(join(root, filePath))) {
124
- result.missing_files.push(filePath);
125
- failures.push(`Required file missing: ${filePath}`);
126
- continue;
127
- }
128
-
129
- const semanticCheck = evaluateWorkflowGateSemantics(root, filePath);
130
- if (semanticCheck && !semanticCheck.ok) {
131
- failures.push(semanticCheck.reason);
132
- }
133
- }
134
- }
223
+ failures.push(...evaluateGateArtifacts({
224
+ root,
225
+ config,
226
+ gateDef,
227
+ phase: currentPhase,
228
+ result,
229
+ }));
135
230
 
136
231
  // Predicate: requires_verification_pass
137
232
  if (gateDef.requires_verification_pass) {
@@ -240,20 +335,13 @@ export function evaluateRunCompletion({ state, config, acceptedTurn, root }) {
240
335
  const result = { ...baseResult, gate_id: gateId };
241
336
  const failures = [];
242
337
 
243
- if (gateDef.requires_files && Array.isArray(gateDef.requires_files)) {
244
- for (const filePath of gateDef.requires_files) {
245
- if (!existsSync(join(root, filePath))) {
246
- result.missing_files.push(filePath);
247
- failures.push(`Required file missing: ${filePath}`);
248
- continue;
249
- }
250
-
251
- const semanticCheck = evaluateWorkflowGateSemantics(root, filePath);
252
- if (semanticCheck && !semanticCheck.ok) {
253
- failures.push(semanticCheck.reason);
254
- }
255
- }
256
- }
338
+ failures.push(...evaluateGateArtifacts({
339
+ root,
340
+ config,
341
+ gateDef,
342
+ phase: currentPhase,
343
+ result,
344
+ }));
257
345
 
258
346
  if (gateDef.requires_verification_pass) {
259
347
  const verificationStatus = acceptedTurn.verification?.status;
@@ -446,7 +446,26 @@ export function validateGovernedWorkflowKit(root, config = {}) {
446
446
  const gateRequiredFiles = uniqueStrings(
447
447
  Object.values(config?.gates || {}).flatMap((gate) => Array.isArray(gate?.requires_files) ? gate.requires_files : [])
448
448
  );
449
- const requiredFiles = uniqueStrings([...GOVERNED_WORKFLOW_KIT_BASE_FILES, ...gateRequiredFiles]);
449
+
450
+ // Collect workflow-kit artifact paths from explicit config
451
+ const wkArtifactPaths = [];
452
+ const wk = config?.workflow_kit;
453
+ if (wk && wk.phases && typeof wk.phases === 'object') {
454
+ for (const phaseConfig of Object.values(wk.phases)) {
455
+ if (Array.isArray(phaseConfig.artifacts)) {
456
+ for (const a of phaseConfig.artifacts) {
457
+ if (a.path && a.required !== false) {
458
+ wkArtifactPaths.push(a.path);
459
+ }
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ const hasExplicitWorkflowKit = wk && wk._explicit === true;
466
+ const hasExplicitWorkflowKitArtifacts = hasExplicitWorkflowKit && Object.keys(wk.phases || {}).length > 0;
467
+ const baseFiles = hasExplicitWorkflowKit ? wkArtifactPaths : GOVERNED_WORKFLOW_KIT_BASE_FILES;
468
+ const requiredFiles = uniqueStrings([...baseFiles, ...gateRequiredFiles]);
450
469
  const present = [];
451
470
  const missing = [];
452
471
 
@@ -459,32 +478,38 @@ export function validateGovernedWorkflowKit(root, config = {}) {
459
478
  }
460
479
  }
461
480
 
462
- const structuralChecks = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.map((check) => {
463
- const absPath = join(root, check.file);
464
- if (!existsSync(absPath)) {
481
+ // Build structural checks: from explicit workflow_kit semantics or hardcoded defaults
482
+ let structuralChecks;
483
+ if (hasExplicitWorkflowKit) {
484
+ structuralChecks = buildStructuralChecksFromWorkflowKit(root, wk, errors);
485
+ } else {
486
+ structuralChecks = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.map((check) => {
487
+ const absPath = join(root, check.file);
488
+ if (!existsSync(absPath)) {
489
+ return {
490
+ id: check.id,
491
+ file: check.file,
492
+ ok: false,
493
+ skipped: true,
494
+ description: check.description,
495
+ };
496
+ }
497
+
498
+ const content = readFileSync(absPath, 'utf8');
499
+ const ok = check.pattern.test(content);
500
+ if (!ok) {
501
+ errors.push(`Workflow kit file "${check.file}" must preserve its structural marker: ${check.description}.`);
502
+ }
503
+
465
504
  return {
466
505
  id: check.id,
467
506
  file: check.file,
468
- ok: false,
469
- skipped: true,
507
+ ok,
508
+ skipped: false,
470
509
  description: check.description,
471
510
  };
472
- }
473
-
474
- const content = readFileSync(absPath, 'utf8');
475
- const ok = check.pattern.test(content);
476
- if (!ok) {
477
- errors.push(`Workflow kit file "${check.file}" must preserve its structural marker: ${check.description}.`);
478
- }
479
-
480
- return {
481
- id: check.id,
482
- file: check.file,
483
- ok,
484
- skipped: false,
485
- description: check.description,
486
- };
487
- });
511
+ });
512
+ }
488
513
 
489
514
  return {
490
515
  ok: errors.length === 0,
@@ -498,6 +523,64 @@ export function validateGovernedWorkflowKit(root, config = {}) {
498
523
  };
499
524
  }
500
525
 
526
+ function buildStructuralChecksFromWorkflowKit(root, wk, errors) {
527
+ const checks = [];
528
+ if (!wk.phases) return checks;
529
+
530
+ for (const [phase, phaseConfig] of Object.entries(wk.phases)) {
531
+ if (!Array.isArray(phaseConfig.artifacts)) continue;
532
+ for (const artifact of phaseConfig.artifacts) {
533
+ if (!artifact.semantics) continue;
534
+
535
+ if (artifact.semantics === 'section_check' && artifact.semantics_config?.required_sections?.length) {
536
+ for (const section of artifact.semantics_config.required_sections) {
537
+ const checkId = `wk_${phase}_${artifact.path.replace(/[^a-zA-Z0-9]/g, '_')}_section_${section.replace(/[^a-zA-Z0-9]/g, '_')}`;
538
+ const description = `${artifact.path} defines ${section}`;
539
+ const absPath = join(root, artifact.path);
540
+
541
+ if (!existsSync(absPath)) {
542
+ checks.push({ id: checkId, file: artifact.path, ok: false, skipped: true, description });
543
+ continue;
544
+ }
545
+
546
+ const content = readFileSync(absPath, 'utf8');
547
+ const ok = content.includes(section);
548
+ if (!ok) {
549
+ errors.push(`Workflow kit file "${artifact.path}" must contain section: ${section}.`);
550
+ }
551
+ checks.push({ id: checkId, file: artifact.path, ok, skipped: false, description });
552
+ }
553
+ } else if (artifact.semantics !== 'section_check') {
554
+ // Built-in semantic check — generate a structural check entry
555
+ const checkId = `wk_${phase}_${artifact.semantics}`;
556
+ const description = `${artifact.path} passes ${artifact.semantics} validation`;
557
+ const absPath = join(root, artifact.path);
558
+
559
+ if (!existsSync(absPath)) {
560
+ checks.push({ id: checkId, file: artifact.path, ok: false, skipped: true, description });
561
+ continue;
562
+ }
563
+
564
+ // For built-in validators, delegate to the hardcoded check if one exists
565
+ const hardcoded = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.find(c => c.file === artifact.path);
566
+ if (hardcoded) {
567
+ const content = readFileSync(absPath, 'utf8');
568
+ const ok = hardcoded.pattern.test(content);
569
+ if (!ok) {
570
+ errors.push(`Workflow kit file "${artifact.path}" must preserve its structural marker: ${hardcoded.description}.`);
571
+ }
572
+ checks.push({ id: checkId, file: artifact.path, ok, skipped: false, description: hardcoded.description });
573
+ } else {
574
+ // No hardcoded check for this semantic — mark as passing (runtime gate handles full validation)
575
+ checks.push({ id: checkId, file: artifact.path, ok: true, skipped: false, description });
576
+ }
577
+ }
578
+ }
579
+ }
580
+
581
+ return checks;
582
+ }
583
+
501
584
  export const SYSTEM_SPEC_OVERLAY_SEPARATOR = '## Template-Specific Guidance';
502
585
 
503
586
  export function buildSystemSpecContent(projectName, overlay) {
@@ -22,7 +22,29 @@ const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
22
22
  export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
23
23
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
24
24
  const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
25
+ export { DEFAULT_PHASES };
25
26
  const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
27
+ const VALID_SEMANTIC_IDS = ['pm_signoff', 'system_spec', 'implementation_notes', 'acceptance_matrix', 'ship_verdict', 'release_notes', 'section_check'];
28
+
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
+ };
26
48
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
27
49
  const VALID_API_PROXY_RETRY_CLASSES = [
28
50
  'rate_limited',
@@ -448,9 +470,117 @@ export function validateV4Config(data, projectRoot) {
448
470
  errors.push(...notificationValidation.errors);
449
471
  }
450
472
 
473
+ // Workflow Kit (optional but validated if present)
474
+ if (data.workflow_kit !== undefined) {
475
+ const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing);
476
+ errors.push(...wkValidation.errors);
477
+ }
478
+
451
479
  return { ok: errors.length === 0, errors };
452
480
  }
453
481
 
482
+ /**
483
+ * Validate the workflow_kit config section.
484
+ * Returns { ok, errors, warnings }.
485
+ */
486
+ export function validateWorkflowKitConfig(wk, routing) {
487
+ const errors = [];
488
+ const warnings = [];
489
+
490
+ if (wk === null || (typeof wk === 'object' && !Array.isArray(wk) && Object.keys(wk).length === 0)) {
491
+ // Empty workflow_kit is a valid opt-out
492
+ return { ok: true, errors, warnings };
493
+ }
494
+
495
+ if (!wk || typeof wk !== 'object' || Array.isArray(wk)) {
496
+ errors.push('workflow_kit must be an object');
497
+ return { ok: false, errors, warnings };
498
+ }
499
+
500
+ if (wk.phases !== undefined) {
501
+ if (!wk.phases || typeof wk.phases !== 'object' || Array.isArray(wk.phases)) {
502
+ errors.push('workflow_kit.phases must be an object');
503
+ return { ok: false, errors, warnings };
504
+ }
505
+
506
+ const routingPhases = routing ? new Set(Object.keys(routing)) : new Set(DEFAULT_PHASES);
507
+
508
+ for (const [phase, phaseConfig] of Object.entries(wk.phases)) {
509
+ if (!VALID_PHASE_NAME.test(phase)) {
510
+ errors.push(`workflow_kit phase name "${phase}" must be lowercase alphanumeric starting with a letter (hyphens and underscores allowed)`);
511
+ continue;
512
+ }
513
+
514
+ if (!routingPhases.has(phase)) {
515
+ warnings.push(`workflow_kit declares phase "${phase}" which is not in routing`);
516
+ }
517
+
518
+ if (!phaseConfig || typeof phaseConfig !== 'object' || Array.isArray(phaseConfig)) {
519
+ errors.push(`workflow_kit.phases.${phase} must be an object`);
520
+ continue;
521
+ }
522
+
523
+ if (!Array.isArray(phaseConfig.artifacts)) {
524
+ errors.push(`workflow_kit.phases.${phase}.artifacts must be an array`);
525
+ continue;
526
+ }
527
+
528
+ const seenPaths = new Set();
529
+ for (let i = 0; i < phaseConfig.artifacts.length; i++) {
530
+ const artifact = phaseConfig.artifacts[i];
531
+ const prefix = `workflow_kit.phases.${phase}.artifacts[${i}]`;
532
+
533
+ if (!artifact || typeof artifact !== 'object') {
534
+ errors.push(`${prefix} must be an object`);
535
+ continue;
536
+ }
537
+
538
+ if (typeof artifact.path !== 'string' || !artifact.path.trim()) {
539
+ errors.push(`${prefix} requires a non-empty path`);
540
+ continue;
541
+ }
542
+
543
+ if (artifact.path.includes('..')) {
544
+ errors.push(`${prefix} path must not traverse above project root (contains "..")`);
545
+ continue;
546
+ }
547
+
548
+ if (seenPaths.has(artifact.path)) {
549
+ errors.push(`duplicate artifact path "${artifact.path}" in phase "${phase}"`);
550
+ }
551
+ seenPaths.add(artifact.path);
552
+
553
+ if (artifact.semantics !== null && artifact.semantics !== undefined) {
554
+ if (typeof artifact.semantics !== 'string') {
555
+ errors.push(`${prefix} semantics must be a string or null`);
556
+ } else if (!VALID_SEMANTIC_IDS.includes(artifact.semantics)) {
557
+ errors.push(`${prefix} unknown semantics validator "${artifact.semantics}"; valid values: ${VALID_SEMANTIC_IDS.join(', ')}`);
558
+ } else if (artifact.semantics === 'section_check') {
559
+ if (!artifact.semantics_config || typeof artifact.semantics_config !== 'object') {
560
+ errors.push(`${prefix} section_check requires semantics_config`);
561
+ } else if (!Array.isArray(artifact.semantics_config.required_sections) || artifact.semantics_config.required_sections.length === 0) {
562
+ errors.push(`${prefix} section_check requires semantics_config.required_sections as a non-empty array`);
563
+ } else {
564
+ for (const section of artifact.semantics_config.required_sections) {
565
+ if (typeof section !== 'string' || !section.trim()) {
566
+ errors.push(`${prefix} section_check required_sections must contain non-empty strings`);
567
+ break;
568
+ }
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ if (artifact.required !== undefined && typeof artifact.required !== 'boolean') {
575
+ errors.push(`${prefix} required must be a boolean`);
576
+ }
577
+ }
578
+ }
579
+ }
580
+
581
+ return { ok: errors.length === 0, errors, warnings };
582
+ }
583
+
454
584
  /**
455
585
  * Normalize a legacy v3 config into the internal shape.
456
586
  * Does NOT modify the original file — this is a read-time transformation.
@@ -492,6 +622,7 @@ export function normalizeV3(raw) {
492
622
  hooks: {},
493
623
  notifications: {},
494
624
  budget: null,
625
+ workflow_kit: normalizeWorkflowKit(undefined, DEFAULT_PHASES),
495
626
  retention: {
496
627
  talk_strategy: 'append_only',
497
628
  history_strategy: 'jsonl_append_only',
@@ -536,6 +667,9 @@ export function normalizeV4(raw) {
536
667
  }
537
668
  }
538
669
 
670
+ const routing = raw.routing || {};
671
+ const routingPhases = Object.keys(routing).length > 0 ? Object.keys(routing) : DEFAULT_PHASES;
672
+
539
673
  return {
540
674
  schema_version: 4,
541
675
  protocol_mode: 'governed',
@@ -547,11 +681,12 @@ export function normalizeV4(raw) {
547
681
  },
548
682
  roles,
549
683
  runtimes: raw.runtimes || {},
550
- routing: raw.routing || {},
684
+ routing,
551
685
  gates: raw.gates || {},
552
686
  hooks: raw.hooks || {},
553
687
  notifications: raw.notifications || {},
554
688
  budget: raw.budget || null,
689
+ workflow_kit: normalizeWorkflowKit(raw.workflow_kit, routingPhases),
555
690
  retention: raw.retention || {
556
691
  talk_strategy: 'append_only',
557
692
  history_strategy: 'jsonl_append_only',
@@ -633,6 +768,50 @@ export function getMaxConcurrentTurns(config, phase) {
633
768
  }
634
769
 
635
770
 
771
+ /**
772
+ * Normalize workflow_kit config.
773
+ * When absent, builds defaults from routing phases using DEFAULT_PHASE_ARTIFACTS.
774
+ * When present, normalizes artifact entries.
775
+ */
776
+ export function normalizeWorkflowKit(raw, routingPhases) {
777
+ if (raw === undefined || raw === null) {
778
+ return buildDefaultWorkflowKit(routingPhases);
779
+ }
780
+
781
+ // Empty object is an explicit opt-out — no artifacts
782
+ if (typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length === 0) {
783
+ return { phases: {}, _explicit: true };
784
+ }
785
+
786
+ const phases = {};
787
+ if (raw.phases) {
788
+ for (const [phase, phaseConfig] of Object.entries(raw.phases)) {
789
+ phases[phase] = {
790
+ artifacts: (phaseConfig.artifacts || []).map(a => ({
791
+ path: a.path,
792
+ semantics: a.semantics || null,
793
+ semantics_config: a.semantics_config || null,
794
+ required: a.required !== false,
795
+ })),
796
+ };
797
+ }
798
+ }
799
+
800
+ return { phases, _explicit: true };
801
+ }
802
+
803
+ function buildDefaultWorkflowKit(routingPhases) {
804
+ const phases = {};
805
+ for (const phase of routingPhases) {
806
+ if (DEFAULT_PHASE_ARTIFACTS[phase]) {
807
+ phases[phase] = {
808
+ artifacts: DEFAULT_PHASE_ARTIFACTS[phase].map(a => ({ ...a, semantics_config: null })),
809
+ };
810
+ }
811
+ }
812
+ return { phases };
813
+ }
814
+
636
815
  // --- Internal helpers ---
637
816
 
638
817
  function inferWriteAuthority(agentId) {
@@ -8,6 +8,14 @@ export const ACCEPTANCE_MATRIX_PATH = '.planning/acceptance-matrix.md';
8
8
  export const SHIP_VERDICT_PATH = '.planning/ship-verdict.md';
9
9
  export const RELEASE_NOTES_PATH = '.planning/RELEASE_NOTES.md';
10
10
  export const RECOVERY_REPORT_PATH = '.agentxchain/multirepo/RECOVERY_REPORT.md';
11
+ const PATH_SEMANTIC_IDS = {
12
+ [PM_SIGNOFF_PATH]: 'pm_signoff',
13
+ [SYSTEM_SPEC_PATH]: 'system_spec',
14
+ [IMPLEMENTATION_NOTES_PATH]: 'implementation_notes',
15
+ [ACCEPTANCE_MATRIX_PATH]: 'acceptance_matrix',
16
+ [SHIP_VERDICT_PATH]: 'ship_verdict',
17
+ [RELEASE_NOTES_PATH]: 'release_notes',
18
+ };
11
19
 
12
20
  const AFFIRMATIVE_SHIP_VERDICTS = new Set(['YES', 'SHIP', 'SHIP IT']);
13
21
  const AFFIRMATIVE_ACCEPTANCE_STATUSES = new Set(['PASS', 'PASSED', 'OK', 'YES']);
@@ -354,6 +362,57 @@ function evaluateShipVerdict(content) {
354
362
  return { ok: true };
355
363
  }
356
364
 
365
+ function evaluateSectionCheck(content, config) {
366
+ if (!config?.required_sections?.length) {
367
+ return { ok: true };
368
+ }
369
+ const missing = config.required_sections.filter(
370
+ section => !content.includes(section)
371
+ );
372
+ if (missing.length > 0) {
373
+ return {
374
+ ok: false,
375
+ reason: `Document must contain sections: ${missing.join(', ')}`,
376
+ };
377
+ }
378
+ return { ok: true };
379
+ }
380
+
381
+ const SEMANTIC_VALIDATORS = {
382
+ pm_signoff: evaluatePmSignoff,
383
+ system_spec: evaluateSystemSpec,
384
+ implementation_notes: evaluateImplementationNotes,
385
+ acceptance_matrix: evaluateAcceptanceMatrix,
386
+ ship_verdict: evaluateShipVerdict,
387
+ release_notes: evaluateReleaseNotes,
388
+ section_check: evaluateSectionCheck,
389
+ };
390
+
391
+ export function getSemanticIdForPath(relPath) {
392
+ return PATH_SEMANTIC_IDS[relPath] || null;
393
+ }
394
+
395
+ /**
396
+ * Evaluate a workflow artifact's semantic constraints by validator ID.
397
+ * @param {string} root - project root directory
398
+ * @param {object} artifact - { path, semantics, semantics_config }
399
+ * @returns {{ ok: boolean, reason?: string } | null}
400
+ */
401
+ export function evaluateArtifactSemantics(root, artifact) {
402
+ if (!artifact.semantics) return null;
403
+ const validator = SEMANTIC_VALIDATORS[artifact.semantics];
404
+ if (!validator) return null;
405
+ const content = readFile(root, artifact.path);
406
+ if (content === null) return null;
407
+ if (artifact.semantics === 'section_check') {
408
+ return validator(content, artifact.semantics_config);
409
+ }
410
+ return validator(content);
411
+ }
412
+
413
+ /**
414
+ * Path-based dispatch (backward compat for code calling this directly).
415
+ */
357
416
  export function evaluateWorkflowGateSemantics(root, relPath) {
358
417
  const content = readFile(root, relPath);
359
418
  if (content === null) {