@hone-ai/cli 1.32.0 → 1.34.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 +149 -70
- package/lib/auto-verify.js +21 -1
- package/lib/emit-pr.js +1 -0
- package/lib/headroom.js +63 -0
- package/lib/materialize-diff.js +157 -20
- package/lib/mcp-tools.js +64 -18
- package/lib/poll-decision.js +16 -1
- package/lib/run-report.js +147 -6
- package/lib/run-target.js +97 -0
- package/lib/tree-artifact.js +166 -0
- package/lib/verify-patch.js +88 -3
- package/package.json +1 -1
package/lib/run-report.js
CHANGED
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
/** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
|
|
20
20
|
const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* HC-GF-008: the verification verdicts that leave nothing outstanding. `disabled` is verify-pr's record of
|
|
24
|
+
* an explicit `ci.gate: none`. Everything else — including a value this file has never seen — is an item,
|
|
25
|
+
* so a renamed or added verdict becomes a visible line rather than silence.
|
|
26
|
+
*/
|
|
27
|
+
const CLEAR_VERIFICATION_VERDICTS = ['pass', 'disabled'];
|
|
28
|
+
/** The ones that gate the script exit. `unresolved` gates because an absent command used to report `fail`. */
|
|
29
|
+
const GATING_VERIFICATION_VERDICTS = ['fail', 'timeout', 'unresolved'];
|
|
30
|
+
|
|
22
31
|
function pad(s, n) { return String(s).padEnd(n); }
|
|
23
32
|
|
|
24
33
|
/**
|
|
@@ -38,6 +47,37 @@ function durationOf(startedAt, endedAt) {
|
|
|
38
47
|
* @param {object} run the GET /:runId projection
|
|
39
48
|
* @param {{cost?: {metered:number, notional:number}}} [extra]
|
|
40
49
|
*/
|
|
50
|
+
/**
|
|
51
|
+
* HC-GF-046(b): the error codes on which approving is REFUSED — and nothing else.
|
|
52
|
+
*
|
|
53
|
+
* This must equal exactly what `checkGuardrails` emits, because the report tells the
|
|
54
|
+
* operator "approving will be refused" for these. The first version was a hand-typed list
|
|
55
|
+
* written from memory, and one of its four entries was right: MAX_STEPS_EXCEEDED was the
|
|
56
|
+
* wrong string (the real code is MAX_TOTAL_STEPS_EXCEEDED), BUDGET_EXCEEDED could never
|
|
57
|
+
* match the prefixed TOKEN_/BATCH_BUDGET_EXCEEDED past the ^ anchor, and GARBAGE_OUTPUT is
|
|
58
|
+
* not a guardrail refusal at all — approveGate accepts it. It is not an ordinary gate either;
|
|
59
|
+
* see EXHAUSTED_RE below.
|
|
60
|
+
*
|
|
61
|
+
* Exported so a test pins it equal to the enforcer's own reason codes. The enforcer lives in
|
|
62
|
+
* the server package and this in the CLI package, so they cannot share a constant at
|
|
63
|
+
* runtime; the equality is asserted in CI instead, which is where drift would be caught.
|
|
64
|
+
*/
|
|
65
|
+
const BLOCKING_REASON_CODES = Object.freeze(['MAX_RETRIES_EXCEEDED', 'MAX_TOTAL_STEPS_EXCEEDED']);
|
|
66
|
+
const BLOCKING_REASON_RE = new RegExp(`^(${BLOCKING_REASON_CODES.join('|')})[^\\n]*`);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* HC-GF-046(b), corrected in review: a step that used up its format retries.
|
|
70
|
+
*
|
|
71
|
+
* This first shipped as an ordinary gate ("a gate is waiting on your judgment"), on the claim
|
|
72
|
+
* that approving past it started a real attempt — a3, a4 and a5 on run 5f1a7f75. An independent
|
|
73
|
+
* review refuted that from the rows: a3 was the automatic retry, a4 and a5 were route-backs from
|
|
74
|
+
* step_5, and the one approval of that pause re-ran nothing. approveGate closes the step's
|
|
75
|
+
* in-flight rows and moves on, so later steps read the most recent EARLIER attempt — on
|
|
76
|
+
* 5f1a7f75, the build step_5 had already rejected. "Gate" invites exactly that. HC-GF-075 then
|
|
77
|
+
* changed what approving does — it retries the step — and this line says so.
|
|
78
|
+
*/
|
|
79
|
+
const EXHAUSTED_RE = /^GARBAGE_OUTPUT at (step_[0-9a-z]+)/;
|
|
80
|
+
|
|
41
81
|
function buildReport(run, extra = {}) {
|
|
42
82
|
const cfg = run.config || {};
|
|
43
83
|
const steps = Array.isArray(run.steps) ? run.steps : [];
|
|
@@ -61,15 +101,29 @@ function buildReport(run, extra = {}) {
|
|
|
61
101
|
// A resumable pause — a local rate-limit window, for instance — is not a refusal, and
|
|
62
102
|
// silencing a real gate on those would be the over-reach that makes the surface
|
|
63
103
|
// untrustworthy in the other direction.
|
|
64
|
-
const blocked = String(run.errorMessage || '')
|
|
65
|
-
|
|
66
|
-
if (
|
|
104
|
+
const blocked = String(run.errorMessage || '').match(BLOCKING_REASON_RE);
|
|
105
|
+
const exhausted = String(run.errorMessage || '').match(EXHAUSTED_RE);
|
|
106
|
+
if (run.approvalRefusal) {
|
|
107
|
+
// HC-GF-074: rendered from the server's own decision, never re-derived from verdict records here.
|
|
108
|
+
needsYou.push({
|
|
109
|
+
what: `run CANNOT be approved at ${run.currentStep} as it stands — ${String(run.approvalRefusal.message).slice(0, 300)}`,
|
|
110
|
+
how: `fix and re-verify (hone verify-patch ${run.runId} --report), or hone run-story ${run.runId} --approve ${run.currentStep} --acknowledge-failed-verdict`,
|
|
111
|
+
});
|
|
112
|
+
} else if (blocked) {
|
|
67
113
|
needsYou.push({
|
|
68
114
|
what: `run CANNOT proceed at ${run.currentStep} — ${blocked[0].slice(0, 160)}. `
|
|
69
115
|
+ 'Approving will be refused by the guardrail, so this is a decision, not a gate',
|
|
70
116
|
how: `hone run-story ${run.runId} --kill (or raise guardrails.max_retries_per_step `
|
|
71
117
|
+ 'in .pipeline-config.yml and re-run the story)',
|
|
72
118
|
});
|
|
119
|
+
} else if (exhausted) {
|
|
120
|
+
const s = exhausted[1];
|
|
121
|
+
needsYou.push({
|
|
122
|
+
what: `run is paused at ${s} — ${s} used up its format retries. Approving starts another cycle `
|
|
123
|
+
+ `of ${s} with a fresh format budget; it does not accept an earlier attempt`,
|
|
124
|
+
how: `hone show step ${run.runId} ${s} (then --approve ${s} to retry, or --kill; `
|
|
125
|
+
+ 'guardrails.max_retries_per_step caps the cycles)',
|
|
126
|
+
});
|
|
73
127
|
} else {
|
|
74
128
|
needsYou.push({
|
|
75
129
|
what: `run is paused at ${run.currentStep} — a gate is waiting on your judgment`,
|
|
@@ -169,6 +223,35 @@ function buildReport(run, extra = {}) {
|
|
|
169
223
|
});
|
|
170
224
|
}
|
|
171
225
|
|
|
226
|
+
// HC-GF-050: a cycle that changed nothing, and attempts escalating toward the ceiling. Computed by the server from every
|
|
227
|
+
// attempt (GET /:runId `convergence`) and rendered here, never re-derived. ADVISORY: a new signal reports before it gates
|
|
228
|
+
// (HC-026c), and a story that legitimately needs three cycles must not flip a script's exit status. When cycles were
|
|
229
|
+
// compared and none was flagged, say what was compared — absence must not read as not-checked.
|
|
230
|
+
const conv = run.convergence;
|
|
231
|
+
if (conv) {
|
|
232
|
+
for (const u of conv.unchanged || []) {
|
|
233
|
+
needsYou.push({
|
|
234
|
+
what: `a cycle changed nothing — ${u.detail}`,
|
|
235
|
+
how: `hone show step ${run.runId} step_4 --attempt ${u.attempts[u.attempts.length - 1]}`,
|
|
236
|
+
howLabel: 'inspect',
|
|
237
|
+
advisory: true,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (conv.escalating) {
|
|
241
|
+
needsYou.push({
|
|
242
|
+
what: `each build attempt runs longer and produces more — ${conv.escalating.detail}`,
|
|
243
|
+
how: `hone show step ${run.runId} step_4 --attempt ${conv.escalating.attempts[conv.escalating.attempts.length - 1]}`,
|
|
244
|
+
howLabel: 'inspect',
|
|
245
|
+
advisory: true,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const ex = conv.examined || {};
|
|
249
|
+
if (!(conv.unchanged || []).length && !conv.escalating && (ex.reviewCycles || ex.repeatedFailurePairs)) {
|
|
250
|
+
worthKnowing.push(`convergence: compared ${ex.reviewCycles} review cycle(s) and ${ex.repeatedFailurePairs} repeated failure(s) `
|
|
251
|
+
+ `across ${ex.builds} build attempt(s) — each rebuild changed materially (${ex.criteria})`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
172
255
|
// 1d. HC-GF-041: two diagnostics the orchestrator has stamped since HC-019n-followup-41
|
|
173
256
|
// and -43, which NOTHING has ever read on this path — the run payload carried only
|
|
174
257
|
// token counts until the `diagnostics` projection was added. HC-GF-016's row specifies
|
|
@@ -179,6 +262,42 @@ function buildReport(run, extra = {}) {
|
|
|
179
262
|
// "none" would be a claim the report cannot support for a run that never ran the check.
|
|
180
263
|
for (const s of steps) {
|
|
181
264
|
const d = s.diagnostics || {};
|
|
265
|
+
// HC-GF-089: how close this step came to its limits. worthKnowing, never needsYou — approaching
|
|
266
|
+
// a limit is not an action item, and an advisory that reads like one trains operators to ignore
|
|
267
|
+
// it. Absent when the server projected no headroom fields, which means "nothing to judge".
|
|
268
|
+
//
|
|
269
|
+
// Computed inline rather than imported: this module is deliberately dependency-free (see the
|
|
270
|
+
// header), and the numbers come from stored data like every other line here. cli/lib/headroom.js
|
|
271
|
+
// owns the same thresholds for the status surface, and HC-GF-089's tests pin the two agree.
|
|
272
|
+
const hr = d.headroom;
|
|
273
|
+
if (hr && typeof hr === 'object') {
|
|
274
|
+
const el = Number(hr.elapsed_ms);
|
|
275
|
+
const to = Number(hr.timeout_ms);
|
|
276
|
+
const toks = (Array.isArray(hr.output_tokens) ? hr.output_tokens : [])
|
|
277
|
+
.map(Number).filter((n) => Number.isFinite(n) && n >= 0);
|
|
278
|
+
const busiest = toks.length ? Math.max(...toks) : null;
|
|
279
|
+
const near = [];
|
|
280
|
+
if (Number.isFinite(el) && Number.isFinite(to) && to > 0 && el / to >= 0.75) {
|
|
281
|
+
near.push(`time ${Math.round((el / to) * 100)}% (${Math.round(el / 1000)}s of ${Math.round(to / 1000)}s)`);
|
|
282
|
+
}
|
|
283
|
+
if (busiest !== null && busiest / 128000 >= 0.75) {
|
|
284
|
+
near.push(`tokens ${Math.round((busiest / 128000) * 100)}% (${busiest} of 128000 in one message)`);
|
|
285
|
+
}
|
|
286
|
+
// HC-GF-129: `>= 0.75` has no upper bound, so this said "not over it" about a call that had
|
|
287
|
+
// gone over and died (run 692b0c29: 550,017ms of a 550,000ms ceiling, cut off). The wording
|
|
288
|
+
// and the PLACEMENT are wrong together — worthKnowing is right for a near miss, and wrong
|
|
289
|
+
// for a call that actually hit its limit, which is exactly what an operator needs to see.
|
|
290
|
+
const overTime = Number.isFinite(el) && Number.isFinite(to) && to > 0 && el >= to;
|
|
291
|
+
const overTokens = busiest !== null && busiest >= 128000;
|
|
292
|
+
if (near.length && (overTime || overTokens)) {
|
|
293
|
+
needsYou.push({
|
|
294
|
+
what: `${s.stepKey} headroom: ${near.join(', ')} — the limit was REACHED: that call was cut off`,
|
|
295
|
+
how: `hone show step ${run.runId} ${s.stepKey}`,
|
|
296
|
+
});
|
|
297
|
+
} else if (near.length) {
|
|
298
|
+
worthKnowing.push(`${s.stepKey} headroom: ${near.join(', ')} — close to the limit, not over it`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
182
301
|
if (Array.isArray(d.invented_paths) && d.invented_paths.length) {
|
|
183
302
|
needsYou.push({
|
|
184
303
|
what: `${s.stepKey} named ${d.invented_paths.length} path(s) that do not exist in the repo, and was accepted anyway`,
|
|
@@ -191,6 +310,18 @@ function buildReport(run, extra = {}) {
|
|
|
191
310
|
worthKnowing.push(
|
|
192
311
|
`${s.stepKey} dropped composed guardrail section(s) on a retry and was accepted (budget spent): ${d.composed_missing.join(', ')}`);
|
|
193
312
|
}
|
|
313
|
+
// HC-GF-059: this build contains files the attempt did not write. Advisory — they are the previous build's files,
|
|
314
|
+
// unchanged, and whoever approves should read them as that build's content.
|
|
315
|
+
if (d.carried_forward && Array.isArray(d.carried_forward.paths) && d.carried_forward.paths.length) {
|
|
316
|
+
needsYou.push({
|
|
317
|
+
what: `${s.stepKey} attempt ${s.attempt} did not re-emit ${d.carried_forward.paths.length} planned file(s); they were `
|
|
318
|
+
+ `carried forward unchanged from attempt ${d.carried_forward.from_attempt} — review them as that build's content`,
|
|
319
|
+
detail: d.carried_forward.paths.map(String),
|
|
320
|
+
how: `hone show step ${run.runId} ${s.stepKey} --attempt ${s.attempt}`,
|
|
321
|
+
howLabel: 'inspect',
|
|
322
|
+
advisory: true,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
194
325
|
}
|
|
195
326
|
|
|
196
327
|
// 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
|
|
@@ -205,12 +336,15 @@ function buildReport(run, extra = {}) {
|
|
|
205
336
|
const lastVerification = verifications[verifications.length - 1] || null;
|
|
206
337
|
if (lastVerification) {
|
|
207
338
|
const bad = (lastVerification.gates || []).filter(g => ACTIONABLE_GATE_VERDICTS.includes(g.verdict));
|
|
208
|
-
|
|
339
|
+
// HC-GF-008: "not a pass" is the enforcer's rule (verification-refusal `failingVerdict`), so it is the
|
|
340
|
+
// report's. Listing the failing values instead left `does_not_apply`, `error` — and the new `unresolved`,
|
|
341
|
+
// which an absent command used to report as a gating `fail` — rendering "nothing outstanding".
|
|
342
|
+
const failed = !CLEAR_VERIFICATION_VERDICTS.includes(lastVerification.verdict);
|
|
209
343
|
if (failed || bad.length) {
|
|
210
344
|
const stepKey = lastVerification.stepKey || 'step_4';
|
|
211
345
|
needsYou.push({
|
|
212
346
|
what: failed
|
|
213
|
-
? `verification ${lastVerification.verdict} on ${stepKey}`
|
|
347
|
+
? `${lastVerification.verdict === 'unresolved' ? 'verification could not run' : `verification ${lastVerification.verdict}`} on ${stepKey}`
|
|
214
348
|
+ `${lastVerification.detail ? ` — ${lastVerification.detail}` : ''}`
|
|
215
349
|
: `${bad.length} gate(s) not passing on ${stepKey}`,
|
|
216
350
|
// Sub-lines carry the per-gate verdicts, so one glance separates
|
|
@@ -220,6 +354,10 @@ function buildReport(run, extra = {}) {
|
|
|
220
354
|
+ `${g.command ? ` [${g.command}]` : ''}`),
|
|
221
355
|
how: `hone verify-patch ${run.runId} ${stepKey} --keep`,
|
|
222
356
|
howLabel: 'reproduce',
|
|
357
|
+
// Report before gating (HC-026c): verdicts this report used to swallow are shown in full but do not flip
|
|
358
|
+
// the script exit yet. `unresolved` is not one of them — a report must not stop gating a run on which
|
|
359
|
+
// nothing was run.
|
|
360
|
+
...(failed && !bad.length && !GATING_VERIFICATION_VERDICTS.includes(lastVerification.verdict) ? { advisory: true } : {}),
|
|
223
361
|
});
|
|
224
362
|
}
|
|
225
363
|
}
|
|
@@ -302,6 +440,8 @@ function buildReport(run, extra = {}) {
|
|
|
302
440
|
// HC-GF-065: what the exit code should count. Advisory items are shown but do not
|
|
303
441
|
// gate, so a new signal cannot silently change the meaning of a script's exit status.
|
|
304
442
|
gatingCount: needsYou.filter(i => !i.advisory).length,
|
|
443
|
+
// HC-GF-050: carried so the empty NEEDS YOU can name convergence among what was checked.
|
|
444
|
+
convergence: run.convergence || null,
|
|
305
445
|
};
|
|
306
446
|
}
|
|
307
447
|
|
|
@@ -344,6 +484,7 @@ function renderReport(r) {
|
|
|
344
484
|
checked.push('verification');
|
|
345
485
|
for (const g of last.gates || []) checked.push(g.name);
|
|
346
486
|
}
|
|
487
|
+
if (r.convergence) checked.push('convergence');
|
|
347
488
|
// HC-GF-065: the empty state is only reachable when something was actually CHECKED.
|
|
348
489
|
// With no verdict there is nothing executed against the diff, so "nothing outstanding"
|
|
349
490
|
// asserts knowledge the report does not have — the missing verification is now an
|
|
@@ -384,4 +525,4 @@ function renderReport(r) {
|
|
|
384
525
|
return L.join('\n');
|
|
385
526
|
}
|
|
386
527
|
|
|
387
|
-
module.exports = { buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS };
|
|
528
|
+
module.exports = { BLOCKING_REASON_CODES, buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-106 — the ONE place a `run-story` verb turns its argument into the run it acts on.
|
|
4
|
+
*
|
|
5
|
+
* `--status`, `--kill` and `--approve` each forwarded the argument verbatim (`runId || storyIdOrRunId`),
|
|
6
|
+
* so a story ID reached the server as a run ID and the route's UUID check answered `Run not found` —
|
|
7
|
+
* while `--help` promised "the latest workflow run for this story". Observed 2026-09-11:
|
|
8
|
+
* `hone run-story TICK-002 --kill` printed `Failed: Run not found` and left the paused run untouched.
|
|
9
|
+
* Every verb that acts on an existing run resolves here, so no two of them can disagree about which
|
|
10
|
+
* run that is, and none can fall back to sending the story ID.
|
|
11
|
+
*
|
|
12
|
+
* HC-GF-108 — the lookup asks the server for the story in the repository (`storyId`, `repoName`)
|
|
13
|
+
* instead of reading the org's 100 most recent runs, which reported "not found" for a story whose runs
|
|
14
|
+
* were all older than that. It still filters every row itself: a server that predates the filters
|
|
15
|
+
* ignores them and returns the org's most recent runs, and whatever this returns is posted to.
|
|
16
|
+
*
|
|
17
|
+
* I/O is the injected `client` (the CLI's axios instance). No console, no exit — the caller renders
|
|
18
|
+
* `error` and decides the exit code.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
|
|
23
|
+
// GET /orchestrate clamps `limit` to 1..100, lists newest first, and is scoped to the caller's org.
|
|
24
|
+
// Filtered, its first row IS the story's latest run in the repository. From a server that ignores the
|
|
25
|
+
// filters, the first match in the widest window is the latest run whenever that run is in the window —
|
|
26
|
+
// and when it is not, the lookup says "not found" rather than picking an older one.
|
|
27
|
+
const LOOKUP_LIMIT = 100;
|
|
28
|
+
|
|
29
|
+
const isRunId = (s) => typeof s === 'string' && RUN_ID.test(s);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One GET /orchestrate. `filtered` is true only when the server echoed every filter sent: a server
|
|
33
|
+
* that predates the filters echoes none of them and lists the org's most recent runs instead.
|
|
34
|
+
*/
|
|
35
|
+
async function listRuns(client, filters) {
|
|
36
|
+
const qs = new URLSearchParams({ limit: String(LOOKUP_LIMIT), ...filters });
|
|
37
|
+
const { data } = await client.get(`/orchestrate?${qs}`);
|
|
38
|
+
const runs = Array.isArray(data && data.runs) ? data.runs : [];
|
|
39
|
+
const filtered = Object.entries(filters).every(([k, v]) => Boolean(data) && data[k] === v);
|
|
40
|
+
return { runs, filtered };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} p
|
|
45
|
+
* @param {{ get(path: string): Promise<{ data: any }> }} p.client
|
|
46
|
+
* @param {string} p.idOrStory what the operator typed: a run ID or a story ID
|
|
47
|
+
* @param {string} p.repoName the repository a story ID is scoped to — the name the start path records
|
|
48
|
+
* @param {string} p.flag the verb as typed (e.g. `--kill`, `--approve step_0`), for the hint
|
|
49
|
+
* @returns {Promise<{ runId: string, run: object|null, source: 'run-id'|'story' } | { error: string }>}
|
|
50
|
+
* Rejects only when the lookup request itself fails.
|
|
51
|
+
*/
|
|
52
|
+
async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
|
|
53
|
+
if (isRunId(idOrStory)) return { runId: idOrStory, run: null, source: 'run-id' };
|
|
54
|
+
|
|
55
|
+
const byRunId = `pass a run ID instead: hone run-story <runId> ${flag}`;
|
|
56
|
+
// Nothing to look up: an empty story ID, or a checkout at `/`, whose directory name is empty. Said here,
|
|
57
|
+
// rather than as the server's 400 for an empty filter, which names a query parameter nobody typed.
|
|
58
|
+
if (!idOrStory || !repoName) {
|
|
59
|
+
return {
|
|
60
|
+
error: `Cannot look up a run without a story ID and a repository name (story ${JSON.stringify(idOrStory || '')}, repository ${JSON.stringify(repoName || '')}).\n`
|
|
61
|
+
+ ` Name the repository with --repo <name>, or ${byRunId}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A row for another story, or whose id is not a run ID, is never a target — whichever server answered.
|
|
66
|
+
const forStory = (runs) => runs.filter(r => r && r.story_id === idOrStory && isRunId(r.id));
|
|
67
|
+
|
|
68
|
+
const here = await listRuns(client, { storyId: idOrStory, repoName });
|
|
69
|
+
const run = forStory(here.runs).find(r => r.repo_name === repoName);
|
|
70
|
+
if (run) return { runId: run.id, run, source: 'story' };
|
|
71
|
+
|
|
72
|
+
// Not in this repository — so where does the story have runs? A filtering server needs a story-only
|
|
73
|
+
// lookup to say; one that ignored the filters already returned everything this lookup can see. The
|
|
74
|
+
// answer is "not found" either way, so a failed hint lookup costs the hint, never the answer.
|
|
75
|
+
const wide = here.filtered
|
|
76
|
+
? await listRuns(client, { storyId: idOrStory }).catch(() => ({ runs: [] }))
|
|
77
|
+
: here;
|
|
78
|
+
|
|
79
|
+
const elsewhere = [...new Set(forStory(wide.runs).map(r => r.repo_name))];
|
|
80
|
+
if (elsewhere.length) {
|
|
81
|
+
return {
|
|
82
|
+
error: `No workflow run for story ${idOrStory} in repository ${repoName}; it has runs in: ${elsewhere.join(', ')}.\n`
|
|
83
|
+
+ ` Target that repository with --repo ${elsewhere[0]}, or ${byRunId}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const searched = here.filtered
|
|
87
|
+
? "searched all of your org's runs"
|
|
88
|
+
: here.runs.length < LOOKUP_LIMIT
|
|
89
|
+
? `searched all ${here.runs.length} runs in your org`
|
|
90
|
+
: `searched your org's ${here.runs.length} most recent runs`;
|
|
91
|
+
return {
|
|
92
|
+
error: `No workflow run found for story ${idOrStory} in repository ${repoName} (${searched}).\n`
|
|
93
|
+
+ ` To act on a run by its ID, ${byRunId} (run IDs: hone workflows)`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { resolveRunTarget, isRunId, LOOKUP_LIMIT };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* tree-artifact.js — HC-GF-091: render a step_4 artifact from the WORKING TREE.
|
|
4
|
+
*
|
|
5
|
+
* Editor mode had the same single-response ceiling as the server: `hone submit-step` read one
|
|
6
|
+
* file and `hone_submit_step` took a string, so the editor model still re-typed every changed
|
|
7
|
+
* file into a single response under its own output cap. The agent has already written those
|
|
8
|
+
* files with its own tools; this renders the artifact from what is on disk instead.
|
|
9
|
+
*
|
|
10
|
+
* The output is consumed by `cli/lib/materialize-diff.js`. Its exit modes, enumerated before a
|
|
11
|
+
* line was written (HC-GF-117), and what this module does about each:
|
|
12
|
+
*
|
|
13
|
+
* files[] whole-file blocks — HANDLED: created + modified files
|
|
14
|
+
* deletes[] `DELETE: <path>` — HANDLED: deleted files
|
|
15
|
+
* edits[] region edits — DELIBERATELY NOT: from-tree always sends whole files. The
|
|
16
|
+
* tree is the truth; an anchor-based edit would reintroduce
|
|
17
|
+
* exactly the drift this removes.
|
|
18
|
+
* malformed[] unparsable block — DELIBERATELY NOT REACHABLE: every fence we emit is longer
|
|
19
|
+
* than any fence run inside the file it wraps (see below).
|
|
20
|
+
* noChanges the honest no-op — HANDLED: a clean tree says NO CHANGES rather than nothing.
|
|
21
|
+
*
|
|
22
|
+
* Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path` only.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { execFileSync } = require('node:child_process');
|
|
28
|
+
const { gitEnv } = require('./git-env');
|
|
29
|
+
|
|
30
|
+
/** Bytes above which we do not attempt to inline a file at all. */
|
|
31
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* SC-002-followup-2: git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE to the processes it
|
|
35
|
+
* spawns, and those BEAT `cwd`. Without gitEnv() this renders a different repository's tree when
|
|
36
|
+
* it runs under a git hook — and it would look like a perfectly normal build.
|
|
37
|
+
*/
|
|
38
|
+
function gitOut(repoRoot, args) {
|
|
39
|
+
return execFileSync('git', args, { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A fence wide enough to carry this content.
|
|
44
|
+
*
|
|
45
|
+
* HC-GF-127: the block closes on a line matching its OWN fence, so a file holding a ```js sample
|
|
46
|
+
* needs at least four backticks. Before that fix the format could not carry such a file at all and
|
|
47
|
+
* this module refused it by name; the parsers now take 3-or-more with a matching closer.
|
|
48
|
+
*/
|
|
49
|
+
function fenceFor(content) {
|
|
50
|
+
let longest = 2;
|
|
51
|
+
for (const line of String(content).split('\n')) {
|
|
52
|
+
const m = /^[ \t]*(`{3,})/.exec(line);
|
|
53
|
+
if (m && m[1].length > longest) longest = m[1].length;
|
|
54
|
+
}
|
|
55
|
+
return '`'.repeat(longest + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** NUL in the first 8000 bytes is git's own binary heuristic. */
|
|
59
|
+
function looksBinary(buf) {
|
|
60
|
+
const n = Math.min(buf.length, 8000);
|
|
61
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Paths changed against HEAD, plus untracked files.
|
|
67
|
+
*
|
|
68
|
+
* `git diff --name-status HEAD` alone does NOT see untracked files, so the obvious one-command
|
|
69
|
+
* implementation ships a build missing every file the agent created — the first named mutation
|
|
70
|
+
* target. `ls-files --others --exclude-standard` is the other half, and it is what honours
|
|
71
|
+
* .gitignore: a node_modules SYMLINK is a file to a naive directory walk
|
|
72
|
+
* (HC-COMM-010-followup-8), and git excludes it because the adopter said so.
|
|
73
|
+
*/
|
|
74
|
+
function collectPaths(repoRoot) {
|
|
75
|
+
const changed = [];
|
|
76
|
+
const deleted = [];
|
|
77
|
+
let hasHead = true;
|
|
78
|
+
try { gitOut(repoRoot, ['rev-parse', '--verify', 'HEAD']); } catch { hasHead = false; }
|
|
79
|
+
|
|
80
|
+
if (hasHead) {
|
|
81
|
+
const out = gitOut(repoRoot, ['diff', '--name-status', '--no-renames', 'HEAD']);
|
|
82
|
+
for (const line of out.split('\n')) {
|
|
83
|
+
if (!line.trim()) continue;
|
|
84
|
+
const tab = line.indexOf('\t');
|
|
85
|
+
if (tab === -1) continue;
|
|
86
|
+
const status = line.slice(0, tab).trim();
|
|
87
|
+
const rel = line.slice(tab + 1).trim();
|
|
88
|
+
if (!rel) continue;
|
|
89
|
+
if (status.startsWith('D')) deleted.push(rel);
|
|
90
|
+
else changed.push(rel);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const others = gitOut(repoRoot, ['ls-files', '--others', '--exclude-standard']);
|
|
94
|
+
for (const rel of others.split('\n')) if (rel.trim()) changed.push(rel.trim());
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
changed: [...new Set(changed)].sort(),
|
|
98
|
+
deleted: [...new Set(deleted)].sort(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render the artifact.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} opts
|
|
106
|
+
* @param {string} opts.repoRoot repository to read
|
|
107
|
+
* @param {string} [opts.testReport] the agent's own test-run report, carried verbatim. NEVER
|
|
108
|
+
* invented: an absent report stays absent, because a fabricated
|
|
109
|
+
* "N passing" is the anti-fabrication failure the step_4
|
|
110
|
+
* validator already exists to catch.
|
|
111
|
+
* @returns {{ text: string, files: string[], deletes: string[], skipped: Array<{path,reason}> }}
|
|
112
|
+
*/
|
|
113
|
+
function renderFromTree({ repoRoot, testReport } = {}) {
|
|
114
|
+
if (!repoRoot) throw new Error('renderFromTree: repoRoot is required');
|
|
115
|
+
const { changed, deleted } = collectPaths(repoRoot);
|
|
116
|
+
|
|
117
|
+
const blocks = [];
|
|
118
|
+
const included = [];
|
|
119
|
+
const skipped = [];
|
|
120
|
+
|
|
121
|
+
for (const rel of changed) {
|
|
122
|
+
const abs = path.join(repoRoot, rel);
|
|
123
|
+
let st;
|
|
124
|
+
try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
|
|
125
|
+
// Structural, and deliberately silent: a symlink is not file content. materialize-diff would
|
|
126
|
+
// write a REGULAR file holding the link's target bytes, converting it without saying so. Naming
|
|
127
|
+
// every one of these would also put `node_modules` in the artifact of any repo whose .gitignore
|
|
128
|
+
// says `node_modules/` — a directory pattern that does not match the symlink (HC-COMM-010-followup-8).
|
|
129
|
+
if (st.isSymbolicLink() || !st.isFile()) continue;
|
|
130
|
+
if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
|
|
131
|
+
|
|
132
|
+
const buf = fs.readFileSync(abs);
|
|
133
|
+
if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
|
|
134
|
+
|
|
135
|
+
const content = buf.toString('utf8');
|
|
136
|
+
const fence = fenceFor(content);
|
|
137
|
+
const body = content.endsWith('\n') ? content.slice(0, -1) : content;
|
|
138
|
+
blocks.push(`${fence}file: ${rel}\n${body}\n${fence}`);
|
|
139
|
+
included.push(rel);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = ['## Changed Files', ''];
|
|
143
|
+
|
|
144
|
+
if (!included.length && !deleted.length) {
|
|
145
|
+
// The honest no-op, in the shape the validator and the materialiser both already accept.
|
|
146
|
+
lines.push('NO CHANGES — the working tree matches HEAD.');
|
|
147
|
+
} else {
|
|
148
|
+
for (const b of blocks) { lines.push(b, ''); }
|
|
149
|
+
for (const rel of deleted) lines.push(`DELETE: ${rel}`);
|
|
150
|
+
if (deleted.length) lines.push('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (skipped.length) {
|
|
154
|
+
// Named, not silent: a file that is in the tree and not in the artifact must be visible as a
|
|
155
|
+
// decision. Kept out of the Changed Files blocks so it can never parse as one.
|
|
156
|
+
lines.push('### Not included', '');
|
|
157
|
+
for (const s of skipped) lines.push(`- \`${s.path}\` — ${s.reason}`);
|
|
158
|
+
lines.push('');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (testReport) lines.push('### Test run', '', String(testReport).trim(), '');
|
|
162
|
+
|
|
163
|
+
return { text: lines.join('\n'), files: included, deletes: deleted, skipped };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { renderFromTree, _internal: { fenceFor, looksBinary, collectPaths, MAX_FILE_BYTES } };
|
package/lib/verify-patch.js
CHANGED
|
@@ -25,6 +25,9 @@ const EXIT = {
|
|
|
25
25
|
DOES_NOT_APPLY: 2, // diff did not apply
|
|
26
26
|
TESTS_FAILED: 3, // diff applied, tests failed
|
|
27
27
|
TIMEOUT: 4, // the command exceeded --timeout
|
|
28
|
+
// HC-GF-008: the command itself could not start, so nothing about the patch is known. Not 5 or 6:
|
|
29
|
+
// emit-pr re-exits with these codes, and those two are its BRANCH_CONFLICT and PUBLISH_FAILED.
|
|
30
|
+
UNRESOLVED: 7,
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
/**
|
|
@@ -83,11 +86,81 @@ function resolveCommandDetailed(explicit, configured, opts = {}) {
|
|
|
83
86
|
}
|
|
84
87
|
function resolveCommand(explicit, configured, opts) { return resolveCommandDetailed(explicit, configured, opts).command; }
|
|
85
88
|
|
|
89
|
+
/**
|
|
90
|
+
* HC-GF-008 — did the resolved command itself fail to START?
|
|
91
|
+
*
|
|
92
|
+
* The resolver's last tier is a guess (`make ci`), and a guess that names nothing came back as
|
|
93
|
+
* `tests failed (exit 2)`: the builder blamed for a command that does not exist, on every
|
|
94
|
+
* greenfield repo and every `npm init` manifest. Measured before building, on real processes —
|
|
95
|
+
* GNU make 4.3 + dash and make 3.81 + bash differ only in quote characters.
|
|
96
|
+
*
|
|
97
|
+
* Absence is the RUNNER's own words about a name the command contains:
|
|
98
|
+
* - the shell exits 127 saying it cannot find a program the command names
|
|
99
|
+
* (`sh: uv: command not found`, dash `sh: 7: uv: not found`, `sh: ./ci.sh: No such file or directory`);
|
|
100
|
+
* - top-level make says a goal the command names has no rule (`No rule to make target 'ci'. Stop.`),
|
|
101
|
+
* or that there is no makefile at all.
|
|
102
|
+
* Deliberately NOT absence, because something started and the patch is a plausible cause: a missing
|
|
103
|
+
* prerequisite (`No rule to make target 'test', needed by 'ci'` names `test`, which the command does not),
|
|
104
|
+
* `make[1]:` (a sub-make a recipe ran), a tool missing inside a recipe or an npm script, and a 127 whose
|
|
105
|
+
* not-found line names nothing in the command. The command's own words are the discriminator throughout.
|
|
106
|
+
* Every residual lands on `fail`, which blocks exactly as before — the safe side.
|
|
107
|
+
*
|
|
108
|
+
* @returns {{ missing: string, reason: string } | null}
|
|
109
|
+
*/
|
|
110
|
+
const SHELL_NOT_FOUND_RE = /^[^\s:]*sh(?::\s*line\s+\d+)?:\s*(?:\d+:\s*)?(\S+):\s*(?:command not found|not found|No such file or directory)\s*$/gm;
|
|
111
|
+
const MAKE_NO_RULE_RE = /^make: \*\*\* No rule to make target [`'\u2018]([^`'\u2019]+)['\u2019]/gm;
|
|
112
|
+
const MAKE_NO_MAKEFILE_RE = /^make: \*\*\* No targets specified and no makefile found\./m;
|
|
113
|
+
|
|
114
|
+
function commandAbsence(command, { code, stdout = '', stderr = '' } = {}) {
|
|
115
|
+
const cmd = String(command || '').trim();
|
|
116
|
+
if (!cmd) return null;
|
|
117
|
+
const words = new Set(cmd.split(/[\s;&|()<>]+/).filter(Boolean));
|
|
118
|
+
const out = `${stdout || ''}\n${stderr || ''}`;
|
|
119
|
+
if (code === 127) {
|
|
120
|
+
// `shell: true` runs /bin/sh, so these are sh's own words — bash-as-sh or dash.
|
|
121
|
+
for (const m of out.matchAll(SHELL_NOT_FOUND_RE)) {
|
|
122
|
+
if (words.has(m[1])) return { missing: m[1], reason: `\`${m[1]}\` is not installed or not on PATH` };
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (words.has('make')) {
|
|
127
|
+
for (const m of out.matchAll(MAKE_NO_RULE_RE)) {
|
|
128
|
+
if (words.has(m[1])) return { missing: m[1], reason: `no Makefile target \`${m[1]}\`` };
|
|
129
|
+
}
|
|
130
|
+
if (MAKE_NO_MAKEFILE_RE.test(out)) return { missing: 'Makefile', reason: 'no Makefile' };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* HC-GF-008 — the environment an adopter's verification command runs in.
|
|
137
|
+
*
|
|
138
|
+
* make exports its recursion state to every recipe, so a `hone` verb run from a make target — or from
|
|
139
|
+
* `make ci` itself — hands that state to the command it verifies. Measured on GNU make 3.81 and 4.3:
|
|
140
|
+
* - MAKELEVEL=1 turns the verified make's own `make: *** No rule to make target` into `make[1]: …`,
|
|
141
|
+
* the prefix of a sub-make a recipe ran, so the absent-command check went inert (it passed alone and
|
|
142
|
+
* failed under `make ci`);
|
|
143
|
+
* - MAKEFLAGS=i (ignore errors) and MAKEFLAGS=n (dry run) make a failing `make ci` exit 0, and
|
|
144
|
+
* GNUMAKEFLAGS=i does the same on make 4 — a pass for tests that failed or never ran, which under
|
|
145
|
+
* enforce approves the gate.
|
|
146
|
+
* The verified command is the adopter's own top-level invocation: level 0, no inherited flags. Returns a
|
|
147
|
+
* copy; the caller's environment is untouched.
|
|
148
|
+
*/
|
|
149
|
+
const INHERITED_MAKE_VARS = ['MAKELEVEL', 'MAKEFLAGS', 'GNUMAKEFLAGS'];
|
|
150
|
+
function verifyCommandEnv(base = process.env) {
|
|
151
|
+
const env = { ...base };
|
|
152
|
+
for (const key of INHERITED_MAKE_VARS) delete env[key];
|
|
153
|
+
return env;
|
|
154
|
+
}
|
|
155
|
+
|
|
86
156
|
/**
|
|
87
157
|
* Interpret a completed command run into a condition-6 verdict.
|
|
88
158
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
159
|
+
* Pass the `command` that ran and the resolver's `source` for it: without them an absent command is
|
|
160
|
+
* indistinguishable from a failing one (HC-GF-008), and the CLI's two executors both do.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string, command?: string, source?: string }} run
|
|
163
|
+
* @returns {{ verdict: 'pass'|'fail'|'timeout'|'unresolved', exitCode: number, detail: string }}
|
|
91
164
|
*/
|
|
92
165
|
function verdictFromRun(run) {
|
|
93
166
|
if (run.timedOut) {
|
|
@@ -96,6 +169,18 @@ function verdictFromRun(run) {
|
|
|
96
169
|
if (run.code === 0) {
|
|
97
170
|
return { verdict: 'pass', exitCode: EXIT.PASS, detail: 'tests passed on the applied diff' };
|
|
98
171
|
}
|
|
172
|
+
const absent = commandAbsence(run.command, run);
|
|
173
|
+
if (absent) {
|
|
174
|
+
return {
|
|
175
|
+
verdict: 'unresolved',
|
|
176
|
+
exitCode: EXIT.UNRESOLVED,
|
|
177
|
+
detail: `tests never ran: ${absent.reason}`
|
|
178
|
+
+ (run.source === 'default'
|
|
179
|
+
? ` — no test command is configured or detected, so \`${run.command}\` was a guess; set ci.local_command in .pipeline-config.yml`
|
|
180
|
+
: ''),
|
|
181
|
+
missing: absent.missing,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
99
184
|
const sum = summarizeFailure(run);
|
|
100
185
|
// HC-GF-023: lead with HOW MANY. "1 of 8 named" and "the only failure" are
|
|
101
186
|
// different situations that this line used to render identically.
|
|
@@ -265,5 +350,5 @@ function classifyGateRun(run) {
|
|
|
265
350
|
module.exports = {
|
|
266
351
|
EXIT, resolveCommand, resolveCommandDetailed, DEFAULT_COMMAND,
|
|
267
352
|
AUX_GATES, AUX_DETECT, resolveAuxGates, classifyGateRun,
|
|
268
|
-
verdictFromRun, lastMeaningfulLine, summarizeFailure, FAILURE_LINE_RE,
|
|
353
|
+
verdictFromRun, commandAbsence, verifyCommandEnv, lastMeaningfulLine, summarizeFailure, FAILURE_LINE_RE,
|
|
269
354
|
};
|