@inneranimalmedia/agentsam-sdk 2.0.0 → 2.1.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.
@@ -0,0 +1,257 @@
1
+ """Unit tests for repository.recon -- packet building and report validation.
2
+
3
+ No git dependency required (base_sha falls back to "unknown" outside a repo);
4
+ no model calls; no network.
5
+ """
6
+ import json
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ from agentsam_sdk.repository.recon import (
12
+ PacketError,
13
+ ReportError,
14
+ build_task_packet,
15
+ from_ast_grep,
16
+ from_matches,
17
+ from_ripgrep,
18
+ validate_report,
19
+ )
20
+
21
+
22
+ class TestBuildTaskPacket(unittest.TestCase):
23
+ def setUp(self):
24
+ self.tmp = tempfile.TemporaryDirectory()
25
+ self.root = Path(self.tmp.name)
26
+ (self.root / "backend").mkdir()
27
+ (self.root / "backend" / "workflows.js").write_text(
28
+ "\n".join(f"line {i}" for i in range(1, 201)), encoding="utf-8"
29
+ )
30
+
31
+ def tearDown(self):
32
+ self.tmp.cleanup()
33
+
34
+ def test_builds_bounded_packet_with_content(self):
35
+ packet = build_task_packet(
36
+ self.root,
37
+ question="Does this file depend on workspace_id?",
38
+ slices=[{"path": "backend/workflows.js", "start_line": 1, "end_line": 5}],
39
+ )
40
+ self.assertEqual(packet["schema_version"], 1)
41
+ self.assertEqual(len(packet["slices"]), 1)
42
+ self.assertEqual(packet["slices"][0]["content"], "line 1\nline 2\nline 3\nline 4\nline 5")
43
+ self.assertEqual(packet["ceilings"]["max_follow_up_reads"], 2)
44
+ self.assertTrue(packet["task_id"].startswith("recon-"))
45
+
46
+ def test_rejects_missing_question(self):
47
+ with self.assertRaises(PacketError):
48
+ build_task_packet(self.root, question=" ", slices=[{"path": "backend/workflows.js"}])
49
+
50
+ def test_rejects_more_than_five_slices(self):
51
+ slices = [{"path": "backend/workflows.js"} for _ in range(6)]
52
+ with self.assertRaises(PacketError):
53
+ build_task_packet(self.root, question="q", slices=slices)
54
+
55
+ def test_rejects_slice_outside_repo_root(self):
56
+ with self.assertRaises(PacketError):
57
+ build_task_packet(self.root, question="q", slices=[{"path": "../../etc/passwd"}])
58
+
59
+ def test_rejects_missing_file(self):
60
+ with self.assertRaises(PacketError):
61
+ build_task_packet(self.root, question="q", slices=[{"path": "backend/nope.js"}])
62
+
63
+
64
+ class TestFromMatches(unittest.TestCase):
65
+ def setUp(self):
66
+ self.tmp = tempfile.TemporaryDirectory()
67
+ self.root = Path(self.tmp.name)
68
+ (self.root / "backend").mkdir()
69
+ for name in ("a.js", "b.js", "c.js", "d.js", "e.js", "f.js"):
70
+ (self.root / "backend" / name).write_text(
71
+ "\n".join(f"line {i}" for i in range(1, 51)), encoding="utf-8"
72
+ )
73
+
74
+ def tearDown(self):
75
+ self.tmp.cleanup()
76
+
77
+ def test_chunks_more_than_five_files_into_multiple_packets(self):
78
+ # 6 files -> ceiling is 5 per packet -> 2 packets
79
+ matches = [
80
+ {"path": f"backend/{name}", "line": 10, "kind": "member"}
81
+ for name in ("a.js", "b.js", "c.js", "d.js", "e.js", "f.js")
82
+ ]
83
+ packets = from_matches(self.root, question="q", matches=matches, task_id_prefix="wf")
84
+ self.assertEqual(len(packets), 2)
85
+ self.assertEqual(packets[0]["task_id"], "wf-1of2")
86
+ self.assertEqual(packets[1]["task_id"], "wf-2of2")
87
+ total_slices = sum(len(p["slices"]) for p in packets)
88
+ self.assertEqual(total_slices, 6)
89
+ for p in packets:
90
+ self.assertLessEqual(len(p["slices"]), 5)
91
+
92
+ def test_groups_multiple_hits_in_one_file_into_one_windowed_slice(self):
93
+ matches = [
94
+ {"path": "backend/a.js", "line": 5, "kind": "sql_string"},
95
+ {"path": "backend/a.js", "line": 20, "kind": "sql_string"},
96
+ ]
97
+ packets = from_matches(self.root, question="q", matches=matches, context_lines=2)
98
+ slice_ = packets[0]["slices"][0]
99
+ self.assertEqual(slice_["start_line"], 3)
100
+ self.assertEqual(slice_["end_line"], 22)
101
+ self.assertEqual(slice_["hit_count"], 2)
102
+ self.assertEqual(slice_["kind"], "sql_string")
103
+
104
+ def test_rejects_empty_matches(self):
105
+ with self.assertRaises(PacketError):
106
+ from_matches(self.root, question="q", matches=[])
107
+
108
+
109
+ class TestFromRipgrep(unittest.TestCase):
110
+ def test_parses_ndjson_match_lines_only(self):
111
+ # Real `rg --json` shape: begin/match/end/summary records per file.
112
+ ndjson = "\n".join([
113
+ json.dumps({"type": "begin", "data": {"path": {"text": "backend/http/workflows/scope.js"}}}),
114
+ json.dumps({
115
+ "type": "match",
116
+ "data": {
117
+ "path": {"text": "backend/http/workflows/scope.js"},
118
+ "line_number": 66,
119
+ "lines": {"text": " body.workspace_id\n"},
120
+ },
121
+ }),
122
+ json.dumps({"type": "end", "data": {"path": {"text": "backend/http/workflows/scope.js"}}}),
123
+ json.dumps({"type": "summary", "data": {}}),
124
+ ])
125
+ hits = from_ripgrep(ndjson)
126
+ self.assertEqual(hits, [{"path": "backend/http/workflows/scope.js", "line": 66}])
127
+
128
+ def test_accepts_a_list_of_lines_too(self):
129
+ lines = [json.dumps({"type": "match", "data": {"path": {"text": "a.js"}, "line_number": 1}})]
130
+ self.assertEqual(from_ripgrep(lines), [{"path": "a.js", "line": 1}])
131
+
132
+ def test_empty_input_yields_no_hits(self):
133
+ self.assertEqual(from_ripgrep(""), [])
134
+
135
+ def test_rejects_malformed_json_line(self):
136
+ with self.assertRaises(PacketError):
137
+ from_ripgrep("not json")
138
+
139
+
140
+ class TestFromAstGrep(unittest.TestCase):
141
+ def test_parses_compact_json_array_with_caller_supplied_kind(self):
142
+ # Real `sg -p '...' --json=compact` shape.
143
+ raw = json.dumps([
144
+ {
145
+ "text": "body.workspace_id",
146
+ "range": {"start": {"line": 66, "column": 76}, "end": {"line": 66, "column": 93}},
147
+ "file": "backend/http/workflows/scope.js",
148
+ },
149
+ {
150
+ "text": "ctx.workspaceId",
151
+ "range": {"start": {"line": 12, "column": 4}, "end": {"line": 12, "column": 19}},
152
+ "file": "backend/workflows/handlers/tool.js",
153
+ },
154
+ ])
155
+ hits = from_ast_grep(raw, kind="member")
156
+ self.assertEqual(hits, [
157
+ {"path": "backend/http/workflows/scope.js", "line": 66, "kind": "member"},
158
+ {"path": "backend/workflows/handlers/tool.js", "line": 12, "kind": "member"},
159
+ ])
160
+
161
+ def test_kind_is_optional(self):
162
+ raw = json.dumps([{"range": {"start": {"line": 1}}, "file": "a.js"}])
163
+ self.assertEqual(from_ast_grep(raw), [{"path": "a.js", "line": 1}])
164
+
165
+ def test_empty_array_yields_no_hits(self):
166
+ self.assertEqual(from_ast_grep("[]"), [])
167
+
168
+ def test_rejects_invalid_json(self):
169
+ with self.assertRaises(PacketError):
170
+ from_ast_grep("not json")
171
+
172
+ def test_adapters_compose_into_from_matches(self):
173
+ # The actual workflow: run several classified sg queries + one rg sweep,
174
+ # concatenate, then chunk into packets -- same shape as the iMac smoke test.
175
+ member_hits = from_ast_grep(
176
+ json.dumps([{"range": {"start": {"line": 5}}, "file": "backend/a.js"}]), kind="member"
177
+ )
178
+ sql_hits = from_ast_grep(
179
+ json.dumps([{"range": {"start": {"line": 20}}, "file": "backend/b.js"}]), kind="sql_string"
180
+ )
181
+ rg_hits = from_ripgrep(json.dumps({
182
+ "type": "match", "data": {"path": {"text": "backend/c.js"}, "line_number": 8},
183
+ }))
184
+ combined = member_hits + sql_hits + rg_hits
185
+ self.assertEqual(len(combined), 3)
186
+ self.assertEqual({h["path"] for h in combined}, {"backend/a.js", "backend/b.js", "backend/c.js"})
187
+ self.assertEqual(combined[2].get("kind"), None) # rg hit carries no kind
188
+
189
+
190
+ class TestValidateReport(unittest.TestCase):
191
+ def setUp(self):
192
+ self.packet = {
193
+ "task_id": "recon-abc123",
194
+ "slices": [{"path": "backend/workflows.js"}],
195
+ }
196
+
197
+ def test_accepts_answered_report_citing_known_file(self):
198
+ report = {
199
+ "schema_version": 1,
200
+ "task_id": "recon-abc123",
201
+ "status": "answered",
202
+ "findings": [
203
+ {
204
+ "severity": "high",
205
+ "file": "backend/workflows.js",
206
+ "finding": "still queries workspace_id",
207
+ }
208
+ ],
209
+ }
210
+ self.assertEqual(validate_report(report, self.packet), report)
211
+
212
+ def test_accepts_needs_context_with_reason(self):
213
+ report = {
214
+ "schema_version": 1,
215
+ "task_id": "recon-abc123",
216
+ "status": "needs_context",
217
+ "reason": "definition of ensureWorkflowRun() not supplied",
218
+ }
219
+ self.assertEqual(validate_report(report, self.packet)["status"], "needs_context")
220
+
221
+ def test_rejects_needs_context_without_reason(self):
222
+ report = {"schema_version": 1, "task_id": "recon-abc123", "status": "needs_context"}
223
+ with self.assertRaises(ReportError):
224
+ validate_report(report, self.packet)
225
+
226
+ def test_rejects_finding_that_cites_a_file_outside_the_packet(self):
227
+ report = {
228
+ "schema_version": 1,
229
+ "task_id": "recon-abc123",
230
+ "status": "answered",
231
+ "findings": [
232
+ {"severity": "high", "file": "backend/other.js", "finding": "made up"}
233
+ ],
234
+ }
235
+ with self.assertRaises(ReportError):
236
+ validate_report(report, self.packet)
237
+
238
+ def test_rejects_task_id_mismatch(self):
239
+ report = {"schema_version": 1, "task_id": "recon-different", "status": "needs_context", "reason": "x"}
240
+ with self.assertRaises(ReportError):
241
+ validate_report(report, self.packet)
242
+
243
+ def test_rejects_invalid_severity(self):
244
+ report = {
245
+ "schema_version": 1,
246
+ "task_id": "recon-abc123",
247
+ "status": "answered",
248
+ "findings": [
249
+ {"severity": "critical", "file": "backend/workflows.js", "finding": "x"}
250
+ ],
251
+ }
252
+ with self.assertRaises(ReportError):
253
+ validate_report(report, self.packet)
254
+
255
+
256
+ if __name__ == "__main__":
257
+ unittest.main()
package/src/cli.js CHANGED
@@ -21,7 +21,9 @@ import { runTui } from './commands/tui.js';
21
21
  import { runDockerize } from './commands/dockerize.js';
22
22
  import { runMini } from './commands/mini.js';
23
23
  import { runMerkle } from './commands/merkle.js';
24
+ import { runDeployReceipt } from './commands/deploy-receipt.js';
24
25
  import { runSecurity } from './commands/security.js';
26
+ import { runRecon } from './commands/recon.js';
25
27
  import { SLASH_COMMANDS, SHELL_PHASES } from './lib/slash-commands.js';
26
28
  import fs from 'node:fs';
27
29
  import { repositoryRoot } from './knowledge/config.js';
@@ -48,6 +50,8 @@ function printHelp() {
48
50
  agentsam repo snapshot Git composition/churn; --save retains observations
49
51
  agentsam mini <name> Create and preview a small local gadget (--help for options)
50
52
  agentsam merkle File integrity, snapshots, comparisons, and TUI (--help)
53
+ agentsam deploy-receipt Merkle deploy/checkpoint capture + promote/failure receipts (--help)
54
+ agentsam recon Bounded-worker task packets + finding-report validation (--help)
51
55
  agentsam security Dependency scan, log triage, and verified repair (--help)
52
56
  agentsam status [--json] Live local Git + DB + API + PTY status
53
57
  agentsam db init|status Manage the project-local SQLite database
@@ -121,13 +125,13 @@ async function runLocalInit(config) {
121
125
  );
122
126
 
123
127
  console.log(`
124
- ┌─────────────────────────────────────┐
128
+ ┌──────────────────────────────────────┐
125
129
  │ Agent Sam — local-first scaffold │
126
- ├─────────────────────────────────────┤
130
+ ├──────────────────────────────────────┤
127
131
  │ Name: ${meta.projectName.padEnd(25)}│
128
132
  │ Lane: ${meta.laneKey.padEnd(25)}│
129
133
  │ Run: ${meta.runTarget.padEnd(25)}│
130
- └─────────────────────────────────────┘
134
+ └──────────────────────────────────────┘
131
135
  `);
132
136
 
133
137
  const dir = writeScaffoldFiles(meta.projectName, meta.files);
@@ -165,10 +169,10 @@ async function initInteractive(partial = {}) {
165
169
  const prompt = createPrompt();
166
170
 
167
171
  console.log(`
168
- ╔═══════════════════════════════════╗
172
+ ╔════════════════════════════════╗
169
173
  ║ Agent Sam SDK — Init ║
170
174
  ║ Local-first · Node only ║
171
- ╚═══════════════════════════════════╝
175
+ ╚════════════════════════════════╝
172
176
  `);
173
177
 
174
178
  const projectName =
@@ -241,9 +245,9 @@ async function runShellInfo(argv = []) {
241
245
 
242
246
  const next = SHELL_PHASES.find((p) => p.status === 'next' || p.status === 'current');
243
247
  console.log(`
244
- ╔═══════════════════════════════════╗
248
+ ╔═══════════════════════════════╗
245
249
  ║ Agent Sam Terminal ║
246
- ╚═══════════════════════════════════╝
250
+ ╚════════════════════════════════╝
247
251
 
248
252
  Local PTY agentsam start-local ws://127.0.0.1:3099
249
253
  ANSI TUI agentsam tui zero-dependency Node UI
@@ -329,6 +333,10 @@ if (command === '--version' || command === '-v') {
329
333
  await runSecurity(rest);
330
334
  } else if (command === 'merkle') {
331
335
  await runMerkle(rest);
336
+ } else if (command === 'deploy-receipt') {
337
+ await runDeployReceipt(rest);
338
+ } else if (command === 'recon') {
339
+ await runRecon(rest);
332
340
  } else if (command === 'mini') {
333
341
  try {
334
342
  await runMini(rest);
@@ -0,0 +1,129 @@
1
+ import { captureDeployReceipt, finalizeDeployReceipt, showLatestDeployReceipt } from '../lib/deploy-receipt/index.js';
2
+
3
+ export function printDeployReceiptHelp() {
4
+ console.log(`
5
+ agentsam deploy-receipt — reusable Merkle deployment/checkpoint lifecycle
6
+
7
+ capture [path] Capture the current tree and compare to the last promoted baseline
8
+ success [path] Finalize a successful run and promote its snapshot to latest
9
+ failure [path] Finalize a failed run without advancing the baseline
10
+ show [path] Print the latest promoted receipt
11
+
12
+ --project <id> Logical project identifier (defaults to directory name)
13
+ --state-dir <path> Runtime state directory (default: .agentsam/deploy-merkle)
14
+ --baseline <snapshot> Explicit baseline snapshot for capture (e.g. restored from R2)
15
+ --baseline-source <label> Baseline provenance label (e.g. r2, local-cache)
16
+ --include <default-rule> Include one AgentSam default-ignored category
17
+ --exclude <path-or-name> Additional literal exclusion (repeatable)
18
+ --max-changed-files <n> Receipt path cap (default 100)
19
+ --deployment-id <id> Deployment/ledger identity for success/failure
20
+ --worker-version <id> Provider version identity for success/failure
21
+ --metadata-json <json> Extra compact receipt metadata object
22
+ --json Machine-readable output
23
+
24
+ Runtime state is intentionally not source. The state directory is excluded from the
25
+ captured Merkle tree, successful finalize advances latest.*, and failure never does.
26
+
27
+ Examples:
28
+ agentsam deploy-receipt capture . --project my-worker --json
29
+ wrangler deploy
30
+ agentsam deploy-receipt success . --deployment-id dep_123 --json
31
+ agentsam deploy-receipt failure . --deployment-id dep_124 --json
32
+ `);
33
+ }
34
+
35
+ function parseJsonObject(value, flag) {
36
+ let parsed;
37
+ try { parsed = JSON.parse(value); } catch { throw new Error(`${flag} must be valid JSON.`); }
38
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${flag} must be a JSON object.`);
39
+ return parsed;
40
+ }
41
+
42
+ function parse(argv) {
43
+ const [command, ...args] = argv;
44
+ const normalized = command === 'fail' ? 'failure' : command === 'promote' ? 'success' : command;
45
+ if (!['capture', 'success', 'failure', 'show'].includes(normalized)) throw new Error(`Unknown deploy-receipt command: ${command || ''}`);
46
+ const opts = { command: normalized, root: '.', include: [], exclude: [], json: false, metadata: {} };
47
+ let positional = false;
48
+ for (let i = 0; i < args.length; i++) {
49
+ const arg = args[i];
50
+ if (arg === '--') { positional = true; continue; }
51
+ if (positional || !arg.startsWith('-')) {
52
+ if (opts.root !== '.') throw new Error('Only one root path may be supplied.');
53
+ opts.root = arg;
54
+ continue;
55
+ }
56
+ if (arg === '--json') { opts.json = true; continue; }
57
+ if (['--project', '--state-dir', '--baseline', '--baseline-source', '--deployment-id', '--worker-version', '--metadata-json', '--max-changed-files', '--include', '--exclude'].includes(arg)) {
58
+ const value = args[++i];
59
+ if (value == null || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
60
+ if (arg === '--include') opts.include.push(value);
61
+ else if (arg === '--exclude') opts.exclude.push(value);
62
+ else if (arg === '--metadata-json') opts.metadata = parseJsonObject(value, arg);
63
+ else if (arg === '--max-changed-files') {
64
+ const count = Number(value);
65
+ if (!Number.isSafeInteger(count) || count < 1 || count > 10000) throw new Error('--max-changed-files must be an integer from 1 to 10000.');
66
+ opts.maxChangedFiles = count;
67
+ } else opts[arg.slice(2).replaceAll('-', '_')] = value;
68
+ continue;
69
+ }
70
+ throw new Error(`Unknown option: ${arg}`);
71
+ }
72
+ if (opts.command !== 'capture' && (opts.baseline || opts.baseline_source || opts.include.length || opts.exclude.length || opts.maxChangedFiles)) {
73
+ throw new Error('Baseline/include/exclude/change-cap options apply only to capture.');
74
+ }
75
+ if (!['success', 'failure'].includes(opts.command) && (opts.deployment_id || opts.worker_version)) {
76
+ throw new Error('--deployment-id and --worker-version apply only to success/failure.');
77
+ }
78
+ return opts;
79
+ }
80
+
81
+ function human(result, command) {
82
+ if (!result) return 'No promoted deploy receipt.\n';
83
+ const receipt = result.receipt || result;
84
+ if (command === 'capture') {
85
+ const delta = receipt.diff_stats ? `${receipt.diff_stats.added} added, ${receipt.diff_stats.modified} modified, ${receipt.diff_stats.removed} removed` : 'no baseline';
86
+ return `Captured ${receipt.root_hash} (${delta}; baseline=${receipt.baseline_source}).\n`;
87
+ }
88
+ return `${receipt.status}: ${receipt.root_hash}${receipt.deployment_id ? ` deployment=${receipt.deployment_id}` : ''}\n`;
89
+ }
90
+
91
+ export async function runDeployReceipt(argv = []) {
92
+ if (!argv.length || argv.includes('--help') || argv.includes('-h')) { printDeployReceiptHelp(); return; }
93
+ try {
94
+ const opts = parse(argv);
95
+ let result;
96
+ if (opts.command === 'capture') {
97
+ result = await captureDeployReceipt({
98
+ root: opts.root,
99
+ project: opts.project,
100
+ stateDir: opts.state_dir,
101
+ baselineSnapshot: opts.baseline,
102
+ baselineSource: opts.baseline_source,
103
+ include: opts.include,
104
+ exclude: opts.exclude,
105
+ maxChangedFiles: opts.maxChangedFiles,
106
+ metadata: opts.metadata,
107
+ });
108
+ } else if (opts.command === 'show') {
109
+ result = await showLatestDeployReceipt({ root: opts.root, stateDir: opts.state_dir });
110
+ } else {
111
+ result = await finalizeDeployReceipt({
112
+ root: opts.root,
113
+ stateDir: opts.state_dir,
114
+ status: opts.command === 'success' ? 'success' : 'failed',
115
+ deploymentId: opts.deployment_id,
116
+ workerVersionId: opts.worker_version,
117
+ metadata: opts.metadata,
118
+ });
119
+ }
120
+ const output = result?.receipt || result;
121
+ if (opts.json) process.stdout.write(JSON.stringify(output, null, 2) + '\n');
122
+ else process.stdout.write(human(result, opts.command));
123
+ return result;
124
+ } catch (error) {
125
+ process.exitCode = 2;
126
+ const json = argv.includes('--json');
127
+ process.stderr.write(json ? JSON.stringify({ error: error.message }) + '\n' : `Deploy receipt: ${error.message}\n`);
128
+ }
129
+ }
@@ -0,0 +1,71 @@
1
+ import path from 'node:path';
2
+ import { spawn } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const PYTHON_ROOT = fileURLToPath(new URL('../../python', import.meta.url));
6
+
7
+ export function printReconHelp() {
8
+ console.log(`
9
+ agentsam recon — bounded-worker task packets and finding-report validation
10
+
11
+ pack --repo-root <path> --question "..." --slice <path[:start-end]> [--slice ...]
12
+ Build a bounded ReconTaskPacket (<=5 slices, hard ceilings)
13
+ validate --packet <file> --report <file>
14
+ Validate a ReconFindingReport against its packet
15
+
16
+ pack options:
17
+ --repo-root <path> Default: .
18
+ --question <text> Required. Exactly one bounded question.
19
+ --slice <path[:start-end]> Repeatable, up to 5. e.g. --slice src/foo.js:10-40
20
+ --task-id <id> Default: generated
21
+ --out <file> Write the packet JSON to a file instead of stdout
22
+
23
+ validate options:
24
+ --packet <file> Required. Path to a packet JSON file.
25
+ --report <file> Required. Path to a worker's report JSON file.
26
+
27
+ Neither command calls a model or writes into the target repository. This is a thin
28
+ passthrough to the bundled Python module (agentsam_sdk.repository.recon) — same
29
+ engine as \`python -m agentsam_sdk.repository.recon\`. See docs/RECON.md.
30
+
31
+ Examples:
32
+ agentsam recon pack --repo-root . --question "Does this still read workspace_id?" \\
33
+ --slice backend/workflows/repository.js:1-180 --out /tmp/packet.json
34
+ agentsam recon validate --packet /tmp/packet.json --report /tmp/report.json
35
+
36
+ For a raw rg/ast-grep hit list instead of hand-picked slices, use
37
+ agentsam_sdk.repository.recon.{from_ripgrep,from_ast_grep,from_matches}() from
38
+ Python directly — those have no Node CLI surface yet since hit shapes vary by tool.
39
+ `);
40
+ }
41
+
42
+ function run(command, args) {
43
+ return new Promise((resolve) => {
44
+ const child = spawn(command, args, {
45
+ stdio: 'inherit',
46
+ cwd: process.cwd(),
47
+ env: { ...process.env, PYTHONPATH: [PYTHON_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) },
48
+ });
49
+ child.once('error', (err) => {
50
+ console.error(`\n ✗ Could not run Python (${err.message}). Python 3.10+ must be on PATH for \`agentsam recon\`.\n`);
51
+ resolve(1);
52
+ });
53
+ child.once('exit', (code, signal) => {
54
+ if (signal) { console.error(`\n ✗ recon ${signal}\n`); resolve(1); }
55
+ else resolve(code ?? 1);
56
+ });
57
+ });
58
+ }
59
+
60
+ export async function runRecon(argv = []) {
61
+ if (!argv.length || argv.includes('--help') || argv.includes('-h')) { printReconHelp(); return; }
62
+ const [command] = argv;
63
+ if (!['pack', 'validate'].includes(command)) {
64
+ console.error(`\n ✗ Unknown recon command: ${command}. Use \`agentsam recon --help\`.\n`);
65
+ process.exitCode = 2;
66
+ return;
67
+ }
68
+ const python = process.platform === 'win32' ? 'python' : 'python3';
69
+ const code = await run(python, ['-B', '-m', 'agentsam_sdk.repository.recon', ...argv]);
70
+ if (code !== 0) process.exitCode = code;
71
+ }
package/src/index.js CHANGED
@@ -6,6 +6,14 @@ export { AgentSam } from './AgentSam.js';
6
6
  export { routeIntent } from './lib/router.js';
7
7
  export { getToolCatalog } from './lib/tools.js';
8
8
  export { scaffoldProject } from './lib/scaffold.js';
9
+ export {
10
+ DEFAULT_DEPLOY_EXCLUDES,
11
+ captureDeployReceipt,
12
+ finalizeDeployReceipt,
13
+ showLatestDeployReceipt,
14
+ captureCheckpoint,
15
+ promoteCheckpoint,
16
+ } from './lib/deploy-receipt/index.js';
9
17
  export {
10
18
  normalizeGitRemote,
11
19
  resolveGitContext,