@hone-ai/cli 1.35.1 → 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
@@ -377,7 +377,7 @@ program
377
377
  verdict: anyFailed ? 'fail' : 'pass',
378
378
  exitCode: anyFailed ? 1 : 0,
379
379
  detail: `agent-eval ${opts.category}: ${results.filter((r) => r.passed).length}/${results.length} probes passed`,
380
- });
380
+ }, require('./lib/fresh-connection').freshConnection());
381
381
  if (opts.output !== 'json') console.log(` → reported agent-eval verdict '${anyFailed ? 'fail' : 'pass'}' to run ${opts.workflowId} (advisory)`);
382
382
  } catch (e) {
383
383
  if (opts.output !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
@@ -928,6 +928,9 @@ program
928
928
  if (ciGateChoice.source !== 'auto-detect') {
929
929
  setupEnv.CI_GATE = ciGateChoice.mode;
930
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();
931
934
  try {
932
935
  execSync(`bash "${scriptPath}" ${flags}`, {
933
936
  stdio: 'inherit',
@@ -939,6 +942,25 @@ program
939
942
  process.exit(1);
940
943
  }
941
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
+
942
964
  // HC-RC-004: if the adopter chose `local`, run the setup-local-ci
943
965
  // scaffold inline so they end the setup command with a working
944
966
  // local-CI stack (Makefile + compose) instead of having to know
@@ -1564,6 +1586,10 @@ async function createTestEnv(repoRoot, opts) {
1564
1586
  function recommendTestFramework(repoRoot) {
1565
1587
  const detected = detectExistingTestFramework(repoRoot);
1566
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;
1567
1593
 
1568
1594
  const pkgs = getInstallPackages(detected.stack);
1569
1595
  const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
@@ -6167,12 +6193,7 @@ program
6167
6193
  // the 2026-09-08 editor test nearly meaningless; here it costs money rather than time.
6168
6194
  try {
6169
6195
  const h = await client.get('/health').then(r => r.data).catch(() => null);
6170
- const llm = (h && h.llm) || null;
6171
- if (llm && llm.local) {
6172
- console.log(`LOCAL ${config.apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`);
6173
- } else {
6174
- console.log(`PROD ${config.apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`);
6175
- }
6196
+ console.log(require('./lib/server-banner').serverBanner(config.apiUrl, h));
6176
6197
  } catch { /* never block a run on a banner */ }
6177
6198
 
6178
6199
  const { resolveRunTarget, isRunId } = require('./lib/run-target');
@@ -8286,7 +8307,7 @@ program
8286
8307
  attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
8287
8308
  headSha,
8288
8309
  gates: reportGates.length ? reportGates : undefined,
8289
- });
8310
+ }, require('./lib/fresh-connection').freshConnection());
8290
8311
  if (opts.format !== 'json') {
8291
8312
  // HC-GF-070: name the gate that is actually configured. This said "(advisory)"
8292
8313
  // unconditionally, and printed it on a run where `enforce` was auto-approving
@@ -8451,12 +8472,26 @@ program
8451
8472
  const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
8452
8473
  const { verifyStateFor } = require('./lib/emit-pr');
8453
8474
  const {
8454
- EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
8475
+ EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish, recordPr,
8455
8476
  } = require('./lib/emit-pr');
8456
8477
  const { gitEnv } = require('./lib/git-env');
8457
8478
  const config = getConfig();
8458
8479
  const client = api(config);
8459
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}`);
8460
8495
 
8461
8496
  // --open-pr implies --push (cannot open a PR against an unpushed branch).
8462
8497
  const doPush = Boolean(opts.push || opts.openPr);
@@ -8477,12 +8512,16 @@ program
8477
8512
  // branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
8478
8513
  // never arrived.
8479
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;
8480
8518
  try {
8481
8519
  const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
8482
8520
  const r = await client.get(url);
8483
8521
  // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
8484
8522
  if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
8485
8523
  output = r.data?.output;
8524
+ stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
8486
8525
  try {
8487
8526
  const runR = await client.get(orchestratePath([workflowId]));
8488
8527
  // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
@@ -8587,8 +8626,24 @@ program
8587
8626
  }
8588
8627
  const decision = decidePublish({ branchExists, openPrUrl });
8589
8628
  if (decision.action === 'already_open') {
8590
- emit({ ok: true, verdict: 'already_open', prUrl: decision.prUrl, branch },
8591
- `✓ 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)}`);
8592
8647
  process.exit(EXIT.PASS);
8593
8648
  }
8594
8649
  if (decision.action === 'branch_conflict') {
@@ -8761,8 +8816,13 @@ program
8761
8816
 
8762
8817
  if (exitCode === EXIT.PASS) {
8763
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;
8764
8824
  const human = prUrl
8765
- ? `✓ draft PR opened: ${prUrl}`
8825
+ ? `✓ draft PR opened: ${prUrl}${recordWarning(rec)}`
8766
8826
  : pushed
8767
8827
  ? `✓ branch pushed to origin/${branch}\n → open a PR: gh pr create --head ${branch} --draft`
8768
8828
  : `✓ branch '${branch}' built locally (dry run — nothing pushed)\n` +
@@ -8770,7 +8830,11 @@ program
8770
8830
  ` → push it: hone emit-pr ${workflowId} --push\n` +
8771
8831
  ` → push + PR: hone emit-pr ${workflowId} --open-pr\n` +
8772
8832
  ` → discard it: git branch -D ${branch}`;
8773
- 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);
8774
8838
  }
8775
8839
  }
8776
8840
  }
@@ -8912,7 +8976,7 @@ program
8912
8976
  verdict: verdict.verdict,
8913
8977
  exitCode: verdict.exitCode,
8914
8978
  detail: verdict.detail,
8915
- });
8979
+ }, require('./lib/fresh-connection').freshConnection());
8916
8980
  reported = true;
8917
8981
  if (opts.format !== 'json') console.log(` → reported CI-gate verdict '${verdict.verdict}' to run ${opts.workflowId} (advisory)`);
8918
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 };
@@ -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.1",
3
+ "version": "1.36.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {