@hone-ai/cli 1.38.0 → 1.39.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,270 @@
1
+ 'use strict';
2
+ /**
3
+ * context-reserve.js — HC-GF-351: serve the files a hard-paused run asked for, once, without a person.
4
+ *
5
+ * A step that cannot proceed for want of a file says so (HC-GF-263 records the paths it named), and a batch had
6
+ * nothing to serve them with. `follow-batch` now kills that run and queues its story again with those paths
7
+ * added to the story's line. This module is the part that decides WHETHER a request may be served and WHAT the
8
+ * line says. Pure: data in, data out. The loop's I/O is injected (cli/lib/batch-follow.js).
9
+ *
10
+ * THE TRUST BOUNDARY. The paths came out of an LLM artifact and the bundler reads each one and sends its contents
11
+ * to a model. An agent can be steered by the bundled source and the story text, so a request is a claim to check,
12
+ * not an instruction. A request is served only when EVERY path in it:
13
+ * - passes hard-pause-remedy's token allowlist (the characters a shell could misread are refused outright),
14
+ * - is a plain relative path: no `..`, nothing absolute, home-relative or option-like, no empty segment,
15
+ * - is not a credential file or a hidden file or directory (`.env`, `*.pem`, `id_rsa*`, `.ssh/`, `.git/`),
16
+ * - is a REGULAR FILE that git tracks in this checkout. A tracked symlink is refused: the bundler follows it,
17
+ * so `notes.txt -> ~/.ssh/id_rsa` would be a tracked path that leaves the checkout.
18
+ * - resolves, on the disk as it is NOW, to a path inside this checkout that passes the same rules. The index
19
+ * says what git recorded; a file or a directory replaced by a symlink since is invisible to it, and the
20
+ * bundler reads through the link (`realPath`, repoRealPath).
21
+ * The paths the story's own line carried (config.include_paths) are held to the same rules: any client of the org
22
+ * can write a run's config, so they are no more trusted than an agent's.
23
+ * One path that fails refuses the whole request, and so does a request the server cut off (`truncated`). A partly served request is a run that pauses again for what was
24
+ * left out, and the loop does not serve twice.
25
+ *
26
+ * Why not just "exists in the working tree": that includes untracked and ignored files, which is exactly where an
27
+ * `.env`, a local key or a scratch file with a pasted token lives. Tracked means a reviewer already sees it.
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const { remedyFor, partitionSafePaths, tokenOf, isSafeToken } = require('./hard-pause-remedy');
33
+ const { parseIncludeTarget } = require('./include-paths');
34
+ const { isSecretPath } = require('./bundle-context');
35
+ const { isBatchId } = require('./run-target');
36
+
37
+ /** The start of the reason a re-served run is killed with. The loop reads it back to find the new batch. */
38
+ const RESERVED_PREFIX = 're-served as batch ';
39
+
40
+ /** What the kill reason says; `reservedBatchOf` reads it back. */
41
+ function reserveReason(batchId, stepKey) {
42
+ return `${RESERVED_PREFIX}${batchId}: ${stepKey} named files it was not given`;
43
+ }
44
+
45
+ /**
46
+ * The batch a killed run was re-served as, or null. The kill is how the loop remembers, so a restart follows the
47
+ * new batch and does not serve the same story a second time.
48
+ * @param {object} run a row of `GET /orchestrate/batch/:id` runs (untrusted shape)
49
+ */
50
+ function reservedBatchOf(run) {
51
+ if (!run || run.status !== 'killed' || typeof run.errorMessage !== 'string') return null;
52
+ const m = run.errorMessage.match(/^re-served as batch (\S+?)(?::|\s|$)/);
53
+ return m && isBatchId(m[1]) ? m[1] : null;
54
+ }
55
+
56
+ /**
57
+ * The request a run is hard-paused on, or null. Scoped by hard-pause-remedy's reader to the run's CURRENT step,
58
+ * and the step the pause message names must be that step, so a request recorded on an earlier step that the run
59
+ * has since moved past is never served as if the current pause had asked for it.
60
+ * @param {object} run a `GET /orchestrate/:runId` payload
61
+ * @returns {{stepKey: string, paths: object[], truncated: boolean}|null}
62
+ */
63
+ function hardPauseRequest(run) {
64
+ if (!run || run.status !== 'paused' || typeof run.errorMessage !== 'string') return null;
65
+ const at = run.errorMessage.match(/^hard_pause at (\S+?)(?::|\s|$)/);
66
+ const remedy = at ? remedyFor(run) : null;
67
+ return remedy && remedy.stepKey === at[1] ? remedy : null;
68
+ }
69
+
70
+ // Credential-looking names the bundler's own refusal (isSecretPath) does not cover: a public half of a key pair,
71
+ // key containers, anything the author named a secret or a credential.
72
+ const SECRET_LOOKING = [/(^|[._-])id_(rsa|dsa|ecdsa|ed25519)/i, /\.(key|pfx|jks|keystore|p8)$/i, /(secret|credential)/i];
73
+
74
+ /** Why a path is not a plain relative path, or null. `file` has no `:line` or `:symbol` suffix. */
75
+ function structuralRefusal(file) {
76
+ if (typeof file !== 'string' || file === '') return 'empty';
77
+ if (/^[-/~]/.test(file)) return 'absolute, home-relative or option-like';
78
+ if (/[\\\0]/.test(file)) return 'not a plain relative path';
79
+ if (file.split('/').some((s) => s === '' || s === '.' || s === '..')) return 'not a plain relative path';
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Why the CONTENT of a path (a plain file path, no `:line`) must not be sent to a model, or null: the three
85
+ * rules that need no file list. HC-GF-217 reads this directly for the lines of a search result, so a search
86
+ * and a re-serve cannot disagree about what is withheld (they used to be one inline sequence in requestRefusal).
87
+ * @param {string} file
88
+ */
89
+ function contentRefusal(file) {
90
+ const plain = structuralRefusal(file);
91
+ if (plain) return plain;
92
+ if (isSecretPath(file) || SECRET_LOOKING.some((re) => re.test(path.posix.basename(file)))) return 'looks like a credential file';
93
+ if (file.split('/').some((s) => s.startsWith('.'))) return 'a hidden file or directory';
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Why a requested path (`file`, `file:line` or `file:symbol`) must not be served, or null.
99
+ * @param {string} token
100
+ * @param {Set<string>|null} tracked the regular files git tracks here; null when they could not be listed
101
+ */
102
+ function requestRefusal(token, tracked) {
103
+ const file = parseIncludeTarget(token).path;
104
+ const why = contentRefusal(file);
105
+ if (why) return why;
106
+ if (!(tracked instanceof Set)) return 'this checkout\'s tracked files could not be listed';
107
+ if (!tracked.has(file)) return 'not a regular file git tracks in this checkout';
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * May this request be served? All of it, or none of it.
113
+ * @param {{paths: object[], truncated?: boolean}} request from hardPauseRequest
114
+ * @param {{tracked?: Set<string>|null, realPath?: ((file: string) => string|null)|null, original?: string[]}} [ctx]
115
+ * `realPath`: where `file` resolves on the disk now, relative to the checkout, or null if outside it or unreadable;
116
+ * without it nothing is served (a request is not checked on a disk nobody looked at). `original`: the entries of
117
+ * the story's own line, judged by the same rules and written before what the step asked for.
118
+ * @returns {{ok: true, tokens: string[]}|{ok: false, reason: string}}
119
+ */
120
+ function judgeRequest(request, { tracked = null, realPath = null, original = [] } = {}) {
121
+ if (request && request.truncated === true) {
122
+ return { ok: false, reason: 'the step asked for more paths than were recorded, and a part of a request pauses again; nothing was served' };
123
+ }
124
+ const { safe, dropped } = partitionSafePaths(request && request.paths);
125
+ const candidates = [...original.map((token) => ({ token, own: true })), ...safe.map((p) => ({ token: tokenOf(p) }))];
126
+ const tokens = [];
127
+ const refused = [];
128
+ for (const { token, own } of candidates) {
129
+ const why = requestRefusal(token, tracked) || resolutionRefusal(token, tracked, realPath);
130
+ if (why) refused.push(`${token} (${why}${own ? ', on the story\'s own line' : ''})`);
131
+ else if (!tokens.includes(token)) tokens.push(token);
132
+ }
133
+ if (refused.length === 0 && dropped.length === 0) return { ok: true, tokens };
134
+ // A path the allowlist dropped is never printed: it holds characters a terminal or a shell could misread.
135
+ const parts = [];
136
+ if (refused.length) parts.push(`refused ${refused.slice(0, 5).join(', ')}${refused.length > 5 ? `, and ${refused.length - 5} more` : ''}`);
137
+ if (dropped.length) parts.push(`${dropped.length} requested path(s) hold characters a shell could misread`);
138
+ return { ok: false, reason: `${parts.join('; ')}; nothing was served` };
139
+ }
140
+
141
+ /**
142
+ * Where a path that passed requestRefusal really points. Null when it is fine, else why not. The path the bundler
143
+ * will read is the one the disk resolves, so THAT path has to pass the rules: a tracked `notes.txt` that is a link to
144
+ * the checkout's own `.env` is a credential read, and one that is a link out of the checkout is a read of anything.
145
+ */
146
+ function resolutionRefusal(token, tracked, realPath) {
147
+ if (typeof realPath !== 'function') return 'where it resolves on the disk was not checked';
148
+ const file = parseIncludeTarget(token).path;
149
+ const real = realPath(file);
150
+ if (real === null || real === undefined) return 'it does not resolve to a readable path inside this checkout';
151
+ if (real === file) return null;
152
+ const why = requestRefusal(real, tracked);
153
+ return why ? `it resolves to ${real}, which is refused: ${why}` : null;
154
+ }
155
+
156
+ /**
157
+ * `rel` resolved through every symlink on the disk, relative to the checkout (POSIX separators), or null when it
158
+ * does not exist or ends outside the checkout. The one place this module touches the file system.
159
+ */
160
+ function repoRealPath(cwd, rel) {
161
+ try {
162
+ const root = fs.realpathSync(cwd);
163
+ const inside = path.relative(root, fs.realpathSync(path.resolve(root, rel)));
164
+ if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return null;
165
+ return inside.split(path.sep).join('/');
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * The paths the story's own line carried, recorded on its run config as `include_paths` (cli/lib/run-context.js).
173
+ * An entry that is not a safe token is counted, never passed on; the rest are judged by judgeRequest with the
174
+ * request, because the config can be written by any client of the org.
175
+ * @param {object} run a `GET /orchestrate/:runId` payload
176
+ * @returns {{paths: string[], dropped: number}}
177
+ */
178
+ function originalPathsOf(run) {
179
+ const list = run && run.config && Array.isArray(run.config.include_paths) ? run.config.include_paths : [];
180
+ const paths = [];
181
+ let dropped = 0;
182
+ for (const entry of list) {
183
+ if (!isSafeToken(entry)) dropped += 1;
184
+ else if (!paths.includes(entry)) paths.push(entry);
185
+ }
186
+ return { paths, dropped };
187
+ }
188
+
189
+ /**
190
+ * The batch id `hone queue-stories --format json` printed. The command prints its per-story lines first and the
191
+ * response document last, and the document starts at a line that is exactly `{`.
192
+ * @returns {string|null} a batch id, or null when no document with one is there
193
+ */
194
+ function batchIdFromQueueOutput(stdout) {
195
+ const text = String(stdout || '');
196
+ const starts = [...text.matchAll(/^\{$/gm)].map((m) => m.index).reverse();
197
+ for (const at of starts) {
198
+ try {
199
+ const doc = JSON.parse(text.slice(at));
200
+ if (doc && isBatchId(doc.batchId)) return doc.batchId;
201
+ } catch { /* not the document; try the next opening brace back */ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Has this story already been served? From `GET /orchestrate?storyId=&repoName=` rows (id, story_id, repo_name,
208
+ * status, created_at, error_message), newest first.
209
+ * - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
210
+ * queued but whose kill failed, or that died between the two, leaves exactly this and no marker.
211
+ * - `earlier`: a run of the story killed with `re-served as batch <id>` BEFORE this one, so this run is that
212
+ * re-serve (the loop was started on the new batch alone) and a second pause here is the second.
213
+ * - `unknown`: the paused run's creation time is not a date, so neither can be told.
214
+ * @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string}|null}
215
+ */
216
+ function serveHistory(rows, { runId, storyId, repoName, since }) {
217
+ const t0 = Date.parse(since);
218
+ if (!Number.isFinite(t0)) return { kind: 'unknown' };
219
+ for (const row of Array.isArray(rows) ? rows : []) {
220
+ if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
221
+ const t = Date.parse(row.created_at);
222
+ if (!Number.isFinite(t)) continue;
223
+ if (t > t0 && row.status !== 'killed') return { kind: 'later', runId: row.id };
224
+ const batchId = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
225
+ if (batchId) return { kind: 'earlier', runId: row.id, batchId };
226
+ }
227
+ return null;
228
+ }
229
+
230
+ /**
231
+ * serveHistory over the server, and, for a `later` run, the batch it is in (found among the batches created since the
232
+ * paused run, newest first, at most 10; a lookup that fails only costs the batch id).
233
+ * @param {{listRuns: (storyId: string, repoName: string) => Promise<object[]>, listBatches: () => Promise<object[]>,
234
+ * fetchBatch: (id: string) => Promise<object>}} io
235
+ * @param {{runId: string, storyId: string, repoName: string|null, since: string|null}} action
236
+ */
237
+ async function findServe(io, action) {
238
+ const hit = serveHistory(await io.listRuns(action.storyId, action.repoName), action);
239
+ if (!hit || hit.kind !== 'later') return hit;
240
+ try {
241
+ const since = Date.parse(action.since) - 60_000;
242
+ const recent = (await io.listBatches()).filter((b) => b && isBatchId(b.batchId) && !(Date.parse(b.createdAt) < since)).slice(0, 10);
243
+ for (const b of recent) {
244
+ const batch = await io.fetchBatch(b.batchId);
245
+ if ((Array.isArray(batch && batch.runs) ? batch.runs : []).some((r) => r && r.runId === hit.runId)) return { ...hit, batchId: b.batchId };
246
+ }
247
+ } catch { /* the batch is only worth following if it can be found */ }
248
+ return hit;
249
+ }
250
+
251
+ /**
252
+ * The regular files in `git ls-files -z -s` output. A symlink (120000) and a submodule (160000) are tracked but
253
+ * are not files this checkout holds, and a symlink can point anywhere; a conflicted path (stage above 0) has no
254
+ * single content.
255
+ * @param {string} output
256
+ * @returns {Set<string>}
257
+ */
258
+ function trackedRegularFiles(output) {
259
+ const files = new Set();
260
+ for (const entry of String(output || '').split('\0')) {
261
+ const m = entry.match(/^(\d{6}) [0-9a-f]+ (\d)\t(.+)$/s);
262
+ if (m && m[2] === '0' && (m[1] === '100644' || m[1] === '100755')) files.add(m[3]);
263
+ }
264
+ return files;
265
+ }
266
+
267
+ module.exports = {
268
+ RESERVED_PREFIX, reserveReason, reservedBatchOf, hardPauseRequest, requestRefusal, contentRefusal, judgeRequest, repoRealPath,
269
+ originalPathsOf, serveHistory, findServe, batchIdFromQueueOutput, trackedRegularFiles,
270
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ /**
3
+ * detect-test-framework.js — what `hone setup` finds already installed in an adopter repo: the stack,
4
+ * test framework, package manager, Playwright, configs and test script, read from package.json, the
5
+ * lockfiles and the config files on disk.
6
+ *
7
+ * HC-CI-019-followup-3: moved verbatim out of cli/hone-cli.js, which cannot be required (it parses
8
+ * argv on load), so a test can call it. Before the move no suite asserted any of its fields:
9
+ * deleting each one left every suite the continuity gate selects green. The I/O stays here rather
10
+ * than behind injected callbacks, so the move changes no behaviour.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ function detectExistingTestFramework(repoRoot) {
16
+ const pkgPath = path.join(repoRoot, 'package.json');
17
+ if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
18
+
19
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
20
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
21
+
22
+ // Detect stack
23
+ let stack = 'node';
24
+ if (allDeps['react']) stack = allDeps['vite'] ? 'react-vite' : 'react';
25
+ if (allDeps['next']) stack = 'nextjs';
26
+ if (allDeps['@nestjs/core']) stack = 'nestjs';
27
+
28
+ // Detect existing test framework
29
+ let testFw = null;
30
+ if (allDeps['vitest']) testFw = 'vitest';
31
+ else if (allDeps['jest']) testFw = 'jest';
32
+ else if (allDeps['mocha']) testFw = 'mocha';
33
+
34
+ // Detect Playwright
35
+ const hasPlaywright = !!allDeps['@playwright/test'];
36
+
37
+ // Detect package manager
38
+ let pkgMgr = 'npm';
39
+ if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) pkgMgr = 'yarn';
40
+ if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) pkgMgr = 'pnpm';
41
+
42
+ // Detect existing configs
43
+ const hasTestConfig = ['vitest.config.js', 'vitest.config.ts', 'jest.config.js', 'jest.config.ts', 'jest.config.cjs']
44
+ .some(f => fs.existsSync(path.join(repoRoot, f)));
45
+ const hasPlaywrightConfig = fs.existsSync(path.join(repoRoot, 'playwright.config.js'))
46
+ || fs.existsSync(path.join(repoRoot, 'playwright.config.ts'));
47
+
48
+ // Detect existing test script
49
+ const hasTestScript = !!pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1';
50
+
51
+ return { stack, testFw, hasPlaywright, pkgMgr, hasTestConfig, hasPlaywrightConfig, hasTestScript, pkg };
52
+ }
53
+
54
+ module.exports = { detectExistingTestFramework };
@@ -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 };
@@ -98,4 +98,4 @@ function renderIncludePathsCommand(storyId, paths) {
98
98
  return `hone run-story ${id} ${safe.map((p) => `--include-paths ${tokenOf(p)}`).join(' ')}`;
99
99
  }
100
100
 
101
- module.exports = { remedyFor, renderIncludePathsCommand, partitionSafePaths };
101
+ module.exports = { remedyFor, renderIncludePathsCommand, partitionSafePaths, tokenOf, isSafeToken };
@@ -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 };
@@ -67,7 +67,8 @@ function updateTopLevelStatus(yamlText, newStatus) {
67
67
  return { text: yamlText, updated: false };
68
68
  }
69
69
  return {
70
- text: yamlText.replace(re, `status: ${newStatus}`),
70
+ // A function, so a `$&` or `$'` in the status is written as itself, not expanded as a replacement pattern.
71
+ text: yamlText.replace(re, () => `status: ${newStatus}`),
71
72
  updated: true,
72
73
  };
73
74
  }