@hone-ai/cli 1.39.0 → 1.41.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 +174 -53
- package/lib/anthropic-response.js +58 -0
- package/lib/architect-config.js +5 -0
- package/lib/batch-follow.js +253 -16
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-limits.js +10 -0
- package/lib/bundle-paths.js +20 -3
- package/lib/call-sites.js +209 -26
- package/lib/context-reserve.js +11 -8
- package/lib/judge-provider.js +20 -11
- package/lib/operator-question.js +9 -4
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/review-diff.js +188 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/run-context.js +18 -41
- package/lib/run-report.js +94 -4
- package/lib/security-scanner.js +7 -4
- package/lib/tree-artifact.js +36 -3
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
package/lib/run-context.js
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
const { composeStoryDescription } = require('./story-compose');
|
|
28
|
-
const { extractStoryIdFromBranch } = require('./pipeline-status');
|
|
29
28
|
const {
|
|
30
29
|
extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
|
|
31
30
|
} = require('./bundle-paths');
|
|
@@ -37,7 +36,6 @@ const { classifyStory } = require('./story-classifier');
|
|
|
37
36
|
const {
|
|
38
37
|
extractType, extractSecurityImplications, extractCrossRepoDependency,
|
|
39
38
|
} = require('./story-classifier-extract');
|
|
40
|
-
const { readArchitectConfigFromText } = require('./architect-config');
|
|
41
39
|
const path = require('path');
|
|
42
40
|
|
|
43
41
|
/**
|
|
@@ -49,13 +47,16 @@ const path = require('path');
|
|
|
49
47
|
*/
|
|
50
48
|
const SERVER_OWNED_KEYS = Object.freeze([
|
|
51
49
|
'batchTokenBudget', 'batchFailureThreshold', 'dependsOnRunIds', 'monthlyTokenBudget', 'overnight',
|
|
50
|
+
// HC-GF-030: whether a story was consulted is what the server's step_0b recorded, never what a
|
|
51
|
+
// client says. A client that could send these true would skip the consult.
|
|
52
|
+
'architect_consulted', 'checklist_b_completed', 'architect_consult',
|
|
52
53
|
]);
|
|
53
54
|
|
|
54
55
|
// HC-019n-followup-13d: the bundle caps. OptionsFlow #96 hit 49,783 of the old 50,000 with six
|
|
55
56
|
// files, so the next slightly larger story silently dropped one. 30 / 150K / 25K.
|
|
56
57
|
const BUNDLE_MAX_FILES = 30;
|
|
57
58
|
const BUNDLE_MAX_TOTAL_CHARS = 150_000;
|
|
58
|
-
const BUNDLE_MAX_PER_FILE_CHARS =
|
|
59
|
+
const { BUNDLE_MAX_PER_FILE_CHARS } = require('./bundle-limits'); // HC-GF-390: shared with review-diff.js
|
|
59
60
|
|
|
60
61
|
const info = (text) => ({ level: 'info', text });
|
|
61
62
|
const warn = (text) => ({ level: 'warn', text });
|
|
@@ -158,7 +159,6 @@ async function issueStorySource(io, issueNumber, issue) {
|
|
|
158
159
|
* @param {() => string} io.e2eSpecMode
|
|
159
160
|
* @param {() => string} io.verificationGate
|
|
160
161
|
* @param {() => string[]} io.verificationGateSources
|
|
161
|
-
* @param {() => string} io.readPlanText EXECUTION_PLAN.yml text, '' when absent
|
|
162
162
|
* @param {{ e2eSpecs?: boolean, skipE2eSpecs?: boolean, treeByteCap?: number }} [opts] run-story's per-run
|
|
163
163
|
* flags; `treeByteCap` is queue-stories' byte cap on the tree's path lists (none for run-story)
|
|
164
164
|
*/
|
|
@@ -295,13 +295,7 @@ function resolveRepoContext(io, opts = {}) {
|
|
|
295
295
|
config.verification_gate_sources = ['verify-patch'];
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
|
|
299
|
-
let planText = '';
|
|
300
|
-
try { planText = io.readPlanText() || ''; } catch (e) {
|
|
301
|
-
notes.push(warn(` ⚠ EXECUTION_PLAN.yml read failed (non-fatal, architect flags default to {false, false}): ${firstLine(e)}`));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return { config, notes, repoFiles, defaultIncludePaths, planText, execution };
|
|
298
|
+
return { config, notes, repoFiles, defaultIncludePaths, execution };
|
|
305
299
|
}
|
|
306
300
|
|
|
307
301
|
/**
|
|
@@ -324,8 +318,8 @@ function resolveRepoContext(io, opts = {}) {
|
|
|
324
318
|
* @param {object} repoCtx resolveRepoContext's result
|
|
325
319
|
* @param {string} storyId the id as the operator typed it — sent as `storyId`
|
|
326
320
|
* @param {{ explicitText?: string|null, explicitLabel?: string, explicitSource?: {kind: 'file'|'description', label?: string},
|
|
327
|
-
* includePaths?: string[], axes?: string }} [opts]
|
|
328
|
-
* @returns {Promise<{ storyId: string,
|
|
321
|
+
* includePaths?: string[], axes?: string, consultArchitect?: boolean }} [opts]
|
|
322
|
+
* @returns {Promise<{ storyId: string, rawText: string,
|
|
329
323
|
* textSource: 'explicit'|'issue'|'roadmap'|null, textFailure: {kind:string, detail?:string}|null,
|
|
330
324
|
* config: object, bundle: {files:number, chars:number}, notes: {pre: object[], post: object[]} }>}
|
|
331
325
|
*/
|
|
@@ -336,7 +330,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
336
330
|
let rawText = '';
|
|
337
331
|
let textSource = null;
|
|
338
332
|
let textFailure = null;
|
|
339
|
-
let resolvedStoryId = storyId; // canonical id for the architect-config lookup
|
|
340
333
|
|
|
341
334
|
// ── Story text: explicit → GitHub issue → Hone roadmap ────────────────────────────────────
|
|
342
335
|
// HC-GF-010: an EXPLICIT story source short-circuits both lookups, so the operator can never
|
|
@@ -369,17 +362,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
369
362
|
const prov = await issueStorySource(io, storyId, issue);
|
|
370
363
|
config.story_source = prov.source;
|
|
371
364
|
pre.push(...prov.notes);
|
|
372
|
-
// HC-019b-followup-1 F1: the canonical story id from the issue title, or the architect
|
|
373
|
-
// lookup keys on '104' and silently misses every EXECUTION_PLAN.yml entry.
|
|
374
|
-
try {
|
|
375
|
-
const titleId = extractStoryIdFromBranch(issue.title);
|
|
376
|
-
if (titleId) {
|
|
377
|
-
resolvedStoryId = titleId;
|
|
378
|
-
pre.push(info(` → resolved issue #${storyId} → canonical story id '${titleId}' for architect-config lookup`));
|
|
379
|
-
} else {
|
|
380
|
-
pre.push(warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyId}' (likely silent miss)`));
|
|
381
|
-
}
|
|
382
|
-
} catch { /* extractor throws → fall back to the numeric id */ }
|
|
383
365
|
} catch (e) {
|
|
384
366
|
textFailure = { kind: 'issue-error', detail: firstLine(e) };
|
|
385
367
|
// HC-GF-270: stated rather than left absent, so the later pause is readable. Classified, not
|
|
@@ -392,7 +374,6 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
392
374
|
};
|
|
393
375
|
pre.push(warn(` ⚠ could not fetch GitHub issue context: ${firstLine(e)}`));
|
|
394
376
|
pre.push(warn(' → proceeding without story_description; step_0 may produce placeholder output'));
|
|
395
|
-
pre.push(warn(` ⚠ architect-config lookup will use '${storyId}' (likely silent miss)`));
|
|
396
377
|
}
|
|
397
378
|
} else {
|
|
398
379
|
// HC-019n-followup-18: a roadmap-sourced story. Only the SERVER knows what it says; only the
|
|
@@ -540,11 +521,14 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
540
521
|
path: candidate,
|
|
541
522
|
});
|
|
542
523
|
content = excerpt.content;
|
|
543
|
-
if (excerpt.notice) pre.push(warn(excerpt.notice));
|
|
544
|
-
for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
|
|
545
524
|
if (totalChars + content.length > BUNDLE_MAX_TOTAL_CHARS) { budgetSpent = true; break; }
|
|
546
525
|
totalChars += content.length;
|
|
547
526
|
codebaseFiles.push({ path: candidate, content });
|
|
527
|
+
// Only a file that made the bundle gets the "bundled lines A-B" notice: announced before the
|
|
528
|
+
// budget check, a dropped file printed it AND the NOT-bundled line below, and the operator
|
|
529
|
+
// read the first one (seen 2026-10-16 on server/scripts/seed-agent-prompts.js).
|
|
530
|
+
if (excerpt.notice) pre.push(warn(excerpt.notice));
|
|
531
|
+
for (const w of anchorWarnings(anchors, excerpt.unhonoured)) pre.push(warn(w));
|
|
548
532
|
}
|
|
549
533
|
// HC-GF-288: an operator-named entry whose file never reached the bundle (the budget ran
|
|
550
534
|
// out first, or it names a directory) vanished as silently as the anchor did. A refused
|
|
@@ -597,6 +581,11 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
597
581
|
}
|
|
598
582
|
}
|
|
599
583
|
|
|
584
|
+
// ── Architect consult on request (HC-GF-397) ─────────────────────────────────────────────
|
|
585
|
+
// --consult-architect: the server runs step_0b even on a `not_engaged` grooming. Sent only as `true`; the server reads
|
|
586
|
+
// nothing else as a request, and no value of it can skip a consult.
|
|
587
|
+
if (opts.consultArchitect === true) config.consult_architect = true;
|
|
588
|
+
|
|
600
589
|
// ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
|
|
601
590
|
// Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
|
|
602
591
|
// come from the groomer at step_0 and are harvested server-side. Fail-open.
|
|
@@ -621,19 +610,7 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
621
610
|
}
|
|
622
611
|
}
|
|
623
612
|
|
|
624
|
-
|
|
625
|
-
// workflow-dag.js's validateStepPreConditions BLOCKS step_1 when architect_consulted is true
|
|
626
|
-
// and checklist_b_completed is not; without these flags every architect-engaged story
|
|
627
|
-
// deadlocks. Keyed on the RESOLVED id (HC-019b-followup-1 F1).
|
|
628
|
-
const arch = readArchitectConfigFromText(repoCtx.planText || '', resolvedStoryId);
|
|
629
|
-
config.architect_consulted = arch.architect_consulted;
|
|
630
|
-
config.checklist_b_completed = arch.checklist_b_completed;
|
|
631
|
-
if (arch.diagnostic) post.push(warn(` ⚠ ${arch.diagnostic}`));
|
|
632
|
-
if (arch.architect_consulted) {
|
|
633
|
-
post.push(info(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`));
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
return { storyId, resolvedStoryId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
|
|
613
|
+
return { storyId, rawText, textSource, textFailure, config, bundle, notes: { pre, post } };
|
|
637
614
|
}
|
|
638
615
|
|
|
639
616
|
/**
|
package/lib/run-report.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
// HC-GF-234: the one wording for a step's unanswered question, shared with `--status`, `show
|
|
20
20
|
// step` and the human-gate prompt. A pure leaf module, so this file stays pure.
|
|
21
|
-
const { normalizeQuestion, questionLines, countPhrase } = require('./operator-question');
|
|
21
|
+
const { normalizeQuestion, questionLines, countPhrase, scrub } = require('./operator-question');
|
|
22
22
|
|
|
23
23
|
/** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
|
|
24
24
|
const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
|
|
@@ -69,6 +69,25 @@ function durationOf(startedAt, endedAt) {
|
|
|
69
69
|
const BLOCKING_REASON_CODES = Object.freeze(['MAX_RETRIES_EXCEEDED', 'MAX_TOTAL_STEPS_EXCEEDED']);
|
|
70
70
|
const BLOCKING_REASON_RE = new RegExp(`^(${BLOCKING_REASON_CODES.join('|')})[^\\n]*`);
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* HC-GF-030: the pause the architect-consult guard takes. NOT in BLOCKING_REASON_CODES, which is pinned equal to
|
|
74
|
+
* what orchestrator-guardrails.js emits (HC-GF-046b); this one is emitted by workflow-dag.js's
|
|
75
|
+
* validateStepPreConditions. Approving is refused for it too — by the route and by approveGate — so it is a decision
|
|
76
|
+
* and not a gate. The server package and this one cannot share a constant, so
|
|
77
|
+
* tests/regression/HC-GF-030-run-report.test.js pins this string equal to architect-consult.js CONSULT_REQUIRED_CODE.
|
|
78
|
+
*/
|
|
79
|
+
const CONSULT_REQUIRED_CODE = 'ARCHITECT_CONSULT_REQUIRED';
|
|
80
|
+
const CONSULT_REQUIRED_RE = new RegExp(`^${CONSULT_REQUIRED_CODE}:[^\\n]*`);
|
|
81
|
+
/** The start of the message a batch run pauses with when its consult failed the mechanical check (orchestrator.js). */
|
|
82
|
+
const CONSULT_NEEDS_PERSON_RE = /^architect consult needs a person to read it[^\n]*/;
|
|
83
|
+
/**
|
|
84
|
+
* The pause a consult whose OWN verdict is `checklist_b_completed: false` takes (workflow-dag.js FAILURE_ROUTING_TABLE.step_0b.step_0b_abort).
|
|
85
|
+
* Approving it is refused (an `_abort` needs an acknowledgement, and even then nothing records a false verdict), so offering
|
|
86
|
+
* `--approve` here is HC-GF-046 again: the line invites the one action the server has already decided to refuse.
|
|
87
|
+
* tests/regression/HC-GF-030-run-report.test.js pins this prefix to the server's message.
|
|
88
|
+
*/
|
|
89
|
+
const CONSULT_ABORT_RE = /^step_0b: the architect consult did not complete Checklist B[^\n]*/;
|
|
90
|
+
|
|
72
91
|
/**
|
|
73
92
|
* HC-GF-046(b), corrected in review: a step that used up its format retries.
|
|
74
93
|
*
|
|
@@ -107,7 +126,28 @@ function buildReport(run, extra = {}) {
|
|
|
107
126
|
// untrustworthy in the other direction.
|
|
108
127
|
const blocked = String(run.errorMessage || '').match(BLOCKING_REASON_RE);
|
|
109
128
|
const exhausted = String(run.errorMessage || '').match(EXHAUSTED_RE);
|
|
110
|
-
|
|
129
|
+
const consultRequired = String(run.errorMessage || '').match(CONSULT_REQUIRED_RE);
|
|
130
|
+
const consultNeedsPerson = String(run.errorMessage || '').match(CONSULT_NEEDS_PERSON_RE);
|
|
131
|
+
const consultAbort = String(run.errorMessage || '').match(CONSULT_ABORT_RE);
|
|
132
|
+
if (consultRequired) {
|
|
133
|
+
needsYou.push({
|
|
134
|
+
what: `run CANNOT proceed at ${run.currentStep} — ${scrub(consultRequired[0].slice(0, 400))}. There is no waiver and `
|
|
135
|
+
+ 'approving is refused, so this is a decision, not a gate',
|
|
136
|
+
how: `hone run-story ${run.runId} --kill (then re-run the story: step_0 is cached, so it costs one consult)`,
|
|
137
|
+
});
|
|
138
|
+
} else if (consultNeedsPerson) {
|
|
139
|
+
needsYou.push({
|
|
140
|
+
what: `the architect consult is waiting for a person to read it — ${scrub(consultNeedsPerson[0].slice(0, 400))}`,
|
|
141
|
+
how: `hone show step ${run.runId} step_0b (then hone run-story ${run.runId} --approve step_0b once you have read it)`,
|
|
142
|
+
});
|
|
143
|
+
} else if (consultAbort) {
|
|
144
|
+
needsYou.push({
|
|
145
|
+
what: `the architect consult did not pass at ${run.currentStep} — ${scrub(consultAbort[0].slice(0, 400))}. Approving is refused, `
|
|
146
|
+
+ 'so this is a decision, not a gate',
|
|
147
|
+
how: `hone show step ${run.runId} step_0b (read what it flagged, change the story to answer it, `
|
|
148
|
+
+ `then hone run-story ${run.runId} --kill and run the story again)`,
|
|
149
|
+
});
|
|
150
|
+
} else if (run.approvalRefusal) {
|
|
111
151
|
// HC-GF-074: rendered from the server's own decision, never re-derived from verdict records here.
|
|
112
152
|
needsYou.push({
|
|
113
153
|
what: `run CANNOT be approved at ${run.currentStep} as it stands — ${String(run.approvalRefusal.message).slice(0, 300)}`,
|
|
@@ -430,6 +470,26 @@ function buildReport(run, extra = {}) {
|
|
|
430
470
|
worthKnowing.push(`${s.stepKey} ran ${s.attempts} times`);
|
|
431
471
|
}
|
|
432
472
|
}
|
|
473
|
+
// HC-GF-030: a run that finished with an architect classified as needed and no consult recorded. The consult
|
|
474
|
+
// guard makes this unreachable for a run the server drove; it is said here because "no run records
|
|
475
|
+
// `architect_engaged: true` with no consult" is the acceptance, and a report is where that gets checked.
|
|
476
|
+
const arch = run.architect && typeof run.architect === 'object' ? run.architect : null;
|
|
477
|
+
// HC-GF-397-followup-1: `hone run-story --consult-architect` asks for a consult on a `not_engaged` grooming. Read the way
|
|
478
|
+
// the server reads it — the boolean `true` only — so a requested consult that is not approved is never "not needed".
|
|
479
|
+
const consultRequested = cfg.consult_architect === true;
|
|
480
|
+
if (arch && (arch.verdict !== 'not_engaged' || consultRequested) && !arch.consulted && run.status === 'completed') {
|
|
481
|
+
const why = arch.verdict === 'engaged' ? 'the architect classified as needed'
|
|
482
|
+
: arch.verdict === 'not_engaged' ? 'an architect consult REQUESTED with --consult-architect'
|
|
483
|
+
: 'the architect verdict unreadable';
|
|
484
|
+
needsYou.push({
|
|
485
|
+
what: `run COMPLETED with ${why} and NO approved consult`,
|
|
486
|
+
how: `hone show step ${run.runId} step_0b`,
|
|
487
|
+
// HC-026c: a new signal must not turn every run that finished BEFORE the consult step existed into a failing exit
|
|
488
|
+
// code (about 27 of 28 completed runs on the local server were engaged). Shown in full, counted in `needsYou`, and
|
|
489
|
+
// not gating: the consult guard is what stops a new run, and this line is for reading a run that predates it.
|
|
490
|
+
advisory: true,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
433
493
|
// The two "blind run" tells, both from HC-GF-006's preflight reasoning.
|
|
434
494
|
const descChars = typeof cfg.story_description === 'string' ? cfg.story_description.length : 0;
|
|
435
495
|
if (descChars === 0) {
|
|
@@ -459,9 +519,21 @@ function buildReport(run, extra = {}) {
|
|
|
459
519
|
duration: durationOf(run.startedAt, run.completedAt),
|
|
460
520
|
cost: extra.cost || null,
|
|
461
521
|
architectConsulted: cfg.architect_consulted === true,
|
|
522
|
+
// HC-GF-030: derived server-side from the step rows, never from config. null on a server that predates it.
|
|
523
|
+
architect: arch ? {
|
|
524
|
+
verdict: arch.verdict, consulted: arch.consulted === true, requested: consultRequested,
|
|
525
|
+
// A number or nothing: this is printed, and it came off the network.
|
|
526
|
+
consultAttempt: Number.isFinite(Number(arch.consultAttempt)) && Number(arch.consultAttempt) > 0 ? Math.trunc(Number(arch.consultAttempt)) : null,
|
|
527
|
+
blockers: Number(arch.blockers) || 0,
|
|
528
|
+
// Agent-written text going to a terminal: the server scrubs it, and this CLI may be talking to an older or different
|
|
529
|
+
// server, so it scrubs again where it is stored for printing (the rule cli/lib/operator-question.js already keeps).
|
|
530
|
+
findings: (Array.isArray(arch.findings) ? arch.findings : []).map(f => scrub(String(f).slice(0, 400))),
|
|
531
|
+
callSiteGaps: (Array.isArray(arch.callSiteGaps) ? arch.callSiteGaps : []).map(g => scrub(String(g).slice(0, 400))),
|
|
532
|
+
} : null,
|
|
462
533
|
storyDescriptionChars: descChars,
|
|
463
534
|
bundledFiles: bundled,
|
|
464
|
-
|
|
535
|
+
// The groomer's own words (the server harvests them from step_0's output), printed on a terminal: scrubbed, and strings only.
|
|
536
|
+
classifierAxes: Array.isArray(cfg.classifier_axes) ? cfg.classifier_axes.map(a => scrub(String(a).slice(0, 80))) : [],
|
|
465
537
|
needsYou,
|
|
466
538
|
worthKnowing,
|
|
467
539
|
steps,
|
|
@@ -530,6 +602,21 @@ function renderReport(r) {
|
|
|
530
602
|
L.push('');
|
|
531
603
|
}
|
|
532
604
|
|
|
605
|
+
if (r.architect) {
|
|
606
|
+
const a = r.architect;
|
|
607
|
+
L.push('ARCHITECT');
|
|
608
|
+
L.push(` ${a.verdict === 'engaged' ? 'engaged' : a.verdict === 'not_engaged' ? 'not engaged (step_0 said so, readably)' : 'verdict unreadable (the consult runs)'}`
|
|
609
|
+
+ `${r.classifierAxes.length ? ` · axes: ${r.classifierAxes.join(', ')}` : ''}`
|
|
610
|
+
// HC-GF-397-followup-1: the request is named only where it changed the outcome — an engaged or unreadable verdict
|
|
611
|
+
// runs the consult anyway, so its line stays as it was.
|
|
612
|
+
+ `${a.requested && a.verdict === 'not_engaged' ? ' · consult requested (--consult-architect)' : ''}`
|
|
613
|
+
+ ` · consult ${a.consulted ? `approved${a.consultAttempt ? ` (step_0b attempt ${a.consultAttempt})` : ''}` : a.verdict === 'not_engaged' && !a.requested ? 'not needed' : 'NOT approved'}`);
|
|
614
|
+
if (a.blockers > 0) L.push(` ${a.blockers} finding(s) marked BLOCKER`);
|
|
615
|
+
for (const f of a.findings) L.push(` · ${f}`);
|
|
616
|
+
for (const g of a.callSiteGaps) L.push(` ⚠ call sites the consult could not see: ${g}`);
|
|
617
|
+
L.push('');
|
|
618
|
+
}
|
|
619
|
+
|
|
533
620
|
L.push('STEPS');
|
|
534
621
|
for (const s of r.steps) {
|
|
535
622
|
const mark = s.status === 'completed' ? '✓' : s.status === 'skipped' ? '○' : '·';
|
|
@@ -540,8 +627,11 @@ function renderReport(r) {
|
|
|
540
627
|
L.push('');
|
|
541
628
|
|
|
542
629
|
L.push('RUN FACTS');
|
|
630
|
+
// HC-GF-030: the consult state comes from the server's step rows when it reports them (`architect`); the config flag is
|
|
631
|
+
// only a mirror, cleared by a new grooming, and is what an older server still reports.
|
|
632
|
+
const consultedFact = r.architect ? r.architect.consulted : r.architectConsulted;
|
|
543
633
|
L.push(` mode ${r.mode || 'unknown'} · story text ${r.storyDescriptionChars} chars · `
|
|
544
|
-
+ `${r.bundledFiles} file(s) bundled · architect_consulted ${
|
|
634
|
+
+ `${r.bundledFiles} file(s) bundled · architect_consulted ${consultedFact}`);
|
|
545
635
|
if (r.classifierAxes.length) L.push(` axes: ${r.classifierAxes.join(', ')}`);
|
|
546
636
|
if (r.verifications.length) {
|
|
547
637
|
for (const v of r.verifications) {
|
package/lib/security-scanner.js
CHANGED
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
const SECRET_PATTERNS = [
|
|
15
15
|
{
|
|
16
16
|
id: 'hardcoded-secret-apikey',
|
|
17
|
-
|
|
17
|
+
// HC-GF-364: `["']?` after the name reads a quoted key — JSON, a dict literal — as the served
|
|
18
|
+
// workflow's scan does since HC-GF-362.
|
|
19
|
+
pattern: /(?:api[_-]?key|apikey|api[_-]?secret)["']?\s*[:=]\s*["']([A-Za-z0-9/+=]{20,})["']/i,
|
|
18
20
|
severity: 'HIGH',
|
|
19
21
|
message: 'Hardcoded API key detected. Use environment variables instead.',
|
|
20
22
|
},
|
|
21
23
|
{
|
|
22
24
|
id: 'hardcoded-secret-password',
|
|
23
|
-
pattern: /(?:password|passwd|pwd|secret|token)
|
|
25
|
+
pattern: /(?:password|passwd|pwd|secret|token)["']?\s*[:=]\s*["']([A-Za-z0-9@#$%^&*!]{16,})["']/i,
|
|
24
26
|
severity: 'HIGH',
|
|
25
27
|
message: 'Hardcoded password/secret detected. Use environment variables or a vault.',
|
|
26
28
|
},
|
|
@@ -126,8 +128,9 @@ function scanFiles(opts = {}) {
|
|
|
126
128
|
|
|
127
129
|
// Skip env var references (not hardcoded)
|
|
128
130
|
if (/process\.env\.|os\.environ|System\.getenv|ENV\[/i.test(line)) continue;
|
|
129
|
-
// Skip comments
|
|
130
|
-
|
|
131
|
+
// Skip comments. `--` is a SQL comment; `-----BEGIN … PRIVATE KEY-----` is not (HC-GF-362: it was
|
|
132
|
+
// skipped as one, so the private-key rule never fired on a key at the start of a line).
|
|
133
|
+
if (/^\s*(\/\/|#|--(?!-)|\/\*|\*)/.test(line)) continue;
|
|
131
134
|
|
|
132
135
|
for (const rule of rules) {
|
|
133
136
|
if (rule.pattern.test(line)) {
|
package/lib/tree-artifact.js
CHANGED
|
@@ -18,14 +18,34 @@
|
|
|
18
18
|
* malformed[] unparsable block — DELIBERATELY NOT REACHABLE: every fence we emit is longer
|
|
19
19
|
* than any fence run inside the file it wraps (see below).
|
|
20
20
|
* noChanges the honest no-op — HANDLED: a clean tree says NO CHANGES rather than nothing.
|
|
21
|
+
* HC-GF-395: it says "matches HEAD" only when git reported no
|
|
22
|
+
* change at all (`!changed.length`). When git reported changes
|
|
23
|
+
* but none could be sent (`!included.length`), it says "NO
|
|
24
|
+
* CHANGES — the working tree differs from HEAD, but no changed
|
|
25
|
+
* file could be sent as content (N skipped; see ### Not
|
|
26
|
+
* included)", N counting named skips only (symlinks are silent).
|
|
21
27
|
*
|
|
22
|
-
* Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path
|
|
28
|
+
* Dependency-free by intent, like `cli/lib/headroom.js`: `git`, `fs` and `path`, plus the one
|
|
29
|
+
* classifier the bundler already uses — `isSecretPath` from `./bundle-context`, which itself
|
|
30
|
+
* requires only `path`.
|
|
31
|
+
*
|
|
32
|
+
* HC-GF-393: a path `isSecretPath()` classifies (`.env*`, `*.pem`, `id_rsa`-style keys, …) is
|
|
33
|
+
* never read. It is named under `### Not included`, so it is absent from the patch and the PR
|
|
34
|
+
* on purpose and visibly, not silently. A deleted one still renders as `DELETE:` (no content).
|
|
35
|
+
*
|
|
36
|
+
* HC-GF-394: a file that cannot be read is named under `### Not included` as `unreadable`. It used
|
|
37
|
+
* to throw, and the whole submission failed with it — every readable change included.
|
|
38
|
+
*
|
|
39
|
+
* HC-GF-390: for step_4 the submit also computes a review-only base diff of the over-cap files this
|
|
40
|
+
* render emitted whole (`cli/lib/review-diff.js`). It is sent as a separate `reviewDiff` field and
|
|
41
|
+
* stored apart; it is never part of this artifact, and nothing here changes because of it.
|
|
23
42
|
*/
|
|
24
43
|
|
|
25
44
|
const fs = require('node:fs');
|
|
26
45
|
const path = require('node:path');
|
|
27
46
|
const { execFileSync } = require('node:child_process');
|
|
28
47
|
const { gitEnv } = require('./git-env');
|
|
48
|
+
const { isSecretPath } = require('./bundle-context');
|
|
29
49
|
|
|
30
50
|
/** Bytes above which we do not attempt to inline a file at all. */
|
|
31
51
|
const MAX_FILE_BYTES = 1_000_000;
|
|
@@ -119,6 +139,9 @@ function renderFromTree({ repoRoot, testReport } = {}) {
|
|
|
119
139
|
const skipped = [];
|
|
120
140
|
|
|
121
141
|
for (const rel of changed) {
|
|
142
|
+
// First, before lstat or read: the bytes of a secret path never enter this process. The name
|
|
143
|
+
// is the signal, so a symlink or an oversized file with a secret name is listed as a secret.
|
|
144
|
+
if (isSecretPath(rel)) { skipped.push({ path: rel, reason: 'secret path — not sent' }); continue; }
|
|
122
145
|
const abs = path.join(repoRoot, rel);
|
|
123
146
|
let st;
|
|
124
147
|
try { st = fs.lstatSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
|
|
@@ -129,7 +152,8 @@ function renderFromTree({ repoRoot, testReport } = {}) {
|
|
|
129
152
|
if (st.isSymbolicLink() || !st.isFile()) continue;
|
|
130
153
|
if (st.size > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: `larger than ${MAX_FILE_BYTES} bytes` }); continue; }
|
|
131
154
|
|
|
132
|
-
|
|
155
|
+
let buf;
|
|
156
|
+
try { buf = fs.readFileSync(abs); } catch { skipped.push({ path: rel, reason: 'unreadable' }); continue; }
|
|
133
157
|
if (looksBinary(buf)) { skipped.push({ path: rel, reason: 'binary' }); continue; }
|
|
134
158
|
|
|
135
159
|
const content = buf.toString('utf8');
|
|
@@ -141,9 +165,18 @@ function renderFromTree({ repoRoot, testReport } = {}) {
|
|
|
141
165
|
|
|
142
166
|
const lines = ['## Changed Files', ''];
|
|
143
167
|
|
|
144
|
-
if (!
|
|
168
|
+
if (!changed.length && !deleted.length) {
|
|
145
169
|
// The honest no-op, in the shape the validator and the materialiser both already accept.
|
|
146
170
|
lines.push('NO CHANGES — the working tree matches HEAD.');
|
|
171
|
+
} else if (!included.length && !deleted.length) {
|
|
172
|
+
// HC-GF-395: still a no-op, but not a clean tree. One line with the same `NO CHANGES —` prefix,
|
|
173
|
+
// which every parser keys on; the rest of the line is the reason stored in the artifact (the
|
|
174
|
+
// CLI's own no-op messages are fixed text). Only a
|
|
175
|
+
// named skip has a list to point at; a symlink or non-regular file is skipped silently.
|
|
176
|
+
const why = skipped.length
|
|
177
|
+
? `${skipped.length} skipped; see ### Not included`
|
|
178
|
+
: 'symlinks and non-regular files are not sent';
|
|
179
|
+
lines.push(`NO CHANGES — the working tree differs from HEAD, but no changed file could be sent as content (${why}).`);
|
|
147
180
|
} else {
|
|
148
181
|
for (const b of blocks) { lines.push(b, ''); }
|
|
149
182
|
for (const rel of deleted) lines.push(`DELETE: ${rel}`);
|
package/lib/verify-patch.js
CHANGED
|
@@ -262,23 +262,97 @@ function verdictFromRun(run, provisioning = null) {
|
|
|
262
262
|
missing: absent.missing,
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
|
-
const sum = summarizeFailure(run);
|
|
265
|
+
const sum = summarizeFailure(run, { runner: true });
|
|
266
266
|
// HC-GF-023: lead with HOW MANY. "1 of 8 named" and "the only failure" are
|
|
267
267
|
// different situations that this line used to render identically.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
268
|
+
// HC-GF-363: and with WHICH, from the runner's own failure records. This detail is the only verification
|
|
269
|
+
// text a retried builder receives, so it names the failing tests, not lines that merely contain "Error".
|
|
270
|
+
// The wording never writes "<n> tests failed/failing": the failure router reads that as a test failure
|
|
271
|
+
// (FAIL_COUNT_RE in server/src/services/failure-router.js) and a builder that echoed it would be re-routed.
|
|
272
|
+
const head = `tests failed (exit ${run.code})`;
|
|
273
|
+
const first = sum.first ? `, first: ${routerSafe(sum.first)}` : '';
|
|
274
|
+
let detail;
|
|
275
|
+
if (sum.source === 'runner') {
|
|
276
|
+
const named = sum.tests.map((t) => routerSafe(t.slice(0, 160))).join('; ');
|
|
277
|
+
const more = sum.count > sum.tests.length ? `; +${sum.count - sum.tests.length} more` : '';
|
|
278
|
+
detail = `${head} — ${sum.count} failing ${sum.count === 1 ? 'test' : 'tests'} (runner records): ${named}${more}${first}`;
|
|
279
|
+
} else {
|
|
280
|
+
detail = head + (sum.count > 1 ? ` — ${sum.count} failure lines (pattern match, no runner records)` : '') + first;
|
|
281
|
+
}
|
|
282
|
+
return { verdict: 'fail', exitCode: EXIT.TESTS_FAILED, detail, failure: sum };
|
|
276
283
|
}
|
|
277
284
|
|
|
278
285
|
/** Lines that look like a runner reporting a failure. Deliberately broad — this
|
|
279
286
|
* counts EVIDENCE of failure across pytest/node/jest/go/cargo, not test cases. */
|
|
280
287
|
const FAILURE_LINE_RE = /(^|[\s│|])(FAILED|FAIL|ERROR|ERR!|not ok|✗|✖|×)\b|\w+Error\b|panic:|AssertionError/;
|
|
281
288
|
|
|
289
|
+
/**
|
|
290
|
+
* HC-GF-363 (review MED-1): adopter text (a test's name, a log line) rides inside the detail, and the detail can be
|
|
291
|
+
* echoed into step_4's output, which the server's failure router READS. A failing test named `reports 2 tests failed`
|
|
292
|
+
* would route as `test_fail`, and a linter line `3 errors, 0 warnings` as `lint_fail`. This quotes the phrases the
|
|
293
|
+
* router's step_4 and step_5 detectors match (a count before "tests failed/failing" or "errors", "lint failed",
|
|
294
|
+
* "eslint error:", the `[UNKNOWN … operator must supply]` blocker, a `REVIEW SUMMARY:` verdict) so none can match;
|
|
295
|
+
* the words stay readable. Idempotent. tests/regression/HC-GF-363-auto-reject.test.js runs the REAL detectFailure.
|
|
296
|
+
*/
|
|
297
|
+
function routerSafe(text) {
|
|
298
|
+
return String(text == null ? '' : text)
|
|
299
|
+
.replace(/(\d+)(\s+)(tests?)(\s+)(failing|failed)\b/gi, '$1$2"$3"$4$5')
|
|
300
|
+
.replace(/(\d+)(\s+)(errors?)\b/gi, '$1$2"$3"')
|
|
301
|
+
.replace(/\b(lint(?:ing)?)(\s+)(failed)\b/gi, '$1$2"$3"')
|
|
302
|
+
.replace(/\b(eslint|ruff|mypy)(\s+)(error)(\s*:)/gi, '$1$2"$3"$4')
|
|
303
|
+
.replace(/\[UNKNOWN\b/gi, '[ UNKNOWN')
|
|
304
|
+
.replace(/REVIEW(\s+)SUMMARY:/gi, 'REVIEW$1"SUMMARY":')
|
|
305
|
+
.replace(/##(\s+)Abort(\s+)Reason:/gi, '# #$1Abort$2Reason:');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* HC-GF-363 — the failure records a test RUNNER prints, anchored at the start of the line. A line that merely
|
|
310
|
+
* contains "Error" or "FAIL" in a test's name (`✔ parseGhError recognizes …`, `ok 7 - … FAIL …`) is a PASS record
|
|
311
|
+
* and matches none of these: on the pilot's log 118 of the 124 lines FAILURE_LINE_RE flagged were passes.
|
|
312
|
+
* Each returns the failing test's name, or null when the line is not that runner's failure record.
|
|
313
|
+
*/
|
|
314
|
+
const RUNNER_RECORDS = Object.freeze([
|
|
315
|
+
// TAP (node --test, tap, prove): `not ok 3 - name`. A `# TODO` / `# SKIP` directive is not a failure.
|
|
316
|
+
['tap', (l) => {
|
|
317
|
+
const m = l.match(/^\s*not ok\b(.*)$/);
|
|
318
|
+
if (!m || /#\s*(?:TODO|SKIP)\b/i.test(m[1])) return null;
|
|
319
|
+
return m[1].replace(/^\s*\d*\s*(?:-\s*)?/, '').trim() || 'not ok';
|
|
320
|
+
}],
|
|
321
|
+
// node:test spec reporter: `✖ name (12ms)` (U+2716), also printed again in its `✖ failing tests:` recap. eslint's
|
|
322
|
+
// own `✖ 3 problems` summary is not a test.
|
|
323
|
+
['spec', (l) => {
|
|
324
|
+
const m = l.match(/^\s*\u2716\s+(.*)$/);
|
|
325
|
+
if (!m || /^failing tests\b/i.test(m[1]) || /^\d+\s+problems?\b/i.test(m[1]) || /#\s*(?:TODO|SKIP)\b/i.test(m[1])) return null;
|
|
326
|
+
return m[1].replace(/\s*\(\d+(?:\.\d+)?\s*ms\)\s*$/, '').trim() || null;
|
|
327
|
+
}],
|
|
328
|
+
// jest: `✕ name (3 ms)` (U+2715)
|
|
329
|
+
['jest', (l) => {
|
|
330
|
+
const m = l.match(/^\s*\u2715\s+(.*)$/);
|
|
331
|
+
return m ? (m[1].replace(/\s*\(\d+(?:\.\d+)?\s*ms\)\s*$/, '').trim() || null) : null;
|
|
332
|
+
}],
|
|
333
|
+
// pytest: the short summary `FAILED path::test - reason` / `ERROR path::test`, and verbose `path::test FAILED`
|
|
334
|
+
['pytest', (l) => {
|
|
335
|
+
const m = l.match(/^(?:FAILED|ERROR)\s+(\S+\.py::\S*)/) || l.match(/^(\S+\.py::\S+)\s+FAILED\b/);
|
|
336
|
+
return m ? m[1] : null;
|
|
337
|
+
}],
|
|
338
|
+
// go test: `--- FAIL: TestX (0.00s)` (subtests are indented)
|
|
339
|
+
['go', (l) => { const m = l.match(/^\s*--- FAIL:\s+(\S+)/); return m ? m[1] : null; }],
|
|
340
|
+
// cargo test: `test path::name ... FAILED`
|
|
341
|
+
['cargo', (l) => { const m = l.match(/^test\s+(\S+)\s+\.\.\.\s+FAILED$/); return m ? m[1] : null; }],
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
/** The failing test a runner record names, or null. Pure. */
|
|
345
|
+
function runnerFailure(line) {
|
|
346
|
+
for (const [, read] of RUNNER_RECORDS) {
|
|
347
|
+
const name = read(line);
|
|
348
|
+
if (name) return name;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A line the runner prints for a test that did NOT fail (or an announcement, or a TODO/SKIP): never "the first failure". */
|
|
354
|
+
const RUNNER_NON_FAILURE_RE = /^\s*(?:ok\b|not ok\b[^\n]*#\s*(?:TODO|SKIP)\b|#\s*Subtest:|[\u2714\u2713]\s|--- (?:PASS|SKIP):|=== (?:RUN|PAUSE|CONT)\b|test \S+ \.\.\. ok$|\S+::\S+\s+PASSED\b|PASSED\s)/;
|
|
355
|
+
|
|
282
356
|
/**
|
|
283
357
|
* HC-GF-023 — summarise a failed run instead of quoting one line of it.
|
|
284
358
|
*
|
|
@@ -290,19 +364,52 @@ const FAILURE_LINE_RE = /(^|[\s│|])(FAILED|FAIL|ERROR|ERR!|not ok|✗|✖|×)\
|
|
|
290
364
|
* re-run. A one-line quote of a multi-failure run is not a summary; it is a
|
|
291
365
|
* sample presented as a conclusion.
|
|
292
366
|
*
|
|
367
|
+
* HC-GF-363: `runner: true` (verdictFromRun only) counts and names the runner's own failure records first
|
|
368
|
+
* (RUNNER_RECORDS) and says which it used. `first` is then the first broad-pattern line that is not a runner PASS
|
|
369
|
+
* record, so a SyntaxError still leads the FAILED names (HC-GF-023) and a passing test never does. With no runner
|
|
370
|
+
* record, or without the option (classifyGateRun: a linter prints `✖ N problems`, which is not a test), it is the
|
|
371
|
+
* broad pattern exactly as before.
|
|
372
|
+
*
|
|
293
373
|
* Pure: returns the count, the FIRST failure (usually nearer the cause than the
|
|
294
374
|
* last) and a tail excerpt. Writing the full log is IO and stays in the CLI.
|
|
295
375
|
*
|
|
296
|
-
* @returns {{ count: number, first: string, excerpt: string[] }}
|
|
376
|
+
* @returns {{ count: number, first: string, excerpt: string[], tests: string[], source: 'runner'|'pattern' }}
|
|
377
|
+
* `tests`: up to 10 failing test names (200 chars each) from runner records, in order, repeats removed;
|
|
378
|
+
* `count`: the number of distinct runner records (source 'runner') or of broad-pattern lines ('pattern')
|
|
297
379
|
*/
|
|
298
|
-
function summarizeFailure({ stdout = '', stderr = '' }, { maxLines = 12 } = {}) {
|
|
380
|
+
function summarizeFailure({ stdout = '', stderr = '' }, { maxLines = 12, runner = false } = {}) {
|
|
299
381
|
const lines = `${stdout}\n${stderr}`.split('\n').map(l => l.replace(/\s+$/, ''));
|
|
300
382
|
const nonEmpty = lines.filter(l => l.trim());
|
|
383
|
+
const excerpt = nonEmpty.slice(-maxLines).map(l => l.slice(0, 300));
|
|
301
384
|
const failing = nonEmpty.filter(l => FAILURE_LINE_RE.test(l));
|
|
385
|
+
if (runner) {
|
|
386
|
+
const names = [];
|
|
387
|
+
let firstRecord = '';
|
|
388
|
+
for (const l of nonEmpty) {
|
|
389
|
+
const n = runnerFailure(l);
|
|
390
|
+
if (!n) continue;
|
|
391
|
+
if (!firstRecord) firstRecord = l;
|
|
392
|
+
if (!names.includes(n)) names.push(n);
|
|
393
|
+
}
|
|
394
|
+
if (names.length) {
|
|
395
|
+
// TAP prints a test's own stdout as `# …` comment lines, so a passing test that logs a TypeError must not lead.
|
|
396
|
+
const tap = nonEmpty.some(l => /^\s*not ok\b/.test(l));
|
|
397
|
+
const lead = failing.find(l => !RUNNER_NON_FAILURE_RE.test(l) && !(tap && /^\s*#\s/.test(l))) || firstRecord;
|
|
398
|
+
return {
|
|
399
|
+
count: names.length,
|
|
400
|
+
first: lead.trim().slice(0, 200),
|
|
401
|
+
excerpt,
|
|
402
|
+
tests: names.slice(0, 10).map(n => n.slice(0, 200)),
|
|
403
|
+
source: 'runner',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
302
407
|
return {
|
|
303
408
|
count: failing.length,
|
|
304
409
|
first: (failing[0] || nonEmpty[nonEmpty.length - 1] || '').trim().slice(0, 200),
|
|
305
|
-
excerpt
|
|
410
|
+
excerpt,
|
|
411
|
+
tests: [],
|
|
412
|
+
source: 'pattern',
|
|
306
413
|
};
|
|
307
414
|
}
|
|
308
415
|
|
|
@@ -419,6 +526,8 @@ function classifyGateRun(run) {
|
|
|
419
526
|
return { verdict: 'unavailable', detail: 'output exceeded the capture buffer (ENOBUFS) — gate NOT run to completion' };
|
|
420
527
|
}
|
|
421
528
|
if (run.code === 0) return { verdict: 'pass', detail: 'clean' };
|
|
529
|
+
// HC-GF-363: no `runner` option, on purpose. A linter's `✖ N problems` is not a test record, and this detail is
|
|
530
|
+
// pinned byte for byte by the first-finding ordering below.
|
|
422
531
|
const sum = summarizeFailure(run, { maxLines: 6 });
|
|
423
532
|
// A linter prints its findings from the TOP, so unlike a test runner the most
|
|
424
533
|
// useful line is the first, not the last. summarizeFailure's fallback is the
|
|
@@ -438,4 +547,5 @@ module.exports = {
|
|
|
438
547
|
EXIT, resolveCommand, resolveCommandDetailed, DEFAULT_COMMAND,
|
|
439
548
|
AUX_GATES, AUX_DETECT, resolveAuxGates, classifyGateRun,
|
|
440
549
|
verdictFromRun, commandAbsence, verifyCommandEnv, lastMeaningfulLine, summarizeFailure, FAILURE_LINE_RE,
|
|
550
|
+
RUNNER_RECORDS, runnerFailure, routerSafe,
|
|
441
551
|
};
|