@hone-ai/cli 1.19.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,310 @@
1
+ 'use strict';
2
+ /**
3
+ * mcp-tools.js — HC-COMM-012 (A1 P1): the agent-eval/pipeline tool HANDLERS behind
4
+ * the Hone MCP server. Kept separate from the MCP protocol wiring (mcp-server.js)
5
+ * so the logic is unit-testable without the SDK or a real MCP client.
6
+ *
7
+ * Design (architect memo HC-COMM-012 §3): these handlers SHELL OUT to the installed
8
+ * `hone` CLI verbs — the one execution implementation — and parse their output. They
9
+ * re-implement nothing: `verify-patch` etc. still run in the CLI, honoring the
10
+ * server↔CLI boundary. Pure parsers are exported so the parsing is tested directly.
11
+ */
12
+ const { spawn } = require('node:child_process');
13
+ const fsSync = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+
17
+ // Resolve the CLI binary. Default `hone` (the installed bin); overridable for tests
18
+ // / non-global installs via HONE_CLI_BIN (e.g. a path to hone-cli.js run with node).
19
+ function honeBin() {
20
+ return process.env.HONE_CLI_BIN || 'hone';
21
+ }
22
+
23
+ // Resolve token + apiUrl the SAME way the CLI's getConfig does (env → ~/.honerc →
24
+ // default) — but WITHOUT exiting on a missing token (the MCP server must start and
25
+ // report the problem via a preflight / hone_doctor, not die). Editor-agnostic:
26
+ // works from any MCP client that passes HONE_TOKEN/HONE_API in the server env.
27
+ function readHoneConfig() {
28
+ let rc = {};
29
+ try { rc = JSON.parse(fsSync.readFileSync(path.join(os.homedir(), '.honerc'), 'utf8')) || {}; } catch { rc = {}; }
30
+ const token = process.env.HONE_TOKEN || rc.token || null;
31
+ const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
32
+ return { token, apiUrl, hasToken: !!token };
33
+ }
34
+
35
+ // Run a `hone` subcommand, capturing stdout/stderr/exit. Never throws — returns a
36
+ // structured result (mirrors the executor's never-throw contract).
37
+ function runHone(args, { cwd, timeoutMs = 120000 } = {}) {
38
+ return new Promise((resolve) => {
39
+ let child;
40
+ let stdout = '';
41
+ let stderr = '';
42
+ let timedOut = false;
43
+ try {
44
+ child = spawn(honeBin(), args, { cwd: cwd || process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
45
+ } catch (e) {
46
+ return resolve({ ok: false, exitCode: null, stdout: '', stderr: '', error: e.code === 'ENOENT' ? 'hone CLI not found on PATH' : e.message });
47
+ }
48
+ const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGTERM'); } catch { /* gone */ } }, timeoutMs);
49
+ child.stdout.on('data', (c) => { stdout += c.toString(); });
50
+ child.stderr.on('data', (c) => { stderr += c.toString(); });
51
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, exitCode: null, stdout, stderr, error: e.code === 'ENOENT' ? 'hone CLI not found on PATH' : e.message }); });
52
+ child.on('close', (code) => {
53
+ clearTimeout(timer);
54
+ if (timedOut) return resolve({ ok: false, exitCode: null, stdout, stderr, error: `timed out after ${timeoutMs}ms` });
55
+ resolve({ ok: code === 0, exitCode: code, stdout, stderr });
56
+ });
57
+ });
58
+ }
59
+
60
+ // ── Pure parsers (unit-tested directly) ──────────────────────────────────────
61
+
62
+ // `hone run-story <id>` prints "Workflow started: <uuid>" on success.
63
+ function parseRunStarted(stdout) {
64
+ const m = String(stdout).match(/Workflow started:\s*([0-9a-f-]{8,})/i);
65
+ return m ? m[1] : null;
66
+ }
67
+
68
+ // `--format json` output: the LAST JSON object/array in stdout (grounding logs may
69
+ // precede it). Returns the parsed value or null.
70
+ function parseLastJson(stdout) {
71
+ const s = String(stdout);
72
+ // find the last top-level '{' or '[' and try to parse from there to the end
73
+ for (const open of ['{', '[']) {
74
+ const i = s.lastIndexOf('\n' + open);
75
+ const start = i >= 0 ? i + 1 : (s.trimStart().startsWith(open) ? s.indexOf(open) : -1);
76
+ if (start < 0) continue;
77
+ try { return JSON.parse(s.slice(start).trim()); } catch { /* try the other bracket */ }
78
+ }
79
+ try { return JSON.parse(s.trim()); } catch { return null; }
80
+ }
81
+
82
+ // ── Tool handlers ────────────────────────────────────────────────────────────
83
+
84
+ // hone_run_story — start a run. Pure API path in the CLI (POST /orchestrate).
85
+ async function runStory({ storyId, mode = 'batch', cwd } = {}) {
86
+ if (storyId === undefined || storyId === null || String(storyId).trim() === '') {
87
+ return { isError: true, detail: 'storyId is required' };
88
+ }
89
+ const r = await runHone(['run-story', String(storyId), '--mode', String(mode)], { cwd });
90
+ if (r.error) return { isError: true, detail: r.error };
91
+ const runId = parseRunStarted(r.stdout);
92
+ return {
93
+ isError: !runId && !r.ok,
94
+ runId,
95
+ exitCode: r.exitCode,
96
+ detail: runId ? `run started: ${runId}` : (r.stderr || r.stdout || 'run-story produced no workflow id').slice(0, 500),
97
+ };
98
+ }
99
+
100
+ // hone_status — read run status (readOnly). GET /orchestrate/:id.
101
+ async function status({ runId, cwd } = {}) {
102
+ if (!runId) return { isError: true, detail: 'runId is required' };
103
+ const r = await runHone(['run-story', String(runId), '--status', '--format', 'json'], { cwd });
104
+ if (r.error) return { isError: true, detail: r.error };
105
+ const data = parseLastJson(r.stdout);
106
+ return { isError: !data, status: data, exitCode: r.exitCode, detail: data ? 'ok' : (r.stderr || 'could not parse status JSON').slice(0, 500) };
107
+ }
108
+
109
+ // hone_verify_patch — apply step_4 diff in a worktree + run make ci (REAL local
110
+ // execution, in the CLI), optionally report the verdict. Long-running → wide timeout.
111
+ async function verifyPatch({ runId, report = false, cwd } = {}) {
112
+ if (!runId) return { isError: true, detail: 'runId is required' };
113
+ const args = ['verify-patch', String(runId), '--format', 'json'];
114
+ if (report) args.push('--report');
115
+ const r = await runHone(args, { cwd, timeoutMs: 600000 });
116
+ if (r.error) return { isError: true, detail: r.error };
117
+ const data = parseLastJson(r.stdout);
118
+ const verdict = data && (data.verdict || data.result) ? (data.verdict || data.result) : (r.ok ? 'pass' : 'fail');
119
+ return { isError: false, verdict, passed: r.ok, exitCode: r.exitCode, report: data, detail: (data && data.detail) || (r.ok ? 'tests passed' : 'tests failed') };
120
+ }
121
+
122
+ // hone_check_patch — does step_4's diff apply cleanly? (throwaway git apply --check)
123
+ async function checkPatch({ runId, stepKey = 'step_4', cwd } = {}) {
124
+ if (!runId) return { isError: true, detail: 'runId is required' };
125
+ const r = await runHone(['check-patch', String(runId), '--step-key', String(stepKey), '--format', 'json'], { cwd });
126
+ if (r.error) return { isError: true, detail: r.error };
127
+ const data = parseLastJson(r.stdout);
128
+ return { isError: false, applies: r.ok, exitCode: r.exitCode, report: data, detail: (data && data.detail) || (r.ok ? 'diff applies cleanly' : 'diff does not apply') };
129
+ }
130
+
131
+ // hone_emit_pr — verified diff → branch → draft PR (staged: --push, --open-pr).
132
+ async function emitPr({ runId, push = false, openPr = false, branch, base, cwd } = {}) {
133
+ if (!runId) return { isError: true, detail: 'runId is required' };
134
+ const args = ['emit-pr', String(runId), '--format', 'json'];
135
+ if (openPr) args.push('--open-pr'); else if (push) args.push('--push');
136
+ if (branch) args.push('--branch', String(branch));
137
+ if (base) args.push('--base', String(base));
138
+ const r = await runHone(args, { cwd, timeoutMs: 180000 });
139
+ if (r.error) return { isError: true, detail: r.error };
140
+ const data = parseLastJson(r.stdout);
141
+ return { isError: !r.ok, exitCode: r.exitCode, report: data, detail: ((data && (data.prUrl || data.detail)) || (r.ok ? 'emitted' : r.stderr || 'emit failed')).slice(0, 500) };
142
+ }
143
+
144
+ // hone_verify_pr — real skill audit + real CI gate on an open PR, optional report.
145
+ async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
146
+ if (!pr) return { isError: true, detail: 'pr (number or branch) is required' };
147
+ const args = ['verify-pr', String(pr), '--format', 'json'];
148
+ if (report) { args.push('--report'); if (workflowId) args.push('--workflow-id', String(workflowId)); }
149
+ const r = await runHone(args, { cwd, timeoutMs: 600000 });
150
+ if (r.error) return { isError: true, detail: r.error };
151
+ const data = parseLastJson(r.stdout);
152
+ const verdict = data && data.verdict ? data.verdict : (r.ok ? 'pass' : 'fail');
153
+ return { isError: false, verdict, passed: r.ok, exitCode: r.exitCode, report: data, detail: (data && data.detail) || (r.ok ? 'CI green' : 'CI red') };
154
+ }
155
+
156
+ // hone_agent_eval — test the adopter's own agents (HC-COMM-011), optional report.
157
+ async function agentEval({ category, target, judge = false, provider, report = false, workflowId, cwd } = {}) {
158
+ const args = ['agent-eval', '--output', 'json'];
159
+ if (category) args.push('--category', String(category));
160
+ if (target) args.push('--target', String(target));
161
+ if (judge) args.push('--judge');
162
+ if (provider) args.push('--provider', String(provider));
163
+ if (report) { args.push('--report'); if (workflowId) args.push('--workflow-id', String(workflowId)); }
164
+ const r = await runHone(args, { cwd, timeoutMs: 300000 });
165
+ if (r.error) return { isError: true, detail: r.error };
166
+ const data = parseLastJson(r.stdout);
167
+ const passed = data && typeof data.passed === 'boolean' ? data.passed : r.ok;
168
+ return { isError: false, passed, exitCode: r.exitCode, report: data, detail: (data && Array.isArray(data.results)) ? `${data.results.filter((x) => x.passed).length}/${data.results.length} probes passed` : (r.ok ? 'passed' : 'failed') };
169
+ }
170
+
171
+ // hone_show_step — the LLM output of an orchestrator step (readOnly).
172
+ async function showStep({ runId, stepKey, cwd } = {}) {
173
+ if (!runId || !stepKey) return { isError: true, detail: 'runId and stepKey are required' };
174
+ const r = await runHone(['show', 'step', String(runId), String(stepKey)], { cwd });
175
+ if (r.error) return { isError: true, detail: r.error };
176
+ return { isError: !r.ok, exitCode: r.exitCode, output: r.stdout.slice(0, 8000), detail: r.ok ? 'ok' : (r.stderr || 'show step failed').slice(0, 500) };
177
+ }
178
+
179
+ // hone_approve — approve a paused human gate (run-story --approve).
180
+ async function approve({ runId, stepKey, cwd } = {}) {
181
+ if (!runId || !stepKey) return { isError: true, detail: 'runId and stepKey are required' };
182
+ const r = await runHone(['run-story', String(runId), '--approve', String(stepKey)], { cwd });
183
+ if (r.error) return { isError: true, detail: r.error };
184
+ return { isError: !r.ok, exitCode: r.exitCode, detail: (r.ok ? `approved ${stepKey}` : (r.stderr || r.stdout || 'approve failed')).slice(0, 500) };
185
+ }
186
+
187
+ // hone_derive — derive/refresh the adopter's domain skills (async, server-side).
188
+ async function derive({ cwd } = {}) {
189
+ const r = await runHone(['derive'], { cwd, timeoutMs: 600000 });
190
+ if (r.error) return { isError: true, detail: r.error };
191
+ return { isError: !r.ok, exitCode: r.exitCode, detail: (r.ok ? 'derive complete' : (r.stderr || r.stdout || 'derive failed')).slice(0, 500) };
192
+ }
193
+
194
+ // hone_sync — pull latest skills + agent prompts into the local repo (writes files).
195
+ async function sync({ skillsOnly = false, agentsOnly = false, cwd } = {}) {
196
+ const args = ['sync'];
197
+ if (skillsOnly) args.push('--skills-only');
198
+ if (agentsOnly) args.push('--agents-only');
199
+ const r = await runHone(args, { cwd, timeoutMs: 120000 });
200
+ if (r.error) return { isError: true, detail: r.error };
201
+ return { isError: !r.ok, exitCode: r.exitCode, detail: (r.ok ? 'sync complete' : (r.stderr || r.stdout || 'sync failed')).slice(0, 500) };
202
+ }
203
+
204
+ // hone_doctor — self-check the setup (readOnly): is a token present, and is the
205
+ // `hone` CLI installed + reachable? Invaluable across editors, where "is it wired?"
206
+ // is the #1 MCP setup question. No network call (fast, never hangs).
207
+ async function doctor({ cwd } = {}) {
208
+ const cfg = readHoneConfig();
209
+ const checks = [];
210
+ checks.push({ name: 'token', ok: cfg.hasToken, detail: cfg.hasToken ? 'token present (HONE_TOKEN / ~/.honerc)' : 'no token — run `hone init --token <t>` or set HONE_TOKEN' });
211
+ checks.push({ name: 'apiUrl', ok: true, detail: cfg.apiUrl });
212
+ const v = await runHone(['--version'], { cwd, timeoutMs: 15000 });
213
+ const cliOk = v.ok && /\d+\.\d+\.\d+/.test(v.stdout);
214
+ checks.push({ name: 'hone-cli', ok: cliOk, detail: cliOk ? `hone ${v.stdout.trim()}` : (v.error || 'hone CLI not found — `npm i -g @hone-ai/cli`') });
215
+ const allOk = checks.every((c) => c.ok);
216
+ return { isError: false, ok: allOk, checks, detail: allOk ? 'ready' : 'setup incomplete — see checks' };
217
+ }
218
+
219
+ // The tool registry the MCP server iterates. Kept declarative so mcp-server.js is thin.
220
+ const TOOLS = [
221
+ {
222
+ name: 'hone_run_story',
223
+ description: 'Start a Hone SDLC pipeline run for a story id or GitHub issue number. Runs server-side on the org\'s Anthropic key; returns a runId. Does not execute locally.',
224
+ readOnly: false,
225
+ inputSchema: { type: 'object', properties: { storyId: { type: 'string', description: 'roadmap story id or GitHub issue number' }, mode: { type: 'string', enum: ['interactive', 'batch'], default: 'batch' } }, required: ['storyId'] },
226
+ handler: runStory,
227
+ },
228
+ {
229
+ name: 'hone_status',
230
+ description: 'Read the status of a Hone run (steps + gate state). Read-only.',
231
+ readOnly: true,
232
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' } }, required: ['runId'] },
233
+ handler: status,
234
+ },
235
+ {
236
+ name: 'hone_verify_patch',
237
+ description: 'Verify a run\'s step_4 change LOCALLY: apply the diff in a throwaway worktree and run the tests (make ci), then optionally report the verdict. This RUNS YOUR TESTS and touches a temporary worktree.',
238
+ readOnly: false,
239
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' }, report: { type: 'boolean', default: false } }, required: ['runId'] },
240
+ handler: verifyPatch,
241
+ },
242
+ {
243
+ name: 'hone_check_patch',
244
+ description: 'Check whether a run\'s step_4 diff applies cleanly (throwaway `git apply --check`). Read-mostly; touches no tracked files.',
245
+ readOnly: false,
246
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' }, stepKey: { type: 'string', default: 'step_4' } }, required: ['runId'] },
247
+ handler: checkPatch,
248
+ },
249
+ {
250
+ name: 'hone_show_step',
251
+ description: 'Print the LLM output of an orchestrator step for a run (e.g. the plan, the tests, the diff). Read-only.',
252
+ readOnly: true,
253
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' }, stepKey: { type: 'string' } }, required: ['runId', 'stepKey'] },
254
+ handler: showStep,
255
+ },
256
+ {
257
+ name: 'hone_approve',
258
+ description: 'Approve a paused human gate on a run so it advances (e.g. stepKey "step_4").',
259
+ readOnly: false,
260
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' }, stepKey: { type: 'string' } }, required: ['runId', 'stepKey'] },
261
+ handler: approve,
262
+ },
263
+ {
264
+ name: 'hone_emit_pr',
265
+ description: 'Turn a verified run into a branch and (staged) a draft PR. By default a local dry run; set push=true to push, openPr=true to open a draft PR. WRITES a branch / PR when pushed.',
266
+ readOnly: false,
267
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' }, push: { type: 'boolean', default: false }, openPr: { type: 'boolean', default: false }, branch: { type: 'string' }, base: { type: 'string' } }, required: ['runId'] },
268
+ handler: emitPr,
269
+ },
270
+ {
271
+ name: 'hone_verify_pr',
272
+ description: 'Run the real skill audit + CI gate on an open PR (gh pr checks / make ci), optionally reporting the verdict. EXECUTES locally.',
273
+ readOnly: false,
274
+ inputSchema: { type: 'object', properties: { pr: { type: 'string', description: 'PR number or branch' }, report: { type: 'boolean', default: false }, workflowId: { type: 'string' } }, required: ['pr'] },
275
+ handler: verifyPr,
276
+ },
277
+ {
278
+ name: 'hone_agent_eval',
279
+ description: 'Test the adopter\'s own agents (adversarial, faithfulness, safety, boundary), optionally with the free NLI judge, optionally reporting the verdict. Deterministic + $0 by default. EXECUTES locally.',
280
+ readOnly: false,
281
+ inputSchema: { type: 'object', properties: { category: { type: 'string', enum: ['adversarial', 'faithfulness', 'safety', 'boundary'] }, target: { type: 'string' }, judge: { type: 'boolean', default: false }, provider: { type: 'string', enum: ['gh-models', 'claude'] }, report: { type: 'boolean', default: false }, workflowId: { type: 'string' } } },
282
+ handler: agentEval,
283
+ },
284
+ {
285
+ name: 'hone_derive',
286
+ description: 'Derive/refresh the adopter\'s domain skills from the codebase (async, server-side). Long-running.',
287
+ readOnly: false,
288
+ inputSchema: { type: 'object', properties: {} },
289
+ handler: derive,
290
+ },
291
+ {
292
+ name: 'hone_sync',
293
+ description: 'Pull the latest derived skills + agent prompts into the local repo. WRITES files under .claude/agents and .github/skills.',
294
+ readOnly: false,
295
+ inputSchema: { type: 'object', properties: { skillsOnly: { type: 'boolean', default: false }, agentsOnly: { type: 'boolean', default: false } } },
296
+ handler: sync,
297
+ },
298
+ {
299
+ name: 'hone_doctor',
300
+ description: 'Self-check the Hone setup (read-only): is a token configured, and is the `hone` CLI installed + reachable? Run this first if other tools fail.',
301
+ readOnly: true,
302
+ inputSchema: { type: 'object', properties: {} },
303
+ handler: doctor,
304
+ },
305
+ ];
306
+
307
+ module.exports = {
308
+ runStory, status, verifyPatch, checkPatch, showStep, approve, emitPr, verifyPr, agentEval, derive, sync, doctor,
309
+ runHone, parseRunStarted, parseLastJson, honeBin, readHoneConfig, TOOLS,
310
+ };
@@ -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 };
@@ -152,6 +152,213 @@ function readCIGateConfig(repoRoot) {
152
152
  return defaults;
153
153
  }
154
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
+
155
362
  module.exports = {
156
363
  readStoryClassifierConfig,
157
364
  KNOWN_THRESHOLD_KEYS,
@@ -160,4 +367,17 @@ module.exports = {
160
367
  CI_GATE_MODES,
161
368
  KNOWN_CI_GATE_KEYS,
162
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,
163
383
  };