@hone-ai/cli 1.9.0 → 1.11.0
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/hone-cli.js +617 -106
- package/lib/compare-reviews.js +279 -0
- package/lib/parse-review-json.js +87 -0
- package/lib/refresh-knowledge.js +4 -1
- package/lib/release-review-config.js +98 -0
- package/package.json +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* compare-reviews.js — HC-080a-spike side-by-side comparison helper.
|
|
4
|
+
*
|
|
5
|
+
* Reads two release-review JSON artifacts (Opus + GH Models GPT-4.1) and
|
|
6
|
+
* prints a markdown comparison table that can be pasted into the decision
|
|
7
|
+
* record in docs/architecture/release-review-llm-provider-decision.md.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node cli/lib/compare-reviews.js <opus-findings.json> <gh-models-findings.json>
|
|
11
|
+
*
|
|
12
|
+
* Both files should be JSON envelopes produced by `hone release-review --format json`
|
|
13
|
+
* (see cli/hone-cli.js release-review handler — both providers emit the same
|
|
14
|
+
* envelope shape).
|
|
15
|
+
*
|
|
16
|
+
* The script is intentionally tolerant of partial/raw outputs: if a provider
|
|
17
|
+
* returned a non-JSON response, the comparison still shows what's there and
|
|
18
|
+
* flags the format problem in the rubric.
|
|
19
|
+
*
|
|
20
|
+
* Exit codes:
|
|
21
|
+
* 0 — both files parsed, comparison printed
|
|
22
|
+
* 1 — missing file, invalid JSON, or other unrecoverable error
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
// Normalize file paths for cross-provider equality. Strips leading "./",
|
|
31
|
+
// converts backslashes, and removes duplicate separators. Coarse: does NOT
|
|
32
|
+
// resolve symlinks, just compares string-equivalent paths.
|
|
33
|
+
function normalizePath(p) {
|
|
34
|
+
if (typeof p !== 'string') return '';
|
|
35
|
+
let n = p.replace(/\\/g, '/').replace(/^\.\//, '').trim();
|
|
36
|
+
// collapse repeated slashes but preserve leading slash
|
|
37
|
+
n = n.replace(/\/+/g, '/');
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Escape a string for safe interpolation into a markdown table cell or bullet.
|
|
42
|
+
// Replaces pipes (`|` — breaks table column count), backticks (corrupt code
|
|
43
|
+
// spans), and newlines (break bullet lines). Backticks are removed (rather
|
|
44
|
+
// than backslash-escaped) because backslash-escapes inside code spans are NOT
|
|
45
|
+
// honored by CommonMark — replacing with a similar-looking char preserves
|
|
46
|
+
// rendering. This is defense against LLM-emitted content that may contain
|
|
47
|
+
// any of these chars in `issue` / `file` strings.
|
|
48
|
+
function escapeMd(s) {
|
|
49
|
+
if (s == null) return '';
|
|
50
|
+
return String(s)
|
|
51
|
+
.replace(/\|/g, '\\|')
|
|
52
|
+
.replace(/`/g, '‘') // ` → left single quote (visually close)
|
|
53
|
+
.replace(/\r?\n/g, ' '); // newlines → spaces inside a cell
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadEnvelope(filePath) {
|
|
57
|
+
if (!fs.existsSync(filePath)) {
|
|
58
|
+
return { error: `file not found: ${filePath}` };
|
|
59
|
+
}
|
|
60
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return { error: `JSON parse failed: ${e.message}`, raw };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bucketSeverity(findings) {
|
|
69
|
+
const buckets = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, OTHER: 0 };
|
|
70
|
+
if (!Array.isArray(findings)) return buckets;
|
|
71
|
+
for (const f of findings) {
|
|
72
|
+
const sev = String(f.severity || '').toUpperCase();
|
|
73
|
+
if (sev in buckets) buckets[sev]++;
|
|
74
|
+
else buckets.OTHER++;
|
|
75
|
+
}
|
|
76
|
+
return buckets;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describe(env) {
|
|
80
|
+
if (env.error) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: env.error,
|
|
84
|
+
status: 'unparseable',
|
|
85
|
+
provider: 'unknown',
|
|
86
|
+
model: 'unknown',
|
|
87
|
+
counts: bucketSeverity([]),
|
|
88
|
+
totalFindings: 0,
|
|
89
|
+
cost: { inputTokens: 0, outputTokens: 0 },
|
|
90
|
+
elapsedMs: null,
|
|
91
|
+
recommendation: 'n/a',
|
|
92
|
+
formatOk: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const findings = Array.isArray(env.findings) ? env.findings : [];
|
|
96
|
+
const counts = bucketSeverity(findings);
|
|
97
|
+
// `status` field (added by HC-080a-spike fix pass) tells operators whether
|
|
98
|
+
// this row is a real review, a known error envelope, or an unknown shape.
|
|
99
|
+
// Status values produced by the CLI: 'reviewed' | 'no_changes' |
|
|
100
|
+
// 'empty_response' | 'auth_error' | 'rate_limited' | 'http_error'.
|
|
101
|
+
// Default to 'reviewed' if absent so older artifacts still display.
|
|
102
|
+
const status = env.status || (findings.length > 0 ? 'reviewed' : 'unknown');
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
status,
|
|
106
|
+
provider: env.provider || 'unknown',
|
|
107
|
+
model: env.model || 'unknown',
|
|
108
|
+
counts,
|
|
109
|
+
totalFindings: findings.length,
|
|
110
|
+
cost: {
|
|
111
|
+
inputTokens: env.inputTokens || 0,
|
|
112
|
+
outputTokens: env.outputTokens || 0,
|
|
113
|
+
},
|
|
114
|
+
elapsedMs: typeof env.elapsedMs === 'number' ? env.elapsedMs : null,
|
|
115
|
+
recommendation: env.recommendation || (env.raw ? 'raw-output' : 'n/a'),
|
|
116
|
+
formatOk: !env.raw,
|
|
117
|
+
summary: env.summary || null,
|
|
118
|
+
findings,
|
|
119
|
+
httpStatus: env.httpStatus ?? null,
|
|
120
|
+
errorMessage: env.errorMessage || null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Findings overlap detection ────────────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// Two findings are considered the "same" bug if they reference the same
|
|
127
|
+
// normalized file path AND both have explicit line numbers within ±3 of each
|
|
128
|
+
// other. Findings without explicit line numbers are NOT auto-merged — two
|
|
129
|
+
// unrelated file-level findings on the same file would otherwise collapse
|
|
130
|
+
// into a false overlap.
|
|
131
|
+
//
|
|
132
|
+
// This is coarse — a precise match would require semantic similarity scoring,
|
|
133
|
+
// which is overkill for a 5-release spike. The output is meant to anchor
|
|
134
|
+
// manual review, not auto-decide.
|
|
135
|
+
function findOverlap(aFindings, bFindings) {
|
|
136
|
+
const overlap = [];
|
|
137
|
+
const aOnly = [];
|
|
138
|
+
const bUsed = new Set();
|
|
139
|
+
|
|
140
|
+
for (const a of aFindings) {
|
|
141
|
+
let matched = false;
|
|
142
|
+
const aFile = normalizePath(a.file);
|
|
143
|
+
const aLine = Number.isFinite(a.line) ? a.line : null;
|
|
144
|
+
for (let i = 0; i < bFindings.length; i++) {
|
|
145
|
+
if (bUsed.has(i)) continue;
|
|
146
|
+
const b = bFindings[i];
|
|
147
|
+
const bFile = normalizePath(b.file);
|
|
148
|
+
const bLine = Number.isFinite(b.line) ? b.line : null;
|
|
149
|
+
if (aFile !== bFile || aFile === '') continue;
|
|
150
|
+
// Require both lines to be explicit AND nearby. Two undefined lines
|
|
151
|
+
// do NOT count as a match — they're treated as distinct file-level
|
|
152
|
+
// findings until a human merges them manually in the rubric.
|
|
153
|
+
if (aLine === null || bLine === null) continue;
|
|
154
|
+
if (Math.abs(aLine - bLine) > 3) continue;
|
|
155
|
+
overlap.push({ a, b });
|
|
156
|
+
bUsed.add(i);
|
|
157
|
+
matched = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
if (!matched) aOnly.push(a);
|
|
161
|
+
}
|
|
162
|
+
const bOnly = bFindings.filter((_, i) => !bUsed.has(i));
|
|
163
|
+
return { overlap, aOnly, bOnly };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderMarkdown(opus, gh) {
|
|
167
|
+
const lines = [];
|
|
168
|
+
lines.push('# Release-Review Provider Comparison (HC-080a-spike)');
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push(`_Generated: ${new Date().toISOString()}_`);
|
|
171
|
+
lines.push('');
|
|
172
|
+
|
|
173
|
+
lines.push('## Summary');
|
|
174
|
+
lines.push('');
|
|
175
|
+
lines.push('| Dimension | Opus (Anthropic) | GPT-4.1 (GitHub Models) |');
|
|
176
|
+
lines.push('|---|---|---|');
|
|
177
|
+
lines.push(`| Provider | ${opus.provider} | ${gh.provider} |`);
|
|
178
|
+
lines.push(`| Status | ${opus.status} | ${gh.status} |`);
|
|
179
|
+
lines.push(`| Model | ${opus.model} | ${gh.model} |`);
|
|
180
|
+
lines.push(`| Format OK | ${opus.formatOk ? 'yes' : 'NO (raw)'} | ${gh.formatOk ? 'yes' : 'NO (raw)'} |`);
|
|
181
|
+
lines.push(`| Latency (ms) | ${opus.elapsedMs ?? 'n/a'} | ${gh.elapsedMs ?? 'n/a'} |`);
|
|
182
|
+
lines.push(`| Input tokens | ${opus.cost.inputTokens} | ${gh.cost.inputTokens} |`);
|
|
183
|
+
lines.push(`| Output tokens | ${opus.cost.outputTokens} | ${gh.cost.outputTokens} |`);
|
|
184
|
+
lines.push(`| Total findings | ${opus.totalFindings} | ${gh.totalFindings} |`);
|
|
185
|
+
lines.push(`| Findings: CRITICAL | ${opus.counts.CRITICAL} | ${gh.counts.CRITICAL} |`);
|
|
186
|
+
lines.push(`| Findings: HIGH | ${opus.counts.HIGH} | ${gh.counts.HIGH} |`);
|
|
187
|
+
lines.push(`| Findings: MEDIUM | ${opus.counts.MEDIUM} | ${gh.counts.MEDIUM} |`);
|
|
188
|
+
lines.push(`| Findings: LOW | ${opus.counts.LOW} | ${gh.counts.LOW} |`);
|
|
189
|
+
lines.push(`| Recommendation | ${opus.recommendation} | ${gh.recommendation} |`);
|
|
190
|
+
lines.push('');
|
|
191
|
+
|
|
192
|
+
if (!opus.ok || !gh.ok) {
|
|
193
|
+
lines.push('## Errors');
|
|
194
|
+
lines.push('');
|
|
195
|
+
if (!opus.ok) lines.push(`- Opus envelope: ${opus.error}`);
|
|
196
|
+
if (!gh.ok) lines.push(`- GH Models envelope: ${gh.error}`);
|
|
197
|
+
lines.push('');
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { overlap, aOnly: opusOnly, bOnly: ghOnly } = findOverlap(opus.findings, gh.findings);
|
|
202
|
+
|
|
203
|
+
lines.push('## Overlap analysis (coarse: same file + line ±3)');
|
|
204
|
+
lines.push('');
|
|
205
|
+
lines.push(`- Shared findings (both providers caught): ${overlap.length}`);
|
|
206
|
+
lines.push(`- Opus-only findings: ${opusOnly.length}`);
|
|
207
|
+
lines.push(`- GH Models-only findings: ${ghOnly.length}`);
|
|
208
|
+
lines.push('');
|
|
209
|
+
|
|
210
|
+
if (overlap.length) {
|
|
211
|
+
lines.push('### Shared findings (sample, up to 5)');
|
|
212
|
+
lines.push('');
|
|
213
|
+
for (const { a, b } of overlap.slice(0, 5)) {
|
|
214
|
+
lines.push(`- **${escapeMd(a.file)}:${a.line}** — Opus says: _${escapeMd(a.issue)}_ — GPT says: _${escapeMd(b.issue)}_`);
|
|
215
|
+
}
|
|
216
|
+
lines.push('');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (opusOnly.length) {
|
|
220
|
+
lines.push('### Opus-only findings (POTENTIAL QUALITY GAP if real bugs)');
|
|
221
|
+
lines.push('');
|
|
222
|
+
for (const f of opusOnly.slice(0, 10)) {
|
|
223
|
+
lines.push(`- [${escapeMd(f.severity)}] **${escapeMd(f.file)}:${f.line ?? '?'}** — ${escapeMd(f.issue)}`);
|
|
224
|
+
}
|
|
225
|
+
if (opusOnly.length > 10) lines.push(`- ... and ${opusOnly.length - 10} more`);
|
|
226
|
+
lines.push('');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (ghOnly.length) {
|
|
230
|
+
lines.push('### GH Models-only findings');
|
|
231
|
+
lines.push('');
|
|
232
|
+
for (const f of ghOnly.slice(0, 10)) {
|
|
233
|
+
lines.push(`- [${escapeMd(f.severity)}] **${escapeMd(f.file)}:${f.line ?? '?'}** — ${escapeMd(f.issue)}`);
|
|
234
|
+
}
|
|
235
|
+
if (ghOnly.length > 10) lines.push(`- ... and ${ghOnly.length - 10} more`);
|
|
236
|
+
lines.push('');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
lines.push('## Decision rubric — fill after manual review');
|
|
240
|
+
lines.push('');
|
|
241
|
+
lines.push('- [ ] Did GH Models catch ≥80% of Opus HIGH/MEDIUM findings on this release?');
|
|
242
|
+
lines.push('- [ ] Were any Opus-only findings actually real bugs (not noise)?');
|
|
243
|
+
lines.push('- [ ] Were any GH Models-only findings actually real bugs that Opus missed?');
|
|
244
|
+
lines.push('- [ ] Was the output format clean for both providers?');
|
|
245
|
+
lines.push('');
|
|
246
|
+
lines.push('Append this comparison row to the rubric in');
|
|
247
|
+
lines.push('`docs/architecture/release-review-llm-provider-decision.md`.');
|
|
248
|
+
|
|
249
|
+
return lines.join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function main(argv) {
|
|
253
|
+
if (argv.length < 2) {
|
|
254
|
+
console.error('Usage: node cli/lib/compare-reviews.js <opus-findings.json> <gh-models-findings.json>');
|
|
255
|
+
console.error('');
|
|
256
|
+
console.error('Download both artifacts from a release CI run, then run this script.');
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const opusFile = path.resolve(argv[0]);
|
|
260
|
+
const ghFile = path.resolve(argv[1]);
|
|
261
|
+
const opus = describe(loadEnvelope(opusFile));
|
|
262
|
+
const gh = describe(loadEnvelope(ghFile));
|
|
263
|
+
console.log(renderMarkdown(opus, gh));
|
|
264
|
+
process.exit(0);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (require.main === module) {
|
|
268
|
+
main(process.argv.slice(2));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
loadEnvelope,
|
|
273
|
+
describe,
|
|
274
|
+
findOverlap,
|
|
275
|
+
bucketSeverity,
|
|
276
|
+
renderMarkdown,
|
|
277
|
+
normalizePath,
|
|
278
|
+
escapeMd,
|
|
279
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* parse-review-json.js — HC-080a-spike extraction helper.
|
|
4
|
+
*
|
|
5
|
+
* Robust JSON extraction from LLM output. Used by `hone release-review` to
|
|
6
|
+
* parse the structured `{ findings, summary, recommendation }` response from
|
|
7
|
+
* either Anthropic Opus or GitHub Models GPT-4.1.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: LLMs don't reliably emit pure JSON. The function tries
|
|
10
|
+
* three shapes in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. Pure JSON — the whole response IS valid JSON.
|
|
13
|
+
* 2. Code-fenced — ```json { ... } ``` block somewhere in the response.
|
|
14
|
+
* 3. Forward-scan balanced — find every balanced `{...}` span in the
|
|
15
|
+
* response and JSON.parse the LONGEST one (LLMs that emit a schema
|
|
16
|
+
* example before their real answer would otherwise corrupt a naive
|
|
17
|
+
* greedy regex; the real answer is almost always the largest block).
|
|
18
|
+
*
|
|
19
|
+
* The function returns the parsed object, or null on failure. Callers should
|
|
20
|
+
* use the loose substring fallback (e.g., `/\bCRITICAL\b/`) on the raw text
|
|
21
|
+
* when this returns null — that's the safety net against silent CRITICAL
|
|
22
|
+
* passes on non-JSON output.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} text Raw LLM response.
|
|
27
|
+
* @returns {object|null} Parsed JSON, or null if no parseable JSON found.
|
|
28
|
+
*/
|
|
29
|
+
function parseReviewJSON(text) {
|
|
30
|
+
if (!text || typeof text !== 'string') return null;
|
|
31
|
+
|
|
32
|
+
// Shape 1: try the whole string. Cheap; happens when the LLM follows the
|
|
33
|
+
// "Output as JSON" instruction strictly.
|
|
34
|
+
try { return JSON.parse(text); } catch { /* fall through */ }
|
|
35
|
+
|
|
36
|
+
// Shape 2: ```json ... ``` fenced block. Common when the LLM emits prose
|
|
37
|
+
// commentary before the structured answer.
|
|
38
|
+
const fenced = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
|
|
39
|
+
if (fenced) {
|
|
40
|
+
try { return JSON.parse(fenced[1]); } catch { /* fall through */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Shape 3: forward-scan for balanced `{...}` spans, then try parsing each
|
|
44
|
+
// from longest to shortest. This handles LLMs that emit a schema example
|
|
45
|
+
// then their real answer (we'd otherwise capture both as one bad blob if
|
|
46
|
+
// we used a greedy `/\{[\s\S]*\}/` regex).
|
|
47
|
+
//
|
|
48
|
+
// The scanner tracks whether we're inside a JSON string literal so braces
|
|
49
|
+
// inside strings don't disrupt depth counting. Escape handling: a `\`
|
|
50
|
+
// inside a string marks the next character as escaped, so `\"` doesn't
|
|
51
|
+
// terminate the string.
|
|
52
|
+
const spans = [];
|
|
53
|
+
let depth = 0, start = -1, inString = false, escaped = false;
|
|
54
|
+
for (let i = 0; i < text.length; i++) {
|
|
55
|
+
const ch = text[i];
|
|
56
|
+
if (escaped) { escaped = false; continue; }
|
|
57
|
+
if (inString) {
|
|
58
|
+
if (ch === '\\') escaped = true;
|
|
59
|
+
else if (ch === '"') inString = false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ch === '"') { inString = true; continue; }
|
|
63
|
+
if (ch === '{') {
|
|
64
|
+
if (depth === 0) start = i;
|
|
65
|
+
depth++;
|
|
66
|
+
} else if (ch === '}') {
|
|
67
|
+
depth--;
|
|
68
|
+
if (depth === 0 && start >= 0) {
|
|
69
|
+
spans.push([start, i + 1]);
|
|
70
|
+
start = -1;
|
|
71
|
+
} else if (depth < 0) {
|
|
72
|
+
// Recover from malformed input — extra `}` without matching `{`.
|
|
73
|
+
depth = 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Try longest spans first — the LLM's real answer is usually the largest
|
|
79
|
+
// block in the response.
|
|
80
|
+
spans.sort((a, b) => (b[1] - b[0]) - (a[1] - a[0]));
|
|
81
|
+
for (const [a, b] of spans) {
|
|
82
|
+
try { return JSON.parse(text.slice(a, b)); } catch { /* try next span */ }
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { parseReviewJSON };
|
package/lib/refresh-knowledge.js
CHANGED
|
@@ -26,9 +26,12 @@ const path = require('node:path');
|
|
|
26
26
|
const crypto = require('node:crypto');
|
|
27
27
|
const { execSync } = require('node:child_process');
|
|
28
28
|
|
|
29
|
+
// HC-019y (2026-05-29): removed `.github/agents` from refresh paths.
|
|
30
|
+
// Agents live exclusively at `.claude/agents/` (Claude Code's read path)
|
|
31
|
+
// after the install-time mirror was eliminated. `.github/agents/` is now
|
|
32
|
+
// a legacy directory adopters are expected to manually delete.
|
|
29
33
|
const KNOWLEDGE_DIRS = [
|
|
30
34
|
'.github/skills',
|
|
31
|
-
'.github/agents',
|
|
32
35
|
'.claude/agents',
|
|
33
36
|
'.github/copilot-instructions.md',
|
|
34
37
|
];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* release-review-config.js — HC-080a-spike helpers extracted for unit coverage.
|
|
4
|
+
*
|
|
5
|
+
* Two helpers exist here because they were sources of bugs in the first
|
|
6
|
+
* spike CI run (PR #313 data point 1):
|
|
7
|
+
*
|
|
8
|
+
* 1. resolveBaseRef() — the CI step passes `--base origin/main` and the
|
|
9
|
+
* CLI was prefixing `origin/` again, producing `origin/origin/main`
|
|
10
|
+
* which `git diff` rejects. Either calling convention (`main` or
|
|
11
|
+
* `origin/main`) should now work.
|
|
12
|
+
*
|
|
13
|
+
* 2. getMaxDiffChars() — GH Models GPT-4.1 enforces an 8000-token cap
|
|
14
|
+
* on the REQUEST BODY (input). The previous 100k-char truncation
|
|
15
|
+
* worked fine for Opus (200k context) but failed every GH Models call
|
|
16
|
+
* with "Request body too large." Provider-specific truncation fixes it.
|
|
17
|
+
* See the function's docstring for budget math + the input-only vs
|
|
18
|
+
* total-body interpretation.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a base ref to a stable `origin/<branch>` form regardless of
|
|
23
|
+
* whether the caller already prefixed it.
|
|
24
|
+
*
|
|
25
|
+
* Examples:
|
|
26
|
+
* resolveBaseRef('main') // → 'origin/main'
|
|
27
|
+
* resolveBaseRef('origin/main') // → 'origin/main' (no double prefix)
|
|
28
|
+
* resolveBaseRef('refs/remotes/origin/main') // → 'origin/main'
|
|
29
|
+
*
|
|
30
|
+
* @param {string} base User-supplied base branch.
|
|
31
|
+
* @returns {string} Normalized git revision suitable for `git diff <ref>...HEAD`.
|
|
32
|
+
*/
|
|
33
|
+
function resolveBaseRef(base) {
|
|
34
|
+
if (!base || typeof base !== 'string') return 'origin/main';
|
|
35
|
+
const trimmed = base.trim();
|
|
36
|
+
if (trimmed.startsWith('refs/remotes/origin/')) {
|
|
37
|
+
return 'origin/' + trimmed.slice('refs/remotes/origin/'.length);
|
|
38
|
+
}
|
|
39
|
+
if (trimmed.startsWith('origin/')) return trimmed;
|
|
40
|
+
return 'origin/' + trimmed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Provider-specific max diff size (in chars). Models with smaller input
|
|
45
|
+
* windows need more aggressive truncation.
|
|
46
|
+
*
|
|
47
|
+
* ## Budget math
|
|
48
|
+
*
|
|
49
|
+
* GH Models `openai/gpt-4.1` via `models.github.ai/inference` enforces an
|
|
50
|
+
* 8000-token cap on the REQUEST BODY (everything in the JSON payload sent
|
|
51
|
+
* to /chat/completions: system message + user message + model name + the
|
|
52
|
+
* `max_tokens` integer). The OUTPUT cap (`max_tokens: 4096`) is counted
|
|
53
|
+
* separately by the model and does NOT consume the 8000-token request
|
|
54
|
+
* budget — that 4096 governs how many tokens the response is allowed to
|
|
55
|
+
* have, not how many tokens our request can include.
|
|
56
|
+
*
|
|
57
|
+
* Decomposition of the 8000-token input budget (typical):
|
|
58
|
+
* system prompt ~600 tokens (~2400 chars)
|
|
59
|
+
* user prompt template ~200 tokens (~800 chars)
|
|
60
|
+
* file list (40 entries) ~400 tokens (~1600 chars)
|
|
61
|
+
* safety headroom ~800 tokens (~3200 chars)
|
|
62
|
+
* -------------------------------
|
|
63
|
+
* diff budget ~6000 tokens ≈ 24000 chars
|
|
64
|
+
*
|
|
65
|
+
* We use 20000 chars (≈5000 tokens) as a conservative cap to leave room
|
|
66
|
+
* for prompt drift if we evolve the system/user templates. If GH Models
|
|
67
|
+
* documentation later confirms the cap is TOTAL (input + output) instead
|
|
68
|
+
* of input-only, drop this number AND `max_tokens` together.
|
|
69
|
+
*
|
|
70
|
+
* Anthropic Opus `claude-opus-4-20250514`: 200K context window. The 100K
|
|
71
|
+
* char cap is generous and unchanged from the original implementation.
|
|
72
|
+
*
|
|
73
|
+
* ## Default behavior — FAIL CLOSED
|
|
74
|
+
*
|
|
75
|
+
* Unknown providers receive the SMALLEST known cap (20000), not the
|
|
76
|
+
* largest. Reasoning: if a future small-context provider (haiku, mini,
|
|
77
|
+
* flash) is added and a developer forgets to add a switch case here,
|
|
78
|
+
* the 20000 default truncates safely. Falling back to 100000 would
|
|
79
|
+
* re-introduce exactly the bug class this helper exists to fix.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} provider 'opus' or 'gh-models'.
|
|
82
|
+
* @returns {number} Max diff chars to send to this provider.
|
|
83
|
+
*/
|
|
84
|
+
function getMaxDiffChars(provider) {
|
|
85
|
+
switch (provider) {
|
|
86
|
+
case 'opus':
|
|
87
|
+
return 100000;
|
|
88
|
+
case 'gh-models':
|
|
89
|
+
return 20000;
|
|
90
|
+
default:
|
|
91
|
+
return 20000;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
resolveBaseRef,
|
|
97
|
+
getMaxDiffChars,
|
|
98
|
+
};
|