@hone-ai/cli 1.40.0 → 1.42.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/README.md +7 -0
- package/hone-cli.js +191 -55
- package/lib/admin-request.js +49 -0
- package/lib/architect-config.js +5 -0
- package/lib/batch-follow.js +23 -7
- package/lib/block-grammar.js +35 -0
- package/lib/bundle-context.js +7 -12
- package/lib/bundle-limits.js +10 -0
- package/lib/call-sites.js +213 -29
- package/lib/context-reserve.js +13 -26
- package/lib/default-api.js +109 -0
- package/lib/editor-park.js +8 -2
- package/lib/materialize-diff.js +32 -13
- package/lib/mcp-tools.js +17 -5
- package/lib/operator-question.js +9 -4
- package/lib/path-refusal.js +79 -0
- package/lib/review-diff-check.js +262 -0
- package/lib/review-diff.js +188 -0
- package/lib/run-context.js +18 -41
- package/lib/run-report.js +165 -20
- package/lib/tree-artifact.js +440 -55
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* review-diff-check.js — HC-GF-390-followup-1: does the stored review diff describe HEAD?
|
|
4
|
+
*
|
|
5
|
+
* HC-GF-390 stores, beside step_4's artifact, the CLI-reported diff against HEAD of each over-cap file the build
|
|
6
|
+
* changed, and the reviewers read "a region with no hunk is unchanged from HEAD". The server can vet a section's
|
|
7
|
+
* post-image against the stored build, but it has no repository, so it cannot vet the pre-image, the hunk offsets or
|
|
8
|
+
* the context lines. `hone verify-patch` has the clone: this re-derives each stored section from HEAD and reports
|
|
9
|
+
* whether it holds. The server renders a mismatch as withheld, so a forged region is never shown as "unchanged".
|
|
10
|
+
*
|
|
11
|
+
* Per section, stopping at the first failure:
|
|
12
|
+
* parse the strict grammar `git diff --full-index` prints for a text modification, and nothing else
|
|
13
|
+
* pre_image the `index` pre-image id is HEAD's blob for that path
|
|
14
|
+
* offset every hunk's old lines are HEAD's lines at its stated start, and every new start is what git computes
|
|
15
|
+
* (`git apply` would accept a hunk at a nearby offset, and a wrong `+c` it never reads)
|
|
16
|
+
* apply the section applies with no fuzz to HEAD, on a private index
|
|
17
|
+
* post_image the result's blob id is the `index` post-image id
|
|
18
|
+
*
|
|
19
|
+
* It never throws and never touches the real index or worktree. Exit modes of what it consumes (HC-GF-117):
|
|
20
|
+
* stored == null HANDLED: absent (the server has no review diff for that attempt).
|
|
21
|
+
* head sha malformed HANDLED: unverifiable/invalid_head.
|
|
22
|
+
* head not in this clone HANDLED: unverifiable/head_missing (`cat-file -e` exits 128).
|
|
23
|
+
* HEAD moved since submit HANDLED: unverifiable/head_moved — the diff describes another commit.
|
|
24
|
+
* no sections HANDLED: unverifiable/no_sections — nothing was checked, so nothing matched.
|
|
25
|
+
* git killed / timed out HANDLED: unverifiable/timeout. Deadline spent: unverifiable/deadline.
|
|
26
|
+
* git spawn / other throw HANDLED: unverifiable/git_error.
|
|
27
|
+
* child killed by SIGINT HANDLED: the temp dir is removed, then SIGINT is re-raised on this process. The git
|
|
28
|
+
* calls are synchronous, so no JS signal handler could run during them; the child's
|
|
29
|
+
* death is how an operator's Ctrl-C reaches this code.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { execFileSync } = require('node:child_process');
|
|
36
|
+
const { gitEnv } = require('./git-env');
|
|
37
|
+
|
|
38
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
39
|
+
const PER_CALL_MS = 15_000;
|
|
40
|
+
const DEADLINE_MS = 60_000;
|
|
41
|
+
const MAX_FILE_CHARS = 300;
|
|
42
|
+
const TMP_PREFIX = 'hone-rdc-';
|
|
43
|
+
// What a check can report. The server keeps only these (server/src/services/review-diff.js CHECK_STEPS/CHECK_REASONS,
|
|
44
|
+
// pinned equal by a regression test). `fetch_failed` and `attempt_mismatch` are set by verify-patch in hone-cli.js.
|
|
45
|
+
const STEPS = Object.freeze(['parse', 'pre_image', 'offset', 'apply', 'post_image']);
|
|
46
|
+
const REASONS = Object.freeze(['ok', 'invalid_head', 'head_missing', 'head_moved', 'no_sections', 'timeout', 'git_error',
|
|
47
|
+
'fetch_failed', 'attempt_mismatch', 'deadline', 'context_mismatch', 'new_start_mismatch']);
|
|
48
|
+
|
|
49
|
+
const INDEX_RE = /^index ([0-9a-f]{40}|[0-9a-f]{64})\.\.([0-9a-f]{40}|[0-9a-f]{64})(?: ([0-7]{6}))?$/;
|
|
50
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/;
|
|
51
|
+
const NO_NEWLINE = '\';
|
|
52
|
+
|
|
53
|
+
/** `diff --git a/<rel> b/<rel>` with the same <rel> twice, or null. A quoted path is not a plain path. */
|
|
54
|
+
function relOfDiffGit(line) {
|
|
55
|
+
const PREFIX = 'diff --git a/';
|
|
56
|
+
if (!line.startsWith(PREFIX)) return null;
|
|
57
|
+
const rest = line.slice(PREFIX.length);
|
|
58
|
+
if ((rest.length - 3) % 2 !== 0 || rest.length < 5) return null;
|
|
59
|
+
const n = (rest.length - 3) / 2;
|
|
60
|
+
const a = rest.slice(0, n);
|
|
61
|
+
if (rest.slice(n, n + 3) !== ' b/' || rest.slice(n + 3) !== a || a.startsWith('"')) return null;
|
|
62
|
+
return a;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One section of `git diff --full-index` for a text modification. Every line must be accounted for: a mode, rename or
|
|
67
|
+
* copy header, a line beyond a hunk's counts, or a hunk out of order is a parse error, because `git apply` would read
|
|
68
|
+
* some of them and a reviewer would read all of them.
|
|
69
|
+
*/
|
|
70
|
+
function parseSection(text) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
73
|
+
const rel = relOfDiffGit(lines[0] || '');
|
|
74
|
+
const out = { rel, pre: null, post: null, mode: null, hunks: [], text, error: null };
|
|
75
|
+
const fail = (why) => ({ ...out, error: why });
|
|
76
|
+
if (rel === null) return fail('not a plain `diff --git a/<path> b/<path>` line');
|
|
77
|
+
const idx = INDEX_RE.exec(lines[1] || '');
|
|
78
|
+
if (!idx) return fail('line 2 is not a full-index `index` line');
|
|
79
|
+
out.pre = idx[1]; out.post = idx[2]; out.mode = idx[3] || null;
|
|
80
|
+
// git ends these two lines with a TAB when the path contains a space (diff.c, so a patch tool can find the name).
|
|
81
|
+
const tab = rel.includes(' ') ? '\t' : '';
|
|
82
|
+
if (lines[2] !== `--- a/${rel}${tab}`) return fail('line 3 is not `--- a/<path>`');
|
|
83
|
+
if (lines[3] !== `+++ b/${rel}${tab}`) return fail('line 4 is not `+++ b/<path>`');
|
|
84
|
+
let i = 4;
|
|
85
|
+
let prevEnd = 0;
|
|
86
|
+
while (i < lines.length) {
|
|
87
|
+
const h = HUNK_RE.exec(lines[i]);
|
|
88
|
+
if (!h) return fail(`line ${i + 1} is not a hunk header`);
|
|
89
|
+
const a = Number(h[1]); const b = h[2] === undefined ? 1 : Number(h[2]);
|
|
90
|
+
const c = Number(h[3]); const d = h[4] === undefined ? 1 : Number(h[4]);
|
|
91
|
+
if (b === 0 && d === 0) return fail(`line ${i + 1}: an empty hunk`);
|
|
92
|
+
const oldBegin = b === 0 ? a : a - 1;
|
|
93
|
+
if (oldBegin < 0 || oldBegin < prevEnd) return fail(`line ${i + 1}: hunks overlap or are out of order`);
|
|
94
|
+
prevEnd = oldBegin + b;
|
|
95
|
+
i += 1;
|
|
96
|
+
const oldLines = []; const newLines = [];
|
|
97
|
+
let ob = 0; let nd = 0;
|
|
98
|
+
while (ob < b || nd < d) {
|
|
99
|
+
const l = lines[i];
|
|
100
|
+
if (l === undefined) return fail('a hunk ends before its counts');
|
|
101
|
+
const k = l[0];
|
|
102
|
+
if (k === ' ') { oldLines.push(l.slice(1)); newLines.push(l.slice(1)); ob += 1; nd += 1; }
|
|
103
|
+
else if (k === '-') { oldLines.push(l.slice(1)); ob += 1; }
|
|
104
|
+
else if (k === '+') { newLines.push(l.slice(1)); nd += 1; }
|
|
105
|
+
else return fail(`line ${i + 1} is not a hunk line`);
|
|
106
|
+
if (ob > b || nd > d) return fail(`line ${i + 1} is beyond its hunk's counts`);
|
|
107
|
+
i += 1;
|
|
108
|
+
if (lines[i] === NO_NEWLINE) {
|
|
109
|
+
if (k !== '+') oldLines.noNewlineAt = oldLines.length - 1;
|
|
110
|
+
if (k !== '-') newLines.noNewlineAt = newLines.length - 1;
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out.hunks.push({ a, b, c, d, oldBegin, oldLines, newLines });
|
|
115
|
+
}
|
|
116
|
+
if (!out.hunks.length) return fail('a section with no hunk');
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Split a stored review diff on `diff --git`. Text before the first section is a section with an error. */
|
|
121
|
+
function parseSections(text) {
|
|
122
|
+
const t = String(text || '');
|
|
123
|
+
if (!t.trim()) return [];
|
|
124
|
+
const starts = [];
|
|
125
|
+
for (const m of t.matchAll(/^diff --git /gm)) starts.push(m.index);
|
|
126
|
+
const sections = [];
|
|
127
|
+
const head = t.slice(0, starts.length ? starts[0] : t.length);
|
|
128
|
+
if (head.trim()) {
|
|
129
|
+
sections.push({ rel: null, pre: null, post: null, mode: null, hunks: [], text: head,
|
|
130
|
+
error: 'text outside a diff section' });
|
|
131
|
+
}
|
|
132
|
+
for (let i = 0; i < starts.length; i++) {
|
|
133
|
+
sections.push(parseSection(t.slice(starts[i], i + 1 < starts.length ? starts[i + 1] : t.length)));
|
|
134
|
+
}
|
|
135
|
+
return sections;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** HEAD's lines, each with its own newline (the last may lack one). */
|
|
139
|
+
function linesOf(blob) { return String(blob).match(/[^\n]*\n|[^\n]+$/g) || []; }
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The offsets git apply does not check. Old lines must be HEAD's lines at the stated start, byte for byte (newline
|
|
143
|
+
* included), and each new start must be what git computes from the old start and the hunks before it.
|
|
144
|
+
* @returns {null | 'context_mismatch' | 'new_start_mismatch'}
|
|
145
|
+
*/
|
|
146
|
+
function checkOffsets(section, blob) {
|
|
147
|
+
const head = linesOf(blob);
|
|
148
|
+
let delta = 0;
|
|
149
|
+
for (const h of section.hunks) {
|
|
150
|
+
if (h.oldBegin + h.b > head.length) return 'context_mismatch';
|
|
151
|
+
for (let j = 0; j < h.b; j++) {
|
|
152
|
+
const want = h.oldLines[j] + (h.oldLines.noNewlineAt === j ? '' : '\n');
|
|
153
|
+
if (head[h.oldBegin + j] !== want) return 'context_mismatch';
|
|
154
|
+
}
|
|
155
|
+
const expectC = h.a + delta + (h.b === 0 ? 1 : 0) - (h.d === 0 ? 1 : 0);
|
|
156
|
+
if (h.c !== expectC) return 'new_start_mismatch';
|
|
157
|
+
delta += h.d - h.b;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function defaultRunGit(repoRoot) {
|
|
163
|
+
return (args, { timeout, input, env } = {}) => execFileSync('git', args, {
|
|
164
|
+
cwd: repoRoot, env: gitEnv({ ...(env || {}), GIT_NO_LAZY_FETCH: '1' }), input,
|
|
165
|
+
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: Math.max(1, Math.floor(timeout || PER_CALL_MS)),
|
|
166
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class Stop extends Error {
|
|
171
|
+
constructor(result) { super(result.reason || result.status); this.result = result; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {{stored: ({headSha: string, diff: string, notes?: string[]}|null), appliedHead: string, repoRoot: string,
|
|
176
|
+
* runGit?: Function, now?: () => number, mkTemp?: () => string}} opts
|
|
177
|
+
* @returns {{status: 'match'|'mismatch'|'unverifiable'|'absent', headSha: (string|null), step: (string|null),
|
|
178
|
+
* reason: (string|null), file: (string|null)}}
|
|
179
|
+
*/
|
|
180
|
+
function checkReviewDiff({ stored, appliedHead, repoRoot, runGit, now, mkTemp } = {}) {
|
|
181
|
+
const clock = now || Date.now;
|
|
182
|
+
const deadline = clock() + DEADLINE_MS;
|
|
183
|
+
let git = runGit || defaultRunGit(repoRoot || process.cwd());
|
|
184
|
+
const headSha = stored && typeof stored.headSha === 'string' && SHA_RE.test(stored.headSha) ? stored.headSha : null;
|
|
185
|
+
const result = (status, extra = {}) => ({ status, headSha, step: null, reason: null, file: null, ...extra });
|
|
186
|
+
const unverifiable = (reason) => result('unverifiable', { reason });
|
|
187
|
+
let tmp = null;
|
|
188
|
+
let interrupted = false;
|
|
189
|
+
|
|
190
|
+
// One git call, returning its stdout: a throw becomes a Stop (timeout, deadline, git_error, SIGINT) unless
|
|
191
|
+
// `allowExit` lets a non-zero exit through as `null` for the caller to judge.
|
|
192
|
+
const call = (args, { input, env, allowExit } = {}) => {
|
|
193
|
+
const left = Math.min(PER_CALL_MS, deadline - clock());
|
|
194
|
+
if (left <= 0) throw new Stop(unverifiable('deadline'));
|
|
195
|
+
try {
|
|
196
|
+
return String(git(args, { timeout: left, input, env }));
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (e && e.signal === 'SIGINT') { interrupted = true; throw new Stop(unverifiable('git_error')); }
|
|
199
|
+
if (e && (e.killed || e.code === 'ETIMEDOUT')) throw new Stop(unverifiable('timeout'));
|
|
200
|
+
if (allowExit && e && typeof e.status === 'number' && e.status !== 0) return null;
|
|
201
|
+
throw new Stop(unverifiable('git_error'));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
if (stored == null) return result('absent');
|
|
207
|
+
if (!headSha) return unverifiable('invalid_head');
|
|
208
|
+
// `git apply` skips a path outside the cwd's prefix and still exits 0, while `rev-parse :<rel>` resolves from the
|
|
209
|
+
// root: run from a subdirectory, a correct diff would read as a post_image mismatch. Every call uses the toplevel.
|
|
210
|
+
if (!runGit) git = defaultRunGit(call(['rev-parse', '--show-toplevel']).trim());
|
|
211
|
+
if (call(['cat-file', '-e', `${headSha}^{commit}`], { allowExit: true }) === null) return unverifiable('head_missing');
|
|
212
|
+
if (appliedHead !== headSha) return unverifiable('head_moved');
|
|
213
|
+
const sections = parseSections(stored.diff);
|
|
214
|
+
if (!sections.length) return unverifiable('no_sections');
|
|
215
|
+
|
|
216
|
+
tmp = (mkTemp || (() => fs.mkdtempSync(path.join(os.tmpdir(), TMP_PREFIX))))();
|
|
217
|
+
const indexEnv = { GIT_INDEX_FILE: path.join(tmp, 'index') };
|
|
218
|
+
call(['read-tree', headSha], { env: indexEnv });
|
|
219
|
+
|
|
220
|
+
for (const s of sections) {
|
|
221
|
+
const mismatch = (step, reason = null) => result('mismatch', { step, reason, file: s.rel });
|
|
222
|
+
if (s.error) return mismatch('parse');
|
|
223
|
+
const pre = call(['rev-parse', '--verify', '--quiet', `${headSha}:${s.rel}`], { allowExit: true });
|
|
224
|
+
if (pre === null || pre.trim() !== s.pre) return mismatch('pre_image');
|
|
225
|
+
const blob = call(['cat-file', 'blob', `${headSha}:${s.rel}`]);
|
|
226
|
+
const off = checkOffsets(s, blob);
|
|
227
|
+
if (off) return mismatch('offset', off);
|
|
228
|
+
const applied = call(['-c', 'apply.ignoreWhitespace=no', '-c', 'apply.whitespace=nowarn',
|
|
229
|
+
'apply', '--cached', '-'], { input: s.text, env: indexEnv, allowExit: true });
|
|
230
|
+
if (applied === null) return mismatch('apply');
|
|
231
|
+
const post = call(['rev-parse', '--verify', '--quiet', `:${s.rel}`], { env: indexEnv, allowExit: true });
|
|
232
|
+
if (post === null || post.trim() !== s.post) return mismatch('post_image');
|
|
233
|
+
}
|
|
234
|
+
return result('match', { reason: 'ok' });
|
|
235
|
+
} catch (e) {
|
|
236
|
+
return e instanceof Stop ? e.result : unverifiable('git_error');
|
|
237
|
+
} finally {
|
|
238
|
+
if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
239
|
+
if (interrupted) process.kill(process.pid, 'SIGINT');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Exactly what verify-patch posts as `reviewDiffCheck`: the six keys, `file` bounded. `attempt` is the step_4
|
|
245
|
+
* attempt whose stored diff was fetched and checked; the server binds the check to it.
|
|
246
|
+
*/
|
|
247
|
+
function toWireCheck(r, attempt) {
|
|
248
|
+
const c = r || {};
|
|
249
|
+
return {
|
|
250
|
+
status: c.status ?? null,
|
|
251
|
+
headSha: c.headSha ?? null,
|
|
252
|
+
attempt: Number.isInteger(attempt) ? attempt : null,
|
|
253
|
+
step: c.step ?? null,
|
|
254
|
+
reason: c.reason ?? null,
|
|
255
|
+
file: typeof c.file === 'string' ? c.file.slice(0, MAX_FILE_CHARS) : null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
parseSections, checkReviewDiff, toWireCheck,
|
|
261
|
+
_internal: { parseSection, checkOffsets, linesOf, relOfDiffGit, SHA_RE, PER_CALL_MS, DEADLINE_MS, TMP_PREFIX, STEPS, REASONS },
|
|
262
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* review-diff.js — HC-GF-390: the base diff a reviewer needs beside a whole emitted file.
|
|
4
|
+
*
|
|
5
|
+
* In editor mode step_4's artifact carries every changed file whole (tree-artifact.js), while the
|
|
6
|
+
* reviewers (step_5, 5d, 5e) see the ORIGINAL only through the bundle, which windows a file longer
|
|
7
|
+
* than BUNDLE_MAX_PER_FILE_CHARS. A change outside those windows was compared with nothing. This
|
|
8
|
+
* computes, for step_4 only, `git diff HEAD` of each file the render emitted whole whose HEAD version
|
|
9
|
+
* is over that cap. It is sent beside the artifact as `reviewDiff` and stored apart from it: the
|
|
10
|
+
* artifact itself is never touched.
|
|
11
|
+
*
|
|
12
|
+
* It never fails a submit. Every git call has a timeout; anything it cannot do becomes a one-line
|
|
13
|
+
* note, and a note means "unverified", exactly as an absent diff did before.
|
|
14
|
+
*
|
|
15
|
+
* Exit modes of what it consumes (HC-GF-117):
|
|
16
|
+
* rendered.files HANDLED: the only candidates. A created, deleted, renamed, binary-at-render
|
|
17
|
+
* or over-1 MB file is never in it, so it is never diffed.
|
|
18
|
+
* git rev-parse fails HANDLED: no HEAD → null, nothing sent.
|
|
19
|
+
* git diff --name-status HANDLED: a throw → one note, nothing diffed.
|
|
20
|
+
* per-file git calls HANDLED: a throw (timeout, ENOBUFS, spawn, non-zero) → a note, next file.
|
|
21
|
+
* "Binary files … differ" HANDLED: a note (a text file .gitattributes marks binary).
|
|
22
|
+
* empty diff HANDLED: a note (the file no longer differs from HEAD).
|
|
23
|
+
* post-image ≠ rendered HANDLED: a note. The diff must describe the bytes the reviewer was sent.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const crypto = require('node:crypto');
|
|
27
|
+
const { execFileSync } = require('node:child_process');
|
|
28
|
+
const { gitEnv } = require('./git-env');
|
|
29
|
+
const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits');
|
|
30
|
+
const { pathRefusal } = require('./path-refusal');
|
|
31
|
+
const { FILE_BLOCK_RE } = require('./block-grammar');
|
|
32
|
+
|
|
33
|
+
const REVIEW_DIFF_MAX_CHARS = 60_000; // diff text, summed over the files included
|
|
34
|
+
const PER_CALL_MS = 15_000;
|
|
35
|
+
const DEADLINE_MS = 60_000; // every per-file call gets min(PER_CALL_MS, what is left)
|
|
36
|
+
const MAX_NOTES = 100;
|
|
37
|
+
const MAX_NOTE_CHARS = 300;
|
|
38
|
+
// Kept equal to server/src/services/review-diff.js REVIEW_DIFF_MAX_BYTES (pinned by the HC-GF-390 tests).
|
|
39
|
+
const REVIEW_DIFF_MAX_BYTES = 256 * 1024;
|
|
40
|
+
|
|
41
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
42
|
+
|
|
43
|
+
/** The bundle windows a file whose text is longer than the cap (bundle-paths.js `text.length <= limit` is whole). */
|
|
44
|
+
const overCap = (length) => length > BUNDLE_MAX_PER_FILE_CHARS;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pinned diff. Every option a user's git config could change is given on the command line, as a
|
|
48
|
+
* flag the `diff` subcommand takes (not a leading `-c`, so a caller can still recognise the call).
|
|
49
|
+
*/
|
|
50
|
+
function diffArgs(headSha, rel) {
|
|
51
|
+
return ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--full-index',
|
|
52
|
+
'--diff-algorithm=myers', '--no-relative', '--inter-hunk-context=0', '-U3',
|
|
53
|
+
'--src-prefix=a/', '--dst-prefix=b/', headSha, '--', rel];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultRunGit(repoRoot) {
|
|
57
|
+
return (args, timeoutMs) => execFileSync('git', args, {
|
|
58
|
+
cwd: repoRoot, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
59
|
+
timeout: Math.max(1, Math.floor(timeoutMs)), stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The contents of the first `file:` block for `rel` in a rendered artifact, as the server's
|
|
65
|
+
* carry-forward `blocksOf` reads it (same grammar, same scope, same one-newline strip), or null.
|
|
66
|
+
*/
|
|
67
|
+
function blockContents(text, rel) {
|
|
68
|
+
const t = String(text || '');
|
|
69
|
+
const sec = t.search(/^##\s+Changed Files/im);
|
|
70
|
+
const scope = t.slice(sec === -1 ? 0 : sec);
|
|
71
|
+
for (const m of scope.matchAll(FILE_BLOCK_RE)) {
|
|
72
|
+
if (m[2].trim() === rel) return m[3].replace(/\r?\n$/, '');
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** git's object id of a blob holding `str` as UTF-8, computed in memory (never `hash-object -w`). */
|
|
78
|
+
function gitBlobId(str, algo = 'sha1') {
|
|
79
|
+
const bytes = Buffer.from(String(str), 'utf8');
|
|
80
|
+
return crypto.createHash(algo).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One line, no control characters, at most `max` chars. */
|
|
84
|
+
function oneLine(s, max) {
|
|
85
|
+
const flat = String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]+/g, ' ').trim();
|
|
86
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {object} opts
|
|
91
|
+
* @param {string} opts.repoRoot
|
|
92
|
+
* @param {{text: string, files: string[]}} opts.rendered renderFromTree()'s result
|
|
93
|
+
* @param {(args: string[], timeoutMs: number) => string} [opts.runGit]
|
|
94
|
+
* @param {() => number} [opts.now]
|
|
95
|
+
* @param {(line: string) => void} [opts.warn] why nothing is sent when that is not "no candidate"
|
|
96
|
+
* @returns {null | { headSha: string, diff: string, notes: string[], stats: object }}
|
|
97
|
+
* null when there is nothing to send: no HEAD, no candidate, or a payload over its byte cap.
|
|
98
|
+
*/
|
|
99
|
+
function computeReviewDiff({ repoRoot, rendered, runGit, now = Date.now, warn = () => {} } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const git = runGit || defaultRunGit(repoRoot);
|
|
102
|
+
const emitted = new Set(rendered && Array.isArray(rendered.files) ? rendered.files : []);
|
|
103
|
+
if (!emitted.size) return null;
|
|
104
|
+
|
|
105
|
+
const deadline = now() + DEADLINE_MS;
|
|
106
|
+
let headSha;
|
|
107
|
+
try { headSha = String(git(['rev-parse', '--verify', 'HEAD^{commit}'], PER_CALL_MS)).trim(); } catch { return null; }
|
|
108
|
+
if (!SHA_RE.test(headSha)) return null;
|
|
109
|
+
const algo = headSha.length === 64 ? 'sha256' : 'sha1';
|
|
110
|
+
|
|
111
|
+
const notes = [];
|
|
112
|
+
const sections = [];
|
|
113
|
+
let used = 0;
|
|
114
|
+
const note = (rel, reason) => notes.push(oneLine(`${JSON.stringify(rel)}: ${reason}`, MAX_NOTE_CHARS));
|
|
115
|
+
|
|
116
|
+
let candidates = [];
|
|
117
|
+
try {
|
|
118
|
+
const tokens = String(git(['diff', '--name-status', '-z', '--no-renames', headSha], PER_CALL_MS)).split('\0');
|
|
119
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
120
|
+
const status = tokens[i]; const rel = tokens[i + 1];
|
|
121
|
+
if ((status === 'M' || status === 'T') && emitted.has(rel)) candidates.push(rel);
|
|
122
|
+
}
|
|
123
|
+
candidates = [...new Set(candidates)].sort();
|
|
124
|
+
} catch (e) {
|
|
125
|
+
notes.push(oneLine(`(all files): git failed listing changes against HEAD: ${e && e.message}`, MAX_NOTE_CHARS));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
129
|
+
const rel = candidates[i];
|
|
130
|
+
const left = () => Math.min(PER_CALL_MS, deadline - now());
|
|
131
|
+
let t = left();
|
|
132
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
133
|
+
try {
|
|
134
|
+
// The size first: it reads no content, and a file at or under the cap was bundled whole.
|
|
135
|
+
// UTF-8 bytes >= UTF-16 length, so a size at or under the cap cannot be a length over it.
|
|
136
|
+
const size = Number(String(git(['cat-file', '-s', `${headSha}:${rel}`], t)).trim());
|
|
137
|
+
if (!Number.isFinite(size) || !overCap(size)) continue;
|
|
138
|
+
if (pathRefusal(rel, { mode: 'egress' })) { note(rel, 'secret path; not diffed'); continue; }
|
|
139
|
+
t = left();
|
|
140
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
141
|
+
const head = String(git(['cat-file', 'blob', `${headSha}:${rel}`], t));
|
|
142
|
+
if (!overCap(head.length)) continue;
|
|
143
|
+
t = left();
|
|
144
|
+
if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
|
|
145
|
+
const out = String(git(diffArgs(headSha, rel), t));
|
|
146
|
+
if (!out.trim()) { note(rel, 'no longer differs from HEAD; not diffed'); continue; }
|
|
147
|
+
if (/^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out)) { note(rel, 'binary to git; not diffed'); continue; }
|
|
148
|
+
if ((out.match(/^diff --git /gm) || []).length !== 1) { note(rel, 'unexpected diff shape; not included'); continue; }
|
|
149
|
+
const post = /^index [0-9a-f]+\.\.([0-9a-f]+)(?: [0-7]+)?$/m.exec(out);
|
|
150
|
+
const contents = blockContents(rendered.text, rel);
|
|
151
|
+
if (!post || contents === null || post[1] !== gitBlobId(`${contents}\n`, algo)) {
|
|
152
|
+
note(rel, 'post-image differs from the rendered bytes (an eol or clean filter, no final newline, '
|
|
153
|
+
+ 'non-UTF-8 content, or a change after render); not included');
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const section = out.endsWith('\n') ? out : `${out}\n`;
|
|
157
|
+
if (used + section.length > REVIEW_DIFF_MAX_CHARS) {
|
|
158
|
+
note(rel, `over the ${REVIEW_DIFF_MAX_CHARS.toLocaleString('en-US')}-char review-diff budget; not included`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
sections.push(section);
|
|
162
|
+
used += section.length;
|
|
163
|
+
} catch (e) {
|
|
164
|
+
note(rel, `git failed: ${oneLine(String((e && e.message) || e).split('\n')[0], 120)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const kept = notes.length > MAX_NOTES
|
|
169
|
+
? [...notes.slice(0, MAX_NOTES - 1), `${notes.length - (MAX_NOTES - 1)} more note(s) omitted`]
|
|
170
|
+
: notes;
|
|
171
|
+
if (!sections.length && !kept.length) return null;
|
|
172
|
+
const payload = { headSha, diff: sections.join(''), notes: kept };
|
|
173
|
+
const bytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
|
|
174
|
+
if (bytes > REVIEW_DIFF_MAX_BYTES) { warn(`review diff not sent: ${bytes} bytes is over the ${REVIEW_DIFF_MAX_BYTES}-byte limit`); return null; }
|
|
175
|
+
return { ...payload, stats: { candidates: candidates.length, files: sections.length, chars: used } };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
warn(`review diff not sent: ${oneLine((e && e.message) || e, 200)}`);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Exactly what `hone submit-step` posts as `reviewDiff`: the stats stay CLI-side. One mapper, so a test can post what the CLI posts. */
|
|
183
|
+
function toWire(r) { return { headSha: r.headSha, diff: r.diff, notes: r.notes }; }
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
computeReviewDiff, blockContents, gitBlobId, toWire,
|
|
187
|
+
_internal: { overCap, diffArgs, REVIEW_DIFF_MAX_CHARS, PER_CALL_MS, DEADLINE_MS, MAX_NOTES, MAX_NOTE_CHARS, REVIEW_DIFF_MAX_BYTES },
|
|
188
|
+
};
|
package/lib/run-context.js
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
const { composeStoryDescription } = require('./story-compose');
|
|
28
|
-
const { extractStoryIdFromBranch } = require('./pipeline-status');
|
|
29
28
|
const {
|
|
30
29
|
extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
|
|
31
30
|
} = require('./bundle-paths');
|
|
@@ -37,7 +36,6 @@ const { classifyStory } = require('./story-classifier');
|
|
|
37
36
|
const {
|
|
38
37
|
extractType, extractSecurityImplications, extractCrossRepoDependency,
|
|
39
38
|
} = require('./story-classifier-extract');
|
|
40
|
-
const { readArchitectConfigFromText } = require('./architect-config');
|
|
41
39
|
const path = require('path');
|
|
42
40
|
|
|
43
41
|
/**
|
|
@@ -49,13 +47,16 @@ const path = require('path');
|
|
|
49
47
|
*/
|
|
50
48
|
const SERVER_OWNED_KEYS = Object.freeze([
|
|
51
49
|
'batchTokenBudget', 'batchFailureThreshold', 'dependsOnRunIds', 'monthlyTokenBudget', 'overnight',
|
|
50
|
+
// HC-GF-030: whether a story was consulted is what the server's step_0b recorded, never what a
|
|
51
|
+
// client says. A client that could send these true would skip the consult.
|
|
52
|
+
'architect_consulted', 'checklist_b_completed', 'architect_consult',
|
|
52
53
|
]);
|
|
53
54
|
|
|
54
55
|
// HC-019n-followup-13d: the bundle caps. OptionsFlow #96 hit 49,783 of the old 50,000 with six
|
|
55
56
|
// files, so the next slightly larger story silently dropped one. 30 / 150K / 25K.
|
|
56
57
|
const BUNDLE_MAX_FILES = 30;
|
|
57
58
|
const BUNDLE_MAX_TOTAL_CHARS = 150_000;
|
|
58
|
-
const BUNDLE_MAX_PER_FILE_CHARS =
|
|
59
|
+
const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits'); // HC-GF-390: shared with review-diff.js
|
|
59
60
|
|
|
60
61
|
const info = (text) => ({ level: 'info', text });
|
|
61
62
|
const warn = (text) => ({ level: 'warn', text });
|
|
@@ -158,7 +159,6 @@ async function issueStorySource(io, issueNumber, issue) {
|
|
|
158
159
|
* @param {() => string} io.e2eSpecMode
|
|
159
160
|
* @param {() => string} io.verificationGate
|
|
160
161
|
* @param {() => string[]} io.verificationGateSources
|
|
161
|
-
* @param {() => string} io.readPlanText EXECUTION_PLAN.yml text, '' when absent
|
|
162
162
|
* @param {{ e2eSpecs?: boolean, skipE2eSpecs?: boolean, treeByteCap?: number }} [opts] run-story's per-run
|
|
163
163
|
* flags; `treeByteCap` is queue-stories' byte cap on the tree's path lists (none for run-story)
|
|
164
164
|
*/
|
|
@@ -295,13 +295,7 @@ function resolveRepoContext(io, opts = {}) {
|
|
|
295
295
|
config.verification_gate_sources = ['verify-patch'];
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
|
|
299
|
-
let planText = '';
|
|
300
|
-
try { planText = io.readPlanText() || ''; } catch (e) {
|
|
301
|
-
notes.push(warn(` ⚠ EXECUTION_PLAN.yml read failed (non-fatal, architect flags default to {false, false}): ${firstLine(e)}`));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return { config, notes, repoFiles, defaultIncludePaths, planText, execution };
|
|
298
|
+
return { config, notes, repoFiles, defaultIncludePaths, execution };
|
|
305
299
|
}
|
|
306
300
|
|
|
307
301
|
/**
|
|
@@ -324,8 +318,8 @@ function resolveRepoContext(io, opts = {}) {
|
|
|
324
318
|
* @param {object} repoCtx resolveRepoContext's result
|
|
325
319
|
* @param {string} storyId the id as the operator typed it — sent as `storyId`
|
|
326
320
|
* @param {{ explicitText?: string|null, explicitLabel?: string, explicitSource?: {kind: 'file'|'description', label?: string},
|
|
327
|
-
* includePaths?: string[], axes?: string }} [opts]
|
|
328
|
-
* @returns {Promise<{ storyId: string,
|
|
321
|
+
* includePaths?: string[], axes?: string, consultArchitect?: boolean }} [opts]
|
|
322
|
+
* @returns {Promise<{ storyId: string, rawText: string,
|
|
329
323
|
* textSource: 'explicit'|'issue'|'roadmap'|null, textFailure: {kind:string, detail?:string}|null,
|
|
330
324
|
* config: object, bundle: {files:number, chars:number}, notes: {pre: object[], post: object[]} }>}
|
|
331
325
|
*/
|
|
@@ -336,7 +330,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
336
330
|
let rawText = '';
|
|
337
331
|
let textSource = null;
|
|
338
332
|
let textFailure = null;
|
|
339
|
-
let resolvedStoryId = storyId; // canonical id for the architect-config lookup
|
|
340
333
|
|
|
341
334
|
// ── Story text: explicit → GitHub issue → Hone roadmap ────────────────────────────────────
|
|
342
335
|
// HC-GF-010: an EXPLICIT story source short-circuits both lookups, so the operator can never
|
|
@@ -369,17 +362,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
369
362
|
const prov = await issueStorySource(io, storyId, issue);
|
|
370
363
|
config.story_source = prov.source;
|
|
371
364
|
pre.push(...prov.notes);
|
|
372
|
-
// HC-019b-followup-1 F1: the canonical story id from the issue title, or the architect
|
|
373
|
-
// lookup keys on '104' and silently misses every EXECUTION_PLAN.yml entry.
|
|
374
|
-
try {
|
|
375
|
-
const titleId = extractStoryIdFromBranch(issue.title);
|
|
376
|
-
if (titleId) {
|
|
377
|
-
resolvedStoryId = titleId;
|
|
378
|
-
pre.push(info(` → resolved issue #${storyId} → canonical story id '${titleId}' for architect-config lookup`));
|
|
379
|
-
} else {
|
|
380
|
-
pre.push(warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyId}' (likely silent miss)`));
|
|
381
|
-
}
|
|
382
|
-
} catch { /* extractor throws → fall back to the numeric id */ }
|
|
383
365
|
} catch (e) {
|
|
384
366
|
textFailure = { kind: 'issue-error', detail: firstLine(e) };
|
|
385
367
|
// HC-GF-270: stated rather than left absent, so the later pause is readable. Classified, not
|
|
@@ -392,7 +374,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
392
374
|
};
|
|
393
375
|
pre.push(warn(` ⚠ could not fetch GitHub issue context: ${firstLine(e)}`));
|
|
394
376
|
pre.push(warn(' → proceeding without story_description; step_0 may produce placeholder output'));
|
|
395
|
-
pre.push(warn(` ⚠ architect-config lookup will use '${storyId}' (likely silent miss)`));
|
|
396
377
|
}
|
|
397
378
|
} else {
|
|
398
379
|
// HC-019n-followup-18: a roadmap-sourced story. Only the SERVER knows what it says; only the
|
|
@@ -540,11 +521,14 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
540
521
|
path: candidate,
|
|
541
522
|
});
|
|
542
523
|
content = excerpt.content;
|
|
543
|
-
if (excerpt.notice) pre.push(warn(excerpt.notice));
|
|
544
|
-
for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
|
|
545
524
|
if (totalChars + content.length > BUNDLE_MAX_TOTAL_CHARS) { budgetSpent = true; break; }
|
|
546
525
|
totalChars += content.length;
|
|
547
526
|
codebaseFiles.push({ path: candidate, content });
|
|
527
|
+
// Only a file that made the bundle gets the "bundled lines A-B" notice: announced before the
|
|
528
|
+
// budget check, a dropped file printed it AND the NOT-bundled line below, and the operator
|
|
529
|
+
// read the first one (seen 2026-10-16 on server/scripts/seed-agent-prompts.js).
|
|
530
|
+
if (excerpt.notice) pre.push(warn(excerpt.notice));
|
|
531
|
+
for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
|
|
548
532
|
}
|
|
549
533
|
// HC-GF-288: an operator-named entry whose file never reached the bundle (the budget ran
|
|
550
534
|
// out first, or it names a directory) vanished as silently as the anchor did. A refused
|
|
@@ -597,6 +581,11 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
597
581
|
}
|
|
598
582
|
}
|
|
599
583
|
|
|
584
|
+
// ── Architect consult on request (HC-GF-397) ─────────────────────────────────────────────
|
|
585
|
+
// --consult-architect: the server runs step_0b even on a `not_engaged` grooming. Sent only as `true`; the server reads
|
|
586
|
+
// nothing else as a request, and no value of it can skip a consult.
|
|
587
|
+
if (opts.consultArchitect === true) config.consult_architect = true;
|
|
588
|
+
|
|
600
589
|
// ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
|
|
601
590
|
// Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
|
|
602
591
|
// come from the groomer at step_0 and are harvested server-side. Fail-open.
|
|
@@ -621,19 +610,7 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
621
610
|
}
|
|
622
611
|
}
|
|
623
612
|
|
|
624
|
-
|
|
625
|
-
// workflow-dag.js's validateStepPreConditions BLOCKS step_1 when architect_consulted is true
|
|
626
|
-
// and checklist_b_completed is not; without these flags every architect-engaged story
|
|
627
|
-
// deadlocks. Keyed on the RESOLVED id (HC-019b-followup-1 F1).
|
|
628
|
-
const arch = readArchitectConfigFromText(repoCtx.planText || '', resolvedStoryId);
|
|
629
|
-
config.architect_consulted = arch.architect_consulted;
|
|
630
|
-
config.checklist_b_completed = arch.checklist_b_completed;
|
|
631
|
-
if (arch.diagnostic) post.push(warn(` ⚠ ${arch.diagnostic}`));
|
|
632
|
-
if (arch.architect_consulted) {
|
|
633
|
-
post.push(info(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`));
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
return { storyId, resolvedStoryId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
|
|
613
|
+
return { storyId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
|
|
637
614
|
}
|
|
638
615
|
|
|
639
616
|
/**
|