@hone-ai/cli 1.37.0 → 1.38.1

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.
@@ -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 };
@@ -38,7 +38,6 @@ const {
38
38
  extractType, extractSecurityImplications, extractCrossRepoDependency,
39
39
  } = require('./story-classifier-extract');
40
40
  const { readArchitectConfigFromText } = require('./architect-config');
41
- const { remoteHostFromUrl } = require('./story-provenance');
42
41
  const path = require('path');
43
42
 
44
43
  /**
@@ -121,7 +120,8 @@ async function issueStorySource(io, issueNumber, issue) {
121
120
  author: (api.user && api.user.login) || null,
122
121
  association: api.author_association,
123
122
  html_url: api.html_url,
124
- remoteHost: remoteHostFromUrl(got.remoteUrl),
123
+ // HC-GF-283: the host BOTH gh calls were told about — an SSH alias already resolved to it.
124
+ remoteHost: (got && got.host) || null,
125
125
  };
126
126
  return { source, notes: [info(` → story provenance: ${source.author || 'unknown'} (author_association: ${source.association || 'unknown'})`)] };
127
127
  } catch (e) {
@@ -311,7 +311,8 @@ function resolveRepoContext(io, opts = {}) {
311
311
  * @param {object} io
312
312
  * @param {(n: string) => Promise<{title:string, body?:string, url:string}>|{title:string, body?:string, url:string}} io.fetchIssue
313
313
  * rejects or throws on failure
314
- * @param {(n: string) => Promise<{owner:string, repo:string, remoteUrl:string, issue:object}>} io.fetchIssueProvenance
314
+ * @param {(n: string) => Promise<{owner:string, repo:string, remoteUrl:string, host:string, issue:object}>} io.fetchIssueProvenance
315
+ * `host` is the GitHub host the call was made against (HC-GF-283), which the provenance check compares with html_url's
315
316
  * HC-GF-270: `gh api repos/<owner>/<repo>/issues/<n>`; rejects with gh's stderr on the error, and
316
317
  * `where: {owner, repo}` once the remote was parsed
317
318
  * @param {(id: string) => Promise<object>} io.fetchStoryContext GET /stories/:id/context; throws
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
@@ -18,6 +18,8 @@
18
18
  * `error` and decides the exit code.
19
19
  */
20
20
 
21
+ const path = require('node:path');
22
+
21
23
  const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
24
 
23
25
  // GET /orchestrate clamps `limit` to 1..100, lists newest first, and is scoped to the caller's org.
@@ -104,17 +106,55 @@ async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
104
106
  const isStepKey = (s) => typeof s === 'string' && /^step_[0-9a-z_]+$/i.test(s);
105
107
  const isAttempt = (s) => (typeof s === 'string' || typeof s === 'number') && /^[1-9]\d{0,8}$/.test(String(s));
106
108
  const isBatchId = isRunId;
109
+ // HC-GF-270-followup-2: POST /derive is the only producer of a derive job id (server/src/routes/derive.js),
110
+ // and it issues `drv_` + 12 lower-case hex characters into a TEXT key compared exactly, so no other shape
111
+ // can name a job. `hone approve --job` GETs and then POSTs under that id.
112
+ const isDeriveJobId = (s) => typeof s === 'string' && /^drv_[0-9a-f]{12}$/.test(s);
113
+ // A name that becomes ONE path segment, such as a repository name. Encoding makes any other character
114
+ // safe; these three it cannot, because `new URL()` resolves `.` and `..` as directory steps.
115
+ const isPathName = (s) => typeof s === 'string' && s !== '' && s !== '.' && s !== '..';
116
+
117
+ // HC-GF-270-followup-3: a story id names a directory (`.github/pipeline/<id>/`, `.github/metrics/<id>.yml`).
118
+ // The branch extractor (pipeline-status.js STORY_ID_PATTERN) emits only `[A-Za-z0-9_-]` but has no length cap,
119
+ // so an id read from a branch is checked too (step-5b, metrics collect, verify-pr); the fallbacks are
120
+ // `unknown-story` and `no-story`. No `.`, `/` or `\`, so `../` cannot leave the directory; 80 characters, the cap
121
+ // server/src/routes/stories.js applies (MAX_STORY_ID_LEN). Its STORY_ID_SHAPE is stricter (no `_`): it decides
122
+ // what the server stores, this decides what may name a local directory.
123
+ const isStoryId = (s) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(s);
124
+ // A ref git or gh may receive as ONE argument. `;`, `$(` and quotes are harmless there (and legal in ref names);
125
+ // a leading `-` is not: `git diff --name-only '--output=x...HEAD'` writes a file. `--end-of-options` would
126
+ // also stop it, but only on git 2.24 and later — on an older git every diff would fail and an audit would pass
127
+ // on nothing, so the value is refused instead.
128
+ const isGitRev = (s) => typeof s === 'string' && s !== '' && !s.startsWith('-') && !s.includes('\0');
107
129
 
108
130
  const KINDS = {
109
131
  runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
110
132
  batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
111
133
  stepKey: { ok: isStepKey, why: 'must name a step, like step_4', hint: '' },
112
134
  attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
135
+ deriveJobId: { ok: isDeriveJobId, why: 'must be a derive job ID, like drv_0123456789ab', hint: ' — the ID "hone derive --refresh" printed' },
136
+ pathName: { ok: isPathName, why: 'must be a name, not empty, "." or ".."', hint: '' },
137
+ storyId: { ok: isStoryId, why: 'must be a story ID, like HC-GF-270 (letters, digits, "-" and "_")', hint: '' },
138
+ gitRev: { ok: isGitRev, why: 'must be a git branch or revision, not empty and not starting with "-"', hint: '' },
139
+ count: { ok: isAttempt, why: 'must be a whole number from 1, like 15', hint: '' },
113
140
  };
114
141
 
142
+ /**
143
+ * HC-GF-270-followup-3: the directory a story id names under `baseDir`, refusing any id that is not one — or that
144
+ * resolves anywhere but one level under `baseDir`, checked independently of the pattern. Every verb that writes
145
+ * here refused a bad id first (`runArgsError`, kind `storyId`), so a throw here is a bug, not an input error.
146
+ */
147
+ function storyDir(baseDir, storyId) {
148
+ const base = path.resolve(String(baseDir));
149
+ if (!isStoryId(storyId)) throw new TypeError(`storyDir: ${JSON.stringify(storyId)} is not a story ID`);
150
+ const dir = path.resolve(base, storyId);
151
+ if (path.dirname(dir) !== base) throw new TypeError(`storyDir: ${JSON.stringify(storyId)} leaves ${base}`);
152
+ return dir;
153
+ }
154
+
115
155
  /**
116
156
  * 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
157
+ * @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName'|'storyId'|'gitRev'|'count', value: any, optional?: boolean }>} checks
118
158
  * `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
119
159
  * is undefined (a flag that was not given); an empty string is always checked.
120
160
  */
@@ -130,23 +170,29 @@ function runArgsError(checks) {
130
170
  }
131
171
 
132
172
  /**
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.
173
+ * The one way the CLI builds a path on the Hone API: `/<prefix>/<segment>/…?<query>`. `prefix` is a
174
+ * literal resource name (`derive`, `skills`, …), never a value. Each segment is encoded exactly once, and
175
+ * the segments encoding cannot make safe (empty, `.`, `..`, null, undefined) throw: every caller-supplied
176
+ * value has passed `runArgsError` first, so a throw here is a bug, not an input error. Query values that
177
+ * are null, undefined, false or '' are dropped; the rest go through URLSearchParams.
137
178
  *
138
179
  * 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.
180
+ * HC-GF-270-followup-2 scan fails on one.
181
+ *
182
+ * HC-GF-270-followup-2: generalized from orchestratePath. HC-GF-270-followup-1 closed the defect under
183
+ * `/orchestrate/`; `/derive/`, `/skills/`, `/prompts/`, `/scripts/` and `/stories/` were still built by
184
+ * hand, and `hone approve --job` POSTed under an id nothing checked.
140
185
  */
141
- function orchestratePath(segments, query) {
142
- if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
186
+ function apiPath(prefix, segments, query) {
187
+ 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)}`);
188
+ if (!Array.isArray(segments)) throw new TypeError('apiPath: segments must be an array');
143
189
  const parts = segments.map((v, i) => {
144
- if (v === null || v === undefined) throw new TypeError(`orchestratePath: segment ${i} is ${v}`);
190
+ if (v === null || v === undefined) throw new TypeError(`apiPath: segment ${i} is ${v}`);
145
191
  const s = String(v);
146
- if (s === '' || s === '.' || s === '..') throw new TypeError(`orchestratePath: segment ${i} is ${JSON.stringify(s)}`);
192
+ if (s === '' || s === '.' || s === '..') throw new TypeError(`apiPath: segment ${i} is ${JSON.stringify(s)}`);
147
193
  return encodeURIComponent(s);
148
194
  });
149
- const base = ['', 'orchestrate', ...parts].join('/');
195
+ const base = ['', prefix, ...parts].join('/');
150
196
  const qs = new URLSearchParams();
151
197
  for (const [k, v] of Object.entries(query || {})) {
152
198
  if (v === null || v === undefined || v === false || v === '') continue;
@@ -156,7 +202,14 @@ function orchestratePath(segments, query) {
156
202
  return q ? `${base}?${q}` : base;
157
203
  }
158
204
 
205
+ /** An `/orchestrate/…` path: `apiPath('orchestrate', …)` with at least one segment (HC-GF-270-followup-1). */
206
+ function orchestratePath(segments, query) {
207
+ if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
208
+ return apiPath('orchestrate', segments, query);
209
+ }
210
+
159
211
  module.exports = {
160
212
  resolveRunTarget, isRunId, LOOKUP_LIMIT,
161
- isStepKey, isAttempt, isBatchId, runArgsError, orchestratePath,
213
+ isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, isStoryId, isGitRev, storyDir,
214
+ runArgsError, apiPath, orchestratePath,
162
215
  };
@@ -53,11 +53,12 @@ function isIssueShapedId(storyId) {
53
53
  /**
54
54
  * Is this a HOSTNAME we may compare against `html_url`'s host?
55
55
  *
56
- * Binding decision 2 (operator, step_1 gate of run fa6e00c4): compare hosts ONLY when the
57
- * remote's host is a dotted domain — the GitHub Enterprise case. An SSH host alias such as
58
- * `github-work` from `~/.ssh/config` is not a hostname, and treating it as one would make
59
- * every collaborator's issue untrusted for every operator who uses one. That false
60
- * fail-closed is the version of this feature that gets reverted.
56
+ * HC-GF-270's binding decision 2 (operator, step_1 gate of run fa6e00c4) compared hosts ONLY when
57
+ * the remote's host was a dotted domain, because an SSH alias such as `github-work` is not a
58
+ * hostname and comparing it would refuse every collaborator's issue for every operator who uses
59
+ * one. HC-GF-283 removes the reason for the exception: run-story now resolves an alias with
60
+ * `ssh -G` (cli/lib/git-remote.js) and records the REAL host, so the comparison runs always, and a
61
+ * recorded host that is still not a hostname is a host nobody established — untrusted.
61
62
  */
62
63
  function isDottedDomain(host) {
63
64
  const h = String(host == null ? '' : host).trim();
@@ -237,13 +238,18 @@ function assessStorySource(source, storyId) {
237
238
  + 'or the request redirected to a different repository');
238
239
  }
239
240
 
240
- const remoteHost = source.remoteHost;
241
- if (isDottedDomain(remoteHost)) {
242
- const rh = String(remoteHost).trim().toLowerCase();
243
- if (rh !== url.hostname.toLowerCase()) {
244
- return no('issue', `the git remote's host ${rh} is not ${url.hostname.toLowerCase()} — the issue was read `
245
- + 'from a different GitHub host than this repository lives on, where someone else can own the same name');
246
- }
241
+ // HC-GF-283: ALWAYS. An unresolved alias left gh on its default host, where the same owner/repo
242
+ // name can belong to someone else — the case binding decision 2's exception let through.
243
+ const rh = String(source.remoteHost == null ? '' : source.remoteHost).trim().toLowerCase();
244
+ if (!isDottedDomain(rh)) {
245
+ return no('issue', rh
246
+ ? `the git remote's host was recorded as '${rh}', which is not a hostname — an SSH alias that was never `
247
+ + `resolved (a run started before HC-GF-283), so which GitHub host issue ${refLabel(source)} was read from cannot be checked`
248
+ : `no git remote host was recorded for issue ${refLabel(source)}, so which GitHub host it was read from cannot be checked`);
249
+ }
250
+ if (rh !== url.hostname.toLowerCase()) {
251
+ return no('issue', `the git remote's host ${rh} is not ${url.hostname.toLowerCase()} — the issue was read `
252
+ + 'from a different GitHub host than this repository lives on, where someone else can own the same name');
247
253
  }
248
254
 
249
255
  return yes('issue', `issue ${refLabel(source)} was authored by ${source.author || 'an unknown user'} `
@@ -258,6 +264,7 @@ function assessStorySource(source, storyId) {
258
264
  module.exports = {
259
265
  assessStorySource,
260
266
  remoteHostFromUrl,
267
+ rawRemoteHost,
261
268
  isIssueShapedId,
262
269
  isDottedDomain,
263
270
  refLabel,
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.1",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {