@inneranimalmedia/agentsam-sdk 2.0.0 → 2.2.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.
Files changed (61) hide show
  1. package/README.md +28 -15
  2. package/docs/CAPABILITIES.md +93 -0
  3. package/docs/DEPLOY_RECEIPTS.md +83 -0
  4. package/docs/RECON.md +165 -0
  5. package/docs/RELEASES.md +4 -3
  6. package/docs/portable-knowledge.md +4 -4
  7. package/docs/sdk-2.0-release.md +19 -21
  8. package/package.json +8 -1
  9. package/packages/identity/package.json +1 -1
  10. package/packages/identity/src/frontend/auth-portal/README.md +1 -1
  11. package/packages/identity/src/index.js +2 -0
  12. package/protocol/capabilities/capability-manifest.schema.json +36 -0
  13. package/protocol/capabilities/manifest.json +179 -0
  14. package/protocol/capabilities/repository-audit-input.schema.json +14 -0
  15. package/protocol/capabilities/repository-audit.schema.json +30 -0
  16. package/protocol/capabilities/repository-snapshot-input.schema.json +11 -0
  17. package/protocol/capabilities/repository-snapshot.schema.json +20 -0
  18. package/protocol/knowledge/chunk.schema.json +15 -73
  19. package/protocol/knowledge/document.schema.json +10 -48
  20. package/protocol/knowledge/index-config.schema.json +2 -2
  21. package/protocol/knowledge/repository.schema.json +11 -52
  22. package/protocol/knowledge/retrieval-query.schema.json +13 -63
  23. package/protocol/knowledge/source.schema.json +9 -43
  24. package/protocol/presets/catalog.json +48 -0
  25. package/protocol/recon/README.md +19 -0
  26. package/protocol/recon/finding-report.schema.json +46 -0
  27. package/protocol/recon/task-packet.schema.json +79 -0
  28. package/python/agentsam_sdk/knowledge/models.py +19 -7
  29. package/python/agentsam_sdk/repository/__main__.py +2 -2
  30. package/python/agentsam_sdk/repository/recon/__init__.py +28 -0
  31. package/python/agentsam_sdk/repository/recon/__main__.py +3 -0
  32. package/python/agentsam_sdk/repository/recon/cli.py +178 -0
  33. package/python/agentsam_sdk/repository/recon/packet.py +294 -0
  34. package/python/agentsam_sdk/repository/recon/validate.py +76 -0
  35. package/python/tests/test_knowledge_models.py +6 -2
  36. package/python/tests/test_recon.py +257 -0
  37. package/src/agent/capability-adapter.js +50 -0
  38. package/src/agent/index.js +2 -0
  39. package/src/agent/repository-audit.js +188 -0
  40. package/src/capabilities/index.js +7 -0
  41. package/src/capabilities/manifest.js +22 -0
  42. package/src/capabilities/repository-snapshot.js +180 -0
  43. package/src/cli.js +68 -8
  44. package/src/commands/deploy-receipt.js +129 -0
  45. package/src/commands/deploy.js +0 -1
  46. package/src/commands/knowledge.js +5 -6
  47. package/src/commands/product.js +119 -0
  48. package/src/commands/recon.js +71 -0
  49. package/src/index.js +17 -0
  50. package/src/knowledge/config.js +12 -6
  51. package/src/knowledge/contracts.js +1 -1
  52. package/src/knowledge/engine.js +1 -1
  53. package/src/knowledge/service/server.js +2 -2
  54. package/src/lib/deploy-receipt/index.js +246 -0
  55. package/src/lib/git-context.js +3 -1
  56. package/src/presets/index.js +20 -0
  57. package/src/repository/index.js +4 -0
  58. package/test/agent-capabilities.test.mjs +67 -0
  59. package/test/capabilities.test.mjs +84 -0
  60. package/test/deploy-receipt.test.mjs +91 -0
  61. package/test/portable-context.test.mjs +11 -1
