@williambeto/ai-workflow 2.2.6 → 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/dist-assets/docs/compatibility/provider-usage.md +8 -0
- package/dist-assets/docs/compatibility/runtime-matrix.md +1 -0
- package/dist-assets/templates/.antigravityignore.template +8 -0
- package/dist-assets/templates/ANTIGRAVITY.md.template +20 -0
- 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/adapters/index.js +1 -0
- package/src/adapters/platforms/antigravity.js +382 -0
- package/src/adapters/platforms/codex.js +13 -0
- package/src/adapters/platforms/gemini.js +14 -1
- package/src/cli.js +6 -3
- package/src/commands/collect-evidence.js +3 -39
- package/src/commands/doctor.js +16 -0
- package/src/commands/execute.js +303 -119
- package/src/commands/init.js +25 -2
- 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/gates/merge-gate.js +74 -0
- 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/templates.js +8 -0
- package/src/core/validation/evidence-collector.js +30 -3
- package/src/core/validation/quality-guard.js +8 -0
- 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
package/src/commands/init.js
CHANGED
|
@@ -6,7 +6,7 @@ import { exists, writeFileSafe, readJson } from "../core/filesystem.js";
|
|
|
6
6
|
import { createManagedBackup, createManagedPathBackup } from "../core/backup.js";
|
|
7
7
|
import { mergeOpencodeConfig } from "../core/opencode-merge.js";
|
|
8
8
|
import { buildSymlinkEntries, isSymlinkTo, setupInternalSymlinks } from "../core/symlink-layout.js";
|
|
9
|
-
import { GeminiAdapter, ClaudeAdapter, CodexAdapter } from "../adapters/index.js";
|
|
9
|
+
import { GeminiAdapter, ClaudeAdapter, CodexAdapter, AntigravityAdapter } from "../adapters/index.js";
|
|
10
10
|
|
|
11
11
|
function summarize(actions) {
|
|
12
12
|
const createCount = actions.filter((a) => a.type === "create").length;
|
|
@@ -100,7 +100,7 @@ async function injectScripts(cwd) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export async function runInit({
|
|
103
|
-
cwd, yes, force, dryRun, noInstall, noOverwrite, gemini, claude, codex, "dev-mode": devMode, profile }) {
|
|
103
|
+
cwd, yes, force, dryRun, noInstall, noOverwrite, gemini, claude, codex, antigravity, "dev-mode": devMode, profile }) {
|
|
104
104
|
// Self-execution guard
|
|
105
105
|
if (!devMode) {
|
|
106
106
|
try {
|
|
@@ -131,6 +131,7 @@ export async function runInit({
|
|
|
131
131
|
if (gemini) platforms.push("gemini");
|
|
132
132
|
if (claude) platforms.push("claude");
|
|
133
133
|
if (codex) platforms.push("codex");
|
|
134
|
+
if (antigravity) platforms.push("antigravity");
|
|
134
135
|
|
|
135
136
|
console.log(`ai-workflow: initializing in ${cwd}`);
|
|
136
137
|
console.log(`Profile: ${selectedProfile}`);
|
|
@@ -260,6 +261,9 @@ export async function runInit({
|
|
|
260
261
|
if (claude) {
|
|
261
262
|
generatedIgnoreEntries.push(".claude/");
|
|
262
263
|
}
|
|
264
|
+
if (antigravity) {
|
|
265
|
+
generatedIgnoreEntries.push(".agents/");
|
|
266
|
+
}
|
|
263
267
|
|
|
264
268
|
const gitignoreResult = await upsertGitignoreBlock(cwd, generatedIgnoreEntries);
|
|
265
269
|
const scriptInjected = await injectScripts(cwd);
|
|
@@ -303,6 +307,25 @@ export async function runInit({
|
|
|
303
307
|
await adapter.deploy(installRoot);
|
|
304
308
|
console.log("- codex: deployed AI Workflow Kit agents to .agents/, .codex/, and .github/ (native discovery enabled)");
|
|
305
309
|
}
|
|
310
|
+
|
|
311
|
+
if (antigravity) {
|
|
312
|
+
const adapter = new AntigravityAdapter({ cwd });
|
|
313
|
+
|
|
314
|
+
// Collect specialized skills from the new opencode/skills path
|
|
315
|
+
const skillFolders = await fs.readdir(skillsDir).catch(() => []);
|
|
316
|
+
const skillFiles = [];
|
|
317
|
+
for (const folder of skillFolders) {
|
|
318
|
+
const skillPath = path.join(skillsDir, folder, "SKILL.md");
|
|
319
|
+
if (await exists(skillPath)) {
|
|
320
|
+
skillFiles.push(skillPath);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const allFiles = [...agentFiles, ...skillFiles];
|
|
325
|
+
const transformed = await Promise.all(allFiles.map((f) => adapter.transform(f)));
|
|
326
|
+
await adapter.deploy(transformed, installRoot);
|
|
327
|
+
console.log(`- antigravity: deployed AI Workflow Kit agents to .agents/ (native discovery enabled)`);
|
|
328
|
+
}
|
|
306
329
|
}
|
|
307
330
|
|
|
308
331
|
console.log("Installation complete.");
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { OpenCodeAdapter } from "./runtime/opencode-adapter.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DelegationController - Manages and logs coordination between Atlas, Astra, Sage, and Phoenix.
|
|
5
|
+
*/
|
|
6
|
+
export class DelegationController {
|
|
7
|
+
constructor({ cwd = process.cwd(), ledger, adapter } = {}) {
|
|
8
|
+
this.cwd = cwd;
|
|
9
|
+
this.ledger = ledger;
|
|
10
|
+
this.adapter = adapter || new OpenCodeAdapter({ cwd });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Logs routing decision by Atlas.
|
|
15
|
+
*/
|
|
16
|
+
async route(naturalRequest, classification) {
|
|
17
|
+
if (this.ledger) {
|
|
18
|
+
this.ledger.logEvent({
|
|
19
|
+
actor: "Atlas",
|
|
20
|
+
actorType: "control-plane",
|
|
21
|
+
observed: true,
|
|
22
|
+
eventType: "routing",
|
|
23
|
+
provenance: "request-classifier",
|
|
24
|
+
data: {
|
|
25
|
+
request: naturalRequest,
|
|
26
|
+
classification,
|
|
27
|
+
event: "routing.completed"
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Logs and executes the implementation process by Astra.
|
|
35
|
+
*/
|
|
36
|
+
async implement(promptMsg, owner, options = {}) {
|
|
37
|
+
if (this.ledger) {
|
|
38
|
+
this.ledger.logEvent({
|
|
39
|
+
actor: owner,
|
|
40
|
+
actorType: "runtime-agent",
|
|
41
|
+
observed: true,
|
|
42
|
+
runtime: "opencode",
|
|
43
|
+
eventType: "implementation_start",
|
|
44
|
+
provenance: "opencode-adapter",
|
|
45
|
+
data: {
|
|
46
|
+
agent: owner,
|
|
47
|
+
prompt: promptMsg,
|
|
48
|
+
event: "implementation.started"
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const runResult = await this.adapter.execute(promptMsg, { agent: owner, ...options });
|
|
54
|
+
|
|
55
|
+
if (this.ledger) {
|
|
56
|
+
this.ledger.logEvent({
|
|
57
|
+
actor: owner,
|
|
58
|
+
actorType: "runtime-agent",
|
|
59
|
+
observed: true,
|
|
60
|
+
runtime: "opencode",
|
|
61
|
+
eventType: "implementation_complete",
|
|
62
|
+
provenance: "opencode-adapter",
|
|
63
|
+
data: {
|
|
64
|
+
success: runResult.success,
|
|
65
|
+
commandsRun: runResult.commandsRun || [],
|
|
66
|
+
eventCount: runResult.eventCount || 0,
|
|
67
|
+
error: runResult.error,
|
|
68
|
+
event: "implementation.completed"
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return runResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Runs validation locally without logging Sage since no validation agent runtime was executed.
|
|
78
|
+
*/
|
|
79
|
+
async validate(taskSlug, mode, profile, verifyFn) {
|
|
80
|
+
const result = await verifyFn();
|
|
81
|
+
|
|
82
|
+
if (mode === "full") {
|
|
83
|
+
if (this.ledger) {
|
|
84
|
+
this.ledger.logEvent({
|
|
85
|
+
actor: "Sage",
|
|
86
|
+
actorType: "auditor",
|
|
87
|
+
observed: true,
|
|
88
|
+
runtime: "opencode",
|
|
89
|
+
eventType: "validation_start",
|
|
90
|
+
provenance: "opencode-adapter",
|
|
91
|
+
data: {
|
|
92
|
+
agent: "Sage",
|
|
93
|
+
event: "validation.started"
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let gitDiff = "";
|
|
99
|
+
try {
|
|
100
|
+
const { execSync } = await import("node:child_process");
|
|
101
|
+
gitDiff = execSync("git diff HEAD", { cwd: this.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const prompt = `Please validate the implementation. Diff:\n${gitDiff}\nLocal Evidence:\n${JSON.stringify(result, null, 2)}`;
|
|
107
|
+
const runResult = await this.adapter.execute(prompt, { agent: "Sage" });
|
|
108
|
+
|
|
109
|
+
if (this.ledger) {
|
|
110
|
+
this.ledger.logEvent({
|
|
111
|
+
actor: "Sage",
|
|
112
|
+
actorType: "auditor",
|
|
113
|
+
observed: true,
|
|
114
|
+
runtime: "opencode",
|
|
115
|
+
eventType: "validation_complete",
|
|
116
|
+
provenance: "opencode-adapter",
|
|
117
|
+
data: {
|
|
118
|
+
success: runResult.success,
|
|
119
|
+
commandsRun: runResult.commandsRun || [],
|
|
120
|
+
eventCount: runResult.eventCount || 0,
|
|
121
|
+
error: runResult.error,
|
|
122
|
+
event: "validation.completed"
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!runResult.success) {
|
|
128
|
+
result.overallStatus = "FAIL_QUALITY_GATE";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EvidenceLedger - Traces structural workflow events to make delegation observable.
|
|
6
|
+
*/
|
|
7
|
+
export class EvidenceLedger {
|
|
8
|
+
constructor({ cwd = process.cwd(), workflowId = "unknown" } = {}) {
|
|
9
|
+
this.cwd = cwd;
|
|
10
|
+
this.workflowId = workflowId;
|
|
11
|
+
this.events = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Logs a workflow event to the ledger.
|
|
16
|
+
* @param {Object} eventParam
|
|
17
|
+
* @param {string} eventParam.actor - The agent name (Atlas, Astra, Sage, Phoenix).
|
|
18
|
+
* @param {string} eventParam.eventType - Type of event.
|
|
19
|
+
* @param {string} eventParam.provenance - Originating component.
|
|
20
|
+
* @param {Object} [eventParam.data] - Additional metadata.
|
|
21
|
+
* @returns {Object} The logged event.
|
|
22
|
+
*/
|
|
23
|
+
logEvent({ actor, eventType, provenance, data = {}, actorType, observed, runtime }) {
|
|
24
|
+
const event = {
|
|
25
|
+
workflowId: this.workflowId,
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
actor,
|
|
28
|
+
actorType: actorType !== undefined ? actorType : undefined,
|
|
29
|
+
observed: observed !== undefined ? observed : undefined,
|
|
30
|
+
runtime: runtime !== undefined ? runtime : undefined,
|
|
31
|
+
eventType,
|
|
32
|
+
provenance,
|
|
33
|
+
data
|
|
34
|
+
};
|
|
35
|
+
this.events.push(event);
|
|
36
|
+
console.log(`[LEDGER] [${actor}] ${eventType}: ${JSON.stringify(data)}`);
|
|
37
|
+
return event;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getEvents() {
|
|
41
|
+
return this.events;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Saves the ledger events to a JSON file.
|
|
46
|
+
* @param {string} filePath - Absolute or relative path.
|
|
47
|
+
*/
|
|
48
|
+
async save(filePath) {
|
|
49
|
+
const targetPath = path.isAbsolute(filePath) ? filePath : path.join(this.cwd, filePath);
|
|
50
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
51
|
+
await fs.writeFile(targetPath, JSON.stringify(this.events, null, 2), "utf8");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -21,6 +21,10 @@ export class ExecutionPlanner {
|
|
|
21
21
|
? path.join("docs/workflows", taskSlug, "spec.md")
|
|
22
22
|
: null;
|
|
23
23
|
|
|
24
|
+
const owner = classification.intent === "write"
|
|
25
|
+
? "Astra"
|
|
26
|
+
: classification.owner;
|
|
27
|
+
|
|
24
28
|
// Build default expected validations based on the resolved profile
|
|
25
29
|
const validationsExpected = [];
|
|
26
30
|
if (classification.validationNeeded) {
|
|
@@ -45,7 +49,7 @@ export class ExecutionPlanner {
|
|
|
45
49
|
objective: classification.request,
|
|
46
50
|
scope: classification.intent === "read-only" ? "Analysis and verification" : "Implementation of requested behavior",
|
|
47
51
|
restrictions,
|
|
48
|
-
owner
|
|
52
|
+
owner,
|
|
49
53
|
skills: [...classification.skills],
|
|
50
54
|
branchNeeded,
|
|
51
55
|
branchName: branchNeeded ? `feat/${taskSlug}` : null,
|
|
@@ -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}`;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MergeGate verifies that commit history on integration branches respects
|
|
5
|
+
* the non-fast-forward (--no-ff) merge strategy, preserving feature branch
|
|
6
|
+
* topology instead of flattening/fast-forwarding.
|
|
7
|
+
*/
|
|
8
|
+
export class MergeGate {
|
|
9
|
+
constructor({ cwd = process.cwd() } = {}) {
|
|
10
|
+
this.cwd = cwd;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
runGit(command) {
|
|
14
|
+
return execSync(command, {
|
|
15
|
+
cwd: this.cwd,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
18
|
+
}).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks the commit history of the repository/branch.
|
|
23
|
+
* @param {string} branchName The branch to check.
|
|
24
|
+
* @returns {{ status: "PASS" | "FAIL_QUALITY_GATE" | "ERROR", reason?: string }}
|
|
25
|
+
*/
|
|
26
|
+
check(branchName) {
|
|
27
|
+
try {
|
|
28
|
+
// Get the last 50 commits with parent hashes and subjects
|
|
29
|
+
const logOutput = this.runGit('git log -n 50 --format="%H|%P|%s"');
|
|
30
|
+
const lines = logOutput.split("\n").filter(Boolean);
|
|
31
|
+
|
|
32
|
+
const commits = lines.map((line) => {
|
|
33
|
+
const parts = line.split("|");
|
|
34
|
+
const hash = parts[0] || "";
|
|
35
|
+
const parentsStr = parts[1] || "";
|
|
36
|
+
const parents = parentsStr.split(" ").filter(Boolean);
|
|
37
|
+
const subject = parts[2] || "";
|
|
38
|
+
return { hash, parents, subject };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 1. Enforce that any commit containing merge-like patterns in its subject must have at least 2 parents
|
|
42
|
+
for (const commit of commits) {
|
|
43
|
+
const isMergeSubject = /merge\b/i.test(commit.subject) || /pull request/i.test(commit.subject);
|
|
44
|
+
if (isMergeSubject && commit.parents.length < 2) {
|
|
45
|
+
return {
|
|
46
|
+
status: "FAIL_QUALITY_GATE",
|
|
47
|
+
reason: `Linear or fast-forward merge detected for commit '${commit.hash.slice(0, 7)}' ("${commit.subject}"). Merges into integration branches must use the non-fast-forward (--no-ff) strategy to preserve topology.`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. If the current branch is an integration branch, ensure it contains at least one merge commit (2+ parents)
|
|
53
|
+
// to establish non-linear history, unless the repository has very few commits (e.g. initial setup).
|
|
54
|
+
const isIntegrationBranch = ["main", "master"].includes(branchName) || String(branchName).startsWith("release");
|
|
55
|
+
if (isIntegrationBranch && commits.length > 5) {
|
|
56
|
+
const hasMergeCommit = commits.some((c) => c.parents.length >= 2);
|
|
57
|
+
if (!hasMergeCommit) {
|
|
58
|
+
return {
|
|
59
|
+
status: "FAIL_QUALITY_GATE",
|
|
60
|
+
reason: `Integration branch '${branchName}' has a purely linear commit history. Integrated branches must use non-fast-forward (--no-ff) merges to preserve feature branches.`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { status: "PASS" };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// If we are not inside a git repo or git is not available, we fail-closed or report error
|
|
68
|
+
return {
|
|
69
|
+
status: "ERROR",
|
|
70
|
+
reason: `Failed to check merge integrity: ${error.message}`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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,
|