@hone-ai/cli 1.32.0 → 1.34.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),
@@ -5547,7 +5552,9 @@ program
5547
5552
  const config = getConfig();
5548
5553
  const client = api(config);
5549
5554
  try {
5550
- const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction`);
5555
+ // HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
5556
+ // knows, so an older CLI — which sends nothing — keeps being served contract 1.
5557
+ const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction?contracts=1,2`);
5551
5558
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5552
5559
  console.log(`Step: ${data.stepKey} (${data.agent})`);
5553
5560
  console.log(`Contract: ${data.contractVersion}`);
@@ -5568,15 +5575,36 @@ program
5568
5575
  .command('submit-step <runId> <stepKey>')
5569
5576
  .description('Submit editor-executed output for an awaiting_editor step (HC-COMM-010 B3)')
5570
5577
  .option('--file <path>', 'Read the step output from this file')
5578
+ .option('--from-tree', 'HC-GF-091: render the artifact from the working tree against HEAD instead of reading one file — the agent writes files with its own tools, so no single model response has to hold the whole build')
5579
+ .option('--test-report <text>', 'With --from-tree: the test-run report to carry verbatim. Never invented when absent.')
5571
5580
  .option('--contract <version>', 'Instruction contract version (from step-instruction)', '1')
5572
5581
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
5573
5582
  .action(async (runId, stepKey, opts) => {
5574
5583
  const config = getConfig();
5575
5584
  const client = api(config);
5576
5585
  try {
5577
- if (!opts.file) { console.error('Provide the output via --file <path> (the text your editor produced).'); process.exit(1); }
5586
+ if (opts.file && opts.fromTree) { console.error('Use --file OR --from-tree, not both: they are two different claims about where the build is.'); process.exit(1); }
5587
+ // HC-GF-092: under contract 2 the instruction told the agent to WRITE the files with its own
5588
+ // tools. A hand-assembled --file artifact means the instruction and the submission disagree
5589
+ // about where the build is, and the server cannot see that — only this side can.
5590
+ if (String(opts.contract) === '2' && !opts.fromTree) {
5591
+ console.error('Contract 2 is the from-tree contract: the instruction told the agent to write the files with its own tools. Re-run with --from-tree, or fetch a contract 1 instruction if you really did assemble the output by hand.');
5592
+ process.exit(1);
5593
+ }
5594
+ if (!opts.file && !opts.fromTree) { console.error('Provide the output via --file <path> (the text your editor produced), or --from-tree to render it from the working tree.'); process.exit(1); }
5578
5595
  const fs = require('fs');
5579
- const output = fs.readFileSync(opts.file, 'utf8');
5596
+ let output;
5597
+ if (opts.fromTree) {
5598
+ const { renderFromTree } = require('./lib/tree-artifact');
5599
+ const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport });
5600
+ output = rendered.text;
5601
+ // Say what was left out BEFORE the submission, not after: a file the tree has and the
5602
+ // artifact does not is a decision the operator has to be able to see.
5603
+ for (const s2 of rendered.skipped) console.error(`not included: ${s2.path} — ${s2.reason}`);
5604
+ console.error(`from-tree: ${rendered.files.length} file(s), ${rendered.deletes.length} deletion(s), ${rendered.skipped.length} skipped`);
5605
+ } else {
5606
+ output = fs.readFileSync(opts.file, 'utf8');
5607
+ }
5580
5608
  const { data } = await client.post(
5581
5609
  `/orchestrate/${encodeURIComponent(runId)}/step/${encodeURIComponent(stepKey)}/editor-output`,
5582
5610
  { contractVersion: opts.contract, output }
@@ -5598,17 +5626,18 @@ program
5598
5626
  .command('run-story <storyId>')
5599
5627
  .description('Run the SDLC pipeline for a story via the orchestrator')
5600
5628
  .option('--mode <mode>', 'Execution mode: interactive or batch', 'interactive')
5601
- .option('--repo <name>', 'Repository name override (default: directory name)')
5629
+ .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
5630
  .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')
5631
+ .option('--status', 'Show status of a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
5632
+ .option('--kill', 'Kill a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
5605
5633
  .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)')
5634
+ .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
5635
  .option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
5608
5636
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
5609
5637
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
5610
5638
  .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
5639
  .option('--description <text>', 'HC-GF-010: the story text, inline. Same effect as --story-file.')
5640
+ .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
5641
  .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
5642
  .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
5643
  .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 +5664,25 @@ program
5635
5664
  }
5636
5665
  } catch { /* never block a run on a banner */ }
5637
5666
 
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;
5667
+ const { resolveRunTarget, isRunId } = require('./lib/run-target');
5668
+ const isUuid = isRunId(storyIdOrRunId);
5669
+ const repoName = opts.repo || path.basename(process.cwd());
5670
+
5671
+ // HC-GF-106: --status, --kill and --approve act on an EXISTING run, and all three resolve their
5672
+ // argument here. Each used to forward it verbatim (`runId || storyIdOrRunId`), so a story ID was
5673
+ // posted as a run ID and answered `Run not found` — while --help promised the story's latest run.
5674
+ // A story is scoped to `repoName`, the name the start path below records, so it resolves to a run
5675
+ // this checkout started. Never falls back to the argument: no run found means exit 1.
5676
+ const existingRunId = async (flag) => {
5677
+ const t = await resolveRunTarget({ client, idOrStory: storyIdOrRunId, repoName, flag });
5678
+ if (t.error) { console.error(t.error); process.exit(1); }
5679
+ if (t.source === 'story') {
5680
+ // stderr: stdout is what a `--status --format json` caller parses, so the note stays out of it.
5681
+ const at = t.run.current_step ? ` at ${t.run.current_step}` : '';
5682
+ console.error(`→ ${storyIdOrRunId}: latest run in ${repoName} is ${t.runId} (${t.run.status}${at})`);
5683
+ }
5684
+ return t.runId;
5685
+ };
5643
5686
 
5644
5687
  // HC-GF-070: ONE poll loop, reachable from BOTH entry points.
5645
5688
  //
@@ -5688,8 +5731,20 @@ program
5688
5731
  process.exit(d.exitCode);
5689
5732
  }
