@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.
- package/hone-cli.js +282 -309
- package/lib/batch-follow.js +418 -51
- package/lib/bundle-paths.js +168 -21
- package/lib/call-sites.js +489 -0
- package/lib/classify-spec.js +68 -0
- package/lib/context-reserve.js +270 -0
- package/lib/detect-test-framework.js +54 -0
- package/lib/doctor-admin-merge.js +11 -3
- package/lib/git-remote.js +103 -0
- package/lib/hard-pause-remedy.js +1 -1
- package/lib/judge-provider.js +39 -34
- package/lib/learnings-sync.js +2 -1
- package/lib/mcp-tools.js +42 -1
- package/lib/run-context.js +25 -3
- package/lib/run-target.js +34 -2
- package/lib/story-line.js +48 -12
- package/lib/story-provenance.js +19 -12
- package/package.json +1 -1
|
@@ -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 {
|
|
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/hard-pause-remedy.js
CHANGED
|
@@ -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 };
|
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/learnings-sync.js
CHANGED
|
@@ -67,7 +67,8 @@ function updateTopLevelStatus(yamlText, newStatus) {
|
|
|
67
67
|
return { text: yamlText, updated: false };
|
|
68
68
|
}
|
|
69
69
|
return {
|
|
70
|
-
|
|
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
|
}
|