gnhf 0.1.33 → 0.1.34

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 +2 -0
  2. package/dist/cli.mjs +257 -9
  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
@@ -142,6 +143,7 @@ npm link
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,6 +505,62 @@ 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) {
509
565
  git(["add", "-A"], cwd);
510
566
  try {
@@ -16784,6 +16840,158 @@ var Orchestrator = class extends EventEmitter {
16784
16840
  }
16785
16841
  };
16786
16842
  //#endregion
16843
+ //#region src/utils/tokens.ts
16844
+ function formatTokens(count) {
16845
+ if (count >= 0xe8d4a51000) return `${(count / 0xe8d4a51000).toFixed(1)}T`;
16846
+ if (count >= 1e9) return `${(count / 1e9).toFixed(1)}B`;
16847
+ if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M`;
16848
+ if (count >= 1e3) return `${Math.round(count / 1e3)}K`;
16849
+ return String(count);
16850
+ }
16851
+ //#endregion
16852
+ //#region src/core/exit-summary.ts
16853
+ const MIN_CARD_WIDTH = 62;
16854
+ const LABEL_WIDTH = 16;
16855
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
16856
+ const ANSI_TOKEN_RE = /\x1b\[[0-9;]*m/g;
16857
+ const NO_MISTAKES_URL = "https://github.com/kunchenguid/no-mistakes";
16858
+ function stripExitSummaryAnsi(text) {
16859
+ return text.replace(ANSI_RE, "");
16860
+ }
16861
+ function makeStyles(color) {
16862
+ const wrap = (open, text) => color ? `${open}${text}\x1b[0m` : text;
16863
+ return {
16864
+ dim: (text) => wrap("\x1B[2m", text),
16865
+ bold: (text) => wrap("\x1B[1m", text),
16866
+ cyan: (text) => wrap("\x1B[36m", text),
16867
+ yellow: (text) => wrap("\x1B[33m", text),
16868
+ green: (text) => wrap("\x1B[32m", text),
16869
+ red: (text) => wrap("\x1B[31m", text),
16870
+ magenta: (text) => wrap("\x1B[35m", text),
16871
+ blueUnderline: (text) => wrap("\x1B[34;4m", text)
16872
+ };
16873
+ }
16874
+ function visibleLength(text) {
16875
+ return stripExitSummaryAnsi(text).length;
16876
+ }
16877
+ function padVisible(text, width) {
16878
+ return text + " ".repeat(Math.max(0, width - visibleLength(text)));
16879
+ }
16880
+ function truncateVisible(text, width) {
16881
+ if (visibleLength(text) <= width) return text;
16882
+ if (width <= 0) return "";
16883
+ const targetWidth = Math.max(0, width - 1);
16884
+ let output = "";
16885
+ let visible = 0;
16886
+ let index = 0;
16887
+ let hasActiveStyle = false;
16888
+ const finish = () => `${output}…${hasActiveStyle ? "\x1B[0m" : ""}`;
16889
+ for (const match of text.matchAll(ANSI_TOKEN_RE)) {
16890
+ const chunk = text.slice(index, match.index);
16891
+ for (const char of chunk) {
16892
+ if (visible >= targetWidth) return finish();
16893
+ output += char;
16894
+ visible += 1;
16895
+ }
16896
+ output += match[0];
16897
+ hasActiveStyle = match[0] !== "\x1B[0m";
16898
+ index = match.index + match[0].length;
16899
+ }
16900
+ for (const char of text.slice(index)) {
16901
+ if (visible >= targetWidth) return finish();
16902
+ output += char;
16903
+ visible += 1;
16904
+ }
16905
+ return finish();
16906
+ }
16907
+ function formatDuration(ms) {
16908
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
16909
+ const hours = Math.floor(totalSeconds / 3600);
16910
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
16911
+ const seconds = totalSeconds % 60;
16912
+ if (hours > 0) return `${hours}h ${minutes}m`;
16913
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
16914
+ return `${seconds}s`;
16915
+ }
16916
+ function formatNumber(value) {
16917
+ return new Intl.NumberFormat("en-US").format(value);
16918
+ }
16919
+ function formatTokenCount$1(value, suffix, estimated) {
16920
+ return `${estimated ? "~" : ""}${formatTokens(value)} ${suffix}`;
16921
+ }
16922
+ function plural(value, singular, pluralText = `${singular}s`) {
16923
+ return `${formatNumber(value)} ${value === 1 ? singular : pluralText}`;
16924
+ }
16925
+ function metricLine(label, columns) {
16926
+ return ` ${padVisible(`${padVisible(label, LABEL_WIDTH)}${columns[0] ?? ""}`, 30)}${padVisible(columns[1] ?? "", 13)}${columns[2] ?? ""}`;
16927
+ }
16928
+ function commandLine(label, command) {
16929
+ return ` ${padVisible(label, LABEL_WIDTH)}${command}`;
16930
+ }
16931
+ function continuationLine(text) {
16932
+ return ` ${"".padEnd(LABEL_WIDTH)}${text}`;
16933
+ }
16934
+ function resolveCardWidth(contents, terminalColumns) {
16935
+ const contentWidth = Math.max(...contents.map(visibleLength));
16936
+ const desiredWidth = Math.max(MIN_CARD_WIDTH, contentWidth + 4);
16937
+ const columns = terminalColumns && terminalColumns > 0 ? terminalColumns : void 0;
16938
+ return Math.max(4, columns ? Math.min(desiredWidth, columns) : desiredWidth);
16939
+ }
16940
+ function cardBorder(left, right, width, dim) {
16941
+ return dim(`${left}${"─".repeat(Math.max(0, width - 2))}${right}`);
16942
+ }
16943
+ function cardLine(content, width, dim) {
16944
+ const contentWidth = Math.max(0, width - 4);
16945
+ return `${dim("│ ")}${padVisible(truncateVisible(content, contentWidth), contentWidth)}${dim(" │")}`;
16946
+ }
16947
+ function renderExitSummary(options) {
16948
+ const s = makeStyles(options.color);
16949
+ const elapsed = formatDuration(options.elapsedMs);
16950
+ const stopped = options.status === "aborted";
16951
+ const title = stopped ? `${s.red("×")} ${s.bold("gnhf stopped")}` : `${s.cyan("✦")} ${s.bold("gnhf wrapped")}`;
16952
+ 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)}`;
16953
+ const cardWidth = resolveCardWidth([title, ` ${subtitle}`], options.terminalColumns);
16954
+ const rolledBack = `${options.failCount} rolled back`;
16955
+ const inputTokens = formatTokenCount$1(options.totalInputTokens, "in", options.tokensEstimated);
16956
+ const outputTokens = formatTokenCount$1(options.totalOutputTokens, "out", options.tokensEstimated);
16957
+ const commits = plural(options.commitCount, "commit");
16958
+ const linesAdded = `+${formatNumber(options.diffStats.linesAdded)}`;
16959
+ const linesDeleted = `-${formatNumber(options.diffStats.linesDeleted)}`;
16960
+ return `\n${[
16961
+ cardBorder("╭", "╮", cardWidth, s.dim),
16962
+ cardLine(title, cardWidth, s.dim),
16963
+ cardLine(` ${subtitle}`, cardWidth, s.dim),
16964
+ cardBorder("╰", "╯", cardWidth, s.dim),
16965
+ "",
16966
+ metricLine(s.dim("iterations"), [
16967
+ `${s.bold(String(options.iterations))} total`,
16968
+ s.green(`${options.successCount} good`),
16969
+ stopped ? s.red(rolledBack) : s.yellow(rolledBack)
16970
+ ]),
16971
+ metricLine(s.dim("tokens"), [s.bold(inputTokens), s.bold(outputTokens)]),
16972
+ metricLine(s.dim("branch diff"), [
16973
+ s.bold(commits),
16974
+ s.green(linesAdded),
16975
+ s.red(linesDeleted)
16976
+ ]),
16977
+ metricLine(s.dim("files"), [
16978
+ `${options.diffStats.filesAdded} added`,
16979
+ `${options.diffStats.filesUpdated} updated`,
16980
+ `${options.diffStats.filesDeleted} deleted`
16981
+ ]),
16982
+ "",
16983
+ commandLine(s.dim("notes"), options.notesPath),
16984
+ commandLine(s.dim("debug log"), options.logPath),
16985
+ "",
16986
+ commandLine(s.dim("next steps"), s.cyan(`git log --oneline ${options.baseRef}..HEAD`)),
16987
+ continuationLine(s.cyan(`git diff --stat ${options.baseRef}..HEAD`)),
16988
+ continuationLine(s.cyan("gh pr create")),
16989
+ "",
16990
+ commandLine(s.dim("too much"), `${s.cyan("git push no-mistakes")}:`),
16991
+ commandLine(s.dim("to review?"), s.blueUnderline(NO_MISTAKES_URL))
16992
+ ].join("\n")}\n`;
16993
+ }
16994
+ //#endregion
16787
16995
  //#region src/mock-orchestrator.ts
16788
16996
  function mockIter(n, success, summary, agoMs) {
16789
16997
  return {
@@ -16997,15 +17205,6 @@ function formatElapsed(ms) {
16997
17205
  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
17206
  }
16999
17207
  //#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
17208
  //#region src/utils/terminal-width.ts
17010
17209
  const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
17011
17210
  const MARK_REGEX = /\p{Mark}/u;
@@ -17656,6 +17855,22 @@ function getNativeAgentName(spec) {
17656
17855
  function getTelemetryAgent(spec) {
17657
17856
  return redactAgentSpecForLogs(spec);
17658
17857
  }
17858
+ function shouldUseColor() {
17859
+ return process$1.stdout.isTTY === true && process$1.env.NO_COLOR === void 0 && process$1.env.TERM !== "dumb";
17860
+ }
17861
+ function emptyBranchDiffStats(commitCount) {
17862
+ return {
17863
+ commits: commitCount,
17864
+ filesChanged: 0,
17865
+ filesAdded: 0,
17866
+ filesUpdated: 0,
17867
+ filesDeleted: 0,
17868
+ filesRenamed: 0,
17869
+ binaryFiles: 0,
17870
+ linesAdded: 0,
17871
+ linesDeleted: 0
17872
+ };
17873
+ }
17659
17874
  function redactDebugArgs(args) {
17660
17875
  const redacted = [...args];
17661
17876
  for (let i = 0; i < redacted.length; i += 1) {
@@ -18135,6 +18350,38 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
18135
18350
  }
18136
18351
  {
18137
18352
  const finalState = orchestrator.getState();
18353
+ let finalBranchName = "HEAD";
18354
+ try {
18355
+ finalBranchName = getCurrentBranch(effectiveCwd);
18356
+ } catch (error) {
18357
+ appendDebugLog("summary:branch-error", { error: serializeError(error) });
18358
+ }
18359
+ let diffStats = emptyBranchDiffStats(finalState.commitCount);
18360
+ try {
18361
+ diffStats = getBranchDiffStats(runInfo.baseCommit, effectiveCwd);
18362
+ } catch (error) {
18363
+ appendDebugLog("summary:diff-stats-error", { error: serializeError(error) });
18364
+ }
18365
+ const exitSummary = renderExitSummary({
18366
+ agentName: redactAgentSpecForLogs(config.agent),
18367
+ branchName: finalBranchName,
18368
+ elapsedMs: Date.now() - finalState.startTime.getTime(),
18369
+ status: finalState.status,
18370
+ abortReason: finalState.lastAgentError ?? finalState.lastMessage,
18371
+ iterations: finalState.currentIteration,
18372
+ successCount: finalState.successCount,
18373
+ failCount: finalState.failCount,
18374
+ totalInputTokens: finalState.totalInputTokens,
18375
+ totalOutputTokens: finalState.totalOutputTokens,
18376
+ tokensEstimated: finalState.tokensEstimated,
18377
+ commitCount: finalState.commitCount,
18378
+ notesPath: runInfo.notesPath,
18379
+ logPath: runInfo.logPath,
18380
+ baseRef: runInfo.baseCommit.slice(0, 12) || runInfo.baseCommit,
18381
+ diffStats,
18382
+ color: shouldUseColor(),
18383
+ terminalColumns: process$1.stdout.columns
18384
+ });
18138
18385
  appendDebugLog("run:complete", {
18139
18386
  signal: shutdownSignal,
18140
18387
  status: finalState.status,
@@ -18172,6 +18419,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
18172
18419
  worktreeCleanup = null;
18173
18420
  appendDebugLog("worktree:cleaned-up", { worktreePath });
18174
18421
  }
18422
+ process$1.stdout.write(exitSummary);
18175
18423
  }
18176
18424
  if (shutdownSignal) process$1.exit(getSignalExitCode(shutdownSignal));
18177
18425
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {