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 +107 -3
- package/dist/catalog/blocks/index.js +2 -0
- package/dist/catalog/blocks/loop-guard.d.ts +10 -0
- package/dist/catalog/blocks/loop-guard.js +120 -0
- package/dist/cli/commands/loop.d.ts +54 -0
- package/dist/cli/commands/loop.js +221 -0
- package/dist/cli/index.js +56 -0
- package/dist/core/generator.js +64 -3
- package/dist/core/harness-converter-v2.js +24 -0
- package/dist/core/harness-schema.d.ts +118 -1
- package/dist/core/harness-schema.js +45 -0
- package/dist/core/merged-config.d.ts +33 -0
- package/dist/core/uninstall.js +15 -0
- package/dist/generators/codex-config.js +2 -1
- package/dist/generators/loop-assets.d.ts +14 -0
- package/dist/generators/loop-assets.js +15 -0
- package/dist/loop/classify.d.ts +36 -0
- package/dist/loop/classify.js +46 -0
- package/dist/loop/ledger.d.ts +19 -0
- package/dist/loop/ledger.js +33 -0
- package/dist/loop/protocol.d.ts +15 -0
- package/dist/loop/protocol.js +81 -0
- package/dist/loop/runtime.d.ts +35 -0
- package/dist/loop/runtime.js +112 -0
- package/dist/loop/state.d.ts +79 -0
- package/dist/loop/state.js +256 -0
- package/dist/loop/stop.d.ts +36 -0
- package/dist/loop/stop.js +127 -0
- package/dist/loop/supervisor.d.ts +27 -0
- package/dist/loop/supervisor.js +188 -0
- package/dist/loop/worktree.d.ts +38 -0
- package/dist/loop/worktree.js +166 -0
- package/package.json +1 -1
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/ #
|
|
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 —
|
|
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");
|