@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.
@@ -1,8 +1,13 @@
1
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.
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
- export async function runValidation({ cwd, goal, previousIteration }) {
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
- if (record.commands.length === 0) {
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 = record.commands.every((c) => c.status === 0);
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", "test-cmd", "lint-cmd", "type-cmd",
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