agentxchain 2.31.0 → 2.33.1

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.
@@ -75,6 +75,7 @@ import { approveTransitionCommand } from '../src/commands/approve-transition.js'
75
75
  import { approveCompletionCommand } from '../src/commands/approve-completion.js';
76
76
  import { dashboardCommand } from '../src/commands/dashboard.js';
77
77
  import { exportCommand } from '../src/commands/export.js';
78
+ import { restoreCommand } from '../src/commands/restore.js';
78
79
  import { reportCommand } from '../src/commands/report.js';
79
80
  import {
80
81
  pluginInstallCommand,
@@ -139,6 +140,12 @@ program
139
140
  .option('--output <path>', 'Write the export artifact to a file instead of stdout')
140
141
  .action(exportCommand);
141
142
 
143
+ program
144
+ .command('restore')
145
+ .description('Restore governed continuity roots from a run export artifact')
146
+ .requiredOption('--input <path>', 'Path to a prior run export artifact')
147
+ .action(restoreCommand);
148
+
142
149
  program
143
150
  .command('report')
144
151
  .description('Render a human-readable governance summary from an export artifact')
package/dashboard/app.js CHANGED
@@ -13,6 +13,7 @@ import { render as renderGate } from './components/gate.js';
13
13
  import { render as renderInitiative } from './components/initiative.js';
14
14
  import { render as renderCrossRepo } from './components/cross-repo.js';
15
15
  import { render as renderBlockers } from './components/blockers.js';
16
+ import { render as renderArtifacts } from './components/artifacts.js';
16
17
 
17
18
  const VIEWS = {
18
19
  timeline: { fetch: ['state', 'history', 'audit', 'annotations'], render: renderTimeline },
@@ -23,6 +24,7 @@ const VIEWS = {
23
24
  initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger', 'coordinatorBlockers'], render: renderInitiative },
24
25
  'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
25
26
  blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
27
+ artifacts: { fetch: ['workflowKitArtifacts'], render: renderArtifacts },
26
28
  };
27
29
 
28
30
  const API_MAP = {
@@ -37,6 +39,7 @@ const API_MAP = {
37
39
  barrierLedger: '/api/coordinator/barrier-ledger',
38
40
  coordinatorAudit: '/api/coordinator/hooks/audit',
39
41
  coordinatorBlockers: '/api/coordinator/blockers',
42
+ workflowKitArtifacts: '/api/workflow-kit-artifacts',
40
43
  };
41
44
 
42
45
  const viewState = {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Workflow-Kit Artifacts view — renders live workflow-kit artifact status.
3
+ *
4
+ * Pure render function: takes data from /api/workflow-kit-artifacts, returns HTML.
5
+ * Ownership resolution and file-existence checks are server-side. This view
6
+ * renders the snapshot without reimplementing any logic.
7
+ *
8
+ * See: WORKFLOW_KIT_DASHBOARD_SPEC.md
9
+ */
10
+
11
+ function esc(str) {
12
+ if (!str) return '';
13
+ return String(str)
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&#39;');
19
+ }
20
+
21
+ function badge(label, color = 'var(--text-dim)') {
22
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
23
+ }
24
+
25
+ function statusIndicator(exists) {
26
+ if (exists) {
27
+ return `<span style="color:var(--green);font-weight:600" title="File exists">✓ exists</span>`;
28
+ }
29
+ return `<span style="color:var(--red);font-weight:600" title="File missing">✗ missing</span>`;
30
+ }
31
+
32
+ function renderArtifactRow(artifact) {
33
+ const isMissingRequired = !artifact.exists && artifact.required;
34
+ const rowStyle = isMissingRequired
35
+ ? ' style="border-left:3px solid var(--red)"'
36
+ : '';
37
+
38
+ return `<tr${rowStyle}>
39
+ <td class="mono">${esc(artifact.path)}</td>
40
+ <td>${artifact.required ? badge('required', 'var(--yellow)') : badge('optional', 'var(--text-dim)')}</td>
41
+ <td>${artifact.semantics ? `<span class="mono">${esc(artifact.semantics)}</span>` : '<span style="color:var(--text-dim)">—</span>'}</td>
42
+ <td>${artifact.owned_by ? esc(artifact.owned_by) : '<span style="color:var(--text-dim)">—</span>'}</td>
43
+ <td>${artifact.owner_resolution ? badge(artifact.owner_resolution, artifact.owner_resolution === 'explicit' ? 'var(--accent)' : 'var(--text-dim)') : '—'}</td>
44
+ <td>${statusIndicator(artifact.exists)}</td>
45
+ </tr>`;
46
+ }
47
+
48
+ export function render({ workflowKitArtifacts }) {
49
+ if (!workflowKitArtifacts) {
50
+ return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>No workflow artifact data available. Ensure a governed run is active.</p></div>`;
51
+ }
52
+
53
+ if (workflowKitArtifacts.ok === false) {
54
+ const hint = workflowKitArtifacts.code === 'config_missing' || workflowKitArtifacts.code === 'state_missing'
55
+ ? ' Run <code>agentxchain init --governed</code> to get started.'
56
+ : '';
57
+ return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>${esc(workflowKitArtifacts.error || 'Failed to load workflow artifact data.')}${hint}</p></div>`;
58
+ }
59
+
60
+ const data = workflowKitArtifacts;
61
+ const artifacts = data.artifacts;
62
+
63
+ // No workflow_kit configured
64
+ if (artifacts === null) {
65
+ return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>No <code>workflow_kit</code> configured in <code>agentxchain.json</code>. Add a <code>workflow_kit</code> section to track phase artifacts.</p></div>`;
66
+ }
67
+
68
+ // Phase has no artifacts
69
+ if (!Array.isArray(artifacts) || artifacts.length === 0) {
70
+ return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>Current phase <strong>${esc(data.phase || 'unknown')}</strong> has no workflow-kit artifacts defined.</p></div>`;
71
+ }
72
+
73
+ const missingRequired = artifacts.filter((a) => a.required && !a.exists).length;
74
+ const totalExists = artifacts.filter((a) => a.exists).length;
75
+
76
+ let html = `<div class="artifacts-view">`;
77
+
78
+ // Header
79
+ html += `<div class="run-header"><div class="run-meta">`;
80
+ if (data.phase) {
81
+ html += `<span class="phase-label">Phase: <strong>${esc(data.phase)}</strong></span>`;
82
+ }
83
+ html += `<span class="turn-count">${artifacts.length} artifact${artifacts.length !== 1 ? 's' : ''}</span>`;
84
+ html += `<span class="turn-count">${totalExists}/${artifacts.length} present</span>`;
85
+ if (missingRequired > 0) {
86
+ html += badge(`${missingRequired} missing required`, 'var(--red)');
87
+ }
88
+ html += `</div></div>`;
89
+
90
+ // Table
91
+ html += `<div class="section"><h3>Workflow-Kit Artifacts</h3>
92
+ <table class="data-table">
93
+ <thead>
94
+ <tr>
95
+ <th>Path</th>
96
+ <th>Required</th>
97
+ <th>Semantics</th>
98
+ <th>Owner</th>
99
+ <th>Resolution</th>
100
+ <th>Status</th>
101
+ </tr>
102
+ </thead>
103
+ <tbody>`;
104
+
105
+ for (const artifact of artifacts) {
106
+ html += renderArtifactRow(artifact);
107
+ }
108
+
109
+ html += `</tbody></table></div>`;
110
+
111
+ html += `</div>`;
112
+ return html;
113
+ }
@@ -382,6 +382,7 @@
382
382
  <a href="#blocked">Blocked</a>
383
383
  <a href="#gate">Gates</a>
384
384
  <a href="#blockers">Blockers</a>
385
+ <a href="#artifacts">Artifacts</a>
385
386
  </nav>
386
387
  <main id="view-container">
387
388
  <div class="placeholder">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.31.0",
3
+ "version": "2.33.1",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,44 @@
1
+ import chalk from 'chalk';
2
+
3
+ import { loadExportArtifact, verifyExportArtifact } from '../lib/export-verifier.js';
4
+ import { restoreRunExport } from '../lib/restore.js';
5
+
6
+ export async function restoreCommand(opts) {
7
+ const input = opts?.input;
8
+ if (!input) {
9
+ console.error('Restore requires --input <path>.');
10
+ process.exitCode = 1;
11
+ return;
12
+ }
13
+
14
+ const loaded = loadExportArtifact(input, process.cwd());
15
+ if (!loaded.ok) {
16
+ console.error(loaded.error);
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+
21
+ const verification = verifyExportArtifact(loaded.artifact);
22
+ if (!verification.ok) {
23
+ console.error('Restore input failed export verification:');
24
+ for (const error of verification.errors.slice(0, 20)) {
25
+ console.error(`- ${error}`);
26
+ }
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+
31
+ const result = restoreRunExport(process.cwd(), loaded.artifact);
32
+ if (!result.ok) {
33
+ console.error(result.error);
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+
38
+ console.log(chalk.green(`Restored governed continuity state from ${loaded.input}`));
39
+ console.log(` ${chalk.dim('Run:')} ${result.run_id || 'none'}`);
40
+ console.log(` ${chalk.dim('Status:')} ${result.status || 'unknown'}`);
41
+ console.log(` ${chalk.dim('Files:')} ${result.restored_files}`);
42
+ console.log(` ${chalk.dim('Next:')} agentxchain resume`);
43
+ }
44
+
@@ -18,6 +18,7 @@ import { readResource } from './state-reader.js';
18
18
  import { FileWatcher } from './file-watcher.js';
19
19
  import { approvePendingDashboardGate } from './actions.js';
20
20
  import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
21
+ import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
21
22
 
22
23
  const MIME_TYPES = {
23
24
  '.html': 'text/html; charset=utf-8',
@@ -279,6 +280,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
279
280
  return;
280
281
  }
281
282
 
283
+ if (pathname === '/api/workflow-kit-artifacts') {
284
+ const result = readWorkflowKitArtifacts(workspacePath);
285
+ writeJson(res, result.status, result.body);
286
+ return;
287
+ }
288
+
282
289
  // API routes
283
290
  if (pathname.startsWith('/api/')) {
284
291
  const result = readResource(agentxchainDir, pathname);
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Workflow-kit artifact status — computed dashboard endpoint.
3
+ *
4
+ * Reads agentxchain.json config and .agentxchain/state.json to derive
5
+ * current-phase workflow-kit artifact status for live dashboard observation.
6
+ *
7
+ * Per DEC-WK-REPORT-002: ownership resolution uses explicit owned_by first,
8
+ * then falls back to routing[phase].entry_role.
9
+ *
10
+ * See: WORKFLOW_KIT_DASHBOARD_SPEC.md
11
+ */
12
+
13
+ import { existsSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { loadConfig } from '../config.js';
16
+ import { readJsonFile } from './state-reader.js';
17
+
18
+ /**
19
+ * Read workflow-kit artifact status for the current phase.
20
+ *
21
+ * @param {string} workspacePath — project root (parent of .agentxchain/)
22
+ * @returns {{ ok: boolean, status: number, body: object }}
23
+ */
24
+ export function readWorkflowKitArtifacts(workspacePath) {
25
+ const configResult = loadConfig(workspacePath);
26
+ if (!configResult) {
27
+ return {
28
+ ok: false,
29
+ status: 404,
30
+ body: {
31
+ ok: false,
32
+ code: 'config_missing',
33
+ error: 'Project config not found. Run `agentxchain init --governed` first.',
34
+ },
35
+ };
36
+ }
37
+
38
+ const agentxchainDir = join(workspacePath, '.agentxchain');
39
+ const state = readJsonFile(agentxchainDir, 'state.json');
40
+ if (!state) {
41
+ return {
42
+ ok: false,
43
+ status: 404,
44
+ body: {
45
+ ok: false,
46
+ code: 'state_missing',
47
+ error: 'Run state not found. Run `agentxchain init --governed` first.',
48
+ },
49
+ };
50
+ }
51
+
52
+ const config = configResult.config;
53
+ const phase = state.phase || null;
54
+
55
+ if (!config.workflow_kit) {
56
+ return {
57
+ ok: true,
58
+ status: 200,
59
+ body: {
60
+ ok: true,
61
+ phase,
62
+ artifacts: null,
63
+ },
64
+ };
65
+ }
66
+
67
+ if (!phase) {
68
+ return {
69
+ ok: true,
70
+ status: 200,
71
+ body: {
72
+ ok: true,
73
+ phase: null,
74
+ artifacts: [],
75
+ },
76
+ };
77
+ }
78
+
79
+ const phaseConfig = config.workflow_kit.phases?.[phase];
80
+ if (!phaseConfig) {
81
+ return {
82
+ ok: true,
83
+ status: 200,
84
+ body: {
85
+ ok: true,
86
+ phase,
87
+ artifacts: [],
88
+ },
89
+ };
90
+ }
91
+
92
+ const artifacts = Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
93
+ if (artifacts.length === 0) {
94
+ return {
95
+ ok: true,
96
+ status: 200,
97
+ body: {
98
+ ok: true,
99
+ phase,
100
+ artifacts: [],
101
+ },
102
+ };
103
+ }
104
+
105
+ const entryRole = config.routing?.[phase]?.entry_role || null;
106
+
107
+ const result = artifacts
108
+ .filter((a) => a && typeof a.path === 'string')
109
+ .map((a) => {
110
+ const hasExplicitOwner = typeof a.owned_by === 'string' && a.owned_by.length > 0;
111
+ return {
112
+ path: a.path,
113
+ required: a.required !== false,
114
+ semantics: a.semantics || null,
115
+ owned_by: hasExplicitOwner ? a.owned_by : entryRole,
116
+ owner_resolution: hasExplicitOwner ? 'explicit' : 'entry_role',
117
+ exists: existsSync(join(workspacePath, a.path)),
118
+ };
119
+ })
120
+ .sort((a, b) => a.path.localeCompare(b.path, 'en'));
121
+
122
+ return {
123
+ ok: true,
124
+ status: 200,
125
+ body: {
126
+ ok: true,
127
+ phase,
128
+ artifacts: result,
129
+ },
130
+ };
131
+ }
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
 
6
- const SUPPORTED_EXPORT_SCHEMA_VERSION = '0.2';
6
+ const SUPPORTED_EXPORT_SCHEMA_VERSIONS = new Set(['0.2', '0.3']);
7
7
  const VALID_FILE_FORMATS = new Set(['json', 'jsonl', 'text']);
8
8
 
9
9
  function sha256(buffer) {
@@ -51,8 +51,8 @@ function verifyFileEntry(relPath, entry, errors) {
51
51
  addError(errors, path, 'sha256 must be a 64-character lowercase hex digest');
52
52
  }
53
53
 
54
- if (typeof entry.content_base64 !== 'string' || entry.content_base64.length === 0) {
55
- addError(errors, path, 'content_base64 must be a non-empty string');
54
+ if (typeof entry.content_base64 !== 'string') {
55
+ addError(errors, path, 'content_base64 must be a string');
56
56
  return;
57
57
  }
58
58
 
@@ -161,6 +161,29 @@ function verifyRunExport(artifact, errors) {
161
161
  addError(errors, 'state', 'must match files..agentxchain/state.json.data');
162
162
  }
163
163
 
164
+ if ('workspace' in artifact) {
165
+ if (!artifact.workspace || typeof artifact.workspace !== 'object' || Array.isArray(artifact.workspace)) {
166
+ addError(errors, 'workspace', 'must be an object');
167
+ } else {
168
+ const git = artifact.workspace.git;
169
+ if (!git || typeof git !== 'object' || Array.isArray(git)) {
170
+ addError(errors, 'workspace.git', 'must be an object');
171
+ } else {
172
+ if (typeof git.is_repo !== 'boolean') addError(errors, 'workspace.git.is_repo', 'must be a boolean');
173
+ if (git.head_sha !== null && (typeof git.head_sha !== 'string' || git.head_sha.length === 0)) {
174
+ addError(errors, 'workspace.git.head_sha', 'must be a string or null');
175
+ }
176
+ if (!Array.isArray(git.dirty_paths) || git.dirty_paths.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
177
+ addError(errors, 'workspace.git.dirty_paths', 'must be an array of non-empty strings');
178
+ }
179
+ if (typeof git.restore_supported !== 'boolean') addError(errors, 'workspace.git.restore_supported', 'must be a boolean');
180
+ if (!Array.isArray(git.restore_blockers) || git.restore_blockers.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
181
+ addError(errors, 'workspace.git.restore_blockers', 'must be an array of non-empty strings');
182
+ }
183
+ }
184
+ }
185
+ }
186
+
164
187
  const activeTurnIds = Object.keys(artifact.state.active_turns || {}).sort((a, b) => a.localeCompare(b, 'en'));
165
188
  const retainedTurnIds = Object.keys(artifact.state.retained_turns || {}).sort((a, b) => a.localeCompare(b, 'en'));
166
189
 
@@ -340,8 +363,8 @@ export function verifyExportArtifact(artifact) {
340
363
  };
341
364
  }
342
365
 
343
- if (artifact.schema_version !== SUPPORTED_EXPORT_SCHEMA_VERSION) {
344
- addError(errors, 'schema_version', `must be "${SUPPORTED_EXPORT_SCHEMA_VERSION}"`);
366
+ if (!SUPPORTED_EXPORT_SCHEMA_VERSIONS.has(artifact.schema_version)) {
367
+ addError(errors, 'schema_version', `must be one of ${[...SUPPORTED_EXPORT_SCHEMA_VERSIONS].map((v) => `"${v}"`).join(', ')}`);
345
368
  }
346
369
 
347
370
  if (typeof artifact.export_kind !== 'string') {
package/src/lib/export.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
4
  import { join, relative, resolve } from 'node:path';
@@ -6,7 +7,7 @@ import { loadProjectContext, loadProjectState } from './config.js';
6
7
  import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
7
8
  import { loadCoordinatorState } from './coordinator-state.js';
8
9
 
9
- const EXPORT_SCHEMA_VERSION = '0.2';
10
+ const EXPORT_SCHEMA_VERSION = '0.3';
10
11
 
11
12
  const COORDINATOR_INCLUDED_ROOTS = [
12
13
  'agentxchain-multi.json',
@@ -18,8 +19,9 @@ const COORDINATOR_INCLUDED_ROOTS = [
18
19
  '.agentxchain/multirepo/RECOVERY_REPORT.md',
19
20
  ];
20
21
 
21
- const INCLUDED_ROOTS = [
22
+ export const RUN_EXPORT_INCLUDED_ROOTS = [
22
23
  'agentxchain.json',
24
+ 'TALK.md',
23
25
  '.agentxchain/state.json',
24
26
  '.agentxchain/history.jsonl',
25
27
  '.agentxchain/decision-ledger.jsonl',
@@ -31,9 +33,40 @@ const INCLUDED_ROOTS = [
31
33
  '.agentxchain/transactions/accept',
32
34
  '.agentxchain/intake',
33
35
  '.agentxchain/multirepo',
36
+ '.agentxchain/reviews',
37
+ '.agentxchain/proposed',
38
+ '.agentxchain/reports',
34
39
  '.planning',
35
40
  ];
36
41
 
42
+ export const RUN_RESTORE_ROOTS = [
43
+ 'agentxchain.json',
44
+ 'TALK.md',
45
+ '.agentxchain/state.json',
46
+ '.agentxchain/history.jsonl',
47
+ '.agentxchain/decision-ledger.jsonl',
48
+ '.agentxchain/hook-audit.jsonl',
49
+ '.agentxchain/hook-annotations.jsonl',
50
+ '.agentxchain/notification-audit.jsonl',
51
+ '.agentxchain/dispatch',
52
+ '.agentxchain/staging',
53
+ '.agentxchain/transactions/accept',
54
+ '.agentxchain/intake',
55
+ '.agentxchain/multirepo',
56
+ '.agentxchain/reviews',
57
+ '.agentxchain/proposed',
58
+ '.agentxchain/reports',
59
+ '.planning',
60
+ ];
61
+
62
+ function pathWithinRoots(relPath, roots) {
63
+ return roots.some((root) => relPath === root || relPath.startsWith(`${root}/`));
64
+ }
65
+
66
+ export function isRunRestorePath(relPath) {
67
+ return pathWithinRoots(relPath, RUN_RESTORE_ROOTS);
68
+ }
69
+
37
70
  function sha256(buffer) {
38
71
  return createHash('sha256').update(buffer).digest('hex');
39
72
  }
@@ -122,6 +155,95 @@ function countDirectoryFiles(files, prefix) {
122
155
  return Object.keys(files).filter((path) => path.startsWith(`${prefix}/`)).length;
123
156
  }
124
157
 
158
+ function isGitRepo(root) {
159
+ try {
160
+ execSync('git rev-parse --is-inside-work-tree', {
161
+ cwd: root,
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ });
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function getGitHeadSha(root) {
172
+ try {
173
+ return execSync('git rev-parse HEAD', {
174
+ cwd: root,
175
+ encoding: 'utf8',
176
+ stdio: ['ignore', 'pipe', 'ignore'],
177
+ }).trim() || null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function getWorkingTreeChanges(root) {
184
+ try {
185
+ const tracked = execSync('git diff --name-only HEAD', {
186
+ cwd: root,
187
+ encoding: 'utf8',
188
+ stdio: ['ignore', 'pipe', 'ignore'],
189
+ }).trim();
190
+ const staged = execSync('git diff --name-only --cached', {
191
+ cwd: root,
192
+ encoding: 'utf8',
193
+ stdio: ['ignore', 'pipe', 'ignore'],
194
+ }).trim();
195
+ const untracked = execSync('git ls-files --others --exclude-standard', {
196
+ cwd: root,
197
+ encoding: 'utf8',
198
+ stdio: ['ignore', 'pipe', 'ignore'],
199
+ }).trim();
200
+
201
+ return [...new Set([tracked, staged, untracked]
202
+ .flatMap((chunk) => chunk.split('\n').filter(Boolean)))]
203
+ .sort((a, b) => a.localeCompare(b, 'en'));
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ export function buildRunWorkspaceMetadata(root) {
210
+ if (!isGitRepo(root)) {
211
+ return {
212
+ git: {
213
+ is_repo: false,
214
+ head_sha: null,
215
+ dirty_paths: [],
216
+ restore_supported: false,
217
+ restore_blockers: ['Export restore requires a git-backed checkout on the source machine.'],
218
+ },
219
+ };
220
+ }
221
+
222
+ const headSha = getGitHeadSha(root);
223
+ const dirtyPaths = getWorkingTreeChanges(root);
224
+ const restoreBlockers = [];
225
+
226
+ if (!headSha) {
227
+ restoreBlockers.push('Export restore requires a stable git HEAD in the source checkout.');
228
+ }
229
+
230
+ for (const dirtyPath of dirtyPaths) {
231
+ if (!isRunRestorePath(dirtyPath)) {
232
+ restoreBlockers.push(`Dirty path outside governed continuity roots: ${dirtyPath}`);
233
+ }
234
+ }
235
+
236
+ return {
237
+ git: {
238
+ is_repo: true,
239
+ head_sha: headSha,
240
+ dirty_paths: dirtyPaths,
241
+ restore_supported: restoreBlockers.length === 0,
242
+ restore_blockers: restoreBlockers,
243
+ },
244
+ };
245
+ }
246
+
125
247
  export function buildRunExport(startDir = process.cwd()) {
126
248
  const context = loadProjectContext(startDir);
127
249
  if (!context) {
@@ -141,7 +263,7 @@ export function buildRunExport(startDir = process.cwd()) {
141
263
  const { root, rawConfig, config, version } = context;
142
264
  const state = loadProjectState(root, config);
143
265
 
144
- const collectedPaths = [...new Set(INCLUDED_ROOTS.flatMap((relPath) => collectPaths(root, relPath)))]
266
+ const collectedPaths = [...new Set(RUN_EXPORT_INCLUDED_ROOTS.flatMap((relPath) => collectPaths(root, relPath)))]
145
267
  .sort((a, b) => a.localeCompare(b, 'en'));
146
268
 
147
269
  const files = {};
@@ -181,6 +303,7 @@ export function buildRunExport(startDir = process.cwd()) {
181
303
  intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
182
304
  coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
183
305
  },
306
+ workspace: buildRunWorkspaceMetadata(root),
184
307
  files,
185
308
  config: rawConfig,
186
309
  state,
@@ -0,0 +1,153 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { Buffer } from 'node:buffer';
3
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+
6
+ import { loadProjectContext } from './config.js';
7
+ import { RUN_RESTORE_ROOTS, isRunRestorePath } from './export.js';
8
+
9
+ function isGitRepo(root) {
10
+ try {
11
+ execSync('git rev-parse --is-inside-work-tree', {
12
+ cwd: root,
13
+ encoding: 'utf8',
14
+ stdio: ['ignore', 'pipe', 'ignore'],
15
+ });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function getHeadSha(root) {
23
+ try {
24
+ return execSync('git rev-parse HEAD', {
25
+ cwd: root,
26
+ encoding: 'utf8',
27
+ stdio: ['ignore', 'pipe', 'ignore'],
28
+ }).trim() || null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function getWorkingTreeChanges(root) {
35
+ try {
36
+ const tracked = execSync('git diff --name-only HEAD', {
37
+ cwd: root,
38
+ encoding: 'utf8',
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ }).trim();
41
+ const staged = execSync('git diff --name-only --cached', {
42
+ cwd: root,
43
+ encoding: 'utf8',
44
+ stdio: ['ignore', 'pipe', 'ignore'],
45
+ }).trim();
46
+ const untracked = execSync('git ls-files --others --exclude-standard', {
47
+ cwd: root,
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ }).trim();
51
+
52
+ return [...new Set([tracked, staged, untracked]
53
+ .flatMap((chunk) => chunk.split('\n').filter(Boolean)))];
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ function clearRestoreRoots(root) {
60
+ for (const relPath of RUN_RESTORE_ROOTS) {
61
+ rmSync(join(root, relPath), { recursive: true, force: true });
62
+ }
63
+ }
64
+
65
+ function writeRestoredFiles(root, files) {
66
+ const relPaths = Object.keys(files).sort((a, b) => a.localeCompare(b, 'en'));
67
+
68
+ for (const relPath of relPaths) {
69
+ if (!isRunRestorePath(relPath)) {
70
+ throw new Error(`Export contains non-restorable file "${relPath}"`);
71
+ }
72
+ const entry = files[relPath];
73
+ if (!entry || typeof entry.content_base64 !== 'string') {
74
+ throw new Error(`Export file "${relPath}" is missing content_base64`);
75
+ }
76
+ const absPath = join(root, relPath);
77
+ mkdirSync(dirname(absPath), { recursive: true });
78
+ writeFileSync(absPath, Buffer.from(entry.content_base64, 'base64'));
79
+ }
80
+ }
81
+
82
+ export function restoreRunExport(targetDir, artifact) {
83
+ if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
84
+ return { ok: false, error: 'Restore input must be a JSON export artifact.' };
85
+ }
86
+
87
+ if (artifact.export_kind !== 'agentxchain_run_export') {
88
+ return { ok: false, error: `Restore only supports run exports in this slice. Got "${artifact.export_kind || 'unknown'}".` };
89
+ }
90
+
91
+ const workspaceGit = artifact.workspace?.git;
92
+ if (!workspaceGit || typeof workspaceGit !== 'object') {
93
+ return { ok: false, error: 'Export is missing workspace.git metadata required for restore.' };
94
+ }
95
+
96
+ if (workspaceGit.restore_supported !== true) {
97
+ const blockers = Array.isArray(workspaceGit.restore_blockers) ? workspaceGit.restore_blockers : [];
98
+ return {
99
+ ok: false,
100
+ error: blockers.length > 0
101
+ ? `Export cannot be restored safely:\n- ${blockers.join('\n- ')}`
102
+ : 'Export cannot be restored safely because restore_supported is false.',
103
+ };
104
+ }
105
+
106
+ const context = loadProjectContext(targetDir);
107
+ if (!context || context.config?.protocol_mode !== 'governed') {
108
+ return { ok: false, error: 'Restore target must be a governed project rooted by agentxchain.json.' };
109
+ }
110
+
111
+ if (context.config?.project?.id !== artifact.project?.id) {
112
+ return {
113
+ ok: false,
114
+ error: `Project mismatch: export is "${artifact.project?.id || 'unknown'}" but target is "${context.config?.project?.id || 'unknown'}".`,
115
+ };
116
+ }
117
+
118
+ if (!isGitRepo(context.root)) {
119
+ return { ok: false, error: 'Restore target must be a git-backed checkout.' };
120
+ }
121
+
122
+ const targetHead = getHeadSha(context.root);
123
+ if (!targetHead || targetHead !== workspaceGit.head_sha) {
124
+ return {
125
+ ok: false,
126
+ error: `Target HEAD mismatch: export expects "${workspaceGit.head_sha || 'unknown'}" but target is "${targetHead || 'unknown'}".`,
127
+ };
128
+ }
129
+
130
+ const dirtyPaths = getWorkingTreeChanges(context.root);
131
+ if (dirtyPaths.length > 0) {
132
+ return {
133
+ ok: false,
134
+ error: `Restore target must be clean before applying continuity state. Dirty paths: ${dirtyPaths.slice(0, 10).join(', ')}${dirtyPaths.length > 10 ? '...' : ''}`,
135
+ };
136
+ }
137
+
138
+ const files = artifact.files;
139
+ if (!files || typeof files !== 'object' || Array.isArray(files) || Object.keys(files).length === 0) {
140
+ return { ok: false, error: 'Export is missing restorable files.' };
141
+ }
142
+
143
+ clearRestoreRoots(context.root);
144
+ writeRestoredFiles(context.root, files);
145
+
146
+ return {
147
+ ok: true,
148
+ root: resolve(context.root),
149
+ run_id: artifact.summary?.run_id || null,
150
+ status: artifact.summary?.status || null,
151
+ restored_files: Object.keys(files).length,
152
+ };
153
+ }