@hone-ai/cli 1.18.0 → 1.19.0

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