5690
5733
 
5691
- if (d.outcome === POLL_OUTCOMES.HUMAN_GATE) {
5692
- console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
5734
+ if (d.outcome === POLL_OUTCOMES.HUMAN_GATE || d.outcome === POLL_OUTCOMES.EXHAUSTED) {
5735
+ // HC-GF-075: an exhausted step is a decision, not a gate on an artifact. Say what approving
5736
+ // does, and do NOT auto-verify — the attempt on record is the one the validator rejected,
5737
+ // and a verdict on it would read as a verdict on the build.
5738
+ const exhausted = d.outcome === POLL_OUTCOMES.EXHAUSTED;
5739
+ if (exhausted) {
5740
+ console.log(`⏸ Step ${d.stepKey} (${d.agent || 'unknown'}) used up its format retries and paused.`);
5741
+ console.log(` ${String(d.errorMessage || '').slice(0, 240)}`);
5742
+ console.log(` Inspect: hone show step ${pollRunId} ${d.stepKey}`);
5743
+ console.log(` Approving starts another cycle of ${d.stepKey} with a fresh format budget; it does not `
5744
+ + 'accept an earlier attempt. guardrails.max_retries_per_step caps the cycles.');
5745
+ } else {
5746
+ console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
5747
+ }
5693
5748
 
5694
5749
  // HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
5695
5750
  // existed since HC-019n-followup-34/35 and never fired, because nothing ever
@@ -5698,8 +5753,8 @@ program
5698
5753
  // cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
5699
5754
  // Never throws — a failure here must leave the operator exactly where they
5700
5755
  // were, which is being asked to approve.
5701
- try {
5702
- const { decideAutoVerify, announce, shouldRepoll, alreadyVerified } = require('./lib/auto-verify');
5756
+ if (!exhausted) try {
5757
+ const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine } = require('./lib/auto-verify');
5703
5758
  const decision = decideAutoVerify({
5704
5759
  stepKey: d.stepKey,
5705
5760
  repoRoot: process.cwd(),
@@ -5721,9 +5776,8 @@ program
5721
5776
  console.log(' → verified; enforce may have advanced the run. Re-checking...');
5722
5777
  continue;
5723
5778
  }
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.');
5779
+ // HC-GF-008: keyed by verify-patch's exit code — "did not pass" only when a command ran and failed.
5780
+ console.log(verifyOutcomeLine(r.status, decision.gate));
5727
5781
  }
5728
5782
  } catch (e) {
5729
5783
  console.log(` (auto-verify skipped: ${e.message})`);
@@ -5732,7 +5786,7 @@ program
5732
5786
  if (process.stdin.isTTY) {
5733
5787
  const readline = require('readline');
5734
5788
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5735
- const answer = await new Promise(resolve => rl.question(' Approve? [y/n/kill] ', resolve));
5789
+ 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
5790
  rl.close();
5737
5791
 
5738
5792
  if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
@@ -5757,7 +5811,9 @@ program
5757
5811
  } else {
5758
5812
  // Non-interactive (CI): exit 2 (paused, needs approval) — the contract
5759
5813
  // adopter pipelines already branch on.
5760
- console.log(` Non-interactive mode. Approve with: hone run-story ${pollRunId} --approve ${d.stepKey}`);
5814
+ console.log(exhausted
5815
+ ? ` Non-interactive mode. Retry with: hone run-story ${pollRunId} --approve ${d.stepKey} (or --kill)`
5816
+ : ` Non-interactive mode. Approve with: hone run-story ${pollRunId} --approve ${d.stepKey}`);
5761
5817
  process.exit(d.exitCode);
5762
5818
  }
5763
5819
  continue;
@@ -5769,8 +5825,8 @@ program
5769
5825
 
5770
5826
  // Status mode
5771
5827
  if (opts.status) {
5772
- const statusId = runId || storyIdOrRunId;
5773
5828
  try {
5829
+ const statusId = await existingRunId('--status');
5774
5830
  const { data } = await client.get(`/orchestrate/${statusId}`);
5775
5831
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5776
5832
  console.log('');
@@ -5819,8 +5875,8 @@ program
5819
5875
 
5820
5876
  // Kill mode
5821
5877
  if (opts.kill) {
5822
- const killId = runId || storyIdOrRunId;
5823
5878
  try {
5879
+ const killId = await existingRunId('--kill');
5824
5880
  const { data } = await client.post(`/orchestrate/${killId}/kill`, { reason: 'CLI kill' });
5825
5881
  console.log(`Workflow ${data.runId} killed.`);
5826
5882
  } catch (e) {
@@ -5832,10 +5888,12 @@ program
5832
5888
 
5833
5889
  // Approve mode
5834
5890
  if (opts.approve) {
5835
- const approveId = runId || storyIdOrRunId;
5891
+ let approveId;
5836
5892
  try {
5893
+ approveId = await existingRunId(`--approve ${opts.approve}`);
5837
5894
  await client.post(`/orchestrate/${approveId}/approve`,
5838
- { stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true });
5895
+ { stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
5896
+ acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
5839
5897
  console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
5840
5898
  } catch (e) {
5841
5899
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
@@ -5871,7 +5929,6 @@ program
5871
5929
  process.exit(1);
5872
5930
  }
5873
5931
 
5874
- const repoName = opts.repo || path.basename(process.cwd());
5875
5932
  let branch = opts.branch;
5876
5933
  if (!branch) {
5877
5934
  try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); } catch { branch = null; }
@@ -7509,7 +7566,7 @@ showCmd
7509
7566
  // to apply — structure is not applicability.
7510
7567
  program
7511
7568
  .command('check-patch <workflowId>')
7512
- .description('Verify step_4\'s diff applies to the current working tree (condition 5)')
7569
+ .description('Verify step_4\'s diff applies to HEAD — the tree verify-patch and emit-pr apply it to (condition 5)')
7513
7570
  .option('--step-key <key>', 'Step to check (default: step_4)', 'step_4')
7514
7571
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7515
7572
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
@@ -7517,7 +7574,7 @@ program
7517
7574
  const { execFileSync } = require('child_process');
7518
7575
  const os = require('os');
7519
7576
  const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
7520
- const { extractFileBlocks, buildDiff, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
7577
+ const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
7521
7578
  const { gitEnv } = require('./lib/git-env');
7522
7579
  const config = getConfig();
7523
7580
  const client = api(config);
@@ -7551,10 +7608,10 @@ program
7551
7608
  `○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
7552
7609
  return;
7553
7610
  }
7554
- if (blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0) {
7611
+ if (hasMaterializableWork(blocks)) {
7555
7612
  let built;
7556
7613
  try {
7557
- built = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() }));
7614
+ built = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() }));
7558
7615
  } catch (e) {
7559
7616
  if (e instanceof MaterializeError) {
7560
7617
  emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
@@ -7567,8 +7624,9 @@ program
7567
7624
  source = 'file-blocks';
7568
7625
  noChanges = built.partCount === 0;
7569
7626
  if (noChanges) {
7570
- emit({ ok: true, verdict: 'no_changes' },
7571
- `○ ${opts.stepKey} file blocks were identical to the tree — nothing to apply.`);
7627
+ // HC-GF-077: identical to HEAD, not to the working copy — the editor's uncommitted edits are not HEAD.
7628
+ emit({ ok: true, verdict: 'already_in_head', source: 'file-blocks', detail: `HEAD already contains ${opts.stepKey}'s files` },
7629
+ `○ HEAD already contains ${opts.stepKey}'s files — nothing to apply.`);
7572
7630
  return;
7573
7631
  }
7574
7632
  } else {
@@ -7587,16 +7645,25 @@ program
7587
7645
  }
7588
7646
  }
7589
7647
 
7590
- // Write to a temp file and run git apply --check against the current repo.
7648
+ // Write to a temp file and run git apply --check against HEAD. HC-GF-077: against an index read from HEAD,
7649
+ // not the working copy — an editor's uncommitted edits are exactly what emit-pr's branch will NOT contain.
7591
7650
  const tmp = path.join(os.tmpdir(), `hone-check-patch-${Date.now()}.diff`);
7592
7651
  let result;
7593
7652
  try {
7594
7653
  fs.writeFileSync(tmp, diff);
7595
7654
  let code = 0, stderr = '';
7596
7655
  try {
7597
- execFileSync('git', ['apply', '--check', tmp], {
7598
- cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
7599
- });
7656
+ const index = path.join(os.tmpdir(), `hone-check-index-${process.pid}-${Date.now()}`);
7657
+ try {
7658
+ try {
7659
+ execFileSync('git', ['read-tree', `--index-output=${index}`, 'HEAD'], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
7660
+ } catch { // no commit yet: every path is new, so check against the empty tree
7661
+ execFileSync('git', ['read-tree', '--empty', `--index-output=${index}`], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
7662
+ }
7663
+ execFileSync('git', ['apply', '--check', '--cached', tmp], {
7664
+ cwd: process.cwd(), env: gitEnv({ GIT_INDEX_FILE: index }), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
7665
+ });
7666
+ } finally { try { fs.unlinkSync(index); } catch { /* best-effort */ } }
7600
7667
  } catch (e) {
7601
7668
  code = e.status || 1;
7602
7669
  stderr = (e.stderr || '').toString();
@@ -7612,7 +7679,7 @@ program
7612
7679
  } else {
7613
7680
  emit({ ok: false, verdict: 'failed', source, detail: result.detail },
7614
7681
  `✗ ${opts.stepKey} diff does NOT apply: ${result.detail}\n` +
7615
- ` The output is a well-formed diff but does not match the working tree — ` +
7682
+ ` The output is a well-formed diff but does not match HEAD — ` +
7616
7683
  `exactly the defect condition 5 catches that condition 4 cannot.`);
7617
7684
  process.exit(2); // distinct exit: 2 = does-not-apply, 1 = no-diff/fetch
7618
7685
  }
@@ -7640,8 +7707,8 @@ program
7640
7707
  .action(async (workflowId, opts) => {
7641
7708
  const { execFileSync, spawnSync } = require('child_process');
7642
7709
  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');
7710
+ const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
7711
+ const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
7645
7712
  const { gitEnv } = require('./lib/git-env');
7646
7713
  const config = getConfig();
7647
7714
  const client = api(config);
@@ -7652,12 +7719,17 @@ program
7652
7719
  };
7653
7720
 
7654
7721
  // 1. Fetch step_4 output.
7722
+ // HC-GF-074: the attempt this run verified — reported with the verdict below.
7723
+ let verifiedAttempt;
7655
7724
  let output, runConfig;
7656
7725
  try {
7657
7726
  const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
7658
7727
  (opts.attempt ? `?attempt=${opts.attempt}` : '');
7659
7728
  const r = await client.get(url);
7660
7729
  output = r.data?.output;
7730
+ // HC-GF-074: remember WHICH attempt this is. Without --attempt the server returns the latest, and the verdict
7731
+ // used to be reported with `attempt: null` — so no verdict on record could say which build it judged.
7732
+ verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7661
7733
  // best-effort: pull the run's ci_local_command for the default
7662
7734
  try {
7663
7735
  const runR = await client.get(`/orchestrate/${workflowId}`);
@@ -7673,7 +7745,7 @@ program
7673
7745
  // 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
7674
7746
  // Condition 6 presupposes 5.
7675
7747
  const blocks = extractFileBlocks(output);
7676
- const hasWork = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
7748
+ const hasWork = hasMaterializableWork(blocks);
7677
7749
  if (blocks.noChanges || !hasWork) {
7678
7750
  emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
7679
7751
  `✗ ${opts.stepKey} produced no file changes to verify.`);
@@ -7681,7 +7753,7 @@ program
7681
7753
  }
7682
7754
  let diff;
7683
7755
  try {
7684
- diff = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() })).diff;
7756
+ diff = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() })).diff;
7685
7757
  } catch (e) {
7686
7758
  if (e instanceof MaterializeError) {
7687
7759
  emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
@@ -7690,17 +7762,17 @@ program
7690
7762
  }
7691
7763
  throw e;
7692
7764
  }
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
- }
7765
+ // HC-GF-077: an empty diff against HEAD means HEAD already contains the step's files. That is nothing to
7766
+ // APPLY, not nothing to VERIFY — the command still runs, on HEAD as it is. The old early return exited 0
7767
+ // without running anything, and auto-verify called that "verified and recorded".
7768
+ const alreadyInHead = !diff.trim();
7769
+ if (alreadyInHead && opts.format !== 'json') console.log(` ○ HEAD already contains ${opts.stepKey}'s files — verifying HEAD as it is`);
7698
7770
 
7699
7771
  // 3. Isolated worktree from HEAD.
7700
7772
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
7701
7773
  const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
7702
7774
  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;
7775
+ 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
7776
  let cleanupDone = false;
7705
7777
  const cleanup = () => {
7706
7778
  if (cleanupDone || opts.keep) return;
@@ -7729,9 +7801,9 @@ program
7729
7801
  execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
7730
7802
  { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
7731
7803
 
7732
- // 4. Apply the diff INTO the worktree (real apply, isolated).
7804
+ // 4. Apply the diff INTO the worktree (real apply, isolated) — unless HEAD already holds it.
7733
7805
  let applied = true, applyDetail = '';
7734
- try {
7806
+ if (!alreadyInHead) try {
7735
7807
  execFileSync('git', ['-C', wt, 'apply', diffFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
7736
7808
  } catch (e) {
7737
7809
  applied = false;
@@ -7757,12 +7829,14 @@ program
7757
7829
  if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7758
7830
  const run = spawnSync(command, {
7759
7831
  cwd: wt, shell: true, encoding: 'utf8',
7760
- timeout: Number(opts.timeout) * 1000, env: gitEnv(),
7832
+ timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
7761
7833
  stdio: ['ignore', 'pipe', 'pipe'],
7762
7834
  });
7763
7835
  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' ? '⏱' : '✗';
7836
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
7837
+ // HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
7838
+ if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
7839
+ const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
7766
7840
 
7767
7841
  // HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
7768
7842
  // The full log is written OUTSIDE the worktree (cleanup removes the worktree)
@@ -7807,7 +7881,7 @@ program
7807
7881
  for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
7808
7882
  const gr = spawnSync(gate.command, {
7809
7883
  cwd: wt, shell: true, encoding: 'utf8',
7810
- timeout: Number(opts.timeout) * 1000, env: gitEnv(),
7884
+ timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
7811
7885
  stdio: ['ignore', 'pipe', 'pipe'],
7812
7886
  });
7813
7887
  const c = classifyGateRun({
@@ -7847,7 +7921,7 @@ program
7847
7921
  verdict: reportVerdict,
7848
7922
  exitCode,
7849
7923
  detail: reportDetail,
7850
- attempt: opts.attempt ? Number(opts.attempt) : undefined,
7924
+ attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
7851
7925
  headSha,
7852
7926
  gates: reportGates.length ? reportGates : undefined,
7853
7927
  });
@@ -7921,8 +7995,8 @@ program
7921
7995
  .action(async (workflowId, opts) => {
7922
7996
  const { execFileSync, spawnSync } = require('child_process');
7923
7997
  const os = require('os');
7924
- const { extractFileBlocks, buildDiff, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
7925
- const { resolveCommand, verdictFromRun } = require('./lib/verify-patch');
7998
+ const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
7999
+ const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
7926
8000
  const {
7927
8001
  EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
7928
8002
  } = require('./lib/emit-pr');
@@ -7962,7 +8036,7 @@ program
7962
8036
  // 2. Materialize the diff (shared with check-patch/verify-patch,
7963
8037
  // followup-23/27). Condition 7 presupposes 5.
7964
8038
  const blocks = extractFileBlocks(output);
7965
- const hasWork = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
8039
+ const hasWork = hasMaterializableWork(blocks);
7966
8040
  if (blocks.noChanges || !hasWork) {
7967
8041
  emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
7968
8042
  `✗ ${opts.stepKey} produced no file changes to emit.`);
@@ -7970,7 +8044,7 @@ program
7970
8044
  }
7971
8045
  let diff;
7972
8046
  try {
7973
- diff = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() })).diff;
8047
+ diff = buildDiff(blocks, headMaterializeIO({ cwd: process.cwd() })).diff;
7974
8048
  } catch (e) {
7975
8049
  if (e instanceof MaterializeError) {
7976
8050
  emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
@@ -7980,8 +8054,9 @@ program
7980
8054
  throw e;
7981
8055
  }
7982
8056
  if (!diff.trim()) {
7983
- emit({ ok: true, verdict: 'no_changes' },
7984
- `○ ${opts.stepKey} file blocks matched the tree — nothing to emit.`);
8057
+ // HC-GF-077: empty against HEAD — the base branch already holds the change, so there is nothing to emit.
8058
+ emit({ ok: true, verdict: 'already_in_head', detail: `HEAD already contains ${opts.stepKey}'s files` },
8059
+ `○ HEAD already contains ${opts.stepKey}'s files — nothing to emit.`);
7985
8060
  return;
7986
8061
  }
7987
8062
 
@@ -8028,7 +8103,7 @@ program
8028
8103
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
8029
8104
  const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
8030
8105
  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;
8106
+ 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
8107
  // `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
8033
8108
  // before a successful commit (apply fails, verify fails), that empty branch
8034
8109
  // (pointing at HEAD) must be torn down too — else "a failing diff never
@@ -8086,19 +8161,21 @@ program
8086
8161
  if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
8087
8162
  const run = spawnSync(command, {
8088
8163
  cwd: wt, shell: true, encoding: 'utf8',
8089
- timeout: Number(opts.timeout) * 1000, env: gitEnv(),
8164
+ timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
8090
8165
  stdio: ['ignore', 'pipe', 'pipe'],
8091
8166
  });
8092
8167
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8093
- const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
8168
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
8094
8169
  if (v.verdict === 'pass') {
8095
8170
  verifyState = 'pass';
8096
8171
  } else {
8097
8172
  verifyOk = false;
8098
- const sym = v.verdict === 'timeout' ? '⏱' : '✗';
8173
+ // HC-GF-008: a command that never started is not a failed verify.
8174
+ const unresolved = v.verdict === 'unresolved';
8175
+ const sym = unresolved ? '○' : v.verdict === 'timeout' ? '⏱' : '✗';
8099
8176
  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
8177
+ `${sym} ${unresolved ? 'verify could not run, no branch emitted' : 'verify failed, no branch emitted'}: ${v.detail}\n (command: ${command})`);
8178
+ exitCode = v.exitCode; // 3 tests-failed, 4 timeout, 7 unresolved
8102
8179
  }
8103
8180
  }
8104
8181
 
@@ -8258,7 +8335,9 @@ program
8258
8335
  if (!command) { try { command = readCIGateConfig(repoRoot).local_command; } catch { command = 'make ci'; } }
8259
8336
  command = command || 'make ci';
8260
8337
  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'] });
8338
+ // HC-GF-008: the adopter's own command, without make state inherited from whoever ran hone.
8339
+ const { verifyCommandEnv } = require('./lib/verify-patch');
8340
+ const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: Number(opts.timeout) * 1000, stdio: ['ignore', 'inherit', 'inherit'], env: verifyCommandEnv(gitEnv()) });
8262
8341
  local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
8263
8342
  }
8264
8343
 
@@ -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
  /**
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-089 — how close a step came to its limits, said before it hits one.
4
+ *
5
+ * The largest local build used 793s of its 900s limit and nothing said so. Until now the only way to
6
+ * learn where a limit bites was to hit it: HC-GF-114 exists because the cut-off path had never fired
7
+ * in the entire run history, and reaching it took lowering a cap by hand. A run one slow story away
8
+ * from a timeout should say so while it still completes.
9
+ *
10
+ * ADVISORY by construction. This never blocks, never routes back and never changes a verdict — it
11
+ * prints. A signal that could fail a run would need a far higher bar than "approaching a limit", and
12
+ * a warning that reads like a rejection trains operators to ignore it.
13
+ */
14
+
15
+ /** HC-GF-080's per-message output cap — what the CLI child actually runs with. */
16
+ const TOKEN_CAP_PER_MESSAGE = 128_000;
17
+
18
+ /** Three quarters. Low enough to give a story's worth of warning, high enough not to fire routinely. */
19
+ const THRESHOLD = 0.75;
20
+
21
+ /** A ratio, or null when there is nothing to judge — a missing limit is not a limit of zero. */
22
+ function ratio(used, limit) {
23
+ const u = Number(used);
24
+ const l = Number(limit);
25
+ if (!Number.isFinite(u) || !Number.isFinite(l) || l <= 0 || u < 0) return null;
26
+ return u / l;
27
+ }
28
+
29
+ /**
30
+ * One line when a step passed THRESHOLD of its time or per-message token budget, else null.
31
+ * @param {object} generation HC-GF-079's generation report, as stored on the step row.
32
+ */
33
+ function headroomLine(generation) {
34
+ const g = generation && typeof generation === 'object' ? generation : {};
35
+
36
+ const timeRatio = ratio(g.elapsed_ms, g.timeout_ms);
37
+ // The BUSIEST message decides: a build whose last message nearly filled the cap is at risk even
38
+ // when the earlier ones were small, and an average would hide exactly that.
39
+ const counts = (Array.isArray(g.output_tokens) ? g.output_tokens : [])
40
+ .map(Number).filter((n) => Number.isFinite(n) && n >= 0);
41
+ const busiest = counts.length ? Math.max(...counts) : null;
42
+ const tokenRatio = busiest === null ? null : ratio(busiest, TOKEN_CAP_PER_MESSAGE);
43
+
44
+ const parts = [];
45
+ if (timeRatio !== null && timeRatio >= THRESHOLD) {
46
+ parts.push(`time ${Math.round(timeRatio * 100)}% `
47
+ + `(${Math.round(Number(g.elapsed_ms) / 1000)}s of ${Math.round(Number(g.timeout_ms) / 1000)}s)`);
48
+ }
49
+ if (tokenRatio !== null && tokenRatio >= THRESHOLD) {
50
+ parts.push(`tokens ${Math.round(tokenRatio * 100)}% `
51
+ + `(${busiest} of ${TOKEN_CAP_PER_MESSAGE} in one message)`);
52
+ }
53
+ if (!parts.length) return null;
54
+ // HC-GF-129: the threshold has no upper bound, so every ratio from 75% to infinity used to get
55
+ // the same reassurance. On run 692b0c29 a call that ran 550,017ms against a 550,000ms ceiling —
56
+ // it TIMED OUT, `complete: false`, the build was cut off — was reported as "not over it".
57
+ // At or past 1.0 the limit was reached, and saying otherwise is simply false.
58
+ const over = (timeRatio !== null && timeRatio >= 1) || (tokenRatio !== null && tokenRatio >= 1);
59
+ return `headroom: ${parts.join(', ')} — `
60
+ + (over ? 'the limit was REACHED: this call was cut off' : 'close to the limit, not over it');
61
+ }
62
+
63
+ module.exports = { headroomLine, TOKEN_CAP_PER_MESSAGE, THRESHOLD };