@hone-ai/cli 1.35.1 → 1.37.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
@@ -42,6 +42,14 @@ const { collectIncludePaths } = require('./lib/include-paths');
42
42
  const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
43
43
  const program = new Command();
44
44
 
45
+ // HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
46
+ // (this repo's own `make ci`) overflowed it, spawnSync returned status: null with error.code
47
+ // ENOBUFS, and verdictFromRun/classifyGateRun read that null status as a plain test failure —
48
+ // not as "the output could not be captured." Generous relative to any suite measured so far
49
+ // (`make ci` prints low hundreds of KB; 64 MiB is two orders of magnitude of headroom) without
50
+ // holding an unbounded amount of memory for a runaway command.
51
+ const VERIFY_MAX_BUFFER = 64 * 1024 * 1024;
52
+
45
53
  // ── Config resolution ─────────────────────────────────────────────────────────
46
54
  const RC_PATH = path.join(os.homedir(), '.honerc');
47
55
 
@@ -377,7 +385,7 @@ program
377
385
  verdict: anyFailed ? 'fail' : 'pass',
378
386
  exitCode: anyFailed ? 1 : 0,
379
387
  detail: `agent-eval ${opts.category}: ${results.filter((r) => r.passed).length}/${results.length} probes passed`,
380
- });
388
+ }, require('./lib/fresh-connection').freshConnection());
381
389
  if (opts.output !== 'json') console.log(` → reported agent-eval verdict '${anyFailed ? 'fail' : 'pass'}' to run ${opts.workflowId} (advisory)`);
382
390
  } catch (e) {
383
391
  if (opts.output !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
@@ -928,6 +936,9 @@ program
928
936
  if (ciGateChoice.source !== 'auto-detect') {
929
937
  setupEnv.CI_GATE = ciGateChoice.mode;
930
938
  }
939
+ // HC-GF-321: --stack was declared and never read. The script applies it
940
+ // after detection (apply_stack_override) and refuses an unsupported value.
941
+ if (opts.stack) setupEnv.HONE_STACK_OVERRIDE = String(opts.stack).toLowerCase();
931
942
  try {
932
943
  execSync(`bash "${scriptPath}" ${flags}`, {
933
944
  stdio: 'inherit',
@@ -939,6 +950,25 @@ program
939
950
  process.exit(1);
940
951
  }
941
952
 
953
+ // HC-GF-320: every phase below writes (CLAUDE.md, config grounding, test
954
+ // packages, GitHub branch protection). A dry run stops here and says what
955
+ // they would do.
956
+ if (opts.dryRun) {
957
+ const { postScriptDryRunPlan } = require('./lib/setup-dry-run');
958
+ const plan = postScriptDryRunPlan({
959
+ claudeMdTemplate: fs.existsSync(path.join(tmpDir, 'CLAUDE.md')),
960
+ claudeMdExists: fs.existsSync(path.join(process.cwd(), 'CLAUDE.md')),
961
+ configExists: fs.existsSync(path.join(process.cwd(), '.github', '.pipeline-config.yml')),
962
+ installTests: !!opts.installTests,
963
+ skipBranchProtection: opts.branchProtection === false ||
964
+ /^(1|true|yes)$/i.test(process.env.HONE_SKIP_BRANCH_PROTECTION || ''),
965
+ });
966
+ for (const line of plan) console.log(` DRY-RUN: ${line}`);
967
+ console.log(' Dry run complete: nothing was written.');
968
+ fs.rmSync(tmpDir, { recursive: true, force: true });
969
+ return;
970
+ }
971
+
942
972
  // HC-RC-004: if the adopter chose `local`, run the setup-local-ci
943
973
  // scaffold inline so they end the setup command with a working
944
974
  // local-CI stack (Makefile + compose) instead of having to know
@@ -1564,6 +1594,10 @@ async function createTestEnv(repoRoot, opts) {
1564
1594
  function recommendTestFramework(repoRoot) {
1565
1595
  const detected = detectExistingTestFramework(repoRoot);
1566
1596
  if (detected.testFw) return; // Already has tests — no recommendation needed
1597
+ // HC-GF-321: every recommendation below is an npm package. Without a root
1598
+ // package.json the stack is not Node (or lives in subdirectories), and a
1599
+ // pytest repository was told to install vitest.
1600
+ if (detected.stack === 'unknown') return;
1567
1601
 
1568
1602
  const pkgs = getInstallPackages(detected.stack);
1569
1603
  const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
@@ -6139,6 +6173,8 @@ program
6139
6173
  .option('--kill', 'Kill a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
6140
6174
  .option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
6141
6175
  .option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0) on a run ID, or on a story ID\'s latest run in this repository. Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
6176
+ .option('--reject <step>', 'HC-GF-323: send a paused gate BACK for a re-run instead of approving it or killing the whole run (e.g., --reject step_4). Requires --reason. Steps before it are left approved; only this step re-runs, with the reason in its context.')
6177
+ .option('--reason <text>', 'With --reject: why this attempt is being sent back. Required, and reaches the re-run as feedback the same way a code-reviewer\'s FAIL verdict does.')
6142
6178
  .option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
6143
6179
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
6144
6180
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
@@ -6167,12 +6203,7 @@ program
6167
6203
  // the 2026-09-08 editor test nearly meaningless; here it costs money rather than time.
6168
6204
  try {
6169
6205
  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
- }
6206
+ console.log(require('./lib/server-banner').serverBanner(config.apiUrl, h));
6176
6207
  } catch { /* never block a run on a banner */ }
6177
6208
 
6178
6209
  const { resolveRunTarget, isRunId } = require('./lib/run-target');
@@ -6458,6 +6489,7 @@ program
6458
6489
  console.log('');
6459
6490
  console.log(`Awaiting approval at ${data.currentStep}.`);
6460
6491
  console.log(`Approve: hone run-story ${data.runId} --approve ${data.currentStep}`);
6492
+ console.log(`Reject: hone run-story ${data.runId} --reject ${data.currentStep} --reason "<why>"`);
6461
6493
  }
6462
6494
  console.log('');
6463
6495
  // HC-GF-016: at a TERMINAL state, print the report unasked. The whole
@@ -6527,6 +6559,37 @@ program
6527
6559
  return;
6528
6560
  }
6529
6561
 
6562
+ // Reject mode (HC-GF-323)
6563
+ if (opts.reject) {
6564
+ if (!opts.reason || !String(opts.reason).trim()) {
6565
+ console.error('--reject requires --reason <text>');
6566
+ process.exit(1);
6567
+ }
6568
+ let rejectId;
6569
+ try {
6570
+ rejectId = await existingRunId(`--reject ${opts.reject}`);
6571
+ await client.post(orchestratePath([rejectId, 'reject']),
6572
+ { stepKey: opts.reject, reason: opts.reason });
6573
+ console.log(`Gate ${opts.reject} rejected. Re-running with the reason. Workflow resuming.`);
6574
+ } catch (e) {
6575
+ console.error(`Failed: ${e.response?.data?.error || e.message}`);
6576
+ process.exit(1);
6577
+ }
6578
+ // HC-GF-070: same FOLLOW behaviour as --approve — a caller told to poll does not.
6579
+ if (opts.follow === false) {
6580
+ console.log(`Poll: hone run-story ${rejectId} --status`);
6581
+ return;
6582
+ }
6583
+ try {
6584
+ await pollUntilDecision(rejectId);
6585
+ } catch (e) {
6586
+ console.error(`Rejected, but following the run failed: ${e.message}`);
6587
+ console.error(`Poll: hone run-story ${rejectId} --status`);
6588
+ process.exit(1);
6589
+ }
6590
+ return;
6591
+ }
6592
+
6530
6593
  // Start workflow (storyId required, runId not accepted for starting)
6531
6594
  if (isUuid) {
6532
6595
  console.error('Cannot start workflow with a runId. Use a story ID like HC-042.');
@@ -7860,7 +7923,11 @@ program
7860
7923
  verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7861
7924
  // best-effort: pull the run's ci_local_command for the default
7862
7925
  try {
7863
- const runR = await client.get(orchestratePath([workflowId]));
7926
+ // HC-GF-302-followup-1: on a connection of its own. This GET follows the step GET at once, and
7927
+ // on the socket that one pooled a server closing it idle resets the request - a failed config
7928
+ // read, so the provenance gate below refused a trusted run (exit 9). Nothing is retried: a read
7929
+ // that fails on its own connection still refuses.
7930
+ const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
7864
7931
  // HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
7865
7932
  // `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
7866
7933
  // 200 with no config. And a body that is not THIS run is not this run's config.
@@ -8164,9 +8231,10 @@ program
8164
8231
  cwd: wt, shell: true, encoding: 'utf8',
8165
8232
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8166
8233
  stdio: ['ignore', 'pipe', 'pipe'],
8234
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: a >1 MiB suite (this repo's own `make ci`) must not ENOBUFS
8167
8235
  });
8168
8236
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8169
- const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
8237
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
8170
8238
  // HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
8171
8239
  if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
8172
8240
  const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
@@ -8222,6 +8290,7 @@ program
8222
8290
  cwd: wt, shell: true, encoding: 'utf8',
8223
8291
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8224
8292
  stdio: ['ignore', 'pipe', 'pipe'],
8293
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: shares the shape at :8184 — same fix
8225
8294
  });
8226
8295
  const c = classifyGateRun({
8227
8296
  code: gr.status, error: gr.error,
@@ -8286,7 +8355,7 @@ program
8286
8355
  attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
8287
8356
  headSha,
8288
8357
  gates: reportGates.length ? reportGates : undefined,
8289
- });
8358
+ }, require('./lib/fresh-connection').freshConnection());
8290
8359
  if (opts.format !== 'json') {
8291
8360
  // HC-GF-070: name the gate that is actually configured. This said "(advisory)"
8292
8361
  // unconditionally, and printed it on a run where `enforce` was auto-approving
@@ -8451,12 +8520,26 @@ program
8451
8520
  const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
8452
8521
  const { verifyStateFor } = require('./lib/emit-pr');
8453
8522
  const {
8454
- EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
8523
+ EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish, recordPr,
8455
8524
  } = require('./lib/emit-pr');
8456
8525
  const { gitEnv } = require('./lib/git-env');
8457
8526
  const config = getConfig();
8458
8527
  const client = api(config);
8459
8528
  const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
8529
+ // HC-GF-302: record the PR on the run. The POST goes out after the verify, push and `gh` steps,
8530
+ // which block the event loop for minutes, and a keep-alive socket the server closed meanwhile
8531
+ // fails `write EPIPE` when reused (HC-GF-326). A fresh connection per record avoids that.
8532
+ //
8533
+ // F3 (review): baseSha and attempt are the CALLER's to supply, not this closure's defaults —
8534
+ // the two call sites below have different bases for trusting each one (see their own
8535
+ // comments). recordPr itself stays pure; this only wires up the transport.
8536
+ const recordPrOnRun = ({ prUrl, branch: prBranch, baseSha: recBaseSha = null, attempt: recAttempt }) => recordPr({
8537
+ post: (p, body) => client.post(p, body, require('./lib/fresh-connection').freshConnection()),
8538
+ path: orchestratePath([workflowId, 'pr-record']),
8539
+ prUrl, branch: prBranch, baseSha: recBaseSha, attempt: recAttempt,
8540
+ });
8541
+ const recordWarning = (rec) => (rec.recorded || opts.format === 'json'
8542
+ ? '' : `\n ⚠ the run's PR record was not saved (non-fatal; the morning report may miss this link): ${rec.reason}`);
8460
8543
 
8461
8544
  // --open-pr implies --push (cannot open a PR against an unpushed branch).
8462
8545
  const doPush = Boolean(opts.push || opts.openPr);
@@ -8477,14 +8560,19 @@ program
8477
8560
  // branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
8478
8561
  // never arrived.
8479
8562
  let runStoryId = null;
8563
+ // HC-GF-302: the attempt the server resolved (the latest one unless --attempt named it), which
8564
+ // orders this run's PR records. Not opts.attempt: that is absent in the default case.
8565
+ let stepAttempt = null;
8480
8566
  try {
8481
8567
  const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
8482
8568
  const r = await client.get(url);
8483
8569
  // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
8484
8570
  if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
8485
8571
  output = r.data?.output;
8572
+ stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
8486
8573
  try {
8487
- const runR = await client.get(orchestratePath([workflowId]));
8574
+ // HC-GF-302-followup-1: its own connection, as verify-patch's (see there).
8575
+ const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
8488
8576
  // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
8489
8577
  // is not THIS run does not count as its config.
8490
8578
  if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
@@ -8587,8 +8675,24 @@ program
8587
8675
  }
8588
8676
  const decision = decidePublish({ branchExists, openPrUrl });
8589
8677
  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}`);
8678
+ // HC-GF-302 (review F3): record here too — hone follow-batch sends a completed run's emit
8679
+ // to the branch its draft already opened, so this is the path that retries a record the
8680
+ // draft failed to save. But this path never verified anything against a specific attempt:
8681
+ // it found an already-open PR without running check-patch/verify-patch here, so it has no
8682
+ // basis to claim `headSha` is this attempt's base — baseSha is always null on this path.
8683
+ // And `stepAttempt` above is whichever attempt the server considers LATEST, which is not
8684
+ // necessarily the attempt the open PR's branch was built from — so this only records when
8685
+ // the caller named the attempt explicitly with --attempt. `hone follow-batch` always does
8686
+ // (cli/lib/batch-follow.js buildArgs pushes `--attempt N` for every emit-pr call it makes);
8687
+ // a bare `hone emit-pr --open-pr` with no --attempt has no basis to order a record either,
8688
+ // so it records nothing rather than guessing.
8689
+ const explicitAttempt = opts.attempt !== undefined ? Number(opts.attempt) : null;
8690
+ const rec = explicitAttempt != null
8691
+ ? await recordPrOnRun({ prUrl: decision.prUrl, branch, baseSha: null, attempt: explicitAttempt })
8692
+ : { recorded: false, reason: 'the open PR\'s attempt is unknown; pass --attempt' };
8693
+ emit({ ok: true, verdict: 'already_open', prUrl: decision.prUrl, branch, recorded: rec.recorded,
8694
+ ...(rec.recorded ? {} : { recordError: rec.reason }) },
8695
+ `✓ a draft PR is already open for ${branch}: ${decision.prUrl}${recordWarning(rec)}`);
8592
8696
  process.exit(EXIT.PASS);
8593
8697
  }
8594
8698
  if (decision.action === 'branch_conflict') {
@@ -8683,9 +8787,10 @@ program
8683
8787
  cwd: wt, shell: true, encoding: 'utf8',
8684
8788
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8685
8789
  stdio: ['ignore', 'pipe', 'pipe'],
8790
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: emit-pr's verify shares verify-patch's spawnSync shape
8686
8791
  });
8687
8792
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8688
- const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
8793
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
8689
8794
  // HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
8690
8795
  // narrowed verdict fell into the else below, which printed "verify failed, no branch
8691
8796
  // emitted" and then exited with that verdict's own exit code — zero. A failure path that
@@ -8761,8 +8866,13 @@ program
8761
8866
 
8762
8867
  if (exitCode === EXIT.PASS) {
8763
8868
  const tier = prUrl ? 'pr' : pushed ? 'pushed' : 'dry_run';
8869
+ // HC-GF-302: only a PR is recorded; a pushed or local branch has no link to show. This
8870
+ // is the path that just verified the diff and committed it: headSha is the commit the
8871
+ // patch was verified against, so it is a true baseSha here (unlike the already_open
8872
+ // path above, which never ran that verification and always records baseSha: null).
8873
+ const rec = prUrl ? await recordPrOnRun({ prUrl, branch, baseSha: headSha || null, attempt: stepAttempt }) : null;
8764
8874
  const human = prUrl
8765
- ? `✓ draft PR opened: ${prUrl}`
8875
+ ? `✓ draft PR opened: ${prUrl}${recordWarning(rec)}`
8766
8876
  : pushed
8767
8877
  ? `✓ branch pushed to origin/${branch}\n → open a PR: gh pr create --head ${branch} --draft`
8768
8878
  : `✓ branch '${branch}' built locally (dry run — nothing pushed)\n` +
@@ -8770,7 +8880,11 @@ program
8770
8880
  ` → push it: hone emit-pr ${workflowId} --push\n` +
8771
8881
  ` → push + PR: hone emit-pr ${workflowId} --open-pr\n` +
8772
8882
  ` → discard it: git branch -D ${branch}`;
8773
- emit({ ok: true, verdict: tier, branch, verifyState, prUrl, pushed, headSha }, human);
8883
+ // F8 (review): --format json previously dropped the failure reason entirely once
8884
+ // `recorded` was false — a reader had no way to tell why without re-running in
8885
+ // --format pretty. Same object, one extra field, omitted once recorded:true.
8886
+ emit({ ok: true, verdict: tier, branch, verifyState, prUrl, pushed, headSha,
8887
+ ...(rec ? { recorded: rec.recorded, ...(rec.recorded ? {} : { recordError: rec.reason }) } : {}) }, human);
8774
8888
  }
8775
8889
  }
8776
8890
  }
@@ -8912,7 +9026,7 @@ program
8912
9026
  verdict: verdict.verdict,
8913
9027
  exitCode: verdict.exitCode,
8914
9028
  detail: verdict.detail,
8915
- });
9029
+ }, require('./lib/fresh-connection').freshConnection());
8916
9030
  reported = true;
8917
9031
  if (opts.format !== 'json') console.log(` → reported CI-gate verdict '${verdict.verdict}' to run ${opts.workflowId} (advisory)`);
8918
9032
  } catch (e) {
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+ /**
3
+ * bundle-mount-follow.js — HC-GF-325.
4
+ *
5
+ * `cli/lib/bundle-paths.js` bundles only paths the story NAMES — it has no mount, prefix or
6
+ * import following. step_2 (API test writer) then has no way to know a router file's routes are
7
+ * served under a prefix, because nothing in the bundle says so. Measured live: step_2 wrote 7
8
+ * tests against `/properties` for an app that mounts every router under `/api/v1`
9
+ * (`app.include_router(properties.router, prefix=settings.api_prefix)`); all 7 failed on a 404.
10
+ *
11
+ * This module answers one question given a bundled file's path and content: "if this file
12
+ * defines routes, which OTHER tracked file mounts it, and which existing test already exercises
13
+ * a router mounted the same way?" Both answers are returned as extra paths for the caller to
14
+ * bundle alongside the file the story named — it does not read the filesystem itself, so it stays
15
+ * as pure/testable as the rest of `cli/lib`.
16
+ */
17
+
18
+ const path = require('path');
19
+
20
+ // A file DEFINES routes when it either builds a router object (FastAPI's `APIRouter()`, Express's
21
+ // `Router()`) or decorates/registers at least one HTTP verb against one (`@router.get(...)`,
22
+ // `router.get(...)`) — the object alone (an empty router) is not enough to justify following it.
23
+ const DEFINES_ROUTER_OBJECT = /\bAPIRouter\s*\(|\b(?:express\.)?Router\s*\(\s*\)/;
24
+ const REGISTERS_A_ROUTE = /@\w+\.(get|post|put|delete|patch)\s*\(|\b\w+\.(get|post|put|delete|patch)\s*\(\s*['"`]/i;
25
+
26
+ function definesRoutes(content) {
27
+ const text = String(content || '');
28
+ return DEFINES_ROUTER_OBJECT.test(text) && REGISTERS_A_ROUTE.test(text);
29
+ }
30
+
31
+ // Where an app typically registers its routers/routes — bounded, so following a mount never
32
+ // means reading every tracked file's content. FastAPI/Flask/Express conventions only; a repo
33
+ // using neither simply gets no mount file, same as today.
34
+ const ENTRYPOINT_NAME_RE = /(^|\/)(main|app|asgi|wsgi|server|index|__init__)\.(py|js|mjs|cjs|ts)$/i;
35
+
36
+ // `app.include_router(...)` (FastAPI/Starlette) or `app.use(...)` (Express) — the call that
37
+ // registers a router built elsewhere. Requires the SAME file to also reference the router
38
+ // file's module name, so a coincidental `app.use(` for an unrelated router does not match.
39
+ const MOUNT_CALL_RE = /\b\w+\.include_router\s*\(|\b\w+\.use\s*\(/;
40
+
41
+ function baseModuleName(filePath) {
42
+ return path.posix.basename(String(filePath || ''), path.posix.extname(String(filePath || '')));
43
+ }
44
+
45
+ function mentionsModule(content, moduleName) {
46
+ if (!moduleName) return false;
47
+ const re = new RegExp(`\\b${moduleName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
48
+ return re.test(String(content || ''));
49
+ }
50
+
51
+ /**
52
+ * @param {object} o
53
+ * @param {string} o.routeFilePath the bundled file being checked
54
+ * @param {string} o.routeFileContent
55
+ * @param {string[]} o.repoFiles every tracked path (the caller's git ls-files list)
56
+ * @param {(p:string)=>string} o.readFile reads a candidate mount file; the caller's disk I/O
57
+ * @returns {string|null} the path of the file that mounts routeFilePath's router, or null
58
+ */
59
+ function findMountFile({ routeFilePath, routeFileContent, repoFiles, readFile }) {
60
+ if (!definesRoutes(routeFileContent)) return null;
61
+ const moduleName = baseModuleName(routeFilePath);
62
+ const candidates = (Array.isArray(repoFiles) ? repoFiles : [])
63
+ .filter((p) => p !== routeFilePath && ENTRYPOINT_NAME_RE.test(p));
64
+ for (const candidate of candidates) {
65
+ let content;
66
+ try { content = readFile(candidate); } catch { continue; }
67
+ if (MOUNT_CALL_RE.test(content) && mentionsModule(content, moduleName)) return candidate;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // The same directory-prefix + name convention `cli/lib/layout-detect.js` uses for a test root —
73
+ // kept independent (not imported) because this module's job is narrower: ONE existing file to
74
+ // show the test writer the mounted-prefix convention, not a directory computation.
75
+ const TEST_PATH_RE = /(^|\/)(tests?|__tests__|spec|specs|e2e)(\/|$)|\.(test|spec)\.[a-z]+$/i;
76
+ // A test that actually drives an HTTP client — the convention worth copying — over one that
77
+ // merely lives in a test directory (a fixtures or conftest file, say).
78
+ const API_TEST_SIGNAL_RE = /\bTestClient\s*\(|\bsupertest\s*\(|\brequest\s*\(\s*app\s*\)|\bclient\.(get|post|put|delete|patch)\s*\(/;
79
+
80
+ function topLevelDir(filePath) {
81
+ const norm = String(filePath || '').replace(/^\.\//, '');
82
+ return norm.includes('/') ? norm.split('/')[0] : '';
83
+ }
84
+
85
+ /**
86
+ * @param {object} o
87
+ * @param {string} o.routeFilePath
88
+ * @param {string[]} o.repoFiles
89
+ * @param {(p:string)=>string} o.readFile
90
+ * @returns {string|null} an existing test file demonstrating the same kind of route testing
91
+ */
92
+ function findExistingApiTest({ routeFilePath, repoFiles, readFile }) {
93
+ const dir = topLevelDir(routeFilePath);
94
+ const candidates = (Array.isArray(repoFiles) ? repoFiles : [])
95
+ .filter((p) => p !== routeFilePath && (!dir || p.startsWith(dir + '/')) && TEST_PATH_RE.test(p));
96
+ for (const candidate of candidates) {
97
+ let content;
98
+ try { content = readFile(candidate); } catch { continue; }
99
+ if (API_TEST_SIGNAL_RE.test(content)) return candidate;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * The one entry point `run-context.js` calls per already-bundled ("present") file.
106
+ * @returns {string[]} zero, one or two extra paths to bundle (mount file, existing API test)
107
+ */
108
+ function followRouterMount({ routeFilePath, routeFileContent, repoFiles, readFile }) {
109
+ if (!definesRoutes(routeFileContent)) return [];
110
+ const extra = [];
111
+ const mountFile = findMountFile({ routeFilePath, routeFileContent, repoFiles, readFile });
112
+ if (mountFile) extra.push(mountFile);
113
+ const testFile = findExistingApiTest({ routeFilePath, repoFiles, readFile });
114
+ if (testFile) extra.push(testFile);
115
+ return extra;
116
+ }
117
+
118
+ module.exports = { definesRoutes, findMountFile, findExistingApiTest, followRouterMount };
@@ -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}`);
@@ -449,8 +464,19 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
449
464
  // The radius is sized before any window exists, so it prices the marker at its widest:
450
465
  // the longest label among the sites, with line numbers at the file's own width.
451
466
  const widest = { startLine: totalLines, endLine: totalLines, totalLines };
467
+ // HC-GF-334: the footer repeats `filePath` in EVERY kept window (see `footer` above), so
468
+ // sizing `radius` off its real length made window boundaries — and therefore which sites
469
+ // and collapsed hits are reachable — depend on how long the file's path happens to be.
470
+ // Measured: with real files and roadmap rows, a fake path fixed at length 80 or 90 dropped
471
+ // a hit that lengths 10-70 and 100-200 all kept, for the SAME content and story — a path a
472
+ // few characters different flips whether a window's char-radius snaps past a line boundary.
473
+ // Reserve a fixed, generous footer cost for SIZING the radius instead of the real one, so
474
+ // the estimate — and the windows it produces — no longer shift with path length. `costOf`
475
+ // and `budget()` below still charge the REAL footer length, so the cap is still never
476
+ // exceeded even for a path longer than this reserve; it only stops falling short of it.
477
+ const FOOTER_SIZING_RESERVE_CHARS = 300;
452
478
  const markerCost = Math.max(...chosen.map((s) => header(widest, s.anchor, totalLines).length))
453
- + footer.length + 1;
479
+ + Math.max(footer.length, FOOTER_SIZING_RESERVE_CHARS) + 1;
454
480
  // HC-GF-288: the 800 covers ONE window's marker, and every further window brings its
455
481
  // own. Sized from `limit - 800` alone, n windows overran the cap from n = 3 — so the
456
482
  // LAST region was always dropped however much room the rest left. Measured on
@@ -515,15 +541,15 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
515
541
  // only ever dropped when the stated regions alone exceed the cap.
516
542
  const budget = (ord) => {
517
543
  const kept = [];
544
+ const skipped = [];
518
545
  let spent = 0;
519
- let skipped = 0;
520
546
  for (const site of ord) {
521
547
  const cost = costOf(site);
522
548
  if (spent + cost > limit) {
523
549
  // A dropped stated region is reported by LINE below, not counted here: the
524
550
  // advice attached to this count — "name them as path:<line>" — is advice the
525
551
  // operator has already followed for those.
526
- if (!site.stated) skipped += 1;
552
+ if (!site.stated) skipped.push(site);
527
553
  continue;
528
554
  }
529
555
  spent += cost;
@@ -551,6 +577,12 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
551
577
  // considered: a layout with no window sends the result to the fallback, which overruns
552
578
  // the cap by construction. Third review: preferring width alone discarded a 1,066-char
553
579
  // layout for a 33,002-char fallback at cap 12,000.
580
+ //
581
+ // HC-GF-188-followup-1: so a layout that kept nothing has not succeeded, stated lines or
582
+ // not. With none stated, "honours every stated line" was vacuously true at the estimate,
583
+ // the narrower retry never ran, and the fallback returned 30,502 chars at cap 12,000 —
584
+ // the first inferred anchor's long line — while the second anchor's window, 411 chars at
585
+ // radius 200, was never tried. With a stated line, honouring it already means a window.
554
586
  let best = null;
555
587
  const better = (a, b) => (a.honoured - b.honoured)
556
588
  || (Number(a.plan.kept.length > 0) - Number(b.plan.kept.length > 0))
@@ -560,10 +592,10 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
560
592
  const honoured = stated.filter((l) => plan.kept.some(({ w }) => covers(w, l))).length;
561
593
  const cand = { r, plan, honoured };
562
594
  if (!best || better(cand, best) > 0) best = cand;
563
- return honoured === stated.length;
595
+ return honoured === stated.length && plan.kept.length > 0;
564
596
  };
565
597
  if (!tryRadius(radius) && radius > 200 && tryRadius(200)) {
566
- // All stated lines fit at 200 but not at the estimate: binary-search between them.
598
+ // It fits at 200 but not at the estimate: binary-search between them.
567
599
  // Bounded by log2(radius) layouts — measured at most 12 layouts and 228 ms on a
568
600
  // 1.6 MB file of long lines, a few ms on ordinary source.
569
601
  let lo = 200;
@@ -575,7 +607,6 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
575
607
  }
576
608
  radius = best.r;
577
609
  windows.push(...best.plan.kept);
578
- const dropped = best.plan.skipped;
579
610
  if (!windows.length) {
580
611
  // Nothing fits at any radius tried — one line alone is longer than the cap. Build the
581
612
  // window at the MINIMUM radius, so the overrun is that line and no more: at the
@@ -583,6 +614,66 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
583
614
  const w = windowAroundLine(text, chosen[0].line, 200);
584
615
  windows.push({ w, anchor: chosen[0].anchor, line: chosen[0].line });
585
616
  }
617
+ // HC-GF-188-followup-1: a region is dropped when NOTHING bundled reaches it — counted
618
+ // against what is returned, not what the budget skipped. The fallback above returns one
619
+ // region the budget had skipped, and the notice still called it dropped: "2 further
620
+ // region(s) DROPPED" with one missing. Every window grows with the radius around the
621
+ // same line, so the fallback's window lies inside that region's; and the layout's
622
+ // regions are disjoint once merged, so no other skipped region intersects a kept one.
623
+ const dropped = best.plan.skipped.filter((s) =>
624
+ !windows.some(({ w }) => w.startLine <= s.w.endLine && s.w.startLine <= w.endLine)).length;
625
+
626
+ // HC-GF-206-followup-1: a collapsed hit shares its neighbour's window in EXPECTATION only.
627
+ // `radius` was chosen for the whole layout and can end up under the 40-line threshold that
628
+ // collapsed the hit in the first place, cutting it out of the window that was supposed to
629
+ // carry it. Pull each missed hit back in by widening its neighbour's window to reach it,
630
+ // then re-merge — widening can make two previously-separate kept windows intersect.
631
+ let unreached = 0;
632
+ if (collapsed.length) {
633
+ for (const hit of collapsed) {
634
+ if (windows.some(({ w }) => covers(w, hit.line))) continue; // already inside some window
635
+ // The neighbour is the kept window whose site.line this hit collapsed into — falling
636
+ // back to nearest-by-distance when that site's own window was dropped for budget.
637
+ let target = windows.find(({ line: siteLine }) => siteLine === hit.near);
638
+ if (!target) {
639
+ let bestDist = Infinity;
640
+ for (const entry of windows) {
641
+ const dist = hit.line < entry.w.startLine ? entry.w.startLine - hit.line
642
+ : hit.line > entry.w.endLine ? hit.line - entry.w.endLine : 0;
643
+ if (dist < bestDist) { bestDist = dist; target = entry; }
644
+ }
645
+ }
646
+ if (!target) continue; // no window exists at all — reported as unreached below
647
+ // THE BUDGET PROMISE STILL HOLDS: widen, re-merge (an overlap with another kept window
648
+ // is a real possibility once widened), price the result, and roll back rather than
649
+ // overrun the cap. A hit that cannot be reached within budget is unreached, not free.
650
+ const before = windows.map((e) => ({ ...e }));
651
+ target.w = windowBetweenLines(text,
652
+ Math.min(target.w.startLine, hit.line), Math.max(target.w.endLine, hit.line));
653
+ const merged = mergeIntersectingWindows(text, windows);
654
+ const cost = merged.reduce((s, e) => s + costOf(e), 0);
655
+ if (cost > limit) {
656
+ windows.length = 0;
657
+ windows.push(...before);
658
+ continue;
659
+ }
660
+ windows.length = 0;
661
+ windows.push(...merged);
662
+ }
663
+ // Re-sort stated windows back to the front — merging can reorder when a widened window
664
+ // now overlaps one processed earlier, and `windows[0]` below is read as THE region (its
665
+ // anchor and line drive `because`). Mirrors `layout()`'s own sort exactly: `rankOf` returns
666
+ // -1 for a non-stated window, and sorting on that raw value would put it AHEAD of a stated
667
+ // one (found-index 0) rather than behind every stated window — map it to Infinity first.
668
+ const resorted = windows
669
+ .map((c, i) => { const k = rankOf(c); return { c, k: k === -1 ? Infinity : k, i }; })
670
+ .sort((a, b) => (a.k - b.k) || (a.i - b.i))
671
+ .map(({ c }) => c);
672
+ windows.length = 0;
673
+ windows.push(...resorted);
674
+ unreached = collapsed.filter((hit) => !windows.some(({ w }) => covers(w, hit.line))).length;
675
+ }
676
+
586
677
  // HC-GF-288: which stated lines no kept window contains. Only the budget can do that
587
678
  // here — every stated line seeded a window, and a merge never shrinks one.
588
679
  const lost = stated.filter((l) => !windows.some(({ w }) => covers(w, l)));
@@ -600,6 +691,11 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
600
691
  : '')
601
692
  + (lost.length > 0
602
693
  ? ` — stated line(s) ${lost.join(', ')} DROPPED for budget; name fewer regions in ${filePath}`
694
+ : '')
695
+ // HC-GF-206-followup-1: a hit collapsed into a neighbour whose final window still missed
696
+ // it — counted rather than silent, the same shape as `dropped` and `lost` above.
697
+ + (unreached > 0
698
+ ? ` — ${unreached} near-duplicate hit(s) collapsed into a window that did not reach them`
603
699
  : '');
604
700
 
605
701
  return {
@@ -613,12 +709,16 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
613
709
  };
614
710
  }
615
711
 
616
- const dropped = text.length - limit;
712
+ // HC-GF-188-followup-1: the marker is inside the cap, as a window's header and footer are.
713
+ // Appended after `limit` chars, it took cli/hone-cli.js to 8,077 chars at cap 8,000. It
714
+ // names the chars KEPT, which is at most `limit`, so its own length can only shrink.
715
+ const marker = (n) => `\n\n# [HC-019n-followup-12: truncated at ${n} chars; full file is ${text.length} chars]`;
716
+ const keep = Math.max(0, limit - marker(limit).length);
717
+ const dropped = text.length - keep;
617
718
  return {
618
719
  mode: 'truncate',
619
720
  unhonoured,
620
- content: text.slice(0, limit)
621
- + `\n\n# [HC-019n-followup-12: truncated at ${limit} chars; full file is ${text.length} chars]`,
721
+ content: text.slice(0, keep) + marker(keep),
622
722
  notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
623
723
  + `DROPPED and nothing in the story text matched inside the file, so the region you care about `
624
724
  + `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 };
@@ -20,6 +20,14 @@ const EXCLUDE = /(^|\/)(node_modules|dist|build|coverage|\.git|\.next|vendor|__p
20
20
  const SOURCE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|java|kt|rb|go|rs|php|cs|scala|swift|cls|trigger|apex)$/i;
21
21
  const TEST_HINT = /(^|\/)(tests?|__tests__|spec|specs|e2e)(\/|$)|\.(test|spec)\.[a-z]+$/i;
22
22
 
23
+ // HC-GF-020-followup-1: a documentation directory routinely carries a handful of genuinely
24
+ // source-extension files (Sphinx's conf.py, a docs-site build script) that clear MIN_FILES on
25
+ // extension count alone — measured live: a docs/ of mostly .html/.svg pages qualified as a
26
+ // source dir because it also held two .py files. Extension MIX, not source-extension COUNT, is
27
+ // what tells a docs directory apart from a package: if a top-level dir's tracked files are more
28
+ // documentation than source, it is documentation, however many stray source-ext files it holds.
29
+ const DOC_EXT = /\.(md|mdx|rst|adoc|txt|html?|svg|png|jpe?g|gif|ico|pdf)$/i;
30
+
23
31
  // Directories whose NAME means "this is where the code is". A repo that puts code directly at the
24
32
  // root of a package still gets that package dir, via the manifest-dir fallback below.
25
33
  const SOURCE_DIR_NAMES = /^(src|lib|app|source|server|client|api|packages|apps|core)$/i;
@@ -61,6 +69,27 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
61
69
  const srcFiles = paths.filter(p => SOURCE_EXT.test(p) && !TEST_HINT.test(p));
62
70
  const testFiles = paths.filter(p => SOURCE_EXT.test(p) && TEST_HINT.test(p));
63
71
 
72
+ // Extension mix per top-level dir, over EVERY tracked path (not just the source-ext-filtered
73
+ // srcFiles/testFiles) — a docs/ directory's defining feature is the files roll() never looks
74
+ // at otherwise (its .html/.svg pages), so the doc-vs-source comparison needs the full list.
75
+ const extCountsByTop = new Map();
76
+ for (const p of paths) {
77
+ const top = p.includes('/') ? p.split('/')[0] : '.';
78
+ if (!extCountsByTop.has(top)) extCountsByTop.set(top, { doc: 0, src: 0 });
79
+ const c = extCountsByTop.get(top);
80
+ if (DOC_EXT.test(p)) c.doc += 1;
81
+ else if (SOURCE_EXT.test(p)) c.src += 1;
82
+ }
83
+ function isDocumentationOnly(top) {
84
+ // A dotdir (.github, .storybook, .devcontainer, ...) is tooling/config by convention, never
85
+ // a published-docs directory, even when it holds far more Markdown records (pipeline history,
86
+ // learnings) than source-ext scripts — hone-server's OWN .github/scripts/ is real code that
87
+ // must stay visible (HC-DF-005) alongside .github/pipeline/'s hundreds of Markdown records.
88
+ if (top.startsWith('.')) return false;
89
+ const c = extCountsByTop.get(top);
90
+ return !!c && c.doc > c.src;
91
+ }
92
+
64
93
  function roll(files, nameRe = SOURCE_DIR_NAMES) {
65
94
  const byTop = new Map();
66
95
  for (const f of files) {
@@ -71,6 +100,7 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
71
100
  const out = [];
72
101
  for (const [top, list] of byTop) {
73
102
  if (top === '.') continue; // root-level scripts are not a source ROOT
103
+ if (isDocumentationOnly(top)) continue; // docs/ with a stray conf.py is still docs/
74
104
  const qualifies = nameRe.test(top) || manifestDirs.has(top) || list.length >= minFiles;
75
105
  if (!qualifies) continue;
76
106
  // Descend one level when this is a container of packages, or when a conventional source
@@ -83,14 +113,23 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
83
113
  // Files sitting DIRECTLY in this dir (cli/hone-cli.js). If any exist, the dir itself is
84
114
  // the source root — descending would silently drop them, which is how an early cut of this
85
115
  // rule emitted `cli/lib` and lost cli/hone-cli.js (5.8k lines, the whole CLI surface).
86
- // Descend ONLY into a package container (packages/, apps/). Anywhere else, prefer the
87
- // PARENT: a wider scope is the safe error for grounding, a narrower one hides real files.
88
- // An earlier rule descended into conventional children and emitted `server/src`, which
89
- // dropped `server/scripts/` — the exact residual the followup-42 dogfood run hit, where the
90
- // planner could not see the agent prompts and proposed inventing them elsewhere.
91
116
  const direct = list.filter(f => f.split('/').length === 2).length;
92
117
  const container = /^(packages|apps|services|modules)$/i.test(top);
93
- if (container && kids.size > 0 && direct === 0) { out.push(...[...kids.keys()]); continue; }
118
+ // HC-GF-020-followup-1: TEST dirs alone also descend into a sole, unambiguous child (a repo's
119
+ // real test dir was reported as `backend` because every test sat under `backend/tests`, a name
120
+ // this rule never recognized). Deliberately NOT extended to source dirs: an earlier rule
121
+ // descended into ANY conventional child regardless of ambiguity and emitted `server/src`,
122
+ // which dropped `server/scripts/` — the exact residual the followup-42 dogfood run hit, where
123
+ // the planner could not see the agent prompts and proposed inventing them elsewhere. `server/`
124
+ // has both `server/src` and `server/scripts` (2 kids) so it is unaffected either way, but a
125
+ // repo where `server/` holds ONLY `server/src` must still stay at `server` for source dirs —
126
+ // measured by this file's own regression pins — so the single-kid case is test-dirs-only.
127
+ // ...and only when `top` itself is NOT already a conventionally-named test root: a `tests/`
128
+ // dir with a single `tests/integration` child is intentionally left at `tests` — descending
129
+ // would report a narrower, equally-arbitrary name in place of the convention that already
130
+ // says exactly what this directory is.
131
+ const soleUnambiguousTestChild = nameRe === TEST_DIR_NAMES && !nameRe.test(top) && kids.size === 1 && direct === 0;
132
+ if ((container || soleUnambiguousTestChild) && kids.size > 0 && direct === 0) { out.push(...[...kids.keys()]); continue; }
94
133
  out.push(top);
95
134
  }
96
135
  return [...new Set(out)].sort();
@@ -30,6 +30,7 @@ const {
30
30
  extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
31
31
  } = require('./bundle-paths');
32
32
  const { selectBundle, auditLines } = require('./bundle-context');
33
+ const { followRouterMount } = require('./bundle-mount-follow');
33
34
  const { parseIncludePaths, partitionIncludeTargets, statedAnchorsFor, anchorWarnings } = require('./include-paths');
34
35
  const { buildRepoTree, allDirs, MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS } = require('./repo-tree');
35
36
  const { classifyStory } = require('./story-classifier');
@@ -497,7 +498,24 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
497
498
  // HC-GF-073: what exists, what is pending, plus repo-level signal files and existing
498
499
  // neighbours of the pending paths. Order is priority order: the char cap cuts from the end.
499
500
  const selection = selectBundle({ named: [...candidates], repoFiles: repoCtx.repoFiles, exists: io.exists });
500
- const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
501
+ // HC-GF-325: a present file that DEFINES routes says nothing about where they are MOUNTED —
502
+ // step_2 wrote 7 tests against `/properties` for an app that serves every router under
503
+ // `/api/v1`. Right after `present`, so a mount file and an existing API test of the same
504
+ // kind get priority over signal files and siblings within the char budget, same as a path
505
+ // the story named directly would.
506
+ const alreadySelected = new Set([...selection.present, ...selection.signal, ...selection.siblings]);
507
+ const mountFollowed = [];
508
+ for (const p of selection.present) {
509
+ let content;
510
+ try { content = io.readFile(p); } catch { continue; }
511
+ for (const extra of followRouterMount({ routeFilePath: p, routeFileContent: content, repoFiles: repoCtx.repoFiles, readFile: io.readFile })) {
512
+ if (alreadySelected.has(extra)) continue;
513
+ alreadySelected.add(extra);
514
+ mountFollowed.push(extra);
515
+ pre.push(info(` → bundled ${extra} — mounts/tests the router in ${p} (HC-GF-325)`));
516
+ }
517
+ }
518
+ const ordered = [...selection.present, ...mountFollowed, ...selection.signal, ...selection.siblings];
501
519
  const codebaseFiles = [];
502
520
  let totalChars = 0;
503
521
  let budgetSpent = false; // HC-GF-288: so a not-bundled warning can say WHY
@@ -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 };
@@ -196,7 +196,7 @@ function verifyCommandEnv(base = process.env) {
196
196
  * Pass the `command` that ran and the resolver's `source` for it: without them an absent command is
197
197
  * indistinguishable from a failing one (HC-GF-008), and the CLI's two executors both do.
198
198
  *
199
- * @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string, command?: string, source?: string }} run
199
+ * @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string, command?: string, source?: string, error?: { code?: string } }} run
200
200
  * @returns {{ verdict: 'pass'|'fail'|'timeout'|'unresolved', exitCode: number, detail: string }}
201
201
  */
202
202
  function verdictFromRun(run, provisioning = null) {
@@ -216,6 +216,18 @@ function verdictFromRun(run, provisioning = null) {
216
216
  if (run.timedOut) {
217
217
  return { verdict: 'timeout', exitCode: EXIT.TIMEOUT, detail: 'test command exceeded the timeout' };
218
218
  }
219
+ // HC-GF-331: spawnSync kills the child and reports status: null with error.code ENOBUFS when
220
+ // its stdout/stderr exceed maxBuffer. A null status otherwise falls through to the generic
221
+ // "tests failed (exit null)" path below — the suite may have passed; nothing conclusive was
222
+ // observed about it either way, so this is its own outcome, never a reported failure.
223
+ if (run.error && run.error.code === 'ENOBUFS') {
224
+ return {
225
+ verdict: 'unresolved',
226
+ exitCode: EXIT.UNRESOLVED,
227
+ detail: 'test output exceeded the capture buffer (ENOBUFS) — nothing conclusive was observed '
228
+ + 'about this change; raise maxBuffer or have the command print less',
229
+ };
230
+ }
219
231
  if (run.code === 0) {
220
232
  // HC-GF-223: a pass under an operator-supplied command is evidence about a SUBSET. Measured on
221
233
  // run `437246a7`: `--command "node --test <3 files>"` reported `pass` to the server and the same
@@ -401,6 +413,11 @@ function classifyGateRun(run) {
401
413
  return { verdict: 'unavailable', detail: 'command not found — gate NOT run' };
402
414
  }
403
415
  if (run.timedOut) return { verdict: 'unavailable', detail: 'timed out — gate NOT run to completion' };
416
+ // HC-GF-331: shares verdictFromRun's shape — a null status from an ENOBUFS overflow is not a
417
+ // finding, it is the gate not having run to a readable conclusion.
418
+ if (run.error && run.error.code === 'ENOBUFS') {
419
+ return { verdict: 'unavailable', detail: 'output exceeded the capture buffer (ENOBUFS) — gate NOT run to completion' };
420
+ }
404
421
  if (run.code === 0) return { verdict: 'pass', detail: 'clean' };
405
422
  const sum = summarizeFailure(run, { maxLines: 6 });
406
423
  // A linter prints its findings from the TOP, so unlike a test runner the most
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.35.1",
3
+ "version": "1.37.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {