oh-my-harness 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,12 +103,16 @@ your-project/
103
103
  │ │ ├── catalog-command-guard.sh # Blocks dangerous commands
104
104
  │ │ ├── catalog-lint-on-save.sh # Auto-lint on save
105
105
  │ │ └── catalog-auto-pr.sh # Auto-create PR after push
106
+ │ ├── loop/
107
+ │ │ └── worktree/ # gitignored — the loop's isolated git worktree (see Loop Engine)
106
108
  │ ├── state/ # gitignored — log/runtime data
107
109
  │ │ ├── events.jsonl # Unified hook event log (powers omh stats)
110
+ │ │ ├── loop/ # run.json (lock + identity), stop flag, runs/<id>/events.jsonl
108
111
  │ │ └── tdd-edits.json # TDD guard working state
109
112
  │ └── manifest.json # Generated-files manifest
110
113
  ├── .claude/
111
114
  │ ├── settings.json # Claude permissions + hooks → .omh/hooks/*.sh
115
+ │ ├── skills/omh-loop/SKILL.md # "run this as a loop" skill
112
116
  │ └── oh-my-harness.json # Harness init/sync state
113
117
  └── .codex/
114
118
  ├── config.toml # [features] hooks = true, goals = true
@@ -215,6 +219,7 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
215
219
  | 🗜️ `compact-context` | maintenance | Re-injects context on session start |
216
220
  | 📋 `config-audit` | audit | Audit trail for config changes |
217
221
  | 🔔 `desktop-notify` | ux | Cross-platform desktop notifications |
222
+ | 🔁 `loop-guard` | quality | Blocks a loop session from writing its own work orders or touching architect-only paths |
218
223
 
219
224
  ### Usage in `harness.yaml`
220
225
 
@@ -261,6 +266,96 @@ on a non-blocking block (e.g. `lint-on-save`) is reported and ignored.
261
266
 
262
267
  ---
263
268
 
