@hone-ai/cli 1.25.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
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const { Command } = require('commander');
20
+ const { isEditorParked, editorParkedGuidance, currentStep, EDITOR_PARKED_EXIT_CODE } = require('./lib/editor-park'); // HC-COMM-010-followup-1
20
21
  const axios = require('axios');
21
22
  const fs = require('fs');
22
23
  const path = require('path');
@@ -2385,6 +2386,19 @@ program
2385
2386
  const { isEditorStubbedAgent, editorStubContent } = require('./lib/editor-stub');
2386
2387
  const { readExecutionMode } = require('./lib/pipeline-config');
2387
2388
  const syncExecutionMode = readExecutionMode(process.cwd());
2389
+ // HC-COMM-010-followup-3: in editor mode, register the Hone MCP server for
2390
+ // Claude Code by writing a project-scoped .mcp.json (merge, never clobber).
2391
+ // Replaces the manual `claude mcp add hone -- hone-mcp` runbook step.
2392
+ try {
2393
+ const { ensureClaudeMcpConfig, MCP_FILE, FALLBACK_LINE } = require('./lib/mcp-config');
2394
+ const mcp = ensureClaudeMcpConfig(process.cwd(), { mode: syncExecutionMode });
2395
+ if (mcp.action === 'written' || mcp.action === 'updated') console.log(` ✓ Registered the Hone MCP server for Claude Code in ${MCP_FILE} (commit it; Claude Code asks once to approve project servers)`);
2396
+ else if (mcp.action === 'skipped' && syncExecutionMode === 'editor') console.log(` ⚠ ${mcp.reason}`);
2397
+ else if (mcp.action === 'unchanged') console.log(` ✓ Hone MCP server already registered in ${MCP_FILE}`);
2398
+ void FALLBACK_LINE;
2399
+ } catch (e) {
2400
+ console.log(` ⚠ MCP registration skipped (non-fatal): ${e.message}`);
2401
+ }
2388
2402
 
2389
2403
  let synced = 0;
2390
2404
  for (const agent of agents) {
@@ -5535,7 +5549,7 @@ program
5535
5549
  .option('--status', 'Show status of the latest workflow run for this story')
5536
5550
  .option('--kill', 'Kill the latest workflow run for this story')
5537
5551
  .option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
5538
- .option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0)')
5552
+ .option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0). Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
5539
5553
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
5540
5554
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
5541
5555
  .option('--include-paths <paths>', 'Comma-separated list of source file paths to bundle into the codebase context (HC-019n-followup-12). Augments auto-detected paths from the issue body.')
@@ -5569,9 +5583,15 @@ program
5569
5583
  for (const s of (data.steps || [])) {
5570
5584
  const icon = s.status === 'completed' ? '✓' : s.status === 'running' ? '⏳' : s.status === 'failed' ? '✗' : '⬜';
5571
5585
  const gate = s.gateResult ? ` (${s.gateResult})` : '';
5572
- console.log(` ${icon} ${s.stepKey} (${s.agent}) ${s.status}${gate}`);
5586
+ // HC-019n-followup-40: one line per step = its CURRENT state; a retried
5587
+ // step says so instead of listing a stale failed row above a completed one.
5588
+ const tries = (s.attempts || 1) > 1 ? ` [attempt ${s.attempt} of ${s.attempts}]` : '';
5589
+ console.log(` ${icon} ${s.stepKey} (${s.agent}) ${s.status}${gate}${tries}`);
5573
5590
  }
5574
- if (data.awaitingApproval) {
5591
+ if (data.awaitingApproval && isEditorParked(data)) {
5592
+ console.log('');
5593
+ console.log(editorParkedGuidance(data.runId, data.currentStep, currentStep(data)?.agent));
5594
+ } else if (data.awaitingApproval) {
5575
5595
  console.log('');
5576
5596
  console.log(`Awaiting approval at ${data.currentStep}.`);
5577
5597
  console.log(`Approve: hone run-story ${data.runId} --approve ${data.currentStep}`);
@@ -5877,6 +5897,37 @@ program
5877
5897
  console.warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js');
5878
5898
  }
5879
5899
 
5900
+ // HC-COMM-010 axes wiring (CLI half): when --axes wasn't given, derive
5901
+ // the DETERMINISTIC axes from the extractors (SC-002) — security labels/
5902
+ // text, cross-repo markers, recently-modified overlap vs the bundled
5903
+ // paths. Judgment axes (shared-chokepoint etc.) come from the groomer at
5904
+ // step_0 and are harvested server-side. Fail-open; explicit --axes wins.
5905
+ if (!opts.axes) {
5906
+ try {
5907
+ const { classifyStory } = require('./lib/story-classifier');
5908
+ const {
5909
+ extractType, extractSecurityImplications, extractCrossRepoDependency, extractRecentlyModifiedOverlap,
5910
+ } = require('./lib/story-classifier-extract');
5911
+ const autoBody = issueBodyForFiles || '';
5912
+ const autoLabels = [];
5913
+ const touched = Array.isArray(orchestrateConfig.codebaseFiles)
5914
+ ? orchestrateConfig.codebaseFiles.map(f => f.path).filter(Boolean)
5915
+ : Object.keys(orchestrateConfig.codebaseFiles || {});
5916
+ const auto = classifyStory({
5917
+ type: extractType(autoLabels, autoBody, String(storyIdOrRunId)),
5918
+ has_security_implications: extractSecurityImplications(autoLabels, autoBody),
5919
+ cross_repo_dependency: extractCrossRepoDependency(autoBody),
5920
+ recently_modified_overlap: extractRecentlyModifiedOverlap(process.cwd(), touched, 14),
5921
+ });
5922
+ if (auto.architect.engage && auto.architect.axes.length > 0) {
5923
+ orchestrateConfig.classifier_axes = auto.architect.axes;
5924
+ console.log(` → auto-detected classifier axes: ${auto.architect.axes.join(', ')}`);
5925
+ }
5926
+ } catch (e) {
5927
+ console.warn(` ⚠ axes auto-detection failed (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
5928
+ }
5929
+ }
5930
+
5880
5931
  // HC-101-followup-2: pass the adopter's CI gate config to the
5881
5932
  // orchestrator so step_5c can branch (github / local / both / none).
5882
5933
  // Defaults to gate=github + local_command='make ci' when the config
@@ -6049,6 +6100,12 @@ program
6049
6100
 
6050
6101
  if (status.status === 'paused' && status.awaitingApproval) {
6051
6102
  const step = status.steps.find(s => s.stepKey === status.currentStep);
6103
+ // HC-COMM-010-followup-1: an editor-parked run is a gate the EDITOR
6104
+ // satisfies, not a human — never offer --approve here (B4 refuses it).
6105
+ if (isEditorParked(status)) {
6106
+ console.log(editorParkedGuidance(data.runId, status.currentStep, step?.agent));
6107
+ process.exit(EDITOR_PARKED_EXIT_CODE);
6108
+ }
6052
6109
  console.log(`⏳ Step ${status.currentStep} (${step?.agent || 'unknown'}) — awaiting approval`);
6053
6110
 
6054
6111
  // Interactive prompt
@@ -7296,6 +7353,7 @@ program
7296
7353
  .option('--command <cmd>', 'Test command (default: ci.local_command from config, else `make ci`)')
7297
7354
  .option('--timeout <sec>', 'Seconds before the test command is killed', '600')
7298
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)')
7299
7357
  .option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
7300
7358
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7301
7359
  .action(async (workflowId, opts) => {
@@ -7361,7 +7419,7 @@ program
7361
7419
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
7362
7420
  const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
7363
7421
  fs.writeFileSync(diffFile, diff);
7364
- 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;
7365
7423
  let cleanupDone = false;
7366
7424
  const cleanup = () => {
7367
7425
  if (cleanupDone || opts.keep) return;
@@ -7401,6 +7459,16 @@ program
7401
7459
  reportVerdict = 'does_not_apply'; reportDetail = applyDetail;
7402
7460
  } else {
7403
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
+ }
7404
7472
  if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7405
7473
  const run = spawnSync(command, {
7406
7474
  cwd: wt, shell: true, encoding: 'utf8',
@@ -7466,6 +7534,7 @@ program
7466
7534
  .option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
7467
7535
  .option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
7468
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)')
7469
7538
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7470
7539
  .action(async (workflowId, opts) => {
7471
7540
  const { execFileSync, spawnSync } = require('child_process');
@@ -7577,7 +7646,7 @@ program
7577
7646
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
7578
7647
  const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
7579
7648
  fs.writeFileSync(diffFile, diff);
7580
- 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;
7581
7650
  // `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
7582
7651
  // before a successful commit (apply fails, verify fails), that empty branch
7583
7652
  // (pointing at HEAD) must be torn down too — else "a failing diff never
@@ -7622,6 +7691,16 @@ program
7622
7691
  // 4b. Re-verify inline (default) — a failing diff never reaches a branch.
7623
7692
  let verifyOk = true;
7624
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
+ }
7625
7704
  if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7626
7705
  const run = spawnSync(command, {
7627
7706
  cwd: wt, shell: true, encoding: 'utf8',
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ /**
3
+ * editor-park.js — HC-COMM-010-followup-1: recognize a run that is PARKED FOR
4
+ * THE EDITOR (execution.mode: editor, step awaiting_editor) and say what to do.
5
+ *
6
+ * Why: the run-status endpoint reports an editor-parked run as
7
+ * `paused` + `awaitingApproval: true` (it IS at a gate, just not one a human
8
+ * approves) — so run-story's poll loop used to print "--approve" guidance,
9
+ * which the B4 guard then refuses. Detection keys on the CURRENT step's
10
+ * status, reliable since HC-019n-followup-40 collapsed steps[] to the latest
11
+ * attempt. Pure: no I/O, no process access — the CLI decides how to exit.
12
+ */
13
+ const EDITOR_PARKED_EXIT_CODE = 3; // distinct from 2 (paused at a human gate)
14
+
15
+ function currentStep(status) {
16
+ if (!status || !Array.isArray(status.steps)) return null;
17
+ return status.steps.find(s => s && s.stepKey === status.currentStep) || null;
18
+ }
19
+
20
+ /** @returns {boolean} true when the run is paused because the editor owes output */
21
+ function isEditorParked(status) {
22
+ if (!status || status.status !== 'paused') return false;
23
+ const step = currentStep(status);
24
+ return !!(step && step.status === 'awaiting_editor');
25
+ }
26
+
27
+ /**
28
+ * The parked-state copy (ux-copy pass, 2026-09-05): what happened, where it is,
29
+ * the two ways forward (editor MCP tools / CLI verbs), and the closing step.
30
+ */
31
+ function editorParkedGuidance(runId, stepKey, agent) {
32
+ const a = agent ? ` (${agent})` : '';
33
+ return [
34
+ `⏸ Parked for your editor at ${stepKey}${a} — this step runs in your editor AI, not on Hone.`,
35
+ ` In Claude Code: call hone_step_instruction, build the step there, then hone_submit_step.`,
36
+ ` From the CLI: hone step-instruction ${runId}`,
37
+ ` … build … then: hone submit-step ${runId} ${stepKey} --file out.md`,
38
+ ` When submitted: hone run-story ${runId} --approve ${stepKey}`,
39
+ ` Guide: docs/runbooks/editor-mode.md`,
40
+ ].join('\n');
41
+ }
42
+
43
+ module.exports = { EDITOR_PARKED_EXIT_CODE, isEditorParked, editorParkedGuidance, currentStep };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ /**
3
+ * mcp-config.js — HC-COMM-010-followup-3: register the Hone MCP server for
4
+ * Claude Code by writing a PROJECT-scoped `.mcp.json` when the repo runs in
5
+ * editor mode. Replaces the manual `claude mcp add hone -- hone-mcp` step.
6
+ *
7
+ * Contract: merge, never clobber. Other servers in an existing `.mcp.json` are
8
+ * preserved; only the `hone` entry is added/refreshed; a malformed file is left
9
+ * untouched (fail-open with a reason). Pure over an injected repoRoot.
10
+ */
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ const MCP_FILE = '.mcp.json';
15
+ const HONE_SERVER = Object.freeze({ command: 'hone-mcp', args: [] });
16
+ const FALLBACK_LINE = 'claude mcp add hone -- hone-mcp';
17
+
18
+ function ensureClaudeMcpConfig(repoRoot, { mode } = {}) {
19
+ if (mode !== 'editor') return { action: 'skipped', reason: 'execution.mode is not editor' };
20
+ const file = path.join(repoRoot, MCP_FILE);
21
+ let doc = { mcpServers: {} };
22
+ if (fs.existsSync(file)) {
23
+ try {
24
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
25
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
26
+ doc = parsed;
27
+ if (!doc.mcpServers || typeof doc.mcpServers !== 'object' || Array.isArray(doc.mcpServers)) doc.mcpServers = {};
28
+ } catch (e) {
29
+ return { action: 'skipped', reason: `${MCP_FILE} is not valid JSON (${e.message}); left untouched — register manually: ${FALLBACK_LINE}` };
30
+ }
31
+ }
32
+ const existing = doc.mcpServers.hone;
33
+ if (existing && existing.command === HONE_SERVER.command && JSON.stringify(existing.args || []) === '[]') {
34
+ return { action: 'unchanged', file };
35
+ }
36
+ doc.mcpServers.hone = { ...HONE_SERVER, args: [] };
37
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
38
+ return { action: existing ? 'updated' : 'written', file };
39
+ }
40
+
41
+ module.exports = { ensureClaudeMcpConfig, MCP_FILE, HONE_SERVER, FALLBACK_LINE };
@@ -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.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {