@hone-ai/cli 1.34.0 → 1.35.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.
Files changed (44) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1263 -756
  3. package/lib/agent-eval-pacing.js +102 -0
  4. package/lib/agent-eval-probes-boundary.js +0 -0
  5. package/lib/agent-eval-probes-stateful.js +72 -0
  6. package/lib/agent-eval-score.js +70 -0
  7. package/lib/agent-executor.js +26 -4
  8. package/lib/asset-fetch-list.js +72 -0
  9. package/lib/auto-verify.js +67 -3
  10. package/lib/batch-follow.js +333 -0
  11. package/lib/block-grammar.js +126 -0
  12. package/lib/bundle-context.js +43 -4
  13. package/lib/bundle-paths.js +698 -1
  14. package/lib/bundle-source-dirs.js +79 -0
  15. package/lib/doctor-bind-default.js +192 -18
  16. package/lib/doctor-local-run.js +139 -1
  17. package/lib/emit-pr.js +39 -6
  18. package/lib/git-helpers.js +19 -8
  19. package/lib/hard-pause-remedy.js +101 -0
  20. package/lib/include-paths.js +155 -0
  21. package/lib/learnings-parse.js +51 -6
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/pipeline-config.js +182 -0
  24. package/lib/private-temp.js +34 -0
  25. package/lib/promote-payload.js +77 -0
  26. package/lib/refresh-knowledge.js +15 -6
  27. package/lib/repo-tree.js +22 -3
  28. package/lib/run-context.js +698 -0
  29. package/lib/sandbox-lifecycle.js +357 -0
  30. package/lib/schedule-cron.js +1 -1
  31. package/lib/skill-assertions.js +61 -7
  32. package/lib/skill-audit-render.js +1 -1
  33. package/lib/story-classifier-extract.js +16 -8
  34. package/lib/story-classifier.js +64 -0
  35. package/lib/story-compose.js +69 -0
  36. package/lib/story-line.js +40 -0
  37. package/lib/story-provenance.js +267 -0
  38. package/lib/timeout-option.js +42 -0
  39. package/lib/untrusted-code-env.js +51 -0
  40. package/lib/verdict-env.js +31 -0
  41. package/lib/verify-patch.js +74 -4
  42. package/lib/verify-pr.js +2 -1
  43. package/lib/worktree-provision.js +62 -2
  44. package/package.json +1 -1
@@ -0,0 +1,698 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-097 — the one place a pipeline run's config is built.
4
+ *
5
+ * `hone run-story` assembled fourteen config keys inline, one try/catch at a time, and
6
+ * `hone queue-stories` was written beside it with two of them. Nothing noticed, because each key
7
+ * lived in the control flow of one command rather than in a value both could share: a queued
8
+ * story ran with no story text, no codebase bundle, no repository tree, and — through the server's
9
+ * default — `ci_gate: github` whatever `.pipeline-config.yml` said. The fix makes the config a
10
+ * VALUE produced here and consumed by both commands, so the two cannot drift again:
11
+ *
12
+ * const repo = resolveRepoContext(io, opts); // once per command
13
+ * const story = await resolveStoryContext(io, repo, id, o); // once per story
14
+ * const config = buildRunConfig(repo, story); // the only assembler
15
+ *
16
+ * Pure in the `cli/lib` sense: every read of the disk, git, GitHub and the Hone server arrives
17
+ * through `io`, and nothing here prints or exits. Each step's outcome — including every
18
+ * fail-open default — comes back as a note `{ level: 'info'|'warn', text }`, and the command
19
+ * decides how to show it. The texts are the ones run-story printed before the move, so its output
20
+ * is unchanged.
21
+ *
22
+ * The no-text POLICY is deliberately not here. run-story warns and proceeds on a story with no
23
+ * text (HC-019n-followup-18); queue-stories refuses the batch. This module reports `rawText`
24
+ * and `hasStoryText` answers the question; each command decides what an empty answer means.
25
+ */
26
+
27
+ const { composeStoryDescription } = require('./story-compose');
28
+ const { extractStoryIdFromBranch } = require('./pipeline-status');
29
+ const {
30
+ extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
31
+ } = require('./bundle-paths');
32
+ const { selectBundle, auditLines } = require('./bundle-context');
33
+ const { parseIncludePaths, partitionIncludeTargets, statedAnchorsFor, anchorWarnings } = require('./include-paths');
34
+ const { buildRepoTree, allDirs, MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS } = require('./repo-tree');
35
+ const { classifyStory } = require('./story-classifier');
36
+ const {
37
+ extractType, extractSecurityImplications, extractCrossRepoDependency,
38
+ } = require('./story-classifier-extract');
39
+ const { readArchitectConfigFromText } = require('./architect-config');
40
+ const { remoteHostFromUrl } = require('./story-provenance');
41
+ const path = require('path');
42
+
43
+ /**
44
+ * Keys the SERVER owns in a run's config. None may come out of here. The server strips them from
45
+ * whatever a client sends (workflow-store.js SERVER_OWNED_CONFIG_KEYS, HC-GF-097-followup-1) and
46
+ * refuses a per-story `overnight` outright; refusing here as well makes an assembler that emits one
47
+ * fail loudly instead of losing the value on the server. The two lists are pinned equal by
48
+ * tests/regression/HC-GF-097-followup-1-server-owned-keys.test.js.
49
+ */
50
+ const SERVER_OWNED_KEYS = Object.freeze([
51
+ 'batchTokenBudget', 'batchFailureThreshold', 'dependsOnRunIds', 'monthlyTokenBudget', 'overnight',
52
+ ]);
53
+
54
+ // HC-019n-followup-13d: the bundle caps. OptionsFlow #96 hit 49,783 of the old 50,000 with six
55
+ // files, so the next slightly larger story silently dropped one. 30 / 150K / 25K.
56
+ const BUNDLE_MAX_FILES = 30;
57
+ const BUNDLE_MAX_TOTAL_CHARS = 150_000;
58
+ const BUNDLE_MAX_PER_FILE_CHARS = 25_000;
59
+
60
+ const info = (text) => ({ level: 'info', text });
61
+ const warn = (text) => ({ level: 'warn', text });
62
+ const firstLine = (e) => ((e && e.message) ? e.message.split('\n')[0] : String(e));
63
+
64
+ // ── Story provenance (HC-GF-270) ─────────────────────────────────────────────────────────────
65
+ // Where a story's text came from, recorded as raw facts on the run config (`story_source`). Trust
66
+ // is DERIVED from them in one place, cli/lib/story-provenance.js: a patch built from text an
67
+ // outside party wrote must not execute as the operator before a human reads it. HC-GF-270 stamped
68
+ // this inline in run-story; HC-GF-097 moved run-story's config assembly here, so it is stamped
69
+ // here, and queue-stories, which now fetches the same issue text, records the same facts.
70
+
71
+ /**
72
+ * Why a provenance lookup failed, as a CLASSIFICATION, never gh's raw stderr: `workflow_runs.config`
73
+ * outlives the run and validateConfig inspects key NAMES only, so a stderr line that echoed a token
74
+ * or a signed URL would be persisted server-side (HC-GF-270 review LOW-7). Reads the error code and
75
+ * gh's stderr, never the `Command failed: <cmd>` line, and removes the repository name before
76
+ * matching, so `acme/auth-service` is not an auth failure (review LOW-2, second review LOW-3).
77
+ */
78
+ function classifyProvenanceFailure(err, ownerRepo) {
79
+ if (err && err.code === 'HC_URL_MISMATCH') return 'the issue text and its provenance record name different issues';
80
+ let detail = [
81
+ // An async execFile timeout reports `killed` with no code; without this it reads as a generic
82
+ // failure. (A child killed for overflowing its buffer carries its own code, which wins.)
83
+ err && (err.code || (err.killed ? 'ETIMEDOUT' : null)),
84
+ err && err.stderr && String(err.stderr),
85
+ err && err.message && String(err.message).replace(/^Command failed:[^\n]*\n?/, ''),
86
+ ].filter(Boolean).join('\n') || String(err);
87
+ if (ownerRepo) detail = detail.split(ownerRepo).join('<repo>');
88
+ return /timed? ?out|ETIMEDOUT/i.test(detail) ? 'the gh api call timed out'
89
+ : /rate limit|HTTP 403/i.test(detail) ? 'gh api refused the request (rate limit or forbidden)'
90
+ : /HTTP 404|not found/i.test(detail) ? 'gh api could not find that issue (HTTP 404)'
91
+ : /auth|credential|gh auth login|HTTP 401/i.test(detail) ? 'gh is not authenticated for this host'
92
+ : /ENOTFOUND|ENETUNREACH|ECONNREFUSED|dial tcp/i.test(detail) ? 'the gh api call could not reach the network'
93
+ : /JSON|Unexpected token/i.test(detail) ? 'gh api returned output that is not JSON'
94
+ : 'the gh api call failed';
95
+ }
96
+
97
+ /**
98
+ * The `story_source` of an issue-sourced story, from a SECOND gh call: `gh issue view --json` does
99
+ * not expose author_association (measured on gh 2.92). A failure is recorded, untrusted, with its
100
+ * classified reason, so it PAUSES the run rather than letting it proceed. gh's own first line goes
101
+ * to the operator as a note (printed locally) and is never persisted.
102
+ * @returns {Promise<{ source: object, notes: object[] }>}
103
+ */
104
+ async function issueStorySource(io, issueNumber, issue) {
105
+ const number = String(issueNumber);
106
+ let where = null;
107
+ try {
108
+ const got = await io.fetchIssueProvenance(number);
109
+ where = got;
110
+ const api = (got && got.issue) || {};
111
+ // The text came from the first call and the trust from the second, so they must name the SAME
112
+ // issue. A disagreement is never resolved in favour of trust (review LOW-5).
113
+ if (issue && issue.url && api.html_url && issue.url !== api.html_url) {
114
+ throw Object.assign(new Error('issue text and provenance name different issues'), { code: 'HC_URL_MISMATCH' });
115
+ }
116
+ const source = {
117
+ kind: 'issue',
118
+ ref: { owner: got.owner, repo: got.repo, number },
119
+ label: `${got.owner}/${got.repo}#${number}`,
120
+ author: (api.user && api.user.login) || null,
121
+ association: api.author_association,
122
+ html_url: api.html_url,
123
+ remoteHost: remoteHostFromUrl(got.remoteUrl),
124
+ };
125
+ return { source, notes: [info(` → story provenance: ${source.author || 'unknown'} (author_association: ${source.association || 'unknown'})`)] };
126
+ } catch (e) {
127
+ where = where || (e && e.where) || null;
128
+ const classified = classifyProvenanceFailure(e, where ? `${where.owner}/${where.repo}` : null);
129
+ const ghSaid = e && e.stderr ? String(e.stderr).split('\n').map((l) => l.trim()).find(Boolean) : null;
130
+ return {
131
+ source: {
132
+ kind: 'issue',
133
+ ref: where ? { owner: where.owner, repo: where.repo, number } : null,
134
+ label: where ? `${where.owner}/${where.repo}#${number}` : `#${number}`,
135
+ error: classified,
136
+ },
137
+ notes: [
138
+ warn(` ⚠ could not establish issue provenance: ${classified}${ghSaid ? ` — gh said: ${ghSaid}` : ` (${firstLine(e)})`}`),
139
+ warn(' → this run will NOT auto-verify; you will be asked to read the patch first'),
140
+ ],
141
+ };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Everything a run's config takes from the REPOSITORY rather than the story. Computed once per
147
+ * command, so a 50-story batch lists the repository once, not fifty times.
148
+ *
149
+ * @param {object} io
150
+ * @param {() => string[]} io.listFiles tracked paths; throws when git cannot list
151
+ * @param {(trackedPaths: string[]) => {source_dirs:string[],test_dirs:string[],repaired?:boolean,dropped?:string[],detected?:boolean}} io.sourceDirs
152
+ * @param {() => {include_paths:string[]}} io.codebaseDefaults
153
+ * @param {() => {gate:string,local_command:string}} io.ciGate
154
+ * @param {() => {raw:string|null,mode:string,recognized:boolean}} io.executionMode
155
+ * @param {string} io.consentNote shown when editor mode is used
156
+ * @param {() => {overlays:object[],skipped:string[]}} io.ruleOverlays
157
+ * @param {() => string} io.e2eSpecMode
158
+ * @param {() => string} io.verificationGate
159
+ * @param {() => string[]} io.verificationGateSources
160
+ * @param {() => string} io.readPlanText EXECUTION_PLAN.yml text, '' when absent
161
+ * @param {{ e2eSpecs?: boolean, skipE2eSpecs?: boolean, treeByteCap?: number }} [opts] run-story's per-run
162
+ * flags; `treeByteCap` is queue-stories' byte cap on the tree's path lists (none for run-story)
163
+ */
164
+ function resolveRepoContext(io, opts = {}) {
165
+ const config = {};
166
+ const notes = [];
167
+
168
+ // HC-DF-001: config-level default include paths, merged with the story's own paths below.
169
+ let defaultIncludePaths = [];
170
+ try {
171
+ const d = io.codebaseDefaults();
172
+ defaultIncludePaths = Array.isArray(d && d.include_paths) ? d.include_paths : [];
173
+ } catch (e) {
174
+ notes.push(warn(` ⚠ codebase.default_include_paths read failed (non-fatal): ${firstLine(e)}`));
175
+ }
176
+
177
+ // One listing serves both the bundle's bare-ref resolution and the tree. It used to be taken
178
+ // twice, with different buffers (64 MB and the 1 MB default), so a repository whose path list
179
+ // passed 1 MB bundled its files but silently lost its tree.
180
+ let repoFiles = [];
181
+ let listError = null;
182
+ try { repoFiles = io.listFiles(); } catch (e) { listError = e; }
183
+
184
+ // HC-019n-followup-42: repository-structure grounding. Bundling FILES is not grounding — with
185
+ // no tree in the prompt the groomer invents a layout and the planner inherits it (dogfood run
186
+ // f276fe00). Fail-open.
187
+ try {
188
+ if (listError) throw listError;
189
+ // The listing just taken is handed on, so a stale config's self-heal does not list git again.
190
+ const dirs = io.sourceDirs(repoFiles);
191
+ // HC-DF-002: readSourceDirs self-heals a stale config; say so, or the operator sees a
192
+ // confident success line built from directories that do not exist.
193
+ if (dirs.repaired) {
194
+ notes.push(info(` ⚠ source dirs in .pipeline-config.yml not found on disk: ${dirs.dropped.join(', ')}`));
195
+ notes.push(info(` using ${dirs.detected ? 'detected' : 'the surviving'} dirs instead: ${dirs.source_dirs.join(', ') || '(none)'}`));
196
+ notes.push(info(' fix it permanently: hone detect-layout, then set codebase.source_dirs in .github/.pipeline-config.yml'));
197
+ }
198
+ const tree = buildRepoTree(repoFiles, {
199
+ sourceDirs: dirs.source_dirs, testDirs: dirs.test_dirs, maxTransportBytes: opts.treeByteCap,
200
+ });
201
+ if (tree.selected_total > 0) {
202
+ config.repo_tree = tree;
203
+ const scope = dirs.source_dirs.length ? dirs.source_dirs.join(', ') : 'all tracked';
204
+ notes.push(info(` → repository structure: ${tree.shown} of ${tree.selected_total} paths under ${scope} (${(Buffer.byteLength(tree.text, 'utf8') / 1024).toFixed(1)} KB) — the groomer/planner/builder are told to derive every path from it`));
205
+ // HC-GF-097: over a transport cap the path lists are omitted and the server reads the tree as
206
+ // v1, so it does NOT check the planner's `## Files` against this repository. That is a guard
207
+ // switching off; say so, and say which cap did it.
208
+ if (!Array.isArray(tree.paths)) {
209
+ const dirCount = allDirs(repoFiles).length;
210
+ const why = tree.selected_total > MAX_TRANSPORT_PATHS
211
+ ? `${tree.selected_total} paths, over the ${MAX_TRANSPORT_PATHS}-path cap`
212
+ : dirCount > MAX_TRANSPORT_DIRS
213
+ ? `${dirCount} directories, over the ${MAX_TRANSPORT_DIRS}-directory cap`
214
+ : `the path lists exceed the ${opts.treeByteCap}-byte cap`;
215
+ notes.push(warn(` ⚠ repository tree sent WITHOUT its path lists (${why}) — the server will not check the planner's \`## Files\` paths against this repository. Narrow codebase.source_dirs to bring it under the cap.`));
216
+ }
217
+ }
218
+ } catch (e) {
219
+ notes.push(warn(` ⚠ repository structure not bundled (non-fatal): ${firstLine(e)}`));
220
+ }
221
+
222
+ // HC-101-followup-2: the adopter's CI gate, so step_5c can branch (github / local / both /
223
+ // none). Defaults to github + 'make ci' when the config predates the field.
224
+ try {
225
+ const ciGate = io.ciGate();
226
+ config.ci_gate = ciGate.gate;
227
+ config.ci_local_command = ciGate.local_command;
228
+ notes.push(info(` → CI gate mode: ${ciGate.gate}${ciGate.gate !== 'github' ? ` (local_command: ${ciGate.local_command})` : ''}`));
229
+ } catch (e) {
230
+ notes.push(warn(` ⚠ CI gate config read failed (non-fatal, defaulting to gate=github): ${firstLine(e)}`));
231
+ config.ci_gate = 'github';
232
+ config.ci_local_command = 'make ci';
233
+ }
234
+
235
+ // HC-COMM-010 B0.5: execution.mode is stamped at CREATION, so a mid-flight
236
+ // .pipeline-config.yml flip cannot change a running run.
237
+ let execution;
238
+ try {
239
+ execution = io.executionMode();
240
+ config.execution_mode = execution.mode;
241
+ if (execution.mode === 'editor') {
242
+ // §2a: the consent note is shown at the moment editor mode is USED.
243
+ notes.push(info(' → execution mode: editor (opt-in)'));
244
+ notes.push(info(` ${io.consentNote}`));
245
+ }
246
+ } catch (e) {
247
+ notes.push(warn(` ⚠ execution mode read failed (non-fatal, defaulting to server): ${firstLine(e)}`));
248
+ config.execution_mode = 'server';
249
+ // Could-not-check is not checked: an unattended batch must not read this as `server`.
250
+ execution = { raw: null, mode: 'server', recognized: false, unreadable: firstLine(e) };
251
+ }
252
+
253
+ // HC-COMM-010 C0: composition transports. .hone-local/ is adopter disk the server can never
254
+ // read, so raw overlay texts travel in the run config, size/count-capped, degrade-to-empty.
255
+ try {
256
+ const ov = io.ruleOverlays();
257
+ if (ov.overlays.length > 0) {
258
+ config.rule_overlays = ov.overlays;
259
+ notes.push(info(` → bundled ${ov.overlays.length} rule overlay(s) for server-side composition`));
260
+ }
261
+ for (const skip of ov.skipped) notes.push(warn(` ⚠ overlay skipped: ${skip}`));
262
+ } catch (e) {
263
+ notes.push(warn(` ⚠ overlay read failed (non-fatal): ${firstLine(e)}`));
264
+ }
265
+
266
+ // HC-019n-followup-28: the E2E spec-generation mode. Per-run flag > config > `auto`. The
267
+ // "is E2E required?" decision stays server-side; the CLI passes only the MODE.
268
+ try {
269
+ let e2eMode, source;
270
+ if (opts.e2eSpecs) { e2eMode = 'auto'; source = '--e2e-specs'; }
271
+ else if (opts.skipE2eSpecs) { e2eMode = 'never'; source = '--skip-e2e-specs'; }
272
+ else { e2eMode = io.e2eSpecMode(); source = 'config/default'; }
273
+ config.e2e_spec_generation = e2eMode;
274
+ // The step_3b row is created unless the adopter opted out; the orchestrator still skips it
275
+ // at runtime for a story that does not require an E2E plan.
276
+ if (e2eMode !== 'never') config.enableConditional = ['step_3b'];
277
+ notes.push(info(` → E2E spec generation (step_3b): ${e2eMode} (source: ${source})`));
278
+ } catch (e) {
279
+ notes.push(warn(` ⚠ e2e spec-mode read failed (non-fatal, defaulting to auto): ${firstLine(e)}`));
280
+ config.e2e_spec_generation = 'auto';
281
+ }
282
+
283
+ // HC-019n-followup-34: the closed-loop verification gate. HC-COMM-011-followup-6: which verdict
284
+ // sources may DRIVE the step_4 gate under enforce.
285
+ try {
286
+ config.verification_gate = io.verificationGate();
287
+ config.verification_gate_sources = io.verificationGateSources();
288
+ if (config.verification_gate !== 'advisory') {
289
+ notes.push(info(` → verification gate: ${config.verification_gate} (sources: ${config.verification_gate_sources.join(', ')})`));
290
+ }
291
+ } catch (e) {
292
+ notes.push(warn(` ⚠ verification-gate read failed (non-fatal, defaulting to advisory): ${firstLine(e)}`));
293
+ config.verification_gate = 'advisory';
294
+ config.verification_gate_sources = ['verify-patch'];
295
+ }
296
+
297
+ // HC-019b: EXECUTION_PLAN.yml, read once. Each story's architect flags come from it below.
298
+ let planText = '';
299
+ try { planText = io.readPlanText() || ''; } catch (e) {
300
+ notes.push(warn(` ⚠ EXECUTION_PLAN.yml read failed (non-fatal, architect flags default to {false, false}): ${firstLine(e)}`));
301
+ }
302
+
303
+ return { config, notes, repoFiles, defaultIncludePaths, planText, execution };
304
+ }
305
+
306
+ /**
307
+ * Everything a run's config takes from the STORY: its text, the code it names, its classifier
308
+ * axes and its architect flags.
309
+ *
310
+ * @param {object} io
311
+ * @param {(n: string) => Promise<{title:string, body?:string, url:string}>|{title:string, body?:string, url:string}} io.fetchIssue
312
+ * rejects or throws on failure
313
+ * @param {(n: string) => Promise<{owner:string, repo:string, remoteUrl:string, issue:object}>} io.fetchIssueProvenance
314
+ * HC-GF-270: `gh api repos/<owner>/<repo>/issues/<n>`; rejects with gh's stderr on the error, and
315
+ * `where: {owner, repo}` once the remote was parsed
316
+ * @param {(id: string) => Promise<object>} io.fetchStoryContext GET /stories/:id/context; throws
317
+ * an axios-shaped error (`e.response.status`) on refusal
318
+ * @param {(rel: string) => boolean} io.exists repository-scoped; false for anything outside it
319
+ * @param {(rel: string) => boolean} [io.isDirectory] repository-scoped; HC-GF-288 names a directory as the reason
320
+ * @param {(rel: string) => string} io.readFile repository-scoped; throws when unreadable
321
+ * @param {(touched: string[]) => boolean} io.recentlyModifiedOverlap
322
+ * @param {object} repoCtx resolveRepoContext's result
323
+ * @param {string} storyId the id as the operator typed it — sent as `storyId`
324
+ * @param {{ explicitText?: string|null, explicitLabel?: string, explicitSource?: {kind: 'file'|'description', label?: string},
325
+ * includePaths?: string[], axes?: string }} [opts]
326
+ * @returns {Promise<{ storyId: string, resolvedStoryId: string, rawText: string,
327
+ * textSource: 'explicit'|'issue'|'roadmap'|null, textFailure: {kind:string, detail?:string}|null,
328
+ * config: object, bundle: {files:number, chars:number}, notes: {pre: object[], post: object[]} }>}
329
+ */
330
+ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
331
+ const config = {};
332
+ const pre = [];
333
+ const post = [];
334
+ let rawText = '';
335
+ let textSource = null;
336
+ let textFailure = null;
337
+ let resolvedStoryId = storyId; // canonical id for the architect-config lookup
338
+
339
+ // ── Story text: explicit → GitHub issue → Hone roadmap ────────────────────────────────────
340
+ // HC-GF-010: an EXPLICIT story source short-circuits both lookups, so the operator can never
341
+ // be told "no roadmap row found" about a story they just handed us.
342
+ if (opts.explicitText) {
343
+ config.story_description = opts.explicitText;
344
+ rawText = opts.explicitText;
345
+ textSource = 'explicit';
346
+ // HC-GF-270: the operator wrote this text, or pointed at a file they chose, so it is trusted.
347
+ // The label is the file's NAME only: it is persisted server-side, and the operator's path is
348
+ // not the server's business (second review LOW-5).
349
+ const src = opts.explicitSource || {};
350
+ config.story_source = src.kind === 'file'
351
+ ? { kind: 'file', ref: null, label: path.basename(String(src.label || '')) || 'story file' }
352
+ : { kind: 'description', ref: null, label: '--description' };
353
+ pre.push(info(` → story text from ${opts.explicitLabel || '--description'} (${opts.explicitText.length} chars) — roadmap and issue lookups skipped`));
354
+ } else if (/^\d+$/.test(storyId)) {
355
+ // HC-019n-followup-11: a bare integer is a GitHub issue number. Degrade gracefully on any
356
+ // failure; the step_1 hard_pause net catches the resulting placeholder cascade.
357
+ try {
358
+ const issue = await io.fetchIssue(storyId);
359
+ const desc = `# ${issue.title}\n\n${issue.body || '_(issue body is empty)_'}\n\n_Source: ${issue.url}_`;
360
+ config.story_description = desc;
361
+ rawText = `${issue.title}\n${issue.body || ''}`;
362
+ textSource = 'issue';
363
+ if (!rawText.trim()) textFailure = { kind: 'issue-empty' };
364
+ pre.push(info(` → fetched GitHub issue #${storyId} for story context (${desc.length} chars)`));
365
+ // HC-GF-270: the text is now something an outside party may have written, and the pipeline
366
+ // turns it into a patch that auto-verify EXECUTES as the operator. Record who wrote it.
367
+ const prov = await issueStorySource(io, storyId, issue);
368
+ config.story_source = prov.source;
369
+ pre.push(...prov.notes);
370
+ // HC-019b-followup-1 F1: the canonical story id from the issue title, or the architect
371
+ // lookup keys on '104' and silently misses every EXECUTION_PLAN.yml entry.
372
+ try {
373
+ const titleId = extractStoryIdFromBranch(issue.title);
374
+ if (titleId) {
375
+ resolvedStoryId = titleId;
376
+ pre.push(info(` → resolved issue #${storyId} → canonical story id '${titleId}' for architect-config lookup`));
377
+ } else {
378
+ pre.push(warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyId}' (likely silent miss)`));
379
+ }
380
+ } catch { /* extractor throws → fall back to the numeric id */ }
381
+ } catch (e) {
382
+ textFailure = { kind: 'issue-error', detail: firstLine(e) };
383
+ // HC-GF-270: stated rather than left absent, so the later pause is readable. Classified, not
384
+ // raw: this string is persisted in workflow_runs.config for the life of the run.
385
+ config.story_source = {
386
+ kind: 'none',
387
+ ref: null,
388
+ label: String(storyId),
389
+ error: /timed? ?out/i.test(firstLine(e)) ? 'the gh issue fetch timed out' : 'the gh issue fetch failed',
390
+ };
391
+ pre.push(warn(` ⚠ could not fetch GitHub issue context: ${firstLine(e)}`));
392
+ pre.push(warn(' → proceeding without story_description; step_0 may produce placeholder output'));
393
+ pre.push(warn(` ⚠ architect-config lookup will use '${storyId}' (likely silent miss)`));
394
+ }
395
+ } else {
396
+ // HC-019n-followup-18: a roadmap-sourced story. Only the SERVER knows what it says; only the
397
+ // CLI can read the adopter's disk — so ask the server, then read the files it names here.
398
+ // HC-GF-270: roadmap text is committed repository content, so it is trusted. Stamped BEFORE the
399
+ // fetch, so a 404 or a network failure still records 'roadmap' rather than no source at all.
400
+ config.story_source = { kind: 'roadmap', ref: null, label: String(storyId) };
401
+ try {
402
+ const data = await io.fetchStoryContext(storyId);
403
+ if (data && data.description) {
404
+ // HC-GF-232: siblings fold into story_description and NOT into the path-extraction text —
405
+ // the server's contract says siblings carry PROSE ONLY.
406
+ const sibs = Array.isArray(data.referencedStories) ? data.referencedStories : [];
407
+ config.story_description = composeStoryDescription(data.description, sibs);
408
+ rawText = data.description;
409
+ textSource = 'roadmap';
410
+ const n = Array.isArray(data.referencedPaths) ? data.referencedPaths.length : 0;
411
+ pre.push(info(
412
+ ` → resolved story context from roadmap (${data.description.length} chars, ` +
413
+ `${n} referenced path${n === 1 ? '' : 's'}` +
414
+ (sibs.length ? `, ${sibs.length} sibling row${sibs.length === 1 ? '' : 's'}` : '') +
415
+ ')'));
416
+ } else {
417
+ // A 200 with no description: nothing to groom from, and it looks like success.
418
+ textFailure = { kind: 'roadmap-empty' };
419
+ }
420
+ } catch (e) {
421
+ const status = e && e.response && e.response.status;
422
+ if (status === 404) {
423
+ textFailure = { kind: 'roadmap-missing' };
424
+ pre.push(warn(` ⚠ no roadmap row found for '${storyId}'`));
425
+ } else {
426
+ textFailure = { kind: 'roadmap-error', detail: firstLine(e) };
427
+ pre.push(warn(` ⚠ could not fetch story context: ${firstLine(e)}`));
428
+ }
429
+ pre.push(warn(' → proceeding without story context; the warning below applies'));
430
+ }
431
+ }
432
+
433
+ // ── Codebase bundle (HC-019n-followup-12) ─────────────────────────────────────────────────
434
+ // Without it step_1/step_4 have only training-data priors and produce code in the SHAPE of
435
+ // the adopter's stack but not its architecture (OptionsFlow #104 invented SQLAlchemy for a
436
+ // JSON-lines repo).
437
+ const bundle = { files: 0, chars: 0 };
438
+ const includePaths = opts.includePaths || [];
439
+ const defaultIncludePaths = repoCtx.defaultIncludePaths || [];
440
+ if (rawText || parseIncludePaths(includePaths).length > 0 || defaultIncludePaths.length > 0) {
441
+ try {
442
+ // HC-GF-188: a `:line` on a FULL path survives, so the windowing below can fire.
443
+ const candidates = new Set();
444
+ const pathRefLines = new Map();
445
+ if (rawText) {
446
+ for (const r of extractPathRefs(rawText)) {
447
+ candidates.add(r.path);
448
+ if (r.line && !pathRefLines.has(r.path)) pathRefLines.set(r.path, r.line);
449
+ }
450
+ }
451
+ // HC-GF-205: an entry may name a REGION — `path:<line>` or `path:<symbol>`.
452
+ // HC-GF-288: EVERY region per file, in flag order. One slot per file, filled story-first,
453
+ // is what discarded `cli/hone-cli.js:6180` when the story had already cited
454
+ // `cli/hone-cli.js:6580`: no message, and the same five regions bundled.
455
+ const includeAnchors = new Map();
456
+ let includeTargets = [];
457
+ if (parseIncludePaths(includePaths).length) {
458
+ const { resolved, missing } = partitionIncludeTargets(includePaths, io.exists);
459
+ includeTargets = resolved;
460
+ for (const t of resolved) {
461
+ candidates.add(t.path);
462
+ if (t.line || t.symbol) includeAnchors.set(t.path, [...(includeAnchors.get(t.path) || []), t]);
463
+ }
464
+ // A flag that silently does nothing is worse than one that errors.
465
+ for (const t of missing) pre.push(warn(` ⚠ --include-paths '${t.raw}' matched no file — not bundled`));
466
+ }
467
+ for (const p of defaultIncludePaths) candidates.add(p);
468
+ const candidateLines = new Map(pathRefLines);
469
+ // HC-GF-288: the operator's anchors first, then the story's line, alongside rather than
470
+ // instead (see statedAnchorsFor). This used to return the story's line whenever there was
471
+ // one, so a symbol for a story-cited file was never even looked up.
472
+ const resolveAnchors = (candidate, fileContent) => {
473
+ const { anchors, unresolved } = statedAnchorsFor({
474
+ path: candidate,
475
+ content: fileContent,
476
+ operator: includeAnchors.get(candidate) || [],
477
+ storyLine: candidateLines.get(candidate) || null,
478
+ });
479
+ for (const u of unresolved) pre.push(warn(` ⚠ --include-paths '${u.raw}' — ${u.reason}; not anchored`));
480
+ return anchors;
481
+ };
482
+ // HC-019n-followup-26: a file named BARE in prose resolves against the repository's own
483
+ // list — bundled ONLY on a unique basename match, never on a guess.
484
+ if (rawText) {
485
+ for (const { name, line } of extractBareRefs(rawText)) {
486
+ if (alreadyCovered(name, candidates)) continue;
487
+ const r = resolveBareName(name, repoCtx.repoFiles);
488
+ if (r.status === 'unique') {
489
+ candidates.add(r.path);
490
+ if (line && !candidateLines.has(r.path)) candidateLines.set(r.path, line);
491
+ pre.push(info(` → resolved bare ref '${name}${line ? ':' + line : ''}' → ${r.path}`));
492
+ } else if (r.status === 'ambiguous') {
493
+ pre.push(warn(` ⚠ '${name}' is ambiguous (${r.matches.length} files) — not bundling; use --include-paths to disambiguate`));
494
+ }
495
+ }
496
+ }
497
+ // HC-GF-073: what exists, what is pending, plus repo-level signal files and existing
498
+ // neighbours of the pending paths. Order is priority order: the char cap cuts from the end.
499
+ const selection = selectBundle({ named: [...candidates], repoFiles: repoCtx.repoFiles, exists: io.exists });
500
+ const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
501
+ const codebaseFiles = [];
502
+ let totalChars = 0;
503
+ let budgetSpent = false; // HC-GF-288: so a not-bundled warning can say WHY
504
+ for (const candidate of ordered) {
505
+ if (codebaseFiles.length >= BUNDLE_MAX_FILES) { budgetSpent = true; break; }
506
+ if (candidate.startsWith('/') || candidate.startsWith('~') || candidate.includes('..')) continue;
507
+ if (!io.exists(candidate)) continue;
508
+ let content;
509
+ try { content = io.readFile(candidate); } catch { continue; }
510
+ // HC-GF-188: `path:line` or quoted code anchors the window, and a cut file is ANNOUNCED.
511
+ const anchors = resolveAnchors(candidate, content);
512
+ const excerpt = planFileExcerpt({
513
+ content,
514
+ cap: BUNDLE_MAX_PER_FILE_CHARS,
515
+ lines: anchors.map((a) => a.line),
516
+ storyText: rawText,
517
+ path: candidate,
518
+ });
519
+ content = excerpt.content;
520
+ if (excerpt.notice) pre.push(warn(excerpt.notice));
521
+ for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
522
+ if (totalChars + content.length > BUNDLE_MAX_TOTAL_CHARS) { budgetSpent = true; break; }
523
+ totalChars += content.length;
524
+ codebaseFiles.push({ path: candidate, content });
525
+ }
526
+ // HC-GF-288: an operator-named entry whose file never reached the bundle (the budget ran
527
+ // out first, or it names a directory) vanished as silently as the anchor did. A refused
528
+ // credential file is already reported by auditLines below.
529
+ const bundledPaths = new Set(codebaseFiles.map((f) => f.path));
530
+ for (const t of includeTargets) {
531
+ if (bundledPaths.has(t.path) || selection.refused.includes(t.path)) continue;
532
+ const why = (io.isDirectory && io.isDirectory(t.path)) ? 'it is a directory; name the files in it'
533
+ : budgetSpent ? `the bundle's ${BUNDLE_MAX_FILES}-file / ${BUNDLE_MAX_TOTAL_CHARS}-char budget ran out first`
534
+ : 'it could not be read as a file inside this repository';
535
+ pre.push(warn(` ⚠ --include-paths '${t.raw}' — NOT bundled: ${why}`));
536
+ }
537
+ for (const line of auditLines(selection)) pre.push(info(line));
538
+ if (codebaseFiles.length > 0) {
539
+ config.codebaseFiles = codebaseFiles;
540
+ bundle.files = codebaseFiles.length;
541
+ bundle.chars = totalChars;
542
+ pre.push(info(` → bundled ${codebaseFiles.length} codebase file(s) for step_1/step_4 context (${totalChars} chars)`));
543
+ }
544
+ } catch (e) {
545
+ pre.push(warn(` ⚠ codebase bundling failed (non-fatal): ${firstLine(e)}`));
546
+ }
547
+ }
548
+
549
+ // HC-019n-followup-17: say so LOUDLY when the code-aware steps will run blind.
550
+ if (!config.codebaseFiles) {
551
+ pre.push(warn(' ⚠ no codebase files bundled — step_1 / step_4 / step_5 / step_5d / step_5e'));
552
+ pre.push(warn(' will run WITHOUT source context and may invent APIs that do not exist.'));
553
+ if (!/^\d+$/.test(storyId)) {
554
+ pre.push(warn(` '${storyId}' is not a GitHub issue number, so file paths cannot be`));
555
+ pre.push(warn(' auto-detected from an issue body.'));
556
+ }
557
+ pre.push(warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js'));
558
+ }
559
+
560
+ // ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
561
+ // Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
562
+ // come from the groomer at step_0 and are harvested server-side. Fail-open.
563
+ if (opts.axes) {
564
+ const axes = String(opts.axes).split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
565
+ if (axes.length > 0) config.classifier_axes = axes;
566
+ } else {
567
+ try {
568
+ const touched = Array.isArray(config.codebaseFiles) ? config.codebaseFiles.map(f => f.path).filter(Boolean) : [];
569
+ const auto = classifyStory({
570
+ type: extractType([], rawText, String(storyId)),
571
+ has_security_implications: extractSecurityImplications([], rawText),
572
+ cross_repo_dependency: extractCrossRepoDependency(rawText),
573
+ recently_modified_overlap: io.recentlyModifiedOverlap(touched),
574
+ });
575
+ if (auto.architect.engage && auto.architect.axes.length > 0) {
576
+ config.classifier_axes = auto.architect.axes;
577
+ pre.push(info(` → auto-detected classifier axes: ${auto.architect.axes.join(', ')}`));
578
+ }
579
+ } catch (e) {
580
+ pre.push(warn(` ⚠ axes auto-detection failed (non-fatal): ${firstLine(e)}`));
581
+ }
582
+ }
583
+
584
+ // ── Architect flags (HC-019b) ─────────────────────────────────────────────────────────────
585
+ // workflow-dag.js's validateStepPreConditions BLOCKS step_1 when architect_consulted is true
586
+ // and checklist_b_completed is not; without these flags every architect-engaged story
587
+ // deadlocks. Keyed on the RESOLVED id (HC-019b-followup-1 F1).
588
+ const arch = readArchitectConfigFromText(repoCtx.planText || '', resolvedStoryId);
589
+ config.architect_consulted = arch.architect_consulted;
590
+ config.checklist_b_completed = arch.checklist_b_completed;
591
+ if (arch.diagnostic) post.push(warn(` ⚠ ${arch.diagnostic}`));
592
+ if (arch.architect_consulted) {
593
+ post.push(info(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`));
594
+ }
595
+
596
+ return { storyId, resolvedStoryId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
597
+ }
598
+
599
+ /**
600
+ * The run config both commands send. The only assembler: run-story and queue-stories agree
601
+ * because they call this, not because a test compares two hand-built objects.
602
+ */
603
+ function buildRunConfig(repoCtx, storyCtx) {
604
+ const config = { ...repoCtx.config, ...storyCtx.config };
605
+ for (const k of SERVER_OWNED_KEYS) {
606
+ if (Object.prototype.hasOwnProperty.call(config, k)) {
607
+ throw new Error(`run config carries the server-owned key '${k}' — the server sets it, a client never does`);
608
+ }
609
+ }
610
+ return config;
611
+ }
612
+
613
+ /** Whether a story has text to groom from. Judged on the RAW text, never the composed description. */
614
+ function hasStoryText(storyCtx) {
615
+ return String((storyCtx && storyCtx.rawText) || '').trim().length > 0;
616
+ }
617
+
618
+ /** One line naming why a story has no text — every cause it could be, where they cannot be told apart. */
619
+ function describeMissingText(storyCtx) {
620
+ const f = (storyCtx && storyCtx.textFailure) || { kind: 'unknown' };
621
+ const id = storyCtx && storyCtx.storyId;
622
+ switch (f.kind) {
623
+ case 'roadmap-missing':
624
+ // stories.js answers 404 for both, by design, so neither can be ruled out from here.
625
+ return `${id} — no roadmap row for this id, or this org is not on the roadmap allowlist (the server answers both with 404)`;
626
+ case 'roadmap-empty':
627
+ return `${id} — the roadmap row exists but its description is empty`;
628
+ case 'roadmap-error':
629
+ return `${id} — the story context could not be fetched: ${f.detail}`;
630
+ case 'issue-error':
631
+ return `${id} — GitHub issue #${id} could not be fetched: ${f.detail}`;
632
+ case 'issue-empty':
633
+ return `${id} — GitHub issue #${id} has an empty title and body`;
634
+ default:
635
+ return `${id} — no story text was found`;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Why an OVERNIGHT batch must not start in this repository, or null. Fail closed: an unattended
641
+ * batch runs with nobody to notice that a mode was mistyped or that every story is waiting.
642
+ */
643
+ function overnightModeRefusal(execution) {
644
+ const ex = execution || { raw: null, mode: 'server', recognized: false };
645
+ if (!ex.recognized) {
646
+ const what = ex.unreadable
647
+ ? `could not be read (${ex.unreadable})`
648
+ : `is '${ex.raw}', which is not a mode Hone knows (server, editor)`;
649
+ return `✗ --overnight refused: execution.mode in .github/.pipeline-config.yml ${what}. ` +
650
+ 'An unattended batch would run on a guess nobody is awake to check. Fix the mode, then re-run.';
651
+ }
652
+ if (ex.mode === 'editor') {
653
+ return '✗ --overnight refused: execution.mode is editor, so every story would park at step_4 waiting for ' +
654
+ 'your editor AI, with nobody there to build it. Queue without --overnight to park them for the ' +
655
+ 'morning, or set execution.mode to server for an unattended batch.';
656
+ }
657
+ return null;
658
+ }
659
+
660
+ /**
661
+ * The server's cap on an overnight batch: MUST equal MAX_STORIES_PER_OVERNIGHT_BATCH in
662
+ * server/src/services/batch-store.js (a cross-package test pins it; the CLI cannot require server
663
+ * code). Checked BEFORE any story is resolved or uploaded — review of #1100: a 26-50 story overnight
664
+ * file resolved every story and sent up to ~26 MB only for the server to refuse it.
665
+ */
666
+ const OVERNIGHT_MAX_STORIES = 25;
667
+
668
+ /**
669
+ * How long `hone queue-stories` waits for POST /orchestrate/batch, sized to the body.
670
+ *
671
+ * Review of #1100: the API client's 30 s timeout covered the UPLOAD. A 26 MB batch needed about
672
+ * 7 Mbit/s to finish in time, and one that finished just after the server committed printed
673
+ * "Failed" for a batch that was queued, so re-running it queued (and paid for) the stories twice.
674
+ * 30 s plus 4 s per started MB is a 2 Mbit/s floor. `HONE_BATCH_POST_TIMEOUT_BASE_MS` overrides the
675
+ * 30 s base (tests).
676
+ *
677
+ * @param {number} bodyBytes
678
+ * @returns {number} milliseconds
679
+ */
680
+ function batchPostTimeoutMs(bodyBytes, baseMs = Number(process.env.HONE_BATCH_POST_TIMEOUT_BASE_MS) || 30_000) {
681
+ return baseMs + Math.ceil(Math.max(0, bodyBytes) / 1_048_576) * 4_000;
682
+ }
683
+
684
+ module.exports = {
685
+ batchPostTimeoutMs,
686
+ classifyProvenanceFailure,
687
+ OVERNIGHT_MAX_STORIES,
688
+ resolveRepoContext,
689
+ resolveStoryContext,
690
+ buildRunConfig,
691
+ hasStoryText,
692
+ describeMissingText,
693
+ overnightModeRefusal,
694
+ SERVER_OWNED_KEYS,
695
+ BUNDLE_MAX_FILES,
696
+ BUNDLE_MAX_TOTAL_CHARS,
697
+ BUNDLE_MAX_PER_FILE_CHARS,
698
+ };