@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
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 ─────────────────────────────────────────────────────────
|
|
@@ -61,6 +62,12 @@ function getConfig() {
|
|
|
61
62
|
// exit so the warning lands AFTER the command's normal output and
|
|
62
63
|
// doesn't fight for attention with whatever the user is doing.
|
|
63
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();
|
|
64
71
|
|
|
65
72
|
function _compareSemverMinor(a, b) {
|
|
66
73
|
// Returns true if `a` is strictly older than `b` for major.minor.patch.
|
|
@@ -97,10 +104,25 @@ function api(config) {
|
|
|
97
104
|
` ⚠ @hone-ai/cli ${latest} is available — you have ${pkg.version}\n` +
|
|
98
105
|
` Run: npm install -g @hone-ai/cli@latest`;
|
|
99
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
|
+
}
|
|
100
114
|
} catch { /* never break the response path */ }
|
|
101
115
|
return response;
|
|
102
116
|
},
|
|
103
|
-
(error) =>
|
|
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
|
+
}
|
|
104
126
|
);
|
|
105
127
|
return client;
|
|
106
128
|
}
|
|
@@ -111,20 +133,341 @@ process.on('exit', () => {
|
|
|
111
133
|
if (_outdatedWarning) {
|
|
112
134
|
try { console.error('\n' + _outdatedWarning); } catch { /* swallow */ }
|
|
113
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
|
+
}
|
|
114
145
|
});
|
|
115
146
|
|
|
147
|
+
// ── AGENT-EVAL command (HC-COMM-011-followup-1) ──────────────────────────────────
|
|
148
|
+
// Runs deterministic adversarial probes against a configured agent target. $0 —
|
|
149
|
+
// no LLM judge. A probe passes iff the executor ran AND every grader passes
|
|
150
|
+
// (canary absent AND refusal present). Exits non-zero if any probe fails.
|
|
151
|
+
program
|
|
152
|
+
.command('agent-eval')
|
|
153
|
+
.description('Run agent-eval probes against configured agent targets (HC-COMM-011-followup-1)')
|
|
154
|
+
.option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary)', 'adversarial')
|
|
155
|
+
.option('--target <name>', 'Run against a specific agent target (default: all configured)')
|
|
156
|
+
.option('--output <format>', 'Output format (text|json)', 'text')
|
|
157
|
+
.option('--judge', 'Add the free NLI judge layer (faithfulness only): claims are checked for entailment against the probe context')
|
|
158
|
+
.option('--provider <name>', 'Judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
|
|
159
|
+
.option('--report', 'Report the agent-eval verdict to the run (advisory) — needs --workflow-id')
|
|
160
|
+
.option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
|
|
161
|
+
.action(async (opts) => {
|
|
162
|
+
const { readAgentEvalConfig } = require('./lib/pipeline-config');
|
|
163
|
+
const { runAgent } = require('./lib/agent-executor');
|
|
164
|
+
const { runCheck } = require('./lib/eval-graders');
|
|
165
|
+
const config = readAgentEvalConfig(process.cwd());
|
|
166
|
+
|
|
167
|
+
if (!config.targets || config.targets.length === 0) {
|
|
168
|
+
console.log('No agent targets configured in .pipeline-config.yml (agent_eval.targets)');
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const PROBE_PACKS = {
|
|
173
|
+
adversarial: './lib/agent-eval-probes-adversarial',
|
|
174
|
+
faithfulness: './lib/agent-eval-probes-faithfulness',
|
|
175
|
+
safety: './lib/agent-eval-probes-safety',
|
|
176
|
+
boundary: './lib/agent-eval-probes-boundary',
|
|
177
|
+
};
|
|
178
|
+
let probes;
|
|
179
|
+
if (PROBE_PACKS[opts.category]) {
|
|
180
|
+
probes = require(PROBE_PACKS[opts.category]);
|
|
181
|
+
} else {
|
|
182
|
+
console.error(`Unknown probe category: ${opts.category} (supported: ${Object.keys(PROBE_PACKS).join(', ')})`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const targets = opts.target
|
|
187
|
+
? config.targets.filter((t) => t.name === opts.target)
|
|
188
|
+
: config.targets;
|
|
189
|
+
if (targets.length === 0) {
|
|
190
|
+
console.error(`No agent target named "${opts.target}" in config`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Optional free NLI judge layer (faithfulness only). Build the provider callLLM
|
|
195
|
+
// ONCE — a missing credential is a hard error, not a silent skip.
|
|
196
|
+
let judgeCall = null;
|
|
197
|
+
if (opts.judge) {
|
|
198
|
+
if (opts.category !== 'faithfulness') {
|
|
199
|
+
console.error(`--judge applies to --category faithfulness only (got: ${opts.category})`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
const { buildJudgeCallLLM } = require('./lib/judge-provider');
|
|
203
|
+
const built = buildJudgeCallLLM(opts.provider, { axios });
|
|
204
|
+
if (built.error) { console.error(built.error); process.exit(1); }
|
|
205
|
+
judgeCall = built.callLLM;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const results = [];
|
|
209
|
+
let anyFailed = false;
|
|
210
|
+
for (const target of targets) {
|
|
211
|
+
for (const probe of probes) {
|
|
212
|
+
const run = await runAgent(target, probe.input);
|
|
213
|
+
const output = run && typeof run.output === 'string' ? run.output : '';
|
|
214
|
+
const graders = probe.graders.map((g) => runCheck(output, g));
|
|
215
|
+
let passed = !run.error && graders.every((r) => r.passed);
|
|
216
|
+
|
|
217
|
+
// Judge layer: only for grounded probes (those with a context to entail against).
|
|
218
|
+
let judge = null;
|
|
219
|
+
if (judgeCall && probe.context) {
|
|
220
|
+
const { judgeFaithfulness } = require('./lib/agent-eval-judge');
|
|
221
|
+
judge = await judgeFaithfulness({ output, context: probe.context, callLLM: judgeCall });
|
|
222
|
+
passed = passed && judge.passed;
|
|
223
|
+
}
|
|
224
|
+
if (!passed) anyFailed = true;
|
|
225
|
+
results.push({ target: target.name, probe: probe.id, passed, error: run.error || null, graders, judge });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (opts.output === 'json') {
|
|
230
|
+
console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, results }, null, 2));
|
|
231
|
+
} else {
|
|
232
|
+
for (const r of results) {
|
|
233
|
+
const mark = r.passed ? '✓' : '✗';
|
|
234
|
+
console.log(` ${mark} ${r.target} / ${r.probe}${r.error ? ` (executor: ${r.error})` : ''}`);
|
|
235
|
+
if (!r.passed) {
|
|
236
|
+
for (const g of r.graders.filter((x) => !x.passed)) console.log(` - ${g.type}: ${g.detail}`);
|
|
237
|
+
if (r.judge && !r.judge.passed) console.log(` - judge: ${r.judge.detail}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const pass = results.filter((r) => r.passed).length;
|
|
241
|
+
console.log(`\n${pass}/${results.length} probes passed (category: ${opts.category})`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Closed loop (HC-COMM-011-followup-5): report the verdict to the run. agent-eval
|
|
245
|
+
// binds to step_4 (a prompt change is a step_4 diff) and is ADVISORY — it records
|
|
246
|
+
// into config.verifications[], never auto-gates. Best-effort; never changes the
|
|
247
|
+
// local exit code.
|
|
248
|
+
if (opts.report) {
|
|
249
|
+
if (!opts.workflowId) {
|
|
250
|
+
if (opts.output !== 'json') console.warn(' ⚠ --report needs --workflow-id <run id> to address the run; skipping report.');
|
|
251
|
+
} else {
|
|
252
|
+
try {
|
|
253
|
+
const client = api(getConfig());
|
|
254
|
+
await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
|
|
255
|
+
source: 'agent-eval',
|
|
256
|
+
stepKey: 'step_4',
|
|
257
|
+
verdict: anyFailed ? 'fail' : 'pass',
|
|
258
|
+
exitCode: anyFailed ? 1 : 0,
|
|
259
|
+
detail: `agent-eval ${opts.category}: ${results.filter((r) => r.passed).length}/${results.length} probes passed`,
|
|
260
|
+
});
|
|
261
|
+
if (opts.output !== 'json') console.log(` → reported agent-eval verdict '${anyFailed ? 'fail' : 'pass'}' to run ${opts.workflowId} (advisory)`);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (opts.output !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
process.exit(anyFailed ? 1 : 0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── SETUP-LOCAL-CI command (HC-101-followup-3) ─────────────────────────────────
|
|
272
|
+
//
|
|
273
|
+
// Scaffolds HC-101's local-CI assets into the adopter's repo so they can
|
|
274
|
+
// switch `ci.gate: local` and avoid burning GitHub Actions minutes. Pairs
|
|
275
|
+
// with the X-Hone-Recommendation header the server emits when an adopter
|
|
276
|
+
// posts a job with ci.gate=none — both nudge toward "real safety net via
|
|
277
|
+
// local-mode" instead of "no safety net via none-mode".
|
|
278
|
+
//
|
|
279
|
+
// What this command does:
|
|
280
|
+
// 1. Pulls the Makefile template + compose.local-ci.yml template from
|
|
281
|
+
// GET /scripts/local-ci/{makefile,compose} (lives inside /server/
|
|
282
|
+
// so it ships with Railway deploys per HC-019y-followup-3-railway-path).
|
|
283
|
+
// 2. Writes them to the adopter's repo root. Existing files are backed
|
|
284
|
+
// up to `<file>.hone-backup` to avoid clobbering adopter customizations.
|
|
285
|
+
// 3. Flips `.pipeline-config.yml`'s `ci.gate` to `local` so the very next
|
|
286
|
+
// `hone run-story` uses the new gate. Existing pipeline-config is
|
|
287
|
+
// required (run `hone setup` first); the command refuses to scaffold
|
|
288
|
+
// otherwise so the gate flip doesn't dangle without a config.
|
|
289
|
+
// 4. Prints a copy-paste checklist of next steps (customize Makefile
|
|
290
|
+
// targets, run `make ci`, etc.).
|
|
291
|
+
program
|
|
292
|
+
.command('setup-local-ci')
|
|
293
|
+
.description('Scaffold HC-101 Makefile + compose.local-ci.yml + flip ci.gate=local (HC-101-followup-3)')
|
|
294
|
+
.option('--force', 'Overwrite existing Makefile / compose.local-ci.yml without backing up')
|
|
295
|
+
.option('--dry-run', 'Show what would change without writing any files')
|
|
296
|
+
.action(async (opts) => {
|
|
297
|
+
const fs = require('fs');
|
|
298
|
+
const path = require('path');
|
|
299
|
+
const yaml = require('js-yaml');
|
|
300
|
+
|
|
301
|
+
const config = getConfig();
|
|
302
|
+
const client = api(config);
|
|
303
|
+
const repoRoot = process.cwd();
|
|
304
|
+
|
|
305
|
+
console.log('Hone AI — Setup Local CI (HC-101-followup-3)');
|
|
306
|
+
console.log('============================================');
|
|
307
|
+
console.log('');
|
|
308
|
+
|
|
309
|
+
// 1. Verify .pipeline-config.yml exists AND has a ci: block. Pick
|
|
310
|
+
// the file that ACTUALLY carries the ci: block — readCIGateConfig
|
|
311
|
+
// falls through to the second candidate when the first lacks one,
|
|
312
|
+
// so the write must target the same file the reader will pick or
|
|
313
|
+
// the flip is a silent no-op.
|
|
314
|
+
const configCandidates = [
|
|
315
|
+
path.join(repoRoot, '.pipeline-config.yml'),
|
|
316
|
+
path.join(repoRoot, '.github/.pipeline-config.yml'),
|
|
317
|
+
];
|
|
318
|
+
let pipelineConfigPath = null;
|
|
319
|
+
for (const p of configCandidates) {
|
|
320
|
+
if (!fs.existsSync(p)) continue;
|
|
321
|
+
let raw;
|
|
322
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
323
|
+
let parsedPeek;
|
|
324
|
+
try { parsedPeek = yaml.load(raw); } catch { continue; }
|
|
325
|
+
if (parsedPeek && typeof parsedPeek === 'object' && parsedPeek.ci && typeof parsedPeek.ci === 'object') {
|
|
326
|
+
pipelineConfigPath = p;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Fall back to the FIRST existing file if none has a ci: block — the
|
|
331
|
+
// flip will add one. This still avoids the write/read divergence
|
|
332
|
+
// because if neither candidate has a ci: block, the reader returns
|
|
333
|
+
// defaults anyway.
|
|
334
|
+
if (!pipelineConfigPath) {
|
|
335
|
+
pipelineConfigPath = configCandidates.find((p) => fs.existsSync(p)) || null;
|
|
336
|
+
}
|
|
337
|
+
if (!pipelineConfigPath) {
|
|
338
|
+
console.error(' ✗ No .pipeline-config.yml found in this repo.');
|
|
339
|
+
console.error(' Run `hone setup` first to scaffold the pipeline, then re-run this command.');
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
console.log(` ✓ Found pipeline config at ${path.relative(repoRoot, pipelineConfigPath)}`);
|
|
343
|
+
|
|
344
|
+
// 2. Fetch the two assets from the server.
|
|
345
|
+
const assets = [
|
|
346
|
+
{ remote: '/scripts/local-ci/makefile', local: 'Makefile' },
|
|
347
|
+
{ remote: '/scripts/local-ci/compose', local: 'compose.local-ci.yml' },
|
|
348
|
+
];
|
|
349
|
+
const fetched = [];
|
|
350
|
+
for (const a of assets) {
|
|
351
|
+
try {
|
|
352
|
+
const { data } = await client.get(a.remote, { responseType: 'text', transformResponse: [(d) => d] });
|
|
353
|
+
fetched.push({ ...a, content: String(data) });
|
|
354
|
+
console.log(` ✓ Pulled ${a.local} from server (${data.length} bytes)`);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
const msg = e?.response?.status === 404 ? `404 — server has no ${a.local} asset` : (e?.message || String(e));
|
|
357
|
+
console.error(` ✗ Failed to fetch ${a.local}: ${msg}`);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (opts.dryRun) {
|
|
363
|
+
console.log('');
|
|
364
|
+
console.log('Dry-run: would write the following files:');
|
|
365
|
+
for (const f of fetched) {
|
|
366
|
+
const target = path.join(repoRoot, f.local);
|
|
367
|
+
const exists = fs.existsSync(target);
|
|
368
|
+
console.log(` ${exists ? '⚠' : '✓'} ${f.local}${exists ? ' (existing file would be backed up)' : ''}`);
|
|
369
|
+
}
|
|
370
|
+
console.log(' ✓ Would flip ci.gate -> local in pipeline config');
|
|
371
|
+
console.log('');
|
|
372
|
+
console.log('Re-run without --dry-run to apply.');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 3. Write the assets, backing up any existing files. If the default
|
|
377
|
+
// .hone-backup already exists (re-run of setup-local-ci), use a
|
|
378
|
+
// timestamped suffix so the FIRST run's backup — the only one that
|
|
379
|
+
// has the adopter's actual original — is preserved.
|
|
380
|
+
function pickBackupPath(target) {
|
|
381
|
+
const def = target + '.hone-backup';
|
|
382
|
+
if (!fs.existsSync(def)) return def;
|
|
383
|
+
// .hone-backup-YYYYMMDD-HHMMSS — sortable, unique per second
|
|
384
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15);
|
|
385
|
+
return target + `.hone-backup-${ts}`;
|
|
386
|
+
}
|
|
387
|
+
for (const f of fetched) {
|
|
388
|
+
const target = path.join(repoRoot, f.local);
|
|
389
|
+
if (fs.existsSync(target) && !opts.force) {
|
|
390
|
+
const backup = pickBackupPath(target);
|
|
391
|
+
fs.copyFileSync(target, backup);
|
|
392
|
+
console.log(` ✓ Backed up existing ${f.local} → ${path.relative(repoRoot, backup)}`);
|
|
393
|
+
}
|
|
394
|
+
fs.writeFileSync(target, f.content);
|
|
395
|
+
console.log(` ✓ Wrote ${f.local}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 4. Flip ci.gate -> local in the existing pipeline config. CRITICAL:
|
|
399
|
+
// `yaml.dump(parsed)` would strip every comment + reformat every
|
|
400
|
+
// array/string — but `.pipeline-config.yml` is the file the
|
|
401
|
+
// adopter is told to hand-edit, and our own generated config has
|
|
402
|
+
// instructional comments. Instead, do a targeted in-place text
|
|
403
|
+
// edit on the `gate:` line under `ci:` — preserve comments and
|
|
404
|
+
// every other byte verbatim. A backup is written first so the
|
|
405
|
+
// adopter can always recover.
|
|
406
|
+
const configBackup = pipelineConfigPath + (fs.existsSync(pipelineConfigPath + '.hone-backup')
|
|
407
|
+
? `.hone-backup-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15)}`
|
|
408
|
+
: '.hone-backup');
|
|
409
|
+
fs.copyFileSync(pipelineConfigPath, configBackup);
|
|
410
|
+
console.log(` ✓ Backed up pipeline config → ${path.relative(repoRoot, configBackup)}`);
|
|
411
|
+
|
|
412
|
+
const raw = fs.readFileSync(pipelineConfigPath, 'utf8');
|
|
413
|
+
let parsedPeek = null;
|
|
414
|
+
try { parsedPeek = yaml.load(raw); } catch { /* keep raw, work with regex */ }
|
|
415
|
+
|
|
416
|
+
// Detect prevGate via parse (best effort) — purely for the operator log line.
|
|
417
|
+
const prevGate = parsedPeek?.ci?.gate;
|
|
418
|
+
|
|
419
|
+
let updated;
|
|
420
|
+
if (/^ci:\s*$/m.test(raw)) {
|
|
421
|
+
// ci: block exists. Find it, see if `gate:` is inside; if yes, replace
|
|
422
|
+
// its value. Preserve any trailing `# comment` on the same line —
|
|
423
|
+
// adopters may have annotated the gate choice and we shouldn't lose it.
|
|
424
|
+
// If no gate: key inside the block, insert `gate: local` right after `ci:`.
|
|
425
|
+
const ciIdx = raw.search(/^ci:\s*$/m);
|
|
426
|
+
// Match: (head incl. ci: line) (indent + gate:) (value: word chars only) (trailing whitespace+comment+newline)
|
|
427
|
+
const gateInBlock = /^(ci:[\s\S]*?\n)(\s+gate:\s*)([A-Za-z0-9_-]+)(\s*(?:#[^\n]*)?\n)/m;
|
|
428
|
+
if (gateInBlock.test(raw.slice(ciIdx, ciIdx + 1500))) {
|
|
429
|
+
updated = raw.replace(gateInBlock, (_m, head, indent, _oldVal, trailing) =>
|
|
430
|
+
`${head}${indent}local${trailing}`,
|
|
431
|
+
);
|
|
432
|
+
} else {
|
|
433
|
+
// ci: block exists but no gate key. Insert it right after `ci:`.
|
|
434
|
+
updated = raw.replace(/^(ci:\s*\n)/m, `$1 gate: local\n local_command: make ci\n`);
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
// No ci: block at all — append it at the end with the required keys.
|
|
438
|
+
updated = raw.replace(/\s*$/, '') + '\n\nci:\n gate: local\n local_command: make ci\n';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
fs.writeFileSync(pipelineConfigPath, updated);
|
|
442
|
+
console.log(` ✓ Flipped ci.gate: ${prevGate || '(absent)'} → local in pipeline config`);
|
|
443
|
+
|
|
444
|
+
// 5. Operator next steps. The label is unique-per-command (not the
|
|
445
|
+
// shared "Next steps" string) so the H-012 post-setup anchor that
|
|
446
|
+
// indexOf-scans for the post-setup checklist doesn't latch here.
|
|
447
|
+
console.log('');
|
|
448
|
+
console.log('Local CI next steps:');
|
|
449
|
+
console.log(' 1. Open Makefile + replace the TODO sections with your stack\'s commands');
|
|
450
|
+
console.log(' (unit / regression / integration / e2e — `make help` lists every target)');
|
|
451
|
+
console.log(' 2. Run `make ci` locally to verify every gate passes');
|
|
452
|
+
console.log(' 3. Your next `hone run-story` will use local-mode CI gating —');
|
|
453
|
+
console.log(' step_5c will trust your local `make ci` instead of polling GitHub Actions');
|
|
454
|
+
console.log('');
|
|
455
|
+
console.log('Revert: set ci.gate back to "github" in your pipeline-config.yml');
|
|
456
|
+
});
|
|
457
|
+
|
|
116
458
|
// ── SETUP command ─────────────────────────────────────────────────────────────
|
|
117
459
|
program
|
|
118
460
|
.command('setup')
|
|
119
461
|
.description('Run setup-ai-pipeline.sh v3.1 — detects stack, scaffolds agents + skills')
|
|
120
462
|
.option('--dry-run', 'Preview what would be created without writing files')
|
|
121
463
|
.option('--non-interactive', 'Use detected defaults without prompting')
|
|
122
|
-
.option('--stack <stack>', 'Override stack detection (node|java|python|dotnet|salesforce)')
|
|
464
|
+
.option('--stack <stack>', 'Override stack detection (node|java|python|dotnet|salesforce|netsuite)')
|
|
123
465
|
.option('--install-tests', 'Install unit test framework (vitest/jest/pytest) + create config')
|
|
124
466
|
.option('--e2e', 'Also install Playwright E2E framework (use with --install-tests)')
|
|
125
467
|
.option('--no-e2e', 'Skip Playwright even when --install-tests is set')
|
|
126
468
|
.option('--no-branch-protection', 'Skip installing GitHub branch protection on the default branch (H-001)')
|
|
127
469
|
.option('--refresh', 'Re-scan platform metadata without re-running full setup (HC-013c)')
|
|
470
|
+
.option('--ci-gate <mode>', 'HC-RC-004: CI gating mode (github | local | mixed | none). Skips interactive prompt.')
|
|
128
471
|
.action(async (opts) => {
|
|
129
472
|
const config = getConfig();
|
|
130
473
|
const client = api(config);
|
|
@@ -417,23 +760,102 @@ program
|
|
|
417
760
|
console.log(' (non-TTY detected — running in non-interactive mode)');
|
|
418
761
|
}
|
|
419
762
|
|
|
763
|
+
// HC-RC-004: pick the ci.gate mode (interactive prompt with cost
|
|
764
|
+
// trade-off, --ci-gate flag, env var, or auto-detect — whichever
|
|
765
|
+
// applies). The chosen mode is passed via CI_GATE env var which
|
|
766
|
+
// setup-ai-pipeline.sh already reads (HC-101-followup-3 auto-detect
|
|
767
|
+
// path uses the same var as override). Choosing `local` ALSO
|
|
768
|
+
// triggers the setup-local-ci scaffold flow after setup completes,
|
|
769
|
+
// so the adopter ends the command with a working local-CI stack
|
|
770
|
+
// ready to use instead of having to know about the second command.
|
|
771
|
+
const { chooseCIGate } = require('./lib/ci-gate-chooser');
|
|
772
|
+
const ciGateChoice = await chooseCIGate({
|
|
773
|
+
flagMode: opts.ciGate,
|
|
774
|
+
nonInteractive: isNonInteractive,
|
|
775
|
+
repoRoot: process.cwd(),
|
|
776
|
+
});
|
|
777
|
+
console.log(` ✓ ci.gate = ${ciGateChoice.mode} (source: ${ciGateChoice.source})`);
|
|
778
|
+
// HC-RC-004 pass-1 (MED-2): only lecture about ci.gate=none when the
|
|
779
|
+
// adopter LANDED there via auto-detect fallback (i.e. they didn't
|
|
780
|
+
// explicitly pick it). Explicit choices (--ci-gate=none, env, prompt)
|
|
781
|
+
// mean they know what they want — don't re-warn on every setup re-run.
|
|
782
|
+
if (ciGateChoice.mode === 'none' && ciGateChoice.source === 'auto-detect') {
|
|
783
|
+
console.log(' ⚠ ci.gate=none means NO CI verification. Run `hone setup-local-ci` later');
|
|
784
|
+
console.log(' to switch to local-mode (real safety net at $0 CI cost).');
|
|
785
|
+
}
|
|
786
|
+
|
|
420
787
|
const flags = [
|
|
421
788
|
`--source "${path.join(tmpDir, 'enterprise-github')}"`,
|
|
422
789
|
opts.dryRun ? '--dry-run' : '',
|
|
423
790
|
isNonInteractive ? '--non-interactive' : '',
|
|
424
791
|
].filter(Boolean).join(' ');
|
|
425
792
|
|
|
793
|
+
// HC-RC-004 pass-1 (HIGH-1): when source==='auto-detect', LEAVE
|
|
794
|
+
// CI_GATE unset so the bash script's existing detected_ci_default
|
|
795
|
+
// logic owns the call. Defense against JS+bash detector drift —
|
|
796
|
+
// they have to agree forever if both decide. Only force CI_GATE when
|
|
797
|
+
// the adopter explicitly chose (flag / env / prompt).
|
|
798
|
+
const setupEnv = { ...process.env };
|
|
799
|
+
if (ciGateChoice.source !== 'auto-detect') {
|
|
800
|
+
setupEnv.CI_GATE = ciGateChoice.mode;
|
|
801
|
+
}
|
|
426
802
|
try {
|
|
427
803
|
execSync(`bash "${scriptPath}" ${flags}`, {
|
|
428
804
|
stdio: 'inherit',
|
|
429
805
|
cwd: process.cwd(),
|
|
430
|
-
env:
|
|
806
|
+
env: setupEnv,
|
|
431
807
|
});
|
|
432
808
|
} catch (e) {
|
|
433
809
|
console.error('Setup script failed:', e.message);
|
|
434
810
|
process.exit(1);
|
|
435
811
|
}
|
|
436
812
|
|
|
813
|
+
// HC-RC-004: if the adopter chose `local`, run the setup-local-ci
|
|
814
|
+
// scaffold inline so they end the setup command with a working
|
|
815
|
+
// local-CI stack (Makefile + compose) instead of having to know
|
|
816
|
+
// about a second command. Skipped on --dry-run + when a Makefile
|
|
817
|
+
// already exists at the repo root (auto-detect would have picked
|
|
818
|
+
// `local`; nothing more to scaffold).
|
|
819
|
+
if (ciGateChoice.mode === 'local' && !opts.dryRun) {
|
|
820
|
+
const repoRootForScaffold = process.cwd();
|
|
821
|
+
const makefileExists = fs.existsSync(path.join(repoRootForScaffold, 'Makefile'));
|
|
822
|
+
if (!makefileExists) {
|
|
823
|
+
console.log('');
|
|
824
|
+
console.log('HC-RC-004: scaffolding HC-101 Makefile + compose.local-ci.yml for local mode...');
|
|
825
|
+
try {
|
|
826
|
+
const assets = [
|
|
827
|
+
{ remote: '/scripts/local-ci/makefile', local: 'Makefile' },
|
|
828
|
+
{ remote: '/scripts/local-ci/compose', local: 'compose.local-ci.yml' },
|
|
829
|
+
];
|
|
830
|
+
// HC-RC-004 pass-1 (MED-1): each asset gets the same
|
|
831
|
+
// backup-on-exist treatment setup-local-ci uses (HC-101-followup-3
|
|
832
|
+
// timestamped-backup lesson). The Makefile branch is already
|
|
833
|
+
// gated above; the compose file also needs the same defense so
|
|
834
|
+
// an adopter who hand-rolled compose.local-ci.yml first then
|
|
835
|
+
// re-ran setup doesn't lose customizations.
|
|
836
|
+
for (const a of assets) {
|
|
837
|
+
const target = path.join(repoRootForScaffold, a.local);
|
|
838
|
+
if (fs.existsSync(target)) {
|
|
839
|
+
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15);
|
|
840
|
+
const backup = `${target}.hone-backup-${ts}`;
|
|
841
|
+
fs.copyFileSync(target, backup);
|
|
842
|
+
console.log(` ✓ Backed up existing ${a.local} → ${path.basename(backup)}`);
|
|
843
|
+
}
|
|
844
|
+
const { data } = await client.get(a.remote, { responseType: 'text', transformResponse: [(d) => d] });
|
|
845
|
+
fs.writeFileSync(target, String(data));
|
|
846
|
+
console.log(` ✓ Wrote ${a.local}`);
|
|
847
|
+
}
|
|
848
|
+
console.log(' → Customize Makefile TODO sections, then run `make ci` to verify');
|
|
849
|
+
} catch (e) {
|
|
850
|
+
console.log(` ⚠ Could not scaffold local-CI assets: ${e.message}`);
|
|
851
|
+
console.log(' Run `hone setup-local-ci` to retry the scaffold step.');
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
console.log(' ℹ Existing Makefile detected — skipping local-CI scaffold');
|
|
855
|
+
console.log(' (the auto-detect path already picked `local` for you)');
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
437
859
|
// ── Phase 1b: Install CLAUDE.md ──────────────────────────────────────────
|
|
438
860
|
// HC-019y: removed the install-time .github/agents/ -> .claude/agents/
|
|
439
861
|
// mirror. The bash setup script now writes agents directly to
|
|
@@ -1160,6 +1582,11 @@ program
|
|
|
1160
1582
|
if (result.skills) {
|
|
1161
1583
|
let preservedCount = 0;
|
|
1162
1584
|
let sidecarCount = 0;
|
|
1585
|
+
let evalScenariosCount = 0;
|
|
1586
|
+
// HC-010d pass-2 HIGH: per-skill eval-scenarios.json payload.
|
|
1587
|
+
const evalScenarios = (result.evalScenarios && typeof result.evalScenarios === 'object')
|
|
1588
|
+
? result.evalScenarios
|
|
1589
|
+
: {};
|
|
1163
1590
|
for (const [skillName, content] of Object.entries(result.skills)) {
|
|
1164
1591
|
if (!content || content.length < 50) continue;
|
|
1165
1592
|
const skillDir = path.join(repoRoot, '.github', 'skills', skillName);
|
|
@@ -1183,6 +1610,19 @@ program
|
|
|
1183
1610
|
console.log(` ✓ .github/skills/${skillName}/SKILL.md`);
|
|
1184
1611
|
}
|
|
1185
1612
|
}
|
|
1613
|
+
// HC-010d pass-2 HIGH: write eval-scenarios.json sibling if the
|
|
1614
|
+
// server validated one for this skill. Skip null/undefined/empty
|
|
1615
|
+
// (parseOutput validator dropped malformed blocks already).
|
|
1616
|
+
const scenarios = evalScenarios[skillName];
|
|
1617
|
+
const isEmpty = !scenarios
|
|
1618
|
+
|| (Array.isArray(scenarios) && scenarios.length === 0)
|
|
1619
|
+
|| (typeof scenarios === 'object' && Object.keys(scenarios).length === 0);
|
|
1620
|
+
if (!isEmpty) {
|
|
1621
|
+
const evalFile = path.join(skillDir, 'eval-scenarios.json');
|
|
1622
|
+
fs.writeFileSync(evalFile, JSON.stringify(scenarios, null, 2) + '\n');
|
|
1623
|
+
evalScenariosCount++;
|
|
1624
|
+
console.log(` ✓ .github/skills/${skillName}/eval-scenarios.json`);
|
|
1625
|
+
}
|
|
1186
1626
|
}
|
|
1187
1627
|
if (preservedCount > 0) {
|
|
1188
1628
|
console.log(`\n Preserved adopter REPO-SPECIFIC content in ${preservedCount} skill(s).`);
|
|
@@ -1194,6 +1634,10 @@ program
|
|
|
1194
1634
|
console.log(` To opt into automatic splice protection on the next derive, add a`);
|
|
1195
1635
|
console.log(` '<!-- REPO-SPECIFIC -->' marker to the original file.`);
|
|
1196
1636
|
}
|
|
1637
|
+
if (evalScenariosCount > 0) {
|
|
1638
|
+
console.log(`\n Wrote ${evalScenariosCount} eval-scenarios.json file(s) (HC-010d).`);
|
|
1639
|
+
console.log(` Future executor (hone skill-eval, HC-010d-followup-1) will probe these.`);
|
|
1640
|
+
}
|
|
1197
1641
|
}
|
|
1198
1642
|
|
|
1199
1643
|
// H-022: surface parser warnings so silent drops become VISIBLE failures.
|
|
@@ -1457,6 +1901,207 @@ program
|
|
|
1457
1901
|
}
|
|
1458
1902
|
});
|
|
1459
1903
|
|
|
1904
|
+
// ── BYO-KEY command (HC-COMM-013 / A2) ───────────────────────────────────────
|
|
1905
|
+
// Self-serve management of your org's Anthropic API key for server mode. The
|
|
1906
|
+
// key is read from stdin (piped) or a HIDDEN prompt — NEVER accepted on argv,
|
|
1907
|
+
// which would leak it to shell history (~/.zsh_history), `ps`, and CI logs.
|
|
1908
|
+
|
|
1909
|
+
// Read a secret without echoing it. Piped stdin → read all of it (CI/secret-
|
|
1910
|
+
// manager injection). Interactive TTY → a muted readline prompt.
|
|
1911
|
+
async function _readSecret(promptText) {
|
|
1912
|
+
if (!process.stdin.isTTY) {
|
|
1913
|
+
const chunks = [];
|
|
1914
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1915
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
1916
|
+
}
|
|
1917
|
+
const readline = await import('readline');
|
|
1918
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1919
|
+
return new Promise((resolve) => {
|
|
1920
|
+
process.stdout.write(promptText);
|
|
1921
|
+
// Mute echo of typed characters — the prompt is already written.
|
|
1922
|
+
rl._writeToOutput = () => {};
|
|
1923
|
+
rl.question('', (answer) => {
|
|
1924
|
+
rl.close();
|
|
1925
|
+
process.stdout.write('\n');
|
|
1926
|
+
resolve(answer);
|
|
1927
|
+
});
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function _handleByoKeyError(e) {
|
|
1932
|
+
const status = e.response?.status;
|
|
1933
|
+
if (status === 401) {
|
|
1934
|
+
console.error('Not authenticated. Run: hone init --token <token>');
|
|
1935
|
+
} else if (status === 429) {
|
|
1936
|
+
const retry = e.response?.data?.retryAfter;
|
|
1937
|
+
console.error(`Rate limited — too many key writes.${retry ? ` Retry after ${retry}s.` : ''}`);
|
|
1938
|
+
} else if (status === 503) {
|
|
1939
|
+
console.error(`Server cannot store the key right now: ${e.response?.data?.error || 'unavailable'}`);
|
|
1940
|
+
} else if (e.response?.data?.error) {
|
|
1941
|
+
console.error(`Failed: ${e.response.data.error}`);
|
|
1942
|
+
} else {
|
|
1943
|
+
console.error(`Failed: ${e.message}`);
|
|
1944
|
+
}
|
|
1945
|
+
process.exit(1);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const MAX_BYO_KEY_BYTES_CLI = 1024;
|
|
1949
|
+
|
|
1950
|
+
const byoKeyCmd = program
|
|
1951
|
+
.command('byo-key')
|
|
1952
|
+
.description("Manage your org's BYO Anthropic API key (server mode)");
|
|
1953
|
+
|
|
1954
|
+
byoKeyCmd
|
|
1955
|
+
.command('set')
|
|
1956
|
+
.description('Set or rotate your key — reads from stdin or a hidden prompt (never pass the key on the command line)')
|
|
1957
|
+
.option('--key-file <path>', 'Read the key from a file (e.g. a 0600 secrets file) instead of stdin/prompt')
|
|
1958
|
+
.action(async (opts) => {
|
|
1959
|
+
const config = getConfig();
|
|
1960
|
+
const client = api(config);
|
|
1961
|
+
|
|
1962
|
+
let key;
|
|
1963
|
+
try {
|
|
1964
|
+
key = opts.keyFile
|
|
1965
|
+
? fs.readFileSync(opts.keyFile, 'utf8')
|
|
1966
|
+
: await _readSecret('Anthropic API key: ');
|
|
1967
|
+
} catch (e) {
|
|
1968
|
+
console.error(`Could not read key: ${e.message}`);
|
|
1969
|
+
process.exit(1);
|
|
1970
|
+
}
|
|
1971
|
+
key = (key || '').trim();
|
|
1972
|
+
|
|
1973
|
+
// Client-side convenience checks (server remains the authority).
|
|
1974
|
+
if (!key) {
|
|
1975
|
+
console.error('No key provided (empty input). Pipe it in or type it at the prompt.');
|
|
1976
|
+
process.exit(1);
|
|
1977
|
+
}
|
|
1978
|
+
if (Buffer.byteLength(key, 'utf8') > MAX_BYO_KEY_BYTES_CLI) {
|
|
1979
|
+
console.error(`Key too large (max ${MAX_BYO_KEY_BYTES_CLI} bytes). Did you paste the wrong thing?`);
|
|
1980
|
+
process.exit(1);
|
|
1981
|
+
}
|
|
1982
|
+
if (!key.startsWith('sk-ant-')) {
|
|
1983
|
+
// Warn-only (memo §8): Anthropic key formats can change; don't hard-block.
|
|
1984
|
+
console.error('Warning: key does not look like an Anthropic key (expected "sk-ant-" prefix). Sending anyway.');
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
try {
|
|
1988
|
+
const { data } = await client.put('/orgs/me/byo-key', { byo_anthropic_key: key });
|
|
1989
|
+
console.log(data.wasConfigured
|
|
1990
|
+
? 'BYO key rotated. Pipeline LLM calls now bill your own Anthropic API.'
|
|
1991
|
+
: 'BYO key set. Pipeline LLM calls now bill your own Anthropic API.');
|
|
1992
|
+
} catch (e) { _handleByoKeyError(e); }
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
byoKeyCmd
|
|
1996
|
+
.command('clear')
|
|
1997
|
+
.description('Clear your key — your org reverts to the Hone default key')
|
|
1998
|
+
.action(async () => {
|
|
1999
|
+
const config = getConfig();
|
|
2000
|
+
const client = api(config);
|
|
2001
|
+
try {
|
|
2002
|
+
const { data } = await client.delete('/orgs/me/byo-key');
|
|
2003
|
+
console.log(data.wasConfigured
|
|
2004
|
+
? 'BYO key cleared. Your org reverts to the Hone default key.'
|
|
2005
|
+
: 'No BYO key was configured — nothing to clear.');
|
|
2006
|
+
} catch (e) { _handleByoKeyError(e); }
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
byoKeyCmd
|
|
2010
|
+
.command('status')
|
|
2011
|
+
.description('Show whether a BYO key is configured (never prints the key)')
|
|
2012
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2013
|
+
.action(async (opts) => {
|
|
2014
|
+
const config = getConfig();
|
|
2015
|
+
const client = api(config);
|
|
2016
|
+
try {
|
|
2017
|
+
const { data } = await client.get('/orgs/me/byo-key');
|
|
2018
|
+
if (opts.format === 'json') {
|
|
2019
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
console.log(`BYO key configured: ${data.configured ? 'yes' : 'no'}`);
|
|
2023
|
+
if (data.lastWrite) {
|
|
2024
|
+
const when = String(data.lastWrite.at).split('T')[0];
|
|
2025
|
+
console.log(`Last change: ${data.lastWrite.action} by ${data.lastWrite.actorType} on ${when}`);
|
|
2026
|
+
}
|
|
2027
|
+
if (data.configured && data.envelopeOk === false) {
|
|
2028
|
+
console.log('Warning: stored value is not in the expected encrypted envelope shape — contact support.');
|
|
2029
|
+
}
|
|
2030
|
+
// HC-COMM-014: fold a one-line trial summary in (non-fatal if unavailable).
|
|
2031
|
+
try {
|
|
2032
|
+
const { data: plan } = await client.get('/orgs/me/plan');
|
|
2033
|
+
if (plan.mode === 'trial') {
|
|
2034
|
+
console.log(`Trial: active — ${plan.trial.daysLeft}d left, $${Number(plan.trial.creditRemaining).toFixed(2)} credit`);
|
|
2035
|
+
} else if (plan.mode === 'expired') {
|
|
2036
|
+
console.log('Trial: ended (set a key or upgrade)');
|
|
2037
|
+
}
|
|
2038
|
+
} catch { /* trial line is a nicety — never block byo-key status */ }
|
|
2039
|
+
console.log('Tip: run `hone byo-key check` to verify the key still authenticates with Anthropic.');
|
|
2040
|
+
} catch (e) { _handleByoKeyError(e); }
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
byoKeyCmd
|
|
2044
|
+
.command('check')
|
|
2045
|
+
.description('Verify the configured key still authenticates with Anthropic (zero-token live probe; never prints the key)')
|
|
2046
|
+
.action(async () => {
|
|
2047
|
+
const config = getConfig();
|
|
2048
|
+
const client = api(config);
|
|
2049
|
+
try {
|
|
2050
|
+
const { data } = await client.post('/orgs/me/byo-key/check');
|
|
2051
|
+
if (data.ok) {
|
|
2052
|
+
console.log('BYO key is valid — Anthropic accepted it.');
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
const msg = {
|
|
2056
|
+
unauthorized: 'BYO key was REJECTED by Anthropic (401/403). Rotate it with `hone byo-key set`.',
|
|
2057
|
+
unreachable: 'Could not reach Anthropic to check the key — try again shortly (key not changed).',
|
|
2058
|
+
unexpected: 'Anthropic returned an unexpected status checking the key — try again shortly.',
|
|
2059
|
+
}[data.reason] || `Key check failed: ${data.reason}`;
|
|
2060
|
+
console.error(msg);
|
|
2061
|
+
process.exit(1);
|
|
2062
|
+
} catch (e) { _handleByoKeyError(e); }
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
// ── TRIAL command (HC-COMM-014 / A3) ─────────────────────────────────────────
|
|
2066
|
+
const trialCmd = program
|
|
2067
|
+
.command('trial')
|
|
2068
|
+
.description("View or start your org's Hone-funded trial (server mode)");
|
|
2069
|
+
|
|
2070
|
+
trialCmd
|
|
2071
|
+
.command('status')
|
|
2072
|
+
.description('Show trial mode, days left, and credit remaining')
|
|
2073
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2074
|
+
.action(async (opts) => {
|
|
2075
|
+
const config = getConfig();
|
|
2076
|
+
const client = api(config);
|
|
2077
|
+
try {
|
|
2078
|
+
const { data } = await client.get('/orgs/me/plan');
|
|
2079
|
+
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
2080
|
+
const t = data.trial || {};
|
|
2081
|
+
console.log(`Plan mode: ${data.mode}`);
|
|
2082
|
+
console.log(`Trial status: ${t.status}`);
|
|
2083
|
+
if (t.status === 'active') {
|
|
2084
|
+
console.log(`Days left: ${t.daysLeft}`);
|
|
2085
|
+
console.log(`Credit: $${Number(t.creditUsdUsed || 0).toFixed(2)} used / $${Number(t.creditUsdCap).toFixed(2)} cap ($${Number(t.creditRemaining).toFixed(2)} left)`);
|
|
2086
|
+
}
|
|
2087
|
+
if (data.mode === 'expired') {
|
|
2088
|
+
console.log('Trial ended — set your own key (`hone byo-key set`) or upgrade to continue.');
|
|
2089
|
+
}
|
|
2090
|
+
} catch (e) { _handleByoKeyError(e); }
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
trialCmd
|
|
2094
|
+
.command('start')
|
|
2095
|
+
.description('Start your trial now (idempotent — no-op if already started or ended)')
|
|
2096
|
+
.action(async () => {
|
|
2097
|
+
const config = getConfig();
|
|
2098
|
+
const client = api(config);
|
|
2099
|
+
try {
|
|
2100
|
+
const { data } = await client.post('/orgs/me/trial/start');
|
|
2101
|
+
console.log(data.started ? `Trial started — status: ${data.status}.` : `No-op — ${data.note}`);
|
|
2102
|
+
} catch (e) { _handleByoKeyError(e); }
|
|
2103
|
+
});
|
|
2104
|
+
|
|
1460
2105
|
// ── ADMIN-USAGE command ──────────────────────────────────────────────────────
|
|
1461
2106
|
program
|
|
1462
2107
|
.command('admin-usage')
|
|
@@ -2209,7 +2854,7 @@ program
|
|
|
2209
2854
|
const branchSamples = [];
|
|
2210
2855
|
try {
|
|
2211
2856
|
const out = execSync('git for-each-ref --sort=-committerdate --count=30 --format=%(refname:short) refs/heads/ refs/remotes/', {
|
|
2212
|
-
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
2857
|
+
cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
2213
2858
|
});
|
|
2214
2859
|
branchSamples.push(...out.split('\n').filter(Boolean));
|
|
2215
2860
|
} catch { /* not a git repo — skip */ }
|
|
@@ -2320,6 +2965,26 @@ program
|
|
|
2320
2965
|
const repoRoot = process.cwd();
|
|
2321
2966
|
const results = [];
|
|
2322
2967
|
|
|
2968
|
+
// Pass-2 review LOW (HC-020e): validate --check against the
|
|
2969
|
+
// allowlist of known sub-checks. Before this, a typo (e.g.,
|
|
2970
|
+
// `--check architectur`) silently ran nothing and exited 0,
|
|
2971
|
+
// which is the worst doctor outcome ("looks healthy" but
|
|
2972
|
+
// actually skipped everything).
|
|
2973
|
+
const KNOWN_CHECKS = new Set([
|
|
2974
|
+
'all',
|
|
2975
|
+
'docs',
|
|
2976
|
+
'admin-merge',
|
|
2977
|
+
'bind-default',
|
|
2978
|
+
'placeholders',
|
|
2979
|
+
'skill-staleness',
|
|
2980
|
+
'architecture',
|
|
2981
|
+
]);
|
|
2982
|
+
if (!KNOWN_CHECKS.has(opts.check)) {
|
|
2983
|
+
const known = [...KNOWN_CHECKS].sort().join(', ');
|
|
2984
|
+
console.error(`✗ --check: unknown name "${opts.check}". Known checks: ${known}`);
|
|
2985
|
+
process.exit(2);
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2323
2988
|
// Read .pipeline-config.yml to learn the stack
|
|
2324
2989
|
let stack = 'unknown';
|
|
2325
2990
|
try {
|
|
@@ -2394,6 +3059,15 @@ program
|
|
|
2394
3059
|
results.push(checkSkillStaleness({ repoRoot }));
|
|
2395
3060
|
}
|
|
2396
3061
|
|
|
3062
|
+
// HC-020e: architecture staleness check — flags drift in
|
|
3063
|
+
// docs/sdlc/ARCHITECTURE.md based on the `<!-- Generated by
|
|
3064
|
+
// derive-domain-skills on YYYY-MM-DD -->` marker the derive prompt
|
|
3065
|
+
// now emits (companion change in this PR).
|
|
3066
|
+
if (opts.check === 'all' || opts.check === 'architecture') {
|
|
3067
|
+
const { checkArchitectureStaleness } = require('./lib/doctor-architecture');
|
|
3068
|
+
results.push(checkArchitectureStaleness({ repoRoot }));
|
|
3069
|
+
}
|
|
3070
|
+
|
|
2397
3071
|
// Render
|
|
2398
3072
|
if (opts.json) {
|
|
2399
3073
|
console.log(JSON.stringify({ checks: results }, null, 2));
|
|
@@ -2406,7 +3080,9 @@ program
|
|
|
2406
3080
|
: r.status === 'drift' ? '✗'
|
|
2407
3081
|
: r.status === 'info' ? 'ℹ'
|
|
2408
3082
|
: '⚠';
|
|
2409
|
-
const label = r.name === 'docs' ? 'Docs freshness'
|
|
3083
|
+
const label = r.name === 'docs' ? 'Docs freshness'
|
|
3084
|
+
: r.name === 'architecture' ? 'Architecture staleness'
|
|
3085
|
+
: r.name;
|
|
2410
3086
|
console.log(`${icon} ${label} — ${r.reason}`);
|
|
2411
3087
|
if (r.suggestedFix) {
|
|
2412
3088
|
console.log(` Fix: ${r.suggestedFix}`);
|
|
@@ -4102,7 +4778,7 @@ program
|
|
|
4102
4778
|
let branchName = '';
|
|
4103
4779
|
try {
|
|
4104
4780
|
branchName = execSync('git rev-parse --abbrev-ref HEAD',
|
|
4105
|
-
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
4781
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
4106
4782
|
} catch { /* defensive */ }
|
|
4107
4783
|
const storyId = cmdOpts.storyId || extractStoryIdFromBranch(branchName);
|
|
4108
4784
|
if (!storyId) {
|
|
@@ -4115,12 +4791,12 @@ program
|
|
|
4115
4791
|
let diff = '';
|
|
4116
4792
|
try {
|
|
4117
4793
|
diff = execSync(`git diff origin/${baseBranch}...HEAD`,
|
|
4118
|
-
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4794
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4119
4795
|
} catch {
|
|
4120
4796
|
// Fallback: no remote tracking — try local base
|
|
4121
4797
|
try {
|
|
4122
4798
|
diff = execSync(`git diff ${baseBranch}...HEAD`,
|
|
4123
|
-
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4799
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
4124
4800
|
} catch { /* leave empty */ }
|
|
4125
4801
|
}
|
|
4126
4802
|
|
|
@@ -4247,7 +4923,9 @@ program
|
|
|
4247
4923
|
.option('--contracts', 'Run contract validation between pipeline agents')
|
|
4248
4924
|
.option('--snapshot', 'Save current eval + contract results as regression baseline')
|
|
4249
4925
|
.option('--regression', 'Compare current results against saved baseline (detect drift)')
|
|
4250
|
-
.option('--judge', 'Run LLM-as-judge scenarios (
|
|
4926
|
+
.option('--judge', 'Run LLM-as-judge scenarios (default provider: free gh-models via GITHUB_TOKEN)')
|
|
4927
|
+
.option('--provider <name>', 'LLM-judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
|
|
4928
|
+
.option('--evidence-mode <mode>', 'HC-RC-001 editor-LLM evidence transfer: "local" writes .hone/eval-evidence.json (signed); "off" disables (default)')
|
|
4251
4929
|
.action(async (opts) => {
|
|
4252
4930
|
const path = require('path');
|
|
4253
4931
|
const fs = require('fs');
|
|
@@ -4314,12 +4992,8 @@ program
|
|
|
4314
4992
|
const { loadScenarios, formatResults } = require('./lib/eval-runner');
|
|
4315
4993
|
const { runJudgeScenario } = require('./lib/eval-llm-judge');
|
|
4316
4994
|
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
console.error('ANTHROPIC_API_KEY required for --judge mode. Set: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
4320
|
-
process.exit(1);
|
|
4321
|
-
}
|
|
4322
|
-
|
|
4995
|
+
// Load + filter FIRST — if there are no judge scenarios, exit cleanly
|
|
4996
|
+
// without demanding any API key / token.
|
|
4323
4997
|
const scenarios = loadScenarios({
|
|
4324
4998
|
evalDir, agent: opts.agent, tag: opts.tag, scenarioId: opts.scenario,
|
|
4325
4999
|
readFile: (p) => fs.readFileSync(p, 'utf8'),
|
|
@@ -4333,25 +5007,21 @@ program
|
|
|
4333
5007
|
process.exit(0);
|
|
4334
5008
|
}
|
|
4335
5009
|
|
|
4336
|
-
// LLM
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
return data.content?.[0]?.text || '';
|
|
4352
|
-
}
|
|
4353
|
-
|
|
4354
|
-
console.log(`Running ${judgeScenarios.length} LLM-judge scenario(s)...`);
|
|
5010
|
+
// HC-019n-followup-33 (pipeline-standards G6): the LLM-judge lane must have a
|
|
5011
|
+
// ZERO-COST path (feedback_pipeline_llm_cost_reduction — every LLM-cost gate
|
|
5012
|
+
// needs a free/near-free route so adopters aren't double-billed). DEFAULT to
|
|
5013
|
+
// GH Models (free inference via GITHUB_TOKEN); paid Claude Sonnet is opt-in
|
|
5014
|
+
// via `--provider claude`. Mirrors `hone skill-eval`'s provider convention.
|
|
5015
|
+
// HC-COMM-011-followup-3: provider wiring extracted to lib/judge-provider.js
|
|
5016
|
+
// (shared with `hone agent-eval --judge`). Same zero-cost default (gh-models)
|
|
5017
|
+
// + Sonnet-not-Opus paid path; the retired dated Sonnet id must not appear
|
|
5018
|
+
// (pinned by opus-4-8-upgrade.test.js).
|
|
5019
|
+
const { buildJudgeCallLLM } = require('./lib/judge-provider');
|
|
5020
|
+
const built = buildJudgeCallLLM(opts.provider, { axios });
|
|
5021
|
+
if (built.error) { console.error(built.error); process.exit(1); }
|
|
5022
|
+
const callLLM = built.callLLM;
|
|
5023
|
+
|
|
5024
|
+
console.log(`Running ${judgeScenarios.length} LLM-judge scenario(s) via ${built.provider}...`);
|
|
4355
5025
|
console.log('');
|
|
4356
5026
|
|
|
4357
5027
|
const results = [];
|
|
@@ -4413,35 +5083,260 @@ program
|
|
|
4413
5083
|
const results = runAllScenarios(scenarios, AGENT_PROMPTS, { failFast: opts.failFast });
|
|
4414
5084
|
console.log(formatResults(results, opts.format));
|
|
4415
5085
|
|
|
5086
|
+
// HC-RC-001: optionally write signed eval-evidence so CI can short-circuit
|
|
5087
|
+
// the LLM-cost gate. No-op when --evidence-mode is omitted or "off", or
|
|
5088
|
+
// when prerequisites (HONE_EVIDENCE_SECRET + diff input + metadata) are
|
|
5089
|
+
// missing. Skips with stderr warning rather than failing the eval run.
|
|
5090
|
+
try {
|
|
5091
|
+
const {
|
|
5092
|
+
normalizeEvidenceMode,
|
|
5093
|
+
buildEvidenceFromEval,
|
|
5094
|
+
writeEvidenceFile,
|
|
5095
|
+
} = require('./lib/eval-evidence');
|
|
5096
|
+
const mode = normalizeEvidenceMode(opts.evidenceMode);
|
|
5097
|
+
if (mode === 'local') {
|
|
5098
|
+
const record = buildEvidenceFromEval({ results, mode });
|
|
5099
|
+
if (record) {
|
|
5100
|
+
const out = writeEvidenceFile(record);
|
|
5101
|
+
process.stderr.write(`[hone eval] evidence-mode=local wrote ${out}\n`);
|
|
5102
|
+
}
|
|
5103
|
+
}
|
|
5104
|
+
} catch (e) {
|
|
5105
|
+
process.stderr.write(`[hone eval] evidence-mode error: ${e.message}\n`);
|
|
5106
|
+
}
|
|
5107
|
+
|
|
4416
5108
|
process.exit(results.failed + results.errors > 0 ? 1 : 0);
|
|
4417
5109
|
});
|
|
4418
5110
|
|
|
4419
|
-
// ── HC-
|
|
5111
|
+
// ── HC-010d-followup-1: hone skill-eval runtime executor ────────────────────
|
|
5112
|
+
//
|
|
5113
|
+
// Consumes the eval-scenarios.json artifacts that HC-010d emits next to
|
|
5114
|
+
// every derived <stack>-developer/SKILL.md and <stack>-architect/SKILL.md.
|
|
5115
|
+
// Distinct from `hone eval` (HC-019d) which grades AGENT PROMPTS
|
|
5116
|
+
// deterministically — `hone skill-eval` grades DERIVED SKILL OUTPUTS by
|
|
5117
|
+
// calling an LLM and scoring against expected_output_keywords +
|
|
5118
|
+
// expected_output_format heuristics. Two systems, two different
|
|
5119
|
+
// questions; coexist.
|
|
5120
|
+
//
|
|
5121
|
+
// Provider defaults to gh-models (free GH PAT inference) per the
|
|
5122
|
+
// [Pipeline LLM Cost Reduction] memory — adopters must not be
|
|
5123
|
+
// double-billed for what their pipeline already invoked.
|
|
4420
5124
|
program
|
|
4421
|
-
.command('
|
|
4422
|
-
.description('Run
|
|
4423
|
-
.option('--
|
|
4424
|
-
.option('--
|
|
4425
|
-
.option('--
|
|
4426
|
-
.option('--
|
|
4427
|
-
.option('--
|
|
4428
|
-
.option('--
|
|
4429
|
-
.option('--
|
|
4430
|
-
.
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
const
|
|
4434
|
-
|
|
5125
|
+
.command('skill-eval <skillName>')
|
|
5126
|
+
.description('Run derived-skill eval scenarios against an LLM (HC-010d-followup-1)')
|
|
5127
|
+
.option('--provider <name>', 'LLM provider: gh-models (default, $0) | claude-haiku (paid)', 'gh-models')
|
|
5128
|
+
.option('--scenario <id>', 'Run a single scenario by id (e.g., SF-DEV-EVAL-001)')
|
|
5129
|
+
.option('--tag <tag>', 'Filter scenarios by HC-010c rule-id tag (e.g., SF-SEC-001)')
|
|
5130
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
5131
|
+
.option('--fail-fast', 'Stop on first non-pass scenario')
|
|
5132
|
+
.option('--no-llm', 'Dry run: validate scenarios + print plan without calling the LLM')
|
|
5133
|
+
.option('--repo-root <path>', 'Override the repo root used for SKILL.md / eval-scenarios.json lookup')
|
|
5134
|
+
.action(async (skillName, opts) => {
|
|
5135
|
+
const fsLocal = require('fs');
|
|
5136
|
+
const pathLocal = require('path');
|
|
5137
|
+
const repoRoot = opts.repoRoot || process.cwd();
|
|
5138
|
+
|
|
5139
|
+
const { validateScenarios } = require(
|
|
5140
|
+
pathLocal.resolve(__dirname, '..', 'server', 'src', 'services', 'eval-scenarios')
|
|
5141
|
+
);
|
|
5142
|
+
const {
|
|
5143
|
+
loadSkillEvalScenarios,
|
|
5144
|
+
runAllSkillScenarios,
|
|
5145
|
+
formatResults,
|
|
5146
|
+
} = require('./lib/skill-eval-runner');
|
|
5147
|
+
|
|
5148
|
+
const loaded = loadSkillEvalScenarios({
|
|
5149
|
+
repoRoot, skillName, fs: fsLocal, path: pathLocal, validateScenarios,
|
|
5150
|
+
});
|
|
5151
|
+
if (!loaded.ok) {
|
|
5152
|
+
console.error(`✗ hone skill-eval: cannot load ${skillName}`);
|
|
5153
|
+
for (const e of loaded.errors) {
|
|
5154
|
+
console.error(` ${e.path}: ${e.message}`);
|
|
5155
|
+
}
|
|
5156
|
+
process.exit(2);
|
|
5157
|
+
}
|
|
4435
5158
|
|
|
4436
|
-
//
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
5159
|
+
// Apply --scenario / --tag filters.
|
|
5160
|
+
let scenarios = loaded.scenarios;
|
|
5161
|
+
if (opts.scenario) {
|
|
5162
|
+
scenarios = scenarios.filter((s) => s.id === opts.scenario);
|
|
5163
|
+
if (scenarios.length === 0) {
|
|
5164
|
+
console.error(`✗ no scenario with id "${opts.scenario}" in ${loaded.scenariosPath}`);
|
|
5165
|
+
process.exit(2);
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
if (opts.tag) {
|
|
5169
|
+
scenarios = scenarios.filter((s) => (s.tags || []).includes(opts.tag));
|
|
5170
|
+
if (scenarios.length === 0) {
|
|
5171
|
+
console.error(`✗ no scenarios tagged "${opts.tag}" in ${loaded.scenariosPath}`);
|
|
5172
|
+
process.exit(2);
|
|
5173
|
+
}
|
|
5174
|
+
}
|
|
4441
5175
|
|
|
4442
|
-
//
|
|
4443
|
-
|
|
4444
|
-
|
|
5176
|
+
// --no-llm: validate + print plan, exit 0. Lets adopters check shape
|
|
5177
|
+
// without spending tokens or burning CI minutes (per the memory
|
|
5178
|
+
// [CI Minutes Budget]).
|
|
5179
|
+
//
|
|
5180
|
+
// commander.js converts `--no-llm` to opts.llm = false.
|
|
5181
|
+
if (opts.llm === false) {
|
|
5182
|
+
console.log(`Hone Skill Eval — dry run (--no-llm)`);
|
|
5183
|
+
console.log('====================================');
|
|
5184
|
+
console.log(`Skill: ${loaded.skill}`);
|
|
5185
|
+
console.log(`SKILL.md: ${loaded.skillPath}`);
|
|
5186
|
+
console.log(`eval-scenarios: ${loaded.scenariosPath}`);
|
|
5187
|
+
console.log(`Scenarios: ${scenarios.length} matched`);
|
|
5188
|
+
console.log('');
|
|
5189
|
+
for (const s of scenarios) {
|
|
5190
|
+
const tagStr = s.tags?.length ? ` [${s.tags.join(', ')}]` : '';
|
|
5191
|
+
console.log(` ${s.id} ${s.category.padEnd(16)} ${s.name}${tagStr}`);
|
|
5192
|
+
}
|
|
5193
|
+
process.exit(0);
|
|
5194
|
+
}
|
|
5195
|
+
|
|
5196
|
+
// Provider wiring — gh-models default. Inline + per-provider so a
|
|
5197
|
+
// future provider addition lives in one switch.
|
|
5198
|
+
const axios = require('axios');
|
|
5199
|
+
// Pass-2 review caught the original 32000-char slice collided with
|
|
5200
|
+
// GH Models' documented 8000-token request-body cap (see
|
|
5201
|
+
// `cli/lib/release-review-config.js:49-68`). A real adopter
|
|
5202
|
+
// SKILL.md after years of derivations is plausibly 20-40K chars;
|
|
5203
|
+
// 32K leaves zero budget for scenario.input + JSON envelope, so
|
|
5204
|
+
// GH Models returns HTTP 400 and every scenario errors. 8000
|
|
5205
|
+
// chars ≈ ~2000 tokens for the system slot, leaving ~6000 tokens
|
|
5206
|
+
// for scenario.input + envelope — comfortable under GH Models'
|
|
5207
|
+
// cap while still preserving enough of the skill body to evaluate
|
|
5208
|
+
// adopter patterns.
|
|
5209
|
+
const MAX_SKILL_PROMPT_CHARS = 8000;
|
|
5210
|
+
let apiKey, modelLabel, callLLM;
|
|
5211
|
+
if (opts.provider === 'gh-models') {
|
|
5212
|
+
apiKey = process.env.GITHUB_TOKEN;
|
|
5213
|
+
if (!apiKey) {
|
|
5214
|
+
console.error('✗ GITHUB_TOKEN not set. Required for --provider gh-models.');
|
|
5215
|
+
console.error(' In CI: GITHUB_TOKEN is auto-injected. Locally: export GITHUB_TOKEN=<your PAT>.');
|
|
5216
|
+
// Exit 2 — operator config error, NOT a skill regression.
|
|
5217
|
+
// Pass-2 review caught: exit 1 collided with the CI-gate exit
|
|
5218
|
+
// code for eval failures, so a missing secret was reported
|
|
5219
|
+
// as "skill regression" by the CI gate. The convention from
|
|
5220
|
+
// the regression at line 172-188 is exit 2 for operator
|
|
5221
|
+
// errors, exit 1 for eval failures.
|
|
5222
|
+
process.exit(2);
|
|
5223
|
+
}
|
|
5224
|
+
modelLabel = 'openai/gpt-4.1';
|
|
5225
|
+
callLLM = async (systemPrompt, userPrompt) => {
|
|
5226
|
+
const { data } = await axios.post(
|
|
5227
|
+
'https://models.github.ai/inference/chat/completions',
|
|
5228
|
+
{
|
|
5229
|
+
model: modelLabel,
|
|
5230
|
+
messages: [
|
|
5231
|
+
{ role: 'system', content: systemPrompt.slice(0, MAX_SKILL_PROMPT_CHARS) },
|
|
5232
|
+
{ role: 'user', content: userPrompt },
|
|
5233
|
+
],
|
|
5234
|
+
max_tokens: 2048,
|
|
5235
|
+
},
|
|
5236
|
+
{
|
|
5237
|
+
headers: {
|
|
5238
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
5239
|
+
'Content-Type': 'application/json',
|
|
5240
|
+
},
|
|
5241
|
+
timeout: 120000,
|
|
5242
|
+
}
|
|
5243
|
+
);
|
|
5244
|
+
return data.choices?.[0]?.message?.content || '';
|
|
5245
|
+
};
|
|
5246
|
+
} else if (opts.provider === 'claude-haiku') {
|
|
5247
|
+
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
5248
|
+
if (!apiKey) {
|
|
5249
|
+
console.error('✗ ANTHROPIC_API_KEY not set. Required for --provider claude-haiku.');
|
|
5250
|
+
console.error(' Set: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
5251
|
+
// Exit 2 — operator config error (see gh-models branch).
|
|
5252
|
+
process.exit(2);
|
|
5253
|
+
}
|
|
5254
|
+
modelLabel = 'claude-haiku-4-5-20251001';
|
|
5255
|
+
callLLM = async (systemPrompt, userPrompt) => {
|
|
5256
|
+
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
5257
|
+
model: modelLabel,
|
|
5258
|
+
max_tokens: 2048,
|
|
5259
|
+
system: systemPrompt.slice(0, MAX_SKILL_PROMPT_CHARS),
|
|
5260
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
5261
|
+
}, {
|
|
5262
|
+
headers: {
|
|
5263
|
+
'x-api-key': apiKey,
|
|
5264
|
+
'anthropic-version': '2023-06-01',
|
|
5265
|
+
'content-type': 'application/json',
|
|
5266
|
+
},
|
|
5267
|
+
timeout: 120000,
|
|
5268
|
+
});
|
|
5269
|
+
return data.content?.[0]?.text || '';
|
|
5270
|
+
};
|
|
5271
|
+
} else {
|
|
5272
|
+
console.error(`✗ Invalid --provider: ${opts.provider}. Use 'gh-models' or 'claude-haiku'.`);
|
|
5273
|
+
process.exit(2);
|
|
5274
|
+
}
|
|
5275
|
+
|
|
5276
|
+
// Stream progress so the operator sees a heartbeat on long runs
|
|
5277
|
+
// (8-15 dev scenarios × ~5-20s LLM round-trip = 1-5 min per skill).
|
|
5278
|
+
const onProgress = (cur, total, lastResult) => {
|
|
5279
|
+
if (opts.format === 'json') return; // JSON mode is silent until the final dump
|
|
5280
|
+
const icon = lastResult.result === 'pass' ? '✓'
|
|
5281
|
+
: lastResult.result === 'fail' ? '✗'
|
|
5282
|
+
: '!';
|
|
5283
|
+
process.stderr.write(
|
|
5284
|
+
` [${cur}/${total}] ${icon} ${lastResult.id} — ${lastResult.name}\n`
|
|
5285
|
+
);
|
|
5286
|
+
};
|
|
5287
|
+
|
|
5288
|
+
if (opts.format !== 'json') {
|
|
5289
|
+
console.log(`Hone Skill Eval — ${loaded.skill}`);
|
|
5290
|
+
console.log(`Provider: ${opts.provider} (${modelLabel})`);
|
|
5291
|
+
console.log(`Scenarios: ${scenarios.length}`);
|
|
5292
|
+
console.log('');
|
|
5293
|
+
}
|
|
5294
|
+
|
|
5295
|
+
const summary = await runAllSkillScenarios({
|
|
5296
|
+
scenarios,
|
|
5297
|
+
skillContent: loaded.skillContent,
|
|
5298
|
+
callLLM,
|
|
5299
|
+
failFast: opts.failFast,
|
|
5300
|
+
onProgress,
|
|
5301
|
+
});
|
|
5302
|
+
|
|
5303
|
+
console.log(formatResults(summary, opts.format));
|
|
5304
|
+
|
|
5305
|
+
// Exit 1 on any non-pass so CI (HC-019f-style gate) can wire this
|
|
5306
|
+
// as a required check. Per the [Pipeline LLM Cost Reduction]
|
|
5307
|
+
// memory, this gate can run in CI on the gh-models default with
|
|
5308
|
+
// zero adopter cost.
|
|
5309
|
+
process.exit(summary.failed + summary.errors > 0 ? 1 : 0);
|
|
5310
|
+
});
|
|
5311
|
+
|
|
5312
|
+
// ── HC-041: Run Story (Orchestrator) ─────────────────────────────────────────
|
|
5313
|
+
program
|
|
5314
|
+
.command('run-story <storyId>')
|
|
5315
|
+
.description('Run the SDLC pipeline for a story via the orchestrator')
|
|
5316
|
+
.option('--mode <mode>', 'Execution mode: interactive or batch', 'interactive')
|
|
5317
|
+
.option('--repo <name>', 'Repository name override (default: directory name)')
|
|
5318
|
+
.option('--branch <name>', 'Git branch (default: current)')
|
|
5319
|
+
.option('--status', 'Show status of the latest workflow run for this story')
|
|
5320
|
+
.option('--kill', 'Kill the latest workflow run for this story')
|
|
5321
|
+
.option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0)')
|
|
5322
|
+
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
5323
|
+
.option('--poll-interval <s>', 'Poll interval in seconds', '5')
|
|
5324
|
+
.option('--include-paths <paths>', 'Comma-separated list of source file paths to bundle into the codebase context (HC-019n-followup-12). Augments auto-detected paths from the issue body.')
|
|
5325
|
+
.option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
|
|
5326
|
+
.option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
|
|
5327
|
+
.action(async (storyIdOrRunId, opts) => {
|
|
5328
|
+
const config = getConfig();
|
|
5329
|
+
const client = api(config);
|
|
5330
|
+
|
|
5331
|
+
// Resolve: if it looks like a UUID, use as runId directly.
|
|
5332
|
+
// Otherwise treat as storyId — will be used for POST /orchestrate body.
|
|
5333
|
+
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(storyIdOrRunId);
|
|
5334
|
+
const storyId = isUuid ? null : storyIdOrRunId;
|
|
5335
|
+
const runId = isUuid ? storyIdOrRunId : null;
|
|
5336
|
+
|
|
5337
|
+
// Status mode
|
|
5338
|
+
if (opts.status) {
|
|
5339
|
+
const statusId = runId || storyIdOrRunId;
|
|
4445
5340
|
try {
|
|
4446
5341
|
const { data } = await client.get(`/orchestrate/${statusId}`);
|
|
4447
5342
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
@@ -4523,8 +5418,16 @@ program
|
|
|
4523
5418
|
// fail, private repo without auth): warn and proceed without context.
|
|
4524
5419
|
// The existing HC-019n-followup-7 hard_pause safety net catches the
|
|
4525
5420
|
// resulting placeholder cascade at step_1.
|
|
5421
|
+
//
|
|
5422
|
+
// HC-019b-followup-1 F1: when invoked with an issue number, also try
|
|
5423
|
+
// to extract a canonical story-id (e.g. HC-019b) from the issue title.
|
|
5424
|
+
// Used by the architect-config read below — without this, the lookup
|
|
5425
|
+
// keys on '104' instead of 'HC-019b' and silently bypasses every
|
|
5426
|
+
// architect-engaged story whose EXECUTION_PLAN.yml entry uses the
|
|
5427
|
+
// canonical id (which is the common adopter case).
|
|
4526
5428
|
const orchestrateConfig = {};
|
|
4527
5429
|
let issueBodyForFiles = null;
|
|
5430
|
+
let resolvedStoryId = storyIdOrRunId; // canonical id for architect-config lookup
|
|
4528
5431
|
if (/^\d+$/.test(storyIdOrRunId)) {
|
|
4529
5432
|
try {
|
|
4530
5433
|
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
|
|
@@ -4540,10 +5443,60 @@ program
|
|
|
4540
5443
|
orchestrateConfig.story_description = desc;
|
|
4541
5444
|
issueBodyForFiles = `${issue.title}\n${issue.body || ''}`;
|
|
4542
5445
|
console.log(` → fetched GitHub issue #${storyIdOrRunId} for story context (${desc.length} chars)`);
|
|
5446
|
+
// HC-019b-followup-1 F1: extract the canonical story-id from the
|
|
5447
|
+
// issue title. extractStoryIdFromBranch's regex (STORY_ID_PATTERN
|
|
5448
|
+
// in pipeline-status.js) handles HC-NNN, HC-NNN-A, H-NNNb, E22-D,
|
|
5449
|
+
// and HC-NNN-followup-N shapes after H-029-followup-2.
|
|
5450
|
+
try {
|
|
5451
|
+
const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
|
|
5452
|
+
const titleId = extractStoryIdFromBranch(issue.title);
|
|
5453
|
+
if (titleId) {
|
|
5454
|
+
resolvedStoryId = titleId;
|
|
5455
|
+
console.log(` → resolved issue #${storyIdOrRunId} → canonical story id '${titleId}' for architect-config lookup`);
|
|
5456
|
+
} else {
|
|
5457
|
+
console.warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
5458
|
+
}
|
|
5459
|
+
} catch { /* extractor missing/throws → fall back to numeric id */ }
|
|
4543
5460
|
} catch (e) {
|
|
4544
5461
|
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
4545
5462
|
console.warn(` ⚠ could not fetch GitHub issue context: ${msg}`);
|
|
4546
5463
|
console.warn(' → proceeding without story_description; step_0 may produce placeholder output');
|
|
5464
|
+
console.warn(` ⚠ architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
5465
|
+
}
|
|
5466
|
+
} else {
|
|
5467
|
+
// HC-019n-followup-18: roadmap-sourced story. Only the SERVER knows what
|
|
5468
|
+
// the story says (server/seeds/master-roadmap.md is a Hone asset, absent
|
|
5469
|
+
// from adopter repos) and only the CLI can read the adopter's disk — so
|
|
5470
|
+
// ask the server what the description references, then read those files
|
|
5471
|
+
// here. See .github/pipeline/HC-019n-followup-18/architect.md §1 for why
|
|
5472
|
+
// this beats the CLI reading a local roadmap (hardcodes a Hone-internal
|
|
5473
|
+
// path into the adopter CLI, and fails silently anywhere else).
|
|
5474
|
+
//
|
|
5475
|
+
// Advisory, never fatal: a story with no resolvable context still runs,
|
|
5476
|
+
// it just runs blind — and loudly, via the followup-17 warning below.
|
|
5477
|
+
try {
|
|
5478
|
+
const ctxClient = api(config);
|
|
5479
|
+
const { data } = await ctxClient.get(
|
|
5480
|
+
`/stories/${encodeURIComponent(storyIdOrRunId)}/context`
|
|
5481
|
+
);
|
|
5482
|
+
if (data && data.description) {
|
|
5483
|
+
orchestrateConfig.story_description = data.description;
|
|
5484
|
+
issueBodyForFiles = data.description;
|
|
5485
|
+
const n = Array.isArray(data.referencedPaths) ? data.referencedPaths.length : 0;
|
|
5486
|
+
console.log(
|
|
5487
|
+
` → resolved story context from roadmap (${data.description.length} chars, ` +
|
|
5488
|
+
`${n} referenced path${n === 1 ? '' : 's'})`
|
|
5489
|
+
);
|
|
5490
|
+
}
|
|
5491
|
+
} catch (e) {
|
|
5492
|
+
const status = e && e.response && e.response.status;
|
|
5493
|
+
if (status === 404) {
|
|
5494
|
+
console.warn(` ⚠ no roadmap row found for '${storyIdOrRunId}'`);
|
|
5495
|
+
} else {
|
|
5496
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
5497
|
+
console.warn(` ⚠ could not fetch story context: ${msg}`);
|
|
5498
|
+
}
|
|
5499
|
+
console.warn(' → proceeding without story context; the warning below applies');
|
|
4547
5500
|
}
|
|
4548
5501
|
}
|
|
4549
5502
|
|
|
@@ -4586,6 +5539,45 @@ program
|
|
|
4586
5539
|
candidates.add(p);
|
|
4587
5540
|
}
|
|
4588
5541
|
}
|
|
5542
|
+
// HC-019n-followup-26: FILE_PATH_RE above requires a directory segment,
|
|
5543
|
+
// so a story that names a file BARE (`derive-worker.js:787` in prose)
|
|
5544
|
+
// extracts 0 paths and step_4 runs blind — and, blind, hallucinates a
|
|
5545
|
+
// file at a non-existent path (2026-08-30 e2e demo). Catch bare refs too
|
|
5546
|
+
// and resolve each against the repo's own file list: bundle ONLY on a
|
|
5547
|
+
// unique basename match; never guess an ambiguous or absent one.
|
|
5548
|
+
// HC-019n-followup-27: candidate path → referenced line number, so the
|
|
5549
|
+
// bundler can WINDOW a too-big file around its edit site instead of
|
|
5550
|
+
// head-truncating past it (the demo's line 787 sits at char 37K).
|
|
5551
|
+
const candidateLines = new Map();
|
|
5552
|
+
if (issueBodyForFiles) {
|
|
5553
|
+
const { extractBareRefs, resolveBareName, alreadyCovered } = require('./lib/bundle-paths');
|
|
5554
|
+
const { gitEnv } = require('./lib/git-env');
|
|
5555
|
+
const bareRefs = extractBareRefs(issueBodyForFiles);
|
|
5556
|
+
if (bareRefs.length > 0) {
|
|
5557
|
+
let repoFiles = null;
|
|
5558
|
+
const listRepoFiles = () => {
|
|
5559
|
+
if (repoFiles) return repoFiles;
|
|
5560
|
+
try {
|
|
5561
|
+
repoFiles = execSync('git ls-files', {
|
|
5562
|
+
cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
5563
|
+
}).split('\n').filter(Boolean);
|
|
5564
|
+
} catch { repoFiles = []; }
|
|
5565
|
+
return repoFiles;
|
|
5566
|
+
};
|
|
5567
|
+
for (const { name, line } of bareRefs) {
|
|
5568
|
+
if (alreadyCovered(name, candidates)) continue;
|
|
5569
|
+
const r = resolveBareName(name, listRepoFiles());
|
|
5570
|
+
if (r.status === 'unique') {
|
|
5571
|
+
candidates.add(r.path);
|
|
5572
|
+
if (line && !candidateLines.has(r.path)) candidateLines.set(r.path, line);
|
|
5573
|
+
console.log(` → resolved bare ref '${name}${line ? ':' + line : ''}' → ${r.path}`);
|
|
5574
|
+
} else if (r.status === 'ambiguous') {
|
|
5575
|
+
console.warn(` ⚠ '${name}' is ambiguous (${r.matches.length} files) — not bundling; use --include-paths to disambiguate`);
|
|
5576
|
+
}
|
|
5577
|
+
// status === 'none' → not every filename in prose is a repo file; skip quietly
|
|
5578
|
+
}
|
|
5579
|
+
}
|
|
5580
|
+
}
|
|
4589
5581
|
// HC-019n-followup-13d: bump caps. Pre-fix MAX_FILES=10 /
|
|
4590
5582
|
// MAX_TOTAL_CHARS=50_000 / MAX_PER_FILE_CHARS=10_000 was overly
|
|
4591
5583
|
// conservative — OptionsFlow #96 hit 49,783/50,000 with 6 files
|
|
@@ -4609,7 +5601,23 @@ program
|
|
|
4609
5601
|
content = fs.readFileSync(abs, 'utf8');
|
|
4610
5602
|
} catch { continue; }
|
|
4611
5603
|
if (content.length > MAX_PER_FILE_CHARS) {
|
|
4612
|
-
|
|
5604
|
+
const line = candidateLines.get(candidate);
|
|
5605
|
+
if (line) {
|
|
5606
|
+
// HC-019n-followup-27: window around the referenced line so step_4
|
|
5607
|
+
// SEES the edit site (head-truncation would hide a site past the
|
|
5608
|
+
// cut) and can write a region-edit anchor. The window marker tells
|
|
5609
|
+
// step_4 to emit an `edit:` block, not a whole-file block.
|
|
5610
|
+
const { windowAroundLine } = require('./lib/bundle-paths');
|
|
5611
|
+
const w = windowAroundLine(content, line, Math.floor((MAX_PER_FILE_CHARS - 800) / 2));
|
|
5612
|
+
content =
|
|
5613
|
+
`# [HC-019n-followup-27: windowed excerpt, lines ${w.startLine}-${w.endLine} of ${w.totalLines} ` +
|
|
5614
|
+
`(centered on referenced line ${line}); you were NOT shown the whole file — ` +
|
|
5615
|
+
`emit an \`edit:\` block with a unique OLD anchor from this excerpt, NOT a whole-file \`file:\` block]\n` +
|
|
5616
|
+
w.excerpt +
|
|
5617
|
+
`\n# [end windowed excerpt of ${candidate}]`;
|
|
5618
|
+
} else {
|
|
5619
|
+
content = content.slice(0, MAX_PER_FILE_CHARS) + `\n\n# [HC-019n-followup-12: truncated at ${MAX_PER_FILE_CHARS} chars; full file is ${content.length} chars]`;
|
|
5620
|
+
}
|
|
4613
5621
|
}
|
|
4614
5622
|
if (totalChars + content.length > MAX_TOTAL_CHARS) break;
|
|
4615
5623
|
totalChars += content.length;
|
|
@@ -4625,6 +5633,126 @@ program
|
|
|
4625
5633
|
}
|
|
4626
5634
|
}
|
|
4627
5635
|
|
|
5636
|
+
// HC-019n-followup-17: warn when the code-aware steps will run BLIND.
|
|
5637
|
+
//
|
|
5638
|
+
// Auto-detection reads file paths out of `issueBodyForFiles`, which is only
|
|
5639
|
+
// populated on the numeric-GitHub-issue branch above. A roadmap-sourced id
|
|
5640
|
+
// (HC-CI-005, SC-002-followup-2, ...) never enters that branch, so
|
|
5641
|
+
// `codebaseFiles` stays unset and step_1 / step_4 / step_5 / step_5d /
|
|
5642
|
+
// step_5e all run with no source to read — the exact "confidently wrong"
|
|
5643
|
+
// failure HC-019n-followup-12 was built to prevent, still open for roadmap
|
|
5644
|
+
// ids. Observed 2026-08-29: run 61fddfb2 wrote 8,444 chars of code for
|
|
5645
|
+
// HC-CI-005 having never seen the files that story names.
|
|
5646
|
+
//
|
|
5647
|
+
// A fix is structurally awkward: only the CLI can read adopter files, and
|
|
5648
|
+
// only the SERVER knows the roadmap description (server/seeds/master-
|
|
5649
|
+
// roadmap.md is a Hone asset, absent from adopter repos). Closing it needs
|
|
5650
|
+
// the CLI to fetch the resolved description before queueing — a round trip
|
|
5651
|
+
// that belongs in its own story. Until then the gap is at least LOUD
|
|
5652
|
+
// instead of silent, which is the part that actually bit.
|
|
5653
|
+
if (!orchestrateConfig.codebaseFiles) {
|
|
5654
|
+
console.warn(' ⚠ no codebase files bundled — step_1 / step_4 / step_5 / step_5d / step_5e');
|
|
5655
|
+
console.warn(' will run WITHOUT source context and may invent APIs that do not exist.');
|
|
5656
|
+
if (!/^\d+$/.test(storyIdOrRunId)) {
|
|
5657
|
+
console.warn(` '${storyIdOrRunId}' is not a GitHub issue number, so file paths cannot be`);
|
|
5658
|
+
console.warn(' auto-detected from an issue body.');
|
|
5659
|
+
}
|
|
5660
|
+
console.warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js');
|
|
5661
|
+
}
|
|
5662
|
+
|
|
5663
|
+
// HC-101-followup-2: pass the adopter's CI gate config to the
|
|
5664
|
+
// orchestrator so step_5c can branch (github / local / both / none).
|
|
5665
|
+
// Defaults to gate=github + local_command='make ci' when the config
|
|
5666
|
+
// is missing — backward-compat for adopters whose .pipeline-config.yml
|
|
5667
|
+
// predates this field.
|
|
5668
|
+
try {
|
|
5669
|
+
const { readCIGateConfig } = require('./lib/pipeline-config');
|
|
5670
|
+
const ciGate = readCIGateConfig(process.cwd());
|
|
5671
|
+
orchestrateConfig.ci_gate = ciGate.gate;
|
|
5672
|
+
orchestrateConfig.ci_local_command = ciGate.local_command;
|
|
5673
|
+
console.log(` → CI gate mode: ${ciGate.gate}${ciGate.gate !== 'github' ? ` (local_command: ${ciGate.local_command})` : ''}`);
|
|
5674
|
+
} catch (e) {
|
|
5675
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
5676
|
+
console.warn(` ⚠ CI gate config read failed (non-fatal, defaulting to gate=github): ${msg}`);
|
|
5677
|
+
orchestrateConfig.ci_gate = 'github';
|
|
5678
|
+
orchestrateConfig.ci_local_command = 'make ci';
|
|
5679
|
+
}
|
|
5680
|
+
|
|
5681
|
+
// HC-019n-followup-28: resolve the E2E spec-generation mode (step_3b /
|
|
5682
|
+
// Playwright). Precedence mirrors ci.gate: per-run flag > .pipeline-config.yml
|
|
5683
|
+
// `e2e.spec_generation` > default `auto`. `auto` = the orchestrator runs
|
|
5684
|
+
// step_3b when the story requires an E2E plan (step_0 emitted `yes`); `never`
|
|
5685
|
+
// = step_3b is always skipped. The runtime "is E2E required?" decision stays
|
|
5686
|
+
// server-side (checkE2eRequirement reads step_0's output) — the CLI only
|
|
5687
|
+
// passes the MODE.
|
|
5688
|
+
try {
|
|
5689
|
+
const { readE2eSpecMode } = require('./lib/pipeline-config');
|
|
5690
|
+
let e2eMode, source;
|
|
5691
|
+
if (opts.e2eSpecs) { e2eMode = 'auto'; source = '--e2e-specs'; }
|
|
5692
|
+
else if (opts.skipE2eSpecs) { e2eMode = 'never'; source = '--skip-e2e-specs'; }
|
|
5693
|
+
else { e2eMode = readE2eSpecMode(process.cwd()); source = 'config/default'; }
|
|
5694
|
+
orchestrateConfig.e2e_spec_generation = e2eMode;
|
|
5695
|
+
// Enable step_3b (conditional) unless the adopter opted out. The row is
|
|
5696
|
+
// then created; the orchestrator still skips it at runtime for a story that
|
|
5697
|
+
// does not require an E2E plan (HC-019n-followup-28).
|
|
5698
|
+
if (e2eMode !== 'never') {
|
|
5699
|
+
const ec = new Set(orchestrateConfig.enableConditional || []);
|
|
5700
|
+
ec.add('step_3b');
|
|
5701
|
+
orchestrateConfig.enableConditional = [...ec];
|
|
5702
|
+
}
|
|
5703
|
+
console.log(` → E2E spec generation (step_3b): ${e2eMode} (source: ${source})`);
|
|
5704
|
+
} catch (e) {
|
|
5705
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
5706
|
+
console.warn(` ⚠ e2e spec-mode read failed (non-fatal, defaulting to auto): ${msg}`);
|
|
5707
|
+
orchestrateConfig.e2e_spec_generation = 'auto';
|
|
5708
|
+
}
|
|
5709
|
+
|
|
5710
|
+
// HC-019n-followup-34: thread the closed-loop verification gate mode from
|
|
5711
|
+
// .pipeline-config.yml. Advisory (default) = the server RECORDS verify-patch/
|
|
5712
|
+
// verify-pr verdicts reported via --report; enforce (follow-up) = a red
|
|
5713
|
+
// verdict blocks. Default-safe: never fatal.
|
|
5714
|
+
try {
|
|
5715
|
+
const { readVerificationGateConfig, readVerificationGateSources } = require('./lib/pipeline-config');
|
|
5716
|
+
orchestrateConfig.verification_gate = readVerificationGateConfig(process.cwd());
|
|
5717
|
+
// HC-COMM-011-followup-6: which verdict sources may DRIVE the step_4 gate under
|
|
5718
|
+
// enforce (default ['verify-patch']). An adopter adds 'agent-eval' to let a
|
|
5719
|
+
// green agent-eval verdict auto-advance a prompt change.
|
|
5720
|
+
orchestrateConfig.verification_gate_sources = readVerificationGateSources(process.cwd());
|
|
5721
|
+
if (orchestrateConfig.verification_gate !== 'advisory') {
|
|
5722
|
+
console.log(` → verification gate: ${orchestrateConfig.verification_gate} (sources: ${orchestrateConfig.verification_gate_sources.join(', ')})`);
|
|
5723
|
+
}
|
|
5724
|
+
} catch (e) {
|
|
5725
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
5726
|
+
console.warn(` ⚠ verification-gate read failed (non-fatal, defaulting to advisory): ${msg}`);
|
|
5727
|
+
orchestrateConfig.verification_gate = 'advisory';
|
|
5728
|
+
orchestrateConfig.verification_gate_sources = ['verify-patch'];
|
|
5729
|
+
}
|
|
5730
|
+
|
|
5731
|
+
// HC-019b: read per-story architect flags from .github/EXECUTION_PLAN.yml
|
|
5732
|
+
// and plumb them into workflow_runs.config. The orchestrator's
|
|
5733
|
+
// validateStepPreConditions (server/src/services/workflow-dag.js:340)
|
|
5734
|
+
// BLOCKS step_1 when architect_consulted=true but checklist_b_completed=false.
|
|
5735
|
+
// Without this plumbing, the HC-019a flags written by the architect prompt
|
|
5736
|
+
// never reach the server and every architect-engaged story deadlocks.
|
|
5737
|
+
// Defaults to {false, false} when the file/story/config is missing —
|
|
5738
|
+
// i.e., assume the architect was not consulted (no block).
|
|
5739
|
+
//
|
|
5740
|
+
// Code-review F2/F3: malformed YAML or missing story entry was previously
|
|
5741
|
+
// silent. The helper now returns a `diagnostic` string for those cases;
|
|
5742
|
+
// we surface it as a console.warn so operators see the silent-bypass.
|
|
5743
|
+
// HC-019b-followup-1 F1: use the resolved canonical story-id (HC-NNN)
|
|
5744
|
+
// not the raw `storyIdOrRunId` which is the issue number when invoked
|
|
5745
|
+
// as `hone run-story 104`. The HC-019n-followup-11 block above sets
|
|
5746
|
+
// resolvedStoryId to the title-extracted id when it can.
|
|
5747
|
+
const { readArchitectConfig } = require('./lib/architect-config');
|
|
5748
|
+
const arch = readArchitectConfig(process.cwd(), resolvedStoryId);
|
|
5749
|
+
orchestrateConfig.architect_consulted = arch.architect_consulted;
|
|
5750
|
+
orchestrateConfig.checklist_b_completed = arch.checklist_b_completed;
|
|
5751
|
+
if (arch.diagnostic) console.warn(` ⚠ ${arch.diagnostic}`);
|
|
5752
|
+
if (arch.architect_consulted) {
|
|
5753
|
+
console.log(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`);
|
|
5754
|
+
}
|
|
5755
|
+
|
|
4628
5756
|
try {
|
|
4629
5757
|
const { data } = await client.post('/orchestrate', {
|
|
4630
5758
|
storyId: storyIdOrRunId, repoName, branch, mode: opts.mode, config: orchestrateConfig,
|
|
@@ -4923,33 +6051,94 @@ program
|
|
|
4923
6051
|
catch { branch = null; }
|
|
4924
6052
|
}
|
|
4925
6053
|
|
|
6054
|
+
// HC-019b: read EXECUTION_PLAN.yml ONCE up front so the per-story lookup
|
|
6055
|
+
// doesn't re-stat the disk for each line. Empty text \u2192 all stories get
|
|
6056
|
+
// the {false, false} default per architect-config.js. Try/catch keeps
|
|
6057
|
+
// the batch path resilient if the file is missing or unreadable.
|
|
6058
|
+
let planText = '';
|
|
6059
|
+
try {
|
|
6060
|
+
const planPath = path.join(process.cwd(), '.github', 'EXECUTION_PLAN.yml');
|
|
6061
|
+
if (fs.existsSync(planPath)) planText = fs.readFileSync(planPath, 'utf8');
|
|
6062
|
+
} catch (e) {
|
|
6063
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6064
|
+
console.warn(`\u26a0 EXECUTION_PLAN.yml read failed for batch (non-fatal, architect flags default to {false, false}): ${msg}`);
|
|
6065
|
+
}
|
|
6066
|
+
const { readArchitectConfigFromText } = require('./lib/architect-config');
|
|
6067
|
+
|
|
4926
6068
|
// HC-059: parse `STORY-A depends:STORY-B,STORY-C` per-line syntax. The
|
|
4927
6069
|
// `depends:` token is case-sensitive and must come AFTER the storyId.
|
|
4928
6070
|
// Multiple deps separated by commas, whitespace tolerant. Lines without
|
|
4929
6071
|
// `depends:` yield no `dependsOn` (server validates absence vs empty).
|
|
6072
|
+
// HC-019b: attach per-story `config: { architect_consulted, checklist_b_completed }`
|
|
6073
|
+
// \u2014 the server's createBatch (batch-store.js:218) spreads s.config into each
|
|
6074
|
+
// workflow_runs.config row, so this is the load-bearing plumbing for
|
|
6075
|
+
// validateStepPreConditions (workflow-dag.js:340) in batch mode.
|
|
4930
6076
|
const stories = storyIds.map(line => {
|
|
4931
6077
|
const depsMatch = line.match(/^(\S+)\s+depends:(\S+)\s*$/);
|
|
6078
|
+
let storyId, dependsOn;
|
|
4932
6079
|
if (depsMatch) {
|
|
4933
6080
|
const [, id, depsCsv] = depsMatch;
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
}
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
6081
|
+
storyId = id;
|
|
6082
|
+
dependsOn = depsCsv.split(',').map(s => s.trim()).filter(Boolean);
|
|
6083
|
+
} else {
|
|
6084
|
+
// Reject ambiguous lines (storyId followed by garbage) \u2014 better than
|
|
6085
|
+
// silently treating `STORY-A something` as just `STORY-A`.
|
|
6086
|
+
if (/\s/.test(line)) {
|
|
6087
|
+
console.error(`Malformed line in --file: "${line}"`);
|
|
6088
|
+
console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
|
|
6089
|
+
process.exit(1);
|
|
6090
|
+
}
|
|
6091
|
+
storyId = line;
|
|
6092
|
+
dependsOn = undefined;
|
|
6093
|
+
}
|
|
6094
|
+
const arch = readArchitectConfigFromText(planText, storyId);
|
|
6095
|
+
// Code-review F2/F3: surface silent-bypass diagnostics per story in
|
|
6096
|
+
// the batch path too. Multi-story batches with one bad plan line
|
|
6097
|
+
// would previously disable the contract for ALL stories silently.
|
|
6098
|
+
if (arch.diagnostic) console.warn(` ⚠ [${storyId}] ${arch.diagnostic}`);
|
|
6099
|
+
const obj = {
|
|
6100
|
+
storyId,
|
|
6101
|
+
repoName,
|
|
6102
|
+
branch,
|
|
6103
|
+
config: {
|
|
6104
|
+
architect_consulted: arch.architect_consulted,
|
|
6105
|
+
checklist_b_completed: arch.checklist_b_completed,
|
|
6106
|
+
},
|
|
6107
|
+
};
|
|
6108
|
+
if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
|
|
6109
|
+
return obj;
|
|
4945
6110
|
});
|
|
4946
6111
|
|
|
4947
6112
|
// HC-054: Night Shift opt-in. config.overnight=true plumbs end-to-end
|
|
4948
6113
|
// (server validates the 25-story cap + applies default token budget +
|
|
4949
6114
|
// denormalizes flag into each child's workflow_runs.config).
|
|
4950
6115
|
const body = { stories };
|
|
6116
|
+
body.config = body.config || {};
|
|
4951
6117
|
if (opts.overnight) {
|
|
4952
|
-
body.config =
|
|
6118
|
+
body.config.overnight = true;
|
|
6119
|
+
}
|
|
6120
|
+
|
|
6121
|
+
// HC-101-followup-2: plumb the adopter's CI gate config to every story
|
|
6122
|
+
// in the batch. Without this, the batch path would silently default to
|
|
6123
|
+
// gate=github on the server even when .pipeline-config.yml says local/none —
|
|
6124
|
+
// exactly the "silent skip" the design warned against. Same try/catch
|
|
6125
|
+
// pattern as the run-story path (cli/hone-cli.js ~L4628).
|
|
6126
|
+
try {
|
|
6127
|
+
const { readCIGateConfig, DEFAULT_CI_LOCAL_COMMAND } = require('./lib/pipeline-config');
|
|
6128
|
+
const ciGate = readCIGateConfig(process.cwd());
|
|
6129
|
+
body.config.ci_gate = ciGate.gate;
|
|
6130
|
+
body.config.ci_local_command = ciGate.local_command;
|
|
6131
|
+
if (ciGate.gate !== 'github') {
|
|
6132
|
+
console.log(`CI gate mode for batch: ${ciGate.gate} (local_command: ${ciGate.local_command})`);
|
|
6133
|
+
}
|
|
6134
|
+
} catch (e) {
|
|
6135
|
+
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6136
|
+
console.warn(`⚠ CI gate config read failed for batch (non-fatal, defaulting to gate=github): ${msg}`);
|
|
6137
|
+
body.config.ci_gate = 'github';
|
|
6138
|
+
try {
|
|
6139
|
+
const { DEFAULT_CI_LOCAL_COMMAND } = require('./lib/pipeline-config');
|
|
6140
|
+
body.config.ci_local_command = DEFAULT_CI_LOCAL_COMMAND;
|
|
6141
|
+
} catch { body.config.ci_local_command = 'make ci'; }
|
|
4953
6142
|
}
|
|
4954
6143
|
|
|
4955
6144
|
try {
|
|
@@ -4980,6 +6169,71 @@ program
|
|
|
4980
6169
|
}
|
|
4981
6170
|
});
|
|
4982
6171
|
|
|
6172
|
+
// ── HC-054g: Night-shift retroactive revert command ─────────────────────────
|
|
6173
|
+
//
|
|
6174
|
+
// Two-step operator workflow after rejected_rate drift (HC-054c) flags a
|
|
6175
|
+
// batch of bad overnight auto-approves:
|
|
6176
|
+
//
|
|
6177
|
+
// 1. `hone night-shift revert <runId> --step-key <key> --revert-pr <url>`
|
|
6178
|
+
// 2. CLI calls POST /night-shift/runs/:runId/retroactive-reject (if not yet)
|
|
6179
|
+
// then POST /night-shift/runs/:runId/revert with the revert PR URL.
|
|
6180
|
+
//
|
|
6181
|
+
// We deliberately don't take repo write access; the operator runs the git
|
|
6182
|
+
// revert themselves and passes the resulting revert-PR URL. This keeps the
|
|
6183
|
+
// server side free of GitHub credentials + repo-specific permissions while
|
|
6184
|
+
// still giving the audit log the revert provenance.
|
|
6185
|
+
const nightShiftCmd = program.command('night-shift').description('HC-054 night-shift audit + revert workflow');
|
|
6186
|
+
nightShiftCmd
|
|
6187
|
+
.command('revert <runId>')
|
|
6188
|
+
.description('Record a revert action for a retroactively-rejected overnight auto-approve')
|
|
6189
|
+
.requiredOption('--step-key <stepKey>', 'The step_key whose auto-approve produced the bad output (e.g. step_4 or step_5)')
|
|
6190
|
+
.requiredOption('--revert-pr <url>', 'GitHub PR URL of the revert commit (https://github.com/.../pull/<n>)')
|
|
6191
|
+
.option('--reason <reason>', 'Free-text reason for the retroactive rejection (recorded with the audit row)')
|
|
6192
|
+
.action(async (runId, opts) => {
|
|
6193
|
+
const config = getConfig();
|
|
6194
|
+
const client = api(config);
|
|
6195
|
+
try {
|
|
6196
|
+
// Step 1: retroactively-reject if not yet rejected. The endpoint
|
|
6197
|
+
// returns 409 if already rejected — we treat that as a no-op
|
|
6198
|
+
// (operator may have done step 1 yesterday, run revert today).
|
|
6199
|
+
try {
|
|
6200
|
+
await client.post(`/night-shift/runs/${runId}/retroactive-reject`, {
|
|
6201
|
+
stepKey: opts.stepKey,
|
|
6202
|
+
reason: opts.reason,
|
|
6203
|
+
});
|
|
6204
|
+
console.log(`[night-shift] retroactively rejected (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
6205
|
+
} catch (e) {
|
|
6206
|
+
const status = e.response?.status;
|
|
6207
|
+
if (status === 409) {
|
|
6208
|
+
console.log(`[night-shift] already retroactively rejected (continuing to revert step)`);
|
|
6209
|
+
} else if (status === 404) {
|
|
6210
|
+
console.error(`hone night-shift revert failed: no auto-approve audit row for (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
6211
|
+
process.exit(1);
|
|
6212
|
+
} else {
|
|
6213
|
+
throw e;
|
|
6214
|
+
}
|
|
6215
|
+
}
|
|
6216
|
+
|
|
6217
|
+
// Step 2: record the revert PR URL.
|
|
6218
|
+
const r = await client.post(`/night-shift/runs/${runId}/revert`, {
|
|
6219
|
+
stepKey: opts.stepKey,
|
|
6220
|
+
revertPrUrl: opts.revertPr,
|
|
6221
|
+
});
|
|
6222
|
+
console.log(`[night-shift] revert recorded:`);
|
|
6223
|
+
console.log(` runId: ${r.data.runId}`);
|
|
6224
|
+
console.log(` stepKey: ${r.data.stepKey}`);
|
|
6225
|
+
console.log(` revertInitiatedAt: ${r.data.revertInitiatedAt}`);
|
|
6226
|
+
console.log(` revertPrUrl: ${r.data.revertPrUrl}`);
|
|
6227
|
+
} catch (e) {
|
|
6228
|
+
const msg = e.response?.data?.error || e.message;
|
|
6229
|
+
console.error(`hone night-shift revert failed: ${msg}`);
|
|
6230
|
+
if (e.response?.data?.remediation) {
|
|
6231
|
+
console.error(`Remediation: ${e.response.data.remediation}`);
|
|
6232
|
+
}
|
|
6233
|
+
process.exit(1);
|
|
6234
|
+
}
|
|
6235
|
+
});
|
|
6236
|
+
|
|
4983
6237
|
// ── HC-056: Schedule install (GitHub Actions overnight template) ────────────
|
|
4984
6238
|
//
|
|
4985
6239
|
// Installs a parameterized .github/workflows/<name>.yml that runs `hone
|
|
@@ -5001,6 +6255,9 @@ program
|
|
|
5001
6255
|
.option('--file <path>', 'Default stories file path (relative to repo root)', 'stories.txt')
|
|
5002
6256
|
.option('--out <dir>', 'Output directory for the workflow file', '.github/workflows')
|
|
5003
6257
|
.option('--force', 'Overwrite existing workflow file', false)
|
|
6258
|
+
.option('--overnight <mode>',
|
|
6259
|
+
'Night Shift mode: auto (default — derives from cron hour) | yes | no (HC-054f)',
|
|
6260
|
+
'auto')
|
|
5004
6261
|
.action(async (action, opts) => {
|
|
5005
6262
|
if (action !== 'install') {
|
|
5006
6263
|
console.error(`Unknown schedule action: ${action}. Supported: install`);
|
|
@@ -5026,6 +6283,22 @@ program
|
|
|
5026
6283
|
process.exit(1);
|
|
5027
6284
|
}
|
|
5028
6285
|
|
|
6286
|
+
// HC-054f: derive whether the workflow should pass --overnight to
|
|
6287
|
+
// queue-stories. Defaults to 'auto' which inspects the cron hour.
|
|
6288
|
+
// Pass-1 review HIGH #1 fix: validate the mode value UPFRONT and
|
|
6289
|
+
// exit non-zero on typos — otherwise `--overnight YES` or
|
|
6290
|
+
// `--overnight on` silently fell back to auto, contradicting
|
|
6291
|
+
// adopter intent.
|
|
6292
|
+
const { analyzeCron, resolveOvernight, isKnownOvernightMode } = require('./lib/schedule-cron');
|
|
6293
|
+
if (!isKnownOvernightMode(opts.overnight)) {
|
|
6294
|
+
console.error(`Invalid --overnight value "${opts.overnight}".`);
|
|
6295
|
+
console.error('Accepted: auto (default — derives from cron hour) | yes | no');
|
|
6296
|
+
console.error('Aliases: true/false/1/0/on/off/enable/disable also work (case-insensitive).');
|
|
6297
|
+
process.exit(1);
|
|
6298
|
+
}
|
|
6299
|
+
const cronAnalysis = analyzeCron(opts.cron);
|
|
6300
|
+
const overnightDecision = resolveOvernight(opts.overnight, cronAnalysis);
|
|
6301
|
+
|
|
5029
6302
|
const config = getConfig();
|
|
5030
6303
|
const client = api(config);
|
|
5031
6304
|
|
|
@@ -5054,10 +6327,16 @@ program
|
|
|
5054
6327
|
|
|
5055
6328
|
// 2. Substitute placeholders. Use replace-all so any future template
|
|
5056
6329
|
// additions referencing the same placeholder are handled.
|
|
6330
|
+
// HC-054f: {{OVERNIGHT_FLAG}} → either ` --overnight` (with leading
|
|
6331
|
+
// space) or empty string. The leading space keeps the queue-stories
|
|
6332
|
+
// command tidy when the flag is absent. Templates written before
|
|
6333
|
+
// HC-054f don't carry the placeholder — replace-all is a no-op there.
|
|
6334
|
+
const overnightFlag = overnightDecision.overnight ? ' --overnight' : '';
|
|
5057
6335
|
const populated = template
|
|
5058
6336
|
.replace(/\{\{NAME\}\}/g, opts.name)
|
|
5059
6337
|
.replace(/\{\{CRON\}\}/g, opts.cron)
|
|
5060
|
-
.replace(/\{\{STORIES_FILE\}\}/g, opts.file)
|
|
6338
|
+
.replace(/\{\{STORIES_FILE\}\}/g, opts.file)
|
|
6339
|
+
.replace(/\{\{OVERNIGHT_FLAG\}\}/g, overnightFlag);
|
|
5061
6340
|
|
|
5062
6341
|
// 3. Decide output path. Default writes to `.github/workflows/<name>.yml`.
|
|
5063
6342
|
const outDir = path.resolve(process.cwd(), opts.out);
|
|
@@ -5078,8 +6357,23 @@ program
|
|
|
5078
6357
|
console.log('');
|
|
5079
6358
|
console.log(`✓ Installed schedule: ${path.relative(process.cwd(), outFile)}`);
|
|
5080
6359
|
console.log('');
|
|
5081
|
-
console.log(' Schedule:
|
|
5082
|
-
console.log(' Stories:
|
|
6360
|
+
console.log(' Schedule: ' + opts.cron + ' (UTC)');
|
|
6361
|
+
console.log(' Stories: ' + opts.file);
|
|
6362
|
+
// HC-054f: surface the overnight decision + WHY so the adopter
|
|
6363
|
+
// sees that a `0 2 * * 1-5` cron auto-enabled Night Shift without
|
|
6364
|
+
// having to dig into the workflow file.
|
|
6365
|
+
const overnightLabel = overnightDecision.overnight ? 'ENABLED' : 'disabled';
|
|
6366
|
+
// HC-054f pass-1 review LOW: if the source enum ever grows, the
|
|
6367
|
+
// `|| ''` fallback would emit a dangling trailing space. Use an
|
|
6368
|
+
// explicit `unknown-source` marker so a future enum addition fails
|
|
6369
|
+
// loudly in CI rather than silently degrading the output.
|
|
6370
|
+
const sourceLabel = {
|
|
6371
|
+
'explicit-yes': '(--overnight yes)',
|
|
6372
|
+
'explicit-no': '(--overnight no)',
|
|
6373
|
+
'auto-detect-yes': '(auto-detected: ' + cronAnalysis.reason + ')',
|
|
6374
|
+
'auto-detect-no': '(auto-detected: ' + cronAnalysis.reason + ')',
|
|
6375
|
+
}[overnightDecision.source] || `(unknown-source:${overnightDecision.source})`;
|
|
6376
|
+
console.log(` Overnight: ${overnightLabel} ${sourceLabel}`);
|
|
5083
6377
|
console.log('');
|
|
5084
6378
|
console.log('Next steps:');
|
|
5085
6379
|
console.log(' 1. Ensure repo secret HONE_TOKEN is set');
|
|
@@ -5099,12 +6393,13 @@ program
|
|
|
5099
6393
|
|
|
5100
6394
|
program
|
|
5101
6395
|
.command('release-review')
|
|
5102
|
-
.description('Holistic code review of all changed files before deployment (
|
|
6396
|
+
.description('Holistic code review of all changed files before deployment (default: GH Models, $0)')
|
|
5103
6397
|
.option('--base <branch>', 'Base branch to diff against', 'main')
|
|
5104
6398
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
5105
6399
|
.option('--dry-run', 'Show what would be reviewed without calling the LLM', false)
|
|
5106
6400
|
.option('--max-files <n>', 'Max source files to include in review', '40')
|
|
5107
|
-
.option('--provider <name>', 'LLM provider:
|
|
6401
|
+
.option('--provider <name>', 'LLM provider: gh-models (default, $0) | opus (legacy, paid)', 'gh-models')
|
|
6402
|
+
.option('--cache <mode>', 'HC-RC-002-followup-1 content-hash cache: on | off (default on)', 'on')
|
|
5108
6403
|
.action(async (opts) => {
|
|
5109
6404
|
const { execSync } = require('child_process');
|
|
5110
6405
|
const fs = require('fs');
|
|
@@ -5146,11 +6441,11 @@ program
|
|
|
5146
6441
|
// 1. Get changed files
|
|
5147
6442
|
let changedFiles;
|
|
5148
6443
|
try {
|
|
5149
|
-
const raw = execSync(`git diff --name-only ${baseRef}...HEAD`, { encoding: 'utf8', cwd: repoRoot });
|
|
6444
|
+
const raw = execSync(`git diff --name-only ${baseRef}...HEAD`, { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
|
|
5150
6445
|
changedFiles = raw.trim().split('\n').filter(Boolean);
|
|
5151
6446
|
} catch {
|
|
5152
6447
|
try {
|
|
5153
|
-
const raw = execSync('git diff --name-only HEAD~10', { encoding: 'utf8', cwd: repoRoot });
|
|
6448
|
+
const raw = execSync('git diff --name-only HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv() });
|
|
5154
6449
|
changedFiles = raw.trim().split('\n').filter(Boolean);
|
|
5155
6450
|
} catch {
|
|
5156
6451
|
console.error('Could not determine changed files. Run from a git repo.');
|
|
@@ -5205,7 +6500,7 @@ program
|
|
|
5205
6500
|
}
|
|
5206
6501
|
} else {
|
|
5207
6502
|
apiKey = process.env.ANTHROPIC_API_KEY;
|
|
5208
|
-
providerLabel = 'Anthropic Opus (claude-opus-4-
|
|
6503
|
+
providerLabel = 'Anthropic Opus (claude-opus-4-8)';
|
|
5209
6504
|
if (!apiKey) {
|
|
5210
6505
|
console.error('ANTHROPIC_API_KEY not set. Required for --provider opus.');
|
|
5211
6506
|
console.error('Set it: export ANTHROPIC_API_KEY=sk-ant-...');
|
|
@@ -5217,11 +6512,11 @@ program
|
|
|
5217
6512
|
let diffContent;
|
|
5218
6513
|
try {
|
|
5219
6514
|
diffContent = execSync(`git diff ${baseRef}...HEAD -- ${filesToReview.map(f => `'${f}'`).join(' ')}`, {
|
|
5220
|
-
encoding: 'utf8', cwd: repoRoot, maxBuffer: 10 * 1024 * 1024,
|
|
6515
|
+
encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024,
|
|
5221
6516
|
});
|
|
5222
6517
|
} catch {
|
|
5223
6518
|
try {
|
|
5224
|
-
diffContent = execSync('git diff HEAD~10', { encoding: 'utf8', cwd: repoRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
6519
|
+
diffContent = execSync('git diff HEAD~10', { encoding: 'utf8', cwd: repoRoot, env: gitEnv(), maxBuffer: 10 * 1024 * 1024 });
|
|
5225
6520
|
} catch (e) {
|
|
5226
6521
|
console.error(`Could not generate diff: ${e.message}`);
|
|
5227
6522
|
process.exit(1);
|
|
@@ -5294,6 +6589,89 @@ program
|
|
|
5294
6589
|
banner(`Calling ${providerLabel}...`);
|
|
5295
6590
|
banner('');
|
|
5296
6591
|
|
|
6592
|
+
// ── HC-RC-002-followup-1: content-hash cache check ──────────────────
|
|
6593
|
+
//
|
|
6594
|
+
// The contentHash is computed over (diff + systemPrompt + model). A
|
|
6595
|
+
// hit means an earlier run of the SAME diff against the SAME model
|
|
6596
|
+
// produced a review already — replay it for $0 Anthropic spend +
|
|
6597
|
+
// ~50ms instead of ~30s for an Opus call. Pre-fix adopters paid
|
|
6598
|
+
// ~\$1.50 per release-review × ~3 retries per PR.
|
|
6599
|
+
//
|
|
6600
|
+
// Cache is opportunistic: any lookup error falls through to the LLM.
|
|
6601
|
+
// Stamp `cache_hit: true` and `billing_source: 'cache'` on the
|
|
6602
|
+
// envelope so the CI artifact analyzer can distinguish cached from
|
|
6603
|
+
// fresh runs.
|
|
6604
|
+
const {
|
|
6605
|
+
computeReviewContentHash,
|
|
6606
|
+
lookupReviewCache,
|
|
6607
|
+
storeReviewCache,
|
|
6608
|
+
normalizeCacheFlag,
|
|
6609
|
+
} = require('./lib/release-review-cache');
|
|
6610
|
+
const cacheEnabled = normalizeCacheFlag(opts.cache);
|
|
6611
|
+
const modelForCache = opts.provider === 'gh-models'
|
|
6612
|
+
? 'openai/gpt-4.1'
|
|
6613
|
+
: 'claude-opus-4-8';
|
|
6614
|
+
let cacheContentHash = null;
|
|
6615
|
+
if (cacheEnabled) {
|
|
6616
|
+
try {
|
|
6617
|
+
cacheContentHash = computeReviewContentHash({
|
|
6618
|
+
diff: diffContent,
|
|
6619
|
+
systemPrompt,
|
|
6620
|
+
model: modelForCache,
|
|
6621
|
+
});
|
|
6622
|
+
} catch (e) {
|
|
6623
|
+
banner(`Cache disabled: contentHash computation failed: ${e.message}`);
|
|
6624
|
+
}
|
|
6625
|
+
}
|
|
6626
|
+
if (cacheEnabled && cacheContentHash) {
|
|
6627
|
+
const config = getConfig();
|
|
6628
|
+
const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
|
|
6629
|
+
const token = (config && config.token) || process.env.HONE_TOKEN;
|
|
6630
|
+
const cached = await lookupReviewCache({
|
|
6631
|
+
axios,
|
|
6632
|
+
apiBase,
|
|
6633
|
+
token,
|
|
6634
|
+
contentHash: cacheContentHash,
|
|
6635
|
+
model: modelForCache,
|
|
6636
|
+
banner,
|
|
6637
|
+
});
|
|
6638
|
+
if (cached && cached.hit === true) {
|
|
6639
|
+
banner('');
|
|
6640
|
+
banner(`✓ Cache HIT — replaying cached release-review response`);
|
|
6641
|
+
banner(` Cache key: ${cacheContentHash.slice(0, 16)}… (hit_count: ${cached.hit_count})`);
|
|
6642
|
+
banner(` Saved: ~${cached.tokens_saved} tokens • billing_source: cache`);
|
|
6643
|
+
banner('');
|
|
6644
|
+
const elapsedMs = 50; // approximate — actual lookup + response time
|
|
6645
|
+
if (isJsonOut) {
|
|
6646
|
+
const envelope = {
|
|
6647
|
+
status: 'reviewed',
|
|
6648
|
+
base: opts.base,
|
|
6649
|
+
resolvedBase: baseRef,
|
|
6650
|
+
provider: opts.provider,
|
|
6651
|
+
totalFiles: changedFiles.length,
|
|
6652
|
+
sourceFiles: sourceFiles.length,
|
|
6653
|
+
reviewedFiles: filesToReview.length,
|
|
6654
|
+
model: modelForCache,
|
|
6655
|
+
inputTokens: 0,
|
|
6656
|
+
outputTokens: 0,
|
|
6657
|
+
elapsedMs,
|
|
6658
|
+
cache_hit: true,
|
|
6659
|
+
billing_source: 'cache',
|
|
6660
|
+
tokens_saved: cached.tokens_saved,
|
|
6661
|
+
};
|
|
6662
|
+
// Cached responses are already JSON-parsed (server stores JSON);
|
|
6663
|
+
// spread them in then overlay envelope so audit fields can't be
|
|
6664
|
+
// poisoned by stored content.
|
|
6665
|
+
console.log(JSON.stringify({ ...cached.response, ...envelope }, null, 2));
|
|
6666
|
+
} else {
|
|
6667
|
+
console.log(typeof cached.response === 'string'
|
|
6668
|
+
? cached.response
|
|
6669
|
+
: JSON.stringify(cached.response, null, 2));
|
|
6670
|
+
}
|
|
6671
|
+
process.exit(0);
|
|
6672
|
+
}
|
|
6673
|
+
}
|
|
6674
|
+
|
|
5297
6675
|
// 6. Call LLM (provider-branched, HC-080a-spike)
|
|
5298
6676
|
// max_tokens is held SYMMETRIC across providers so the HC-080a-spike
|
|
5299
6677
|
// comparison measures model capability, not output budget. 4096 is the
|
|
@@ -5331,7 +6709,7 @@ program
|
|
|
5331
6709
|
modelLabel = 'openai/gpt-4.1';
|
|
5332
6710
|
} else {
|
|
5333
6711
|
const { data } = await axios.post('https://api.anthropic.com/v1/messages', {
|
|
5334
|
-
model: 'claude-opus-4-
|
|
6712
|
+
model: 'claude-opus-4-8',
|
|
5335
6713
|
max_tokens: MAX_OUTPUT_TOKENS,
|
|
5336
6714
|
system: systemPrompt,
|
|
5337
6715
|
messages: [{ role: 'user', content: userPrompt }],
|
|
@@ -5346,7 +6724,7 @@ program
|
|
|
5346
6724
|
responseText = data.content?.[0]?.text || '';
|
|
5347
6725
|
inputTokens = data.usage?.input_tokens || 0;
|
|
5348
6726
|
outputTokens = data.usage?.output_tokens || 0;
|
|
5349
|
-
modelLabel = 'claude-opus-4-
|
|
6727
|
+
modelLabel = 'claude-opus-4-8';
|
|
5350
6728
|
}
|
|
5351
6729
|
|
|
5352
6730
|
const elapsedMs = Date.now() - startedAt;
|
|
@@ -5406,6 +6784,33 @@ program
|
|
|
5406
6784
|
console.log(responseText);
|
|
5407
6785
|
}
|
|
5408
6786
|
|
|
6787
|
+
// HC-RC-002-followup-1: store the fresh response in the cache so
|
|
6788
|
+
// future runs of the SAME diff hit cache instead of paying for
|
|
6789
|
+
// another Opus call. Fire-and-forget — caller already has the
|
|
6790
|
+
// response; a failed store is logged but doesn't change exit code.
|
|
6791
|
+
// Total tokens billed for this run = inputTokens + outputTokens —
|
|
6792
|
+
// those are the tokens a future hit would save.
|
|
6793
|
+
if (cacheEnabled && cacheContentHash) {
|
|
6794
|
+
const config = getConfig();
|
|
6795
|
+
const apiBase = (config && config.apiBase) || process.env.HONE_API_BASE;
|
|
6796
|
+
const token = (config && config.token) || process.env.HONE_TOKEN;
|
|
6797
|
+
// Cache the parsed JSON when available (cleaner replay), otherwise
|
|
6798
|
+
// wrap the raw text in { raw } so the cache always stores an object.
|
|
6799
|
+
const responseForCache = parsed && typeof parsed === 'object'
|
|
6800
|
+
? parsed
|
|
6801
|
+
: { raw: responseText };
|
|
6802
|
+
storeReviewCache({
|
|
6803
|
+
axios, apiBase, token,
|
|
6804
|
+
contentHash: cacheContentHash,
|
|
6805
|
+
model: modelForCache,
|
|
6806
|
+
response: responseForCache,
|
|
6807
|
+
tokensSaved: inputTokens + outputTokens,
|
|
6808
|
+
banner,
|
|
6809
|
+
}).then(({ stored }) => {
|
|
6810
|
+
if (stored) banner(`✓ Cache stored: ${cacheContentHash.slice(0, 16)}…`);
|
|
6811
|
+
}).catch(() => { /* logged inside helper */ });
|
|
6812
|
+
}
|
|
6813
|
+
|
|
5409
6814
|
// 8. Exit code — defense in depth:
|
|
5410
6815
|
// (a) structured check against the parsed JSON, then
|
|
5411
6816
|
// (b) loose substring check on the raw response (catches LLMs that
|
|
@@ -5499,6 +6904,690 @@ showCmd
|
|
|
5499
6904
|
}
|
|
5500
6905
|
});
|
|
5501
6906
|
|
|
6907
|
+
// ── HC-019n-followup-20: check-patch (pipeline-recovery condition 5) ──────────
|
|
6908
|
+
// Fetch step_4's output, extract its diff, and run `git apply --check` against
|
|
6909
|
+
// the real tree. The server can only verify the output LOOKS like a diff
|
|
6910
|
+
// (patch-validator); only the CLI, running inside the repo, can prove it
|
|
6911
|
+
// APPLIES. Run c122c92a produced a diff that passed the server check and failed
|
|
6912
|
+
// to apply — structure is not applicability.
|
|
6913
|
+
program
|
|
6914
|
+
.command('check-patch <workflowId>')
|
|
6915
|
+
.description('Verify step_4\'s diff applies to the current working tree (condition 5)')
|
|
6916
|
+
.option('--step-key <key>', 'Step to check (default: step_4)', 'step_4')
|
|
6917
|
+
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
6918
|
+
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
6919
|
+
.action(async (workflowId, opts) => {
|
|
6920
|
+
const { execFileSync } = require('child_process');
|
|
6921
|
+
const os = require('os');
|
|
6922
|
+
const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
|
|
6923
|
+
const { extractFileBlocks, buildDiff, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
|
|
6924
|
+
const { gitEnv } = require('./lib/git-env');
|
|
6925
|
+
const config = getConfig();
|
|
6926
|
+
const client = api(config);
|
|
6927
|
+
|
|
6928
|
+
const emit = (obj, humanLine) => {
|
|
6929
|
+
if (opts.format === 'json') console.log(JSON.stringify(obj));
|
|
6930
|
+
else console.log(humanLine);
|
|
6931
|
+
};
|
|
6932
|
+
|
|
6933
|
+
let output;
|
|
6934
|
+
try {
|
|
6935
|
+
const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
|
|
6936
|
+
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
6937
|
+
const r = await client.get(url);
|
|
6938
|
+
output = r.data?.output;
|
|
6939
|
+
} catch (e) {
|
|
6940
|
+
const msg = e.response?.data?.error || e.message;
|
|
6941
|
+
emit({ ok: false, reason: 'fetch_failed', detail: msg },
|
|
6942
|
+
`✗ could not fetch ${opts.stepKey} output: ${msg}`);
|
|
6943
|
+
process.exit(1);
|
|
6944
|
+
}
|
|
6945
|
+
|
|
6946
|
+
// HC-019n-followup-23/27: prefer whole-file blocks + region edits
|
|
6947
|
+
// (## Changed Files). git computes the hunks, so the assembled diff applies
|
|
6948
|
+
// by construction — no LLM line-counting. Fall back to the raw-diff path
|
|
6949
|
+
// (followup-20) for stories still on the old ## Patch contract.
|
|
6950
|
+
let diff, source, noChanges;
|
|
6951
|
+
const blocks = extractFileBlocks(output);
|
|
6952
|
+
if (blocks.noChanges) {
|
|
6953
|
+
emit({ ok: true, verdict: 'no_changes' },
|
|
6954
|
+
`○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
|
|
6955
|
+
return;
|
|
6956
|
+
}
|
|
6957
|
+
if (blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0) {
|
|
6958
|
+
let built;
|
|
6959
|
+
try {
|
|
6960
|
+
built = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() }));
|
|
6961
|
+
} catch (e) {
|
|
6962
|
+
if (e instanceof MaterializeError) {
|
|
6963
|
+
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
6964
|
+
`✗ region edit could not be applied (${e.code}): ${e.message}`);
|
|
6965
|
+
process.exit(2);
|
|
6966
|
+
}
|
|
6967
|
+
throw e;
|
|
6968
|
+
}
|
|
6969
|
+
diff = built.diff;
|
|
6970
|
+
source = 'file-blocks';
|
|
6971
|
+
noChanges = built.partCount === 0;
|
|
6972
|
+
if (noChanges) {
|
|
6973
|
+
emit({ ok: true, verdict: 'no_changes' },
|
|
6974
|
+
`○ ${opts.stepKey} file blocks were identical to the tree — nothing to apply.`);
|
|
6975
|
+
return;
|
|
6976
|
+
}
|
|
6977
|
+
} else {
|
|
6978
|
+
// Legacy path: a hand-written ## Patch diff.
|
|
6979
|
+
({ diff, source, noChanges } = extractDiff(output));
|
|
6980
|
+
if (noChanges) {
|
|
6981
|
+
emit({ ok: true, verdict: 'no_changes' },
|
|
6982
|
+
`○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
|
|
6983
|
+
return;
|
|
6984
|
+
}
|
|
6985
|
+
if (!diff) {
|
|
6986
|
+
emit({ ok: false, verdict: 'no_diff', reason: 'no_diff' },
|
|
6987
|
+
`✗ ${opts.stepKey} output contains neither ## Changed Files blocks nor a ` +
|
|
6988
|
+
`unified diff — the agent produced prose. (Placeholder-cascade failure mode.)`);
|
|
6989
|
+
process.exit(1);
|
|
6990
|
+
}
|
|
6991
|
+
}
|
|
6992
|
+
|
|
6993
|
+
// Write to a temp file and run git apply --check against the current repo.
|
|
6994
|
+
const tmp = path.join(os.tmpdir(), `hone-check-patch-${Date.now()}.diff`);
|
|
6995
|
+
let result;
|
|
6996
|
+
try {
|
|
6997
|
+
fs.writeFileSync(tmp, diff);
|
|
6998
|
+
let code = 0, stderr = '';
|
|
6999
|
+
try {
|
|
7000
|
+
execFileSync('git', ['apply', '--check', tmp], {
|
|
7001
|
+
cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
7002
|
+
});
|
|
7003
|
+
} catch (e) {
|
|
7004
|
+
code = e.status || 1;
|
|
7005
|
+
stderr = (e.stderr || '').toString();
|
|
7006
|
+
}
|
|
7007
|
+
result = interpretApplyCheck({ code, stderr });
|
|
7008
|
+
} finally {
|
|
7009
|
+
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
|
|
7010
|
+
}
|
|
7011
|
+
|
|
7012
|
+
if (result.applies) {
|
|
7013
|
+
emit({ ok: true, verdict: result.verdict, source, detail: result.detail },
|
|
7014
|
+
`✓ ${opts.stepKey} diff ${result.detail} (${source} block)`);
|
|
7015
|
+
} else {
|
|
7016
|
+
emit({ ok: false, verdict: 'failed', source, detail: result.detail },
|
|
7017
|
+
`✗ ${opts.stepKey} diff does NOT apply: ${result.detail}\n` +
|
|
7018
|
+
` The output is a well-formed diff but does not match the working tree — ` +
|
|
7019
|
+
`exactly the defect condition 5 catches that condition 4 cannot.`);
|
|
7020
|
+
process.exit(2); // distinct exit: 2 = does-not-apply, 1 = no-diff/fetch
|
|
7021
|
+
}
|
|
7022
|
+
});
|
|
7023
|
+
|
|
7024
|
+
// ── HC-019n-followup-24: verify-patch (pipeline-recovery condition 6) ─────────
|
|
7025
|
+
// Apply step_4's diff inside a throwaway git WORKTREE (checked out from HEAD),
|
|
7026
|
+
// run the adopter's test command there, report pass/fail. The real tree —
|
|
7027
|
+
// including uncommitted work — is never touched. Isolates the TREE, not the
|
|
7028
|
+
// process: ci.local_command is the adopter's own command, same trust as running
|
|
7029
|
+
// it by hand. Verdict is REPORTED, not yet enforced — the server-gate closed
|
|
7030
|
+
// loop is a deliberate follow-up (enabling a gate before its input is proven is
|
|
7031
|
+
// the HC-026c mistake).
|
|
7032
|
+
program
|
|
7033
|
+
.command('verify-patch <workflowId>')
|
|
7034
|
+
.description('Apply step_4\'s diff in an isolated worktree and run the tests (condition 6)')
|
|
7035
|
+
.option('--step-key <key>', 'Step to verify (default: step_4)', 'step_4')
|
|
7036
|
+
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7037
|
+
.option('--command <cmd>', 'Test command (default: ci.local_command from config, else `make ci`)')
|
|
7038
|
+
.option('--timeout <sec>', 'Seconds before the test command is killed', '600')
|
|
7039
|
+
.option('--keep', 'Leave the worktree in place for debugging')
|
|
7040
|
+
.option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
|
|
7041
|
+
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7042
|
+
.action(async (workflowId, opts) => {
|
|
7043
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
7044
|
+
const os = require('os');
|
|
7045
|
+
const { extractFileBlocks, buildDiff, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
|
|
7046
|
+
const { resolveCommand, verdictFromRun, EXIT } = require('./lib/verify-patch');
|
|
7047
|
+
const { gitEnv } = require('./lib/git-env');
|
|
7048
|
+
const config = getConfig();
|
|
7049
|
+
const client = api(config);
|
|
7050
|
+
|
|
7051
|
+
const emit = (obj, humanLine) => {
|
|
7052
|
+
if (opts.format === 'json') console.log(JSON.stringify(obj));
|
|
7053
|
+
else console.log(humanLine);
|
|
7054
|
+
};
|
|
7055
|
+
|
|
7056
|
+
// 1. Fetch step_4 output.
|
|
7057
|
+
let output, runConfig;
|
|
7058
|
+
try {
|
|
7059
|
+
const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
|
|
7060
|
+
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7061
|
+
const r = await client.get(url);
|
|
7062
|
+
output = r.data?.output;
|
|
7063
|
+
// best-effort: pull the run's ci_local_command for the default
|
|
7064
|
+
try {
|
|
7065
|
+
const runR = await client.get(`/orchestrate/${workflowId}`);
|
|
7066
|
+
runConfig = runR.data?.config || {};
|
|
7067
|
+
} catch { runConfig = {}; }
|
|
7068
|
+
} catch (e) {
|
|
7069
|
+
const msg = e.response?.data?.error || e.message;
|
|
7070
|
+
emit({ ok: false, reason: 'fetch_failed', detail: msg },
|
|
7071
|
+
`✗ could not fetch ${opts.stepKey} output: ${msg}`);
|
|
7072
|
+
process.exit(EXIT.NO_DIFF);
|
|
7073
|
+
}
|
|
7074
|
+
|
|
7075
|
+
// 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
|
|
7076
|
+
// Condition 6 presupposes 5.
|
|
7077
|
+
const blocks = extractFileBlocks(output);
|
|
7078
|
+
const hasWork = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
|
|
7079
|
+
if (blocks.noChanges || !hasWork) {
|
|
7080
|
+
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
7081
|
+
`✗ ${opts.stepKey} produced no file changes to verify.`);
|
|
7082
|
+
process.exit(EXIT.NO_DIFF);
|
|
7083
|
+
}
|
|
7084
|
+
let diff;
|
|
7085
|
+
try {
|
|
7086
|
+
diff = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() })).diff;
|
|
7087
|
+
} catch (e) {
|
|
7088
|
+
if (e instanceof MaterializeError) {
|
|
7089
|
+
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
7090
|
+
`✗ region edit could not be applied (${e.code}): ${e.message}`);
|
|
7091
|
+
process.exit(EXIT.DOES_NOT_APPLY);
|
|
7092
|
+
}
|
|
7093
|
+
throw e;
|
|
7094
|
+
}
|
|
7095
|
+
if (!diff.trim()) {
|
|
7096
|
+
emit({ ok: true, verdict: 'no_changes' },
|
|
7097
|
+
`○ ${opts.stepKey} file blocks matched the tree — nothing to verify.`);
|
|
7098
|
+
return;
|
|
7099
|
+
}
|
|
7100
|
+
|
|
7101
|
+
// 3. Isolated worktree from HEAD.
|
|
7102
|
+
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
|
|
7103
|
+
const diffFile = path.join(os.tmpdir(), `hone-verify-${Date.now()}.diff`);
|
|
7104
|
+
fs.writeFileSync(diffFile, diff);
|
|
7105
|
+
const command = resolveCommand(opts.command, runConfig?.ci_local_command);
|
|
7106
|
+
let cleanupDone = false;
|
|
7107
|
+
const cleanup = () => {
|
|
7108
|
+
if (cleanupDone || opts.keep) return;
|
|
7109
|
+
cleanupDone = true;
|
|
7110
|
+
try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
|
|
7111
|
+
try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
7112
|
+
try { fs.unlinkSync(diffFile); } catch { /* best-effort */ }
|
|
7113
|
+
};
|
|
7114
|
+
// Guard against leaving a worktree if the process is interrupted.
|
|
7115
|
+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
7116
|
+
|
|
7117
|
+
// HC-019n-followup-24 fix: NEVER call process.exit() inside the try — it
|
|
7118
|
+
// skips the finally, so the worktree leaks (found by the live verification:
|
|
7119
|
+
// three hone-verify-* worktrees survived). Compute the exit code, let the
|
|
7120
|
+
// finally clean up, then exit.
|
|
7121
|
+
let exitCode = EXIT.PASS;
|
|
7122
|
+
// HC-019n-followup-34: capture the verdict + HEAD sha for the closed-loop
|
|
7123
|
+
// report (--report). Hoisted so they survive the try/finally to the POST.
|
|
7124
|
+
let reportVerdict = null, reportDetail = null, headSha = null;
|
|
7125
|
+
try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
|
|
7126
|
+
try {
|
|
7127
|
+
execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'],
|
|
7128
|
+
{ cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7129
|
+
|
|
7130
|
+
// 4. Apply the diff INTO the worktree (real apply, isolated).
|
|
7131
|
+
let applied = true, applyDetail = '';
|
|
7132
|
+
try {
|
|
7133
|
+
execFileSync('git', ['-C', wt, 'apply', diffFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7134
|
+
} catch (e) {
|
|
7135
|
+
applied = false;
|
|
7136
|
+
applyDetail = (e.stderr || '').toString().split('\n').find(Boolean) || 'git apply failed';
|
|
7137
|
+
}
|
|
7138
|
+
if (!applied) {
|
|
7139
|
+
emit({ ok: false, verdict: 'does_not_apply', detail: applyDetail },
|
|
7140
|
+
`✗ diff did not apply in the worktree: ${applyDetail}`);
|
|
7141
|
+
exitCode = EXIT.DOES_NOT_APPLY;
|
|
7142
|
+
reportVerdict = 'does_not_apply'; reportDetail = applyDetail;
|
|
7143
|
+
} else {
|
|
7144
|
+
// 5. Run the adopter's test command in the worktree.
|
|
7145
|
+
if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
7146
|
+
const run = spawnSync(command, {
|
|
7147
|
+
cwd: wt, shell: true, encoding: 'utf8',
|
|
7148
|
+
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
7149
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7150
|
+
});
|
|
7151
|
+
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
7152
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
|
|
7153
|
+
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : '✗';
|
|
7154
|
+
emit(
|
|
7155
|
+
{ ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail },
|
|
7156
|
+
`${sym} ${v.detail}\n (command: ${command})`);
|
|
7157
|
+
exitCode = v.exitCode;
|
|
7158
|
+
reportVerdict = v.verdict; reportDetail = v.detail;
|
|
7159
|
+
}
|
|
7160
|
+
} catch (e) {
|
|
7161
|
+
emit({ ok: false, verdict: 'error', detail: e.message },
|
|
7162
|
+
`✗ verify-patch failed: ${e.message}`);
|
|
7163
|
+
exitCode = 1;
|
|
7164
|
+
reportVerdict = 'error'; reportDetail = e.message;
|
|
7165
|
+
} finally {
|
|
7166
|
+
cleanup();
|
|
7167
|
+
}
|
|
7168
|
+
|
|
7169
|
+
// HC-019n-followup-34: the closed loop — report the EXECUTED verdict back to
|
|
7170
|
+
// the server (advisory record). Best-effort: a reporting failure must not
|
|
7171
|
+
// change the local exit code (the verdict is already true locally).
|
|
7172
|
+
if (opts.report && reportVerdict) {
|
|
7173
|
+
try {
|
|
7174
|
+
await client.post(`/orchestrate/${workflowId}/verdict`, {
|
|
7175
|
+
source: 'verify-patch',
|
|
7176
|
+
stepKey: opts.stepKey,
|
|
7177
|
+
verdict: reportVerdict,
|
|
7178
|
+
exitCode,
|
|
7179
|
+
detail: reportDetail,
|
|
7180
|
+
attempt: opts.attempt ? Number(opts.attempt) : undefined,
|
|
7181
|
+
headSha,
|
|
7182
|
+
});
|
|
7183
|
+
if (opts.format !== 'json') console.log(` → reported verdict '${reportVerdict}' to the server (advisory)`);
|
|
7184
|
+
} catch (e) {
|
|
7185
|
+
if (opts.format !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
7186
|
+
}
|
|
7187
|
+
}
|
|
7188
|
+
process.exit(exitCode);
|
|
7189
|
+
});
|
|
7190
|
+
|
|
7191
|
+
// ── HC-019n-followup-25: emit-pr (pipeline-recovery condition 7) ──────────────
|
|
7192
|
+
// Condition 6 proved step_4's diff applies AND its tests pass in isolation.
|
|
7193
|
+
// Condition 7 turns that verified diff into the reviewable artifact: a branch
|
|
7194
|
+
// and (opt-in) a draft PR. Publishing is staged — tier-1 dry run by default;
|
|
7195
|
+
// --push, then --open-pr. Sibling to check-patch/verify-patch (top-level).
|
|
7196
|
+
program
|
|
7197
|
+
.command('emit-pr <workflowId>')
|
|
7198
|
+
.description('Emit a branch + draft PR from step_4\'s verified diff (condition 7)')
|
|
7199
|
+
.option('--step-key <key>', 'Step to emit (default: step_4)', 'step_4')
|
|
7200
|
+
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7201
|
+
.option('--branch <name>', 'Override the generated branch name')
|
|
7202
|
+
.option('--base <ref>', 'Target base branch for the PR (default: repo default)')
|
|
7203
|
+
.option('--push', 'Push the branch to origin (default: local dry run only)')
|
|
7204
|
+
.option('--open-pr', 'Open a draft PR (implies --push)')
|
|
7205
|
+
.option('--ready', 'Open the PR ready-for-review instead of draft')
|
|
7206
|
+
.option('--skip-verify', 'Do not re-run the tests before committing (downgrades provenance)')
|
|
7207
|
+
.option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
|
|
7208
|
+
.option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
|
|
7209
|
+
.option('--keep', 'Leave the worktree in place for debugging')
|
|
7210
|
+
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7211
|
+
.action(async (workflowId, opts) => {
|
|
7212
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
7213
|
+
const os = require('os');
|
|
7214
|
+
const { extractFileBlocks, buildDiff, nodeMaterializeIO, MaterializeError } = require('./lib/materialize-diff');
|
|
7215
|
+
const { resolveCommand, verdictFromRun } = require('./lib/verify-patch');
|
|
7216
|
+
const {
|
|
7217
|
+
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
7218
|
+
} = require('./lib/emit-pr');
|
|
7219
|
+
const { gitEnv } = require('./lib/git-env');
|
|
7220
|
+
const config = getConfig();
|
|
7221
|
+
const client = api(config);
|
|
7222
|
+
|
|
7223
|
+
// --open-pr implies --push (cannot open a PR against an unpushed branch).
|
|
7224
|
+
const doPush = Boolean(opts.push || opts.openPr);
|
|
7225
|
+
const doOpenPr = Boolean(opts.openPr);
|
|
7226
|
+
const doVerify = !opts.skipVerify;
|
|
7227
|
+
|
|
7228
|
+
const emit = (obj, humanLine) => {
|
|
7229
|
+
if (opts.format === 'json') console.log(JSON.stringify(obj));
|
|
7230
|
+
else if (humanLine != null) console.log(humanLine);
|
|
7231
|
+
};
|
|
7232
|
+
|
|
7233
|
+
// 1. Fetch step_4 output + the run's storyId (for naming/provenance).
|
|
7234
|
+
let output, storyId = 'story', runConfig = {};
|
|
7235
|
+
try {
|
|
7236
|
+
const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
|
|
7237
|
+
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7238
|
+
const r = await client.get(url);
|
|
7239
|
+
output = r.data?.output;
|
|
7240
|
+
try {
|
|
7241
|
+
const runR = await client.get(`/orchestrate/${workflowId}`);
|
|
7242
|
+
storyId = runR.data?.storyId || runR.data?.story_id || 'story';
|
|
7243
|
+
runConfig = runR.data?.config || {};
|
|
7244
|
+
} catch { /* naming falls back to 'story'; command falls back to make ci */ }
|
|
7245
|
+
} catch (e) {
|
|
7246
|
+
const msg = e.response?.data?.error || e.message;
|
|
7247
|
+
emit({ ok: false, reason: 'fetch_failed', detail: msg },
|
|
7248
|
+
`✗ could not fetch ${opts.stepKey} output: ${msg}`);
|
|
7249
|
+
process.exit(EXIT.NO_DIFF);
|
|
7250
|
+
}
|
|
7251
|
+
|
|
7252
|
+
// 2. Materialize the diff (shared with check-patch/verify-patch,
|
|
7253
|
+
// followup-23/27). Condition 7 presupposes 5.
|
|
7254
|
+
const blocks = extractFileBlocks(output);
|
|
7255
|
+
const hasWork = blocks.files.length > 0 || (blocks.edits && blocks.edits.length > 0) || blocks.deletes.length > 0;
|
|
7256
|
+
if (blocks.noChanges || !hasWork) {
|
|
7257
|
+
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
7258
|
+
`✗ ${opts.stepKey} produced no file changes to emit.`);
|
|
7259
|
+
process.exit(EXIT.NO_DIFF);
|
|
7260
|
+
}
|
|
7261
|
+
let diff;
|
|
7262
|
+
try {
|
|
7263
|
+
diff = buildDiff(blocks, nodeMaterializeIO({ cwd: process.cwd() })).diff;
|
|
7264
|
+
} catch (e) {
|
|
7265
|
+
if (e instanceof MaterializeError) {
|
|
7266
|
+
emit({ ok: false, verdict: 'does_not_apply', reason: e.code, detail: e.message },
|
|
7267
|
+
`✗ region edit could not be applied (${e.code}): ${e.message}`);
|
|
7268
|
+
process.exit(EXIT.DOES_NOT_APPLY);
|
|
7269
|
+
}
|
|
7270
|
+
throw e;
|
|
7271
|
+
}
|
|
7272
|
+
if (!diff.trim()) {
|
|
7273
|
+
emit({ ok: true, verdict: 'no_changes' },
|
|
7274
|
+
`○ ${opts.stepKey} file blocks matched the tree — nothing to emit.`);
|
|
7275
|
+
return;
|
|
7276
|
+
}
|
|
7277
|
+
|
|
7278
|
+
// 3. Resolve names + probe idempotency BEFORE any mutation.
|
|
7279
|
+
let headSha = '';
|
|
7280
|
+
try {
|
|
7281
|
+
headSha = execFileSync('git', ['rev-parse', 'HEAD'],
|
|
7282
|
+
{ cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim();
|
|
7283
|
+
} catch {
|
|
7284
|
+
emit({ ok: false, reason: 'no_head' }, '✗ repository has no HEAD commit — cannot branch from nothing.');
|
|
7285
|
+
process.exit(1);
|
|
7286
|
+
}
|
|
7287
|
+
const branch = buildBranchName({ storyId, workflowId, override: opts.branch });
|
|
7288
|
+
|
|
7289
|
+
const branchExists = (() => {
|
|
7290
|
+
try {
|
|
7291
|
+
execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`],
|
|
7292
|
+
{ cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' });
|
|
7293
|
+
return true;
|
|
7294
|
+
} catch { return false; }
|
|
7295
|
+
})();
|
|
7296
|
+
// Only probe the remote for an existing PR when we intend to publish.
|
|
7297
|
+
let openPrUrl = null;
|
|
7298
|
+
if (doOpenPr) {
|
|
7299
|
+
try {
|
|
7300
|
+
const out = execFileSync('gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'url', '--jq', '.[0].url'],
|
|
7301
|
+
{ cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
7302
|
+
openPrUrl = out || null;
|
|
7303
|
+
} catch { openPrUrl = null; /* gh missing/unauthed handled at push/open time */ }
|
|
7304
|
+
}
|
|
7305
|
+
const decision = decidePublish({ branchExists, openPrUrl });
|
|
7306
|
+
if (decision.action === 'already_open') {
|
|
7307
|
+
emit({ ok: true, verdict: 'already_open', prUrl: decision.prUrl, branch },
|
|
7308
|
+
`✓ a draft PR is already open for ${branch}: ${decision.prUrl}`);
|
|
7309
|
+
process.exit(EXIT.PASS);
|
|
7310
|
+
}
|
|
7311
|
+
if (decision.action === 'branch_conflict') {
|
|
7312
|
+
emit({ ok: false, verdict: 'branch_conflict', branch },
|
|
7313
|
+
`✗ branch '${branch}' already exists — not overwriting. Use --branch to pick another name.`);
|
|
7314
|
+
process.exit(EXIT.BRANCH_CONFLICT);
|
|
7315
|
+
}
|
|
7316
|
+
|
|
7317
|
+
// 4. Isolated worktree with a NAMED branch off HEAD (adopter tree untouched).
|
|
7318
|
+
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
|
|
7319
|
+
const diffFile = path.join(os.tmpdir(), `hone-emit-${Date.now()}.diff`);
|
|
7320
|
+
fs.writeFileSync(diffFile, diff);
|
|
7321
|
+
const command = resolveCommand(opts.command, runConfig?.ci_local_command);
|
|
7322
|
+
// `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
|
|
7323
|
+
// before a successful commit (apply fails, verify fails), that empty branch
|
|
7324
|
+
// (pointing at HEAD) must be torn down too — else "a failing diff never
|
|
7325
|
+
// reaches a branch" would be false and a re-run would hit a bogus conflict.
|
|
7326
|
+
// `committed` gates it: once we have the deliverable commit, keep the branch.
|
|
7327
|
+
let cleanupDone = false;
|
|
7328
|
+
let committed = false;
|
|
7329
|
+
const cleanup = () => {
|
|
7330
|
+
if (cleanupDone || opts.keep) return;
|
|
7331
|
+
cleanupDone = true;
|
|
7332
|
+
try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
|
|
7333
|
+
// Remove the leaked empty branch only when nothing was committed onto it.
|
|
7334
|
+
if (!committed) {
|
|
7335
|
+
try { execFileSync('git', ['branch', '-D', branch], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
|
|
7336
|
+
}
|
|
7337
|
+
try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
7338
|
+
try { fs.unlinkSync(diffFile); } catch { /* best-effort */ }
|
|
7339
|
+
};
|
|
7340
|
+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
7341
|
+
|
|
7342
|
+
// HC-019n-followup-24 lesson: NEVER process.exit() inside the try — it skips
|
|
7343
|
+
// the finally and leaks the worktree. Capture exitCode; exit after cleanup.
|
|
7344
|
+
let exitCode = EXIT.PASS;
|
|
7345
|
+
let verifyState = 'skipped';
|
|
7346
|
+
try {
|
|
7347
|
+
execFileSync('git', ['worktree', 'add', '-b', branch, wt, 'HEAD'],
|
|
7348
|
+
{ cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7349
|
+
|
|
7350
|
+
// 4a. Apply the diff INTO the worktree.
|
|
7351
|
+
let applied = true, applyDetail = '';
|
|
7352
|
+
try {
|
|
7353
|
+
execFileSync('git', ['-C', wt, 'apply', diffFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7354
|
+
} catch (e) {
|
|
7355
|
+
applied = false;
|
|
7356
|
+
applyDetail = (e.stderr || '').toString().split('\n').find(Boolean) || 'git apply failed';
|
|
7357
|
+
}
|
|
7358
|
+
if (!applied) {
|
|
7359
|
+
emit({ ok: false, verdict: 'does_not_apply', detail: applyDetail },
|
|
7360
|
+
`✗ diff did not apply in the worktree: ${applyDetail}`);
|
|
7361
|
+
exitCode = EXIT.DOES_NOT_APPLY;
|
|
7362
|
+
} else {
|
|
7363
|
+
// 4b. Re-verify inline (default) — a failing diff never reaches a branch.
|
|
7364
|
+
let verifyOk = true;
|
|
7365
|
+
if (doVerify) {
|
|
7366
|
+
if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
7367
|
+
const run = spawnSync(command, {
|
|
7368
|
+
cwd: wt, shell: true, encoding: 'utf8',
|
|
7369
|
+
timeout: Number(opts.timeout) * 1000, env: gitEnv(),
|
|
7370
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7371
|
+
});
|
|
7372
|
+
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
7373
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr });
|
|
7374
|
+
if (v.verdict === 'pass') {
|
|
7375
|
+
verifyState = 'pass';
|
|
7376
|
+
} else {
|
|
7377
|
+
verifyOk = false;
|
|
7378
|
+
const sym = v.verdict === 'timeout' ? '⏱' : '✗';
|
|
7379
|
+
emit({ ok: false, verdict: v.verdict, command, detail: v.detail },
|
|
7380
|
+
`${sym} verify failed, no branch emitted: ${v.detail}\n (command: ${command})`);
|
|
7381
|
+
exitCode = v.exitCode; // 3 tests-failed or 4 timeout
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
|
|
7385
|
+
if (verifyOk) {
|
|
7386
|
+
// 4c. Commit the applied diff onto the branch, inside the worktree.
|
|
7387
|
+
// No summary source (GET /orchestrate omits config/story text), so the
|
|
7388
|
+
// commit subject falls back to the storyId + default in buildCommitMessage.
|
|
7389
|
+
const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState });
|
|
7390
|
+
const msgFile = path.join(os.tmpdir(), `hone-emit-msg-${Date.now()}.txt`);
|
|
7391
|
+
fs.writeFileSync(msgFile, message);
|
|
7392
|
+
try {
|
|
7393
|
+
execFileSync('git', ['-C', wt, 'add', '-A'], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7394
|
+
execFileSync('git', ['-C', wt, 'commit', '-F', msgFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7395
|
+
committed = true; // the branch now holds the deliverable — keep it past cleanup
|
|
7396
|
+
} finally { try { fs.unlinkSync(msgFile); } catch { /* best-effort */ } }
|
|
7397
|
+
|
|
7398
|
+
const stat = (() => {
|
|
7399
|
+
try {
|
|
7400
|
+
return execFileSync('git', ['-C', wt, 'show', '--stat', '--oneline', 'HEAD'],
|
|
7401
|
+
{ env: gitEnv(), encoding: 'utf8' }).trim();
|
|
7402
|
+
} catch { return ''; }
|
|
7403
|
+
})();
|
|
7404
|
+
|
|
7405
|
+
// ── Boundary: everything above is local + reversible. ──
|
|
7406
|
+
let prUrl = null, pushed = false;
|
|
7407
|
+
if (doPush) {
|
|
7408
|
+
try {
|
|
7409
|
+
execFileSync('git', ['push', '-u', 'origin', branch],
|
|
7410
|
+
{ cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7411
|
+
pushed = true;
|
|
7412
|
+
} catch (e) {
|
|
7413
|
+
const detail = (e.stderr || e.message || '').toString().split('\n').find(Boolean) || 'push failed';
|
|
7414
|
+
emit({ ok: false, verdict: 'publish_failed', stage: 'push', detail, branch },
|
|
7415
|
+
`✗ push failed (branch committed locally as '${branch}', not published): ${detail}`);
|
|
7416
|
+
exitCode = EXIT.PUBLISH_FAILED;
|
|
7417
|
+
}
|
|
7418
|
+
}
|
|
7419
|
+
if (pushed && doOpenPr) {
|
|
7420
|
+
const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined });
|
|
7421
|
+
const bodyFile = path.join(os.tmpdir(), `hone-emit-prbody-${Date.now()}.md`);
|
|
7422
|
+
fs.writeFileSync(bodyFile, prBody);
|
|
7423
|
+
const title = `${storyId}: Hone step_4 change (workflow ${String(workflowId).slice(0, 8)})`;
|
|
7424
|
+
const ghArgs = ['pr', 'create', '--head', branch, '--title', title, '--body-file', bodyFile];
|
|
7425
|
+
if (!opts.ready) ghArgs.push('--draft');
|
|
7426
|
+
if (opts.base) ghArgs.push('--base', opts.base);
|
|
7427
|
+
try {
|
|
7428
|
+
prUrl = execFileSync('gh', ghArgs,
|
|
7429
|
+
{ cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
7430
|
+
} catch (e) {
|
|
7431
|
+
const detail = (e.stderr || e.message || '').toString().split('\n').find(Boolean) || 'gh pr create failed';
|
|
7432
|
+
emit({ ok: false, verdict: 'publish_failed', stage: 'open_pr', detail, branch },
|
|
7433
|
+
`✗ branch pushed, but opening the PR failed: ${detail}\n → open it manually, or check \`gh auth status\`.`);
|
|
7434
|
+
exitCode = EXIT.PUBLISH_FAILED;
|
|
7435
|
+
} finally { try { fs.unlinkSync(bodyFile); } catch { /* best-effort */ } }
|
|
7436
|
+
}
|
|
7437
|
+
|
|
7438
|
+
if (exitCode === EXIT.PASS) {
|
|
7439
|
+
const tier = prUrl ? 'pr' : pushed ? 'pushed' : 'dry_run';
|
|
7440
|
+
const human = prUrl
|
|
7441
|
+
? `✓ draft PR opened: ${prUrl}`
|
|
7442
|
+
: pushed
|
|
7443
|
+
? `✓ branch pushed to origin/${branch}\n → open a PR: gh pr create --head ${branch} --draft`
|
|
7444
|
+
: `✓ branch '${branch}' built locally (dry run — nothing pushed)\n` +
|
|
7445
|
+
` verify: ${verifyState}\n\n${stat}\n\n` +
|
|
7446
|
+
` → push it: hone emit-pr ${workflowId} --push\n` +
|
|
7447
|
+
` → push + PR: hone emit-pr ${workflowId} --open-pr\n` +
|
|
7448
|
+
` → discard it: git branch -D ${branch}`;
|
|
7449
|
+
emit({ ok: true, verdict: tier, branch, verifyState, prUrl, pushed, headSha }, human);
|
|
7450
|
+
}
|
|
7451
|
+
}
|
|
7452
|
+
}
|
|
7453
|
+
} catch (e) {
|
|
7454
|
+
emit({ ok: false, verdict: 'error', detail: e.message }, `✗ emit-pr failed: ${e.message}`);
|
|
7455
|
+
exitCode = 1;
|
|
7456
|
+
} finally {
|
|
7457
|
+
cleanup();
|
|
7458
|
+
}
|
|
7459
|
+
process.exit(exitCode);
|
|
7460
|
+
});
|
|
7461
|
+
|
|
7462
|
+
// ── HC-019n-followup-32: verify-pr (pipeline-standards G4 §2, G3 functional) ────
|
|
7463
|
+
// The post-emit-pr step: once a REAL PR exists, run the real skill audit
|
|
7464
|
+
// (`hone step-5b`) + the real CI gate (`gh pr checks` / `make ci`), writing the
|
|
7465
|
+
// two artifacts (step-5b-skill-audit.md, step-5c-ci.md) the server DAG can only
|
|
7466
|
+
// MODEL, never execute (those steps are serverExecutable:false). VERIFY + REPORT
|
|
7467
|
+
// only — no auto-fix loop; the operator (or a later closed loop) acts on the
|
|
7468
|
+
// verdict. Sibling to check-patch → verify-patch → emit-pr → verify-pr.
|
|
7469
|
+
program
|
|
7470
|
+
.command('verify-pr <prOrBranch>')
|
|
7471
|
+
.description('Post-PR verification: real skill audit (hone step-5b) + real CI gate (gh pr checks / make ci) (HC-019n-followup-32)')
|
|
7472
|
+
.option('--story-id <id>', 'override story id (defaults to extraction from the branch)')
|
|
7473
|
+
.option('--base <branch>', 'base branch for the skill-audit diff', 'develop')
|
|
7474
|
+
.option('--mode <mode>', 'CI gate mode: github | local | both | none (default: ci.gate config, else github)')
|
|
7475
|
+
.option('--command <cmd>', 'local CI command (default: ci.local_command, else make ci)')
|
|
7476
|
+
.option('--timeout <sec>', 'seconds before the local CI command is killed', '900')
|
|
7477
|
+
.option('--skip-skill-audit', 'skip the hone step-5b skill audit')
|
|
7478
|
+
.option('--report', 'HC-019n-followup-36: report the CI-gate verdict back to the server (closed loop, records step_5c)')
|
|
7479
|
+
.option('--workflow-id <id>', 'the run id to report the verdict against (required with --report)')
|
|
7480
|
+
.option('--format <fmt>', 'pretty or json', 'pretty')
|
|
7481
|
+
.action(async (prOrBranch, opts) => {
|
|
7482
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
7483
|
+
const { readCIGateConfig } = require('./lib/pipeline-config');
|
|
7484
|
+
const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
|
|
7485
|
+
const { EXIT, resolveGateMode, interpretGhChecks, ciGateVerdict, renderStep5cArtifact } = require('./lib/verify-pr');
|
|
7486
|
+
const { gitEnv } = require('./lib/git-env');
|
|
7487
|
+
const repoRoot = process.cwd();
|
|
7488
|
+
const emit = (obj, human) => {
|
|
7489
|
+
if (opts.format === 'json') console.log(JSON.stringify(obj));
|
|
7490
|
+
else if (human != null) console.log(human);
|
|
7491
|
+
};
|
|
7492
|
+
|
|
7493
|
+
// 1. Story id: flag > branch > the ref itself.
|
|
7494
|
+
let branch = '';
|
|
7495
|
+
try { branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* */ }
|
|
7496
|
+
const storyId = opts.storyId || extractStoryIdFromBranch(branch) || extractStoryIdFromBranch(prOrBranch) || 'unknown-story';
|
|
7497
|
+
|
|
7498
|
+
// 2. CI gate mode: --mode > ci.gate config > github.
|
|
7499
|
+
let configGate = 'github';
|
|
7500
|
+
try { configGate = readCIGateConfig(repoRoot).gate; } catch { /* default */ }
|
|
7501
|
+
const mode = resolveGateMode(opts.mode, configGate);
|
|
7502
|
+
|
|
7503
|
+
// 3. Skill audit — reuse the working `hone step-5b` command (deterministic
|
|
7504
|
+
// SA-001 engine over the PR diff). Informational; never affects the exit.
|
|
7505
|
+
let skillAudit = { ran: false };
|
|
7506
|
+
if (!opts.skipSkillAudit) {
|
|
7507
|
+
try {
|
|
7508
|
+
execFileSync(process.execPath, [__filename, 'step-5b', '--story-id', storyId, '--base', opts.base],
|
|
7509
|
+
{ cwd: repoRoot, env: process.env, stdio: opts.format === 'json' ? 'ignore' : 'inherit' });
|
|
7510
|
+
skillAudit = { ran: true, artifact: `.github/pipeline/${storyId}/step-5b-skill-audit.md` };
|
|
7511
|
+
} catch (e) {
|
|
7512
|
+
skillAudit = { ran: true, error: (e.message || '').split('\n')[0] };
|
|
7513
|
+
}
|
|
7514
|
+
}
|
|
7515
|
+
|
|
7516
|
+
// 4. CI gate.
|
|
7517
|
+
let gh = null, local = null;
|
|
7518
|
+
if (mode === 'github' || mode === 'both') {
|
|
7519
|
+
let prNum = /^\d+$/.test(prOrBranch) ? prOrBranch : null;
|
|
7520
|
+
if (!prNum) { const m = String(prOrBranch).match(/\/pull\/(\d+)/); if (m) prNum = m[1]; }
|
|
7521
|
+
if (!prNum) {
|
|
7522
|
+
try { prNum = execFileSync('gh', ['pr', 'list', '--head', prOrBranch, '--state', 'all', '--json', 'number', '--jq', '.[0].number'], { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; } catch { prNum = null; }
|
|
7523
|
+
}
|
|
7524
|
+
if (!prNum) {
|
|
7525
|
+
emit({ ok: false, reason: 'no_pr', ref: prOrBranch },
|
|
7526
|
+
`✗ could not resolve a PR for '${prOrBranch}' — github mode needs a PR number, URL, or a branch with a PR.`);
|
|
7527
|
+
process.exit(EXIT.NO_PR);
|
|
7528
|
+
}
|
|
7529
|
+
// gh pr checks exits non-zero when checks fail/pend, but still prints JSON.
|
|
7530
|
+
let raw = '';
|
|
7531
|
+
try {
|
|
7532
|
+
raw = execFileSync('gh', ['pr', 'checks', prNum, '--json', 'name,state,bucket'], { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
7533
|
+
} catch (e) { raw = (e.stdout || '').toString(); }
|
|
7534
|
+
try { gh = interpretGhChecks(JSON.parse(raw)); } catch { gh = null; }
|
|
7535
|
+
}
|
|
7536
|
+
if (mode === 'local' || mode === 'both') {
|
|
7537
|
+
let command = (opts.command && opts.command.trim()) || '';
|
|
7538
|
+
if (!command) { try { command = readCIGateConfig(repoRoot).local_command; } catch { command = 'make ci'; } }
|
|
7539
|
+
command = command || 'make ci';
|
|
7540
|
+
if (opts.format !== 'json') console.log(` running local CI: ${command} (timeout ${opts.timeout}s)`);
|
|
7541
|
+
const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: Number(opts.timeout) * 1000, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
7542
|
+
local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
|
|
7543
|
+
}
|
|
7544
|
+
|
|
7545
|
+
const verdict = ciGateVerdict({ mode, gh, local });
|
|
7546
|
+
|
|
7547
|
+
// 5. Write the real step-5c-ci.md artifact.
|
|
7548
|
+
try {
|
|
7549
|
+
const dir = path.join(repoRoot, '.github/pipeline', storyId);
|
|
7550
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
7551
|
+
fs.writeFileSync(path.join(dir, 'step-5c-ci.md'), renderStep5cArtifact({ storyId, mode, prRef: prOrBranch, gh, local, verdict }));
|
|
7552
|
+
} catch { /* best-effort */ }
|
|
7553
|
+
|
|
7554
|
+
// 6. Closed loop (HC-019n-followup-36): report the CI-gate verdict to the
|
|
7555
|
+
// server. This RECORDS against step_5c — which is a post-PR, CLI-produced
|
|
7556
|
+
// node (serverExecutable:false), so there is no server gate to auto-approve:
|
|
7557
|
+
// verify-pr's verdict is ADVISORY audit even in enforce mode (enforce gates
|
|
7558
|
+
// step_4, the build). Best-effort — never changes the local exit code.
|
|
7559
|
+
let reported = false;
|
|
7560
|
+
if (opts.report) {
|
|
7561
|
+
if (!opts.workflowId) {
|
|
7562
|
+
if (opts.format !== 'json') console.warn(' ⚠ --report needs --workflow-id <run id> to address the run; skipping report.');
|
|
7563
|
+
} else {
|
|
7564
|
+
try {
|
|
7565
|
+
const client = api(getConfig());
|
|
7566
|
+
await client.post(`/orchestrate/${opts.workflowId}/verdict`, {
|
|
7567
|
+
source: 'verify-pr',
|
|
7568
|
+
stepKey: 'step_5c',
|
|
7569
|
+
verdict: verdict.verdict,
|
|
7570
|
+
exitCode: verdict.exitCode,
|
|
7571
|
+
detail: verdict.detail,
|
|
7572
|
+
});
|
|
7573
|
+
reported = true;
|
|
7574
|
+
if (opts.format !== 'json') console.log(` → reported CI-gate verdict '${verdict.verdict}' to run ${opts.workflowId} (advisory)`);
|
|
7575
|
+
} catch (e) {
|
|
7576
|
+
if (opts.format !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
7577
|
+
}
|
|
7578
|
+
}
|
|
7579
|
+
}
|
|
7580
|
+
|
|
7581
|
+
// 7. Report. The exit code is the CI verdict's; the skill audit is advisory.
|
|
7582
|
+
const sym = (verdict.verdict === 'pass' || verdict.verdict === 'disabled') ? '✓' : verdict.verdict === 'pending' ? '⏳' : '✗';
|
|
7583
|
+
emit(
|
|
7584
|
+
{ ok: verdict.exitCode === EXIT.PASS, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
|
|
7585
|
+
`${sym} CI gate (${mode}): ${verdict.verdict} — ${verdict.detail}\n` +
|
|
7586
|
+
` skill audit: ${opts.skipSkillAudit ? 'skipped' : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
|
|
7587
|
+
` → .github/pipeline/${storyId}/step-5c-ci.md`);
|
|
7588
|
+
process.exit(verdict.exitCode);
|
|
7589
|
+
});
|
|
7590
|
+
|
|
5502
7591
|
// ── CLI setup ─────────────────────────────────────────────────────────────────
|
|
5503
7592
|
program
|
|
5504
7593
|
.name('hone')
|