@hone-ai/cli 1.31.0 → 1.32.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
@@ -5609,6 +5609,8 @@ program
5609
5609
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
5610
5610
  .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
5611
  .option('--description <text>', 'HC-GF-010: the story text, inline. Same effect as --story-file.')
5612
+ .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
+ .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
5614
  .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
5615
  .option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
5614
5616
  .option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
@@ -5639,6 +5641,132 @@ program
5639
5641
  const storyId = isUuid ? null : storyIdOrRunId;
5640
5642
  const runId = isUuid ? storyIdOrRunId : null;
5641
5643
 
5644
+ // HC-GF-070: ONE poll loop, reachable from BOTH entry points.
5645
+ //
5646
+ // It used to be reachable from exactly one — the initial `run-story <storyId>` — so
5647
+ // `--approve` posted, printed "Poll: ...", and returned. The caller was told to poll
5648
+ // and, exactly as with the verify-patch nag that motivated HC-GF-064 (5 of 22 runs),
5649
+ // nobody did. Every driver written against this re-implemented the loop and each had
5650
+ // a different bug: a paused gate treated as terminal; a no-op approval hot-looping
5651
+ // nine times; a gate advertised by a run whose driver had already exited. Not three
5652
+ // driver bugs — one missing behaviour, three times.
5653
+ //
5654
+ // Its DECISIONS live in cli/lib/poll-decision.js as a pure function, because calling
5655
+ // the same loop from a second place is only safe if what it decides is pinned.
5656
+ const pollUntilDecision = async (pollRunId) => {
5657
+ const { classifyPollState, POLL_OUTCOMES } = require('./lib/poll-decision');
5658
+ const interval = parseInt(opts.pollInterval, 10) * 1000;
5659
+ // HC-GF-064: at most ONE verification per attempt per process. Without this the
5660
+ // advisory path re-verifies on every poll tick, because advisory never advances
5661
+ // the run and the pause it re-observes is the same pause.
5662
+ const _verifiedAttempts = new Set();
5663
+
5664
+ for (;;) {
5665
+ await new Promise(r => setTimeout(r, interval));
5666
+
5667
+ let status;
5668
+ try {
5669
+ const { data: pollData } = await client.get(`/orchestrate/${pollRunId}`);
5670
+ status = pollData;
5671
+ } catch (e) {
5672
+ console.error(`Poll error: ${e.message}`);
5673
+ continue;
5674
+ }
5675
+
5676
+ const d = classifyPollState(status);
5677
+
5678
+ if (d.outcome === POLL_OUTCOMES.COMPLETED) {
5679
+ console.log(`✓ Workflow completed! Total tokens: ${(d.totalTokens || 0).toLocaleString()}`);
5680
+ process.exit(d.exitCode);
5681
+ }
5682
+ if (d.outcome === POLL_OUTCOMES.TERMINAL_FAILURE) {
5683
+ console.error(`✗ Workflow ${d.runStatus}: ${d.errorMessage}`);
5684
+ process.exit(d.exitCode);
5685
+ }
5686
+ if (d.outcome === POLL_OUTCOMES.EDITOR_PARKED) {
5687
+ console.log(editorParkedGuidance(pollRunId, d.stepKey, d.agent));
5688
+ process.exit(d.exitCode);
5689
+ }
5690
+
5691
+ if (d.outcome === POLL_OUTCOMES.HUMAN_GATE) {
5692
+ console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
5693
+
5694
+ // HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
5695
+ // existed since HC-019n-followup-34/35 and never fired, because nothing ever
5696
+ // ran the executing step. Consent is DERIVED: `ci.local_command` being
5697
+ // explicitly set is the adopter's own statement of how to test this repo (see
5698
+ // cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
5699
+ // Never throws — a failure here must leave the operator exactly where they
5700
+ // were, which is being asked to approve.
5701
+ try {
5702
+ const { decideAutoVerify, announce, shouldRepoll, alreadyVerified } = require('./lib/auto-verify');
5703
+ const decision = decideAutoVerify({
5704
+ stepKey: d.stepKey,
5705
+ repoRoot: process.cwd(),
5706
+ noVerifyFlag: opts.verify === false,
5707
+ });
5708
+ if (decision.run && !alreadyVerified(d.attemptKey, _verifiedAttempts)) {
5709
+ _verifiedAttempts.add(d.attemptKey);
5710
+ console.log(announce(decision, pollRunId));
5711
+ const { spawnSync } = require('child_process');
5712
+ const r = spawnSync(process.execPath,
5713
+ [process.argv[1], 'verify-patch', pollRunId, '--report'],
5714
+ { stdio: 'inherit', env: process.env });
5715
+ // A green verdict under `enforce` AUTO-APPROVES this gate, so the run may
5716
+ // already have moved on. Re-poll rather than prompting for an approval that
5717
+ // is no longer wanted — asking a human to approve something already approved
5718
+ // is how an operator learns to distrust the output.
5719
+ if (shouldRepoll({ gate: decision.gate, verdictOk: r.status === 0,
5720
+ attemptKey: d.attemptKey, verifiedAttempts: null })) {
5721
+ console.log(' → verified; enforce may have advanced the run. Re-checking...');
5722
+ continue;
5723
+ }
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.');
5727
+ }
5728
+ } catch (e) {
5729
+ console.log(` (auto-verify skipped: ${e.message})`);
5730
+ }
5731
+
5732
+ if (process.stdin.isTTY) {
5733
+ const readline = require('readline');
5734
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5735
+ const answer = await new Promise(resolve => rl.question(' Approve? [y/n/kill] ', resolve));
5736
+ rl.close();
5737
+
5738
+ if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
5739
+ try {
5740
+ await client.post(`/orchestrate/${pollRunId}/approve`, { stepKey: d.stepKey });
5741
+ console.log(' Approved — workflow resuming...');
5742
+ } catch (e) {
5743
+ console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
5744
+ }
5745
+ } else if (answer.toLowerCase() === 'kill') {
5746
+ try {
5747
+ await client.post(`/orchestrate/${pollRunId}/kill`, { reason: 'CLI kill' });
5748
+ console.log(' Workflow killed.');
5749
+ process.exit(0);
5750
+ } catch (e) {
5751
+ console.error(` Kill failed: ${e.message}`);
5752
+ }
5753
+ } else {
5754
+ console.log(` Skipped. Resume later: hone run-story ${pollRunId} --status`);
5755
+ process.exit(0);
5756
+ }
5757
+ } else {
5758
+ // Non-interactive (CI): exit 2 (paused, needs approval) — the contract
5759
+ // adopter pipelines already branch on.
5760
+ console.log(` Non-interactive mode. Approve with: hone run-story ${pollRunId} --approve ${d.stepKey}`);
5761
+ process.exit(d.exitCode);
5762
+ }
5763
+ continue;
5764
+ }
5765
+
5766
+ if (d.stepKey) process.stdout.write(` Running: ${d.stepKey} (${d.agent})...\r`);
5767
+ }
5768
+ };
5769
+
5642
5770
  // Status mode
5643
5771
  if (opts.status) {
5644
5772
  const statusId = runId || storyIdOrRunId;
@@ -5706,13 +5834,33 @@ program
5706
5834
  if (opts.approve) {
5707
5835
  const approveId = runId || storyIdOrRunId;
5708
5836
  try {
5709
- const { data } = await client.post(`/orchestrate/${approveId}/approve`, { stepKey: opts.approve });
5837
+ await client.post(`/orchestrate/${approveId}/approve`,
5838
+ { stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true });
5710
5839
  console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
5711
- console.log(`Poll: hone run-story ${approveId} --status`);
5712
5840
  } catch (e) {
5713
5841
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
5714
5842
  process.exit(1);
5715
5843
  }
5844
+ // HC-GF-070: FOLLOW the run to its next decision point instead of returning here.
5845
+ // Returning is what made every automated flow exit blind after each gate, and is
5846
+ // the direct reason HC-GF-064's auto-verify — which lives at the step_4 pause —
5847
+ // never fired outside an interactive terminal. Exit codes are unchanged: 0 done,
5848
+ // 1 failed, 2 next human gate, 3 editor park.
5849
+ if (opts.follow === false) {
5850
+ console.log(`Poll: hone run-story ${approveId} --status`);
5851
+ return;
5852
+ }
5853
+ // The initial invocation runs this inside the start-workflow try/catch; the
5854
+ // approve path has none, and an unhandled rejection here would surface as a bare
5855
+ // stack trace after a successful approval — the approval landed, so the exit code
5856
+ // must say what happened to the RUN, not look like the approve failed.
5857
+ try {
5858
+ await pollUntilDecision(approveId);
5859
+ } catch (e) {
5860
+ console.error(`Approved, but following the run failed: ${e.message}`);
5861
+ console.error(`Poll: hone run-story ${approveId} --status`);
5862
+ process.exit(1);
5863
+ }
5716
5864
  return;
5717
5865
  }
5718
5866
 
@@ -6263,130 +6411,7 @@ program
6263
6411
  console.log(`Poll: ${data.pollUrl}`);
6264
6412
  console.log('');
6265
6413
 
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
- }
6414
+ await pollUntilDecision(data.runId);
6390
6415
  } catch (e) {
6391
6416
  if (e.response?.status === 429) {
6392
6417
  console.error(`Max concurrent workflows reached. ${e.response.data.error}`);
@@ -7826,7 +7851,16 @@ program
7826
7851
  headSha,
7827
7852
  gates: reportGates.length ? reportGates : undefined,
7828
7853
  });
7829
- if (opts.format !== 'json') console.log(` → reported verdict '${reportVerdict}' to the server (advisory)`);
7854
+ if (opts.format !== 'json') {
7855
+ // HC-GF-070: name the gate that is actually configured. This said "(advisory)"
7856
+ // unconditionally, and printed it on a run where `enforce` was auto-approving
7857
+ // step_4 on the strength of that very verdict.
7858
+ const { verdictReportLine } = require('./lib/auto-verify');
7859
+ const { readVerificationGateConfig } = require('./lib/pipeline-config');
7860
+ let gate = 'advisory';
7861
+ try { gate = readVerificationGateConfig(process.cwd()) || 'advisory'; } catch { /* label only */ }
7862
+ console.log(verdictReportLine(reportVerdict, gate));
7863
+ }
7830
7864
  } catch (e) {
7831
7865
  if (opts.format !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
7832
7866
  }
@@ -7851,7 +7885,12 @@ program
7851
7885
  else console.log(renderReport(report));
7852
7886
  // Exit non-zero when the operator has something to do, so `hone run-report`
7853
7887
  // is usable as a gate in a script and not merely as prose.
7854
- process.exit(report.needsYou.length ? 1 : 0);
7888
+ //
7889
+ // HC-GF-065: count the GATING items, not every item. An advisory — currently
7890
+ // "verification never ran" — is shown in full but must not flip this exit code:
7891
+ // it fires on every unverified terminal run, and gating on it would turn a
7892
+ // passing adopter CI red the day they upgrade. HC-026c: report before you gate.
7893
+ process.exit((report.gatingCount ?? report.needsYou.length) ? 1 : 0);
7855
7894
  } catch (e) {
7856
7895
  console.error(`run-report failed: ${e.response?.data?.error || e.message}`);
7857
7896
  process.exit(1);
@@ -146,4 +146,22 @@ function alreadyVerified(attemptKey, verifiedAttempts) {
146
146
  return Boolean(verifiedAttempts && verifiedAttempts.has(attemptKey));
147
147
  }
148
148
 
149
- module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, VERIFIABLE_STEP };
149
+ /**
150
+ * The line printed after a verdict is posted.
151
+ *
152
+ * HC-GF-070: this used to end in a hardcoded `(advisory)`. On the acceptance run for that
153
+ * story the gate was `enforce` and the verdict AUTO-APPROVED step_4 — the run advanced
154
+ * with no human involved — while the CLI told the operator the verdict was advisory. A
155
+ * constant cannot be wrong about the run it is describing, which is the same
156
+ * cannot-fail shape as a check that never fires, arriving in the output layer: it
157
+ * understates the one case where the verdict actually changed something.
158
+ */
159
+ function verdictReportLine(verdict, gate) {
160
+ const mode = gate === 'enforce' ? 'enforce' : 'advisory';
161
+ const effect = mode === 'enforce'
162
+ ? 'enforce — a passing verdict approves this gate'
163
+ : 'advisory — records only, the gate still needs a human';
164
+ return ` → reported verdict '${verdict}' to the server (${effect})`;
165
+ }
166
+
167
+ module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, verdictReportLine, VERIFIABLE_STEP };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+ /**
3
+ * poll-decision.js — HC-GF-070: what the poll loop should DO about one status payload.
4
+ *
5
+ * WHY THIS IS ITS OWN MODULE. The loop had exactly one entry point — the initial
6
+ * `run-story <storyId>` — and `--approve` returned without polling, so every automated
7
+ * flow exited blind after each gate. Making `--approve` poll means calling the same loop
8
+ * from a second place, and the only way to do that safely is for its DECISIONS to be a
9
+ * pure function of the payload rather than control flow tangled through 130 lines of
10
+ * printing, prompting and process.exit.
11
+ *
12
+ * The ordering of the branches is the load-bearing part, and each ordering below is a
13
+ * bug a hand-written driver actually shipped in this session:
14
+ *
15
+ * terminal BEFORE gate a killed run carrying a stale `awaitingApproval` offered a
16
+ * gate to a driver whose run was already dead
17
+ * park BEFORE human gate an editor-parked step is satisfied by the EDITOR; the
18
+ * server refuses `--approve` there, so a driver that read it
19
+ * as a human gate would approve into a rejection forever
20
+ * awaitingApproval required a run can be `paused` while the server decides what is
21
+ * next; treating that as a gate approves a step nobody asked
22
+ * for, which is HC-GF-069 in miniature
23
+ *
24
+ * Everything unknown classifies as RUNNING. Inventing a terminal state for a status we
25
+ * have not seen would end a live run; polling one more tick costs an interval.
26
+ */
27
+
28
+ const { isEditorParked } = require('./editor-park');
29
+
30
+ const POLL_OUTCOMES = Object.freeze({
31
+ COMPLETED: 'completed',
32
+ TERMINAL_FAILURE: 'terminal_failure',
33
+ EDITOR_PARKED: 'editor_parked',
34
+ HUMAN_GATE: 'human_gate',
35
+ RUNNING: 'running',
36
+ });
37
+
38
+ /** Exit codes are a CONTRACT with adopter CI: 2 = human gate, 3 = editor park. */
39
+ const EXIT = Object.freeze({
40
+ [POLL_OUTCOMES.COMPLETED]: 0,
41
+ [POLL_OUTCOMES.TERMINAL_FAILURE]: 1,
42
+ [POLL_OUTCOMES.HUMAN_GATE]: 2,
43
+ [POLL_OUTCOMES.EDITOR_PARKED]: 3,
44
+ [POLL_OUTCOMES.RUNNING]: null,
45
+ });
46
+
47
+ const decide = (outcome, extra = {}) => ({ outcome, exitCode: EXIT[outcome], ...extra });
48
+
49
+ /**
50
+ * @param {object|null|undefined} status a `GET /orchestrate/:runId` payload, straight
51
+ * off the network and therefore not trusted to
52
+ * have any particular shape
53
+ * @returns {{outcome:string, exitCode:number|null, stepKey?:string, agent?:string,
54
+ * attemptKey?:string, totalTokens?:number, errorMessage?:string}}
55
+ */
56
+ function classifyPollState(status) {
57
+ const s = (status && typeof status === 'object') ? status : {};
58
+ const steps = Array.isArray(s.steps) ? s.steps : [];
59
+
60
+ if (s.status === 'completed') {
61
+ return decide(POLL_OUTCOMES.COMPLETED, { totalTokens: s.totalTokens || 0 });
62
+ }
63
+ if (s.status === 'failed' || s.status === 'killed') {
64
+ return decide(POLL_OUTCOMES.TERMINAL_FAILURE, {
65
+ errorMessage: s.errorMessage || 'unknown', runStatus: s.status,
66
+ });
67
+ }
68
+
69
+ if (s.status === 'paused' && s.awaitingApproval) {
70
+ const step = steps.find((x) => x && x.stepKey === s.currentStep);
71
+ if (isEditorParked(s)) {
72
+ return decide(POLL_OUTCOMES.EDITOR_PARKED, {
73
+ stepKey: s.currentStep, agent: step && step.agent,
74
+ });
75
+ }
76
+ // HC-GF-064 keys its at-most-once verification on the ATTEMPT: a retried step is a
77
+ // new artifact and deserves a new verdict, while re-verifying the SAME attempt on
78
+ // every tick is the unbounded advisory loop that story had to fix before merge.
79
+ const attempt = (step && step.attempt) || 1;
80
+ return decide(POLL_OUTCOMES.HUMAN_GATE, {
81
+ stepKey: s.currentStep, agent: step && step.agent,
82
+ attemptKey: `${s.currentStep}#${attempt}`,
83
+ });
84
+ }
85
+
86
+ const running = steps.find((x) => x && x.status === 'running');
87
+ return decide(POLL_OUTCOMES.RUNNING, {
88
+ stepKey: running && running.stepKey, agent: running && running.agent,
89
+ });
90
+ }
91
+
92
+ module.exports = { classifyPollState, POLL_OUTCOMES, EXIT };
package/lib/run-report.js CHANGED
@@ -48,10 +48,34 @@ function buildReport(run, extra = {}) {
48
48
  // ── NEEDS YOU ──────────────────────────────────────────────────────────────
49
49
  // 1. The run itself is not finished.
50
50
  if (run.status === 'paused' && run.currentStep) {
51
- needsYou.push({
52
- what: `run is paused at ${run.currentStep} — a gate is waiting on your judgment`,
53
- how: `hone run-story ${run.runId} --approve ${run.currentStep}`,
54
- });
51
+ // HC-GF-046: a paused run is not always a gate.
52
+ //
53
+ // Run 3dbde316 sat `paused` at step_4 with `MAX_RETRIES_EXCEEDED: step_4 retried 2
54
+ // times (max 2)`. That LOOKS like an available approval — paused, with a pending gate
55
+ // — but `checkGuardrails` refuses step_4 before the LLM call, so approving can never
56
+ // move the run. The line described the run's SHAPE and not its CAUSE, and so invited
57
+ // the one action the orchestrator had already decided to refuse. A driver looped on
58
+ // it; a human tried once and had to read the database.
59
+ //
60
+ // Matched on the specific messages the guardrail acts on, NOT on "any error_message".
61
+ // A resumable pause — a local rate-limit window, for instance — is not a refusal, and
62
+ // silencing a real gate on those would be the over-reach that makes the surface
63
+ // untrustworthy in the other direction.
64
+ const blocked = String(run.errorMessage || '')
65
+ .match(/^(MAX_RETRIES_EXCEEDED|MAX_STEPS_EXCEEDED|BUDGET_EXCEEDED|GARBAGE_OUTPUT)[^\n]*/);
66
+ if (blocked) {
67
+ needsYou.push({
68
+ what: `run CANNOT proceed at ${run.currentStep} — ${blocked[0].slice(0, 160)}. `
69
+ + 'Approving will be refused by the guardrail, so this is a decision, not a gate',
70
+ how: `hone run-story ${run.runId} --kill (or raise guardrails.max_retries_per_step `
71
+ + 'in .pipeline-config.yml and re-run the story)',
72
+ });
73
+ } else {
74
+ needsYou.push({
75
+ what: `run is paused at ${run.currentStep} — a gate is waiting on your judgment`,
76
+ how: `hone run-story ${run.runId} --approve ${run.currentStep}`,
77
+ });
78
+ }
55
79
  }
56
80
  if (run.status === 'failed') {
57
81
  needsYou.push({
@@ -59,6 +83,19 @@ function buildReport(run, extra = {}) {
59
83
  how: `hone show step ${run.runId} ${run.currentStep || '<stepKey>'}`,
60
84
  });
61
85
  }
86
+ // HC-GF-065: a KILLED run needs you too.
87
+ //
88
+ // The predicate above is `status === 'failed'`, which `killed` does not match — so nine
89
+ // terminated runs in the corpus printed `NEEDS YOU (0)` and "nothing outstanding". A run
90
+ // somebody stopped mid-flight has work outstanding by definition, and the digest told
91
+ // the operator it did not. This is HC-GF-044's defect one field over, and its own lesson
92
+ // states the rule: a well-formed "nothing" is only as true as the predicate behind it.
93
+ if (run.status === 'killed') {
94
+ needsYou.push({
95
+ what: `run was KILLED before it finished${run.errorMessage ? `: ${String(run.errorMessage).slice(0, 160)}` : ''}`,
96
+ how: `hone run-story ${run.runId} --status (re-run the story, or read the last step)`,
97
+ });
98
+ }
62
99
 
63
100
  // 1b. HC-GF-044: a STEP can be failed while the RUN says completed.
64
101
  //
@@ -187,6 +224,32 @@ function buildReport(run, extra = {}) {
187
224
  }
188
225
  }
189
226
 
227
+ // HC-GF-065: NO verdict at all is itself an item, not a footnote under a zero.
228
+ //
229
+ // Measured over the corpus with this very function: 10 of 21 terminal runs printed
230
+ // "nothing outstanding", and the sentence is false in every one of them. Nothing was
231
+ // executed against the diff, so the report cannot know whether anything is outstanding
232
+ // — the two reviewing steps READ, they do not run anything, which is exactly why
233
+ // HC-GF-064's verdict exists.
234
+ //
235
+ // Scoped to terminal runs on purpose. A run still in flight has not reached the point
236
+ // where a verdict is expected, and flagging it would make the section noise on every
237
+ // poll — which is how the old nag reached 5-of-22 and got ignored.
238
+ if (['completed', 'failed', 'killed'].includes(run.status) && !lastVerification) {
239
+ needsYou.push({
240
+ what: 'verification never ran for this run — no test, lint or typecheck result was '
241
+ + 'reported, so nothing here is known to build',
242
+ how: `hone verify-patch ${run.runId} step_4 --report`,
243
+ // HC-GF-065: ADVISORY. `hone run-report` exits 1 when needsYou is non-empty, and
244
+ // the comment at that exit says why — "so run-report is usable as a GATE in a
245
+ // script". This item fires on every unverified terminal run (15 of 21 in the
246
+ // corpus, most of them otherwise healthy), so gating on it would turn an adopter's
247
+ // passing CI red overnight. HC-026c is precisely this rule: a new signal REPORTS
248
+ // before it GATES. Visible in the report, absent from the exit code.
249
+ advisory: true,
250
+ });
251
+ }
252
+
190
253
  // ── WORTH KNOWING ──────────────────────────────────────────────────────────
191
254
  // Retries: the step ran more than once. HC-GF-041(c): this used to append "(its
192
255
  // first output was rejected)", which the report never checked and which is often
@@ -236,6 +299,9 @@ function buildReport(run, extra = {}) {
236
299
  worthKnowing,
237
300
  steps,
238
301
  verifications,
302
+ // HC-GF-065: what the exit code should count. Advisory items are shown but do not
303
+ // gate, so a new signal cannot silently change the meaning of a script's exit status.
304
+ gatingCount: needsYou.filter(i => !i.advisory).length,
239
305
  };
240
306
  }
241
307
 
@@ -278,11 +344,13 @@ function renderReport(r) {
278
344
  checked.push('verification');
279
345
  for (const g of last.gates || []) checked.push(g.name);
280
346
  }
347
+ // HC-GF-065: the empty state is only reachable when something was actually CHECKED.
348
+ // With no verdict there is nothing executed against the diff, so "nothing outstanding"
349
+ // asserts knowledge the report does not have — the missing verification is now an
350
+ // ITEM above rather than a footnote under a zero, and this branch is unreachable in
351
+ // that case. Kept as a genuine all-clear for a run that WAS verified.
281
352
  L.push('NEEDS YOU (0)');
282
- L.push(checked.length
283
- ? ` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`
284
- : ' nothing outstanding, but NO verification was reported for this run — '
285
- + 'run `hone verify-patch <runId> step_4 --report` before trusting that');
353
+ L.push(` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`);
286
354
  }
287
355
  L.push('');
288
356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {