@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.
- package/CHANGELOG.md +14 -0
- package/dist-assets/docs/cli-reference.md +38 -10
- package/docs/releases/v2.3.0-release-decision.md +35 -0
- package/package.json +14 -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 +303 -119
- package/src/core/delegation-controller.js +134 -0
- package/src/core/evidence/evidence-ledger.js +53 -0
- package/src/core/execution-planner.js +5 -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/runtime/opencode-adapter.js +137 -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,77 @@
|
|
|
1
|
+
import { WorkspaceSnapshot } from "./workspace-snapshot.js";
|
|
2
|
+
|
|
3
|
+
export class Finalizer {
|
|
4
|
+
constructor({ cwd = process.cwd() } = {}) {
|
|
5
|
+
this.cwd = cwd;
|
|
6
|
+
this.snapshotManager = new WorkspaceSnapshot({ cwd });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compares the current workspace state with a previously captured snapshot.
|
|
11
|
+
* @param {Object} previousSnapshot - The snapshot object captured earlier.
|
|
12
|
+
* @returns {Object} { valid: boolean, changes: { added: string[], modified: string[], deleted: string[] } }
|
|
13
|
+
*/
|
|
14
|
+
async verifyIntegrity(previousSnapshot) {
|
|
15
|
+
const currentSnapshot = await this.snapshotManager.capture();
|
|
16
|
+
const added = [];
|
|
17
|
+
const modified = [];
|
|
18
|
+
const deleted = [];
|
|
19
|
+
|
|
20
|
+
let valid = true;
|
|
21
|
+
|
|
22
|
+
// Compare metadata and git trees
|
|
23
|
+
if (previousSnapshot.branch !== currentSnapshot.branch) {
|
|
24
|
+
modified.push(`branch:${previousSnapshot.branch}->${currentSnapshot.branch}`);
|
|
25
|
+
valid = false;
|
|
26
|
+
}
|
|
27
|
+
if (previousSnapshot.head !== currentSnapshot.head) {
|
|
28
|
+
modified.push(`head:${previousSnapshot.head}->${currentSnapshot.head}`);
|
|
29
|
+
valid = false;
|
|
30
|
+
}
|
|
31
|
+
if (previousSnapshot.indexTree !== currentSnapshot.indexTree) {
|
|
32
|
+
modified.push(`indexTree:${previousSnapshot.indexTree}->${currentSnapshot.indexTree}`);
|
|
33
|
+
valid = false;
|
|
34
|
+
}
|
|
35
|
+
if (previousSnapshot.stagedDiffHash !== currentSnapshot.stagedDiffHash) {
|
|
36
|
+
modified.push("stagedDiff");
|
|
37
|
+
valid = false;
|
|
38
|
+
}
|
|
39
|
+
if (previousSnapshot.unstagedDiffHash !== currentSnapshot.unstagedDiffHash) {
|
|
40
|
+
modified.push("unstagedDiff");
|
|
41
|
+
valid = false;
|
|
42
|
+
}
|
|
43
|
+
if (previousSnapshot.untrackedFilesHash !== currentSnapshot.untrackedFilesHash) {
|
|
44
|
+
modified.push("untrackedFiles");
|
|
45
|
+
valid = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check for additions and modifications in files
|
|
49
|
+
const prevFiles = previousSnapshot.files || {};
|
|
50
|
+
const currFiles = currentSnapshot.files || {};
|
|
51
|
+
|
|
52
|
+
for (const [file, info] of Object.entries(currFiles)) {
|
|
53
|
+
const prevInfo = prevFiles[file];
|
|
54
|
+
if (!prevInfo) {
|
|
55
|
+
added.push(file);
|
|
56
|
+
} else if (prevInfo.sha256 !== info.sha256 || prevInfo.mode !== info.mode) {
|
|
57
|
+
modified.push(file);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check for deletions in files
|
|
62
|
+
for (const file of Object.keys(prevFiles)) {
|
|
63
|
+
if (!currFiles[file]) {
|
|
64
|
+
deleted.push(file);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (added.length > 0 || modified.length > 0 || deleted.length > 0) {
|
|
69
|
+
valid = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
valid,
|
|
74
|
+
changes: { added, modified, deleted }
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export class WorkspaceSnapshot {
|
|
7
|
+
constructor({ cwd = process.cwd() } = {}) {
|
|
8
|
+
this.cwd = cwd;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
isIgnored(relativePath) {
|
|
12
|
+
const norm = relativePath.replaceAll("\\", "/");
|
|
13
|
+
if (norm === "node_modules" || norm.startsWith("node_modules/")) return true;
|
|
14
|
+
if (norm === "vendor" || norm.startsWith("vendor/")) return true;
|
|
15
|
+
if (norm === ".git" || norm.startsWith(".git/")) return true;
|
|
16
|
+
if (norm === "EVIDENCE.json") return true;
|
|
17
|
+
if (norm === ".ai-workflow/EVIDENCE.json") return true;
|
|
18
|
+
if (norm.startsWith(".ai-workflow/history/")) return true;
|
|
19
|
+
if (norm.startsWith(".ai-workflow/handoffs/")) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async capture() {
|
|
24
|
+
const files = {};
|
|
25
|
+
await this.scanDir(this.cwd, files);
|
|
26
|
+
|
|
27
|
+
const execGit = (args) => {
|
|
28
|
+
try {
|
|
29
|
+
return execSync(`git ${args}`, { cwd: this.cwd, encoding: "utf8", stdio: "pipe" }).trim();
|
|
30
|
+
} catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const branch = execGit("rev-parse --abbrev-ref HEAD");
|
|
36
|
+
const head = execGit("rev-parse HEAD");
|
|
37
|
+
const indexTree = execGit("write-tree");
|
|
38
|
+
|
|
39
|
+
const stagedDiff = execGit("diff --cached");
|
|
40
|
+
const unstagedDiff = execGit("diff");
|
|
41
|
+
const untrackedFiles = execGit("status --porcelain");
|
|
42
|
+
|
|
43
|
+
const hashString = (str) => {
|
|
44
|
+
return crypto.createHash("sha256").update(str).digest("hex");
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
branch,
|
|
49
|
+
head,
|
|
50
|
+
indexTree,
|
|
51
|
+
files,
|
|
52
|
+
stagedDiffHash: hashString(stagedDiff),
|
|
53
|
+
unstagedDiffHash: hashString(unstagedDiff),
|
|
54
|
+
untrackedFilesHash: hashString(untrackedFiles)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async scanDir(dir, filesMap) {
|
|
59
|
+
try {
|
|
60
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const fullPath = path.join(dir, entry.name);
|
|
63
|
+
const relativePath = path.relative(this.cwd, fullPath);
|
|
64
|
+
|
|
65
|
+
if (this.isIgnored(relativePath)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
await this.scanDir(fullPath, filesMap);
|
|
71
|
+
} else if (entry.isFile()) {
|
|
72
|
+
try {
|
|
73
|
+
const stat = await fs.stat(fullPath);
|
|
74
|
+
const content = await fs.readFile(fullPath);
|
|
75
|
+
const sha256 = crypto.createHash("sha256").update(content).digest("hex");
|
|
76
|
+
const isExecutable = (stat.mode & 0o111) !== 0;
|
|
77
|
+
const mode = isExecutable ? "100755" : "100644";
|
|
78
|
+
|
|
79
|
+
filesMap[relativePath] = {
|
|
80
|
+
sha256,
|
|
81
|
+
mode
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
// ignore unreadable/locked files
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
calculateHash(snapshot) {
|
|
94
|
+
const hash = crypto.createHash("sha256");
|
|
95
|
+
hash.update(snapshot.branch || "");
|
|
96
|
+
hash.update(snapshot.head || "");
|
|
97
|
+
hash.update(snapshot.indexTree || "");
|
|
98
|
+
hash.update(snapshot.stagedDiffHash || "");
|
|
99
|
+
hash.update(snapshot.unstagedDiffHash || "");
|
|
100
|
+
hash.update(snapshot.untrackedFilesHash || "");
|
|
101
|
+
|
|
102
|
+
const sortedFiles = Object.keys(snapshot.files || {}).sort();
|
|
103
|
+
for (const file of sortedFiles) {
|
|
104
|
+
hash.update(file);
|
|
105
|
+
hash.update(snapshot.files[file].sha256);
|
|
106
|
+
hash.update(snapshot.files[file].mode);
|
|
107
|
+
}
|
|
108
|
+
return hash.digest("hex");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -76,66 +76,54 @@ export class BranchGate {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
*
|
|
80
|
-
* @param {{autoRecover?: boolean, taskSlug?: string, readOnly?: boolean}} options
|
|
79
|
+
* Strictly verifies branch safety without any override bypasses.
|
|
81
80
|
*/
|
|
82
|
-
check(
|
|
81
|
+
check(overrideIgnored = "", { taskSlug = "implementation", readOnly = false } = {}) {
|
|
83
82
|
if (readOnly) {
|
|
84
83
|
const currentBranch = this.getCurrentBranch();
|
|
85
84
|
return { blocked: false, branch: currentBranch, recovered: false, readOnly: true };
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
try {
|
|
89
|
-
//
|
|
88
|
+
// 1. Verify inside worktree (Git availability)
|
|
90
89
|
try {
|
|
91
90
|
this.run("git rev-parse --is-inside-work-tree");
|
|
92
91
|
} catch (e) {
|
|
93
|
-
const reason = "
|
|
92
|
+
const reason = "Git is unavailable or not inside a Git repository. Implementation work is blocked.";
|
|
94
93
|
this.log(`BLOCKED: ${reason}`);
|
|
95
94
|
return { blocked: true, branch: "unknown", reason };
|
|
96
95
|
}
|
|
97
96
|
|
|
97
|
+
// 2. Get current branch
|
|
98
98
|
const currentBranch = this.getCurrentBranch();
|
|
99
|
-
if (currentBranch === "unknown") {
|
|
99
|
+
if (!currentBranch || currentBranch === "unknown" || currentBranch.trim() === "") {
|
|
100
100
|
const reason = "Could not determine current Git branch name. Implementation work is blocked.";
|
|
101
101
|
this.log(`BLOCKED: ${reason}`);
|
|
102
102
|
return { blocked: true, branch: "unknown", reason };
|
|
103
103
|
}
|
|
104
|
-
const isProtected = this.protectedBranches.includes(currentBranch);
|
|
105
|
-
if (!isProtected) return { blocked: false, branch: currentBranch, recovered: false };
|
|
106
104
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return { blocked: false, branch: currentBranch,
|
|
105
|
+
const isProtected = this.protectedBranches.includes(currentBranch);
|
|
106
|
+
if (!isProtected) {
|
|
107
|
+
return { blocked: false, branch: currentBranch, recovered: false };
|
|
110
108
|
}
|
|
111
109
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const recoveredBranch = this.createScopedBranch(taskSlug);
|
|
121
|
-
this.log(`AUTO-RECOVERED '${currentBranch}' -> '${recoveredBranch}'`);
|
|
122
|
-
return {
|
|
123
|
-
blocked: false,
|
|
124
|
-
branch: recoveredBranch,
|
|
125
|
-
branchBefore: currentBranch,
|
|
126
|
-
recovered: true,
|
|
127
|
-
dirtyState: dirty
|
|
128
|
-
};
|
|
110
|
+
// 3. Protected branch checks - any dirty file blocks
|
|
111
|
+
const dirty = this.getDirtyState();
|
|
112
|
+
if (!dirty.clean) {
|
|
113
|
+
const reason = `Direct writes to protected branch '${currentBranch}' are prohibited and the branch is dirty (has changes).`;
|
|
114
|
+
this.log(`BLOCKED: ${reason}`);
|
|
115
|
+
return { blocked: true, branch: currentBranch, reason, dirtyState: dirty };
|
|
129
116
|
}
|
|
130
117
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.log(`BLOCKED ATTEMPT on '${currentBranch}': ${reason}`);
|
|
118
|
+
// 4. Create scoped branch (Auto-recover)
|
|
119
|
+
const recoveredBranch = this.createScopedBranch(taskSlug);
|
|
120
|
+
this.log(`AUTO-RECOVERED '${currentBranch}' -> '${recoveredBranch}'`);
|
|
135
121
|
return {
|
|
136
|
-
blocked:
|
|
137
|
-
branch:
|
|
138
|
-
|
|
122
|
+
blocked: false,
|
|
123
|
+
branch: recoveredBranch,
|
|
124
|
+
branchBefore: currentBranch,
|
|
125
|
+
recovered: true,
|
|
126
|
+
dirtyState: dirty
|
|
139
127
|
};
|
|
140
128
|
} catch (error) {
|
|
141
129
|
const reason = `Git command failure on branch gate check: ${error.message}`;
|
|
@@ -36,11 +36,12 @@ function collectBlockingFindings(result = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export class HealerEngine {
|
|
39
|
-
constructor({ cwd, mode = "standard", taskSlug = "implementation", maxAttempts = null } = {}) {
|
|
39
|
+
constructor({ cwd, mode = "standard", taskSlug = "implementation", maxAttempts = null, ledger = null } = {}) {
|
|
40
40
|
this.cwd = cwd;
|
|
41
41
|
this.mode = MODE_ATTEMPTS[mode] ? mode : "standard";
|
|
42
42
|
this.taskSlug = taskSlug;
|
|
43
43
|
this.maxAttempts = maxAttempts ?? MODE_ATTEMPTS[this.mode];
|
|
44
|
+
this.ledger = ledger;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
get statePath() {
|
|
@@ -139,6 +140,38 @@ export class HealerEngine {
|
|
|
139
140
|
|
|
140
141
|
const next = await validate({ affectedChecks: this.affectedChecks(current), attempt });
|
|
141
142
|
const progress = this.hasProgress(current, next, remediation);
|
|
143
|
+
|
|
144
|
+
// Verify strict conditions to declare Phoenix remediation applied/completed
|
|
145
|
+
const findingsConcrete = this.affectedChecks(current).length > 0;
|
|
146
|
+
const hashAltered = remediation.changedFiles && remediation.changedFiles.length > 0;
|
|
147
|
+
const inScope = hashAltered && remediation.changedFiles.every(file => {
|
|
148
|
+
const norm = file.replaceAll("\\", "/");
|
|
149
|
+
if (norm.startsWith(".ai-workflow/")) return false;
|
|
150
|
+
if (norm === "package.json" || norm === "package-lock.json" || norm === "tsconfig.json" || norm === ".gitignore" || norm === "opencode.jsonc" || norm === "EVIDENCE.json") {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
const validationPassed = next.overallStatus === "PASS" || next.overallStatus === "PASS_WITH_NOTES";
|
|
156
|
+
|
|
157
|
+
const phoenixSuccessful = findingsConcrete && hashAltered && inScope && validationPassed;
|
|
158
|
+
|
|
159
|
+
if (this.ledger && phoenixSuccessful) {
|
|
160
|
+
this.ledger.logEvent({
|
|
161
|
+
actor: "Phoenix",
|
|
162
|
+
actorType: "runtime-agent",
|
|
163
|
+
observed: true,
|
|
164
|
+
runtime: "opencode",
|
|
165
|
+
eventType: "remediation_complete",
|
|
166
|
+
provenance: "opencode-adapter",
|
|
167
|
+
data: {
|
|
168
|
+
success: true,
|
|
169
|
+
changedFiles: remediation.changedFiles,
|
|
170
|
+
event: "remediation.completed"
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
history.push({
|
|
143
176
|
attempt,
|
|
144
177
|
before: current.overallStatus,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { OpenCodeAdapter } from "../runtime/opencode-adapter.js";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export function createRuntimeRemediationExecutor(cwd, ledger = null) {
|
|
7
|
+
const adapter = new OpenCodeAdapter({ cwd });
|
|
8
|
+
|
|
9
|
+
function getChangedFiles() {
|
|
10
|
+
try {
|
|
11
|
+
const output = execSync("git status --short", { cwd, encoding: "utf8" });
|
|
12
|
+
return output
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.map((line) => line.slice(3).trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return async function runtimeRemediationExecutor({ attempt, maxAttempts, mode, affectedChecks, requestPath, result }) {
|
|
23
|
+
console.log(`\n[HEALER] Running Runtime/Phoenix Structural Remediation [Attempt ${attempt}/${maxAttempts}]`);
|
|
24
|
+
console.log(`[HEALER] Affected checks: ${affectedChecks.join(", ")}`);
|
|
25
|
+
|
|
26
|
+
if (ledger) {
|
|
27
|
+
ledger.logEvent({
|
|
28
|
+
actor: "Phoenix",
|
|
29
|
+
actorType: "runtime-agent",
|
|
30
|
+
observed: true,
|
|
31
|
+
runtime: "opencode",
|
|
32
|
+
eventType: "remediation_start",
|
|
33
|
+
provenance: "opencode-adapter",
|
|
34
|
+
data: {
|
|
35
|
+
attempt,
|
|
36
|
+
affectedChecks,
|
|
37
|
+
event: "remediation.started"
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let requestContent = {};
|
|
43
|
+
try {
|
|
44
|
+
requestContent = JSON.parse(await fs.readFile(requestPath, "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore/fallback
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const filesBefore = getChangedFiles();
|
|
50
|
+
const fileContentsBefore = {};
|
|
51
|
+
for (const file of filesBefore) {
|
|
52
|
+
try {
|
|
53
|
+
fileContentsBefore[file] = await fs.readFile(path.join(cwd, file), "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
fileContentsBefore[file] = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract concrete command outputs for failing checks
|
|
60
|
+
const failedCommands = (result?.evidence?.commands || [])
|
|
61
|
+
.filter(cmd => ["FAIL", "BLOCKED"].includes(cmd.status));
|
|
62
|
+
|
|
63
|
+
let concreteOutput = "";
|
|
64
|
+
if (failedCommands.length > 0) {
|
|
65
|
+
concreteOutput = failedCommands.map(cmd => {
|
|
66
|
+
return `Command: ${cmd.name} (${Array.isArray(cmd.command) ? cmd.command.join(" ") : cmd.command})
|
|
67
|
+
Exit Code: ${cmd.exitCode}
|
|
68
|
+
Output:
|
|
69
|
+
${cmd.output}`;
|
|
70
|
+
}).join("\n\n");
|
|
71
|
+
} else {
|
|
72
|
+
concreteOutput = JSON.stringify(result?.quality || result || {}, null, 2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const promptMsg = `We are in remediation mode (attempt ${attempt}/${maxAttempts}).
|
|
76
|
+
The following validation checks have failed:
|
|
77
|
+
${affectedChecks.join(", ")}
|
|
78
|
+
|
|
79
|
+
Details of the failure:
|
|
80
|
+
\`\`\`
|
|
81
|
+
${concreteOutput}
|
|
82
|
+
\`\`\`
|
|
83
|
+
|
|
84
|
+
Please inspect the code, identify the root cause of these failures (e.g. failing tests, syntax issues, build errors), and write a correction to the relevant files in the workspace to make the tests/validations pass.`;
|
|
85
|
+
|
|
86
|
+
console.log(`[HEALER] Sending structural healing request to Phoenix agent...`);
|
|
87
|
+
const runResult = await adapter.execute(promptMsg, {
|
|
88
|
+
agent: "Phoenix"
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const filesAfter = getChangedFiles();
|
|
92
|
+
const changedFiles = [];
|
|
93
|
+
for (const file of filesAfter) {
|
|
94
|
+
if (!filesBefore.includes(file)) {
|
|
95
|
+
changedFiles.push(file);
|
|
96
|
+
} else {
|
|
97
|
+
try {
|
|
98
|
+
const contentAfter = await fs.readFile(path.join(cwd, file), "utf8");
|
|
99
|
+
if (contentAfter !== fileContentsBefore[file]) {
|
|
100
|
+
changedFiles.push(file);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
if (fileContentsBefore[file] !== null) {
|
|
104
|
+
changedFiles.push(file);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const success = runResult.success && changedFiles.length > 0;
|
|
110
|
+
|
|
111
|
+
if (!runResult.success) {
|
|
112
|
+
console.log(`[HEALER] Phoenix remediation failed: ${runResult.error || "Unknown error"}`);
|
|
113
|
+
return {
|
|
114
|
+
applied: false,
|
|
115
|
+
reason: `Phoenix runtime execution failed: ${runResult.error || "Unknown error"}`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (changedFiles.length === 0) {
|
|
120
|
+
console.log(`[HEALER] Phoenix execution succeeded but no files were modified.`);
|
|
121
|
+
return {
|
|
122
|
+
applied: false,
|
|
123
|
+
reason: "Phoenix runtime executed but did not modify any files in the workspace."
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`[HEALER] Phoenix successfully executed remediation commands.`);
|
|
128
|
+
console.log(`[HEALER] Files modified: ${changedFiles.join(", ")}`);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
applied: true,
|
|
132
|
+
changedFiles,
|
|
133
|
+
evidenceAdded: ["phoenix-healing-run"]
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
}
|