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.
- package/README.md +2 -0
- package/dist/cli.mjs +257 -9
- 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
|
});
|