@hone-ai/cli 1.31.0 → 1.33.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 : [];
@@ -48,10 +88,48 @@ function buildReport(run, extra = {}) {
48
88
  // ── NEEDS YOU ──────────────────────────────────────────────────────────────
49
89
  // 1. The run itself is not finished.
50
90
  if (run.status === 'paused' && run.currentStep) {
51
- needsYou.push({
52
- what: `run is paused at ${run.currentStep} — a gate is waiting on your judgment`,
53
- how: `hone run-story ${run.runId} --approve ${run.currentStep}`,
54
- });
91
+ // HC-GF-046: a paused run is not always a gate.
92
+ //
93
+ // Run 3dbde316 sat `paused` at step_4 with `MAX_RETRIES_EXCEEDED: step_4 retried 2
94
+ // times (max 2)`. That LOOKS like an available approval — paused, with a pending gate
95
+ // — but `checkGuardrails` refuses step_4 before the LLM call, so approving can never
96
+ // move the run. The line described the run's SHAPE and not its CAUSE, and so invited
97
+ // the one action the orchestrator had already decided to refuse. A driver looped on
98
+ // it; a human tried once and had to read the database.
99
+ //
100
+ // Matched on the specific messages the guardrail acts on, NOT on "any error_message".
101
+ // A resumable pause — a local rate-limit window, for instance — is not a refusal, and
102
+ // silencing a real gate on those would be the over-reach that makes the surface
103
+ // untrustworthy in the other direction.
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) {
113
+ needsYou.push({
114
+ what: `run CANNOT proceed at ${run.currentStep} — ${blocked[0].slice(0, 160)}. `
115
+ + 'Approving will be refused by the guardrail, so this is a decision, not a gate',
116
+ how: `hone run-story ${run.runId} --kill (or raise guardrails.max_retries_per_step `
117
+ + 'in .pipeline-config.yml and re-run the story)',
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
+ });
127
+ } else {
128
+ needsYou.push({
129
+ what: `run is paused at ${run.currentStep} — a gate is waiting on your judgment`,
130
+ how: `hone run-story ${run.runId} --approve ${run.currentStep}`,
131
+ });
132
+ }
55
133
  }
56
134
  if (run.status === 'failed') {
57
135
  needsYou.push({
@@ -59,6 +137,19 @@ function buildReport(run, extra = {}) {
59
137
  how: `hone show step ${run.runId} ${run.currentStep || '<stepKey>'}`,
60
138
  });
61
139
  }
140
+ // HC-GF-065: a KILLED run needs you too.
141
+ //
142
+ // The predicate above is `status === 'failed'`, which `killed` does not match — so nine
143
+ // terminated runs in the corpus printed `NEEDS YOU (0)` and "nothing outstanding". A run
144
+ // somebody stopped mid-flight has work outstanding by definition, and the digest told
145
+ // the operator it did not. This is HC-GF-044's defect one field over, and its own lesson
146
+ // states the rule: a well-formed "nothing" is only as true as the predicate behind it.
147
+ if (run.status === 'killed') {
148
+ needsYou.push({
149
+ what: `run was KILLED before it finished${run.errorMessage ? `: ${String(run.errorMessage).slice(0, 160)}` : ''}`,
150
+ how: `hone run-story ${run.runId} --status (re-run the story, or read the last step)`,
151
+ });
152
+ }
62
153
 
63
154
  // 1b. HC-GF-044: a STEP can be failed while the RUN says completed.
64
155
  //
@@ -132,6 +223,35 @@ function buildReport(run, extra = {}) {
132
223
  });
133
224
  }
134
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
+
135
255
  // 1d. HC-GF-041: two diagnostics the orchestrator has stamped since HC-019n-followup-41
136
256
  // and -43, which NOTHING has ever read on this path — the run payload carried only
137
257
  // token counts until the `diagnostics` projection was added. HC-GF-016's row specifies
@@ -154,6 +274,18 @@ function buildReport(run, extra = {}) {
154
274
  worthKnowing.push(
155
275
  `${s.stepKey} dropped composed guardrail section(s) on a retry and was accepted (budget spent): ${d.composed_missing.join(', ')}`);
156
276
  }
277
+ // HC-GF-059: this build contains files the attempt did not write. Advisory — they are the previous build's files,
278
+ // unchanged, and whoever approves should read them as that build's content.
279
+ if (d.carried_forward && Array.isArray(d.carried_forward.paths) && d.carried_forward.paths.length) {
280
+ needsYou.push({
281
+ what: `${s.stepKey} attempt ${s.attempt} did not re-emit ${d.carried_forward.paths.length} planned file(s); they were `
282
+ + `carried forward unchanged from attempt ${d.carried_forward.from_attempt} — review them as that build's content`,
283
+ detail: d.carried_forward.paths.map(String),
284
+ how: `hone show step ${run.runId} ${s.stepKey} --attempt ${s.attempt}`,
285
+ howLabel: 'inspect',
286
+ advisory: true,
287
+ });
288
+ }
157
289
  }
158
290
 
159
291
  // 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
@@ -168,12 +300,15 @@ function buildReport(run, extra = {}) {
168
300
  const lastVerification = verifications[verifications.length - 1] || null;
169
301
  if (lastVerification) {
170
302
  const bad = (lastVerification.gates || []).filter(g => ACTIONABLE_GATE_VERDICTS.includes(g.verdict));
171
- const failed = lastVerification.verdict === 'fail' || lastVerification.verdict === 'timeout';
303
+ // HC-GF-008: "not a pass" is the enforcer's rule (verification-refusal `failingVerdict`), so it is the
304
+ // report's. Listing the failing values instead left `does_not_apply`, `error` — and the new `unresolved`,
305
+ // which an absent command used to report as a gating `fail` — rendering "nothing outstanding".
306
+ const failed = !CLEAR_VERIFICATION_VERDICTS.includes(lastVerification.verdict);
172
307
  if (failed || bad.length) {
173
308
  const stepKey = lastVerification.stepKey || 'step_4';
174
309
  needsYou.push({
175
310
  what: failed
176
- ? `verification ${lastVerification.verdict} on ${stepKey}`
311
+ ? `${lastVerification.verdict === 'unresolved' ? 'verification could not run' : `verification ${lastVerification.verdict}`} on ${stepKey}`
177
312
  + `${lastVerification.detail ? ` — ${lastVerification.detail}` : ''}`
178
313
  : `${bad.length} gate(s) not passing on ${stepKey}`,
179
314
  // Sub-lines carry the per-gate verdicts, so one glance separates
@@ -183,10 +318,40 @@ function buildReport(run, extra = {}) {
183
318
  + `${g.command ? ` [${g.command}]` : ''}`),
184
319
  how: `hone verify-patch ${run.runId} ${stepKey} --keep`,
185
320
  howLabel: 'reproduce',
321
+ // Report before gating (HC-026c): verdicts this report used to swallow are shown in full but do not flip
322
+ // the script exit yet. `unresolved` is not one of them — a report must not stop gating a run on which
323
+ // nothing was run.
324
+ ...(failed && !bad.length && !GATING_VERIFICATION_VERDICTS.includes(lastVerification.verdict) ? { advisory: true } : {}),
186
325
  });
187
326
  }
188
327
  }
189
328
 
329
+ // HC-GF-065: NO verdict at all is itself an item, not a footnote under a zero.
330
+ //
331
+ // Measured over the corpus with this very function: 10 of 21 terminal runs printed
332
+ // "nothing outstanding", and the sentence is false in every one of them. Nothing was
333
+ // executed against the diff, so the report cannot know whether anything is outstanding
334
+ // — the two reviewing steps READ, they do not run anything, which is exactly why
335
+ // HC-GF-064's verdict exists.
336
+ //
337
+ // Scoped to terminal runs on purpose. A run still in flight has not reached the point
338
+ // where a verdict is expected, and flagging it would make the section noise on every
339
+ // poll — which is how the old nag reached 5-of-22 and got ignored.
340
+ if (['completed', 'failed', 'killed'].includes(run.status) && !lastVerification) {
341
+ needsYou.push({
342
+ what: 'verification never ran for this run — no test, lint or typecheck result was '
343
+ + 'reported, so nothing here is known to build',
344
+ how: `hone verify-patch ${run.runId} step_4 --report`,
345
+ // HC-GF-065: ADVISORY. `hone run-report` exits 1 when needsYou is non-empty, and
346
+ // the comment at that exit says why — "so run-report is usable as a GATE in a
347
+ // script". This item fires on every unverified terminal run (15 of 21 in the
348
+ // corpus, most of them otherwise healthy), so gating on it would turn an adopter's
349
+ // passing CI red overnight. HC-026c is precisely this rule: a new signal REPORTS
350
+ // before it GATES. Visible in the report, absent from the exit code.
351
+ advisory: true,
352
+ });
353
+ }
354
+
190
355
  // ── WORTH KNOWING ──────────────────────────────────────────────────────────
191
356
  // Retries: the step ran more than once. HC-GF-041(c): this used to append "(its
192
357
  // first output was rejected)", which the report never checked and which is often
@@ -236,6 +401,11 @@ function buildReport(run, extra = {}) {
236
401
  worthKnowing,
237
402
  steps,
238
403
  verifications,
404
+ // HC-GF-065: what the exit code should count. Advisory items are shown but do not
405
+ // gate, so a new signal cannot silently change the meaning of a script's exit status.
406
+ gatingCount: needsYou.filter(i => !i.advisory).length,
407
+ // HC-GF-050: carried so the empty NEEDS YOU can name convergence among what was checked.
408
+ convergence: run.convergence || null,
239
409
  };
240
410
  }
241
411
 
@@ -278,11 +448,14 @@ function renderReport(r) {
278
448
  checked.push('verification');
279
449
  for (const g of last.gates || []) checked.push(g.name);
280
450
  }
451
+ if (r.convergence) checked.push('convergence');
452
+ // HC-GF-065: the empty state is only reachable when something was actually CHECKED.
453
+ // With no verdict there is nothing executed against the diff, so "nothing outstanding"
454
+ // asserts knowledge the report does not have — the missing verification is now an
455
+ // ITEM above rather than a footnote under a zero, and this branch is unreachable in
456
+ // that case. Kept as a genuine all-clear for a run that WAS verified.
281
457
  L.push('NEEDS YOU (0)');
282
- L.push(checked.length
283
- ? ` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`
284
- : ' nothing outstanding, but NO verification was reported for this run — '
285
- + 'run `hone verify-patch <runId> step_4 --report` before trusting that');
458
+ L.push(` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`);
286
459
  }
287
460
  L.push('');
288
461
 
@@ -316,4 +489,4 @@ function renderReport(r) {
316
489
  return L.join('\n');
317
490
  }
318
491
 
319
- module.exports = { buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS };
492
+ 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 };
@@ -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.31.0",
3
+ "version": "1.33.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {