@hone-ai/cli 1.17.0 → 1.19.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 +1022 -34
- package/lib/architect-config.js +121 -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/eval-evidence.js +213 -0
- package/lib/git-env.js +94 -0
- package/lib/git-helpers.js +3 -2
- package/lib/pipeline-config.js +80 -0
- package/lib/pipeline-status.js +12 -1
- 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/package.json +3 -1
package/hone-cli.js
CHANGED
|
@@ -27,6 +27,7 @@ const { execSync } = require('child_process');
|
|
|
27
27
|
const pkg = require('./package.json');
|
|
28
28
|
const { parseReviewJSON } = require('./lib/parse-review-json');
|
|
29
29
|
const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
|
|
30
|
+
const { gitEnv } = require('./lib/git-env');
|
|
30
31
|
const program = new Command();
|
|
31
32
|
|
|
32
33
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
@@ -55,8 +56,34 @@ function getConfig() {
|
|
|
55
56
|
return { token, apiUrl };
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// HC-019y-followup-3: module-level upgrade-warning bucket. Set by the
|
|
60
|
+
// axios response interceptor when the server's X-CLI-Latest-Version
|
|
61
|
+
// header reports a newer version than ours. Printed once at process
|
|
62
|
+
// exit so the warning lands AFTER the command's normal output and
|
|
63
|
+
// doesn't fight for attention with whatever the user is doing.
|
|
64
|
+
let _outdatedWarning = null;
|
|
65
|
+
// HC-101-followup-3: server-side recommendation buckets, surfaced via
|
|
66
|
+
// the X-Hone-Recommendation response header. Same printing rules as
|
|
67
|
+
// _outdatedWarning (queued during interceptor, flushed at process.exit).
|
|
68
|
+
// Stored as a Set to dedupe when one CLI invocation makes multiple
|
|
69
|
+
// requests that return the same recommendation.
|
|
70
|
+
const _serverRecommendations = new Set();
|
|
71
|
+
|
|
72
|
+
function _compareSemverMinor(a, b) {
|
|
73
|
+
// Returns true if `a` is strictly older than `b` for major.minor.patch.
|
|
74
|
+
// Liberal-parse: anything we can't make sense of → false (don't warn).
|
|
75
|
+
const re = /^(\d+)\.(\d+)\.(\d+)/;
|
|
76
|
+
const ma = re.exec(String(a || '')), mb = re.exec(String(b || ''));
|
|
77
|
+
if (!ma || !mb) return false;
|
|
78
|
+
const [, aMaj, aMin, aPat] = ma.map(Number);
|
|
79
|
+
const [, bMaj, bMin, bPat] = mb.map(Number);
|
|
80
|
+
if (aMaj !== bMaj) return aMaj < bMaj;
|
|
81
|
+
if (aMin !== bMin) return aMin < bMin;
|
|
82
|
+
return aPat < bPat;
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
function api(config) {
|
|
59
|
-
|
|
86
|
+
const client = axios.create({
|
|
60
87
|
baseURL: config.apiUrl,
|
|
61
88
|
headers: {
|
|
62
89
|
Authorization: `Bearer ${config.token}`,
|
|
@@ -64,20 +91,259 @@ function api(config) {
|
|
|
64
91
|
},
|
|
65
92
|
timeout: 30_000,
|
|
66
93
|
});
|
|
94
|
+
// HC-019y-followup-3: response interceptor reads server's
|
|
95
|
+
// X-CLI-Latest-Version, sets the upgrade-warning bucket if outdated.
|
|
96
|
+
// Fire-and-forget — never throws. Captures errors silently so a
|
|
97
|
+
// missing/malformed header can't break the command.
|
|
98
|
+
client.interceptors.response.use(
|
|
99
|
+
(response) => {
|
|
100
|
+
try {
|
|
101
|
+
const latest = response?.headers?.['x-cli-latest-version'];
|
|
102
|
+
if (latest && _compareSemverMinor(pkg.version, latest)) {
|
|
103
|
+
_outdatedWarning =
|
|
104
|
+
` ⚠ @hone-ai/cli ${latest} is available — you have ${pkg.version}\n` +
|
|
105
|
+
` Run: npm install -g @hone-ai/cli@latest`;
|
|
106
|
+
}
|
|
107
|
+
// HC-101-followup-3: server can recommend config changes via
|
|
108
|
+
// X-Hone-Recommendation (e.g. ci.gate=none → "consider gate=local").
|
|
109
|
+
// Queue the text; print at exit so it lands after normal output.
|
|
110
|
+
const rec = response?.headers?.['x-hone-recommendation'];
|
|
111
|
+
if (rec && typeof rec === 'string' && rec.trim()) {
|
|
112
|
+
_serverRecommendations.add(rec.trim());
|
|
113
|
+
}
|
|
114
|
+
} catch { /* never break the response path */ }
|
|
115
|
+
return response;
|
|
116
|
+
},
|
|
117
|
+
(error) => {
|
|
118
|
+
try {
|
|
119
|
+
const rec = error?.response?.headers?.['x-hone-recommendation'];
|
|
120
|
+
if (rec && typeof rec === 'string' && rec.trim()) {
|
|
121
|
+
_serverRecommendations.add(rec.trim());
|
|
122
|
+
}
|
|
123
|
+
} catch { /* swallow */ }
|
|
124
|
+
return Promise.reject(error);
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
return client;
|
|
67
128
|
}
|
|
68
129
|
|
|
130
|
+
// HC-019y-followup-3: emit the upgrade warning at process exit if set.
|
|
131
|
+
// Wrapped in try/catch — a failed warning is fundamentally non-critical.
|
|
132
|
+
process.on('exit', () => {
|
|
133
|
+
if (_outdatedWarning) {
|
|
134
|
+
try { console.error('\n' + _outdatedWarning); } catch { /* swallow */ }
|
|
135
|
+
}
|
|
136
|
+
// HC-101-followup-3: flush any server-side recommendations queued by
|
|
137
|
+
// the response interceptor (e.g. ci.gate=none → setup-local-ci nudge).
|
|
138
|
+
if (_serverRecommendations.size > 0) {
|
|
139
|
+
try {
|
|
140
|
+
for (const rec of _serverRecommendations) {
|
|
141
|
+
console.error('\n ⚠ ' + rec);
|
|
142
|
+
}
|
|
143
|
+
} catch { /* swallow */ }
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── SETUP-LOCAL-CI command (HC-101-followup-3) ─────────────────────────────────
|
|
148
|
+
//
|
|
149
|
+
// Scaffolds HC-101's local-CI assets into the adopter's repo so they can
|
|
150
|
+
// switch `ci.gate: local` and avoid burning GitHub Actions minutes. Pairs
|
|
151
|
+
// with the X-Hone-Recommendation header the server emits when an adopter
|
|
152
|
+
// posts a job with ci.gate=none — both nudge toward "real safety net via
|
|
153
|
+
// local-mode" instead of "no safety net via none-mode".
|
|
154
|
+
//
|
|
155
|
+
// What this command does:
|
|
156
|
+
// 1. Pulls the Makefile template + compose.local-ci.yml template from
|
|
157
|
+
// GET /scripts/local-ci/{makefile,compose} (lives inside /server/
|
|
158
|
+
// so it ships with Railway deploys per HC-019y-followup-3-railway-path).
|
|
159
|
+
// 2. Writes them to the adopter's repo root. Existing files are backed
|
|
160
|
+
// up to `<file>.hone-backup` to avoid clobbering adopter customizations.
|
|
161
|
+
// 3. Flips `.pipeline-config.yml`'s `ci.gate` to `local` so the very next
|
|
162
|
+
// `hone run-story` uses the new gate. Existing pipeline-config is
|
|
163
|
+
// required (run `hone setup` first); the command refuses to scaffold
|
|
164
|
+
// otherwise so the gate flip doesn't dangle without a config.
|
|
165
|
+
// 4. Prints a copy-paste checklist of next steps (customize Makefile
|
|
166
|
+
// targets, run `make ci`, etc.).
|
|
167
|
+
program
|
|
168
|
+
.command('setup-local-ci')
|
|
169
|
+
.description('Scaffold HC-101 Makefile + compose.local-ci.yml + flip ci.gate=local (HC-101-followup-3)')
|
|
170
|
+
.option('--force', 'Overwrite existing Makefile / compose.local-ci.yml without backing up')
|
|
171
|
+
.option('--dry-run', 'Show what would change without writing any files')
|
|
172
|
+
.action(async (opts) => {
|
|
173
|
+
const fs = require('fs');
|
|
174
|
+
const path = require('path');
|
|
175
|
+
const yaml = require('js-yaml');
|
|
176
|
+
|
|
177
|
+
const config = getConfig();
|
|
178
|
+
const client = api(config);
|
|
179
|
+
const repoRoot = process.cwd();
|
|
180
|
+
|
|
181
|
+
console.log('Hone AI — Setup Local CI (HC-101-followup-3)');
|
|
182
|
+
console.log('============================================');
|
|
183
|
+
console.log('');
|
|
184
|
+
|
|
185
|
+
// 1. Verify .pipeline-config.yml exists AND has a ci: block. Pick
|
|
186
|
+
// the file that ACTUALLY carries the ci: block — readCIGateConfig
|
|
187
|
+
// falls through to the second candidate when the first lacks one,
|
|
188
|
+
// so the write must target the same file the reader will pick or
|
|
189
|
+
// the flip is a silent no-op.
|
|
190
|
+
const configCandidates = [
|
|
191
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
192
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
193
|
+
];
|
|
194
|
+
let pipelineConfigPath = null;
|
|
195
|
+
for (const p of configCandidates) {
|
|
196
|
+
if (!fs.existsSync(p)) continue;
|
|
197
|
+
let raw;
|
|
198
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
199
|
+
let parsedPeek;
|
|
200
|
+
try { parsedPeek = yaml.load(raw); } catch { continue; }
|
|
201
|
+
if (parsedPeek && typeof parsedPeek === 'object' && parsedPeek.ci && typeof parsedPeek.ci === 'object') {
|
|
202
|
+
pipelineConfigPath = p;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Fall back to the FIRST existing file if none has a ci: block — the
|
|
207
|
+
// flip will add one. This still avoids the write/read divergence
|
|
208
|
+
// because if neither candidate has a ci: block, the reader returns
|
|
209
|
+
// defaults anyway.
|
|
210
|
+
if (!pipelineConfigPath) {
|
|
211
|
+
pipelineConfigPath = configCandidates.find((p) => fs.existsSync(p)) || null;
|
|
212
|
+
}
|
|
213
|
+
if (!pipelineConfigPath) {
|
|
214
|
+
console.error(' ✗ No .pipeline-config.yml found in this repo.');
|
|
215
|
+
console.error(' Run `hone setup` first to scaffold the pipeline, then re-run this command.');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
console.log(` ✓ Found pipeline config at ${path.relative(repoRoot, pipelineConfigPath)}`);
|
|
219
|
+
|
|
220
|
+
// 2. Fetch the two assets from the server.
|
|
221
|
+
const assets = [
|
|
222
|
+
{ remote: '/scripts/local-ci/makefile', local: 'Makefile' },
|
|
223
|
+
{ remote: '/scripts/local-ci/compose', local: 'compose.local-ci.yml' },
|
|
224
|
+
];
|
|
225
|
+
const fetched = [];
|
|
226
|
+
for (const a of assets) {
|
|
227
|
+
try {
|
|
228
|
+
const { data } = await client.get(a.remote, { responseType: 'text', transformResponse: [(d) => d] });
|
|
229
|
+
fetched.push({ ...a, content: String(data) });
|
|
230
|
+
console.log(` ✓ Pulled ${a.local} from server (${data.length} bytes)`);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
const msg = e?.response?.status === 404 ? `404 — server has no ${a.local} asset` : (e?.message || String(e));
|
|
233
|
+
console.error(` ✗ Failed to fetch ${a.local}: ${msg}`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (opts.dryRun) {
|
|
239
|
+
console.log('');
|
|
240
|
+
console.log('Dry-run: would write the following files:');
|
|
241
|
+
for (const f of fetched) {
|
|
242
|
+
const target = path.join(repoRoot, f.local);
|
|
243
|
+
const exists = fs.existsSync(target);
|
|
244
|
+
console.log(` ${exists ? '⚠' : '✓'} ${f.local}${exists ? ' (existing file would be backed up)' : ''}`);
|
|
245
|
+
}
|
|
246
|
+
console.log(' ✓ Would flip ci.gate -> local in pipeline config');
|
|
247
|
+
console.log('');
|
|
248
|
+
console.log('Re-run without --dry-run to apply.');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 3. Write the assets, backing up any existing files. If the default
|
|
253
|
+
// .hone-backup already exists (re-run of setup-local-ci), use a
|
|
254
|
+
// timestamped suffix so the FIRST run's backup — the only one that
|
|
255
|
+
// has the adopter's actual original — is preserved.
|
|
256
|
+
function pickBackupPath(target) {
|
|
257
|
+
const def = target + '.hone-backup';
|
|
258
|
+
if (!fs.existsSync(def)) return def;
|
|
259
|
+
// .hone-backup-YYYYMMDD-HHMMSS — sortable, unique per second
|
|
260
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15);
|
|
261
|
+
return target + `.hone-backup-${ts}`;
|
|
262
|
+
}
|
|
263
|
+
for (const f of fetched) {
|
|
264
|
+
const target = path.join(repoRoot, f.local);
|
|
265
|
+
if (fs.existsSync(target) && !opts.force) {
|
|
266
|
+
const backup = pickBackupPath(target);
|
|
267
|
+
fs.copyFileSync(target, backup);
|
|
268
|
+
console.log(` ✓ Backed up existing ${f.local} → ${path.relative(repoRoot, backup)}`);
|
|
269
|
+
}
|
|
270
|
+
fs.writeFileSync(target, f.content);
|
|
271
|
+
console.log(` ✓ Wrote ${f.local}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 4. Flip ci.gate -> local in the existing pipeline config. CRITICAL:
|
|
275
|
+
// `yaml.dump(parsed)` would strip every comment + reformat every
|
|
276
|
+
// array/string — but `.pipeline-config.yml` is the file the
|
|
277
|
+
// adopter is told to hand-edit, and our own generated config has
|
|
278
|
+
// instructional comments. Instead, do a targeted in-place text
|
|
279
|
+
// edit on the `gate:` line under `ci:` — preserve comments and
|
|
280
|
+
// every other byte verbatim. A backup is written first so the
|
|
281
|
+
// adopter can always recover.
|
|
282
|
+
const configBackup = pipelineConfigPath + (fs.existsSync(pipelineConfigPath + '.hone-backup')
|
|
283
|
+
? `.hone-backup-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15)}`
|
|
284
|
+
: '.hone-backup');
|
|
285
|
+
fs.copyFileSync(pipelineConfigPath, configBackup);
|
|
286
|
+
console.log(` ✓ Backed up pipeline config → ${path.relative(repoRoot, configBackup)}`);
|
|
287
|
+
|
|
288
|
+
const raw = fs.readFileSync(pipelineConfigPath, 'utf8');
|
|
289
|
+
let parsedPeek = null;
|
|
290
|
+
try { parsedPeek = yaml.load(raw); } catch { /* keep raw, work with regex */ }
|
|
291
|
+
|
|
292
|
+
// Detect prevGate via parse (best effort) — purely for the operator log line.
|
|
293
|
+
const prevGate = parsedPeek?.ci?.gate;
|
|
294
|
+
|
|
295
|
+
let updated;
|
|
296
|
+
if (/^ci:\s*$/m.test(raw)) {
|
|
297
|
+
// ci: block exists. Find it, see if `gate:` is inside; if yes, replace
|
|
298
|
+
// its value. Preserve any trailing `# comment` on the same line —
|
|
299
|
+
// adopters may have annotated the gate choice and we shouldn't lose it.
|
|
300
|
+
// If no gate: key inside the block, insert `gate: local` right after `ci:`.
|
|
301
|
+
const ciIdx = raw.search(/^ci:\s*$/m);
|
|
302
|
+
// Match: (head incl. ci: line) (indent + gate:) (value: word chars only) (trailing whitespace+comment+newline)
|
|
303
|
+
const gateInBlock = /^(ci:[\s\S]*?\n)(\s+gate:\s*)([A-Za-z0-9_-]+)(\s*(?:#[^\n]*)?\n)/m;
|
|
304
|
+
if (gateInBlock.test(raw.slice(ciIdx, ciIdx + 1500))) {
|
|
305
|
+
updated = raw.replace(gateInBlock, (_m, head, indent, _oldVal, trailing) =>
|
|
306
|
+
`${head}${indent}local${trailing}`,
|
|
307
|
+
);
|
|
308
|
+
} else {
|
|
309
|
+
// ci: block exists but no gate key. Insert it right after `ci:`.
|
|
310
|
+
updated = raw.replace(/^(ci:\s*\n)/m, `$1 gate: local\n local_command: make ci\n`);
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
// No ci: block at all — append it at the end with the required keys.
|
|
314
|
+
updated = raw.replace(/\s*$/, '') + '\n\nci:\n gate: local\n local_command: make ci\n';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
fs.writeFileSync(pipelineConfigPath, updated);
|
|
318
|
+
console.log(` ✓ Flipped ci.gate: ${prevGate || '(absent)'} → local in pipeline config`);
|
|
319
|
+
|
|
320
|
+
// 5. Operator next steps. The label is unique-per-command (not the
|
|
321
|
+
// shared "Next steps" string) so the H-012 post-setup anchor that
|
|
322
|
+
// indexOf-scans for the post-setup checklist doesn't latch here.
|
|
323
|
+
console.log('');
|
|
324
|
+
console.log('Local CI next steps:');
|
|
325
|
+
console.log(' 1. Open Makefile + replace the TODO sections with your stack\'s commands');
|
|
326
|
+
console.log(' (unit / regression / integration / e2e — `make help` lists every target)');
|
|
327
|
+
console.log(' 2. Run `make ci` locally to verify every gate passes');
|
|
328
|
+
console.log(' 3. Your next `hone run-story` will use local-mode CI gating —');
|
|
329
|
+
console.log(' step_5c will trust your local `make ci` instead of polling GitHub Actions');
|
|
330
|
+
console.log('');
|
|
331
|
+
console.log('Revert: set ci.gate back to "github" in your pipeline-config.yml');
|
|
332
|
+
});
|
|
333
|
+
|
|
69
334
|
// ── SETUP command ─────────────────────────────────────────────────────────────
|
|
70
335
|
program
|
|
71
336
|
.command('setup')
|
|
72
337
|
.description('Run setup-ai-pipeline.sh v3.1 — detects stack, scaffolds agents + skills')
|
|
73
338
|
.option('--dry-run', 'Preview what would be created without writing files')
|
|
74
339
|
.option('--non-interactive', 'Use detected defaults without prompting')
|
|
75
|
-
.option('--stack <stack>', 'Override stack detection (node|java|python|dotnet|salesforce)')
|
|
340
|
+
.option('--stack <stack>', 'Override stack detection (node|java|python|dotnet|salesforce|netsuite)')
|
|
76
341
|
.option('--install-tests', 'Install unit test framework (vitest/jest/pytest) + create config')
|
|
77
342
|
.option('--e2e', 'Also install Playwright E2E framework (use with --install-tests)')
|
|
78
343
|
.option('--no-e2e', 'Skip Playwright even when --install-tests is set')
|
|
79
344
|
.option('--no-branch-protection', 'Skip installing GitHub branch protection on the default branch (H-001)')
|
|
80
345
|
.option('--refresh', 'Re-scan platform metadata without re-running full setup (HC-013c)')
|
|
346
|
+
.option('--ci-gate <mode>', 'HC-RC-004: CI gating mode (github | local | mixed | none). Skips interactive prompt.')
|
|
81
347
|
.action(async (opts) => {
|
|
82
348
|
const config = getConfig();
|
|
83
349
|
const client = api(config);
|
|
@@ -370,23 +636,102 @@ program
|
|
|
370
636
|
console.log(' (non-TTY detected — running in non-interactive mode)');
|
|
371
637
|
}
|
|
372
638
|
|
|
639
|
+
// HC-RC-004: pick the ci.gate mode (interactive prompt with cost
|
|
640
|
+
// trade-off, --ci-gate flag, env var, or auto-detect — whichever
|
|
641
|
+
// applies). The chosen mode is passed via CI_GATE env var which
|
|
642
|
+
// setup-ai-pipeline.sh already reads (HC-101-followup-3 auto-detect
|
|
643
|
+
// path uses the same var as override). Choosing `local` ALSO
|
|
644
|
+
// triggers the setup-local-ci scaffold flow after setup completes,
|
|
645
|
+
// so the adopter ends the command with a working local-CI stack
|
|
646
|
+
// ready to use instead of having to know about the second command.
|
|
647
|
+
const { chooseCIGate } = require('./lib/ci-gate-chooser');
|
|
648
|
+
const ciGateChoice = await chooseCIGate({
|
|
649
|
+
flagMode: opts.ciGate,
|
|
650
|
+
nonInteractive: isNonInteractive,
|
|
651
|
+
repoRoot: process.cwd(),
|
|
652
|
+
});
|
|
653
|
+
console.log(` ✓ ci.gate = ${ciGateChoice.mode} (source: ${ciGateChoice.source})`);
|
|
654
|
+
// HC-RC-004 pass-1 (MED-2): only lecture about ci.gate=none when the
|
|
655
|
+
// adopter LANDED there via auto-detect fallback (i.e. they didn't
|
|
656
|
+
// explicitly pick it). Explicit choices (--ci-gate=none, env, prompt)
|
|
657
|
+
// mean they know what they want — don't re-warn on every setup re-run.
|
|
658
|
+
if (ciGateChoice.mode === 'none' && ciGateChoice.source === 'auto-detect') {
|
|
659
|
+
console.log(' ⚠ ci.gate=none means NO CI verification. Run `hone setup-local-ci` later');
|
|
660
|
+
console.log(' to switch to local-mode (real safety net at $0 CI cost).');
|
|
661
|
+
}
|
|
662
|
+
|
|
373
663
|
const flags = [
|
|
374
664
|
`--source "${path.join(tmpDir, 'enterprise-github')}"`,
|
|
375
665
|
opts.dryRun ? '--dry-run' : '',
|
|
376
666
|
isNonInteractive ? '--non-interactive' : '',
|
|
377
667
|
].filter(Boolean).join(' ');
|
|
378
668
|
|
|
669
|
+
// HC-RC-004 pass-1 (HIGH-1): when source==='auto-detect', LEAVE
|
|
670
|
+
// CI_GATE unset so the bash script's existing detected_ci_default
|
|
671
|
+
// logic owns the call. Defense against JS+bash detector drift —
|
|
672
|
+
// they have to agree forever if both decide. Only force CI_GATE when
|
|
673
|
+
// the adopter explicitly chose (flag / env / prompt).
|
|
674
|
+
const setupEnv = { ...process.env };
|
|
675
|
+
if (ciGateChoice.source !== 'auto-detect') {
|
|
676
|
+
setupEnv.CI_GATE = ciGateChoice.mode;
|
|
677
|
+
}
|
|
379
678
|
try {
|
|
380
679
|
execSync(`bash "${scriptPath}" ${flags}`, {
|
|
381
680
|
stdio: 'inherit',
|
|
382
681
|
cwd: process.cwd(),
|
|
383
|
-
env:
|
|
682
|
+
env: setupEnv,
|
|
384
683
|
});
|
|
385
684
|
} catch (e) {
|
|
386
685
|
console.error('Setup script failed:', e.message);
|
|
387
686
|
process.exit(1);
|
|
388
687
|
}
|
|
389
688
|
|
|
689
|
+
// HC-RC-004: if the adopter chose `local`, run the setup-local-ci
|
|
690
|
+
// scaffold inline so they end the setup command with a working
|
|
691
|
+
// local-CI stack (Makefile + compose) instead of having to know
|
|
692
|
+
// about a second command. Skipped on --dry-run + when a Makefile
|
|
693
|
+
// already exists at the repo root (auto-detect would have picked
|
|
694
|
+
// `local`; nothing more to scaffold).
|
|
695
|
+
if (ciGateChoice.mode === 'local' && !opts.dryRun) {
|
|
696
|
+
const repoRootForScaffold = process.cwd();
|
|
697
|
+
const makefileExists = fs.existsSync(path.join(repoRootForScaffold, 'Makefile'));
|
|
698
|
+
if (!makefileExists) {
|
|
699
|
+
console.log('');
|
|
700
|
+
console.log('HC-RC-004: scaffolding HC-101 Makefile + compose.local-ci.yml for local mode...');
|
|
701
|
+
try {
|
|
702
|
+
const assets = [
|
|
703
|
+
{ remote: '/scripts/local-ci/makefile', local: 'Makefile' },
|
|
704
|
+
{ remote: '/scripts/local-ci/compose', local: 'compose.local-ci.yml' },
|
|
705
|
+
];
|
|
706
|
+
// HC-RC-004 pass-1 (MED-1): each asset gets the same
|
|
707
|
+
// backup-on-exist treatment setup-local-ci uses (HC-101-followup-3
|
|
708
|
+
// timestamped-backup lesson). The Makefile branch is already
|
|
709
|
+
// gated above; the compose file also needs the same defense so
|
|
710
|
+
// an adopter who hand-rolled compose.local-ci.yml first then
|
|
711
|
+
// re-ran setup doesn't lose customizations.
|
|
712
|
+
for (const a of assets) {
|
|
713
|
+
const target = path.join(repoRootForScaffold, a.local);
|
|
714
|
+
if (fs.existsSync(target)) {
|
|
715
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15);
|
|
716
|
+
const backup = `${target}.hone-backup-${ts}`;
|
|
717
|
+
fs.copyFileSync(target, backup);
|
|
718
|
+
console.log(` ✓ Backed up existing ${a.local} → ${path.basename(backup)}`);
|
|
719
|
+
}
|
|
720
|
+
const { data } = await client.get(a.remote, { responseType: 'text', transformResponse: [(d) => d] });
|
|
721
|
+
fs.writeFileSync(target, String(data));
|
|
722
|
+
console.log(` ✓ Wrote ${a.local}`);
|
|
723
|
+
}
|
|
724
|
+
console.log(' → Customize Makefile TODO sections, then run `make ci` to verify');
|
|
725
|
+
} catch (e) {
|
|
726
|
+
console.log(` ⚠ Could not scaffold local-CI assets: ${e.message}`);
|
|
727
|
+
console.log(' Run `hone setup-local-ci` to retry the scaffold step.');
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
console.log(' ℹ Existing Makefile detected — skipping local-CI scaffold');
|
|
731
|
+
console.log(' (the auto-detect path already picked `local` for you)');
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
390
735
|
// ── Phase 1b: Install CLAUDE.md ──────────────────────────────────────────
|
|
391
736
|
// HC-019y: removed the install-time .github/agents/ -> .claude/agents/
|
|
392
737
|
// mirror. The bash setup script now writes agents directly to
|
|
@@ -1113,6 +1458,11 @@ program
|
|
|
1113
1458
|
if (result.skills) {
|
|
1114
1459
|
let preservedCount = 0;
|
|
1115
1460
|
let sidecarCount = 0;
|
|
1461
|
+
let evalScenariosCount = 0;
|
|
1462
|
+
// HC-010d pass-2 HIGH: per-skill eval-scenarios.json payload.
|
|
1463
|
+
const evalScenarios = (result.evalScenarios && typeof result.evalScenarios === 'object')
|
|
1464
|
+
? result.evalScenarios
|
|
1465
|
+
: {};
|
|
1116
1466
|
for (const [skillName, content] of Object.entries(result.skills)) {
|
|
1117
1467
|
if (!content || content.length < 50) continue;
|
|
1118
1468
|
const skillDir = path.join(repoRoot, '.github', 'skills', skillName);
|
|
@@ -1136,6 +1486,19 @@ program
|
|
|
1136
1486
|
console.log(` ✓ .github/skills/${skillName}/SKILL.md`);
|
|
1137
1487
|
}
|
|
1138
1488
|
}
|
|
1489
|
+
// HC-010d pass-2 HIGH: write eval-scenarios.json sibling if the
|
|
1490
|
+
// server validated one for this skill. Skip null/undefined/empty
|
|
1491
|
+
// (parseOutput validator dropped malformed blocks already).
|
|
1492
|
+
const scenarios = evalScenarios[skillName];
|
|
1493
|
+
const isEmpty = !scenarios
|
|
1494
|
+
|| (Array.isArray(scenarios) && scenarios.length === 0)
|
|
1495
|
+
|| (typeof scenarios === 'object' && Object.keys(scenarios).length === 0);
|
|
1496
|
+
if (!isEmpty) {
|
|
1497
|
+
const evalFile = path.join(skillDir, 'eval-scenarios.json');
|
|
1498
|
+
fs.writeFileSync(evalFile, JSON.stringify(scenarios, null, 2) + '\n');
|
|
1499
|
+
evalScenariosCount++;
|
|
1500
|
+
console.log(` ✓ .github/skills/${skillName}/eval-scenarios.json`);
|
|
1501
|
+
}
|
|
1139
1502
|
}
|
|
1140
1503
|
if (preservedCount > 0) {
|
|
1141
1504
|
console.log(`\n Preserved adopter REPO-SPECIFIC content in ${preservedCount} skill(s).`);
|
|
@@ -1147,6 +1510,10 @@ program
|
|
|
1147
1510
|
console.log(` To opt into automatic splice protection on the next derive, add a`);
|
|
1148
1511
|
console.log(` '<!-- REPO-SPECIFIC -->' marker to the original file.`);
|
|
1149
1512
|
}
|
|
1513
|
+
if (evalScenariosCount > 0) {
|
|
1514
|
+
console.log(`\n Wrote ${evalScenariosCount} eval-scenarios.json file(s) (HC-010d).`);
|
|
1515
|
+
console.log(` Future executor (hone skill-eval, HC-010d-followup-1) will probe these.`);
|
|
1516
|
+
}
|
|
1150
1517
|
}
|
|
1151
1518
|
|
|
1152
1519
|
// H-022: surface parser warnings so silent drops become VISIBLE failures.
|
|
@@ -2162,7 +2529,7 @@ program
|
|
|
2162
2529
|
const branchSamples = [];
|
|
2163
2530
|
try {
|
|
2164
2531
|
const out = execSync('git for-each-ref --sort=-committerdate --count=30 --format=%(refname:short) refs/heads/ refs/remotes/', {
|
|
2165
|
-
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
2532
|
+
cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
2166
2533
|
});
|
|
2167
2534
|
branchSamples.push(...out.split('\n').filter(Boolean));
|
|
2168
2535
|
} catch { /* not a git repo — skip */ }
|
|
@@ -2273,6 +2640,26 @@ program
|
|
|
2273
2640
|
const repoRoot = process.cwd();
|
|
2274
2641
|
const results = [];
|
|
2275
2642
|
|
|
2643
|
+
// Pass-2 review LOW (HC-020e): validate --check against the
|
|
2644
|
+
// allowlist of known sub-checks. Before this, a typo (e.g.,
|
|
2645
|
+
// `--check architectur`) silently ran nothing and exited 0,
|
|
2646
|
+
// which is the worst doctor outcome ("looks healthy" but
|
|
2647
|
+
// actually skipped everything).
|
|
2648
|
+
const KNOWN_CHECKS = new Set([
|
|
2649
|
+
'all',
|
|
2650
|
+
'docs',
|
|
2651
|
+
'admin-merge',
|
|
2652
|
+
'bind-default',
|
|
2653
|
+
'placeholders',
|
|
2654
|
+
'skill-staleness',
|
|
2655
|
+
'architecture',
|
|
2656
|
+
]);
|
|
2657
|
+
if (!KNOWN_CHECKS.has(opts.check)) {
|
|
2658
|
+
const known = [...KNOWN_CHECKS].sort().join(', ');
|
|
2659
|
+
console.error(`✗ --check: unknown name "${opts.check}". Known checks: ${known}`);
|
|
2660
|
+
process.exit(2);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2276
2663
|
// Read .pipeline-config.yml to learn the stack
|
|
2277
2664
|
let stack = 'unknown';
|
|
2278
2665
|
try {
|
|
@@ -2347,6 +2734,15 @@ program
|
|
|
2347
2734
|
results.push(checkSkillStaleness({ repoRoot }));
|
|
2348
2735
|
}
|
|
2349
2736
|
|
|
2737
|
+
// HC-020e: architecture staleness check — flags drift in
|
|
2738
|
+
// docs/sdlc/ARCHITECTURE.md based on the `<!-- Generated by
|
|
2739
|
+
// derive-domain-skills on YYYY-MM-DD -->` marker the derive prompt
|
|
2740
|
+
// now emits (companion change in this PR).
|
|
2741
|
+
if (opts.check === 'all' || opts.check === 'architecture') {
|
|
2742
|
+
const { checkArchitectureStaleness } = require('./lib/doctor-architecture');
|
|
2743
|
+
results.push(checkArchitectureStaleness({ repoRoot }));
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2350
2746
|
// Render
|
|
2351
2747
|
if (opts.json) {
|
|
2352
2748
|
console.log(JSON.stringify({ checks: results }, null, 2));
|
|
@@ -2359,7 +2755,9 @@ program
|
|
|
2359
2755
|
: r.status === 'drift' ? '✗'
|
|
2360
2756
|
: r.status === 'info' ? 'ℹ'
|
|
2361
2757
|
: '⚠';
|
|
2362
|
-
const label = r.name === 'docs' ? 'Docs freshness'
|
|
2758
|
+
const label = r.name === 'docs' ? 'Docs freshness'
|
|
2759
|
+
: r.name === 'architecture' ? 'Architecture staleness'
|
|
2760
|
+
: r.name;
|
|
2363
2761
|
console.log(`${icon} ${label} — ${r.reason}`);
|
|
2364
2762
|
if (r.suggestedFix) {
|
|
2365
2763
|
console.log(` Fix: ${r.suggestedFix}`);
|
|
@@ -4055,7 +4453,7 @@ program
|
|
|
4055
4453
|
let branchName = '';
|
|
4056
4454
|
try {
|
|
4057
4455
|
branchName = execSync('git rev-parse --abbrev-ref HEAD',
|
|
4058
|
-
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
4456
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
4059
4457
|
} catch { /* defensive */ }
|
|
4060
4458
|
const storyId = cmdOpts.storyId || extractStoryIdFromBranch(branchName);
|
|
4061
4459
|
if (!storyId) {
|
|
@@ -4068,12 +4466,12 @@ program
|
|
|
4068
4466
|
let diff = '';
|
|
4069
4467
|
try {
|
|
4070
4468
|
diff = execSync(`git diff origin/${baseBranch}...HEAD`,
|
|
4071
|
-
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4469
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4072
4470
|
} catch {
|
|
4073
4471
|
// Fallback: no remote tracking — try local base
|
|
4074
4472
|
try {
|
|
4075
4473
|
diff = execSync(`git diff ${baseBranch}...HEAD`,
|
|
4076
|
-
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4474
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4077
4475
|
} catch { /* leave empty */ }
|
|
4078
4476
|
}
|
|
4079
4477
|
|
|
@@ -4201,6 +4599,7 @@ program
|
|
|
4201
4599
|
.option('--snapshot', 'Save current eval + contract results as regression baseline')
|
|
4202
4600
|
.option('--regression', 'Compare current results against saved baseline (detect drift)')
|
|
4203
4601
|
.option('--judge', 'Run LLM-as-judge scenarios (requires ANTHROPIC_API_KEY, costs tokens)')
|
|
4602
|
+
.option('--evidence-mode <mode>', 'HC-RC-001 editor-LLM evidence transfer: "local" writes .hone/eval-evidence.json (signed); "off" disables (default)')
|
|
4204
4603
|
.action(async (opts) => {
|
|
4205
4604
|
const path = require('path');
|
|
4206
4605
|
const fs = require('fs');
|
|
@@ -4289,7 +4688,27 @@ program
|
|
|
4289
4688
|
// LLM call function using Anthropic API
|
|
4290
4689
|
async function callLLM(systemPrompt, userPrompt) {
|
|
4291
4690
|
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
4292
|
-
|
|
4691
|
+
// MODEL CHOICE (per feedback_model_choice_cost_amplifier): Sonnet tier,
|
|
4692
|
+
// deliberately NOT Opus. The eval judge is not one of the three
|
|
4693
|
+
// load-bearing SDLC agents on the OPUS_AGENTS allowlist (architect,
|
|
4694
|
+
// security, code-reviewer — HC-COMM-007); it grades eval scenarios,
|
|
4695
|
+
// where Sonnet is sufficient and Opus would be a 2.5x input-cost
|
|
4696
|
+
// escalation on a call site that can run once per scenario.
|
|
4697
|
+
//
|
|
4698
|
+
// Previously a dated 2025-05-14 Sonnet id, which Anthropic deprecated
|
|
4699
|
+
// with a 2026-06-15 deadline. The #507 Opus 4.8 sweep missed this call
|
|
4700
|
+
// site because it searched only for opus ids. Pinned by
|
|
4701
|
+
// tests/regression/opus-4-8-upgrade.test.js — note that suite forbids
|
|
4702
|
+
// the retired id even inside comments, so name it descriptively here.
|
|
4703
|
+
model: 'claude-sonnet-5',
|
|
4704
|
+
// Sonnet 5 runs ADAPTIVE THINKING when `thinking` is omitted, unlike
|
|
4705
|
+
// the dated Sonnet 4 model this replaced. Two consequences if left
|
|
4706
|
+
// default, both silent: content[0] becomes a `thinking` block (so a
|
|
4707
|
+
// [0].text read returns undefined and every judge criterion fails to
|
|
4708
|
+
// parse), and thinking tokens share max_tokens with the answer.
|
|
4709
|
+
// The judge returns a short structured verdict, so keep it off and
|
|
4710
|
+
// preserve the previous cost/latency profile.
|
|
4711
|
+
thinking: { type: 'disabled' },
|
|
4293
4712
|
max_tokens: 2048,
|
|
4294
4713
|
system: systemPrompt,
|
|
4295
4714
|
messages: [{ role: 'user', content: userPrompt }],
|
|
@@ -4301,7 +4720,10 @@ program
|
|
|
4301
4720
|
},
|
|
4302
4721
|
timeout: 60000,
|
|
4303
4722
|
});
|
|
4304
|
-
|
|
4723
|
+
// Select the first TEXT block rather than content[0]: any future model
|
|
4724
|
+
// or config that emits a leading thinking block must not silently
|
|
4725
|
+
// degrade every scenario to "could not parse response".
|
|
4726
|
+
return (data.content || []).find(b => b?.type === 'text')?.text || '';
|
|
4305
4727
|
}
|
|
4306
4728
|
|
|
4307
4729
|
console.log(`Running ${judgeScenarios.length} LLM-judge scenario(s)...`);
|
|
@@ -4366,9 +4788,232 @@ program
|
|
|
4366
4788
|
const results = runAllScenarios(scenarios, AGENT_PROMPTS, { failFast: opts.failFast });
|
|
4367
4789
|
console.log(formatResults(results, opts.format));
|
|
4368
4790
|
|
|
4791
|
+
// HC-RC-001: optionally write signed eval-evidence so CI can short-circuit
|
|
4792
|
+
// the LLM-cost gate. No-op when --evidence-mode is omitted or "off", or
|
|
4793
|
+
// when prerequisites (HONE_EVIDENCE_SECRET + diff input + metadata) are
|
|
4794
|
+
// missing. Skips with stderr warning rather than failing the eval run.
|
|
4795
|
+
try {
|
|
4796
|
+
const {
|
|
4797
|
+
normalizeEvidenceMode,
|
|
4798
|
+
buildEvidenceFromEval,
|
|
4799
|
+
writeEvidenceFile,
|
|
4800
|
+
} = require('./lib/eval-evidence');
|
|
4801
|
+
const mode = normalizeEvidenceMode(opts.evidenceMode);
|
|
4802
|
+
if (mode === 'local') {
|
|
4803
|
+
const record = buildEvidenceFromEval({ results, mode });
|
|
4804
|
+
if (record) {
|
|
4805
|
+
const out = writeEvidenceFile(record);
|
|
4806
|
+
process.stderr.write(`[hone eval] evidence-mode=local wrote ${out}\n`);
|
|
4807
|
+
}
|
|
4808
|
+
}
|
|
4809
|
+
} catch (e) {
|
|
4810
|
+
process.stderr.write(`[hone eval] evidence-mode error: ${e.message}\n`);
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4369
4813
|
process.exit(results.failed + results.errors > 0 ? 1 : 0);
|
|
4370
4814
|
});
|
|
4371
4815
|
|
|
4816
|
+
// ── HC-010d-followup-1: hone skill-eval runtime executor ────────────────────
|
|
4817
|
+
//
|
|
4818
|
+
// Consumes the eval-scenarios.json artifacts that HC-010d emits next to
|
|
4819
|
+
// every derived <stack>-developer/SKILL.md and <stack>-architect/SKILL.md.
|
|
4820
|
+
// Distinct from `hone eval` (HC-019d) which grades AGENT PROMPTS
|
|
4821
|
+
// deterministically — `hone skill-eval` grades DERIVED SKILL OUTPUTS by
|
|
4822
|
+
// calling an LLM and scoring against expected_output_keywords +
|
|
4823
|
+
// expected_output_format heuristics. Two systems, two different
|
|
4824
|
+
// questions; coexist.
|
|
4825
|
+
//
|
|
4826
|
+
// Provider defaults to gh-models (free GH PAT inference) per the
|
|
4827
|
+
// [Pipeline LLM Cost Reduction] memory — adopters must not be
|
|
4828
|
+
// double-billed for what their pipeline already invoked.
|
|
4829
|
+
program
|
|
4830
|
+
.command('skill-eval <skillName>')
|
|
4831
|
+
.description('Run derived-skill eval scenarios against an LLM (HC-010d-followup-1)')
|
|
4832
|
+
.option('--provider <name>', 'LLM provider: gh-models (default, $0) | claude-haiku (paid)', 'gh-models')
|
|
4833
|
+
.option('--scenario <id>', 'Run a single scenario by id (e.g., SF-DEV-EVAL-001)')
|
|
4834
|
+
.option('--tag <tag>', 'Filter scenarios by HC-010c rule-id tag (e.g., SF-SEC-001)')
|
|
4835
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
4836
|
+
.option('--fail-fast', 'Stop on first non-pass scenario')
|
|
4837
|
+
.option('--no-llm', 'Dry run: validate scenarios + print plan without calling the LLM')
|
|
4838
|
+
.option('--repo-root <path>', 'Override the repo root used for SKILL.md / eval-scenarios.json lookup')
|
|
4839
|
+
.action(async (skillName, opts) => {
|
|
4840
|
+
const fsLocal = require('fs');
|
|
4841
|
+
const pathLocal = require('path');
|
|
4842
|
+
const repoRoot = opts.repoRoot || process.cwd();
|
|
4843
|
+
|
|
4844
|
+
const { validateScenarios } = require(
|
|
4845
|
+
pathLocal.resolve(__dirname, '..', 'server', 'src', 'services', 'eval-scenarios')
|
|
4846
|
+
);
|
|
4847
|
+
const {
|
|
4848
|
+
loadSkillEvalScenarios,
|
|
4849
|
+
runAllSkillScenarios,
|
|
4850
|
+
formatResults,
|
|
4851
|
+
} = require('./lib/skill-eval-runner');
|
|
4852
|
+
|
|
4853
|
+
const loaded = loadSkillEvalScenarios({
|
|
4854
|
+
repoRoot, skillName, fs: fsLocal, path: pathLocal, validateScenarios,
|
|
4855
|
+
});
|
|
4856
|
+
if (!loaded.ok) {
|
|
4857
|
+
console.error(`✗ hone skill-eval: cannot load ${skillName}`);
|
|
4858
|
+
for (const e of loaded.errors) {
|
|
4859
|
+
console.error(` ${e.path}: ${e.message}`);
|
|
4860
|
+
}
|
|
4861
|
+
process.exit(2);
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4864
|
+
// Apply --scenario / --tag filters.
|
|
4865
|
+
let scenarios = loaded.scenarios;
|
|
4866
|
+
if (opts.scenario) {
|
|
4867
|
+
scenarios = scenarios.filter((s) => s.id === opts.scenario);
|
|
4868
|
+
if (scenarios.length === 0) {
|
|
4869
|
+
console.error(`✗ no scenario with id "${opts.scenario}" in ${loaded.scenariosPath}`);
|
|
4870
|
+
process.exit(2);
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4873
|
+
if (opts.tag) {
|
|
4874
|
+
scenarios = scenarios.filter((s) => (s.tags || []).includes(opts.tag));
|
|
4875
|
+
if (scenarios.length === 0) {
|
|
4876
|
+
console.error(`✗ no scenarios tagged "${opts.tag}" in ${loaded.scenariosPath}`);
|
|
4877
|
+
process.exit(2);
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
// --no-llm: validate + print plan, exit 0. Lets adopters check shape
|
|
4882
|
+
// without spending tokens or burning CI minutes (per the memory
|
|
4883
|
+
// [CI Minutes Budget]).
|
|
4884
|
+
//
|
|
4885
|
+
// commander.js converts `--no-llm` to opts.llm = false.
|
|
4886
|
+
if (opts.llm === false) {
|
|
4887
|
+
console.log(`Hone Skill Eval — dry run (--no-llm)`);
|
|
4888
|
+
console.log('====================================');
|
|
4889
|
+
console.log(`Skill: ${loaded.skill}`);
|
|
4890
|
+
console.log(`SKILL.md: ${loaded.skillPath}`);
|
|
4891
|
+
console.log(`eval-scenarios: ${loaded.scenariosPath}`);
|
|
4892
|
+
console.log(`Scenarios: ${scenarios.length} matched`);
|
|
4893
|
+
console.log('');
|
|
4894
|
+
for (const s of scenarios) {
|
|
4895
|
+
const tagStr = s.tags?.length ? ` [${s.tags.join(', ')}]` : '';
|
|
4896
|
+
console.log(` ${s.id} ${s.category.padEnd(16)} ${s.name}${tagStr}`);
|
|
4897
|
+
}
|
|
4898
|
+
process.exit(0);
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
// Provider wiring — gh-models default. Inline + per-provider so a
|
|
4902
|
+
// future provider addition lives in one switch.
|
|
4903
|
+
const axios = require('axios');
|
|
4904
|
+
// Pass-2 review caught the original 32000-char slice collided with
|
|
4905
|
+
// GH Models' documented 8000-token request-body cap (see
|
|
4906
|
+
// `cli/lib/release-review-config.js:49-68`). A real adopter
|
|
4907
|
+
// SKILL.md after years of derivations is plausibly 20-40K chars;
|
|
4908
|
+
// 32K leaves zero budget for scenario.input + JSON envelope, so
|
|
4909
|
+
// GH Models returns HTTP 400 and every scenario errors. 8000
|
|
4910
|
+
// chars ≈ ~2000 tokens for the system slot, leaving ~6000 tokens
|
|
4911
|
+
// for scenario.input + envelope — comfortable under GH Models'
|
|
4912
|
+
// cap while still preserving enough of the skill body to evaluate
|
|
4913
|
+
// adopter patterns.
|
|
4914
|
+
const MAX_SKILL_PROMPT_CHARS = 8000;
|
|
4915
|
+
let apiKey, modelLabel, callLLM;
|
|
4916
|
+
if (opts.provider === 'gh-models') {
|
|
4917
|
+
apiKey = process.env.GITHUB_TOKEN;
|
|
4918
|
+
if (!apiKey) {
|
|
4919
|
+
console.error('✗ GITHUB_TOKEN not set. Required for --provider gh-models.');
|
|
4920
|
+
console.error(' In CI: GITHUB_TOKEN is auto-injected. Locally: export GITHUB_TOKEN=<your PAT>.');
|
|
4921
|
+
// Exit 2 — operator config error, NOT a skill regression.
|
|
4922
|
+
// Pass-2 review caught: exit 1 collided with the CI-gate exit
|
|
4923
|
+
// code for eval failures, so a missing secret was reported
|
|
4924
|
+
// as "skill regression" by the CI gate. The convention from
|
|
4925
|
+
// the regression at line 172-188 is exit 2 for operator
|
|
4926
|
+
// errors, exit 1 for eval failures.
|
|
4927
|
+
process.exit(2);
|
|
4928
|
+
}
|
|
4929
|
+
modelLabel = 'openai/gpt-4.1';
|
|
4930
|
+
callLLM = async (systemPrompt, userPrompt) => {
|
|
4931
|
+
const { data } = await axios.post(
|
|
4932
|
+
'https://models.github.ai/inference/chat/completions',
|
|
4933
|
+
{
|
|
4934
|
+
model: modelLabel,
|
|
4935
|
+
messages: [
|
|
4936
|
+
{ role: 'system', content: systemPrompt.slice(0, MAX_SKILL_PROMPT_CHARS) },
|
|
4937
|
+
{ role: 'user', content: userPrompt },
|
|
4938
|
+
],
|
|
4939
|
+
max_tokens: 2048,
|
|
4940
|
+
},
|
|
4941
|
+
{
|
|
4942
|
+
headers: {
|
|
4943
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
4944
|
+
'Content-Type': 'application/json',
|
|
4945
|
+
},
|
|
4946
|
+
timeout: 120000,
|
|
4947
|
+
}
|
|
4948
|
+
);
|
|
4949
|
+
return data.choices?.[0]?.message?.content || '';
|
|
4950
|
+
};
|
|
4951
|
+
} else if (opts.provider === 'claude-haiku') {
|
|
4952
|
+
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
4953
|
+
if (!apiKey) {
|
|
4954
|
+
console.error('✗ ANTHROPIC_API_KEY not set. Required for --provider claude-haiku.');
|
|
4955
|
+
console.error(' Set: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
4956
|
+
// Exit 2 — operator config error (see gh-models branch).
|
|
4957
|
+
process.exit(2);
|
|
4958
|
+
}
|
|
4959
|
+
modelLabel = 'claude-haiku-4-5-20251001';
|
|
4960
|
+
callLLM = async (systemPrompt, userPrompt) => {
|
|
4961
|
+
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
4962
|
+
model: modelLabel,
|
|
4963
|
+
max_tokens: 2048,
|
|
4964
|
+
system: systemPrompt.slice(0, MAX_SKILL_PROMPT_CHARS),
|
|
4965
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
4966
|
+
}, {
|
|
4967
|
+
headers: {
|
|
4968
|
+
'x-api-key': apiKey,
|
|
4969
|
+
'anthropic-version': '2023-06-01',
|
|
4970
|
+
'content-type': 'application/json',
|
|
4971
|
+
},
|
|
4972
|
+
timeout: 120000,
|
|
4973
|
+
});
|
|
4974
|
+
return data.content?.[0]?.text || '';
|
|
4975
|
+
};
|
|
4976
|
+
} else {
|
|
4977
|
+
console.error(`✗ Invalid --provider: ${opts.provider}. Use 'gh-models' or 'claude-haiku'.`);
|
|
4978
|
+
process.exit(2);
|
|
4979
|
+
}
|
|
4980
|
+
|
|
4981
|
+
// Stream progress so the operator sees a heartbeat on long runs
|
|
4982
|
+
// (8-15 dev scenarios × ~5-20s LLM round-trip = 1-5 min per skill).
|
|
4983
|
+
const onProgress = (cur, total, lastResult) => {
|
|
4984
|
+
if (opts.format === 'json') return; // JSON mode is silent until the final dump
|
|
4985
|
+
const icon = lastResult.result === 'pass' ? '✓'
|
|
4986
|
+
: lastResult.result === 'fail' ? '✗'
|
|
4987
|
+
: '!';
|
|
4988
|
+
process.stderr.write(
|
|
4989
|
+
` [${cur}/${total}] ${icon} ${lastResult.id} — ${lastResult.name}\n`
|
|
4990
|
+
);
|
|
4991
|
+
};
|
|
4992
|
+
|
|
4993
|
+
if (opts.format !== 'json') {
|
|
4994
|
+
console.log(`Hone Skill Eval — ${loaded.skill}`);
|
|
4995
|
+
console.log(`Provider: ${opts.provider} (${modelLabel})`);
|
|
4996
|
+
console.log(`Scenarios: ${scenarios.length}`);
|
|
4997
|
+
console.log('');
|
|
4998
|
+
}
|
|
4999
|
+
|
|
5000
|
+
const summary = await runAllSkillScenarios({
|
|
5001
|
+
scenarios,
|
|
5002
|
+
skillContent: loaded.skillContent,
|
|
5003
|
+
callLLM,
|
|
5004
|
+
failFast: opts.failFast,
|
|
5005
|
+
onProgress,
|
|
5006
|
+
});
|
|
5007
|
+
|
|
5008
|
+
console.log(formatResults(summary, opts.format));
|
|
5009
|
+
|
|
5010
|
+
// Exit 1 on any non-pass so CI (HC-019f-style gate) can wire this
|
|
5011
|
+
// as a required check. Per the [Pipeline LLM Cost Reduction]
|
|
5012
|
+
// memory, this gate can run in CI on the gh-models default with
|
|
5013
|
+
// zero adopter cost.
|
|
5014
|
+
process.exit(summary.failed + summary.errors > 0 ? 1 : 0);
|
|
5015
|
+
});
|
|
5016
|
+
|
|
4372
5017
|
// ── HC-041: Run Story (Orchestrator) ─────────────────────────────────────────
|
|
4373
5018
|
program
|
|
4374
5019
|
.command('run-story <storyId>')
|
|
@@ -4476,8 +5121,16 @@ program
|
|
|
4476
5121
|
// fail, private repo without auth): warn and proceed without context.
|
|
4477
5122
|
// The existing HC-019n-followup-7 hard_pause safety net catches the
|
|
4478
5123
|
// resulting placeholder cascade at step_1.
|
|
5124
|
+
//
|
|
5125
|
+
// HC-019b-followup-1 F1: when invoked with an issue number, also try
|
|
5126
|
+
// to extract a canonical story-id (e.g. HC-019b) from the issue title.
|
|
5127
|
+
// Used by the architect-config read below — without this, the lookup
|
|
5128
|
+
// keys on '104' instead of 'HC-019b' and silently bypasses every
|
|
5129
|
+
// architect-engaged story whose EXECUTION_PLAN.yml entry uses the
|
|
5130
|
+
// canonical id (which is the common adopter case).
|
|
4479
5131
|
const orchestrateConfig = {};
|
|
4480
5132
|
let issueBodyForFiles = null;
|
|
5133
|
+
let resolvedStoryId = storyIdOrRunId; // canonical id for architect-config lookup
|
|
4481
5134
|
if (/^\d+$/.test(storyIdOrRunId)) {
|
|
4482
5135
|
try {
|
|
4483
5136
|
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
|
|
@@ -4493,10 +5146,25 @@ program
|
|
|
4493
5146
|
orchestrateConfig.story_description = desc;
|
|
4494
5147
|
issueBodyForFiles = `${issue.title}\n${issue.body || ''}`;
|
|
4495
5148
|
console.log(` → fetched GitHub issue #${storyIdOrRunId} for story context (${desc.length} chars)`);
|
|
5149
|
+
// HC-019b-followup-1 F1: extract the canonical story-id from the
|
|
5150
|
+
// issue title. extractStoryIdFromBranch's regex (STORY_ID_PATTERN
|
|
5151
|
+
// in pipeline-status.js) handles HC-NNN, HC-NNN-A, H-NNNb, E22-D,
|
|
5152
|
+
// and HC-NNN-followup-N shapes after H-029-followup-2.
|
|
5153
|
+
try {
|
|
5154
|
+
const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
|
|
5155
|
+
const titleId = extractStoryIdFromBranch(issue.title);
|
|
5156
|
+
if (titleId) {
|
|
5157
|
+
resolvedStoryId = titleId;
|
|
5158
|
+
console.log(` → resolved issue #${storyIdOrRunId} → canonical story id '${titleId}' for architect-config lookup`);
|
|
5159
|
+
} else {
|
|
5160
|
+
console.warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
5161
|
+
}
|
|
5162
|
+
} catch { /* extractor missing/throws → fall back to numeric id */ }
|
|
4496
5163
|
} catch (e) {
|
|
4497
5164
|
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
4498
5165
|
console.warn(` ⚠ could not fetch GitHub issue context: ${msg}`);
|
|
4499
5166
|
console.warn(' → proceeding without story_description; step_0 may produce placeholder output');
|
|
5167
|
+
console.warn(` ⚠ architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
4500
5168
|
}
|
|
4501
5169
|
}
|
|
4502
5170
|
|
|
@@ -4578,6 +5246,49 @@ program
|
|
|
4578
5246
|
}
|
|
4579
5247
|
}
|
|
4580
5248
|
|
|
5249
|
+
// HC-101-followup-2: pass the adopter's CI gate config to the
|
|
5250
|
+
// orchestrator so step_5c can branch (github / local / both / none).
|
|
5251
|
+
// Defaults to gate=github + local_command='make ci' when the config
|
|
5252
|
+
// is missing — backward-compat for adopters whose .pipeline-config.yml
|
|
5253
|
+
// predates this field.
|
|
5254
|
+
try {
|
|
5255
|
+
const { readCIGateConfig } = require('./lib/pipeline-config');
|
|
5256
|
+
const ciGate = readCIGateConfig(process.cwd());
|
|
5257
|
+
orchestrateConfig.ci_gate = ciGate.gate;
|
|
5258
|
+
orchestrateConfig.ci_local_command = ciGate.local_command;
|
|
5259
|
+
console.log(` → CI gate mode: ${ciGate.gate}${ciGate.gate !== 'github' ? ` (local_command: ${ciGate.local_command})` : ''}`);
|
|
5260
|
+
} catch (e) {
|
|
5261
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
5262
|
+
console.warn(` ⚠ CI gate config read failed (non-fatal, defaulting to gate=github): ${msg}`);
|
|
5263
|
+
orchestrateConfig.ci_gate = 'github';
|
|
5264
|
+
orchestrateConfig.ci_local_command = 'make ci';
|
|
5265
|
+
}
|
|
5266
|
+
|
|
5267
|
+
// HC-019b: read per-story architect flags from .github/EXECUTION_PLAN.yml
|
|
5268
|
+
// and plumb them into workflow_runs.config. The orchestrator's
|
|
5269
|
+
// validateStepPreConditions (server/src/services/workflow-dag.js:340)
|
|
5270
|
+
// BLOCKS step_1 when architect_consulted=true but checklist_b_completed=false.
|
|
5271
|
+
// Without this plumbing, the HC-019a flags written by the architect prompt
|
|
5272
|
+
// never reach the server and every architect-engaged story deadlocks.
|
|
5273
|
+
// Defaults to {false, false} when the file/story/config is missing —
|
|
5274
|
+
// i.e., assume the architect was not consulted (no block).
|
|
5275
|
+
//
|
|
5276
|
+
// Code-review F2/F3: malformed YAML or missing story entry was previously
|
|
5277
|
+
// silent. The helper now returns a `diagnostic` string for those cases;
|
|
5278
|
+
// we surface it as a console.warn so operators see the silent-bypass.
|
|
5279
|
+
// HC-019b-followup-1 F1: use the resolved canonical story-id (HC-NNN)
|
|
5280
|
+
// not the raw `storyIdOrRunId` which is the issue number when invoked
|
|
5281
|
+
// as `hone run-story 104`. The HC-019n-followup-11 block above sets
|
|
5282
|
+
// resolvedStoryId to the title-extracted id when it can.
|
|
5283
|
+
const { readArchitectConfig } = require('./lib/architect-config');
|
|
5284
|
+
const arch = readArchitectConfig(process.cwd(), resolvedStoryId);
|
|
5285
|
+
orchestrateConfig.architect_consulted = arch.architect_consulted;
|
|
5286
|
+
orchestrateConfig.checklist_b_completed = arch.checklist_b_completed;
|
|
5287
|
+
if (arch.diagnostic) console.warn(` ⚠ ${arch.diagnostic}`);
|
|
5288
|
+
if (arch.architect_consulted) {
|
|
5289
|
+
console.log(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`);
|
|
5290
|
+
}
|
|
5291
|
+
|
|
4581
5292
|
try {
|
|
4582
5293
|
const { data } = await client.post('/orchestrate', {
|
|
4583
5294
|
storyId: storyIdOrRunId, repoName, branch, mode: opts.mode, config: orchestrateConfig,
|
|
@@ -4876,33 +5587,94 @@ program
|
|
|
4876
5587
|
catch { branch = null; }
|
|
4877
5588
|
}
|
|
4878
5589
|
|
|
5590
|
+
// HC-019b: read EXECUTION_PLAN.yml ONCE up front so the per-story lookup
|
|
5591
|
+
// doesn't re-stat the disk for each line. Empty text \u2192 all stories get
|
|
5592
|
+
// the {false, false} default per architect-config.js. Try/catch keeps
|
|
5593
|
+
// the batch path resilient if the file is missing or unreadable.
|
|
5594
|
+
let planText = '';
|
|
5595
|
+
try {
|
|
5596
|
+
const planPath = path.join(process.cwd(), '.github', 'EXECUTION_PLAN.yml');
|
|
5597
|
+
if (fs.existsSync(planPath)) planText = fs.readFileSync(planPath, 'utf8');
|
|
5598
|
+
} catch (e) {
|
|
5599
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
5600
|
+
console.warn(`\u26a0 EXECUTION_PLAN.yml read failed for batch (non-fatal, architect flags default to {false, false}): ${msg}`);
|
|
5601
|
+
}
|
|
5602
|
+
const { readArchitectConfigFromText } = require('./lib/architect-config');
|
|
5603
|
+
|
|
4879
5604
|
// HC-059: parse `STORY-A depends:STORY-B,STORY-C` per-line syntax. The
|
|
4880
5605
|
// `depends:` token is case-sensitive and must come AFTER the storyId.
|
|
4881
5606
|
// Multiple deps separated by commas, whitespace tolerant. Lines without
|
|
4882
5607
|
// `depends:` yield no `dependsOn` (server validates absence vs empty).
|
|
5608
|
+
// HC-019b: attach per-story `config: { architect_consulted, checklist_b_completed }`
|
|
5609
|
+
// \u2014 the server's createBatch (batch-store.js:218) spreads s.config into each
|
|
5610
|
+
// workflow_runs.config row, so this is the load-bearing plumbing for
|
|
5611
|
+
// validateStepPreConditions (workflow-dag.js:340) in batch mode.
|
|
4883
5612
|
const stories = storyIds.map(line => {
|
|
4884
5613
|
const depsMatch = line.match(/^(\S+)\s+depends:(\S+)\s*$/);
|
|
5614
|
+
let storyId, dependsOn;
|
|
4885
5615
|
if (depsMatch) {
|
|
4886
5616
|
const [, id, depsCsv] = depsMatch;
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
}
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
5617
|
+
storyId = id;
|
|
5618
|
+
dependsOn = depsCsv.split(',').map(s => s.trim()).filter(Boolean);
|
|
5619
|
+
} else {
|
|
5620
|
+
// Reject ambiguous lines (storyId followed by garbage) \u2014 better than
|
|
5621
|
+
// silently treating `STORY-A something` as just `STORY-A`.
|
|
5622
|
+
if (/\s/.test(line)) {
|
|
5623
|
+
console.error(`Malformed line in --file: "${line}"`);
|
|
5624
|
+
console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
|
|
5625
|
+
process.exit(1);
|
|
5626
|
+
}
|
|
5627
|
+
storyId = line;
|
|
5628
|
+
dependsOn = undefined;
|
|
5629
|
+
}
|
|
5630
|
+
const arch = readArchitectConfigFromText(planText, storyId);
|
|
5631
|
+
// Code-review F2/F3: surface silent-bypass diagnostics per story in
|
|
5632
|
+
// the batch path too. Multi-story batches with one bad plan line
|
|
5633
|
+
// would previously disable the contract for ALL stories silently.
|
|
5634
|
+
if (arch.diagnostic) console.warn(` ⚠ [${storyId}] ${arch.diagnostic}`);
|
|
5635
|
+
const obj = {
|
|
5636
|
+
storyId,
|
|
5637
|
+
repoName,
|
|
5638
|
+
branch,
|
|
5639
|
+
config: {
|
|
5640
|
+
architect_consulted: arch.architect_consulted,
|
|
5641
|
+
checklist_b_completed: arch.checklist_b_completed,
|
|
5642
|
+
},
|
|
5643
|
+
};
|
|
5644
|
+
if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
|
|
5645
|
+
return obj;
|
|
4898
5646
|
});
|
|
4899
5647
|
|
|
4900
5648
|
// HC-054: Night Shift opt-in. config.overnight=true plumbs end-to-end
|
|
4901
5649
|
// (server validates the 25-story cap + applies default token budget +
|
|
4902
5650
|
// denormalizes flag into each child's workflow_runs.config).
|
|
4903
5651
|
const body = { stories };
|
|
5652
|
+
body.config = body.config || {};
|
|
4904
5653
|
if (opts.overnight) {
|
|
4905
|
-
body.config =
|
|
5654
|
+
body.config.overnight = true;
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
// HC-101-followup-2: plumb the adopter's CI gate config to every story
|
|
5658
|
+
// in the batch. Without this, the batch path would silently default to
|
|
5659
|
+
// gate=github on the server even when .pipeline-config.yml says local/none —
|
|
5660
|
+
// exactly the "silent skip" the design warned against. Same try/catch
|
|
5661
|
+
// pattern as the run-story path (cli/hone-cli.js ~L4628).
|
|
5662
|
+
try {
|
|
5663
|
+
const { readCIGateConfig, DEFAULT_CI_LOCAL_COMMAND } = require('./lib/pipeline-config');
|
|
5664
|
+
const ciGate = readCIGateConfig(process.cwd());
|
|
5665
|
+
body.config.ci_gate = ciGate.gate;
|
|
5666
|
+
body.config.ci_local_command = ciGate.local_command;
|
|
5667
|
+
if (ciGate.gate !== 'github') {
|
|
5668
|
+
console.log(`CI gate mode for batch: ${ciGate.gate} (local_command: ${ciGate.local_command})`);
|
|
5669
|
+
}
|
|
5670
|
+
} catch (e) {
|
|
5671
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
5672
|
+
console.warn(`⚠ CI gate config read failed for batch (non-fatal, defaulting to gate=github): ${msg}`);
|
|
5673
|
+
body.config.ci_gate = 'github';
|
|
5674
|
+
try {
|
|
5675
|
+
const { DEFAULT_CI_LOCAL_COMMAND } = require('./lib/pipeline-config');
|
|
5676
|
+
body.config.ci_local_command = DEFAULT_CI_LOCAL_COMMAND;
|
|
5677
|
+
} catch { body.config.ci_local_command = 'make ci'; }
|
|
4906
5678
|
}
|
|
4907
5679
|
|
|
4908
5680
|
try {
|
|
@@ -4933,6 +5705,71 @@ program
|
|
|
4933
5705
|
}
|
|
4934
5706
|
});
|
|
4935
5707
|
|
|
5708
|
+
// ── HC-054g: Night-shift retroactive revert command ─────────────────────────
|
|
5709
|
+
//
|
|
5710
|
+
// Two-step operator workflow after rejected_rate drift (HC-054c) flags a
|
|
5711
|
+
// batch of bad overnight auto-approves:
|
|
5712
|
+
//
|
|
5713
|
+
// 1. `hone night-shift revert <runId> --step-key <key> --revert-pr <url>`
|
|
5714
|
+
// 2. CLI calls POST /night-shift/runs/:runId/retroactive-reject (if not yet)
|
|
5715
|
+
// then POST /night-shift/runs/:runId/revert with the revert PR URL.
|
|
5716
|
+
//
|
|
5717
|
+
// We deliberately don't take repo write access; the operator runs the git
|
|
5718
|
+
// revert themselves and passes the resulting revert-PR URL. This keeps the
|
|
5719
|
+
// server side free of GitHub credentials + repo-specific permissions while
|
|
5720
|
+
// still giving the audit log the revert provenance.
|
|
5721
|
+
const nightShiftCmd = program.command('night-shift').description('HC-054 night-shift audit + revert workflow');
|
|
5722
|
+
nightShiftCmd
|
|
5723
|
+
.command('revert <runId>')
|
|
5724
|
+
.description('Record a revert action for a retroactively-rejected overnight auto-approve')
|
|
5725
|
+
.requiredOption('--step-key <stepKey>', 'The step_key whose auto-approve produced the bad output (e.g. step_4 or step_5)')
|
|
5726
|
+
.requiredOption('--revert-pr <url>', 'GitHub PR URL of the revert commit (https://github.com/.../pull/<n>)')
|
|
5727
|
+
.option('--reason <reason>', 'Free-text reason for the retroactive rejection (recorded with the audit row)')
|
|
5728
|
+
.action(async (runId, opts) => {
|
|
5729
|
+
const config = getConfig();
|
|
5730
|
+
const client = api(config);
|
|
5731
|
+
try {
|
|
5732
|
+
// Step 1: retroactively-reject if not yet rejected. The endpoint
|
|
5733
|
+
// returns 409 if already rejected — we treat that as a no-op
|
|
5734
|
+
// (operator may have done step 1 yesterday, run revert today).
|
|
5735
|
+
try {
|
|
5736
|
+
await client.post(`/night-shift/runs/${runId}/retroactive-reject`, {
|
|
5737
|
+
stepKey: opts.stepKey,
|
|
5738
|
+
reason: opts.reason,
|
|
5739
|
+
});
|
|
5740
|
+
console.log(`[night-shift] retroactively rejected (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
5741
|
+
} catch (e) {
|
|
5742
|
+
const status = e.response?.status;
|
|
5743
|
+
if (status === 409) {
|
|
5744
|
+
console.log(`[night-shift] already retroactively rejected (continuing to revert step)`);
|
|
5745
|
+
} else if (status === 404) {
|
|
5746
|
+
console.error(`hone night-shift revert failed: no auto-approve audit row for (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
5747
|
+
process.exit(1);
|
|
5748
|
+
} else {
|
|
5749
|
+
throw e;
|
|
5750
|
+
}
|
|
5751
|
+
}
|
|
5752
|
+
|
|
5753
|
+
// Step 2: record the revert PR URL.
|
|
5754
|
+
const r = await client.post(`/night-shift/runs/${runId}/revert`, {
|
|
5755
|
+
stepKey: opts.stepKey,
|
|
5756
|
+
revertPrUrl: opts.revertPr,
|
|
5757
|
+
});
|
|
5758
|
+
console.log(`[night-shift] revert recorded:`);
|
|
5759
|
+
console.log(` runId: ${r.data.runId}`);
|
|
5760
|
+
console.log(` stepKey: ${r.data.stepKey}`);
|
|
5761
|
+
console.log(` revertInitiatedAt: ${r.data.revertInitiatedAt}`);
|
|
5762
|
+
console.log(` revertPrUrl: ${r.data.revertPrUrl}`);
|
|
5763
|
+
} catch (e) {
|
|
5764
|
+
const msg = e.response?.data?.error || e.message;
|
|
5765
|
+
console.error(`hone night-shift revert failed: ${msg}`);
|
|
5766
|
+
if (e.response?.data?.remediation) {
|
|
5767
|
+
console.error(`Remediation: ${e.response.data.remediation}`);
|
|
5768
|
+
}
|
|
5769
|
+
process.exit(1);
|
|
5770
|
+
}
|
|
5771
|
+
});
|
|
5772
|
+
|
|
4936
5773
|
// ── HC-056: Schedule install (GitHub Actions overnight template) ────────────
|
|
4937
5774
|
//
|
|
4938
5775
|
// Installs a parameterized .github/workflows/<name>.yml that runs `hone
|
|
@@ -4954,6 +5791,9 @@ program
|
|
|
4954
5791
|
.option('--file <path>', 'Default stories file path (relative to repo root)', 'stories.txt')
|
|
4955
5792
|
.option('--out <dir>', 'Output directory for the workflow file', '.github/workflows')
|
|
4956
5793
|
.option('--force', 'Overwrite existing workflow file', false)
|
|
5794
|
+
.option('--overnight <mode>',
|
|
5795
|
+
'Night Shift mode: auto (default — derives from cron hour) | yes | no (HC-054f)',
|
|
5796
|
+
'auto')
|
|
4957
5797
|
.action(async (action, opts) => {
|
|
4958
5798
|
if (action !== 'install') {
|
|
4959
5799
|
console.error(`Unknown schedule action: ${action}. Supported: install`);
|
|
@@ -4979,6 +5819,22 @@ program
|
|
|
4979
5819
|
process.exit(1);
|
|
4980
5820
|
}
|
|
4981
5821
|
|
|
5822
|
+
// HC-054f: derive whether the workflow should pass --overnight to
|
|
5823
|
+
// queue-stories. Defaults to 'auto' which inspects the cron hour.
|
|
5824
|
+
// Pass-1 review HIGH #1 fix: validate the mode value UPFRONT and
|
|
5825
|
+
// exit non-zero on typos — otherwise `--overnight YES` or
|
|
5826
|
+
// `--overnight on` silently fell back to auto, contradicting
|
|
5827
|
+
// adopter intent.
|
|
5828
|
+
const { analyzeCron, resolveOvernight, isKnownOvernightMode } = require('./lib/schedule-cron');
|
|
5829
|
+
if (!isKnownOvernightMode(opts.overnight)) {
|
|
5830
|
+
console.error(`Invalid --overnight value "${opts.overnight}".`);
|
|
5831
|
+
console.error('Accepted: auto (default — derives from cron hour) | yes | no');
|
|
5832
|
+
console.error('Aliases: true/false/1/0/on/off/enable/disable also work (case-insensitive).');
|
|
5833
|
+
process.exit(1);
|
|
5834
|
+
}
|
|
5835
|
+
const cronAnalysis = analyzeCron(opts.cron);
|
|
5836
|
+
const overnightDecision = resolveOvernight(opts.overnight, cronAnalysis);
|
|
5837
|
+
|
|
4982
5838
|
const config = getConfig();
|
|
4983
5839
|
const client = api(config);
|
|
4984
5840
|
|
|
@@ -5007,10 +5863,16 @@ program
|
|
|
5007
5863
|
|
|
5008
5864
|
// 2. Substitute placeholders. Use replace-all so any future template
|
|
5009
5865
|
// additions referencing the same placeholder are handled.
|
|
5866
|
+
// HC-054f: {{OVERNIGHT_FLAG}} → either ` --overnight` (with leading
|
|
5867
|
+
// space) or empty string. The leading space keeps the queue-stories
|
|
5868
|
+
// command tidy when the flag is absent. Templates written before
|
|
5869
|
+
// HC-054f don't carry the placeholder — replace-all is a no-op there.
|
|
5870
|
+
const overnightFlag = overnightDecision.overnight ? ' --overnight' : '';
|
|
5010
5871
|
const populated = template
|
|
5011
5872
|
.replace(/\{\{NAME\}\}/g, opts.name)
|
|
5012
5873
|
.replace(/\{\{CRON\}\}/g, opts.cron)
|
|
5013
|
-
.replace(/\{\{STORIES_FILE\}\}/g, opts.file)
|
|
5874
|
+
.replace(/\{\{STORIES_FILE\}\}/g, opts.file)
|
|
5875
|
+
.replace(/\{\{OVERNIGHT_FLAG\}\}/g, overnightFlag);
|
|
5014
5876
|
|
|
5015
5877
|
// 3. Decide output path. Default writes to `.github/workflows/<name>.yml`.
|
|
5016
5878
|
const outDir = path.resolve(process.cwd(), opts.out);
|
|
@@ -5031,8 +5893,23 @@ program
|
|
|
5031
5893
|
console.log('');
|
|
5032
5894
|
console.log(`✓ Installed schedule: ${path.relative(process.cwd(), outFile)}`);
|
|
5033
5895
|
console.log('');
|
|
5034
|
-
console.log(' Schedule:
|
|
5035
|
-
console.log(' Stories:
|
|
5896
|
+
console.log(' Schedule: ' + opts.cron + ' (UTC)');
|
|
5897
|
+
console.log(' Stories: ' + opts.file);
|
|
5898
|
+
// HC-054f: surface the overnight decision + WHY so the adopter
|
|
5899
|
+
// sees that a `0 2 * * 1-5` cron auto-enabled Night Shift without
|
|
5900
|
+
// having to dig into the workflow file.
|
|
5901
|
+
const overnightLabel = overnightDecision.overnight ? 'ENABLED' : 'disabled';
|
|
5902
|
+
// HC-054f pass-1 review LOW: if the source enum ever grows, the
|
|
5903
|
+
// `|| ''` fallback would emit a dangling trailing space. Use an
|
|
5904
|
+
// explicit `unknown-source` marker so a future enum addition fails
|
|
5905
|
+
// loudly in CI rather than silently degrading the output.
|
|
5906
|
+
const sourceLabel = {
|
|
5907
|
+
'explicit-yes': '(--overnight yes)',
|
|
5908
|
+
'explicit-no': '(--overnight no)',
|
|
5909
|
+
'auto-detect-yes': '(auto-detected: ' + cronAnalysis.reason + ')',
|
|
5910
|
+
'auto-detect-no': '(auto-detected: ' + cronAnalysis.reason + ')',
|
|
5911
|
+
}[overnightDecision.source] || `(unknown-source:${overnightDecision.source})`;
|
|
5912
|
+
console.log(` Overnight: ${overnightLabel} ${sourceLabel}`);
|
|
5036
5913
|
console.log('');
|
|
5037
5914
|
console.log('Next steps:');
|
|
5038
5915
|
console.log(' 1. Ensure repo secret HONE_TOKEN is set');
|
|
@@ -5052,12 +5929,13 @@ program
|
|
|
5052
5929
|
|
|
5053
5930
|
program
|
|
5054
5931
|
.command('release-review')
|
|
5055
|
-
.description('Holistic code review of all changed files before deployment (
|
|
5932
|
+
.description('Holistic code review of all changed files before deployment (default: GH Models, $0)')
|
|
5056
5933
|
.option('--base <branch>', 'Base branch to diff against', 'main')
|
|
5057
5934
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
5058
5935
|
.option('--dry-run', 'Show what would be reviewed without calling the LLM', false)
|
|
5059
5936
|
.option('--max-files <n>', 'Max source files to include in review', '40')
|
|
5060
|
-
.option('--provider <name>', 'LLM provider:
|
|
5937
|
+
.option('--provider <name>', 'LLM provider: gh-models (default, $0) | opus (legacy, paid)', 'gh-models')
|
|
5938
|
+
.option('--cache <mode>', 'HC-RC-002-followup-1 content-hash cache: on | off (default on)', 'on')
|
|
5061
5939
|
.action(async (opts) => {
|
|
5062
5940
|
const { execSync } = require('child_process');
|
|
5063
5941
|
const fs = require('fs');
|
|
@@ -5099,11 +5977,11 @@ program
|
|
|
5099
5977
|
// 1. Get changed files
|
|
5100
5978
|
let changedFiles;
|
|
5101
5979
|
try {
|
|
5102
|
-
const raw = execSync(`git diff --name-only ${baseRef}...HEAD`, { encoding: 'utf8', cwd: repoRoot });
|
|
5980
|
+
const raw = execSync(`git diff --name-only ${baseRef}...HEAD`, { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
|
|
5103
5981
|
changedFiles = raw.trim().split('\n').filter(Boolean);
|
|
5104
5982
|
} catch {
|
|
5105
5983
|
try {
|
|
5106
|
-
const raw = execSync('git diff --name-only HEAD~10', { encoding: 'utf8', cwd: repoRoot });
|
|
5984
|
+
const raw = execSync('git diff --name-only HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
|
|
5107
5985
|
changedFiles = raw.trim().split('\n').filter(Boolean);
|
|
5108
5986
|
} catch {
|
|
5109
5987
|
console.error('Could not determine changed files. Run from a git repo.');
|
|
@@ -5158,7 +6036,7 @@ program
|
|
|
5158
6036
|
}
|
|
5159
6037
|
} else {
|
|
5160
6038
|
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
5161
|
-
providerLabel = 'Anthropic Opus (claude-opus-4-
|
|
6039
|
+
providerLabel = 'Anthropic Opus (claude-opus-4-8)';
|
|
5162
6040
|
if (!apiKey) {
|
|
5163
6041
|
console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
|
|
5164
6042
|
console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
@@ -5170,11 +6048,11 @@ program
|
|
|
5170
6048
|
let diffContent;
|
|
5171
6049
|
try {
|
|
5172
6050
|
diffContent = execSync(`git diff ${baseRef}...HEAD -- ${filesToReview.map(f => `'${f}'`).join(' ')}`, {
|
|
5173
|
-
encoding: 'utf8', cwd: repoRoot, maxBuffer: 10 * 1024 * 1024,
|
|
6051
|
+
encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024,
|
|
5174
6052
|
});
|
|
5175
6053
|
} catch {
|
|
5176
6054
|
try {
|
|
5177
|
-
diffContent = execSync('git diff HEAD~10', { encoding: 'utf8', cwd: repoRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
6055
|
+
diffContent = execSync('git diff HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024 });
|
|
5178
6056
|
} catch (e) {
|
|
5179
6057
|
console.error(`Could not generate diff: ${e.message}`);
|
|
5180
6058
|
process.exit(1);
|
|
@@ -5247,6 +6125,89 @@ program
|
|
|
5247
6125
|
banner(`Calling ${providerLabel}...`);
|
|
5248
6126
|
banner('');
|
|
5249
6127
|
|
|
6128
|
+
// ── HC-RC-002-followup-1: content-hash cache check ──────────────────
|
|
6129
|
+
//
|
|
6130
|
+
// The contentHash is computed over (diff + systemPrompt + model). A
|
|
6131
|
+
// hit means an earlier run of the SAME diff against the SAME model
|
|
6132
|
+
// produced a review already — replay it for $0 Anthropic spend +
|
|
6133
|
+
// ~50ms instead of ~30s for an Opus call. Pre-fix adopters paid
|
|
6134
|
+
// ~\$1.50 per release-review × ~3 retries per PR.
|
|
6135
|
+
//
|
|
6136
|
+
// Cache is opportunistic: any lookup error falls through to the LLM.
|
|
6137
|
+
// Stamp `cache_hit: true` and `billing_source: 'cache'` on the
|
|
6138
|
+
// envelope so the CI artifact analyzer can distinguish cached from
|
|
6139
|
+
// fresh runs.
|
|
6140
|
+
const {
|
|
6141
|
+
computeReviewContentHash,
|
|
6142
|
+
lookupReviewCache,
|
|
6143
|
+
storeReviewCache,
|
|
6144
|
+
normalizeCacheFlag,
|
|
6145
|
+
} = require('./lib/release-review-cache');
|
|
6146
|
+
const cacheEnabled = normalizeCacheFlag(opts.cache);
|
|
6147
|
+
const modelForCache = opts.provider === 'gh-models'
|
|
6148
|
+
? 'openai/gpt-4.1'
|
|
6149
|
+
: 'claude-opus-4-8';
|
|
6150
|
+
let cacheContentHash = null;
|
|
6151
|
+
if (cacheEnabled) {
|
|
6152
|
+
try {
|
|
6153
|
+
cacheContentHash = computeReviewContentHash({
|
|
6154
|
+
diff: diffContent,
|
|
6155
|
+
systemPrompt,
|
|
6156
|
+
model: modelForCache,
|
|
6157
|
+
});
|
|
6158
|
+
} catch (e) {
|
|
6159
|
+
banner(`Cache disabled: contentHash computation failed: ${e.message}`);
|
|
6160
|
+
}
|
|
6161
|
+
}
|
|
6162
|
+
if (cacheEnabled && cacheContentHash) {
|
|
6163
|
+
const config = getConfig();
|
|
6164
|
+
const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
|
|
6165
|
+
const token = (config && config.token) || process.env.HONE_TOKEN;
|
|
6166
|
+
const cached = await lookupReviewCache({
|
|
6167
|
+
axios,
|
|
6168
|
+
apiBase,
|
|
6169
|
+
token,
|
|
6170
|
+
contentHash: cacheContentHash,
|
|
6171
|
+
model: modelForCache,
|
|
6172
|
+
banner,
|
|
6173
|
+
});
|
|
6174
|
+
if (cached && cached.hit === true) {
|
|
6175
|
+
banner('');
|
|
6176
|
+
banner(`✓ Cache HIT — replaying cached release-review response`);
|
|
6177
|
+
banner(` Cache key: ${cacheContentHash.slice(0, 16)}… (hit_count: ${cached.hit_count})`);
|
|
6178
|
+
banner(` Saved: ~${cached.tokens_saved} tokens • billing_source: cache`);
|
|
6179
|
+
banner('');
|
|
6180
|
+
const elapsedMs = 50; // approximate — actual lookup + response time
|
|
6181
|
+
if (isJsonOut) {
|
|
6182
|
+
const envelope = {
|
|
6183
|
+
status: 'reviewed',
|
|
6184
|
+
base: opts.base,
|
|
6185
|
+
resolvedBase: baseRef,
|
|
6186
|
+
provider: opts.provider,
|
|
6187
|
+
totalFiles: changedFiles.length,
|
|
6188
|
+
sourceFiles: sourceFiles.length,
|
|
6189
|
+
reviewedFiles: filesToReview.length,
|
|
6190
|
+
model: modelForCache,
|
|
6191
|
+
inputTokens: 0,
|
|
6192
|
+
outputTokens: 0,
|
|
6193
|
+
elapsedMs,
|
|
6194
|
+
cache_hit: true,
|
|
6195
|
+
billing_source: 'cache',
|
|
6196
|
+
tokens_saved: cached.tokens_saved,
|
|
6197
|
+
};
|
|
6198
|
+
// Cached responses are already JSON-parsed (server stores JSON);
|
|
6199
|
+
// spread them in then overlay envelope so audit fields can't be
|
|
6200
|
+
// poisoned by stored content.
|
|
6201
|
+
console.log(JSON.stringify({ ...cached.response, ...envelope }, null, 2));
|
|
6202
|
+
} else {
|
|
6203
|
+
console.log(typeof cached.response === 'string'
|
|
6204
|
+
? cached.response
|
|
6205
|
+
: JSON.stringify(cached.response, null, 2));
|
|
6206
|
+
}
|
|
6207
|
+
process.exit(0);
|
|
6208
|
+
}
|
|
6209
|
+
}
|
|
6210
|
+
|
|
5250
6211
|
// 6. Call LLM (provider-branched, HC-080a-spike)
|
|
5251
6212
|
// max_tokens is held SYMMETRIC across providers so the HC-080a-spike
|
|
5252
6213
|
// comparison measures model capability, not output budget. 4096 is the
|
|
@@ -5284,7 +6245,7 @@ program
|
|
|
5284
6245
|
modelLabel = 'openai/gpt-4.1';
|
|
5285
6246
|
} else {
|
|
5286
6247
|
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
5287
|
-
model: 'claude-opus-4-
|
|
6248
|
+
model: 'claude-opus-4-8',
|
|
5288
6249
|
max_tokens: MAX_OUTPUT_TOKENS,
|
|
5289
6250
|
system: systemPrompt,
|
|
5290
6251
|
messages: [{ role: 'user', content: userPrompt }],
|
|
@@ -5299,7 +6260,7 @@ program
|
|
|
5299
6260
|
responseText = data.content?.[0]?.text || '';
|
|
5300
6261
|
inputTokens = data.usage?.input_tokens || 0;
|
|
5301
6262
|
outputTokens = data.usage?.output_tokens || 0;
|
|
5302
|
-
modelLabel = 'claude-opus-4-
|
|
6263
|
+
modelLabel = 'claude-opus-4-8';
|
|
5303
6264
|
}
|
|
5304
6265
|
|
|
5305
6266
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -5359,6 +6320,33 @@ program
|
|
|
5359
6320
|
console.log(responseText);
|
|
5360
6321
|
}
|
|
5361
6322
|
|
|
6323
|
+
// HC-RC-002-followup-1: store the fresh response in the cache so
|
|
6324
|
+
// future runs of the SAME diff hit cache instead of paying for
|
|
6325
|
+
// another Opus call. Fire-and-forget — caller already has the
|
|
6326
|
+
// response; a failed store is logged but doesn't change exit code.
|
|
6327
|
+
// Total tokens billed for this run = inputTokens + outputTokens —
|
|
6328
|
+
// those are the tokens a future hit would save.
|
|
6329
|
+
if (cacheEnabled && cacheContentHash) {
|
|
6330
|
+
const config = getConfig();
|
|
6331
|
+
const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
|
|
6332
|
+
const token = (config && config.token) || process.env.HONE_TOKEN;
|
|
6333
|
+
// Cache the parsed JSON when available (cleaner replay), otherwise
|
|
6334
|
+
// wrap the raw text in { raw } so the cache always stores an object.
|
|
6335
|
+
const responseForCache = parsed && typeof parsed === 'object'
|
|
6336
|
+
? parsed
|
|
6337
|
+
: { raw: responseText };
|
|
6338
|
+
storeReviewCache({
|
|
6339
|
+
axios, apiBase, token,
|
|
6340
|
+
contentHash: cacheContentHash,
|
|
6341
|
+
model: modelForCache,
|
|
6342
|
+
response: responseForCache,
|
|
6343
|
+
tokensSaved: inputTokens + outputTokens,
|
|
6344
|
+
banner,
|
|
6345
|
+
}).then(({ stored }) => {
|
|
6346
|
+
if (stored) banner(`✓ Cache stored: ${cacheContentHash.slice(0, 16)}…`);
|
|
6347
|
+
}).catch(() => { /* logged inside helper */ });
|
|
6348
|
+
}
|
|
6349
|
+
|
|
5362
6350
|
// 8. Exit code — defense in depth:
|
|
5363
6351
|
// (a) structured check against the parsed JSON, then
|
|
5364
6352
|
// (b) loose substring check on the raw response (catches LLMs that
|