@hone-ai/cli 1.38.0 → 1.39.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/lib/mcp-tools.js CHANGED
@@ -124,10 +124,22 @@ async function listWorkflows({ status, limit, cwd } = {}) {
124
124
  // Right for a person at a terminal; wrong behind a tool whose contract is an explicit run ID, where a
125
125
  // story ID is an agent's guess at a target. It used to fail only by accident (the server 404'd it), so
126
126
  // the tools that ACT on a run refuse anything that is not a run ID before shelling out.
127
- const { isRunId } = require('./run-target');
127
+ const { isRunId, runArgsError } = require('./run-target');
128
128
  const notARunId = (runId) =>
129
129
  `runId must be a run ID (a UUID); ${JSON.stringify(String(runId))} is not. Get one from hone_run_story or hone_list_workflows.`;
130
130
 
131
+ // HC-GF-270-followup-3: the other tools that take a run id, step key or ref check it the same way, BEFORE the
132
+ // CLI is spawned. The CLI refuses them too, but with exit 2 — which checkPatch reported to the agent as
133
+ // "diff does not apply". Labels are the tool's own input names. Values are checked as the CLI receives them.
134
+ const refused = (checks) => {
135
+ const msg = runArgsError(checks);
136
+ return msg ? { isError: true, detail: msg } : null;
137
+ };
138
+ // An optional input that is absent, null or '' counts as not given: both tools that use this drop such a value
139
+ // before it reaches the CLI (`if (workflowId)`), so there is nothing to check. That is deliberate, unlike runArgsError on its own,
140
+ // which still checks a '' it is handed.
141
+ const asArg = (v) => (v === undefined || v === null || v === '' ? undefined : String(v));
142
+
131
143
  // hone_kill_workflow — kill ONE run by explicit id (DESTRUCTIVE, irreversible).
132
144
  // runId is schema-required: an agent must never guess a target for a kill —
133
145
  // explicit id or nothing (memo D3 refutation). Server 409s non-killable states.
@@ -150,6 +162,8 @@ async function killWorkflow({ runId, cwd } = {}) {
150
162
  // written to disk by any Hone code.
151
163
  async function stepInstruction({ runId, cwd } = {}) {
152
164
  if (!runId) return { isError: true, detail: 'runId is required' };
165
+ const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
166
+ if (bad) return bad;
153
167
  const r = await runHone(['step-instruction', String(runId), '--format', 'json'], { cwd });
154
168
  if (r.error) return { isError: true, detail: r.error };
155
169
  const data = parseLastJson(r.stdout);
@@ -185,6 +199,12 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
185
199
  if (!fromTree && !hasOutput) {
186
200
  return { isError: true, detail: 'output is required (the complete step output text), or set fromTree' };
187
201
  }
202
+ // Before the temp file: a refused id writes nothing.
203
+ const bad = refused([
204
+ { label: 'runId', kind: 'runId', value: String(runId) },
205
+ { label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
206
+ ]);
207
+ if (bad) return bad;
188
208
 
189
209
  const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
190
210
  let tmp = null;
@@ -232,6 +252,8 @@ async function status({ runId, cwd } = {}) {
232
252
  // execution, in the CLI), optionally report the verdict. Long-running → wide timeout.
233
253
  async function verifyPatch({ runId, report = false, cwd } = {}) {
234
254
  if (!runId) return { isError: true, detail: 'runId is required' };
255
+ const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
256
+ if (bad) return bad;
235
257
  const args = ['verify-patch', String(runId), '--format', 'json'];
236
258
  if (report) args.push('--report');
237
259
  const r = await runHone(args, { cwd, timeoutMs: 600000 });
@@ -246,6 +268,11 @@ async function verifyPatch({ runId, report = false, cwd } = {}) {
246
268
  // hone_check_patch — does step_4's diff apply cleanly? (throwaway git apply --check)
247
269
  async function checkPatch({ runId, stepKey = 'step_4', cwd } = {}) {
248
270
  if (!runId) return { isError: true, detail: 'runId is required' };
271
+ const bad = refused([
272
+ { label: 'runId', kind: 'runId', value: String(runId) },
273
+ { label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
274
+ ]);
275
+ if (bad) return bad;
249
276
  const r = await runHone(['check-patch', String(runId), '--step-key', String(stepKey), '--format', 'json'], { cwd });
250
277
  if (r.error) return { isError: true, detail: r.error };
251
278
  const data = parseLastJson(r.stdout);
@@ -255,6 +282,8 @@ async function checkPatch({ runId, stepKey = 'step_4', cwd } = {}) {
255
282
  // hone_emit_pr — verified diff → branch → draft PR (staged: --push, --open-pr).
256
283
  async function emitPr({ runId, push = false, openPr = false, branch, base, cwd } = {}) {
257
284
  if (!runId) return { isError: true, detail: 'runId is required' };
285
+ const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
286
+ if (bad) return bad;
258
287
  const args = ['emit-pr', String(runId), '--format', 'json'];
259
288
  if (openPr) args.push('--open-pr'); else if (push) args.push('--push');
260
289
  if (branch) args.push('--branch', String(branch));
@@ -268,6 +297,11 @@ async function emitPr({ runId, push = false, openPr = false, branch, base, cwd }
268
297
  // hone_verify_pr — real skill audit + real CI gate on an open PR, optional report.
269
298
  async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
270
299
  if (!pr) return { isError: true, detail: 'pr (number or branch) is required' };
300
+ const bad = refused([
301
+ { label: 'pr', kind: 'gitRev', value: String(pr) },
302
+ { label: 'workflowId', kind: 'runId', value: asArg(workflowId), optional: true },
303
+ ]);
304
+ if (bad) return bad;
271
305
  const args = ['verify-pr', String(pr), '--format', 'json'];
272
306
  if (report) { args.push('--report'); if (workflowId) args.push('--workflow-id', String(workflowId)); }
273
307
  const r = await runHone(args, { cwd, timeoutMs: 600000 });
@@ -280,6 +314,8 @@ async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
280
314
 
281
315
  // hone_agent_eval — test the adopter's own agents (HC-COMM-011), optional report.
282
316
  async function agentEval({ category, target, judge = false, provider, report = false, workflowId, cwd } = {}) {
317
+ const bad = refused([{ label: 'workflowId', kind: 'runId', value: asArg(workflowId), optional: true }]);
318
+ if (bad) return bad;
283
319
  const args = ['agent-eval', '--output', 'json'];
284
320
  if (category) args.push('--category', String(category));
285
321
  if (target) args.push('--target', String(target));
@@ -296,6 +332,11 @@ async function agentEval({ category, target, judge = false, provider, report = f
296
332
  // hone_show_step — the LLM output of an orchestrator step (readOnly).
297
333
  async function showStep({ runId, stepKey, cwd } = {}) {
298
334
  if (!runId || !stepKey) return { isError: true, detail: 'runId and stepKey are required' };
335
+ const bad = refused([
336
+ { label: 'runId', kind: 'runId', value: String(runId) },
337
+ { label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
338
+ ]);
339
+ if (bad) return bad;
299
340
  const r = await runHone(['show', 'step', String(runId), String(stepKey)], { cwd });
300
341
  if (r.error) return { isError: true, detail: r.error };
301
342
  return { isError: !r.ok, exitCode: r.exitCode, output: r.stdout.slice(0, 8000), detail: r.ok ? 'ok' : (r.stderr || 'show step failed').slice(0, 500) };
@@ -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
@@ -437,6 +438,10 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
437
438
  // JSON-lines repo).
438
439
  const bundle = { files: 0, chars: 0 };
439
440
  const includePaths = opts.includePaths || [];
441
+ // HC-GF-351: what the operator named, recorded on the run so follow-batch can carry it into a re-serve. Never
442
+ // inferred: the story's own paths and the defaults are re-derived whenever a story is resolved again.
443
+ const operatorPaths = parseIncludePaths(includePaths);
444
+ if (operatorPaths.length) config.include_paths = operatorPaths;
440
445
  const defaultIncludePaths = repoCtx.defaultIncludePaths || [];
441
446
  if (rawText || parseIncludePaths(includePaths).length > 0 || defaultIncludePaths.length > 0) {
442
447
  try {
@@ -575,6 +580,23 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
575
580
  pre.push(warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js'));
576
581
  }
577
582
 
583
+ // ── Call sites (HC-GF-217) ────────────────────────────────────────────────────────────────
584
+ // The planner is told to enumerate every call site of a shared function it changes, and a server-executed
585
+ // agent has no checkout to search (cwd is a temp dir, deliberately). This CLI has the checkout: it searches for
586
+ // the compound identifiers the story puts in backticks and sends the result for the server to render. It runs
587
+ // whatever the classifier axes turn out to be, because the shared-chokepoint axis is harvested from step_0's
588
+ // output, after this bundle has been sent. Fail-soft: a search that cannot run costs the planner its
589
+ // enumeration (it then says so and pauses), never the queueing of the story.
590
+ if (rawText && typeof io.callSites === 'function') {
591
+ try {
592
+ const found = await io.callSites(rawText);
593
+ if (found && found.callSites) config.call_sites = found.callSites;
594
+ for (const n of (found && found.notes) || []) pre.push(n);
595
+ } catch (e) {
596
+ pre.push(warn(` ⚠ call-site search failed (non-fatal): ${firstLine(e)} — the planner will have no call-site enumeration for this story`));
597
+ }
598
+ }
599
+
578
600
  // ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
579
601
  // Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
580
602
  // come from the groomer at step_0 and are harvested server-side. Fail-open.
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.
@@ -112,6 +114,19 @@ const isDeriveJobId = (s) => typeof s === 'string' && /^drv_[0-9a-f]{12}$/.test(
112
114
  // safe; these three it cannot, because `new URL()` resolves `.` and `..` as directory steps.
113
115
  const isPathName = (s) => typeof s === 'string' && s !== '' && s !== '.' && s !== '..';
114
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');
129
+
115
130
  const KINDS = {
116
131
  runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
117
132
  batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
@@ -119,11 +134,27 @@ const KINDS = {
119
134
  attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
120
135
  deriveJobId: { ok: isDeriveJobId, why: 'must be a derive job ID, like drv_0123456789ab', hint: ' — the ID "hone derive --refresh" printed' },
121
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: '' },
122
140
  };
123
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
+
124
155
  /**
125
156
  * The first failed check's message, or null when every check passes.
126
- * @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName', 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
127
158
  * `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
128
159
  * is undefined (a flag that was not given); an empty string is always checked.
129
160
  */
@@ -179,5 +210,6 @@ function orchestratePath(segments, query) {
179
210
 
180
211
  module.exports = {
181
212
  resolveRunTarget, isRunId, LOOKUP_LIMIT,
182
- isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, runArgsError, apiPath, orchestratePath,
213
+ isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, isStoryId, isGitRev, storyDir,
214
+ runArgsError, apiPath, orchestratePath,
183
215
  };
package/lib/story-line.js CHANGED
@@ -2,9 +2,15 @@
2
2
  /**
3
3
  * story-line.js — one line of a `hone queue-stories --file` list.
4
4
  *
5
- * `STORY-ID` or `STORY-ID depends:DEP1,DEP2`. The `depends:` token is case-sensitive and must come
6
- * after the storyId; multiple deps are comma-separated and whitespace tolerant. A line with no
7
- * `depends:` yields no `dependsOn` at all — the server validates absence differently from empty.
5
+ * `STORY-ID`, optionally followed by `depends:DEP1,DEP2` and/or `paths:a.js,b.js`, in either order. Both
6
+ * tokens are case-sensitive, must come after the storyId, and may each appear once; their lists are
7
+ * comma-separated and whitespace tolerant. A line with no `depends:` yields no `dependsOn` at all — the server
8
+ * validates absence differently from empty — and a line with no `paths:` yields no `paths`.
9
+ *
10
+ * `paths:` (HC-GF-351) is `run-story --include-paths` for one story of a batch: the files the bundler must put
11
+ * in that story's context. A batch cannot carry the flag, and `run-story` runs carry no batch id, so
12
+ * `follow-batch` cannot follow them. The entries are checked where `--include-paths` entries are, by the
13
+ * bundler (cli/lib/run-context.js), not here: this module only splits the line.
8
14
  *
9
15
  * Extracted by HC-GF-140 stage 2. The regex was pinned by its SOURCE TEXT
10
16
  * (`assert.match(cliSrc, /\^\(\\S\+\)\\s\+depends:\(\\S\+\)\\s\*\$/)`), which is the worst shape a
@@ -19,22 +25,52 @@ function isStoryLine(line) {
19
25
  return Boolean(t) && !t.startsWith('#');
20
26
  }
21
27
 
28
+ const csv = (s) => s.split(',').map((x) => x.trim()).filter(Boolean);
29
+ const unique = (list) => list.filter((x, i) => list.indexOf(x) === i);
30
+
22
31
  /**
23
32
  * @param {string} line
24
- * @returns {{storyId: string, dependsOn?: string[]}|{error: string}}
33
+ * @returns {{storyId: string, dependsOn?: string[], paths?: string[]}|{error: string}}
25
34
  * `error` for a line the caller must reject — a storyId followed by anything unrecognised.
26
- * Silently treating `STORY-A something` as `STORY-A` loses the operator's intent.
35
+ * Silently treating `STORY-A something` as `STORY-A` loses the operator's intent. So does a
36
+ * `paths:` that names no path: the operator meant to bundle something.
27
37
  */
28
38
  function parseStoryLine(line) {
29
39
  const t = String(line).trim();
30
- const m = t.match(/^(\S+)\s+depends:(\S+)\s*$/);
31
- if (m) {
32
- return { storyId: m[1], dependsOn: m[2].split(',').map(s => s.trim()).filter(Boolean) };
40
+ const bad = { error: `Malformed line in --file: "${t}"` };
41
+ const [storyId, ...tokens] = t.split(/\s+/);
42
+ const out = { storyId };
43
+ for (const tok of tokens) {
44
+ if (tok.startsWith('depends:') && tok.length > 'depends:'.length && !('dependsOn' in out)) {
45
+ out.dependsOn = csv(tok.slice('depends:'.length));
46
+ } else if (tok.startsWith('paths:') && !('paths' in out)) {
47
+ const paths = unique(csv(tok.slice('paths:'.length)));
48
+ if (paths.length === 0) return bad;
49
+ out.paths = paths;
50
+ } else {
51
+ return bad;
52
+ }
33
53
  }
34
- if (/\s/.test(t)) {
35
- return { error: `Malformed line in --file: "${t}"` };
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * The line `parseStoryLine` reads back as the same story — for a caller that writes a stories file
59
+ * (follow-batch's re-serve). Throws on a value the line cannot carry, rather than writing a line that parses as
60
+ * something else: whitespace splits a token, and a comma splits a list.
61
+ * @param {{storyId: string, dependsOn?: string[], paths?: string[]}} story
62
+ */
63
+ function formatStoryLine({ storyId, dependsOn, paths } = {}) {
64
+ const carries = (v) => typeof v === 'string' && v !== '' && !/[\s,]/.test(v);
65
+ if (!carries(storyId)) throw new TypeError(`formatStoryLine: ${JSON.stringify(storyId)} is not a story id a line can carry`);
66
+ const parts = [storyId];
67
+ for (const [token, list] of [['depends', dependsOn], ['paths', paths]]) {
68
+ if (!Array.isArray(list) || list.length === 0) continue;
69
+ const bad = list.find((v) => !carries(v));
70
+ if (bad !== undefined) throw new TypeError(`formatStoryLine: ${JSON.stringify(bad)} cannot be written in ${token}:`);
71
+ parts.push(`${token}:${list.join(',')}`);
36
72
  }
37
- return { storyId: t };
73
+ return parts.join(' ');
38
74
  }
39
75
 
40
- module.exports = { parseStoryLine, isStoryLine };
76
+ module.exports = { parseStoryLine, isStoryLine, formatStoryLine };
@@ -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.38.0",
3
+ "version": "1.39.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {