agentxchain 2.155.9 → 2.155.11

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.
@@ -130,6 +130,7 @@ import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, sche
130
130
  import { chainLatestCommand, chainListCommand, chainShowCommand } from '../src/commands/chain.js';
131
131
  import { missionAttachChainCommand, missionBindCoordinatorCommand, missionListCommand, missionPlanApproveCommand, missionPlanAutopilotCommand, missionPlanCommand, missionPlanLaunchCommand, missionPlanListCommand, missionPlanShowCommand, missionShowCommand, missionStartCommand } from '../src/commands/mission.js';
132
132
  import { workflowKitDescribeCommand } from '../src/commands/workflow-kit.js';
133
+ import { serveMcpCommand } from '../src/commands/serve-mcp.js';
133
134
 
134
135
  const __dirname = dirname(fileURLToPath(import.meta.url));
135
136
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -782,6 +783,12 @@ program
782
783
  .option('--dry-run', 'Show configured gate actions without executing approval')
783
784
  .action(approveCompletionCommand);
784
785
 
786
+ program
787
+ .command('serve-mcp')
788
+ .description('Start an MCP server exposing governance tools (stdio transport)')
789
+ .option('--root <path>', 'Project root directory (default: cwd)')
790
+ .action(serveMcpCommand);
791
+
785
792
  program
786
793
  .command('dashboard')
787
794
  .description('Open the live governance dashboard for the current repo/workspace')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.9",
