diff --git a/.gitignore b/.gitignore
index 7024744..1217747 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ coverage/
.env.local
tests/e2e/.env.test
.vitest-cache/
+.codex/night-loop/
# Local Claude artifacts stay ignored, but shared agent skills are tracked.
.claude/*
!.claude/skills/
diff --git a/context/map/artifact-1-territory.md b/context/map/artifact-1-territory.md
index e938077..26be2f0 100644
--- a/context/map/artifact-1-territory.md
+++ b/context/map/artifact-1-territory.md
@@ -2,7 +2,7 @@
-> Scan of `7e3dab4` (2026-07-28) · window: last 12 months · engine: tools/repo-map
+> Scan of `c823d6d` (2026-07-28) · window: last 12 months · engine: tools/repo-map
**Where the work actually happens.** Git-history activity over the last 12 months, noise filtered (lockfiles, generated files, assets). Activity is a signal of *risk of accidental regression*, not of quality.
@@ -11,11 +11,11 @@
| Area | Commits |
| --- | --- |
| `src/lib` | 75 |
+| `context/map` | 54 |
| `context/changes` | 53 |
-| `context/map` | 50 |
| `tests/e2e` | 32 |
| `src/commands` | 30 |
-| `package.json` | 28 |
+| `package.json` | 29 |
| `.github/workflows` | 19 |
| `context/archive` | 19 |
| `tests/get-command.test.ts` | 13 |
@@ -30,15 +30,15 @@
| File | Commits |
| --- | --- |
-| `package.json` | 28 |
+| `package.json` | 29 |
| `src/commands/get.ts` | 16 |
| `.github/workflows/ci.yml` | 14 |
| `tests/get-command.test.ts` | 13 |
| `src/lib/writer.ts` | 12 |
-| `context/map/artifact-1-territory.md` | 10 |
-| `context/map/artifact-2-structure.md` | 10 |
-| `context/map/artifact-3-contributors.md` | 10 |
-| `context/map/repo-map.md` | 10 |
+| `context/map/artifact-1-territory.md` | 11 |
+| `context/map/artifact-2-structure.md` | 11 |
+| `context/map/artifact-3-contributors.md` | 11 |
+| `context/map/repo-map.md` | 11 |
| `README.md` | 9 |
| `tests/writer.test.ts` | 9 |
| `context/map/graph/coupling.json` | 8 |
@@ -51,8 +51,8 @@
| Area | 2026-Q2 | 2026-Q3 |
| --- | --- | --- |
| `src/lib` | 34 | 1 |
+| `context/map` | 0 | 11 |
| `context/changes` | 28 | 1 |
-| `context/map` | 0 | 10 |
| `tests/e2e` | 11 | 0 |
| `src/commands` | 23 | 0 |
diff --git a/context/map/artifact-2-structure.md b/context/map/artifact-2-structure.md
index ff3c214..6c008ff 100644
--- a/context/map/artifact-2-structure.md
+++ b/context/map/artifact-2-structure.md
@@ -2,7 +2,7 @@
-> Scan of `7e3dab4` (2026-07-28) · window: last 12 months · engine: tools/repo-map
+> Scan of `c823d6d` (2026-07-28) · window: last 12 months · engine: tools/repo-map
**How it is built.** Static import graph of `src` plus a file inventory of every module. Folders show where the code is; the graph shows what actually depends on what.
@@ -12,7 +12,7 @@
| --- | --- | --- |
| commands (CLI surface) | `src/commands` | 5 |
| lib (core logic) | `src/lib` | 19 |
-| tests | `tests` | 46 |
+| tests | `tests` | 47 |
## Circular dependencies in `src`
diff --git a/context/map/artifact-3-contributors.md b/context/map/artifact-3-contributors.md
index 1707a1b..7cecdfc 100644
--- a/context/map/artifact-3-contributors.md
+++ b/context/map/artifact-3-contributors.md
@@ -2,7 +2,7 @@
-> Scan of `7e3dab4` (2026-07-28) · window: last 12 months · engine: tools/repo-map
+> Scan of `c823d6d` (2026-07-28) · window: last 12 months · engine: tools/repo-map
**Who to ask before you change something.** Bots and AI agents (Claude, Codex, Copilot, dependabot…) are filtered out — this is about humans who hold undocumented context. Answers "who has context on this area", not "who changed this line" (that is `git blame`).
@@ -20,8 +20,8 @@
| Area | Ask (commits) |
| --- | --- |
| `src/lib` | “mkczarkowski” (29), psmyrdek (4) |
-| `context/changes` | “mkczarkowski” (28) |
| `context/map` | Marcin Czarkowski (1) |
+| `context/changes` | “mkczarkowski” (28) |
| `tests/e2e` | “mkczarkowski” (8), psmyrdek (3) |
| `src/commands` | “mkczarkowski” (19), psmyrdek (4) |
| `package.json` | “mkczarkowski” (7), Przemek Smyrdek (1) |
diff --git a/context/map/repo-map.md b/context/map/repo-map.md
index 3f4b468..a27d2bb 100644
--- a/context/map/repo-map.md
+++ b/context/map/repo-map.md
@@ -2,7 +2,7 @@
-> Scan of `7e3dab4` (2026-07-28) · window: last 12 months · engine: tools/repo-map
+> Scan of `c823d6d` (2026-07-28) · window: last 12 months · engine: tools/repo-map
>
> Operational map of the territory, built from cheap deterministic signals (git history + import graph), synthesized for decisions — not an essay. A new developer should know in ~15 min where things live, what is dangerous, and where to start. Refresh with `npm run repo-map`.
@@ -19,7 +19,7 @@
graph TD
commands["commands (CLI surface)
5 files"]
lib["lib (core logic)
19 files"]
- tests["tests
46 files"]
+ tests["tests
47 files"]
```
## 2. Terrain — where the project lives
@@ -29,11 +29,11 @@ Top active areas (full table in `artifact-1-territory.md`):
| Area | Commits |
| --- | --- |
| `src/lib` | 75 |
+| `context/map` | 54 |
| `context/changes` | 53 |
-| `context/map` | 50 |
| `tests/e2e` | 32 |
| `src/commands` | 30 |
-| `package.json` | 28 |
+| `package.json` | 29 |
| `.github/workflows` | 19 |
| `context/archive` | 19 |
@@ -61,22 +61,22 @@ _No high-risk zones flagged this run._
| Area | Ask |
| --- | --- |
| `src/lib` | “mkczarkowski” (29), psmyrdek (4) |
-| `context/changes` | “mkczarkowski” (28) |
| `context/map` | Marcin Czarkowski (1) |
+| `context/changes` | “mkczarkowski” (28) |
| `tests/e2e` | “mkczarkowski” (8), psmyrdek (3) |
| `src/commands` | “mkczarkowski” (19), psmyrdek (4) |
| `package.json` | “mkczarkowski” (7), Przemek Smyrdek (1) |
## 6. First day — read these first
-- `package.json` — 28 commits in window
+- `package.json` — 29 commits in window
- `src/commands/get.ts` — 16 commits in window
- `.github/workflows/ci.yml` — 14 commits in window
- `tests/get-command.test.ts` — 13 commits in window
- `src/lib/writer.ts` — 12 commits in window
-- `context/map/artifact-1-territory.md` — 10 commits in window
-- `context/map/artifact-2-structure.md` — 10 commits in window
-- `context/map/artifact-3-contributors.md` — 10 commits in window
+- `context/map/artifact-1-territory.md` — 11 commits in window
+- `context/map/artifact-2-structure.md` — 11 commits in window
+- `context/map/artifact-3-contributors.md` — 11 commits in window
## 7. Limitations — what this map does NOT say
diff --git a/context/night-loop.md b/context/night-loop.md
new file mode 100644
index 0000000..9723a58
--- /dev/null
+++ b/context/night-loop.md
@@ -0,0 +1,58 @@
+# Night loop runner
+
+`scripts/night-loop.mjs` runs bounded, local Codex iterations against
+`claude/context-loops-bootstrap`. It is intentionally narrower than the full
+loop menu: config-only lint ratchets that already pass. Characterization work
+is excluded until mutation proof can be executed mechanically; examples work
+is excluded because its executable config cannot safely run in an unattended
+outer gate.
+
+## Safety contract
+
+- Hard local-time deadline (default: the next `07:30`).
+- One agent and one isolated git worktree at a time.
+- Codex runs with `workspace-write`, `approval_policy=never`, and network
+ disabled. The runner—not the agent—performs authenticated GitHub operations.
+- Maximum 8 iterations and 800,000 reported tokens by default. A single Codex
+ turn can overshoot the remaining token budget because usage is reported only
+ after the turn; no repair or later iteration starts after the ceiling.
+- One base commit is pinned for the entire run. If the remote base advances,
+ the loop stops instead of comparing against a stale baseline.
+- Exact differential gate: lint cannot regress; passing tests cannot decrease;
+ the pinned set of 8 build-dependent failures must remain byte-for-byte the
+ same. Characterization work must increase the passing-test count.
+- At most two repair turns in the same Codex session. Persistent failure opens
+ an `automation` issue and preserves the failed worktree for diagnosis.
+- Successful iterations open one small PR each. The runner never merges PRs.
+- Agent and gate environments omit credential variables. Codex and outer gates
+ run without network; binary diffs and patches matching common secret formats
+ are rejected.
+
+The machine must remain awake and online. Saved Codex authentication and `gh`
+authentication must already work.
+
+## Review and dry run
+
+```bash
+bun test tests/night-loop.test.ts
+bun run night-loop -- --deadline 07:30 --dry-run
+```
+
+## Start an overnight run
+
+```bash
+nohup bun run night-loop -- --deadline 07:30 \
+ > .codex/night-loop-launcher.log 2>&1 &
+```
+
+State, command logs, final model outputs, and the final summary are written
+under `.codex/night-loop//`. The directory is ignored by git.
+
+Optional bounds:
+
+```bash
+bun run night-loop -- \
+ --deadline 2026-07-28T07:30:00+02:00 \
+ --max-iterations 4 \
+ --max-total-tokens 400000
+```
diff --git a/package.json b/package.json
index d0979cd..975520a 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,8 @@
"repo-map": "node tools/repo-map/scan.mjs",
"repo-map:check": "node tools/repo-map/scan.mjs --check",
"examples:catalog": "node tools/examples-catalog/build.mjs",
- "examples:catalog:check": "node tools/examples-catalog/build.mjs --check"
+ "examples:catalog:check": "node tools/examples-catalog/build.mjs --check",
+ "night-loop": "node scripts/night-loop.mjs"
},
"dependencies": {
"@clack/prompts": "0.9.1",
diff --git a/scripts/night-loop-lib.d.mts b/scripts/night-loop-lib.d.mts
new file mode 100644
index 0000000..464a92a
--- /dev/null
+++ b/scripts/night-loop-lib.d.mts
@@ -0,0 +1,47 @@
+export interface LintSummary {
+ warnings: number;
+ errors: number;
+}
+
+export interface TestSummary {
+ pass: number;
+ fail: number;
+}
+
+export interface Baseline {
+ lint: LintSummary;
+ tests: TestSummary;
+ failedTests: string[];
+}
+
+export interface DiffStats {
+ files: number;
+ additions: number;
+ deletions: number;
+ binaryFiles: number;
+}
+
+export type LoopKind = "lint";
+
+export function resolveDeadline(now: Date, value?: string): Date;
+export function parseLintSummary(output: string): LintSummary;
+export function parseTestSummary(output: string): TestSummary;
+export function parseFailedTests(output: string): string[];
+export function parseCodexEvents(output: string): {
+ threadId?: string;
+ totalTokens: number;
+ completedTurns: number;
+};
+export function parseNumstat(output: string): DiffStats;
+export function validateScope(
+ kind: LoopKind,
+ paths: string[],
+ stats: DiffStats,
+): string[];
+export function validateDifferential(
+ kind: LoopKind,
+ baseline: Baseline,
+ candidate: Baseline,
+): string[];
+export function scanPatchForSecrets(patch: string): string[];
+export function validateLintRuleChange(baseText: string, candidateText: string): string[];
diff --git a/scripts/night-loop-lib.mjs b/scripts/night-loop-lib.mjs
new file mode 100644
index 0000000..dcbe326
--- /dev/null
+++ b/scripts/night-loop-lib.mjs
@@ -0,0 +1,195 @@
+export function resolveDeadline(now, value = "07:30") {
+ if (/^\d{2}:\d{2}$/.test(value)) {
+ const [hour, minute] = value.split(":").map(Number);
+ if (hour > 23 || minute > 59) throw new Error(`Invalid deadline time: ${value}`);
+ const deadline = new Date(now);
+ deadline.setHours(hour, minute, 0, 0);
+ if (deadline <= now) deadline.setDate(deadline.getDate() + 1);
+ return deadline;
+ }
+
+ const deadline = new Date(value);
+ if (Number.isNaN(deadline.getTime())) throw new Error(`Invalid deadline: ${value}`);
+ if (deadline <= now) throw new Error(`Deadline is not in the future: ${value}`);
+ return deadline;
+}
+
+export function parseLintSummary(output) {
+ const match = output.match(/Found\s+(\d+)\s+warnings?\s+and\s+(\d+)\s+errors?/i);
+ if (match) return { warnings: Number(match[1]), errors: Number(match[2]) };
+ if (/Finished in .+ on \d+ files/i.test(output)) return { warnings: 0, errors: 0 };
+ throw new Error("Could not parse lint summary");
+}
+
+export function parseTestSummary(output) {
+ const matches = [...output.matchAll(/^\s*(\d+)\s+pass\s*$/gim)];
+ const failMatches = [...output.matchAll(/^\s*(\d+)\s+fail\s*$/gim)];
+ if (matches.length === 0 || failMatches.length === 0) {
+ throw new Error("Could not parse test summary");
+ }
+ return {
+ pass: Number(matches.at(-1)[1]),
+ fail: Number(failMatches.at(-1)[1]),
+ };
+}
+
+export function parseFailedTests(output) {
+ const names = [...output.matchAll(/^\(fail\)\s+(.+?)(?:\s+\[[^\]]+\])?$/gm)].map(
+ (match) => match[1].trim(),
+ );
+ return [...new Set(names)].sort();
+}
+
+export function parseCodexEvents(output) {
+ let threadId;
+ let totalTokens = 0;
+ let completedTurns = 0;
+
+ for (const line of output.split("\n")) {
+ if (!line.trim()) continue;
+ try {
+ const event = JSON.parse(line);
+ if (event.type === "thread.started" && typeof event.thread_id === "string") {
+ threadId = event.thread_id;
+ }
+ if (event.type === "turn.completed" && event.usage) {
+ completedTurns += 1;
+ totalTokens +=
+ Number(event.usage.input_tokens ?? 0) +
+ Number(event.usage.output_tokens ?? 0) +
+ Number(event.usage.reasoning_output_tokens ?? 0);
+ }
+ } catch {
+ // stderr or future non-JSON status lines are retained in logs, not parsed.
+ }
+ }
+
+ return { threadId, totalTokens, completedTurns };
+}
+
+export function parseNumstat(output) {
+ let files = 0;
+ let additions = 0;
+ let deletions = 0;
+ let binaryFiles = 0;
+
+ for (const line of output.trim().split("\n")) {
+ if (!line) continue;
+ const [added, deleted] = line.split("\t");
+ files += 1;
+ if (added === "-" || deleted === "-") binaryFiles += 1;
+ if (added !== "-") additions += Number(added);
+ if (deleted !== "-") deletions += Number(deleted);
+ }
+
+ return { files, additions, deletions, binaryFiles };
+}
+
+const MAP_PATH = /^context\/map\//;
+
+export function validateScope(kind, paths, stats) {
+ if (paths.length === 0) return ["No files changed"];
+ const errors = [];
+
+ if (stats.files > 8) errors.push(`Changed ${stats.files} files; maximum is 8`);
+ if (stats.additions + stats.deletions > 500) {
+ errors.push(`Changed ${stats.additions + stats.deletions} lines; maximum is 500`);
+ }
+ if (stats.binaryFiles > 0) errors.push("Binary changes are forbidden");
+
+ const allowed = {
+ lint: (path) =>
+ path === ".oxlintrc.json" ||
+ MAP_PATH.test(path),
+ }[kind];
+
+ if (!allowed) return [`Unsupported loop kind: ${kind}`];
+
+ for (const path of paths) {
+ if (!allowed(path)) errors.push(`Path is outside the ${kind} allowlist: ${path}`);
+ if (
+ /(^|\/)(\.env|auth\.json|credentials?|secrets?)(\.|\/|$)/i.test(path) ||
+ /\.(pem|key|p12)$/i.test(path)
+ ) {
+ errors.push(`Secret-like path is forbidden: ${path}`);
+ }
+ }
+
+ if (kind === "lint" && !paths.includes(".oxlintrc.json")) {
+ errors.push("Lint iteration did not change .oxlintrc.json");
+ }
+
+ return errors;
+}
+
+export function validateDifferential(kind, baseline, candidate) {
+ const errors = [];
+ if (candidate.lint.errors !== 0) errors.push("Lint has errors");
+ if (candidate.lint.warnings > baseline.lint.warnings) {
+ errors.push("Lint warning count increased");
+ }
+ if (candidate.tests.fail !== baseline.tests.fail) {
+ errors.push("Test failure count changed");
+ }
+ if (candidate.failedTests.join("\n") !== baseline.failedTests.join("\n")) {
+ errors.push("Pinned failing-test set changed");
+ }
+ if (candidate.tests.pass < baseline.tests.pass) {
+ errors.push("Passing-test count decreased");
+ }
+ return errors;
+}
+
+const SECRET_PATTERNS = [
+ ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
+ ["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}\b/],
+ ["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{20,}\b/],
+ ["AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
+ [
+ "assigned credential",
+ /\b(?:api[_-]?key|secret|token|password)\b\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{20,}/i,
+ ],
+];
+
+export function scanPatchForSecrets(patch) {
+ return SECRET_PATTERNS.filter(([, pattern]) => pattern.test(patch)).map(
+ ([name]) => `Patch matches forbidden secret pattern: ${name}`,
+ );
+}
+
+export function validateLintRuleChange(baseText, candidateText) {
+ let base;
+ let candidate;
+ try {
+ base = JSON.parse(baseText);
+ candidate = JSON.parse(candidateText);
+ } catch {
+ return ["Could not parse .oxlintrc.json"];
+ }
+
+ const { rules: baseRules = {}, ...baseRest } = base;
+ const { rules: candidateRules = {}, ...candidateRest } = candidate;
+ if (JSON.stringify(baseRest) !== JSON.stringify(candidateRest)) {
+ return ["Lint ratchet changed config outside the rules object"];
+ }
+
+ const errors = [];
+ for (const [name, value] of Object.entries(baseRules)) {
+ if (candidateRules[name] !== value) {
+ errors.push(`Lint ratchet changed existing rule: ${name}`);
+ }
+ }
+ const added = Object.keys(candidateRules).filter(
+ (name) => !Object.hasOwn(baseRules, name),
+ );
+ const removed = Object.keys(baseRules).filter(
+ (name) => !Object.hasOwn(candidateRules, name),
+ );
+ if (removed.length > 0) errors.push(`Lint ratchet removed rules: ${removed.join(", ")}`);
+ if (added.length !== 1) {
+ errors.push(`Lint ratchet must add exactly one rule; found ${added.length}`);
+ } else if (candidateRules[added[0]] !== "error") {
+ errors.push(`New lint rule must be enforced as error: ${added[0]}`);
+ }
+ return errors;
+}
diff --git a/scripts/night-loop-result.schema.json b/scripts/night-loop-result.schema.json
new file mode 100644
index 0000000..609fc82
--- /dev/null
+++ b/scripts/night-loop-result.schema.json
@@ -0,0 +1,48 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": ["changed", "no_change", "blocked"]
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["lint"]
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100
+ },
+ "commit_message": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100
+ },
+ "summary": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1000
+ },
+ "metric": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500
+ },
+ "evidence": {
+ "type": "string",
+ "maxLength": 1000
+ }
+ },
+ "required": [
+ "status",
+ "kind",
+ "title",
+ "commit_message",
+ "summary",
+ "metric",
+ "evidence"
+ ]
+}
diff --git a/scripts/night-loop.mjs b/scripts/night-loop.mjs
new file mode 100644
index 0000000..b239d97
--- /dev/null
+++ b/scripts/night-loop.mjs
@@ -0,0 +1,1336 @@
+#!/usr/bin/env node
+
+import { spawn } from "node:child_process";
+import { createHash } from "node:crypto";
+import {
+ closeSync,
+ createWriteStream,
+ existsSync,
+ mkdirSync,
+ openSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ statSync,
+ unlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ parseCodexEvents,
+ parseFailedTests,
+ parseLintSummary,
+ parseNumstat,
+ parseTestSummary,
+ resolveDeadline,
+ scanPatchForSecrets,
+ validateDifferential,
+ validateLintRuleChange,
+ validateScope,
+} from "./night-loop-lib.mjs";
+
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const RESULT_SCHEMA = join(SCRIPT_DIR, "night-loop-result.schema.json");
+const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
+const MAX_PATCH_BYTES = 1024 * 1024;
+const DEFAULT_COMMAND_TIMEOUT_MS = 20 * 60 * 1000;
+const DEFAULT_BASE = "claude/context-loops-bootstrap";
+const VALID_REF = /^[A-Za-z0-9._/-]+$/;
+const NO_NETWORK_PROFILE = "(version 1)(allow default)(deny network*)";
+const activeChildren = new Set();
+let lockPath;
+let fatalContext;
+
+function sanitizedEnvironment() {
+ const allowed = [
+ "PATH",
+ "HOME",
+ "CODEX_HOME",
+ "TMPDIR",
+ "LANG",
+ "LC_ALL",
+ "USER",
+ "LOGNAME",
+ "SHELL",
+ "TERM",
+ "TZ",
+ ];
+ return Object.fromEntries(
+ allowed.filter((name) => process.env[name] !== undefined).map((name) => [name, process.env[name]]),
+ );
+}
+
+function parseArgs(argv) {
+ const options = {
+ deadline: "07:30",
+ base: DEFAULT_BASE,
+ maxIterations: 8,
+ maxTotalTokens: 800_000,
+ dryRun: false,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ const next = () => {
+ const value = argv[index + 1];
+ if (!value) throw new Error(`Missing value for ${arg}`);
+ index += 1;
+ return value;
+ };
+
+ if (arg === "--deadline") options.deadline = next();
+ else if (arg === "--base") options.base = next();
+ else if (arg === "--max-iterations") options.maxIterations = Number(next());
+ else if (arg === "--max-total-tokens") options.maxTotalTokens = Number(next());
+ else if (arg === "--dry-run") options.dryRun = true;
+ else if (arg === "--help" || arg === "-h") options.help = true;
+ else throw new Error(`Unknown option: ${arg}`);
+ }
+
+ if (!VALID_REF.test(options.base)) throw new Error(`Unsafe base ref: ${options.base}`);
+ if (!Number.isInteger(options.maxIterations) || options.maxIterations < 1) {
+ throw new Error("--max-iterations must be a positive integer");
+ }
+ if (!Number.isInteger(options.maxTotalTokens) || options.maxTotalTokens < 1) {
+ throw new Error("--max-total-tokens must be a positive integer");
+ }
+ return options;
+}
+
+function usage() {
+ return `Usage: bun run night-loop -- [options]
+
+Options:
+ --deadline Hard cutoff (default: next 07:30 local time)
+ --base PR base branch (default: ${DEFAULT_BASE})
+ --max-iterations Maximum Codex iterations (default: 8)
+ --max-total-tokens Reported token ceiling (default: 800000)
+ --dry-run Validate configuration without invoking Codex
+`;
+}
+
+function runId(now) {
+ return now.toISOString().replace(/\D/g, "").slice(0, 14);
+}
+
+function appendCapture(current, chunk) {
+ if (current.length >= MAX_CAPTURE_BYTES) return current;
+ return (current + chunk).slice(0, MAX_CAPTURE_BYTES);
+}
+
+function terminate(child) {
+ if (!child.pid) return;
+ try {
+ if (process.platform === "win32") child.kill("SIGTERM");
+ else process.kill(-child.pid, "SIGTERM");
+ } catch {
+ child.kill("SIGTERM");
+ }
+ setTimeout(() => {
+ try {
+ if (process.platform === "win32") child.kill("SIGKILL");
+ else process.kill(-child.pid, "SIGKILL");
+ } catch {
+ child.kill("SIGKILL");
+ }
+ }, 5_000).unref();
+}
+
+async function runCommand({
+ command,
+ args,
+ cwd,
+ deadlineMs,
+ timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
+ stdin,
+ stdoutFile,
+ stderrFile,
+ env,
+ inheritEnv = true,
+}) {
+ const remaining = deadlineMs - Date.now();
+ if (remaining <= 0) {
+ return { code: null, signal: "DEADLINE", stdout: "", stderr: "", timedOut: true };
+ }
+
+ const effectiveTimeout = Math.min(timeoutMs, remaining);
+ const child = spawn(command, args, {
+ cwd,
+ env: { ...(inheritEnv ? process.env : {}), ...env },
+ stdio: ["pipe", "pipe", "pipe"],
+ detached: process.platform !== "win32",
+ });
+ activeChildren.add(child);
+ const stdoutStream = stdoutFile ? createWriteStream(stdoutFile, { flags: "a" }) : undefined;
+ const stderrStream = stderrFile ? createWriteStream(stderrFile, { flags: "a" }) : undefined;
+ let stdout = "";
+ let stderr = "";
+ let timedOut = false;
+
+ child.stdout.on("data", (chunk) => {
+ const text = chunk.toString();
+ stdout = appendCapture(stdout, text);
+ stdoutStream?.write(chunk);
+ });
+ child.stderr.on("data", (chunk) => {
+ const text = chunk.toString();
+ stderr = appendCapture(stderr, text);
+ stderrStream?.write(chunk);
+ });
+ if (stdin !== undefined) child.stdin.end(stdin);
+ else child.stdin.end();
+
+ const timer = setTimeout(() => {
+ timedOut = true;
+ terminate(child);
+ }, effectiveTimeout);
+
+ const result = await new Promise((resolveResult) => {
+ child.on("error", (error) => {
+ stderr = appendCapture(stderr, String(error));
+ resolveResult({ code: null, signal: "SPAWN_ERROR" });
+ });
+ child.on("close", (code, signal) => resolveResult({ code, signal }));
+ });
+
+ clearTimeout(timer);
+ activeChildren.delete(child);
+ await Promise.all(
+ [stdoutStream, stderrStream]
+ .filter(Boolean)
+ .map(
+ (stream) =>
+ new Promise((resolveStream) => {
+ stream.end(resolveStream);
+ }),
+ ),
+ );
+ return { ...result, stdout, stderr, timedOut };
+}
+
+function combined(result) {
+ return `${result.stdout}\n${result.stderr}`;
+}
+
+function mustSucceed(result, label) {
+ if (result.code !== 0) {
+ throw new Error(`${label} failed (exit ${result.code ?? result.signal})`);
+ }
+ return result;
+}
+
+function writeJsonAtomic(path, value) {
+ const temporary = `${path}.tmp`;
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
+ renameSync(temporary, path);
+}
+
+function releaseLock() {
+ if (!lockPath) return;
+ try {
+ unlinkSync(lockPath);
+ } catch {
+ // The lock may already have been removed during signal handling.
+ }
+ lockPath = undefined;
+}
+
+function acquireLock(root) {
+ mkdirSync(root, { recursive: true });
+ const candidate = join(root, "active.lock");
+
+ const create = () => {
+ const descriptor = openSync(candidate, "wx", 0o600);
+ writeFileSync(descriptor, `${process.pid}\n`);
+ closeSync(descriptor);
+ lockPath = candidate;
+ };
+
+ try {
+ create();
+ } catch (error) {
+ if (error.code !== "EEXIST") throw error;
+ const priorPid = Number(readFileSync(candidate, "utf8").trim());
+ let active = Number.isInteger(priorPid) && priorPid > 0;
+ if (active) {
+ try {
+ process.kill(priorPid, 0);
+ } catch {
+ active = false;
+ }
+ }
+ if (active) throw new Error(`Night loop is already running as PID ${priorPid}`);
+ unlinkSync(candidate);
+ create();
+ }
+}
+
+function handleSignal(signal) {
+ for (const child of activeChildren) terminate(child);
+ if (fatalContext) {
+ fatalContext.state.status = "interrupted";
+ fatalContext.state.errors = [`Received ${signal}`];
+ fatalContext.state.finishedAt = new Date().toISOString();
+ writeJsonAtomic(fatalContext.stateFile, fatalContext.state);
+ writeFileSync(
+ join(fatalContext.stateRoot, "summary.md"),
+ finalSummary(fatalContext.state),
+ );
+ }
+ process.stderr.write(`night-loop: received ${signal}; terminating active children\n`);
+ setTimeout(() => process.exit(128), 5_500);
+}
+
+process.once("SIGINT", () => handleSignal("SIGINT"));
+process.once("SIGTERM", () => handleSignal("SIGTERM"));
+process.on("exit", releaseLock);
+
+function formatDuration(milliseconds) {
+ const minutes = Math.max(0, Math.floor(milliseconds / 60_000));
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
+}
+
+function tail(value, max = 6_000) {
+ return value.length <= max ? value : value.slice(-max);
+}
+
+function hash(value) {
+ return createHash("sha256").update(value).digest("hex");
+}
+
+async function captureExactPatch({ worktree, deadlineMs, path }) {
+ writeFileSync(path, "", { mode: 0o600 });
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["diff", "--binary", "HEAD"],
+ cwd: worktree,
+ deadlineMs,
+ stdoutFile: path,
+ }),
+ "exact patch capture",
+ );
+ const bytes = statSync(path).size;
+ if (bytes > MAX_PATCH_BYTES) {
+ return {
+ bytes,
+ content: "",
+ digest: "",
+ errors: [`Patch is ${bytes} bytes; maximum is ${MAX_PATCH_BYTES}`],
+ };
+ }
+ const content = readFileSync(path, "utf8");
+ return { bytes, content, digest: hash(content), errors: [] };
+}
+
+function promptForIteration({ iteration, deadline, baseline, completed }) {
+ return `Use the repository's loop-engineering skill and implement exactly ONE smallest safe
+engineering-loop iteration in this isolated worktree.
+
+Hard constraints:
+- Stop work before ${deadline.toISOString()}.
+- Do not commit, push, create PRs, use the network, or modify git configuration.
+- Choose the lint kind. Characterization and examples work are disabled because
+ this runner cannot safely execute their proof/config surfaces unattended.
+- Lint: add exactly one already-passing rule in .oxlintrc.json. Do not change src/
+ or tests/. Generated context/map files may be refreshed if needed.
+- Never touch secrets, auth, release workflows, dependency versions, generated API
+ types, the eight pinned failing tests, or product behavior.
+- Maximum diff: 8 files and 500 changed lines.
+- Read context/map/repo-map.md and context/examples/catalog.md before choosing.
+- Run focused verification. Do not build the compiled binary; the outer runner owns
+ the exact differential gate.
+- If no non-duplicative safe target exists, return no_change instead of forcing work.
+
+Pinned outer baseline:
+- lint: ${baseline.lint.warnings} warnings / ${baseline.lint.errors} errors
+- tests: ${baseline.tests.pass} pass / ${baseline.tests.fail} fail
+- pinned failures: ${baseline.failedTests.join("; ")}
+
+Earlier successful iterations in this run:
+${completed.length ? completed.map((item) => `- ${item}`).join("\n") : "- none"}
+
+This is iteration ${iteration}. Your final response must match the provided JSON schema.`;
+}
+
+function repairPrompt(errors) {
+ return `The outer safety gate rejected the current iteration. Repair only the existing
+single change; do not broaden scope or start a different iteration.
+
+Gate failures:
+${errors.map((error) => `- ${error}`).join("\n")}
+
+Run focused checks, leave the worktree ready for the outer runner, and return the
+same JSON result schema. If a safe repair is not possible, return status "blocked".`;
+}
+
+async function runCodex({
+ cwd,
+ deadlineMs,
+ logDir,
+ prompt,
+ resultFile,
+ attempt,
+ threadId,
+}) {
+ const stdoutFile = join(logDir, `codex-${attempt}.jsonl`);
+ const stderrFile = join(logDir, `codex-${attempt}.stderr.log`);
+ const common = [
+ "--json",
+ "--ignore-user-config",
+ "-c",
+ 'approval_policy="never"',
+ "-c",
+ 'sandbox_mode="workspace-write"',
+ "-c",
+ "sandbox_workspace_write.network_access=false",
+ "--output-schema",
+ RESULT_SCHEMA,
+ "-o",
+ resultFile,
+ ];
+ const args = threadId
+ ? ["exec", "resume", ...common, threadId, "-"]
+ : ["exec", ...common, "--sandbox", "workspace-write", "-C", cwd, "-"];
+
+ const result = await runCommand({
+ command: "codex",
+ args,
+ cwd,
+ deadlineMs,
+ timeoutMs: 45 * 60 * 1000,
+ stdin: prompt,
+ stdoutFile,
+ stderrFile,
+ env: sanitizedEnvironment(),
+ inheritEnv: false,
+ });
+ const events = parseCodexEvents(
+ existsSync(stdoutFile) ? readFileSync(stdoutFile, "utf8") : result.stdout,
+ );
+ return { ...result, ...events, stdoutFile, stderrFile };
+}
+
+async function captureBaseline({ repoRoot, base, deadlineMs, runRoot }) {
+ const baselineDir = join(runRoot, "baseline-worktree");
+ const logDir = join(runRoot, "baseline");
+ mkdirSync(logDir, { recursive: true });
+
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["fetch", "origin", base],
+ cwd: repoRoot,
+ deadlineMs,
+ stdoutFile: join(logDir, "fetch.log"),
+ stderrFile: join(logDir, "fetch.log"),
+ }),
+ "baseline fetch",
+ );
+ const baseSha = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["rev-parse", `origin/${base}`],
+ cwd: repoRoot,
+ deadlineMs,
+ }),
+ "baseline SHA",
+ ).stdout.trim();
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["worktree", "add", "--detach", baselineDir, baseSha],
+ cwd: repoRoot,
+ deadlineMs,
+ stdoutFile: join(logDir, "worktree.log"),
+ stderrFile: join(logDir, "worktree.log"),
+ }),
+ "baseline worktree creation",
+ );
+
+ try {
+ mustSucceed(
+ await runCommand({
+ command: "bun",
+ args: ["install", "--frozen-lockfile"],
+ cwd: baselineDir,
+ deadlineMs,
+ stdoutFile: join(logDir, "install.log"),
+ stderrFile: join(logDir, "install.log"),
+ env: sanitizedEnvironment(),
+ inheritEnv: false,
+ }),
+ "baseline install",
+ );
+ const lint = await runCommand({
+ command: "sandbox-exec",
+ args: ["-p", NO_NETWORK_PROFILE, "bun", "run", "lint"],
+ cwd: baselineDir,
+ deadlineMs,
+ stdoutFile: join(logDir, "lint.log"),
+ stderrFile: join(logDir, "lint.log"),
+ env: sanitizedEnvironment(),
+ inheritEnv: false,
+ });
+ const tests = await runCommand({
+ command: "sandbox-exec",
+ args: ["-p", NO_NETWORK_PROFILE, "bun", "test"],
+ cwd: baselineDir,
+ deadlineMs,
+ stdoutFile: join(logDir, "test.log"),
+ stderrFile: join(logDir, "test.log"),
+ env: sanitizedEnvironment(),
+ inheritEnv: false,
+ });
+ const lintSummary = parseLintSummary(combined(lint));
+ const testSummary = parseTestSummary(combined(tests));
+ const failedTests = parseFailedTests(combined(tests));
+
+ if (lintSummary.errors !== 0) throw new Error("Baseline lint contains errors");
+ if (testSummary.fail !== failedTests.length) {
+ throw new Error("Baseline failing-test names do not match the failure count");
+ }
+ if (testSummary.fail !== 8) {
+ throw new Error(`Expected the pinned baseline of 8 failures, found ${testSummary.fail}`);
+ }
+ return { baseSha, lint: lintSummary, tests: testSummary, failedTests };
+ } finally {
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["worktree", "remove", "--force", baselineDir],
+ cwd: repoRoot,
+ deadlineMs: Math.max(deadlineMs, Date.now() + 30_000),
+ }),
+ "baseline worktree cleanup",
+ );
+ }
+}
+
+async function changedPaths(worktree, deadlineMs) {
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["add", "-N", "--", "."],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "intent-to-add",
+ );
+ const names = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["diff", "--name-only", "-z"],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "changed-file listing",
+ ).stdout;
+ return names.split("\0").filter(Boolean);
+}
+
+async function validateIteration({
+ worktree,
+ startingSha,
+ result,
+ baseline,
+ deadlineMs,
+ logDir,
+}) {
+ const errors = [];
+ if (!existsSync(join(worktree, ".git"))) errors.push("Iteration worktree disappeared");
+
+ const currentHead = await runCommand({
+ command: "git",
+ args: ["rev-parse", "HEAD"],
+ cwd: worktree,
+ deadlineMs,
+ });
+ if (currentHead.code !== 0) errors.push("Could not verify the iteration commit boundary");
+ else if (currentHead.stdout.trim() !== startingSha) {
+ errors.push("Agent changed HEAD; the runner owns commits");
+ }
+
+ const staged = await runCommand({
+ command: "git",
+ args: ["diff", "--cached", "--quiet"],
+ cwd: worktree,
+ deadlineMs,
+ });
+ if (staged.code === 1) errors.push("Agent staged changes; the runner owns the index");
+ else if (staged.code !== 0) errors.push("Could not verify the iteration index");
+
+ const paths = await changedPaths(worktree, deadlineMs);
+ const numstatResult = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["diff", "--numstat"],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "diff statistics",
+ );
+ const stats = parseNumstat(numstatResult.stdout);
+ errors.push(...validateScope(result.kind, paths, stats));
+ const patchBefore = await captureExactPatch({
+ worktree,
+ deadlineMs,
+ path: join(logDir, "candidate-before.patch"),
+ });
+ errors.push(...patchBefore.errors, ...scanPatchForSecrets(patchBefore.content));
+
+ if (result.kind === "lint") {
+ const baseConfig = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["show", `${startingSha}:.oxlintrc.json`],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "base lint config",
+ ).stdout;
+ errors.push(
+ ...validateLintRuleChange(
+ baseConfig,
+ readFileSync(join(worktree, ".oxlintrc.json"), "utf8"),
+ ),
+ );
+ }
+
+ const distDir = join(worktree, "dist");
+ if (existsSync(distDir)) rmSync(distDir, { recursive: true, force: true });
+
+ const commands = [
+ ["diff", "git", ["diff", "--check"], true],
+ ["typecheck", "bun", ["run", "typecheck"], true],
+ ["lint", "bun", ["run", "lint"], true],
+ ["test", "bun", ["test"], false],
+ ["build", "bun", ["run", "build"], true],
+ ["examples", "bun", ["run", "examples:catalog:check"], true],
+ ["repo-map", "bun", ["run", "repo-map:check"], true],
+ ];
+ const outputs = {};
+
+ for (const [name, command, args, expectZero] of commands) {
+ const commandResult = await runCommand({
+ command: "sandbox-exec",
+ args: ["-p", NO_NETWORK_PROFILE, command, ...args],
+ cwd: worktree,
+ deadlineMs,
+ stdoutFile: join(logDir, `gate-${name}.log`),
+ stderrFile: join(logDir, `gate-${name}.log`),
+ env: sanitizedEnvironment(),
+ inheritEnv: false,
+ });
+ outputs[name] = combined(commandResult);
+ if (expectZero && commandResult.code !== 0) {
+ errors.push(`${name} gate exited ${commandResult.code ?? commandResult.signal}`);
+ }
+ if (commandResult.timedOut) errors.push(`${name} gate timed out`);
+ }
+
+ const patchAfter = await captureExactPatch({
+ worktree,
+ deadlineMs,
+ path: join(logDir, "candidate-after.patch"),
+ });
+ errors.push(...patchAfter.errors);
+ if (patchAfter.digest !== patchBefore.digest) {
+ errors.push("Diff changed while gates were running");
+ }
+
+ let candidate;
+ try {
+ candidate = {
+ lint: parseLintSummary(outputs.lint),
+ tests: parseTestSummary(outputs.test),
+ failedTests: parseFailedTests(outputs.test),
+ };
+ errors.push(...validateDifferential(result.kind, baseline, candidate));
+ } catch (error) {
+ errors.push(error.message);
+ }
+
+ return {
+ errors: [...new Set(errors)],
+ paths,
+ stats,
+ candidate,
+ diffHash: patchAfter.digest,
+ };
+}
+
+async function openEscalationIssue({ repoRoot, repo, base, iteration, errors, worktree, deadlineMs }) {
+ const body = [
+ "The bounded local night loop stopped after two unsuccessful repair attempts.",
+ "",
+ `- Base: \`${base}\``,
+ `- Iteration: ${iteration}`,
+ `- Preserved worktree: \`${worktree}\``,
+ "",
+ "Gate failures:",
+ ...errors.map((error) => `- ${error}`),
+ "",
+ "No PR was opened and nothing was merged.",
+ ].join("\n");
+ return runCommand({
+ command: "gh",
+ args: [
+ "issue",
+ "create",
+ "--repo",
+ repo,
+ "--label",
+ "automation",
+ "--title",
+ `automation: night loop iteration ${iteration} blocked`,
+ "--body",
+ body,
+ ],
+ cwd: repoRoot,
+ deadlineMs,
+ });
+}
+
+async function publishIteration({
+ repoRoot,
+ repo,
+ base,
+ branch,
+ worktree,
+ startingSha,
+ result,
+ validation,
+ deadlineMs,
+ logDir,
+}) {
+ const currentHead = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["rev-parse", "HEAD"],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "pre-commit HEAD verification",
+ ).stdout.trim();
+ if (currentHead !== startingSha) throw new Error("HEAD changed after validation");
+
+ const staged = await runCommand({
+ command: "git",
+ args: ["diff", "--cached", "--quiet"],
+ cwd: worktree,
+ deadlineMs,
+ });
+ if (staged.code !== 0) throw new Error("Index changed after validation");
+
+ const finalPaths = await changedPaths(worktree, deadlineMs);
+ const finalNumstat = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["diff", "--numstat"],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "pre-commit diff statistics",
+ );
+ const finalStats = parseNumstat(finalNumstat.stdout);
+ const finalScopeErrors = validateScope(result.kind, finalPaths, finalStats);
+ if (finalScopeErrors.length > 0) {
+ throw new Error(`Scope changed after validation: ${finalScopeErrors.join("; ")}`);
+ }
+ if (
+ finalPaths.join("\n") !== validation.paths.join("\n") ||
+ JSON.stringify(finalStats) !== JSON.stringify(validation.stats)
+ ) {
+ throw new Error("Diff changed while safety gates were running");
+ }
+ const finalPatch = await captureExactPatch({
+ worktree,
+ deadlineMs,
+ path: join(logDir, "precommit.patch"),
+ });
+ if (finalPatch.errors.length > 0) throw new Error(finalPatch.errors.join("; "));
+ if (finalPatch.digest !== validation.diffHash) {
+ throw new Error("Diff content changed after validation");
+ }
+ const secretErrors = scanPatchForSecrets(finalPatch.content);
+ if (secretErrors.length > 0) throw new Error(secretErrors.join("; "));
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["diff", "--check"],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "pre-commit whitespace check",
+ );
+
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["add", "-A"],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "git add",
+ );
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: [
+ "-c",
+ "user.name=codex-night-loop",
+ "-c",
+ "user.email=codex-night-loop@users.noreply.github.com",
+ "commit",
+ "-m",
+ result.commit_message,
+ ],
+ cwd: worktree,
+ deadlineMs,
+ stdoutFile: join(logDir, "commit.log"),
+ stderrFile: join(logDir, "commit.log"),
+ }),
+ "git commit",
+ );
+
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["fetch", "origin", base],
+ cwd: worktree,
+ deadlineMs,
+ }),
+ "pre-push fetch",
+ );
+ const baseCheck = await runCommand({
+ command: "git",
+ args: ["merge-base", "--is-ancestor", `origin/${base}`, "HEAD"],
+ cwd: worktree,
+ deadlineMs,
+ });
+ if (baseCheck.code !== 0) {
+ throw new Error("Base branch advanced during the iteration; preserving worktree for review");
+ }
+
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["push", "--set-upstream", "origin", branch],
+ cwd: worktree,
+ deadlineMs,
+ stdoutFile: join(logDir, "push.log"),
+ stderrFile: join(logDir, "push.log"),
+ }),
+ "git push",
+ );
+
+ const bodyFile = join(logDir, "pr-body.md");
+ writeFileSync(
+ bodyFile,
+ [
+ "## Summary",
+ "",
+ result.summary,
+ "",
+ "## Monotonic metric",
+ "",
+ result.metric,
+ "",
+ "## Verification evidence",
+ "",
+ result.evidence || "No additional evidence supplied.",
+ "",
+ "## Outer safety gate",
+ "",
+ `- lint: ${validation.candidate.lint.warnings} warnings / ${validation.candidate.lint.errors} errors`,
+ `- tests: ${validation.candidate.tests.pass} pass / ${validation.candidate.tests.fail} pinned failures`,
+ "- typecheck, build, examples catalog, and repo-map freshness: passed",
+ `- scope: ${validation.stats.files} files, ${validation.stats.additions} additions, ${validation.stats.deletions} deletions`,
+ "",
+ "Generated by the bounded night loop. This PR is intentionally not auto-merged.",
+ "",
+ ].join("\n"),
+ );
+
+ const pr = mustSucceed(
+ await runCommand({
+ command: "gh",
+ args: [
+ "pr",
+ "create",
+ "--repo",
+ repo,
+ "--base",
+ base,
+ "--head",
+ branch,
+ "--label",
+ "automation",
+ "--title",
+ result.title,
+ "--body-file",
+ bodyFile,
+ ],
+ cwd: repoRoot,
+ deadlineMs,
+ stdoutFile: join(logDir, "pr.log"),
+ stderrFile: join(logDir, "pr.log"),
+ }),
+ "PR creation",
+ );
+ return pr.stdout.trim();
+}
+
+function finalSummary(state) {
+ const lines = [
+ "# Night loop summary",
+ "",
+ `- Status: ${state.status}`,
+ `- Started: ${state.startedAt}`,
+ `- Finished: ${state.finishedAt}`,
+ `- Deadline: ${state.deadline}`,
+ `- Reported tokens: ${state.totalTokens} / ${state.maxTotalTokens}`,
+ `- Iterations: ${state.iterations.length} / ${state.maxIterations}`,
+ "",
+ ];
+ if (state.errors?.length) {
+ lines.push("## Fatal errors", "", ...state.errors.map((error) => `- ${error}`), "");
+ }
+ for (const iteration of state.iterations) {
+ lines.push(
+ `## Iteration ${iteration.number}`,
+ "",
+ `- Status: ${iteration.status}`,
+ `- Summary: ${iteration.summary ?? "n/a"}`,
+ `- PR: ${iteration.prUrl ?? "none"}`,
+ `- Worktree: ${iteration.worktree ?? "removed"}`,
+ "",
+ );
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ process.stdout.write(usage());
+ return;
+ }
+
+ const now = new Date();
+ const deadline = resolveDeadline(now, options.deadline);
+ const repoRootResult = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["rev-parse", "--show-toplevel"],
+ cwd: process.cwd(),
+ deadlineMs: deadline.getTime(),
+ }),
+ "repository lookup",
+ );
+ const repoRoot = resolve(repoRootResult.stdout.trim());
+ if (process.platform !== "darwin") {
+ throw new Error("Night loop currently requires macOS sandbox-exec for networkless gates");
+ }
+ const stateParent = join(repoRoot, ".codex", "night-loop");
+ acquireLock(stateParent);
+ const repoResult = mustSucceed(
+ await runCommand({
+ command: "gh",
+ args: ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
+ cwd: repoRoot,
+ deadlineMs: deadline.getTime(),
+ }),
+ "GitHub repository lookup",
+ );
+ const repo = repoResult.stdout.trim();
+ const identifier = runId(now);
+ const stateRoot = join(stateParent, identifier);
+ const worktreeRoot = join(tmpdir(), "10x-cli-night-loop", identifier);
+ mkdirSync(stateRoot, { recursive: true });
+ mkdirSync(worktreeRoot, { recursive: true });
+
+ const state = {
+ version: 1,
+ pid: process.pid,
+ repo,
+ base: options.base,
+ startedAt: now.toISOString(),
+ deadline: deadline.toISOString(),
+ maxIterations: options.maxIterations,
+ maxTotalTokens: options.maxTotalTokens,
+ totalTokens: 0,
+ status: "preflight",
+ iterations: [],
+ };
+ const stateFile = join(stateRoot, "state.json");
+ writeJsonAtomic(stateFile, state);
+ fatalContext = { state, stateFile, stateRoot };
+
+ const status = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["status", "--porcelain"],
+ cwd: repoRoot,
+ deadlineMs: deadline.getTime(),
+ }),
+ "git status",
+ );
+ if (status.stdout.trim()) throw new Error("Runner checkout must be clean");
+
+ for (const command of ["bun", "codex", "gh", "sandbox-exec"]) {
+ mustSucceed(
+ await runCommand({
+ command: "which",
+ args: [command],
+ cwd: repoRoot,
+ deadlineMs: deadline.getTime(),
+ }),
+ `${command} preflight`,
+ );
+ }
+ mustSucceed(
+ await runCommand({
+ command: "gh",
+ args: ["auth", "status"],
+ cwd: repoRoot,
+ deadlineMs: deadline.getTime(),
+ stdoutFile: join(stateRoot, "gh-auth.log"),
+ stderrFile: join(stateRoot, "gh-auth.log"),
+ }),
+ "GitHub authentication",
+ );
+
+ if (options.dryRun) {
+ state.status = "dry_run_complete";
+ state.finishedAt = new Date().toISOString();
+ writeJsonAtomic(stateFile, state);
+ writeFileSync(join(stateRoot, "summary.md"), finalSummary(state));
+ process.stdout.write(
+ `Dry run passed. Deadline ${deadline.toISOString()} (${formatDuration(deadline - now)} remaining).\n`,
+ );
+ return;
+ }
+
+ state.status = "pinning_baseline";
+ writeJsonAtomic(stateFile, state);
+ const pinned = await captureBaseline({
+ repoRoot,
+ base: options.base,
+ deadlineMs: deadline.getTime(),
+ runRoot: stateRoot,
+ });
+ state.baseSha = pinned.baseSha;
+ state.baseline = {
+ lint: pinned.lint,
+ tests: pinned.tests,
+ failedTests: pinned.failedTests,
+ };
+ state.status = "running";
+ writeJsonAtomic(stateFile, state);
+
+ const completed = [];
+ for (let number = 1; number <= options.maxIterations; number += 1) {
+ if (Date.now() >= deadline.getTime()) {
+ state.status = "deadline_reached";
+ break;
+ }
+ if (state.totalTokens >= options.maxTotalTokens) {
+ state.status = "token_budget_reached";
+ break;
+ }
+
+ const iterationId = String(number).padStart(2, "0");
+ const logDir = join(stateRoot, `iteration-${iterationId}`);
+ const worktree = join(worktreeRoot, `iteration-${iterationId}`);
+ const branch = `automation/night-loop-${identifier}-${iterationId}`;
+ mkdirSync(logDir, { recursive: true });
+ const iteration = {
+ number,
+ branch,
+ worktree,
+ startedAt: new Date().toISOString(),
+ status: "starting",
+ attempts: [],
+ };
+ state.iterations.push(iteration);
+ writeJsonAtomic(stateFile, state);
+
+ try {
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["fetch", "origin", options.base],
+ cwd: repoRoot,
+ deadlineMs: deadline.getTime(),
+ stdoutFile: join(logDir, "fetch.log"),
+ stderrFile: join(logDir, "fetch.log"),
+ }),
+ "iteration fetch",
+ );
+ const remoteBaseSha = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["rev-parse", `origin/${options.base}`],
+ cwd: repoRoot,
+ deadlineMs: deadline.getTime(),
+ }),
+ "iteration base SHA",
+ ).stdout.trim();
+ if (remoteBaseSha !== state.baseSha) {
+ iteration.status = "base_advanced";
+ iteration.finishedAt = new Date().toISOString();
+ state.status = "base_advanced";
+ writeJsonAtomic(stateFile, state);
+ break;
+ }
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["worktree", "add", "--detach", worktree, state.baseSha],
+ cwd: repoRoot,
+ deadlineMs: deadline.getTime(),
+ stdoutFile: join(logDir, "worktree.log"),
+ stderrFile: join(logDir, "worktree.log"),
+ }),
+ "iteration worktree creation",
+ );
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["switch", "-c", branch],
+ cwd: worktree,
+ deadlineMs: deadline.getTime(),
+ }),
+ "iteration branch creation",
+ );
+ const startingSha = mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["rev-parse", "HEAD"],
+ cwd: worktree,
+ deadlineMs: deadline.getTime(),
+ }),
+ "iteration starting SHA",
+ ).stdout.trim();
+ iteration.startingSha = startingSha;
+ mustSucceed(
+ await runCommand({
+ command: "bun",
+ args: ["install", "--frozen-lockfile"],
+ cwd: worktree,
+ deadlineMs: deadline.getTime(),
+ stdoutFile: join(logDir, "install.log"),
+ stderrFile: join(logDir, "install.log"),
+ env: sanitizedEnvironment(),
+ inheritEnv: false,
+ }),
+ "iteration install",
+ );
+
+ const resultFile = join(logDir, "result.json");
+ let threadId;
+ let result;
+ let validation;
+ let errors = [];
+
+ for (let attempt = 0; attempt <= 2; attempt += 1) {
+ if (attempt > 0 && state.totalTokens >= options.maxTotalTokens) {
+ errors.push("Reported token ceiling reached; refusing to start a repair turn");
+ break;
+ }
+ iteration.status = attempt === 0 ? "agent_running" : "repairing";
+ writeJsonAtomic(stateFile, state);
+ const agentRun = await runCodex({
+ cwd: worktree,
+ deadlineMs: deadline.getTime(),
+ logDir,
+ prompt:
+ attempt === 0
+ ? promptForIteration({
+ iteration: number,
+ deadline,
+ baseline: state.baseline,
+ completed,
+ })
+ : repairPrompt(errors),
+ resultFile,
+ attempt,
+ threadId,
+ });
+ threadId = threadId ?? agentRun.threadId;
+ state.totalTokens += agentRun.totalTokens;
+ iteration.attempts.push({
+ number: attempt + 1,
+ exitCode: agentRun.code,
+ timedOut: agentRun.timedOut,
+ reportedTokens: agentRun.totalTokens,
+ completedTurns: agentRun.completedTurns,
+ });
+ writeJsonAtomic(stateFile, state);
+
+ errors = [];
+ if (agentRun.code !== 0) {
+ errors.push(
+ `Codex attempt ${attempt + 1} exited ${agentRun.code ?? agentRun.signal}: ${tail(agentRun.stderr, 1500)}`,
+ );
+ }
+ if (agentRun.code === 0 && agentRun.completedTurns < 1) {
+ errors.push("Codex completed without a flushed turn.completed usage event");
+ }
+ if (agentRun.code === 0 && agentRun.totalTokens < 1) {
+ errors.push("Codex completed without reported token usage");
+ }
+ if (!existsSync(resultFile)) {
+ errors.push("Codex did not write a structured result");
+ } else {
+ try {
+ result = JSON.parse(readFileSync(resultFile, "utf8"));
+ } catch (error) {
+ errors.push(`Could not parse Codex result: ${error.message}`);
+ }
+ }
+
+ if (errors.length === 0 && result.status === "no_change") {
+ const paths = await changedPaths(worktree, deadline.getTime());
+ if (paths.length > 0) errors.push("Agent returned no_change but modified files");
+ else {
+ iteration.status = "no_change";
+ iteration.summary = result.summary;
+ state.status = "no_safe_target";
+ break;
+ }
+ }
+ if (errors.length === 0 && result.status === "blocked") {
+ errors.push(`Agent reported blocked: ${result.summary}`);
+ continue;
+ }
+ if (errors.length === 0 && result.status === "changed") {
+ iteration.status = "validating";
+ writeJsonAtomic(stateFile, state);
+ validation = await validateIteration({
+ worktree,
+ startingSha,
+ result,
+ baseline: state.baseline,
+ deadlineMs: deadline.getTime(),
+ logDir,
+ });
+ errors.push(...validation.errors);
+ if (errors.length === 0) break;
+ }
+ }
+
+ if (iteration.status === "no_change") {
+ iteration.finishedAt = new Date().toISOString();
+ writeJsonAtomic(stateFile, state);
+ break;
+ }
+ if (errors.length > 0) {
+ iteration.status = "escalated";
+ iteration.errors = errors;
+ const issue = mustSucceed(
+ await openEscalationIssue({
+ repoRoot,
+ repo,
+ base: options.base,
+ iteration: number,
+ errors,
+ worktree,
+ deadlineMs: deadline.getTime(),
+ }),
+ "escalation issue creation",
+ );
+ iteration.issueUrl = issue.stdout.trim() || undefined;
+ iteration.finishedAt = new Date().toISOString();
+ state.status = "iteration_failed";
+ writeJsonAtomic(stateFile, state);
+ break;
+ }
+
+ iteration.status = "publishing";
+ writeJsonAtomic(stateFile, state);
+ const prUrl = await publishIteration({
+ repoRoot,
+ repo,
+ base: options.base,
+ branch,
+ worktree,
+ startingSha,
+ result,
+ validation,
+ deadlineMs: deadline.getTime(),
+ logDir,
+ });
+ iteration.status = "pr_opened";
+ iteration.summary = result.summary;
+ iteration.prUrl = prUrl;
+ iteration.finishedAt = new Date().toISOString();
+ completed.push(`${result.kind}: ${result.summary} (${prUrl})`);
+ writeJsonAtomic(stateFile, state);
+
+ mustSucceed(
+ await runCommand({
+ command: "git",
+ args: ["worktree", "remove", "--force", worktree],
+ cwd: repoRoot,
+ deadlineMs: Math.max(deadline.getTime(), Date.now() + 30_000),
+ }),
+ "iteration worktree cleanup",
+ );
+ iteration.worktree = undefined;
+ writeJsonAtomic(stateFile, state);
+ } catch (error) {
+ iteration.status = "runner_error";
+ iteration.errors = [error.message];
+ iteration.finishedAt = new Date().toISOString();
+ state.status = "runner_error";
+ writeJsonAtomic(stateFile, state);
+ const escalation = await openEscalationIssue({
+ repoRoot,
+ repo,
+ base: options.base,
+ iteration: number,
+ errors: iteration.errors,
+ worktree,
+ deadlineMs: Math.max(deadline.getTime(), Date.now() + 30_000),
+ });
+ if (escalation.code !== 0) {
+ iteration.errors.push(
+ `Escalation issue failed: ${tail(combined(escalation), 1_500)}`,
+ );
+ writeJsonAtomic(stateFile, state);
+ }
+ break;
+ }
+ }
+
+ if (state.status === "running") {
+ state.status =
+ state.iterations.length >= options.maxIterations
+ ? "iteration_budget_reached"
+ : "completed";
+ }
+ state.finishedAt = new Date().toISOString();
+ writeJsonAtomic(stateFile, state);
+ writeFileSync(join(stateRoot, "summary.md"), finalSummary(state));
+ process.stdout.write(`${finalSummary(state)}State: ${stateRoot}\n`);
+}
+
+main().catch((error) => {
+ if (fatalContext) {
+ fatalContext.state.status = "fatal_error";
+ fatalContext.state.errors = [error.message];
+ fatalContext.state.finishedAt = new Date().toISOString();
+ writeJsonAtomic(fatalContext.stateFile, fatalContext.state);
+ writeFileSync(
+ join(fatalContext.stateRoot, "summary.md"),
+ finalSummary(fatalContext.state),
+ );
+ }
+ process.stderr.write(`night-loop: ${error.stack ?? error.message}\n`);
+ process.exitCode = 1;
+});
diff --git a/tests/night-loop.test.ts b/tests/night-loop.test.ts
new file mode 100644
index 0000000..bb57ef6
--- /dev/null
+++ b/tests/night-loop.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from "bun:test";
+import {
+ parseCodexEvents,
+ parseFailedTests,
+ parseLintSummary,
+ parseNumstat,
+ parseTestSummary,
+ resolveDeadline,
+ scanPatchForSecrets,
+ validateDifferential,
+ validateLintRuleChange,
+ validateScope,
+} from "../scripts/night-loop-lib.mjs";
+
+describe("night-loop deadline", () => {
+ it("uses today's local cutoff when it is still ahead", () => {
+ const now = new Date(2026, 6, 28, 1, 15);
+ expect(resolveDeadline(now, "07:30")).toEqual(new Date(2026, 6, 28, 7, 30));
+ });
+
+ it("rolls a clock-only cutoff to tomorrow after the cutoff", () => {
+ const now = new Date(2026, 6, 28, 8, 0);
+ expect(resolveDeadline(now, "07:30")).toEqual(new Date(2026, 6, 29, 7, 30));
+ });
+
+ it("rejects an explicit deadline in the past", () => {
+ const now = new Date("2026-07-28T06:00:00Z");
+ expect(() => resolveDeadline(now, "2026-07-28T05:00:00Z")).toThrow();
+ });
+});
+
+describe("night-loop output parsing", () => {
+ it("parses lint and final test totals", () => {
+ expect(parseLintSummary("Found 2 warnings and 0 errors.")).toEqual({
+ warnings: 2,
+ errors: 0,
+ });
+ expect(parseTestSummary("1 pass\n0 fail\n\n464 pass\n8 fail\n")).toEqual({
+ pass: 464,
+ fail: 8,
+ });
+ });
+
+ it("pins normalized failing-test names", () => {
+ const output = [
+ "(fail) compiled binary > exists [0.17ms]",
+ "(fail) e2e: list > (unnamed)",
+ "(fail) compiled binary > exists [1.20ms]",
+ ].join("\n");
+ expect(parseFailedTests(output)).toEqual([
+ "compiled binary > exists",
+ "e2e: list > (unnamed)",
+ ]);
+ });
+
+ it("extracts the thread and sums completed-turn usage", () => {
+ const output = [
+ '{"type":"thread.started","thread_id":"thread-1"}',
+ '{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":20,"reasoning_output_tokens":5}}',
+ ].join("\n");
+ expect(parseCodexEvents(output)).toEqual({
+ threadId: "thread-1",
+ totalTokens: 125,
+ completedTurns: 1,
+ });
+ });
+
+ it("parses numstat without counting binary markers", () => {
+ expect(parseNumstat("10\t2\ttests/a.test.ts\n-\t-\tfixture.bin\n")).toEqual({
+ files: 2,
+ additions: 10,
+ deletions: 2,
+ binaryFiles: 1,
+ });
+ });
+});
+
+describe("night-loop safety gates", () => {
+ it("accepts a config-only lint diff", () => {
+ expect(
+ validateScope(
+ "lint",
+ [".oxlintrc.json", "context/map/repo-map.md"],
+ { files: 2, additions: 80, deletions: 4, binaryFiles: 0 },
+ ),
+ ).toEqual([]);
+ });
+
+ it("rejects source changes from an overnight lint ratchet", () => {
+ expect(
+ validateScope("lint", [".oxlintrc.json", "src/lib/api.ts"], {
+ files: 2,
+ additions: 2,
+ deletions: 1,
+ binaryFiles: 0,
+ }),
+ ).toContain("Path is outside the lint allowlist: src/lib/api.ts");
+ });
+
+ it("requires the pinned failures and non-regressing test count", () => {
+ const baseline = {
+ lint: { warnings: 2, errors: 0 },
+ tests: { pass: 464, fail: 8 },
+ failedTests: ["known failure"],
+ };
+ expect(
+ validateDifferential("lint", baseline, {
+ lint: { warnings: 2, errors: 0 },
+ tests: { pass: 465, fail: 8 },
+ failedTests: ["known failure"],
+ }),
+ ).toEqual([]);
+ expect(
+ validateDifferential("lint", baseline, {
+ lint: { warnings: 3, errors: 0 },
+ tests: { pass: 464, fail: 8 },
+ failedTests: ["different failure"],
+ }),
+ ).toHaveLength(2);
+ });
+
+ it("rejects common credential shapes without echoing their value", () => {
+ const errors = scanPatchForSecrets('+ token = "ghp_abcdefghijklmnopqrstuvwxyz1234"');
+ expect(errors).toContain("Patch matches forbidden secret pattern: GitHub token");
+ expect(errors.every((error) => !error.includes("abcdefghijklmnopqrstuvwxyz"))).toBe(true);
+ });
+
+ it("requires one additive error-level lint rule", () => {
+ const base = JSON.stringify({ rules: { a: "error" }, ignorePatterns: ["dist"] });
+ const valid = JSON.stringify({
+ rules: { a: "error", b: "error" },
+ ignorePatterns: ["dist"],
+ });
+ expect(validateLintRuleChange(base, valid)).toEqual([]);
+ expect(
+ validateLintRuleChange(
+ base,
+ JSON.stringify({ rules: { a: "off", b: "warn" }, ignorePatterns: ["dist"] }),
+ ),
+ ).toHaveLength(2);
+ });
+});