gnhf 0.1.33 → 0.1.35

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 +3 -1
  2. package/dist/cli.mjs +276 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -47,6 +47,7 @@ You wake up to a branch full of clean work and a log of everything that happened
47
47
  - **Dead simple** — one command starts an autonomous loop that runs until you request stop or a configured runtime cap is reached
48
48
  - **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries; retryable hard agent errors back off exponentially while agent-reported failures continue immediately
49
49
  - **Live terminal title** — interactive runs keep your terminal title updated with live status, token totals, and commit count, then clear or restore it on exit depending on terminal support; token totals prefixed with `~` are estimates
50
+ - **Exit summary**: every run ends with a permanent summary covering elapsed time, branch, iterations, tokens, branch diff stats, local notes/log paths, and review commands
50
51
  - **Agent-agnostic**: works with Claude Code, Codex, Rovo Dev, OpenCode, GitHub Copilot CLI, Pi, or ACP targets out of the box
51
52
 
52
53
  ## Quick Start
@@ -137,11 +138,12 @@ npm link
137
138
  └──────────────────────────────────────┘
138
139
  ```
139
140
 
140
- - **Incremental commits** - each successful iteration is a separate unsigned git commit, so you can cherry-pick or revert individual changes without GPG or SSH signing prompts blocking the run
141
+ - **Incremental commits** - each successful iteration is a separate unsigned git commit, so you can cherry-pick or revert individual changes without GPG or SSH signing prompts blocking the run; if the first commit attempt fails, gnhf re-stages changes and retries with `--no-verify` so hook-mutated work is not stranded
141
142
  - **Failure handling** - all failed iterations are rolled back with `git reset --hard`; agent-reported failures proceed to the next iteration immediately, retryable hard agent errors use exponential backoff, and permanent agent errors such as Claude low credit balance abort immediately and print the run log path. Complete no-op iterations are reported as failures and count toward the consecutive-failure abort limit.
142
143
  - **Runtime caps** - `--max-iterations` stops before the next iteration begins, `--max-tokens` can abort mid-iteration once reported usage reaches the cap, and `--stop-when` ends the loop after an iteration whose agent output reports the natural-language condition is met; resumed runs reuse the saved stop condition unless you pass a new value, or `--stop-when ""` to clear it; uncommitted work is rolled back in either case, and in the interactive TUI the final state remains visible until you press Ctrl+C to exit
143
144
  - **Iteration finalization** - agents are expected to finish validation, stop any background processes they started, and only then emit the final JSON result for the iteration
144
145
  - **Graceful interrupts** - in the interactive TUI, the first Ctrl+C requests a graceful stop and lets the current iteration finish (or ends backoff early), the second Ctrl+C force-stops immediately, and `SIGTERM` also force-stops immediately
146
+ - **Exit summary** - after shutdown cleanup, gnhf prints a permanent stdout summary with the final branch, elapsed time, iteration and token totals, branch diff stats, notes/debug-log paths, and review commands
145
147
  - **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
146
148
  - **Local run metadata** — gnhf stores prompt, notes, stop conditions, and commit-message convention metadata under `.gnhf/runs/` and ignores it locally, so your branch only contains intentional work
147
149
  - **Resume support** — run `gnhf` while on an existing `gnhf/` branch to pick up where a previous run left off; if you provide a different prompt, gnhf asks whether to update the saved prompt and continue with the existing history, start a new branch, or quit. New runs whose generated branch already exists use a numeric suffix such as `gnhf/<slug>-1`.
package/dist/cli.mjs CHANGED
@@ -505,18 +505,84 @@ function getBranchCommitCount(baseCommit, cwd) {
505
505
  `${baseCommit}..HEAD`
506
506
  ], cwd), 10);
507
507
  }
508
+ function emptyBranchDiffStats$1() {
509
+ return {
510
+ commits: 0,
511
+ filesChanged: 0,
512
+ filesAdded: 0,
513
+ filesUpdated: 0,
514
+ filesDeleted: 0,
515
+ filesRenamed: 0,
516
+ binaryFiles: 0,
517
+ linesAdded: 0,
518
+ linesDeleted: 0
519
+ };
520
+ }
521
+ function getBranchDiffStats(baseCommit, cwd) {
522
+ if (!baseCommit) return emptyBranchDiffStats$1();
523
+ const range = `${baseCommit}..HEAD`;
524
+ const stats = emptyBranchDiffStats$1();
525
+ stats.commits = Number.parseInt(git([
526
+ "rev-list",
527
+ "--count",
528
+ "--first-parent",
529
+ range
530
+ ], cwd), 10);
531
+ const nameStatus = git([
532
+ "diff",
533
+ "--name-status",
534
+ range
535
+ ], cwd);
536
+ for (const line of nameStatus.split("\n")) {
537
+ if (!line) continue;
538
+ const [status] = line.split(" ");
539
+ stats.filesChanged++;
540
+ if (status === "A") stats.filesAdded++;
541
+ else if (status === "D") stats.filesDeleted++;
542
+ else if (status?.startsWith("R")) {
543
+ stats.filesUpdated++;
544
+ stats.filesRenamed++;
545
+ } else stats.filesUpdated++;
546
+ }
547
+ const numstat = git([
548
+ "diff",
549
+ "--numstat",
550
+ range
551
+ ], cwd);
552
+ for (const line of numstat.split("\n")) {
553
+ if (!line) continue;
554
+ const [added, deleted] = line.split(" ");
555
+ if (added === "-" || deleted === "-") {
556
+ stats.binaryFiles++;
557
+ continue;
558
+ }
559
+ stats.linesAdded += Number.parseInt(added ?? "0", 10) || 0;
560
+ stats.linesDeleted += Number.parseInt(deleted ?? "0", 10) || 0;
561
+ }
562
+ return stats;
563
+ }
508
564
  function commitAll(message, cwd) {
565
+ const commitArgs = [
566
+ "-c",
567
+ "commit.gpgsign=false",
568
+ "-c",
569
+ "tag.gpgsign=false",
570
+ "commit",
571
+ "-m",
572
+ message
573
+ ];
509
574
  git(["add", "-A"], cwd);
575
+ let firstError;
510
576
  try {
511
- git([
512
- "-c",
513
- "commit.gpgsign=false",
514
- "-c",
515
- "tag.gpgsign=false",
516
- "commit",
517
- "-m",
518
- message
519
- ], cwd);
577
+ git(commitArgs, cwd);
578
+ return;
579
+ } catch (error) {
580
+ firstError = error;
581
+ }
582
+ git(["add", "-A"], cwd);
583
+ try {
584
+ git([...commitArgs, "--no-verify"], cwd);
585
+ appendDebugLog("git:commit:no-verify-fallback", { firstError: serializeError(firstError) });
520
586
  } catch {}
521
587
  }
522
588
  function resetHard(cwd) {
@@ -16784,6 +16850,158 @@ var Orchestrator = class extends EventEmitter {
16784
16850
  }
16785
16851
  };
16786
16852
  //#endregion
16853
+ //#region src/utils/tokens.ts
16854
+ function formatTokens(count) {
16855
+ if (count >= 0xe8d4a51000) return `${(count / 0xe8d4a51000).toFixed(1)}T`;
16856
+ if (count >= 1e9) return `${(count / 1e9).toFixed(1)}B`;
16857
+ if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M`;
16858
+ if (count >= 1e3) return `${Math.round(count / 1e3)}K`;
16859
+ return String(count);
16860
+ }
16861
+ //#endregion
16862
+ //#region src/core/exit-summary.ts
16863
+ const MIN_CARD_WIDTH = 62;
16864
+ const LABEL_WIDTH = 16;
16865
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
16866
+ const ANSI_TOKEN_RE = /\x1b\[[0-9;]*m/g;
16867
+ const NO_MISTAKES_URL = "https://github.com/kunchenguid/no-mistakes";
16868
+ function stripExitSummaryAnsi(text) {
16869
+ return text.replace(ANSI_RE, "");
16870
+ }
16871
+ function makeStyles(color) {
16872
+ const wrap = (open, text) => color ? `${open}${text}\x1b[0m` : text;
16873
+ return {
16874
+ dim: (text) => wrap("\x1B[2m", text),
16875
+ bold: (text) => wrap("\x1B[1m", text),
16876
+ cyan: (text) => wrap("\x1B[36m", text),
16877
+ yellow: (text) => wrap("\x1B[33m", text),
16878
+ green: (text) => wrap("\x1B[32m", text),
16879
+ red: (text) => wrap("\x1B[31m", text),
16880
+ magenta: (text) => wrap("\x1B[35m", text),
16881
+ blueUnderline: (text) => wrap("\x1B[34;4m", text)
16882
+ };
16883
+ }
16884
+ function visibleLength(text) {
16885
+ return stripExitSummaryAnsi(text).length;
16886
+ }
16887
+ function padVisible(text, width) {
16888
+ return text + " ".repeat(Math.max(0, width - visibleLength(text)));
16889
+ }
16890
+ function truncateVisible(text, width) {
16891
+ if (visibleLength(text) <= width) return text;
16892
+ if (width <= 0) return "";
16893
+ const targetWidth = Math.max(0, width - 1);
16894
+ let output = "";
16895
+ let visible = 0;
16896
+ let index = 0;
16897
+ let hasActiveStyle = false;
16898
+ const finish = () => `${output}…${hasActiveStyle ? "\x1B[0m" : ""}`;
16899
+ for (const match of text.matchAll(ANSI_TOKEN_RE)) {
16900
+ const chunk = text.slice(index, match.index);
16901
+ for (const char of chunk) {
16902
+ if (visible >= targetWidth) return finish();
16903
+ output += char;
16904
+ visible += 1;
16905
+ }
16906
+ output += match[0];
16907
+ hasActiveStyle = match[0] !== "\x1B[0m";
16908
+ index = match.index + match[0].length;
16909
+ }
16910
+ for (const char of text.slice(index)) {
16911
+ if (visible >= targetWidth) return finish();
16912
+ output += char;
16913
+ visible += 1;
16914
+ }
16915
+ return finish();
16916
+ }
16917
+ function formatDuration(ms) {
16918
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
16919
+ const hours = Math.floor(totalSeconds / 3600);
16920
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
16921
+ const seconds = totalSeconds % 60;
16922
+ if (hours > 0) return `${hours}h ${minutes}m`;
16923
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
16924
+ return `${seconds}s`;
16925
+ }
16926
+ function formatNumber(value) {
16927
+ return new Intl.NumberFormat("en-US").format(value);
16928
+ }
16929
+ function formatTokenCount$1(value, suffix, estimated) {
16930
+ return `${estimated ? "~" : ""}${formatTokens(value)} ${suffix}`;
16931
+ }
16932
+ function plural(value, singular, pluralText = `${singular}s`) {
16933
+ return `${formatNumber(value)} ${value === 1 ? singular : pluralText}`;
16934
+ }
16935
+ function metricLine(label, columns) {
16936
+ return ` ${padVisible(`${padVisible(label, LABEL_WIDTH)}${columns[0] ?? ""}`, 30)}${padVisible(columns[1] ?? "", 13)}${columns[2] ?? ""}`;
16937
+ }
16938
+ function commandLine(label, command) {
16939
+ return ` ${padVisible(label, LABEL_WIDTH)}${command}`;
16940
+ }
16941
+ function continuationLine(text) {
16942
+ return ` ${"".padEnd(LABEL_WIDTH)}${text}`;
16943
+ }
16944
+ function resolveCardWidth(contents, terminalColumns) {
16945
+ const contentWidth = Math.max(...contents.map(visibleLength));
16946
+ const desiredWidth = Math.max(MIN_CARD_WIDTH, contentWidth + 4);
16947
+ const columns = terminalColumns && terminalColumns > 0 ? terminalColumns : void 0;
16948
+ return Math.max(4, columns ? Math.min(desiredWidth, columns) : desiredWidth);
16949
+ }
16950
+ function cardBorder(left, right, width, dim) {
16951
+ return dim(`${left}${"─".repeat(Math.max(0, width - 2))}${right}`);
16952
+ }
16953
+ function cardLine(content, width, dim) {
16954
+ const contentWidth = Math.max(0, width - 4);
16955
+ return `${dim("│ ")}${padVisible(truncateVisible(content, contentWidth), contentWidth)}${dim(" │")}`;
16956
+ }
16957
+ function renderExitSummary(options) {
16958
+ const s = makeStyles(options.color);
16959
+ const elapsed = formatDuration(options.elapsedMs);
16960
+ const stopped = options.status === "aborted";
16961
+ const title = stopped ? `${s.red("×")} ${s.bold("gnhf stopped")}` : `${s.cyan("✦")} ${s.bold("gnhf wrapped")}`;
16962
+ const subtitle = stopped ? `${s.cyan(options.agentName)} ran for ${s.yellow(elapsed)} before: ${options.abortReason ?? options.status}` : `${s.cyan(options.agentName)} worked for ${s.yellow(elapsed)} on ${s.magenta(options.branchName)}`;
16963
+ const cardWidth = resolveCardWidth([title, ` ${subtitle}`], options.terminalColumns);
16964
+ const rolledBack = `${options.failCount} rolled back`;
16965
+ const inputTokens = formatTokenCount$1(options.totalInputTokens, "in", options.tokensEstimated);
16966
+ const outputTokens = formatTokenCount$1(options.totalOutputTokens, "out", options.tokensEstimated);
16967
+ const commits = plural(options.commitCount, "commit");
16968
+ const linesAdded = `+${formatNumber(options.diffStats.linesAdded)}`;
16969
+ const linesDeleted = `-${formatNumber(options.diffStats.linesDeleted)}`;
16970
+ return `\n${[
16971
+ cardBorder("╭", "╮", cardWidth, s.dim),
16972
+ cardLine(title, cardWidth, s.dim),
16973
+ cardLine(` ${subtitle}`, cardWidth, s.dim),
16974
+ cardBorder("╰", "╯", cardWidth, s.dim),
16975
+ "",
16976
+ metricLine(s.dim("iterations"), [
16977
+ `${s.bold(String(options.iterations))} total`,
16978
+ s.green(`${options.successCount} good`),
16979
+ stopped ? s.red(rolledBack) : s.yellow(rolledBack)
16980
+ ]),
16981
+ metricLine(s.dim("tokens"), [s.bold(inputTokens), s.bold(outputTokens)]),
16982
+ metricLine(s.dim("branch diff"), [
16983
+ s.bold(commits),
16984
+ s.green(linesAdded),
16985
+ s.red(linesDeleted)
16986
+ ]),
16987
+ metricLine(s.dim("files"), [
16988
+ `${options.diffStats.filesAdded} added`,
16989
+ `${options.diffStats.filesUpdated} updated`,
16990
+ `${options.diffStats.filesDeleted} deleted`
16991
+ ]),
16992
+ "",
16993
+ commandLine(s.dim("notes"), options.notesPath),
16994
+ commandLine(s.dim("debug log"), options.logPath),
16995
+ "",
16996
+ commandLine(s.dim("next steps"), s.cyan(`git log --oneline ${options.baseRef}..HEAD`)),
16997
+ continuationLine(s.cyan(`git diff --stat ${options.baseRef}..HEAD`)),
16998
+ continuationLine(s.cyan("gh pr create")),
16999
+ "",
17000
+ commandLine(s.dim("too much"), `${s.cyan("git push no-mistakes")}:`),
17001
+ commandLine(s.dim("to review?"), s.blueUnderline(NO_MISTAKES_URL))
17002
+ ].join("\n")}\n`;
17003
+ }
17004
+ //#endregion
16787
17005
  //#region src/mock-orchestrator.ts
16788
17006
  function mockIter(n, success, summary, agoMs) {
16789
17007
  return {
@@ -16997,15 +17215,6 @@ function formatElapsed(ms) {
16997
17215
  return `${String(Math.floor(s / 3600)).padStart(2, "0")}:${String(Math.floor(s % 3600 / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
16998
17216
  }
16999
17217
  //#endregion
17000
- //#region src/utils/tokens.ts
17001
- function formatTokens(count) {
17002
- if (count >= 0xe8d4a51000) return `${(count / 0xe8d4a51000).toFixed(1)}T`;
17003
- if (count >= 1e9) return `${(count / 1e9).toFixed(1)}B`;
17004
- if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M`;
17005
- if (count >= 1e3) return `${Math.round(count / 1e3)}K`;
17006
- return String(count);
17007
- }
17008
- //#endregion
17009
17218
  //#region src/utils/terminal-width.ts
17010
17219
  const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
17011
17220
  const MARK_REGEX = /\p{Mark}/u;
@@ -17656,6 +17865,22 @@ function getNativeAgentName(spec) {
17656
17865
  function getTelemetryAgent(spec) {
17657
17866
  return redactAgentSpecForLogs(spec);
17658
17867
  }
17868
+ function shouldUseColor() {
17869
+ return process$1.stdout.isTTY === true && process$1.env.NO_COLOR === void 0 && process$1.env.TERM !== "dumb";
17870
+ }
17871
+ function emptyBranchDiffStats(commitCount) {
17872
+ return {
17873
+ commits: commitCount,
17874
+ filesChanged: 0,
17875
+ filesAdded: 0,
17876
+ filesUpdated: 0,
17877
+ filesDeleted: 0,
17878
+ filesRenamed: 0,
17879
+ binaryFiles: 0,
17880
+ linesAdded: 0,
17881
+ linesDeleted: 0
17882
+ };
17883
+ }
17659
17884
  function redactDebugArgs(args) {
17660
17885
  const redacted = [...args];
17661
17886
  for (let i = 0; i < redacted.length; i += 1) {
@@ -18135,6 +18360,38 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
18135
18360
  }
18136
18361
  {
18137
18362
  const finalState = orchestrator.getState();
18363
+ let finalBranchName = "HEAD";
18364
+ try {
18365
+ finalBranchName = getCurrentBranch(effectiveCwd);
18366
+ } catch (error) {
18367
+ appendDebugLog("summary:branch-error", { error: serializeError(error) });
18368
+ }
18369
+ let diffStats = emptyBranchDiffStats(finalState.commitCount);
18370
+ try {
18371
+ diffStats = getBranchDiffStats(runInfo.baseCommit, effectiveCwd);
18372
+ } catch (error) {
18373
+ appendDebugLog("summary:diff-stats-error", { error: serializeError(error) });
18374
+ }
18375
+ const exitSummary = renderExitSummary({
18376
+ agentName: redactAgentSpecForLogs(config.agent),
18377
+ branchName: finalBranchName,
18378
+ elapsedMs: Date.now() - finalState.startTime.getTime(),
18379
+ status: finalState.status,
18380
+ abortReason: finalState.lastAgentError ?? finalState.lastMessage,
18381
+ iterations: finalState.currentIteration,
18382
+ successCount: finalState.successCount,
18383
+ failCount: finalState.failCount,
18384
+ totalInputTokens: finalState.totalInputTokens,
18385
+ totalOutputTokens: finalState.totalOutputTokens,
18386
+ tokensEstimated: finalState.tokensEstimated,
18387
+ commitCount: finalState.commitCount,
18388
+ notesPath: runInfo.notesPath,
18389
+ logPath: runInfo.logPath,
18390
+ baseRef: runInfo.baseCommit.slice(0, 12) || runInfo.baseCommit,
18391
+ diffStats,
18392
+ color: shouldUseColor(),
18393
+ terminalColumns: process$1.stdout.columns
18394
+ });
18138
18395
  appendDebugLog("run:complete", {
18139
18396
  signal: shutdownSignal,
18140
18397
  status: finalState.status,
@@ -18172,6 +18429,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
18172
18429
  worktreeCleanup = null;
18173
18430
  appendDebugLog("worktree:cleaned-up", { worktreePath });
18174
18431
  }
18432
+ process$1.stdout.write(exitSummary);
18175
18433
  }
18176
18434
  if (shutdownSignal) process$1.exit(getSignalExitCode(shutdownSignal));
18177
18435
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {