@hone-ai/cli 1.32.0 → 1.33.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 +123 -67
- package/lib/auto-verify.js +21 -1
- package/lib/emit-pr.js +1 -0
- package/lib/materialize-diff.js +142 -17
- package/lib/mcp-tools.js +28 -7
- package/lib/poll-decision.js +16 -1
- package/lib/run-report.js +111 -6
- package/lib/run-target.js +97 -0
- package/lib/verify-patch.js +88 -3
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -5522,11 +5522,16 @@ program
|
|
|
5522
5522
|
const m = Math.floor((Date.now() - new Date(ts).getTime()) / 60000);
|
|
5523
5523
|
return m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`;
|
|
5524
5524
|
};
|
|
5525
|
-
|
|
5525
|
+
// A story ID cut to fit is marked as cut: pasted into `run-story <storyId>`, a bare prefix could name another story.
|
|
5526
|
+
// ASCII on purpose: U+2026 is ambiguous-width, and a terminal that draws it double-width shifts the row.
|
|
5527
|
+
const clip = (s, w) => (s.length > w ? `${s.slice(0, w - 3)}...` : s);
|
|
5528
|
+
// HC-GF-108: the FULL run ID. The footer says `hone run-story <runId> --kill`, and an 8-character
|
|
5529
|
+
// prefix is not a run ID; nor is one accepted as a target, since `12345678` is also a valid story ID.
|
|
5530
|
+
console.log(['RUN ID'.padEnd(36), 'STORY'.padEnd(22), 'STATUS'.padEnd(10), 'STEP'.padEnd(10), 'AGE'].join(' '));
|
|
5526
5531
|
for (const r of data.runs) {
|
|
5527
5532
|
console.log([
|
|
5528
|
-
String(r.id).
|
|
5529
|
-
String(r.story_id || '-')
|
|
5533
|
+
String(r.id).padEnd(36),
|
|
5534
|
+
clip(String(r.story_id || '-'), 21).padEnd(22),
|
|
5530
5535
|
String(r.status).padEnd(10),
|
|
5531
5536
|
String(r.current_step || '-').padEnd(10),
|
|
5532
5537
|
age(r.created_at),
|
|
@@ -5598,17 +5603,18 @@ program
|
|
|
5598
5603
|
.command('run-story <storyId>')
|
|
5599
5604
|
.description('Run the SDLC pipeline for a story via the orchestrator')
|
|
5600
5605
|
.option('--mode <mode>', 'Execution mode: interactive or batch', 'interactive')
|
|
5601
|
-
.option('--repo <name>', 'Repository name override (default: directory name)')
|
|
5606
|
+
.option('--repo <name>', 'Repository name override (default: directory name). Also the repository a story ID is looked up in for --status, --kill and --approve')
|
|
5602
5607
|
.option('--branch <name>', 'Git branch (default: current)')
|
|
5603
|
-
.option('--status', 'Show status of
|
|
5604
|
-
.option('--kill', 'Kill
|
|
5608
|
+
.option('--status', 'Show status of a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
|
|
5609
|
+
.option('--kill', 'Kill a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
|
|
5605
5610
|
.option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
|
|
5606
|
-
.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)')
|
|
5611
|
+
.option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0) on a run ID, or on a story ID\'s latest run in this repository. Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
|
|
5607
5612
|
.option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
|
|
5608
5613
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
5609
5614
|
.option('--poll-interval <s>', 'Poll interval in seconds', '5')
|
|
5610
5615
|
.option('--story-file <path>', 'HC-GF-010: read the story text from a local file. Skips the GitHub-issue and Hone-roadmap lookups entirely, so a repo with neither can still run the pipeline.')
|
|
5611
5616
|
.option('--description <text>', 'HC-GF-010: the story text, inline. Same effect as --story-file.')
|
|
5617
|
+
.option('--acknowledge-failed-verdict', 'Approve a step whose verification did NOT pass under verification.gate: enforce (HC-GF-074). The server refuses that approval by default and names the failing gates; this is for a human who has read them and still wants to proceed.')
|
|
5612
5618
|
.option('--acknowledge-abort', 'Approve a step the agent ABORTED (HC-GF-069). An abort means the agent found nothing it could review or build, so approving advances past a step that produced no usable result — read the reason first. Exists so a human can override, which is why the refusal is not simply a hard block.')
|
|
5613
5619
|
.option('--no-follow', 'With --approve: post the approval and exit immediately instead of following the run to its next decision point (HC-GF-070). The default FOLLOWS, because a caller told to poll does not — which is why HC-GF-064 auto-verify never fired outside a TTY.')
|
|
5614
5620
|
.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.')
|
|
@@ -5635,11 +5641,25 @@ program
|
|
|
5635
5641
|
}
|
|
5636
5642
|
} catch { /* never block a run on a banner */ }
|
|
5637
5643
|
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
const
|
|
5641
|
-
|
|
5642
|
-
|
|
5644
|
+
const { resolveRunTarget, isRunId } = require('./lib/run-target');
|
|
5645
|
+
const isUuid = isRunId(storyIdOrRunId);
|
|
5646
|
+
const repoName = opts.repo || path.basename(process.cwd());
|
|
5647
|
+
|
|
5648
|
+
// HC-GF-106: --status, --kill and --approve act on an EXISTING run, and all three resolve their
|
|
5649
|
+
// argument here. Each used to forward it verbatim (`runId || storyIdOrRunId`), so a story ID was
|
|
5650
|
+
// posted as a run ID and answered `Run not found` — while --help promised the story's latest run.
|
|
5651
|
+
// A story is scoped to `repoName`, the name the start path below records, so it resolves to a run
|
|
5652
|
+
// this checkout started. Never falls back to the argument: no run found means exit 1.
|
|
5653
|
+
const existingRunId = async (flag) => {
|
|
5654
|
+
const t = await resolveRunTarget({ client, idOrStory: storyIdOrRunId, repoName, flag });
|
|
5655
|
+
if (t.error) { console.error(t.error); process.exit(1); }
|
|
5656
|
+
if (t.source === 'story') {
|
|
5657
|
+
// stderr: stdout is what a `--status --format json` caller parses, so the note stays out of it.
|
|
5658
|
+
const at = t.run.current_step ? ` at ${t.run.current_step}` : '';
|
|
5659
|
+
console.error(`→ ${storyIdOrRunId}: latest run in ${repoName} is ${t.runId} (${t.run.status}${at})`);
|
|
5660
|
+
}
|
|
5661
|
+
return t.runId;
|
|
5662
|
+
};
|
|
5643
5663
|
|
|
5644
5664
|
// HC-GF-070: ONE poll loop, reachable from BOTH entry points.
|
|
5645
5665
|
//
|
|
@@ -5688,8 +5708,20 @@ program
|
|
|
5688
5708
|
process.exit(d.exitCode);
|
|
5689
5709
|
}
|
|
5690
5710
|
|
|
5691
|
-
if (d.outcome === POLL_OUTCOMES.HUMAN_GATE) {
|
|
5692
|
-
|
|
5711
|
+
if (d.outcome === POLL_OUTCOMES.HUMAN_GATE || d.outcome === POLL_OUTCOMES.EXHAUSTED) {
|
|
5712
|
+
// HC-GF-075: an exhausted step is a decision, not a gate on an artifact. Say what approving
|
|
5713
|
+
// does, and do NOT auto-verify — the attempt on record is the one the validator rejected,
|
|
5714
|
+
// and a verdict on it would read as a verdict on the build.
|
|
5715
|
+
const exhausted = d.outcome === POLL_OUTCOMES.EXHAUSTED;
|
|
5716
|
+
if (exhausted) {
|
|
5717
|
+
console.log(`⏸ Step ${d.stepKey} (${d.agent || 'unknown'}) used up its format retries and paused.`);
|
|
5718
|
+
console.log(` ${String(d.errorMessage || '').slice(0, 240)}`);
|
|
5719
|
+
console.log(` Inspect: hone show step ${pollRunId} ${d.stepKey}`);
|
|
5720
|
+
console.log(` Approving starts another cycle of ${d.stepKey} with a fresh format budget; it does not `
|
|
5721
|
+
+ 'accept an earlier attempt. guardrails.max_retries_per_step caps the cycles.');
|
|
5722
|
+
} else {
|
|
5723
|
+
console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
|
|
5724
|
+
}
|
|
5693
5725
|
|
|
5694
5726
|
// HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
|
|
5695
5727
|
// existed since HC-019n-followup-34/35 and never fired, because nothing ever
|
|
@@ -5698,8 +5730,8 @@ program
|
|
|
5698
5730
|
// cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
|
|
5699
5731
|
// Never throws — a failure here must leave the operator exactly where they
|
|
5700
5732
|
// were, which is being asked to approve.
|
|
5701
|
-
try {
|
|
5702
|
-
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified } = require('./lib/auto-verify');
|
|
5733
|
+
if (!exhausted) try {
|
|
5734
|
+
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine } = require('./lib/auto-verify');
|
|
5703
5735
|
const decision = decideAutoVerify({
|
|
5704
5736
|
stepKey: d.stepKey,
|
|
5705
5737
|
repoRoot: process.cwd(),
|
|
@@ -5721,9 +5753,8 @@ program
|
|
|
5721
5753
|
console.log(' → verified; enforce may have advanced the run. Re-checking...');
|
|
5722
5754
|
continue;
|
|
5723
5755
|
}
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
: ' → verification did not pass; the gate still needs you.');
|
|
5756
|
+
// HC-GF-008: keyed by verify-patch's exit code — "did not pass" only when a command ran and failed.
|
|
5757
|
+
console.log(verifyOutcomeLine(r.status, decision.gate));
|
|
5727
5758
|
}
|
|
5728
5759
|
} catch (e) {
|
|
5729
5760
|
console.log(` (auto-verify skipped: ${e.message})`);
|
|
@@ -5732,7 +5763,7 @@ program
|
|
|
5732
5763
|
if (process.stdin.isTTY) {
|
|
5733
5764
|
const readline = require('readline');
|
|
5734
5765
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
5735
|
-
const answer = await new Promise(resolve => rl.question(' Approve? [y/n/kill] ', resolve));
|
|
5766
|
+
const answer = await new Promise(resolve => rl.question(exhausted ? ` Retry ${d.stepKey} with a fresh format budget? [y/n/kill] ` : ' Approve? [y/n/kill] ', resolve));
|
|
5736
5767
|
rl.close();
|
|
5737
5768
|
|
|
5738
5769
|
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
@@ -5757,7 +5788,9 @@ program
|
|
|
5757
5788
|
} else {
|
|
5758
5789
|
// Non-interactive (CI): exit 2 (paused, needs approval) — the contract
|
|
5759
5790
|
// adopter pipelines already branch on.
|
|
5760
|
-
console.log(
|
|
5791
|
+
console.log(exhausted
|
|
5792
|
+
? ` Non-interactive mode. Retry with: hone run-story ${pollRunId} --approve ${d.stepKey} (or --kill)`
|
|
5793
|
+
: ` Non-interactive mode. Approve with: hone run-story ${pollRunId} --approve ${d.stepKey}`);
|
|
5761
5794
|
process.exit(d.exitCode);
|
|
5762
5795
|
}
|
|
5763
5796
|
continue;
|
|
@@ -5769,8 +5802,8 @@ program
|
|
|
5769
5802
|
|
|
5770
5803
|
// Status mode
|
|
5771
5804
|
if (opts.status) {
|
|
5772
|
-
const statusId = runId || storyIdOrRunId;
|
|
5773
5805
|
try {
|
|
5806
|
+
const statusId = await existingRunId('--status');
|
|
5774
5807
|
const { data } = await client.get(`/orchestrate/${statusId}`);
|
|
5775
5808
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5776
5809
|
console.log('');
|
|
@@ -5819,8 +5852,8 @@ program
|
|
|
5819
5852
|
|
|
5820
5853
|
// Kill mode
|
|
5821
5854
|
if (opts.kill) {
|
|
5822
|
-
const killId = runId || storyIdOrRunId;
|
|
5823
5855
|
try {
|
|
5856
|
+
const killId = await existingRunId('--kill');
|
|
5824
5857
|
const { data } = await client.post(`/orchestrate/${killId}/kill`, { reason: 'CLI kill' });
|
|
5825
5858
|
console.log(`Workflow ${data.runId} killed.`);
|
|
5826
5859
|
} catch (e) {
|
|
@@ -5832,10 +5865,12 @@ program
|
|
|
5832
5865
|
|
|
5833
5866
|
// Approve mode
|
|
5834
5867
|
if (opts.approve) {
|
|
5835
|
-
|
|
5868
|
+
let approveId;
|
|
5836
5869
|
try {
|
|
5870
|
+
approveId = await existingRunId(`--approve ${opts.approve}`);
|
|
5837
5871
|
await client.post(`/orchestrate/${approveId}/approve`,
|
|
5838
|
-
{ stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true
|
|
5872
|
+
{ stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
|
|
5873
|
+
acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
|
|
5839
5874
|
console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
|
|
5840
5875
|
} catch (e) {
|
|
5841
5876
|
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
@@ -5871,7 +5906,6 @@ program
|
|
|
5871
5906
|
process.exit(1);
|
|
5872
5907
|
}
|
|
5873
5908
|
|
|
5874
|
-
const repoName = opts.repo || path.basename(process.cwd());
|
|
5875
5909
|
let branch = opts.branch;
|
|
5876
5910
|
if (!branch) {
|
|
5877
5911
|
try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); } catch { branch = null; }
|
|
@@ -7509,7 +7543,7 @@ showCmd
|
|
|
7509
7543
|
// to apply — structure is not applicability.
|
|
7510
7544
|
program
|
|
7511
7545
|
.command('check-patch <workflowId>')
|
|
7512
|
-
.description('Verify step_4\'s diff applies to
|
|
7546
|
+
.description('Verify step_4\'s diff applies to HEAD — the tree verify-patch and emit-pr apply it to (condition 5)')
|
|
7513
7547
|
.option('--step-key <key>', 'Step to check (default: step_4)', 'step_4')
|
|
7514
7548
|
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7515
7549
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
@@ -7517,7 +7551,7 @@ program
|
|
|
7517
7551
|
const { execFileSync } = require('child_process');
|
|
7518
7552
|
const os = require('os');
|
|
7519
7553
|
const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
|
|
7520
|
-
const { extractFileBlocks, buildDiff,
|
|
7554
|
+
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7521
7555
|
const { gitEnv } = require('./lib/git-env');
|
|
7522
7556
|
const config = getConfig();
|
|
7523
7557
|
const client = api(config);
|
|
@@ -7551,10 +7585,10 @@ program
|
|
|
7551
7585
|
`○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
|
|
7552
7586
|
return;
|
|
7553
7587
|
}
|
|
7554
|
-
if (
|
|
7588
|
+
if (hasMaterializableWork(blocks)) {
|
|
7555
7589
|
let built;
|
|
7556
7590
|
try {
|
|
7557
|
-
built = buildDiff(blocks,
|
|
7591
|
+
built = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() }));
|
|
7558
7592
|
} catch (e) {
|
|
7559
7593
|
if (e instanceof MaterializeError) {
|
|
7560
7594
|
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
@@ -7567,8 +7601,9 @@ program
|
|
|
7567
7601
|
source = 'file-blocks';
|
|
7568
7602
|
noChanges = built.partCount === 0;
|
|
7569
7603
|
if (noChanges) {
|
|
7570
|
-
|
|
7571
|
-
|
|
7604
|
+
// HC-GF-077: identical to HEAD, not to the working copy — the editor's uncommitted edits are not HEAD.
|
|
7605
|
+
emit({ ok: true, verdict: 'already_in_head', source: 'file-blocks', detail: `HEAD already contains ${opts.stepKey}'s files` },
|
|
7606
|
+
`○ HEAD already contains ${opts.stepKey}'s files — nothing to apply.`);
|
|
7572
7607
|
return;
|
|
7573
7608
|
}
|
|
7574
7609
|
} else {
|
|
@@ -7587,16 +7622,25 @@ program
|
|
|
7587
7622
|
}
|
|
7588
7623
|
}
|
|
7589
7624
|
|
|
7590
|
-
// Write to a temp file and run git apply --check against
|
|
7625
|
+
// Write to a temp file and run git apply --check against HEAD. HC-GF-077: against an index read from HEAD,
|
|
7626
|
+
// not the working copy — an editor's uncommitted edits are exactly what emit-pr's branch will NOT contain.
|
|
7591
7627
|
const tmp = path.join(os.tmpdir(), `hone-check-patch-${Date.now()}.diff`);
|
|
7592
7628
|
let result;
|
|
7593
7629
|
try {
|
|
7594
7630
|
fs.writeFileSync(tmp, diff);
|
|
7595
7631
|
let code = 0, stderr = '';
|
|
7596
7632
|
try {
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7633
|
+
const index = path.join(os.tmpdir(), `hone-check-index-${process.pid}-${Date.now()}`);
|
|
7634
|
+
try {
|
|
7635
|
+
try {
|
|
7636
|
+
execFileSync('git', ['read-tree', `--index-output=${index}`, 'HEAD'], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7637
|
+
} catch { // no commit yet: every path is new, so check against the empty tree
|
|
7638
|
+
execFileSync('git', ['read-tree', '--empty', `--index-output=${index}`], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7639
|
+
}
|
|
7640
|
+
execFileSync('git', ['apply', '--check', '--cached', tmp], {
|
|
7641
|
+
cwd: process.cwd(), env: gitEnv({ GIT_INDEX_FILE: index }), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
7642
|
+
});
|
|
7643
|
+
} finally { try { fs.unlinkSync(index); } catch { /* best-effort */ } }
|
|
7600
7644
|
} catch (e) {
|
|
7601
7645
|
code = e.status || 1;
|
|
7602
7646
|
stderr = (e.stderr || '').toString();
|
|
@@ -7612,7 +7656,7 @@ program
|
|
|
7612
7656
|
} else {
|
|
7613
7657
|
emit({ ok: false, verdict: 'failed', source, detail: result.detail },
|
|
7614
7658
|
`✗ ${opts.stepKey} diff does NOT apply: ${result.detail}\n` +
|
|
7615
|
-
` The output is a well-formed diff but does not match
|
|
7659
|
+
` The output is a well-formed diff but does not match HEAD — ` +
|
|
7616
7660
|
`exactly the defect condition 5 catches that condition 4 cannot.`);
|
|
7617
7661
|
process.exit(2); // distinct exit: 2 = does-not-apply, 1 = no-diff/fetch
|
|
7618
7662
|
}
|
|
@@ -7640,8 +7684,8 @@ program
|
|
|
7640
7684
|
.action(async (workflowId, opts) => {
|
|
7641
7685
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7642
7686
|
const os = require('os');
|
|
7643
|
-
const { extractFileBlocks, buildDiff,
|
|
7644
|
-
const {
|
|
7687
|
+
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7688
|
+
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
|
|
7645
7689
|
const { gitEnv } = require('./lib/git-env');
|
|
7646
7690
|
const config = getConfig();
|
|
7647
7691
|
const client = api(config);
|
|
@@ -7652,12 +7696,17 @@ program
|
|
|
7652
7696
|
};
|
|
7653
7697
|
|
|
7654
7698
|
// 1. Fetch step_4 output.
|
|
7699
|
+
// HC-GF-074: the attempt this run verified — reported with the verdict below.
|
|
7700
|
+
let verifiedAttempt;
|
|
7655
7701
|
let output, runConfig;
|
|
7656
7702
|
try {
|
|
7657
7703
|
const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
|
|
7658
7704
|
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7659
7705
|
const r = await client.get(url);
|
|
7660
7706
|
output = r.data?.output;
|
|
7707
|
+
// HC-GF-074: remember WHICH attempt this is. Without --attempt the server returns the latest, and the verdict
|
|
7708
|
+
// used to be reported with `attempt: null` — so no verdict on record could say which build it judged.
|
|
7709
|
+
verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
|
|
7661
7710
|
// best-effort: pull the run's ci_local_command for the default
|
|
7662
7711
|
try {
|
|
7663
7712
|
const runR = await client.get(`/orchestrate/${workflowId}`);
|
|
@@ -7673,7 +7722,7 @@ program
|
|
|
7673
7722
|
// 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
|
|
7674
7723
|
// Condition 6 presupposes 5.
|
|
7675
7724
|
const blocks = extractFileBlocks(output);
|
|
7676
|
-
const hasWork =
|
|
7725
|
+
const hasWork = hasMaterializableWork(blocks);
|
|
7677
7726
|
if (blocks.noChanges || !hasWork) {
|
|
7678
7727
|
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
7679
7728
|
`✗ ${opts.stepKey} produced no file changes to verify.`);
|
|
@@ -7681,7 +7730,7 @@ program
|
|
|
7681
7730
|
}
|
|
7682
7731
|
let diff;
|
|
7683
7732
|
try {
|
|
7684
|
-
diff = buildDiff(blocks,
|
|
7733
|
+
diff = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() })).diff;
|
|
7685
7734
|
} catch (e) {
|
|
7686
7735
|
if (e instanceof MaterializeError) {
|
|
7687
7736
|
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
@@ -7690,17 +7739,17 @@ program
|
|
|
7690
7739
|
}
|
|
7691
7740
|
throw e;
|
|
7692
7741
|
}
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
}
|
|
7742
|
+
// HC-GF-077: an empty diff against HEAD means HEAD already contains the step's files. That is nothing to
|
|
7743
|
+
// APPLY, not nothing to VERIFY — the command still runs, on HEAD as it is. The old early return exited 0
|
|
7744
|
+
// without running anything, and auto-verify called that "verified and recorded".
|
|
7745
|
+
const alreadyInHead = !diff.trim();
|
|
7746
|
+
if (alreadyInHead && opts.format !== 'json') console.log(` ○ HEAD already contains ${opts.stepKey}'s files — verifying HEAD as it is`);
|
|
7698
7747
|
|
|
7699
7748
|
// 3. Isolated worktree from HEAD.
|
|
7700
7749
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
|
|
7701
7750
|
const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
|
|
7702
7751
|
fs.writeFileSync(diffFile, diff);
|
|
7703
|
-
const command =
|
|
7752
|
+
const { command, source: commandSource } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess
|
|
7704
7753
|
let cleanupDone = false;
|
|
7705
7754
|
const cleanup = () => {
|
|
7706
7755
|
if (cleanupDone || opts.keep) return;
|
|
@@ -7729,9 +7778,9 @@ program
|
|
|
7729
7778
|
execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
|
|
7730
7779
|
{ cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7731
7780
|
|
|
7732
|
-
// 4. Apply the diff INTO the worktree (real apply, isolated).
|
|
7781
|
+
// 4. Apply the diff INTO the worktree (real apply, isolated) — unless HEAD already holds it.
|
|
7733
7782
|
let applied = true, applyDetail = '';
|
|
7734
|
-
try {
|
|
7783
|
+
if (!alreadyInHead) try {
|
|
7735
7784
|
execFileSync('git', ['-C', wt, 'apply', diffFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7736
7785
|
} catch (e) {
|
|
7737
7786
|
applied = false;
|
|
@@ -7757,12 +7806,14 @@ program
|
|
|
7757
7806
|
if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
7758
7807
|
const run = spawnSync(command, {
|
|
7759
7808
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7760
|
-
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
7809
|
+
timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
|
|
7761
7810
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7762
7811
|
});
|
|
7763
7812
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
7764
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
|
|
7765
|
-
|
|
7813
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
|
|
7814
|
+
// HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
|
|
7815
|
+
if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
|
|
7816
|
+
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
|
|
7766
7817
|
|
|
7767
7818
|
// HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
|
|
7768
7819
|
// The full log is written OUTSIDE the worktree (cleanup removes the worktree)
|
|
@@ -7807,7 +7858,7 @@ program
|
|
|
7807
7858
|
for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
|
|
7808
7859
|
const gr = spawnSync(gate.command, {
|
|
7809
7860
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7810
|
-
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
7861
|
+
timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
|
|
7811
7862
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7812
7863
|
});
|
|
7813
7864
|
const c = classifyGateRun({
|
|
@@ -7847,7 +7898,7 @@ program
|
|
|
7847
7898
|
verdict: reportVerdict,
|
|
7848
7899
|
exitCode,
|
|
7849
7900
|
detail: reportDetail,
|
|
7850
|
-
attempt: opts.attempt ? Number(opts.attempt) :
|
|
7901
|
+
attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
|
|
7851
7902
|
headSha,
|
|
7852
7903
|
gates: reportGates.length ? reportGates : undefined,
|
|
7853
7904
|
});
|
|
@@ -7921,8 +7972,8 @@ program
|
|
|
7921
7972
|
.action(async (workflowId, opts) => {
|
|
7922
7973
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7923
7974
|
const os = require('os');
|
|
7924
|
-
const { extractFileBlocks, buildDiff,
|
|
7925
|
-
const {
|
|
7975
|
+
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7976
|
+
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
|
|
7926
7977
|
const {
|
|
7927
7978
|
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
7928
7979
|
} = require('./lib/emit-pr');
|
|
@@ -7962,7 +8013,7 @@ program
|
|
|
7962
8013
|
// 2. Materialize the diff (shared with check-patch/verify-patch,
|
|
7963
8014
|
// followup-23/27). Condition 7 presupposes 5.
|
|
7964
8015
|
const blocks = extractFileBlocks(output);
|
|
7965
|
-
const hasWork =
|
|
8016
|
+
const hasWork = hasMaterializableWork(blocks);
|
|
7966
8017
|
if (blocks.noChanges || !hasWork) {
|
|
7967
8018
|
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
7968
8019
|
`✗ ${opts.stepKey} produced no file changes to emit.`);
|
|
@@ -7970,7 +8021,7 @@ program
|
|
|
7970
8021
|
}
|
|
7971
8022
|
let diff;
|
|
7972
8023
|
try {
|
|
7973
|
-
diff = buildDiff(blocks,
|
|
8024
|
+
diff = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() })).diff;
|
|
7974
8025
|
} catch (e) {
|
|
7975
8026
|
if (e instanceof MaterializeError) {
|
|
7976
8027
|
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
@@ -7980,8 +8031,9 @@ program
|
|
|
7980
8031
|
throw e;
|
|
7981
8032
|
}
|
|
7982
8033
|
if (!diff.trim()) {
|
|
7983
|
-
|
|
7984
|
-
|
|
8034
|
+
// HC-GF-077: empty against HEAD — the base branch already holds the change, so there is nothing to emit.
|
|
8035
|
+
emit({ ok: true, verdict: 'already_in_head', detail: `HEAD already contains ${opts.stepKey}'s files` },
|
|
8036
|
+
`○ HEAD already contains ${opts.stepKey}'s files — nothing to emit.`);
|
|
7985
8037
|
return;
|
|
7986
8038
|
}
|
|
7987
8039
|
|
|
@@ -8028,7 +8080,7 @@ program
|
|
|
8028
8080
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
|
|
8029
8081
|
const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
|
|
8030
8082
|
fs.writeFileSync(diffFile, diff);
|
|
8031
|
-
const command =
|
|
8083
|
+
const { command, source: commandSource } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess
|
|
8032
8084
|
// `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
|
|
8033
8085
|
// before a successful commit (apply fails, verify fails), that empty branch
|
|
8034
8086
|
// (pointing at HEAD) must be torn down too — else "a failing diff never
|
|
@@ -8086,19 +8138,21 @@ program
|
|
|
8086
8138
|
if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
8087
8139
|
const run = spawnSync(command, {
|
|
8088
8140
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8089
|
-
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
8141
|
+
timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
|
|
8090
8142
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8091
8143
|
});
|
|
8092
8144
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8093
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
|
|
8145
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
|
|
8094
8146
|
if (v.verdict === 'pass') {
|
|
8095
8147
|
verifyState = 'pass';
|
|
8096
8148
|
} else {
|
|
8097
8149
|
verifyOk = false;
|
|
8098
|
-
|
|
8150
|
+
// HC-GF-008: a command that never started is not a failed verify.
|
|
8151
|
+
const unresolved = v.verdict === 'unresolved';
|
|
8152
|
+
const sym = unresolved ? '○' : v.verdict === 'timeout' ? '⏱' : '✗';
|
|
8099
8153
|
emit({ ok: false, verdict: v.verdict, command, detail: v.detail },
|
|
8100
|
-
`${sym} verify failed, no branch emitted: ${v.detail}\n (command: ${command})`);
|
|
8101
|
-
exitCode = v.exitCode; // 3 tests-failed
|
|
8154
|
+
`${sym} ${unresolved ? 'verify could not run, no branch emitted' : 'verify failed, no branch emitted'}: ${v.detail}\n (command: ${command})`);
|
|
8155
|
+
exitCode = v.exitCode; // 3 tests-failed, 4 timeout, 7 unresolved
|
|
8102
8156
|
}
|
|
8103
8157
|
}
|
|
8104
8158
|
|
|
@@ -8258,7 +8312,9 @@ program
|
|
|
8258
8312
|
if (!command) { try { command = readCIGateConfig(repoRoot).local_command; } catch { command = 'make ci'; } }
|
|
8259
8313
|
command = command || 'make ci';
|
|
8260
8314
|
if (opts.format !== 'json') console.log(` running local CI: ${command} (timeout ${opts.timeout}s)`);
|
|
8261
|
-
|
|
8315
|
+
// HC-GF-008: the adopter's own command, without make state inherited from whoever ran hone.
|
|
8316
|
+
const { verifyCommandEnv } = require('./lib/verify-patch');
|
|
8317
|
+
const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: Number(opts.timeout) * 1000, stdio: ['ignore', 'inherit', 'inherit'], env: verifyCommandEnv(gitEnv()) });
|
|
8262
8318
|
local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
|
|
8263
8319
|
}
|
|
8264
8320
|
|
package/lib/auto-verify.js
CHANGED
|
@@ -164,4 +164,24 @@ function verdictReportLine(verdict, gate) {
|
|
|
164
164
|
return ` → reported verdict '${verdict}' to the server (${effect})`;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
/**
|
|
168
|
+
* HC-GF-008 — the line run-story prints after auto-verify's `verify-patch` exits, keyed by that exit code.
|
|
169
|
+
*
|
|
170
|
+
* It said "verification did not pass" for every non-zero exit. On TICK-002 (run 5f1a7f75) that line sat
|
|
171
|
+
* directly under `✗ step_4 produced no file changes to verify.` — nothing had run, because the build's
|
|
172
|
+
* output had been discarded (HC-GF-075). Only a failing or timed-out command is a verification that did
|
|
173
|
+
* not pass; every other exit is a verification that did not happen, and says which.
|
|
174
|
+
*/
|
|
175
|
+
function verifyOutcomeLine(status, gate) {
|
|
176
|
+
const { EXIT } = require('./verify-patch');
|
|
177
|
+
switch (status) {
|
|
178
|
+
case EXIT.PASS: return ` → verified and recorded (gate: ${gate}). The gate still needs you.`;
|
|
179
|
+
case EXIT.TESTS_FAILED: case EXIT.TIMEOUT: return ' → verification did not pass; the gate still needs you.';
|
|
180
|
+
case EXIT.UNRESOLVED: return ' → verification could not run (see above) — nothing is known about this build; the gate still needs you.';
|
|
181
|
+
case EXIT.NO_DIFF: return ' → nothing was verified: no diff to apply, or the step output could not be fetched (see above); the gate still needs you.';
|
|
182
|
+
case EXIT.DOES_NOT_APPLY: return ' → nothing was verified: the diff did not apply (see above); the gate still needs you.';
|
|
183
|
+
default: return ` → verify-patch exited ${status} without a verdict (see above); the gate still needs you.`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, verdictReportLine, verifyOutcomeLine, VERIFIABLE_STEP };
|
package/lib/emit-pr.js
CHANGED
|
@@ -34,6 +34,7 @@ const EXIT = {
|
|
|
34
34
|
TIMEOUT: 4, // --verify test command timed out
|
|
35
35
|
BRANCH_CONFLICT: 5, // local branch already exists (not overwritten)
|
|
36
36
|
PUBLISH_FAILED: 6, // push or gh pr create failed (no remote / no auth / rejected)
|
|
37
|
+
UNRESOLVED: 7, // --verify could not start the test command (HC-GF-008) — no commit made
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
/**
|