@hone-ai/cli 1.38.0 → 1.38.1

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.
@@ -259,6 +259,22 @@ function extractPathRefs(text) {
259
259
  /** Backticked spans in a story, longest first — the longest is the most specific anchor. */
260
260
  const BACKTICKED_RE = /`([^`\n]{3,200})`/g;
261
261
 
262
+ /**
263
+ * The terms a story quotes that could locate code, longest first — one copy of the filter.
264
+ * HC-GF-206-followup-3: three readers now ask this question (`anchorLineForStory`,
265
+ * `anchorSitesForStory` and `unanchoredTerms`), and two hand-typed copies of it is how one
266
+ * reader ends up judging a different set of terms than the others. Path-shaped spans and the
267
+ * extensionless filenames are dropped: a story always quotes the file's own path.
268
+ */
269
+ function storyTermsOf(storyText) {
270
+ return [...String(storyText || '').matchAll(BACKTICKED_RE)]
271
+ .map((m) => m[1].trim())
272
+ .filter((c) => c.length >= 3
273
+ && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
274
+ && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
275
+ .sort((a, b) => b.length - a.length);
276
+ }
277
+
262
278
  /**
263
279
  * The line of the first thing the STORY quoted that actually occurs in this file — HC-GF-188.
264
280
  *
@@ -286,12 +302,7 @@ function anchorLineForStory(content, storyText) {
286
302
  const story = String(storyText || '');
287
303
  if (!text || !story) return null;
288
304
 
289
- const candidates = [...story.matchAll(BACKTICKED_RE)]
290
- .map((m) => m[1].trim())
291
- .filter((c) => c.length >= 3
292
- && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
293
- && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
294
- .sort((a, b) => b.length - a.length);
305
+ const candidates = storyTermsOf(story);
295
306
 
296
307
  for (const anchor of candidates) {
297
308
  const at = definitionIndexOf(text, anchor);
@@ -334,12 +345,7 @@ function anchorSitesForStory(content, storyText, max = 6) {
334
345
  const text = String(content || '');
335
346
  const story = String(storyText || '');
336
347
  if (!text || !story) return { sites: [], collapsed: [] };
337
- const candidates = [...story.matchAll(BACKTICKED_RE)]
338
- .map((m) => m[1].trim())
339
- .filter((c) => c.length >= 3
340
- && !new RegExp(`^[\\w.\\-/]+\\.(?:${FILE_EXT})$`).test(c)
341
- && !new RegExp(`^(?:${EXTENSIONLESS})$`).test(c))
342
- .sort((a, b) => b.length - a.length);
348
+ const candidates = storyTermsOf(story);
343
349
 
344
350
  const seenLine = new Set();
345
351
  const sites = [];
@@ -382,6 +388,81 @@ function definitionIndexOf(text, term) {
382
388
  return text.indexOf(term);
383
389
  }
384
390
 
391
+ /**
392
+ * The story's terms that occur in this file but in NONE of the windows bundled — HC-GF-206-followup-3.
393
+ *
394
+ * `anchorSitesForStory` stops at 6 sites, and terms are tried longest first, so every hit after
395
+ * the sixth site — including every hit of a shorter term — is neither a site nor a collapsed hit.
396
+ * A window can also be dropped for budget with its term in it. The notice counted neither, and
397
+ * nothing told the agent: a term absent from the excerpt reads exactly like a term absent from the
398
+ * FILE. Measured 2026-10-05 at the production cap over every roadmap row against cli/hone-cli.js and
399
+ * server/src/services/orchestrator.js: 425 of 1,198 windowed excerpts left out 901 whole-word terms,
400
+ * none named.
401
+ *
402
+ * Judged against the FINAL windows, not the site list, so it holds whatever dropped the term — the
403
+ * site limit, the budget, a collapse — and needs no knowledge of how the layout was chosen.
404
+ *
405
+ * A term counts as occurring only where it stands as a whole word. `definitionIndexOf` falls back to
406
+ * a bare `indexOf`, which finds `info` inside `information`; reporting that as "`info` is at line N"
407
+ * would send an agent to a line that does not contain it.
408
+ *
409
+ * @param {string} content full file contents
410
+ * @param {string} storyText
411
+ * @param {Array<[number, number]>} ranges the bundled windows' [startLine, endLine], 1-indexed
412
+ * @returns {Array<{term: string, line: number}>} story order (longest first); `line` is the
413
+ * definition's line when the definition is a whole-word occurrence, else the first mention
414
+ */
415
+ function unanchoredTerms(content, storyText, ranges) {
416
+ const text = String(content || '');
417
+ if (!text) return [];
418
+ const starts = [0];
419
+ for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
420
+ const lineOf = (off) => {
421
+ let lo = 0;
422
+ let hi = starts.length - 1;
423
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; }
424
+ return lo + 1;
425
+ };
426
+ const covered = (l) => ranges.some(([a, b]) => a <= l && l <= b);
427
+ const out = [];
428
+ for (const term of new Set(storyTermsOf(storyText))) {
429
+ const def = definitionIndexOf(text, term);
430
+ if (def === -1) continue;
431
+ const t = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
432
+ const mentions = [...text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))].map((m) => lineOf(m.index));
433
+ if (!mentions.length) continue;
434
+ // Covered only by a whole-word occurrence. The bundler may have anchored on `def` itself, but
435
+ // when `def` is the indexOf fallback that window shows `information`, not `info`.
436
+ if (mentions.some(covered)) continue;
437
+ const defLine = lineOf(def);
438
+ out.push({ term, line: mentions.includes(defLine) ? defLine : mentions[0] });
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * The excerpt's first line when story terms were left out — HC-GF-206-followup-3. It goes FIRST
445
+ * because step_0's premise check keeps only the first 2,000 chars of each file
446
+ * (`STEP_0_CODE_CAP_PER_FILE`, server/src/services/orchestrator.js), and "this symbol already
447
+ * exists" is exactly what that step looks for. It must fit the room the windows left under the cap:
448
+ * terms are listed longest first and the tail is folded into "+N more"; with no room for even that,
449
+ * it is omitted and only the operator's notice carries the list.
450
+ */
451
+ function unanchoredMarker(unanchored, room) {
452
+ if (!unanchored.length) return '';
453
+ const head = `# [HC-GF-206-followup-3: ${unanchored.length} term(s) the story names occur in this file `
454
+ + 'OUTSIDE every excerpt below: ';
455
+ const tail = '. They exist; you were not shown them, so do not conclude they are absent.]\n';
456
+ const items = unanchored.map(({ term, line }) => `\`${term}\` (line ${line})`);
457
+ for (let k = items.length; k >= 0; k -= 1) {
458
+ const rest = items.length - k;
459
+ const list = items.slice(0, k).join(', ') + (rest ? `${k ? ', ' : ''}+${rest} more` : '');
460
+ const marker = head + list + tail;
461
+ if (marker.length <= room) return marker;
462
+ }
463
+ return '';
464
+ }
465
+
385
466
  /**
386
467
  * Decide what a bundled file should actually contain — HC-GF-188.
387
468
  *
@@ -401,7 +482,9 @@ function definitionIndexOf(text, term) {
401
482
  *
402
483
  * @param {{content: string, cap: number, line?: number|null, lines?: number[], storyText?: string, path?: string}} input
403
484
  * @returns {{mode: 'full'|'window'|'truncate', content: string, notice: string|null, anchor?: string,
404
- * unhonoured: Array<{line: number, reason: 'past-eof'|'budget', totalLines: number}>}}
485
+ * unhonoured: Array<{line: number, reason: 'past-eof'|'budget', totalLines: number}>,
486
+ * unanchored: Array<{term: string, line: number}>}} — HC-GF-206-followup-3: story terms the file
487
+ * has that no bundled window carries; always empty outside `window` mode
405
488
  */
406
489
  function planFileExcerpt({ content, cap, line = null, lines = null, storyText = '', path: filePath = 'file' } = {}) {
407
490
  const text = String(content || '');
@@ -415,7 +498,7 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
415
498
  const stated = requested.filter((n) => n <= totalLines);
416
499
  const unhonoured = requested.filter((n) => n > totalLines)
417
500
  .map((n) => ({ line: n, reason: 'past-eof', totalLines }));
418
- if (text.length <= limit) return { mode: 'full', content: text, notice: null, unhonoured };
501
+ if (text.length <= limit) return { mode: 'full', content: text, notice: null, unhonoured, unanchored: [] };
419
502
 
420
503
  // An explicit line wins: the author stated it, the anchor is inferred from prose.
421
504
  const hit = stated.length ? { line: stated[0], anchor: null } : anchorLineForStory(text, storyText);
@@ -698,14 +781,30 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
698
781
  ? ` — ${unreached} near-duplicate hit(s) collapsed into a window that did not reach them`
699
782
  : '');
700
783
 
784
+ const body = windows.map(({ w, anchor, line: siteLine }) =>
785
+ header(w, anchor, siteLine) + w.excerpt + footer).join('\n');
786
+ // HC-GF-206-followup-3: a story term the file has and no window carries is NAMED, to the agent
787
+ // first and the operator second. Only the room the windows left is spent: the layout above is
788
+ // untouched, so what every run already bundled stays exactly what it was.
789
+ const unanchored = unanchoredTerms(text, storyText, windows.map(({ w }) => [w.startLine, w.endLine]));
790
+ const marker = unanchoredMarker(unanchored, limit - body.length);
791
+ const SHOWN = 8;
792
+ const termsNote = unanchored.length
793
+ ? ` — ${unanchored.length} story term(s) in the file but in no bundled window: `
794
+ + unanchored.slice(0, SHOWN).map(({ term, line: l }) => `\`${term}\` (line ${l})`).join(', ')
795
+ + (unanchored.length > SHOWN ? `, +${unanchored.length - SHOWN} more` : '')
796
+ + `; name them as ${filePath}:<line> to anchor them`
797
+ + (marker ? '' : ' (the excerpt had no room left to tell the agent)')
798
+ : '';
799
+
701
800
  return {
702
801
  mode: 'window',
703
802
  unhonoured,
803
+ unanchored,
704
804
  anchor: windows[0].anchor || null,
705
- content: windows.map(({ w, anchor, line: siteLine }) =>
706
- header(w, anchor, siteLine) + w.excerpt + footer).join('\n'),
805
+ content: marker + body,
707
806
  notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — bundled lines `
708
- + `${ranges} of ${windows[0].w.totalLines}, ${because}${shortfall}`,
807
+ + `${ranges} of ${windows[0].w.totalLines}, ${because}${shortfall}${termsNote}`,
709
808
  };
710
809
  }
711
810
 
@@ -718,6 +817,7 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
718
817
  return {
719
818
  mode: 'truncate',
720
819
  unhonoured,
820
+ unanchored: [], // reached only when no story term occurs in the file at all
721
821
  content: text.slice(0, keep) + marker(keep),
722
822
  notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
723
823
  + `DROPPED and nothing in the story text matched inside the file, so the region you care about `
@@ -932,7 +1032,7 @@ function classifyStoryPaths(text, named) {
932
1032
  module.exports = {
933
1033
  BARE_FILE_RE, extractBareNames, extractBareRefs, resolveBareName, alreadyCovered, windowAroundLine,
934
1034
  PATH_REF_RE, extractPathRefs, anchorLineForStory, planFileExcerpt, definitionIndexOf, anchorSitesForStory,
935
- windowBetweenLines, mergeIntersectingWindows,
1035
+ windowBetweenLines, mergeIntersectingWindows, storyTermsOf, unanchoredTerms, unanchoredMarker,
936
1036
  // HC-GF-229 — classification. Pinned byte-for-byte against the server copy.
937
1037
  LABEL_RE, maskCodeSpans, targetSpans, classifyStoryPaths,
938
1038
  };
@@ -22,7 +22,7 @@
22
22
 
23
23
  const fs = require('node:fs');
24
24
  const path = require('node:path');
25
- const { execSync } = require('node:child_process');
25
+ const { execFileSync } = require('node:child_process');
26
26
  const { gitEnv } = require('./git-env');
27
27
 
28
28
  // HC-005-A: ambiguous pattern named explicitly so the ambiguity-guard
@@ -62,10 +62,18 @@ function checkAdminMerge(args) {
62
62
  return { name, status: 'skip', reason: 'no repoRoot supplied' };
63
63
  }
64
64
 
65
+ // HC-GF-270-followup-3: `lookback` became part of a shell string, so any caller of this exported
66
+ // function could run a command through it. It is a count; anything else is refused, and git gets an
67
+ // argument list. The format has no shell quotes now — under a shell they were stripped; as an
68
+ // argument they would reach git literally and end up in every sha.
69
+ if (!Number.isInteger(lookback) || lookback < 1) {
70
+ return { name, status: 'skip', reason: `lookback must be a positive integer (got ${typeof lookback === 'number' ? String(lookback) : JSON.stringify(lookback)})` };
71
+ }
72
+
65
73
  // Verify it's a git repo
66
74
  let log;
67
75
  try {
68
- log = execSync(`git log -n ${lookback} --pretty=format:'%H%x09%s%x09%b%x1e'`, {
76
+ log = execFileSync('git', ['log', '-n', String(lookback), '--pretty=format:%H%x09%s%x09%b%x1e'], {
69
77
  cwd: repoRoot, env: gitEnv(),
70
78
  encoding: 'utf8',
71
79
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -166,7 +174,7 @@ function checkAdminMerge(args) {
166
174
  */
167
175
  function isShallowClone(repoRoot) {
168
176
  try {
169
- const out = execSync('git rev-parse --is-shallow-repository', {
177
+ const out = execFileSync('git', ['rev-parse', '--is-shallow-repository'], {
170
178
  cwd: repoRoot, env: gitEnv(),
171
179
  encoding: 'utf8',
172
180
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ /**
3
+ * git-remote.js — HC-GF-283: the GitHub host, owner and repository this checkout's `origin` names, with
4
+ * an SSH host alias resolved to the host it stands for.
5
+ *
6
+ * WHY. `run-story <issue-number>` fetches the issue's text with `gh issue view` and its author with
7
+ * `gh api`. Both used to receive `owner/repo` and no host, so gh used its DEFAULT host. For a remote
8
+ * like `git@ghe:platform/api.git`, where `ghe` is a `~/.ssh/config` alias for a GitHub Enterprise host
9
+ * and gh's default is github.com, both calls read `github.com/platform/api` — where someone else can
10
+ * own that name, file issue #N as its OWNER, and pass HC-GF-270's provenance check, which skipped the
11
+ * host comparison for an alias because an alias is not a hostname.
12
+ *
13
+ * `ssh -G <alias>` prints the configuration ssh would use for that alias, `hostname` included, without
14
+ * connecting. That turns the alias into the host gh must be told about, so both gh calls name it and
15
+ * the provenance check can always compare it with the issue's `html_url`.
16
+ *
17
+ * Pure apart from the injected `sshConfig`, so every branch is testable without ssh or a network.
18
+ */
19
+
20
+ const { rawRemoteHost } = require('./story-provenance');
21
+
22
+ // owner/repo: the last two path segments, `.git` dropped. The same rule run-story has always used.
23
+ const OWNER_REPO_RE = /[/:]([^/]+)\/([^/]+?)(?:\.git)?$/;
24
+ // What may be handed to `ssh -G` as an argument: a host name or alias, never an option. A remote of
25
+ // `git@-oProxyCommand=…:o/r` must not become `ssh -G -oProxyCommand=…`.
26
+ const SSH_HOST_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+ // The resolved host gh is told about: a hostname, nothing that could be read as a path or a flag.
28
+ const HOSTNAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
29
+
30
+ class RemoteHostError extends Error {
31
+ constructor(message) { super(message); this.code = 'HC_REMOTE_HOST'; }
32
+ }
33
+
34
+ /**
35
+ * How the remote is reached: 'ssh' (an scp-like `user@host:path` or an ssh:// URL — the host may be an
36
+ * alias), 'url' (https/http/git — the host is the host), or null (a local path or nothing parsable).
37
+ */
38
+ function remoteTransport(remoteUrl) {
39
+ const s = String(remoteUrl == null ? '' : remoteUrl).trim();
40
+ if (!s) return null;
41
+ const scheme = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):\/\//);
42
+ if (scheme) {
43
+ const p = scheme[1].toLowerCase();
44
+ if (p === 'ssh' || p === 'git+ssh' || p === 'ssh+git') return 'ssh';
45
+ if (p === 'https' || p === 'http' || p === 'git') return 'url';
46
+ return null; // file:// and anything else has no GitHub host
47
+ }
48
+ // scp-like: `[user@]host:path`. A Windows drive letter (`C:\…`, `C:/…`) is a local path.
49
+ if (/^[A-Za-z]:[\\/]/.test(s)) return null;
50
+ return /^(?:[^@/\s]+@)?[^/:\s]+:/.test(s) ? 'ssh' : null;
51
+ }
52
+
53
+ /** GitHub's SSH-over-HTTPS endpoint serves github.com's repositories (HC-GF-270 security review LOW-3). */
54
+ function canonicalHost(host) {
55
+ const h = String(host || '').trim().toLowerCase();
56
+ return h === 'ssh.github.com' ? 'github.com' : h;
57
+ }
58
+
59
+ /** The `hostname` line of `ssh -G` output, or null. */
60
+ function hostnameFromSshConfig(stdout) {
61
+ const m = String(stdout || '').match(/^hostname\s+(\S+)\s*$/im);
62
+ return m ? m[1] : null;
63
+ }
64
+
65
+ /**
66
+ * @param {string} remoteUrl `git remote get-url origin`
67
+ * @param {{ sshConfig: (host: string) => Promise<string> }} io runs `ssh -G <host>` and resolves to its stdout
68
+ * @returns {Promise<{ host: string, owner: string, repo: string, remoteUrl: string, via: string|null }>}
69
+ * `host` is the host gh must be told about; `via` is the alias it was resolved from, or null.
70
+ * Rejects with a RemoteHostError (code HC_REMOTE_HOST) when the host cannot be established: the
71
+ * callers then fetch nothing, rather than letting gh fall back to its default host.
72
+ */
73
+ async function resolveRemote(remoteUrl, io) {
74
+ const url = String(remoteUrl == null ? '' : remoteUrl).trim();
75
+ const m = url.match(OWNER_REPO_RE);
76
+ if (!m) throw new RemoteHostError(`could not parse owner/repo from remote: ${url}`);
77
+ const [, owner, repo] = m;
78
+ const transport = remoteTransport(url);
79
+ const raw = rawRemoteHost(url);
80
+ if (!transport || !raw) throw new RemoteHostError(`the remote ${url} names no GitHub host`);
81
+
82
+ let host = raw;
83
+ let via = null;
84
+ if (transport === 'ssh') {
85
+ if (!SSH_HOST_ARG_RE.test(raw)) throw new RemoteHostError(`the remote's SSH host ${JSON.stringify(raw)} is not a host name`);
86
+ let out;
87
+ try {
88
+ out = await io.sshConfig(raw);
89
+ } catch (e) {
90
+ const why = String((e && e.stderr) || (e && e.message) || e).trim().split('\n')[0];
91
+ throw new RemoteHostError(`could not resolve the SSH host ${raw} with ssh -G: ${why}`);
92
+ }
93
+ const resolved = hostnameFromSshConfig(out);
94
+ if (!resolved) throw new RemoteHostError(`ssh -G ${raw} printed no hostname`);
95
+ host = resolved.toLowerCase();
96
+ if (host !== raw) via = raw;
97
+ }
98
+ host = canonicalHost(host);
99
+ if (!HOSTNAME_RE.test(host)) throw new RemoteHostError(`the remote's host ${JSON.stringify(host)} is not a hostname`);
100
+ return { host, owner, repo, remoteUrl: url, via };
101
+ }
102
+
103
+ module.exports = { resolveRemote, remoteTransport, hostnameFromSshConfig, canonicalHost, RemoteHostError };
@@ -1,27 +1,44 @@
1
- 'use strict';
2
1
  /**
3
- * judge-provider.js — HC-COMM-011-followup-3
2
+ * judge-provider.js — HC-COMM-011-followup-3, HC-GF-340
4
3
  *
5
4
  * The shared LLM-judge provider builder. Returns a `callLLM(system, user)` bound to
6
- * a provider, honoring the zero-cost discipline (feedback_pipeline_llm_cost_reduction):
7
- * default is FREE GH Models (via GITHUB_TOKEN); paid Claude Sonnet is opt-in.
5
+ * a provider. Returns `{ callLLM }` on success or `{ error }` when no working provider
6
+ * was named or its credential is missing — the caller decides how to surface it.
8
7
  *
9
- * Extracted so both `hone eval --judge` and `hone agent-eval --judge` use ONE path
10
- * instead of duplicating the provider wiring. Returns `{ callLLM }` on success or
11
- * `{ error }` when the required credential is missing — the caller decides how to
12
- * surface it (exit, skip, warn).
8
+ * HC-GF-340: the free default (GitHub Models via GITHUB_TOKEN) is gone. GitHub retired it on
9
+ * 2026-07-30 and its endpoint now answers HTTP 200 "OK" to every request, so a judge on it could
10
+ * not answer and every criterion read "could not parse response". There is no free replacement
11
+ * here, so no provider is a default: the operator names one, and the only one that works is the
12
+ * paid `claude` (Sonnet) provider. An adopter must not be billed by a default they never chose.
13
13
  *
14
- * MODEL CHOICE (feedback_model_choice_cost_amplifier): the judge is NOT on the
15
- * OPUS_AGENTS allowlist — paid mode uses Sonnet (`claude-sonnet-5`), never Opus.
14
+ * ADOPTER SURFACE (HC-LC-001): this file ships in the published CLI, so it must never spawn a
15
+ * local Claude Code binary. A free path for the repo owner's own machine, if wanted, belongs in
16
+ * the unshipped `scripts/` tree.
16
17
  */
18
+
19
+ /** Said by every command that used to default to GitHub Models, so the cause is one sentence. */
20
+ const GH_MODELS_RETIRED =
21
+ 'GitHub Models (--provider gh-models), the free default, was retired by GitHub on 2026-07-30: '
22
+ + 'its endpoint answers HTTP 200 "OK" to every request, so nothing can be judged or reviewed through it.';
23
+
24
+ /**
25
+ * The message for an unnamed or retired provider.
26
+ * @param {string} flag e.g. '--provider claude' — the one working provider for the calling command.
27
+ */
28
+ function providerRequiredMessage(flag) {
29
+ return `${GH_MODELS_RETIRED}\n Name a provider that works: ${flag} (paid, needs ANTHROPIC_API_KEY).`;
30
+ }
31
+
17
32
  function buildJudgeCallLLM(provider, { axios }) {
18
- const p = String(provider || 'gh-models').toLowerCase();
33
+ const p = String(provider || '').toLowerCase();
19
34
 
20
35
  if (p === 'claude' || p === 'anthropic') {
21
36
  const apiKey = process.env.ANTHROPIC_API_KEY;
22
37
  if (!apiKey) {
23
- return { error: 'ANTHROPIC_API_KEY required for --provider claude. Set it, or use the default free gh-models path (needs GITHUB_TOKEN).' };
38
+ return { error: 'ANTHROPIC_API_KEY required for --provider claude. Set it: export ANTHROPIC_API_KEY=sk-ant-...' };
24
39
  }
40
+ // 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.
25
42
  const callLLM = async (systemPrompt, userPrompt) => {
26
43
  const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
27
44
  model: 'claude-sonnet-5',
@@ -33,31 +50,19 @@ function buildJudgeCallLLM(provider, { axios }) {
33
50
  headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
34
51
  timeout: 60000,
35
52
  });
36
- return (data.content || []).find((b) => b?.type === 'text')?.text || '';
53
+ const text = (data.content || []).find((b) => b?.type === 'text')?.text;
54
+ // 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;
37
60
  };
38
61
  return { callLLM, provider: 'claude', paid: true };
39
62
  }
40
63
 
41
- // Default: GH Models — free inference via GITHUB_TOKEN (OpenAI-shaped).
42
- const ghToken = process.env.GITHUB_TOKEN;
43
- if (!ghToken) {
44
- return { error: 'GITHUB_TOKEN not set — required for the default free gh-models judge. In CI it is auto-injected; locally: export GITHUB_TOKEN=<PAT>. Or use --provider claude (paid, needs ANTHROPIC_API_KEY).' };
45
- }
46
- const callLLM = async (systemPrompt, userPrompt) => {
47
- const { data } = await axios.post('https://models.github.ai/inference/chat/completions', {
48
- model: 'openai/gpt-4.1',
49
- messages: [
50
- { role: 'system', content: systemPrompt },
51
- { role: 'user', content: userPrompt },
52
- ],
53
- max_tokens: 2048,
54
- }, {
55
- headers: { Authorization: `Bearer ${ghToken}`, 'content-type': 'application/json' },
56
- timeout: 60000,
57
- });
58
- return data.choices?.[0]?.message?.content || '';
59
- };
60
- return { callLLM, provider: 'gh-models', paid: false };
64
+ // No provider named, or the retired one: refuse before any request is made.
65
+ return { error: providerRequiredMessage('--provider claude') };
61
66
  }
62
67
 
63
- module.exports = { buildJudgeCallLLM };
68
+ module.exports = { buildJudgeCallLLM, providerRequiredMessage, GH_MODELS_RETIRED };
package/lib/mcp-tools.js CHANGED
@@ -124,10 +124,22 @@ async function listWorkflows({ status, limit, cwd } = {}) {
124
124
  // Right for a person at a terminal; wrong behind a tool whose contract is an explicit run ID, where a
125
125
  // story ID is an agent's guess at a target. It used to fail only by accident (the server 404'd it), so
126
126
  // the tools that ACT on a run refuse anything that is not a run ID before shelling out.
127
- const { isRunId } = require('./run-target');
127
+ const { isRunId, runArgsError } = require('./run-target');
128
128
  const notARunId = (runId) =>
129
129
  `runId must be a run ID (a UUID); ${JSON.stringify(String(runId))} is not. Get one from hone_run_story or hone_list_workflows.`;
130
130
 
131
+ // HC-GF-270-followup-3: the other tools that take a run id, step key or ref check it the same way, BEFORE the
132
+ // CLI is spawned. The CLI refuses them too, but with exit 2 — which checkPatch reported to the agent as
133
+ // "diff does not apply". Labels are the tool's own input names. Values are checked as the CLI receives them.
134
+ const refused = (checks) => {
135
+ const msg = runArgsError(checks);
136
+ return msg ? { isError: true, detail: msg } : null;
137
+ };
138
+ // An optional input that is absent, null or '' counts as not given: both tools that use this drop such a value
139
+ // before it reaches the CLI (`if (workflowId)`), so there is nothing to check. That is deliberate, unlike runArgsError on its own,
140
+ // which still checks a '' it is handed.
141
+ const asArg = (v) => (v === undefined || v === null || v === '' ? undefined : String(v));
142
+
131
143
  // hone_kill_workflow — kill ONE run by explicit id (DESTRUCTIVE, irreversible).
132
144
  // runId is schema-required: an agent must never guess a target for a kill —
133
145
  // explicit id or nothing (memo D3 refutation). Server 409s non-killable states.
@@ -150,6 +162,8 @@ async function killWorkflow({ runId, cwd } = {}) {
150
162
  // written to disk by any Hone code.
151
163
  async function stepInstruction({ runId, cwd } = {}) {
152
164
  if (!runId) return { isError: true, detail: 'runId is required' };
165
+ const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
166
+ if (bad) return bad;
153
167
  const r = await runHone(['step-instruction', String(runId), '--format', 'json'], { cwd });
154
168
  if (r.error) return { isError: true, detail: r.error };
155
169
  const data = parseLastJson(r.stdout);
@@ -185,6 +199,12 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
185
199
  if (!fromTree && !hasOutput) {
186
200
  return { isError: true, detail: 'output is required (the complete step output text), or set fromTree' };
187
201
  }
202
+ // Before the temp file: a refused id writes nothing.
203
+ const bad = refused([
204
+ { label: 'runId', kind: 'runId', value: String(runId) },
205
+ { label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
206
+ ]);
207
+ if (bad) return bad;
188
208
 
189
209
  const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
190
210
  let tmp = null;
@@ -232,6 +252,8 @@ async function status({ runId, cwd } = {}) {
232
252
  // execution, in the CLI), optionally report the verdict. Long-running → wide timeout.
233
253
  async function verifyPatch({ runId, report = false, cwd } = {}) {
234
254
  if (!runId) return { isError: true, detail: 'runId is required' };
255
+ const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
256
+ if (bad) return bad;
235
257
  const args = ['verify-patch', String(runId), '--format', 'json'];
236
258
  if (report) args.push('--report');
237
259
  const r = await runHone(args, { cwd, timeoutMs: 600000 });
@@ -246,6 +268,11 @@ async function verifyPatch({ runId, report = false, cwd } = {}) {
246
268
  // hone_check_patch — does step_4's diff apply cleanly? (throwaway git apply --check)
247
269
  async function checkPatch({ runId, stepKey = 'step_4', cwd } = {}) {
248
270
  if (!runId) return { isError: true, detail: 'runId is required' };
271
+ const bad = refused([
272
+ { label: 'runId', kind: 'runId', value: String(runId) },
273
+ { label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
274
+ ]);
275
+ if (bad) return bad;
249
276
  const r = await runHone(['check-patch', String(runId), '--step-key', String(stepKey), '--format', 'json'], { cwd });
250
277
  if (r.error) return { isError: true, detail: r.error };
251
278
  const data = parseLastJson(r.stdout);
@@ -255,6 +282,8 @@ async function checkPatch({ runId, stepKey = 'step_4', cwd } = {}) {
255
282
  // hone_emit_pr — verified diff → branch → draft PR (staged: --push, --open-pr).
256
283
  async function emitPr({ runId, push = false, openPr = false, branch, base, cwd } = {}) {
257
284
  if (!runId) return { isError: true, detail: 'runId is required' };
285
+ const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
286
+ if (bad) return bad;
258
287
  const args = ['emit-pr', String(runId), '--format', 'json'];
259
288
  if (openPr) args.push('--open-pr'); else if (push) args.push('--push');
260
289
  if (branch) args.push('--branch', String(branch));
@@ -268,6 +297,11 @@ async function emitPr({ runId, push = false, openPr = false, branch, base, cwd }
268
297
  // hone_verify_pr — real skill audit + real CI gate on an open PR, optional report.
269
298
  async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
270
299
  if (!pr) return { isError: true, detail: 'pr (number or branch) is required' };
300
+ const bad = refused([
301
+ { label: 'pr', kind: 'gitRev', value: String(pr) },
302
+ { label: 'workflowId', kind: 'runId', value: asArg(workflowId), optional: true },
303
+ ]);
304
+ if (bad) return bad;
271
305
  const args = ['verify-pr', String(pr), '--format', 'json'];
272
306
  if (report) { args.push('--report'); if (workflowId) args.push('--workflow-id', String(workflowId)); }
273
307
  const r = await runHone(args, { cwd, timeoutMs: 600000 });
@@ -280,6 +314,8 @@ async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
280
314
 
281
315
  // hone_agent_eval — test the adopter's own agents (HC-COMM-011), optional report.
282
316
  async function agentEval({ category, target, judge = false, provider, report = false, workflowId, cwd } = {}) {
317
+ const bad = refused([{ label: 'workflowId', kind: 'runId', value: asArg(workflowId), optional: true }]);
318
+ if (bad) return bad;
283
319
  const args = ['agent-eval', '--output', 'json'];
284
320
  if (category) args.push('--category', String(category));
285
321
  if (target) args.push('--target', String(target));
@@ -296,6 +332,11 @@ async function agentEval({ category, target, judge = false, provider, report = f
296
332
  // hone_show_step — the LLM output of an orchestrator step (readOnly).
297
333
  async function showStep({ runId, stepKey, cwd } = {}) {
298
334
  if (!runId || !stepKey) return { isError: true, detail: 'runId and stepKey are required' };
335
+ const bad = refused([
336
+ { label: 'runId', kind: 'runId', value: String(runId) },
337
+ { label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
338
+ ]);
339
+ if (bad) return bad;
299
340
  const r = await runHone(['show', 'step', String(runId), String(stepKey)], { cwd });
300
341
  if (r.error) return { isError: true, detail: r.error };
301
342
  return { isError: !r.ok, exitCode: r.exitCode, output: r.stdout.slice(0, 8000), detail: r.ok ? 'ok' : (r.stderr || 'show step failed').slice(0, 500) };
@@ -38,7 +38,6 @@ const {
38
38
  extractType, extractSecurityImplications, extractCrossRepoDependency,
39
39
  } = require('./story-classifier-extract');
40
40
  const { readArchitectConfigFromText } = require('./architect-config');
41
- const { remoteHostFromUrl } = require('./story-provenance');
42
41
  const path = require('path');
43
42
 
44
43
  /**
@@ -121,7 +120,8 @@ async function issueStorySource(io, issueNumber, issue) {
121
120
  author: (api.user && api.user.login) || null,
122
121
  association: api.author_association,
123
122
  html_url: api.html_url,
124
- remoteHost: remoteHostFromUrl(got.remoteUrl),
123
+ // HC-GF-283: the host BOTH gh calls were told about — an SSH alias already resolved to it.
124
+ remoteHost: (got && got.host) || null,
125
125
  };
126
126
  return { source, notes: [info(` → story provenance: ${source.author || 'unknown'} (author_association: ${source.association || 'unknown'})`)] };
127
127
  } catch (e) {
@@ -311,7 +311,8 @@ function resolveRepoContext(io, opts = {}) {
311
311
  * @param {object} io
312
312
  * @param {(n: string) => Promise<{title:string, body?:string, url:string}>|{title:string, body?:string, url:string}} io.fetchIssue
313
313
  * rejects or throws on failure
314
- * @param {(n: string) => Promise<{owner:string, repo:string, remoteUrl:string, issue:object}>} io.fetchIssueProvenance
314
+ * @param {(n: string) => Promise<{owner:string, repo:string, remoteUrl:string, host:string, issue:object}>} io.fetchIssueProvenance
315
+ * `host` is the GitHub host the call was made against (HC-GF-283), which the provenance check compares with html_url's
315
316
  * HC-GF-270: `gh api repos/<owner>/<repo>/issues/<n>`; rejects with gh's stderr on the error, and
316
317
  * `where: {owner, repo}` once the remote was parsed
317
318
  * @param {(id: string) => Promise<object>} io.fetchStoryContext GET /stories/:id/context; throws
package/lib/run-target.js CHANGED
@@ -18,6 +18,8 @@
18
18
  * `error` and decides the exit code.
19
19
  */
20
20
 
21
+ const path = require('node:path');
22
+
21
23
  const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
24
 
23
25
  // GET /orchestrate clamps `limit` to 1..100, lists newest first, and is scoped to the caller's org.
@@ -112,6 +114,19 @@ const isDeriveJobId = (s) => typeof s === 'string' && /^drv_[0-9a-f]{12}$/.test(
112
114
  // safe; these three it cannot, because `new URL()` resolves `.` and `..` as directory steps.
113
115
  const isPathName = (s) => typeof s === 'string' && s !== '' && s !== '.' && s !== '..';
114
116
 
117
+ // HC-GF-270-followup-3: a story id names a directory (`.github/pipeline/<id>/`, `.github/metrics/<id>.yml`).
118
+ // The branch extractor (pipeline-status.js STORY_ID_PATTERN) emits only `[A-Za-z0-9_-]` but has no length cap,
119
+ // so an id read from a branch is checked too (step-5b, metrics collect, verify-pr); the fallbacks are
120
+ // `unknown-story` and `no-story`. No `.`, `/` or `\`, so `../` cannot leave the directory; 80 characters, the cap
121
+ // server/src/routes/stories.js applies (MAX_STORY_ID_LEN). Its STORY_ID_SHAPE is stricter (no `_`): it decides
122
+ // what the server stores, this decides what may name a local directory.
123
+ const isStoryId = (s) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(s);
124
+ // A ref git or gh may receive as ONE argument. `;`, `$(` and quotes are harmless there (and legal in ref names);
125
+ // a leading `-` is not: `git diff --name-only '--output=x...HEAD'` writes a file. `--end-of-options` would
126
+ // also stop it, but only on git 2.24 and later — on an older git every diff would fail and an audit would pass
127
+ // on nothing, so the value is refused instead.
128
+ const isGitRev = (s) => typeof s === 'string' && s !== '' && !s.startsWith('-') && !s.includes('\0');
129
+
115
130
  const KINDS = {
116
131
  runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
117
132
  batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
@@ -119,11 +134,27 @@ const KINDS = {
119
134
  attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
120
135
  deriveJobId: { ok: isDeriveJobId, why: 'must be a derive job ID, like drv_0123456789ab', hint: ' — the ID "hone derive --refresh" printed' },
121
136
  pathName: { ok: isPathName, why: 'must be a name, not empty, "." or ".."', hint: '' },
137
+ storyId: { ok: isStoryId, why: 'must be a story ID, like HC-GF-270 (letters, digits, "-" and "_")', hint: '' },
138
+ gitRev: { ok: isGitRev, why: 'must be a git branch or revision, not empty and not starting with "-"', hint: '' },
139
+ count: { ok: isAttempt, why: 'must be a whole number from 1, like 15', hint: '' },
122
140
  };
123
141
 
142
+ /**
143
+ * HC-GF-270-followup-3: the directory a story id names under `baseDir`, refusing any id that is not one — or that
144
+ * resolves anywhere but one level under `baseDir`, checked independently of the pattern. Every verb that writes
145
+ * here refused a bad id first (`runArgsError`, kind `storyId`), so a throw here is a bug, not an input error.
146
+ */
147
+ function storyDir(baseDir, storyId) {
148
+ const base = path.resolve(String(baseDir));
149
+ if (!isStoryId(storyId)) throw new TypeError(`storyDir: ${JSON.stringify(storyId)} is not a story ID`);
150
+ const dir = path.resolve(base, storyId);
151
+ if (path.dirname(dir) !== base) throw new TypeError(`storyDir: ${JSON.stringify(storyId)} leaves ${base}`);
152
+ return dir;
153
+ }
154
+
124
155
  /**
125
156
  * The first failed check's message, or null when every check passes.
126
- * @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName', value: any, optional?: boolean }>} checks
157
+ * @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName'|'storyId'|'gitRev'|'count', value: any, optional?: boolean }>} checks
127
158
  * `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
128
159
  * is undefined (a flag that was not given); an empty string is always checked.
129
160
  */
@@ -179,5 +210,6 @@ function orchestratePath(segments, query) {
179
210
 
180
211
  module.exports = {
181
212
  resolveRunTarget, isRunId, LOOKUP_LIMIT,
182
- isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, runArgsError, apiPath, orchestratePath,
213
+ isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, isStoryId, isGitRev, storyDir,
214
+ runArgsError, apiPath, orchestratePath,
183
215
  };