@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 +100 -61
- package/lib/learnings-parse.js +46 -1
- package/lib/mcp-tools.js +20 -14
- package/lib/private-temp.js +21 -3
- package/lib/run-target.js +66 -1
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
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
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
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
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
8338
|
-
fetchRun: async (id) => (await client.get(
|
|
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 =
|
|
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(
|
|
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(
|
|
8909
|
+
await client.post(orchestratePath([opts.workflowId, 'verdict']), {
|
|
8871
8910
|
source: 'verify-pr',
|
|
8872
8911
|
stepKey: 'step_5c',
|
|
8873
8912
|
verdict: verdict.verdict,
|
package/lib/learnings-parse.js
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
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)
|
|
218
|
+
if (tmp) tmp.remove(); // the directory, not just the file; never throws
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
221
|
|
package/lib/private-temp.js
CHANGED
|
@@ -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
|
|
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: () => {
|
|
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
|
-
|
|
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
|
+
};
|