@hone-ai/cli 1.39.0 → 1.41.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 +174 -53
- package/lib/anthropic-response.js +58 -0
- package/lib/architect-config.js +5 -0
- package/lib/batch-follow.js +253 -16
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-limits.js +10 -0
- package/lib/bundle-paths.js +20 -3
- package/lib/call-sites.js +209 -26
- package/lib/context-reserve.js +11 -8
- package/lib/judge-provider.js +20 -11
- package/lib/operator-question.js +9 -4
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/review-diff.js +188 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/run-context.js +18 -41
- package/lib/run-report.js +94 -4
- package/lib/security-scanner.js +7 -4
- package/lib/tree-artifact.js +36 -3
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
package/lib/call-sites.js
CHANGED
|
@@ -73,9 +73,22 @@ const SCOPE = 'tracked-repo';
|
|
|
73
73
|
|
|
74
74
|
// The caps. server/src/services/call-sites.js accepts at least these (pinned by a cross-package test): a server
|
|
75
75
|
// that took less would quietly turn a complete list into an incomplete one.
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// HC-GF-385: 16, up from 6. Six slots spent on string keys (`step_0b`, `config`) left the functions a story changes
|
|
77
|
+
// NOT SEARCHED and hard-stopped step_1 four times on one story. The wire budget is held by MAX_TOTAL_HITS, not by
|
|
78
|
+
// the symbol count: a symbol past it is sent as its per-file list (FILES ONLY), so 16 costs what 6 did.
|
|
79
|
+
const MAX_SYMBOLS = 16;
|
|
80
|
+
/** Secret-looking names are reported, not searched; they take no slot of the 16 but are reported up to this many. */
|
|
78
81
|
const MAX_REFUSED = 2;
|
|
82
|
+
/**
|
|
83
|
+
* HC-GF-385: the hit lines ALL symbols together may send, spent in rank order: today's 6 x 40, so the 448 KiB
|
|
84
|
+
* per-story wire budget (server/src/lib/batch-body-limit.js) and its test keep their meaning. A symbol reached after
|
|
85
|
+
* it is spent sends `hits: []` plus its per-file list.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_TOTAL_HITS = 240;
|
|
88
|
+
/** HC-GF-385: the definition probe ranks at most this many candidates, best-ranked first. */
|
|
89
|
+
const MAX_PROBE_CANDIDATES = 32;
|
|
90
|
+
/** HC-GF-385: the probe's own share of TOTAL_BUDGET_MS; past it the story's own order is kept, and said. */
|
|
91
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
79
92
|
/** Past MAX_HITS_PER_SYMBOL a symbol also carries a per-file list (path + hit count), up to this many files... */
|
|
80
93
|
const MAX_FILE_LIST_FILES = 300;
|
|
81
94
|
/** ...and all the symbols' file lists together stay under this many bytes of JSON (server/src/lib/batch-body-limit.js budgets it). */
|
|
@@ -97,7 +110,38 @@ const PROSE_EXTENSIONS = Object.freeze(['md', 'mdx', 'markdown', 'rst', 'adoc',
|
|
|
97
110
|
|
|
98
111
|
// ── Choosing the symbols ────────────────────────────────────────────────────────────────────────
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
// Backticked spans are PAIRED first, by the CommonMark rule, and the 2-to-200 rule is applied to each paired span afterwards.
|
|
114
|
+
// A bound INSIDE a regex decides which backticks pair: at `x`, and `foo` the opening backtick of `x` cannot start a 2-character
|
|
115
|
+
// match, so its closing one does, the "span" is `, and`, and `foo` is lost (measured 2026-10-10 over the roadmap rows: 68 of 939
|
|
116
|
+
// extracted a wrong span set). A plain /`([^`\n]*)`/ fixed that and made the next one: after a run of TWO OR MORE backticks
|
|
117
|
+
// (a ``double`` span, an inline ```fence) the pairing never recovered for the rest of the line, and 7 rows lost symbols the old
|
|
118
|
+
// pattern found. The rule that has no such failure is the one Markdown itself uses: a run of N backticks opens a span that the
|
|
119
|
+
// next run of EXACTLY N backticks closes, and a run with no such closer is literal text. Per line (a span does not cross a
|
|
120
|
+
// newline here, as before). Linear: the next run of the same length is computed once per run, not searched for.
|
|
121
|
+
// The same defect as BACKTICKED_RE in bundle-paths.js (HC-GF-188-followup-2), which has the first fix only.
|
|
122
|
+
function backtickedSpans(text) {
|
|
123
|
+
const spans = [];
|
|
124
|
+
for (const line of String(text || '').split('\n')) {
|
|
125
|
+
if (!line.includes('`')) continue;
|
|
126
|
+
const runs = [];
|
|
127
|
+
for (const m of line.matchAll(/`+/g)) runs.push({ at: m.index, len: m[0].length });
|
|
128
|
+
const nextSame = new Array(runs.length).fill(-1);
|
|
129
|
+
const lastOfLen = new Map();
|
|
130
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
131
|
+
nextSame[i] = lastOfLen.has(runs[i].len) ? lastOfLen.get(runs[i].len) : -1;
|
|
132
|
+
lastOfLen.set(runs[i].len, i);
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0; i < runs.length; i++) {
|
|
135
|
+
const j = nextSame[i];
|
|
136
|
+
if (j < 0) continue; // no closer of this length: the run is literal text
|
|
137
|
+
let span = line.slice(runs[i].at + runs[i].len, runs[j].at);
|
|
138
|
+
if (span.length > 2 && span.startsWith(' ') && span.endsWith(' ') && /[^ ]/.test(span)) span = span.slice(1, -1); // CommonMark: one space each side
|
|
139
|
+
spans.push(span);
|
|
140
|
+
i = j; // resume after the closer; the runs between are inside the span
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return spans;
|
|
144
|
+
}
|
|
101
145
|
// One identifier, optionally qualified (`a.b`, `A::b`, `a->b`, `A#b`) and optionally called (`f()`, `f(x)`). The LAST
|
|
102
146
|
// segment is the symbol: `this.ensureConnected` is a reference to `ensureConnected` wherever it is called from.
|
|
103
147
|
// Group 1 is the qualifier (empty when there is none), 2 the name, 3 the call's parentheses (undefined when not called).
|
|
@@ -158,25 +202,129 @@ function symbolOfSpan(span) {
|
|
|
158
202
|
* @returns {{symbols: string[], omitted: string[], omittedTotal: number, refused: string[]}}
|
|
159
203
|
*/
|
|
160
204
|
function pickSymbols(storyText, { max = MAX_SYMBOLS } = {}) {
|
|
205
|
+
const { ranked, refusedAll } = rankCandidates(storyText);
|
|
206
|
+
return sliceRanked(ranked.map((c) => c.name), refusedAll, max);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Every candidate the story names, in the story's order (explicitly written first, then by how often it is named,
|
|
211
|
+
* ties in the order first named), with the facts that order was made from. Secret-looking names are split off.
|
|
212
|
+
* Synchronous and text-only: `pickSymbols` keeps that contract for callers this file cannot see (HC-GF-385).
|
|
213
|
+
* @returns {{ranked: Array<{name: string, explicit: boolean, count: number}>, refusedAll: string[]}}
|
|
214
|
+
*/
|
|
215
|
+
function rankCandidates(storyText) {
|
|
161
216
|
const counts = new Map();
|
|
162
217
|
const explicitNames = new Set();
|
|
163
|
-
for (const
|
|
164
|
-
|
|
218
|
+
for (const span of backtickedSpans(storyText)) {
|
|
219
|
+
if (span.length < 2 || span.length > 200) continue; // the length rule, after pairing
|
|
220
|
+
const sym = symbolOfSpan(span);
|
|
165
221
|
if (!sym) continue;
|
|
166
222
|
counts.set(sym.name, (counts.get(sym.name) || 0) + 1);
|
|
167
223
|
if (sym.explicit) explicitNames.add(sym.name);
|
|
168
224
|
}
|
|
169
225
|
// Array.prototype.sort is stable (ES2019), and a Map iterates in insertion order: ties keep the order the story first names them.
|
|
170
226
|
const all = [...counts.keys()].sort((a, b) => (explicitNames.has(b) - explicitNames.has(a)) || (counts.get(b) - counts.get(a)));
|
|
171
|
-
const refusedAll = all.filter((n) => SECRET_NAME.test(n));
|
|
172
|
-
const ranked = all.filter((n) => !SECRET_NAME.test(n));
|
|
173
|
-
const refused = refusedAll.slice(0, MAX_REFUSED);
|
|
174
|
-
const beyond = [...ranked.slice(max), ...refusedAll.slice(MAX_REFUSED)];
|
|
175
227
|
return {
|
|
176
|
-
|
|
228
|
+
ranked: all.filter((n) => !SECRET_NAME.test(n)).map((name) => ({ name, explicit: explicitNames.has(name), count: counts.get(name) })),
|
|
229
|
+
refusedAll: all.filter((n) => SECRET_NAME.test(n)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The first `max` names are searched; the rest, and the refused names past MAX_REFUSED, are reported as omitted. */
|
|
234
|
+
function sliceRanked(names, refusedAll, max) {
|
|
235
|
+
const beyond = [...names.slice(max), ...refusedAll.slice(MAX_REFUSED)];
|
|
236
|
+
return {
|
|
237
|
+
symbols: names.slice(0, max),
|
|
177
238
|
omitted: beyond.slice(0, MAX_OMITTED_NAMES),
|
|
178
239
|
omittedTotal: beyond.length,
|
|
179
|
-
refused,
|
|
240
|
+
refused: refusedAll.slice(0, MAX_REFUSED),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Ranking by definition (HC-GF-385) ───────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** Files whose matches never define code: documentation, and data formats (an object or YAML key is not a symbol). */
|
|
247
|
+
const DATA_EXTENSIONS = Object.freeze(['json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'lock', 'csv', 'tsv', 'xml', 'ini', 'env', 'properties']);
|
|
248
|
+
/** A word before `name(` that makes it a call or an expression, never a C-family definition. */
|
|
249
|
+
const NOT_A_RETURN_TYPE = new Set(['return', 'await', 'new', 'throw', 'yield', 'else', 'case', 'typeof', 'delete', 'void', 'in', 'of', 'do',
|
|
250
|
+
'if', 'while', 'for', 'switch', 'catch', 'and', 'or', 'not', 'is', 'as', 'echo', 'print', 'puts', 'go', 'defer', 'export', 'default']);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* How strongly one line defines `name`: 2 for a function, method, class or type; 1 for a top-level or exported
|
|
254
|
+
* constant or variable; 0 for anything else (a call, a key, a mention). Language-neutral patterns, covering
|
|
255
|
+
* JavaScript and TypeScript, Python, Go, Ruby, Java and the C family, and `fn`/`fun`/`func` (Rust, Kotlin, Swift).
|
|
256
|
+
* `name` is an identifier ([A-Za-z0-9_]), so it is safe inside a RegExp.
|
|
257
|
+
* @param {string} line
|
|
258
|
+
* @param {string} name
|
|
259
|
+
* @returns {0|1|2}
|
|
260
|
+
*/
|
|
261
|
+
function definitionTier(line, name) {
|
|
262
|
+
const n = `${name}(?![A-Za-z0-9_$])`;
|
|
263
|
+
const t = String(line);
|
|
264
|
+
// A keyword that introduces a definition: `function f`, `class C`, `def f`, `def self.f`, `func (r *T) f`, `fn f`, `fun f`.
|
|
265
|
+
if (new RegExp(`(?:^|[^A-Za-z0-9_$.])(?:function\\*?|class|def|func|fn|fun|interface|enum|struct|trait|type|module|record|protocol)\\s+(?:self\\.)?(?:\\([^)]*\\)\\s*)?${n}`).test(t)) return 2;
|
|
266
|
+
// A function value bound at top level or exported: `export const f = async (a) =>`, `const f = function`.
|
|
267
|
+
if (new RegExp(`^(?:export\\s+)?(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\b\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\\s*=>)`).test(t)) return 2;
|
|
268
|
+
// A method, in a class body or an object literal: ` async f(a, b) {`, ` static get f() {`.
|
|
269
|
+
if (new RegExp(`^\\s*(?:(?:static|async|get|set|public|private|protected|readonly|override|abstract|final)\\s+)*${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^={;]+)?\\{`).test(t)) return 2;
|
|
270
|
+
// A C-family signature: one or more type words, then `name(`, and no `;` (a prototype or a call statement).
|
|
271
|
+
const sig = new RegExp(`^\\s*((?:[A-Za-z_][A-Za-z0-9_<>\\[\\],:*&?]*\\s+)+)\\*?${n}\\s*\\([^;]*$`).exec(t);
|
|
272
|
+
if (sig) {
|
|
273
|
+
const words = sig[1].trim().split(/\s+/);
|
|
274
|
+
if (!NOT_A_RETURN_TYPE.has(words[words.length - 1])) return 2;
|
|
275
|
+
}
|
|
276
|
+
// A top-level or exported binding of anything else: `const f =`, `export let f`, `module.exports.f =`, Go `var f`.
|
|
277
|
+
if (new RegExp(`^(?:export\\s+)?(?:const|let|var|val)\\s+${n}`).test(t)) return 1;
|
|
278
|
+
if (new RegExp(`^(?:module\\.)?exports\\.${n}\\s*=`).test(t)) return 1;
|
|
279
|
+
// Python and Ruby top-level assignment: `f = ...` at column 0, not `f == x`.
|
|
280
|
+
if (new RegExp(`^${n}\\s*=(?!=)`).test(t)) return 1;
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Rank candidates by whether the repository defines them, with ONE batched `git grep -F -w` over all of them.
|
|
286
|
+
* Functions, methods, classes and types come first, then top-level or exported constants, then names with no
|
|
287
|
+
* definition (string keys, config fields, words). Within a tier the story's own order holds. Any failure, timeout
|
|
288
|
+
* or output this cannot read keeps the story's order, and the note says so: a probe never empties the search.
|
|
289
|
+
* @param {Array<{name: string}>} ranked the story's order
|
|
290
|
+
* @returns {Promise<{ranked: Array<{name: string}>, fallback: string|null, undefinedNames: string[]}>}
|
|
291
|
+
*/
|
|
292
|
+
async function rankByDefinition(ranked, { run, realPath }) {
|
|
293
|
+
if (ranked.length === 0) return { ranked, fallback: null, undefinedNames: [] };
|
|
294
|
+
const probed = ranked.slice(0, MAX_PROBE_CANDIDATES);
|
|
295
|
+
const argv = ['grep', '-n', '-z', '-w', '-F', '-I', '--no-color', '--no-recurse-submodules'];
|
|
296
|
+
for (const c of probed) argv.push('-e', c.name);
|
|
297
|
+
let r;
|
|
298
|
+
try { r = await run(argv, { timeoutMs: PROBE_TIMEOUT_MS }); } catch { r = { failure: 'the probe could not be run' }; }
|
|
299
|
+
const problem = grepProblem(r || {});
|
|
300
|
+
if (problem) return { ranked, fallback: `the definition probe gave no answer (${problem}), so the story's own order was used`, undefinedNames: [] };
|
|
301
|
+
const lines = r.status === 1 ? [] : parseGrepOutput(r.stdout);
|
|
302
|
+
if (lines === null) return { ranked, fallback: 'the definition probe printed output that could not be read, so the story\'s own order was used', undefinedNames: [] };
|
|
303
|
+
|
|
304
|
+
const tier = new Map(probed.map((c) => [c.name, 0]));
|
|
305
|
+
const kinds = new Map();
|
|
306
|
+
const wordRe = /[A-Za-z_$][A-Za-z0-9_$]*/g;
|
|
307
|
+
for (const hit of lines) {
|
|
308
|
+
let kind = kinds.get(hit.path);
|
|
309
|
+
if (kind === undefined) {
|
|
310
|
+
const ext = path.posix.extname(hit.path).slice(1).toLowerCase();
|
|
311
|
+
kind = DATA_EXTENSIONS.includes(ext) ? 'data' : classifyPath(hit.path, realPath);
|
|
312
|
+
kinds.set(hit.path, kind);
|
|
313
|
+
}
|
|
314
|
+
if (kind !== 'ok') continue; // documentation, data and withheld paths define nothing here
|
|
315
|
+
for (const w of new Set(String(hit.text).match(wordRe) || [])) {
|
|
316
|
+
if (!tier.has(w) || tier.get(w) === 2) continue;
|
|
317
|
+
const d = definitionTier(hit.text, w);
|
|
318
|
+
if (d > tier.get(w)) tier.set(w, d);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Stable: within a tier the story's order is kept. Candidates past the probe cap keep their place after the probed.
|
|
322
|
+
const order = probed.map((c, i) => ({ c, i, t: tier.get(c.name) }))
|
|
323
|
+
.sort((a, b) => (b.t - a.t) || (a.i - b.i)).map((x) => x.c);
|
|
324
|
+
return {
|
|
325
|
+
ranked: [...order, ...ranked.slice(MAX_PROBE_CANDIDATES)],
|
|
326
|
+
fallback: null,
|
|
327
|
+
undefinedNames: probed.filter((c) => tier.get(c.name) === 0).map((c) => c.name),
|
|
180
328
|
};
|
|
181
329
|
}
|
|
182
330
|
|
|
@@ -288,7 +436,7 @@ function parseFileList(out) {
|
|
|
288
436
|
* - 'not_searched': no answer, and `reason` says why. Never to be read as "no callers".
|
|
289
437
|
* `binary` is how many files match only when git's binary-file skip (-I) is off; they cannot be listed.
|
|
290
438
|
*/
|
|
291
|
-
async function searchSymbol(symbol, { run, realPath }) {
|
|
439
|
+
async function searchSymbol(symbol, { run, realPath, maxHits = MAX_HITS_PER_SYMBOL }) {
|
|
292
440
|
// Defence in depth: pickSymbols only yields identifiers, but this is exported, and the symbol reaches an argv and a RegExp.
|
|
293
441
|
if (typeof symbol !== 'string' || !/^[A-Za-z0-9_]+$/.test(symbol)) {
|
|
294
442
|
return { symbol: String(symbol).slice(0, 80), status: 'not_searched', reason: 'not an identifier' };
|
|
@@ -323,7 +471,9 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
323
471
|
else sendable.push(hit);
|
|
324
472
|
}
|
|
325
473
|
const total = sendable.length + withheld;
|
|
326
|
-
|
|
474
|
+
// HC-GF-385: `maxHits` is what is left of the story's MAX_TOTAL_HITS (0 once it is spent), never more than the per-symbol cap.
|
|
475
|
+
const cap = Math.max(0, Math.min(MAX_HITS_PER_SYMBOL, Number.isSafeInteger(maxHits) ? maxHits : MAX_HITS_PER_SYMBOL));
|
|
476
|
+
const hits = sendable.slice(0, cap).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
|
|
327
477
|
const perFile = new Map();
|
|
328
478
|
for (const h of sendable) perFile.set(h.path, (perFile.get(h.path) || 0) + 1);
|
|
329
479
|
const entry = {
|
|
@@ -336,7 +486,10 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
336
486
|
binary,
|
|
337
487
|
hits,
|
|
338
488
|
};
|
|
339
|
-
|
|
489
|
+
// HC-GF-385: every searched symbol with a sendable hit carries its per-file list, not only one past the hit cap.
|
|
490
|
+
// A symbol cut by the total-hit budget needs it to be FILES ONLY, and a whole symbol whose hit lines the server's
|
|
491
|
+
// render budget drops needs it to stay an enumeration at file level.
|
|
492
|
+
if (sendable.length > 0) {
|
|
340
493
|
const all = [...perFile].map(([p, n]) => ({ path: p, hits: n }));
|
|
341
494
|
entry.file_list = all.slice(0, MAX_FILE_LIST_FILES);
|
|
342
495
|
entry.file_list_complete = all.length <= MAX_FILE_LIST_FILES;
|
|
@@ -344,11 +497,17 @@ async function searchSymbol(symbol, { run, realPath }) {
|
|
|
344
497
|
return entry;
|
|
345
498
|
}
|
|
346
499
|
|
|
347
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so.
|
|
502
|
+
* HC-GF-385: the bytes go first to the symbols whose hit lines were cut (by the per-symbol cap or the total-hit
|
|
503
|
+
* budget), in rank order, because for them the list IS the enumeration; then to the rest, in rank order. Spent
|
|
504
|
+
* in plain rank order, whole symbols at the top starved the FILES ONLY ones below them into INCOMPLETE.
|
|
505
|
+
*/
|
|
348
506
|
function fitFileLists(symbols) {
|
|
349
507
|
let room = MAX_FILE_LIST_BYTES;
|
|
350
|
-
|
|
351
|
-
|
|
508
|
+
const cut = (s) => Array.isArray(s.hits) && Number.isSafeInteger(s.total) && s.hits.length < s.total - (s.withheld || 0);
|
|
509
|
+
const ordered = [...symbols.filter((s) => s.file_list && cut(s)), ...symbols.filter((s) => s.file_list && !cut(s))];
|
|
510
|
+
for (const s of ordered) {
|
|
352
511
|
const kept = [];
|
|
353
512
|
for (const f of s.file_list) {
|
|
354
513
|
const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1;
|
|
@@ -379,8 +538,9 @@ const warn = (text) => ({ level: 'warn', text });
|
|
|
379
538
|
*/
|
|
380
539
|
async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now, maxSymbols = MAX_SYMBOLS }) {
|
|
381
540
|
const notes = [];
|
|
382
|
-
const
|
|
383
|
-
|
|
541
|
+
const { ranked: storyOrder, refusedAll } = rankCandidates(storyText);
|
|
542
|
+
// The early return comes BEFORE the probe: a story that names nothing searchable runs no git at all.
|
|
543
|
+
if (storyOrder.length === 0 && refusedAll.length === 0) return { callSites: null, notes };
|
|
384
544
|
|
|
385
545
|
// From the repository's top level, wherever `hone` ran: `git grep` from a subdirectory searches only that subtree.
|
|
386
546
|
let root = cwd;
|
|
@@ -389,17 +549,24 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
|
|
|
389
549
|
const dir = top.failure || top.status !== 0 ? '' : top.stdout.trim();
|
|
390
550
|
if (dir) root = dir;
|
|
391
551
|
}
|
|
392
|
-
const git = run || ((argv) => runGit(argv, { cwd: root }));
|
|
552
|
+
const git = run || ((argv, opts = {}) => runGit(argv, { cwd: root, ...opts }));
|
|
393
553
|
const real = realPath || ((rel) => repoRealPath(root, rel));
|
|
394
554
|
const commit = await headCommit(git);
|
|
395
|
-
|
|
555
|
+
// HC-GF-385: the clock starts before the probe, so the probe's time comes out of the same TOTAL_BUDGET_MS.
|
|
396
556
|
const started = now();
|
|
557
|
+
// Rank BEFORE the slice and compute what was omitted AFTER it, or the functions never reach the slice.
|
|
558
|
+
const probe = await rankByDefinition(storyOrder, { run: git, realPath: real });
|
|
559
|
+
const picked = sliceRanked(probe.ranked.map((c) => c.name), refusedAll, maxSymbols);
|
|
560
|
+
const symbols = [];
|
|
561
|
+
let hitsLeft = MAX_TOTAL_HITS;
|
|
397
562
|
for (const symbol of picked.symbols) {
|
|
398
563
|
if (now() - started > TOTAL_BUDGET_MS) {
|
|
399
564
|
symbols.push({ symbol, status: 'not_searched', reason: `the ${TOTAL_BUDGET_MS / 1000}s search budget for this story was spent on earlier symbols` });
|
|
400
565
|
continue;
|
|
401
566
|
}
|
|
402
|
-
|
|
567
|
+
const entry = await searchSymbol(symbol, { run: git, realPath: real, maxHits: hitsLeft });
|
|
568
|
+
if (Array.isArray(entry.hits)) hitsLeft = Math.max(0, hitsLeft - entry.hits.length);
|
|
569
|
+
symbols.push(entry);
|
|
403
570
|
}
|
|
404
571
|
for (const symbol of picked.refused) symbols.push({ symbol, status: 'not_searched', reason: SECRET_REASON });
|
|
405
572
|
fitFileLists(symbols);
|
|
@@ -413,12 +580,17 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
|
|
|
413
580
|
omitted: picked.omitted,
|
|
414
581
|
omitted_total: picked.omittedTotal,
|
|
415
582
|
};
|
|
416
|
-
notes.push(
|
|
583
|
+
if (probe.fallback) notes.push(warn(` ⚠ call sites: ${probe.fallback}`));
|
|
584
|
+
const searchedUndefined = probe.undefinedNames.filter((n) => picked.symbols.includes(n));
|
|
585
|
+
if (!probe.fallback && searchedUndefined.length > 0) {
|
|
586
|
+
notes.push(info(` no definition found for ${searchedUndefined.join(', ')} (ranked after defined code; it may be a key, a field, or one this story creates)`));
|
|
587
|
+
}
|
|
588
|
+
notes.push(...describeCallSites(callSites, { maxSymbols }));
|
|
417
589
|
return { callSites, notes };
|
|
418
590
|
}
|
|
419
591
|
|
|
420
592
|
/** The operator's lines for a result: one info line per symbol that is whole, a warning for each that is not. */
|
|
421
|
-
function describeCallSites(callSites) {
|
|
593
|
+
function describeCallSites(callSites, { maxSymbols = MAX_SYMBOLS } = {}) {
|
|
422
594
|
const notes = [];
|
|
423
595
|
const where = callSites.commit ? `tracked files at ${callSites.commit.slice(0, 12)}` : 'tracked files (commit unknown)';
|
|
424
596
|
notes.push(info(` → call sites: searched ${callSites.symbols.length} symbol(s) the story names, in ${where}`));
|
|
@@ -437,7 +609,10 @@ function describeCallSites(callSites) {
|
|
|
437
609
|
binary ? `${binary} binary file${binary === 1 ? '' : 's'} match and cannot be listed` : '',
|
|
438
610
|
].filter(Boolean).join('; ');
|
|
439
611
|
const fileLevel = s.status === 'incomplete' && binary === 0 && s.file_list_complete === true && Array.isArray(s.file_list);
|
|
440
|
-
if (s.status === 'incomplete' && fileLevel) {
|
|
612
|
+
if (s.status === 'incomplete' && fileLevel && s.hits.length === 0) {
|
|
613
|
+
// HC-GF-385: reached after the total-hit budget was spent. The server renders it FILES ONLY.
|
|
614
|
+
notes.push(info(` ${s.symbol}: FILES ONLY — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; every file with a hit is named with its count, individual lines not sent (the ${MAX_TOTAL_HITS}-hit budget was spent on higher-ranked symbols)${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
|
|
615
|
+
} else if (s.status === 'incomplete' && fileLevel) {
|
|
441
616
|
notes.push(info(` ${s.symbol}: COMPLETE AT FILE LEVEL — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; the first ${s.hits.length} are listed and every file with a hit is named with its count${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
|
|
442
617
|
} else if (s.status === 'incomplete') {
|
|
443
618
|
notes.push(warn(` ⚠ call sites for ${s.symbol}: INCOMPLETE — ${s.hits.length} of ${listable} hits listed (cap ${MAX_HITS_PER_SYMBOL}${s.file_list ? `, ${s.file_list.length} of ${s.files} files named` : ''}); the planner cannot claim an enumeration${extra ? ` (${extra})` : ''}`));
|
|
@@ -454,16 +629,21 @@ function describeCallSites(callSites) {
|
|
|
454
629
|
const omittedTotal = callSites.omitted_total || 0;
|
|
455
630
|
if (omittedTotal > 0) {
|
|
456
631
|
const shown = callSites.omitted.join(', ') + (omittedTotal > callSites.omitted.length ? ', …' : '');
|
|
457
|
-
notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${
|
|
632
|
+
notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${maxSymbols}-symbol cap — NOT SEARCHED: ${shown}`));
|
|
458
633
|
}
|
|
459
634
|
return notes;
|
|
460
635
|
}
|
|
461
636
|
|
|
462
637
|
module.exports = {
|
|
638
|
+
backtickedSpans,
|
|
463
639
|
CALL_SITES_VERSION,
|
|
464
640
|
SCOPE,
|
|
465
641
|
MAX_SYMBOLS,
|
|
466
642
|
MAX_REFUSED,
|
|
643
|
+
MAX_TOTAL_HITS,
|
|
644
|
+
MAX_PROBE_CANDIDATES,
|
|
645
|
+
PROBE_TIMEOUT_MS,
|
|
646
|
+
DATA_EXTENSIONS,
|
|
467
647
|
MAX_FILE_LIST_FILES,
|
|
468
648
|
MAX_FILE_LIST_BYTES,
|
|
469
649
|
SECRET_NAME,
|
|
@@ -478,6 +658,9 @@ module.exports = {
|
|
|
478
658
|
MAX_OUTPUT_BYTES,
|
|
479
659
|
isCompoundIdentifier,
|
|
480
660
|
pickSymbols,
|
|
661
|
+
rankCandidates,
|
|
662
|
+
definitionTier,
|
|
663
|
+
rankByDefinition,
|
|
481
664
|
fitFileLists,
|
|
482
665
|
runGit,
|
|
483
666
|
parseGrepOutput,
|
package/lib/context-reserve.js
CHANGED
|
@@ -208,21 +208,24 @@ function batchIdFromQueueOutput(stdout) {
|
|
|
208
208
|
* status, created_at, error_message), newest first.
|
|
209
209
|
* - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
|
|
210
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>`
|
|
212
|
-
* re-serve (the loop was started on the new batch alone) and a second
|
|
213
|
-
*
|
|
214
|
-
*
|
|
211
|
+
* - `earlier`: a run of the story, older than this one, killed with `re-served as batch <id>` where <id> is the batch
|
|
212
|
+
* THIS run is in (`batchId`): this run is that re-serve (the loop was started on the new batch alone), and a second
|
|
213
|
+
* pause here is the second. A story re-served into some OTHER batch, however long ago, is history and not this
|
|
214
|
+
* run's lineage: it neither blocks nor justifies anything (HC-GF-351-followup-1).
|
|
215
|
+
* - `unknown`: the paused run's creation time is not a date or its batch id is not one, so neither can be told.
|
|
216
|
+
* @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string, why?: string}|null}
|
|
215
217
|
*/
|
|
216
|
-
function serveHistory(rows, { runId, storyId, repoName, since }) {
|
|
218
|
+
function serveHistory(rows, { runId, storyId, repoName, since, batchId: ownBatch }) {
|
|
217
219
|
const t0 = Date.parse(since);
|
|
218
|
-
if (!Number.isFinite(t0)) return { kind: 'unknown' };
|
|
220
|
+
if (!Number.isFinite(t0)) return { kind: 'unknown', why: 'its creation time is not known' };
|
|
221
|
+
if (!isBatchId(ownBatch)) return { kind: 'unknown', why: 'the batch it is in is not known' };
|
|
219
222
|
for (const row of Array.isArray(rows) ? rows : []) {
|
|
220
223
|
if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
|
|
221
224
|
const t = Date.parse(row.created_at);
|
|
222
225
|
if (!Number.isFinite(t)) continue;
|
|
223
226
|
if (t > t0 && row.status !== 'killed') return { kind: 'later', runId: row.id };
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
227
|
+
const reserved = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
|
|
228
|
+
if (reserved && reserved === ownBatch) return { kind: 'earlier', runId: row.id, batchId: reserved };
|
|
226
229
|
}
|
|
227
230
|
return null;
|
|
228
231
|
}
|
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/operator-question.js
CHANGED
|
@@ -27,10 +27,15 @@
|
|
|
27
27
|
// U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
|
|
28
28
|
// \u escapes: a literal bidi character in this source would reorder how THIS file reads.
|
|
29
29
|
// eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
|
|
30
|
-
const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\
|
|
31
|
-
|
|
30
|
+
const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff\u{e0000}-\u{e007f}]/gu;
|
|
31
|
+
// A line break or tab is a space, so two words do not join; the characters above draw nothing yet change what a reader sees
|
|
32
|
+
// (zero-width, soft hyphen, BOM, the Unicode tag block); a lone surrogate is not a character a terminal or JSON can carry.
|
|
33
|
+
// Mirrors server/src/services/architect-consult.js scrubForTerminal: the two packages cannot share code, and a test pins them equal.
|
|
32
34
|
function scrub(text) {
|
|
33
|
-
return String(text == null ? '' : text)
|
|
35
|
+
return String(text == null ? '' : text)
|
|
36
|
+
.replace(/[\t\n\v\f\r\u0085\u2028\u2029]+/g, ' ')
|
|
37
|
+
.replace(TERMINAL_UNSAFE_RE, '')
|
|
38
|
+
.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\ufffd');
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/**
|
|
@@ -91,4 +96,4 @@ function gateQuestionLines(status, stepKey) {
|
|
|
91
96
|
];
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
|
|
99
|
+
module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase, scrub };
|
|
@@ -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
|
*
|