@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.
Files changed (46) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1341 -795
  3. package/lib/agent-eval-pacing.js +102 -0
  4. package/lib/agent-eval-probes-boundary.js +0 -0
  5. package/lib/agent-eval-probes-stateful.js +72 -0
  6. package/lib/agent-eval-score.js +70 -0
  7. package/lib/agent-executor.js +26 -4
  8. package/lib/asset-fetch-list.js +72 -0
  9. package/lib/auto-verify.js +67 -3
  10. package/lib/batch-follow.js +333 -0
  11. package/lib/block-grammar.js +126 -0
  12. package/lib/bundle-context.js +43 -4
  13. package/lib/bundle-paths.js +698 -1
  14. package/lib/bundle-source-dirs.js +79 -0
  15. package/lib/doctor-bind-default.js +192 -18
  16. package/lib/doctor-local-run.js +139 -1
  17. package/lib/emit-pr.js +39 -6
  18. package/lib/git-helpers.js +19 -8
  19. package/lib/hard-pause-remedy.js +101 -0
  20. package/lib/include-paths.js +155 -0
  21. package/lib/learnings-parse.js +97 -7
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/mcp-tools.js +20 -14
  24. package/lib/pipeline-config.js +182 -0
  25. package/lib/private-temp.js +52 -0
  26. package/lib/promote-payload.js +77 -0
  27. package/lib/refresh-knowledge.js +15 -6
  28. package/lib/repo-tree.js +22 -3
  29. package/lib/run-context.js +698 -0
  30. package/lib/run-target.js +66 -1
  31. package/lib/sandbox-lifecycle.js +357 -0
  32. package/lib/schedule-cron.js +1 -1
  33. package/lib/skill-assertions.js +61 -7
  34. package/lib/skill-audit-render.js +1 -1
  35. package/lib/story-classifier-extract.js +16 -8
  36. package/lib/story-classifier.js +64 -0
  37. package/lib/story-compose.js +69 -0
  38. package/lib/story-line.js +40 -0
  39. package/lib/story-provenance.js +267 -0
  40. package/lib/timeout-option.js +42 -0
  41. package/lib/untrusted-code-env.js +51 -0
  42. package/lib/verdict-env.js +31 -0
  43. package/lib/verify-patch.js +74 -4
  44. package/lib/verify-pr.js +2 -1
  45. package/lib/worktree-provision.js +62 -2
  46. package/package.json +1 -1
package/lib/run-target.js CHANGED
@@ -94,4 +94,69 @@ async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
94
94
  };
95
95
  }
96
96
 
97
- module.exports = { resolveRunTarget, isRunId, LOOKUP_LIMIT };
97
+ /**
98
+ * HC-GF-270-followup-1 — what a caller-supplied argument must look like before any verb builds a URL
99
+ * from it. HC-GF-270 checked the step key inline in two verbs; five more verbs took a run id, a step key
100
+ * or an attempt that nothing checked. Encoding alone is not enough: `encodeURIComponent('..') === '..'`,
101
+ * and axios resolves the request path with `new URL()`, which collapses dot segments, so
102
+ * `/orchestrate/../verdict` goes out as `/verdict`.
103
+ */
104
+ const isStepKey = (s) => typeof s === 'string' && /^step_[0-9a-z_]+$/i.test(s);
105
+ const isAttempt = (s) => (typeof s === 'string' || typeof s === 'number') && /^[1-9]\d{0,8}$/.test(String(s));
106
+ const isBatchId = isRunId;
107
+
108
+ const KINDS = {
109
+ runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
110
+ batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
111
+ stepKey: { ok: isStepKey, why: 'must name a step, like step_4', hint: '' },
112
+ attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
113
+ };
114
+
115
+ /**
116
+ * The first failed check's message, or null when every check passes.
117
+ * @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt', value: any, optional?: boolean }>} checks
118
+ * `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
119
+ * is undefined (a flag that was not given); an empty string is always checked.
120
+ */
121
+ function runArgsError(checks) {
122
+ for (const { label, kind, value, optional } of checks) {
123
+ if (optional && value === undefined) continue;
124
+ const k = KINDS[kind];
125
+ if (!k) throw new TypeError(`runArgsError: unknown kind ${JSON.stringify(kind)}`);
126
+ // JSON.stringify: a crafted value's control characters are escaped, never printed raw to the terminal.
127
+ if (!k.ok(value)) return `${label} ${k.why} (got ${JSON.stringify(value === undefined ? null : value)})${k.hint}`;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * The one way a CLI verb builds an `/orchestrate/…` path. Each segment is encoded exactly once, and the
134
+ * segments encoding cannot make safe (empty, `.`, `..`, null, undefined) throw: every caller-supplied value
135
+ * has passed `runArgsError` first, so a throw here is a bug, not an input error. Query values that are
136
+ * null, undefined, false or '' are dropped; the rest go through URLSearchParams.
137
+ *
138
+ * Callers pass raw values. A pre-encoded segment would be encoded twice (`%` becomes `%25`), and the
139
+ * HC-GF-270-followup-1 scan fails on one.
140
+ */
141
+ function orchestratePath(segments, query) {
142
+ if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
143
+ const parts = segments.map((v, i) => {
144
+ if (v === null || v === undefined) throw new TypeError(`orchestratePath: segment ${i} is ${v}`);
145
+ const s = String(v);
146
+ if (s === '' || s === '.' || s === '..') throw new TypeError(`orchestratePath: segment ${i} is ${JSON.stringify(s)}`);
147
+ return encodeURIComponent(s);
148
+ });
149
+ const base = ['', 'orchestrate', ...parts].join('/');
150
+ const qs = new URLSearchParams();
151
+ for (const [k, v] of Object.entries(query || {})) {
152
+ if (v === null || v === undefined || v === false || v === '') continue;
153
+ qs.append(k, String(v));
154
+ }
155
+ const q = qs.toString();
156
+ return q ? `${base}?${q}` : base;
157
+ }
158
+
159
+ module.exports = {
160
+ resolveRunTarget, isRunId, LOOKUP_LIMIT,
161
+ isStepKey, isAttempt, isBatchId, runArgsError, orchestratePath,
162
+ };
@@ -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 };
@@ -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 auto-approve / longer timeouts / 25-story cap.
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
@@ -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 # advisory; no escalation logic in v1
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
- * E29-G may later define a richer DSL; v1 stays parseable so existing
31
- * skill assertions keep working until adopters explicitly migrate.
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. v1 carries them through findings; no escalation logic. */
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: **informational only** — exit-code-0 by contract; severity-to-gate mapping deferred to OptionsFlow E29-G.`);
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
- * and shells out to git via execSync — same pattern as
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 { execSync } = require('node:child_process');
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
- const out = execSync(
184
- `git log --since="${w} days ago" --format=%H -- ${JSON.stringify(rel)}`,
185
- { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
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;