@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.
- package/hone-cli.js +123 -67
- package/lib/auto-verify.js +21 -1
- package/lib/emit-pr.js +1 -0
- package/lib/materialize-diff.js +142 -17
- package/lib/mcp-tools.js +28 -7
- package/lib/poll-decision.js +16 -1
- package/lib/run-report.js +111 -6
- package/lib/run-target.js +97 -0
- package/lib/verify-patch.js +88 -3
- package/package.json +1 -1
|
@@ -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 };
|
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
|
};
|