@jhlee0619/codexloop 0.1.0 → 0.1.3
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/package.json +2 -2
- package/prompts/evaluate.md +10 -0
- package/prompts/rank.md +4 -0
- package/prompts/suggest.md +10 -0
- package/schemas/operation.schema.json +45 -0
- package/schemas/task-spec.schema.json +85 -0
- package/schemas/validator-result.schema.json +45 -0
- package/scripts/lib/apply.mjs +112 -15
- package/scripts/lib/codex-exec.mjs +25 -0
- package/scripts/lib/convergence.mjs +45 -11
- package/scripts/lib/iteration.mjs +39 -11
- package/scripts/lib/modes/artifact.mjs +56 -0
- package/scripts/lib/modes/code.mjs +33 -0
- package/scripts/lib/modes/index.mjs +30 -0
- package/scripts/lib/operations.mjs +244 -0
- package/scripts/lib/retrieve.mjs +132 -0
- package/scripts/lib/state.mjs +28 -6
- package/scripts/lib/task-spec.mjs +111 -0
- package/scripts/lib/validate.mjs +37 -6
- package/scripts/lib/validators/file-exists.mjs +35 -0
- package/scripts/lib/validators/headings-present.mjs +44 -0
- package/scripts/lib/validators/max-placeholder-count.mjs +58 -0
- package/scripts/lib/validators/registry.mjs +47 -0
- package/scripts/loop-companion.mjs +53 -2
package/scripts/lib/validate.mjs
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// Validation runner: executes the user-configured test / lint / type
|
|
2
|
-
// commands
|
|
3
|
-
// flag relative to the previous iteration.
|
|
2
|
+
// commands AND the built-in validator pipeline, then reports combined
|
|
3
|
+
// pass/fail plus a simple regression flag relative to the previous iteration.
|
|
4
|
+
//
|
|
5
|
+
// v0.2: validator pipeline runs spec.validators + mode-implicit validators
|
|
6
|
+
// from the mode adapter. Each result is stored in record.validatorResults.
|
|
7
|
+
// Overall pass = shell_passed AND all error-severity validators passed.
|
|
4
8
|
|
|
5
9
|
import { runCommand } from "./process.mjs";
|
|
10
|
+
import { runValidator } from "./validators/registry.mjs";
|
|
6
11
|
|
|
7
12
|
function tailLines(text, max = 20) {
|
|
8
13
|
if (!text) return "";
|
|
@@ -27,7 +32,8 @@ function runShell(cmd, { cwd, timeoutMs = 600_000 }) {
|
|
|
27
32
|
// Returns a structured record the iteration layer records verbatim.
|
|
28
33
|
// `previousIteration` is the previous iteration summary from state.iterations
|
|
29
34
|
// (undefined on the first iteration).
|
|
30
|
-
|
|
35
|
+
// `spec` and `implicitValidators` are v0.2 additions for the built-in validator pipeline.
|
|
36
|
+
export async function runValidation({ cwd, goal, previousIteration, spec = null, implicitValidators = [] }) {
|
|
31
37
|
const record = {
|
|
32
38
|
passed: null,
|
|
33
39
|
testsPassed: null,
|
|
@@ -36,7 +42,8 @@ export async function runValidation({ cwd, goal, previousIteration }) {
|
|
|
36
42
|
typeErrors: null,
|
|
37
43
|
lintErrors: null,
|
|
38
44
|
regression: false,
|
|
39
|
-
commands: []
|
|
45
|
+
commands: [],
|
|
46
|
+
validatorResults: []
|
|
40
47
|
};
|
|
41
48
|
|
|
42
49
|
const test = goal?.testCmd ? runShell(goal.testCmd, { cwd }) : null;
|
|
@@ -57,10 +64,34 @@ export async function runValidation({ cwd, goal, previousIteration }) {
|
|
|
57
64
|
record.typeErrors = typeCheck.status === 0 ? 0 : 1;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
|
|
67
|
+
// ── Built-in validator pipeline (v0.2) ──────────────────────────
|
|
68
|
+
const validatorEntries = [
|
|
69
|
+
...implicitValidators,
|
|
70
|
+
...(Array.isArray(spec?.validators) ? spec.validators : [])
|
|
71
|
+
];
|
|
72
|
+
const seenNames = new Set();
|
|
73
|
+
for (const entry of validatorEntries) {
|
|
74
|
+
if (!entry.name || seenNames.has(entry.name)) continue;
|
|
75
|
+
seenNames.add(entry.name);
|
|
76
|
+
const result = runValidator(entry.name, { cwd, spec, args: entry.args ?? {} });
|
|
77
|
+
record.validatorResults.push(result);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Combined pass logic ────────────────────────────────────────
|
|
81
|
+
const shellPassed = record.commands.length === 0
|
|
82
|
+
? null
|
|
83
|
+
: record.commands.every((c) => c.status === 0);
|
|
84
|
+
|
|
85
|
+
const errorValidatorsFailed = record.validatorResults
|
|
86
|
+
.filter((r) => r.severity === "error")
|
|
87
|
+
.some((r) => !r.passed);
|
|
88
|
+
|
|
89
|
+
if (shellPassed === null && record.validatorResults.length === 0) {
|
|
61
90
|
record.passed = null;
|
|
91
|
+
} else if (shellPassed === false || errorValidatorsFailed) {
|
|
92
|
+
record.passed = false;
|
|
62
93
|
} else {
|
|
63
|
-
record.passed =
|
|
94
|
+
record.passed = true;
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
if (previousIteration?.validate?.passed === true && record.passed === false) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const name = "file_exists";
|
|
5
|
+
|
|
6
|
+
export function run({ cwd, spec, args }) {
|
|
7
|
+
const files = args?.files ?? spec?.required_files ?? [];
|
|
8
|
+
const missing = [];
|
|
9
|
+
const empty = [];
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
const abs = path.resolve(cwd, file);
|
|
12
|
+
if (!fs.existsSync(abs)) {
|
|
13
|
+
missing.push(file);
|
|
14
|
+
} else {
|
|
15
|
+
const stat = fs.statSync(abs);
|
|
16
|
+
if (stat.size === 0) {
|
|
17
|
+
empty.push(file);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const issues = [...missing.map((f) => `missing: ${f}`), ...empty.map((f) => `empty: ${f}`)];
|
|
22
|
+
return {
|
|
23
|
+
name,
|
|
24
|
+
passed: issues.length === 0,
|
|
25
|
+
severity: issues.length > 0 ? "error" : "info",
|
|
26
|
+
count: issues.length,
|
|
27
|
+
details: issues.length === 0
|
|
28
|
+
? `All ${files.length} required file(s) exist and are non-empty.`
|
|
29
|
+
: `${issues.length} issue(s): ${issues.join("; ")}`,
|
|
30
|
+
evidence: [
|
|
31
|
+
...missing.map((f) => ({ file: f, line: null, snippet: "file does not exist" })),
|
|
32
|
+
...empty.map((f) => ({ file: f, line: null, snippet: "file is empty (0 bytes)" }))
|
|
33
|
+
]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const name = "headings_present";
|
|
5
|
+
|
|
6
|
+
export function run({ cwd, spec, args }) {
|
|
7
|
+
const sections = args?.sections ?? spec?.required_sections ?? [];
|
|
8
|
+
const missing = [];
|
|
9
|
+
|
|
10
|
+
for (const entry of sections) {
|
|
11
|
+
const file = entry.file;
|
|
12
|
+
const headings = Array.isArray(entry.headings) ? entry.headings : [];
|
|
13
|
+
const abs = path.resolve(cwd, file);
|
|
14
|
+
if (!fs.existsSync(abs)) {
|
|
15
|
+
for (const h of headings) {
|
|
16
|
+
missing.push({ file, heading: h, reason: "file not found" });
|
|
17
|
+
}
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
21
|
+
const lines = content.split(/\r?\n/).map((l) => l.trim());
|
|
22
|
+
for (const h of headings) {
|
|
23
|
+
const trimmed = h.trim();
|
|
24
|
+
if (!lines.includes(trimmed)) {
|
|
25
|
+
missing.push({ file, heading: trimmed, reason: "heading not found" });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
passed: missing.length === 0,
|
|
33
|
+
severity: missing.length > 0 ? "error" : "info",
|
|
34
|
+
count: missing.length,
|
|
35
|
+
details: missing.length === 0
|
|
36
|
+
? "All required headings are present."
|
|
37
|
+
: `${missing.length} heading(s) missing: ${missing.map((m) => `${m.heading} in ${m.file}`).join("; ")}`,
|
|
38
|
+
evidence: missing.map((m) => ({
|
|
39
|
+
file: m.file,
|
|
40
|
+
line: null,
|
|
41
|
+
snippet: `${m.reason}: ${m.heading}`
|
|
42
|
+
}))
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const name = "max_placeholder_count";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PATTERN = /\bTODO\b|\bTBD\b|\bXXX\b|\[placeholder\]/gi;
|
|
7
|
+
const FENCE_OPEN = /^```/;
|
|
8
|
+
const FENCE_CLOSE = /^```$/;
|
|
9
|
+
|
|
10
|
+
export function run({ cwd, spec, args }) {
|
|
11
|
+
const max = args?.max ?? spec?.placeholder_policy?.max ?? 10;
|
|
12
|
+
const customPattern = args?.pattern ? new RegExp(args.pattern, "gi") : null;
|
|
13
|
+
const pattern = customPattern ?? DEFAULT_PATTERN;
|
|
14
|
+
const files = args?.files ?? spec?.required_files ?? [];
|
|
15
|
+
|
|
16
|
+
const hits = [];
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
const abs = path.resolve(cwd, file);
|
|
19
|
+
if (!fs.existsSync(abs)) continue;
|
|
20
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
21
|
+
const lines = content.split(/\r?\n/);
|
|
22
|
+
|
|
23
|
+
let inFence = false;
|
|
24
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
25
|
+
const line = lines[i];
|
|
26
|
+
if (FENCE_OPEN.test(line.trim())) {
|
|
27
|
+
inFence = !inFence;
|
|
28
|
+
if (FENCE_CLOSE.test(line.trim()) && inFence) {
|
|
29
|
+
inFence = true;
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (inFence) continue;
|
|
34
|
+
|
|
35
|
+
pattern.lastIndex = 0;
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
38
|
+
hits.push({
|
|
39
|
+
file,
|
|
40
|
+
line: i + 1,
|
|
41
|
+
snippet: line.trim().slice(0, 160)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const passed = hits.length <= max;
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
passed,
|
|
51
|
+
severity: passed ? (hits.length > 0 ? "warn" : "info") : "error",
|
|
52
|
+
count: hits.length,
|
|
53
|
+
details: hits.length === 0
|
|
54
|
+
? "No placeholders found."
|
|
55
|
+
: `${hits.length} placeholder(s) found (max ${max}).${passed ? "" : " EXCEEDS LIMIT."}`,
|
|
56
|
+
evidence: hits.slice(0, 20)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Validator name → run function registry.
|
|
2
|
+
// validate.mjs looks up validators here by name. Each validator is a
|
|
3
|
+
// standalone .mjs file exporting { name, run({ cwd, spec, args }) }.
|
|
4
|
+
|
|
5
|
+
import { name as fileExistsName, run as fileExistsRun } from "./file-exists.mjs";
|
|
6
|
+
import { name as headingsPresentName, run as headingsPresentRun } from "./headings-present.mjs";
|
|
7
|
+
import { name as maxPlaceholderName, run as maxPlaceholderRun } from "./max-placeholder-count.mjs";
|
|
8
|
+
|
|
9
|
+
const REGISTRY = {
|
|
10
|
+
[fileExistsName]: fileExistsRun,
|
|
11
|
+
[headingsPresentName]: headingsPresentRun,
|
|
12
|
+
[maxPlaceholderName]: maxPlaceholderRun
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function getValidator(name) {
|
|
16
|
+
return REGISTRY[name] ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getRegisteredNames() {
|
|
20
|
+
return Object.keys(REGISTRY);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function runValidator(name, context) {
|
|
24
|
+
const fn = getValidator(name);
|
|
25
|
+
if (!fn) {
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
passed: false,
|
|
29
|
+
severity: "error",
|
|
30
|
+
count: null,
|
|
31
|
+
details: `Unknown validator: ${name}. Registered: ${getRegisteredNames().join(", ")}`,
|
|
32
|
+
evidence: []
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return fn(context);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
passed: false,
|
|
41
|
+
severity: "error",
|
|
42
|
+
count: null,
|
|
43
|
+
details: `Validator ${name} threw: ${err.message}`,
|
|
44
|
+
evidence: []
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
VALID_REASONING_EFFORTS,
|
|
57
57
|
writePidFile
|
|
58
58
|
} from "./lib/state.mjs";
|
|
59
|
+
import { loadTaskSpec, autoDetectTaskSpec } from "./lib/task-spec.mjs";
|
|
60
|
+
import { getValidModes } from "./lib/modes/index.mjs";
|
|
59
61
|
import { runIteration } from "./lib/iteration.mjs";
|
|
60
62
|
import { checkCodexAvailable } from "./lib/codex-exec.mjs";
|
|
61
63
|
import { checkStopping } from "./lib/convergence.mjs";
|
|
@@ -98,7 +100,8 @@ const USAGE = [
|
|
|
98
100
|
|
|
99
101
|
const VALUE_OPTIONS = [
|
|
100
102
|
"goal", "task-file", "max-iter", "max-time", "max-calls",
|
|
101
|
-
"model", "effort", "nproposals", "
|
|
103
|
+
"model", "effort", "nproposals", "mode",
|
|
104
|
+
"test-cmd", "lint-cmd", "type-cmd",
|
|
102
105
|
"cwd", "loop-id", "iteration"
|
|
103
106
|
];
|
|
104
107
|
const BOOLEAN_OPTIONS = [
|
|
@@ -262,13 +265,49 @@ async function handleStart(argv) {
|
|
|
262
265
|
return 1;
|
|
263
266
|
}
|
|
264
267
|
}
|
|
268
|
+
// v0.2: task mode + task spec
|
|
269
|
+
if (options.mode) {
|
|
270
|
+
const validModes = getValidModes();
|
|
271
|
+
const lower = String(options.mode).trim().toLowerCase();
|
|
272
|
+
if (!validModes.includes(lower)) {
|
|
273
|
+
printError(`Invalid task mode "${options.mode}". Valid: ${validModes.join(", ")}`);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
state.taskMode = lower;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Load task spec: --task-file <path>.json → parse as spec; auto-detect cloop.task.json at repo root
|
|
280
|
+
if (options["task-file"] && String(options["task-file"]).endsWith(".json")) {
|
|
281
|
+
try {
|
|
282
|
+
const specPath = path.resolve(repoRoot, options["task-file"]);
|
|
283
|
+
const spec = loadTaskSpec(specPath);
|
|
284
|
+
if (spec) {
|
|
285
|
+
state.goal.spec = spec;
|
|
286
|
+
if (spec.mode && !options.mode) {
|
|
287
|
+
state.taskMode = spec.mode;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch (err) {
|
|
291
|
+
printError(`Failed to load task spec: ${err.message}`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
} else if (!options["task-file"]) {
|
|
295
|
+
const detected = autoDetectTaskSpec(repoRoot);
|
|
296
|
+
if (detected) {
|
|
297
|
+
state.goal.spec = detected;
|
|
298
|
+
if (detected.mode && !options.mode) {
|
|
299
|
+
state.taskMode = detected.mode;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
265
304
|
state.startedAt = nowIso();
|
|
266
305
|
state.goal.text = goalText;
|
|
267
306
|
state.goal.seedCommit = gitHeadSha(repoRoot);
|
|
268
307
|
state.goal.testCmd = options["test-cmd"] ?? null;
|
|
269
308
|
state.goal.lintCmd = options["lint-cmd"] ?? null;
|
|
270
309
|
state.goal.typeCmd = options["type-cmd"] ?? null;
|
|
271
|
-
state.goal.goalHash = computeGoalHash(state.goal);
|
|
310
|
+
state.goal.goalHash = computeGoalHash(state.goal, state.taskMode);
|
|
272
311
|
|
|
273
312
|
if (options["max-iter"]) {
|
|
274
313
|
state.budget.maxIterations = parseInteger(options["max-iter"], {
|
|
@@ -295,6 +334,18 @@ async function handleStart(argv) {
|
|
|
295
334
|
const gi = ensureGitignore(repoRoot);
|
|
296
335
|
if (gi.added) {
|
|
297
336
|
process.stdout.write(`Added .loop/ to ${gi.path}\n`);
|
|
337
|
+
// Commit the .gitignore change so the working tree is clean for the first
|
|
338
|
+
// iteration's applyPatch check. handleStart already verified the tree was
|
|
339
|
+
// clean before this point, so the only dirty file is .gitignore.
|
|
340
|
+
const commitEnv = {
|
|
341
|
+
...process.env,
|
|
342
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "CodexLoop",
|
|
343
|
+
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "codexloop@local",
|
|
344
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "CodexLoop",
|
|
345
|
+
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "codexloop@local"
|
|
346
|
+
};
|
|
347
|
+
runCommand("git", ["add", ".gitignore"], { cwd: repoRoot, env: commitEnv });
|
|
348
|
+
runCommand("git", ["commit", "-m", "cloop: add .loop/ to .gitignore"], { cwd: repoRoot, env: commitEnv });
|
|
298
349
|
}
|
|
299
350
|
saveState(repoRoot, state);
|
|
300
351
|
|