@hone-ai/cli 1.37.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.
- package/hone-cli.js +259 -214
- package/lib/asset-fetch-list.js +7 -3
- package/lib/bundle-paths.js +118 -18
- package/lib/doctor-admin-merge.js +11 -3
- package/lib/git-remote.js +103 -0
- package/lib/judge-provider.js +39 -34
- package/lib/mcp-tools.js +42 -1
- package/lib/operator-question.js +94 -0
- package/lib/run-context.js +4 -3
- package/lib/run-report.js +29 -0
- package/lib/run-target.js +65 -12
- package/lib/story-provenance.js +19 -12
- package/package.json +1 -1
package/lib/asset-fetch-list.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* network or a filesystem.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
// HC-GF-270-followup-2: every path goes through the one builder. The derived set's repository is the
|
|
15
|
+
// checkout's directory name, which can hold `?`, `#` or `%`; hand-built, `x?y` fetched `/skills/x`.
|
|
16
|
+
const { apiPath } = require('./run-target');
|
|
17
|
+
|
|
14
18
|
/**
|
|
15
19
|
* @param {object} args
|
|
16
20
|
* @param {string[]} args.agents agent names
|
|
@@ -39,12 +43,12 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
|
|
|
39
43
|
return [
|
|
40
44
|
...agents.map(agent => ({
|
|
41
45
|
key: `${agent}.agent.md`,
|
|
42
|
-
promise: get(
|
|
46
|
+
promise: get(apiPath('scripts', ['agent-stub', agent])),
|
|
43
47
|
write: (data) => writeAgent(agent, data),
|
|
44
48
|
})),
|
|
45
49
|
...skills.map(skill => ({
|
|
46
50
|
key: `skills/${skill}/SKILL.md`,
|
|
47
|
-
promise: get(
|
|
51
|
+
promise: get(apiPath('skills', [skill])),
|
|
48
52
|
write: (data) => writeSkill(skill, data),
|
|
49
53
|
})),
|
|
50
54
|
// ── HC-GF-184: the per-repo DERIVED set ────────────────────────────────────────────────────
|
|
@@ -59,7 +63,7 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
|
|
|
59
63
|
// derived, so setup behaves exactly as before for them.
|
|
60
64
|
...(derivedRepo ? [{
|
|
61
65
|
key: `skills/<derived for ${derivedRepo}>`,
|
|
62
|
-
promise: get(
|
|
66
|
+
promise: get(apiPath('skills', [derivedRepo, 'derived'])),
|
|
63
67
|
// Tolerant by construction: this runs mid-setup, AFTER the baseline has landed, so a shape
|
|
64
68
|
// change at the endpoint must not abort an adopter's install half-written.
|
|
65
69
|
write: (data) => {
|
package/lib/bundle-paths.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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:
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 };
|
package/lib/judge-provider.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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 || '
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
42
|
-
|
|
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) };
|