@hone-ai/cli 1.34.0 → 1.35.1
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/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1341 -795
- package/lib/agent-eval-pacing.js +102 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-stateful.js +72 -0
- package/lib/agent-eval-score.js +70 -0
- package/lib/agent-executor.js +26 -4
- package/lib/asset-fetch-list.js +72 -0
- package/lib/auto-verify.js +67 -3
- package/lib/batch-follow.js +333 -0
- package/lib/block-grammar.js +126 -0
- package/lib/bundle-context.js +43 -4
- package/lib/bundle-paths.js +698 -1
- package/lib/bundle-source-dirs.js +79 -0
- package/lib/doctor-bind-default.js +192 -18
- package/lib/doctor-local-run.js +139 -1
- package/lib/emit-pr.js +39 -6
- package/lib/git-helpers.js +19 -8
- package/lib/hard-pause-remedy.js +101 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +97 -7
- package/lib/materialize-diff.js +131 -73
- package/lib/mcp-tools.js +20 -14
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +52 -0
- package/lib/promote-payload.js +77 -0
- package/lib/refresh-knowledge.js +15 -6
- package/lib/repo-tree.js +22 -3
- package/lib/run-context.js +698 -0
- package/lib/run-target.js +66 -1
- package/lib/sandbox-lifecycle.js +357 -0
- package/lib/schedule-cron.js +1 -1
- package/lib/skill-assertions.js +61 -7
- package/lib/skill-audit-render.js +1 -1
- package/lib/story-classifier-extract.js +16 -8
- package/lib/story-classifier.js +64 -0
- package/lib/story-compose.js +69 -0
- package/lib/story-line.js +40 -0
- package/lib/story-provenance.js +267 -0
- package/lib/timeout-option.js +42 -0
- package/lib/untrusted-code-env.js +51 -0
- package/lib/verdict-env.js +31 -0
- package/lib/verify-patch.js +74 -4
- package/lib/verify-pr.js +2 -1
- package/lib/worktree-provision.js +62 -2
- 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
|
+
};
|