gnhf 0.1.4 → 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 +19 -7
- package/dist/cli.mjs +125 -19
- 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,9 +50,18 @@ 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
54
|
```
|
|
55
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
|
|
61
|
+
```
|
|
62
|
+
|
|
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.
|
|
64
|
+
|
|
56
65
|
## Install
|
|
57
66
|
|
|
58
67
|
**npm**
|
|
@@ -116,6 +125,7 @@ npm link
|
|
|
116
125
|
```
|
|
117
126
|
|
|
118
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
|
|
119
129
|
- **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
|
|
120
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
|
|
121
131
|
- **Resume support** — run `gnhf` while on an existing `gnhf/` branch to pick up where a previous run left off
|
|
@@ -131,10 +141,12 @@ npm link
|
|
|
131
141
|
|
|
132
142
|
### Flags
|
|
133
143
|
|
|
134
|
-
| Flag
|
|
135
|
-
|
|
|
136
|
-
| `--agent <agent>`
|
|
137
|
-
| `--
|
|
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 | |
|
|
138
150
|
|
|
139
151
|
## Configuration
|
|
140
152
|
|
|
@@ -150,7 +162,7 @@ maxConsecutiveFailures: 3
|
|
|
150
162
|
|
|
151
163
|
If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
|
|
152
164
|
|
|
153
|
-
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`.
|
|
154
166
|
|
|
155
167
|
## Development
|
|
156
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";
|
|
@@ -50,15 +50,47 @@ function loadConfig(overrides) {
|
|
|
50
50
|
}
|
|
51
51
|
//#endregion
|
|
52
52
|
//#region src/core/git.ts
|
|
53
|
+
const NOT_GIT_REPOSITORY_MESSAGE = "This command must be run inside a Git repository. Change into a repo or run \"git init\" first.";
|
|
54
|
+
function translateGitError(error) {
|
|
55
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
56
|
+
}
|
|
53
57
|
function git(args, cwd) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
try {
|
|
59
|
+
return execSync(`git ${args}`, {
|
|
60
|
+
cwd,
|
|
61
|
+
encoding: "utf-8",
|
|
62
|
+
stdio: "pipe"
|
|
63
|
+
}).trim();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw translateGitError(error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function isGitRepository(cwd) {
|
|
69
|
+
try {
|
|
70
|
+
execSync("git rev-parse --git-dir", {
|
|
71
|
+
cwd,
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
stdio: "pipe",
|
|
74
|
+
env: {
|
|
75
|
+
...process.env,
|
|
76
|
+
LC_ALL: "C"
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function ensureGitRepository(cwd) {
|
|
85
|
+
if (!isGitRepository(cwd)) throw new Error(NOT_GIT_REPOSITORY_MESSAGE);
|
|
59
86
|
}
|
|
60
87
|
function getCurrentBranch(cwd) {
|
|
61
|
-
|
|
88
|
+
ensureGitRepository(cwd);
|
|
89
|
+
try {
|
|
90
|
+
return git("symbolic-ref --short HEAD", cwd);
|
|
91
|
+
} catch {
|
|
92
|
+
return git("rev-parse --abbrev-ref HEAD", cwd);
|
|
93
|
+
}
|
|
62
94
|
}
|
|
63
95
|
function ensureCleanWorkingTree(cwd) {
|
|
64
96
|
if (git("status --porcelain", cwd)) throw new Error("Working tree is not clean. Commit or stash changes first.");
|
|
@@ -461,8 +493,10 @@ var Orchestrator = class extends EventEmitter {
|
|
|
461
493
|
runInfo;
|
|
462
494
|
cwd;
|
|
463
495
|
prompt;
|
|
496
|
+
limits;
|
|
464
497
|
stopRequested = false;
|
|
465
498
|
activeAbortController = null;
|
|
499
|
+
pendingAbortReason = null;
|
|
466
500
|
state = {
|
|
467
501
|
status: "running",
|
|
468
502
|
currentIteration: 0,
|
|
@@ -477,13 +511,14 @@ var Orchestrator = class extends EventEmitter {
|
|
|
477
511
|
waitingUntil: null,
|
|
478
512
|
lastMessage: null
|
|
479
513
|
};
|
|
480
|
-
constructor(config, agent, runInfo, prompt, cwd, startIteration = 0) {
|
|
514
|
+
constructor(config, agent, runInfo, prompt, cwd, startIteration = 0, limits = {}) {
|
|
481
515
|
super();
|
|
482
516
|
this.config = config;
|
|
483
517
|
this.agent = agent;
|
|
484
518
|
this.runInfo = runInfo;
|
|
485
519
|
this.prompt = prompt;
|
|
486
520
|
this.cwd = cwd;
|
|
521
|
+
this.limits = limits;
|
|
487
522
|
this.state.currentIteration = startIteration;
|
|
488
523
|
this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
|
|
489
524
|
}
|
|
@@ -503,6 +538,11 @@ var Orchestrator = class extends EventEmitter {
|
|
|
503
538
|
this.state.status = "running";
|
|
504
539
|
this.emit("state", this.getState());
|
|
505
540
|
while (!this.stopRequested) {
|
|
541
|
+
const preIterationAbortReason = this.getPreIterationAbortReason();
|
|
542
|
+
if (preIterationAbortReason) {
|
|
543
|
+
this.abort(preIterationAbortReason);
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
506
546
|
this.state.currentIteration++;
|
|
507
547
|
this.state.status = "running";
|
|
508
548
|
this.emit("iteration:start", this.state.currentIteration);
|
|
@@ -512,15 +552,22 @@ var Orchestrator = class extends EventEmitter {
|
|
|
512
552
|
runId: this.runInfo.runId,
|
|
513
553
|
prompt: this.prompt
|
|
514
554
|
});
|
|
515
|
-
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;
|
|
516
561
|
this.state.iterations.push(record);
|
|
517
562
|
this.emit("iteration:end", record);
|
|
518
563
|
this.emit("state", this.getState());
|
|
564
|
+
const postIterationAbortReason = this.getPostIterationAbortReason();
|
|
565
|
+
if (postIterationAbortReason) {
|
|
566
|
+
this.abort(postIterationAbortReason);
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
519
569
|
if (this.state.consecutiveFailures >= this.config.maxConsecutiveFailures) {
|
|
520
|
-
this.
|
|
521
|
-
const reason = `${this.config.maxConsecutiveFailures} consecutive failures`;
|
|
522
|
-
this.emit("abort", reason);
|
|
523
|
-
this.emit("state", this.getState());
|
|
570
|
+
this.abort(`${this.config.maxConsecutiveFailures} consecutive failures`);
|
|
524
571
|
break;
|
|
525
572
|
}
|
|
526
573
|
if (this.state.consecutiveFailures > 0 && !this.stopRequested) {
|
|
@@ -541,10 +588,16 @@ var Orchestrator = class extends EventEmitter {
|
|
|
541
588
|
const baseInputTokens = this.state.totalInputTokens;
|
|
542
589
|
const baseOutputTokens = this.state.totalOutputTokens;
|
|
543
590
|
this.activeAbortController = new AbortController();
|
|
591
|
+
this.pendingAbortReason = null;
|
|
544
592
|
const onUsage = (usage) => {
|
|
545
593
|
this.state.totalInputTokens = baseInputTokens + usage.inputTokens;
|
|
546
594
|
this.state.totalOutputTokens = baseOutputTokens + usage.outputTokens;
|
|
547
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
|
+
}
|
|
548
601
|
};
|
|
549
602
|
const onMessage = (text) => {
|
|
550
603
|
this.state.lastMessage = text;
|
|
@@ -558,11 +611,30 @@ var Orchestrator = class extends EventEmitter {
|
|
|
558
611
|
signal: this.activeAbortController.signal,
|
|
559
612
|
logPath
|
|
560
613
|
});
|
|
561
|
-
if (result.output.success) return
|
|
562
|
-
|
|
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
|
+
};
|
|
563
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
|
+
}
|
|
564
630
|
const summary = err instanceof Error ? err.message : String(err);
|
|
565
|
-
return
|
|
631
|
+
return {
|
|
632
|
+
type: "completed",
|
|
633
|
+
record: this.recordFailure(`[ERROR] ${summary}`, summary, [])
|
|
634
|
+
};
|
|
635
|
+
} finally {
|
|
636
|
+
this.activeAbortController = null;
|
|
637
|
+
this.pendingAbortReason = null;
|
|
566
638
|
}
|
|
567
639
|
}
|
|
568
640
|
recordSuccess(output) {
|
|
@@ -608,6 +680,27 @@ var Orchestrator = class extends EventEmitter {
|
|
|
608
680
|
});
|
|
609
681
|
});
|
|
610
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
|
+
}
|
|
611
704
|
};
|
|
612
705
|
//#endregion
|
|
613
706
|
//#region src/mock-orchestrator.ts
|
|
@@ -1199,6 +1292,16 @@ function slugifyPrompt(prompt) {
|
|
|
1199
1292
|
//#endregion
|
|
1200
1293
|
//#region src/cli.ts
|
|
1201
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
|
+
}
|
|
1301
|
+
function humanizeErrorMessage(message) {
|
|
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.";
|
|
1303
|
+
return message;
|
|
1304
|
+
}
|
|
1202
1305
|
function initializeNewBranch(prompt, cwd) {
|
|
1203
1306
|
ensureCleanWorkingTree(cwd);
|
|
1204
1307
|
const baseCommit = getHeadCommit(cwd);
|
|
@@ -1220,7 +1323,7 @@ function ask(question) {
|
|
|
1220
1323
|
});
|
|
1221
1324
|
}
|
|
1222
1325
|
const program = new Command();
|
|
1223
|
-
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) => {
|
|
1224
1327
|
if (options.mock) {
|
|
1225
1328
|
const mock = new MockOrchestrator();
|
|
1226
1329
|
enterAltScreen();
|
|
@@ -1271,7 +1374,10 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
|
|
|
1271
1374
|
}
|
|
1272
1375
|
runInfo = initializeNewBranch(prompt, cwd);
|
|
1273
1376
|
}
|
|
1274
|
-
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
|
+
});
|
|
1275
1381
|
enterAltScreen();
|
|
1276
1382
|
const renderer = new Renderer(orchestrator, prompt);
|
|
1277
1383
|
renderer.start();
|
|
@@ -1292,7 +1398,7 @@ function exitAltScreen() {
|
|
|
1292
1398
|
process$1.stdout.write("\x1B[?1049l");
|
|
1293
1399
|
}
|
|
1294
1400
|
function die(message) {
|
|
1295
|
-
console.error(`\n gnhf: ${message}\n`);
|
|
1401
|
+
console.error(`\n gnhf: ${humanizeErrorMessage(message)}\n`);
|
|
1296
1402
|
process$1.exit(1);
|
|
1297
1403
|
}
|
|
1298
1404
|
try {
|