@hone-ai/cli 1.33.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.
- package/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1289 -759
- 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/headroom.js +63 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +51 -6
- package/lib/materialize-diff.js +134 -64
- package/lib/mcp-tools.js +36 -11
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +34 -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-report.js +36 -0
- 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/tree-artifact.js +166 -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
package/lib/materialize-diff.js
CHANGED
|
@@ -22,22 +22,20 @@
|
|
|
22
22
|
const path = require('node:path');
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* The block grammar — `file:` and `edit:` blocks, the OLD/NEW split, `DELETE:` lines — lives in
|
|
26
|
+
* block-grammar.js, a byte-identical copy of the server's, so the CLI that applies a build and the
|
|
27
|
+
* server that stores it read the same blocks (HC-GF-290). Its comments carry the history: HC-GF-022
|
|
28
|
+
* (an empty block), HC-GF-127 (a longer fence for a `file:`), HC-GF-109 (a framed edit body) and
|
|
29
|
+
* HC-GF-290 (a longer fence for an `edit:`).
|
|
27
30
|
*
|
|
28
|
-
* HC-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* written containing a literal ``` `, and the following file — 2,888 bytes of
|
|
35
|
-
* pipeline logic — vanished from the diff entirely. `check-patch` reported
|
|
36
|
-
* "applies cleanly", because the diff git built from the surviving blocks was
|
|
37
|
-
* perfectly valid. Line-anchoring is also strictly better for a body that
|
|
38
|
-
* contains an indented or info-tagged fence of its own.
|
|
31
|
+
* A `file:` block (HC-019n-followup-23) is a whole file. An `edit:` block (HC-019n-followup-27) is for
|
|
32
|
+
* a file too big to bundle whole (>25K chars, truncated/windowed): step_4 never saw the whole file,
|
|
33
|
+
* so it emits a unique OLD anchor from the excerpt it WAS shown and its NEW replacement, framed with
|
|
34
|
+
* conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and lets git build the
|
|
35
|
+
* diff — so the count stays git's, exactly as for whole-file blocks. See
|
|
36
|
+
* .github/pipeline/HC-019n-followup-27/architect.md.
|
|
39
37
|
*/
|
|
40
|
-
const FILE_BLOCK_RE
|
|
38
|
+
const { FILE_BLOCK_RE, EDIT_BLOCK_RE, EDIT_OPEN_RE, EDIT_SPLIT_RE, DELETE_RE } = require('./block-grammar');
|
|
41
39
|
|
|
42
40
|
/**
|
|
43
41
|
* The line-anchored closer necessarily absorbs the newline that terminates the
|
|
@@ -49,39 +47,6 @@ function blockBody(raw) {
|
|
|
49
47
|
return String(raw == null ? '' : raw).replace(/\r?\n$/, '');
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
/**
|
|
53
|
-
* A ```edit:<path> fenced block — HC-019n-followup-27. For a file too big to
|
|
54
|
-
* bundle whole (>25K chars, truncated/windowed), step_4 cannot emit a whole-file
|
|
55
|
-
* block (it never saw the whole file). Instead it emits a REGION edit: a unique
|
|
56
|
-
* OLD anchor (from the excerpt it WAS shown) and its NEW replacement, framed with
|
|
57
|
-
* conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and
|
|
58
|
-
* lets git build the diff — so the count stays git's, exactly as for whole-file
|
|
59
|
-
* blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
|
|
60
|
-
*/
|
|
61
|
-
const EDIT_BLOCK_RE = new RegExp(
|
|
62
|
-
'^[ \\t]*```[ \\t]*edit[ \\t]*:[ \\t]*([^\\n`]+?)[ \\t]*\\r?\\n(' +
|
|
63
|
-
// HC-GF-109: a framed body ends at the fence after its `>>>>>>> NEW` line, so a NEW holding a fence line of its own
|
|
64
|
-
// (a docstring example, a Markdown file) is not cut short, and it never runs on into the next block.
|
|
65
|
-
'<{3,}[ \\t]*OLD[ \\t]*\\r?\\n(?:(?!^[ \\t]*```[ \\t]*(?:file|edit)[ \\t]*:)[\\s\\S])*?^>{3,}[ \\t]*NEW[ \\t]*\\r?\\n' +
|
|
66
|
-
// Anything else runs to the first fence line, and extractFileBlocks reports it as malformed.
|
|
67
|
-
'|(?:(?!^[ \\t]*```)[\\s\\S])*?' +
|
|
68
|
-
')^[ \\t]*```[ \\t]*\\r?$',
|
|
69
|
-
'gim');
|
|
70
|
-
|
|
71
|
-
/** HC-GF-109: the opening line of an edit block, whether or not the block parses — compared, so none is lost. */
|
|
72
|
-
const EDIT_OPEN_RE = /^[ \t]*```[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
|
|
76
|
-
* Whitespace after the marker word is tolerated; the bodies are captured verbatim
|
|
77
|
-
* (indentation is part of the anchor). An empty NEW deletes the anchor (HC-GF-109) — it used to
|
|
78
|
-
* fail this split, and the block was skipped without a word.
|
|
79
|
-
*/
|
|
80
|
-
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;
|
|
81
|
-
|
|
82
|
-
/** A `DELETE: <path>` line inside the ## Changed Files section. */
|
|
83
|
-
const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
84
|
-
|
|
85
50
|
/**
|
|
86
51
|
* Typed error for the region-edit apply path, so a consumer can map it to a
|
|
87
52
|
* distinct exit code and a message that tells step_4 how to fix its anchor.
|
|
@@ -108,15 +73,15 @@ class MaterializeError extends Error {
|
|
|
108
73
|
* reason: string|null
|
|
109
74
|
* }}
|
|
110
75
|
*/
|
|
111
|
-
|
|
112
|
-
|
|
76
|
+
/**
|
|
77
|
+
* HC-GF-191: the parser, over an ALREADY-SCOPED string. Split out so the same reading can be run
|
|
78
|
+
* twice — once over the mandated section, and once over the whole artifact when that section
|
|
79
|
+
* turned out to hold nothing. It used to compute its own scope, which made "what did the scope
|
|
80
|
+
* cost us?" a question no caller could ask.
|
|
81
|
+
*/
|
|
82
|
+
function parseBlocksIn(scope) {
|
|
113
83
|
const empty = { files: [], edits: [], deletes: [], malformed: [], noChanges: false, reason: null };
|
|
114
84
|
|
|
115
|
-
// Scope everything to the ## Changed Files section if present, so a code
|
|
116
|
-
// sample elsewhere in the prose is never mistaken for an intended file.
|
|
117
|
-
const secIdx = text.search(/^##\s+Changed Files/im);
|
|
118
|
-
const scope = secIdx === -1 ? text : text.slice(secIdx);
|
|
119
|
-
|
|
120
85
|
// Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
|
|
121
86
|
// declaration only counts when there is NO file AND NO edit block to apply.
|
|
122
87
|
const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_OPEN_RE.test(scope));
|
|
@@ -129,11 +94,12 @@ function extractFileBlocks(output) {
|
|
|
129
94
|
const files = [];
|
|
130
95
|
const seen = new Set();
|
|
131
96
|
for (const m of scope.matchAll(FILE_BLOCK_RE)) {
|
|
132
|
-
|
|
97
|
+
// HC-GF-127: m[1] is the fence itself now, so the path and body moved to m[2]/m[3].
|
|
98
|
+
const rel = normalizeRepoPath(m[2]);
|
|
133
99
|
if (!rel) continue; // rejected (traversal/absolute) — see below
|
|
134
100
|
if (seen.has(rel)) continue; // first block wins on a dup path
|
|
135
101
|
seen.add(rel);
|
|
136
|
-
files.push({ path: rel, contents: blockBody(m[
|
|
102
|
+
files.push({ path: rel, contents: blockBody(m[3]) });
|
|
137
103
|
}
|
|
138
104
|
|
|
139
105
|
// HC-019n-followup-27: region edits for files too big to bundle whole.
|
|
@@ -146,10 +112,10 @@ function extractFileBlocks(output) {
|
|
|
146
112
|
const parsedAt = new Set();
|
|
147
113
|
for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
|
|
148
114
|
parsedAt.add(m.index);
|
|
149
|
-
const rel = normalizeRepoPath(m[
|
|
115
|
+
const rel = normalizeRepoPath(m[2]); // HC-GF-290: m[1] is the fence now
|
|
150
116
|
if (!rel) continue;
|
|
151
117
|
if (seen.has(rel)) continue; // a whole-file block wins
|
|
152
|
-
const split = EDIT_SPLIT_RE.exec(m[
|
|
118
|
+
const split = EDIT_SPLIT_RE.exec(m[3]);
|
|
153
119
|
if (!split) { // no OLD/NEW markers: reported, and buildDiff refuses the patch
|
|
154
120
|
malformed.push({ path: rel, reason: 'no <<<<<<< OLD / ======= / >>>>>>> NEW markers' });
|
|
155
121
|
continue;
|
|
@@ -212,6 +178,49 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
|
|
|
212
178
|
return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
|
|
213
179
|
}
|
|
214
180
|
|
|
181
|
+
/**
|
|
182
|
+
* HC-GF-265 — the two-index diagnosis. BYTE-IDENTICAL to the copy in
|
|
183
|
+
* server/src/services/carry-forward.js (the server cannot require the CLI package, so the
|
|
184
|
+
* wording is restated, not imported); parity is pinned by
|
|
185
|
+
* tests/regression/HC-GF-265-diagnosis-message-parity.test.js.
|
|
186
|
+
*/
|
|
187
|
+
function destroyedAnchorSuffix(j, k, total) {
|
|
188
|
+
return `Edit ${j} of ${total} rewrote this anchor: it matched before edit ${j} was applied and not after. `
|
|
189
|
+
+ `Merge edits ${j} and ${k} into ONE edit whose OLD is the text as it stands before either change.`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* HC-GF-265 — which EARLIER edit destroyed edit k's anchor. Returns a 0-based index, or null.
|
|
194
|
+
*
|
|
195
|
+
* Runs only on the failure path, so an accept/reject outcome cannot change: nothing is
|
|
196
|
+
* pre-checked. Edit ORDER is load-bearing (HC-GF-109, run 1a8940af), so the earlier edits are
|
|
197
|
+
* REPLAYED over the original base rather than matched against it — which is why run 011f04c7,
|
|
198
|
+
* where two edits share an OLD first line and both apply, is never touched.
|
|
199
|
+
*
|
|
200
|
+
* null means "today's message": the anchor matched at no stage of the replay (the HC-GF-222 shape,
|
|
201
|
+
* explicitly out of scope), the anchor is empty, or no earlier edit stopped it matching. An anchor
|
|
202
|
+
* absent from the ORIGINAL file is still replayed: an earlier edit may have inserted it (HC-GF-109). Never fabricates
|
|
203
|
+
* a pair. The LAST >0→0 transition wins: if edit j destroys the anchor and a later edit
|
|
204
|
+
* re-inserts it, the culprit is the edit after the re-insertion, not j.
|
|
205
|
+
*/
|
|
206
|
+
function diagnoseDestroyedAnchor(baseContents, edits, k) {
|
|
207
|
+
const anchor = String((edits[k] && edits[k].oldString) || '');
|
|
208
|
+
if (!anchor || k < 1) return null;
|
|
209
|
+
let cur = String(baseContents);
|
|
210
|
+
let lastBreak = null;
|
|
211
|
+
for (let j = 0; j < k; j++) {
|
|
212
|
+
let next;
|
|
213
|
+
try {
|
|
214
|
+
next = applyEdit(cur, edits[j].oldString, edits[j].newString, '');
|
|
215
|
+
} catch {
|
|
216
|
+
return lastBreak; // replay diverged — report only what is established, never a guess
|
|
217
|
+
}
|
|
218
|
+
if (countOccurrences(cur, anchor) > 0 && countOccurrences(next, anchor) === 0) lastBreak = j;
|
|
219
|
+
cur = next;
|
|
220
|
+
}
|
|
221
|
+
return lastBreak;
|
|
222
|
+
}
|
|
223
|
+
|
|
215
224
|
/**
|
|
216
225
|
* HC-GF-109 — apply every region edit for ONE file, as one change.
|
|
217
226
|
*
|
|
@@ -229,9 +238,21 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
|
|
|
229
238
|
* @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
|
|
230
239
|
*/
|
|
231
240
|
function applyEdits(fileContents, edits, repoPath) {
|
|
232
|
-
|
|
241
|
+
const base = String(fileContents);
|
|
242
|
+
let out = base;
|
|
233
243
|
edits.forEach((e, i) => {
|
|
234
|
-
|
|
244
|
+
const where = edits.length > 1 ? `${repoPath} (edit ${i + 1} of ${edits.length})` : repoPath;
|
|
245
|
+
try {
|
|
246
|
+
out = applyEdit(out, e.oldString, e.newString, where);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
// HC-GF-265: only the not-found case is diagnosed. `anchor_ambiguous` already names the
|
|
249
|
+
// edit and is out of scope (HC-GF-109 pins its message), so it is rethrown untouched.
|
|
250
|
+
if (!(err instanceof MaterializeError) || err.code !== 'anchor_not_found') throw err;
|
|
251
|
+
const j = diagnoseDestroyedAnchor(base, edits, i);
|
|
252
|
+
if (j === null) throw err;
|
|
253
|
+
throw new MaterializeError('anchor_not_found',
|
|
254
|
+
`${err.message} ${destroyedAnchorSuffix(j + 1, i + 1, edits.length)}`);
|
|
255
|
+
}
|
|
235
256
|
});
|
|
236
257
|
return out;
|
|
237
258
|
}
|
|
@@ -417,7 +438,6 @@ function ensureTrailingNewline(s) {
|
|
|
417
438
|
*/
|
|
418
439
|
function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
|
|
419
440
|
const fs = require('node:fs');
|
|
420
|
-
const os = require('node:os');
|
|
421
441
|
const p = require('node:path');
|
|
422
442
|
const { execFileSync } = require('node:child_process');
|
|
423
443
|
const { gitEnv } = require('./git-env');
|
|
@@ -429,9 +449,10 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
|
|
|
429
449
|
return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
|
|
430
450
|
},
|
|
431
451
|
tmpFile: (contents) => {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
452
|
+
// HC-GF-304: its own directory, not a name in the shared one that another local user can predict and plant.
|
|
453
|
+
const t = require('./private-temp').privateTempFile('hone-mat', `candidate-${n++}`);
|
|
454
|
+
fs.writeFileSync(t.file, contents);
|
|
455
|
+
return { path: t.file, cleanup: t.remove };
|
|
435
456
|
},
|
|
436
457
|
runGitDiff: (origArg, candPath) => {
|
|
437
458
|
let stdout = '', code = 0;
|
|
@@ -493,6 +514,51 @@ function hasMaterializableWork(blocks) {
|
|
|
493
514
|
(blocks.deletes || []).length > 0 || (blocks.malformed || []).length > 0;
|
|
494
515
|
}
|
|
495
516
|
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Read the file / edit / delete blocks an agent emitted.
|
|
520
|
+
*
|
|
521
|
+
* Scoped to `## Changed Files` when that heading exists, so a code sample elsewhere in the prose
|
|
522
|
+
* is never mistaken for an intended file — the guard is right and is kept.
|
|
523
|
+
*
|
|
524
|
+
* HC-GF-191: what the guard could not survive is the heading arriving AFTER the blocks. On run
|
|
525
|
+
* 44a0c69f the builder emitted a complete implementation under `## Implementation Code` and then a
|
|
526
|
+
* `## Changed Files` SUMMARY below it, so the slice began past every block: 0 files, 0 edits, and
|
|
527
|
+
* 0 malformed. verify-patch reported "produced no file changes to verify" for a finished build,
|
|
528
|
+
* with nothing anywhere saying a section had been skipped.
|
|
529
|
+
*
|
|
530
|
+
* The fallback is narrow on purpose. It fires ONLY when the scoped section yields nothing, so
|
|
531
|
+
* wherever the mandated section holds real blocks there is nothing to confuse them with and the
|
|
532
|
+
* scope still wins. And it is never silent: `scopeFallback` plus a reason name the deviation,
|
|
533
|
+
* because a guard that quietly changes what it read is precisely how this defect reached a run.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} output raw agent artifact
|
|
536
|
+
* @returns {{files: Array, edits: Array, deletes: Array, malformed: Array, noChanges: boolean,
|
|
537
|
+
* reason: string|null, scopeFallback?: boolean}}
|
|
538
|
+
*/
|
|
539
|
+
function extractFileBlocks(output) {
|
|
540
|
+
const text = String(output || '');
|
|
541
|
+
const secIdx = text.search(/^##\s+Changed Files/im);
|
|
542
|
+
if (secIdx === -1) return parseBlocksIn(text);
|
|
543
|
+
|
|
544
|
+
const scoped = parseBlocksIn(text.slice(secIdx));
|
|
545
|
+
if (hasMaterializableWork(scoped) || scoped.noChanges) return scoped;
|
|
546
|
+
|
|
547
|
+
// The mandated section held nothing. Before calling that "no changes", look at the whole
|
|
548
|
+
// artifact — the difference between "there is nothing to apply" and "what there is, is
|
|
549
|
+
// somewhere else" is the difference between an honest no-op and a lost build.
|
|
550
|
+
const whole = parseBlocksIn(text);
|
|
551
|
+
if (!hasMaterializableWork(whole)) return scoped;
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
...whole,
|
|
555
|
+
scopeFallback: true,
|
|
556
|
+
reason: 'blocks were found OUTSIDE the `## Changed Files` section and read from the whole '
|
|
557
|
+
+ 'artifact instead. The code-builder contract puts them under that heading; emitting them '
|
|
558
|
+
+ 'above it (or under another heading) would otherwise be read as "no changes".',
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
496
562
|
module.exports = {
|
|
497
563
|
extractFileBlocks,
|
|
498
564
|
normalizeRepoPath,
|
|
@@ -503,6 +569,10 @@ module.exports = {
|
|
|
503
569
|
headMaterializeIO,
|
|
504
570
|
applyEdit,
|
|
505
571
|
applyEdits,
|
|
572
|
+
// HC-GF-265: exported so the resolved pair is asserted by BEHAVIOUR and so the wording can be
|
|
573
|
+
// compared against carry-forward.js's copy directly, not inferred from a message regex.
|
|
574
|
+
destroyedAnchorSuffix,
|
|
575
|
+
diagnoseDestroyedAnchor,
|
|
506
576
|
hasMaterializableWork,
|
|
507
577
|
EDIT_OPEN_RE,
|
|
508
578
|
countOccurrences,
|
package/lib/mcp-tools.js
CHANGED
|
@@ -164,18 +164,39 @@ async function stepInstruction({ runId, cwd } = {}) {
|
|
|
164
164
|
// hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
|
|
165
165
|
// travels via a temp file (1MB-class payloads must not ride argv); the file
|
|
166
166
|
// is best-effort deleted after the shell-out.
|
|
167
|
-
async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd } = {}) {
|
|
167
|
+
async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
|
|
168
168
|
if (!runId) return { isError: true, detail: 'runId is required' };
|
|
169
169
|
if (!stepKey) return { isError: true, detail: 'stepKey is required' };
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
|
|
171
|
+
// HC-GF-130: DELEGATE the from-tree render to the CLI rather than rendering here and submitting
|
|
172
|
+
// the result with `--file`. That is what shipped in HC-GF-091, and HC-GF-092's guard — which
|
|
173
|
+
// refuses `--contract 2` without `--from-tree` — turned the one caller that HAD built from the
|
|
174
|
+
// tree away. The guard reads the flag, not the provenance, so the adopter's primary path was
|
|
175
|
+
// blocked while the CLI fallback worked. Delegating also removes a second producer of the same
|
|
176
|
+
// artifact, which is the shape HC-GF-127 had to unpick across three parsers.
|
|
177
|
+
const hasOutput = typeof output === 'string' && output.trim() !== '';
|
|
178
|
+
if (fromTree && hasOutput) {
|
|
179
|
+
return { isError: true, detail: 'pass output OR fromTree, not both: they are two different claims about where the build is' };
|
|
172
180
|
}
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
if (!fromTree && !hasOutput) {
|
|
182
|
+
return { isError: true, detail: 'output is required (the complete step output text), or set fromTree' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
|
|
186
|
+
let tmp = null;
|
|
187
|
+
if (fromTree) {
|
|
188
|
+
base.push('--from-tree');
|
|
189
|
+
// Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
|
|
190
|
+
if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
|
|
191
|
+
} else {
|
|
192
|
+
// Output travels via a temp file — 1MB-class payloads must not ride argv.
|
|
193
|
+
tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
|
|
175
194
|
fsSync.writeFileSync(tmp, output, 'utf8');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
195
|
+
base.push('--file', tmp);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const r = await module.exports._internal.runHone(base, { cwd });
|
|
179
200
|
if (r.error) return { isError: true, detail: r.error };
|
|
180
201
|
const data = parseLastJson(r.stdout);
|
|
181
202
|
return {
|
|
@@ -188,7 +209,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', cwd }
|
|
|
188
209
|
: (r.stderr || r.stdout || 'submission failed').slice(0, 500),
|
|
189
210
|
};
|
|
190
211
|
} finally {
|
|
191
|
-
try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
212
|
+
if (tmp) { try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ } }
|
|
192
213
|
}
|
|
193
214
|
}
|
|
194
215
|
|
|
@@ -432,10 +453,12 @@ const TOOLS = [
|
|
|
432
453
|
properties: {
|
|
433
454
|
runId: { type: 'string' },
|
|
434
455
|
stepKey: { type: 'string' },
|
|
435
|
-
output: { type: 'string' },
|
|
456
|
+
output: { type: 'string', description: 'The complete step output text. Omit when fromTree is true.' },
|
|
436
457
|
contractVersion: { type: 'string', default: '1' },
|
|
458
|
+
fromTree: { type: 'boolean', default: false, description: 'HC-GF-091: render the artifact from the working tree against HEAD — write the files with your own tools first, then submit. Removes the single-response output ceiling.' },
|
|
459
|
+
testReport: { type: 'string', description: 'With fromTree: your test-run report, carried verbatim. Omit if you did not run them; it is never invented for you.' },
|
|
437
460
|
},
|
|
438
|
-
required: ['runId', 'stepKey'
|
|
461
|
+
required: ['runId', 'stepKey'],
|
|
439
462
|
},
|
|
440
463
|
handler: submitStep,
|
|
441
464
|
},
|
|
@@ -455,4 +478,6 @@ module.exports = {
|
|
|
455
478
|
killWorkflow,
|
|
456
479
|
runStory, status, verifyPatch, checkPatch, showStep, approve, emitPr, verifyPr, agentEval, derive, sync, doctor,
|
|
457
480
|
runHone, parseRunStarted, parseLastJson, honeBin, readHoneConfig, TOOLS,
|
|
481
|
+
// HC-GF-130: exposed so a test can observe the argv handed to the CLI without running it.
|
|
482
|
+
_internal: { runHone },
|
|
458
483
|
};
|
package/lib/pipeline-config.js
CHANGED
|
@@ -388,6 +388,47 @@ function readExecutionMode(repoRoot) {
|
|
|
388
388
|
return DEFAULT_EXECUTION_MODE;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
/**
|
|
392
|
+
* HC-GF-097: the execution mode AS WRITTEN, beside the normalized one. `readExecutionMode` maps any
|
|
393
|
+
* value it does not know to 'server', which is the right default for a run an operator is
|
|
394
|
+
* watching — and the wrong one for an unattended overnight batch, where "server" is a guess about a
|
|
395
|
+
* typo nobody will see. queue-stories refuses `--overnight` on an unrecognised mode, so it has to
|
|
396
|
+
* be able to tell `mode: edtior` from no `mode:` at all. Same candidate order as readExecutionMode.
|
|
397
|
+
*
|
|
398
|
+
* @param {string} repoRoot
|
|
399
|
+
* @returns {{ raw: string|null, mode: 'server'|'editor', recognized: boolean }}
|
|
400
|
+
* raw is null when no config declares a mode (the default is a recognised state).
|
|
401
|
+
*/
|
|
402
|
+
function readExecutionModeRaw(repoRoot) {
|
|
403
|
+
const none = { raw: null, mode: DEFAULT_EXECUTION_MODE, recognized: true };
|
|
404
|
+
if (!repoRoot || typeof repoRoot !== 'string') return none;
|
|
405
|
+
const candidates = [
|
|
406
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
407
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
408
|
+
];
|
|
409
|
+
for (const p of candidates) {
|
|
410
|
+
if (!fs.existsSync(p)) continue;
|
|
411
|
+
let raw;
|
|
412
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
413
|
+
let parsed;
|
|
414
|
+
try {
|
|
415
|
+
const yaml = require('js-yaml');
|
|
416
|
+
parsed = yaml.load(raw);
|
|
417
|
+
} catch { continue; }
|
|
418
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
419
|
+
const block = parsed.execution;
|
|
420
|
+
if (!block || typeof block !== 'object') continue;
|
|
421
|
+
if (block.mode === undefined) continue;
|
|
422
|
+
const written = String(block.mode == null ? '' : block.mode);
|
|
423
|
+
return {
|
|
424
|
+
raw: written,
|
|
425
|
+
mode: normalizeExecutionMode(block.mode),
|
|
426
|
+
recognized: EXECUTION_MODES.includes(written.toLowerCase().trim()),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return none;
|
|
430
|
+
}
|
|
431
|
+
|
|
391
432
|
// ── HC-019n-followup-34: closed-loop verification gate ────────────────────────
|
|
392
433
|
// The CLI verbs (verify-patch/verify-pr) EXECUTE and report a verdict to the
|
|
393
434
|
// server. `verification.gate` controls what the server does with it:
|
|
@@ -542,7 +583,147 @@ function readAgentEvalConfig(repoRoot) {
|
|
|
542
583
|
return { targets: [] };
|
|
543
584
|
}
|
|
544
585
|
|
|
586
|
+
|
|
587
|
+
// ── HC-GF-169: the adopter-declared scratch datastore ────────────────────────────────────────
|
|
588
|
+
//
|
|
589
|
+
// `.pipeline-config.yml` had no data-layer concept, so no step could validate behaviour needing
|
|
590
|
+
// stored state — which is most of an adopter's backend. The contract is the one `ci.local_command`
|
|
591
|
+
// already uses: Hone cannot know their datastore, so the adopter declares it and the CLI executes
|
|
592
|
+
// it adopter-side, where `verify-patch` already runs with their credentials.
|
|
593
|
+
//
|
|
594
|
+
// THE GUARD IS THE LOAD-BEARING PART. Seeding test rows into a customer's production database is
|
|
595
|
+
// the worst outcome this design can produce, so `verify` is REQUIRED whenever commands are declared
|
|
596
|
+
// and the assertion reads the LIVE CONNECTION, never the URL. A URL can spell `_sandbox` and point
|
|
597
|
+
// anywhere — DNS, a pooler, a tunnel, an overridden PGDATABASE.
|
|
598
|
+
//
|
|
599
|
+
// HC-GF-183, learned from the first live run: THE DATABASE AND THE VERIFY TABLE MUST ALREADY
|
|
600
|
+
// EXIST. The probe runs BEFORE `create` — deliberately, because proving a target is scratch has to
|
|
601
|
+
// precede writing to it — so there is nothing for it to read on a fresh database. That makes
|
|
602
|
+
// `create` responsible for the TABLES, not the database: an adopter writing `create: createdb foo`
|
|
603
|
+
// is refused on their first run with nothing explaining why. Provision the database itself once,
|
|
604
|
+
// out of band, then let `create` build the schema inside it.
|
|
605
|
+
//
|
|
606
|
+
// And `reset` must leave the sandbox PROBEABLE. The CLI runs `reset` then `create` as the teardown
|
|
607
|
+
// (see cli/lib/sandbox-lifecycle.js) precisely so a reset that drops the schema cannot brick the
|
|
608
|
+
// next run — which is exactly what happened the first time this was declared against a real
|
|
609
|
+
// database.
|
|
610
|
+
//
|
|
611
|
+
// Wired since HC-GF-171: verify-patch probes, guards, seeds and tears down.
|
|
612
|
+
const SANDBOX_COMMAND_KEYS = Object.freeze(['create', 'migrate', 'reset']);
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* @param {string} repoRoot
|
|
616
|
+
* @returns {null | {create: string|null, migrate: string|null, reset: string|null,
|
|
617
|
+
* capabilities: string[], verify: {database: string, table: string, max_rows: number}}}
|
|
618
|
+
* @throws when a sandbox declares commands without a usable `verify` block.
|
|
619
|
+
*/
|
|
620
|
+
function readSandboxConfig(repoRoot) {
|
|
621
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
622
|
+
for (const p of [path.join(repoRoot, '.pipeline-config.yml'),
|
|
623
|
+
path.join(repoRoot, '.github/.pipeline-config.yml')]) {
|
|
624
|
+
if (!fs.existsSync(p)) continue;
|
|
625
|
+
let parsed;
|
|
626
|
+
try { parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8')); } catch { continue; }
|
|
627
|
+
const block = parsed && typeof parsed === 'object' ? parsed.sandbox : null;
|
|
628
|
+
if (!block || typeof block !== 'object') continue;
|
|
629
|
+
|
|
630
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
631
|
+
const commands = {};
|
|
632
|
+
for (const k of SANDBOX_COMMAND_KEYS) commands[k] = str(block[k]);
|
|
633
|
+
const declaresWork = SANDBOX_COMMAND_KEYS.some(k => commands[k]);
|
|
634
|
+
|
|
635
|
+
const v = block.verify && typeof block.verify === 'object' ? block.verify : null;
|
|
636
|
+
// HC-GF-171: `probe` is the command that PRODUCES the evidence `assertScratchTarget` demands.
|
|
637
|
+
// Carried through here, required at ENGAGEMENT (see cli/lib/sandbox-lifecycle.js) rather than
|
|
638
|
+
// at parse time: a declaration missing it is incomplete, not malformed, and the refusal is far
|
|
639
|
+
// more useful at the moment the sandbox would have fired — it can name what to add.
|
|
640
|
+
const verify = v && str(v.database) && str(v.table) && Number.isFinite(Number(v.max_rows))
|
|
641
|
+
? { database: str(v.database), table: str(v.table), max_rows: Number(v.max_rows),
|
|
642
|
+
probe: str(v.probe) }
|
|
643
|
+
: null;
|
|
644
|
+
|
|
645
|
+
if (declaresWork && !verify) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`${p}: sandbox declares ${SANDBOX_COMMAND_KEYS.filter(k => commands[k]).join('/')} but no `
|
|
648
|
+
+ 'usable `verify` block (needs database, table and max_rows). Without it the CLI cannot '
|
|
649
|
+
+ 'prove the target is scratch before writing to it, and an optional guard on this action '
|
|
650
|
+
+ 'is worse than no feature.');
|
|
651
|
+
}
|
|
652
|
+
if (!declaresWork && !verify) return null;
|
|
653
|
+
|
|
654
|
+
const capabilities = Array.isArray(block.capabilities)
|
|
655
|
+
? block.capabilities.map(str).filter(Boolean) : [];
|
|
656
|
+
|
|
657
|
+
// HC-GF-192: `test` is the command that READS the sandbox, and `env` is what it reads the
|
|
658
|
+
// connection from. Carried here and required at ENGAGEMENT, for the same reason `probe` is: a
|
|
659
|
+
// declaration missing them is incomplete rather than malformed, and the refusal is far more
|
|
660
|
+
// useful at the moment the sandbox would have fired, where it can name what to add.
|
|
661
|
+
//
|
|
662
|
+
// Shape is checked in the plan, not here — `env: DATABASE_URL=…` is valid YAML (a string) and
|
|
663
|
+
// an invalid env map, and refusing it next to the other engagement refusals keeps one place
|
|
664
|
+
// that decides whether this sandbox can be used.
|
|
665
|
+
const test = str(block.test);
|
|
666
|
+
// Passed through UNVALIDATED, deliberately. An earlier draft tested the type here and then
|
|
667
|
+
// returned `block.env` on both branches — a guard that read like validation and changed no
|
|
668
|
+
// outcome, which is the shape this whole chain keeps finding. The real check is
|
|
669
|
+
// `plainStringMap` in sandbox-lifecycle.js, where a bad `env` becomes a refusal that names the
|
|
670
|
+
// fix; duplicating it here would mean two places deciding, and eventually disagreeing.
|
|
671
|
+
const env = block.env === undefined ? null : block.env;
|
|
672
|
+
|
|
673
|
+
return { ...commands, capabilities, verify, test, env };
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Refuse unless the LIVE connection proves the target is scratch.
|
|
680
|
+
*
|
|
681
|
+
* @param {{database: string, table: string, max_rows: number}} verify what the adopter declared
|
|
682
|
+
* @param {{database?: string, rows?: number, url?: string}} probe what the SERVER reported
|
|
683
|
+
* @throws when the probe is absent, incomplete, names another database, or the target is populated.
|
|
684
|
+
*/
|
|
685
|
+
function assertScratchTarget(verify, probe) {
|
|
686
|
+
if (!verify || typeof verify !== 'object') {
|
|
687
|
+
throw new Error('sandbox verify block missing — refusing to treat any target as scratch');
|
|
688
|
+
}
|
|
689
|
+
if (!probe || typeof probe !== 'object') {
|
|
690
|
+
throw new Error('no live probe — refusing to write. "Could not check" is not "checked and '
|
|
691
|
+
+ 'found nothing"; run the probe or stop.');
|
|
692
|
+
}
|
|
693
|
+
// `probe.url` is deliberately ignored. It is accepted only so a caller passing it for logging
|
|
694
|
+
// cannot mistake this for a check of it.
|
|
695
|
+
const live = typeof probe.database === 'string' ? probe.database.trim() : null;
|
|
696
|
+
if (!live) {
|
|
697
|
+
throw new Error('the probe reported no database name — refusing. The name must come from the '
|
|
698
|
+
+ 'server (e.g. `SELECT current_database()`), not from the connection string.');
|
|
699
|
+
}
|
|
700
|
+
if (live !== verify.database) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`refusing to write: the live connection reports database "${live}", but the sandbox declares `
|
|
703
|
+
+ `"${verify.database}". A connection string can spell the sandbox name and still point `
|
|
704
|
+
+ 'elsewhere.');
|
|
705
|
+
}
|
|
706
|
+
// A real NUMBER, not something that coerces to one. `Number(null)`, `Number('')`,
|
|
707
|
+
// `Number(false)` and `Number([])` are all 0, so a probe that reported nothing would read as an
|
|
708
|
+
// empty database and be allowed — the exact "could not check treated as checked" this refuses.
|
|
709
|
+
if (typeof probe.rows !== 'number' || !Number.isFinite(probe.rows)) {
|
|
710
|
+
throw new Error(`refusing to write to "${live}": no row count was probed `
|
|
711
|
+
+ `(got ${JSON.stringify(probe.rows)}). A name match alone is not proof — a restored copy of `
|
|
712
|
+
+ 'production can carry the scratch name.');
|
|
713
|
+
}
|
|
714
|
+
const rows = probe.rows;
|
|
715
|
+
if (rows > verify.max_rows) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
`refusing to write to "${live}": ${verify.table} holds ${rows} rows, above the declared `
|
|
718
|
+
+ `ceiling of ${verify.max_rows}. This looks like real data.`);
|
|
719
|
+
}
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
|
|
545
723
|
module.exports = {
|
|
724
|
+
readSandboxConfig,
|
|
725
|
+
assertScratchTarget,
|
|
726
|
+
SANDBOX_COMMAND_KEYS,
|
|
546
727
|
readSourceDirs,
|
|
547
728
|
readCodebaseDefaults,
|
|
548
729
|
readCIConfigDetails,
|
|
@@ -551,6 +732,7 @@ module.exports = {
|
|
|
551
732
|
EDITOR_MODE_CONSENT_NOTE,
|
|
552
733
|
normalizeExecutionMode,
|
|
553
734
|
readExecutionMode,
|
|
735
|
+
readExecutionModeRaw,
|
|
554
736
|
readStoryClassifierConfig,
|
|
555
737
|
KNOWN_THRESHOLD_KEYS,
|
|
556
738
|
readCIGateConfig,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* private-temp.js — HC-GF-304. A temp file no other process can share.
|
|
4
|
+
*
|
|
5
|
+
* check-patch, verify-patch and emit-pr named their diff `os.tmpdir()/hone-<verb>-${Date.now()}.diff`. Two of
|
|
6
|
+
* them started in the same millisecond on one machine (two worktrees, the MCP server beside the CLI, the
|
|
7
|
+
* regression suite's parallel files) wrote the SAME path, so one applied, verified or committed the other's
|
|
8
|
+
* patch. On the runner it was caught applying HC-GF-077's `src/app.js` diff inside HC-GF-098's test repository.
|
|
9
|
+
* A verify-patch that reads another run's diff can report a pass for a patch that is not its run's, and under
|
|
10
|
+
* `verification_gate: enforce` that pass approves step_4.
|
|
11
|
+
*
|
|
12
|
+
* A clock is not an identity. `fs.mkdtempSync` creates a new directory with a random suffix, atomically, with
|
|
13
|
+
* mode 0700, so the file inside it belongs to exactly one invocation whatever the time, and nobody else can
|
|
14
|
+
* plant or replace it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} prefix names the owner in the directory, e.g. 'hone-emit'
|
|
23
|
+
* @param {string} name the file's name inside its private directory, e.g. 'patch.diff'
|
|
24
|
+
* @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws
|
|
25
|
+
*/
|
|
26
|
+
function privateTempFile(prefix, name) {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
|
|
28
|
+
return {
|
|
29
|
+
file: path.join(dir, name),
|
|
30
|
+
remove: () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { privateTempFile };
|