@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist-assets/docs/cli-reference.md +38 -10
  3. package/dist-assets/docs/compatibility/provider-usage.md +8 -0
  4. package/dist-assets/docs/compatibility/runtime-matrix.md +1 -0
  5. package/dist-assets/templates/.antigravityignore.template +8 -0
  6. package/dist-assets/templates/ANTIGRAVITY.md.template +20 -0
  7. package/docs/releases/v2.3.0-release-decision.md +35 -0
  8. package/package.json +14 -4
  9. package/read_only_safety_verification.md +48 -0
  10. package/src/adapters/index.js +1 -0
  11. package/src/adapters/platforms/antigravity.js +382 -0
  12. package/src/adapters/platforms/codex.js +13 -0
  13. package/src/adapters/platforms/gemini.js +14 -1
  14. package/src/cli.js +6 -3
  15. package/src/commands/collect-evidence.js +3 -39
  16. package/src/commands/doctor.js +16 -0
  17. package/src/commands/execute.js +303 -119
  18. package/src/commands/init.js +25 -2
  19. package/src/core/delegation-controller.js +134 -0
  20. package/src/core/evidence/evidence-ledger.js +53 -0
  21. package/src/core/execution-planner.js +5 -1
  22. package/src/core/finalization/finalizer.js +77 -0
  23. package/src/core/finalization/workspace-snapshot.js +110 -0
  24. package/src/core/gates/branch-gate.js +23 -35
  25. package/src/core/gates/merge-gate.js +74 -0
  26. package/src/core/healing/healer-engine.js +34 -1
  27. package/src/core/healing/runtime-remediation-executor.js +136 -0
  28. package/src/core/runtime/opencode-adapter.js +137 -62
  29. package/src/core/templates.js +8 -0
  30. package/src/core/validation/evidence-collector.js +30 -3
  31. package/src/core/validation/quality-guard.js +8 -0
  32. package/src/core/validation/stack-detector.js +65 -0
  33. package/src/core/validation/validation-planner.js +134 -0
  34. package/src/core/workspace/read-only-workspace.js +119 -0
@@ -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: classification.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
- * @param {string} override
80
- * @param {{autoRecover?: boolean, taskSlug?: string, readOnly?: boolean}} options
79
+ * Strictly verifies branch safety without any override bypasses.
81
80
  */
82
- check(override = "", { autoRecover = false, taskSlug = "implementation", readOnly = false } = {}) {
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
- // Enforce fail-closed: verify inside worktree first
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 = "Not inside a Git repository or Git is unavailable. Implementation work is blocked.";
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
- if (override && override.trim().length >= 20) {
108
- this.log(`AUTHORIZED BYPASS on '${currentBranch}': ${override.trim()}`);
109
- return { blocked: false, branch: currentBranch, authorized: true, recovered: false };
105
+ const isProtected = this.protectedBranches.includes(currentBranch);
106
+ if (!isProtected) {
107
+ return { blocked: false, branch: currentBranch, recovered: false };
110
108
  }
111
109
 
112
- if (autoRecover) {
113
- const dirty = this.getDirtyState();
114
- if (dirty.tracked.length > 0) {
115
- const reason = `Unsafe tracked changes prevent Branch Gate Auto-Recovery: ${dirty.tracked.join(", ")}`;
116
- this.log(`BLOCKED AUTO-RECOVERY on '${currentBranch}': ${reason}`);
117
- return { blocked: true, branch: currentBranch, reason, dirtyState: dirty };
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
- const reason = override && override.trim().length < 20
132
- ? "Override justification too short (min 20 chars)."
133
- : `Direct commits to '${currentBranch}' are prohibited.`;
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: true,
137
- branch: currentBranch,
138
- reason: `${reason} Enable safe auto-recovery or use AI_OVERRIDE with a concrete justification.`
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,