@hone-ai/cli 1.37.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,7 +39,8 @@ 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
 
45
46
  // HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
@@ -1754,7 +1755,7 @@ program
1754
1755
  await sleep(pollMs);
1755
1756
  process.stdout.write('.');
1756
1757
  try {
1757
- const r = await client.get(`/derive/${jobId}`);
1758
+ const r = await client.get(apiPath('derive', [jobId]));
1758
1759
  if (r.data.status === 'complete') {
1759
1760
  result = r.data;
1760
1761
  break;
@@ -1991,6 +1992,10 @@ program
1991
1992
  .requiredOption('--job <jobId>', 'Job ID returned by "hone derive --refresh"')
1992
1993
  .option('--changes <mode>', 'Which changes to apply: all (default)', 'all')
1993
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 }]);
1994
1999
  const config = getConfig();
1995
2000
  const client = api(config);
1996
2001
 
@@ -1999,7 +2004,7 @@ program
1999
2004
  // Verify job is ready
2000
2005
  let jobData;
2001
2006
  try {
2002
- const r = await client.get(`/derive/${opts.job}`);
2007
+ const r = await client.get(apiPath('derive', [opts.job]));
2003
2008
  jobData = r.data;
2004
2009
  } catch (e) {
2005
2010
  console.error(`Failed to fetch job: ${e.response?.data?.error || e.message}`);
@@ -2017,7 +2022,7 @@ program
2017
2022
 
2018
2023
  // Apply changes
2019
2024
  try {
2020
- const r = await client.post(`/derive/${opts.job}/approve`, {
2025
+ const r = await client.post(apiPath('derive', [opts.job, 'approve']), {
2021
2026
  approvedChanges: opts.changes,
2022
2027
  });
2023
2028
  console.log('✓ Changes approved and applied');
@@ -2513,6 +2518,7 @@ program
2513
2518
  .option('--with-learnings-report', 'Also fetch promoted-learnings report + update local YAML status (H-013)')
2514
2519
  .option('--force', 'Overwrite local edits (default: skip files with local changes — H-024)')
2515
2520
  .action(async (opts) => {
2521
+ refuseBadRunArgs([{ label: '--repo', kind: 'pathName', value: opts.repo, optional: true }]); // HC-GF-270-followup-2
2516
2522
  const config = getConfig();
2517
2523
  const client = api(config);
2518
2524
  const repoName = opts.repo || path.basename(process.cwd());
@@ -2548,7 +2554,7 @@ program
2548
2554
  // ── 1. Sync derived skills ──────────────────────────────────────────────
2549
2555
  if (!opts.agentsOnly) {
2550
2556
  try {
2551
- const r = await client.get(`/skills/${repoName}/derived`);
2557
+ const r = await client.get(apiPath('skills', [repoName, 'derived']));
2552
2558
  const { skills } = r.data;
2553
2559
  if (!skills.length) {
2554
2560
  console.log('No derived skills found. Run "hone derive" first.');
@@ -2628,7 +2634,7 @@ program
2628
2634
  content = editorStubContent(agent);
2629
2635
  console.log(` → ${agent}: editor mode — syncing runtime stub (full prompt served ephemerally)`);
2630
2636
  } else {
2631
- const r = await client.get(`/prompts/${agent}`, {
2637
+ const r = await client.get(apiPath('prompts', [agent]), {
2632
2638
  responseType: 'text',
2633
2639
  headers: { Accept: 'text/markdown' },
2634
2640
  });
@@ -2939,8 +2945,7 @@ program
2939
2945
  // The default request is byte-identical to the one that shipped — no query string at all —
2940
2946
  // so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
2941
2947
  // parameter appears only when the operator asked for a status other than the default.
2942
- const qs = status === 'promoted' ? '' : `?status=${encodeURIComponent(status)}`;
2943
- const { data } = await client.get(`/learnings/promoted${qs}`);
2948
+ const { data } = await client.get(apiPath('learnings', ['promoted'], { status: status === 'promoted' ? undefined : status }));
2944
2949
  const all = Array.isArray(data.learnings) ? data.learnings : [];
2945
2950
  // The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
2946
2951
  // router found no target; a row promoted before that story carries neither field at all.
@@ -5958,11 +5963,7 @@ program
5958
5963
  const config = getConfig();
5959
5964
  const client = api(config);
5960
5965
  try {
5961
- const params = new URLSearchParams();
5962
- if (opts.status) params.set('status', opts.status);
5963
- if (opts.limit) params.set('limit', opts.limit);
5964
- const qs = params.toString();
5965
- const { data } = await client.get(`/orchestrate${qs ? `?${qs}` : ''}`);
5966
+ const { data } = await client.get(apiPath('orchestrate', [], { status: opts.status, limit: opts.limit }));
5966
5967
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5967
5968
  if (!data.runs.length) { console.log('No workflow runs found.'); return; }
5968
5969
  const age = (ts) => {
@@ -6146,7 +6147,7 @@ function runContextIo(apiConfig) {
6146
6147
  }
6147
6148
  },
6148
6149
  fetchStoryContext: async (storyId) => {
6149
- const { data } = await api(apiConfig).get(`/stories/${encodeURIComponent(storyId)}/context`);
6150
+ const { data } = await api(apiConfig).get(apiPath('stories', [storyId, 'context']));
6150
6151
  return data;
6151
6152
  },
6152
6153
  recentlyModifiedOverlap: (touched) =>
@@ -6419,6 +6420,16 @@ program
6419
6420
  console.log(` (auto-verify skipped: ${e.message})`);
6420
6421
  }
6421
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
+
6422
6433
  if (process.stdin.isTTY) {
6423
6434
  const readline = require('readline');
6424
6435
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -6481,6 +6492,14 @@ program
6481
6492
  // step says so instead of listing a stale failed row above a completed one.
6482
6493
  const tries = (s.attempts || 1) > 1 ? ` [attempt ${s.attempt} of ${s.attempts}]` : '';
6483
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
+ }
6484
6503
  }
6485
6504
  if (data.awaitingApproval && isEditorParked(data)) {
6486
6505
  console.log('');
@@ -7693,6 +7712,16 @@ showCmd
7693
7712
  console.log(` startedAt: ${s.startedAt || '(not started)'}`);
7694
7713
  console.log(` completedAt: ${s.completedAt || '(not completed)'}`);
7695
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
+ }
7696
7725
  if (s.errorContext) {
7697
7726
  console.log(``);
7698
7727
  console.log(`── error_context (retry feedback) ──────────────────────`);
@@ -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,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 };
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {