@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.
- package/.env.example +7 -0
- package/LICENSE +674 -0
- package/README.md +455 -0
- package/ngrok-example.yml +9 -0
- package/package.json +23 -0
- package/src/agent-profiles.js +167 -0
- package/src/agent-runner.js +37 -0
- package/src/approval.js +45 -0
- package/src/cli.js +381 -0
- package/src/config.js +90 -0
- package/src/context-builder.js +91 -0
- package/src/doctor.js +151 -0
- package/src/file-updates.js +89 -0
- package/src/follow-up.js +33 -0
- package/src/git-worktree.js +35 -0
- package/src/github-webhooks.js +159 -0
- package/src/github.js +261 -0
- package/src/init.js +91 -0
- package/src/job-store.js +97 -0
- package/src/job-worker.js +390 -0
- package/src/models.js +55 -0
- package/src/openai-agent-runner.js +141 -0
- package/src/orchestrator.js +221 -0
- package/src/patch.js +78 -0
- package/src/planner.js +279 -0
- package/src/repo-inspector.js +88 -0
- package/src/report.js +46 -0
- package/src/result-validation.js +55 -0
- package/src/review-agent-runner.js +127 -0
- package/src/root.js +51 -0
- package/src/run-store.js +104 -0
- package/src/scope.js +107 -0
- package/src/slack-thread-store.js +34 -0
- package/src/slack-webhooks.js +120 -0
- package/src/slack.js +31 -0
- package/src/validation.js +33 -0
- package/src/verification-runner.js +51 -0
- package/src/webhook-server.js +64 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createAgentRunner } from "./agent-runner.js";
|
|
2
|
+
import { resolveApproval } from "./approval.js";
|
|
3
|
+
import { applyFileUpdates, validateAdditiveFileUpdates } from "./file-updates.js";
|
|
4
|
+
import { applyPatch } from "./patch.js";
|
|
5
|
+
import { createPlan } from "./planner.js";
|
|
6
|
+
import { createFinalReport } from "./report.js";
|
|
7
|
+
import { validateTaskResult } from "./result-validation.js";
|
|
8
|
+
import { validatePlan } from "./validation.js";
|
|
9
|
+
import { runVerificationCommands } from "./verification-runner.js";
|
|
10
|
+
import {
|
|
11
|
+
appendEvent,
|
|
12
|
+
createRunId,
|
|
13
|
+
initializeRun,
|
|
14
|
+
readRun,
|
|
15
|
+
writeArtifact,
|
|
16
|
+
writeRunFile,
|
|
17
|
+
writeState,
|
|
18
|
+
} from "./run-store.js";
|
|
19
|
+
|
|
20
|
+
export async function createRun(request, { rootDir = process.cwd(), agentRunner, metadata = {} } = {}) {
|
|
21
|
+
const resolvedAgentRunner = agentRunner ?? createAgentRunner();
|
|
22
|
+
const plan = createPlan(request, { rootDir });
|
|
23
|
+
const errors = validatePlan(plan);
|
|
24
|
+
if (errors.length > 0) {
|
|
25
|
+
throw new Error(errors.join("\n"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const runId = createRunId();
|
|
29
|
+
const state = await initializeRun({ runId, request: plan.request, plan, metadata }, rootDir);
|
|
30
|
+
|
|
31
|
+
await executeReadyTasks({ runId, plan, state, rootDir, agentRunner: resolvedAgentRunner });
|
|
32
|
+
|
|
33
|
+
return { runId, plan, state };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function executeReadyTasks({ runId, plan, state, rootDir, agentRunner }) {
|
|
37
|
+
state.status = "running";
|
|
38
|
+
await writeState(runId, state, rootDir);
|
|
39
|
+
await appendEvent(runId, { type: "run.started" }, rootDir);
|
|
40
|
+
|
|
41
|
+
while (true) {
|
|
42
|
+
const task = nextReadyTask(plan, state);
|
|
43
|
+
if (!task) {
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
state.currentTaskId = task.id;
|
|
48
|
+
state.tasks[task.id].status = "in_progress";
|
|
49
|
+
await writeState(runId, state, rootDir);
|
|
50
|
+
await appendEvent(runId, { type: "task.started", taskId: task.id }, rootDir);
|
|
51
|
+
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await agentRunner(task, { rootDir, plan, state, runId });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const failure = {
|
|
57
|
+
taskId: task.id,
|
|
58
|
+
status: "failed",
|
|
59
|
+
summary: "Agent execution failed.",
|
|
60
|
+
changedFiles: [],
|
|
61
|
+
artifacts: [],
|
|
62
|
+
risks: [error.message],
|
|
63
|
+
};
|
|
64
|
+
state.tasks[task.id].status = "failed";
|
|
65
|
+
state.currentTaskId = null;
|
|
66
|
+
await writeState(runId, state, rootDir);
|
|
67
|
+
await appendEvent(runId, { type: "task.failed", taskId: task.id, data: failure }, rootDir);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
const resultErrors = validateTaskResult(task, result);
|
|
71
|
+
resultErrors.push(...(await validateAdditiveFileUpdates(task, result, rootDir)));
|
|
72
|
+
await writeArtifact(runId, task.id, "result.json", result, rootDir);
|
|
73
|
+
if (result.patch) {
|
|
74
|
+
await writeRunFile(runId, `artifacts/${task.id}/patch.diff`, result.patch, rootDir);
|
|
75
|
+
}
|
|
76
|
+
if (result.fileUpdates?.length > 0) {
|
|
77
|
+
await writeArtifact(runId, task.id, "file-updates.json", result.fileUpdates, rootDir);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (resultErrors.length > 0) {
|
|
81
|
+
const failure = {
|
|
82
|
+
taskId: task.id,
|
|
83
|
+
status: "failed",
|
|
84
|
+
summary: "Agent result failed contract validation.",
|
|
85
|
+
changedFiles: [],
|
|
86
|
+
artifacts: ["result.json"],
|
|
87
|
+
risks: resultErrors,
|
|
88
|
+
};
|
|
89
|
+
state.tasks[task.id].status = "failed";
|
|
90
|
+
state.currentTaskId = null;
|
|
91
|
+
await writeArtifact(runId, task.id, "validation-errors.json", { errors: resultErrors }, rootDir);
|
|
92
|
+
await writeState(runId, state, rootDir);
|
|
93
|
+
await appendEvent(runId, { type: "task.failed", taskId: task.id, data: failure }, rootDir);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const approval = resolveApproval({ task, result });
|
|
98
|
+
await appendEvent(runId, { type: `approval.${approval.status}`, taskId: task.id, data: approval }, rootDir);
|
|
99
|
+
if (!approval.approved) {
|
|
100
|
+
const status = approval.status === "dry_run" || approval.status === "pending" ? "blocked" : "failed";
|
|
101
|
+
const failure = {
|
|
102
|
+
taskId: task.id,
|
|
103
|
+
status,
|
|
104
|
+
summary: approval.reason,
|
|
105
|
+
changedFiles: result.changedFiles,
|
|
106
|
+
artifacts: result.artifacts,
|
|
107
|
+
risks: [approval.reason],
|
|
108
|
+
};
|
|
109
|
+
state.tasks[task.id].status = status;
|
|
110
|
+
state.currentTaskId = null;
|
|
111
|
+
await writeState(runId, state, rootDir);
|
|
112
|
+
await appendEvent(runId, { type: `task.${status}`, taskId: task.id, data: failure }, rootDir);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.status === "complete" && result.fileUpdates?.length > 0) {
|
|
117
|
+
try {
|
|
118
|
+
await applyFileUpdates(result.fileUpdates, rootDir);
|
|
119
|
+
await appendEvent(runId, { type: "files.updated", taskId: task.id, data: { changedFiles: result.changedFiles } }, rootDir);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const failure = {
|
|
122
|
+
taskId: task.id,
|
|
123
|
+
status: "failed",
|
|
124
|
+
summary: "File updates failed to apply.",
|
|
125
|
+
changedFiles: result.changedFiles,
|
|
126
|
+
artifacts: ["result.json", "file-updates.json"],
|
|
127
|
+
risks: [error.message],
|
|
128
|
+
};
|
|
129
|
+
state.tasks[task.id].status = "failed";
|
|
130
|
+
state.currentTaskId = null;
|
|
131
|
+
await writeArtifact(runId, task.id, "file-update-errors.json", { error: error.message }, rootDir);
|
|
132
|
+
await writeState(runId, state, rootDir);
|
|
133
|
+
await appendEvent(runId, { type: "task.failed", taskId: task.id, data: failure }, rootDir);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (result.status === "complete" && result.patch) {
|
|
139
|
+
try {
|
|
140
|
+
await applyPatch(result.patch, rootDir);
|
|
141
|
+
await appendEvent(runId, { type: "patch.applied", taskId: task.id, data: { changedFiles: result.changedFiles } }, rootDir);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const failure = {
|
|
144
|
+
taskId: task.id,
|
|
145
|
+
status: "failed",
|
|
146
|
+
summary: "Patch failed to apply.",
|
|
147
|
+
changedFiles: result.changedFiles,
|
|
148
|
+
artifacts: ["result.json", "patch.diff"],
|
|
149
|
+
risks: [error.message],
|
|
150
|
+
};
|
|
151
|
+
state.tasks[task.id].status = "failed";
|
|
152
|
+
state.currentTaskId = null;
|
|
153
|
+
await writeArtifact(runId, task.id, "patch-errors.json", { error: error.message }, rootDir);
|
|
154
|
+
await writeState(runId, state, rootDir);
|
|
155
|
+
await appendEvent(runId, { type: "task.failed", taskId: task.id, data: failure }, rootDir);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (result.status === "complete" && task.owner === "implementation-agent" && plan.verification.length > 0) {
|
|
161
|
+
await runVerification(runId, plan, rootDir);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
state.tasks[task.id].status = result.status;
|
|
165
|
+
state.currentTaskId = null;
|
|
166
|
+
await writeState(runId, state, rootDir);
|
|
167
|
+
await appendEvent(runId, { type: `task.${result.status}`, taskId: task.id, data: result }, rootDir);
|
|
168
|
+
|
|
169
|
+
if (result.status !== "complete") {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
state.status = determineRunStatus(state);
|
|
175
|
+
|
|
176
|
+
if (state.status === "complete" && hasFailedVerification(await readRun(runId, rootDir))) {
|
|
177
|
+
state.status = "failed";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await writeState(runId, state, rootDir);
|
|
181
|
+
await appendEvent(runId, { type: `run.${state.status}` }, rootDir);
|
|
182
|
+
|
|
183
|
+
const run = await readRun(runId, rootDir);
|
|
184
|
+
await writeRunFile(runId, "final-report.md", createFinalReport(run), rootDir);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function runVerification(runId, plan, rootDir) {
|
|
188
|
+
await appendEvent(runId, { type: "verification.started", data: { commands: plan.verification } }, rootDir);
|
|
189
|
+
const verificationResults = await runVerificationCommands(plan.verification, rootDir);
|
|
190
|
+
await writeArtifact(runId, "verification", "result.json", verificationResults, rootDir);
|
|
191
|
+
await appendEvent(runId, { type: "verification.completed", data: { results: verificationResults } }, rootDir);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hasFailedVerification(run) {
|
|
195
|
+
return run.events
|
|
196
|
+
.filter((event) => event.type === "verification.completed")
|
|
197
|
+
.some((event) => event.data.results.some((result) => result.exitCode !== 0));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function nextReadyTask(plan, state) {
|
|
201
|
+
return plan.tasks.find((task) => {
|
|
202
|
+
if (state.tasks[task.id].status !== "pending") {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
return task.dependencies.every((dependency) => state.tasks[dependency]?.status === "complete");
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function allTasksComplete(state) {
|
|
210
|
+
return Object.values(state.tasks).every((task) => task.status === "complete");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function determineRunStatus(state) {
|
|
214
|
+
if (allTasksComplete(state)) {
|
|
215
|
+
return "complete";
|
|
216
|
+
}
|
|
217
|
+
if (Object.values(state.tasks).some((task) => task.status === "failed")) {
|
|
218
|
+
return "failed";
|
|
219
|
+
}
|
|
220
|
+
return "blocked";
|
|
221
|
+
}
|
package/src/patch.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { validateChangedFilesWithinScope } from "./scope.js";
|
|
4
|
+
|
|
5
|
+
export function extractPatchChangedFiles(patch) {
|
|
6
|
+
const files = new Set();
|
|
7
|
+
|
|
8
|
+
for (const line of String(patch ?? "").split(/\r?\n/)) {
|
|
9
|
+
if (line.startsWith("diff --git ")) {
|
|
10
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
11
|
+
if (match) {
|
|
12
|
+
files.add(match[1]);
|
|
13
|
+
files.add(match[2]);
|
|
14
|
+
}
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (line.startsWith("+++ ") || line.startsWith("--- ")) {
|
|
19
|
+
const rawPath = line.slice(4).trim().split(/\t/)[0];
|
|
20
|
+
if (rawPath === "/dev/null") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
files.add(rawPath.replace(/^a\//, "").replace(/^b\//, ""));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [...files].sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validatePatchForTask(task, result) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
if (!result.patch) {
|
|
33
|
+
return errors;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const patchFiles = extractPatchChangedFiles(result.patch);
|
|
37
|
+
if (patchFiles.length === 0) {
|
|
38
|
+
errors.push(`task ${task.id} patch does not include changed files`);
|
|
39
|
+
return errors;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
errors.push(...validateChangedFilesWithinScope(task, patchFiles));
|
|
43
|
+
|
|
44
|
+
const declaredFiles = new Set(result.changedFiles);
|
|
45
|
+
for (const patchFile of patchFiles) {
|
|
46
|
+
if (!declaredFiles.has(patchFile)) {
|
|
47
|
+
errors.push(`task ${task.id} patch changes ${patchFile} but result.changedFiles does not list it`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function applyPatch(patch, rootDir = process.cwd()) {
|
|
55
|
+
await runGitApply(["apply", "--check", "-"], patch, rootDir);
|
|
56
|
+
await runGitApply(["apply", "-"], patch, rootDir);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runGitApply(args, patch, rootDir) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const child = spawn("git", args, { cwd: rootDir });
|
|
62
|
+
let stderr = "";
|
|
63
|
+
|
|
64
|
+
child.stderr.on("data", (chunk) => {
|
|
65
|
+
stderr += chunk;
|
|
66
|
+
});
|
|
67
|
+
child.on("error", reject);
|
|
68
|
+
child.on("close", (code) => {
|
|
69
|
+
if (code === 0) {
|
|
70
|
+
resolve();
|
|
71
|
+
} else {
|
|
72
|
+
reject(new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.stdin.end(patch);
|
|
77
|
+
});
|
|
78
|
+
}
|
package/src/planner.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { attachAgentProfiles } from "./agent-profiles.js";
|
|
2
|
+
import { createExecutionPlan, createTask } from "./models.js";
|
|
3
|
+
import { inspectRepository } from "./repo-inspector.js";
|
|
4
|
+
|
|
5
|
+
export function createPlan(request, { rootDir = process.cwd(), repoContext = inspectRepository(rootDir) } = {}) {
|
|
6
|
+
const normalizedRequest = normalizeRequest(request);
|
|
7
|
+
const slug = slugify(normalizedRequest);
|
|
8
|
+
const implementationScope = repoContext.suggestedScopes.implementation;
|
|
9
|
+
const reviewScope = repoContext.suggestedScopes.review;
|
|
10
|
+
const verification = repoContext.verificationCommands;
|
|
11
|
+
const changePolicy = normalizedRequest.toLowerCase().startsWith("add ") ? "additive" : "modify";
|
|
12
|
+
const requestKind = classifyRequest(normalizedRequest);
|
|
13
|
+
const targetContext = inferTargetContext(normalizedRequest, repoContext);
|
|
14
|
+
const implementationTasks = createImplementationTasks({
|
|
15
|
+
slug,
|
|
16
|
+
requestKind,
|
|
17
|
+
implementationScope,
|
|
18
|
+
changePolicy,
|
|
19
|
+
targetContext,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const baseTasks = [
|
|
23
|
+
createTask({
|
|
24
|
+
id: `${slug}-plan`,
|
|
25
|
+
title: "Define implementation plan",
|
|
26
|
+
owner: "planner",
|
|
27
|
+
description:
|
|
28
|
+
`Inspect the ${repoContext.language ?? "unknown"} repository, clarify scope, and produce task ownership with acceptance criteria.`,
|
|
29
|
+
files: ["README.md"],
|
|
30
|
+
scope: {
|
|
31
|
+
paths: ["README.md"],
|
|
32
|
+
allowlist: [],
|
|
33
|
+
denylist: [".env", ".env.*", ".git/"],
|
|
34
|
+
},
|
|
35
|
+
acceptanceCriteria: [
|
|
36
|
+
"Plan lists owned files or systems for each task.",
|
|
37
|
+
"Plan identifies blockers, dependencies, and verification steps.",
|
|
38
|
+
"Plan uses repository structure to narrow implementation and review scope where possible.",
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
...implementationTasks,
|
|
42
|
+
createTask({
|
|
43
|
+
id: `${slug}-verify`,
|
|
44
|
+
title: "Verify and review output",
|
|
45
|
+
owner: "review-agent",
|
|
46
|
+
description:
|
|
47
|
+
"Run relevant checks, review the diff, and summarize risks before human handoff.",
|
|
48
|
+
scope: reviewScope,
|
|
49
|
+
dependencies: implementationTasks.map((task) => task.id),
|
|
50
|
+
acceptanceCriteria: [
|
|
51
|
+
"Automated checks pass or failures are documented.",
|
|
52
|
+
"Review notes include known risks and follow-up recommendations.",
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
];
|
|
56
|
+
const { tasks, agentProfiles } = attachAgentProfiles(baseTasks);
|
|
57
|
+
|
|
58
|
+
return createExecutionPlan({
|
|
59
|
+
request: normalizedRequest,
|
|
60
|
+
summary: `Coordinate delivery for: ${normalizedRequest}`,
|
|
61
|
+
tasks,
|
|
62
|
+
agentProfiles,
|
|
63
|
+
risks: [
|
|
64
|
+
targetContext.files.length > 0
|
|
65
|
+
? `Planner inferred likely target files: ${targetContext.files.join(", ")}.`
|
|
66
|
+
: "Planner could not infer exact target files; implementation scope remains broader.",
|
|
67
|
+
"Planner is deterministic and repository-aware; deeper semantic decomposition is still evolving.",
|
|
68
|
+
],
|
|
69
|
+
verification,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function classifyRequest(request) {
|
|
74
|
+
const lower = request.toLowerCase();
|
|
75
|
+
if (/\b(readme|docs?|documentation)\b/.test(lower)) {
|
|
76
|
+
return "docs";
|
|
77
|
+
}
|
|
78
|
+
if (/\b(test|tests|coverage|spec)\b/.test(lower)) {
|
|
79
|
+
return "tests";
|
|
80
|
+
}
|
|
81
|
+
if (/\b(refactor|cleanup|rename)\b/.test(lower)) {
|
|
82
|
+
return "refactor";
|
|
83
|
+
}
|
|
84
|
+
return "code";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createImplementationTasks({ slug, requestKind, implementationScope, changePolicy, targetContext }) {
|
|
88
|
+
if (requestKind === "docs") {
|
|
89
|
+
const docsScope = targetContext.docs.length > 0
|
|
90
|
+
? scopeForFiles(targetContext.docs, [".env", ".env.*", ".git/", ".assembly/"])
|
|
91
|
+
: {
|
|
92
|
+
paths: [],
|
|
93
|
+
allowlist: ["README.md"],
|
|
94
|
+
denylist: [".env", ".env.*", ".git/", ".assembly/"],
|
|
95
|
+
};
|
|
96
|
+
return [
|
|
97
|
+
createTask({
|
|
98
|
+
id: `${slug}-docs`,
|
|
99
|
+
title: "Update documentation",
|
|
100
|
+
owner: "implementation-agent",
|
|
101
|
+
description: "Make the requested documentation change while preserving unrelated content.",
|
|
102
|
+
scope: docsScope,
|
|
103
|
+
changePolicy,
|
|
104
|
+
dependencies: [`${slug}-plan`],
|
|
105
|
+
acceptanceCriteria: [
|
|
106
|
+
"Documentation change directly addresses the request.",
|
|
107
|
+
"Unrelated documentation content remains intact.",
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (requestKind === "tests") {
|
|
114
|
+
const testScope = targetContext.tests.length > 0
|
|
115
|
+
? scopeForFiles(targetContext.tests, [".env", ".env.*", ".git/", ".assembly/"])
|
|
116
|
+
: {
|
|
117
|
+
paths: ["tests/"],
|
|
118
|
+
allowlist: ["package.json", "package-lock.json"],
|
|
119
|
+
denylist: [".env", ".env.*", ".git/", ".assembly/"],
|
|
120
|
+
};
|
|
121
|
+
return [
|
|
122
|
+
createTask({
|
|
123
|
+
id: `${slug}-tests`,
|
|
124
|
+
title: "Update tests",
|
|
125
|
+
owner: "implementation-agent",
|
|
126
|
+
description: "Add or update tests for the requested behavior.",
|
|
127
|
+
scope: testScope,
|
|
128
|
+
changePolicy,
|
|
129
|
+
dependencies: [`${slug}-plan`],
|
|
130
|
+
acceptanceCriteria: [
|
|
131
|
+
"Tests cover the requested behavior or regression.",
|
|
132
|
+
"Test changes stay within the assigned test scope.",
|
|
133
|
+
],
|
|
134
|
+
}),
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (requestKind === "refactor") {
|
|
139
|
+
return [
|
|
140
|
+
createTask({
|
|
141
|
+
id: `${slug}-refactor`,
|
|
142
|
+
title: "Refactor implementation",
|
|
143
|
+
owner: "implementation-agent",
|
|
144
|
+
description: "Refactor the relevant implementation while preserving behavior.",
|
|
145
|
+
scope: targetContext.implementation.length > 0
|
|
146
|
+
? scopeForFiles(targetContext.implementation, [".env", ".env.*", ".git/", ".assembly/"])
|
|
147
|
+
: implementationScope,
|
|
148
|
+
changePolicy,
|
|
149
|
+
dependencies: [`${slug}-plan`],
|
|
150
|
+
acceptanceCriteria: [
|
|
151
|
+
"Behavior remains unchanged unless the request explicitly says otherwise.",
|
|
152
|
+
"Refactor stays inside the assigned ownership scope.",
|
|
153
|
+
],
|
|
154
|
+
}),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const codeScope = targetContext.implementation.length > 0
|
|
159
|
+
? scopeForFiles(targetContext.implementation, [".env", ".env.*", ".git/", ".assembly/"])
|
|
160
|
+
: implementationScope;
|
|
161
|
+
const tasks = [
|
|
162
|
+
createTask({
|
|
163
|
+
id: `${slug}-implement`,
|
|
164
|
+
title: "Implement requested change",
|
|
165
|
+
owner: "implementation-agent",
|
|
166
|
+
description:
|
|
167
|
+
"Make the smallest coherent code changes needed to satisfy the approved plan.",
|
|
168
|
+
scope: codeScope,
|
|
169
|
+
changePolicy,
|
|
170
|
+
dependencies: [`${slug}-plan`],
|
|
171
|
+
acceptanceCriteria: [
|
|
172
|
+
"Changes are limited to the assigned ownership area.",
|
|
173
|
+
"Implementation satisfies the request and preserves existing behavior.",
|
|
174
|
+
],
|
|
175
|
+
}),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
if (targetContext.tests.length > 0) {
|
|
179
|
+
tasks.push(createTask({
|
|
180
|
+
id: `${slug}-tests`,
|
|
181
|
+
title: "Update targeted tests",
|
|
182
|
+
owner: "implementation-agent",
|
|
183
|
+
description: "Update tests directly related to the inferred implementation target.",
|
|
184
|
+
scope: scopeForFiles(targetContext.tests, [".env", ".env.*", ".git/", ".assembly/"]),
|
|
185
|
+
changePolicy,
|
|
186
|
+
dependencies: [`${slug}-implement`],
|
|
187
|
+
acceptanceCriteria: [
|
|
188
|
+
"Tests cover the requested behavior or regression.",
|
|
189
|
+
"Test changes correspond to the inferred implementation target.",
|
|
190
|
+
],
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return tasks;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function inferTargetContext(request, repoContext) {
|
|
198
|
+
const sourceFiles = repoContext.sourceFiles ?? [];
|
|
199
|
+
const testFiles = repoContext.testFiles ?? [];
|
|
200
|
+
const docs = repoContext.documentationFiles ?? [];
|
|
201
|
+
const configFiles = repoContext.configFiles ?? [];
|
|
202
|
+
const candidates = [...sourceFiles, ...testFiles, ...docs, ...configFiles];
|
|
203
|
+
const terms = new Set(tokenize(request));
|
|
204
|
+
const mentionedFiles = candidates.filter((file) => fileMatchesTerms(file, terms));
|
|
205
|
+
const implementation = mentionedFiles.filter((file) => file.startsWith("src/") || isConfigFile(file));
|
|
206
|
+
const tests = [
|
|
207
|
+
...mentionedFiles.filter((file) => file.startsWith("tests/")),
|
|
208
|
+
...findRelatedTestFiles(implementation, testFiles),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
files: unique([...implementation, ...tests, ...mentionedFiles.filter((file) => docs.includes(file))]),
|
|
213
|
+
implementation: unique(implementation),
|
|
214
|
+
tests: unique(tests),
|
|
215
|
+
docs: unique(mentionedFiles.filter((file) => docs.includes(file))),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function tokenize(value) {
|
|
220
|
+
return String(value ?? "")
|
|
221
|
+
.toLowerCase()
|
|
222
|
+
.split(/[^a-z0-9]+/)
|
|
223
|
+
.filter((token) => token.length >= 3 && !GENERIC_MATCH_TERMS.has(token));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const GENERIC_MATCH_TERMS = new Set([
|
|
227
|
+
"add",
|
|
228
|
+
"change",
|
|
229
|
+
"code",
|
|
230
|
+
"docs",
|
|
231
|
+
"file",
|
|
232
|
+
"fix",
|
|
233
|
+
"for",
|
|
234
|
+
"implementation",
|
|
235
|
+
"improve",
|
|
236
|
+
"src",
|
|
237
|
+
"test",
|
|
238
|
+
"tests",
|
|
239
|
+
"update",
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
function fileMatchesTerms(file, terms) {
|
|
243
|
+
const fileTokens = tokenize(file);
|
|
244
|
+
return fileTokens.some((token) => terms.has(token));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findRelatedTestFiles(implementationFiles, testFiles) {
|
|
248
|
+
const implementationTerms = new Set(implementationFiles.flatMap((file) => tokenize(file)));
|
|
249
|
+
return testFiles.filter((file) => tokenize(file).some((token) => implementationTerms.has(token)));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function scopeForFiles(files, denylist) {
|
|
253
|
+
return {
|
|
254
|
+
paths: [],
|
|
255
|
+
allowlist: unique(files).sort(),
|
|
256
|
+
denylist,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isConfigFile(file) {
|
|
261
|
+
return /(^|\/)(package(-lock)?\.json|\.gitignore|eslint|prettier|tsconfig|vite|webpack|rollup)/i.test(file);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function unique(values) {
|
|
265
|
+
return [...new Set(values)].sort();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeRequest(request) {
|
|
269
|
+
const normalized = String(request ?? "").trim().replace(/\s+/g, " ");
|
|
270
|
+
if (!normalized) {
|
|
271
|
+
throw new Error("request cannot be empty");
|
|
272
|
+
}
|
|
273
|
+
return normalized;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function slugify(value) {
|
|
277
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
278
|
+
return slug.slice(0, 32).replace(/-$/g, "") || "request";
|
|
279
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function inspectRepository(rootDir = process.cwd()) {
|
|
5
|
+
const files = listTopLevelFiles(rootDir);
|
|
6
|
+
const hasPackageJson = files.includes("package.json");
|
|
7
|
+
const hasTests = existsSync(path.join(rootDir, "tests"));
|
|
8
|
+
const allFiles = listRepositoryFiles(rootDir);
|
|
9
|
+
const packageJson = readPackageJson(rootDir);
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
rootDir,
|
|
13
|
+
files,
|
|
14
|
+
sourceFiles: allFiles.filter((file) => file.startsWith("src/")),
|
|
15
|
+
testFiles: allFiles.filter((file) => file.startsWith("tests/")),
|
|
16
|
+
documentationFiles: allFiles.filter((file) => /(^|\/)(readme|docs?|documentation)|\.md$/i.test(file)),
|
|
17
|
+
configFiles: allFiles.filter((file) => /(^|\/)(package(-lock)?\.json|\.gitignore|eslint|prettier|tsconfig|vite|webpack|rollup)/i.test(file)),
|
|
18
|
+
language: hasPackageJson ? "javascript" : "unknown",
|
|
19
|
+
packageManager: hasPackageJson ? "npm" : null,
|
|
20
|
+
scripts: packageJson?.scripts ?? {},
|
|
21
|
+
verificationCommands: hasPackageJson ? ["npm test"] : [],
|
|
22
|
+
suggestedScopes: {
|
|
23
|
+
implementation: {
|
|
24
|
+
paths: ["src/", "tests/"],
|
|
25
|
+
allowlist: ["package.json", "package-lock.json", "README.md", ".gitignore"],
|
|
26
|
+
denylist: [".env", ".env.*", ".git/", ".assembly/"],
|
|
27
|
+
},
|
|
28
|
+
review: {
|
|
29
|
+
paths: hasTests ? ["tests/", ".assembly/"] : [".assembly/"],
|
|
30
|
+
allowlist: ["README.md"],
|
|
31
|
+
denylist: [".env", ".env.*", ".git/"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function listTopLevelFiles(rootDir) {
|
|
38
|
+
try {
|
|
39
|
+
return readdirSync(rootDir, { withFileTypes: true })
|
|
40
|
+
.filter((entry) => entry.isFile() || entry.isDirectory())
|
|
41
|
+
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
|
|
42
|
+
.sort();
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listRepositoryFiles(rootDir) {
|
|
49
|
+
const ignoredDirectories = new Set([".git", ".assembly", "node_modules", "dist", "build", "coverage"]);
|
|
50
|
+
const results = [];
|
|
51
|
+
|
|
52
|
+
function visit(relativeDir = "") {
|
|
53
|
+
const absoluteDir = path.join(rootDir, relativeDir);
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(absoluteDir, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.name.startsWith(".") && ![".gitignore"].includes(entry.name)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const relativePath = path.posix.join(relativeDir.split(path.sep).join(path.posix.sep), entry.name);
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (!ignoredDirectories.has(entry.name)) {
|
|
68
|
+
visit(relativePath);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (entry.isFile()) {
|
|
73
|
+
results.push(relativePath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
visit();
|
|
79
|
+
return results.sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readPackageJson(rootDir) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|