@hone-ai/cli 1.35.0 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hone-cli.js +178 -75
- package/lib/bundle-paths.js +103 -14
- package/lib/emit-pr.js +28 -0
- package/lib/fresh-connection.js +27 -0
- 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/lib/server-banner.js +19 -0
- package/lib/setup-dry-run.js +29 -0
- 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,13 +371,13 @@ 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',
|
|
350
378
|
exitCode: anyFailed ? 1 : 0,
|
|
351
379
|
detail: `agent-eval ${opts.category}: ${results.filter((r) => r.passed).length}/${results.length} probes passed`,
|
|
352
|
-
});
|
|
380
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
353
381
|
if (opts.output !== 'json') console.log(` → reported agent-eval verdict '${anyFailed ? 'fail' : 'pass'}' to run ${opts.workflowId} (advisory)`);
|
|
354
382
|
} catch (e) {
|
|
355
383
|
if (opts.output !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
@@ -900,6 +928,9 @@ program
|
|
|
900
928
|
if (ciGateChoice.source !== 'auto-detect') {
|
|
901
929
|
setupEnv.CI_GATE = ciGateChoice.mode;
|
|
902
930
|
}
|
|
931
|
+
// HC-GF-321: --stack was declared and never read. The script applies it
|
|
932
|
+
// after detection (apply_stack_override) and refuses an unsupported value.
|
|
933
|
+
if (opts.stack) setupEnv.HONE_STACK_OVERRIDE = String(opts.stack).toLowerCase();
|
|
903
934
|
try {
|
|
904
935
|
execSync(`bash "${scriptPath}" ${flags}`, {
|
|
905
936
|
stdio: 'inherit',
|
|
@@ -911,6 +942,25 @@ program
|
|
|
911
942
|
process.exit(1);
|
|
912
943
|
}
|
|
913
944
|
|
|
945
|
+
// HC-GF-320: every phase below writes (CLAUDE.md, config grounding, test
|
|
946
|
+
// packages, GitHub branch protection). A dry run stops here and says what
|
|
947
|
+
// they would do.
|
|
948
|
+
if (opts.dryRun) {
|
|
949
|
+
const { postScriptDryRunPlan } = require('./lib/setup-dry-run');
|
|
950
|
+
const plan = postScriptDryRunPlan({
|
|
951
|
+
claudeMdTemplate: fs.existsSync(path.join(tmpDir, 'CLAUDE.md')),
|
|
952
|
+
claudeMdExists: fs.existsSync(path.join(process.cwd(), 'CLAUDE.md')),
|
|
953
|
+
configExists: fs.existsSync(path.join(process.cwd(), '.github', '.pipeline-config.yml')),
|
|
954
|
+
installTests: !!opts.installTests,
|
|
955
|
+
skipBranchProtection: opts.branchProtection === false ||
|
|
956
|
+
/^(1|true|yes)$/i.test(process.env.HONE_SKIP_BRANCH_PROTECTION || ''),
|
|
957
|
+
});
|
|
958
|
+
for (const line of plan) console.log(` DRY-RUN: ${line}`);
|
|
959
|
+
console.log(' Dry run complete: nothing was written.');
|
|
960
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
914
964
|
// HC-RC-004: if the adopter chose `local`, run the setup-local-ci
|
|
915
965
|
// scaffold inline so they end the setup command with a working
|
|
916
966
|
// local-CI stack (Makefile + compose) instead of having to know
|
|
@@ -1536,6 +1586,10 @@ async function createTestEnv(repoRoot, opts) {
|
|
|
1536
1586
|
function recommendTestFramework(repoRoot) {
|
|
1537
1587
|
const detected = detectExistingTestFramework(repoRoot);
|
|
1538
1588
|
if (detected.testFw) return; // Already has tests — no recommendation needed
|
|
1589
|
+
// HC-GF-321: every recommendation below is an npm package. Without a root
|
|
1590
|
+
// package.json the stack is not Node (or lives in subdirectories), and a
|
|
1591
|
+
// pytest repository was told to install vitest.
|
|
1592
|
+
if (detected.stack === 'unknown') return;
|
|
1539
1593
|
|
|
1540
1594
|
const pkgs = getInstallPackages(detected.stack);
|
|
1541
1595
|
const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
|
|
@@ -3060,23 +3114,25 @@ program
|
|
|
3060
3114
|
console.log('No .github/learnings/ directory found — nothing to retract.');
|
|
3061
3115
|
return;
|
|
3062
3116
|
}
|
|
3063
|
-
|
|
3117
|
+
// HC-GF-311-followup-1: per learning, in every schema `hone promote` reads — not just the file's top level.
|
|
3118
|
+
const { parseRetracted } = require('./lib/learnings-parse');
|
|
3064
3119
|
const files = fs.readdirSync(learningsDir).filter(f => f.endsWith('.yml'));
|
|
3065
3120
|
for (const file of files) {
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3121
|
+
// One unreadable entry (a directory or a dangling link named *.yml) must not hide the rest.
|
|
3122
|
+
let content;
|
|
3123
|
+
try { content = fs.readFileSync(path.join(learningsDir, file), 'utf8'); }
|
|
3124
|
+
catch (e) { console.warn(` ⚠ ${file}: read error — ${e.message}`); continue; }
|
|
3125
|
+
const { retracted, parseError } = parseRetracted(content);
|
|
3126
|
+
if (parseError) {
|
|
3127
|
+
console.warn(` ⚠ ${file}: parse error — ${parseError.message}`);
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
for (const { id, reason } of retracted) {
|
|
3131
|
+
payload.push({
|
|
3132
|
+
id,
|
|
3133
|
+
repo_name: repoName,
|
|
3134
|
+
reason: reason || 'Retracted (no reason provided in YAML)',
|
|
3135
|
+
});
|
|
3080
3136
|
}
|
|
3081
3137
|
}
|
|
3082
3138
|
if (payload.length === 0) {
|
|
@@ -4955,10 +5011,19 @@ program
|
|
|
4955
5011
|
}
|
|
4956
5012
|
// Path B: GitHub issue fetch via gh
|
|
4957
5013
|
else if (issueId) {
|
|
5014
|
+
// HC-GF-270-followup-1: an issue number or an issue URL, which are the forms gh accepts, and
|
|
5015
|
+
// nothing else. gh reads an argument that begins with `-` as a flag even without a shell.
|
|
5016
|
+
if (!/^[1-9]\d*$/.test(issueId) && !/^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/issues\/[1-9]\d*$/.test(issueId)) {
|
|
5017
|
+
console.error(`error: <issueId> must be an issue number like 142, or an issue URL (got ${JSON.stringify(issueId)})`);
|
|
5018
|
+
console.error('usage: hone classify-story 142');
|
|
5019
|
+
console.error(' hone classify-story --input-file inputs.yml');
|
|
5020
|
+
process.exit(2);
|
|
5021
|
+
}
|
|
4958
5022
|
try {
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
5023
|
+
// An argument list, not a shell string, and a bound: a hung gh must not hang the command.
|
|
5024
|
+
const out = require('child_process').execFileSync(
|
|
5025
|
+
'gh', ['issue', 'view', String(issueId), '--json', 'title,body,labels,url'],
|
|
5026
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }
|
|
4962
5027
|
);
|
|
4963
5028
|
issueMeta = JSON.parse(out);
|
|
4964
5029
|
const labels = (issueMeta.labels || []).map(l => l.name);
|
|
@@ -4980,6 +5045,10 @@ program
|
|
|
4980
5045
|
is_first_of_its_kind: false,
|
|
4981
5046
|
};
|
|
4982
5047
|
} catch (e) {
|
|
5048
|
+
if (e && e.code === 'ETIMEDOUT') {
|
|
5049
|
+
console.error(`error: gh issue view ${issueId} timed out after 30s`);
|
|
5050
|
+
process.exit(2);
|
|
5051
|
+
}
|
|
4983
5052
|
console.error(`error: gh issue view ${issueId} failed: ${(e.stderr || '').toString().trim() || e.message}`);
|
|
4984
5053
|
console.error(`hint: install GitHub CLI (cli.github.com), run \`gh auth login\`, or pass --input-file <path>`);
|
|
4985
5054
|
process.exit(2);
|
|
@@ -5924,7 +5993,7 @@ program
|
|
|
5924
5993
|
try {
|
|
5925
5994
|
// HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
|
|
5926
5995
|
// knows, so an older CLI — which sends nothing — keeps being served contract 1.
|
|
5927
|
-
const { data } = await client.get(
|
|
5996
|
+
const { data } = await client.get(orchestratePath([runId, 'step-instruction'], { contracts: '1,2' }));
|
|
5928
5997
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5929
5998
|
console.log(`Step: ${data.stepKey} (${data.agent})`);
|
|
5930
5999
|
console.log(`Contract: ${data.contractVersion}`);
|
|
@@ -5976,7 +6045,7 @@ program
|
|
|
5976
6045
|
output = fs.readFileSync(opts.file, 'utf8');
|
|
5977
6046
|
}
|
|
5978
6047
|
const { data } = await client.post(
|
|
5979
|
-
|
|
6048
|
+
orchestratePath([runId, 'step', stepKey, 'editor-output']),
|
|
5980
6049
|
{ contractVersion: opts.contract, output }
|
|
5981
6050
|
);
|
|
5982
6051
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
@@ -6124,12 +6193,7 @@ program
|
|
|
6124
6193
|
// the 2026-09-08 editor test nearly meaningless; here it costs money rather than time.
|
|
6125
6194
|
try {
|
|
6126
6195
|
const h = await client.get('/health').then(r => r.data).catch(() => null);
|
|
6127
|
-
|
|
6128
|
-
if (llm && llm.local) {
|
|
6129
|
-
console.log(`LOCAL ${config.apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`);
|
|
6130
|
-
} else {
|
|
6131
|
-
console.log(`PROD ${config.apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`);
|
|
6132
|
-
}
|
|
6196
|
+
console.log(require('./lib/server-banner').serverBanner(config.apiUrl, h));
|
|
6133
6197
|
} catch { /* never block a run on a banner */ }
|
|
6134
6198
|
|
|
6135
6199
|
const { resolveRunTarget, isRunId } = require('./lib/run-target');
|
|
@@ -6182,7 +6246,7 @@ program
|
|
|
6182
6246
|
|
|
6183
6247
|
let status;
|
|
6184
6248
|
try {
|
|
6185
|
-
const { data: pollData } = await client.get(
|
|
6249
|
+
const { data: pollData } = await client.get(orchestratePath([pollRunId]));
|
|
6186
6250
|
status = pollData;
|
|
6187
6251
|
} catch (e) {
|
|
6188
6252
|
console.error(`Poll error: ${e.message}`);
|
|
@@ -6250,7 +6314,8 @@ program
|
|
|
6250
6314
|
console.log(` ${remedy.stepKey} named the files it needs. Re-queue with:`);
|
|
6251
6315
|
console.log(` ${cmd}`);
|
|
6252
6316
|
if (remedy.truncated) {
|
|
6253
|
-
|
|
6317
|
+
// HC-GF-263-followup-1: the cap counts entries, and one file may have several.
|
|
6318
|
+
console.log(` ⚠ it asked for more than ${remedy.paths.length} paths; the rest were dropped.`);
|
|
6254
6319
|
}
|
|
6255
6320
|
}
|
|
6256
6321
|
// H5: say how many requested paths were withheld from the command, never what they were.
|
|
@@ -6352,14 +6417,14 @@ program
|
|
|
6352
6417
|
|
|
6353
6418
|
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
6354
6419
|
try {
|
|
6355
|
-
await client.post(
|
|
6420
|
+
await client.post(orchestratePath([pollRunId, 'approve']), { stepKey: d.stepKey });
|
|
6356
6421
|
console.log(' Approved — workflow resuming...');
|
|
6357
6422
|
} catch (e) {
|
|
6358
6423
|
console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
|
|
6359
6424
|
}
|
|
6360
6425
|
} else if (answer.toLowerCase() === 'kill') {
|
|
6361
6426
|
try {
|
|
6362
|
-
await client.post(
|
|
6427
|
+
await client.post(orchestratePath([pollRunId, 'kill']), { reason: 'CLI kill' });
|
|
6363
6428
|
console.log(' Workflow killed.');
|
|
6364
6429
|
process.exit(0);
|
|
6365
6430
|
} catch (e) {
|
|
@@ -6388,7 +6453,7 @@ program
|
|
|
6388
6453
|
if (opts.status) {
|
|
6389
6454
|
try {
|
|
6390
6455
|
const statusId = await existingRunId('--status');
|
|
6391
|
-
const { data } = await client.get(
|
|
6456
|
+
const { data } = await client.get(orchestratePath([statusId]));
|
|
6392
6457
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
6393
6458
|
console.log('');
|
|
6394
6459
|
console.log(`Workflow: ${data.runId}`);
|
|
@@ -6438,7 +6503,7 @@ program
|
|
|
6438
6503
|
if (opts.kill) {
|
|
6439
6504
|
try {
|
|
6440
6505
|
const killId = await existingRunId('--kill');
|
|
6441
|
-
const { data } = await client.post(
|
|
6506
|
+
const { data } = await client.post(orchestratePath([killId, 'kill']), { reason: 'CLI kill' });
|
|
6442
6507
|
console.log(`Workflow ${data.runId} killed.`);
|
|
6443
6508
|
} catch (e) {
|
|
6444
6509
|
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
@@ -6452,7 +6517,7 @@ program
|
|
|
6452
6517
|
let approveId;
|
|
6453
6518
|
try {
|
|
6454
6519
|
approveId = await existingRunId(`--approve ${opts.approve}`);
|
|
6455
|
-
await client.post(
|
|
6520
|
+
await client.post(orchestratePath([approveId, 'approve']),
|
|
6456
6521
|
{ stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
|
|
6457
6522
|
acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
|
|
6458
6523
|
console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
|
|
@@ -6606,6 +6671,10 @@ program
|
|
|
6606
6671
|
// auto-approve on output text, which never ran. Approve overnight on evidence with `hone follow-batch`.
|
|
6607
6672
|
.option('--overnight', 'Enable Night Shift mode (longer timeouts, max 25 stories; step_4/step_5 still pause for approval)', false)
|
|
6608
6673
|
.action(async (opts) => {
|
|
6674
|
+
refuseBadRunArgs([ // HC-GF-270-followup-1
|
|
6675
|
+
{ label: '--status', kind: 'batchId', value: opts.status, optional: true },
|
|
6676
|
+
{ label: '--report', kind: 'batchId', value: opts.report, optional: true },
|
|
6677
|
+
]);
|
|
6609
6678
|
const config = getConfig();
|
|
6610
6679
|
const client = api(config);
|
|
6611
6680
|
|
|
@@ -6650,7 +6719,7 @@ program
|
|
|
6650
6719
|
try {
|
|
6651
6720
|
const format = opts.format === 'json' ? 'json' : 'md';
|
|
6652
6721
|
const { data, headers } = await client.get(
|
|
6653
|
-
|
|
6722
|
+
orchestratePath(['batch', opts.report, 'report']),
|
|
6654
6723
|
{ params: { format }, responseType: 'text', transformResponse: [(d) => d] }
|
|
6655
6724
|
);
|
|
6656
6725
|
if (format === 'json') {
|
|
@@ -6701,7 +6770,7 @@ program
|
|
|
6701
6770
|
// ── --status <batchId> ────────────────────────────────────
|
|
6702
6771
|
if (opts.status) {
|
|
6703
6772
|
try {
|
|
6704
|
-
const { data } = await client.get(
|
|
6773
|
+
const { data } = await client.get(orchestratePath(['batch', opts.status]));
|
|
6705
6774
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
6706
6775
|
console.log('');
|
|
6707
6776
|
console.log(`Batch: ${data.batchId}`);
|
|
@@ -7561,13 +7630,15 @@ showCmd
|
|
|
7561
7630
|
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7562
7631
|
.option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
|
|
7563
7632
|
.action(async (workflowId, stepKey, opts) => {
|
|
7633
|
+
refuseBadRunArgs([ // HC-GF-270-followup-1
|
|
7634
|
+
{ label: '<workflowId>', kind: 'runId', value: workflowId },
|
|
7635
|
+
{ label: '<stepKey>', kind: 'stepKey', value: stepKey },
|
|
7636
|
+
{ label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
|
|
7637
|
+
]);
|
|
7564
7638
|
const config = getConfig();
|
|
7565
7639
|
const client = api(config);
|
|
7566
7640
|
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('&')}` : ''}`;
|
|
7641
|
+
const url = orchestratePath([workflowId, 'step', stepKey], { attempt: opts.attempt, prompt: opts.prompt ? '1' : undefined });
|
|
7571
7642
|
const r = await client.get(url);
|
|
7572
7643
|
const s = r.data;
|
|
7573
7644
|
console.log(`── step output ──────────────────────────────────────────`);
|
|
@@ -7624,6 +7695,7 @@ program
|
|
|
7624
7695
|
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7625
7696
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7626
7697
|
.action(async (workflowId, opts) => {
|
|
7698
|
+
refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270-followup-1
|
|
7627
7699
|
const { execFileSync } = require('child_process');
|
|
7628
7700
|
const os = require('os');
|
|
7629
7701
|
const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
|
|
@@ -7639,8 +7711,7 @@ program
|
|
|
7639
7711
|
|
|
7640
7712
|
let output;
|
|
7641
7713
|
try {
|
|
7642
|
-
const url =
|
|
7643
|
-
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7714
|
+
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
7644
7715
|
const r = await client.get(url);
|
|
7645
7716
|
output = r.data?.output;
|
|
7646
7717
|
} catch (e) {
|
|
@@ -7764,6 +7835,7 @@ program
|
|
|
7764
7835
|
.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
7836
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7766
7837
|
.action(async (workflowId, opts) => {
|
|
7838
|
+
refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
|
|
7767
7839
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7768
7840
|
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
7769
7841
|
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
@@ -7782,13 +7854,6 @@ program
|
|
|
7782
7854
|
const { gitEnv } = require('./lib/git-env');
|
|
7783
7855
|
const config = getConfig();
|
|
7784
7856
|
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
7857
|
const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
|
|
7793
7858
|
|
|
7794
7859
|
const emit = (obj, humanLine) => {
|
|
@@ -7806,8 +7871,7 @@ program
|
|
|
7806
7871
|
let runStoryId = null;
|
|
7807
7872
|
let runFetchFailed = false;
|
|
7808
7873
|
try {
|
|
7809
|
-
const url =
|
|
7810
|
-
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7874
|
+
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
7811
7875
|
const r = await client.get(url);
|
|
7812
7876
|
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
7813
7877
|
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
@@ -7817,7 +7881,7 @@ program
|
|
|
7817
7881
|
verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
|
|
7818
7882
|
// best-effort: pull the run's ci_local_command for the default
|
|
7819
7883
|
try {
|
|
7820
|
-
const runR = await client.get(
|
|
7884
|
+
const runR = await client.get(orchestratePath([workflowId]));
|
|
7821
7885
|
// HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
|
|
7822
7886
|
// `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
|
|
7823
7887
|
// 200 with no config. And a body that is not THIS run is not this run's config.
|
|
@@ -8234,7 +8298,7 @@ program
|
|
|
8234
8298
|
// change the local exit code (the verdict is already true locally).
|
|
8235
8299
|
if (opts.report && reportVerdict) {
|
|
8236
8300
|
try {
|
|
8237
|
-
await client.post(
|
|
8301
|
+
await client.post(orchestratePath([workflowId, 'verdict']), {
|
|
8238
8302
|
source: 'verify-patch',
|
|
8239
8303
|
stepKey: opts.stepKey,
|
|
8240
8304
|
verdict: reportVerdict,
|
|
@@ -8243,7 +8307,7 @@ program
|
|
|
8243
8307
|
attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
|
|
8244
8308
|
headSha,
|
|
8245
8309
|
gates: reportGates.length ? reportGates : undefined,
|
|
8246
|
-
});
|
|
8310
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
8247
8311
|
if (opts.format !== 'json') {
|
|
8248
8312
|
// HC-GF-070: name the gate that is actually configured. This said "(advisory)"
|
|
8249
8313
|
// unconditionally, and printed it on a run where `enforce` was auto-approving
|
|
@@ -8268,11 +8332,12 @@ program
|
|
|
8268
8332
|
.description('What happened on a run, and what still needs you')
|
|
8269
8333
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
8270
8334
|
.action(async (runId, opts) => {
|
|
8335
|
+
refuseBadRunArgs([{ label: '<runId>', kind: 'runId', value: runId }]); // HC-GF-270-followup-1
|
|
8271
8336
|
const { buildReport, renderReport } = require('./lib/run-report');
|
|
8272
8337
|
const config = getConfig();
|
|
8273
8338
|
const client = api(config);
|
|
8274
8339
|
try {
|
|
8275
|
-
const { data } = await client.get(
|
|
8340
|
+
const { data } = await client.get(orchestratePath([runId]));
|
|
8276
8341
|
const report = buildReport(data);
|
|
8277
8342
|
if (opts.format === 'json') console.log(JSON.stringify(report, null, 2));
|
|
8278
8343
|
else console.log(renderReport(report));
|
|
@@ -8334,8 +8399,8 @@ program
|
|
|
8334
8399
|
const client = api(config);
|
|
8335
8400
|
const repoName = opts.repo || path.basename(process.cwd());
|
|
8336
8401
|
const io = {
|
|
8337
|
-
fetchBatch: async (id) => (await client.get(
|
|
8338
|
-
fetchRun: async (id) => (await client.get(
|
|
8402
|
+
fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
|
|
8403
|
+
fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
|
|
8339
8404
|
runVerb: (argv) => new Promise((resolve) => {
|
|
8340
8405
|
const child = spawn(process.execPath, [process.argv[1], ...argv],
|
|
8341
8406
|
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
@@ -8389,6 +8454,7 @@ program
|
|
|
8389
8454
|
.option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
|
|
8390
8455
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
8391
8456
|
.action(async (workflowId, opts) => {
|
|
8457
|
+
refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
|
|
8392
8458
|
const { execFileSync, spawnSync } = require('child_process');
|
|
8393
8459
|
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
8394
8460
|
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
@@ -8406,19 +8472,26 @@ program
|
|
|
8406
8472
|
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
|
|
8407
8473
|
const { verifyStateFor } = require('./lib/emit-pr');
|
|
8408
8474
|
const {
|
|
8409
|
-
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
8475
|
+
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish, recordPr,
|
|
8410
8476
|
} = require('./lib/emit-pr');
|
|
8411
8477
|
const { gitEnv } = require('./lib/git-env');
|
|
8412
8478
|
const config = getConfig();
|
|
8413
8479
|
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
8480
|
const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
|
|
8481
|
+
// HC-GF-302: record the PR on the run. The POST goes out after the verify, push and `gh` steps,
|
|
8482
|
+
// which block the event loop for minutes, and a keep-alive socket the server closed meanwhile
|
|
8483
|
+
// fails `write EPIPE` when reused (HC-GF-326). A fresh connection per record avoids that.
|
|
8484
|
+
//
|
|
8485
|
+
// F3 (review): baseSha and attempt are the CALLER's to supply, not this closure's defaults —
|
|
8486
|
+
// the two call sites below have different bases for trusting each one (see their own
|
|
8487
|
+
// comments). recordPr itself stays pure; this only wires up the transport.
|
|
8488
|
+
const recordPrOnRun = ({ prUrl, branch: prBranch, baseSha: recBaseSha = null, attempt: recAttempt }) => recordPr({
|
|
8489
|
+
post: (p, body) => client.post(p, body, require('./lib/fresh-connection').freshConnection()),
|
|
8490
|
+
path: orchestratePath([workflowId, 'pr-record']),
|
|
8491
|
+
prUrl, branch: prBranch, baseSha: recBaseSha, attempt: recAttempt,
|
|
8492
|
+
});
|
|
8493
|
+
const recordWarning = (rec) => (rec.recorded || opts.format === 'json'
|
|
8494
|
+
? '' : `\n ⚠ the run's PR record was not saved (non-fatal; the morning report may miss this link): ${rec.reason}`);
|
|
8422
8495
|
|
|
8423
8496
|
// --open-pr implies --push (cannot open a PR against an unpushed branch).
|
|
8424
8497
|
const doPush = Boolean(opts.push || opts.openPr);
|
|
@@ -8439,15 +8512,18 @@ program
|
|
|
8439
8512
|
// branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
|
|
8440
8513
|
// never arrived.
|
|
8441
8514
|
let runStoryId = null;
|
|
8515
|
+
// HC-GF-302: the attempt the server resolved (the latest one unless --attempt named it), which
|
|
8516
|
+
// orders this run's PR records. Not opts.attempt: that is absent in the default case.
|
|
8517
|
+
let stepAttempt = null;
|
|
8442
8518
|
try {
|
|
8443
|
-
const url =
|
|
8444
|
-
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
8519
|
+
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
8445
8520
|
const r = await client.get(url);
|
|
8446
8521
|
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
8447
8522
|
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
8448
8523
|
output = r.data?.output;
|
|
8524
|
+
stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
|
|
8449
8525
|
try {
|
|
8450
|
-
const runR = await client.get(
|
|
8526
|
+
const runR = await client.get(orchestratePath([workflowId]));
|
|
8451
8527
|
// HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
|
|
8452
8528
|
// is not THIS run does not count as its config.
|
|
8453
8529
|
if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
|
|
@@ -8550,8 +8626,24 @@ program
|
|
|
8550
8626
|
}
|
|
8551
8627
|
const decision = decidePublish({ branchExists, openPrUrl });
|
|
8552
8628
|
if (decision.action === 'already_open') {
|
|
8553
|
-
|
|
8554
|
-
|
|
8629
|
+
// HC-GF-302 (review F3): record here too — hone follow-batch sends a completed run's emit
|
|
8630
|
+
// to the branch its draft already opened, so this is the path that retries a record the
|
|
8631
|
+
// draft failed to save. But this path never verified anything against a specific attempt:
|
|
8632
|
+
// it found an already-open PR without running check-patch/verify-patch here, so it has no
|
|
8633
|
+
// basis to claim `headSha` is this attempt's base — baseSha is always null on this path.
|
|
8634
|
+
// And `stepAttempt` above is whichever attempt the server considers LATEST, which is not
|
|
8635
|
+
// necessarily the attempt the open PR's branch was built from — so this only records when
|
|
8636
|
+
// the caller named the attempt explicitly with --attempt. `hone follow-batch` always does
|
|
8637
|
+
// (cli/lib/batch-follow.js buildArgs pushes `--attempt N` for every emit-pr call it makes);
|
|
8638
|
+
// a bare `hone emit-pr --open-pr` with no --attempt has no basis to order a record either,
|
|
8639
|
+
// so it records nothing rather than guessing.
|
|
8640
|
+
const explicitAttempt = opts.attempt !== undefined ? Number(opts.attempt) : null;
|
|
8641
|
+
const rec = explicitAttempt != null
|
|
8642
|
+
? await recordPrOnRun({ prUrl: decision.prUrl, branch, baseSha: null, attempt: explicitAttempt })
|
|
8643
|
+
: { recorded: false, reason: 'the open PR\'s attempt is unknown; pass --attempt' };
|
|
8644
|
+
emit({ ok: true, verdict: 'already_open', prUrl: decision.prUrl, branch, recorded: rec.recorded,
|
|
8645
|
+
...(rec.recorded ? {} : { recordError: rec.reason }) },
|
|
8646
|
+
`✓ a draft PR is already open for ${branch}: ${decision.prUrl}${recordWarning(rec)}`);
|
|
8555
8647
|
process.exit(EXIT.PASS);
|
|
8556
8648
|
}
|
|
8557
8649
|
if (decision.action === 'branch_conflict') {
|
|
@@ -8724,8 +8816,13 @@ program
|
|
|
8724
8816
|
|
|
8725
8817
|
if (exitCode === EXIT.PASS) {
|
|
8726
8818
|
const tier = prUrl ? 'pr' : pushed ? 'pushed' : 'dry_run';
|
|
8819
|
+
// HC-GF-302: only a PR is recorded; a pushed or local branch has no link to show. This
|
|
8820
|
+
// is the path that just verified the diff and committed it: headSha is the commit the
|
|
8821
|
+
// patch was verified against, so it is a true baseSha here (unlike the already_open
|
|
8822
|
+
// path above, which never ran that verification and always records baseSha: null).
|
|
8823
|
+
const rec = prUrl ? await recordPrOnRun({ prUrl, branch, baseSha: headSha || null, attempt: stepAttempt }) : null;
|
|
8727
8824
|
const human = prUrl
|
|
8728
|
-
? `✓ draft PR opened: ${prUrl}`
|
|
8825
|
+
? `✓ draft PR opened: ${prUrl}${recordWarning(rec)}`
|
|
8729
8826
|
: pushed
|
|
8730
8827
|
? `✓ branch pushed to origin/${branch}\n → open a PR: gh pr create --head ${branch} --draft`
|
|
8731
8828
|
: `✓ branch '${branch}' built locally (dry run — nothing pushed)\n` +
|
|
@@ -8733,7 +8830,11 @@ program
|
|
|
8733
8830
|
` → push it: hone emit-pr ${workflowId} --push\n` +
|
|
8734
8831
|
` → push + PR: hone emit-pr ${workflowId} --open-pr\n` +
|
|
8735
8832
|
` → discard it: git branch -D ${branch}`;
|
|
8736
|
-
|
|
8833
|
+
// F8 (review): --format json previously dropped the failure reason entirely once
|
|
8834
|
+
// `recorded` was false — a reader had no way to tell why without re-running in
|
|
8835
|
+
// --format pretty. Same object, one extra field, omitted once recorded:true.
|
|
8836
|
+
emit({ ok: true, verdict: tier, branch, verifyState, prUrl, pushed, headSha,
|
|
8837
|
+
...(rec ? { recorded: rec.recorded, ...(rec.recorded ? {} : { recordError: rec.reason }) } : {}) }, human);
|
|
8737
8838
|
}
|
|
8738
8839
|
}
|
|
8739
8840
|
}
|
|
@@ -8766,6 +8867,8 @@ program
|
|
|
8766
8867
|
.option('--workflow-id <id>', 'the run id to report the verdict against (required with --report)')
|
|
8767
8868
|
.option('--format <fmt>', 'pretty or json', 'pretty')
|
|
8768
8869
|
.action(async (prOrBranch, opts) => {
|
|
8870
|
+
// HC-GF-270-followup-1: a --workflow-id is checked before any gh call or CI command runs.
|
|
8871
|
+
refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
|
|
8769
8872
|
const { execFileSync, spawnSync } = require('child_process');
|
|
8770
8873
|
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
8771
8874
|
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
@@ -8867,13 +8970,13 @@ program
|
|
|
8867
8970
|
} else {
|
|
8868
8971
|
try {
|
|
8869
8972
|
const client = api(getConfig());
|
|
8870
|
-
await client.post(
|
|
8973
|
+
await client.post(orchestratePath([opts.workflowId, 'verdict']), {
|
|
8871
8974
|
source: 'verify-pr',
|
|
8872
8975
|
stepKey: 'step_5c',
|
|
8873
8976
|
verdict: verdict.verdict,
|
|
8874
8977
|
exitCode: verdict.exitCode,
|
|
8875
8978
|
detail: verdict.detail,
|
|
8876
|
-
});
|
|
8979
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
8877
8980
|
reported = true;
|
|
8878
8981
|
if (opts.format !== 'json') console.log(` → reported CI-gate verdict '${verdict.verdict}' to run ${opts.workflowId} (advisory)`);
|
|
8879
8982
|
} catch (e) {
|
package/lib/bundle-paths.js
CHANGED
|
@@ -315,13 +315,25 @@ function anchorLineForStory(content, storyText) {
|
|
|
315
315
|
* The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
|
|
316
316
|
* gets the operator a window near it, which beats none.
|
|
317
317
|
*/
|
|
318
|
+
/**
|
|
319
|
+
* HC-GF-206-followup-1: a collapsed hit is a real site — the collapse only means it EXPECTS to
|
|
320
|
+
* share its neighbour's window, not that it is covered. The final window's radius is chosen
|
|
321
|
+
* later (as low as 200 chars, `planFileExcerpt`) and can land under the 40-line threshold that
|
|
322
|
+
* collapsed it, so a hit collapsed at 36 lines from its neighbour can fall outside the window
|
|
323
|
+
* that was supposed to carry it, with nothing reporting the loss. `collapsed` returns each
|
|
324
|
+
* dropped hit with `near`, the kept site's line it was folded into, so the caller can extend
|
|
325
|
+
* that site's window to actually reach it.
|
|
326
|
+
*
|
|
327
|
+
* @returns {{sites: Array<{line: number, anchor: string}>,
|
|
328
|
+
* collapsed: Array<{line: number, anchor: string, near: number}>}}
|
|
329
|
+
*/
|
|
318
330
|
function anchorSitesForStory(content, storyText, max = 6) {
|
|
319
331
|
// HC-GF-204: every region a story's terms point at, best-first. `anchorLineForStory`
|
|
320
332
|
// answers "where does this live"; a story also needs "where is it USED", and on run
|
|
321
333
|
// ad4a561f the planner had the definition and refused for want of the call sites.
|
|
322
334
|
const text = String(content || '');
|
|
323
335
|
const story = String(storyText || '');
|
|
324
|
-
if (!text || !story) return [];
|
|
336
|
+
if (!text || !story) return { sites: [], collapsed: [] };
|
|
325
337
|
const candidates = [...story.matchAll(BACKTICKED_RE)]
|
|
326
338
|
.map((m) => m[1].trim())
|
|
327
339
|
.filter((c) => c.length >= 3
|
|
@@ -331,6 +343,7 @@ function anchorSitesForStory(content, storyText, max = 6) {
|
|
|
331
343
|
|
|
332
344
|
const seenLine = new Set();
|
|
333
345
|
const sites = [];
|
|
346
|
+
const collapsed = [];
|
|
334
347
|
for (const anchor of candidates) {
|
|
335
348
|
const def = definitionIndexOf(text, anchor);
|
|
336
349
|
if (def === -1) continue;
|
|
@@ -339,14 +352,16 @@ function anchorSitesForStory(content, storyText, max = 6) {
|
|
|
339
352
|
for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
|
|
340
353
|
for (const off of offsets) {
|
|
341
354
|
const line = text.slice(0, off).split('\n').length;
|
|
342
|
-
// Collapse near-duplicates: two hits 20 lines apart share one window.
|
|
343
|
-
|
|
355
|
+
// Collapse near-duplicates: two hits 20 lines apart share one window. `near` records
|
|
356
|
+
// WHICH kept site it shares with, so a caller can pull it back in later.
|
|
357
|
+
const near = [...seenLine].find((l) => Math.abs(l - line) < 40);
|
|
358
|
+
if (near !== undefined) { collapsed.push({ line, anchor, near }); continue; }
|
|
344
359
|
seenLine.add(line);
|
|
345
360
|
sites.push({ line, anchor });
|
|
346
|
-
if (sites.length >= max) return sites;
|
|
361
|
+
if (sites.length >= max) return { sites, collapsed };
|
|
347
362
|
}
|
|
348
363
|
}
|
|
349
|
-
return sites;
|
|
364
|
+
return { sites, collapsed };
|
|
350
365
|
}
|
|
351
366
|
|
|
352
367
|
function definitionIndexOf(text, term) {
|
|
@@ -428,7 +443,7 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
428
443
|
// hence budgeted first, and `mergeIntersectingWindows` unions it with any inferred
|
|
429
444
|
// window it overlaps. No sort, no priority field — array order already says it.
|
|
430
445
|
// HC-GF-288: every stated line seeds it, in the caller's order.
|
|
431
|
-
const inferred = anchorSitesForStory(text, storyText);
|
|
446
|
+
const { sites: inferred, collapsed } = anchorSitesForStory(text, storyText);
|
|
432
447
|
const sites = [...stated.map((l) => ({ line: l, anchor: null })), ...inferred];
|
|
433
448
|
const chosen = sites.length ? sites : [hit];
|
|
434
449
|
const why = (a, l) => (a ? `anchored on \`${a}\`` : `centered on referenced line ${l}`);
|
|
@@ -515,15 +530,15 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
515
530
|
// only ever dropped when the stated regions alone exceed the cap.
|
|
516
531
|
const budget = (ord) => {
|
|
517
532
|
const kept = [];
|
|
533
|
+
const skipped = [];
|
|
518
534
|
let spent = 0;
|
|
519
|
-
let skipped = 0;
|
|
520
535
|
for (const site of ord) {
|
|
521
536
|
const cost = costOf(site);
|
|
522
537
|
if (spent + cost > limit) {
|
|
523
538
|
// A dropped stated region is reported by LINE below, not counted here: the
|
|
524
539
|
// advice attached to this count — "name them as path:<line>" — is advice the
|
|
525
540
|
// operator has already followed for those.
|
|
526
|
-
if (!site.stated) skipped
|
|
541
|
+
if (!site.stated) skipped.push(site);
|
|
527
542
|
continue;
|
|
528
543
|
}
|
|
529
544
|
spent += cost;
|
|
@@ -551,6 +566,12 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
551
566
|
// considered: a layout with no window sends the result to the fallback, which overruns
|
|
552
567
|
// the cap by construction. Third review: preferring width alone discarded a 1,066-char
|
|
553
568
|
// layout for a 33,002-char fallback at cap 12,000.
|
|
569
|
+
//
|
|
570
|
+
// HC-GF-188-followup-1: so a layout that kept nothing has not succeeded, stated lines or
|
|
571
|
+
// not. With none stated, "honours every stated line" was vacuously true at the estimate,
|
|
572
|
+
// the narrower retry never ran, and the fallback returned 30,502 chars at cap 12,000 —
|
|
573
|
+
// the first inferred anchor's long line — while the second anchor's window, 411 chars at
|
|
574
|
+
// radius 200, was never tried. With a stated line, honouring it already means a window.
|
|
554
575
|
let best = null;
|
|
555
576
|
const better = (a, b) => (a.honoured - b.honoured)
|
|
556
577
|
|| (Number(a.plan.kept.length > 0) - Number(b.plan.kept.length > 0))
|
|
@@ -560,10 +581,10 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
560
581
|
const honoured = stated.filter((l) => plan.kept.some(({ w }) => covers(w, l))).length;
|
|
561
582
|
const cand = { r, plan, honoured };
|
|
562
583
|
if (!best || better(cand, best) > 0) best = cand;
|
|
563
|
-
return honoured === stated.length;
|
|
584
|
+
return honoured === stated.length && plan.kept.length > 0;
|
|
564
585
|
};
|
|
565
586
|
if (!tryRadius(radius) && radius > 200 && tryRadius(200)) {
|
|
566
|
-
//
|
|
587
|
+
// It fits at 200 but not at the estimate: binary-search between them.
|
|
567
588
|
// Bounded by log2(radius) layouts — measured at most 12 layouts and 228 ms on a
|
|
568
589
|
// 1.6 MB file of long lines, a few ms on ordinary source.
|
|
569
590
|
let lo = 200;
|
|
@@ -575,7 +596,6 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
575
596
|
}
|
|
576
597
|
radius = best.r;
|
|
577
598
|
windows.push(...best.plan.kept);
|
|
578
|
-
const dropped = best.plan.skipped;
|
|
579
599
|
if (!windows.length) {
|
|
580
600
|
// Nothing fits at any radius tried — one line alone is longer than the cap. Build the
|
|
581
601
|
// window at the MINIMUM radius, so the overrun is that line and no more: at the
|
|
@@ -583,6 +603,66 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
583
603
|
const w = windowAroundLine(text, chosen[0].line, 200);
|
|
584
604
|
windows.push({ w, anchor: chosen[0].anchor, line: chosen[0].line });
|
|
585
605
|
}
|
|
606
|
+
// HC-GF-188-followup-1: a region is dropped when NOTHING bundled reaches it — counted
|
|
607
|
+
// against what is returned, not what the budget skipped. The fallback above returns one
|
|
608
|
+
// region the budget had skipped, and the notice still called it dropped: "2 further
|
|
609
|
+
// region(s) DROPPED" with one missing. Every window grows with the radius around the
|
|
610
|
+
// same line, so the fallback's window lies inside that region's; and the layout's
|
|
611
|
+
// regions are disjoint once merged, so no other skipped region intersects a kept one.
|
|
612
|
+
const dropped = best.plan.skipped.filter((s) =>
|
|
613
|
+
!windows.some(({ w }) => w.startLine <= s.w.endLine && s.w.startLine <= w.endLine)).length;
|
|
614
|
+
|
|
615
|
+
// HC-GF-206-followup-1: a collapsed hit shares its neighbour's window in EXPECTATION only.
|
|
616
|
+
// `radius` was chosen for the whole layout and can end up under the 40-line threshold that
|
|
617
|
+
// collapsed the hit in the first place, cutting it out of the window that was supposed to
|
|
618
|
+
// carry it. Pull each missed hit back in by widening its neighbour's window to reach it,
|
|
619
|
+
// then re-merge — widening can make two previously-separate kept windows intersect.
|
|
620
|
+
let unreached = 0;
|
|
621
|
+
if (collapsed.length) {
|
|
622
|
+
for (const hit of collapsed) {
|
|
623
|
+
if (windows.some(({ w }) => covers(w, hit.line))) continue; // already inside some window
|
|
624
|
+
// The neighbour is the kept window whose site.line this hit collapsed into — falling
|
|
625
|
+
// back to nearest-by-distance when that site's own window was dropped for budget.
|
|
626
|
+
let target = windows.find(({ line: siteLine }) => siteLine === hit.near);
|
|
627
|
+
if (!target) {
|
|
628
|
+
let bestDist = Infinity;
|
|
629
|
+
for (const entry of windows) {
|
|
630
|
+
const dist = hit.line < entry.w.startLine ? entry.w.startLine - hit.line
|
|
631
|
+
: hit.line > entry.w.endLine ? hit.line - entry.w.endLine : 0;
|
|
632
|
+
if (dist < bestDist) { bestDist = dist; target = entry; }
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!target) continue; // no window exists at all — reported as unreached below
|
|
636
|
+
// THE BUDGET PROMISE STILL HOLDS: widen, re-merge (an overlap with another kept window
|
|
637
|
+
// is a real possibility once widened), price the result, and roll back rather than
|
|
638
|
+
// overrun the cap. A hit that cannot be reached within budget is unreached, not free.
|
|
639
|
+
const before = windows.map((e) => ({ ...e }));
|
|
640
|
+
target.w = windowBetweenLines(text,
|
|
641
|
+
Math.min(target.w.startLine, hit.line), Math.max(target.w.endLine, hit.line));
|
|
642
|
+
const merged = mergeIntersectingWindows(text, windows);
|
|
643
|
+
const cost = merged.reduce((s, e) => s + costOf(e), 0);
|
|
644
|
+
if (cost > limit) {
|
|
645
|
+
windows.length = 0;
|
|
646
|
+
windows.push(...before);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
windows.length = 0;
|
|
650
|
+
windows.push(...merged);
|
|
651
|
+
}
|
|
652
|
+
// Re-sort stated windows back to the front — merging can reorder when a widened window
|
|
653
|
+
// now overlaps one processed earlier, and `windows[0]` below is read as THE region (its
|
|
654
|
+
// anchor and line drive `because`). Mirrors `layout()`'s own sort exactly: `rankOf` returns
|
|
655
|
+
// -1 for a non-stated window, and sorting on that raw value would put it AHEAD of a stated
|
|
656
|
+
// one (found-index 0) rather than behind every stated window — map it to Infinity first.
|
|
657
|
+
const resorted = windows
|
|
658
|
+
.map((c, i) => { const k = rankOf(c); return { c, k: k === -1 ? Infinity : k, i }; })
|
|
659
|
+
.sort((a, b) => (a.k - b.k) || (a.i - b.i))
|
|
660
|
+
.map(({ c }) => c);
|
|
661
|
+
windows.length = 0;
|
|
662
|
+
windows.push(...resorted);
|
|
663
|
+
unreached = collapsed.filter((hit) => !windows.some(({ w }) => covers(w, hit.line))).length;
|
|
664
|
+
}
|
|
665
|
+
|
|
586
666
|
// HC-GF-288: which stated lines no kept window contains. Only the budget can do that
|
|
587
667
|
// here — every stated line seeded a window, and a merge never shrinks one.
|
|
588
668
|
const lost = stated.filter((l) => !windows.some(({ w }) => covers(w, l)));
|
|
@@ -600,6 +680,11 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
600
680
|
: '')
|
|
601
681
|
+ (lost.length > 0
|
|
602
682
|
? ` — stated line(s) ${lost.join(', ')} DROPPED for budget; name fewer regions in ${filePath}`
|
|
683
|
+
: '')
|
|
684
|
+
// HC-GF-206-followup-1: a hit collapsed into a neighbour whose final window still missed
|
|
685
|
+
// it — counted rather than silent, the same shape as `dropped` and `lost` above.
|
|
686
|
+
+ (unreached > 0
|
|
687
|
+
? ` — ${unreached} near-duplicate hit(s) collapsed into a window that did not reach them`
|
|
603
688
|
: '');
|
|
604
689
|
|
|
605
690
|
return {
|
|
@@ -613,12 +698,16 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
613
698
|
};
|
|
614
699
|
}
|
|
615
700
|
|
|
616
|
-
|
|
701
|
+
// HC-GF-188-followup-1: the marker is inside the cap, as a window's header and footer are.
|
|
702
|
+
// Appended after `limit` chars, it took cli/hone-cli.js to 8,077 chars at cap 8,000. It
|
|
703
|
+
// names the chars KEPT, which is at most `limit`, so its own length can only shrink.
|
|
704
|
+
const marker = (n) => `\n\n# [HC-019n-followup-12: truncated at ${n} chars; full file is ${text.length} chars]`;
|
|
705
|
+
const keep = Math.max(0, limit - marker(limit).length);
|
|
706
|
+
const dropped = text.length - keep;
|
|
617
707
|
return {
|
|
618
708
|
mode: 'truncate',
|
|
619
709
|
unhonoured,
|
|
620
|
-
content: text.slice(0,
|
|
621
|
-
+ `\n\n# [HC-019n-followup-12: truncated at ${limit} chars; full file is ${text.length} chars]`,
|
|
710
|
+
content: text.slice(0, keep) + marker(keep),
|
|
622
711
|
notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
|
|
623
712
|
+ `DROPPED and nothing in the story text matched inside the file, so the region you care about `
|
|
624
713
|
+ `may not be in the bundle. Name it as ${filePath}:<line>, or quote the code, to anchor it.`,
|
package/lib/emit-pr.js
CHANGED
|
@@ -188,7 +188,35 @@ function decidePublish({ branchExists, openPrUrl }) {
|
|
|
188
188
|
return { action: 'proceed', exitCode: EXIT.PASS };
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* HC-GF-302: tell the server which PR this emit opened or found already open, so the batch's
|
|
193
|
+
* morning report links it instead of hoping an agent wrote the URL into its output.
|
|
194
|
+
*
|
|
195
|
+
* Best-effort, and the caller never changes its exit code on the result: the PR exists either way.
|
|
196
|
+
* It only reports whether the server now knows about it. No PR URL (a dry run, or a push without
|
|
197
|
+
* --open-pr) records nothing: the report has no link to show for a branch. The attempt is the one
|
|
198
|
+
* the step GET resolved; without it the server cannot order records, so nothing is sent.
|
|
199
|
+
*
|
|
200
|
+
* @param {{ post: (path: string, body: object) => Promise<unknown>, path: string, prUrl?: string|null,
|
|
201
|
+
* branch: string, baseSha?: string|null, attempt?: number|null }} args
|
|
202
|
+
* @returns {Promise<{ recorded: boolean, reason?: string }>}
|
|
203
|
+
*/
|
|
204
|
+
async function recordPr({ post, path, prUrl, branch, baseSha, attempt }) {
|
|
205
|
+
if (!prUrl) return { recorded: false, reason: 'no PR URL to record' };
|
|
206
|
+
if (!Number.isInteger(attempt) || attempt < 1) {
|
|
207
|
+
return { recorded: false, reason: 'the server did not say which step_4 attempt this is' };
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await post(path, { branch, prUrl, baseSha: baseSha || null, attempt });
|
|
211
|
+
return { recorded: true };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const detail = (e && e.response && e.response.data && e.response.data.error) || (e && (e.code || e.message)) || String(e);
|
|
214
|
+
return { recorded: false, reason: e && e.response ? `HTTP ${e.response.status}: ${detail}` : detail };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
191
218
|
module.exports = {
|
|
219
|
+
recordPr,
|
|
192
220
|
verifyStateFor,
|
|
193
221
|
EXIT,
|
|
194
222
|
HONEST_LIMIT,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HC-GF-326: request options that open a NEW connection for one request.
|
|
5
|
+
*
|
|
6
|
+
* Node's default agent keeps sockets alive. verify-patch GETs the run, then runs the
|
|
7
|
+
* test suite with spawnSync, which blocks the event loop for minutes — far past the
|
|
8
|
+
* server's keep-alive timeout (5 s by default). The server closes the idle socket, the
|
|
9
|
+
* blocked client never processes the close, and the verdict POST is written to the dead
|
|
10
|
+
* pooled socket: `read ECONNRESET`, 5 reports out of 5, each labelled non-fatal. Every
|
|
11
|
+
* verdict a verify produced was lost to the server.
|
|
12
|
+
*
|
|
13
|
+
* Pass these to the one request that follows a long synchronous block. What makes the
|
|
14
|
+
* connection fresh is the new agent: its pool is empty, so the request opens a socket.
|
|
15
|
+
* `keepAlive: false` only stops that socket being pooled afterwards.
|
|
16
|
+
*/
|
|
17
|
+
const http = require('http');
|
|
18
|
+
const https = require('https');
|
|
19
|
+
|
|
20
|
+
function freshConnection() {
|
|
21
|
+
return {
|
|
22
|
+
httpAgent: new http.Agent({ keepAlive: false }),
|
|
23
|
+
httpsAgent: new https.Agent({ keepAlive: false }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { freshConnection };
|
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
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HC-LC-004 banner, with a third state (HC-GF-328).
|
|
5
|
+
*
|
|
6
|
+
* `health` is the parsed `/health` body, or null when the server did not answer. The
|
|
7
|
+
* banner used to print PROD / THIS RUN COSTS MONEY for null, so an unreachable
|
|
8
|
+
* 127.0.0.1 was announced as a paid production run, and the run then failed with
|
|
9
|
+
* ECONNREFUSED. A server that did not answer is named as such: nothing about its cost
|
|
10
|
+
* or provider is known, so the line keeps HC-LC-004's safe default: assume it costs money.
|
|
11
|
+
*/
|
|
12
|
+
function serverBanner(apiUrl, health) {
|
|
13
|
+
if (!health) return `UNREACHABLE ${apiUrl} · /health did not answer · provider unknown · if this run starts, assume it COSTS MONEY`;
|
|
14
|
+
const llm = health.llm || null;
|
|
15
|
+
if (llm && llm.local) return `LOCAL ${apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`;
|
|
16
|
+
return `PROD ${apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { serverBanner };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HC-GF-320: what `hone setup --dry-run` would do AFTER the setup script.
|
|
5
|
+
*
|
|
6
|
+
* The script honours --dry-run itself; the CLI phases that follow it did not.
|
|
7
|
+
* A dry run installed CLAUDE.md, rewrote an existing .pipeline-config.yml
|
|
8
|
+
* (platform grounding), installed test packages (--install-tests) and PUT
|
|
9
|
+
* branch protection on the default branch through the GitHub API. The CLI
|
|
10
|
+
* now stops after the script on a dry run and prints these lines instead.
|
|
11
|
+
*
|
|
12
|
+
* Pure: every fact is passed in, so the plan is testable without a server.
|
|
13
|
+
*/
|
|
14
|
+
function postScriptDryRunPlan({ claudeMdTemplate, claudeMdExists, configExists, installTests, skipBranchProtection }) {
|
|
15
|
+
const lines = [];
|
|
16
|
+
if (claudeMdTemplate) {
|
|
17
|
+
lines.push(claudeMdExists
|
|
18
|
+
? 'CLAUDE.md already exists — would skip it'
|
|
19
|
+
: 'Would install CLAUDE.md (Claude Code pipeline context)');
|
|
20
|
+
}
|
|
21
|
+
if (configExists) lines.push('Would re-run platform grounding on .github/.pipeline-config.yml');
|
|
22
|
+
if (installTests) lines.push('Would install a unit test framework (--install-tests)');
|
|
23
|
+
lines.push(skipBranchProtection
|
|
24
|
+
? 'Would skip branch protection (opted out)'
|
|
25
|
+
: 'Would install GitHub branch protection on the default branch (pass --no-branch-protection to skip)');
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { postScriptDryRunPlan };
|