@williambeto/ai-workflow 2.2.7 → 2.3.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.
@@ -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,161 @@ 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
+ if (!inspection.available) {
55
+ return {
56
+ success: false,
57
+ commandsRun: [],
58
+ eventCount: 0,
59
+ error: "OpenCode CLI is not installed or not available in the system PATH."
60
+ };
61
+ }
62
+
63
+ const runInWorkspace = async (targetCwd) => {
64
+ return new Promise((resolve) => {
65
+ const args = ["run", message];
66
+ if (inspection.supports.formatJson) {
67
+ args.push("--format", "json");
68
+ }
69
+ if (agent && inspection.supports.agent) {
70
+ args.push("--agent", agent);
71
+ }
72
+ if (model && inspection.supports.model) {
73
+ args.push("--model", model);
74
+ }
30
75
 
31
- console.log(`[RUNTIME] Delegating to OpenCode: opencode ${args.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}`);
76
+ console.log(`[RUNTIME] Delegating to OpenCode: opencode ${args.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}`);
32
77
 
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
- });
78
+ const child = spawn("opencode", args, {
79
+ cwd: targetCwd,
80
+ stdio: ["ignore", "pipe", "inherit"] // Inherit stderr to show warnings directly
81
+ });
38
82
 
39
- const rl = readline.createInterface({
40
- input: child.stdout,
41
- terminal: false
42
- });
83
+ const rl = readline.createInterface({
84
+ input: child.stdout,
85
+ terminal: false
86
+ });
43
87
 
44
- const commandsRun = [];
45
- let eventCount = 0;
88
+ const commandsRun = [];
89
+ let eventCount = 0;
46
90
 
47
- rl.on("line", (line) => {
48
- const trimmed = line.trim();
49
- if (!trimmed) return;
91
+ rl.on("line", (line) => {
92
+ const trimmed = line.trim();
93
+ if (!trimmed) return;
50
94
 
51
- try {
52
- const event = JSON.parse(trimmed);
53
- eventCount++;
95
+ try {
96
+ const event = JSON.parse(trimmed);
97
+ eventCount++;
54
98
 
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
- }
99
+ // 1. Stream agent output text in real-time to user
100
+ if (event.type === "text" && event.part?.text) {
101
+ process.stdout.write(event.part.text);
102
+ }
59
103
 
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);
104
+ // 2. Capture tool executions/commands
105
+ if (event.type === "step_start" && event.part?.toolCalls) {
106
+ for (const call of event.part.toolCalls) {
107
+ if (call.name === "run_command" && call.args?.CommandLine) {
108
+ commandsRun.push(call.args.CommandLine);
109
+ }
110
+ if (readOnly) {
111
+ const name = call.name || "";
112
+ const isWriteTool = name.startsWith("write_") || name.includes("write") || name.includes("replace") || name.includes("edit") || name.includes("delete") || name.includes("remove");
113
+ let isDestructiveGit = false;
114
+ if (name === "run_command" && call.args?.CommandLine) {
115
+ const cmd = call.args.CommandLine;
116
+ 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|>>|>/;
117
+ if (destPattern.test(cmd)) {
118
+ isDestructiveGit = true;
119
+ }
120
+ }
121
+ if (isWriteTool || isDestructiveGit) {
122
+ console.error(`\n[SECURITY WARNING] Read-only execution blocked attempt to write/modify workspace: tool '${name}' or command detected.`);
123
+ child.kill();
124
+ resolve({
125
+ success: false,
126
+ commandsRun,
127
+ eventCount,
128
+ error: `Read-only confinement violation: tool '${name}' or command attempted write/modify operation during read-only task.`
129
+ });
130
+ return;
131
+ }
132
+ }
65
133
  }
66
134
  }
135
+ } catch {
136
+ // If a line is not valid JSON (e.g. starting/finishing logs), print it directly
137
+ console.log(trimmed);
67
138
  }
68
- } catch {
69
- // If a line is not valid JSON (e.g. starting/finishing logs), print it directly
70
- console.log(trimmed);
71
- }
72
- });
139
+ });
73
140
 
74
- child.on("close", (code) => {
75
- console.log("\n[RUNTIME] OpenCode finished execution.");
76
- resolve({
77
- success: code === 0,
78
- commandsRun,
79
- eventCount
141
+ child.on("close", (code) => {
142
+ console.log("\n[RUNTIME] OpenCode finished execution.");
143
+ resolve({
144
+ success: code === 0,
145
+ commandsRun,
146
+ eventCount
147
+ });
80
148
  });
81
- });
82
149
 
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
150
+ child.on("error", (err) => {
151
+ console.error(`\n[RUNTIME] Failed to launch opencode: ${err.message}`);
152
+ resolve({
153
+ success: false,
154
+ commandsRun,
155
+ eventCount: 0,
156
+ error: err.message
157
+ });
90
158
  });
91
159
  });
92
- });
160
+ };
161
+
162
+ if (readOnly) {
163
+ const { runInReadOnlyWorkspace } = await import("../workspace/read-only-workspace.js");
164
+ return runInReadOnlyWorkspace(this.cwd, runInWorkspace);
165
+ } else {
166
+ return runInWorkspace(this.cwd);
167
+ }
93
168
  }
94
169
  }
@@ -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
+ }
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { StackDetector } from "./stack-detector.js";
4
+
5
+ export class ValidationPlanner {
6
+ constructor({ cwd = process.cwd(), qualityGuard } = {}) {
7
+ this.cwd = cwd;
8
+ this.qualityGuard = qualityGuard;
9
+ this.stackDetector = new StackDetector({ cwd });
10
+ }
11
+
12
+ async plan(profile = "generic") {
13
+ const stacks = await this.stackDetector.detect();
14
+ const tasks = [];
15
+
16
+ const changedFiles = this.qualityGuard.getChangedFiles().filter((file) => {
17
+ return !/(^|\/)(node_modules|vendor|dist|coverage|\.cache)(\/|$)/.test(String(file).replaceAll("\\", "/"));
18
+ });
19
+ const executableBehavior = await this.qualityGuard.hasExecutableBehaviorChanges();
20
+
21
+ // Node Stack Planning
22
+ if (stacks.includes("node")) {
23
+ try {
24
+ const pkg = JSON.parse(await fs.readFile(path.join(this.cwd, "package.json"), "utf8"));
25
+ const scripts = pkg.scripts || {};
26
+
27
+ this.addNodeScriptTask(tasks, scripts, "lint", ["npm", "run", "lint"]);
28
+ this.addNodeScriptTask(tasks, scripts, "test", ["npm", "test"], { rejectNoOp: executableBehavior });
29
+ this.addNodeScriptTask(tasks, scripts, "build", ["npm", "run", "build"]);
30
+ this.addNodeScriptTask(tasks, scripts, "typecheck", ["npm", "run", "typecheck"]);
31
+
32
+ if (scripts.validate && !String(scripts.validate).includes("ai-workflow collect-evidence")) {
33
+ this.addNodeScriptTask(tasks, scripts, "validate", ["npm", "run", "validate"]);
34
+ }
35
+
36
+ const hasTs = changedFiles.some((file) => /\.(ts|tsx)$/.test(file)) ||
37
+ await fs.access(path.join(this.cwd, "tsconfig.json")).then(() => true).catch(() => false);
38
+ if (hasTs && !scripts.typecheck) {
39
+ tasks.push({
40
+ name: "typecheck",
41
+ command: ["npm", "run", "typecheck"],
42
+ kind: "typecheck",
43
+ presetStatus: "FAIL_QUALITY_GATE",
44
+ summary: "TypeScript detected but no typecheck script is configured."
45
+ });
46
+ }
47
+ } catch {
48
+ // Fallback or ignore if pkg read fails
49
+ }
50
+ }
51
+
52
+ // Python Stack Planning
53
+ if (stacks.includes("python")) {
54
+ const pythonFiles = changedFiles.filter(f => f.endsWith(".py"));
55
+
56
+ // Compiler syntax check for changed python files
57
+ if (pythonFiles.length > 0) {
58
+ tasks.push({
59
+ name: "python-syntax",
60
+ command: ["python", "-m", "py_compile", ...pythonFiles],
61
+ kind: "lint"
62
+ });
63
+ }
64
+
65
+ // Check if testing is needed
66
+ if (executableBehavior) {
67
+ // Check if pytest or unittest is configured
68
+ const hasPytestConfig = await fs.access(path.join(this.cwd, "pytest.ini")).then(() => true).catch(() => false) ||
69
+ await fs.access(path.join(this.cwd, "pyproject.toml")).then(() => true).catch(() => false);
70
+
71
+ if (hasPytestConfig || pythonFiles.length > 0) {
72
+ tasks.push({
73
+ name: "python-test",
74
+ command: ["pytest"],
75
+ kind: "test"
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ // PHP Stack Planning
82
+ if (stacks.includes("php")) {
83
+ const phpFiles = changedFiles.filter(f => f.endsWith(".php") || f.endsWith(".inc"));
84
+
85
+ // Compiler syntax check for each changed PHP file - split into separate tasks
86
+ if (phpFiles.length > 0) {
87
+ for (const file of phpFiles) {
88
+ tasks.push({
89
+ name: `php-syntax:${file}`,
90
+ command: ["php", "-l", file],
91
+ kind: "lint"
92
+ });
93
+ }
94
+ }
95
+
96
+ // Check if testing is needed
97
+ if (executableBehavior) {
98
+ const hasPhpUnitXml = await fs.access(path.join(this.cwd, "phpunit.xml")).then(() => true).catch(() => false) ||
99
+ await fs.access(path.join(this.cwd, "phpunit.xml.dist")).then(() => true).catch(() => false);
100
+ if (hasPhpUnitXml || phpFiles.length > 0) {
101
+ tasks.push({
102
+ name: "php-test",
103
+ command: ["vendor/bin/phpunit"],
104
+ kind: "test"
105
+ });
106
+ }
107
+ }
108
+ }
109
+
110
+ return tasks;
111
+ }
112
+
113
+ addNodeScriptTask(tasks, scripts, name, command, { rejectNoOp = false } = {}) {
114
+ const script = scripts[name];
115
+ if (!script) return;
116
+ if (!rejectNoOp && name === "test" && this.isNoOpScript(script)) return;
117
+ if (rejectNoOp && this.isNoOpScript(script)) {
118
+ tasks.push({
119
+ name,
120
+ command,
121
+ kind: name === "test" ? "test" : name,
122
+ presetStatus: "FAIL_QUALITY_GATE",
123
+ summary: `Configured ${name} script is a no-op: ${script}`
124
+ });
125
+ return;
126
+ }
127
+ tasks.push({ name, command, kind: name === "test" ? "test" : name });
128
+ }
129
+
130
+ isNoOpScript(script = "") {
131
+ const value = String(script).trim().toLowerCase();
132
+ return /^(echo\b|true$|exit\s+0$|node\s+-e\s+["']?console\.log)/.test(value) || value.includes("tests-pass");
133
+ }
134
+ }
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto from "node:crypto";
5
+
6
+ async function copyDirectory(src, dest) {
7
+ const entries = await fs.readdir(src, { withFileTypes: true });
8
+ for (const entry of entries) {
9
+ if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "tmp") {
10
+ continue;
11
+ }
12
+ const srcPath = path.join(src, entry.name);
13
+ const destPath = path.join(dest, entry.name);
14
+
15
+ if (entry.isDirectory()) {
16
+ await fs.mkdir(destPath, { recursive: true });
17
+ await copyDirectory(srcPath, destPath);
18
+ } else if (entry.isFile()) {
19
+ await fs.copyFile(srcPath, destPath);
20
+ }
21
+ }
22
+ }
23
+
24
+ async function captureHashSnapshot(dir) {
25
+ const snapshot = {};
26
+ await scanDir(dir, dir, snapshot);
27
+ return snapshot;
28
+ }
29
+
30
+ async function scanDir(baseDir, currentDir, snapshot) {
31
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
32
+ for (const entry of entries) {
33
+ if (entry.name === "opencode.jsonc" || entry.name === "node_modules" || entry.name === ".git" || entry.name === "EVIDENCE.json" || entry.name === "tmp") {
34
+ continue;
35
+ }
36
+ const fullPath = path.join(currentDir, entry.name);
37
+ const relPath = path.relative(baseDir, fullPath);
38
+
39
+ if (entry.isDirectory()) {
40
+ await scanDir(baseDir, fullPath, snapshot);
41
+ } else if (entry.isFile()) {
42
+ try {
43
+ const content = await fs.readFile(fullPath);
44
+ const hash = crypto.createHash("sha256").update(content).digest("hex");
45
+ snapshot[relPath] = hash;
46
+ } catch {
47
+ // ignore
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Runs a function inside a temporary copy of the workspace.
55
+ * Mutating the workspace inside this temp directory will trigger a BLOCKED result.
56
+ */
57
+ export async function runInReadOnlyWorkspace(originalCwd, runFn) {
58
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-ro-"));
59
+
60
+ try {
61
+ // 1. Copy repository files recursively (excluding git/node_modules/tmp)
62
+ await copyDirectory(originalCwd, tempDir);
63
+
64
+ // 2. Write the required opencode.jsonc permission config file
65
+ const opencodeConfig = {
66
+ permission: {
67
+ edit: "deny",
68
+ external_directory: "deny",
69
+ bash: {
70
+ "*": "deny",
71
+ "git status*": "allow",
72
+ "git diff*": "allow",
73
+ "git log*": "allow",
74
+ "grep *": "allow",
75
+ "find *": "allow"
76
+ }
77
+ }
78
+ };
79
+ await fs.writeFile(
80
+ path.join(tempDir, "opencode.jsonc"),
81
+ JSON.stringify(opencodeConfig, null, 2)
82
+ );
83
+
84
+ // 3. Capture initial file hash snapshot
85
+ const snapshotBefore = await captureHashSnapshot(tempDir);
86
+
87
+ // 4. Execute the callback function inside the temp directory
88
+ const runResult = await runFn(tempDir);
89
+
90
+ // 5. Capture post-run snapshot and verify no file mutations occurred
91
+ const snapshotAfter = await captureHashSnapshot(tempDir);
92
+
93
+ const mutated = [];
94
+ for (const [file, hash] of Object.entries(snapshotAfter)) {
95
+ if (snapshotBefore[file] !== hash) {
96
+ mutated.push(file);
97
+ }
98
+ }
99
+ for (const file of Object.keys(snapshotBefore)) {
100
+ if (snapshotAfter[file] === undefined) {
101
+ mutated.push(file);
102
+ }
103
+ }
104
+
105
+ if (mutated.length > 0) {
106
+ return {
107
+ success: false,
108
+ error: `Read-only confinement violation: mutating changes detected inside temporary workspace: ${mutated.join(", ")}`,
109
+ commandsRun: runResult.commandsRun || [],
110
+ eventCount: runResult.eventCount || 0
111
+ };
112
+ }
113
+
114
+ return runResult;
115
+ } finally {
116
+ // 6. Delete the temporary workspace copy
117
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
118
+ }
119
+ }