@kodrunhq/opencode-autopilot 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/LICENSE +21 -0
- package/README.md +1 -0
- package/assets/agents/placeholder-agent.md +13 -0
- package/assets/commands/configure.md +17 -0
- package/assets/commands/new-agent.md +16 -0
- package/assets/commands/new-command.md +15 -0
- package/assets/commands/new-skill.md +15 -0
- package/assets/commands/review-pr.md +49 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/coding-standards/SKILL.md +327 -0
- package/package.json +52 -0
- package/src/agents/autopilot.ts +42 -0
- package/src/agents/documenter.ts +44 -0
- package/src/agents/index.ts +49 -0
- package/src/agents/metaprompter.ts +50 -0
- package/src/agents/pipeline/index.ts +25 -0
- package/src/agents/pipeline/oc-architect.ts +49 -0
- package/src/agents/pipeline/oc-challenger.ts +44 -0
- package/src/agents/pipeline/oc-critic.ts +42 -0
- package/src/agents/pipeline/oc-explorer.ts +46 -0
- package/src/agents/pipeline/oc-implementer.ts +56 -0
- package/src/agents/pipeline/oc-planner.ts +45 -0
- package/src/agents/pipeline/oc-researcher.ts +46 -0
- package/src/agents/pipeline/oc-retrospector.ts +42 -0
- package/src/agents/pipeline/oc-reviewer.ts +44 -0
- package/src/agents/pipeline/oc-shipper.ts +42 -0
- package/src/agents/pr-reviewer.ts +74 -0
- package/src/agents/researcher.ts +43 -0
- package/src/config.ts +168 -0
- package/src/index.ts +152 -0
- package/src/installer.ts +130 -0
- package/src/orchestrator/arena.ts +41 -0
- package/src/orchestrator/artifacts.ts +28 -0
- package/src/orchestrator/confidence.ts +59 -0
- package/src/orchestrator/fallback/chat-message-handler.ts +49 -0
- package/src/orchestrator/fallback/error-classifier.ts +148 -0
- package/src/orchestrator/fallback/event-handler.ts +235 -0
- package/src/orchestrator/fallback/fallback-config.ts +16 -0
- package/src/orchestrator/fallback/fallback-manager.ts +323 -0
- package/src/orchestrator/fallback/fallback-state.ts +120 -0
- package/src/orchestrator/fallback/index.ts +11 -0
- package/src/orchestrator/fallback/message-replay.ts +40 -0
- package/src/orchestrator/fallback/resolve-chain.ts +34 -0
- package/src/orchestrator/fallback/tool-execute-handler.ts +44 -0
- package/src/orchestrator/fallback/types.ts +46 -0
- package/src/orchestrator/handlers/architect.ts +114 -0
- package/src/orchestrator/handlers/build.ts +363 -0
- package/src/orchestrator/handlers/challenge.ts +41 -0
- package/src/orchestrator/handlers/explore.ts +9 -0
- package/src/orchestrator/handlers/index.ts +21 -0
- package/src/orchestrator/handlers/plan.ts +35 -0
- package/src/orchestrator/handlers/recon.ts +40 -0
- package/src/orchestrator/handlers/retrospective.ts +123 -0
- package/src/orchestrator/handlers/ship.ts +38 -0
- package/src/orchestrator/handlers/types.ts +31 -0
- package/src/orchestrator/lesson-injection.ts +80 -0
- package/src/orchestrator/lesson-memory.ts +110 -0
- package/src/orchestrator/lesson-schemas.ts +24 -0
- package/src/orchestrator/lesson-types.ts +6 -0
- package/src/orchestrator/phase.ts +76 -0
- package/src/orchestrator/plan.ts +43 -0
- package/src/orchestrator/schemas.ts +86 -0
- package/src/orchestrator/skill-injection.ts +52 -0
- package/src/orchestrator/state.ts +80 -0
- package/src/orchestrator/types.ts +20 -0
- package/src/review/agent-catalog.ts +439 -0
- package/src/review/agents/auth-flow-verifier.ts +47 -0
- package/src/review/agents/code-quality-auditor.ts +51 -0
- package/src/review/agents/concurrency-checker.ts +47 -0
- package/src/review/agents/contract-verifier.ts +45 -0
- package/src/review/agents/database-auditor.ts +47 -0
- package/src/review/agents/dead-code-scanner.ts +47 -0
- package/src/review/agents/go-idioms-auditor.ts +46 -0
- package/src/review/agents/index.ts +82 -0
- package/src/review/agents/logic-auditor.ts +47 -0
- package/src/review/agents/product-thinker.ts +49 -0
- package/src/review/agents/python-django-auditor.ts +46 -0
- package/src/review/agents/react-patterns-auditor.ts +46 -0
- package/src/review/agents/red-team.ts +49 -0
- package/src/review/agents/rust-safety-auditor.ts +46 -0
- package/src/review/agents/scope-intent-verifier.ts +45 -0
- package/src/review/agents/security-auditor.ts +47 -0
- package/src/review/agents/silent-failure-hunter.ts +45 -0
- package/src/review/agents/spec-checker.ts +45 -0
- package/src/review/agents/state-mgmt-auditor.ts +46 -0
- package/src/review/agents/test-interrogator.ts +43 -0
- package/src/review/agents/type-soundness.ts +46 -0
- package/src/review/agents/wiring-inspector.ts +46 -0
- package/src/review/cross-verification.ts +71 -0
- package/src/review/finding-builder.ts +74 -0
- package/src/review/fix-cycle.ts +146 -0
- package/src/review/memory.ts +114 -0
- package/src/review/pipeline.ts +258 -0
- package/src/review/report.ts +141 -0
- package/src/review/sanitize.ts +8 -0
- package/src/review/schemas.ts +75 -0
- package/src/review/selection.ts +98 -0
- package/src/review/severity.ts +71 -0
- package/src/review/stack-gate.ts +127 -0
- package/src/review/types.ts +43 -0
- package/src/templates/agent-template.ts +47 -0
- package/src/templates/command-template.ts +29 -0
- package/src/templates/skill-template.ts +42 -0
- package/src/tools/confidence.ts +93 -0
- package/src/tools/create-agent.ts +81 -0
- package/src/tools/create-command.ts +74 -0
- package/src/tools/create-skill.ts +74 -0
- package/src/tools/forensics.ts +88 -0
- package/src/tools/orchestrate.ts +310 -0
- package/src/tools/phase.ts +92 -0
- package/src/tools/placeholder.ts +11 -0
- package/src/tools/plan.ts +56 -0
- package/src/tools/review.ts +295 -0
- package/src/tools/state.ts +112 -0
- package/src/utils/fs-helpers.ts +39 -0
- package/src/utils/gitignore.ts +27 -0
- package/src/utils/paths.ts +17 -0
- package/src/utils/validators.ts +57 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oc_review tool -- multi-agent code review.
|
|
3
|
+
*
|
|
4
|
+
* Stateful between invocations:
|
|
5
|
+
* - scope arg -> start new review (stage 1 dispatch)
|
|
6
|
+
* - findings arg -> advance pipeline to next stage
|
|
7
|
+
* - no args with active state -> return status
|
|
8
|
+
* - no args without state -> error
|
|
9
|
+
*
|
|
10
|
+
* State persisted at {projectRoot}/.opencode-autopilot/current-review.json
|
|
11
|
+
* Memory persisted at {projectRoot}/.opencode-autopilot/review-memory.json
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { tool } from "@opencode-ai/plugin";
|
|
19
|
+
import { REVIEW_AGENTS, SPECIALIZED_AGENTS } from "../review/agents/index";
|
|
20
|
+
import {
|
|
21
|
+
createEmptyMemory,
|
|
22
|
+
loadReviewMemory,
|
|
23
|
+
pruneMemory,
|
|
24
|
+
saveReviewMemory,
|
|
25
|
+
} from "../review/memory";
|
|
26
|
+
import type { ReviewState } from "../review/pipeline";
|
|
27
|
+
import { advancePipeline } from "../review/pipeline";
|
|
28
|
+
import { reviewStateSchema } from "../review/schemas";
|
|
29
|
+
import { selectAgents } from "../review/selection";
|
|
30
|
+
import { detectStackTags } from "../review/stack-gate";
|
|
31
|
+
import { ensureDir, isEnoentError } from "../utils/fs-helpers";
|
|
32
|
+
import { getProjectArtifactDir } from "../utils/paths";
|
|
33
|
+
|
|
34
|
+
interface ReviewArgs {
|
|
35
|
+
readonly scope?: string;
|
|
36
|
+
readonly filter?: string;
|
|
37
|
+
readonly directory?: string;
|
|
38
|
+
readonly findings?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const execFileAsync = promisify(execFile);
|
|
42
|
+
|
|
43
|
+
const STATE_FILE = "current-review.json";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get changed file paths for the given review scope.
|
|
47
|
+
* Uses execFile (not exec) to prevent shell injection.
|
|
48
|
+
* Returns empty array on any error (best-effort).
|
|
49
|
+
*/
|
|
50
|
+
async function getChangedFiles(
|
|
51
|
+
scope: string,
|
|
52
|
+
projectRoot: string,
|
|
53
|
+
directory?: string,
|
|
54
|
+
): Promise<readonly string[]> {
|
|
55
|
+
try {
|
|
56
|
+
let args: string[];
|
|
57
|
+
switch (scope) {
|
|
58
|
+
case "staged":
|
|
59
|
+
args = ["diff", "--cached", "--name-only"];
|
|
60
|
+
break;
|
|
61
|
+
case "unstaged":
|
|
62
|
+
args = ["diff", "--name-only"];
|
|
63
|
+
break;
|
|
64
|
+
case "branch":
|
|
65
|
+
args = ["diff-tree", "--no-commit-id", "--name-only", "--root", "-r", "HEAD"];
|
|
66
|
+
break;
|
|
67
|
+
case "directory":
|
|
68
|
+
args = directory ? ["diff", "--name-only", "--", directory] : ["diff", "--name-only"];
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
args = ["diff", "--name-only", "HEAD"];
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
const { stdout } = await execFileAsync("git", args, { cwd: projectRoot, timeout: 10000 });
|
|
75
|
+
return stdout.trim().split("\n").filter(Boolean);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load review state from disk. Returns null if no active review.
|
|
83
|
+
*/
|
|
84
|
+
async function loadReviewState(artifactDir: string): Promise<ReviewState | null> {
|
|
85
|
+
const statePath = join(artifactDir, STATE_FILE);
|
|
86
|
+
try {
|
|
87
|
+
const raw = await readFile(statePath, "utf-8");
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
return reviewStateSchema.parse(parsed) as ReviewState;
|
|
90
|
+
} catch (error: unknown) {
|
|
91
|
+
if (isEnoentError(error)) return null;
|
|
92
|
+
// Treat parse/schema errors as recoverable — delete corrupt file
|
|
93
|
+
if (error instanceof SyntaxError || (error && typeof error === "object" && "issues" in error)) {
|
|
94
|
+
try {
|
|
95
|
+
await unlink(statePath);
|
|
96
|
+
} catch {
|
|
97
|
+
/* ignore cleanup errors */
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Save review state atomically.
|
|
107
|
+
*/
|
|
108
|
+
async function saveReviewState(state: ReviewState, artifactDir: string): Promise<void> {
|
|
109
|
+
await ensureDir(artifactDir);
|
|
110
|
+
// Validate before writing (bidirectional validation, same as orchestrator state)
|
|
111
|
+
const validated = reviewStateSchema.parse(state);
|
|
112
|
+
const statePath = join(artifactDir, STATE_FILE);
|
|
113
|
+
const tmpPath = `${statePath}.tmp.${Date.now()}`;
|
|
114
|
+
await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
|
|
115
|
+
await rename(tmpPath, statePath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Delete review state file (pipeline complete or error cleanup).
|
|
120
|
+
*/
|
|
121
|
+
async function clearReviewState(artifactDir: string): Promise<void> {
|
|
122
|
+
const statePath = join(artifactDir, STATE_FILE);
|
|
123
|
+
try {
|
|
124
|
+
await unlink(statePath);
|
|
125
|
+
} catch (error: unknown) {
|
|
126
|
+
if (!isEnoentError(error)) throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Start a new review -- detect stacks, select agents, and build stage 1 dispatch prompts.
|
|
132
|
+
*/
|
|
133
|
+
async function startNewReview(
|
|
134
|
+
scope: string,
|
|
135
|
+
projectRoot: string,
|
|
136
|
+
options?: { readonly filter?: string; readonly directory?: string },
|
|
137
|
+
): Promise<{
|
|
138
|
+
readonly state: ReviewState;
|
|
139
|
+
readonly agents: readonly { readonly name: string; readonly prompt: string }[];
|
|
140
|
+
}> {
|
|
141
|
+
// Detect stacks from changed files via git (run in projectRoot)
|
|
142
|
+
const changedFiles = await getChangedFiles(scope, projectRoot, options?.directory);
|
|
143
|
+
const detectedStacks = detectStackTags(changedFiles);
|
|
144
|
+
|
|
145
|
+
// Build diff analysis from changed file paths
|
|
146
|
+
const diffAnalysis = {
|
|
147
|
+
hasTests: changedFiles.some((f) => f.includes("test") || f.includes("spec")),
|
|
148
|
+
hasAuth: changedFiles.some(
|
|
149
|
+
(f) => f.includes("auth") || f.includes("login") || f.includes("session"),
|
|
150
|
+
),
|
|
151
|
+
hasConfig: changedFiles.some(
|
|
152
|
+
(f) => f.includes("config") || f.includes("settings") || f.includes(".env"),
|
|
153
|
+
),
|
|
154
|
+
fileCount: changedFiles.length,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Select agents from all candidates (universal + specialized)
|
|
158
|
+
const allCandidates = [...REVIEW_AGENTS, ...SPECIALIZED_AGENTS];
|
|
159
|
+
const selection = selectAgents(detectedStacks, diffAnalysis, allCandidates);
|
|
160
|
+
|
|
161
|
+
const selectedNames = selection.selected.map((a) => a.name);
|
|
162
|
+
|
|
163
|
+
// Build stage 1 prompts (specialist review with diff placeholder)
|
|
164
|
+
const agentPrompts = selection.selected.map((agent) => {
|
|
165
|
+
const prompt = agent.prompt
|
|
166
|
+
.replace("{{DIFF}}", `[Diff for scope: ${scope}]`)
|
|
167
|
+
.replace("{{PRIOR_FINDINGS}}", "No prior findings yet.")
|
|
168
|
+
.replace("{{MEMORY}}", "");
|
|
169
|
+
return Object.freeze({ name: agent.name, prompt });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const state: ReviewState = {
|
|
173
|
+
stage: 1,
|
|
174
|
+
selectedAgentNames: selectedNames,
|
|
175
|
+
accumulatedFindings: [],
|
|
176
|
+
scope,
|
|
177
|
+
startedAt: new Date().toISOString(),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return { state, agents: Object.freeze(agentPrompts) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function reviewCore(args: ReviewArgs, projectRoot: string): Promise<string> {
|
|
184
|
+
try {
|
|
185
|
+
const artifactDir = getProjectArtifactDir(projectRoot);
|
|
186
|
+
const currentState = await loadReviewState(artifactDir);
|
|
187
|
+
|
|
188
|
+
// Case 1: No state, scope provided -> start new review
|
|
189
|
+
if (currentState === null && args.scope) {
|
|
190
|
+
const { state, agents } = await startNewReview(args.scope, projectRoot, {
|
|
191
|
+
filter: args.filter,
|
|
192
|
+
directory: args.directory,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Load memory for false positive context
|
|
196
|
+
const memory = await loadReviewMemory(projectRoot);
|
|
197
|
+
if (memory) {
|
|
198
|
+
// Inject false positive context into prompts (via {{MEMORY}} already replaced above)
|
|
199
|
+
// Future enhancement: pass FP context to agent prompts
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await saveReviewState(state, artifactDir);
|
|
203
|
+
|
|
204
|
+
return JSON.stringify({
|
|
205
|
+
action: "dispatch",
|
|
206
|
+
stage: 1,
|
|
207
|
+
agents,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Case 2: No state, no scope -> error
|
|
212
|
+
if (currentState === null && !args.scope) {
|
|
213
|
+
return JSON.stringify({
|
|
214
|
+
action: "error",
|
|
215
|
+
message: "No active review. Provide scope to start.",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Case 3: State exists, findings provided -> advance pipeline
|
|
220
|
+
if (currentState !== null && args.findings) {
|
|
221
|
+
const result = advancePipeline(args.findings, currentState);
|
|
222
|
+
|
|
223
|
+
if (result.action === "dispatch" && result.state) {
|
|
224
|
+
await saveReviewState(result.state, artifactDir);
|
|
225
|
+
return JSON.stringify({
|
|
226
|
+
action: "dispatch",
|
|
227
|
+
stage: result.stage,
|
|
228
|
+
agents: result.agents,
|
|
229
|
+
message: result.message,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (result.action === "complete") {
|
|
234
|
+
// Update memory with findings
|
|
235
|
+
const memory = (await loadReviewMemory(projectRoot)) ?? createEmptyMemory();
|
|
236
|
+
const updatedMemory = pruneMemory({
|
|
237
|
+
...memory,
|
|
238
|
+
recentFindings: [...memory.recentFindings, ...(result.report?.findings ?? [])],
|
|
239
|
+
lastReviewedAt: new Date().toISOString(),
|
|
240
|
+
});
|
|
241
|
+
await saveReviewMemory(updatedMemory, projectRoot);
|
|
242
|
+
|
|
243
|
+
// Clear state
|
|
244
|
+
await clearReviewState(artifactDir);
|
|
245
|
+
|
|
246
|
+
return JSON.stringify({
|
|
247
|
+
action: "complete",
|
|
248
|
+
report: result.report,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (result.action === "error") {
|
|
253
|
+
await clearReviewState(artifactDir);
|
|
254
|
+
return JSON.stringify({
|
|
255
|
+
action: "error",
|
|
256
|
+
message: result.message ?? "Pipeline error",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Case 4: State exists, no findings -> return status
|
|
262
|
+
if (currentState !== null && !args.findings) {
|
|
263
|
+
return JSON.stringify({
|
|
264
|
+
action: "status",
|
|
265
|
+
stage: currentState.stage,
|
|
266
|
+
message: "Awaiting findings from dispatched agents",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return JSON.stringify({ action: "error", message: "Unexpected state" });
|
|
271
|
+
} catch (error: unknown) {
|
|
272
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
273
|
+
return JSON.stringify({ action: "error", message });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const ocReview = tool({
|
|
278
|
+
description:
|
|
279
|
+
"Run multi-agent code review. Provide scope (staged|unstaged|branch|all|directory) to start, or findings from dispatched agents to advance the pipeline. Returns JSON with action (dispatch|complete|status|error).",
|
|
280
|
+
args: {
|
|
281
|
+
scope: tool.schema
|
|
282
|
+
.enum(["staged", "unstaged", "branch", "all", "directory"])
|
|
283
|
+
.optional()
|
|
284
|
+
.describe("Review scope"),
|
|
285
|
+
filter: tool.schema.string().optional().describe("Regex pattern to filter files"),
|
|
286
|
+
directory: tool.schema.string().optional().describe("Directory path for directory scope"),
|
|
287
|
+
findings: tool.schema
|
|
288
|
+
.string()
|
|
289
|
+
.optional()
|
|
290
|
+
.describe("JSON findings from previously dispatched review agents"),
|
|
291
|
+
},
|
|
292
|
+
async execute(args) {
|
|
293
|
+
return reviewCore(args, process.cwd());
|
|
294
|
+
},
|
|
295
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { appendDecision, loadState, patchState, saveState } from "../orchestrator/state";
|
|
3
|
+
import { getProjectArtifactDir } from "../utils/paths";
|
|
4
|
+
|
|
5
|
+
const PATCHABLE_FIELDS = ["status", "arenaConfidence", "exploreTriggered"] as const;
|
|
6
|
+
|
|
7
|
+
interface StateArgs {
|
|
8
|
+
readonly subcommand: string;
|
|
9
|
+
readonly field?: string;
|
|
10
|
+
readonly value?: string;
|
|
11
|
+
readonly phase?: string;
|
|
12
|
+
readonly agent?: string;
|
|
13
|
+
readonly decision?: string;
|
|
14
|
+
readonly rationale?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function stateCore(args: StateArgs, artifactDir: string): Promise<string> {
|
|
18
|
+
try {
|
|
19
|
+
switch (args.subcommand) {
|
|
20
|
+
case "load": {
|
|
21
|
+
const state = await loadState(artifactDir);
|
|
22
|
+
if (state === null) {
|
|
23
|
+
return JSON.stringify({ error: "no_state" });
|
|
24
|
+
}
|
|
25
|
+
return JSON.stringify(state);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case "get": {
|
|
29
|
+
if (!args.field) {
|
|
30
|
+
return JSON.stringify({ error: "field required" });
|
|
31
|
+
}
|
|
32
|
+
const state = await loadState(artifactDir);
|
|
33
|
+
if (state === null) {
|
|
34
|
+
return JSON.stringify({ error: "no_state" });
|
|
35
|
+
}
|
|
36
|
+
const value = state[args.field as keyof typeof state];
|
|
37
|
+
return JSON.stringify({ field: args.field, value });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case "patch": {
|
|
41
|
+
if (!args.field) {
|
|
42
|
+
return JSON.stringify({ error: "field required" });
|
|
43
|
+
}
|
|
44
|
+
if (!(PATCHABLE_FIELDS as readonly string[]).includes(args.field)) {
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
error: `field not patchable: ${args.field}. Allowed: ${PATCHABLE_FIELDS.join(", ")}`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const state = await loadState(artifactDir);
|
|
50
|
+
if (state === null) {
|
|
51
|
+
return JSON.stringify({ error: "no_state" });
|
|
52
|
+
}
|
|
53
|
+
// Coerce value based on field type
|
|
54
|
+
let coercedValue: string | boolean | null = args.value ?? null;
|
|
55
|
+
if (args.field === "exploreTriggered") {
|
|
56
|
+
coercedValue = args.value === "true";
|
|
57
|
+
}
|
|
58
|
+
if (args.field === "arenaConfidence" && args.value === "null") {
|
|
59
|
+
coercedValue = null;
|
|
60
|
+
}
|
|
61
|
+
const updated = patchState(state, { [args.field]: coercedValue });
|
|
62
|
+
await saveState(updated, artifactDir);
|
|
63
|
+
return JSON.stringify({ ok: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
case "append-decision": {
|
|
67
|
+
if (!args.phase || !args.agent || !args.decision || !args.rationale) {
|
|
68
|
+
return JSON.stringify({
|
|
69
|
+
error: "phase, agent, decision, and rationale are required",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const state = await loadState(artifactDir);
|
|
73
|
+
if (state === null) {
|
|
74
|
+
return JSON.stringify({ error: "no_state" });
|
|
75
|
+
}
|
|
76
|
+
const updated = appendDecision(state, {
|
|
77
|
+
phase: args.phase,
|
|
78
|
+
agent: args.agent,
|
|
79
|
+
decision: args.decision,
|
|
80
|
+
rationale: args.rationale,
|
|
81
|
+
});
|
|
82
|
+
await saveState(updated, artifactDir);
|
|
83
|
+
return JSON.stringify({ ok: true, decisions: updated.decisions.length });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
default:
|
|
87
|
+
return JSON.stringify({ error: `unknown subcommand: ${args.subcommand}` });
|
|
88
|
+
}
|
|
89
|
+
} catch (error: unknown) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
return JSON.stringify({ error: message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const ocState = tool({
|
|
96
|
+
description:
|
|
97
|
+
"Manage orchestrator pipeline state. Subcommands: load (full state), get (single field), patch (update field), append-decision (add decision entry).",
|
|
98
|
+
args: {
|
|
99
|
+
subcommand: tool.schema
|
|
100
|
+
.enum(["load", "get", "patch", "append-decision"])
|
|
101
|
+
.describe("Operation to perform"),
|
|
102
|
+
field: tool.schema.string().optional().describe("Field name for get/patch subcommands"),
|
|
103
|
+
value: tool.schema.string().optional().describe("Value for patch subcommand"),
|
|
104
|
+
phase: tool.schema.string().optional().describe("Phase name for append-decision"),
|
|
105
|
+
agent: tool.schema.string().optional().describe("Agent name for append-decision"),
|
|
106
|
+
decision: tool.schema.string().optional().describe("Decision text for append-decision"),
|
|
107
|
+
rationale: tool.schema.string().optional().describe("Rationale text for append-decision"),
|
|
108
|
+
},
|
|
109
|
+
async execute(args) {
|
|
110
|
+
return stateCore(args, getProjectArtifactDir(process.cwd()));
|
|
111
|
+
},
|
|
112
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, copyFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function isEnoentError(error: unknown): error is NodeJS.ErrnoException {
|
|
6
|
+
return (
|
|
7
|
+
error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function fileExists(path: string): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
await access(path);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function ensureDir(dirPath: string): Promise<void> {
|
|
21
|
+
await mkdir(dirPath, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function copyIfMissing(source: string, target: string): Promise<{ copied: boolean }> {
|
|
25
|
+
await ensureDir(dirname(target));
|
|
26
|
+
try {
|
|
27
|
+
await copyFile(source, target, constants.COPYFILE_EXCL);
|
|
28
|
+
return { copied: true };
|
|
29
|
+
} catch (error: unknown) {
|
|
30
|
+
if (
|
|
31
|
+
error instanceof Error &&
|
|
32
|
+
"code" in error &&
|
|
33
|
+
(error as NodeJS.ErrnoException).code === "EEXIST"
|
|
34
|
+
) {
|
|
35
|
+
return { copied: false };
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isEnoentError } from "./fs-helpers";
|
|
4
|
+
|
|
5
|
+
const GITIGNORE_ENTRY = ".opencode-autopilot/";
|
|
6
|
+
|
|
7
|
+
export async function ensureGitignore(projectRoot: string): Promise<void> {
|
|
8
|
+
const gitignorePath = join(projectRoot, ".gitignore");
|
|
9
|
+
|
|
10
|
+
let content: string;
|
|
11
|
+
try {
|
|
12
|
+
content = await readFile(gitignorePath, "utf-8");
|
|
13
|
+
} catch (error: unknown) {
|
|
14
|
+
if (isEnoentError(error)) {
|
|
15
|
+
await writeFile(gitignorePath, `${GITIGNORE_ENTRY}\n`, "utf-8");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (content.includes(GITIGNORE_ENTRY)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const suffix = content.endsWith("\n") ? "" : "\n";
|
|
26
|
+
await writeFile(gitignorePath, `${content}${suffix}${GITIGNORE_ENTRY}\n`, "utf-8");
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = import.meta.dir ?? dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
export function getGlobalConfigDir(): string {
|
|
8
|
+
return join(homedir(), ".config", "opencode");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getAssetsDir(): string {
|
|
12
|
+
return join(__dirname, "..", "..", "assets");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getProjectArtifactDir(projectRoot: string): string {
|
|
16
|
+
return join(projectRoot, ".opencode-autopilot");
|
|
17
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const ASSET_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
2
|
+
|
|
3
|
+
export const MAX_NAME_LENGTH = 64;
|
|
4
|
+
|
|
5
|
+
export const BUILT_IN_COMMANDS: ReadonlySet<string> = new Set([
|
|
6
|
+
"init",
|
|
7
|
+
"undo",
|
|
8
|
+
"redo",
|
|
9
|
+
"share",
|
|
10
|
+
"help",
|
|
11
|
+
"config",
|
|
12
|
+
"compact",
|
|
13
|
+
"clear",
|
|
14
|
+
"cost",
|
|
15
|
+
"login",
|
|
16
|
+
"logout",
|
|
17
|
+
"bug",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
interface ValidationResult {
|
|
21
|
+
readonly valid: boolean;
|
|
22
|
+
readonly error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validateAssetName(name: string): ValidationResult {
|
|
26
|
+
if (name.length === 0 || name.length > MAX_NAME_LENGTH) {
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
valid: false,
|
|
29
|
+
error: `Name must be 1-${MAX_NAME_LENGTH} characters. Got ${name.length}.`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!ASSET_NAME_REGEX.test(name)) {
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
valid: false,
|
|
36
|
+
error: `Name '${name}' is invalid. Names must be 1-${MAX_NAME_LENGTH} lowercase alphanumeric characters with hyphens (e.g., 'my-agent').`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Object.freeze({ valid: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateCommandName(name: string): ValidationResult {
|
|
44
|
+
const assetResult = validateAssetName(name);
|
|
45
|
+
if (!assetResult.valid) {
|
|
46
|
+
return assetResult;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (BUILT_IN_COMMANDS.has(name)) {
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
valid: false,
|
|
52
|
+
error: `Command name '${name}' conflicts with a built-in OpenCode command. Choose a different name.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Object.freeze({ valid: true });
|
|
57
|
+
}
|