@arungeorgesaji/assembly 0.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,45 @@
1
+ import { getApprovalMode } from "./config.js";
2
+
3
+ const VALID_APPROVAL_MODES = new Set(["auto", "manual", "never"]);
4
+
5
+ export function resolveApproval({ task, result, mode = getApprovalMode() }) {
6
+ if (!VALID_APPROVAL_MODES.has(mode)) {
7
+ return {
8
+ approved: false,
9
+ status: "failed",
10
+ reason: `unknown ASSEMBLY_APPROVAL_MODE: ${mode}`,
11
+ };
12
+ }
13
+
14
+ const hasEdits = Boolean(result.patch) || result.fileUpdates?.length > 0;
15
+ if (!hasEdits || result.status !== "complete") {
16
+ return {
17
+ approved: true,
18
+ status: "approved",
19
+ reason: "No edits require approval.",
20
+ };
21
+ }
22
+
23
+ if (mode === "auto") {
24
+ return {
25
+ approved: true,
26
+ status: "approved",
27
+ reason: "Auto approval mode approved validated edits.",
28
+ };
29
+ }
30
+
31
+ if (mode === "never") {
32
+ return {
33
+ approved: false,
34
+ status: "dry_run",
35
+ reason: "Approval mode never records edits without applying them.",
36
+ };
37
+ }
38
+
39
+ return {
40
+ approved: false,
41
+ status: "pending",
42
+ reason: `Manual approval required before applying edits for task ${task.id}.`,
43
+ };
44
+ }
45
+
package/src/cli.js ADDED
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { realpathSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { createPlan } from "./planner.js";
8
+ import { createRun } from "./orchestrator.js";
9
+ import { createFollowUpRun } from "./follow-up.js";
10
+ import { createGitHubPullRequest, getGitHubComment } from "./github.js";
11
+ import { formatInitResult, initializeAssembly } from "./init.js";
12
+ import { processJob } from "./job-worker.js";
13
+ import { listJobs, readJob, resetJobForRetry } from "./job-store.js";
14
+ import { readRun } from "./run-store.js";
15
+ import { startWebhookServer } from "./webhook-server.js";
16
+ import { loadEnv } from "./config.js";
17
+ import { formatDoctorReport, runDoctor } from "./doctor.js";
18
+ import { parseGlobalOptions, resolveRootDir } from "./root.js";
19
+ import { validatePlan } from "./validation.js";
20
+
21
+ export async function main(argv = process.argv.slice(2), io = process) {
22
+ const { args: resolvedArgv, repo, errors } = parseGlobalOptions(argv);
23
+ if (errors.length > 0) {
24
+ for (const error of errors) {
25
+ io.stderr.write(`error: ${error}\n`);
26
+ }
27
+ io.stderr.write("usage: assembly [--repo <path>] <init|plan|run|follow-up|status|inspect|github|job|webhook|doctor> ...\n");
28
+ return 2;
29
+ }
30
+ const rootDir = await resolveRootDir({ repo });
31
+ await loadEnv(rootDir);
32
+ const [command, ...args] = resolvedArgv;
33
+
34
+ if (!["init", "plan", "run", "follow-up", "status", "inspect", "github", "job", "webhook", "doctor"].includes(command)) {
35
+ io.stderr.write("usage: assembly [--repo <path>] <init|plan|run|follow-up|status|inspect|github|job|webhook|doctor> ...\n");
36
+ return 2;
37
+ }
38
+
39
+ if (command === "init") {
40
+ return handleInit(args, io, rootDir);
41
+ }
42
+ if (command === "plan") {
43
+ return handlePlan(args, io, rootDir);
44
+ }
45
+ if (command === "run") {
46
+ return handleRun(args, io, rootDir);
47
+ }
48
+ if (command === "follow-up") {
49
+ return handleFollowUp(args, io, rootDir);
50
+ }
51
+ if (command === "status") {
52
+ return handleStatus(args, io, rootDir);
53
+ }
54
+ if (command === "inspect") {
55
+ return handleInspect(args, io, rootDir);
56
+ }
57
+ if (command === "github") {
58
+ return handleGitHub(args, io, rootDir);
59
+ }
60
+ if (command === "job") {
61
+ return handleJob(args, io, rootDir);
62
+ }
63
+ if (command === "doctor") {
64
+ return handleDoctor(args, io, rootDir);
65
+ }
66
+ return handleWebhook(args, io, rootDir);
67
+ }
68
+
69
+ async function handleInit(args, io, rootDir) {
70
+ const json = args.includes("--json");
71
+ const force = args.includes("--force");
72
+ const unknown = args.filter((arg) => !["--json", "--force"].includes(arg));
73
+ if (unknown.length > 0) {
74
+ io.stderr.write("usage: assembly init [--force] [--json]\n");
75
+ return 2;
76
+ }
77
+
78
+ try {
79
+ const result = await initializeAssembly({ rootDir, force });
80
+ io.stdout.write(json ? `${JSON.stringify(result, null, 2)}\n` : formatInitResult(result));
81
+ return 0;
82
+ } catch (error) {
83
+ io.stderr.write(`error: ${error.message}\n`);
84
+ return 1;
85
+ }
86
+ }
87
+
88
+ function handlePlan(args, io, rootDir) {
89
+ const pretty = args.includes("--pretty");
90
+ const request = args.filter((arg) => arg !== "--pretty").join(" ");
91
+
92
+ let plan;
93
+ try {
94
+ plan = createPlan(request, { rootDir });
95
+ } catch (error) {
96
+ io.stderr.write(`error: ${error.message}\n`);
97
+ return 2;
98
+ }
99
+
100
+ const errors = validatePlan(plan);
101
+ if (errors.length > 0) {
102
+ for (const error of errors) {
103
+ io.stderr.write(`error: ${error}\n`);
104
+ }
105
+ return 1;
106
+ }
107
+
108
+ io.stdout.write(`${JSON.stringify(plan, null, pretty ? 2 : 0)}\n`);
109
+ return 0;
110
+ }
111
+
112
+ async function handleRun(args, io, rootDir) {
113
+ const pretty = args.includes("--pretty");
114
+ const request = args.filter((arg) => arg !== "--pretty").join(" ");
115
+
116
+ try {
117
+ const run = await createRun(request, { rootDir });
118
+ io.stdout.write(`${JSON.stringify(run, null, pretty ? 2 : 0)}\n`);
119
+ return 0;
120
+ } catch (error) {
121
+ io.stderr.write(`error: ${error.message}\n`);
122
+ return 1;
123
+ }
124
+ }
125
+
126
+ async function handleFollowUp(args, io, rootDir) {
127
+ const pretty = args.includes("--pretty");
128
+ const filteredArgs = args.filter((arg) => arg !== "--pretty");
129
+ const parentRunId = filteredArgs[0];
130
+ const feedback = filteredArgs.slice(1).join(" ");
131
+
132
+ if (!parentRunId || !feedback) {
133
+ io.stderr.write("usage: assembly follow-up <run-id> <feedback> [--pretty]\n");
134
+ return 2;
135
+ }
136
+
137
+ try {
138
+ const run = await createFollowUpRun(parentRunId, feedback, { rootDir });
139
+ io.stdout.write(`${JSON.stringify(run, null, pretty ? 2 : 0)}\n`);
140
+ return 0;
141
+ } catch (error) {
142
+ io.stderr.write(`error: ${error.message}\n`);
143
+ return 1;
144
+ }
145
+ }
146
+
147
+ async function handleStatus(args, io, rootDir) {
148
+ const runId = args[0];
149
+ if (!runId) {
150
+ io.stderr.write("usage: assembly status <run-id>\n");
151
+ return 2;
152
+ }
153
+
154
+ try {
155
+ const run = await readRun(runId, rootDir);
156
+ io.stdout.write(formatStatus(run));
157
+ return 0;
158
+ } catch (error) {
159
+ io.stderr.write(`error: unable to read run ${runId}: ${error.message}\n`);
160
+ return 1;
161
+ }
162
+ }
163
+
164
+ async function handleInspect(args, io, rootDir) {
165
+ const pretty = args.includes("--pretty");
166
+ const runId = args.find((arg) => arg !== "--pretty");
167
+ if (!runId) {
168
+ io.stderr.write("usage: assembly inspect <run-id> [--pretty]\n");
169
+ return 2;
170
+ }
171
+
172
+ try {
173
+ const run = await readRun(runId, rootDir);
174
+ io.stdout.write(`${JSON.stringify(run, null, pretty ? 2 : 0)}\n`);
175
+ return 0;
176
+ } catch (error) {
177
+ io.stderr.write(`error: unable to read run ${runId}: ${error.message}\n`);
178
+ return 1;
179
+ }
180
+ }
181
+
182
+ async function handleGitHub(args, io, rootDir) {
183
+ const [subcommand, ...rest] = args;
184
+ if (subcommand === "create-pr") {
185
+ const runId = rest[0];
186
+ if (!runId) {
187
+ io.stderr.write("usage: assembly github create-pr <run-id>\n");
188
+ return 2;
189
+ }
190
+
191
+ try {
192
+ const result = await createGitHubPullRequest(runId, { rootDir });
193
+ io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
194
+ return 0;
195
+ } catch (error) {
196
+ io.stderr.write(`error: ${error.message}\n`);
197
+ return 1;
198
+ }
199
+ }
200
+
201
+ if (subcommand === "comment-to-follow-up") {
202
+ const [parentRunId, commentId] = rest;
203
+ if (!parentRunId || !commentId) {
204
+ io.stderr.write("usage: assembly github comment-to-follow-up <run-id> <comment-id>\n");
205
+ return 2;
206
+ }
207
+
208
+ try {
209
+ const comment = await getGitHubComment(commentId, { rootDir });
210
+ const run = await createFollowUpRun(parentRunId, comment.body, {
211
+ rootDir,
212
+ source: {
213
+ provider: "github",
214
+ kind: "issue_comment",
215
+ id: comment.id,
216
+ url: comment.url,
217
+ },
218
+ });
219
+ io.stdout.write(`${JSON.stringify(run, null, 2)}\n`);
220
+ return 0;
221
+ } catch (error) {
222
+ io.stderr.write(`error: ${error.message}\n`);
223
+ return 1;
224
+ }
225
+ }
226
+
227
+ io.stderr.write("usage: assembly github <create-pr|comment-to-follow-up> ...\n");
228
+ return 2;
229
+ }
230
+
231
+ async function handleJob(args, io, rootDir) {
232
+ const [subcommand, jobId] = args;
233
+ const pretty = args.includes("--pretty");
234
+ const json = args.includes("--json");
235
+
236
+ if (subcommand === "list") {
237
+ const jobs = await listJobs(rootDir);
238
+ if (json) {
239
+ io.stdout.write(`${JSON.stringify(jobs, null, pretty ? 2 : 0)}\n`);
240
+ return 0;
241
+ }
242
+ io.stdout.write(formatJobs(jobs));
243
+ return 0;
244
+ }
245
+
246
+ if (subcommand === "inspect" && jobId) {
247
+ const job = await readJob(jobId, rootDir);
248
+ io.stdout.write(`${JSON.stringify(job, null, pretty ? 2 : 0)}\n`);
249
+ return 0;
250
+ }
251
+
252
+ if (subcommand === "process" && jobId) {
253
+ const job = await processJob(jobId, { rootDir });
254
+ io.stdout.write(`${JSON.stringify(job, null, 2)}\n`);
255
+ return job.status === "failed" ? 1 : 0;
256
+ }
257
+
258
+ if (subcommand === "retry" && jobId) {
259
+ await resetJobForRetry(jobId, rootDir);
260
+ const job = await processJob(jobId, { rootDir });
261
+ io.stdout.write(`${JSON.stringify(job, null, 2)}\n`);
262
+ return job.status === "failed" ? 1 : 0;
263
+ }
264
+
265
+ if (subcommand === "retry") {
266
+ io.stderr.write("usage: assembly job retry <job-id>\n");
267
+ return 2;
268
+ }
269
+
270
+ io.stderr.write("usage: assembly job <list|inspect|process|retry> ...\n");
271
+ return 2;
272
+ }
273
+
274
+ function handleWebhook(args, io, rootDir) {
275
+ const portIndex = args.indexOf("--port");
276
+ const port = portIndex === -1 ? 3000 : Number(args[portIndex + 1]);
277
+ if (!Number.isInteger(port) || port <= 0) {
278
+ io.stderr.write("usage: assembly webhook [--port <port>]\n");
279
+ return 2;
280
+ }
281
+
282
+ const server = startWebhookServer({ port, rootDir });
283
+ server.on("listening", () => {
284
+ io.stdout.write([
285
+ `Assembly webhook server listening on http://127.0.0.1:${port}`,
286
+ `GitHub: http://127.0.0.1:${port}/github/webhook`,
287
+ `Slack: http://127.0.0.1:${port}/slack/events`,
288
+ "",
289
+ ].join("\n"));
290
+ });
291
+ server.on("error", (error) => {
292
+ io.stderr.write(`error: unable to start webhook server: ${error.message}\n`);
293
+ process.exitCode = 1;
294
+ });
295
+ return 0;
296
+ }
297
+
298
+ async function handleDoctor(args, io, rootDir) {
299
+ const json = args.includes("--json");
300
+ const pretty = args.includes("--pretty");
301
+ const unknown = args.filter((arg) => !["--json", "--pretty"].includes(arg));
302
+ if (unknown.length > 0) {
303
+ io.stderr.write("usage: assembly doctor [--json] [--pretty]\n");
304
+ return 2;
305
+ }
306
+
307
+ const report = await runDoctor({ rootDir });
308
+ if (json) {
309
+ io.stdout.write(`${JSON.stringify(report, null, pretty ? 2 : 0)}\n`);
310
+ } else {
311
+ io.stdout.write(formatDoctorReport(report));
312
+ }
313
+ return report.ok ? 0 : 1;
314
+ }
315
+
316
+ function formatStatus({ request, state }) {
317
+ const taskLines = Object.entries(state.tasks)
318
+ .map(([taskId, task]) => `- ${task.status.padEnd(11)} ${taskId} (${task.owner})`)
319
+ .join("\n");
320
+
321
+ return [
322
+ `Run: ${state.runId}`,
323
+ `Status: ${state.status}`,
324
+ `Request: ${request.request}`,
325
+ "",
326
+ "Tasks:",
327
+ taskLines,
328
+ "",
329
+ ].join("\n");
330
+ }
331
+
332
+ function formatJobs(jobs) {
333
+ if (jobs.length === 0) {
334
+ return "No jobs found.\n";
335
+ }
336
+
337
+ const rows = jobs.map((job) => [
338
+ job.id,
339
+ job.status,
340
+ job.type,
341
+ describeJobTarget(job),
342
+ job.updatedAt ?? job.createdAt,
343
+ ]);
344
+ const headers = ["ID", "STATUS", "TYPE", "TARGET", "UPDATED"];
345
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => String(row[index] ?? "").length)));
346
+ const formatRow = (row) => row.map((value, index) => String(value ?? "").padEnd(widths[index])).join(" ");
347
+ return [
348
+ formatRow(headers),
349
+ formatRow(widths.map((width) => "-".repeat(width))),
350
+ ...rows.map(formatRow),
351
+ "",
352
+ ].join("\n");
353
+ }
354
+
355
+ function describeJobTarget(job) {
356
+ if (job.payload?.prNumber) {
357
+ return `PR #${job.payload.prNumber}`;
358
+ }
359
+ if (job.payload?.issueNumber) {
360
+ return `Issue #${job.payload.issueNumber}`;
361
+ }
362
+ return "";
363
+ }
364
+
365
+ function isDirectExecution() {
366
+ if (!process.argv[1]) {
367
+ return false;
368
+ }
369
+ if (path.basename(process.argv[1]) === "assembly") {
370
+ return true;
371
+ }
372
+ try {
373
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
374
+ } catch {
375
+ return false;
376
+ }
377
+ }
378
+
379
+ if (isDirectExecution()) {
380
+ process.exitCode = await main();
381
+ }
package/src/config.js ADDED
@@ -0,0 +1,90 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function loadEnv(rootDir = process.cwd()) {
5
+ const envPath = path.join(rootDir, ".env");
6
+ let contents;
7
+
8
+ try {
9
+ contents = await readFile(envPath, "utf8");
10
+ } catch (error) {
11
+ if (error.code === "ENOENT") {
12
+ return;
13
+ }
14
+ throw error;
15
+ }
16
+
17
+ for (const line of contents.split(/\r?\n/)) {
18
+ const parsed = parseEnvLine(line);
19
+ if (!parsed || process.env[parsed.key] !== undefined) {
20
+ continue;
21
+ }
22
+ process.env[parsed.key] = parsed.value;
23
+ }
24
+ }
25
+
26
+ export function getAgentProvider() {
27
+ return process.env.ASSEMBLY_AGENT_PROVIDER || "stub";
28
+ }
29
+
30
+ export function getApprovalMode() {
31
+ return process.env.ASSEMBLY_APPROVAL_MODE || "auto";
32
+ }
33
+
34
+ export function getGitHubWebhookSecret() {
35
+ return process.env.GITHUB_WEBHOOK_SECRET || "";
36
+ }
37
+
38
+ export function getSlackSigningSecret() {
39
+ return process.env.SLACK_SIGNING_SECRET || "";
40
+ }
41
+
42
+ export function getSlackBotToken() {
43
+ return process.env.SLACK_BOT_TOKEN || "";
44
+ }
45
+
46
+ export function getOpenAIConfig() {
47
+ const apiKey = process.env.OPENAI_API_KEY;
48
+ if (!apiKey) {
49
+ throw new Error("OPENAI_API_KEY is required when ASSEMBLY_AGENT_PROVIDER=openai");
50
+ }
51
+
52
+ return {
53
+ apiKey,
54
+ model: process.env.OPENAI_MODEL || "gpt-4.1-mini",
55
+ baseUrl: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1",
56
+ };
57
+ }
58
+
59
+ function parseEnvLine(line) {
60
+ const trimmed = line.trim();
61
+ if (!trimmed || trimmed.startsWith("#")) {
62
+ return null;
63
+ }
64
+
65
+ const equalsIndex = trimmed.indexOf("=");
66
+ if (equalsIndex === -1) {
67
+ return null;
68
+ }
69
+
70
+ const key = trimmed.slice(0, equalsIndex).trim();
71
+ const rawValue = trimmed.slice(equalsIndex + 1).trim();
72
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
73
+ return null;
74
+ }
75
+
76
+ return {
77
+ key,
78
+ value: unquote(rawValue),
79
+ };
80
+ }
81
+
82
+ function unquote(value) {
83
+ if (
84
+ (value.startsWith('"') && value.endsWith('"')) ||
85
+ (value.startsWith("'") && value.endsWith("'"))
86
+ ) {
87
+ return value.slice(1, -1);
88
+ }
89
+ return value;
90
+ }
@@ -0,0 +1,91 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const MAX_FILES = 24;
5
+ const MAX_TOTAL_CHARS = 60000;
6
+ const SKIP_DIRS = new Set([".git", ".assembly", "node_modules", "coverage", "dist", "build"]);
7
+
8
+ export async function buildTaskContext(task, rootDir = process.cwd()) {
9
+ const candidatePaths = [...task.scope.allowlist, ...task.scope.paths];
10
+ const files = [];
11
+ let totalChars = 0;
12
+
13
+ for (const candidatePath of candidatePaths) {
14
+ if (files.length >= MAX_FILES || totalChars >= MAX_TOTAL_CHARS) {
15
+ break;
16
+ }
17
+
18
+ const discovered = await discoverFiles(rootDir, candidatePath);
19
+ for (const filePath of discovered) {
20
+ if (files.length >= MAX_FILES || totalChars >= MAX_TOTAL_CHARS) {
21
+ break;
22
+ }
23
+
24
+ const content = await readFile(path.join(rootDir, filePath), "utf8").catch(() => null);
25
+ if (content === null) {
26
+ continue;
27
+ }
28
+
29
+ const remainingChars = MAX_TOTAL_CHARS - totalChars;
30
+ const truncatedContent = content.slice(0, remainingChars);
31
+ files.push({
32
+ path: filePath,
33
+ content: truncatedContent,
34
+ truncated: truncatedContent.length < content.length,
35
+ });
36
+ totalChars += truncatedContent.length;
37
+ }
38
+ }
39
+
40
+ return {
41
+ files,
42
+ limits: {
43
+ maxFiles: MAX_FILES,
44
+ maxTotalChars: MAX_TOTAL_CHARS,
45
+ truncated: files.length >= MAX_FILES || totalChars >= MAX_TOTAL_CHARS,
46
+ },
47
+ };
48
+ }
49
+
50
+ async function discoverFiles(rootDir, scopePath) {
51
+ const normalized = scopePath.replaceAll("\\", "/");
52
+ const absolutePath = path.join(rootDir, normalized);
53
+ const stats = await stat(absolutePath).catch(() => null);
54
+ if (!stats) {
55
+ return [];
56
+ }
57
+
58
+ if (stats.isFile()) {
59
+ return [normalized];
60
+ }
61
+
62
+ if (!stats.isDirectory()) {
63
+ return [];
64
+ }
65
+
66
+ return walkDirectory(rootDir, normalized);
67
+ }
68
+
69
+ async function walkDirectory(rootDir, relativeDir) {
70
+ const entries = await readdir(path.join(rootDir, relativeDir), { withFileTypes: true }).catch(() => []);
71
+ const files = [];
72
+
73
+ for (const entry of entries) {
74
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) {
75
+ continue;
76
+ }
77
+
78
+ const childPath = path.posix.join(relativeDir, entry.name);
79
+ if (entry.isDirectory()) {
80
+ files.push(...(await walkDirectory(rootDir, childPath)));
81
+ } else if (entry.isFile() && isTextFile(childPath)) {
82
+ files.push(childPath);
83
+ }
84
+ }
85
+
86
+ return files.sort();
87
+ }
88
+
89
+ function isTextFile(filePath) {
90
+ return /\.(cjs|css|js|json|jsx|md|mjs|ts|tsx|txt|yaml|yml)$/i.test(filePath);
91
+ }