gnhf 0.1.5 → 0.1.6
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 +17 -7
- package/dist/cli.mjs +82 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
gnhf is a [ralph](https://ghuntley.com/ralph/), [autoresearch](https://github.com/karpathy/autoresearch)-style orchestrator that keeps your agents running while you sleep — each iteration makes one small, committed, documented change towards an objective.
|
|
43
43
|
You wake up to a branch full of clean work and a log of everything that happened.
|
|
44
44
|
|
|
45
|
-
- **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C
|
|
45
|
+
- **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C or a configured runtime cap is reached
|
|
46
46
|
- **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries and exponential backoff
|
|
47
47
|
- **Agent-agnostic** — works with Claude Code or Codex out of the box
|
|
48
48
|
|
|
@@ -50,7 +50,14 @@ You wake up to a branch full of clean work and a log of everything that happened
|
|
|
50
50
|
|
|
51
51
|
```sh
|
|
52
52
|
$ gnhf "reduce complexity of the codebase without changing functionality"
|
|
53
|
-
#
|
|
53
|
+
# have a good sleep
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
$ gnhf "reduce complexity of the codebase without changing functionality" \
|
|
58
|
+
--max-iterations 10 \
|
|
59
|
+
--max-tokens 5000000
|
|
60
|
+
# have a good nap
|
|
54
61
|
```
|
|
55
62
|
|
|
56
63
|
Run `gnhf` from inside a Git repository with a clean working tree. If you are starting from a plain directory, run `git init` first.
|
|
@@ -118,6 +125,7 @@ npm link
|
|
|
118
125
|
```
|
|
119
126
|
|
|
120
127
|
- **Incremental commits** — each successful iteration is a separate git commit, so you can cherry-pick or revert individual changes
|
|
128
|
+
- **Runtime caps** — `--max-iterations` stops before the next iteration begins, while `--max-tokens` can abort mid-iteration once reported usage reaches the cap; uncommitted work is rolled back in either case
|
|
121
129
|
- **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
|
|
122
130
|
- **Local run metadata** — gnhf stores prompt, notes, and resume metadata under `.gnhf/runs/` and ignores it locally, so your branch only contains intentional work
|
|
123
131
|
- **Resume support** — run `gnhf` while on an existing `gnhf/` branch to pick up where a previous run left off
|
|
@@ -133,10 +141,12 @@ npm link
|
|
|
133
141
|
|
|
134
142
|
### Flags
|
|
135
143
|
|
|
136
|
-
| Flag
|
|
137
|
-
|
|
|
138
|
-
| `--agent <agent>`
|
|
139
|
-
| `--
|
|
144
|
+
| Flag | Description | Default |
|
|
145
|
+
| ---------------------- | ----------------------------------------- | ---------------------- |
|
|
146
|
+
| `--agent <agent>` | Agent to use (`claude` or `codex`) | config file (`claude`) |
|
|
147
|
+
| `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
|
|
148
|
+
| `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
|
|
149
|
+
| `--version` | Show version | |
|
|
140
150
|
|
|
141
151
|
## Configuration
|
|
142
152
|
|
|
@@ -152,7 +162,7 @@ maxConsecutiveFailures: 3
|
|
|
152
162
|
|
|
153
163
|
If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
|
|
154
164
|
|
|
155
|
-
CLI flags override config file values.
|
|
165
|
+
CLI flags override config file values. The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
|
|
156
166
|
|
|
157
167
|
## Development
|
|
158
168
|
|
package/dist/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import process$1 from "node:process";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
|
-
import { Command } from "commander";
|
|
5
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
6
6
|
import { dirname, isAbsolute, join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import yaml from "js-yaml";
|
|
@@ -493,8 +493,10 @@ var Orchestrator = class extends EventEmitter {
|
|
|
493
493
|
runInfo;
|
|
494
494
|
cwd;
|
|
495
495
|
prompt;
|
|
496
|
+
limits;
|
|
496
497
|
stopRequested = false;
|
|
497
498
|
activeAbortController = null;
|
|
499
|
+
pendingAbortReason = null;
|
|
498
500
|
state = {
|
|
499
501
|
status: "running",
|
|
500
502
|
currentIteration: 0,
|
|
@@ -509,13 +511,14 @@ var Orchestrator = class extends EventEmitter {
|
|
|
509
511
|
waitingUntil: null,
|
|
510
512
|
lastMessage: null
|
|
511
513
|
};
|
|
512
|
-
constructor(config, agent, runInfo, prompt, cwd, startIteration = 0) {
|
|
514
|
+
constructor(config, agent, runInfo, prompt, cwd, startIteration = 0, limits = {}) {
|
|
513
515
|
super();
|
|
514
516
|
this.config = config;
|
|
515
517
|
this.agent = agent;
|
|
516
518
|
this.runInfo = runInfo;
|
|
517
519
|
this.prompt = prompt;
|
|
518
520
|
this.cwd = cwd;
|
|
521
|
+
this.limits = limits;
|
|
519
522
|
this.state.currentIteration = startIteration;
|
|
520
523
|
this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
|
|
521
524
|
}
|
|
@@ -535,6 +538,11 @@ var Orchestrator = class extends EventEmitter {
|
|
|
535
538
|
this.state.status = "running";
|
|
536
539
|
this.emit("state", this.getState());
|
|
537
540
|
while (!this.stopRequested) {
|
|
541
|
+
const preIterationAbortReason = this.getPreIterationAbortReason();
|
|
542
|
+
if (preIterationAbortReason) {
|
|
543
|
+
this.abort(preIterationAbortReason);
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
538
546
|
this.state.currentIteration++;
|
|
539
547
|
this.state.status = "running";
|
|
540
548
|
this.emit("iteration:start", this.state.currentIteration);
|
|
@@ -544,15 +552,22 @@ var Orchestrator = class extends EventEmitter {
|
|
|
544
552
|
runId: this.runInfo.runId,
|
|
545
553
|
prompt: this.prompt
|
|
546
554
|
});
|
|
547
|
-
const
|
|
555
|
+
const result = await this.runIteration(iterationPrompt);
|
|
556
|
+
if (result.type === "aborted") {
|
|
557
|
+
this.abort(result.reason);
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
const { record } = result;
|
|
548
561
|
this.state.iterations.push(record);
|
|
549
562
|
this.emit("iteration:end", record);
|
|
550
563
|
this.emit("state", this.getState());
|
|
564
|
+
const postIterationAbortReason = this.getPostIterationAbortReason();
|
|
565
|
+
if (postIterationAbortReason) {
|
|
566
|
+
this.abort(postIterationAbortReason);
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
551
569
|
if (this.state.consecutiveFailures >= this.config.maxConsecutiveFailures) {
|
|
552
|
-
this.
|
|
553
|
-
const reason = `${this.config.maxConsecutiveFailures} consecutive failures`;
|
|
554
|
-
this.emit("abort", reason);
|
|
555
|
-
this.emit("state", this.getState());
|
|
570
|
+
this.abort(`${this.config.maxConsecutiveFailures} consecutive failures`);
|
|
556
571
|
break;
|
|
557
572
|
}
|
|
558
573
|
if (this.state.consecutiveFailures > 0 && !this.stopRequested) {
|
|
@@ -573,10 +588,16 @@ var Orchestrator = class extends EventEmitter {
|
|
|
573
588
|
const baseInputTokens = this.state.totalInputTokens;
|
|
574
589
|
const baseOutputTokens = this.state.totalOutputTokens;
|
|
575
590
|
this.activeAbortController = new AbortController();
|
|
591
|
+
this.pendingAbortReason = null;
|
|
576
592
|
const onUsage = (usage) => {
|
|
577
593
|
this.state.totalInputTokens = baseInputTokens + usage.inputTokens;
|
|
578
594
|
this.state.totalOutputTokens = baseOutputTokens + usage.outputTokens;
|
|
579
595
|
this.emit("state", this.getState());
|
|
596
|
+
const reason = this.getTokenAbortReason();
|
|
597
|
+
if (reason && this.activeAbortController && !this.activeAbortController.signal.aborted) {
|
|
598
|
+
this.pendingAbortReason = reason;
|
|
599
|
+
this.activeAbortController.abort();
|
|
600
|
+
}
|
|
580
601
|
};
|
|
581
602
|
const onMessage = (text) => {
|
|
582
603
|
this.state.lastMessage = text;
|
|
@@ -590,11 +611,30 @@ var Orchestrator = class extends EventEmitter {
|
|
|
590
611
|
signal: this.activeAbortController.signal,
|
|
591
612
|
logPath
|
|
592
613
|
});
|
|
593
|
-
if (result.output.success) return
|
|
594
|
-
|
|
614
|
+
if (result.output.success) return {
|
|
615
|
+
type: "completed",
|
|
616
|
+
record: this.recordSuccess(result.output)
|
|
617
|
+
};
|
|
618
|
+
return {
|
|
619
|
+
type: "completed",
|
|
620
|
+
record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, result.output.key_learnings)
|
|
621
|
+
};
|
|
595
622
|
} catch (err) {
|
|
623
|
+
if (this.pendingAbortReason && err instanceof Error && err.message === "Agent was aborted") {
|
|
624
|
+
resetHard(this.cwd);
|
|
625
|
+
return {
|
|
626
|
+
type: "aborted",
|
|
627
|
+
reason: this.pendingAbortReason
|
|
628
|
+
};
|
|
629
|
+
}
|
|
596
630
|
const summary = err instanceof Error ? err.message : String(err);
|
|
597
|
-
return
|
|
631
|
+
return {
|
|
632
|
+
type: "completed",
|
|
633
|
+
record: this.recordFailure(`[ERROR] ${summary}`, summary, [])
|
|
634
|
+
};
|
|
635
|
+
} finally {
|
|
636
|
+
this.activeAbortController = null;
|
|
637
|
+
this.pendingAbortReason = null;
|
|
598
638
|
}
|
|
599
639
|
}
|
|
600
640
|
recordSuccess(output) {
|
|
@@ -640,6 +680,27 @@ var Orchestrator = class extends EventEmitter {
|
|
|
640
680
|
});
|
|
641
681
|
});
|
|
642
682
|
}
|
|
683
|
+
getPreIterationAbortReason() {
|
|
684
|
+
if (this.limits.maxIterations !== void 0 && this.state.currentIteration >= this.limits.maxIterations) return `max iterations reached (${this.limits.maxIterations})`;
|
|
685
|
+
return this.getTokenAbortReason();
|
|
686
|
+
}
|
|
687
|
+
getPostIterationAbortReason() {
|
|
688
|
+
if (this.limits.maxIterations !== void 0 && this.state.currentIteration >= this.limits.maxIterations) return `max iterations reached (${this.limits.maxIterations})`;
|
|
689
|
+
return this.getTokenAbortReason();
|
|
690
|
+
}
|
|
691
|
+
getTokenAbortReason() {
|
|
692
|
+
if (this.limits.maxTokens === void 0) return null;
|
|
693
|
+
const totalTokens = this.state.totalInputTokens + this.state.totalOutputTokens;
|
|
694
|
+
if (totalTokens < this.limits.maxTokens) return null;
|
|
695
|
+
return `max tokens reached (${totalTokens}/${this.limits.maxTokens})`;
|
|
696
|
+
}
|
|
697
|
+
abort(reason) {
|
|
698
|
+
this.state.status = "aborted";
|
|
699
|
+
this.state.lastMessage = reason;
|
|
700
|
+
this.state.waitingUntil = null;
|
|
701
|
+
this.emit("abort", reason);
|
|
702
|
+
this.emit("state", this.getState());
|
|
703
|
+
}
|
|
643
704
|
};
|
|
644
705
|
//#endregion
|
|
645
706
|
//#region src/mock-orchestrator.ts
|
|
@@ -1231,6 +1292,12 @@ function slugifyPrompt(prompt) {
|
|
|
1231
1292
|
//#endregion
|
|
1232
1293
|
//#region src/cli.ts
|
|
1233
1294
|
const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
1295
|
+
function parseNonNegativeInteger(value) {
|
|
1296
|
+
if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
|
|
1297
|
+
const parsed = Number.parseInt(value, 10);
|
|
1298
|
+
if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError("must be a safe integer");
|
|
1299
|
+
return parsed;
|
|
1300
|
+
}
|
|
1234
1301
|
function humanizeErrorMessage(message) {
|
|
1235
1302
|
if (message.includes("not a git repository")) return "This command must be run inside a Git repository. Change into a repo or run \"git init\" first.";
|
|
1236
1303
|
return message;
|
|
@@ -1256,7 +1323,7 @@ function ask(question) {
|
|
|
1256
1323
|
});
|
|
1257
1324
|
}
|
|
1258
1325
|
const program = new Command();
|
|
1259
|
-
program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude or codex)").option("--mock", "", false).action(async (promptArg, options) => {
|
|
1326
|
+
program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude or codex)").option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--mock", "", false).action(async (promptArg, options) => {
|
|
1260
1327
|
if (options.mock) {
|
|
1261
1328
|
const mock = new MockOrchestrator();
|
|
1262
1329
|
enterAltScreen();
|
|
@@ -1307,7 +1374,10 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
|
|
|
1307
1374
|
}
|
|
1308
1375
|
runInfo = initializeNewBranch(prompt, cwd);
|
|
1309
1376
|
}
|
|
1310
|
-
const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration
|
|
1377
|
+
const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration, {
|
|
1378
|
+
maxIterations: options.maxIterations,
|
|
1379
|
+
maxTokens: options.maxTokens
|
|
1380
|
+
});
|
|
1311
1381
|
enterAltScreen();
|
|
1312
1382
|
const renderer = new Renderer(orchestrator, prompt);
|
|
1313
1383
|
renderer.start();
|