@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,333 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* batch-follow.js — HC-GF-098: `hone follow-batch <batchId>`, the loop that runs a queued batch's tests.
|
|
4
|
+
*
|
|
5
|
+
* The server cannot execute; the trust boundary is the authenticated CLI. So a batch queued overnight
|
|
6
|
+
* reached step_4 with no verdict: check-patch, verify-patch and emit-pr ran only when a person typed them.
|
|
7
|
+
* This module decides, tick by tick, what the loop does. `cli/hone-cli.js` supplies every piece of I/O.
|
|
8
|
+
*
|
|
9
|
+
* Design (architect review of 2026-09-25, recorded on the PR):
|
|
10
|
+
* - The three verbs run as CHILD PROCESSES of the same CLI, one at a time. They call `process.exit`, install
|
|
11
|
+
* signal handlers and read `process.cwd()`, and a child runs exactly the code a person runs. Their exit
|
|
12
|
+
* codes are the contract. One at a time, because verify-patch seeds and tears down the adopter's live
|
|
13
|
+
* datastore from the real tree, and two at once would destroy each other's rows.
|
|
14
|
+
* - The loop never approves and never posts a verdict (`verify-patch --report` does). It never passes
|
|
15
|
+
* `--trust-source`, `--skip-verify`, `--ready` or `--acknowledge-*`: an untrusted story stays paused for a
|
|
16
|
+
* person (HC-GF-270), and so does one whose repository never consented to running its tests (HC-GF-064).
|
|
17
|
+
* - It stops BEFORE acting when a breaker has fired. A terminal batch ends the loop after one last emit-pr pass.
|
|
18
|
+
* - It is idempotent across restarts: an attempt that already has a verify-patch verdict on the server is not
|
|
19
|
+
* verified again, and emit-pr's BRANCH_CONFLICT (5) means the branch was already emitted.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { classifyPollState, POLL_OUTCOMES } = require('./poll-decision');
|
|
23
|
+
|
|
24
|
+
/** follow-batch's own exit codes. */
|
|
25
|
+
const EXIT = Object.freeze({ DONE: 0, FATAL: 1, NEEDS_YOU: 2, DEADLINE: 3 });
|
|
26
|
+
|
|
27
|
+
/** Flags the loop must never pass, whatever it is asked to do. The unit test checks every argv buildArgs can build. */
|
|
28
|
+
const FORBIDDEN_FLAGS = Object.freeze(['--trust-source', '--skip-verify', '--ready', '--acknowledge-failed-verdict',
|
|
29
|
+
'--acknowledge-abort', '--approve']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pause reasons that mean a child is NOT at a gate waiting for its step_4 output to be judged. `GET /:runId`
|
|
33
|
+
* sets `awaitingApproval` on ANY paused run with a current step, so without this a child paused by a breaker,
|
|
34
|
+
* a budget, an agent's hard pause or a guardrail would look like a gate, and the loop would run its patch.
|
|
35
|
+
* Sources: orchestrator.js (BATCH_*), orchestrator-guardrails.js GUARDRAIL_REFUSAL_CODES, the hard-pause scan.
|
|
36
|
+
*/
|
|
37
|
+
const NOT_A_GATE = /^(BATCH_FAILURE_THRESHOLD_EXCEEDED:|BATCH_BUDGET_EXCEEDED:|MAX_RETRIES_EXCEEDED:|MAX_TOTAL_STEPS_EXCEEDED:|hard_pause at )/;
|
|
38
|
+
|
|
39
|
+
/** What each child exit code means, for the action line. verify-patch.js and emit-pr.js EXIT maps. */
|
|
40
|
+
const CHECK_EXIT = { 0: 'applies', 1: 'no diff', 2: 'does not apply' };
|
|
41
|
+
const VERIFY_EXIT = { 0: 'pass', 1: 'no diff', 2: 'does not apply', 3: 'tests failed', 4: 'timed out',
|
|
42
|
+
7: 'test command could not start', 8: 'not run: environment incomplete', 9: 'refused: untrusted story source' };
|
|
43
|
+
const EMIT_EXIT = { 0: 'emitted', 1: 'no diff', 2: 'does not apply', 3: 'tests failed', 4: 'timed out',
|
|
44
|
+
5: 'already emitted (branch exists)', 6: 'publish failed', 7: 'test command could not start',
|
|
45
|
+
9: 'refused: untrusted story source' };
|
|
46
|
+
|
|
47
|
+
const firstLine = (s) => String(s || '').split('\n')[0];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Should the loop stop before acting on anything? A breaker pauses the batch's LATER CHILDREN rather than the
|
|
51
|
+
* batch: `GET /orchestrate/batch/:id` reports `failurePauseTriggered` (HC-051, null threshold = disabled) and
|
|
52
|
+
* `budgetExceeded` (HC-050), and the child it paused carries the reason as its `errorMessage` prefix.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} batch a `GET /orchestrate/batch/:id` payload (untrusted shape)
|
|
55
|
+
* @returns {string|null} why the loop stops, in words an operator can act on, or null to carry on
|
|
56
|
+
*/
|
|
57
|
+
function breakerStop(batch) {
|
|
58
|
+
const b = (batch && typeof batch === 'object') ? batch : {};
|
|
59
|
+
const runs = Array.isArray(b.runs) ? b.runs : [];
|
|
60
|
+
// A child the breaker paused is evidence the breaker fired, even when the batch-level flag has not caught up.
|
|
61
|
+
const pausedBy = (prefix) => runs.find((r) => r && typeof r.errorMessage === 'string' && r.errorMessage.startsWith(prefix));
|
|
62
|
+
const failed = pausedBy('BATCH_FAILURE_THRESHOLD_EXCEEDED:');
|
|
63
|
+
if (b.failurePauseTriggered === true || failed) {
|
|
64
|
+
const n = Number.isFinite(b.failureCount) && Number.isFinite(b.failureThreshold)
|
|
65
|
+
? ` (${b.failureCount} failed, threshold ${b.failureThreshold})` : '';
|
|
66
|
+
return `the batch's failure breaker fired${n}${failed ? `: ${failed.storyId} ${firstLine(failed.errorMessage)}` : ''}`;
|
|
67
|
+
}
|
|
68
|
+
const spent = pausedBy('BATCH_BUDGET_EXCEEDED:');
|
|
69
|
+
if (b.budgetExceeded === true || spent) {
|
|
70
|
+
const pct = Number.isFinite(b.budgetPct) ? ` (${b.budgetPct}% used)` : '';
|
|
71
|
+
return `the batch's token budget is spent${pct}${spent ? `: ${spent.storyId} ${firstLine(spent.errorMessage)}` : ''}`;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True once the server has stored a terminal status for the batch (it does so on the same GET). */
|
|
77
|
+
function isTerminal(batch) {
|
|
78
|
+
return !!(batch && batch.status && batch.status !== 'active');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The verify-patch verdict the server already holds for this run's step_4 attempt, or null. */
|
|
82
|
+
function priorVerdict(run, attempt) {
|
|
83
|
+
const list = Array.isArray(run && run.verifications) ? run.verifications : [];
|
|
84
|
+
return list.find((v) => v && v.source === 'verify-patch' && v.stepKey === 'step_4' && Number(v.attempt) === attempt) || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is this paused run a step_4 gate whose patch the loop should check and verify now?
|
|
89
|
+
* @param {object} run a `GET /orchestrate/:runId` payload
|
|
90
|
+
* @returns {{actionable: true, attempt: number} | {actionable: false, reason?: string}}
|
|
91
|
+
*/
|
|
92
|
+
function step4Action(run) {
|
|
93
|
+
const c = classifyPollState(run);
|
|
94
|
+
// Not a step_4 gate: not this loop's business, and nothing to say about it.
|
|
95
|
+
if (c.outcome !== POLL_OUTCOMES.HUMAN_GATE || c.stepKey !== 'step_4') return { actionable: false };
|
|
96
|
+
const why = String((run && run.errorMessage) || '');
|
|
97
|
+
if (NOT_A_GATE.test(why)) return { actionable: false, reason: `paused, not at a gate: ${firstLine(why)}` };
|
|
98
|
+
if (run.approvalRefusal) return { actionable: false, reason: 'the server refuses approval of this attempt' };
|
|
99
|
+
const attempt = Number(String(c.attemptKey || '').split('#')[1]) || 1;
|
|
100
|
+
const prior = priorVerdict(run, attempt);
|
|
101
|
+
if (prior) {
|
|
102
|
+
const id = run.runId || '<runId>';
|
|
103
|
+
// Under the advisory gate a failing verdict does not refuse approval, so this line is all that stands between
|
|
104
|
+
// the operator and approving a patch whose tests failed. Only a pass is offered the approve command.
|
|
105
|
+
return { actionable: false, reason: prior.verdict === 'pass'
|
|
106
|
+
? `attempt ${attempt} passed verify-patch; approve it with hone run-story ${id} --approve step_4`
|
|
107
|
+
: `attempt ${attempt}'s verify-patch verdict is ${prior.verdict}; read it with hone run-report ${id}` };
|
|
108
|
+
}
|
|
109
|
+
return { actionable: true, attempt };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The argv for one child verb. Exit codes are the contract; `--format json` only carries detail.
|
|
114
|
+
* @param {'check-patch'|'verify-patch'|'emit-pr'} verb
|
|
115
|
+
*/
|
|
116
|
+
function buildArgs(verb, runId, { attempt, openPr = false, branch } = {}) {
|
|
117
|
+
const args = [verb, runId];
|
|
118
|
+
if (verb !== 'emit-pr' && attempt) args.push('--step-key', 'step_4', '--attempt', String(attempt));
|
|
119
|
+
if (verb === 'verify-patch') args.push('--report');
|
|
120
|
+
if (verb === 'emit-pr' && attempt) args.push('--attempt', String(attempt));
|
|
121
|
+
if (verb === 'emit-pr' && branch) args.push('--branch', branch);
|
|
122
|
+
if (verb === 'emit-pr' && openPr) args.push('--push', '--open-pr');
|
|
123
|
+
args.push('--format', 'json');
|
|
124
|
+
return args;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The step_4 attempt a run is on now: the highest step_4 attempt in its steps, or null. */
|
|
128
|
+
function currentStep4Attempt(run) {
|
|
129
|
+
const steps = Array.isArray(run && run.steps) ? run.steps : [];
|
|
130
|
+
const attempts = steps.filter((st) => st && st.stepKey === 'step_4').map((st) => Number(st.attempt) || 1);
|
|
131
|
+
return attempts.length ? Math.max(...attempts) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The current step_4 attempt, when the server holds a `pass` verify-patch verdict for it; else null. */
|
|
135
|
+
function verifiedStep4Attempt(run) {
|
|
136
|
+
const attempt = currentStep4Attempt(run);
|
|
137
|
+
const v = attempt && priorVerdict(run, attempt);
|
|
138
|
+
return v && v.verdict === 'pass' ? attempt : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* HC-GF-301: one branch, so one draft PR, per step_4 attempt. emit-pr's own name has no attempt in it, and for a
|
|
143
|
+
* branch with an open PR it reports "already open" and pushes nothing — so after a step_5 route-back the PR would go
|
|
144
|
+
* on showing the older attempt's code while the command said it succeeded. emit-pr slugifies the name.
|
|
145
|
+
*/
|
|
146
|
+
function draftBranch(storyId, runId, attempt) {
|
|
147
|
+
return `hone/${storyId || 'story'}-${String(runId).slice(0, 8)}-a${attempt}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One tick's decisions, from the batch and the details of its paused runs. Pure.
|
|
152
|
+
* @param {object} batch `GET /orchestrate/batch/:id`
|
|
153
|
+
* @param {Record<string, object>} details runId → `GET /orchestrate/:runId`, for paused runs and runs to emit
|
|
154
|
+
* @param {{emitted: Set<string>}} memory runs whose emit-pr already ran in this process
|
|
155
|
+
* @param {{repoName?: string|null}} [ctx] this checkout, so a run for another repository is not touched
|
|
156
|
+
* @returns {{stop: {exitCode: number, reason: string}|null, actions: object[], notes: string[]}}
|
|
157
|
+
*/
|
|
158
|
+
function planTick(batch, details, memory, ctx = {}) {
|
|
159
|
+
const b = (batch && typeof batch === 'object') ? batch : {};
|
|
160
|
+
const halt = breakerStop(b);
|
|
161
|
+
if (halt) return { stop: { exitCode: EXIT.NEEDS_YOU, reason: halt }, actions: [], notes: [] };
|
|
162
|
+
|
|
163
|
+
const actions = [];
|
|
164
|
+
const notes = [];
|
|
165
|
+
for (const r of Array.isArray(b.runs) ? b.runs : []) {
|
|
166
|
+
if (!r || !r.runId) continue;
|
|
167
|
+
if (ctx.repoName && r.repoName && r.repoName !== ctx.repoName) {
|
|
168
|
+
notes.push(`${r.storyId} ${r.runId} belongs to ${r.repoName}, not this checkout (${ctx.repoName}): skipped`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (r.status === 'completed') {
|
|
172
|
+
if (!memory.emitted.has(r.runId)) {
|
|
173
|
+
// Opening PRs, the completed run's emit goes to its step_4 attempt's branch, so a draft opened for that
|
|
174
|
+
// attempt is found open and nothing is opened twice.
|
|
175
|
+
const attempt = ctx.openPr ? currentStep4Attempt(details[r.runId]) : null;
|
|
176
|
+
actions.push({ kind: 'emit', runId: r.runId, storyId: r.storyId, run: details[r.runId],
|
|
177
|
+
...(attempt ? { attempt, branch: draftBranch(r.storyId, r.runId, attempt) } : {}) });
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (r.status !== 'paused' || !details[r.runId]) continue;
|
|
182
|
+
// HC-GF-301: opening PRs, a step_4 attempt that passed verify-patch gets its draft now, while step_5 and its
|
|
183
|
+
// gate are still ahead, instead of when the whole run completes.
|
|
184
|
+
const verified = ctx.openPr ? verifiedStep4Attempt(details[r.runId]) : null;
|
|
185
|
+
if (verified && !(memory.drafted && memory.drafted.has(`${r.runId}#${verified}`))) {
|
|
186
|
+
actions.push({ kind: 'draft', runId: r.runId, storyId: r.storyId, attempt: verified, run: details[r.runId],
|
|
187
|
+
branch: draftBranch(r.storyId, r.runId, verified) });
|
|
188
|
+
const vs = Array.isArray(details[r.runId].verifications) ? details[r.runId].verifications : [];
|
|
189
|
+
const earlier = vs.filter((v) => v && v.source === 'verify-patch' && v.stepKey === 'step_4' && v.verdict === 'pass'
|
|
190
|
+
&& Number(v.attempt) < verified).map((v) => Number(v.attempt));
|
|
191
|
+
if (earlier.length) {
|
|
192
|
+
const m = Math.max(...earlier);
|
|
193
|
+
notes.push(`${r.storyId} ${r.runId}: the draft for attempt ${verified} supersedes the one on ${draftBranch(r.storyId, r.runId, m)}; close that one when you are ready`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const a = step4Action(details[r.runId]);
|
|
197
|
+
// verify-patch's verdict POST is best-effort: an attempt this process already ran is not re-run every tick.
|
|
198
|
+
if (a.actionable && memory.tried && memory.tried.has(`${r.runId}#${a.attempt}`)) {
|
|
199
|
+
notes.push(`${r.storyId} ${r.runId} needs you: attempt ${a.attempt} was verified here but no verdict reached the server`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (a.actionable) {
|
|
203
|
+
actions.push({ kind: 'verify', runId: r.runId, storyId: r.storyId, attempt: a.attempt, run: details[r.runId] });
|
|
204
|
+
} else if (a.reason) {
|
|
205
|
+
notes.push(`${r.storyId} ${r.runId} needs you: ${a.reason}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!isTerminal(b)) return { stop: null, actions, notes };
|
|
210
|
+
const unfinished = (b.counts && ((b.counts.failed || 0) + (b.counts.killed || 0) + (b.counts.stalled || 0))) || 0;
|
|
211
|
+
return {
|
|
212
|
+
stop: unfinished
|
|
213
|
+
? { exitCode: EXIT.NEEDS_YOU, reason: `batch ${b.status}: ${unfinished} run(s) did not complete` }
|
|
214
|
+
: { exitCode: EXIT.DONE, reason: `batch ${b.status}` },
|
|
215
|
+
actions,
|
|
216
|
+
notes,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run one action's child verbs, in order, and describe what happened. Nothing that executes the adopter's tests
|
|
222
|
+
* (verify-patch, or emit-pr, which re-verifies) starts unless the repository's consent and the story's provenance
|
|
223
|
+
* allow it; verify-patch also waits for check-patch to exit 0.
|
|
224
|
+
* @param {object} action from planTick
|
|
225
|
+
* @param {{runVerb: (argv: string[]) => Promise<{code: number|null}>,
|
|
226
|
+
* verifyDecision: (run: object) => {run: boolean, reason: string}}} io
|
|
227
|
+
* @param {{openPr?: boolean}} [opts]
|
|
228
|
+
* @returns {Promise<{lines: string[], needsYou: boolean}>}
|
|
229
|
+
*/
|
|
230
|
+
async function runTask(action, io, opts = {}) {
|
|
231
|
+
const tag = `[follow-batch] ${action.storyId} ${action.runId}`;
|
|
232
|
+
if (action.kind === 'emit' || action.kind === 'draft') {
|
|
233
|
+
// emit-pr re-runs the tests before it commits, even as a local dry run, so it needs the same consent and
|
|
234
|
+
// provenance verify-patch does. Without this, a repository that never set ci.local_command gets `make ci`.
|
|
235
|
+
const what = action.kind === 'draft' ? `draft PR (attempt ${action.attempt})` : 'emit-pr';
|
|
236
|
+
const allowed = io.verifyDecision(action.run || {});
|
|
237
|
+
if (!allowed || !allowed.run) {
|
|
238
|
+
return { lines: [`${tag} ${what} not run: ${(allowed && allowed.reason) || 'not allowed'}; emit it yourself with hone emit-pr ${action.runId}`], needsYou: true };
|
|
239
|
+
}
|
|
240
|
+
const r = await io.runVerb(buildArgs('emit-pr', action.runId,
|
|
241
|
+
{ openPr: action.kind === 'draft' || !!opts.openPr, attempt: action.attempt, branch: action.branch }));
|
|
242
|
+
const meaning = EMIT_EXIT[r.code] || `exit ${r.code}`;
|
|
243
|
+
return { lines: [`${tag} ${what} → ${meaning}${action.branch ? ` (${action.branch})` : ''}`], needsYou: r.code !== 0 && r.code !== 5 };
|
|
244
|
+
}
|
|
245
|
+
const check = await io.runVerb(buildArgs('check-patch', action.runId, { attempt: action.attempt }));
|
|
246
|
+
const lines = [`${tag} check-patch (attempt ${action.attempt}) → ${CHECK_EXIT[check.code] || `exit ${check.code}`}`];
|
|
247
|
+
if (check.code !== 0) return { lines, needsYou: true };
|
|
248
|
+
const decision = io.verifyDecision(action.run || {});
|
|
249
|
+
if (!decision || !decision.run) {
|
|
250
|
+
lines.push(`${tag} verify-patch not run: ${(decision && decision.reason) || 'not allowed'}; left paused for you`);
|
|
251
|
+
return { lines, needsYou: true };
|
|
252
|
+
}
|
|
253
|
+
const v = await io.runVerb(buildArgs('verify-patch', action.runId, { attempt: action.attempt }));
|
|
254
|
+
lines.push(`${tag} verify-patch (attempt ${action.attempt}) → ${VERIFY_EXIT[v.code] || `exit ${v.code}`}`);
|
|
255
|
+
return { lines, needsYou: v.code !== 0 };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The loop. Every effect is injected, so a test drives it with a stub server and a recording runVerb.
|
|
260
|
+
* @param {string} batchId
|
|
261
|
+
* @param {{fetchBatch: Function, fetchRun: Function, runVerb: Function, verifyDecision: Function,
|
|
262
|
+
* sleep: (ms: number) => Promise<void>, now: () => number, log: (line: string) => void}} io
|
|
263
|
+
* @param {{pollMs?: number, deadlineMs?: number|null, maxOfflineMs?: number, openPr?: boolean,
|
|
264
|
+
* repoName?: string|null}} [opts]
|
|
265
|
+
* @returns {Promise<{exitCode: number, reason: string}>}
|
|
266
|
+
*/
|
|
267
|
+
async function followBatch(batchId, io, opts = {}) {
|
|
268
|
+
const pollMs = opts.pollMs || 30_000;
|
|
269
|
+
const maxOfflineMs = opts.maxOfflineMs || 15 * 60_000;
|
|
270
|
+
// owed: runs whose emit-pr needed a person. A completed batch with one of these is not DONE (HC-GF-207's shape:
|
|
271
|
+
// a failure path that exits 0).
|
|
272
|
+
const memory = { emitted: new Set(), tried: new Set(), drafted: new Set(), said: new Set(), owed: new Map() };
|
|
273
|
+
const start = io.now();
|
|
274
|
+
let offlineSince = null;
|
|
275
|
+
const say = (line) => { if (!memory.said.has(line)) { memory.said.add(line); io.log(line); } };
|
|
276
|
+
|
|
277
|
+
for (;;) {
|
|
278
|
+
if (opts.deadlineMs && io.now() - start >= opts.deadlineMs) {
|
|
279
|
+
return { exitCode: EXIT.DEADLINE, reason: `deadline reached after ${Math.round(opts.deadlineMs / 60_000)} min` };
|
|
280
|
+
}
|
|
281
|
+
// A tick reads everything or acts on nothing: a run that could not be read is the server being unreachable,
|
|
282
|
+
// not a run with no details (which would skip its gate, or emit it with no provenance to assess).
|
|
283
|
+
let batch;
|
|
284
|
+
const details = {};
|
|
285
|
+
try {
|
|
286
|
+
batch = await io.fetchBatch(batchId);
|
|
287
|
+
for (const r of Array.isArray(batch && batch.runs) ? batch.runs : []) {
|
|
288
|
+
const wanted = r && r.runId && (r.status === 'paused' || (r.status === 'completed' && !memory.emitted.has(r.runId)));
|
|
289
|
+
if (wanted) details[r.runId] = await io.fetchRun(r.runId);
|
|
290
|
+
}
|
|
291
|
+
offlineSince = null;
|
|
292
|
+
} catch (e) {
|
|
293
|
+
const status = e && e.response && e.response.status;
|
|
294
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
295
|
+
return { exitCode: EXIT.FATAL, reason: `the server answered ${status} while reading batch ${batchId}` };
|
|
296
|
+
}
|
|
297
|
+
if (offlineSince === null) offlineSince = io.now();
|
|
298
|
+
if (io.now() - offlineSince >= maxOfflineMs) {
|
|
299
|
+
return { exitCode: EXIT.FATAL, reason: `the server has been unreachable for ${Math.round(maxOfflineMs / 60_000)} min: ${firstLine(e && e.message)}` };
|
|
300
|
+
}
|
|
301
|
+
await io.sleep(pollMs);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const plan = planTick(batch, details, memory, { repoName: opts.repoName || null, openPr: !!opts.openPr });
|
|
306
|
+
for (const n of plan.notes) say(`[follow-batch] ${n}`);
|
|
307
|
+
for (const action of plan.actions) {
|
|
308
|
+
const out = await runTask(action, io, { openPr: opts.openPr });
|
|
309
|
+
if (action.kind === 'emit' || action.kind === 'draft') {
|
|
310
|
+
if (action.kind === 'emit') memory.emitted.add(action.runId);
|
|
311
|
+
else memory.drafted.add(`${action.runId}#${action.attempt}`);
|
|
312
|
+
// A run is owed until an emit for it succeeds: a draft that failed is retried by the completed-run emit.
|
|
313
|
+
if (out.needsYou) memory.owed.set(action.runId, `${action.storyId} ${action.runId}`);
|
|
314
|
+
else if (action.kind === 'emit') memory.owed.delete(action.runId);
|
|
315
|
+
} else {
|
|
316
|
+
memory.tried.add(`${action.runId}#${action.attempt}`);
|
|
317
|
+
}
|
|
318
|
+
for (const line of out.lines) io.log(line);
|
|
319
|
+
}
|
|
320
|
+
if (plan.stop && plan.stop.exitCode === EXIT.DONE && memory.owed.size) {
|
|
321
|
+
return { exitCode: EXIT.NEEDS_YOU,
|
|
322
|
+
reason: `${plan.stop.reason}, but ${memory.owed.size} run(s) were not emitted: ${[...memory.owed.values()].join(', ')}` };
|
|
323
|
+
}
|
|
324
|
+
if (plan.stop) return plan.stop;
|
|
325
|
+
await io.sleep(pollMs);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
EXIT, FORBIDDEN_FLAGS, NOT_A_GATE,
|
|
331
|
+
breakerStop, isTerminal, priorVerdict, step4Action, buildArgs, planTick, runTask, followBatch,
|
|
332
|
+
currentStep4Attempt, verifiedStep4Attempt, draftBranch,
|
|
333
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* block-grammar.js — HC-GF-290: the ONE definition of a fenced block in a step_4 build artifact.
|
|
4
|
+
*
|
|
5
|
+
* A fence is a line that starts (after blanks) with THREE OR MORE backticks. The block it opens ends
|
|
6
|
+
* at the first later line made of backticks alone, AT LEAST AS MANY as the opener's (blanks around
|
|
7
|
+
* them allowed). That is CommonMark's rule, and it is how a block carries content that itself holds
|
|
8
|
+
* ``` lines: open it with four.
|
|
9
|
+
*
|
|
10
|
+
* The rule used to be a literal in each parser, and it changed in one parser at a time. HC-GF-127
|
|
11
|
+
* taught the `file:` regex in materialize-diff and carry-forward the longer fence; the `edit:` regex
|
|
12
|
+
* beside it and generation.js's block scanner kept three. On run 0e4cd053 step_4 attempt 1 — a
|
|
13
|
+
* finished 51,793-char build, stop_reason end_turn — one ````edit: block was an unnamed code block
|
|
14
|
+
* to the scanner, closed at the next ``` line, and shifted every later pairing by one. The final
|
|
15
|
+
* Self-Check fence then read as an opener, `generation.complete` came back false, and the
|
|
16
|
+
* orchestrator regenerated the whole build as output_incomplete. For the same block the CLI
|
|
17
|
+
* reported `no closing fence` and refused the patch.
|
|
18
|
+
*
|
|
19
|
+
* This file exists twice, byte for byte: `server/src/services/block-grammar.js` and
|
|
20
|
+
* `cli/lib/block-grammar.js`. The server cannot require the CLI package (Railway installs server/
|
|
21
|
+
* only), and the published CLI carries no server code. A difference between the two is a red test
|
|
22
|
+
* (tests/regression/HC-GF-290-longer-fences.test.js).
|
|
23
|
+
*
|
|
24
|
+
* Pure. No I/O.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A line that opens a fenced block. Group 1 is the fence. */
|
|
28
|
+
const FENCE_OPEN_RE = /^[ \t]*(`{3,})/;
|
|
29
|
+
/** A line that can close one: backticks alone. Group 1 is the fence. */
|
|
30
|
+
const FENCE_CLOSE_RE = /^[ \t]*(`{3,})[ \t]*\r?$/;
|
|
31
|
+
/** The opener of a `file:` or `edit:` block. Group 1 is the kind, group 2 the path. */
|
|
32
|
+
const DIRECTIVE_RE = /^[ \t]*`{3,}[ \t]*(file|edit)[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/i;
|
|
33
|
+
|
|
34
|
+
/** How wide a fence `line` opens, or 0 when it opens none. */
|
|
35
|
+
function fenceWidth(line) {
|
|
36
|
+
const m = FENCE_OPEN_RE.exec(line);
|
|
37
|
+
return m ? m[1].length : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether `line` closes a block whose fence is `width` backticks wide. */
|
|
41
|
+
function closesFence(line, width) {
|
|
42
|
+
const m = FENCE_CLOSE_RE.exec(line);
|
|
43
|
+
return m !== null && m[1].length >= width;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every fenced block of `text`, walked line by line, so a block's own fence decides what closes it.
|
|
48
|
+
*
|
|
49
|
+
* Offsets address `text`. [start, end) runs from the opener line to the end of the closer line (its
|
|
50
|
+
* newline excluded); [bodyStart, bodyEnd) is everything between the two lines. `open` is a block
|
|
51
|
+
* the text never closed.
|
|
52
|
+
*
|
|
53
|
+
* @returns {{ blocks: Array<{start:number, end:number, bodyStart:number, bodyEnd:number, width:number, opener:string}>,
|
|
54
|
+
* open: {start:number, bodyStart:number, width:number, opener:string}|null }}
|
|
55
|
+
*/
|
|
56
|
+
function fencedSpans(text) {
|
|
57
|
+
const src = String(text == null ? '' : text);
|
|
58
|
+
const blocks = [];
|
|
59
|
+
let cur = null;
|
|
60
|
+
for (let i = 0; ;) {
|
|
61
|
+
const nl = src.indexOf('\n', i);
|
|
62
|
+
const end = nl === -1 ? src.length : nl;
|
|
63
|
+
const line = src.slice(i, end);
|
|
64
|
+
if (!cur) {
|
|
65
|
+
const width = fenceWidth(line);
|
|
66
|
+
if (width) cur = { start: i, bodyStart: nl === -1 ? src.length : nl + 1, width, opener: line };
|
|
67
|
+
} else if (closesFence(line, cur.width)) {
|
|
68
|
+
blocks.push({ ...cur, end, bodyEnd: i });
|
|
69
|
+
cur = null;
|
|
70
|
+
}
|
|
71
|
+
if (nl === -1) break;
|
|
72
|
+
i = nl + 1;
|
|
73
|
+
}
|
|
74
|
+
return { blocks, open: cur };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A `file:` block as one regex: group 1 the fence, 2 the path, 3 the body.
|
|
79
|
+
*
|
|
80
|
+
* HC-GF-022: the closer is anchored to its OWN line, so an empty block (every Python package's
|
|
81
|
+
* `__init__.py`) cannot swallow its closer and eat the next block. HC-GF-127: the fence is three OR
|
|
82
|
+
* MORE backticks and the closer backreferences it, so a file whose content holds a ```js sample is
|
|
83
|
+
* not cut at the sample. HC-GF-290: the closer is the opener's fence or wider, as for every block.
|
|
84
|
+
*/
|
|
85
|
+
const FILE_BLOCK_RE = /^[ \t]*(`{3,})[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*\1`*[ \t]*\r?$/gim;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An `edit:` block (HC-019n-followup-27) as one regex: group 1 the fence, 2 the path, 3 the body.
|
|
89
|
+
*
|
|
90
|
+
* HC-GF-109: a body framed by `<<<<<<< OLD` … `>>>>>>> NEW` ends at the closer right after its NEW
|
|
91
|
+
* line, so a NEW holding a fence line of its own is not cut short, and it never runs on into the next
|
|
92
|
+
* block. Anything else runs to the first line that starts with the block's own fence, and the
|
|
93
|
+
* parser reports it as malformed. HC-GF-290: the fence is three or more backticks and the closer is
|
|
94
|
+
* that fence or wider — a ````edit: block was invisible to this regex, so the CLI called it
|
|
95
|
+
* unterminated and refused the whole patch.
|
|
96
|
+
*/
|
|
97
|
+
const EDIT_BLOCK_RE = new RegExp(
|
|
98
|
+
'^[ \\t]*(`{3,})[ \\t]*edit[ \\t]*:[ \\t]*([^\\n`]+?)[ \\t]*\\r?\\n(' +
|
|
99
|
+
'<{3,}[ \\t]*OLD[ \\t]*\\r?\\n(?:(?!^[ \\t]*`{3,}[ \\t]*(?:file|edit)[ \\t]*:)[\\s\\S])*?^>{3,}[ \\t]*NEW[ \\t]*\\r?\\n' +
|
|
100
|
+
'|(?:(?!^[ \\t]*\\1)[\\s\\S])*?' +
|
|
101
|
+
')^[ \\t]*\\1`*[ \\t]*\\r?$',
|
|
102
|
+
'gim');
|
|
103
|
+
|
|
104
|
+
/** HC-GF-109: the opening line of an edit block, whether or not the block parses — compared, so none is lost. */
|
|
105
|
+
const EDIT_OPEN_RE = /^[ \t]*`{3,}[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The OLD/NEW split inside an edit block: `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`. The bodies are
|
|
109
|
+
* verbatim (indentation is part of the anchor); an empty NEW deletes the anchor (HC-GF-109).
|
|
110
|
+
*/
|
|
111
|
+
const EDIT_SPLIT_RE = /^<{3,}[ \t]*OLD[ \t]*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?>{3,}[ \t]*NEW[ \t]*\r?$/m;
|
|
112
|
+
|
|
113
|
+
/** A `DELETE: <path>` line. */
|
|
114
|
+
const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
fenceWidth,
|
|
118
|
+
closesFence,
|
|
119
|
+
fencedSpans,
|
|
120
|
+
DIRECTIVE_RE,
|
|
121
|
+
FILE_BLOCK_RE,
|
|
122
|
+
EDIT_BLOCK_RE,
|
|
123
|
+
EDIT_OPEN_RE,
|
|
124
|
+
EDIT_SPLIT_RE,
|
|
125
|
+
DELETE_RE,
|
|
126
|
+
};
|
package/lib/bundle-context.js
CHANGED
|
@@ -64,6 +64,40 @@ function isSecretPath(p) {
|
|
|
64
64
|
return SECRET_BASENAMES.some((re) => re.test(base));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Generated or lock files: real, tracked, and useless as source context.
|
|
69
|
+
*
|
|
70
|
+
* HC-GF-206: run `c92d8cdc` pulled `cli/package-lock.json` and `cli/CHANGELOG.md`
|
|
71
|
+
* in as siblings and spent 125,897 chars on files no story mentions. A lockfile
|
|
72
|
+
* windowed on `node_modules` is noise at any cap, and a generated CHANGELOG
|
|
73
|
+
* teaches nothing about the conventions a new file must match — which is the
|
|
74
|
+
* whole reason siblings are bundled.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately narrow: only files a tool writes and a human does not edit. A
|
|
77
|
+
* `.json` config an adopter maintains by hand is NOT generated and stays
|
|
78
|
+
* eligible.
|
|
79
|
+
*/
|
|
80
|
+
const GENERATED_BASENAMES = [
|
|
81
|
+
/^package-lock\.json$/i,
|
|
82
|
+
/^npm-shrinkwrap\.json$/i,
|
|
83
|
+
/^yarn\.lock$/i,
|
|
84
|
+
/^pnpm-lock\.yaml$/i,
|
|
85
|
+
/^Gemfile\.lock$/i,
|
|
86
|
+
/^poetry\.lock$/i,
|
|
87
|
+
/^Cargo\.lock$/i,
|
|
88
|
+
/^composer\.lock$/i,
|
|
89
|
+
/^go\.sum$/i,
|
|
90
|
+
/^CHANGELOG\.md$/i,
|
|
91
|
+
/\.min\.(?:js|css)$/i,
|
|
92
|
+
/\.map$/i,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function isGeneratedPath(p) {
|
|
96
|
+
const base = path.posix.basename(String(p || '').replace(/\\/g, '/'));
|
|
97
|
+
return GENERATED_BASENAMES.some((re) => re.test(base));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
67
101
|
/** Directory of a repo-relative path, '' at the root. */
|
|
68
102
|
const dirOf = (p) => {
|
|
69
103
|
const d = path.posix.dirname(String(p).replace(/\\/g, '/'));
|
|
@@ -86,11 +120,16 @@ function directChildren(dir, repoFiles) {
|
|
|
86
120
|
* return the entire repo.
|
|
87
121
|
*/
|
|
88
122
|
function nearestPopulatedAncestor(pendingPath, repoFiles) {
|
|
123
|
+
// HC-GF-206: `dir` used to be returned alongside `kids` and the single caller
|
|
124
|
+
// destructures only `kids`, so it was a handoff that never arrived anywhere.
|
|
125
|
+
// It stays the loop variable; it is no longer offered as a result. Same call
|
|
126
|
+
// as the `warning` field on the A and B parse paths: a value handed on but
|
|
127
|
+
// never consumed reads like a carried result and is not one.
|
|
89
128
|
let dir = dirOf(pendingPath);
|
|
90
129
|
for (;;) {
|
|
91
130
|
const kids = directChildren(dir, repoFiles);
|
|
92
|
-
if (kids.length > 0) return {
|
|
93
|
-
if (dir === '') return {
|
|
131
|
+
if (kids.length > 0) return { kids };
|
|
132
|
+
if (dir === '') return { kids: [] };
|
|
94
133
|
dir = dirOf(dir);
|
|
95
134
|
}
|
|
96
135
|
}
|
|
@@ -129,7 +168,7 @@ function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSib
|
|
|
129
168
|
(path.posix.extname(b) === ext) - (path.posix.extname(a) === ext));
|
|
130
169
|
for (const k of ranked) {
|
|
131
170
|
if (siblings.length >= maxSiblings) break;
|
|
132
|
-
if (taken.has(k) || isSecretPath(k)) continue;
|
|
171
|
+
if (taken.has(k) || isSecretPath(k) || isGeneratedPath(k)) continue;
|
|
133
172
|
taken.add(k); siblings.push(k);
|
|
134
173
|
}
|
|
135
174
|
}
|
|
@@ -179,4 +218,4 @@ function auditLines(sel) {
|
|
|
179
218
|
return out;
|
|
180
219
|
}
|
|
181
220
|
|
|
182
|
-
module.exports = { selectBundle, auditLines, isSecretPath, SIGNAL_FILES };
|
|
221
|
+
module.exports = { selectBundle, auditLines, isSecretPath, isGeneratedPath, SIGNAL_FILES };
|