infinity-harness 2.6.6 → 2.8.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 +80 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +600 -26
- package/harness/docs/ARCHITECTURE.md +13 -7
- package/harness/docs/CONSTRAINTS.md +13 -5
- package/harness/docs/DECISIONS.md +44 -0
- package/harness/docs/DOMAIN.md +44 -8
- package/package.json +1 -1
- package/src/core/config.ts +88 -1
- package/src/core/featureList.ts +85 -17
- package/src/core/gates.ts +8 -6
- package/src/core/init.ts +33 -3
- package/src/core/modelRouter.ts +149 -0
- package/src/core/paths.ts +29 -0
- package/src/core/plan.ts +39 -0
- package/src/core/runState.ts +151 -0
- package/src/core/settings.ts +138 -4
- package/src/core/types.ts +49 -0
- package/src/daemon/budget.ts +94 -0
- package/src/daemon/guard.ts +113 -0
- package/src/daemon/index.ts +421 -0
- package/src/daemon/isolation.ts +95 -0
- package/src/daemon/preflight.ts +132 -0
- package/src/daemon/server.ts +153 -0
- package/src/daemon/supervisorState.ts +83 -0
- package/src/daemon/worker.ts +239 -0
- package/src/daemon/worktree.ts +95 -0
- package/src/exec/piWorker.ts +706 -0
- package/src/goalState.ts +2 -22
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +28 -7
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +115 -135
- package/src/supervisor.ts +955 -0
- package/src/taskList.ts +41 -3
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +189 -0
- package/src/ui/wizard.ts +43 -7
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — daemon/worker.ts
|
|
3
|
+
*
|
|
4
|
+
* One AgentSession: create → prompt → settle → dispose, plus the
|
|
5
|
+
* events->TurnResult adapter. prompt() returns void in the SDK, so every
|
|
6
|
+
* turn field (servedModel, usage, tools, summary, contextRatio, compacted)
|
|
7
|
+
* comes via subscribe().
|
|
8
|
+
*
|
|
9
|
+
* v2.7's WorkerSession.prompt returned TurnResult; the SDK's prompt returns void.
|
|
10
|
+
* This file rebuilds the same shape by accumulating events.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Usage type is from pi session — use a loose shape to avoid hard dep on pi-ai path
|
|
14
|
+
type Usage = { input?: number; output?: number; inputTokens?: number; outputTokens?: number; cacheRead?: number; cacheWrite?: number; cost?: number };
|
|
15
|
+
|
|
16
|
+
export type TurnResult = {
|
|
17
|
+
summary: string;
|
|
18
|
+
tools: Array<{ name: string; ok: boolean }>;
|
|
19
|
+
usage: { input: number; output: number; cacheRead?: number; cacheWrite?: number; cost?: number };
|
|
20
|
+
contextRatio: number | null;
|
|
21
|
+
servedModel: string | null;
|
|
22
|
+
askedModel: string;
|
|
23
|
+
compacted: boolean;
|
|
24
|
+
aborted: boolean;
|
|
25
|
+
error: string | null;
|
|
26
|
+
modelFallbackMessage?: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type WorkerEvents = {
|
|
30
|
+
onMessageStart?: (ev: unknown) => void;
|
|
31
|
+
onMessageUpdate?: (ev: unknown) => void;
|
|
32
|
+
onMessageEnd?: (ev: unknown) => void;
|
|
33
|
+
onToolStart?: (ev: unknown) => void;
|
|
34
|
+
onToolEnd?: (ev: unknown) => void;
|
|
35
|
+
onCompactionStart?: (ev: unknown) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CreateWorkerOpts = {
|
|
39
|
+
cwd: string;
|
|
40
|
+
agentDir?: string;
|
|
41
|
+
modelSpec: { provider: string; id: string; thinkingLevel?: string };
|
|
42
|
+
askedModel: string;
|
|
43
|
+
sessionManagerDir?: string;
|
|
44
|
+
customTools?: unknown[];
|
|
45
|
+
thinkingLevel?: string;
|
|
46
|
+
resourceLoader?: unknown;
|
|
47
|
+
runId?: string;
|
|
48
|
+
unitKey?: string;
|
|
49
|
+
isolationBypassForTest?: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type PromptOpts = {
|
|
53
|
+
text: string;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create one SDK AgentSession with harness-free loader + customTools.
|
|
59
|
+
* Returns session + unsubscribe + dispose handles.
|
|
60
|
+
*/
|
|
61
|
+
export async function createWorker(opts: CreateWorkerOpts): Promise<{
|
|
62
|
+
session: unknown;
|
|
63
|
+
unsubscribe: () => void;
|
|
64
|
+
dispose: () => void;
|
|
65
|
+
modelFallbackMessage?: string | null;
|
|
66
|
+
events: { servedModel: string | null; usage: Usage | null; tools: TurnResult["tools"]; summary: string; compacted: boolean };
|
|
67
|
+
}> {
|
|
68
|
+
const mod = await import("@earendil-works/pi-coding-agent");
|
|
69
|
+
const { createAgentSession, ModelRuntime, SessionManager, DefaultResourceLoader, SettingsManager } = mod as unknown as {
|
|
70
|
+
createAgentSession: (opts: unknown) => Promise<{ session: unknown; modelFallbackMessage?: string | null; extensionsResult?: unknown }>;
|
|
71
|
+
ModelRuntime: { create: () => Promise<{ getModel: (p: string, id: string) => unknown; hasConfiguredAuth: (p: string) => boolean; checkAuth: (p: string) => Promise<unknown> }> };
|
|
72
|
+
SessionManager: { create: (cwd: string, dir?: string) => unknown; inMemory: (cwd: string) => unknown };
|
|
73
|
+
DefaultResourceLoader: new (opts: unknown) => { reload: () => Promise<void> };
|
|
74
|
+
SettingsManager: { create: (cwd: string, agentDir?: string) => unknown };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const runtime = await ModelRuntime.create();
|
|
78
|
+
const model = runtime.getModel(opts.modelSpec.provider, opts.modelSpec.id);
|
|
79
|
+
if (!model) throw new Error(`unknown model ${opts.modelSpec.provider}/${opts.modelSpec.id}`);
|
|
80
|
+
|
|
81
|
+
let loader: unknown = opts.resourceLoader;
|
|
82
|
+
if (!loader && !opts.isolationBypassForTest) {
|
|
83
|
+
const sm = SettingsManager.create(opts.cwd, opts.agentDir);
|
|
84
|
+
const l = new DefaultResourceLoader({
|
|
85
|
+
cwd: opts.cwd,
|
|
86
|
+
agentDir: opts.agentDir ?? "",
|
|
87
|
+
settingsManager: sm,
|
|
88
|
+
noExtensions: true,
|
|
89
|
+
noSkills: true,
|
|
90
|
+
noPromptTemplates: true,
|
|
91
|
+
noThemes: true,
|
|
92
|
+
noContextFiles: true,
|
|
93
|
+
} as unknown as ConstructorParameters<typeof DefaultResourceLoader>[0]);
|
|
94
|
+
await l.reload();
|
|
95
|
+
loader = l;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Verify isolation: if loader discovery includes harness, fail fast (unless bypassed for test).
|
|
99
|
+
if (!opts.isolationBypassForTest && loader && typeof (loader as { getExtensions?: () => unknown }).getExtensions === "function") {
|
|
100
|
+
try {
|
|
101
|
+
const ext = (loader as { getExtensions: () => { extensions?: Array<{ id?: string }> } }).getExtensions();
|
|
102
|
+
const found = (ext?.extensions ?? []).filter(e => String(e?.id ?? "").includes("infinity-harness"));
|
|
103
|
+
if (found.length) throw new Error(`isolation violated: loader has ${found.length} harness extension(s)`);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e instanceof Error && e.message.includes("isolation violated")) throw e;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sessionManager = opts.sessionManagerDir
|
|
110
|
+
? SessionManager.create(opts.cwd, opts.sessionManagerDir)
|
|
111
|
+
: SessionManager.inMemory(opts.cwd);
|
|
112
|
+
|
|
113
|
+
const thinkingLevel = (opts.thinkingLevel ?? opts.modelSpec.thinkingLevel ?? "medium") as unknown as string;
|
|
114
|
+
|
|
115
|
+
const agentTools = opts.customTools as unknown as import("@earendil-works/pi-coding-agent").ToolDefinition[] | undefined;
|
|
116
|
+
|
|
117
|
+
const created = await createAgentSession({
|
|
118
|
+
model: model as never,
|
|
119
|
+
modelRuntime: runtime as never,
|
|
120
|
+
cwd: opts.cwd,
|
|
121
|
+
thinkingLevel: thinkingLevel as never,
|
|
122
|
+
resourceLoader: loader as never,
|
|
123
|
+
customTools: agentTools as never,
|
|
124
|
+
sessionManager: sessionManager as never,
|
|
125
|
+
} as never);
|
|
126
|
+
|
|
127
|
+
const session = created.session as {
|
|
128
|
+
subscribe: (fn: (ev: { type: string; [k: string]: unknown }) => void) => () => void;
|
|
129
|
+
prompt: (text: string, opts?: unknown) => Promise<void>;
|
|
130
|
+
steer: (text: string) => Promise<void>;
|
|
131
|
+
dispose: () => void;
|
|
132
|
+
abort?: () => void;
|
|
133
|
+
model?: { provider?: string; id?: string };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (created.modelFallbackMessage) throw new Error(`modelFallbackMessage: ${created.modelFallbackMessage}`);
|
|
137
|
+
|
|
138
|
+
const state = { servedModel: null as string | null, usage: null as Usage | null, tools: [] as TurnResult["tools"], summary: "", compacted: false };
|
|
139
|
+
|
|
140
|
+
const unsubscribe = session.subscribe((ev: { type: string; [k: string]: unknown }) => {
|
|
141
|
+
if (ev.type === "message_start") {
|
|
142
|
+
// message_start carries provider/model in some SDK versions
|
|
143
|
+
const prov = (ev as { provider?: string }).provider ?? (ev as { model?: { provider?: string } }).model?.provider;
|
|
144
|
+
const mid = (ev as { modelId?: string }).modelId ?? (ev as { model?: { id?: string } }).model?.id;
|
|
145
|
+
if (prov || mid) state.servedModel = `${prov ?? "?"}:${mid ?? "?"}`;
|
|
146
|
+
} else if (ev.type === "message_end") {
|
|
147
|
+
// usage is cumulative per session
|
|
148
|
+
const usage = (ev as { usage?: Usage }).usage;
|
|
149
|
+
if (usage) state.usage = usage as Usage;
|
|
150
|
+
const text = (ev as { content?: unknown }).content ?? (ev as { text?: string }).text;
|
|
151
|
+
if (typeof text === "string" && text) state.summary = text;
|
|
152
|
+
else if (Array.isArray((ev as { content?: unknown }).content)) {
|
|
153
|
+
const c = (ev as { content?: unknown }).content as Array<{ type?: string; text?: string }>;
|
|
154
|
+
const t = c.filter(x => x?.type === "text").map(x => x.text ?? "").join("\n");
|
|
155
|
+
if (t) state.summary = t;
|
|
156
|
+
}
|
|
157
|
+
} else if (ev.type === "tool_execution_start" || ev.type === "tool_execution_end") {
|
|
158
|
+
const name = String((ev as { toolName?: string }).toolName ?? (ev as { name?: string }).name ?? "tool");
|
|
159
|
+
const ok = ev.type === "tool_execution_end" ? ((ev as { ok?: boolean }).ok ?? true) : true;
|
|
160
|
+
// dedupe: keep last ok per name per end event
|
|
161
|
+
if (ev.type === "tool_execution_end") state.tools.push({ name, ok: Boolean(ok) });
|
|
162
|
+
else if (ev.type === "tool_execution_start") state.tools.push({ name, ok: true });
|
|
163
|
+
} else if (ev.type === "compaction_start") {
|
|
164
|
+
state.compacted = true;
|
|
165
|
+
} else if (ev.type === "entry_appended") {
|
|
166
|
+
// session entries include model_change and compaction; capture compaction usage
|
|
167
|
+
const entry = (ev as { entry?: { type?: string; usage?: Usage } }).entry;
|
|
168
|
+
if (entry?.type === "compaction" && entry.usage) state.compacted = true;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
session,
|
|
174
|
+
unsubscribe,
|
|
175
|
+
dispose: () => { try { unsubscribe(); } catch {} try { session.dispose(); } catch {} },
|
|
176
|
+
modelFallbackMessage: created.modelFallbackMessage ?? null,
|
|
177
|
+
events: state,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function promptWorker(
|
|
182
|
+
worker: { session: { prompt: (t: string, o?: unknown) => Promise<void> }; events: { servedModel: string | null; usage: Usage | null; tools: TurnResult["tools"]; summary: string; compacted: boolean } },
|
|
183
|
+
opts: PromptOpts,
|
|
184
|
+
): Promise<TurnResult> {
|
|
185
|
+
// We need the askedModel — fall back to events.servedModel if not known.
|
|
186
|
+
const askedModel = "asked";
|
|
187
|
+
const startedUsage = worker.events.usage;
|
|
188
|
+
try {
|
|
189
|
+
const timeoutMs = opts.timeoutMs ?? 30 * 60 * 1000;
|
|
190
|
+
let settled = false;
|
|
191
|
+
const timer = timeoutMs > 0 ? setTimeout(() => { if (!settled) try { (worker.session as { abort?: () => void }).abort?.(); } catch {} }, timeoutMs) : null;
|
|
192
|
+
try {
|
|
193
|
+
await worker.session.prompt(opts.text);
|
|
194
|
+
settled = true;
|
|
195
|
+
} finally {
|
|
196
|
+
if (timer) clearTimeout(timer);
|
|
197
|
+
settled = true;
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
201
|
+
// CredentialSynchronizationError is handled by caller; bubble it.
|
|
202
|
+
if (msg.includes("CredentialSynchronizationError") || (e as { name?: string })?.name === "CredentialSynchronizationError") throw e;
|
|
203
|
+
return {
|
|
204
|
+
summary: worker.events.summary,
|
|
205
|
+
tools: worker.events.tools,
|
|
206
|
+
usage: toUsageTotals(worker.events.usage, startedUsage),
|
|
207
|
+
contextRatio: null,
|
|
208
|
+
servedModel: worker.events.servedModel,
|
|
209
|
+
askedModel,
|
|
210
|
+
compacted: worker.events.compacted,
|
|
211
|
+
aborted: msg.toLowerCase().includes("abort"),
|
|
212
|
+
error: msg,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
summary: worker.events.summary,
|
|
217
|
+
tools: worker.events.tools,
|
|
218
|
+
usage: toUsageTotals(worker.events.usage, startedUsage),
|
|
219
|
+
contextRatio: null,
|
|
220
|
+
servedModel: worker.events.servedModel,
|
|
221
|
+
askedModel,
|
|
222
|
+
compacted: worker.events.compacted,
|
|
223
|
+
aborted: false,
|
|
224
|
+
error: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function toUsageTotals(cur: Usage | null, _prev: Usage | null): { input: number; output: number; cacheRead?: number; cacheWrite?: number; cost?: number } {
|
|
229
|
+
if (!cur) return { input: 0, output: 0 };
|
|
230
|
+
// pi usage is cumulative per session; the last reading IS the total.
|
|
231
|
+
const input = typeof (cur as { input?: number }).input === "number" ? (cur as { input: number }).input
|
|
232
|
+
: typeof (cur as { inputTokens?: number }).inputTokens === "number" ? (cur as unknown as { inputTokens: number }).inputTokens : 0;
|
|
233
|
+
const output = typeof (cur as { output?: number }).output === "number" ? (cur as { output: number }).output
|
|
234
|
+
: typeof (cur as { outputTokens?: number }).outputTokens === "number" ? (cur as unknown as { outputTokens: number }).outputTokens : 0;
|
|
235
|
+
const cacheRead = (cur as { cacheRead?: number }).cacheRead ?? 0;
|
|
236
|
+
const cacheWrite = (cur as { cacheWrite?: number }).cacheWrite ?? 0;
|
|
237
|
+
const cost = (cur as { cost?: number }).cost ?? 0;
|
|
238
|
+
return { input, output, cacheRead, cacheWrite, cost };
|
|
239
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — daemon/worktree.ts
|
|
3
|
+
*
|
|
4
|
+
* Git worktree per concurrent worker. Gate in worktree, merge lock, unlock.
|
|
5
|
+
*
|
|
6
|
+
* v3.0 is sequential (maxWorkers:1) — this file is the isolation that will be
|
|
7
|
+
* used when we lift that. Kept isolated in its own module so it cannot leak
|
|
8
|
+
* into the single-owner path prematurely.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { worktreePath, worktreesDir } from "../core/paths.ts";
|
|
14
|
+
import { run } from "../core/exec.ts";
|
|
15
|
+
import { ensureDir } from "../core/fsx.ts";
|
|
16
|
+
|
|
17
|
+
export async function isWorktreeSupported(targetDir: string): Promise<boolean> {
|
|
18
|
+
const r = await run("git rev-parse --is-inside-work-tree", { cwd: targetDir, timeoutMs: 10_000 });
|
|
19
|
+
return r.ok && r.stdout.trim() === "true";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function createWorktree(targetDir: string, branch: string): Promise<{ path: string; error: string | null }> {
|
|
23
|
+
if (!await isWorktreeSupported(targetDir)) return { path: "", error: "not a git repo — cannot create worktree" };
|
|
24
|
+
const worktree = worktreePath(targetDir, branch);
|
|
25
|
+
if (existsSync(worktree)) return { path: worktree, error: null };
|
|
26
|
+
try { ensureDir(worktreesDir(targetDir)); } catch {}
|
|
27
|
+
// Safe to create on current HEAD: `git worktree add <path>` with no branch creates detached worktree
|
|
28
|
+
const r = await run(`git worktree add --detach "${worktree.replace(/"/g, '\\"')}"`, { cwd: targetDir, timeoutMs: 30_000 });
|
|
29
|
+
if (!r.ok) return { path: "", error: (r.stderr || r.stdout || r.spawnError || `git worktree add exited ${r.code}`).slice(0, 400) };
|
|
30
|
+
return { path: worktree, error: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function removeWorktree(targetDir: string, branch: string): Promise<{ ok: boolean; error: string | null }> {
|
|
34
|
+
const worktree = worktreePath(targetDir, branch);
|
|
35
|
+
if (!existsSync(worktree)) return { ok: true, error: null };
|
|
36
|
+
const r = await run(`git worktree remove --force "${worktree.replace(/"/g, '\\"')}"`, { cwd: targetDir, timeoutMs: 30_000 });
|
|
37
|
+
if (!r.ok && !r.stderr?.includes("not a valid path")) return { ok: false, error: (r.stderr || r.stdout || r.spawnError || `exit ${r.code}`).slice(0,400) };
|
|
38
|
+
// Also prune any stale branch/worktree registration
|
|
39
|
+
await run("git worktree prune", { cwd: targetDir, timeoutMs: 15_000 });
|
|
40
|
+
return { ok: true, error: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function removeAllWorktrees(targetDir: string): Promise<void> {
|
|
44
|
+
await run("git worktree prune", { cwd: targetDir, timeoutMs: 15_000 });
|
|
45
|
+
// Best-effort remove each directory under harness/worktrees
|
|
46
|
+
const root = worktreesDir(targetDir);
|
|
47
|
+
if (!existsSync(root)) return;
|
|
48
|
+
let entries: string[] = [];
|
|
49
|
+
try { const { readdirSync } = await import("node:fs"); entries = readdirSync(root); } catch {}
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
await removeWorktree(targetDir, e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function gateInWorktree(targetDir: string, worktree: string, phase: string): Promise<{ pass: boolean; reason?: string }> {
|
|
56
|
+
const { runChecks } = await import("../core/gates.ts");
|
|
57
|
+
const g = await runChecks(worktree, phase as never, { record: false });
|
|
58
|
+
if (g.overall) return { pass: true };
|
|
59
|
+
return { pass: false, reason: g.failures.join(", ") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function mergeWorktreeBranch(targetDir: string, branch: string, worktree: string, gatePhase?: string): Promise<{ ok: boolean; conflict?: boolean; reason?: string }> {
|
|
63
|
+
// We use a harness/<unit> branch name when creating the worktree. Merge with `git merge`.
|
|
64
|
+
const branchName = `harness/${branch}`;
|
|
65
|
+
// Ensure branch exists (worktree add --detach doesn't create one). Create branch from worktree HEAD.
|
|
66
|
+
const currentHead = (await run(`git -C "${worktree.replace(/"/g,'\\"')}" rev-parse HEAD`, { cwd: targetDir, timeoutMs: 10_000 })).stdout?.trim() ?? null;
|
|
67
|
+
if (currentHead) {
|
|
68
|
+
const create = await run(`git branch "${branchName}" "${currentHead}"`, { cwd: targetDir, timeoutMs: 15_000 });
|
|
69
|
+
// exists is ok — we will merge the branch if already there.
|
|
70
|
+
if (!create.ok && !(create.stderr||"").includes("already exists")) {
|
|
71
|
+
// best-effort: continue to merge attempt
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const merge = await run(`git merge --no-ff --no-edit "${branchName}"`, { cwd: targetDir, timeoutMs: 30_000 });
|
|
75
|
+
if (merge.ok) {
|
|
76
|
+
await run(`git branch -D "${branchName}"`, { cwd: targetDir, timeoutMs: 10_000 });
|
|
77
|
+
const { runChecks: _runChecks } = await import("../core/gates.ts");
|
|
78
|
+
const { loadConfig } = await import("../core/config.ts");
|
|
79
|
+
const phase = gatePhase ?? ((loadConfig(targetDir).config?.currentPhase as unknown as string) ?? "build");
|
|
80
|
+
const post = await _runChecks(targetDir, phase as never, { record: false });
|
|
81
|
+
if (!post.overall) {
|
|
82
|
+
await run("git reset --hard HEAD~1", { cwd: targetDir, timeoutMs: 15_000 }).catch(()=>null as never);
|
|
83
|
+
return { ok: false, conflict: false, reason: `post-merge gate FAIL: ${post.failures.join(", ")}` };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
const out = (merge.stdout ?? "") + "\n" + (merge.stderr ?? "");
|
|
88
|
+
const conflict = /conflict/i.test(out);
|
|
89
|
+
if (conflict) {
|
|
90
|
+
await run("git merge --abort", { cwd: targetDir, timeoutMs: 10_000 }).catch(()=>null as never);
|
|
91
|
+
return { ok: false, conflict: true, reason: out.slice(0,500) };
|
|
92
|
+
}
|
|
93
|
+
await run("git merge --abort", { cwd: targetDir, timeoutMs: 10_000 }).catch(()=>null as never);
|
|
94
|
+
return { ok: false, conflict: false, reason: out.slice(0,500) };
|
|
95
|
+
}
|