@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 +975 -34
- package/lib/architect-config.js +121 -0
- package/lib/ci-gate-chooser.js +267 -0
- package/lib/doctor-admin-merge.js +3 -2
- package/lib/doctor-architecture.js +213 -0
- package/lib/eval-evidence.js +213 -0
- package/lib/git-env.js +94 -0
- package/lib/git-helpers.js +3 -2
- package/lib/pipeline-config.js +80 -0
- package/lib/pipeline-status.js +12 -1
- package/lib/refresh-knowledge.js +7 -0
- package/lib/release-review-cache.js +162 -0
- package/lib/release-review-config.js +4 -2
- package/lib/schedule-cron.js +141 -0
- package/lib/skill-eval-runner.js +667 -0
- package/lib/stack-paths.js +84 -6
- package/lib/story-classifier-extract.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/lib/git-helpers.js
CHANGED
|
@@ -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)
|
package/lib/pipeline-config.js
CHANGED
|
@@ -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
|
};
|
package/lib/pipeline-status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/lib/refresh-knowledge.js
CHANGED
|
@@ -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-
|
|
71
|
-
* char cap is
|
|
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
|
*
|