@hone-ai/cli 1.26.0 → 1.27.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
@@ -7353,6 +7353,7 @@ program
7353
7353
  .option('--command <cmd>', 'Test command (default: ci.local_command from config, else `make ci`)')
7354
7354
  .option('--timeout <sec>', 'Seconds before the test command is killed', '600')
7355
7355
  .option('--keep', 'Leave the worktree in place for debugging')
7356
+ .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
7356
7357
  .option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
7357
7358
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7358
7359
  .action(async (workflowId, opts) => {
@@ -7418,7 +7419,7 @@ program
7418
7419
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
7419
7420
  const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
7420
7421
  fs.writeFileSync(diffFile, diff);
7421
- const command = resolveCommand(opts.command, runConfig?.ci_local_command);
7422
+ const command = resolveCommand(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }) // followup-7: file re-read + Makefile/npm-aware default;
7422
7423
  let cleanupDone = false;
7423
7424
  const cleanup = () => {
7424
7425
  if (cleanupDone || opts.keep) return;
@@ -7458,6 +7459,16 @@ program
7458
7459
  reportVerdict = 'does_not_apply'; reportDetail = applyDetail;
7459
7460
  } else {
7460
7461
  // 5. Run the adopter's test command in the worktree.
7462
+ // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
7463
+ // (link the repo's node_modules when the lockfile is unchanged, else run the
7464
+ // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
7465
+ if (opts.provision !== false) {
7466
+ const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
7467
+ const { readCIConfigDetails } = require('./lib/pipeline-config');
7468
+ const plan = planProvision({ worktreeDir: wt, parentDir: process.cwd(), installCommand: readCIConfigDetails(process.cwd()).install_command });
7469
+ const prov = provisionWorktree(plan, { worktreeDir: wt, exec: spawnSync });
7470
+ if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
7471
+ }
7461
7472
  if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7462
7473
  const run = spawnSync(command, {
7463
7474
  cwd: wt, shell: true, encoding: 'utf8',
@@ -7523,6 +7534,7 @@ program
7523
7534
  .option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
7524
7535
  .option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
7525
7536
  .option('--keep', 'Leave the worktree in place for debugging')
7537
+ .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
7526
7538
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7527
7539
  .action(async (workflowId, opts) => {
7528
7540
  const { execFileSync, spawnSync } = require('child_process');
@@ -7634,7 +7646,7 @@ program
7634
7646
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
7635
7647
  const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
7636
7648
  fs.writeFileSync(diffFile, diff);
7637
- const command = resolveCommand(opts.command, runConfig?.ci_local_command);
7649
+ const command = resolveCommand(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }) // followup-7: file re-read + Makefile/npm-aware default;
7638
7650
  // `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
7639
7651
  // before a successful commit (apply fails, verify fails), that empty branch
7640
7652
  // (pointing at HEAD) must be torn down too — else "a failing diff never
@@ -7679,6 +7691,16 @@ program
7679
7691
  // 4b. Re-verify inline (default) — a failing diff never reaches a branch.
7680
7692
  let verifyOk = true;
7681
7693
  if (doVerify) {
7694
+ // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
7695
+ // (link the repo's node_modules when the lockfile is unchanged, else run the
7696
+ // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
7697
+ if (opts.provision !== false) {
7698
+ const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
7699
+ const { readCIConfigDetails } = require('./lib/pipeline-config');
7700
+ const plan = planProvision({ worktreeDir: wt, parentDir: process.cwd(), installCommand: readCIConfigDetails(process.cwd()).install_command });
7701
+ const prov = provisionWorktree(plan, { worktreeDir: wt, exec: spawnSync });
7702
+ if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
7703
+ }
7682
7704
  if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7683
7705
  const run = spawnSync(command, {
7684
7706
  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,29 @@ 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
+
114
137
  function readCIGateConfig(repoRoot) {
115
138
  const defaults = { gate: 'github', local_command: DEFAULT_CI_LOCAL_COMMAND };
116
139
  if (!repoRoot || typeof repoRoot !== 'string') return defaults;
@@ -417,6 +440,7 @@ function readAgentEvalConfig(repoRoot) {
417
440
  }
418
441
 
419
442
  module.exports = {
443
+ readCIConfigDetails,
420
444
  EXECUTION_MODES,
421
445
  DEFAULT_EXECUTION_MODE,
422
446
  EDITOR_MODE_CONSENT_NOTE,
@@ -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.27.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {