@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/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
- .match(/^(MAX_RETRIES_EXCEEDED|MAX_STEPS_EXCEEDED|BUDGET_EXCEEDED|GARBAGE_OUTPUT)[^\n]*/);
66
- if (blocked) {
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
- const failed = lastVerification.verdict === 'fail' || lastVerification.verdict === 'timeout';
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 } };
@@ -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
- * @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string }} run
90
- * @returns {{ verdict: 'pass'|'fail'|'timeout', exitCode: number, detail: string }}
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {