@hone-ai/cli 1.39.0 → 1.41.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.
@@ -25,7 +25,6 @@
25
25
  */
26
26
 
27
27
  const { composeStoryDescription } = require('./story-compose');
28
- const { extractStoryIdFromBranch } = require('./pipeline-status');
29
28
  const {
30
29
  extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
31
30
  } = require('./bundle-paths');
@@ -37,7 +36,6 @@ const { classifyStory } = require('./story-classifier');
37
36
  const {
38
37
  extractType, extractSecurityImplications, extractCrossRepoDependency,
39
38
  } = require('./story-classifier-extract');
40
- const { readArchitectConfigFromText } = require('./architect-config');
41
39
  const path = require('path');
42
40
 
43
41
  /**
@@ -49,13 +47,16 @@ const path = require('path');
49
47
  */
50
48
  const SERVER_OWNED_KEYS = Object.freeze([
51
49
  'batchTokenBudget', 'batchFailureThreshold', 'dependsOnRunIds', 'monthlyTokenBudget', 'overnight',
50
+ // HC-GF-030: whether a story was consulted is what the server's step_0b recorded, never what a
51
+ // client says. A client that could send these true would skip the consult.
52
+ 'architect_consulted', 'checklist_b_completed', 'architect_consult',
52
53
  ]);
53
54
 
54
55
  // HC-019n-followup-13d: the bundle caps. OptionsFlow #96 hit 49,783 of the old 50,000 with six
55
56
  // files, so the next slightly larger story silently dropped one. 30 / 150K / 25K.
56
57
  const BUNDLE_MAX_FILES = 30;
57
58
  const BUNDLE_MAX_TOTAL_CHARS = 150_000;
58
- const BUNDLE_MAX_PER_FILE_CHARS = 25_000;
59
+ const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits'); // HC-GF-390: shared with review-diff.js
59
60
 
60
61
  const info = (text) => ({ level: 'info', text });
61
62
  const warn = (text) => ({ level: 'warn', text });
@@ -158,7 +159,6 @@ async function issueStorySource(io, issueNumber, issue) {
158
159
  * @param {() => string} io.e2eSpecMode
159
160
  * @param {() => string} io.verificationGate
160
161
  * @param {() => string[]} io.verificationGateSources
161
- * @param {() => string} io.readPlanText EXECUTION_PLAN.yml text, '' when absent
162
162
  * @param {{ e2eSpecs?: boolean, skipE2eSpecs?: boolean, treeByteCap?: number }} [opts] run-story's per-run
163
163
  * flags; `treeByteCap` is queue-stories' byte cap on the tree's path lists (none for run-story)
164
164
  */
@@ -295,13 +295,7 @@ function resolveRepoContext(io, opts = {}) {
295
295
  config.verification_gate_sources = ['verify-patch'];
296
296
  }
297
297
 
298
- // HC-019b: EXECUTION_PLAN.yml, read once. Each story's architect flags come from it below.
299
- let planText = '';
300
- try { planText = io.readPlanText() || ''; } catch (e) {
301
- notes.push(warn(` ⚠ EXECUTION_PLAN.yml read failed (non-fatal, architect flags default to {false, false}): ${firstLine(e)}`));
302
- }
303
-
304
- return { config, notes, repoFiles, defaultIncludePaths, planText, execution };
298
+ return { config, notes, repoFiles, defaultIncludePaths, execution };
305
299
  }
306
300
 
307
301
  /**
@@ -324,8 +318,8 @@ function resolveRepoContext(io, opts = {}) {
324
318
  * @param {object} repoCtx resolveRepoContext's result
325
319
  * @param {string} storyId the id as the operator typed it — sent as `storyId`
326
320
  * @param {{ explicitText?: string|null, explicitLabel?: string, explicitSource?: {kind: 'file'|'description', label?: string},
327
- * includePaths?: string[], axes?: string }} [opts]
328
- * @returns {Promise<{ storyId: string, resolvedStoryId: string, rawText: string,
321
+ * includePaths?: string[], axes?: string, consultArchitect?: boolean }} [opts]
322
+ * @returns {Promise<{ storyId: string, rawText: string,
329
323
  * textSource: 'explicit'|'issue'|'roadmap'|null, textFailure: {kind:string, detail?:string}|null,
330
324
  * config: object, bundle: {files:number, chars:number}, notes: {pre: object[], post: object[]} }>}
331
325
  */
@@ -336,7 +330,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
336
330
  let rawText = '';
337
331
  let textSource = null;
338
332
  let textFailure = null;
339
- let resolvedStoryId = storyId; // canonical id for the architect-config lookup
340
333
 
341
334
  // ── Story text: explicit → GitHub issue → Hone roadmap ────────────────────────────────────
342
335
  // HC-GF-010: an EXPLICIT story source short-circuits both lookups, so the operator can never
@@ -369,17 +362,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
369
362
  const prov = await issueStorySource(io, storyId, issue);
370
363
  config.story_source = prov.source;
371
364
  pre.push(...prov.notes);
372
- // HC-019b-followup-1 F1: the canonical story id from the issue title, or the architect
373
- // lookup keys on '104' and silently misses every EXECUTION_PLAN.yml entry.
374
- try {
375
- const titleId = extractStoryIdFromBranch(issue.title);
376
- if (titleId) {
377
- resolvedStoryId = titleId;
378
- pre.push(info(` → resolved issue #${storyId} → canonical story id '${titleId}' for architect-config lookup`));
379
- } else {
380
- pre.push(warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyId}' (likely silent miss)`));
381
- }
382
- } catch { /* extractor throws → fall back to the numeric id */ }
383
365
  } catch (e) {
384
366
  textFailure = { kind: 'issue-error', detail: firstLine(e) };
385
367
  // HC-GF-270: stated rather than left absent, so the later pause is readable. Classified, not
@@ -392,7 +374,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
392
374
  };
393
375
  pre.push(warn(` ⚠ could not fetch GitHub issue context: ${firstLine(e)}`));
394
376
  pre.push(warn(' → proceeding without story_description; step_0 may produce placeholder output'));
395
- pre.push(warn(` ⚠ architect-config lookup will use '${storyId}' (likely silent miss)`));
396
377
  }
397
378
  } else {
398
379
  // HC-019n-followup-18: a roadmap-sourced story. Only the SERVER knows what it says; only the
@@ -540,11 +521,14 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
540
521
  path: candidate,
541
522
  });
542
523
  content = excerpt.content;
543
- if (excerpt.notice) pre.push(warn(excerpt.notice));
544
- for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
545
524
  if (totalChars + content.length > BUNDLE_MAX_TOTAL_CHARS) { budgetSpent = true; break; }
546
525
  totalChars += content.length;
547
526
  codebaseFiles.push({ path: candidate, content });
527
+ // Only a file that made the bundle gets the "bundled lines A-B" notice: announced before the
528
+ // budget check, a dropped file printed it AND the NOT-bundled line below, and the operator
529
+ // read the first one (seen 2026-10-16 on server/scripts/seed-agent-prompts.js).
530
+ if (excerpt.notice) pre.push(warn(excerpt.notice));
531
+ for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
548
532
  }
549
533
  // HC-GF-288: an operator-named entry whose file never reached the bundle (the budget ran
550
534
  // out first, or it names a directory) vanished as silently as the anchor did. A refused
@@ -597,6 +581,11 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
597
581
  }
598
582
  }
599
583
 
584
+ // ── Architect consult on request (HC-GF-397) ─────────────────────────────────────────────
585
+ // --consult-architect: the server runs step_0b even on a `not_engaged` grooming. Sent only as `true`; the server reads
586
+ // nothing else as a request, and no value of it can skip a consult.
587
+ if (opts.consultArchitect === true) config.consult_architect = true;
588
+
600
589
  // ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
601
590
  // Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
602
591
  // come from the groomer at step_0 and are harvested server-side. Fail-open.
@@ -621,19 +610,7 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
621
610
  }
622
611
  }
623
612
 
624
- // ── Architect flags (HC-019b) ─────────────────────────────────────────────────────────────
625
- // workflow-dag.js's validateStepPreConditions BLOCKS step_1 when architect_consulted is true
626
- // and checklist_b_completed is not; without these flags every architect-engaged story
627
- // deadlocks. Keyed on the RESOLVED id (HC-019b-followup-1 F1).
628
- const arch = readArchitectConfigFromText(repoCtx.planText || '', resolvedStoryId);
629
- config.architect_consulted = arch.architect_consulted;
630
- config.checklist_b_completed = arch.checklist_b_completed;
631
- if (arch.diagnostic) post.push(warn(` ⚠ ${arch.diagnostic}`));
632
- if (arch.architect_consulted) {
633
- post.push(info(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`));
634
- }
635
-
636
- return { storyId, resolvedStoryId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
613
+ return { storyId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
637
614
  }
638
615
 
639
616
  /**
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,25 @@ 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
+ * The pause a consult whose OWN verdict is `checklist_b_completed: false` takes (workflow-dag.js FAILURE_ROUTING_TABLE.step_0b.step_0b_abort).
85
+ * Approving it is refused (an `_abort` needs an acknowledgement, and even then nothing records a false verdict), so offering
86
+ * `--approve` here is HC-GF-046 again: the line invites the one action the server has already decided to refuse.
87
+ * tests/regression/HC-GF-030-run-report.test.js pins this prefix to the server's message.
88
+ */
89
+ const CONSULT_ABORT_RE = /^step_0b: the architect consult did not complete Checklist B[^\n]*/;
90
+
72
91
  /**
73
92
  * HC-GF-046(b), corrected in review: a step that used up its format retries.
74
93
  *
@@ -107,7 +126,28 @@ function buildReport(run, extra = {}) {
107
126
  // untrustworthy in the other direction.
108
127
  const blocked = String(run.errorMessage || '').match(BLOCKING_REASON_RE);
109
128
  const exhausted = String(run.errorMessage || '').match(EXHAUSTED_RE);
110
- if (run.approvalRefusal) {
129
+ const consultRequired = String(run.errorMessage || '').match(CONSULT_REQUIRED_RE);
130
+ const consultNeedsPerson = String(run.errorMessage || '').match(CONSULT_NEEDS_PERSON_RE);
131
+ const consultAbort = String(run.errorMessage || '').match(CONSULT_ABORT_RE);
132
+ if (consultRequired) {
133
+ needsYou.push({
134
+ what: `run CANNOT proceed at ${run.currentStep} — ${scrub(consultRequired[0].slice(0, 400))}. There is no waiver and `
135
+ + 'approving is refused, so this is a decision, not a gate',
136
+ how: `hone run-story ${run.runId} --kill (then re-run the story: step_0 is cached, so it costs one consult)`,
137
+ });
138
+ } else if (consultNeedsPerson) {
139
+ needsYou.push({
140
+ what: `the architect consult is waiting for a person to read it — ${scrub(consultNeedsPerson[0].slice(0, 400))}`,
141
+ how: `hone show step ${run.runId} step_0b (then hone run-story ${run.runId} --approve step_0b once you have read it)`,
142
+ });
143
+ } else if (consultAbort) {
144
+ needsYou.push({
145
+ what: `the architect consult did not pass at ${run.currentStep} — ${scrub(consultAbort[0].slice(0, 400))}. Approving is refused, `
146
+ + 'so this is a decision, not a gate',
147
+ how: `hone show step ${run.runId} step_0b (read what it flagged, change the story to answer it, `
148
+ + `then hone run-story ${run.runId} --kill and run the story again)`,
149
+ });
150
+ } else if (run.approvalRefusal) {
111
151
  // HC-GF-074: rendered from the server's own decision, never re-derived from verdict records here.
112
152
  needsYou.push({
113
153
  what: `run CANNOT be approved at ${run.currentStep} as it stands — ${String(run.approvalRefusal.message).slice(0, 300)}`,
@@ -430,6 +470,26 @@ function buildReport(run, extra = {}) {
430
470
  worthKnowing.push(`${s.stepKey} ran ${s.attempts} times`);
431
471
  }
432
472
  }
473
+ // HC-GF-030: a run that finished with an architect classified as needed and no consult recorded. The consult
474
+ // guard makes this unreachable for a run the server drove; it is said here because "no run records
475
+ // `architect_engaged: true` with no consult" is the acceptance, and a report is where that gets checked.
476
+ const arch = run.architect && typeof run.architect === 'object' ? run.architect : null;
477
+ // HC-GF-397-followup-1: `hone run-story --consult-architect` asks for a consult on a `not_engaged` grooming. Read the way
478
+ // the server reads it — the boolean `true` only — so a requested consult that is not approved is never "not needed".
479
+ const consultRequested = cfg.consult_architect === true;
480
+ if (arch && (arch.verdict !== 'not_engaged' || consultRequested) && !arch.consulted && run.status === 'completed') {
481
+ const why = arch.verdict === 'engaged' ? 'the architect classified as needed'
482
+ : arch.verdict === 'not_engaged' ? 'an architect consult REQUESTED with --consult-architect'
483
+ : 'the architect verdict unreadable';
484
+ needsYou.push({
485
+ what: `run COMPLETED with ${why} and NO approved consult`,
486
+ how: `hone show step ${run.runId} step_0b`,
487
+ // HC-026c: a new signal must not turn every run that finished BEFORE the consult step existed into a failing exit
488
+ // code (about 27 of 28 completed runs on the local server were engaged). Shown in full, counted in `needsYou`, and
489
+ // not gating: the consult guard is what stops a new run, and this line is for reading a run that predates it.
490
+ advisory: true,
491
+ });
492
+ }
433
493
  // The two "blind run" tells, both from HC-GF-006's preflight reasoning.
434
494
  const descChars = typeof cfg.story_description === 'string' ? cfg.story_description.length : 0;
435
495
  if (descChars === 0) {
@@ -459,9 +519,21 @@ function buildReport(run, extra = {}) {
459
519
  duration: durationOf(run.startedAt, run.completedAt),
460
520
  cost: extra.cost || null,
461
521
  architectConsulted: cfg.architect_consulted === true,
522
+ // HC-GF-030: derived server-side from the step rows, never from config. null on a server that predates it.
523
+ architect: arch ? {
524
+ verdict: arch.verdict, consulted: arch.consulted === true, requested: consultRequested,
525
+ // A number or nothing: this is printed, and it came off the network.
526
+ consultAttempt: Number.isFinite(Number(arch.consultAttempt)) && Number(arch.consultAttempt) > 0 ? Math.trunc(Number(arch.consultAttempt)) : null,
527
+ blockers: Number(arch.blockers) || 0,
528
+ // Agent-written text going to a terminal: the server scrubs it, and this CLI may be talking to an older or different
529
+ // server, so it scrubs again where it is stored for printing (the rule cli/lib/operator-question.js already keeps).
530
+ findings: (Array.isArray(arch.findings) ? arch.findings : []).map(f => scrub(String(f).slice(0, 400))),
531
+ callSiteGaps: (Array.isArray(arch.callSiteGaps) ? arch.callSiteGaps : []).map(g => scrub(String(g).slice(0, 400))),
532
+ } : null,
462
533
  storyDescriptionChars: descChars,
463
534
  bundledFiles: bundled,
464
- classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes : [],
535
+ // The groomer's own words (the server harvests them from step_0's output), printed on a terminal: scrubbed, and strings only.
536
+ classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes.map(a => scrub(String(a).slice(0, 80))) : [],
465
537
  needsYou,
466
538
  worthKnowing,
467
539
  steps,
@@ -530,6 +602,21 @@ function renderReport(r) {
530
602
  L.push('');
531
603
  }
532
604
 
605
+ if (r.architect) {
606
+ const a = r.architect;
607
+ L.push('ARCHITECT');
608
+ L.push(` ${a.verdict === 'engaged' ? 'engaged' : a.verdict === 'not_engaged' ? 'not engaged (step_0 said so, readably)' : 'verdict unreadable (the consult runs)'}`
609
+ + `${r.classifierAxes.length ? ` · axes: ${r.classifierAxes.join(', ')}` : ''}`
610
+ // HC-GF-397-followup-1: the request is named only where it changed the outcome — an engaged or unreadable verdict
611
+ // runs the consult anyway, so its line stays as it was.
612
+ + `${a.requested && a.verdict === 'not_engaged' ? ' · consult requested (--consult-architect)' : ''}`
613
+ + ` · consult ${a.consulted ? `approved${a.consultAttempt ? ` (step_0b attempt ${a.consultAttempt})` : ''}` : a.verdict === 'not_engaged' && !a.requested ? 'not needed' : 'NOT approved'}`);
614
+ if (a.blockers > 0) L.push(` ${a.blockers} finding(s) marked BLOCKER`);
615
+ for (const f of a.findings) L.push(` · ${f}`);
616
+ for (const g of a.callSiteGaps) L.push(` ⚠ call sites the consult could not see: ${g}`);
617
+ L.push('');
618
+ }
619
+
533
620
  L.push('STEPS');
534
621
  for (const s of r.steps) {
535
622
  const mark = s.status === 'completed' ? '✓' : s.status === 'skipped' ? '○' : '·';
@@ -540,8 +627,11 @@ function renderReport(r) {
540
627
  L.push('');
541
628
 
542
629
  L.push('RUN FACTS');
630
+ // HC-GF-030: the consult state comes from the server's step rows when it reports them (`architect`); the config flag is
631
+ // only a mirror, cleared by a new grooming, and is what an older server still reports.
632
+ const consultedFact = r.architect ? r.architect.consulted : r.architectConsulted;
543
633
  L.push(` mode ${r.mode || 'unknown'} · story text ${r.storyDescriptionChars} chars · `
544
- + `${r.bundledFiles} file(s) bundled · architect_consulted ${r.architectConsulted}`);
634
+ + `${r.bundledFiles} file(s) bundled · architect_consulted ${consultedFact}`);
545
635
  if (r.classifierAxes.length) L.push(` axes: ${r.classifierAxes.join(', ')}`);
546
636
  if (r.verifications.length) {
547
637
  for (const v of r.verifications) {
@@ -14,13 +14,15 @@
14
14
  const SECRET_PATTERNS = [
15
15
  {
16
16
  id: 'hardcoded-secret-apikey',
17
- pattern: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*["']([A-Za-z0-9/+=]{20,})["']/i,
17
+ // HC-GF-364: `["']?` after the name reads a quoted key — JSON, a dict literal — as the served
18
+ // workflow's scan does since HC-GF-362.
19
+ pattern: /(?:api[_-]?key|apikey|api[_-]?secret)["']?\s*[:=]\s*["']([A-Za-z0-9/+=]{20,})["']/i,
18
20
  severity: 'HIGH',
19
21
  message: 'Hardcoded API key detected. Use environment variables instead.',
20
22
  },
21
23
  {
22
24
  id: 'hardcoded-secret-password',
23
- pattern: /(?:password|passwd|pwd|secret|token)\s*[:=]\s*["']([A-Za-z0-9@#$%^&*!]{16,})["']/i,
25
+ pattern: /(?:password|passwd|pwd|secret|token)["']?\s*[:=]\s*["']([A-Za-z0-9@#$%^&*!]{16,})["']/i,
24
26
  severity: 'HIGH',
25
27
  message: 'Hardcoded password/secret detected. Use environment variables or a vault.',
26
28
  },
@@ -126,8 +128,9 @@ function scanFiles(opts = {}) {
126
128
 
127
129
  // Skip env var references (not hardcoded)
128
130
  if (/process\.env\.|os\.environ|System\.getenv|ENV\[/i.test(line)) continue;
129
- // Skip comments
130
- if (/^\s*(\/\/|#|--|\/\*|\*)/.test(line)) continue;
131
+ // Skip comments. `--` is a SQL comment; `-----BEGIN … PRIVATE KEY-----` is not (HC-GF-362: it was
132
+ // skipped as one, so the private-key rule never fired on a key at the start of a line).
133
+ if (/^\s*(\/\/|#|--(?!-)|\/\*|\*)/.test(line)) continue;
131
134
 
132
135
  for (const rule of rules) {
133
136
  if (rule.pattern.test(line)) {
@@ -18,14 +18,34 @@
18
18
  * malformed[] unparsable block — DELIBERATELY NOT REACHABLE: every fence we emit is longer
19
19
  * than any fence run inside the file it wraps (see below).
20
20
  * noChanges the honest no-op — HANDLED: a clean tree says NO CHANGES rather than nothing.
21
+ * HC-GF-395: it says "matches HEAD" only when git reported no
22
+ * change at all (`!changed.length`). When git reported changes
23
+ * but none could be sent (`!included.length`), it says "NO
24
+ * CHANGES — the working tree differs from HEAD, but no changed
25
+ * file could be sent as content (N skipped; see ### Not
26
+ * included)", N counting named skips only (symlinks are silent).
21
27
  *
22
- * Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path` only.
28
+ * Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path`, plus the one
29
+ * classifier the bundler already uses — `isSecretPath` from `./bundle-context`, which itself
30
+ * requires only `path`.
31
+ *
32
+ * HC-GF-393: a path `isSecretPath()` classifies (`.env*`, `*.pem`, `id_rsa`-style keys, …) is
33
+ * never read. It is named under `### Not included`, so it is absent from the patch and the PR
34
+ * on purpose and visibly, not silently. A deleted one still renders as `DELETE:` (no content).
35
+ *
36
+ * HC-GF-394: a file that cannot be read is named under `### Not included` as `unreadable`. It used
37
+ * to throw, and the whole submission failed with it — every readable change included.
38
+ *
39
+ * HC-GF-390: for step_4 the submit also computes a review-only base diff of the over-cap files this
40
+ * render emitted whole (`cli/lib/review-diff.js`). It is sent as a separate `reviewDiff` field and
41
+ * stored apart; it is never part of this artifact, and nothing here changes because of it.
23
42
  */
24
43
 
25
44
  const fs = require('node:fs');
26
45
  const path = require('node:path');
27
46
  const { execFileSync } = require('node:child_process');
28
47
  const { gitEnv } = require('./git-env');
48
+ const { isSecretPath } = require('./bundle-context');
29
49
 
30
50
  /** Bytes above which we do not attempt to inline a file at all. */
31
51
  const MAX_FILE_BYTES = 1_000_000;
@@ -119,6 +139,9 @@ function renderFromTree({ repoRoot, testReport } = {}) {
119
139
  const skipped = [];
120
140
 
121
141
  for (const rel of changed) {
142
+ // First, before lstat or read: the bytes of a secret path never enter this process. The name
143
+ // is the signal, so a symlink or an oversized file with a secret name is listed as a secret.
144
+ if (isSecretPath(rel)) { skipped.push({ path: rel, reason: 'secret path — not sent' }); continue; }
122
145
  const abs = path.join(repoRoot, rel);
123
146
  let st;
124
147
  try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
@@ -129,7 +152,8 @@ function renderFromTree({ repoRoot, testReport } = {}) {
129
152
  if (st.isSymbolicLink() || !st.isFile()) continue;
130
153
  if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
131
154
 
132
- const buf = fs.readFileSync(abs);
155
+ let buf;
156
+ try { buf = fs.readFileSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
133
157
  if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
134
158
 
135
159
  const content = buf.toString('utf8');
@@ -141,9 +165,18 @@ function renderFromTree({ repoRoot, testReport } = {}) {
141
165
 
142
166
  const lines = ['## Changed Files', ''];
143
167
 
144
- if (!included.length && !deleted.length) {
168
+ if (!changed.length && !deleted.length) {
145
169
  // The honest no-op, in the shape the validator and the materialiser both already accept.
146
170
  lines.push('NO CHANGES — the working tree matches HEAD.');
171
+ } else if (!included.length && !deleted.length) {
172
+ // HC-GF-395: still a no-op, but not a clean tree. One line with the same `NO CHANGES —` prefix,
173
+ // which every parser keys on; the rest of the line is the reason stored in the artifact (the
174
+ // CLI's own no-op messages are fixed text). Only a
175
+ // named skip has a list to point at; a symlink or non-regular file is skipped silently.
176
+ const why = skipped.length
177
+ ? `${skipped.length} skipped; see ### Not included`
178
+ : 'symlinks and non-regular files are not sent';
179
+ lines.push(`NO CHANGES — the working tree differs from HEAD, but no changed file could be sent as content (${why}).`);
147
180
  } else {
148
181
  for (const b of blocks) { lines.push(b, ''); }
149
182
  for (const rel of deleted) lines.push(`DELETE: ${rel}`);
@@ -262,23 +262,97 @@ function verdictFromRun(run, provisioning = null) {
262
262
  missing: absent.missing,
263
263
  };
264
264
  }
265
- const sum = summarizeFailure(run);
265
+ const sum = summarizeFailure(run, { runner: true });
266
266
  // HC-GF-023: lead with HOW MANY. "1 of 8 named" and "the only failure" are
267
267
  // different situations that this line used to render identically.
268
- const head = `tests failed (exit ${run.code})`
269
- + (sum.count > 1 ? ` — ${sum.count} failure lines` : '');
270
- return {
271
- verdict: 'fail',
272
- exitCode: EXIT.TESTS_FAILED,
273
- detail: head + (sum.first ? `, first: ${sum.first}` : ''),
274
- failure: sum,
275
- };
268
+ // HC-GF-363: and with WHICH, from the runner's own failure records. This detail is the only verification
269
+ // text a retried builder receives, so it names the failing tests, not lines that merely contain "Error".
270
+ // The wording never writes "<n> tests failed/failing": the failure router reads that as a test failure
271
+ // (FAIL_COUNT_RE in server/src/services/failure-router.js) and a builder that echoed it would be re-routed.
272
+ const head = `tests failed (exit ${run.code})`;
273
+ const first = sum.first ? `, first: ${routerSafe(sum.first)}` : '';
274
+ let detail;
275
+ if (sum.source === 'runner') {
276
+ const named = sum.tests.map((t) => routerSafe(t.slice(0, 160))).join('; ');
277
+ const more = sum.count > sum.tests.length ? `; +${sum.count - sum.tests.length} more` : '';
278
+ detail = `${head} — ${sum.count} failing ${sum.count === 1 ? 'test' : 'tests'} (runner records): ${named}${more}${first}`;
279
+ } else {
280
+ detail = head + (sum.count > 1 ? ` — ${sum.count} failure lines (pattern match, no runner records)` : '') + first;
281
+ }
282
+ return { verdict: 'fail', exitCode: EXIT.TESTS_FAILED, detail, failure: sum };
276
283
  }
277
284
 
278
285
  /** Lines that look like a runner reporting a failure. Deliberately broad — this
279
286
  * counts EVIDENCE of failure across pytest/node/jest/go/cargo, not test cases. */
280
287
  const FAILURE_LINE_RE = /(^|[\s│|])(FAILED|FAIL|ERROR|ERR!|not ok|✗|✖|×)\b|\w+Error\b|panic:|AssertionError/;
281
288
 
289
+ /**
290
+ * HC-GF-363 (review MED-1): adopter text (a test's name, a log line) rides inside the detail, and the detail can be
291
+ * echoed into step_4's output, which the server's failure router READS. A failing test named `reports 2 tests failed`
292
+ * would route as `test_fail`, and a linter line `3 errors, 0 warnings` as `lint_fail`. This quotes the phrases the
293
+ * router's step_4 and step_5 detectors match (a count before "tests failed/failing" or "errors", "lint failed",
294
+ * "eslint error:", the `[UNKNOWN … operator must supply]` blocker, a `REVIEW SUMMARY:` verdict) so none can match;
295
+ * the words stay readable. Idempotent. tests/regression/HC-GF-363-auto-reject.test.js runs the REAL detectFailure.
296
+ */
297
+ function routerSafe(text) {
298
+ return String(text == null ? '' : text)
299
+ .replace(/(\d+)(\s+)(tests?)(\s+)(failing|failed)\b/gi, '$1$2"$3"$4$5')
300
+ .replace(/(\d+)(\s+)(errors?)\b/gi, '$1$2"$3"')
301
+ .replace(/\b(lint(?:ing)?)(\s+)(failed)\b/gi, '$1$2"$3"')
302
+ .replace(/\b(eslint|ruff|mypy)(\s+)(error)(\s*:)/gi, '$1$2"$3"$4')
303
+ .replace(/\[UNKNOWN\b/gi, '[ UNKNOWN')
304
+ .replace(/REVIEW(\s+)SUMMARY:/gi, 'REVIEW$1"SUMMARY":')
305
+ .replace(/##(\s+)Abort(\s+)Reason:/gi, '# #$1Abort$2Reason:');
306
+ }
307
+
308
+ /**
309
+ * HC-GF-363 — the failure records a test RUNNER prints, anchored at the start of the line. A line that merely
310
+ * contains "Error" or "FAIL" in a test's name (`✔ parseGhError recognizes …`, `ok 7 - … FAIL …`) is a PASS record
311
+ * and matches none of these: on the pilot's log 118 of the 124 lines FAILURE_LINE_RE flagged were passes.
312
+ * Each returns the failing test's name, or null when the line is not that runner's failure record.
313
+ */
314
+ const RUNNER_RECORDS = Object.freeze([
315
+ // TAP (node --test, tap, prove): `not ok 3 - name`. A `# TODO` / `# SKIP` directive is not a failure.
316
+ ['tap', (l) => {
317
+ const m = l.match(/^\s*not ok\b(.*)$/);
318
+ if (!m || /#\s*(?:TODO|SKIP)\b/i.test(m[1])) return null;
319
+ return m[1].replace(/^\s*\d*\s*(?:-\s*)?/, '').trim() || 'not ok';
320
+ }],
321
+ // node:test spec reporter: `✖ name (12ms)` (U+2716), also printed again in its `✖ failing tests:` recap. eslint's
322
+ // own `✖ 3 problems` summary is not a test.
323
+ ['spec', (l) => {
324
+ const m = l.match(/^\s*\u2716\s+(.*)$/);
325
+ if (!m || /^failing tests\b/i.test(m[1]) || /^\d+\s+problems?\b/i.test(m[1]) || /#\s*(?:TODO|SKIP)\b/i.test(m[1])) return null;
326
+ return m[1].replace(/\s*\(\d+(?:\.\d+)?\s*ms\)\s*$/, '').trim() || null;
327
+ }],
328
+ // jest: `✕ name (3 ms)` (U+2715)
329
+ ['jest', (l) => {
330
+ const m = l.match(/^\s*\u2715\s+(.*)$/);
331
+ return m ? (m[1].replace(/\s*\(\d+(?:\.\d+)?\s*ms\)\s*$/, '').trim() || null) : null;
332
+ }],
333
+ // pytest: the short summary `FAILED path::test - reason` / `ERROR path::test`, and verbose `path::test FAILED`
334
+ ['pytest', (l) => {
335
+ const m = l.match(/^(?:FAILED|ERROR)\s+(\S+\.py::\S*)/) || l.match(/^(\S+\.py::\S+)\s+FAILED\b/);
336
+ return m ? m[1] : null;
337
+ }],
338
+ // go test: `--- FAIL: TestX (0.00s)` (subtests are indented)
339
+ ['go', (l) => { const m = l.match(/^\s*--- FAIL:\s+(\S+)/); return m ? m[1] : null; }],
340
+ // cargo test: `test path::name ... FAILED`
341
+ ['cargo', (l) => { const m = l.match(/^test\s+(\S+)\s+\.\.\.\s+FAILED$/); return m ? m[1] : null; }],
342
+ ]);
343
+
344
+ /** The failing test a runner record names, or null. Pure. */
345
+ function runnerFailure(line) {
346
+ for (const [, read] of RUNNER_RECORDS) {
347
+ const name = read(line);
348
+ if (name) return name;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /** A line the runner prints for a test that did NOT fail (or an announcement, or a TODO/SKIP): never "the first failure". */
354
+ const RUNNER_NON_FAILURE_RE = /^\s*(?:ok\b|not ok\b[^\n]*#\s*(?:TODO|SKIP)\b|#\s*Subtest:|[\u2714\u2713]\s|--- (?:PASS|SKIP):|=== (?:RUN|PAUSE|CONT)\b|test \S+ \.\.\. ok$|\S+::\S+\s+PASSED\b|PASSED\s)/;
355
+
282
356
  /**
283
357
  * HC-GF-023 — summarise a failed run instead of quoting one line of it.
284
358
  *
@@ -290,19 +364,52 @@ const FAILURE_LINE_RE = /(^|[\s│|])(FAILED|FAIL|ERROR|ERR!|not ok|✗|✖|×)\
290
364
  * re-run. A one-line quote of a multi-failure run is not a summary; it is a
291
365
  * sample presented as a conclusion.
292
366
  *
367
+ * HC-GF-363: `runner: true` (verdictFromRun only) counts and names the runner's own failure records first
368
+ * (RUNNER_RECORDS) and says which it used. `first` is then the first broad-pattern line that is not a runner PASS
369
+ * record, so a SyntaxError still leads the FAILED names (HC-GF-023) and a passing test never does. With no runner
370
+ * record, or without the option (classifyGateRun: a linter prints `✖ N problems`, which is not a test), it is the
371
+ * broad pattern exactly as before.
372
+ *
293
373
  * Pure: returns the count, the FIRST failure (usually nearer the cause than the
294
374
  * last) and a tail excerpt. Writing the full log is IO and stays in the CLI.
295
375
  *
296
- * @returns {{ count: number, first: string, excerpt: string[] }}
376
+ * @returns {{ count: number, first: string, excerpt: string[], tests: string[], source: 'runner'|'pattern' }}
377
+ * `tests`: up to 10 failing test names (200 chars each) from runner records, in order, repeats removed;
378
+ * `count`: the number of distinct runner records (source 'runner') or of broad-pattern lines ('pattern')
297
379
  */
298
- function summarizeFailure({ stdout = '', stderr = '' }, { maxLines = 12 } = {}) {
380
+ function summarizeFailure({ stdout = '', stderr = '' }, { maxLines = 12, runner = false } = {}) {
299
381
  const lines = `${stdout}\n${stderr}`.split('\n').map(l => l.replace(/\s+$/, ''));
300
382
  const nonEmpty = lines.filter(l => l.trim());
383
+ const excerpt = nonEmpty.slice(-maxLines).map(l => l.slice(0, 300));
301
384
  const failing = nonEmpty.filter(l => FAILURE_LINE_RE.test(l));
385
+ if (runner) {
386
+ const names = [];
387
+ let firstRecord = '';
388
+ for (const l of nonEmpty) {
389
+ const n = runnerFailure(l);
390
+ if (!n) continue;
391
+ if (!firstRecord) firstRecord = l;
392
+ if (!names.includes(n)) names.push(n);
393
+ }
394
+ if (names.length) {
395
+ // TAP prints a test's own stdout as `# …` comment lines, so a passing test that logs a TypeError must not lead.
396
+ const tap = nonEmpty.some(l => /^\s*not ok\b/.test(l));
397
+ const lead = failing.find(l => !RUNNER_NON_FAILURE_RE.test(l) && !(tap && /^\s*#\s/.test(l))) || firstRecord;
398
+ return {
399
+ count: names.length,
400
+ first: lead.trim().slice(0, 200),
401
+ excerpt,
402
+ tests: names.slice(0, 10).map(n => n.slice(0, 200)),
403
+ source: 'runner',
404
+ };
405
+ }
406
+ }
302
407
  return {
303
408
  count: failing.length,
304
409
  first: (failing[0] || nonEmpty[nonEmpty.length - 1] || '').trim().slice(0, 200),
305
- excerpt: nonEmpty.slice(-maxLines).map(l => l.slice(0, 300)),
410
+ excerpt,
411
+ tests: [],
412
+ source: 'pattern',
306
413
  };
307
414
  }
308
415
 
@@ -419,6 +526,8 @@ function classifyGateRun(run) {
419
526
  return { verdict: 'unavailable', detail: 'output exceeded the capture buffer (ENOBUFS) — gate NOT run to completion' };
420
527
  }
421
528
  if (run.code === 0) return { verdict: 'pass', detail: 'clean' };
529
+ // HC-GF-363: no `runner` option, on purpose. A linter's `✖ N problems` is not a test record, and this detail is
530
+ // pinned byte for byte by the first-finding ordering below.
422
531
  const sum = summarizeFailure(run, { maxLines: 6 });
423
532
  // A linter prints its findings from the TOP, so unlike a test runner the most
424
533
  // useful line is the first, not the last. summarizeFailure's fallback is the
@@ -438,4 +547,5 @@ module.exports = {
438
547
  EXIT, resolveCommand, resolveCommandDetailed, DEFAULT_COMMAND,
439
548
  AUX_GATES, AUX_DETECT, resolveAuxGates, classifyGateRun,
440
549
  verdictFromRun, commandAbsence, verifyCommandEnv, lastMeaningfulLine, summarizeFailure, FAILURE_LINE_RE,
550
+ RUNNER_RECORDS, runnerFailure, routerSafe,
441
551
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.39.0",
3
+ "version": "1.41.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {