@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 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
- console.log('RUN ID STORY STATUS STEP AGE');
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).slice(0, 8).padEnd(9),
5529
- String(r.story_id || '-').slice(0, 21).padEnd(22),
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 the latest workflow run for this story')
5604
- .option('--kill', 'Kill the latest workflow run for this story')
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
- // Resolve: if it looks like a UUID, use as runId directly.
5639
- // Otherwise treat as storyId — will be used for POST /orchestrate body.
5640
- const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(storyIdOrRunId);
5641
- const storyId = isUuid ? null : storyIdOrRunId;
5642
- const runId = isUuid ? storyIdOrRunId : null;
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
- console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
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
- console.log(r.status === 0
5725
- ? ` → verified and recorded (gate: ${decision.gate}). The gate still needs you.`
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(` Non-interactive mode. Approve with: hone run-story ${pollRunId} --approve ${d.stepKey}`);
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
- const approveId = runId || storyIdOrRunId;
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 the current working tree (condition 5)')
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, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
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 (blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0) {
7588
+ if (hasMaterializableWork(blocks)) {
7555
7589
  let built;
7556
7590
  try {
7557
- built = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() }));
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
- emit({ ok: true, verdict: 'no_changes' },
7571
- `○ ${opts.stepKey} file blocks were identical to the tree — nothing to apply.`);
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 the current repo.
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
- execFileSync('git', ['apply', '--check', tmp], {
7598
- cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
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 the working tree — ` +
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, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
7644
- const { resolveCommand, verdictFromRun, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
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 = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
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, nodeMaterializeIO({ cwd: process.cwd() })).diff;
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
- if (!diff.trim()) {
7694
- emit({ ok: true, verdict: 'no_changes' },
7695
- `○ ${opts.stepKey} file blocks matched the tree — nothing to verify.`);
7696
- return;
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 = resolveCommand(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }) // followup-7: file re-read + Makefile/npm-aware default;
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
- const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : '✗';
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) : undefined,
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, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
7925
- const { resolveCommand, verdictFromRun } = require('./lib/verify-patch');
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 = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
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, nodeMaterializeIO({ cwd: process.cwd() })).diff;
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
- emit({ ok: true, verdict: 'no_changes' },
7984
- `○ ${opts.stepKey} file blocks matched the tree — nothing to emit.`);
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 = resolveCommand(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }) // followup-7: file re-read + Makefile/npm-aware default;
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
- const sym = v.verdict === 'timeout' ? '⏱' : '✗';
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 or 4 timeout
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
- const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: Number(opts.timeout) * 1000, stdio: ['ignore', 'inherit', 'inherit'] });
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
 
@@ -164,4 +164,24 @@ function verdictReportLine(verdict, gate) {
164
164
  return ` → reported verdict '${verdict}' to the server (${effect})`;
165
165
  }
166
166
 
167
- module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, verdictReportLine, VERIFIABLE_STEP };
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
  /**