@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/run-report.js
CHANGED
|
@@ -262,6 +262,42 @@ function buildReport(run, extra = {}) {
|
|
|
262
262
|
// "none" would be a claim the report cannot support for a run that never ran the check.
|
|
263
263
|
for (const s of steps) {
|
|
264
264
|
const d = s.diagnostics || {};
|
|
265
|
+
// HC-GF-089: how close this step came to its limits. worthKnowing, never needsYou — approaching
|
|
266
|
+
// a limit is not an action item, and an advisory that reads like one trains operators to ignore
|
|
267
|
+
// it. Absent when the server projected no headroom fields, which means "nothing to judge".
|
|
268
|
+
//
|
|
269
|
+
// Computed inline rather than imported: this module is deliberately dependency-free (see the
|
|
270
|
+
// header), and the numbers come from stored data like every other line here. cli/lib/headroom.js
|
|
271
|
+
// owns the same thresholds for the status surface, and HC-GF-089's tests pin the two agree.
|
|
272
|
+
const hr = d.headroom;
|
|
273
|
+
if (hr && typeof hr === 'object') {
|
|
274
|
+
const el = Number(hr.elapsed_ms);
|
|
275
|
+
const to = Number(hr.timeout_ms);
|
|
276
|
+
const toks = (Array.isArray(hr.output_tokens) ? hr.output_tokens : [])
|
|
277
|
+
.map(Number).filter((n) => Number.isFinite(n) && n >= 0);
|
|
278
|
+
const busiest = toks.length ? Math.max(...toks) : null;
|
|
279
|
+
const near = [];
|
|
280
|
+
if (Number.isFinite(el) && Number.isFinite(to) && to > 0 && el / to >= 0.75) {
|
|
281
|
+
near.push(`time ${Math.round((el / to) * 100)}% (${Math.round(el / 1000)}s of ${Math.round(to / 1000)}s)`);
|
|
282
|
+
}
|
|
283
|
+
if (busiest !== null && busiest / 128000 >= 0.75) {
|
|
284
|
+
near.push(`tokens ${Math.round((busiest / 128000) * 100)}% (${busiest} of 128000 in one message)`);
|
|
285
|
+
}
|
|
286
|
+
// HC-GF-129: `>= 0.75` has no upper bound, so this said "not over it" about a call that had
|
|
287
|
+
// gone over and died (run 692b0c29: 550,017ms of a 550,000ms ceiling, cut off). The wording
|
|
288
|
+
// and the PLACEMENT are wrong together — worthKnowing is right for a near miss, and wrong
|
|
289
|
+
// for a call that actually hit its limit, which is exactly what an operator needs to see.
|
|
290
|
+
const overTime = Number.isFinite(el) && Number.isFinite(to) && to > 0 && el >= to;
|
|
291
|
+
const overTokens = busiest !== null && busiest >= 128000;
|
|
292
|
+
if (near.length && (overTime || overTokens)) {
|
|
293
|
+
needsYou.push({
|
|
294
|
+
what: `${s.stepKey} headroom: ${near.join(', ')} — the limit was REACHED: that call was cut off`,
|
|
295
|
+
how: `hone show step ${run.runId} ${s.stepKey}`,
|
|
296
|
+
});
|
|
297
|
+
} else if (near.length) {
|
|
298
|
+
worthKnowing.push(`${s.stepKey} headroom: ${near.join(', ')} — close to the limit, not over it`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
265
301
|
if (Array.isArray(d.invented_paths) && d.invented_paths.length) {
|
|
266
302
|
needsYou.push({
|
|
267
303
|
what: `${s.stepKey} named ${d.invented_paths.length} path(s) that do not exist in the repo, and was accepted anyway`,
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-171 — the caller that makes the sandbox fire.
|
|
4
|
+
*
|
|
5
|
+
* HC-GF-168 built the fixture, HC-GF-169 built the declaration and the guard, HC-GF-173 built the
|
|
6
|
+
* trigger, HC-GF-170 built the shapes. Every one shipped, and a run still touches no datastore,
|
|
7
|
+
* because `assertScratchTarget` — the part 169 calls load-bearing — has ZERO production callers.
|
|
8
|
+
*
|
|
9
|
+
* THE HOLE THIS CLOSES. That guard refuses without a live probe of the adopter's database, and
|
|
10
|
+
* rightly: "could not check" is not "checked". But the `sandbox:` block declares no command that
|
|
11
|
+
* could produce one, and 169's own premise is that Hone cannot know the adopter's datastore. The
|
|
12
|
+
* guard demanded proof it gave nobody the means to supply, so it could never pass and the feature
|
|
13
|
+
* could never fire. `verify.probe` is that means — an adopter-declared command, executed
|
|
14
|
+
* adopter-side exactly like `create`/`migrate`/`reset`, whose stdout is the evidence.
|
|
15
|
+
*
|
|
16
|
+
* REFUSE, DO NOT SKIP. Every path here ends in engage, stand-down or refusal, and the three are
|
|
17
|
+
* distinguishable. A sandbox that quietly does nothing when it is misconfigured is the failure this
|
|
18
|
+
* whole chain keeps producing: work that completes, reports success and changes nothing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { isHoneVar } = require('./untrusted-code-env');
|
|
22
|
+
|
|
23
|
+
/** Why a declared sandbox could not be used. A refusal names the fix; a stand-down is not an error. */
|
|
24
|
+
const SANDBOX_REFUSAL = Object.freeze({
|
|
25
|
+
NO_PROBE: 'no_probe',
|
|
26
|
+
NO_RESET: 'no_reset',
|
|
27
|
+
// HC-GF-192: the sandbox was seeded and nothing could read it.
|
|
28
|
+
NO_TEST_COMMAND: 'no_test_command',
|
|
29
|
+
NO_ENV: 'no_env',
|
|
30
|
+
// HC-GF-269: a published name Hone withholds from every command that runs the patch.
|
|
31
|
+
RESERVED_ENV: 'reserved_env',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the adopter's probe command output into the evidence `assertScratchTarget` demands.
|
|
36
|
+
*
|
|
37
|
+
* Accepts `<database>|<rows>` (what `psql -tAc` gives) or a JSON object (what a `jq` pipeline or a
|
|
38
|
+
* custom script gives). Anything else THROWS. It must never return a partial object: a shape like
|
|
39
|
+
* `{rows: 0}` with no database, or `{database}` with no count, reads downstream as an empty scratch
|
|
40
|
+
* database and turns "the probe failed" into "the target is safe to write" — the exact laundering
|
|
41
|
+
* HC-GF-169's guard exists to refuse.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} stdout
|
|
44
|
+
* @returns {{database: string, rows: number}}
|
|
45
|
+
* @throws when the output is absent, malformed, or missing either field
|
|
46
|
+
*/
|
|
47
|
+
function parseProbeOutput(stdout) {
|
|
48
|
+
if (typeof stdout !== 'string' || !stdout.trim()) {
|
|
49
|
+
throw new Error('sandbox probe produced no output — refusing to treat the target as scratch. '
|
|
50
|
+
+ '"Could not check" is not "checked and found nothing".');
|
|
51
|
+
}
|
|
52
|
+
const text = stdout.trim();
|
|
53
|
+
|
|
54
|
+
let database = null;
|
|
55
|
+
let rows = null;
|
|
56
|
+
|
|
57
|
+
if (text.startsWith('{')) {
|
|
58
|
+
let obj;
|
|
59
|
+
try { obj = JSON.parse(text); } catch {
|
|
60
|
+
throw new Error(`sandbox probe emitted unparseable JSON: ${text.slice(0, 120)}`);
|
|
61
|
+
}
|
|
62
|
+
database = typeof obj.database === 'string' ? obj.database.trim() : null;
|
|
63
|
+
rows = typeof obj.rows === 'number' && Number.isFinite(obj.rows) ? obj.rows : null;
|
|
64
|
+
} else {
|
|
65
|
+
// The LAST non-empty line: psql and friends prepend notices, and the reading is the result.
|
|
66
|
+
const line = text.split('\n').map(l => l.trim()).filter(Boolean).pop() || '';
|
|
67
|
+
const parts = line.split('|').map(s => s.trim());
|
|
68
|
+
if (parts.length === 2) {
|
|
69
|
+
database = parts[0] || null;
|
|
70
|
+
// A REAL number. `Number('')` is 0 and `Number('abc')` is NaN; only digits count, because a
|
|
71
|
+
// probe that printed nothing for the count must not read as an empty table.
|
|
72
|
+
rows = /^\d+$/.test(parts[1]) ? Number(parts[1]) : null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!database) {
|
|
77
|
+
throw new Error(`sandbox probe reported no database name (got: ${String(stdout).slice(0, 120)}). `
|
|
78
|
+
+ 'The name must come from the server, e.g. SELECT current_database().');
|
|
79
|
+
}
|
|
80
|
+
if (rows === null) {
|
|
81
|
+
throw new Error(`sandbox probe reported no row count (got: ${String(stdout).slice(0, 120)}). `
|
|
82
|
+
+ 'A name match alone is not proof — a restored copy of production can carry the scratch name.');
|
|
83
|
+
}
|
|
84
|
+
return { database, rows };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Decide whether this run touches the adopter's datastore, and with what.
|
|
89
|
+
*
|
|
90
|
+
* @param {{sandbox: object|null, axes?: string[], capability?: string|null}} input
|
|
91
|
+
* @returns {{engage: boolean, refuse: string|null, reason: string,
|
|
92
|
+
* commands: string[], reset: string|null, probe: string|null, verify: object|null}}
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A mapping of variable name to string value, or null when it is not one — HC-GF-192.
|
|
97
|
+
*
|
|
98
|
+
* `env: DATABASE_URL=postgres://…` is the natural YAML mistake and parses as a STRING. Reading
|
|
99
|
+
* that leniently would engage the sandbox with nothing published and report success, which is the
|
|
100
|
+
* exact failure this story closes; so anything that is not a flat string map is refused by the
|
|
101
|
+
* caller rather than coerced.
|
|
102
|
+
*/
|
|
103
|
+
function plainStringMap(value) {
|
|
104
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const [k, v] of Object.entries(value)) {
|
|
107
|
+
if (typeof k !== 'string' || !k.trim()) return null;
|
|
108
|
+
if (typeof v !== 'string' && typeof v !== 'number') return null;
|
|
109
|
+
out[k] = String(v);
|
|
110
|
+
}
|
|
111
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function planSandboxRun({ sandbox = null, axes = [], capability = null } = {}) {
|
|
115
|
+
// HC-GF-183: every path returns the SAME shape, `teardown` included. It was missing here, so a
|
|
116
|
+
// stand-down or refusal handed back `teardown: undefined` and any caller iterating it would
|
|
117
|
+
// throw — the CLI only escapes that by checking `engage` first. A plan whose fields depend on
|
|
118
|
+
// which branch produced it is a trap for the next consumer.
|
|
119
|
+
// HC-GF-192: `test` and `env` join the shape on EVERY path, for the reason HC-GF-183 gave when
|
|
120
|
+
// `teardown` did not: a plan whose fields depend on which branch produced it is a trap.
|
|
121
|
+
const stand = (reason) => ({ engage: false, refuse: null, reason, commands: [], reset: null, teardown: [], probe: null, verify: null, test: null, env: {}, controlEnv: null });
|
|
122
|
+
const deny = (refuse, reason) => ({ engage: false, refuse, reason, commands: [], reset: null, teardown: [], probe: null, verify: null, test: null, env: {}, controlEnv: null });
|
|
123
|
+
|
|
124
|
+
const stateful = Array.isArray(axes) && axes.includes('stateful');
|
|
125
|
+
if (!stateful) {
|
|
126
|
+
return stand('the story declares no stored state — the datastore is not touched');
|
|
127
|
+
}
|
|
128
|
+
if (!sandbox || typeof sandbox !== 'object') {
|
|
129
|
+
// HC-GF-173's note already says this at classification time. Repeating it here is deliberate:
|
|
130
|
+
// this is the moment the capability would have fired, and a silent no-op here is what taught
|
|
131
|
+
// adopters the feature does not exist.
|
|
132
|
+
return stand('the story needs stored state but no `sandbox:` block is declared in '
|
|
133
|
+
+ '.pipeline-config.yml — declare one (create/migrate/reset plus a verify block) to have the '
|
|
134
|
+
+ 'pipeline seed a scratch datastore instead of testing against whatever the machine holds');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const verify = sandbox.verify && typeof sandbox.verify === 'object' ? sandbox.verify : null;
|
|
138
|
+
const probe = verify && typeof verify.probe === 'string' && verify.probe.trim()
|
|
139
|
+
? verify.probe.trim() : null;
|
|
140
|
+
if (!probe) {
|
|
141
|
+
return deny(SANDBOX_REFUSAL.NO_PROBE,
|
|
142
|
+
'the sandbox declares no `verify.probe` command, so the target can never be PROVEN scratch '
|
|
143
|
+
+ 'and the guard would refuse every run. Add `verify.probe` — a command printing '
|
|
144
|
+
+ '`<database>|<row count>` for the declared table, read from the live connection '
|
|
145
|
+
+ '(e.g. SELECT current_database()), not from the connection string.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const commands = ['create', 'migrate'].map(k => (typeof sandbox[k] === 'string' ? sandbox[k].trim() : ''))
|
|
149
|
+
.filter(Boolean);
|
|
150
|
+
const reset = typeof sandbox.reset === 'string' && sandbox.reset.trim() ? sandbox.reset.trim() : null;
|
|
151
|
+
if (commands.length > 0 && !reset) {
|
|
152
|
+
// Setting up without a teardown leaves seeded rows in the adopter's datastore after the run.
|
|
153
|
+
return deny(SANDBOX_REFUSAL.NO_RESET,
|
|
154
|
+
'the sandbox declares setup commands but no `reset`, so seeded rows would outlive the run. '
|
|
155
|
+
+ 'Declare `reset` — a sandbox that is never cleaned is indistinguishable from a dirty database.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// An EMPTY capabilities list means the sandbox covers everything. Reading it as an empty
|
|
159
|
+
// allow-list would make the feature inert for exactly the adopters who declared it most simply.
|
|
160
|
+
const caps = Array.isArray(sandbox.capabilities) ? sandbox.capabilities.filter(Boolean) : [];
|
|
161
|
+
if (capability && caps.length > 0 && !caps.includes(capability)) {
|
|
162
|
+
return stand(`the sandbox covers ${caps.join(', ')} and this story's capability is `
|
|
163
|
+
+ `"${capability}" — standing down rather than seeding a datastore nobody said covers it`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// HC-GF-183: the teardown is reset THEN create, not reset alone.
|
|
167
|
+
//
|
|
168
|
+
// Found by the first live run: `reset` tore the schema down, the probe reads a table in it, and
|
|
169
|
+
// the NEXT run refused with "could not prove the target is scratch" — self-inflicted, and
|
|
170
|
+
// invisible to every test because each drove one lifecycle in isolation. "Reset" already implies
|
|
171
|
+
// "back to a usable baseline"; it was the pipeline reading it as "destroy" that was wrong.
|
|
172
|
+
//
|
|
173
|
+
// `migrate` is deliberately NOT replayed: restoring the shape the probe needs is `create`, and
|
|
174
|
+
// re-running migrations here would double the slowest part of every teardown.
|
|
175
|
+
const create = typeof sandbox.create === 'string' && sandbox.create.trim() ? sandbox.create.trim() : null;
|
|
176
|
+
const teardown = [reset, create].filter(Boolean);
|
|
177
|
+
|
|
178
|
+
// HC-GF-192: WHO READS THE SANDBOX.
|
|
179
|
+
//
|
|
180
|
+
// Run 4f463e54 seeded `hone_sandbox` correctly and ran `make ci` against
|
|
181
|
+
// `hone_test_$(CHECKOUT_ID)`. The lifecycle was perfect and observed nothing. The row's proposed
|
|
182
|
+
// fix — publish the connection as the adopter's own TEST_DATABASE_URL — was refuted by
|
|
183
|
+
// measurement: `ensure-test-db.js` refuses any name outside `hone_test*`, and the suite
|
|
184
|
+
// TRUNCATEs, which would wipe the seed. The adopter's whole ci command was never the right
|
|
185
|
+
// consumer; a suite that manages its own database fights a seeded one, and mature suites do.
|
|
186
|
+
//
|
|
187
|
+
// So the sandbox declares the command that reads it and the environment that suite expects.
|
|
188
|
+
// Hone publishes one into the other, which is what makes the result PROVABLE instead of merely
|
|
189
|
+
// configured: the connection is one Hone set, not one an adopter promises is wired up.
|
|
190
|
+
const test = typeof sandbox.test === 'string' && sandbox.test.trim() ? sandbox.test.trim() : null;
|
|
191
|
+
const env = plainStringMap(sandbox.env);
|
|
192
|
+
|
|
193
|
+
if (!test) {
|
|
194
|
+
return deny(SANDBOX_REFUSAL.NO_TEST_COMMAND,
|
|
195
|
+
'the sandbox declares setup commands but no `test` command, so it would be created, seeded '
|
|
196
|
+
+ 'and torn down with nothing reading it — which reports a clean verification for stored-state '
|
|
197
|
+
+ 'behaviour that was never exercised. Add `test:` (the command that runs the story\'s '
|
|
198
|
+
+ 'sandbox suite) plus `env:` (the variables that suite reads).');
|
|
199
|
+
}
|
|
200
|
+
if (!env) {
|
|
201
|
+
return deny(SANDBOX_REFUSAL.NO_ENV,
|
|
202
|
+
'the sandbox declares a `test` command but no usable `env` map of variable to value, so that '
|
|
203
|
+
+ 'suite would run against whatever the machine happens to hold rather than the scratch '
|
|
204
|
+
+ 'datastore just seeded — the condition the sandbox exists to remove. Add `env:` as a '
|
|
205
|
+
+ 'mapping, e.g. `env: {DATABASE_URL: postgres://…}`.');
|
|
206
|
+
}
|
|
207
|
+
// HC-GF-269: `HONE_*` is withheld from every command that runs the patch, so a published name in
|
|
208
|
+
// it would be announced and then never arrive — the real run and the control identical, and the
|
|
209
|
+
// verdict blaming the patch or the glob for a variable Hone removed. Refuse it by name instead.
|
|
210
|
+
const reserved = Object.keys(env).filter(isHoneVar);
|
|
211
|
+
if (reserved.length) {
|
|
212
|
+
return deny(SANDBOX_REFUSAL.RESERVED_ENV,
|
|
213
|
+
`the sandbox publishes ${reserved.join(', ')}, but \`HONE_*\` is Hone's own namespace and is `
|
|
214
|
+
+ 'withheld from every command that runs the patch, so the sandbox suite would never see it. '
|
|
215
|
+
+ 'Rename the variable, and read the new name in the suite.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
engage: true,
|
|
220
|
+
refuse: null,
|
|
221
|
+
reason: `seeding the declared sandbox (${verify.database}) before the tests run`,
|
|
222
|
+
// `reset` stays exposed so nothing reading the field HC-GF-171 shipped changes meaning.
|
|
223
|
+
commands, reset, teardown, probe, verify, test, env, controlEnv: controlEnvFor(env),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* What the guard PROVED, in one line — HC-GF-190.
|
|
230
|
+
*
|
|
231
|
+
* `assertScratchTarget` throws on refusal and returns nothing on success, so a passing guard was
|
|
232
|
+
* silent. Run 6be117b3's output showed the seed, the suite and the teardown, and HC-GF-185's
|
|
233
|
+
* acceptance — "a real run's output shows the probe, the guard verdict and the teardown" — stayed
|
|
234
|
+
* open on a run where all three HAD happened. That they ran was an inference from the seed loop
|
|
235
|
+
* being unreachable otherwise, and an inference is not evidence.
|
|
236
|
+
*
|
|
237
|
+
* READS THE PROBE, NOT THE DECLARATION. Echoing `verify.database` would print the same line
|
|
238
|
+
* whether the guard checked anything or not, which is the false-verified this chain exists to
|
|
239
|
+
* refuse (HC-GF-169). The name here is the one the SERVER returned.
|
|
240
|
+
*
|
|
241
|
+
* @param {{database: string, table: string, max_rows: number}} verify what the adopter declared
|
|
242
|
+
* @param {{database: string, rows: number}} probe what the live connection reported
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
function describeProbeResult(verify, probe) {
|
|
246
|
+
const db = probe && probe.database ? String(probe.database) : '(no database reported)';
|
|
247
|
+
const rows = probe && Number.isFinite(probe.rows) ? probe.rows : '?';
|
|
248
|
+
const table = verify && verify.table ? verify.table : 'the verify table';
|
|
249
|
+
const cap = verify && Number.isFinite(Number(verify.max_rows)) ? Number(verify.max_rows) : '?';
|
|
250
|
+
return `guard ok — the live connection reports ${db}, holding ${rows} of at most ${cap} `
|
|
251
|
+
+ `row(s) in ${table}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The fourth outcome: the sandbox was never asked — HC-GF-190.
|
|
256
|
+
*
|
|
257
|
+
* The contract at the top of this file promises engage, stand-down or refusal, "and the three are
|
|
258
|
+
* distinguishable". That holds inside the planner. It does not hold for a RUN: `verify-patch`
|
|
259
|
+
* exits on `no_diff` before `planSandboxRun` is ever called, so a stateful story with a declared
|
|
260
|
+
* sandbox can produce none of the three and say nothing about it. Run 2a9b2af5 did exactly that,
|
|
261
|
+
* and its verdict is indistinguishable from an ordinary empty build.
|
|
262
|
+
*
|
|
263
|
+
* Returns null when there is nothing to report — a plan that was never going to engage has not
|
|
264
|
+
* been skipped, and a line printed on every run is one nobody reads. A REFUSAL returns null too:
|
|
265
|
+
* it has already said more, and naming it again as merely "not reached" reads as a second, vaguer
|
|
266
|
+
* problem.
|
|
267
|
+
*
|
|
268
|
+
* @param {{engage: boolean, refuse: string|null, verify: object|null}} plan
|
|
269
|
+
* @returns {string|null}
|
|
270
|
+
*/
|
|
271
|
+
function describeNotReached(plan) {
|
|
272
|
+
// `engage` alone is the condition. An earlier draft also tested `plan.refuse`, which reads like
|
|
273
|
+
// a second case and is unreachable: a refusal always carries `engage: false`, so the clause
|
|
274
|
+
// could never change the outcome. Mutation found it — removing it left the suite green, which
|
|
275
|
+
// is the definition of a guard that does nothing.
|
|
276
|
+
if (!plan || !plan.engage) return null;
|
|
277
|
+
const db = plan.verify && plan.verify.database ? plan.verify.database : 'the declared sandbox';
|
|
278
|
+
return `sandbox NOT reached — ${db} would have been seeded for this story, and the run ended `
|
|
279
|
+
+ 'before it was asked. Nothing was written to it, and nothing about stored-state behaviour '
|
|
280
|
+
+ 'was verified.';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A value that cannot resolve to anything real — HC-GF-193.
|
|
286
|
+
*
|
|
287
|
+
* Deliberately not a URL, not a host, and not a path: the control must fail for every variable it
|
|
288
|
+
* replaces, and a value that accidentally WORKS proves the opposite of what the control claims.
|
|
289
|
+
*/
|
|
290
|
+
const CONTROL_VALUE = 'hone-sandbox-control-this-must-not-resolve';
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The published environment, broken on purpose — HC-GF-193.
|
|
294
|
+
*
|
|
295
|
+
* Run 6be117b3's first attempt declared `node --test tests/sandbox/*.test.js`, the worktree was
|
|
296
|
+
* built from HEAD, `tests/sandbox/` was not yet committed, and the glob matched nothing. That
|
|
297
|
+
* exits 0 — `tests 0 / pass 0 / fail 0` — so the sandbox was created, seeded, declared verified
|
|
298
|
+
* and torn down having observed nothing at all.
|
|
299
|
+
*
|
|
300
|
+
* The row's first idea was to establish that the command "had files to execute". Hone cannot know
|
|
301
|
+
* that: the command is an arbitrary shell string and `npm run test:sandbox` names no paths. What
|
|
302
|
+
* Hone DOES control is the environment it published, so the check is a negative control — run the
|
|
303
|
+
* suite again with every published value replaced, and require it to FAIL. A suite that still
|
|
304
|
+
* passes is not reading what we published, whether because it ran no tests, because its tests
|
|
305
|
+
* never touch the datastore, or because it is pointed somewhere else.
|
|
306
|
+
*
|
|
307
|
+
* EVERY variable is replaced, not a guessed subset: Hone cannot know which one carries the
|
|
308
|
+
* connection. And each is replaced rather than UNSET, because an absent variable lets a suite fall
|
|
309
|
+
* back to a default that may well work — which would pass the control while observing nothing,
|
|
310
|
+
* reintroducing the defect through the check meant to catch it.
|
|
311
|
+
*
|
|
312
|
+
* @param {object|null} env the published map
|
|
313
|
+
* @returns {object|null} the same keys with unusable values, or null when nothing was published
|
|
314
|
+
*/
|
|
315
|
+
function controlEnvFor(env) {
|
|
316
|
+
if (!env || typeof env !== 'object') return null;
|
|
317
|
+
const keys = Object.keys(env);
|
|
318
|
+
if (keys.length === 0) return null;
|
|
319
|
+
const out = {};
|
|
320
|
+
for (const k of keys) out[k] = CONTROL_VALUE;
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* What the two runs of the sandbox suite mean together — HC-GF-193.
|
|
326
|
+
*
|
|
327
|
+
* Kept separate from the CLI so the decision is testable and mutable, and so the two failure kinds
|
|
328
|
+
* stay distinguishable: a RED suite sends the operator to the code, a suite that observes nothing
|
|
329
|
+
* sends them to the suite or its wiring. Collapsing them sends them to the wrong place.
|
|
330
|
+
*
|
|
331
|
+
* The control is only consulted when the real run PASSED. A red suite is already a failure, and
|
|
332
|
+
* re-running it with a broken environment spends the timeout to learn nothing.
|
|
333
|
+
*
|
|
334
|
+
* @param {{realStatus: number, controlStatus: number}} input
|
|
335
|
+
* @returns {{ok: boolean, kind: string|null, detail: string|null}}
|
|
336
|
+
*/
|
|
337
|
+
function sandboxSuiteVerdict({ realStatus, controlStatus } = {}) {
|
|
338
|
+
if (realStatus !== 0) {
|
|
339
|
+
return { ok: false, kind: 'sandbox_tests_failed', detail: 'the sandbox suite failed' };
|
|
340
|
+
}
|
|
341
|
+
if (controlStatus === 0) {
|
|
342
|
+
return {
|
|
343
|
+
ok: false,
|
|
344
|
+
kind: 'sandbox_tests_not_observing',
|
|
345
|
+
detail: 'the sandbox suite PASSED with every published variable replaced by an unusable '
|
|
346
|
+
+ 'value, so it is not reading the sandbox at all. The usual cause is that it ran ZERO '
|
|
347
|
+
+ 'tests — a glob that matched nothing, a renamed directory, or files that exist locally '
|
|
348
|
+
+ 'but are not in the commit this worktree was built from. It can also mean the tests run '
|
|
349
|
+
+ 'but never touch the datastore. Either way nothing about stored-state behaviour was '
|
|
350
|
+
+ 'verified, whatever the exit status said.',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return { ok: true, kind: null, detail: null };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = { parseProbeOutput, planSandboxRun, SANDBOX_REFUSAL,
|
|
357
|
+
describeProbeResult, describeNotReached, controlEnvFor, sandboxSuiteVerdict, CONTROL_VALUE };
|
package/lib/schedule-cron.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* UTC) almost always want Night Shift mode (--overnight) because a
|
|
7
7
|
* 2 AM run is by definition an overnight batch. Pre-HC-054f the CLI
|
|
8
8
|
* never passed --overnight to queue-stories so the scheduled workflow
|
|
9
|
-
* silently ran without
|
|
9
|
+
* silently ran without the longer timeouts and the 25-story cap.
|
|
10
10
|
*
|
|
11
11
|
* **Timezone — load-bearing**: GitHub Actions cron runs in **UTC** by
|
|
12
12
|
* default. This helper analyzes the cron's hour field AS UTC. It does
|
package/lib/skill-assertions.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Story 1 ships the ENGINE only. Story 2 (CLI command + Code Reviewer
|
|
7
7
|
* agent prompt) is gated on ≥2 real skills authoring `## Assertions`
|
|
8
|
-
* blocks OR OptionsFlow E29-G design landing.
|
|
8
|
+
* blocks OR OptionsFlow E29-G design landing. (Story 2 shipped as SA-002.)
|
|
9
9
|
*
|
|
10
10
|
* Why ship the engine first (LC-001-L1 pattern): the assertion shape
|
|
11
11
|
* needs to soak against real skill-author intent before the runtime
|
|
@@ -20,15 +20,23 @@
|
|
|
20
20
|
* version: 1
|
|
21
21
|
* assertions:
|
|
22
22
|
* - id: ERR-001
|
|
23
|
-
* severity: BLOCKER #
|
|
23
|
+
* severity: BLOCKER # fails `hone step-5b` (HC-GF-227); WARN/INFO do not
|
|
24
24
|
* description: "console.log forbidden in src/"
|
|
25
25
|
* applies_to: "^src/" # regex matched against file paths
|
|
26
26
|
* forbid_added: "console\\.log" # regex matched against added lines
|
|
27
27
|
* ```
|
|
28
28
|
* ```
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* HC-GF-310 adds a second rule kind, still `version: 1` (additive — every v1 block still parses):
|
|
31
|
+
* ```yaml
|
|
32
|
+
* - id: REPLAY-DECLARED
|
|
33
|
+
* severity: BLOCKER
|
|
34
|
+
* applies_to: "^src/parsers/" # a changed file here triggers the rule
|
|
35
|
+
* require_commit_line: "^REPLAY: \\S" # some line of the branch's commit messages must match
|
|
36
|
+
* ```
|
|
37
|
+
* `forbid_added` judges the DIFF; `require_commit_line` judges what the change SAYS about itself,
|
|
38
|
+
* which no diff line can carry. An assertion has exactly one of the two. When the caller cannot
|
|
39
|
+
* supply the commit messages, the rule is reported as a warning, never as a pass or a finding.
|
|
32
40
|
*
|
|
33
41
|
* Pure helper — no fs / child_process imports. Caller injects:
|
|
34
42
|
* - parseAssertions(skillMarkdown) → { version, assertions, warnings }
|
|
@@ -41,7 +49,7 @@ const yaml = require('js-yaml');
|
|
|
41
49
|
const ASSERTION_VERSION = 1;
|
|
42
50
|
const ASSERTIONS_HEADING_RE = /^##\s+Assertions\s*$/m;
|
|
43
51
|
|
|
44
|
-
/** Severity labels.
|
|
52
|
+
/** Severity labels. The engine only carries them; `hone step-5b` fails on BLOCKER (HC-GF-227). */
|
|
45
53
|
const SEVERITY = Object.freeze({
|
|
46
54
|
BLOCKER: 'BLOCKER',
|
|
47
55
|
WARN: 'WARN',
|
|
@@ -113,13 +121,23 @@ function parseAssertions(skillMarkdown) {
|
|
|
113
121
|
const severity = (typeof raw.severity === 'string' && VALID_SEVERITIES.has(raw.severity)) ? raw.severity : SEVERITY.WARN;
|
|
114
122
|
const appliesToSrc = typeof raw.applies_to === 'string' ? raw.applies_to : null;
|
|
115
123
|
const forbidAddedSrc = typeof raw.forbid_added === 'string' ? raw.forbid_added : null;
|
|
124
|
+
const requireCommitLineSrc = typeof raw.require_commit_line === 'string' ? raw.require_commit_line : null;
|
|
116
125
|
|
|
117
126
|
if (!id) {
|
|
118
127
|
out.warnings.push({ kind: 'assertion_missing_id', message: `assertions[${i}] missing required field 'id'` });
|
|
119
128
|
continue;
|
|
120
129
|
}
|
|
121
|
-
if (!forbidAddedSrc) {
|
|
122
|
-
out.warnings.push({ kind: 'assertion_missing_pattern', message: `assertion ${id} missing required field 'forbid_added'` });
|
|
130
|
+
if (!forbidAddedSrc && !requireCommitLineSrc) {
|
|
131
|
+
out.warnings.push({ kind: 'assertion_missing_pattern', message: `assertion ${id} missing required field 'forbid_added' or 'require_commit_line'` });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (forbidAddedSrc && requireCommitLineSrc) {
|
|
135
|
+
out.warnings.push({ kind: 'assertion_two_patterns', message: `assertion ${id} sets both 'forbid_added' and 'require_commit_line'; use one rule per assertion` });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (requireCommitLineSrc && !appliesToSrc) {
|
|
139
|
+
// Without a path scope the rule would demand the line of every change in the repository.
|
|
140
|
+
out.warnings.push({ kind: 'assertion_missing_scope', message: `assertion ${id}: 'require_commit_line' needs 'applies_to'` });
|
|
123
141
|
continue;
|
|
124
142
|
}
|
|
125
143
|
|
|
@@ -132,6 +150,18 @@ function parseAssertions(skillMarkdown) {
|
|
|
132
150
|
}
|
|
133
151
|
}
|
|
134
152
|
|
|
153
|
+
if (requireCommitLineSrc) {
|
|
154
|
+
let requireCommitLine;
|
|
155
|
+
// `m`: the pattern is judged per line, so `^` and `$` mean a line of a commit message.
|
|
156
|
+
try { requireCommitLine = new RegExp(requireCommitLineSrc, 'm'); }
|
|
157
|
+
catch (e) {
|
|
158
|
+
out.warnings.push({ kind: 'invalid_require_commit_line', message: `assertion ${id}: invalid require_commit_line regex — ${e.message}` });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
out.assertions.push({ id, severity, description, appliesTo, appliesToSrc, requireCommitLine, requireCommitLineSrc });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
135
165
|
let forbidAdded;
|
|
136
166
|
try { forbidAdded = new RegExp(forbidAddedSrc); }
|
|
137
167
|
catch (e) {
|
|
@@ -159,6 +189,8 @@ function parseAssertions(skillMarkdown) {
|
|
|
159
189
|
* @param {string} opts.diff - unified diff text (output of `git diff origin/develop...HEAD`)
|
|
160
190
|
* @param {Array<{id: string, content: string}>} opts.skills - skill objects with markdown content
|
|
161
191
|
* @param {string[]} [opts.referencedSkills] - if provided, only these skills are evaluated
|
|
192
|
+
* @param {string} [opts.commitMessages] - the branch's commit messages, for `require_commit_line`.
|
|
193
|
+
* Absent means "could not read them": those rules then warn instead of judging.
|
|
162
194
|
* @returns {{ findings: Finding[], scannedSkills: string[], skipped: Skip[], warnings: Warning[] }}
|
|
163
195
|
*/
|
|
164
196
|
function runAssertions(opts = {}) {
|
|
@@ -194,6 +226,28 @@ function runAssertions(opts = {}) {
|
|
|
194
226
|
scannedSkills.push(skillId);
|
|
195
227
|
|
|
196
228
|
for (const a of parsed.assertions) {
|
|
229
|
+
if (a.requireCommitLine) {
|
|
230
|
+
const triggered = [...filesAddedLines.keys()].filter((f) => a.appliesTo.test(f));
|
|
231
|
+
if (triggered.length === 0) continue;
|
|
232
|
+
if (typeof opts.commitMessages !== 'string') {
|
|
233
|
+
warnings.push({ skillId, kind: 'commit_messages_unavailable',
|
|
234
|
+
message: `assertion ${a.id}: ${triggered.length} file(s) in scope changed but the commit messages were not supplied — not judged` });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (a.requireCommitLine.test(opts.commitMessages)) continue;
|
|
238
|
+
findings.push({
|
|
239
|
+
skillId,
|
|
240
|
+
assertionId: a.id,
|
|
241
|
+
severity: a.severity,
|
|
242
|
+
description: a.description,
|
|
243
|
+
matchedFile: triggered[0],
|
|
244
|
+
matchedLine: null,
|
|
245
|
+
matchedText: `${triggered.length} file(s) in scope changed; no commit message line matches /${a.requireCommitLineSrc}/`,
|
|
246
|
+
requireCommitLineSrc: a.requireCommitLineSrc,
|
|
247
|
+
appliesToSrc: a.appliesToSrc,
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
197
251
|
for (const [filePath, addedLines] of filesAddedLines) {
|
|
198
252
|
if (a.appliesTo && !a.appliesTo.test(filePath)) continue;
|
|
199
253
|
for (const { lineNumber, text } of addedLines) {
|
|
@@ -55,7 +55,7 @@ function renderStep5bArtifact(opts = {}) {
|
|
|
55
55
|
lines.push(`> Branch: \`${branchName}\` vs \`${baseBranch}\``);
|
|
56
56
|
lines.push(`> Generated: ${generatedAt}`);
|
|
57
57
|
lines.push(`> Engine: \`cli/lib/skill-assertions.js\` (SA-001 v1)`);
|
|
58
|
-
lines.push(`> Gating:
|
|
58
|
+
lines.push(`> Gating: a BLOCKER finding fails \`hone step-5b\` (exit 2) and \`hone verify-pr\` (exit 5); WARN and INFO are informational (HC-GF-227).`);
|
|
59
59
|
lines.push('');
|
|
60
60
|
|
|
61
61
|
// ─── Marker 1: ^## Skill audit (canonical heading) ───
|
|
@@ -11,18 +11,23 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Pure-helper-with-injected-IO style:
|
|
13
13
|
* - Extractors take parsed inputs (labels array, body string, etc.)
|
|
14
|
-
* - The git-log query is the one extractor that needs the repo path
|
|
15
|
-
*
|
|
16
|
-
* cli/lib/git-helpers.js.
|
|
14
|
+
* - The git-log query is the one extractor that needs the repo path.
|
|
15
|
+
* It runs git with an argument array, never a shell string — same
|
|
16
|
+
* pattern as cli/lib/git-helpers.js (HC-GF-097-followup-2).
|
|
17
17
|
*
|
|
18
18
|
* Issue: user-request 2026-05-05 SC-002. Builds on SC-001's classifyStory().
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
const {
|
|
21
|
+
const { execFileSync } = require('node:child_process');
|
|
22
22
|
const { gitEnv } = require('./git-env');
|
|
23
23
|
const fs = require('node:fs');
|
|
24
24
|
const path = require('node:path');
|
|
25
25
|
|
|
26
|
+
// HC-GF-097-followup-2: `hone queue-stories` reaches this for up to 50 stories per batch, so
|
|
27
|
+
// one git that never returns must not hang the batch. A timeout throws, which reads as "no
|
|
28
|
+
// overlap" like any other git failure below.
|
|
29
|
+
const GIT_LOG_TIMEOUT_MS = 10_000;
|
|
30
|
+
|
|
26
31
|
// ── Label-to-type canonical mapping ──
|
|
27
32
|
// First-match-wins per label-array order.
|
|
28
33
|
const LABEL_TO_TYPE = Object.freeze({
|
|
@@ -180,13 +185,16 @@ function extractRecentlyModifiedOverlap(repoRoot, touchedFiles, windowDays) {
|
|
|
180
185
|
const abs = path.join(repoRoot, rel);
|
|
181
186
|
if (!fs.existsSync(abs)) continue;
|
|
182
187
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
// HC-GF-097-followup-2: argv, not a shell string. `rel` is a tracked file's name, and
|
|
189
|
+
// double quotes do not stop `$(...)` or backticks: a file named `a$(touch x).js` ran
|
|
190
|
+
// `touch x` on the operator's machine, and in the nightly queue job, beside HONE_TOKEN.
|
|
191
|
+
const out = execFileSync(
|
|
192
|
+
'git', ['log', `--since=${w} days ago`, '--format=%H', '--', rel],
|
|
193
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_LOG_TIMEOUT_MS }
|
|
186
194
|
).trim();
|
|
187
195
|
if (out) return true;
|
|
188
196
|
} catch {
|
|
189
|
-
// Git unavailable / not a repo — silently continue
|
|
197
|
+
// Git unavailable / not a repo / timed out — silently continue
|
|
190
198
|
}
|
|
191
199
|
}
|
|
192
200
|
return false;
|