@hone-ai/cli 1.31.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 +280 -185
- package/lib/auto-verify.js +39 -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 +107 -0
- package/lib/run-report.js +184 -11
- 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,20 @@ 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.')
|
|
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.')
|
|
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.')
|
|
5612
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.')
|
|
5613
5621
|
.option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
|
|
5614
5622
|
.option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
|
|
@@ -5633,16 +5641,169 @@ program
|
|
|
5633
5641
|
}
|
|
5634
5642
|
} catch { /* never block a run on a banner */ }
|
|
5635
5643
|
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
const
|
|
5639
|
-
|
|
5640
|
-
|
|
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
|
+
};
|
|
5663
|
+
|
|
5664
|
+
// HC-GF-070: ONE poll loop, reachable from BOTH entry points.
|
|
5665
|
+
//
|
|
5666
|
+
// It used to be reachable from exactly one — the initial `run-story <storyId>` — so
|
|
5667
|
+
// `--approve` posted, printed "Poll: ...", and returned. The caller was told to poll
|
|
5668
|
+
// and, exactly as with the verify-patch nag that motivated HC-GF-064 (5 of 22 runs),
|
|
5669
|
+
// nobody did. Every driver written against this re-implemented the loop and each had
|
|
5670
|
+
// a different bug: a paused gate treated as terminal; a no-op approval hot-looping
|
|
5671
|
+
// nine times; a gate advertised by a run whose driver had already exited. Not three
|
|
5672
|
+
// driver bugs — one missing behaviour, three times.
|
|
5673
|
+
//
|
|
5674
|
+
// Its DECISIONS live in cli/lib/poll-decision.js as a pure function, because calling
|
|
5675
|
+
// the same loop from a second place is only safe if what it decides is pinned.
|
|
5676
|
+
const pollUntilDecision = async (pollRunId) => {
|
|
5677
|
+
const { classifyPollState, POLL_OUTCOMES } = require('./lib/poll-decision');
|
|
5678
|
+
const interval = parseInt(opts.pollInterval, 10) * 1000;
|
|
5679
|
+
// HC-GF-064: at most ONE verification per attempt per process. Without this the
|
|
5680
|
+
// advisory path re-verifies on every poll tick, because advisory never advances
|
|
5681
|
+
// the run and the pause it re-observes is the same pause.
|
|
5682
|
+
const _verifiedAttempts = new Set();
|
|
5683
|
+
|
|
5684
|
+
for (;;) {
|
|
5685
|
+
await new Promise(r => setTimeout(r, interval));
|
|
5686
|
+
|
|
5687
|
+
let status;
|
|
5688
|
+
try {
|
|
5689
|
+
const { data: pollData } = await client.get(`/orchestrate/${pollRunId}`);
|
|
5690
|
+
status = pollData;
|
|
5691
|
+
} catch (e) {
|
|
5692
|
+
console.error(`Poll error: ${e.message}`);
|
|
5693
|
+
continue;
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
const d = classifyPollState(status);
|
|
5697
|
+
|
|
5698
|
+
if (d.outcome === POLL_OUTCOMES.COMPLETED) {
|
|
5699
|
+
console.log(`✓ Workflow completed! Total tokens: ${(d.totalTokens || 0).toLocaleString()}`);
|
|
5700
|
+
process.exit(d.exitCode);
|
|
5701
|
+
}
|
|
5702
|
+
if (d.outcome === POLL_OUTCOMES.TERMINAL_FAILURE) {
|
|
5703
|
+
console.error(`✗ Workflow ${d.runStatus}: ${d.errorMessage}`);
|
|
5704
|
+
process.exit(d.exitCode);
|
|
5705
|
+
}
|
|
5706
|
+
if (d.outcome === POLL_OUTCOMES.EDITOR_PARKED) {
|
|
5707
|
+
console.log(editorParkedGuidance(pollRunId, d.stepKey, d.agent));
|
|
5708
|
+
process.exit(d.exitCode);
|
|
5709
|
+
}
|
|
5710
|
+
|
|
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
|
+
}
|
|
5725
|
+
|
|
5726
|
+
// HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
|
|
5727
|
+
// existed since HC-019n-followup-34/35 and never fired, because nothing ever
|
|
5728
|
+
// ran the executing step. Consent is DERIVED: `ci.local_command` being
|
|
5729
|
+
// explicitly set is the adopter's own statement of how to test this repo (see
|
|
5730
|
+
// cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
|
|
5731
|
+
// Never throws — a failure here must leave the operator exactly where they
|
|
5732
|
+
// were, which is being asked to approve.
|
|
5733
|
+
if (!exhausted) try {
|
|
5734
|
+
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine } = require('./lib/auto-verify');
|
|
5735
|
+
const decision = decideAutoVerify({
|
|
5736
|
+
stepKey: d.stepKey,
|
|
5737
|
+
repoRoot: process.cwd(),
|
|
5738
|
+
noVerifyFlag: opts.verify === false,
|
|
5739
|
+
});
|
|
5740
|
+
if (decision.run && !alreadyVerified(d.attemptKey, _verifiedAttempts)) {
|
|
5741
|
+
_verifiedAttempts.add(d.attemptKey);
|
|
5742
|
+
console.log(announce(decision, pollRunId));
|
|
5743
|
+
const { spawnSync } = require('child_process');
|
|
5744
|
+
const r = spawnSync(process.execPath,
|
|
5745
|
+
[process.argv[1], 'verify-patch', pollRunId, '--report'],
|
|
5746
|
+
{ stdio: 'inherit', env: process.env });
|
|
5747
|
+
// A green verdict under `enforce` AUTO-APPROVES this gate, so the run may
|
|
5748
|
+
// already have moved on. Re-poll rather than prompting for an approval that
|
|
5749
|
+
// is no longer wanted — asking a human to approve something already approved
|
|
5750
|
+
// is how an operator learns to distrust the output.
|
|
5751
|
+
if (shouldRepoll({ gate: decision.gate, verdictOk: r.status === 0,
|
|
5752
|
+
attemptKey: d.attemptKey, verifiedAttempts: null })) {
|
|
5753
|
+
console.log(' → verified; enforce may have advanced the run. Re-checking...');
|
|
5754
|
+
continue;
|
|
5755
|
+
}
|
|
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));
|
|
5758
|
+
}
|
|
5759
|
+
} catch (e) {
|
|
5760
|
+
console.log(` (auto-verify skipped: ${e.message})`);
|
|
5761
|
+
}
|
|
5762
|
+
|
|
5763
|
+
if (process.stdin.isTTY) {
|
|
5764
|
+
const readline = require('readline');
|
|
5765
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
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));
|
|
5767
|
+
rl.close();
|
|
5768
|
+
|
|
5769
|
+
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
5770
|
+
try {
|
|
5771
|
+
await client.post(`/orchestrate/${pollRunId}/approve`, { stepKey: d.stepKey });
|
|
5772
|
+
console.log(' Approved — workflow resuming...');
|
|
5773
|
+
} catch (e) {
|
|
5774
|
+
console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
|
|
5775
|
+
}
|
|
5776
|
+
} else if (answer.toLowerCase() === 'kill') {
|
|
5777
|
+
try {
|
|
5778
|
+
await client.post(`/orchestrate/${pollRunId}/kill`, { reason: 'CLI kill' });
|
|
5779
|
+
console.log(' Workflow killed.');
|
|
5780
|
+
process.exit(0);
|
|
5781
|
+
} catch (e) {
|
|
5782
|
+
console.error(` Kill failed: ${e.message}`);
|
|
5783
|
+
}
|
|
5784
|
+
} else {
|
|
5785
|
+
console.log(` Skipped. Resume later: hone run-story ${pollRunId} --status`);
|
|
5786
|
+
process.exit(0);
|
|
5787
|
+
}
|
|
5788
|
+
} else {
|
|
5789
|
+
// Non-interactive (CI): exit 2 (paused, needs approval) — the contract
|
|
5790
|
+
// adopter pipelines already branch on.
|
|
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}`);
|
|
5794
|
+
process.exit(d.exitCode);
|
|
5795
|
+
}
|
|
5796
|
+
continue;
|
|
5797
|
+
}
|
|
5798
|
+
|
|
5799
|
+
if (d.stepKey) process.stdout.write(` Running: ${d.stepKey} (${d.agent})...\r`);
|
|
5800
|
+
}
|
|
5801
|
+
};
|
|
5641
5802
|
|
|
5642
5803
|
// Status mode
|
|
5643
5804
|
if (opts.status) {
|
|
5644
|
-
const statusId = runId || storyIdOrRunId;
|
|
5645
5805
|
try {
|
|
5806
|
+
const statusId = await existingRunId('--status');
|
|
5646
5807
|
const { data } = await client.get(`/orchestrate/${statusId}`);
|
|
5647
5808
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5648
5809
|
console.log('');
|
|
@@ -5691,8 +5852,8 @@ program
|
|
|
5691
5852
|
|
|
5692
5853
|
// Kill mode
|
|
5693
5854
|
if (opts.kill) {
|
|
5694
|
-
const killId = runId || storyIdOrRunId;
|
|
5695
5855
|
try {
|
|
5856
|
+
const killId = await existingRunId('--kill');
|
|
5696
5857
|
const { data } = await client.post(`/orchestrate/${killId}/kill`, { reason: 'CLI kill' });
|
|
5697
5858
|
console.log(`Workflow ${data.runId} killed.`);
|
|
5698
5859
|
} catch (e) {
|
|
@@ -5704,15 +5865,37 @@ program
|
|
|
5704
5865
|
|
|
5705
5866
|
// Approve mode
|
|
5706
5867
|
if (opts.approve) {
|
|
5707
|
-
|
|
5868
|
+
let approveId;
|
|
5708
5869
|
try {
|
|
5709
|
-
|
|
5870
|
+
approveId = await existingRunId(`--approve ${opts.approve}`);
|
|
5871
|
+
await client.post(`/orchestrate/${approveId}/approve`,
|
|
5872
|
+
{ stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
|
|
5873
|
+
acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
|
|
5710
5874
|
console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
|
|
5711
|
-
console.log(`Poll: hone run-story ${approveId} --status`);
|
|
5712
5875
|
} catch (e) {
|
|
5713
5876
|
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
5714
5877
|
process.exit(1);
|
|
5715
5878
|
}
|
|
5879
|
+
// HC-GF-070: FOLLOW the run to its next decision point instead of returning here.
|
|
5880
|
+
// Returning is what made every automated flow exit blind after each gate, and is
|
|
5881
|
+
// the direct reason HC-GF-064's auto-verify — which lives at the step_4 pause —
|
|
5882
|
+
// never fired outside an interactive terminal. Exit codes are unchanged: 0 done,
|
|
5883
|
+
// 1 failed, 2 next human gate, 3 editor park.
|
|
5884
|
+
if (opts.follow === false) {
|
|
5885
|
+
console.log(`Poll: hone run-story ${approveId} --status`);
|
|
5886
|
+
return;
|
|
5887
|
+
}
|
|
5888
|
+
// The initial invocation runs this inside the start-workflow try/catch; the
|
|
5889
|
+
// approve path has none, and an unhandled rejection here would surface as a bare
|
|
5890
|
+
// stack trace after a successful approval — the approval landed, so the exit code
|
|
5891
|
+
// must say what happened to the RUN, not look like the approve failed.
|
|
5892
|
+
try {
|
|
5893
|
+
await pollUntilDecision(approveId);
|
|
5894
|
+
} catch (e) {
|
|
5895
|
+
console.error(`Approved, but following the run failed: ${e.message}`);
|
|
5896
|
+
console.error(`Poll: hone run-story ${approveId} --status`);
|
|
5897
|
+
process.exit(1);
|
|
5898
|
+
}
|
|
5716
5899
|
return;
|
|
5717
5900
|
}
|
|
5718
5901
|
|
|
@@ -5723,7 +5906,6 @@ program
|
|
|
5723
5906
|
process.exit(1);
|
|
5724
5907
|
}
|
|
5725
5908
|
|
|
5726
|
-
const repoName = opts.repo || path.basename(process.cwd());
|
|
5727
5909
|
let branch = opts.branch;
|
|
5728
5910
|
if (!branch) {
|
|
5729
5911
|
try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); } catch { branch = null; }
|
|
@@ -6263,130 +6445,7 @@ program
|
|
|
6263
6445
|
console.log(`Poll: ${data.pollUrl}`);
|
|
6264
6446
|
console.log('');
|
|
6265
6447
|
|
|
6266
|
-
|
|
6267
|
-
const interval = parseInt(opts.pollInterval, 10) * 1000;
|
|
6268
|
-
// HC-GF-064: at most ONE verification per attempt per process. Without this the
|
|
6269
|
-
// advisory path re-verifies on every poll tick, because advisory never advances
|
|
6270
|
-
// the run and the pause it re-observes is the same pause.
|
|
6271
|
-
const _verifiedAttempts = new Set();
|
|
6272
|
-
while (true) {
|
|
6273
|
-
await new Promise(r => setTimeout(r, interval));
|
|
6274
|
-
|
|
6275
|
-
let status;
|
|
6276
|
-
try {
|
|
6277
|
-
const { data: pollData } = await client.get(`/orchestrate/${data.runId}`);
|
|
6278
|
-
status = pollData;
|
|
6279
|
-
} catch (e) {
|
|
6280
|
-
console.error(`Poll error: ${e.message}`);
|
|
6281
|
-
continue;
|
|
6282
|
-
}
|
|
6283
|
-
|
|
6284
|
-
if (status.status === 'completed') {
|
|
6285
|
-
console.log(`✓ Workflow completed! Total tokens: ${(status.totalTokens || 0).toLocaleString()}`);
|
|
6286
|
-
process.exit(0);
|
|
6287
|
-
}
|
|
6288
|
-
|
|
6289
|
-
if (status.status === 'failed' || status.status === 'killed') {
|
|
6290
|
-
console.error(`✗ Workflow ${status.status}: ${status.errorMessage || 'unknown'}`);
|
|
6291
|
-
process.exit(1);
|
|
6292
|
-
}
|
|
6293
|
-
|
|
6294
|
-
if (status.status === 'paused' && status.awaitingApproval) {
|
|
6295
|
-
const step = status.steps.find(s => s.stepKey === status.currentStep);
|
|
6296
|
-
// HC-COMM-010-followup-1: an editor-parked run is a gate the EDITOR
|
|
6297
|
-
// satisfies, not a human — never offer --approve here (B4 refuses it).
|
|
6298
|
-
if (isEditorParked(status)) {
|
|
6299
|
-
console.log(editorParkedGuidance(data.runId, status.currentStep, step?.agent));
|
|
6300
|
-
process.exit(EDITOR_PARKED_EXIT_CODE);
|
|
6301
|
-
}
|
|
6302
|
-
console.log(`⏳ Step ${status.currentStep} (${step?.agent || 'unknown'}) — awaiting approval`);
|
|
6303
|
-
|
|
6304
|
-
// HC-GF-064: verify BEFORE asking anyone to approve.
|
|
6305
|
-
//
|
|
6306
|
-
// The gate this feeds has existed since HC-019n-followup-34/35 and had never
|
|
6307
|
-
// fired — 22 of 22 runs advisory, 5 of 22 with any verdict at all — because
|
|
6308
|
-
// nothing ever ran the executing step. The only prompt was a run-report line,
|
|
6309
|
-
// and 5-of-22 is what that nag was worth.
|
|
6310
|
-
//
|
|
6311
|
-
// Consent is DERIVED: `ci.local_command` being explicitly set is the adopter's
|
|
6312
|
-
// own statement of how to test this repo (see cli/lib/auto-verify.js and
|
|
6313
|
-
// docs/architecture/adr-verification-invocation.md). Never throws — a failure
|
|
6314
|
-
// here must leave the operator exactly where they were, which is being asked
|
|
6315
|
-
// to approve.
|
|
6316
|
-
try {
|
|
6317
|
-
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified } = require('./lib/auto-verify');
|
|
6318
|
-
const attemptKey = `${status.currentStep}#${step?.attempt ?? 1}`;
|
|
6319
|
-
const decision = decideAutoVerify({
|
|
6320
|
-
stepKey: status.currentStep,
|
|
6321
|
-
repoRoot: process.cwd(),
|
|
6322
|
-
noVerifyFlag: opts.verify === false,
|
|
6323
|
-
});
|
|
6324
|
-
if (decision.run && !alreadyVerified(attemptKey, _verifiedAttempts)) {
|
|
6325
|
-
_verifiedAttempts.add(attemptKey);
|
|
6326
|
-
console.log(announce(decision, data.runId));
|
|
6327
|
-
const { spawnSync } = require('child_process');
|
|
6328
|
-
const r = spawnSync(process.execPath,
|
|
6329
|
-
[process.argv[1], 'verify-patch', data.runId, '--report'],
|
|
6330
|
-
{ stdio: 'inherit', env: process.env });
|
|
6331
|
-
// A green verdict under `enforce` AUTO-APPROVES this gate, so the run may
|
|
6332
|
-
// already have moved on. Re-poll rather than prompting for an approval that
|
|
6333
|
-
// is no longer wanted — asking a human to approve something already approved
|
|
6334
|
-
// is how an operator learns to distrust the output.
|
|
6335
|
-
if (shouldRepoll({ gate: decision.gate, verdictOk: r.status === 0,
|
|
6336
|
-
attemptKey, verifiedAttempts: null })) {
|
|
6337
|
-
console.log(' → verified; enforce may have advanced the run. Re-checking...');
|
|
6338
|
-
continue;
|
|
6339
|
-
}
|
|
6340
|
-
console.log(r.status === 0
|
|
6341
|
-
? ` → verified and recorded (gate: ${decision.gate}). The gate still needs you.`
|
|
6342
|
-
: ' → verification did not pass; the gate still needs you.');
|
|
6343
|
-
}
|
|
6344
|
-
} catch (e) {
|
|
6345
|
-
console.log(` (auto-verify skipped: ${e.message})`);
|
|
6346
|
-
}
|
|
6347
|
-
|
|
6348
|
-
// Interactive prompt
|
|
6349
|
-
if (process.stdin.isTTY) {
|
|
6350
|
-
const readline = require('readline');
|
|
6351
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
6352
|
-
const answer = await new Promise(resolve => {
|
|
6353
|
-
rl.question(' Approve? [y/n/kill] ', resolve);
|
|
6354
|
-
});
|
|
6355
|
-
rl.close();
|
|
6356
|
-
|
|
6357
|
-
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
6358
|
-
try {
|
|
6359
|
-
await client.post(`/orchestrate/${data.runId}/approve`, { stepKey: status.currentStep });
|
|
6360
|
-
console.log(' Approved — workflow resuming...');
|
|
6361
|
-
} catch (e) {
|
|
6362
|
-
console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
|
|
6363
|
-
}
|
|
6364
|
-
} else if (answer.toLowerCase() === 'kill') {
|
|
6365
|
-
try {
|
|
6366
|
-
await client.post(`/orchestrate/${data.runId}/kill`, { reason: 'CLI kill' });
|
|
6367
|
-
console.log(' Workflow killed.');
|
|
6368
|
-
process.exit(0);
|
|
6369
|
-
} catch (e) {
|
|
6370
|
-
console.error(` Kill failed: ${e.message}`);
|
|
6371
|
-
}
|
|
6372
|
-
} else {
|
|
6373
|
-
console.log(` Skipped. Resume later: hone run-story ${data.runId} --status`);
|
|
6374
|
-
process.exit(0);
|
|
6375
|
-
}
|
|
6376
|
-
} else {
|
|
6377
|
-
// Non-interactive (CI): exit with code 2 (paused, needs approval)
|
|
6378
|
-
console.log(` Non-interactive mode. Approve with: hone run-story ${data.runId} --approve ${status.currentStep}`);
|
|
6379
|
-
process.exit(2);
|
|
6380
|
-
}
|
|
6381
|
-
continue;
|
|
6382
|
-
}
|
|
6383
|
-
|
|
6384
|
-
// Still running
|
|
6385
|
-
const currentStep = status.steps.find(s => s.status === 'running');
|
|
6386
|
-
if (currentStep) {
|
|
6387
|
-
process.stdout.write(` Running: ${currentStep.stepKey} (${currentStep.agent})...\r`);
|
|
6388
|
-
}
|
|
6389
|
-
}
|
|
6448
|
+
await pollUntilDecision(data.runId);
|
|
6390
6449
|
} catch (e) {
|
|
6391
6450
|
if (e.response?.status === 429) {
|
|
6392
6451
|
console.error(`Max concurrent workflows reached. ${e.response.data.error}`);
|
|
@@ -7484,7 +7543,7 @@ showCmd
|
|
|
7484
7543
|
// to apply — structure is not applicability.
|
|
7485
7544
|
program
|
|
7486
7545
|
.command('check-patch <workflowId>')
|
|
7487
|
-
.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)')
|
|
7488
7547
|
.option('--step-key <key>', 'Step to check (default: step_4)', 'step_4')
|
|
7489
7548
|
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7490
7549
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
@@ -7492,7 +7551,7 @@ program
|
|
|
7492
7551
|
const { execFileSync } = require('child_process');
|
|
7493
7552
|
const os = require('os');
|
|
7494
7553
|
const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
|
|
7495
|
-
const { extractFileBlocks, buildDiff,
|
|
7554
|
+
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7496
7555
|
const { gitEnv } = require('./lib/git-env');
|
|
7497
7556
|
const config = getConfig();
|
|
7498
7557
|
const client = api(config);
|
|
@@ -7526,10 +7585,10 @@ program
|
|
|
7526
7585
|
`○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
|
|
7527
7586
|
return;
|
|
7528
7587
|
}
|
|
7529
|
-
if (
|
|
7588
|
+
if (hasMaterializableWork(blocks)) {
|
|
7530
7589
|
let built;
|
|
7531
7590
|
try {
|
|
7532
|
-
built = buildDiff(blocks,
|
|
7591
|
+
built = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() }));
|
|
7533
7592
|
} catch (e) {
|
|
7534
7593
|
if (e instanceof MaterializeError) {
|
|
7535
7594
|
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
@@ -7542,8 +7601,9 @@ program
|
|
|
7542
7601
|
source = 'file-blocks';
|
|
7543
7602
|
noChanges = built.partCount === 0;
|
|
7544
7603
|
if (noChanges) {
|
|
7545
|
-
|
|
7546
|
-
|
|
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.`);
|
|
7547
7607
|
return;
|
|
7548
7608
|
}
|
|
7549
7609
|
} else {
|
|
@@ -7562,16 +7622,25 @@ program
|
|
|
7562
7622
|
}
|
|
7563
7623
|
}
|
|
7564
7624
|
|
|
7565
|
-
// 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.
|
|
7566
7627
|
const tmp = path.join(os.tmpdir(), `hone-check-patch-${Date.now()}.diff`);
|
|
7567
7628
|
let result;
|
|
7568
7629
|
try {
|
|
7569
7630
|
fs.writeFileSync(tmp, diff);
|
|
7570
7631
|
let code = 0, stderr = '';
|
|
7571
7632
|
try {
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
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 */ } }
|
|
7575
7644
|
} catch (e) {
|
|
7576
7645
|
code = e.status || 1;
|
|
7577
7646
|
stderr = (e.stderr || '').toString();
|
|
@@ -7587,7 +7656,7 @@ program
|
|
|
7587
7656
|
} else {
|
|
7588
7657
|
emit({ ok: false, verdict: 'failed', source, detail: result.detail },
|
|
7589
7658
|
`✗ ${opts.stepKey} diff does NOT apply: ${result.detail}\n` +
|
|
7590
|
-
` The output is a well-formed diff but does not match
|
|
7659
|
+
` The output is a well-formed diff but does not match HEAD — ` +
|
|
7591
7660
|
`exactly the defect condition 5 catches that condition 4 cannot.`);
|
|
7592
7661
|
process.exit(2); // distinct exit: 2 = does-not-apply, 1 = no-diff/fetch
|
|
7593
7662
|
}
|
|
@@ -7615,8 +7684,8 @@ program
|
|
|
7615
7684
|
.action(async (workflowId, opts) => {
|
|
7616
7685
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7617
7686
|
const os = require('os');
|
|
7618
|
-
const { extractFileBlocks, buildDiff,
|
|
7619
|
-
const {
|
|
7687
|
+
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7688
|
+
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
|
|
7620
7689
|
const { gitEnv } = require('./lib/git-env');
|
|
7621
7690
|
const config = getConfig();
|
|
7622
7691
|
const client = api(config);
|
|
@@ -7627,12 +7696,17 @@ program
|
|
|
7627
7696
|
};
|
|
7628
7697
|
|
|
7629
7698
|
// 1. Fetch step_4 output.
|
|
7699
|
+
// HC-GF-074: the attempt this run verified — reported with the verdict below.
|
|
7700
|
+
let verifiedAttempt;
|
|
7630
7701
|
let output, runConfig;
|
|
7631
7702
|
try {
|
|
7632
7703
|
const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
|
|
7633
7704
|
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7634
7705
|
const r = await client.get(url);
|
|
7635
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;
|
|
7636
7710
|
// best-effort: pull the run's ci_local_command for the default
|
|
7637
7711
|
try {
|
|
7638
7712
|
const runR = await client.get(`/orchestrate/${workflowId}`);
|
|
@@ -7648,7 +7722,7 @@ program
|
|
|
7648
7722
|
// 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
|
|
7649
7723
|
// Condition 6 presupposes 5.
|
|
7650
7724
|
const blocks = extractFileBlocks(output);
|
|
7651
|
-
const hasWork =
|
|
7725
|
+
const hasWork = hasMaterializableWork(blocks);
|
|
7652
7726
|
if (blocks.noChanges || !hasWork) {
|
|
7653
7727
|
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
7654
7728
|
`✗ ${opts.stepKey} produced no file changes to verify.`);
|
|
@@ -7656,7 +7730,7 @@ program
|
|
|
7656
7730
|
}
|
|
7657
7731
|
let diff;
|
|
7658
7732
|
try {
|
|
7659
|
-
diff = buildDiff(blocks,
|
|
7733
|
+
diff = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() })).diff;
|
|
7660
7734
|
} catch (e) {
|
|
7661
7735
|
if (e instanceof MaterializeError) {
|
|
7662
7736
|
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
@@ -7665,17 +7739,17 @@ program
|
|
|
7665
7739
|
}
|
|
7666
7740
|
throw e;
|
|
7667
7741
|
}
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
}
|
|
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`);
|
|
7673
7747
|
|
|
7674
7748
|
// 3. Isolated worktree from HEAD.
|
|
7675
7749
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
|
|
7676
7750
|
const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
|
|
7677
7751
|
fs.writeFileSync(diffFile, diff);
|
|
7678
|
-
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
|
|
7679
7753
|
let cleanupDone = false;
|
|
7680
7754
|
const cleanup = () => {
|
|
7681
7755
|
if (cleanupDone || opts.keep) return;
|
|
@@ -7704,9 +7778,9 @@ program
|
|
|
7704
7778
|
execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
|
|
7705
7779
|
{ cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7706
7780
|
|
|
7707
|
-
// 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.
|
|
7708
7782
|
let applied = true, applyDetail = '';
|
|
7709
|
-
try {
|
|
7783
|
+
if (!alreadyInHead) try {
|
|
7710
7784
|
execFileSync('git', ['-C', wt, 'apply', diffFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7711
7785
|
} catch (e) {
|
|
7712
7786
|
applied = false;
|
|
@@ -7732,12 +7806,14 @@ program
|
|
|
7732
7806
|
if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
7733
7807
|
const run = spawnSync(command, {
|
|
7734
7808
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7735
|
-
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
7809
|
+
timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
|
|
7736
7810
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7737
7811
|
});
|
|
7738
7812
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
7739
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
|
|
7740
|
-
|
|
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' ? '○' : '✗';
|
|
7741
7817
|
|
|
7742
7818
|
// HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
|
|
7743
7819
|
// The full log is written OUTSIDE the worktree (cleanup removes the worktree)
|
|
@@ -7782,7 +7858,7 @@ program
|
|
|
7782
7858
|
for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
|
|
7783
7859
|
const gr = spawnSync(gate.command, {
|
|
7784
7860
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7785
|
-
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
7861
|
+
timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
|
|
7786
7862
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7787
7863
|
});
|
|
7788
7864
|
const c = classifyGateRun({
|
|
@@ -7822,11 +7898,20 @@ program
|
|
|
7822
7898
|
verdict: reportVerdict,
|
|
7823
7899
|
exitCode,
|
|
7824
7900
|
detail: reportDetail,
|
|
7825
|
-
attempt: opts.attempt ? Number(opts.attempt) :
|
|
7901
|
+
attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
|
|
7826
7902
|
headSha,
|
|
7827
7903
|
gates: reportGates.length ? reportGates : undefined,
|
|
7828
7904
|
});
|
|
7829
|
-
if (opts.format !== 'json')
|
|
7905
|
+
if (opts.format !== 'json') {
|
|
7906
|
+
// HC-GF-070: name the gate that is actually configured. This said "(advisory)"
|
|
7907
|
+
// unconditionally, and printed it on a run where `enforce` was auto-approving
|
|
7908
|
+
// step_4 on the strength of that very verdict.
|
|
7909
|
+
const { verdictReportLine } = require('./lib/auto-verify');
|
|
7910
|
+
const { readVerificationGateConfig } = require('./lib/pipeline-config');
|
|
7911
|
+
let gate = 'advisory';
|
|
7912
|
+
try { gate = readVerificationGateConfig(process.cwd()) || 'advisory'; } catch { /* label only */ }
|
|
7913
|
+
console.log(verdictReportLine(reportVerdict, gate));
|
|
7914
|
+
}
|
|
7830
7915
|
} catch (e) {
|
|
7831
7916
|
if (opts.format !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
7832
7917
|
}
|
|
@@ -7851,7 +7936,12 @@ program
|
|
|
7851
7936
|
else console.log(renderReport(report));
|
|
7852
7937
|
// Exit non-zero when the operator has something to do, so `hone run-report`
|
|
7853
7938
|
// is usable as a gate in a script and not merely as prose.
|
|
7854
|
-
|
|
7939
|
+
//
|
|
7940
|
+
// HC-GF-065: count the GATING items, not every item. An advisory — currently
|
|
7941
|
+
// "verification never ran" — is shown in full but must not flip this exit code:
|
|
7942
|
+
// it fires on every unverified terminal run, and gating on it would turn a
|
|
7943
|
+
// passing adopter CI red the day they upgrade. HC-026c: report before you gate.
|
|
7944
|
+
process.exit((report.gatingCount ?? report.needsYou.length) ? 1 : 0);
|
|
7855
7945
|
} catch (e) {
|
|
7856
7946
|
console.error(`run-report failed: ${e.response?.data?.error || e.message}`);
|
|
7857
7947
|
process.exit(1);
|
|
@@ -7882,8 +7972,8 @@ program
|
|
|
7882
7972
|
.action(async (workflowId, opts) => {
|
|
7883
7973
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7884
7974
|
const os = require('os');
|
|
7885
|
-
const { extractFileBlocks, buildDiff,
|
|
7886
|
-
const {
|
|
7975
|
+
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7976
|
+
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
|
|
7887
7977
|
const {
|
|
7888
7978
|
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
7889
7979
|
} = require('./lib/emit-pr');
|
|
@@ -7923,7 +8013,7 @@ program
|
|
|
7923
8013
|
// 2. Materialize the diff (shared with check-patch/verify-patch,
|
|
7924
8014
|
// followup-23/27). Condition 7 presupposes 5.
|
|
7925
8015
|
const blocks = extractFileBlocks(output);
|
|
7926
|
-
const hasWork =
|
|
8016
|
+
const hasWork = hasMaterializableWork(blocks);
|
|
7927
8017
|
if (blocks.noChanges || !hasWork) {
|
|
7928
8018
|
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
7929
8019
|
`✗ ${opts.stepKey} produced no file changes to emit.`);
|
|
@@ -7931,7 +8021,7 @@ program
|
|
|
7931
8021
|
}
|
|
7932
8022
|
let diff;
|
|
7933
8023
|
try {
|
|
7934
|
-
diff = buildDiff(blocks,
|
|
8024
|
+
diff = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() })).diff;
|
|
7935
8025
|
} catch (e) {
|
|
7936
8026
|
if (e instanceof MaterializeError) {
|
|
7937
8027
|
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
@@ -7941,8 +8031,9 @@ program
|
|
|
7941
8031
|
throw e;
|
|
7942
8032
|
}
|
|
7943
8033
|
if (!diff.trim()) {
|
|
7944
|
-
|
|
7945
|
-
|
|
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.`);
|
|
7946
8037
|
return;
|
|
7947
8038
|
}
|
|
7948
8039
|
|
|
@@ -7989,7 +8080,7 @@ program
|
|
|
7989
8080
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
|
|
7990
8081
|
const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
|
|
7991
8082
|
fs.writeFileSync(diffFile, diff);
|
|
7992
|
-
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
|
|
7993
8084
|
// `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
|
|
7994
8085
|
// before a successful commit (apply fails, verify fails), that empty branch
|
|
7995
8086
|
// (pointing at HEAD) must be torn down too — else "a failing diff never
|
|
@@ -8047,19 +8138,21 @@ program
|
|
|
8047
8138
|
if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
8048
8139
|
const run = spawnSync(command, {
|
|
8049
8140
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8050
|
-
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
8141
|
+
timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
|
|
8051
8142
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8052
8143
|
});
|
|
8053
8144
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8054
|
-
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 });
|
|
8055
8146
|
if (v.verdict === 'pass') {
|
|
8056
8147
|
verifyState = 'pass';
|
|
8057
8148
|
} else {
|
|
8058
8149
|
verifyOk = false;
|
|
8059
|
-
|
|
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' ? '⏱' : '✗';
|
|
8060
8153
|
emit({ ok: false, verdict: v.verdict, command, detail: v.detail },
|
|
8061
|
-
`${sym} verify failed, no branch emitted: ${v.detail}\n (command: ${command})`);
|
|
8062
|
-
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
|
|
8063
8156
|
}
|
|
8064
8157
|
}
|
|
8065
8158
|
|
|
@@ -8219,7 +8312,9 @@ program
|
|
|
8219
8312
|
if (!command) { try { command = readCIGateConfig(repoRoot).local_command; } catch { command = 'make ci'; } }
|
|
8220
8313
|
command = command || 'make ci';
|
|
8221
8314
|
if (opts.format !== 'json') console.log(` running local CI: ${command} (timeout ${opts.timeout}s)`);
|
|
8222
|
-
|
|
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()) });
|
|
8223
8318
|
local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
|
|
8224
8319
|
}
|
|
8225
8320
|
|