@hone-ai/cli 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hone-cli.js CHANGED
@@ -39,6 +39,7 @@ const { parseReviewJSON } = require('./lib/parse-review-json');
39
39
  const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
40
40
  const { gitEnv } = require('./lib/git-env');
41
41
  const { collectIncludePaths } = require('./lib/include-paths');
42
+ const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
42
43
  const program = new Command();
43
44
 
44
45
  // ── Config resolution ─────────────────────────────────────────────────────────
@@ -68,6 +69,31 @@ function requireAdminKey() {
68
69
  return adminKey;
69
70
  }
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
+
71
97
  function getConfig() {
72
98
  // Priority: env var → ~/.honerc → error
73
99
  const rc = readRc();
@@ -188,6 +214,8 @@ program
188
214
  .option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
189
215
  .option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
190
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 }]);
191
219
  const { readAgentEvalConfig } = require('./lib/pipeline-config');
192
220
  const { runAgent } = require('./lib/agent-executor');
193
221
  const { runCheck } = require('./lib/eval-graders');
@@ -343,13 +371,13 @@ program
343
371
  } else {
344
372
  try {
345
373
  const client = api(getConfig());
346
- await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
374
+ await client.post(orchestratePath([opts.workflowId, 'verdict']), {
347
375
  source: 'agent-eval',
348
376
  stepKey: 'step_4',
349
377
  verdict: anyFailed ? 'fail' : 'pass',
350
378
  exitCode: anyFailed ? 1 : 0,
351
379
  detail: `agent-eval ${opts.category}: ${results.filter((r) => r.passed).length}/${results.length} probes passed`,
352
- });
380
+ }, require('./lib/fresh-connection').freshConnection());
353
381
  if (opts.output !== 'json') console.log(` → reported agent-eval verdict '${anyFailed ? 'fail' : 'pass'}' to run ${opts.workflowId} (advisory)`);
354
382
  } catch (e) {
355
383
  if (opts.output !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
@@ -900,6 +928,9 @@ program
900
928
  if (ciGateChoice.source !== 'auto-detect') {
901
929
  setupEnv.CI_GATE = ciGateChoice.mode;
902
930
  }
931
+ // HC-GF-321: --stack was declared and never read. The script applies it
932
+ // after detection (apply_stack_override) and refuses an unsupported value.
933
+ if (opts.stack) setupEnv.HONE_STACK_OVERRIDE = String(opts.stack).toLowerCase();
903
934
  try {
904
935
  execSync(`bash "${scriptPath}" ${flags}`, {
905
936
  stdio: 'inherit',
@@ -911,6 +942,25 @@ program
911
942
  process.exit(1);
912
943
  }
913
944
 
945
+ // HC-GF-320: every phase below writes (CLAUDE.md, config grounding, test
946
+ // packages, GitHub branch protection). A dry run stops here and says what
947
+ // they would do.
948
+ if (opts.dryRun) {
949
+ const { postScriptDryRunPlan } = require('./lib/setup-dry-run');
950
+ const plan = postScriptDryRunPlan({
951
+ claudeMdTemplate: fs.existsSync(path.join(tmpDir, 'CLAUDE.md')),
952
+ claudeMdExists: fs.existsSync(path.join(process.cwd(), 'CLAUDE.md')),
953
+ configExists: fs.existsSync(path.join(process.cwd(), '.github', '.pipeline-config.yml')),
954
+ installTests: !!opts.installTests,
955
+ skipBranchProtection: opts.branchProtection === false ||
956
+ /^(1|true|yes)$/i.test(process.env.HONE_SKIP_BRANCH_PROTECTION || ''),
957
+ });
958
+ for (const line of plan) console.log(` DRY-RUN: ${line}`);
959
+ console.log(' Dry run complete: nothing was written.');
960
+ fs.rmSync(tmpDir, { recursive: true, force: true });
961
+ return;
962
+ }
963
+
914
964
  // HC-RC-004: if the adopter chose `local`, run the setup-local-ci
915
965
  // scaffold inline so they end the setup command with a working
916
966
  // local-CI stack (Makefile + compose) instead of having to know
@@ -1536,6 +1586,10 @@ async function createTestEnv(repoRoot, opts) {
1536
1586
  function recommendTestFramework(repoRoot) {
1537
1587
  const detected = detectExistingTestFramework(repoRoot);
1538
1588
  if (detected.testFw) return; // Already has tests — no recommendation needed
1589
+ // HC-GF-321: every recommendation below is an npm package. Without a root
1590
+ // package.json the stack is not Node (or lives in subdirectories), and a
1591
+ // pytest repository was told to install vitest.
1592
+ if (detected.stack === 'unknown') return;
1539
1593
 
1540
1594
  const pkgs = getInstallPackages(detected.stack);
1541
1595
  const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
@@ -3060,23 +3114,25 @@ program
3060
3114
  console.log('No .github/learnings/ directory found — nothing to retract.');
3061
3115
  return;
3062
3116
  }
3063
- const yaml = require('js-yaml');
3117
+ // HC-GF-311-followup-1: per learning, in every schema `hone promote` reads — not just the file's top level.
3118
+ const { parseRetracted } = require('./lib/learnings-parse');
3064
3119
  const files = fs.readdirSync(learningsDir).filter(f => f.endsWith('.yml'));
3065
3120
  for (const file of files) {
3066
- try {
3067
- const parsed = yaml.load(fs.readFileSync(path.join(learningsDir, file), 'utf8'));
3068
- const entries = Array.isArray(parsed) ? parsed : [parsed];
3069
- for (const entry of entries) {
3070
- if (entry?.status === 'retracted' && entry.id) {
3071
- payload.push({
3072
- id: entry.id,
3073
- repo_name: repoName,
3074
- reason: entry.retraction_reason || 'Retracted (no reason provided in YAML)',
3075
- });
3076
- }
3077
- }
3078
- } catch (e) {
3079
- console.warn(` ⚠ ${file}: parse error — ${e.message}`);
3121
+ // One unreadable entry (a directory or a dangling link named *.yml) must not hide the rest.
3122
+ let content;
3123
+ try { content = fs.readFileSync(path.join(learningsDir, file), 'utf8'); }
3124
+ catch (e) { console.warn(` ⚠ ${file}: read error — ${e.message}`); continue; }
3125
+ const { retracted, parseError } = parseRetracted(content);
3126
+ if (parseError) {
3127
+ console.warn(` ⚠ ${file}: parse error — ${parseError.message}`);
3128
+ continue;
3129
+ }
3130
+ for (const { id, reason } of retracted) {
3131
+ payload.push({
3132
+ id,
3133
+ repo_name: repoName,
3134
+ reason: reason || 'Retracted (no reason provided in YAML)',
3135
+ });
3080
3136
  }
3081
3137
  }
3082
3138
  if (payload.length === 0) {
@@ -4955,10 +5011,19 @@ program
4955
5011
  }
4956
5012
  // Path B: GitHub issue fetch via gh
4957
5013
  else if (issueId) {
5014
+ // HC-GF-270-followup-1: an issue number or an issue URL, which are the forms gh accepts, and
5015
+ // nothing else. gh reads an argument that begins with `-` as a flag even without a shell.
5016
+ if (!/^[1-9]\d*$/.test(issueId) && !/^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/issues\/[1-9]\d*$/.test(issueId)) {
5017
+ console.error(`error: <issueId> must be an issue number like 142, or an issue URL (got ${JSON.stringify(issueId)})`);
5018
+ console.error('usage: hone classify-story 142');
5019
+ console.error(' hone classify-story --input-file inputs.yml');
5020
+ process.exit(2);
5021
+ }
4958
5022
  try {
4959
- const out = execSync(
4960
- `gh issue view ${issueId} --json title,body,labels,url`,
4961
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
5023
+ // An argument list, not a shell string, and a bound: a hung gh must not hang the command.
5024
+ const out = require('child_process').execFileSync(
5025
+ 'gh', ['issue', 'view', String(issueId), '--json', 'title,body,labels,url'],
5026
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }
4962
5027
  );
4963
5028
  issueMeta = JSON.parse(out);
4964
5029
  const labels = (issueMeta.labels || []).map(l => l.name);
@@ -4980,6 +5045,10 @@ program
4980
5045
  is_first_of_its_kind: false,
4981
5046
  };
4982
5047
  } catch (e) {
5048
+ if (e && e.code === 'ETIMEDOUT') {
5049
+ console.error(`error: gh issue view ${issueId} timed out after 30s`);
5050
+ process.exit(2);
5051
+ }
4983
5052
  console.error(`error: gh issue view ${issueId} failed: ${(e.stderr || '').toString().trim() || e.message}`);
4984
5053
  console.error(`hint: install GitHub CLI (cli.github.com), run \`gh auth login\`, or pass --input-file <path>`);
4985
5054
  process.exit(2);
@@ -5924,7 +5993,7 @@ program
5924
5993
  try {
5925
5994
  // HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
5926
5995
  // knows, so an older CLI — which sends nothing — keeps being served contract 1.
5927
- const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction?contracts=1,2`);
5996
+ const { data } = await client.get(orchestratePath([runId, 'step-instruction'], { contracts: '1,2' }));
5928
5997
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5929
5998
  console.log(`Step: ${data.stepKey} (${data.agent})`);
5930
5999
  console.log(`Contract: ${data.contractVersion}`);
@@ -5976,7 +6045,7 @@ program
5976
6045
  output = fs.readFileSync(opts.file, 'utf8');
5977
6046
  }
5978
6047
  const { data } = await client.post(
5979
- `/orchestrate/${encodeURIComponent(runId)}/step/${encodeURIComponent(stepKey)}/editor-output`,
6048
+ orchestratePath([runId, 'step', stepKey, 'editor-output']),
5980
6049
  { contractVersion: opts.contract, output }
5981
6050
  );
5982
6051
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
@@ -6124,12 +6193,7 @@ program
6124
6193
  // the 2026-09-08 editor test nearly meaningless; here it costs money rather than time.
6125
6194
  try {
6126
6195
  const h = await client.get('/health').then(r => r.data).catch(() => null);
6127
- const llm = (h && h.llm) || null;
6128
- if (llm && llm.local) {
6129
- console.log(`LOCAL ${config.apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`);
6130
- } else {
6131
- console.log(`PROD ${config.apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`);
6132
- }
6196
+ console.log(require('./lib/server-banner').serverBanner(config.apiUrl, h));
6133
6197
  } catch { /* never block a run on a banner */ }
6134
6198
 
6135
6199
  const { resolveRunTarget, isRunId } = require('./lib/run-target');
@@ -6182,7 +6246,7 @@ program
6182
6246
 
6183
6247
  let status;
6184
6248
  try {
6185
- const { data: pollData } = await client.get(`/orchestrate/${pollRunId}`);
6249
+ const { data: pollData } = await client.get(orchestratePath([pollRunId]));
6186
6250
  status = pollData;
6187
6251
  } catch (e) {
6188
6252
  console.error(`Poll error: ${e.message}`);
@@ -6250,7 +6314,8 @@ program
6250
6314
  console.log(` ${remedy.stepKey} named the files it needs. Re-queue with:`);
6251
6315
  console.log(` ${cmd}`);
6252
6316
  if (remedy.truncated) {
6253
- console.log(` ⚠ it asked for more than ${remedy.paths.length} files; the rest were dropped.`);
6317
+ // HC-GF-263-followup-1: the cap counts entries, and one file may have several.
6318
+ console.log(` ⚠ it asked for more than ${remedy.paths.length} paths; the rest were dropped.`);
6254
6319
  }
6255
6320
  }
6256
6321
  // H5: say how many requested paths were withheld from the command, never what they were.
@@ -6352,14 +6417,14 @@ program
6352
6417
 
6353
6418
  if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
6354
6419
  try {
6355
- await client.post(`/orchestrate/${pollRunId}/approve`, { stepKey: d.stepKey });
6420
+ await client.post(orchestratePath([pollRunId, 'approve']), { stepKey: d.stepKey });
6356
6421
  console.log(' Approved — workflow resuming...');
6357
6422
  } catch (e) {
6358
6423
  console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
6359
6424
  }
6360
6425
  } else if (answer.toLowerCase() === 'kill') {
6361
6426
  try {
6362
- await client.post(`/orchestrate/${pollRunId}/kill`, { reason: 'CLI kill' });
6427
+ await client.post(orchestratePath([pollRunId, 'kill']), { reason: 'CLI kill' });
6363
6428
  console.log(' Workflow killed.');
6364
6429
  process.exit(0);
6365
6430
  } catch (e) {
@@ -6388,7 +6453,7 @@ program
6388
6453
  if (opts.status) {
6389
6454
  try {
6390
6455
  const statusId = await existingRunId('--status');
6391
- const { data } = await client.get(`/orchestrate/${statusId}`);
6456
+ const { data } = await client.get(orchestratePath([statusId]));
6392
6457
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6393
6458
  console.log('');
6394
6459
  console.log(`Workflow: ${data.runId}`);
@@ -6438,7 +6503,7 @@ program
6438
6503
  if (opts.kill) {
6439
6504
  try {
6440
6505
  const killId = await existingRunId('--kill');
6441
- const { data } = await client.post(`/orchestrate/${killId}/kill`, { reason: 'CLI kill' });
6506
+ const { data } = await client.post(orchestratePath([killId, 'kill']), { reason: 'CLI kill' });
6442
6507
  console.log(`Workflow ${data.runId} killed.`);
6443
6508
  } catch (e) {
6444
6509
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
@@ -6452,7 +6517,7 @@ program
6452
6517
  let approveId;
6453
6518
  try {
6454
6519
  approveId = await existingRunId(`--approve ${opts.approve}`);
6455
- await client.post(`/orchestrate/${approveId}/approve`,
6520
+ await client.post(orchestratePath([approveId, 'approve']),
6456
6521
  { stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
6457
6522
  acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
6458
6523
  console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
@@ -6606,6 +6671,10 @@ program
6606
6671
  // auto-approve on output text, which never ran. Approve overnight on evidence with `hone follow-batch`.
6607
6672
  .option('--overnight', 'Enable Night Shift mode (longer timeouts, max 25 stories; step_4/step_5 still pause for approval)', false)
6608
6673
  .action(async (opts) => {
6674
+ refuseBadRunArgs([ // HC-GF-270-followup-1
6675
+ { label: '--status', kind: 'batchId', value: opts.status, optional: true },
6676
+ { label: '--report', kind: 'batchId', value: opts.report, optional: true },
6677
+ ]);
6609
6678
  const config = getConfig();
6610
6679
  const client = api(config);
6611
6680
 
@@ -6650,7 +6719,7 @@ program
6650
6719
  try {
6651
6720
  const format = opts.format === 'json' ? 'json' : 'md';
6652
6721
  const { data, headers } = await client.get(
6653
- `/orchestrate/batch/${opts.report}/report`,
6722
+ orchestratePath(['batch', opts.report, 'report']),
6654
6723
  { params: { format }, responseType: 'text', transformResponse: [(d) => d] }
6655
6724
  );
6656
6725
  if (format === 'json') {
@@ -6701,7 +6770,7 @@ program
6701
6770
  // ── --status <batchId> ────────────────────────────────────
6702
6771
  if (opts.status) {
6703
6772
  try {
6704
- const { data } = await client.get(`/orchestrate/batch/${opts.status}`);
6773
+ const { data } = await client.get(orchestratePath(['batch', opts.status]));
6705
6774
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6706
6775
  console.log('');
6707
6776
  console.log(`Batch: ${data.batchId}`);
@@ -7561,13 +7630,15 @@ showCmd
7561
7630
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7562
7631
  .option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
7563
7632
  .action(async (workflowId, stepKey, opts) => {
7633
+ refuseBadRunArgs([ // HC-GF-270-followup-1
7634
+ { label: '<workflowId>', kind: 'runId', value: workflowId },
7635
+ { label: '<stepKey>', kind: 'stepKey', value: stepKey },
7636
+ { label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
7637
+ ]);
7564
7638
  const config = getConfig();
7565
7639
  const client = api(config);
7566
7640
  try {
7567
- const q = [];
7568
- if (opts.attempt) q.push(`attempt=${opts.attempt}`);
7569
- if (opts.prompt) q.push('prompt=1');
7570
- const url = `/orchestrate/${workflowId}/step/${stepKey}${q.length ? `?${q.join('&')}` : ''}`;
7641
+ const url = orchestratePath([workflowId, 'step', stepKey], { attempt: opts.attempt, prompt: opts.prompt ? '1' : undefined });
7571
7642
  const r = await client.get(url);
7572
7643
  const s = r.data;
7573
7644
  console.log(`── step output ──────────────────────────────────────────`);
@@ -7624,6 +7695,7 @@ program
7624
7695
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7625
7696
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7626
7697
  .action(async (workflowId, opts) => {
7698
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270-followup-1
7627
7699
  const { execFileSync } = require('child_process');
7628
7700
  const os = require('os');
7629
7701
  const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
@@ -7639,8 +7711,7 @@ program
7639
7711
 
7640
7712
  let output;
7641
7713
  try {
7642
- const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
7643
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
7714
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7644
7715
  const r = await client.get(url);
7645
7716
  output = r.data?.output;
7646
7717
  } catch (e) {
@@ -7764,6 +7835,7 @@ program
7764
7835
  .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)')
7765
7836
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7766
7837
  .action(async (workflowId, opts) => {
7838
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
7767
7839
  const { execFileSync, spawnSync } = require('child_process');
7768
7840
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
7769
7841
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -7782,13 +7854,6 @@ program
7782
7854
  const { gitEnv } = require('./lib/git-env');
7783
7855
  const config = getConfig();
7784
7856
  const client = api(config);
7785
- // HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
7786
- // resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
7787
- // patch while THIS run's config was assessed. A step key is a step key.
7788
- if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
7789
- console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
7790
- process.exit(2);
7791
- }
7792
7857
  const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
7793
7858
 
7794
7859
  const emit = (obj, humanLine) => {
@@ -7806,8 +7871,7 @@ program
7806
7871
  let runStoryId = null;
7807
7872
  let runFetchFailed = false;
7808
7873
  try {
7809
- const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
7810
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
7874
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7811
7875
  const r = await client.get(url);
7812
7876
  // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
7813
7877
  if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
@@ -7817,7 +7881,7 @@ program
7817
7881
  verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7818
7882
  // best-effort: pull the run's ci_local_command for the default
7819
7883
  try {
7820
- const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
7884
+ const runR = await client.get(orchestratePath([workflowId]));
7821
7885
  // HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
7822
7886
  // `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
7823
7887
  // 200 with no config. And a body that is not THIS run is not this run's config.
@@ -8234,7 +8298,7 @@ program
8234
8298
  // change the local exit code (the verdict is already true locally).
8235
8299
  if (opts.report && reportVerdict) {
8236
8300
  try {
8237
- await client.post(`/orchestrate/${encodeURIComponent(workflowId)}/verdict`, {
8301
+ await client.post(orchestratePath([workflowId, 'verdict']), {
8238
8302
  source: 'verify-patch',
8239
8303
  stepKey: opts.stepKey,
8240
8304
  verdict: reportVerdict,
@@ -8243,7 +8307,7 @@ program
8243
8307
  attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
8244
8308
  headSha,
8245
8309
  gates: reportGates.length ? reportGates : undefined,
8246
- });
8310
+ }, require('./lib/fresh-connection').freshConnection());
8247
8311
  if (opts.format !== 'json') {
8248
8312
  // HC-GF-070: name the gate that is actually configured. This said "(advisory)"
8249
8313
  // unconditionally, and printed it on a run where `enforce` was auto-approving
@@ -8268,11 +8332,12 @@ program
8268
8332
  .description('What happened on a run, and what still needs you')
8269
8333
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
8270
8334
  .action(async (runId, opts) => {
8335
+ refuseBadRunArgs([{ label: '<runId>', kind: 'runId', value: runId }]); // HC-GF-270-followup-1
8271
8336
  const { buildReport, renderReport } = require('./lib/run-report');
8272
8337
  const config = getConfig();
8273
8338
  const client = api(config);
8274
8339
  try {
8275
- const { data } = await client.get(`/orchestrate/${runId}`);
8340
+ const { data } = await client.get(orchestratePath([runId]));
8276
8341
  const report = buildReport(data);
8277
8342
  if (opts.format === 'json') console.log(JSON.stringify(report, null, 2));
8278
8343
  else console.log(renderReport(report));
@@ -8334,8 +8399,8 @@ program
8334
8399
  const client = api(config);
8335
8400
  const repoName = opts.repo || path.basename(process.cwd());
8336
8401
  const io = {
8337
- fetchBatch: async (id) => (await client.get(`/orchestrate/batch/${encodeURIComponent(id)}`)).data,
8338
- fetchRun: async (id) => (await client.get(`/orchestrate/${encodeURIComponent(id)}`)).data,
8402
+ fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
8403
+ fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
8339
8404
  runVerb: (argv) => new Promise((resolve) => {
8340
8405
  const child = spawn(process.execPath, [process.argv[1], ...argv],
8341
8406
  { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
@@ -8389,6 +8454,7 @@ program
8389
8454
  .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
8390
8455
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
8391
8456
  .action(async (workflowId, opts) => {
8457
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
8392
8458
  const { execFileSync, spawnSync } = require('child_process');
8393
8459
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8394
8460
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -8406,19 +8472,26 @@ program
8406
8472
  const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
8407
8473
  const { verifyStateFor } = require('./lib/emit-pr');
8408
8474
  const {
8409
- EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
8475
+ EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish, recordPr,
8410
8476
  } = require('./lib/emit-pr');
8411
8477
  const { gitEnv } = require('./lib/git-env');
8412
8478
  const config = getConfig();
8413
8479
  const client = api(config);
8414
- // HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
8415
- // resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
8416
- // patch while THIS run's config was assessed. A step key is a step key.
8417
- if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
8418
- console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
8419
- process.exit(2);
8420
- }
8421
8480
  const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
8481
+ // HC-GF-302: record the PR on the run. The POST goes out after the verify, push and `gh` steps,
8482
+ // which block the event loop for minutes, and a keep-alive socket the server closed meanwhile
8483
+ // fails `write EPIPE` when reused (HC-GF-326). A fresh connection per record avoids that.
8484
+ //
8485
+ // F3 (review): baseSha and attempt are the CALLER's to supply, not this closure's defaults —
8486
+ // the two call sites below have different bases for trusting each one (see their own
8487
+ // comments). recordPr itself stays pure; this only wires up the transport.
8488
+ const recordPrOnRun = ({ prUrl, branch: prBranch, baseSha: recBaseSha = null, attempt: recAttempt }) => recordPr({
8489
+ post: (p, body) => client.post(p, body, require('./lib/fresh-connection').freshConnection()),
8490
+ path: orchestratePath([workflowId, 'pr-record']),
8491
+ prUrl, branch: prBranch, baseSha: recBaseSha, attempt: recAttempt,
8492
+ });
8493
+ const recordWarning = (rec) => (rec.recorded || opts.format === 'json'
8494
+ ? '' : `\n ⚠ the run's PR record was not saved (non-fatal; the morning report may miss this link): ${rec.reason}`);
8422
8495
 
8423
8496
  // --open-pr implies --push (cannot open a PR against an unpushed branch).
8424
8497
  const doPush = Boolean(opts.push || opts.openPr);
@@ -8439,15 +8512,18 @@ program
8439
8512
  // branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
8440
8513
  // never arrived.
8441
8514
  let runStoryId = null;
8515
+ // HC-GF-302: the attempt the server resolved (the latest one unless --attempt named it), which
8516
+ // orders this run's PR records. Not opts.attempt: that is absent in the default case.
8517
+ let stepAttempt = null;
8442
8518
  try {
8443
- const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
8444
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
8519
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
8445
8520
  const r = await client.get(url);
8446
8521
  // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
8447
8522
  if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
8448
8523
  output = r.data?.output;
8524
+ stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
8449
8525
  try {
8450
- const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
8526
+ const runR = await client.get(orchestratePath([workflowId]));
8451
8527
  // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
8452
8528
  // is not THIS run does not count as its config.
8453
8529
  if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
@@ -8550,8 +8626,24 @@ program
8550
8626
  }
8551
8627
  const decision = decidePublish({ branchExists, openPrUrl });
8552
8628
  if (decision.action === 'already_open') {
8553
- emit({ ok: true, verdict: 'already_open', prUrl: decision.prUrl, branch },
8554
- `✓ a draft PR is already open for ${branch}: ${decision.prUrl}`);
8629
+ // HC-GF-302 (review F3): record here too — hone follow-batch sends a completed run's emit
8630
+ // to the branch its draft already opened, so this is the path that retries a record the
8631
+ // draft failed to save. But this path never verified anything against a specific attempt:
8632
+ // it found an already-open PR without running check-patch/verify-patch here, so it has no
8633
+ // basis to claim `headSha` is this attempt's base — baseSha is always null on this path.
8634
+ // And `stepAttempt` above is whichever attempt the server considers LATEST, which is not
8635
+ // necessarily the attempt the open PR's branch was built from — so this only records when
8636
+ // the caller named the attempt explicitly with --attempt. `hone follow-batch` always does
8637
+ // (cli/lib/batch-follow.js buildArgs pushes `--attempt N` for every emit-pr call it makes);
8638
+ // a bare `hone emit-pr --open-pr` with no --attempt has no basis to order a record either,
8639
+ // so it records nothing rather than guessing.
8640
+ const explicitAttempt = opts.attempt !== undefined ? Number(opts.attempt) : null;
8641
+ const rec = explicitAttempt != null
8642
+ ? await recordPrOnRun({ prUrl: decision.prUrl, branch, baseSha: null, attempt: explicitAttempt })
8643
+ : { recorded: false, reason: 'the open PR\'s attempt is unknown; pass --attempt' };
8644
+ emit({ ok: true, verdict: 'already_open', prUrl: decision.prUrl, branch, recorded: rec.recorded,
8645
+ ...(rec.recorded ? {} : { recordError: rec.reason }) },
8646
+ `✓ a draft PR is already open for ${branch}: ${decision.prUrl}${recordWarning(rec)}`);
8555
8647
  process.exit(EXIT.PASS);
8556
8648
  }
8557
8649
  if (decision.action === 'branch_conflict') {
@@ -8724,8 +8816,13 @@ program
8724
8816
 
8725
8817
  if (exitCode === EXIT.PASS) {
8726
8818
  const tier = prUrl ? 'pr' : pushed ? 'pushed' : 'dry_run';
8819
+ // HC-GF-302: only a PR is recorded; a pushed or local branch has no link to show. This
8820
+ // is the path that just verified the diff and committed it: headSha is the commit the
8821
+ // patch was verified against, so it is a true baseSha here (unlike the already_open
8822
+ // path above, which never ran that verification and always records baseSha: null).
8823
+ const rec = prUrl ? await recordPrOnRun({ prUrl, branch, baseSha: headSha || null, attempt: stepAttempt }) : null;
8727
8824
  const human = prUrl
8728
- ? `✓ draft PR opened: ${prUrl}`
8825
+ ? `✓ draft PR opened: ${prUrl}${recordWarning(rec)}`
8729
8826
  : pushed
8730
8827
  ? `✓ branch pushed to origin/${branch}\n → open a PR: gh pr create --head ${branch} --draft`
8731
8828
  : `✓ branch '${branch}' built locally (dry run — nothing pushed)\n` +
@@ -8733,7 +8830,11 @@ program
8733
8830
  ` → push it: hone emit-pr ${workflowId} --push\n` +
8734
8831
  ` → push + PR: hone emit-pr ${workflowId} --open-pr\n` +
8735
8832
  ` → discard it: git branch -D ${branch}`;
8736
- emit({ ok: true, verdict: tier, branch, verifyState, prUrl, pushed, headSha }, human);
8833
+ // F8 (review): --format json previously dropped the failure reason entirely once
8834
+ // `recorded` was false — a reader had no way to tell why without re-running in
8835
+ // --format pretty. Same object, one extra field, omitted once recorded:true.
8836
+ emit({ ok: true, verdict: tier, branch, verifyState, prUrl, pushed, headSha,
8837
+ ...(rec ? { recorded: rec.recorded, ...(rec.recorded ? {} : { recordError: rec.reason }) } : {}) }, human);
8737
8838
  }
8738
8839
  }
8739
8840
  }
@@ -8766,6 +8867,8 @@ program
8766
8867
  .option('--workflow-id <id>', 'the run id to report the verdict against (required with --report)')
8767
8868
  .option('--format <fmt>', 'pretty or json', 'pretty')
8768
8869
  .action(async (prOrBranch, opts) => {
8870
+ // HC-GF-270-followup-1: a --workflow-id is checked before any gh call or CI command runs.
8871
+ refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
8769
8872
  const { execFileSync, spawnSync } = require('child_process');
8770
8873
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8771
8874
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -8867,13 +8970,13 @@ program
8867
8970
  } else {
8868
8971
  try {
8869
8972
  const client = api(getConfig());
8870
- await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
8973
+ await client.post(orchestratePath([opts.workflowId, 'verdict']), {
8871
8974
  source: 'verify-pr',
8872
8975
  stepKey: 'step_5c',
8873
8976
  verdict: verdict.verdict,
8874
8977
  exitCode: verdict.exitCode,
8875
8978
  detail: verdict.detail,
8876
- });
8979
+ }, require('./lib/fresh-connection').freshConnection());
8877
8980
  reported = true;
8878
8981
  if (opts.format !== 'json') console.log(` → reported CI-gate verdict '${verdict.verdict}' to run ${opts.workflowId} (advisory)`);
8879
8982
  } catch (e) {
@@ -315,13 +315,25 @@ function anchorLineForStory(content, storyText) {
315
315
  * The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
316
316
  * gets the operator a window near it, which beats none.
317
317
  */
318
+ /**
319
+ * HC-GF-206-followup-1: a collapsed hit is a real site — the collapse only means it EXPECTS to
320
+ * share its neighbour's window, not that it is covered. The final window's radius is chosen
321
+ * later (as low as 200 chars, `planFileExcerpt`) and can land under the 40-line threshold that
322
+ * collapsed it, so a hit collapsed at 36 lines from its neighbour can fall outside the window
323
+ * that was supposed to carry it, with nothing reporting the loss. `collapsed` returns each
324
+ * dropped hit with `near`, the kept site's line it was folded into, so the caller can extend
325
+ * that site's window to actually reach it.
326
+ *
327
+ * @returns {{sites: Array<{line: number, anchor: string}>,
328
+ * collapsed: Array<{line: number, anchor: string, near: number}>}}
329
+ */
318
330
  function anchorSitesForStory(content, storyText, max = 6) {
319
331
  // HC-GF-204: every region a story's terms point at, best-first. `anchorLineForStory`
320
332
  // answers "where does this live"; a story also needs "where is it USED", and on run
321
333
  // ad4a561f the planner had the definition and refused for want of the call sites.
322
334
  const text = String(content || '');
323
335
  const story = String(storyText || '');
324
- if (!text || !story) return [];
336
+ if (!text || !story) return { sites: [], collapsed: [] };
325
337
  const candidates = [...story.matchAll(BACKTICKED_RE)]
326
338
  .map((m) => m[1].trim())
327
339
  .filter((c) => c.length >= 3
@@ -331,6 +343,7 @@ function anchorSitesForStory(content, storyText, max = 6) {
331
343
 
332
344
  const seenLine = new Set();
333
345
  const sites = [];
346
+ const collapsed = [];
334
347
  for (const anchor of candidates) {
335
348
  const def = definitionIndexOf(text, anchor);
336
349
  if (def === -1) continue;
@@ -339,14 +352,16 @@ function anchorSitesForStory(content, storyText, max = 6) {
339
352
  for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
340
353
  for (const off of offsets) {
341
354
  const line = text.slice(0, off).split('\n').length;
342
- // Collapse near-duplicates: two hits 20 lines apart share one window.
343
- if ([...seenLine].some((l) => Math.abs(l - line) < 40)) continue;
355
+ // Collapse near-duplicates: two hits 20 lines apart share one window. `near` records
356
+ // WHICH kept site it shares with, so a caller can pull it back in later.
357
+ const near = [...seenLine].find((l) => Math.abs(l - line) < 40);
358
+ if (near !== undefined) { collapsed.push({ line, anchor, near }); continue; }
344
359
  seenLine.add(line);
345
360
  sites.push({ line, anchor });
346
- if (sites.length >= max) return sites;
361
+ if (sites.length >= max) return { sites, collapsed };
347
362
  }
348
363
  }
349
- return sites;
364
+ return { sites, collapsed };
350
365
  }
351
366
 
352
367
  function definitionIndexOf(text, term) {
@@ -428,7 +443,7 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
428
443
  // hence budgeted first, and `mergeIntersectingWindows` unions it with any inferred
429
444
  // window it overlaps. No sort, no priority field — array order already says it.
430
445
  // HC-GF-288: every stated line seeds it, in the caller's order.
431
- const inferred = anchorSitesForStory(text, storyText);
446
+ const { sites: inferred, collapsed } = anchorSitesForStory(text, storyText);
432
447
  const sites = [...stated.map((l) => ({ line: l, anchor: null })), ...inferred];
433
448
  const chosen = sites.length ? sites : [hit];
434
449
  const why = (a, l) => (a ? `anchored on \`${a}\`` : `centered on referenced line ${l}`);
@@ -515,15 +530,15 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
515
530
  // only ever dropped when the stated regions alone exceed the cap.
516
531
  const budget = (ord) => {
517
532
  const kept = [];
533
+ const skipped = [];
518
534
  let spent = 0;
519
- let skipped = 0;
520
535
  for (const site of ord) {
521
536
  const cost = costOf(site);
522
537
  if (spent + cost > limit) {
523
538
  // A dropped stated region is reported by LINE below, not counted here: the
524
539
  // advice attached to this count — "name them as path:<line>" — is advice the
525
540
  // operator has already followed for those.
526
- if (!site.stated) skipped += 1;
541
+ if (!site.stated) skipped.push(site);
527
542
  continue;
528
543
  }
529
544
  spent += cost;
@@ -551,6 +566,12 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
551
566
  // considered: a layout with no window sends the result to the fallback, which overruns
552
567
  // the cap by construction. Third review: preferring width alone discarded a 1,066-char
553
568
  // layout for a 33,002-char fallback at cap 12,000.
569
+ //
570
+ // HC-GF-188-followup-1: so a layout that kept nothing has not succeeded, stated lines or
571
+ // not. With none stated, "honours every stated line" was vacuously true at the estimate,
572
+ // the narrower retry never ran, and the fallback returned 30,502 chars at cap 12,000 —
573
+ // the first inferred anchor's long line — while the second anchor's window, 411 chars at
574
+ // radius 200, was never tried. With a stated line, honouring it already means a window.
554
575
  let best = null;
555
576
  const better = (a, b) => (a.honoured - b.honoured)
556
577
  || (Number(a.plan.kept.length > 0) - Number(b.plan.kept.length > 0))
@@ -560,10 +581,10 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
560
581
  const honoured = stated.filter((l) => plan.kept.some(({ w }) => covers(w, l))).length;
561
582
  const cand = { r, plan, honoured };
562
583
  if (!best || better(cand, best) > 0) best = cand;
563
- return honoured === stated.length;
584
+ return honoured === stated.length && plan.kept.length > 0;
564
585
  };
565
586
  if (!tryRadius(radius) && radius > 200 && tryRadius(200)) {
566
- // All stated lines fit at 200 but not at the estimate: binary-search between them.
587
+ // It fits at 200 but not at the estimate: binary-search between them.
567
588
  // Bounded by log2(radius) layouts — measured at most 12 layouts and 228 ms on a
568
589
  // 1.6 MB file of long lines, a few ms on ordinary source.
569
590
  let lo = 200;
@@ -575,7 +596,6 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
575
596
  }
576
597
  radius = best.r;
577
598
  windows.push(...best.plan.kept);
578
- const dropped = best.plan.skipped;
579
599
  if (!windows.length) {
580
600
  // Nothing fits at any radius tried — one line alone is longer than the cap. Build the
581
601
  // window at the MINIMUM radius, so the overrun is that line and no more: at the
@@ -583,6 +603,66 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
583
603
  const w = windowAroundLine(text, chosen[0].line, 200);
584
604
  windows.push({ w, anchor: chosen[0].anchor, line: chosen[0].line });
585
605
  }
606
+ // HC-GF-188-followup-1: a region is dropped when NOTHING bundled reaches it — counted
607
+ // against what is returned, not what the budget skipped. The fallback above returns one
608
+ // region the budget had skipped, and the notice still called it dropped: "2 further
609
+ // region(s) DROPPED" with one missing. Every window grows with the radius around the
610
+ // same line, so the fallback's window lies inside that region's; and the layout's
611
+ // regions are disjoint once merged, so no other skipped region intersects a kept one.
612
+ const dropped = best.plan.skipped.filter((s) =>
613
+ !windows.some(({ w }) => w.startLine <= s.w.endLine && s.w.startLine <= w.endLine)).length;
614
+
615
+ // HC-GF-206-followup-1: a collapsed hit shares its neighbour's window in EXPECTATION only.
616
+ // `radius` was chosen for the whole layout and can end up under the 40-line threshold that
617
+ // collapsed the hit in the first place, cutting it out of the window that was supposed to
618
+ // carry it. Pull each missed hit back in by widening its neighbour's window to reach it,
619
+ // then re-merge — widening can make two previously-separate kept windows intersect.
620
+ let unreached = 0;
621
+ if (collapsed.length) {
622
+ for (const hit of collapsed) {
623
+ if (windows.some(({ w }) => covers(w, hit.line))) continue; // already inside some window
624
+ // The neighbour is the kept window whose site.line this hit collapsed into — falling
625
+ // back to nearest-by-distance when that site's own window was dropped for budget.
626
+ let target = windows.find(({ line: siteLine }) => siteLine === hit.near);
627
+ if (!target) {
628
+ let bestDist = Infinity;
629
+ for (const entry of windows) {
630
+ const dist = hit.line < entry.w.startLine ? entry.w.startLine - hit.line
631
+ : hit.line > entry.w.endLine ? hit.line - entry.w.endLine : 0;
632
+ if (dist < bestDist) { bestDist = dist; target = entry; }
633
+ }
634
+ }
635
+ if (!target) continue; // no window exists at all — reported as unreached below
636
+ // THE BUDGET PROMISE STILL HOLDS: widen, re-merge (an overlap with another kept window
637
+ // is a real possibility once widened), price the result, and roll back rather than
638
+ // overrun the cap. A hit that cannot be reached within budget is unreached, not free.
639
+ const before = windows.map((e) => ({ ...e }));
640
+ target.w = windowBetweenLines(text,
641
+ Math.min(target.w.startLine, hit.line), Math.max(target.w.endLine, hit.line));
642
+ const merged = mergeIntersectingWindows(text, windows);
643
+ const cost = merged.reduce((s, e) => s + costOf(e), 0);
644
+ if (cost > limit) {
645
+ windows.length = 0;
646
+ windows.push(...before);
647
+ continue;
648
+ }
649
+ windows.length = 0;
650
+ windows.push(...merged);
651
+ }
652
+ // Re-sort stated windows back to the front — merging can reorder when a widened window
653
+ // now overlaps one processed earlier, and `windows[0]` below is read as THE region (its
654
+ // anchor and line drive `because`). Mirrors `layout()`'s own sort exactly: `rankOf` returns
655
+ // -1 for a non-stated window, and sorting on that raw value would put it AHEAD of a stated
656
+ // one (found-index 0) rather than behind every stated window — map it to Infinity first.
657
+ const resorted = windows
658
+ .map((c, i) => { const k = rankOf(c); return { c, k: k === -1 ? Infinity : k, i }; })
659
+ .sort((a, b) => (a.k - b.k) || (a.i - b.i))
660
+ .map(({ c }) => c);
661
+ windows.length = 0;
662
+ windows.push(...resorted);
663
+ unreached = collapsed.filter((hit) => !windows.some(({ w }) => covers(w, hit.line))).length;
664
+ }
665
+
586
666
  // HC-GF-288: which stated lines no kept window contains. Only the budget can do that
587
667
  // here — every stated line seeded a window, and a merge never shrinks one.
588
668
  const lost = stated.filter((l) => !windows.some(({ w }) => covers(w, l)));
@@ -600,6 +680,11 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
600
680
  : '')
601
681
  + (lost.length > 0
602
682
  ? ` — stated line(s) ${lost.join(', ')} DROPPED for budget; name fewer regions in ${filePath}`
683
+ : '')
684
+ // HC-GF-206-followup-1: a hit collapsed into a neighbour whose final window still missed
685
+ // it — counted rather than silent, the same shape as `dropped` and `lost` above.
686
+ + (unreached > 0
687
+ ? ` — ${unreached} near-duplicate hit(s) collapsed into a window that did not reach them`
603
688
  : '');
604
689
 
605
690
  return {
@@ -613,12 +698,16 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
613
698
  };
614
699
  }
615
700
 
616
- const dropped = text.length - limit;
701
+ // HC-GF-188-followup-1: the marker is inside the cap, as a window's header and footer are.
702
+ // Appended after `limit` chars, it took cli/hone-cli.js to 8,077 chars at cap 8,000. It
703
+ // names the chars KEPT, which is at most `limit`, so its own length can only shrink.
704
+ const marker = (n) => `\n\n# [HC-019n-followup-12: truncated at ${n} chars; full file is ${text.length} chars]`;
705
+ const keep = Math.max(0, limit - marker(limit).length);
706
+ const dropped = text.length - keep;
617
707
  return {
618
708
  mode: 'truncate',
619
709
  unhonoured,
620
- content: text.slice(0, limit)
621
- + `\n\n# [HC-019n-followup-12: truncated at ${limit} chars; full file is ${text.length} chars]`,
710
+ content: text.slice(0, keep) + marker(keep),
622
711
  notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
623
712
  + `DROPPED and nothing in the story text matched inside the file, so the region you care about `
624
713
  + `may not be in the bundle. Name it as ${filePath}:<line>, or quote the code, to anchor it.`,
package/lib/emit-pr.js CHANGED
@@ -188,7 +188,35 @@ function decidePublish({ branchExists, openPrUrl }) {
188
188
  return { action: 'proceed', exitCode: EXIT.PASS };
189
189
  }
190
190
 
191
+ /**
192
+ * HC-GF-302: tell the server which PR this emit opened or found already open, so the batch's
193
+ * morning report links it instead of hoping an agent wrote the URL into its output.
194
+ *
195
+ * Best-effort, and the caller never changes its exit code on the result: the PR exists either way.
196
+ * It only reports whether the server now knows about it. No PR URL (a dry run, or a push without
197
+ * --open-pr) records nothing: the report has no link to show for a branch. The attempt is the one
198
+ * the step GET resolved; without it the server cannot order records, so nothing is sent.
199
+ *
200
+ * @param {{ post: (path: string, body: object) => Promise<unknown>, path: string, prUrl?: string|null,
201
+ * branch: string, baseSha?: string|null, attempt?: number|null }} args
202
+ * @returns {Promise<{ recorded: boolean, reason?: string }>}
203
+ */
204
+ async function recordPr({ post, path, prUrl, branch, baseSha, attempt }) {
205
+ if (!prUrl) return { recorded: false, reason: 'no PR URL to record' };
206
+ if (!Number.isInteger(attempt) || attempt < 1) {
207
+ return { recorded: false, reason: 'the server did not say which step_4 attempt this is' };
208
+ }
209
+ try {
210
+ await post(path, { branch, prUrl, baseSha: baseSha || null, attempt });
211
+ return { recorded: true };
212
+ } catch (e) {
213
+ const detail = (e && e.response && e.response.data && e.response.data.error) || (e && (e.code || e.message)) || String(e);
214
+ return { recorded: false, reason: e && e.response ? `HTTP ${e.response.status}: ${detail}` : detail };
215
+ }
216
+ }
217
+
191
218
  module.exports = {
219
+ recordPr,
192
220
  verifyStateFor,
193
221
  EXIT,
194
222
  HONEST_LIMIT,
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HC-GF-326: request options that open a NEW connection for one request.
5
+ *
6
+ * Node's default agent keeps sockets alive. verify-patch GETs the run, then runs the
7
+ * test suite with spawnSync, which blocks the event loop for minutes — far past the
8
+ * server's keep-alive timeout (5 s by default). The server closes the idle socket, the
9
+ * blocked client never processes the close, and the verdict POST is written to the dead
10
+ * pooled socket: `read ECONNRESET`, 5 reports out of 5, each labelled non-fatal. Every
11
+ * verdict a verify produced was lost to the server.
12
+ *
13
+ * Pass these to the one request that follows a long synchronous block. What makes the
14
+ * connection fresh is the new agent: its pool is empty, so the request opens a socket.
15
+ * `keepAlive: false` only stops that socket being pooled afterwards.
16
+ */
17
+ const http = require('http');
18
+ const https = require('https');
19
+
20
+ function freshConnection() {
21
+ return {
22
+ httpAgent: new http.Agent({ keepAlive: false }),
23
+ httpsAgent: new https.Agent({ keepAlive: false }),
24
+ };
25
+ }
26
+
27
+ module.exports = { freshConnection };
@@ -284,11 +284,55 @@ function parseSchemaD(doc, includePromoted = false) {
284
284
  }];
285
285
  }
286
286
 
287
+ // ─────────────────────────────────────────────────────────────────────
288
+ // parseRetracted — the entries `hone retract --from-yaml` sends
289
+ //
290
+ // HC-GF-311-followup-1: the retract action read only each file's TOP level, so a Schema C
291
+ // file offered its story frame, which has no `id`, and every per-learning
292
+ // `status: retracted` beneath it was skipped. Measured 2026-09-26 on
293
+ // origin/main: 14 retracted learnings in 12 files, and the dry-run found 0.
294
+ // Each id below is the one the matching parseSchemaX gives `hone promote`,
295
+ // because the server can only retract a row under the id it was promoted as.
296
+ // The id stays the value YAML loaded, not String(id): both commands send it
297
+ // through JSON, so `id: 2026-09-26` (a Date) arrives as the same ISO string.
298
+ // An entry with no id is skipped; promote sent those under the file name.
299
+ // A learning must say `retracted` itself: a story-level status is not
300
+ // inherited here, because a retraction is a claim about one learning.
301
+ // Returns { schema, retracted: [{ id, reason }], parseError? }; reason is
302
+ // null when the entry gives no `retraction_reason`.
303
+ // ─────────────────────────────────────────────────────────────────────
304
+ function parseRetracted(content) {
305
+ let parsed;
306
+ try { parsed = yaml.load(content); }
307
+ catch (e) { return { schema: SCHEMA_UNKNOWN, retracted: [], parseError: e }; }
308
+
309
+ const schema = detectSchema(parsed);
310
+ const isRetracted = (e) => e && typeof e === 'object' && e.status === 'retracted';
311
+ const entry = (id, e) => ({ id, reason: e.retraction_reason || null });
312
+
313
+ let retracted = [];
314
+ if (schema === SCHEMA_A) {
315
+ retracted = parsed.filter(i => isRetracted(i) && i.id).map(i => entry(i.id, i));
316
+ } else if (schema === SCHEMA_B) {
317
+ retracted = parsed.enterprise_candidates
318
+ .filter(c => isRetracted(c) && c.id !== null && c.id !== undefined)
319
+ .map(c => entry(`${parsed.story_id}-${c.id}`, c));
320
+ } else if (schema === SCHEMA_C) {
321
+ retracted = parsed.learnings.filter(l => isRetracted(l) && l.id).map(l => entry(l.id, l));
322
+ } else if (schema === SCHEMA_D) {
323
+ if (isRetracted(parsed) && (parsed.id || parsed.story_id)) retracted = [entry(parsed.id || parsed.story_id, parsed)];
324
+ } else if (isRetracted(parsed) && parsed.id) {
325
+ // Any other single dict keeps the top-level read the action always did.
326
+ retracted = [entry(parsed.id, parsed)];
327
+ }
328
+ return { schema, retracted };
329
+ }
330
+
287
331
  // ─────────────────────────────────────────────────────────────────────
288
332
  // writeBackPromoted — schema-aware idempotent regex rewrite
289
333
  //
290
334
  // All 3 schemas write per-item `status: promoted` because `hone retract`
291
- // at cli/hone-cli.js:1349 reads `entry?.status === 'retracted'` per-item.
335
+ // (parseRetracted above) reads `status: retracted` per-item.
292
336
  // Replacing per-item status with a top-level marker would break retract.
293
337
  // ─────────────────────────────────────────────────────────────────────
294
338
  function writeBackPromoted(content, schema, idsPromoted) {
@@ -424,6 +468,7 @@ module.exports = {
424
468
  SCHEMA_UNKNOWN,
425
469
  detectSchema,
426
470
  parseLearningsFile,
471
+ parseRetracted,
427
472
  writeBackPromoted,
428
473
  // LC-001 / #58 G2 partial: categorization API (advisory).
429
474
  LEARNING_CATEGORIES,
package/lib/mcp-tools.js CHANGED
@@ -13,6 +13,7 @@ const { spawn } = require('node:child_process');
13
13
  const fsSync = require('node:fs');
14
14
  const os = require('node:os');
15
15
  const path = require('node:path');
16
+ const { privateTempFile } = require('./private-temp');
16
17
 
17
18
  // Resolve the CLI binary. Default `hone` (the installed bin); overridable for tests
18
19
  // / non-global installs via HONE_CLI_BIN (e.g. a path to hone-cli.js run with node).
@@ -24,12 +25,15 @@ function honeBin() {
24
25
  // default) — but WITHOUT exiting on a missing token (the MCP server must start and
25
26
  // report the problem via a preflight / hone_doctor, not die). Editor-agnostic:
26
27
  // works from any MCP client that passes HONE_TOKEN/HONE_API in the server env.
28
+ // HC-GF-304-followup-1: reports THAT a token exists, never the token. Nothing here sends one — every
29
+ // tool shells out to the CLI, which reads its own — and `token` was returned to callers that read
30
+ // only `hasToken`, one serialization away from the stderr preflight.
27
31
  function readHoneConfig() {
28
32
  let rc = {};
29
33
  try { rc = JSON.parse(fsSync.readFileSync(path.join(os.homedir(), '.honerc'), 'utf8')) || {}; } catch { rc = {}; }
30
34
  const token = process.env.HONE_TOKEN || rc.token || null;
31
35
  const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
32
- return { token, apiUrl, hasToken: !!token };
36
+ return { apiUrl, hasToken: !!token };
33
37
  }
34
38
 
35
39
  // Run a `hone` subcommand, capturing stdout/stderr/exit. Never throws — returns a
@@ -163,7 +167,7 @@ async function stepInstruction({ runId, cwd } = {}) {
163
167
 
164
168
  // hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
165
169
  // travels via a temp file (1MB-class payloads must not ride argv); the file
166
- // is best-effort deleted after the shell-out.
170
+ // and its directory are best-effort deleted after the shell-out.
167
171
  async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
168
172
  if (!runId) return { isError: true, detail: 'runId is required' };
169
173
  if (!stepKey) return { isError: true, detail: 'stepKey is required' };
@@ -184,18 +188,20 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
184
188
 
185
189
  const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
186
190
  let tmp = null;
187
- if (fromTree) {
188
- base.push('--from-tree');
189
- // Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
190
- if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
191
- } else {
192
- // Output travels via a temp file — 1MB-class payloads must not ride argv.
193
- tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
194
- fsSync.writeFileSync(tmp, output, 'utf8');
195
- base.push('--file', tmp);
196
- }
197
-
198
191
  try {
192
+ if (fromTree) {
193
+ base.push('--from-tree');
194
+ // Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
195
+ if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
196
+ } else {
197
+ // Output travels via a temp file — 1MB-class payloads must not ride argv. HC-GF-304-followup-1: in a
198
+ // directory only this call created (mode 0700). The old pid-named file could not collide, but its
199
+ // name was predictable in a shared /tmp. Created inside the `try` so a failed write removes it too.
200
+ tmp = privateTempFile('hone-editor-output', 'output.txt');
201
+ fsSync.writeFileSync(tmp.file, output, 'utf8');
202
+ base.push('--file', tmp.file);
203
+ }
204
+
199
205
  const r = await module.exports._internal.runHone(base, { cwd });
200
206
  if (r.error) return { isError: true, detail: r.error };
201
207
  const data = parseLastJson(r.stdout);
@@ -209,7 +215,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
209
215
  : (r.stderr || r.stdout || 'submission failed').slice(0, 500),
210
216
  };
211
217
  } finally {
212
- if (tmp) { try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ } }
218
+ if (tmp) tmp.remove(); // the directory, not just the file; never throws
213
219
  }
214
220
  }
215
221
 
@@ -12,6 +12,13 @@
12
12
  * A clock is not an identity. `fs.mkdtempSync` creates a new directory with a random suffix, atomically, with
13
13
  * mode 0700, so the file inside it belongs to exactly one invocation whatever the time, and nobody else can
14
14
  * plant or replace it.
15
+ *
16
+ * HC-CI-081: `remove` is a recursive delete of a computed path in a root every process of this user shares, so
17
+ * it deletes only a directory shaped like the one `mkdtempSync` made for it: a direct child of the temp root,
18
+ * named `<prefix>-` plus mkdtemp's six characters. On 2026-09-26 a local mutation run replaced the mkdtemp with
19
+ * `os.tmpdir()` and left `remove` as it was, so each call was `rm -rf $TMPDIR`. It ran outside the sandbox, in
20
+ * the tmpdir the self-hosted runners share, and deleted three other tests' directories mid-run on runner 2
21
+ * (HC-CI-063's, HC-CI-064's and HC-GF-098's; run 36211682382, attempt 1).
15
22
  */
16
23
 
17
24
  const fs = require('node:fs');
@@ -21,14 +28,25 @@ const path = require('node:path');
21
28
  /**
22
29
  * @param {string} prefix names the owner in the directory, e.g. 'hone-emit'
23
30
  * @param {string} name the file's name inside its private directory, e.g. 'patch.diff'
24
- * @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws
31
+ * @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws; a directory
32
+ * it did not make is left alone
25
33
  */
26
34
  function privateTempFile(prefix, name) {
27
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
35
+ const root = path.resolve(os.tmpdir());
36
+ const dir = fs.mkdtempSync(path.join(root, `${prefix}-`));
28
37
  return {
29
38
  file: path.join(dir, name),
30
- remove: () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } },
39
+ remove: () => {
40
+ if (!madeBy(root, prefix, dir)) return;
41
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
42
+ },
31
43
  };
32
44
  }
33
45
 
46
+ /** Whether `dir` is what `mkdtempSync(path.join(root, prefix + '-'))` returns: that prefix, six more characters. */
47
+ function madeBy(root, prefix, dir) {
48
+ const base = path.basename(dir);
49
+ return path.dirname(dir) === root && base.startsWith(`${prefix}-`) && base.length === prefix.length + 7;
50
+ }
51
+
34
52
  module.exports = { privateTempFile };
package/lib/run-target.js CHANGED
@@ -94,4 +94,69 @@ async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
94
94
  };
95
95
  }
96
96
 
97
- module.exports = { resolveRunTarget, isRunId, LOOKUP_LIMIT };
97
+ /**
98
+ * HC-GF-270-followup-1 — what a caller-supplied argument must look like before any verb builds a URL
99
+ * from it. HC-GF-270 checked the step key inline in two verbs; five more verbs took a run id, a step key
100
+ * or an attempt that nothing checked. Encoding alone is not enough: `encodeURIComponent('..') === '..'`,
101
+ * and axios resolves the request path with `new URL()`, which collapses dot segments, so
102
+ * `/orchestrate/../verdict` goes out as `/verdict`.
103
+ */
104
+ const isStepKey = (s) => typeof s === 'string' && /^step_[0-9a-z_]+$/i.test(s);
105
+ const isAttempt = (s) => (typeof s === 'string' || typeof s === 'number') && /^[1-9]\d{0,8}$/.test(String(s));
106
+ const isBatchId = isRunId;
107
+
108
+ const KINDS = {
109
+ runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
110
+ batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
111
+ stepKey: { ok: isStepKey, why: 'must name a step, like step_4', hint: '' },
112
+ attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
113
+ };
114
+
115
+ /**
116
+ * The first failed check's message, or null when every check passes.
117
+ * @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt', value: any, optional?: boolean }>} checks
118
+ * `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
119
+ * is undefined (a flag that was not given); an empty string is always checked.
120
+ */
121
+ function runArgsError(checks) {
122
+ for (const { label, kind, value, optional } of checks) {
123
+ if (optional && value === undefined) continue;
124
+ const k = KINDS[kind];
125
+ if (!k) throw new TypeError(`runArgsError: unknown kind ${JSON.stringify(kind)}`);
126
+ // JSON.stringify: a crafted value's control characters are escaped, never printed raw to the terminal.
127
+ if (!k.ok(value)) return `${label} ${k.why} (got ${JSON.stringify(value === undefined ? null : value)})${k.hint}`;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * The one way a CLI verb builds an `/orchestrate/…` path. Each segment is encoded exactly once, and the
134
+ * segments encoding cannot make safe (empty, `.`, `..`, null, undefined) throw: every caller-supplied value
135
+ * has passed `runArgsError` first, so a throw here is a bug, not an input error. Query values that are
136
+ * null, undefined, false or '' are dropped; the rest go through URLSearchParams.
137
+ *
138
+ * Callers pass raw values. A pre-encoded segment would be encoded twice (`%` becomes `%25`), and the
139
+ * HC-GF-270-followup-1 scan fails on one.
140
+ */
141
+ function orchestratePath(segments, query) {
142
+ if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
143
+ const parts = segments.map((v, i) => {
144
+ if (v === null || v === undefined) throw new TypeError(`orchestratePath: segment ${i} is ${v}`);
145
+ const s = String(v);
146
+ if (s === '' || s === '.' || s === '..') throw new TypeError(`orchestratePath: segment ${i} is ${JSON.stringify(s)}`);
147
+ return encodeURIComponent(s);
148
+ });
149
+ const base = ['', 'orchestrate', ...parts].join('/');
150
+ const qs = new URLSearchParams();
151
+ for (const [k, v] of Object.entries(query || {})) {
152
+ if (v === null || v === undefined || v === false || v === '') continue;
153
+ qs.append(k, String(v));
154
+ }
155
+ const q = qs.toString();
156
+ return q ? `${base}?${q}` : base;
157
+ }
158
+
159
+ module.exports = {
160
+ resolveRunTarget, isRunId, LOOKUP_LIMIT,
161
+ isStepKey, isAttempt, isBatchId, runArgsError, orchestratePath,
162
+ };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HC-LC-004 banner, with a third state (HC-GF-328).
5
+ *
6
+ * `health` is the parsed `/health` body, or null when the server did not answer. The
7
+ * banner used to print PROD / THIS RUN COSTS MONEY for null, so an unreachable
8
+ * 127.0.0.1 was announced as a paid production run, and the run then failed with
9
+ * ECONNREFUSED. A server that did not answer is named as such: nothing about its cost
10
+ * or provider is known, so the line keeps HC-LC-004's safe default: assume it costs money.
11
+ */
12
+ function serverBanner(apiUrl, health) {
13
+ if (!health) return `UNREACHABLE ${apiUrl} · /health did not answer · provider unknown · if this run starts, assume it COSTS MONEY`;
14
+ const llm = health.llm || null;
15
+ if (llm && llm.local) return `LOCAL ${apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`;
16
+ return `PROD ${apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`;
17
+ }
18
+
19
+ module.exports = { serverBanner };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HC-GF-320: what `hone setup --dry-run` would do AFTER the setup script.
5
+ *
6
+ * The script honours --dry-run itself; the CLI phases that follow it did not.
7
+ * A dry run installed CLAUDE.md, rewrote an existing .pipeline-config.yml
8
+ * (platform grounding), installed test packages (--install-tests) and PUT
9
+ * branch protection on the default branch through the GitHub API. The CLI
10
+ * now stops after the script on a dry run and prints these lines instead.
11
+ *
12
+ * Pure: every fact is passed in, so the plan is testable without a server.
13
+ */
14
+ function postScriptDryRunPlan({ claudeMdTemplate, claudeMdExists, configExists, installTests, skipBranchProtection }) {
15
+ const lines = [];
16
+ if (claudeMdTemplate) {
17
+ lines.push(claudeMdExists
18
+ ? 'CLAUDE.md already exists — would skip it'
19
+ : 'Would install CLAUDE.md (Claude Code pipeline context)');
20
+ }
21
+ if (configExists) lines.push('Would re-run platform grounding on .github/.pipeline-config.yml');
22
+ if (installTests) lines.push('Would install a unit test framework (--install-tests)');
23
+ lines.push(skipBranchProtection
24
+ ? 'Would skip branch protection (opted out)'
25
+ : 'Would install GitHub branch protection on the default branch (pass --no-branch-protection to skip)');
26
+ return lines;
27
+ }
28
+
29
+ module.exports = { postScriptDryRunPlan };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {