@williambeto/ai-workflow 2.2.7 → 2.3.1
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/AGENTS.md +27 -0
- package/CHANGELOG.md +34 -0
- package/dist-assets/agents/astra.md +74 -45
- package/dist-assets/agents/atlas.md +110 -152
- package/dist-assets/agents/nexus.md +64 -19
- package/dist-assets/agents/orion.md +72 -27
- package/dist-assets/agents/phoenix.md +64 -19
- package/dist-assets/agents/sage.md +67 -36
- package/dist-assets/commands/atlas.md +66 -6
- package/dist-assets/commands/audit.md +62 -4
- package/dist-assets/commands/deploy.md +66 -5
- package/dist-assets/commands/discover.md +72 -4
- package/dist-assets/commands/implement.md +66 -18
- package/dist-assets/commands/optimize-tokens.md +60 -4
- package/dist-assets/commands/plan.md +64 -4
- package/dist-assets/commands/release.md +76 -5
- package/dist-assets/commands/run.md +62 -16
- package/dist-assets/commands/spec-create.md +66 -4
- package/dist-assets/commands/spec-implement.md +68 -4
- package/dist-assets/commands/spec-review.md +63 -4
- package/dist-assets/commands/update-memory.md +62 -4
- package/dist-assets/commands/validate.md +70 -6
- package/dist-assets/docs/cli-reference.md +38 -10
- package/dist-assets/skills/architecture/SKILL.md +62 -7
- package/dist-assets/skills/backend-development/SKILL.md +62 -7
- package/dist-assets/skills/deployment/SKILL.md +62 -7
- package/dist-assets/skills/design-principles/SKILL.md +59 -7
- package/dist-assets/skills/documentation/SKILL.md +61 -7
- package/dist-assets/skills/frontend-development/SKILL.md +62 -7
- package/dist-assets/skills/full-stack-development/SKILL.md +62 -7
- package/dist-assets/skills/optimize-tokens/SKILL.md +61 -7
- package/dist-assets/skills/pr-workflow/SKILL.md +65 -7
- package/dist-assets/skills/product-discovery/SKILL.md +81 -7
- package/dist-assets/skills/product-planning/SKILL.md +62 -7
- package/dist-assets/skills/project-memory/SKILL.md +55 -22
- package/dist-assets/skills/prompt-engineer/SKILL.md +59 -7
- package/dist-assets/skills/qa-workflow/SKILL.md +72 -7
- package/dist-assets/skills/refactoring/SKILL.md +68 -7
- package/dist-assets/skills/release-workflow/SKILL.md +72 -7
- package/dist-assets/skills/spec-driven-development/SKILL.md +75 -7
- package/dist-assets/skills/technical-leadership/SKILL.md +61 -7
- package/dist-assets/skills/ui-ux-design/SKILL.md +60 -7
- package/docs/compatibility/provider-usage.md +46 -0
- package/docs/compatibility/runtime-matrix.md +31 -0
- package/docs/getting-started/DESKTOP_PROMPT.md +52 -0
- package/docs/getting-started/quickstart.md +17 -0
- package/docs/getting-started/upgrading-to-v2.md +55 -0
- package/package.json +20 -4
- package/read_only_safety_verification.md +48 -0
- package/src/cli.js +4 -2
- package/src/commands/collect-evidence.js +3 -39
- package/src/commands/doctor.js +16 -0
- package/src/commands/execute.js +312 -119
- package/src/core/delegation-controller.js +193 -0
- package/src/core/evidence/evidence-ledger.js +53 -0
- package/src/core/execution-planner.js +4 -1
- package/src/core/finalization/finalizer.js +77 -0
- package/src/core/finalization/workspace-snapshot.js +110 -0
- package/src/core/gates/branch-gate.js +23 -35
- package/src/core/healing/healer-engine.js +34 -1
- package/src/core/healing/runtime-remediation-executor.js +136 -0
- package/src/core/request-classifier.js +15 -5
- package/src/core/request-router.js +289 -0
- package/src/core/runtime/opencode-adapter.js +170 -62
- package/src/core/validation/evidence-collector.js +26 -3
- package/src/core/validation/stack-detector.js +65 -0
- package/src/core/validation/validation-planner.js +134 -0
- package/src/core/workspace/read-only-workspace.js +119 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
export class RequestRouter {
|
|
5
|
+
constructor({ cwd = process.cwd() } = {}) {
|
|
6
|
+
this.cwd = cwd;
|
|
7
|
+
this.validActors = ["Atlas", "Nexus", "Orion", "Astra", "Sage", "Phoenix"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Understands and classifies a natural language request.
|
|
12
|
+
* @param {string} request
|
|
13
|
+
* @returns {Object} RequestUnderstanding
|
|
14
|
+
*/
|
|
15
|
+
understand(request) {
|
|
16
|
+
const text = String(request).trim();
|
|
17
|
+
if (!text) {
|
|
18
|
+
throw new Error("Cannot classify an empty request.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 1. Extract requested actor
|
|
22
|
+
let requestedActor = undefined;
|
|
23
|
+
let invalidActor = undefined;
|
|
24
|
+
|
|
25
|
+
// Direct addressing/reference pattern e.g., "Nexus, discover...", "ask Sage to..."
|
|
26
|
+
const askMatch = text.match(/\b(?:ask|use|tell|run|agent|actor)\s+([a-zA-Z]+)\b/i);
|
|
27
|
+
const addressMatch = text.match(/\b([a-zA-Z]+)\s*(?:,)/);
|
|
28
|
+
|
|
29
|
+
let candidate = null;
|
|
30
|
+
if (askMatch) {
|
|
31
|
+
candidate = askMatch[1];
|
|
32
|
+
} else if (addressMatch) {
|
|
33
|
+
candidate = addressMatch[1];
|
|
34
|
+
} else {
|
|
35
|
+
// General match for known actors
|
|
36
|
+
const knownMatch = text.match(/\b(Atlas|Nexus|Orion|Astra|Sage|Phoenix)\b/i);
|
|
37
|
+
if (knownMatch) {
|
|
38
|
+
candidate = knownMatch[1];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (candidate) {
|
|
43
|
+
const normalized = candidate.charAt(0).toUpperCase() + candidate.slice(1).toLowerCase();
|
|
44
|
+
if (this.validActors.includes(normalized)) {
|
|
45
|
+
requestedActor = normalized;
|
|
46
|
+
} else {
|
|
47
|
+
invalidActor = candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Extract operationType and requestedCapability
|
|
52
|
+
let operationType = "explain"; // default
|
|
53
|
+
let requestedCapability = undefined;
|
|
54
|
+
|
|
55
|
+
const capabilityMappings = [
|
|
56
|
+
{ op: "discover", pattern: /\b(discover|inspect|map|scan|structure|architecture|repo|search|find)\b/i },
|
|
57
|
+
{ op: "validate", pattern: /\b(validate|audit|review|check|verify|test)\b/i },
|
|
58
|
+
{ op: "implement", pattern: /\b(implement|build|create|add|develop|write|change|modify)\b/i },
|
|
59
|
+
{ op: "fix", pattern: /\b(fix|repair|correct|heal|solve|remediate)\b/i },
|
|
60
|
+
{ op: "release", pattern: /\b(release|tag|prepare release)\b/i },
|
|
61
|
+
{ op: "deploy", pattern: /\b(deploy|publish|push)\b/i }
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
for (const mapping of capabilityMappings) {
|
|
65
|
+
const match = text.match(mapping.pattern);
|
|
66
|
+
if (match) {
|
|
67
|
+
operationType = mapping.op;
|
|
68
|
+
requestedCapability = match[1];
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 3. Determine mutationIntent
|
|
74
|
+
const writePatterns = /\b(implement|build|fix|write|change|modify|create|add|refactor|update)\b/i;
|
|
75
|
+
const readOnlyPatterns = /\b(analyze|check|inspect|review|find|search|show|list|read|view|verify|validate|discover|map)\b/i;
|
|
76
|
+
|
|
77
|
+
let mutationIntent = "write"; // default
|
|
78
|
+
if (/\b(read-only|readonly)\b/i.test(text)) {
|
|
79
|
+
mutationIntent = "readonly";
|
|
80
|
+
} else if (writePatterns.test(text)) {
|
|
81
|
+
mutationIntent = "write";
|
|
82
|
+
} else if (readOnlyPatterns.test(text) || ["discover", "validate"].includes(operationType)) {
|
|
83
|
+
mutationIntent = "readonly";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (/\b(publish|release|deploy|push to npm|push to remote)\b/i.test(text)) {
|
|
87
|
+
mutationIntent = "publish";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Determine Mode (needed for riskLevel mapping)
|
|
91
|
+
let mode = "standard";
|
|
92
|
+
if (/\b(deep|architectural|migration|major|full|\[deep\])\b/i.test(text)) {
|
|
93
|
+
mode = "full";
|
|
94
|
+
} else if (/\b(tiny|small|quick|simple|only\s+update|typo|comment|\[tiny\])\b/i.test(text)) {
|
|
95
|
+
mode = "quick";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 4. Determine riskLevel
|
|
99
|
+
let riskLevel = "medium";
|
|
100
|
+
if (mode === "full" || mutationIntent === "publish" || /\b(bypass|force|direct)\b/i.test(text)) {
|
|
101
|
+
riskLevel = "high";
|
|
102
|
+
} else if (mode === "quick" || mutationIntent === "readonly") {
|
|
103
|
+
riskLevel = "low";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 5. safetyConstraints and requiredEvidence
|
|
107
|
+
const safetyConstraints = [];
|
|
108
|
+
const requiredEvidence = [];
|
|
109
|
+
|
|
110
|
+
if (mutationIntent === "publish") {
|
|
111
|
+
safetyConstraints.push("Require release gate", "Awaiting user authorization");
|
|
112
|
+
requiredEvidence.push("release-decision", "tarball-hash");
|
|
113
|
+
} else if (mutationIntent === "write") {
|
|
114
|
+
safetyConstraints.push("Require branch gate", "No direct commits to main");
|
|
115
|
+
requiredEvidence.push("tests", "commit-hash");
|
|
116
|
+
} else {
|
|
117
|
+
safetyConstraints.push("No workspace mutations allowed");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (/\b(bypass|force|direct)\b/i.test(text)) {
|
|
121
|
+
safetyConstraints.push("Block bypass attempts");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
rawRequest: text,
|
|
126
|
+
taskGoal: `Perform ${operationType} operation under ${mutationIntent} intent`,
|
|
127
|
+
requestedActor,
|
|
128
|
+
invalidActor,
|
|
129
|
+
requestedCapability,
|
|
130
|
+
operationType,
|
|
131
|
+
mutationIntent,
|
|
132
|
+
riskLevel,
|
|
133
|
+
requiredEvidence,
|
|
134
|
+
safetyConstraints
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Decides the routing decision.
|
|
140
|
+
* @param {Object} understanding - RequestUnderstanding
|
|
141
|
+
* @returns {Object} RoutingDecision
|
|
142
|
+
*/
|
|
143
|
+
route(understanding) {
|
|
144
|
+
const { requestedActor, invalidActor, operationType, mutationIntent, rawRequest } = understanding;
|
|
145
|
+
|
|
146
|
+
// 1. Check for bypass safety attempts
|
|
147
|
+
if (/\b(bypass safety|bypass validation|force push|directly to main|push to remote without gate)\b/i.test(rawRequest)) {
|
|
148
|
+
return {
|
|
149
|
+
requestedActor,
|
|
150
|
+
selectedActor: "Atlas",
|
|
151
|
+
operationType,
|
|
152
|
+
mutationIntent,
|
|
153
|
+
permissionDecision: "blocked",
|
|
154
|
+
reason: "Security block: Attempt to bypass safety or push directly is forbidden.",
|
|
155
|
+
workflowPath: ["Atlas"]
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Check for invalid/ambiguous actor names in critical actions
|
|
160
|
+
if (invalidActor) {
|
|
161
|
+
if (["release", "deploy", "fix", "implement"].includes(operationType) || mutationIntent === "publish") {
|
|
162
|
+
return {
|
|
163
|
+
requestedActor: undefined,
|
|
164
|
+
selectedActor: "Atlas",
|
|
165
|
+
operationType,
|
|
166
|
+
mutationIntent,
|
|
167
|
+
permissionDecision: "blocked",
|
|
168
|
+
reason: `Security block: Request mentions invalid/unrecognized actor '${invalidActor}' for a critical operation.`,
|
|
169
|
+
workflowPath: ["Atlas"]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. Permission matrix check
|
|
175
|
+
const PERMISSION_MATRIX = {
|
|
176
|
+
Atlas: {
|
|
177
|
+
allowedOperations: ["explain"],
|
|
178
|
+
allowedMutations: ["readonly"]
|
|
179
|
+
},
|
|
180
|
+
Nexus: {
|
|
181
|
+
allowedOperations: ["discover", "explain", "plan"],
|
|
182
|
+
allowedMutations: ["readonly", "write"]
|
|
183
|
+
},
|
|
184
|
+
Orion: {
|
|
185
|
+
allowedOperations: ["plan", "release", "deploy"],
|
|
186
|
+
allowedMutations: ["readonly", "write", "publish"]
|
|
187
|
+
},
|
|
188
|
+
Astra: {
|
|
189
|
+
allowedOperations: ["implement", "fix", "explain"],
|
|
190
|
+
allowedMutations: ["readonly", "write"]
|
|
191
|
+
},
|
|
192
|
+
Sage: {
|
|
193
|
+
allowedOperations: ["validate", "explain"],
|
|
194
|
+
allowedMutations: ["readonly"]
|
|
195
|
+
},
|
|
196
|
+
Phoenix: {
|
|
197
|
+
allowedOperations: ["fix"],
|
|
198
|
+
allowedMutations: ["readonly", "write"]
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let selectedActor = undefined;
|
|
203
|
+
let permissionDecision = "allowed";
|
|
204
|
+
let reason = "Routed successfully.";
|
|
205
|
+
let workflowPath = ["Atlas"];
|
|
206
|
+
|
|
207
|
+
if (requestedActor) {
|
|
208
|
+
const perms = PERMISSION_MATRIX[requestedActor];
|
|
209
|
+
const isOpAllowed = perms.allowedOperations.includes(operationType);
|
|
210
|
+
const isMutationAllowed = perms.allowedMutations.includes(mutationIntent);
|
|
211
|
+
|
|
212
|
+
if (isOpAllowed && isMutationAllowed) {
|
|
213
|
+
selectedActor = requestedActor;
|
|
214
|
+
} else {
|
|
215
|
+
// Valid actor but unauthorized operation or mutation
|
|
216
|
+
if (mutationIntent === "publish") {
|
|
217
|
+
selectedActor = "Orion";
|
|
218
|
+
permissionDecision = "rerouted";
|
|
219
|
+
reason = `Rerouted: Requested actor '${requestedActor}' is not authorized to publish. Redirecting to Orion.`;
|
|
220
|
+
} else {
|
|
221
|
+
selectedActor = requestedActor;
|
|
222
|
+
permissionDecision = "blocked";
|
|
223
|
+
reason = `Blocked: Requested actor '${requestedActor}' does not have permission for operation '${operationType}' or mutation '${mutationIntent}'.`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
// Infer actor from operation, risk, capability
|
|
228
|
+
if (operationType === "discover") {
|
|
229
|
+
selectedActor = "Nexus";
|
|
230
|
+
} else if (operationType === "validate") {
|
|
231
|
+
selectedActor = "Sage";
|
|
232
|
+
} else if (operationType === "fix") {
|
|
233
|
+
selectedActor = "Phoenix";
|
|
234
|
+
} else if (operationType === "implement") {
|
|
235
|
+
selectedActor = "Astra";
|
|
236
|
+
} else if (operationType === "release" || operationType === "deploy") {
|
|
237
|
+
selectedActor = "Orion";
|
|
238
|
+
} else {
|
|
239
|
+
// Fallback for general explain/plan
|
|
240
|
+
selectedActor = mutationIntent === "write" ? "Astra" : "Atlas";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Phoenix findings check: A fix request requires findings before Phoenix acts
|
|
245
|
+
if (selectedActor === "Phoenix") {
|
|
246
|
+
const requestPath = path.join(this.cwd, ".ai-workflow/remediation-request.json");
|
|
247
|
+
let hasFindings = false;
|
|
248
|
+
if (fs.existsSync(requestPath)) {
|
|
249
|
+
try {
|
|
250
|
+
const reqData = JSON.parse(fs.readFileSync(requestPath, "utf8"));
|
|
251
|
+
if (reqData.affectedChecks && reqData.affectedChecks.length > 0) {
|
|
252
|
+
hasFindings = true;
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!hasFindings) {
|
|
259
|
+
permissionDecision = "blocked";
|
|
260
|
+
reason = "Blocked: Phoenix acts only with concrete findings and bounded remediation.";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (selectedActor && !workflowPath.includes(selectedActor)) {
|
|
265
|
+
workflowPath.push(selectedActor);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Atlas self-execution guard
|
|
269
|
+
if (selectedActor === "Atlas" && (requestedActor || ["discover", "validate", "fix", "release", "deploy"].includes(operationType))) {
|
|
270
|
+
permissionDecision = "blocked";
|
|
271
|
+
reason = "Atlas self-execution guard: Atlas cannot silently execute specialized actor or capability requests.";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Sage and Phoenix revalidation loop path in full mode:
|
|
275
|
+
if (selectedActor === "Astra" && understanding.riskLevel === "high") {
|
|
276
|
+
workflowPath.push("Sage", "Phoenix", "Sage");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
requestedActor,
|
|
281
|
+
selectedActor,
|
|
282
|
+
operationType,
|
|
283
|
+
mutationIntent,
|
|
284
|
+
permissionDecision,
|
|
285
|
+
reason,
|
|
286
|
+
workflowPath
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn,
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import readline from "node:readline";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -9,86 +9,194 @@ export class OpenCodeAdapter {
|
|
|
9
9
|
this.cwd = cwd;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Inspects the availability and capability of the OpenCode CLI.
|
|
14
|
+
* @returns {Promise<{available: boolean, supports: {run: boolean, formatJson: boolean, agent: boolean, model: boolean}}>}
|
|
15
|
+
*/
|
|
16
|
+
async inspect() {
|
|
17
|
+
try {
|
|
18
|
+
const cliHelp = spawnSync("opencode", ["--help"], { encoding: "utf8" });
|
|
19
|
+
const runHelp = spawnSync("opencode", ["run", "--help"], { encoding: "utf8" });
|
|
20
|
+
|
|
21
|
+
const available = cliHelp.status === 0 && runHelp.status === 0;
|
|
22
|
+
if (!available) {
|
|
23
|
+
return {
|
|
24
|
+
available: false,
|
|
25
|
+
supports: { run: false, formatJson: false, agent: false, model: false }
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
available: true,
|
|
31
|
+
supports: {
|
|
32
|
+
run: /\brun\b/.test(cliHelp.stdout),
|
|
33
|
+
formatJson: /--format/.test(runHelp.stdout),
|
|
34
|
+
agent: /--agent/.test(runHelp.stdout),
|
|
35
|
+
model: /--model/.test(runHelp.stdout),
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return {
|
|
40
|
+
available: false,
|
|
41
|
+
supports: { run: false, formatJson: false, agent: false, model: false }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
12
46
|
/**
|
|
13
47
|
* Runs opencode with a prompt and options.
|
|
14
48
|
* @param {string} message - The message prompt.
|
|
15
|
-
* @param {Object} options - CLI options (e.g. agent, model,
|
|
16
|
-
* @returns {Promise<{success: boolean, commandsRun: string[], eventCount: number}>}
|
|
49
|
+
* @param {Object} options - CLI options (e.g. agent, model, readOnly).
|
|
50
|
+
* @returns {Promise<{success: boolean, commandsRun: string[], eventCount: number, error?: string}>}
|
|
17
51
|
*/
|
|
18
|
-
async execute(message, { agent = null, model = null,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (agent) {
|
|
22
|
-
args.push("--agent", agent);
|
|
23
|
-
}
|
|
24
|
-
if (model) {
|
|
25
|
-
args.push("--model", model);
|
|
26
|
-
}
|
|
27
|
-
if (dangerouslySkipPermissions) {
|
|
28
|
-
args.push("--dangerously-skip-permissions");
|
|
29
|
-
}
|
|
52
|
+
async execute(message, { agent = null, model = null, readOnly = false } = {}) {
|
|
53
|
+
const inspection = await this.inspect();
|
|
54
|
+
const isSpecializedAgent = agent && ["Nexus", "Orion", "Astra", "Sage", "Phoenix"].includes(agent);
|
|
30
55
|
|
|
31
|
-
|
|
56
|
+
if (!inspection.available) {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
status: isSpecializedAgent ? "BLOCKED" : "FAILED",
|
|
60
|
+
commandsRun: [],
|
|
61
|
+
eventCount: 0,
|
|
62
|
+
error: "OpenCode CLI is not installed or not available in the system PATH.",
|
|
63
|
+
runtimeAgentRequested: agent || null,
|
|
64
|
+
runtimeAgentApplied: null,
|
|
65
|
+
runtimeAgentSupported: false,
|
|
66
|
+
runtimeAgentSelectionMode: isSpecializedAgent ? "explicit" : "default"
|
|
67
|
+
};
|
|
68
|
+
}
|
|
32
69
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
70
|
+
if (isSpecializedAgent && !inspection.supports.agent) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
status: "BLOCKED",
|
|
74
|
+
error: `Requested runtime actor '${agent}' cannot be applied because this OpenCode runtime does not support --agent.`,
|
|
75
|
+
runtimeAgentRequested: agent,
|
|
76
|
+
runtimeAgentApplied: null,
|
|
77
|
+
runtimeAgentSupported: false,
|
|
78
|
+
runtimeAgentSelectionMode: "unsupported",
|
|
79
|
+
commandsRun: [],
|
|
80
|
+
eventCount: 0
|
|
81
|
+
};
|
|
82
|
+
}
|
|
38
83
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
84
|
+
const runInWorkspace = async (targetCwd) => {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const args = ["run", message];
|
|
87
|
+
if (inspection.supports.formatJson) {
|
|
88
|
+
args.push("--format", "json");
|
|
89
|
+
}
|
|
90
|
+
if (isSpecializedAgent && inspection.supports.agent) {
|
|
91
|
+
args.push("--agent", agent);
|
|
92
|
+
}
|
|
93
|
+
if (model && inspection.supports.model) {
|
|
94
|
+
args.push("--model", model);
|
|
95
|
+
}
|
|
43
96
|
|
|
44
|
-
|
|
45
|
-
let eventCount = 0;
|
|
97
|
+
console.log(`[RUNTIME] Delegating to OpenCode: opencode ${args.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}`);
|
|
46
98
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
const child = spawn("opencode", args, {
|
|
100
|
+
cwd: targetCwd,
|
|
101
|
+
stdio: ["ignore", "pipe", "inherit"] // Inherit stderr to show warnings directly
|
|
102
|
+
});
|
|
50
103
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
104
|
+
const rl = readline.createInterface({
|
|
105
|
+
input: child.stdout,
|
|
106
|
+
terminal: false
|
|
107
|
+
});
|
|
54
108
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
109
|
+
const commandsRun = [];
|
|
110
|
+
let eventCount = 0;
|
|
111
|
+
|
|
112
|
+
rl.on("line", (line) => {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
if (!trimmed) return;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const event = JSON.parse(trimmed);
|
|
118
|
+
eventCount++;
|
|
59
119
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
120
|
+
// 1. Stream agent output text in real-time to user
|
|
121
|
+
if (event.type === "text" && event.part?.text) {
|
|
122
|
+
process.stdout.write(event.part.text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Capture tool executions/commands
|
|
126
|
+
if (event.type === "step_start" && event.part?.toolCalls) {
|
|
127
|
+
for (const call of event.part.toolCalls) {
|
|
128
|
+
if (call.name === "run_command" && call.args?.CommandLine) {
|
|
129
|
+
commandsRun.push(call.args.CommandLine);
|
|
130
|
+
}
|
|
131
|
+
if (readOnly) {
|
|
132
|
+
const name = call.name || "";
|
|
133
|
+
const isWriteTool = name.startsWith("write_") || name.includes("write") || name.includes("replace") || name.includes("edit") || name.includes("delete") || name.includes("remove");
|
|
134
|
+
let isDestructiveGit = false;
|
|
135
|
+
if (name === "run_command" && call.args?.CommandLine) {
|
|
136
|
+
const cmd = call.args.CommandLine;
|
|
137
|
+
const destPattern = /\b(git\s+(commit|add|push|merge|reset|rm|branch|checkout|switch|stash|init)|touch|rm|mkdir|cp|mv|chmod|chown|tee|sed|awk)\b|>>|>/;
|
|
138
|
+
if (destPattern.test(cmd)) {
|
|
139
|
+
isDestructiveGit = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (isWriteTool || isDestructiveGit) {
|
|
143
|
+
console.error(`\n[SECURITY WARNING] Read-only execution blocked attempt to write/modify workspace: tool '${name}' or command detected.`);
|
|
144
|
+
child.kill();
|
|
145
|
+
resolve({
|
|
146
|
+
success: false,
|
|
147
|
+
commandsRun,
|
|
148
|
+
eventCount,
|
|
149
|
+
error: `Read-only confinement violation: tool '${name}' or command attempted write/modify operation during read-only task.`,
|
|
150
|
+
runtimeAgentRequested: agent || null,
|
|
151
|
+
runtimeAgentApplied: null,
|
|
152
|
+
runtimeAgentSupported: inspection.supports.agent,
|
|
153
|
+
runtimeAgentSelectionMode: isSpecializedAgent ? "explicit" : "default"
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
65
158
|
}
|
|
66
159
|
}
|
|
160
|
+
} catch {
|
|
161
|
+
// If a line is not valid JSON (e.g. starting/finishing logs), print it directly
|
|
162
|
+
console.log(trimmed);
|
|
67
163
|
}
|
|
68
|
-
}
|
|
69
|
-
// If a line is not valid JSON (e.g. starting/finishing logs), print it directly
|
|
70
|
-
console.log(trimmed);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
164
|
+
});
|
|
73
165
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
166
|
+
child.on("close", (code) => {
|
|
167
|
+
console.log("\n[RUNTIME] OpenCode finished execution.");
|
|
168
|
+
resolve({
|
|
169
|
+
success: code === 0,
|
|
170
|
+
commandsRun,
|
|
171
|
+
eventCount,
|
|
172
|
+
runtimeAgentRequested: agent || null,
|
|
173
|
+
runtimeAgentApplied: isSpecializedAgent ? agent : null,
|
|
174
|
+
runtimeAgentSupported: inspection.supports.agent,
|
|
175
|
+
runtimeAgentSelectionMode: isSpecializedAgent ? "explicit" : "default"
|
|
176
|
+
});
|
|
80
177
|
});
|
|
81
|
-
});
|
|
82
178
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
179
|
+
child.on("error", (err) => {
|
|
180
|
+
console.error(`\n[RUNTIME] Failed to launch opencode: ${err.message}`);
|
|
181
|
+
resolve({
|
|
182
|
+
success: false,
|
|
183
|
+
commandsRun,
|
|
184
|
+
eventCount: 0,
|
|
185
|
+
error: err.message,
|
|
186
|
+
runtimeAgentRequested: agent || null,
|
|
187
|
+
runtimeAgentApplied: null,
|
|
188
|
+
runtimeAgentSupported: inspection.supports.agent,
|
|
189
|
+
runtimeAgentSelectionMode: isSpecializedAgent ? "explicit" : "default"
|
|
190
|
+
});
|
|
90
191
|
});
|
|
91
192
|
});
|
|
92
|
-
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (readOnly) {
|
|
196
|
+
const { runInReadOnlyWorkspace } = await import("../workspace/read-only-workspace.js");
|
|
197
|
+
return runInReadOnlyWorkspace(this.cwd, runInWorkspace);
|
|
198
|
+
} else {
|
|
199
|
+
return runInWorkspace(this.cwd);
|
|
200
|
+
}
|
|
93
201
|
}
|
|
94
202
|
}
|
|
@@ -42,22 +42,45 @@ export class EvidenceCollector {
|
|
|
42
42
|
if (task.presetStatus) {
|
|
43
43
|
return { name: task.name, command: task.command, kind, status: task.presetStatus, exitCode: null, summary: task.summary, output: "" };
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
let cmd = task.command;
|
|
47
|
+
let args = [];
|
|
48
|
+
if (Array.isArray(cmd)) {
|
|
49
|
+
const [c, ...a] = cmd;
|
|
50
|
+
cmd = c;
|
|
51
|
+
args = a;
|
|
52
|
+
} else if (typeof cmd === "string") {
|
|
53
|
+
const parts = cmd.split(/\s+/);
|
|
54
|
+
cmd = parts[0];
|
|
55
|
+
args = parts.slice(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
const isWin32 = process.platform === "win32";
|
|
46
59
|
const isUncPath = typeof this.cwd === "string" && (this.cwd.startsWith("\\\\") || this.cwd.startsWith("//"));
|
|
47
|
-
const shellOption = isWin32 && isUncPath ? "powershell.exe" : true;
|
|
60
|
+
const shellOption = isWin32 && isUncPath ? "powershell.exe" : (isWin32 ? true : false);
|
|
48
61
|
|
|
49
|
-
const result = spawnSync(
|
|
62
|
+
const result = spawnSync(cmd, args, {
|
|
50
63
|
cwd: this.cwd,
|
|
51
64
|
shell: shellOption,
|
|
52
65
|
encoding: "utf8",
|
|
53
66
|
timeout: this.timeout
|
|
54
67
|
});
|
|
68
|
+
|
|
55
69
|
let status = result.status === 0 ? "PASS" : "FAIL";
|
|
70
|
+
if (result.error) {
|
|
71
|
+
status = "FAIL";
|
|
72
|
+
if (result.error.code === "ETIMEDOUT") {
|
|
73
|
+
status = "BLOCKED";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
let summary = status === "PASS" ? "Command completed successfully." : "Command failed.";
|
|
57
78
|
if (result.error?.code === "ETIMEDOUT") {
|
|
58
|
-
status = "BLOCKED";
|
|
59
79
|
summary = `Command timed out after ${this.timeout / 1000}s.`;
|
|
80
|
+
} else if (result.error) {
|
|
81
|
+
summary = `Command execution error: ${result.error.message}`;
|
|
60
82
|
}
|
|
83
|
+
|
|
61
84
|
const rawOutput = `${result.stdout || ""}${result.stderr || ""}`.trim();
|
|
62
85
|
const output = rawOutput.length > this.maxLogLength ? `${rawOutput.slice(0, this.maxLogLength)}\n... [TRUNCATED]` : rawOutput;
|
|
63
86
|
return { name: task.name, command: task.command, kind, status, exitCode: result.status, signal: result.signal, summary, output };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class StackDetector {
|
|
5
|
+
constructor({ cwd = process.cwd() } = {}) {
|
|
6
|
+
this.cwd = cwd;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async detect() {
|
|
10
|
+
const stacks = [];
|
|
11
|
+
|
|
12
|
+
// 1. Node check
|
|
13
|
+
const packageJsonExists = await fs.access(path.join(this.cwd, "package.json")).then(() => true).catch(() => false);
|
|
14
|
+
if (packageJsonExists) {
|
|
15
|
+
stacks.push("node");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Python check
|
|
19
|
+
const pyprojectExists = await fs.access(path.join(this.cwd, "pyproject.toml")).then(() => true).catch(() => false) ||
|
|
20
|
+
await fs.access(path.join(this.cwd, "requirements.txt")).then(() => true).catch(() => false) ||
|
|
21
|
+
await fs.access(path.join(this.cwd, "setup.py")).then(() => true).catch(() => false) ||
|
|
22
|
+
await fs.access(path.join(this.cwd, "Pipfile")).then(() => true).catch(() => false);
|
|
23
|
+
|
|
24
|
+
let hasPythonFiles = pyprojectExists;
|
|
25
|
+
if (!hasPythonFiles) {
|
|
26
|
+
hasPythonFiles = await this.hasFileWithExtension(this.cwd, ".py");
|
|
27
|
+
}
|
|
28
|
+
if (hasPythonFiles) {
|
|
29
|
+
stacks.push("python");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 3. PHP check
|
|
33
|
+
const composerExists = await fs.access(path.join(this.cwd, "composer.json")).then(() => true).catch(() => false);
|
|
34
|
+
let hasPhpFiles = composerExists;
|
|
35
|
+
if (!hasPhpFiles) {
|
|
36
|
+
hasPhpFiles = await this.hasFileWithExtension(this.cwd, ".php");
|
|
37
|
+
}
|
|
38
|
+
if (hasPhpFiles) {
|
|
39
|
+
stacks.push("php");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return stacks;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async hasFileWithExtension(dir, ext) {
|
|
46
|
+
try {
|
|
47
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
if (["node_modules", "vendor", ".git", "dist", "build", ".cache"].includes(entry.name)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const subPath = path.join(dir, entry.name);
|
|
54
|
+
const found = await this.hasFileWithExtension(subPath, ext);
|
|
55
|
+
if (found) return true;
|
|
56
|
+
} else if (entry.isFile() && entry.name.endsWith(ext)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|