@hone-ai/cli 1.40.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.
package/hone-cli.js CHANGED
@@ -6038,6 +6038,8 @@ program
6038
6038
  if (!opts.file && !opts.fromTree) { console.error('Provide the output via --file <path> (the text your editor produced), or --from-tree to render it from the working tree.'); process.exit(1); }
6039
6039
  const fs = require('fs');
6040
6040
  let output;
6041
+ let reviewDiff = null;
6042
+ let warned = false;
6041
6043
  if (opts.fromTree) {
6042
6044
  const { renderFromTree } = require('./lib/tree-artifact');
6043
6045
  const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport });
@@ -6046,15 +6048,32 @@ program
6046
6048
  // artifact does not is a decision the operator has to be able to see.
6047
6049
  for (const s2 of rendered.skipped) console.error(`not included: ${s2.path} — ${s2.reason}`);
6048
6050
  console.error(`from-tree: ${rendered.files.length} file(s), ${rendered.deletes.length} deletion(s), ${rendered.skipped.length} skipped`);
6051
+ // HC-GF-390: for a build, the base diff of each file emitted whole whose original the reviewers saw only in
6052
+ // windows. Sent beside the artifact, stored apart from it, and never a reason this submit fails.
6053
+ if (stepKey === 'step_4') {
6054
+ try {
6055
+ const { computeReviewDiff } = require('./lib/review-diff');
6056
+ reviewDiff = computeReviewDiff({ repoRoot: process.cwd(), rendered,
6057
+ warn: (line) => { warned = true; console.error(line); } });
6058
+ } catch (e) { reviewDiff = null; warned = true; console.error(`review diff not sent: ${e.message}`); }
6059
+ if (reviewDiff) {
6060
+ console.error(`review diff: ${reviewDiff.stats.files} file(s), ${reviewDiff.notes.length} note(s), against ${reviewDiff.headSha.slice(0, 12)}`);
6061
+ for (const n of reviewDiff.notes) console.error(` review diff note: ${n}`);
6062
+ } else {
6063
+ console.error(warned ? 'review diff: none (see above)' : 'review diff: none (no modified file over the bundle cap)');
6064
+ }
6065
+ }
6049
6066
  } else {
6050
6067
  output = fs.readFileSync(opts.file, 'utf8');
6051
6068
  }
6052
6069
  const { data } = await client.post(
6053
6070
  orchestratePath([runId, 'step', stepKey, 'editor-output']),
6054
- { contractVersion: opts.contract, output }
6071
+ { contractVersion: opts.contract, output,
6072
+ ...(reviewDiff ? { reviewDiff: require('./lib/review-diff').toWire(reviewDiff) } : {}) }
6055
6073
  );
6056
6074
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6057
6075
  console.log(`Step ${data.stepKey} completed (executed_by: ${data.executedBy}); gate: ${data.gateResult}.`);
6076
+ if (data.reviewDiffIgnored) console.error(`review diff not stored: ${data.reviewDiffIgnored} (the reviewers see the files unverified, as before)`);
6058
6077
  console.log(`Approve when reviewed: hone run-story ${runId} --approve ${data.stepKey}`);
6059
6078
  console.log('Verification is unchanged — check-patch/verify-patch judge this output, not trust.');
6060
6079
  } catch (e) {
@@ -6107,10 +6126,6 @@ function runContextIo(apiConfig) {
6107
6126
  e2eSpecMode: () => pc.readE2eSpecMode(cwd),
6108
6127
  verificationGate: () => pc.readVerificationGateConfig(cwd),
6109
6128
  verificationGateSources: () => pc.readVerificationGateSources(cwd),
6110
- readPlanText: () => {
6111
- const planPath = path.join(cwd, '.github', 'EXECUTION_PLAN.yml');
6112
- return fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
6113
- },
6114
6129
  exists: (rel) => { const abs = inRepo(rel); return !!abs && fs.existsSync(abs); },
6115
6130
  isDirectory: (rel) => { const abs = inRepo(rel); try { return !!abs && fs.statSync(abs).isDirectory(); } catch { return false; } },
6116
6131
  readFile: (rel) => {
@@ -6200,6 +6215,7 @@ program
6200
6215
  .option('--include-paths <paths>', 'Source file paths to bundle into the codebase context (HC-019n-followup-12). Comma-separated, and the flag may be repeated; both forms accumulate. Augments auto-detected paths from the issue body.', collectIncludePaths, [])
6201
6216
  .option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
6202
6217
  .option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
6218
+ .option('--consult-architect', 'HC-GF-397: run the architect consult (step_0b) even when the groomer says architect_engaged: false. It can only add the consult, never skip it.')
6203
6219
  .action(async (storyIdOrRunId, opts) => {
6204
6220
  const config = getConfig();
6205
6221
  const client = api(config);
@@ -6678,6 +6694,7 @@ program
6678
6694
  explicitSource: opts.storyFile ? { kind: 'file', label: path.basename(String(opts.storyFile)) } : { kind: 'description' },
6679
6695
  includePaths: opts.includePaths,
6680
6696
  axes: opts.axes,
6697
+ consultArchitect: opts.consultArchitect,
6681
6698
  });
6682
6699
  printRunContextNotes(storyCtx.notes.pre);
6683
6700
  printRunContextNotes(repoCtx.notes);
@@ -20,6 +20,11 @@
20
20
  *
21
21
  * Pure helper — caller wraps fs.readFileSync + path resolution so the
22
22
  * helper is unit-testable with stubs.
23
+ *
24
+ * HC-GF-030: NO PRODUCTION CODE CALLS THIS ANY MORE. `architect_consulted` and `checklist_b_completed`
25
+ * are server-owned (workflow-store.js SERVER_OWNED_CONFIG_KEYS; run-context.js SERVER_OWNED_KEYS): the
26
+ * server writes them when its step_0b consult is approved, and a plan file's claim is not evidence that
27
+ * a consult ran on THIS story. The module stays as the documented reader of the plan's shape.
23
28
  */
24
29
 
25
30
  const path = require('node:path');
@@ -59,9 +59,11 @@ const FORBIDDEN_FLAGS = Object.freeze(['--trust-source', '--skip-verify', '--rea
59
59
  * Pause reasons that mean a child is NOT at a gate waiting for its step_4 output to be judged. `GET /:runId`
60
60
  * sets `awaitingApproval` on ANY paused run with a current step, so without this a child paused by a breaker,
61
61
  * a budget, an agent's hard pause or a guardrail would look like a gate, and the loop would run its patch.
62
- * Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan.
62
+ * Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan, and
63
+ * architect-consult.js CONSULT_REQUIRED_CODE (HC-GF-030): a run the consult guard refused at a step it had no
64
+ * consult for. Its attempt is `pending` and has no output, and approving it is refused by the server.
63
65
  */
64
- const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|hard_pause at )/;
66
+ const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|ARCHITECT_CONSULT_REQUIRED:|hard_pause at )/;
65
67
 
66
68
  /** What each child exit code means, for the action line. verify-patch.js and emit-pr.js EXIT maps. */
67
69
  const CHECK_EXIT = { 0: 'applies', 1: 'no diff', 2: 'does not apply' };
@@ -393,6 +395,9 @@ function priorWhy(prior, runId) {
393
395
  if (prior.state === 'adopted') {
394
396
  return `a newer run of this story already exists${b ? ` (batch ${b})` : ''}, queued by an earlier re-serve or by you, so it is not re-served again`;
395
397
  }
398
+ if (prior.state === 'inherited') {
399
+ return `this run is already the re-serve of ${prior.fromRunId ? `run ${prior.fromRunId}` : 'an earlier run'}${b ? ` (it is in batch ${b})` : ''}, so it is not re-served a second time`;
400
+ }
396
401
  if (prior.state === 'queue-failed') return 're-serving it failed earlier in this run; nothing more is tried';
397
402
  return `it names files again after being re-served as batch ${b}; a second re-serve is not tried`;
398
403
  }
@@ -450,7 +455,7 @@ function stuckReason(waiting) {
450
455
  * @param {Record<string, object>} details runId → `GET /orchestrate/:runId`, for paused runs and runs to emit
451
456
  * @param {{emitted: Set<string>, reserved?: Map<string, {batchId: string|null, state: string}>}} memory `emitted`: runs
452
457
  * whose emit-pr already ran in this process; `reserved`: story → what became of its re-serve (`state`: served,
453
- * kill-failed, queue-failed or adopted)
458
+ * kill-failed, queue-failed, adopted or inherited)
454
459
  * @param {{repoName?: string|null, openPr?: boolean, tracked?: Set<string>|null, trackedRetry?: string|null,
455
460
  * realPath?: ((file: string) => string|null)|null}} [ctx] this checkout, so a run for another repository is
456
461
  * not touched; `tracked`: the regular files git tracks here, which a request is checked against (`trackedRetry`:
@@ -594,7 +599,7 @@ function planTick(batch, details, memory, ctx = {}) {
594
599
  * crash between the queue and the kill, leaves no marker, and only a newer run of the same story shows it happened.
595
600
  * @returns {Promise<{lines: string[], needsYou: boolean, batchId: string|null, state: string|null, retry?: boolean}>}
596
601
  * `batchId`: the batch the story was queued as, set whenever it was, even when the kill then failed; `state`: what
597
- * became of the re-serve (served, kill-failed, queue-failed, adopted); `retry`: nothing was decided, ask again
602
+ * became of the re-serve (served, kill-failed, queue-failed, adopted = a newer run exists, inherited = this run is itself the re-serve); `retry`: nothing was decided, ask again
598
603
  */
599
604
  async function reserve(action, io, tag) {
600
605
  if (io.priorServe) {
@@ -606,12 +611,16 @@ async function reserve(action, io, tag) {
606
611
  needsYou: false, batchId: null, state: null, retry: true };
607
612
  }
608
613
  if (prior && prior.kind === 'unknown') {
609
- return { lines: [`${tag} not re-served: could not tell whether the story was served already; the paused run was left alone`],
614
+ return { lines: [`${tag} not re-served: could not tell whether the story was served already${prior.why ? ` (${prior.why})` : ''}; the paused run was left alone`],
610
615
  needsYou: true, batchId: null, state: 'queue-failed' };
611
616
  }
617
+ if (prior && prior.kind === 'earlier') {
618
+ return { lines: [`${tag} not re-served: this run is already the re-serve of run ${prior.runId} (it is in batch ${prior.batchId}), so it is not re-served a second time`],
619
+ needsYou: true, batchId: prior.batchId || null, state: 'inherited', fromRunId: prior.runId };
620
+ }
612
621
  if (prior) {
613
622
  const where = prior.batchId ? ` in batch ${prior.batchId}` : '';
614
- return { lines: [`${tag} not re-served: run ${prior.runId}${where} is this story's ${prior.kind === 'later' ? 'newer' : 'earlier, re-served'} run; following it`],
623
+ return { lines: [`${tag} not re-served: a newer run of this story exists (run ${prior.runId}${where})${prior.batchId ? '; following it' : '; its batch was not found, so it is not followed'}`],
615
624
  needsYou: true, batchId: prior.batchId || null, state: 'adopted' };
616
625
  }
617
626
  }
@@ -695,6 +704,13 @@ async function autoReject(action, io, tag) {
695
704
  const status = statusOf(e);
696
705
  const f = fatal(e, `rejecting ${where}`);
697
706
  if (f) return { lines: [], needsYou: false, state: 'retry', fatal: f };
707
+ // HC-GF-323-followup-2: the server caps rejects at step_4's max_retries_per_step, counting a person's rejects too, so
708
+ // its refusal can come before this client's own bound. That 409 is not "the run moved on": the run is still paused
709
+ // at the failed attempt, and only a person can approve, kill or raise the guardrail.
710
+ if (status === 409 && e.response.data && e.response.data.code === 'REJECT_RETRIES_EXHAUSTED') {
711
+ const note = `${where} failed verification and the server refused the reject: ${oneLine(e.response.data.error, 160)}`;
712
+ return { lines: [`${tag} ${note}`], needsYou: true, state: 'stuck', note };
713
+ }
698
714
  if (status === 409) return { lines: [`${tag} ${where} was not rejected: the run moved on (409); this does not count`], needsYou: false, state: 'moved' };
699
715
  if (status === 400) {
700
716
  const note = `${where} failed verification and the server refused the reject (400: ${oneLine(e.response.data && e.response.data.error, 120)})`;
@@ -889,7 +905,7 @@ async function followBatch(batchId, io, opts = {}) {
889
905
  }
890
906
  if (action.kind === 'reserve') {
891
907
  if (!out.retry) {
892
- memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state });
908
+ memory.reserved.set(action.storyId, { batchId: out.batchId, state: out.state, fromRunId: out.fromRunId });
893
909
  if (out.batchId && !followed.includes(out.batchId)) { followed.push(out.batchId); grew = true; }
894
910
  }
895
911
  } else if (action.kind === 'emit' || action.kind === 'draft') {
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-390: the leaf home of the bundle's per-file cap. run-context.js windows a file whose text is
4
+ * longer than this (bundle-paths.js `planFileExcerpt`: `text.length <= limit` is sent whole), and
5
+ * review-diff.js diffs exactly the files a reviewer saw only windows of, so both read it from here.
6
+ * run-context.js re-exports it under the same name for the tests that import it from there.
7
+ */
8
+ const BUNDLE_MAX_PER_FILE_CHARS = 25_000;
9
+
10
+ module.exports = { BUNDLE_MAX_PER_FILE_CHARS };
package/lib/call-sites.js CHANGED
@@ -73,9 +73,22 @@ const SCOPE = 'tracked-repo';
73
73
 
74
74
  // The caps. server/src/services/call-sites.js accepts at least these (pinned by a cross-package test): a server
75
75
  // that took less would quietly turn a complete list into an incomplete one.
76
- const MAX_SYMBOLS = 6;
77
- /** Secret-looking names are reported, not searched; they take no slot of the 6 but are reported up to this many. */
76
+ // HC-GF-385: 16, up from 6. Six slots spent on string keys (`step_0b`, `config`) left the functions a story changes
77
+ // NOT SEARCHED and hard-stopped step_1 four times on one story. The wire budget is held by MAX_TOTAL_HITS, not by
78
+ // the symbol count: a symbol past it is sent as its per-file list (FILES ONLY), so 16 costs what 6 did.
79
+ const MAX_SYMBOLS = 16;
80
+ /** Secret-looking names are reported, not searched; they take no slot of the 16 but are reported up to this many. */
78
81
  const MAX_REFUSED = 2;
82
+ /**
83
+ * HC-GF-385: the hit lines ALL symbols together may send, spent in rank order: today's 6 x 40, so the 448 KiB
84
+ * per-story wire budget (server/src/lib/batch-body-limit.js) and its test keep their meaning. A symbol reached after
85
+ * it is spent sends `hits: []` plus its per-file list.
86
+ */
87
+ const MAX_TOTAL_HITS = 240;
88
+ /** HC-GF-385: the definition probe ranks at most this many candidates, best-ranked first. */
89
+ const MAX_PROBE_CANDIDATES = 32;
90
+ /** HC-GF-385: the probe's own share of TOTAL_BUDGET_MS; past it the story's own order is kept, and said. */
91
+ const PROBE_TIMEOUT_MS = 8_000;
79
92
  /** Past MAX_HITS_PER_SYMBOL a symbol also carries a per-file list (path + hit count), up to this many files... */
80
93
  const MAX_FILE_LIST_FILES = 300;
81
94
  /** ...and all the symbols' file lists together stay under this many bytes of JSON (server/src/lib/batch-body-limit.js budgets it). */
@@ -97,7 +110,38 @@ const PROSE_EXTENSIONS = Object.freeze(['md', 'mdx', 'markdown', 'rst', 'adoc',
97
110
 
98
111
  // ── Choosing the symbols ────────────────────────────────────────────────────────────────────────
99
112
 
100
- const BACKTICKED = /`([^`\n]{2,200})`/g;
113
+ // Backticked spans are PAIRED first, by the CommonMark rule, and the 2-to-200 rule is applied to each paired span afterwards.
114
+ // A bound INSIDE a regex decides which backticks pair: at `x`, and `foo` the opening backtick of `x` cannot start a 2-character
115
+ // match, so its closing one does, the "span" is `, and`, and `foo` is lost (measured 2026-10-10 over the roadmap rows: 68 of 939
116
+ // extracted a wrong span set). A plain /`([^`\n]*)`/ fixed that and made the next one: after a run of TWO OR MORE backticks
117
+ // (a ``double`` span, an inline ```fence) the pairing never recovered for the rest of the line, and 7 rows lost symbols the old
118
+ // pattern found. The rule that has no such failure is the one Markdown itself uses: a run of N backticks opens a span that the
119
+ // next run of EXACTLY N backticks closes, and a run with no such closer is literal text. Per line (a span does not cross a
120
+ // newline here, as before). Linear: the next run of the same length is computed once per run, not searched for.
121
+ // The same defect as BACKTICKED_RE in bundle-paths.js (HC-GF-188-followup-2), which has the first fix only.
122
+ function backtickedSpans(text) {
123
+ const spans = [];
124
+ for (const line of String(text || '').split('\n')) {
125
+ if (!line.includes('`')) continue;
126
+ const runs = [];
127
+ for (const m of line.matchAll(/`+/g)) runs.push({ at: m.index, len: m[0].length });
128
+ const nextSame = new Array(runs.length).fill(-1);
129
+ const lastOfLen = new Map();
130
+ for (let i = runs.length - 1; i >= 0; i--) {
131
+ nextSame[i] = lastOfLen.has(runs[i].len) ? lastOfLen.get(runs[i].len) : -1;
132
+ lastOfLen.set(runs[i].len, i);
133
+ }
134
+ for (let i = 0; i < runs.length; i++) {
135
+ const j = nextSame[i];
136
+ if (j < 0) continue; // no closer of this length: the run is literal text
137
+ let span = line.slice(runs[i].at + runs[i].len, runs[j].at);
138
+ if (span.length > 2 && span.startsWith(' ') && span.endsWith(' ') && /[^ ]/.test(span)) span = span.slice(1, -1); // CommonMark: one space each side
139
+ spans.push(span);
140
+ i = j; // resume after the closer; the runs between are inside the span
141
+ }
142
+ }
143
+ return spans;
144
+ }
101
145
  // One identifier, optionally qualified (`a.b`, `A::b`, `a->b`, `A#b`) and optionally called (`f()`, `f(x)`). The LAST
102
146
  // segment is the symbol: `this.ensureConnected` is a reference to `ensureConnected` wherever it is called from.
103
147
  // Group 1 is the qualifier (empty when there is none), 2 the name, 3 the call's parentheses (undefined when not called).
@@ -158,25 +202,129 @@ function symbolOfSpan(span) {
158
202
  * @returns {{symbols: string[], omitted: string[], omittedTotal: number, refused: string[]}}
159
203
  */
160
204
  function pickSymbols(storyText, { max = MAX_SYMBOLS } = {}) {
205
+ const { ranked, refusedAll } = rankCandidates(storyText);
206
+ return sliceRanked(ranked.map((c) => c.name), refusedAll, max);
207
+ }
208
+
209
+ /**
210
+ * Every candidate the story names, in the story's order (explicitly written first, then by how often it is named,
211
+ * ties in the order first named), with the facts that order was made from. Secret-looking names are split off.
212
+ * Synchronous and text-only: `pickSymbols` keeps that contract for callers this file cannot see (HC-GF-385).
213
+ * @returns {{ranked: Array<{name: string, explicit: boolean, count: number}>, refusedAll: string[]}}
214
+ */
215
+ function rankCandidates(storyText) {
161
216
  const counts = new Map();
162
217
  const explicitNames = new Set();
163
- for (const m of String(storyText || '').matchAll(BACKTICKED)) {
164
- const sym = symbolOfSpan(m[1]);
218
+ for (const span of backtickedSpans(storyText)) {
219
+ if (span.length < 2 || span.length > 200) continue; // the length rule, after pairing
220
+ const sym = symbolOfSpan(span);
165
221
  if (!sym) continue;
166
222
  counts.set(sym.name, (counts.get(sym.name) || 0) + 1);
167
223
  if (sym.explicit) explicitNames.add(sym.name);
168
224
  }
169
225
  // Array.prototype.sort is stable (ES2019), and a Map iterates in insertion order: ties keep the order the story first names them.
170
226
  const all = [...counts.keys()].sort((a, b) => (explicitNames.has(b) - explicitNames.has(a)) || (counts.get(b) - counts.get(a)));
171
- const refusedAll = all.filter((n) => SECRET_NAME.test(n));
172
- const ranked = all.filter((n) => !SECRET_NAME.test(n));
173
- const refused = refusedAll.slice(0, MAX_REFUSED);
174
- const beyond = [...ranked.slice(max), ...refusedAll.slice(MAX_REFUSED)];
175
227
  return {
176
- symbols: ranked.slice(0, max),
228
+ ranked: all.filter((n) => !SECRET_NAME.test(n)).map((name) => ({ name, explicit: explicitNames.has(name), count: counts.get(name) })),
229
+ refusedAll: all.filter((n) => SECRET_NAME.test(n)),
230
+ };
231
+ }
232
+
233
+ /** The first `max` names are searched; the rest, and the refused names past MAX_REFUSED, are reported as omitted. */
234
+ function sliceRanked(names, refusedAll, max) {
235
+ const beyond = [...names.slice(max), ...refusedAll.slice(MAX_REFUSED)];
236
+ return {
237
+ symbols: names.slice(0, max),
177
238
  omitted: beyond.slice(0, MAX_OMITTED_NAMES),
178
239
  omittedTotal: beyond.length,
179
- refused,
240
+ refused: refusedAll.slice(0, MAX_REFUSED),
241
+ };
242
+ }
243
+
244
+ // ── Ranking by definition (HC-GF-385) ───────────────────────────────────────────────────────────
245
+
246
+ /** Files whose matches never define code: documentation, and data formats (an object or YAML key is not a symbol). */
247
+ const DATA_EXTENSIONS = Object.freeze(['json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'lock', 'csv', 'tsv', 'xml', 'ini', 'env', 'properties']);
248
+ /** A word before `name(` that makes it a call or an expression, never a C-family definition. */
249
+ const NOT_A_RETURN_TYPE = new Set(['return', 'await', 'new', 'throw', 'yield', 'else', 'case', 'typeof', 'delete', 'void', 'in', 'of', 'do',
250
+ 'if', 'while', 'for', 'switch', 'catch', 'and', 'or', 'not', 'is', 'as', 'echo', 'print', 'puts', 'go', 'defer', 'export', 'default']);
251
+
252
+ /**
253
+ * How strongly one line defines `name`: 2 for a function, method, class or type; 1 for a top-level or exported
254
+ * constant or variable; 0 for anything else (a call, a key, a mention). Language-neutral patterns, covering
255
+ * JavaScript and TypeScript, Python, Go, Ruby, Java and the C family, and `fn`/`fun`/`func` (Rust, Kotlin, Swift).
256
+ * `name` is an identifier ([A-Za-z0-9_]), so it is safe inside a RegExp.
257
+ * @param {string} line
258
+ * @param {string} name
259
+ * @returns {0|1|2}
260
+ */
261
+ function definitionTier(line, name) {
262
+ const n = `${name}(?![A-Za-z0-9_$])`;
263
+ const t = String(line);
264
+ // A keyword that introduces a definition: `function f`, `class C`, `def f`, `def self.f`, `func (r *T) f`, `fn f`, `fun f`.
265
+ if (new RegExp(`(?:^|[^A-Za-z0-9_$.])(?:function\\*?|class|def|func|fn|fun|interface|enum|struct|trait|type|module|record|protocol)\\s+(?:self\\.)?(?:\\([^)]*\\)\\s*)?${n}`).test(t)) return 2;
266
+ // A function value bound at top level or exported: `export const f = async (a) =>`, `const f = function`.
267
+ if (new RegExp(`^(?:export\\s+)?(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\b\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\\s*=>)`).test(t)) return 2;
268
+ // A method, in a class body or an object literal: ` async f(a, b) {`, ` static get f() {`.
269
+ if (new RegExp(`^\\s*(?:(?:static|async|get|set|public|private|protected|readonly|override|abstract|final)\\s+)*${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^={;]+)?\\{`).test(t)) return 2;
270
+ // A C-family signature: one or more type words, then `name(`, and no `;` (a prototype or a call statement).
271
+ const sig = new RegExp(`^\\s*((?:[A-Za-z_][A-Za-z0-9_<>\\[\\],:*&?]*\\s+)+)\\*?${n}\\s*\\([^;]*$`).exec(t);
272
+ if (sig) {
273
+ const words = sig[1].trim().split(/\s+/);
274
+ if (!NOT_A_RETURN_TYPE.has(words[words.length - 1])) return 2;
275
+ }
276
+ // A top-level or exported binding of anything else: `const f =`, `export let f`, `module.exports.f =`, Go `var f`.
277
+ if (new RegExp(`^(?:export\\s+)?(?:const|let|var|val)\\s+${n}`).test(t)) return 1;
278
+ if (new RegExp(`^(?:module\\.)?exports\\.${n}\\s*=`).test(t)) return 1;
279
+ // Python and Ruby top-level assignment: `f = ...` at column 0, not `f == x`.
280
+ if (new RegExp(`^${n}\\s*=(?!=)`).test(t)) return 1;
281
+ return 0;
282
+ }
283
+
284
+ /**
285
+ * Rank candidates by whether the repository defines them, with ONE batched `git grep -F -w` over all of them.
286
+ * Functions, methods, classes and types come first, then top-level or exported constants, then names with no
287
+ * definition (string keys, config fields, words). Within a tier the story's own order holds. Any failure, timeout
288
+ * or output this cannot read keeps the story's order, and the note says so: a probe never empties the search.
289
+ * @param {Array<{name: string}>} ranked the story's order
290
+ * @returns {Promise<{ranked: Array<{name: string}>, fallback: string|null, undefinedNames: string[]}>}
291
+ */
292
+ async function rankByDefinition(ranked, { run, realPath }) {
293
+ if (ranked.length === 0) return { ranked, fallback: null, undefinedNames: [] };
294
+ const probed = ranked.slice(0, MAX_PROBE_CANDIDATES);
295
+ const argv = ['grep', '-n', '-z', '-w', '-F', '-I', '--no-color', '--no-recurse-submodules'];
296
+ for (const c of probed) argv.push('-e', c.name);
297
+ let r;
298
+ try { r = await run(argv, { timeoutMs: PROBE_TIMEOUT_MS }); } catch { r = { failure: 'the probe could not be run' }; }
299
+ const problem = grepProblem(r || {});
300
+ if (problem) return { ranked, fallback: `the definition probe gave no answer (${problem}), so the story's own order was used`, undefinedNames: [] };
301
+ const lines = r.status === 1 ? [] : parseGrepOutput(r.stdout);
302
+ if (lines === null) return { ranked, fallback: 'the definition probe printed output that could not be read, so the story\'s own order was used', undefinedNames: [] };
303
+
304
+ const tier = new Map(probed.map((c) => [c.name, 0]));
305
+ const kinds = new Map();
306
+ const wordRe = /[A-Za-z_$][A-Za-z0-9_$]*/g;
307
+ for (const hit of lines) {
308
+ let kind = kinds.get(hit.path);
309
+ if (kind === undefined) {
310
+ const ext = path.posix.extname(hit.path).slice(1).toLowerCase();
311
+ kind = DATA_EXTENSIONS.includes(ext) ? 'data' : classifyPath(hit.path, realPath);
312
+ kinds.set(hit.path, kind);
313
+ }
314
+ if (kind !== 'ok') continue; // documentation, data and withheld paths define nothing here
315
+ for (const w of new Set(String(hit.text).match(wordRe) || [])) {
316
+ if (!tier.has(w) || tier.get(w) === 2) continue;
317
+ const d = definitionTier(hit.text, w);
318
+ if (d > tier.get(w)) tier.set(w, d);
319
+ }
320
+ }
321
+ // Stable: within a tier the story's order is kept. Candidates past the probe cap keep their place after the probed.
322
+ const order = probed.map((c, i) => ({ c, i, t: tier.get(c.name) }))
323
+ .sort((a, b) => (b.t - a.t) || (a.i - b.i)).map((x) => x.c);
324
+ return {
325
+ ranked: [...order, ...ranked.slice(MAX_PROBE_CANDIDATES)],
326
+ fallback: null,
327
+ undefinedNames: probed.filter((c) => tier.get(c.name) === 0).map((c) => c.name),
180
328
  };
181
329
  }
182
330
 
@@ -288,7 +436,7 @@ function parseFileList(out) {
288
436
  * - 'not_searched': no answer, and `reason` says why. Never to be read as "no callers".
289
437
  * `binary` is how many files match only when git's binary-file skip (-I) is off; they cannot be listed.
290
438
  */
291
- async function searchSymbol(symbol, { run, realPath }) {
439
+ async function searchSymbol(symbol, { run, realPath, maxHits = MAX_HITS_PER_SYMBOL }) {
292
440
  // Defence in depth: pickSymbols only yields identifiers, but this is exported, and the symbol reaches an argv and a RegExp.
293
441
  if (typeof symbol !== 'string' || !/^[A-Za-z0-9_]+$/.test(symbol)) {
294
442
  return { symbol: String(symbol).slice(0, 80), status: 'not_searched', reason: 'not an identifier' };
@@ -323,7 +471,9 @@ async function searchSymbol(symbol, { run, realPath }) {
323
471
  else sendable.push(hit);
324
472
  }
325
473
  const total = sendable.length + withheld;
326
- const hits = sendable.slice(0, MAX_HITS_PER_SYMBOL).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
474
+ // HC-GF-385: `maxHits` is what is left of the story's MAX_TOTAL_HITS (0 once it is spent), never more than the per-symbol cap.
475
+ const cap = Math.max(0, Math.min(MAX_HITS_PER_SYMBOL, Number.isSafeInteger(maxHits) ? maxHits : MAX_HITS_PER_SYMBOL));
476
+ const hits = sendable.slice(0, cap).map((h) => ({ path: h.path, line: h.line, text: lineText(h.text, symbol) }));
327
477
  const perFile = new Map();
328
478
  for (const h of sendable) perFile.set(h.path, (perFile.get(h.path) || 0) + 1);
329
479
  const entry = {
@@ -336,7 +486,10 @@ async function searchSymbol(symbol, { run, realPath }) {
336
486
  binary,
337
487
  hits,
338
488
  };
339
- if (sendable.length > MAX_HITS_PER_SYMBOL) {
489
+ // HC-GF-385: every searched symbol with a sendable hit carries its per-file list, not only one past the hit cap.
490
+ // A symbol cut by the total-hit budget needs it to be FILES ONLY, and a whole symbol whose hit lines the server's
491
+ // render budget drops needs it to stay an enumeration at file level.
492
+ if (sendable.length > 0) {
340
493
  const all = [...perFile].map(([p, n]) => ({ path: p, hits: n }));
341
494
  entry.file_list = all.slice(0, MAX_FILE_LIST_FILES);
342
495
  entry.file_list_complete = all.length <= MAX_FILE_LIST_FILES;
@@ -344,11 +497,17 @@ async function searchSymbol(symbol, { run, realPath }) {
344
497
  return entry;
345
498
  }
346
499
 
347
- /** Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so. */
500
+ /**
501
+ * Keep the symbols' file lists inside MAX_FILE_LIST_BYTES together; a list that is cut says so.
502
+ * HC-GF-385: the bytes go first to the symbols whose hit lines were cut (by the per-symbol cap or the total-hit
503
+ * budget), in rank order, because for them the list IS the enumeration; then to the rest, in rank order. Spent
504
+ * in plain rank order, whole symbols at the top starved the FILES ONLY ones below them into INCOMPLETE.
505
+ */
348
506
  function fitFileLists(symbols) {
349
507
  let room = MAX_FILE_LIST_BYTES;
350
- for (const s of symbols) {
351
- if (!s.file_list) continue;
508
+ const cut = (s) => Array.isArray(s.hits) && Number.isSafeInteger(s.total) && s.hits.length < s.total - (s.withheld || 0);
509
+ const ordered = [...symbols.filter((s) => s.file_list && cut(s)), ...symbols.filter((s) => s.file_list && !cut(s))];
510
+ for (const s of ordered) {
352
511
  const kept = [];
353
512
  for (const f of s.file_list) {
354
513
  const size = Buffer.byteLength(JSON.stringify(f), 'utf8') + 1;
@@ -379,8 +538,9 @@ const warn = (text) => ({ level: 'warn', text });
379
538
  */
380
539
  async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now, maxSymbols = MAX_SYMBOLS }) {
381
540
  const notes = [];
382
- const picked = pickSymbols(storyText, { max: maxSymbols });
383
- if (picked.symbols.length === 0 && picked.refused.length === 0) return { callSites: null, notes };
541
+ const { ranked: storyOrder, refusedAll } = rankCandidates(storyText);
542
+ // The early return comes BEFORE the probe: a story that names nothing searchable runs no git at all.
543
+ if (storyOrder.length === 0 && refusedAll.length === 0) return { callSites: null, notes };
384
544
 
385
545
  // From the repository's top level, wherever `hone` ran: `git grep` from a subdirectory searches only that subtree.
386
546
  let root = cwd;
@@ -389,17 +549,24 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
389
549
  const dir = top.failure || top.status !== 0 ? '' : top.stdout.trim();
390
550
  if (dir) root = dir;
391
551
  }
392
- const git = run || ((argv) => runGit(argv, { cwd: root }));
552
+ const git = run || ((argv, opts = {}) => runGit(argv, { cwd: root, ...opts }));
393
553
  const real = realPath || ((rel) => repoRealPath(root, rel));
394
554
  const commit = await headCommit(git);
395
- const symbols = [];
555
+ // HC-GF-385: the clock starts before the probe, so the probe's time comes out of the same TOTAL_BUDGET_MS.
396
556
  const started = now();
557
+ // Rank BEFORE the slice and compute what was omitted AFTER it, or the functions never reach the slice.
558
+ const probe = await rankByDefinition(storyOrder, { run: git, realPath: real });
559
+ const picked = sliceRanked(probe.ranked.map((c) => c.name), refusedAll, maxSymbols);
560
+ const symbols = [];
561
+ let hitsLeft = MAX_TOTAL_HITS;
397
562
  for (const symbol of picked.symbols) {
398
563
  if (now() - started > TOTAL_BUDGET_MS) {
399
564
  symbols.push({ symbol, status: 'not_searched', reason: `the ${TOTAL_BUDGET_MS / 1000}s search budget for this story was spent on earlier symbols` });
400
565
  continue;
401
566
  }
402
- symbols.push(await searchSymbol(symbol, { run: git, realPath: real }));
567
+ const entry = await searchSymbol(symbol, { run: git, realPath: real, maxHits: hitsLeft });
568
+ if (Array.isArray(entry.hits)) hitsLeft = Math.max(0, hitsLeft - entry.hits.length);
569
+ symbols.push(entry);
403
570
  }
404
571
  for (const symbol of picked.refused) symbols.push({ symbol, status: 'not_searched', reason: SECRET_REASON });
405
572
  fitFileLists(symbols);
@@ -413,12 +580,17 @@ async function collectCallSites({ cwd, storyText, run, realPath, now = Date.now,
413
580
  omitted: picked.omitted,
414
581
  omitted_total: picked.omittedTotal,
415
582
  };
416
- notes.push(...describeCallSites(callSites));
583
+ if (probe.fallback) notes.push(warn(` ⚠ call sites: ${probe.fallback}`));
584
+ const searchedUndefined = probe.undefinedNames.filter((n) => picked.symbols.includes(n));
585
+ if (!probe.fallback && searchedUndefined.length > 0) {
586
+ notes.push(info(` no definition found for ${searchedUndefined.join(', ')} (ranked after defined code; it may be a key, a field, or one this story creates)`));
587
+ }
588
+ notes.push(...describeCallSites(callSites, { maxSymbols }));
417
589
  return { callSites, notes };
418
590
  }
419
591
 
420
592
  /** The operator's lines for a result: one info line per symbol that is whole, a warning for each that is not. */
421
- function describeCallSites(callSites) {
593
+ function describeCallSites(callSites, { maxSymbols = MAX_SYMBOLS } = {}) {
422
594
  const notes = [];
423
595
  const where = callSites.commit ? `tracked files at ${callSites.commit.slice(0, 12)}` : 'tracked files (commit unknown)';
424
596
  notes.push(info(` → call sites: searched ${callSites.symbols.length} symbol(s) the story names, in ${where}`));
@@ -437,7 +609,10 @@ function describeCallSites(callSites) {
437
609
  binary ? `${binary} binary file${binary === 1 ? '' : 's'} match and cannot be listed` : '',
438
610
  ].filter(Boolean).join('; ');
439
611
  const fileLevel = s.status === 'incomplete' && binary === 0 && s.file_list_complete === true && Array.isArray(s.file_list);
440
- if (s.status === 'incomplete' && fileLevel) {
612
+ if (s.status === 'incomplete' && fileLevel && s.hits.length === 0) {
613
+ // HC-GF-385: reached after the total-hit budget was spent. The server renders it FILES ONLY.
614
+ notes.push(info(` ${s.symbol}: FILES ONLY — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; every file with a hit is named with its count, individual lines not sent (the ${MAX_TOTAL_HITS}-hit budget was spent on higher-ranked symbols)${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
615
+ } else if (s.status === 'incomplete' && fileLevel) {
441
616
  notes.push(info(` ${s.symbol}: COMPLETE AT FILE LEVEL — ${listable} hits in ${s.files} file${s.files === 1 ? '' : 's'}; the first ${s.hits.length} are listed and every file with a hit is named with its count${s.withheld ? `; EXCEPT ${s.withheld} hit${s.withheld === 1 ? '' : 's'} in withheld paths the planner cannot see` : ''}${s.prose ? `; ${s.prose} more in documentation` : ''}`));
442
617
  } else if (s.status === 'incomplete') {
443
618
  notes.push(warn(` ⚠ call sites for ${s.symbol}: INCOMPLETE — ${s.hits.length} of ${listable} hits listed (cap ${MAX_HITS_PER_SYMBOL}${s.file_list ? `, ${s.file_list.length} of ${s.files} files named` : ''}); the planner cannot claim an enumeration${extra ? ` (${extra})` : ''}`));
@@ -454,16 +629,21 @@ function describeCallSites(callSites) {
454
629
  const omittedTotal = callSites.omitted_total || 0;
455
630
  if (omittedTotal > 0) {
456
631
  const shown = callSites.omitted.join(', ') + (omittedTotal > callSites.omitted.length ? ', …' : '');
457
- notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${MAX_SYMBOLS}-symbol cap — NOT SEARCHED: ${shown}`));
632
+ notes.push(warn(` ⚠ call sites: the story names ${omittedTotal} more compound identifier(s) beyond the ${maxSymbols}-symbol cap — NOT SEARCHED: ${shown}`));
458
633
  }
459
634
  return notes;
460
635
  }
461
636
 
462
637
  module.exports = {
638
+ backtickedSpans,
463
639
  CALL_SITES_VERSION,
464
640
  SCOPE,
465
641
  MAX_SYMBOLS,
466
642
  MAX_REFUSED,
643
+ MAX_TOTAL_HITS,
644
+ MAX_PROBE_CANDIDATES,
645
+ PROBE_TIMEOUT_MS,
646
+ DATA_EXTENSIONS,
467
647
  MAX_FILE_LIST_FILES,
468
648
  MAX_FILE_LIST_BYTES,
469
649
  SECRET_NAME,
@@ -478,6 +658,9 @@ module.exports = {
478
658
  MAX_OUTPUT_BYTES,
479
659
  isCompoundIdentifier,
480
660
  pickSymbols,
661
+ rankCandidates,
662
+ definitionTier,
663
+ rankByDefinition,
481
664
  fitFileLists,
482
665
  runGit,
483
666
  parseGrepOutput,
@@ -208,21 +208,24 @@ function batchIdFromQueueOutput(stdout) {
208
208
  * status, created_at, error_message), newest first.
209
209
  * - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
210
210
  * queued but whose kill failed, or that died between the two, leaves exactly this and no marker.
211
- * - `earlier`: a run of the story killed with `re-served as batch <id>` BEFORE this one, so this run is that
212
- * re-serve (the loop was started on the new batch alone) and a second pause here is the second.
213
- * - `unknown`: the paused run's creation time is not a date, so neither can be told.
214
- * @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string}|null}
211
+ * - `earlier`: a run of the story, older than this one, killed with `re-served as batch <id>` where <id> is the batch
212
+ * THIS run is in (`batchId`): this run is that re-serve (the loop was started on the new batch alone), and a second
213
+ * pause here is the second. A story re-served into some OTHER batch, however long ago, is history and not this
214
+ * run's lineage: it neither blocks nor justifies anything (HC-GF-351-followup-1).
215
+ * - `unknown`: the paused run's creation time is not a date or its batch id is not one, so neither can be told.
216
+ * @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string, why?: string}|null}
215
217
  */
216
- function serveHistory(rows, { runId, storyId, repoName, since }) {
218
+ function serveHistory(rows, { runId, storyId, repoName, since, batchId: ownBatch }) {
217
219
  const t0 = Date.parse(since);
218
- if (!Number.isFinite(t0)) return { kind: 'unknown' };
220
+ if (!Number.isFinite(t0)) return { kind: 'unknown', why: 'its creation time is not known' };
221
+ if (!isBatchId(ownBatch)) return { kind: 'unknown', why: 'the batch it is in is not known' };
219
222
  for (const row of Array.isArray(rows) ? rows : []) {
220
223
  if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
221
224
  const t = Date.parse(row.created_at);
222
225
  if (!Number.isFinite(t)) continue;
223
226
  if (t > t0 && row.status !== 'killed') return { kind: 'later', runId: row.id };
224
- const batchId = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
225
- if (batchId) return { kind: 'earlier', runId: row.id, batchId };
227
+ const reserved = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
228
+ if (reserved && reserved === ownBatch) return { kind: 'earlier', runId: row.id, batchId: reserved };
226
229
  }
227
230
  return null;
228
231
  }
@@ -27,10 +27,15 @@
27
27
  // U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
28
28
  // \u escapes: a literal bidi character in this source would reorder how THIS file reads.
29
29
  // eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
30
- const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
31
-
30
+ const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff\u{e0000}-\u{e007f}]/gu;
31
+ // A line break or tab is a space, so two words do not join; the characters above draw nothing yet change what a reader sees
32
+ // (zero-width, soft hyphen, BOM, the Unicode tag block); a lone surrogate is not a character a terminal or JSON can carry.
33
+ // Mirrors server/src/services/architect-consult.js scrubForTerminal: the two packages cannot share code, and a test pins them equal.
32
34
  function scrub(text) {
33
- return String(text == null ? '' : text).replace(TERMINAL_UNSAFE_RE, '');
35
+ return String(text == null ? '' : text)
36
+ .replace(/[\t\n\v\f\r\u0085\u2028\u2029]+/g, ' ')
37
+ .replace(TERMINAL_UNSAFE_RE, '')
38
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\ufffd');
34
39
  }
35
40
 
36
41
  /**
@@ -91,4 +96,4 @@ function gateQuestionLines(status, stepKey) {
91
96
  ];
92
97
  }
93
98
 
94
- module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
99
+ module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase, scrub };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ /**
3
+ * review-diff.js — HC-GF-390: the base diff a reviewer needs beside a whole emitted file.
4
+ *
5
+ * In editor mode step_4's artifact carries every changed file whole (tree-artifact.js), while the
6
+ * reviewers (step_5, 5d, 5e) see the ORIGINAL only through the bundle, which windows a file longer
7
+ * than BUNDLE_MAX_PER_FILE_CHARS. A change outside those windows was compared with nothing. This
8
+ * computes, for step_4 only, `git diff HEAD` of each file the render emitted whole whose HEAD version
9
+ * is over that cap. It is sent beside the artifact as `reviewDiff` and stored apart from it: the
10
+ * artifact itself is never touched.
11
+ *
12
+ * It never fails a submit. Every git call has a timeout; anything it cannot do becomes a one-line
13
+ * note, and a note means "unverified", exactly as an absent diff did before.
14
+ *
15
+ * Exit modes of what it consumes (HC-GF-117):
16
+ * rendered.files HANDLED: the only candidates. A created, deleted, renamed, binary-at-render
17
+ * or over-1 MB file is never in it, so it is never diffed.
18
+ * git rev-parse fails HANDLED: no HEAD → null, nothing sent.
19
+ * git diff --name-status HANDLED: a throw → one note, nothing diffed.
20
+ * per-file git calls HANDLED: a throw (timeout, ENOBUFS, spawn, non-zero) → a note, next file.
21
+ * "Binary files … differ" HANDLED: a note (a text file .gitattributes marks binary).
22
+ * empty diff HANDLED: a note (the file no longer differs from HEAD).
23
+ * post-image ≠ rendered HANDLED: a note. The diff must describe the bytes the reviewer was sent.
24
+ */
25
+
26
+ const crypto = require('node:crypto');
27
+ const { execFileSync } = require('node:child_process');
28
+ const { gitEnv } = require('./git-env');
29
+ const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits');
30
+ const { isSecretPath } = require('./bundle-context');
31
+ const { FILE_BLOCK_RE } = require('./block-grammar');
32
+
33
+ const REVIEW_DIFF_MAX_CHARS = 60_000; // diff text, summed over the files included
34
+ const PER_CALL_MS = 15_000;
35
+ const DEADLINE_MS = 60_000; // every per-file call gets min(PER_CALL_MS, what is left)
36
+ const MAX_NOTES = 100;
37
+ const MAX_NOTE_CHARS = 300;
38
+ // Kept equal to server/src/services/review-diff.js REVIEW_DIFF_MAX_BYTES (pinned by the HC-GF-390 tests).
39
+ const REVIEW_DIFF_MAX_BYTES = 256 * 1024;
40
+
41
+ const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
42
+
43
+ /** The bundle windows a file whose text is longer than the cap (bundle-paths.js `text.length <= limit` is whole). */
44
+ const overCap = (length) => length > BUNDLE_MAX_PER_FILE_CHARS;
45
+
46
+ /**
47
+ * The pinned diff. Every option a user's git config could change is given on the command line, as a
48
+ * flag the `diff` subcommand takes (not a leading `-c`, so a caller can still recognise the call).
49
+ */
50
+ function diffArgs(headSha, rel) {
51
+ return ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--full-index',
52
+ '--diff-algorithm=myers', '--no-relative', '--inter-hunk-context=0', '-U3',
53
+ '--src-prefix=a/', '--dst-prefix=b/', headSha, '--', rel];
54
+ }
55
+
56
+ function defaultRunGit(repoRoot) {
57
+ return (args, timeoutMs) => execFileSync('git', args, {
58
+ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
59
+ timeout: Math.max(1, Math.floor(timeoutMs)), stdio: ['ignore', 'pipe', 'pipe'],
60
+ });
61
+ }
62
+
63
+ /**
64
+ * The contents of the first `file:` block for `rel` in a rendered artifact, as the server's
65
+ * carry-forward `blocksOf` reads it (same grammar, same scope, same one-newline strip), or null.
66
+ */
67
+ function blockContents(text, rel) {
68
+ const t = String(text || '');
69
+ const sec = t.search(/^##\s+Changed Files/im);
70
+ const scope = t.slice(sec === -1 ? 0 : sec);
71
+ for (const m of scope.matchAll(FILE_BLOCK_RE)) {
72
+ if (m[2].trim() === rel) return m[3].replace(/\r?\n$/, '');
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** git's object id of a blob holding `str` as UTF-8, computed in memory (never `hash-object -w`). */
78
+ function gitBlobId(str, algo = 'sha1') {
79
+ const bytes = Buffer.from(String(str), 'utf8');
80
+ return crypto.createHash(algo).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
81
+ }
82
+
83
+ /** One line, no control characters, at most `max` chars. */
84
+ function oneLine(s, max) {
85
+ const flat = String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]+/g, ' ').trim();
86
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
87
+ }
88
+
89
+ /**
90
+ * @param {object} opts
91
+ * @param {string} opts.repoRoot
92
+ * @param {{text: string, files: string[]}} opts.rendered renderFromTree()'s result
93
+ * @param {(args: string[], timeoutMs: number) => string} [opts.runGit]
94
+ * @param {() => number} [opts.now]
95
+ * @param {(line: string) => void} [opts.warn] why nothing is sent when that is not "no candidate"
96
+ * @returns {null | { headSha: string, diff: string, notes: string[], stats: object }}
97
+ * null when there is nothing to send: no HEAD, no candidate, or a payload over its byte cap.
98
+ */
99
+ function computeReviewDiff({ repoRoot, rendered, runGit, now = Date.now, warn = () => {} } = {}) {
100
+ try {
101
+ const git = runGit || defaultRunGit(repoRoot);
102
+ const emitted = new Set(rendered && Array.isArray(rendered.files) ? rendered.files : []);
103
+ if (!emitted.size) return null;
104
+
105
+ const deadline = now() + DEADLINE_MS;
106
+ let headSha;
107
+ try { headSha = String(git(['rev-parse', '--verify', 'HEAD^{commit}'], PER_CALL_MS)).trim(); } catch { return null; }
108
+ if (!SHA_RE.test(headSha)) return null;
109
+ const algo = headSha.length === 64 ? 'sha256' : 'sha1';
110
+
111
+ const notes = [];
112
+ const sections = [];
113
+ let used = 0;
114
+ const note = (rel, reason) => notes.push(oneLine(`${JSON.stringify(rel)}: ${reason}`, MAX_NOTE_CHARS));
115
+
116
+ let candidates = [];
117
+ try {
118
+ const tokens = String(git(['diff', '--name-status', '-z', '--no-renames', headSha], PER_CALL_MS)).split('\0');
119
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
120
+ const status = tokens[i]; const rel = tokens[i + 1];
121
+ if ((status === 'M' || status === 'T') && emitted.has(rel)) candidates.push(rel);
122
+ }
123
+ candidates = [...new Set(candidates)].sort();
124
+ } catch (e) {
125
+ notes.push(oneLine(`(all files): git failed listing changes against HEAD: ${e && e.message}`, MAX_NOTE_CHARS));
126
+ }
127
+
128
+ for (let i = 0; i < candidates.length; i++) {
129
+ const rel = candidates[i];
130
+ const left = () => Math.min(PER_CALL_MS, deadline - now());
131
+ let t = left();
132
+ if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
133
+ try {
134
+ // The size first: it reads no content, and a file at or under the cap was bundled whole.
135
+ // UTF-8 bytes >= UTF-16 length, so a size at or under the cap cannot be a length over it.
136
+ const size = Number(String(git(['cat-file', '-s', `${headSha}:${rel}`], t)).trim());
137
+ if (!Number.isFinite(size) || !overCap(size)) continue;
138
+ if (isSecretPath(rel)) { note(rel, 'secret path; not diffed'); continue; }
139
+ t = left();
140
+ if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
141
+ const head = String(git(['cat-file', 'blob', `${headSha}:${rel}`], t));
142
+ if (!overCap(head.length)) continue;
143
+ t = left();
144
+ if (t <= 0) { notes.push(`deadline reached; ${candidates.length - i} file(s) not diffed`); break; }
145
+ const out = String(git(diffArgs(headSha, rel), t));
146
+ if (!out.trim()) { note(rel, 'no longer differs from HEAD; not diffed'); continue; }
147
+ if (/^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out)) { note(rel, 'binary to git; not diffed'); continue; }
148
+ if ((out.match(/^diff --git /gm) || []).length !== 1) { note(rel, 'unexpected diff shape; not included'); continue; }
149
+ const post = /^index [0-9a-f]+\.\.([0-9a-f]+)(?: [0-7]+)?$/m.exec(out);
150
+ const contents = blockContents(rendered.text, rel);
151
+ if (!post || contents === null || post[1] !== gitBlobId(`${contents}\n`, algo)) {
152
+ note(rel, 'post-image differs from the rendered bytes (an eol or clean filter, no final newline, '
153
+ + 'non-UTF-8 content, or a change after render); not included');
154
+ continue;
155
+ }
156
+ const section = out.endsWith('\n') ? out : `${out}\n`;
157
+ if (used + section.length > REVIEW_DIFF_MAX_CHARS) {
158
+ note(rel, `over the ${REVIEW_DIFF_MAX_CHARS.toLocaleString('en-US')}-char review-diff budget; not included`);
159
+ continue;
160
+ }
161
+ sections.push(section);
162
+ used += section.length;
163
+ } catch (e) {
164
+ note(rel, `git failed: ${oneLine(String((e && e.message) || e).split('\n')[0], 120)}`);
165
+ }
166
+ }
167
+
168
+ const kept = notes.length > MAX_NOTES
169
+ ? [...notes.slice(0, MAX_NOTES - 1), `${notes.length - (MAX_NOTES - 1)} more note(s) omitted`]
170
+ : notes;
171
+ if (!sections.length && !kept.length) return null;
172
+ const payload = { headSha, diff: sections.join(''), notes: kept };
173
+ const bytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
174
+ if (bytes > REVIEW_DIFF_MAX_BYTES) { warn(`review diff not sent: ${bytes} bytes is over the ${REVIEW_DIFF_MAX_BYTES}-byte limit`); return null; }
175
+ return { ...payload, stats: { candidates: candidates.length, files: sections.length, chars: used } };
176
+ } catch (e) {
177
+ warn(`review diff not sent: ${oneLine((e && e.message) || e, 200)}`);
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /** Exactly what `hone submit-step` posts as `reviewDiff`: the stats stay CLI-side. One mapper, so a test can post what the CLI posts. */
183
+ function toWire(r) { return { headSha: r.headSha, diff: r.diff, notes: r.notes }; }
184
+
185
+ module.exports = {
186
+ computeReviewDiff, blockContents, gitBlobId, toWire,
187
+ _internal: { overCap, diffArgs, REVIEW_DIFF_MAX_CHARS, PER_CALL_MS, DEADLINE_MS, MAX_NOTES, MAX_NOTE_CHARS, REVIEW_DIFF_MAX_BYTES },
188
+ };
@@ -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) {
@@ -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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {