@hone-ai/cli 1.38.1 → 1.40.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,354 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-348 — the status checks `hone setup` requires are READ from the repository's own workflows.
4
+ *
5
+ * Setup used to require `ai-code-review` on every repository. No workflow Hone serves reports a check
6
+ * by that name (the served reviewer job is `ai-review`, with no `name:`), and since HC-GF-337g that job
7
+ * runs only on `--ai-reviewer`. A required check that never reports leaves every PR waiting, and
8
+ * `enforce_admins: true` stops admins merging around it. So the name is now taken from the installed
9
+ * workflow, and required only when that workflow will run the job.
10
+ *
11
+ * GitHub names an Actions check after the job: its job-level `name:` if it has one, else its key.
12
+ * The parser below reads exactly that and nothing else. Two near-misses it must reject:
13
+ * - the workflow's top-level `name:` (column 0) — that names the workflow, not a check;
14
+ * - a step's `- name:` (deeper, behind a dash) — that names a step.
15
+ * A `name:` built from an expression (`${{ matrix.x }}`) is returned as written. GitHub reports the
16
+ * evaluated value, and a job with a `strategy:` reports one check per matrix leg (`ai-review (x)`),
17
+ * never its bare name. Neither can be REQUIRED (it would never report — the failure this module
18
+ * exists to prevent), so requiredChecksForSetup requires nothing for such a reviewer job. For the
19
+ * prune they are harmless: such a name never equals the legacy one.
20
+ *
21
+ * No YAML library: the CLI bundles none, and reviewer-optin.js reads this same file with a regex.
22
+ *
23
+ * Exports
24
+ * workflowCheckNames(text) → string[] the check names a workflow's jobs report
25
+ * reviewerCheckName(text) → string|null the name the `ai-review` job reports
26
+ * requiredChecksForSetup({...}) → string[] what setup requires: the reviewer check only when it runs
27
+ * reportedChecksInDir(dir) → string[]|null every name reported under dir; null = could not tell
28
+ */
29
+
30
+ const REVIEWER_JOB_KEY = 'ai-review';
31
+ const SECURITY_SCAN_JOB_KEY = 'security-scan';
32
+
33
+ function unquote(v) {
34
+ let s = String(v).replace(/\s+#.*$/, '').trim();
35
+ if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) {
36
+ s = s.slice(1, -1);
37
+ }
38
+ return s;
39
+ }
40
+
41
+ /** [{ key, name, matrix }] for each job in the workflow's top-level `jobs:` block; name is null without one. */
42
+ function workflowJobs(text) {
43
+ if (typeof text !== 'string' || !text) return [];
44
+ const lines = text.split(/\r?\n/);
45
+ const start = lines.findIndex((l) => /^jobs:\s*(#.*)?$/.test(l));
46
+ if (start < 0) return [];
47
+ const jobs = [];
48
+ let jobIndent = null; // the column job keys sit at
49
+ let propIndent = null; // the column the current job's own properties sit at
50
+ let current = null;
51
+ for (let i = start + 1; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ if (!line.trim() || /^\s*#/.test(line)) continue;
54
+ const indent = line.match(/^ */)[0].length;
55
+ if (indent === 0) break; // the next top-level key ends `jobs:`
56
+ if (jobIndent == null) jobIndent = indent;
57
+ if (indent <= jobIndent) {
58
+ const m = /^ *([A-Za-z0-9_-]+):\s*(#.*)?$/.exec(line);
59
+ current = m && indent === jobIndent ? { key: m[1], name: null, matrix: false, reusable: false } : null;
60
+ if (current) jobs.push(current);
61
+ propIndent = null;
62
+ continue;
63
+ }
64
+ if (!current) continue;
65
+ if (propIndent == null) propIndent = indent;
66
+ // Only the job's own properties count; anything deeper (steps, with:, env:) is not its name.
67
+ if (indent !== propIndent) continue;
68
+ if (/^ *strategy:/.test(line)) current.matrix = true;
69
+ // A job that calls a reusable workflow reports as `caller / callee`, never its own name.
70
+ if (/^ *uses:/.test(line)) current.reusable = true;
71
+ const n = current.name == null && /^ *name:\s*(.+)$/.exec(line);
72
+ if (n) current.name = unquote(n[1]) || null;
73
+ }
74
+ return jobs;
75
+ }
76
+
77
+ function workflowCheckNames(text) {
78
+ return workflowJobs(text).map((j) => j.name || j.key);
79
+ }
80
+
81
+ function reviewerCheckName(text) {
82
+ const job = workflowJobs(text).find((j) => j.key === REVIEWER_JOB_KEY);
83
+ return job ? (job.name || job.key) : null;
84
+ }
85
+
86
+ // ── on: — does a workflow run on pull requests to a branch? (HC-GF-354) ────────────────────────────
87
+
88
+ const PR_EVENTS = new Set(['pull_request', 'pull_request_target']);
89
+
90
+ /** `[a, b]` or a bare scalar → list of unquoted items; null when it is neither (an anchor, a placeholder). */
91
+ function flowList(v) {
92
+ const t = String(v).replace(/\s+#.*$/, '').trim();
93
+ if (!t) return null;
94
+ if (t.startsWith('[')) {
95
+ if (!t.endsWith(']')) return null;
96
+ const inner = t.slice(1, -1).trim();
97
+ return inner ? inner.split(',').map((x) => unquote(x)).filter(Boolean) : [];
98
+ }
99
+ if (/^[&*{|>]/.test(t)) return null;
100
+ return [unquote(t)];
101
+ }
102
+
103
+ /**
104
+ * The pull-request triggers of a workflow: [{ event, branches, branchesIgnore, types, unreadable }], each
105
+ * filter null when absent and 'unreadable' when present but not a list this parser can read
106
+ * (`{{BRANCHES}}` before setup substitutes it, an anchor, a nested mapping). `unreadable: true` marks a
107
+ * trigger with anything else that can skip a PR (`paths`, `paths-ignore`, an inline mapping). Reads
108
+ * `on: x`, `on: [x, y]` and the block form.
109
+ */
110
+ function workflowPullRequestTriggers(text) {
111
+ if (typeof text !== 'string' || !text) return [];
112
+ const lines = text.split(/\r?\n/);
113
+ const start = lines.findIndex((l) => /^["']?on["']?:/.test(l));
114
+ if (start < 0) return [];
115
+ const inline = lines[start].replace(/^["']?on["']?:/, '').replace(/\s+#.*$/, '').trim();
116
+ if (inline) {
117
+ const events = flowList(inline) || [];
118
+ return events.filter((e) => PR_EVENTS.has(e)).map((event) => ({ event, branches: null, branchesIgnore: null, types: null, unreadable: false }));
119
+ }
120
+ const out = [];
121
+ let eventIndent = null;
122
+ let current = null; // the PR trigger being read
123
+ let pending = null; // { key, indent } of a `branches:` whose list is on the lines below
124
+ for (let i = start + 1; i < lines.length; i++) {
125
+ const line = lines[i];
126
+ if (!line.trim() || /^\s*#/.test(line)) continue;
127
+ const indent = line.match(/^ */)[0].length;
128
+ if (indent === 0) break;
129
+ if (eventIndent == null) eventIndent = indent;
130
+ if (pending && indent >= pending.indent && /^ *- /.test(line)) {
131
+ const item = unquote(line.replace(/^ *- /, ''));
132
+ if (current[pending.key] === 'unreadable') continue;
133
+ if (!item || /^[&*{]/.test(item)) current[pending.key] = 'unreadable';
134
+ else current[pending.key].push(item);
135
+ continue;
136
+ }
137
+ if (pending && current[pending.key] && current[pending.key].length === 0) current[pending.key] = 'unreadable';
138
+ pending = null;
139
+ if (indent === eventIndent) {
140
+ const m = /^ *([A-Za-z_]+):\s*(.*)$/.exec(line);
141
+ current = m && PR_EVENTS.has(m[1]) ? { event: m[1], branches: null, branchesIgnore: null, types: null, unreadable: false } : null;
142
+ if (current) {
143
+ // `pull_request: { branches: [x] }`, an alias, any inline value: a filter not read here.
144
+ const v = m[2].replace(/\s+#.*$/, '').trim();
145
+ if (v && v !== 'null' && v !== '~') current.unreadable = true;
146
+ out.push(current);
147
+ }
148
+ continue;
149
+ }
150
+ if (!current) continue;
151
+ // A path filter skips some pull requests to the branch, so a check from it is not sure to report.
152
+ if (/^ *(paths|paths-ignore):/.test(line)) { current.unreadable = true; continue; }
153
+ const f = /^ *(branches|branches-ignore|types):\s*(.*)$/.exec(line);
154
+ if (!f) continue;
155
+ const key = { branches: 'branches', 'branches-ignore': 'branchesIgnore', types: 'types' }[f[1]];
156
+ const rest = f[2].replace(/\s+#.*$/, '').trim();
157
+ if (!rest) { current[key] = []; pending = { key, indent }; continue; }
158
+ current[key] = flowList(rest) || 'unreadable';
159
+ }
160
+ if (pending && current && current[pending.key] && current[pending.key].length === 0) current[pending.key] = 'unreadable';
161
+ return out;
162
+ }
163
+
164
+ /** GitHub's branch glob, as far as it is read here: `**` anything, `*` anything but `/`. 'unreadable' otherwise. */
165
+ function branchMatchesPattern(pattern, branch) {
166
+ if (typeof pattern !== 'string' || !pattern || /[!?[\]+]/.test(pattern) || pattern.startsWith('/') || pattern.includes('{{')) {
167
+ return 'unreadable';
168
+ }
169
+ const re = pattern.replace(/[.^$()|\\{}]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*');
170
+ return new RegExp(`^${re}$`).test(branch);
171
+ }
172
+
173
+ function filterMatches(patterns, branch) {
174
+ const r = patterns.map((p) => branchMatchesPattern(p, branch));
175
+ if (r.includes('unreadable')) return 'unreadable';
176
+ return r.includes(true);
177
+ }
178
+
179
+ /**
180
+ * True only when the workflow is SURE to run on a pull request to `branch`. Anything it cannot read
181
+ * answers false: requiring nothing is safe, requiring a check that never reports holds every PR.
182
+ */
183
+ function workflowTriggersPullRequestTo(text, branch) {
184
+ if (typeof branch !== 'string' || !branch) return false;
185
+ return workflowPullRequestTriggers(text).some((t) => {
186
+ if (t.unreadable || t.branches === 'unreadable' || t.branchesIgnore === 'unreadable' || t.types === 'unreadable') return false;
187
+ // Only a trigger that fires when a PR is opened AND when it is pushed to reports on every PR.
188
+ if (Array.isArray(t.types) && !(t.types.includes('opened') && t.types.includes('synchronize'))) return false;
189
+ if (t.branches && t.branchesIgnore) return false; // GitHub rejects a workflow that sets both
190
+ if (t.branches) return filterMatches(t.branches, branch) === true;
191
+ if (t.branchesIgnore) return filterMatches(t.branchesIgnore, branch) === false;
192
+ return true;
193
+ });
194
+ }
195
+
196
+ // ── ci.required_checks in .pipeline-config.yml (HC-GF-352) ───────────────────────────────────────
197
+
198
+ /** The names listed under `ci: required_checks:`, flow or block form; [] when absent. */
199
+ function configuredRequiredChecks(configText) {
200
+ if (typeof configText !== 'string' || !configText) return [];
201
+ const lines = configText.split(/\r?\n/);
202
+ const start = lines.findIndex((l) => /^ci:\s*(#.*)?$/.test(l));
203
+ if (start < 0) return [];
204
+ let keyIndent = null;
205
+ for (let i = start + 1; i < lines.length; i++) {
206
+ const line = lines[i];
207
+ if (!line.trim() || /^\s*#/.test(line)) continue;
208
+ const indent = line.match(/^ */)[0].length;
209
+ if (indent === 0) break;
210
+ if (keyIndent == null) keyIndent = indent;
211
+ if (indent !== keyIndent) continue;
212
+ const m = /^ *required_checks:\s*(.*)$/.exec(line);
213
+ if (!m) continue;
214
+ const rest = m[1].replace(/\s+#.*$/, '').trim();
215
+ if (rest) return flowList(rest) || [];
216
+ const items = [];
217
+ for (let j = i + 1; j < lines.length; j++) {
218
+ const l = lines[j];
219
+ if (!l.trim() || /^\s*#/.test(l)) continue;
220
+ if (!/^ *- /.test(l) || l.match(/^ */)[0].length < keyIndent) break;
221
+ const v = unquote(l.replace(/^ *- /, ''));
222
+ if (v) items.push(v);
223
+ }
224
+ return items;
225
+ }
226
+ return [];
227
+ }
228
+
229
+ // ── what setup requires ──────────────────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * [{ key, name }] for the jobs that report under ONE fixed name. Not a matrix job (one check per leg,
233
+ * `test (18)`), not a reusable-workflow call (`caller / callee`), not a name built from an expression.
234
+ * Requiring nothing is always safe; requiring a name that never reports holds every PR.
235
+ */
236
+ function fixedJobs(text) {
237
+ return workflowJobs(text)
238
+ .filter((j) => !j.matrix && !j.reusable && !(j.name || j.key).includes('${{'))
239
+ .map((j) => ({ key: j.key, name: j.name || j.key }));
240
+ }
241
+
242
+ /** The fixed check name a served job reports, or null. */
243
+ function fixedJobCheck(text, key) {
244
+ const job = fixedJobs(text).find((j) => j.key === key);
245
+ return job ? job.name : null;
246
+ }
247
+
248
+ /**
249
+ * What `hone setup` requires on the protected branch, and every candidate it left out with the reason.
250
+ *
251
+ * Candidates: the served reviewer check (only when the installed workflow runs that job — HC-GF-348),
252
+ * the served `security-scan` check (always — HC-GF-354), and the names in `ci.required_checks`
253
+ * (HC-GF-352). Each is required only if a workflow reports it under a fixed name and is sure to run on
254
+ * pull requests to `branch`. `branch` null (not known yet): no trigger test, nothing dropped for it.
255
+ *
256
+ * @returns {{ required: string[], dropped: Array<{ name: string, reason: string }> }}
257
+ */
258
+ function requiredChecksPlan({
259
+ reviewerState, reviewerWorkflowText, workflowsDir, configText, branch = null,
260
+ fsImpl = require('fs'), pathImpl = require('path'),
261
+ } = {}) {
262
+ const required = [];
263
+ const dropped = [];
264
+ const notOnBranch = `its workflow does not run on pull requests to \`${branch}\``;
265
+ const served = (name) => {
266
+ if (!name || required.includes(name)) return;
267
+ if (branch != null && !workflowTriggersPullRequestTo(reviewerWorkflowText, branch)) dropped.push({ name, reason: notOnBranch });
268
+ else required.push(name);
269
+ };
270
+ if (reviewerState === 'on' || reviewerState === 'legacy') served(fixedJobCheck(reviewerWorkflowText, REVIEWER_JOB_KEY));
271
+ served(fixedJobCheck(reviewerWorkflowText, SECURITY_SCAN_JOB_KEY));
272
+
273
+ const configured = configuredRequiredChecks(configText)
274
+ .filter((n) => !required.includes(n) && !dropped.some((d) => d.name === n));
275
+ if (configured.length) {
276
+ const files = reportedChecksByFileInDir(workflowsDir, { fsImpl, pathImpl });
277
+ for (const name of configured) {
278
+ if (files == null) { dropped.push({ name, reason: "the repository's workflows could not all be read" }); continue; }
279
+ const reporting = files.filter((f) => f.names.includes(name));
280
+ if (!reporting.length) { dropped.push({ name, reason: 'not reported by any workflow' }); continue; }
281
+ const fixed = reporting.filter((f) => fixedJobs(f.text).some((j) => j.name === name));
282
+ if (!fixed.length) {
283
+ dropped.push({ name, reason: 'its job reports per matrix leg or as a reusable-workflow call, never under this name' });
284
+ continue;
285
+ }
286
+ if (branch != null && !fixed.some((f) => workflowTriggersPullRequestTo(f.text, branch))) {
287
+ dropped.push({ name, reason: notOnBranch });
288
+ continue;
289
+ }
290
+ required.push(name);
291
+ }
292
+ }
293
+ return { required, dropped };
294
+ }
295
+
296
+ /**
297
+ * @param {{ reviewerState: 'on'|'off'|'legacy'|'absent', reviewerWorkflowText: string|null }} args
298
+ * reviewerState is workflowReviewerState() (cli/lib/reviewer-optin.js) of the installed ai-review.yml.
299
+ */
300
+ function requiredChecksForSetup(args = {}) {
301
+ return requiredChecksPlan(args).required;
302
+ }
303
+
304
+ /**
305
+ * [{ file, text, names }] for each workflow in `dir`. `[]` when there is no such directory. `null` when a
306
+ * workflow file exists but could not be read.
307
+ */
308
+ function reportedChecksByFileInDir(dir, { fsImpl = require('fs'), pathImpl = require('path') } = {}) {
309
+ if (typeof dir !== 'string' || !dir) return [];
310
+ let entries;
311
+ try {
312
+ entries = fsImpl.readdirSync(dir);
313
+ } catch (e) {
314
+ return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR') ? [] : null;
315
+ }
316
+ const out = [];
317
+ for (const f of entries) {
318
+ if (!/\.ya?ml$/i.test(f)) continue;
319
+ let text;
320
+ try {
321
+ text = fsImpl.readFileSync(pathImpl.join(dir, f), 'utf8');
322
+ } catch {
323
+ return null;
324
+ }
325
+ out.push({ file: f, text, names: workflowCheckNames(text) });
326
+ }
327
+ return out;
328
+ }
329
+
330
+ /**
331
+ * Every check name the workflows in `dir` report. `[]` when there is no such directory (nothing reports
332
+ * anything). `null` when a workflow file exists but could not be read: the caller cannot tell what is
333
+ * reported, so it must not remove a required check on the strength of this answer.
334
+ */
335
+ function reportedChecksInDir(dir, opts = {}) {
336
+ const files = reportedChecksByFileInDir(dir, opts);
337
+ if (files == null) return null;
338
+ return [...new Set(files.flatMap((f) => f.names))];
339
+ }
340
+
341
+ module.exports = {
342
+ workflowCheckNames,
343
+ reviewerCheckName,
344
+ requiredChecksForSetup,
345
+ requiredChecksPlan,
346
+ reportedChecksInDir,
347
+ reportedChecksByFileInDir,
348
+ workflowPullRequestTriggers,
349
+ workflowTriggersPullRequestTo,
350
+ branchMatchesPattern,
351
+ configuredRequiredChecks,
352
+ REVIEWER_JOB_KEY,
353
+ SECURITY_SCAN_JOB_KEY,
354
+ };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-337g — the AI code reviewer is OPT-IN.
4
+ *
5
+ * `hone setup` used to install it in every adopter repository, where it runs in the adopter's CI
6
+ * on the adopter's own ANTHROPIC_API_KEY — so a repository that never chose it, and never set the
7
+ * key, got a red check on every PR. Now its scripts install only on `--ai-reviewer`, and the
8
+ * ai-review job runs only then. .github/workflows/ai-review.yml itself installs either way: its other
9
+ * gates (secret scan, tracker, metrics check, E2E healer) are not the reviewer's to take with it.
10
+ *
11
+ * Commander keeps the default `undefined` only because `--ai-reviewer` is declared BEFORE
12
+ * `--no-ai-reviewer` ("If you define --foo first, adding --no-foo does not change the default
13
+ * value"). Declared the other way round the default becomes `true`, and the always-install comes
14
+ * back with nothing in the diff to show it — which is why the decision is `=== true` here, and why
15
+ * tests/regression/hc-gf-337g-reviewer-optin-default.test.js runs the real CLI with no flag.
16
+ *
17
+ * Pure: no I/O, so it is unit-testable without the CLI.
18
+ */
19
+
20
+ /** True only for an explicit `--ai-reviewer`. No flag, `--no-ai-reviewer`, or no opts at all: false. */
21
+ function shouldInstallReviewer(opts) {
22
+ return Boolean(opts) && opts.aiReviewer === true;
23
+ }
24
+
25
+ /**
26
+ * What an installed .github/workflows/ai-review.yml says about the reviewer job (HC-GF-337g):
27
+ * 'on' / 'off' (setup's AI_REVIEWER stamp), 'legacy' (written before the stamp existed — it runs the
28
+ * reviewer on every PR, red without a key) or 'absent' (no file; pass null).
29
+ */
30
+ function workflowReviewerState(text) {
31
+ if (text == null) return 'absent';
32
+ const m = /AI_REVIEWER: "([^"]*)"/.exec(text);
33
+ if (!m) return 'legacy';
34
+ return m[1] === 'true' ? 'on' : 'off';
35
+ }
36
+
37
+ /**
38
+ * What the operator is told once the setup script has run.
39
+ *
40
+ * @param {boolean} installed the reviewer script is now in the repository (with dryRun: would be)
41
+ * @param {{ requested?: boolean, alreadyPresent?: boolean, dryRun?: boolean, workflow?: string }} [ctx]
42
+ * requested: `--ai-reviewer` was passed (so `installed: false` means the download failed)
43
+ * alreadyPresent: the repository already has .github/workflows/ai-review.yml from an earlier setup
44
+ * dryRun: `--dry-run` — nothing was written, so nothing may be reported as installed
45
+ * workflow: workflowReviewerState() of the repository's ai-review.yml after the script ran (before,
46
+ * on a dry run). An existing workflow is never regenerated, so an install is claimed only
47
+ * when the workflow actually runs the job.
48
+ * @returns {string} one or more lines, no trailing newline
49
+ */
50
+ function reviewerNotice(installed, { requested = false, alreadyPresent = false, dryRun = false, workflow } = {}) {
51
+ const LEGACY = [
52
+ ' .github/workflows/ai-review.yml predates the opt-in and was kept: it runs the reviewer on every PR,',
53
+ ' red without ANTHROPIC_API_KEY. Delete it and re-run `hone setup --ai-reviewer` to get the version that',
54
+ ' skips without a key.',
55
+ ];
56
+ // Unknown workflow state (a caller that did not read it): the reviewer side assumes the job is on.
57
+ const wf = workflow || 'on';
58
+ if (installed && dryRun) {
59
+ if (wf === 'legacy') {
60
+ return [' DRY-RUN: would install the AI reviewer scripts, but not the job that skips without a key:', ...LEGACY].join('\n');
61
+ }
62
+ return [
63
+ ' DRY-RUN: would install the AI code reviewer (the ai-review job of .github/workflows/ai-review.yml).',
64
+ ' It runs in your CI on YOUR OWN key: add ANTHROPIC_API_KEY as a repository secret.',
65
+ ].join('\n');
66
+ }
67
+ if (installed && wf === 'legacy') {
68
+ return [' ⚠ AI reviewer scripts installed, but not the job that skips without a key:', ...LEGACY].join('\n');
69
+ }
70
+ if (installed && wf !== 'on') {
71
+ return [
72
+ ` ⚠ AI reviewer scripts installed, but .github/workflows/ai-review.yml ${wf === 'off' ? 'has the reviewer stamped off' : 'is missing'},`,
73
+ ' so the ai-review job will not run. Delete .github/workflows/ai-review.yml (if present) and re-run',
74
+ ' `hone setup --ai-reviewer`.',
75
+ ].join('\n');
76
+ }
77
+ if (installed) {
78
+ return [
79
+ ' ✓ AI code reviewer installed (the ai-review job of .github/workflows/ai-review.yml).',
80
+ ' It runs in your CI on YOUR OWN key: add ANTHROPIC_API_KEY as a repository secret.',
81
+ ' Without it the job is skipped (grey, never blocks a merge). The H-019 learnings gate runs inside it.',
82
+ ].join('\n');
83
+ }
84
+ const lines = requested
85
+ ? [' ⚠ AI code reviewer requested (--ai-reviewer) but NOT installed — its download failed or setup did not write it. Re-run setup to retry.']
86
+ : [' ℹ AI code reviewer not installed (opt-in: hone setup --ai-reviewer).'];
87
+ // What is said about the workflow's OTHER gates is read from the workflow too (re-review step_5): it is
88
+ // never claimed for a workflow that did not arrive, predates the opt-in, or still has the job on.
89
+ if (workflow === 'absent') {
90
+ lines.push(
91
+ ' ⚠ .github/workflows/ai-review.yml is not installed either (its download failed), so its secret scan,',
92
+ ' tracker and metrics check are missing too. Re-run setup to retry.',
93
+ );
94
+ } else if (workflow === 'legacy') {
95
+ lines.push(' .github/workflows/ai-review.yml predates the opt-in and was kept: delete it and re-run setup to get the version whose ai-review job runs only when chosen.');
96
+ } else if (workflow === 'on') {
97
+ lines.push(' .github/workflows/ai-review.yml was kept with the ai-review job switched on by an earlier setup; setup never switches it off.');
98
+ } else {
99
+ lines.push(' .github/workflows/ai-review.yml is installed either way: its secret scan, tracker and metrics check run; its ai-review job is skipped.');
100
+ }
101
+ lines.push(
102
+ ' It needs your own ANTHROPIC_API_KEY repository secret, and it carries the H-019 learnings gate:',
103
+ ' without the reviewer, the learnings gate is not installed either.',
104
+ );
105
+ if (alreadyPresent) {
106
+ lines.push(
107
+ ' ⚠ .github/scripts/ai-reviewer.js is still here from an earlier setup and was left in place, and an',
108
+ ' existing .github/workflows/ai-review.yml is never regenerated. To opt out, delete both files and re-run',
109
+ ' setup; to keep the reviewer, re-run with --ai-reviewer.',
110
+ );
111
+ }
112
+ return lines.join('\n');
113
+ }
114
+
115
+ module.exports = { shouldInstallReviewer, reviewerNotice, workflowReviewerState };
@@ -438,6 +438,10 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
438
438
  // JSON-lines repo).
439
439
  const bundle = { files: 0, chars: 0 };
440
440
  const includePaths = opts.includePaths || [];
441
+ // HC-GF-351: what the operator named, recorded on the run so follow-batch can carry it into a re-serve. Never
442
+ // inferred: the story's own paths and the defaults are re-derived whenever a story is resolved again.
443
+ const operatorPaths = parseIncludePaths(includePaths);
444
+ if (operatorPaths.length) config.include_paths = operatorPaths;
441
445
  const defaultIncludePaths = repoCtx.defaultIncludePaths || [];
442
446
  if (rawText || parseIncludePaths(includePaths).length > 0 || defaultIncludePaths.length > 0) {
443
447
  try {
@@ -576,6 +580,23 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
576
580
  pre.push(warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js'));
577
581
  }
578
582
 
583
+ // ── Call sites (HC-GF-217) ────────────────────────────────────────────────────────────────
584
+ // The planner is told to enumerate every call site of a shared function it changes, and a server-executed
585
+ // agent has no checkout to search (cwd is a temp dir, deliberately). This CLI has the checkout: it searches for
586
+ // the compound identifiers the story puts in backticks and sends the result for the server to render. It runs
587
+ // whatever the classifier axes turn out to be, because the shared-chokepoint axis is harvested from step_0's
588
+ // output, after this bundle has been sent. Fail-soft: a search that cannot run costs the planner its
589
+ // enumeration (it then says so and pauses), never the queueing of the story.
590
+ if (rawText && typeof io.callSites === 'function') {
591
+ try {
592
+ const found = await io.callSites(rawText);
593
+ if (found && found.callSites) config.call_sites = found.callSites;
594
+ for (const n of (found && found.notes) || []) pre.push(n);
595
+ } catch (e) {
596
+ pre.push(warn(` ⚠ call-site search failed (non-fatal): ${firstLine(e)} — the planner will have no call-site enumeration for this story`));
597
+ }
598
+ }
599
+
579
600
  // ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
580
601
  // Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
581
602
  // come from the groomer at step_0 and are harvested server-side. Fail-open.
@@ -14,13 +14,15 @@
14
14
  const SECRET_PATTERNS = [
15
15
  {
16
16
  id: 'hardcoded-secret-apikey',
17
- pattern: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*["']([A-Za-z0-9/+=]{20,})["']/i,
17
+ // HC-GF-364: `["']?` after the name reads a quoted key — JSON, a dict literal — as the served
18
+ // workflow's scan does since HC-GF-362.
19
+ pattern: /(?:api[_-]?key|apikey|api[_-]?secret)["']?\s*[:=]\s*["']([A-Za-z0-9/+=]{20,})["']/i,
18
20
  severity: 'HIGH',
19
21
  message: 'Hardcoded API key detected. Use environment variables instead.',
20
22
  },
21
23
  {
22
24
  id: 'hardcoded-secret-password',
23
- pattern: /(?:password|passwd|pwd|secret|token)\s*[:=]\s*["']([A-Za-z0-9@#$%^&*!]{16,})["']/i,
25
+ pattern: /(?:password|passwd|pwd|secret|token)["']?\s*[:=]\s*["']([A-Za-z0-9@#$%^&*!]{16,})["']/i,
24
26
  severity: 'HIGH',
25
27
  message: 'Hardcoded password/secret detected. Use environment variables or a vault.',
26
28
  },
@@ -126,8 +128,9 @@ function scanFiles(opts = {}) {
126
128
 
127
129
  // Skip env var references (not hardcoded)
128
130
  if (/process\.env\.|os\.environ|System\.getenv|ENV\[/i.test(line)) continue;
129
- // Skip comments
130
- if (/^\s*(\/\/|#|--|\/\*|\*)/.test(line)) continue;
131
+ // Skip comments. `--` is a SQL comment; `-----BEGIN … PRIVATE KEY-----` is not (HC-GF-362: it was
132
+ // skipped as one, so the private-key rule never fired on a key at the start of a line).
133
+ if (/^\s*(\/\/|#|--(?!-)|\/\*|\*)/.test(line)) continue;
131
134
 
132
135
  for (const rule of rules) {
133
136
  if (rule.pattern.test(line)) {
package/lib/story-line.js CHANGED
@@ -2,9 +2,15 @@
2
2
  /**
3
3
  * story-line.js — one line of a `hone queue-stories --file` list.
4
4
  *
5
- * `STORY-ID` or `STORY-ID depends:DEP1,DEP2`. The `depends:` token is case-sensitive and must come
6
- * after the storyId; multiple deps are comma-separated and whitespace tolerant. A line with no
7
- * `depends:` yields no `dependsOn` at all — the server validates absence differently from empty.
5
+ * `STORY-ID`, optionally followed by `depends:DEP1,DEP2` and/or `paths:a.js,b.js`, in either order. Both
6
+ * tokens are case-sensitive, must come after the storyId, and may each appear once; their lists are
7
+ * comma-separated and whitespace tolerant. A line with no `depends:` yields no `dependsOn` at all — the server
8
+ * validates absence differently from empty — and a line with no `paths:` yields no `paths`.
9
+ *
10
+ * `paths:` (HC-GF-351) is `run-story --include-paths` for one story of a batch: the files the bundler must put
11
+ * in that story's context. A batch cannot carry the flag, and `run-story` runs carry no batch id, so
12
+ * `follow-batch` cannot follow them. The entries are checked where `--include-paths` entries are, by the
13
+ * bundler (cli/lib/run-context.js), not here: this module only splits the line.
8
14
  *
9
15
  * Extracted by HC-GF-140 stage 2. The regex was pinned by its SOURCE TEXT
10
16
  * (`assert.match(cliSrc, /\^\(\\S\+\)\\s\+depends:\(\\S\+\)\\s\*\$/)`), which is the worst shape a
@@ -19,22 +25,52 @@ function isStoryLine(line) {
19
25
  return Boolean(t) && !t.startsWith('#');
20
26
  }
21
27
 
28
+ const csv = (s) => s.split(',').map((x) => x.trim()).filter(Boolean);
29
+ const unique = (list) => list.filter((x, i) => list.indexOf(x) === i);
30
+
22
31
  /**
23
32
  * @param {string} line
24
- * @returns {{storyId: string, dependsOn?: string[]}|{error: string}}
33
+ * @returns {{storyId: string, dependsOn?: string[], paths?: string[]}|{error: string}}
25
34
  * `error` for a line the caller must reject — a storyId followed by anything unrecognised.
26
- * Silently treating `STORY-A something` as `STORY-A` loses the operator's intent.
35
+ * Silently treating `STORY-A something` as `STORY-A` loses the operator's intent. So does a
36
+ * `paths:` that names no path: the operator meant to bundle something.
27
37
  */
28
38
  function parseStoryLine(line) {
29
39
  const t = String(line).trim();
30
- const m = t.match(/^(\S+)\s+depends:(\S+)\s*$/);
31
- if (m) {
32
- return { storyId: m[1], dependsOn: m[2].split(',').map(s => s.trim()).filter(Boolean) };
40
+ const bad = { error: `Malformed line in --file: "${t}"` };
41
+ const [storyId, ...tokens] = t.split(/\s+/);
42
+ const out = { storyId };
43
+ for (const tok of tokens) {
44
+ if (tok.startsWith('depends:') && tok.length > 'depends:'.length && !('dependsOn' in out)) {
45
+ out.dependsOn = csv(tok.slice('depends:'.length));
46
+ } else if (tok.startsWith('paths:') && !('paths' in out)) {
47
+ const paths = unique(csv(tok.slice('paths:'.length)));
48
+ if (paths.length === 0) return bad;
49
+ out.paths = paths;
50
+ } else {
51
+ return bad;
52
+ }
33
53
  }
34
- if (/\s/.test(t)) {
35
- return { error: `Malformed line in --file: "${t}"` };
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * The line `parseStoryLine` reads back as the same story — for a caller that writes a stories file
59
+ * (follow-batch's re-serve). Throws on a value the line cannot carry, rather than writing a line that parses as
60
+ * something else: whitespace splits a token, and a comma splits a list.
61
+ * @param {{storyId: string, dependsOn?: string[], paths?: string[]}} story
62
+ */
63
+ function formatStoryLine({ storyId, dependsOn, paths } = {}) {
64
+ const carries = (v) => typeof v === 'string' && v !== '' && !/[\s,]/.test(v);
65
+ if (!carries(storyId)) throw new TypeError(`formatStoryLine: ${JSON.stringify(storyId)} is not a story id a line can carry`);
66
+ const parts = [storyId];
67
+ for (const [token, list] of [['depends', dependsOn], ['paths', paths]]) {
68
+ if (!Array.isArray(list) || list.length === 0) continue;
69
+ const bad = list.find((v) => !carries(v));
70
+ if (bad !== undefined) throw new TypeError(`formatStoryLine: ${JSON.stringify(bad)} cannot be written in ${token}:`);
71
+ parts.push(`${token}:${list.join(',')}`);
36
72
  }
37
- return { storyId: t };
73
+ return parts.join(' ');
38
74
  }
39
75
 
40
- module.exports = { parseStoryLine, isStoryLine };
76
+ module.exports = { parseStoryLine, isStoryLine, formatStoryLine };