agentxchain 2.11.0 → 2.13.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
@@ -25,7 +25,7 @@ npm install -g agentxchain
25
25
  Or run without installing:
26
26
 
27
27
  ```bash
28
- npx agentxchain init --governed -y
28
+ npx agentxchain init --governed --dir my-agentxchain-project -y
29
29
  ```
30
30
 
31
31
  ## Testing
@@ -49,7 +49,7 @@ Duplicate execution remains intentional for the current 36-file slice until a la
49
49
  ### Governed workflow
50
50
 
51
51
  ```bash
52
- npx agentxchain init --governed -y
52
+ npx agentxchain init --governed --dir my-agentxchain-project -y
53
53
  cd my-agentxchain-project
54
54
  git init
55
55
  git add -A
@@ -58,10 +58,16 @@ agentxchain status
58
58
  agentxchain step --role pm
59
59
  ```
60
60
 
61
+ The default governed dev runtime is `claude --print` with stdin prompt delivery. If your local coding agent uses a different launch contract, set it during scaffold creation:
62
+
63
+ ```bash
64
+ npx agentxchain init --governed --dir my-agentxchain-project --dev-command ./scripts/dev-agent.sh --dev-prompt-transport dispatch_bundle_only -y
65
+ ```
66
+
61
67
  If you want template-specific planning artifacts from day one:
62
68
 
63
69
  ```bash
64
- npx agentxchain init --governed --template api-service -y
70
+ npx agentxchain init --governed --template api-service --dir my-agentxchain-project -y
65
71
  ```
66
72
 
67
73
  Built-in governed templates:
@@ -83,7 +89,20 @@ agentxchain step --role qa
83
89
  agentxchain approve-completion
84
90
  ```
85
91
 
86
- Default governed scaffolding configures QA as `api_proxy` with `ANTHROPIC_API_KEY`. For a provider-free walkthrough, switch the QA runtime to `manual` before the QA step.
92
+ Default governed scaffolding configures QA as `api_proxy` with `ANTHROPIC_API_KEY`. For a provider-free walkthrough, switch the QA runtime to `manual` before the QA step. If you override the dev runtime, either include `{prompt}` for argv delivery or set `--dev-prompt-transport` explicitly.
93
+
94
+ ### Multi-repo coordination
95
+
96
+ For initiatives spanning multiple governed repos, use the coordinator to add cross-repo sequencing and shared gates:
97
+
98
+ ```bash
99
+ npx agentxchain init --governed --template api-service --dir repos/backend -y
100
+ npx agentxchain init --governed --template web-app --dir repos/frontend -y
101
+ agentxchain multi init
102
+ agentxchain multi step --repo backend --role pm
103
+ ```
104
+
105
+ See the [multi-repo quickstart](https://agentxchain.dev/docs/quickstart#multi-repo-cold-start) for the full cold-start walkthrough.
87
106
 
88
107
  ### Migrate a legacy project
89
108
 
@@ -99,7 +118,7 @@ agentxchain step
99
118
 
100
119
  | Command | What it does |
101
120
  |---|---|
102
- | `init --governed [--template <id>]` | Create a governed project, optionally with project-shape-specific planning artifacts |
121
+ | `init --governed [--dir <path>] [--template <id>]` | Create a governed project, optionally in-place or in an explicit target directory, with project-shape-specific planning artifacts |
103
122
  | `migrate` | Convert a legacy v3 project to governed format |
104
123
  | `status` | Show current run, template, phase, turn, and approval state |
105
124
  | `resume` | Initialize or continue a governed run and assign the next turn |
@@ -115,7 +115,10 @@ program
115
115
  .description('Create a new AgentXchain project folder')
116
116
  .option('-y, --yes', 'Skip prompts, use defaults')
117
117
  .option('--governed', 'Create a governed project (orchestrator-owned state)')
118
+ .option('--dir <path>', 'Scaffold target directory. Use "." for in-place bootstrap.')
118
119
  .option('--template <id>', 'Governed scaffold template: generic, api-service, cli-tool, library, web-app')
120
+ .option('--dev-command <parts...>', 'Governed local-dev command parts. Include {prompt} for argv prompt delivery.')
121
+ .option('--dev-prompt-transport <mode>', 'Governed local-dev prompt transport: argv, stdin, dispatch_bundle_only')
119
122
  .option('--schema-version <version>', 'Schema version (3 for legacy, or use --governed for current)')
120
123
  .action(initCommand);
121
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +1,12 @@
1
1
  import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
2
- import { join, resolve, dirname } from 'path';
2
+ import { basename, join, relative, resolve, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import chalk from 'chalk';
5
5
  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 } from '../lib/governed-templates.js';
9
+ import { VALID_PROMPT_TRANSPORTS } from '../lib/normalized-config.js';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const TEMPLATES_DIR = join(__dirname, '../templates');
@@ -93,9 +94,16 @@ const GOVERNED_ROLES = {
93
94
  }
94
95
  };
95
96
 
97
+ const DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME = Object.freeze({
98
+ type: 'local_cli',
99
+ command: ['claude', '--print'],
100
+ cwd: '.',
101
+ prompt_transport: 'stdin',
102
+ });
103
+
96
104
  const GOVERNED_RUNTIMES = {
97
105
  'manual-pm': { type: 'manual' },
98
- 'local-dev': { type: 'local_cli', command: ['claude', '--print', '-p', '{prompt}'], cwd: '.', prompt_transport: 'argv' },
106
+ 'local-dev': DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME,
99
107
  'api-qa': { type: 'api_proxy', provider: 'anthropic', model: 'claude-sonnet-4-6', auth_env: 'ANTHROPIC_API_KEY' },
100
108
  'manual-director': { type: 'manual' }
101
109
  };
@@ -353,8 +361,92 @@ ${role.write_authority === 'authoritative'
353
361
  `;
354
362
  }
355
363
 
356
- export function scaffoldGoverned(dir, projectName, projectId, templateId = 'generic') {
364
+ function commandHasPromptPlaceholder(parts = []) {
365
+ return parts.some((part) => typeof part === 'string' && part.includes('{prompt}'));
366
+ }
367
+
368
+ function resolveGovernedLocalDevRuntime(opts = {}) {
369
+ const customCommand = Array.isArray(opts.devCommand)
370
+ ? opts.devCommand.map((part) => String(part).trim()).filter(Boolean)
371
+ : null;
372
+ const explicitTransport = typeof opts.devPromptTransport === 'string' && opts.devPromptTransport.trim()
373
+ ? opts.devPromptTransport.trim()
374
+ : null;
375
+
376
+ if (explicitTransport && !VALID_PROMPT_TRANSPORTS.includes(explicitTransport)) {
377
+ throw new Error(`Unknown --dev-prompt-transport "${explicitTransport}". Valid values: ${VALID_PROMPT_TRANSPORTS.join(', ')}`);
378
+ }
379
+
380
+ if (!customCommand?.length) {
381
+ const command = [...DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME.command];
382
+ if (explicitTransport === 'argv') {
383
+ throw new Error('Default local dev command does not include {prompt}. Use --dev-command ... {prompt} for argv mode.');
384
+ }
385
+ return {
386
+ runtime: {
387
+ ...DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME,
388
+ command,
389
+ prompt_transport: explicitTransport || DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME.prompt_transport,
390
+ },
391
+ };
392
+ }
393
+
394
+ const hasPlaceholder = commandHasPromptPlaceholder(customCommand);
395
+
396
+ if (!explicitTransport && !hasPlaceholder) {
397
+ throw new Error('Custom --dev-command must either include {prompt} or set --dev-prompt-transport explicitly.');
398
+ }
399
+
400
+ if (explicitTransport === 'argv' && !hasPlaceholder) {
401
+ throw new Error('--dev-prompt-transport argv requires {prompt} in --dev-command.');
402
+ }
403
+
404
+ if (explicitTransport && explicitTransport !== 'argv' && hasPlaceholder) {
405
+ throw new Error(`--dev-prompt-transport ${explicitTransport} must not be combined with {prompt} in --dev-command.`);
406
+ }
407
+
408
+ return {
409
+ runtime: {
410
+ type: 'local_cli',
411
+ command: customCommand,
412
+ cwd: '.',
413
+ prompt_transport: explicitTransport || 'argv',
414
+ },
415
+ };
416
+ }
417
+
418
+ function formatGovernedRuntimeCommand(runtime) {
419
+ return Array.isArray(runtime?.command) ? runtime.command.join(' ') : String(runtime?.command || '');
420
+ }
421
+
422
+ function resolveInitDirOption(dirOption) {
423
+ if (dirOption == null) return null;
424
+ const value = String(dirOption).trim();
425
+ if (!value) {
426
+ throw new Error('--dir must not be empty.');
427
+ }
428
+ return value;
429
+ }
430
+
431
+ function inferProjectNameFromTarget(targetPath, fallbackName) {
432
+ const inferred = basename(resolve(process.cwd(), targetPath));
433
+ return inferred && inferred.trim() ? inferred : fallbackName;
434
+ }
435
+
436
+ function formatInitTarget(dir) {
437
+ const rel = relative(process.cwd(), dir);
438
+ if (!rel) return '.';
439
+ if (!rel.startsWith('..')) return rel;
440
+ return dir;
441
+ }
442
+
443
+ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'generic', runtimeOptions = {}) {
357
444
  const template = loadGovernedTemplate(templateId);
445
+ const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
446
+ const runtimes = {
447
+ ...GOVERNED_RUNTIMES,
448
+ 'local-dev': localDevRuntime,
449
+ };
358
450
  const config = {
359
451
  schema_version: '1.0',
360
452
  template: template.id,
@@ -364,7 +456,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
364
456
  default_branch: 'main'
365
457
  },
366
458
  roles: GOVERNED_ROLES,
367
- runtimes: GOVERNED_RUNTIMES,
459
+ runtimes,
368
460
  routing: GOVERNED_ROUTING,
369
461
  gates: GOVERNED_GATES,
370
462
  budget: {
@@ -475,6 +567,14 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
475
567
  async function initGoverned(opts) {
476
568
  let projectName, folderName;
477
569
  const templateId = opts.template || 'generic';
570
+ let explicitDir;
571
+
572
+ try {
573
+ explicitDir = resolveInitDirOption(opts.dir);
574
+ } catch (err) {
575
+ console.error(chalk.red(` Error: ${err.message}`));
576
+ process.exit(1);
577
+ }
478
578
 
479
579
  if (!VALID_GOVERNED_TEMPLATE_IDS.includes(templateId)) {
480
580
  console.error(chalk.red(` Error: Unknown template "${templateId}".`));
@@ -489,29 +589,44 @@ async function initGoverned(opts) {
489
589
  }
490
590
 
491
591
  if (opts.yes) {
492
- projectName = 'My AgentXchain Project';
493
- folderName = slugify(projectName);
592
+ projectName = explicitDir
593
+ ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain Project')
594
+ : 'My AgentXchain Project';
595
+ folderName = explicitDir || slugify(projectName);
494
596
  } else {
495
597
  const { name } = await inquirer.prompt([{
496
598
  type: 'input',
497
599
  name: 'name',
498
600
  message: 'Project name:',
499
- default: 'My AgentXchain Project'
601
+ default: explicitDir
602
+ ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain Project')
603
+ : 'My AgentXchain Project'
500
604
  }]);
501
605
  projectName = name;
502
- folderName = slugify(projectName);
606
+ folderName = explicitDir || slugify(projectName);
503
607
 
504
- const { folder } = await inquirer.prompt([{
505
- type: 'input',
506
- name: 'folder',
507
- message: 'Folder name:',
508
- default: folderName
509
- }]);
510
- folderName = folder;
608
+ if (!explicitDir) {
609
+ const { folder } = await inquirer.prompt([{
610
+ type: 'input',
611
+ name: 'folder',
612
+ message: 'Folder name:',
613
+ default: folderName
614
+ }]);
615
+ folderName = folder;
616
+ }
511
617
  }
512
618
 
513
619
  const dir = resolve(process.cwd(), folderName);
620
+ const targetLabel = formatInitTarget(dir);
514
621
  const projectId = slugify(projectName);
622
+ let localDevRuntime;
623
+
624
+ try {
625
+ ({ runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(opts));
626
+ } catch (err) {
627
+ console.error(chalk.red(` Error: ${err.message}`));
628
+ process.exit(1);
629
+ }
515
630
 
516
631
  if (existsSync(dir) && existsSync(join(dir, CONFIG_FILE))) {
517
632
  if (!opts.yes) {
@@ -530,10 +645,10 @@ async function initGoverned(opts) {
530
645
 
531
646
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
532
647
 
533
- scaffoldGoverned(dir, projectName, projectId, templateId);
648
+ scaffoldGoverned(dir, projectName, projectId, templateId, opts);
534
649
 
535
650
  console.log('');
536
- console.log(chalk.green(` ✓ Created governed project ${chalk.bold(folderName)}/`));
651
+ console.log(chalk.green(` ✓ Created governed project ${chalk.bold(targetLabel)}/`));
537
652
  console.log('');
538
653
  console.log(` ${chalk.dim('├──')} agentxchain.json ${chalk.dim('(governed)')}`);
539
654
  console.log(` ${chalk.dim('├──')} .agentxchain/`);
@@ -549,10 +664,13 @@ async function initGoverned(opts) {
549
664
  console.log('');
550
665
  console.log(` ${chalk.dim('Roles:')} pm, dev, qa, eng_director`);
551
666
  console.log(` ${chalk.dim('Template:')} ${templateId}`);
667
+ console.log(` ${chalk.dim('Dev runtime:')} ${formatGovernedRuntimeCommand(localDevRuntime)} ${chalk.dim(`(${localDevRuntime.prompt_transport})`)}`);
552
668
  console.log(` ${chalk.dim('Protocol:')} governed convergence`);
553
669
  console.log('');
554
670
  console.log(` ${chalk.cyan('Next:')}`);
555
- console.log(` ${chalk.bold(`cd ${folderName}`)}`);
671
+ if (dir !== process.cwd()) {
672
+ console.log(` ${chalk.bold(`cd ${targetLabel}`)}`);
673
+ }
556
674
  console.log(` ${chalk.bold('agentxchain step')} ${chalk.dim('# run the first governed turn')}`);
557
675
  console.log(` ${chalk.bold('agentxchain status')} ${chalk.dim('# inspect phase, gate, and turn state')}`);
558
676
  console.log('');
@@ -564,11 +682,20 @@ export async function initCommand(opts) {
564
682
  }
565
683
 
566
684
  let project, agents, folderName, rules;
685
+ let explicitDir;
686
+ try {
687
+ explicitDir = resolveInitDirOption(opts.dir);
688
+ } catch (err) {
689
+ console.error(chalk.red(` Error: ${err.message}`));
690
+ process.exit(1);
691
+ }
567
692
 
568
693
  if (opts.yes) {
569
- project = 'My AgentXchain project';
694
+ project = explicitDir
695
+ ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain project')
696
+ : 'My AgentXchain project';
570
697
  agents = DEFAULT_AGENTS;
571
- folderName = slugify(project);
698
+ folderName = explicitDir || slugify(project);
572
699
  rules = {
573
700
  max_consecutive_claims: 2,
574
701
  require_message: true,
@@ -618,7 +745,9 @@ export async function initCommand(opts) {
618
745
  type: 'input',
619
746
  name: 'projectName',
620
747
  message: 'Project name:',
621
- default: 'My AgentXchain project'
748
+ default: explicitDir
749
+ ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain project')
750
+ : 'My AgentXchain project'
622
751
  }]);
623
752
  project = projectName;
624
753
  } else {
@@ -626,7 +755,9 @@ export async function initCommand(opts) {
626
755
  type: 'input',
627
756
  name: 'projectName',
628
757
  message: 'Project name:',
629
- default: 'My AgentXchain project'
758
+ default: explicitDir
759
+ ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain project')
760
+ : 'My AgentXchain project'
630
761
  }]);
631
762
  project = projectName;
632
763
  agents = {};
@@ -681,14 +812,16 @@ export async function initCommand(opts) {
681
812
  }
682
813
  }
683
814
 
684
- folderName = slugify(project);
685
- const { folder } = await inquirer.prompt([{
686
- type: 'input',
687
- name: 'folder',
688
- message: 'Folder name:',
689
- default: folderName
690
- }]);
691
- folderName = folder;
815
+ folderName = explicitDir || slugify(project);
816
+ if (!explicitDir) {
817
+ const { folder } = await inquirer.prompt([{
818
+ type: 'input',
819
+ name: 'folder',
820
+ message: 'Folder name:',
821
+ default: folderName
822
+ }]);
823
+ folderName = folder;
824
+ }
692
825
  }
693
826
 
694
827
  const dir = resolve(process.cwd(), folderName);
@@ -66,6 +66,16 @@ function readRepoLocalHistory(repoPath) {
66
66
  }
67
67
  }
68
68
 
69
+ function isAcceptedRepoHistoryEntry(entry) {
70
+ if (!entry || typeof entry !== 'object') {
71
+ return false;
72
+ }
73
+
74
+ // Real governed history records the turn outcome in `status` and acceptance
75
+ // via `accepted_at`. Older coordinator fixtures used `status: "accepted"`.
76
+ return Boolean(entry.accepted_at) || entry.status === 'accepted';
77
+ }
78
+
69
79
  // ── Divergence Detection ────────────────────────────────────────────────────
70
80
 
71
81
  /**
@@ -166,7 +176,7 @@ export function detectDivergence(workspacePath, state, config) {
166
176
  // Read repo-local history to determine what happened
167
177
  const repoHistory = readRepoLocalHistory(repo.resolved_path);
168
178
  const turnAccepted = repoHistory.some(
169
- e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'accepted'
179
+ e => e?.turn_id === dispatch.repo_turn_id && isAcceptedRepoHistoryEntry(e)
170
180
  );
171
181
  const turnRejected = repoHistory.some(
172
182
  e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'rejected'
@@ -279,7 +289,7 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
279
289
 
280
290
  const repoHistory = readRepoLocalHistory(repo.resolved_path);
281
291
  const acceptedEntry = repoHistory.find(
282
- e => e?.turn_id === dispatch.repo_turn_id && e?.status === 'accepted'
292
+ e => e?.turn_id === dispatch.repo_turn_id && isAcceptedRepoHistoryEntry(e)
283
293
  );
284
294
 
285
295
  if (acceptedEntry) {
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { existsSync } from 'fs';
21
21
  import { join } from 'path';
22
+ import { evaluateWorkflowGateSemantics } from './workflow-gate-semantics.js';
22
23
 
23
24
  /**
24
25
  * Evaluate whether the current phase exit gate is satisfied.
@@ -113,6 +114,12 @@ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
113
114
  if (!existsSync(join(root, filePath))) {
114
115
  result.missing_files.push(filePath);
115
116
  failures.push(`Required file missing: ${filePath}`);
117
+ continue;
118
+ }
119
+
120
+ const semanticCheck = evaluateWorkflowGateSemantics(root, filePath);
121
+ if (semanticCheck && !semanticCheck.ok) {
122
+ failures.push(semanticCheck.reason);
116
123
  }
117
124
  }
118
125
  }
@@ -229,6 +236,12 @@ export function evaluateRunCompletion({ state, config, acceptedTurn, root }) {
229
236
  if (!existsSync(join(root, filePath))) {
230
237
  result.missing_files.push(filePath);
231
238
  failures.push(`Required file missing: ${filePath}`);
239
+ continue;
240
+ }
241
+
242
+ const semanticCheck = evaluateWorkflowGateSemantics(root, filePath);
243
+ if (semanticCheck && !semanticCheck.ok) {
244
+ failures.push(semanticCheck.reason);
232
245
  }
233
246
  }
234
247
  }
@@ -19,7 +19,7 @@ import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
19
19
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
20
20
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp'];
21
21
  const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
22
- const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
22
+ export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
23
23
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
24
24
  const VALID_PHASES = ['planning', 'implementation', 'qa'];
25
25
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
@@ -1,7 +1,7 @@
1
1
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
- import { evaluatePhaseExit } from './gate-evaluator.js';
4
+ import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
5
5
  import {
6
6
  approvePhaseTransition,
7
7
  approveRunCompletion,
@@ -708,6 +708,51 @@ function executeFixtureOperation(workspace, fixture) {
708
708
  };
709
709
  }
710
710
 
711
+ case 'evaluate_run_completion': {
712
+ const state = readJson(join(root, '.agentxchain', 'state.json'));
713
+ const acceptedTurn = {
714
+ run_completion_request: fixture.setup.turn_result?.run_completion_request ?? true,
715
+ verification: fixture.setup.turn_result?.verification || { status: 'pass' },
716
+ };
717
+ const result = evaluateRunCompletion({
718
+ state,
719
+ config: fixtureConfig,
720
+ acceptedTurn,
721
+ root,
722
+ });
723
+
724
+ if (result.action === 'awaiting_human_approval') {
725
+ return {
726
+ result: 'success',
727
+ action: result.action,
728
+ new_status: 'paused',
729
+ pending_run_completion: {
730
+ phase: state.phase,
731
+ gate: result.gate_id,
732
+ },
733
+ };
734
+ }
735
+
736
+ if (result.action === 'complete') {
737
+ return {
738
+ result: 'success',
739
+ action: result.action,
740
+ new_status: 'completed',
741
+ };
742
+ }
743
+
744
+ return {
745
+ result: 'success',
746
+ action: result.action,
747
+ state_unchanged: true,
748
+ reason: result.missing_files.length > 0
749
+ ? 'requires_files predicate failed'
750
+ : result.missing_verification
751
+ ? 'requires_verification_pass predicate failed'
752
+ : (result.reasons[0] || null),
753
+ };
754
+ }
755
+
711
756
  case 'append_decision': {
712
757
  const ledgerPath = join(root, '.agentxchain', 'decision-ledger.jsonl');
713
758
  const existingLedger = readJsonl(ledgerPath);
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const PM_SIGNOFF_PATH = '.planning/PM_SIGNOFF.md';
5
+ export const SHIP_VERDICT_PATH = '.planning/ship-verdict.md';
6
+
7
+ const AFFIRMATIVE_SHIP_VERDICTS = new Set(['YES', 'SHIP', 'SHIP IT']);
8
+
9
+ function normalizeToken(value) {
10
+ return value.trim().replace(/\s+/g, ' ').toUpperCase();
11
+ }
12
+
13
+ function readFile(root, relPath) {
14
+ const absPath = join(root, relPath);
15
+ if (!existsSync(absPath)) {
16
+ return null;
17
+ }
18
+ return readFileSync(absPath, 'utf8');
19
+ }
20
+
21
+ function parseLineValue(content, pattern) {
22
+ const match = content.match(pattern);
23
+ return match ? match[1].trim() : null;
24
+ }
25
+
26
+ function evaluatePmSignoff(content) {
27
+ const approved = parseLineValue(content, /^Approved\s*:\s*(.+)$/im);
28
+ if (!approved) {
29
+ return {
30
+ ok: false,
31
+ reason: 'PM signoff must declare `Approved: YES` in .planning/PM_SIGNOFF.md.',
32
+ };
33
+ }
34
+
35
+ if (normalizeToken(approved) !== 'YES') {
36
+ return {
37
+ ok: false,
38
+ reason: `PM signoff is not approved. Found "Approved: ${approved}" in .planning/PM_SIGNOFF.md; set it to "Approved: YES".`,
39
+ };
40
+ }
41
+
42
+ return { ok: true };
43
+ }
44
+
45
+ function evaluateShipVerdict(content) {
46
+ const verdict = parseLineValue(content, /^##\s+Verdict\s*:\s*(.+)$/im);
47
+ if (!verdict) {
48
+ return {
49
+ ok: false,
50
+ reason: 'Ship verdict must declare an affirmative `## Verdict:` line in .planning/ship-verdict.md.',
51
+ };
52
+ }
53
+
54
+ if (!AFFIRMATIVE_SHIP_VERDICTS.has(normalizeToken(verdict))) {
55
+ return {
56
+ ok: false,
57
+ reason: `Ship verdict is not affirmative. Found "## Verdict: ${verdict}" in .planning/ship-verdict.md; use "## Verdict: YES".`,
58
+ };
59
+ }
60
+
61
+ return { ok: true };
62
+ }
63
+
64
+ export function evaluateWorkflowGateSemantics(root, relPath) {
65
+ const content = readFile(root, relPath);
66
+ if (content === null) {
67
+ return null;
68
+ }
69
+
70
+ if (relPath === PM_SIGNOFF_PATH) {
71
+ return evaluatePmSignoff(content);
72
+ }
73
+
74
+ if (relPath === SHIP_VERDICT_PATH) {
75
+ return evaluateShipVerdict(content);
76
+ }
77
+
78
+ return null;
79
+ }