@hone-ai/cli 1.39.0 → 1.40.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.
@@ -17,14 +17,24 @@
17
17
  * mergeContexts(existing, added)
18
18
  * Pure. Dedupes context names and preserves first-seen order so a
19
19
  * pre-existing custom check (e.g. 'lint') is preserved when Hone
20
- * appends 'ai-code-review'.
20
+ * appends the checks it requires.
21
+ *
22
+ * pruneLegacyChecks(contexts, { reportedChecks, keep })
23
+ * Pure. Removes LEGACY_REQUIRED_CHECK ('ai-code-review'), which earlier
24
+ * setups required and no served workflow reports (HC-GF-348), unless a
25
+ * workflow in the repository reports it or the caller requires it.
26
+ * Removes nothing else.
21
27
  *
22
28
  * parseGhError(stderr, exitCode)
23
29
  * Pure. Classifies a `gh` invocation failure into a stable kind:
24
30
  * no-gh / no-auth / no-admin / plan-restricted / org-ruleset / unknown.
25
31
  *
26
32
  * installBranchProtection({ exec?, log?, opts? })
27
- * Orchestrator. Returns { status, reason?, branch?, manualCommand? }
33
+ * Orchestrator. opts.requiredChecks is the list `hone setup` reads from
34
+ * the installed workflow (cli/lib/required-checks.js) and may be empty;
35
+ * opts.reportedChecks is every check name the repository's workflows
36
+ * report (null = unknown, which keeps the legacy check).
37
+ * Returns { status, reason?, branch?, manualCommand? }
28
38
  * where status ∈ 'installed' | 'skipped' | 'failed'. Never throws,
29
39
  * never exits — degrades gracefully so `hone setup` always returns 0.
30
40
  *
@@ -34,7 +44,14 @@
34
44
  * invocations during testing.
35
45
  */
36
46
 
37
- const DEFAULT_REQUIRED_CHECKS = ['ai-code-review'];
47
+ // HC-GF-348: no check is required by default. Setup passes the checks the repository's own workflow
48
+ // reports (cli/lib/required-checks.js); an empty list is valid — the PUT still requires a review and
49
+ // sets enforce_admins. This used to be ['ai-code-review'], a check nothing reports, which held every PR.
50
+ const DEFAULT_REQUIRED_CHECKS = [];
51
+
52
+ // The one context an earlier `hone setup` installed that no served workflow reports. Re-running setup
53
+ // removes it (pruneLegacyChecks), so a repository already held by it is released.
54
+ const LEGACY_REQUIRED_CHECK = 'ai-code-review';
38
55
 
39
56
  // ─────────────────────────────────────────────────────────────────────
40
57
  // defaultExec — production exec, wraps spawnSync
