@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 +24 -2
- package/lib/pipeline-config.js +25 -1
- package/lib/verify-patch.js +44 -6
- package/lib/worktree-provision.js +64 -0
- package/package.json +1 -1
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',
|
package/lib/pipeline-config.js
CHANGED
|
@@ -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,
|
package/lib/verify-patch.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 };
|