agentxchain 2.80.0 → 2.82.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
@@ -13,6 +13,7 @@ Legacy IDE-window coordination is still shipped as a compatibility mode for team
13
13
  - [Quickstart](https://agentxchain.dev/docs/quickstart/)
14
14
  - [Getting Started](https://agentxchain.dev/docs/getting-started/)
15
15
  - [CLI reference](https://agentxchain.dev/docs/cli/)
16
+ - [Lights-Out Scheduling](https://agentxchain.dev/docs/lights-out-scheduling/)
16
17
  - [Templates](https://agentxchain.dev/docs/templates/)
17
18
  - [Export schema reference](https://agentxchain.dev/docs/export-schema/)
18
19
  - [Adapter reference](https://agentxchain.dev/docs/adapters/)
@@ -71,6 +72,8 @@ Duplicate execution remains intentional for the current 36-file slice until a la
71
72
 
72
73
  ### Governed workflow
73
74
 
75
+ Run `agentxchain init --governed` for the guided scaffold. Use the explicit non-interactive form below for scripts, CI, or copy-paste onboarding:
76
+
74
77
  ```bash
75
78
  agentxchain init --governed --goal "Build an API change planner for release teams" --dir my-agentxchain-project -y
76
79
  cd my-agentxchain-project
@@ -151,7 +154,7 @@ agentxchain step
151
154
 
152
155
  ## Command Sets
153
156
 
154
- ### Governed
157
+ ### Governed lifecycle and execution
155
158
 
156
159
  | Command | What it does |
157
160
  |---|---|
@@ -167,12 +170,41 @@ agentxchain step
167
170
  | `approve-completion` | Approve a pending human-gated run completion |
168
171
  | `validate` | Validate governed kickoff wiring, a staged turn, or both |
169
172
  | `template validate` | Prove the template registry, workflow-kit scaffold contract, and planning artifact completeness (`--json` exposes a `workflow_kit` block) |
173
+ | `verify turn` | Replay a staged turn's declared machine-evidence commands to confirm reproducibility before acceptance |
174
+ | `replay turn` | Replay an accepted turn's machine-evidence commands from history for audit and drift detection |
170
175
  | `verify protocol` | Run the shipped protocol conformance suite against a target implementation |
171
176
  | `dashboard` | Open the local governance dashboard in your browser for repo-local runs or multi-repo coordinator initiatives, including pending gate approvals |
177
+ | `run [--auto-approve] [--max-turns N] [--dry-run]` | Drive a governed run from start to completion — dispatches turns, handles gates, manages rejection/retry |
178
+
179
+ ### Governed proof and inspection
180
+
181
+ | Command | What it does |
182
+ |---|---|
183
+ | `audit [--format json]` | Live governance audit report with cost summary, decision history, and artifact inventory |
184
+ | `diff <left> <right>` | Compare two governed runs side by side (phase, decisions, artifacts, timing) |
185
+ | `report` | Generate a governance report for the current run |
186
+ | `events [--type <type>] [--limit N]` | Inspect the lifecycle event stream (turns, phases, gates, governance events) |
187
+ | `history [--limit N] [--role <role>]` | Query accepted-turn history from append-only JSONL |
188
+ | `role list\|show` | List all configured roles or inspect a single role's charter, runtime, and phase assignment |
189
+ | `turn show` | Inspect the active turn in detail (assignment, artifacts, timing, verification) |
190
+ | `phase list\|show` | List configured phases or inspect a single phase's gate requirements and state |
191
+ | `gate list\|show [--evaluate]` | List configured gates or evaluate a gate's current pass/fail state |
192
+ | `doctor [--json]` | Governed project health check: config, roles, runtimes, state, schedules, plugins, workflow-kit, connector handoff |
193
+ | `connector check [--json]` | Live health probes for all configured connectors (api_proxy, remote_agent, MCP stdio/streamable_http) |
194
+
195
+ ### Governed automation, plugins, and continuity
196
+
197
+ | Command | What it does |
198
+ |---|---|
172
199
  | `multi init\|status\|step\|resume\|approve-gate\|resync` | Run the multi-repo coordinator lifecycle, including blocked-state recovery via `multi resume` |
173
200
  | `intake record\|triage\|approve\|plan\|start\|scan\|resolve` | Continuous-delivery intake: turn delivery signals into governed work items |
174
201
  | `intake handoff` | Bridge a planned intake intent to a coordinator workstream for multi-repo execution |
175
- | `plugin install\|list\|remove` | Install, inspect, or remove governed hook plugins backed by `agentxchain-plugin.json` manifests |
202
+ | `schedule list\|run-due\|daemon\|status` | Run repo-local lights-out scheduling: inspect schedules, execute due runs, poll in a local daemon loop, or check daemon heartbeat |
203
+ | `plugin install\|list\|remove` | Install, inspect, or remove governed hook plugins under `.agentxchain/plugins/` |
204
+ | `plugin list-available` | List bundled built-in plugins installable by short name |
205
+ | `export [--output <path>]` | Export run state for cross-machine continuity |
206
+ | `restore --input <path>` | Restore run state from a prior export on a same-repo, same-commit checkout |
207
+ | `restart` | Rebuild lost session context from `.agentxchain/session.json` |
176
208
 
177
209
  ### Shared utilities
178
210
 
@@ -60,6 +60,7 @@ import { doctorCommand } from '../src/commands/doctor.js';
60
60
  import { superviseCommand } from '../src/commands/supervise.js';
61
61
  import { validateCommand } from '../src/commands/validate.js';
62
62
  import { verifyExportCommand, verifyProtocolCommand, verifyTurnCommand } from '../src/commands/verify.js';
63
+ import { replayTurnCommand } from '../src/commands/replay.js';
63
64
  import { kickoffCommand } from '../src/commands/kickoff.js';
64
65
  import { rebindCommand } from '../src/commands/rebind.js';
65
66
  import { branchCommand } from '../src/commands/branch.js';
@@ -130,7 +131,7 @@ program
130
131
  program
131
132
  .command('init')
132
133
  .description('Create a new AgentXchain project folder')
133
- .option('-y, --yes', 'Skip prompts, use defaults')
134
+ .option('-y, --yes', 'Skip guided prompts, use defaults')
134
135
  .option('--governed', 'Create a governed project (orchestrator-owned state)')
135
136
  .option('--dir <path>', 'Scaffold target directory. Use "." for in-place bootstrap.')
136
137
  .option('--template <id>', 'Governed scaffold template: generic, api-service, cli-tool, library, web-app, enterprise-app')
@@ -388,6 +389,17 @@ verifyCmd
388
389
  .option('--format <format>', 'Output format: text or json', 'text')
389
390
  .action(verifyExportCommand);
390
391
 
392
+ const replayCmd = program
393
+ .command('replay')
394
+ .description('Replay accepted governed evidence against the current workspace');
395
+
396
+ replayCmd
397
+ .command('turn [turn_id]')
398
+ .description('Replay an accepted turn\'s declared machine-evidence commands from history')
399
+ .option('-j, --json', 'Output as JSON')
400
+ .option('--timeout <ms>', 'Per-command replay timeout in milliseconds', '30000')
401
+ .action(replayTurnCommand);
402
+
391
403
  program
392
404
  .command('migrate')
393
405
  .description('Migrate a legacy v3 project to governed format')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.80.0",
3
+ "version": "2.82.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@ import { getWatchPid } from './watch.js';
8
8
  import { loadNormalizedConfig, detectConfigVersion } from '../lib/normalized-config.js';
9
9
  import { readDaemonState, evaluateDaemonStatus } from '../lib/run-schedule.js';
10
10
  import { getGovernedVersionSurface, formatGovernedVersionLabel } from '../lib/protocol-version.js';
11
+ import { PLUGIN_MANIFEST_FILE } from '../lib/plugins.js';
11
12
 
12
13
  export async function doctorCommand(opts = {}) {
13
14
  const root = findProjectRoot(process.cwd());
@@ -74,6 +75,7 @@ function governedDoctor(root, rawConfig, opts) {
74
75
  const check = checkRuntimeReachable(rtId, rt);
75
76
  checks.push(check);
76
77
  }
78
+ const connectorProbe = getConnectorProbeRecommendation(runtimes);
77
79
 
78
80
  // 4. State directory
79
81
  const stateDir = join(root, '.agentxchain');
@@ -130,6 +132,90 @@ function governedDoctor(root, rawConfig, opts) {
130
132
  }
131
133
  }
132
134
 
135
+ // 8. Installed plugin health (only when plugins are installed)
136
+ const installedPlugins = rawConfig.plugins || {};
137
+ const pluginNames = Object.keys(installedPlugins);
138
+ if (pluginNames.length > 0) {
139
+ for (const pluginName of pluginNames) {
140
+ const meta = installedPlugins[pluginName];
141
+ const checkId = `plugin_${pluginName.replace(/[^a-z0-9_-]/gi, '_')}`;
142
+
143
+ // Check install path exists
144
+ if (!meta.install_path) {
145
+ checks.push({ id: checkId, name: `Plugin: ${pluginName}`, level: 'fail', detail: 'No install_path recorded', plugin_name: pluginName });
146
+ continue;
147
+ }
148
+ const installAbsPath = join(root, meta.install_path);
149
+ if (!existsSync(installAbsPath)) {
150
+ checks.push({ id: checkId, name: `Plugin: ${pluginName}`, level: 'fail', detail: `Install path missing: ${meta.install_path}`, plugin_name: pluginName });
151
+ continue;
152
+ }
153
+
154
+ // Check manifest exists and is valid
155
+ const manifestPath = join(installAbsPath, PLUGIN_MANIFEST_FILE);
156
+ if (!existsSync(manifestPath)) {
157
+ checks.push({ id: checkId, name: `Plugin: ${pluginName}`, level: 'fail', detail: 'Manifest file missing', plugin_name: pluginName });
158
+ continue;
159
+ }
160
+ let manifest;
161
+ try {
162
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
163
+ } catch (err) {
164
+ checks.push({ id: checkId, name: `Plugin: ${pluginName}`, level: 'fail', detail: `Manifest is corrupt JSON: ${err.message}`, plugin_name: pluginName });
165
+ continue;
166
+ }
167
+
168
+ // Check hook files exist
169
+ const hookErrors = [];
170
+ if (manifest.hooks && typeof manifest.hooks === 'object') {
171
+ for (const [hookName, hookDef] of Object.entries(manifest.hooks)) {
172
+ if (!hookDef) continue;
173
+ const commands = Array.isArray(hookDef) ? hookDef : (hookDef.command ? [hookDef] : []);
174
+ for (const cmd of commands) {
175
+ const cmdArgs = cmd.command || cmd;
176
+ if (Array.isArray(cmdArgs) && cmdArgs.length > 0) {
177
+ const firstArg = cmdArgs[0];
178
+ if (typeof firstArg === 'string' && (firstArg.startsWith('./') || firstArg.startsWith('../'))) {
179
+ const hookFilePath = join(installAbsPath, firstArg);
180
+ if (!existsSync(hookFilePath)) {
181
+ hookErrors.push(`${hookName}: ${firstArg}`);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ if (hookErrors.length > 0) {
189
+ checks.push({ id: checkId, name: `Plugin: ${pluginName}`, level: 'fail', detail: `Missing hook files: ${hookErrors.join(', ')}`, plugin_name: pluginName });
190
+ continue;
191
+ }
192
+
193
+ // Check config env vars (warn only)
194
+ const envWarnings = [];
195
+ const pluginConfig = meta.config || {};
196
+ for (const [key, value] of Object.entries(pluginConfig)) {
197
+ if (typeof value === 'string' && value.startsWith('$')) {
198
+ const envVar = value.slice(1);
199
+ if (!process.env[envVar]) {
200
+ envWarnings.push(envVar);
201
+ }
202
+ }
203
+ }
204
+ // Also check webhook_env pattern from config
205
+ if (pluginConfig.webhook_env && !process.env[pluginConfig.webhook_env]) {
206
+ if (!envWarnings.includes(pluginConfig.webhook_env)) {
207
+ envWarnings.push(pluginConfig.webhook_env);
208
+ }
209
+ }
210
+
211
+ if (envWarnings.length > 0) {
212
+ checks.push({ id: checkId, name: `Plugin: ${pluginName}`, level: 'warn', detail: `Env var(s) not set: ${envWarnings.join(', ')}`, plugin_name: pluginName });
213
+ } else {
214
+ checks.push({ id: checkId, name: `Plugin: ${pluginName}`, level: 'pass', detail: `v${manifest.version || '?'}, ${Object.keys(manifest.hooks || {}).length} hooks`, plugin_name: pluginName });
215
+ }
216
+ }
217
+ }
218
+
133
219
  // Compute summary
134
220
  const failCount = checks.filter(c => c.level === 'fail').length;
135
221
  const warnCount = checks.filter(c => c.level === 'warn').length;
@@ -143,6 +229,9 @@ function governedDoctor(root, rawConfig, opts) {
143
229
  ...versionSurface,
144
230
  config_version: versionSurface.config_generation,
145
231
  overall,
232
+ connector_probe_recommended: connectorProbe.recommended,
233
+ connector_probe_runtime_ids: connectorProbe.runtimeIds,
234
+ connector_probe_detail: connectorProbe.detail,
146
235
  checks,
147
236
  fail_count: failCount,
148
237
  warn_count: warnCount,
@@ -173,6 +262,9 @@ function governedDoctor(root, rawConfig, opts) {
173
262
  } else {
174
263
  console.log(chalk.red(` Not ready: ${failCount} failure${failCount > 1 ? 's' : ''}, ${warnCount} warning${warnCount > 1 ? 's' : ''}.`));
175
264
  }
265
+ if (failCount === 0 && connectorProbe.recommended) {
266
+ console.log(chalk.dim(` Next: ${connectorProbe.detail}`));
267
+ }
176
268
  console.log('');
177
269
  }
178
270
 
@@ -236,6 +328,35 @@ function checkRuntimeReachable(rtId, rt) {
236
328
  }
237
329
  }
238
330
 
331
+ function getConnectorProbeRecommendation(runtimes) {
332
+ const runtimeIds = [];
333
+
334
+ for (const [rtId, rt] of Object.entries(runtimes || {})) {
335
+ if (!rt || typeof rt !== 'object') continue;
336
+ if (rt.type === 'api_proxy' || rt.type === 'remote_agent') {
337
+ runtimeIds.push(rtId);
338
+ continue;
339
+ }
340
+ if (rt.type === 'mcp' && (rt.transport || 'stdio') === 'streamable_http') {
341
+ runtimeIds.push(rtId);
342
+ }
343
+ }
344
+
345
+ if (runtimeIds.length === 0) {
346
+ return {
347
+ recommended: false,
348
+ runtimeIds: [],
349
+ detail: null,
350
+ };
351
+ }
352
+
353
+ return {
354
+ recommended: true,
355
+ runtimeIds,
356
+ detail: 'run `agentxchain connector check` to live-probe api / remote HTTP runtimes before the first governed turn.',
357
+ };
358
+ }
359
+
239
360
  function getCurrentPhase(root) {
240
361
  const statePath = join(root, '.agentxchain', 'state.json');
241
362
  if (!existsSync(statePath)) return null;
@@ -5,7 +5,7 @@ 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
- import { loadGovernedTemplate, VALID_GOVERNED_TEMPLATE_IDS, buildSystemSpecContent } from '../lib/governed-templates.js';
8
+ import { loadAllGovernedTemplates, loadGovernedTemplate, VALID_GOVERNED_TEMPLATE_IDS, buildSystemSpecContent } from '../lib/governed-templates.js';
9
9
  import { normalizeWorkflowKit, VALID_PROMPT_TRANSPORTS } from '../lib/normalized-config.js';
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -527,6 +527,76 @@ function formatInitTarget(dir) {
527
527
  return dir;
528
528
  }
529
529
 
530
+ function normalizeOptionalGoal(value) {
531
+ if (typeof value !== 'string') return undefined;
532
+ const trimmed = value.trim();
533
+ return trimmed ? trimmed : undefined;
534
+ }
535
+
536
+ export function buildGovernedTemplateChoices(templates = loadAllGovernedTemplates()) {
537
+ return templates.map((template) => ({
538
+ name: `${chalk.cyan(template.display_name)} (${template.id}) — ${template.description}`,
539
+ value: template.id,
540
+ short: template.id,
541
+ }));
542
+ }
543
+
544
+ export async function resolveGovernedInitAnswers(opts, prompt = (questions) => inquirer.prompt(questions)) {
545
+ const explicitDir = resolveInitDirOption(opts.dir);
546
+ let templateId = opts.template || null;
547
+
548
+ if (!templateId) {
549
+ const { template } = await prompt([{
550
+ type: 'list',
551
+ name: 'template',
552
+ message: 'Governed template:',
553
+ choices: buildGovernedTemplateChoices(),
554
+ default: 'generic',
555
+ }]);
556
+ templateId = template;
557
+ }
558
+
559
+ const { name } = await prompt([{
560
+ type: 'input',
561
+ name: 'name',
562
+ message: 'Project name:',
563
+ default: explicitDir
564
+ ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain Project')
565
+ : 'My AgentXchain Project',
566
+ }]);
567
+ const projectName = name;
568
+ let folderName = explicitDir || slugify(projectName);
569
+
570
+ let projectGoal = normalizeOptionalGoal(opts.goal);
571
+ if (!projectGoal) {
572
+ const { goal } = await prompt([{
573
+ type: 'input',
574
+ name: 'goal',
575
+ message: 'Project goal (recommended; shown to every agent turn):',
576
+ default: '',
577
+ }]);
578
+ projectGoal = normalizeOptionalGoal(goal);
579
+ }
580
+
581
+ if (!explicitDir) {
582
+ const { folder } = await prompt([{
583
+ type: 'input',
584
+ name: 'folder',
585
+ message: 'Folder name:',
586
+ default: folderName,
587
+ }]);
588
+ folderName = folder;
589
+ }
590
+
591
+ return {
592
+ explicitDir,
593
+ templateId,
594
+ projectName,
595
+ folderName,
596
+ goal: projectGoal,
597
+ };
598
+ }
599
+
530
600
  function generateWorkflowKitPlaceholder(artifact, projectName) {
531
601
  const filename = basename(artifact.path);
532
602
  const title = filename.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
@@ -813,12 +883,24 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
813
883
 
814
884
  async function initGoverned(opts) {
815
885
  let projectName, folderName;
816
- const templateId = opts.template || 'generic';
886
+ let templateId;
817
887
  let selectedTemplate;
818
888
  let explicitDir;
889
+ let projectGoal;
819
890
 
820
891
  try {
821
- explicitDir = resolveInitDirOption(opts.dir);
892
+ if (opts.yes) {
893
+ explicitDir = resolveInitDirOption(opts.dir);
894
+ templateId = opts.template || 'generic';
895
+ projectGoal = normalizeOptionalGoal(opts.goal);
896
+ } else {
897
+ const answers = await resolveGovernedInitAnswers(opts);
898
+ explicitDir = answers.explicitDir;
899
+ templateId = answers.templateId;
900
+ projectName = answers.projectName;
901
+ folderName = answers.folderName;
902
+ projectGoal = answers.goal;
903
+ }
822
904
  } catch (err) {
823
905
  console.error(chalk.red(` Error: ${err.message}`));
824
906
  process.exit(1);
@@ -842,27 +924,6 @@ async function initGoverned(opts) {
842
924
  ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain Project')
843
925
  : 'My AgentXchain Project';
844
926
  folderName = explicitDir || slugify(projectName);
845
- } else {
846
- const { name } = await inquirer.prompt([{
847
- type: 'input',
848
- name: 'name',
849
- message: 'Project name:',
850
- default: explicitDir
851
- ? inferProjectNameFromTarget(explicitDir, 'My AgentXchain Project')
852
- : 'My AgentXchain Project'
853
- }]);
854
- projectName = name;
855
- folderName = explicitDir || slugify(projectName);
856
-
857
- if (!explicitDir) {
858
- const { folder } = await inquirer.prompt([{
859
- type: 'input',
860
- name: 'folder',
861
- message: 'Folder name:',
862
- default: folderName
863
- }]);
864
- folderName = folder;
865
- }
866
927
  }
867
928
 
868
929
  const dir = resolve(process.cwd(), folderName);
@@ -908,7 +969,10 @@ async function initGoverned(opts) {
908
969
  }
909
970
  }
910
971
 
911
- const { config, scaffoldWorkflowKitConfig } = scaffoldGoverned(dir, projectName, projectId, templateId, opts, workflowKitConfig);
972
+ const scaffoldOptions = projectGoal
973
+ ? { ...opts, goal: projectGoal }
974
+ : { ...opts };
975
+ const { config, scaffoldWorkflowKitConfig } = scaffoldGoverned(dir, projectName, projectId, templateId, scaffoldOptions, workflowKitConfig);
912
976
 
913
977
  console.log('');
914
978
  console.log(chalk.green(` ✓ Created governed project ${chalk.bold(targetLabel)}/`));
@@ -933,6 +997,9 @@ async function initGoverned(opts) {
933
997
  console.log(` ${chalk.dim('Roles:')} ${promptRoleIds.join(', ')}`);
934
998
  console.log(` ${chalk.dim('Phases:')} ${phaseNames.join(' → ')} ${chalk.dim(selectedTemplate.scaffold_blueprint ? '(template-defined; edit routing in agentxchain.json to customize)' : '(default; extend via routing in agentxchain.json)')}`);
935
999
  console.log(` ${chalk.dim('Template:')} ${templateId}`);
1000
+ if (config.project?.goal) {
1001
+ console.log(` ${chalk.dim('Goal:')} ${config.project.goal}`);
1002
+ }
936
1003
  console.log(` ${chalk.dim('Dev runtime:')} ${formatGovernedRuntimeCommand(localDevRuntime)} ${chalk.dim(`(${localDevRuntime.prompt_transport})`)}`);
937
1004
  console.log(` ${chalk.dim('Protocol:')} governed convergence`);
938
1005
  console.log('');
@@ -235,6 +235,14 @@ export async function multiStepCommand(options) {
235
235
  // Fire on_escalation for the blocked resync
236
236
  fireEscalationHook(workspacePath, configResult.config, state, resync.blocked_reason || 'resync failure');
237
237
  console.error(`Coordinator resync entered blocked state: ${resync.blocked_reason || 'unknown reason'}`);
238
+ for (const mismatch of resync.mismatch_details || []) {
239
+ const codeTag = mismatch.code ? `[${mismatch.code}] ` : '';
240
+ console.error(` - ${codeTag}${mismatch.message}`);
241
+ if (mismatch.code === 'repo_run_id_mismatch') {
242
+ console.error(` expected: ${mismatch.expected_run_id}`);
243
+ console.error(` actual: ${mismatch.actual_run_id}`);
244
+ }
245
+ }
238
246
  process.exitCode = 1;
239
247
  return;
240
248
  }
@@ -0,0 +1,120 @@
1
+ import chalk from 'chalk';
2
+
3
+ import { loadProjectContext } from '../lib/config.js';
4
+ import { normalizeVerification } from '../lib/repo-observer.js';
5
+ import { resolveAcceptedTurnHistoryReference } from '../lib/accepted-turn-history.js';
6
+ import {
7
+ DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS,
8
+ replayVerificationMachineEvidence,
9
+ } from '../lib/verification-replay.js';
10
+
11
+ export async function replayTurnCommand(turnId, opts = {}) {
12
+ const context = loadProjectContext();
13
+ if (!context) {
14
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
15
+ process.exit(2);
16
+ }
17
+
18
+ if (context.config.protocol_mode !== 'governed' || context.version !== 4) {
19
+ console.log(chalk.red('replay turn is only available in governed v4 projects.'));
20
+ process.exit(2);
21
+ }
22
+
23
+ const timeoutMs = Number.parseInt(String(opts.timeout || String(DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS)), 10);
24
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
25
+ console.log(chalk.red('replay turn requires a positive integer --timeout in milliseconds.'));
26
+ process.exit(2);
27
+ }
28
+
29
+ const { root, config } = context;
30
+ const resolved = resolveAcceptedTurnHistoryReference(root, turnId);
31
+ if (!resolved.ok) {
32
+ console.log(chalk.red(resolved.error));
33
+ process.exit(2);
34
+ }
35
+
36
+ const entry = resolved.entry;
37
+ const runtimeType = config.runtimes?.[entry.runtime_id]?.type || 'unknown';
38
+ const payload = {
39
+ source: 'history',
40
+ match_kind: resolved.match_kind,
41
+ turn_id: entry.turn_id,
42
+ resolved_turn_id: resolved.resolved_ref,
43
+ run_id: entry.run_id || null,
44
+ role: entry.role || null,
45
+ phase: entry.phase || null,
46
+ runtime_id: entry.runtime_id || null,
47
+ runtime_type: runtimeType,
48
+ accepted_at: entry.accepted_at || null,
49
+ declared_status: entry.verification?.status || 'skipped',
50
+ normalized_status: normalizeVerification(entry.verification, runtimeType).status,
51
+ timeout_ms: timeoutMs,
52
+ prior_verification_replay: entry.verification_replay || null,
53
+ ...replayVerificationMachineEvidence({
54
+ root,
55
+ verification: entry.verification,
56
+ timeoutMs,
57
+ }),
58
+ };
59
+
60
+ emitReplayTurn(payload, opts.json);
61
+ process.exit(payload.overall === 'match' ? 0 : 1);
62
+ }
63
+
64
+ function emitReplayTurn(payload, jsonMode) {
65
+ if (jsonMode) {
66
+ console.log(JSON.stringify(payload, null, 2));
67
+ return;
68
+ }
69
+
70
+ console.log('');
71
+ console.log(chalk.bold(` Replay Turn: ${chalk.cyan(payload.turn_id)}`));
72
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
73
+ console.log(` ${chalk.dim('Source:')} accepted history (${payload.match_kind})`);
74
+ console.log(` ${chalk.dim('Run:')} ${payload.run_id || '—'}`);
75
+ console.log(` ${chalk.dim('Role:')} ${payload.role || '—'}`);
76
+ console.log(` ${chalk.dim('Phase:')} ${payload.phase || '—'}`);
77
+ console.log(` ${chalk.dim('Runtime:')} ${payload.runtime_id || '—'} (${payload.runtime_type})`);
78
+ console.log(` ${chalk.dim('Accepted:')} ${payload.accepted_at || '—'}`);
79
+ console.log(` ${chalk.dim('Declared:')} ${payload.declared_status}`);
80
+ console.log(` ${chalk.dim('Normalized:')} ${payload.normalized_status}`);
81
+ if (payload.prior_verification_replay) {
82
+ const prior = payload.prior_verification_replay;
83
+ const verifiedAt = prior.verified_at ? ` at ${prior.verified_at}` : '';
84
+ console.log(` ${chalk.dim('Prior replay:')} ${prior.overall} (${prior.matched_commands || 0}/${prior.replayed_commands || 0})${verifiedAt}`);
85
+ }
86
+ console.log(` ${chalk.dim('Outcome:')} ${formatOutcome(payload.overall)}`);
87
+
88
+ if (payload.reason) {
89
+ console.log(` ${chalk.dim('Reason:')} ${payload.reason}`);
90
+ console.log('');
91
+ return;
92
+ }
93
+
94
+ console.log('');
95
+ for (const command of payload.commands || []) {
96
+ const marker = command.matched ? chalk.green('match') : chalk.red('mismatch');
97
+ console.log(` [${marker}] ${command.command}`);
98
+ console.log(` declared=${command.declared_exit_code} actual=${command.actual_exit_code == null ? 'null' : command.actual_exit_code}`);
99
+ if (command.signal) {
100
+ console.log(` signal=${command.signal}`);
101
+ }
102
+ if (command.timed_out) {
103
+ console.log(' timed_out=true');
104
+ }
105
+ if (command.error) {
106
+ console.log(` error=${command.error}`);
107
+ }
108
+ }
109
+
110
+ console.log('');
111
+ console.log(chalk.dim(' Replay uses the current workspace and shell environment. It verifies declared exit-code reproducibility, not historical stdout/stderr identity.'));
112
+ console.log('');
113
+ }
114
+
115
+ function formatOutcome(outcome) {
116
+ if (outcome === 'match') return chalk.green('match');
117
+ if (outcome === 'mismatch') return chalk.red('mismatch');
118
+ return chalk.yellow('not_reproducible');
119
+ }
120
+
@@ -36,7 +36,6 @@ import {
36
36
  getDispatchEffectiveContextPath,
37
37
  getDispatchPromptPath,
38
38
  } from '../lib/turn-paths.js';
39
- import { safeWriteJson } from '../lib/safe-write.js';
40
39
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
41
40
  import { runHooks } from '../lib/hook-runner.js';
42
41
 
@@ -100,6 +99,11 @@ export async function resumeCommand(opts) {
100
99
  process.exit(1);
101
100
  }
102
101
 
102
+ if (state.pending_phase_transition || state.pending_run_completion) {
103
+ printRecoverySummary(state, 'This run is awaiting approval.');
104
+ process.exit(1);
105
+ }
106
+
103
107
  // §47: paused + retained turn with failed/retrying status → re-dispatch same turn
104
108
  if (state.status === 'paused' && activeCount > 0) {
105
109
  // Resolve which turn to re-dispatch
@@ -129,10 +133,12 @@ export async function resumeCommand(opts) {
129
133
  console.log(` Attempt: ${retainedTurn.attempt}`);
130
134
  console.log('');
131
135
 
132
- // Reactivate the run
133
- state.status = 'active';
134
- state.blocked_on = null;
135
- safeWriteJson(statePath, state);
136
+ const reactivated = reactivateGovernedRun(root, state, { via: 'resume --turn', notificationConfig: config });
137
+ if (!reactivated.ok) {
138
+ console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
139
+ process.exit(1);
140
+ }
141
+ state = reactivated.state;
136
142
 
137
143
  // Write dispatch bundle for the existing turn
138
144
  const bundleResult = writeDispatchBundle(root, state, config);
@@ -236,11 +242,12 @@ export async function resumeCommand(opts) {
236
242
 
237
243
  // §47: paused + run_id exists → resume same run
238
244
  if (state.status === 'paused' && state.run_id) {
239
- state.status = 'active';
240
- state.blocked_on = null;
241
- state.blocked_reason = null;
242
- state.escalation = null;
243
- safeWriteJson(statePath, state);
245
+ const reactivated = reactivateGovernedRun(root, state, { via: 'resume', notificationConfig: config });
246
+ if (!reactivated.ok) {
247
+ console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
248
+ process.exit(1);
249
+ }
250
+ state = reactivated.state;
244
251
  console.log(chalk.green(`Resumed governed run: ${state.run_id}`));
245
252
  }
246
253
 
@@ -422,6 +429,19 @@ function printAssignmentWarnings(assignResult) {
422
429
  }
423
430
  }
424
431
 
432
+ function printRecoverySummary(state, heading) {
433
+ const recovery = deriveRecoveryDescriptor(state);
434
+ console.log(chalk.yellow(heading));
435
+ if (!recovery) {
436
+ return;
437
+ }
438
+ console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
439
+ console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
440
+ if (recovery.detail) {
441
+ console.log(` ${chalk.dim('Detail:')} ${recovery.detail}`);
442
+ }
443
+ }
444
+
425
445
  function printAssignmentHookFailure(result, roleId) {
426
446
  const recovery = deriveRecoveryDescriptor(result.state);
427
447
  const hookName = result.hookResults?.blocker?.hook_name
@@ -60,7 +60,6 @@ import {
60
60
  getTurnStagingResultPath,
61
61
  } from '../lib/turn-paths.js';
62
62
  import { dispatchApiProxy } from '../lib/adapters/api-proxy-adapter.js';
63
- import { safeWriteJson } from '../lib/safe-write.js';
64
63
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
65
64
  import { runHooks } from '../lib/hook-runner.js';
66
65
  import { finalizeDispatchManifest, verifyDispatchManifest } from '../lib/dispatch-manifest.js';
@@ -165,8 +164,8 @@ export async function stepCommand(opts) {
165
164
  }
166
165
 
167
166
  if (!skipAssignment) {
168
- if (state.status === 'paused' && (state.pending_phase_transition || state.pending_run_completion)) {
169
- printRecoverySummary(state, 'This run is paused for approval.');
167
+ if (state.pending_phase_transition || state.pending_run_completion) {
168
+ printRecoverySummary(state, 'This run is awaiting approval.');
170
169
  process.exit(1);
171
170
  }
172
171
 
@@ -231,10 +230,12 @@ export async function stepCommand(opts) {
231
230
  const turnStatus = pausedTurn?.status;
232
231
  if (turnStatus === 'failed' || turnStatus === 'retrying') {
233
232
  console.log(chalk.yellow(`Re-dispatching failed turn: ${pausedTurn.turn_id}`));
234
- state.status = 'active';
235
- state.blocked_on = null;
236
- state.blocked_reason = null;
237
- safeWriteJson(join(root, STATE_PATH), state);
233
+ const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
234
+ if (!reactivated.ok) {
235
+ console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
236
+ process.exit(1);
237
+ }
238
+ state = reactivated.state;
238
239
  skipAssignment = true;
239
240
 
240
241
  const bundleResult = writeDispatchBundle(root, state, config);
@@ -270,11 +271,12 @@ export async function stepCommand(opts) {
270
271
  }
271
272
 
272
273
  if (!skipAssignment && state.status === 'paused' && state.run_id) {
273
- state.status = 'active';
274
- state.blocked_on = null;
275
- state.blocked_reason = null;
276
- state.escalation = null;
277
- safeWriteJson(join(root, STATE_PATH), state);
274
+ const reactivated = reactivateGovernedRun(root, state, { via: 'step', notificationConfig: config });
275
+ if (!reactivated.ok) {
276
+ console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
277
+ process.exit(1);
278
+ }
279
+ state = reactivated.state;
278
280
  console.log(chalk.green(`Resumed governed run: ${state.run_id}`));
279
281
  }
280
282
 
@@ -0,0 +1,84 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const HISTORY_PATH = '.agentxchain/history.jsonl';
5
+
6
+ export function queryAcceptedTurnHistory(root) {
7
+ const filePath = join(root, HISTORY_PATH);
8
+ if (!existsSync(filePath)) return [];
9
+
10
+ let content;
11
+ try {
12
+ content = readFileSync(filePath, 'utf8').trim();
13
+ } catch {
14
+ return [];
15
+ }
16
+
17
+ if (!content) return [];
18
+
19
+ return content
20
+ .split('\n')
21
+ .filter(Boolean)
22
+ .map((line) => {
23
+ try {
24
+ return JSON.parse(line);
25
+ } catch {
26
+ return null;
27
+ }
28
+ })
29
+ .filter((entry) => entry && typeof entry.turn_id === 'string')
30
+ .reverse();
31
+ }
32
+
33
+ export function resolveAcceptedTurnHistoryReference(root, ref) {
34
+ const entries = queryAcceptedTurnHistory(root);
35
+
36
+ if (entries.length === 0) {
37
+ return {
38
+ ok: false,
39
+ error: 'No accepted turn history found. Accept at least one governed turn first.',
40
+ };
41
+ }
42
+
43
+ if (!ref) {
44
+ return {
45
+ ok: true,
46
+ entry: entries[0],
47
+ resolved_ref: entries[0].turn_id,
48
+ match_kind: 'latest',
49
+ };
50
+ }
51
+
52
+ const exact = entries.find((entry) => entry.turn_id === ref);
53
+ if (exact) {
54
+ return {
55
+ ok: true,
56
+ entry: exact,
57
+ resolved_ref: exact.turn_id,
58
+ match_kind: 'exact',
59
+ };
60
+ }
61
+
62
+ const prefixMatches = entries.filter((entry) => entry.turn_id.startsWith(ref));
63
+ if (prefixMatches.length === 1) {
64
+ return {
65
+ ok: true,
66
+ entry: prefixMatches[0],
67
+ resolved_ref: prefixMatches[0].turn_id,
68
+ match_kind: 'prefix',
69
+ };
70
+ }
71
+
72
+ if (prefixMatches.length > 1) {
73
+ return {
74
+ ok: false,
75
+ error: `Turn reference "${ref}" is ambiguous. Matches: ${prefixMatches.map((entry) => entry.turn_id).join(', ')}`,
76
+ };
77
+ }
78
+
79
+ return {
80
+ ok: false,
81
+ error: `Accepted turn ${ref} not found in history.`,
82
+ };
83
+ }
84
+
@@ -239,6 +239,7 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
239
239
  const resyncedRepos = [];
240
240
  const projectedAcceptances = [];
241
241
  const barrierChanges = [];
242
+ const mismatchDetails = [];
242
243
 
243
244
  // Step 1: Refresh repo_runs from repo-local authority
244
245
  const updatedRepoRuns = { ...state.repo_runs };
@@ -262,6 +263,13 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
262
263
  if (repoRun.run_id && repoRun.run_id !== (repoState.run_id ?? null)) {
263
264
  const reason = buildRunIdMismatchReason(repoId, repoRun.run_id, repoState.run_id ?? null);
264
265
  runIdMismatches.push(reason);
266
+ mismatchDetails.push({
267
+ code: 'repo_run_id_mismatch',
268
+ repo_id: repoId,
269
+ expected_run_id: repoRun.run_id,
270
+ actual_run_id: repoState.run_id ?? null,
271
+ message: reason,
272
+ });
265
273
  errors.push(reason);
266
274
  continue;
267
275
  }
@@ -447,6 +455,7 @@ export function resyncFromRepoAuthority(workspacePath, state, config) {
447
455
  resynced_repos: [...new Set(resyncedRepos)],
448
456
  projected_acceptances: projectedAcceptances,
449
457
  barrier_changes: barrierChanges,
458
+ mismatch_details: mismatchDetails,
450
459
  errors,
451
460
  blocked_reason: blockedReason || undefined,
452
461
  };
@@ -118,6 +118,9 @@ export function writeDispatchBundle(root, state, config, opts = {}) {
118
118
  budget_reservation_usd: state.budget_reservations?.[turn.turn_id]?.reserved_usd ?? null,
119
119
  active_siblings: activeSiblings,
120
120
  };
121
+ if (turn.intake_context) {
122
+ assignment.intake_context = turn.intake_context;
123
+ }
121
124
  if (turn.conflict_context) {
122
125
  assignment.conflict_context = turn.conflict_context;
123
126
  }
@@ -523,6 +526,27 @@ function renderContext(state, config, root, turn, role) {
523
526
  lines.push('');
524
527
  }
525
528
 
529
+ if (turn.intake_context) {
530
+ lines.push('## Intake Intent');
531
+ lines.push('');
532
+ lines.push(`- **Intent:** ${turn.intake_context.intent_id || 'unknown'}`);
533
+ lines.push(`- **Event:** ${turn.intake_context.event_id || 'unknown'}`);
534
+ lines.push(`- **Source:** ${turn.intake_context.source || 'unknown'}`);
535
+ if (turn.intake_context.category) {
536
+ lines.push(`- **Category:** ${turn.intake_context.category}`);
537
+ }
538
+ if (turn.intake_context.charter) {
539
+ lines.push(`- **Charter:** ${turn.intake_context.charter}`);
540
+ }
541
+ if (Array.isArray(turn.intake_context.acceptance_contract) && turn.intake_context.acceptance_contract.length > 0) {
542
+ lines.push('- **Acceptance Contract:**');
543
+ for (const requirement of turn.intake_context.acceptance_contract) {
544
+ lines.push(` - ${requirement}`);
545
+ }
546
+ }
547
+ lines.push('');
548
+ }
549
+
526
550
  // Inherited context from parent run (when --inherit-context was used)
527
551
  if (state.inherited_context) {
528
552
  // First turn gets the full rendering; subsequent turns get compact
@@ -1197,13 +1221,13 @@ function buildTurnResultTemplate(state, turn, roleId, role) {
1197
1221
  role: roleId,
1198
1222
  runtime_id: turn.runtime_id,
1199
1223
  status: 'completed',
1200
- summary: 'TODO: describe what you accomplished this turn',
1224
+ summary: '<one-line summary of what you accomplished>',
1201
1225
  decisions: [
1202
1226
  {
1203
1227
  id: 'DEC-001',
1204
1228
  category: 'implementation',
1205
- statement: 'TODO: describe the decision',
1206
- rationale: 'TODO: explain why',
1229
+ statement: '<what was decided and why it matters>',
1230
+ rationale: '<reasoning behind this decision>',
1207
1231
  },
1208
1232
  ],
1209
1233
  objections: isReviewOnly
@@ -1211,29 +1235,29 @@ function buildTurnResultTemplate(state, turn, roleId, role) {
1211
1235
  {
1212
1236
  id: 'OBJ-001',
1213
1237
  severity: 'medium',
1214
- against_turn_id: state.last_completed_turn_id || 'TODO',
1215
- statement: 'TODO: challenge the previous turn (required for review_only roles)',
1238
+ against_turn_id: state.last_completed_turn_id || '<turn_id of the turn you are reviewing>',
1239
+ statement: '<specific objection to the previous turn required for review_only roles>',
1216
1240
  status: 'raised',
1217
1241
  },
1218
1242
  ]
1219
1243
  : [],
1220
- files_changed: isReviewOnly ? [] : ['TODO: list every file you modified'],
1244
+ files_changed: isReviewOnly ? [] : ['<path/to/modified/file>'],
1221
1245
  artifacts_created: [],
1222
1246
  verification: {
1223
1247
  status: isReviewOnly ? 'skipped' : 'pass',
1224
- commands: isReviewOnly ? [] : ['TODO: list commands you ran'],
1248
+ commands: isReviewOnly ? [] : ['<command you ran to verify>'],
1225
1249
  evidence_summary: isReviewOnly
1226
1250
  ? 'Review turn — no verification commands required.'
1227
- : 'TODO: describe what you verified',
1251
+ : '<what you verified and how>',
1228
1252
  machine_evidence: isReviewOnly
1229
1253
  ? []
1230
- : [{ command: 'TODO', exit_code: 0 }],
1254
+ : [{ command: '<exact command that was run>', exit_code: 0 }],
1231
1255
  },
1232
1256
  artifact: {
1233
1257
  type: isReviewOnly ? 'review' : 'workspace',
1234
1258
  ref: isReviewOnly ? null : 'git:dirty',
1235
1259
  },
1236
- proposed_next_role: 'TODO',
1260
+ proposed_next_role: '<role_id that should act next>',
1237
1261
  phase_transition_request: null,
1238
1262
  run_completion_request: null,
1239
1263
  needs_human_reason: null,
@@ -1774,6 +1774,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
1774
1774
  if (!state || typeof state !== 'object') {
1775
1775
  return { ok: false, error: 'State is required.' };
1776
1776
  }
1777
+ if (state.status !== 'blocked' && state.status !== 'paused') {
1778
+ return { ok: false, error: `Cannot reactivate run: status is "${state.status}", expected "blocked" or "paused".` };
1779
+ }
1780
+ if (state.pending_phase_transition || state.pending_run_completion) {
1781
+ return { ok: false, error: 'Cannot reactivate run: this run is awaiting approval. Use approve-transition or approve-completion.' };
1782
+ }
1777
1783
 
1778
1784
  const now = new Date().toISOString();
1779
1785
  const wasEscalation = state.status === 'blocked' && typeof state.blocked_on === 'string' && state.blocked_on.startsWith('escalation:');
@@ -1819,7 +1825,7 @@ export function reactivateGovernedRun(root, state, details = {}) {
1819
1825
  // ── Core Operations ──────────────────────────────────────────────────────────
1820
1826
 
1821
1827
  /**
1822
- * Initialize a governed run from idle state.
1828
+ * Initialize a governed run from bootstrap state.
1823
1829
  * Creates a run_id and sets status to 'active'.
1824
1830
  *
1825
1831
  * @param {string} root - project root directory
@@ -1837,8 +1843,8 @@ export function initializeGovernedRun(root, config, options = {}) {
1837
1843
  return { ok: false, error: 'Cannot initialize run: this run is already completed. Start a new project or reset state.' };
1838
1844
  }
1839
1845
  const allowBlockedBootstrap = state.status === 'blocked' && state.run_id === null && getActiveTurnCount(state) === 0;
1840
- if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap && !allowTerminalRestart) {
1841
- return { ok: false, error: `Cannot initialize run: status is "${state.status}", expected "idle", "paused", or pre-run "blocked"` };
1846
+ if (state.status !== 'idle' && !allowBlockedBootstrap && !allowTerminalRestart) {
1847
+ return { ok: false, error: `Cannot initialize run: status is "${state.status}", expected "idle" or pre-run "blocked"` };
1842
1848
  }
1843
1849
  if (allowTerminalRestart) {
1844
1850
  state = buildFreshIdleStateForNewRun(state, config);
package/src/lib/intake.js CHANGED
@@ -521,6 +521,20 @@ export function startIntent(root, intentId, options = {}) {
521
521
  };
522
522
  }
523
523
 
524
+ const loadedEvent = readEvent(root, intent.event_id);
525
+ if (!loadedEvent.ok) {
526
+ return loadedEvent;
527
+ }
528
+ const { event } = loadedEvent;
529
+ const intakeContext = {
530
+ intent_id: intent.intent_id,
531
+ event_id: intent.event_id,
532
+ source: event.source || null,
533
+ category: event.category || null,
534
+ charter: intent.charter || null,
535
+ acceptance_contract: Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [],
536
+ };
537
+
524
538
  // Load governed project context
525
539
  const context = loadProjectContext(root);
526
540
  if (!context) {
@@ -569,6 +583,10 @@ export function startIntent(root, intentId, options = {}) {
569
583
  };
570
584
  }
571
585
 
586
+ if (state.status === 'paused') {
587
+ return { ok: false, error: 'cannot start: run is paused (awaiting approval). Resolve the blocking gate before starting a new intake turn.', exitCode: 1 };
588
+ }
589
+
572
590
  if (state.pending_phase_transition) {
573
591
  return { ok: false, error: `cannot start: pending phase transition to "${state.pending_phase_transition}"`, exitCode: 1 };
574
592
  }
@@ -579,21 +597,20 @@ export function startIntent(root, intentId, options = {}) {
579
597
 
580
598
  // Bootstrap: idle with no run → initialize
581
599
  if (state.status === 'idle' && !state.run_id) {
582
- const initResult = initializeGovernedRun(root, config);
600
+ const initResult = initializeGovernedRun(root, config, {
601
+ provenance: {
602
+ trigger: 'intake',
603
+ intake_intent_id: intent.intent_id,
604
+ trigger_reason: intent.charter || null,
605
+ created_by: 'operator',
606
+ },
607
+ });
583
608
  if (!initResult.ok) {
584
609
  return { ok: false, error: `run initialization failed: ${initResult.error}`, exitCode: 1 };
585
610
  }
586
611
  state = initResult.state;
587
612
  }
588
613
 
589
- // Resume: paused with no active turns → reactivate
590
- if (state.status === 'paused' && state.run_id) {
591
- state.status = 'active';
592
- state.blocked_on = null;
593
- state.escalation = null;
594
- safeWriteJson(statePath, state);
595
- }
596
-
597
614
  // Resolve role
598
615
  const roleId = resolveIntakeRole(options.role, state, config);
599
616
  if (!roleId.ok) {
@@ -614,6 +631,12 @@ export function startIntent(root, intentId, options = {}) {
614
631
  return { ok: false, error: 'turn assignment succeeded but turn not found in state', exitCode: 1 };
615
632
  }
616
633
 
634
+ assignedTurn.intake_context = intakeContext;
635
+ if (state.active_turns?.[assignedTurn.turn_id]) {
636
+ state.active_turns[assignedTurn.turn_id].intake_context = intakeContext;
637
+ safeWriteJson(statePath, state);
638
+ }
639
+
617
640
  // Write dispatch bundle
618
641
  const bundleResult = writeDispatchBundle(root, state, config);
619
642
  if (!bundleResult.ok) {
@@ -263,9 +263,68 @@ function validateSchema(tr) {
263
263
  }
264
264
  }
265
265
 
266
+ errors.push(...collectUnfilledTemplatePlaceholderErrors(tr));
267
+
266
268
  return errors;
267
269
  }
268
270
 
271
+ function collectUnfilledTemplatePlaceholderErrors(tr) {
272
+ const errors = [];
273
+
274
+ checkPlaceholder(errors, 'summary', tr.summary);
275
+ checkPlaceholder(errors, 'proposed_next_role', tr.proposed_next_role);
276
+
277
+ if (Array.isArray(tr.decisions)) {
278
+ for (let i = 0; i < tr.decisions.length; i++) {
279
+ const decision = tr.decisions[i];
280
+ checkPlaceholder(errors, `decisions[${i}].statement`, decision?.statement);
281
+ checkPlaceholder(errors, `decisions[${i}].rationale`, decision?.rationale);
282
+ }
283
+ }
284
+
285
+ if (Array.isArray(tr.objections)) {
286
+ for (let i = 0; i < tr.objections.length; i++) {
287
+ const objection = tr.objections[i];
288
+ checkPlaceholder(errors, `objections[${i}].against_turn_id`, objection?.against_turn_id);
289
+ checkPlaceholder(errors, `objections[${i}].statement`, objection?.statement);
290
+ }
291
+ }
292
+
293
+ if (Array.isArray(tr.files_changed)) {
294
+ for (let i = 0; i < tr.files_changed.length; i++) {
295
+ checkPlaceholder(errors, `files_changed[${i}]`, tr.files_changed[i]);
296
+ }
297
+ }
298
+
299
+ const verification = tr.verification;
300
+ if (verification && typeof verification === 'object' && !Array.isArray(verification)) {
301
+ if (Array.isArray(verification.commands)) {
302
+ for (let i = 0; i < verification.commands.length; i++) {
303
+ checkPlaceholder(errors, `verification.commands[${i}]`, verification.commands[i]);
304
+ }
305
+ }
306
+ checkPlaceholder(errors, 'verification.evidence_summary', verification.evidence_summary);
307
+
308
+ if (Array.isArray(verification.machine_evidence)) {
309
+ for (let i = 0; i < verification.machine_evidence.length; i++) {
310
+ checkPlaceholder(
311
+ errors,
312
+ `verification.machine_evidence[${i}].command`,
313
+ verification.machine_evidence[i]?.command
314
+ );
315
+ }
316
+ }
317
+ }
318
+
319
+ return errors;
320
+ }
321
+
322
+ function checkPlaceholder(errors, fieldPath, value) {
323
+ if (typeof value === 'string' && /^<[^>]+>$/.test(value)) {
324
+ errors.push(`${fieldPath} contains an unfilled template placeholder: "${value}".`);
325
+ }
326
+ }
327
+
269
328
  function validateDecision(dec, index) {
270
329
  const errors = [];
271
330
  const prefix = `decisions[${index}]`;
@@ -56,6 +56,31 @@ function evaluatePmSignoff(content) {
56
56
  return { ok: true };
57
57
  }
58
58
 
59
+ const SYSTEM_SPEC_SCAFFOLD_PLACEHOLDER = /^\(.*\)$/;
60
+ const SYSTEM_SPEC_ACCEPTANCE_SCAFFOLD = /^- \[ \] Name the executable checks/;
61
+
62
+ function isSystemSpecPlaceholderLine(line) {
63
+ return SYSTEM_SPEC_SCAFFOLD_PLACEHOLDER.test(line) || SYSTEM_SPEC_ACCEPTANCE_SCAFFOLD.test(line);
64
+ }
65
+
66
+ function hasSectionRealContent(content, sectionHeader, isPlaceholderFn) {
67
+ const lines = content.split(/\r?\n/);
68
+ const headerIndex = lines.findIndex((line) => line.trim().startsWith(sectionHeader));
69
+ if (headerIndex === -1) {
70
+ return { found: false, hasContent: false };
71
+ }
72
+
73
+ for (let i = headerIndex + 1; i < lines.length; i++) {
74
+ const line = lines[i].trim();
75
+ if (line.startsWith('## ')) break;
76
+ if (!line) continue;
77
+ if (isPlaceholderFn(line)) continue;
78
+ return { found: true, hasContent: true };
79
+ }
80
+
81
+ return { found: true, hasContent: false };
82
+ }
83
+
59
84
  function evaluateSystemSpec(content) {
60
85
  const requiredSections = ['## Purpose', '## Interface', '## Acceptance Tests'];
61
86
  const missingSections = requiredSections.filter((section) => !content.includes(section));
@@ -67,6 +92,21 @@ function evaluateSystemSpec(content) {
67
92
  };
68
93
  }
69
94
 
95
+ const placeholderSections = [];
96
+ for (const section of requiredSections) {
97
+ const result = hasSectionRealContent(content, section, isSystemSpecPlaceholderLine);
98
+ if (result.found && !result.hasContent) {
99
+ placeholderSections.push(section);
100
+ }
101
+ }
102
+
103
+ if (placeholderSections.length > 0) {
104
+ return {
105
+ ok: false,
106
+ reason: `${placeholderSections.join(' and ')} in .planning/SYSTEM_SPEC.md still contains only scaffold placeholder text. Replace placeholder content with real spec content before planning can exit.`,
107
+ };
108
+ }
109
+
70
110
  return { ok: true };
71
111
  }
72
112
 
@@ -362,6 +402,15 @@ function evaluateShipVerdict(content) {
362
402
  return { ok: true };
363
403
  }
364
404
 
405
+ const SECTION_CHECK_SCAFFOLD_PLACEHOLDERS = [
406
+ /^\(Content here\.\)$/i,
407
+ /^\(Operator fills this in\.\)$/i,
408
+ ];
409
+
410
+ function isSectionCheckPlaceholderLine(line) {
411
+ return SECTION_CHECK_SCAFFOLD_PLACEHOLDERS.some((re) => re.test(line));
412
+ }
413
+
365
414
  function evaluateSectionCheck(content, config) {
366
415
  if (!config?.required_sections?.length) {
367
416
  return { ok: true };
@@ -375,6 +424,22 @@ function evaluateSectionCheck(content, config) {
375
424
  reason: `Document must contain sections: ${missing.join(', ')}`,
376
425
  };
377
426
  }
427
+
428
+ const placeholderSections = [];
429
+ for (const section of config.required_sections) {
430
+ const result = hasSectionRealContent(content, section, isSectionCheckPlaceholderLine);
431
+ if (result.found && !result.hasContent) {
432
+ placeholderSections.push(section);
433
+ }
434
+ }
435
+
436
+ if (placeholderSections.length > 0) {
437
+ return {
438
+ ok: false,
439
+ reason: `Sections still contain only scaffold placeholder text: ${placeholderSections.join(', ')}. Replace placeholder content before this gate can pass.`,
440
+ };
441
+ }
442
+
378
443
  return { ok: true };
379
444
  }
380
445