@hone-ai/cli 1.26.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 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
- if (issueBodyForFiles || opts.includePaths) {
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
@@ -7353,6 +7379,7 @@ program
7353
7379
  .option('--command <cmd>', 'Test command (default: ci.local_command from config, else `make ci`)')
7354
7380
  .option('--timeout <sec>', 'Seconds before the test command is killed', '600')
7355
7381
  .option('--keep', 'Leave the worktree in place for debugging')
7382
+ .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
7356
7383
  .option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
7357
7384
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7358
7385
  .action(async (workflowId, opts) => {
@@ -7418,7 +7445,7 @@ program
7418
7445
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
7419
7446
  const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
7420
7447
  fs.writeFileSync(diffFile, diff);
7421
- const command = resolveCommand(opts.command, runConfig?.ci_local_command);
7448
+ const command = resolveCommand(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }) // followup-7: file re-read + Makefile/npm-aware default;
7422
7449
  let cleanupDone = false;
7423
7450
  const cleanup = () => {
7424
7451
  if (cleanupDone || opts.keep) return;
@@ -7458,6 +7485,16 @@ program
7458
7485
  reportVerdict = 'does_not_apply'; reportDetail = applyDetail;
7459
7486
  } else {
7460
7487
  // 5. Run the adopter's test command in the worktree.
7488
+ // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
7489
+ // (link the repo's node_modules when the lockfile is unchanged, else run the
7490
+ // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
7491
+ if (opts.provision !== false) {
7492
+ const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
7493
+ const { readCIConfigDetails } = require('./lib/pipeline-config');
7494
+ const plan = planProvision({ worktreeDir: wt, parentDir: process.cwd(), installCommand: readCIConfigDetails(process.cwd()).install_command });
7495
+ const prov = provisionWorktree(plan, { worktreeDir: wt, exec: spawnSync });
7496
+ if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
7497
+ }
7461
7498
  if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7462
7499
  const run = spawnSync(command, {
7463
7500
  cwd: wt, shell: true, encoding: 'utf8',
@@ -7523,6 +7560,7 @@ program
7523
7560
  .option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
7524
7561
  .option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
7525
7562
  .option('--keep', 'Leave the worktree in place for debugging')
7563
+ .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
7526
7564
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7527
7565
  .action(async (workflowId, opts) => {
7528
7566
  const { execFileSync, spawnSync } = require('child_process');
@@ -7634,7 +7672,7 @@ program
7634
7672
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
7635
7673
  const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
7636
7674
  fs.writeFileSync(diffFile, diff);
7637
- const command = resolveCommand(opts.command, runConfig?.ci_local_command);
7675
+ const command = resolveCommand(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }) // followup-7: file re-read + Makefile/npm-aware default;
7638
7676
  // `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
7639
7677
  // before a successful commit (apply fails, verify fails), that empty branch
7640
7678
  // (pointing at HEAD) must be torn down too — else "a failing diff never
@@ -7679,6 +7717,16 @@ program
7679
7717
  // 4b. Re-verify inline (default) — a failing diff never reaches a branch.
7680
7718
  let verifyOk = true;
7681
7719
  if (doVerify) {
7720
+ // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
7721
+ // (link the repo's node_modules when the lockfile is unchanged, else run the
7722
+ // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
7723
+ if (opts.provision !== false) {
7724
+ const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
7725
+ const { readCIConfigDetails } = require('./lib/pipeline-config');
7726
+ const plan = planProvision({ worktreeDir: wt, parentDir: process.cwd(), installCommand: readCIConfigDetails(process.cwd()).install_command });
7727
+ const prov = provisionWorktree(plan, { worktreeDir: wt, exec: spawnSync });
7728
+ if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
7729
+ }
7682
7730
  if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7683
7731
  const run = spawnSync(command, {
7684
7732
  cwd: wt, shell: true, encoding: 'utf8',
@@ -33,7 +33,7 @@ const path = require('node:path');
33
33
  const KNOWN_THRESHOLD_KEYS = ['estimate_full_sdlc', 'recently_modified_window_days'];
34
34
 
35
35
  const CI_GATE_MODES = ['github', 'local', 'both', 'none'];
36
- const KNOWN_CI_GATE_KEYS = ['gate', 'local_command'];
36
+ const KNOWN_CI_GATE_KEYS = ['gate', 'local_command', 'install_command']; // install_command: HC-COMM-010-followup-6
37
37
  const DEFAULT_CI_LOCAL_COMMAND = 'make ci';
38
38
 
39
39
  /**
@@ -111,6 +111,69 @@ function normalizeCIGate(raw) {
111
111
  * @param {string} repoRoot
112
112
  * @returns {{ gate: 'github'|'local'|'both'|'none', local_command: string }}
113
113
  */
114
+ /**
115
+ * HC-COMM-010-followup-6/-7: the ci: block details readCIGateConfig deliberately
116
+ * does not expose (its return shape is pinned): whether local_command was set
117
+ * EXPLICITLY in the file (vs the `make ci` default), and an optional
118
+ * install_command used to provision the verification worktree.
119
+ * @returns {{ local_command: string|null, local_command_explicit: boolean, install_command: string|null }}
120
+ */
121
+ function readCIConfigDetails(repoRoot) {
122
+ const none = { local_command: null, local_command_explicit: false, install_command: null };
123
+ if (!repoRoot || typeof repoRoot !== 'string') return none;
124
+ for (const p of [path.join(repoRoot, '.pipeline-config.yml'), path.join(repoRoot, '.github/.pipeline-config.yml')]) {
125
+ if (!fs.existsSync(p)) continue;
126
+ let parsed;
127
+ try { parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8')); } catch { continue; }
128
+ const block = parsed && typeof parsed === 'object' ? parsed.ci : null;
129
+ if (!block || typeof block !== 'object') continue;
130
+ const lc = typeof block.local_command === 'string' && block.local_command.trim() ? block.local_command.trim() : null;
131
+ const ic = typeof block.install_command === 'string' && block.install_command.trim() ? block.install_command.trim() : null;
132
+ return { local_command: lc, local_command_explicit: lc !== null, install_command: ic };
133
+ }
134
+ return none;
135
+ }
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
+
114
177
  function readCIGateConfig(repoRoot) {
115
178
  const defaults = { gate: 'github', local_command: DEFAULT_CI_LOCAL_COMMAND };
116
179
  if (!repoRoot || typeof repoRoot !== 'string') return defaults;
@@ -417,6 +480,9 @@ function readAgentEvalConfig(repoRoot) {
417
480
  }
418
481
 
419
482
  module.exports = {
483
+ readSourceDirs,
484
+ readCodebaseDefaults,
485
+ readCIConfigDetails,
420
486
  EXECUTION_MODES,
421
487
  DEFAULT_EXECUTION_MODE,
422
488
  EDITOR_MODE_CONSENT_NOTE,
@@ -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 };
@@ -38,12 +38,50 @@ const EXIT = {
38
38
  * @param {string|undefined} configured ci_local_command from run config
39
39
  * @returns {string}
40
40
  */
41
- function resolveCommand(explicit, configured) {
42
- const pick = (explicit && explicit.trim())
43
- || (configured && configured.trim())
44
- || 'make ci';
45
- return pick;
41
+ const DEFAULT_COMMAND = 'make ci';
42
+ function _hasMakeCiTarget(cwd) {
43
+ try { const f = require('node:fs'); const p = require('node:path').join(cwd, 'Makefile'); return f.existsSync(p) && /^ci\s*:/m.test(f.readFileSync(p, 'utf8')); } catch { return false; }
46
44
  }
45
+ function _npmTestScript(cwd) {
46
+ try {
47
+ const f = require('node:fs'); const p = require('node:path').join(cwd, 'package.json');
48
+ if (!f.existsSync(p)) return null;
49
+ const t = JSON.parse(f.readFileSync(p, 'utf8'))?.scripts?.test;
50
+ return (typeof t === 'string' && t.trim() && !/no test specified/i.test(t)) ? 'npm test' : null;
51
+ } catch { return null; }
52
+ }
53
+ /**
54
+ * HC-COMM-010-followup-7: tiered resolution. Without `opts.cwd` this is the
55
+ * original pure function (explicit → configured → `make ci`). With `opts.cwd`:
56
+ * 1. --command (explicit)
57
+ * 2. ci.local_command set EXPLICITLY in the file NOW (re-read at verify/emit
58
+ * time — the run config captured it at creation and may be stale)
59
+ * 3. the run config's ci_local_command, unless it is just the stamped default
60
+ * 4. `make ci` if the Makefile has a ci target
61
+ * 5. `npm test` if package.json has a real test script
62
+ * 6. `make ci` (documented default; fails loudly)
63
+ * @returns {{command:string, source:string}}
64
+ */
65
+ function resolveCommandDetailed(explicit, configured, opts = {}) {
66
+ const e = (typeof explicit === 'string' ? explicit : '').trim();
67
+ if (e) return { command: e, source: '--command' };
68
+ const cwd = opts && opts.cwd;
69
+ if (cwd) {
70
+ try {
71
+ const { readCIConfigDetails } = require('./pipeline-config');
72
+ const d = readCIConfigDetails(cwd);
73
+ if (d.local_command_explicit) return { command: d.local_command, source: 'ci.local_command (config file)' };
74
+ } catch { /* fall through */ }
75
+ }
76
+ const c = (typeof configured === 'string' ? configured : '').trim();
77
+ if (c && c !== DEFAULT_COMMAND) return { command: c, source: 'run config' };
78
+ if (cwd) {
79
+ if (_hasMakeCiTarget(cwd)) return { command: DEFAULT_COMMAND, source: 'Makefile ci target' };
80
+ const npm = _npmTestScript(cwd); if (npm) return { command: npm, source: 'package.json test script' };
81
+ }
82
+ return { command: DEFAULT_COMMAND, source: 'default' };
83
+ }
84
+ function resolveCommand(explicit, configured, opts) { return resolveCommandDetailed(explicit, configured, opts).command; }
47
85
 
48
86
  /**
49
87
  * Interpret a completed command run into a condition-6 verdict.
@@ -75,4 +113,4 @@ function lastMeaningfulLine({ stdout = '', stderr = '' }) {
75
113
  return (failLine || combined[0] || '').slice(0, 200);
76
114
  }
77
115
 
78
- module.exports = { EXIT, resolveCommand, verdictFromRun, lastMeaningfulLine };
116
+ module.exports = { EXIT, resolveCommand, resolveCommandDetailed, DEFAULT_COMMAND, verdictFromRun, lastMeaningfulLine };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /**
3
+ * worktree-provision.js — HC-COMM-010-followup-6: "verify in an isolated
4
+ * worktree" implies "provision the worktree". A bare `git worktree add` has no
5
+ * node_modules, so a Node adopter's test command failed with "Cannot find
6
+ * package 'vitest'" before any test ran (found 2026-09-05, run 4ab3a962).
7
+ *
8
+ * Plan (pure) → execute (injected exec). Fast path: when the worktree's
9
+ * lockfile is byte-identical to the repo's and the repo already has
10
+ * node_modules, LINK it (seconds, no network). Otherwise run the lockfile's
11
+ * install. Explicit `ci.install_command` wins. Fail-open: a provisioning
12
+ * failure is reported and the test command still runs (it will fail clearly).
13
+ */
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ const LOCKFILES = [
18
+ ['package-lock.json', 'npm ci --no-audit --no-fund'],
19
+ ['npm-shrinkwrap.json', 'npm ci --no-audit --no-fund'],
20
+ ['pnpm-lock.yaml', 'pnpm install --frozen-lockfile'],
21
+ ['yarn.lock', 'yarn install --frozen-lockfile'],
22
+ ];
23
+ const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
24
+
25
+ function planProvision({ worktreeDir, parentDir, installCommand = null, fsMod = fs }) {
26
+ const ex = (p) => { try { return fsMod.existsSync(p); } catch { return false; } };
27
+ const rd = (p) => { try { return fsMod.readFileSync(p, 'utf8'); } catch { return null; } };
28
+ if (installCommand && String(installCommand).trim()) {
29
+ return { kind: 'install', command: String(installCommand).trim(), reason: 'ci.install_command' };
30
+ }
31
+ for (const [lock, cmd] of LOCKFILES) {
32
+ const wl = path.join(worktreeDir, lock);
33
+ if (!ex(wl)) continue;
34
+ const pl = path.join(parentDir, lock); const pnm = path.join(parentDir, 'node_modules');
35
+ if (ex(pnm) && ex(pl) && rd(wl) !== null && rd(wl) === rd(pl)) {
36
+ return { kind: 'link', target: pnm, reason: `${lock} unchanged — linking the repo's node_modules` };
37
+ }
38
+ return { kind: 'install', command: cmd, reason: `${lock} present${ex(pnm) ? ' but changed' : ''}` };
39
+ }
40
+ if (ex(path.join(worktreeDir, 'package.json'))) {
41
+ return { kind: 'install', command: 'npm install --no-audit --no-fund', reason: 'package.json without a lockfile' };
42
+ }
43
+ return { kind: 'none', reason: 'no dependency manifest detected' };
44
+ }
45
+
46
+ /** @returns {{ok:boolean, did:'none'|'link'|'install', summary:string, command?:string}} */
47
+ function provisionWorktree(plan, { worktreeDir, exec, fsMod = fs }) {
48
+ if (!plan || plan.kind === 'none') return { ok: true, did: 'none', summary: `skipped (${plan ? plan.reason : 'no plan'})` };
49
+ if (plan.kind === 'link') {
50
+ try {
51
+ const dest = path.join(worktreeDir, 'node_modules');
52
+ if (!fsMod.existsSync(dest)) fsMod.symlinkSync(plan.target, dest, 'dir');
53
+ return { ok: true, did: 'link', summary: `linked node_modules (${plan.reason})` };
54
+ } catch (e) { return { ok: false, did: 'link', summary: `link failed (${e.message}) — tests run unprovisioned` }; }
55
+ }
56
+ try {
57
+ const run = exec(plan.command, { cwd: worktreeDir, shell: true, encoding: 'utf8', timeout: INSTALL_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'pipe'] });
58
+ const ok = run && run.status === 0;
59
+ const tail = ok ? '' : ` — ${String((run && (run.stderr || run.stdout)) || '').trim().split('\n').filter(Boolean).pop() || 'install failed'}`;
60
+ return { ok, did: 'install', command: plan.command, summary: `${ok ? 'ran' : 'FAILED'} \`${plan.command}\` (${plan.reason})${tail}` };
61
+ } catch (e) { return { ok: false, did: 'install', command: plan.command, summary: `install threw (${e.message}) — tests run unprovisioned` }; }
62
+ }
63
+
64
+ module.exports = { planProvision, provisionWorktree, LOCKFILES, INSTALL_TIMEOUT_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {