@hone-ai/cli 1.27.0 → 1.29.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/README.md +90 -0
- package/hone-cli.js +250 -6
- 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 +108 -3
- package/lib/repo-tree.js +148 -0
- package/lib/run-report.js +217 -0
- package/lib/verify-patch.js +157 -4
- package/lib/worktree-provision.js +71 -2
- package/package.json +5 -3
package/lib/repo-tree.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* repo-tree.js — HC-019n-followup-42: repository-structure grounding.
|
|
4
|
+
*
|
|
5
|
+
* Why: bundling FILES is not grounding. With two real files bundled, the groomer
|
|
6
|
+
* still invented a TypeScript monorepo (`packages/hone-cli/src/commands/story.ts`)
|
|
7
|
+
* because no repository STRUCTURE ever reached a prompt, and the planner carried
|
|
8
|
+
* the assumption forward (dogfood run f276fe00, 2026-09-06). This module turns
|
|
9
|
+
* `git ls-files` into a compact, capped, LLM-readable tree that the CLI stamps
|
|
10
|
+
* into the run config and the server renders for the steps that name paths.
|
|
11
|
+
*
|
|
12
|
+
* Pure: takes a path list; the CLI does the git I/O. Deterministic (sorted).
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_MAX_ENTRIES = 300;
|
|
15
|
+
const DEFAULT_MAX_BYTES = 16 * 1024;
|
|
16
|
+
const ALWAYS_EXCLUDE = /(^|\/)(node_modules|dist|build|coverage|\.git|\.next|vendor|__pycache__|target)(\/|$)/;
|
|
17
|
+
|
|
18
|
+
function _norm(d) { return String(d || '').replace(/^\.\//, '').replace(/\/+$/, ''); }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Select the tracked paths worth showing: under source/test dirs when configured,
|
|
22
|
+
* otherwise everything tracked minus generated/dependency dirs.
|
|
23
|
+
*/
|
|
24
|
+
function selectPaths(allPaths, { sourceDirs = [], testDirs = [] } = {}) {
|
|
25
|
+
const dirs = [...sourceDirs, ...testDirs].map(_norm).filter(Boolean);
|
|
26
|
+
const paths = (Array.isArray(allPaths) ? allPaths : []).map(p => String(p).trim()).filter(p => p && !ALWAYS_EXCLUDE.test(p));
|
|
27
|
+
if (dirs.length === 0) return paths.sort();
|
|
28
|
+
const inScope = paths.filter(p => dirs.some(d => p === d || p.startsWith(d + '/')));
|
|
29
|
+
// Keep repo-root manifests so the model sees the package layout even when only dirs are configured.
|
|
30
|
+
const roots = paths.filter(p => !p.includes('/') && /^(package\.json|pyproject\.toml|go\.mod|Cargo\.toml|Makefile|pom\.xml|build\.gradle|Gemfile)$/.test(p));
|
|
31
|
+
return [...new Set([...roots, ...inScope])].sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build a nested {name -> node} structure from paths. */
|
|
35
|
+
function _nest(paths) {
|
|
36
|
+
const root = { dirs: new Map(), files: [] };
|
|
37
|
+
for (const p of paths) {
|
|
38
|
+
const segs = p.split('/'); let node = root;
|
|
39
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
40
|
+
if (!node.dirs.has(segs[i])) node.dirs.set(segs[i], { dirs: new Map(), files: [] });
|
|
41
|
+
node = node.dirs.get(segs[i]);
|
|
42
|
+
}
|
|
43
|
+
node.files.push(segs[segs.length - 1]);
|
|
44
|
+
}
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
function _count(node) { let n = node.files.length; for (const d of node.dirs.values()) n += _count(d); return n; }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render an indented tree. Over the entry cap, the deepest/largest directories are
|
|
51
|
+
* collapsed to `name/ (N files)` so the SHAPE survives even when the listing cannot.
|
|
52
|
+
* @returns {{ text: string, shown: number, total: number, collapsed: number }}
|
|
53
|
+
*/
|
|
54
|
+
function renderTree(paths, { maxEntries = DEFAULT_MAX_ENTRIES, maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
55
|
+
const sorted = [...new Set(paths)].sort(); const total = sorted.length;
|
|
56
|
+
const root = _nest(sorted);
|
|
57
|
+
let budget = Math.max(1, maxEntries); let collapsed = 0; let shown = 0;
|
|
58
|
+
const lines = [];
|
|
59
|
+
// Convention: FILES first, then subdirectories (a reader sees what lives at a
|
|
60
|
+
// level before descending; shallow files keep priority under the entry cap).
|
|
61
|
+
// Budget applies to FILE lines; directories always appear (the shape must survive the cap):
|
|
62
|
+
// once the budget is spent, remaining files at a level become `… (N more files)` and remaining
|
|
63
|
+
// subdirectories become `name/ (N files)`. Deep, large directories are collapsed regardless.
|
|
64
|
+
function walk(node, indent, depth) {
|
|
65
|
+
const dirNames = [...node.dirs.keys()].sort();
|
|
66
|
+
const files = [...node.files].sort();
|
|
67
|
+
let skipped = 0;
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
if (budget <= 0) { skipped += 1; continue; }
|
|
70
|
+
lines.push(`${indent}${f}`); budget -= 1; shown += 1;
|
|
71
|
+
}
|
|
72
|
+
if (skipped > 0) { lines.push(`${indent}… (${skipped} more files)`); collapsed += skipped; }
|
|
73
|
+
for (const d of dirNames) {
|
|
74
|
+
const child = node.dirs.get(d); const n = _count(child);
|
|
75
|
+
if (budget <= 0 || (depth >= 4 && n > 8)) { lines.push(`${indent}${d}/ (${n} files)`); collapsed += n; continue; }
|
|
76
|
+
lines.push(`${indent}${d}/`); walk(child, indent + ' ', depth + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
walk(root, '', 0);
|
|
80
|
+
let text = lines.join('\n');
|
|
81
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
|
|
82
|
+
// Hard byte cap: trim whole lines from the end and say so.
|
|
83
|
+
while (lines.length && Buffer.byteLength(lines.join('\n'), 'utf8') > maxBytes - 40) { lines.pop(); }
|
|
84
|
+
text = lines.join('\n') + '\n… (listing truncated to fit the size cap)';
|
|
85
|
+
}
|
|
86
|
+
return { text, shown, total, collapsed: Math.max(0, total - shown) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const MAX_TRANSPORT_PATHS = 5000;
|
|
90
|
+
const MAX_TRANSPORT_DIRS = 3000;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* HC-019n-followup-43: every tracked directory repo-wide (minus generated/dependency dirs),
|
|
94
|
+
* all ancestor levels included. Directories are few (hundreds) and answer "does this
|
|
95
|
+
* directory exist?" even OUTSIDE the tree's source/test scope — the validator's question.
|
|
96
|
+
*/
|
|
97
|
+
function allDirs(allPaths) {
|
|
98
|
+
const out = new Set();
|
|
99
|
+
for (const raw of (Array.isArray(allPaths) ? allPaths : [])) {
|
|
100
|
+
const p = String(raw).trim(); if (!p || ALWAYS_EXCLUDE.test(p)) continue;
|
|
101
|
+
const segs = p.split('/');
|
|
102
|
+
for (let i = 1; i < segs.length; i++) out.add(segs.slice(0, i).join('/'));
|
|
103
|
+
}
|
|
104
|
+
return [...out].sort();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The transport object stamped into run.config.repo_tree.
|
|
109
|
+
* v2 (followup-43) adds `paths` (the selected files) and `dirs` (all tracked directories) so
|
|
110
|
+
* the server can validate the planner's `## Files` without token cost; `text` is unchanged
|
|
111
|
+
* from v1 (prompt bytes stable). Both lists are capped; over the cap they are omitted and
|
|
112
|
+
* the server treats the tree as v1 (guard skipped, never a false rejection).
|
|
113
|
+
*/
|
|
114
|
+
function buildRepoTree(allPaths, { sourceDirs = [], testDirs = [], maxEntries, maxBytes } = {}) {
|
|
115
|
+
const selected = selectPaths(allPaths, { sourceDirs, testDirs });
|
|
116
|
+
const r = renderTree(selected, { maxEntries, maxBytes });
|
|
117
|
+
const dirs = allDirs(allPaths);
|
|
118
|
+
const tree = {
|
|
119
|
+
// v3 (HC-019n-followup-45): adds `detected_source_dirs` + `omitted_dirs` so the server can
|
|
120
|
+
// grade coverage instead of asking a binary question. A ratio alone cannot distinguish a
|
|
121
|
+
// deliberately narrow scope (server/src only = 6% of this repo, correct) from an accidental
|
|
122
|
+
// one (server/src+cli = 39%, missing scripts/ and server/scripts/ where real code lives).
|
|
123
|
+
// The DETECTOR's opinion is the semantic signal: which source-bearing dirs did the config
|
|
124
|
+
// leave out? Old servers ignore the new fields; old CLIs simply do not send them.
|
|
125
|
+
version: 3,
|
|
126
|
+
source_dirs: sourceDirs.map(_norm).filter(Boolean),
|
|
127
|
+
test_dirs: testDirs.map(_norm).filter(Boolean),
|
|
128
|
+
tracked_total: Array.isArray(allPaths) ? allPaths.length : 0,
|
|
129
|
+
selected_total: selected.length,
|
|
130
|
+
shown: r.shown,
|
|
131
|
+
text: r.text,
|
|
132
|
+
};
|
|
133
|
+
if (selected.length <= MAX_TRANSPORT_PATHS && dirs.length <= MAX_TRANSPORT_DIRS) {
|
|
134
|
+
tree.paths = selected; tree.dirs = dirs;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const { detectLayout } = require('./layout-detect');
|
|
138
|
+
const d = detectLayout({ trackedPaths: allPaths });
|
|
139
|
+
tree.detected_source_dirs = d.source_dirs;
|
|
140
|
+
// A detected source dir is "omitted" when no configured dir contains it (or equals it).
|
|
141
|
+
const cfg = tree.source_dirs.map(_norm).filter(Boolean);
|
|
142
|
+
tree.omitted_dirs = cfg.length === 0 ? [] : d.source_dirs.filter(
|
|
143
|
+
dir => !cfg.some(c => dir === c || dir.startsWith(c + '/') || c.startsWith(dir + '/')));
|
|
144
|
+
} catch { /* detection is best-effort; without it the server falls back to the ratio */ }
|
|
145
|
+
return tree;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { selectPaths, renderTree, buildRepoTree, allDirs, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_BYTES, MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS };
|
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
// 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
|
|
64
|
+
// Listing "verification failed" and "lint failed" separately gave two entries
|
|
65
|
+
// for one problem resolved by one fix — and NEEDS YOU is a list of DISTINCT
|
|
66
|
+
// ACTIONS, not of observations. Grouping dedupes structurally rather than by
|
|
67
|
+
// string-matching a gate name inside a failure message, which would be a
|
|
68
|
+
// heuristic that quietly mis-fires.
|
|
69
|
+
//
|
|
70
|
+
// `fail` and `unavailable` are both actionable and mean different things:
|
|
71
|
+
// one found problems, the other never ran. Never collapse them.
|
|
72
|
+
const lastVerification = verifications[verifications.length - 1] || null;
|
|
73
|
+
if (lastVerification) {
|
|
74
|
+
const bad = (lastVerification.gates || []).filter(g => ACTIONABLE_GATE_VERDICTS.includes(g.verdict));
|
|
75
|
+
const failed = lastVerification.verdict === 'fail' || lastVerification.verdict === 'timeout';
|
|
76
|
+
if (failed || bad.length) {
|
|
77
|
+
const stepKey = lastVerification.stepKey || 'step_4';
|
|
78
|
+
needsYou.push({
|
|
79
|
+
what: failed
|
|
80
|
+
? `verification ${lastVerification.verdict} on ${stepKey}`
|
|
81
|
+
+ `${lastVerification.detail ? ` — ${lastVerification.detail}` : ''}`
|
|
82
|
+
: `${bad.length} gate(s) not passing on ${stepKey}`,
|
|
83
|
+
// Sub-lines carry the per-gate verdicts, so one glance separates
|
|
84
|
+
// "the tests broke" from "the linter never ran".
|
|
85
|
+
detail: (lastVerification.gates || []).map(
|
|
86
|
+
g => `${g.name}: ${g.verdict}${g.detail ? ` — ${g.detail}` : ''}`
|
|
87
|
+
+ `${g.command ? ` [${g.command}]` : ''}`),
|
|
88
|
+
how: `hone verify-patch ${run.runId} ${stepKey} --keep`,
|
|
89
|
+
howLabel: 'reproduce',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── WORTH KNOWING ──────────────────────────────────────────────────────────
|
|
95
|
+
// Retries: the step produced something the pipeline rejected at least once.
|
|
96
|
+
for (const s of steps) {
|
|
97
|
+
if (Number(s.attempts) > 1) {
|
|
98
|
+
worthKnowing.push(`${s.stepKey} retried — ${s.attempts} attempts (its first output was rejected)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The two "blind run" tells, both from HC-GF-006's preflight reasoning.
|
|
102
|
+
const descChars = typeof cfg.story_description === 'string' ? cfg.story_description.length : 0;
|
|
103
|
+
if (descChars === 0) {
|
|
104
|
+
worthKnowing.push('story_description was EMPTY — step_0 groomed a story it never received');
|
|
105
|
+
}
|
|
106
|
+
const bundled = Array.isArray(cfg.codebaseFiles) ? cfg.codebaseFiles.length : 0;
|
|
107
|
+
if (bundled === 0) {
|
|
108
|
+
worthKnowing.push('no codebase files bundled — step_1/step_4 ran without source context');
|
|
109
|
+
}
|
|
110
|
+
// Skipped steps are a decision, not an absence; say which and stay silent on why
|
|
111
|
+
// (the reason lives in the step output, and inferring it here would be narration).
|
|
112
|
+
const skipped = steps.filter(s => s.status === 'skipped').map(s => s.stepKey);
|
|
113
|
+
if (skipped.length) worthKnowing.push(`skipped: ${skipped.join(', ')}`);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
runId: run.runId,
|
|
117
|
+
storyId: run.storyId,
|
|
118
|
+
status: run.status,
|
|
119
|
+
mode: cfg.execution_mode || run.mode || null,
|
|
120
|
+
stepsRun: steps.filter(s => s.status === 'completed').length,
|
|
121
|
+
stepsTotal: steps.length,
|
|
122
|
+
totalTokens: Number(run.totalTokens) || 0,
|
|
123
|
+
// startedAt/completedAt, never createdAt/updatedAt: the latter moves on any
|
|
124
|
+
// later write (a --report verdict, an approve) and reported 433m for a
|
|
125
|
+
// 12-minute run. Null when the run has not finished — an absent duration is
|
|
126
|
+
// honest; a wrong one is not.
|
|
127
|
+
duration: durationOf(run.startedAt, run.completedAt),
|
|
128
|
+
cost: extra.cost || null,
|
|
129
|
+
architectConsulted: cfg.architect_consulted === true,
|
|
130
|
+
storyDescriptionChars: descChars,
|
|
131
|
+
bundledFiles: bundled,
|
|
132
|
+
classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes : [],
|
|
133
|
+
needsYou,
|
|
134
|
+
worthKnowing,
|
|
135
|
+
steps,
|
|
136
|
+
verifications,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Render to terminal text.
|
|
142
|
+
*
|
|
143
|
+
* EMPTY-STATE RULE, applied ASYMMETRICALLY and on purpose:
|
|
144
|
+
* NEEDS YOU with nothing to report must SAY SO and name what it checked — an
|
|
145
|
+
* omitted section is indistinguishable from an unchecked one, which is this
|
|
146
|
+
* repo's dominant defect class (HC-GF-005, HC-GF-012, HC-GF-022, HC-GF-030).
|
|
147
|
+
* WORTH KNOWING may be omitted silently: nothing actionable is lost.
|
|
148
|
+
*/
|
|
149
|
+
function renderReport(r) {
|
|
150
|
+
const L = [];
|
|
151
|
+
const cost = r.cost
|
|
152
|
+
? `$${r.cost.metered.toFixed(2)} metered${r.cost.notional != null ? ` ($${r.cost.notional.toFixed(2)} notional)` : ''}`
|
|
153
|
+
: null;
|
|
154
|
+
L.push([`${r.storyId || r.runId}`, r.status,
|
|
155
|
+
r.stepsTotal && r.stepsRun !== r.stepsTotal
|
|
156
|
+
? `${r.stepsRun} of ${r.stepsTotal} steps` : `${r.stepsRun} steps`,
|
|
157
|
+
`${r.totalTokens.toLocaleString()} tokens`, cost,
|
|
158
|
+
r.duration ? `${r.duration} elapsed` : null]
|
|
159
|
+
.filter(Boolean).join(' · '));
|
|
160
|
+
L.push('');
|
|
161
|
+
|
|
162
|
+
if (r.needsYou.length) {
|
|
163
|
+
L.push(`NEEDS YOU (${r.needsYou.length})`);
|
|
164
|
+
r.needsYou.forEach((n, i) => {
|
|
165
|
+
L.push(` ${i + 1}. ${n.what}`);
|
|
166
|
+
for (const d of n.detail || []) L.push(` ${d}`);
|
|
167
|
+
// Label the command when it REPRODUCES rather than resolves — an unlabelled
|
|
168
|
+
// arrow reads as "run this to fix it", which `ruff check .` does not do.
|
|
169
|
+
if (n.how) L.push(` → ${n.howLabel ? `${n.howLabel}: ` : ''}${n.how}`);
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
// Name what was checked, so "nothing" is a finding rather than a silence.
|
|
173
|
+
const checked = [];
|
|
174
|
+
const last = r.verifications[r.verifications.length - 1];
|
|
175
|
+
if (last) {
|
|
176
|
+
checked.push('verification');
|
|
177
|
+
for (const g of last.gates || []) checked.push(g.name);
|
|
178
|
+
}
|
|
179
|
+
L.push('NEEDS YOU (0)');
|
|
180
|
+
L.push(checked.length
|
|
181
|
+
? ` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`
|
|
182
|
+
: ' nothing outstanding, but NO verification was reported for this run — '
|
|
183
|
+
+ 'run `hone verify-patch <runId> step_4 --report` before trusting that');
|
|
184
|
+
}
|
|
185
|
+
L.push('');
|
|
186
|
+
|
|
187
|
+
if (r.worthKnowing.length) {
|
|
188
|
+
L.push(`WORTH KNOWING (${r.worthKnowing.length})`);
|
|
189
|
+
for (const w of r.worthKnowing) L.push(` · ${w}`);
|
|
190
|
+
L.push('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
L.push('STEPS');
|
|
194
|
+
for (const s of r.steps) {
|
|
195
|
+
const mark = s.status === 'completed' ? '✓' : s.status === 'skipped' ? '○' : '·';
|
|
196
|
+
const retry = Number(s.attempts) > 1 ? ` [${s.attempts} attempts]` : '';
|
|
197
|
+
L.push(` ${mark} ${pad(s.stepKey, 8)} ${pad(s.agent || '', 24)} ${s.status}`
|
|
198
|
+
+ `${s.gateResult ? ` (${s.gateResult})` : ''}${retry}`);
|
|
199
|
+
}
|
|
200
|
+
L.push('');
|
|
201
|
+
|
|
202
|
+
L.push('RUN FACTS');
|
|
203
|
+
L.push(` mode ${r.mode || 'unknown'} · story text ${r.storyDescriptionChars} chars · `
|
|
204
|
+
+ `${r.bundledFiles} file(s) bundled · architect_consulted ${r.architectConsulted}`);
|
|
205
|
+
if (r.classifierAxes.length) L.push(` axes: ${r.classifierAxes.join(', ')}`);
|
|
206
|
+
if (r.verifications.length) {
|
|
207
|
+
for (const v of r.verifications) {
|
|
208
|
+
const gates = (v.gates || []).map(g => `${g.name}=${g.verdict}`).join(' · ');
|
|
209
|
+
L.push(` verify(${v.stepKey || '?'}): ${v.verdict}${gates ? ` · ${gates}` : ''}`);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
L.push(' verify: none reported');
|
|
213
|
+
}
|
|
214
|
+
return L.join('\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
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.0",
|
|
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
|
}
|