@hone-ai/cli 1.38.1 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hone-cli.js +218 -157
- package/lib/anthropic-response.js +58 -0
- package/lib/batch-follow.js +641 -53
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-paths.js +77 -13
- 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/hard-pause-remedy.js +1 -1
- package/lib/judge-provider.js +20 -11
- package/lib/learnings-sync.js +2 -1
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/run-context.js +21 -0
- package/lib/security-scanner.js +7 -4
- package/lib/story-line.js +48 -12
- package/lib/verify-patch.js +122 -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 };
|
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
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* the unshipped `scripts/` tree.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
const { readAnthropicText } = require('./anthropic-response');
|
|
20
|
+
|
|
19
21
|
/** Said by every command that used to default to GitHub Models, so the cause is one sentence. */
|
|
20
22
|
const GH_MODELS_RETIRED =
|
|
21
23
|
'GitHub Models (--provider gh-models), the free default, was retired by GitHub on 2026-07-30: '
|
|
@@ -38,25 +40,32 @@ function buildJudgeCallLLM(provider, { axios }) {
|
|
|
38
40
|
return { error: 'ANTHROPIC_API_KEY required for --provider claude. Set it: export ANTHROPIC_API_KEY=sk-ant-...' };
|
|
39
41
|
}
|
|
40
42
|
// MODEL CHOICE (feedback_model_choice_cost_amplifier): the judge is NOT on the
|
|
41
|
-
// OPUS_AGENTS allowlist — paid mode uses Sonnet (`claude-sonnet-5`), never Opus.
|
|
43
|
+
// OPUS_AGENTS allowlist — paid mode uses Sonnet (`claude-sonnet-5-5`), never Opus.
|
|
44
|
+
//
|
|
45
|
+
// HC-GF-337f: Sonnet 5.5 answers HTTP 400 to `thinking: {type: 'disabled'}`. Thinking and effort are
|
|
46
|
+
// TWO sibling fields, never one: `thinking: {type: 'adaptive'}` and `output_config: {effort: 'low'}`
|
|
47
|
+
// (a `thinking` object with an `effort` key is wrong). Effort is sent because its default is `high`
|
|
48
|
+
// and a judge wants `low`. Thinking tokens share `max_tokens`, hence 8192. No `temperature`, `top_p`,
|
|
49
|
+
// `top_k` or `tool_choice` is sent, and that is load-bearing now: a non-default sampling value or a
|
|
50
|
+
// forced tool choice is a 400 on this model.
|
|
42
51
|
const callLLM = async (systemPrompt, userPrompt) => {
|
|
43
52
|
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
44
|
-
model: 'claude-sonnet-5',
|
|
45
|
-
thinking: { type: '
|
|
46
|
-
|
|
53
|
+
model: 'claude-sonnet-5-5',
|
|
54
|
+
thinking: { type: 'adaptive' },
|
|
55
|
+
output_config: { effort: 'low' },
|
|
56
|
+
max_tokens: 8192,
|
|
47
57
|
system: systemPrompt,
|
|
48
58
|
messages: [{ role: 'user', content: userPrompt }],
|
|
49
59
|
}, {
|
|
50
60
|
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
51
|
-
timeout:
|
|
61
|
+
timeout: 120000,
|
|
52
62
|
});
|
|
53
|
-
const text = (data.content || []).find((b) => b?.type === 'text')?.text;
|
|
54
63
|
// A provider that answered with nothing is a provider failure, not a failed criterion:
|
|
55
|
-
// returning '' here made every criterion read "could not parse response".
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return text;
|
|
64
|
+
// returning '' here made every criterion read "could not parse response". A refusal (HTTP 200,
|
|
65
|
+
// stop_reason "refusal") names its category so it is not read as a quota or parse problem.
|
|
66
|
+
const r = readAnthropicText(data, { label: 'the claude provider', allowPartial: true }); // a verdict cut off defaults to NO
|
|
67
|
+
if (!r.ok) throw new Error(r.message);
|
|
68
|
+
return r.text;
|
|
60
69
|
};
|
|
61
70
|
return { callLLM, provider: 'claude', paid: true };
|
|
62
71
|
}
|
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
|
}
|
|
@@ -67,8 +67,9 @@ function resolveBaseRef(base) {
|
|
|
67
67
|
* documentation later confirms the cap is TOTAL (input + output) instead
|
|
68
68
|
* of input-only, drop this number AND `max_tokens` together.
|
|
69
69
|
*
|
|
70
|
-
* Anthropic Opus `claude-opus-
|
|
71
|
-
* the legacy 4-20250514's 200K
|
|
70
|
+
* Anthropic Opus `claude-opus-5-5`: 1M context window (upgraded from
|
|
71
|
+
* the legacy 4-20250514's 200K; HC-GF-337f moved the id from claude-opus-4-8; the 1M figure is the
|
|
72
|
+
* Anthropic models table's context window for claude-opus-5-5, read 2026-10-10). The 100K char cap is well below the
|
|
72
73
|
* model's headroom but kept conservative — the prompt + diff together
|
|
73
74
|
* comfortably fit, and bumping the cap is a separate decision.
|
|
74
75
|
*
|