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.
Files changed (3) hide show
  1. package/README.md +19 -7
  2. package/dist/cli.mjs +125 -19
  3. 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
- # go to sleep
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 | Description | Default |
135
- | ----------------- | ---------------------------------- | ---------------------- |
136
- | `--agent <agent>` | Agent to use (`claude` or `codex`) | config file (`claude`) |
137
- | `--version` | Show version | |
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
- return execSync(`git ${args}`, {
55
- cwd,
56
- encoding: "utf-8",
57
- stdio: "pipe"
58
- }).trim();
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
- return git("rev-parse --abbrev-ref HEAD", cwd);
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 record = await this.runIteration(iterationPrompt);
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.state.status = "aborted";
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 this.recordSuccess(result.output);
562
- return this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, result.output.key_learnings);
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 this.recordFailure(`[ERROR] ${summary}`, summary, []);
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {