@hone-ai/cli 1.32.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.
@@ -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.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {