@hone-ai/cli 1.27.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hone-cli.js +27 -1
- package/lib/pipeline-config.js +42 -0
- package/lib/repo-tree.js +133 -0
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -5755,7 +5755,11 @@ program
|
|
|
5755
5755
|
// escape the repo)
|
|
5756
5756
|
// - .md / .txt / .yml docs (rarely needed for code-builder; would
|
|
5757
5757
|
// blow context with prose)
|
|
5758
|
-
|
|
5758
|
+
// HC-DF-001: config-level default include paths (codebase.default_include_paths)
|
|
5759
|
+
// are merged with story-text auto-detection and --include-paths.
|
|
5760
|
+
const { readCodebaseDefaults } = require('./lib/pipeline-config');
|
|
5761
|
+
const defaultIncludePaths = readCodebaseDefaults(process.cwd()).include_paths;
|
|
5762
|
+
if (issueBodyForFiles || opts.includePaths || defaultIncludePaths.length > 0) {
|
|
5759
5763
|
try {
|
|
5760
5764
|
// HC-019n-followup-13c: word-boundary `\b` at end of extension
|
|
5761
5765
|
// group so `.jsonl` doesn't match as `.js` and `.tsx` doesn't
|
|
@@ -5776,6 +5780,7 @@ program
|
|
|
5776
5780
|
candidates.add(p);
|
|
5777
5781
|
}
|
|
5778
5782
|
}
|
|
5783
|
+
for (const p of defaultIncludePaths) candidates.add(p); // HC-DF-001 config defaults
|
|
5779
5784
|
// HC-019n-followup-26: FILE_PATH_RE above requires a directory segment,
|
|
5780
5785
|
// so a story that names a file BARE (`derive-worker.js:787` in prose)
|
|
5781
5786
|
// extracts 0 paths and step_4 runs blind — and, blind, hallucinates a
|
|
@@ -5928,6 +5933,27 @@ program
|
|
|
5928
5933
|
}
|
|
5929
5934
|
}
|
|
5930
5935
|
|
|
5936
|
+
// HC-019n-followup-42: repository-structure grounding. Bundling FILES is not
|
|
5937
|
+
// grounding — with no tree in the prompt the groomer invents a layout and the
|
|
5938
|
+
// planner inherits it (dogfood run f276fe00). Stamp a compact, capped tree of
|
|
5939
|
+
// tracked paths (scoped to source_dirs/test_dirs when configured) into the run
|
|
5940
|
+
// config; the server renders it for the steps that name paths. Fail-open.
|
|
5941
|
+
try {
|
|
5942
|
+
const { execSync } = require('child_process');
|
|
5943
|
+
const { buildRepoTree } = require('./lib/repo-tree');
|
|
5944
|
+
const { readSourceDirs } = require('./lib/pipeline-config');
|
|
5945
|
+
const tracked = execSync('git ls-files', { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
|
|
5946
|
+
const dirs = readSourceDirs(process.cwd());
|
|
5947
|
+
const tree = buildRepoTree(tracked, { sourceDirs: dirs.source_dirs, testDirs: dirs.test_dirs });
|
|
5948
|
+
if (tree.selected_total > 0) {
|
|
5949
|
+
orchestrateConfig.repo_tree = tree;
|
|
5950
|
+
const scope = dirs.source_dirs.length ? dirs.source_dirs.join(', ') : 'all tracked';
|
|
5951
|
+
console.log(` → repository structure: ${tree.shown} of ${tree.selected_total} paths under ${scope} (${(Buffer.byteLength(tree.text, 'utf8') / 1024).toFixed(1)} KB) — the groomer/planner/builder are told to derive every path from it`);
|
|
5952
|
+
}
|
|
5953
|
+
} catch (e) {
|
|
5954
|
+
console.warn(` ⚠ repository structure not bundled (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
5955
|
+
}
|
|
5956
|
+
|
|
5931
5957
|
// HC-101-followup-2: pass the adopter's CI gate config to the
|
|
5932
5958
|
// orchestrator so step_5c can branch (github / local / both / none).
|
|
5933
5959
|
// Defaults to gate=github + local_command='make ci' when the config
|
package/lib/pipeline-config.js
CHANGED
|
@@ -134,6 +134,46 @@ function readCIConfigDetails(repoRoot) {
|
|
|
134
134
|
return none;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* HC-DF-001: `codebase.default_include_paths` — repo-relative files bundled into
|
|
139
|
+
* every run-story from this repo, merged with story-text auto-detection and
|
|
140
|
+
* --include-paths. Opt-in, empty by default. Malformed → [] (fail-open).
|
|
141
|
+
* @returns {{ include_paths: string[] }}
|
|
142
|
+
*/
|
|
143
|
+
function readCodebaseDefaults(repoRoot) {
|
|
144
|
+
const none = { include_paths: [] };
|
|
145
|
+
if (!repoRoot || typeof repoRoot !== 'string') return none;
|
|
146
|
+
for (const p of [path.join(repoRoot, '.pipeline-config.yml'), path.join(repoRoot, '.github/.pipeline-config.yml')]) {
|
|
147
|
+
if (!fs.existsSync(p)) continue;
|
|
148
|
+
let parsed;
|
|
149
|
+
try { parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8')); } catch { continue; }
|
|
150
|
+
const block = parsed && typeof parsed === 'object' ? parsed.codebase : null;
|
|
151
|
+
if (!block || typeof block !== 'object') continue;
|
|
152
|
+
const list = Array.isArray(block.default_include_paths) ? block.default_include_paths : [];
|
|
153
|
+
return { include_paths: list.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) };
|
|
154
|
+
}
|
|
155
|
+
return none;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* HC-019n-followup-42: `source_dirs` / `test_dirs` from the pipeline config (setup
|
|
160
|
+
* writes them) — used to scope the repository-structure signal. Absent ⇒ [].
|
|
161
|
+
* @returns {{ source_dirs: string[], test_dirs: string[] }}
|
|
162
|
+
*/
|
|
163
|
+
function readSourceDirs(repoRoot) {
|
|
164
|
+
const none = { source_dirs: [], test_dirs: [] };
|
|
165
|
+
if (!repoRoot || typeof repoRoot !== 'string') return none;
|
|
166
|
+
for (const p of [path.join(repoRoot, '.pipeline-config.yml'), path.join(repoRoot, '.github/.pipeline-config.yml')]) {
|
|
167
|
+
if (!fs.existsSync(p)) continue;
|
|
168
|
+
let parsed;
|
|
169
|
+
try { parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8')); } catch { continue; }
|
|
170
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
171
|
+
const list = (v) => (Array.isArray(v) ? v : []).filter(x => typeof x === 'string' && x.trim()).map(x => x.trim());
|
|
172
|
+
return { source_dirs: list(parsed.source_dirs), test_dirs: list(parsed.test_dirs) };
|
|
173
|
+
}
|
|
174
|
+
return none;
|
|
175
|
+
}
|
|
176
|
+
|
|
137
177
|
function readCIGateConfig(repoRoot) {
|
|
138
178
|
const defaults = { gate: 'github', local_command: DEFAULT_CI_LOCAL_COMMAND };
|
|
139
179
|
if (!repoRoot || typeof repoRoot !== 'string') return defaults;
|
|
@@ -440,6 +480,8 @@ function readAgentEvalConfig(repoRoot) {
|
|
|
440
480
|
}
|
|
441
481
|
|
|
442
482
|
module.exports = {
|
|
483
|
+
readSourceDirs,
|
|
484
|
+
readCodebaseDefaults,
|
|
443
485
|
readCIConfigDetails,
|
|
444
486
|
EXECUTION_MODES,
|
|
445
487
|
DEFAULT_EXECUTION_MODE,
|
package/lib/repo-tree.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
version: 2,
|
|
120
|
+
source_dirs: sourceDirs.map(_norm).filter(Boolean),
|
|
121
|
+
test_dirs: testDirs.map(_norm).filter(Boolean),
|
|
122
|
+
tracked_total: Array.isArray(allPaths) ? allPaths.length : 0,
|
|
123
|
+
selected_total: selected.length,
|
|
124
|
+
shown: r.shown,
|
|
125
|
+
text: r.text,
|
|
126
|
+
};
|
|
127
|
+
if (selected.length <= MAX_TRANSPORT_PATHS && dirs.length <= MAX_TRANSPORT_DIRS) {
|
|
128
|
+
tree.paths = selected; tree.dirs = dirs;
|
|
129
|
+
}
|
|
130
|
+
return tree;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { selectPaths, renderTree, buildRepoTree, allDirs, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_BYTES, MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS };
|