@hone-ai/cli 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+ /**
3
+ * eval-evidence.js — HC-RC-001 CLI-side evidence file producer.
4
+ *
5
+ * When the adopter passes `--evidence-mode local` to `hone eval`, this
6
+ * module writes `.hone/eval-evidence.json` containing a signed payload
7
+ * the CI gate can verify offline (no Anthropic API call).
8
+ *
9
+ * The HMAC secret is sourced from `process.env.HONE_EVIDENCE_SECRET` at
10
+ * adopter-machine call time and NEVER leaves the machine. Server stores
11
+ * only the ciphertext form (envelope-encrypted via byo-key-crypto). CI
12
+ * fetches the same secret from its own org-scoped vault.
13
+ *
14
+ * When the env var is missing, the CLI prints a one-line warning and
15
+ * SKIPS the evidence file (does NOT throw — `hone eval` still exits with
16
+ * the eval result code; evidence is opportunistic).
17
+ */
18
+
19
+ const path = require('node:path');
20
+ const fs = require('node:fs');
21
+
22
+ const SIGNER_PATH = path.resolve(__dirname, '..', '..', 'server', 'src', 'services', 'evidence-signer.js');
23
+ const { buildEvidence, computeScenarioSetHash } = require(SIGNER_PATH);
24
+
25
+ const DEFAULT_OUTPUT_REL = path.join('.hone', 'eval-evidence.json');
26
+ const ENV_SECRET = 'HONE_EVIDENCE_SECRET';
27
+ const ENV_GRADER_VERSION = 'HONE_GRADER_VERSION';
28
+ const ENV_PROMPT_SET_VERSION = 'HONE_PROMPT_SET_VERSION';
29
+ const ENV_MODEL = 'HONE_EVIDENCE_MODEL';
30
+ const ENV_DIFF_FILE = 'HONE_DIFF_FILE';
31
+ const ENV_ORG_ID = 'HONE_ORG_ID';
32
+
33
+ const SUPPORTED_MODES = ['local', 'off'];
34
+
35
+ /**
36
+ * Normalize the --evidence-mode flag. Unknown values fall through to
37
+ * 'off' with a warning to stderr (NOT a hard fail — adopters in older
38
+ * scripts shouldn't break on typos that pre-date this story).
39
+ *
40
+ * @param {string|undefined} raw
41
+ * @returns {string}
42
+ */
43
+ function normalizeEvidenceMode(raw) {
44
+ if (raw === undefined || raw === null || raw === '') return 'off';
45
+ const v = String(raw).toLowerCase().trim();
46
+ if (SUPPORTED_MODES.includes(v)) return v;
47
+ process.stderr.write(
48
+ `[hone eval] warning: unknown --evidence-mode value "${raw}" — falling back to "off"\n`,
49
+ );
50
+ return 'off';
51
+ }
52
+
53
+ /**
54
+ * Read the diff that the evidence covers.
55
+ *
56
+ * Single source today: `process.env.HONE_DIFF_FILE` path. Callers that want
57
+ * to pipe via stdin must materialize to a temp file and set the env var;
58
+ * stdin streaming inside an async commander action is footgun-prone (would
59
+ * require parking on process.stdin before the eval starts, complicating
60
+ * error handling). Empty return → caller should refuse to produce evidence.
61
+ *
62
+ * HC-RC-001 pass-1 (LOW-2): docstring no longer claims stdin fallback that
63
+ * the implementation didn't honor.
64
+ *
65
+ * @param {{readFileSync?: Function}=} deps
66
+ * @returns {string}
67
+ */
68
+ function readDiffInput(deps = {}) {
69
+ const readFileSync = deps.readFileSync || fs.readFileSync;
70
+ const envPath = process.env[ENV_DIFF_FILE];
71
+ if (envPath && envPath.length > 0) {
72
+ return readFileSync(envPath, 'utf8');
73
+ }
74
+ return '';
75
+ }
76
+
77
+ /**
78
+ * Build the evidence file payload from eval results + env-supplied
79
+ * metadata. Returns the canonical record or null when prerequisites
80
+ * are missing.
81
+ *
82
+ * Reasons we return null (not throw): adopter scripts that pass
83
+ * `--evidence-mode local` opportunistically must not crash on a
84
+ * missing secret. We log a warning to stderr and continue.
85
+ *
86
+ * @param {{
87
+ * results: object,
88
+ * mode: string,
89
+ * diff?: string,
90
+ * secret?: string,
91
+ * model?: string,
92
+ * graderVersion?: string,
93
+ * promptSetVersion?: string,
94
+ * now?: Date,
95
+ * warn?: Function
96
+ * }} input
97
+ * @returns {object|null}
98
+ */
99
+ function buildEvidenceFromEval(input) {
100
+ if (!input || typeof input !== 'object') return null;
101
+ const warn = input.warn || ((msg) => process.stderr.write(`[hone eval] ${msg}\n`));
102
+ if (input.mode !== 'local') return null;
103
+
104
+ // Trim env-sourced secret to defeat trailing-newline mismatches between
105
+ // CLI signer and server verifier (e.g. Windows `setx` adds \r\n).
106
+ const rawSecret = input.secret || process.env[ENV_SECRET] || '';
107
+ const secret = String(rawSecret).trim();
108
+ if (secret.length === 0) {
109
+ warn(`evidence-mode=local but ${ENV_SECRET} is not set — skipping evidence file`);
110
+ return null;
111
+ }
112
+ const diff = typeof input.diff === 'string' ? input.diff : readDiffInput();
113
+ if (diff.length === 0) {
114
+ warn(`evidence-mode=local but no diff input (set ${ENV_DIFF_FILE}) — skipping evidence file`);
115
+ return null;
116
+ }
117
+ const model = input.model || process.env[ENV_MODEL] || '';
118
+ const graderVersion = input.graderVersion || process.env[ENV_GRADER_VERSION] || '';
119
+ const promptSetVersion = input.promptSetVersion || process.env[ENV_PROMPT_SET_VERSION] || '';
120
+ // HC-RC-001 pass-1 (CRIT-2): orgId is REQUIRED to bind evidence to a
121
+ // specific org. Cross-org replay is otherwise possible.
122
+ const orgId = input.orgId || process.env[ENV_ORG_ID] || '';
123
+ if (!model || !graderVersion || !promptSetVersion || !orgId) {
124
+ warn(
125
+ `evidence-mode=local missing metadata ` +
126
+ `(model=${ENV_MODEL}, graderVersion=${ENV_GRADER_VERSION}, ` +
127
+ `promptSetVersion=${ENV_PROMPT_SET_VERSION}, orgId=${ENV_ORG_ID}) — skipping evidence file`,
128
+ );
129
+ return null;
130
+ }
131
+ if (!input.results || typeof input.results !== 'object') {
132
+ warn('evidence-mode=local but eval results are missing — skipping evidence file');
133
+ return null;
134
+ }
135
+ // HC-RC-001 pass-1 (HIGH-3): bind which scenarios were actually run so an
136
+ // adopter cannot cherry-pick a passing subset and present as full evidence.
137
+ // Caller passes scenarioIds; if missing, derive from results.scenarios[].id
138
+ // (the eval-runner output shape).
139
+ const scenarioIds = Array.isArray(input.scenarioIds)
140
+ ? input.scenarioIds
141
+ : (Array.isArray(input.results.scenarios)
142
+ ? input.results.scenarios.map((s) => s && s.id).filter((id) => typeof id === 'string' && id.length > 0)
143
+ : []);
144
+ if (scenarioIds.length === 0) {
145
+ warn('evidence-mode=local but no scenario IDs available — skipping evidence file');
146
+ return null;
147
+ }
148
+ let scenarioSetHash;
149
+ try {
150
+ scenarioSetHash = computeScenarioSetHash(scenarioIds);
151
+ } catch (e) {
152
+ warn(`evidence-mode=local scenarioSetHash failed: ${e.message} — skipping evidence file`);
153
+ return null;
154
+ }
155
+ try {
156
+ return buildEvidence({
157
+ diff,
158
+ model,
159
+ graderVersion,
160
+ promptSetVersion,
161
+ orgId,
162
+ scenarioSetHash,
163
+ results: input.results,
164
+ secret,
165
+ now: input.now,
166
+ });
167
+ } catch (e) {
168
+ warn(`evidence-mode=local but buildEvidence failed: ${e.message} — skipping evidence file`);
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Write the evidence record to .hone/eval-evidence.json relative to a
175
+ * given root. Creates the .hone/ directory if missing.
176
+ *
177
+ * @param {object} record — output of buildEvidenceFromEval
178
+ * @param {{
179
+ * root?: string,
180
+ * outputPath?: string,
181
+ * mkdirSync?: Function,
182
+ * writeFileSync?: Function
183
+ * }=} deps
184
+ * @returns {string} the absolute path written
185
+ */
186
+ function writeEvidenceFile(record, deps = {}) {
187
+ if (!record || typeof record !== 'object') {
188
+ throw new Error('writeEvidenceFile: record must be an object');
189
+ }
190
+ const root = deps.root || process.cwd();
191
+ const rel = deps.outputPath || DEFAULT_OUTPUT_REL;
192
+ const abs = path.isAbsolute(rel) ? rel : path.join(root, rel);
193
+ const mkdirSync = deps.mkdirSync || fs.mkdirSync;
194
+ const writeFileSync = deps.writeFileSync || fs.writeFileSync;
195
+ mkdirSync(path.dirname(abs), { recursive: true });
196
+ writeFileSync(abs, JSON.stringify(record, null, 2) + '\n', 'utf8');
197
+ return abs;
198
+ }
199
+
200
+ module.exports = {
201
+ normalizeEvidenceMode,
202
+ readDiffInput,
203
+ buildEvidenceFromEval,
204
+ writeEvidenceFile,
205
+ DEFAULT_OUTPUT_REL,
206
+ ENV_SECRET,
207
+ ENV_GRADER_VERSION,
208
+ ENV_PROMPT_SET_VERSION,
209
+ ENV_MODEL,
210
+ ENV_DIFF_FILE,
211
+ ENV_ORG_ID,
212
+ SUPPORTED_MODES,
213
+ };
package/lib/git-env.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ /**
3
+ * git-env.js — SC-002-followup-2.
4
+ *
5
+ * Sanitized environment for `git` subprocesses that target an EXPLICIT
6
+ * repository (i.e. any call that passes `cwd`).
7
+ *
8
+ * ── The problem ──────────────────────────────────────────────────────────────
9
+ * Git exports a set of variables to the processes it spawns — most notably to
10
+ * hooks. `GIT_DIR` and `GIT_WORK_TREE` take precedence over the working
11
+ * directory, so a child `git` invocation is silently retargeted at whatever
12
+ * repository the ambient environment names, regardless of the `cwd` the caller
13
+ * asked for:
14
+ *
15
+ * GIT_DIR=/elsewhere/.git git -C /my/repo log -- file.js
16
+ * # reads /elsewhere, NOT /my/repo
17
+ *
18
+ * This is live for Hone: the hook templates in `cli/lib/hook-templates/`
19
+ * invoke `hone` from pre-commit, so CLI code genuinely runs with `GIT_DIR`
20
+ * set in adopter repos. The failure is quiet — most call sites wrap git in a
21
+ * try/catch that degrades to `null`/`false`, so a retargeted read surfaces as
22
+ * a plausible wrong answer rather than an error.
23
+ *
24
+ * ── The rule ─────────────────────────────────────────────────────────────────
25
+ * If a git call passes `cwd`, it means "this repository" and MUST also pass
26
+ * `env: gitEnv()`. If it deliberately means "whatever repo the caller is in"
27
+ * (no `cwd`), leave the ambient environment alone — under a hook, inheriting
28
+ * GIT_DIR is then the correct behavior.
29
+ *
30
+ * Pinned by tests/regression/SC-002-followup-2-git-env-sanitization.test.js,
31
+ * which walks the source for `cwd`-bearing git calls that lack `env:`.
32
+ */
33
+
34
+ /**
35
+ * Repository-selecting variables. These override `cwd`, so they must go.
36
+ * @see https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables
37
+ */
38
+ const REPO_SELECTING_VARS = [
39
+ 'GIT_DIR',
40
+ 'GIT_WORK_TREE',
41
+ 'GIT_INDEX_FILE',
42
+ 'GIT_COMMON_DIR',
43
+ 'GIT_OBJECT_DIRECTORY',
44
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
45
+ 'GIT_NAMESPACE',
46
+ 'GIT_PREFIX',
47
+ ];
48
+
49
+ /**
50
+ * Deliberately NOT stripped.
51
+ *
52
+ * GIT_CEILING_DIRECTORIES limits how far up the tree git will search for a
53
+ * repository — it constrains discovery rather than selecting a repo. Removing
54
+ * it is strictly worse on both counts: git may ascend past an operator's
55
+ * ceiling and report data from an unrelated parent repository (where it would
56
+ * previously have failed cleanly), and it reintroduces the slow-network-mount
57
+ * stall the variable exists to prevent.
58
+ */
59
+ const PRESERVED_VARS = ['GIT_CEILING_DIRECTORIES'];
60
+
61
+ /**
62
+ * Identity/date variables. A hook-invoked `hone` inherits the in-flight
63
+ * commit's dates; any child command that reads or writes history should not
64
+ * be steered by them.
65
+ */
66
+ const IDENTITY_VARS = [
67
+ 'GIT_AUTHOR_DATE',
68
+ 'GIT_COMMITTER_DATE',
69
+ ];
70
+
71
+ const STRIPPED_VARS = [...REPO_SELECTING_VARS, ...IDENTITY_VARS];
72
+
73
+ /**
74
+ * Build an env for a `git` subprocess that targets an explicit `cwd`.
75
+ *
76
+ * @param {NodeJS.ProcessEnv} [extra] Additional vars to set (merged last).
77
+ * @param {NodeJS.ProcessEnv} [base] Base env; defaults to process.env.
78
+ * Injectable so tests need not mutate the
79
+ * real process environment.
80
+ * @returns {NodeJS.ProcessEnv}
81
+ */
82
+ function gitEnv(extra, base) {
83
+ const env = { ...(base || process.env) };
84
+ for (const key of STRIPPED_VARS) delete env[key];
85
+ return extra ? { ...env, ...extra } : env;
86
+ }
87
+
88
+ module.exports = {
89
+ gitEnv,
90
+ STRIPPED_VARS,
91
+ REPO_SELECTING_VARS,
92
+ IDENTITY_VARS,
93
+ PRESERVED_VARS,
94
+ };
@@ -20,6 +20,7 @@
20
20
  * Issue: #143 (LC-004) — captured_at fallback to git log file mtime.
21
21
  */
22
22
  const { execSync } = require('node:child_process');
23
+ const { gitEnv } = require('./git-env');
23
24
  const fs = require('node:fs');
24
25
  const path = require('node:path');
25
26
 
@@ -40,7 +41,7 @@ function getLastCommitTimestampSeconds(repoRoot, relativePath) {
40
41
  try {
41
42
  const out = execSync(
42
43
  `git log -1 --format=%ct -- ${JSON.stringify(relativePath)}`,
43
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
44
+ { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
44
45
  ).trim();
45
46
  if (!out) return null;
46
47
  const seconds = Number(out);
@@ -75,7 +76,7 @@ function getFirstCommitDateMs(repoRoot, relativePath) {
75
76
  try {
76
77
  const out = execSync(
77
78
  `git log --diff-filter=A --follow --format=%at -- ${JSON.stringify(relativePath)}`,
78
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
79
+ { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
79
80
  ).trim();
80
81
  if (!out) return null;
81
82
  // Multiple ADD events possible (file renames); take the OLDEST (last line)
@@ -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,83 @@ 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
+
80
155
  module.exports = {
81
156
  readStoryClassifierConfig,
82
157
  KNOWN_THRESHOLD_KEYS,
158
+ readCIGateConfig,
159
+ normalizeCIGate,
160
+ CI_GATE_MODES,
161
+ KNOWN_CI_GATE_KEYS,
162
+ DEFAULT_CI_LOCAL_COMMAND,
83
163
  };
@@ -72,7 +72,18 @@ const CONDITIONAL_STEPS = new Set(STEPS.filter(s => s.conditional).map(s => s.ke
72
72
  // - `hone step-5b` (SA-002, this is what surfaced the bug — when run
73
73
  // against OptionsFlow's feature/E34-A-* branches, story id was
74
74
  // 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]+)/;
75
+ //
76
+ // H-029-followup-2 (2026-06-04): added `(?:-followup-[0-9]+[a-z]?(?=[-_]|$))?`
77
+ // to both alternatives so `hone status` on `feat/HC-052d-followup-1-...`
78
+ // shows the followup story id correctly instead of stopping at `HC-052d`.
79
+ // Mirrors H-029-followup-1's fix to the learnings-gate regex. `[a-z]?`
80
+ // covers the HC-019n family's parallel-iteration suffix shape (HC-019n-
81
+ // followup-13a etc.) — real prod data, not speculation. Trailing
82
+ // lookahead `(?=[-_]|$)` anchors the suffix so description tails don't
83
+ // get gobbled. The adopter-facing setup-ai-pipeline.sh default stays as
84
+ // the H-005-canonical `[A-Z]+[-_][A-Za-z0-9]+` — pinned by
85
+ // tests/regression/H-005-pizza-tracker-non-jira-regex.test.js.
86
+ const STORY_ID_PATTERN = /(E[0-9]+-[A-Z][A-Za-z0-9]*(?:-followup-[0-9]+[a-z]?(?=[-_]|$))?|[A-Z]+[-_][A-Za-z0-9]+(?:-followup-[0-9]+[a-z]?(?=[-_]|$))?)/;
76
87
 
77
88
  // ─────────────────────────────────────────────────────────────────────
78
89
  // 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
  *