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