@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.
Files changed (68) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +34 -0
  3. package/dist-assets/agents/astra.md +74 -45
  4. package/dist-assets/agents/atlas.md +110 -152
  5. package/dist-assets/agents/nexus.md +64 -19
  6. package/dist-assets/agents/orion.md +72 -27
  7. package/dist-assets/agents/phoenix.md +64 -19
  8. package/dist-assets/agents/sage.md +67 -36
  9. package/dist-assets/commands/atlas.md +66 -6
  10. package/dist-assets/commands/audit.md +62 -4
  11. package/dist-assets/commands/deploy.md +66 -5
  12. package/dist-assets/commands/discover.md +72 -4
  13. package/dist-assets/commands/implement.md +66 -18
  14. package/dist-assets/commands/optimize-tokens.md +60 -4
  15. package/dist-assets/commands/plan.md +64 -4
  16. package/dist-assets/commands/release.md +76 -5
  17. package/dist-assets/commands/run.md +62 -16
  18. package/dist-assets/commands/spec-create.md +66 -4
  19. package/dist-assets/commands/spec-implement.md +68 -4
  20. package/dist-assets/commands/spec-review.md +63 -4
  21. package/dist-assets/commands/update-memory.md +62 -4
  22. package/dist-assets/commands/validate.md +70 -6
  23. package/dist-assets/docs/cli-reference.md +38 -10
  24. package/dist-assets/skills/architecture/SKILL.md +62 -7
  25. package/dist-assets/skills/backend-development/SKILL.md +62 -7
  26. package/dist-assets/skills/deployment/SKILL.md +62 -7
  27. package/dist-assets/skills/design-principles/SKILL.md +59 -7
  28. package/dist-assets/skills/documentation/SKILL.md +61 -7
  29. package/dist-assets/skills/frontend-development/SKILL.md +62 -7
  30. package/dist-assets/skills/full-stack-development/SKILL.md +62 -7
  31. package/dist-assets/skills/optimize-tokens/SKILL.md +61 -7
  32. package/dist-assets/skills/pr-workflow/SKILL.md +65 -7
  33. package/dist-assets/skills/product-discovery/SKILL.md +81 -7
  34. package/dist-assets/skills/product-planning/SKILL.md +62 -7
  35. package/dist-assets/skills/project-memory/SKILL.md +55 -22
  36. package/dist-assets/skills/prompt-engineer/SKILL.md +59 -7
  37. package/dist-assets/skills/qa-workflow/SKILL.md +72 -7
  38. package/dist-assets/skills/refactoring/SKILL.md +68 -7
  39. package/dist-assets/skills/release-workflow/SKILL.md +72 -7
  40. package/dist-assets/skills/spec-driven-development/SKILL.md +75 -7
  41. package/dist-assets/skills/technical-leadership/SKILL.md +61 -7
  42. package/dist-assets/skills/ui-ux-design/SKILL.md +60 -7
  43. package/docs/compatibility/provider-usage.md +46 -0
  44. package/docs/compatibility/runtime-matrix.md +31 -0
  45. package/docs/getting-started/DESKTOP_PROMPT.md +52 -0
  46. package/docs/getting-started/quickstart.md +17 -0
  47. package/docs/getting-started/upgrading-to-v2.md +55 -0
  48. package/package.json +20 -4
  49. package/read_only_safety_verification.md +48 -0
  50. package/src/cli.js +4 -2
  51. package/src/commands/collect-evidence.js +3 -39
  52. package/src/commands/doctor.js +16 -0
  53. package/src/commands/execute.js +312 -119
  54. package/src/core/delegation-controller.js +193 -0
  55. package/src/core/evidence/evidence-ledger.js +53 -0
  56. package/src/core/execution-planner.js +4 -1
  57. package/src/core/finalization/finalizer.js +77 -0
  58. package/src/core/finalization/workspace-snapshot.js +110 -0
  59. package/src/core/gates/branch-gate.js +23 -35
  60. package/src/core/healing/healer-engine.js +34 -1
  61. package/src/core/healing/runtime-remediation-executor.js +136 -0
  62. package/src/core/request-classifier.js +15 -5
  63. package/src/core/request-router.js +289 -0
  64. package/src/core/runtime/opencode-adapter.js +170 -62
  65. package/src/core/validation/evidence-collector.js +26 -3
  66. package/src/core/validation/stack-detector.js +65 -0
  67. package/src/core/validation/validation-planner.js +134 -0
  68. 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, execSync } from "node:child_process";
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, dangerouslySkipPermissions).
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, dangerouslySkipPermissions = false } = {}) {
19
- return new Promise((resolve) => {
20
- const args = ["run", message, "--format", "json"];
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
- console.log(`[RUNTIME] Delegating to OpenCode: opencode ${args.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}`);
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
- const child = spawn("opencode", args, {
34
- cwd: this.cwd,
35
- shell: true,
36
- stdio: ["ignore", "pipe", "inherit"] // Inherit stderr to show warnings directly
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
- const rl = readline.createInterface({
40
- input: child.stdout,
41
- terminal: false
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
- const commandsRun = [];
45
- let eventCount = 0;
97
+ console.log(`[RUNTIME] Delegating to OpenCode: opencode ${args.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}`);
46
98
 
47
- rl.on("line", (line) => {
48
- const trimmed = line.trim();
49
- if (!trimmed) return;
99
+ const child = spawn("opencode", args, {
100
+ cwd: targetCwd,
101
+ stdio: ["ignore", "pipe", "inherit"] // Inherit stderr to show warnings directly
102
+ });
50
103
 
51
- try {
52
- const event = JSON.parse(trimmed);
53
- eventCount++;
104
+ const rl = readline.createInterface({
105
+ input: child.stdout,
106
+ terminal: false
107
+ });
54
108
 
55
- // 1. Stream agent output text in real-time to user
56
- if (event.type === "text" && event.part?.text) {
57
- process.stdout.write(event.part.text);
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
- // 2. Capture tool executions/commands
61
- if (event.type === "step_start" && event.part?.toolCalls) {
62
- for (const call of event.part.toolCalls) {
63
- if (call.name === "run_command" && call.args?.CommandLine) {
64
- commandsRun.push(call.args.CommandLine);
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
- } catch {
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
- child.on("close", (code) => {
75
- console.log("\n[RUNTIME] OpenCode finished execution.");
76
- resolve({
77
- success: code === 0,
78
- commandsRun,
79
- eventCount
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
- child.on("error", (err) => {
84
- console.error(`\n[RUNTIME] Failed to launch opencode: ${err.message}`);
85
- resolve({
86
- success: false,
87
- commandsRun,
88
- eventCount: 0,
89
- error: err.message
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(task.command, {
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
+ }