@@ -0,0 +1,246 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { buildMerkleTree, diffTrees, readSnapshot, validateSnapshot } from '../merkle/index.js';
6
+ import { normalizePolicy } from '../merkle/policy.js';
7
+
8
+ export const DEFAULT_DEPLOY_EXCLUDES = Object.freeze([
9
+ '.agentsam/deploy-merkle',
10
+ '.wrangler',
11
+ 'coverage',
12
+ '.cache',
13
+ '.turbo',
14
+ '.env',
15
+ '.env.local',
16
+ '.env.cloudflare',
17
+ '.env.cloudflare.local',
18
+ '.env.production',
19
+ '.env.production.local',
20
+ '.env.development',
21
+ '.env.development.local',
22
+ '.env.test',
23
+ '.env.test.local',
24
+ '.env.staging',
25
+ '.env.preview',
26
+ '.env.docker',
27
+ '.dev.vars',
28
+ '.npmrc',
29
+ '.deploy-dashboard-source-fingerprint.json',
30
+ '.deploy-pipeline-stats.json',
31
+ '.deploy-r2-delta-stats.json',
32
+ '.deploy-r2-static-hashes.json',
33
+ '.deploy-route-stats.json',
34
+ '.deploy-sw-tiered-manifest.json',
35
+ '.deploy-worker-fingerprint.json',
36
+ '.deploy-worker-stats.json',
37
+ ]);
38
+
39
+ function git(root, args) {
40
+ try {
41
+ return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
42
+ } catch {
43
+ return '';
44
+ }
45
+ }
46
+
47
+ function portableRelative(root, target) {
48
+ const relative = path.relative(root, target).split(path.sep).join('/');
49
+ return relative && !relative.startsWith('../') && relative !== '..' ? relative : '';
50
+ }
51
+
52
+ function paths(root, stateDir) {
53
+ const state = path.resolve(stateDir || path.join(root, '.agentsam/deploy-merkle'));
54
+ return {
55
+ stateDir: state,
56
+ pendingSnapshot: path.join(state, 'pending.snapshot.json'),
57
+ pendingReceipt: path.join(state, 'pending.receipt.json'),
58
+ latestSnapshot: path.join(state, 'latest.snapshot.json'),
59
+ latestReceipt: path.join(state, 'latest.receipt.json'),
60
+ historyDir: path.join(state, 'history'),
61
+ };
62
+ }
63
+
64
+ async function atomicJson(filename, value) {
65
+ await fs.mkdir(path.dirname(filename), { recursive: true });
66
+ const temp = `${filename}.tmp-${randomBytes(8).toString('hex')}`;
67
+ try {
68
+ await fs.writeFile(temp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
69
+ await fs.rename(temp, filename);
70
+ } finally {
71
+ await fs.rm(temp, { force: true }).catch(() => {});
72
+ }
73
+ }
74
+
75
+ async function loadSnapshot(input) {
76
+ if (!input) return null;
77
+ if (typeof input === 'string') return readSnapshot(input);
78
+ return validateSnapshot(input);
79
+ }
80
+
81
+ function sourceDirty(root, stateDir) {
82
+ const raw = git(root, ['status', '--porcelain=v1', '--untracked-files=all']);
83
+ if (!raw) return false;
84
+ const stateRelative = portableRelative(root, stateDir);
85
+ return raw.split('\n').some((line) => {
86
+ const candidate = line.slice(3).replace(/^"|"$/g, '').replace(/\\/g, '/');
87
+ return !(stateRelative && (candidate === stateRelative || candidate.startsWith(`${stateRelative}/`)));
88
+ });
89
+ }
90
+
91
+ function gitMetadata(root) {
92
+ return {
93
+ git_sha: git(root, ['rev-parse', 'HEAD']),
94
+ git_branch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
95
+ repository: git(root, ['remote', 'get-url', 'origin']),
96
+ };
97
+ }
98
+
99
+ function truncateChanges(changes, maxChangedFiles) {
100
+ const files = changes.map((change) => change.path);
101
+ if (files.length <= maxChangedFiles) return files;
102
+ return [...files.slice(0, maxChangedFiles), `__truncated__:+${files.length - maxChangedFiles}_more`];
103
+ }
104
+
105
+ export async function captureDeployReceipt({
106
+ root = '.',
107
+ project,
108
+ stateDir,
109
+ baselineSnapshot,
110
+ baselineSource,
111
+ include = [],
112
+ exclude = [],
113
+ maxChangedFiles = 100,
114
+ metadata = {},
115
+ } = {}) {
116
+ const rootPath = await fs.realpath(root);
117
+ const state = paths(rootPath, stateDir);
118
+ const dirty = sourceDirty(rootPath, state.stateDir);
119
+ const stateRelative = portableRelative(rootPath, state.stateDir);
120
+ const policy = normalizePolicy({
121
+ include,
122
+ exclude: [...new Set([
123
+ ...DEFAULT_DEPLOY_EXCLUDES,
124
+ ...(stateRelative ? [stateRelative] : []),
125
+ ...exclude,
126
+ ])],
127
+ });
128
+
129
+ await fs.mkdir(state.historyDir, { recursive: true });
130
+
131
+ let baseline = null;
132
+ let resolvedBaselineSource = baselineSource || 'none';
133
+ if (baselineSnapshot) {
134
+ try {
135
+ baseline = await loadSnapshot(baselineSnapshot);
136
+ resolvedBaselineSource = baselineSource || 'provided';
137
+ } catch {
138
+ resolvedBaselineSource = 'invalid';
139
+ }
140
+ } else {
141
+ try {
142
+ baseline = await readSnapshot(state.latestSnapshot);
143
+ resolvedBaselineSource = 'local';
144
+ } catch (error) {
145
+ if (error?.code !== 'ENOENT') resolvedBaselineSource = 'invalid';
146
+ }
147
+ }
148
+
149
+ const tree = await buildMerkleTree(rootPath, { policy });
150
+ await atomicJson(state.pendingSnapshot, tree);
151
+
152
+ let diff = null;
153
+ if (baseline) {
154
+ try {
155
+ diff = diffTrees(baseline, tree);
156
+ } catch {
157
+ baseline = null;
158
+ resolvedBaselineSource = 'invalid';
159
+ }
160
+ }
161
+
162
+ const gitInfo = gitMetadata(rootPath);
163
+ const receipt = {
164
+ format: 'agentsam-deploy-merkle',
165
+ version: 1,
166
+ engine: 'agentsam-merkle-v1',
167
+ status: 'captured',
168
+ project: project || path.basename(rootPath),
169
+ repository: gitInfo.repository || null,
170
+ git_sha: gitInfo.git_sha || null,
171
+ git_branch: gitInfo.git_branch || null,
172
+ captured_at: new Date().toISOString(),
173
+ root_hash: tree.rootHash,
174
+ previous_root_hash: baseline?.rootHash || null,
175
+ has_baseline: Boolean(baseline),
176
+ baseline_source: resolvedBaselineSource,
177
+ working_tree_dirty: dirty,
178
+ stats: tree.stats,
179
+ diff_stats: diff?.stats || null,
180
+ changed_files: diff ? truncateChanges(diff.changes, maxChangedFiles) : [],
181
+ ...metadata,
182
+ };
183
+ await atomicJson(state.pendingReceipt, receipt);
184
+ return { receipt, snapshot: tree, paths: state };
185
+ }
186
+
187
+ export async function finalizeDeployReceipt({
188
+ root = '.',
189
+ stateDir,
190
+ status,
191
+ deploymentId,
192
+ workerVersionId,
193
+ metadata = {},
194
+ } = {}) {
195
+ if (!['success', 'failed'].includes(status)) throw new Error('Deploy receipt status must be success or failed.');
196
+ const rootPath = await fs.realpath(root);
197
+ const state = paths(rootPath, stateDir);
198
+ const [pendingReceipt, pendingSnapshot] = await Promise.all([
199
+ JSON.parse(await fs.readFile(state.pendingReceipt, 'utf8')),
200
+ readSnapshot(state.pendingSnapshot),
201
+ ]).catch((error) => {
202
+ if (error?.code === 'ENOENT') throw new Error('No pending deploy receipt capture exists.');
203
+ throw error;
204
+ });
205
+ if (pendingReceipt?.format !== 'agentsam-deploy-merkle' || pendingReceipt?.status !== 'captured') {
206
+ throw new Error('Pending deploy receipt is invalid or already finalized.');
207
+ }
208
+ if (pendingReceipt.root_hash !== pendingSnapshot.rootHash) throw new Error('Pending receipt/snapshot root mismatch.');
209
+
210
+ const completed = new Date().toISOString();
211
+ const receipt = {
212
+ ...pendingReceipt,
213
+ ...metadata,
214
+ status,
215
+ completed_at: completed,
216
+ deployment_id: deploymentId || null,
217
+ worker_version_id: workerVersionId || null,
218
+ };
219
+ const stamp = completed.replace(/[-:.]/g, '').replace('Z', 'Z');
220
+ const sha = (receipt.git_sha || 'unknown').replace(/[^A-Za-z0-9._-]/g, '-');
221
+ const rootSlug = receipt.root_hash.replace(/^sha256:/, '');
222
+ const historyReceipt = path.join(state.historyDir, `${stamp}-${sha}-${rootSlug.slice(0, 12)}-${status}.receipt.json`);
223
+ await atomicJson(historyReceipt, receipt);
224
+
225
+ if (status === 'success') {
226
+ await fs.copyFile(state.pendingSnapshot, state.latestSnapshot);
227
+ await atomicJson(state.latestReceipt, receipt);
228
+ }
229
+ return { receipt, snapshot: pendingSnapshot, paths: { ...state, historyReceipt } };
230
+ }
231
+
232
+ export async function showLatestDeployReceipt({ root = '.', stateDir } = {}) {
233
+ const rootPath = await fs.realpath(root);
234
+ const state = paths(rootPath, stateDir);
235
+ try {
236
+ return JSON.parse(await fs.readFile(state.latestReceipt, 'utf8'));
237
+ } catch (error) {
238
+ if (error?.code === 'ENOENT') return null;
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ export const captureCheckpoint = captureDeployReceipt;
244
+ export async function promoteCheckpoint(options = {}) {
245
+ return finalizeDeployReceipt({ ...options, status: 'success' });
246
+ }
@@ -66,7 +66,9 @@ export function resolveGitContext(options = {}) {
66
66
  ? preferredRemote
67
67
  : availableRemotes[0] || preferredRemote;
68
68
  const remoteUrl = runGit(root, ['remote', 'get-url', remote], { required: false });
69
- const revisionSha = runGit(root, ['rev-parse', 'HEAD']);
69
+ // A newly initialized repository can have an unborn HEAD. Repository identity
70
+ // and local inspection must still work before the first commit.
71
+ const revisionSha = runGit(root, ['rev-parse', 'HEAD'], { required: false }) || null;
70
72
  const branch = runGit(root, ['branch', '--show-current'], { required: false }) || null;
71
73
  const status = runGit(root, ['status', '--porcelain=v1'], { required: false });
72
74
 
@@ -0,0 +1,20 @@
1
+ import catalog from '../../protocol/presets/catalog.json' with { type: 'json' };
2
+
3
+ export const PRESET_CATALOG_VERSION = catalog.schema_version;
4
+
5
+ export function getPresetCatalog() { return structuredClone(catalog); }
6
+ export function listPresets() { return Object.values(catalog.presets).map((row) => structuredClone(row)); }
7
+ export function getPreset(id) {
8
+ const row = catalog.presets[String(id || '').trim().toLowerCase()];
9
+ return row ? structuredClone(row) : null;
10
+ }
11
+ export function resolvePreset(id = 'fullstack') {
12
+ const preset = getPreset(id);
13
+ if (!preset) throw new Error(`unknown_preset:${id}; expected ${Object.keys(catalog.presets).join(',')}`);
14
+ return preset;
15
+ }
16
+ export function listAddons() { return Object.values(catalog.addons).map((row) => structuredClone(row)); }
17
+ export function getAddon(id) {
18
+ const row = catalog.addons[String(id || '').trim().toLowerCase()];
19
+ return row ? structuredClone(row) : null;
20
+ }
@@ -0,0 +1,4 @@
1
+ export { repositorySnapshot } from '../capabilities/repository-snapshot.js';
2
+ export { buildMerkleTree, diffTrees, readSnapshot, saveSnapshot, validateSnapshot } from '../lib/merkle/index.js';
3
+ export { resolveGitContext, tryResolveGitContext, normalizeGitRemote } from '../lib/git-context.js';
4
+ export { buildRetrievalPlan, createContextPack } from '../knowledge/index.js';
@@ -0,0 +1,67 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createCapabilityAdapter } from '../src/agent/index.js';
4
+ import { buildRepositoryAuditPacket, runRepositoryAudit } from '../src/agent/repository-audit.js';
5
+
6
+ const snapshot = {
7
+ schema_version: 1,
8
+ capability: 'repository.snapshot',
9
+ snapshot_id: 'rsnap_0123456789abcdef01234567',
10
+ content_hash: `sha256:${'a'.repeat(64)}`,
11
+ repository: { repository_id: 'github:owner/repo', provider: 'github', full_name: 'owner/repo', branch: 'main', revision_sha: 'b'.repeat(40), dirty: false },
12
+ tree: { merkle_root: `sha256:${'c'.repeat(64)}`, stats: { files: 10 } },
13
+ intelligence: {
14
+ summary: { file_count: 10, total_lines: 500 },
15
+ languages: [{ language: 'JavaScript', files: 7 }],
16
+ manifests: [{ path: 'package.json', kind: 'node' }],
17
+ top_level: [{ path: 'src', files: 7 }],
18
+ pressure_points: [{ path: 'src', pressure_score: 70 }],
19
+ },
20
+ packages: [{ path: 'package.json', name: 'demo', version: '1.0.0' }],
21
+ knowledge: { configured: false },
22
+ deploy: null,
23
+ };
24
+
25
+ test('repository audit packet is bounded read-only evidence', () => {
26
+ const packet = buildRepositoryAuditPacket({ snapshot, focus: ['packages'], evidenceBudget: 1000 });
27
+ assert.equal(packet.primitive, 'repository.audit');
28
+ assert.equal(packet.rules.read_only, true);
29
+ assert.equal(packet.rules.may_edit, false);
30
+ assert.equal(packet.snapshot_id, snapshot.snapshot_id);
31
+ assert.ok(JSON.stringify(packet.evidence).length <= 4000);
32
+ });
33
+
34
+ test('repository audit uses injected reasoning and validates read-only output', async () => {
35
+ const audit = await runRepositoryAudit({
36
+ snapshot,
37
+ reasoner: async packet => ({
38
+ summary: `Audited ${packet.evidence.repository.full_name}`,
39
+ packages: packet.evidence.packages,
40
+ notable_combinations: ['snapshot + audit'],
41
+ findings: [{ severity: 'low', finding: 'example' }],
42
+ recommended_routes: ['inspect capability registry'],
43
+ evidence_refs: [packet.evidence_index.packages],
44
+ }),
45
+ });
46
+ assert.equal(audit.primitive, 'repository.audit');
47
+ assert.equal(audit.summary, 'Audited owner/repo');
48
+ assert.equal(audit.packages.length, 1);
49
+ assert.deepEqual(audit.commands, []);
50
+
51
+ await assert.rejects(() => runRepositoryAudit({ snapshot, reasoner: async () => ({ summary: 'bad', jobs: [] }) }), /mutation field: jobs/);
52
+ });
53
+
54
+ test('agent capability adapter exposes only executable tools by default', async () => {
55
+ const adapter = createCapabilityAdapter({
56
+ reasoner: async packet => ({ summary: 'ok', evidence_refs: [packet.evidence_index.summary] }),
57
+ handlers: { 'knowledge.search': async input => ({ query: input.text, hits: [] }) },
58
+ });
59
+ const names = adapter.toolDescriptors().map(row => row.name);
60
+ assert.ok(names.includes('repository.snapshot'));
61
+ assert.ok(names.includes('repository.audit'));
62
+ assert.ok(names.includes('knowledge.search'));
63
+ assert.equal(names.includes('knowledge.index'), false);
64
+ const result = await adapter.invoke('knowledge.search', { text: 'hello' });
65
+ assert.equal(result.capability_id, 'knowledge.search');
66
+ assert.deepEqual(result.result.hits, []);
67
+ });
@@ -0,0 +1,84 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { getCapability, getCapabilityManifest, repositorySnapshot } from '../src/capabilities/index.js';
8
+ import { getPreset, resolvePreset } from '../src/presets/index.js';
9
+
10
+ const CLI = path.resolve('src/cli.js');
11
+
12
+ function git(cwd, args) { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
13
+
14
+ function fixture() {
15
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-capability-'));
16
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'snapshot-fixture', version: '1.0.0', type: 'module' }, null, 2));
17
+ fs.mkdirSync(path.join(root, 'src'));
18
+ fs.writeFileSync(path.join(root, 'src', 'index.js'), 'export const value = 1;\n');
19
+ git(root, ['init', '-q']);
20
+ git(root, ['config', 'user.email', 'test@example.com']);
21
+ git(root, ['config', 'user.name', 'AgentSam Test']);
22
+ git(root, ['add', '.']);
23
+ git(root, ['commit', '-qm', 'fixture']);
24
+ return root;
25
+ }
26
+
27
+ test('capability manifest exposes deterministic primitives without requiring a model', () => {
28
+ const manifest = getCapabilityManifest();
29
+ assert.equal(manifest.schema_version, 1);
30
+ assert.equal(getCapability('repository.snapshot').model_required, false);
31
+ assert.equal(getCapability('repository.snapshot').side_effects, 'none');
32
+ assert.equal(getCapability('knowledge.index').deterministic, true);
33
+ });
34
+
35
+ test('presets resolve to explicit feature/capability selections', () => {
36
+ assert.equal(getPreset('cms').lane, 'cms');
37
+ const preset = resolvePreset('prototype');
38
+ assert.deepEqual(preset.features, ['agent']);
39
+ assert.throws(() => resolvePreset('nope'), /unknown_preset/);
40
+ });
41
+
42
+ test('repository.snapshot composes deterministic evidence and content addressing', async t => {
43
+ const root = fixture();
44
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
45
+ const one = await repositorySnapshot({ cwd: root, churnDays: 30 });
46
+ const two = await repositorySnapshot({ cwd: root, churnDays: 30 });
47
+ assert.equal(one.capability, 'repository.snapshot');
48
+ assert.match(one.snapshot_id, /^rsnap_[a-f0-9]{24}$/);
49
+ assert.match(one.content_hash, /^sha256:[a-f0-9]{64}$/);
50
+ assert.equal(one.content_hash, two.content_hash);
51
+ assert.equal(one.snapshot_id, two.snapshot_id);
52
+ assert.equal(one.repository.revision_sha, git(root, ['rev-parse', 'HEAD']));
53
+ assert.ok(one.tree.merkle_root);
54
+ assert.ok(one.intelligence.summary.file_count >= 2);
55
+ assert.equal(one.packages[0].name, 'snapshot-fixture');
56
+ assert.equal(one.knowledge.configured, false);
57
+ });
58
+
59
+ test('product UX creates a preset project, adds a feature, and inspects before first commit', t => {
60
+ const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-product-'));
61
+ t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
62
+ execFileSync(process.execPath, [CLI, 'create', 'demo', '--preset', 'cms', '--target', 'local'], {
63
+ cwd: parent,
64
+ stdio: 'pipe',
65
+ });
66
+ const project = path.join(parent, 'demo');
67
+ const config = JSON.parse(fs.readFileSync(path.join(project, '.agentsam', 'config.json'), 'utf8'));
68
+ assert.equal(config.preset, 'cms');
69
+ assert.equal(config.lane, 'cms');
70
+ assert.deepEqual(config.features, ['cms', 'knowledge']);
71
+ assert.ok(config.capabilities.includes('repository.snapshot'));
72
+
73
+ execFileSync(process.execPath, [CLI, 'add', 'knowledge', '--cwd', project, '--json'], { stdio: 'pipe' });
74
+ const features = JSON.parse(fs.readFileSync(path.join(project, '.agentsam', 'features.json'), 'utf8'));
75
+ assert.equal(features.features.knowledge.selected, true);
76
+
77
+ const snapshot = JSON.parse(execFileSync(process.execPath, [CLI, 'inspect', '--cwd', project, '--json'], {
78
+ encoding: 'utf8',
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ }));
81
+ assert.equal(snapshot.capability, 'repository.snapshot');
82
+ assert.equal(snapshot.repository.revision_sha, null);
83
+ assert.ok(snapshot.tree.stats.files > 0);
84
+ });
@@ -0,0 +1,91 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ captureDeployReceipt,
10
+ finalizeDeployReceipt,
11
+ showLatestDeployReceipt,
12
+ } from '../src/lib/deploy-receipt/index.js';
13
+
14
+ const cli = fileURLToPath(new URL('../src/cli.js', import.meta.url));
15
+
16
+ async function fixture(t) {
17
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'agentsam-deploy-receipt-'));
18
+ t.after(() => fs.rm(root, { recursive: true, force: true }));
19
+ execFileSync('git', ['init', '-q', root]);
20
+ execFileSync('git', ['-C', root, 'config', 'user.email', 'receipt-test@example.invalid']);
21
+ execFileSync('git', ['-C', root, 'config', 'user.name', 'Receipt Test']);
22
+ await fs.mkdir(path.join(root, 'src'), { recursive: true });
23
+ await fs.writeFile(path.join(root, 'src/app.js'), 'export const value = 1;\n');
24
+ execFileSync('git', ['-C', root, 'add', '.']);
25
+ execFileSync('git', ['-C', root, 'commit', '-qm', 'fixture']);
26
+ return root;
27
+ }
28
+
29
+ function run(args, cwd) {
30
+ return spawnSync(process.execPath, [cli, 'deploy-receipt', ...args], { cwd, encoding: 'utf8', timeout: 15000 });
31
+ }
32
+
33
+ test('successful finalize advances baseline while failure preserves the last trusted tree', async (t) => {
34
+ const root = await fixture(t);
35
+ const first = await captureDeployReceipt({ root, project: 'fixture' });
36
+ assert.equal(first.receipt.has_baseline, false);
37
+ assert.equal(first.receipt.baseline_source, 'none');
38
+ assert.equal(first.receipt.working_tree_dirty, false);
39
+ assert.ok(!first.snapshot.entries.some((entry) => entry.path.startsWith('.agentsam/deploy-merkle')));
40
+
41
+ const promoted = await finalizeDeployReceipt({ root, status: 'success', deploymentId: 'dep_1' });
42
+ assert.equal(promoted.receipt.status, 'success');
43
+ assert.equal(promoted.receipt.deployment_id, 'dep_1');
44
+ assert.equal((await showLatestDeployReceipt({ root })).root_hash, first.receipt.root_hash);
45
+
46
+ const clean = await captureDeployReceipt({ root, project: 'fixture' });
47
+ assert.equal(clean.receipt.has_baseline, true);
48
+ assert.equal(clean.receipt.baseline_source, 'local');
49
+ assert.deepEqual(clean.receipt.diff_stats, { unchanged: 1, modified: 0, added: 0, removed: 0 });
50
+ assert.deepEqual(clean.receipt.changed_files, []);
51
+ assert.equal(clean.receipt.root_hash, first.receipt.root_hash);
52
+ assert.equal(clean.receipt.working_tree_dirty, false, 'runtime receipt state must not make the source tree dirty');
53
+
54
+ await fs.writeFile(path.join(root, 'src/app.js'), 'export const value = 2;\n');
55
+ const changed = await captureDeployReceipt({ root, project: 'fixture' });
56
+ assert.equal(changed.receipt.working_tree_dirty, true);
57
+ assert.deepEqual(changed.receipt.diff_stats, { unchanged: 0, modified: 1, added: 0, removed: 0 });
58
+ assert.deepEqual(changed.receipt.changed_files, ['src/app.js']);
59
+ assert.notEqual(changed.receipt.root_hash, first.receipt.root_hash);
60
+
61
+ await finalizeDeployReceipt({ root, status: 'failed', deploymentId: 'dep_2' });
62
+ assert.equal((await showLatestDeployReceipt({ root })).root_hash, first.receipt.root_hash, 'failed run must not advance baseline');
63
+
64
+ const retry = await captureDeployReceipt({ root, project: 'fixture' });
65
+ assert.deepEqual(retry.receipt.changed_files, ['src/app.js']);
66
+ await finalizeDeployReceipt({ root, status: 'success', deploymentId: 'dep_3' });
67
+ const settled = await captureDeployReceipt({ root, project: 'fixture' });
68
+ assert.deepEqual(settled.receipt.diff_stats, { unchanged: 1, modified: 0, added: 0, removed: 0 });
69
+ assert.deepEqual(settled.receipt.changed_files, []);
70
+ });
71
+
72
+ test('deploy-receipt CLI emits machine-readable capture/finalize/show receipts', async (t) => {
73
+ const root = await fixture(t);
74
+ const capture = run(['capture', '.', '--project', 'cli-fixture', '--json'], root);
75
+ assert.equal(capture.status, 0, capture.stderr);
76
+ const captured = JSON.parse(capture.stdout);
77
+ assert.equal(captured.format, 'agentsam-deploy-merkle');
78
+ assert.equal(captured.project, 'cli-fixture');
79
+ assert.equal(captured.status, 'captured');
80
+
81
+ const success = run(['success', '.', '--deployment-id', 'dep_cli', '--worker-version', 'worker_v1', '--json'], root);
82
+ assert.equal(success.status, 0, success.stderr);
83
+ const finalized = JSON.parse(success.stdout);
84
+ assert.equal(finalized.status, 'success');
85
+ assert.equal(finalized.deployment_id, 'dep_cli');
86
+ assert.equal(finalized.worker_version_id, 'worker_v1');
87
+
88
+ const show = run(['show', '.', '--json'], root);
89
+ assert.equal(show.status, 0, show.stderr);
90
+ assert.equal(JSON.parse(show.stdout).root_hash, finalized.root_hash);
91
+ });
@@ -1,7 +1,7 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
 
@@ -39,6 +39,16 @@ test('resolveGitContext derives repository identity from Git without user/worksp
39
39
  assert.equal('userId' in ctx, false);
40
40
  });
41
41
 
42
+ test('resolveGitContext supports an initialized repository before its first commit', () => {
43
+ const root = mkdtempSync(join(tmpdir(), 'agentsam-sdk-unborn-'));
44
+ execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' });
45
+ writeFileSync(join(root, 'README.md'), '# unborn\n');
46
+ const ctx = resolveGitContext({ cwd: root });
47
+ assert.equal(ctx.root, realpathSync(root));
48
+ assert.equal(ctx.revisionSha, null);
49
+ assert.equal(ctx.dirty, true);
50
+ });
51
+
42
52
  test('bridge headers authenticate only the machine principal', () => {
43
53
  const headers = buildBridgeHeaders({
44
54
  env: {