@hone-ai/cli 1.35.0 → 1.35.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.
package/hone-cli.js CHANGED
@@ -39,6 +39,7 @@ 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
43
  const program = new Command();
43
44
 
44
45
  // ── Config resolution ─────────────────────────────────────────────────────────
@@ -68,6 +69,31 @@ function requireAdminKey() {
68
69
  return adminKey;
69
70
  }
70
71
 
72
+ /**
73
+ * HC-GF-270-followup-1: refuse a caller-supplied run id, batch id, step key or attempt that is not one,
74
+ * with exit 2. Called as the FIRST statement of a verb's action, before getConfig() or api(), so a
75
+ * refusal makes no request, reads no ~/.honerc, and runs no probe, gh call or CI command.
76
+ */
77
+ function refuseBadRunArgs(checks) {
78
+ const msg = runArgsError(checks);
79
+ if (msg) {
80
+ console.error(msg);
81
+ process.exit(2);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The arguments check-patch, verify-patch and emit-pr turn into a step-output URL. HC-GF-270 (second
87
+ * review MEDIUM-1): the step key goes into a URL and a file name, and URL resolution collapses `..` —
88
+ * `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's patch while THIS run's config was
89
+ * assessed. A step key is a step key, a run id is a run id, and an attempt is a whole number.
90
+ */
91
+ const stepFetchChecks = (workflowId, opts) => [
92
+ { label: '<workflowId>', kind: 'runId', value: workflowId },
93
+ { label: '--step-key', kind: 'stepKey', value: opts.stepKey },
94
+ { label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
95
+ ];
96
+
71
97
  function getConfig() {
72
98
  // Priority: env var → ~/.honerc → error
73
99
  const rc = readRc();
@@ -188,6 +214,8 @@ program
188
214
  .option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
189
215
  .option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
190
216
  .action(async (opts) => {
217
+ // HC-GF-270-followup-1: a --workflow-id is checked before any probe spends provider quota.
218
+ refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
191
219
  const { readAgentEvalConfig } = require('./lib/pipeline-config');
192
220
  const { runAgent } = require('./lib/agent-executor');
193
221
  const { runCheck } = require('./lib/eval-graders');
@@ -343,7 +371,7 @@ program
343
371
  } else {
344
372
  try {
345
373
  const client = api(getConfig());
346
- await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
374
+ await client.post(orchestratePath([opts.workflowId, 'verdict']), {
347
375
  source: 'agent-eval',
348
376
  stepKey: 'step_4',
349
377
  verdict: anyFailed ? 'fail' : 'pass',
@@ -3060,23 +3088,25 @@ program
3060
3088
  console.log('No .github/learnings/ directory found — nothing to retract.');
3061
3089
  return;
3062
3090
  }
3063
- const yaml = require('js-yaml');
3091
+ // HC-GF-311-followup-1: per learning, in every schema `hone promote` reads — not just the file's top level.
3092
+ const { parseRetracted } = require('./lib/learnings-parse');
3064
3093
  const files = fs.readdirSync(learningsDir).filter(f => f.endsWith('.yml'));
3065
3094
  for (const file of files) {
3066
- try {
3067
- const parsed = yaml.load(fs.readFileSync(path.join(learningsDir, file), 'utf8'));
3068
- const entries = Array.isArray(parsed) ? parsed : [parsed];
3069
- for (const entry of entries) {
3070
- if (entry?.status === 'retracted' && entry.id) {
3071
- payload.push({
3072
- id: entry.id,
3073
- repo_name: repoName,
3074
- reason: entry.retraction_reason || 'Retracted (no reason provided in YAML)',
3075
- });
3076
- }
3077
- }
3078
- } catch (e) {
3079
- console.warn(` ⚠ ${file}: parse error — ${e.message}`);
3095
+ // One unreadable entry (a directory or a dangling link named *.yml) must not hide the rest.
3096
+ let content;
3097
+ try { content = fs.readFileSync(path.join(learningsDir, file), 'utf8'); }
3098
+ catch (e) { console.warn(` ⚠ ${file}: read error — ${e.message}`); continue; }
3099
+ const { retracted, parseError } = parseRetracted(content);
3100
+ if (parseError) {
3101
+ console.warn(` ⚠ ${file}: parse error — ${parseError.message}`);
3102
+ continue;
3103
+ }
3104
+ for (const { id, reason } of retracted) {
3105
+ payload.push({
3106
+ id,
3107
+ repo_name: repoName,
3108
+ reason: reason || 'Retracted (no reason provided in YAML)',
3109
+ });
3080
3110
  }
3081
3111
  }
3082
3112
  if (payload.length === 0) {
@@ -4955,10 +4985,19 @@ program
4955
4985
  }
4956
4986
  // Path B: GitHub issue fetch via gh
4957
4987
  else if (issueId) {
4988
+ // HC-GF-270-followup-1: an issue number or an issue URL, which are the forms gh accepts, and
4989
+ // nothing else. gh reads an argument that begins with `-` as a flag even without a shell.
4990
+ if (!/^[1-9]\d*$/.test(issueId) && !/^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/issues\/[1-9]\d*$/.test(issueId)) {
4991
+ console.error(`error: <issueId> must be an issue number like 142, or an issue URL (got ${JSON.stringify(issueId)})`);
4992
+ console.error('usage: hone classify-story 142');
4993
+ console.error(' hone classify-story --input-file inputs.yml');
4994
+ process.exit(2);
4995
+ }
4958
4996
  try {
4959
- const out = execSync(
4960
- `gh issue view ${issueId} --json title,body,labels,url`,
4961
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
4997
+ // An argument list, not a shell string, and a bound: a hung gh must not hang the command.
4998
+ const out = require('child_process').execFileSync(
4999
+ 'gh', ['issue', 'view', String(issueId), '--json', 'title,body,labels,url'],
5000
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }
4962
5001
  );
4963
5002
  issueMeta = JSON.parse(out);
4964
5003
  const labels = (issueMeta.labels || []).map(l => l.name);
@@ -4980,6 +5019,10 @@ program
4980
5019
  is_first_of_its_kind: false,
4981
5020
  };
4982
5021
  } catch (e) {
5022
+ if (e && e.code === 'ETIMEDOUT') {
5023
+ console.error(`error: gh issue view ${issueId} timed out after 30s`);
5024
+ process.exit(2);
5025
+ }
4983
5026
  console.error(`error: gh issue view ${issueId} failed: ${(e.stderr || '').toString().trim() || e.message}`);
4984
5027
  console.error(`hint: install GitHub CLI (cli.github.com), run \`gh auth login\`, or pass --input-file <path>`);
4985
5028
  process.exit(2);
@@ -5924,7 +5967,7 @@ program
5924
5967
  try {
5925
5968
  // HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
5926
5969
  // knows, so an older CLI — which sends nothing — keeps being served contract 1.
5927
- const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction?contracts=1,2`);
5970
+ const { data } = await client.get(orchestratePath([runId, 'step-instruction'], { contracts: '1,2' }));
5928
5971
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
5929
5972
  console.log(`Step: ${data.stepKey} (${data.agent})`);
5930
5973
  console.log(`Contract: ${data.contractVersion}`);
@@ -5976,7 +6019,7 @@ program
5976
6019
  output = fs.readFileSync(opts.file, 'utf8');
5977
6020
  }
5978
6021
  const { data } = await client.post(
5979
- `/orchestrate/${encodeURIComponent(runId)}/step/${encodeURIComponent(stepKey)}/editor-output`,
6022
+ orchestratePath([runId, 'step', stepKey, 'editor-output']),
5980
6023
  { contractVersion: opts.contract, output }
5981
6024
  );
5982
6025
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
@@ -6182,7 +6225,7 @@ program
6182
6225
 
6183
6226
  let status;
6184
6227
  try {
6185
- const { data: pollData } = await client.get(`/orchestrate/${pollRunId}`);
6228
+ const { data: pollData } = await client.get(orchestratePath([pollRunId]));
6186
6229
  status = pollData;
6187
6230
  } catch (e) {
6188
6231
  console.error(`Poll error: ${e.message}`);
@@ -6250,7 +6293,8 @@ program
6250
6293
  console.log(` ${remedy.stepKey} named the files it needs. Re-queue with:`);
6251
6294
  console.log(` ${cmd}`);
6252
6295
  if (remedy.truncated) {
6253
- console.log(` ⚠ it asked for more than ${remedy.paths.length} files; the rest were dropped.`);
6296
+ // HC-GF-263-followup-1: the cap counts entries, and one file may have several.
6297
+ console.log(` ⚠ it asked for more than ${remedy.paths.length} paths; the rest were dropped.`);
6254
6298
  }
6255
6299
  }
6256
6300
  // H5: say how many requested paths were withheld from the command, never what they were.
@@ -6352,14 +6396,14 @@ program
6352
6396
 
6353
6397
  if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
6354
6398
  try {
6355
- await client.post(`/orchestrate/${pollRunId}/approve`, { stepKey: d.stepKey });
6399
+ await client.post(orchestratePath([pollRunId, 'approve']), { stepKey: d.stepKey });
6356
6400
  console.log(' Approved — workflow resuming...');
6357
6401
  } catch (e) {
6358
6402
  console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
6359
6403
  }
6360
6404
  } else if (answer.toLowerCase() === 'kill') {
6361
6405
  try {
6362
- await client.post(`/orchestrate/${pollRunId}/kill`, { reason: 'CLI kill' });
6406
+ await client.post(orchestratePath([pollRunId, 'kill']), { reason: 'CLI kill' });
6363
6407
  console.log(' Workflow killed.');
6364
6408
  process.exit(0);
6365
6409
  } catch (e) {
@@ -6388,7 +6432,7 @@ program
6388
6432
  if (opts.status) {
6389
6433
  try {
6390
6434
  const statusId = await existingRunId('--status');
6391
- const { data } = await client.get(`/orchestrate/${statusId}`);
6435
+ const { data } = await client.get(orchestratePath([statusId]));
6392
6436
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6393
6437
  console.log('');
6394
6438
  console.log(`Workflow: ${data.runId}`);
@@ -6438,7 +6482,7 @@ program
6438
6482
  if (opts.kill) {
6439
6483
  try {
6440
6484
  const killId = await existingRunId('--kill');
6441
- const { data } = await client.post(`/orchestrate/${killId}/kill`, { reason: 'CLI kill' });
6485
+ const { data } = await client.post(orchestratePath([killId, 'kill']), { reason: 'CLI kill' });
6442
6486
  console.log(`Workflow ${data.runId} killed.`);
6443
6487
  } catch (e) {
6444
6488
  console.error(`Failed: ${e.response?.data?.error || e.message}`);
@@ -6452,7 +6496,7 @@ program
6452
6496
  let approveId;
6453
6497
  try {
6454
6498
  approveId = await existingRunId(`--approve ${opts.approve}`);
6455
- await client.post(`/orchestrate/${approveId}/approve`,
6499
+ await client.post(orchestratePath([approveId, 'approve']),
6456
6500
  { stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
6457
6501
  acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
6458
6502
  console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
@@ -6606,6 +6650,10 @@ program
6606
6650
  // auto-approve on output text, which never ran. Approve overnight on evidence with `hone follow-batch`.
6607
6651
  .option('--overnight', 'Enable Night Shift mode (longer timeouts, max 25 stories; step_4/step_5 still pause for approval)', false)
6608
6652
  .action(async (opts) => {
6653
+ refuseBadRunArgs([ // HC-GF-270-followup-1
6654
+ { label: '--status', kind: 'batchId', value: opts.status, optional: true },
6655
+ { label: '--report', kind: 'batchId', value: opts.report, optional: true },
6656
+ ]);
6609
6657
  const config = getConfig();
6610
6658
  const client = api(config);
6611
6659
 
@@ -6650,7 +6698,7 @@ program
6650
6698
  try {
6651
6699
  const format = opts.format === 'json' ? 'json' : 'md';
6652
6700
  const { data, headers } = await client.get(
6653
- `/orchestrate/batch/${opts.report}/report`,
6701
+ orchestratePath(['batch', opts.report, 'report']),
6654
6702
  { params: { format }, responseType: 'text', transformResponse: [(d) => d] }
6655
6703
  );
6656
6704
  if (format === 'json') {
@@ -6701,7 +6749,7 @@ program
6701
6749
  // ── --status <batchId> ────────────────────────────────────
6702
6750
  if (opts.status) {
6703
6751
  try {
6704
- const { data } = await client.get(`/orchestrate/batch/${opts.status}`);
6752
+ const { data } = await client.get(orchestratePath(['batch', opts.status]));
6705
6753
  if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
6706
6754
  console.log('');
6707
6755
  console.log(`Batch: ${data.batchId}`);
@@ -7561,13 +7609,15 @@ showCmd
7561
7609
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7562
7610
  .option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
7563
7611
  .action(async (workflowId, stepKey, opts) => {
7612
+ refuseBadRunArgs([ // HC-GF-270-followup-1
7613
+ { label: '<workflowId>', kind: 'runId', value: workflowId },
7614
+ { label: '<stepKey>', kind: 'stepKey', value: stepKey },
7615
+ { label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
7616
+ ]);
7564
7617
  const config = getConfig();
7565
7618
  const client = api(config);
7566
7619
  try {
7567
- const q = [];
7568
- if (opts.attempt) q.push(`attempt=${opts.attempt}`);
7569
- if (opts.prompt) q.push('prompt=1');
7570
- const url = `/orchestrate/${workflowId}/step/${stepKey}${q.length ? `?${q.join('&')}` : ''}`;
7620
+ const url = orchestratePath([workflowId, 'step', stepKey], { attempt: opts.attempt, prompt: opts.prompt ? '1' : undefined });
7571
7621
  const r = await client.get(url);
7572
7622
  const s = r.data;
7573
7623
  console.log(`── step output ──────────────────────────────────────────`);
@@ -7624,6 +7674,7 @@ program
7624
7674
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7625
7675
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7626
7676
  .action(async (workflowId, opts) => {
7677
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270-followup-1
7627
7678
  const { execFileSync } = require('child_process');
7628
7679
  const os = require('os');
7629
7680
  const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
@@ -7639,8 +7690,7 @@ program
7639
7690
 
7640
7691
  let output;
7641
7692
  try {
7642
- const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
7643
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
7693
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7644
7694
  const r = await client.get(url);
7645
7695
  output = r.data?.output;
7646
7696
  } catch (e) {
@@ -7764,6 +7814,7 @@ program
7764
7814
  .option('--trust-source', 'HC-GF-270: run a patch whose story text came from a source Hone does not trust (you have read the diff)')
7765
7815
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
7766
7816
  .action(async (workflowId, opts) => {
7817
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
7767
7818
  const { execFileSync, spawnSync } = require('child_process');
7768
7819
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
7769
7820
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -7782,13 +7833,6 @@ program
7782
7833
  const { gitEnv } = require('./lib/git-env');
7783
7834
  const config = getConfig();
7784
7835
  const client = api(config);
7785
- // HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
7786
- // resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
7787
- // patch while THIS run's config was assessed. A step key is a step key.
7788
- if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
7789
- console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
7790
- process.exit(2);
7791
- }
7792
7836
  const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
7793
7837
 
7794
7838
  const emit = (obj, humanLine) => {
@@ -7806,8 +7850,7 @@ program
7806
7850
  let runStoryId = null;
7807
7851
  let runFetchFailed = false;
7808
7852
  try {
7809
- const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
7810
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
7853
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
7811
7854
  const r = await client.get(url);
7812
7855
  // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
7813
7856
  if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
@@ -7817,7 +7860,7 @@ program
7817
7860
  verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7818
7861
  // best-effort: pull the run's ci_local_command for the default
7819
7862
  try {
7820
- const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
7863
+ const runR = await client.get(orchestratePath([workflowId]));
7821
7864
  // HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
7822
7865
  // `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
7823
7866
  // 200 with no config. And a body that is not THIS run is not this run's config.
@@ -8234,7 +8277,7 @@ program
8234
8277
  // change the local exit code (the verdict is already true locally).
8235
8278
  if (opts.report && reportVerdict) {
8236
8279
  try {
8237
- await client.post(`/orchestrate/${encodeURIComponent(workflowId)}/verdict`, {
8280
+ await client.post(orchestratePath([workflowId, 'verdict']), {
8238
8281
  source: 'verify-patch',
8239
8282
  stepKey: opts.stepKey,
8240
8283
  verdict: reportVerdict,
@@ -8268,11 +8311,12 @@ program
8268
8311
  .description('What happened on a run, and what still needs you')
8269
8312
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
8270
8313
  .action(async (runId, opts) => {
8314
+ refuseBadRunArgs([{ label: '<runId>', kind: 'runId', value: runId }]); // HC-GF-270-followup-1
8271
8315
  const { buildReport, renderReport } = require('./lib/run-report');
8272
8316
  const config = getConfig();
8273
8317
  const client = api(config);
8274
8318
  try {
8275
- const { data } = await client.get(`/orchestrate/${runId}`);
8319
+ const { data } = await client.get(orchestratePath([runId]));
8276
8320
  const report = buildReport(data);
8277
8321
  if (opts.format === 'json') console.log(JSON.stringify(report, null, 2));
8278
8322
  else console.log(renderReport(report));
@@ -8334,8 +8378,8 @@ program
8334
8378
  const client = api(config);
8335
8379
  const repoName = opts.repo || path.basename(process.cwd());
8336
8380
  const io = {
8337
- fetchBatch: async (id) => (await client.get(`/orchestrate/batch/${encodeURIComponent(id)}`)).data,
8338
- fetchRun: async (id) => (await client.get(`/orchestrate/${encodeURIComponent(id)}`)).data,
8381
+ fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
8382
+ fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
8339
8383
  runVerb: (argv) => new Promise((resolve) => {
8340
8384
  const child = spawn(process.execPath, [process.argv[1], ...argv],
8341
8385
  { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
@@ -8389,6 +8433,7 @@ program
8389
8433
  .option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
8390
8434
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
8391
8435
  .action(async (workflowId, opts) => {
8436
+ refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
8392
8437
  const { execFileSync, spawnSync } = require('child_process');
8393
8438
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8394
8439
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -8411,13 +8456,6 @@ program
8411
8456
  const { gitEnv } = require('./lib/git-env');
8412
8457
  const config = getConfig();
8413
8458
  const client = api(config);
8414
- // HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
8415
- // resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
8416
- // patch while THIS run's config was assessed. A step key is a step key.
8417
- if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
8418
- console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
8419
- process.exit(2);
8420
- }
8421
8459
  const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
8422
8460
 
8423
8461
  // --open-pr implies --push (cannot open a PR against an unpushed branch).
@@ -8440,14 +8478,13 @@ program
8440
8478
  // never arrived.
8441
8479
  let runStoryId = null;
8442
8480
  try {
8443
- const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
8444
- (opts.attempt ? `?attempt=${opts.attempt}` : '');
8481
+ const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
8445
8482
  const r = await client.get(url);
8446
8483
  // The step output must be THIS run's: nothing else ties the patch to the config assessed below.
8447
8484
  if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
8448
8485
  output = r.data?.output;
8449
8486
  try {
8450
- const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
8487
+ const runR = await client.get(orchestratePath([workflowId]));
8451
8488
  // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
8452
8489
  // is not THIS run does not count as its config.
8453
8490
  if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
@@ -8766,6 +8803,8 @@ program
8766
8803
  .option('--workflow-id <id>', 'the run id to report the verdict against (required with --report)')
8767
8804
  .option('--format <fmt>', 'pretty or json', 'pretty')
8768
8805
  .action(async (prOrBranch, opts) => {
8806
+ // HC-GF-270-followup-1: a --workflow-id is checked before any gh call or CI command runs.
8807
+ refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
8769
8808
  const { execFileSync, spawnSync } = require('child_process');
8770
8809
  // HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
8771
8810
  // a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
@@ -8867,7 +8906,7 @@ program
8867
8906
  } else {
8868
8907
  try {
8869
8908
  const client = api(getConfig());
8870
- await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
8909
+ await client.post(orchestratePath([opts.workflowId, 'verdict']), {
8871
8910
  source: 'verify-pr',
8872
8911
  stepKey: 'step_5c',
8873
8912
  verdict: verdict.verdict,
@@ -284,11 +284,55 @@ function parseSchemaD(doc, includePromoted = false) {
284
284
  }];
285
285
  }
286
286
 
287
+ // ─────────────────────────────────────────────────────────────────────
288
+ // parseRetracted — the entries `hone retract --from-yaml` sends
289
+ //
290
+ // HC-GF-311-followup-1: the retract action read only each file's TOP level, so a Schema C
291
+ // file offered its story frame, which has no `id`, and every per-learning
292
+ // `status: retracted` beneath it was skipped. Measured 2026-09-26 on
293
+ // origin/main: 14 retracted learnings in 12 files, and the dry-run found 0.
294
+ // Each id below is the one the matching parseSchemaX gives `hone promote`,
295
+ // because the server can only retract a row under the id it was promoted as.
296
+ // The id stays the value YAML loaded, not String(id): both commands send it
297
+ // through JSON, so `id: 2026-09-26` (a Date) arrives as the same ISO string.
298
+ // An entry with no id is skipped; promote sent those under the file name.
299
+ // A learning must say `retracted` itself: a story-level status is not
300
+ // inherited here, because a retraction is a claim about one learning.
301
+ // Returns { schema, retracted: [{ id, reason }], parseError? }; reason is
302
+ // null when the entry gives no `retraction_reason`.
303
+ // ─────────────────────────────────────────────────────────────────────
304
+ function parseRetracted(content) {
305
+ let parsed;
306
+ try { parsed = yaml.load(content); }
307
+ catch (e) { return { schema: SCHEMA_UNKNOWN, retracted: [], parseError: e }; }
308
+
309
+ const schema = detectSchema(parsed);
310
+ const isRetracted = (e) => e && typeof e === 'object' && e.status === 'retracted';
311
+ const entry = (id, e) => ({ id, reason: e.retraction_reason || null });
312
+
313
+ let retracted = [];
314
+ if (schema === SCHEMA_A) {
315
+ retracted = parsed.filter(i => isRetracted(i) && i.id).map(i => entry(i.id, i));
316
+ } else if (schema === SCHEMA_B) {
317
+ retracted = parsed.enterprise_candidates
318
+ .filter(c => isRetracted(c) && c.id !== null && c.id !== undefined)
319
+ .map(c => entry(`${parsed.story_id}-${c.id}`, c));
320
+ } else if (schema === SCHEMA_C) {
321
+ retracted = parsed.learnings.filter(l => isRetracted(l) && l.id).map(l => entry(l.id, l));
322
+ } else if (schema === SCHEMA_D) {
323
+ if (isRetracted(parsed) && (parsed.id || parsed.story_id)) retracted = [entry(parsed.id || parsed.story_id, parsed)];
324
+ } else if (isRetracted(parsed) && parsed.id) {
325
+ // Any other single dict keeps the top-level read the action always did.
326
+ retracted = [entry(parsed.id, parsed)];
327
+ }
328
+ return { schema, retracted };
329
+ }
330
+
287
331
  // ─────────────────────────────────────────────────────────────────────
288
332
  // writeBackPromoted — schema-aware idempotent regex rewrite
289
333
  //
290
334
  // All 3 schemas write per-item `status: promoted` because `hone retract`
291
- // at cli/hone-cli.js:1349 reads `entry?.status === 'retracted'` per-item.
335
+ // (parseRetracted above) reads `status: retracted` per-item.
292
336
  // Replacing per-item status with a top-level marker would break retract.
293
337
  // ─────────────────────────────────────────────────────────────────────
294
338
  function writeBackPromoted(content, schema, idsPromoted) {
@@ -424,6 +468,7 @@ module.exports = {
424
468
  SCHEMA_UNKNOWN,
425
469
  detectSchema,
426
470
  parseLearningsFile,
471
+ parseRetracted,
427
472
  writeBackPromoted,
428
473
  // LC-001 / #58 G2 partial: categorization API (advisory).
429
474
  LEARNING_CATEGORIES,
package/lib/mcp-tools.js CHANGED
@@ -13,6 +13,7 @@ const { spawn } = require('node:child_process');
13
13
  const fsSync = require('node:fs');
14
14
  const os = require('node:os');
15
15
  const path = require('node:path');
16
+ const { privateTempFile } = require('./private-temp');
16
17
 
17
18
  // Resolve the CLI binary. Default `hone` (the installed bin); overridable for tests
18
19
  // / non-global installs via HONE_CLI_BIN (e.g. a path to hone-cli.js run with node).
@@ -24,12 +25,15 @@ function honeBin() {
24
25
  // default) — but WITHOUT exiting on a missing token (the MCP server must start and
25
26
  // report the problem via a preflight / hone_doctor, not die). Editor-agnostic:
26
27
  // works from any MCP client that passes HONE_TOKEN/HONE_API in the server env.
28
+ // HC-GF-304-followup-1: reports THAT a token exists, never the token. Nothing here sends one — every
29
+ // tool shells out to the CLI, which reads its own — and `token` was returned to callers that read
30
+ // only `hasToken`, one serialization away from the stderr preflight.
27
31
  function readHoneConfig() {
28
32
  let rc = {};
29
33
  try { rc = JSON.parse(fsSync.readFileSync(path.join(os.homedir(), '.honerc'), 'utf8')) || {}; } catch { rc = {}; }
30
34
  const token = process.env.HONE_TOKEN || rc.token || null;
31
35
  const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
32
- return { token, apiUrl, hasToken: !!token };
36
+ return { apiUrl, hasToken: !!token };
33
37
  }
34
38
 
35
39
  // Run a `hone` subcommand, capturing stdout/stderr/exit. Never throws — returns a
@@ -163,7 +167,7 @@ async function stepInstruction({ runId, cwd } = {}) {
163
167
 
164
168
  // hone_submit_step — submit editor-executed output (HC-COMM-010 B3). Output
165
169
  // travels via a temp file (1MB-class payloads must not ride argv); the file
166
- // is best-effort deleted after the shell-out.
170
+ // and its directory are best-effort deleted after the shell-out.
167
171
  async function submitStep({ runId, stepKey, output, contractVersion = '1', fromTree = false, testReport, cwd } = {}) {
168
172
  if (!runId) return { isError: true, detail: 'runId is required' };
169
173
  if (!stepKey) return { isError: true, detail: 'stepKey is required' };
@@ -184,18 +188,20 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
184
188
 
185
189
  const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
186
190
  let tmp = null;
187
- if (fromTree) {
188
- base.push('--from-tree');
189
- // Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
190
- if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
191
- } else {
192
- // Output travels via a temp file — 1MB-class payloads must not ride argv.
193
- tmp = path.join(os.tmpdir(), `hone-editor-output-${Date.now()}-${process.pid}.txt`);
194
- fsSync.writeFileSync(tmp, output, 'utf8');
195
- base.push('--file', tmp);
196
- }
197
-
198
191
  try {
192
+ if (fromTree) {
193
+ base.push('--from-tree');
194
+ // Never invented: an absent report stays absent, the same rule the CLI and the prompt carry.
195
+ if (typeof testReport === 'string' && testReport.trim() !== '') base.push('--test-report', testReport);
196
+ } else {
197
+ // Output travels via a temp file — 1MB-class payloads must not ride argv. HC-GF-304-followup-1: in a
198
+ // directory only this call created (mode 0700). The old pid-named file could not collide, but its
199
+ // name was predictable in a shared /tmp. Created inside the `try` so a failed write removes it too.
200
+ tmp = privateTempFile('hone-editor-output', 'output.txt');
201
+ fsSync.writeFileSync(tmp.file, output, 'utf8');
202
+ base.push('--file', tmp.file);
203
+ }
204
+
199
205
  const r = await module.exports._internal.runHone(base, { cwd });
200
206
  if (r.error) return { isError: true, detail: r.error };
201
207
  const data = parseLastJson(r.stdout);
@@ -209,7 +215,7 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
209
215
  : (r.stderr || r.stdout || 'submission failed').slice(0, 500),
210
216
  };
211
217
  } finally {
212
- if (tmp) { try { fsSync.unlinkSync(tmp); } catch { /* best-effort cleanup */ } }
218
+ if (tmp) tmp.remove(); // the directory, not just the file; never throws
213
219
  }
214
220
  }
215
221
 
@@ -12,6 +12,13 @@
12
12
  * A clock is not an identity. `fs.mkdtempSync` creates a new directory with a random suffix, atomically, with
13
13
  * mode 0700, so the file inside it belongs to exactly one invocation whatever the time, and nobody else can
14
14
  * plant or replace it.
15
+ *
16
+ * HC-CI-081: `remove` is a recursive delete of a computed path in a root every process of this user shares, so
17
+ * it deletes only a directory shaped like the one `mkdtempSync` made for it: a direct child of the temp root,
18
+ * named `<prefix>-` plus mkdtemp's six characters. On 2026-09-26 a local mutation run replaced the mkdtemp with
19
+ * `os.tmpdir()` and left `remove` as it was, so each call was `rm -rf $TMPDIR`. It ran outside the sandbox, in
20
+ * the tmpdir the self-hosted runners share, and deleted three other tests' directories mid-run on runner 2
21
+ * (HC-CI-063's, HC-CI-064's and HC-GF-098's; run 36211682382, attempt 1).
15
22
  */
16
23
 
17
24
  const fs = require('node:fs');
@@ -21,14 +28,25 @@ const path = require('node:path');
21
28
  /**
22
29
  * @param {string} prefix names the owner in the directory, e.g. 'hone-emit'
23
30
  * @param {string} name the file's name inside its private directory, e.g. 'patch.diff'
24
- * @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws
31
+ * @returns {{ file: string, remove: () => void }} `remove` deletes the directory and never throws; a directory
32
+ * it did not make is left alone
25
33
  */
26
34
  function privateTempFile(prefix, name) {
27
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
35
+ const root = path.resolve(os.tmpdir());
36
+ const dir = fs.mkdtempSync(path.join(root, `${prefix}-`));
28
37
  return {
29
38
  file: path.join(dir, name),
30
- remove: () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } },
39
+ remove: () => {
40
+ if (!madeBy(root, prefix, dir)) return;
41
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
42
+ },
31
43
  };
32
44
  }
33
45
 
46
+ /** Whether `dir` is what `mkdtempSync(path.join(root, prefix + '-'))` returns: that prefix, six more characters. */
47
+ function madeBy(root, prefix, dir) {
48
+ const base = path.basename(dir);
49
+ return path.dirname(dir) === root && base.startsWith(`${prefix}-`) && base.length === prefix.length + 7;
50
+ }
51
+
34
52
  module.exports = { privateTempFile };
package/lib/run-target.js CHANGED
@@ -94,4 +94,69 @@ async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
94
94
  };
95
95
  }
96
96
 
97
- module.exports = { resolveRunTarget, isRunId, LOOKUP_LIMIT };
97
+ /**
98
+ * HC-GF-270-followup-1 — what a caller-supplied argument must look like before any verb builds a URL
99
+ * from it. HC-GF-270 checked the step key inline in two verbs; five more verbs took a run id, a step key
100
+ * or an attempt that nothing checked. Encoding alone is not enough: `encodeURIComponent('..') === '..'`,
101
+ * and axios resolves the request path with `new URL()`, which collapses dot segments, so
102
+ * `/orchestrate/../verdict` goes out as `/verdict`.
103
+ */
104
+ const isStepKey = (s) => typeof s === 'string' && /^step_[0-9a-z_]+$/i.test(s);
105
+ const isAttempt = (s) => (typeof s === 'string' || typeof s === 'number') && /^[1-9]\d{0,8}$/.test(String(s));
106
+ const isBatchId = isRunId;
107
+
108
+ const KINDS = {
109
+ runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
110
+ batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
111
+ stepKey: { ok: isStepKey, why: 'must name a step, like step_4', hint: '' },
112
+ attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
113
+ };
114
+
115
+ /**
116
+ * 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
118
+ * `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
119
+ * is undefined (a flag that was not given); an empty string is always checked.
120
+ */
121
+ function runArgsError(checks) {
122
+ for (const { label, kind, value, optional } of checks) {
123
+ if (optional && value === undefined) continue;
124
+ const k = KINDS[kind];
125
+ if (!k) throw new TypeError(`runArgsError: unknown kind ${JSON.stringify(kind)}`);
126
+ // JSON.stringify: a crafted value's control characters are escaped, never printed raw to the terminal.
127
+ if (!k.ok(value)) return `${label} ${k.why} (got ${JSON.stringify(value === undefined ? null : value)})${k.hint}`;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
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.
137
+ *
138
+ * 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.
140
+ */
141
+ function orchestratePath(segments, query) {
142
+ if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
143
+ const parts = segments.map((v, i) => {
144
+ if (v === null || v === undefined) throw new TypeError(`orchestratePath: segment ${i} is ${v}`);
145
+ const s = String(v);
146
+ if (s === '' || s === '.' || s === '..') throw new TypeError(`orchestratePath: segment ${i} is ${JSON.stringify(s)}`);
147
+ return encodeURIComponent(s);
148
+ });
149
+ const base = ['', 'orchestrate', ...parts].join('/');
150
+ const qs = new URLSearchParams();
151
+ for (const [k, v] of Object.entries(query || {})) {
152
+ if (v === null || v === undefined || v === false || v === '') continue;
153
+ qs.append(k, String(v));
154
+ }
155
+ const q = qs.toString();
156
+ return q ? `${base}?${q}` : base;
157
+ }
158
+
159
+ module.exports = {
160
+ resolveRunTarget, isRunId, LOOKUP_LIMIT,
161
+ isStepKey, isAttempt, isBatchId, runArgsError, orchestratePath,
162
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.35.0",
3
+ "version": "1.35.1",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {