@hone-ai/cli 1.28.0 → 1.29.1
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/README.md +90 -0
- package/hone-cli.js +223 -5
- package/lib/doctor-local-run.js +183 -0
- package/lib/doctor-source-dirs.js +78 -0
- package/lib/layout-detect.js +145 -0
- package/lib/learnings-parse.js +64 -0
- package/lib/materialize-diff.js +46 -3
- package/lib/pipeline-config.js +71 -8
- package/lib/repo-tree.js +16 -1
- package/lib/run-report.js +251 -0
- package/lib/verify-patch.js +157 -4
- package/lib/worktree-provision.js +71 -2
- package/package.json +5 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* run-report.js — HC-GF-016. What happened, and what still needs you.
|
|
4
|
+
*
|
|
5
|
+
* WHY: after a run finishes there was no command that said how it went. Judging
|
|
6
|
+
* one took five separate hand-checks, and the owner's actual complaint is
|
|
7
|
+
* sharper than that — reading every agent's prose LOSES THE ACTION ITEMS.
|
|
8
|
+
*
|
|
9
|
+
* THE LOAD-BEARING CONSTRAINT: every line here is DERIVED from stored data.
|
|
10
|
+
* Nothing is narrated by an agent. A step that WROTE this summary would
|
|
11
|
+
* confidently assert things — the same defect HC-GF-026 measured twice, one
|
|
12
|
+
* layer up — and a digest that lies is worse than no digest, because it is the
|
|
13
|
+
* thing you read INSTEAD OF the detail.
|
|
14
|
+
*
|
|
15
|
+
* Pure: takes the `GET /orchestrate/:runId` projection, returns a structure.
|
|
16
|
+
* Rendering is separate so both are testable without a server.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
|
|
20
|
+
const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
|
|
21
|
+
|
|
22
|
+
function pad(s, n) { return String(s).padEnd(n); }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wall-clock from start to terminal state. This INCLUDES time parked at a human
|
|
26
|
+
* gate, which is usually most of it — TICK-001 reads 176m for ~12m of pipeline
|
|
27
|
+
* work because it waited on an operator. Rendered as "elapsed" rather than bare,
|
|
28
|
+
* so it is not mistaken for compute time next to a token count.
|
|
29
|
+
*/
|
|
30
|
+
function durationOf(startedAt, endedAt) {
|
|
31
|
+
const a = Date.parse(startedAt || ''), b = Date.parse(endedAt || '');
|
|
32
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || b < a) return null;
|
|
33
|
+
const secs = Math.round((b - a) / 1000);
|
|
34
|
+
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${String(secs % 60).padStart(2, '0')}s`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} run the GET /:runId projection
|
|
39
|
+
* @param {{cost?: {metered:number, notional:number}}} [extra]
|
|
40
|
+
*/
|
|
41
|
+
function buildReport(run, extra = {}) {
|
|
42
|
+
const cfg = run.config || {};
|
|
43
|
+
const steps = Array.isArray(run.steps) ? run.steps : [];
|
|
44
|
+
const verifications = Array.isArray(run.verifications) ? run.verifications : [];
|
|
45
|
+
const needsYou = [];
|
|
46
|
+
const worthKnowing = [];
|
|
47
|
+
|
|
48
|
+
// ── NEEDS YOU ──────────────────────────────────────────────────────────────
|
|
49
|
+
// 1. The run itself is not finished.
|
|
50
|
+
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
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (run.status === 'failed') {
|
|
57
|
+
needsYou.push({
|
|
58
|
+
what: `run FAILED${run.errorMessage ? `: ${String(run.errorMessage).slice(0, 160)}` : ''}`,
|
|
59
|
+
how: `hone show step ${run.runId} ${run.currentStep || '<stepKey>'}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 1b. HC-GF-044: a STEP can be failed while the RUN says completed.
|
|
64
|
+
//
|
|
65
|
+
// Everything above keys on `run.status`. Nothing read `steps[].status`, so a run
|
|
66
|
+
// reported `completed` that contained a `failed` step produced ZERO items and the
|
|
67
|
+
// empty-state branch then printed the affirmative "nothing outstanding". Observed on
|
|
68
|
+
// run 08c27392: `Status: completed` / `Error: MAX_RETRIES_EXCEEDED: step_5 retried 2
|
|
69
|
+
// times` / `✗ step_5 (code-reviewer) failed`, and then `NEEDS YOU (0)`. The STEPS block
|
|
70
|
+
// below rendered the failure correctly, so the data was present and read — only the
|
|
71
|
+
// actionable layer missed it.
|
|
72
|
+
//
|
|
73
|
+
// This is the exact class HC-GF-016 exists to prevent, and the sharp part is that its
|
|
74
|
+
// empty-state rule was HONOURED: the section rendered and named what it checked. It
|
|
75
|
+
// checked the wrong field. A well-formed "nothing" is only as true as its predicate.
|
|
76
|
+
//
|
|
77
|
+
// DELIBERATELY INDEPENDENT OF HC-GF-043. That story stops the orchestrator writing
|
|
78
|
+
// `completed` over a failed step, which would make THIS run fire here via the branch
|
|
79
|
+
// above. It would not help a `completed` run whose step failed for any other reason, or
|
|
80
|
+
// a report built from a run recorded before that fix shipped. Both are needed; neither
|
|
81
|
+
// substitutes for the other.
|
|
82
|
+
//
|
|
83
|
+
// Keyed on the step's OWN state, and `rejected` counts alongside `failed`: a rejected
|
|
84
|
+
// gate is a human decision awaiting follow-up, not a null result.
|
|
85
|
+
const brokenSteps = steps.filter(s => s.status === 'failed' || s.gateResult === 'rejected');
|
|
86
|
+
for (const s of brokenSteps) {
|
|
87
|
+
// Skip the one the run-level item already names, so one problem is one entry.
|
|
88
|
+
if (run.status === 'failed' && s.stepKey === run.currentStep) continue;
|
|
89
|
+
needsYou.push({
|
|
90
|
+
what: `${s.stepKey} (${s.agent || 'agent'}) ${s.status === 'failed' ? 'FAILED' : 'was REJECTED'}`
|
|
91
|
+
+ ` — the run is recorded ${run.status}, so nothing downstream will revisit it`,
|
|
92
|
+
how: `hone show step ${run.runId} ${s.stepKey}`,
|
|
93
|
+
howLabel: 'inspect',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
|
|
98
|
+
// Listing "verification failed" and "lint failed" separately gave two entries
|
|
99
|
+
// for one problem resolved by one fix — and NEEDS YOU is a list of DISTINCT
|
|
100
|
+
// ACTIONS, not of observations. Grouping dedupes structurally rather than by
|
|
101
|
+
// string-matching a gate name inside a failure message, which would be a
|
|
102
|
+
// heuristic that quietly mis-fires.
|
|
103
|
+
//
|
|
104
|
+
// `fail` and `unavailable` are both actionable and mean different things:
|
|
105
|
+
// one found problems, the other never ran. Never collapse them.
|
|
106
|
+
const lastVerification = verifications[verifications.length - 1] || null;
|
|
107
|
+
if (lastVerification) {
|
|
108
|
+
const bad = (lastVerification.gates || []).filter(g => ACTIONABLE_GATE_VERDICTS.includes(g.verdict));
|
|
109
|
+
const failed = lastVerification.verdict === 'fail' || lastVerification.verdict === 'timeout';
|
|
110
|
+
if (failed || bad.length) {
|
|
111
|
+
const stepKey = lastVerification.stepKey || 'step_4';
|
|
112
|
+
needsYou.push({
|
|
113
|
+
what: failed
|
|
114
|
+
? `verification ${lastVerification.verdict} on ${stepKey}`
|
|
115
|
+
+ `${lastVerification.detail ? ` — ${lastVerification.detail}` : ''}`
|
|
116
|
+
: `${bad.length} gate(s) not passing on ${stepKey}`,
|
|
117
|
+
// Sub-lines carry the per-gate verdicts, so one glance separates
|
|
118
|
+
// "the tests broke" from "the linter never ran".
|
|
119
|
+
detail: (lastVerification.gates || []).map(
|
|
120
|
+
g => `${g.name}: ${g.verdict}${g.detail ? ` — ${g.detail}` : ''}`
|
|
121
|
+
+ `${g.command ? ` [${g.command}]` : ''}`),
|
|
122
|
+
how: `hone verify-patch ${run.runId} ${stepKey} --keep`,
|
|
123
|
+
howLabel: 'reproduce',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── WORTH KNOWING ──────────────────────────────────────────────────────────
|
|
129
|
+
// Retries: the step produced something the pipeline rejected at least once.
|
|
130
|
+
for (const s of steps) {
|
|
131
|
+
if (Number(s.attempts) > 1) {
|
|
132
|
+
worthKnowing.push(`${s.stepKey} retried — ${s.attempts} attempts (its first output was rejected)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// The two "blind run" tells, both from HC-GF-006's preflight reasoning.
|
|
136
|
+
const descChars = typeof cfg.story_description === 'string' ? cfg.story_description.length : 0;
|
|
137
|
+
if (descChars === 0) {
|
|
138
|
+
worthKnowing.push('story_description was EMPTY — step_0 groomed a story it never received');
|
|
139
|
+
}
|
|
140
|
+
const bundled = Array.isArray(cfg.codebaseFiles) ? cfg.codebaseFiles.length : 0;
|
|
141
|
+
if (bundled === 0) {
|
|
142
|
+
worthKnowing.push('no codebase files bundled — step_1/step_4 ran without source context');
|
|
143
|
+
}
|
|
144
|
+
// Skipped steps are a decision, not an absence; say which and stay silent on why
|
|
145
|
+
// (the reason lives in the step output, and inferring it here would be narration).
|
|
146
|
+
const skipped = steps.filter(s => s.status === 'skipped').map(s => s.stepKey);
|
|
147
|
+
if (skipped.length) worthKnowing.push(`skipped: ${skipped.join(', ')}`);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
runId: run.runId,
|
|
151
|
+
storyId: run.storyId,
|
|
152
|
+
status: run.status,
|
|
153
|
+
mode: cfg.execution_mode || run.mode || null,
|
|
154
|
+
stepsRun: steps.filter(s => s.status === 'completed').length,
|
|
155
|
+
stepsTotal: steps.length,
|
|
156
|
+
totalTokens: Number(run.totalTokens) || 0,
|
|
157
|
+
// startedAt/completedAt, never createdAt/updatedAt: the latter moves on any
|
|
158
|
+
// later write (a --report verdict, an approve) and reported 433m for a
|
|
159
|
+
// 12-minute run. Null when the run has not finished — an absent duration is
|
|
160
|
+
// honest; a wrong one is not.
|
|
161
|
+
duration: durationOf(run.startedAt, run.completedAt),
|
|
162
|
+
cost: extra.cost || null,
|
|
163
|
+
architectConsulted: cfg.architect_consulted === true,
|
|
164
|
+
storyDescriptionChars: descChars,
|
|
165
|
+
bundledFiles: bundled,
|
|
166
|
+
classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes : [],
|
|
167
|
+
needsYou,
|
|
168
|
+
worthKnowing,
|
|
169
|
+
steps,
|
|
170
|
+
verifications,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Render to terminal text.
|
|
176
|
+
*
|
|
177
|
+
* EMPTY-STATE RULE, applied ASYMMETRICALLY and on purpose:
|
|
178
|
+
* NEEDS YOU with nothing to report must SAY SO and name what it checked — an
|
|
179
|
+
* omitted section is indistinguishable from an unchecked one, which is this
|
|
180
|
+
* repo's dominant defect class (HC-GF-005, HC-GF-012, HC-GF-022, HC-GF-030).
|
|
181
|
+
* WORTH KNOWING may be omitted silently: nothing actionable is lost.
|
|
182
|
+
*/
|
|
183
|
+
function renderReport(r) {
|
|
184
|
+
const L = [];
|
|
185
|
+
const cost = r.cost
|
|
186
|
+
? `$${r.cost.metered.toFixed(2)} metered${r.cost.notional != null ? ` ($${r.cost.notional.toFixed(2)} notional)` : ''}`
|
|
187
|
+
: null;
|
|
188
|
+
L.push([`${r.storyId || r.runId}`, r.status,
|
|
189
|
+
r.stepsTotal && r.stepsRun !== r.stepsTotal
|
|
190
|
+
? `${r.stepsRun} of ${r.stepsTotal} steps` : `${r.stepsRun} steps`,
|
|
191
|
+
`${r.totalTokens.toLocaleString()} tokens`, cost,
|
|
192
|
+
r.duration ? `${r.duration} elapsed` : null]
|
|
193
|
+
.filter(Boolean).join(' · '));
|
|
194
|
+
L.push('');
|
|
195
|
+
|
|
196
|
+
if (r.needsYou.length) {
|
|
197
|
+
L.push(`NEEDS YOU (${r.needsYou.length})`);
|
|
198
|
+
r.needsYou.forEach((n, i) => {
|
|
199
|
+
L.push(` ${i + 1}. ${n.what}`);
|
|
200
|
+
for (const d of n.detail || []) L.push(` ${d}`);
|
|
201
|
+
// Label the command when it REPRODUCES rather than resolves — an unlabelled
|
|
202
|
+
// arrow reads as "run this to fix it", which `ruff check .` does not do.
|
|
203
|
+
if (n.how) L.push(` → ${n.howLabel ? `${n.howLabel}: ` : ''}${n.how}`);
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
// Name what was checked, so "nothing" is a finding rather than a silence.
|
|
207
|
+
const checked = [];
|
|
208
|
+
const last = r.verifications[r.verifications.length - 1];
|
|
209
|
+
if (last) {
|
|
210
|
+
checked.push('verification');
|
|
211
|
+
for (const g of last.gates || []) checked.push(g.name);
|
|
212
|
+
}
|
|
213
|
+
L.push('NEEDS YOU (0)');
|
|
214
|
+
L.push(checked.length
|
|
215
|
+
? ` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`
|
|
216
|
+
: ' nothing outstanding, but NO verification was reported for this run — '
|
|
217
|
+
+ 'run `hone verify-patch <runId> step_4 --report` before trusting that');
|
|
218
|
+
}
|
|
219
|
+
L.push('');
|
|
220
|
+
|
|
221
|
+
if (r.worthKnowing.length) {
|
|
222
|
+
L.push(`WORTH KNOWING (${r.worthKnowing.length})`);
|
|
223
|
+
for (const w of r.worthKnowing) L.push(` · ${w}`);
|
|
224
|
+
L.push('');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
L.push('STEPS');
|
|
228
|
+
for (const s of r.steps) {
|
|
229
|
+
const mark = s.status === 'completed' ? '✓' : s.status === 'skipped' ? '○' : '·';
|
|
230
|
+
const retry = Number(s.attempts) > 1 ? ` [${s.attempts} attempts]` : '';
|
|
231
|
+
L.push(` ${mark} ${pad(s.stepKey, 8)} ${pad(s.agent || '', 24)} ${s.status}`
|
|
232
|
+
+ `${s.gateResult ? ` (${s.gateResult})` : ''}${retry}`);
|
|
233
|
+
}
|
|
234
|
+
L.push('');
|
|
235
|
+
|
|
236
|
+
L.push('RUN FACTS');
|
|
237
|
+
L.push(` mode ${r.mode || 'unknown'} · story text ${r.storyDescriptionChars} chars · `
|
|
238
|
+
+ `${r.bundledFiles} file(s) bundled · architect_consulted ${r.architectConsulted}`);
|
|
239
|
+
if (r.classifierAxes.length) L.push(` axes: ${r.classifierAxes.join(', ')}`);
|
|
240
|
+
if (r.verifications.length) {
|
|
241
|
+
for (const v of r.verifications) {
|
|
242
|
+
const gates = (v.gates || []).map(g => `${g.name}=${g.verdict}`).join(' · ');
|
|
243
|
+
L.push(` verify(${v.stepKey || '?'}): ${v.verdict}${gates ? ` · ${gates}` : ''}`);
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
L.push(' verify: none reported');
|
|
247
|
+
}
|
|
248
|
+
return L.join('\n');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS };
|
package/lib/verify-patch.js
CHANGED
|
@@ -96,21 +96,174 @@ function verdictFromRun(run) {
|
|
|
96
96
|
if (run.code === 0) {
|
|
97
97
|
return { verdict: 'pass', exitCode: EXIT.PASS, detail: 'tests passed on the applied diff' };
|
|
98
98
|
}
|
|
99
|
+
const sum = summarizeFailure(run);
|
|
100
|
+
// HC-GF-023: lead with HOW MANY. "1 of 8 named" and "the only failure" are
|
|
101
|
+
// different situations that this line used to render identically.
|
|
102
|
+
const head = `tests failed (exit ${run.code})`
|
|
103
|
+
+ (sum.count > 1 ? ` — ${sum.count} failure lines` : '');
|
|
99
104
|
return {
|
|
100
105
|
verdict: 'fail',
|
|
101
106
|
exitCode: EXIT.TESTS_FAILED,
|
|
102
|
-
detail:
|
|
107
|
+
detail: head + (sum.first ? `, first: ${sum.first}` : ''),
|
|
108
|
+
failure: sum,
|
|
103
109
|
};
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
/**
|
|
112
|
+
/** Lines that look like a runner reporting a failure. Deliberately broad — this
|
|
113
|
+
* counts EVIDENCE of failure across pytest/node/jest/go/cargo, not test cases. */
|
|
114
|
+
const FAILURE_LINE_RE = /(^|[\s│|])(FAILED|FAIL|ERROR|ERR!|not ok|✗|✖|×)\b|\w+Error\b|panic:|AssertionError/;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* HC-GF-023 — summarise a failed run instead of quoting one line of it.
|
|
118
|
+
*
|
|
119
|
+
* The old `lastMeaningfulLine` scanned BACKWARDS for anything matching
|
|
120
|
+
* /fail|error|…/ and returned it alone. On run 74fc6519 pytest reported EIGHT
|
|
121
|
+
* failures caused by a single SyntaxError; this returned the LAST line of the
|
|
122
|
+
* short summary — one unrelated-looking test name, no count, and no cause. It
|
|
123
|
+
* pointed away from the defect, and diagnosing it took `--keep` plus a manual
|
|
124
|
+
* re-run. A one-line quote of a multi-failure run is not a summary; it is a
|
|
125
|
+
* sample presented as a conclusion.
|
|
126
|
+
*
|
|
127
|
+
* Pure: returns the count, the FIRST failure (usually nearer the cause than the
|
|
128
|
+
* last) and a tail excerpt. Writing the full log is IO and stays in the CLI.
|
|
129
|
+
*
|
|
130
|
+
* @returns {{ count: number, first: string, excerpt: string[] }}
|
|
131
|
+
*/
|
|
132
|
+
function summarizeFailure({ stdout = '', stderr = '' }, { maxLines = 12 } = {}) {
|
|
133
|
+
const lines = `${stdout}\n${stderr}`.split('\n').map(l => l.replace(/\s+$/, ''));
|
|
134
|
+
const nonEmpty = lines.filter(l => l.trim());
|
|
135
|
+
const failing = nonEmpty.filter(l => FAILURE_LINE_RE.test(l));
|
|
136
|
+
return {
|
|
137
|
+
count: failing.length,
|
|
138
|
+
first: (failing[0] || nonEmpty[nonEmpty.length - 1] || '').trim().slice(0, 200),
|
|
139
|
+
excerpt: nonEmpty.slice(-maxLines).map(l => l.slice(0, 300)),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The most useful one-liner from a failed run's output. Retained for callers
|
|
144
|
+
* that want a single string; verdictFromRun no longer relies on it alone. */
|
|
107
145
|
function lastMeaningfulLine({ stdout = '', stderr = '' }) {
|
|
108
146
|
const combined = `${stdout}\n${stderr}`.split('\n')
|
|
109
147
|
.map(l => l.trim())
|
|
110
148
|
.filter(Boolean);
|
|
111
|
-
// Prefer a line that names a failure; else the last non-empty line.
|
|
112
149
|
const failLine = combined.reverse().find(l => /fail|error|✗|not ok|assert/i.test(l));
|
|
113
150
|
return (failLine || combined[0] || '').slice(0, 200);
|
|
114
151
|
}
|
|
115
152
|
|
|
116
|
-
|
|
153
|
+
/**
|
|
154
|
+
* ── HC-GF-026: the gates nobody was running ──────────────────────────────────
|
|
155
|
+
*
|
|
156
|
+
* On run 74fc6519 step_4 emitted this, verbatim, in its artifact:
|
|
157
|
+
*
|
|
158
|
+
* $ ruff check flowforge tests
|
|
159
|
+
* All checks passed!
|
|
160
|
+
* $ mypy flowforge
|
|
161
|
+
* Success: no issues found in 3 source files
|
|
162
|
+
*
|
|
163
|
+
* It ran neither. step_4 is a server-side LLM call with NO execution surface.
|
|
164
|
+
* Measured against the emitted branch: six real violations of the repo's own
|
|
165
|
+
* `pyproject.toml` rules. step_5 then read that artifact and reported
|
|
166
|
+
* `STANDARDS: Clean — ruff+mypy green`, so a fabrication became a reviewed fact
|
|
167
|
+
* without one new piece of evidence entering the chain.
|
|
168
|
+
*
|
|
169
|
+
* The one TRUE line in that block — `8 passed` — was the only claim the pipeline
|
|
170
|
+
* independently executes. That is the whole lesson: the cure for a confidently
|
|
171
|
+
* invented gate result is not prompt wording telling the model to stop, it is
|
|
172
|
+
* RUNNING the gate so there is a verdict to compare against. Ground truth beats
|
|
173
|
+
* instruction ([[feedback_placeholder_vs_hallucination_cascades]]).
|
|
174
|
+
*
|
|
175
|
+
* These gates are REPORTED, not blocking. A lint failure must not veto a build
|
|
176
|
+
* that passes its tests — that would change the meaning of verify-patch's exit
|
|
177
|
+
* code for every existing adopter, and style is not correctness. What it must do
|
|
178
|
+
* is exist, be measured, and be visible to the operator and the server.
|
|
179
|
+
*/
|
|
180
|
+
|
|
181
|
+
/** Config key → the gate's name, in the order they run. */
|
|
182
|
+
const AUX_GATES = [
|
|
183
|
+
{ name: 'lint', configKey: 'lint_command' },
|
|
184
|
+
{ name: 'typecheck', configKey: 'typecheck_command' },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Detection tiers, deliberately conservative: a gate is only inferred when the
|
|
189
|
+
* repo DECLARES the tool in its own config. We never guess a linter a repo has
|
|
190
|
+
* not adopted, and every inferred command fails CLOSED to `unavailable` when the
|
|
191
|
+
* binary is absent (`npx --no-install` rather than `npx`, which would download).
|
|
192
|
+
*/
|
|
193
|
+
const AUX_DETECT = {
|
|
194
|
+
lint: [
|
|
195
|
+
{ file: 'pyproject.toml', contains: '[tool.ruff]', command: 'ruff check .', source: 'pyproject [tool.ruff]' },
|
|
196
|
+
{ file: 'package.json', script: 'lint', command: 'npm run --silent lint', source: 'package.json lint script' },
|
|
197
|
+
],
|
|
198
|
+
typecheck: [
|
|
199
|
+
{ file: 'pyproject.toml', contains: '[tool.mypy]', command: 'mypy', source: 'pyproject [tool.mypy]' },
|
|
200
|
+
{ file: 'package.json', script: 'typecheck', command: 'npm run --silent typecheck', source: 'package.json typecheck script' },
|
|
201
|
+
{ file: 'tsconfig.json', command: 'npx --no-install tsc --noEmit', source: 'tsconfig.json' },
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolve the auxiliary gates for a repo. Pure — filesystem access is injected.
|
|
207
|
+
* @returns {Array<{ name, command, source }>} only gates that resolved
|
|
208
|
+
*/
|
|
209
|
+
function resolveAuxGates({ configured = {}, cwd = process.cwd(), fsMod = require('node:fs') } = {}) {
|
|
210
|
+
const path = require('node:path');
|
|
211
|
+
const read = (f) => { try { return fsMod.readFileSync(path.join(cwd, f), 'utf8'); } catch { return null; } };
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const { name, configKey } of AUX_GATES) {
|
|
214
|
+
const explicit = configured[configKey];
|
|
215
|
+
if (typeof explicit === 'string' && explicit.trim()) {
|
|
216
|
+
out.push({ name, command: explicit.trim(), source: `ci.${configKey}` });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
for (const tier of AUX_DETECT[name]) {
|
|
220
|
+
const body = read(tier.file);
|
|
221
|
+
if (body === null) continue;
|
|
222
|
+
if (tier.contains && !body.includes(tier.contains)) continue;
|
|
223
|
+
if (tier.script) {
|
|
224
|
+
let scripts = null;
|
|
225
|
+
try { scripts = (JSON.parse(body) || {}).scripts || null; } catch { continue; }
|
|
226
|
+
if (!scripts || typeof scripts[tier.script] !== 'string') continue;
|
|
227
|
+
}
|
|
228
|
+
out.push({ name, command: tier.command, source: tier.source });
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Classify an aux gate run. The three-way split is the point: `unavailable` must
|
|
237
|
+
* never read as `fail`, or a missing linter looks like a broken patch — the exact
|
|
238
|
+
* confusion HC-GF-008 exists to prevent one layer up.
|
|
239
|
+
* @returns {{ verdict: 'pass'|'fail'|'unavailable', detail: string }}
|
|
240
|
+
*/
|
|
241
|
+
function classifyGateRun(run) {
|
|
242
|
+
const text = `${run.stdout || ''}\n${run.stderr || ''}`;
|
|
243
|
+
const missing = run.code === 127
|
|
244
|
+
|| (run.error && run.error.code === 'ENOENT')
|
|
245
|
+
|| /command not found|: not found|No such file or directory|is not recognized/i.test(text);
|
|
246
|
+
if (missing) {
|
|
247
|
+
return { verdict: 'unavailable', detail: 'command not found — gate NOT run' };
|
|
248
|
+
}
|
|
249
|
+
if (run.timedOut) return { verdict: 'unavailable', detail: 'timed out — gate NOT run to completion' };
|
|
250
|
+
if (run.code === 0) return { verdict: 'pass', detail: 'clean' };
|
|
251
|
+
const sum = summarizeFailure(run, { maxLines: 6 });
|
|
252
|
+
// A linter prints its findings from the TOP, so unlike a test runner the most
|
|
253
|
+
// useful line is the first, not the last. summarizeFailure's fallback is the
|
|
254
|
+
// last line (right for a runner's summary block), so pick the head here.
|
|
255
|
+
const firstOut = text.split('\n').map(l => l.trim()).find(Boolean) || '';
|
|
256
|
+
const lead = sum.count ? sum.first : firstOut;
|
|
257
|
+
return {
|
|
258
|
+
verdict: 'fail',
|
|
259
|
+
detail: (sum.count ? `${sum.count} finding${sum.count === 1 ? '' : 's'}` : `exit ${run.code}`)
|
|
260
|
+
+ (lead ? ` — ${lead.slice(0, 160)}` : ''),
|
|
261
|
+
excerpt: sum.excerpt,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
EXIT, resolveCommand, resolveCommandDetailed, DEFAULT_COMMAND,
|
|
267
|
+
AUX_GATES, AUX_DETECT, resolveAuxGates, classifyGateRun,
|
|
268
|
+
verdictFromRun, lastMeaningfulLine, summarizeFailure, FAILURE_LINE_RE,
|
|
269
|
+
};
|
|
@@ -20,6 +20,48 @@ const LOCKFILES = [
|
|
|
20
20
|
['pnpm-lock.yaml', 'pnpm install --frozen-lockfile'],
|
|
21
21
|
['yarn.lock', 'yarn install --frozen-lockfile'],
|
|
22
22
|
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HC-GF-024: manifests we can RECOGNISE but deliberately do not install.
|
|
26
|
+
*
|
|
27
|
+
* Observed on FlowForge (a Python repo with a root `pyproject.toml` declaring
|
|
28
|
+
* `dependencies = ["pydantic>=2.7"]`): the provisioner printed
|
|
29
|
+
* `skipped (no dependency manifest detected)` — the manifest was right there.
|
|
30
|
+
* It happened not to matter because that story's code was stdlib-only, but the
|
|
31
|
+
* next story with a real import would have failed `ModuleNotFoundError` and been
|
|
32
|
+
* reported as a FAILING TEST, i.e. as the code-builder's fault.
|
|
33
|
+
*
|
|
34
|
+
* We stop short of running these. `npm ci` writes into the worktree's own
|
|
35
|
+
* `node_modules` and is contained; `pip install -r requirements.txt` with no
|
|
36
|
+
* active virtualenv writes into the operator's GLOBAL site-packages — a side
|
|
37
|
+
* effect outside the worktree that "isolated worktree" promises not to have.
|
|
38
|
+
* Guessing a venv here would also silently diverge from whatever the adopter's
|
|
39
|
+
* real CI does. So: name the manifest, say we did not provision, and point at
|
|
40
|
+
* `ci.install_command`, which already wins over every other tier.
|
|
41
|
+
*/
|
|
42
|
+
const RECOGNISED_MANIFESTS = [
|
|
43
|
+
['uv.lock', 'python'],
|
|
44
|
+
['poetry.lock', 'python'],
|
|
45
|
+
['Pipfile.lock', 'python'],
|
|
46
|
+
['requirements.txt', 'python'],
|
|
47
|
+
['pyproject.toml', 'python'],
|
|
48
|
+
['setup.cfg', 'python'],
|
|
49
|
+
['go.sum', 'go'],
|
|
50
|
+
['go.mod', 'go'],
|
|
51
|
+
['Cargo.lock', 'rust'],
|
|
52
|
+
['Cargo.toml', 'rust'],
|
|
53
|
+
['Gemfile.lock', 'ruby'],
|
|
54
|
+
['pom.xml', 'java'],
|
|
55
|
+
['build.gradle', 'java'],
|
|
56
|
+
['build.gradle.kts', 'java'],
|
|
57
|
+
['composer.lock', 'php'],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Every filename planProvision looks for, so "not detected" is falsifiable. */
|
|
61
|
+
const ALL_MANIFEST_NAMES = [
|
|
62
|
+
...LOCKFILES.map(([f]) => f), 'package.json', ...RECOGNISED_MANIFESTS.map(([f]) => f),
|
|
63
|
+
];
|
|
64
|
+
|
|
23
65
|
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
66
|
|
|
25
67
|
function planProvision({ worktreeDir, parentDir, installCommand = null, fsMod = fs }) {
|
|
@@ -40,12 +82,36 @@ function planProvision({ worktreeDir, parentDir, installCommand = null, fsMod =
|
|
|
40
82
|
if (ex(path.join(worktreeDir, 'package.json'))) {
|
|
41
83
|
return { kind: 'install', command: 'npm install --no-audit --no-fund', reason: 'package.json without a lockfile' };
|
|
42
84
|
}
|
|
43
|
-
|
|
85
|
+
// HC-GF-024: a manifest we recognise but cannot safely install. Say so by NAME.
|
|
86
|
+
for (const [file, ecosystem] of RECOGNISED_MANIFESTS) {
|
|
87
|
+
if (!ex(path.join(worktreeDir, file))) continue;
|
|
88
|
+
return {
|
|
89
|
+
kind: 'unsupported',
|
|
90
|
+
manifest: file,
|
|
91
|
+
ecosystem,
|
|
92
|
+
reason: `${file} detected — ${ecosystem} provisioning is not automated; ` +
|
|
93
|
+
'set `ci.install_command` in .pipeline-config.yml if the tests need dependencies',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// HC-GF-024: name what was looked for, so "not detected" can be checked rather
|
|
98
|
+
// than believed. A bare "no dependency manifest detected" is unfalsifiable and
|
|
99
|
+
// reads identically whether the repo has none or we simply do not know the file.
|
|
100
|
+
return {
|
|
101
|
+
kind: 'none',
|
|
102
|
+
reason: `no dependency manifest detected (looked for: ${ALL_MANIFEST_NAMES.join(', ')})`,
|
|
103
|
+
};
|
|
44
104
|
}
|
|
45
105
|
|
|
46
106
|
/** @returns {{ok:boolean, did:'none'|'link'|'install', summary:string, command?:string}} */
|
|
47
107
|
function provisionWorktree(plan, { worktreeDir, exec, fsMod = fs }) {
|
|
48
108
|
if (!plan || plan.kind === 'none') return { ok: true, did: 'none', summary: `skipped (${plan ? plan.reason : 'no plan'})` };
|
|
109
|
+
// HC-GF-024: recognised-but-unautomated. ok:true keeps the fail-open contract (the
|
|
110
|
+
// test command still runs), but the summary names the manifest so a later
|
|
111
|
+
// ModuleNotFoundError reads as "unprovisioned", not as a broken patch.
|
|
112
|
+
if (plan.kind === 'unsupported') {
|
|
113
|
+
return { ok: true, did: 'none', summary: `NOT provisioned — ${plan.reason}` };
|
|
114
|
+
}
|
|
49
115
|
if (plan.kind === 'link') {
|
|
50
116
|
try {
|
|
51
117
|
const dest = path.join(worktreeDir, 'node_modules');
|
|
@@ -61,4 +127,7 @@ function provisionWorktree(plan, { worktreeDir, exec, fsMod = fs }) {
|
|
|
61
127
|
} catch (e) { return { ok: false, did: 'install', command: plan.command, summary: `install threw (${e.message}) — tests run unprovisioned` }; }
|
|
62
128
|
}
|
|
63
129
|
|
|
64
|
-
module.exports = {
|
|
130
|
+
module.exports = {
|
|
131
|
+
planProvision, provisionWorktree,
|
|
132
|
+
LOCKFILES, RECOGNISED_MANIFESTS, ALL_MANIFEST_NAMES, INSTALL_TIMEOUT_MS,
|
|
133
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hone-ai/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.29.1",
|
|
4
4
|
"description": "Hone AI — Enterprise SDLC Pipeline CLI",
|
|
5
5
|
"main": "hone-cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"hone-mcp": "./bin/hone-mcp.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
+
"README.md",
|
|
11
12
|
"bin/",
|
|
12
13
|
"hone-cli.js",
|
|
13
14
|
"lib/",
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
"link": "npm link",
|
|
21
22
|
"sync-server-cli-version": "node scripts/sync-server-cli-version.js",
|
|
22
23
|
"prepublishOnly": "node scripts/sync-server-cli-version.js",
|
|
23
|
-
"postinstall": "echo '\\n Hone AI CLI installed successfully.\\n Next: run `hone init --token <YOUR_TOKEN>` to configure.\\n Docs: https://
|
|
24
|
+
"postinstall": "echo '\\n Hone AI CLI installed successfully.\\n Next: run `hone init --token <YOUR_TOKEN>` to configure.\\n Docs: https://www.npmjs.com/package/@hone-ai/cli\\n'"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
@@ -44,5 +45,6 @@
|
|
|
44
45
|
"license": "UNLICENSED",
|
|
45
46
|
"publishConfig": {
|
|
46
47
|
"access": "public"
|
|
47
|
-
}
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://www.npmjs.com/package/@hone-ai/cli"
|
|
48
50
|
}
|