agentxchain 2.25.1 → 2.26.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.
@@ -19,7 +19,131 @@
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
+ owned_by: null,
58
+ };
59
+
60
+ existing.required = existing.required || artifact.required !== false;
61
+
62
+ if (artifact.owned_by && typeof artifact.owned_by === 'string') {
63
+ existing.owned_by = artifact.owned_by;
64
+ }
65
+
66
+ if (artifact.semantics) {
67
+ const legacySemanticId = existing.useLegacySemantics ? getSemanticIdForPath(artifact.path) : null;
68
+ if (artifact.semantics !== legacySemanticId) {
69
+ existing.semanticChecks.push({
70
+ semantics: artifact.semantics,
71
+ semantics_config: artifact.semantics_config || null,
72
+ });
73
+ }
74
+ }
75
+
76
+ byPath.set(artifact.path, existing);
77
+ }
78
+
79
+ return [...byPath.values()];
80
+ }
81
+
82
+ function addMissingFile(result, filePath) {
83
+ if (!result.missing_files.includes(filePath)) {
84
+ result.missing_files.push(filePath);
85
+ }
86
+ }
87
+
88
+ function prefixSemanticReason(filePath, reason) {
89
+ if (!reason || reason.includes(filePath)) {
90
+ return reason;
91
+ }
92
+ return `${filePath}: ${reason}`;
93
+ }
94
+
95
+ function hasRoleParticipationInPhase(state, phase, roleId) {
96
+ const history = state?.history;
97
+ if (!Array.isArray(history)) {
98
+ return false;
99
+ }
100
+ return history.some(
101
+ turn => turn.phase === phase && turn.role === roleId,
102
+ );
103
+ }
104
+
105
+ function evaluateGateArtifacts({ root, config, gateDef, phase, result, state }) {
106
+ const failures = [];
107
+ const artifacts = buildEffectiveGateArtifacts(config, gateDef, phase);
108
+
109
+ for (const artifact of artifacts) {
110
+ const absPath = join(root, artifact.path);
111
+ if (!existsSync(absPath)) {
112
+ if (artifact.required) {
113
+ addMissingFile(result, artifact.path);
114
+ failures.push(`Required file missing: ${artifact.path}`);
115
+ }
116
+ continue;
117
+ }
118
+
119
+ if (artifact.useLegacySemantics) {
120
+ const semanticCheck = evaluateWorkflowGateSemantics(root, artifact.path);
121
+ if (semanticCheck && !semanticCheck.ok) {
122
+ failures.push(semanticCheck.reason);
123
+ }
124
+ }
125
+
126
+ for (const semantic of artifact.semanticChecks) {
127
+ const semanticCheck = evaluateArtifactSemantics(root, {
128
+ path: artifact.path,
129
+ semantics: semantic.semantics,
130
+ semantics_config: semantic.semantics_config,
131
+ });
132
+ if (semanticCheck && !semanticCheck.ok) {
133
+ failures.push(prefixSemanticReason(artifact.path, semanticCheck.reason));
134
+ }
135
+ }
136
+
137
+ // Charter enforcement: verify owning role participated in this phase
138
+ if (artifact.owned_by && !hasRoleParticipationInPhase(state, phase, artifact.owned_by)) {
139
+ failures.push(
140
+ `"${artifact.path}" requires participation from role "${artifact.owned_by}" in phase "${phase}", but no accepted turn from that role was found`,
141
+ );
142
+ }
143
+ }
144
+
145
+ return failures;
146
+ }
23
147
 
24
148
  /**
25
149
  * Evaluate whether the current phase exit gate is satisfied.
@@ -117,21 +241,15 @@ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
117
241
 
118
242
  const failures = [];
119
243
 
120
- // 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
- }
244
+ // Predicate: requires_files + ownership
245
+ failures.push(...evaluateGateArtifacts({
246
+ root,
247
+ config,
248
+ gateDef,
249
+ phase: currentPhase,
250
+ result,
251
+ state,
252
+ }));
135
253
 
136
254
  // Predicate: requires_verification_pass
137
255
  if (gateDef.requires_verification_pass) {
@@ -240,20 +358,14 @@ export function evaluateRunCompletion({ state, config, acceptedTurn, root }) {
240
358
  const result = { ...baseResult, gate_id: gateId };
241
359
  const failures = [];
242
360
 
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
- }
361
+ failures.push(...evaluateGateArtifacts({
362
+ root,
363
+ config,
364
+ gateDef,
365
+ phase: currentPhase,
366
+ result,
367
+ state,
368
+ }));
257
369
 
258
370
  if (gateDef.requires_verification_pass) {
259
371
  const verificationStatus = acceptedTurn.verification?.status;
@@ -2129,6 +2129,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2129
2129
  turn_id: turnResult.turn_id,
2130
2130
  run_id: turnResult.run_id,
2131
2131
  role: turnResult.role,
2132
+ phase: state.phase,
2132
2133
  runtime_id: turnResult.runtime_id,
2133
2134
  status: turnResult.status,
2134
2135
  summary: turnResult.summary,
@@ -2280,12 +2281,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2280
2281
  };
2281
2282
  }
2282
2283
  } else {
2284
+ const nextHistoryEntries = [...historyEntries, historyEntry];
2283
2285
  const postAcceptanceState = {
2284
2286
  ...state,
2285
2287
  active_turns: remainingTurns,
2286
2288
  turn_sequence: acceptedSequence,
2289
+ history: nextHistoryEntries,
2287
2290
  };
2288
- const nextHistoryEntries = [...historyEntries, historyEntry];
2289
2291
  const completionSource = turnResult.run_completion_request
2290
2292
  ? turnResult
2291
2293
  : findHistoryTurnRequest(nextHistoryEntries, state.queued_run_completion?.requested_by_turn, 'run_completion');
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { validateV4Config } from './normalized-config.js';
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
 
@@ -11,9 +12,10 @@ export const VALID_GOVERNED_TEMPLATE_IDS = Object.freeze([
11
12
  'cli-tool',
12
13
  'library',
13
14
  'web-app',
15
+ 'enterprise-app',
14
16
  ]);
15
17
 
16
- const VALID_PROMPT_OVERRIDE_ROLES = new Set(['pm', 'dev', 'qa', 'eng_director']);
18
+ const VALID_ROLE_ID_PATTERN = /^[a-z0-9_-]+$/;
17
19
 
18
20
  function validatePlanningArtifacts(artifacts, errors) {
19
21
  if (!Array.isArray(artifacts)) {
@@ -50,8 +52,8 @@ function validatePromptOverrides(promptOverrides, errors) {
50
52
  }
51
53
 
52
54
  for (const [roleId, content] of Object.entries(promptOverrides)) {
53
- if (!VALID_PROMPT_OVERRIDE_ROLES.has(roleId)) {
54
- errors.push(`prompt_overrides contains unknown role "${roleId}"`);
55
+ if (!VALID_ROLE_ID_PATTERN.test(roleId)) {
56
+ errors.push(`prompt_overrides contains invalid role ID "${roleId}" (must match ${VALID_ROLE_ID_PATTERN})`);
55
57
  }
56
58
  if (typeof content !== 'string' || !content.trim()) {
57
59
  errors.push(`prompt_overrides["${roleId}"] must be a non-empty string`);
@@ -73,6 +75,47 @@ function validateAcceptanceHints(acceptanceHints, errors) {
73
75
  }
74
76
  }
75
77
 
78
+ const VALID_SCAFFOLD_BLUEPRINT_KEYS = new Set([
79
+ 'roles',
80
+ 'runtimes',
81
+ 'routing',
82
+ 'gates',
83
+ 'workflow_kit',
84
+ ]);
85
+
86
+ function validateScaffoldBlueprint(scaffoldBlueprint, errors) {
87
+ if (scaffoldBlueprint === undefined) return;
88
+ if (!scaffoldBlueprint || typeof scaffoldBlueprint !== 'object' || Array.isArray(scaffoldBlueprint)) {
89
+ errors.push('scaffold_blueprint must be an object when provided');
90
+ return;
91
+ }
92
+
93
+ for (const key of Object.keys(scaffoldBlueprint)) {
94
+ if (!VALID_SCAFFOLD_BLUEPRINT_KEYS.has(key)) {
95
+ errors.push(`scaffold_blueprint contains unknown key "${key}"`);
96
+ }
97
+ }
98
+
99
+ const validation = validateV4Config({
100
+ schema_version: '1.0',
101
+ project: {
102
+ id: 'template-manifest',
103
+ name: 'Template Manifest',
104
+ },
105
+ roles: scaffoldBlueprint.roles,
106
+ runtimes: scaffoldBlueprint.runtimes,
107
+ routing: scaffoldBlueprint.routing,
108
+ gates: scaffoldBlueprint.gates,
109
+ workflow_kit: scaffoldBlueprint.workflow_kit,
110
+ });
111
+
112
+ if (!validation.ok) {
113
+ for (const error of validation.errors) {
114
+ errors.push(`scaffold_blueprint ${error}`);
115
+ }
116
+ }
117
+ }
118
+
76
119
  const VALID_SPEC_OVERLAY_KEYS = new Set([
77
120
  'purpose_guidance',
78
121
  'interface_guidance',
@@ -138,6 +181,7 @@ export function validateGovernedTemplateManifest(manifest, expectedId = null) {
138
181
  validatePromptOverrides(manifest.prompt_overrides, errors);
139
182
  validateAcceptanceHints(manifest.acceptance_hints, errors);
140
183
  validateSystemSpecOverlay(manifest.system_spec_overlay, errors);
184
+ validateScaffoldBlueprint(manifest.scaffold_blueprint, errors);
141
185
 
142
186
  return { ok: errors.length === 0, errors };
143
187
  }
@@ -446,7 +490,26 @@ export function validateGovernedWorkflowKit(root, config = {}) {
446
490
  const gateRequiredFiles = uniqueStrings(
447
491
  Object.values(config?.gates || {}).flatMap((gate) => Array.isArray(gate?.requires_files) ? gate.requires_files : [])
448
492
  );
449
- const requiredFiles = uniqueStrings([...GOVERNED_WORKFLOW_KIT_BASE_FILES, ...gateRequiredFiles]);
493
+
494
+ // Collect workflow-kit artifact paths from explicit config
495
+ const wkArtifactPaths = [];
496
+ const wk = config?.workflow_kit;
497
+ if (wk && wk.phases && typeof wk.phases === 'object') {
498
+ for (const phaseConfig of Object.values(wk.phases)) {
499
+ if (Array.isArray(phaseConfig.artifacts)) {
500
+ for (const a of phaseConfig.artifacts) {
501
+ if (a.path && a.required !== false) {
502
+ wkArtifactPaths.push(a.path);
503
+ }
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ const hasExplicitWorkflowKit = wk && wk._explicit === true;
510
+ const hasExplicitWorkflowKitArtifacts = hasExplicitWorkflowKit && Object.keys(wk.phases || {}).length > 0;
511
+ const baseFiles = hasExplicitWorkflowKit ? wkArtifactPaths : GOVERNED_WORKFLOW_KIT_BASE_FILES;
512
+ const requiredFiles = uniqueStrings([...baseFiles, ...gateRequiredFiles]);
450
513
  const present = [];
451
514
  const missing = [];
452
515
 
@@ -459,32 +522,38 @@ export function validateGovernedWorkflowKit(root, config = {}) {
459
522
  }
460
523
  }
461
524
 
462
- const structuralChecks = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.map((check) => {
463
- const absPath = join(root, check.file);
464
- if (!existsSync(absPath)) {
525
+ // Build structural checks: from explicit workflow_kit semantics or hardcoded defaults
526
+ let structuralChecks;
527
+ if (hasExplicitWorkflowKit) {
528
+ structuralChecks = buildStructuralChecksFromWorkflowKit(root, wk, errors);
529
+ } else {
530
+ structuralChecks = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.map((check) => {
531
+ const absPath = join(root, check.file);
532
+ if (!existsSync(absPath)) {
533
+ return {
534
+ id: check.id,
535
+ file: check.file,
536
+ ok: false,
537
+ skipped: true,
538
+ description: check.description,
539
+ };
540
+ }
541
+
542
+ const content = readFileSync(absPath, 'utf8');
543
+ const ok = check.pattern.test(content);
544
+ if (!ok) {
545
+ errors.push(`Workflow kit file "${check.file}" must preserve its structural marker: ${check.description}.`);
546
+ }
547
+
465
548
  return {
466
549
  id: check.id,
467
550
  file: check.file,
468
- ok: false,
469
- skipped: true,
551
+ ok,
552
+ skipped: false,
470
553
  description: check.description,
471
554
  };
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
- });
555
+ });
556
+ }
488
557
 
489
558
  return {
490
559
  ok: errors.length === 0,
@@ -498,6 +567,64 @@ export function validateGovernedWorkflowKit(root, config = {}) {
498
567
  };
499
568
  }
500
569
 
570
+ function buildStructuralChecksFromWorkflowKit(root, wk, errors) {
571
+ const checks = [];
572
+ if (!wk.phases) return checks;
573
+
574
+ for (const [phase, phaseConfig] of Object.entries(wk.phases)) {
575
+ if (!Array.isArray(phaseConfig.artifacts)) continue;
576
+ for (const artifact of phaseConfig.artifacts) {
577
+ if (!artifact.semantics) continue;
578
+
579
+ if (artifact.semantics === 'section_check' && artifact.semantics_config?.required_sections?.length) {
580
+ for (const section of artifact.semantics_config.required_sections) {
581
+ const checkId = `wk_${phase}_${artifact.path.replace(/[^a-zA-Z0-9]/g, '_')}_section_${section.replace(/[^a-zA-Z0-9]/g, '_')}`;
582
+ const description = `${artifact.path} defines ${section}`;
583
+ const absPath = join(root, artifact.path);
584
+
585
+ if (!existsSync(absPath)) {
586
+ checks.push({ id: checkId, file: artifact.path, ok: false, skipped: true, description });
587
+ continue;
588
+ }
589
+
590
+ const content = readFileSync(absPath, 'utf8');
591
+ const ok = content.includes(section);
592
+ if (!ok) {
593
+ errors.push(`Workflow kit file "${artifact.path}" must contain section: ${section}.`);
594
+ }
595
+ checks.push({ id: checkId, file: artifact.path, ok, skipped: false, description });
596
+ }
597
+ } else if (artifact.semantics !== 'section_check') {
598
+ // Built-in semantic check — generate a structural check entry
599
+ const checkId = `wk_${phase}_${artifact.semantics}`;
600
+ const description = `${artifact.path} passes ${artifact.semantics} validation`;
601
+ const absPath = join(root, artifact.path);
602
+
603
+ if (!existsSync(absPath)) {
604
+ checks.push({ id: checkId, file: artifact.path, ok: false, skipped: true, description });
605
+ continue;
606
+ }
607
+
608
+ // For built-in validators, delegate to the hardcoded check if one exists
609
+ const hardcoded = GOVERNED_WORKFLOW_KIT_STRUCTURAL_CHECKS.find(c => c.file === artifact.path);
610
+ if (hardcoded) {
611
+ const content = readFileSync(absPath, 'utf8');
612
+ const ok = hardcoded.pattern.test(content);
613
+ if (!ok) {
614
+ errors.push(`Workflow kit file "${artifact.path}" must preserve its structural marker: ${hardcoded.description}.`);
615
+ }
616
+ checks.push({ id: checkId, file: artifact.path, ok, skipped: false, description: hardcoded.description });
617
+ } else {
618
+ // No hardcoded check for this semantic — mark as passing (runtime gate handles full validation)
619
+ checks.push({ id: checkId, file: artifact.path, ok: true, skipped: false, description });
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ return checks;
626
+ }
627
+
501
628
  export const SYSTEM_SPEC_OVERLAY_SEPARATOR = '## Template-Specific Guidance';
502
629
 
503
630
  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,127 @@ 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, data.roles);
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, roles) {
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
+ if (artifact.owned_by !== undefined && artifact.owned_by !== null) {
579
+ if (typeof artifact.owned_by !== 'string') {
580
+ errors.push(`${prefix} owned_by must be a string`);
581
+ } else if (!/^[a-z0-9_-]+$/.test(artifact.owned_by)) {
582
+ errors.push(`${prefix} owned_by "${artifact.owned_by}" is not a valid role ID (must be lowercase alphanumeric with hyphens/underscores)`);
583
+ } else if (roles && typeof roles === 'object' && !roles[artifact.owned_by]) {
584
+ errors.push(`${prefix} owned_by "${artifact.owned_by}" does not reference a defined role`);
585
+ }
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ return { ok: errors.length === 0, errors, warnings };
592
+ }
593
+
454
594
  /**
455
595
  * Normalize a legacy v3 config into the internal shape.
456
596
  * Does NOT modify the original file — this is a read-time transformation.
@@ -492,6 +632,7 @@ export function normalizeV3(raw) {
492
632
  hooks: {},
493
633
  notifications: {},
494
634
  budget: null,
635
+ workflow_kit: normalizeWorkflowKit(undefined, DEFAULT_PHASES),
495
636
  retention: {
496
637
  talk_strategy: 'append_only',
497
638
  history_strategy: 'jsonl_append_only',
@@ -536,6 +677,9 @@ export function normalizeV4(raw) {
536
677
  }
537
678
  }
538
679
 
680
+ const routing = raw.routing || {};
681
+ const routingPhases = Object.keys(routing).length > 0 ? Object.keys(routing) : DEFAULT_PHASES;
682
+
539
683
  return {
540
684
  schema_version: 4,
541
685
  protocol_mode: 'governed',
@@ -547,11 +691,12 @@ export function normalizeV4(raw) {
547
691
  },
548
692
  roles,
549
693
  runtimes: raw.runtimes || {},
550
- routing: raw.routing || {},
694
+ routing,
551
695
  gates: raw.gates || {},
552
696
  hooks: raw.hooks || {},
553
697
  notifications: raw.notifications || {},
554
698
  budget: raw.budget || null,
699
+ workflow_kit: normalizeWorkflowKit(raw.workflow_kit, routingPhases),
555
700
  retention: raw.retention || {
556
701
  talk_strategy: 'append_only',
557
702
  history_strategy: 'jsonl_append_only',
@@ -633,6 +778,51 @@ export function getMaxConcurrentTurns(config, phase) {
633
778
  }
634
779
 
635
780
 
781
+ /**
782
+ * Normalize workflow_kit config.
783
+ * When absent, builds defaults from routing phases using DEFAULT_PHASE_ARTIFACTS.
784
+ * When present, normalizes artifact entries.
785
+ */
786
+ export function normalizeWorkflowKit(raw, routingPhases) {
787
+ if (raw === undefined || raw === null) {
788
+ return buildDefaultWorkflowKit(routingPhases);
789
+ }
790
+
791
+ // Empty object is an explicit opt-out — no artifacts
792
+ if (typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length === 0) {
793
+ return { phases: {}, _explicit: true };
794
+ }
795
+
796
+ const phases = {};
797
+ if (raw.phases) {
798
+ for (const [phase, phaseConfig] of Object.entries(raw.phases)) {
799
+ phases[phase] = {
800
+ artifacts: (phaseConfig.artifacts || []).map(a => ({
801
+ path: a.path,
802
+ semantics: a.semantics || null,
803
+ semantics_config: a.semantics_config || null,
804
+ owned_by: a.owned_by || null,
805
+ required: a.required !== false,
806
+ })),
807
+ };
808
+ }
809
+ }
810
+
811
+ return { phases, _explicit: true };
812
+ }
813
+
814
+ function buildDefaultWorkflowKit(routingPhases) {
815
+ const phases = {};
816
+ for (const phase of routingPhases) {
817
+ if (DEFAULT_PHASE_ARTIFACTS[phase]) {
818
+ phases[phase] = {
819
+ artifacts: DEFAULT_PHASE_ARTIFACTS[phase].map(a => ({ ...a, semantics_config: null })),
820
+ };
821
+ }
822
+ }
823
+ return { phases };
824
+ }
825
+
636
826
  // --- Internal helpers ---
637
827
 
638
828
  function inferWriteAuthority(agentId) {