269
+ ## 🔁 Autonomous Loop Engine
270
+
271
+ Once `omh init`/`omh sync` has run (with `loop.enabled: true`, the default),
272
+ any agent session (Claude Code, Codex, Pi) can be told **"run this as a loop"** — the generated `omh-loop` skill turns that
273
+ into a fully set-up autonomous loop, no manual wiring:
274
+
275
+ ```text
276
+ you: "ship the remaining Phase B tasks as a loop"
277
+ │
278
+ ▼ omh-loop skill (the session becomes the ARCHITECT)
279
+ 1. writes the `loop.ledger` — default WORKPLAN.md; goal gates + task checkboxes
280
+ 2. writes `loop.workOrders`/*.md — default docs/work-orders; one exact work order per task
281
+ 3. runs `omh loop start` — a detached supervisor; with `isolate: true` (default), in its own git worktree
282
+ 4. attaches monitoring — omh loop status, then tail -f .omh/state/loop/runs/<id>/events.jsonl
283
+ │
284
+ ▼ loop (fresh headless session per iteration, cheap model)
285
+ pick next unchecked task → implement its work order exactly → run its
286
+ acceptance commands → tick checkbox + commit → repeat until the sentinel
287
+ ```
288
+
289
+ The design follows a battle-tested pattern from real autonomous runs: state
290
+ lives in **files** (ledger + git log), never in a conversation. Each iteration
291
+ is a fresh headless session (`claude -p` / `codex exec --dangerously-bypass-hook-trust` / `pi --print --no-session`)
292
+ on an explicit cheap model, so token use stays flat.
293
+
294
+ ### Why it doesn't fall over
295
+
296
+ Every guard below exists because the failure actually happened somewhere:
297
+
298
+ | Guard | Failure it prevents |
299
+ |-------|---------------------|
300
+ | Sentinel = fixed string, whole line, clean exit only | a crashed turn (or a turn merely *mentioning* the sentinel) ending the loop as "complete" |
301
+ | Three **separate** backoffs — usage limit / empty-output failed turn / consecutive `BLOCKED` | a loop once spun **266 iterations** doing nothing because waiting-on-a-human was treated like a failure |
302
+ | `loop-guard` hook blocks the loop writing its own work orders (Edit/Write **and** Bash redirection/`cd`) | self-approval: the loop authoring the spec it then implements |
303
+ | `loop-guard` blocks architect-only paths, listed **by name** | an abstract "don't improvise" is ignored; a named path is obeyed |
304
+ | Worktree isolation (`isolate: true`, default) | the loop and the architect fighting over the same working tree |
305
+ | Ledger seeded once, re-seeded only when the main-tree copy is newer | the loop's progress being rolled back — or a second goal reusing the first goal's ledger |
306
+
307
+ ### Configuration
308
+
309
+ On by default. Everything is optional in `harness.yaml`:
310
+
311
+ ```yaml
312
+ loop:
313
+ enabled: true # false removes all loop assets on next sync
314
+ ledger: WORKPLAN.md # single source of truth
315
+ workOrders: docs/work-orders # architect-written task specs
316
+ model: sonnet # cheap implementation model (always explicit)
317
+ sentinel: OMH_GOAL_COMPLETE # whole-line completion signal
318
+ interval: 120 # seconds between iterations
319
+ blockedBackoff: 1800 # backoff once stallStreak blocked/idle turns pile up
320
+ limitBackoff: 1800 # backoff after a provider usage limit
321
+ emptyBackoff: 300 # backoff after a crashed (empty-output / timed-out) turn
322
+ stallStreak: 3 # consecutive blocked/idle turns before blockedBackoff
323
+ turnTimeout: 7200 # hard per-turn timeout (seconds); the turn is killed past this
324
+ architectOnly: [] # paths the loop must never touch (name them!)
325
+ isolate: true # run in .omh/loop/worktree on branch omh-loop
326
+ runtime: claude # claude | codex | pi
327
+ ```
328
+
329
+ ### Operating it
330
+
331
+ ```bash
332
+ omh loop start # preflight, then a detached supervisor (the skill does this for you)
333
+ omh loop status # run id, pid, iteration, last event, events path
334
+ tail -f .omh/state/loop/runs/<id>/events.jsonl # progress / blocked / idle / limit / crash
335
+ omh loop stop [--now] # stop flag + SIGTERM to the whole process group (--now: 1s grace)
336
+ omh loop clean [--branch] # remove the worktree and stale state (and optionally the omh-loop branch)
337
+ ```
338
+
339
+ The supervisor is TypeScript (`src/loop/`), not a generated script: the runtime
340
+ is spawned from an argv array (no shell), each turn is judged by the **ledger
341
+ diff and git HEAD** rather than by grepping output, and the run lock is a single
342
+ `run.json` created atomically with `link(2)`. POSIX only — it relies on process
343
+ groups and `ps` for identity checks.
344
+
345
+ Known limit: the ledger is seeded into the worktree once per goal (by content
346
+ hash). Editing the main-tree ledger mid-run does nothing until the next start,
347
+ which then re-seeds it — add mid-run tasks as new work orders instead, and edit
348
+ the worktree's ledger if you must.
349
+
350
+ A task the loop cannot finish (needs a human, or 3 failed attempts) is marked
351
+ `BLOCKED: <reason>` in the ledger and skipped — the loop never idles waiting
352
+ for a person. When you unblock it (e.g. fix an architect-only file), the loop
353
+ picks it back up after its backoff. When the goal completes in isolation, merge
354
+ the `omh-loop` branch and **re-verify** — a clean textual merge is not a
355
+ semantic one.
356
+
357
+ ---
358
+
264
359
  ## 🖥️ Commands
265
360
 
