@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
package/lib/materialize-diff.js
CHANGED
|
@@ -22,33 +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
|
-
|
|
41
|
-
* HC-GF-127: the fence is 3 OR MORE backticks, and the closer must MATCH the opener (backreference).
|
|
42
|
-
*
|
|
43
|
-
* It used to be exactly three, so a block closed on the first ``` line inside it: a README carrying
|
|
44
|
-
* a ```js sample ended AT the sample, the rest was lost, and `malformed` came back empty — the
|
|
45
|
-
* artifact looked valid and materialized to the wrong bytes. Widening the opener alone would not
|
|
46
|
-
* help; only a matching closer lets a wider fence carry content that contains a narrower one.
|
|
47
|
-
*
|
|
48
|
-
* 3-backtick blocks parse exactly as before: `\1` on a 3-backtick opener IS the old literal. Pinned
|
|
49
|
-
* against 48 artifacts captured from real runs.
|
|
50
|
-
*/
|
|
51
|
-
const FILE_BLOCK_RE = /^[ \t]*(`{3,})[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*\1[ \t]*\r?$/gim;
|
|
38
|
+
const { FILE_BLOCK_RE, EDIT_BLOCK_RE, EDIT_OPEN_RE, EDIT_SPLIT_RE, DELETE_RE } = require('./block-grammar');
|
|
52
39
|
|
|
53
40
|
/**
|
|
54
41
|
* The line-anchored closer necessarily absorbs the newline that terminates the
|
|
@@ -60,39 +47,6 @@ function blockBody(raw) {
|
|
|
60
47
|
return String(raw == null ? '' : raw).replace(/\r?\n$/, '');
|
|
61
48
|
}
|
|
62
49
|
|
|
63
|
-
/**
|
|
64
|
-
* A ```edit:<path> fenced block — HC-019n-followup-27. For a file too big to
|
|
65
|
-
* bundle whole (>25K chars, truncated/windowed), step_4 cannot emit a whole-file
|
|
66
|
-
* block (it never saw the whole file). Instead it emits a REGION edit: a unique
|
|
67
|
-
* OLD anchor (from the excerpt it WAS shown) and its NEW replacement, framed with
|
|
68
|
-
* conflict markers. The CLI applies OLD→NEW to the REAL full file on disk and
|
|
69
|
-
* lets git build the diff — so the count stays git's, exactly as for whole-file
|
|
70
|
-
* blocks. See .github/pipeline/HC-019n-followup-27/architect.md.
|
|
71
|
-
*/
|
|
72
|
-
const EDIT_BLOCK_RE = new RegExp(
|
|
73
|
-
'^[ \\t]*```[ \\t]*edit[ \\t]*:[ \\t]*([^\\n`]+?)[ \\t]*\\r?\\n(' +
|
|
74
|
-
// HC-GF-109: a framed body ends at the fence after its `>>>>>>> NEW` line, so a NEW holding a fence line of its own
|
|
75
|
-
// (a docstring example, a Markdown file) is not cut short, and it never runs on into the next block.
|
|
76
|
-
'<{3,}[ \\t]*OLD[ \\t]*\\r?\\n(?:(?!^[ \\t]*```[ \\t]*(?:file|edit)[ \\t]*:)[\\s\\S])*?^>{3,}[ \\t]*NEW[ \\t]*\\r?\\n' +
|
|
77
|
-
// Anything else runs to the first fence line, and extractFileBlocks reports it as malformed.
|
|
78
|
-
'|(?:(?!^[ \\t]*```)[\\s\\S])*?' +
|
|
79
|
-
')^[ \\t]*```[ \\t]*\\r?$',
|
|
80
|
-
'gim');
|
|
81
|
-
|
|
82
|
-
/** HC-GF-109: the opening line of an edit block, whether or not the block parses — compared, so none is lost. */
|
|
83
|
-
const EDIT_OPEN_RE = /^[ \t]*`{3,}[ \t]*edit[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?$/gim;
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The OLD/NEW split inside an edit block. `<<<<<<< OLD` … `=======` … `>>>>>>> NEW`.
|
|
87
|
-
* Whitespace after the marker word is tolerated; the bodies are captured verbatim
|
|
88
|
-
* (indentation is part of the anchor). An empty NEW deletes the anchor (HC-GF-109) — it used to
|
|
89
|
-
* fail this split, and the block was skipped without a word.
|
|
90
|
-
*/
|
|
91
|
-
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;
|
|
92
|
-
|
|
93
|
-
/** A `DELETE: <path>` line inside the ## Changed Files section. */
|
|
94
|
-
const DELETE_RE = /^[ \t]*DELETE:[ \t]*(\S.*?)[ \t]*$/gim;
|
|
95
|
-
|
|
96
50
|
/**
|
|
97
51
|
* Typed error for the region-edit apply path, so a consumer can map it to a
|
|
98
52
|
* distinct exit code and a message that tells step_4 how to fix its anchor.
|
|
@@ -119,15 +73,15 @@ class MaterializeError extends Error {
|
|
|
119
73
|
* reason: string|null
|
|
120
74
|
* }}
|
|
121
75
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
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) {
|
|
124
83
|
const empty = { files: [], edits: [], deletes: [], malformed: [], noChanges: false, reason: null };
|
|
125
84
|
|
|
126
|
-
// Scope everything to the ## Changed Files section if present, so a code
|
|
127
|
-
// sample elsewhere in the prose is never mistaken for an intended file.
|
|
128
|
-
const secIdx = text.search(/^##\s+Changed Files/im);
|
|
129
|
-
const scope = secIdx === -1 ? text : text.slice(secIdx);
|
|
130
|
-
|
|
131
85
|
// Honest no-op, same shape as the followup-19 NO CHANGES escape. A NO CHANGES
|
|
132
86
|
// declaration only counts when there is NO file AND NO edit block to apply.
|
|
133
87
|
const hasBlock = (FILE_BLOCK_RE.test(scope) || EDIT_OPEN_RE.test(scope));
|
|
@@ -158,10 +112,10 @@ function extractFileBlocks(output) {
|
|
|
158
112
|
const parsedAt = new Set();
|
|
159
113
|
for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
|
|
160
114
|
parsedAt.add(m.index);
|
|
161
|
-
const rel = normalizeRepoPath(m[
|
|
115
|
+
const rel = normalizeRepoPath(m[2]); // HC-GF-290: m[1] is the fence now
|
|
162
116
|
if (!rel) continue;
|
|
163
117
|
if (seen.has(rel)) continue; // a whole-file block wins
|
|
164
|
-
const split = EDIT_SPLIT_RE.exec(m[
|
|
118
|
+
const split = EDIT_SPLIT_RE.exec(m[3]);
|
|
165
119
|
if (!split) { // no OLD/NEW markers: reported, and buildDiff refuses the patch
|
|
166
120
|
malformed.push({ path: rel, reason: 'no <<<<<<< OLD / ======= / >>>>>>> NEW markers' });
|
|
167
121
|
continue;
|
|
@@ -224,6 +178,49 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
|
|
|
224
178
|
return src.slice(0, idx) + String(newString) + src.slice(idx + anchor.length);
|
|
225
179
|
}
|
|
226
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
|
+
|
|
227
224
|
/**
|
|
228
225
|
* HC-GF-109 — apply every region edit for ONE file, as one change.
|
|
229
226
|
*
|
|
@@ -241,9 +238,21 @@ function applyEdit(fileContents, oldString, newString, repoPath) {
|
|
|
241
238
|
* @throws {MaterializeError} code 'anchor_not_found' | 'anchor_ambiguous'
|
|
242
239
|
*/
|
|
243
240
|
function applyEdits(fileContents, edits, repoPath) {
|
|
244
|
-
|
|
241
|
+
const base = String(fileContents);
|
|
242
|
+
let out = base;
|
|
245
243
|
edits.forEach((e, i) => {
|
|
246
|
-
|
|
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
|
+
}
|
|
247
256
|
});
|
|
248
257
|
return out;
|
|
249
258
|
}
|
|
@@ -429,7 +438,6 @@ function ensureTrailingNewline(s) {
|
|
|
429
438
|
*/
|
|
430
439
|
function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
|
|
431
440
|
const fs = require('node:fs');
|
|
432
|
-
const os = require('node:os');
|
|
433
441
|
const p = require('node:path');
|
|
434
442
|
const { execFileSync } = require('node:child_process');
|
|
435
443
|
const { gitEnv } = require('./git-env');
|
|
@@ -441,9 +449,10 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
|
|
|
441
449
|
return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
|
|
442
450
|
},
|
|
443
451
|
tmpFile: (contents) => {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 };
|
|
447
456
|
},
|
|
448
457
|
runGitDiff: (origArg, candPath) => {
|
|
449
458
|
let stdout = '', code = 0;
|
|
@@ -505,6 +514,51 @@ function hasMaterializableWork(blocks) {
|
|
|
505
514
|
(blocks.deletes || []).length > 0 || (blocks.malformed || []).length > 0;
|
|
506
515
|
}
|
|
507
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
|
+
|
|
508
562
|
module.exports = {
|
|
509
563
|
extractFileBlocks,
|
|
510
564
|
normalizeRepoPath,
|
|
@@ -515,6 +569,10 @@ module.exports = {
|
|
|
515
569
|
headMaterializeIO,
|
|
516
570
|
applyEdit,
|
|
517
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,
|
|
518
576
|
hasMaterializableWork,
|
|
519
577
|
EDIT_OPEN_RE,
|
|
520
578
|
countOccurrences,
|
package/lib/mcp-tools.js
CHANGED
|
@@ -13,6 +13,7 @@ const { spawn } = require('node:child_process');
|
|
|
13
13
|
const fsSync = require('node:fs');
|
|
14
14
|
const os = require('node:os');
|
|
15
15
|
const path = require('node:path');
|
|
16
|
+
const { privateTempFile } = require('./private-temp');
|
|
16
17
|
|
|
17
18
|
// Resolve the CLI binary. Default `hone` (the installed bin); overridable for tests
|
|
18
19
|
// / non-global installs via HONE_CLI_BIN (e.g. a path to hone-cli.js run with node).
|
|
@@ -24,12 +25,15 @@ function honeBin() {
|
|
|
24
25
|
// default) — but WITHOUT exiting on a missing token (the MCP server must start and
|
|
25
26
|
// report the problem via a preflight / hone_doctor, not die). Editor-agnostic:
|
|
26
27
|
// works from any MCP client that passes HONE_TOKEN/HONE_API in the server env.
|
|
28
|
+
// HC-GF-304-followup-1: reports THAT a token exists, never the token. Nothing here sends one — every
|
|
29
|
+
// tool shells out to the CLI, which reads its own — and `token` was returned to callers that read
|
|
30
|
+
// only `hasToken`, one serialization away from the stderr preflight.
|
|
27
31
|
function readHoneConfig() {
|
|
28
32
|
let rc = {};
|
|
29
33
|
try { rc = JSON.parse(fsSync.readFileSync(path.join(os.homedir(), '.honerc'), 'utf8')) || {}; } catch { rc = {}; }
|
|
30
34
|
const token = process.env.HONE_TOKEN || rc.token || null;
|
|
31
35
|
const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
|
|
32
|
-
return {
|
|
36
|
+
return { apiUrl, hasToken: !!token };
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
// Run a `hone` subcommand, capturing stdout/stderr/exit. Never throws — returns a
|
|
@@ -163,7 +167,7 @@ async function stepInstruction({ runId, cwd } = {}) {
|
|
|
163
167
|
|
|
164
168
|
// hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
|
|
165
169
|
// travels via a temp file (1MB-class payloads must not ride argv); the file
|
|
166
|
-
//
|
|
170
|
+
// and its directory are best-effort deleted after the shell-out.
|
|
167
171
|
async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
|
|
168
172
|
if (!runId) return { isError: true, detail: 'runId is required' };
|
|
169
173
|
if (!stepKey) return { isError: true, detail: 'stepKey is required' };
|
|
@@ -184,18 +188,20 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
|
|
|
184
188
|
|
|
185
189
|
const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
|
|
186
190
|
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`);
|
|
194
|
-
fsSync.writeFileSync(tmp, output, 'utf8');
|
|
195
|
-
base.push('--file', tmp);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
191
|
try {
|
|
192
|
+
if (fromTree) {
|
|
193
|
+
base.push('--from-tree');
|
|
194
|
+
// Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
|
|
195
|
+
if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
|
|
196
|
+
} else {
|
|
197
|
+
// Output travels via a temp file — 1MB-class payloads must not ride argv. HC-GF-304-followup-1: in a
|
|
198
|
+
// directory only this call created (mode 0700). The old pid-named file could not collide, but its
|
|
199
|
+
// name was predictable in a shared /tmp. Created inside the `try` so a failed write removes it too.
|
|
200
|
+
tmp = privateTempFile('hone-editor-output', 'output.txt');
|
|
201
|
+
fsSync.writeFileSync(tmp.file, output, 'utf8');
|
|
202
|
+
base.push('--file', tmp.file);
|
|
203
|
+
}
|
|
204
|
+
|
|
199
205
|
const r = await module.exports._internal.runHone(base, { cwd });
|
|
200
206
|
if (r.error) return { isError: true, detail: r.error };
|
|
201
207
|
const data = parseLastJson(r.stdout);
|
|
@@ -209,7 +215,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
|
|
|
209
215
|
: (r.stderr || r.stdout || 'submission failed').slice(0, 500),
|
|
210
216
|
};
|
|
211
217
|
} finally {
|
|
212
|
-
if (tmp)
|
|
218
|
+
if (tmp) tmp.remove(); // the directory, not just the file; never throws
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
221
|
|
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,52 @@
|
|
|
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
|
+
* HC-CI-081: `remove` is a recursive delete of a computed path in a root every process of this user shares, so
|
|
17
|
+
* it deletes only a directory shaped like the one `mkdtempSync` made for it: a direct child of the temp root,
|
|
18
|
+
* named `<prefix>-` plus mkdtemp's six characters. On 2026-09-26 a local mutation run replaced the mkdtemp with
|
|
19
|
+
* `os.tmpdir()` and left `remove` as it was, so each call was `rm -rf $TMPDIR`. It ran outside the sandbox, in
|
|
20
|
+
* the tmpdir the self-hosted runners share, and deleted three other tests' directories mid-run on runner 2
|
|
21
|
+
* (HC-CI-063's, HC-CI-064's and HC-GF-098's; run 36211682382, attempt 1).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} prefix names the owner in the directory, e.g. 'hone-emit'
|
|
30
|
+
* @param {string} name the file's name inside its private directory, e.g. 'patch.diff'
|
|
31
|
+
* @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws; a directory
|
|
32
|
+
* it did not make is left alone
|
|
33
|
+
*/
|
|
34
|
+
function privateTempFile(prefix, name) {
|
|
35
|
+
const root = path.resolve(os.tmpdir());
|
|
36
|
+
const dir = fs.mkdtempSync(path.join(root, `${prefix}-`));
|
|
37
|
+
return {
|
|
38
|
+
file: path.join(dir, name),
|
|
39
|
+
remove: () => {
|
|
40
|
+
if (!madeBy(root, prefix, dir)) return;
|
|
41
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `dir` is what `mkdtempSync(path.join(root, prefix + '-'))` returns: that prefix, six more characters. */
|
|
47
|
+
function madeBy(root, prefix, dir) {
|
|
48
|
+
const base = path.basename(dir);
|
|
49
|
+
return path.dirname(dir) === root && base.startsWith(`${prefix}-`) && base.length === prefix.length + 7;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { privateTempFile };
|