@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 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,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 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.')
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
- // Resolve: if it looks like a UUID, use as runId directly.
5637
- // Otherwise treat as storyId — will be used for POST /orchestrate body.
5638
- 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);
5639
- const storyId = isUuid ? null : storyIdOrRunId;
5640
- 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
+ };
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
- const approveId = runId || storyIdOrRunId;
5868
+ let approveId;
5708
5869
  try {
5709
- const { data } = await client.post(`/orchestrate/${approveId}/approve`, { stepKey: opts.approve });
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
- // Poll loop
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 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)')
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, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
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 (blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0) {
7588
+ if (hasMaterializableWork(blocks)) {
7530
7589
  let built;
7531
7590
  try {
7532
- built = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() }));
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
- emit({ ok: true, verdict: 'no_changes' },
7546
- `○ ${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.`);
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 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.
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
- execFileSync('git', ['apply', '--check', tmp], {
7573
- cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
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 the working tree — ` +
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, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
7619
- 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');
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 = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
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, nodeMaterializeIO({ cwd: process.cwd() })).diff;
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
- if (!diff.trim()) {
7669
- emit({ ok: true, verdict: 'no_changes' },
7670
- `○ ${opts.stepKey} file blocks matched the tree — nothing to verify.`);
7671
- return;
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 = 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
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
- 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' ? '○' : '✗';
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) : undefined,
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') console.log(` → reported verdict '${reportVerdict}' to the server (advisory)`);
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
- process.exit(report.needsYou.length ? 1 : 0);
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, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
7886
- 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');
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 = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
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, nodeMaterializeIO({ cwd: process.cwd() })).diff;
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
- emit({ ok: true, verdict: 'no_changes' },
7945
- `○ ${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.`);
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 = 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
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
- 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' ? '⏱' : '✗';
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 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
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
- 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()) });
8223
8318
  local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
8224
8319
  }
8225
8320