@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.
@@ -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 };
@@ -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: `tests failed (exit ${run.code})` + (lastMeaningfulLine(run) ? `: ${lastMeaningfulLine(run)}` : ''),
107
+ detail: head + (sum.first ? `, first: ${sum.first}` : ''),
108
+ failure: sum,
103
109
  };
104
110
  }
105
111
 
106
- /** The most useful one-liner from a failed run's output, for the report. */
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
- module.exports = { EXIT, resolveCommand, resolveCommandDetailed, DEFAULT_COMMAND, verdictFromRun, lastMeaningfulLine };
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
- return { kind: 'none', reason: 'no dependency manifest detected' };
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 = { planProvision, provisionWorktree, LOCKFILES, INSTALL_TIMEOUT_MS };
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.27.0",
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://github.com/subbareddyvani/hone-server\\n'"
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
  }