agentxchain 2.37.0 → 2.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,9 @@ Legacy IDE-window coordination is still shipped as a compatibility mode for team
9
9
  ## Docs
10
10
 
11
11
  - [Quickstart](https://agentxchain.dev/docs/quickstart/)
12
+ - [Getting Started](https://agentxchain.dev/docs/getting-started/)
12
13
  - [CLI reference](https://agentxchain.dev/docs/cli/)
14
+ - [Templates](https://agentxchain.dev/docs/templates/)
13
15
  - [Export schema reference](https://agentxchain.dev/docs/export-schema/)
14
16
  - [Adapter reference](https://agentxchain.dev/docs/adapters/)
15
17
  - [Protocol spec (v6)](https://agentxchain.dev/docs/protocol/)
@@ -90,6 +92,15 @@ Built-in governed templates:
90
92
  - `web-app`: user flows, UI acceptance, browser support
91
93
  - `enterprise-app`: enterprise planning artifacts plus blueprint-backed `architect` and `security_reviewer` phases
92
94
 
95
+ Inspect the shipped template surfaces instead of inferring them from docs:
96
+
97
+ ```bash
98
+ agentxchain template list
99
+ agentxchain template list --phase-templates
100
+ ```
101
+
102
+ `template list` enumerates governed project templates. `template list --phase-templates` enumerates the reusable workflow-kit phase-template bundles you can reference from `workflow_kit.phases.<phase>.template`.
103
+
93
104
  `step` writes a turn-scoped bundle under `.agentxchain/dispatch/turns/<turn_id>/` and expects a staged result at `.agentxchain/staging/<turn_id>/turn-result.json`. Typical continuation:
94
105
 
95
106
  ```bash
@@ -411,6 +411,7 @@ templateCmd
411
411
  .command('list')
412
412
  .description('List available governed templates')
413
413
  .option('-j, --json', 'Output as JSON')
414
+ .option('--phase-templates', 'List workflow-kit phase templates instead of governed project templates')
414
415
  .action(templateListCommand);
415
416
 
416
417
  templateCmd
@@ -154,8 +154,19 @@ function renderContinuityPanel(continuity) {
154
154
  html += `<div class="turn-detail"><span class="detail-label">Checkpoint:</span> No session checkpoint recorded</div>`;
155
155
  }
156
156
 
157
- if (continuity.restart_recommended) {
158
- html += `<div class="turn-detail"><span class="detail-label">Restart:</span> <span class="mono">agentxchain restart</span></div>`;
157
+ if (continuity.drift_detected === true && Array.isArray(continuity.drift_warnings) && continuity.drift_warnings.length > 0) {
158
+ html += `<div class="turn-detail risks"><span class="detail-label">Drift:</span><ul>`;
159
+ for (const warning of continuity.drift_warnings) {
160
+ html += `<li>${esc(warning)}</li>`;
161
+ }
162
+ html += `</ul></div>`;
163
+ } else if (continuity.drift_detected === false) {
164
+ html += `<div class="turn-detail"><span class="detail-label">Drift:</span> none detected since checkpoint</div>`;
165
+ }
166
+
167
+ if (continuity.recommended_command) {
168
+ const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
169
+ html += `<div class="turn-detail"><span class="detail-label">Action:</span> <span class="mono">${esc(continuity.recommended_command)}</span>${esc(detail)}</div>`;
159
170
  }
160
171
 
161
172
  if (continuity.recovery_report_path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.37.0",
3
+ "version": "2.39.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ import inquirer from 'inquirer';
6
6
  import { CONFIG_FILE, LOCK_FILE, STATE_FILE } from '../lib/config.js';
7
7
  import { generateVSCodeFiles } from '../lib/generate-vscode.js';
8
8
  import { loadGovernedTemplate, VALID_GOVERNED_TEMPLATE_IDS, buildSystemSpecContent } from '../lib/governed-templates.js';
9
- import { VALID_PROMPT_TRANSPORTS } from '../lib/normalized-config.js';
9
+ import { normalizeWorkflowKit, VALID_PROMPT_TRANSPORTS } from '../lib/normalized-config.js';
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const TEMPLATES_DIR = join(__dirname, '../templates');
@@ -628,6 +628,9 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
628
628
  const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
629
629
  const scaffoldConfig = buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitConfig);
630
630
  const { roles, runtimes, routing, gates, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
631
+ const scaffoldWorkflowKitConfig = effectiveWorkflowKitConfig
632
+ ? normalizeWorkflowKit(effectiveWorkflowKitConfig, Object.keys(routing))
633
+ : null;
631
634
  const initialPhase = Object.keys(routing)[0] || 'planning';
632
635
  const phaseGateStatus = Object.fromEntries(
633
636
  [...new Set(
@@ -709,7 +712,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
709
712
  const basePrompt = buildGovernedPrompt(roleId, role, {
710
713
  routing,
711
714
  gates,
712
- workflowKitConfig: effectiveWorkflowKitConfig,
715
+ workflowKitConfig: scaffoldWorkflowKitConfig,
713
716
  });
714
717
  const prompt = appendPromptOverride(basePrompt, template.prompt_overrides?.[roleId]);
715
718
  writeFileSync(join(dir, '.agentxchain', 'prompts', `${roleId}.md`), prompt);
@@ -736,7 +739,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
736
739
 
737
740
  // Workflow-kit custom artifacts — only scaffold files from explicit workflow_kit config
738
741
  // that are not already handled by the default scaffold above
739
- if (effectiveWorkflowKitConfig && effectiveWorkflowKitConfig.phases && typeof effectiveWorkflowKitConfig.phases === 'object') {
742
+ if (scaffoldWorkflowKitConfig && scaffoldWorkflowKitConfig.phases && typeof scaffoldWorkflowKitConfig.phases === 'object') {
740
743
  const defaultScaffoldPaths = new Set([
741
744
  '.planning/PM_SIGNOFF.md',
742
745
  '.planning/ROADMAP.md',
@@ -747,7 +750,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
747
750
  '.planning/RELEASE_NOTES.md',
748
751
  ]);
749
752
 
750
- for (const phaseConfig of Object.values(effectiveWorkflowKitConfig.phases)) {
753
+ for (const phaseConfig of Object.values(scaffoldWorkflowKitConfig.phases)) {
751
754
  if (!Array.isArray(phaseConfig.artifacts)) continue;
752
755
  for (const artifact of phaseConfig.artifacts) {
753
756
  if (!artifact.path || defaultScaffoldPaths.has(artifact.path)) continue;
@@ -782,7 +785,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
782
785
  }
783
786
  }
784
787
 
785
- return { config, state };
788
+ return { config, state, scaffoldWorkflowKitConfig };
786
789
  }
787
790
 
788
791
  async function initGoverned(opts) {
@@ -882,7 +885,7 @@ async function initGoverned(opts) {
882
885
  }
883
886
  }
884
887
 
885
- const { config } = scaffoldGoverned(dir, projectName, projectId, templateId, opts, workflowKitConfig);
888
+ const { config, scaffoldWorkflowKitConfig } = scaffoldGoverned(dir, projectName, projectId, templateId, opts, workflowKitConfig);
886
889
 
887
890
  console.log('');
888
891
  console.log(chalk.green(` ✓ Created governed project ${chalk.bold(targetLabel)}/`));
@@ -897,7 +900,7 @@ async function initGoverned(opts) {
897
900
  console.log(` ${chalk.dim('│')} ${chalk.dim('├──')} reviews/`);
898
901
  console.log(` ${chalk.dim('│')} ${chalk.dim('└──')} dispatch/`);
899
902
  console.log(` ${chalk.dim('├──')} .planning/`);
900
- const planningSummaryLines = buildPlanningSummaryLines(selectedTemplate, config.workflow_kit);
903
+ const planningSummaryLines = buildPlanningSummaryLines(selectedTemplate, scaffoldWorkflowKitConfig);
901
904
  for (const [index, line] of planningSummaryLines.entries()) {
902
905
  const branch = index === planningSummaryLines.length - 1 ? '└──' : '├──';
903
906
  console.log(` ${chalk.dim('│')} ${chalk.dim(branch)} ${line}`);
@@ -23,13 +23,13 @@ import {
23
23
  HISTORY_PATH,
24
24
  LEDGER_PATH,
25
25
  } from '../lib/governed-state.js';
26
- import { readSessionCheckpoint, SESSION_PATH } from '../lib/session-checkpoint.js';
26
+ import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
27
27
 
28
28
  /**
29
29
  * Generate a session recovery report summarizing the run state
30
30
  * so a new agent session can orient quickly.
31
31
  */
32
- function generateRecoveryReport(root, state, checkpoint) {
32
+ function generateRecoveryReport(root, state, checkpoint, driftWarnings = []) {
33
33
  const lines = [
34
34
  '# Session Recovery Report',
35
35
  '',
@@ -102,7 +102,50 @@ function generateRecoveryReport(root, state, checkpoint) {
102
102
  }
103
103
  }
104
104
 
105
- lines.push('## Next Steps', '', 'The next turn has been assigned. Check the dispatch bundle for context.', '');
105
+ // Pending gate / run completion surfacing
106
+ if (state.pending_phase_transition) {
107
+ const pt = state.pending_phase_transition;
108
+ lines.push(
109
+ '## Pending Phase Transition',
110
+ '',
111
+ `- **From**: ${pt.from}`,
112
+ `- **To**: ${pt.to}`,
113
+ `- **Gate**: ${pt.gate}`,
114
+ `- **Requested by**: ${pt.requested_by_turn || 'unknown'}`,
115
+ `- **Action**: Run \`agentxchain approve-transition\` to approve`,
116
+ '',
117
+ );
118
+ }
119
+
120
+ if (state.pending_run_completion) {
121
+ const pc = state.pending_run_completion;
122
+ lines.push(
123
+ '## Pending Run Completion',
124
+ '',
125
+ `- **Gate**: ${pc.gate}`,
126
+ `- **Requested by**: ${pc.requested_by_turn || 'unknown'}`,
127
+ `- **Action**: Run \`agentxchain approve-completion\` to approve`,
128
+ '',
129
+ );
130
+ }
131
+
132
+ // Repo-drift warnings
133
+ if (checkpoint?.baseline_ref && driftWarnings?.length > 0) {
134
+ lines.push('## Continuity Warnings', '');
135
+ for (const warning of driftWarnings) {
136
+ lines.push(`- ⚠ ${warning}`);
137
+ }
138
+ lines.push('');
139
+ }
140
+
141
+ lines.push('## Next Steps', '');
142
+ if (state.pending_phase_transition) {
143
+ lines.push('A phase transition is pending approval. Run `agentxchain approve-transition` before assigning new turns.', '');
144
+ } else if (state.pending_run_completion) {
145
+ lines.push('A run completion is pending approval. Run `agentxchain approve-completion` to finalize.', '');
146
+ } else {
147
+ lines.push('The next turn has been assigned. Check the dispatch bundle for context.', '');
148
+ }
106
149
 
107
150
  return lines.join('\n');
108
151
  }
@@ -160,6 +203,42 @@ export async function restartCommand(opts) {
160
203
  process.exit(1);
161
204
  }
162
205
 
206
+ // ── Repo-drift detection ────────────────────────────────────────────────
207
+ const driftWarnings = [];
208
+ if (checkpoint?.baseline_ref) {
209
+ const currentBaseline = captureBaselineRef(root);
210
+ const prev = checkpoint.baseline_ref;
211
+
212
+ if (prev.git_head && currentBaseline.git_head && prev.git_head !== currentBaseline.git_head) {
213
+ driftWarnings.push(`Git HEAD has moved since checkpoint: ${prev.git_head.slice(0, 8)} → ${currentBaseline.git_head.slice(0, 8)}`);
214
+ }
215
+ if (prev.git_branch && currentBaseline.git_branch && prev.git_branch !== currentBaseline.git_branch) {
216
+ driftWarnings.push(`Branch changed since checkpoint: ${prev.git_branch} → ${currentBaseline.git_branch}`);
217
+ }
218
+ if (prev.workspace_dirty === false && currentBaseline.workspace_dirty === true) {
219
+ driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
220
+ }
221
+ }
222
+
223
+ if (driftWarnings.length > 0) {
224
+ for (const warning of driftWarnings) {
225
+ console.log(chalk.yellow(`⚠ ${warning}`));
226
+ }
227
+ }
228
+
229
+ // ── Pending gate / completion check ────────────────────────────────────
230
+ if (state.pending_phase_transition) {
231
+ const pt = state.pending_phase_transition;
232
+ console.log(chalk.yellow(`Pending phase transition: ${pt.from} → ${pt.to} (gate: ${pt.gate})`));
233
+ console.log(chalk.dim('Run `agentxchain approve-transition` to approve before assigning new turns.'));
234
+ }
235
+
236
+ if (state.pending_run_completion) {
237
+ const pc = state.pending_run_completion;
238
+ console.log(chalk.yellow(`Pending run completion (gate: ${pc.gate})`));
239
+ console.log(chalk.dim('Run `agentxchain approve-completion` to finalize.'));
240
+ }
241
+
163
242
  // Handle abandoned active turns (assigned but never completed)
164
243
  const activeTurns = getActiveTurns(state);
165
244
  const activeTurnCount = getActiveTurnCount(state);
@@ -167,9 +246,29 @@ export async function restartCommand(opts) {
167
246
  const turnIds = Object.keys(activeTurns);
168
247
  console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
169
248
  console.log(chalk.dim('These turns will be available for the next agent to complete.'));
249
+
250
+ // Fail closed if retained turn + irreconcilable drift
251
+ if (driftWarnings.length > 0) {
252
+ console.log(chalk.yellow('Active turns exist with repo drift since checkpoint. Reconnecting with warnings.'));
253
+ console.log(chalk.dim('Inspect the drift before continuing work on the retained turns.'));
254
+ }
170
255
  }
171
256
 
172
- // If paused, reactivate
257
+ // If pending gate/completion, do not bypass — surface and exit with recovery
258
+ if (state.pending_phase_transition || state.pending_run_completion) {
259
+ // Write checkpoint for the reconnect
260
+ writeSessionCheckpoint(root, state, 'restart_reconnect');
261
+
262
+ const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
263
+ const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
264
+ const recoveryDir = dirname(recoveryPath);
265
+ if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
266
+ writeFileSync(recoveryPath, recoveryReport);
267
+ console.log(chalk.dim(` Recovery report: .agentxchain/SESSION_RECOVERY.md`));
268
+ return;
269
+ }
270
+
271
+ // If paused or idle without a pending approval gate, reactivate.
173
272
  if (state.status === 'paused' || state.status === 'idle') {
174
273
  const reactivated = reactivateGovernedRun(root, state, {
175
274
  reason: 'session_restart',
@@ -198,6 +297,8 @@ export async function restartCommand(opts) {
198
297
  process.exit(1);
199
298
  }
200
299
 
300
+ // assignGovernedTurn already writes a checkpoint at turn_assigned
301
+
201
302
  console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
202
303
  console.log(chalk.dim(` Phase: ${phase}`));
203
304
  console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
@@ -206,6 +307,9 @@ export async function restartCommand(opts) {
206
307
  console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
207
308
  }
208
309
  } else {
310
+ // Reconnect to existing active turns — write checkpoint
311
+ writeSessionCheckpoint(root, state, 'restart_reconnect');
312
+
209
313
  console.log(chalk.green(`✓ Reconnected to run ${state.run_id}`));
210
314
  console.log(chalk.dim(` Phase: ${phase}`));
211
315
  console.log(chalk.dim(` Active turns: ${Object.keys(activeTurns).join(', ')}`));
@@ -213,7 +317,7 @@ export async function restartCommand(opts) {
213
317
  }
214
318
 
215
319
  // Write session recovery report
216
- const recoveryReport = generateRecoveryReport(root, state, checkpoint);
320
+ const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
217
321
  const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
218
322
  const recoveryDir = dirname(recoveryPath);
219
323
  if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
@@ -268,8 +268,21 @@ function renderContinuityStatus(continuity, state) {
268
268
  console.log(` ${chalk.dim('Checkpoint:')} ${chalk.yellow('No session checkpoint recorded')}`);
269
269
  }
270
270
 
271
- if (continuity.restart_recommended) {
272
- console.log(` ${chalk.dim('Restart:')} ${chalk.cyan('agentxchain restart')} (rebuild session context from disk)`);
271
+ if (continuity.drift_detected === true) {
272
+ const [firstWarning, ...remainingWarnings] = continuity.drift_warnings || [];
273
+ if (firstWarning) {
274
+ console.log(` ${chalk.dim('Drift:')} ${chalk.yellow(firstWarning)}`);
275
+ }
276
+ for (const warning of remainingWarnings) {
277
+ console.log(` ${chalk.dim(' ')} ${chalk.yellow(warning)}`);
278
+ }
279
+ } else if (continuity.drift_detected === false) {
280
+ console.log(` ${chalk.dim('Drift:')} ${chalk.green('none detected since checkpoint')}`);
281
+ }
282
+
283
+ if (continuity.recommended_command) {
284
+ const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
285
+ console.log(` ${chalk.dim('Action:')} ${chalk.cyan(continuity.recommended_command)}${chalk.dim(detail)}`);
273
286
  }
274
287
 
275
288
  if (continuity.recovery_report_path) {
@@ -1,7 +1,12 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadAllGovernedTemplates, VALID_GOVERNED_TEMPLATE_IDS } from '../lib/governed-templates.js';
3
+ import { listWorkflowKitPhaseTemplates } from '../lib/workflow-kit-phase-templates.js';
3
4
 
4
5
  export function templateListCommand(opts) {
6
+ if (opts.phaseTemplates) {
7
+ return listPhaseTemplates(opts);
8
+ }
9
+
5
10
  if (opts.json) {
6
11
  const templates = loadAllGovernedTemplates();
7
12
  const output = templates.map((t) => ({
@@ -34,4 +39,40 @@ export function templateListCommand(opts) {
34
39
  console.log('');
35
40
  }
36
41
  console.log(chalk.dim(` Usage: agentxchain template set <id>\n`));
42
+ console.log(chalk.dim(` Tip: use --phase-templates to list workflow-kit phase templates.\n`));
43
+ }
44
+
45
+ function listPhaseTemplates(opts) {
46
+ const templates = listWorkflowKitPhaseTemplates();
47
+
48
+ if (opts.json) {
49
+ const output = templates.map((t) => ({
50
+ id: t.id,
51
+ description: t.description,
52
+ artifacts: t.artifacts.map((a) => ({
53
+ path: a.path,
54
+ semantics: a.semantics || null,
55
+ semantics_config: a.semantics_config || null,
56
+ required: a.required,
57
+ })),
58
+ }));
59
+ console.log(JSON.stringify(output, null, 2));
60
+ return;
61
+ }
62
+
63
+ console.log(chalk.bold('\n Workflow-kit phase templates:\n'));
64
+ for (const t of templates) {
65
+ console.log(` ${chalk.cyan(t.id)} — ${t.description}`);
66
+ for (const a of t.artifacts) {
67
+ const req = a.required ? chalk.green('required') : chalk.dim('optional');
68
+ const sem = a.semantics ? chalk.yellow(a.semantics) : chalk.dim('none');
69
+ console.log(` ${a.path} [${req}] [semantics: ${sem}]`);
70
+ if (a.semantics === 'section_check' && a.semantics_config?.required_sections) {
71
+ console.log(` sections: ${a.semantics_config.required_sections.join(', ')}`);
72
+ }
73
+ }
74
+ console.log('');
75
+ }
76
+ console.log(chalk.dim(' Usage in agentxchain.json:'));
77
+ console.log(chalk.dim(' "workflow_kit": { "phases": { "<phase>": { "template": "<id>" } } }\n'));
37
78
  }
@@ -1,9 +1,111 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { readSessionCheckpoint } from './session-checkpoint.js';
3
+ import { captureBaselineRef, readSessionCheckpoint } from './session-checkpoint.js';
4
4
 
5
5
  export const SESSION_RECOVERY_PATH = '.agentxchain/SESSION_RECOVERY.md';
6
6
 
7
+ function deriveRecommendedContinuityAction(state) {
8
+ if (!state) {
9
+ return {
10
+ recommended_command: null,
11
+ recommended_reason: 'no_state',
12
+ recommended_detail: null,
13
+ restart_recommended: false,
14
+ };
15
+ }
16
+
17
+ if (state.pending_phase_transition) {
18
+ const pt = state.pending_phase_transition;
19
+ return {
20
+ recommended_command: 'agentxchain approve-transition',
21
+ recommended_reason: 'pending_phase_transition',
22
+ recommended_detail: `${pt.from || 'unknown'} -> ${pt.to || 'unknown'} (gate: ${pt.gate || 'unknown'})`,
23
+ restart_recommended: false,
24
+ };
25
+ }
26
+
27
+ if (state.pending_run_completion) {
28
+ const pc = state.pending_run_completion;
29
+ return {
30
+ recommended_command: 'agentxchain approve-completion',
31
+ recommended_reason: 'pending_run_completion',
32
+ recommended_detail: pc.gate ? `gate: ${pc.gate}` : null,
33
+ restart_recommended: false,
34
+ };
35
+ }
36
+
37
+ if (!['blocked', 'completed', 'failed'].includes(state.status)) {
38
+ return {
39
+ recommended_command: 'agentxchain restart',
40
+ recommended_reason: 'restart_available',
41
+ recommended_detail: 'rebuild session context from disk',
42
+ restart_recommended: true,
43
+ };
44
+ }
45
+
46
+ return {
47
+ recommended_command: null,
48
+ recommended_reason: state.status === 'blocked' ? 'blocked' : 'terminal_state',
49
+ recommended_detail: null,
50
+ restart_recommended: false,
51
+ };
52
+ }
53
+
54
+ function deriveCheckpointDrift(root, checkpoint, staleCheckpoint) {
55
+ if (!checkpoint?.baseline_ref || staleCheckpoint) {
56
+ return {
57
+ drift_detected: null,
58
+ drift_warnings: [],
59
+ };
60
+ }
61
+
62
+ const currentBaseline = captureBaselineRef(root);
63
+ const previousBaseline = checkpoint.baseline_ref;
64
+
65
+ if (
66
+ currentBaseline.git_head == null
67
+ && currentBaseline.git_branch == null
68
+ && currentBaseline.workspace_dirty == null
69
+ ) {
70
+ return {
71
+ drift_detected: null,
72
+ drift_warnings: [],
73
+ };
74
+ }
75
+
76
+ const driftWarnings = [];
77
+
78
+ if (
79
+ previousBaseline.git_head
80
+ && currentBaseline.git_head
81
+ && previousBaseline.git_head !== currentBaseline.git_head
82
+ ) {
83
+ driftWarnings.push(
84
+ `Git HEAD has moved since checkpoint: ${previousBaseline.git_head.slice(0, 8)} -> ${currentBaseline.git_head.slice(0, 8)}`
85
+ );
86
+ }
87
+
88
+ if (
89
+ previousBaseline.git_branch
90
+ && currentBaseline.git_branch
91
+ && previousBaseline.git_branch !== currentBaseline.git_branch
92
+ ) {
93
+ driftWarnings.push(`Branch changed since checkpoint: ${previousBaseline.git_branch} -> ${currentBaseline.git_branch}`);
94
+ }
95
+
96
+ if (
97
+ previousBaseline.workspace_dirty === false
98
+ && currentBaseline.workspace_dirty === true
99
+ ) {
100
+ driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
101
+ }
102
+
103
+ return {
104
+ drift_detected: driftWarnings.length > 0,
105
+ drift_warnings: driftWarnings,
106
+ };
107
+ }
108
+
7
109
  export function getContinuityStatus(root, state) {
8
110
  const checkpoint = readSessionCheckpoint(root);
9
111
  const recoveryReportPath = existsSync(join(root, SESSION_RECOVERY_PATH))
@@ -18,10 +120,18 @@ export function getContinuityStatus(root, state) {
18
120
  && checkpoint.run_id !== state.run_id
19
121
  );
20
122
 
123
+ const action = deriveRecommendedContinuityAction(state);
124
+ const drift = deriveCheckpointDrift(root, checkpoint, staleCheckpoint);
125
+
21
126
  return {
22
127
  checkpoint,
23
128
  stale_checkpoint: staleCheckpoint,
24
129
  recovery_report_path: recoveryReportPath,
25
- restart_recommended: !!state && !['blocked', 'completed', 'failed'].includes(state.status),
130
+ restart_recommended: action.restart_recommended,
131
+ recommended_command: action.recommended_command,
132
+ recommended_reason: action.recommended_reason,
133
+ recommended_detail: action.recommended_detail,
134
+ drift_detected: drift.drift_detected,
135
+ drift_warnings: drift.drift_warnings,
26
136
  };
27
137
  }
@@ -1352,6 +1352,11 @@ export function markRunBlocked(root, details) {
1352
1352
 
1353
1353
  writeState(root, updatedState);
1354
1354
 
1355
+ // Session checkpoint — non-fatal, written after blocked state is persisted
1356
+ writeSessionCheckpoint(root, updatedState, 'blocked', {
1357
+ role: turnId ? (getActiveTurns(updatedState)[turnId]?.assigned_role || null) : null,
1358
+ });
1359
+
1355
1360
  emitBlockedNotification(root, details.notificationConfig, updatedState, {
1356
1361
  category: details.category,
1357
1362
  blockedOn: details.blockedOn,
@@ -1753,6 +1758,13 @@ export function assignGovernedTurn(root, config, roleId) {
1753
1758
  };
1754
1759
 
1755
1760
  writeState(root, updatedState);
1761
+
1762
+ // Session checkpoint — non-fatal, written after every successful turn assignment
1763
+ writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
1764
+ role: roleId,
1765
+ dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
1766
+ });
1767
+
1756
1768
  const assignedTurn = updatedState.active_turns[turnId];
1757
1769
  const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), turn: assignedTurn };
1758
1770
  if (warnings.length > 0) {
@@ -15,6 +15,12 @@
15
15
  import { validateHooksConfig } from './hook-runner.js';
16
16
  import { validateNotificationsConfig } from './notification-runner.js';
17
17
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
18
+ import {
19
+ buildDefaultWorkflowKitArtifactsForPhase,
20
+ expandWorkflowKitPhaseArtifacts,
21
+ isWorkflowKitPhaseTemplateId,
22
+ VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS,
23
+ } from './workflow-kit-phase-templates.js';
18
24
 
19
25
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
20
26
  const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
@@ -26,25 +32,6 @@ export { DEFAULT_PHASES };
26
32
  const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
27
33
  const VALID_SEMANTIC_IDS = ['pm_signoff', 'system_spec', 'implementation_notes', 'acceptance_matrix', 'ship_verdict', 'release_notes', 'section_check'];
28
34
 
29
- /**
30
- * Default artifact map for phases when workflow_kit is absent from config.
31
- * Only phases present in this map get default artifacts.
32
- */
33
- const DEFAULT_PHASE_ARTIFACTS = {
34
- planning: [
35
- { path: '.planning/PM_SIGNOFF.md', semantics: 'pm_signoff', required: true },
36
- { path: '.planning/SYSTEM_SPEC.md', semantics: 'system_spec', required: true },
37
- { path: '.planning/ROADMAP.md', semantics: null, required: true },
38
- ],
39
- implementation: [
40
- { path: '.planning/IMPLEMENTATION_NOTES.md', semantics: 'implementation_notes', required: true },
41
- ],
42
- qa: [
43
- { path: '.planning/acceptance-matrix.md', semantics: 'acceptance_matrix', required: true },
44
- { path: '.planning/ship-verdict.md', semantics: 'ship_verdict', required: true },
45
- { path: '.planning/RELEASE_NOTES.md', semantics: 'release_notes', required: true },
46
- ],
47
- };
48
35
  const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
49
36
  const VALID_API_PROXY_RETRY_CLASSES = [
50
37
  'rate_limited',
@@ -566,14 +553,35 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
566
553
  continue;
567
554
  }
568
555
 
569
- if (!Array.isArray(phaseConfig.artifacts)) {
556
+ let templateValid = true;
557
+ if (phaseConfig.template !== undefined) {
558
+ if (typeof phaseConfig.template !== 'string' || !phaseConfig.template.trim()) {
559
+ errors.push(`workflow_kit.phases.${phase}.template must be a non-empty string`);
560
+ templateValid = false;
561
+ } else if (!isWorkflowKitPhaseTemplateId(phaseConfig.template)) {
562
+ errors.push(
563
+ `workflow_kit.phases.${phase}.template "${phaseConfig.template}" is unknown; valid values: ${VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.join(', ')}`,
564
+ );
565
+ templateValid = false;
566
+ }
567
+ }
568
+
569
+ if (phaseConfig.artifacts !== undefined && !Array.isArray(phaseConfig.artifacts)) {
570
570
  errors.push(`workflow_kit.phases.${phase}.artifacts must be an array`);
571
571
  continue;
572
572
  }
573
573
 
574
+ if (phaseConfig.template === undefined && phaseConfig.artifacts === undefined) {
575
+ errors.push(`workflow_kit.phases.${phase} must declare template, artifacts, or both`);
576
+ continue;
577
+ }
578
+
574
579
  const seenPaths = new Set();
575
- for (let i = 0; i < phaseConfig.artifacts.length; i++) {
576
- const artifact = phaseConfig.artifacts[i];
580
+ const expandedArtifacts = templateValid
581
+ ? expandWorkflowKitPhaseArtifacts(phaseConfig)
582
+ : Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
583
+ for (let i = 0; i < expandedArtifacts.length; i++) {
584
+ const artifact = expandedArtifacts[i];
577
585
  const prefix = `workflow_kit.phases.${phase}.artifacts[${i}]`;
578
586
 
579
587
  if (!artifact || typeof artifact !== 'object') {
@@ -845,7 +853,7 @@ export function getMaxConcurrentTurns(config, phase) {
845
853
 
846
854
  /**
847
855
  * Normalize workflow_kit config.
848
- * When absent, builds defaults from routing phases using DEFAULT_PHASE_ARTIFACTS.
856
+ * When absent, builds defaults from routing phases using the built-in phase templates.
849
857
  * When present, normalizes artifact entries.
850
858
  */
851
859
  export function normalizeWorkflowKit(raw, routingPhases) {
@@ -862,7 +870,7 @@ export function normalizeWorkflowKit(raw, routingPhases) {
862
870
  if (raw.phases) {
863
871
  for (const [phase, phaseConfig] of Object.entries(raw.phases)) {
864
872
  phases[phase] = {
865
- artifacts: (phaseConfig.artifacts || []).map(a => ({
873
+ artifacts: expandWorkflowKitPhaseArtifacts(phaseConfig).map(a => ({
866
874
  path: a.path,
867
875
  semantics: a.semantics || null,
868
876
  semantics_config: a.semantics_config || null,
@@ -879,9 +887,10 @@ export function normalizeWorkflowKit(raw, routingPhases) {
879
887
  function buildDefaultWorkflowKit(routingPhases) {
880
888
  const phases = {};
881
889
  for (const phase of routingPhases) {
882
- if (DEFAULT_PHASE_ARTIFACTS[phase]) {
890
+ const templateArtifacts = buildDefaultWorkflowKitArtifactsForPhase(phase);
891
+ if (templateArtifacts) {
883
892
  phases[phase] = {
884
- artifacts: DEFAULT_PHASE_ARTIFACTS[phase].map(a => ({ ...a, semantics_config: null })),
893
+ artifacts: templateArtifacts.map(a => ({ ...a, semantics_config: a.semantics_config || null })),
885
894
  };
886
895
  }
887
896
  }
@@ -2,9 +2,10 @@
2
2
  * Session checkpoint — automatic state markers for cross-session restart.
3
3
  *
4
4
  * Writes .agentxchain/session.json at every governance boundary
5
- * (turn acceptance, phase transition, gate approval, run completion)
6
- * so that `agentxchain restart` can reconstruct dispatch context
7
- * without any in-memory session state.
5
+ * (turn assignment, acceptance, phase transition, blocked state,
6
+ * gate approval, run completion, restart/reconnect) so that
7
+ * `agentxchain restart` can reconstruct dispatch context without
8
+ * any in-memory session state.
8
9
  *
9
10
  * Design rules:
10
11
  * - Checkpoint writes are non-fatal: failures log a warning and do not
@@ -12,11 +13,13 @@
12
13
  * - The file is always overwritten, not appended.
13
14
  * - run_id in session.json must agree with state.json; mismatch is a
14
15
  * corruption signal.
16
+ * - state.json is always authoritative; session.json is recovery metadata.
15
17
  */
16
18
 
17
19
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
18
20
  import { join, dirname } from 'path';
19
21
  import { randomBytes } from 'crypto';
22
+ import { execSync as shellExec } from 'child_process';
20
23
 
21
24
  const SESSION_PATH = '.agentxchain/session.json';
22
25
 
@@ -27,6 +30,25 @@ function generateSessionId() {
27
30
  return `session_${randomBytes(8).toString('hex')}`;
28
31
  }
29
32
 
33
+ /**
34
+ * Capture git baseline ref for repo-drift detection.
35
+ * Non-fatal: returns partial/null fields on failure.
36
+ */
37
+ export function captureBaselineRef(root) {
38
+ try {
39
+ const gitHead = shellExec('git rev-parse HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
40
+ const gitBranch = shellExec('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
41
+ const statusOutput = shellExec('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
42
+ return {
43
+ git_head: gitHead,
44
+ git_branch: gitBranch,
45
+ workspace_dirty: statusOutput.length > 0,
46
+ };
47
+ } catch {
48
+ return { git_head: null, git_branch: null, workspace_dirty: null };
49
+ }
50
+ }
51
+
30
52
  /**
31
53
  * Read the current session checkpoint, or null if none exists.
32
54
  */
@@ -40,12 +62,21 @@ export function readSessionCheckpoint(root) {
40
62
  }
41
63
  }
42
64
 
65
+ /**
66
+ * Extract active turn IDs from governed state.
67
+ */
68
+ function getActiveTurnIds(state) {
69
+ const turns = state.active_turns || {};
70
+ return Object.keys(turns);
71
+ }
72
+
43
73
  /**
44
74
  * Write or update the session checkpoint.
45
75
  *
46
76
  * @param {string} root - project root
47
77
  * @param {object} state - current governed state (from state.json)
48
- * @param {string} reason - checkpoint reason (e.g. 'turn_accepted', 'phase_approved', 'run_completed')
78
+ * @param {string} reason - checkpoint reason (e.g. 'turn_assigned', 'turn_accepted',
79
+ * 'phase_approved', 'run_completed', 'blocked', 'restart_reconnect')
49
80
  * @param {object} [extra] - optional extra context fields
50
81
  */
51
82
  export function writeSessionCheckpoint(root, state, reason, extra = {}) {
@@ -58,20 +89,40 @@ export function writeSessionCheckpoint(root, state, reason, extra = {}) {
58
89
  : generateSessionId();
59
90
 
60
91
  const currentTurn = state.current_turn || null;
61
- const lastTurnId = currentTurn?.id || currentTurn?.turn_id || state.last_completed_turn_id || null;
92
+ const activeTurnIds = getActiveTurnIds(state);
93
+ const lastTurnId = currentTurn?.id || currentTurn?.turn_id
94
+ || (activeTurnIds.length > 0 ? activeTurnIds[activeTurnIds.length - 1] : null)
95
+ || state.last_completed_turn_id || null;
62
96
  const lastRole = currentTurn?.role || currentTurn?.assigned_role || extra.role || null;
63
97
  const lastPhase = state.current_phase || state.phase || null;
64
98
 
99
+ // Derive last_completed_turn_id from history or state
100
+ const lastCompletedTurnId = state.last_completed_turn_id || existing?.last_completed_turn_id || null;
101
+
102
+ // Derive pending gates
103
+ const pendingGate = state.pending_phase_transition?.gate || state.pending_transition?.gate || null;
104
+ const pendingRunCompletion = state.pending_run_completion?.gate || null;
105
+
106
+ // Capture git baseline for repo-drift detection
107
+ const baselineRef = extra.baseline_ref || captureBaselineRef(root);
108
+
65
109
  const checkpoint = {
66
110
  session_id: sessionId,
67
111
  run_id: state.run_id,
68
112
  started_at: existing?.started_at || new Date().toISOString(),
69
113
  last_checkpoint_at: new Date().toISOString(),
114
+ checkpoint_reason: reason,
115
+ run_status: state.status || null,
116
+ phase: lastPhase,
117
+ last_phase: lastPhase, // backward compat alias for report.js consumers
70
118
  last_turn_id: lastTurnId,
71
- last_phase: lastPhase,
119
+ last_completed_turn_id: lastCompletedTurnId,
120
+ active_turn_ids: activeTurnIds,
72
121
  last_role: lastRole,
73
- run_status: state.status || null,
74
- checkpoint_reason: reason,
122
+ pending_gate: pendingGate,
123
+ pending_run_completion: pendingRunCompletion ? true : null,
124
+ blocked: state.status === 'blocked',
125
+ baseline_ref: baselineRef,
75
126
  agent_context: {
76
127
  adapter: extra.adapter || null,
77
128
  dispatch_dir: extra.dispatch_dir || null,
@@ -0,0 +1,102 @@
1
+ function cloneJsonCompatible(value) {
2
+ return value == null ? value : JSON.parse(JSON.stringify(value));
3
+ }
4
+
5
+ const WORKFLOW_KIT_PHASE_TEMPLATE_REGISTRY = Object.freeze({
6
+ 'planning-default': {
7
+ description: 'Core planning proof surface for governed repos.',
8
+ artifacts: [
9
+ { path: '.planning/PM_SIGNOFF.md', semantics: 'pm_signoff', required: true },
10
+ { path: '.planning/SYSTEM_SPEC.md', semantics: 'system_spec', required: true },
11
+ { path: '.planning/ROADMAP.md', semantics: null, required: true },
12
+ ],
13
+ },
14
+ 'implementation-default': {
15
+ description: 'Implementation proof surface for governed repos.',
16
+ artifacts: [
17
+ { path: '.planning/IMPLEMENTATION_NOTES.md', semantics: 'implementation_notes', required: true },
18
+ ],
19
+ },
20
+ 'qa-default': {
21
+ description: 'QA and ship-verdict proof surface for governed repos.',
22
+ artifacts: [
23
+ { path: '.planning/acceptance-matrix.md', semantics: 'acceptance_matrix', required: true },
24
+ { path: '.planning/ship-verdict.md', semantics: 'ship_verdict', required: true },
25
+ { path: '.planning/RELEASE_NOTES.md', semantics: 'release_notes', required: true },
26
+ ],
27
+ },
28
+ 'architecture-review': {
29
+ description: 'Structured architecture-review document with required sections.',
30
+ artifacts: [
31
+ {
32
+ path: '.planning/ARCHITECTURE.md',
33
+ semantics: 'section_check',
34
+ semantics_config: {
35
+ required_sections: ['## Context', '## Proposed Design', '## Trade-offs', '## Risks'],
36
+ },
37
+ required: true,
38
+ },
39
+ ],
40
+ },
41
+ 'security-review': {
42
+ description: 'Structured security-review document with required sections.',
43
+ artifacts: [
44
+ {
45
+ path: '.planning/SECURITY_REVIEW.md',
46
+ semantics: 'section_check',
47
+ semantics_config: {
48
+ required_sections: ['## Threat Model', '## Findings', '## Verdict'],
49
+ },
50
+ required: true,
51
+ },
52
+ ],
53
+ },
54
+ });
55
+
56
+ export const VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS = Object.freeze(
57
+ Object.keys(WORKFLOW_KIT_PHASE_TEMPLATE_REGISTRY),
58
+ );
59
+
60
+ const DEFAULT_WORKFLOW_KIT_PHASE_TEMPLATE_BY_PHASE = Object.freeze({
61
+ planning: 'planning-default',
62
+ implementation: 'implementation-default',
63
+ qa: 'qa-default',
64
+ });
65
+
66
+ export function listWorkflowKitPhaseTemplates() {
67
+ return VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.map((id) => loadWorkflowKitPhaseTemplate(id));
68
+ }
69
+
70
+ export function isWorkflowKitPhaseTemplateId(templateId) {
71
+ return VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.includes(templateId);
72
+ }
73
+
74
+ export function loadWorkflowKitPhaseTemplate(templateId) {
75
+ if (!isWorkflowKitPhaseTemplateId(templateId)) {
76
+ throw new Error(
77
+ `Unknown workflow-kit phase template "${templateId}". Valid templates: ${VALID_WORKFLOW_KIT_PHASE_TEMPLATE_IDS.join(', ')}`,
78
+ );
79
+ }
80
+
81
+ const template = WORKFLOW_KIT_PHASE_TEMPLATE_REGISTRY[templateId];
82
+ return {
83
+ id: templateId,
84
+ description: template.description,
85
+ artifacts: cloneJsonCompatible(template.artifacts),
86
+ };
87
+ }
88
+
89
+ export function expandWorkflowKitPhaseArtifacts(phaseConfig = {}) {
90
+ const templateArtifacts = phaseConfig.template
91
+ ? loadWorkflowKitPhaseTemplate(phaseConfig.template).artifacts
92
+ : [];
93
+ const explicitArtifacts = Array.isArray(phaseConfig.artifacts)
94
+ ? cloneJsonCompatible(phaseConfig.artifacts)
95
+ : [];
96
+ return [...templateArtifacts, ...explicitArtifacts];
97
+ }
98
+
99
+ export function buildDefaultWorkflowKitArtifactsForPhase(phase) {
100
+ const templateId = DEFAULT_WORKFLOW_KIT_PHASE_TEMPLATE_BY_PHASE[phase];
101
+ return templateId ? loadWorkflowKitPhaseTemplate(templateId).artifacts : null;
102
+ }