@hone-ai/cli 1.36.0 → 1.38.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,9 +39,18 @@ 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
+ const { runArgsError, orchestratePath, apiPath } = require('./lib/run-target'); // HC-GF-270-followup-1, -2
43
+ const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase } = require('./lib/operator-question'); // HC-GF-234
43
44
  const program = new Command();
44
45
 
46
+ // HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
47
+ // (this repo's own `make ci`) overflowed it, spawnSync returned status: null with error.code
48
+ // ENOBUFS, and verdictFromRun/classifyGateRun read that null status as a plain test failure —
49
+ // not as "the output could not be captured." Generous relative to any suite measured so far
50
+ // (`make ci` prints low hundreds of KB; 64 MiB is two orders of magnitude of headroom) without
51
+ // holding an unbounded amount of memory for a runaway command.
52
+ const VERIFY_MAX_BUFFER = 64 * 1024 * 1024;
53
+
45
54
  // ── Config resolution ─────────────────────────────────────────────────────────
46
55
  const RC_PATH = path.join(os.homedir(), '.honerc');
47
56
 
@@ -1746,7 +1755,7 @@ program
1746
1755
  await sleep(pollMs);
1747
1756
  process.stdout.write('.');
1748
1757
  try {
1749
- const r = await client.get(`/derive/${jobId}`);
1758
+ const r = await client.get(apiPath('derive', [jobId]));
1750
1759
  if (r.data.status === 'complete') {
1751
1760
  result = r.data;
1752
1761
  break;
@@ -1983,6 +1992,10 @@ program
1983
1992
  .requiredOption('--job <jobId>', 'Job ID returned by "hone derive --refresh"')
1984
1993
  .option('--changes <mode>', 'Which changes to apply: all (default)', 'all')
1985
1994
  .action(async (opts) => {
1995
+ // HC-GF-270-followup-2: this GETs the job named by `--job` and then POSTs its approval, so the id is
1996
+ // checked before any config read or request: `drv_x/../../orchestrate/<run>/approve?` became a POST
1997
+ // to another endpoint's approval.
1998
+ refuseBadRunArgs([{ label: '--job', kind: 'deriveJobId', value: opts.job }]);
1986
1999
  const config = getConfig();
1987
2000
  const client = api(config);
1988
2001
 
@@ -1991,7 +2004,7 @@ program
1991
2004
  // Verify job is ready
1992
2005
  let jobData;
1993
2006
  try {
1994
- const r = await client.get(`/derive/${opts.job}`);
2007
+ const r = await client.get(apiPath('derive', [opts.job]));
1995
2008
  jobData = r.data;
1996
2009
  } catch (e) {
1997
2010
  console.error(`Failed to fetch job: ${e.response?.data?.error || e.message}`);
@@ -2009,7 +2022,7 @@ program
2009
2022
 
2010
2023
  // Apply changes
2011
2024
  try {
2012
- const r = await client.post(`/derive/${opts.job}/approve`, {
2025
+ const r = await client.post(apiPath('derive', [opts.job, 'approve']), {
2013
2026
  approvedChanges: opts.changes,
2014
2027
  });
2015
2028
  console.log('✓ Changes approved and applied');
@@ -2505,6 +2518,7 @@ program
2505
2518
  .option('--with-learnings-report', 'Also fetch promoted-learnings report + update local YAML status (H-013)')
2506
2519
  .option('--force', 'Overwrite local edits (default: skip files with local changes — H-024)')
2507
2520
  .action(async (opts) => {
2521
+ refuseBadRunArgs([{ label: '--repo', kind: 'pathName', value: opts.repo, optional: true }]); // HC-GF-270-followup-2
2508
2522
  const config = getConfig();
2509
2523
  const client = api(config);
2510
2524
  const repoName = opts.repo || path.basename(process.cwd());
@@ -2540,7 +2554,7 @@ program
2540
2554
  // ── 1. Sync derived skills ──────────────────────────────────────────────
2541
2555
  if (!opts.agentsOnly) {
2542
2556
  try {
2543
- const r = await client.get(`/skills/${repoName}/derived`);
2557
+ const r = await client.get(apiPath('skills', [repoName, 'derived']));
2544
2558
  const { skills } = r.data;
2545
2559
  if (!skills.length) {
2546
2560
  console.log('No derived skills found. Run "hone derive" first.');
@@ -2620,7 +2634,7 @@ program
2620
2634
  content = editorStubContent(agent);
2621
2635
  console.log(` → ${agent}: editor mode — syncing runtime stub (full prompt served ephemerally)`);
2622
2636
  } else {
2623
- const r = await client.get(`/prompts/${agent}`, {
2637
+ const r = await client.get(apiPath('prompts', [agent]), {
2624
2638
  responseType: 'text',
2625
2639
  headers: { Accept: 'text/markdown' },
2626
2640
  });
@@ -2931,8 +2945,7 @@ program
2931
2945
  // The default request is byte-identical to the one that shipped — no query string at all —
2932
2946
  // so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
2933
2947
  // parameter appears only when the operator asked for a status other than the default.
2934
- const qs = status === 'promoted' ? '' : `?status=${encodeURIComponent(status)}`;
2935
- const { data } = await client.get(`/learnings/promoted${qs}`);
2948
+ const { data } = await client.get(apiPath('learnings', ['promoted'], { status: status === 'promoted' ? undefined : status }));
2936
2949
  const all = Array.isArray(data.learnings) ? data.learnings : [];
2937
2950
  // The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
2938
2951
  // router found no target; a row promoted before that story carries neither field at all.
@@ -5950,11 +5963,7 @@ program
5950
5963
  const config = getConfig();
5951
5964
  const client = api(config);
5952
5965
  try {
5953
- const params = new URLSearchParams();
5954
- if (opts.status) params.set('status', opts.status);
5955
- if (opts.limit) params.set('limit', opts.limit);
5956
- const qs = params.toString();
5957
- const { data } = await client.get(`/orchestrate${qs ? `?${qs}` : ''}`);
5966
+ const { data } = await client.get(apiPath('orchestrate', [], { status: opts.status, limit: opts.limit }));
5958
5967
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5959
5968
  if (!data.runs.length) { console.log('No workflow runs found.'); return; }
5960
5969
  const age = (ts) => {
@@ -6138,7 +6147,7 @@ function runContextIo(apiConfig) {
6138
6147
  }
6139
6148
  },
6140
6149
  fetchStoryContext: async (storyId) => {
6141
- const { data } = await api(apiConfig).get(`/stories/${encodeURIComponent(storyId)}/context`);
6150
+ const { data } = await api(apiConfig).get(apiPath('stories', [storyId, 'context']));
6142
6151
  return data;
6143
6152
  },
6144
6153
  recentlyModifiedOverlap: (touched) =>
@@ -6165,6 +6174,8 @@ program
6165
6174
  .option('--kill', 'Kill a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
6166
6175
  .option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
6167
6176
  .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)')
6177
+ .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.')
6178
+ .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.')
6168
6179
  .option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
6169
6180
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
6170
6181
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
@@ -6409,6 +6420,16 @@ program
6409
6420
  console.log(` (auto-verify skipped: ${e.message})`);
6410
6421
  }
6411
6422
 
6423
+ // HC-GF-234: show what the step ASKED where the operator decides. Approving records
6424
+ // `approved` and moves on — it does not answer the question, and until now nothing at
6425
+ // this prompt said there was one. Printed last, right above the prompt (auto-verify's
6426
+ // output above can run to pages), and in both the gate and the exhausted branch: the
6427
+ // step's question rides on the attempt the operator is about to decide about.
6428
+ // Never throws: a print must not end a poll loop.
6429
+ try {
6430
+ for (const line of gateQuestionLines(status, d.stepKey)) console.log(line);
6431
+ } catch { /* a print must never end a poll loop */ }
6432
+
6412
6433
  if (process.stdin.isTTY) {
6413
6434
  const readline = require('readline');
6414
6435
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -6471,6 +6492,14 @@ program
6471
6492
  // step says so instead of listing a stale failed row above a completed one.
6472
6493
  const tries = (s.attempts || 1) > 1 ? ` [attempt ${s.attempt} of ${s.attempts}]` : '';
6473
6494
  console.log(` ${icon} ${s.stepKey} (${s.agent}) ${s.status}${gate}${tries}`);
6495
+ // HC-GF-234: `status: completed` / `gateResult: approved` alone cannot tell "did its
6496
+ // work" apart from "asked a question nobody answered" — batch mode has no human gate
6497
+ // on steps 0-3b, so this line is the only place that question ever surfaces here.
6498
+ const asked = normalizeQuestion(s.operatorQuestion);
6499
+ if (asked) {
6500
+ console.log(` asked ${countPhrase(asked.count)} nobody answered:`);
6501
+ for (const l of questionLines(asked, { max: 120 })) console.log(` ${l}`);
6502
+ }
6474
6503
  }
6475
6504
  if (data.awaitingApproval && isEditorParked(data)) {
6476
6505
  console.log('');
@@ -6479,6 +6508,7 @@ program
6479
6508
  console.log('');
6480
6509
  console.log(`Awaiting approval at ${data.currentStep}.`);
6481
6510
  console.log(`Approve: hone run-story ${data.runId} --approve ${data.currentStep}`);
6511
+ console.log(`Reject: hone run-story ${data.runId} --reject ${data.currentStep} --reason "<why>"`);
6482
6512
  }
6483
6513
  console.log('');
6484
6514
  // HC-GF-016: at a TERMINAL state, print the report unasked. The whole
@@ -6548,6 +6578,37 @@ program
6548
6578
  return;
6549
6579
  }
6550
6580
 
6581
+ // Reject mode (HC-GF-323)
6582
+ if (opts.reject) {
6583
+ if (!opts.reason || !String(opts.reason).trim()) {
6584
+ console.error('--reject requires --reason <text>');
6585
+ process.exit(1);
6586
+ }
6587
+ let rejectId;
6588
+ try {
6589
+ rejectId = await existingRunId(`--reject ${opts.reject}`);
6590
+ await client.post(orchestratePath([rejectId, 'reject']),
6591
+ { stepKey: opts.reject, reason: opts.reason });
6592
+ console.log(`Gate ${opts.reject} rejected. Re-running with the reason. Workflow resuming.`);
6593
+ } catch (e) {
6594
+ console.error(`Failed: ${e.response?.data?.error || e.message}`);
6595
+ process.exit(1);
6596
+ }
6597
+ // HC-GF-070: same FOLLOW behaviour as --approve — a caller told to poll does not.
6598
+ if (opts.follow === false) {
6599
+ console.log(`Poll: hone run-story ${rejectId} --status`);
6600
+ return;
6601
+ }
6602
+ try {
6603
+ await pollUntilDecision(rejectId);
6604
+ } catch (e) {
6605
+ console.error(`Rejected, but following the run failed: ${e.message}`);
6606
+ console.error(`Poll: hone run-story ${rejectId} --status`);
6607
+ process.exit(1);
6608
+ }
6609
+ return;
6610
+ }
6611
+
6551
6612
  // Start workflow (storyId required, runId not accepted for starting)
6552
6613
  if (isUuid) {
6553
6614
  console.error('Cannot start workflow with a runId. Use a story ID like HC-042.');
@@ -7651,6 +7712,16 @@ showCmd
7651
7712
  console.log(` startedAt: ${s.startedAt || '(not started)'}`);
7652
7713
  console.log(` completedAt: ${s.completedAt || '(not completed)'}`);
7653
7714
  if (s.tokenUsage) console.log(` tokenUsage: ${JSON.stringify(s.tokenUsage)}`);
7715
+ // HC-GF-234: `status`/`gateResult` above read identically whether this step did its
7716
+ // work or ended by asking the operator something nobody answered — surface it here so
7717
+ // an operator does not have to read raw step output to find out.
7718
+ const asked = normalizeQuestion(s.operatorQuestion);
7719
+ if (asked) {
7720
+ console.log(``);
7721
+ console.log(`── operator question(s) — asked, not answered ──────────`);
7722
+ console.log(` asked ${asked.count} question(s):`);
7723
+ for (const l of questionLines(asked)) console.log(` ${l}`);
7724
+ }
7654
7725
  if (s.errorContext) {
7655
7726
  console.log(``);
7656
7727
  console.log(`── error_context (retry feedback) ──────────────────────`);
@@ -7881,7 +7952,11 @@ program
7881
7952
  verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7882
7953
  // best-effort: pull the run's ci_local_command for the default
7883
7954
  try {
7884
- const runR = await client.get(orchestratePath([workflowId]));
7955
+ // HC-GF-302-followup-1: on a connection of its own. This GET follows the step GET at once, and
7956
+ // on the socket that one pooled a server closing it idle resets the request - a failed config
7957
+ // read, so the provenance gate below refused a trusted run (exit 9). Nothing is retried: a read
7958
+ // that fails on its own connection still refuses.
7959
+ const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
7885
7960
  // HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
7886
7961
  // `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
7887
7962
  // 200 with no config. And a body that is not THIS run is not this run's config.
@@ -8185,9 +8260,10 @@ program
8185
8260
  cwd: wt, shell: true, encoding: 'utf8',
8186
8261
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8187
8262
  stdio: ['ignore', 'pipe', 'pipe'],
8263
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: a >1 MiB suite (this repo's own `make ci`) must not ENOBUFS
8188
8264
  });
8189
8265
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8190
- const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
8266
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
8191
8267
  // HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
8192
8268
  if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
8193
8269
  const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
@@ -8243,6 +8319,7 @@ program
8243
8319
  cwd: wt, shell: true, encoding: 'utf8',
8244
8320
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8245
8321
  stdio: ['ignore', 'pipe', 'pipe'],
8322
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: shares the shape at :8184 — same fix
8246
8323
  });
8247
8324
  const c = classifyGateRun({
8248
8325
  code: gr.status, error: gr.error,
@@ -8523,7 +8600,8 @@ program
8523
8600
  output = r.data?.output;
8524
8601
  stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
8525
8602
  try {
8526
- const runR = await client.get(orchestratePath([workflowId]));
8603
+ // HC-GF-302-followup-1: its own connection, as verify-patch's (see there).
8604
+ const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
8527
8605
  // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
8528
8606
  // is not THIS run does not count as its config.
8529
8607
  if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
@@ -8738,9 +8816,10 @@ program
8738
8816
  cwd: wt, shell: true, encoding: 'utf8',
8739
8817
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8740
8818
  stdio: ['ignore', 'pipe', 'pipe'],
8819
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: emit-pr's verify shares verify-patch's spawnSync shape
8741
8820
  });
8742
8821
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8743
- const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
8822
+ const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
8744
8823
  // HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
8745
8824
  // narrowed verdict fell into the else below, which printed "verify failed, no branch
8746
8825
  // emitted" and then exited with that verdict's own exit code — zero. A failure path that
@@ -11,6 +11,10 @@
11
11
  * network or a filesystem.
12
12
  */
13
13
 
14
+ // HC-GF-270-followup-2: every path goes through the one builder. The derived set's repository is the
15
+ // checkout's directory name, which can hold `?`, `#` or `%`; hand-built, `x?y` fetched `/skills/x`.
16
+ const { apiPath } = require('./run-target');
17
+
14
18
  /**
15
19
  * @param {object} args
16
20
  * @param {string[]} args.agents agent names
@@ -39,12 +43,12 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
39
43
  return [
40
44
  ...agents.map(agent => ({
41
45
  key: `${agent}.agent.md`,
42
- promise: get(`/scripts/agent-stub/${agent}`),
46
+ promise: get(apiPath('scripts', ['agent-stub', agent])),
43
47
  write: (data) => writeAgent(agent, data),
44
48
  })),
45
49
  ...skills.map(skill => ({
46
50
  key: `skills/${skill}/SKILL.md`,
47
- promise: get(`/skills/${skill}`),
51
+ promise: get(apiPath('skills', [skill])),
48
52
  write: (data) => writeSkill(skill, data),
49
53
  })),
50
54
  // ── HC-GF-184: the per-repo DERIVED set ────────────────────────────────────────────────────
@@ -59,7 +63,7 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
59
63
  // derived, so setup behaves exactly as before for them.
60
64
  ...(derivedRepo ? [{
61
65
  key: `skills/<derived for ${derivedRepo}>`,
62
- promise: get(`/skills/${derivedRepo}/derived`),
66
+ promise: get(apiPath('skills', [derivedRepo, 'derived'])),
63
67
  // Tolerant by construction: this runs mid-setup, AFTER the baseline has landed, so a shape
64
68
  // change at the endpoint must not abort an adopter's install half-written.
65
69
  write: (data) => {
@@ -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 };
@@ -464,8 +464,19 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
464
464
  // The radius is sized before any window exists, so it prices the marker at its widest:
465
465
  // the longest label among the sites, with line numbers at the file's own width.
466
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;
467
478
  const markerCost = Math.max(...chosen.map((s) => header(widest, s.anchor, totalLines).length))
468
- + footer.length + 1;
479
+ + Math.max(footer.length, FOOTER_SIZING_RESERVE_CHARS) + 1;
469
480
  // HC-GF-288: the 800 covers ONE window's marker, and every further window brings its
470
481
  // own. Sized from `limit - 800` alone, n windows overran the cap from n = 3 — so the
471
482
  // LAST region was always dropped however much room the rest left. Measured on
@@ -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();
@@ -0,0 +1,94 @@
1
+ /**
2
+ * operator-question.js — HC-GF-234: render the operator question(s) a step asked and nobody answered.
3
+ *
4
+ * A step that ends by asking the operator something used to be recorded exactly like a step that
5
+ * did its work (`status: completed`, `gateResult: approved`). The server now stores
6
+ * `{ count, questions }` on the step's attempt row and carries it as `operatorQuestion` on
7
+ * `GET /orchestrate/:runId` and `.../step/:stepKey`. Four CLI surfaces print it — `--status`,
8
+ * `show step`, the human-gate prompt, and the NEEDS YOU list — and they share this module so the
9
+ * wording and the two honesty rules below cannot drift apart.
10
+ *
11
+ * PURE: data in, strings out. No fs, no console, no process — a leaf, so run-report.js can keep
12
+ * its "derived, never narrated" contract and require it.
13
+ *
14
+ * TWO HONESTY RULES.
15
+ * - `count` is the TRUE number of questions the step asked; `questions` holds at most the first
16
+ * five. When count exceeds what is stored, the surfaces say "+N more not stored" instead of
17
+ * letting a short list read as the whole of it.
18
+ * - The text is AGENT-WRITTEN and goes to a terminal. The server scrubs control characters
19
+ * before storing, but this CLI may talk to an older or different server, so it scrubs again
20
+ * at the point of printing: C0/C1 controls, DEL, and the bidirectional controls that reorder
21
+ * what a terminal draws.
22
+ *
23
+ * TOLERANT OF AN OLDER SERVER BY CONSTRUCTION: a missing key, null, or a payload of the wrong
24
+ * shape normalises to null, which is today's output (nothing printed).
25
+ */
26
+
27
+ // U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
28
+ // \u escapes: a literal bidi character in this source would reorder how THIS file reads.
29
+ // eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
30
+ const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
31
+
32
+ function scrub(text) {
33
+ return String(text == null ? '' : text).replace(TERMINAL_UNSAFE_RE, '');
34
+ }
35
+
36
+ /**
37
+ * @param {*} oq a step's `operatorQuestion`, straight off the network (not trusted to have a shape)
38
+ * @returns {{count:number, questions:string[], more:number}|null} null = the step did not ask
39
+ */
40
+ function normalizeQuestion(oq) {
41
+ if (!oq || typeof oq !== 'object') return null;
42
+ const count = Math.floor(Number(oq.count));
43
+ if (!Number.isFinite(count) || count < 1) return null;
44
+ const questions = (Array.isArray(oq.questions) ? oq.questions : []).map(scrub);
45
+ return { count, questions, more: Math.max(0, count - questions.length) };
46
+ }
47
+
48
+ // Clips by CODE POINT, not UTF-16 unit (review round 3 F4): a `.slice(0, max)` that lands between
49
+ // the halves of an emoji prints a lone surrogate, which a terminal draws as U+FFFD.
50
+ function clip(text, max) {
51
+ if (!Number.isFinite(max)) return text;
52
+ const points = Array.from(text);
53
+ return points.length > max ? `${points.slice(0, max).join('')}...` : text;
54
+ }
55
+
56
+ /**
57
+ * One entry per stored question (`- <question>`, clipped to `max` chars) and, when the step asked
58
+ * more than was stored, a closing `+ N more not stored`. Callers add their own indentation.
59
+ */
60
+ function questionLines(asked, { max = Infinity } = {}) {
61
+ if (!asked) return [];
62
+ const lines = asked.questions.map((q) => `- ${clip(q, max)}`);
63
+ if (asked.more > 0) lines.push(`+ ${asked.more} more not stored`);
64
+ return lines;
65
+ }
66
+
67
+ function countPhrase(count) {
68
+ return `${count} question${count === 1 ? '' : 's'}`;
69
+ }
70
+
71
+ /**
72
+ * The block printed at the human gate, right before "Approve?" — the one place the operator
73
+ * decides, and where approving silently consumes the question without answering it.
74
+ *
75
+ * SCOPED TO THE STEP THE RUN IS PAUSED AT (`stepKey`), the same discipline as
76
+ * hard-pause-remedy.js's remedyFor: the column is durable, so a question step_3b asked must not
77
+ * be re-printed as if it belonged to an unrelated step_4 gate.
78
+ *
79
+ * @param {object} status a GET /orchestrate/:runId payload
80
+ * @param {string} stepKey the step the gate is for
81
+ * @returns {string[]} lines to print (empty when that step asked nothing)
82
+ */
83
+ function gateQuestionLines(status, stepKey) {
84
+ const steps = status && Array.isArray(status.steps) ? status.steps : [];
85
+ const step = steps.find((x) => x && typeof x === 'object' && x.stepKey === stepKey);
86
+ const asked = step ? normalizeQuestion(step.operatorQuestion) : null;
87
+ if (!asked) return [];
88
+ return [
89
+ ` ${stepKey} asked ${countPhrase(asked.count)} that nobody has answered — approving does not answer ${asked.count === 1 ? 'it' : 'them'}:`,
90
+ ...questionLines(asked, { max: 240 }).map((l) => ` ${l}`),
91
+ ];
92
+ }
93
+
94
+ module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
@@ -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
package/lib/run-report.js CHANGED
@@ -16,6 +16,10 @@
16
16
  * Rendering is separate so both are testable without a server.
17
17
  */
18
18
 
19
+ // HC-GF-234: the one wording for a step's unanswered question, shared with `--status`, `show
20
+ // step` and the human-gate prompt. A pure leaf module, so this file stays pure.
21
+ const { normalizeQuestion, questionLines, countPhrase } = require('./operator-question');
22
+
19
23
  /** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
20
24
  const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
21
25
 
@@ -324,6 +328,31 @@ function buildReport(run, extra = {}) {
324
328
  }
325
329
  }
326
330
 
331
+ // 1e. HC-GF-234: a step that ended by asking the operator something.
332
+ //
333
+ // `status: completed` / `gateResult: approved` cannot say this: batch mode has no human gate on
334
+ // steps 0-3b, and approving a gate records `approved` whether or not the question was read. The
335
+ // step row carries the question on its own column; this is the only place a TERMINAL run's
336
+ // report can tell "did its work" from "asked, and nobody here answered".
337
+ //
338
+ // Says only what is MEASURED — the step asked, and the run has no answer to it. It does not say
339
+ // the step produced no deliverable or that the run proceeded on a guess: nothing checks either.
340
+ //
341
+ // ADVISORY (HC-026c: a new signal REPORTS before it GATES): `hone run-report` exits 1 on any
342
+ // non-advisory item, and an agent that asks one benign question must not turn an adopter's
343
+ // passing script red overnight. Visible in the report, absent from the exit code.
344
+ for (const s of steps) {
345
+ const asked = normalizeQuestion(s.operatorQuestion);
346
+ if (!asked) continue;
347
+ needsYou.push({
348
+ what: `${s.stepKey} (${s.agent || 'agent'}) asked ${countPhrase(asked.count)} that nothing in this run answered`,
349
+ detail: questionLines(asked, { max: 200 }),
350
+ how: `hone show step ${run.runId} ${s.stepKey}`,
351
+ howLabel: 'inspect',
352
+ advisory: true,
353
+ });
354
+ }
355
+
327
356
  // 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
328
357
  // Listing "verification failed" and "lint failed" separately gave two entries
329
358
  // for one problem resolved by one fix — and NEEDS YOU is a list of DISTINCT
package/lib/run-target.js CHANGED
@@ -104,17 +104,26 @@ async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
104
104
  const isStepKey = (s) => typeof s === 'string' && /^step_[0-9a-z_]+$/i.test(s);
105
105
  const isAttempt = (s) => (typeof s === 'string' || typeof s === 'number') && /^[1-9]\d{0,8}$/.test(String(s));
106
106
  const isBatchId = isRunId;
107
+ // HC-GF-270-followup-2: POST /derive is the only producer of a derive job id (server/src/routes/derive.js),
108
+ // and it issues `drv_` + 12 lower-case hex characters into a TEXT key compared exactly, so no other shape
109
+ // can name a job. `hone approve --job` GETs and then POSTs under that id.
110
+ const isDeriveJobId = (s) => typeof s === 'string' && /^drv_[0-9a-f]{12}$/.test(s);
111
+ // A name that becomes ONE path segment, such as a repository name. Encoding makes any other character
112
+ // safe; these three it cannot, because `new URL()` resolves `.` and `..` as directory steps.
113
+ const isPathName = (s) => typeof s === 'string' && s !== '' && s !== '.' && s !== '..';
107
114
 
108
115
  const KINDS = {
109
116
  runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
110
117
  batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
111
118
  stepKey: { ok: isStepKey, why: 'must name a step, like step_4', hint: '' },
112
119
  attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
120
+ deriveJobId: { ok: isDeriveJobId, why: 'must be a derive job ID, like drv_0123456789ab', hint: ' — the ID "hone derive --refresh" printed' },
121
+ pathName: { ok: isPathName, why: 'must be a name, not empty, "." or ".."', hint: '' },
113
122
  };
114
123
 
115
124
  /**
116
125
  * 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
126
+ * @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName', value: any, optional?: boolean }>} checks
118
127
  * `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
119
128
  * is undefined (a flag that was not given); an empty string is always checked.
120
129
  */
@@ -130,23 +139,29 @@ function runArgsError(checks) {
130
139
  }
131
140
 
132
141
  /**
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.
142
+ * The one way the CLI builds a path on the Hone API: `/<prefix>/<segment>/…?<query>`. `prefix` is a
143
+ * literal resource name (`derive`, `skills`, …), never a value. Each segment is encoded exactly once, and
144
+ * the segments encoding cannot make safe (empty, `.`, `..`, null, undefined) throw: every caller-supplied
145
+ * value has passed `runArgsError` first, so a throw here is a bug, not an input error. Query values that
146
+ * are null, undefined, false or '' are dropped; the rest go through URLSearchParams.
137
147
  *
138
148
  * 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.
149
+ * HC-GF-270-followup-2 scan fails on one.
150
+ *
151
+ * HC-GF-270-followup-2: generalized from orchestratePath. HC-GF-270-followup-1 closed the defect under
152
+ * `/orchestrate/`; `/derive/`, `/skills/`, `/prompts/`, `/scripts/` and `/stories/` were still built by
153
+ * hand, and `hone approve --job` POSTed under an id nothing checked.
140
154
  */
141
- function orchestratePath(segments, query) {
142
- if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
155
+ function apiPath(prefix, segments, query) {
156
+ if (typeof prefix !== 'string' || !/^[a-z][a-z0-9-]*$/.test(prefix)) throw new TypeError(`apiPath: prefix must be a resource name, got ${JSON.stringify(prefix)}`);
157
+ if (!Array.isArray(segments)) throw new TypeError('apiPath: segments must be an array');
143
158
  const parts = segments.map((v, i) => {
144
- if (v === null || v === undefined) throw new TypeError(`orchestratePath: segment ${i} is ${v}`);
159
+ if (v === null || v === undefined) throw new TypeError(`apiPath: segment ${i} is ${v}`);
145
160
  const s = String(v);
146
- if (s === '' || s === '.' || s === '..') throw new TypeError(`orchestratePath: segment ${i} is ${JSON.stringify(s)}`);
161
+ if (s === '' || s === '.' || s === '..') throw new TypeError(`apiPath: segment ${i} is ${JSON.stringify(s)}`);
147
162
  return encodeURIComponent(s);
148
163
  });
149
- const base = ['', 'orchestrate', ...parts].join('/');
164
+ const base = ['', prefix, ...parts].join('/');
150
165
  const qs = new URLSearchParams();
151
166
  for (const [k, v] of Object.entries(query || {})) {
152
167
  if (v === null || v === undefined || v === false || v === '') continue;
@@ -156,7 +171,13 @@ function orchestratePath(segments, query) {
156
171
  return q ? `${base}?${q}` : base;
157
172
  }
158
173
 
174
+ /** An `/orchestrate/…` path: `apiPath('orchestrate', …)` with at least one segment (HC-GF-270-followup-1). */
175
+ function orchestratePath(segments, query) {
176
+ if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
177
+ return apiPath('orchestrate', segments, query);
178
+ }
179
+
159
180
  module.exports = {
160
181
  resolveRunTarget, isRunId, LOOKUP_LIMIT,
161
- isStepKey, isAttempt, isBatchId, runArgsError, orchestratePath,
182
+ isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, runArgsError, apiPath, orchestratePath,
162
183
  };
@@ -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.36.0",
3
+ "version": "1.38.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {