@hone-ai/cli 1.34.0 → 1.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/hone-mcp.js +1 -0
  2. package/hone-cli.js +1341 -795
  3. package/lib/agent-eval-pacing.js +102 -0
  4. package/lib/agent-eval-probes-boundary.js +0 -0
  5. package/lib/agent-eval-probes-stateful.js +72 -0
  6. package/lib/agent-eval-score.js +70 -0
  7. package/lib/agent-executor.js +26 -4
  8. package/lib/asset-fetch-list.js +72 -0
  9. package/lib/auto-verify.js +67 -3
  10. package/lib/batch-follow.js +333 -0
  11. package/lib/block-grammar.js +126 -0
  12. package/lib/bundle-context.js +43 -4
  13. package/lib/bundle-paths.js +698 -1
  14. package/lib/bundle-source-dirs.js +79 -0
  15. package/lib/doctor-bind-default.js +192 -18
  16. package/lib/doctor-local-run.js +139 -1
  17. package/lib/emit-pr.js +39 -6
  18. package/lib/git-helpers.js +19 -8
  19. package/lib/hard-pause-remedy.js +101 -0
  20. package/lib/include-paths.js +155 -0
  21. package/lib/learnings-parse.js +97 -7
  22. package/lib/materialize-diff.js +131 -73
  23. package/lib/mcp-tools.js +20 -14
  24. package/lib/pipeline-config.js +182 -0
  25. package/lib/private-temp.js +52 -0
  26. package/lib/promote-payload.js +77 -0
  27. package/lib/refresh-knowledge.js +15 -6
  28. package/lib/repo-tree.js +22 -3
  29. package/lib/run-context.js +698 -0
  30. package/lib/run-target.js +66 -1
  31. package/lib/sandbox-lifecycle.js +357 -0
  32. package/lib/schedule-cron.js +1 -1
  33. package/lib/skill-assertions.js +61 -7
  34. package/lib/skill-audit-render.js +1 -1
  35. package/lib/story-classifier-extract.js +16 -8
  36. package/lib/story-classifier.js +64 -0
  37. package/lib/story-compose.js +69 -0
  38. package/lib/story-line.js +40 -0
  39. package/lib/story-provenance.js +267 -0
  40. package/lib/timeout-option.js +42 -0
  41. package/lib/untrusted-code-env.js +51 -0
  42. package/lib/verdict-env.js +31 -0
  43. package/lib/verify-patch.js +74 -4
  44. package/lib/verify-pr.js +2 -1
  45. package/lib/worktree-provision.js +62 -2
  46. package/package.json +1 -1
package/hone-cli.js CHANGED
@@ -25,10 +25,21 @@ 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');
42
+ const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
32
43
  const program = new Command();
33
44
 
34
45
  // ── Config resolution ─────────────────────────────────────────────────────────
@@ -41,6 +52,48 @@ function readRc() {
41
52
  return {};
42
53
  }
43
54
 
55
+ /**
56
+ * Resolve the Hone-team admin key, or stop with the instruction for supplying it.
57
+ *
58
+ * HC-GF-239: `hone absorb` is the second command to need this, and the second copy of the same
59
+ * six lines is how the two drift. The key is read from the environment or ~/.honerc and NEVER
60
+ * taken as an argument — an argument lands in shell history, and this is a production key.
61
+ */
62
+ function requireAdminKey() {
63
+ const adminKey = process.env.HONE_ADMIN_KEY || readRc().admin_key;
64
+ if (!adminKey) {
65
+ console.error('Error: Admin key not found.');
66
+ console.error('Set HONE_ADMIN_KEY env var, or add "admin_key" to ~/.honerc');
67
+ process.exit(1);
68
+ }
69
+ return adminKey;
70
+ }
71
+
72
+ /**
73
+ * HC-GF-270-followup-1: refuse a caller-supplied run id, batch id, step key or attempt that is not one,
74
+ * with exit 2. Called as the FIRST statement of a verb's action, before getConfig() or api(), so a
75
+ * refusal makes no request, reads no ~/.honerc, and runs no probe, gh call or CI command.
76
+ */
77
+ function refuseBadRunArgs(checks) {
78
+ const msg = runArgsError(checks);
79
+ if (msg) {
80
+ console.error(msg);
81
+ process.exit(2);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The arguments check-patch, verify-patch and emit-pr turn into a step-output URL. HC-GF-270 (second
87
+ * review MEDIUM-1): the step key goes into a URL and a file name, and URL resolution collapses `..` —
88
+ * `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's patch while THIS run's config was
89
+ * assessed. A step key is a step key, a run id is a run id, and an attempt is a whole number.
90
+ */
91
+ const stepFetchChecks = (workflowId, opts) => [
92
+ { label: '<workflowId>', kind: 'runId', value: workflowId },
93
+ { label: '--step-key', kind: 'stepKey', value: opts.stepKey },
94
+ { label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
95
+ ];
96
+
44
97
  function getConfig() {
45
98
  // Priority: env var → ~/.honerc → error
46
99
  const rc = readRc();
@@ -152,19 +205,35 @@ process.on('exit', () => {
152
205
  program
153
206
  .command('agent-eval')
154
207
  .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')
208
+ .option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary|stateful)', 'adversarial')
156
209
  .option('--target <name>', 'Run against a specific agent target (default: all configured)')
157
210
  .option('--output <format>', 'Output format (text|json)', 'text')
158
211
  .option('--judge', 'Add the free NLI judge layer (faithfulness only): claims are checked for entailment against the probe context')
159
212
  .option('--provider <name>', 'Judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
160
213
  .option('--report', 'Report the agent-eval verdict to the run (advisory) — needs --workflow-id')
161
214
  .option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
215
+ .option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
162
216
  .action(async (opts) => {
217
+ // HC-GF-270-followup-1: a --workflow-id is checked before any probe spends provider quota.
218
+ refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
163
219
  const { readAgentEvalConfig } = require('./lib/pipeline-config');
164
220
  const { runAgent } = require('./lib/agent-executor');
165
221
  const { runCheck } = require('./lib/eval-graders');
222
+ const { scoreProbe, summarizeProbes, describeProbeSummary } = require('./lib/agent-eval-score');
166
223
  const config = readAgentEvalConfig(process.cwd());
167
224
 
225
+ // HC-GF-197: validated, not coerced. `Number('abc')` is NaN, NaN is NOT nullish so the
226
+ // executor's `?? 5000` default never applies, and `setTimeout(NaN)` fires in 1ms — so a typo
227
+ // in this flag times out every probe instantly and reports them as WRONG ANSWERS. That is the
228
+ // defect HC-GF-198 exists to fix, reachable by mistyping a flag.
229
+ let probeTimeoutMs;
230
+ try {
231
+ probeTimeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
232
+ } catch (e) {
233
+ console.error(e.message);
234
+ process.exit(2);
235
+ }
236
+
168
237
  if (!config.targets || config.targets.length === 0) {
169
238
  console.log('No agent targets configured in .pipeline-config.yml (agent_eval.targets)');
170
239
  process.exit(0);
@@ -175,6 +244,9 @@ program
175
244
  faithfulness: './lib/agent-eval-probes-faithfulness',
176
245
  safety: './lib/agent-eval-probes-safety',
177
246
  boundary: './lib/agent-eval-probes-boundary',
247
+ // HC-GF-197: calibration, not safety. The other four packs ask whether an agent misbehaves;
248
+ // this one asks whether ONE judgment is right, scored against stories the owner labelled.
249
+ stateful: './lib/agent-eval-probes-stateful',
178
250
  };
179
251
  let probes;
180
252
  if (PROBE_PACKS[opts.category]) {
@@ -208,38 +280,85 @@ program
208
280
 
209
281
  const results = [];
210
282
  let anyFailed = false;
283
+ // HC-GF-199: pace from what the provider DOES, and run the probes where
284
+ // their writes cannot reach this repository.
285
+ const { createPacer } = require('./lib/agent-eval-pacing');
286
+ const pacer = createPacer();
287
+ const probeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-agent-eval-'));
288
+ let stopped = null;
289
+ let attempted = 0;
290
+
211
291
  for (const target of targets) {
212
292
  for (const probe of probes) {
213
- const run = await runAgent(target, probe.input);
293
+ if (stopped) break;
294
+ attempted += 1;
295
+ const wait = pacer.delayBeforeNext();
296
+ if (wait > 0) {
297
+ // Say it. A run that quietly slows down looks identical to a fast one,
298
+ // and that is how a degraded provider reads as a clean measurement.
299
+ if (opts.output !== 'json') {
300
+ console.log(` pacing: waiting ${Math.round(wait / 1000)}s — the provider refused the previous probe`);
301
+ }
302
+ await new Promise((r) => setTimeout(r, wait));
303
+ pacer.noteWaited(wait);
304
+ }
305
+ // HC-GF-197: the default is 5s, sized for the deterministic packs — a canary string or a
306
+ // refusal comes back immediately. A calibration probe asks for a real grooming, which takes
307
+ // tens of seconds, so every one of them timed out and scored as a failed judgment. A
308
+ // verdict produced by a timeout is not a verdict about the agent.
309
+ const run = await runAgent(target, probe.input, { timeout: probeTimeoutMs, cwd: probeCwd, repoRoot: process.cwd() });
214
310
  const output = run && typeof run.output === 'string' ? run.output : '';
215
311
  const graders = probe.graders.map((g) => runCheck(output, g));
216
- let passed = !run.error && graders.every((r) => r.passed);
312
+ // HC-GF-198: three outcomes. A probe that never answered is not one that answered wrongly,
313
+ // and scoring them alike reported 4/8 for a run where 4 of the 5 that COMPLETED were right.
314
+ const scored = scoreProbe(run, graders);
315
+ let { outcome } = scored;
316
+ let passed = scored.passed;
217
317
 
218
318
  // Judge layer: only for grounded probes (those with a context to entail against).
219
319
  let judge = null;
220
- if (judgeCall && probe.context) {
320
+ if (judgeCall && probe.context && outcome !== 'error') {
221
321
  const { judgeFaithfulness } = require('./lib/agent-eval-judge');
222
322
  judge = await judgeFaithfulness({ output, context: probe.context, callLLM: judgeCall });
223
- passed = passed && judge.passed;
323
+ if (!judge.passed) { passed = false; outcome = 'fail'; }
224
324
  }
225
325
  if (!passed) anyFailed = true;
226
- results.push({ target: target.name, probe: probe.id, passed, error: run.error || null, graders, judge });
326
+ results.push({ target: target.name, probe: probe.id, outcome, passed, error: run.error || null,
327
+ stderr: run.stderr || null, graders, judge });
328
+
329
+ pacer.record({ outcome });
330
+ stopped = pacer.stopReason();
227
331
  }
228
332
  }
229
333
 
334
+ try { fs.rmSync(probeCwd, { recursive: true, force: true }); } catch { /* best-effort */ }
335
+
336
+ const summary = summarizeProbes(results);
337
+ // HC-GF-199: a run that gave up must SAY so, and say how many probes it never
338
+ // reached. Reporting only what completed is how a dead run reads as a fast one.
339
+ const pacing = { ...pacer.summary(), stopped, attempted, planned: targets.length * probes.length };
340
+ if (stopped) {
341
+ anyFailed = true;
342
+ if (opts.output !== 'json') {
343
+ console.error(`\n⊘ run STOPPED: ${stopped}`);
344
+ console.error(` ${attempted} of ${pacing.planned} probes attempted — the rest were never run.`);
345
+ }
346
+ }
230
347
  if (opts.output === 'json') {
231
- console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, results }, null, 2));
348
+ console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, summary, pacing, results }, null, 2));
232
349
  } else {
233
350
  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) {
351
+ // HC-GF-198: three marks, because there are three outcomes. `✗` for a wrong answer and
352
+ // `⊘` for one that never came — printing both as `✗` is what made 3 timeouts read as 3
353
+ // wrong answers.
354
+ const mark = r.outcome === 'pass' ? '✓' : (r.outcome === 'error' ? '⊘' : '✗');
355
+ console.log(` ${mark} ${r.target} / ${r.probe}${r.error ? ` (no answer: ${r.error})` : ''}`);
356
+ if (r.outcome === 'fail') {
237
357
  for (const g of r.graders.filter((x) => !x.passed)) console.log(` - ${g.type}: ${g.detail}`);
238
358
  if (r.judge && !r.judge.passed) console.log(` - judge: ${r.judge.detail}`);
239
359
  }
240
360
  }
241
- const pass = results.filter((r) => r.passed).length;
242
- console.log(`\n${pass}/${results.length} probes passed (category: ${opts.category})`);
361
+ console.log(`\n${describeProbeSummary(summary)} (category: ${opts.category})`);
243
362
  }
244
363
 
245
364
  // Closed loop (HC-COMM-011-followup-5): report the verdict to the run. agent-eval
@@ -252,7 +371,7 @@ program
252
371
  } else {
253
372
  try {
254
373
  const client = api(getConfig());
255
- await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
374
+ await client.post(orchestratePath([opts.workflowId, 'verdict']), {
256
375
  source: 'agent-eval',
257
376
  stepKey: 'step_4',
258
377
  verdict: anyFailed ? 'fail' : 'pass',
@@ -588,22 +707,31 @@ program
588
707
 
589
708
  // Build parallel fetch list
590
709
  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) => {
710
+ // Agent stubs and skill files. HC-GF-136's gate found `key`, `promise` and `write` here
711
+ // computed and asserted by nothing; the mapping is now a pure function in cli/lib. The REST
712
+ // of this list is untouched — an earlier attempt replaced the whole array and silently
713
+ // dropped ai-reviewer.js, check-learnings-gate.js and the ESM package.json, which would have
714
+ // broken setup for every adopter. The regression suite caught it.
715
+ ...assetFetchList({
716
+ agents, skills,
717
+ // HC-GF-184: also pull whatever derive produced for THIS repo. Same basename rule the
718
+ // other commands use (`hone sync`, `hone derive`), so the three agree on the repo's name.
719
+ //
720
+ // `process.cwd()` directly, NOT `repoRoot`: the only `repoRoot` bindings near here are in
721
+ // ANOTHER command's action and inside `if (opts.refresh)`, which returns long before this
722
+ // line. Referencing it threw `ReferenceError` on the normal setup path — for every
723
+ // adopter — while `node --check` passed and the unit tests, which drive assetFetchList
724
+ // directly, never evaluated it.
725
+ derivedRepo: path.basename(process.cwd()),
726
+ get: (url) => client.get(url).then(r => r.data),
727
+ writeAgent: (agent, data) =>
728
+ fs.writeFileSync(path.join(agentsDir, `${agent}.agent.md`), data),
729
+ writeSkill: (skill, data) => {
602
730
  const skillDir = path.join(skillsDir, skill);
603
731
  fs.mkdirSync(skillDir, { recursive: true });
604
732
  fs.writeFileSync(path.join(skillDir, 'SKILL.md'), data);
605
733
  },
606
- })),
734
+ }),
607
735
  // Copilot instructions
608
736
  {
609
737
  key: 'copilot-instructions.md',
@@ -994,6 +1122,27 @@ program
994
1122
 
995
1123
  // ── Test framework installation helpers ────────────────────────────────────────
996
1124
 
1125
+ /**
1126
+ * HC-GF-190: what the sandbox WOULD have done, for the paths that end before it is asked.
1127
+ *
1128
+ * Builds the plan the run never got to and describes it. `planSandboxRun` is pure — it runs no
1129
+ * commands — so this is safe on an early exit, and it is the only way an operator can tell a
1130
+ * sandbox that was SKIPPED from one that was never configured.
1131
+ */
1132
+ function describeSandboxDisposition(runConfig) {
1133
+ try {
1134
+ const { readSandboxConfig } = require('./lib/pipeline-config');
1135
+ const { planSandboxRun, describeNotReached } = require('./lib/sandbox-lifecycle');
1136
+ return describeNotReached(planSandboxRun({
1137
+ sandbox: readSandboxConfig(process.cwd()),
1138
+ axes: runConfig?.classifier_axes || runConfig?.axes || [],
1139
+ capability: runConfig?.capability || null,
1140
+ }));
1141
+ } catch {
1142
+ return null; // reporting must never be the thing that breaks a run
1143
+ }
1144
+ }
1145
+
997
1146
  function detectExistingTestFramework(repoRoot) {
998
1147
  const pkgPath = path.join(repoRoot, 'package.json');
999
1148
  if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
@@ -1311,7 +1460,14 @@ async function installTestFramework(repoRoot, opts) {
1311
1460
  console.log('');
1312
1461
  console.log('── Verification ────────────────────────────────────────');
1313
1462
  try {
1314
- execSync('npm test 2>&1', { cwd: repoRoot, stdio: 'inherit', timeout: 30_000 });
1463
+ // HC-GF-144: `node` exports NODE_TEST_CONTEXT to the processes IT spawns, and this repo's
1464
+ // regression suites spawn the CLI. Inherited, it makes a FAILING `node --test` exit 0 — so
1465
+ // this verification would print "npm test passed" for a red repository, which is the one
1466
+ // thing it exists not to do. Only the verdict-flipping variables are removed; the adopter's
1467
+ // toolchain environment is otherwise passed through untouched.
1468
+ execSync('npm test 2>&1', {
1469
+ cwd: repoRoot, stdio: 'inherit', timeout: 30_000, env: verdictEnv(),
1470
+ });
1315
1471
  console.log(' ✓ npm test passed');
1316
1472
  } catch {
1317
1473
  console.log(' ⚠ npm test had issues — review test output above');
@@ -1477,15 +1633,42 @@ program
1477
1633
  const maxFiles = parseInt(opts.maxFiles);
1478
1634
  let fileCount = 0;
1479
1635
 
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;
1636
+ // HC-GF-178: allocate the cap ACROSS the declared dirs. The previous loop drained them in
1637
+ // order and `break`-ed at the cap, so `cli/` (142 files vs a default of 30) filled it alone and
1638
+ // server/scripts/enterprise-assets/.github were never read — while the output still said
1639
+ // "Bundling 30 source files...". The derive agent graded the repo on a fifth of it and said so;
1640
+ // the CLI said nothing.
1641
+ const { planBundle } = require('./lib/bundle-source-dirs');
1642
+ const perDirFiles = new Map();
1643
+ const plan = planBundle({
1644
+ sourceDirs,
1645
+ maxFiles,
1646
+ listFiles: (dir) => {
1647
+ const dirPath = path.join(repoRoot, dir.replace(/"/g, ''));
1648
+ if (!fs.existsSync(dirPath)) return [];
1649
+ // COST, stated rather than hidden: this reads every bundlable file in the directory to
1650
+ // plan a selection of a few. On this repo that is ~1,400 files (each capped at 50,000
1651
+ // chars by bundleDirectory) to choose 100, and a derive run still completes in ~90s. It is
1652
+ // the honest shape — you cannot allocate a budget across sources without knowing what each
1653
+ // holds — but on a very large monorepo it is the first thing to optimise, by separating
1654
+ // listing from reading. Filed as a follow-up rather than guessed at now.
1655
+ const collected = {};
1656
+ bundleDirectory(dirPath, repoRoot, collected, Number.MAX_SAFE_INTEGER, 0);
1657
+ perDirFiles.set(dir, collected);
1658
+ return Object.keys(collected);
1659
+ },
1660
+ });
1661
+ for (const rel of plan.selected) {
1662
+ for (const collected of perDirFiles.values()) {
1663
+ if (Object.prototype.hasOwnProperty.call(collected, rel)) { files[rel] = collected[rel]; break; }
1664
+ }
1486
1665
  }
1666
+ fileCount = Object.keys(files).length;
1487
1667
 
1488
1668
  console.log(`Bundling ${Object.keys(files).length} source files...`);
1669
+ // Say what was left out. An unreported omission is what made this defect survive: the agent
1670
+ // could see the gap in its own input and the operator could not.
1671
+ for (const line of plan.dropped) console.log(` ⚠ not fully bundled — ${line}`);
1489
1672
 
1490
1673
  // 3. Bundle existing skills if refresh mode
1491
1674
  let existingSkills = {};
@@ -2226,14 +2409,8 @@ program
2226
2409
  .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
2227
2410
  .action(async (opts) => {
2228
2411
  const rc = readRc();
2229
- const adminKey = process.env.HONE_ADMIN_KEY || rc.admin_key;
2230
2412
  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
- }
2413
+ const adminKey = requireAdminKey();
2237
2414
 
2238
2415
  try {
2239
2416
  const { data } = await axios.get(`${apiUrl}/admin/usage`, {
@@ -2643,15 +2820,7 @@ program
2643
2820
  }
2644
2821
 
2645
2822
  // 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
- }));
2823
+ const payload = promotePayload(eligible);
2655
2824
 
2656
2825
  try {
2657
2826
  const r = await client.post('/learnings/promote', { learnings: payload });
@@ -2660,7 +2829,11 @@ program
2660
2829
  } catch (e) {
2661
2830
  console.error(`\nPromotion failed: ${e.response?.data?.error || e.message}`);
2662
2831
  console.error('Local files unchanged — safe to retry.');
2663
- return;
2832
+ // HC-GF-207: a failed promotion must be a failed command. Returning here exited 0, so
2833
+ // `make promote` and the auto-promote workflow went green on ten consecutive runs that
2834
+ // promoted nothing (HC-GF-236). A dry run and a cancelled prompt still exit 0 — those
2835
+ // are refusals, not failures.
2836
+ process.exit(1);
2664
2837
  }
2665
2838
 
2666
2839
  // 6. Update local YAML: status → promoted (per-schema, idempotent).
@@ -2670,23 +2843,220 @@ program
2670
2843
  if (!promotedByFile.has(l._filePath)) promotedByFile.set(l._filePath, []);
2671
2844
  promotedByFile.get(l._filePath).push(l.id);
2672
2845
  }
2846
+ // HC-GF-208: an UNCHANGED file is not a success. writeBackPromoted returns the
2847
+ // content untouched when it cannot flip a status; fs.writeFileSync then succeeds
2848
+ // on identical bytes, and this loop used to print the same tick either way — so
2849
+ // the log said 296 files flipped while the filesystem had 220. Eligibility already
2850
+ // excludes anything promoted, so for these ids "unchanged" can only mean failure:
2851
+ // there is no idempotent-rerun case to mistake it for.
2852
+ const unflipped = [];
2673
2853
  for (const [filePath, ids] of promotedByFile) {
2854
+ const fileName = path.basename(filePath);
2674
2855
  try {
2675
2856
  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`);
2857
+ const before = fs.readFileSync(filePath, 'utf8');
2858
+ const after = writeBackPromoted(before, schema, ids);
2859
+ if (after === before) {
2860
+ unflipped.push(fileName);
2861
+ 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`);
2862
+ continue;
2863
+ }
2864
+ fs.writeFileSync(filePath, after);
2865
+ console.log(` \u2713 ${fileName}: ${ids.length} learning(s) \u2192 promoted`);
2681
2866
  } catch (e) {
2682
- console.warn(` ⚠ ${path.basename(filePath)}: failed to update local file — ${e.message}`);
2867
+ unflipped.push(fileName);
2868
+ console.warn(` \u26a0 ${fileName}: failed to update local file \u2014 ${e.message}`);
2683
2869
  }
2684
2870
  }
2685
2871
 
2872
+ if (unflipped.length) {
2873
+ console.warn(`\n\u26a0 ${unflipped.length} file(s) were sent but NOT marked promoted locally.`);
2874
+ console.warn(' Their learnings remain eligible and will be re-sent on the next run.');
2875
+ }
2876
+
2686
2877
  console.log('\nDone. Commit the updated learnings files:');
2687
2878
  console.log(' git add .github/learnings/ && git commit -m "chore: promote learnings to enterprise"');
2688
2879
  });
2689
2880
 
2881
+ // ── PROMOTED command (HC-GF-210) ─────────────────────────────────────────────
2882
+ // The consumer for GET /learnings/promoted. HC-GF-159b built that route as "THE READER",
2883
+ // on the premise that promotion without read-back is a write-only counter — and then nothing
2884
+ // called it. Auditing the production queue on 2026-09-20 meant hand-rolling an HTTPS call,
2885
+ // and that audit found 203 of 736 rows carrying an EMPTY enterprise_summary, invisible for
2886
+ // five months because no shipped tool could show them. So the two counts in the footer are
2887
+ // the point of this command, not decoration: a promoted row with no summary, or none a run
2888
+ // can reach, was promoted into nothing, and only a read-back can say so.
2889
+ program
2890
+ .command('promoted')
2891
+ .description("List the learnings this org has promoted, with their content (HC-GF-210)")
2892
+ .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
2893
+ .option('--empty', 'Show only rows whose enterprise summary is missing or blank')
2894
+ .option('--unrouted', 'Show only rows that name no skill, prompt or script to reach')
2895
+ // HC-GF-238: absorbing a row removed it from this command, because the route filtered on one
2896
+ // status. The default is still `promoted` — nothing an existing caller sees changes — and these
2897
+ // two options are how an operator reads back what the team actually LANDED.
2898
+ .option('--status <status>', 'Lifecycle status: promoted (default) | absorbed | retracted | pending | overdue | all')
2899
+ .option('--all', 'Every status, whatever a row has reached — the same as --status all')
2900
+ .action(async (opts) => {
2901
+ const config = getConfig();
2902
+ const client = api(config);
2903
+ const status = opts.all ? 'all' : (opts.status || 'promoted');
2904
+ try {
2905
+ // The default request is byte-identical to the one that shipped — no query string at all —
2906
+ // so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
2907
+ // parameter appears only when the operator asked for a status other than the default.
2908
+ const qs = status === 'promoted' ? '' : `?status=${encodeURIComponent(status)}`;
2909
+ const { data } = await client.get(`/learnings/promoted${qs}`);
2910
+ const all = Array.isArray(data.learnings) ? data.learnings : [];
2911
+ // The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
2912
+ // router found no target; a row promoted before that story carries neither field at all.
2913
+ const isEmpty = (l) => !String(l.enterpriseSummary || '').trim();
2914
+ const isUnrouted = (l) => !String(l.targetSkill || '').trim()
2915
+ || String(l.targetKind || 'unrouted') === 'unrouted';
2916
+ const empties = all.filter(isEmpty);
2917
+ const unrouted = all.filter(isUnrouted);
2918
+ let shown = all;
2919
+ if (opts.empty) shown = shown.filter(isEmpty);
2920
+ if (opts.unrouted) shown = shown.filter(isUnrouted);
2921
+
2922
+ if (opts.format === 'json') {
2923
+ console.log(JSON.stringify({
2924
+ orgId: data.orgId,
2925
+ status,
2926
+ count: all.length,
2927
+ empty: empties.length,
2928
+ unrouted: unrouted.length,
2929
+ learnings: shown,
2930
+ }, null, 2));
2931
+ return;
2932
+ }
2933
+
2934
+ console.log(status === 'promoted' ? 'Hone AI — Promoted Learnings' : `Hone AI — Learnings (${status})`);
2935
+ console.log('================================');
2936
+ if (!all.length) {
2937
+ console.log(status === 'promoted'
2938
+ ? "\nNothing promoted yet. Send this repo's learnings with: hone promote"
2939
+ : `\nNo learnings with status '${status}'.`);
2940
+ return;
2941
+ }
2942
+ // ASCII on purpose, and read by column position: same reasoning as `hone workflows` (HC-GF-108).
2943
+ const clip = (s, w) => (s.length > w ? `${s.slice(0, w - 3)}...` : s);
2944
+ const day = (ts) => { const d = ts ? new Date(ts) : null; return d && !isNaN(d) ? d.toISOString().slice(0, 10) : '-'; };
2945
+ console.log('');
2946
+ // The STATUS column appears ONLY when the listing can hold more than one status. This table
2947
+ // is read by column position (HC-GF-108), so the default `hone promoted` keeps the exact
2948
+ // layout it shipped with and no existing reader shifts by a column.
2949
+ const mixed = status === 'all';
2950
+ console.log([
2951
+ ...(mixed ? ['STATUS'.padEnd(10)] : []),
2952
+ 'PROMOTED'.padEnd(11), 'TARGET'.padEnd(24), 'KIND'.padEnd(9), 'LEARNING',
2953
+ ].join(' '));
2954
+ for (const l of shown) {
2955
+ console.log([
2956
+ ...(mixed ? [String(l.status || '-').padEnd(10)] : []),
2957
+ day(l.promotedAt).padEnd(11),
2958
+ clip(String(l.targetSkill || '-'), 23).padEnd(24),
2959
+ String(l.targetKind || 'unrouted').padEnd(9),
2960
+ String(l.id || '-'),
2961
+ ].join(' '));
2962
+ const summary = String(l.enterpriseSummary || '').trim();
2963
+ console.log(` ${summary ? clip(summary.replace(/\s+/g, ' '), 100) : '(no enterprise summary — this row carries no lesson)'}`);
2964
+ // The destination is the whole reason `absorbed` is worth reading back: it names where
2965
+ // the lesson now lives, which is what makes the claim checkable rather than asserted.
2966
+ if (l.absorbedIn) console.log(` absorbed into: ${l.absorbedIn}${l.absorbedAt ? ` (${day(l.absorbedAt)})` : ''}`);
2967
+ }
2968
+ if (shown.length !== all.length) console.log(`\nShowing ${shown.length} of ${all.length}.`);
2969
+ console.log(`\n${all.length} promoted · ${empties.length} with no summary · ${unrouted.length} unrouted`);
2970
+ if (empties.length || unrouted.length) {
2971
+ console.log('A row with no summary, or none a run can reach, was promoted into nothing.');
2972
+ console.log('Inspect: hone promoted --empty · hone promoted --unrouted · Retract: hone retract <id> --reason "..."');
2973
+ }
2974
+ } catch (e) {
2975
+ // HC-GF-207, same class: a read that never happened is not an empty queue. Exit non-zero
2976
+ // so a caller cannot mistake a dead endpoint for "this org has promoted nothing".
2977
+ console.error(`Failed to read promoted learnings: ${e.response?.data?.error || e.message}`);
2978
+ process.exit(1);
2979
+ }
2980
+ });
2981
+
2982
+ // ── ABSORB command (HC-GF-239) ───────────────────────────────────────────────
2983
+ // The Hone-team half of the flywheel's outbox. `hone promote`, `hone retract` and `hone promoted`
2984
+ // are commands; absorbing — the act that ENDS a learning's life — was a hand-built curl with
2985
+ // ADMIN_KEY pasted on the command line, which is how a production key reaches shell history. It
2986
+ // is also the step most likely to be skipped, because it is the one with no affordance.
2987
+ //
2988
+ // The key is read from the environment or ~/.honerc, never from an argument (the `admin-usage`
2989
+ // precedent). `--in` is required by commander AND re-checked here, so a missing or blank
2990
+ // destination is refused BEFORE a request is built — an absorption with no destination is the
2991
+ // unverifiable claim this queue already holds hundreds of.
2992
+ program
2993
+ .command('absorb <ids...>')
2994
+ .description('Admin: mark promoted learnings absorbed, naming where the lesson now lives (HC-GF-239)')
2995
+ .requiredOption('--in <destination>', 'Where the lesson now lives: a skill section, prompt, gate script or PR')
2996
+ .option('--org <uuid>', "Org whose learnings to absorb (default: this CLI token's own org)")
2997
+ .option('--repo <name>', 'Restrict to one repository')
2998
+ .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
2999
+ .action(async (ids, opts) => {
3000
+ const destination = String(opts.in || '').trim();
3001
+ if (!destination) {
3002
+ console.error('Error: --in must name where the lesson now lives (a skill section, prompt, gate script or PR).');
3003
+ console.error('An absorption with no destination cannot be checked by anyone later.');
3004
+ process.exit(1);
3005
+ }
3006
+ const wanted = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))];
3007
+ if (!wanted.length) {
3008
+ console.error('Error: name at least one learning id to absorb.');
3009
+ process.exit(1);
3010
+ }
3011
+
3012
+ const adminKey = requireAdminKey();
3013
+ // `getConfig()` already resolves the base (HONE_API -> ~/.honerc -> default) and exits with a
3014
+ // usable message when no token is configured. Re-deriving it here would have added a TENTH
3015
+ // copy of the `api.hone.ai` literal that [[HC-GF-236]] is trying to reduce to one.
3016
+ const config = getConfig();
3017
+ const apiUrl = config.apiUrl;
3018
+
3019
+ try {
3020
+ // The org defaults to the one this CLI token already authenticates as, so the common case
3021
+ // (a Hone operator absorbing their own repo's learnings) needs no uuid on the command line.
3022
+ let orgId = opts.org;
3023
+ if (!orgId) {
3024
+ const { data } = await api(config).get('/learnings/promoted');
3025
+ orgId = data.orgId;
3026
+ }
3027
+
3028
+ const { data } = await axios.post(`${apiUrl}/admin/learnings/absorb`, {
3029
+ orgId, ids: wanted, repo: opts.repo, absorbedIn: destination,
3030
+ }, {
3031
+ headers: { 'x-admin-key': adminKey, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
3032
+ timeout: 15000,
3033
+ });
3034
+
3035
+ if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
3036
+
3037
+ console.log('');
3038
+ console.log('Hone AI — Absorb Learnings');
3039
+ console.log('================================');
3040
+ console.log(`Destination: ${data.absorbedIn}`);
3041
+ console.log('');
3042
+ for (const a of data.absorbed) console.log(` absorbed ${a.id}${a.repoName ? ` (${a.repoName})` : ''}`);
3043
+ // Skipped rows are printed, never summarised away: "3 of 5 absorbed" without the other two
3044
+ // is the shape of report this whole capability exists to stop producing.
3045
+ for (const sk of data.skipped) console.log(` skipped ${sk.id} — ${sk.reason}`);
3046
+ console.log('');
3047
+ console.log(`${data.count} absorbed · ${data.skipped.length} skipped`);
3048
+ if (data.count) console.log('Read them back with: hone promoted --status absorbed');
3049
+ if (data.skipped.length) process.exitCode = 1;
3050
+ } catch (e) {
3051
+ const status = e.response?.status;
3052
+ if (status === 401) console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
3053
+ else if (status === 429) console.error('Too many failed admin-auth attempts from this address. Wait for the window to pass.');
3054
+ else if (status === 503) console.error(`Server cannot absorb right now: ${e.response?.data?.error || 'unavailable'}`);
3055
+ else console.error(`Absorb failed: ${e.response?.data?.error || e.message}`);
3056
+ process.exit(1);
3057
+ }
3058
+ });
3059
+
2690
3060
  // ── RETRACT command (H-023) ──────────────────────────────────────────────────
2691
3061
  // Two modes:
2692
3062
  // 1. Single retract: `hone retract <LEARNING-ID> --reason "..."`
@@ -2718,23 +3088,25 @@ program
2718
3088
  console.log('No .github/learnings/ directory found — nothing to retract.');
2719
3089
  return;
2720
3090
  }
2721
- const yaml = require('js-yaml');
3091
+ // HC-GF-311-followup-1: per learning, in every schema `hone promote` reads — not just the file's top level.
3092
+ const { parseRetracted } = require('./lib/learnings-parse');
2722
3093
  const files = fs.readdirSync(learningsDir).filter(f => f.endsWith('.yml'));
2723
3094
  for (const file of files) {
2724
- try {
2725
- const parsed = yaml.load(fs.readFileSync(path.join(learningsDir, file), 'utf8'));
2726
- const entries = Array.isArray(parsed) ? parsed : [parsed];
2727
- for (const entry of entries) {
2728
- if (entry?.status === 'retracted' && entry.id) {
2729
- payload.push({
2730
- id: entry.id,
2731
- repo_name: repoName,
2732
- reason: entry.retraction_reason || 'Retracted (no reason provided in YAML)',
2733
- });
2734
- }
2735
- }
2736
- } catch (e) {
2737
- console.warn(` ⚠ ${file}: parse error — ${e.message}`);
3095
+ // One unreadable entry (a directory or a dangling link named *.yml) must not hide the rest.
3096
+ let content;
3097
+ try { content = fs.readFileSync(path.join(learningsDir, file), 'utf8'); }
3098
+ catch (e) { console.warn(` ⚠ ${file}: read error — ${e.message}`); continue; }
3099
+ const { retracted, parseError } = parseRetracted(content);
3100
+ if (parseError) {
3101
+ console.warn(` ⚠ ${file}: parse error — ${parseError.message}`);
3102
+ continue;
3103
+ }
3104
+ for (const { id, reason } of retracted) {
3105
+ payload.push({
3106
+ id,
3107
+ repo_name: repoName,
3108
+ reason: reason || 'Retracted (no reason provided in YAML)',
3109
+ });
2738
3110
  }
2739
3111
  }
2740
3112
  if (payload.length === 0) {
@@ -4613,10 +4985,19 @@ program
4613
4985
  }
4614
4986
  // Path B: GitHub issue fetch via gh
4615
4987
  else if (issueId) {
4988
+ // HC-GF-270-followup-1: an issue number or an issue URL, which are the forms gh accepts, and
4989
+ // nothing else. gh reads an argument that begins with `-` as a flag even without a shell.
4990
+ if (!/^[1-9]\d*$/.test(issueId) && !/^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/issues\/[1-9]\d*$/.test(issueId)) {
4991
+ console.error(`error: <issueId> must be an issue number like 142, or an issue URL (got ${JSON.stringify(issueId)})`);
4992
+ console.error('usage: hone classify-story 142');
4993
+ console.error(' hone classify-story --input-file inputs.yml');
4994
+ process.exit(2);
4995
+ }
4616
4996
  try {
4617
- const out = execSync(
4618
- `gh issue view ${issueId} --json title,body,labels,url`,
4619
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
4997
+ // An argument list, not a shell string, and a bound: a hung gh must not hang the command.
4998
+ const out = require('child_process').execFileSync(
4999
+ 'gh', ['issue', 'view', String(issueId), '--json', 'title,body,labels,url'],
5000
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }
4620
5001
  );
4621
5002
  issueMeta = JSON.parse(out);
4622
5003
  const labels = (issueMeta.labels || []).map(l => l.name);
@@ -4638,6 +5019,10 @@ program
4638
5019
  is_first_of_its_kind: false,
4639
5020
  };
4640
5021
  } catch (e) {
5022
+ if (e && e.code === 'ETIMEDOUT') {
5023
+ console.error(`error: gh issue view ${issueId} timed out after 30s`);
5024
+ process.exit(2);
5025
+ }
4641
5026
  console.error(`error: gh issue view ${issueId} failed: ${(e.stderr || '').toString().trim() || e.message}`);
4642
5027
  console.error(`hint: install GitHub CLI (cli.github.com), run \`gh auth login\`, or pass --input-file <path>`);
4643
5028
  process.exit(2);
@@ -4950,12 +5335,19 @@ program
4950
5335
 
4951
5336
  // audit runner. Shells out to `git diff origin/<base>...HEAD`, walks
4952
5337
  // `.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.
5338
+ // `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`. HC-GF-227: a BLOCKER
5339
+ // finding fails the command (exit 2, after the artifact is written); WARN and
5340
+ // INFO stay informational. The deferral this replaced named OptionsFlow's E29-G,
5341
+ // which no row in this roadmap owned.
5342
+ /** `hone step-5b`'s exit code when a BLOCKER assertion fired (HC-GF-227). Not 1: that is a crash. */
5343
+ const STEP_5B_BLOCKED = 2;
4955
5344
  program
4956
5345
  .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).')
5346
+ .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
5347
  .option('--base <branch>', 'base branch to diff against', 'develop')
5348
+ .option('--advisory', 'report BLOCKER findings without failing (exit 0), for a caller that wants only the artifact')
5349
+ .option('--all-skills', 'evaluate every skill, not only those the story\'s learnings reference (a repository-wide gate)')
5350
+ .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
5351
  .option('--story-id <id>', 'override story id (defaults to extraction from current branch name)')
4960
5352
  .action(async (cmdOpts) => {
4961
5353
  const { runAssertions } = require('./lib/skill-assertions');
@@ -4991,6 +5383,17 @@ program
4991
5383
  } catch { /* leave empty */ }
4992
5384
  }
4993
5385
 
5386
+ // 2b. HC-GF-310: the branch's commit messages, for `require_commit_line` assertions. A failed read
5387
+ // leaves them undefined, and the engine then warns instead of judging — never a silent pass.
5388
+ let commitMessages;
5389
+ for (const range of [`origin/${baseBranch}..HEAD`, `${baseBranch}..HEAD`]) {
5390
+ try {
5391
+ commitMessages = execSync(`git log --format=%B ${range}`,
5392
+ { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
5393
+ break;
5394
+ } catch { /* try the next range */ }
5395
+ }
5396
+
4994
5397
  // 3. Walk skills. Adopters live at .github/skills/; the hone-server
4995
5398
  // framework dogfoods against server/seeds/skills/ (same fallback as
4996
5399
  // `hone audit-skills`).
@@ -5028,7 +5431,8 @@ program
5028
5431
  const result = runAssertions({
5029
5432
  diff,
5030
5433
  skills,
5031
- referencedSkills: referencedSkills.length > 0 ? referencedSkills : undefined,
5434
+ referencedSkills: referencedSkills.length > 0 && !cmdOpts.allSkills ? referencedSkills : undefined,
5435
+ commitMessages,
5032
5436
  });
5033
5437
 
5034
5438
  // 6. Render + write artifact
@@ -5039,7 +5443,7 @@ program
5039
5443
  baseBranch,
5040
5444
  referencedSkills,
5041
5445
  });
5042
- const outDir = path.join(repoRoot, '.github/pipeline', storyId);
5446
+ const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : path.join(repoRoot, '.github/pipeline', storyId);
5043
5447
  fs.mkdirSync(outDir, { recursive: true });
5044
5448
  const outPath = path.join(outDir, 'step-5b-skill-audit.md');
5045
5449
  fs.writeFileSync(outPath, artifact);
@@ -5063,7 +5467,16 @@ program
5063
5467
  }, { BLOCKER: 0, WARN: 0, INFO: 0 }),
5064
5468
  }, null, 2));
5065
5469
 
5066
- // Always exit 0 — Step 5b is informational. E29-G owns severity-to-gate mapping.
5470
+ // HC-GF-227: a BLOCKER finding fails the audit. The skill's author chose the severity, so the
5471
+ // gate is theirs; WARN and INFO stay informational. Exit 2, not 1: node exits 1 when it crashes,
5472
+ // and a caller (verify-pr) must be able to tell a verdict from a failure to produce one.
5473
+ const blockers = result.findings.filter((f) => f.severity === 'BLOCKER');
5474
+ if (blockers.length && !cmdOpts.advisory) {
5475
+ console.error(`step-5b: ${blockers.length} BLOCKER finding(s): ` +
5476
+ blockers.map((f) => `${f.skillId}/${f.assertionId} at ${f.matchedFile}${f.matchedLine != null ? `:${f.matchedLine}` : ''}`).join('; ') +
5477
+ ` — see ${outPath.startsWith(repoRoot + path.sep) ? path.relative(repoRoot, outPath) : outPath}`);
5478
+ process.exit(STEP_5B_BLOCKED);
5479
+ }
5067
5480
  process.exit(0);
5068
5481
  });
5069
5482
 
@@ -5554,7 +5967,7 @@ program
5554
5967
  try {
5555
5968
  // HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
5556
5969
  // knows, so an older CLI — which sends nothing — keeps being served contract 1.
5557
- const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction?contracts=1,2`);
5970
+ const { data } = await client.get(orchestratePath([runId, 'step-instruction'], { contracts: '1,2' }));
5558
5971
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5559
5972
  console.log(`Step: ${data.stepKey} (${data.agent})`);
5560
5973
  console.log(`Contract: ${data.contractVersion}`);
@@ -5606,7 +6019,7 @@ program
5606
6019
  output = fs.readFileSync(opts.file, 'utf8');
5607
6020
  }
5608
6021
  const { data } = await client.post(
5609
- `/orchestrate/${encodeURIComponent(runId)}/step/${encodeURIComponent(stepKey)}/editor-output`,
6022
+ orchestratePath([runId, 'step', stepKey, 'editor-output']),
5610
6023
  { contractVersion: opts.contract, output }
5611
6024
  );
5612
6025
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
@@ -5622,6 +6035,100 @@ program
5622
6035
  }
5623
6036
  });
5624
6037
 
6038
+ // HC-GF-097: the I/O cli/lib/run-context.js is handed — every disk, git, GitHub and Hone read a
6039
+ // run's config needs, bound to this checkout. run-story and queue-stories build from the same one.
6040
+ function runContextIo(apiConfig) {
6041
+ const { gitEnv } = require('./lib/git-env');
6042
+ const pc = require('./lib/pipeline-config');
6043
+ const cwd = process.cwd();
6044
+ // Repository-scoped: a path that leaves the checkout neither exists nor reads.
6045
+ const inRepo = (rel) => {
6046
+ if (typeof rel !== 'string' || rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return null;
6047
+ const abs = path.resolve(cwd, rel);
6048
+ return abs.startsWith(cwd + path.sep) ? abs : null;
6049
+ };
6050
+ return {
6051
+ // Every subprocess this adapter starts carries a timeout: a hung git or gh must fail open with a
6052
+ // message, not stall the command (node-developer hard rule 5). The module turns each failure into
6053
+ // a note. (recentlyModifiedOverlap's `git log` lives in story-classifier-extract.js, unchanged.)
6054
+ listFiles: () => execSync('git ls-files', {
6055
+ cwd, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000,
6056
+ }).split('\n').filter(Boolean),
6057
+ sourceDirs: (trackedPaths) => pc.readSourceDirs(cwd, { trackedPaths }),
6058
+ codebaseDefaults: () => pc.readCodebaseDefaults(cwd),
6059
+ ciGate: () => pc.readCIGateConfig(cwd),
6060
+ executionMode: () => pc.readExecutionModeRaw(cwd),
6061
+ consentNote: pc.EDITOR_MODE_CONSENT_NOTE,
6062
+ ruleOverlays: () => require('./lib/overlay-transport').readRuleOverlays(cwd),
6063
+ e2eSpecMode: () => pc.readE2eSpecMode(cwd),
6064
+ verificationGate: () => pc.readVerificationGateConfig(cwd),
6065
+ verificationGateSources: () => pc.readVerificationGateSources(cwd),
6066
+ readPlanText: () => {
6067
+ const planPath = path.join(cwd, '.github', 'EXECUTION_PLAN.yml');
6068
+ return fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
6069
+ },
6070
+ exists: (rel) => { const abs = inRepo(rel); return !!abs && fs.existsSync(abs); },
6071
+ isDirectory: (rel) => { const abs = inRepo(rel); try { return !!abs && fs.statSync(abs).isDirectory(); } catch { return false; } },
6072
+ readFile: (rel) => {
6073
+ const abs = inRepo(rel);
6074
+ if (!abs) throw new Error(`${rel} is outside the repository`);
6075
+ return fs.readFileSync(abs, 'utf8');
6076
+ },
6077
+ // Async (execFile, no shell) so queue-stories can resolve several issues at once.
6078
+ fetchIssue: async (issueNumber) => {
6079
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6080
+ const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6081
+ if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6082
+ const { execFile } = require('child_process');
6083
+ let stdout;
6084
+ try {
6085
+ ({ stdout } = await require('util').promisify(execFile)('gh',
6086
+ ['issue', 'view', String(issueNumber), '--repo', `${m[1]}/${m[2]}`, '--json', 'title,body,url'],
6087
+ { encoding: 'utf8', timeout: 30_000 }));
6088
+ } catch (e) {
6089
+ // execFile keeps gh's stderr on the error — its first line is the actionable part ("gh auth
6090
+ // login", "Could not resolve to an Issue"), and a timeout must not read like any failure.
6091
+ if (e && e.killed) throw new Error(`gh issue view timed out after 30s`);
6092
+ const why = String((e && e.stderr) || '').trim().split('\n')[0];
6093
+ throw new Error(why || (e && e.message) || String(e));
6094
+ }
6095
+ return JSON.parse(stdout);
6096
+ },
6097
+ // HC-GF-270: who wrote the issue. run-context.js classifies a failure and story-provenance.js
6098
+ // decides trust; this only fetches. execFile, not a shell string: owner/repo come from the remote.
6099
+ fetchIssueProvenance: async (issueNumber) => {
6100
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
6101
+ const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
6102
+ if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
6103
+ const where = { owner: m[1], repo: m[2], remoteUrl };
6104
+ try {
6105
+ const { execFile } = require('child_process');
6106
+ const { stdout } = await require('util').promisify(execFile)('gh',
6107
+ ['api', `repos/${m[1]}/${m[2]}/issues/${issueNumber}`],
6108
+ { encoding: 'utf8', timeout: 20_000 });
6109
+ return { ...where, issue: JSON.parse(stdout) };
6110
+ } catch (e) {
6111
+ throw Object.assign(e, { where }); // `where` lets the failure keep its issue reference
6112
+ }
6113
+ },
6114
+ fetchStoryContext: async (storyId) => {
6115
+ const { data } = await api(apiConfig).get(`/stories/${encodeURIComponent(storyId)}/context`);
6116
+ return data;
6117
+ },
6118
+ recentlyModifiedOverlap: (touched) =>
6119
+ require('./lib/story-classifier-extract').extractRecentlyModifiedOverlap(cwd, touched, 14),
6120
+ };
6121
+ }
6122
+
6123
+ // Prints run-context notes at their level. `prefix` labels a note with its story in a batch.
6124
+ function printRunContextNotes(notes, { prefix = '', only } = {}) {
6125
+ for (const n of notes || []) {
6126
+ if (only && n.level !== only) continue;
6127
+ const text = prefix ? n.text.replace(/^(\s*)/, `$1${prefix} `) : n.text;
6128
+ if (n.level === 'warn') console.warn(text); else console.log(text);
6129
+ }
6130
+ }
6131
+
5625
6132
  program
5626
6133
  .command('run-story <storyId>')
5627
6134
  .description('Run the SDLC pipeline for a story via the orchestrator')
@@ -5640,7 +6147,11 @@ program
5640
6147
  .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
6148
  .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
6149
  .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.')
6150
+ // HC-GF-202: `collectIncludePaths` accumulates every occurrence. Without it the
6151
+ // option is single-valued and commander keeps the LAST, so the remedy the planner
6152
+ // itself prints — `--include-paths Makefile --include-paths scripts/` — silently
6153
+ // discarded `Makefile` and produced a bundle identical to passing nothing.
6154
+ .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
6155
  .option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
5645
6156
  .option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
5646
6157
  .action(async (storyIdOrRunId, opts) => {
@@ -5703,19 +6214,37 @@ program
5703
6214
  // advisory path re-verifies on every poll tick, because advisory never advances
5704
6215
  // the run and the pause it re-observes is the same pause.
5705
6216
  const _verifiedAttempts = new Set();
6217
+ // HC-GF-189: one-shot, and the "once" lives in the reporter so it can be DRIVEN by a test
6218
+ // rather than grepped for — mutation walked through the inline version twice.
6219
+ const { makeReadinessReporter } = require('./lib/story-classifier');
6220
+ const { readSandboxConfig } = require('./lib/pipeline-config');
6221
+ const _reportReadiness = makeReadinessReporter({ readSandbox: () => readSandboxConfig(process.cwd()) });
5706
6222
 
5707
6223
  for (;;) {
5708
6224
  await new Promise(r => setTimeout(r, interval));
5709
6225
 
5710
6226
  let status;
5711
6227
  try {
5712
- const { data: pollData } = await client.get(`/orchestrate/${pollRunId}`);
6228
+ const { data: pollData } = await client.get(orchestratePath([pollRunId]));
5713
6229
  status = pollData;
5714
6230
  } catch (e) {
5715
6231
  console.error(`Poll error: ${e.message}`);
5716
6232
  continue;
5717
6233
  }
5718
6234
 
6235
+ // HC-GF-189: the first tick at which the axis EXISTS. step_0's groomer answers
6236
+ // `requires_stored_state`, the server harvests it into `config.classifier_axes`, and only
6237
+ // the CLI can pair that with "no `sandbox:` block is declared in this repo" — the server
6238
+ // has never seen one (HC-GF-169). Emitted here rather than at verify-patch, where
6239
+ // `planSandboxRun`'s stand-down says the same thing but only after step_2 has already
6240
+ // written its tests against whatever the machine happened to hold.
6241
+ //
6242
+ // Once. The loop re-observes the same state every few seconds, and a warning that repeats
6243
+ // until the run ends teaches the reader to skip it — the reason HC-GF-064 guards
6244
+ // auto-verify with a Set.
6245
+ const _readinessNote = _reportReadiness(status?.config);
6246
+ if (_readinessNote) console.warn(` ⚠ ${_readinessNote}`);
6247
+
5719
6248
  const d = classifyPollState(status);
5720
6249
 
5721
6250
  if (d.outcome === POLL_OUTCOMES.COMPLETED) {
@@ -5746,6 +6275,36 @@ program
5746
6275
  console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
5747
6276
  }
5748
6277
 
6278
+ // HC-GF-263: the step named the files it needs, in a fenced `hone run-story` command,
6279
+ // and until now nobody read it — the server kept 200 chars of the first line and this
6280
+ // loop printed 240 of those on the EXHAUSTED branch and nothing at all on a human
6281
+ // gate. Printed in BOTH branches, and ADDITIVE to the errorMessage above: that line
6282
+ // is the only surviving statement of why the step stopped.
6283
+ //
6284
+ // Never throws: a print must not end a poll loop.
6285
+ try {
6286
+ const { remedyFor, renderIncludePathsCommand, partitionSafePaths } = require('./lib/hard-pause-remedy');
6287
+ const remedy = remedyFor(status);
6288
+ if (remedy) {
6289
+ const cmd = renderIncludePathsCommand(status && status.storyId, remedy.paths);
6290
+ if (cmd) {
6291
+ // Name the step: the request belongs to ONE step, and an unattributed command
6292
+ // reads as advice about the run rather than about this pause (H1).
6293
+ console.log(` ${remedy.stepKey} named the files it needs. Re-queue with:`);
6294
+ console.log(` ${cmd}`);
6295
+ if (remedy.truncated) {
6296
+ // HC-GF-263-followup-1: the cap counts entries, and one file may have several.
6297
+ console.log(` ⚠ it asked for more than ${remedy.paths.length} paths; the rest were dropped.`);
6298
+ }
6299
+ }
6300
+ // H5: say how many requested paths were withheld from the command, never what they were.
6301
+ const { dropped } = partitionSafePaths(remedy.paths);
6302
+ if (dropped.length) {
6303
+ console.log(` ${remedy.stepKey}: ${dropped.length} requested path(s) not shown — characters a shell could misread; see \`hone show step\`.`);
6304
+ }
6305
+ }
6306
+ } catch { /* a print must never end a poll loop */ }
6307
+
5749
6308
  // HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
5750
6309
  // existed since HC-019n-followup-34/35 and never fired, because nothing ever
5751
6310
  // ran the executing step. Consent is DERIVED: `ci.local_command` being
@@ -5753,13 +6312,59 @@ program
5753
6312
  // cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
5754
6313
  // Never throws — a failure here must leave the operator exactly where they
5755
6314
  // were, which is being asked to approve.
6315
+ // HC-GF-270: computed OUTSIDE the try below, on purpose. That catch turns a throw
6316
+ // into `(auto-verify skipped: ...)` and CONTINUES — harmless for a decision about
6317
+ // whether to run tests, a fail-OPEN for a decision about whether this patch may
6318
+ // execute at all. So the one call that answers "may we run it" cannot be inside it,
6319
+ // and its own failure is untrusted rather than absent.
6320
+ let _provenance;
6321
+ // HC-GF-270: attempts whose NEEDS YOU block has already been printed this process.
6322
+ // Declared lazily on the enclosing scope so the Set survives across poll ticks —
6323
+ // a per-tick Set would suppress nothing.
6324
+ if (!globalThis.__honeProvenanceNoticed) globalThis.__honeProvenanceNoticed = new Set();
6325
+ const _provenanceNoticed = globalThis.__honeProvenanceNoticed;
6326
+ if (!exhausted) {
6327
+ try {
6328
+ const { assessStorySource } = require('./lib/story-provenance');
6329
+ _provenance = assessStorySource(
6330
+ status?.config?.story_source,
6331
+ status?.storyId || status?.story_id);
6332
+ } catch (e) {
6333
+ _provenance = {
6334
+ trusted: false,
6335
+ label: 'assessment-failed',
6336
+ reason: `story provenance could not be assessed: ${e.message}`,
6337
+ };
6338
+ }
6339
+ }
5756
6340
  if (!exhausted) try {
5757
- const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine } = require('./lib/auto-verify');
6341
+ const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine, provenanceNotice } = require('./lib/auto-verify');
5758
6342
  const decision = decideAutoVerify({
5759
6343
  stepKey: d.stepKey,
5760
6344
  repoRoot: process.cwd(),
5761
6345
  noVerifyFlag: opts.verify === false,
6346
+ assessment: _provenance,
5762
6347
  });
6348
+ // HC-GF-270: only when the step_4 gate is the one paused — an untrusted run is
6349
+ // untrusted at every gate, and repeating this at each of them would teach the
6350
+ // operator to skip it (the HC-GF-189 lesson about a warning that never stops).
6351
+ // HC-GF-270 code review (MEDIUM-5): ONCE per attempt, the same discipline
6352
+ // `alreadyVerified` applies to `announce`. The loop re-observes the same pause every
6353
+ // few seconds; a NEEDS YOU block that reprints on every tick teaches the operator to
6354
+ // scroll past it, which is the HC-GF-189 lesson this comment used to only half-apply
6355
+ // (it suppressed the notice at OTHER gates, not on re-polls of this one).
6356
+ //
6357
+ // Its own Set, not `_verifiedAttempts`: that one means "this attempt was verified",
6358
+ // and an untrusted attempt is precisely one that was NOT. Sharing it would make
6359
+ // `alreadyVerified` lie about what happened.
6360
+ if (decision.untrusted && d.stepKey === 'step_4' && !_provenanceNoticed.has(d.attemptKey)) {
6361
+ _provenanceNoticed.add(d.attemptKey);
6362
+ console.log(provenanceNotice(_provenance, {
6363
+ runId: pollRunId,
6364
+ source: status?.config?.story_source || {},
6365
+ stepKey: d.stepKey,
6366
+ }));
6367
+ }
5763
6368
  if (decision.run && !alreadyVerified(d.attemptKey, _verifiedAttempts)) {
5764
6369
  _verifiedAttempts.add(d.attemptKey);
5765
6370
  console.log(announce(decision, pollRunId));
@@ -5791,14 +6396,14 @@ program
5791
6396
 
5792
6397
  if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
5793
6398
  try {
5794
- await client.post(`/orchestrate/${pollRunId}/approve`, { stepKey: d.stepKey });
6399
+ await client.post(orchestratePath([pollRunId, 'approve']), { stepKey: d.stepKey });
5795
6400
  console.log(' Approved — workflow resuming...');
5796
6401
  } catch (e) {
5797
6402
  console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
5798
6403
  }
5799
6404
  } else if (answer.toLowerCase() === 'kill') {
5800
6405
  try {
5801
- await client.post(`/orchestrate/${pollRunId}/kill`, { reason: 'CLI kill' });
6406
+ await client.post(orchestratePath([pollRunId, 'kill']), { reason: 'CLI kill' });
5802
6407
  console.log(' Workflow killed.');
5803
6408
  process.exit(0);
5804
6409
  } catch (e) {
@@ -5827,7 +6432,7 @@ program
5827
6432
  if (opts.status) {
5828
6433
  try {
5829
6434
  const statusId = await existingRunId('--status');
5830
- const { data } = await client.get(`/orchestrate/${statusId}`);
6435
+ const { data } = await client.get(orchestratePath([statusId]));
5831
6436
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5832
6437
  console.log('');
5833
6438
  console.log(`Workflow: ${data.runId}`);
@@ -5877,7 +6482,7 @@ program
5877
6482
  if (opts.kill) {
5878
6483
  try {
5879
6484
  const killId = await existingRunId('--kill');
5880
- const { data } = await client.post(`/orchestrate/${killId}/kill`, { reason: 'CLI kill' });
6485
+ const { data } = await client.post(orchestratePath([killId, 'kill']), { reason: 'CLI kill' });
5881
6486
  console.log(`Workflow ${data.runId} killed.`);
5882
6487
  } catch (e) {
5883
6488
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
@@ -5891,7 +6496,7 @@ program
5891
6496
  let approveId;
5892
6497
  try {
5893
6498
  approveId = await existingRunId(`--approve ${opts.approve}`);
5894
- await client.post(`/orchestrate/${approveId}/approve`,
6499
+ await client.post(orchestratePath([approveId, 'approve']),
5895
6500
  { stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
5896
6501
  acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
5897
6502
  console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
@@ -5934,27 +6539,6 @@ program
5934
6539
  try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); } catch { branch = null; }
5935
6540
  }
5936
6541
 
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
6542
  // HC-GF-010: an EXPLICIT story source, and the only one that works on a repository
5959
6543
  // that is neither a GitHub issue tracker nor listed on Hone's own roadmap.
5960
6544
  //
@@ -5989,473 +6573,25 @@ program
5989
6573
  }
5990
6574
  }
5991
6575
 
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
- }
6576
+ // HC-GF-097: the run config is built by cli/lib/run-context.js, the one assembler this
6577
+ // command and `queue-stories` share. It used to be assembled inline here, key by key, and
6578
+ // queue-stories was written beside it with two of the fourteen — so a queued story ran with
6579
+ // no story text, no bundle, no tree and the server's default CI gate. The module returns
6580
+ // what it decided as notes; they print in the order this command always printed them.
6581
+ const { resolveRepoContext, resolveStoryContext, buildRunConfig } = require('./lib/run-context');
6582
+ const rcIo = runContextIo(config);
6583
+ const repoCtx = resolveRepoContext(rcIo, { e2eSpecs: opts.e2eSpecs, skipE2eSpecs: opts.skipE2eSpecs });
6584
+ const storyCtx = await resolveStoryContext(rcIo, repoCtx, storyIdOrRunId, {
6585
+ explicitText: explicitStory,
6586
+ explicitLabel: opts.storyFile ? path.relative(process.cwd(), path.resolve(opts.storyFile)) : '--description',
6587
+ explicitSource: opts.storyFile ? { kind: 'file', label: path.basename(String(opts.storyFile)) } : { kind: 'description' },
6588
+ includePaths: opts.includePaths,
6589
+ axes: opts.axes,
6590
+ });
6591
+ printRunContextNotes(storyCtx.notes.pre);
6592
+ printRunContextNotes(repoCtx.notes);
6593
+ printRunContextNotes(storyCtx.notes.post);
6594
+ const orchestrateConfig = buildRunConfig(repoCtx, storyCtx);
6459
6595
 
6460
6596
  try {
6461
6597
  const { data } = await client.post('/orchestrate', {
@@ -6508,13 +6644,16 @@ program
6508
6644
  .option('--list', 'List recent batches for this org')
6509
6645
  .option('--limit <n>', 'Number of batches to list (default 20, max 100)', '20')
6510
6646
  .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)
6647
+ // HC-054: Night Shift mode. Extended LLM timeout (480s), orchestrator watchdog (900s/step), default token
6648
+ // budget of 400K × story_count when none is set, a cap of 25 stories, and an exhausted pause that fails the run
6649
+ // so its dependents are released (HC-GF-137b). step_4 and step_5 still pause: HC-GF-303 removed HC-054's
6650
+ // auto-approve on output text, which never ran. Approve overnight on evidence with `hone follow-batch`.
6651
+ .option('--overnight', 'Enable Night Shift mode (longer timeouts, max 25 stories; step_4/step_5 still pause for approval)', false)
6517
6652
  .action(async (opts) => {
6653
+ refuseBadRunArgs([ // HC-GF-270-followup-1
6654
+ { label: '--status', kind: 'batchId', value: opts.status, optional: true },
6655
+ { label: '--report', kind: 'batchId', value: opts.report, optional: true },
6656
+ ]);
6518
6657
  const config = getConfig();
6519
6658
  const client = api(config);
6520
6659
 
@@ -6559,7 +6698,7 @@ program
6559
6698
  try {
6560
6699
  const format = opts.format === 'json' ? 'json' : 'md';
6561
6700
  const { data, headers } = await client.get(
6562
- `/orchestrate/batch/${opts.report}/report`,
6701
+ orchestratePath(['batch', opts.report, 'report']),
6563
6702
  { params: { format }, responseType: 'text', transformResponse: [(d) => d] }
6564
6703
  );
6565
6704
  if (format === 'json') {
@@ -6610,7 +6749,7 @@ program
6610
6749
  // ── --status <batchId> ────────────────────────────────────
6611
6750
  if (opts.status) {
6612
6751
  try {
6613
- const { data } = await client.get(`/orchestrate/batch/${opts.status}`);
6752
+ const { data } = await client.get(orchestratePath(['batch', opts.status]));
6614
6753
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6615
6754
  console.log('');
6616
6755
  console.log(`Batch: ${data.batchId}`);
@@ -6686,98 +6825,103 @@ program
6686
6825
  catch { branch = null; }
6687
6826
  }
6688
6827
 
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;
6828
+ const { parseStoryLine } = require('./lib/story-line');
6829
+ const {
6830
+ resolveRepoContext, resolveStoryContext, buildRunConfig, hasStoryText, describeMissingText, overnightModeRefusal,
6831
+ } = require('./lib/run-context');
6832
+
6833
+ // HC-059: `STORY-A depends:STORY-B,STORY-C` per line. HC-GF-140 stage 2: the parse lives in
6834
+ // cli/lib so it is asserted by BEHAVIOUR. Every line is parsed before anything is resolved.
6835
+ const lines = storyIds.map(line => {
6836
+ const parsed = parseStoryLine(line);
6837
+ if (parsed.error) {
6838
+ console.error(parsed.error);
6839
+ console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
6840
+ process.exit(1);
6841
+ }
6842
+ return parsed;
6745
6843
  });
6746
6844
 
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 || {};
6845
+ // HC-GF-097: each queued story gets the config `hone run-story` would build for it, from the
6846
+ // same module (cli/lib/run-context.js). This path used to send two architect flags per story,
6847
+ // so a queued story was groomed, planned and built from its id alone. The repository half is
6848
+ // computed once for the whole batch.
6849
+ const rcIo = runContextIo(config);
6850
+ // The byte cap on the tree's path lists is the batch's: its request limit is derived from it.
6851
+ const repoCtx = resolveRepoContext(rcIo, { treeByteCap: require('./lib/repo-tree').MAX_TRANSPORT_BYTES });
6852
+
6853
+ // Fail closed FIRST — before any story is resolved or anything is sent. An overnight batch
6854
+ // runs with nobody watching: an editor-mode repo would park every story at step_4, and an
6855
+ // unrecognised mode would run on a guess.
6752
6856
  if (opts.overnight) {
6753
- body.config.overnight = true;
6857
+ const { OVERNIGHT_MAX_STORIES } = require('./lib/run-context');
6858
+ if (lines.length > OVERNIGHT_MAX_STORIES) {
6859
+ console.error(`✗ --overnight refused: ${lines.length} stories, over the ${OVERNIGHT_MAX_STORIES}-story overnight cap. ` +
6860
+ 'Nothing was resolved or sent; split the file.');
6861
+ process.exit(1);
6862
+ }
6863
+ const refusal = overnightModeRefusal(repoCtx.execution);
6864
+ if (refusal) {
6865
+ console.error(refusal);
6866
+ process.exit(1);
6867
+ }
6754
6868
  }
6869
+ printRunContextNotes(repoCtx.notes);
6755
6870
 
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})`);
6871
+ // Resolved with bounded concurrency: each story can make a network call (its roadmap context,
6872
+ // or `gh issue view`), and fifty in series is minutes of waiting. The output is printed after
6873
+ // every story has resolved, in file order, so it reads exactly like a serial pass.
6874
+ const RESOLVE_CONCURRENCY = 5;
6875
+ const resolved = new Array(lines.length);
6876
+ let nextLine = 0;
6877
+ await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, lines.length) }, async () => {
6878
+ while (nextLine < lines.length) {
6879
+ const i = nextLine++;
6880
+ resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId);
6768
6881
  }
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'; }
6882
+ }));
6883
+ // One summary line per story, and every warning labelled with its story. The info lines
6884
+ // run-story prints for one run would be a dozen per story here.
6885
+ for (const ctx of resolved) {
6886
+ const { storyId } = ctx;
6887
+ const src = ctx.textSource ? `${ctx.textSource} text (${ctx.rawText.length} chars)` : 'no story text';
6888
+ console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled`);
6889
+ printRunContextNotes(ctx.notes.pre, { prefix: `[${storyId}]`, only: 'warn' });
6890
+ printRunContextNotes(ctx.notes.post, { prefix: `[${storyId}]`, only: 'warn' });
6891
+ }
6892
+
6893
+ // HC-GF-097: a story with no text grooms to placeholders. run-story warns and proceeds for a
6894
+ // run someone is watching; a batch refuses — naming EVERY such story — before anything is
6895
+ // queued. Judged on the raw text, never the composed description.
6896
+ const missing = resolved.filter(ctx => !hasStoryText(ctx));
6897
+ if (missing.length > 0) {
6898
+ console.error(`✗ ${missing.length} of ${resolved.length} ${resolved.length === 1 ? 'story' : 'stories'} ` +
6899
+ `${missing.length === 1 ? 'has' : 'have'} no story text, so nothing was queued:`);
6900
+ for (const ctx of missing) console.error(` ${describeMissingText(ctx)}`);
6901
+ console.error('Give each one text (a roadmap row or a GitHub issue number), or remove it from the file.');
6902
+ process.exit(1);
6777
6903
  }
6778
6904
 
6905
+ const stories = lines.map(({ storyId, dependsOn }, i) => {
6906
+ const obj = { storyId, repoName, branch, config: buildRunConfig(repoCtx, resolved[i]) };
6907
+ if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
6908
+ return obj;
6909
+ });
6910
+
6911
+ // HC-054: Night Shift. The flag lives at batch level only; the server caps an overnight batch
6912
+ // at 25 and denormalizes the flag into each child itself.
6913
+ const body = { stories, config: {} };
6914
+ if (opts.overnight) body.config.overnight = true;
6915
+ // HC-101-followup-2: the CI gate also travels at batch level. Each story already carries its
6916
+ // own; a server from HC-GF-097 on fills the batch value into any story that did not.
6917
+ body.config.ci_gate = repoCtx.config.ci_gate;
6918
+ body.config.ci_local_command = repoCtx.config.ci_local_command;
6919
+
6920
+ // Sized to the body: the client's 30 s default covered the upload (review of #1100).
6921
+ const bodyBytes = Buffer.byteLength(JSON.stringify(body), 'utf8');
6922
+ const postTimeoutMs = require('./lib/run-context').batchPostTimeoutMs(bodyBytes);
6779
6923
  try {
6780
- const { data } = await client.post('/orchestrate/batch', body);
6924
+ const { data } = await client.post('/orchestrate/batch', body, { timeout: postTimeoutMs });
6781
6925
 
6782
6926
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6783
6927
 
@@ -6797,6 +6941,25 @@ program
6797
6941
  console.error(`Org concurrency limit reached: ${e.response.data.error}`);
6798
6942
  } else if (e.response?.status === 400) {
6799
6943
  console.error(`Rejected: ${e.response.data.error}`);
6944
+ } else if (e.response?.status === 413) {
6945
+ // HC-GF-097: full per-story context can outgrow the server's request limit. Say how big,
6946
+ // which stories, and what to do — not a bare "request entity too large".
6947
+ const total = Buffer.byteLength(JSON.stringify(body), 'utf8');
6948
+ console.error(`Rejected: the batch is ${(total / 1048576).toFixed(1)} MB, over the server's request limit (413).`);
6949
+ console.error('Largest stories by run-config size:');
6950
+ const sizes = stories.map(s => ({ id: s.storyId, bytes: Buffer.byteLength(JSON.stringify(s.config), 'utf8') }))
6951
+ .sort((a, b) => b.bytes - a.bytes);
6952
+ for (const s of sizes.slice(0, 10)) console.error(` ${s.id} ${(s.bytes / 1024).toFixed(0)} KB`);
6953
+ console.error('Split the file into smaller batches, or narrow codebase.source_dirs so each story carries less.');
6954
+ } else if (e.response?.status === 503) {
6955
+ // HC-GF-097 review: the server admits a few batch submissions at a time; nothing was queued.
6956
+ console.error(`Server busy, nothing was queued: ${e.response.data?.error || 'retry shortly'}`);
6957
+ } else if (!e.response && (e.code === 'ECONNABORTED' || /timeout/i.test(e.message || ''))) {
6958
+ // The body may have arrived and been committed before the timer fired. Re-running blind
6959
+ // would queue the same stories twice, so say so and point at the list.
6960
+ console.error(`Timed out after ${Math.round(postTimeoutMs / 1000)}s sending a ${(bodyBytes / 1048576).toFixed(1)} MB batch.`);
6961
+ console.error('The server may have queued it anyway. Check before re-running, or the stories will be queued twice:');
6962
+ console.error(' hone queue-stories --list');
6800
6963
  } else {
6801
6964
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
6802
6965
  }
@@ -6804,71 +6967,6 @@ program
6804
6967
  }
6805
6968
  });
6806
6969
 
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
6970
  // ── HC-056: Schedule install (GitHub Actions overnight template) ────────────
6873
6971
  //
6874
6972
  // Installs a parameterized .github/workflows/<name>.yml that runs `hone
@@ -7434,13 +7532,16 @@ program
7434
7532
  const responseForCache = parsed && typeof parsed === 'object'
7435
7533
  ? parsed
7436
7534
  : { raw: responseText };
7437
- storeReviewCache({
7535
+ await storeReviewCache({
7438
7536
  axios, apiBase, token,
7439
7537
  contentHash: cacheContentHash,
7440
7538
  model: modelForCache,
7441
7539
  response: responseForCache,
7442
7540
  tokensSaved: inputTokens + outputTokens,
7443
7541
  banner,
7542
+ // HC-GF-260: awaited. The next thing this command does is compute an exit code and
7543
+ // leave, so a detached POST can lose the entry the cache exists to save — the same shape
7544
+ // as HC-GF-250, where a process returned before its write landed.
7444
7545
  }).then(({ stored }) => {
7445
7546
  if (stored) banner(`✓ Cache stored: ${cacheContentHash.slice(0, 16)}…`);
7446
7547
  }).catch(() => { /* logged inside helper */ });
@@ -7508,13 +7609,15 @@ showCmd
7508
7609
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7509
7610
  .option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
7510
7611
  .action(async (workflowId, stepKey, opts) => {
7612
+ refuseBadRunArgs([ // HC-GF-270-followup-1
7613
+ { label: '<workflowId>', kind: 'runId', value: workflowId },
7614
+ { label: '<stepKey>', kind: 'stepKey', value: stepKey },
7615
+ { label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
7616
+ ]);
7511
7617
  const config = getConfig();
7512
7618
  const client = api(config);
7513
7619
  try {
7514
- const q = [];
7515
- if (opts.attempt) q.push(`attempt=${opts.attempt}`);
7516
- if (opts.prompt) q.push('prompt=1');
7517
- const url = `/orchestrate/${workflowId}/step/${stepKey}${q.length ? `?${q.join('&')}` : ''}`;
7620
+ const url = orchestratePath([workflowId, 'step', stepKey], { attempt: opts.attempt, prompt: opts.prompt ? '1' : undefined });
7518
7621
  const r = await client.get(url);
7519
7622
  const s = r.data;
7520
7623
  console.log(`── step output ──────────────────────────────────────────`);
@@ -7571,6 +7674,7 @@ program
7571
7674
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7572
7675
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7573
7676
  .action(async (workflowId, opts) => {
7677
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270-followup-1
7574
7678
  const { execFileSync } = require('child_process');
7575
7679
  const os = require('os');
7576
7680
  const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
@@ -7586,8 +7690,7 @@ program
7586
7690
 
7587
7691
  let output;
7588
7692
  try {
7589
- const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
7590
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
7693
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7591
7694
  const r = await client.get(url);
7592
7695
  output = r.data?.output;
7593
7696
  } catch (e) {
@@ -7603,6 +7706,9 @@ program
7603
7706
  // (followup-20) for stories still on the old ## Patch contract.
7604
7707
  let diff, source, noChanges;
7605
7708
  const blocks = extractFileBlocks(output);
7709
+ // HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
7710
+ // silently re-scoping is how a finished build came back as "no file changes to verify".
7711
+ if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
7606
7712
  if (blocks.noChanges) {
7607
7713
  emit({ ok: true, verdict: 'no_changes' },
7608
7714
  `○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
@@ -7647,13 +7753,15 @@ program
7647
7753
 
7648
7754
  // Write to a temp file and run git apply --check against HEAD. HC-GF-077: against an index read from HEAD,
7649
7755
  // 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`);
7756
+ // HC-GF-304: a directory only this invocation created, not a name built from the clock.
7757
+ const tmpFile = require('./lib/private-temp').privateTempFile('hone-check-patch', 'patch.diff');
7758
+ const tmp = tmpFile.file;
7651
7759
  let result;
7652
7760
  try {
7653
7761
  fs.writeFileSync(tmp, diff);
7654
7762
  let code = 0, stderr = '';
7655
7763
  try {
7656
- const index = path.join(os.tmpdir(), `hone-check-index-${process.pid}-${Date.now()}`);
7764
+ const index = path.join(path.dirname(tmp), 'index'); // HC-GF-304: inside this invocation's private directory
7657
7765
  try {
7658
7766
  try {
7659
7767
  execFileSync('git', ['read-tree', `--index-output=${index}`, 'HEAD'], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
@@ -7670,7 +7778,7 @@ program
7670
7778
  }
7671
7779
  result = interpretApplyCheck({ code, stderr });
7672
7780
  } finally {
7673
- try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
7781
+ tmpFile.remove();
7674
7782
  }
7675
7783
 
7676
7784
  if (result.applies) {
@@ -7703,15 +7811,29 @@ program
7703
7811
  .option('--keep', 'Leave the worktree in place for debugging')
7704
7812
  .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
7705
7813
  .option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
7814
+ .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
7815
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7707
7816
  .action(async (workflowId, opts) => {
7817
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
7708
7818
  const { execFileSync, spawnSync } = require('child_process');
7819
+ // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
7820
+ // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
7821
+ // deep in child_process, and a blank value coerces to 0 and kills the
7822
+ // command the instant it starts.
7823
+ let timeoutMs;
7824
+ try {
7825
+ timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
7826
+ } catch (e) {
7827
+ console.error(e.message);
7828
+ process.exit(2);
7829
+ }
7709
7830
  const os = require('os');
7710
7831
  const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
7711
7832
  const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
7712
7833
  const { gitEnv } = require('./lib/git-env');
7713
7834
  const config = getConfig();
7714
7835
  const client = api(config);
7836
+ const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
7715
7837
 
7716
7838
  const emit = (obj, humanLine) => {
7717
7839
  if (opts.format === 'json') console.log(JSON.stringify(obj));
@@ -7722,19 +7844,30 @@ program
7722
7844
  // HC-GF-074: the attempt this run verified — reported with the verdict below.
7723
7845
  let verifiedAttempt;
7724
7846
  let output, runConfig;
7847
+ // HC-GF-270: the id shape is the legacy provenance rule's only input, and a FAILED run
7848
+ // fetch is untrusted regardless of it — the `catch { runConfig = {} }` below is the
7849
+ // fail-open this gate exists to close.
7850
+ let runStoryId = null;
7851
+ let runFetchFailed = false;
7725
7852
  try {
7726
- const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
7727
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
7853
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7728
7854
  const r = await client.get(url);
7855
+ // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
7856
+ if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
7729
7857
  output = r.data?.output;
7730
7858
  // HC-GF-074: remember WHICH attempt this is. Without --attempt the server returns the latest, and the verdict
7731
7859
  // used to be reported with `attempt: null` — so no verdict on record could say which build it judged.
7732
7860
  verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7733
7861
  // best-effort: pull the run's ci_local_command for the default
7734
7862
  try {
7735
- const runR = await client.get(`/orchestrate/${workflowId}`);
7863
+ const runR = await client.get(orchestratePath([workflowId]));
7864
+ // HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
7865
+ // `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
7866
+ // 200 with no config. And a body that is not THIS run is not this run's config.
7867
+ if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
7736
7868
  runConfig = runR.data?.config || {};
7737
- } catch { runConfig = {}; }
7869
+ runStoryId = runR.data?.storyId || runR.data?.story_id || null;
7870
+ } catch (e) { runConfig = {}; runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; }
7738
7871
  } catch (e) {
7739
7872
  const msg = e.response?.data?.error || e.message;
7740
7873
  emit({ ok: false, reason: 'fetch_failed', detail: msg },
@@ -7742,12 +7875,58 @@ program
7742
7875
  process.exit(EXIT.NO_DIFF);
7743
7876
  }
7744
7877
 
7878
+ // ── HC-GF-270: the provenance gate ────────────────────────────────────────────────────
7879
+ //
7880
+ // HERE, and not one line later. Telling the operator at the poll loop is not enough: an
7881
+ // MCP agent reads the same untrusted issue text and can call `hone_verify_patch` itself
7882
+ // (cli/lib/mcp-tools.js), and a scripted `--approve` takes the same path. This runs
7883
+ // BEFORE the diff is materialized, before a worktree exists, and before provisioning
7884
+ // runs the PATCHED manifest's install as the operator — by the time `planProvision`
7885
+ // sees it, the code is already on disk, which is why worktree-provision.js is not the
7886
+ // layer for this.
7887
+ {
7888
+ let assessment;
7889
+ try {
7890
+ const { assessStorySource } = require('./lib/story-provenance');
7891
+ assessment = runFetchFailed
7892
+ ? { trusted: false, label: 'run-fetch-failed',
7893
+ reason: runFetchFailed === 'mismatch'
7894
+ ? 'the server answered for a different run, so this run\'s config was never read'
7895
+ : 'this run\'s config could not be read, so nothing is known about where its story text came from' }
7896
+ : assessStorySource(runConfig?.story_source, runStoryId);
7897
+ } catch (e) {
7898
+ assessment = { trusted: false, label: 'assessment-failed',
7899
+ reason: `story provenance could not be assessed: ${e.message}` };
7900
+ }
7901
+ if (!assessment.trusted && !opts.trustSource) {
7902
+ // NO --report on this path, deliberately. A non-pass verdict on record makes the
7903
+ // server refuse the human's later approval under `enforce`
7904
+ // (server/src/services/verification-refusal.js), and nothing was verified here —
7905
+ // this is a check that did not RUN, which is a third outcome, not a failure.
7906
+ emit({ ok: false, reason: 'untrusted_source', verdict: 'not_run', detail: assessment.reason },
7907
+ `✗ refusing to verify: ${assessment.reason}\n`
7908
+ + ' Verifying runs this patch — the provisioning install first — as you, with your $HOME and network.\n'
7909
+ + ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
7910
+ + ` Then: hone verify-patch ${workflowId} --report --trust-source`);
7911
+ process.exit(EXIT.UNTRUSTED_SOURCE);
7912
+ }
7913
+ }
7914
+
7745
7915
  // 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
7746
7916
  // Condition 6 presupposes 5.
7747
7917
  const blocks = extractFileBlocks(output);
7918
+ // HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
7919
+ // silently re-scoping is how a finished build came back as "no file changes to verify".
7920
+ if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
7748
7921
  const hasWork = hasMaterializableWork(blocks);
7749
7922
  if (blocks.noChanges || !hasWork) {
7750
- emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
7923
+ // HC-GF-190: this exit precedes planSandboxRun entirely, so a stateful story with a declared
7924
+ // sandbox produces NONE of the planner's three outcomes and, before this line, said nothing
7925
+ // about it — run 2a9b2af5's verdict was indistinguishable from an ordinary empty build.
7926
+ // `not reached` is a fourth outcome, not a variant of the stand-down.
7927
+ const missed = describeSandboxDisposition(runConfig);
7928
+ if (missed && opts.format !== 'json') console.warn(` ⚠ ${missed}`);
7929
+ emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff', sandbox: missed || undefined },
7751
7930
  `✗ ${opts.stepKey} produced no file changes to verify.`);
7752
7931
  process.exit(EXIT.NO_DIFF);
7753
7932
  }
@@ -7770,16 +7949,19 @@ program
7770
7949
 
7771
7950
  // 3. Isolated worktree from HEAD.
7772
7951
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
7773
- const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
7952
+ // HC-GF-304: a directory only this invocation created. A clock-named file was shared by two verbs started in
7953
+ // the same millisecond, and one of them verified the other's patch.
7954
+ const diffTmp = require('./lib/private-temp').privateTempFile('hone-verify-diff', 'patch.diff');
7955
+ const diffFile = diffTmp.file;
7774
7956
  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
7957
+ 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
7958
  let cleanupDone = false;
7777
7959
  const cleanup = () => {
7778
7960
  if (cleanupDone || opts.keep) return;
7779
7961
  cleanupDone = true;
7780
7962
  try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
7781
7963
  try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
7782
- try { fs.unlinkSync(diffFile); } catch { /* best-effort */ }
7964
+ diffTmp.remove();
7783
7965
  };
7784
7966
  // Guard against leaving a worktree if the process is interrupted.
7785
7967
  process.on('SIGINT', () => { cleanup(); process.exit(130); });
@@ -7795,6 +7977,9 @@ program
7795
7977
  // HC-GF-026: measured verdicts for the gates step_4 asserts. Hoisted so they
7796
7978
  // survive the try/finally to the --report POST, exactly like reportVerdict.
7797
7979
  const reportGates = [];
7980
+ // HC-GF-171: hoisted for the same reason reportVerdict is — the teardown must survive the
7981
+ // try/finally. Seeded rows outliving a run is the worst outcome this feature can produce.
7982
+ let sandboxTeardown = [];
7798
7983
  const ciCfg = require('./lib/pipeline-config').readCIConfigDetails(process.cwd());
7799
7984
  try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
7800
7985
  try {
@@ -7819,24 +8004,172 @@ program
7819
8004
  // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
7820
8005
  // (link the repo's node_modules when the lockfile is unchanged, else run the
7821
8006
  // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
8007
+ // HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
8008
+ // and with --no-provision there is none, which is not the same as a failure.
8009
+ let provAll = null;
7822
8010
  if (opts.provision !== false) {
7823
- const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
8011
+ const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
7824
8012
  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 });
8013
+ // HC-GF-201: provision EVERY package root, not just the worktree's top level.
8014
+ // This repo has three; the two skipped are where ci.unit runs, so their absent
8015
+ // modules surfaced as `Cannot find module` and were counted as the PATCH failing
8016
+ // tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
8017
+ const installCmd = readCIConfigDetails(process.cwd()).install_command;
8018
+ const provEntries = discoverPackageRoots(wt).map((root) => ({
8019
+ root,
8020
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
8021
+ exec: spawnSync,
8022
+ plan: planProvision({
8023
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
8024
+ parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
8025
+ installCommand: root === '.' ? installCmd : undefined,
8026
+ }),
8027
+ }));
8028
+ provAll = provisionAllRoots(provEntries);
8029
+ const prov = { ok: provAll.ok,
8030
+ summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
8031
+ + (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
7827
8032
  if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
7828
8033
  }
8034
+ // ── HC-GF-171: seed the adopter-declared sandbox before the tests run ────────────────
8035
+ // HC-GF-168/169/170/173 each shipped a piece and a run still touched no datastore, because
8036
+ // `assertScratchTarget` had no caller. This is that caller. It runs adopter-side, with the
8037
+ // adopter's credentials, exactly where `ci.local_command` already runs.
8038
+ //
8039
+ // REFUSE, NEVER SKIP. A misconfigured sandbox stops verification with a message naming the
8040
+ // fix; it does not quietly fall through to "tests passed", which would report a green
8041
+ // verdict for code whose stored-state behaviour was never exercised.
8042
+ try {
8043
+ const { readSandboxConfig, assertScratchTarget } = require('./lib/pipeline-config');
8044
+ const { planSandboxRun, parseProbeOutput, describeProbeResult, sandboxSuiteVerdict } = require('./lib/sandbox-lifecycle');
8045
+ const plan = planSandboxRun({
8046
+ sandbox: readSandboxConfig(process.cwd()),
8047
+ axes: runConfig?.classifier_axes || runConfig?.axes || [],
8048
+ capability: runConfig?.capability || null,
8049
+ });
8050
+ if (plan.refuse) {
8051
+ emit({ ok: false, verdict: 'sandbox_refused', detail: plan.reason },
8052
+ `✗ sandbox refused: ${plan.reason}`);
8053
+ exitCode = EXIT.FAIL;
8054
+ reportVerdict = 'sandbox_refused'; reportDetail = plan.reason;
8055
+ throw new Error('sandbox refused');
8056
+ }
8057
+ if (plan.engage) {
8058
+ // The probe runs in the REAL tree, not the worktree: it reads the adopter's live
8059
+ // datastore, which the worktree does not change.
8060
+ const probed = spawnSync(plan.probe, {
8061
+ cwd: process.cwd(), shell: true, encoding: 'utf8',
8062
+ timeout: 60_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'],
8063
+ });
8064
+ // A probe that exited non-zero is not evidence, whatever it left on stdout — a
8065
+ // command that fails partway can still print a stale or partial line, and reading it
8066
+ // would turn a failed check into a pass.
8067
+ if (probed.status !== 0) {
8068
+ throw new Error(`sandbox probe exited ${probed.status === null ? 'abnormally' : probed.status}`
8069
+ + ` (${plan.probe}): ${((probed.stderr || '').split('\n')[0] || 'no stderr')}`);
8070
+ }
8071
+ // The guard reads the LIVE connection and nothing else (HC-GF-169).
8072
+ const probeEvidence = parseProbeOutput(probed.stdout);
8073
+ assertScratchTarget(plan.verify, probeEvidence);
8074
+ // HC-GF-190: say what the guard PROVED. It throws on refusal and returns nothing on
8075
+ // success, so a passing guard was silent — and HC-GF-185's acceptance ("the output
8076
+ // shows the probe, the guard verdict and the teardown") stayed open on run 6be117b3,
8077
+ // where all three had in fact happened. Printed after the check and before the first
8078
+ // write, so it describes something that has already occurred.
8079
+ if (opts.format !== 'json') console.log(` sandbox: ${describeProbeResult(plan.verify, probeEvidence)}`);
8080
+ // Armed BEFORE the first command, not after the last. `create` succeeding and
8081
+ // `migrate` failing would otherwise leave the created rows behind — exactly the
8082
+ // outcome the teardown exists to prevent, on the path where it matters most.
8083
+ sandboxTeardown = plan.teardown;
8084
+ for (const c of plan.commands) {
8085
+ if (opts.format !== 'json') console.log(` sandbox: ${c}`);
8086
+ const r = spawnSync(c, { cwd: wt, shell: true, encoding: 'utf8',
8087
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
8088
+ if (r.status !== 0) throw new Error(`sandbox command failed: ${c}`);
8089
+ }
8090
+
8091
+ // HC-GF-192: run the suite that READS the sandbox, immediately after seeding and
8092
+ // before the adopter's own command.
8093
+ //
8094
+ // Run 4f463e54 seeded `hone_sandbox` and then ran `make ci` against
8095
+ // `hone_test_<checkout>`: a flawless lifecycle around a verification that could not
8096
+ // observe it. The adopter's whole CI command is the wrong consumer — a suite that
8097
+ // manages its own database fights a seeded one — so the sandbox declares the command
8098
+ // that reads it, and the connection is one WE publish rather than one an adopter
8099
+ // promises is wired up. That is what makes the result provable.
8100
+ //
8101
+ // First, because the seed is freshest here and the adopter's command can run for
8102
+ // minutes; and a failure names the stored-state behaviour rather than arriving after
8103
+ // an unrelated suite. Names are logged, never values: the connection holds credentials.
8104
+ if (opts.format !== 'json') {
8105
+ console.log(` sandbox: ${plan.test}`);
8106
+ console.log(` sandbox: published ${Object.keys(plan.env).join(', ')} to it`);
8107
+ }
8108
+ const sandboxRun = spawnSync(plan.test, {
8109
+ cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
8110
+ env: verifyCommandEnv(gitEnv(plan.env)), stdio: ['ignore', 'pipe', 'pipe'],
8111
+ });
8112
+
8113
+ // HC-GF-193: the NEGATIVE CONTROL. A runner with nothing to run exits 0, so the status
8114
+ // above cannot tell "everything passed" from "nothing ran" — run 6be117b3's first
8115
+ // attempt was reported as verified on a glob that matched no files. Re-run with every
8116
+ // published variable replaced by an unusable value: a suite that observes the sandbox
8117
+ // must fail, and one that still passes is not reading what we published.
8118
+ //
8119
+ // Only when the real run PASSED. A red suite is already a failure, and re-running it
8120
+ // with a broken environment spends the timeout to learn nothing.
8121
+ let controlStatus = 1;
8122
+ if (sandboxRun.status === 0 && plan.controlEnv) {
8123
+ if (opts.format !== 'json') console.log(' sandbox: re-running the suite with the published variables broken (control)');
8124
+ const ctl = spawnSync(plan.test, {
8125
+ cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
8126
+ env: verifyCommandEnv(gitEnv(plan.controlEnv)), stdio: ['ignore', 'pipe', 'pipe'],
8127
+ });
8128
+ controlStatus = ctl.status === 0 ? 0 : 1;
8129
+ }
8130
+
8131
+ const suiteVerdict = sandboxSuiteVerdict({ realStatus: sandboxRun.status, controlStatus });
8132
+ if (!suiteVerdict.ok) {
8133
+ const ran = ((sandboxRun.stdout || '') + (sandboxRun.stderr || ''))
8134
+ .split('\n').filter(Boolean).slice(-12).join('\n');
8135
+ const detail = suiteVerdict.kind === 'sandbox_tests_not_observing'
8136
+ ? suiteVerdict.detail : ran;
8137
+ emit({ ok: false, verdict: suiteVerdict.kind, detail },
8138
+ `✗ ${suiteVerdict.kind === 'sandbox_tests_not_observing'
8139
+ ? `the sandbox suite verified nothing:\n${detail}`
8140
+ : `the sandbox suite failed:\n${detail}`}`);
8141
+ exitCode = EXIT.FAIL;
8142
+ reportVerdict = suiteVerdict.kind; reportDetail = detail;
8143
+ // Thrown so the `finally` tears the sandbox down — seeded rows outliving a red run
8144
+ // is the worst outcome this capability can produce (HC-GF-171).
8145
+ throw new Error('sandbox tests failed');
8146
+ }
8147
+ } else if (opts.format !== 'json') {
8148
+ console.log(` sandbox: ${plan.reason}`);
8149
+ }
8150
+ } catch (e) {
8151
+ if (e.message === 'sandbox refused' || e.message === 'sandbox tests failed') throw e;
8152
+ // A sandbox that cannot be proven scratch STOPS the run. Continuing would report a
8153
+ // verdict for stored-state behaviour that was never exercised — a verdict we did not
8154
+ // obtain (HC-GF-177 §18.5).
8155
+ emit({ ok: false, verdict: 'sandbox_unusable', detail: e.message },
8156
+ `✗ sandbox unusable: ${e.message}`);
8157
+ exitCode = EXIT.FAIL;
8158
+ reportVerdict = 'sandbox_unusable'; reportDetail = e.message;
8159
+ throw e;
8160
+ }
8161
+
7829
8162
  if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
7830
8163
  const run = spawnSync(command, {
7831
8164
  cwd: wt, shell: true, encoding: 'utf8',
7832
- timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
8165
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
7833
8166
  stdio: ['ignore', 'pipe', 'pipe'],
7834
8167
  });
7835
8168
  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 });
8169
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
7837
8170
  // HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
7838
8171
  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' ? '○' : '✗';
8172
+ const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
7840
8173
 
7841
8174
  // HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
7842
8175
  // The full log is written OUTSIDE the worktree (cleanup removes the worktree)
@@ -7857,7 +8190,13 @@ program
7857
8190
  + v.failure.excerpt.map(l => ` │ ${l}`).join('\n');
7858
8191
  }
7859
8192
  if (logPath) pretty += `\n full output: ${logPath}`;
7860
- if (v.verdict !== 'pass') pretty += `\n inspect the tree: re-run with --keep`;
8193
+ if (v.verdict !== 'pass' && v.verdict !== 'pass_narrowed') pretty += `\n inspect the tree: re-run with --keep`;
8194
+ // HC-GF-223: a narrowed run needs the command that did NOT run, not the worktree — the way
8195
+ // to a real verdict is the next line rather than a re-read of the story.
8196
+ if (v.verdict === 'pass_narrowed') {
8197
+ pretty += `\n not a verification: re-run without --command to verify with `
8198
+ + `\`${commandReplaced.command}\``;
8199
+ }
7861
8200
 
7862
8201
  emit(
7863
8202
  { ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail,
@@ -7881,7 +8220,7 @@ program
7881
8220
  for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
7882
8221
  const gr = spawnSync(gate.command, {
7883
8222
  cwd: wt, shell: true, encoding: 'utf8',
7884
- timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
8223
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
7885
8224
  stdio: ['ignore', 'pipe', 'pipe'],
7886
8225
  });
7887
8226
  const c = classifyGateRun({
@@ -7902,11 +8241,34 @@ program
7902
8241
  }
7903
8242
  }
7904
8243
  } 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;
8244
+ // HC-GF-171: a verdict already reported by the sandbox path is the SPECIFIC one — keep it.
8245
+ // Overwriting it with a generic `error` loses which check refused and emits a second,
8246
+ // vaguer line over the first, so the operator reads "verify-patch failed" instead of the
8247
+ // message naming the config key to add.
8248
+ if (reportVerdict) {
8249
+ exitCode = exitCode === EXIT.PASS ? 1 : exitCode;
8250
+ } else {
8251
+ emit({ ok: false, verdict: 'error', detail: e.message },
8252
+ `✗ verify-patch failed: ${e.message}`);
8253
+ exitCode = 1;
8254
+ reportVerdict = 'error'; reportDetail = e.message;
8255
+ }
7909
8256
  } finally {
8257
+ // HC-GF-171: reset runs on EVERY path — pass, fail, refusal, throw. A sandbox that is only
8258
+ // cleaned on success leaves its rows behind exactly when something went wrong, which is when
8259
+ // the adopter is least able to tell seeded data from real.
8260
+ for (const c of sandboxTeardown) {
8261
+ const r = spawnSync(c, { cwd: process.cwd(), shell: true, encoding: 'utf8',
8262
+ timeout: 120_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
8263
+ if (opts.format !== 'json') {
8264
+ console.log(r.status === 0
8265
+ ? ` sandbox: ${c}`
8266
+ : ` sandbox: TEARDOWN FAILED (${c}) — seeded rows may remain: ${(r.stderr || '').split('\n')[0]}`);
8267
+ }
8268
+ // HC-GF-183: keep going. If the reset succeeded and the restore failed, stopping here
8269
+ // leaves the sandbox unprobeable and the NEXT run refuses — the defect this fixes.
8270
+ }
8271
+
7910
8272
  cleanup();
7911
8273
  }
7912
8274
 
@@ -7915,7 +8277,7 @@ program
7915
8277
  // change the local exit code (the verdict is already true locally).
7916
8278
  if (opts.report && reportVerdict) {
7917
8279
  try {
7918
- await client.post(`/orchestrate/${workflowId}/verdict`, {
8280
+ await client.post(orchestratePath([workflowId, 'verdict']), {
7919
8281
  source: 'verify-patch',
7920
8282
  stepKey: opts.stepKey,
7921
8283
  verdict: reportVerdict,
@@ -7949,11 +8311,12 @@ program
7949
8311
  .description('What happened on a run, and what still needs you')
7950
8312
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7951
8313
  .action(async (runId, opts) => {
8314
+ refuseBadRunArgs([{ label: '<runId>', kind: 'runId', value: runId }]); // HC-GF-270-followup-1
7952
8315
  const { buildReport, renderReport } = require('./lib/run-report');
7953
8316
  const config = getConfig();
7954
8317
  const client = api(config);
7955
8318
  try {
7956
- const { data } = await client.get(`/orchestrate/${runId}`);
8319
+ const { data } = await client.get(orchestratePath([runId]));
7957
8320
  const report = buildReport(data);
7958
8321
  if (opts.format === 'json') console.log(JSON.stringify(report, null, 2));
7959
8322
  else console.log(renderReport(report));
@@ -7971,6 +8334,82 @@ program
7971
8334
  }
7972
8335
  });
7973
8336
 
8337
+ // ── HC-GF-098: follow-batch — a queued batch's step_4 patches checked and verified overnight ──
8338
+ // The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
8339
+ // check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
8340
+ // decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
8341
+ // Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you, 3 deadline.
8342
+ program
8343
+ .command('follow-batch <batchId>')
8344
+ .description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098)')
8345
+ .option('--poll-interval <sec>', 'Seconds between polls', '30')
8346
+ .option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
8347
+ .option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
8348
+ .option('--open-pr', 'Push each emitted branch and open a draft PR (default: a local branch only)')
8349
+ .option('--repo <name>', 'Repository name the batch was queued under (default: this directory\'s name)')
8350
+ .action(async (batchId, opts) => {
8351
+ const { followBatch, EXIT } = require('./lib/batch-follow');
8352
+ const { spawn, spawnSync } = require('child_process');
8353
+ const positive = (raw, flag, unit) => {
8354
+ const n = Number(raw);
8355
+ if (!Number.isFinite(n) || n <= 0) {
8356
+ console.error(`${flag} must be a positive number of ${unit} (got ${JSON.stringify(raw)})`);
8357
+ process.exit(EXIT.FATAL);
8358
+ }
8359
+ return n;
8360
+ };
8361
+ const pollMs = positive(opts.pollInterval, '--poll-interval', 'seconds') * 1000;
8362
+ const maxOfflineMs = positive(opts.maxOffline, '--max-offline', 'minutes') * 60_000;
8363
+ const deadlineMs = opts.deadline === undefined ? null : positive(opts.deadline, '--deadline', 'minutes') * 60_000;
8364
+
8365
+ // Preflight: every verb reads this checkout, and --open-pr needs gh. Refuse before the first poll, not at 3am.
8366
+ const { gitEnv } = require('./lib/git-env');
8367
+ const inRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' });
8368
+ if (inRepo.status !== 0 || String(inRepo.stdout).trim() !== 'true') {
8369
+ console.error('follow-batch runs the batch\'s patches against this checkout: run it from the repository the batch was queued in.');
8370
+ process.exit(EXIT.FATAL);
8371
+ }
8372
+ if (opts.openPr && spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status !== 0) {
8373
+ console.error('--open-pr pushes and opens draft PRs with gh, and `gh auth status` failed: log in, or drop --open-pr.');
8374
+ process.exit(EXIT.FATAL);
8375
+ }
8376
+
8377
+ const config = getConfig();
8378
+ const client = api(config);
8379
+ const repoName = opts.repo || path.basename(process.cwd());
8380
+ const io = {
8381
+ fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
8382
+ fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
8383
+ runVerb: (argv) => new Promise((resolve) => {
8384
+ const child = spawn(process.execPath, [process.argv[1], ...argv],
8385
+ { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
8386
+ child.on('error', (e) => resolve({ code: null, error: e }));
8387
+ child.on('close', (code) => resolve({ code }));
8388
+ }),
8389
+ // The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
8390
+ verifyDecision: (run) => {
8391
+ let assessment;
8392
+ try {
8393
+ assessment = require('./lib/story-provenance').assessStorySource(
8394
+ run && run.config && run.config.story_source, run && (run.storyId || run.story_id));
8395
+ } catch (e) {
8396
+ assessment = { trusted: false, label: 'assessment-failed', reason: `story provenance could not be assessed: ${e.message}` };
8397
+ }
8398
+ return require('./lib/auto-verify').decideAutoVerify({ stepKey: 'step_4', repoRoot: process.cwd(), assessment });
8399
+ },
8400
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
8401
+ now: () => Date.now(),
8402
+ log: (line) => console.log(line),
8403
+ };
8404
+
8405
+ console.log(`[follow-batch] following batch ${batchId} for ${repoName}: poll ${pollMs / 1000}s, `
8406
+ + `${deadlineMs ? `deadline ${deadlineMs / 60_000} min` : 'no deadline'}, `
8407
+ + `emit ${opts.openPr ? 'push + draft PR' : 'local branch only'}`);
8408
+ const res = await followBatch(batchId, io, { pollMs, deadlineMs, maxOfflineMs, openPr: !!opts.openPr, repoName });
8409
+ console.log(`[follow-batch] ${res.reason}`);
8410
+ process.exit(res.exitCode);
8411
+ });
8412
+
7974
8413
  // ── HC-019n-followup-25: emit-pr (pipeline-recovery condition 7) ──────────────
7975
8414
  // Condition 6 proved step_4's diff applies AND its tests pass in isolation.
7976
8415
  // Condition 7 turns that verified diff into the reviewable artifact: a branch
@@ -7987,22 +8426,37 @@ program
7987
8426
  .option('--open-pr', 'Open a draft PR (implies --push)')
7988
8427
  .option('--ready', 'Open the PR ready-for-review instead of draft')
7989
8428
  .option('--skip-verify', 'Do not re-run the tests before committing (downgrades provenance)')
8429
+ .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
8430
  .option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
7991
8431
  .option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
7992
8432
  .option('--keep', 'Leave the worktree in place for debugging')
7993
8433
  .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
7994
8434
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7995
8435
  .action(async (workflowId, opts) => {
8436
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
7996
8437
  const { execFileSync, spawnSync } = require('child_process');
8438
+ // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8439
+ // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
8440
+ // deep in child_process, and a blank value coerces to 0 and kills the
8441
+ // command the instant it starts.
8442
+ let timeoutMs;
8443
+ try {
8444
+ timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
8445
+ } catch (e) {
8446
+ console.error(e.message);
8447
+ process.exit(2);
8448
+ }
7997
8449
  const os = require('os');
7998
8450
  const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
7999
8451
  const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
8452
+ const { verifyStateFor } = require('./lib/emit-pr');
8000
8453
  const {
8001
8454
  EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
8002
8455
  } = require('./lib/emit-pr');
8003
8456
  const { gitEnv } = require('./lib/git-env');
8004
8457
  const config = getConfig();
8005
8458
  const client = api(config);
8459
+ const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
8006
8460
 
8007
8461
  // --open-pr implies --push (cannot open a PR against an unpushed branch).
8008
8462
  const doPush = Boolean(opts.push || opts.openPr);
@@ -8016,16 +8470,28 @@ program
8016
8470
 
8017
8471
  // 1. Fetch step_4 output + the run's storyId (for naming/provenance).
8018
8472
  let output, storyId = 'story', runConfig = {};
8473
+ // HC-GF-270: a swallowed run fetch leaves storyId as the non-numeric 'story', which the
8474
+ // legacy rule would read as trusted. A failed fetch is untrusted on its own evidence.
8475
+ let runFetchFailed = false;
8476
+ // The run's REAL story id, for the provenance rule. `storyId` above falls back to 'story' for
8477
+ // branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
8478
+ // never arrived.
8479
+ let runStoryId = null;
8019
8480
  try {
8020
- const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
8021
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
8481
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
8022
8482
  const r = await client.get(url);
8483
+ // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
8484
+ if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
8023
8485
  output = r.data?.output;
8024
8486
  try {
8025
- const runR = await client.get(`/orchestrate/${workflowId}`);
8026
- storyId = runR.data?.storyId || runR.data?.story_id || 'story';
8487
+ const runR = await client.get(orchestratePath([workflowId]));
8488
+ // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
8489
+ // is not THIS run does not count as its config.
8490
+ if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
8491
+ runStoryId = runR.data?.storyId || runR.data?.story_id || null;
8492
+ storyId = runStoryId || 'story';
8027
8493
  runConfig = runR.data?.config || {};
8028
- } catch { /* naming falls back to 'story'; command falls back to make ci */ }
8494
+ } catch (e) { runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; /* naming falls back to 'story'; command falls back to make ci */ }
8029
8495
  } catch (e) {
8030
8496
  const msg = e.response?.data?.error || e.message;
8031
8497
  emit({ ok: false, reason: 'fetch_failed', detail: msg },
@@ -8033,9 +8499,41 @@ program
8033
8499
  process.exit(EXIT.NO_DIFF);
8034
8500
  }
8035
8501
 
8502
+ // ── HC-GF-270: the provenance gate (see verify-patch's copy for the full argument) ─────
8503
+ //
8504
+ // emit-pr runs `--verify` by default and creates a branch, so it is a second execution
8505
+ // surface and a second publishing surface for the same untrusted text. Its own guard, so
8506
+ // its own mutation kills its own test (Checklist B item 6).
8507
+ {
8508
+ let assessment;
8509
+ try {
8510
+ const { assessStorySource } = require('./lib/story-provenance');
8511
+ assessment = runFetchFailed
8512
+ ? { trusted: false, label: 'run-fetch-failed',
8513
+ reason: runFetchFailed === 'mismatch'
8514
+ ? 'the server answered for a different run, so this run\'s config was never read'
8515
+ : 'this run\'s config could not be read, so nothing is known about where its story text came from' }
8516
+ : assessStorySource(runConfig?.story_source, runStoryId);
8517
+ } catch (e) {
8518
+ assessment = { trusted: false, label: 'assessment-failed',
8519
+ reason: `story provenance could not be assessed: ${e.message}` };
8520
+ }
8521
+ if (!assessment.trusted && !opts.trustSource) {
8522
+ emit({ ok: false, reason: 'untrusted_source', detail: assessment.reason },
8523
+ `✗ refusing to emit: ${assessment.reason}\n`
8524
+ + ' No branch was created and nothing was executed.\n'
8525
+ + ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
8526
+ + ` Then: hone emit-pr ${workflowId} --trust-source`);
8527
+ process.exit(EXIT.UNTRUSTED_SOURCE);
8528
+ }
8529
+ }
8530
+
8036
8531
  // 2. Materialize the diff (shared with check-patch/verify-patch,
8037
8532
  // followup-23/27). Condition 7 presupposes 5.
8038
8533
  const blocks = extractFileBlocks(output);
8534
+ // HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
8535
+ // silently re-scoping is how a finished build came back as "no file changes to verify".
8536
+ if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
8039
8537
  const hasWork = hasMaterializableWork(blocks);
8040
8538
  if (blocks.noChanges || !hasWork) {
8041
8539
  emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
@@ -8101,9 +8599,11 @@ program
8101
8599
 
8102
8600
  // 4. Isolated worktree with a NAMED branch off HEAD (adopter tree untouched).
8103
8601
  const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
8104
- const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
8602
+ // HC-GF-304: see verify-patch. emit-pr would COMMIT the other invocation's patch onto this run's branch.
8603
+ const diffTmp = require('./lib/private-temp').privateTempFile('hone-emit-diff', 'patch.diff');
8604
+ const diffFile = diffTmp.file;
8105
8605
  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
8606
+ 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
8607
  // `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
8108
8608
  // before a successful commit (apply fails, verify fails), that empty branch
8109
8609
  // (pointing at HEAD) must be torn down too — else "a failing diff never
@@ -8120,7 +8620,7 @@ program
8120
8620
  try { execFileSync('git', ['branch', '-D', branch], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
8121
8621
  }
8122
8622
  try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
8123
- try { fs.unlinkSync(diffFile); } catch { /* best-effort */ }
8623
+ diffTmp.remove();
8124
8624
  };
8125
8625
  process.on('SIGINT', () => { cleanup(); process.exit(130); });
8126
8626
 
@@ -8151,23 +8651,48 @@ program
8151
8651
  // HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
8152
8652
  // (link the repo's node_modules when the lockfile is unchanged, else run the
8153
8653
  // lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
8654
+ // HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
8655
+ // and with --no-provision there is none, which is not the same as a failure.
8656
+ let provAll = null;
8154
8657
  if (opts.provision !== false) {
8155
- const { planProvision, provisionWorktree } = require('./lib/worktree-provision');
8658
+ const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
8156
8659
  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 });
8660
+ // HC-GF-201: provision EVERY package root, not just the worktree's top level.
8661
+ // This repo has three; the two skipped are where ci.unit runs, so their absent
8662
+ // modules surfaced as `Cannot find module` and were counted as the PATCH failing
8663
+ // tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
8664
+ const installCmd = readCIConfigDetails(process.cwd()).install_command;
8665
+ const provEntries = discoverPackageRoots(wt).map((root) => ({
8666
+ root,
8667
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
8668
+ exec: spawnSync,
8669
+ plan: planProvision({
8670
+ worktreeDir: root === '.' ? wt : path.join(wt, root),
8671
+ parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
8672
+ installCommand: root === '.' ? installCmd : undefined,
8673
+ }),
8674
+ }));
8675
+ provAll = provisionAllRoots(provEntries);
8676
+ const prov = { ok: provAll.ok,
8677
+ summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
8678
+ + (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
8159
8679
  if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
8160
8680
  }
8161
8681
  if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
8162
8682
  const run = spawnSync(command, {
8163
8683
  cwd: wt, shell: true, encoding: 'utf8',
8164
- timeout: Number(opts.timeout) * 1000, env: verifyCommandEnv(gitEnv()),
8684
+ timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8165
8685
  stdio: ['ignore', 'pipe', 'pipe'],
8166
8686
  });
8167
8687
  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';
8688
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
8689
+ // HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
8690
+ // narrowed verdict fell into the else below, which printed "verify failed, no branch
8691
+ // emitted" and then exited with that verdict's own exit code — zero. A failure path that
8692
+ // exits 0 is the HC-GF-207 shape this session opened by fixing.
8693
+ const emitState = verifyStateFor(v.verdict);
8694
+ if (emitState) {
8695
+ verifyState = emitState;
8171
8696
  } else {
8172
8697
  verifyOk = false;
8173
8698
  // HC-GF-008: a command that never started is not a failed verify.
@@ -8183,14 +8708,15 @@ program
8183
8708
  // 4c. Commit the applied diff onto the branch, inside the worktree.
8184
8709
  // No summary source (GET /orchestrate omits config/story text), so the
8185
8710
  // 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`);
8711
+ const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState, replacedCommand: commandReplaced && commandReplaced.command });
8712
+ const msgTmp = require('./lib/private-temp').privateTempFile('hone-emit-msg', 'message.txt');
8713
+ const msgFile = msgTmp.file;
8188
8714
  fs.writeFileSync(msgFile, message);
8189
8715
  try {
8190
8716
  execFileSync('git', ['-C', wt, 'add', '-A'], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
8191
8717
  execFileSync('git', ['-C', wt, 'commit', '-F', msgFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
8192
8718
  committed = true; // the branch now holds the deliverable — keep it past cleanup
8193
- } finally { try { fs.unlinkSync(msgFile); } catch { /* best-effort */ } }
8719
+ } finally { msgTmp.remove(); }
8194
8720
 
8195
8721
  const stat = (() => {
8196
8722
  try {
@@ -8214,8 +8740,9 @@ program
8214
8740
  }
8215
8741
  }
8216
8742
  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`);
8743
+ const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined, replacedCommand: commandReplaced && commandReplaced.command });
8744
+ const bodyTmp = require('./lib/private-temp').privateTempFile('hone-emit-prbody', 'body.md');
8745
+ const bodyFile = bodyTmp.file;
8219
8746
  fs.writeFileSync(bodyFile, prBody);
8220
8747
  const title = `${storyId}: Hone step_4 change (workflow ${String(workflowId).slice(0, 8)})`;
8221
8748
  const ghArgs = ['pr', 'create', '--head', branch, '--title', title, '--body-file', bodyFile];
@@ -8229,7 +8756,7 @@ program
8229
8756
  emit({ ok: false, verdict: 'publish_failed', stage: 'open_pr', detail, branch },
8230
8757
  `✗ branch pushed, but opening the PR failed: ${detail}\n → open it manually, or check \`gh auth status\`.`);
8231
8758
  exitCode = EXIT.PUBLISH_FAILED;
8232
- } finally { try { fs.unlinkSync(bodyFile); } catch { /* best-effort */ } }
8759
+ } finally { bodyTmp.remove(); }
8233
8760
  }
8234
8761
 
8235
8762
  if (exitCode === EXIT.PASS) {
@@ -8276,7 +8803,20 @@ program
8276
8803
  .option('--workflow-id <id>', 'the run id to report the verdict against (required with --report)')
8277
8804
  .option('--format <fmt>', 'pretty or json', 'pretty')
8278
8805
  .action(async (prOrBranch, opts) => {
8806
+ // HC-GF-270-followup-1: a --workflow-id is checked before any gh call or CI command runs.
8807
+ refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
8279
8808
  const { execFileSync, spawnSync } = require('child_process');
8809
+ // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8810
+ // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
8811
+ // deep in child_process, and a blank value coerces to 0 and kills the
8812
+ // command the instant it starts.
8813
+ let timeoutMs;
8814
+ try {
8815
+ timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
8816
+ } catch (e) {
8817
+ console.error(e.message);
8818
+ process.exit(2);
8819
+ }
8280
8820
  const { readCIGateConfig } = require('./lib/pipeline-config');
8281
8821
  const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
8282
8822
  const { EXIT, resolveGateMode, interpretGhChecks, ciGateVerdict, renderStep5cArtifact } = require('./lib/verify-pr');
@@ -8298,15 +8838,19 @@ program
8298
8838
  const mode = resolveGateMode(opts.mode, configGate);
8299
8839
 
8300
8840
  // 3. Skill audit — reuse the working `hone step-5b` command (deterministic
8301
- // SA-001 engine over the PR diff). Informational; never affects the exit.
8841
+ // SA-001 engine over the PR diff). HC-GF-227: its BLOCKER verdict (exit 2) fails this command
8842
+ // too, once the CI gate itself has passed; any other failure to run it is reported, not a verdict.
8302
8843
  let skillAudit = { ran: false };
8303
8844
  if (!opts.skipSkillAudit) {
8845
+ const artifact = `.github/pipeline/${storyId}/step-5b-skill-audit.md`;
8304
8846
  try {
8305
8847
  execFileSync(process.execPath, [__filename, 'step-5b', '--story-id', storyId, '--base', opts.base],
8306
8848
  { cwd: repoRoot, env: process.env, stdio: opts.format === 'json' ? 'ignore' : 'inherit' });
8307
- skillAudit = { ran: true, artifact: `.github/pipeline/${storyId}/step-5b-skill-audit.md` };
8849
+ skillAudit = { ran: true, artifact };
8308
8850
  } catch (e) {
8309
- skillAudit = { ran: true, error: (e.message || '').split('\n')[0] };
8851
+ skillAudit = e.status === STEP_5B_BLOCKED
8852
+ ? { ran: true, blocked: true, artifact }
8853
+ : { ran: true, error: (e.message || '').split('\n')[0] };
8310
8854
  }
8311
8855
  }
8312
8856
 
@@ -8337,7 +8881,7 @@ program
8337
8881
  if (opts.format !== 'json') console.log(` running local CI: ${command} (timeout ${opts.timeout}s)`);
8338
8882
  // HC-GF-008: the adopter's own command, without make state inherited from whoever ran hone.
8339
8883
  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()) });
8884
+ const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'inherit', 'inherit'], env: verifyCommandEnv(gitEnv()) });
8341
8885
  local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
8342
8886
  }
8343
8887
 
@@ -8362,7 +8906,7 @@ program
8362
8906
  } else {
8363
8907
  try {
8364
8908
  const client = api(getConfig());
8365
- await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
8909
+ await client.post(orchestratePath([opts.workflowId, 'verdict']), {
8366
8910
  source: 'verify-pr',
8367
8911
  stepKey: 'step_5c',
8368
8912
  verdict: verdict.verdict,
@@ -8377,14 +8921,16 @@ program
8377
8921
  }
8378
8922
  }
8379
8923
 
8380
- // 7. Report. The exit code is the CI verdict's; the skill audit is advisory.
8924
+ // 7. Report. A failing CI gate keeps its own exit code; when CI passed, a BLOCKER skill assertion
8925
+ // fails the command with EXIT.SKILL_BLOCKER (HC-GF-227). The report sent above is the CI gate's.
8926
+ const exitCode = verdict.exitCode === EXIT.PASS && skillAudit.blocked ? EXIT.SKILL_BLOCKER : verdict.exitCode;
8381
8927
  const sym = (verdict.verdict === 'pass' || verdict.verdict === 'disabled') ? '✓' : verdict.verdict === 'pending' ? '⏳' : '✗';
8382
8928
  emit(
8383
- { ok: verdict.exitCode === EXIT.PASS, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
8929
+ { ok: exitCode === EXIT.PASS, exitCode, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
8384
8930
  `${sym} CI gate (${mode}): ${verdict.verdict} — ${verdict.detail}\n` +
8385
- ` skill audit: ${opts.skipSkillAudit ? 'skipped' : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
8931
+ ` skill audit: ${opts.skipSkillAudit ? 'skipped' : skillAudit.blocked ? `✗ BLOCKER finding(s) — ${skillAudit.artifact}` : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
8386
8932
  ` → .github/pipeline/${storyId}/step-5c-ci.md`);
8387
- process.exit(verdict.exitCode);
8933
+ process.exit(exitCode);
8388
8934
  });
8389
8935
 
8390
8936
  // ── CLI setup ─────────────────────────────────────────────────────────────────