266
361
  ```bash
@@ -403,13 +498,13 @@ oh-my-harness/
403
498
  ├── bin/ # CLI entry point
404
499
  ├── src/
405
500
  │ ├── catalog/
406
- │ │ ├── blocks/ # 17 building block definitions
501
+ │ │ ├── blocks/ # 18 building block definitions
407
502
  │ │ ├── types.ts # BuildingBlock, HookEntry schemas
408
503
  │ │ ├── registry.ts # Block discovery & search
409
504
  │ │ ├── template-engine.ts # Handlebars rendering + applyDefaults
410
505
  │ │ └── converter.ts # HookEntry[] → rendered scripts
411
506
  │ ├── cli/
412
- │ │ ├── commands/ # init, doctor, catalog, hook, sync, test
507
+ │ │ ├── commands/ # init, doctor, catalog, hook, sync, test, loop (start/run/stop/status/clean)
413
508
  │ │ ├── stats/ # TUI dashboard (ink/React)
414
509
  │ │ │ ├── App.tsx # App shell (tab bar, keyboard nav)
415
510
  │ │ │ ├── data.ts # Data aggregation layer
@@ -420,6 +515,14 @@ oh-my-harness/
420
515
  │ │ ├── tui/ # Interactive provider & model selection
421
516
  │ │ ├── provider-setup.ts # Provider configuration UI
422
517
  │ │ └── tool-checker.ts # Command executable checks
518
+ │ ├── loop/ # autonomous loop supervisor (TypeScript, no generated shell)
519
+ │ │ ├── supervisor.ts # one run: lock → worktree/seed → turns → release
520
+ │ │ ├── state.ts # run.json link-lock, events, atomic writes
521
+ │ │ ├── classify.ts # turn verdict from ledger diff + HEAD; wait policy
522
+ │ │ ├── runtime.ts # argv per runtime, turn execution in its own process group
523
+ │ │ ├── worktree.ts # git worktree lifecycle, asset sync, ledger seeding
524
+ │ │ ├── protocol.ts # the rules, rendered into prompt / CLAUDE.md / skill
525
+ │ │ └── stop.ts # identity-checked group stop, group sweep
423
526
  │ ├── core/
424
527
  │ │ ├── harness-schema.ts # harness.yaml Zod schema
425
528
  │ │ ├── merged-config.ts # MergedConfig + HooksConfig interfaces
@@ -466,7 +569,7 @@ oh-my-harness/
466
569
 
467
570
  - [x] `npx oh-my-harness` — zero-install usage
468
571
  - [x] `omh sync` — regenerate from harness.yaml
469
- - [x] Building block catalog — 17 verified hook templates
572
+ - [x] Building block catalog — 18 verified hook templates
470
573
  - [x] Project detector — 14 language auto-detection
471
574
  - [x] `omh test` — dry-run hook verification
472
575
  - [x] `omh stats` — TUI analytics dashboard (ink)
@@ -481,6 +584,7 @@ oh-my-harness/
481
584
  - [x] `ask` mode — request approval before executing risky tools (Claude native prompt / Pi `ctx.ui.select`; Codex falls back to block)
482
585
  - [x] `omh uninstall` — remove generated artifacts while preserving user content
483
586
  - [x] `omh config` — view, reconfigure, or reset the saved AI provider (rotate expired keys, switch provider/model)
587
+ - [x] Autonomous loop engine — `omh-loop` skill, worktree-isolated runner, loop-guard, JSONL monitoring
484
588
  - [ ] Community harness.yaml registry — share and reuse configs
485
589
  - [ ] `omh modify "change X"` — NL config editing
486
590
 
@@ -15,6 +15,7 @@ import { desktopNotify } from "./desktop-notify.js";
15
15
  import { configAudit } from "./config-audit.js";
16
16
  import { compactContext } from "./compact-context.js";
17
17
  import { worktreeSetup } from "./worktree-setup.js";
18
+ import { loopGuard } from "./loop-guard.js";
18
19
  export const builtinBlocks = [
19
20
  branchGuard,
20
21
  commitTestGate,
@@ -33,4 +34,5 @@ export const builtinBlocks = [
33
34
  configAudit,
34
35
  compactContext,
35
36
  worktreeSetup,
37
+ loopGuard,
36
38
  ];
@@ -0,0 +1,10 @@
1
+ import type { BuildingBlock } from "../types.js";
2
+ /**
3
+ * Enforces the loop protocol's two deterministic bans as hard blocks instead
4
+ * of prose. Both have been violated in practice when they lived only in a
5
+ * ledger: a loop that writes its own work order and then implements it is
6
+ * approving itself, and architect-only files are exactly the ones a loop
7
+ * mis-diagnoses. Fires only in loop sessions — the runner exports OMH_LOOP=1,
8
+ * so the architect's own session is never blocked.
9
+ */
10
+ export declare const loopGuard: BuildingBlock;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Enforces the loop protocol's two deterministic bans as hard blocks instead
3
+ * of prose. Both have been violated in practice when they lived only in a
4
+ * ledger: a loop that writes its own work order and then implements it is
5
+ * approving itself, and architect-only files are exactly the ones a loop
6
+ * mis-diagnoses. Fires only in loop sessions — the runner exports OMH_LOOP=1,
7
+ * so the architect's own session is never blocked.
8
+ */
9
+ export const loopGuard = {
10
+ id: "loop-guard",
11
+ name: "Loop Guard",
12
+ description: "Blocks a loop session from writing its own work orders or touching architect-only paths",
13
+ category: "quality",
14
+ event: "PreToolUse",
15
+ matcher: "Edit|Write|Bash",
16
+ canBlock: true,
17
+ params: [
18
+ {
19
+ name: "workOrders",
20
+ type: "string",
21
+ description: "Work-order directory the loop must never write into",
22
+ required: false,
23
+ default: "docs/work-orders",
24
+ },
25
+ {
26
+ name: "architectOnly",
27
+ type: "string[]",
28
+ description: "Path prefixes only the architect may edit",
29
+ required: false,
30
+ default: [],
31
+ },
32
+ ],
33
+ template: `#!/bin/bash
34
+ set -euo pipefail
35
+ INPUT=$(cat)
36
+
37
+ # Architect sessions pass through untouched; only the runner exports this.
38
+ [[ "\${OMH_LOOP:-}" != "1" ]] && exit 0
39
+
40
+ TOOL_NAME=$(echo "\$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
41
+ FILE_PATH=$(echo "\$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
42
+ COMMAND=$(echo "\$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
43
+
44
+ # Codex apply_patch ships the whole patch in tool_input.command and names the
45
+ # files in "*** {Add|Update|Delete} File: <path>" headers. Collect those
46
+ # paths and treat them like file_path; the Bash write heuristic must NOT run
47
+ # over patch text (it would fire on words inside the diff).
48
+ PATCH_PATHS=()
49
+ if [[ "\$TOOL_NAME" == "apply_patch" && -n "\$COMMAND" ]]; then
50
+ while IFS= read -r _p; do
51
+ _p="\${_p%$'\\r'}"
52
+ [[ -n "\$_p" ]] && PATCH_PATHS+=("\$_p")
53
+ done < <(printf '%s\\n' "\$COMMAND" | sed -nE 's/^\\*\\*\\* ((Add|Update|Delete) File|Move to): (.+)$/\\3/p')
54
+ COMMAND=""
55
+ fi
56
+ [[ -z "\$FILE_PATH" && -z "\$COMMAND" && \${#PATCH_PATHS[@]} -eq 0 ]] && exit 0
57
+
58
+ # Component-boundary matching: wrap both sides in slashes so 'ios' matches
59
+ # ios/App.swift and /repo/ios/... but never src/kiosk.ts.
60
+ _omh_path_under() {
61
+ local file="/\$1/" prefix="\${2%/}"
62
+ [[ "\$file" == *"/\$prefix/"* ]]
63
+ }
64
+
65
+ # Bash coverage: a shell command that mentions a protected path AND carries a
66
+ # write indicator is blocked. Reads (cat/grep of a work order) pass.
67
+ # ponytail: substring heuristic — a command writing elsewhere while merely
68
+ # mentioning a protected path is over-blocked; tighten to arg-level parsing if
69
+ # that ever bites. This guards a drifting loop, not a malicious one — evasion
70
+ # via split cd chains, variable indirection, pushd, or symlinks is explicitly
71
+ # out of scope: winning that arms race needs a filesystem sandbox, not a hook.
72
+ _omh_bash_writes_to() {
73
+ local cmd="\$1" target="\${2%/}"
74
+ [[ -z "\$cmd" || -z "\$target" ]] && return 1
75
+ [[ "\$cmd" == *"\$target"* ]] || return 1
76
+ # The path is interpolated into an ERE below: escape its metacharacters so
77
+ # "docs/[orders" cannot break the pattern (grep error = silent bypass) and
78
+ # "docs/o+rders" is compared literally.
79
+ target=$(printf '%s' "\$target" | sed -e 's/[][\\.*^$(){}?+|]/\\\\&/g')
80
+ local WRITE_OPS='(tee|mv|cp|rm|touch|truncate|sed[[:space:]]+-i[^[:space:]]*)'
81
+ # write op targeting the protected path directly ...
82
+ echo "\$cmd" | grep -qE '(>|>>)[[:space:]]*[^|&;]*'"\$target"'|(^|[^[:alnum:]_])'"\$WRITE_OPS"'[[:space:]][^|&;]*'"\$target" && return 0
83
+ # ... or a cd into it followed by any write op (relative paths escape the
84
+ # direct pattern once the cwd is inside the protected directory)
85
+ echo "\$cmd" | grep -qE '(^|[^[:alnum:]_])cd[[:space:]][^|&;]*'"\$target" \\
86
+ && echo "\$cmd" | grep -qE '>|(^|[^[:alnum:]_])'"\$WRITE_OPS"'[[:space:]]'
87
+ }
88
+
89
+ # Any path a tool call names: the direct file_path plus apply_patch headers.
90
+ _omh_any_path_under() {
91
+ local prefix="\$1" f
92
+ [[ -n "\$FILE_PATH" ]] && _omh_path_under "\$FILE_PATH" "\$prefix" && return 0
93
+ for f in "\${PATCH_PATHS[@]+"\${PATCH_PATHS[@]}"}"; do
94
+ _omh_path_under "\$f" "\$prefix" && return 0
95
+ done
96
+ return 1
97
+ }
98
+
99
+ WORK_ORDERS='{{{workOrders}}}'
100
+ if _omh_any_path_under "\$WORK_ORDERS" || _omh_bash_writes_to "\$COMMAND" "\$WORK_ORDERS"; then
101
+ REASON="oh-my-harness: loop-guard — the loop must not write its own work orders. Mark the task 'BLOCKED: no work order' and move on; the architect writes work orders."
102
+ _log_event "block" "\$REASON"
103
+ _emit_decision "block" "\$REASON"
104
+ exit 0
105
+ fi
106
+
107
+ ARCHITECT_ONLY=({{#each architectOnly}}"{{{this}}}" {{/each}})
108
+ for prefix in "\${ARCHITECT_ONLY[@]+"\${ARCHITECT_ONLY[@]}"}"; do
109
+ [[ -z "\$prefix" ]] && continue
110
+ if _omh_any_path_under "\$prefix" || _omh_bash_writes_to "\$COMMAND" "\$prefix"; then
111
+ REASON="oh-my-harness: loop-guard — \$prefix is architect-only. Mark the task 'BLOCKED: architect-only path' and move on."
112
+ _log_event "block" "\$REASON"
113
+ _emit_decision "block" "\$REASON"
114
+ exit 0
115
+ fi
116
+ done
117
+
118
+ exit 0`,
119
+ tags: ["loop", "autonomous", "quality", "guardrail"],
120
+ };
@@ -0,0 +1,54 @@
1
+ import type { LoopConfig } from "../../core/merged-config.js";
2
+ import type { LivenessDeps } from "../../loop/state.js";
3
+ import { stopLoop } from "../../loop/stop.js";
4
+ import type { SupervisorExit } from "../../loop/supervisor.js";
5
+ /**
6
+ * `omh loop` — the user-facing (and skill-facing) entry points to the
7
+ * supervisor in src/loop. `start` launches `run` detached; everything else
8
+ * reads or signals the state that `run` maintains.
9
+ */
10
+ export interface LoopResult {
11
+ exitCode: number;
12
+ }
13
+ /** The subset of ChildProcess that start relies on; injectable for tests. */
14
+ export interface ChildLike {
15
+ pid?: number;
16
+ exitCode: number | null;
17
+ unref(): void;
18
+ once(event: "exit", cb: (code: number | null) => void): unknown;
19
+ }
20
+ export type SpawnLike = (cmd: string, args: string[], opts: Record<string, unknown>) => ChildLike;
21
+ export interface LoopStartOptions extends LivenessDeps {
22
+ projectDir?: string;
23
+ spawnImpl?: SpawnLike;
24
+ platform?: NodeJS.Platform;
25
+ /** How long to wait for the supervisor to acquire the run. */
26
+ acquireTimeoutMs?: number;
27
+ }
28
+ export declare function loopStartCommand(options?: LoopStartOptions): Promise<LoopResult>;
29
+ export interface LoopRunOptions {
30
+ projectDir?: string;
31
+ runId: string;
32
+ supervisorImpl?: (o: {
33
+ projectDir: string;
34
+ cfg: LoopConfig;
35
+ runId: string;
36
+ }) => Promise<SupervisorExit>;
37
+ sweepImpl?: () => void;
38
+ }
39
+ export declare function loopRunCommand(options: LoopRunOptions): Promise<LoopResult>;
40
+ export interface LoopStopOptions {
41
+ projectDir?: string;
42
+ now?: boolean;
43
+ stopImpl?: typeof stopLoop;
44
+ }
45
+ export declare function loopStopCommand(options?: LoopStopOptions): Promise<LoopResult>;
46
+ export interface LoopStatusOptions extends LivenessDeps {
47
+ projectDir?: string;
48
+ }
49
+ export declare function loopStatusCommand(options?: LoopStatusOptions): Promise<LoopResult>;
50
+ export interface LoopCleanOptions {
51
+ projectDir?: string;
52
+ branch?: boolean;
53
+ }
54
+ export declare function loopCleanCommand(options?: LoopCleanOptions): Promise<LoopResult>;
@@ -0,0 +1,221 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import yaml from "js-yaml";
6
+ import chalk from "chalk";
7
+ import { HarnessConfigSchema } from "../../core/harness-schema.js";
8
+ import { loopPaths, readRun, isRunLive, pruneRuns, runDir, readEvents } from "../../loop/state.js";
9
+ import { stopLoop, sweepOwnGroup, isOurTurnGroup } from "../../loop/stop.js";
10
+ import { runSupervisor } from "../../loop/supervisor.js";
11
+ import { currentBranch, git, removeWorktree, WorktreeError } from "../../loop/worktree.js";
12
+ const fail = (msg) => {
13
+ console.error(chalk.red(`omh loop: ${msg}`));
14
+ return { exitCode: 1 };
15
+ };
16
+ async function loadLoopConfig(projectDir) {
17
+ let raw;
18
+ try {
19
+ raw = fs.readFileSync(path.join(projectDir, "harness.yaml"), "utf-8");
20
+ }
21
+ catch {
22
+ return fail("harness.yaml not found — run `omh init` first");
23
+ }
24
+ const parsed = HarnessConfigSchema.safeParse(yaml.load(raw));
25
+ if (!parsed.success)
26
+ return fail(`harness.yaml is invalid: ${parsed.error.issues[0]?.message ?? "schema error"}`);
27
+ const loop = parsed.data.loop;
28
+ if (!loop || !loop.enabled)
29
+ return fail("the loop is disabled in harness.yaml (loop.enabled: false)");
30
+ return loop;
31
+ }
32
+ const isResult = (v) => typeof v === "object" && v !== null && "exitCode" in v;
33
+ function newRunId(now = new Date()) {
34
+ const pad = (n) => String(n).padStart(2, "0");
35
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-` +
36
+ `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
37
+ return `${stamp}-${randomBytes(2).toString("hex")}`;
38
+ }
39
+ export async function loopStartCommand(options = {}) {
40
+ const projectDir = path.resolve(options.projectDir ?? process.cwd());
41
+ const platform = options.platform ?? process.platform;
42
+ if (platform === "win32")
43
+ return fail("the loop is POSIX-only (process groups and ps are required)");
44
+ try {
45
+ await git(projectDir, ["rev-parse", "--show-toplevel"]);
46
+ }
47
+ catch {
48
+ return fail("not a git repository");
49
+ }
50
+ const cfg = await loadLoopConfig(projectDir);
51
+ if (isResult(cfg))
52
+ return cfg;
53
+ const p = loopPaths(projectDir);
54
+ try {
55
+ if ((await currentBranch(projectDir)) === p.branch) {
56
+ return fail(`the main tree is checked out on ${p.branch}; switch branches first`);
57
+ }
58
+ }
59
+ catch {
60
+ // An unborn HEAD (no commits) cannot host a worktree or be diffed.
61
+ return fail("the repository has no commits yet (unborn HEAD) — make an initial commit first");
62
+ }
63
+ if (!fs.existsSync(path.join(projectDir, cfg.ledger))) {
64
+ return fail(`ledger ${cfg.ledger} not found — write it first (see the omh-loop skill)`);
65
+ }
66
+ const ordersDir = path.join(projectDir, cfg.workOrders);
67
+ const orders = fs.existsSync(ordersDir) ? fs.readdirSync(ordersDir).filter((f) => f.endsWith(".md")) : [];
68
+ if (orders.length === 0)
69
+ return fail(`no work order found in ${cfg.workOrders}/ — the loop has nothing it may do`);
70
+ const existing = readRun(projectDir);
71
+ if (existing && isRunLive(existing, options)) {
72
+ return fail(`a loop is already running (run ${existing.runId}, pid ${existing.pid}); \`omh loop stop\` first`);
73
+ }
74
+ if (existing?.childPid !== undefined) {
75
+ // A SIGKILLed supervisor can leave its turn running; two loops must not
76
+ // share the worktree, so the orphan has to be stopped first.
77
+ try {
78
+ process.kill(-existing.childPid, 0);
79
+ // The group exists. A dead leader with surviving members can only be
80
+ // our own turn's remnants (pid reuse needs a living leader), and a
81
+ // living leader must actually be the runtime; only a reused pid on an
82
+ // unrelated group is safe to reclaim past.
83
+ if (isOurTurnGroup(existing.childPid, existing.runtime)) {
84
+ return fail(`run ${existing.runId} left an orphaned turn still running (pid ${existing.childPid}); \`omh loop stop\` first`);
85
+ }
86
+ }
87
+ catch {
88
+ // group gone — safe to reclaim
89
+ }
90
+ }
91
+ pruneRuns(projectDir, 5);
92
+ // Only start clears the flag. A stop issued between here and the
93
+ // supervisor acquiring the run must not be lost, so run never clears it.
94
+ fs.rmSync(p.stopFlag, { force: true });
95
+ const runId = newRunId();
96
+ const dir = runDir(p, runId);
97
+ fs.mkdirSync(dir, { recursive: true });
98
+ const logFd = fs.openSync(path.join(dir, "supervisor.log"), "a");
99
+ const spawnImpl = options.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts));
100
+ const child = spawnImpl(process.execPath, [...process.execArgv, process.argv[1], "loop", "run", "--run-id", runId, "-d", projectDir], {
101
+ detached: true,
102
+ stdio: ["ignore", logFd, logFd],
103
+ env: { ...process.env, OMH_SKIP_VERSION_CHECK: "1" },
104
+ });
105
+ // Wait until the supervisor owns the run (or died trying) before letting go.
106
+ const acquired = await new Promise((resolve) => {
107
+ let done = false;
108
+ const settle = (v) => {
109
+ if (done)
110
+ return;
111
+ done = true;
112
+ clearInterval(poll);
113
+ clearTimeout(limit);
114
+ resolve(v);
115
+ };
116
+ child.once("exit", (code) => settle(code ?? 1));
117
+ const poll = setInterval(() => {
118
+ if (readRun(projectDir)?.runId === runId)
119
+ settle("acquired");
120
+ }, 100);
121
+ const limit = setTimeout(() => settle(child.exitCode ?? 1), options.acquireTimeoutMs ?? 15_000);
122
+ });
123
+ fs.closeSync(logFd);
124
+ // A very fast goal can run to completion — and release run.json — before
125
+ // our first poll ever sees the record. Exit 0 plus events for this run id
126
+ // is a finished loop, not a failed start.
127
+ if (acquired === 0 && readEvents(projectDir, runId).length > 0) {
128
+ const last = readEvents(projectDir, runId).at(-1);
129
+ console.log(chalk.green(`omh loop: started run ${runId} (pid ${child.pid ?? "?"}) — it already finished (${last?.kind ?? "done"})`));
130
+ console.log(` events: ${path.join(dir, "events.jsonl")}`);
131
+ return { exitCode: 0 };
132
+ }
133
+ if (acquired !== "acquired") {
134
+ // Always let go of the handle: a still-running child would otherwise keep
135
+ // this CLI process alive until the whole loop finished.
136
+ child.unref();
137
+ const stillRunning = child.exitCode === null;
138
+ console.error(chalk.red(acquired === 3
139
+ ? "omh loop: another loop is already running"
140
+ : stillRunning
141
+ ? `omh loop: the supervisor did not acquire the run within the timeout — see ${path.join(dir, "supervisor.log")}`
142
+ : `omh loop: the supervisor exited with code ${acquired} before starting — see ${path.join(dir, "supervisor.log")}`));
143
+ // Exit 0 without any recorded event is not a success we can vouch for.
144
+ return { exitCode: acquired === 0 ? 1 : acquired };
145
+ }
146
+ child.unref();
147
+ console.log(chalk.green(`omh loop: started run ${runId} (pid ${child.pid ?? "?"})`));
148
+ console.log(` events: ${path.join(dir, "events.jsonl")}`);
149
+ console.log(` watch: omh loop status | tail -f ${path.join(dir, "events.jsonl")}`);
150
+ console.log(" stop: omh loop stop");
151
+ return { exitCode: 0 };
152
+ }
153
+ export async function loopRunCommand(options) {
154
+ const projectDir = path.resolve(options.projectDir ?? process.cwd());
155
+ const cfg = await loadLoopConfig(projectDir);
156
+ if (isResult(cfg))
157
+ return cfg;
158
+ const run = options.supervisorImpl ?? ((o) => runSupervisor(o));
159
+ const exit = await run({ projectDir, cfg, runId: options.runId });
160
+ // Whatever a turn left behind in our process group goes with us.
161
+ (options.sweepImpl ?? sweepOwnGroup)();
162
+ switch (exit) {
163
+ case "complete":
164
+ case "stopped":
165
+ return { exitCode: 0 };
166
+ case "already-running":
167
+ return { exitCode: 3 };
168
+ case "failed":
169
+ return { exitCode: 1 };
170
+ }
171
+ }
172
+ export async function loopStopCommand(options = {}) {
173
+ const projectDir = path.resolve(options.projectDir ?? process.cwd());
174
+ const run = readRun(projectDir);
175
+ // Even with no record, stopLoop lays down the stop flag so a supervisor
176
+ // mid-acquire still sees the request.
177
+ await (options.stopImpl ?? stopLoop)(projectDir, { graceMs: options.now ? 1000 : 10_000 });
178
+ if (!run) {
179
+ console.log("omh loop: no active loop (stop flag recorded)");
180
+ return { exitCode: 0 };
181
+ }
182
+ console.log(chalk.green(`omh loop: stopped run ${run.runId}`));
183
+ return { exitCode: 0 };
184
+ }
185
+ export async function loopStatusCommand(options = {}) {
186
+ const projectDir = path.resolve(options.projectDir ?? process.cwd());
187
+ const run = readRun(projectDir);
188
+ if (!run) {
189
+ console.log("omh loop: no active loop");
190
+ return { exitCode: 0 };
191
+ }
192
+ const live = isRunLive(run, options);
193
+ const events = readEvents(projectDir, run.runId);
194
+ const last = events.at(-1);
195
+ console.log(`run: ${run.runId} (${live ? "running" : "not running — stale record"})`);
196
+ console.log(`pid: ${run.pid}`);
197
+ console.log(`runtime: ${run.runtime}`);
198
+ console.log(`iteration: ${run.iteration}`);
199
+ console.log(`cwd: ${run.cwd}`);
200
+ if (last)
201
+ console.log(`last: ${last.kind}${last.message ? ` — ${last.message}` : ""} (${last.ts})`);
202
+ console.log(`events: ${path.join(runDir(loopPaths(projectDir), run.runId), "events.jsonl")}`);
203
+ return { exitCode: 0 };
204
+ }
205
+ export async function loopCleanCommand(options = {}) {
206
+ const projectDir = path.resolve(options.projectDir ?? process.cwd());
207
+ const p = loopPaths(projectDir);
208
+ await stopLoop(projectDir, { graceMs: 5000 });
209
+ try {
210
+ await removeWorktree(projectDir, { branch: options.branch });
211
+ }
212
+ catch (err) {
213
+ if (!(err instanceof WorktreeError))
214
+ throw err;
215
+ return fail(err.message);
216
+ }
217
+ fs.rmSync(p.stopFlag, { force: true });
218
+ fs.rmSync(p.runJson, { force: true });
219
+ console.log(chalk.green(`omh loop: cleaned worktree${options.branch ? ` and branch ${p.branch}` : ""}`));
220
+ return { exitCode: 0 };
221
+ }
package/dist/cli/index.js CHANGED
@@ -124,6 +124,62 @@ export function createCli() {
124
124
  const { catalogInfoCommand } = await import("./commands/catalog.js");
125
125
  await catalogInfoCommand(blockId);
126
126
  });
127
+ const loopCmd = program
128
+ .command("loop")
129
+ .description("Run the autonomous loop (see the omh-loop skill)");
130
+ loopCmd
131
+ .command("start")
132
+ .description("Start the loop supervisor in the background")
133
+ .option("-d, --project-dir <dir>", "Project directory")
134
+ .action(async (options) => {
135
+ const { loopStartCommand } = await import("./commands/loop.js");
136
+ const result = await loopStartCommand(options);
137
+ if (result.exitCode !== 0)
138
+ process.exitCode = result.exitCode;
139
+ });
140
+ loopCmd
141
+ .command("run")
142
+ .description("Run the loop supervisor in the foreground (used by start)")
143
+ .requiredOption("--run-id <id>", "Run identifier")
144
+ .option("-d, --project-dir <dir>", "Project directory")
145
+ .action(async (options) => {
146
+ const { loopRunCommand } = await import("./commands/loop.js");
147
+ const result = await loopRunCommand(options);
148
+ if (result.exitCode !== 0)
149
+ process.exitCode = result.exitCode;
150
+ });
151
+ loopCmd
152
+ .command("stop")
153
+ .description("Stop the running loop")
154
+ .option("--now", "Skip the grace period")
155
+ .option("-d, --project-dir <dir>", "Project directory")
156
+ .action(async (options) => {
157
+ const { loopStopCommand } = await import("./commands/loop.js");
158
+ const result = await loopStopCommand(options);
159
+ if (result.exitCode !== 0)
160
+ process.exitCode = result.exitCode;
161
+ });
162
+ loopCmd
163
+ .command("status")
164
+ .description("Show the current loop run")
165
+ .option("-d, --project-dir <dir>", "Project directory")
166
+ .action(async (options) => {
167
+ const { loopStatusCommand } = await import("./commands/loop.js");
168
+ const result = await loopStatusCommand(options);
169
+ if (result.exitCode !== 0)
170
+ process.exitCode = result.exitCode;
171
+ });
172
+ loopCmd
173
+ .command("clean")
174
+ .description("Remove the loop worktree and stale state")
175
+ .option("--branch", "Also delete the omh-loop branch")
176
+ .option("-d, --project-dir <dir>", "Project directory")
177
+ .action(async (options) => {
178
+ const { loopCleanCommand } = await import("./commands/loop.js");
179
+ const result = await loopCleanCommand(options);
180
+ if (result.exitCode !== 0)
181
+ process.exitCode = result.exitCode;
182
+ });
127
183
  const hookCmd = program
128
184
  .command("hook")
129
185
  .description("Manage hooks");