3
+ "version": "2.155.11",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -673,7 +673,7 @@ export async function executeGovernedRun(context, opts = {}) {
673
673
  const reportsDir = join(root, '.agentxchain', 'reports');
674
674
  mkdirSync(reportsDir, { recursive: true });
675
675
 
676
- const exportResult = buildRunExport(root);
676
+ const exportResult = buildRunExport(root, { maxJsonlEntries: 1000, maxBase64Bytes: 1024 * 1024 });
677
677
  if (exportResult.ok) {
678
678
  const runId = result.state.run_id || 'unknown';
679
679
  const exportPath = join(reportsDir, `export-${runId}.json`);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * agentxchain serve-mcp — start an MCP server exposing governance tools.
3
+ *
4
+ * Runs a stdio-based MCP server that lets any MCP-compatible client
5
+ * (Claude Code, Cursor, Windsurf, VS Code extensions, etc.) interact
6
+ * with a governed AgentXchain project.
7
+ *
8
+ * Usage:
9
+ * agentxchain serve-mcp [--root <project-root>]
10
+ *
11
+ * The server reads JSON-RPC from stdin and writes responses to stdout,
12
+ * per the MCP specification.
13
+ */
14
+
15
+ import { resolve } from 'path';
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
+ import { createAgentXchainMcpServer } from '../lib/mcp-server.js';
18
+ import { findProjectRoot } from '../lib/config.js';
19
+
20
+ export async function serveMcpCommand(opts) {
21
+ const rootHint = opts.root || process.cwd();
22
+ const root = findProjectRoot(resolve(rootHint));
23
+
24
+ if (!root) {
25
+ // Even without a governed project, start the server so MCP clients
26
+ // get descriptive tool errors instead of a connection-refused.
27
+ process.stderr.write(
28
+ `[agentxchain serve-mcp] Warning: no agentxchain.json found at ${rootHint}. ` +
29
+ `Tools will return errors until a governed project is available.\n`,
30
+ );
31
+ }
32
+
33
+ const projectRoot = root || resolve(rootHint);
34
+ const server = createAgentXchainMcpServer(projectRoot);
35
+ const transport = new StdioServerTransport();
36
+
37
+ process.stderr.write(`[agentxchain serve-mcp] Starting MCP server for ${projectRoot}\n`);
38
+
39
+ await server.connect(transport);
40
+ }
@@ -157,6 +157,32 @@ function getAcceptedIdleExpansionEntries(execution) {
157
157
  return entries.filter((entry) => entry?.turn_result?.idle_expansion_result);
158
158
  }
159
159
 
160
+ function readIdleExpansionPrompt(root, config) {
161
+ const configuredPath = config?.run_loop?.continuous?.idle_expansion?.pm_prompt_path
162
+ ?? config?.continuous?.idle_expansion?.pm_prompt_path;
163
+ const promptPath = typeof configuredPath === 'string' && configuredPath.trim().length > 0
164
+ ? configuredPath.trim()
165
+ : '.agentxchain/prompts/pm-idle-expansion.md';
166
+
167
+ const absPromptPath = join(root, promptPath);
168
+ if (!existsSync(absPromptPath)) {
169
+ return { promptPath, content: '' };
170
+ }
171
+
172
+ try {
173
+ return {
174
+ promptPath,
175
+ content: readFileSync(absPromptPath, 'utf8').trim(),
176
+ };
177
+ } catch (err) {
178
+ return {
179
+ promptPath,
180
+ content: '',
181
+ warning: `Failed to load PM idle-expansion prompt "${promptPath}": ${err.message}`,
182
+ };
183
+ }
184
+ }
185
+
160
186
  function ingestAcceptedIdleExpansionsFromExecution(context, session, execution, log = console.log) {
161
187
  const entries = getAcceptedIdleExpansionEntries(execution);
162
188
  if (entries.length === 0) {
@@ -750,6 +776,19 @@ async function dispatchIdleExpansion(context, session, contOpts, absVisionPath,
750
776
  .map(e => ` - ${e.path} (${e.headings.length} headings, ${e.byte_count} bytes${e.warning ? `, warning: ${e.warning}` : ''})`)
751
777
  .join('\n');
752
778
  const visionHeadings = (session.vision_headings_snapshot || []).map(h => ` - ${h}`).join('\n');
779
+ const idleExpansionPrompt = readIdleExpansionPrompt(root, context.config);
780
+ const promptBlock = idleExpansionPrompt.content
781
+ ? [
782
+ ``,
783
+ `PM idle-expansion prompt from ${idleExpansionPrompt.promptPath}:`,
784
+ idleExpansionPrompt.content,
785
+ ]
786
+ : idleExpansionPrompt.warning
787
+ ? [
788
+ ``,
789
+ `PM idle-expansion prompt warning: ${idleExpansionPrompt.warning}`,
790
+ ]
791
+ : [];
753
792
 
754
793
  const charter = [
755
794
  `[idle-expansion #${currentIteration}] Inspect VISION.md, ROADMAP.md, SYSTEM_SPEC.md, and current project state.`,
@@ -786,6 +825,7 @@ async function dispatchIdleExpansion(context, session, contOpts, absVisionPath,
786
825
  ``,
787
826
  `Source manifest:`,
788
827
  sourceList || ' (no sources available)',
828
+ ...promptBlock,
789
829
  ].join('\n');
790
830
 
791
831
  // Use a placeholder accepted_turn_id for the signal — it will be the turn assigned by intake
@@ -1072,6 +1112,10 @@ export function resolveContinuousOptions(opts, config) {
1072
1112
  maxExpansions: configIdleExpansion.max_expansions ?? 5,
1073
1113
  role: configIdleExpansion.role ?? 'pm',
1074
1114
  malformedRetryLimit: configIdleExpansion.malformed_retry_limit ?? 1,
1115
+ pmPromptPath: typeof configIdleExpansion.pm_prompt_path === 'string'
1116
+ && configIdleExpansion.pm_prompt_path.trim().length > 0
1117
+ ? configIdleExpansion.pm_prompt_path.trim()
1118
+ : '.agentxchain/prompts/pm-idle-expansion.md',
1075
1119
  } : null;
1076
1120
 
1077
1121
  return {
@@ -261,6 +261,14 @@ function renderPrompt(role, roleId, turn, state, config, root) {
261
261
  lines.push('- Your artifact type should be `workspace` or `commit`.');
262
262
  lines.push('- You must accurately declare all files you changed.');
263
263
  lines.push('');
264
+ if (phase === 'implementation') {
265
+ lines.push('### Implementation Phase: Code Production Required');
266
+ lines.push('');
267
+ lines.push('- Your PRIMARY deliverable in this phase is **working source code** — source files, test files, configurations, or executable scripts that change the project\'s runtime behavior.');
268
+ lines.push('- Planning documents (`.planning/*.md`, `IMPLEMENTATION_NOTES.md`) are supplementary evidence of what you built, not your primary output.');
269
+ lines.push('- A completed turn in the implementation phase MUST include actual product code changes in `files_changed`, not only documentation or planning artifacts.');
270
+ lines.push('');
271
+ }
264
272
  } else if (role.write_authority === 'proposed') {
265
273
  lines.push('### Write Authority: proposed');
266
274
  lines.push('');
package/src/lib/export.js CHANGED
@@ -107,15 +107,64 @@ function parseJsonl(relPath, raw) {
107
107
  });
108
108
  }
109
109
 
110
- function parseFile(root, relPath) {
110
+ function parseJsonlBuffer(relPath, buffer, maxEntries) {
111
+ const shouldWindow = Number.isInteger(maxEntries) && maxEntries > 0;
112
+ const retained = [];
113
+ let totalEntries = 0;
114
+ let physicalLine = 1;
115
+ let lineStart = 0;
116
+
117
+ const retainLine = (line, lineNumber) => {
118
+ if (!line.trim()) {
119
+ return;
120
+ }
121
+ totalEntries += 1;
122
+ if (shouldWindow && retained.length >= maxEntries) {
123
+ retained.shift();
124
+ }
125
+ retained.push({ line, lineNumber });
126
+ };
127
+
128
+ for (let i = 0; i <= buffer.length; i += 1) {
129
+ if (i !== buffer.length && buffer[i] !== 10) {
130
+ continue;
131
+ }
132
+
133
+ let lineEnd = i;
134
+ if (lineEnd > lineStart && buffer[lineEnd - 1] === 13) {
135
+ lineEnd -= 1;
136
+ }
137
+ retainLine(buffer.toString('utf8', lineStart, lineEnd), physicalLine);
138
+ lineStart = i + 1;
139
+ physicalLine += 1;
140
+ }
141
+
142
+ const entries = retained.map(({ line, lineNumber }) => {
143
+ try {
144
+ return JSON.parse(line);
145
+ } catch (error) {
146
+ throw new Error(`${relPath}: invalid JSONL at line ${lineNumber}: ${error.message}`);
147
+ }
148
+ });
149
+
150
+ return {
151
+ entries,
152
+ totalEntries,
153
+ truncated: shouldWindow && totalEntries > maxEntries,
154
+ };
155
+ }
156
+
157
+ function parseFile(root, relPath, opts = {}) {
111
158
  const absPath = join(root, relPath);
112
159
  const buffer = readFileSync(absPath);
113
- const raw = buffer.toString('utf8');
114
160
 
115
161
  let format = 'text';
116
- let data = raw;
162
+ let data = null;
163
+ let truncated = false;
164
+ let totalEntries = null;
117
165
 
118
166
  if (relPath.endsWith('.json')) {
167
+ const raw = buffer.toString('utf8');
119
168
  try {
120
169
  data = JSON.parse(raw);
121
170
  format = 'json';
@@ -123,17 +172,37 @@ function parseFile(root, relPath) {
123
172
  throw new Error(`${relPath}: invalid JSON: ${error.message}`);
124
173
  }
125
174
  } else if (relPath.endsWith('.jsonl')) {
126
- data = parseJsonl(relPath, raw);
175
+ const maxEntries = opts.maxJsonlEntries;
176
+ const parsed = Number.isInteger(maxEntries) && maxEntries > 0
177
+ ? parseJsonlBuffer(relPath, buffer, maxEntries)
178
+ : { entries: parseJsonl(relPath, buffer.toString('utf8')), totalEntries: null, truncated: false };
127
179
  format = 'jsonl';
180
+ data = parsed.entries;
181
+ truncated = parsed.truncated;
182
+ totalEntries = parsed.totalEntries;
183
+ } else {
184
+ data = buffer.toString('utf8');
128
185
  }
129
186
 
130
- return {
187
+ const skipBase64 = truncated || (opts.maxBase64Bytes && buffer.byteLength > opts.maxBase64Bytes);
188
+ const result = {
131
189
  format,
132
190
  bytes: buffer.byteLength,
133
191
  sha256: sha256(buffer),
134
- content_base64: buffer.toString('base64'),
192
+ content_base64: skipBase64 ? null : buffer.toString('base64'),
135
193
  data,
136
194
  };
195
+
196
+ if (truncated) {
197
+ result.truncated = true;
198
+ result.total_entries = totalEntries;
199
+ result.retained_entries = data.length;
200
+ }
201
+ if (skipBase64 && !truncated) {
202
+ result.content_base64_skipped = true;
203
+ }
204
+
205
+ return result;
137
206
  }
138
207
 
139
208
  function countJsonl(files, relPath) {
@@ -366,7 +435,7 @@ export function buildRunWorkspaceMetadata(root) {
366
435
  };
367
436
  }
368
437
 
369
- export function buildRunExport(startDir = process.cwd()) {
438
+ export function buildRunExport(startDir = process.cwd(), exportOpts = {}) {
370
439
  const context = loadProjectContext(startDir);
371
440
  if (!context) {
372
441
  return {
@@ -388,9 +457,17 @@ export function buildRunExport(startDir = process.cwd()) {
388
457
  const collectedPaths = [...new Set(RUN_EXPORT_INCLUDED_ROOTS.flatMap((relPath) => collectPaths(root, relPath)))]
389
458
  .sort((a, b) => a.localeCompare(b, 'en'));
390
459
 
460
+ const parseOpts = {};
461
+ if (exportOpts.maxJsonlEntries) {
462
+ parseOpts.maxJsonlEntries = exportOpts.maxJsonlEntries;
463
+ }
464
+ if (exportOpts.maxBase64Bytes) {
465
+ parseOpts.maxBase64Bytes = exportOpts.maxBase64Bytes;
466
+ }
467
+
391
468
  const files = {};
392
469
  for (const relPath of collectedPaths) {
393
- files[relPath] = parseFile(root, relPath);
470
+ files[relPath] = parseFile(root, relPath, parseOpts);
394
471
  }
395
472
 
396
473
  const activeTurns = Object.keys(state?.active_turns || {}).sort((a, b) => a.localeCompare(b, 'en'));
@@ -1385,6 +1385,17 @@ function classifyAcceptanceOverlap(targetTurn, conflictFiles, historyEntries, co
1385
1385
  const forwardRevisionTurns = new Map();
1386
1386
 
1387
1387
  for (const entry of historyEntries) {
1388
+ // BUG-66: skip reissued turns — they are superseded, not competing.
1389
+ // A reissued turn's archived observation must not trigger overlap against
1390
+ // its replacement turn (or any subsequent turn).
1391
+ if (entry.status === 'reissued') {
1392
+ continue;
1393
+ }
1394
+ // Also skip if this turn is the direct replacement for the history entry
1395
+ if (targetTurn?.reissued_from && targetTurn.reissued_from === entry.turn_id) {
1396
+ continue;
1397
+ }
1398
+
1388
1399
  if (targetTurn?.run_id && entry.run_id && entry.run_id !== targetTurn.run_id) {
1389
1400
  continue;
1390
1401
  }
@@ -6818,4 +6829,5 @@ export {
6818
6829
  LEDGER_PATH,
6819
6830
  STAGING_PATH,
6820
6831
  TALK_PATH,
6832
+ classifyAcceptanceOverlap as _classifyAcceptanceOverlap,
6821
6833
  };
@@ -0,0 +1,305 @@
1
+ /**
2
+ * AgentXchain MCP Server — exposes governance operations as MCP tools.
3
+ *
4
+ * This module creates an MCP server that lets any MCP-compatible client
5
+ * (Claude Code, Cursor, Windsurf, VS Code extensions, etc.) natively
6
+ * query run status, approve gates, read events/history, and record
7
+ * intake events against a governed AgentXchain project.
8
+ *
9
+ * The existing mcp-adapter.js is the inverse: AgentXchain as an MCP client
10
+ * dispatching turns TO an MCP server. This module makes AgentXchain itself
11
+ * the server.
12
+ *
13
+ * Spec: .planning/SPEC-MCP-SERVER.md
14
+ */
15
+
16
+ import { existsSync, readFileSync } from 'fs';
17
+ import { join } from 'path';
18
+ import * as z from 'zod/v4';
19
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20
+ import { findProjectRoot, loadProjectContext, loadProjectState } from './config.js';
21
+ import { readRunEvents } from './run-events.js';
22
+ import { queryRunHistory } from './run-history.js';
23
+ import { getActiveTurns } from './governed-state.js';
24
+ import { getOpenHumanEscalation, findCurrentHumanEscalation } from './human-escalations.js';
25
+ import { readContinuousSession } from './continuous-run.js';
26
+
27
+ const SERVER_NAME = 'agentxchain';
28
+ const SERVER_VERSION = '1.0.0';
29
+
30
+ /**
31
+ * Create and configure the AgentXchain MCP server.
32
+ *
33
+ * @param {string} root - Absolute path to the project root.
34
+ * @returns {McpServer} Configured MCP server (not yet connected to a transport).
35
+ */
36
+ export function createAgentXchainMcpServer(root) {
37
+ const server = new McpServer(
38
+ { name: SERVER_NAME, version: SERVER_VERSION },
39
+ {
40
+ capabilities: {
41
+ tools: {},
42
+ resources: {},
43
+ },
44
+ instructions:
45
+ 'AgentXchain governance server. Use tools to query governed run status, ' +
46
+ 'read events and history, approve human gates, and record intake events.',
47
+ },
48
+ );
49
+
50
+ registerTools(server, root);
51
+ registerResources(server, root);
52
+
53
+ return server;
54
+ }
55
+
56
+ // ── Tools ──────────────────────────────────────────────────────────────────
57
+
58
+ function registerTools(server, root) {
59
+ server.tool(
60
+ 'agentxchain_status',
61
+ 'Read current governed project and run status',
62
+ {},
63
+ () => handleStatus(root),
64
+ );
65
+
66
+ server.tool(
67
+ 'agentxchain_events',
68
+ 'Read recent events from the governed project event log',
69
+ { limit: z.number().optional().describe('Max events to return (default 50)') },
70
+ (args) => handleEvents(root, args),
71
+ );
72
+
73
+ server.tool(
74
+ 'agentxchain_history',
75
+ 'Read run history entries',
76
+ { limit: z.number().optional().describe('Max history entries to return (default 20)') },
77
+ (args) => handleHistory(root, args),
78
+ );
79
+
80
+ server.tool(
81
+ 'agentxchain_approve_gate',
82
+ 'Approve a pending human gate/escalation to unblock a governed run',
83
+ {
84
+ gate_id: z.string().describe('The escalation ID to approve (e.g. hesc_abc123)'),
85
+ reason: z.string().optional().describe('Optional reason for approval'),
86
+ },
87
+ (args) => handleApproveGate(root, args),
88
+ );
89
+
90
+ server.tool(
91
+ 'agentxchain_intake_record',
92
+ 'Record a new intake event for the governed project',
93
+ {
94
+ source: z.string().describe('Event source (e.g. manual, ci_failure, schedule)'),
95
+ title: z.string().describe('Short title for the intake event'),
96
+ description: z.string().optional().describe('Detailed description'),
97
+ priority: z.string().optional().describe('Priority level (e.g. high, medium, low)'),
98
+ },
99
+ (args) => handleIntakeRecord(root, args),
100
+ );
101
+ }
102
+
103
+ // ── Resources ──────────────────────────────────────────────────────────────
104
+
105
+ function registerResources(server, root) {
106
+ server.resource(
107
+ 'governed-state',
108
+ 'agentxchain://state',
109
+ { description: 'Current .agentxchain/state.json contents', mimeType: 'application/json' },
110
+ () => handleReadState(root),
111
+ );
112
+
113
+ server.resource(
114
+ 'continuous-session',
115
+ 'agentxchain://session',
116
+ { description: 'Current .agentxchain/continuous-session.json contents', mimeType: 'application/json' },
117
+ () => handleReadSession(root),
118
+ );
119
+ }
120
+
121
+ // ── Tool handlers ──────────────────────────────────────────────────────────
122
+
123
+ function handleStatus(root) {
124
+ const context = loadProjectContext(root);
125
+ if (!context) {
126
+ return toolResult({ ok: false, error: `No governed project found at ${root}` });
127
+ }
128
+
129
+ const { config } = context;
130
+ const state = loadProjectState(root, config);
131
+
132
+ const result = {
133
+ ok: true,
134
+ project: config.project || null,
135
+ protocol_mode: config.protocol_mode || null,
136
+ run_id: state?.run_id || null,
137
+ phase: state?.phase || null,
138
+ status: state?.status || null,
139
+ active_turns: state ? getActiveTurnsSummary(state) : [],
140
+ pending_gates: state?.blocked_reason?.type || null,
141
+ blocked: state?.status === 'blocked',
142
+ blocked_on: state?.blocked_on || null,
143
+ };
144
+
145
+ const session = readContinuousSession(root);
146
+ if (session) {
147
+ result.continuous = {
148
+ session_id: session.session_id || null,
149
+ status: session.status || null,
150
+ runs_completed: session.runs_completed || 0,
151
+ idle_cycles: session.idle_cycles || 0,
152
+ };
153
+ }
154
+
155
+ return toolResult(result);
156
+ }
157
+
158
+ function handleEvents(root, args) {
159
+ const context = loadProjectContext(root);
160
+ if (!context) {
161
+ return toolResult({ ok: false, error: `No governed project found at ${root}` });
162
+ }
163
+
164
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : 50;
165
+ const events = readRunEvents(root, { limit });
166
+
167
+ return toolResult({ ok: true, count: events.length, events });
168
+ }
169
+
170
+ function handleHistory(root, args) {
171
+ const context = loadProjectContext(root);
172
+ if (!context) {
173
+ return toolResult({ ok: false, error: `No governed project found at ${root}` });
174
+ }
175
+
176
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : 20;
177
+ const entries = queryRunHistory(root, { limit });
178
+
179
+ return toolResult({ ok: true, count: entries.length, entries });
180
+ }
181
+
182
+ function handleApproveGate(root, args) {
183
+ const context = loadProjectContext(root);
184
+ if (!context) {
185
+ return toolResult({ ok: false, error: `No governed project found at ${root}` });
186
+ }
187
+
188
+ const { config } = context;
189
+ const state = loadProjectState(root, config);
190
+ if (!state) {
191
+ return toolResult({ ok: false, error: 'No governed state found.' });
192
+ }
193
+
194
+ const gateId = typeof args.gate_id === 'string' ? args.gate_id.trim() : '';
195
+ if (!gateId) {
196
+ return toolResult({ ok: false, error: 'gate_id is required.' });
197
+ }
198
+
199
+ if (state.status !== 'blocked') {
200
+ return toolResult({
201
+ ok: false,
202
+ error: `Run is not blocked (status: "${state.status}"). Nothing to approve.`,
203
+ });
204
+ }
205
+
206
+ const escalation = getOpenHumanEscalation(root, gateId);
207
+ if (!escalation) {
208
+ return toolResult({ ok: false, error: `No open escalation found for ${gateId}.` });
209
+ }
210
+
211
+ const current = findCurrentHumanEscalation(root, state);
212
+ if (!current || current.escalation_id !== escalation.escalation_id) {
213
+ return toolResult({
214
+ ok: false,
215
+ error: `${gateId} is not the current blocker for this run.`,
216
+ });
217
+ }
218
+
219
+ // We return the gate info rather than performing the unblock directly,
220
+ // because unblock has side-effects (resume) that should be explicit.
221
+ // The caller should use the CLI `agentxchain unblock <id>` for the actual mutation.
222
+ return toolResult({
223
+ ok: true,
224
+ gate_id: gateId,
225
+ type: escalation.type,
226
+ detail: escalation.detail || null,
227
+ instruction: `Gate ${gateId} is the current blocker. Run "agentxchain unblock ${gateId}" to approve and resume.`,
228
+ });
229
+ }
230
+
231
+ function handleIntakeRecord(root, args) {
232
+ const context = loadProjectContext(root);
233
+ if (!context) {
234
+ return toolResult({ ok: false, error: `No governed project found at ${root}` });
235
+ }
236
+
237
+ const source = typeof args.source === 'string' ? args.source.trim() : '';
238
+ const title = typeof args.title === 'string' ? args.title.trim() : '';
239
+
240
+ if (!source || !title) {
241
+ return toolResult({ ok: false, error: 'Both source and title are required.' });
242
+ }
243
+
244
+ // Similar to approve_gate: we expose the information needed to record,
245
+ // but defer the actual mutation to the intake pipeline.
246
+ // Direct mutation via MCP would bypass the CLI's validation and event emission.
247
+ return toolResult({
248
+ ok: true,
249
+ instruction: `Use "agentxchain intake record --source ${source} --title '${title}'${args.description ? ` --description '${args.description}'` : ''}${args.priority ? ` --priority ${args.priority}` : ''}" to record this event.`,
250
+ source,
251
+ title,
252
+ description: args.description || null,
253
+ priority: args.priority || null,
254
+ });
255
+ }
256
+
257
+ // ── Resource handlers ──────────────────────────────────────────────────────
258
+
259
+ function handleReadState(root) {
260
+ const statePath = join(root, '.agentxchain', 'state.json');
261
+ if (!existsSync(statePath)) {
262
+ return { contents: [{ uri: 'agentxchain://state', text: '{}', mimeType: 'application/json' }] };
263
+ }
264
+ try {
265
+ const text = readFileSync(statePath, 'utf8');
266
+ return { contents: [{ uri: 'agentxchain://state', text, mimeType: 'application/json' }] };
267
+ } catch {
268
+ return { contents: [{ uri: 'agentxchain://state', text: '{}', mimeType: 'application/json' }] };
269
+ }
270
+ }
271
+
272
+ function handleReadSession(root) {
273
+ const sessionPath = join(root, '.agentxchain', 'continuous-session.json');
274
+ if (!existsSync(sessionPath)) {
275
+ return { contents: [{ uri: 'agentxchain://session', text: '{}', mimeType: 'application/json' }] };
276
+ }
277
+ try {
278
+ const text = readFileSync(sessionPath, 'utf8');
279
+ return { contents: [{ uri: 'agentxchain://session', text, mimeType: 'application/json' }] };
280
+ } catch {
281
+ return { contents: [{ uri: 'agentxchain://session', text: '{}', mimeType: 'application/json' }] };
282
+ }
283
+ }
284
+
285
+ // ── Helpers ────────────────────────────────────────────────────────────────
286
+
287
+ function getActiveTurnsSummary(state) {
288
+ try {
289
+ const turns = getActiveTurns(state);
290
+ return turns.map((t) => ({
291
+ turn_id: t.turn_id,
292
+ role: t.assigned_role,
293
+ status: t.status,
294
+ phase: t.phase,
295
+ }));
296
+ } catch {
297
+ return [];
298
+ }
299
+ }
300
+
301
+ function toolResult(data) {
302
+ return {
303
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
304
+ };
305
+ }
@@ -694,6 +694,11 @@ function validateRunLoopContinuousConfig(path, continuous, errors) {
694
694
  errors.push(`${path}.idle_expansion.malformed_retry_limit must be a non-negative integer`);
695
695
  }
696
696
  }
697
+ if (continuous.idle_expansion.pm_prompt_path !== undefined
698
+ && continuous.idle_expansion.pm_prompt_path !== null
699
+ && typeof continuous.idle_expansion.pm_prompt_path !== 'string') {
700
+ errors.push(`${path}.idle_expansion.pm_prompt_path must be a string`);
701
+ }
697
702
  }
698
703
  }
699
704
  }
@@ -1365,9 +1370,10 @@ function normalizeIdleExpansion(raw) {
1365
1370
  sources: ['.planning/VISION.md', '.planning/ROADMAP.md', '.planning/SYSTEM_SPEC.md'],
1366
1371
  max_expansions: 5,
1367
1372
  role: 'pm',
1368
- output: 'intake_intent_or_vision_exhausted',
1369
- malformed_retry_limit: 1,
1370
- };
1373
+ output: 'intake_intent_or_vision_exhausted',
1374
+ malformed_retry_limit: 1,
1375
+ pm_prompt_path: '.agentxchain/prompts/pm-idle-expansion.md',
1376
+ };
1371
1377
  }
1372
1378
  const sources = Array.isArray(raw.sources) && raw.sources.length > 0
1373
1379
  ? raw.sources.filter(s => typeof s === 'string' && s.length > 0)
@@ -1379,6 +1385,9 @@ function normalizeIdleExpansion(raw) {
1379
1385
  output: 'intake_intent_or_vision_exhausted',
1380
1386
  malformed_retry_limit: Number.isInteger(raw.malformed_retry_limit) && raw.malformed_retry_limit >= 0
1381
1387
  ? raw.malformed_retry_limit : 1,
1388
+ pm_prompt_path: typeof raw.pm_prompt_path === 'string' && raw.pm_prompt_path.trim().length > 0
1389
+ ? raw.pm_prompt_path.trim()
1390
+ : '.agentxchain/prompts/pm-idle-expansion.md',
1382
1391
  };
1383
1392
  }
1384
1393
 
@@ -69,6 +69,12 @@ export const BASELINE_EXEMPT_PATH_PREFIXES = Object.freeze([
69
69
  '.agentxchain/proposed/',
70
70
  ]);
71
71
 
72
+ const GENERATED_REPORT_PATH_PATTERNS = Object.freeze([
73
+ /^\.agentxchain\/reports\/report-[^/]+\.md$/,
74
+ /^\.agentxchain\/reports\/export-[^/]+\.json$/,
75
+ /^\.agentxchain\/reports\/chain-[^/]+\.json$/,
76
+ ]);
77
+
72
78
  // Continuity export/restore must stay aligned with orchestrator ownership,
73
79
  // but only for the subset that represents governed run state.
74
80
  export const RUN_CONTINUITY_STATE_FILES = Object.freeze([
@@ -93,6 +99,10 @@ function pathMatchesAnyRoot(filePath, roots) {
93
99
  return roots.some((root) => filePath === root || filePath.startsWith(`${root}/`));
94
100
  }
95
101
 
102
+ function isGeneratedReportPath(filePath) {
103
+ return GENERATED_REPORT_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
104
+ }
105
+
96
106
  /**
97
107
  * Return the repo-owned vs framework-owned classification flags for a path.
98
108
  *
@@ -106,7 +116,8 @@ function pathMatchesAnyRoot(filePath, roots) {
106
116
  */
107
117
  export function classifyRepoPath(filePath) {
108
118
  const operational = pathMatchesAnyPrefix(filePath, OPERATIONAL_PATH_PREFIXES)
109
- || ORCHESTRATOR_STATE_FILES.includes(filePath);
119
+ || ORCHESTRATOR_STATE_FILES.includes(filePath)
120
+ || isGeneratedReportPath(filePath);
110
121
  const continuityState = RUN_CONTINUITY_STATE_FILES.includes(filePath)
111
122
  || pathMatchesAnyRoot(filePath, RUN_CONTINUITY_DIRECTORY_ROOTS);
112
123
  const baselineExempt = operational
@@ -136,6 +136,10 @@
136
136
  "type": "integer",
137
137
  "minimum": 0,
138
138
  "description": "Reserved retry budget for malformed idle_expansion_result outputs. Default 1."
139
+ },
140
+ "pm_prompt_path": {
141
+ "type": "string",
142
+ "description": "Project-relative prompt supplement loaded into PM idle-expansion charters. Default .agentxchain/prompts/pm-idle-expansion.md."
139
143
  }
140
144
  },
141
145
  "additionalProperties": true
@@ -479,7 +479,15 @@ function buildIdleExpansionValidationContext(state, opts, activeTurn) {
479
479
  }
480
480
 
481
481
  function maybeAttachIdleExpansionSidecar(root, stagingRel, turnResult, context) {
482
- if (context.required !== true || turnResult?.idle_expansion_result !== undefined) {
482
+ if (turnResult?.idle_expansion_result !== undefined) {
483
+ // Normalize embedded idle_expansion_result in-place (same transforms as sidecar path)
484
+ const normalizedResult = normalizeIdleExpansionSidecar(turnResult.idle_expansion_result, context);
485
+ return {
486
+ turnResult: { ...turnResult, idle_expansion_result: normalizedResult },
487
+ warnings: [],
488
+ };
489
+ }
490
+ if (context.required !== true) {
483
491
  return { turnResult, warnings: [] };
484
492
  }
485
493
 
@@ -525,7 +533,24 @@ function normalizeIdleExpansionSidecar(sidecar, context) {
525
533
  : context.expansionIteration,
526
534
  };
527
535
 
528
- const intent = sidecar.new_intake_intent || sidecar.proposed_intent;
536
+ let intent = sidecar.new_intake_intent || sidecar.proposed_intent;
537
+ // Normalize flat new_intake_intent fields: if the PM put title/charter at the
538
+ // top level instead of nesting under new_intake_intent, extract them.
539
+ if (sidecar.kind === 'new_intake_intent' && !intent && sidecar.title && sidecar.charter) {
540
+ intent = {
541
+ title: sidecar.title,
542
+ charter: sidecar.charter,
543
+ acceptance_contract: sidecar.acceptance_contract,
544
+ priority: sidecar.priority,
545
+ template: sidecar.template,
546
+ };
547
+ // Remove flat fields from result to avoid schema pollution
548
+ delete result.title;
549
+ delete result.charter;
550
+ delete result.acceptance_contract;
551
+ delete result.priority;
552
+ delete result.template;
553
+ }
529
554
  if (sidecar.kind === 'new_intake_intent' && intent && typeof intent === 'object' && !Array.isArray(intent)) {
530
555
  result.new_intake_intent = {
531
556
  title: intent.title,
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dev": {
55
55
  "title": "Developer",
56
- "mandate": "Implement approved work safely and verify behavior.",
56
+ "mandate": "Write source code that implements approved work, then verify it passes tests.",
57
57
  "write_authority": "authoritative",
58
58
  "runtime": "local-dev"
59
59
  },
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dev": {
26
26
  "title": "Developer",
27
- "mandate": "Implement approved work safely and verify behavior.",
27
+ "mandate": "Write source code that implements approved work, then verify it passes tests.",
28
28
  "write_authority": "authoritative",
29
29
  "runtime": "local-dev"
30
30
  },
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dev": {
16
16
  "title": "Developer",
17
- "mandate": "Implement approved work safely and verify behavior.",
17
+ "mandate": "Write source code that implements approved work, then verify it passes tests.",
18
18
  "write_authority": "authoritative",
19
19
  "runtime": "manual-dev"
20
20
  },