@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.
@@ -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 };
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {