@hone-ai/cli 1.40.0 → 1.42.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/lib/run-report.js CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  // HC-GF-234: the one wording for a step's unanswered question, shared with `--status`, `show
20
20
  // step` and the human-gate prompt. A pure leaf module, so this file stays pure.
21
- const { normalizeQuestion, questionLines, countPhrase } = require('./operator-question');
21
+ const { normalizeQuestion, questionLines, countPhrase, scrub } = require('./operator-question');
22
22
 
23
23
  /** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
24
24
  const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
@@ -69,6 +69,28 @@ function durationOf(startedAt, endedAt) {
69
69
  const BLOCKING_REASON_CODES = Object.freeze(['MAX_RETRIES_EXCEEDED', 'MAX_TOTAL_STEPS_EXCEEDED']);
70
70
  const BLOCKING_REASON_RE = new RegExp(`^(${BLOCKING_REASON_CODES.join('|')})[^\\n]*`);
71
71
 
72
+ /**
73
+ * HC-GF-030: the pause the architect-consult guard takes. NOT in BLOCKING_REASON_CODES, which is pinned equal to
74
+ * what orchestrator-guardrails.js emits (HC-GF-046b); this one is emitted by workflow-dag.js's
75
+ * validateStepPreConditions. Approving is refused for it too — by the route and by approveGate — so it is a decision
76
+ * and not a gate. The server package and this one cannot share a constant, so
77
+ * tests/regression/HC-GF-030-run-report.test.js pins this string equal to architect-consult.js CONSULT_REQUIRED_CODE.
78
+ */
79
+ const CONSULT_REQUIRED_CODE = 'ARCHITECT_CONSULT_REQUIRED';
80
+ const CONSULT_REQUIRED_RE = new RegExp(`^${CONSULT_REQUIRED_CODE}:[^\\n]*`);
81
+ /** The start of the message a batch run pauses with when its consult failed the mechanical check (orchestrator.js). */
82
+ const CONSULT_NEEDS_PERSON_RE = /^architect consult needs a person to read it[^\n]*/;
83
+ /**
84
+ * HC-GF-379-followup-1: the PAUSE-ERA message. Before HC-GF-379, a consult whose OWN verdict was `checklist_b_completed: false`
85
+ * paused the run with this message. Since HC-GF-379 a false consult routes back to step_0 (workflow-dag.js
86
+ * FAILURE_ROUTING_TABLE.step_0b.step_0b_abort: a retry, at most twice, then the run ends failed), so no new run pauses with it;
87
+ * this regex is kept only for runs that paused under the old routing. Approving one is refused (an `_abort` needs an
88
+ * acknowledgement, and even then nothing records a false verdict), so offering `--approve` here is HC-GF-046 again: the line
89
+ * invites the one action the server has already decided to refuse.
90
+ * tests/regression/HC-GF-030-run-report.test.js pins this branch verbatim with a literal pause-era message.
91
+ */
92
+ const CONSULT_ABORT_RE = /^step_0b: the architect consult did not complete Checklist B[^\n]*/;
93
+
72
94
  /**
73
95
  * HC-GF-046(b), corrected in review: a step that used up its format retries.
74
96
  *
@@ -82,6 +104,22 @@ const BLOCKING_REASON_RE = new RegExp(`^(${BLOCKING_REASON_CODES.join('|')})[^\\
82
104
  */
83
105
  const EXHAUSTED_RE = /^GARBAGE_OUTPUT at (step_[0-9a-z]+)/;
84
106
 
107
+ /** HC-GF-036(b), HC-GF-379-followup-1: adverse verdicts in the agent's own vocabulary. Module scope so a test can pin
108
+ * `step_0b_abort` to the kind failure-router.js's step_0b detector returns. */
109
+ const ADVERSE_VERDICTS = Object.freeze({
110
+ review_reject: 'returned a FAIL review',
111
+ security_high: 'raised a HIGH security finding',
112
+ security_medium: 'raised a MEDIUM security finding',
113
+ test_fail: 'reported failing tests',
114
+ lint_fail: 'reported lint errors',
115
+ perf_finding: 'raised a performance finding',
116
+ validator_rejected: 'produced output the format validator rejected',
117
+ step_0b_abort: 'the architect consult answered false',
118
+ });
119
+ /** HC-GF-379: the run's error_message when the third false consult ends it (workflow-dag.js routeFailure's reason).
120
+ * The packages cannot share it at runtime; tests/regression/HC-GF-030-run-report.test.js pins it to the producer. */
121
+ const CONSULT_ROUTE_LIMIT_RE = /^max retries \((\d+)\) exceeded for step_0b:step_0b_abort$/;
122
+
85
123
  function buildReport(run, extra = {}) {
86
124
  const cfg = run.config || {};
87
125
  const steps = Array.isArray(run.steps) ? run.steps : [];
@@ -107,7 +145,28 @@ function buildReport(run, extra = {}) {
107
145
  // untrustworthy in the other direction.
108
146
  const blocked = String(run.errorMessage || '').match(BLOCKING_REASON_RE);
109
147
  const exhausted = String(run.errorMessage || '').match(EXHAUSTED_RE);
110
- if (run.approvalRefusal) {
148
+ const consultRequired = String(run.errorMessage || '').match(CONSULT_REQUIRED_RE);
149
+ const consultNeedsPerson = String(run.errorMessage || '').match(CONSULT_NEEDS_PERSON_RE);
150
+ const consultAbort = String(run.errorMessage || '').match(CONSULT_ABORT_RE);
151
+ if (consultRequired) {
152
+ needsYou.push({
153
+ what: `run CANNOT proceed at ${run.currentStep} — ${scrub(consultRequired[0].slice(0, 400))}. There is no waiver and `
154
+ + 'approving is refused, so this is a decision, not a gate',
155
+ how: `hone run-story ${run.runId} --kill (then re-run the story: step_0 is cached, so it costs one consult)`,
156
+ });
157
+ } else if (consultNeedsPerson) {
158
+ needsYou.push({
159
+ what: `the architect consult is waiting for a person to read it — ${scrub(consultNeedsPerson[0].slice(0, 400))}`,
160
+ how: `hone show step ${run.runId} step_0b (then hone run-story ${run.runId} --approve step_0b once you have read it)`,
161
+ });
162
+ } else if (consultAbort) {
163
+ needsYou.push({
164
+ what: `the architect consult did not pass at ${run.currentStep} — ${scrub(consultAbort[0].slice(0, 400))}. Approving is refused, `
165
+ + 'so this is a decision, not a gate',
166
+ how: `hone show step ${run.runId} step_0b (read what it flagged, change the story to answer it, `
167
+ + `then hone run-story ${run.runId} --kill and run the story again)`,
168
+ });
169
+ } else if (run.approvalRefusal) {
111
170
  // HC-GF-074: rendered from the server's own decision, never re-derived from verdict records here.
112
171
  needsYou.push({
113
172
  what: `run CANNOT be approved at ${run.currentStep} as it stands — ${String(run.approvalRefusal.message).slice(0, 300)}`,
@@ -136,10 +195,23 @@ function buildReport(run, extra = {}) {
136
195
  }
137
196
  }
138
197
  if (run.status === 'failed') {
139
- needsYou.push({
140
- what: `run FAILED${run.errorMessage ? `: ${String(run.errorMessage).slice(0, 160)}` : ''}`,
141
- how: `hone show step ${run.runId} ${run.currentStep || '<stepKey>'}`,
142
- });
198
+ // HC-GF-379-followup-1: a third false consult ends the run failed at step_0b. Both the row AND the route's own
199
+ // reason must say so: a guardrail or budget that failed step_0b after one false verdict keeps the generic line.
200
+ const consultRow = steps.find((x) => x && x.stepKey === 'step_0b');
201
+ const limit = run.currentStep === 'step_0b'
202
+ && (consultRow && consultRow.diagnostics && consultRow.diagnostics.failure_record || {}).failure_kind === 'step_0b_abort'
203
+ && String(run.errorMessage || '').match(CONSULT_ROUTE_LIMIT_RE);
204
+ if (limit && ADVERSE_VERDICTS.step_0b_abort) {
205
+ needsYou.push({
206
+ what: `run FAILED: ${ADVERSE_VERDICTS.step_0b_abort} ${Number(limit[1]) + 1} times, the route-back limit`,
207
+ how: `hone show step ${run.runId} step_0b (every consult attempt's findings, newest first)`,
208
+ });
209
+ } else {
210
+ needsYou.push({
211
+ what: `run FAILED${run.errorMessage ? `: ${String(run.errorMessage).slice(0, 160)}` : ''}`,
212
+ how: `hone show step ${run.runId} ${run.currentStep || '<stepKey>'}`,
213
+ });
214
+ }
143
215
  }
144
216
  // HC-GF-065: a KILLED run needs you too.
145
217
  //
@@ -186,17 +258,9 @@ function buildReport(run, extra = {}) {
186
258
  // conflation that was inflating batch failure_rates — and it points the operator at
187
259
  // the wrong thing to fix. The verdict kinds name the agent's own vocabulary.
188
260
  const kind = (s.diagnostics && s.diagnostics.failure_record || {}).failure_kind;
189
- const VERDICT = {
190
- review_reject: 'returned a FAIL review',
191
- security_high: 'raised a HIGH security finding',
192
- security_medium: 'raised a MEDIUM security finding',
193
- test_fail: 'reported failing tests',
194
- lint_fail: 'reported lint errors',
195
- perf_finding: 'raised a performance finding',
196
- validator_rejected: 'produced output the format validator rejected',
197
- };
198
- const what = kind && VERDICT[kind]
199
- ? `${s.stepKey} (${s.agent || 'agent'}) ${VERDICT[kind]}`
261
+ if (kind === 'step_0b_abort') continue; // HC-GF-379-followup-1: reported once, in 1b′ below
262
+ const what = kind && ADVERSE_VERDICTS[kind]
263
+ ? `${s.stepKey} (${s.agent || 'agent'}) ${ADVERSE_VERDICTS[kind]}`
200
264
  : `${s.stepKey} (${s.agent || 'agent'}) ${s.status === 'failed' ? 'FAILED' : 'was REJECTED'}`;
201
265
  needsYou.push({
202
266
  what: `${what} — the run is recorded ${run.status}, so nothing downstream will revisit it`,
@@ -205,6 +269,35 @@ function buildReport(run, extra = {}) {
205
269
  });
206
270
  }
207
271
 
272
+ // 1b′. HC-GF-379-followup-1: the latest step_0b row is a false consult. Steps carry only the latest attempt, so a
273
+ // consult that went false and then true raises nothing here. Named once: skipped when the run-failed item or the
274
+ // pause-era abort branch above already names it.
275
+ {
276
+ const em = String(run.errorMessage || '');
277
+ const row = steps.find((x) => x && x.stepKey === 'step_0b');
278
+ const rowKind = row && (row.diagnostics && row.diagnostics.failure_record || {}).failure_kind;
279
+ const namedAbove = (run.status === 'failed' && run.currentStep === 'step_0b')
280
+ || (run.status === 'paused' && run.currentStep && em.match(CONSULT_ABORT_RE));
281
+ if (rowKind === 'step_0b_abort' && !namedAbove) {
282
+ // "Paused at step_0" means the plain gate only: the final else of the paused branch above.
283
+ const plainGate = run.status === 'paused' && run.currentStep === 'step_0' && !run.approvalRefusal
284
+ && ![BLOCKING_REASON_RE, EXHAUSTED_RE, CONSULT_REQUIRED_RE, CONSULT_NEEDS_PERSON_RE, CONSULT_ABORT_RE].some((re) => em.match(re));
285
+ const regrooming = run.status === 'running' || plainGate;
286
+ const suffix = regrooming
287
+ ? 'the run is re-grooming with these findings'
288
+ : `the run is recorded ${run.status}${run.status === 'failed' && run.currentStep ? ` at ${run.currentStep}` : ''}, `
289
+ + 'so nothing downstream will revisit it';
290
+ needsYou.push({
291
+ what: `step_0b: ${ADVERSE_VERDICTS.step_0b_abort} — ${suffix}`,
292
+ how: `hone show step ${run.runId} step_0b`,
293
+ howLabel: 'inspect',
294
+ // A run inside the consult loop needs nobody: the pipeline is answering the findings itself. Shown, not counted
295
+ // toward the exit code (a paused run's own gate item, when there is one, still is).
296
+ ...(regrooming ? { advisory: true } : {}),
297
+ });
298
+ }
299
+ }
300
+
208
301
  // 1c. HC-GF-041: a retry that came back with LESS than the attempt it replaces.
209
302
  //
210
303
  // The newest attempt REPLACES the previous one for every downstream consumer, so this
@@ -430,6 +523,26 @@ function buildReport(run, extra = {}) {
430
523
  worthKnowing.push(`${s.stepKey} ran ${s.attempts} times`);
431
524
  }
432
525
  }
526
+ // HC-GF-030: a run that finished with an architect classified as needed and no consult recorded. The consult
527
+ // guard makes this unreachable for a run the server drove; it is said here because "no run records
528
+ // `architect_engaged: true` with no consult" is the acceptance, and a report is where that gets checked.
529
+ const arch = run.architect && typeof run.architect === 'object' ? run.architect : null;
530
+ // HC-GF-397-followup-1: `hone run-story --consult-architect` asks for a consult on a `not_engaged` grooming. Read the way
531
+ // the server reads it — the boolean `true` only — so a requested consult that is not approved is never "not needed".
532
+ const consultRequested = cfg.consult_architect === true;
533
+ if (arch && (arch.verdict !== 'not_engaged' || consultRequested) && !arch.consulted && run.status === 'completed') {
534
+ const why = arch.verdict === 'engaged' ? 'the architect classified as needed'
535
+ : arch.verdict === 'not_engaged' ? 'an architect consult REQUESTED with --consult-architect'
536
+ : 'the architect verdict unreadable';
537
+ needsYou.push({
538
+ what: `run COMPLETED with ${why} and NO approved consult`,
539
+ how: `hone show step ${run.runId} step_0b`,
540
+ // HC-026c: a new signal must not turn every run that finished BEFORE the consult step existed into a failing exit
541
+ // code (about 27 of 28 completed runs on the local server were engaged). Shown in full, counted in `needsYou`, and
542
+ // not gating: the consult guard is what stops a new run, and this line is for reading a run that predates it.
543
+ advisory: true,
544
+ });
545
+ }
433
546
  // The two "blind run" tells, both from HC-GF-006's preflight reasoning.
434
547
  const descChars = typeof cfg.story_description === 'string' ? cfg.story_description.length : 0;
435
548
  if (descChars === 0) {
@@ -459,9 +572,21 @@ function buildReport(run, extra = {}) {
459
572
  duration: durationOf(run.startedAt, run.completedAt),
460
573
  cost: extra.cost || null,
461
574
  architectConsulted: cfg.architect_consulted === true,
575
+ // HC-GF-030: derived server-side from the step rows, never from config. null on a server that predates it.
576
+ architect: arch ? {
577
+ verdict: arch.verdict, consulted: arch.consulted === true, requested: consultRequested,
578
+ // A number or nothing: this is printed, and it came off the network.
579
+ consultAttempt: Number.isFinite(Number(arch.consultAttempt)) && Number(arch.consultAttempt) > 0 ? Math.trunc(Number(arch.consultAttempt)) : null,
580
+ blockers: Number(arch.blockers) || 0,
581
+ // Agent-written text going to a terminal: the server scrubs it, and this CLI may be talking to an older or different
582
+ // server, so it scrubs again where it is stored for printing (the rule cli/lib/operator-question.js already keeps).
583
+ findings: (Array.isArray(arch.findings) ? arch.findings : []).map(f => scrub(String(f).slice(0, 400))),
584
+ callSiteGaps: (Array.isArray(arch.callSiteGaps) ? arch.callSiteGaps : []).map(g => scrub(String(g).slice(0, 400))),
585
+ } : null,
462
586
  storyDescriptionChars: descChars,
463
587
  bundledFiles: bundled,
464
- classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes : [],
588
+ // The groomer's own words (the server harvests them from step_0's output), printed on a terminal: scrubbed, and strings only.
589
+ classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes.map(a => scrub(String(a).slice(0, 80))) : [],
465
590
  needsYou,
466
591
  worthKnowing,
467
592
  steps,
@@ -530,6 +655,21 @@ function renderReport(r) {
530
655
  L.push('');
531
656
  }
532
657
 
658
+ if (r.architect) {
659
+ const a = r.architect;
660
+ L.push('ARCHITECT');
661
+ L.push(` ${a.verdict === 'engaged' ? 'engaged' : a.verdict === 'not_engaged' ? 'not engaged (step_0 said so, readably)' : 'verdict unreadable (the consult runs)'}`
662
+ + `${r.classifierAxes.length ? ` · axes: ${r.classifierAxes.join(', ')}` : ''}`
663
+ // HC-GF-397-followup-1: the request is named only where it changed the outcome — an engaged or unreadable verdict
664
+ // runs the consult anyway, so its line stays as it was.
665
+ + `${a.requested && a.verdict === 'not_engaged' ? ' · consult requested (--consult-architect)' : ''}`
666
+ + ` · consult ${a.consulted ? `approved${a.consultAttempt ? ` (step_0b attempt ${a.consultAttempt})` : ''}` : a.verdict === 'not_engaged' && !a.requested ? 'not needed' : 'NOT approved'}`);
667
+ if (a.blockers > 0) L.push(` ${a.blockers} finding(s) marked BLOCKER`);
668
+ for (const f of a.findings) L.push(` · ${f}`);
669
+ for (const g of a.callSiteGaps) L.push(` ⚠ call sites the consult could not see: ${g}`);
670
+ L.push('');
671
+ }
672
+
533
673
  L.push('STEPS');
534
674
  for (const s of r.steps) {
535
675
  const mark = s.status === 'completed' ? '✓' : s.status === 'skipped' ? '○' : '·';
@@ -540,8 +680,11 @@ function renderReport(r) {
540
680
  L.push('');
541
681
 
542
682
  L.push('RUN FACTS');
683
+ // HC-GF-030: the consult state comes from the server's step rows when it reports them (`architect`); the config flag is
684
+ // only a mirror, cleared by a new grooming, and is what an older server still reports.
685
+ const consultedFact = r.architect ? r.architect.consulted : r.architectConsulted;
543
686
  L.push(` mode ${r.mode || 'unknown'} · story text ${r.storyDescriptionChars} chars · `
544
- + `${r.bundledFiles} file(s) bundled · architect_consulted ${r.architectConsulted}`);
687
+ + `${r.bundledFiles} file(s) bundled · architect_consulted ${consultedFact}`);
545
688
  if (r.classifierAxes.length) L.push(` axes: ${r.classifierAxes.join(', ')}`);
546
689
  if (r.verifications.length) {
547
690
  for (const v of r.verifications) {
@@ -554,4 +697,6 @@ function renderReport(r) {
554
697
  return L.join('\n');
555
698
  }
556
699
 
557
- module.exports = { BLOCKING_REASON_CODES, buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS };
700
+ module.exports = {
701
+ BLOCKING_REASON_CODES, buildReport, renderReport, durationOf, ACTIONABLE_GATE_VERDICTS, ADVERSE_VERDICTS, CONSULT_ROUTE_LIMIT_RE,
702
+ };