@jhlee0619/codexloop 0.1.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.
@@ -0,0 +1,378 @@
1
+ // CodexLoop state persistence.
2
+ //
3
+ // State lives project-local under <repo>/.loop/ so it is committable/ignorable
4
+ // by the team and survives machine moves. (Codex plugin's per-session state
5
+ // under $CLAUDE_PLUGIN_DATA is a good fit for transient jobs, but loops are
6
+ // long-running per-project artifacts.)
7
+ //
8
+ // Layout:
9
+ // <repo>/.loop/state.json canonical state
10
+ // <repo>/.loop/iterations/NNNN.json per-iteration forensic dump
11
+ // <repo>/.loop/iterations-dryrun/NNNN.json
12
+ // <repo>/.loop/proposals/NNNN-<id>.json
13
+ // <repo>/.loop/progress.log append-only learning log
14
+ // <repo>/.loop/loop.lock advisory lock { pid, loopId, acquiredAt }
15
+ // <repo>/.loop/loop.pid pid of running worker
16
+ // <repo>/.loop/loop.log worker stdout/stderr stream
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import crypto from "node:crypto";
21
+ import process from "node:process";
22
+
23
+ import { isProcessAlive } from "./process.mjs";
24
+
25
+ const STATE_VERSION = 1;
26
+ const STATE_FILENAME = "state.json";
27
+
28
+ const MIGRATIONS = [
29
+ // Index 0 would migrate v0 -> v1, etc. Empty for now.
30
+ ];
31
+
32
+ function nowIso() {
33
+ return new Date().toISOString();
34
+ }
35
+
36
+ export function resolveLoopDir(repoRoot) {
37
+ return path.join(repoRoot, ".loop");
38
+ }
39
+
40
+ export function getLoopPaths(repoRoot) {
41
+ const root = resolveLoopDir(repoRoot);
42
+ return {
43
+ root,
44
+ state: path.join(root, STATE_FILENAME),
45
+ iterations: path.join(root, "iterations"),
46
+ iterationsDryRun: path.join(root, "iterations-dryrun"),
47
+ proposals: path.join(root, "proposals"),
48
+ progressLog: path.join(root, "progress.log"),
49
+ lock: path.join(root, "loop.lock"),
50
+ pid: path.join(root, "loop.pid"),
51
+ log: path.join(root, "loop.log"),
52
+ archive: path.join(root, "archive")
53
+ };
54
+ }
55
+
56
+ export function ensureLoopDir(repoRoot) {
57
+ const paths = getLoopPaths(repoRoot);
58
+ fs.mkdirSync(paths.root, { recursive: true });
59
+ fs.mkdirSync(paths.iterations, { recursive: true });
60
+ fs.mkdirSync(paths.proposals, { recursive: true });
61
+ return paths;
62
+ }
63
+
64
+ // CodexLoop's opinionated defaults — independent of ~/.codex/config.toml.
65
+ // These apply when the user starts a loop without passing --model / --effort
66
+ // and when no prior state has set them explicitly.
67
+ export const DEFAULT_MODEL = "gpt-5.4";
68
+ export const DEFAULT_REASONING_EFFORT = "xhigh";
69
+ export const VALID_REASONING_EFFORTS = Object.freeze([
70
+ "none",
71
+ "minimal",
72
+ "low",
73
+ "medium",
74
+ "high",
75
+ "xhigh"
76
+ ]);
77
+
78
+ export function defaultState() {
79
+ return {
80
+ version: STATE_VERSION,
81
+ loopId: null,
82
+ status: "idle",
83
+ pid: null,
84
+ mode: "interactive",
85
+ model: DEFAULT_MODEL,
86
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
87
+ startedAt: null,
88
+ lastIterationAt: null,
89
+ completedAt: null,
90
+ goal: {
91
+ text: "",
92
+ acceptanceCriteria: [],
93
+ seedCommit: null,
94
+ relevantGlobs: [],
95
+ testCmd: null,
96
+ lintCmd: null,
97
+ typeCmd: null,
98
+ goalHash: null
99
+ },
100
+ budget: {
101
+ maxIterations: 20,
102
+ maxElapsedMs: 3_600_000,
103
+ maxCodexCalls: 200,
104
+ consumed: {
105
+ iterations: 0,
106
+ elapsedMs: 0,
107
+ codexCalls: 0,
108
+ startedAtMs: null
109
+ }
110
+ },
111
+ convergence: {
112
+ epsilon: 0.02,
113
+ stableWindow: 2,
114
+ scoreHistory: [],
115
+ stalledSince: null
116
+ },
117
+ iterations: [],
118
+ accepted: [],
119
+ rejected: [],
120
+ openIssues: [],
121
+ openIssuesInitial: 0,
122
+ stopReason: null,
123
+ error: null
124
+ };
125
+ }
126
+
127
+ export const MODEL_ALIASES = Object.freeze({
128
+ spark: "gpt-5.3-codex-spark"
129
+ });
130
+
131
+ export function normalizeModelName(name) {
132
+ if (name == null) return null;
133
+ const trimmed = String(name).trim();
134
+ if (!trimmed) return null;
135
+ const lower = trimmed.toLowerCase();
136
+ return MODEL_ALIASES[lower] ?? trimmed;
137
+ }
138
+
139
+ export function normalizeReasoningEffort(effort) {
140
+ if (effort == null) return null;
141
+ const trimmed = String(effort).trim().toLowerCase();
142
+ if (!trimmed) return null;
143
+ if (!VALID_REASONING_EFFORTS.includes(trimmed)) {
144
+ throw new Error(
145
+ `Invalid reasoning effort "${effort}". Valid values: ${VALID_REASONING_EFFORTS.join(", ")}`
146
+ );
147
+ }
148
+ return trimmed;
149
+ }
150
+
151
+ function mergeStateShape(loaded) {
152
+ const base = defaultState();
153
+ const merged = {
154
+ ...base,
155
+ ...loaded,
156
+ goal: { ...base.goal, ...(loaded.goal ?? {}) },
157
+ budget: {
158
+ ...base.budget,
159
+ ...(loaded.budget ?? {}),
160
+ consumed: { ...base.budget.consumed, ...(loaded.budget?.consumed ?? {}) }
161
+ },
162
+ convergence: { ...base.convergence, ...(loaded.convergence ?? {}) },
163
+ iterations: Array.isArray(loaded.iterations) ? loaded.iterations : [],
164
+ accepted: Array.isArray(loaded.accepted) ? loaded.accepted : [],
165
+ rejected: Array.isArray(loaded.rejected) ? loaded.rejected : [],
166
+ openIssues: Array.isArray(loaded.openIssues) ? loaded.openIssues : []
167
+ };
168
+ merged.version = STATE_VERSION;
169
+ return merged;
170
+ }
171
+
172
+ function applyMigrations(loaded) {
173
+ let state = loaded;
174
+ const version = Number.isFinite(state?.version) ? state.version : 0;
175
+ for (let i = version; i < STATE_VERSION; i += 1) {
176
+ const migrate = MIGRATIONS[i];
177
+ if (typeof migrate === "function") {
178
+ state = migrate(state);
179
+ }
180
+ }
181
+ if (state) {
182
+ state.version = STATE_VERSION;
183
+ }
184
+ return state;
185
+ }
186
+
187
+ export function loadState(repoRoot) {
188
+ const paths = getLoopPaths(repoRoot);
189
+ if (!fs.existsSync(paths.state)) {
190
+ return defaultState();
191
+ }
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(fs.readFileSync(paths.state, "utf8"));
195
+ } catch (err) {
196
+ throw new Error(`Failed to parse ${paths.state}: ${err.message}`);
197
+ }
198
+ return mergeStateShape(applyMigrations(parsed));
199
+ }
200
+
201
+ function atomicWriteFile(filePath, contents) {
202
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
203
+ const suffix = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
204
+ const tmp = `${filePath}.tmp-${suffix}`;
205
+ fs.writeFileSync(tmp, contents, "utf8");
206
+ fs.renameSync(tmp, filePath);
207
+ }
208
+
209
+ export function saveState(repoRoot, state) {
210
+ ensureLoopDir(repoRoot);
211
+ const paths = getLoopPaths(repoRoot);
212
+ const payload = `${JSON.stringify({ ...state, version: STATE_VERSION }, null, 2)}\n`;
213
+ atomicWriteFile(paths.state, payload);
214
+ return state;
215
+ }
216
+
217
+ export function updateState(repoRoot, mutate) {
218
+ const state = loadState(repoRoot);
219
+ mutate(state);
220
+ return saveState(repoRoot, state);
221
+ }
222
+
223
+ export function generateLoopId() {
224
+ const time = Date.now().toString(36);
225
+ const rand = crypto.randomBytes(3).toString("hex");
226
+ return `loop-${time}-${rand}`;
227
+ }
228
+
229
+ export function computeGoalHash(goal) {
230
+ const canonical = JSON.stringify({
231
+ text: goal.text ?? "",
232
+ acceptanceCriteria: goal.acceptanceCriteria ?? [],
233
+ testCmd: goal.testCmd ?? null,
234
+ lintCmd: goal.lintCmd ?? null,
235
+ typeCmd: goal.typeCmd ?? null
236
+ });
237
+ return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
238
+ }
239
+
240
+ export function writeIterationFile(repoRoot, iteration, { dryRun = false } = {}) {
241
+ const paths = getLoopPaths(repoRoot);
242
+ const dir = dryRun ? paths.iterationsDryRun : paths.iterations;
243
+ fs.mkdirSync(dir, { recursive: true });
244
+ const name = `${String(iteration.index).padStart(4, "0")}.json`;
245
+ const filePath = path.join(dir, name);
246
+ atomicWriteFile(filePath, `${JSON.stringify(iteration, null, 2)}\n`);
247
+ return filePath;
248
+ }
249
+
250
+ export function readIterationFile(repoRoot, index, { dryRun = false } = {}) {
251
+ const paths = getLoopPaths(repoRoot);
252
+ const dir = dryRun ? paths.iterationsDryRun : paths.iterations;
253
+ const name = `${String(index).padStart(4, "0")}.json`;
254
+ const filePath = path.join(dir, name);
255
+ if (!fs.existsSync(filePath)) return null;
256
+ try {
257
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ export function writeProposalFile(repoRoot, iterationIndex, proposalId, proposal) {
264
+ const paths = getLoopPaths(repoRoot);
265
+ fs.mkdirSync(paths.proposals, { recursive: true });
266
+ const safeId = String(proposalId).replace(/[^a-zA-Z0-9_-]/g, "_");
267
+ const name = `${String(iterationIndex).padStart(4, "0")}-${safeId}.json`;
268
+ const filePath = path.join(paths.proposals, name);
269
+ atomicWriteFile(filePath, `${JSON.stringify(proposal, null, 2)}\n`);
270
+ return filePath;
271
+ }
272
+
273
+ export function appendProgressLog(repoRoot, text) {
274
+ const paths = getLoopPaths(repoRoot);
275
+ fs.mkdirSync(paths.root, { recursive: true });
276
+ const line = `${nowIso()} ${text}\n`;
277
+ fs.appendFileSync(paths.progressLog, line, "utf8");
278
+ }
279
+
280
+ export function readProgressLogTail(repoRoot, { lines = 30 } = {}) {
281
+ const paths = getLoopPaths(repoRoot);
282
+ if (!fs.existsSync(paths.progressLog)) {
283
+ return "";
284
+ }
285
+ const contents = fs.readFileSync(paths.progressLog, "utf8");
286
+ const split = contents.split(/\r?\n/).filter(Boolean);
287
+ return split.slice(-lines).join("\n");
288
+ }
289
+
290
+ export class LoopLockError extends Error {
291
+ constructor(message, info) {
292
+ super(message);
293
+ this.name = "LoopLockError";
294
+ this.info = info;
295
+ }
296
+ }
297
+
298
+ export function acquireLock(repoRoot, { pid, loopId }) {
299
+ ensureLoopDir(repoRoot);
300
+ const paths = getLoopPaths(repoRoot);
301
+ if (fs.existsSync(paths.lock)) {
302
+ let existing = null;
303
+ try {
304
+ existing = JSON.parse(fs.readFileSync(paths.lock, "utf8"));
305
+ } catch {
306
+ existing = null;
307
+ }
308
+ if (existing && Number.isFinite(existing.pid) && isProcessAlive(existing.pid)) {
309
+ throw new LoopLockError(
310
+ `Another CodexLoop is already running (pid=${existing.pid}, loopId=${existing.loopId ?? "unknown"}). ` +
311
+ "Run /cloop:status to inspect or /cloop:stop to cancel it.",
312
+ existing
313
+ );
314
+ }
315
+ }
316
+ const payload = { pid, loopId, acquiredAt: nowIso() };
317
+ atomicWriteFile(paths.lock, `${JSON.stringify(payload, null, 2)}\n`);
318
+ return payload;
319
+ }
320
+
321
+ export function releaseLock(repoRoot) {
322
+ const paths = getLoopPaths(repoRoot);
323
+ if (fs.existsSync(paths.lock)) {
324
+ try { fs.unlinkSync(paths.lock); } catch {}
325
+ }
326
+ }
327
+
328
+ export function readLock(repoRoot) {
329
+ const paths = getLoopPaths(repoRoot);
330
+ if (!fs.existsSync(paths.lock)) return null;
331
+ try {
332
+ return JSON.parse(fs.readFileSync(paths.lock, "utf8"));
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+
338
+ export function writePidFile(repoRoot, pid) {
339
+ const paths = getLoopPaths(repoRoot);
340
+ ensureLoopDir(repoRoot);
341
+ atomicWriteFile(paths.pid, `${pid}\n`);
342
+ }
343
+
344
+ export function removePidFile(repoRoot) {
345
+ const paths = getLoopPaths(repoRoot);
346
+ if (fs.existsSync(paths.pid)) {
347
+ try { fs.unlinkSync(paths.pid); } catch {}
348
+ }
349
+ }
350
+
351
+ export function readPidFile(repoRoot) {
352
+ const paths = getLoopPaths(repoRoot);
353
+ if (!fs.existsSync(paths.pid)) return null;
354
+ try {
355
+ const pid = Number.parseInt(fs.readFileSync(paths.pid, "utf8").trim(), 10);
356
+ return Number.isFinite(pid) ? pid : null;
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ export function ensureGitignore(repoRoot) {
363
+ const gitignorePath = path.join(repoRoot, ".gitignore");
364
+ const entry = ".loop/";
365
+ let existing = "";
366
+ if (fs.existsSync(gitignorePath)) {
367
+ existing = fs.readFileSync(gitignorePath, "utf8");
368
+ const normalized = existing.split(/\r?\n/).map((l) => l.trim());
369
+ if (normalized.includes(".loop/") || normalized.includes(".loop") || normalized.includes("/.loop/")) {
370
+ return { added: false, path: gitignorePath };
371
+ }
372
+ }
373
+ const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
374
+ fs.writeFileSync(gitignorePath, `${existing}${separator}${entry}\n`, "utf8");
375
+ return { added: true, path: gitignorePath };
376
+ }
377
+
378
+ export { STATE_VERSION };
@@ -0,0 +1,71 @@
1
+ // Validation runner: executes the user-configured test / lint / type
2
+ // commands and reports exit-code-derived pass/fail plus a simple regression
3
+ // flag relative to the previous iteration.
4
+
5
+ import { runCommand } from "./process.mjs";
6
+
7
+ function tailLines(text, max = 20) {
8
+ if (!text) return "";
9
+ const split = String(text).split(/\r?\n/);
10
+ return split.slice(-max).join("\n");
11
+ }
12
+
13
+ function runShell(cmd, { cwd, timeoutMs = 600_000 }) {
14
+ const startedAt = Date.now();
15
+ const result = runCommand("sh", ["-c", cmd], { cwd, timeoutMs });
16
+ return {
17
+ cmd,
18
+ status: result.status,
19
+ signal: result.signal,
20
+ error: result.error ? result.error.message : null,
21
+ stdoutTail: tailLines(result.stdout),
22
+ stderrTail: tailLines(result.stderr),
23
+ durationMs: Date.now() - startedAt
24
+ };
25
+ }
26
+
27
+ // Returns a structured record the iteration layer records verbatim.
28
+ // `previousIteration` is the previous iteration summary from state.iterations
29
+ // (undefined on the first iteration).
30
+ export async function runValidation({ cwd, goal, previousIteration }) {
31
+ const record = {
32
+ passed: null,
33
+ testsPassed: null,
34
+ passingTests: null,
35
+ failingTests: null,
36
+ typeErrors: null,
37
+ lintErrors: null,
38
+ regression: false,
39
+ commands: []
40
+ };
41
+
42
+ const test = goal?.testCmd ? runShell(goal.testCmd, { cwd }) : null;
43
+ if (test) {
44
+ record.commands.push({ kind: "test", ...test });
45
+ record.testsPassed = test.status === 0;
46
+ }
47
+
48
+ const lint = goal?.lintCmd ? runShell(goal.lintCmd, { cwd }) : null;
49
+ if (lint) {
50
+ record.commands.push({ kind: "lint", ...lint });
51
+ record.lintErrors = lint.status === 0 ? 0 : 1;
52
+ }
53
+
54
+ const typeCheck = goal?.typeCmd ? runShell(goal.typeCmd, { cwd }) : null;
55
+ if (typeCheck) {
56
+ record.commands.push({ kind: "type", ...typeCheck });
57
+ record.typeErrors = typeCheck.status === 0 ? 0 : 1;
58
+ }
59
+
60
+ if (record.commands.length === 0) {
61
+ record.passed = null;
62
+ } else {
63
+ record.passed = record.commands.every((c) => c.status === 0);
64
+ }
65
+
66
+ if (previousIteration?.validate?.passed === true && record.passed === false) {
67
+ record.regression = true;
68
+ }
69
+
70
+ return record;
71
+ }
@@ -0,0 +1,49 @@
1
+ // Workspace root resolution.
2
+ //
3
+ // CodexLoop requires a git repository at the target directory so it can use
4
+ // `git stash` / `git apply` / `git reset --hard` to make patch application
5
+ // safely reversible. This module is the single source of truth for finding
6
+ // the repo root and for refusing to start a loop outside of one.
7
+
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ function runGit(args, cwd) {
11
+ return spawnSync("git", args, {
12
+ cwd,
13
+ encoding: "utf8",
14
+ stdio: ["ignore", "pipe", "pipe"]
15
+ });
16
+ }
17
+
18
+ export function resolveWorkspaceRoot(cwd = process.cwd()) {
19
+ const result = runGit(["rev-parse", "--show-toplevel"], cwd);
20
+ if (result.status === 0 && result.stdout.trim()) {
21
+ return result.stdout.trim();
22
+ }
23
+ return cwd;
24
+ }
25
+
26
+ export function isInsideGitRepo(cwd = process.cwd()) {
27
+ const result = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
28
+ return result.status === 0 && result.stdout.trim() === "true";
29
+ }
30
+
31
+ export function requireGitRepository(cwd = process.cwd()) {
32
+ const result = runGit(["rev-parse", "--show-toplevel"], cwd);
33
+ if (result.status !== 0 || !result.stdout.trim()) {
34
+ const detail = (result.stderr || result.stdout || "").trim();
35
+ throw new Error(
36
+ `CodexLoop requires a git repository. '${cwd}' is not inside one${detail ? ` (${detail})` : ""}. ` +
37
+ "Run 'git init' and commit a baseline before starting a loop."
38
+ );
39
+ }
40
+ return result.stdout.trim();
41
+ }
42
+
43
+ export function gitHeadSha(cwd) {
44
+ const result = runGit(["rev-parse", "HEAD"], cwd);
45
+ if (result.status !== 0) {
46
+ return null;
47
+ }
48
+ return result.stdout.trim() || null;
49
+ }