@@ -59,7 +76,8 @@ function buildPayload({ contexts = [], requireReviews } = {}) {
59
76
  for (const c of contexts) {
60
77
  if (!seen.has(c)) { seen.add(c); dedupedContexts.push(c); }
61
78
  }
62
- const reviewCount = typeof requireReviews === 'number' && requireReviews > 0
79
+ // 0 is a real answer since HC-GF-353 (a repository with one writer); anything that is not a count is 1.
80
+ const reviewCount = Number.isInteger(requireReviews) && requireReviews >= 0
63
81
  ? requireReviews
64
82
  : 1;
65
83
  return {
@@ -90,13 +108,27 @@ function mergeContexts(existing, added) {
90
108
  return out;
91
109
  }
92
110
 
111
+ // ─────────────────────────────────────────────────────────────────────
112
+ // pruneLegacyChecks — pure (HC-GF-348)
113
+ // ─────────────────────────────────────────────────────────────────────
114
+ function pruneLegacyChecks(contexts, { reportedChecks = [], keep = [] } = {}) {
115
+ const arr = Array.isArray(contexts) ? contexts : [];
116
+ // null: the repository's workflows could not all be read, so whether one reports it is unknown.
117
+ if (reportedChecks == null) return arr.slice();
118
+ const reported = Array.isArray(reportedChecks) ? reportedChecks : [];
119
+ const kept = Array.isArray(keep) ? keep : [];
120
+ if (reported.includes(LEGACY_REQUIRED_CHECK) || kept.includes(LEGACY_REQUIRED_CHECK)) return arr.slice();
121
+ return arr.filter((c) => c !== LEGACY_REQUIRED_CHECK);
122
+ }
123
+
93
124
  // ─────────────────────────────────────────────────────────────────────
94
125
  // parseGhError — pure
95
126
  // ─────────────────────────────────────────────────────────────────────
96
127
  function parseGhError(stderr = '', exitCode = 0) {
97
128
  const s = String(stderr || '');
98
129
  // gh binary missing entirely
99
- if (exitCode === 127 || /command not found|gh:.*not found/i.test(s)) {
130
+ // Not GitHub's own `gh: Not Found (HTTP 404)`, which the shell's wording also matched.
131
+ if (exitCode === 127 || /command not found/i.test(s) || (/gh:.*not found/i.test(s) && !/\(HTTP \d{3}\)/.test(s))) {
100
132
  return { kind: 'no-gh', message: 'gh CLI not installed (or not on PATH)' };
101
133
  }
102
134
  // gh installed but not authenticated
@@ -128,28 +160,67 @@ function parseGhError(stderr = '', exitCode = 0) {
128
160
  // ─────────────────────────────────────────────────────────────────────
129
161
  // buildManualCommand — produces a copy-paste shell snippet for the user
130
162
  // ─────────────────────────────────────────────────────────────────────
131
- function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main' } = {}) {
132
- const contextLines = contexts
133
- .map(c => ` -f 'required_status_checks[contexts][]=${c}' \\`)
134
- .join('\n');
163
+ function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main', requireReviews } = {}) {
164
+ // HC-GF-348: the exact payload setup PUTs, sent as JSON. `-f` fields cannot carry an empty contexts
165
+ // list (now the default) or a null `restrictions` — `-f 'restrictions='` sent the empty string.
135
166
  return [
136
- `gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection \\`,
137
- ` -f 'required_status_checks[strict]=true' \\`,
138
- contextLines,
139
- ` -f 'enforce_admins=true' \\`,
140
- ` -f 'required_pull_request_reviews[required_approving_review_count]=1' \\`,
141
- ` -f 'restrictions='`,
167
+ `gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection --input - <<'JSON'`,
168
+ JSON.stringify(buildPayload({ contexts, requireReviews }), null, 2),
169
+ 'JSON',
142
170
  ].join('\n');
143
171
  }
144
172
 
173
+ // ─────────────────────────────────────────────────────────────────────
174
+ // resolveChecks — the required list, which may depend on the branch (HC-GF-354)
175
+ // ─────────────────────────────────────────────────────────────────────
176
+ // Setup's list depends on the protected branch, which only `gh repo view` names. On the paths that return
177
+ // before that, the printed manual command is a template, so the list is resolved with branch null. A
178
+ // resolver that throws requires nothing rather than failing the install.
179
+ function resolveChecks(requiredChecks, branch) {
180
+ if (typeof requiredChecks === 'function') {
181
+ try {
182
+ const r = requiredChecks(branch);
183
+ return Array.isArray(r) ? r : [];
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+ return Array.isArray(requiredChecks) ? requiredChecks : DEFAULT_REQUIRED_CHECKS;
189
+ }
190
+
191
+ // ─────────────────────────────────────────────────────────────────────
192
+ // countWriters — how many people can push (HC-GF-353)
193
+ // ─────────────────────────────────────────────────────────────────────
194
+ function countWriters(exec, owner, repo) {
195
+ let r;
196
+ try {
197
+ r = exec('gh', ['api', '--paginate', `repos/${owner}/${repo}/collaborators?per_page=100`]);
198
+ } catch (e) {
199
+ return { count: null, reason: `gh could not be run: ${e && e.message}` };
200
+ }
201
+ if (r.status !== 0) return { count: null, reason: parseGhError(r.stderr, r.status).message };
202
+ let list;
203
+ try {
204
+ // --paginate concatenates one JSON array per page: `[...][...]`.
205
+ const pages = JSON.parse(`[${String(r.stdout || '').trim().replace(/\]\s*\[/g, '],[')}]`);
206
+ list = pages.length && pages.every(Array.isArray) ? pages.flat() : null;
207
+ } catch {
208
+ list = null;
209
+ }
210
+ if (!Array.isArray(list) || list.length === 0) return { count: null, reason: 'the collaborator list could not be read' };
211
+ const n = list.filter((c) => c && c.permissions && (c.permissions.push === true || c.permissions.admin === true)).length;
212
+ // Nobody with a readable push or admin permission is an answer that could not be read, not "zero
213
+ // writers": it must never remove the review.
214
+ if (n === 0) return { count: null, reason: 'no collaborator in the list has a readable push or admin permission' };
215
+ return { count: n };
216
+ }
217
+
145
218
  // ─────────────────────────────────────────────────────────────────────
146
219
  // installBranchProtection — orchestrator
147
220
  // ─────────────────────────────────────────────────────────────────────
148
221
  async function installBranchProtection({ exec = defaultExec, log = () => {}, opts = {} } = {}) {
149
- const requiredChecks = (opts.requiredChecks && opts.requiredChecks.length)
150
- ? opts.requiredChecks
151
- : DEFAULT_REQUIRED_CHECKS;
152
- const requireReviews = opts.requireReviews || 1;
222
+ // Not given: unknown, which keeps the legacy check. Only a caller that read the workflows can prune it.
223
+ const reportedChecks = opts.reportedChecks === undefined ? null : opts.reportedChecks;
153
224
 
154
225
  // 1. Honor opt-out — short-circuit before any subprocess call
155
226
  if (opts.skip) {
@@ -159,6 +230,12 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
159
230
  };
160
231
  }
161
232
 
233
+ // An explicit list — empty included — is what the caller requires (HC-GF-348); a function of the
234
+ // protected branch (HC-GF-354); none given: the default. `requiredChecks` is the answer before the
235
+ // branch is known: for a function that is nothing, since a check it would name might never report on
236
+ // the real branch, and the manual command printed on those paths is a template the user edits.
237
+ const requiredChecks = typeof opts.requiredChecks === 'function' ? [] : resolveChecks(opts.requiredChecks, null);
238
+
162
239
  // 2. Resolve git remote
163
240
  const remote = exec('git', ['remote', 'get-url', 'origin']);
164
241
  if (remote.status !== 0) {
@@ -206,10 +283,23 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
206
283
  };
207
284
  }
208
285
 
286
+ const branchChecks = resolveChecks(opts.requiredChecks, branch);
287
+
209
288
  // 4. GET existing protection (404 = none, 200 = merge)
210
289
  const protPath = `repos/${owner}/${repo}/branches/${branch}/protection`;
211
290
  const existing = exec('gh', ['api', protPath]);
212
291
  let existingContexts = [];
292
+ // HC-GF-348: only "Branch not protected" (HTTP 404) means there is nothing to merge. Any other failure
293
+ // to read the protection must not be taken for "none": the PUT replaces the contexts, so it would drop
294
+ // every check the repository already requires — and with this story the computed list can be empty.
295
+ if (existing.status !== 0 && !/Branch not protected/i.test(`${existing.stderr || ''}${existing.stdout || ''}`)) {
296
+ const err = parseGhError(existing.stderr, existing.status);
297
+ return {
298
+ status: 'failed',
299
+ reason: `could not read the existing branch protection, so it was left unchanged: ${err.message}`,
300
+ manualCommand: buildManualCommand({ contexts: branchChecks, owner, repo, branch }),
301
+ };
302
+ }
213
303
  if (existing.status === 0) {
214
304
  try {
215
305
  const e = JSON.parse(existing.stdout || '{}');
@@ -220,11 +310,32 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
220
310
  existingContexts = [];
221
311
  }
222
312
  }
223
- // (non-zero exit is treated as "no existing protection" — the PUT below will succeed if the user has permissions)
224
313
 
225
- // 5. Build merged payload
226
- const mergedContexts = mergeContexts(existingContexts, requiredChecks);
227
- const payload = buildPayload({ contexts: mergedContexts, requireReviews });
314
+ // 5. Build merged payload; drop the legacy check an earlier setup installed (HC-GF-348)
315
+ const mergedContexts = pruneLegacyChecks(mergeContexts(existingContexts, branchChecks), {
316
+ reportedChecks, keep: branchChecks,
317
+ });
318
+
319
+ // HC-GF-353: GitHub does not count a PR author's approval of their own PR, so one required review on a
320
+ // repository with a single writer makes every PR unmergeable. An explicit opts.requireReviews wins. A
321
+ // count that could not be established keeps 1: failing open on human review would be the regression.
322
+ let reviewCount = 1;
323
+ let reviewReason;
324
+ if (Number.isInteger(opts.requireReviews) && opts.requireReviews >= 0) {
325
+ reviewCount = opts.requireReviews;
326
+ reviewReason = 'set by the caller';
327
+ } else {
328
+ const w = countWriters(exec, owner, repo);
329
+ if (w.count == null) {
330
+ reviewReason = `the number of people who can push could not be counted (${w.reason}), so one review is still required`;
331
+ } else if (w.count === 1) {
332
+ reviewCount = 0;
333
+ reviewReason = 'one person can push, and GitHub does not count a pull request author\'s own approval; a pull request is still required';
334
+ } else {
335
+ reviewReason = `${w.count} people can push`;
336
+ }
337
+ }
338
+ const payload = buildPayload({ contexts: mergedContexts, requireReviews: reviewCount });
228
339
 
229
340
  // 6. PUT
230
341
  const put = exec(
@@ -233,7 +344,7 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
233
344
  { input: JSON.stringify(payload) }
234
345
  );
235
346
  if (put.status === 0) {
236
- return { status: 'installed', branch };
347
+ return { status: 'installed', branch, requiredChecks: mergedContexts, reviewCount, reviewReason };
237
348
  }
238
349
 
239
350
  // 7. PUT failed — classify and return graceful failure
@@ -241,16 +352,21 @@ async function installBranchProtection({ exec = defaultExec, log = () => {}, opt
241
352
  return {
242
353
  status: 'failed',
243
354
  reason: err.message,
244
- manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch }),
355
+ manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch, requireReviews: reviewCount }),
245
356
  };
246
357
  }
247
358
 
248
359
  module.exports = {
249
360
  buildPayload,
250
361
  mergeContexts,
362
+ pruneLegacyChecks,
363
+ resolveChecks,
364
+ countWriters,
251
365
  parseGhError,
252
366
  installBranchProtection,
253
367
  // Internal helpers exposed for inspection / future reuse
254
368
  buildManualCommand,
369
+ defaultExec,
255
370
  DEFAULT_REQUIRED_CHECKS,
371
+ LEGACY_REQUIRED_CHECK,
256
372
  };
@@ -256,8 +256,20 @@ function extractPathRefs(text) {
256
256
  return out;
257
257
  }
258
258
 
259
- /** Backticked spans in a story, longest first — the longest is the most specific anchor. */
260
- const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
259
+ /**
260
+ * Backticked spans in a story, PAIRED FIRST and length-unbounded; `storyTermsOf` applies the
261
+ * 3-to-200 rule to each paired span. HC-GF-188-followup-2.
262
+ *
263
+ * A BOUND INSIDE THE MATCH DECIDES WHICH BACKTICKS PAIR. At `` `x`, and `foo` `` the opening
264
+ * backtick of `x` could not start a 3-character match, so its CLOSING backtick did: the span ran
265
+ * to `foo`'s OPENING backtick, the "term" was `, and`, and `foo` was lost because no backtick was
266
+ * left to open it. A span over 200 characters, and an empty pair, shift the pairing the same way.
267
+ *
268
+ * The pairing is the one `maskCodeSpans` uses below: one rule for what a code span is, and the
269
+ * filter decides which spans can anchor. Fences are NOT masked here, which would widen the change
270
+ * past what the row measured.
271
+ */
272
+ const BACKTICKED_RE = /`([^`\n]*)`/g;
261
273
 
262
274
  /**
263
275
  * The terms a story quotes that could locate code, longest first — one copy of the filter.
@@ -269,7 +281,8 @@ const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
269
281
  function storyTermsOf(storyText) {
270
282
  return [...String(storyText || '').matchAll(BACKTICKED_RE)]
271
283
  .map((m) => m[1].trim())
272
- .filter((c) => c.length >= 3
284
+ // Both bounds read the TRIMMED span; the ceiling used to live in the regex, on the raw span.
285
+ .filter((c) => c.length >= 3 && c.length <= 200
273
286
  && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
274
287
  && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
275
288
  .sort((a, b) => b.length - a.length);
@@ -924,6 +937,10 @@ const LABEL_RE = /\*\*([A-Z][^*\n]{0,80}?):\*\*/g;
924
937
  * Fences are masked FIRST so a backtick inside a fence cannot pair with one
925
938
  * outside it.
926
939
  *
940
+ * HC-GF-188-followup-2: `BACKTICKED_RE` (read by `storyTermsOf`) pairs backticks the same way,
941
+ * `[^`\n]*` between two of them. Change the pairing in one place and change it in the other, or
942
+ * the two will disagree about what a code span is.
943
+ *
927
944
  * @param {string} text
928
945
  * @returns {string} same length as the input
929
946
  */
@@ -16,6 +16,8 @@
16
16
  * the unshipped `scripts/` tree.
17
17
  */
18
18
 
19
+ const { readAnthropicText } = require('./anthropic-response');
20
+
19
21
  /** Said by every command that used to default to GitHub Models, so the cause is one sentence. */
20
22
  const GH_MODELS_RETIRED =
21
23
  'GitHub Models (--provider gh-models), the free default, was retired by GitHub on 2026-07-30: '
@@ -38,25 +40,32 @@ function buildJudgeCallLLM(provider, { axios }) {
38
40
  return { error: 'ANTHROPIC_API_KEY required for --provider claude. Set it: export ANTHROPIC_API_KEY=sk-ant-...' };
39
41
  }
40
42
  // MODEL CHOICE (feedback_model_choice_cost_amplifier): the judge is NOT on the
41
- // OPUS_AGENTS allowlist — paid mode uses Sonnet (`claude-sonnet-5`), never Opus.
43
+ // OPUS_AGENTS allowlist — paid mode uses Sonnet (`claude-sonnet-5-5`), never Opus.
44
+ //
45
+ // HC-GF-337f: Sonnet 5.5 answers HTTP 400 to `thinking: {type: 'disabled'}`. Thinking and effort are
46
+ // TWO sibling fields, never one: `thinking: {type: 'adaptive'}` and `output_config: {effort: 'low'}`
47
+ // (a `thinking` object with an `effort` key is wrong). Effort is sent because its default is `high`
48
+ // and a judge wants `low`. Thinking tokens share `max_tokens`, hence 8192. No `temperature`, `top_p`,
49
+ // `top_k` or `tool_choice` is sent, and that is load-bearing now: a non-default sampling value or a
50
+ // forced tool choice is a 400 on this model.
42
51
  const callLLM = async (systemPrompt, userPrompt) => {
43
52
  const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
44
- model: 'claude-sonnet-5',
45
- thinking: { type: 'disabled' },
46
- max_tokens: 2048,
53
+ model: 'claude-sonnet-5-5',
54
+ thinking: { type: 'adaptive' },
55
+ output_config: { effort: 'low' },
56
+ max_tokens: 8192,
47
57
  system: systemPrompt,
48
58
  messages: [{ role: 'user', content: userPrompt }],
49
59
  }, {
50
60
  headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
51
- timeout: 60000,
61
+ timeout: 120000,
52
62
  });
53
- const text = (data.content || []).find((b) => b?.type === 'text')?.text;
54
63
  // A provider that answered with nothing is a provider failure, not a failed criterion:
55
- // returning '' here made every criterion read "could not parse response".
56
- if (typeof text !== 'string' || !text.trim()) {
57
- throw new Error(`the claude provider returned no text (stop_reason: ${data.stop_reason || 'unknown'})`);
58
- }
59
- return text;
64
+ // returning '' here made every criterion read "could not parse response". A refusal (HTTP 200,
65
+ // stop_reason "refusal") names its category so it is not read as a quota or parse problem.
66
+ const r = readAnthropicText(data, { label: 'the claude provider', allowPartial: true }); // a verdict cut off defaults to NO
67
+ if (!r.ok) throw new Error(r.message);
68
+ return r.text;
60
69
  };
61
70
  return { callLLM, provider: 'claude', paid: true };
62
71
  }
@@ -67,8 +67,9 @@ function resolveBaseRef(base) {
67
67
  * documentation later confirms the cap is TOTAL (input + output) instead
68
68
  * of input-only, drop this number AND `max_tokens` together.
69
69
  *
70
- * Anthropic Opus `claude-opus-4-8`: 1M context window (upgraded from
71
- * the legacy 4-20250514's 200K). The 100K char cap is well below the
70
+ * Anthropic Opus `claude-opus-5-5`: 1M context window (upgraded from
71
+ * the legacy 4-20250514's 200K; HC-GF-337f moved the id from claude-opus-4-8; the 1M figure is the
72
+ * Anthropic models table's context window for claude-opus-5-5, read 2026-10-10). The 100K char cap is well below the
72
73
  * model's headroom but kept conservative — the prompt + diff together
73
74
  * comfortably fit, and bumping the cap is a separate decision.
74
75
  *