@hone-ai/cli 1.35.1 → 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 +78 -14
- package/lib/bundle-paths.js +103 -14
- package/lib/emit-pr.js +28 -0
- package/lib/fresh-connection.js +27 -0
- 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
|
@@ -377,7 +377,7 @@ program
|
|
|
377
377
|
verdict: anyFailed ? 'fail' : 'pass',
|
|
378
378
|
exitCode: anyFailed ? 1 : 0,
|
|
379
379
|
detail: `agent-eval ${opts.category}: ${results.filter((r) => r.passed).length}/${results.length} probes passed`,
|
|
380
|
-
});
|
|
380
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
381
381
|
if (opts.output !== 'json') console.log(` → reported agent-eval verdict '${anyFailed ? 'fail' : 'pass'}' to run ${opts.workflowId} (advisory)`);
|
|
382
382
|
} catch (e) {
|
|
383
383
|
if (opts.output !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
@@ -928,6 +928,9 @@ program
|
|
|
928
928
|
if (ciGateChoice.source !== 'auto-detect') {
|
|
929
929
|
setupEnv.CI_GATE = ciGateChoice.mode;
|
|
930
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();
|
|
931
934
|
try {
|
|
932
935
|
execSync(`bash "${scriptPath}" ${flags}`, {
|
|
933
936
|
stdio: 'inherit',
|
|
@@ -939,6 +942,25 @@ program
|
|
|
939
942
|
process.exit(1);
|
|
940
943
|
}
|
|
941
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
|
+
|
|
942
964
|
// HC-RC-004: if the adopter chose `local`, run the setup-local-ci
|
|
943
965
|
// scaffold inline so they end the setup command with a working
|
|
944
966
|
// local-CI stack (Makefile + compose) instead of having to know
|
|
@@ -1564,6 +1586,10 @@ async function createTestEnv(repoRoot, opts) {
|
|
|
1564
1586
|
function recommendTestFramework(repoRoot) {
|
|
1565
1587
|
const detected = detectExistingTestFramework(repoRoot);
|
|
1566
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;
|
|
1567
1593
|
|
|
1568
1594
|
const pkgs = getInstallPackages(detected.stack);
|
|
1569
1595
|
const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
|
|
@@ -6167,12 +6193,7 @@ program
|
|
|
6167
6193
|
// the 2026-09-08 editor test nearly meaningless; here it costs money rather than time.
|
|
6168
6194
|
try {
|
|
6169
6195
|
const h = await client.get('/health').then(r => r.data).catch(() => null);
|
|
6170
|
-
|
|
6171
|
-
if (llm && llm.local) {
|
|
6172
|
-
console.log(`LOCAL ${config.apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`);
|
|
6173
|
-
} else {
|
|
6174
|
-
console.log(`PROD ${config.apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`);
|
|
6175
|
-
}
|
|
6196
|
+
console.log(require('./lib/server-banner').serverBanner(config.apiUrl, h));
|
|
6176
6197
|
} catch { /* never block a run on a banner */ }
|
|
6177
6198
|
|
|
6178
6199
|
const { resolveRunTarget, isRunId } = require('./lib/run-target');
|
|
@@ -8286,7 +8307,7 @@ program
|
|
|
8286
8307
|
attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
|
|
8287
8308
|
headSha,
|
|
8288
8309
|
gates: reportGates.length ? reportGates : undefined,
|
|
8289
|
-
});
|
|
8310
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
8290
8311
|
if (opts.format !== 'json') {
|
|
8291
8312
|
// HC-GF-070: name the gate that is actually configured. This said "(advisory)"
|
|
8292
8313
|
// unconditionally, and printed it on a run where `enforce` was auto-approving
|
|
@@ -8451,12 +8472,26 @@ program
|
|
|
8451
8472
|
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
|
|
8452
8473
|
const { verifyStateFor } = require('./lib/emit-pr');
|
|
8453
8474
|
const {
|
|
8454
|
-
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
8475
|
+
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish, recordPr,
|
|
8455
8476
|
} = require('./lib/emit-pr');
|
|
8456
8477
|
const { gitEnv } = require('./lib/git-env');
|
|
8457
8478
|
const config = getConfig();
|
|
8458
8479
|
const client = api(config);
|
|
8459
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}`);
|
|
8460
8495
|
|
|
8461
8496
|
// --open-pr implies --push (cannot open a PR against an unpushed branch).
|
|
8462
8497
|
const doPush = Boolean(opts.push || opts.openPr);
|
|
@@ -8477,12 +8512,16 @@ program
|
|
|
8477
8512
|
// branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
|
|
8478
8513
|
// never arrived.
|
|
8479
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;
|
|
8480
8518
|
try {
|
|
8481
8519
|
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
8482
8520
|
const r = await client.get(url);
|
|
8483
8521
|
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
8484
8522
|
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
8485
8523
|
output = r.data?.output;
|
|
8524
|
+
stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
|
|
8486
8525
|
try {
|
|
8487
8526
|
const runR = await client.get(orchestratePath([workflowId]));
|
|
8488
8527
|
// HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
|
|
@@ -8587,8 +8626,24 @@ program
|
|
|
8587
8626
|
}
|
|
8588
8627
|
const decision = decidePublish({ branchExists, openPrUrl });
|
|
8589
8628
|
if (decision.action === 'already_open') {
|
|
8590
|
-
|
|
8591
|
-
|
|
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)}`);
|
|
8592
8647
|
process.exit(EXIT.PASS);
|
|
8593
8648
|
}
|
|
8594
8649
|
if (decision.action === 'branch_conflict') {
|
|
@@ -8761,8 +8816,13 @@ program
|
|
|
8761
8816
|
|
|
8762
8817
|
if (exitCode === EXIT.PASS) {
|
|
8763
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;
|
|
8764
8824
|
const human = prUrl
|
|
8765
|
-
? `✓ draft PR opened: ${prUrl}`
|
|
8825
|
+
? `✓ draft PR opened: ${prUrl}${recordWarning(rec)}`
|
|
8766
8826
|
: pushed
|
|
8767
8827
|
? `✓ branch pushed to origin/${branch}\n → open a PR: gh pr create --head ${branch} --draft`
|
|
8768
8828
|
: `✓ branch '${branch}' built locally (dry run — nothing pushed)\n` +
|
|
@@ -8770,7 +8830,11 @@ program
|
|
|
8770
8830
|
` → push it: hone emit-pr ${workflowId} --push\n` +
|
|
8771
8831
|
` → push + PR: hone emit-pr ${workflowId} --open-pr\n` +
|
|
8772
8832
|
` → discard it: git branch -D ${branch}`;
|
|
8773
|
-
|
|
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);
|
|
8774
8838
|
}
|
|
8775
8839
|
}
|
|
8776
8840
|
}
|
|
@@ -8912,7 +8976,7 @@ program
|
|
|
8912
8976
|
verdict: verdict.verdict,
|
|
8913
8977
|
exitCode: verdict.exitCode,
|
|
8914
8978
|
detail: verdict.detail,
|
|
8915
|
-
});
|
|
8979
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
8916
8980
|
reported = true;
|
|
8917
8981
|
if (opts.format !== 'json') console.log(` → reported CI-gate verdict '${verdict.verdict}' to run ${opts.workflowId} (advisory)`);
|
|
8918
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 };
|
|
@@ -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 };
|