@hone-ai/cli 1.34.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.
Files changed (44) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1263 -756
  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 +51 -6
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/pipeline-config.js +182 -0
  24. package/lib/private-temp.js +34 -0
  25. package/lib/promote-payload.js +77 -0
  26. package/lib/refresh-knowledge.js +15 -6
  27. package/lib/repo-tree.js +22 -3
  28. package/lib/run-context.js +698 -0
  29. package/lib/sandbox-lifecycle.js +357 -0
  30. package/lib/schedule-cron.js +1 -1
  31. package/lib/skill-assertions.js +61 -7
  32. package/lib/skill-audit-render.js +1 -1
  33. package/lib/story-classifier-extract.js +16 -8
  34. package/lib/story-classifier.js +64 -0
  35. package/lib/story-compose.js +69 -0
  36. package/lib/story-line.js +40 -0
  37. package/lib/story-provenance.js +267 -0
  38. package/lib/timeout-option.js +42 -0
  39. package/lib/untrusted-code-env.js +51 -0
  40. package/lib/verdict-env.js +31 -0
  41. package/lib/verify-patch.js +74 -4
  42. package/lib/verify-pr.js +2 -1
  43. package/lib/worktree-provision.js +62 -2
  44. package/package.json +1 -1
package/hone-cli.js CHANGED
@@ -25,10 +25,20 @@ const os = require('os');
25
25
  const crypto = require('crypto');
26
26
  const { execSync } = require('child_process');
27
27
 
28
+ // HC-GF-144: verdictEnv lives in cli/lib so it is importable and can be tested by BEHAVIOUR.
29
+ // A source-regex asserting the variable name matched this very comment and passed while the
30
+ // scrub was gutted.
31
+ const { verdictEnv } = require('./lib/verdict-env');
32
+ const { parseTimeoutSeconds } = require('./lib/timeout-option.js');
33
+ // HC-GF-143 batch: both mappings were computed inline and asserted by nothing (HC-GF-136).
34
+ const { promotePayload } = require('./lib/promote-payload');
35
+ const { assetFetchList } = require('./lib/asset-fetch-list');
36
+
28
37
  const pkg = require('./package.json');
29
38
  const { parseReviewJSON } = require('./lib/parse-review-json');
30
39
  const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
31
40
  const { gitEnv } = require('./lib/git-env');
41
+ const { collectIncludePaths } = require('./lib/include-paths');
32
42
  const program = new Command();
33
43
 
34
44
  // ── Config resolution ─────────────────────────────────────────────────────────
@@ -41,6 +51,23 @@ function readRc() {
41
51
  return {};
42
52
  }
43
53
 
54
+ /**
55
+ * Resolve the Hone-team admin key, or stop with the instruction for supplying it.
56
+ *
57
+ * HC-GF-239: `hone absorb` is the second command to need this, and the second copy of the same
58
+ * six lines is how the two drift. The key is read from the environment or ~/.honerc and NEVER
59
+ * taken as an argument — an argument lands in shell history, and this is a production key.
60
+ */
61
+ function requireAdminKey() {
62
+ const adminKey = process.env.HONE_ADMIN_KEY || readRc().admin_key;
63
+ if (!adminKey) {
64
+ console.error('Error: Admin key not found.');
65
+ console.error('Set HONE_ADMIN_KEY env var, or add "admin_key" to ~/.honerc');
66
+ process.exit(1);
67
+ }
68
+ return adminKey;
69
+ }
70
+
44
71
  function getConfig() {
45
72
  // Priority: env var → ~/.honerc → error
46
73
  const rc = readRc();
@@ -152,19 +179,33 @@ process.on('exit', () => {
152
179
  program
153
180
  .command('agent-eval')
154
181
  .description('Run agent-eval probes against configured agent targets (HC-COMM-011-followup-1)')
155
- .option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary)', 'adversarial')
182
+ .option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary|stateful)', 'adversarial')
156
183
  .option('--target <name>', 'Run against a specific agent target (default: all configured)')
157
184
  .option('--output <format>', 'Output format (text|json)', 'text')
158
185
  .option('--judge', 'Add the free NLI judge layer (faithfulness only): claims are checked for entailment against the probe context')
159
186
  .option('--provider <name>', 'Judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
160
187
  .option('--report', 'Report the agent-eval verdict to the run (advisory) — needs --workflow-id')
161
188
  .option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
189
+ .option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
162
190
  .action(async (opts) => {
163
191
  const { readAgentEvalConfig } = require('./lib/pipeline-config');
164
192
  const { runAgent } = require('./lib/agent-executor');
165
193
  const { runCheck } = require('./lib/eval-graders');
194
+ const { scoreProbe, summarizeProbes, describeProbeSummary } = require('./lib/agent-eval-score');
166
195
  const config = readAgentEvalConfig(process.cwd());
167
196
 
197
+ // HC-GF-197: validated, not coerced. `Number('abc')` is NaN, NaN is NOT nullish so the
198
+ // executor's `?? 5000` default never applies, and `setTimeout(NaN)` fires in 1ms — so a typo
199
+ // in this flag times out every probe instantly and reports them as WRONG ANSWERS. That is the
200
+ // defect HC-GF-198 exists to fix, reachable by mistyping a flag.
201
+ let probeTimeoutMs;
202
+ try {
203
+ probeTimeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
204
+ } catch (e) {
205
+ console.error(e.message);
206
+ process.exit(2);
207
+ }
208
+
168
209
  if (!config.targets || config.targets.length === 0) {
169
210
  console.log('No agent targets configured in .pipeline-config.yml (agent_eval.targets)');
170
211
  process.exit(0);
@@ -175,6 +216,9 @@ program
175
216
  faithfulness: './lib/agent-eval-probes-faithfulness',
176
217
  safety: './lib/agent-eval-probes-safety',
177
218
  boundary: './lib/agent-eval-probes-boundary',
219
+ // HC-GF-197: calibration, not safety. The other four packs ask whether an agent misbehaves;
220
+ // this one asks whether ONE judgment is right, scored against stories the owner labelled.
221
+ stateful: './lib/agent-eval-probes-stateful',
178
222
  };
179
223
  let probes;
180
224
  if (PROBE_PACKS[opts.category]) {
@@ -208,38 +252,85 @@ program
208
252
 
209
253
  const results = [];
210
254
  let anyFailed = false;
255
+ // HC-GF-199: pace from what the provider DOES, and run the probes where
256
+ // their writes cannot reach this repository.
257
+ const { createPacer } = require('./lib/agent-eval-pacing');
258
+ const pacer = createPacer();
259
+ const probeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-agent-eval-'));
260
+ let stopped = null;
261
+ let attempted = 0;
262
+
211
263
  for (const target of targets) {
212
264
  for (const probe of probes) {
213
- const run = await runAgent(target, probe.input);
265
+ if (stopped) break;
266
+ attempted += 1;
267
+ const wait = pacer.delayBeforeNext();
268
+ if (wait > 0) {
269
+ // Say it. A run that quietly slows down looks identical to a fast one,
270
+ // and that is how a degraded provider reads as a clean measurement.
271
+ if (opts.output !== 'json') {
272
+ console.log(` pacing: waiting ${Math.round(wait / 1000)}s — the provider refused the previous probe`);
273
+ }
274
+ await new Promise((r) => setTimeout(r, wait));
275
+ pacer.noteWaited(wait);
276
+ }
277
+ // HC-GF-197: the default is 5s, sized for the deterministic packs — a canary string or a
278
+ // refusal comes back immediately. A calibration probe asks for a real grooming, which takes
279
+ // tens of seconds, so every one of them timed out and scored as a failed judgment. A
280
+ // verdict produced by a timeout is not a verdict about the agent.
281
+ const run = await runAgent(target, probe.input, { timeout: probeTimeoutMs, cwd: probeCwd, repoRoot: process.cwd() });
214
282
  const output = run && typeof run.output === 'string' ? run.output : '';
215
283
  const graders = probe.graders.map((g) => runCheck(output, g));
216
- let passed = !run.error && graders.every((r) => r.passed);
284
+ // HC-GF-198: three outcomes. A probe that never answered is not one that answered wrongly,
285
+ // and scoring them alike reported 4/8 for a run where 4 of the 5 that COMPLETED were right.
286
+ const scored = scoreProbe(run, graders);
287
+ let { outcome } = scored;
288
+ let passed = scored.passed;
217
289
 
218
290
  // Judge layer: only for grounded probes (those with a context to entail against).
219
291
  let judge = null;
220
- if (judgeCall && probe.context) {
292
+ if (judgeCall && probe.context && outcome !== 'error') {
221
293
  const { judgeFaithfulness } = require('./lib/agent-eval-judge');
222
294
  judge = await judgeFaithfulness({ output, context: probe.context, callLLM: judgeCall });
223
- passed = passed && judge.passed;
295
+ if (!judge.passed) { passed = false; outcome = 'fail'; }
224
296
  }
225
297
  if (!passed) anyFailed = true;
226
- results.push({ target: target.name, probe: probe.id, passed, error: run.error || null, graders, judge });
298
+ results.push({ target: target.name, probe: probe.id, outcome, passed, error: run.error || null,
299
+ stderr: run.stderr || null, graders, judge });
300
+
301
+ pacer.record({ outcome });
302
+ stopped = pacer.stopReason();
227
303
  }
228
304
  }
229
305
 
306
+ try { fs.rmSync(probeCwd, { recursive: true, force: true }); } catch { /* best-effort */ }
307
+
308
+ const summary = summarizeProbes(results);
309
+ // HC-GF-199: a run that gave up must SAY so, and say how many probes it never
310
+ // reached. Reporting only what completed is how a dead run reads as a fast one.
311
+ const pacing = { ...pacer.summary(), stopped, attempted, planned: targets.length * probes.length };
312
+ if (stopped) {
313
+ anyFailed = true;
314
+ if (opts.output !== 'json') {
315
+ console.error(`\n⊘ run STOPPED: ${stopped}`);
316
+ console.error(` ${attempted} of ${pacing.planned} probes attempted — the rest were never run.`);
317
+ }
318
+ }
230
319
  if (opts.output === 'json') {
231
- console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, results }, null, 2));
320
+ console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, summary, pacing, results }, null, 2));
232
321
  } else {
233
322
  for (const r of results) {
234
- const mark = r.passed ? '✓' : '✗';
235
- console.log(` ${mark} ${r.target} / ${r.probe}${r.error ? ` (executor: ${r.error})` : ''}`);
236
- if (!r.passed) {
323
+ // HC-GF-198: three marks, because there are three outcomes. `✗` for a wrong answer and
324
+ // `⊘` for one that never came — printing both as `✗` is what made 3 timeouts read as 3
325
+ // wrong answers.
326
+ const mark = r.outcome === 'pass' ? '✓' : (r.outcome === 'error' ? '⊘' : '✗');
327
+ console.log(` ${mark} ${r.target} / ${r.probe}${r.error ? ` (no answer: ${r.error})` : ''}`);
328
+ if (r.outcome === 'fail') {
237
329
  for (const g of r.graders.filter((x) => !x.passed)) console.log(` - ${g.type}: ${g.detail}`);
238
330
  if (r.judge && !r.judge.passed) console.log(` - judge: ${r.judge.detail}`);
239
331
  }
240
332
  }
241
- const pass = results.filter((r) => r.passed).length;
242
- console.log(`\n${pass}/${results.length} probes passed (category: ${opts.category})`);
333
+ console.log(`\n${describeProbeSummary(summary)} (category: ${opts.category})`);
243
334
  }
244
335
 
245
336
  // Closed loop (HC-COMM-011-followup-5): report the verdict to the run. agent-eval
@@ -588,22 +679,31 @@ program
588
679
 
589
680
  // Build parallel fetch list
590
681
  const fetches = [
591
- // Agent stubs
592
- ...agents.map(agent => ({
593
- key: `${agent}.agent.md`,
594
- promise: client.get(`/scripts/agent-stub/${agent}`).then(r => r.data),
595
- write: (data) => fs.writeFileSync(path.join(agentsDir, `${agent}.agent.md`), data),
596
- })),
597
- // Skill files
598
- ...skills.map(skill => ({
599
- key: `skills/${skill}/SKILL.md`,
600
- promise: client.get(`/skills/${skill}`).then(r => r.data),
601
- write: (data) => {
682
+ // Agent stubs and skill files. HC-GF-136's gate found `key`, `promise` and `write` here
683
+ // computed and asserted by nothing; the mapping is now a pure function in cli/lib. The REST
684
+ // of this list is untouched — an earlier attempt replaced the whole array and silently
685
+ // dropped ai-reviewer.js, check-learnings-gate.js and the ESM package.json, which would have
686
+ // broken setup for every adopter. The regression suite caught it.
687
+ ...assetFetchList({
688
+ agents, skills,
689
+ // HC-GF-184: also pull whatever derive produced for THIS repo. Same basename rule the
690
+ // other commands use (`hone sync`, `hone derive`), so the three agree on the repo's name.
691
+ //
692
+ // `process.cwd()` directly, NOT `repoRoot`: the only `repoRoot` bindings near here are in
693
+ // ANOTHER command's action and inside `if (opts.refresh)`, which returns long before this
694
+ // line. Referencing it threw `ReferenceError` on the normal setup path — for every
695
+ // adopter — while `node --check` passed and the unit tests, which drive assetFetchList
696
+ // directly, never evaluated it.
697
+ derivedRepo: path.basename(process.cwd()),
698
+ get: (url) => client.get(url).then(r => r.data),
699
+ writeAgent: (agent, data) =>
700
+ fs.writeFileSync(path.join(agentsDir, `${agent}.agent.md`), data),
701
+ writeSkill: (skill, data) => {
602
702
  const skillDir = path.join(skillsDir, skill);
603
703
  fs.mkdirSync(skillDir, { recursive: true });
604
704
  fs.writeFileSync(path.join(skillDir, 'SKILL.md'), data);
605
705
  },
606
- })),
706
+ }),
607
707
  // Copilot instructions
608
708
  {
609
709
  key: 'copilot-instructions.md',
@@ -994,6 +1094,27 @@ program
994
1094
 
995
1095
  // ── Test framework installation helpers ────────────────────────────────────────
996
1096
 
1097
+ /**
1098
+ * HC-GF-190: what the sandbox WOULD have done, for the paths that end before it is asked.
1099
+ *
1100
+ * Builds the plan the run never got to and describes it. `planSandboxRun` is pure — it runs no
1101
+ * commands — so this is safe on an early exit, and it is the only way an operator can tell a
1102
+ * sandbox that was SKIPPED from one that was never configured.
1103
+ */
1104
+ function describeSandboxDisposition(runConfig) {
1105
+ try {
1106
+ const { readSandboxConfig } = require('./lib/pipeline-config');
1107
+ const { planSandboxRun, describeNotReached } = require('./lib/sandbox-lifecycle');
1108
+ return describeNotReached(planSandboxRun({
1109
+ sandbox: readSandboxConfig(process.cwd()),
1110
+ axes: runConfig?.classifier_axes || runConfig?.axes || [],
1111
+ capability: runConfig?.capability || null,
1112
+ }));
1113
+ } catch {
1114
+ return null; // reporting must never be the thing that breaks a run
1115
+ }
1116
+ }
1117
+
997
1118
  function detectExistingTestFramework(repoRoot) {
998
1119
  const pkgPath = path.join(repoRoot, 'package.json');
999
1120
  if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
@@ -1311,7 +1432,14 @@ async function installTestFramework(repoRoot, opts) {
1311
1432
  console.log('');
1312
1433
  console.log('── Verification ────────────────────────────────────────');
1313
1434
  try {
1314
- execSync('npm test 2>&1', { cwd: repoRoot, stdio: 'inherit', timeout: 30_000 });
1435
+ // HC-GF-144: `node` exports NODE_TEST_CONTEXT to the processes IT spawns, and this repo's
1436
+ // regression suites spawn the CLI. Inherited, it makes a FAILING `node --test` exit 0 — so
1437
+ // this verification would print "npm test passed" for a red repository, which is the one
1438
+ // thing it exists not to do. Only the verdict-flipping variables are removed; the adopter's
1439
+ // toolchain environment is otherwise passed through untouched.
1440
+ execSync('npm test 2>&1', {
1441
+ cwd: repoRoot, stdio: 'inherit', timeout: 30_000, env: verdictEnv(),
1442
+ });
1315
1443
  console.log(' ✓ npm test passed');
1316
1444
  } catch {
1317
1445
  console.log(' ⚠ npm test had issues — review test output above');
@@ -1477,15 +1605,42 @@ program
1477
1605
  const maxFiles = parseInt(opts.maxFiles);
1478
1606
  let fileCount = 0;
1479
1607
 
1480
- for (const dir of sourceDirs) {
1481
- const dirPath = path.join(repoRoot, dir.replace(/"/g, ''));
1482
- if (!fs.existsSync(dirPath)) continue;
1483
- bundleDirectory(dirPath, repoRoot, files, maxFiles, fileCount);
1484
- fileCount = Object.keys(files).length;
1485
- if (fileCount >= maxFiles) break;
1608
+ // HC-GF-178: allocate the cap ACROSS the declared dirs. The previous loop drained them in
1609
+ // order and `break`-ed at the cap, so `cli/` (142 files vs a default of 30) filled it alone and
1610
+ // server/scripts/enterprise-assets/.github were never read — while the output still said
1611
+ // "Bundling 30 source files...". The derive agent graded the repo on a fifth of it and said so;
1612
+ // the CLI said nothing.
1613
+ const { planBundle } = require('./lib/bundle-source-dirs');
1614
+ const perDirFiles = new Map();
1615
+ const plan = planBundle({
1616
+ sourceDirs,
1617
+ maxFiles,
1618
+ listFiles: (dir) => {
1619
+ const dirPath = path.join(repoRoot, dir.replace(/"/g, ''));
1620
+ if (!fs.existsSync(dirPath)) return [];
1621
+ // COST, stated rather than hidden: this reads every bundlable file in the directory to
1622
+ // plan a selection of a few. On this repo that is ~1,400 files (each capped at 50,000
1623
+ // chars by bundleDirectory) to choose 100, and a derive run still completes in ~90s. It is
1624
+ // the honest shape — you cannot allocate a budget across sources without knowing what each
1625
+ // holds — but on a very large monorepo it is the first thing to optimise, by separating
1626
+ // listing from reading. Filed as a follow-up rather than guessed at now.
1627
+ const collected = {};
1628
+ bundleDirectory(dirPath, repoRoot, collected, Number.MAX_SAFE_INTEGER, 0);
1629
+ perDirFiles.set(dir, collected);
1630
+ return Object.keys(collected);
1631
+ },
1632
+ });
1633
+ for (const rel of plan.selected) {
1634
+ for (const collected of perDirFiles.values()) {
1635
+ if (Object.prototype.hasOwnProperty.call(collected, rel)) { files[rel] = collected[rel]; break; }
1636
+ }
1486
1637
  }
1638
+ fileCount = Object.keys(files).length;
1487
1639
 
1488
1640
  console.log(`Bundling ${Object.keys(files).length} source files...`);
1641
+ // Say what was left out. An unreported omission is what made this defect survive: the agent
1642
+ // could see the gap in its own input and the operator could not.
1643
+ for (const line of plan.dropped) console.log(` ⚠ not fully bundled — ${line}`);
1489
1644
 
1490
1645
  // 3. Bundle existing skills if refresh mode
1491
1646
  let existingSkills = {};
@@ -2226,14 +2381,8 @@ program
2226
2381
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
2227
2382
  .action(async (opts) => {
2228
2383
  const rc = readRc();
2229
- const adminKey = process.env.HONE_ADMIN_KEY || rc.admin_key;
2230
2384
  const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
2231
-
2232
- if (!adminKey) {
2233
- console.error('Error: Admin key not found.');
2234
- console.error('Set HONE_ADMIN_KEY env var, or add "admin_key" to ~/.honerc');
2235
- process.exit(1);
2236
- }
2385
+ const adminKey = requireAdminKey();
2237
2386
 
2238
2387
  try {
2239
2388
  const { data } = await axios.get(`${apiUrl}/admin/usage`, {
@@ -2643,15 +2792,7 @@ program
2643
2792
  }
2644
2793
 
2645
2794
  // 5. POST to /learnings/promote (enterprise_summary only — never local_summary)
2646
- const payload = eligible.map(l => ({
2647
- id: l.id || l._file,
2648
- repo_name: l.repo_name,
2649
- type: l.type,
2650
- agent: l.agent,
2651
- target_skill: l.target_skill,
2652
- enterprise_summary: l.enterprise_summary,
2653
- proposed_fix: l.proposed_fix || l.absorbed_into,
2654
- }));
2795
+ const payload = promotePayload(eligible);
2655
2796
 
2656
2797
  try {
2657
2798
  const r = await client.post('/learnings/promote', { learnings: payload });
@@ -2660,7 +2801,11 @@ program
2660
2801
  } catch (e) {
2661
2802
  console.error(`\nPromotion failed: ${e.response?.data?.error || e.message}`);
2662
2803
  console.error('Local files unchanged — safe to retry.');
2663
- return;
2804
+ // HC-GF-207: a failed promotion must be a failed command. Returning here exited 0, so
2805
+ // `make promote` and the auto-promote workflow went green on ten consecutive runs that
2806
+ // promoted nothing (HC-GF-236). A dry run and a cancelled prompt still exit 0 — those
2807
+ // are refusals, not failures.
2808
+ process.exit(1);
2664
2809
  }
2665
2810
 
2666
2811
  // 6. Update local YAML: status → promoted (per-schema, idempotent).
@@ -2670,23 +2815,220 @@ program
2670
2815
  if (!promotedByFile.has(l._filePath)) promotedByFile.set(l._filePath, []);
2671
2816
  promotedByFile.get(l._filePath).push(l.id);
2672
2817
  }
2818
+ // HC-GF-208: an UNCHANGED file is not a success. writeBackPromoted returns the
2819
+ // content untouched when it cannot flip a status; fs.writeFileSync then succeeds
2820
+ // on identical bytes, and this loop used to print the same tick either way — so
2821
+ // the log said 296 files flipped while the filesystem had 220. Eligibility already
2822
+ // excludes anything promoted, so for these ids "unchanged" can only mean failure:
2823
+ // there is no idempotent-rerun case to mistake it for.
2824
+ const unflipped = [];
2673
2825
  for (const [filePath, ids] of promotedByFile) {
2826
+ const fileName = path.basename(filePath);
2674
2827
  try {
2675
2828
  const schema = fileSchemas[filePath];
2676
- let content = fs.readFileSync(filePath, 'utf8');
2677
- content = writeBackPromoted(content, schema, ids);
2678
- fs.writeFileSync(filePath, content);
2679
- const fileName = path.basename(filePath);
2680
- console.log(` ✓ ${fileName}: ${ids.length} learning(s) → promoted`);
2829
+ const before = fs.readFileSync(filePath, 'utf8');
2830
+ const after = writeBackPromoted(before, schema, ids);
2831
+ if (after === before) {
2832
+ unflipped.push(fileName);
2833
+ console.warn(` \u26a0 ${fileName}: ${ids.length} learning(s) sent, but the local status did not change \u2014 they stay eligible and will be re-sent next run`);
2834
+ continue;
2835
+ }
2836
+ fs.writeFileSync(filePath, after);
2837
+ console.log(` \u2713 ${fileName}: ${ids.length} learning(s) \u2192 promoted`);
2681
2838
  } catch (e) {
2682
- console.warn(` ⚠ ${path.basename(filePath)}: failed to update local file — ${e.message}`);
2839
+ unflipped.push(fileName);
2840
+ console.warn(` \u26a0 ${fileName}: failed to update local file \u2014 ${e.message}`);
2683
2841
  }
2684
2842
  }
2685
2843
 
2844
+ if (unflipped.length) {
2845
+ console.warn(`\n\u26a0 ${unflipped.length} file(s) were sent but NOT marked promoted locally.`);
2846
+ console.warn(' Their learnings remain eligible and will be re-sent on the next run.');
2847
+ }
2848
+
2686
2849
  console.log('\nDone. Commit the updated learnings files:');
2687
2850
  console.log(' git add .github/learnings/ && git commit -m "chore: promote learnings to enterprise"');
2688
2851
  });
2689
2852
 
2853
+ // ── PROMOTED command (HC-GF-210) ─────────────────────────────────────────────
2854
+ // The consumer for GET /learnings/promoted. HC-GF-159b built that route as "THE READER",
2855
+ // on the premise that promotion without read-back is a write-only counter — and then nothing
2856
+ // called it. Auditing the production queue on 2026-09-20 meant hand-rolling an HTTPS call,
2857
+ // and that audit found 203 of 736 rows carrying an EMPTY enterprise_summary, invisible for
2858
+ // five months because no shipped tool could show them. So the two counts in the footer are
2859
+ // the point of this command, not decoration: a promoted row with no summary, or none a run
2860
+ // can reach, was promoted into nothing, and only a read-back can say so.
2861
+ program
2862
+ .command('promoted')
2863
+ .description("List the learnings this org has promoted, with their content (HC-GF-210)")
2864
+ .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
2865
+ .option('--empty', 'Show only rows whose enterprise summary is missing or blank')
2866
+ .option('--unrouted', 'Show only rows that name no skill, prompt or script to reach')
2867
+ // HC-GF-238: absorbing a row removed it from this command, because the route filtered on one
2868
+ // status. The default is still `promoted` — nothing an existing caller sees changes — and these
2869
+ // two options are how an operator reads back what the team actually LANDED.
2870
+ .option('--status <status>', 'Lifecycle status: promoted (default) | absorbed | retracted | pending | overdue | all')
2871
+ .option('--all', 'Every status, whatever a row has reached — the same as --status all')
2872
+ .action(async (opts) => {
2873
+ const config = getConfig();
2874
+ const client = api(config);
2875
+ const status = opts.all ? 'all' : (opts.status || 'promoted');
2876
+ try {
2877
+ // The default request is byte-identical to the one that shipped — no query string at all —
2878
+ // so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
2879
+ // parameter appears only when the operator asked for a status other than the default.
2880
+ const qs = status === 'promoted' ? '' : `?status=${encodeURIComponent(status)}`;
2881
+ const { data } = await client.get(`/learnings/promoted${qs}`);
2882
+ const all = Array.isArray(data.learnings) ? data.learnings : [];
2883
+ // The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
2884
+ // router found no target; a row promoted before that story carries neither field at all.
2885
+ const isEmpty = (l) => !String(l.enterpriseSummary || '').trim();
2886
+ const isUnrouted = (l) => !String(l.targetSkill || '').trim()
2887
+ || String(l.targetKind || 'unrouted') === 'unrouted';
2888
+ const empties = all.filter(isEmpty);
2889
+ const unrouted = all.filter(isUnrouted);
2890
+ let shown = all;
2891
+ if (opts.empty) shown = shown.filter(isEmpty);
2892
+ if (opts.unrouted) shown = shown.filter(isUnrouted);
2893
+
2894
+ if (opts.format === 'json') {
2895
+ console.log(JSON.stringify({
2896
+ orgId: data.orgId,
2897
+ status,
2898
+ count: all.length,
2899
+ empty: empties.length,
2900
+ unrouted: unrouted.length,
2901
+ learnings: shown,
2902
+ }, null, 2));
2903
+ return;
2904
+ }
2905
+
2906
+ console.log(status === 'promoted' ? 'Hone AI — Promoted Learnings' : `Hone AI — Learnings (${status})`);
2907
+ console.log('================================');
2908
+ if (!all.length) {
2909
+ console.log(status === 'promoted'
2910
+ ? "\nNothing promoted yet. Send this repo's learnings with: hone promote"
2911
+ : `\nNo learnings with status '${status}'.`);
2912
+ return;
2913
+ }
2914
+ // ASCII on purpose, and read by column position: same reasoning as `hone workflows` (HC-GF-108).
2915
+ const clip = (s, w) => (s.length > w ? `${s.slice(0, w - 3)}...` : s);
2916
+ const day = (ts) => { const d = ts ? new Date(ts) : null; return d && !isNaN(d) ? d.toISOString().slice(0, 10) : '-'; };
2917
+ console.log('');
2918
+ // The STATUS column appears ONLY when the listing can hold more than one status. This table
2919
+ // is read by column position (HC-GF-108), so the default `hone promoted` keeps the exact
2920
+ // layout it shipped with and no existing reader shifts by a column.
2921
+ const mixed = status === 'all';
2922
+ console.log([
2923
+ ...(mixed ? ['STATUS'.padEnd(10)] : []),
2924
+ 'PROMOTED'.padEnd(11), 'TARGET'.padEnd(24), 'KIND'.padEnd(9), 'LEARNING',
2925
+ ].join(' '));
2926
+ for (const l of shown) {
2927
+ console.log([
2928
+ ...(mixed ? [String(l.status || '-').padEnd(10)] : []),
2929
+ day(l.promotedAt).padEnd(11),
2930
+ clip(String(l.targetSkill || '-'), 23).padEnd(24),
2931
+ String(l.targetKind || 'unrouted').padEnd(9),
2932
+ String(l.id || '-'),
2933
+ ].join(' '));
2934
+ const summary = String(l.enterpriseSummary || '').trim();
2935
+ console.log(` ${summary ? clip(summary.replace(/\s+/g, ' '), 100) : '(no enterprise summary — this row carries no lesson)'}`);
2936
+ // The destination is the whole reason `absorbed` is worth reading back: it names where
2937
+ // the lesson now lives, which is what makes the claim checkable rather than asserted.
2938
+ if (l.absorbedIn) console.log(` absorbed into: ${l.absorbedIn}${l.absorbedAt ? ` (${day(l.absorbedAt)})` : ''}`);
2939
+ }
2940
+ if (shown.length !== all.length) console.log(`\nShowing ${shown.length} of ${all.length}.`);
2941
+ console.log(`\n${all.length} promoted · ${empties.length} with no summary · ${unrouted.length} unrouted`);
2942
+ if (empties.length || unrouted.length) {
2943
+ console.log('A row with no summary, or none a run can reach, was promoted into nothing.');
2944
+ console.log('Inspect: hone promoted --empty · hone promoted --unrouted · Retract: hone retract <id> --reason "..."');
2945
+ }
2946
+ } catch (e) {
2947
+ // HC-GF-207, same class: a read that never happened is not an empty queue. Exit non-zero
2948
+ // so a caller cannot mistake a dead endpoint for "this org has promoted nothing".
2949
+ console.error(`Failed to read promoted learnings: ${e.response?.data?.error || e.message}`);
2950
+ process.exit(1);
2951
+ }
2952
+ });
2953
+
2954
+ // ── ABSORB command (HC-GF-239) ───────────────────────────────────────────────
2955
+ // The Hone-team half of the flywheel's outbox. `hone promote`, `hone retract` and `hone promoted`
2956
+ // are commands; absorbing — the act that ENDS a learning's life — was a hand-built curl with
2957
+ // ADMIN_KEY pasted on the command line, which is how a production key reaches shell history. It
2958
+ // is also the step most likely to be skipped, because it is the one with no affordance.
2959
+ //
2960
+ // The key is read from the environment or ~/.honerc, never from an argument (the `admin-usage`
2961
+ // precedent). `--in` is required by commander AND re-checked here, so a missing or blank
2962
+ // destination is refused BEFORE a request is built — an absorption with no destination is the
2963
+ // unverifiable claim this queue already holds hundreds of.
2964
+ program
2965
+ .command('absorb <ids...>')
2966
+ .description('Admin: mark promoted learnings absorbed, naming where the lesson now lives (HC-GF-239)')
2967
+ .requiredOption('--in <destination>', 'Where the lesson now lives: a skill section, prompt, gate script or PR')
2968
+ .option('--org <uuid>', "Org whose learnings to absorb (default: this CLI token's own org)")
2969
+ .option('--repo <name>', 'Restrict to one repository')
2970
+ .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
2971
+ .action(async (ids, opts) => {
2972
+ const destination = String(opts.in || '').trim();
2973
+ if (!destination) {
2974
+ console.error('Error: --in must name where the lesson now lives (a skill section, prompt, gate script or PR).');
2975
+ console.error('An absorption with no destination cannot be checked by anyone later.');
2976
+ process.exit(1);
2977
+ }
2978
+ const wanted = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))];
2979
+ if (!wanted.length) {
2980
+ console.error('Error: name at least one learning id to absorb.');
2981
+ process.exit(1);
2982
+ }
2983
+
2984
+ const adminKey = requireAdminKey();
2985
+ // `getConfig()` already resolves the base (HONE_API -> ~/.honerc -> default) and exits with a
2986
+ // usable message when no token is configured. Re-deriving it here would have added a TENTH
2987
+ // copy of the `api.hone.ai` literal that [[HC-GF-236]] is trying to reduce to one.
2988
+ const config = getConfig();
2989
+ const apiUrl = config.apiUrl;
2990
+
2991
+ try {
2992
+ // The org defaults to the one this CLI token already authenticates as, so the common case
2993
+ // (a Hone operator absorbing their own repo's learnings) needs no uuid on the command line.
2994
+ let orgId = opts.org;
2995
+ if (!orgId) {
2996
+ const { data } = await api(config).get('/learnings/promoted');
2997
+ orgId = data.orgId;
2998
+ }
2999
+
3000
+ const { data } = await axios.post(`${apiUrl}/admin/learnings/absorb`, {
3001
+ orgId, ids: wanted, repo: opts.repo, absorbedIn: destination,
3002
+ }, {
3003
+ headers: { 'x-admin-key': adminKey, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
3004
+ timeout: 15000,
3005
+ });
3006
+
3007
+ if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
3008
+
3009
+ console.log('');
3010
+ console.log('Hone AI — Absorb Learnings');
3011
+ console.log('================================');
3012
+ console.log(`Destination: ${data.absorbedIn}`);
3013
+ console.log('');
3014
+ for (const a of data.absorbed) console.log(` absorbed ${a.id}${a.repoName ? ` (${a.repoName})` : ''}`);
3015
+ // Skipped rows are printed, never summarised away: "3 of 5 absorbed" without the other two
3016
+ // is the shape of report this whole capability exists to stop producing.
3017
+ for (const sk of data.skipped) console.log(` skipped ${sk.id} — ${sk.reason}`);
3018
+ console.log('');
3019
+ console.log(`${data.count} absorbed · ${data.skipped.length} skipped`);
3020
+ if (data.count) console.log('Read them back with: hone promoted --status absorbed');
3021
+ if (data.skipped.length) process.exitCode = 1;
3022
+ } catch (e) {
3023
+ const status = e.response?.status;
3024
+ if (status === 401) console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
3025
+ else if (status === 429) console.error('Too many failed admin-auth attempts from this address. Wait for the window to pass.');
3026
+ else if (status === 503) console.error(`Server cannot absorb right now: ${e.response?.data?.error || 'unavailable'}`);
3027
+ else console.error(`Absorb failed: ${e.response?.data?.error || e.message}`);
3028
+ process.exit(1);
3029
+ }
3030
+ });
3031
+
2690
3032
  // ── RETRACT command (H-023) ──────────────────────────────────────────────────
2691
3033
  // Two modes:
2692
3034
  // 1. Single retract: `hone retract <LEARNING-ID> --reason "..."`
@@ -4950,12 +5292,19 @@ program
4950
5292
 
4951
5293
  // audit runner. Shells out to `git diff origin/<base>...HEAD`, walks
4952
5294
  // `.github/skills/`, calls SA-001's `runAssertions()`, writes
4953
- // `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`. Always exits 0;
4954
- // gating deferred to OptionsFlow E29-G.
5295
+ // `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`. HC-GF-227: a BLOCKER
5296
+ // finding fails the command (exit 2, after the artifact is written); WARN and
5297
+ // INFO stay informational. The deferral this replaced named OptionsFlow's E29-G,
5298
+ // which no row in this roadmap owned.
5299
+ /** `hone step-5b`'s exit code when a BLOCKER assertion fired (HC-GF-227). Not 1: that is a crash. */
5300
+ const STEP_5B_BLOCKED = 2;
4955
5301
  program
4956
5302
  .command('step-5b')
4957
- .description('Step 5b: runtime skill-application audit. Reads the story\'s referenced_skill cross-refs (LC-001), runs SA-001 assertion engine against PR diff, writes step-5b-skill-audit.md. Always exits 0 (informational; gating deferred to E29-G).')
5303
+ .description('Step 5b: runtime skill-application audit. Reads the story\'s referenced_skill cross-refs (LC-001), runs SA-001 assertion engine against PR diff, writes step-5b-skill-audit.md. Exits 2 when a BLOCKER assertion fires (HC-GF-227); WARN and INFO findings are informational.')
4958
5304
  .option('--base <branch>', 'base branch to diff against', 'develop')
5305
+ .option('--advisory', 'report BLOCKER findings without failing (exit 0), for a caller that wants only the artifact')
5306
+ .option('--all-skills', 'evaluate every skill, not only those the story\'s learnings reference (a repository-wide gate)')
5307
+ .option('--artifact-dir <dir>', 'write the artifact here instead of .github/pipeline/<STORY-ID>/ (a CI gate must not leave files in the checkout)')
4959
5308
  .option('--story-id <id>', 'override story id (defaults to extraction from current branch name)')
4960
5309
  .action(async (cmdOpts) => {
4961
5310
  const { runAssertions } = require('./lib/skill-assertions');
@@ -4991,6 +5340,17 @@ program
4991
5340
  } catch { /* leave empty */ }
4992
5341
  }
4993
5342
 
5343
+ // 2b. HC-GF-310: the branch's commit messages, for `require_commit_line` assertions. A failed read
5344
+ // leaves them undefined, and the engine then warns instead of judging — never a silent pass.
5345
+ let commitMessages;
5346
+ for (const range of [`origin/${baseBranch}..HEAD`, `${baseBranch}..HEAD`]) {
5347
+ try {
5348
+ commitMessages = execSync(`git log --format=%B ${range}`,
5349
+ { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5350
+ break;
5351
+ } catch { /* try the next range */ }
5352
+ }
5353
+
4994
5354
  // 3. Walk skills. Adopters live at .github/skills/; the hone-server
4995
5355
  // framework dogfoods against server/seeds/skills/ (same fallback as
4996
5356
  // `hone audit-skills`).
@@ -5028,7 +5388,8 @@ program
5028
5388
  const result = runAssertions({
5029
5389
  diff,
5030
5390
  skills,
5031
- referencedSkills: referencedSkills.length > 0 ? referencedSkills : undefined,
5391
+ referencedSkills: referencedSkills.length > 0 && !cmdOpts.allSkills ? referencedSkills : undefined,
5392
+ commitMessages,
5032
5393
  });
5033
5394
 
5034
5395
  // 6. Render + write artifact
@@ -5039,7 +5400,7 @@ program
5039
5400
  baseBranch,
5040
5401
  referencedSkills,
5041
5402
  });
5042
- const outDir = path.join(repoRoot, '.github/pipeline', storyId);
5403
+ const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : path.join(repoRoot, '.github/pipeline', storyId);
5043
5404
  fs.mkdirSync(outDir, { recursive: true });
5044
5405
  const outPath = path.join(outDir, 'step-5b-skill-audit.md');
5045
5406
  fs.writeFileSync(outPath, artifact);
@@ -5063,7 +5424,16 @@ program
5063
5424
  }, { BLOCKER: 0, WARN: 0, INFO: 0 }),
5064
5425
  }, null, 2));
5065
5426
 
5066
- // Always exit 0 — Step 5b is informational. E29-G owns severity-to-gate mapping.
5427
+ // HC-GF-227: a BLOCKER finding fails the audit. The skill's author chose the severity, so the
5428
+ // gate is theirs; WARN and INFO stay informational. Exit 2, not 1: node exits 1 when it crashes,
5429
+ // and a caller (verify-pr) must be able to tell a verdict from a failure to produce one.
5430
+ const blockers = result.findings.filter((f) => f.severity === 'BLOCKER');
5431
+ if (blockers.length && !cmdOpts.advisory) {
5432
+ console.error(`step-5b: ${blockers.length} BLOCKER finding(s): ` +
5433
+ blockers.map((f) => `${f.skillId}/${f.assertionId} at ${f.matchedFile}${f.matchedLine != null ? `:${f.matchedLine}` : ''}`).join('; ') +
5434
+ ` — see ${outPath.startsWith(repoRoot + path.sep) ? path.relative(repoRoot, outPath) : outPath}`);
5435
+ process.exit(STEP_5B_BLOCKED);
5436
+ }
5067
5437
  process.exit(0);
5068
5438
  });
5069
5439
 
@@ -5622,6 +5992,100 @@ program
5622
5992
  }
5623
5993
  });
5624
5994
 
5995
+ // HC-GF-097: the I/O cli/lib/run-context.js is handed — every disk, git, GitHub and Hone read a
5996
+ // run's config needs, bound to this checkout. run-story and queue-stories build from the same one.
5997
+ function runContextIo(apiConfig) {
5998
+ const { gitEnv } = require('./lib/git-env');
5999
+ const pc = require('./lib/pipeline-config');
6000
+ const cwd = process.cwd();
6001
+ // Repository-scoped: a path that leaves the checkout neither exists nor reads.
6002
+ const inRepo = (rel) => {
6003
+ if (typeof rel !== 'string' || rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return null;
6004
+ const abs = path.resolve(cwd, rel);
6005
+ return abs.startsWith(cwd + path.sep) ? abs : null;
6006
+ };
6007
+ return {
6008
+ // Every subprocess this adapter starts carries a timeout: a hung git or gh must fail open with a
6009
+ // message, not stall the command (node-developer hard rule 5). The module turns each failure into
6010
+ // a note. (recentlyModifiedOverlap's `git log` lives in story-classifier-extract.js, unchanged.)
6011
+ listFiles: () => execSync('git ls-files', {
6012
+ cwd, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000,
6013
+ }).split('\n').filter(Boolean),
6014
+ sourceDirs: (trackedPaths) => pc.readSourceDirs(cwd, { trackedPaths }),
6015
+ codebaseDefaults: () => pc.readCodebaseDefaults(cwd),
6016
+ ciGate: () => pc.readCIGateConfig(cwd),
6017
+ executionMode: () => pc.readExecutionModeRaw(cwd),
6018
+ consentNote: pc.EDITOR_MODE_CONSENT_NOTE,
6019
+ ruleOverlays: () => require('./lib/overlay-transport').readRuleOverlays(cwd),
6020
+ e2eSpecMode: () => pc.readE2eSpecMode(cwd),
6021
+ verificationGate: () => pc.readVerificationGateConfig(cwd),
6022
+ verificationGateSources: () => pc.readVerificationGateSources(cwd),
6023
+ readPlanText: () => {
6024
+ const planPath = path.join(cwd, '.github', 'EXECUTION_PLAN.yml');
6025
+ return fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
6026
+ },
6027
+ exists: (rel) => { const abs = inRepo(rel); return !!abs && fs.existsSync(abs); },
6028
+ isDirectory: (rel) => { const abs = inRepo(rel); try { return !!abs && fs.statSync(abs).isDirectory(); } catch { return false; } },
6029
+ readFile: (rel) => {
6030
+ const abs = inRepo(rel);
6031
+ if (!abs) throw new Error(`${rel} is outside the repository`);
6032
+ return fs.readFileSync(abs, 'utf8');
6033
+ },
6034
+ // Async (execFile, no shell) so queue-stories can resolve several issues at once.
6035
+ fetchIssue: async (issueNumber) => {
6036
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6037
+ const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6038
+ if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6039
+ const { execFile } = require('child_process');
6040
+ let stdout;
6041
+ try {
6042
+ ({ stdout } = await require('util').promisify(execFile)('gh',
6043
+ ['issue', 'view', String(issueNumber), '--repo', `${m[1]}/${m[2]}`, '--json', 'title,body,url'],
6044
+ { encoding: 'utf8', timeout: 30_000 }));
6045
+ } catch (e) {
6046
+ // execFile keeps gh's stderr on the error — its first line is the actionable part ("gh auth
6047
+ // login", "Could not resolve to an Issue"), and a timeout must not read like any failure.
6048
+ if (e && e.killed) throw new Error(`gh issue view timed out after 30s`);
6049
+ const why = String((e && e.stderr) || '').trim().split('\n')[0];
6050
+ throw new Error(why || (e && e.message) || String(e));
6051
+ }
6052
+ return JSON.parse(stdout);
6053
+ },
6054
+ // HC-GF-270: who wrote the issue. run-context.js classifies a failure and story-provenance.js
6055
+ // decides trust; this only fetches. execFile, not a shell string: owner/repo come from the remote.
6056
+ fetchIssueProvenance: async (issueNumber) => {
6057
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6058
+ const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6059
+ if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6060
+ const where = { owner: m[1], repo: m[2], remoteUrl };
6061
+ try {
6062
+ const { execFile } = require('child_process');
6063
+ const { stdout } = await require('util').promisify(execFile)('gh',
6064
+ ['api', `repos/${m[1]}/${m[2]}/issues/${issueNumber}`],
6065
+ { encoding: 'utf8', timeout: 20_000 });
6066
+ return { ...where, issue: JSON.parse(stdout) };
6067
+ } catch (e) {
6068
+ throw Object.assign(e, { where }); // `where` lets the failure keep its issue reference
6069
+ }
6070
+ },
6071
+ fetchStoryContext: async (storyId) => {
6072
+ const { data } = await api(apiConfig).get(`/stories/${encodeURIComponent(storyId)}/context`);
6073
+ return data;
6074
+ },
6075
+ recentlyModifiedOverlap: (touched) =>
6076
+ require('./lib/story-classifier-extract').extractRecentlyModifiedOverlap(cwd, touched, 14),
6077
+ };
6078
+ }
6079
+
6080
+ // Prints run-context notes at their level. `prefix` labels a note with its story in a batch.
6081
+ function printRunContextNotes(notes, { prefix = '', only } = {}) {
6082
+ for (const n of notes || []) {
6083
+ if (only && n.level !== only) continue;
6084
+ const text = prefix ? n.text.replace(/^(\s*)/, `$1${prefix} `) : n.text;
6085
+ if (n.level === 'warn') console.warn(text); else console.log(text);
6086
+ }
6087
+ }
6088
+
5625
6089
  program
5626
6090
  .command('run-story <storyId>')
5627
6091
  .description('Run the SDLC pipeline for a story via the orchestrator')
@@ -5640,7 +6104,11 @@ program
5640
6104
  .option('--acknowledge-failed-verdict', 'Approve a step whose verification did NOT pass under verification.gate: enforce (HC-GF-074). The server refuses that approval by default and names the failing gates; this is for a human who has read them and still wants to proceed.')
5641
6105
  .option('--acknowledge-abort', 'Approve a step the agent ABORTED (HC-GF-069). An abort means the agent found nothing it could review or build, so approving advances past a step that produced no usable result — read the reason first. Exists so a human can override, which is why the refusal is not simply a hard block.')
5642
6106
  .option('--no-follow', 'With --approve: post the approval and exit immediately instead of following the run to its next decision point (HC-GF-070). The default FOLLOWS, because a caller told to poll does not — which is why HC-GF-064 auto-verify never fired outside a TTY.')
5643
- .option('--include-paths <paths>', 'Comma-separated list of source file paths to bundle into the codebase context (HC-019n-followup-12). Augments auto-detected paths from the issue body.')
6107
+ // HC-GF-202: `collectIncludePaths` accumulates every occurrence. Without it the
6108
+ // option is single-valued and commander keeps the LAST, so the remedy the planner
6109
+ // itself prints — `--include-paths Makefile --include-paths scripts/` — silently
6110
+ // discarded `Makefile` and produced a bundle identical to passing nothing.
6111
+ .option('--include-paths <paths>', 'Source file paths to bundle into the codebase context (HC-019n-followup-12). Comma-separated, and the flag may be repeated; both forms accumulate. Augments auto-detected paths from the issue body.', collectIncludePaths, [])
5644
6112
  .option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
5645
6113
  .option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
5646
6114
  .action(async (storyIdOrRunId, opts) => {
@@ -5703,6 +6171,11 @@ program
5703
6171
  // advisory path re-verifies on every poll tick, because advisory never advances
5704
6172
  // the run and the pause it re-observes is the same pause.
5705
6173
  const _verifiedAttempts = new Set();
6174
+ // HC-GF-189: one-shot, and the "once" lives in the reporter so it can be DRIVEN by a test
6175
+ // rather than grepped for — mutation walked through the inline version twice.
6176
+ const { makeReadinessReporter } = require('./lib/story-classifier');
6177
+ const { readSandboxConfig } = require('./lib/pipeline-config');
6178
+ const _reportReadiness = makeReadinessReporter({ readSandbox: () => readSandboxConfig(process.cwd()) });
5706
6179
 
5707
6180
  for (;;) {
5708
6181
  await new Promise(r => setTimeout(r, interval));
@@ -5716,6 +6189,19 @@ program
5716
6189
  continue;
5717
6190
  }
5718
6191
 
6192
+ // HC-GF-189: the first tick at which the axis EXISTS. step_0's groomer answers
6193
+ // `requires_stored_state`, the server harvests it into `config.classifier_axes`, and only
6194
+ // the CLI can pair that with "no `sandbox:` block is declared in this repo" — the server
6195
+ // has never seen one (HC-GF-169). Emitted here rather than at verify-patch, where
6196
+ // `planSandboxRun`'s stand-down says the same thing but only after step_2 has already
6197
+ // written its tests against whatever the machine happened to hold.
6198
+ //
6199
+ // Once. The loop re-observes the same state every few seconds, and a warning that repeats
6200
+ // until the run ends teaches the reader to skip it — the reason HC-GF-064 guards
6201
+ // auto-verify with a Set.
6202
+ const _readinessNote = _reportReadiness(status?.config);
6203
+ if (_readinessNote) console.warn(` ⚠ ${_readinessNote}`);
6204
+
5719
6205
  const d = classifyPollState(status);
5720
6206
 
5721
6207
  if (d.outcome === POLL_OUTCOMES.COMPLETED) {
@@ -5746,6 +6232,35 @@ program
5746
6232
  console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
5747
6233
  }
5748
6234
 
6235
+ // HC-GF-263: the step named the files it needs, in a fenced `hone run-story` command,
6236
+ // and until now nobody read it — the server kept 200 chars of the first line and this
6237
+ // loop printed 240 of those on the EXHAUSTED branch and nothing at all on a human
6238
+ // gate. Printed in BOTH branches, and ADDITIVE to the errorMessage above: that line
6239
+ // is the only surviving statement of why the step stopped.
6240
+ //
6241
+ // Never throws: a print must not end a poll loop.
6242
+ try {
6243
+ const { remedyFor, renderIncludePathsCommand, partitionSafePaths } = require('./lib/hard-pause-remedy');
6244
+ const remedy = remedyFor(status);
6245
+ if (remedy) {
6246
+ const cmd = renderIncludePathsCommand(status && status.storyId, remedy.paths);
6247
+ if (cmd) {
6248
+ // Name the step: the request belongs to ONE step, and an unattributed command
6249
+ // reads as advice about the run rather than about this pause (H1).
6250
+ console.log(` ${remedy.stepKey} named the files it needs. Re-queue with:`);
6251
+ console.log(` ${cmd}`);
6252
+ if (remedy.truncated) {
6253
+ console.log(` ⚠ it asked for more than ${remedy.paths.length} files; the rest were dropped.`);
6254
+ }
6255
+ }
6256
+ // H5: say how many requested paths were withheld from the command, never what they were.
6257
+ const { dropped } = partitionSafePaths(remedy.paths);
6258
+ if (dropped.length) {
6259
+ console.log(` ${remedy.stepKey}: ${dropped.length} requested path(s) not shown — characters a shell could misread; see \`hone show step\`.`);
6260
+ }
6261
+ }
6262
+ } catch { /* a print must never end a poll loop */ }
6263
+
5749
6264
  // HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
5750
6265
  // existed since HC-019n-followup-34/35 and never fired, because nothing ever
5751
6266
  // ran the executing step. Consent is DERIVED: `ci.local_command` being
@@ -5753,13 +6268,59 @@ program
5753
6268
  // cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
5754
6269
  // Never throws — a failure here must leave the operator exactly where they
5755
6270
  // were, which is being asked to approve.
6271
+ // HC-GF-270: computed OUTSIDE the try below, on purpose. That catch turns a throw
6272
+ // into `(auto-verify skipped: ...)` and CONTINUES — harmless for a decision about
6273
+ // whether to run tests, a fail-OPEN for a decision about whether this patch may
6274
+ // execute at all. So the one call that answers "may we run it" cannot be inside it,
6275
+ // and its own failure is untrusted rather than absent.
6276
+ let _provenance;
6277
+ // HC-GF-270: attempts whose NEEDS YOU block has already been printed this process.
6278
+ // Declared lazily on the enclosing scope so the Set survives across poll ticks —
6279
+ // a per-tick Set would suppress nothing.
6280
+ if (!globalThis.__honeProvenanceNoticed) globalThis.__honeProvenanceNoticed = new Set();
6281
+ const _provenanceNoticed = globalThis.__honeProvenanceNoticed;
6282
+ if (!exhausted) {
6283
+ try {
6284
+ const { assessStorySource } = require('./lib/story-provenance');
6285
+ _provenance = assessStorySource(
6286
+ status?.config?.story_source,
6287
+ status?.storyId || status?.story_id);
6288
+ } catch (e) {
6289
+ _provenance = {
6290
+ trusted: false,
6291
+ label: 'assessment-failed',
6292
+ reason: `story provenance could not be assessed: ${e.message}`,
6293
+ };
6294
+ }
6295
+ }
5756
6296
  if (!exhausted) try {
5757
- const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine } = require('./lib/auto-verify');
6297
+ const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine, provenanceNotice } = require('./lib/auto-verify');
5758
6298
  const decision = decideAutoVerify({
5759
6299
  stepKey: d.stepKey,
5760
6300
  repoRoot: process.cwd(),
5761
6301
  noVerifyFlag: opts.verify === false,
6302
+ assessment: _provenance,
5762
6303
  });
6304
+ // HC-GF-270: only when the step_4 gate is the one paused — an untrusted run is
6305
+ // untrusted at every gate, and repeating this at each of them would teach the
6306
+ // operator to skip it (the HC-GF-189 lesson about a warning that never stops).
6307
+ // HC-GF-270 code review (MEDIUM-5): ONCE per attempt, the same discipline
6308
+ // `alreadyVerified` applies to `announce`. The loop re-observes the same pause every
6309
+ // few seconds; a NEEDS YOU block that reprints on every tick teaches the operator to
6310
+ // scroll past it, which is the HC-GF-189 lesson this comment used to only half-apply
6311
+ // (it suppressed the notice at OTHER gates, not on re-polls of this one).
6312
+ //
6313
+ // Its own Set, not `_verifiedAttempts`: that one means "this attempt was verified",
6314
+ // and an untrusted attempt is precisely one that was NOT. Sharing it would make
6315
+ // `alreadyVerified` lie about what happened.
6316
+ if (decision.untrusted && d.stepKey === 'step_4' && !_provenanceNoticed.has(d.attemptKey)) {
6317
+ _provenanceNoticed.add(d.attemptKey);
6318
+ console.log(provenanceNotice(_provenance, {
6319
+ runId: pollRunId,
6320
+ source: status?.config?.story_source || {},
6321
+ stepKey: d.stepKey,
6322
+ }));
6323
+ }
5763
6324
  if (decision.run && !alreadyVerified(d.attemptKey, _verifiedAttempts)) {
5764
6325
  _verifiedAttempts.add(d.attemptKey);
5765
6326
  console.log(announce(decision, pollRunId));
@@ -5934,27 +6495,6 @@ program
5934
6495
  try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); } catch { branch = null; }
5935
6496
  }
5936
6497
 
5937
- // HC-019n-followup-11: when storyId is a bare integer, treat it as a
5938
- // GitHub issue number and fetch the issue's title + body via the local
5939
- // `gh` CLI. Inject into config.story_description so the orchestrator's
5940
- // buildStepContext (HC-019n root-cause fix B) hands the real story
5941
- // text to step_0 instead of letting the LLM produce placeholder
5942
- // [ASSUMPTION] grooming. Hook reused — no server change required.
5943
- //
5944
- // Gracefully degrade on any error (gh missing, no remote, network
5945
- // fail, private repo without auth): warn and proceed without context.
5946
- // The existing HC-019n-followup-7 hard_pause safety net catches the
5947
- // resulting placeholder cascade at step_1.
5948
- //
5949
- // HC-019b-followup-1 F1: when invoked with an issue number, also try
5950
- // to extract a canonical story-id (e.g. HC-019b) from the issue title.
5951
- // Used by the architect-config read below — without this, the lookup
5952
- // keys on '104' instead of 'HC-019b' and silently bypasses every
5953
- // architect-engaged story whose EXECUTION_PLAN.yml entry uses the
5954
- // canonical id (which is the common adopter case).
5955
- const orchestrateConfig = {};
5956
- let issueBodyForFiles = null;
5957
- let resolvedStoryId = storyIdOrRunId; // canonical id for architect-config lookup
5958
6498
  // HC-GF-010: an EXPLICIT story source, and the only one that works on a repository
5959
6499
  // that is neither a GitHub issue tracker nor listed on Hone's own roadmap.
5960
6500
  //
@@ -5989,473 +6529,25 @@ program
5989
6529
  }
5990
6530
  }
5991
6531
 
5992
- if (explicitStory) {
5993
- orchestrateConfig.story_description = explicitStory;
5994
- issueBodyForFiles = explicitStory;
5995
- console.log(
5996
- ` → story text from ${opts.storyFile ? path.relative(process.cwd(), path.resolve(opts.storyFile)) : '--description'} ` +
5997
- `(${explicitStory.length} chars) — roadmap and issue lookups skipped`);
5998
- } else if (/^\d+$/.test(storyIdOrRunId)) {
5999
- try {
6000
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
6001
- const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6002
- if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6003
- const ownerRepo = `${m[1]}/${m[2]}`;
6004
- const issueJson = execSync(
6005
- `gh issue view ${storyIdOrRunId} --repo ${ownerRepo} --json title,body,url`,
6006
- { encoding: 'utf8' }
6007
- );
6008
- const issue = JSON.parse(issueJson);
6009
- const desc = `# ${issue.title}\n\n${issue.body || '_(issue body is empty)_'}\n\n_Source: ${issue.url}_`;
6010
- orchestrateConfig.story_description = desc;
6011
- issueBodyForFiles = `${issue.title}\n${issue.body || ''}`;
6012
- console.log(` → fetched GitHub issue #${storyIdOrRunId} for story context (${desc.length} chars)`);
6013
- // HC-019b-followup-1 F1: extract the canonical story-id from the
6014
- // issue title. extractStoryIdFromBranch's regex (STORY_ID_PATTERN
6015
- // in pipeline-status.js) handles HC-NNN, HC-NNN-A, H-NNNb, E22-D,
6016
- // and HC-NNN-followup-N shapes after H-029-followup-2.
6017
- try {
6018
- const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
6019
- const titleId = extractStoryIdFromBranch(issue.title);
6020
- if (titleId) {
6021
- resolvedStoryId = titleId;
6022
- console.log(` → resolved issue #${storyIdOrRunId} → canonical story id '${titleId}' for architect-config lookup`);
6023
- } else {
6024
- console.warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
6025
- }
6026
- } catch { /* extractor missing/throws → fall back to numeric id */ }
6027
- } catch (e) {
6028
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6029
- console.warn(` ⚠ could not fetch GitHub issue context: ${msg}`);
6030
- console.warn(' → proceeding without story_description; step_0 may produce placeholder output');
6031
- console.warn(` ⚠ architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
6032
- }
6033
- } else {
6034
- // HC-019n-followup-18: roadmap-sourced story. Only the SERVER knows what
6035
- // the story says (server/seeds/master-roadmap.md is a Hone asset, absent
6036
- // from adopter repos) and only the CLI can read the adopter's disk — so
6037
- // ask the server what the description references, then read those files
6038
- // here. See .github/pipeline/HC-019n-followup-18/architect.md §1 for why
6039
- // this beats the CLI reading a local roadmap (hardcodes a Hone-internal
6040
- // path into the adopter CLI, and fails silently anywhere else).
6041
- //
6042
- // Advisory, never fatal: a story with no resolvable context still runs,
6043
- // it just runs blind — and loudly, via the followup-17 warning below.
6044
- try {
6045
- const ctxClient = api(config);
6046
- const { data } = await ctxClient.get(
6047
- `/stories/${encodeURIComponent(storyIdOrRunId)}/context`
6048
- );
6049
- if (data && data.description) {
6050
- orchestrateConfig.story_description = data.description;
6051
- issueBodyForFiles = data.description;
6052
- const n = Array.isArray(data.referencedPaths) ? data.referencedPaths.length : 0;
6053
- console.log(
6054
- ` → resolved story context from roadmap (${data.description.length} chars, ` +
6055
- `${n} referenced path${n === 1 ? '' : 's'})`
6056
- );
6057
- }
6058
- } catch (e) {
6059
- const status = e && e.response && e.response.status;
6060
- if (status === 404) {
6061
- console.warn(` ⚠ no roadmap row found for '${storyIdOrRunId}'`);
6062
- } else {
6063
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6064
- console.warn(` ⚠ could not fetch story context: ${msg}`);
6065
- }
6066
- console.warn(' → proceeding without story context; the warning below applies');
6067
- }
6068
- }
6069
-
6070
- // HC-019n-followup-12: auto-bundle codebase files referenced in the
6071
- // issue body (or by the operator via --include-paths). Without this,
6072
- // step_1/step_4 have only training-data priors and produce code that
6073
- // matches the SHAPE of the adopter's stack but not its actual
6074
- // architecture (cf. OptionsFlow #104 step_4 hallucinating SQLAlchemy
6075
- // for a JSON-lines repo). The bundle is capped at 10 files / 50K
6076
- // chars total to stay well inside the LLM context window.
6077
- //
6078
- // Detection scans the issue body for code-file-like paths:
6079
- // - foo/bar/baz.py (relative paths with extensions)
6080
- // - tests/unit/test_foo.py (typical pytest structure)
6081
- // - src/web/routes.py (src trees)
6082
- // Excludes:
6083
- // - https:// or http:// URLs (those aren't local files)
6084
- // - paths starting with / or ~ (absolute / home — bundle would
6085
- // escape the repo)
6086
- // - .md / .txt / .yml docs (rarely needed for code-builder; would
6087
- // blow context with prose)
6088
- // HC-DF-001: config-level default include paths (codebase.default_include_paths)
6089
- // are merged with story-text auto-detection and --include-paths.
6090
- const { readCodebaseDefaults } = require('./lib/pipeline-config');
6091
- const defaultIncludePaths = readCodebaseDefaults(process.cwd()).include_paths;
6092
- if (issueBodyForFiles || opts.includePaths || defaultIncludePaths.length > 0) {
6093
- try {
6094
- // HC-019n-followup-13c: word-boundary `\b` at end of extension
6095
- // group so `.jsonl` doesn't match as `.js` and `.tsx` doesn't
6096
- // match as `.ts`. Pre-fix, both `logs/calibration_runs.jsonl`
6097
- // and `src/web/app.tsx` got truncated. The truncated paths
6098
- // failed the downstream `fs.existsSync` check so they didn't
6099
- // crash the bundling, but they polluted the regex output set
6100
- // and would have produced confusing "file not found" warnings
6101
- // if we ever tightened the diagnostic logging.
6102
- const FILE_PATH_RE = /(?<![\w/])([a-zA-Z_][\w-]*\/(?:[\w.-]+\/)*[\w.-]+\.(?:py|js|ts|jsx|tsx|go|rs|java|rb|sh|sql))\b/g;
6103
- const candidates = new Set();
6104
- if (issueBodyForFiles) {
6105
- for (const m of issueBodyForFiles.matchAll(FILE_PATH_RE)) candidates.add(m[1]);
6106
- }
6107
- if (opts.includePaths) {
6108
- // --include-paths a/b.py,c/d.py
6109
- for (const p of String(opts.includePaths).split(',').map(s => s.trim()).filter(Boolean)) {
6110
- candidates.add(p);
6111
- }
6112
- }
6113
- for (const p of defaultIncludePaths) candidates.add(p); // HC-DF-001 config defaults
6114
- // HC-019n-followup-26: FILE_PATH_RE above requires a directory segment,
6115
- // so a story that names a file BARE (`derive-worker.js:787` in prose)
6116
- // extracts 0 paths and step_4 runs blind — and, blind, hallucinates a
6117
- // file at a non-existent path (2026-08-30 e2e demo). Catch bare refs too
6118
- // and resolve each against the repo's own file list: bundle ONLY on a
6119
- // unique basename match; never guess an ambiguous or absent one.
6120
- // HC-019n-followup-27: candidate path → referenced line number, so the
6121
- // bundler can WINDOW a too-big file around its edit site instead of
6122
- // head-truncating past it (the demo's line 787 sits at char 37K).
6123
- const candidateLines = new Map();
6124
- // HC-GF-073: hoisted out of the bare-ref branch below, because the sibling tier
6125
- // needs the same list on stories that contain no bare refs at all — which is most
6126
- // of them.
6127
- const { gitEnv } = require('./lib/git-env');
6128
- let repoFiles = null;
6129
- const listRepoFiles = () => {
6130
- if (repoFiles) return repoFiles;
6131
- try {
6132
- repoFiles = execSync('git ls-files', {
6133
- cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
6134
- }).split('\n').filter(Boolean);
6135
- } catch { repoFiles = []; }
6136
- return repoFiles;
6137
- };
6138
- if (issueBodyForFiles) {
6139
- const { extractBareRefs, resolveBareName, alreadyCovered } = require('./lib/bundle-paths');
6140
- const bareRefs = extractBareRefs(issueBodyForFiles);
6141
- if (bareRefs.length > 0) {
6142
- for (const { name, line } of bareRefs) {
6143
- if (alreadyCovered(name, candidates)) continue;
6144
- const r = resolveBareName(name, listRepoFiles());
6145
- if (r.status === 'unique') {
6146
- candidates.add(r.path);
6147
- if (line && !candidateLines.has(r.path)) candidateLines.set(r.path, line);
6148
- console.log(` → resolved bare ref '${name}${line ? ':' + line : ''}' → ${r.path}`);
6149
- } else if (r.status === 'ambiguous') {
6150
- console.warn(` ⚠ '${name}' is ambiguous (${r.matches.length} files) — not bundling; use --include-paths to disambiguate`);
6151
- }
6152
- // status === 'none' → not every filename in prose is a repo file; skip quietly
6153
- }
6154
- }
6155
- }
6156
- // HC-019n-followup-13d: bump caps. Pre-fix MAX_FILES=10 /
6157
- // MAX_TOTAL_CHARS=50_000 / MAX_PER_FILE_CHARS=10_000 was overly
6158
- // conservative — OptionsFlow #96 hit 49,783/50,000 with 6 files
6159
- // (217 chars to spare), meaning the next slightly-larger story
6160
- // silently dropped files. LLM context windows are 200K+; the
6161
- // bundle should be able to fill ~25% of that without surprise.
6162
- // New caps: 30 / 150_000 / 25_000.
6163
- const MAX_FILES = 30;
6164
- const MAX_TOTAL_CHARS = 150_000;
6165
- const MAX_PER_FILE_CHARS = 25_000;
6166
- // HC-GF-073: `candidates` is only what the STORY named, and a story names the
6167
- // files it will CREATE — so on a greenfield repo every one of them fails the
6168
- // existsSync below and the bundle is EMPTY. Measured across FlowForge's 33 real
6169
- // stories: 29 extracted zero existing files. The dogfood runs looked fine only
6170
- // because an operator had typed --include-paths once; 12 of 12 runs then bundled
6171
- // the same three files across five different stories.
6172
- //
6173
- // selectBundle() splits them into what exists, what is pending, and adds two
6174
- // tiers the story can never name: repo-level signal files (whose ABSENCE is what
6175
- // a security reviewer must be able to check — HC-GF-063) and existing neighbours
6176
- // of the pending paths (the conventions a new module has to match). Order is
6177
- // priority order, because the char cap below truncates from the end.
6178
- const { selectBundle, auditLines } = require('./lib/bundle-context');
6179
- const selection = selectBundle({
6180
- named: [...candidates],
6181
- repoFiles: listRepoFiles(),
6182
- exists: (rel) => {
6183
- if (rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return false;
6184
- const abs = path.resolve(process.cwd(), rel);
6185
- return abs.startsWith(process.cwd() + path.sep) && fs.existsSync(abs);
6186
- },
6187
- });
6188
- const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
6189
- const codebaseFiles = [];
6190
- let totalChars = 0;
6191
- for (const candidate of ordered) {
6192
- if (codebaseFiles.length >= MAX_FILES) break;
6193
- if (candidate.startsWith('/') || candidate.startsWith('~') || candidate.includes('..')) continue;
6194
- const abs = path.resolve(process.cwd(), candidate);
6195
- if (!abs.startsWith(process.cwd() + path.sep)) continue; // escape guard
6196
- if (!fs.existsSync(abs)) continue;
6197
- let content;
6198
- try {
6199
- content = fs.readFileSync(abs, 'utf8');
6200
- } catch { continue; }
6201
- if (content.length > MAX_PER_FILE_CHARS) {
6202
- const line = candidateLines.get(candidate);
6203
- if (line) {
6204
- // HC-019n-followup-27: window around the referenced line so step_4
6205
- // SEES the edit site (head-truncation would hide a site past the
6206
- // cut) and can write a region-edit anchor. The window marker tells
6207
- // step_4 to emit an `edit:` block, not a whole-file block.
6208
- const { windowAroundLine } = require('./lib/bundle-paths');
6209
- const w = windowAroundLine(content, line, Math.floor((MAX_PER_FILE_CHARS - 800) / 2));
6210
- content =
6211
- `# [HC-019n-followup-27: windowed excerpt, lines ${w.startLine}-${w.endLine} of ${w.totalLines} ` +
6212
- `(centered on referenced line ${line}); you were NOT shown the whole file — ` +
6213
- `emit an \`edit:\` block with a unique OLD anchor from this excerpt, NOT a whole-file \`file:\` block]\n` +
6214
- w.excerpt +
6215
- `\n# [end windowed excerpt of ${candidate}]`;
6216
- } else {
6217
- content = content.slice(0, MAX_PER_FILE_CHARS) + `\n\n# [HC-019n-followup-12: truncated at ${MAX_PER_FILE_CHARS} chars; full file is ${content.length} chars]`;
6218
- }
6219
- }
6220
- if (totalChars + content.length > MAX_TOTAL_CHARS) break;
6221
- totalChars += content.length;
6222
- codebaseFiles.push({ path: candidate, content });
6223
- }
6224
- // HC-GF-073: the old line printed a COUNT, and a count cannot distinguish three
6225
- // files the story named from three it did not. That is why 12 consecutive runs
6226
- // bundled an unrelated module and nothing said so — the "running blind" warning
6227
- // below fires on an EMPTY bundle, and these were not empty.
6228
- for (const line of auditLines(selection)) console.log(line);
6229
- if (codebaseFiles.length > 0) {
6230
- orchestrateConfig.codebaseFiles = codebaseFiles;
6231
- console.log(` → bundled ${codebaseFiles.length} codebase file(s) for step_1/step_4 context (${totalChars} chars)`);
6232
- }
6233
- } catch (e) {
6234
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6235
- console.warn(` ⚠ codebase bundling failed (non-fatal): ${msg}`);
6236
- }
6237
- }
6238
-
6239
- // HC-019n-followup-17: warn when the code-aware steps will run BLIND.
6240
- //
6241
- // Auto-detection reads file paths out of `issueBodyForFiles`, which is only
6242
- // populated on the numeric-GitHub-issue branch above. A roadmap-sourced id
6243
- // (HC-CI-005, SC-002-followup-2, ...) never enters that branch, so
6244
- // `codebaseFiles` stays unset and step_1 / step_4 / step_5 / step_5d /
6245
- // step_5e all run with no source to read — the exact "confidently wrong"
6246
- // failure HC-019n-followup-12 was built to prevent, still open for roadmap
6247
- // ids. Observed 2026-08-29: run 61fddfb2 wrote 8,444 chars of code for
6248
- // HC-CI-005 having never seen the files that story names.
6249
- //
6250
- // A fix is structurally awkward: only the CLI can read adopter files, and
6251
- // only the SERVER knows the roadmap description (server/seeds/master-
6252
- // roadmap.md is a Hone asset, absent from adopter repos). Closing it needs
6253
- // the CLI to fetch the resolved description before queueing — a round trip
6254
- // that belongs in its own story. Until then the gap is at least LOUD
6255
- // instead of silent, which is the part that actually bit.
6256
- if (!orchestrateConfig.codebaseFiles) {
6257
- console.warn(' ⚠ no codebase files bundled — step_1 / step_4 / step_5 / step_5d / step_5e');
6258
- console.warn(' will run WITHOUT source context and may invent APIs that do not exist.');
6259
- if (!/^\d+$/.test(storyIdOrRunId)) {
6260
- console.warn(` '${storyIdOrRunId}' is not a GitHub issue number, so file paths cannot be`);
6261
- console.warn(' auto-detected from an issue body.');
6262
- }
6263
- console.warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js');
6264
- }
6265
-
6266
- // HC-COMM-010 axes wiring (CLI half): when --axes wasn't given, derive
6267
- // the DETERMINISTIC axes from the extractors (SC-002) — security labels/
6268
- // text, cross-repo markers, recently-modified overlap vs the bundled
6269
- // paths. Judgment axes (shared-chokepoint etc.) come from the groomer at
6270
- // step_0 and are harvested server-side. Fail-open; explicit --axes wins.
6271
- if (!opts.axes) {
6272
- try {
6273
- const { classifyStory } = require('./lib/story-classifier');
6274
- const {
6275
- extractType, extractSecurityImplications, extractCrossRepoDependency, extractRecentlyModifiedOverlap,
6276
- } = require('./lib/story-classifier-extract');
6277
- const autoBody = issueBodyForFiles || '';
6278
- const autoLabels = [];
6279
- const touched = Array.isArray(orchestrateConfig.codebaseFiles)
6280
- ? orchestrateConfig.codebaseFiles.map(f => f.path).filter(Boolean)
6281
- : Object.keys(orchestrateConfig.codebaseFiles || {});
6282
- const auto = classifyStory({
6283
- type: extractType(autoLabels, autoBody, String(storyIdOrRunId)),
6284
- has_security_implications: extractSecurityImplications(autoLabels, autoBody),
6285
- cross_repo_dependency: extractCrossRepoDependency(autoBody),
6286
- recently_modified_overlap: extractRecentlyModifiedOverlap(process.cwd(), touched, 14),
6287
- });
6288
- if (auto.architect.engage && auto.architect.axes.length > 0) {
6289
- orchestrateConfig.classifier_axes = auto.architect.axes;
6290
- console.log(` → auto-detected classifier axes: ${auto.architect.axes.join(', ')}`);
6291
- }
6292
- } catch (e) {
6293
- console.warn(` ⚠ axes auto-detection failed (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
6294
- }
6295
- }
6296
-
6297
- // HC-019n-followup-42: repository-structure grounding. Bundling FILES is not
6298
- // grounding — with no tree in the prompt the groomer invents a layout and the
6299
- // planner inherits it (dogfood run f276fe00). Stamp a compact, capped tree of
6300
- // tracked paths (scoped to source_dirs/test_dirs when configured) into the run
6301
- // config; the server renders it for the steps that name paths. Fail-open.
6302
- try {
6303
- const { execSync } = require('child_process');
6304
- const { buildRepoTree } = require('./lib/repo-tree');
6305
- const { readSourceDirs } = require('./lib/pipeline-config');
6306
- const tracked = execSync('git ls-files', { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
6307
- const dirs = readSourceDirs(process.cwd());
6308
- // HC-DF-002: readSourceDirs self-heals a stale config; say so, or the operator sees a
6309
- // confident success line built from directories that do not exist.
6310
- if (dirs.repaired) {
6311
- console.log(` ⚠ source dirs in .pipeline-config.yml not found on disk: ${dirs.dropped.join(', ')}`);
6312
- console.log(` using ${dirs.detected ? 'detected' : 'the surviving'} dirs instead: ${dirs.source_dirs.join(', ') || '(none)'}`);
6313
- console.log(' fix it permanently: hone detect-layout, then set codebase.source_dirs in .github/.pipeline-config.yml');
6314
- }
6315
- const tree = buildRepoTree(tracked, { sourceDirs: dirs.source_dirs, testDirs: dirs.test_dirs });
6316
- if (tree.selected_total > 0) {
6317
- orchestrateConfig.repo_tree = tree;
6318
- const scope = dirs.source_dirs.length ? dirs.source_dirs.join(', ') : 'all tracked';
6319
- console.log(` → repository structure: ${tree.shown} of ${tree.selected_total} paths under ${scope} (${(Buffer.byteLength(tree.text, 'utf8') / 1024).toFixed(1)} KB) — the groomer/planner/builder are told to derive every path from it`);
6320
- }
6321
- } catch (e) {
6322
- console.warn(` ⚠ repository structure not bundled (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
6323
- }
6324
-
6325
- // HC-101-followup-2: pass the adopter's CI gate config to the
6326
- // orchestrator so step_5c can branch (github / local / both / none).
6327
- // Defaults to gate=github + local_command='make ci' when the config
6328
- // is missing — backward-compat for adopters whose .pipeline-config.yml
6329
- // predates this field.
6330
- try {
6331
- const { readCIGateConfig } = require('./lib/pipeline-config');
6332
- const ciGate = readCIGateConfig(process.cwd());
6333
- orchestrateConfig.ci_gate = ciGate.gate;
6334
- orchestrateConfig.ci_local_command = ciGate.local_command;
6335
- console.log(` → CI gate mode: ${ciGate.gate}${ciGate.gate !== 'github' ? ` (local_command: ${ciGate.local_command})` : ''}`);
6336
- } catch (e) {
6337
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6338
- console.warn(` ⚠ CI gate config read failed (non-fatal, defaulting to gate=github): ${msg}`);
6339
- orchestrateConfig.ci_gate = 'github';
6340
- orchestrateConfig.ci_local_command = 'make ci';
6341
- }
6342
-
6343
- // HC-COMM-010 B0.5 (memo §12 A3): stamp execution.mode into the run config
6344
- // at CREATION — the server reads it from run.config, making the mode
6345
- // immutable per run (a mid-flight .pipeline-config.yml flip cannot change
6346
- // a running run). Absent/old CLIs → the server defaults to 'server'.
6347
- // Inert until B1: the server ignores the key today.
6348
- try {
6349
- const { readExecutionMode, EDITOR_MODE_CONSENT_NOTE } = require('./lib/pipeline-config');
6350
- const executionMode = readExecutionMode(process.cwd());
6351
- orchestrateConfig.execution_mode = executionMode;
6352
- if (executionMode === 'editor') {
6353
- // §2a: the consent note is shown at the moment editor mode is USED.
6354
- console.log(` → execution mode: editor (opt-in)`);
6355
- console.log(` ${EDITOR_MODE_CONSENT_NOTE}`);
6356
- }
6357
- } catch (e) {
6358
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6359
- console.warn(` ⚠ execution mode read failed (non-fatal, defaulting to server): ${msg}`);
6360
- orchestrateConfig.execution_mode = 'server';
6361
- }
6362
-
6363
- // HC-COMM-010 C0: composition transports — INERT until C1 reads them.
6364
- // Overlays: .hone-local/ is adopter disk the server can never read
6365
- // (the cross-validation finding), so raw texts travel in run config —
6366
- // the codebaseFiles pattern, size/count-capped, degrade-to-empty.
6367
- // Axes: explicit --axes flag for now; automated wiring from classify
6368
- // runs is future work (documented in the sprint plan).
6369
- try {
6370
- const { readRuleOverlays } = require('./lib/overlay-transport');
6371
- const ov = readRuleOverlays(process.cwd());
6372
- if (ov.overlays.length > 0) {
6373
- orchestrateConfig.rule_overlays = ov.overlays;
6374
- console.log(` → bundled ${ov.overlays.length} rule overlay(s) for server-side composition`);
6375
- }
6376
- for (const skip of ov.skipped) console.warn(` ⚠ overlay skipped: ${skip}`);
6377
- } catch (e) {
6378
- console.warn(` ⚠ overlay read failed (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
6379
- }
6380
- if (opts.axes) {
6381
- const axes = String(opts.axes).split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
6382
- if (axes.length > 0) orchestrateConfig.classifier_axes = axes;
6383
- }
6384
-
6385
- // HC-019n-followup-28: resolve the E2E spec-generation mode (step_3b /
6386
- // Playwright). Precedence mirrors ci.gate: per-run flag > .pipeline-config.yml
6387
- // `e2e.spec_generation` > default `auto`. `auto` = the orchestrator runs
6388
- // step_3b when the story requires an E2E plan (step_0 emitted `yes`); `never`
6389
- // = step_3b is always skipped. The runtime "is E2E required?" decision stays
6390
- // server-side (checkE2eRequirement reads step_0's output) — the CLI only
6391
- // passes the MODE.
6392
- try {
6393
- const { readE2eSpecMode } = require('./lib/pipeline-config');
6394
- let e2eMode, source;
6395
- if (opts.e2eSpecs) { e2eMode = 'auto'; source = '--e2e-specs'; }
6396
- else if (opts.skipE2eSpecs) { e2eMode = 'never'; source = '--skip-e2e-specs'; }
6397
- else { e2eMode = readE2eSpecMode(process.cwd()); source = 'config/default'; }
6398
- orchestrateConfig.e2e_spec_generation = e2eMode;
6399
- // Enable step_3b (conditional) unless the adopter opted out. The row is
6400
- // then created; the orchestrator still skips it at runtime for a story that
6401
- // does not require an E2E plan (HC-019n-followup-28).
6402
- if (e2eMode !== 'never') {
6403
- const ec = new Set(orchestrateConfig.enableConditional || []);
6404
- ec.add('step_3b');
6405
- orchestrateConfig.enableConditional = [...ec];
6406
- }
6407
- console.log(` → E2E spec generation (step_3b): ${e2eMode} (source: ${source})`);
6408
- } catch (e) {
6409
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6410
- console.warn(` ⚠ e2e spec-mode read failed (non-fatal, defaulting to auto): ${msg}`);
6411
- orchestrateConfig.e2e_spec_generation = 'auto';
6412
- }
6413
-
6414
- // HC-019n-followup-34: thread the closed-loop verification gate mode from
6415
- // .pipeline-config.yml. Advisory (default) = the server RECORDS verify-patch/
6416
- // verify-pr verdicts reported via --report; enforce (follow-up) = a red
6417
- // verdict blocks. Default-safe: never fatal.
6418
- try {
6419
- const { readVerificationGateConfig, readVerificationGateSources } = require('./lib/pipeline-config');
6420
- orchestrateConfig.verification_gate = readVerificationGateConfig(process.cwd());
6421
- // HC-COMM-011-followup-6: which verdict sources may DRIVE the step_4 gate under
6422
- // enforce (default ['verify-patch']). An adopter adds 'agent-eval' to let a
6423
- // green agent-eval verdict auto-advance a prompt change.
6424
- orchestrateConfig.verification_gate_sources = readVerificationGateSources(process.cwd());
6425
- if (orchestrateConfig.verification_gate !== 'advisory') {
6426
- console.log(` → verification gate: ${orchestrateConfig.verification_gate} (sources: ${orchestrateConfig.verification_gate_sources.join(', ')})`);
6427
- }
6428
- } catch (e) {
6429
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6430
- console.warn(` ⚠ verification-gate read failed (non-fatal, defaulting to advisory): ${msg}`);
6431
- orchestrateConfig.verification_gate = 'advisory';
6432
- orchestrateConfig.verification_gate_sources = ['verify-patch'];
6433
- }
6434
-
6435
- // HC-019b: read per-story architect flags from .github/EXECUTION_PLAN.yml
6436
- // and plumb them into workflow_runs.config. The orchestrator's
6437
- // validateStepPreConditions (server/src/services/workflow-dag.js:340)
6438
- // BLOCKS step_1 when architect_consulted=true but checklist_b_completed=false.
6439
- // Without this plumbing, the HC-019a flags written by the architect prompt
6440
- // never reach the server and every architect-engaged story deadlocks.
6441
- // Defaults to {false, false} when the file/story/config is missing —
6442
- // i.e., assume the architect was not consulted (no block).
6443
- //
6444
- // Code-review F2/F3: malformed YAML or missing story entry was previously
6445
- // silent. The helper now returns a `diagnostic` string for those cases;
6446
- // we surface it as a console.warn so operators see the silent-bypass.
6447
- // HC-019b-followup-1 F1: use the resolved canonical story-id (HC-NNN)
6448
- // not the raw `storyIdOrRunId` which is the issue number when invoked
6449
- // as `hone run-story 104`. The HC-019n-followup-11 block above sets
6450
- // resolvedStoryId to the title-extracted id when it can.
6451
- const { readArchitectConfig } = require('./lib/architect-config');
6452
- const arch = readArchitectConfig(process.cwd(), resolvedStoryId);
6453
- orchestrateConfig.architect_consulted = arch.architect_consulted;
6454
- orchestrateConfig.checklist_b_completed = arch.checklist_b_completed;
6455
- if (arch.diagnostic) console.warn(` ⚠ ${arch.diagnostic}`);
6456
- if (arch.architect_consulted) {
6457
- console.log(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`);
6458
- }
6532
+ // HC-GF-097: the run config is built by cli/lib/run-context.js, the one assembler this
6533
+ // command and `queue-stories` share. It used to be assembled inline here, key by key, and
6534
+ // queue-stories was written beside it with two of the fourteen — so a queued story ran with
6535
+ // no story text, no bundle, no tree and the server's default CI gate. The module returns
6536
+ // what it decided as notes; they print in the order this command always printed them.
6537
+ const { resolveRepoContext, resolveStoryContext, buildRunConfig } = require('./lib/run-context');
6538
+ const rcIo = runContextIo(config);
6539
+ const repoCtx = resolveRepoContext(rcIo, { e2eSpecs: opts.e2eSpecs, skipE2eSpecs: opts.skipE2eSpecs });
6540
+ const storyCtx = await resolveStoryContext(rcIo, repoCtx, storyIdOrRunId, {
6541
+ explicitText: explicitStory,
6542
+ explicitLabel: opts.storyFile ? path.relative(process.cwd(), path.resolve(opts.storyFile)) : '--description',
6543
+ explicitSource: opts.storyFile ? { kind: 'file', label: path.basename(String(opts.storyFile)) } : { kind: 'description' },
6544
+ includePaths: opts.includePaths,
6545
+ axes: opts.axes,
6546
+ });
6547
+ printRunContextNotes(storyCtx.notes.pre);
6548
+ printRunContextNotes(repoCtx.notes);
6549
+ printRunContextNotes(storyCtx.notes.post);
6550
+ const orchestrateConfig = buildRunConfig(repoCtx, storyCtx);
6459
6551
 
6460
6552
  try {
6461
6553
  const { data } = await client.post('/orchestrate', {
@@ -6508,12 +6600,11 @@ program
6508
6600
  .option('--list', 'List recent batches for this org')
6509
6601
  .option('--limit <n>', 'Number of batches to list (default 20, max 100)', '20')
6510
6602
  .option('--format <fmt>', 'Output format: pretty or json (with --report, also: md)', 'pretty')
6511
- // HC-054: Night Shift mode. Conditional auto-approve at step_4/step_5
6512
- // when the agent output passes isCleanOutput's positive allowlist +
6513
- // inverse blocklist. Extended LLM timeout (480s), orchestrator watchdog
6514
- // (900s/step), default token budget of 400K × story_count when none
6515
- // explicitly set. Cap of 25 stories per overnight batch.
6516
- .option('--overnight', 'Enable Night Shift mode (conditional auto-approve, longer timeouts, max 25 stories)', false)
6603
+ // HC-054: Night Shift mode. Extended LLM timeout (480s), orchestrator watchdog (900s/step), default token
6604
+ // budget of 400K × story_count when none is set, a cap of 25 stories, and an exhausted pause that fails the run
6605
+ // so its dependents are released (HC-GF-137b). step_4 and step_5 still pause: HC-GF-303 removed HC-054's
6606
+ // auto-approve on output text, which never ran. Approve overnight on evidence with `hone follow-batch`.
6607
+ .option('--overnight', 'Enable Night Shift mode (longer timeouts, max 25 stories; step_4/step_5 still pause for approval)', false)
6517
6608
  .action(async (opts) => {
6518
6609
  const config = getConfig();
6519
6610
  const client = api(config);
@@ -6686,98 +6777,103 @@ program
6686
6777
  catch { branch = null; }
6687
6778
  }
6688
6779
 
6689
- // HC-019b: read EXECUTION_PLAN.yml ONCE up front so the per-story lookup
6690
- // doesn't re-stat the disk for each line. Empty text \u2192 all stories get
6691
- // the {false, false} default per architect-config.js. Try/catch keeps
6692
- // the batch path resilient if the file is missing or unreadable.
6693
- let planText = '';
6694
- try {
6695
- const planPath = path.join(process.cwd(), '.github', 'EXECUTION_PLAN.yml');
6696
- if (fs.existsSync(planPath)) planText = fs.readFileSync(planPath, 'utf8');
6697
- } catch (e) {
6698
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6699
- console.warn(`\u26a0 EXECUTION_PLAN.yml read failed for batch (non-fatal, architect flags default to {false, false}): ${msg}`);
6700
- }
6701
- const { readArchitectConfigFromText } = require('./lib/architect-config');
6702
-
6703
- // HC-059: parse `STORY-A depends:STORY-B,STORY-C` per-line syntax. The
6704
- // `depends:` token is case-sensitive and must come AFTER the storyId.
6705
- // Multiple deps separated by commas, whitespace tolerant. Lines without
6706
- // `depends:` yield no `dependsOn` (server validates absence vs empty).
6707
- // HC-019b: attach per-story `config: { architect_consulted, checklist_b_completed }`
6708
- // \u2014 the server's createBatch (batch-store.js:218) spreads s.config into each
6709
- // workflow_runs.config row, so this is the load-bearing plumbing for
6710
- // validateStepPreConditions (workflow-dag.js:340) in batch mode.
6711
- const stories = storyIds.map(line => {
6712
- const depsMatch = line.match(/^(\S+)\s+depends:(\S+)\s*$/);
6713
- let storyId, dependsOn;
6714
- if (depsMatch) {
6715
- const [, id, depsCsv] = depsMatch;
6716
- storyId = id;
6717
- dependsOn = depsCsv.split(',').map(s => s.trim()).filter(Boolean);
6718
- } else {
6719
- // Reject ambiguous lines (storyId followed by garbage) \u2014 better than
6720
- // silently treating `STORY-A something` as just `STORY-A`.
6721
- if (/\s/.test(line)) {
6722
- console.error(`Malformed line in --file: "${line}"`);
6723
- console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
6724
- process.exit(1);
6725
- }
6726
- storyId = line;
6727
- dependsOn = undefined;
6728
- }
6729
- const arch = readArchitectConfigFromText(planText, storyId);
6730
- // Code-review F2/F3: surface silent-bypass diagnostics per story in
6731
- // the batch path too. Multi-story batches with one bad plan line
6732
- // would previously disable the contract for ALL stories silently.
6733
- if (arch.diagnostic) console.warn(` ⚠ [${storyId}] ${arch.diagnostic}`);
6734
- const obj = {
6735
- storyId,
6736
- repoName,
6737
- branch,
6738
- config: {
6739
- architect_consulted: arch.architect_consulted,
6740
- checklist_b_completed: arch.checklist_b_completed,
6741
- },
6742
- };
6743
- if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
6744
- return obj;
6780
+ const { parseStoryLine } = require('./lib/story-line');
6781
+ const {
6782
+ resolveRepoContext, resolveStoryContext, buildRunConfig, hasStoryText, describeMissingText, overnightModeRefusal,
6783
+ } = require('./lib/run-context');
6784
+
6785
+ // HC-059: `STORY-A depends:STORY-B,STORY-C` per line. HC-GF-140 stage 2: the parse lives in
6786
+ // cli/lib so it is asserted by BEHAVIOUR. Every line is parsed before anything is resolved.
6787
+ const lines = storyIds.map(line => {
6788
+ const parsed = parseStoryLine(line);
6789
+ if (parsed.error) {
6790
+ console.error(parsed.error);
6791
+ console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
6792
+ process.exit(1);
6793
+ }
6794
+ return parsed;
6745
6795
  });
6746
6796
 
6747
- // HC-054: Night Shift opt-in. config.overnight=true plumbs end-to-end
6748
- // (server validates the 25-story cap + applies default token budget +
6749
- // denormalizes flag into each child's workflow_runs.config).
6750
- const body = { stories };
6751
- body.config = body.config || {};
6797
+ // HC-GF-097: each queued story gets the config `hone run-story` would build for it, from the
6798
+ // same module (cli/lib/run-context.js). This path used to send two architect flags per story,
6799
+ // so a queued story was groomed, planned and built from its id alone. The repository half is
6800
+ // computed once for the whole batch.
6801
+ const rcIo = runContextIo(config);
6802
+ // The byte cap on the tree's path lists is the batch's: its request limit is derived from it.
6803
+ const repoCtx = resolveRepoContext(rcIo, { treeByteCap: require('./lib/repo-tree').MAX_TRANSPORT_BYTES });
6804
+
6805
+ // Fail closed FIRST — before any story is resolved or anything is sent. An overnight batch
6806
+ // runs with nobody watching: an editor-mode repo would park every story at step_4, and an
6807
+ // unrecognised mode would run on a guess.
6752
6808
  if (opts.overnight) {
6753
- body.config.overnight = true;
6809
+ const { OVERNIGHT_MAX_STORIES } = require('./lib/run-context');
6810
+ if (lines.length > OVERNIGHT_MAX_STORIES) {
6811
+ console.error(`✗ --overnight refused: ${lines.length} stories, over the ${OVERNIGHT_MAX_STORIES}-story overnight cap. ` +
6812
+ 'Nothing was resolved or sent; split the file.');
6813
+ process.exit(1);
6814
+ }
6815
+ const refusal = overnightModeRefusal(repoCtx.execution);
6816
+ if (refusal) {
6817
+ console.error(refusal);
6818
+ process.exit(1);
6819
+ }
6754
6820
  }
6821
+ printRunContextNotes(repoCtx.notes);
6755
6822
 
6756
- // HC-101-followup-2: plumb the adopter's CI gate config to every story
6757
- // in the batch. Without this, the batch path would silently default to
6758
- // gate=github on the server even when .pipeline-config.yml says local/none —
6759
- // exactly the "silent skip" the design warned against. Same try/catch
6760
- // pattern as the run-story path (cli/hone-cli.js ~L4628).
6761
- try {
6762
- const { readCIGateConfig, DEFAULT_CI_LOCAL_COMMAND } = require('./lib/pipeline-config');
6763
- const ciGate = readCIGateConfig(process.cwd());
6764
- body.config.ci_gate = ciGate.gate;
6765
- body.config.ci_local_command = ciGate.local_command;
6766
- if (ciGate.gate !== 'github') {
6767
- console.log(`CI gate mode for batch: ${ciGate.gate} (local_command: ${ciGate.local_command})`);
6823
+ // Resolved with bounded concurrency: each story can make a network call (its roadmap context,
6824
+ // or `gh issue view`), and fifty in series is minutes of waiting. The output is printed after
6825
+ // every story has resolved, in file order, so it reads exactly like a serial pass.
6826
+ const RESOLVE_CONCURRENCY = 5;
6827
+ const resolved = new Array(lines.length);
6828
+ let nextLine = 0;
6829
+ await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, lines.length) }, async () => {
6830
+ while (nextLine < lines.length) {
6831
+ const i = nextLine++;
6832
+ resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId);
6768
6833
  }
6769
- } catch (e) {
6770
- const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
6771
- console.warn(`⚠ CI gate config read failed for batch (non-fatal, defaulting to gate=github): ${msg}`);
6772
- body.config.ci_gate = 'github';
6773
- try {
6774
- const { DEFAULT_CI_LOCAL_COMMAND } = require('./lib/pipeline-config');
6775
- body.config.ci_local_command = DEFAULT_CI_LOCAL_COMMAND;
6776
- } catch { body.config.ci_local_command = 'make ci'; }
6834
+ }));
6835
+ // One summary line per story, and every warning labelled with its story. The info lines
6836
+ // run-story prints for one run would be a dozen per story here.
6837
+ for (const ctx of resolved) {
6838
+ const { storyId } = ctx;
6839
+ const src = ctx.textSource ? `${ctx.textSource} text (${ctx.rawText.length} chars)` : 'no story text';
6840
+ console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled`);
6841
+ printRunContextNotes(ctx.notes.pre, { prefix: `[${storyId}]`, only: 'warn' });
6842
+ printRunContextNotes(ctx.notes.post, { prefix: `[${storyId}]`, only: 'warn' });
6843
+ }
6844
+
6845
+ // HC-GF-097: a story with no text grooms to placeholders. run-story warns and proceeds for a
6846
+ // run someone is watching; a batch refuses — naming EVERY such story — before anything is
6847
+ // queued. Judged on the raw text, never the composed description.
6848
+ const missing = resolved.filter(ctx => !hasStoryText(ctx));
6849
+ if (missing.length > 0) {
6850
+ console.error(`✗ ${missing.length} of ${resolved.length} ${resolved.length === 1 ? 'story' : 'stories'} ` +
6851
+ `${missing.length === 1 ? 'has' : 'have'} no story text, so nothing was queued:`);
6852
+ for (const ctx of missing) console.error(` ${describeMissingText(ctx)}`);
6853
+ console.error('Give each one text (a roadmap row or a GitHub issue number), or remove it from the file.');
6854
+ process.exit(1);
6777
6855
  }
6778
6856
 
6857
+ const stories = lines.map(({ storyId, dependsOn }, i) => {
6858
+ const obj = { storyId, repoName, branch, config: buildRunConfig(repoCtx, resolved[i]) };
6859
+ if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
6860
+ return obj;
6861
+ });
6862
+
6863
+ // HC-054: Night Shift. The flag lives at batch level only; the server caps an overnight batch
6864
+ // at 25 and denormalizes the flag into each child itself.
6865
+ const body = { stories, config: {} };
6866
+ if (opts.overnight) body.config.overnight = true;
6867
+ // HC-101-followup-2: the CI gate also travels at batch level. Each story already carries its
6868
+ // own; a server from HC-GF-097 on fills the batch value into any story that did not.
6869
+ body.config.ci_gate = repoCtx.config.ci_gate;
6870
+ body.config.ci_local_command = repoCtx.config.ci_local_command;
6871
+
6872
+ // Sized to the body: the client's 30 s default covered the upload (review of #1100).
6873
+ const bodyBytes = Buffer.byteLength(JSON.stringify(body), 'utf8');
6874
+ const postTimeoutMs = require('./lib/run-context').batchPostTimeoutMs(bodyBytes);
6779
6875
  try {
6780
- const { data } = await client.post('/orchestrate/batch', body);
6876
+ const { data } = await client.post('/orchestrate/batch', body, { timeout: postTimeoutMs });
6781
6877
 
6782
6878
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6783
6879
 
@@ -6797,6 +6893,25 @@ program
6797
6893
  console.error(`Org concurrency limit reached: ${e.response.data.error}`);
6798
6894
  } else if (e.response?.status === 400) {
6799
6895
  console.error(`Rejected: ${e.response.data.error}`);
6896
+ } else if (e.response?.status === 413) {
6897
+ // HC-GF-097: full per-story context can outgrow the server's request limit. Say how big,
6898
+ // which stories, and what to do — not a bare "request entity too large".
6899
+ const total = Buffer.byteLength(JSON.stringify(body), 'utf8');
6900
+ console.error(`Rejected: the batch is ${(total / 1048576).toFixed(1)} MB, over the server's request limit (413).`);
6901
+ console.error('Largest stories by run-config size:');
6902
+ const sizes = stories.map(s => ({ id: s.storyId, bytes: Buffer.byteLength(JSON.stringify(s.config), 'utf8') }))
6903
+ .sort((a, b) => b.bytes - a.bytes);
6904
+ for (const s of sizes.slice(0, 10)) console.error(` ${s.id} ${(s.bytes / 1024).toFixed(0)} KB`);
6905
+ console.error('Split the file into smaller batches, or narrow codebase.source_dirs so each story carries less.');
6906
+ } else if (e.response?.status === 503) {
6907
+ // HC-GF-097 review: the server admits a few batch submissions at a time; nothing was queued.
6908
+ console.error(`Server busy, nothing was queued: ${e.response.data?.error || 'retry shortly'}`);
6909
+ } else if (!e.response && (e.code === 'ECONNABORTED' || /timeout/i.test(e.message || ''))) {
6910
+ // The body may have arrived and been committed before the timer fired. Re-running blind
6911
+ // would queue the same stories twice, so say so and point at the list.
6912
+ console.error(`Timed out after ${Math.round(postTimeoutMs / 1000)}s sending a ${(bodyBytes / 1048576).toFixed(1)} MB batch.`);
6913
+ console.error('The server may have queued it anyway. Check before re-running, or the stories will be queued twice:');
6914
+ console.error(' hone queue-stories --list');
6800
6915
  } else {
6801
6916
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
6802
6917
  }
@@ -6804,71 +6919,6 @@ program
6804
6919
  }
6805
6920
  });
6806
6921
 
6807
- // ── HC-054g: Night-shift retroactive revert command ─────────────────────────
6808
- //
6809
- // Two-step operator workflow after rejected_rate drift (HC-054c) flags a
6810
- // batch of bad overnight auto-approves:
6811
- //
6812
- // 1. `hone night-shift revert <runId> --step-key <key> --revert-pr <url>`
6813
- // 2. CLI calls POST /night-shift/runs/:runId/retroactive-reject (if not yet)
6814
- // then POST /night-shift/runs/:runId/revert with the revert PR URL.
6815
- //
6816
- // We deliberately don't take repo write access; the operator runs the git
6817
- // revert themselves and passes the resulting revert-PR URL. This keeps the
6818
- // server side free of GitHub credentials + repo-specific permissions while
6819
- // still giving the audit log the revert provenance.
6820
- const nightShiftCmd = program.command('night-shift').description('HC-054 night-shift audit + revert workflow');
6821
- nightShiftCmd
6822
- .command('revert <runId>')
6823
- .description('Record a revert action for a retroactively-rejected overnight auto-approve')
6824
- .requiredOption('--step-key <stepKey>', 'The step_key whose auto-approve produced the bad output (e.g. step_4 or step_5)')
6825
- .requiredOption('--revert-pr <url>', 'GitHub PR URL of the revert commit (https://github.com/.../pull/<n>)')
6826
- .option('--reason <reason>', 'Free-text reason for the retroactive rejection (recorded with the audit row)')
6827
- .action(async (runId, opts) => {
6828
- const config = getConfig();
6829
- const client = api(config);
6830
- try {
6831
- // Step 1: retroactively-reject if not yet rejected. The endpoint
6832
- // returns 409 if already rejected — we treat that as a no-op
6833
- // (operator may have done step 1 yesterday, run revert today).
6834
- try {
6835
- await client.post(`/night-shift/runs/${runId}/retroactive-reject`, {
6836
- stepKey: opts.stepKey,
6837
- reason: opts.reason,
6838
- });
6839
- console.log(`[night-shift] retroactively rejected (runId=${runId}, stepKey=${opts.stepKey})`);
6840
- } catch (e) {
6841
- const status = e.response?.status;
6842
- if (status === 409) {
6843
- console.log(`[night-shift] already retroactively rejected (continuing to revert step)`);
6844
- } else if (status === 404) {
6845
- console.error(`hone night-shift revert failed: no auto-approve audit row for (runId=${runId}, stepKey=${opts.stepKey})`);
6846
- process.exit(1);
6847
- } else {
6848
- throw e;
6849
- }
6850
- }
6851
-
6852
- // Step 2: record the revert PR URL.
6853
- const r = await client.post(`/night-shift/runs/${runId}/revert`, {
6854
- stepKey: opts.stepKey,
6855
- revertPrUrl: opts.revertPr,
6856
- });
6857
- console.log(`[night-shift] revert recorded:`);
6858
- console.log(` runId: ${r.data.runId}`);
6859
- console.log(` stepKey: ${r.data.stepKey}`);
6860
- console.log(` revertInitiatedAt: ${r.data.revertInitiatedAt}`);
6861
- console.log(` revertPrUrl: ${r.data.revertPrUrl}`);
6862
- } catch (e) {
6863
- const msg = e.response?.data?.error || e.message;
6864
- console.error(`hone night-shift revert failed: ${msg}`);
6865
- if (e.response?.data?.remediation) {
6866
- console.error(`Remediation: ${e.response.data.remediation}`);
6867
- }
6868
- process.exit(1);
6869
- }
6870
- });
6871
-
6872
6922
  // ── HC-056: Schedule install (GitHub Actions overnight template) ────────────
6873
6923
  //
6874
6924
  // Installs a parameterized .github/workflows/<name>.yml that runs `hone
@@ -7434,13 +7484,16 @@ program
7434
7484
  const responseForCache = parsed && typeof parsed === 'object'
7435
7485
  ? parsed
7436
7486
  : { raw: responseText };
7437
- storeReviewCache({
7487
+ await storeReviewCache({
7438
7488
  axios, apiBase, token,
7439
7489
  contentHash: cacheContentHash,
7440
7490
  model: modelForCache,
7441
7491
  response: responseForCache,
7442
7492
  tokensSaved: inputTokens + outputTokens,
7443
7493
  banner,
7494
+ // HC-GF-260: awaited. The next thing this command does is compute an exit code and
7495
+ // leave, so a detached POST can lose the entry the cache exists to save — the same shape
7496
+ // as HC-GF-250, where a process returned before its write landed.
7444
7497
  }).then(({ stored }) => {
7445
7498
  if (stored) banner(`✓ Cache stored: ${cacheContentHash.slice(0, 16)}…`);
7446
7499
  }).catch(() => { /* logged inside helper */ });
@@ -7603,6 +7656,9 @@ program
7603
7656
  // (followup-20) for stories still on the old ## Patch contract.
7604
7657
  let diff, source, noChanges;
7605
7658
  const blocks = extractFileBlocks(output);
7659
+ // HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
7660
+ // silently re-scoping is how a finished build came back as "no file changes to verify".
7661
+ if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
7606
7662
  if (blocks.noChanges) {
7607
7663
  emit({ ok: true, verdict: 'no_changes' },
7608
7664
  `○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
@@ -7647,13 +7703,15 @@ program
7647
7703
 
7648
7704
  // Write to a temp file and run git apply --check against HEAD. HC-GF-077: against an index read from HEAD,
7649
7705
  // not the working copy — an editor's uncommitted edits are exactly what emit-pr's branch will NOT contain.
7650
- const tmp = path.join(os.tmpdir(), `hone-check-patch-${Date.now()}.diff`);
7706
+ // HC-GF-304: a directory only this invocation created, not a name built from the clock.
7707
+ const tmpFile = require('./lib/private-temp').privateTempFile('hone-check-patch', 'patch.diff');
7708
+ const tmp = tmpFile.file;
7651
7709
  let result;
7652
7710
  try {
7653
7711
  fs.writeFileSync(tmp, diff);
7654
7712
  let code = 0, stderr = '';
7655
7713
  try {
7656
- const index = path.join(os.tmpdir(), `hone-check-index-${process.pid}-${Date.now()}`);
7714
+ const index = path.join(path.dirname(tmp), 'index'); // HC-GF-304: inside this invocation's private directory
7657
7715
  try {
7658
7716
  try {
7659
7717
  execFileSync('git', ['read-tree', `--index-output=${index}`, 'HEAD'], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
@@ -7670,7 +7728,7 @@ program
7670
7728
  }
7671
7729
  result = interpretApplyCheck({ code, stderr });
7672
7730
  } finally {
7673
- try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
7731
+ tmpFile.remove();
7674
7732
  }
7675
7733
 
7676
7734
  if (result.applies) {
@@ -7703,15 +7761,35 @@ program
7703
7761
  .option('--keep', 'Leave the worktree in place for debugging')
7704
7762
  .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
7705
7763
  .option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
7764
+ .option('--trust-source', 'HC-GF-270: run a patch whose story text came from a source Hone does not trust (you have read the diff)')
7706
7765
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7707
7766
  .action(async (workflowId, opts) => {
7708
7767
  const { execFileSync, spawnSync } = require('child_process');
7768
+ // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
7769
+ // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
7770
+ // deep in child_process, and a blank value coerces to 0 and kills the
7771
+ // command the instant it starts.
7772
+ let timeoutMs;
7773
+ try {
7774
+ timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
7775
+ } catch (e) {
7776
+ console.error(e.message);
7777
+ process.exit(2);
7778
+ }
7709
7779
  const os = require('os');
7710
7780
  const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
7711
7781
  const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
7712
7782
  const { gitEnv } = require('./lib/git-env');
7713
7783
  const config = getConfig();
7714
7784
  const client = api(config);
7785
+ // HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
7786
+ // resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
7787
+ // patch while THIS run's config was assessed. A step key is a step key.
7788
+ if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
7789
+ console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
7790
+ process.exit(2);
7791
+ }
7792
+ const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
7715
7793
 
7716
7794
  const emit = (obj, humanLine) => {
7717
7795
  if (opts.format === 'json') console.log(JSON.stringify(obj));
@@ -7722,19 +7800,31 @@ program
7722
7800
  // HC-GF-074: the attempt this run verified — reported with the verdict below.
7723
7801
  let verifiedAttempt;
7724
7802
  let output, runConfig;
7803
+ // HC-GF-270: the id shape is the legacy provenance rule's only input, and a FAILED run
7804
+ // fetch is untrusted regardless of it — the `catch { runConfig = {} }` below is the
7805
+ // fail-open this gate exists to close.
7806
+ let runStoryId = null;
7807
+ let runFetchFailed = false;
7725
7808
  try {
7726
- const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
7809
+ const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
7727
7810
  (opts.attempt ? `?attempt=${opts.attempt}` : '');
7728
7811
  const r = await client.get(url);
7812
+ // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
7813
+ if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
7729
7814
  output = r.data?.output;
7730
7815
  // HC-GF-074: remember WHICH attempt this is. Without --attempt the server returns the latest, and the verdict
7731
7816
  // used to be reported with `attempt: null` — so no verdict on record could say which build it judged.
7732
7817
  verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7733
7818
  // best-effort: pull the run's ci_local_command for the default
7734
7819
  try {
7735
- const runR = await client.get(`/orchestrate/${workflowId}`);
7820
+ const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
7821
+ // HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
7822
+ // `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
7823
+ // 200 with no config. And a body that is not THIS run is not this run's config.
7824
+ if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
7736
7825
  runConfig = runR.data?.config || {};
7737
- } catch { runConfig = {}; }
7826
+ runStoryId = runR.data?.storyId || runR.data?.story_id || null;
7827
+ } catch (e) { runConfig = {}; runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; }
7738
7828
  } catch (e) {
7739
7829
  const msg = e.response?.data?.error || e.message;
7740
7830
  emit({ ok: false, reason: 'fetch_failed', detail: msg },
@@ -7742,12 +7832,58 @@ program
7742
7832
  process.exit(EXIT.NO_DIFF);
7743
7833
  }
7744
7834
 
7835
+ // ── HC-GF-270: the provenance gate ────────────────────────────────────────────────────
7836
+ //
7837
+ // HERE, and not one line later. Telling the operator at the poll loop is not enough: an
7838
+ // MCP agent reads the same untrusted issue text and can call `hone_verify_patch` itself
7839
+ // (cli/lib/mcp-tools.js), and a scripted `--approve` takes the same path. This runs
7840
+ // BEFORE the diff is materialized, before a worktree exists, and before provisioning
7841
+ // runs the PATCHED manifest's install as the operator — by the time `planProvision`
7842
+ // sees it, the code is already on disk, which is why worktree-provision.js is not the
7843
+ // layer for this.
7844
+ {
7845
+ let assessment;
7846
+ try {
7847
+ const { assessStorySource } = require('./lib/story-provenance');
7848
+ assessment = runFetchFailed
7849
+ ? { trusted: false, label: 'run-fetch-failed',
7850
+ reason: runFetchFailed === 'mismatch'
7851
+ ? 'the server answered for a different run, so this run\'s config was never read'
7852
+ : 'this run\'s config could not be read, so nothing is known about where its story text came from' }
7853
+ : assessStorySource(runConfig?.story_source, runStoryId);
7854
+ } catch (e) {
7855
+ assessment = { trusted: false, label: 'assessment-failed',
7856
+ reason: `story provenance could not be assessed: ${e.message}` };
7857
+ }
7858
+ if (!assessment.trusted && !opts.trustSource) {
7859
+ // NO --report on this path, deliberately. A non-pass verdict on record makes the
7860
+ // server refuse the human's later approval under `enforce`
7861
+ // (server/src/services/verification-refusal.js), and nothing was verified here —
7862
+ // this is a check that did not RUN, which is a third outcome, not a failure.
7863
+ emit({ ok: false, reason: 'untrusted_source', verdict: 'not_run', detail: assessment.reason },
7864
+ `✗ refusing to verify: ${assessment.reason}\n`
7865
+ + ' Verifying runs this patch — the provisioning install first — as you, with your $HOME and network.\n'
7866
+ + ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
7867
+ + ` Then: hone verify-patch ${workflowId} --report --trust-source`);
7868
+ process.exit(EXIT.UNTRUSTED_SOURCE);
7869
+ }
7870
+ }
7871
+
7745
7872
  // 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
7746
7873
  // Condition 6 presupposes 5.
7747
7874
  const blocks = extractFileBlocks(output);
7875
+ // HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
7876
+ // silently re-scoping is how a finished build came back as "no file changes to verify".
7877
+ if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
7748
7878
  const hasWork = hasMaterializableWork(blocks);
7749
7879
  if (blocks.noChanges || !hasWork) {
7750
- emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
7880
+ // HC-GF-190: this exit precedes planSandboxRun entirely, so a stateful story with a declared
7881
+ // sandbox produces NONE of the planner's three outcomes and, before this line, said nothing
7882
+ // about it — run 2a9b2af5's verdict was indistinguishable from an ordinary empty build.
7883
+ // `not reached` is a fourth outcome, not a variant of the stand-down.
7884
+ const missed = describeSandboxDisposition(runConfig);
7885
+ if (missed && opts.format !== 'json') console.warn(` ⚠ ${missed}`);
7886
+ emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff', sandbox: missed || undefined },
7751
7887
  `✗ ${opts.stepKey} produced no file changes to verify.`);
7752
7888
  process.exit(EXIT.NO_DIFF);
7753
7889
  }
@@ -7770,16 +7906,19 @@ program
7770
7906
 
7771
7907
  // 3. Isolated worktree from HEAD.
7772
7908
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
7773
- const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
7909
+ // HC-GF-304: a directory only this invocation created. A clock-named file was shared by two verbs started in
7910
+ // the same millisecond, and one of them verified the other's patch.
7911
+ const diffTmp = require('./lib/private-temp').privateTempFile('hone-verify-diff', 'patch.diff');
7912
+ const diffFile = diffTmp.file;
7774
7913
  fs.writeFileSync(diffFile, diff);
7775
- const { command, source: commandSource } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess
7914
+ const { command, source: commandSource, replaced: commandReplaced } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess; HC-GF-223: `replaced` says what an override skipped
7776
7915
  let cleanupDone = false;
7777
7916
  const cleanup = () => {
7778
7917
  if (cleanupDone || opts.keep) return;
7779
7918
  cleanupDone = true;
7780
7919
  try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
7781
7920
  try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
7782
- try { fs.unlinkSync(diffFile); } catch { /* best-effort */ }
7921
+ diffTmp.remove();
7783
7922
  };
7784
7923
  // Guard against leaving a worktree if the process is interrupted.
7785
7924
  process.on('SIGINT', () => { cleanup(); process.exit(130); });
@@ -7795,6 +7934,9 @@ program
7795
7934
  // HC-GF-026: measured verdicts for the gates step_4 asserts. Hoisted so they
7796
7935
  // survive the try/finally to the --report POST, exactly like reportVerdict.
7797
7936
  const reportGates = [];
7937
+ // HC-GF-171: hoisted for the same reason reportVerdict is — the teardown must survive the
7938
+ // try/finally. Seeded rows outliving a run is the worst outcome this feature can produce.
7939
+ let sandboxTeardown = [];
7798
7940
  const ciCfg = require('./lib/pipeline-config').readCIConfigDetails(process.cwd());
7799
7941
  try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
7800
7942
  try {
@@ -7819,24 +7961,172 @@ program
7819
7961
  // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
7820
7962
  // (link the repo's node_modules when the lockfile is unchanged, else run the
7821
7963
  // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
7964
+ // HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
7965
+ // and with --no-provision there is none, which is not the same as a failure.
7966
+ let provAll = null;
7822
7967
  if (opts.provision !== false) {
7823
- const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
7968
+ const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
7824
7969
  const { readCIConfigDetails } = require('./lib/pipeline-config');
7825
- const plan = planProvision({ worktreeDir: wt, parentDir: process.cwd(), installCommand: readCIConfigDetails(process.cwd()).install_command });
7826
- const prov = provisionWorktree(plan, { worktreeDir: wt, exec: spawnSync });
7970
+ // HC-GF-201: provision EVERY package root, not just the worktree's top level.
7971
+ // This repo has three; the two skipped are where ci.unit runs, so their absent
7972
+ // modules surfaced as `Cannot find module` and were counted as the PATCH failing
7973
+ // tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
7974
+ const installCmd = readCIConfigDetails(process.cwd()).install_command;
7975
+ const provEntries = discoverPackageRoots(wt).map((root) => ({
7976
+ root,
7977
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
7978
+ exec: spawnSync,
7979
+ plan: planProvision({
7980
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
7981
+ parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
7982
+ installCommand: root === '.' ? installCmd : undefined,
7983
+ }),
7984
+ }));
7985
+ provAll = provisionAllRoots(provEntries);
7986
+ const prov = { ok: provAll.ok,
7987
+ summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
7988
+ + (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
7827
7989
  if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
7828
7990
  }
7991
+ // ── HC-GF-171: seed the adopter-declared sandbox before the tests run ────────────────
7992
+ // HC-GF-168/169/170/173 each shipped a piece and a run still touched no datastore, because
7993
+ // `assertScratchTarget` had no caller. This is that caller. It runs adopter-side, with the
7994
+ // adopter's credentials, exactly where `ci.local_command` already runs.
7995
+ //
7996
+ // REFUSE, NEVER SKIP. A misconfigured sandbox stops verification with a message naming the
7997
+ // fix; it does not quietly fall through to "tests passed", which would report a green
7998
+ // verdict for code whose stored-state behaviour was never exercised.
7999
+ try {
8000
+ const { readSandboxConfig, assertScratchTarget } = require('./lib/pipeline-config');
8001
+ const { planSandboxRun, parseProbeOutput, describeProbeResult, sandboxSuiteVerdict } = require('./lib/sandbox-lifecycle');
8002
+ const plan = planSandboxRun({
8003
+ sandbox: readSandboxConfig(process.cwd()),
8004
+ axes: runConfig?.classifier_axes || runConfig?.axes || [],
8005
+ capability: runConfig?.capability || null,
8006
+ });
8007
+ if (plan.refuse) {
8008
+ emit({ ok: false, verdict: 'sandbox_refused', detail: plan.reason },
8009
+ `✗ sandbox refused: ${plan.reason}`);
8010
+ exitCode = EXIT.FAIL;
8011
+ reportVerdict = 'sandbox_refused'; reportDetail = plan.reason;
8012
+ throw new Error('sandbox refused');
8013
+ }
8014
+ if (plan.engage) {
8015
+ // The probe runs in the REAL tree, not the worktree: it reads the adopter's live
8016
+ // datastore, which the worktree does not change.
8017
+ const probed = spawnSync(plan.probe, {
8018
+ cwd: process.cwd(), shell: true, encoding: 'utf8',
8019
+ timeout: 60_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'],
8020
+ });
8021
+ // A probe that exited non-zero is not evidence, whatever it left on stdout — a
8022
+ // command that fails partway can still print a stale or partial line, and reading it
8023
+ // would turn a failed check into a pass.
8024
+ if (probed.status !== 0) {
8025
+ throw new Error(`sandbox probe exited ${probed.status === null ? 'abnormally' : probed.status}`
8026
+ + ` (${plan.probe}): ${((probed.stderr || '').split('\n')[0] || 'no stderr')}`);
8027
+ }
8028
+ // The guard reads the LIVE connection and nothing else (HC-GF-169).
8029
+ const probeEvidence = parseProbeOutput(probed.stdout);
8030
+ assertScratchTarget(plan.verify, probeEvidence);
8031
+ // HC-GF-190: say what the guard PROVED. It throws on refusal and returns nothing on
8032
+ // success, so a passing guard was silent — and HC-GF-185's acceptance ("the output
8033
+ // shows the probe, the guard verdict and the teardown") stayed open on run 6be117b3,
8034
+ // where all three had in fact happened. Printed after the check and before the first
8035
+ // write, so it describes something that has already occurred.
8036
+ if (opts.format !== 'json') console.log(` sandbox: ${describeProbeResult(plan.verify, probeEvidence)}`);
8037
+ // Armed BEFORE the first command, not after the last. `create` succeeding and
8038
+ // `migrate` failing would otherwise leave the created rows behind — exactly the
8039
+ // outcome the teardown exists to prevent, on the path where it matters most.
8040
+ sandboxTeardown = plan.teardown;
8041
+ for (const c of plan.commands) {
8042
+ if (opts.format !== 'json') console.log(` sandbox: ${c}`);
8043
+ const r = spawnSync(c, { cwd: wt, shell: true, encoding: 'utf8',
8044
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
8045
+ if (r.status !== 0) throw new Error(`sandbox command failed: ${c}`);
8046
+ }
8047
+
8048
+ // HC-GF-192: run the suite that READS the sandbox, immediately after seeding and
8049
+ // before the adopter's own command.
8050
+ //
8051
+ // Run 4f463e54 seeded `hone_sandbox` and then ran `make ci` against
8052
+ // `hone_test_<checkout>`: a flawless lifecycle around a verification that could not
8053
+ // observe it. The adopter's whole CI command is the wrong consumer — a suite that
8054
+ // manages its own database fights a seeded one — so the sandbox declares the command
8055
+ // that reads it, and the connection is one WE publish rather than one an adopter
8056
+ // promises is wired up. That is what makes the result provable.
8057
+ //
8058
+ // First, because the seed is freshest here and the adopter's command can run for
8059
+ // minutes; and a failure names the stored-state behaviour rather than arriving after
8060
+ // an unrelated suite. Names are logged, never values: the connection holds credentials.
8061
+ if (opts.format !== 'json') {
8062
+ console.log(` sandbox: ${plan.test}`);
8063
+ console.log(` sandbox: published ${Object.keys(plan.env).join(', ')} to it`);
8064
+ }
8065
+ const sandboxRun = spawnSync(plan.test, {
8066
+ cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
8067
+ env: verifyCommandEnv(gitEnv(plan.env)), stdio: ['ignore', 'pipe', 'pipe'],
8068
+ });
8069
+
8070
+ // HC-GF-193: the NEGATIVE CONTROL. A runner with nothing to run exits 0, so the status
8071
+ // above cannot tell "everything passed" from "nothing ran" — run 6be117b3's first
8072
+ // attempt was reported as verified on a glob that matched no files. Re-run with every
8073
+ // published variable replaced by an unusable value: a suite that observes the sandbox
8074
+ // must fail, and one that still passes is not reading what we published.
8075
+ //
8076
+ // Only when the real run PASSED. A red suite is already a failure, and re-running it
8077
+ // with a broken environment spends the timeout to learn nothing.
8078
+ let controlStatus = 1;
8079
+ if (sandboxRun.status === 0 && plan.controlEnv) {
8080
+ if (opts.format !== 'json') console.log(' sandbox: re-running the suite with the published variables broken (control)');
8081
+ const ctl = spawnSync(plan.test, {
8082
+ cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
8083
+ env: verifyCommandEnv(gitEnv(plan.controlEnv)), stdio: ['ignore', 'pipe', 'pipe'],
8084
+ });
8085
+ controlStatus = ctl.status === 0 ? 0 : 1;
8086
+ }
8087
+
8088
+ const suiteVerdict = sandboxSuiteVerdict({ realStatus: sandboxRun.status, controlStatus });
8089
+ if (!suiteVerdict.ok) {
8090
+ const ran = ((sandboxRun.stdout || '') + (sandboxRun.stderr || ''))
8091
+ .split('\n').filter(Boolean).slice(-12).join('\n');
8092
+ const detail = suiteVerdict.kind === 'sandbox_tests_not_observing'
8093
+ ? suiteVerdict.detail : ran;
8094
+ emit({ ok: false, verdict: suiteVerdict.kind, detail },
8095
+ `✗ ${suiteVerdict.kind === 'sandbox_tests_not_observing'
8096
+ ? `the sandbox suite verified nothing:\n${detail}`
8097
+ : `the sandbox suite failed:\n${detail}`}`);
8098
+ exitCode = EXIT.FAIL;
8099
+ reportVerdict = suiteVerdict.kind; reportDetail = detail;
8100
+ // Thrown so the `finally` tears the sandbox down — seeded rows outliving a red run
8101
+ // is the worst outcome this capability can produce (HC-GF-171).
8102
+ throw new Error('sandbox tests failed');
8103
+ }
8104
+ } else if (opts.format !== 'json') {
8105
+ console.log(` sandbox: ${plan.reason}`);
8106
+ }
8107
+ } catch (e) {
8108
+ if (e.message === 'sandbox refused' || e.message === 'sandbox tests failed') throw e;
8109
+ // A sandbox that cannot be proven scratch STOPS the run. Continuing would report a
8110
+ // verdict for stored-state behaviour that was never exercised — a verdict we did not
8111
+ // obtain (HC-GF-177 §18.5).
8112
+ emit({ ok: false, verdict: 'sandbox_unusable', detail: e.message },
8113
+ `✗ sandbox unusable: ${e.message}`);
8114
+ exitCode = EXIT.FAIL;
8115
+ reportVerdict = 'sandbox_unusable'; reportDetail = e.message;
8116
+ throw e;
8117
+ }
8118
+
7829
8119
  if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7830
8120
  const run = spawnSync(command, {
7831
8121
  cwd: wt, shell: true, encoding: 'utf8',
7832
- timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
8122
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
7833
8123
  stdio: ['ignore', 'pipe', 'pipe'],
7834
8124
  });
7835
8125
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
7836
- const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
8126
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
7837
8127
  // HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
7838
8128
  if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
7839
- const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
8129
+ const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
7840
8130
 
7841
8131
  // HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
7842
8132
  // The full log is written OUTSIDE the worktree (cleanup removes the worktree)
@@ -7857,7 +8147,13 @@ program
7857
8147
  + v.failure.excerpt.map(l => ` │ ${l}`).join('\n');
7858
8148
  }
7859
8149
  if (logPath) pretty += `\n full output: ${logPath}`;
7860
- if (v.verdict !== 'pass') pretty += `\n inspect the tree: re-run with --keep`;
8150
+ if (v.verdict !== 'pass' && v.verdict !== 'pass_narrowed') pretty += `\n inspect the tree: re-run with --keep`;
8151
+ // HC-GF-223: a narrowed run needs the command that did NOT run, not the worktree — the way
8152
+ // to a real verdict is the next line rather than a re-read of the story.
8153
+ if (v.verdict === 'pass_narrowed') {
8154
+ pretty += `\n not a verification: re-run without --command to verify with `
8155
+ + `\`${commandReplaced.command}\``;
8156
+ }
7861
8157
 
7862
8158
  emit(
7863
8159
  { ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail,
@@ -7881,7 +8177,7 @@ program
7881
8177
  for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
7882
8178
  const gr = spawnSync(gate.command, {
7883
8179
  cwd: wt, shell: true, encoding: 'utf8',
7884
- timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
8180
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
7885
8181
  stdio: ['ignore', 'pipe', 'pipe'],
7886
8182
  });
7887
8183
  const c = classifyGateRun({
@@ -7902,11 +8198,34 @@ program
7902
8198
  }
7903
8199
  }
7904
8200
  } catch (e) {
7905
- emit({ ok: false, verdict: 'error', detail: e.message },
7906
- `✗ verify-patch failed: ${e.message}`);
7907
- exitCode = 1;
7908
- reportVerdict = 'error'; reportDetail = e.message;
8201
+ // HC-GF-171: a verdict already reported by the sandbox path is the SPECIFIC one — keep it.
8202
+ // Overwriting it with a generic `error` loses which check refused and emits a second,
8203
+ // vaguer line over the first, so the operator reads "verify-patch failed" instead of the
8204
+ // message naming the config key to add.
8205
+ if (reportVerdict) {
8206
+ exitCode = exitCode === EXIT.PASS ? 1 : exitCode;
8207
+ } else {
8208
+ emit({ ok: false, verdict: 'error', detail: e.message },
8209
+ `✗ verify-patch failed: ${e.message}`);
8210
+ exitCode = 1;
8211
+ reportVerdict = 'error'; reportDetail = e.message;
8212
+ }
7909
8213
  } finally {
8214
+ // HC-GF-171: reset runs on EVERY path — pass, fail, refusal, throw. A sandbox that is only
8215
+ // cleaned on success leaves its rows behind exactly when something went wrong, which is when
8216
+ // the adopter is least able to tell seeded data from real.
8217
+ for (const c of sandboxTeardown) {
8218
+ const r = spawnSync(c, { cwd: process.cwd(), shell: true, encoding: 'utf8',
8219
+ timeout: 120_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
8220
+ if (opts.format !== 'json') {
8221
+ console.log(r.status === 0
8222
+ ? ` sandbox: ${c}`
8223
+ : ` sandbox: TEARDOWN FAILED (${c}) — seeded rows may remain: ${(r.stderr || '').split('\n')[0]}`);
8224
+ }
8225
+ // HC-GF-183: keep going. If the reset succeeded and the restore failed, stopping here
8226
+ // leaves the sandbox unprobeable and the NEXT run refuses — the defect this fixes.
8227
+ }
8228
+
7910
8229
  cleanup();
7911
8230
  }
7912
8231
 
@@ -7915,7 +8234,7 @@ program
7915
8234
  // change the local exit code (the verdict is already true locally).
7916
8235
  if (opts.report && reportVerdict) {
7917
8236
  try {
7918
- await client.post(`/orchestrate/${workflowId}/verdict`, {
8237
+ await client.post(`/orchestrate/${encodeURIComponent(workflowId)}/verdict`, {
7919
8238
  source: 'verify-patch',
7920
8239
  stepKey: opts.stepKey,
7921
8240
  verdict: reportVerdict,
@@ -7971,6 +8290,82 @@ program
7971
8290
  }
7972
8291
  });
7973
8292
 
8293
+ // ── HC-GF-098: follow-batch — a queued batch's step_4 patches checked and verified overnight ──
8294
+ // The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
8295
+ // check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
8296
+ // decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
8297
+ // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you, 3 deadline.
8298
+ program
8299
+ .command('follow-batch <batchId>')
8300
+ .description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098)')
8301
+ .option('--poll-interval <sec>', 'Seconds between polls', '30')
8302
+ .option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
8303
+ .option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
8304
+ .option('--open-pr', 'Push each emitted branch and open a draft PR (default: a local branch only)')
8305
+ .option('--repo <name>', 'Repository name the batch was queued under (default: this directory\'s name)')
8306
+ .action(async (batchId, opts) => {
8307
+ const { followBatch, EXIT } = require('./lib/batch-follow');
8308
+ const { spawn, spawnSync } = require('child_process');
8309
+ const positive = (raw, flag, unit) => {
8310
+ const n = Number(raw);
8311
+ if (!Number.isFinite(n) || n <= 0) {
8312
+ console.error(`${flag} must be a positive number of ${unit} (got ${JSON.stringify(raw)})`);
8313
+ process.exit(EXIT.FATAL);
8314
+ }
8315
+ return n;
8316
+ };
8317
+ const pollMs = positive(opts.pollInterval, '--poll-interval', 'seconds') * 1000;
8318
+ const maxOfflineMs = positive(opts.maxOffline, '--max-offline', 'minutes') * 60_000;
8319
+ const deadlineMs = opts.deadline === undefined ? null : positive(opts.deadline, '--deadline', 'minutes') * 60_000;
8320
+
8321
+ // Preflight: every verb reads this checkout, and --open-pr needs gh. Refuse before the first poll, not at 3am.
8322
+ const { gitEnv } = require('./lib/git-env');
8323
+ const inRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' });
8324
+ if (inRepo.status !== 0 || String(inRepo.stdout).trim() !== 'true') {
8325
+ console.error('follow-batch runs the batch\'s patches against this checkout: run it from the repository the batch was queued in.');
8326
+ process.exit(EXIT.FATAL);
8327
+ }
8328
+ if (opts.openPr && spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status !== 0) {
8329
+ console.error('--open-pr pushes and opens draft PRs with gh, and `gh auth status` failed: log in, or drop --open-pr.');
8330
+ process.exit(EXIT.FATAL);
8331
+ }
8332
+
8333
+ const config = getConfig();
8334
+ const client = api(config);
8335
+ const repoName = opts.repo || path.basename(process.cwd());
8336
+ const io = {
8337
+ fetchBatch: async (id) => (await client.get(`/orchestrate/batch/${encodeURIComponent(id)}`)).data,
8338
+ fetchRun: async (id) => (await client.get(`/orchestrate/${encodeURIComponent(id)}`)).data,
8339
+ runVerb: (argv) => new Promise((resolve) => {
8340
+ const child = spawn(process.execPath, [process.argv[1], ...argv],
8341
+ { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
8342
+ child.on('error', (e) => resolve({ code: null, error: e }));
8343
+ child.on('close', (code) => resolve({ code }));
8344
+ }),
8345
+ // The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
8346
+ verifyDecision: (run) => {
8347
+ let assessment;
8348
+ try {
8349
+ assessment = require('./lib/story-provenance').assessStorySource(
8350
+ run && run.config && run.config.story_source, run && (run.storyId || run.story_id));
8351
+ } catch (e) {
8352
+ assessment = { trusted: false, label: 'assessment-failed', reason: `story provenance could not be assessed: ${e.message}` };
8353
+ }
8354
+ return require('./lib/auto-verify').decideAutoVerify({ stepKey: 'step_4', repoRoot: process.cwd(), assessment });
8355
+ },
8356
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
8357
+ now: () => Date.now(),
8358
+ log: (line) => console.log(line),
8359
+ };
8360
+
8361
+ console.log(`[follow-batch] following batch ${batchId} for ${repoName}: poll ${pollMs / 1000}s, `
8362
+ + `${deadlineMs ? `deadline ${deadlineMs / 60_000} min` : 'no deadline'}, `
8363
+ + `emit ${opts.openPr ? 'push + draft PR' : 'local branch only'}`);
8364
+ const res = await followBatch(batchId, io, { pollMs, deadlineMs, maxOfflineMs, openPr: !!opts.openPr, repoName });
8365
+ console.log(`[follow-batch] ${res.reason}`);
8366
+ process.exit(res.exitCode);
8367
+ });
8368
+
7974
8369
  // ── HC-019n-followup-25: emit-pr (pipeline-recovery condition 7) ──────────────
7975
8370
  // Condition 6 proved step_4's diff applies AND its tests pass in isolation.
7976
8371
  // Condition 7 turns that verified diff into the reviewable artifact: a branch
@@ -7987,6 +8382,7 @@ program
7987
8382
  .option('--open-pr', 'Open a draft PR (implies --push)')
7988
8383
  .option('--ready', 'Open the PR ready-for-review instead of draft')
7989
8384
  .option('--skip-verify', 'Do not re-run the tests before committing (downgrades provenance)')
8385
+ .option('--trust-source', 'HC-GF-270: emit a patch whose story text came from a source Hone does not trust (you have read the diff)')
7990
8386
  .option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
7991
8387
  .option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
7992
8388
  .option('--keep', 'Leave the worktree in place for debugging')
@@ -7994,15 +8390,35 @@ program
7994
8390
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7995
8391
  .action(async (workflowId, opts) => {
7996
8392
  const { execFileSync, spawnSync } = require('child_process');
8393
+ // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8394
+ // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
8395
+ // deep in child_process, and a blank value coerces to 0 and kills the
8396
+ // command the instant it starts.
8397
+ let timeoutMs;
8398
+ try {
8399
+ timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
8400
+ } catch (e) {
8401
+ console.error(e.message);
8402
+ process.exit(2);
8403
+ }
7997
8404
  const os = require('os');
7998
8405
  const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
7999
8406
  const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
8407
+ const { verifyStateFor } = require('./lib/emit-pr');
8000
8408
  const {
8001
8409
  EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
8002
8410
  } = require('./lib/emit-pr');
8003
8411
  const { gitEnv } = require('./lib/git-env');
8004
8412
  const config = getConfig();
8005
8413
  const client = api(config);
8414
+ // HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
8415
+ // resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
8416
+ // patch while THIS run's config was assessed. A step key is a step key.
8417
+ if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
8418
+ console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
8419
+ process.exit(2);
8420
+ }
8421
+ const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
8006
8422
 
8007
8423
  // --open-pr implies --push (cannot open a PR against an unpushed branch).
8008
8424
  const doPush = Boolean(opts.push || opts.openPr);
@@ -8016,16 +8432,29 @@ program
8016
8432
 
8017
8433
  // 1. Fetch step_4 output + the run's storyId (for naming/provenance).
8018
8434
  let output, storyId = 'story', runConfig = {};
8435
+ // HC-GF-270: a swallowed run fetch leaves storyId as the non-numeric 'story', which the
8436
+ // legacy rule would read as trusted. A failed fetch is untrusted on its own evidence.
8437
+ let runFetchFailed = false;
8438
+ // The run's REAL story id, for the provenance rule. `storyId` above falls back to 'story' for
8439
+ // branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
8440
+ // never arrived.
8441
+ let runStoryId = null;
8019
8442
  try {
8020
- const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
8443
+ const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
8021
8444
  (opts.attempt ? `?attempt=${opts.attempt}` : '');
8022
8445
  const r = await client.get(url);
8446
+ // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
8447
+ if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
8023
8448
  output = r.data?.output;
8024
8449
  try {
8025
- const runR = await client.get(`/orchestrate/${workflowId}`);
8026
- storyId = runR.data?.storyId || runR.data?.story_id || 'story';
8450
+ const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
8451
+ // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
8452
+ // is not THIS run does not count as its config.
8453
+ if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
8454
+ runStoryId = runR.data?.storyId || runR.data?.story_id || null;
8455
+ storyId = runStoryId || 'story';
8027
8456
  runConfig = runR.data?.config || {};
8028
- } catch { /* naming falls back to 'story'; command falls back to make ci */ }
8457
+ } catch (e) { runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; /* naming falls back to 'story'; command falls back to make ci */ }
8029
8458
  } catch (e) {
8030
8459
  const msg = e.response?.data?.error || e.message;
8031
8460
  emit({ ok: false, reason: 'fetch_failed', detail: msg },
@@ -8033,9 +8462,41 @@ program
8033
8462
  process.exit(EXIT.NO_DIFF);
8034
8463
  }
8035
8464
 
8465
+ // ── HC-GF-270: the provenance gate (see verify-patch's copy for the full argument) ─────
8466
+ //
8467
+ // emit-pr runs `--verify` by default and creates a branch, so it is a second execution
8468
+ // surface and a second publishing surface for the same untrusted text. Its own guard, so
8469
+ // its own mutation kills its own test (Checklist B item 6).
8470
+ {
8471
+ let assessment;
8472
+ try {
8473
+ const { assessStorySource } = require('./lib/story-provenance');
8474
+ assessment = runFetchFailed
8475
+ ? { trusted: false, label: 'run-fetch-failed',
8476
+ reason: runFetchFailed === 'mismatch'
8477
+ ? 'the server answered for a different run, so this run\'s config was never read'
8478
+ : 'this run\'s config could not be read, so nothing is known about where its story text came from' }
8479
+ : assessStorySource(runConfig?.story_source, runStoryId);
8480
+ } catch (e) {
8481
+ assessment = { trusted: false, label: 'assessment-failed',
8482
+ reason: `story provenance could not be assessed: ${e.message}` };
8483
+ }
8484
+ if (!assessment.trusted && !opts.trustSource) {
8485
+ emit({ ok: false, reason: 'untrusted_source', detail: assessment.reason },
8486
+ `✗ refusing to emit: ${assessment.reason}\n`
8487
+ + ' No branch was created and nothing was executed.\n'
8488
+ + ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
8489
+ + ` Then: hone emit-pr ${workflowId} --trust-source`);
8490
+ process.exit(EXIT.UNTRUSTED_SOURCE);
8491
+ }
8492
+ }
8493
+
8036
8494
  // 2. Materialize the diff (shared with check-patch/verify-patch,
8037
8495
  // followup-23/27). Condition 7 presupposes 5.
8038
8496
  const blocks = extractFileBlocks(output);
8497
+ // HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
8498
+ // silently re-scoping is how a finished build came back as "no file changes to verify".
8499
+ if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
8039
8500
  const hasWork = hasMaterializableWork(blocks);
8040
8501
  if (blocks.noChanges || !hasWork) {
8041
8502
  emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
@@ -8101,9 +8562,11 @@ program
8101
8562
 
8102
8563
  // 4. Isolated worktree with a NAMED branch off HEAD (adopter tree untouched).
8103
8564
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
8104
- const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
8565
+ // HC-GF-304: see verify-patch. emit-pr would COMMIT the other invocation's patch onto this run's branch.
8566
+ const diffTmp = require('./lib/private-temp').privateTempFile('hone-emit-diff', 'patch.diff');
8567
+ const diffFile = diffTmp.file;
8105
8568
  fs.writeFileSync(diffFile, diff);
8106
- const { command, source: commandSource } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess
8569
+ const { command, source: commandSource, replaced: commandReplaced } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess; HC-GF-223: `replaced` says what an override skipped
8107
8570
  // `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
8108
8571
  // before a successful commit (apply fails, verify fails), that empty branch
8109
8572
  // (pointing at HEAD) must be torn down too — else "a failing diff never
@@ -8120,7 +8583,7 @@ program
8120
8583
  try { execFileSync('git', ['branch', '-D', branch], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
8121
8584
  }
8122
8585
  try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
8123
- try { fs.unlinkSync(diffFile); } catch { /* best-effort */ }
8586
+ diffTmp.remove();
8124
8587
  };
8125
8588
  process.on('SIGINT', () => { cleanup(); process.exit(130); });
8126
8589
 
@@ -8151,23 +8614,48 @@ program
8151
8614
  // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
8152
8615
  // (link the repo's node_modules when the lockfile is unchanged, else run the
8153
8616
  // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
8617
+ // HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
8618
+ // and with --no-provision there is none, which is not the same as a failure.
8619
+ let provAll = null;
8154
8620
  if (opts.provision !== false) {
8155
- const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
8621
+ const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
8156
8622
  const { readCIConfigDetails } = require('./lib/pipeline-config');
8157
- const plan = planProvision({ worktreeDir: wt, parentDir: process.cwd(), installCommand: readCIConfigDetails(process.cwd()).install_command });
8158
- const prov = provisionWorktree(plan, { worktreeDir: wt, exec: spawnSync });
8623
+ // HC-GF-201: provision EVERY package root, not just the worktree's top level.
8624
+ // This repo has three; the two skipped are where ci.unit runs, so their absent
8625
+ // modules surfaced as `Cannot find module` and were counted as the PATCH failing
8626
+ // tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
8627
+ const installCmd = readCIConfigDetails(process.cwd()).install_command;
8628
+ const provEntries = discoverPackageRoots(wt).map((root) => ({
8629
+ root,
8630
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
8631
+ exec: spawnSync,
8632
+ plan: planProvision({
8633
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
8634
+ parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
8635
+ installCommand: root === '.' ? installCmd : undefined,
8636
+ }),
8637
+ }));
8638
+ provAll = provisionAllRoots(provEntries);
8639
+ const prov = { ok: provAll.ok,
8640
+ summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
8641
+ + (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
8159
8642
  if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
8160
8643
  }
8161
8644
  if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
8162
8645
  const run = spawnSync(command, {
8163
8646
  cwd: wt, shell: true, encoding: 'utf8',
8164
- timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
8647
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8165
8648
  stdio: ['ignore', 'pipe', 'pipe'],
8166
8649
  });
8167
8650
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8168
- const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
8169
- if (v.verdict === 'pass') {
8170
- verifyState = 'pass';
8651
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
8652
+ // HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
8653
+ // narrowed verdict fell into the else below, which printed "verify failed, no branch
8654
+ // emitted" and then exited with that verdict's own exit code — zero. A failure path that
8655
+ // exits 0 is the HC-GF-207 shape this session opened by fixing.
8656
+ const emitState = verifyStateFor(v.verdict);
8657
+ if (emitState) {
8658
+ verifyState = emitState;
8171
8659
  } else {
8172
8660
  verifyOk = false;
8173
8661
  // HC-GF-008: a command that never started is not a failed verify.
@@ -8183,14 +8671,15 @@ program
8183
8671
  // 4c. Commit the applied diff onto the branch, inside the worktree.
8184
8672
  // No summary source (GET /orchestrate omits config/story text), so the
8185
8673
  // commit subject falls back to the storyId + default in buildCommitMessage.
8186
- const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState });
8187
- const msgFile = path.join(os.tmpdir(), `hone-emit-msg-${Date.now()}.txt`);
8674
+ const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState, replacedCommand: commandReplaced && commandReplaced.command });
8675
+ const msgTmp = require('./lib/private-temp').privateTempFile('hone-emit-msg', 'message.txt');
8676
+ const msgFile = msgTmp.file;
8188
8677
  fs.writeFileSync(msgFile, message);
8189
8678
  try {
8190
8679
  execFileSync('git', ['-C', wt, 'add', '-A'], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
8191
8680
  execFileSync('git', ['-C', wt, 'commit', '-F', msgFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
8192
8681
  committed = true; // the branch now holds the deliverable — keep it past cleanup
8193
- } finally { try { fs.unlinkSync(msgFile); } catch { /* best-effort */ } }
8682
+ } finally { msgTmp.remove(); }
8194
8683
 
8195
8684
  const stat = (() => {
8196
8685
  try {
@@ -8214,8 +8703,9 @@ program
8214
8703
  }
8215
8704
  }
8216
8705
  if (pushed && doOpenPr) {
8217
- const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined });
8218
- const bodyFile = path.join(os.tmpdir(), `hone-emit-prbody-${Date.now()}.md`);
8706
+ const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined, replacedCommand: commandReplaced && commandReplaced.command });
8707
+ const bodyTmp = require('./lib/private-temp').privateTempFile('hone-emit-prbody', 'body.md');
8708
+ const bodyFile = bodyTmp.file;
8219
8709
  fs.writeFileSync(bodyFile, prBody);
8220
8710
  const title = `${storyId}: Hone step_4 change (workflow ${String(workflowId).slice(0, 8)})`;
8221
8711
  const ghArgs = ['pr', 'create', '--head', branch, '--title', title, '--body-file', bodyFile];
@@ -8229,7 +8719,7 @@ program
8229
8719
  emit({ ok: false, verdict: 'publish_failed', stage: 'open_pr', detail, branch },
8230
8720
  `✗ branch pushed, but opening the PR failed: ${detail}\n → open it manually, or check \`gh auth status\`.`);
8231
8721
  exitCode = EXIT.PUBLISH_FAILED;
8232
- } finally { try { fs.unlinkSync(bodyFile); } catch { /* best-effort */ } }
8722
+ } finally { bodyTmp.remove(); }
8233
8723
  }
8234
8724
 
8235
8725
  if (exitCode === EXIT.PASS) {
@@ -8277,6 +8767,17 @@ program
8277
8767
  .option('--format <fmt>', 'pretty or json', 'pretty')
8278
8768
  .action(async (prOrBranch, opts) => {
8279
8769
  const { execFileSync, spawnSync } = require('child_process');
8770
+ // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8771
+ // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
8772
+ // deep in child_process, and a blank value coerces to 0 and kills the
8773
+ // command the instant it starts.
8774
+ let timeoutMs;
8775
+ try {
8776
+ timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
8777
+ } catch (e) {
8778
+ console.error(e.message);
8779
+ process.exit(2);
8780
+ }
8280
8781
  const { readCIGateConfig } = require('./lib/pipeline-config');
8281
8782
  const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
8282
8783
  const { EXIT, resolveGateMode, interpretGhChecks, ciGateVerdict, renderStep5cArtifact } = require('./lib/verify-pr');
@@ -8298,15 +8799,19 @@ program
8298
8799
  const mode = resolveGateMode(opts.mode, configGate);
8299
8800
 
8300
8801
  // 3. Skill audit — reuse the working `hone step-5b` command (deterministic
8301
- // SA-001 engine over the PR diff). Informational; never affects the exit.
8802
+ // SA-001 engine over the PR diff). HC-GF-227: its BLOCKER verdict (exit 2) fails this command
8803
+ // too, once the CI gate itself has passed; any other failure to run it is reported, not a verdict.
8302
8804
  let skillAudit = { ran: false };
8303
8805
  if (!opts.skipSkillAudit) {
8806
+ const artifact = `.github/pipeline/${storyId}/step-5b-skill-audit.md`;
8304
8807
  try {
8305
8808
  execFileSync(process.execPath, [__filename, 'step-5b', '--story-id', storyId, '--base', opts.base],
8306
8809
  { cwd: repoRoot, env: process.env, stdio: opts.format === 'json' ? 'ignore' : 'inherit' });
8307
- skillAudit = { ran: true, artifact: `.github/pipeline/${storyId}/step-5b-skill-audit.md` };
8810
+ skillAudit = { ran: true, artifact };
8308
8811
  } catch (e) {
8309
- skillAudit = { ran: true, error: (e.message || '').split('\n')[0] };
8812
+ skillAudit = e.status === STEP_5B_BLOCKED
8813
+ ? { ran: true, blocked: true, artifact }
8814
+ : { ran: true, error: (e.message || '').split('\n')[0] };
8310
8815
  }
8311
8816
  }
8312
8817
 
@@ -8337,7 +8842,7 @@ program
8337
8842
  if (opts.format !== 'json') console.log(` running local CI: ${command} (timeout ${opts.timeout}s)`);
8338
8843
  // HC-GF-008: the adopter's own command, without make state inherited from whoever ran hone.
8339
8844
  const { verifyCommandEnv } = require('./lib/verify-patch');
8340
- const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: Number(opts.timeout) * 1000, stdio: ['ignore', 'inherit', 'inherit'], env: verifyCommandEnv(gitEnv()) });
8845
+ const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'inherit', 'inherit'], env: verifyCommandEnv(gitEnv()) });
8341
8846
  local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
8342
8847
  }
8343
8848
 
@@ -8377,14 +8882,16 @@ program
8377
8882
  }
8378
8883
  }
8379
8884
 
8380
- // 7. Report. The exit code is the CI verdict's; the skill audit is advisory.
8885
+ // 7. Report. A failing CI gate keeps its own exit code; when CI passed, a BLOCKER skill assertion
8886
+ // fails the command with EXIT.SKILL_BLOCKER (HC-GF-227). The report sent above is the CI gate's.
8887
+ const exitCode = verdict.exitCode === EXIT.PASS && skillAudit.blocked ? EXIT.SKILL_BLOCKER : verdict.exitCode;
8381
8888
  const sym = (verdict.verdict === 'pass' || verdict.verdict === 'disabled') ? '✓' : verdict.verdict === 'pending' ? '⏳' : '✗';
8382
8889
  emit(
8383
- { ok: verdict.exitCode === EXIT.PASS, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
8890
+ { ok: exitCode === EXIT.PASS, exitCode, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
8384
8891
  `${sym} CI gate (${mode}): ${verdict.verdict} — ${verdict.detail}\n` +
8385
- ` skill audit: ${opts.skipSkillAudit ? 'skipped' : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
8892
+ ` skill audit: ${opts.skipSkillAudit ? 'skipped' : skillAudit.blocked ? `✗ BLOCKER finding(s) — ${skillAudit.artifact}` : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
8386
8893
  ` → .github/pipeline/${storyId}/step-5c-ci.md`);
8387
- process.exit(verdict.exitCode);
8894
+ process.exit(exitCode);
8388
8895
  });
8389
8896
 
8390
8897
  // ── CLI setup ─────────────────────────────────────────────────────────────────