@hone-ai/cli 1.18.0 → 1.20.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/bin/hone-mcp.js +9 -0
- package/hone-cli.js +2171 -82
- package/lib/agent-eval-judge.js +60 -0
- package/lib/agent-eval-probes-adversarial.js +45 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-faithfulness.js +59 -0
- package/lib/agent-eval-probes-safety.js +28 -0
- package/lib/agent-executor.js +139 -0
- package/lib/architect-config.js +121 -0
- package/lib/bundle-paths.js +141 -0
- package/lib/ci-gate-chooser.js +267 -0
- package/lib/doctor-admin-merge.js +3 -2
- package/lib/doctor-architecture.js +213 -0
- package/lib/emit-pr.js +167 -0
- package/lib/eval-evidence.js +213 -0
- package/lib/eval-graders.js +98 -1
- package/lib/git-env.js +94 -0
- package/lib/git-helpers.js +3 -2
- package/lib/judge-provider.js +63 -0
- package/lib/materialize-diff.js +345 -0
- package/lib/mcp-tools.js +310 -0
- package/lib/patch-apply.js +108 -0
- package/lib/pipeline-config.js +300 -0
- package/lib/pipeline-status.js +27 -2
- package/lib/refresh-knowledge.js +7 -0
- package/lib/release-review-cache.js +162 -0
- package/lib/release-review-config.js +4 -2
- package/lib/schedule-cron.js +141 -0
- package/lib/skill-eval-runner.js +667 -0
- package/lib/stack-paths.js +84 -6
- package/lib/story-classifier-extract.js +2 -1
- package/lib/verify-patch.js +78 -0
- package/lib/verify-pr.js +141 -0
- package/mcp-server.js +67 -0
- package/package.json +8 -5
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* patch-apply.js — HC-019n-followup-20 (pipeline-recovery condition 5).
|
|
4
|
+
*
|
|
5
|
+
* The true closed loop: does step_4's diff actually APPLY to a real working
|
|
6
|
+
* tree? The server can only check that the output LOOKS like a diff
|
|
7
|
+
* (patch-validator, condition 4) because it has no adopter tree. Only the CLI,
|
|
8
|
+
* running inside the repo, can run `git apply --check`.
|
|
9
|
+
*
|
|
10
|
+
* This module is the pure half — extract a diff from agent output and interpret
|
|
11
|
+
* a `git apply --check` result. The git invocation itself lives in the command
|
|
12
|
+
* (hone-cli.js) so this stays I/O-free and unit-testable.
|
|
13
|
+
*
|
|
14
|
+
* Condition 4 (well-formed) and condition 5 (applies) are genuinely different:
|
|
15
|
+
* run c122c92a produced a diff that PASSED the server validator and FAILED to
|
|
16
|
+
* apply — the agent emitted `@@ -0,0 +1,54 @@` (create-file) for a file that
|
|
17
|
+
* already existed. Structure is not applicability.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pull the unified diff out of a code-builder artifact.
|
|
22
|
+
*
|
|
23
|
+
* Prefers a fenced ```diff block (what the HC-019n-followup-19 prompt asks
|
|
24
|
+
* for). Falls back to a bare diff — headers through the last hunk-ish line —
|
|
25
|
+
* so a model that forgets the fence but emits a real patch is still checkable.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} output raw step_4 output
|
|
28
|
+
* @returns {{ diff: string|null, source: 'fenced'|'bare'|null, noChanges: boolean }}
|
|
29
|
+
*/
|
|
30
|
+
function extractDiff(output) {
|
|
31
|
+
const text = String(output || '');
|
|
32
|
+
|
|
33
|
+
// Honest "no changes" escape from the prompt — a first-class signal, not a
|
|
34
|
+
// failure. Anchored to the ## Patch section so prose elsewhere can't trip it.
|
|
35
|
+
const patchIdx = text.indexOf('## Patch');
|
|
36
|
+
if (patchIdx !== -1) {
|
|
37
|
+
const section = text.slice(patchIdx, patchIdx + 400);
|
|
38
|
+
if (/NO CHANGES\b/i.test(section)) {
|
|
39
|
+
return { diff: null, source: null, noChanges: true };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fenced ```diff … ``` (allow ```patch too; both are used in the wild).
|
|
44
|
+
const fenced = text.match(/```(?:diff|patch)\r?\n([\s\S]*?)\r?\n```/);
|
|
45
|
+
if (fenced && /^---[ \t]/m.test(fenced[1])) {
|
|
46
|
+
return { diff: normalize(fenced[1]), source: 'fenced', noChanges: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Bare fallback: from the first `diff --git` or `--- ` header to the end.
|
|
50
|
+
const headerMatch = text.match(/^(?:diff --git |--- )/m);
|
|
51
|
+
if (headerMatch) {
|
|
52
|
+
const start = text.indexOf(headerMatch[0]);
|
|
53
|
+
let body = text.slice(start);
|
|
54
|
+
// Trim a trailing prose tail: keep up to the last line that looks like part
|
|
55
|
+
// of a patch (context/add/remove/header/hunk). Anything after is commentary.
|
|
56
|
+
const lines = body.split('\n');
|
|
57
|
+
let lastPatchLine = -1;
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
if (/^(?:diff --git |index |--- |\+\+\+ |@@ |[ +\-\\])/.test(lines[i]) || lines[i] === '') {
|
|
60
|
+
lastPatchLine = i;
|
|
61
|
+
} else if (lastPatchLine !== -1 && lines[i].trim() !== '') {
|
|
62
|
+
// a non-patch, non-blank line after we've seen patch content → stop
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (lastPatchLine === -1) return { diff: null, source: null, noChanges: false };
|
|
67
|
+
body = lines.slice(0, lastPatchLine + 1).join('\n');
|
|
68
|
+
return { diff: normalize(body), source: 'bare', noChanges: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { diff: null, source: null, noChanges: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A unified diff must end with exactly one trailing newline for `git apply`. */
|
|
75
|
+
function normalize(diff) {
|
|
76
|
+
return diff.replace(/\s*$/, '') + '\n';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Interpret a `git apply --check` result into a verdict.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ code: number, stderr: string }} result from running git
|
|
83
|
+
* @returns {{ applies: boolean, verdict: 'clean'|'offset'|'failed', detail: string }}
|
|
84
|
+
*/
|
|
85
|
+
function interpretApplyCheck({ code, stderr }) {
|
|
86
|
+
const err = String(stderr || '');
|
|
87
|
+
if (code === 0) {
|
|
88
|
+
// `--check` succeeds even with fuzz; surface offsets as a softer signal
|
|
89
|
+
// because they mean the agent's line numbers drifted from the real file.
|
|
90
|
+
const offset = /offset \d+ line/i.test(err);
|
|
91
|
+
return {
|
|
92
|
+
applies: true,
|
|
93
|
+
verdict: offset ? 'offset' : 'clean',
|
|
94
|
+
detail: offset ? 'applies, but with line-number offset (context drift)' : 'applies cleanly',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// Extract the most useful line: git's "patch failed" / "does not apply" / etc.
|
|
98
|
+
const firstError = (err.split('\n').find(l => /error:|patch failed|does not apply/i.test(l)) || err.split('\n')[0] || '')
|
|
99
|
+
.replace(/^error:\s*/i, '')
|
|
100
|
+
.trim();
|
|
101
|
+
return {
|
|
102
|
+
applies: false,
|
|
103
|
+
verdict: 'failed',
|
|
104
|
+
detail: firstError || 'git apply --check reported failure',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { extractDiff, interpretApplyCheck, normalize };
|
package/lib/pipeline-config.js
CHANGED
|
@@ -32,6 +32,10 @@ const path = require('node:path');
|
|
|
32
32
|
|
|
33
33
|
const KNOWN_THRESHOLD_KEYS = ['estimate_full_sdlc', 'recently_modified_window_days'];
|
|
34
34
|
|
|
35
|
+
const CI_GATE_MODES = ['github', 'local', 'both', 'none'];
|
|
36
|
+
const KNOWN_CI_GATE_KEYS = ['gate', 'local_command'];
|
|
37
|
+
const DEFAULT_CI_LOCAL_COMMAND = 'make ci';
|
|
38
|
+
|
|
35
39
|
/**
|
|
36
40
|
* Read story-classifier thresholds from .pipeline-config.yml.
|
|
37
41
|
*
|
|
@@ -77,7 +81,303 @@ function readStoryClassifierConfig(repoRoot) {
|
|
|
77
81
|
return {};
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Normalize raw ci.gate input. Validates against CI_GATE_MODES. Returns
|
|
86
|
+
* the normalized lower-case mode string, OR 'github' (backward-compat
|
|
87
|
+
* default) when input is missing/empty/invalid. Warns to stderr on
|
|
88
|
+
* non-empty input that doesn't match a valid mode, so a typo (e.g.
|
|
89
|
+
* `gate: githhub`) surfaces at queue time instead of silently degrading.
|
|
90
|
+
*
|
|
91
|
+
* @param {unknown} raw raw value from the YAML
|
|
92
|
+
* @returns {'github'|'local'|'both'|'none'}
|
|
93
|
+
*/
|
|
94
|
+
function normalizeCIGate(raw) {
|
|
95
|
+
if (raw === undefined || raw === null || raw === '') return 'github';
|
|
96
|
+
if (typeof raw !== 'string') {
|
|
97
|
+
console.warn(`[pipeline-config] ci.gate must be a string; got ${typeof raw}, defaulting to 'github'`);
|
|
98
|
+
return 'github';
|
|
99
|
+
}
|
|
100
|
+
const lower = raw.trim().toLowerCase();
|
|
101
|
+
if (CI_GATE_MODES.includes(lower)) return lower;
|
|
102
|
+
console.warn(`[pipeline-config] ci.gate '${raw}' is not one of ${CI_GATE_MODES.join('|')} — defaulting to 'github'`);
|
|
103
|
+
return 'github';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the CI-gate configuration from .pipeline-config.yml's `ci:` block.
|
|
108
|
+
* Backward-compatible: returns the safe defaults (gate='github', local_command='make ci')
|
|
109
|
+
* when the config is missing or omits these keys. New for HC-101-followup-2.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} repoRoot
|
|
112
|
+
* @returns {{ gate: 'github'|'local'|'both'|'none', local_command: string }}
|
|
113
|
+
*/
|
|
114
|
+
function readCIGateConfig(repoRoot) {
|
|
115
|
+
const defaults = { gate: 'github', local_command: DEFAULT_CI_LOCAL_COMMAND };
|
|
116
|
+
if (!repoRoot || typeof repoRoot !== 'string') return defaults;
|
|
117
|
+
|
|
118
|
+
const candidates = [
|
|
119
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
120
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const p of candidates) {
|
|
124
|
+
if (!fs.existsSync(p)) continue;
|
|
125
|
+
let raw;
|
|
126
|
+
try { raw = fs.readFileSync(p, 'utf8'); }
|
|
127
|
+
catch { continue; }
|
|
128
|
+
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
const yaml = require('js-yaml');
|
|
132
|
+
parsed = yaml.load(raw);
|
|
133
|
+
} catch { continue; }
|
|
134
|
+
|
|
135
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
136
|
+
const block = parsed.ci;
|
|
137
|
+
if (!block || typeof block !== 'object') {
|
|
138
|
+
// No ci: block in THIS candidate — try the next one (e.g., adopter
|
|
139
|
+
// might keep a slim top-level .pipeline-config.yml and put the ci
|
|
140
|
+
// block in .github/.pipeline-config.yml). Returning defaults here
|
|
141
|
+
// would silently drop the second-candidate's ci block.
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const gate = normalizeCIGate(block.gate);
|
|
146
|
+
const cmd = (typeof block.local_command === 'string' && block.local_command.trim())
|
|
147
|
+
? block.local_command.trim()
|
|
148
|
+
: DEFAULT_CI_LOCAL_COMMAND;
|
|
149
|
+
return { gate, local_command: cmd };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return defaults;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── HC-019n-followup-28: E2E spec-generation mode ─────────────────────────────
|
|
156
|
+
// step_3b (e2e-test-spec-writer) writes the Playwright specs. It was defined but
|
|
157
|
+
// dormant (no activation path). This makes it an adopter preference, like ci.gate:
|
|
158
|
+
// auto (default) → run step_3b when the story requires an E2E plan (step_0
|
|
159
|
+
// emitted `Requires E2E Plan: yes`); skip otherwise.
|
|
160
|
+
// never → never run step_3b (the original "manual E2E mode only").
|
|
161
|
+
// A per-run --e2e-specs / --no-e2e-specs flag overrides the config (flag > config
|
|
162
|
+
// > default), exactly the ci.gate precedence.
|
|
163
|
+
const E2E_SPEC_MODES = ['auto', 'never'];
|
|
164
|
+
const DEFAULT_E2E_SPEC_MODE = 'auto';
|
|
165
|
+
|
|
166
|
+
/** Normalize an e2e.spec_generation value to a known mode, defaulting to auto. */
|
|
167
|
+
function normalizeE2eSpecMode(v) {
|
|
168
|
+
const s = String(v == null ? '' : v).toLowerCase().trim();
|
|
169
|
+
return E2E_SPEC_MODES.includes(s) ? s : DEFAULT_E2E_SPEC_MODE;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read the E2E spec-generation mode from .pipeline-config.yml's `e2e:` block.
|
|
174
|
+
* Same candidate order + graceful degradation as readCIGateConfig.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} repoRoot
|
|
177
|
+
* @returns {'auto'|'never'} default 'auto' when config is missing/malformed.
|
|
178
|
+
*/
|
|
179
|
+
function readE2eSpecMode(repoRoot) {
|
|
180
|
+
if (!repoRoot || typeof repoRoot !== 'string') return DEFAULT_E2E_SPEC_MODE;
|
|
181
|
+
|
|
182
|
+
const candidates = [
|
|
183
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
184
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
for (const p of candidates) {
|
|
188
|
+
if (!fs.existsSync(p)) continue;
|
|
189
|
+
let raw;
|
|
190
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
191
|
+
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
const yaml = require('js-yaml');
|
|
195
|
+
parsed = yaml.load(raw);
|
|
196
|
+
} catch { continue; }
|
|
197
|
+
|
|
198
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
199
|
+
const block = parsed.e2e;
|
|
200
|
+
if (!block || typeof block !== 'object') continue; // try the next candidate
|
|
201
|
+
if (block.spec_generation === undefined) continue;
|
|
202
|
+
return normalizeE2eSpecMode(block.spec_generation);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return DEFAULT_E2E_SPEC_MODE;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── HC-019n-followup-34: closed-loop verification gate ────────────────────────
|
|
209
|
+
// The CLI verbs (verify-patch/verify-pr) EXECUTE and report a verdict to the
|
|
210
|
+
// server. `verification.gate` controls what the server does with it:
|
|
211
|
+
// advisory (default) → record the verdict, never change flow;
|
|
212
|
+
// enforce → (follow-up) a red verdict blocks the run.
|
|
213
|
+
// Mirrors ci.gate: adopter preference in .pipeline-config.yml, default-safe.
|
|
214
|
+
const VERIFICATION_GATE_MODES = ['advisory', 'enforce'];
|
|
215
|
+
const DEFAULT_VERIFICATION_GATE = 'advisory';
|
|
216
|
+
|
|
217
|
+
/** Normalize a verification.gate value, defaulting to advisory. */
|
|
218
|
+
function normalizeVerificationGate(v) {
|
|
219
|
+
const s = String(v == null ? '' : v).toLowerCase().trim();
|
|
220
|
+
return VERIFICATION_GATE_MODES.includes(s) ? s : DEFAULT_VERIFICATION_GATE;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Read the verification gate mode from .pipeline-config.yml's `verification:`
|
|
225
|
+
* block. Same candidate order + graceful degradation as readCIGateConfig.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} repoRoot
|
|
228
|
+
* @returns {'advisory'|'enforce'} default 'advisory' when missing/malformed.
|
|
229
|
+
*/
|
|
230
|
+
function readVerificationGateConfig(repoRoot) {
|
|
231
|
+
if (!repoRoot || typeof repoRoot !== 'string') return DEFAULT_VERIFICATION_GATE;
|
|
232
|
+
|
|
233
|
+
const candidates = [
|
|
234
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
235
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
for (const p of candidates) {
|
|
239
|
+
if (!fs.existsSync(p)) continue;
|
|
240
|
+
let raw;
|
|
241
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
242
|
+
|
|
243
|
+
let parsed;
|
|
244
|
+
try {
|
|
245
|
+
const yaml = require('js-yaml');
|
|
246
|
+
parsed = yaml.load(raw);
|
|
247
|
+
} catch { continue; }
|
|
248
|
+
|
|
249
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
250
|
+
const block = parsed.verification;
|
|
251
|
+
if (!block || typeof block !== 'object') continue;
|
|
252
|
+
if (block.gate === undefined) continue;
|
|
253
|
+
return normalizeVerificationGate(block.gate);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return DEFAULT_VERIFICATION_GATE;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── HC-COMM-011-followup-6: which verdict sources may DRIVE the step_4 gate ────
|
|
260
|
+
// under enforce. Default: verify-patch only (the executed-test gate) — so enabling
|
|
261
|
+
// enforce never silently lets agent-eval (which verifies BEHAVIOUR, not tests)
|
|
262
|
+
// auto-advance the build. An adopter doing prompt-work opts agent-eval in.
|
|
263
|
+
// verify-pr is not gate-eligible (step_5c has no gate) and is rejected here.
|
|
264
|
+
const KNOWN_GATE_SOURCES = ['verify-patch', 'agent-eval'];
|
|
265
|
+
const DEFAULT_GATE_SOURCES = ['verify-patch'];
|
|
266
|
+
|
|
267
|
+
function normalizeGateSources(raw) {
|
|
268
|
+
if (!Array.isArray(raw)) {
|
|
269
|
+
console.warn(`[pipeline-config] verification.gate_sources must be a list; got ${typeof raw}, defaulting to ${DEFAULT_GATE_SOURCES.join(',')}`);
|
|
270
|
+
return [...DEFAULT_GATE_SOURCES];
|
|
271
|
+
}
|
|
272
|
+
const valid = [...new Set(raw.filter((s) => KNOWN_GATE_SOURCES.includes(s)))];
|
|
273
|
+
if (valid.length === 0) {
|
|
274
|
+
console.warn(`[pipeline-config] verification.gate_sources had no known source (allowed: ${KNOWN_GATE_SOURCES.join('|')}); defaulting to ${DEFAULT_GATE_SOURCES.join(',')}`);
|
|
275
|
+
return [...DEFAULT_GATE_SOURCES];
|
|
276
|
+
}
|
|
277
|
+
return valid;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Read verification.gate_sources from .pipeline-config.yml. Default-safe:
|
|
282
|
+
* ['verify-patch']. Any-of semantics — under enforce, any authorized source's
|
|
283
|
+
* green verdict advances step_4; any authorized source's red blocks.
|
|
284
|
+
* @returns {string[]}
|
|
285
|
+
*/
|
|
286
|
+
function readVerificationGateSources(repoRoot) {
|
|
287
|
+
if (!repoRoot || typeof repoRoot !== 'string') return [...DEFAULT_GATE_SOURCES];
|
|
288
|
+
const candidates = [
|
|
289
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
290
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
291
|
+
];
|
|
292
|
+
for (const p of candidates) {
|
|
293
|
+
if (!fs.existsSync(p)) continue;
|
|
294
|
+
let raw;
|
|
295
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
296
|
+
let parsed;
|
|
297
|
+
try { parsed = require('js-yaml').load(raw); } catch { continue; }
|
|
298
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
299
|
+
const block = parsed.verification;
|
|
300
|
+
if (!block || typeof block !== 'object' || block.gate_sources === undefined) continue;
|
|
301
|
+
return normalizeGateSources(block.gate_sources);
|
|
302
|
+
}
|
|
303
|
+
return [...DEFAULT_GATE_SOURCES];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── HC-COMM-011-followup-1: Agent eval config reader ──────────────────────────
|
|
307
|
+
/**
|
|
308
|
+
* Read agent eval targets from .pipeline-config.yml's `agent_eval:` block.
|
|
309
|
+
* Same candidate order + graceful degradation as readCIGateConfig.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} repoRoot
|
|
312
|
+
* @returns {{ targets: Array<{name, invoke: {command}}> }} default {targets: []}
|
|
313
|
+
*/
|
|
314
|
+
function readAgentEvalConfig(repoRoot) {
|
|
315
|
+
if (!repoRoot || typeof repoRoot !== 'string') return { targets: [] };
|
|
316
|
+
|
|
317
|
+
const candidates = [
|
|
318
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
319
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
for (const p of candidates) {
|
|
323
|
+
if (!fs.existsSync(p)) continue;
|
|
324
|
+
let raw;
|
|
325
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
326
|
+
|
|
327
|
+
let parsed;
|
|
328
|
+
try {
|
|
329
|
+
const yaml = require('js-yaml');
|
|
330
|
+
parsed = yaml.load(raw);
|
|
331
|
+
} catch { continue; }
|
|
332
|
+
|
|
333
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
334
|
+
const block = parsed.agent_eval;
|
|
335
|
+
if (!block || typeof block !== 'object') continue;
|
|
336
|
+
|
|
337
|
+
const targets = [];
|
|
338
|
+
if (Array.isArray(block.targets)) {
|
|
339
|
+
for (const target of block.targets) {
|
|
340
|
+
if (!target || typeof target !== 'object') continue;
|
|
341
|
+
if (typeof target.name !== 'string' || !target.name.trim()) {
|
|
342
|
+
console.warn(`[agent-eval-config] target has no valid name, skipping`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (!target.invoke || typeof target.invoke !== 'object' || typeof target.invoke.command !== 'string' || !target.invoke.command.trim()) {
|
|
346
|
+
console.warn(`[agent-eval-config] target "${target.name}" has no valid invoke.command, skipping`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
targets.push({
|
|
350
|
+
name: target.name.trim(),
|
|
351
|
+
invoke: { command: target.invoke.command.trim() },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { targets };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { targets: [] };
|
|
360
|
+
}
|
|
361
|
+
|
|
80
362
|
module.exports = {
|
|
81
363
|
readStoryClassifierConfig,
|
|
82
364
|
KNOWN_THRESHOLD_KEYS,
|
|
365
|
+
readCIGateConfig,
|
|
366
|
+
normalizeCIGate,
|
|
367
|
+
CI_GATE_MODES,
|
|
368
|
+
KNOWN_CI_GATE_KEYS,
|
|
369
|
+
DEFAULT_CI_LOCAL_COMMAND,
|
|
370
|
+
readE2eSpecMode,
|
|
371
|
+
normalizeE2eSpecMode,
|
|
372
|
+
E2E_SPEC_MODES,
|
|
373
|
+
DEFAULT_E2E_SPEC_MODE,
|
|
374
|
+
readVerificationGateConfig,
|
|
375
|
+
normalizeVerificationGate,
|
|
376
|
+
VERIFICATION_GATE_MODES,
|
|
377
|
+
DEFAULT_VERIFICATION_GATE,
|
|
378
|
+
readVerificationGateSources,
|
|
379
|
+
normalizeGateSources,
|
|
380
|
+
KNOWN_GATE_SOURCES,
|
|
381
|
+
DEFAULT_GATE_SOURCES,
|
|
382
|
+
readAgentEvalConfig,
|
|
83
383
|
};
|
package/lib/pipeline-status.js
CHANGED
|
@@ -15,7 +15,9 @@ const yaml = require('js-yaml');
|
|
|
15
15
|
|
|
16
16
|
// ── Canonical step file naming convention (H-009/A2) ───────────────
|
|
17
17
|
// Verified across .github/pipeline/H-001/, H-014/, H-029/, H-035/, H-076/.
|
|
18
|
-
// step_3b is conditional
|
|
18
|
+
// step_3b is conditional. HC-019n-followup-28 gave it an activation path:
|
|
19
|
+
// run-story enables it (config.enableConditional) when e2e.spec_generation != 'never';
|
|
20
|
+
// the orchestrator then runs it when the story requires an E2E plan, else skips it.
|
|
19
21
|
// step_5b is conditional (SA-002 skill audit, present when story consults skills).
|
|
20
22
|
// step_5c is conditional (H-076 CI gate, present after PR creation).
|
|
21
23
|
//
|
|
@@ -72,7 +74,30 @@ const CONDITIONAL_STEPS = new Set(STEPS.filter(s => s.conditional).map(s => s.ke
|
|
|
72
74
|
// - `hone step-5b` (SA-002, this is what surfaced the bug — when run
|
|
73
75
|
// against OptionsFlow's feature/E34-A-* branches, story id was
|
|
74
76
|
// mis-extracted as "A-resolve")
|
|
75
|
-
|
|
77
|
+
//
|
|
78
|
+
// H-029-followup-2 (2026-06-04): added `(?:-followup-[0-9]+[a-z]?(?=[-_]|$))?`
|
|
79
|
+
// to both alternatives so `hone status` on `feat/HC-052d-followup-1-...`
|
|
80
|
+
// shows the followup story id correctly instead of stopping at `HC-052d`.
|
|
81
|
+
// Mirrors H-029-followup-1's fix to the learnings-gate regex. `[a-z]?`
|
|
82
|
+
// covers the HC-019n family's parallel-iteration suffix shape (HC-019n-
|
|
83
|
+
// followup-13a etc.) — real prod data, not speculation. Trailing
|
|
84
|
+
// lookahead `(?=[-_]|$)` anchors the suffix so description tails don't
|
|
85
|
+
// get gobbled. The adopter-facing setup-ai-pipeline.sh default stays as
|
|
86
|
+
// the H-005-canonical `[A-Z]+[-_][A-Za-z0-9]+` — pinned by
|
|
87
|
+
// tests/regression/H-005-pizza-tracker-non-jira-regex.test.js.
|
|
88
|
+
// HC-CI-005: the middle alternative is NEW. `[A-Z]+[-_][A-Za-z0-9]+` stops at
|
|
89
|
+
// the first segment, so a letters-letters-digits id read as only its first two
|
|
90
|
+
// parts: HC-CI-003 -> "HC-CI", HC-COMM-007 -> "HC-COMM", HC-RC-002 -> "HC-RC".
|
|
91
|
+
// That silently broke the HC-019b-followup-1 F1 architect-config lookup, which
|
|
92
|
+
// keys on the extracted id — a miss looks identical to "no architect needed".
|
|
93
|
+
//
|
|
94
|
+
// The new alternative is deliberately NARROW: letters, letters, then DIGITS.
|
|
95
|
+
// A generic trailing `(?:-[A-Za-z0-9]+)*` would be greedy and swallow branch
|
|
96
|
+
// prose — `HC-052-add-uuid-validation` would extract "HC-052-add". Verified:
|
|
97
|
+
// with this alternative that branch still yields "HC-052".
|
|
98
|
+
// Order matters — regex alternation is first-match-wins, so this must precede
|
|
99
|
+
// the general form or the general form shadows it.
|
|
100
|
+
const STORY_ID_PATTERN = /(E[0-9]+-[A-Z][A-Za-z0-9]*(?:-followup-[0-9]+[a-z]?(?=[-_]|$))?|[A-Z]+[-_][A-Z]+[-_][0-9]+[a-z]?(?:-followup-[0-9]+[a-z]?(?=[-_]|$))?|[A-Z]+[-_][A-Za-z0-9]+(?:-followup-[0-9]+[a-z]?(?=[-_]|$))?)/;
|
|
76
101
|
|
|
77
102
|
// ─────────────────────────────────────────────────────────────────────
|
|
78
103
|
// extractStoryIdFromBranch — pure regex on branch name
|
package/lib/refresh-knowledge.js
CHANGED
|
@@ -25,6 +25,7 @@ const fs = require('node:fs');
|
|
|
25
25
|
const path = require('node:path');
|
|
26
26
|
const crypto = require('node:crypto');
|
|
27
27
|
const { execSync } = require('node:child_process');
|
|
28
|
+
const { gitEnv } = require('./git-env');
|
|
28
29
|
|
|
29
30
|
// HC-019y (2026-05-29): removed `.github/agents` from refresh paths.
|
|
30
31
|
// Agents live exclusively at `.claude/agents/` (Claude Code's read path)
|
|
@@ -47,8 +48,14 @@ const FRONTMATTER_MANAGED_RE = /^---[\s\S]*?managed_by:\s*\S+[\s\S]*?---/m;
|
|
|
47
48
|
function defaultRunChannel(name, repoRoot) {
|
|
48
49
|
const cliPath = path.resolve(__dirname, '..', 'hone-cli.js');
|
|
49
50
|
try {
|
|
51
|
+
// SC-002-followup-2: this spawns the Hone CLI, whose own git calls mostly
|
|
52
|
+
// run WITHOUT a cwd (they mean "the repo I was invoked in"). If we handed
|
|
53
|
+
// down an ambient GIT_DIR — under a hook, `rebase -x`, or
|
|
54
|
+
// `submodule foreach` — the child would resolve those against the OUTER
|
|
55
|
+
// repo while we intended repoRoot. Sanitize at the boundary instead.
|
|
50
56
|
const out = execSync(`node "${cliPath}" ${name}`, {
|
|
51
57
|
cwd: repoRoot,
|
|
58
|
+
env: gitEnv(),
|
|
52
59
|
encoding: 'utf8',
|
|
53
60
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
54
61
|
timeout: 5 * 60 * 1000,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-RC-002-followup-1: CLI-side helper for the `hone release-review`
|
|
4
|
+
* content-hash cache integration.
|
|
5
|
+
*
|
|
6
|
+
* Pattern: compute a contentHash over (diff + systemPrompt + model). POST
|
|
7
|
+
* to the server's /llm-cache/lookup; on hit, return the cached response
|
|
8
|
+
* with cache_hit:true (zero LLM cost). On miss, the caller runs the LLM
|
|
9
|
+
* normally then POSTs back to /llm-cache/store.
|
|
10
|
+
*
|
|
11
|
+
* The cache_hit path produces an envelope that matches the post-LLM output
|
|
12
|
+
* shape so downstream tooling (compare-reviews.js, CI artifact parsers)
|
|
13
|
+
* see no difference between cached + fresh runs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
|
|
18
|
+
const GATE_NAME = 'release-review';
|
|
19
|
+
// 14 days = HC-RC-002 default TTL. Held local so the CLI can tune
|
|
20
|
+
// independently of server-side default if needed.
|
|
21
|
+
const DEFAULT_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute the contentHash for a release-review invocation. Bound to:
|
|
25
|
+
* - diff: the git diff content under review
|
|
26
|
+
* - systemPrompt: the reviewer instructions (bumping the prompt invalidates)
|
|
27
|
+
* - model: which LLM (Opus vs GPT-4.1 give different reviews — separate keys)
|
|
28
|
+
*
|
|
29
|
+
* NOT bound (intentionally): user-supplied --max-files, --format, --base.
|
|
30
|
+
* These don't affect the LLM's review semantics — only output formatting.
|
|
31
|
+
*
|
|
32
|
+
* @param {{diff: string, systemPrompt: string, model: string}} input
|
|
33
|
+
* @returns {string} sha256 hex digest
|
|
34
|
+
*/
|
|
35
|
+
function computeReviewContentHash(input) {
|
|
36
|
+
if (!input || typeof input !== 'object') {
|
|
37
|
+
throw new Error('computeReviewContentHash: input must be an object');
|
|
38
|
+
}
|
|
39
|
+
for (const k of ['diff', 'systemPrompt', 'model']) {
|
|
40
|
+
if (typeof input[k] !== 'string' || input[k].length === 0) {
|
|
41
|
+
throw new Error(`computeReviewContentHash: ${k} must be a non-empty string`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Sorted-key canonical JSON (HC-RC-001 + HC-RC-002 pattern). Avoids
|
|
45
|
+
// CLI-vs-server divergence from key-order quirks.
|
|
46
|
+
const canonical = '{'
|
|
47
|
+
+ JSON.stringify('diff') + ':' + JSON.stringify(input.diff) + ','
|
|
48
|
+
+ JSON.stringify('model') + ':' + JSON.stringify(input.model) + ','
|
|
49
|
+
+ JSON.stringify('systemPrompt') + ':' + JSON.stringify(input.systemPrompt)
|
|
50
|
+
+ '}';
|
|
51
|
+
return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Look up a cached release-review response.
|
|
56
|
+
*
|
|
57
|
+
* Returns null on miss OR on any error (cache is opportunistic — caller
|
|
58
|
+
* falls through to the LLM). Logs failures to stderr so adopters can
|
|
59
|
+
* diagnose if hits never materialize.
|
|
60
|
+
*
|
|
61
|
+
* @param {{axios: object, apiBase: string, token: string, contentHash: string, model: string, banner?: Function}} opts
|
|
62
|
+
* @returns {Promise<object|null>}
|
|
63
|
+
*/
|
|
64
|
+
async function lookupReviewCache(opts) {
|
|
65
|
+
const { axios, apiBase, token, contentHash, model, banner } = opts;
|
|
66
|
+
const log = banner || ((msg) => process.stderr.write(`[release-review] ${msg}\n`));
|
|
67
|
+
if (!apiBase || !token) {
|
|
68
|
+
log('cache lookup skipped: missing apiBase/token');
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const { data } = await axios.post(
|
|
73
|
+
`${apiBase}/llm-cache/lookup`,
|
|
74
|
+
{ contentHash, model, gateName: GATE_NAME },
|
|
75
|
+
{
|
|
76
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
77
|
+
timeout: 10_000,
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
if (data && data.hit === true) return data;
|
|
81
|
+
return null;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// 4xx (bad contentHash, bad model) shouldn't crash the run; 5xx
|
|
84
|
+
// shouldn't either. Cache is opportunistic.
|
|
85
|
+
log(`cache lookup failed (non-fatal): ${e.message}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Store a fresh release-review response in the cache for future hits.
|
|
92
|
+
*
|
|
93
|
+
* Fire-and-forget by design — a failed store must not block the operator
|
|
94
|
+
* from seeing the review result. Returns a Promise that the caller MAY
|
|
95
|
+
* await for telemetry, but normal usage is .catch() + ignore.
|
|
96
|
+
*
|
|
97
|
+
* @param {{
|
|
98
|
+
* axios: object,
|
|
99
|
+
* apiBase: string,
|
|
100
|
+
* token: string,
|
|
101
|
+
* contentHash: string,
|
|
102
|
+
* model: string,
|
|
103
|
+
* response: object,
|
|
104
|
+
* tokensSaved?: number,
|
|
105
|
+
* ttlMs?: number,
|
|
106
|
+
* banner?: Function
|
|
107
|
+
* }} opts
|
|
108
|
+
* @returns {Promise<{stored: boolean, error?: string}>}
|
|
109
|
+
*/
|
|
110
|
+
async function storeReviewCache(opts) {
|
|
111
|
+
const { axios, apiBase, token, contentHash, model, response, tokensSaved, ttlMs, banner } = opts;
|
|
112
|
+
const log = banner || ((msg) => process.stderr.write(`[release-review] ${msg}\n`));
|
|
113
|
+
if (!apiBase || !token) {
|
|
114
|
+
return { stored: false, error: 'missing apiBase/token' };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await axios.post(
|
|
118
|
+
`${apiBase}/llm-cache/store`,
|
|
119
|
+
{
|
|
120
|
+
contentHash, model, gateName: GATE_NAME, response,
|
|
121
|
+
tokensSaved: tokensSaved || 0,
|
|
122
|
+
ttlMs: ttlMs || DEFAULT_TTL_MS,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
126
|
+
timeout: 10_000,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
return { stored: true };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log(`cache store failed (non-fatal): ${e.message}`);
|
|
132
|
+
return { stored: false, error: e.message };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize the --cache flag value. Default ON.
|
|
138
|
+
*
|
|
139
|
+
* @param {string|undefined} raw
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
function normalizeCacheFlag(raw) {
|
|
143
|
+
if (raw === undefined || raw === null) return true;
|
|
144
|
+
const v = String(raw).toLowerCase().trim();
|
|
145
|
+
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return false;
|
|
146
|
+
if (v === 'on' || v === 'true' || v === '1' || v === 'yes') return true;
|
|
147
|
+
// Unknown → default to ON with a warning. Adopters shouldn't have to
|
|
148
|
+
// memorize keywords; fall to safe default.
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`[release-review] warning: unknown --cache value "${raw}" — defaulting to "on"\n`,
|
|
151
|
+
);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
computeReviewContentHash,
|
|
157
|
+
lookupReviewCache,
|
|
158
|
+
storeReviewCache,
|
|
159
|
+
normalizeCacheFlag,
|
|
160
|
+
GATE_NAME,
|
|
161
|
+
DEFAULT_TTL_MS,
|
|
162
|
+
};
|
|
@@ -67,8 +67,10 @@ function resolveBaseRef(base) {
|
|
|
67
67
|
* documentation later confirms the cap is TOTAL (input + output) instead
|
|
68
68
|
* of input-only, drop this number AND `max_tokens` together.
|
|
69
69
|
*
|
|
70
|
-
* Anthropic Opus `claude-opus-4-
|
|
71
|
-
* char cap is
|
|
70
|
+
* Anthropic Opus `claude-opus-4-8`: 1M context window (upgraded from
|
|
71
|
+
* the legacy 4-20250514's 200K). The 100K char cap is well below the
|
|
72
|
+
* model's headroom but kept conservative — the prompt + diff together
|
|
73
|
+
* comfortably fit, and bumping the cap is a separate decision.
|
|
72
74
|
*
|
|
73
75
|
* ## Default behavior — FAIL CLOSED
|
|
74
76
|
*
|