@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 +84 -5
- package/lib/editor-park.js +43 -0
- package/lib/mcp-config.js +41 -0
- 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
|
@@ -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
|
-
|
|
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 };
|
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 };
|